"加了 Redisson 锁,库存仍被并发打穿"常见根因不是锁互斥失效,而是锁在数据库事务提交前释放。下一位持锁者可能读到旧快照,形成丢失更新。本文拆解 Spring 代理的时间线,并给出锁覆盖事务、数据库条件更新、唯一约束与幂等键四层防线。
先画时间线,再看代码
假设 @Transactional 标在业务方法上,而锁在方法体内部获取和释放:
text
事务 T1 开始
获取锁 L
读取库存 1,更新为 0
释放锁 L
事务 T1 提交
@Transactional 通常由外层代理开启并提交事务。方法体里的 finally 先执行,代理只有在方法返回后才提交。于是另一个线程可能在 T1 提交前获得 L,并按数据库隔离级别与查询方式读到旧值。锁确实互斥了,但保护区少包了一段关键时间。
方案一:让锁包住完整事务
把"持锁"与"事务方法"拆到不同 Bean,避免同类自调用绕过代理:
java
@Service
public class ResourceFacade {
private final RedissonClient redisson;
private final ResourceTxService txService;
public ResourceFacade(RedissonClient redisson, ResourceTxService txService) {
this.redisson = redisson;
this.txService = txService;
}
public void consume(String resourceId, String requestId) {
RLock lock = redisson.getLock("resource:" + resourceId);
boolean acquired = false;
try {
acquired = lock.tryLock(2, 15, TimeUnit.SECONDS);
if (!acquired) throw new BusyException();
txService.consumeInTransaction(resourceId, requestId);
// txService 返回时,其代理已提交或回滚。
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BusyException();
} finally {
if (acquired && lock.isHeldByCurrentThread()) lock.unlock();
}
}
}
@Service
class ResourceTxService {
@Transactional(rollbackFor = Exception.class)
public void consumeInTransaction(String resourceId, String requestId) {
// 数据库读写与请求去重
}
}
显式 leaseTime 要大于合理的事务上界,否则业务尚未完成锁就过期;使用自动续期机制时,也要验证进程停顿、网络分区和节点故障下的行为。分布式锁不是数据库约束的替代品。
方案二:把不变量交给数据库
库存不能为负是数据库最擅长维护的条件。单条条件更新通常比"先查、再改"更可靠:
sql
UPDATE resource
SET stock = stock - 1,
version = version + 1
WHERE id = :id
AND stock > 0;
应用必须检查影响行数:
java
int changed = mapper.decrementIfAvailable(resourceId);
if (changed != 1) {
throw new SoldOutException(resourceId);
}
这条语句把检查和修改放在同一个原子操作中。对于"一个用户只能领取一次",再加唯一约束:
sql
ALTER TABLE coupon_claim
ADD CONSTRAINT uk_coupon_user UNIQUE (coupon_id, user_id);
并发控制的目标不是避免异常,而是让违反不变量的请求以可识别方式失败。
方案三:为重试建立幂等语义
网关超时、客户端重试、消息重复投递都可能让同一意图执行多次。用业务请求号建立唯一记录:
sql
CREATE TABLE operation_receipt (
request_id VARCHAR(64) PRIMARY KEY,
resource_id VARCHAR(64) NOT NULL,
result_code VARCHAR(32) NOT NULL,
created_at TIMESTAMP NOT NULL
);
首次请求在同一事务内写入回执和业务变更;重复请求读取已有结果。不要在事务提交前把"成功"缓存到 Redis,否则缓存与数据库可能互相矛盾。
什么时候仍需要分布式锁
跨多行、多表或外部资源的复杂临界区仍可能需要锁,但应遵循三个原则:锁粒度对应业务实体;数据库保留最终约束;记录 request_id、锁等待时间、持锁时间、更新行数和提交结果。若发生事故,这些字段能回答究竟是锁提前过期、事务回滚、重复请求,还是查询隔离造成的误判。
还要警惕:Redis 故障转移期间的锁语义、超长 GC 停顿、锁键拼接冲突、锁服务与数据库之间没有原子提交,以及解锁失败。涉及高价值资产时,应评估数据库悲观锁、乐观锁或带 fencing token 的设计,而不是仅增加锁超时时间。
验证清单
- 用两个独立连接控制"更新后未提交"的窗口,验证第二个请求不能读旧值后成功扣减。
- 并发发送相同
request_id,断言只有一条业务变更。 - 在持锁期间注入超时、线程中断和事务回滚。
- 压测后检查库存不变量、唯一约束和回执数量,而不只看接口成功率。
- 将锁等待与数据库提交耗时分开监控,避免平均值掩盖长尾。
总结
分布式锁只保证它覆盖范围内的互斥。若事务提交发生在解锁之后,代码看似加锁,业务不变量仍然暴露。正确顺序是先定义数据库不变量,再用原子更新和唯一约束兜底,以幂等应对重试,最后才用锁协调复杂临界区。