1. 一个接口被打挂之后:为什么单点优化救不了高并发
先说个我亲历的场景。有一年做活动大促,零点一过,商品详情接口的QPS直接冲上了三万,数据库连接池瞬间被打满,紧接着缓存里的热点key开始批量失效,请求穿透到MySQL,主库的CPU飙升到100%,整个商品服务全部超时。当时第一反应是加机器、加缓存、加连接池,结果加了四台应用服务器,问题只是往后推了五分钟,数据库一垮,全链路照样瘫。
那次事故之后我才真正想明白一件事:高并发从来不是某一个组件能扛下来的事情,而是缓存、队列、数据库这三层各司其职、按顺序接力的结果。缓存负责挡掉绝大部分重复读请求,队列负责把瞬时写入压力削平,数据库只处理真正需要落盘的请求。把这三者放在一起做组合优化,才是互联网系统扛住峰值流量的关键,而不是单点死磕某一层。
这篇文章我打算把我自己踩过的坑、压测验过的参数、以及从故障里总结出来的设计思路完整写一遍。适合正在做高并发系统设计、或者公司业务正被流量问题困扰的朋友参考,如果你是初中级后端开发或者刚接触架构设计,也可以把它当成一份组合优化的落地笔记来读,里面的很多细节我会尽量写到能直接照着做的程度。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 组合优化的第一层:缓存不是"存数据",而是"挡流量"
2.1 Redis缓存到底在扛什么
很多人对缓存的认知是"把热点数据放到Redis里,查询走缓存,减少数据库压力"。这个理解没错,但太笼统了。实际在高并发场景里,缓存承担的是一个非常具体的任务:把99%的重复读请求拦在数据库之前。
我做过一次数据统计,在缓存健康的情况下,商品详情接口的访问量大约有97%会命中缓存,只有3%会落到数据库。这3%里还有相当一部分是缓存过期后的补偿请求。换句话说,数据库实际承受的流量只有入口流量的3%,这才是缓存真正的价值所在。
所以设计缓存时第一个要想清楚的,不是"存什么数据",而是"哪些数据值得用缓存"。热点商品、用户会话、配置信息、秒杀商品的库存状态,这类读多写少、频率高的数据才值得缓存。那种写入频繁、读取零星的数据,放进缓存反而会产生一致性问题,得不偿失。
2.2 三种缓存灾难的根因和防治方案
缓存层面常见的灾难有三种,我一个个说清楚,每一种都亲自修过。
第一种是缓存穿透。查询一个不存在的key,缓存没有,数据库也没有,请求每次都穿透到数据库。有人会拿空值也缓存来应对,这招有效但不彻底——如果攻击者用随机变化的key来打,你缓存空值根本来不及。更稳的方式是布隆过滤器,把所有可能存在的key先过滤一遍,请求来了先查布隆过滤器,如果判定不存在,直接返回,根本不会碰数据库。布隆过滤器有一个小概率误判的问题,但它只会把不存在的key误判成存在,最多代价是多查一次数据库,不会漏掉真实数据。
第二种是缓存击穿。某个热点key突然失效,一瞬间大量请求全部涌入数据库。这个场景下,关键在于多线程重建缓存时的互斥控制。我常用的方案是加分布式锁,让请求到达后只有拿到锁的线程去数据库加载数据并重建缓存,其余线程短暂sleep后重新读取缓存。比单纯设置"热点key永不过期"要安全,因为永久缓存会带来数据一直不更新的风险。
第三种是缓存雪崩。大量key在同一时间段集中失效,数据库被瞬间打挂。这属于"批量过期事故",应对策略通常有三板斧:过期时间加随机抖动,比如在一个时间段内随机分布;核心数据用逻辑过期兜底,即使物理key过期了,异步任务刷新时仍然返回旧数据;再就是多级缓存,本地缓存挡一层,Redis挡一层。这三招配合用,雪崩基本能摁住。
2.3 缓存一致性的落地做法
一致性是缓存设计里最容易扯皮的问题。先看清楚本质:缓存和数据库的数据不一致,本质上是因为"写数据库"和"更新缓存"这两个动作不是原子的。
我比较推荐的做法是先更新数据库,再删除缓存。为什么是删除而不是更新?因为更新缓存是一个计算和写入动作,在并发环境下先后顺序容易出问题;删除则是一个幂等操作,即使删除失败,下次查询会触发缓存重建,最多有一段时间的旧数据。而且删除缓存几乎不会像更新那样产生额外的状态依赖。
这里有个细节要注意:删除缓存失败怎么办?我的方案是在删除失败时重试几次,还不行就把这条消息扔到队列里,由异步任务补偿删除。很多团队在这一步就停了,觉得"MySQL更新成功、Redis删除失败的概率很低"。但高并发系统里,低概率事件会随着请求量级放大成必然事件。日志里每十万次删除出现一次失败,在大促期间一天几千万次请求,就是几百次不一致事故,绝对得认真对待。
延时双删也是网上常提的套路,先删缓存、再更新数据库、过几百毫秒再删一次缓存。我实验下来,这个方案确实能缓解"先删缓存后更新数据库"导致不一致的问题,但它引入的延迟窗口在极端情况下还是会出问题。所以我现在的实践是:优先"先更新库再删缓存",配合失败重试和异步补偿,并把最终一致性作为可接受的目标写入设计文档,避免和业务方纠结"更新后立刻读到旧值"这类微观问题。
3. 第二层:队列是削峰闸门,不是消息搬运工
3.1 队列为什么能扛住瞬时洪峰
数据库能承受的写入速度有上限,比如单库单表稳定写入大概在每秒几千条,但接口入口的请求可能是它的十倍甚至百倍。如果让请求直接打到数据库,数据库一定会被压垮。这时候队列的作用就出来了:它接收所有请求,把请求变成消息先存起来,再由下游消费者按照数据库能承受的速度去处理。
我用一句大白话总结队列的价值:数据库是水池,队列是水管上的阀门,入口水流再猛,阀门把流速限制在水池能承受的范围内,水池就不会满溢。
常见的消息队列选型里,Kafka擅长吞吐量极大的日志、流数据场景,单分区顺序写磁盘,吞吐能到每秒几十万条,但它的消息会按保留时间去清理,不适合做需要精确投递的业务消息;RocketMQ和RabbitMQ则更适合业务场景,支持事务消息、死信队列、延迟消息,吞吐量虽然不如Kafka,但功能完整度更高。如果团队需要高吞吐和业务特性的平衡,RocketMQ是很多互联网公司的选择;如果系统里已经重度使用了RabbitMQ,业务峰值也不是特别夸张,完全没必要为了"高并发"强行引入Kafka。
3.2 队列使用中的三个高频事故
队列用多了之后,我发现真正的坑反而不是"消息堆积",而是下面这三类。
重复消费是最常见的。 消息队列的at-least-once投递语义决定了:消费者收到消息、处理成功、但在提交位点之前挂了,这条消息会被重新投递。这时候如果业务逻辑不是幂等的,就会出现重复下单、重复扣款、重复加积分。解决的思路不是在消费端保证"绝对不重复投递",而是保证重复处理的结果和一次处理的相同。最简单的手法是在数据库表里加订单号或消息ID的唯一索引,重复插入直接报唯一键冲突跳过。我个人的习惯是,在业务上能接受的情况下,幂等标记优先做在数据库层——因为Redis里的标记可能丢,数据库唯一索引才是硬保证。
顺序问题要看业务模型。 Kafka默认在单个分区内保证顺序,所以要想保持消息顺序,必须让同一个业务对象的所有消息都进入同一个分区。比如同一个订单的所有状态变更消息,用订单号做key来路由到分区,这样对同一订单来说消息严格有序。很多人踩坑是因为生产端用了随机key,或者把同类消息分散到多个分区并行消费,顺序就全乱了。这里要给一个明确的结论:别指望消息队列在全局层面保证顺序,一定要在设计时把顺序要求收敛到局部(分区/队列内)。
堆积问题要能及时发现。 消息堆积不是故障,是队列的工作原理。真正可怕的是堆积后没有监控告警,消费者线程卡住了,你过了两个小时才发现线上响应都慢了。我现在的做法是为每个核心队列配置监控,关注消费Lag(积压量)和消费速率,Lag超过阈值就告警。还有一个容易被忽略的指标是"单条消息消费耗时",这个值异常上涨往往意味着消费者代码遇到了慢查询或外部依赖变慢,比Lag更早发出信号。
3.3 自己在队列选型里的实践结论
我做过一轮压测对比,给一个参考:
| 维度 | Kafka | RocketMQ | RabbitMQ |
|---|---|---|---|
| 吞吐量 | 极高(单分区几十万/秒) | 高(十万级/秒) | 中(万级/秒) |
| 消息可靠性 | 高,需要合理配置 | 高,支持事务 | 高,支持confirm |
| 顺序消息 | 分区内有序 | 支持分区有序 | 单队列有序 |
| 延迟消息 | 不原生支持 | 支持 | 支持 |
| 运维复杂度 | 中 | 中 | 低 |
如果业务峰值是每秒几万条以内、强依赖事务消息和延迟消息,我建议直接用RocketMQ;如果是日志采集、埋点这类海量数据,Kafka是标准答案;团队规模小、业务简单,RabbitMQ完全够用,别为了"技术先进"去选一个团队不熟悉的组件。
4. 第三层:数据库侧的兜底设计
4.1 连接池的坑与参数选择
当缓存和队列都发挥正常时,最终打到数据库的流量已经是削峰之后的形态。但这不意味着数据库可以不管了,相反,数据库往往是整个链路里最脆弱也最难扩容的环节。
先看连接池。很多团队的数据库连接池配置就是"最大连接数设个500",这是完全不考虑数据库实际承受能力的做法。数据库本身有连接数的处理上限,比如MySQL的max_connections默认在151到几千不等,连接数一旦过高,线程切换、锁等待就会拖垮性能。我一般建议连接池最大连接数设置为数据库max_connections的60%左右,同时要给应用层的其他服务(比如管理后台、定时任务)留余量。
连接池的真正难点在于动态调优。峰值流量来了,连接不够会排队;流量退了,连接池占着一大堆数据库连接不释放,可能拖垮其他服务。我实验下来比较合适的方案:initialSize设置为略高于平均并发数的值(比如10),minIdle保持和initialSize一致,maxActive是数据库能承受的上限的80%,maxWait建议在200毫秒到500毫秒之间——等待时间太长会让请求因排队而产生严重RT,太短又会误伤正常流量。配合连接池的姿态检测和空闲回收策略,这套参数能平稳度过大多数流量波动。
4.2 读写分离和分库分表的合理水位
数据库侧的组合优化,本质上是把压力从单点分散到多个点。两种主流方案:读写分离和分库分表。
读写分离适合读多写少、数据量适中的场景。主库只处理写事务,从库同步主库的binlog来提供查询能力。但它有一个隐藏问题:主从延迟。如果刚写入的数据需要在几毫秒内被读到,而主从延迟还没结束,就会读到旧数据。我的应对方案分两层:核心链路(比如订单创建后立即查询订单详情)强制走主库;非核心链路(比如历史订单列表、统计报表)走从库。这样既保证了业务数据的一致性,又分散了查询压力。
分库分表则是在单库单表的数据量涨到一个量级后不得不走的路。什么时候应该分库分表?我给一个判断标准:单表数据量超过千万行,或者单表写入QPS持续超过数据库承载上限,且读写分离已经不能再拆分。分库分表的方案很多,垂直拆分按业务域拆,水平拆分按用户ID、订单ID做hash取模或者按时间分片。我踩过的坑是分片键选错导致部分库过热。比如按订单ID分片,查询某个用户的订单列表时就要扫全部分片,效率极差。正确做法是:分片键尽量选择查询频率最高的维度,比如按用户ID分片,如果一定要按订单维度查,再建一个订单号和用户ID的映射索引表来路由。
4.3 慢查询治理:SQL层面的一次突围
数据库性能问题中有相当比例跟表结构、SQL语句有关,而不是数据库配置。我的经验是,高并发场景下的SQL一定要"少而精"。用EXPLAIN检查执行计划,确保查询走的是索引而不是全表扫描,尤其是那种在WHERE和JOIN条件里用了函数或隐式转换的SQL,这会导致索引失效。
还有一个对高并发系统特别重要的操作:控制单次查询的数据量。比如列表页接口,一次查询返回几千行数据,就算数据库能查出来,网络传输、JSON序列化、前端渲染都会成为瓶颈。分页加上限,是每个高并发接口都该做的事。
5. 一个完整案例:爆款商品详情链路怎么把三层组合在一起
5.1 场景定义和瓶颈分析
我拿一个在线零售平台来当案例。它的爆款商品详情接口在大促期间QPS冲击到两万,用户的主要动作是查看商品信息、查看库存、加入购物车,最终下单。
先做瓶颈分析:两万QPS的请求如果直接查MySQL,MySQL单库根本扛不住;即使用了Redis缓存热点商品,缓存击穿和雪崩的风险也很大;下单请求的写入压力是脉冲式的,瞬时可能几千甚至上万笔下单请求同时到达,数据库落库压力巨大。这三个问题不是独立的,任何一层崩了都会引发连锁反应。
5.2 组合设计的具体方案
我的组合方案分四步:
第一步,商品详情用Redis做多级缓存。热点商品key永不过期,但设置一个逻辑过期时间,后台任务每五分钟刷新一次热点商品缓存的数据。次热商品设置随机过期时间,同时加布隆过滤器拦截不存在的商品ID请求,防止恶意穿透。
第二步,本地缓存(Caffeine)作为Redis之前的第二道防线。每个应用节点缓存最近十分钟的热门商品ID和简要信息,当Redis出现抖动或网络延迟升高时,本地缓存可以挡住一部分流量。这个设计的目的不是多存数据,而是给Redis争取恢复时间,给系统增加一层容错空间。
第三步,下单请求不直接写数据库,而是先发到RocketMQ队列里。队列接收所有下单请求,消费者按照每秒最多1000笔的速度从队列里取消息落库。这样即使入口瞬时涌入5000笔下单请求,数据库也不会被打垮,消息会排队等着消费。同时用Redis原子操作(比如DECR)预扣库存,防止超卖——注意是"预扣",不是直接改数据库库存,这是为了在数据库层面减少更新冲突。
第四步,数据库写入采用批量+异步。消费者从队列里取到一批订单消息,攒够50条或者每隔100毫秒批量INSERT一次,大幅减少事务和网络开销。订单表按user_id做水平分库分表,查询走用户维度路由;后台的报表查询走只读从库。
5.3 为什么这个组合能扛住且不出错
这套方案能扛住的关键,在于每一层都只处理自己应该处理的请求:
- 本地缓存挡掉了大约40%的重复查询;
- Redis缓存挡掉了大约57%的查询;
- 真正落到数据库的读请求只有总流量的3%左右;
- 写入请求全部进队列,数据库处理速度被稳定控制在每秒1000笔以内。
出错方面,库存预扣时的原子操作防止了超卖;订单落库前用订单号唯一索引做幂等,防止队列重复消费导致重复下单;Redis的key过期时间带随机值,避免了集中失效的雪崩风险。并且每层的故障都有承接方案:Redis挂了,本地缓存还能撑几分钟;队列积压了,数据库照样稳定运行,只是消费者消费慢一些,下游在体验上表现为"稍等片刻",不会直接看到报错。
6. 组合优化后的排查与监控:不看指标就是盲人摸象
6.1 必须盯的四类指标
这套组合链路跑起来之后,不能只看业务有没有报错,要看拆解后的四类指标。
缓存层指标:命中率、缓存过期数量、穿透拦截数、Redis单节点CPU、Redis内存使用率。命中率低于95%就要检查是否有新增的过冷查询,内存使用率接近上限要预警,因为Redis内存满了会触发淘汰策略,导致大量key被紧急淘汰,这很容易引发雪崩。
队列层指标:生产速率、消费速率、Lag积压量、单条消息平均消费耗时、死信队列数量。Lag持续增长说明消费速度跟不上生产速度,要么加消费者实例,要么得排查下游是否出现性能瓶颈。
数据库层指标:连接池活跃数、等待连接数、主库QPS/TPS、慢查询数、主从延迟秒数。等待连接数暴增是数据库要出事的先兆,慢查询数量突然上升意味着某条SQL没有走索引。
应用层指标:各接口的P99 RT、错误率、线程池活跃度。P99 RT是最直观的用户体验指标,如果缓存命中率没问题但P99还是高,要检查应用本身的序列化和网络开销。
我见过一个团队排查线上接口变慢,查了两小时发现是Redis连接池配置的maxTotal设太小,高并发下线程全在等Redis连接。如果他们的监控面板上同时挂了"Redis等待连接数"这个指标,五分钟就能定位到问题。指标不是拿来展示的,是拿来交叉定位的。
6.2 故障排查时的链路排查思路
高并发系统的故障排查,我强烈建议按照"入口→缓存→队列→数据库"的顺序逐层缩小范围,而不是看到报错就怀疑最后端的数据库。
举一个排查示例。某个秒杀接口突然大面积超时,数据面板显示:入口QPS从两万涨到四万,Redis命中率从97%掉到80%,数据库QPS从300涨到2500。从指标上看,Redis命中率下降意味着有大量请求穿透到了数据库,数据库QPS冲高导致连接池打满,最终响应全面变慢。顺着这个链路往前推,根因是某条秒杀相关的新接口没有走缓存,直接把所有请求怼到了数据库。定位到根因后,修复方案就是给这个新接口补上缓存并设置合理的过期时间,整个问题半小时内解决。
这种排查思路的核心是:先问每一层的指标是否异常,再沿着异常的链路组件去定位,而不是面向报错乱猜。
6.3 压测怎么压才算数
组合优化做完了,得上压测验证。压测不能零零散散压一下,我建议分三步走。
第一步做单层压测,分别压Redis、队列、数据库,确定每个单点的性能水位。第二步做链路压测,从入口打到数据库全链路压一遍,观察各层的吞吐和RT,找到第一个瓶颈点。第三步做极限压测,把QPS抬到预估峰值的1.5倍到2倍,观察系统什么时候开始出现异常、是平滑降级还是直接崩溃。
压测过程中要重点观察的是"临界点前后发生了什么"。比如数据库连接池的活跃连接数在QPS到8000时开始满,P99 RT从50毫秒跳到2秒——这就是整个链路的短板。之后你要么提升这个短板(比如扩容、加缓存),要么给它设计降级方案(比如限流、熔断)。压测的目的就是把这些临界点找出来并且在线上大促前解决掉,不是压一个"还挺快"的数字发到技术群里炫耀。
7. 一些我自己才懂的细节和坑
7.1 缓存预热不能只预热一次
很多团队的缓存预热是项目启动时加载一次热点数据,然后就不管了。但实际上热点是会漂移的,上午的爆款商品下午可能已经不是爆款了。我现在的做法是写一个热点发现的任务,定期从访问日志里统计最近五分钟访问频率最高的key,把新热点提前加载到Redis里,把已经降温的key从热点列表里移除。这样缓存命中的稳定性比"启动时预热一次"要高出不少。
7.2 队列消费的幂等不能只靠Redis
刚才提过幂等要落在数据库唯一索引上,这里补充一个更全面的做法。消费消息时,先查本地去重表(或者用业务表里的唯一键)判断这条消息是否已经处理过,如果处理过直接跳过,否则执行业务逻辑。这个判断和执行要在同一个本地事务里完成,才能真正做到"重复消费也不会有副作用"。如果只依赖Redis里存一个"已处理"标记,Redis数据一旦被清理或者集群发生迁移,重复消费就会直接穿透到数据库。
7.3 降级开关比优化更保命
组合优化能解决常态高并发,但扛不住各种意外。比如外部依赖的第三方接口突然变慢,或者Redis集群某个节点盘故障,如果没有任何降级机制,系统就只剩硬扛一条路。我建议对每条核心链路做降级开关:缓存不可用时可以让流量直接走数据库(虽然慢,但不至于完全不可用);队列堆积严重时可以暂停部分非核心消费者,把资源让给核心链路;非核心业务(比如写操作日志、更新推荐位)在压力大时直接丢弃。降级不是追求完美,是保证核心功能在极端情况下仍然可用。
7.4 配置和代码要分离
高并发系统的参数(线程池大小、Redis过期时间、队列消费速率、限流阈值)如果硬编码在代码里,每次调整都要发版,这在线上故障时是灾难。我现在的做法是全部放到配置中心,支持动态下发。比如压测发现某个队列的消费速率要给到2000才不积压,直接改配置推送到所有节点,不用重启服务,几分钟生效。
7.5 别忽视小流量验证
在正式切换全量流量前,先拿5%到10%的流量做灰度验证,观察缓存命中率、队列积压、数据库连接池等指标,稳定后再逐步放开到50%、100%。这一步看起来保守,但能挡住大多数设计缺陷。我自己就经历过一次方案自认为完美,灰度时发现缓存击穿防护的代码在极端流量下有概率产生死锁,如果不是灰度拦住了,直接全量上线就是事故。
8. 结尾:这轮优化我体会最深的三件事
第一个体会,高并发组合优化没有银弹。缓存、队列、数据库每一层都各管一段,单点再做得多好,也扛不住整个链路的失衡。真正让系统稳定的是"每层都做了自己该做的事"这个整体设计。
第二个体会,每层之间的接口要留有余量。缓存命中率不能设计成100%,因为一旦依赖这个100%的假设,缓存抖动时系统就没有退路。我会要求缓存层最多扛90%到95%的请求,剩下的5%到10%的流量必须保证数据库能兜得住。这样即使缓存出现局部问题,数据库依然能平稳运行,系统不至于整体崩溃。
第三个体会,这套组合优化是需要持续迭代的。我从最初只加Redis缓存,到后来引入队列削峰、分库分表、多级缓存,花了好几个版本才磨成相对稳定的形态。每一次大促后我都会复盘,看哪一层的指标在峰值时兜得很勉强,然后针对那一层做进一步的优化。高并发系统是长出来的,不是一次设计出来的。
如果你现在正在做一个高并发的系统,我建议你先画出你的链路图,标出每一层的流量比例和承载上限,然后从最薄弱的那一层开始优化。一个个水位测清楚,流量再猛,你心里也会有个底。
