服务端已经把 x=two 写入文件,并完成同步。返回客户端的连接随即关闭,客户端收到错误。服务进程被终止以后,新的进程应该恢复出什么值?客户端用原来的请求 ID 重试,应该重新写一遍,还是取回上次结果?

第 01 篇的内存去重在重启后会丢失。本篇把业务修改、请求身份和返回结果放进同一条追加日志,再用真实 HTTP 连接和子进程终止验证恢复行为。第 03 篇 讨论 GFS 写入路径中的多个副本;这里暂时收窄到一台机器上的一个服务进程,把单副本自身必须兑现的承诺讲清楚。

网络接口需要先定义可观察语义

这个 KV 服务提供两类请求。GET /kv?key=x 查询当前值;POST /kv 接收包含 IDKeyValue 的 JSON,将一个键设为指定字符串。写入返回原请求与全局递增的 Revision。首次成功修改获得一个新 revision,同 ID 同参数重试返回第一次的记录,不推进 revision。

revision 在这里仅是单个日志中的序号。它既不是物理时间,也不是跨节点一致性协议里的任期,更不能当作全局分布式事务版本。GET 返回当前值与存储的全局 revision。随后写入另一个键时,x 保持原值,但 GET 返回的 revision 仍会增加。

初始日志为空。操作 r1x=one,获得 revision 1;操作 r2x=two,获得 revision 2。此后重试 r1 必须返回原来的 revision 1,而 GET 应读到 two。把缓存回放结果替换成当前值,会改变原操作的含义。

相同 ID 携带不同键或值返回 HTTP 409,表示请求身份冲突。缺少 ID 或键、JSON 之后附带第二个值、未知请求字段和过大的请求都拒绝处理。这些规则使故障分析有明确对象:重复尝试属于原操作,新的业务操作必须使用新 ID。

本例没有身份认证、租户划分和跨进程文件锁,只监听动态分配的本机回环端口。实验控制器保证同一日志同一时刻只有一个服务子进程;把同一文件交给两个服务实例,超出了正确性假设。

文件写入到成功回复之间

Write 返回成功,通常表示应用的数据已经交给操作系统,不代表所有相关内容已经到达持久介质。用户态缓冲、内核页缓存、文件系统元数据与设备缓存各有自己的边界。一个“已落盘”的描述如果没有指出具体动作,很容易把不同阶段混在一起。

Go 的 os.File.Sync承诺将文件当前内容提交到稳定存储,通常对应刷新文件系统内存副本。调用者仍必须检查错误;同步失败时不能发出成功回复。

实际系统调用与平台有关。本次运行环境是 Go 1.27.0、darwin/arm64,已核对该版本的 fd_fsync_darwin.go:它先调用 fcntl(F_FULLFSYNC),遇到 ENOTSUP 时才回退到 fsync。不能直接把 Linux 的系统调用路径写成本次 macOS 实验的实现。

Linux fsync(2)还区分文件内容与目录项:同步文件不一定同时持久化它在目录中的名字。创建新日志时,需要考虑父目录的同步。本例在启动阶段同步日志,再同步父目录;任一步失败都不开始监听。目录同步的支持情况依赖运行平台与文件系统,程序不会忽略错误后继续宣称持久化成功。

这也解释了为什么“写临时文件,再 rename”还不是完整的持久化协议。Linux 的 rename 原子替换语义解决并发观察者是否看见半次名称替换,不能单独证明掉电后目录项仍然保留。若实现快照替换,还需要规定临时文件内容同步、重命名、相关目录同步及恢复策略。本例只有追加日志,暂不实现快照。

一条记录同时恢复业务与去重

把业务数据写入一个文件、请求 ID 写入另一个文件,会产生两个写入之间的崩溃窗口。业务已写而 ID 未写,重试可能再次执行;ID 已写而业务未写,恢复后可能回放一个实际没有生效的结果。

这里将两者编码进同一条日志记录:

1
{"Request":{"ID":"r2","Key":"x","Value":"two"},"Revision":2}

记录末尾附带换行符,日志严格按 revision 排列。恢复每一条完整记录时,同时更新 values[key]seen[id]。因此业务状态和去重状态都由同一日志前缀推导,不需要对两份独立文件做原子提交。

“一条记录”描述恢复的逻辑单位,不意味着操作系统能原子写入这一行。文件追加仍可能留下半条记录,持久化设备也可能发生撕裂写入或其他损坏。本例通过明确的恢复检查拒绝无法解释的文件,不把普通文件写入包装成硬件原子操作。

正常请求的处理顺序如下:

1
2
3
4
5
6
7
8
取得锁
查询 ID:相同参数回放,不同参数拒绝
构造 revision = 当前 revision + 1 的记录
写完整日志行,检查写入长度和错误
Sync 成功
同时更新业务内存与去重内存
释放锁
发送 HTTP 回复(仍可能失败)

锁覆盖完整过程,GET 也使用同一把锁。新写的可见点位于同步成功后的内存更新阶段;其他请求不能在同步尚未结束时读取一个提前公开的值。客户端收到成功以前,记录已经通过程序规定的同步边界。

这个实现刻意串行化所有键,每次新写都同步一次。同步延迟会阻塞读和其他写,吞吐上限也受这种串行过程约束。批量提交、读写分离和细粒度锁可以改善性能,但必须重新证明回复与可见性顺序,本篇不引入这些优化。

崩溃点决定恢复后的解释

如果进程在写日志之前终止,恢复看不到新记录。若一条完整记录已经写出,但同步还未返回,恢复可能读到它,也可能读不到它;客户端没有收到成功,操作结果本来就是未知。恢复接受这条完整记录,相当于保留一个尚未被客户端确认的操作,不构成“凭空执行了新操作”。

若同步已经完成、内存尚未更新时进程终止,恢复必须从日志重建业务与去重记录。若内存已更新、回复尚未到达,恢复仍得到同一条记录,客户端同 ID 重试则回放原结果。这个窗口正是配套实验主动制造的故障。

在运行正常、底层同步履行其契约的模型里,成功回复不能先于可恢复记录;结果未知的请求则允许恢复后存在。这个方向不能倒过来:不能要求所有没收到回复的请求都从恢复状态消失,否则已同步但回复丢失的请求会被错误回滚。

写入或同步出现错误时,程序设置 failed,随后读写都返回 503,要求重启并重新检查日志。原因是文件可能已有部分新数据,而内存仍是旧状态;如果继续追加,后续成功记录可能排在损坏片段后面,恢复将更难解释。失败停止服务保留了证据边界,代价是降低可用性。

这里没有模拟 ENOSPCEIO,错误处理路径只经过源码检查。已有记录的丢失、存储设备谎报同步完成、跨文件系统故障等情况也不在本地验收覆盖范围内。

恢复过程为什么拒绝坏尾

启动时,程序逐行读取日志,要求每条记录有换行结束符、可解析的 JSON、非空 ID 与键,以及连续递增的 revision。已经出现过的 ID 不允许再次写入日志。恢复完成后执行文件同步和父目录同步,才输出就绪地址并开始处理请求。

启动同步还处理了一个容易遗漏的情形:新进程可能从仍存活的操作系统页缓存读到前一个进程尚未同步的完整记录。如果立刻对该 ID 回放成功,而从未同步这条记录,去重命中路径就绕过了持久化承诺。启动阶段先同步,避免这种成功回复。

本例对不完整末行采取拒绝启动,而不是截断。报错包含该行起始字节偏移,日志保持原样。这样不能自动恢复所有常见的部分追加故障,但不会擅自把可能涉及数据丢失的文件改成看似正常的状态。

在限定模型中,可以设计“仅丢弃末尾未完成记录”的恢复策略,前提是明确确认损坏只可能来自最后一次未完成追加,并在截断后再次同步。完整记录解析失败、日志中部损坏和重复 ID 冲突不能全部套用这条规则。读到 JSON 语法正确也不保证内容没变:某个字符翻转后仍可能是合法字符串或数字。本例没有校验和、纠错或备份恢复机制。

SQLite 的 Atomic Commit说明了写入重排、扇区部分修改、同步与恢复之间的关系;该文描述 rollback journal 模式,不能当作 SQLite WAL 的具体实现说明。Powersafe Overwrite则提醒,掉电时一次写入是否会损坏范围之外的字节,本身也是存储假设。只有尾部解析检查的教学日志不能等同于成熟数据库的崩溃恢复。

运行真实网络与子进程实验

代码位于 examples/distributed-systems/kv04/main.go,仅依赖标准库。默认自检创建私有临时目录,启动当前可执行文件的服务模式,读取服务输出的回环地址;控制器用 HTTP 客户端发请求,再终止并等待子进程退出,随后启动新进程读取同一个日志。

1
2
cd examples/distributed-systems
go run -race ./kv04 -check

本次运行的关键输出为:

1
2
3
4
5
6
initial write/read revision=1 value=one
post-sync reply-loss client-error=true
killed/restarted new-pid=true same-id revision=2 read=two
conflict=409 new-id revision=3 old-id revision=1
log-records=3 incomplete-tail startup-rejected=true unchanged=true
CHECK passed

首个请求写入 one 并读取确认。第二个请求写入 two,服务端只有在完成写入与同步后,才根据实验头 X-Drop-Reply: yes 关闭连接而不返回 HTTP 响应。客户端实际观察到连接错误;它不能仅从错误文本知道服务是否执行,但实验的故障注入位置已提供独立证据。

随后控制器调用 Process.KillWait,检查退出状态确认为 SIGKILL,启动一个不同 PID。对第二个请求使用原 ID 重试,返回 revision 2;GET 读取到 two。同 ID 改值收到 409,新 ID 获得 revision 3,旧 ID 则仍返回 revision 1。

终止服务后,控制器直接数日志完整记录,确认只有三条。这个检查与 HTTP 返回值相互补充:仅看到重试返回“成功”不能证明没有重复追加;日志条数、revision 和旧结果回放共同约束了实际行为。

最后在同一个实验临时文件末尾追加半条 JSON,启动新服务应失败并报告 invalid log tail。控制器逐字节比较文件,检查恢复过程没有改变原内容。测试结束只删除自行创建的临时目录,不读取或修改用户其他数据。

可以单独启动服务观察接口:

1
go run ./kv04 -serve /private/tmp/kv04-manual.jsonl

该路径应选为专用实验文件,且同一时刻只启动一个服务。服务只绑定 127.0.0.1,实验头只用于主动制造回复丢失,不能照搬成对外生产 API。停止手动服务不会自动删除该日志;默认自检才负责清理自己的临时目录。

通过了什么,尚未证明什么

这次验收包含真实回环 TCP、HTTP 编解码、文件写入和同步、服务进程终止、新进程恢复以及同 ID 重试。它比进程内传输模型多覆盖了连接失败和进程生命周期,但仍然是单机本地观察。

Process.Kill 不会同时切断整台机器电源,也不会清空操作系统页缓存。进程被杀后,内核仍可能完成之前的后台写入;因此重启后读到内容,不能单凭这个现象证明没有 Sync 也能掉电安全。同步调用的必要性来自故障模型与平台契约,测试只验证代码是否按设计执行。

SQLite 的崩溃测试通过修改 VFS 模拟不完整扇区写入、垃圾页和写入重排。本篇没有这类存储故障注入,也没有测试操作系统崩溃、设备故障、网络文件系统或非本机 Go 版本。race 检查未报告数据竞争,不代表穷举了所有调度。

如果将来添加快照,快照必须同时包含业务值、请求去重记录和日志位置;只保存最新 KV 值会重新引入第 01 篇的重复执行窗口。如果添加多副本,单机 Sync 也不会自动变成集群提交,确认条件还需要复制协议定义。

第 05 篇将把观察范围扩展到多个节点上的事件:日志 revision 能排列本机已接纳的写入,但无法独立比较另一台机器上尚未通信的事件。这是物理时间、逻辑时钟与因果关系要解决的问题。

两个恢复判断练习

练习一:完整记录没有成功回复,恢复能否保留? 服务端写出完整的 revision 4,但在 Sync 返回前终止;客户端只观察到连接错误。新进程从文件读到这条完整记录,启动同步成功后将它纳入状态。这个行为是否违反接口?

没有违反。客户端没有收到成功,并不能确定操作未发生;协议允许未确认操作在恢复后存在。恢复同步建立了之后回放成功所需的持久化边界。若恢复直接回放、跳过同步,则可能对仅存在于操作系统页缓存的记录返回成功。反过来,如果客户端已收到成功,而恢复缺少对应记录,就违背了本例在同步契约有效前提下作出的承诺。

练习二:快照只保存 KV 值是否足够? r1 写入 one 获得 revision 1,r2 写入 two 获得 revision 2。快照只保存 x=two 和序号 2,删掉日志后重启,再收到 r1

恢复后的服务没有 r1 的去重记录,会把旧操作当作新请求,重新写入 one 并分配 revision 3。这同时破坏原结果回放和最新业务值。完整快照需要保存仍可能重试的请求记录与原结果,或者采用能够证明旧请求不会被重新接受的会话、确认与回收协议;只加一个日志位置无法恢复已删除的去重信息。

参考资料与验收附件

资料以 Go 1.27.0 os API该版本 Darwin 同步实现Linux fsyncLinux renameSQLite Atomic CommitPowersafe Overwrite为准。

论断证据表记录版本、支持范围和历史问题核对;本地验证记录保留命令、输出与未覆盖场景。