并发与限流
并发问题有两类,表现完全不同:
- 算错了:库存只剩 1 件,却卖出去了 2 件;点赞数明明有很多人点,数字却只涨了一点;
- 扛不住:促销开始的一瞬间,流量把数据库打满,整个系统不可用;调用的第三方接口有频率限制,结果被对方封禁。
前者是正确性问题,要靠原子操作、锁和事务解决;后者是容量问题,要靠限流、熔断和降级解决。分清楚是哪一类,才能用对办法。
读完这篇你会
- 识别“读—改—写”竞态,用原子操作消除它
- 正确使用乐观锁、数据库锁和分布式锁
- 用限流、熔断和降级保护自己和下游
1. 原理:“读—改—写”之间的空隙
超市货架上只剩最后一瓶水,两个顾客同时看到了,都拿着它去结账。如果收银台不再核对一次,就会卖出两瓶根本不存在的水。
代码里的超卖是同一个道理:
text
时刻 1 请求 A:读取库存 = 1
时刻 2 请求 B:读取库存 = 1 ← 两个请求都看到“还有货”
时刻 3 请求 A:库存 1 - 1 = 0,写入,下单成功
时刻 4 请求 B:库存 1 - 1 = 0,写入,下单成功 ← 超卖了问题出在“读取 → 计算 → 写入”这三步之间有空隙,并发的请求可以钻进来。所有竞态问题的解法,都围绕着一件事:消除这个空隙。能把三步合成一步(原子操作)就不需要锁;合不成一步,才需要用锁让大家排队。
2. 坑一:库存超卖
现象:促销活动结束后,发现卖出的数量比库存多。
js
// ❌ 读和写分开,中间有空隙
const [product] = await db.query('SELECT stock FROM products WHERE id = ?', [id])
if (product.stock < qty) throw new Error('库存不足')
await db.query('UPDATE products SET stock = ? WHERE id = ?', [product.stock - qty, id])修复:把“检查”和“扣减”合进一条带条件的 SQL,由数据库保证原子性,再根据影响的行数判断是否成功:
js
const result = await db.query(
'UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?',
[qty, id, qty]
)
if (result.affectedRows === 0) {
throw new BusinessError('库存不足') // 条件不满足,没有任何行被更新
}这条语句执行时,数据库会锁住这一行,判断和扣减一气呵成,空隙不存在了,也就不需要在应用层加任何锁。
怎么避免再犯:看到“先 SELECT 一个值,在代码里计算,再 UPDATE 回去”的写法,就要警惕。能写成 SET x = x - ? 加条件的,就不要在代码里算。
3. 坑二:计数器丢失更新
现象:文章被很多人同时点赞,最终的点赞数比实际点赞的人数少。
原因:同样是“读—改—写”:两个请求都读到 100,都写入 101,丢了一次。
修复:使用原子递增:
sql
UPDATE articles SET like_count = like_count + 1 WHERE id = ?;高频计数(阅读数、点赞数)可以先在 Redis 里用 INCR 原子递增,再定期批量写回数据库,减轻数据库压力。
4. 坑三:在代码里加了锁,部署多台服务器后又出问题了
现象:为了防止重复处理,在代码里用了一个内存锁(或者一个 Map 记录“正在处理”)。单机测试完全正常,部署到三台服务器后,重复处理的问题又出现了。
原因:内存里的锁只在当前这个进程里有效。三台服务器就是三个独立的进程,各有各的锁,彼此看不到。另外要注意,Node.js 虽然是单线程的,但 await 会让出执行权,两个请求的“读”和“写”依然可以交错执行,单线程不等于没有竞态。
修复:需要跨进程互斥时,锁必须放在所有进程共享的地方:
- 数据库:唯一约束、条件更新、
SELECT ... FOR UPDATE行锁,这是最可靠的; - Redis 分布式锁:适合不涉及数据库的互斥场景。
使用 Redis 分布式锁有两个常见错误:
js
const token = randomUUID()
// ✅ NX:键不存在才设置;PX:自动过期,防止持锁进程崩溃后锁永远不释放
const ok = await redis.set(`lock:order:${orderNo}`, token, 'NX', 'PX', 10_000)
if (!ok) throw new BusinessError('正在处理中,请稍后')
try {
await processOrder(orderNo)
} finally {
// ✅ 只删除自己加的锁:用 Lua 脚本保证“比较 + 删除”是原子的
await redis.eval(
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
1, `lock:order:${orderNo}`, token
)
}- 忘记设置过期时间:持锁的进程崩溃后,锁永远不会释放;
- 直接
DEL释放锁:如果处理时间超过了过期时间,锁已经自动释放并被别的进程拿到,这时你的DEL会把别人的锁删掉。所以释放前要确认锁还是自己的。
即使这样,锁仍可能在处理完成前过期。真正关键的数据一致性,最终还是要靠数据库约束兜底。
5. 坑四:两个人同时编辑,后保存的覆盖了先保存的
现象:两位运营同时编辑同一个商品,A 改了价格,B 改了标题,B 后保存,结果 A 改的价格被覆盖回了旧值。
修复:使用乐观锁:给数据加一个版本号,更新时检查版本号没有变:
sql
UPDATE products
SET title = ?, price = ?, version = version + 1
WHERE id = ? AND version = ?; -- 提交编辑时带上打开页面时读到的版本号影响行数为 0,说明有人在你之后修改过,这时返回 409 Conflict,提示用户刷新后重新编辑,或者展示差异让用户合并。
乐观锁适合“冲突很少、发生冲突可以让用户重试”的场景。冲突非常频繁时(比如秒杀),应该用条件更新、队列串行化等方式。
6. 数据库隔离级别
事务并发执行时,“能看到别人未提交或刚提交的数据吗”由隔离级别决定:
| 隔离级别 | 防止了什么 | 仍可能出现 |
|---|---|---|
| 读未提交 | — | 读到别人还没提交的数据(脏读) |
| 读已提交 | 脏读 | 同一事务里两次读取结果不同 |
| 可重复读(MySQL 默认) | 上面两种 | 部分场景下的幻读 |
| 串行化 | 几乎所有并发异常 | 吞吐量大幅下降 |
不要为了解决某一个并发问题就提高整个数据库的隔离级别。针对具体的行,用条件更新或 SELECT ... FOR UPDATE 加行锁,代价小得多。
7. 限流:让系统在过载时还能活着
限流解决的是容量问题。它的目标不是拒绝用户,而是在流量超过承受能力时,保证一部分请求能被正常处理,而不是所有请求一起超时。
| 算法 | 特点 | 适合 |
|---|---|---|
| 固定窗口 | 每秒最多 N 次,实现最简单;窗口交界处可能出现两倍突发 | 粗粒度统计 |
| 滑动窗口 | 更平滑,没有窗口交界的突发 | 按用户、按接口的配额 |
| 令牌桶 | 以固定速度发放令牌,攒下的令牌允许短时突发 | 入口网关,流量有波动的场景 |
| 漏桶 | 不管来多快,都以固定速度处理 | 保护怕突发的下游,比如调用有频率限制的第三方 |
限流要从多个维度考虑:
text
全局限流 → 防止整个系统过载
按用户/IP → 防止单个用户(或脚本)占满资源
按接口 → 防止昂贵的接口(导出、搜索)被滥用
按下游配额 → 防止把依赖的第三方服务打爆坑五:入口限了流,还是把第三方打爆了
现象:入口网关限制了每秒 1000 个请求,系统本身运行良好,但短信服务商开始返回“请求过于频繁”,并临时封禁了账号。
原因:短信服务商只允许每秒 100 次调用。入口的限流保护的是自己,没有考虑下游的配额。
修复:调用有配额的下游时,在调用处单独限流(漏桶最合适),超出的请求放进队列慢慢发送。
被限流的请求返回 429 Too Many Requests,并通过 Retry-After 响应头告诉客户端多久后可以重试。
8. 熔断与降级:及时止损
当下游服务持续失败时,继续调用只会让请求堆积、线程和连接被占满,最终把自己也拖垮。熔断器像家里的电闸:
text
关闭(正常):正常调用,统计失败率
↓ 失败率超过阈值
打开(跳闸):直接失败或走降级逻辑,不再调用下游
↓ 等待一段时间
半开(试探):放行少量请求试探,成功就恢复正常,失败就继续跳闸熔断必须配合降级方案:返回缓存的数据、返回默认值、暂时隐藏非核心功能(比如推荐模块)。没有降级的熔断,只是把“超时错误”换成了“立即报错”,用户体验并没有变好。
排查思路
- 怀疑存在竞态:写一个脚本同时发出几十个相同的请求,看结果是否正确:
js
// 模拟 50 个用户同时购买最后 10 件商品
const results = await Promise.allSettled(
Array.from({ length: 50 }, () => fetch('/api/orders', { method: 'POST', body: payload }))
)
// 检查:成功的订单数是否恰好等于 10?数据库里的库存是否为 0 而不是负数?- 锁等待和死锁:查看数据库的锁等待信息和死锁日志(见 数据库索引与事务)。
- 过载:查看接口的响应时间分布、错误率、数据库连接池使用率,找到最先饱和的那个资源。
- 被第三方限流:统计对每个下游服务的调用频率,和对方的配额对比。
检查清单
- [ ] 先判断是正确性问题(竞态)还是容量问题(过载)
- [ ] “检查 + 修改”合并成一条带条件的原子操作,并检查影响行数
- [ ] 跨进程互斥使用数据库或 Redis,分布式锁有过期时间并只释放自己的锁
- [ ] 多人编辑的数据使用乐观锁,冲突时返回 409
- [ ] 限流覆盖全局、用户、接口和下游配额四个维度
- [ ] 熔断配有降级方案
- [ ] 用并发脚本验证过关键接口在并发下的正确性