战斗服的遥测计数,不该和每个事件一起落库

一场持续二十分钟的团队战斗里,技能释放、伤害命中、状态附着、区域进入、AI 决策和网络质量都可能产生计量事件。运营需要知道某个技能的使用分布,平衡团队要比较地图热点,值班工程师还要观察房间是否出现异常重试。最直接的实现是在事件发生时更新数据库:一次命中对应一次 UPDATE,一次状态变化再写一行明细。这个方案在功能评审里很顺,因为每个事件似乎都“已经保存”;到了线上,它却把战斗循环的延迟交给数据库锁、连接池和磁盘抖动决定。
真正的问题不是一条 UPDATE 有多慢,而是谁拥有“尚未持久化的计数”,以及业务是否允许它丢失。若这两个问题没有先回答,批量写只是把阻塞从每次事件挪到某个不确定时刻;若把结算金币和伤害分布都塞进同一个计数器,则是把完全不同的一致性等级伪装成同一种数据。
本文依据一个实际 Go 在线系统的计数路径讨论这个边界。代码事实包括:热路径只在互斥锁内更新内存 map;默认每三十秒交换一次批次;总计数和按日计数在一个 SQLite 事务内累加;关停时执行最后一次 flush;数据库写失败后,已交换出的批次不会回灌。真实 SQLite 回归测试验证了三类行为:三个实体各一千次并发累加后结果一致,跨 UTC 日界的五次与七次计数分别进入两个时间桶且总数为十二,关停前四十二次未落盘计数会在 Close 后写入,重复关闭不会阻塞。把这些事实迁移到游戏战斗遥测时,实体从文章变为房间、玩家或指标,所有权和失败语义不变。
先分数据
“战斗数据”不是一个一致性类别。至少要分成三层。
第一层是权威状态:当前生命、技能冷却、角色位置、Buff 列表。它归战斗实例所有,必须参与确定性推进或权威状态同步,不能由异步遥测反向修改。第二层是业务账本:胜负、掉落、货币、排行积分。它可以晚一点完成,但必须可重试、可去重、可审计,最终不能凭空消失。第三层才是遥测计数:某技能被释放多少次、某区域进入多少次、某类网络错误出现多少次。它服务于分析和观测,通常允许一个事先声明的有限损失窗口。
进程内批量计数器只应接收第三层。它的核心不变量有四个:事件采集不能阻塞战斗推进;同一聚合键的增量不能在并发累加中互相覆盖;一个批次内的总量与分桶量必须共同提交或共同失败;最大可接受损失必须由 flush 周期和关停顺序明确界定。这里没有“绝不丢失”这一不变量。相反,系统主动选择了在进程崩溃或写库失败时丢掉有限计数,以保护战斗热路径。
这条边界非常锋利。若产品说某项数据少一条就可能少发奖励,它便不是遥测;若风控要从原始事件重建玩家行为,它也不是简单计数;若客服需要解释某次结算为何发生,它必须进入可审计账本。不能因为已有一个方便的 Incr 方法,就把所有“看起来像数字”的业务都交给它。
所有权
在已验证实现中,未落盘状态只有一个所有者:服务进程内的聚合器。调用方传入实体标识和事件发生时间,聚合器在 UTC 日期维度构造键,在锁内执行一次自增。调用结束后,战斗逻辑不再持有这条增量,也不等待数据库确认。数据库拥有的是已经提交的累计值,分析查询只读取持久化结果,不能越过聚合器修改内存批次。
迁移到游戏系统,键通常应扩展为 (room_id, metric_id, window) 或 (shard_id, mode_id, metric_id, window)。是否包含 player_id 不是语法选择,而是容量与隐私选择:一旦把玩家维度放进键,活跃键数会从“房间数乘指标数”跃迁到“玩家数乘指标数”,内存、事务语句和数据保留压力都会改变。若只需要全局平衡趋势,就不应先收集玩家粒度再期待以后聚合;最小必要粒度应在所有权边界处决定。
flush 不是遍历当前 map 后逐项删除。实现先在锁内把整个 pending 指针交换成新的空 map,然后释放锁,再把旧 map 转成数据库批次。这个顺序保证数据库 I/O 不占用热锁。flush 期间到达的新事件自然进入新 map,不会等待上一批事务结束。交换前的增量归旧批次,交换后的增量归新批次,所有权转移点因此是可描述的,而不是由 goroutine 调度偶然决定。
数据库一侧先把相同实体在不同时间桶的增量合并成实体总增量,再在单个事务中更新总表和分桶表。假设一个房间在 UTC 日界前后分别产生五次和七次事件,总表必须增加十二,两个日桶必须分别增加五和七。测试确实验证了这一关系。只更新总表后再另起事务写日桶,会让失败窗口产生无法解释的报表差异;只写日桶再异步求和,则改变了查询成本和修复方式。当前方案选择用一个短事务维护这两个投影的一致性。

正常时序
正常路径可以压缩为四步。战斗逻辑产生一个可丢遥测事件;聚合器在锁内对键执行自增;定时器到期或进程准备退出时,聚合器交换 map 得到封闭批次;存储层在一个事务内累加总量和时间桶。事务执行期间,新事件已经进入下一批,战斗循环只感知一次很短的内存临界区。
这里需要抵抗一个看似合理的替代方案:把事件写入一个带缓冲 channel,再由单独 goroutine 逐条消费。channel 确实把数据库调用移出了战斗 goroutine,却没有自动得到聚合。若消费者仍逐条写库,数据库语句数没有下降;若 channel 满时阻塞,背压最终仍回到战斗循环;若满时丢弃,还要重新定义丢弃策略和指标。map 聚合的关键价值是把同键的许多事件变成一个增量,而不只是换一个 goroutine 执行同样多的 I/O。
另一个替代方案是每个房间维护自己的定时器和事务。它让对象边界看起来整洁,却会让大量房间在相近时刻同时 flush,制造周期性写入尖峰;房间销毁还必须独立处理计时器退出、尾批落盘和重复关闭。进程级聚合器把同一存储边界上的计数统一成批次,房间生命周期只负责停止产生事件,不负责拥有数据库事务。除非不同房间需要不同的一致性等级或隔离域,否则把持久化调度下放到每个房间,通常增加的生命周期复杂度大于隔离收益。
关停顺序也是正常时序的一部分。真实服务先停止 HTTP 接入,再停止需要收尾的后台流水线,取消其他任务,最后关闭计数器。计数器收到停止信号后做一次 Flush,Close 等待运行循环退出,并通过一次性保护允许重复调用。测试中的四十二次累加在关闭后全部进入数据库,第二次关闭也不会卡住。对战斗服而言,应先摘流并停止创建新房间,等待或迁移存量房间,再禁止新的遥测生产,最后 flush 聚合器。若一边仍在产生事件一边关闭所有者,“最后一批”便没有确定含义。
失败预算
三十秒不是性能魔数,而是风险预算。假设事件速率为 R 次每秒,flush 周期为 T 秒,则一次进程崩溃的理论未落盘事件上界约为 R×T。这不是说内存一定有 R×T 个对象:经过同键合并后,map 条目数约为活跃键数 K。例如一个节点同时承载二百个房间,每个房间记录二十个当前窗口指标,则 K 的量级是四千;即使三十秒内产生六十万次原始事件,内存里仍可能只有约四千个增量条目。数字只是复算示例,不是对现有系统的压测结论。
数据库工作量也可复算。令一个批次涉及的实体数为 U,分桶键数为 K,当前事务的语句数上界约为 U+K,外加事务控制;它不再随原始事件数线性增长。若所有键都只出现一次,聚合收益接近零,此时问题不是把 flush 调快,而是检查维度是否过细。高基数标签、玩家级随机标识或每次对局唯一的事件名都会让 K 逼近 R×T,计数器会从聚合器退化为内存队列。
周期越短,崩溃损失越小,但事务频率越高;周期越长,写放大越低,但丢失窗口和单批事务越大。合理的选择应同时受四个量约束:业务允许的最大损失窗口、活跃键内存上限、单批事务时延、节点关停预算。若业务只能容忍五秒损失,三十秒就不合格;若一次批量事务已经接近下一个周期,继续延长周期只会形成永远追不完的积压。配置值必须由这些约束推导,而不是从另一套系统复制。
失败时序
最直观的失败发生在定时 flush 之前:进程直接崩溃,当前 map 消失。损失最多覆盖一个周期,但关停 flush 无法挽救 SIGKILL、宿主机掉电或运行时崩溃。监控必须把“允许有限丢失”变成可观察承诺,例如记录进程重启、上次成功 flush 时间、批次键数和累计增量。只有这样,分析方才知道某段数据可能不完整,而不是把缺口误判为玩家行为变化。
更值得注意的是数据库写失败。当前实现先把旧 map 从 pending 交换出去,再执行事务;若事务返回错误,旧批次不会重新放回新 map。代码注释明确说明这样做是为了避免无限重试导致内存膨胀。其含义也同样明确:写失败的整个增量批次会丢失。单个事务保证总量和日桶不会只成功一半,却不保证这个批次最终一定成功。
为什么不直接回灌?因为回灌并不只是把 map 加回去。flush 期间新 map 已经累积同键增量,合并需要再次加锁;持续数据库故障会让待处理量无上限增长;若事务其实已经提交但客户端只丢失确认,重试还可能重复累加。没有批次标识、提交去重和持久化重放记录时,“失败就重试”并不能建立可靠语义。当前实现选择可预测的丢失,而不是伪装成可靠投递。
这正是它不能处理结算的理由。结算要求的是至少一次传递配合业务幂等,或者在同一权威事务中完成状态推进;经济账本还要求不可变记录和审计链。它们需要持久 outbox、消息日志或带唯一批次号的存储协议。把批量遥测计数器改成无限重试,不会自动得到这些能力,只会让战斗节点在数据库故障时承担越来越大的内存债务。
还有一种失败容易被忽略:时间归属错误。实现以事件传入时间的 UTC 日期构造键,而不是在 flush 时读取当前日期。因此日界前产生、日界后才落盘的事件仍属于前一天。若改成 flush 时间分桶,周期横跨日界时就会把旧事件计入新一天。游戏项目若按赛季、活动阶段或战斗回合分桶,也应在事件产生时绑定逻辑窗口标识,不能由异步消费者用处理时间猜测。
监控契约
既然系统允许丢失,监控重点就不应只有数据库错误数。至少要观察五组信号:当前活跃键数、每批原始增量和键数的压缩比、flush 耗时及失败数、距离上次成功提交的时间、关停 flush 是否在预算内完成。活跃键数持续增长说明维度失控或窗口没有轮转;压缩比接近一说明聚合键过细;flush 耗时逼近周期说明单节点存储能力接近上限;成功时间长期不推进则说明分析数据已经形成空洞。
还应分别统计“主动舍弃”和“不可见损失”。数据库写失败导致的整批舍弃可以由代码直接计数,并记录批次键数与增量总数,但不能打印玩家标识等高基数内容。进程硬崩溃造成的损失无法在崩溃节点内精确计量,只能由外部存活监控结合最近成功 flush 时间估算风险窗口。两者不能合并成一个 flush_error,因为恢复动作不同:前者检查数据库与事务时延,后者检查节点稳定性与发布关停流程。
验证也要针对不变量,而不是只测函数返回。现有测试用真实 SQLite 而非 mock,三千个 goroutine 并发累加后核对总表与日桶,证明锁内自增没有覆盖;跨天测试证明窗口键取自事件时间;关闭测试证明尾批落盘与幂等关闭。迁移到战斗服后,还应补数据库事务失败测试、flush 与并发生产交错测试、节点摘流后不再产生事件的集成测试,以及高基数键的容量告警测试。当前证据没有验证数据库失败后的指标上报,也没有给出压测吞吐,因此不能声称这套实现已经覆盖所有故障或达到某个 QPS。
演进条件
进程内 map 加单库事务适合单节点拥有一组事件、遥测允许有限丢失、活跃键数可控的阶段。出现以下任一信号,就应评估下一层架构,而不是继续调大 map:单批事务稳定逼近 flush 周期;高基数维度使 K 与原始事件数同阶;多个战斗节点必须合并同一全局键并要求严格顺序;分析方把损失容忍降到接近零;数据库维护窗口导致节点长时间保留批次;需要保存原始事件以支持重放和反作弊调查。
第一种演进不是立即引入复杂流平台,而是给批次增加稳定标识和明确的提交结果,存储层按批次去重。它解决“提交成功但确认丢失”的重复风险。第二种是本地持久化队列或 write-ahead log,让节点重启后可以重放;代价是磁盘管理、回放节流和容量保护。第三种才是把事件交给独立日志系统,由分区键维持局部顺序,下游分别生成实时指标、离线明细和审计投影。此时战斗服仍不能无限阻塞:本地发送缓冲满时,是降采样、丢低优先级指标,还是触发节点摘流,必须提前定义。
即使演进到持久日志,权威战斗状态也不应依赖遥测消费者才能继续推进。日志是事实副本和分析入口,不是战斗状态所有者。结算若通过事件驱动完成,需要独立的幂等业务协议;遥测若通过采样降低成本,需要保留采样率和维度,避免下游把样本数直接当真实次数。系统规模变大后,边界应更清晰,而不是让“事件化”抹平所有一致性差异。
架构判断
战斗服计数的首要优化不是更快的数据库,而是承认不同数据有不同的丢失语义。对于技能使用、地图热点和网络错误这类可丢遥测,进程内聚合器拥有未落盘增量,热路径只做短临界区自增,定时或关停时用短事务维护总量与分桶一致性,是一个边界清楚、成本可复算的方案。它用最多一个周期的潜在损失,换取战斗循环不受数据库抖动支配。
但这项取舍必须写在接口和监控里:写库失败的批次可能直接丢弃,硬崩溃无法靠优雅关停补救,高基数键会让聚合失效。只要数据要求不可丢、可重放或可审计,就应离开这个计数器,进入带幂等标识的持久日志或业务事务。架构评审时最该追问的不是“每秒能加多少次”,而是“这条增量现在归谁,失败后允许它去哪儿”。