目录
-
- 一、从容量估算开始
-
- [1.1 峰值 QPS 怎么估](#1.1 峰值 QPS 怎么估)
- [1.2 线程池和连接池开多大:利特尔法则](#1.2 线程池和连接池开多大:利特尔法则)
- [1.3 压测确认拐点](#1.3 压测确认拐点)
- 二、限流:按层、按接口、按身份
-
- [2.1 算法选择](#2.1 算法选择)
- [2.2 分层限流](#2.2 分层限流)
- [2.3 分布式令牌桶:Redis + Lua](#2.3 分布式令牌桶:Redis + Lua)
- 三、熔断与超时:防止故障传播
-
- [3.1 超时是第一道防线](#3.1 超时是第一道防线)
- [3.2 重试会放大流量](#3.2 重试会放大流量)
- [3.3 熔断器配置](#3.3 熔断器配置)
- 四、降级:提前分级,定期演练
- 五、多级缓存:正确性比命中率更重要
-
- [5.1 三类问题的根因与解法](#5.1 三类问题的根因与解法)
- [5.2 互斥锁重建:修正常见的三个 Bug](#5.2 互斥锁重建:修正常见的三个 Bug)
- [5.3 本地缓存的一致性](#5.3 本地缓存的一致性)
- [5.4 缓存与数据库一致性:先更新库,再删缓存](#5.4 缓存与数据库一致性:先更新库,再删缓存)
- 六、实战:秒杀库存扣减
- 七、高可用:可用性是可以计算的
-
- [7.1 串联与并联](#7.1 串联与并联)
- [7.2 隔离:舱壁模式](#7.2 隔离:舱壁模式)
- [7.3 故障恢复比故障预防更重要](#7.3 故障恢复比故障预防更重要)
- 八、大促前检查清单
- 九、总结
- 参考资料
适合谁读:负责核心交易链路、需要为大促或流量高峰做准备的后端工程师和架构师。
解决什么问题 :限流、熔断、降级、缓存这些词大家都听过,但真正在大促前被问到"阈值设多少""线程池开多大""重试几次"时,很多人只能拍脑袋。本文以一次电商大促备战为主线,把这些问题算清楚,并给出可直接使用、修正过常见 Bug 的代码。文中数字均为示例,前提会写明。
本文是"技术架构实战"系列的第 2 篇,服务边界的划分见上一篇 从单体到微服务:基于DDD的服务拆分方法论与落地实践。
一、从容量估算开始
1.1 峰值 QPS 怎么估
很多系统的"高并发设计"是从"我们要扛 10 万 QPS"这种拍脑袋的数字开始的。更靠谱的方式是从业务数据推导:
峰值 QPS = DAU × 人均访问该接口次数 86400 × 峰值系数 \text{峰值 QPS} = \frac{\text{DAU} \times \text{人均访问该接口次数}}{86400} \times \text{峰值系数} 峰值 QPS=86400DAU×人均访问该接口次数×峰值系数
以商品详情接口为例(示例前提):
- 大促当天预估 DAU 800 万,人均浏览详情 15 次 → 日请求 1.2 亿;
- 平均 QPS = 1.2 亿 / 86400 ≈ 1389;
- 历史数据显示零点开抢的峰值约为日均的 20 倍 → 峰值约 2.8 万 QPS;
- 再乘以 1.5 倍安全余量 → 按 4.2 万 QPS 准备容量。
下单接口则不同:转化率 3%,人均 1.2 单,零点峰值系数取 50 倍(集中抢购更尖锐),算下来峰值约 8300 QPS。同一个系统,不同接口的峰值相差 5 倍以上,所以限流和扩容都必须按接口分别设计。
1.2 线程池和连接池开多大:利特尔法则
利特尔法则(Little's Law):系统中的平均请求数 = 到达速率 × 平均停留时间。
假设单个下单服务实例需要承担 400 QPS,接口平均 RT 为 80ms:
所需并发线程数 = 400 QPS × 0.08 s = 32 \text{所需并发线程数} = 400\ \text{QPS} \times 0.08\ \text{s} = 32 所需并发线程数=400 QPS×0.08 s=32
考虑 RT 抖动(P99 可能是平均值的 3~5 倍),线程池上限取 32 × 3 ≈ 100 比较合理。再看数据库连接池:如果下单流程中实际占用数据库连接的时间是 20ms,那么所需连接数 = 400 × 0.02 = 8。
10 台实例共需约 80 个连接,加上余量,单实例连接池 15~20 就够了。连接池不是越大越好:如果每个实例开 100 个连接,10 台就是 1000 个连接打到 MySQL,数据库会先被连接数和上下文切换拖垮。
1.3 压测确认拐点
估算之后必须压测。压测时逐步加压,观察吞吐和延迟的关系:
| 压测 QPS | P99 延迟 (ms) | 错误率 | 状态 |
|---|---|---|---|
| 2000 | 85 | 0% | 正常 |
| 4000 | 92 | 0% | 正常 |
| 6000 | 110 | 0% | 正常 |
| 7000 | 180 | 0.1% | 拐点:延迟开始陡增 |
| 8000 | 650 | 2.3% | 过载 |
| 9000 | 2100 | 11% | 崩溃区 |
拐点(本例约 7000 QPS)才是系统的真实容量,而不是"压到报错"的那个值。限流阈值可以先取拐点的 70%~80%(本例约 5000~5600 QPS)作为起点,留出应对 RT 抖动和单实例故障的余量,再用一轮压测确认:在该阈值下 P99 延迟和错误率都满足 SLO。
二、限流:按层、按接口、按身份
2.1 算法选择
| 算法 | 特点 | 适用 |
|---|---|---|
| 固定窗口 | 实现最简单,窗口边界可能瞬时放过 2 倍流量 | 粗粒度保护、对精度不敏感的场景 |
| 滑动窗口 | 平滑,内存稍多 | 网关层按接口限流 |
| 令牌桶 | 允许一定突发(桶容量),长期速率受控 | 绝大多数业务接口 |
| 漏桶 | 输出绝对匀速,不允许突发 | 调用有严格速率限制的下游(如短信、第三方 API) |
2.2 分层限流
| 层级 | 目的 | 典型做法 |
|---|---|---|
| 接入层(Nginx/网关) | 挡住恶意和超额流量 | 按 IP、按接口总量限流 |
| 应用层 | 保护本服务不被压垮 | 按接口、按调用方限流(Sentinel/Resilience4j) |
| 业务层 | 公平性与防刷 | 按用户、按商品限流(Redis 分布式限流) |
| 依赖层 | 保护下游 | 调用下游前的并发数/速率控制 |
2.3 分布式令牌桶:Redis + Lua
lua
-- KEYS[1] 限流 key;ARGV[1] 速率(个/秒);ARGV[2] 桶容量;ARGV[3] 本次申请令牌数
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local requested = tonumber(ARGV[3])
-- 使用 Redis 服务器时间,避免各应用实例时钟不一致
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local bucket = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(bucket[1]) or capacity
local ts = tonumber(bucket[2]) or now
tokens = math.min(capacity, tokens + math.max(0, now - ts) * rate / 1000)
local allowed = 0
if tokens >= requested then
tokens = tokens - requested
allowed = 1
end
redis.call('HSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, math.ceil(capacity / rate * 1000) + 1000)
return allowed
相比网上常见的版本,这里做了三处修正:
- 用 Redis 的
TIME而不是客户端传入时间 ,避免多实例时钟漂移导致令牌计算错乱(Redis 5.0 起默认按"效果复制",脚本中调用TIME是安全的); HMSET已被官方标记为废弃,改用HSET多字段写法;- 支持一次申请多个令牌,可用于按"商品件数"限流。
注意:分布式限流每次请求都要访问 Redis,本身也会成为瓶颈。对于单实例 QPS 很高的接口,常见做法是**"本地令牌桶 + 定期向 Redis 申请配额"**,用少量精度换性能。
三、熔断与超时:防止故障传播
3.1 超时是第一道防线
没有超时的远程调用,是雪崩的头号元凶。超时设置有两个原则:
原则一:超时 = 下游 P99 + 少量余量,而不是拍一个"3 秒"。 下游 P99 是 120ms,设 3 秒超时意味着下游故障时,每个请求要白白占用线程 3 秒,线程池会迅速耗尽。
原则二:超时要逐层递减(Deadline 传递)。 如果网关给用户的总超时是 2 秒,那么:
#mermaid-svg-8xM9fFH1vDVMb547{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8xM9fFH1vDVMb547 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8xM9fFH1vDVMb547 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8xM9fFH1vDVMb547 .error-icon{fill:#552222;}#mermaid-svg-8xM9fFH1vDVMb547 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8xM9fFH1vDVMb547 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8xM9fFH1vDVMb547 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8xM9fFH1vDVMb547 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8xM9fFH1vDVMb547 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8xM9fFH1vDVMb547 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8xM9fFH1vDVMb547 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8xM9fFH1vDVMb547 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8xM9fFH1vDVMb547 .marker.cross{stroke:#333333;}#mermaid-svg-8xM9fFH1vDVMb547 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8xM9fFH1vDVMb547 p{margin:0;}#mermaid-svg-8xM9fFH1vDVMb547 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-8xM9fFH1vDVMb547 .cluster-label text{fill:#333;}#mermaid-svg-8xM9fFH1vDVMb547 .cluster-label span{color:#333;}#mermaid-svg-8xM9fFH1vDVMb547 .cluster-label span p{background-color:transparent;}#mermaid-svg-8xM9fFH1vDVMb547 .label text,#mermaid-svg-8xM9fFH1vDVMb547 span{fill:#333;color:#333;}#mermaid-svg-8xM9fFH1vDVMb547 .node rect,#mermaid-svg-8xM9fFH1vDVMb547 .node circle,#mermaid-svg-8xM9fFH1vDVMb547 .node ellipse,#mermaid-svg-8xM9fFH1vDVMb547 .node polygon,#mermaid-svg-8xM9fFH1vDVMb547 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8xM9fFH1vDVMb547 .rough-node .label text,#mermaid-svg-8xM9fFH1vDVMb547 .node .label text,#mermaid-svg-8xM9fFH1vDVMb547 .image-shape .label,#mermaid-svg-8xM9fFH1vDVMb547 .icon-shape .label{text-anchor:middle;}#mermaid-svg-8xM9fFH1vDVMb547 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-8xM9fFH1vDVMb547 .rough-node .label,#mermaid-svg-8xM9fFH1vDVMb547 .node .label,#mermaid-svg-8xM9fFH1vDVMb547 .image-shape .label,#mermaid-svg-8xM9fFH1vDVMb547 .icon-shape .label{text-align:center;}#mermaid-svg-8xM9fFH1vDVMb547 .node.clickable{cursor:pointer;}#mermaid-svg-8xM9fFH1vDVMb547 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-8xM9fFH1vDVMb547 .arrowheadPath{fill:#333333;}#mermaid-svg-8xM9fFH1vDVMb547 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-8xM9fFH1vDVMb547 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-8xM9fFH1vDVMb547 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8xM9fFH1vDVMb547 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8xM9fFH1vDVMb547 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8xM9fFH1vDVMb547 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-8xM9fFH1vDVMb547 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-8xM9fFH1vDVMb547 .cluster text{fill:#333;}#mermaid-svg-8xM9fFH1vDVMb547 .cluster span{color:#333;}#mermaid-svg-8xM9fFH1vDVMb547 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-8xM9fFH1vDVMb547 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8xM9fFH1vDVMb547 rect.text{fill:none;stroke-width:0;}#mermaid-svg-8xM9fFH1vDVMb547 .icon-shape,#mermaid-svg-8xM9fFH1vDVMb547 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8xM9fFH1vDVMb547 .icon-shape p,#mermaid-svg-8xM9fFH1vDVMb547 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-8xM9fFH1vDVMb547 .icon-shape .label rect,#mermaid-svg-8xM9fFH1vDVMb547 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8xM9fFH1vDVMb547 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-8xM9fFH1vDVMb547 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-8xM9fFH1vDVMb547 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 网关
总超时 2000ms
订单服务
1800ms
库存服务
600ms
优惠服务
500ms
更好的做法是把剩余截止时间放在请求头中传递,每一层根据剩余时间决定自己还能等多久,剩余时间不足时直接快速失败,不再调用下游。
3.2 重试会放大流量
重试是把双刃剑。假设调用链有 3 层,每层失败时重试 2 次(共 3 次尝试):
最坏情况下底层收到的请求倍数 = 3 × 3 × 3 = 27 \text{最坏情况下底层收到的请求倍数} = 3 \times 3 \times 3 = 27 最坏情况下底层收到的请求倍数=3×3×3=27
下游本来就因为过载才失败,27 倍的重试流量会让它彻底无法恢复,这就是重试风暴。正确做法:
- 只在一层重试,通常是最靠近失败点的那一层;
- 只重试幂等操作和明确可重试的错误(连接失败、503),不重试 4xx 和超时不确定的写操作;
- 指数退避 + 随机抖动,避免所有客户端同时重试;
- 重试预算:重试请求占比不超过正常请求的 10%,超出后停止重试。
3.3 熔断器配置
以 Resilience4j 为例:
yaml
resilience4j:
circuitbreaker:
instances:
inventory:
sliding-window-type: TIME_BASED
sliding-window-size: 10 # 统计最近 10 秒
minimum-number-of-calls: 50 # 样本太少不判断,避免误熔断
failure-rate-threshold: 50
slow-call-duration-threshold: 500ms
slow-call-rate-threshold: 80
wait-duration-in-open-state: 5s
permitted-number-of-calls-in-half-open-state: 10
automatic-transition-from-open-to-half-open-enabled: true
retry:
instances:
inventory:
max-attempts: 2 # 含首次,共尝试 2 次
wait-duration: 50ms
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
enable-randomized-wait: true
retry-exceptions:
- java.net.ConnectException
timelimiter:
instances:
inventory:
timeout-duration: 600ms
几个参数的选择依据:
minimum-number-of-calls很重要:低峰期 10 秒内只有 4 个请求,2 个失败就是 50% 失败率,会被误判熔断;- 慢调用比例往往比失败率更早反映问题:下游 CPU 打满时通常是先变慢、后报错;
- 熔断打开后,降级逻辑必须足够轻,不能在 fallback 里再去调用另一个远程服务。
四、降级:提前分级,定期演练
降级的核心是在有限资源下保核心链路。大促前要做的是把所有功能分级:
| 级别 | 定义 | 示例 | 降级策略 |
|---|---|---|---|
| P0 | 不可用即资损或无法交易 | 下单、支付、库存扣减 | 不降级,优先保障资源 |
| P1 | 影响体验但可交易 | 商品详情、搜索、购物车 | 降级为缓存数据/简化版 |
| P2 | 锦上添花 | 推荐、评价、浏览足迹、积分实时展示 | 可直接关闭 |
降级开关的工程要点:
- 开关放在配置中心,秒级生效,支持按比例/按用户灰度;
- 预先准备"大促模式"一键预案,一次切换一组开关;
- 每次大促前必须演练:从未打开过的开关,真打开时很可能触发未知 Bug;
- 降级本身要有监控:被降级的请求量是多少、是否影响转化率。
五、多级缓存:正确性比命中率更重要
5.1 三类问题的根因与解法
| 问题 | 根因 | 解法 |
|---|---|---|
| 穿透 | 查询的数据根本不存在 | 缓存空值(短 TTL)、布隆过滤器 |
| 击穿 | 单个热点 key 过期瞬间并发回源 | 互斥锁重建、逻辑过期异步刷新 |
| 雪崩 | 大量 key 同时过期,或缓存集群故障 | TTL 加随机值、多级缓存、熔断保护数据库 |
5.2 互斥锁重建:修正常见的三个 Bug
网上流行的互斥锁写法通常有三个问题:
- 释放锁时直接
DEL,可能删掉别人的锁(自己的锁已超时过期、被另一个线程获得); - 获取锁失败后递归调用,高并发下可能栈溢出;
- 没有重试上限,Redis 异常时线程会无限等待。
修正后的版本:
java
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT = new DefaultRedisScript<>(
"if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end",
Long.class);
public Optional<Product> getProduct(long id) {
String key = "product:" + id;
for (int attempt = 0; attempt < 20; attempt++) {
Product local = localCache.getIfPresent(key);
if (local != null) {
return local == Product.EMPTY ? Optional.empty() : Optional.of(local);
}
String json = redis.opsForValue().get(key);
if (json != null) {
Product p = "NULL".equals(json) ? Product.EMPTY : JSON.parseObject(json, Product.class);
localCache.put(key, p);
return p == Product.EMPTY ? Optional.empty() : Optional.of(p);
}
String lockKey = "lock:" + key;
String token = UUID.randomUUID().toString();
Boolean locked = redis.opsForValue().setIfAbsent(lockKey, token, Duration.ofSeconds(3));
if (Boolean.TRUE.equals(locked)) {
try {
Product p = productMapper.selectById(id);
long ttl = 600 + ThreadLocalRandom.current().nextLong(120); // 防雪崩
redis.opsForValue().set(key, p == null ? "NULL" : JSON.toJSONString(p),
Duration.ofSeconds(p == null ? 60 : ttl)); // 空值短 TTL 防穿透
return Optional.ofNullable(p);
} finally {
redis.execute(UNLOCK_SCRIPT, List.of(lockKey), token); // 只删自己的锁
}
}
sleepQuietly(30);
}
// 多次等待仍拿不到数据:降级处理,而不是让线程一直挂着
throw new ServiceDegradedException("商品信息暂时不可用");
}
5.3 本地缓存的一致性
本地缓存(Caffeine)能扛住热点,但多实例之间的数据会不一致。常用做法:
- 本地缓存 TTL 设得很短(如 5~30 秒),接受短暂不一致;
- 数据变更时通过 Redis Pub/Sub 或 MQ 广播失效消息,各实例收到后删除本地缓存;
- 只缓存允许短暂不一致的数据(商品描述、类目),库存、价格这类数据不放本地缓存,或者只用于展示、不用于交易判断。
5.4 缓存与数据库一致性:先更新库,再删缓存
为什么是"删"而不是"更新"缓存?因为两个并发写请求更新缓存的顺序可能和更新数据库的顺序相反,导致缓存中留下旧值。而"先更新数据库再删缓存"仍有一个极小概率的不一致窗口:
MySQL Redis 线程B(写) 线程A(读) MySQL Redis 线程B(写) 线程A(读) #mermaid-svg-9lzgpPslK07c7dLZ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9lzgpPslK07c7dLZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9lzgpPslK07c7dLZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9lzgpPslK07c7dLZ .error-icon{fill:#552222;}#mermaid-svg-9lzgpPslK07c7dLZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9lzgpPslK07c7dLZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9lzgpPslK07c7dLZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9lzgpPslK07c7dLZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9lzgpPslK07c7dLZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9lzgpPslK07c7dLZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9lzgpPslK07c7dLZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9lzgpPslK07c7dLZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9lzgpPslK07c7dLZ .marker.cross{stroke:#333333;}#mermaid-svg-9lzgpPslK07c7dLZ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9lzgpPslK07c7dLZ p{margin:0;}#mermaid-svg-9lzgpPslK07c7dLZ .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9lzgpPslK07c7dLZ text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-9lzgpPslK07c7dLZ .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-9lzgpPslK07c7dLZ .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-9lzgpPslK07c7dLZ .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-9lzgpPslK07c7dLZ .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-9lzgpPslK07c7dLZ #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-9lzgpPslK07c7dLZ .sequenceNumber{fill:white;}#mermaid-svg-9lzgpPslK07c7dLZ #sequencenumber{fill:#333;}#mermaid-svg-9lzgpPslK07c7dLZ #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-9lzgpPslK07c7dLZ .messageText{fill:#333;stroke:none;}#mermaid-svg-9lzgpPslK07c7dLZ .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9lzgpPslK07c7dLZ .labelText,#mermaid-svg-9lzgpPslK07c7dLZ .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-9lzgpPslK07c7dLZ .loopText,#mermaid-svg-9lzgpPslK07c7dLZ .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-9lzgpPslK07c7dLZ .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-9lzgpPslK07c7dLZ .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-9lzgpPslK07c7dLZ .noteText,#mermaid-svg-9lzgpPslK07c7dLZ .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-9lzgpPslK07c7dLZ .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9lzgpPslK07c7dLZ .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9lzgpPslK07c7dLZ .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9lzgpPslK07c7dLZ .actorPopupMenu{position:absolute;}#mermaid-svg-9lzgpPslK07c7dLZ .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-9lzgpPslK07c7dLZ .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9lzgpPslK07c7dLZ .actor-man circle,#mermaid-svg-9lzgpPslK07c7dLZ line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-9lzgpPslK07c7dLZ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1. 读缓存,未命中 2. 读到旧值 v1 3. 更新为 v2 4. 删除缓存 5. 写入旧值 v1(缓存中残留旧值)
这需要"线程 A 的读库 + 写缓存"比"线程 B 的写库 + 删缓存"还慢,概率很低但不为零。常见的"延迟双删"只是缩小窗口,并不能消除它。要彻底关闭这个窗口,关键是让缓存能识别"旧值":
- 数据表增加单调递增的
version列,每次更新version = version + 1; - "删除缓存"改为"写入墓碑":写入一个只带新版本号、没有数据的占位记录,TTL 与正常缓存相同;
- 回源写缓存时带上读到的版本号,只有版本不低于缓存中的版本时才允许写入,比较和写入在同一个 Lua 脚本里原子完成。
lua
-- KEYS[1] 缓存 key;ARGV[1] 版本号;ARGV[2] 数据(空串表示墓碑);ARGV[3] TTL(秒)
local cur = redis.call('HGET', KEYS[1], 'ver')
local newVer = tonumber(ARGV[1])
if cur then
local curVer = tonumber(cur)
if curVer > newVer then
return 0 -- 旧版本,拒绝覆盖
end
if curVer == newVer and (ARGV[2] == '' or redis.call('HGET', KEYS[1], 'val') ~= '') then
return 0 -- 同版本数据已存在,或重复的墓碑
end
end
redis.call('HSET', KEYS[1], 'ver', newVer, 'val', ARGV[2])
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
return 1
套回上面的时序:线程 B 更新数据库后把版本 2 的墓碑写入缓存;线程 A 带着版本 1 的旧值回来写缓存时,脚本发现 1 < 2 直接拒绝。之后的读请求看到墓碑(val 为空)就按未命中处理,回源读到版本 2 的数据,同版本数据覆盖墓碑成功。
工程落地上还有三点:
- 采用该方案后,5.2 中的
opsForValue().get/set相应改为HGET key val读取、通过上面的脚本写入,商品表的查询需要同时取出version列; - 墓碑由 Binlog 消费者(Canal)统一写入 ,业务代码只管更新数据库。消费失败进入重试队列,保证墓碑最终一定写入。同时监控 Canal 的消费延迟和重试队列积压 ,例如延迟超过 5 秒或积压超过 1000 条即告警;再用定时任务抽样比对缓存与数据库的
version,发现落后就补写墓碑,作为最后一道兜底; - 墓碑的 TTL 必须不短于"一次回源"的最长耗时(这里与正常缓存相同,取 10 分钟),否则极慢的旧请求仍可能在墓碑过期后写入旧值;
- 版本校验解决的是缓存中的旧值覆盖新值 ,并不能让缓存变成强一致。涉及资金和库存的判断仍以数据库条件更新为准,缓存只用于展示。
六、实战:秒杀库存扣减
秒杀是上述所有手段的综合应用。核心挑战是:几万 QPS 抢几百件库存,既不能超卖,也不能把数据库打垮。
#mermaid-svg-mQqxOz9myednP8hd{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-mQqxOz9myednP8hd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-mQqxOz9myednP8hd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-mQqxOz9myednP8hd .error-icon{fill:#552222;}#mermaid-svg-mQqxOz9myednP8hd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-mQqxOz9myednP8hd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-mQqxOz9myednP8hd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-mQqxOz9myednP8hd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-mQqxOz9myednP8hd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-mQqxOz9myednP8hd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-mQqxOz9myednP8hd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-mQqxOz9myednP8hd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-mQqxOz9myednP8hd .marker.cross{stroke:#333333;}#mermaid-svg-mQqxOz9myednP8hd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-mQqxOz9myednP8hd p{margin:0;}#mermaid-svg-mQqxOz9myednP8hd .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-mQqxOz9myednP8hd .cluster-label text{fill:#333;}#mermaid-svg-mQqxOz9myednP8hd .cluster-label span{color:#333;}#mermaid-svg-mQqxOz9myednP8hd .cluster-label span p{background-color:transparent;}#mermaid-svg-mQqxOz9myednP8hd .label text,#mermaid-svg-mQqxOz9myednP8hd span{fill:#333;color:#333;}#mermaid-svg-mQqxOz9myednP8hd .node rect,#mermaid-svg-mQqxOz9myednP8hd .node circle,#mermaid-svg-mQqxOz9myednP8hd .node ellipse,#mermaid-svg-mQqxOz9myednP8hd .node polygon,#mermaid-svg-mQqxOz9myednP8hd .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-mQqxOz9myednP8hd .rough-node .label text,#mermaid-svg-mQqxOz9myednP8hd .node .label text,#mermaid-svg-mQqxOz9myednP8hd .image-shape .label,#mermaid-svg-mQqxOz9myednP8hd .icon-shape .label{text-anchor:middle;}#mermaid-svg-mQqxOz9myednP8hd .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-mQqxOz9myednP8hd .rough-node .label,#mermaid-svg-mQqxOz9myednP8hd .node .label,#mermaid-svg-mQqxOz9myednP8hd .image-shape .label,#mermaid-svg-mQqxOz9myednP8hd .icon-shape .label{text-align:center;}#mermaid-svg-mQqxOz9myednP8hd .node.clickable{cursor:pointer;}#mermaid-svg-mQqxOz9myednP8hd .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-mQqxOz9myednP8hd .arrowheadPath{fill:#333333;}#mermaid-svg-mQqxOz9myednP8hd .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-mQqxOz9myednP8hd .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-mQqxOz9myednP8hd .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-mQqxOz9myednP8hd .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-mQqxOz9myednP8hd .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-mQqxOz9myednP8hd .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-mQqxOz9myednP8hd .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-mQqxOz9myednP8hd .cluster text{fill:#333;}#mermaid-svg-mQqxOz9myednP8hd .cluster span{color:#333;}#mermaid-svg-mQqxOz9myednP8hd div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-mQqxOz9myednP8hd .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-mQqxOz9myednP8hd rect.text{fill:none;stroke-width:0;}#mermaid-svg-mQqxOz9myednP8hd .icon-shape,#mermaid-svg-mQqxOz9myednP8hd .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-mQqxOz9myednP8hd .icon-shape p,#mermaid-svg-mQqxOz9myednP8hd .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-mQqxOz9myednP8hd .icon-shape .label rect,#mermaid-svg-mQqxOz9myednP8hd .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-mQqxOz9myednP8hd .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-mQqxOz9myednP8hd .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-mQqxOz9myednP8hd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 扣减成功
库存不足
用户
网关限流
按用户/IP
秒杀服务
Redis Lua
原子扣减库存
消息队列
直接返回已抢光
订单服务
异步创建订单
MySQL
条件更新兜底
第一层:Redis 原子预扣,同时完成"防重复购买"和"扣库存":
lua
-- KEYS[1] 库存 key;KEYS[2] 已购用户集合;ARGV[1] 用户ID;ARGV[2] 购买数量
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
return -1 -- 重复购买
end
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local n = tonumber(ARGV[2])
if stock < n then
return 0 -- 库存不足
end
redis.call('DECRBY', KEYS[1], n)
redis.call('SADD', KEYS[2], ARGV[1])
return 1
第二层:数据库条件更新兜底,即使 Redis 数据出现偏差,也绝不超卖:
sql
UPDATE seckill_stock
SET available = available - #{n}, version = version + 1
WHERE sku_id = #{skuId} AND available >= #{n};
-- 影响行数为 0 则扣减失败,需要回补 Redis 并通知用户
第三层:异步下单。Redis 扣减成功后发送消息,订单服务按自身处理能力消费,前端轮询下单结果。这样数据库承受的是消费速率(例如每秒 2000),而不是用户的请求速率(例如每秒 5 万)。
还需要处理的细节:用户下单后超时未支付,要通过延时消息关闭订单并回补库存;Redis 与数据库库存每隔几分钟对账一次。
七、高可用:可用性是可以计算的
7.1 串联与并联
- 串联 :链路上所有组件都可用,整体才可用。
A = A1 × A2 × ... × An。5 个 99.9% 的服务串联,整体约 99.5%,即每年多出约 40 小时的不可用时间; - 并联(冗余) :任意一个可用即可。
A = 1 − (1 − A1) × (1 − A2)。两个 99% 的实例并联,整体可达 99.99%。
这两个公式说明了两件事:同步依赖越多,整体可用性越低;冗余是提升可用性最直接的手段。所以核心链路要尽量减少同步依赖(能异步就异步),每个依赖都要多实例、跨可用区部署。
7.2 隔离:舱壁模式
- 线程池隔离:核心接口和非核心接口使用不同的线程池,推荐接口变慢不会耗尽下单的线程;
- 集群隔离:大客户、内部运营后台与 C 端流量分开部署;
- 数据隔离:报表查询走只读从库或数仓,绝不能在主库上跑大查询。
7.3 故障恢复比故障预防更重要
可用性 = MTBF / (MTBF + MTTR)。在故障不可避免的前提下,缩短恢复时间(MTTR)的收益通常比减少故障次数更大:
- 发现:告警基于 SLO 和用户可感知指标(错误率、延迟),而不是只盯着 CPU;
- 定位:全链路追踪 + 统一日志,能在几分钟内定位到具体服务和实例;
- 恢复:发布可一键回滚,降级开关、限流阈值可秒级调整,故障实例自动摘除。
八、大促前检查清单
- 各核心接口是否都有基于业务数据的峰值 QPS 估算?
- 是否完成全链路压测,拐点 QPS 是多少,扩容后是否复测?
- 线程池、连接池是否按利特尔法则核算过,而不是用默认值?
- 每个远程调用是否都设置了超时,超时是否逐层递减?
- 是否只在一层重试,并有退避、抖动和重试预算?
- 限流是否覆盖接入层、应用层、业务层,阈值是否可动态调整?
- 功能是否完成 P0/P1/P2 分级,降级预案是否演练过?
- 缓存是否处理了穿透、击穿、雪崩,锁的释放是否校验持有者?
- 库存、资金类操作是否以数据库条件更新兜底?
- 回滚、降级、扩容的操作手册是否就绪,值班人员是否明确?
九、总结
高并发与高可用的设计,归根结底是三件事:
- 算清楚容量:用业务数据估峰值,用利特尔法则定资源,用压测找拐点;
- 控制住故障的传播:超时逐层递减,重试只在一层,熔断要有足够样本,降级要提前分级和演练;
- 保证数据的正确性:缓存用版本号 + 墓碑防止旧值回写,资金和库存的最终判断永远落在数据库的条件更新上。
回到开头的问题:"阈值设多少"看压测拐点,"线程池开多大"用利特尔法则算,"重试几次"取决于调用链有几层、是否幂等。没有一个数字是可以照抄的,但每一个数字都应该有推导过程。
秒杀中"Redis 扣减成功后异步下单"依赖消息队列的可靠投递和幂等消费,这部分在下一篇 事件驱动架构实战:可靠投递、幂等消费、消息顺序与MQ选型 中展开;服务部署到 K8s 后,探针、优雅停机和弹性伸缩如何配合限流降级,见 云原生架构落地实践:Kubernetes 生产级配置与 Service Mesh 流量治理。
参考资料
- Google SRE Book: Handling Overload(重试预算、按客户端限流)
- Google SRE Book: Addressing Cascading Failures(超时、截止时间传递、级联故障)
- Release It! Second Edition(Michael T. Nygard,熔断器、舱壁模式)
- Timeouts, retries, and backoff with jitter(Marc Brooker,AWS Builders' Library)
- Little's Law(利特尔法则)
- Resilience4j 官方文档(CircuitBreaker 配置说明)
- Alibaba Sentinel(流量控制、熔断降级)
- Redis 官方文档:Scripting with Lua(脚本原子性与复制)
- Caffeine(本地缓存)
- Canal(基于 Binlog 的增量订阅)