DDK Concurrency Starter
按领域概念做并发控制:同一个聚合实例同时只有一个操作在处理,同一个客户端请求只执行一次。底层是 Redisson,本 starter 只补「按聚合加锁」「锁包住事务」「按请求去重」这几个约定。
@Idempotent 登记请求(SET NX + 过期时间) 重复 → DuplicateRequestException(409)
@AggregateLock 获取 ddk:lock:<类型>:<标识> 等待时间内拿不到 → AggregateBusyException(409)
@Transactional 加载 → 修改 → 保存 → 提交
释放锁 在提交之后,下一个操作加载到的是已提交的状态
失败时:释放锁,撤销请求登记能做什么
| 能力 | 说明 |
|---|---|
@AggregateLock | 方法执行期间独占一个聚合实例,锁在事务之外获取、事务结束之后释放 |
@Idempotent | 同一个请求 key 在有效期内只执行一次,执行失败时可以用同一个 key 重试 |
AggregateLocks | 编程式入口,用于不是 Spring Bean 方法的地方 |
| 错误码 | AGGREGATE_BUSY、DUPLICATE_REQUEST,Web starter 都映射为 409 |
引入方式
<dependency>
<groupId>com.ddk</groupId>
<artifactId>ddk-concurrency-starter</artifactId>
</dependency>@Transactional
@AggregateLock(type = "order", id = "#command.orderId()")
@Idempotent(key = "#command.requestId()")
public OrderResponse pay(PayOrderCommand command) {
Order order = orders.findById(command.orderId()).orElseThrow(...);
order.pay();
orders.update(order);
return OrderResponse.from(order);
}应用里已有 RedissonClient 时直接用它;没有时按 Spring Boot 的 Redis 连接信息(spring.data.redis.*)创建一个单机连接。Sentinel、Cluster 或需要自定义 TLS 时,自己声明 RedissonClient。
聚合锁
| 方面 | 行为 |
|---|---|
| 锁名 | <前缀>lock:<type>:<id>。id 是对方法参数求值的 SpEL 表达式,类型化标识取原始值 |
| 等待 | 注解上的 waitTime,或 ddk.concurrency.lock.wait-time。超时抛 AggregateBusyException |
| 持有 | 不设置持有时间时由 Redisson 的看门狗自动续期,直到方法结束;设置 leaseTime 后到期自动释放,方法超时会打一条警告日志 |
| 事务 | 锁包在被标注方法的事务外面:事务开始之前获取,提交或回滚之后释放 |
| 重入 | 同一个线程可以再次获取同一把锁 |
它和乐观锁是什么关系? 互补,不是替代。乐观锁保证两个写入不会互相覆盖:后提交的那个在 UPDATE ... WHERE version = ? 影响 0 行时失败,见 持久化与聚合还原。但后提交的操作已经把活干完了才被丢弃。聚合锁让冲突的操作排队,第二个操作开始时加载到的就是第一个操作提交后的状态。热点聚合(库存、账户)适合加锁;冲突很少的聚合只用乐观锁就够了。
为什么锁要包住事务? 顺序反过来的话,锁释放时事务还没提交,下一个操作拿到锁后读到的仍是旧状态,锁就白加了。所以拦截被放在事务通知的外层,有一个测试在 afterCommit 回调里确认锁仍被持有。这也带来一个限制:只有被标注的方法自己开启事务时才成立。调用方已经在事务里时,锁会在外层事务提交之前释放,所以注解要标在最外层的应用服务方法上。
防重复提交
@Idempotent 在方法执行前用 SET NX 加过期时间登记 <前缀>idempotent:<scope>:<key>。
- key 应当由客户端生成,重试时保持不变,例如打开表单时生成的请求 ID。
- 有效期内用同一个 key 再次调用,抛
DuplicateRequestException。它只拒绝重复请求,不会重放第一次的结果。 - 方法抛异常时登记被撤销,客户端可以用同一个 key 重试。
scope默认是「类名.方法名」,两个用例里相同的 key 不会互相影响。- 重复请求在加锁之前就被拒绝,不占用锁的等待时间。
它防的是连点和客户端重试。消费「至少一次」投递的消息要用 事件 starter 的 IdempotentConsumer:它把消息登记和处理逻辑的写入放在同一个数据库事务里,而这里的登记在 Redis 里,和业务事务不是原子的。
配置项
| 配置项 | 默认值 | 说明 |
|---|---|---|
ddk.concurrency.enabled | true | 关闭注解与客户端,仅用于本地排查 |
ddk.concurrency.key-prefix | ddk: | 所有 Redis key 的前缀,多个应用共用 Redis 时按应用设置 |
ddk.concurrency.lock.wait-time | 3s | 等待锁的默认时间 |
ddk.concurrency.lock.lease-time | 持有锁的默认时间,不设置则由看门狗续期 | |
ddk.concurrency.idempotent.ttl | 10m | 请求登记的默认保留时间 |
实现要点
- 只依赖 Redisson 的核心客户端。 Redisson 自己的 Spring Boot starter 会替换 Spring Data Redis 的连接工厂,影响应用里已有的 Redis 用法(包括 DDK 的 Redis 与缓存 starter)。
- 没有客户端时直接失败。 容器里没有
RedissonClient时,调用带注解的方法会抛IllegalStateException。悄悄地不加锁比报错危险得多。 - 拦截顺序靠后置处理器保证。 拦截挂在所有其他后置处理器之后,并插到已有通知之前,所以锁在事务通知的外层。
不适用的场景 / 已知问题
- 限流:按用户、租户的限流还没有做。
- 示例:用户示例不依赖 Redis,暂时没有用到本 starter。
- 故障切换:锁的可靠性取决于单个 Redis。主从切换时,还没复制到从节点的锁可能被再次授予,所以聚合上的版本校验不能去掉。原理见 Redis 原子性、事务与锁。
- 跨聚合:一次只锁一个聚合。需要同时修改多个聚合时,先确认聚合边界是否合理,见 聚合边界。