3、日志机制原理:操作日志、状态变更日志、日志的持久化与回放

日志这东西,说白了就是系统的「黑匣子」。

我在做量化系统的时候,最怕的就是半夜被电话吵醒——交易所那边说你的订单丢了,或者成交回报对不上。那时候你怎么办?没有日志,你连死因都查不出来。

所以今天咱们聊聊日志机制。别小看它,订单簿的可靠性,一半靠日志撑着。

3.1 操作日志:记录「谁干了什么」

操作日志,记录的是每一次用户操作。比如:

  • 用户A 下了个买单,价格 100.5,数量 200
  • 用户B 撤单了,撤的是订单ID #12345
  • 管理员修改了交易对的状态

这些操作日志的格式,我一般这么设计:

{
  "timestamp": 1699000000123,    // 操作发生时间(毫秒)
  "user_id": "user_001",
  "action": "place_order",       // 操作类型
  "params": {                    // 操作参数
    "symbol": "BTC/USDT",
    "side": "buy",
    "price": "100.5",
    "quantity": "200"
  },
  "result": "success",           // 操作结果
  "order_id": "ord_20231001_001" // 生成的订单ID
}

为什么要记录这么细?

我曾经遇到过一个场景:用户投诉说他的撤单没成功,但系统显示撤单成功了。查日志发现,用户发的撤单请求里,订单ID 写错了——多了一个空格。这种问题,没有操作日志根本说不清楚。

我的习惯:操作日志一定要记录「入参」和「出参」。别只记个「成功/失败」,那样查问题的时候你会想骂人。

3.2 状态变更日志:追踪「状态怎么变的」

操作日志记录的是「人做了什么」,状态变更日志记录的是「系统状态怎么变的」。

举个例子,一个订单的生命周期可能是这样的:

NEW → PARTIALLY_FILLED → FILLED
NEW → PARTIALLY_FILLED → PARTIALLY_CANCELED → CANCELED
NEW → CANCELED

每一次状态变更,都要记录一条日志:

{
  "order_id": "ord_20231001_001",
  "from_status": "NEW",
  "to_status": "PARTIALLY_FILLED",
  "timestamp": 1699000000500,
  "reason": "match_engine",      // 变更原因
  "detail": {                    // 变更详情
    "filled_qty": "50",
    "filled_price": "100.5",
    "remaining_qty": "150"
  }
}

这里有个坑,我踩过:

状态变更必须是「有向无环图」。什么意思?就是你不能从 FILLED 再变回 NEW。我见过一个系统,因为状态机设计不严谨,订单在 FILLED 之后又被撮合引擎更新了一次,结果变成了「已成交+未成交」的诡异状态。嗯,那天的对账报表全乱了。

避坑指南:状态变更日志一定要做「幂等性校验」。同一个订单,同一个 from_status 到 to_status,只能发生一次。否则回放的时候你会得到两份成交记录。

3.3 日志的持久化:写磁盘还是写数据库?

日志存哪儿?这是个经典问题。

我个人的经验是:操作日志写数据库,状态变更日志写文件。

为什么这么分?

  • 操作日志需要频繁查询(查用户、查时间范围),数据库的索引能力更合适。
  • 状态变更日志是顺序写入的,文件系统比数据库快 3-5 倍。而且回放的时候,顺序读文件比随机读数据库快得多。

文件日志的格式,我推荐用 WAL(Write-Ahead Logging) 的思路:

// 每条日志一行,用分隔符隔开
1699000000123|ord_001|NEW|PARTIALLY_FILLED|50|100.5
1699000000150|ord_001|PARTIALLY_FILLED|FILLED|150|100.5
1699000000200|ord_002|NEW|CANCELED|0|0

这种格式的好处是:

  • 写入快,直接 append 到文件末尾
  • 解析简单,按行读就行
  • 压缩率高,文本格式可以压到原始大小的 20%
关键点:日志文件一定要「刷盘」。别用操作系统的 page cache 糊弄自己。我见过一个系统,日志写到了内存缓冲区里,结果机房断电,最后 3 秒的日志全丢了。那 3 秒里正好有一笔大单...你懂的。

3.4 日志回放:从零重建订单簿

日志回放,就是把历史日志重新执行一遍,恢复出当时的系统状态。

什么时候需要回放?

  • 系统重启后,从日志恢复订单簿
  • 数据不一致时,用日志做「对账」
  • 测试环境需要模拟生产数据

回放的流程,我画了张图:

日志回放流程 1. 读取日志文件 2. 解析日志条目 3. 校验完整性 4. 按时间戳排序 5. 逐条执行操作 6. 重建订单簿状态 注意:回放过程中如果发现日志缺失或顺序错乱,需要触发「快照恢复」流程

回放的时候,有几个要点:

  • 按时间戳排序:日志文件可能被切分过,多个文件之间要保证全局有序
  • 幂等执行:同一个日志条目执行两次,结果应该一样
  • 快照加速:如果日志太多(比如几亿条),从头回放太慢。我一般每 10 万条日志打一个快照,回放时从最近的快照开始
我的经验:回放速度是个容易被忽视的问题。我做过一个系统,回放 1 天的日志需要 40 分钟。后来优化了日志格式和解析逻辑,压缩到了 3 分钟。关键点:别用 JSON 解析每条日志,用二进制格式或者定长字段。

3.5 日志与快照的配合

日志和快照是「搭档」关系。

快照是「某一时刻的完整状态」,日志是「快照之后的变化记录」。

恢复的时候:

  1. 先加载最近的快照
  2. 再回放快照之后的所有日志
  3. 得到最终状态

这个机制的好处是:

  • 恢复速度快(不用从头回放)
  • 日志可以定期清理(快照之前的日志可以删掉)
  • 快照本身也可以做校验(和日志回放结果对比)
注意:快照和日志之间不能有「空洞」。也就是说,快照的时间点必须和日志的时间点对齐。我见过一个系统,快照是 10:00:00 的,但日志从 10:00:01 开始——中间那 1 秒的订单全丢了。嗯,那个系统的作者后来被开除了。

好了,日志机制就聊到这儿。说白了,日志就是系统的「后悔药」。写得好,出问题能救你一命;写得不好,出问题你连怎么死的都不知道。

一句话总结:操作日志管「人」,状态变更日志管「系统」,持久化保证「不丢」,回放保证「能恢复」。四者缺一不可。

公众号:蓝海资料掘金营,微信deep3321