并发问题不是只有高并发系统才会遇到。只要有多个执行单元同时读写同一份数据,就可能出现结果和预期不一致的情况。这里的执行单元可以是多个用户、多个请求、多个进程、多个定时任务,也可以是同一个服务里的多条异步任务。
常见的并发问题包括:
- 两个人同时编辑同一条资料,后提交的人覆盖了先提交的人。
- 两个订单同时扣减最后一件库存,结果都显示购买成功。
- 一个定时任务还没跑完,下一轮任务又开始执行,重复处理同一批数据。
- 多个服务同时修改账户余额,最后余额和流水对不上。
这些问题的共同点是:共享数据是可变的,而且多个执行单元同时依赖它的旧状态做决定。
处理并发通常有两条主线:
- 隔离:让不同执行单元尽量操作不同资源,减少共享数据。
- 协调:如果必须共享,就用锁、版本号、事务、唯一约束等机制协调读写顺序。
本文主要聊协调里的两种经典策略:乐观锁和悲观锁。
问题从哪里来
先看一个最常见的“丢失更新”问题。
假设系统里有一条用户资料:
id: 1
name: Alice
phone: 13800000000
version: 1员工 A 和员工 B 同时打开编辑页,都读到了 version = 1 的数据。
A 把手机号改成 13900000000,提交成功。B 过了一会儿把姓名改成 Alicia,也提交成功。如果系统只是简单地用 B 提交的整份表单覆盖数据库,那么 A 刚刚改过的手机号可能就被旧值覆盖了。
这类问题不一定会立刻报错,但它会悄悄破坏数据。并发控制要解决的就是:当多个操作同时依赖同一份旧数据时,系统应该让谁继续、让谁等待、让谁重试。
悲观锁
悲观锁的思路很直接:既然冲突可能发生,那就先把资源锁起来,别人等我处理完再说。
在数据库里,典型做法是把读取和修改放进同一个事务,并在读取时加锁:
BEGIN;
SELECT id, stock
FROM products
WHERE id = 1
FOR UPDATE;
UPDATE products
SET stock = stock - 1
WHERE id = 1;
COMMIT;FOR UPDATE 的含义可以理解为:当前事务准备修改这行数据,在事务提交或回滚之前,其他想修改这行数据的事务要等待。
悲观锁适合下面这些场景:
- 冲突概率很高,比如热点库存、热门优惠券、账户余额。
- 冲突代价很大,比如资金、配额、审批状态。
- 操作不能轻易重试,或者重试会给用户造成明显困扰。
- 必须保证同一时间只有一个执行单元处理某个资源。
悲观锁的优点是模型简单,冲突在进入核心逻辑前就被挡住了。缺点也明显:锁会降低并发能力,锁持有时间越长,等待队列越长,系统越容易被拖慢。
所以使用悲观锁时,要尽量缩短锁持有时间。不要在拿到锁之后再调用外部服务、等待用户输入、做复杂计算。锁内只做必要的读取、校验和写入。
乐观锁
乐观锁的思路相反:先假设冲突不常发生,大家都可以读;真正提交时再检查数据有没有被别人改过。
最常见的实现是版本号。每条记录都有一个 version 字段,更新时带上自己读到的版本号:
UPDATE users
SET
name = 'Alicia',
phone = '13900000000',
version = version + 1
WHERE id = 1
AND version = 1;如果这条 SQL 影响了 1 行,说明更新成功。如果影响了 0 行,说明在你提交之前,数据已经被别人更新过了。此时系统应该提示用户重新加载,或者尝试自动合并。
乐观锁适合下面这些场景:
- 冲突概率较低。
- 读多写少。
- 用户可以接受“数据已变化,请重新确认”。
- 业务允许重试。
- 不希望长时间持有数据库锁。
乐观锁的优点是并发能力好,不会阻塞读取。缺点是冲突会推迟到提交时才暴露,业务上必须处理失败后的流程。
如果只是返回一句“保存失败”,体验通常不够好。更好的做法是告诉用户:
- 这条数据已经被谁修改过。
- 修改发生在什么时候。
- 当前版本和你提交的版本有哪些差异。
- 是否允许基于最新版本重新应用你的修改。
也就是说,乐观锁不是只加一个 version 字段就结束了。真正影响体验的是冲突发生后的处理流程。
库存场景里的区别
用库存扣减来看,两种锁的差异会更清楚。
悲观锁会先锁定商品库存行,然后检查库存并扣减:
BEGIN;
SELECT stock
FROM products
WHERE id = 1
FOR UPDATE;
-- 应用层判断 stock > 0
UPDATE products
SET stock = stock - 1
WHERE id = 1;
COMMIT;同一时间只有一个事务能修改这行库存,逻辑直观,但热点商品会产生等待。
乐观锁则直接用条件更新:
UPDATE products
SET
stock = stock - 1,
version = version + 1
WHERE id = 1
AND stock > 0
AND version = 7;更新成功表示抢到了库存;更新失败表示库存不足或版本已变化,需要重新读取后再决定是否重试。
在真实系统里,库存场景常常还会配合“库存预占”或“购物车保留时间”。这其实也是一种隔离:先把库存从公共池里暂时挪到某个订单或用户名下,支付超时再释放。它不一定比锁简单,但业务表达更清晰。
死锁
悲观锁最典型的问题是死锁。
死锁一般需要几个条件同时成立:
- 多个执行单元持有不同资源。
- 每个执行单元都在等待别人手上的资源。
- 已持有的资源不会主动释放。
- 等待关系形成了环。
例如:
事务 A:锁住 user:1,等待 order:1
事务 B:锁住 order:1,等待 user:1A 等 B,B 等 A,双方都无法继续。
处理死锁有两类方式。
第一类是检测和恢复。数据库通常会检测死锁,一旦发现,就选择一个事务回滚,让另一个事务继续执行。应用层要做的是捕获这类错误,并根据业务决定是否重试。
第二类是超时控制。等待锁超过一定时间后直接失败,避免请求无限挂起。超时不是从根上消灭死锁,但能限制故障影响范围。
更重要的是预防。常见做法有:
- 统一加锁顺序:所有代码都按同样顺序申请资源,比如总是先锁用户,再锁订单。
- 减少锁范围:只锁真正需要修改的数据,不要顺手锁一大片。
- 缩短事务时间:事务里不要做网络请求、文件处理、复杂计算。
- 拆分长流程:把需要用户参与或外部服务参与的流程拆成多个状态转换,不要用一个长事务包住。
- 让锁有明确边界:知道锁保护的是哪一组资源,而不是“先锁了再说”。
锁粒度
锁粒度指的是一把锁覆盖多大的范围。
粗粒度锁覆盖范围大,比如“锁整个用户账户”。它的好处是简单,不容易漏掉相关资源;坏处是并发能力差,很多本来互不影响的操作也会互相等待。
细粒度锁覆盖范围小,比如“只锁某一条地址记录”。它的好处是并发能力好;坏处是复杂,容易出现加锁顺序不一致、资源组合不完整、死锁概率上升等问题。
实际系统通常要找一个业务核心。例如用户资料和用户地址经常一起更新,那么可以考虑以用户为核心,把相关修改都收敛到用户维度的并发控制上。这样代码会更清晰:入口统一,版本号统一,加锁顺序也更容易统一。
这件事在乐观锁里同样存在。假设用户表有 version,地址表也有 version,当用户地址变化会影响用户资料摘要时,到底检查哪一个版本?如果每个子资源都各自为战,冲突处理会很混乱。此时更好的方式可能是找出聚合根,让一组强相关资源共享一个版本边界。
不要只依赖锁
锁很重要,但它不是并发控制的全部。
很多时候,唯一约束、幂等键和状态机也能解决问题,而且更符合业务语义。
例如“同一个用户同一个活动只能领一次券”,与其在应用层先查再加锁,不如在数据库里加唯一约束:
CREATE UNIQUE INDEX uniq_coupon_claim
ON coupon_claims(user_id, campaign_id);然后直接插入。插入成功表示领取成功,唯一约束冲突表示已经领过。这种方案通常比“先查询是否领过,再决定是否插入”更可靠。
再比如支付回调可能重复到达,就应该使用幂等键和状态机:
pending -> paid -> refunded如果订单已经是 paid,重复的支付成功回调就不应该再次加余额、发货或发券。
这些手段和锁并不冲突。好的并发设计往往是组合拳:数据库约束兜底,事务保证原子性,锁控制冲突窗口,版本号处理用户编辑冲突,幂等性处理重复请求。
怎么选择
可以用下面几个问题来判断:
- 冲突频率高吗?高频冲突更偏向悲观锁、队列化或库存预占。
- 冲突代价高吗?资金、库存、权限这类场景要更保守。
- 用户能接受重试吗?能接受就可以考虑乐观锁。
- 操作耗时长吗?耗时长就不要长时间持有悲观锁。
- 是否有天然唯一约束?如果有,优先让数据库约束兜底。
- 是否会跨多个资源?如果会,要先设计统一的加锁顺序或聚合边界。
一句话总结:
- 悲观锁适合冲突高、代价高、必须串行化的场景。
- 乐观锁适合冲突低、读多写少、允许重试的场景。
- 唯一约束和幂等性适合表达“只能发生一次”的业务规则。
- 锁粒度和加锁顺序决定系统能不能长期稳定运行。
并发控制的目标不是“把所有东西都锁起来”,而是在正确的业务边界上限制冲突,让系统既不乱,也不慢。