跳到正文
Kale的数字花园
目录 文章
/8 分钟/8 阅读/0 评论

分布式锁注解 @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 默认可重入,同一线程重复获取同一把锁不会阻塞

实现亮点

  1. 注解 + 切面的非侵入设计:业务方法无需感知锁的存在,只需要在方法签名上贴注解
  2. 抽象基类钩子:convertId2OrderNo 留给子类实现,不同业务域独立扩展
  3. 类型自动转换: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. 面试题清单

  1. 【基础】分布式锁的核心原理是什么?Redis 和 Redisson 实现分布式锁的区别?
    • 考察点:分布式锁概念、Redis SETNX vs Redisson 看门狗机制
  2. 【基础】Redisson 的看门狗(Watch Dog)机制是什么?为什么要自动续期?
    • 考察点:leaseTime = -1 时 Redisson 自动以 1/3 过期时间间隔续期,防止业务执行超时时锁自动释放
  3. 【进阶】加锁时为什么要对 key 列表进行排序后再加锁?
    • 考察点:避免死锁(死锁的经典条件:循环等待 + 不释放资源),排序保证全局加锁顺序一致
  4. 【进阶】多个锁的场景下,如果第二把锁获取失败,已获取的第一把锁怎么处理?
    • 考察点:LockService.lockAndGetResultEx 的 finally 块,确保已获取的锁在异常时全部释放
  5. 【进阶】leaseTime = -1 和 leaseTime = 固定值(比如 30s)分别适用什么场景?各有什么风险?
    • 考察点:看门狗 vs 固定过期时间的 trade-off;固定值可能在业务超时时提前释放锁导致并发问题
  6. 【原理】@LockOrder 注解加在方法上,Spring AOP 是如何拦截的?为什么不能拦截同类内部调用?
    • 考察点:AOP 代理机制(Spring JDK/CGLIB 动态代理)、同类内部调用绕过代理(this.method())
  7. 【原理】SpEL 表达式 #orderId 是如何解析出实际值的?Spring 在切面中如何构建 EvaluationContext?
    • 考察点:Spring Expression Parser、MethodBasedEvaluationContext、JoinPoint.getArgs() 与方法签名的对应关系
  8. 【设计】为什么要设计抽象基类 BaseLockOrderAspect,并在子类实现 convertId2OrderNo?
    • 考察点:模板方法模式、依赖倒置、不同业务域 ID→单据号映射关系不同
  9. 【场景】如果一个接口需要同时锁定"调拨单"和"出库单"(跨单据类型),@LockOrder 能支持吗?应该怎么设计?
    • 考察点:跨域锁的设计;当前框架按单据类型分组,跨域锁需要额外设计
  10. 【延伸】分布式锁的性能如何优化?有没有比 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
相关阅读

评论需要登录。生产环境使用 GitHub 登录,本地可前往 登录页 使用开发登录。

还没有评论,来抢沙发。