你有没有遇到过这种情况:写了一个编辑器或者后台管理系统,用户一顿操作猛如虎,然后想“撤销”一下,结果程序只支持Ctrl+Z,而你的业务数据早就被改得乱七八糟,根本没办法还原?或者你在做游戏开发,辛辛苦苦搞了一整套角色的血量、背包、技能冷却状态,结果一死就要整个重开,玩家想回到上一个检查点都不行。
这类“把对象恢复到某个历史状态”的需求,是业务系统里非常常见的一类问题。专业一点的叫法,就是 Memento 备忘录模式。它是设计模式里的老牌行为型模式,核心价值就一句话:在不破坏封装的前提下,捕获一个对象的内部状态,并在对象外部保存这个状态,好让对象能在未来某个时间点恢复回去。
这篇文章我不打算抄书,而是直接拆开讲讲备忘录模式到底怎么设计、为什么这么设计、还有我在实际项目里踩过哪些坑。如果你是刚开始学设计模式的开发者,或者正在写需要做撤销、快照、回滚、存档功能的模块,这篇文章应该能帮你在十分钟内把思路理顺,并且拿到能直接改改就用的代码。
1. 备忘录模式到底解决什么问题
1.1 一段“危险”的撤销代码
在没有备忘录模式之前,很多人是这样实现撤销的:搞一个变量,把当前状态存起来,等用户说要撤销的时候,再把这个变量赋值回去。
go复制type Editor struct {
lines []string
}
func (e *Editor) snapshot() *Editor {
// 直接把整个对象引用存下来
return e
}
看起来没毛病,但你仔细想一下:snapshot() 返回的是同一个 Editor 实例的指针,不是一份独立副本。如果用户继续编辑,lines 被修改了,你当初存的那个“快照”,其实看到的新内容。等你去做恢复操作的时候,哪里是回到过去,分明是原地踏步。
这是新手非常容易踩的“引用 vs 副本”的坑。备忘录模式要解决的第一个问题,就是让你能够保存一份真正独立的、和当前操作互不干扰的状态切片。
1.2 备忘录模式的核心思想与三个角色
备忘录模式的标准类图里有三个角色,很多教程命名可能不太一样,但职责大同小异:
- 发起人(Originator):它是拥有内部状态的那个对象,也是真正需要被恢复的对象。它负责创建备忘录,也负责用备忘录把自己恢复回去。
- 备忘录(Memento):它是状态的“冷藏柜”。只保存发起人需要的那部分状态,对外部来讲,应该尽量不可修改。
- 负责人(Caretaker):它是管理备忘录的人。它只管把备忘录保存起来、在合适的时机取出来用,但不能也不应该去修改备忘录里的内容,相当于一个保管箱。
注意看这里的宾语:发起人负责“创建”和“恢复”,负责人负责“保存”和“提取”。这个分工是整个模式最精髓的地方。它把“谁有权限改状态”和“谁只负责存状态”给拆开了。这样一来,负责保存快照的历史列表,根本不需要知道业务对象内部是怎么组织的。
1.3 一句话判断要不要用它
什么时候应该上备忘录模式?我的判断标准很简单:
- 你确实需要“恢复历史状态”;
- 保存历史的地方和状态所在的业务对象,不应该互相侵入;
- 或者,历史记录的逻辑正在变得越来越复杂,已经影响到业务代码的可读性。
反过来,如果你的业务对象本身很简单,只有一两个 int 字段,或者你根本不关心历史状态,那完全没必要硬套。设计模式是解决问题的工具,不是 KPI,能不用就不用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手写一个能用起来的备忘录模式
2.1 基础版:文本编辑器的撤销与重做
我用 Go 语言写一个文本编辑器的例子,因为 Go 在封装性上和 Java 不太一样,反而更能体现这个模式的边界感。
定义业务对象 Editor,也就是发起人:
go复制package main
import "fmt"
// Editor 是发起人(Originator)
type Editor struct {
content string
cursorX int
cursorY int
muted bool
}
// NewEditor 构造一个新的编辑器
func NewEditor() *Editor {
return &Editor{}
}
// Write 向编辑器写入内容
func (e *Editor) Write(text string) {
e.content += text
}
// MoveCursor 移动光标
func (e *Editor) MoveCursor(x, y int) {
e.cursorX = x
e.cursorY = y
}
// SetMuted 设置编辑器状态
func (e *Editor) SetMuted(m bool) {
e.muted = m
}
// String 方便打印查看
func (e *Editor) String() string {
return fmt.Sprintf("Editor{content=%q, cursor=(%d,%d), muted=%v}",
e.content, e.cursorX, e.cursorY, e.muted)
}
接下来是增删快照的逻辑。注意,快照类型本身是一个独立的 Snapshot 结构体,它要记录 Editor 的所有需要恢复的字段:
go复制// Snapshot 是备忘录(Memento)
type Snapshot struct {
content string
cursorX int
cursorY int
muted bool
}
// CreateSnapshot 由发起人自己创建快照
// 这也是“封装不被破坏”的关键:只有 Editor 自己知道要存哪些字段
func (e *Editor) CreateSnapshot() Snapshot {
return Snapshot{
content: e.content,
cursorX: e.cursorX,
cursorY: e.cursorY,
muted: e.muted,
}
}
// RestoreFromSnapshot 从快照恢复
func (e *Editor) RestoreFromSnapshot(s Snapshot) {
e.content = s.content
e.cursorX = s.cursorX
e.cursorY = s.cursorY
e.muted = s.muted
}
注意上面 CreateSnapshot 和 RestoreFromSnapshot 都把 Snapshot 作为值类型返回或接收。这里的“值类型”很关键,后面我会专门讲。
然后是历史管理,也就是负责人 Caretaker:
go复制// History 是负责人(Caretaker)
type History struct {
snapshots []Snapshot
current int // 当前所处的位置,方便做撤销和重做
}
// Push 保存一个快照
func (h *History) Push(s Snapshot) {
// 如果之前已经撤销到了中间位置,再写入新快照时,要丢掉后面的记录
if h.current < len(h.snapshots)-1 {
h.snapshots = h.snapshots[:h.current+1]
}
h.snapshots = append(h.snapshots, s)
h.current = len(h.snapshots) - 1
}
// Undo 返回上一个快照
func (h *History) Undo() (Snapshot, bool) {
if h.current <= 0 {
return Snapshot{}, false
}
h.current--
return h.snapshots[h.current], true
}
// Redo 返回下一个快照
func (h *History) Redo() (Snapshot, bool) {
if h.current >= len(h.snapshots)-1 {
return Snapshot{}, false
}
h.current++
return h.snapshots[h.current], true
}
最后在 main 里跑一下:
go复制func main() {
editor := NewEditor()
history := &History{}
// 初始状态:什么都没有
history.Push(editor.CreateSnapshot())
editor.Write("你好")
editor.MoveCursor(2, 0)
history.Push(editor.CreateSnapshot())
editor.Write(",世界")
editor.MoveCursor(5, 0)
history.Push(editor.CreateSnapshot())
fmt.Println("当前:", editor.String())
if s, ok := history.Undo(); ok {
editor.RestoreFromSnapshot(s)
}
fmt.Println("撤销一次:", editor.String())
if s, ok := history.Undo(); ok {
editor.RestoreFromSnapshot(s)
}
fmt.Println("撤销两次:", editor.String())
if s, ok := history.Redo(); ok {
editor.RestoreFromSnapshot(s)
}
fmt.Println("重做一次:", editor.String())
}
输出结果应该是:
code复制当前: Editor{content="你好,世界", cursor=(5,0), muted=false}
撤销一次: Editor{content="你好", cursor=(2,0), muted=false}
撤销两次: Editor{content="", cursor=(0,0), muted=false}
重做一次: Editor{content="你好", cursor=(2,0), muted=false}
到这里,一个能用的撤销/重做功能就出来了。你发现没有,History 从头到尾没有碰过 Editor 的内部字段,它只是把不透明的 Snapshot 当成一个值存起来。这就把“存哪儿”和“怎么存”给解耦了。
2.2 关键设计:为什么 Memento 要不可变
很多教程会说“备忘录应该是不变对象(Immutable)”,但一句话带过了。我展开讲讲原因。
假设 Snapshot 不是一个值类型,而是一个包含切片、Map 甚至指针的结构体,并且这些字段是对外暴露的。那么历史列表里的 Snapshot 就可能被别人偷偷改了。这个“别人”可能是你自己项目的同事,也可能是未来的你。一旦快照内容被篡改,你恢复出来的状态就不是当时的真实状态了,而是被污染过的数据。
在 Java 里面,通常的做法是把 Memento 的字段设置为 private,只提供 getter,不提供 setter。在 Go 里面,最简单可靠的做法就是我上面写的,把 Snapshot 定义成普通结构体,字段使用小写(包内私有),在包外统一通过 CreateSnapshot 和 RestoreFromSnapshot 来操作。如果你的业务需要跨包传递快照,则可以把它定义在独立的 model 包中,但字段依然小写,只暴露必要的读取方法。
这里有个更“Go 风格”的进阶做法:把 Snapshot 定义成接口,对外只暴露一个空接口或只读接口,实现细节完全隐藏。
go复制type Snapshot interface {
// 空接口或只读方法
}
但在实际项目中,除非你的接口跨度很大,否则我不太建议为了抽象而抽象。先保证快照不可变,比搞一堆接口更有价值。
2.3 常见误区:保存引用和浅拷贝的坑
我见过非常多的“伪备忘录模式”,问题都出在拷贝的深浅上。
先看浅拷贝的问题。如果 Editor 内部有一个 []string 的切片字段,直接按下面这种方式保存快照:
go复制type Snapshot struct {
content []string
}
func (e *Editor) CreateSnapshot() Snapshot {
return Snapshot{content: e.content} // 只是复制了切片头,底层数组还是同一个
}
这个快照看似是独立的值,实际上 content 的底层数组和当前编辑器的 content 底层数组是同一个。用户追加一个元素,快照里的切片长度虽然不会变,但如果切片扩容了还好说,最怕的是原地修改已有元素,比如 data[0] = "改掉了",快照的值也会跟着变。
再往里一层,如果状态里有指针、Map、嵌套结构体,问题更严重:
- 指针字段:拷贝的是地址,指向的对象还是同一个,改一处全变;
- Map 字段:Go 的 map 本身就是引用类型,直接赋值仅仅复制了引用;
- 含有锁、连接池、文件句柄的资源对象:这种压根就不应该放进快照里,就算深拷贝也拷贝不了。
所以我的结论是:备忘录模式里,保存的状态结构越“扁平”越好。如果状态本身是普通字符串、数字、布尔值,这一个模式基本就用得非常舒服。如果状态里有大量引用类型,那你必须手写或者用第三方库做深拷贝,这会让代码复杂度暴增。下一节我会专门给出一套处理引用类型的方案。
3. 把备忘录放进真实项目:深拷贝、接口隔离与性能
3.1 深拷贝问题:比想象中难
真实项目里面,发起人的状态很少只有字符串和数字。常见的情况是有一堆复杂的配置对象、嵌套的模型结构。这时候要做快照,最重要的问题不是怎么存,而是怎么“深拷贝”。
我经常看到有人用 json.Marshal 再 Unmarshal 的方式来做深拷贝:
go复制func deepCopyByJSON[T any](src T) (T, error) {
data, err := json.Marshal(src)
if err != nil {
return src, err
}
var dst T
err = json.Unmarshal(data, &dst)
return dst, err
}
这种写法对简单结构来说很好用,但有几个坑:
- 如果结构体里有
time.Time,JSON 默认按字符串序列化,反序列化后时区、精度可能有偏差; - 如果字段里有不可导出的成员,比如
sync.Mutex、[]byte里的小写字段,JSON 反序列化会直接丢失这些字段; - 性能很差,一个几 KB 的状态快照反复序列化,高频操作时内存和 CPU 都会有明显开销;
- 结构体里有循环引用时会直接死循环。
如果一定要序列化,我更推荐 encoding/gob。它对 Go 原生类型支持更好一点,但依然无法完美处理小写字段和循环引用。
所以,如果你的状态模型很重,我建议你不要追求“通用深拷贝”,而是为发起人专门实现一个 clone() 方法。别觉得麻烦,这个方法其实就是把每个字段逐个复制,遇到切片就新建切片,遇到 map 就新建 map,遇到指针就 new 一个再赋值。
go复制type EditorState struct {
content string
cursorX int
cursorY int
muted bool
tags map[string]bool
}
func (s *EditorState) clone() *EditorState {
cloned := &EditorState{
content: s.content,
cursorX: s.cursorX,
cursorY: s.cursorY,
muted: s.muted,
}
// map 类型必须单独复制
if s.tags != nil {
cloned.tags = make(map[string]bool, len(s.tags))
for k, v := range s.tags {
cloned.tags[k] = v
}
}
return cloned
}
这种手写复制的缺点是不优雅、要多写代码,但优点是行为完全可控,不会有隐藏的深拷贝陷阱。我的经验是:当你的快照对象超过五个字段,或者有嵌套结构的时候,花十分钟手写 clone 比花一个小时排查隐藏 bug 划算得多。
3.2 用接口隔离阻止外部篡改
全书都在强调“封装”,但很多人把 Memento 的字段设成公开的,或者设成保护级别,结果外部依然能改。封装性一破,这个模式很容易变成摆设。
Java 中可以用包私有可见性,也就是把 Memento 类和 Originator 放在同一个包下面,外部用 Caretaker 只能持有 Memento 的引用,但访问不了内部字段。Go 里没有包私有这种关键字,比较常见的方案是:
- 把 Snapshot 字段小写,并放在和 Originator 同一个包内;
- 对外只暴露一个接口类型,比如
SnapshotReader,里面只有Content()之类的只读方法; - Caretaker 实际持有的是接口类型而不是具体结构体。
这样做的好处是:负责保存历史记录的模块完全不知道快照内部长什么样,更加不会出现“顺手改了一下快照”的骚操作。
3.3 快照方案与性能取舍:全量快照、增量快照、命令模式
备忘录模式用久了你会发现,它最大的敌人是内存。
每来一次操作就保存一个全量快照,数据一多,内存就爆了。比如一个文档编辑器,每敲一个字母都保存一份全量文本,那一个一万字的文档很快就能吃掉几十 MB。
针对不同场景,我建议按下表做取舍:
| 场景 | 快照方式 | 说明 |
|---|---|---|
| 配置对象的变化 | 全量快照 | 配置对象一般不大,全量快照最简单可靠 |
| 文本编辑器 | 增量快照(diff) | 只保存两次状态之间的变化,恢复时逐个应用 |
| 游戏存档 | 全量快照 + 手动存档点 | 存档频率低,全量存档最安全 |
| 数据库事务 | 回滚日志 | 记录反向操作,而不是保存整个数据库 |
| 长时间运行的服务状态 | 定时快照 + 日志重放 | 先定期存全量,再靠日志重放解决中间部分 |
这里要特别提醒一点:不要拿着全量快照套在所有场景里。如果你的快照很大、操作很频繁,可以考虑把“备忘录模式”和“命令模式”结合,用命令对象保存“做了什么事情”,撤销的时候执行反向操作。从效果看,它也是恢复到历史状态,但内存开销会小很多。
4. 真实应用场景拆解:游戏存档、事务回滚、状态审计
4.1 游戏存档与自动回溯
游戏开发可能是备忘录模式最经典的展示场景。玩家角色有等级、血量、背包、技能冷却,BOSS 战前存一个档,死了以后读档重来。
这里需要注意的是,游戏对象往往很大,背包系统里有成百上千个物品对象,而且物品之间还有引用关系。如果每一帧都全量存档,性能根本扛不住。所以实际项目中一般只在特定时间点,比如进入新地图、打 BOSS 前、玩家手动存档时,才调用一次 CreateSnapshot()。而且快照保存的不一定是全部状态,可能只是血量、位置、关键任务进度那些“核心可回溯数据”。
4.2 数据库事务与回滚日志
数据库事务的回滚其实也可以理解为备忘录模式的一种延伸。事务里每一步操作都会写 undo 日志,等到需要回滚的时候,就按逆序执行这些 undo 日志。和全量快照不一样,它保存的是“操作”,不是“状态”。这种方式在工程上叫“回滚日志”,比全量快照更省空间。
如果你在设计一个组件,需要让底层数据支持事务回滚,可以考虑把“引起状态变化的行为”包装成命令,然后在回滚时执行逆操作。这种做法本质上和备忘录模式的职责分工是一致的:发起人(业务对象)负责执行命令,负责人(事务管理器)负责保存回滚记录。
4.3 不同场景下的模式选型对比
| 应用场景 | 推荐方案 | 主要原因 |
|---|---|---|
| 编辑器撤销/重做 | 全量快照或增量快照 | 状态相对简单、频率可控 |
| 游戏存档 | 全量快照,手动触发 | 存档点少、结构复杂,可靠性优先 |
| 数据库事务 | 回滚日志/命令逆操作 | 状态巨大,无法全量复制 |
| 配置热更新 | 全量快照 + 基线版本 | 配置体积小,需要精确还原 |
| 分布式任务调度 | 定时快照 + 操作日志重放 | 长期运行的任务需要跨时间点恢复 |
另外,在一些大型后台系统里,备忘录模式还能用来做操作审计。每次用户修改关键数据之前,先保存一个快照,后面如果发现是误操作或者权限问题,可以快速回溯。这种场景下的快照往往不需要很频繁,一天存几次,放在数据库里或者对象存储里都行。
5. 常见问题与排查实录
5.1 快照恢复后引用对象不一致
我遇到过不止一次:项目里做了快照,恢复的时候发现,恢复出来的对象和外部其他地方持有的对象引用不同步。
举个例子,编辑器里有一个外部工具栏组件,它一直持有 Editor 的指针。你调用 RestoreFromSnapshot 把 Editor 的内部字段还原了,这时候工具栏其实还能正常工作,因为指针没变。但如果你用“替换对象”的方式做恢复,比如直接让 Editor 变量指向一个新的 Editor 实例,那外部持有的旧指针就完全失效了。
正确的做法是:恢复时不要替换对象,而是把快照里面的字段复制回原对象。这也是我在前面代码里用 RestoreFromSnapshot(s Snapshot) 而不是返回一个新对象的原因。
5.2 深拷贝失败导致状态污染
这种问题隐蔽性很强。比如你有一个嵌套的结构体,里面套了一个指针,你用浅拷贝保存了快照。平时改的是结构体值,一切正常。突然有一天你改了那个指针指向的字段,然后点撤销,结果发现撤销之后数据还是被改过的。
排查思路是:在保存快照的地方打印一份状态,在恢复之前再打印一份状态,然后对比可疑字段的地址。如果发现地址相同,那大概率就是浅拷贝导致共享了底层对象。
5.3 撤销栈无限增长导致内存溢出
撤销功能上线以后,内存占用一天比一天高,最后 OOM。这基本就是历史的快照只增不减。
解决方案通常有三层:
- 限制历史栈的容量,比如最多保存 50 步,超过就丢弃最旧的快照;
- 对快照做压缩,比如只保存差异;
- 使用弱引用,让长时间不用的快照可以被 GC 回收,但这个在 Go 里不太好实现,一般用前两种方案。
5.4 多协程下快照并发访问
如果你的程序里多个 goroutine 同时读写同一个发起人对象,那快照本身也会变成竞争点。很多人忽略了这一点。
解决思路是:发起人的 CreateSnapshot 和 RestoreFromSnapshot 方法内部要加锁,把状态读取放进临界区;同时 History 的 Push/Undo/Redo 操作也需要一把锁保护,否则两个 goroutine 同时撤销会乱套。
go复制type Editor struct {
mu sync.RWMutex
content string
}
func (e *Editor) CreateSnapshot() Snapshot {
e.mu.RLock()
defer e.mu.RUnlock()
// 开始复制状态
...
}
我一般会把锁直接放在业务对象里,因为它本身就是唯一的写者入口,加锁的开销在绝大多数业务场景里可以忽略。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查点 |
|---|---|---|
| 撤销无效或者数据没变 | 保存了引用而不是副本 | 检查快照内部是否复制了底层数组 |
| 恢复后外部对象失效 | 恢复时替换了对象实例 | 改为拷贝字段回原对象 |
| 快照被意外修改 | Memento 字段开放了 setter | 把字段私有化,只提供只读接口 |
| 内存持续增长 | 历史栈无容量限制 | 增加最大步数,或改成增量快照 |
| 并发调用 Undo 报错 | 历史栈没有加锁 | 给 History 加 mutex |
| 序列化丢字段 | JSON 无法处理小写字段 | 改用 gob,或者手写 clone 方法 |
你在实际项目中碰到的问题,八成都能在表里找到影子。如果碰到一个对不上号的,建议先把快照内容完整打印出来,看看到底是哪一层引用没复制干净。
备忘录模式看起来简单,但真正做到“能用、好用、不炸”还是有很多细节的。我自己每次写这种需要支持撤销或者回溯的功能时,都会先问三个问题:快照要保存多深?历史栈要留多长?恢复的时候是原地还原还是替换对象?把这三个问题想清楚了,代码写起来基本就不会跑偏。
最后再分享一个我个人的小习惯:给快照结构体加一个 version 字段。虽然是土办法,但能在状态结构升级的时候帮你快速识别“这是哪一代快照”,避免旧版本快照恢复出诡异数据。这个字段几乎零成本,回报却很大,推荐你也试试。
