Skip to content

并发与限流

并发的本质不是"多个线程同时跑",而是多个访问者同时读写同一份可变状态。没有共享状态就没有竞态,没有外部依赖的突发流量就需要限流。先把问题归类,再选手段,否则容易要么滥用锁把系统拖慢,要么堆限流策略却挡不住真正的故障。

先分清并发问题的类型

类型典型现象解决方向
数据竞态两个请求同时改库存,扣成负数互斥 / 原子操作 / 数据库约束
状态不一致读到写了一半的中间状态隔离级别 / 事务 / 内存可见性
资源争抢连接池打满、数据库被压垮限流 / 排队 / 熔断
顺序依赖任务 B 必须在 A 完成后执行队列 / 显式编排

判断不清时先问:这是"算错了"还是"扛不住"。算错了是正确性问题,靠锁和事务;扛不住是容量问题,靠限流和降级。两者混着治,往往两边都不见效。

竞态条件:错误的根源

竞态发生的条件是"读-改-写"不是原子的:

text
时刻1  请求A 读到库存 = 1
时刻2  请求B 读到库存 = 1
时刻3  请求A 写入库存 = 0,下单成功
时刻4  请求B 写入库存 = 0,下单成功   ← 超卖了

只要"判断"和"修改"中间留了空隙,并发就可能钻进来。能消除空隙就不需要锁,消除不了再上锁。

锁与替代方案

手段适用场景代价
互斥锁临界区明确、持锁时间短阻塞、可能死锁
CAS / 原子操作单变量更新、冲突少冲突多时反复重试
乐观锁(版本号)读多写少、可重试冲突时需客户端重做
单线程串行(队列/Actor)强依赖顺序、状态集中吞吐受单点限制
数据库行锁 / SELECT FOR UPDATE跨进程的一致性锁等待、死锁风险

能用 CAS 和乐观锁就别用悲观锁。乐观锁的典型写法是带版本号更新:

sql
-- 失败说明期间有人改过,由应用层重试
UPDATE account SET balance = balance - 100, version = version + 1
WHERE id = 42 AND version = 7;

这条 SQL 把"读-改-写"压进一条语句,空隙消失了,也就不需要应用层加锁。

数据库行锁与隔离级别

并发一致性最终往往落到数据库。隔离级别决定了"并发执行时能看到什么":

隔离级别能防止仍可能出现
读未提交脏读
读已提交(RC)脏读不可重复读、幻读
可重复读(RR)不可重复读部分数据库仍有幻读
串行化(Serializable)几乎所有异常吞吐大幅下降

隔离级别越高,正确性越稳,但并发吞吐越低。不要无脑拉到最高。

对"同一行不能被两个事务同时改"的需求,用 SELECT ... FOR UPDATE 加行锁即可,不必把整个库的隔离级别调到串行化。业务里真正需要强隔离的场景(转账、对账)远比想象中少。

限流:保护下游而不是惩罚用户

限流解决的是容量问题,目标不是拒绝请求,而是让系统在过载时还能活着服务。三种常见算法各有定位:

算法特点适合
固定窗口实现简单粗粒度统计
滑动窗口平滑、无边界突刺API 配额、按用户限速
令牌桶允许短时突发入口网关、有波动的流量
漏桶强制匀速出队保护下游、平滑调用外部 API

令牌桶和漏桶的区别要记住:令牌桶允许攒够令牌后短暂突发,漏桶强制匀速。保护数据库这种怕突刺的下游,漏桶更稳;面向用户 API 配额,令牌桶更友好。

text
按谁限流:
  全局限流   → 防止整体过载
  按用户     → 防止单人打满
  按接口     → 防止昂贵接口被滥用
  按下游配额 → 防止打爆依赖的外部服务

限流维度选错,等于没限。最容易漏的是"按下游配额"——你调用的第三方只给你 100 QPS,你却在入口按全局 1000 QPS 限流,结果自己没超限却把对方打挂。

熔断与降级

限流是"我还能服务多少",熔断是"我是否该停止尝试"。当下游持续失败时,继续重试只会把双方都拖死:

text
熔断器三态:
  Closed   正常调用,统计失败率
  Open     失败率超阈值,直接拒绝/走降级,不再请求下游
  Half-Open  探测性放行少量请求,成功则恢复

熔断的判断维度通常是"失败率 + 时间窗口",触发后必须配一个降级方案:返回缓存、返回默认值、返回部分结果。没有降级的熔断只是把错误换了个形态,用户体验并没有变好。

何时串行,何时并行

这是一个经常被忽略的决策:

场景选择原因
多个独立、无依赖的任务并行缩短总耗时
依赖前一步结果的链路串行必须等
强一致写入(扣款、对账)串行化关键路径正确性优先
大量同类请求汇总批处理 / 队列平滑压力

把本可并行的请求强行串行是性能浪费,把本该串行的写入强行并发是 bug 温床。判断依据始终是:它们之间有没有共享状态或顺序依赖

务实的检查清单

  • 先确认问题是正确性(竞态)还是容量(过载),对症下药。
  • 能用单条带条件的 SQL 解决,就别在应用层加锁。
  • 乐观锁优先于悲观锁,重试逻辑要明确写到客户端。
  • 限流维度要覆盖"全局 / 用户 / 接口 / 下游配额"四层,尤其别漏下游。
  • 熔断必须配降级方案,否则只是换个错误形态。
  • 串行还是并行,看有没有共享状态或顺序依赖,而不是看"感觉哪个快"。

为复用而记录,为理解而整理。