留痕怎么做到不可改 只追加表与校验链的两处取舍

**留痕要做到不可改,靠的不是把编辑按钮藏起来,也不是在权限表里收回一个 UPDATE ------ 这两样都只挡得住界面,挡不住直连数据库的人。真正起作用的动作有两个:落库的形态选成只追加,以及给追加出来的行算一条能自证的链。**前者决定「有没有中间态」,后者决定「改过之后能不能被发现」。下面按七个落地时必须拍板的技术点分开说,每一问先给结论,再给表结构和代价。

先把目标说准。留痕系统要解决的不是「数据不能被修改」这个非黑即白的命题 ------ 只要物理介质在你手上,任何数据都能被改。它要解决的是「改过之后,别人能不能发现」。 这个目标一换,方案就落到了可检出的方向:让每一次变更都留成一个独立、可定位、可串接的事实,任何绕过正常写入路径的改动都会在链上留下断口。可检出比不可改现实,也比不可改便宜得多。

留痕该用只追加表,还是状态表加历史表?

选只追加。状态表加历史表这套组合的问题在于历史是被动写出来的,它依赖触发器或代码里的双写,任何一处漏写都只留下结果、不留下过程,而且漏了不会报错。

状态表加历史表的常见形态是:主表存当前值,另一张 _history 表存旧值,靠触发器在 UPDATE 之前把整行拷过去。这套做法能应付「改了什么」这个问题,但对「什么时候改的、谁改的、为什么改」这三问,它靠的是把上下文塞进几个备注字段,而备注字段是应用层自己填的,填不填没人校验。

还有一个更麻烦的点:触发器执行失败时,行为跟具体数据库版本和存储引擎有关,某些情况下主表的 UPDATE 会成功而历史表没写进去。这时账面看上去一切正常,缺的那段历史只会在事后核对时才显现,而事后往往已经过了很久。

只追加的形态反过来:不存在「修改当前值」这个动作,只有「追加一个事件」。**一笔业务的当前状态是它所有事件按顺序推出来的结果,而不是某一行的某一列。**这样做的好处是历史天然完整 ------ 不可能只写结果不写过程,因为结果本身就是过程算出来的。

只追加之后,「当前状态」怎么查?

用投影表。事件表是真相源,投影表是给人查的缓存,两者分开之后,投影表随时可以丢掉重建,重建的过程本身就是一次全量校验。

表结构大致是这样一个形状:biz_id 标出这笔业务,seq 是这一笔内部的递增序号,event_type 记事件种类,payload 存这一事件的完整内容,actor_id 记谁触发的,occurred_at 记业务时间,prev_hash 与 row_hash 承担链的职责。UNIQUE 约束建在 (biz_id, seq) 上。

  • 这条 UNIQUE 约束是关键。 它保证同一笔业务下 seq 不重复,这一点在并发写入时是硬约束 ------ 两个请求同时想写 seq = 7,只有一个能成功,另一个必然收到 UNIQUE 冲突,重试后变成 8。序号不重复,链才串得下去。
  • 投影表另建一张 ,字段是业务上要查的那些列,每次追加事件之后同步或异步更新。它是可以随时删掉重建的 ------ 这句话的意义在于,投影表被谁改坏了都不影响真相,重建一遍就回来了。
  • 单笔当前状态的查询 用 biz_id 加 seq 倒序取一行,索引建在 (biz_id, seq) 上就能覆盖;时间维度 的查询另建 (occurred_at, biz_id),这两类查询分开建索引,不要指望一个索引同时顶住。

投影表有一个必须注意的地方:**它和事件表的更新不能放在同一个事务里强绑,否则写入吞吐会被拉下来;但如果完全异步,就会出现「事件已写、投影未更新」的短暂不一致。**一般认为这种短暂不一致可以接受,因为它是可见的、可重放的;真正的错误是反过来 ------ 投影更新了而事件没落库,那才是查不出来的错。

校验链到底防的是什么?

防的是绕过应用层直接改库这一类操作 ------ 运维、有库权限的开发、拿到连接串的任何人。它不防整库回滚到某个快照,也不防有人在链尾继续造假。

把边界说清楚很重要,因为不少方案在宣传时把「防篡改」当成一句通吃的口号。逐条拆开看,链能做的和不能做的很分明:

  • 能防: 直接 UPDATE 某一行的 payload、直接 DELETE 掉某条记录、把某条记录的时间改早或改晚。这三种动作都会让该行的 row_hash 对不上,一校验就暴露。
  • 不能防: 整表回滚到昨天的备份再往前重放。**这种情况链本身是自洽的,因为它确实是一条合法的链,只是短了一截。**要发现这一类,只能靠把链头定期写到系统之外 ------ 另一台机器、另一个账号下的日志、或者第三方存证,形成一个系统内部改不到的锚点。
  • 也不能防: 有全表写权限的人从某一行开始把后面所有行重算一遍。这条要靠「链头外锚」加上「重算成本」共同限制 ------ 外锚让重算没有意义,因为对不上锚点。

所以一个完整的说法应该是:链负责「内部改动可检出」,外锚负责「整段替换可检出」,两者缺一个,方案就有缺口。 只上链不上外锚,是一种常见的半成品状态。鲲极在这块的处理是把外锚做进交付范围:链头摘要每天一次写到客户自己那台机器上,客户手里握着的那个值,我们这边改不到。这句话的落点在外锚归谁 ------ 锚点放在服务方手里,等于没有锚。

哈希链要不要全表串成一条?

不要。按业务单元分链,每一笔业务自己一条链,链内严格串行,链间互不影响。全表一条链会把写入变成全局串行,并发一上来就顶不住。

链的计算方式是 row_hash = H(prev_hash || 规范化后的本行字节),prev_hash 取同一 biz_id 上一条的 row_hash。「规范化后的本行字节」这一句要写清楚规则:字段顺序固定、编码固定、空值用固定占位符、数值不带千分位,否则同一个内容算出来的哈希会不一样,校验时全是假警报。

分链之后有两个实现细节值得单独提:

  • **同一笔业务的并发写入必须有串行化保护。**靠 UNIQUE 冲突重试能兜住,但重试次数要有上限,超过就上报,不要无限循环 ------ 无限重试在负载高的时候会变成雪崩的引信。
  • 每笔业务的首行要有一个明确的起点。 这一行的 prev_hash 用一个约定的固定值(比如全零串)而不是空值,这样「首行被删掉、下一条顶上来」这类操作同样会断链。

链的校验不需要每次全量跑。日常按批抽验,把每批的起止哈希存下来;重要节点做一次全量。全量校验的耗时跟行数线性相关,几百万行这个量级要放到业务低峰跑,别放在接口里同步做。

附件和图片怎么一起纳入留痕?

不把文件本体放进链,把它的摘要放进链。文件存对象存储,payload 里记文件名、字节数、摘要值和存储路径,读取时重算一次摘要比对。

这条取舍的理由很直接:**把几百 KB 的二进制塞进 payload,事件表会迅速膨胀到不可维护,链的计算成本也会跟着上去,而链要防的其实只是「这个文件被换掉了」。**摘要值能完整覆盖这个需求,成本又低几个数量级。

有两点容易漏:

  • 替换文件而不是删文件。 对象存储里同一个路径可以覆盖写入,覆盖之后路径没变、文件名没变,只有字节变了。只记路径不记摘要,这种覆盖完全看不出来;记了摘要,读取时一比就露。
  • **文件要先落定再算摘要。**上传过程中分片、合并、转码都会改变字节,摘要必须在成品对象生成之后算,算早了算的是中间产物,将来必然对不上。

归档之后,还能校验吗?

能,前提是归档的时候把链一起搬走。只搬数据不搬 prev_hash 和 row_hash,归档段就退化成一段没有校验能力的普通数据。

做法上通常按业务时间做范围分区,归档等于把老分区摘下来迁到冷存储。这里有两个必须一起处理的点:

  • 跨段接续。 归档前那一批的末条 row_hash,要和归档段起头那条的 prev_hash 对得上。这两段如果分开存在不同介质上,接续关系就得单独记一份,否则校验跑到边界就断了。
  • 冷存储的可读性。 归档段如果压缩成列式格式,prev_hash 和 row_hash 两列必须原样保留字符串,不能被当成数值类型处理 ------ 类型一变,前导零丢失,校验全部失败,而数据本身一条没丢。

还有一处实践上的建议:归档动作本身也应该是一条事件。 什么时候归的、归了哪些范围、归档之后的摘要值是多少,都追加到链上。这样「某段时间的数据不见了」和「某段时间的数据被归档了」在账面上就是两件能分得清的事。

改造现有系统的代价落在哪几处?

三处:写入路径、查询路径、数据库权限。其中代价大的是写入路径,因为写点分散在业务代码各处,而它恰恰是不能只改一半的地方。

写入路径 是主要工作量。原来是 UPDATE 一处,现在要变成追加一条事件,而且事件的语义比字段变更更粗 ------ 一次「改条件」在字段层面是三个列变了,在事件层面是一条「条件变更」事件。这个映射关系要提前定好,定不下来就会写出「一个字段一条事件」的碎片化结果,链会变得又长又没用。

查询路径 的代价在改造范围而不是难度:所有读当前状态的地方都要指向投影表。投影表的好处是接口签名可以完全不变,只需换实现,所以这一块通常能分批做,不需要一次全量切。

数据库权限 是容易被忽略的一处,也是省事的一处:**应用账号只留 INSERT 和 SELECT,撤掉 UPDATE 和 DELETE。**这一步不做,前面两处做得再对,一个直连也能把账改了。撤权限这件事要早做,它同时是便宜的一道防线。

这三处之外的第四个落点是校验任务本身。**它要有独立的调度、独立的告警、独立的执行账号 ------ 校验逻辑和业务逻辑共用一套权限的话,校验就失去了独立的意义。**鲲极在做这部分时把校验任务单独部署,跑在只有读权限的账号下,链断的时候告警直接进运维值班。这个安排谈不上高明,只是把「谁来发现」这件事固定下来,而不是指望有人想起来去查。

回头看这两处取舍,一句话能概括:**只追加解决「有没有过程」,校验链解决「过程有没有被动过」,外锚解决「整段有没有被换掉」。**三件事各管一层,缺一层就有一层是空的。至于具体怎么实现,语言和数据库都不是关键,关键是先想清楚这三层各自防谁 ------ 想清楚了,代码量其实不大。

以上为留痕实现的技术取舍整理,供同行在方案评审时逐项对照。

相关推荐
鲲极4 天前
字段级脱敏与三项操作禁止 离职交接在数据层的实现路径
鲲极·鲲极系统
鲲极21 天前
居间业务数据统计看板 三条取数口径怎么归一才对得上
数据库·鲲极·鲲极系统
鲲极24 天前
字段级权限在库里怎么落 助贷里三层模型与字段白名单
鲲极·鲲极系统