一个只有结果,没有过程的行业
我做游戏服务端开发很多年。
游戏行业有一个比较特殊的地方:
数据库保存的是结果,日志保存的是过程。
比如玩家反馈:
我的技能为什么没有释放?
你去查数据库,可能只能看到:
- 玩家等级
- 当前装备
- 技能列表
- 当前状态
这些都是最后的结果。
但技能为什么没有释放?
可能是:
- 网络延迟导致请求过期
- 技能状态判断失败
- 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 动态路由,在应用内部是可以实现的。
这个实验后来逐渐变成了一个独立项目:
写在最后
一开始做这个东西的时候,我以为问题很简单:
找到 Key,然后写对应文件。
后来真正实现才发现:
找到文件只是第一步。
真正困难的是:
当 Key 数量增加以后,如何处理:
- 文件数量增长
- 写入性能
- 顺序保证
- 资源限制
- 高并发情况下的稳定性
这些问题,下一篇继续聊: