极简灰度发布实现新老风控系统切流

发布于:2025-09-14 ⋅ 阅读:(14) ⋅ 点赞:(0)

一、背景描述

公司风控服务即将上线全新版本(新风控),为降低全量切流带来的未知风险,决定采用灰度发布策略:

  1. 先让少量用户流量切入新风控系统
  2. 验证无误后逐步放大比例,直至全量
  3. 一旦异常可秒级回滚到老风控系统

约束条件

  • 入口统一由 Nginx 集群承载
  • 老/新风控均为 HTTP 服务,仅 IP 端口不同
  • 必须保证同一用户始终落入同一版本(用户维度一致性)
  • 低延迟、无外部依赖、运维简单

二、总体思路与原理

  1. 在 Nginx 层通过 Lua 模块维护一张共享内存字典
    key = user_name,value = old | new
  2. 用户首次请求按灰度比例随机计算版本并写回字典,后续请求直接读取字典,实现永久绑定
  3. 通过调整灰度比例变量即可完成放量/回滚,仅 reload 即可生效
  4. 共享字典重启会丢失,但允许少量"重新选路";如零容忍可对接 Redis 做二级存储
监控/运维
后端服务
Nginx 层(OpenResty)
外部
user_name
读取/更新
target_ver=old
target_ver=new
access_log 统计
new 占比
错误率/耗时告警
一键 reload 脚本
回滚方案
A. 写死 old
B. gray_rate=0
老风控
192.168.0.10:8080
新风控
192.168.0.11:8080
Nginx 集群
共享内存字典
risk_gray
(key=user_name
value=old|new)
Lua 脚本
access_by_lua_block
灰度比例变量
gray_rate=5%
版本绑定逻辑
首次随机→写字典
后续读字典
用户请求

三、方案优点

  • 0 外部依赖,<1 ms 延迟
  • 单核可扛 5w+ QPS
  • 回滚只需改一行配置 + reload(秒级)
  • 不改业务代码,不分库分表

四、网络拓扑与链路

User → Nginx(OpenResty) → 根据user_name查字典→
├─ old  → 192.168.0.10:8080  老风控
└─ new  → 192.168.0.11:8080  新风控

五、实操步骤(Step by Step)

0. 环境准备

  • 安装 OpenResty(已集成 Lua 模块)
  • 确认 nginx -V--with-http_lua_module

1. 编辑 nginx.conf(http 段顶层)

# 1. 共享内存,10 MB 约可存 80 万用户
lua_shared_dict risk_gray 10m;

# 2. 上游集群
upstream risk_old { server 192.168.0.10:8080 max_fails=2 fail_timeout=10s; }
upstream risk_new { server 192.168.0.11:8080 max_fails=2 fail_timeout=10s; }

2. 在业务 location 中嵌入 Lua 逻辑

location ^~ /api/risk {
    access_by_lua_block {
        -- 取用户名(优先 header,其次参数,其次 cookie)
        local user = ngx.req.get_headers()["X-User-Name"]
                  or ngx.var.arg_user_name
                  or ngx.var.cookie_user_name
        if not user or user == "" then
            ngx.exit(403)   -- 无用户标识直接拒绝
        end

        local dict = ngx.shared.risk_gray
        local ver  = dict:get(user)

        -- 首次访问:按灰度比例计算
        if not ver then
            local gray_rate = 5          -- 初始 5% 可逐步放大
            local h = tonumber(ngx.crc32_long(user)) % 100 + 1
            ver = (h <= gray_rate) and "new" or "old"
            dict:set(user, ver, 2592000) -- 30 天过期
        end

        ngx.var.target_ver = ver        -- 传给 content 阶段
    }

    # 占位变量,content 阶段使用
    set $target_ver "";
    proxy_pass http://$target_ver;
    proxy_set_header X-Risk-Version $target_ver;  -- 方便后台区分版本
    proxy_connect_timeout 1s;
    proxy_read_timeout    3s;
}

3. 灰度放量

  1. 观察新风控错误率、耗时、日志
  2. 修改 gray_rate = 10 → 20 → 50 → 100
  3. 每次执行:
    nginx -t  &&  nginx -s reload
    

4. 紧急回滚

方案 A(瞬间生效)
把 Lua 里 ver = "old" 写死,reload → 所有用户立即回老风控
方案 B(温和)
gray_rate 改 0,新用户全部落老版本,旧映射 30 天后自然淘汰

5. 全量切流(灰度完成)

确认 100% 无异常后,下线老风控节点,将 upstream risk_old 的 IP 改为新风控,注释掉 Lua 段,恢复普通 proxy_pass,再次 reload,即完成全量切换。

六、监控与核对

项目 方法
比例核对 打印 $target_ver 到 access_log,用 ELK/Grafana 统计 new 占比
性能对比 给新风控返回头加 X-Risk-Cost: ms,与老版本对比 P99
异常告警 新风控错误率 >1% 立即触发告警,执行回滚脚本
共享内存监控 通过 ngx.shared.risk_gray:free_space() 定期上报,低于 20% 扩容

七、常见问题 FAQ

Q1 共享内存重启会丢吗?
会丢,但灰度数据允许“重新选路”;可把过期时间调到 30 天降低概率。若零容忍,把 dict:get/ set 换成 redis:get/ set 即可。

Q2 想按"订单号"而不是用户名?
把 Lua 里 user = ... 改成 order_id = ... 即可,其余逻辑不变。

Q3 灰度过程中想加白名单?
在计算 ver 前加一行

if user == "zhangsan" or user == "lisi" then ver = "new" end

Q4 后端想获取版本标识?
已通过 proxy_set_header X-Risk-Version $target_ver 透传,后台直接取 header。

八、附录:一键 reload 脚本(可选)

#!/bin/bash
set -e
nginx -t
nginx -s reload
echo "Nginx reload OK, gray_rate=$(grep gray_rate /usr/local/openresty/nginx/conf/nginx.conf | awk '{print $3}')"

九、结论

采用 “Nginx + Lua + 共享字典” 方案,可在不引入任何外部组件的前提下,实现:

  • 用户维度永久绑定
  • 毫秒级延迟
  • 秒级回滚/放量
  • 单机 5w+ QPS

整个灰度周期只需改动一个变量并 reload,运维成本低、风险可控,适合快速落地新风控系统的灰度发布需求。