预写日志:程序说“成功”之前,应该先留下什么
本文是 Agent 工程里的经典架构 系列的一篇。文中的业务场景和代码为讲解设计,产品能力以所链接的官方资料为准。
做一个接收任务的接口并不难:把任务放进内存队列,返回“已受理”,后台慢慢处理。真正让人不踏实的是,返回成功后一秒钟进程重启了。用户拿着受理编号等结果,我们的系统却已经不知道这件事。接口可用性看起来不错,业务承诺实际上没有兑现。
Write-Ahead Log 给出的基本思路,是先把状态变更记录到持久日志,再依靠这些记录恢复状态。我更愿意从交付边界理解它:什么时候可以告诉调用方“这件事我接住了”?答案取决于我们是否已经留下足够可靠的恢复依据。
假设做一个商品资料整理 Agent。它先登记任务,再获取网页、提取字段,最后生成结果。登记成功至少意味着任务标识、输入和初始状态已经持久化。页面解析的临时变量可以丢,用户交给我们的任务不能跟着 Worker 一起消失。把这两类状态分开,才能放心地扩缩容和发布新版本。
下面用一个只有“设置键值”操作的小例子说明顺序。它刻意在日志落盘之后、内存更新之前模拟进程退出。恢复时不相信旧内存,而是重新读取已完成写入的日志。这里记录的是我们自己定义的命令,不是数据库内部 WAL 格式。
import json
import os
import tempfile
from pathlib import Path
def append(path, key, value):
record = json.dumps({"key": key, "value": value}) + "\n"
with path.open("a", encoding="utf-8") as log:
log.write(record)
log.flush()
os.fsync(log.fileno())
def recover(path):
state = {}
for line in path.read_text(encoding="utf-8").splitlines():
record = json.loads(line) # 损坏时明确失败
state[record["key"]] = record["value"]
return state
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "commands.log"
append(path, "task-17", "accepted")
# 模拟:还没有更新内存,进程就退出了。
state = recover(path)
assert state["task-17"] == "accepted"
append(path, "task-17", "finished")
assert recover(path)["task-17"] == "finished"
print("已确认的状态可从日志恢复")
完整示例可下载,使用 Python 3.9 或以上版本运行:python3 example.py。只依赖标准库,不调用真实业务服务。
代码中的 flush 将 Python 缓冲区交给操作系统,fsync 请求将文件数据同步到存储。它们代表不同层次,少写一次调用不能靠“机器一般不会坏”来补偿。示例只验证进程中断后的重建;操作系统崩溃和断电时的保障,还涉及文件系统、存储设备及目录项的同步策略,不能凭这个脚本作出生产承诺。
注意这个例子没有回放“调用供应商接口”,只回放本地的状态变更。假如日志写着“准备下单”,它不证明供应商已经接单;如果日志写着“下单成功”,也要解释这个事实来自哪里。外部调用完成与本地记录之间仍有故障窗口,需要幂等键、结果查询或对账。WAL 解决本地持久性,不能替两个系统创建一个不存在的原子事务。
从高并发系统的角度看,每条记录都单独同步磁盘,吞吐量很容易受限。可以把多条记录组成一批同步,但必须等这一批真正完成同步,才能对这些请求承诺相应的持久性。批量提交改变了延迟和吞吐量的取舍,没有改变“先持久化,再确认”的顺序。具体批次大小应由故障目标和压测结果决定。
日志也不能无限增长。上线以后,需要检查点、归档和恢复演练。恢复耗时是一个业务指标:即使数据一条没丢,如果要扫描三天日志才能恢复服务,也未必符合可用性目标。日志损坏、最后一条写到一半、多写入者竞争等情况,都要有明确策略;不能把所有解析异常都忽略,否则中间的损坏也会被当作正常尾部截断。
恢复演练还应该区分“服务端确实接受了”和“客户端看到了成功”。日志已经可靠落盘,但响应丢失,客户端仍然可能重试。持久性不会消除这个不确定性,任务入口还需要稳定编号,把重复受理识别成同一个任务。否则恢复得越可靠,重复任务也可能执行得越完整。
示例假定每一行都完整且只有一个写入者。真实日志通常还需要长度、校验和与连续序号,用来识别截断和损坏;保留到哪个位置,也应有可验证的提交边界。写出来的文件能被读取,只是恢复设计的起点。
对于普通业务团队,我通常会先使用数据库已经提供的事务和持久化机制,避免自己实现日志引擎。真正值得从 WAL 学到的,是在设计文档里写清楚确认边界:接口成功表示进入了哪份可靠记录,故障后谁读取它,以及哪些操作允许重新执行。这几句话,比给 Agent 增加一段“请记住任务”的提示词有用得多。