
凌晨两点半手机屏幕亮起来一看是值班同事的来电我心里就咯噔一下。果然电话那头声音都变了“借据状态乱了同一笔借据被还了两次款现在账目对不上赶紧上来看看吧。”那个晚上我在生产环境里跟一只“看不见的锁”搏斗到天亮也第一次真正意识到在信贷系统里借据的并发安全不是靠“小心一点”就能保证的而是要靠一套设计严谨的锁机制来兜底。先说下背景。我所在的团队负责公司信贷核心系统的借据模块所谓“借据锁”本质上就是针对借据记录I/O 单的并发控制机制。借据是借款人每一笔贷款的核心载体它的状态流转生效、部分还款、结清、逾期、核销涉及放款、还款、展期、转让、资产处置等多个业务动作。如果同一个借据在毫秒级的时间窗口内被两个请求同时修改轻则数据错乱重则资金损失——这不是危言耸听。我写的这套“基于 Java 开发的借据锁”就是用来解决这个核心矛盾的。它是信贷业务里几乎绕不开的刚需组件也是一个业务开发岗位很容易踩坑的技术点。如果你是 Java 开发工程师尤其在做支付、信贷、账务、库存这类强一致性的业务系统这篇文章值得看完。1. 为什么要给借据上锁并发场景拆解与业务痛点1.1 借据状态机为什么它这么容易被并发修改先理解借据是什么。一笔贷款审批通过后系统会建立一个借据记录借款金额、期限、利率、还款计划等关键要素。此后借据的每一次变化都是一个状态迁移事件比如正常还款会把余额从 10000 元变成 8000 元提前结清会把状态从“正常”变成“结清”。问题在于这些状态迁移不是顺序发生的而是多个渠道可以并行触发的。举个例子借款人上午在手机 App 上手动还了一笔 2000 元同时银行代扣系统在上午也发起了一笔扣款 3000 元。这两个请求几乎同时到达后端都先读到借据余额 10000 元然后各自计算新的余额一个算出 8000一个算出 7000。最后谁后提交谁就覆盖掉另一个的结果——其中一笔还款的金额凭空消失了账户余额跟实际还款流水对不上。这就是典型的“丢失更新”问题。更可怕的是状态类操作一个请求把借据从“正常”改成“结清”另一个请求在同一个毫秒里把它改成“逾期”。两条分支逻辑都执行成功了最后库里的状态取决于谁后提交但业务流水里却记录了两个完全矛盾的操作结果。1.2 三个真实业务场景锁必须覆盖的并发入口我从生产事故里总结出三类最典型的并发“撞车”场景这套借据锁的设计一开始就是冲着它们去的。第一个是“多端还款并发”。手机银行、柜台、代扣、聚合支付平台可能同时处理同一笔借据的还款。尤其是每月还款日当天自动扣款和手动还款的并发概率极高。如果没有锁就会出现上面说的重复扣款或重复入账。第二个是“业务动作交叠”。比如借据展期操作和提前还款操作同时进行。展期要改还款计划、计息方式提前还款要改剩余本金和结清状态两个事务在数据库层面操作同一行记录或者同一组记录互相干扰。第三个是“后台批处理与前台实时操作并发”。月末跑批程序批量处理逾期借据生成罚息、调整状态运营人员在工单系统里手工处理某笔借据的减免息或纠错操作。跑批是长事务实时操作是短事务两者之间没有锁控制时往往会出现“批处理读到旧数据、实时操作被覆盖”的丑态。1.3 没有锁的教训一次线上的重复还款事故复盘我想直接复盘一次真实事故方便你理解锁的紧迫性。当时生产环境使用的是普通的 Service 方法没有加任何并发控制。某借款人的还款日是周五系统自动扣款在上午 10:00:02 发起借款人在 10:00:03 手动还款。自动扣款请求 ASELECT 剩余金额 5000 元执行扣款 3000 元UPDATE 剩余金额 2000 元事务尚未提交。手动还款请求 BSELECT 剩余金额 5000 元因为 A 还没提交默认隔离级别下读到的是旧值执行还款 2000 元UPDATE 剩余金额 3000 元事务提交。自动扣款事务随后提交覆盖为 2000 元。最终结果借据剩余金额是 2000 元但还款流水里却记录了两笔成功的还款3000 2000 5000账实不符资金方对账时立刻报警。这就是典型的不加锁问题。它跟事务隔离级别无关即使是读已提交两个事务读到的都是同一旧快照依然会互相覆盖它本质上是缺少“更新前锁定”的业务保证。从那天起我彻底放弃了“靠数据库唯一索引兜底”的侥幸心理决定写一个专门针对借据资源的锁组件。2. 锁方案选型为什么最终选择 Redis 分布式锁 数据库兜底2.1 常见锁方案对比从 synchronized 到数据库悲观锁做 Java 开发的都知道并发控制有不少现成方案但每一种放到借据这个业务场景里都有明显的短板。我列一个表把当时我评估过的方案一次说清楚方案实现思路优点缺点适用性synchronized / JVM 锁单机内锁住方法或代码块简单、无额外依赖只对单个实例有效分布式多节点完全无效仅适合单体单机部署数据库乐观锁版本号 / CASUPDATE ... SET status?, versionversion1 WHERE id? AND version?无锁等待性能好业务层需要处理更新失败重试长事务下冲突率高适合并发概率低的场景数据库悲观锁SELECT FOR UPDATE事务内锁行强一致、不用轮询死锁风险、长事务持锁拖垮数据库跨库事务难处理适合数据库单点部署Redis 分布式锁SET NX EX利用 Redis 原子命令抢占锁 Key跨节点可靠、性能高、实现简单需要处理锁过期和误删依赖 Redis 可用性适合分布式服务架构从架构现状看我们系统已经引入了 Redis 作为缓存中间件服务是多节点部署至少四台应用服务器轮询。JVM 锁直接排除因为它锁不住其他节点上的请求数据库乐观锁可以做但业务方法里大量涉及多行借据关联更新比如借据 还款计划 流水光靠版本号会导致重试逻辑分散在各处侵入性太大。数据库悲观锁在单笔借据操作上其实不错但它最大的问题是长事务持锁会占用数据库连接而且如果涉及多库我们有借据库和流水库分库本地事务根本锁不住远端数据。所以最终选型很清晰用 Redis 分布式锁做主要并发控制配合数据库层的唯一约束和状态 CAS 作为兜底防线。这套组合能覆盖绝大多数分布式并发场景又能容忍 Redis 抖动带来的一些极端情况。2.2 锁粒度设计按借据编号而不是按用户或全局在设计锁 Key 时我踩过一个经典误区。最开始图省事有人建议用“全局锁”即任何借据操作都先抢同一个 Key。当时我就觉得不对劲算了一笔账系统高峰期一秒钟要处理几百笔借据操作如果它们都抢同一把锁相当于把并行全部强行转成串行TPS 直接腰斩到几十这完全不可接受。正确的做法是按借据编号拆分锁粒度也就是每条借据一把独立的锁。Key 的格式我设计成这样lock:borrow:{tenantId}:{borrowNo}tenantId 是租户隔离字段我们系统是多租户的不同租户的借据可能编号重复所以必须加上borrowNo 是借据号它是整个借据生命周期内的唯一标识。锁粒度精确到单笔借据后不同借据之间的操作完全不互相阻塞同一笔借据的不同操作会被串行化——这正是我们要的效果。2.3 为什么不用 Redisson 现成锁轻量级自研的取舍这里我想多说一句选型细节。很多团队遇到分布式锁第一反应是引入 Redisson它确实封装了完善的看门狗机制、可重入锁、公平锁等功能。我也评估过但最终没有引入到借据锁这个场景原因有三点第一我们团队对 Redisson 的版本兼容性有顾虑生产环境的 Spring Boot 是 2.3.xRedis 是 3.2Redisson 最新版对旧版本兼容不够友好为了一个锁引入一个重客户端性价比不高。第二借据锁的诉求相对简单互斥、自动过期、可手动释放不需要复杂的公平队列。第三自研代码可控性强出现锁异常时我们能直接定位而不是翻 Redisson 的源码。当然如果你所在团队已经用了 Redisson直接用它内置的 RLock 也完全没问题不用重复造轮子。我这里自研的这套锁更偏向“轻量级自定义注解 AOP”的方式让业务代码零侵入。3. 借据锁核心实现从 Lua 脚本到业务注解3.1 Redis 加锁与释放锁的正确姿势先写加锁的底层代码。实现分布式锁Redis 官方推荐的命令是SET key value EX seconds NX它保证“如果 key 不存在则设置且同时设置过期时间”这个原子操作能避免“先判断再设置”两步操作带来的竞态问题。public class RedisDistributedLock { private static final String LOCK_SUCCESS OK; private static final Long RELEASE_SUCCESS 1L; private final StringRedisTemplate redisTemplate; public RedisDistributedLock(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } /** * 尝试加锁自旋等待指定时间 */ public boolean tryLock(String lockKey, String requestId, long acquireTimeout, long expireTime) { long start System.currentTimeMillis(); while (System.currentTimeMillis() - start acquireTimeout) { Boolean result redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, expireTime, TimeUnit.MILLISECONDS); if (Boolean.TRUE.equals(result)) { return true; } // 短暂休眠避免自旋过度消耗 CPU try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } } return false; } /** * 释放锁通过 Lua 脚本保证只释放自己的锁 */ public boolean releaseLock(String lockKey, String requestId) { // 使用 Lua 脚本先校验 value匹配才删除防止误删他人锁 String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; Long result redisTemplate.execute( new DefaultRedisScript(script, Long.class), Collections.singletonList(lockKey), requestId ); return RELEASE_SUCCESS.equals(result); } }这里有一个极其关键的细节释放锁时为什么必须用 Lua 脚本不能先GET再DEL因为如果请求 A 拿到锁执行完业务后准备释放恰好锁因为过期时间到了被自动删除此时请求 B 抢到了锁并写入了新的 value。如果 A 此时执行DEL它会把 B 的锁误删掉导致 B 失去保护后面的请求 C 也能同时进入临界区锁就名存实亡了。所以释放锁必须校验 value 是否还是自己的 requestId而且校验 删除必须原子操作。Lua 脚本在 Redis 服务端执行天然保证原子性。requestId 我用的是UUID.randomUUID().toString()每次加锁都生成唯一值保证“谁加的锁谁才能释放”。3.2 自定义注解 AOP业务代码零侵入的核心设计锁底层能力有了接下来要解决的是“怎么用”的问题。如果每个业务方法里都手动写 tryLock / releaseLock那代码会非常啰嗦还容易在异常路径上忘记释放锁。我的做法是定义一个注解通过 Spring AOP 把加锁、解锁逻辑统一封装起来。这是整套借据锁里最有价值的设计也是业务方能零成本接受的关键。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface BorrowLock { /** * 锁的 Redis Key 前缀默认 lock:borrow */ String prefix() default lock:borrow; /** * 从方法参数中取哪个字段作为借据编号 */ String keyField() default borrowNo; /** * 获取锁的最大等待时间毫秒 */ long waitTime() default 3000; /** * 锁过期时间毫秒默认 5 秒按业务耗时合理设置 */ long leaseTime() default 5000; }参数 keyField 的设计是核心不同业务方法的入参不一样有的叫 borrowNo有的叫 loanNo有的以 DTO 形式传参有的直接传 String。我在切面里用反射去拿方法参数中字段名为 keyField 的值统一生成锁 Key。这个方法虽然简单但省去了业务方手动拼接 Key 的麻烦。3.3 AOP 切面加锁顺序、重试逻辑与异常处理切面的实现是整套锁的心脏。我直接把核心代码贴出来并解释几个关键分支。Aspect Component public class BorrowLockAspect { private final RedisDistributedLock redisDistributedLock; public BorrowLockAspect(RedisDistributedLock redisDistributedLock) { this.redisDistributedLock redisDistributedLock; } Around(annotation(borrowLock)) public Object around(ProceedingJoinPoint joinPoint, BorrowLock borrowLock) throws Throwable { // 从方法参数中解析借据编号 String borrowNo resolveBorrowNo(joinPoint, borrowLock.keyField()); if (borrowNo null || borrowNo.isEmpty()) { throw new IllegalArgumentException(借据编号为空无法生成借据锁); } String lockKey buildLockKey(borrowLock.prefix(), borrowNo); String requestId UUID.randomUUID().toString(); boolean locked false; try { locked redisDistributedLock.tryLock(lockKey, requestId, borrowLock.waitTime(), borrowLock.leaseTime()); if (!locked) { throw new BorrowLockException(获取借据锁超时借据号 borrowNo 正被其他业务操作中请稍后重试); } // 锁到手执行真正的业务逻辑 return joinPoint.proceed(); } catch (BorrowLockException e) { throw e; } catch (Throwable e) { // 业务异常直接抛出由外层事务处理 throw e; } finally { if (locked) { redisDistributedLock.releaseLock(lockKey, requestId); } } } private String resolveBorrowNo(ProceedingJoinPoint joinPoint, String keyField) { Object[] args joinPoint.getArgs(); for (Object arg : args) { if (arg null) { continue; } // 参数本身是 String且名称匹配通过参数名索引兜底 if (arg instanceof String (keyField.equals(borrowNo) || keyField.equals(loanNo))) { return (String) arg; } // 参数是对象反射读取字段 HashMapString, Object map new HashMap(); // 简化见下通过反射获取 keyField 字段值 Object value getFieldValue(arg, keyField); if (value ! null) { return value.toString(); } } return null; } }这段代码里我想特别强调两个地方都是实际踩坑后补进去的。第一是“获取锁超时抛异常”这个设计。最开始我的做法是获取不到锁就返回 false然后业务方法自己判断、自己抛错。结果每个业务方都在重复写相似的“超时处理”代码而且很多业务方直接忽略了返回值导致没锁也在跑。改成注解切面统一抛出 BorrowLockException 后行为收敛了线上再没出现过“忽略锁状态继续执行”的问题。第二是 finally 里释放锁的时机。很多人会把 releaseLock 放在 try 块内、proceed() 之后立刻执行这是错误的。因为 joinPoint.proceed() 执行完外层事务可能还没提交如果此时释放锁另一个线程立即抢到锁并读取借据数据读到的依然是上一个事务未提交的旧状态。正确做法是让锁跨越整个事务边界——这也是我后面 3.5 小节要详细讲的事。3.4 锁的过期时间与业务耗时权衡锁过期时间也叫 leaseTime这是自研分布式锁最能体现“经验”的参数。设置得太短业务还没执行完锁就自动释放了其他线程提前进来可能读到中间状态设置得太长一旦业务线程崩溃其他线程长时间拿不到锁造成接口超时堆积。我根据业务耗时统计做过一次估算。借据相关的核心操作还款、结清、展期、冲正绝大多数在 100ms 到 500ms 内完成极端情况下如果涉及外部系统调用如调用支付网关可能达到 2 秒。所以我最终把默认 leaseTime 定在 5000ms既给了足够的余量又不会因为线程卡死导致长时间阻塞。但 5 秒并不是永远正确的。如果某个业务方法要执行耗时超过 5 秒的远程调用就必须在注解上调大 leaseTime比如BorrowLock(keyField borrowNo, leaseTime 10000) public void processWithExternalApi(String borrowNo) { // 涉及外部接口调用耗时可能超过 5 秒 }这里还要提醒一个隐患如果真的遇到业务耗时超过锁过期时间的情况一定不要单纯地无限调大 leaseTime更好的办法是引入“看门狗续期”机制——Redisson 的 watch dog 会在锁持有期间自动续期防止锁提前过期。如果你选择自研锁可以在获取锁成功后启动一个 TimerTask 定期给锁续期在 finally 里取消续期核心逻辑并不复杂我把它放在 5.3 节的进阶方案里讲。3.5 事务与锁的摆放顺序最隐蔽的坑这是我在压测时花了两个晚上才定位的问题必须单独拿出来讲。在 Spring 项目中事务是通过Transactional注解由 Spring 的事务代理控制的。AOP 切面的执行顺序和事务切面的执行顺序直接决定了锁的作用范围。打个比方如果事务切面的 order 比锁切面更靠内先开启事务再加锁那么锁释放的时候事务还没提交锁形同虚设。正确的层级关系应该是锁切面在外事务切面在内。也就是说线程必须先拿到锁再开启事务执行完业务逻辑后先提交事务最后才释放锁。这样其他线程只有在看到上一个事务提交后的完整结果时才能进入临界区。Spring 中调整切面顺序的写法是在切面类上加Order注解。事务默认顺序是Ordered.LOWEST_PRECEDENCE也就是最后执行所以理论上只要把锁切面的 order 设置为比它小的值如Order(1)即可。但这里有个隐患如果项目里自定义了多个切面或者调整了事务管理器配置这个顺序就可能被打乱。我的做法是在测试环境专门写了一个并发测试用例验证“锁释放时事务是否已经提交”后面 6.2 小节我会给出具体的验证方案。4. 数据库层的双重兜底唯一索引与状态 CAS 更新4.1 兜底不是可选项而是必要的安全冗余Redis 分布式锁并不是 100% 可靠的。它依赖 Redis 的高可用性如果 Redis 发生主从切换或者在网络抖动瞬间锁信息丢失极端情况下依然可能让多个线程同时进入临界区。单纯依赖分布式锁的业务系统相当于把身家性命押在了 Redis 的稳定性上。所以我在设计借据锁时数据库层的兜底不是可选项而是必需品。实践里我采用了两层数据库防线第一层是业务约束型兜底利用数据库唯一索引从根源防止重复数据第二层是状态机兜底利用“比较并交换”CAS的 SQL 方式保证状态更新是原子的。4.2 借据还款流水表唯一索引防重复入账重复还款事故里最可怕的结果是同一个还款请求被同时处理两次生成两条还款流水扣了两次钱。这时候就算借据余额被锁保住了流水层面也会出现重复记录。解决方案是为还款流水表增加业务唯一索引索引字段用还款来源 外部单号 借据编号例如ALTER TABLE repay_flow ADD UNIQUE KEY uk_biz_no (borrow_no, source_type, out_trade_no);这样即使两个请求同时到达一个插入成功另一个插入时会被数据库唯一键拦截抛出 DuplicateKeyException。在业务层我的做法是捕获这个异常把它转换成幂等响应提示“该还款请求已受理请勿重复提交”。4.3 借据状态更新的 CAS 语句借据表的状态更新同样需要兜底。在生成还款动作时不要直接写“无条件更新状态”而是写成带条件的状态流转 SQLUPDATE borrow SET status SETTLED, version version 1, update_time NOW() WHERE borrow_no #{borrowNo} AND status NORMAL AND version #{oldVersion}如果这条 SQL 影响行数为 0说明借据状态已经被其他请求改变了可能被结清、可能被核销当前请求需要立刻终止而不是继续往下执行。这两个兜底策略的核心逻辑简单说就是分布式锁负责“尽量不让大家同时进去”数据库约束和 CAS 负责“就算同时进去了也只有一个人能改成功”。双重保险下重复入账和状态覆盖这类事故的概率被压到极低。5. 进阶能力扩展可重入、批量锁与看门狗续期5.1 同一线程重复获取锁可重入与计数自研分布式锁最容易忽略的一个细节是“可重入”。什么叫可重入就是一个业务方法内部又调用了另一个带同一把锁的方法。比如还款接口上加了 BorrowLock还款方法内部又调用了“更新借据状态”的方法而这个私有方法上也加了相同的 BorrowLock——如果不做可重入处理第二次加锁就会因为拿不到锁而失败。我在锁组件里加了一个 ThreadLocal 计数器专门解决这个问题。同一个线程第一次加锁成功后计数器记为 1再次加同一把锁时检测到当前线程已持有该锁计数器加 1直接放行释放锁时计数器减 1减到 0 才真正删除 Redis 的 Key。这个方案需要注意一个细节锁 Key 的判断必须精确匹配不仅锁前缀和借据编号要一致requestId 也要一致。实现时用 ThreadLocal 保存当前线程的锁标识锁 Key requestId每次加锁前先检查标识是否匹配。5.2 多个借据同时操作批量锁的排序防死锁除了单笔借据系统里还有一个高频场景批量操作比如一次性结清某客户名下的多笔借据。如果逐笔获取锁就存在死锁风险请求 A 先锁借据 1 再锁借据 2请求 B 先锁借据 2 再锁借据 1两者互相等待谁也拿不到对方的锁最终双方都超时解锁业务失败。出路是按固定顺序加锁。在批量操作场景中把所有借据号先排序比如字典序升序然后按顺序依次获取锁。排序之后所有请求都以相同的顺序申请锁资源就不会出现循环等待死锁从根源上被避免了。我封装了一个批量锁工具方法public ListString tryLockBatch(ListString borrowNoList, long waitTime, long leaseTime) { ListString sortedBorrowNos borrowNoList.stream().sorted().collect(Collectors.toList()); ListString acquiredKeys new ArrayList(); try { for (String borrowNo : sortedBorrowNos) { String lockKey buildLockKey(lock:borrow, borrowNo); String requestId UUID.randomUUID().toString(); if (!redisDistributedLock.tryLock(lockKey, requestId, waitTime, leaseTime)) { throw new BorrowLockException(批量获取借据锁失败: borrowNo); } acquiredKeys.add(lockKey); } return acquiredKeys; } catch (Exception e) { // 一旦失败释放已经获取的锁避免持有部分锁造成资源泄漏 for (String key : acquiredKeys) { redisDistributedLock.releaseLock(key, ...); } throw e; } }这里最容易犯的错就是“获取到部分锁之后失败时只抛异常不解锁”。我见过很多新人写批量逻辑入参校验失败了就直接 return已经拿到手的锁就这么挂着直到过期才被清除。这个习惯必须改。5.3 看门狗续期解决业务耗时超过锁过期时间的问题自研锁要支持看门狗续期其实也简单。核心思路是加锁成功后启动一个延时任务在锁还有一段时间才过期时去刷新过期时间锁释放后取消这个任务。// 伪代码示意生产环境需要抽成独立的续期线程 ScheduledExecutorService scheduledExecutorService Executors.newScheduledThreadPool(1); public boolean tryLockWithWatchDog(String lockKey, String requestId, long leaseTime) { Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, leaseTime, TimeUnit.MILLISECONDS); if (Boolean.TRUE.equals(locked)) { startWatchDog(lockKey, requestId, leaseTime); } return Boolean.TRUE.equals(locked); } private void startWatchDog(String lockKey, String requestId, long leaseTime) { ScheduledFuture? future scheduledExecutorService.scheduleAtFixedRate(() - { // 续期前确认锁还是自己的 String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(pexpire, KEYS[1], ARGV[2]) else return 0 end; redisTemplate.execute(new DefaultRedisScript(script, Long.class), Collections.singletonList(lockKey), requestId, String.valueOf(leaseTime)); }, leaseTime / 3, leaseTime / 3, TimeUnit.MILLISECONDS); // 将 future 保存到 ThreadLocal 或 ConcurrentHashMap在释放锁时 cancel }续期周期我设置为 leaseTime 的三分之一比如锁过期时间 5 秒那么每 1.66 秒续一次留足了时间余量。在 finally 块释放锁时必须先 cancel 续期任务再删除锁 Key顺序不能反——否则释放锁之后续期任务又把一个已经不存在的 Key 做了 pexpire虽然不会造成逻辑错误但会产生无意义的 Redis 调用。5.4 锁等待队列与超时降级最后一个进阶点是“锁等待超时的降级策略”。在高并发场景下可能存在大量请求排队等着同一把借据锁比如还款日当天如果每个请求默认等待 3 秒3000 个请求就要排队很久而且很多请求最终会超时失败。我的经验是区分对待。对于用户实时发起的操作比如 App 里的手动还款等待超时后直接返回友好提示“系统繁忙请稍后重试”这是合理的降级。但对于后台异步任务比如跑批、代扣回调等待超时后不应该直接丢弃而是把操作重新投递到消息队列延迟几秒后重试。这样设计的好处是实时接口保持低延迟异步任务不丢失整体系统的吞吐量和用户体验都能得到保障。降级方案的核心逻辑可以用一个开关来控制BorrowLock(retryOnTimeout true)当等待超时时走 MQ 重试默认为 false直接抛异常。6. 压测、线上问题与排查手段6.1 并发压测用多线程模拟发券式请求写完锁组件后我在测试环境做了完整的并发验证。压测工具我用了 JMeter但这里更想讲讲如何在业务代码层面精确模拟“同一笔借据被并发操作”的场景。我在测试环境写了一个只对测试借据号生效的模拟接口接口内部故意加了 500ms 的耗时操作然后用 50 个线程同时请求这同一笔借据。观察点有两个一是是否有超过一个线程同时进入了临界区二是借据余额的最终值是否等于所有成功请求的累加结果。压测代码的关键是给每个线程分配不同的请求ID方便日志追踪。测试结果应该满足50 个请求中只有 1 个请求进入锁内执行其余 49 个要么等待要么超时。进入锁内的请求执行完成释放锁后下一个请求才被允许进入。最终借据余额正确无重复扣款。如果压测结果出现多个线程同时进入临界区第一反应不是查业务代码而是查 AOP 顺序——检查锁切面是不是真的执行在事务切面外层。6.2 验证事务边界锁释放时事务是否已经提交前面提到 AOP 顺序问题这里给出一个可执行性高的验证方案。我在测试用例里用TransactionSynchronizationManager获取当前线程的事务状态在锁的 finally 块中打印当前是否有活动事务boolean actualTransactionActive TransactionSynchronizationManager.isActualTransactionActive(); logger.info(释放锁时事务激活状态: {}, actualTransactionActive);如果 finally 里打印出来是 true说明事务还没提交锁就被释放了这是有问题的。正确情况下锁释放时事务已经提交完成打印结果应为 false。这个验证方法建议直接固化到测试用例里每次改造后回归。6.3 线上遇到的三类锁异常及排查路径我在生产环境实际遇到过的锁异常主要有三类每类都有特定的症状和排查方向第一类是“锁一直超时获取不到”。现象是接口频繁报“获取借据锁超时”。排查方向确认持锁线程是否卡死通过线程 dump 看是否有线程停留在业务方法内确认是否有长事务持有锁超过 leaseTime查看慢 SQL确认锁 Key 是否被错误复用比如租户隔离字段丢失导致所有租户共用同一把锁。第二类是“Redis 抖动导致锁失效”。现象是偶发数据错乱但不是每次都复现。排查方向检查 Redis 主从切换期间的操作日志确认这段时间内锁是否短暂丢失同时检查数据库层的 CAS 兜底是否生效了如果 CAS 兜底没生效就说明兜底 SQL 的 WHERE 条件写错了。第三类是“锁误删他人锁”。现象是并发业务偶发同时成功但数据库兜底又能拦住一部分。排查方向检查 releaseLock 的 Lua 脚本是否取到了正确的 requestId检查是否在业务代码某处手动调用了redisTemplate.delete(lockKey)绕过了校验逻辑。这三类问题我在组件里分别加了对应的监控指标后面第 7 节会具体讲。7. 锁组件上线后的运维体系建设写代码只是第一步真正让锁组件稳定运行还需要完整的可观测性和运维保障。这块内容没有出现在任何教科书里但它是线上实际运行的生命线。7.1 监控指标锁冲突率、等待时长与锁持有时间我在 Redis 锁组件里加了三组核心监控指标全部通过 Micrometer 暴露给 Prometheus再接入 Grafana 做可视化。这三组指标是borrow_lock_conflict_total获取锁失败的次数反映锁冲突概率。正常情况下一秒内冲突应该很少如果冲突率突然飙升说明有大量并发操作集中到同一批借据上可能是业务异常或批处理与实时操作撞车。borrow_lock_wait_time获取锁花费的等待时间。如果平均等待时间接近 waitTime 上限说明排队严重需要优化业务耗时或提升锁粒度。borrow_lock_hold_time从加锁到释放锁之间的时间。如果某把锁的持有时长持续超过 leaseTime 的一半但频繁续期说明业务操作耗时异常需要排查具体方法。这三组指标是判断锁组件是否正常运转的“心电图”没有它们光靠用户报障才知道锁有问题损失已经造成了。7.2 锁 Key 的审计日志与灰度开关生产环境上出了问题最让人抓狂的是“不知道哪台机器、哪个请求、在哪把锁上排队”。所以我在切面里增加了一个审计日志功能每次加锁成功、超时、释放都记录一条结构化日志borrow_lock|acquired|borrowNoB20240315001|requestIdxxxx|cost12ms|ip10.10.1.1 borrow_lock|timeout|borrowNoB20240315001|wait3000ms|ip10.10.1.2日志里必须包含 IP因为分布式环境下没有 IP 信息你根本不知道是哪个节点的什么问题。此外我给锁组件加了一个基于配置中心的全局开关。如果 Redis 锁出现大面积异常可以一键降级为“纯数据库 CAS 兜底”模式虽然并发能力下降但业务不至于整体不可用。这个开关平时关闭但每次大促前我会演练一次降级流程确保关键时刻真的能切换生效。7.3 手工解锁工具应对死锁与误锁的人工干预最后说一个容易被忽略但很重要的工具人工解锁。线上偶尔会出现锁 Key 残留在 Redis 里的情况比如业务线程被 kill -9 强制杀死finally 里的释放逻辑没来得及执行只能等锁自然过期。如果 leaseTime 设置得特别长比如 60 秒业务就要阻塞一分钟。我开发了一个内部运维命令通过定时任务扫描 Redis 中锁 Key 的存活时间对于超过 leaseTime 数倍的残留锁自动报警同时可以人工通过管理端手动删除指定锁。这个工具的审批流程需要严格管控毕竟本质上它是绕过并发保护的“后门”如果被误用后果比不用锁还严重。从我个人的实战体验来说手工解锁工具存在的意义更多是让运维在极端场景下有一个兜底手段而不是日常依赖它。真正的保障始终来自于锁设计的闭环和精确的可观测性。把这一整套借据锁从零到一落地之后最直观的感受是并发事故的工单基本清零了同事半夜被叫醒的次数也少了。技术层面它不算难难的是把业务并发特征、锁粒度、事务边界、兜底策略、监控运维这些环节想周全。如果你也在写信贷或者类似强一致性的业务系统建议不要只盯着某一个锁的 API而是从“锁怎么设计”“锁怎么用”“锁挂了怎么办”三个维度想清楚这套借据锁的思路也许能帮你少走不少弯路。