Skip to content

OpenResty 网关怎么用 IP 归属地做地域风控?Lua 模块 + 共享内存缓存实战 ​

活动页被刷,运维的第一反应通常是「先把某个省的流量挡掉」。但真去动手就会发现麻烦:风控逻辑长在 Java 或 PHP 的业务代码里,改一条地区规则要发版;好不容易发上去,刷量的人换个省份又得再发一次。

Nginx/OpenResty 这种网关层,其实是放这类规则最合适的位置——请求还没进业务就被拦掉,规则改完立刻生效。但真正把它写出来,第一个坑就来了:把外部 IP 归属地接口直接放在请求路径上同步调用,每次请求多出两百多毫秒,QPS 一上来网关自己先垮。

这篇文章讲清楚两件事:地域风控的哪些部分该放在网关、哪些不该;以及怎么用 Lua 把「查归属地」这件事的代价压到几乎为零。

一、网关做地域风控,边界在哪 ​

先划清范围,不然很容易写成一坨什么都管的中间件。

该放在网关的:

  • IP 维度的粗筛。省份黑名单、单省请求频次、地区灰度路由。这些判断只需要一个省名,不需要用户是谁。
  • 拦截要早。在 access 阶段直接返回 403/429,后端连接都不用建,被刷时省下的资源很可观。
  • 规则热更新。共享内存里的规则表定时刷新,不用 reload Nginx。

不该放在网关的:

  • 账号维度、行为维度的风控。登录地序列、同设备多账号、行为节奏,这些要查库、要攒上下文,放网关只会把网关拖成第二个业务系统。
  • 把归属地当成唯一判据。公司出口、校园网、运营商级 NAT 后面可能坐着几千个正常人,一个省名不够定罪。

还有一件事值得先记住:同一个 IP 的归属地,几天内基本不会变。这决定了整套设计的重心不在「查得快」,而在「尽量不查」。

二、方案设计:热路径不碰网络 ​

思路可以用一句话概括:外部接口只在「首次见到某个 IP」时被调用一次,之后所有请求都只读共享内存。

具体到 OpenResty 有这么几个点:

热路径 / 冷路径分离。 access_by_lua 里只做 ngx.shared.DICT:get(),这是内存操作,微秒级。真正的 HTTP 请求只在缓存未命中时发生,且发生在同一个请求的处理流程之外——不影响其他请求。

TTL 分三层。 正常结果给 3 天;「查得到但省市为空」(内网地址、部分 IPv6、部分海外 IP)给 10 分钟,别让它长期占着缓存;接口报错、被限速这种失败情况只给 30 秒负缓存,防止接口抖动时几千个请求一起打过去。

防缓存击穿。 冷门 IP 第一次出现时,可能有十几个 worker 同时发现缓存没命中,一起发请求。用 resty.lock 让它们排队,抢到锁的去查,其余等待后重读缓存。

出站要自己限速。 免费版接口是 60 次/分钟的量级,网关瞬时并发很容易超过。超出限额时下游会返回 429,更激进的突发甚至会被前置防护直接拦成 403(连 JSON 响应都没有)。所以要在 Lua 侧控制出站速率,超限就当「查不到」处理。

兜底原则只有一条:查不到就放行。 归属地是加分项,不是门票。接口挂了、限速了、查出来是空的,都不能把正常用户挡在门外。

真实客户端 IP 用 $remote_addr。 过 CDN 或负载均衡时,正确做法是在 Nginx 里配 set_real_ip_from + real_ip_header,让 $remote_addr 自动变成用户真实 IP,而不是在 Lua 里手写解析 X-Forwarded-For——后者一旦漏了可信代理判断,别人随手伪造一个 XFF 就能绕过你的地域规则。

三、Lua 实现 ​

准备两个共享内存区:一个放归属地缓存,一个放频次计数。规则表先写成模块内的常量,后面用定时器刷新,真实项目里换成从配置中心或 Redis 拉。

nginx
# nginx.conf(节选)
http {
    lua_package_path "/usr/local/openresty/lualib/?.lua;;";

    lua_shared_dict geo_cache   32m;   # 归属地缓存
    lua_shared_dict geo_counter 8m;    # 省份频次计数

    # 过 CDN / 负载均衡时:让 $remote_addr 变成真实用户 IP
    # 只信任自己链路上的代理地址,别写 0.0.0.0/0
    set_real_ip_from 10.0.0.0/8;
    real_ip_header   X-Forwarded-For;
    real_ip_recursive on;

    server {
        listen 80;
        server_name api.example.com;

        set $geo_prov "";   # Lua 里给变量赋值前,必须先在这里声明

        access_by_lua_block {
            require("geo_guard").check()
        }

        location /api/ {
            proxy_pass http://backend;
            proxy_set_header X-Geo-Prov $geo_prov;   # 归属地透传给业务,业务不用再查一遍
        }
    }
}

下面是模块本体,放到 lua_package_path 指到的目录里(比如 lualib/geo_guard.lua):

lua
-- lualib/geo_guard.lua
-- 网关层地域风控:IP 归属地查询 + 共享内存缓存 + 省份频次限制
local http  = require "resty.http"
local cjson = require "cjson.safe"
local lock  = require "resty.lock"

local _M = {}

local CACHE = ngx.shared.geo_cache
local CNT   = ngx.shared.geo_counter
local API   = "https://ip9.com.cn/get?ip="
local UA    = "openresty-geo-guard/1.0"

local TTL_OK, TTL_EMPTY, TTL_FAIL = 3 * 86400, 600, 30

-- 规则表:真实项目里由配置中心下发,这里先给默认值,定时器负责刷新
local RULES = {
    blocked = {},                       -- 完全拦截的省份,如 { ["某省"] = true }
    quota   = { default = 20 },         -- 每省每秒请求上限,如 { ["广东"] = 80 }
    enabled = true,                     -- 一键关闭,出问题时不用 reload
}

local HIT, MISS = 0, 0                  -- 粗粒度命中统计,定时上报后清零

-- 查一个 IP 的省份;命中共享内存直接返回,未命中才走外部接口
local function lookup(ip)
    local key  = "geo:" .. ip
    local prov = CACHE:get(key)
    if prov ~= nil then
        HIT = HIT + 1
        return prov ~= "" and prov or nil
    end
    MISS = MISS + 1

    -- 同一 IP 的并发首次请求:只放一个去查接口,其余排队后重读缓存
    local l = lock:new("geo_lock", { exptime = 5, timeout = 1 })
    local _, lerr = l:lock(key)
    if lerr then
        ngx.log(ngx.ERR, "geo guard: lock err ", lerr)
        return nil                       -- 抢不到锁就当作查不到,直接放行
    end

    prov = CACHE:get(key)                -- 拿到锁后重读:其他 worker 可能已经写进来了
    if prov == nil then
        local httpc = http.new()
        httpc:set_timeouts(500, 500, 2000)   -- connect / send / read,单位毫秒
        local res, rerr = httpc:request_uri(API .. ip, {
            method  = "GET",
            headers = { ["User-Agent"] = UA },   -- 带上 UA,别用 urllib 之类的默认值
        })

        if not res then
            ngx.log(ngx.ERR, "geo guard: api err ", rerr)
            CACHE:set(key, "", TTL_FAIL)          -- 负缓存,30 秒内不再打接口
        elseif res.status == 200 then
            local body = cjson.decode(res.body)
            local data = body and body.data
            if data and data.prov and data.prov ~= "" then
                prov = data.prov
                CACHE:set(key, prov, TTL_OK)
            else
                CACHE:set(key, "", TTL_EMPTY)     -- 内网 / 保留地址:查得到但没有位置
            end
        else
            -- 429:超出接口限额;403:突发被前置防护拦下
            ngx.log(ngx.WARN, "geo guard: api status ", res.status)
            CACHE:set(key, "", TTL_FAIL)
        end
    end

    l:unlock()
    return (prov and prov ~= "") and prov or nil
end

-- 按省统计每秒请求数,超出规则的返回 true
local function over_quota(prov)
    local limit = RULES.quota[prov] or RULES.quota.default
    local key   = "q:" .. prov .. ":" .. ngx.time()
    local n, err = CNT:incr(key, 1, 0, 2)   -- init=0,计数键自身 2 秒过期
    if not n then
        ngx.log(ngx.ERR, "geo guard: counter err ", err)
        return false                        -- 计数异常不影响正常流量
    end
    return n > limit
end

function _M.check()
    local ip = ngx.var.remote_addr

    -- 内网、本机、探测流量不参与地域规则
    if ip == "127.0.0.1" or ip == "::1"
       or ip:sub(1, 3) == "10." or ip:sub(1, 8) == "192.168."
       or ip:sub(1, 4) == "172." then
        return
    end

    local prov = lookup(ip)
    ngx.var.geo_prov = prov or ""

    if not RULES.enabled or not prov then
        return                              -- 查不到就放行
    end

    if RULES.blocked[prov] then
        ngx.log(ngx.WARN, "geo guard: blocked prov=", prov, " ip=", ip)
        return ngx.exit(403)
    end

    if over_quota(prov) then
        ngx.header["Retry-After"] = "1"
        ngx.log(ngx.WARN, "geo guard: quota prov=", prov)
        return ngx.exit(429)
    end
end

-- 规则热更新 + 命中率上报,由 init_worker 里的定时器调用
function _M.reload_rules()
    local f = io.open("/etc/nginx/conf.d/geo_rules.json", "r")
    if not f then return end
    local raw = f:read("*a"); f:close()
    local cfg = cjson.decode(raw)
    if type(cfg) == "table" then
        RULES.blocked = cfg.blocked or {}
        RULES.quota   = cfg.quota or { default = 20 }
        RULES.enabled = cfg.enabled ~= false
    end
end

function _M.report()
    ngx.log(ngx.INFO, "geo guard: hit=", HIT, " miss=", MISS,
            " cache_items=", CACHE:get_keys(0))
    HIT, MISS = 0, 0
end

return _M

定时器挂在 http 块的 init_worker_by_lua_block 里:

nginx
init_worker_by_lua_block {
    local guard = require "geo_guard"
    guard.reload_rules()
    ngx.timer.every(30, guard.reload_rules)   -- 规则热生效
    ngx.timer.every(60, guard.report)         -- 命中率、缓存条目数
}

四、几个必须知道的数字和坑 ​

接口调用和缓存读取差四个数量级。 我实测了一遍:单个 IP 查询含网络往返约 250ms(响应体里的 qt 是 0.001,说明服务端处理只花了 1 毫秒,其余都是网络)。共享内存读是微秒级。所以这套方案的效果完全取决于缓存命中率——真实业务里一个活跃 IP 一天会被访问几十上百次,命中率通常能到 99% 以上,出站调用量约等于「每天出现过的独立 IP 数」。

HTTP 403 不是你的代码写错了。 用不带 User-Agent 或者 UA 明显像脚本的客户端高频突发请求时,会直接吃 403,响应体还不是 JSON。今天实测用 Python 的 urllib 默认 UA 请求就是 403,换成正常 UA 立刻正常。所以在 Lua 里显式设置 User-Agent 不是可选项。

内网地址会「查成功但没位置」。 查 192.168.1.1、10.0.0.1 返回 ret=200,但 prov、city、lng、lat 全是空串,isp 写着「内网地址」。如果你只判断 ret == 200 就当成有效数据写进缓存,这些脏数据会一直占着位置。上面的实现里给这种情况单独留了 10 分钟的短 TTL。

共享内存大小心里要有数。 缓存键是 geo: + IP(IPv6 会到 40 多字符),加省名字符串,一条几十到一百来字节。32MB 的 dict 装几十万条没问题,但 ngx.shared.DICT 是按 LRU 淘汰的、且条目数有上限(默认不小,但别指望它当数据库)。真出了问题看日志里的 cache_items,涨到接近容量就该加内存或者缩短 TTL。

规则别只按省份写。 被误伤最多的就是企业、高校、网吧这类共享出口 IP:一个出口被刷,整个省的用户跟着吃 429。落地时至少留两个口子——接口调用失败一律放行;blocked 名单先只记日志不真拦,观察一两天看日志确认没伤到正常业务再启用。

想彻底不依赖外部接口,还有一个选择:在网关本地挂离线库。 OpenResty 生态里有现成的包,比如 opm get Appla/lua-resty-ip2region-xdb 就能用 LuaJIT FFI 直接读 ip2region 的 xdb 文件,查询是本地内存操作。代价是数据新鲜度靠你自己更新,海外 IP 的精度也一般。离线和在线两套方案怎么权衡,可以看本站另一篇《ip2region、qqzeng-ip、GeoIP2 和在线接口怎么选》,那里有同一批 IP 的实测对照。

合规上留个心眼。 IP 归属地属于个人信息范畴,网关缓存只是为了让规则跑起来,别顺手存一份完整日志长期留着。我们这边的做法是:缓存只要省名,业务日志里记省份不记原始 IP。

总结 ​

网关这块做地域风控,技术含量其实不在「怎么查归属地」,而在「怎么少查」:共享内存扛住热路径、锁止住并发击穿、负缓存扛住接口抖动、查不到就放行保住可用性。这套 Lua 模块放到线上大概一百多行,改完规则 30 秒生效,比在业务代码里加一堆 if 要省事得多。

如果你的网关不是 OpenResty(比如 Go 写的自研网关),思路完全一样——本文重点讲的是缓存分层和兜底策略,跟语言无关。IP 归属地接口用的是 IP9 的免费接口 https://ip9.com.cn/get?ip=<IP>(不传 ip 参数则返回调用方自己的归属地,IPv4/IPv6 都支持,免费版 60 次/分钟),字段和额度说明在官网 https://www.ip9.com.cn 上有。