之前写过给 Orbit Wars 造自博弈 NN 撞墙的事(见 策略永远学不动)。那篇讲的是「怎么训出一个强 agent」。这篇讲的是另一半——跑这些 agent 的那套分布式竞技场,本身怎么维护。
竞技场的结构很简单:一个 Redis broker(macm4,192.168.1.x)当队列,四台机器(一台 28 核的 n8、一台 mac mini、一台笔记本、broker 本机)各跑多进程 worker,BLPOP 抢对局任务,跑完把胜负上报、更新 TrueSkill 评分、写回放。我挂了一个 15 分钟的巡检循环,每次扫一遍队列深度、arena:live、各机连接、rater 状态。
跑了一整天,抓到一串 bug。复盘的时候发现一个共同点:没一个 bug 在对局逻辑里,全在机器之间的接缝上——连接、队列、时钟、网卡。记录下来。
幽灵对局:HSET 没有 per-field TTL
第一个异常是巡检里 arena:live 这个 hash 的条目数。它存「当前在跑的对局」,每场比赛开始 HSET 一条、结束 HDEL 掉。正常应该约等于在跑的对局数——几十条。
redis-cli hlen arena:live# 263263 条。但四台机器的 worker 进程总共才约 50 个,一个 worker 同时只跑一场。263 远超 50,绝大多数是僵尸。
根因不难找。worker 只在三条路径上 HDEL:正常跑完、SIGALRM 超时、捕获到异常。但每次部署都会 SIGTERM→SIGKILL 强杀旧 worker——被 KILL 的进程,那条 HDEL 永远不会执行。于是每次重新部署,所有在跑对局的 live 条目就泄漏一批。一天部署十几次,累积到 263。
为什么不给条目设个 TTL 自动过期?因为 Redis 的 HASH field 没有 per-field TTL。EXPIRE 只能作用在整个 key 上,没法让单个 hash field 自己过期。(顺带查证:per-field 的 HEXPIRE 直到 Redis 7.4.0(2024-07)才加进来,我们这套没升上去。)
所以泄漏的条目没有任何自动清理机制,只会无限涨。
解法是加一个 reaper。关键洞察是:每场对局有 600 秒的硬墙(ARENA_MATCH_TIMEOUT),所以任何 started_at 超过这个时长一截的 live 条目,必然是死掉的 worker 留下的,绝不可能是还在跑的真对局。于是按 started_at 年龄扫就行,900 秒为界:
def reap_stale_live(r, max_age=900, now=None): now = time.time() if now is None else now stale = [] for match_id, raw in r.hgetall(LIVE_HASH).items(): try: started = json.loads(raw).get("started_at") except Exception: started = None # 时间戳缺失/坏掉 → 无法证明它还活着 → 一并清掉 if started is None or (now - float(started)) > max_age: stale.append(match_id) removed = sum(int(r.hdel(LIVE_HASH, m) or 0) for m in stale) return removed放在哪跑?rater(评分进程,单实例,跑在 broker 上)本来就有一个 BLPOP 空转的间隙,每 60 秒顺手扫一次,hash 就自愈了。手动清了一次,263 → 44,之后稳定在几十条。
队列上限形同虚设:check-then-act 的经典坑
队列有个背压上限 MAX_QUEUE_DEPTH=100,防止任务堆积爆内存。某次巡检发现队列冲到了 167。
# 连采三次cpu=113 cpu=167 cpu=165MAX_QUEUE_DEPTH 配置没被改,确实是 100。但队列就是冲过去了。
翻入队代码,问题一眼就看出来:
# 旧逻辑(有坑)if _queue_depth(r) >= cfg.MAX_QUEUE_DEPTH: # 先查 raise QueueFull(...)r.rpush(queue, job.to_json()) # 再推LLEN 查深度和 RPUSH 入队是两次独立的 Redis 往返,中间有 TOCTOU 窗口。多个 session/agent 并发入队时,大家几乎同时 LLEN 看到 99(< 100,放行),然后全都 RPUSH,一起冲过上限。这是 check-then-act 的经典竞态。
修法是把「检查 + 入队」塞进一个 Lua 脚本里。Redis 的 EVAL 是原子执行的——脚本跑的时候服务器阻塞所有其他命令,没有交错,所以脚本内的 LLEN-then-RPUSH 是 race-free 的(官方文档证实,代价是别写长脚本阻塞 server):
local depth = redis.call('LLEN', KEYS[2]) + redis.call('LLEN', KEYS[3])if depth >= tonumber(ARGV[2]) then return -1endreturn redis.call('RPUSH', KEYS[1], ARGV[1])部署后队列稳稳压在 100 以下。
后来 owner 又提了个要求:超过上限别静默截断,直接报错。原来的 ladder 入队是「能塞多少塞多少,塞满就悄悄停」,结果你让它入 200 场、实际只入了 20 场,还不报错。改成超过 per-call 上限(20)或总深上限(100)就抛 QueueFull(CLI 退出码 1)/ REST 返回 400/503,绝不半填。失败要响。
worker 卡死在一条断掉的连接上空转
某次巡检发现 mac mini 这台机器从 arena:live 的连接列表里消失了。SSH 上去看,worker 子进程还在,但日志最后几行是:
redis.exceptions.ConnectionError: Error 65 connecting to 192.168.1.x:6379. No route to host.而且这台机器从 17:04 之后就再没拉过任务——悄悄死了 2.5 小时。日志里堆了 70 多万行 ConnectionError,日志文件 1.1 GB。
诡异的是:当下 nc -z 192.168.1.x 6379 是通的——网络早恢复了,但 worker 还在刷 “No route to host”。这说明它卡在一条已经坏掉但没被丢弃的连接上空转。
查证下来这是 redis-py 的一个真实陷阱(redis-py #1789):连接池里一条 socket 断了之后,可能被反复复用而不自动重建。worker 的循环虽然 except Exception 了,但只是 sleep(1) 重试同一条死连接,永远好不了。
修法是在循环里专门捕获 ConnectionError,强制丢弃整个连接池,并指数退避:
except redis_module.exceptions.ConnectionError as exc: conn_errors += 1 backoff = min(_CONN_RETRY_MAX_S, 2.0 ** min(conn_errors, 5)) try: r.connection_pool.disconnect() # 丢弃池里的死连接 except Exception: pass log.warning("Broker connection lost (%s); reconnecting in %.0fs", exc, backoff) time.sleep(backoff)这里有个 fact-check 纠正的细节值得写清楚:connection_pool.disconnect() 本身不重连,它只是把池里现有连接关掉清空,重连发生在下一条命令被执行时(懒重连)。所以它是「冲掉死连接」的正确手段,但不是官方推荐的首选。redis-py 更推荐在 client 初始化时直接配 Retry(ExponentialBackoff(), retries=N) + retry_on_error=[ConnectionError, TimeoutError] + health_check_interval,让库自己处理(官方 FAQ)。我这次用的是手动 disconnect + 退避,能解决问题;要重构的话内建 Retry 更干净。
加了指数退避(封顶 30 秒)后,原来 15 次/秒的疯狂空转降成偶发,日志从 1.1 GB 量级降到几 MB。
一台机器悄悄死 2.5 小时:全局指标会骗你
上面那个「mini 死了 2.5 小时没人发现」才是更值得反省的地方。
为什么巡检没早点抓到?因为我一直在看全局指标——队列深度、总评分数、总对局数。这些一直健康:因为另外三台机器(n8 28 核 + 笔记本 + 本机)把活全扛了,全局吞吐看不出少了一台。全局指标会掩盖单机故障。
教训:分布式系统的巡检必须落到每台机器。我从那之后给每轮巡检加了 per-host 检查——看每台机器是否出现在 broker 的 client 列表里、各自日志的近况:
redis-cli client list | grep -oE 'addr=[0-9.]+' | cut -d= -f2 | cut -d: -f1 | sort | uniq -c# 20 127.0.0.1# 51 192.168.1.x <- n8# 17 192.168.1.x <- n8 (WSL2 NAT 出两个 IP)# 25 192.168.1.x# 9 192.168.1.x <- mini下一轮 mini 再出问题,一个周期(15 分钟)内就抓到了,而不是 2.5 小时。
那为什么 mini 老断?根因更底层。SSH 上去看网卡:
ipconfig getifaddr en0 # 192.168.1.x 有线ipconfig getifaddr en1 # 192.168.1.y Wi-Finetstat -rn | grep '^default'# default 192.168.1.1 en0# default 192.168.1.1 en1 <- 两条默认路由这台 mini 同时插着有线和 Wi-Fi,两块网卡在同一个子网,有两条默认路由。 macOS 在两块网卡之间来回切,ARP 不一致,间歇性 EHOSTUNREACH——这是 Apple 文档里明确点名的反模式(HT203436),推荐做法就是禁掉一块、或拆到不同子网、或做 bond。
确认过我的 SSH 走的是 Tailscale(不依赖那块要关的网卡),又确认有线口是 active 的千兆口之后,关掉 Wi-Fi:
networksetup -setairportpower en1 off重启 worker,连接错误从「关之前每分钟 9 次」降到 0。单网卡之后再没大面积抖过。
时区乌龙:差点误判 rater 挂了
有一次我以为闯了大祸。巡检看 rater(评分进程)的日志,最后一行时间戳是 02:21,而当时 UTC 心跳显示 06:xx——日志停了 4 小时没动,但评分数还在涨。一个死了一个在涨,我一度以为有两个 rater、或者 rater 卡死了,查了半天。
真相是:rater 日志用的是机器本地时间(EDT,UTC-4),而我对照的心跳是 UTC。02:21 EDT 就是 06:21 UTC——也就是几分钟前,根本没停。我拿本地时间戳去跟 UTC 比,凭空多算了 4 小时的「静默」。
date "+local=%H:%M:%S %Z utc=$(date -u +%H:%M:%S)"# local=02:27:16 EDT utc=06:27:16教训很蠢但很实在:跨时区比时间戳之前,先确认两边是不是同一个时区。后来给自己记了一条:这台机器的日志是 EDT,别拿去跟 UTC 心跳直接比。验证手段也是从这次学的——与其盯日志时间,不如直接往 arena:rater:inbox 推一条测试信封,看它是否被秒消费,这才是「进程循环是否还活着」的硬证据。
分层调度:主机优先 + 溢出
最后一个不是 bug,是 owner 提的调度需求:n8 是最大最稳的机器(28 核),希望任务不多时全压给 n8,只有溢出的部分才分给其他机器。
但原来是共享队列 + BLPOP,所有 worker 平权抢单,没有优先级。改法是给 worker 加一个 --overflow-threshold N:
- 主机(n8)不带这个参数 = PRIMARY 层,永远积极抢单,最先排空队列;
- 其他机器带
--overflow-threshold 20= OVERFLOW 层,只有当队列深度超过 20 时才下场,否则空转轮询、把任务留给主机。
阈值默认设成 20(正好等于单次入队上限),所以一批入队全落在 n8,只有堆积超过一批才溢出。实测灌 12 个任务,n8 全吃下,其余三台 pull 数 = 0;灌到队列 > 20,溢出层才开始帮忙。
经验总结
复盘这一天,几条能带走的:
- Redis HASH 没有 per-field TTL(7.4 之前)。任何「开始时写、结束时删」的 hash 条目,都要假设「结束时删」会被跳过(进程被 KILL),配一个按时间兜底的 reaper。
- check-then-act 必上 Lua。任何「先查再改」的 Redis 操作,并发下都会被竞态击穿,塞进一个
EVAL里靠原子性解决。 - 断连接要主动丢弃 + 退避。redis-py 不保证自动从坏连接恢复;要么手动
connection_pool.disconnect()+ 退避,要么干脆用内建Retry+retry_on_error。别让 worker 在死连接上空转刷日志。 - 全局指标会骗你。分布式系统的巡检必须落到 per-host,否则一台机器能悄悄死几个小时而全局吞吐毫无异样。
- 同一个子网别插两块网卡。dual-homing 会制造间歇性
EHOSTUNREACH,禁一块最省事。 - 跨时区比时间戳之前先对齐时区,否则一个本地 EDT 的日志能让你凭空多算 4 小时静默、误判进程挂了。
这些坑的共同点前面说了——没一个在对局逻辑里。对局逻辑(怎么算胜负、怎么更新 TrueSkill)一行没动过。出问题的全是机器之间的接缝:一条没删的 hash 条目、一个非原子的入队、一条没丢弃的死连接、一台没人看的机器、两块打架的网卡、两个不对齐的时钟。分布式系统大概就是这样:单机逻辑写对只是起点,真正难的是接缝。