← 返回文章列表

游戏业务日志的架构思考

后端
  • # game-backend
  • # logging
  • # architecture
  • # data-engineering

游戏服务端刚开始时,业务日志通常只是在关键位置打一行 JSON:玩家登录了、道具发放了、金币扣除了。再由采集程序把文件送到 Elasticsearch,看起来就已经完成了日志系统。

这种做法在业务量小时很有吸引力。它简单,出问题也能用关键词搜索。但当运营开始统计活动转化,客服开始查询道具去向,风控开始追踪异常交易时,问题会逐渐暴露:同一个字段有几种名字,重试产生了重复数据,服务发布后旧查询突然失效,最想查的那批记录又恰好丢了。

Elasticsearch 或者某个采集组件往往只是表象。更常见的原因是,我们把一条有业务含义的数据链路,当成了普通的文本输出问题。

先分清几种“日志”

设计架构时,可以先按用途把数据分开,再考虑具体的组件选型。

第一类是可观测性日志。它服务于开发和运维,记录请求失败、上游超时、内部状态和堆栈。这类日志允许采样,也可以随代码调整格式,核心目标是快速定位系统问题。

第二类是业务事件。例如玩家完成一局对战、触发某个活动、购买商品或改变关卡进度。它服务于运营分析、数据产品和实验,需要稳定的语义和可演进的结构。

第三类是审计记录。管理员发放了补偿、客服修改了玩家状态、某个配置被发布到生产环境,这些操作必须回答“谁在什么时候做了什么”。它们不应该被随意采样、覆盖或删改。

还有一类数据很容易被误当成日志:账本。充值、退款、付费货币和高价值道具的变化,不能只靠“最好能收到”的日志还原。它们应该作为业务数据参与事务,具有唯一流水号、变更前后的值和明确原因。日志可以是账本的查询副本,却不应成为唯一事实来源。

这四类数据可以共用一部分基础设施,但不应该共用一套保留时间、权限和可靠性策略。

把日志当成事件,而不是一行文本

一条业务事件通常需要两层结构:外层是所有事件共有的信封,内层才是对应业务的载荷。

{
  "event_id": "01K6...",
  "event_type": "item_granted",
  "schema_version": 2,
  "occurred_at": "2026-09-29T10:15:30.123Z",
  "produced_at": "2026-09-29T10:15:30.136Z",
  "player_id": "p_123",
  "server_id": "s_18",
  "trace_id": "4bf9...",
  "source": "inventory-service",
  "payload": {
    "item_id": "gold",
    "quantity": 100,
    "reason": "quest_reward",
    "balance_after": 860
  }
}

event_type 表达发生了什么,schema_version 让消费方知道如何解析,event_id 用于幂等去重,trace_id 把业务事件与请求链路连起来。occurred_at 和 produced_at 也不是同一件事:前者是业务实际发生的时间,后者是事件被生成的时间。离线重传、客户端弱网和跨区服处理都会让两者产生差异。

统一信封不等于所有数据都塞进一张宽表。信封是为了路由、追踪和治理;具体载荷仍然应该按事件类型定义。一个 item_granted 事件不应该因为某次临时活动,就多出几十个只有少数记录会使用的通用字段。

写入链路不要阻塞核心玩法

一条常见的链路可以简化为:

游戏服务 / 网关
       ↓
本地缓冲或采集 Agent
       ↓
消息队列
       ↓
校验、补全、分流和聚合
       ↓
实时查询库 / 对象存储 / 下游消费者

游戏主链路不应该在每个埋点处同步等待远程日志服务。更合适的做法是,进程先把事件放入有界的内存队列或写入本地文件,再由后台批量发送。批量可以降低网络和存储开销,本地缓冲则能吸收短时间流量峰值。

不过“异步”不等于“无限排队”。缓冲区必须有大小上限,并在满载时明确选择:阻塞、落盘、按优先级丢弃,还是让业务请求失败。调试日志可以采样,普通分析事件可以短暂落盘,而支付和资产变化不应该靠这条异步链路保底。

可靠投递靠的是幂等,不是追求“绝不重复”

分布式链路中,发送方超时后很难确定消息究竟有没有写入成功。如果重试,就可能重复;如果不重试,就可能丢失。因此,业务日志更实用的基线是 at-least-once 投递,再由消费方根据 event_id 实现幂等。

去重也不能只看整条 JSON 是否完全相同。同一个业务动作重试时,发送时间和重试次数可能已经变了,但它仍然是同一个事件。事件 ID 要在业务动作创建时生成,重试时保持不变。

如果需要保证“业务数据已经更改,对应事件一定最终可发送”,可以使用 Transactional Outbox:在同一个数据库事务里更新业务表并写入 outbox 表,再由独立进程把 outbox 记录投递到队列。这不会消除重复,但可以避免“数据库成功、发消息失败”这个缝隙。

顺序也应该按需求设计。大多数场景只需要同一玩家、账户或对局内有序,可以使用这个 ID 作为分区键。为了所有玩家的全局顺序牺牲吞吐,通常没有必要。

架构要能处理迟到和模式演进

游戏事件并不总是按发生顺序到达。客户端可能离线后重传,跨地域网络会抖动,某个消费者也可能因故障停顿。实时聚合因此要区分事件时间和处理时间,并给迟到数据预留一个可接受的窗口。报表也要说明它是实时估算,还是已经完成补数的最终结果。

事件结构同样会变。比起直接删除字段或改变含义,更稳妥的做法是:

  • 新增字段时允许下游忽略,并为缺失值定义语义;
  • 改变原有语义时提升 schema_version,必要时创建新的 event_type;
  • 在入口做 schema 校验,把不合法记录送到隔离区,不要静默丢弃;
  • 记录事件生产方、主要消费方和字段说明,避免“没人敢改”的公共表。

这里需要管理的是生产方和消费方之间的数据契约,JSON 只是它的承载格式。

存储要按查询方式分层

把所有数据长期放进同一套搜索集群,通常是成本增长最快的做法。搜索引擎适合定位近期故障和查找少量特定记录,但游戏业务分析更常见的访问方式是时间范围、分组和聚合。

一个比较实用的分层是:

  • 热数据进入 ClickHouse 这类分析库,支持近期查询、客服检索和运营看板;
  • 需要全文检索的近期可观测性日志,再单独进入搜索引擎;
  • 原始事件按日期和类型写入 S3 或兼容的对象存储,用于长期保留、重放和离线计算;
  • 按天、区服、渠道聚合的指标保留更久,明细则按业务和合规需求设置 TTL。

ClickHouse 的 TTL 可以删除、移动或滚动汇总过期数据;S3 Lifecycle 可以让原始数据在不同存储类别之间迁移或按期删除。这些能力很有用,但保留周期应该先由调查时限、数据价值和合规要求决定,再写成存储规则。

日志系统也需要自己的可观测性

查询端还能打开,并不代表日志链路是健康的。比较值得监控的信号包括:

  • 生产量和写入量之间的差异;
  • 本地缓冲大小、队列积压和端到端延迟;
  • 各事件类型的校验失败率和隔离区增长速度;
  • 重复率、迟到率与消费失败重试次数;
  • 单个玩家、事件类型或服务的流量异常。

比绝对数量更有用的,往往是几个环节之间的对账。例如游戏服务声称产生了 100 万条事件,队列接收了 99.9 万条,分析库最终只有 98 万条。单看任何一端都可能是绿色,数量比对才会暴露数据正在某个环节消失。这里的数字只是说明方法,实际告警阈值应该由业务对完整性和延迟的容忍度决定。

权限和隐私不要留到查询端再处理

业务日志里很容易混入 IP、设备标识、聊天内容、支付渠道和客服处理信息。等数据进入了所有存储、备份和下游报表后,再尝试删除会非常被动。

更合适的顺序是,先确定每个字段是否真的需要采集,再在入口做脱敏、标记数据分级,并按用途授权。客服可以查玩家资产流转,不代表它也需要看到完整 IP 或支付标识。审计记录则要限制写入者,并单独记录谁查询过敏感数据。

不必一开始就建设最完整的平台

小型项目不一定需要消息队列、流处理和多层存储全部到位。一个可以演进的起点,是先做好三件事:给事件定义稳定的名称和结构,为关键事件分配唯一 ID,把事件输出与业务代码之间隔开。

后面可以根据真实压力逐步增加能力:单机文件难以稳定采集时,引入本地 Agent;消费者互相影响或高峰需要削峰时,引入消息队列;查询成本开始增长时,再做热冷分层和预聚合。

我更倾向于先把“什么数据不能丢、可以重复多少、多久能查到、需要保留多久”写清楚,再选择具体组件。这几个问题决定的是架构;至于中间使用 Kafka 还是其他队列,查询端使用 ClickHouse 还是别的分析库,都是这些约束之后的实现选择。