增量快照通过记录字段级变化(Delta)替代全量拷贝,显著降低内存占用;结合COW共享不变数据、字符串池压缩索引、后台delta合并等策略,在高频编辑场景中实现存储与回放效率的工程平衡。

用增量式快照替代全量拷贝
直接深拷贝整个对象树会迅速吃光内存,尤其在高频操作(如编辑器撤销/重做)中不可行。核心思路是只记录「变化」而非「状态」:每次操作生成一个 Delta 对象,描述字段级增删改,回放时按顺序应用即可还原任意历史点。
常见错误是把 std::vector<state></state> 当成快照容器——这本质是全量堆存,内存随操作数线性增长。正确做法是让快照本身轻量,例如:
struct TextDelta {
size_t pos;
std::string inserted;
size_t deleted_len;
};
std::vector<TextDelta> history; // 每个 Delta 通常几十字节
- 仅对可逆操作建模(如插入/删除/替换),避免无法 delta 化的操作(如随机内存覆盖)
- Delta 必须带足够上下文(如插入位置、被删长度),否则回放时无法定位
- 若原始数据结构支持快速 patch(如 rope 或 gap buffer),delta 应与之对齐,减少回放开销
用引用计数 + 写时复制(COW)共享不变数据
当某些子结构长期不变(如文档的只读元数据、资源引用表),反复拷贝纯属浪费。用 std::shared_ptr 管理这些块,快照仅持引用;只有修改发生时才触发 make_unique 分离副本。
典型陷阱是误用裸指针或手动管理引用计数,导致悬挂或泄漏。COW 必须满足两个条件:
立即学习“C++免费学习笔记(深入)”;
- 所有访问路径都通过
shared_ptr(包括快照和当前状态) - 写操作前必须检查
use_count() > 1,仅当有其他快照引用时才 clone - 注意多线程下
use_count()非原子,若需并发快照,改用std::atomic<size_t></size_t>自行维护或加锁
限制快照数量并自动合并旧 delta
无限累积 delta 同样导致内存失控。需要主动压缩:当历史条目超过阈值(如 200 条),将早期连续 delta 合并为一个「合成快照」,再丢弃原始 delta。
合并不是简单叠加——要执行实际回放,生成一个紧凑的中间状态,再从该状态重新推导新 delta 流。例如:
// 原始:[ins"a", ins"b", del(1,1)] → 实际等价于 ins"b" // 合并后只存 ins"b",体积减少 2/3
- 合并时机选在空闲期(如操作间隙或后台线程),避免卡主线程
- 合并策略需按数据类型定制:文本适合归一化,而场景图节点移动可能需保留相对偏移链
- 切勿在合并时修改正在使用的快照引用,应生成新
shared_ptr替换旧链
避免 std::string 和 std::vector 的隐式扩容开销
快照中大量小字符串(如属性名、命令名)若用 std::string 存储,每个实例至少 24 字节(小字符串优化阈值),且动态分配碎片化严重。更优解是字符串池(StringPool)+ 索引:
class StringPool {
std::vector<std::string> pool;
public:
uint16_t intern(const char* s) { /* 返回索引 */ }
};
struct CommandSnapshot { uint16_t cmd_id; /* 不是 string */ };
- 池内字符串只存一份,快照用 2 字节索引代替 24+ 字节对象
- 注意池生命周期必须长于所有引用它的快照,建议全局单例或与快照容器同生命周期
- 类似技巧适用于枚举 ID、颜色值、坐标偏移量等有限集数据,用
uint8_t或int16_t替代对象
真正难的是平衡回放速度和存储密度——过度压缩会让一次撤销变慢,而过度冗余又撑爆内存。关键参数(delta 合并阈值、池大小、COW 触发粒度)必须基于真实 trace 数据调优,不能凭感觉设。


















