故事
民国八年,秋。浦市。
沅江边上这座镇子,自清初起就是湘西最大的桐油集散地。桐子从辰州、麻阳、凤凰、乾州、永绥一路运来,在浦市的油坊里榨成桐油,装进木桶,编号烙印,用船顺沅江下常德,再换大船出洞庭,过武汉,到上海,卖给洋人做船漆、做雨布、做油毡。
同茂油号在浦市西街尽头,门脸不算大,但在浦市做桐油的人里头,同茂的招牌挂得最老。田家祖上三代都做这个生意,到田鹤龄这一代,同茂在浦市、辰溪、常德、汉口都有分号,每年从浦市出的桐油两万多担。
同茂的账房叫滕先生。
滕先生今年六十一岁。在同茂做账房做了三十四年。他是辰州府读书人出身,前清光绪年间考了两次秀才没中,家里供不起他再考了,就托人进了同茂当小伙计。那时候他二十七。田鹤龄的父亲田老东家看他字写得好、算盘打得准、人又木讷不多嘴,三个月后就把他从柜台挪到账房。五年后升副账房,十年后升正账房。三十四年里同茂换过两个东家,账房一直是滕先生。
滕先生的规矩,浦市做生意的人多少都听说过。
头一条是——凡是一笔生意,不经账房挂号,不作数。伙计跟人谈定了一船桐油的买卖,回来先进账房,报给滕先生。滕先生摊开流水账簿——账房的术语叫"日账"——拿毛笔写一行:某月某日某时,某家商号,桐油若干担,价若干,已付定金若干,约定交货若干日之后。写完之后,伙计再把银票、契约、货样——这些才是"真家伙"——交过来入库。日账不先落笔,后头那些真家伙同茂不认。
头一次进同茂做事的伙计,都觉得这条规矩多余。货都验过了、银子都点过了、契约都画押了,为什么还要先到账房挂一笔号?滕先生从不解释。他只说:"这是同茂的规矩,你照做。"做一阵子之后,伙计们自然明白。某笔生意黄了——买主反悔、货出了事、船翻了——要回溯责任的时候,大家都回头翻日账。日账上每一笔都写着"某日某时某人经手",出了事,责任清清楚楚。日账上没有的事情,就等于这件事没发生过;谁要扯皮说"我早跟你说过"——滕先生摇头:"先生,日账上没有。"
第二条规矩更奇怪——滕先生每天闭账之后,要把当天的日账,抄一份副本,装进一只铁皮匣子,随身带回家。
这件事田老东家活着的时候知道,睁一只眼闭一只眼。田鹤龄从父亲手里接字号,知道得没那么清楚,只隐约听说滕先生有这么一个习惯。他问过滕先生一次:"滕先生,账不是都在铺里么?你抄一份带回去做什么?"滕先生回答:"东家,铺子里有保险柜,但保险柜也不是万无一失。"田鹤龄笑了一下:"浦市是开化地方,谁还能把同茂的保险柜撬了?"滕先生点点头,没再说。从那之后田鹤龄也没再问过。
其实浦市不是开化地方。浦市只是一个县城下头的镇子。
民国八年秋天,湘西乱得比前几年更凶。
辛亥之后,湖南一直没有太平日子。湘军、北洋军、桂军、黔军、川军,这个进那个出。杆子——湘西人管土匪叫杆子——趁乱坐大。辰溪、沅陵、泸溪一带,几条大杆子各占山头,白天还好,入夜以后各自下山劫道、打寨、绑票。县府剿过几次,剿了这支出来那支。商队要从浦市往常德运货,十趟里有两趟会出事——货被劫一半、人伤几个、船沉一只,都算家常。保险镖局的价钱一年比一年高。
九月十二,同茂一船桐油从浦市起运下常德。船老大姓覃,跑了十五年沅江了,是田鹤龄最信得过的一位。船上装的是同茂那一季最好的一批油,三百二十担,装在一百六十只油桶里,每只桶上都烙着同茂的戳。
九月十四傍晚,船走到辰溪下游一处叫黑鱼滩的地方。河湾转弯的那一段,两岸都是岩壁,水流急,船得靠边走。那地方向来不太平。覃老大本来想赶在天黑前过滩,结果水位低,船有一次擦底,耽搁了一个钟头。出滩的时候天已经擦黑。
两岸岩壁上同时亮起几十支火把。
上来的是一支杆子,看号衣像是湘西西路那一路,头领号称金鸡大王的人马。劫船的规矩那几年已经形成,不见得杀人,但货要全部留下。覃老大很懂规矩,没抵抗。三百二十担桐油,一百六十只桶,全部卸到岸上,由杆子分头扛进山。船上五个人——覃老大、两个船工、两个同茂的伙计——被绑在船舱里,船放走。半夜里,他们自己挣脱,把船摇到岸边,天亮之后找了一户沅江边的人家,借了马,一路报信回浦市。
九月十六上午,消息传到同茂。
田鹤龄那时候不在浦市。他半个月前去了常德,谈一笔要紧的生意——和上海一家洋行的代理签下一年十万担桐油的长约。
九月十六下午,同茂街上来了另一伙人。
这伙人穿的是军装。号称是湘军某某团的一个营,说是奉命到浦市"剿匪",捉拿黑鱼滩劫案的共谋。他们进了同茂铺面,一口咬定同茂的伙计和杆子有勾结,不然杆子怎么晓得这船的行程。他们要搜铺。
账房坐的是滕先生。滕先生站起来说:"长官,账房要搜,我陪诸位搜。东西在哪里我都清楚。"
领头的是一个三十来岁的营长,脸皮黑,眼神不定,看就知道不是正经军官。他让手下的兵推开滕先生。兵进了账房,把保险柜撬开。柜里摆着同茂的总账——分类账、往来账、库存账、各分号对账簿——每种账一大摞,总共二十多本。
那营长翻了两本,没看出什么名堂。他对兵说:"烧了。"
滕先生上前:"长官——"
那营长拔出枪来。"老东西,你说这是字号的账,我说这是杆子的名册。你要什么不是我说了算?"
滕先生不再说话。他看着那一伙兵把二十多本总账搬到铺子门口的空地上,堆成一堆,浇上煤油,点了火。
烧了大概两刻钟。剩下的没烧透的,那伙人走的时候顺手带走了几本——后来听说是那几个兵晚上在营里烧菜引火用的。
傍晚那伙人走了。铺面没砸,货没抢多少——油桶他们搬不走,他们要的就是账。
为什么要账?后来有明白的人说——那营长和西路那支杆子是一伙的。劫完同茂的油,再来销毁账底,之后同茂就算追讨也没凭据。油号没了账,空有招牌,几个月内自己就得垮。
这个算盘打得很细。
田鹤龄九月十八赶回浦市。
一路上他已经听说了劫案的事情,心里沉得不行——这一船桐油三百二十担,按当时浦市的价,值银子八千多两,同茂这一季的利润基本上就没了。回到浦市,进到铺子里,看见账房那一堆焦灰,他一下子站不住了。
滕先生从后屋出来。手里抱着一只铁皮匣子。
田鹤龄看了滕先生一眼。滕先生把铁皮匣子放在柜台上,打开。匣子里整整齐齐摞着一沓装订好的册子——深蓝色的封皮,每册上头用毛笔写着年月。
"东家。"滕先生说。"同茂开业三十四年的日账副本,都在这里。"
田鹤龄站在那里,半天说不出话。
三天三夜。
滕先生、田鹤龄自己、和同茂里剩下的两个账房学徒——四个人,在后屋里不睡。桌上摊开的是那本铁匣里取出来的民国八年的日账——从正月初一到九月十五,整整八个多月,每一天都有一页。每页上十几到几十笔流水。
滕先生带着他们做一件事:从日账把总账重新抄出来。
先抄分类账。日账是按时间写的——什么时候、什么人、什么事。分类账要按类别分——往来户一个一个立,存货一项一项列,现银一笔一笔归。滕先生一条一条念:
"正月十五,辰溪王家油坊送桐籽三十担,付定金十五两。"
学徒翻到"辰溪王家"那一页,记上"八年正月十五,桐籽三十担,定金十五两"。
"正月十七,辰溪王家补送桐籽二十担,结清上笔定金尾款十两。"
学徒翻到"辰溪王家"那一页,再记一行:"八年正月十七,桐籽二十担,结上一笔尾款十两。"——又翻到现银账那一页,记:"八年正月十七,付辰溪王家十两。"
日账上每一笔,在分类账里可能要翻到两三处不同的账页——一笔生意同时牵涉到往来户、存货、现银、欠款——所有这些账都要从那一笔日账流水里推导出来。
三天三夜,四个人把八个月的日账全部重抄了一遍。到第四天中午,同茂的分类账、往来账、库存账、现银账全部补齐。
田鹤龄看着新抄出来的账本,问:"到九月十五为止,同茂账上该有多少现银?"
滕先生翻账:"一万六千四百七十二两,加上辰溪、常德、汉口三处分号的存款八千三百两,共计二万四千七百七十二两。"
田鹤龄点头。"我们欠人家多少?"
"共欠外头一万一千两零,大头是春天向汉口祥记借的那笔九千两,约定腊月还。"
"人家欠我们多少?"
"一万八千四百两,大头是上海美孚年初那笔货款还没清。"
"库里现在有多少桶油?"
"浦市库八百担,辰溪库三百担,常德库一千二百担。九月十四那一船是三百二十担,那一船没了。"
"还有哪些生意在路上?"
滕先生翻:"九月十号和永顺孙家签的五百担桐油,约十月十五日在浦市交货,定金六百两我们已经付了。九月十二和常德德祥签的一千担下游桶运,约定十月运到汉口,运费八百两到汉交割。九月十三——"他一直念下去。
田鹤龄听完,长长出了一口气。
"滕先生。"他说。"你这份日账副本救了同茂。"
滕先生摇头。"东家,不是副本救了同茂。是规矩救了同茂。"
同茂没有垮。
劫掉的那三百二十担桐油,八千两银子,是实实在在的损失——追不回来,镖局保的也只赔一半。但同茂本身没垮——凭补出来的账,同茂知道自己能收多少、该付多少、库里还有多少、路上还有哪几笔生意——这个字号还能继续做下去。
那年冬天,田鹤龄做了两件事:第一,把同茂所有分号的账房都召到浦市,定新规矩——每个分号的账房,每日闭账之后,除了铺里保险柜存正本之外,另抄一份副本,存到账房自己家里。第二,他自己把同茂的股子分了一小份给滕先生。
滕先生不肯收。田鹤龄说:"滕先生,三十四年我父亲活着的时候,他一次股子没分给你。我爹心里想着分,一直拖到他过世,也没跟你开过口。这次你替同茂挡了一关——这一小份股子你必须收。"
滕先生收了。他回家之后把那只铁皮匣子擦了擦,继续每天装日账副本。
入冬之前有一天,同茂来了一位客人。是浦市另一家油号信达的新东家,一个二十几岁的后生,姓覃,和船老大覃伯不是一支,也不认识。这覃少东家父亲刚过世,字号交到他手里,他一上来就想学点东西。他听说了同茂的事,专程来拜访滕先生。
覃少东家坐下之后问:"滕先生,我想请教一件事。这次同茂之所以能挺过来——按我听说的——是因为您随身带了一份日账副本。要是没有那份副本,同茂还能挺过来么?"
滕先生想了想。
"挺不过来。"他说。
"那——"覃少东家谨慎地问。"铺子里的保险柜要是也备份一份呢?放两个保险柜,分两个地方藏?"
"也要备份。"滕先生说。"但光备份总账不够。"
覃少东家没听懂。
滕先生给他倒了一碗茶。
"覃少爷,我跟您说一件事。您要听明白这件事,同茂的规矩才不白给您讲。"
滕先生说:"做生意,每天都在做事——进货、出货、收钱、付钱、立合同、谈定价——每一件事都是一笔变动。做这些事的时候,你要把这些变动记下来。不记下来,事情做着做着就忘了。"
"记的时候,有两种方式。一种是——事情做完了之后再记。做好了一笔生意,回来写在总账上。总账按类别分——往来户、存货、现银、欠款——每处都要记。这种记法好处是,总账一看就清楚字号现在是什么状况;坏处是,做一笔生意要在总账里翻三四页纸,忙起来容易出错,漏一笔账房不知道。"
"另一种记法是——事情还没开始做之前就先记。你和人家定下了一笔生意,我先在日账上写一行:某月某日某时,某家商号,桐油若干担,价若干,付定金若干。然后伙计才去交银子收货。日账按时间排,一笔接一笔,不分类,不交叉。每天晚上关铺子之前,再把日账上那一天的事情,誊到总账的各处去。"
"你看,两种方式有什么区别?"
覃少东家想了想。"——第二种更费事?"
滕先生笑了。
"第二种更费事。但第二种安稳。"
他说:"第一种记法,如果做到一半出事——比方说你刚和人签了一笔大买卖,契约写好了、定金收了,你正准备回账房填总账,这时候铺子突然烧了,你来不及记——这笔生意,在你的账上就没了。你问伙计伙计知道么?伙计只管交货;你问契约对方?对方当然说他给过定金了。你的账上却没有这件事的痕迹。你想追回也没地方追。"
"第二种记法不一样。先进日账,再做事情。这笔买卖签的时候,日账上就有了。就算你还没在总账上记上,就算铺子烧了、总账没了——日账上那一笔还在。只要日账在,这笔生意的一切就都能找回来。总账可以从日账重抄。日账却不能从总账补——总账上没有这笔,日账上也不会凭空冒出来。"
"所以同茂的规矩——先写日账,再做事——这个次序不能乱。你要把事情的次序记反了,等到出事的时候你就明白什么叫真的完了。"
覃少东家听完,站起来给滕先生作了一个揖。
"我回去也立这个规矩。"
滕先生还礼。他说:"这不是同茂的规矩。这是做生意的规矩。三百年前这么做,三百年后也这么做。"
那年冬天最后一场大雪下下来的时候,田鹤龄站在同茂的门面前看街上。雪把街面盖白了,远处沅江边的桐油桶一排一排堆着,覆着雪。
他想起秋天那一伙人把总账烧在这块街面上的那堆火。火那一刻烧得很旺。火烧尽了之后,一切又从滕先生那只铁皮匣子里长了回来。
滕先生从账房里出来,戴着棉帽,手里抱着那只铁皮匣子。他要回家了。
田鹤龄问:"滕先生,多少年都这么带回去?"
"三十四年。"
"没漏过一天?"
"没漏过一天。"
田鹤龄点了点头。
滕先生走了几步,又转回来。"东家——"
"嗯?"
"账没丢的那天,你不会想起账这件事。账丢的那天,你就知道账是什么了。"
他走了。雪下得大。
概念解析
这则故事讲的是分布式系统和数据库里最基础的一项技术——预写日志(Write-Ahead Log,简称 WAL)。这项技术朴素到看起来没什么——一条"先记录、再修改"的规矩——但它是现代几乎所有持久化系统的基石。关系型数据库(PostgreSQL、MySQL 的 InnoDB)的恢复机制、消息队列(Kafka 的分区存储)、文件系统的日志(ext4、NTFS)、分布式共识(Raft/Paxos 的日志复制)、LSM 树存储引擎(RocksDB、LevelDB)——这些系统最底下那一层,都是 WAL。
问题
想象一个银行的账户数据库。里面存着几百万个账户余额。现在你要处理一笔转账——从 A 账户扣 100 元、给 B 账户加 100 元。程序的执行步骤大致是:
- 从磁盘读取 A 的余额(比如 500)。
- 从磁盘读取 B 的余额(比如 300)。
- 在内存中把 A 改成 400,B 改成 400。
- 把 A 的新余额写回磁盘。
- 把 B 的新余额写回磁盘。
如果一切顺利,这套步骤执行完,转账就完成了。但现实不会永远顺利。计算机在任何一瞬间都可能崩溃——断电、宕机、内核 panic、操作系统把进程杀了、磁盘突然坏了。如果崩溃发生在步骤 4 和步骤 5 之间会怎样?
A 的账户少了 100 元,B 的账户没多 100 元。100 元凭空消失了。
等系统重启之后,它怎么知道这件事?它看到磁盘上的数据:A = 400、B = 300。它没有任何办法分辨这是"一次完整的转账完成之后的状态",还是"一次半成品转账的状态"。
这就是数据库最根本的问题——崩溃一致性。任何一个对持久数据的修改都不是原子的(多个字段要一起改),而崩溃可以发生在任何一个中间状态。没有特别的机制,你几乎无法保证数据在崩溃重启后还是合理的。
WAL 的办法
WAL 的办法非常朴素,可以用一句话概括:
任何对持久数据的修改,都要在真正修改之前,先把"我打算做什么"写到一个只追加的日志文件里。
具体到转账这个例子,加上 WAL 的流程变成:
- 从磁盘读取 A 的余额(500)、B 的余额(300)。
- 在内存中算出新的余额(A=400, B=400)。
- 向日志追加一条记录:
{交易 id: 12345, A: 500→400, B: 300→400}。确保这条日志已经刷到磁盘(fsync)。 - 把 A 的新余额写回磁盘。
- 把 B 的新余额写回磁盘。
- (可选)在日志里追加一条
{交易 12345 完成}。
这个次序是关键。步骤 3 必须发生在步骤 4、5 之前——日志先落盘,数据再改。这就是"预写"(Write-Ahead)的意思——"在数据被改动之前,日志先写好"。
现在设想崩溃发生在任何一步:
- 崩溃在步骤 3 之前:日志里什么都没有。重启之后系统找不到任何关于这笔交易的记录——这笔交易就像没发生过一样。用户会看到转账失败,但账户状态是正确的(A=500, B=300)。
- 崩溃在步骤 3 之后、步骤 5 之前(不管是 4 之前、4 之后、还是 4 和 5 之间):日志里有完整的
{12345: A: 500→400, B: 300→400}。重启之后系统读日志、看到这条记录,就把 A、B 重新按日志上的目标值写一遍。不管崩溃的时候磁盘上是什么状态,按日志重放一遍之后都会到达正确的终点。 - 崩溃在步骤 6 之后:日志有完成标记。系统知道这笔交易已经处理完了,什么都不用做。
关键在于:日志是"意图"的完整快照。只要日志落了盘,无论真正的数据文件正处于什么残缺状态,系统总可以"把日志重放一遍"来恢复到一个一致的状态。
和故事的对应
故事里滕先生和同茂的规矩,正是 WAL 的物理化身:
- 日账 = 预写日志。日账按时间顺序追加,每笔生意先进日账,才能去做这笔生意的实际动作(交货、付款、签契约)。
- 总账 = 数据文件。总账是按类别组织的——往来户、存货、现银、欠款——这是"当前状态"的视图。
- 日账先写、总账后誊。每天闭账,把当天的日账条目一条条誊进总账。这个"后誊"就相当于数据库把内存里的 dirty pages 异步刷到磁盘。
- 日账是不可改的、只追加的。日账一笔写一次,从不修改。总账可以改、可以补、可以重写。
- 崩溃恢复。土匪烧了总账(数据文件毁了)。滕先生凭铁匣里的日账副本,用了三天三夜把总账重抄一遍——一笔一笔回放日账,总账就回来了。
- "只有总账备份还不够"。滕先生对覃少东家说的那段话——光备份总账不够,必须以日账为准——点出了 WAL 的本质:如果总账上漏了一笔,没有日账就永远补不回来;但只要日账上有这一笔,总账永远可以重建。
- "次序不能乱"。滕先生强调的"先写日账,再做事"——这个次序正是 WAL 最严格的规定。数据库里如果允许"先改数据、再写日志",那么崩溃在两者之间发生时,数据已经改了、日志却没记录——恢复的时候系统不知道已经改过什么,也不知道该回滚什么。这就破坏了整个 WAL 的保证。
WAL 的几条关键性质
-
顺序写远快于随机写。日志只追加、只往一个文件的末尾加——磁头不需要移动,SSD 也不需要做随机 IO。而数据文件的修改几乎总是随机的——要更新散布在很多位置上的记录。在传统机械硬盘上,顺序写和随机写的速度差一到两个数量级;即使在现代 SSD 上仍有显著差别。WAL 让你把慢而随机的数据文件修改延迟到空闲时做,关键路径上只做快速的顺序追加。
-
持久化 = 日志持久化。一旦日志里的条目被 fsync 到磁盘,这笔操作就算"安全"了——哪怕对应的数据文件修改还没来得及写,因为恢复时可以重放。这让你可以把"交易成功"的通知发送给用户得很早,不必等数据文件真的改好。
-
日志可以复制。WAL 是一条线性的操作序列。把这条序列复制到另一台机器,那台机器重放同样的序列就能得到同样的状态——这就是状态机复制的原理([✓#6 传灯人] 讲的 Paxos/Raft 本质上就是"分布式地对日志的顺序达成共识")。所有现代分布式数据库的复制机制,底下都是 WAL 的复制。
-
日志可以截断。一旦数据文件里的变动已经完全落盘,且没有任何活着的事务需要回滚到之前的状态,旧的日志就可以删除或归档。日志并不会无限增长。
WAL 的形态
WAL 这个模式在不同系统里呈现出不同的形态,本质都一样:
- PostgreSQL 的 WAL——物理日志,记录每一页的二进制修改。崩溃恢复时重放。
- MySQL InnoDB 的 redo log——物理日志加逻辑元素,用于崩溃恢复;另外还有 undo log 用于事务回滚。
- SQLite 的 WAL 模式——每个事务先写到
.wal文件,定期 checkpoint 合并回主库文件。 - 文件系统的 journal——ext4 的 journal 模式、NTFS 的 $LogFile,都是为 inode 和元数据变更做的 WAL。
- LSM 树(RocksDB、LevelDB)——每次写入先到内存的 memtable,并同时追加到 WAL;memtable 满了后 flush 成磁盘上的 SSTable,此时对应的 WAL 段才能删。
- Kafka——严格来说 Kafka 的分区存储就是一个持久化的 WAL。消费者通过"追赶日志"来重放事件。
- 事件溯源([XIV. 架构范式])——整个应用架构把 WAL 抬到了领域层:事件是唯一的真相,当前状态只是事件重放的结果。
这些系统的设计可以完全不一样——有的是关系型数据库、有的是 NoSQL、有的是消息队列、有的是文件系统——但它们最底下那一层的那条原则,是同一条:先写日志,再改数据。
更深的启示
故事结尾滕先生留给田鹤龄那句话——"账没丢的那天,你不会想起账这件事。账丢的那天,你就知道账是什么了"——其实是每一位数据库工程师都懂得的一条经验:
WAL 的价值只有在崩溃的那一刻才被看见。
平日里,WAL 看起来是多余的——每次写数据要多写一遍日志,磁盘空间多占一倍,程序逻辑多一层复杂。许多年轻的工程师刚接触 WAL 的时候会想:"这有必要吗?大部分时候系统不会崩溃啊。"
然后他们会遇到第一次真正的生产崩溃。服务器断电、机房掉电、内核 panic——重启之后数据库状态诡异,一部分数据是更新后的、一部分是更新前的、少数是半成品——没有 WAL 的话这个状态永远无法恢复。
这就是滕先生要说的——一项规矩的价值,体现在它本该在、却不在的那一天。同茂的那只铁皮匣子三十四年里每天都带回家,三十四年里几乎没用上。直到 1919 年秋天那一天——那一天同茂的所有账本都被烧了——那只匣子救了整个字号。
面对"稀有但毁灭性"的事件,唯一的办法是在平日就付出"看似多余"的代价。 这个代价看起来浪费、烦琐、重复、没意义——直到它真的被需要。这不仅是 WAL 的道理,也是所有冗余机制、备份机制、容灾机制、保险制度的共同道理。
这条道理还可以反过来说——一个系统能否挺过一次灾难,不取决于它遇到灾难那一刻做了什么,而取决于它在灾难发生之前每天都在做什么。崩溃那一瞬间你没有时间设计 WAL,没有时间补账、没有时间追问谁欠谁多少钱——你只能用你手里当时已经有的东西。
故事里覃少东家听完滕先生的解释之后,回去也立了同样的规矩。这是一个好结局——他还没经历过属于他自己的那场土匪。真正明白 WAL 的人,是在没崩溃的时候就把 WAL 做好的人。等崩溃来了,就太晚了。
同茂油号在民国八年之后又做了三十多年的生意,一直到四十年代末。滕先生做到七十岁才告老。他临走之前把一份完整的账房规矩写下来,交给同茂的继任账房,其中第一条,还是那条——
先写日账,再做事。