高并发高可用设计
Java 架构师 · 高并发与架构
高并发关注"扛得住流量"(吞吐、延迟),高可用关注"挂不掉、挂了能恢复"(冗余、隔离、容灾)。二者是架构设计的两条主线:前者靠缓存、拆分、异步削峰,后者靠冗余、隔离、容灾。本质上,二者统一于同一个工程哲学——"假设任何组件都会失败,并把故障影响圈在最小范围"。本章从缓存全链路(穿透/击穿/雪崩)讲到读写分离与主从延迟,再到削峰、预热与异地多活单元化,给出可落地的设计权衡。
一、整体思路:用"摊平"与"圈住"对抗流量与故障
设计分两层:
- 扛流量:多级缓存(本地→Redis)、读写分离、分库分表、异步化、CDN。
- 保可用:冗余副本、超时/重试/熔断、降级、限流、容灾演练。
二、缓存全链路:穿透 / 击穿 / 雪崩
2.1 缓存穿透(查不存在的 key)
恶意/失误查询不存在的数据,缓存与 DB 都没有 → 每次都打 DB。解法:
- 布隆过滤器(Bloom Filter):在缓存前拦截"一定不存在"的 key(有误判但无漏判),空间极小。
- 空值缓存:查不到也写
null并短 TTL,防重复穿透(注意对热点不存在 key 单独设短过期避免占内存)。
2.2 缓存击穿(热点 key 失效瞬间)
某个极热 key 过期瞬间,大量并发同时击穿到 DB,DB 被打爆。解法:
- 互斥重建:首个线程拿到"重建锁"(如 Redis 分布式锁),其余线程等待/返回旧值,只有一个去查 DB 并回填。
- 逻辑过期:key 不物理过期,值里带
expire字段;发现逻辑过期后异步重建,期间返回旧值(用户无感知)。适合"允许短暂旧值"的场景。
2.3 缓存雪崩(大量 key 同时失效 / Redis 宕机)
大量 key 同一时刻过期,或 Redis 集群整体不可用 → 流量全部压到 DB。解法:
- 过期时间加随机抖动:
base + random(0, jitter),避免同时失效。 - 多级缓存:本地 Caffeine 兜底,Redis 挂时本地仍能扛部分读。
- Redis 高可用:集群 + 哨兵,避免单点。
- 限流+降级:DB 层限流,失败返回默认/排队。
三、缓存与数据库一致性:Cache-Aside 与并发缺陷
最常用 Cache-Aside(旁路缓存):
- 读:缓存命中则返回;未命中查 DB 再写缓存。
- 写:先更新 DB,再删除缓存(而非更新缓存——避免并发写导致的缓存脏值)。
但"先更新 DB 再删缓存"仍有并发窗口:删缓存失败 → 缓存脏。更稳妥:
- 延迟双删:更新 DB → 删缓存 → 休眠几百 ms → 再删一次(覆盖删除失败 + 并发读回填的脏值)。
- binlog 订阅(Canal):监听 DB binlog,由独立消费者删/更新缓存,与业务解耦,保证"DB 变 → 缓存最终一致",是大型系统更可靠的做法。
两种写顺序的并发缺陷对比
- 先删缓存再更新 DB:并发 A 删缓存 → B 读(缓存空)读 DB(旧值)写缓存(脏值) → A 更新 DB。结果缓存是旧值,直到下次过期。缺陷:读请求易把旧值回填。
- 先更新 DB 再删缓存:A 更新 DB → B 读缓存(旧值, 此时还没删) → A 删缓存。B 拿到旧值但下次读即新值,影响更小;除非 B 读发生在 A 删之前且 B 慢。综合更优,配合延迟双删/Canal 可基本消除。
四、读写分离与主从延迟
一主多从,写走主库、读走从库,分摊读压力。但主从异步复制有延迟(毫秒到秒级),引发:
- 读己之写缺失:用户刚提交订单就刷新,读从库看到"没有" → 体验 bug。
- 强一致读错乱:金融类查询读到旧余额。
解法:
- 会话一致 / 读主:写后短暂读主库(如 1s 内该用户读主),或"写后强制读主"。
- 半同步复制:主库等至少一个从库 ACK 再返回,降低延迟但牺牲写性能(RPO 与性能的权衡)。
- 监控主从延迟(Seconds_Behind_Master),超阈值把读切回主或告警。
五、削峰填谷:用 MQ 把尖峰变平稳
大促下单:瞬时万级 QPS 但后端只能平稳消费千级。架构:写请求入 MQ,前端返回"排队中",后端按自身能力消费。
要点:MQ 是削峰缓冲,但MQ 本身也会成单点——需限流兜底(MQ 满则拒绝新请求)、多级缓冲(本地队列 + MQ)。前端用"排队中/异步结果"降低用户预期。
六、缓存预热与灰度放量
新实例上线即被打满,往往因为"缓存是冷的"(本地/Redis 都没热点数据)。预热:
- 上线前把热点数据批量加载到 Redis(预热脚本)。
- 本地缓存(Caffeine)在启动后主动加载热点。
- 灰度放量:新实例先接 1% 流量,缓存逐步升温,再全量,避免冷启动击穿 DB。
七、异地多活与单元化:容灾的终极形态
单机房有上限且有"机房级故障"风险(光纤挖断、断电)。异地多活把流量分散到多个机房。核心是单元化(Unitization):
- 单元封闭:用户按路由规则(如 userId 哈希)固定落在某机房,该用户的"写"全在本单元内闭环,跨机房调用最小化。
- 数据多活:单元内数据独立写入,跨单元同步(最终一致)。难点是冲突合并——同一条数据若两单元都可能写(如全局配置),需单写或冲突解决策略。
- 脑裂(Split-Brain):两单元网络断开后各自写入,恢复时数据冲突。对策:明确"哪些数据单写(全局唯一源)、哪些可多活(需冲突合并)";单写数据用租约/选主保证只有一个单元可写。
多活的设计红线
- 不能"所有数据都多活":强一致要求的全局数据(如全局库存、账户总账)必须单写,否则脑裂即数据错乱。
- 容灾演练缺失 = 虚假多活:从没演练过"断一个机房流量切另一个",真故障时切换多半失败。演练(混沌工程)比冗余更重要。
- RTO/RPO 要量化:切流目标(恢复时间 RTO、数据丢失 RPO)必须事先定义并演练验证。
八、最佳实践
- 多级缓存(本地 Caffeine + Redis)+ 热点 key 逻辑过期 + 布隆防穿透。
- 一致性优先 binlog 订阅(Canal),写后删缓存 + 延迟双删兜底。
- 容量规划基于压测,留 30%+ 余量应对突发;核心链路全异步化。
- 容灾演练常态化(关机房、杀节点),验证 RTO/RPO 与单元化闭环。
本节小结
高并发靠"缓存、拆分、异步削峰"把流量摊平——多级缓存抗读、读写分离抗读压力、MQ 削峰抗写尖峰;高可用靠"冗余、隔离、容灾"把故障圈住。缓存三大问题中:穿透用布隆+空值、击穿用互斥重建/逻辑过期、雪崩用过期抖动+多级缓存+集群HA。一致性用 Cache-Aside(先更 DB 再删缓存)+ Canal 订阅兜底。主从延迟用读主/半同步缓解。异地多活靠单元化闭环 + 明确单写/多活边界以避脑裂。铁律:高可用不是堆机器,而是"假设任何组件都会挂"并用演练验证冗余。架构师的价值在于按业务价值分配这些手段的投入强度。
📚 官方文档与参考资料
- Kubernetes 文档(弹性伸缩/容灾编排):https://kubernetes.io/docs/
- Spring Cloud Alibaba(限流/缓存相关):https://sca.aliyun.com/
- Canal(binlog 订阅,阿里开源):https://github.com/alibaba/canal
- Redis 官方文档(缓存模式):https://redis.io/docs/
✏️ 练习与自测
- 设计题:设计一个秒杀系统:1 万 QPS 抢 1000 件商品,画出架构(缓存/锁/MQ/限流)并说明如何防超卖(含 Redis Lua 原子扣减与库存预热)。
- 辨析题:对比"先删缓存再更新 DB"与"先更新 DB 再删缓存"的并发缺陷,给出"延迟双删 + Canal 订阅"如何消除脏缓存。
- 设计题:两地三中心多活方案,说明哪些数据必须单写(避免脑裂)、哪些可多活,并给出网络分区恢复后的冲突合并策略。
- 排查题:主从延迟导致"用户刚下单刷新看不到",给出三种不同一致强度(会话一致/读主/半同步)的取舍与实现。