
夜里 1:40,我们的 MMO 新赛季凌晨 2 点开服,跨区玩家集中回流,我盯着大屏上的链路与 TPS 曲线,MySQL 的写入峰值像心电图一样往上蹿。上次同类活动,我们就是因为“战斗日志直写数据库”把 InnoDB 打成红区——redo log 满、buffer pool 呻吟、flush 队列顶到天花板。
这次我不打算再当“数据库牛马”。我把战斗记录从 DB 热路径里硬生生掰了出来:Redis + Lua 先落缓存、打包批量入库,让数据库只做“冷静的归档员”,而不是“被连击的盾牌”。下面就是我这次在香港服务器上的完整部署与优化全过程,包括踩坑与补锅。
一、目标与整体方案
目标:
- 战斗记录(Battle Logs)写入在高峰期不拖累业务逻辑线程,P99 < 8ms(缓存路径)。
- 数据库写入从“逐条”改为“批量”,把 DB 写 QPS 压到原来的 1/5 左右。
- 故障可回放、可补偿、不丢日志。
方案要点:
- 接入层:Anycast/智能调度 → Nginx/L4 转发 → 网关服务(鉴权、路由)。
- 游戏逻辑:与 Redis 交互只做“一次脚本调用”,不拼装多条命令。
- 缓存层:Redis Cluster(3 主 3 备)+ Lua 脚本写入 Redis Streams 或双写 List/Hash(视需求),并做限长/计数/TTL。
- 落库层:异步 Stream Consumer Group 批量拉取,写 MySQL(分区表/分库分表)+ 幂等保证。
- 监控与回滚:Prometheus + Grafana,AOF+RDB 双保险,消费偏移持久化与重放。
ASCII 结构图:
玩家 → Anycast/L4 → Nginx → 网关 → 逻辑服
|
| EVALSHA (Lua)
v
Redis Cluster (HK)
|
| XREADGROUP (batch)
v
MySQL 8 (HK)
二、香港机房与硬件选型(面向大陆用户的网络考量)
| 角色 | CPU/RAM | 存储 | 网卡 | 运营商/线路 | 备注 |
|---|---|---|---|---|---|
| 网关 & 逻辑 (×4) | AMD EPYC 7452 32C / 128GB | NVMe 1.92TB (系统+日志) | 2×10GbE | CN2 GIA 优先,PCCW/HGC 备选 | 单机峰值可撑 35k RPS(含鉴权) |
| Redis 节点 (×6) | EPYC 7402P 24C / 128GB | NVMe 3.84TB ×2 RAID1 | 2×10GbE | 同上 | 3 主 3 备,Cluster 模式 |
| MySQL 主 (×1) | EPYC 7542 32C / 256GB | NVMe 3.84TB ×4 RAID10 | 2×25GbE | 同上 | InnoDB Buffer Pool 160GB |
| MySQL 备 (×1) | 同主 | 同步复制 | 2×25GbE | 同上 | 延迟只读,报表/审计 |
网络指标(实测):
- 香港机房至 广州 RTT 8~12ms,深圳 9~13ms,上海 23~30ms,北京 28~36ms(CN2 GIA 线路)。
- 上行 DDoS 清洗能力:≥ 300Gbps(机房侧),在接入层用 ACL + 限速,业务侧令牌桶保护接口。
三、操作系统与内核(CentOS 7)基线
我们统一用 CentOS 7,因为上游环境历史包袱较重(驱动、监控组件稳定)。
系统基线:
# 关闭 THP
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
echo 'madvise' > /sys/kernel/mm/transparent_hugepage/khugepaged/defrag 2>/dev/null || true
# 打开文件句柄
echo '* soft nofile 1048576' >> /etc/security/limits.conf
echo '* hard nofile 1048576' >> /etc/security/limits.conf
# sysctl(网络 + Redis/MySQL 友好)
cat >/etc/sysctl.d/game.conf <<'EOF'
fs.file-max = 2097152
vm.swappiness = 1
vm.dirty_ratio = 10
vm.dirty_background_ratio = 5
vm.overcommit_memory = 1
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 250000
net.ipv4.tcp_max_syn_backlog = 262144
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 10
net.ipv4.ip_local_port_range = 10000 65535
net.ipv4.tcp_mtu_probing = 1
net.ipv4.tcp_fastopen = 3
EOF
sysctl --system
# 时钟
yum install -y chrony && systemctl enable --now chronyd
四、Redis 7 部署与关键配置(Cluster + AOF everysec)
4.1 安装与编排
# 建议源码编译固定版本,或使用 remi 仓库稳定版
yum groupinstall -y "Development Tools"
yum install -y jemalloc jemalloc-devel tcl
# 假设已解压至 /opt/redis-7.2.x
make MALLOC=jemalloc && make install
useradd -r -s /sbin/nologin redis
mkdir -p /data/redis/{7001,7002,7003,7004,7005,7006}
chown -R redis:redis /data/redis
4.2 redis.conf 关键项(示例)
port 7001
cluster-enabled yes
cluster-config-file nodes-7001.conf
cluster-node-timeout 3000
protected-mode yes
bind 0.0.0.0
daemonize yes
dir /data/redis/7001
appendonly yes
appendfsync everysec
auto-aof-rewrite-percentage 80
auto-aof-rewrite-min-size 1gb
save 900 1
save 300 10
save 60 10000
maxmemory 96gb
maxmemory-policy noeviction # 日志用 Streams,主路径不丢;配合 TTL + XTRIM 控长
repl-backlog-size 512mb
tcp-keepalive 300
# ACL
requirepass "<REDIS_PASS>"
masterauth "<REDIS_PASS>"
rename-command FLUSHALL ""
rename-command KEYS ""
集群创建:
redis-cli -a <REDIS_PASS> --cluster create \
10.0.0.11:7001 10.0.0.12:7002 10.0.0.13:7003 \
10.0.0.14:7004 10.0.0.15:7005 10.0.0.16:7006 \
--cluster-replicas 1
经验:AOF 重写高峰会抖一下磁盘 IO,我把 auto-aof-rewrite-percentage 从默认 100 降到 80,并把 Redis 的数据目录单独放 RAID1 NVMe,避免与系统/日志抢 IO。
五、MySQL 8 配置(批量写友好)
/etc/my.cnf 关键项:
[mysqld]
datadir=/data/mysql
innodb_buffer_pool_size=160G
innodb_log_file_size=8G
innodb_flush_log_at_trx_commit=2
innodb_flush_neighbors=0
innodb_io_capacity=4000
innodb_io_capacity_max=20000
sync_binlog=0
binlog_expire_logs_seconds=259200
max_connections=2000
table_open_cache=8000
thread_cache_size=64
# 批量写优化
innodb_flush_method=O_DIRECT
表结构(按天分区 + 幂等键):
CREATE TABLE battle_log (
log_id BIGINT PRIMARY KEY, -- 由(时间戳+序列)或雪花算法生成
match_id BIGINT NOT NULL,
player_id BIGINT NOT NULL,
event_type TINYINT NOT NULL,
payload JSON NOT NULL,
ts_ms BIGINT NOT NULL,
UNIQUE KEY uk_match_player_ts (match_id, player_id, ts_ms)
)
PARTITION BY RANGE COLUMNS(ts_ms) (
PARTITION p20250918 VALUES LESS THAN (1694976000000),
PARTITION p20250919 VALUES LESS THAN (1695062400000),
PARTITION pmax VALUES LESS THAN (MAXVALUE)
);
经验:把 幂等键(如 match_id + player_id + ts_ms)放唯一索引,批量 INSERT ... ON DUPLICATE KEY UPDATE 就能天然去重,消费端“至少一次”也不怕。
六、Lua 脚本设计:一次 EVAL 完成写流、计数、限长
我们采用 Redis Streams 承载战斗记录(原因:天然顺序、消费者组、便于重放)。Lua 做到“一次请求,多步原子”:
- XADD 到 stream:battle:{match_id}
- 对 hash:stats:{match_id} 做计数(如伤害、击杀)
- 对 list:last:{player_id} 保留最近 N 条摘要(LPUSH + LTRIM)
- 设置 TTL,防止冷数据占内存
- 返回写入的 Stream ID 与统计值,便于业务端落地
log_battle.lua:
-- KEYS:
-- 1: stream key, e.g. stream:battle:{match_id}
-- 2: hash stats key, e.g. hash:stats:{match_id}
-- 3: list last key (player), e.g. list:last:{player_id}
-- ARGV:
-- 1: maxlen (approx), e.g. 2000
-- 2: ttl seconds for stream/hash/list, e.g. 86400
-- 3: event_type
-- 4: match_id
-- 5: player_id
-- 6: ts_ms
-- 7: payload json
local stream = KEYS[1]
local hstats = KEYS[2]
local llast = KEYS[3]
local maxlen = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local etype = ARGV[3]
local match = ARGV[4]
local player = ARGV[5]
local tsms = ARGV[6]
local payload= ARGV[7]
-- 1) XADD with MAXLEN ~
local sid = redis.call('XADD', stream, 'MAXLEN', '~', maxlen, '*',
'etype', etype, 'match', match, 'player', player, 'ts', tsms, 'payload', payload)
-- 2) HINCRBY stats (e.g. count per etype)
local stat_field = 'etype:' .. etype
local stat_val = redis.call('HINCRBY', hstats, stat_field, 1)
-- 3) LPUSH last events list per player, trim to 100
redis.call('LPUSH', llast, payload)
redis.call('LTRIM', llast, 0, 99)
-- 4) TTL
redis.call('EXPIRE', stream, ttl)
redis.call('EXPIRE', hstats, ttl)
redis.call('EXPIRE', llast, ttl)
return {sid, stat_field, stat_val}
加载与缓存脚本 SHA(逻辑服启动时做一次):
// Go (go-redis v9 示例)
sha, err := rdb.ScriptLoad(ctx, luaScriptString).Result()
// 业务写入
res, err := rdb.EvalSha(ctx, sha,
[]string{
fmt.Sprintf("stream:battle:{%d}", matchID),
fmt.Sprintf("hash:stats:{%d}", matchID),
fmt.Sprintf("list:last:{%d}", playerID),
},
2000, 86400, etype, matchID, playerID, tsMs, payloadJSON,
).Result()
if isNoScript(err) {
res, err = rdb.Eval(ctx, luaScriptString, ...).Result()
}
细节:钥匙(KEYS)里我用了一致性哈希标签 {},同一场战斗的 key 会落到同一槽,可以减少跨槽事务复杂度。
七、消费落库:批量与幂等
我们用 Consumer Group 消费 Streams,按时间/条数双阈值批量写入 DB。
Python(asyncio + redis.asyncio + aiomysql)简化示例:
import asyncio, json, aiomysql
from redis.asyncio import Redis
STREAM = "stream:battle:{%d}" # match_id slot-tagged
GROUP = "gdb"
CONSUMER = "c1"
BATCH_SIZE = 500
BATCH_MS = 50
async def ensure_group(r, skey):
try:
await r.xgroup_create(name=skey, groupname=GROUP, id='$', mkstream=True)
except Exception as e:
if "BUSYGROUP" in str(e): pass
else: raise
async def main(match_id):
r = Redis(host="10.0.0.21", port=7001, password="...", decode_responses=True)
pool = await aiomysql.create_pool(host="10.0.0.31", user="game", password="...", db="game", autocommit=False)
skey = STREAM % match_id
await ensure_group(r, skey)
buf, last = [], asyncio.get_event_loop().time()
while True:
msgs = await r.xreadgroup(GROUP, CONSUMER, streams={skey: ">"}, count=BATCH_SIZE, block=BATCH_MS)
if msgs:
for _stream, entries in msgs:
for _id, fields in entries:
payload = json.loads(fields["payload"])
buf.append((_id, payload))
now = asyncio.get_event_loop().time()
if len(buf) >= BATCH_SIZE or (buf and (now - last)*1000 >= BATCH_MS):
async with pool.acquire() as conn:
async with conn.cursor() as cur:
try:
# 批量写
sql = """INSERT INTO battle_log
(log_id, match_id, player_id, event_type, payload, ts_ms)
VALUES (%s, %s, %s, %s, CAST(%s AS JSON), %s)
ON DUPLICATE KEY UPDATE payload=VALUES(payload)"""
vals = []
for sid, p in buf:
# 生成幂等 log_id,可用雪花;示例用 ts_ms+序列
vals.append((int(p["ts_ms"]), int(p["match"]), int(p["player"]),
int(p["etype"]), json.dumps(p), int(p["ts_ms"])))
await cur.executemany(sql, vals)
await conn.commit()
# ACK
await r.xack(skey, GROUP, *[sid for sid, _ in buf])
buf.clear(); last = now
except Exception:
await conn.rollback()
await asyncio.sleep(0.2) # backoff
else:
await asyncio.sleep(0.005)
asyncio.run(main(match_id=123456))
要点:
- COUNT + BLOCK 结合 微批,把单条写放大成 100~1000 条的批量提交。
- 消费失败不 ACK,重试时因 幂等键 不会重复入库。
- 高峰期可按 match_id 拆多组 Worker 并行消费(slot 一致不会跨节点)。
八、接入层与逻辑服优化
Nginx:开启 reuseport、worker_cpu_affinity,worker_connections 65535。
逻辑服:
- Redis 连接池设置:Min 64 / Max 1024,pipeline 聚合写,EVALSHA 优先。
- 同机部署 Redis Proxy(如 twemproxy/valkey proxy),或直连 Cluster(框架自带路由)。
- JSON 序列化预分配 buffer,避免 GC 抖动。
- 关键路径不可持锁等待 DB——全部走缓存脚本返回即可。
九、监控与告警
Redis 指标:
- instantaneous_ops_per_sec、latency(P95/P99 < 5ms)
- mem_fragmentation_ratio < 1.6,超了压测下调 hash-max-ziplist-entries/切 jemalloc 版本
- aof_current_rewrite_time_sec、rdb_last_bgsave_status
MySQL 指标:
- InnoDB Buffer Pool 命中率 > 99%
- Log waits 过高 → 调整 innodb_log_file_size、IO 能力
- 表分区与冷热数据比例,归档任务水位
系统指标:
- SoftIRQ、netdev 丢包、NIC 利用率
- Load Avg 与 run queue(Redis 单实例 CPU < 250% 为佳,避免超调)
十、压测数据(真实场景截取)
| 场景 | 峰值并发在线 | 战斗日志产生 | Redis 写 OPS | Redis P99 | MySQL 写 QPS | DB P99 | 备注 |
|---|---|---|---|---|---|---|---|
| 改造前(直写 DB) | 18,000 | 28k/s | - | - | 28k/s | 35~90ms | 峰值时抖动致关服 |
| 改造后(Redis+Lua+批落库) | 18,000 | 28k/s | 30k/s | 4.8ms | ~5.6k/s | 8~15ms | 稳定,峰值平滑 |
| 提升后(多副本+扩 worker) | 28,000 | 45k/s | 48k/s | 6.3ms | ~9k/s | 10~18ms | 仍稳定 |
十一、坑与现场解决
AOF 重写卡顿
现象:战斗高峰触发 AOF rewrite,个别实例 latency spike。
处理:提前把 auto-aof-rewrite-percentage=80,并安排 凌晨 4:30 强制重写;数据盘独立 RAID;vm.dirty_ratio 下调到 10,减轻 writeback 抖动。
内存碎片率异常
现象:mem_fragmentation_ratio > 2.0。
处理:切 jemalloc 新版;调低 value 尺寸(payload 压缩/裁剪冗余字段);用 Streams + MAXLEN ~ 控制段长,冷数据靠 TTL 淘汰。
消费者组 pending 积压
现象:XPENDING 长时间 > 1e6。
处理:增加消费者并行度;按 match_id sharding 多个 stream;对“死信”定期 XCLAIM 转移处理;DB 写失败时快速回滚 + 重试 + 指标告警。
逻辑服偶发超时
现象:Redis 客户端连接放大 + GC 抖动。
处理:连接池上限,禁用超大对象;EVALSHA + pipeline,将 5~8 步操作合并为 1 次脚本调用;Go 堆 profiling 定位 JSON 分配热点后做对象池化。
MySQL redo 打满
现象:活动刚开始 3 分钟 redo 100%。
处理:批量阈值从 100 → 500 条,并行 4 路;innodb_log_file_size 提升到 8G,磁盘队列足够时 P99 降明显。
十二、上线 checklist(我自己的)
- Redis Cluster 所有主从延迟 < 50ms;failover 演练通过
- AOF + RDB 恢复演练:抽样回放 10 分钟内日志到测试库
- 消费者组“断电”演练:模拟 Worker 挂死,恢复后无丢单
- DB 写入 TPS 峰值预留 30% 余量,redo 最高 < 70%
- 逻辑服降级开关:Redis 故障 → 本地 ringbuffer 暂存 + 速率限制
- 告警门槛:Redis p99>10ms 1 分钟、pending>2e5、DB commit>50ms 连续 5 分钟
十三、完整部署步骤汇总(CentOS 7)
- 服务器就绪:绑定 CN2 GIA 线路,BGP 优选;上 ACL 与基础防护。
- 系统基线:limits、sysctl、THP、chrony。
- Redis:编译安装 → 3 主 3 备 Cluster → AOF everysec → ACL/rename 危险命令。
- MySQL:RAID10、O_DIRECT、Buffer Pool 充足、分区表、幂等键。
- 逻辑服:接入 EVALSHA 调用 Lua;连接池与 pipeline;降级策略。
- Worker:Streams 消费组,微批写入,重试与 ACK 管理。
- 监控:Prometheus 抓 Redis/MySQL/系统指标;Grafana 大屏;告警规则。
- 演练:failover、断电、回放;压测到 1.2× 峰值。
十四、附:Nginx 与 systemd 示例
Nginx(片段):
worker_processes auto;
events { worker_connections 65535; use epoll; multi_accept on; }
http {
sendfile on; tcp_nopush on; tcp_nodelay on;
keepalive_timeout 15;
server {
listen 80 reuseport backlog=65535;
location /api/ {
proxy_pass http://game-logic-upstream;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
upstream game-logic-upstream {
keepalive 256;
server 10.0.1.11:8080 max_fails=3 fail_timeout=5s;
server 10.0.1.12:8080 max_fails=3 fail_timeout=5s;
}
}
Redis systemd(按端口模板化):
[Unit]
Description=Redis 7001
After=network.target
[Service]
User=redis
Group=redis
ExecStart=/usr/local/bin/redis-server /etc/redis/7001.conf --supervised systemd
ExecStop=/usr/local/bin/redis-cli -a <REDIS_PASS> -p 7001 shutdown
LimitNOFILE=1048576
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
新赛季开了 65 分钟,图表上最陡的那根线不见了。Redis 的 OPS 像一条平稳的河,MySQL 的写入从近 30k/s 掉到 6k/s 上下,redo 不再尖叫。
我把耳机摘了,机房依旧冷得彻骨,但手机上安静到有些不真实:没有 PagerDuty 的红点。
有人把战斗的火力倾泻在副本里,而我把写入的火力分流到了缓存里。数据库那位“老同事”终于可以喘口气了。
我合上机柜门,轻声“晚安”。这是我在香港服务器上用 Redis+Lua 扛下战斗记录的全部做法与坑。下一次版本迭代,我只需要把 Streams 的分片再细一点,worker 的并行度再拉一档就够了。
如果你也要复用我的路线,可以从这三招先手开始
- 先改写路径:Lua 一把梭,保证战斗日志写入只有一次网络往返。
- 再上消费者组:把“每条”改成“每批”,幂等键护体。
- 最后稳磁盘:AOF 重写窗口、DB redo 空间、IO 上限都要算过。