DDK Concurrency Starter
按领域概念做并发控制:同一个聚合实例同时只有一个操作在处理,同一个客户端请求只执行一次,每个用户或租户有各自的调用额度。底层是 Redisson,本 starter 只补「按聚合加锁」「锁包住事务」「按请求去重」「按业务维度限流」这几个约定。
@RateLimit 为这个 key 取一个许可 没有了 → RateLimitedException(429)
@Idempotent 登记请求(SET NX + 过期时间) 重复 → DuplicateRequestException(409)
@AggregateLock 获取 ddk:lock:<类型>:<标识> 等待时间内拿不到 → AggregateBusyException(409)
@Transactional 加载 → 修改 → 保存 → 提交
释放锁 在提交之后,下一个操作加载到的是已提交的状态
失败时:释放锁,撤销请求登记能做什么
| 能力 | 说明 |
|---|---|
@AggregateLock | 方法执行期间独占一个聚合实例,锁在事务之外获取、事务结束之后释放 |
@Idempotent | 同一个请求 key 在有效期内只执行一次,执行失败时可以用同一个 key 重试 |
@RateLimit | 每个 key(用户、租户)在一个时间窗口内最多执行若干次,所有实例共享计数 |
AggregateLocks | 编程式入口,用于不是 Spring Bean 方法的地方 |
| 错误码 | AGGREGATE_BUSY、DUPLICATE_REQUEST 映射为 409,RATE_LIMITED 映射为 429 |
引入方式
<dependency>
<groupId>com.domaindrivenkit</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 回调里确认锁仍被持有。这也带来一个限制:只有被标注的方法自己开启事务时才成立。调用方已经在事务里时,锁会在外层事务提交之前释放,所以注解要标在最外层的应用服务方法上。
0.7.0 起,这个限制不再靠人记:在事务里面加锁会直接抛出异常,注解和编程式入口都一样。
IllegalStateException: Lock [ddk:lock:order:42] is being acquired inside a transaction and would be released before the commit:
acquire the lock first and start the transaction inside it在事务里重入本线程已经持有的锁不算,那把锁是在事务之外拿到的。有意在事务里面加锁时(例如只想让长事务里的某一步串行),用 ddk.concurrency.lock.inside-transaction=warn 或 ignore 放行。
消息的消费方同时需要锁和幂等消费时,把 Event Starter 的 IdempotentConsumer 放在锁的里面。它在没有事务时会自己开启一个,所以一条语句就得到「锁 → 事务 → 去重登记」的嵌套:
aggregateLocks.executeAll("sku", skuIds, () ->
idempotentConsumer.handle("inventory.order-placed", eventId, () -> reserve(command)));防重复提交
@Idempotent 在方法执行前用 SET NX 加过期时间登记 <前缀>idempotent:<scope>:<key>,值是一个随机令牌。
- key 应当由客户端生成,重试时保持不变,例如打开表单时生成的请求 ID。
- 有效期内用同一个 key 再次调用,抛
DuplicateRequestException。它只拒绝重复请求,不会重放第一次的结果。 - 方法抛异常时登记被撤销,客户端可以用同一个 key 重试。只撤销本次调用自己的登记:一次调用超过有效期才失败,而期间另一次调用已经登记了同一个 key,它不会删掉别人的登记(比较令牌后再删除)。
- 撤销登记失败(Redis 不可达)时,调用方拿到的仍是方法自己的异常,Redis 的错误作为 suppressed 附在上面;登记随有效期过期。
scope默认是声明类的全名、方法名和参数类型,例如com.acme.OrderService#place(PlaceOrderCommand)。不同包里的同名类、重载方法不会共用登记,代理也不改变 scope。- 重复请求在加锁之前就被拒绝,不占用锁的等待时间。
它保证的是:有效期内,同一个 key 同一时刻最多放行一次调用,失败的调用可以重试。它不保证副作用恰好执行一次:超过有效期的调用可能和重试重叠,成功的调用也只在有效期内保有登记。支付等必须只发生一次的操作,要靠业务唯一键、数据库状态检查或外部系统自己的幂等键。
0.9 的升级影响
默认 scope 从 OrderService.place 变成了 com.acme.OrderService#place(PlaceOrderCommand),Redis 的 key 随之改变。发布期间,一个有效期内旧版本登记的请求不会被新版本识别为重复,限流窗口也重新开始计数。需要 key 保持不变时显式设置 scope。
它防的是连点和客户端重试。消费「至少一次」投递的消息要用 事件 starter 的 IdempotentConsumer:它把消息登记和处理逻辑的写入放在同一个数据库事务里,而这里的登记在 Redis 里,和业务事务不是原子的。
限流
@RateLimit(limit = 20, period = "1m", key = "#query.tenantId()")
public PageResponse<OrderResponse> search(OrderQuery query) { ... }- 每个 key 各有一份额度:一个
period内最多limit次,类型化标识取原始值。不写key时整个用例共用一份。 - 计数放在 Redis 里(Redisson 的
RRateLimiter),所有实例共享。 - 超过限额时方法不执行,抛
RateLimitedException(RATE_LIMITED),Web starter 返回 429。 - 它排在
@Idempotent和@AggregateLock之前:被限流的调用不占用请求 key,也不等待锁。 - 次数和窗口是 Redis key 的一部分:
<前缀>rate:<scope>:<limit>/<period>:<key>。Redisson 把限流器的配置存在 Redis 里,改了注解本来会继续沿用旧的速率;把配置放进 key 名后,新版本用的是新 key,旧 key 闲置两个窗口后过期。
它管的是业务限额,例如每个租户的导出次数、每个手机号的短信验证码次数。它在应用内部、请求解析之后才执行,挡不住流量洪峰,不能代替网关层的限流。
配置项
| 配置项 | 默认值 | 说明 |
|---|---|---|
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.lock.inside-transaction | fail | 在事务里面加锁时:fail 抛出异常,warn 记警告,ignore 不检查(0.7.0 起) |
ddk.concurrency.idempotent.ttl | 10m | 请求登记的默认保留时间 |
实现要点
- 只依赖 Redisson 的核心客户端。 Redisson 自己的 Spring Boot starter 会替换 Spring Data Redis 的连接工厂,影响应用里已有的 Redis 用法(包括 DDK 的 Redis 与缓存 starter)。
- 没有客户端时直接失败。 容器里没有
RedissonClient时,调用带注解的方法会抛IllegalStateException。悄悄地不加锁比报错危险得多。 - 拦截顺序靠后置处理器保证。 拦截挂在所有其他后置处理器之后,并插到已有通知之前,所以锁在事务通知的外层。
不适用的场景 / 已知问题
- 示例:用户示例不依赖 Redis,暂时没有用到本 starter。
- 故障切换:锁的可靠性取决于单个 Redis。主从切换时,还没复制到从节点的锁可能被再次授予,所以聚合上的版本校验不能去掉。原理见 Redis 原子性、事务与锁。
- 跨聚合:一次只锁一个聚合。需要同时修改多个聚合时,先确认聚合边界是否合理,见 聚合边界。