Skip to content

分布式锁与并发控制实战:锁该加在哪、什么时候可以不加 ​

同样一把锁,加在事务里面和外面,结果完全不同。实测 200 个用户、每人 20 个线程同时下单:锁在事务提交前释放,有 22 个用户下出了重复订单;锁在提交后释放,一个都没有。

上一篇讲了三种分布式锁的实现,这篇讲用法:锁和事务谁包住谁、防重复提交怎么做、以及哪些场景其实不需要锁。

一、先说结论 ​

  • 锁必须包住整个事务:加锁 → 开启事务 → 业务 → 提交 → 释放锁。提前释放会让后一个线程读不到未提交的数据。
  • 实测差别很直接:200 个用户各 20 个线程并发下单,锁在事务内释放时 22 个用户出现重复订单,锁在事务外释放时没有;锁放错了位置,唯一键仍能兜住。
  • 防重复提交分三层:前端置灰(体验)、Token 或幂等键(拦截大部分)、数据库唯一索引(最终保证)。只有最后一层是可靠的。
  • 能用一条 SQL 表达的并发控制,就不要用锁:条件更新、INSERT ... ON DUPLICATE KEY、乐观锁版本号,都比分布式锁更可靠也更快。
  • 锁的粒度要贴着业务键:锁用户、锁订单、锁 SKU,而不是锁整个接口。

二、锁加在事务外面还是里面 ​

2.1 实测 ​

场景:同一个用户重复提交下单请求,业务逻辑是「查一下有没有订单,没有就插入」。

java
// 错误:锁在事务内部,提交前就释放了
connection.setAutoCommit(false);
lock.lock();
if (countOrders(userId) == 0) {
    insertOrder(userId);
}
lock.unlock();          // ← 提前释放
connection.commit();
java
// 正确:锁包住整个事务
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 模拟,排除锁实现本身的干扰:

写法订单数出现重复订单的用户
锁在事务内部(提交前释放)22322
锁在事务外部(提交后释放)2000
锁在事务内部,但 user_id 有唯一键2000(唯一键拒绝了 61 次插入)

重复的用户数取决于线程调度,每次运行不一样(另一次是 28 个),但两次都远不是 0:锁在提交前释放,就留下了一段别人能拿到锁、却看不到这条插入的窗口。第三行说明了为什么数据库约束要留着:锁写错了,唯一键仍然挡住了重复插入,代价是后到的请求收到重复键错误,要按「已下单」处理。

2.2 为什么 ​

线程 A线程 B加锁查询:无订单插入订单解锁提交加锁成功查询查不到 A 未提交的订单 → 再插入一条实测:200 个用户各 20 个线程下单,锁在提交前释放有 22 个用户重复下单,包住事务时 0 个
图 3 · 锁必须在事务提交之后释放;提前释放时,后一个线程查不到未提交的数据,会再插入一条

线程 A 插入订单后、提交前就释放了锁。线程 B 立刻拿到锁,此时 A 的插入还没提交,在 RR 隔离级别下 B 的快照读看不到这条记录,于是 B 认为「没有订单」,又插入了一条。

这和 InnoDB MVCC 里的规则是一致的:未提交的数据对其他事务不可见。锁释放得比事务提交早,就等于把「可见性还没建立」的那段时间暴露给了下一个竞争者。

用 Spring 声明式事务时,这个顺序很容易写反:

java
// 错误:@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,提交时消费它:

java
// 申请:存入 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 业务唯一键 + 唯一索引 ​

真正兜底的是数据库约束。给业务上的唯一含义建唯一索引:

sql
ALTER TABLE `order` ADD UNIQUE KEY uk_request_id (request_id);

request_id 由客户端生成并在重试时保持不变(幂等键)。插入冲突时捕获异常,返回已有订单:

java
try {
    orderMapper.insert(order);
} catch (DuplicateKeyException e) {
    return orderMapper.selectByRequestId(order.getRequestId());   // 幂等返回
}

如果业务本身没有天然的唯一键,就用「用户 + 场景 + 时间窗口」构造一个,比如同一用户对同一商品 5 秒内只能下一单,可以把 user_id + sku_id + 时间窗口编号 作为唯一键。

逻辑删除场景下的唯一索引有个坑(NULL 不参与唯一性比较),见 表设计里的三个细节。

四、能不用锁就不用锁 ​

4.1 条件更新代替「先查后改」 ​

状态流转是最典型的例子。订单支付成功和超时关闭可能同时发生,两个线程各自「查状态 → 判断 → 更新」:

sql
-- 危险:查询和更新之间状态可能已经变了
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 表示别人已经改过了。

java
int changed = orderMapper.markPaid(orderId);
if (changed == 0) {
    // 没抢到:可能已被关闭,也可能已经支付过,按业务决定是退款还是幂等返回
    handleConflict(orderId);
}

4.2 乐观锁版本号 ​

需要更新多个字段、且能接受失败重试时,用版本号:

sql
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 停顿面前都不可靠,而唯一索引不会骗人。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权