开放接口限流从 20 改到 200 还是每分钟 20 次:拆完防重放+幂等+限流,我找到 5 个静默失效的坑

文章来源声明: 原文作者:她的男孩; 来源站点:掘金; 原文链接:https://juejin.cn/post/7689299285154922532; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

这篇把开放网关限流、防重放、幂等的隐蔽失效讲透了,适合后端架构、网关与安全组件开发者对照自查,尤其适合外部开放接口和带副作用的幂等场景。

> 上一篇拆 `RedissonLockManager` 时留了个尾巴:三参 `tryLock(wait, lease, unit)` 不启动 watchdog。这次把上层真正调用它的开放网关链路扒了一遍——446 行的 `forge-starter-openapi-security` + 能力开放网关的九步编排,坑比预想的多,而且**这条链路是真实跑在生产上的**,不是我读源码推演的。

起因:限流阈值改了,线上纹丝不动

我们有个开放网关给外部系统调能力接口,写操作限流配的是每分钟 20 次。

某天业务方说不够用,我把配置从 20 改成 200,重启,压测——还是每分钟 20 次被拦。

第一反应是配置没读到。打日志确认读到了,properties.getWritePermitsPerMinute() 打出来就是 200。

那就是 Redisson 的问题了。翻源码,一行:

<span>RRateLimiter</span> <span>limiter</span> <span>=</span> client.getRateLimiter(rateKey);
limiter.trySetRate(RateType.OVERALL, policy.permitsPerMinute(), Duration.ofMinutes(<span>1</span>));
<span>if</span> (!limiter.tryAcquire()) {
    <span>throw</span> <span>new</span> <span>BusinessException</span>(<span>429</span>, <span>"请求过于频繁,请稍后再试"</span>);
}

trySetRate 的语义是 "try" —— rate limiter 不存在才设置,返回 true;已存在则直接返回 false,什么都不改。

我们的 key 是 forge:openapi:rate:write:capability:12,Redis 里早就有了。改配置?改了个寂寞。


TL;DR:六条结论

  1. RRateLimiter.trySetRate() 只在首次生效,改配置不会更新已存在的桶。想生效必须删 key 或换 key 前缀。
  2. 防重放跑在验签之前 —— 未携带任何有效凭据的伪造请求也能往 Redis 里灌 nonce。
  3. 幂等锁租期 30 秒且不续期,业务超时后锁串行化降级为「数据库唯一约束兜底」。
  4. 快照反序列化失败会被当成 503 基础设施故障 —— 快照明明还在,这个 Idempotency-Key 却永久不可用。
  5. 快照写失败返回 503,但业务已经执行完了 —— 客户端重试就重复执行,幂等在这一刻失效。
  6. 组件本身的态度是 fail-closed(Redis 挂了抛 503,绝不降级放行),这一点比登录锁那套 @Autowired(required=false) 强太多。

全景:446 行里有什么

forge-starter-openapi-security(446 行 / 8 类)
├── config/
│   ├── OpenApiSecurityProperties       <span># keyPrefix / 时间窗 / nonce TTL / 锁参数</span>
│   └── OpenApiSecurityAutoConfiguration
├── replay/OpenApiReplayGuard           <span># 防重放:时间窗 + nonce SETNX</span>
├── idempotency/
│   ├── OpenApiIdempotencyManager       <span># 幂等模板方法(核心)</span>
│   ├── IdempotencyCommand              <span># record:scopeKey + key + 快照读写回调</span>
│   └── IdempotencyResult               <span># record:value + idempotentHit</span>
└── ratelimit/
    ├── OpenApiRateLimitManager         <span># RRateLimiter 封装</span>
    └── RateLimitPolicy                 <span># record:permitsPerMinute(带校验)</span>

上层是 forge-plugin-capability-platform 的开放网关,九步编排:

<span>scope</span> 授权 → 授权目录 → 主体类型 → RBAC 权限 → 限流 → 幂等 → Schema 校验 → 受控执行 → 审计

顺序是对的——限流在幂等之前(避免无效请求占用幂等锁),幂等在业务执行之前。这点想得很清楚。


坑 1:限流阈值改了不生效,而且每个请求多跑一次 Redis

现象

writePermitsPerMinute 从 20 改成 200,重启后限流阈值还是 20。

源码

<span>public</span> <span>void</span> <span>acquire</span><span>(String scopeKey, String operation, RateLimitPolicy policy)</span> {
    <span>if</span> (StringUtils.isBlank(scopeKey) || StringUtils.isBlank(operation) || policy == <span>null</span>) {
        <span>throw</span> <span>new</span> <span>BusinessException</span>(<span>401</span>, <span>"开放API限流主体缺失"</span>);
    }
    <span>String</span> <span>rateKey</span> <span>=</span> keyPrefix + <span>":rate:"</span> + operation + <span>":"</span> + scopeKey;
    <span>try</span> {
        <span>RedissonClient</span> <span>client</span> <span>=</span> redissonClientProvider.getIfAvailable();
        <span>if</span> (client == <span>null</span>) {
            <span>throw</span> serviceUnavailable();
        }
        <span>RRateLimiter</span> <span>limiter</span> <span>=</span> client.getRateLimiter(rateKey);
        limiter.trySetRate(RateType.OVERALL, policy.permitsPerMinute(), Duration.ofMinutes(<span>1</span>));  <span>// ← 这里</span>
        <span>if</span> (!limiter.tryAcquire()) {
            <span>throw</span> <span>new</span> <span>BusinessException</span>(<span>429</span>, <span>"请求过于频繁,请稍后再试"</span>);
        }
    } <span>catch</span> (BusinessException exception) {
        <span>throw</span> exception;
    } <span>catch</span> (RuntimeException exception) {
        log.error(<span>"开放API限流不可用: scopeKey={}, operation={}, exceptionType={}"</span>,
                scopeKey, operation, exception.getClass().getSimpleName());
        <span>throw</span> serviceUnavailable();
    }
}

为什么

Redisson 的 trySetRate 语义是"不存在才设置"。我们的 rateKey 里不包含速率值,所以只要客户端调过一次,这个桶的速率就永久固化在首次设置的值上,直到 Redis key 被删除。

更隐蔽的是第二个问题:trySetRate 每个请求都调一次。它是一次 Redis Lua 脚本执行,等于每个请求多一次网络往返。热路径上这是白白多出来的 RTT。

怎么改

分清「首次初始化」和「已存在」两条路径,用返回值判断:

<span>RRateLimiter</span> <span>limiter</span> <span>=</span> client.getRateLimiter(rateKey);
<span>if</span> (!limiter.isExists()) {
    limiter.trySetRate(RateType.OVERALL, policy.permitsPerMinute(), Duration.ofMinutes(<span>1</span>));
} <span>else</span> <span>if</span> (policy.permitsPerMinute() != limiter.getConfig().getRate()) {
    <span>// 配置漂移:以配置为准强制纠正(低频路径,只在变更时命中)</span>
    limiter.setRate(RateType.OVERALL, policy.permitsPerMinute(), Duration.ofMinutes(<span>1</span>));
}
<span>if</span> (!limiter.tryAcquire()) {
    <span>throw</span> <span>new</span> <span>BusinessException</span>(<span>429</span>, <span>"请求过于频繁,请稍后再试"</span>);
}

嫌每次都判断麻烦,也可以在应用启动时预热一次,之后只 tryAcquire()——但要记得留一个运营侧的"重置限流"入口,否则线上想调整只能去删 Redis key。


坑 2:防重放跑在验签之前(这条最值得改)

现象

任何人只要凑齐 X-Forge-App-Id / X-Forge-Timestamp / X-Forge-Nonce 三个格式合法的请求头,不管签名对不对,都能往 Redis 里写一条 nonce 记录,存活 10 分钟。

源码

OpenGatewayAuthenticator.authenticateSignature() 的执行顺序:

<span>String</span> <span>appId</span>     <span>=</span> StringUtils.trimToNull(request.getHeader(HEADER_APP_ID));
<span>String</span> <span>timestamp</span> <span>=</span> StringUtils.trimToNull(request.getHeader(HEADER_TIMESTAMP));
<span>String</span> <span>nonce</span>     <span>=</span> StringUtils.trimToNull(request.getHeader(HEADER_NONCE));
<span>String</span> <span>signature</span> <span>=</span> StringUtils.trimToNull(request.getHeader(HEADER_SIGNATURE));
<span>if</span> (appId == <span>null</span> || timestamp == <span>null</span> || nonce == <span>null</span> || signature == <span>null</span>) {
    <span>throw</span> unauthorized(<span>"签名请求头不完整"</span>);
}
<span>long</span> timestampMillis;
<span>try</span> {
    timestampMillis = Long.parseLong(timestamp);
} <span>catch</span> (NumberFormatException exception) {
    <span>throw</span> unauthorized(<span>"请求时间戳格式非法"</span>);
}

assertNotReplayed(appId, timestampMillis, nonce);          <span>// ① 先消费 nonce,写 Redis</span>

Long clientId;
<span>try</span> {
    clientId = Long.valueOf(appId);
} <span>catch</span> (NumberFormatException exception) {
    <span>throw</span> unauthorized(<span>"签名 AppId 格式非法"</span>);
}
<span>AiCapabilityClient</span> <span>client</span> <span>=</span> CapabilityTenantContext.executeCredentialLookup(
        () -> clientMapper.selectCredentialById(clientId)); <span>// ② 再查客户端</span>
<span>if</span> (client == <span>null</span> || !<span>"ENABLED"</span>.equals(client.getStatus()) <span>/* ... */</span>) {
    <span>throw</span> unauthorized(<span>"签名凭据无效"</span>);                      <span>// ③ 凭据校验</span>
}
verifySignature(request, body, appId, timestamp, nonce, signature, client);  <span>// ④ 最后才验签</span>

而 OpenApiReplayGuard 那一头是真的会写 Redis:

<span>String</span> <span>nonceKey</span> <span>=</span> keyPrefix + <span>":nonce:"</span> + appId + <span>":"</span> + nonce;
RBucket<String> bucket = client.getBucket(nonceKey);
<span>if</span> (!bucket.setIfAbsent(<span>"1"</span>, Duration.ofMillis(nonceTtlMillis))) {
    <span>throw</span> <span>new</span> <span>BusinessException</span>(<span>401</span>, <span>"请求nonce已被使用,疑似重放"</span>);
}

为什么这是问题

① 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 之后:

<span>AiCapabilityClient</span> <span>client</span> <span>=</span> ...selectCredentialById(clientId);
<span>if</span> (client == <span>null</span> || !<span>"ENABLED"</span>.equals(client.getStatus())) {
    <span>throw</span> unauthorized(<span>"签名凭据无效"</span>);
}
verifySignature(request, body, appId, timestamp, nonce, signature, client);  <span>// 先验签</span>
assertNotReplayed(appId, timestampMillis, nonce);                            <span>// 再防重放</span>

顺带加一层 nonce 长度上限已经在正则里了({8,64}),再加个按 appId 的 nonce 写入速率限制会更稳。


坑 3:幂等锁 30 秒租期、不续期(接上一篇)

现象

业务执行超过 30 秒时,幂等锁自动释放,第二个相同 Idempotency-Key 的请求能进来,两个都执行业务。

源码

<span>RLock</span> <span>lock</span> <span>=</span> client.getLock(lockKey);
<span>boolean</span> <span>acquired</span> <span>=</span> lock.tryLock(lockWaitMillis, lockLeaseMillis, TimeUnit.MILLISECONDS);

lockLeaseMillis 默认 30_000。又是三参 tryLock —— 和上一篇 RedissonLockManager 一模一样的问题:传了 leaseTime,Redisson 就认为你自己知道要锁多久,watchdog 不启动,到期必释放。

为什么没炸

因为设计上留了兜底:

<span>T</span> <span>fresh</span> <span>=</span> action.get();
<span>try</span> {
    command.snapshotWriter().accept(keyHash, fresh);
} <span>catch</span> (DuplicateKeyException exception) {
    <span>T</span> <span>concurrent</span> <span>=</span> loadSnapshot(command, keyHash, <span>"DUPLICATE_SNAPSHOT_LOAD"</span>);
    <span>if</span> (concurrent != <span>null</span>) {
        <span>return</span> IdempotencyResult.hit(concurrent);   <span>// 并发下回查到对方的快照</span>
    }
    <span>throw</span> <span>new</span> <span>BusinessException</span>(<span>409</span>, <span>"幂等记录写入冲突"</span>);
}

数据库表上有唯一索引兜底:

<span>UNIQUE</span> KEY uk_ai_capability_openapi_idem (tenant_id, client_id, capability_id, idempotency_key_hash, del_flag)

两个请求都执行了业务,但第二个插入时撞唯一约束,回查拿到第一个的快照返回。结果正确,代价是业务被跑了两次——如果能力是"扣款""发短信",这两次是有副作用的。

怎么改

分两步:

第一步,开放接口的幂等锁应该走 watchdog(业务型锁,时长不可控):

<span>// 业务执行时长不可预期 → 交给 watchdog 续期</span>
<span>if</span> (lock.tryLock(lockWaitMillis, TimeUnit.MILLISECONDS)) {
    <span>// 注意:不再传 leaseTime</span>
}

第二步,如果不想用 watchdog(怕忘记释放),至少把 idempotencyLockLeaseMillis 配置成大于业务 P99 时长,并在配置注释里写清楚"这个值必须大于业务最大执行时间"。默认 30 秒对涉及外部系统调用的能力来说偏紧。


坑 4:快照反序列化失败 → 503,这个 key 永久废掉

现象

某个 Idempotency-Key 第一次调用成功后,后续重试全部 503「开放API幂等服务暂不可用」——永久性的,重试一万次也是 503。

源码

上层的 loadSnapshot:

<span>private</span> OpenGatewayResponse <span>loadSnapshot</span><span>(
        CapabilitySecurityPrincipal principal, Long capabilityId, String keyHash)</span> {
    <span>AiCapabilityOpenapiIdempotency</span> <span>snapshot</span> <span>=</span> idempotencyMapper.selectActiveSnapshot(
            principal.tenantId(), principal.clientId(), capabilityId, keyHash);
    <span>if</span> (snapshot == <span>null</span>) {
        <span>return</span> <span>null</span>;
    }
    <span>try</span> {
        <span>return</span> objectMapper.readValue(snapshot.getResponseSnapshot(), OpenGatewayResponse.class);
    } <span>catch</span> (JsonProcessingException exception) {
        <span>throw</span> <span>new</span> <span>IllegalStateException</span>(<span>"幂等响应快照反序列化失败"</span>, exception);  <span>// ← RuntimeException</span>
    }
}

管理器的兜底:

<span>private</span> <T> T <span>loadSnapshot</span><span>(IdempotencyCommand<T> command, String keyHash, String phase)</span> {
    <span>try</span> {
        <span>return</span> command.snapshotLoader().apply(keyHash);
    } <span>catch</span> (RuntimeException exception) {
        <span>throw</span> infrastructureUnavailable(command.scopeKey(), phase, exception);   <span>// ← 一律 503</span>
    }
}

为什么

IllegalStateException 是 RuntimeException,被统一归类成"基础设施故障"。

但真实情况是:快照存在,只是反序列化不了——通常是响应结构升级(比如 OpenGatewayResponse 加了个字段、改了枚举),老快照解析不了。

归类成 503 有两个后果:

  1. 误导排查。日志写的是"幂等基础设施异常",运维会去查 Redis 和数据库,而实际上两边都健康。
  2. 无法自愈。503 意味着"稍后重试",但这个 key 的数据永远解析不了,重试多少次都是 503。

怎么改

把"快照损坏"和"基础设施故障"分开:

<span>// 上层:定义明确的快照损坏异常</span>
<span>catch</span> (JsonProcessingException exception) {
    <span>throw</span> <span>new</span> <span>IdempotencySnapshotCorruptedException</span>(keyHash, exception);
}

<span>// 管理器:只对真正的 IO / 连接类异常降级 503</span>
<span>private</span> <T> T <span>loadSnapshot</span><span>(IdempotencyCommand<T> command, String keyHash, String phase)</span> {
    <span>try</span> {
        <span>return</span> command.snapshotLoader().apply(keyHash);
    } <span>catch</span> (IdempotencySnapshotCorruptedException exception) {
        <span>// 快照损坏 = 幂等记录不可用,但业务本身可以重试执行</span>
        log.warn(<span>"[开放API幂等] 快照损坏,按未命中处理: scopeKey={}, phase={}"</span>,
                command.scopeKey(), phase, exception);
        <span>return</span> <span>null</span>;
    } <span>catch</span> (DataAccessException exception) {
        <span>throw</span> infrastructureUnavailable(command.scopeKey(), phase, exception);
    }
}

按"未命中"处理比报 503 好:业务重新执行一次,然后用新格式覆盖写快照。要付出的代价是这一次可能重复执行,但至少不会永久卡死。


坑 5:快照写失败返回 503,可业务已经跑完了

现象

调用方收到 503,按约定重试,结果业务执行了两次。

源码

<span>T</span> <span>fresh</span> <span>=</span> action.get();                                   <span>// ① 业务执行(有副作用)</span>
<span>try</span> {
    command.snapshotWriter().accept(keyHash, fresh);       <span>// ② 写快照</span>
} <span>catch</span> (DuplicateKeyException exception) {
    <span>T</span> <span>concurrent</span> <span>=</span> loadSnapshot(command, keyHash, <span>"DUPLICATE_SNAPSHOT_LOAD"</span>);
    <span>if</span> (concurrent != <span>null</span>) {
        <span>return</span> IdempotencyResult.hit(concurrent);
    }
    <span>throw</span> <span>new</span> <span>BusinessException</span>(<span>409</span>, <span>"幂等记录写入冲突"</span>);
} <span>catch</span> (RuntimeException exception) {
    <span>throw</span> infrastructureUnavailable(command.scopeKey(), <span>"SNAPSHOT_WRITE"</span>, exception);  <span>// ③ 503</span>
}
<span>return</span> IdempotencyResult.fresh(fresh);

为什么

action.get() 已经跑完了,业务副作用已经发生(钱扣了、短信发了)。这时写快照失败(非唯一键冲突,比如连接超时、字段超长),方法抛 503。

调用方看到 503 = "服务不可用",标准动作就是重试。重试时 loadSnapshot 返回 null(快照没写进去)→ 判定为首次请求 → 业务再跑一次。

幂等在这一刻是失效的,而且失效得很隐蔽:调用方以为自己只是在重试一个"没成功"的请求。

怎么改

关键原则是:业务已执行却无法返回结果的请求,不能给调用方"可以重试"的信号。

} <span>catch</span> (RuntimeException exception) {
    <span>if</span> (executionCompleted) {
        <span>// 业务已执行,重试会导致重复副作用:返回 409 让调用方走人工/查询确认,而不是无脑重试</span>
        <span>throw</span> <span>new</span> <span>BusinessException</span>(<span>409</span>,
            <span>"请求已执行但结果未能持久化,请勿重试,使用 Idempotency-Key 查询处理结果"</span>);
    }
    <span>throw</span> infrastructureUnavailable(command.scopeKey(), <span>"SNAPSHOT_WRITE"</span>, exception);
}

配合一个「按 Idempotency-Key 查询处理结果」的接口,调用方就能安全地拿到结果,而不是重试。

如果这个改法太重,退而求其次的方案是:先写快照占位再执行业务(状态机:PROCESSING → DONE),这样即使业务中途失败,重试也能看到"处理中"而不是空白。


它做对的地方(这几条值得抄)

扒了这么多坑,但这个模块的工程态度比前面拆过的几个 starter 都好,有几处可以直接抄进自己的项目。

fail-closed,绝不降级放行

三个组件在 Redis 不可用时的处理完全一致:

<span>RedissonClient</span> <span>client</span> <span>=</span> redissonClientProvider.getIfAvailable();
<span>if</span> (client == <span>null</span>) {
    <span>throw</span> serviceUnavailable();      <span>// 503,不是放行</span>
}

而且异常分类很讲究——业务异常原样上抛,只有基础设施异常才转 503:

} <span>catch</span> (BusinessException exception) {
    <span>throw</span> exception;                 <span>// 业务异常不吞</span>
} <span>catch</span> (RuntimeException exception) {
    log.error(...);
    <span>throw</span> serviceUnavailable();      <span>// 只有连不上/超时才 503</span>
}

对比一下我之前拆的 auth 模块:登录锁用 @Autowired(required = false),Bean 缺失时 isLoginLockEnabled() 静默返回 false,防护直接消失还一句日志都不打。一个是 fail-closed,一个是 fail-open,安全意识差了一个量级。

ObjectProvider 延迟取值

<span>RedissonClient</span> <span>client</span> <span>=</span> redissonClientProvider.getIfAvailable();

用 ObjectProvider 而不是直接 @Autowired,Redis 没配时拿到 null 走 503,而不是启动就失败。这让整套安全组件在"没引 Redis 的环境"下也能装配,只是不能提供服务。

释放锁前检查持有者

<span>private</span> <span>void</span> <span>unlock</span><span>(RLock lock, String scopeKey)</span> {
    <span>if</span> (lock == <span>null</span>) {
        <span>return</span>;
    }
    <span>try</span> {
        <span>if</span> (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    } <span>catch</span> (RuntimeException exception) {
        log.warn(<span>"释放开放API幂等锁失败: scopeKey={}, exceptionType={}"</span>,
                scopeKey, exception.getClass().getSimpleName(), exception);
    }
}

isHeldByCurrentThread() 这一步挡住了最危险的一种事故:业务超时导致锁已被别人持有,你来 unlock 把别人的锁释放了。改成 watchdog 之后这一步更重要——租期不固定了,超时概率更高。

Idempotency-Key 存哈希不存明文

<span>private</span> String <span>hash</span><span>(String value)</span> {
    <span>byte</span>[] digest = MessageDigest.getInstance(<span>"SHA-256"</span>)
            .digest(value.getBytes(StandardCharsets.UTF_8));
    <span>return</span> HexFormat.of().formatHex(digest);
}

存 SHA-256(定长 64 字符)而不是原始 key。好处有三个:定长、避免原始 key 里的业务信息泄露(很多人的 Idempotency-Key 里带订单号/手机号)、天然避开字符集问题。

清理任务是真的挂上调度了

<span>@JobHandler(value = "capabilityOpenapiIdempotencyClean",
        description = "开放网关幂等快照清理", group = "CAPABILITY")</span>
<span>public</span> <span>class</span> <span>OpenapiIdempotencyCleanJob</span> <span>implements</span> <span>IJobExecutor</span> {
    <span>private</span> <span>static</span> <span>final</span> <span>int</span> <span>DEFAULT_BATCH_SIZE</span> <span>=</span> <span>500</span>;
    <span>private</span> <span>static</span> <span>final</span> <span>int</span> <span>MAX_BATCHES</span> <span>=</span> <span>200</span>;
    <span>// 分批循环删除,避免大事务</span>
}

对比一下 excel 模块的 cleanupExpiredTasks()——写了实现、写了测试,全项目找不到任何调度调用点,临时文件永不释放。这边不但挂了 @JobHandler,还做了分批(单批 500、最多 200 批)避免长事务。

总开关默认关闭

<span>private</span> <span>boolean</span> <span>enabled</span> <span>=</span> <span>false</span>;   <span>// 网关总开关,默认关闭(失败关闭策略)</span>

开放网关这么高危的能力,默认是关的。这个默认值选择很正确。


可带走的 6 条诀窍

  1. trySetRate 的 "try" 是字面意思 —— 只在首次生效。凡是"配置项决定速率"的场景,都要显式处理"已存在但配置漂移"的情况,或者留运营侧重置入口。
  2. 防重放永远放在验签之后。 顺序应该是:解析请求 → 查凭据 → 验签 → 校验时间窗 → 消费 nonce。把非认证请求挡在资源消耗之前,不然一个伪造请求就能写你的存储。
  3. 业务型锁用 watchdog(两参 tryLock),不要用固定租期。 判断标准就一条:你能不能确定业务最长跑多久?不能就用 watchdog。
  4. 异常分类要区分"数据坏了"和"设施挂了"。 数据坏了通常可以按"未命中"降级处理(自愈),设施挂了才该 503 让调用方稍后重试。混在一起就会出现"永久 503"。
  5. 业务已执行但结果没存下来时,绝不能返回可重试的错误码。 返回 409 + 查询入口,而不是 503。503 对调用方语义是"可以重试",这在有副作用的接口上就是重复执行。
  6. 幂等 key 存哈希。 定长 + 不泄露业务信息 + 避开字符集问题,一次 SHA-256 全解决。

实战接入:给自己的开放接口加三件套

第 1 步:引依赖,确认 Redisson 在

<span>@AutoConfiguration</span>
<span>@ConditionalOnClass(RedissonClient.class)</span>      <span>// 没有 Redisson 就不装配</span>
<span>@EnableConfigurationProperties(OpenApiSecurityProperties.class)</span>
<span>public</span> <span>class</span> <span>OpenApiSecurityAutoConfiguration</span> {
    <span>@Bean</span> <span>@ConditionalOnMissingBean</span>
    <span>public</span> OpenApiRateLimitManager <span>openApiRateLimitManager</span><span>(
            ObjectProvider<RedissonClient> redissonClientProvider,
            OpenApiSecurityProperties properties)</span> { ... }
    <span>// 幂等、防重放同理</span>
}

三个 Bean 都带 @ConditionalOnMissingBean,想覆盖哪个就自己注册一个同名 Bean。

第 2 步:配置(注意这几个值的含义)

<span>forge:</span>
  <span>openapi:</span>
    <span>security:</span>
      <span>key-prefix:</span> <span>forge:openapi</span>
      <span>timestamp-window-millis:</span> <span>300000</span>     <span># 时间窗 ±5 分钟(双向)</span>
      <span>nonce-ttl-millis:</span> <span>600000</span>            <span># nonce 保留 10 分钟,必须 ≥ 时间窗总宽</span>
      <span>idempotency-lock-wait-millis:</span> <span>500</span>   <span># 等锁 0.5 秒</span>
      <span>idempotency-lock-lease-millis:</span> <span>30000</span> <span># 锁租期 30 秒 → 必须 > 业务 P99</span>

nonce-ttl-millis 要 ≥ timestamp-window-millis 的两倍(因为窗口是双向的 ±5 分钟,总宽 10 分钟)。当前默认 600000 = 300000 × 2,刚好卡在边界上。如果你把时间窗调大到 10 分钟而不改 nonce TTL,就会出现"时间窗内可重放但 nonce 已过期"的缝隙。

第 3 步:控制器里按顺序编排

<span>@PostMapping("/openapi/capability/{code}")</span>
<span>public</span> OpenGatewayResponse <span>invoke</span><span>(<span>@PathVariable</span> String code,
                                  <span>@RequestHeader("X-Forge-App-Id")</span> String appId,
                                  <span>@RequestHeader(value = "Idempotency-Key", required = false)</span> String idempotencyKey,
                                  HttpServletRequest request,
                                  <span>@RequestBody</span> <span>byte</span>[] body)</span> {
    <span>// 1) 认证(内部完成 验签 → 防重放,见坑 2 的修正)</span>
    <span>AuthenticatedCapabilityIdentity</span> <span>identity</span> <span>=</span> authenticator.authenticate(request, body);

    <span>boolean</span> <span>write</span> <span>=</span> !<span>"READ_ONLY"</span>.equals(descriptor.behavior());

    <span>// 2) 限流(在幂等之前,避免无效请求占锁)</span>
    rateLimitManager.acquire(<span>"capability:"</span> + identity.principal().clientId(),
            write ? <span>"write"</span> : <span>"read"</span>,
            RateLimitPolicy.perMinute(write ? <span>20</span> : <span>120</span>));

    <span>// 3) 幂等(仅写操作)</span>
    <span>if</span> (!write) {
        <span>return</span> doExecute(...);
    }
    IdempotencyCommand<OpenGatewayResponse> command = <span>new</span> <span>IdempotencyCommand</span><>(
            scopeKey, idempotencyKey,
            keyHash -> loadSnapshot(principal, capabilityId, keyHash),
            (keyHash, resp) -> writeSnapshot(principal, capabilityId, keyHash, requestId, resp));
    IdempotencyResult<OpenGatewayResponse> result =
            idempotencyManager.execute(command, () -> doExecute(...));
    <span>return</span> 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)