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

周末夜里玩家在排位,监控也在飙红。登录、匹配、战绩、皮肤鉴权的 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 原子决策 + 多维度限流 + 灰度与降级”的组合拳,足够你在凌晨两点半时稳住阵脚。剩下的,就是不断看图表、改阈值、做回放,把“挡住坏人”的副作用对好人降到最低。祝你那一夜也能早点收工。