一句话导读:给一张表加"修改记录"很容易——记下字段、旧值、新值就够了。但当业务数据长成"主表 → 子表 → 孙表"的三层以上一对多结构时,同一次保存里会同时发生"改了第 2 个子表下第 5 条孙表的两个字段""删掉了第 3 条子表及其全部孙表""新增了一整棵子树",日志该记成几条、怎么定位到具体节点、前后值从哪来,就都不再显然。本文提出的做法是:一次保存动作 = 一条日志主记录,字段级差异全部落在明细表里;差异用"保存前的树快照"与"本次提交的树快照"做差集得出,而不是去监听每一个输入框。为了让这套机制不随业务层级增长而重写,把 N 层结构抽象成一个可递归的节点视图,diff 算法只依赖这个抽象。
变更日志必须回答的三个问题
无论业务形态怎么变,一条合格的信息变更日志只需要回答三件事。
| 问题 | 需要的数据 | 常见做法 |
|---|---|---|
| 谁、在什么时候、改了哪一批 | 主记录:操作人、操作时间、业务对象 | 主表一行 |
| 每个字段改前是什么、改后是什么 | 明细:字段、旧值、新值 | 明细表多行 |
| 这条变更落在整棵树的什么位置 | 明细:层级 + 节点路径 | 层级 + 路径字段 |
第一件事决定了"颗粒度",第二件事决定了"内容",第三件事决定了"可读性"。本文后面所有设计都是围着这三件事转。
贯穿全文的例子是一个通用的三层一对多结构:
flowchart TB
P["项目(根实体 · 1)"]
P --> T1["任务 · 1"]
P --> T2["任务 · 2"]
T1 --> S1["子任务 · 1"]
T1 --> S2["子任务 · 2"]
T1 --> S3["子任务 · 3"]
T2 --> S4["子任务 · 4"]
- 第一层:项目,一个根实体,有自己的一批标量字段(名称、负责人、预算……)
- 第二层:任务,挂在项目下,一对多
- 第三层:子任务,挂在任务下,一对多
再往下加"子任务的执行记录""执行记录的附件",就是四层、五层,结构完全同形。整套机制不应该因为多了一层就重写一遍。
为什么多层级数据难记
困难一:颗粒度无处安放
如果按"字段"记,一次保存改了 30 个字段就产生 30 条日志,日志列表会被同一次操作刷屏;如果按"表"记,又说不清到底改了哪几条记录。而且"新增一整条子任务"和"修改子任务的一个字段"是两种完全不同性质的变更,混在一起记,前后值的语义就乱了。
困难二:删掉的节点没法事后定位
子任务被删除后,业务表里已经没有它了。日志里如果只写"子任务表删除了一条记录",过一个月没人知道删的是哪一条、内容是什么。日志必须自带定位信息,不能依赖业务表反查。
困难三:新增/删除没有"字段级差异"
修改一条记录可以逐字段对比前后值;但新增和删除是整条记录的出现与消失,没有"字段 A 从 X 变成 Y"这种说法。所以明细的旧值/新值字段在不同动作下的语义必须分开定义。
困难四:层级之间没有稳定的对齐键
两个快照做差集,前提是能判断"新快照里的这条记录"和"旧快照里的这条记录"是不是同一条。数据库中已有主键的记录好办;本次新提交、尚未落库的记录没有主键,必须以另一种方式对齐,否则新增会被误判成修改。
设计思想
一次保存动作 = 一条日志主记录
页面上的"保存"是一个业务动作,日志的颗粒度就锚定在这个动作上。一次点击产生一条主记录,主记录里带"本次共变更 N 项"的计数。这样日志列表天然按操作排序,不会碎片化,明细则按需展开。
这条原则要求页面放弃"改一个字段提交一次"的模式,改为编辑期只在内存里改,点保存时一次性提交。这也是后文"差集"能够成立的前提。
明细记字段级差异,而不是记录级快照
明细行是差异,不是数据副本:
- 修改一条记录的两个字段 → 生成两行明细,各自带旧值和新值
- 新增/删除一条记录 → 生成一行明细,动作标记为新增/删除,旧值或新值放该条记录的字段摘要
这样明细表既能回答"哪个字段变了",也能回答"整条记录进来了/走了",且行数不会因为记录本身字段多而失控。
用基线快照 + 差集,而不是监听输入
有两种采集方式:
| 方式 | 做法 | 问题 |
|---|---|---|
| 监听式 | 每次输入/每次增删都往"待记数组"里塞一条 | 用户改错又改回去,噪声日志照记;多处代码都要记得塞,容易漏 |
| 快照差集式 | 保存前加载一次旧快照,保存时用新快照与它逐层比对 | 只在最终状态上算差异,天然免疫"改回原值" |
差集式的额外收益是代码只有一个采集点:无论是改字段、加子任务还是删子任务,都只是让新快照与旧快照不同,diff 自然会产出对应的明细,不需要在业务代码各处插入埋点。
这也解释了为什么它和事件溯源(Event Sourcing)同源——日志是"状态变化"的显式记录,只不过这里没有把状态变化当作唯一事实来源,业务表仍是权威数据,日志是它的旁路账本。
把 N 层结构抽象成一个可递归的节点视图
这是整套设计的核心。不要让 diff 算法去认识"项目""任务""子任务"这些具体表,而是要求每一层都能回答五个问题:
| 抽象 | 含义 |
|---|---|
nodeKey() |
机器对齐键:已落库记录用主键,新提交记录用客户端生成的临时 key |
nodeLabel() |
人读识别名:"管线测绘""第 3 标段" |
fields() |
本节点自身的标量字段:字段 key → { 中文名, 值 } |
children() |
本节点挂的子集合:集合名 → 节点列表 |
scopeName() |
该节点在日志里显示的操作范围:"任务""子任务" |
有了这个视图,同一段 diff 代码可以处理任意深度:比较根实体的字段 → 对每个子集合按 key 对齐 → 对齐上的递归进去 → 只在新快照里的判新增 → 只在旧快照里的判删除。加一层只是多一层递归,不需要改算法。
路径双轨:机器对齐用 nodeKey,人读用 nodeLabel
明细里放两个定位字段:
node_path:由各层nodeKey用/拼成,如1/17/5。它是机器语义的路径,用于唯一确定"哪个节点",稳定、不随名字变化。node_label:由各层nodeLabel用/拼成,如北区改造项目 / 管线测绘 / 第 3 标段。它是人读语义的路径,并且是在变更发生那一刻固化下来的,之后业务对象改名,这条历史日志仍然显示当时的名字。
两者都要,缺一个都会有问题:只有 label,同名节点无法区分;只有 path,展示时数字没法读。
只读字段、派生字段必须显式排除
编号、创建时间、系统生成的流水号、由其它字段计算出来的统计值,都不该进日志。做法是在字段视图里显式声明可编辑字段集合,diff 只在白名单内进行。反过来(黑名单排除)在字段不断新增时容易漏,不推荐。
改回原值不记日志
快照差集天然具备这个性质:如果用户最终把某字段改回了原值,两次快照在该字段上相等,差集里就没有它。这是"监听式"做不到的,也是日志可读性的关键。
数据模型
树的抽象
以例子为例,业务表是三张:
tb_project (根实体,1) id, name, owner, budget
tb_task (第二层,N) id, project_id, name, status, sort_no
tb_subtask (第三层,N) id, task_id, name, assignee, due_date
对应到节点视图:
flowchart LR
R["项目节点<br/>scope=项目<br/>level=1"] --> C1["任务集合<br/>scope=任务<br/>level=2"]
C1 --> C2["子任务集合<br/>scope=子任务<br/>level=3"]
三类变更的旧值/新值语义
这是明细表最容易含糊的地方,必须先把语义定死。
| 动作 | 生成条件 | old_value | new_value | record_key |
|---|---|---|---|---|
| 修改 MODIFY | 两边都有该节点,且字段值不等 | 该字段的旧值 | 该字段的新值 | 节点识别名 |
| 新增 ADD | 只有新快照里有该节点 | 空 | 该节点的字段摘要(JSON 或"字段:值;") | 节点识别名 |
| 删除 DELETE | 只有旧快照里有该节点 | 该节点的字段摘要 | 空 | 节点识别名 |
删除时新值为空但旧值保留,这一条是"删掉的东西还能被看见"的全部依据。
层级与路径
level 从 1 开始:1 表示根实体自身的字段变更,2 表示第二层集合上的变更,3 表示第三层,以此类推。它有两个用处:列表里可以按层级筛选、按层级折叠;也方便统计"这次保存一共动了几层"。
层级较高的节点,node_path 会包含它的祖先路径,例如 1/17/5 表示"根实体 1 → 其下第 17 个任务 → 该任务下第 5 个子任务"。所以即使这个子任务已被删除,从路径仍然能看出它属于谁。
数据库结构
主表:一次操作一条记录
CREATE TABLE tb_change_log (
id BIGSERIAL PRIMARY KEY,
biz_type VARCHAR(64) NOT NULL, -- 业务类型:PROJECT / MEMBER ...
root_id BIGINT NOT NULL, -- 根实体主键
root_key VARCHAR(255) NOT NULL, -- 根实体识别名(变更当刻固化)
item_count INT NOT NULL DEFAULT 0, -- 本次变更明细条数
action_summary VARCHAR(128), -- 动作摘要:"修改 5 · 新增 1 · 删除 2"
operator_id BIGINT,
operator_name VARCHAR(64) NOT NULL, -- 操作人(快照,不随账号改名而变)
operate_time TIMESTAMP NOT NULL, -- 操作时间(生产建议 TIMESTAMPTZ)
source VARCHAR(32), -- 来源:WEB / API / 批量导入
remark VARCHAR(512),
created_at TIMESTAMP NOT NULL DEFAULT now()
);
COMMENT ON TABLE tb_change_log IS '信息变更日志主表:一次保存动作 = 一条记录';
主表故意不存"改了哪些字段"。字段级细节全部下沉到明细表——这样主表行宽小、列表查询快,"变更项数"和"动作摘要"作为冗余列直接供列表展示。
root_id 之外的 root_key 是必要的冗余:日志列表展示时不必回查业务表,也不会因为业务对象改名而让历史日志"变形"。
明细表:一条变更一行
CREATE TABLE tb_change_log_item (
id BIGSERIAL PRIMARY KEY,
log_id BIGINT NOT NULL REFERENCES tb_change_log(id),
seq INT NOT NULL, -- 明细序号,从 1 开始
level SMALLINT NOT NULL, -- 层级:1 根实体字段 / 2 第二层 / 3 第三层
node_path VARCHAR(512) NOT NULL, -- 机器路径:1/17/5
node_label VARCHAR(512) NOT NULL, -- 人读路径:北区改造项目 / 管线测绘 / 第 3 标段
scope VARCHAR(64) NOT NULL, -- 操作范围:项目 / 任务 / 子任务
action VARCHAR(8) NOT NULL, -- MODIFY / ADD / DELETE
field_key VARCHAR(64), -- 字段机器名(新增/删除时为 NULL)
field_label VARCHAR(64), -- 字段中文名
record_key VARCHAR(255), -- 记录识别名
old_value TEXT,
new_value TEXT,
create_time TIMESTAMP NOT NULL DEFAULT now()
);
COMMENT ON TABLE tb_change_log_item IS '信息变更日志明细表:一条变更一行(字段级差异)';
CREATE INDEX idx_change_log_root ON tb_change_log (biz_type, root_id, operate_time DESC);
CREATE INDEX idx_change_log_time ON tb_change_log (operate_time DESC);
CREATE INDEX idx_change_item_log ON tb_change_log_item (log_id, seq);
字符串统一用 TEXT 存;如果用的是 MySQL,BIGSERIAL 换成 BIGINT AUTO_INCREMENT,TIMESTAMP 建议 DATETIME(3)。若希望前后值保留结构,PostgreSQL 可以把 old_value/new_value 换成 JSONB,代价是要读的人会看到 JSON,列表工具反而不好用——默认建议纯文本,需要结构化时在 ADD/DELETE 的摘要里用约定的分隔符即可。
三级以上怎么表达
三层、四层、五层共用同一张明细表,靠 level 与 node_path 区分:
| 场景 | level | node_path | scope |
|---|---|---|---|
| 项目名称由 A 改为 B | 1 | 1 |
项目 |
| 任务"管线测绘"的负责人改变 | 2 | 1/17 |
任务 |
| 子任务"第 3 标段"的截止日期改变 | 3 | 1/17/5 |
子任务 |
| 新增子任务"第 4 标段" | 3 | 1/17/9 |
子任务 |
| 删除任务"管线测绘"及其 3 个子任务 | 2 | 1/17 |
任务 |
最后一行说明一个重要的取舍:删除一棵子树时,只在被删的父节点上记一条删除明细,把整棵子树的快照压进 old_value,而不是为每个后代各记一条。理由是"删除"这个动作的业务语义本来就是"这棵子树没了",逐后代记录会让日志行数随子树规模爆炸,且明细里那些后代节点的 node_path 前缀又都是已被删除的父节点,读起来反而更乱。
代码实现
下面按真实的调用顺序走一遍,每一步都说明"为什么这一步必须在这里做"。
第一步:接口只接受"一棵完整的树"
保存接口不再接受"字段补丁",而是接受目标状态:根实体字段 + 全部子集合 + 全部孙集合。
public record SaveProjectRequest(Long projectId, String name, String owner, BigDecimal budget,
List<TaskPayload> tasks) {}
public record TaskPayload(String key, Long id, String name, String status,
List<SubtaskPayload> subtasks) {}
public record SubtaskPayload(String key, Long id, String name, String assignee, LocalDate dueDate) {}
关键在 key 与 id 并存:
- 已落库的记录
id有值,key可以等于String.valueOf(id) - 本次新加、尚未落库的记录
id为 null,key由前端生成一个临时串(UUID 或"新增-序号")
只有新旧快照用同一套 key 规则,差集才能正确对齐。 这条不做,新增记录就会被误判成"新树里有、旧树里没有",还算对;但一旦同一次提交里既有新增又有删除,且 id 都为 null,就会出现无法归属的歧义。所以临时 key 是必需的,不是优化。
第二步:保存前先加载"旧快照"
@Component
public class ProjectSnapshotLoader {
public NodeView load(Long projectId) {
Project p = projectMapper.selectById(projectId);
List<Task> tasks = taskMapper.selectByProjectId(projectId);
List<Subtask> subs = subtaskMapper.selectByProjectIds(tasks.stream().map(Task::getId).toList());
Map<Long, List<Subtask>> subMap = subs.stream().collect(groupingBy(Subtask::getTaskId));
// 组装成 NodeView 树:项目 → 任务 → 子任务
}
}
必须在事务内、写业务表之前读。顺序一旦颠倒(先写新数据再读旧数据),旧值就丢了——这是实践中最常见的一处硬伤。
第三步:把两层模型实现成同一个递归接口
/** 节点视图:任意一层只要提供这 5 个信息,diff 就能工作;加层不需要改算法 */
public interface NodeView {
String nodeKey(); // 机器对齐键
String nodeLabel(); // 人读识别名
Map<String, FieldValue> fields(); // 本节点标量字段
Map<String, ChildCollection> children(); // 子集合
String scopeName(); // 操作范围
}
public record FieldValue(String label, String value) {}
public interface ChildCollection {
String name(); // 中文集合名
List<NodeView> nodes();
}
各层各写一个适配器:ProjectNode implements NodeView、TaskNode implements NodeView、SubtaskNode implements NodeView,各自把实体字段映射成 FieldValue,把下一层封装成 ChildCollection。字段白名单就写在适配器里:
@Override
public Map<String, FieldValue> fields() {
Map<String, FieldValue> m = new LinkedHashMap<>();
m.put("name", new FieldValue("项目名称", name));
m.put("owner", new FieldValue("负责人", owner));
m.put("budget", new FieldValue("预算", plain(budget)));
// code / create_time / create_by 等系统字段不放入 → 天然不进日志
return m;
}
第四步:递归 diff —— 整套机制的算法核心
public final class TreeDiffer {
/** 递归比对两棵同形树,把差异收集到 out 里 */
public static void diff(NodeView oldNode, NodeView newNode,
String path, String labelPath, int level,
List<ChangeItem> out) {
// ① 本节点自身的字段差异(level = 当前层级)
Map<String, FieldValue> o = oldNode.fields();
for (Map.Entry<String, FieldValue> e : newNode.fields().entrySet()) {
FieldValue ov = o.get(e.getKey());
if (ov == null) continue; // 结构不一致,跳过
String nv = e.getValue().value(), ovv = ov.value();
if (!Objects.equals(nullToEmpty(ovv), nullToEmpty(nv))) {
out.add(ChangeItem.modify(level, path, labelPath, newNode.scopeName(),
e.getValue().label(), ovv, nv));
}
}
// ② 逐个子集合对齐
for (Map.Entry<String, ChildCollection> e : newNode.children().entrySet()) {
ChildCollection newColl = e.getValue();
ChildCollection oldColl = oldNode.children().get(e.getKey());
Map<String, NodeView> oldIndex = indexByKey(oldColl); // 旧集合按 key 建索引
for (NodeView child : newColl.nodes()) {
String childPath = join(path, child.nodeKey());
String childLabel = join(labelPath, child.nodeLabel());
NodeView oldChild = oldIndex.remove(child.nodeKey()); // 取走即标记"已配对"
if (oldChild == null) {
// 只在新快照里 → 新增(整条记录摘要进 new_value)
out.add(ChangeItem.add(level + 1, childPath, childLabel,
child.scopeName(), child.nodeLabel(), snapshot(child)));
} else {
// 两边都有 → 递归进下一层
diff(oldChild, child, childPath, childLabel, level + 1, out);
}
}
// 剩下的只存在于旧集合 → 删除(整条记录摘要进 old_value)
for (NodeView left : oldIndex.values()) {
out.add(ChangeItem.delete(level + 1, join(path, left.nodeKey()),
join(labelPath, left.nodeLabel()), left.scopeName(),
left.nodeLabel(), snapshot(left)));
}
}
}
}
几个必须讲清的点:
oldIndex.remove(key)是配对的关键。遍历新集合时把匹配到的旧节点从索引里摘掉,最后索引里剩下的就是"只在旧树里"的节点,直接判删除。不需要额外标记数组。- 递归的层级参数
level + 1:根实体字段变更记 level=1,进入任务集合的节点是 level=2,再进子任务是 level=3。层级由递归深度自然产生,不需要为每层写一遍逻辑。 - 顺序是先字段后子集合,所以同一条日志的明细天然是"由浅入深"的,展示时不用再排序。
snapshot()只序列化白名单字段,与fields()保持同一份清单,避免 ADD/DELETE 的摘要里冒出系统字段。
第五步:把差异翻译成明细行
public record ChangeItem(int level, String nodePath, String nodeLabel, String scope,
String action, String fieldKey, String fieldLabel,
String recordKey, String oldValue, String newValue) {
static ChangeItem modify(int lvl, String p, String lp, String scope, String fieldLabel,
String ov, String nv) {
return new ChangeItem(lvl, p, lp, scope, "MODIFY", null, fieldLabel, last(lp), ov, nv);
}
static ChangeItem add(int lvl, String p, String lp, String scope, String recordKey, String summary) {
return new ChangeItem(lvl, p, lp, scope, "ADD", null, null, recordKey, null, summary);
}
static ChangeItem delete(int lvl, String p, String lp, String scope, String recordKey, String summary) {
return new ChangeItem(lvl, p, lp, scope, "DELETE", null, null, recordKey, summary, null);
}
}
MODIFY 有 fieldLabel("截止日期"),ADD/DELETE 没有字段只有 recordKey("第 3 标段")与整条摘要——这就是"三类变更的旧值/新值语义"在代码上的落地。
第六步:一个事务里完成"写日志 + 写业务数据"
@Transactional(rollbackFor = Exception.class)
public void save(SaveProjectRequest req, Operator op) {
NodeView oldTree = snapshotLoader.load(req.projectId()); // ① 先取旧快照
NodeView newTree = assembler.toView(req); // ② 本次目标状态
List<ChangeItem> items = new ArrayList<>();
TreeDiffer.diff(oldTree, newTree, "", "", 1, items); // ③ 差集
if (items.isEmpty()) { // ④ 无变更直接返回
return; // (也可选择直接报错提示)
}
projectWriter.persist(newTree); // ⑤ 写业务表(含子表/孙表)
changeLogRecorder.record("PROJECT", req.projectId(), // ⑥ 写日志主表 + 明细表
newTree.nodeLabel(), op, items);
}
写日志放在业务落库之后、同一事务内:日志读的是内存里的新旧快照,不依赖刚落库的数据,所以我们完全可以把落库放前面;放在同一事务里的意义是要么都成功要么都回滚——业务数据改了而日志丢了,或反过来,都是审计上的硬伤。
public void record(String bizType, Long rootId, String rootKey, Operator op, List<ChangeItem> items) {
ChangeLog log = new ChangeLog();
log.setBizType(bizType);
log.setRootId(rootId);
log.setRootKey(rootKey);
log.setItemCount(items.size());
log.setActionSummary(summarize(items)); // "修改 5 · 新增 1 · 删除 2"
log.setOperatorId(op.id());
log.setOperatorName(op.name());
log.setOperateTime(LocalDateTime.now());
changeLogMapper.insert(log); // useGeneratedKeys 回填 id
int seq = 1;
for (ChangeItem it : items) {
changeLogItemMapper.insert(it.toRow(log.getId(), seq++));
}
}
明细一定是批量插入(MyBatis 用 <foreach> 拼一条 INSERT ... VALUES (...),(...)...)。一次保存几十条明细时,逐条 insert 会把事务拉长几十倍。
第七步:展示层——主表列表 + 明细展开
主表查询只要走主索引:
SELECT id, root_key, item_count, action_summary, operator_name, operate_time
FROM tb_change_log
WHERE biz_type = 'PROJECT' AND root_id = ?
ORDER BY operate_time DESC
LIMIT 20 OFFSET 0;
明细按 log_id 一次取回:
SELECT seq, level, scope, action, field_label, record_key, node_label, old_value, new_value
FROM tb_change_log_item
WHERE log_id = ?
ORDER BY seq;
列表页里 level 与 scope 不必直接暴露成列,而是用 node_label 呈现"落在哪里":
| 序号 | 操作范围 | 动作 | 变更项 | 变更前 | 变更后 |
|---|---|---|---|---|---|
| 1 | 项目 | 修改 | 项目名称 | 北区改造 | 北区改造(二期) |
| 2 | 任务 | 修改 | 负责人 | 张工 | 李工 |
| 3 | 子任务 | 新增 | — | (空) | 名称:第 4 标段;负责人:王工 |
| 4 | 任务 | 删除 | — | 名称:管线测绘;子任务 3 条 | (空) |
level 只在需要"按层折叠/筛选"时才拿到前台,例如按 level = 3 过滤出所有子任务级变更。
前端的一体化配合
后端差集要求"一次提交一棵完整的树",前端就要配合做到两点。
一、编辑期只改内存,不即时提交。 字段输入、行内编辑、行删除、新增行,全部写进内存对象;页面上只放一个"保存"。这样后端拿到的永远是"目标状态",diff 语义干净。
二、未落库的新行要带临时 key。 前端在新增行时生成 key(例如 tmp_ + 递增序号),随提交一起传给后端。后端在 diff 阶段用这个 key 对齐,落库时再把它换成真实主键——这一步很轻,但少了它,第三步讲的"新增与删除在同一次提交中的歧义"立刻出现。
如果希望前端也能即时展示"改了哪些项",可以在内存里再维护一个待记数组作为"提示",但权威日志仍由后端差集产出。前端那份只是给用户看的实时反馈,不参与记账,避免两套真相。
并发与一致性
差集必须基于同一个版本
两个人同时打开同一个项目,A 先保存、B 后保存。如果 B 的旧快照是 A 保存之前读的,B 的日志会把"A 的改动"也算进自己的变更里,或者干脆覆盖掉 A 的数据。
标准做法是给根实体加版本号:
ALTER TABLE tb_project ADD COLUMN version INT NOT NULL DEFAULT 0;
保存时 UPDATE tb_project SET ..., version = version + 1 WHERE id = ? AND version = ?,影响行数为 0 就抛"数据已被他人修改"。日志与业务数据在同一事务里,版本冲突时整体回滚,日志也随之不写——不会留下一条"实际没生效"的日志。
重命名会带来路径漂移
node_label 是变更当刻固化的。今天日志里是"管线测绘",明天这个任务改名为"管线测绘(复核)",历史日志仍然显示旧名。这是正确行为,不是 bug:审计要看的是"当时发生了什么"。但维护人员如果拿这些 label 去反查业务表,会查不到——所以展示层要说明"路径为变更时快照"。需要反查时用 node_path 里的主键段。
预留给审批流
如果业务将来要"变更先提交、审核后才生效",主表加一列 status(PENDING / APPROVED / REJECTED)即可:提交动作写一条 PENDING 日志,审核动作再写一条"状态变更"日志。整套明细结构不需要动——因为明细记的是"这次动作影响到了什么",与"是否已生效"是两件事。
与其它方案的对比
| 方案 | 能不能拿到业务语义 | 能不能拿到差异 | 结论 |
|---|---|---|---|
| 数据库触发器 | 不知道"是谁点的保存",只能拿到 SQL 级新旧行 | 能,但左连接子表非常难写 | 适合兜底防漏,不适合做主方案 |
| binlog / CDC | 只有 SQL 语义,无"一次保存"的概念 | 需要自己按事务归并,顺序与业务无关 | 适合数据同步,不适合业务审计 |
| 全量快照历史表 | 能 | 每次存整棵树,体积按"树大小 × 次数"增长 | 适合"要能还原任意时刻状态"的场景,代价是存储 |
| 主记录 + 明细差集(本文) | 能(操作人、时间、来源都是业务概念) | 能(字段级前后值) | 存储小、可读、可扩展层数 |
本文方案要额外说明的一点是:它不替你做"数据还原"。日志能告诉你"哪一步把预算从 80 万改成了 100 万",但不能一键回到昨天下午的整棵树。如果业务需要"任意时刻可还原",把全量快照作为补充(例如每天或每次重大节点存一份)与差集日志并存是常规组合。
常见坑清单
- 先写业务表再读旧快照 → 旧值全丢。顺序必须是"读旧快照 → diff → 写业务表 → 写日志"。
- 新增行没有临时 key → 同一次提交里新增与删除无法区分,出现"删了 3 条却记成新增 3 条"。
- 把系统字段放进
fields()→ 编号、创建时间、流水号污染日志。 NULL与空串不加归一 → 用户删掉内容,日志里显示"从 null 变成 ''",看起来什么都没变。- 删除整棵子树时逐后代记明细 → 明细行数爆炸,且路径前缀指向已删节点。改记一条父节点删除 + 子树快照。
- 明细逐条 insert → 事务时间随明细数线性增长,批量插入是必须的。
- 用
node_label反查业务表 → 改名后必然查不到,反查请用node_path。 - 日志没有主键索引 → 列表按
root_id + 时间查时必须建复合索引。 - 时间戳不带时区 → 跨时区部署后审计时间对不上,生产建议
TIMESTAMPTZ。 - 允许修改/删除日志表 → 审计数据应当只增不改,数据库权限上就把 UPDATE/DELETE 收掉。
落地清单
- [ ] 主表、明细表建好,索引建好(
(biz_type, root_id, operate_time DESC)、(log_id, seq)) - [ ] 根实体加
version字段,保存走乐观锁 - [ ] 每层实现
NodeView适配器,显式列出可编辑字段白名单 - [ ] 加载旧快照的时机放在写业务表之前,且在事务内
- [ ] diff 用一份代码覆盖所有层级,层级由递归深度产生
- [ ] 新增行的临时 key 贯通前后端
- [ ] 删除子树只记父节点一条 + 子树快照
- [ ] 明细批量插入,与业务落库同一事务
- [ ] 展示层:主列表一行一条日志,明细按需展开(操作范围 / 动作 / 变更项 / 变更前 / 变更后)
- [ ] 日志表回收 UPDATE/DELETE 权限
小结
把这套机制压缩成四句话:
- 颗粒度锚定在"一次保存",一次保存一条主记录,字段级差异全部下沉到明细表。
- 差异来自"旧快照与目标状态的差集",而不是监听每一次输入——顺带免掉"改回原值还记日志"这类噪声。
- 可扩展性来自把 N 层结构抽象成一个可递归的节点视图:diff 只依赖
nodeKey / nodeLabel / fields / children / scopeName五个信息,加一层只是多一层递归。 - 可读性来自路径双轨与三类动作的语义约定:
node_path给机器定位,node_label给人读,ADD/DELETE 用整条记录摘要把"进来/走了什么"留住。
评论 (0)
还没有评论,来抢沙发。