上一篇拆
RedissonLockManager时留了个尾巴:三参tryLock(wait, lease, unit)不启动 watchdog。这次把上层真正调用它的开放网关链路扒了一遍------446 行的forge-starter-openapi-security+ 能力开放网关的九步编排,坑比预想的多,而且这条链路是真实跑在生产上的,不是我读源码推演的。
起因:限流阈值改了,线上纹丝不动
我们有个开放网关给外部系统调能力接口,写操作限流配的是每分钟 20 次。
某天业务方说不够用,我把配置从 20 改成 200,重启,压测------还是每分钟 20 次被拦。
第一反应是配置没读到。打日志确认读到了,properties.getWritePermitsPerMinute() 打出来就是 200。
那就是 Redisson 的问题了。翻源码,一行:
java
RRateLimiter limiter = client.getRateLimiter(rateKey);
limiter.trySetRate(RateType.OVERALL, policy.permitsPerMinute(), Duration.ofMinutes(1));
if (!limiter.tryAcquire()) {
throw new BusinessException(429, "请求过于频繁,请稍后再试");
}
trySetRate 的语义是 "try" ------ rate limiter 不存在才设置,返回 true;已存在则直接返回 false,什么都不改。
我们的 key 是 forge:openapi:rate:write:capability:12,Redis 里早就有了。改配置?改了个寂寞。
TL;DR:六条结论
RRateLimiter.trySetRate()只在首次生效,改配置不会更新已存在的桶。想生效必须删 key 或换 key 前缀。- 防重放跑在验签之前 ------ 未携带任何有效凭据的伪造请求也能往 Redis 里灌 nonce。
- 幂等锁租期 30 秒且不续期,业务超时后锁串行化降级为「数据库唯一约束兜底」。
- 快照反序列化失败会被当成 503 基础设施故障 ------ 快照明明还在,这个 Idempotency-Key 却永久不可用。
- 快照写失败返回 503,但业务已经执行完了 ------ 客户端重试就重复执行,幂等在这一刻失效。
- 组件本身的态度是 fail-closed (Redis 挂了抛 503,绝不降级放行),这一点比登录锁那套
@Autowired(required=false)强太多。
全景:446 行里有什么
bash
forge-starter-openapi-security(446 行 / 8 类)
├── config/
│ ├── OpenApiSecurityProperties # keyPrefix / 时间窗 / nonce TTL / 锁参数
│ └── OpenApiSecurityAutoConfiguration
├── replay/OpenApiReplayGuard # 防重放:时间窗 + nonce SETNX
├── idempotency/
│ ├── OpenApiIdempotencyManager # 幂等模板方法(核心)
│ ├── IdempotencyCommand # record:scopeKey + key + 快照读写回调
│ └── IdempotencyResult # record:value + idempotentHit
└── ratelimit/
├── OpenApiRateLimitManager # RRateLimiter 封装
└── RateLimitPolicy # record:permitsPerMinute(带校验)
上层是 forge-plugin-capability-platform 的开放网关,九步编排:
sql
scope 授权 → 授权目录 → 主体类型 → RBAC 权限 → 限流 → 幂等 → Schema 校验 → 受控执行 → 审计
顺序是对的------限流在幂等之前(避免无效请求占用幂等锁),幂等在业务执行之前。这点想得很清楚。
坑 1:限流阈值改了不生效,而且每个请求多跑一次 Redis
现象
writePermitsPerMinute 从 20 改成 200,重启后限流阈值还是 20。
源码
java
public void acquire(String scopeKey, String operation, RateLimitPolicy policy) {
if (StringUtils.isBlank(scopeKey) || StringUtils.isBlank(operation) || policy == null) {
throw new BusinessException(401, "开放API限流主体缺失");
}
String rateKey = keyPrefix + ":rate:" + operation + ":" + scopeKey;
try {
RedissonClient client = redissonClientProvider.getIfAvailable();
if (client == null) {
throw serviceUnavailable();
}
RRateLimiter limiter = client.getRateLimiter(rateKey);
limiter.trySetRate(RateType.OVERALL, policy.permitsPerMinute(), Duration.ofMinutes(1)); // ← 这里
if (!limiter.tryAcquire()) {
throw new BusinessException(429, "请求过于频繁,请稍后再试");
}
} catch (BusinessException exception) {
throw exception;
} catch (RuntimeException exception) {
log.error("开放API限流不可用: scopeKey={}, operation={}, exceptionType={}",
scopeKey, operation, exception.getClass().getSimpleName());
throw serviceUnavailable();
}
}
为什么
Redisson 的 trySetRate 语义是"不存在才设置"。我们的 rateKey 里不包含速率值,所以只要客户端调过一次,这个桶的速率就永久固化在首次设置的值上,直到 Redis key 被删除。
更隐蔽的是第二个问题:trySetRate 每个请求都调一次。它是一次 Redis Lua 脚本执行,等于每个请求多一次网络往返。热路径上这是白白多出来的 RTT。
怎么改
分清「首次初始化」和「已存在」两条路径,用返回值判断:
java
RRateLimiter limiter = client.getRateLimiter(rateKey);
if (!limiter.isExists()) {
limiter.trySetRate(RateType.OVERALL, policy.permitsPerMinute(), Duration.ofMinutes(1));
} else if (policy.permitsPerMinute() != limiter.getConfig().getRate()) {
// 配置漂移:以配置为准强制纠正(低频路径,只在变更时命中)
limiter.setRate(RateType.OVERALL, policy.permitsPerMinute(), Duration.ofMinutes(1));
}
if (!limiter.tryAcquire()) {
throw new BusinessException(429, "请求过于频繁,请稍后再试");
}
嫌每次都判断麻烦,也可以在应用启动时预热一次,之后只 tryAcquire()------但要记得留一个运营侧的"重置限流"入口,否则线上想调整只能去删 Redis key。
坑 2:防重放跑在验签之前(这条最值得改)
现象
任何人只要凑齐 X-Forge-App-Id / X-Forge-Timestamp / X-Forge-Nonce 三个格式合法的请求头,不管签名对不对,都能往 Redis 里写一条 nonce 记录,存活 10 分钟。
源码
OpenGatewayAuthenticator.authenticateSignature() 的执行顺序:
java
String appId = StringUtils.trimToNull(request.getHeader(HEADER_APP_ID));
String timestamp = StringUtils.trimToNull(request.getHeader(HEADER_TIMESTAMP));
String nonce = StringUtils.trimToNull(request.getHeader(HEADER_NONCE));
String signature = StringUtils.trimToNull(request.getHeader(HEADER_SIGNATURE));
if (appId == null || timestamp == null || nonce == null || signature == null) {
throw unauthorized("签名请求头不完整");
}
long timestampMillis;
try {
timestampMillis = Long.parseLong(timestamp);
} catch (NumberFormatException exception) {
throw unauthorized("请求时间戳格式非法");
}
assertNotReplayed(appId, timestampMillis, nonce); // ① 先消费 nonce,写 Redis
Long clientId;
try {
clientId = Long.valueOf(appId);
} catch (NumberFormatException exception) {
throw unauthorized("签名 AppId 格式非法");
}
AiCapabilityClient client = CapabilityTenantContext.executeCredentialLookup(
() -> clientMapper.selectCredentialById(clientId)); // ② 再查客户端
if (client == null || !"ENABLED".equals(client.getStatus()) /* ... */) {
throw unauthorized("签名凭据无效"); // ③ 凭据校验
}
verifySignature(request, body, appId, timestamp, nonce, signature, client); // ④ 最后才验签
而 OpenApiReplayGuard 那一头是真的会写 Redis:
java
String nonceKey = keyPrefix + ":nonce:" + appId + ":" + nonce;
RBucket<String> bucket = client.getBucket(nonceKey);
if (!bucket.setIfAbsent("1", Duration.ofMillis(nonceTtlMillis))) {
throw new BusinessException(401, "请求nonce已被使用,疑似重放");
}
为什么这是问题
① nonce 存储 DoS。 攻击者不需要任何有效凭据,循环发 appId=1&nonce=aaaaaaaa... 就能往 Redis 灌 key。nonce 最长 64 字符、TTL 10 分钟,量上来就是实打实的内存压力。限流能挡一部分,但限流是按客户端 ID 分桶 的,伪造请求可以散布到海量 appId 上绕开。
② nonce 抢占。 真实客户端如果和攻击者撞了 nonce(同 appId 下),合法请求会被判"疑似重放"拒掉。概率不高但存在,而且排查时你只会看到"偶发 401"。
③ 顺序反了。 AWS Signature V4、阿里云、微信支付的开放接口,标准做法都是先验签,再查 nonce。原因很直接:验签通过才说明这个请求来自持有密钥的合法方,此时消费它的 nonce 才有意义。反过来等于把资源消耗放在了身份认证之前。
怎么改
把 assertNotReplayed 挪到 verifySignature 之后:
java
AiCapabilityClient client = ...selectCredentialById(clientId);
if (client == null || !"ENABLED".equals(client.getStatus())) {
throw unauthorized("签名凭据无效");
}
verifySignature(request, body, appId, timestamp, nonce, signature, client); // 先验签
assertNotReplayed(appId, timestampMillis, nonce); // 再防重放
顺带加一层 nonce 长度上限已经在正则里了({8,64}),再加个按 appId 的 nonce 写入速率限制会更稳。
坑 3:幂等锁 30 秒租期、不续期(接上一篇)
现象
业务执行超过 30 秒时,幂等锁自动释放,第二个相同 Idempotency-Key 的请求能进来,两个都执行业务。
源码
java
RLock lock = client.getLock(lockKey);
boolean acquired = lock.tryLock(lockWaitMillis, lockLeaseMillis, TimeUnit.MILLISECONDS);
lockLeaseMillis 默认 30_000。又是三参 tryLock ------ 和上一篇 RedissonLockManager 一模一样的问题:传了 leaseTime,Redisson 就认为你自己知道要锁多久,watchdog 不启动,到期必释放。
为什么没炸
因为设计上留了兜底:
java
T fresh = action.get();
try {
command.snapshotWriter().accept(keyHash, fresh);
} catch (DuplicateKeyException exception) {
T concurrent = loadSnapshot(command, keyHash, "DUPLICATE_SNAPSHOT_LOAD");
if (concurrent != null) {
return IdempotencyResult.hit(concurrent); // 并发下回查到对方的快照
}
throw new BusinessException(409, "幂等记录写入冲突");
}
数据库表上有唯一索引兜底:
sql
UNIQUE KEY uk_ai_capability_openapi_idem (tenant_id, client_id, capability_id, idempotency_key_hash, del_flag)
两个请求都执行了业务,但第二个插入时撞唯一约束,回查拿到第一个的快照返回。结果正确,代价是业务被跑了两次------如果能力是"扣款""发短信",这两次是有副作用的。
怎么改
分两步:
第一步,开放接口的幂等锁应该走 watchdog(业务型锁,时长不可控):
java
// 业务执行时长不可预期 → 交给 watchdog 续期
if (lock.tryLock(lockWaitMillis, TimeUnit.MILLISECONDS)) {
// 注意:不再传 leaseTime
}
第二步 ,如果不想用 watchdog(怕忘记释放),至少把 idempotencyLockLeaseMillis 配置成大于业务 P99 时长,并在配置注释里写清楚"这个值必须大于业务最大执行时间"。默认 30 秒对涉及外部系统调用的能力来说偏紧。
坑 4:快照反序列化失败 → 503,这个 key 永久废掉
现象
某个 Idempotency-Key 第一次调用成功后,后续重试全部 503「开放API幂等服务暂不可用」------永久性的,重试一万次也是 503。
源码
上层的 loadSnapshot:
java
private OpenGatewayResponse loadSnapshot(
CapabilitySecurityPrincipal principal, Long capabilityId, String keyHash) {
AiCapabilityOpenapiIdempotency snapshot = idempotencyMapper.selectActiveSnapshot(
principal.tenantId(), principal.clientId(), capabilityId, keyHash);
if (snapshot == null) {
return null;
}
try {
return objectMapper.readValue(snapshot.getResponseSnapshot(), OpenGatewayResponse.class);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("幂等响应快照反序列化失败", exception); // ← RuntimeException
}
}
管理器的兜底:
java
private <T> T loadSnapshot(IdempotencyCommand<T> command, String keyHash, String phase) {
try {
return command.snapshotLoader().apply(keyHash);
} catch (RuntimeException exception) {
throw infrastructureUnavailable(command.scopeKey(), phase, exception); // ← 一律 503
}
}
为什么
IllegalStateException 是 RuntimeException,被统一归类成"基础设施故障"。
但真实情况是:快照存在,只是反序列化不了 ------通常是响应结构升级(比如 OpenGatewayResponse 加了个字段、改了枚举),老快照解析不了。
归类成 503 有两个后果:
- 误导排查。日志写的是"幂等基础设施异常",运维会去查 Redis 和数据库,而实际上两边都健康。
- 无法自愈。503 意味着"稍后重试",但这个 key 的数据永远解析不了,重试多少次都是 503。
怎么改
把"快照损坏"和"基础设施故障"分开:
java
// 上层:定义明确的快照损坏异常
catch (JsonProcessingException exception) {
throw new IdempotencySnapshotCorruptedException(keyHash, exception);
}
// 管理器:只对真正的 IO / 连接类异常降级 503
private <T> T loadSnapshot(IdempotencyCommand<T> command, String keyHash, String phase) {
try {
return command.snapshotLoader().apply(keyHash);
} catch (IdempotencySnapshotCorruptedException exception) {
// 快照损坏 = 幂等记录不可用,但业务本身可以重试执行
log.warn("[开放API幂等] 快照损坏,按未命中处理: scopeKey={}, phase={}",
command.scopeKey(), phase, exception);
return null;
} catch (DataAccessException exception) {
throw infrastructureUnavailable(command.scopeKey(), phase, exception);
}
}
按"未命中"处理比报 503 好:业务重新执行一次,然后用新格式覆盖写快照。要付出的代价是这一次可能重复执行,但至少不会永久卡死。
坑 5:快照写失败返回 503,可业务已经跑完了
现象
调用方收到 503,按约定重试,结果业务执行了两次。
源码
java
T fresh = action.get(); // ① 业务执行(有副作用)
try {
command.snapshotWriter().accept(keyHash, fresh); // ② 写快照
} catch (DuplicateKeyException exception) {
T concurrent = loadSnapshot(command, keyHash, "DUPLICATE_SNAPSHOT_LOAD");
if (concurrent != null) {
return IdempotencyResult.hit(concurrent);
}
throw new BusinessException(409, "幂等记录写入冲突");
} catch (RuntimeException exception) {
throw infrastructureUnavailable(command.scopeKey(), "SNAPSHOT_WRITE", exception); // ③ 503
}
return IdempotencyResult.fresh(fresh);
为什么
action.get() 已经跑完了,业务副作用已经发生(钱扣了、短信发了)。这时写快照失败(非唯一键冲突,比如连接超时、字段超长),方法抛 503。
调用方看到 503 = "服务不可用",标准动作就是重试 。重试时 loadSnapshot 返回 null(快照没写进去)→ 判定为首次请求 → 业务再跑一次。
幂等在这一刻是失效的,而且失效得很隐蔽:调用方以为自己只是在重试一个"没成功"的请求。
怎么改
关键原则是:业务已执行却无法返回结果的请求,不能给调用方"可以重试"的信号。
java
} catch (RuntimeException exception) {
if (executionCompleted) {
// 业务已执行,重试会导致重复副作用:返回 409 让调用方走人工/查询确认,而不是无脑重试
throw new BusinessException(409,
"请求已执行但结果未能持久化,请勿重试,使用 Idempotency-Key 查询处理结果");
}
throw infrastructureUnavailable(command.scopeKey(), "SNAPSHOT_WRITE", exception);
}
配合一个「按 Idempotency-Key 查询处理结果」的接口,调用方就能安全地拿到结果,而不是重试。
如果这个改法太重,退而求其次的方案是:先写快照占位再执行业务(状态机:PROCESSING → DONE),这样即使业务中途失败,重试也能看到"处理中"而不是空白。
它做对的地方(这几条值得抄)
扒了这么多坑,但这个模块的工程态度比前面拆过的几个 starter 都好,有几处可以直接抄进自己的项目。
fail-closed,绝不降级放行
三个组件在 Redis 不可用时的处理完全一致:
java
RedissonClient client = redissonClientProvider.getIfAvailable();
if (client == null) {
throw serviceUnavailable(); // 503,不是放行
}
而且异常分类很讲究------业务异常原样上抛,只有基础设施异常才转 503:
java
} catch (BusinessException exception) {
throw exception; // 业务异常不吞
} catch (RuntimeException exception) {
log.error(...);
throw serviceUnavailable(); // 只有连不上/超时才 503
}
对比一下我之前拆的 auth 模块:登录锁用 @Autowired(required = false),Bean 缺失时 isLoginLockEnabled() 静默返回 false,防护直接消失还一句日志都不打。一个是 fail-closed,一个是 fail-open,安全意识差了一个量级。
ObjectProvider 延迟取值
java
RedissonClient client = redissonClientProvider.getIfAvailable();
用 ObjectProvider 而不是直接 @Autowired,Redis 没配时拿到 null 走 503,而不是启动就失败。这让整套安全组件在"没引 Redis 的环境"下也能装配,只是不能提供服务。
释放锁前检查持有者
java
private void unlock(RLock lock, String scopeKey) {
if (lock == null) {
return;
}
try {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
} catch (RuntimeException exception) {
log.warn("释放开放API幂等锁失败: scopeKey={}, exceptionType={}",
scopeKey, exception.getClass().getSimpleName(), exception);
}
}
isHeldByCurrentThread() 这一步挡住了最危险的一种事故:业务超时导致锁已被别人持有,你来 unlock 把别人的锁释放了。改成 watchdog 之后这一步更重要------租期不固定了,超时概率更高。
Idempotency-Key 存哈希不存明文
java
private String hash(String value) {
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
}
存 SHA-256(定长 64 字符)而不是原始 key。好处有三个:定长、避免原始 key 里的业务信息泄露(很多人的 Idempotency-Key 里带订单号/手机号)、天然避开字符集问题。
清理任务是真的挂上调度了
java
@JobHandler(value = "capabilityOpenapiIdempotencyClean",
description = "开放网关幂等快照清理", group = "CAPABILITY")
public class OpenapiIdempotencyCleanJob implements IJobExecutor {
private static final int DEFAULT_BATCH_SIZE = 500;
private static final int MAX_BATCHES = 200;
// 分批循环删除,避免大事务
}
对比一下 excel 模块的 cleanupExpiredTasks()------写了实现、写了测试,全项目找不到任何调度调用点 ,临时文件永不释放。这边不但挂了 @JobHandler,还做了分批(单批 500、最多 200 批)避免长事务。
总开关默认关闭
java
private boolean enabled = false; // 网关总开关,默认关闭(失败关闭策略)
开放网关这么高危的能力,默认是关的。这个默认值选择很正确。
可带走的 6 条诀窍
-
trySetRate的 "try" 是字面意思 ------ 只在首次生效。凡是"配置项决定速率"的场景,都要显式处理"已存在但配置漂移"的情况,或者留运营侧重置入口。 -
防重放永远放在验签之后。 顺序应该是:解析请求 → 查凭据 → 验签 → 校验时间窗 → 消费 nonce。把非认证请求挡在资源消耗之前,不然一个伪造请求就能写你的存储。
-
业务型锁用 watchdog(两参
tryLock),不要用固定租期。 判断标准就一条:你能不能确定业务最长跑多久?不能就用 watchdog。 -
异常分类要区分"数据坏了"和"设施挂了"。 数据坏了通常可以按"未命中"降级处理(自愈),设施挂了才该 503 让调用方稍后重试。混在一起就会出现"永久 503"。
-
业务已执行但结果没存下来时,绝不能返回可重试的错误码。 返回 409 + 查询入口,而不是 503。503 对调用方语义是"可以重试",这在有副作用的接口上就是重复执行。
-
幂等 key 存哈希。 定长 + 不泄露业务信息 + 避开字符集问题,一次 SHA-256 全解决。
实战接入:给自己的开放接口加三件套
第 1 步:引依赖,确认 Redisson 在
java
@AutoConfiguration
@ConditionalOnClass(RedissonClient.class) // 没有 Redisson 就不装配
@EnableConfigurationProperties(OpenApiSecurityProperties.class)
public class OpenApiSecurityAutoConfiguration {
@Bean @ConditionalOnMissingBean
public OpenApiRateLimitManager openApiRateLimitManager(
ObjectProvider<RedissonClient> redissonClientProvider,
OpenApiSecurityProperties properties) { ... }
// 幂等、防重放同理
}
三个 Bean 都带 @ConditionalOnMissingBean,想覆盖哪个就自己注册一个同名 Bean。
第 2 步:配置(注意这几个值的含义)
yaml
forge:
openapi:
security:
key-prefix: forge:openapi
timestamp-window-millis: 300000 # 时间窗 ±5 分钟(双向)
nonce-ttl-millis: 600000 # nonce 保留 10 分钟,必须 ≥ 时间窗总宽
idempotency-lock-wait-millis: 500 # 等锁 0.5 秒
idempotency-lock-lease-millis: 30000 # 锁租期 30 秒 → 必须 > 业务 P99
nonce-ttl-millis 要 ≥ timestamp-window-millis 的两倍(因为窗口是双向的 ±5 分钟,总宽 10 分钟)。当前默认 600000 = 300000 × 2,刚好卡在边界上。如果你把时间窗调大到 10 分钟而不改 nonce TTL,就会出现"时间窗内可重放但 nonce 已过期"的缝隙。
第 3 步:控制器里按顺序编排
java
@PostMapping("/openapi/capability/{code}")
public OpenGatewayResponse invoke(@PathVariable String code,
@RequestHeader("X-Forge-App-Id") String appId,
@RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey,
HttpServletRequest request,
@RequestBody byte[] body) {
// 1) 认证(内部完成 验签 → 防重放,见坑 2 的修正)
AuthenticatedCapabilityIdentity identity = authenticator.authenticate(request, body);
boolean write = !"READ_ONLY".equals(descriptor.behavior());
// 2) 限流(在幂等之前,避免无效请求占锁)
rateLimitManager.acquire("capability:" + identity.principal().clientId(),
write ? "write" : "read",
RateLimitPolicy.perMinute(write ? 20 : 120));
// 3) 幂等(仅写操作)
if (!write) {
return doExecute(...);
}
IdempotencyCommand<OpenGatewayResponse> command = new IdempotencyCommand<>(
scopeKey, idempotencyKey,
keyHash -> loadSnapshot(principal, capabilityId, keyHash),
(keyHash, resp) -> writeSnapshot(principal, capabilityId, keyHash, requestId, resp));
IdempotencyResult<OpenGatewayResponse> result =
idempotencyManager.execute(command, () -> doExecute(...));
return result.idempotentHit() ? result.value().asIdempotentHit(requestId) : result.value();
}
第 4 步:别忘了挂清理任务
幂等快照默认保留 24 小时,靠 capabilityOpenapiIdempotencyClean 这个 Job 物理删除。如果你的项目没引 job 模块,@ConditionalOnClass(IJobExecutor.class) 会让这个类静默不注册------快照表会一直涨。
自研的话至少加个等价的定时任务,或者用数据库的 TTL 机制。
一句话总结
这个模块态度是对的 (fail-closed、异常分类清晰、清理任务真的挂了调度、总开关默认关闭),收尾差了几处(限流阈值不可调、防重放顺序反了、快照异常分类混了、写失败给了可重试的错误码)。
五个坑里我最想先改的是坑 2------它不需要动架构,只是把两行代码换个位置,但它决定的是"未认证请求能不能消耗你的资源"这种原则性问题。坑 4、坑 5 属于"平时不炸、炸了很难查"的类型,建议一并改掉。
下一篇 :扒 forge-starter-file------文件存储那套本地 / OSS / RustFS 多实现,看看大文件上传、预签名 URL、MIME 校验这些容易翻车的地方是怎么处理的。
如果这篇对你有用,点个赞吧------想一下你自己的项目:限流阈值有没有"改了不生效"的可能?防重放是不是跑在验签前面了?评论区聊聊你踩过的开放接口安全坑,我赌"验签和防重放顺序反了"这条至少一半人中招。
项目地址(文章里所有代码都来自真实仓库):
- Gitee:
https://gitee.com/ForgeLab/forge-admin - GitHub:
https://github.com/yaomindong1996/forge-admin - 文档:
http://www.dlforgelab.com:8084/forge-docs/ - 演示:
http://www.dlforgelab.com:8084/forge/login(admin / 123456)