凌晨两点半,手机屏幕亮起来,一看是值班同事的来电,我心里就咯噔一下。果然,电话那头声音都变了:“借据状态乱了,同一笔借据被还了两次款,现在账目对不上,赶紧上来看看吧。”那个晚上,我在生产环境里跟一只“看不见的锁”搏斗到天亮,也第一次真正意识到:在信贷系统里,借据的并发安全不是靠“小心一点”就能保证的,而是要靠一套设计严谨的锁机制来兜底。
先说下背景。我所在的团队负责公司信贷核心系统的借据模块,所谓“借据锁”,本质上就是针对借据记录(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 手动还款。
- 自动扣款请求 A:SELECT 剩余金额 = 5000 元,执行扣款 3000 元,UPDATE 剩余金额 = 2000 元,事务尚未提交。
- 手动还款请求 B:SELECT 剩余金额 = 5000 元(因为 A 还没提交,默认隔离级别下读到的是旧值),执行还款 2000 元,UPDATE 剩余金额 = 3000 元,事务提交。
- 自动扣款事务随后提交,覆盖为 2000 元。
最终结果:借据剩余金额是 2000 元,但还款流水里却记录了两笔成功的还款(3000 + 2000 = 5000),账实不符,资金方对账时立刻报警。
这就是典型的不加锁问题。它跟事务隔离级别无关(即使是读已提交,两个事务读到的都是同一旧快照,依然会互相覆盖),它本质上是缺少“更新前锁定”的业务保证。从那天起,我彻底放弃了“靠数据库唯一索引兜底”的侥幸心理,决定写一个专门针对借据资源的锁组件。
2. 锁方案选型:为什么最终选择 Redis 分布式锁 + 数据库兜底
2.1 常见锁方案对比:从 synchronized 到数据库悲观锁
做 Java 开发的都知道,并发控制有不少现成方案,但每一种放到借据这个业务场景里都有明显的短板。我列一个表,把当时我评估过的方案一次说清楚:
| 方案 | 实现思路 | 优点 | 缺点 | 适用性 |
|---|---|---|---|---|
| synchronized / JVM 锁 | 单机内锁住方法或代码块 | 简单、无额外依赖 | 只对单个实例有效,分布式多节点完全无效 | 仅适合单体单机部署 |
| 数据库乐观锁(版本号 / CAS) | UPDATE ... SET status=?, version=version+1 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.x,Redis 是 3.2,Redisson 最新版对旧版本兼容不够友好,为了一个锁引入一个重客户端,性价比不高。第二,借据锁的诉求相对简单:互斥、自动过期、可手动释放,不需要复杂的公平队列。第三,自研代码可控性强,出现锁异常时我们能直接定位,而不是翻 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; } // 参数是对象,反射读取字段 HashMap<String, 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 语句
借据表的状态更新同样需要兜底。在生成还款动作时,不要直接写“无条件更新状态”,而是写成带条件的状态流转 SQL:
UPDATE 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 List<String> tryLockBatch(List<String> borrowNoList, long waitTime, long leaseTime) { List<String> sortedBorrowNos = borrowNoList.stream().sorted().collect(Collectors.toList()); List<String> 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|borrowNo=B20240315001|requestId=xxxx|cost=12ms|ip=10.10.1.1 borrow_lock|timeout|borrowNo=B20240315001|wait=3000ms|ip=10.10.1.2日志里必须包含 IP,因为分布式环境下没有 IP 信息,你根本不知道是哪个节点的什么问题。
此外,我给锁组件加了一个基于配置中心的全局开关。如果 Redis 锁出现大面积异常,可以一键降级为“纯数据库 CAS 兜底”模式,虽然并发能力下降,但业务不至于整体不可用。这个开关平时关闭,但每次大促前我会演练一次降级流程,确保关键时刻真的能切换生效。
7.3 手工解锁工具:应对死锁与误锁的人工干预
最后说一个容易被忽略但很重要的工具:人工解锁。线上偶尔会出现锁 Key 残留在 Redis 里的情况,比如业务线程被 kill -9 强制杀死,finally 里的释放逻辑没来得及执行,只能等锁自然过期。如果 leaseTime 设置得特别长(比如 60 秒),业务就要阻塞一分钟。
我开发了一个内部运维命令,通过定时任务扫描 Redis 中锁 Key 的存活时间,对于超过 leaseTime 数倍的残留锁自动报警,同时可以人工通过管理端手动删除指定锁。这个工具的审批流程需要严格管控,毕竟本质上它是绕过并发保护的“后门”,如果被误用,后果比不用锁还严重。
从我个人的实战体验来说,手工解锁工具存在的意义,更多是让运维在极端场景下有一个兜底手段,而不是日常依赖它。真正的保障,始终来自于锁设计的闭环和精确的可观测性。
把这一整套借据锁从零到一落地之后,最直观的感受是:并发事故的工单基本清零了,同事半夜被叫醒的次数也少了。技术层面它不算难,难的是把业务并发特征、锁粒度、事务边界、兜底策略、监控运维这些环节想周全。如果你也在写信贷或者类似强一致性的业务系统,建议不要只盯着某一个锁的 API,而是从“锁怎么设计”“锁怎么用”“锁挂了怎么办”三个维度想清楚,这套借据锁的思路也许能帮你少走不少弯路。