上一篇 下一篇 分享链接 返回 返回顶部

电竞平台如何在香港服务器上部署Redis+Lua限流机制,避免外挂频繁刷请求影响公平性?

发布人:Minchunlin 发布时间:2025-09-22 11:49 阅读量:608


周末夜里玩家在排位,监控也在飙红。登录、匹配、战绩、皮肤鉴权的 QPS 被“慢而广”刷法推到顶,per-IP 限速和 WAF 先后失手。我在将军澳的机柜边,把 Redis、OpenResty、Lua 串成一条硬线,从正面拽住洪水,直到天亮。

这篇文章就是那一夜以及后续一周的完整落地过程与优化细节。

架构与思路

在边缘(OpenResty)统一做接入,所有限流决策落在 Redis 里用 Lua 脚本原子执行:

  • 不只按 IP,还按 用户ID、设备指纹、接口类型、地理/AS 号做维度叠加;
  • 采用 滑动窗口 + 令牌桶 组合;
  • 灰度开关、动态阈值来自 Redis 配置中心;
  • Redis Cluster/哨兵保证高可用;
  • Redis 不可用时自动降级到 NGINX 自带 limit_req;
  • 指标用 Prometheus + Grafana 实时盯。

机房与硬件选型

我们部署在香港机房(多线 BGP,优先走 PCCW/HGC,回国走优化线路),总体拓扑三层:边缘接入层 → 应用层 → 数据与缓存层。

硬件与角色

角色 规格 NIC 存储 备注
OpenResty 接入层 ×2 AMD EPYC 7413 / 24C / 128GB RAM 25GbE ×2 (LACP) NVMe 1.92TB ×2 (RAID1) 直连 LVS/Anycast,启用 SO_REUSEPORT
应用层 ×N Intel Xeon Gold 6330 / 32C / 192GB 25GbE ×2 NVMe 1.92TB ×2 gRPC/HTTP 服务
Redis 集群 ×6 AMD EPYC 7F52 / 16C / 128GB 10GbE ×2 NVMe 1.92TB ×2 3 主 3 从,Cluster 模式

为何这样配:限流逻辑在线路最前面才能最快丢弃垃圾请求;Redis 单核性能强、延迟稳,Lua 原子脚本足够应对我们的窗口规模;NVMe 主要用于持久化与 AOF/RDB(我们限流键多但短命,更多吃内存)。

系统与内核调优(CentOS 7)

机房里第一件事:把 OS 调到能扛事儿的状态。

1) 基础内核参数 /etc/sysctl.d/99-sysctl.conf

net.core.somaxconn = 65535
net.core.netdev_max_backlog = 250000
net.ipv4.tcp_max_syn_backlog = 262144
net.ipv4.tcp_fin_timeout = 10
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_timestamps = 0
net.ipv4.tcp_sack = 1
net.ipv4.tcp_syncookies = 1
net.ipv4.ip_local_port_range = 10000 65000
net.core.rmem_max = 268435456
net.core.wmem_max = 268435456
net.ipv4.tcp_rmem = 4096 87380 268435456
net.ipv4.tcp_wmem = 4096 65536 268435456
fs.file-max = 2621440
vm.max_map_count = 262144

sysctl --system
ulimit -n 1048576

2) IRQ 绑核与 RPS/RFS:根据 NIC 队列数合理绑核,减少软中断抖动。

3) 时钟与时区:NTP 与 PTP 校时,限流窗口依赖时间精度,小偏差也会误S。

安装 OpenResty 与 Redis

OpenResty(Nginx + LuaJIT)

yum install -y epel-release
yum install -y openresty openresty-resty openresty-opm
# 依赖:lua-resty-redis、lua-resty-http 等
opm get openresty/lua-resty-redis

Redis 7 Cluster(3 主 3 从)

yum install -y gcc jemalloc
# 按官方方式编译或用已打包的 redis 7.x
# 每节点 redis.conf 关键项:

redis.conf(关键节选):

port 6379
protected-mode yes
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 2000
appendonly yes
appendfsync everysec
maxmemory 96gb
maxmemory-policy allkeys-lru
tcp-keepalive 60

初始化集群(在任意一台):

# 替换为你的6个IP:PORT
redis-cli --cluster create \
10.0.0.11:6379 10.0.0.12:6379 10.0.0.13:6379 \
10.0.0.14:6379 10.0.0.15:6379 10.0.0.16:6379 \
--cluster-replicas 1

注意:限流键要落在同一哈希槽,使用 哈希标签 例如 rl:{uid}:api。

接入层 Nginx 配置(OpenResty)

/usr/local/openresty/nginx/conf/nginx.conf(节选)

worker_processes auto;
worker_rlimit_nofile 1048576;

events {
    worker_connections 65535;
    use epoll;
    multi_accept on;
}

http {
    lua_shared_dict rl_counters 10m;  # 用于统计与熔断标记
    lua_package_path "/usr/local/openresty/lualib/?.lua;;";

    # 真实IP获取(若有四层LB/Proxy Protocol)
    real_ip_header proxy_protocol;
    set_real_ip_from 10.0.0.0/8;

    # TLS 优化(注意 0-RTT 仅对幂等接口开启)
    ssl_session_cache shared:SSL:50m;
    ssl_session_timeout 1d;

    upstream app_backend {
        server 10.1.0.10:8080 max_fails=3 fail_timeout=5s;
        keepalive 512;
    }

    server {
        listen 443 ssl http2 proxy_protocol;
        server_name api-esports.example.com;

        # 限流在 access_by_lua* 阶段
        access_by_lua_file /etc/nginx/lua/rl_access.lua;

        location / {
            proxy_read_timeout 2s;
            proxy_connect_timeout 0.2s;
            proxy_send_timeout 2s;
            proxy_http_version 1.1;
            proxy_set_header Connection "";
            proxy_pass http://app_backend;
        }

        # 降级兜底:OpenResty down 或 Redis 挂了则启用本地限速
        limit_req_zone $binary_remote_addr zone=perip:10m rate=30r/s;
        location /login {
            limit_req zone=perip burst=60 nodelay;  # 兜底策略
            proxy_pass http://app_backend;
        }
    }
}

设备指纹与限流维度

维度组合(至少三件套):

  • ip:$binary_remote_addr 或 PROXY 协议里带的真实 IP;
  • uid:登录后从 JWT/Session 获取,未登录则用 guest;
  • dfp(设备指纹):UA + 屏幕信息 + 局部持久化 ID(HMAC 防伪);
  • api:接口路由类别(login、match、profile、assets 等);
  • asn/geo:高风险 ASN、异常地区单独加权。

键名规范(Cluster 哈希标签,示例):

rl:{uid123}:login
rl:{ip:1.2.3.4}:match
rl:{dfp:abcd}:assets

核心:Redis Lua 脚本(滑动窗口)

用途:限制“某窗口内请求次数”。适合登录/验证码/匹配开始等接口。

/etc/nginx/lua/redis_scripts/slide_window.lua:

-- KEYS[1] = key
-- ARGV[1] = now_millis
-- ARGV[2] = window_millis
-- ARGV[3] = limit
-- 返回: {allowed, count}
local key = KEYS[1]
local now  = tonumber(ARGV[1])
local win  = tonumber(ARGV[2])
local lim  = tonumber(ARGV[3])

-- 清理过期窗口
redis.call('ZREMRANGEBYSCORE', key, 0, now - win)

local cnt = redis.call('ZCARD', key)
if cnt >= lim then
    return {0, cnt}
end

-- 使用时间戳+随机数作为 member,避免重复
local member = now .. '-' .. math.random(100000, 999999)
redis.call('ZADD', key, now, member)
redis.call('PEXPIRE', key, win + 1000)

return {1, cnt + 1}

核心:Redis Lua 脚本(令牌桶)

用途:对“热点高频拉取类接口”(例如战绩/资产列表)允许短暂突发但限制长期速率。

/etc/nginx/lua/redis_scripts/token_bucket.lua:

-- KEYS[1] = key
-- ARGV[1] = now_millis
-- ARGV[2] = capacity
-- ARGV[3] = fill_rate (tokens per second)
-- ARGV[4] = cost (default 1)

local key = KEYS[1]
local now = tonumber(ARGV[1])
local cap = tonumber(ARGV[2])
local rate = tonumber(ARGV[3])
local cost = tonumber(ARGV[4] or 1)

local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts     = tonumber(data[2])

if not tokens or not ts then
    tokens = cap
    ts = now
else
    local delta = (now - ts) / 1000.0
    tokens = math.min(cap, tokens + delta * rate)
end

local allowed = 0
if tokens >= cost then
    tokens = tokens - cost
    allowed = 1
end

redis.call('HMSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, math.max(1000, math.ceil(cap / rate) * 1000))

return {allowed, tokens}

接入层 Lua 逻辑(整合)

/etc/nginx/lua/rl_access.lua:

local cjson = require "cjson.safe"
local redis = require "resty.redis"

-- 1) 构造限流维度
local ip  = ngx.var.binary_remote_addr or "0.0.0.0"
local uid = ngx.var.http_x_user_id or "guest"
local api = ngx.var.uri or "/"
local dfp = ngx.var.http_x_device_fp or "na"
local route = (api:match("^/login") and "login")
           or (api:match("^/match") and "match")
           or (api:match("^/profile") and "profile")
           or "assets"

-- 2) 读取动态策略(从本地共享字典缓存,失效时再去 Redis 取)
local function get_policy(route)
  -- 可进一步用 ngx.shared.rl_counters 做本地缓存
  -- 简化:返回静态默认策略,后续可替换为 Redis HGETALL
  local default = {
    window_ms = 1000,
    limit = 30,
    bucket_cap = 60,
    bucket_rate = 30,  -- tokens/s
    cost = 1
  }
  if route == "login" then
    return {window_ms=60000, limit=10, bucket_cap=20, bucket_rate=10, cost=1}
  elseif route == "match" then
    return {window_ms=1000, limit=20, bucket_cap=40, bucket_rate=20, cost=1}
  else
    return default
  end
end

local policy = get_policy(route)
local now_ms = math.floor(ngx.now() * 1000)

-- 3) 组装 key(优先 uid,退化到 ip/dfp)
local key_tag = uid ~= "guest" and ("uid:"..uid) or ("ip:"..ip)
local sw_key  = "rl:sw:{"..key_tag.."}:"..route
local tb_key  = "rl:tb:{"..key_tag.."}:"..route

-- 4) 连接 Redis Cluster(通过 twemproxy 或本地 cluster-proxy)
local red = redis:new()
red:set_timeout(50)
local ok, err = red:connect("10.0.0.21", 6379)
if not ok then
  -- Redis 不可用,降级到本地限速或直接放行(建议兜底 limit_req)
  return
end

-- 5) 载入脚本(可以在 init_by_lua 缓存 SHA)
local sw_script = assert(io.open("/etc/nginx/lua/redis_scripts/slide_window.lua")):read("*a")
local tb_script = assert(io.open("/etc/nginx/lua/redis_scripts/token_bucket.lua")):read("*a")

-- 滑动窗口判定
local res1, err1 = red:eval(sw_script, 1, sw_key, now_ms, policy.window_ms, policy.limit)
if not res1 then
  ngx.log(ngx.ERR, "slide_window eval err: ", err1)
  return
end

if tonumber(res1[1]) == 0 then
  ngx.header["X-RateLimit-Policy"] = "slide_window"
  ngx.header["Retry-After"] = math.floor(policy.window_ms/1000)
  return ngx.exit(429)
end

-- 令牌桶判定
local res2, err2 = red:eval(tb_script, 1, tb_key, now_ms, policy.bucket_cap, policy.bucket_rate, policy.cost)
if not res2 then
  ngx.log(ngx.ERR, "token_bucket eval err: ", err2)
  return
end

if tonumber(res2[1]) == 0 then
  ngx.header["X-RateLimit-Policy"] = "token_bucket"
  return ngx.exit(429)
end

-- 6) 透传剩余额度(用于客户端自适应)
ngx.header["X-RateLimit-Remaining"] = tostring(math.floor(res2[2] or 0))

生产建议:把脚本在 init_by_lua_block 里预加载并保存 sha1,用 EVALSHA,避免每次读文件。

限流规则表(首版上线参数)

路由 维度 滑动窗口 令牌桶 (cap/rate) 说明
/login uid/ip/dfp 60s / 10 次 20 / 10 tps 防爆破、防验证码打点
/match/start uid/ip 1s / 20 次 40 / 20 tps 防频繁进入匹配队列
/profile/* uid 1s / 30 次 60 / 30 tps 资料页拉取
/assets/* uid/dfp 1s / 50 次 100 / 50 tps 皮肤、资产清单

风险加权:高风险 ASN(IDC 段)、代理特征、异常地理位置 → 参数整体下调 20–50%。

白名单:运维检查、CI 探针、对接方 IP 段,走 HSET rl:whitelist ip 1,脚本前置跳过。

压测与观测

压测命令(示例)

# 压 /match/start
wrk -t8 -c2000 -d60s --timeout 2s \
  -H "X-User-Id: 12345" \
  https://api-esports.example.com/match/start

关键监控

  • OpenResty:status / ngx http lua 自定义指标 → Prometheus;
  • Redis:redis_exporter 采集 instantaneous_ops_per_sec、latency、rejected_connections、used_memory;
  • 观察 429 比例、P99 延迟、Redis 脚本耗时。

上线前后数据(真实区间值)

指标 上线前 上线后(T+1)
峰值 QPS(接入层) 85k 110k
应用层 P99 210ms 120ms
429 命中率(风暴时) 2% 18%(攻击被挡)
Redis EVAL P95 - 0.35ms

坑与现场解决

多 NAT/代理导致 per-IP 误S

解决:维度叠加(uid + dfp + ip),并对常见代理 ASN 整体下调阈值而非“一刀切拒绝”。

四层 LB 没传真实源 IP

现象:所有请求都打到同一个 IP 维度,瞬间 429。

解决:启用 PROXY 协议,Nginx 配 real_ip_header proxy_protocol,并配 set_real_ip_from 为 LB 网段。

Redis Cluster 键分片问题

现象:Lua 脚本里跨槽访问报错。

解决:使用哈希标签 {},确保同一维度键落一个槽;脚本不做跨键聚合。

ZSET 滑窗内存膨胀

现象:窗口大、突发多时,ZSET 元素短期堆积。

解决:缩短 window_ms,加大 token bucket 主导;定期抽样审计 key 数量。

时间漂移导致误判

解决:统一 NTP/PTP,脚本以客户端传入毫秒为准,同时提供服务器 TIME 兜底。

脚本冷启动抖动

解决:init_worker_by_lua 预热脚本、EVALSHA;OpenResty reload 前做健康检查。

动态策略与灰度

我们把策略放在 Redis Hash 里,便于灰度与回滚:

# 登录接口默认策略
HSET rl:policy:login window_ms 60000 limit 10 bucket_cap 20 bucket_rate 10

# 针对高风险 ASN 下调
HSET rl:policy:login:asn:4809 limit 6 bucket_rate 6

在 rl_access.lua 里先查 rl:policy:<route>:asn:<asn>,没有就回退到 rl:policy:<route>,再无则用本地默认。发布变更时通过发布系统推送、Prometheus 告警阈值临时提高以防误报。

安全与配套

  • 登录/关键接口强制 POST + 签名(时间戳 + 随机数 + HMAC),降低重放;
  • TLS1.3,对非幂等接口禁用 0-RTT;
  • 蜜罐接口:对明显脚本行为提供“看似能用”的接口,命中后直接黑名单;
  • 滑动封禁:ZINCRBY 记录风险分,超阈值封 5–30 分钟;
  • 旁路调试:给运维预留 X-Debug-Bypass: token,便于排障但接口严格 ACL。

回滚与兜底策略

  • Redis 故障 → 降级到 Nginx limit_req(每路由不同 zone);
  • OpenResty 工程故障 → 四层 LB 直转应用,短时放宽 WAF;
  • 策略误S → Redis 的策略哈希一键回滚(保存为 rl:policy:*:YYYYMMDDHH 版本)。
  • 运维清单(上线 checklist)
  •  PROXY 协议与真实 IP 验证通过
  •  所有接口分类正确;灰度策略可回滚
  •  Redis Cluster 哈希标签检查
  •  P95 EVAL < 1ms;QPS 峰值下 Redis CPU < 40%
  •  429 命中率曲线与攻击时段吻合
  •  降级链路演练(拔 Redis、reload OpenResty)

再一次被告警叫醒,是一周后。同样的时间,同样的机房。只是这次,我在 Grafana 上看见 429 像闸门一样把洪水挡在了平台之外,应用层的 P99 线稳得像一条绷直的弦。

我把椅子拉近一点,顺手把 Lua 脚本里一处注释改得更直白——“不要相信 IP,永远相信多维度”。

附:运维脚本与配置片段汇总

Systemd:OpenResty 服务 /etc/systemd/system/openresty.service

[Unit]
Description=OpenResty
After=network.target

[Service]
Type=forking
ExecStart=/usr/local/openresty/bin/openresty -p /usr/local/openresty/nginx/ -c conf/nginx.conf
ExecReload=/usr/local/openresty/bin/openresty -s reload
ExecStop=/usr/local/openresty/bin/openresty -s quit
LimitNOFILE=1048576
Restart=always

[Install]
WantedBy=multi-user.target

 

Redis Exporter(Prometheus)

./redis_exporter --redis.addr=redis://10.0.0.21:6379 --web.listen-address=:9121

示例:风暴期策略临时下调

HSET rl:policy:assets bucket_rate 30 bucket_cap 60
EXPIRE rl:policy:assets 3600  # 一小时后自动恢复

如果你也正打算在香港的节点上为电竞平台落地限流方案,以上这套“边缘接入 + Redis/Lua 原子决策 + 多维度限流 + 灰度与降级”的组合拳,足够你在凌晨两点半时稳住阵脚。剩下的,就是不断看图表、改阈值、做回放,把“挡住坏人”的副作用对好人降到最低。祝你那一夜也能早点收工。

目录结构
全文