1. 从一条"看起来完全合法"的请求日志说起
去年帮朋友看一个抓取问题,接口日志里有一批请求,签名对、时间戳新、参数合法、UA 正常,风控规则一条都没命中,但数据库里的记录被重复写了几十遍。第一反应是并发问题,加了分布式锁,没用;怀疑是重试机制,翻了客户端代码,也没有。最后把原始报文抓出来一条条比对,才发现问题所在——攻击方把一条抓到的合法请求原封不动地重发了上百次,每次只改一个无关紧要的空字段。签名依然成立,因为它本来就是一个真实合法的请求。
这就是防重放要解决的核心问题:请求是真的,签名也是真的,但"发一次"和"发一百次"之间的区别,服务端没看见。在反爬虫和数据安全的语境下,重放是一种成本极低、收益却不低的攻击手段——不需要逆向你的加密算法,不需要破解你的签名密钥,只需要一个抓包工具和一个循环脚本。很多团队把精力全砸在参数加密、JS 混淆、设备指纹上,却忽略了最基础的一环:同一条请求只应该生效一次。
这篇内容面向的是做接口安全、反爬虫对抗、网关中间件、开放平台 API 设计的同学,从零基础到有一定实战经验都能看。我会把重放攻击的完整链路拆开,讲清楚四种主流防重放方案的取舍逻辑、参数怎么算、代码怎么写、上线后会踩哪些坑。文中涉及的业务场景和项目均为虚构代称,代码是按真实工程实践整理的参考实现,可以直接拿去改。
先把核心关键词摆出来:防重放(Anti-Replay),本质是让"请求"具备**时效性(Timeliness)和唯一性(Uniqueness)**两个属性。时效性解决"这条请求是三天前抓的,今天还能用"的问题;唯一性解决"同一条请求发两次"的问题。这两个属性只要缺一个,防重放就是纸糊的。
2. 重放攻击的完整链路拆解:它到底怎么发生的
2.1 三段式流程:抓、存、重发
绝大多数重放攻击的实现路径非常朴素,可以归结为三步。第一步是捕获:攻击方在客户端侧拿到一条完整的、能通过服务端校验的请求,可能是通过逆向拿到签名算法后自行构造,也可能是通过中间人手段直接截获原始报文。第二步是冻结:把这条请求的 method、path、query、body、header 全部原样保存下来,一个字都不改。第三步是重发:用脚本或工具把这条报文循环投递,频率可能很低(比如每分钟一次,规避频控),也可能很高(比如一秒几百次,直接刷库)。
关键点在于:这三步里,没有任何一步需要理解你的业务逻辑。攻击方不需要知道这个接口是查询订单还是提交表单,不需要知道参数 biz_id 是什么含义,只要能通过校验,重复投递就有收益。收益形式很多——重复下单刷优惠券、重复提交表单刷积分、重复调用付费接口消耗对方额度、重复查询把数据批量拉走。
注意:很多人把重放和"重复提交"混为一谈。前端按钮防抖、表单 token 这些只是防误触,攻击方根本不走你的前端,直接在协议层构造请求,前端防护一概无效。防重放必须做在服务端。
2.2 三类最容易被重放的请求
不是所有接口都值得防重放,把防护无差别铺到全站会带来不必要的性能和复杂度成本。按经验,下面三类接口风险最高,应该优先覆盖。
第一类是有副作用的写接口。包括下单、支付、提交表单、领取奖励、发送消息、创建任务。这类接口被重放一次就是一次真实的业务动作,直接造成资损或数据污染。它们的特征是幂等性天然缺失,同样的参数执行两次,结果不一样。
第二类是高成本的查询接口。比如需要跨多个数据源聚合、需要调用第三方计费服务、需要跑复杂 SQL 的查询。攻击方重放这类接口的动机不是改数据,而是"用一条合法请求反复榨取数据",本质是把你的接口当成免费的爬虫通道。
第三类是带身份凭证的敏感接口。比如导出、下载、鉴权换取、会话续期。这类接口的请求体里往往携带了某种凭证,重放等于凭证的无限复用。
反过来,纯静态资源、无状态的公开列表页、GET 语义的幂等查询,通常不需要上重放防护,上了反而增加延迟。判断标准很简单:同一条请求执行 N 次和执行 1 次,结果是否一致。如果不一致,就该防。
2.3 加了签名为什么还是挡不住
这是我在实际排查中遇到最多的认知误区。很多团队认为"我做了 HMAC 签名,参数被篡改就会被发现,所以安全了"。这个推论漏了一环:签名防的是篡改,不防复制。
签名算法的输入通常是 method、path、排序后的参数、body 摘要、密钥。只要这条请求的输入完全不变,算出来的签名就不变,服务端校验自然通过。攻击方拿到一条带合法签名的报文,直接复制粘贴,签名依旧成立。这就是签名机制在面对重放时的天然盲区。
要补上这个盲区,必须往签名输入里注入每次都变化的因子。业界通用的做法有两个:一是时间戳,让签名带上时效,过期就废;二是随机数(Nonce),让每条请求的签名唯一,服务端记录已用过的值。这两个因子就是防重放体系的地基,后面所有方案都是在这两个地基上盖房子。
3. 四种主流防重放方案的取舍逻辑
3.1 方案一:时间戳加签名窗口
最轻量的方案。客户端在请求里带上当前时间戳ts,服务端收到后计算|now - ts|,超过预设窗口(常见 300 秒、60 秒,甚至 30 秒)就直接拒绝。同时把ts纳入签名计算,防止攻击方篡改时间戳。
这个方案的优点非常明显:零存储。服务端不需要记录任何东西,纯计算,对性能几乎无影响,横向扩展毫无压力,多台机器之间也不需要共享状态。实现成本极低,二十行代码就能落地。
缺点也同样明显:窗口内的重放挡不住。如果窗口是 300 秒,攻击方在这 5 分钟内把同一条请求重发一万次,全部合法。它只压缩了攻击的时间窗口,没有消灭重放的可能性。对于低频敏感接口(比如支付、提现、改密),这个窗口可能够用;对于高频接口,基本等于没防。
3.2 方案二:Nonce 一次性随机数
在时间戳基础上加一个客户端生成的随机字符串nonce,服务端维护一个"已使用 nonce 集合",收到请求先查这个集合,存在则判定为重放,不存在则写入并放行,同时给集合里的记录设置过期时间(通常等于或略大于时间戳窗口)。
这个方案能真正挡住窗口内的重放,是目前开放平台 API 采用最广的做法。关键是nonce必须参与签名,否则攻击方可以随手改 nonce 绕过检测。
代价是引入状态存储。每一次请求都要做一次"查 + 写",存储层的 QPS 压力直接等于接口 QPS。这就带来一系列工程问题:用什么存、怎么保证原子性、过期怎么设、多机房怎么同步、存储挂了怎么办。第 5 节会详细算这笔账。
3.3 方案三:单调递增序列号
客户端维护一个只增不减的计数器seq,每条请求带上当前值并加一。服务端记录每个客户端(或每个会话)处理过的最大seq,新请求的seq必须严格大于记录值才放行,否则拒绝。
这个方案的优势是存储占用极小——每个客户端只需要存一个整数,不需要存整个 nonce 集合。对于设备数量可控、连接相对稳定的场景(比如长连接、客户端 SDK),非常合适。它还能顺带解决乱序问题:天然的防重放 + 防乱序。
局限在于它对客户端的状态管理要求高。客户端必须持久化 seq,一旦本地数据被清空或回滚,seq 会重置,服务端就会把后续请求全部拒掉。多端登录、同一账号多设备并发时,seq 的归属也会变得复杂。另外它无法防止"攻击方拿到 seq 后抢先发一次"这种竞争型重放。
3.4 方案四:服务端下发一次性 Token
流程反过来:客户端先调一个专门的接口申请 token,服务端生成随机串写入存储并返回;客户端携带该 token 发起业务请求;服务端校验 token 存在则消费掉(删除),不存在则拒绝。典型的一次性票据模型,和表单 token 是同一个思路。
它的安全性是四种里最高的,因为 token 由服务端生成、服务端消费,客户端无法伪造也无法预知,天然具备一次性。适合对安全性要求极高的场景,比如资金类操作、关键配置变更。
缺点是多了一次网络往返,而且 token 的申请接口本身也可能被刷。所以通常要配合"一次申请、批量使用"或者"申请接口单独限流"来平衡。另外在移动端弱网环境下,token 申请失败会导致业务请求发不出去,需要设计重试和降级策略。
3.5 四方案横向对比与选型建议
| 维度 | 时间戳窗口 | Nonce 集合 | 递增序列号 | 一次性 Token |
|---|---|---|---|---|
| 是否需要存储 | 不需要 | 需要(集合) | 需要(单值) | 需要(集合) |
| 存储压力 | 无 | 高,等于接口 QPS | 低,等于客户端数 | 中 |
| 窗口内重放防护 | 无效 | 有效 | 有效 | 有效 |
| 客户端改造成本 | 低 | 中 | 高 | 中高 |
| 多端并发友好度 | 高 | 高 | 低 | 高 |
| 额外网络往返 | 无 | 无 | 无 | 有 |
| 典型适用场景 | 内部服务、低频接口 | 开放平台 API | 长连接、SDK | 资金、关键操作 |
我在实际项目里的做法是分层组合,不做单点选择。所有接口默认走时间戳窗口作为兜底(成本几乎为零),写接口和敏感接口叠加 Nonce 校验,资金类操作再叠加一次性 Token。这样既保证了整体覆盖率,又把存储开销控制在真正需要的地方。全部接口都上 Nonce 是典型的过度设计——你会发现存储账单涨得比安全收益快得多。
4. 从零落地一套防重放链路的完整实操
4.1 签名规则的字段设计
先定签名输入。一个能同时防篡改和防重放的签名串,至少要包含下面这些要素,用固定分隔符拼接后做 HMAC-SHA256:
待签名串 = method + "\n" + path + "\n" + sorted_query_string + "\n" + md5(body) + "\n" + timestamp + "\n" + nonce + "\n" + app_key几个细节必须强调。第一,query 参数必须按 key 字典序排序后再拼接,否则客户端和服务端的顺序不一致,签名对不上。第二,body 要用摘要而不是原文,避免大 body 拖慢签名计算,同时统一编码问题。第三,分隔符要固定,用\n比用&好,因为&本身会出现在 query 里,容易造成拼接歧义(这个坑我踩过,两个不同参数能拼出同一个字符串)。第四,app_key 放最后不参与实际运算,只用于服务端定位密钥。
时间戳统一用秒级还是毫秒级要提前约定死。我建议用秒级,字符串长度短,且 5 分钟窗口用秒级精度完全够。毫秒级看着精确,实际只会让时钟偏移问题更敏感。
4.2 服务端校验中间件的参考实现
下面这段是 Python + Redis 的参考实现,思路可以直接平移到任何语言。核心逻辑是:先验时效,再验签名,最后验 nonce 唯一性,三步顺序不能乱。
import time import hashlib import hmac import redis REPLAY_WINDOW = 300 # 时间戳容忍窗口,单位秒 NONCE_TTL = REPLAY_WINDOW + 60 # nonce 记录多留 60 秒余量 rds = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True) def build_sign_string(method, path, query_dict, body_bytes, ts, nonce, app_key): sorted_qs = "&".join(f"{k}={query_dict[k]}" for k in sorted(query_dict)) body_md5 = hashlib.md5(body_bytes or b"").hexdigest() raw = "\n".join([method.upper(), path, sorted_qs, body_md5, str(ts), nonce, app_key]) return raw def verify_request(method, path, query_dict, body_bytes, headers, secret): ts = headers.get("X-Ts", "") nonce = headers.get("X-Nonce", "") sign = headers.get("X-Sign", "") if not ts or not nonce or not sign: return False, "missing_auth_header" # 1. 时效校验:先挡掉过期和历史请求,成本最低 try: ts_int = int(ts) except ValueError: return False, "bad_timestamp" if abs(int(time.time()) - ts_int) > REPLAY_WINDOW: return False, "timestamp_expired" # 2. nonce 格式校验,长度限制可以防住超大 key 攻击 if not (8 <= len(nonce) <= 64): return False, "bad_nonce" # 3. 签名校验,用 compare_digest 防时序攻击 raw = build_sign_string(method, path, query_dict, body_bytes, ts, nonce, headers.get("X-App-Key", "")) expect = hmac.new(secret.encode(), raw.encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(expect, sign): return False, "sign_mismatch" # 4. nonce 唯一性校验:SET NX 保证原子性,一次操作完成"查+写" key = f"replay:nonce:{headers.get('X-App-Key','')}:{nonce}" if not rds.set(key, "1", nx=True, ex=NONCE_TTL): return False, "replay_detected" return True, "ok"这段代码里有两个地方值得单独说。
set(nx=True, ex=NONCE_TTL)是整个方案的核心。很多人写这段会先exists再set,这是典型的 check-then-act 竞态:两个并发请求同时 exists 都返回 false,然后都 set 成功,重放就漏过去了。Redis 的 SET NX EX 是单条命令原子执行,天然解决这个问题。如果用的是别的存储,一定要确认有没有等价的原子操作,没有就得靠分布式锁,性能和复杂度都会上一个台阶。
hmac.compare_digest不能省。普通的==比较在遇到第一个不同字符时就返回,理论上可以通过响应时间差异逐字节猜出正确签名。虽然实际利用难度高,但改一个函数名就能规避的风险,没有理由留着。
4.3 存储层选型与过期时间的计算
存储选型直接决定这套方案的吞吐上限。我按实际用过的三种做个对比:
| 存储 | 单机 QPS 量级 | 过期能力 | 多机房 | 适用规模 |
|---|---|---|---|---|
| 本地内存(如进程内字典) | 极高 | 需自己实现 | 不支持 | 单机、测试环境 |
| Redis | 万到十万级 | 原生 TTL | 支持主从/集群 | 绝大多数生产场景 |
| 分布式 KV(如各类自研存储) | 十万级以上 | 视实现 | 支持 | 超大规模、多活 |
本地内存方案在多机部署下会直接失效——请求被负载均衡打散到不同机器,A 机记录过的 nonce 在 B 机上不存在,重放照样通过。除非做会话粘滞,但这会破坏负载均衡的弹性。所以生产环境基本只能用共享存储。
过期时间的计算逻辑要讲清楚,这里最容易设错。TTL 的取值必须满足一个不等式:
NONCE_TTL >= 时间戳窗口 + 网络传输最大延迟 + 时钟偏移容忍度举个例子,窗口设 300 秒,弱网环境下请求从发出到抵达服务端最长可能耗时 10 秒,客户端和服务端时钟可能偏差 30 秒(未对时的情况下)。那么 TTL 至少应该是300 + 10 + 30 = 340秒,取整设 360 秒比较稳妥。
为什么 TTL 不能小于窗口?假设窗口 300 秒、TTL 只有 100 秒,那么一条 200 秒前发出的请求,它的 nonce 记录已经过期被清了,但时间戳还在窗口内 —— 这条请求可以再被重放一次。TTL 小于窗口等于给攻击方开了一个循环重放的口子,每 100 秒就能重放一次,看起来每次都"新鲜"。
为什么 TTL 也不宜过大?因为存储占用和 TTL 成正比。下一节会给出具体估算。
4.4 时钟偏移的工程处理
时间戳方案对客户端时钟高度敏感。用户手机时间被手动改快 10 分钟,所有请求全部被拒,客诉立刻上来。这个问题在真实环境里比想象中普遍。
处理思路有三层。第一层是服务端返回标准时间,在任意接口的响应头里带上X-Server-Time,客户端 SDK 首次请求时记录下本地时间与服务端时间的差值,后续签名用本地时间 + 差值,把绝对时钟转成相对时钟。第二层是窗口放宽,允许一定的负向偏移,比如窗口设为[-60, +300],容忍客户端慢一分钟以内。第三层是错误码区分,时间戳过期返回专门的错误码,SDK 收到后自动做一次时间同步再重试,而不是直接抛异常给用户。
提示:时间戳窗口不对称设置是个很实用的小技巧。客户端时间偏慢(请求看起来"来自过去")是正常网络延迟的表现,可以放宽;客户端时间偏快(请求看起来"来自未来")几乎没有合理场景,应该收紧甚至完全不放。
5. 性能开销与容量估算:这笔账必须提前算
5.1 Nonce 存储的内存估算方法
很多人上线前不估算,上线后发现 Redis 内存暴涨才开始删数据。这里给一套可以直接套用的算法。
单条 nonce 记录的占用大致包括:key 字符串本身(app_key + nonce,假设平均 60 字节)、value(1 字节)、Redis 内部对象开销(约 50 到 80 字节)、过期字典条目(约 30 字节)。合计按150 字节/条估算比较保守。
需要的记录数 =峰值 QPS × NONCE_TTL。假设某业务峰值 QPS 是 20000,TTL 设 360 秒,那么同时在存的记录数约为20000 × 360 = 720 万条,内存占用约720万 × 150B ≈ 1.03 GB。
这个数字对 Redis 集群来说完全可承受,但要注意两点:一是峰值 QPS 要按接口维度算,不是全站 QPS,只给写接口上 nonce 校验的话,量级会小一个数量级;二是要给 Redis 留出内存碎片和主从复制的冗余,实际申请的容量至少是估算值的 1.5 到 2 倍。
如果算下来内存超预算,有几个优化方向:缩短 TTL(同时缩短时间戳窗口)、把 nonce 长度压到 16 字节以内、对低频接口不开 nonce 校验、用 key 前缀做分片把压力摊到多个实例。
5.2 高并发下的原子性与失败降级
SET NX EX解决了原子性,但还有一个问题没人愿意面对:存储挂了怎么办。
这个决策必须在上线前定好,而且要想清楚两个方向的代价。Fail-Open(存储不可用时放行):保证了业务可用性,但在这段时间内防重放完全失效,如果存储故障恰好被攻击方感知到,就是一次集中打击窗口。Fail-Close(存储不可用时拒绝):安全性拉满,但存储抖一下,全站写接口全部报错,业务直接躺下。
我的选择一般是分接口定策略:普通业务接口 Fail-Open 并打告警,资金和敏感接口 Fail-Close。同时给存储层加本地缓存做二级兜底——在进程内维护一个带 TTL 的小型集合,存储不可用时退化到本地判断,虽然多机场景下有漏判可能,但比完全放开强。这个本地缓存容量要设上限(比如 10 万条,超出后按 LRU 淘汰),防止内存被打爆。
还有一个容易被忽略的点:重试风暴。存储抖动时大量请求失败,客户端 SDK 如果自动重试,会在存储恢复的瞬间涌入数倍流量,直接把存储再打挂一次。所以 SDK 的重试必须带指数退避和随机抖动,服务端也要对同一 app_key 的重试做一层限流。
5.3 一次完整请求的耗时拆解
以 Nonce 方案为例,一条请求在防重放环节的额外耗时大概是:时间戳解析与比较(微秒级)、MD5 与 HMAC 计算(body 在 10KB 以内约 0.1 到 0.5 毫秒)、一次 Redis 往返(同机房约 0.5 到 1 毫秒)。合计约 1 到 2 毫秒。
这个开销放在一般业务接口上可以忽略,但如果是高并发的网关层,就需要关注。优化手段包括:把 Redis 客户端连接池调够(连接数不够时建连开销远大于命令执行)、签名计算用更快的哈希实现、把 nonce 校验做成异步预检(代价是失去强一致性)。我实测下来,把连接池从默认的 10 调到 200 后,P99 延迟能降三成左右,这个收益比优化签名算法大得多。
6. 上线后最容易踩的坑与排查实录
6.1 常见问题速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 大量请求报签名错误 | query 参数未排序、body 编码不一致 | 双端各自打印待签名串逐字节比对 |
| 偶发时间戳过期 | 客户端时钟漂移、窗口过窄 | 检查服务端时钟同步、加 X-Server-Time |
| 重放判定误伤正常用户 | TTL 小于窗口、多机房数据不同步 | 核对 TTL 与窗口关系、检查存储拓扑 |
| 存储内存持续上涨 | TTL 未设置成功、key 未带前缀 | 抽样查 TTL、确认 set 时 ex 参数生效 |
| 用户双击按钮报重放 | 前端未做防抖、SDK 未复用 nonce | 前端按钮置灰、SDK 层保证请求级唯一 |
| 高峰期接口超时率上升 | Redis 连接池不足、单 key 热点 | 扩容连接池、检查是否有超大 key |
6.2 几个只有踩过才知道的细节
第一个坑:GET 请求的 query 参数里带数组。当参数是ids=1&ids=2&ids=3这种形式时,不同语言的解析器处理方式不一样——有的解析成列表,有的只保留最后一个,有的保留第一个。如果两端解析结果不同,签名必然对不上。解决办法是在文档里明确约定数组参数的序列化格式,比如统一用逗号拼接成字符串,或者统一按 JSON 字符串传。
第二个坑:body 为空时 md5 的计算。空字符串的 MD5 是d41d8cd98f00b204e9800998ecf8427e,但很多客户端在 body 为空时直接跳过这一步,拼进去一个空串,服务端算出来是那串固定值,两边就不一致了。这类问题在联调阶段最容易发现,上线后才发现的话排查成本极高。建议在 SDK 里对空 body 统一处理,别让业务方每次自己判断。
第三个坑:代理层改写了请求。请求经过反向代理、网关、CDN 之后,path 可能被改写(比如加了前缀、去了尾部斜杠)、header 可能被增删、body 可能被重新序列化。签名必须基于最终到达业务服务的那一份内容计算,否则前面任何一层动一下,签名就废了。我见过一个案例,网关默认把 path 里的双斜杠规范化成单斜杠,导致所有带双斜杠的接口签名全部失败,排查了半天。
第四个坑:nonce 用了弱随机。有客户端图省事,nonce 直接用时间戳 + 自增数。这在单实例下没问题,但多实例并发时两个客户端可能撞出同一个 nonce,后发的那个被判为重放。nonce 必须用足够熵的随机源,UUID v4 或者 16 字节随机 hex 都可以,长度控制在 32 字符左右比较合适。
第五个坑:日志里打印了完整签名。排查问题时把待签名串和签名都打到日志里,方便是方便,但密钥一旦泄露,攻击方可以离线构造任意请求。签名相关的日志要脱敏,至少要截断,同时控制日志的保留周期。
提示:联调阶段最有效的手段是双端打印待签名串,逐字节比对,把两个字符串并排贴出来看差异。绝大多数签名问题看一眼就能定位,比猜快一百倍。
7. 防重放在整体反爬体系里的位置
7.1 它不是一道墙,而是一层滤网
单独看防重放,它挡住的只是"原样重发"这一种攻击形态。真实的反爬对抗是多层叠加的:频控挡住高频请求、设备指纹识别异常客户端、行为分析发现机器规律、参数加密提高逆向成本、防重放消除重复利用。
这几层之间是有协同关系的。举个具体例子:攻击方拿到一条合法请求,想批量拉数据,通常会做两件事——改参数(换 ID 换条件)和重发(同一条请求反复用)。改参数会被签名拦下,除非他能算出新签名;重发会被防重放拦下,除非他能改出有效的 nonce。签名和防重放组合起来,实际上把攻击门槛从"会抓包"抬到了"会逆向并持有密钥",这个跨度非常大。
反过来说,如果只做频控不做防重放,攻击方完全可以用极低的频率(比如 10 秒一次)慢慢重放,永远不触发频控阈值,但一天下来也能刷 8000 多次。这类"低速重放"是频控的盲区,恰恰是防重放的主场。
7.2 误杀控制与灰度上线的节奏
防重放一旦上错,表现是"正常用户用不了",杀伤面比被攻击还大。所以上线节奏必须是灰度的。
我的做法是分四步走。第一步,只观察不拦截:服务端完整执行校验逻辑,但不返回错误,只把校验结果打进日志和监控指标。观察一到两周,看误判率有多高、都是什么原因造成的。第二步,按 app_key 或客户端版本灰度:先让内部测试客户端开启强制校验,确认稳定后逐步放量。第三步,只对新增客户端强制:老版本客户端继续走观察模式,避免强制升级引发大面积故障。第四步,全量:所有客户端版本强制,同时保留一个紧急开关,出问题能一键切回观察模式。
这四步走下来通常要一个月以上,急不得。中间最关键的是第一步的观察数据——如果误判率超过千分之一,说明方案设计或 SDK 实现有问题,必须先修掉再往下走,硬推上去就是给自己找麻烦。
7.3 一个容易被忽略的收益:数据质量和成本
除了安全收益,防重放对业务数据质量还有一层隐形价值。重复请求被拦掉之后,数据库里的重复记录少了,统计口径干净了,下游做数据分析不用再写一大堆去重逻辑。我待过的一个项目,上了防重放之后,某张流水表的日均写入量直接降了 12%,全是之前被重放刷出来的垃圾数据,顺带把存储成本也省了一点。
另外一个实际收益是接口成本可控。对于调用第三方计费服务的接口,每一条重放都是一次真实的计费。之前有个场景,一天被重放了几十万次,对方账单直接翻倍,加上防重放之后才把口子堵上。这类隐性成本在评估方案时经常被漏掉,但往往比安全损失更疼。
最后分享一个我自己在用的排查习惯:每次遇到"数据重复"类的线上问题,第一件事不是查并发,而是把重复记录的请求 ID 或者签名捞出来,看它们是同一条还是不同的。如果是同一条签名重复出现,那就是重放,方向立刻明确,省掉一大堆弯路。这个小习惯帮我省过至少三次通宵。