分布式锁没有失效:真正危险的是锁与事务的提交边界

"加了 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 的设计,而不是仅增加锁超时时间。

验证清单

  1. 用两个独立连接控制"更新后未提交"的窗口,验证第二个请求不能读旧值后成功扣减。
  2. 并发发送相同 request_id,断言只有一条业务变更。
  3. 在持锁期间注入超时、线程中断和事务回滚。
  4. 压测后检查库存不变量、唯一约束和回执数量,而不只看接口成功率。
  5. 将锁等待与数据库提交耗时分开监控,避免平均值掩盖长尾。

总结

分布式锁只保证它覆盖范围内的互斥。若事务提交发生在解锁之后,代码看似加锁,业务不变量仍然暴露。正确顺序是先定义数据库不变量,再用原子更新和唯一约束兜底,以幂等应对重试,最后才用锁协调复杂临界区。

相关推荐
哈尔ai1 小时前
JavaScript 异步竞态治理:先定义谁能写,再谈取消请求
人工智能
jinggongszh1 小时前
MES\WMS软件前端开发工程师:与AI协同前行,解锁前端开发新范式
人工智能·前端开发·开发工程师·制造业转型
亚古数据1 小时前
亚古数据:新西兰公司工商报告Company Extract全解析
大数据·人工智能
AKAMAI1 小时前
Akamai Cloud Pulse 审计日志现已全面开放使用
人工智能·云计算
皮皮蟹虾饺1 小时前
NCCL 源码解析:通信器从出生到消亡的完整一生
linux·人工智能·ubuntu·语言模型·kubernetes
m4Rk_1 小时前
【论文阅读】Agent 记忆机制(41):Agentic Plan Caching——将历史执行轨迹转化为可复用的计划记忆
论文阅读·人工智能·学习·开源·github
小赵AI手记2 小时前
技术拆解(十七)具身智能:机器人动作生成为何走向Diffusion Policy?
人工智能·笔记·python·机器人
CoovallyAIHub2 小时前
系统越上越多、问题越查越慢,制造业厂长的真实痛点
人工智能·agent·数据可视化
xcLeigh2 小时前
AI内容检测:如何判断一篇文章是否为AI生成
人工智能·ai·提示词·灵感写作