数据库只存结果,日志才是过程

74 阅读6分钟

一个只有结果,没有过程的行业

我做游戏服务端开发很多年。

游戏行业有一个比较特殊的地方:

数据库保存的是结果,日志保存的是过程。

比如玩家反馈:

我的技能为什么没有释放?

你去查数据库,可能只能看到:

  • 玩家等级
  • 当前装备
  • 技能列表
  • 当前状态

这些都是最后的结果。

但技能为什么没有释放?

可能是:

  • 网络延迟导致请求过期
  • 技能状态判断失败
  • Buff 冲突
  • 战斗状态不同步
  • AI 逻辑异常

这些信息,数据库里通常找不到。

真正能还原当时发生了什么的,是日志。

所以在游戏服务端,日志有时候不是辅助信息,而是排查问题最重要的依据。


以前遇到线上问题,我经常做的一件事就是:

打开服务器日志目录。

然后在几十 MB,甚至几个 GB 的日志文件里面找玩家行为。

比如:

grep "playerId=10086" game.log | tail -n 200

这条命令我不知道执行过多少次。

它很好用。

但它也有一个明显的问题:

它只能帮你找到包含这个玩家 ID 的日志。

它无法帮你保留完整的上下文。

玩家的一次完整行为可能是:

登录
 ↓
进入地图
 ↓
匹配战斗
 ↓
释放技能
 ↓
结算奖励
 ↓
异常退出

但是实际日志里面可能是:

playerId=10086 登录

playerId=20001 移动

playerId=30002 战斗

playerId=10086 释放技能失败

playerId=40001 掉线

playerId=10086 状态同步

真正排查的时候,需要自己重新把这些片段拼起来。

很多时候,代码问题可能十分钟能定位,但寻找日志上下文花了一两个小时。

后来我开始思考:

日志的问题,可能不只是查询方式的问题,而是写入的时候就没有按照业务维度组织。


先说明:grep 其实没有错

这里先说明一点。

对于很多系统来说:

grep + shell

已经完全够用了。

如果日志量不大,团队规模不大,这种方式简单可靠。

如果已经接入了 ELK、Loki 这类日志平台,按字段查询和聚合也已经非常方便。

我这里讨论的是另一类场景:

  • 日志量比较大,但不想引入完整日志平台
  • 经常需要追踪某个业务对象的完整生命周期
  • 问题定位高度依赖日志上下文

游戏服务端只是其中一个比较明显的例子。

类似的问题,在多租户 SaaS、IoT 设备管理等场景里也存在。


现在常见的解决方式

遇到类似问题,一般有几种方案。

grep

最简单,也最直接。

但是它的问题也明显:

日志产生的时候没有隔离。

出了问题以后,只能通过关键词过滤。

当一个业务对象的日志分散在大量其他日志之间时,恢复完整过程会比较困难。


MDC

很多 Java 项目会使用 MDC。

例如:

tenantId=xxx
playerId=xxx
requestId=xxx

然后让日志带上这些上下文。

它确实解决了一部分问题。

但是 MDC 本质上还是:

给日志增加标识。

日志依然写在一起。

最后还是需要查询和过滤。

另外 MDC 基于 ThreadLocal,在异步场景中还需要额外处理上下文传递。


日志平台

ELK、Loki 这一类方案功能非常强。

可以搜索、聚合、分析。

但是它也有成本:

  • 需要部署维护
  • 增加基础设施
  • 日志需要经过额外传输链路

对于一些中小团队或者单机部署场景,并不是所有项目都会选择。


所以我一直觉得:

这里缺少一种中间方案。

不是替代日志平台。

而是在应用内部,让日志天然按照业务维度组织。


一个容易被忽略的问题:日志应该按照什么组织?

我们写代码的时候,经常按照业务对象设计:

OrderService

PaymentService

PlayerService

因为业务对象才是系统真正关注的东西。

但是日志通常还是按照技术维度组织:

日期

日志级别

Logger 名称

这在过去没有问题。

传统日志框架解决的是:

哪个代码模块输出了什么日志。

但现在很多系统更关心:

某个业务对象经历了什么。

比如:

  • 一个玩家经历了什么
  • 一个订单经历了什么
  • 一个租户发生了什么

这两个关注点,其实是不一样的。


为什么主流日志框架没有直接支持?

像 Logback、Log4j2 这些框架,本身已经非常成熟。

它们解决的问题是:

如何高效、稳定地把日志输出到目标位置。

例如:

  • 控制日志级别
  • 格式化输出
  • 文件滚动
  • 异步写入

这些事情它们做得很好。

但是:

根据每条日志携带的业务 Key,把日志动态写入不同位置。

这是另外一个问题。

它不是简单的输出。

更像是一个路由问题。

传统模式:

Logger
  |
Appender
  |
File

配置的时候已经决定:

哪个 Logger 输出到哪个文件。

而业务 Key 路由:

Log Event
    |
  playerId
    |
player-10086.log

每一条日志产生的时候,目标位置才确定。

例如:

同一个:

PlayerService

可能:

playerId=10086 -> player-10086.log

playerId=20001 -> player-20001.log

这和传统 Logger 模型是不一样的。


一个简单的验证

我一直觉得这个方向可能有价值。

但是也有疑问:

  • 文件数量增加怎么办?
  • 高并发写入怎么办?
  • 如何保证同一个 Key 的顺序?
  • 打开大量文件怎么办?

所以后来做了一个实验。

验证了一件事情:

日志按照业务 Key 动态路由,在应用内部是可以实现的。

这个实验后来逐渐变成了一个独立项目:

github.com/log4key/log…


写在最后

一开始做这个东西的时候,我以为问题很简单:

找到 Key,然后写对应文件。

后来真正实现才发现:

找到文件只是第一步。

真正困难的是:

当 Key 数量增加以后,如何处理:

  • 文件数量增长
  • 写入性能
  • 顺序保证
  • 资源限制
  • 高并发情况下的稳定性

这些问题,下一篇继续聊:

当日志开始按业务对象组织后,为什么一个简单的 Key → 文件映射方案并不能应对真实场景