分布式锁与并发控制实战:锁该加在哪、什么时候可以不加
同样一把锁,加在事务里面和外面,结果完全不同。实测 200 个用户、每人 20 个线程同时下单:锁在事务提交前释放,有 22 个用户下出了重复订单;锁在提交后释放,一个都没有。
上一篇讲了三种分布式锁的实现,这篇讲用法:锁和事务谁包住谁、防重复提交怎么做、以及哪些场景其实不需要锁。
一、先说结论
- 锁必须包住整个事务:加锁 → 开启事务 → 业务 → 提交 → 释放锁。提前释放会让后一个线程读不到未提交的数据。
- 实测差别很直接:200 个用户各 20 个线程并发下单,锁在事务内释放时 22 个用户出现重复订单,锁在事务外释放时没有;锁放错了位置,唯一键仍能兜住。
- 防重复提交分三层:前端置灰(体验)、Token 或幂等键(拦截大部分)、数据库唯一索引(最终保证)。只有最后一层是可靠的。
- 能用一条 SQL 表达的并发控制,就不要用锁:条件更新、
INSERT ... ON DUPLICATE KEY、乐观锁版本号,都比分布式锁更可靠也更快。 - 锁的粒度要贴着业务键:锁用户、锁订单、锁 SKU,而不是锁整个接口。
二、锁加在事务外面还是里面
2.1 实测
场景:同一个用户重复提交下单请求,业务逻辑是「查一下有没有订单,没有就插入」。
// 错误:锁在事务内部,提交前就释放了
connection.setAutoCommit(false);
lock.lock();
if (countOrders(userId) == 0) {
insertOrder(userId);
}
lock.unlock(); // ← 提前释放
connection.commit();// 正确:锁包住整个事务
lock.lock();
try {
connection.setAutoCommit(false);
if (countOrders(userId) == 0) {
insertOrder(userId);
}
connection.commit();
} finally {
lock.unlock(); // ← 提交之后才释放
}在 MySQL 8.4.11(REPEATABLE READ)上,200 个用户、每个用户 20 个线程同时下单。锁按用户加,用一把工作正常的 ReentrantLock 模拟,排除锁实现本身的干扰:
| 写法 | 订单数 | 出现重复订单的用户 |
|---|---|---|
| 锁在事务内部(提交前释放) | 223 | 22 |
| 锁在事务外部(提交后释放) | 200 | 0 |
锁在事务内部,但 user_id 有唯一键 | 200 | 0(唯一键拒绝了 61 次插入) |
重复的用户数取决于线程调度,每次运行不一样(另一次是 28 个),但两次都远不是 0:锁在提交前释放,就留下了一段别人能拿到锁、却看不到这条插入的窗口。第三行说明了为什么数据库约束要留着:锁写错了,唯一键仍然挡住了重复插入,代价是后到的请求收到重复键错误,要按「已下单」处理。
2.2 为什么
线程 A 插入订单后、提交前就释放了锁。线程 B 立刻拿到锁,此时 A 的插入还没提交,在 RR 隔离级别下 B 的快照读看不到这条记录,于是 B 认为「没有订单」,又插入了一条。
这和 InnoDB MVCC 里的规则是一致的:未提交的数据对其他事务不可见。锁释放得比事务提交早,就等于把「可见性还没建立」的那段时间暴露给了下一个竞争者。
用 Spring 声明式事务时,这个顺序很容易写反:
// 错误:@Transactional 在外层,锁在方法内部,释放锁时事务尚未提交
@Transactional
public void placeOrder(long userId) {
lock.lock();
try { ... } finally { lock.unlock(); }
}
// 正确:锁在外层,事务方法在内部
public void placeOrder(long userId) {
lock.lock();
try {
orderService.doPlaceOrder(userId); // 这个方法上标注 @Transactional
} finally {
lock.unlock();
}
}注意第二种写法中,doPlaceOrder 必须是另一个 Bean 的方法,否则自调用不会经过代理,事务根本不生效,见 Spring 事务传播。
2.3 代价
锁包住事务意味着锁的持有时间包含了事务提交的耗时,也就包含了 Redo 与 Binlog 的刷盘时间。所以要让事务尽量短:远程调用、发消息、文件操作都挪到事务之外。
三、防重复提交:三层防线
用户连点两次提交按钮、网络重试、消息重复投递,都会产生重复请求。
| 层 | 手段 | 能挡住 | 挡不住 |
|---|---|---|---|
| 前端 | 按钮置灰、防抖 | 误触 | 绕过前端的请求、重放 |
| 服务端 | 幂等 Token、分布式锁、限流 | 绝大多数并发重复 | 锁失效、Token 校验与业务之间的窗口 |
| 数据库 | 唯一索引、条件更新 | 全部 | —— |
3.1 幂等 Token
进入表单页时申请一个一次性 Token,提交时消费它:
// 申请:存入 Redis,设置合理的过期时间
String token = UUID.randomUUID().toString();
redis.opsForValue().set("idem:" + userId + ":" + token, "1", Duration.ofMinutes(10));
// 提交:用原子操作消费,只有第一次能删除成功
Long removed = redis.delete("idem:" + userId + ":" + token) ? 1L : 0L;
if (removed == 0) {
throw new DuplicateSubmitException("请勿重复提交");
}要点是消费必须是原子的:用 DEL 的返回值判断,而不是「先查再删」。
Token 方案的局限:它只能保证「同一个 Token 只被消费一次」,如果客户端重新申请了新 Token 再次提交,业务上依然是重复的。所以它挡的是「重复点击」,不是「重复下单」。
3.2 业务唯一键 + 唯一索引
真正兜底的是数据库约束。给业务上的唯一含义建唯一索引:
ALTER TABLE `order` ADD UNIQUE KEY uk_request_id (request_id);request_id 由客户端生成并在重试时保持不变(幂等键)。插入冲突时捕获异常,返回已有订单:
try {
orderMapper.insert(order);
} catch (DuplicateKeyException e) {
return orderMapper.selectByRequestId(order.getRequestId()); // 幂等返回
}如果业务本身没有天然的唯一键,就用「用户 + 场景 + 时间窗口」构造一个,比如同一用户对同一商品 5 秒内只能下一单,可以把 user_id + sku_id + 时间窗口编号 作为唯一键。
逻辑删除场景下的唯一索引有个坑(NULL 不参与唯一性比较),见 表设计里的三个细节。
四、能不用锁就不用锁
4.1 条件更新代替「先查后改」
状态流转是最典型的例子。订单支付成功和超时关闭可能同时发生,两个线程各自「查状态 → 判断 → 更新」:
-- 危险:查询和更新之间状态可能已经变了
SELECT status FROM pay_order WHERE id = ?;
UPDATE pay_order SET status = 'PAID', paid_at = NOW() WHERE id = ?;
-- 安全:把判断写进 WHERE,用影响行数判断是否成功
UPDATE pay_order SET status = 'PAID', paid_at = NOW()
WHERE id = ? AND status = 'CREATED';实测 1000 笔订单,「支付」和「关闭」两个线程逐笔处理,每一笔同时开始,模拟两条路径撞上同一笔订单:
| 写法 | 两个动作都执行过的订单 |
|---|---|
| 先查后改 | 990 |
条件更新(WHERE status = 'CREATED') | 0 |
先查后改的写法下,990 笔订单同时被打上了支付时间和关闭时间,最终状态取决于哪个 UPDATE 后执行——数据已经错了。条件更新把「判断」和「修改」合并成一条原子语句,executeUpdate() 返回 1 表示自己抢到了这次流转,返回 0 表示别人已经改过了。
int changed = orderMapper.markPaid(orderId);
if (changed == 0) {
// 没抢到:可能已被关闭,也可能已经支付过,按业务决定是退款还是幂等返回
handleConflict(orderId);
}4.2 乐观锁版本号
需要更新多个字段、且能接受失败重试时,用版本号:
UPDATE account SET balance = ?, version = version + 1
WHERE id = ? AND version = ?;影响行数为 0 表示期间有人改过,重新读取再试。适合冲突概率低的场景;冲突频繁时重试本身会成为瓶颈,这时应该考虑悲观锁或者把热点拆开(见 表设计里的三个细节 中的热点行一节)。
4.3 唯一索引代替「查重锁」
「查一下有没有,没有就插入」这个模式,加锁不如直接插入并处理冲突。数据库的唯一索引本身就是一把跨进程的锁,而且不会因为超时、网络分区而失效。
五、锁的粒度与 key 设计
- 锁业务键,不锁接口。
lock:order:create:{userId}而不是lock:orderCreate,后者会把所有用户串行化。 - key 里要带上业务含义和维度,便于排查时一眼看出锁的是什么。
- 锁的等待时间要有上限,拿不到就快速失败或降级,不要让请求线程无限等待。
- 可重入:同一线程内嵌套获取同一把锁要能成功,否则改造老代码时容易死锁。Redisson 的
RLock支持可重入;自己实现要在 value 里记录持有者和重入次数。
六、常见误区
- 「先加锁再开事务和先开事务再加锁差不多」:实测 200 个用户里有 22 个下出了重复订单,顺序错了锁就白加了。
- 「加了
@Transactional和分布式锁就绝对安全」:自调用会让事务失效,锁提前释放会让并发穿透。 - 「前端置灰就够了」:绕过前端的请求、网络重试、消息重复都挡不住。
- 「有唯一索引就不用锁了」:唯一索引保证正确性,锁减少无效工作和异常量,两者目的不同。
- 「锁的粒度越大越安全」:粒度大只是把并发变成串行,吞吐塌方,而且长时间持锁更容易超时。
小结
并发控制的优先级应该是:能用数据库约束表达的(唯一索引、条件更新、版本号)优先,其次才是分布式锁。用锁时记住两条:锁要包住整个事务,锁的 key 要贴着业务维度。最后,无论锁怎么写,数据库层都要留一道兜底——分布式锁在超时、主从切换、GC 停顿面前都不可靠,而唯一索引不会骗人。
配套实验
- codesphere-labs/distributed/lock-and-transaction:锁在事务提交前后释放、唯一键兜底、先查后改与条件更新(验证记录)
参考资料