并发与限流
并发的本质不是"多个线程同时跑",而是多个访问者同时读写同一份可变状态。没有共享状态就没有竞态,没有外部依赖的突发流量就需要限流。先把问题归类,再选手段,否则容易要么滥用锁把系统拖慢,要么堆限流策略却挡不住真正的故障。
先分清并发问题的类型
| 类型 | 典型现象 | 解决方向 |
|---|---|---|
| 数据竞态 | 两个请求同时改库存,扣成负数 | 互斥 / 原子操作 / 数据库约束 |
| 状态不一致 | 读到写了一半的中间状态 | 隔离级别 / 事务 / 内存可见性 |
| 资源争抢 | 连接池打满、数据库被压垮 | 限流 / 排队 / 熔断 |
| 顺序依赖 | 任务 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 解决,就别在应用层加锁。
- 乐观锁优先于悲观锁,重试逻辑要明确写到客户端。
- 限流维度要覆盖"全局 / 用户 / 接口 / 下游配额"四层,尤其别漏下游。
- 熔断必须配降级方案,否则只是换个错误形态。
- 串行还是并行,看有没有共享状态或顺序依赖,而不是看"感觉哪个快"。