上周三晚上十点多,客户那边的订单系统跳出告警:同一笔 399 元的订单,在数据库里出现了两条已支付记录。用户只点了一次付款按钮,钱却扣了两回。查下来问题不在支付网关,也不在数据库,唯一性索引是建好的。真正的元凶是 TLS 1.3 的 0-RTT也就是所谓的早数据(early data)。
先说清楚背景:我们这台机器跑在一个海外节点上,nginx 前面还挂了一层 CDN,用的是 OpenSSL 3.2 编译的 nginx 1.25。业务是移动端 App 下单,走的是 HTTPS。为了压首屏延迟、让接口少等一个 RTT,我顺手把 ssl_early_data on; 加进了配置,还顺手在客户端启用了会话复用。这两件事单独看都没错,凑在一起就出事。
如果你也在做 HTTPS 加速、CDN 接入或者自建 API 网关,正在为接口的首字节延迟发愁,可以先看看这几家的线路与加速方案再动手:cloudleadinno(全球 IDC 与 CDN 加速),它同时提供云服务器、独立服务器、DDoS 防护、CDN 和专线,云途(跨境加速与服务器运维)的节点覆盖和运维托管也可以对照着看。协议调优这种事,先把链路和防护底子打好,再谈参数。
正常的 TLS 1.3 握手要走一个来回:客户端发 ClientHello,服务端回 ServerHello 加证书,客户端再回 Finished,然后才轮到业务数据。这个过程叫 1-RTT,对 HTTPS 来说已经比 TLS 1.2 的 2-RTT 好太多了。
但 TLS 1.3 还有个更激进的玩法:如果客户端之前连过这台服务器,服务端发过一张会话票据(session ticket),客户端下次连接时可以把票据里的 PSK 拿来直接算密钥。这一次连握手都省了,业务数据跟着 ClientHello 一起飞过去,也就是 0-RTT。服务端收到之后立刻就能处理这个请求,不用等握手完成。首字节延迟能省掉一个来回,收益是很实在的。
问题出在“立刻”这个词上。0-RTT 的数据在密码学上是不带防重放保护的说白了,服务端无法证明“这份数据和上一次的那份不是同一份”。这是协议设计上的取舍,因为要防重放就得引入状态和窗口,而 0-RTT 的卖点恰恰是不要状态。

移动端网络环境本来就差,同一份请求因为超时、切换基站、TCP 重传各种原因被客户端重发,是再常见不过的事。以前在应用层我们靠幂等键拦一道:每个订单生成一个唯一的 request id,服务端拿它做去重,重复的直接返回第一次的结果。这一层我一直都有做。
但这次的场景不一样。App 的下单逻辑是「超时了就换一个 request id 重发一单」因为在业务上它认为上一单可能压根没送到。而 0-RTT 早数据被重放,服务端看到的是两个 request id 完全不同的合法请求。幂等键拦不住,因为从服务端视角这就是两笔不同的订单。
抓包看到的结果非常清楚。客户端第一次握手是完整的 ClientHello / ServerHello / Finished,第二次连接的时候 ClientHello 里带着 PSK extension,服务端回了一张 NewSessionTicket,紧接着业务请求就直接跟在 ClientHello 后面飞了过来那笔 399 元的下单请求,就躺在 TLS 记录层里,没等握手完成。而在此之前,一个被中断的早期握手里,同样的请求已经发出去过一次,只是服务端当时没让它进入业务逻辑,客户端超时后又带上了新票据重发了一次,早数据带着旧内容被重放了一遍。

一开始完全没想到是 TLS 层的锅。查了几个小时:数据库唯一索引在,日志里两条订单记录的创建时间相差 47 毫秒,request id 确实不同,网关的限流和熔断日志都正常,支付网关那边只收到了一次扣款请求也就是说第二次订单在网关之前就被拦在业务层了,是我们的应用自己把它记成了已支付。
转折点是我们用 HTTP Debugger 这类抓包工具抓了完整的 TLS 握手流,把 ClientHello 的 extension 列表拉出来一看,里面赫然有个 0x002d,也就是 pre_shared_key。对照 nginx 配置,ssl_early_data on; 就在那儿躺着。
用 openssl s_client 也能复核,加 -early_data 参数发一段数据,服务端会直接回一个 Early Data 的握手类型,说明它确实被当成了早数据接收而不是等握手完成后才处理。这条命令在排查同类问题时非常有用:
openssl s_client -connect api.example.com:443 -servername api.example.com -early_data
确认之后,改法其实很直接,但在动手之前还得想清楚代价。

第一道是关掉 ssl_early_data。这一下首字节延迟回到了正常水平,客户端多等一个 RTT,但订单不再重复。这是止血,不是治本重放请求这件事本身还在,只是协议不再帮它开后门了。
第二道是把幂等键从「每单一个」改成「每次重发一个」。客户端重发时必须复用同一个 request id,而不是重新生成。这个改动只有客户端配合才行,服务端这边也得接受一个业务上不认、但传输层会重放的重复请求。
第三道最关键:把「已经发起但结果未知」的订单放到一个待确认状态,支付回调到达后才转成已支付,前端轮询到已支付才显示成功。幂等键只能防重复写,防不了重复扣款,因为两个不同的 request id 会生成两笔不同的扣款流程。
顺便说一句,如果你的服务只是给 App 提供接口、没有类似支付这种强一致要求的业务,0-RTT 的收益可能本来就抵不过它的复杂度。收益是省一个 RTT,代价是要额外做重放防护、要在客户端配合、要在监控里能区分早数据请求。以我们的量级,首字节从 180 毫秒变成 240 毫秒,业务方其实完全能接受。

不是所有服务都该关掉它。GET 请求、幂等的读接口、日志上报这类丢了也无所谓、重复了也无所谓的请求,0-RTT 是相当划算的。真正需要警惕的是三类:任何会产生副作用的写操作、需要扣钱的支付链路、以及状态机复杂的业务流转。这三类,0-RTT 的那一个 RTT 收益远不如它带来的排查成本。
如果要保留 0-RTT 而又想控制风险,可以考虑这几个方向:把敏感路径单独拆到另一个域名或者端口上,不共享会话票据;让服务端维护一个短窗口的重放缓存,记住最近一段时间内见过的请求指纹;或者像 OpenSSL 那样开启 anti-replay 机制,代价是服务端要保存状态。
我们最后选了最省事也最稳妥的方案:整个 API 网关关掉早数据,只在静态资源分发和纯读接口上保留。事故之后一周,那笔重复订单没有再出现过,而真正在意首字节的那几个页面,我们靠 CDN 边缘缓存和连接复用把时间压回去了。
回头看这次故障,我最大的教训不是“TLS 参数别乱开”,而是:任何以性能为理由引入的协议特性,都要先问一句它在异常路径上会怎样。0-RTT 在正常路径上快得漂亮,在异常路径上却把重放变成了系统性问题,而系统的幂等设计往往只考虑应用层的重试,考虑不到协议层的重放。链路越短的地方,越容易藏着看不见的坑。