尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Spring Boot实战:自定义@Lock注解+AOP优雅解决并发控制

发布时间:2026/9/26 16:56:24

资讯中心
01
ARTICLE

Spring Boot实战:自定义@Lock注解+AOP优雅解决并发控制

Spring Boot实战:自定义@Lock注解+AOP优雅解决并发控制
下单扣库存、活动领奖、优惠券发放这些接口一遇到并发问题就来了。早年间我处理这类问题第一反应就是在方法里 synchronized 一段或者用 ReentrantLock 手动 lock/unlock。用了几次之后我发现在项目工程里加锁的代码越堆越多到处都是 lock、unlock而且不同同事写的风格还不一样有人在 finally 里释放锁有人忘了释放线上跑着跑着就出现了诡异的卡顿和死锁。后来我把加锁逻辑收敛了一下做成了 Spring 里的自定义 Lock 注解利用 AOP 把加锁解锁这件事从业务代码里彻底剥离出去。这篇文章就把这套方案完整拆开讲一遍注解怎么定义、Key 怎么设计、AOP 切面怎么写、锁和事务的顺序怎么处理以及我在实际项目中踩过的几个坑。适合正在用 Spring Boot 做后端开发、被并发问题缠绕的同行也适合那些想给项目做一个通用并发控制组件的人。1. 为什么需要自定义Lock 注解1.1 手动加锁的三个痛点先说痛点。假设你要做一个库存扣减接口最开始你可能这样写private final ReentrantLock lock new ReentrantLock(); public boolean deductStock(Long skuId, Integer count) { lock.lock(); try { // 查询库存扣减库存写流水... return true; } finally { lock.unlock(); } }单个方法这样写没什么问题。但当一个 Controller 里有五六个涉及并发控制的接口每个接口都需要对不同的业务维度加锁问题就开始显现了。第一个痛点是代码散落。每个方法里都有 lock、unlock 的样板代码如果团队里有人使用习惯不好忘了把解锁放到 finally 里那么一旦业务代码抛出异常这把锁就永远释放不了。第二个痛点是锁对象的归属混乱。有人把锁对象定义成类的静态成员有人定义成实例成员还有人直接从 Spring 容器里拿一个 Bean 当锁不同实例部署到多节点后行为完全不一样。第三个痛点是可维护性差。看到一处加锁代码你无法快速判断出锁的粒度是什么是基于用户维度、订单维度还是全局维度得一直追到锁对象的定义处才能明白。1.2 从手动到声明式把锁变成基础设施后来我想Spring 的事务注解 Transactional 不是已经把“开启事务、提交、回滚”这一套流程收拢底层了么加锁这件事为什么不能同样处理于是我把加锁逻辑抽成了切面开发人员只需要在方法上标注Lock(key #userId : #skuId) public boolean deductStock(Long userId, Long skuId, Integer count) { // 业务代码不需要任何锁操作 }锁的获取、等待超时、释放逻辑全部由注解背后的切面统一处理。业务代码里干干净净只关注业务本身。这个思路的本质是把并发控制从命令式变成声明式让开发人员只要想清楚锁的 Key 是什么剩下的交给框架。这种设计还有一个附带好处——后续如果要升级成分布式锁只需要替换切面里的锁实现业务代码一行都不用改。2. 注解定义与关键设计2.1 注解参数设计自定义注解的定义不复杂但参数需要想清楚。我最终设计的参数是这三个Documented Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface Lock { /** * 锁的 Key支持 SpEL 表达式 */ String key(); /** * 获取锁的等待时间默认 3 秒 */ long waitTime() default 3000; /** * 锁类型可重入锁 / 公平锁 */ LockType type() default LockType.REENTRANT; public enum LockType { REENTRANT, FAIR } }有些朋友可能会疑惑为什么没有 leaseTime锁自动释放时间这个参数因为我们现在做的是本地 JVM 锁锁的生命周期完全由 lock/unlock 控制不需要像 Redis 分布式锁那样考虑异常宕机后的自动释放。如果哪天接入了分布式锁再额外增加 leaseTime 参数也不迟。waitTime 的默认值 3000 是很有讲究的。如果业务碰上短暂的持锁时间比如一次数据库操作几十毫秒那么 3000 毫秒足够让绝大部分线程排队等待。设置太长会导致请求线程堆积设置太短会让正常能拿到锁的线程也抛异常。生产环境我建议按接口的 P99 耗时来定一般是“正常持锁时间的两倍”再加一点缓冲。2.2 LockType 的选择LockType 我保留了 REENTRANT 和 FAIR 两种。默认是可重入锁 ReentrantLock原因是业务场景里经常出现方法嵌套调用A 方法加了 LockA 方法内部又调用了同样加锁的 B 方法如果锁不可重入就会自己把自己锁死。公平锁我用的比较少只有在对业务顺序有强要求时才用。比如秒杀活动要严格保证每个用户按到达顺序处理此时 FairLock 能让等待最久的线程先获得锁。但公平锁的性能比非公平锁差而且如果线程被反复唤醒上下文切换开销会明显升高绝大多数场景没必要开。我还保留了一个思考这个 LockType 是否够用后来我考虑过要不要支持“读锁/写锁”的区分但实际业务里并发保护大多是写操作互斥读多写少的场景用数据库或缓存层处理更合适所以最终没有引入读写锁。2.3 Key 的生成从固定字符串到 SpEL参数里的 key() 是最核心的字段。它决定了锁的粒度。最简单的形式是固定字符串比如Lock(key deductStock)这把锁是全局锁同一时刻只有一个线程能执行这个方法吞吐量很低。实际使用中更多是希望锁的粒度是用户级别或订单级别的比如相同用户不能同时提交重复订单不同用户之间完全不应该互相影响。这时候就需要动态 Key。我采用的方案是支持 SpEL 表达式表达式里可以直接引用方法参数Lock(key #userId : #skuId)这样两个不同用户操作同一商品时它们计算出来的 Key 不同可以并行执行同一个用户重复请求同一商品时Key 相同会被强制串行。这就是锁粒度控制的精髓。3. AOP 切面实现锁逻辑3.1 环绕通知的整体流程注解定义好了真正干活的是 AOP 切面。我用的是 Around 环绕通知因为加锁动作必须在目标方法执行之前发生解锁动作必须在目标方法执行之后发生本质上是把目标方法的调用包在临界区内。Aspect Component Order(Ordered.HIGHEST_PRECEDENCE) public class LockAspect { private final ConcurrentMapString, ReentrantLock lockMap new ConcurrentHashMap(); Around(annotation(lock)) public Object around(ProceedingJoinPoint joinPoint, Lock lock) throws Throwable { String key parseKey(lock.key(), joinPoint); ReentrantLock reentrantLock lockMap.computeIfAbsent(key, k - createLock(lock.type())); boolean locked false; try { locked reentrantLock.tryLock(lock.waitTime(), TimeUnit.MILLISECONDS); if (!locked) { throw new LockAcquireException(获取锁超时, key key); } return joinPoint.proceed(); } finally { if (locked) { reentrantLock.unlock(); } } } private ReentrantLock createLock(Lock.LockType type) { return type Lock.LockType.FAIR ? new ReentrantLock(true) : new ReentrantLock(); } }这里有几个至关重要的细节。首先是 Around(annotation(lock)) 的写法。annotation(lock) 是 AspectJ 的切入点表达式配合环绕通知的方法参数 Lock lockSpring 会自动把目标方法上标注的 Lock 注解实例传进来。这样切面就不需要手动反射获取注解了代码更简洁。其次是lockMap.computeIfAbsent。这个 ConcurrentMap 是锁对象的容器它的 Key 是业务维度Value 是 ReentrantLock。当两个并发线程同时计算出一个相同的 Key比如同一个 userId时computeIfAbsent 能保证只创建一个锁对象不会出现两条线程各拿一把锁、互相不阻塞的情况。这是整个方案正确性的根基。第三是 lock.unlock() 放在了 finally 里。这是最重要的一个习惯只要加锁成功无论如何都要释放锁。我见过不少同事把 unlock 放在 try 块的最后一行一旦业务方法抛异常锁就永远挂在那里。3.2 获取与释放的关键细节tryLock 和 lock 的区别很大。lock() 是阻塞式获取拿不到锁就死等一旦发生死锁问题线程会永久挂起非常难排查。tryLock(waitTime, MILLISECONDS) 则会在指定时间内不断尝试获取锁获取不到就会抛出超时异常把异常信息反馈给调用方。超时之后建议抛一个明确的 LockAcquireException不要返回 null也不要用 boolean 标志。为什么因为这个切面拦截的都是写操作如果返回 null上层业务代码要对空值做额外的判断而且很容易把“获取锁超时”和“业务失败”混为一谈。抛自定义异常可以被全局异常处理器统一捕获并转换为 503 或 429 状态码对调用方来说语义更清晰。有一点需要注意ReentrantLock 是可重入的。比如线程 A 先调用了方法 X拿到了锁然后在方法 X 内部又调用了带同一把锁的方法 Y这时线程 A 再次 tryLock 会立即成功ReentrantLock 内部维护的持有计数从 1 变到 2。对应的finally 里每调用一次 unlock计数减 1直到归零才真正释放。所以可重入锁天然支持同线程的嵌套调用。3.3 锁对象的内存回收问题这段经验是网上很少提到的。使用 ConcurrentMap 存放锁对象如果 Key 的维度是“用户 ID”或“订单 ID”那么随着时间的推移map 里存放的锁对象会持续增加。比如今天有 10 万个用户触发了加锁map 里就有 10 万个 ReentrantLock 对象就算这些用户的锁早就释放了对象也永远不会被回收这就是内存泄漏。解决思路有两个。思路一是使用弱引用。把锁对象放到 WeakReference 里当外部没有引用指向这个 ReentrantLock 时GC 可以自动回收。但实现起来要小心两个线程同时拿到同一个弱引用时可能发生锁对象被 GC 回收的问题需要额外加一层保护复杂度不低。思路二是定期清理空闲锁。给每个锁对象记录一个最后使用时间后台用一个调度任务定期扫描把长时间没有活跃请求的锁从 map 里移除。这个方案实现简单我在生产环境使用的就是这个思路。private final MapString, LockEntry lockMap new ConcurrentHashMap(); private static class LockEntry { final ReentrantLock lock; volatile long lastAccessTime; LockEntry(ReentrantLock lock) { this.lock lock; this.lastAccessTime System.currentTimeMillis(); } }每次加锁时更新 lastAccessTime后台每 5 分钟执行一次清理把 lastAccessTime 超过 30 分钟未更新的 Entry 移除。对于单机几万并发的规模这个方案已经足够稳定。4. 锁与事务的顺序问题4.1 先拿锁还是先开事务在 Spring 项目中加锁的方法往往同时标注了 Transactional。比如扣库存这个场景既要保证并发安全又要保证数据库操作在一个事务里。表面上看锁和事务是两件独立的事但它们的执行顺序会直接影响并发正确性。我生产环境中遇到过很典型的错误顺序Transactional Lock(key #skuId) public void deductStock(Long skuId) { ... }如果切面的执行顺序没有控制好会出现一个致命问题线程 A 先进入事务然后在事务内部拿锁线程 B 也进入事务尝试拿锁时发现锁被 A 持有于是 B 等待。A 执行完业务代码提交事务释放锁B 拿到锁后继续执行。看上去没问题。但这里有个细节A 提交事务是在释放锁之前还是之后如果锁切面位于事务切面的内层也就是先开启了事务再去获取锁那么锁释放时事务其实还没提交。B 拿到锁后立刻去数据库读取数据读到的可能是 A 尚未提交的旧数据。也就是说虽然两个线程串行执行了但 B 看到的仍然不是 A 的最新结果锁的保护形同虚设。4.2 正确顺序锁在事务外层锁释放晚于事务提交正确的顺序一定是先获取锁再开启事务业务执行完先提交事务再释放锁。这样B 拿到锁时A 的事务已经提交完成B 读到的必然是最新数据。Spring AOP 中切面执行顺序由 Order 控制数字越小越靠外。而 Spring 的事务切面默认 Order 是 Ordered.LOWEST_PRECEDENCE也就是 Integer.MAX_VALUE。所以我们的锁切面只要显式标注 Order(Ordered.HIGHEST_PRECEDENCE)就能保证它在事务外层执行。上面的 Aspect 切面里我已经写了这个注解。这里的原理一定要理解清楚很多并发问题排查到最后不是锁没加而是锁和事务的嵌套顺序反了。我给这个情况打个比方锁是走廊的门事务是会议室的门。正确做法是先进入走廊锁门再推开会议室的门开会而不是先推开会议室的门才想起来把走廊的门锁上。如果业务里同时有多个自定义切面建议都用 Order 明确优先级避免未来新增切面时打乱顺序。5. SpEL 表达式与锁粒度控制5.1 SpEL 解析器怎么用SpEL 表达式解析依赖 Spring 的表达式模块具体实现我封装了一个小工具方法private String parseKey(String keyExpression, ProceedingJoinPoint joinPoint) { if (!keyExpression.contains(#)) { return keyExpression; } MethodSignature signature (MethodSignature) joinPoint.getSignature(); Object[] args joinPoint.getArgs(); String[] paramNames parameterNameDiscoverer.getParameterNames(signature.getMethod()); EvaluationContext context new StandardEvaluationContext(); for (int i 0; i paramNames.length; i) { context.setVariable(paramNames[i], args[i]); } return String.valueOf(parser.parseExpression(keyExpression).getValue(context)); }关键的坑在parameterNameDiscoverer.getParameterNames()这里。Spring 的 DefaultParameterNameDiscoverer 在获取方法参数名时依赖编译时是否带上了-parameters参数。如果你用 IDE 直接运行 Spring Boot 项目通常没问题但用 Maven 打包部署时如果 pom.xml 里没有开启 parameters 编译参数反射拿到的参数名是arg0、arg1那么#userId这个表达式就解析不出值。解决方法有两种。一种是在 pom.xml 里配置 Maven 编译插件plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId configuration parameterstrue/parameters /configuration /plugin另一种更稳妥SpEL 里直接写参数位置。#p0代表第一个参数#p1代表第二个参数这是 Spring 内置的约定不依赖编译参数Lock(key #p0 : #p1)这种方式不直观但非常可靠。我个人的习惯是在项目里先开启 -parameters让 #userId 这种写法可用同时在使用文档里注明如果遇到解析失败就回退到 #p0 写法。5.2 锁粒度太粗与太细的权衡锁粒度是一个经常被忽略的核心设计。很多初学者喜欢用全局锁比如Lock(key stock)结果全系统的扣库存操作都被串行化了压测时吞吐量惨不忍睹。但锁粒度太细也有问题比如Lock(key #userId : #skuId : #orderType)维度过多会导致锁命中率极低几乎没什么并发保护效果。我常用的锁粒度设计原则是锁的粒度与并发安全边界一致。也就是说锁的 Key 应当能唯一标识“同一份共享资源”。对于库存共享资源是 skuId那么#skuId就是合适的粒度对于用户重复提交共享资源是 userId那么#userId就是合适的粒度。如果同时涉及多个维度用冒号分隔即可。另一个常见的坑是 SpEL 解析结果可能为 null。比如请求参数 userId 为空表达式返回了 null:1001那也好至少 Key 不会变成真正的 null。但如果你写的表达式是#userId而 userId 参数为 null那么StandardEvaluationContext里的变量就是 null最终String.valueOf(null)的结果是字符串 null仍然不会导致 ConcurrentMap 的 NPE。不过如果参数是整个对象表达式写成#user.id而 user 对象本身为 nullNPE 就逃不掉了。我的建议是在切面解析 Key 之后加一个非空校验兜底处理。还有一点想提醒SpEL 表达式只在切面解析一次不要在表达式中嵌入复杂逻辑。曾经有人在表达式里写#order.getItems().size() 0 ? #order.id : empty可读性非常差。锁的 Key 越简单越直白越好复杂的业务分类逻辑应该放到方法内部通过固定前缀来区分。6. 测试验证与性能对比6.1 用 CountDownLatch 模拟并发写完切面第一件事不是上线而是写一个并发测试来验证锁是否真的管用。我的测试场景是模拟 100 个线程同时扣减库存库存初始为 100每个线程扣 1正确的最终结果是刚好扣完不出现负数。测试代码大致是这样的Test void testConcurrentDeductStock() throws InterruptedException { int threadCount 100; CountDownLatch ready new CountDownLatch(threadCount); CountDownLatch start new CountDownLatch(1); CountDownLatch done new CountDownLatch(threadCount); AtomicInteger successCount new AtomicInteger(); AtomicInteger failCount new AtomicInteger(); for (int i 0; i threadCount; i) { new Thread(() - { ready.countDown(); try { start.await(); boolean result stockService.deductStock(1L, 1); if (result) { successCount.incrementAndGet(); } else { failCount.incrementAndGet(); } } catch (Exception e) { failCount.incrementAndGet(); } finally { done.countDown(); } }).start(); } ready.await(); start.countDown(); done.await(); assertEquals(100, successCount.get()); assertEquals(0, failCount.get()); }CountDownLatch 的意义在于让 100 个线程尽量在同一时刻出发避免因为线程序贯串行导致测试结果失真。没有锁的情况下这个测试大概率会出现超卖也就是成功扣减次数大于 100 或者库存变成负数加了 Lock 后每次只有一个线程能进入扣减逻辑最终成功数必然等于 100。6.2 压测结果解读我拿一个真实的扣库存服务做过对比测试结果是这样的方案100 线程并发成功率库存最终值平均响应耗时无锁超卖严重负数35mssynchronized 方法锁全部成功0120ms自定义 Lock全局 Key全部成功0145ms自定义 LockskuId 粒度全部成功052ms从数据能看出锁的粒度对吞吐量的影响非常明显。全局锁把所有请求全部串行平均耗时就上去了按 skuId 加锁不同商品之间还能并行整个系统的吞吐量基本不受影响。这里也要泼一盆冷水本地锁测试通过只能说明单机环境下的并发是安全的。如果服务部署了多个实例请求被负载均衡分发到两台机器上每台机器维护自己的 lockMap就会发生两台机器同时放行同一个 Key 的请求锁完全失效。这是本地锁方案的天然边界下一篇我会专门讲怎么把它改造成分布式锁。7. 实战踩坑三个最常见的翻车现场7.1 切面不生效第一种最隐蔽的情况是自调用。假设 StockService 里有方法 deductStock 加了 Lock同一类里的另一个方法 buy 调用了它public boolean buy(Long userId, Long skuId) { return deductStock(userId, skuId); }buy 没有加锁它直接通过this.deductStock()调用这个调用不会经过 Spring 代理因此 Lock 注解完全不会生效。解决办法是把 deductStock 放到另一个 Service Bean 里或者通过注入自身代理对象 AopContext.currentProxy() 来实现调用。第二种是注解加在 private 方法上。Spring AOP 基于 JDK 动态代理或 CGLIB但无论哪种代理private 方法都不会被代理拦截。把 Lock 标在 private 方法上等于白标编译器不报错运行时也不生效。这一点我建议团队里通过 Code Review 来约束。第三种是缺少切面扫描。使用 Spring Boot 时如果切面类和业务类不在同一个基础包下Aspect 无法被扫描到。所以 LockAspect 一定要放在能被组件扫描覆盖的包路径下或者在启动类显式手动注册。7.2 锁的 Key 意外共享String 在 Java 里存在常量池机制。假如代码里用常量拼接的方式构造 KeyLock(key stock: skuId)这其实涉及注解属性必须为编译期常量的问题。如果写成stock: skuId编译会直接报错因为注解属性不支持运行时变量。正确的姿势是写全 SpEL 表达式stock: #skuId。另一个容易踩的坑是故意把所有 Key 设计成固定值。比如一个项目里多个业务方法都写了Lock(key global_lock)那么所有标注了这个 Key 的方法会互相串行。这个行为看起来是“正确的并发控制”但实际上会把不相干业务拖垮。排查时如果发现性能骤降先看是不是全局锁的锅。7.3 锁超时时间与业务耗时的失配再讲一个线上事故。某个秒杀接口数据库偶发慢查询正常的持锁时间从几十毫秒飙到了 4 秒而锁的 waitTime 设置的是 3000 毫秒。于是大量请求获取锁超时前端看到的是大量 503 错误。这个锅不全是数据库的也是我超时参数没定好。实际项目中 waitTime 的计算逻辑是先压测出正常持锁时间比如多次实验得到 99% 情况下持锁时间小于 800ms那么 waitTime 设置为 2000ms 是合理的。如果业务对一致性要求没那么高也可以不用 tryLock 的超时机制改用阻塞式 lock()让请求排队等待但这样做会让调用方的响应时间不可控网关层要有兜底超时。还有一种常见情况业务代码里调用了外部 HTTP 接口这个接口自身超时时间是 10 秒持锁时间就会很长。此时即使加了锁其他线程也会大量排队。建议把外部调用移出锁的范围锁内只保护对共享资源的读写操作。8. 扩展从本地锁平滑迁移到分布式锁8.1 本地锁的边界单机部署时ConcurrentMap ReentrantLock 方案又简单又快。但在微服务架构中同一个服务在多个节点上部署每个节点一个应用实例锁状态是分布在各节点 JVM 内存里的节点之间互相不知道对方是否持锁。这时候本地锁就完全失效了。判断一个系统要不要上分布式锁先看两个条件一是服务是否多副本部署二是同一资源是否可能被不同副本上的线程同时访问。两个条件都满足就该换分布式方案。8.2 抽象锁服务换实现不改业务好的设计是提前留好扩展点。我把前面的 LockAspect 中锁的使用逻辑抽象成一个接口public interface DistributedLock { boolean tryLock(String key, long waitTime, TimeUnit unit); void unlock(String key); }本地实现封装 ReentrantLock分布式实现基于 Redis。在实际生产里我推荐直接用 Redisson 的 RLock因为 API 形态和 ReentrantLock 很接近迁移成本最低。public class RedissonLock implements DistributedLock { private final RedissonClient redissonClient; Override public boolean tryLock(String key, long waitTime, TimeUnit unit) { RLock lock redissonClient.getLock(key); try { return lock.tryLock(waitTime, unit); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } } Override public void unlock(String key) { RLock lock redissonClient.getLock(key); if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }使用 Redisson 的 RLock 时leaseTime 如果不设置默认会启用看门狗机制每隔一段时间自动续期避免业务执行太久导致锁在操作中途被自动释放。这个机制对大多数业务都友好但要注意如果主节点宕机Redisson 的看门狗可能来不及续期锁在极端情况下会提前失效。对一致性要求极高的场景还需要配合数据库乐观锁做二次保护。本地锁和分布式锁并不是互斥的。小团队、单机应用可以先上本地锁方案等到业务量大了、服务拆分多实例了再换成 Redisson 实现业务代码保持不动依然是标一个 Lock 注解。从我这些年做项目的经验看这套自定义 Lock 注解让我在并发控制上省了很多心。它最值钱的地方有两个一是把加锁模板代码从业务里抽了出来让并发控制成了一种显式的、可复用的能力二是在锁和事务的顺序、锁粒度、超时策略这些容易翻车的细节上有了统一的兜底设计。如果你正在为项目里的并发问题犯愁不妨也做一个这样的注解先想清楚锁的 Key 是什么再决定用本地锁还是分布式锁。通常把这件简单的事做对并发问题就已经解决了一大半。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。