小Cの已经记不起来的博客

Nginx 反向代理 幂等设计 配置详解

前阵子排查了一个挺恶心的问题:用户点了一次支付,结果后台生成了两笔扣款单,而且应用日志干干净净,根本找不到重复请求的痕迹。查了一整天才定位到,锅在 Nginx —— 某台机器的配置里不知道哪位前辈抄了一行 non_idempotent,上游超时之后,Nginx 把这笔 POST 原样又发给了备机。代理层一个不起眼的重试行为,直接把没做幂等保护的接口打穿了。

下面把反向代理里跟幂等相关的东西捋一遍,配置都是实测可用的,直接抄就行。

先搞清楚 Nginx 什么时候会重试请求

Nginx 收到上游的失败后,默认不会把错误直接甩给客户端,而是看 proxy_next_upstream 的脸色行事。它的默认值是 error timeout,也就是连接出错或者超时了,就自动换下一台机器再试一次。

听起来很贴心对吧,但这里有个隐含前提:重试是安全的,仅当请求本身幂等。GET 重发一万次也没事,POST 重发一次就是两笔订单。所以官方从 1.9.13 开始改了默认行为——非幂等方法(POST、LOCK、PATCH)的请求体一旦发给了上游,就不再转发到下一台机器,想要老行为必须显式写 non_idempotent。

有两个例外要留意。一是 1.9.13 之前的老版本,POST 超时照样会重试,建议先跑一下 nginx -V 看看版本,没准儿你们线上还躺着几台老古董。二是“请求体还没发出去”的失败不算数,比如上游连接直接被拒绝,这种情况下 POST 也会换机器重试,但这是安全的,不算重复。

代理层:把重试关进幂等的笼子里

想明白上面这些,配置思路就简单了:收窄重试的口子,别让它在业务层不可控的地方反复横跳。

upstream backend {
    server 10.0.0.1:8080 max_fails=2 fail_timeout=10s;
    server 10.0.0.2:8080 max_fails=2 fail_timeout=10s;
}

server {
    listen 443 ssl;

    location / {
        # 保持默认就挺好,千万别手贱加 non_idempotent
        proxy_next_upstream error timeout;
        # 最多试两台,别让它把整个集群挨个戳一遍
        proxy_next_upstream_tries 2;
        # 重试传递的总时间预算,0 是不限制,别学
        proxy_next_upstream_timeout 6s;

        proxy_pass http://backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

两个容易搞混的点说一句。proxy_next_upstream_timeout 管的是请求在多台机器之间传递的整个时间预算,跟单次等响应的 proxy_read_timeout 是两回事,最坏情况的耗时得自己算一遍才有底。另外如果是纯读接口,可以加上 http_502 http_503 换一点可用性,一旦有写请求混进来,就老老实实用默认值。

应用层:幂等键才是真正的兜底

代理层只是少惹事,真正的兜底还是得靠应用自己,也就是现在比较通行的幂等键(Idempotency-Key):客户端为每个业务操作生成一个唯一 ID,服务端拿它去重,重复提交直接返回第一次的结果。

Nginx 这边要做的事很朴素:把这个头透传下去,客户端没带就帮它兜底生成一个,再尽量让同一个键落到同一台上游。

# 放在 http 块里,$request_id 需要 1.11.0 之后才有
map $http_idempotency_key $idempotency_key {
    ""      $request_id;
    default $http_idempotency_key;
}

upstream backend {
    # 按幂等键做一致性哈希,同一个键尽量打到同一台机器
    hash $idempotency_key consistent;
    server 10.0.0.1:8080;
    server 10.0.0.2:8080;
}

server {
    listen 443 ssl;

    location /api/orders/ {
        proxy_next_upstream error timeout;
        proxy_next_upstream_tries 2;
        proxy_next_upstream_timeout 6s;

        proxy_pass http://backend;
        proxy_set_header Idempotency-Key $idempotency_key;
    }
}

这里有个坑我踩过:hash 只能写在 upstream 块里,我当时想当然地塞进了 location,然后 nginx -t 直接给我报错... 翻了文档才确认这指令压根不允许出现在那个位置。

服务端拿到 Idempotency-Key 之后,用「业务类型 + 幂等键」建数据库唯一索引,插入冲突就回查旧结果返回。唯一约束是最诚实的,比什么分布式锁都让人放心。不过说实话,一致性哈希只是优化不是保证,机器扩缩容之后请求照样会飘到别的节点,所以那个唯一索引永远不能省。

上线前自己动手验一遍

别信配置,信验证:故意把上游搞慢(后端临时 sleep 几秒),然后用 curl -X POST 打一笔单子,去数据库里数一数记录条数,是 1 条就没问题,是 2 条就回头检查哪一层在偷偷重试。不出意外的话,以后就再也不会收到“重复扣款”这种工单了。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放