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

Nginx 反向代理 分布式锁 配置详解

之前项目上遇到一个挺头疼的事儿,Nginx 反向代理后面挂了三个 Spring Boot 实例,处理用户下单的时候偶发超卖。一开始我寻思这不是应用层该管的事儿吗,锁加在代码里不就完了。结果线上跑了一周,还是有漏网的,查了半天发现是用户同一笔请求因为网关重试打到了两个不同后端实例上,各实例的本地锁压根不互通。

行吧,那就老老实实上分布式锁。但问题是我不想每个后端服务都去改一遍逻辑,十几个微服务呢,改一个漏一个。后来一琢磨,要不直接在 Nginx 这层把锁给拦了?反正所有流量都经过它,加个 lua-resty-redis 不就完了。

然后我就真去 OpenResty 的 GitHub 翻了一晚上 issue,配 lua-resty-redis 的文档写得那叫一个...怎么说呢,对新手不太友好,动不动就是 "please refer to the wiki",我寻思 wiki 上也写得含含糊糊的。可能是我太菜没找着吧...

下面是最终跑通的一套配置,踩过的坑我尽量都标出来了。

首先 Nginx 得用 OpenResty 发行版,原版的没有 lua 模块。我这里用的是 docker pull openresty/openresty:1.25.3.2-alpine-fat,带 fat 后缀的那个,不然 lua-resty-redis 还得自己手动装,麻烦得很。如果你非要用系统包管理器装也行,但别怪我没提醒你 op 命令的坑。

然后配置文件大概长这样,我加了注释说明:

worker_processes 4;
error_log /var/log/nginx/error.log warn;

events {
    worker_connections 4096;
}

http {
    # 这里有个坑,lua_shared_dict 是本地内存,不是分布式的
    # 真正要跨节点用锁,必须走 Redis,别想着这个 dict 能当锁用
    lua_shared_dict order_locks 10m;

    upstream backend_pool {
        server 10.0.1.11:8080 max_fails=3 fail_timeout=30s;
        server 10.0.1.12:8080 max_fails=3 fail_timeout=30s;
        server 10.0.1.13:8080 max_fails=3 fail_timeout=30s;
        keepalive 64;
    }

    server {
        listen 443 ssl;
        server_name order.example.com;

        # 别用 ip_hash 了,我试了,一个公司出口 IP 底下几百号人
        # 全 hash 到一个实例上,负载直接不均,别问我怎么知道的

        location /api/order/create {
            access_by_lua_block {
                local redis = require "resty.redis"
                local red = redis:new()

                -- 这里超时设置血泪教训,默认 1s 根本不够
                -- 我线上 Redis 偶发慢查询 200ms 左右,1s 能撞上
                -- 设成 5s,然后锁的 TTL 一定一定比业务处理时间长
                red:set_timeout(5000)

                local ok, err = red:connect("10.0.2.100", 6379)
                if not ok then
                    ngx.log(ngx.ERR, "redis connect failed: ", err)
                    -- 连不上就直接放行,别卡着用户
                    -- 这步取舍看你自己业务能不能接受,我选的是放行+告警
                    return
                end

                red:auth("your_redis_password")

                -- 用 request_id 当锁的 value,释放的时候校验
                -- 不然超时释放了别的请求的锁,那就更乱了
                local lock_key = "lock:order:" .. ngx.var.http_x_request_id
                local ttl = 30  -- 秒,必须大于业务最慢处理时间

                local res, err = red:set(lock_key, "nginx", "NX", "EX", ttl)
                if not res then
                    -- 说明锁被占了,直接返回 429
                    -- 别用 503,前端对 503 有重试逻辑,会雪崩
                    ngx.status = 429
                    ngx.header["Content-Type"] = "application/json"
                    ngx.say('{"code":429,"msg":"processing, please retry later"}')
                    ngx.exit(429)
                end

                -- 把 lock_key 存到 ngx.var 里,后面用
                ngx.ctx.lock_key = lock_key
            }

            proxy_pass http://backend_pool;
            proxy_http_version 1.1;
            proxy_set_header Connection "";
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_read_timeout 10s;

            log_by_lua_block {
                -- 请求完了把锁释放掉
                if ngx.ctx.lock_key then
                    local redis = require "resty.redis"
                    local red = redis:new()
                    red:set_timeout(2000)
                    local ok = red:connect("10.0.2.100", 6379)
                    if ok then
                        red:auth("your_redis_password")
                        -- 这里有个大坑:不能直接 DEL
                        -- 万一你的请求处理超时了锁自动过期了,
                        -- 下一个请求拿到了同名锁,你这一 DEL 把别人的锁删了
                        -- 得用 Lua 脚本原子校验 value 再删
                        -- 但我这里 value 是 "nginx" 写死的,所以换成
                        -- 随机 uuid 存到 ngx.ctx 里,这里比对
                        -- 懒得搞的话,就接受极小概率的误删吧...
                        red:del(ngx.ctx.lock_key)
                        red:set_keepalive(10000, 100)
                    end
                end
            }
        }

        location / {
            proxy_pass http://backend_pool;
        }
    }
}

配完跑起来之后,我拿 abwrk 对着压了一波,-c 200 -n 2000/api/order/create,Redis 里看 GET lock:order:xxx,确实只有一个 worker 拿到了,其他返回 429。超卖的问题没再复现了。

但说实话这个方案也不算优雅。Nginx worker 是单线程事件模型,access_by_lua_block 里面如果 Redis 慢查询阻塞了,整个 worker 上其他连接都得等着。我线上设了 proxy_read_timeout 10s,Redis 超时 5s,理论最坏情况一个请求卡 15s 才释放 worker,高峰期 4 个 worker 真的顶不住。后来还是退回去在应用层加了 Redisson,Nginx 这层只保留 max_concurrent 限流,锁的事交给服务自己管。

所以如果你也是被这个问题逼到 Nginx 来加锁的,先别急,大概率是上游网关配置有问题,比如重试策略太激进、或者没开幂等校验。Nginx 层的锁当临时止血用可以,别当长期方案。别问我为什么知道,问就是运维工单写了一晚上。

评论

还没有评论。

发表评论

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

未在播放