高并发高可用系统设计:限流、熔断、降级与多级缓存实战指南

目录

适合谁读:负责核心交易链路、需要为大促或流量高峰做准备的后端工程师和架构师。

解决什么问题 :限流、熔断、降级、缓存这些词大家都听过,但真正在大促前被问到"阈值设多少""线程池开多大""重试几次"时,很多人只能拍脑袋。本文以一次电商大促备战为主线,把这些问题算清楚,并给出可直接使用、修正过常见 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

相比网上常见的版本,这里做了三处修正:

  1. 用 Redis 的 TIME 而不是客户端传入时间 ,避免多实例时钟漂移导致令牌计算错乱(Redis 5.0 起默认按"效果复制",脚本中调用 TIME 是安全的);
  2. HMSET 已被官方标记为废弃,改用 HSET 多字段写法;
  3. 支持一次申请多个令牌,可用于按"商品件数"限流。

注意:分布式限流每次请求都要访问 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 倍的重试流量会让它彻底无法恢复,这就是重试风暴。正确做法:

  1. 只在一层重试,通常是最靠近失败点的那一层;
  2. 只重试幂等操作和明确可重试的错误(连接失败、503),不重试 4xx 和超时不确定的写操作;
  3. 指数退避 + 随机抖动,避免所有客户端同时重试;
  4. 重试预算:重试请求占比不超过正常请求的 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 锦上添花 推荐、评价、浏览足迹、积分实时展示 可直接关闭

降级开关的工程要点:

  1. 开关放在配置中心,秒级生效,支持按比例/按用户灰度;
  2. 预先准备"大促模式"一键预案,一次切换一组开关;
  3. 每次大促前必须演练:从未打开过的开关,真打开时很可能触发未知 Bug;
  4. 降级本身要有监控:被降级的请求量是多少、是否影响转化率。

五、多级缓存:正确性比命中率更重要

5.1 三类问题的根因与解法

问题 根因 解法
穿透 查询的数据根本不存在 缓存空值(短 TTL)、布隆过滤器
击穿 单个热点 key 过期瞬间并发回源 互斥锁重建、逻辑过期异步刷新
雪崩 大量 key 同时过期,或缓存集群故障 TTL 加随机值、多级缓存、熔断保护数据库

5.2 互斥锁重建:修正常见的三个 Bug

网上流行的互斥锁写法通常有三个问题:

  1. 释放锁时直接 DEL,可能删掉别人的锁(自己的锁已超时过期、被另一个线程获得);
  2. 获取锁失败后递归调用,高并发下可能栈溢出;
  3. 没有重试上限,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 的写库 + 删缓存"还慢,概率很低但不为零。常见的"延迟双删"只是缩小窗口,并不能消除它。要彻底关闭这个窗口,关键是让缓存能识别"旧值":

  1. 数据表增加单调递增的 version 列,每次更新 version = version + 1;
  2. "删除缓存"改为"写入墓碑":写入一个只带新版本号、没有数据的占位记录,TTL 与正常缓存相同;
  3. 回源写缓存时带上读到的版本号,只有版本不低于缓存中的版本时才允许写入,比较和写入在同一个 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 分级,降级预案是否演练过?
  • 缓存是否处理了穿透、击穿、雪崩,锁的释放是否校验持有者?
  • 库存、资金类操作是否以数据库条件更新兜底?
  • 回滚、降级、扩容的操作手册是否就绪,值班人员是否明确?

九、总结

高并发与高可用的设计,归根结底是三件事:

  1. 算清楚容量:用业务数据估峰值,用利特尔法则定资源,用压测找拐点;
  2. 控制住故障的传播:超时逐层递减,重试只在一层,熔断要有足够样本,降级要提前分级和演练;
  3. 保证数据的正确性:缓存用版本号 + 墓碑防止旧值回写,资金和库存的最终判断永远落在数据库的条件更新上。

回到开头的问题:"阈值设多少"看压测拐点,"线程池开多大"用利特尔法则算,"重试几次"取决于调用链有几层、是否幂等。没有一个数字是可以照抄的,但每一个数字都应该有推导过程。

秒杀中"Redis 扣减成功后异步下单"依赖消息队列的可靠投递和幂等消费,这部分在下一篇 事件驱动架构实战:可靠投递、幂等消费、消息顺序与MQ选型 中展开;服务部署到 K8s 后,探针、优雅停机和弹性伸缩如何配合限流降级,见 云原生架构落地实践:Kubernetes 生产级配置与 Service Mesh 流量治理。

参考资料

相关推荐
DLYSB_1 小时前
门店断网了告警还在吗:边缘站点的缓存与续传架构
缓存·架构·报警灯
AIGC小尼1 小时前
本地 AI 漫剧流水线模型缓存与特征向量持久化:IPAdapter 预编码、特征池复用与内存池管理实战
人工智能·spring boot·缓存·预编码算法·comfyui·ai漫剧
风123456789~2 小时前
【架构设计】习题1 数据库题目 2/2
架构设计·数据库习题
论文复现现场4 小时前
DeepSeek V4.1 Flash 各平台价格怎么比?缓存、峰谷计费与 Python 成本计算
开发语言·python·缓存·大模型·成本优化·deepseek
豆角焖肉4 小时前
SpringBoot + Redis 缓存实战
java·spring boot·redis·后端·缓存·菜单缓存
蓝胖的四次元口袋19 小时前
Redis知识梳理(1)
数据库·redis·缓存
光依旧19 小时前
发版就掉请求?Spring Boot优雅停机的四步
运维·spring boot·nginx·高可用·优雅停机·mcp server
wefg120 小时前
【Redis】C++ 自定义客户端
数据库·redis·缓存
一个有温度的技术博主1 天前
两级缓存一致性实战:Redis + 本地缓存,如何不让数据“分裂”?
redis·spring·缓存