分布式锁注解 @LockOrder 与切面框架
ERP 系统中存在大量**并发安全敏感**的业务场景: 在引入 @LockOrder 之前,业务方处理并发问题的方式非常原始: 每个单据类型对应独立的 key 前缀,天然隔离不同业务域的锁。 基于 Spring Expression Par
1. 背景与痛点
业务背景
ERP 系统中存在大量并发安全敏感的业务场景:
- 同一调拨单同时被多个请求处理(调拨确认、调拨取消、状态更新)
- 同一出库单 / 入库单并发执行,导致库存数据不一致
- 订单履约链路中,多节点同时处理同一运单
技术痛点
在引入 @LockOrder 之前,业务方处理并发问题的方式非常原始:
// 方式一:硬编码锁 key,侵入性强
String lockKey = "allocationOrder:" + orderId;
RLock lock = redisson.getLock(lockKey);
lock.tryLock(...);
try {
// 业务逻辑
} finally {
lock.unlock();
}
// 方式二:synchronized(单 JVM 有效,分布式场景直接失效)
synchronized (this) {
// 业务逻辑
}
核心问题:
- 每个业务方法都要写重复的加锁 / 解锁模板代码
- 锁 key 的拼接规则散落在各处,容易写错
- synchronized 无法跨 JVM 生效
- 调用方根本不知道方法有并发安全要求,无法从方法签名看出来
2. 技术方案
整体设计
@LockOrder 注解(标注在方法上)
↓
BaseLockOrderAspect 切面(Around 拦截)
↓
SpEL 表达式解析 fieldId / fieldOrderNo → 提取参数值
↓
OrderTypeEnum.covertLockKey() → 生成规范锁 key
↓
LockService.lockAndGetResultEx() → 基于 Redisson 加锁并执行
↓
返回业务结果 / 抛 LockFailedException
核心组件职责
| 组件 | 职责 |
|---|---|
@LockOrder |
注解本身,声明单据类型、字段路径、等待时间 |
BaseLockOrderAspect |
切面入口,统一处理拦截逻辑 |
LockOrderAspectUtil |
切面共用方法(SpEL 解析、key 组装、类型转换) |
LockOrderValueParser |
SpEL 表达式解析器,基于 Spring Expression Parser |
LockOrderExpressionEvaluator |
SpEL 上下文构建器 |
OrderTypeEnum |
单据类型枚举 + lockKey 生成规则 |
LockService |
基于 Redisson 的分布式锁底层实现 |
锁 Key 规范
// OrderTypeEnum.covertLockKey()
return type.getCode() + ":" + id;
// 例如:allocationOrder:12345, outboundOrder:67890
每个单据类型对应独立的 key 前缀,天然隔离不同业务域的锁。
3. 实现细节
注解定义
@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface LockOrder {
/** 单据类型,决定锁 key 的前缀 */
OrderTypeEnum orderType();
/** 字段路径(支持 SpEL),从方法参数中取值作为 ID */
String fieldId() default "";
/** 字段路径(支持 SpEL),从方法参数中取值作为单据号 */
String fieldOrderNo() default "";
/** 加锁等待时间(秒),默认 0 表示不等待直接失败 */
int waitTime() default 0;
}
使用方式
// 场景一:通过 ID 加锁(最常见)
@LockOrder(orderType = OrderTypeEnum.ALLOCATION_ORDER, fieldId = "#orderId", waitTime = 5)
public void confirmAllocation(Long orderId) {
// 业务逻辑,自动按 "allocationOrder:123" 加锁
}
// 场景二:通过单据号加锁
@LockOrder(orderType = OrderTypeEnum.OUTBOUND_ORDER, fieldOrderNo = "#outbound.orderNo", waitTime = 3)
public void processOutbound(OutboundDTO outbound) {
// 业务逻辑
}
// 场景三:批量加锁(逗号分隔)
@LockOrder(orderType = OrderTypeEnum.SHIPMENT, fieldId = "#orderIds", waitTime = 10)
public void batchConfirm(List<Long> orderIds) {
// 自动按 ["shipment:1", "shipment:2", "shipment:3"] 批量加锁
}
SpEL 表达式解析
LockOrderValueParser 基于 Spring Expression Parser 实现参数路径解析:
// 支持的表达式格式:
"#orderId" → 从参数中取值为 Long
"#outbound.orderNo" → 从参数对象中取属性(支持嵌套)
"#ids" → 从参数中取值,底层自动处理 Collection/Array/String(逗号分隔)
关键代码:LockOrderAspectUtil.castList() 统一处理单值、Collection、逗号分隔 String 三种输入形态。
多锁获取逻辑(LockService.lockAndGetResultEx)
// 核心逻辑(BaseLockOrderAspect 第 61-63 行)
lockService.lockAndGetResultEx(
keys, annotation.waitTime(), TimeUnit.SECONDS, pjp::proceed);
// LockService 底层(第 479-528 行):
// 1. keys 去重 + 排序(保证加锁顺序一致,避免死锁)
// 2. 依次 tryLock,每把锁都有独立的 waitTime
// 3. 任一锁失败 → 抛出 LockFailedException,已获取的锁在 finally 中全部释放
// 4. leaseTime = -1 → Redisson 自动续期(看门狗机制),业务执行期间锁不自动释放
// 5. 业务执行完毕 → finally 中逐个 unlock
抽象方法:convertId2OrderNo
BaseLockOrderAspect 第 117 行留有一个抽象方法:
public abstract List<String> convertId2OrderNo(OrderTypeEnum orderType, List<Long> orderIdList);
这是钩子方法:不同业务域可能 ID 与单据号的映射关系不同,由具体子类实现(比如 creeks-allocation-server 中通过 Feign 调用拿到调拨单号)。
4. 技术栈与亮点归纳
技术栈清单
- Java / Spring Boot / Spring AOP(AspectJ)
- Spring Expression Language(SpEL)
- Redisson(分布式锁底层实现)
- 设计模式:注解 + 切面(@Aspect)、模板方法(抽象基类)、策略(OrderTypeEnum)
技术亮点
- 注解驱动:业务方法零入侵,加锁逻辑与业务逻辑完全分离
- SpEL 动态 key:支持从任意嵌套参数路径提取锁 key 值,灵活度高
- 批量加锁:单次注解可锁定多个资源(如批量确认 10 个调拨单)
- 看门狗续期:leaseTime = -1,Redisson 自动续期,业务执行期间不会因超时丢锁
- 加锁顺序一致:keys 排序后再加锁,彻底杜绝死锁(经典分布式锁死锁问题)
- 统一异常:获取锁失败 →
LockFailedException→ 全局异常拦截器统一处理
5. 难点与亮点剖析
业务难点
- 跨服务加锁:调拨单处理在
creeks-allocation-server,但加锁的 key 前缀是allocationOrder,需要抽象层隔离实现细节 - 批量场景:一个请求同时锁定 10 个调拨单,必须全部成功才执行;任一失败则整体回滚
技术难点
- SpEL 在切面中的上下文构建:需要把
JoinPoint.getArgs()、目标类、方法签名都塞进EvaluationContext,才能正确解析#orderId这样的表达式 - 加锁顺序一致性:多把锁如果加锁顺序不一致,A→B 成功 + B→A 等待 → 死锁。代码中用
sorted()保证全局顺序一致 - 可重入支持:Redisson 默认可重入,同一线程重复获取同一把锁不会阻塞
实现亮点
- 注解 + 切面的非侵入设计:业务方法无需感知锁的存在,只需要在方法签名上贴注解
- 抽象基类钩子:
convertId2OrderNo留给子类实现,不同业务域独立扩展 - 类型自动转换:ID 可能是
Long、Integer、String、逗号分隔、Collection,底层统一归一化处理
6. 替代方案对比
方案一:synchronized(已排除)
- 实现:方法加
synchronized关键字 - 优劣:简单,但只能锁单 JVM,无法跨微服务
- 结论:ERP 微服务部署多实例,synchronized 在分布式场景下完全失效,不适用
方案二:数据库唯一索引乐观锁(已排除)
- 实现:
UPDATE table SET status = 'PROCESSING' WHERE id = ? AND status = 'PENDING' - 优劣:不引入新组件,但每条业务都要写 SQL,且无法覆盖"批量加锁"场景
- 结论:侵入性强,无法统一管控;适合单行更新,不适合复杂业务锁
方案三:ZooKeeper Curator 分布式锁(已排除)
- 实现:Curator 提供的 InterProcessMutex
- 优劣:可靠性高,但 API 相对 Redisson 笨重;Redisson 封装更友好,支持看门狗续期
- 结论:Redisson 在 Java 生态中更成熟,文档丰富,团队已有积累
最终方案
采用 @LockOrder + Redisson 注解切面方案,兼顾:零侵入、统一管控、可批量、支持看门狗、自动续期。
7. 技术选型与方案抉择复盘
选型思路
团队在 2022 年引入 Redisson 作为统一分布式锁组件(内部调研结论:Redisson API 友好、文档完善、社区活跃),@LockOrder 是在此基础上的注解化封装,目标是减少每个业务方法的样板代码。
优点
- 注解声明式,方法签名即文档,其他开发者一看就知道这个方法有并发安全要求
- 统一锁 key 格式(
OrderTypeEnum:ID),便于监控和排查 - 切面集中处理异常(LockFailedException),业务方无需关心加锁失败的细节
缺点 / 已知不足
- SpEL 表达式调试困难:写错字段路径(如
#outbound.orderNo写成#outbound.order_no)不会编译报错,只能运行时才发现 - 抽象基类的 convertId2OrderNo:需要每个业务域子类实现,重复代码仍存在优化空间(可以考虑注册表模式)
- 不支持公平锁:Redisson 默认是非公平锁,高并发下可能存在"饥饿"问题
可优化点
- 引入
MethodValidation对 @LockOrder 参数做编译时校验(字段路径是否存在) - 抽象基类改用注册表模式,通过
OrderTypeEnum直接查到 ID→单据号转换器,消除子类继承 - 增加锁等待策略配置项(当前只有 waitTime,未来可扩展:等待 / 抛异常 / 队列化)
8. 成果与数据
- 接入规模:覆盖 ERP 中 16 种单据类型的并发安全场景(见 OrderTypeEnum)
- 使用量:全仓库 @LockOrder 注解使用 100+ 处
- 无死锁记录:自 2022 年上线以来,批量加锁场景未出现死锁问题
- 故障归零:统一异常封装后,锁相关故障从"散落各处"变为"统一监控",问题定位时间大幅缩短
9. 面试题清单
- 【基础】分布式锁的核心原理是什么?Redis 和 Redisson 实现分布式锁的区别?
- 考察点:分布式锁概念、Redis SETNX vs Redisson 看门狗机制
- 【基础】Redisson 的看门狗(Watch Dog)机制是什么?为什么要自动续期?
- 考察点:leaseTime = -1 时 Redisson 自动以 1/3 过期时间间隔续期,防止业务执行超时时锁自动释放
- 【进阶】加锁时为什么要对 key 列表进行排序后再加锁?
- 考察点:避免死锁(死锁的经典条件:循环等待 + 不释放资源),排序保证全局加锁顺序一致
- 【进阶】多个锁的场景下,如果第二把锁获取失败,已获取的第一把锁怎么处理?
- 考察点:LockService.lockAndGetResultEx 的 finally 块,确保已获取的锁在异常时全部释放
- 【进阶】leaseTime = -1 和 leaseTime = 固定值(比如 30s)分别适用什么场景?各有什么风险?
- 考察点:看门狗 vs 固定过期时间的 trade-off;固定值可能在业务超时时提前释放锁导致并发问题
- 【原理】@LockOrder 注解加在方法上,Spring AOP 是如何拦截的?为什么不能拦截同类内部调用?
- 考察点:AOP 代理机制(Spring JDK/CGLIB 动态代理)、同类内部调用绕过代理(this.method())
- 【原理】SpEL 表达式
#orderId是如何解析出实际值的?Spring 在切面中如何构建 EvaluationContext?- 考察点:Spring Expression Parser、MethodBasedEvaluationContext、JoinPoint.getArgs() 与方法签名的对应关系
- 【设计】为什么要设计抽象基类 BaseLockOrderAspect,并在子类实现 convertId2OrderNo?
- 考察点:模板方法模式、依赖倒置、不同业务域 ID→单据号映射关系不同
- 【场景】如果一个接口需要同时锁定"调拨单"和"出库单"(跨单据类型),@LockOrder 能支持吗?应该怎么设计?
- 考察点:跨域锁的设计;当前框架按单据类型分组,跨域锁需要额外设计
- 【延伸】分布式锁的性能如何优化?有没有比 Redisson 更轻量的方案?
- 考察点:Redisson vs ShardingSphere-Proxy vs 数据库唯一索引;根据业务一致性要求选型
10. 简历提炼版
设计并实现基于 Redisson 的分布式锁注解框架 @LockOrder,通过 SpEL 表达式自动从方法参数解析锁 key,实现业务方法零入侵加锁;支持 16 种单据类型的批量并发控制,看门狗自动续期保证长事务场景下锁不丢失;全仓库 100+ 处复用,彻底消除并发场景的死锁隐患。
关键源码路径
| 文件 | 路径 |
|---|---|
| @LockOrder 注解 | creeks-common-services/.../lock/LockOrder.java |
| 切面实现 | creeks-common-services/.../lock/BaseLockOrderAspect.java |
| 切面工具类 | creeks-common-services/.../lock/LockOrderAspectUtil.java |
| SpEL 解析器 | creeks-common-services/.../lock/LockOrderValueParser.java |
| 锁服务底层 | creekson-base/.../common/lock/LockService.java |
| 单据类型枚举 | creeks-common-services/.../lock/constant/OrderTypeEnum.java |