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

"加了 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. 将锁等待与数据库提交耗时分开监控,避免平均值掩盖长尾。

总结

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

相关推荐
数商云企2 小时前
2026年陕西软件开发首选数商云企AI微入口小程序定制方案
人工智能·小程序
吴佳浩2 小时前
FDE:从系统落地工程师,演变为企业 AI 能力的知识架构师
人工智能·llm·ai编程
吴佳浩2 小时前
从 OpenClaw、Codex 到 Hermes,看懂 AI Agent 架构为什么正在收敛
人工智能·llm·agent
hanbon2 小时前
标书制作流程与技巧:从读标到装订
人工智能·招投标·ai写标书·技术标
知识分享小能手3 小时前
深度学习学习教程,从入门到精通,深度学习中的正则化 — 完整知识点与代码示例(7)
人工智能·深度学习·学习
小小猪的春天3 小时前
Java 手写第一个 MCP Server:Spring AI MCP 半小时跑通
java·人工智能·spring boot·ai编程
TechEdu2026063 小时前
[人工智能]国内国外大型语言模型技术比较指南V02(2026.9月)
人工智能·ai
AI人工智能集结号3 小时前
2026年9月GEO优化与传统SEO怎么选?预算应该先投向哪一个?
人工智能·geo优化
console.log('npc')3 小时前
Git 冲突与 AI 协助指南
前端·人工智能·git·大模型
LaughingZhu3 小时前
Product Hunt 每日热榜 | 2026-09-05
人工智能·深度学习·神经网络·搜索引擎·百度