我们平时写业务代码,经常遇到"用户点了撤销,数据怎么还原"这种需求。最直觉的做法,就是在操作前把对象当场存个副本塞进列表里,等要回退的时候再取出来覆盖回去。这个做法本身没问题,但真正落地的时候,各种细节问题就全冒出来了:对象是引用类型,直接塞进列表的只是地址,等你要用的时候,数据早被改得面目全非了;如果你用序列化做深拷贝,又会遇到字段增删导致反序列化失败、性能开销大、循环引用直接栈溢出这些情况。备忘录模式(Memento Pattern)就是专门解决这一类"对象状态保存与恢复"问题的设计模式,它把状态快照的职责从业务对象里剥离出来,让保存、恢复这些操作不再和业务逻辑纠缠在一起。
这篇文章我打算结合自己项目里的实战经历,把备忘录模式从原理到落地一次讲透,包括它到底解决什么痛点、三个核心角色怎么设计、实现的时候有哪些绕不开的深坑,以及和命令模式、序列化这些常见方案的对比。不管你是刚接触设计模式的初学者,还是已经在项目里写过不少业务代码的开发者,看完这篇应该都能直接拿来用。
1. 一个看似简单却翻过车的需求:订单编辑的恢复功能
我先说一个真实的踩坑经历。去年做一个后台管理系统,运营同学编辑订单的时候手一抖把金额填错了,填错也就算了,关键是那个表单没有保存前校验,直接提交了。主管当场脸黑,说必须加一个"撤销到上次保存状态"的功能,最好能多点几步,支持撤销多次。
我第一反应是,这不简单吗?我在提交前偷偷把整个订单对象存一份,放进一个 List,用户点撤销的时候 pop 出来塞回去就行了。于是稀里哗啦写完了:
java复制public class Order {
private Long id;
private String customerName;
private BigDecimal amount;
private Integer status;
// getter/setter 省略
}
操作的时候直接:
java复制history.add(order);
order.setAmount(new BigDecimal("9999"));
然后测试的时候就露馅了,而且露馅方式特别尴尬。我改了 order 的 amount,回头一打印 history 里存的所有对象,发现它们的 amount 全变成了 9999。为什么?因为 Java 对象是引用类型,history.add(order) 存进去的是同一个对象的地址,不是拷贝。你后面一改,之前存的那些"历史状态"也跟着变了,等于撤销功能形同虚设。
后来我换了序列化深拷贝的方案,把订单对象转成 JSON 字符串或者字节流存起来,以后恢复的时候再反序列化回来。这招初期确实管用,但问题接踵而至。订单对象里嵌套的商品快照是个包含泛型集合的复杂结构,反序列化的时候遇到类型擦除,集合里的子类对象全被还原成了父类类型,字段直接丢失;还有一次更狠,我在商品类里加了一个双向关联的引用,序列化的时候直接栈溢出。那段时间我真的是被这个"简单的功能"折磨得够呛。
现在回头看,这个需求本质上就是典型的备忘录模式应用场景。它的核心诉求很明确:在不破坏对象封装性的前提下,把对象在某个时刻的内部状态完整地保存下来,并且能在未来的某个时间点精确地恢复到那个状态。如果当时我先想清楚状态保存的边界、快照的存储方式、回退时对象的重建策略,后面那些坑至少能少踩一半。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 备忘录模式的本质:三个角色,各管一段
备忘录模式的定义很简洁:在不破坏封装的前提下,捕获并外部化一个对象的内部状态,以便之后可以将该对象恢复到该状态。它的结构也简单,就三个角色:发起人(Originator)、备忘录(Memento)和负责人(Caretaker)。
2.1 发起人:唯一有权存取状态的角色
发起人就是那个状态需要被保存的业务对象,比如我前面例子里的 Order。在备忘录模式里,发起人承担两个职责:生成备忘录,以及从备忘录恢复状态。
很多人第一次学这个模式的时候会有个疑问:生成备忘录和恢复状态,这不就是两个方法的事吗,为什么要单独抽象一个模式出来?关键在于"封装"这两个字。如果 Order 类把状态字段全部交给外部去存储,那外部就必须知道 Order 内部有哪些字段、哪些要存哪些不用存,一旦 Order 的结构变了,所有依赖它的存储代码都要跟着改。而备忘录模式把这两件事都收归 Order 自己处理,外部只负责保管备忘录对象,不需要关心里面的内容。
我整理了一张职责表,清晰区分这三个角色:
| 角色 | 职责 | 关键原则 |
|---|---|---|
| 发起人(Originator) | 创建备忘录、从备忘录恢复状态 | 只有自己能读写自己的内部状态 |
| 备忘录(Memento) | 保存发起人的状态快照 | 不暴露内部细节,外部拿不到修改入口 |
| 负责人(Caretaker) | 持有并管理备忘录对象的生命周期 | 只存不读,永远不知道备忘录里装了什么 |
2.2 备忘录:一份"只能读不能改"的存档
备忘录这个角色的核心设计点在于信息隐藏。它的理想状态是:发起人能往里写数据,也能从里面读数据,但负责人拿到的只是一份"存档文件",不能去修改或解析里面的内容。
在实际的编程语言里,这个理想状态有不同的落地方式。Java 里可以用嵌套类配合访问权限控制,让负责人无法访问备忘录内部字段;C++ 可以用 friend 声明;Python 这种没有强制访问控制的语言,就只能靠命名约定和程序员自觉了。我见过很多初学者写备忘录模式的时候,直接定义一个 Memento 类,里面一个字段对应发起人一个字段,然后 getter/setter 全部 public,负责人可以随意改动。这其实是把模式写歪了,备忘录的封装性一旦破了,状态被谁改的你都不知道,后面排查起来比 bug 本身还头疼。
2.3 负责人:只管保管,别多管闲事
负责人是备忘录的仓库管理员。它负责存放备忘录、维护历史记录链表、在需要的时候把对应版本的备忘录交给发起人。
有一个常见的误解是:负责人应该负责管理"什么时候该存快照"、"什么时候该恢复"这些业务逻辑。如果业务上确实有这样的集中管理和路由需求,那当然可以加,但那已经是更高层的业务逻辑设计了,不属于备忘录模式本身的核心职责。备忘录模式管的是"状态怎么存、怎么恢复",至于"什么时候存、什么时候恢复",是业务层的事情。
3. 从零手写一个可撤销多次的实战示例
空谈理论没意思,直接上一个我实际项目里改良后的完整实现。我要做的功能是:订单编辑页,支持任意次数的撤销和重做(undo/redo)。
3.1 基础版本的实现
先定义发起人,也就是订单类。这个类里我故意不放任何业务方法,只聚焦状态保存和恢复:
java复制/**
* 发起人:订单业务对象
* 负责生成自己的快照,以及从快照恢复
*/
public class Order {
private Long id;
private String customerName;
private BigDecimal amount;
private OrderStatus status;
private List<OrderItem> items;
/**
* 保存当前状态,生成备忘录
*/
public OrderMemento save() {
// 注意:这里必须做深拷贝,否则备忘录里存的还是引用
return new OrderMemento(this.id, this.customerName, this.amount,
this.status, deepCopyItems(this.items));
}
/**
* 从备忘录恢复状态
*/
public void restore(OrderMemento memento) {
if (memento == null) {
throw new IllegalArgumentException("memento cannot be null");
}
this.id = memento.getId();
this.customerName = memento.getCustomerName();
this.amount = memento.getAmount();
this.status = memento.getStatus();
this.items = deepCopyItems(memento.getItems());
}
private List<OrderItem> deepCopyItems(List<OrderItem> source) {
if (source == null) {
return new ArrayList<>();
}
return source.stream()
.map(item -> new OrderItem(item.getProductName(), item.getQuantity(), item.getPrice()))
.collect(Collectors.toList());
}
// 各种 getter/setter 省略
}
然后是备忘录类,这是状态存储的核心。为了让负责人不能破坏备忘录内容,我采用了一个设计技巧:构造函数包级私有,字段没有 public setter,外部任何包都无法直接修改:
java复制/**
* 备忘录:订单状态的不可变快照
*/
public class OrderMemento {
private final Long id;
private final String customerName;
private final BigDecimal amount;
private final OrderStatus status;
private final List<OrderItem> items;
// 构造函数包级私有,外部无法直接 new
OrderMemento(Long id, String customerName, BigDecimal amount,
OrderStatus status, List<OrderItem> items) {
this.id = id;
this.customerName = customerName;
this.amount = amount;
this.status = status;
this.items = items;
}
public Long getId() {
return id;
}
public String getCustomerName() {
return customerName;
}
public BigDecimal getAmount() {
return amount;
}
public OrderStatus getStatus() {
return status;
}
public List<OrderItem> getItems() {
return items;
}
}
这里有个细节我要强调一下:备忘录里的 items 字段虽然用了 final 修饰,但 final 只保证引用不能变,不保证列表内容不能被修改。所以在构造函数里我保留了传入列表的引用,但外部如果拿到这个引用,依然可以执行 getItems().add(new OrderItem(...))。要彻底安全,构造函数里应该再套一层不可变包装:Collections.unmodifiableList(...)。这个细节很多实战文章不提,但真出了问题就是在这种不起眼的地方。
最后是负责人,承载 undo/redo 的核心逻辑:
java复制/**
* 负责人:管理备忘录历史,支持撤销和重做
*/
public class OrderHistory {
private final Order order; // 关联的发起人
private final Deque<OrderMemento> undoStack = new ArrayDeque<>();
private final Deque<OrderMemento> redoStack = new ArrayDeque<>();
private static final int MAX_HISTORY_SIZE = 20;
public OrderHistory(Order order) {
this.order = order;
}
/**
* 每次修改前调用,把旧状态压入撤销栈
*/
public void snapshot() {
undoStack.push(order.save());
// 一旦有新快照,清空重做栈
redoStack.clear();
// 超出历史上限,丢弃最老的快照
while (undoStack.size() > MAX_HISTORY_SIZE) {
undoStack.removeLast();
}
}
public boolean canUndo() {
return !undoStack.isEmpty();
}
public boolean canRedo() {
return !redoStack.isEmpty();
}
public void undo() {
if (!canUndo()) {
throw new IllegalStateException("no more undo history");
}
// 当前状态进入重做栈
redoStack.push(order.save());
// 从撤销栈弹出并恢复
order.restore(undoStack.pop());
}
public void redo() {
if (!canRedo()) {
throw new IllegalStateException("no more redo history");
}
// 当前状态进入撤销栈
undoStack.push(order.save());
// 从重做栈弹出并恢复
order.restore(redoStack.pop());
}
}
这段实现里有几个关键决策值得说明一下。我用的是"修改前快照"策略:每次状态要变之前,先把旧状态存进撤销栈。这样用户一旦发现改错了,undo 就能恢复成上一版。重做栈的实现逻辑是,每执行一次 undo,先把当前状态压入 redo 栈,再从 undo 栈弹出恢复;redo 则是逆操作。另外,新操作一旦发生,redo 历史就全部作废,我把 redoStack.clear() 放在了 snapshot() 方法里,确保逻辑一致。
3.2 使用时要注意的细节
这个方案跑通后的体验明显好了很多,但还是有几个容易踩的坑:
第一,快照的时机必须保证在线程安全的边界内。如果是多线程共享同一个 Order,snapshot() 和 order.setAmount(...) 之间没有加锁,就会出现状态和快照不一致的情况。我当时把 Order 的更新操作收敛到了一个 service 方法里,在这个方法入口统一加锁,才算彻底解决。
第二,MAX_HISTORY_SIZE 这个上限我之前没设,测试的时候按了二十多次撤销,结果内存里堆了二十多个订单快照。如果每个快照里嵌套的商品列表还有图片 URL、物流信息,那内存增长是很快的。上限设 20 步在大多数管理后台场景里足够了,用户也不太可能连续撤销那么多步。
第三,备忘录里的 items 列表我用的是浅层手动拷贝,只复制了一层。如果 OrderItem 里面还嵌套了更深的对象,比如采购商信息、供应商报价,那这种手工拷贝就不够用了。建议的做法是:临时对这段嵌套层使用序列化进行深拷贝,或者改用 Cloneable 配合递归克隆工具。在实际项目里,我用的是项目自带的 Jackson 序列化库做深拷贝,但要注意它无法处理循环引用,所以我的业务对象里关联关系都是单向的。
4. 备忘录模式最常见的两个变种:白箱与黑箱
设计模式在具体语言里落地的时候,经常会因为语言特性诞生一些变种,备忘录模式也不例外。最经典的分类是白箱封装和黑箱封装。
4.1 白箱封装:简单直观,但有隐患
白箱封装就是备忘录类的所有字段都对外可见,负责人类可以随时访问和修改备忘录的内部数据。这是初学阶段最容易理解的版本,但配套的问题也最明显:负责人在修改时可能不小心破坏了某个历史状态,而后续的恢复操作就用到了这个被篡改的数据,最后表现出来的 bug 往往非常隐蔽。
我自己的建议是:白箱封装只适合在纯学习环境里用,或者用在状态不敏感的内部工具类里。如果你的业务要求历史记录绝对不能变,就别用白箱。
4.2 黑箱封装:用嵌套类实现信息隐藏
黑箱封装更符合备忘录模式的原始思想——负责人类几乎拿不到任何可操作的方法,它只能拿到一个"签名",然后把它原封不动地传给发起人。
Java 里实现黑箱封装的标准做法,是把备忘录类定义为发起人的内部类,并把备忘录的成员全部设为 private。Java 的内部类机制保证了外部类即使获取到内部类的实例,也无法通过反射以外的方式访问其私有成员。下面是这个方案的骨架:
java复制/**
* 发起人:订单业务对象(黑箱版本)
*/
public class Order {
private Long id;
private String customerName;
private BigDecimal amount;
// 对外暴露的统一接口
public interface OrderMemento {
}
// 内部状态快照,仅 Order 类内部可访问其私有字段
private class Snapshot implements OrderMemento {
private final Long snapshotId;
private final String snapshotCustomerName;
private final BigDecimal snapshotAmount;
private Snapshot(Long id, String name, BigDecimal amount) {
this.snapshotId = id;
this.snapshotCustomerName = name;
this.snapshotAmount = amount;
}
}
public OrderMemento save() {
return new Snapshot(this.id, this.customerName, this.amount);
}
public void restore(OrderMemento memento) {
Snapshot snapshot = (Snapshot) memento;
this.id = snapshot.snapshotId;
this.customerName = snapshot.snapshotCustomerName;
this.amount = snapshot.snapshotAmount;
}
}
负责人类拿到的是 OrderMemento 接口,但接口里一个方法都没有。就算想用强转去访问实现类,也几乎无从下手,因为实现类是 private 的,外部类根本不知道它的存在。这就是黑箱封装的核心价值:保证备忘录不可篡改,同时让 save() 和 restore() 的调用方式保持统一。
黑箱封装有个小缺点,就是使用反射或者调试工具时候,你会看到一层嵌套类结构,阅读性稍微差一点。但在业务系统里,安全性和封装性显然比调试便利更重要。
5. 实战中的选型:备忘录和这几个方案怎么取舍
备忘录模式并不是"保存历史状态"的唯一解。很多场景下,别的设计模式或者技术方案表现更好。我把自己尝试过的几种方案整理成一张表,方便大家按场景做决策:
| 解决方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 备忘录模式 | 单一对象的多步撤销/重做,且状态字段较多 | 状态存取封装完善,调用方无感知;结构清晰 | 状态大时存快照开销高;需要自己维护历史栈 |
| 命令模式 | 操作的粒度明确,需要同时支持撤销/重做和操作日志审计 | 天然支持 undo/redo,操作和状态解耦 | 每个操作都要写一个类,类数量多;单纯状态恢复不是它擅长的 |
| 对象序列化/快照轮询 | 高频自动存档、紧急恢复场景,不需要用户手动操作 | 简单粗暴,用现成库即可 | 深度拷贝和序列化性能开销大;循环引用/类型擦除容易踩坑 |
| 事件溯源(Event Sourcing) | 需要完整审计日志、可以还原任意历史时间点状态的业务系统 | 天然支持时间旅行恢复;日志即真相 | 实现复杂度高;查询复杂数据需要配合 CQRS 使用 |
5.1 备忘录和命令模式的取舍
命令模式的核心是把一个操作本身封装成一个对象,持有执行和撤销两个方法。如果你在做的是"把金额改成 X"、"修改收货地址"这类操作,命令模式更贴合语义,因为撤销时你不需要保存整个对象快照,只需要知道"我要把金额改回上一个值"就行,开销小,语义也清晰。
但命令模式有一个代价:每个操作都要写一个类,操作多了会带来大量类文件。并且,如果操作的撤销逻辑非常复杂,牵扯到多个对象、多个字段联动,命令类的撤销代码会越写越臃肿。而备忘录模式等于是一个"免维护撤销实现"的思路——不管你对对象做了什么修改,撤销的时候我不管过程,直接把整个对象恢复成旧状态。在状态恢复这个场景里,备忘录是"更省心"的选项。
5.2 序列化快照方案的坑
说到序列化,我必须展开聊聊。很多项目会用 JSON 或者 Java 原生序列化做深拷贝快照,因为它确实很省事,三行代码就能拿到一个深拷贝对象。但实际项目里,这种方案有三个很让人头疼的坑:
第一是类型擦除问题。订单里的商品列表 List<OrderItem>,JSON 反序列化的时候很容易被还原成 List<LinkedHashMap>,你要是直接用,反射的时候会报 ClassCastException。解决的办法是依靠带泛型参数的 TypeReference,但问题在于:你引入了一个看似无害的工具方法,调用的时候忘记传 TypeReference,就静默埋雷。
第二是循环引用问题。业务对象如果出现了 A 引用 B、B 引用 A 的情况,JSON 序列化第一个版本会直接 StackOverflowError,后面版本配置了循环引用检测,但又没办法保证每个字段都完整还原。对象嵌套深的时候,这个坑尤其致命。
第三是性能问题。虽然做历史快照通常不是高频操作,但如果一个订单对象里有几十个商品,每个商品下面还有几层嵌套结构,每存一次快照就要序列化一次全对象树,再反序列化一次全对象树,这个开销其实不小。高并发场景下,如果是秒杀订单这种被频繁读取、偶尔编辑的数据,序列化快照方案很容易把 CPU 打满。
5.3 什么时候该直接上事件溯源
我还见过一些团队把 "备忘录模式" 和 "保存变更记录" 混为一谈,做成了事件表。从数据管理角度讲,这种场景其实更适合做"记录操作日志 + 重放事件",而不是保存状态快照。如果运营需要看到"这个订单从创建到现在每一步被谁改过、改了什么",备忘录模式就解决不了问题了,因为你只存了状态快照,没有存"状态是怎么一步步变过来的"。
这种情况下,正确的做法是上事件溯源:把所有变更事件追加到事件流里,要还原某个历史状态,就重放事件流。这是完全不同的一套架构决策,理解成本和实现复杂度都高得多,但它带来的审计能力和时间旅行能力也是备忘录模式给不了的。我的建议是:中小型业务、状态字段不多、撤销历史只保留十几步,用备忘录模式足够;大型系统、有强审计需求、需要完整追溯,那应该考虑事件溯源而不是硬憋备忘录。
6. 如何把备忘录模式落到真实项目的代码规范里
最后一个部分,聊点落地的经验。备忘录模式本身不难,难的是怎么保证项目里的同事不在使用的时候把它写歪。我总结了几条在代码评审里会重点关注的规范。
6.1 单例合并:一个对象一个历史栈
我见过最离谱的设计,是给每个订单配了一个 OrderHistoryManager,然后这个 manager 还做成单例,多个订单的撤销栈全塞在同一个类里,用 Map 区分。后来并发操作两个订单,撤销栈的 push/pop 直接互相覆盖,订单 A 的撤销把订单 B 的状态给恢复了。
正确的做法是:一个发起人对象对应一个历史栈实例,历史栈的生命周期跟随业务会话,不要做成全局单例。如果你确实需要全局管理多个对象的撤销,那也要在每个对象实例上用独立的历史记录,或者在内存里做隔离,不要混在一个集里。
6.2 快照的时机:宁可多存,不可漏存
快照的时机要和业务"修改"绑定,但很多团队图省事,直接在 setAmout() 这种 setter 里触发 snapshot()。这样做的后果是:如果一次业务操作里连续调用了几个 setter,比如 setAmount()、setStatus()、setCustomerName(),就会产生三份快照,用户按一下撤销,只回退一个字段,体验非常差。
正确做法是把"快照"的触发点放到业务方法的入口或出口,而不是单个 setter 里。也就是说,一个完整的业务操作,无论内部调用了多少次 setter,整体上只产生一份快照。这样撤销的粒度才是"一次业务操作",而不是"一次字段变更"。
6.3 历史记录的容量管理:不要无上限
前面我提到了 MAX_HISTORY_SIZE,这个太关键了,单独拉出来再说一次。很多项目上线初期没人管,历史记录无限增长,运行了几个月后,用户打开一个复杂订单,内存里攒了上千个快照对象,直接 OutOfMemoryError。
具体容量设多少,取决于你保存快照的对象有多大。一个几十个字段的小对象,留 50 步问题不大;一个带图片列表和关联子对象的复杂聚合根,建议限制在 10 步以内。如果业务上需要更多步骤,可以考虑把快照落库或者落盘,但那就是另一个存储话题了。
6.4 懒恢复策略:等到需要的时候再还原
有些业务场景里,保存快照是高频操作,但恢复操作很少发生。这时候你可以做进一步优化:快照只保存"原始值"和"变更值"之间的差异,而不是全量拷贝。恢复的时候先执行补偿逻辑,把差异回滚。这个优化我第一次在项目里做的时候确实复杂了不少,但对于超大对象、超高频快照的场景,收益立竿见影。
如果差异计算本身太复杂,也可以退一步:只对可变的大字段(比如商品列表)做差异快照,普通的基础字段(如客户名、状态)直接全量存。毕竟基础字段的存储开销微乎其微,真正吃内存的是嵌套集合。
6.5 测试时一定要覆盖的五个场景
最后给一份检查清单,这是我吃过苦头之后整理出来的,建议在代码评审和测试阶段都要覆盖到:
- 空快照恢复:一个从未保存过的对象调用
restore(null)是否能优雅报错。 - 深度嵌套对象修改后的快照和恢复:确保只改了最内层字段时,恢复后整体都不变。
- 多步操作 + 撤销 + 重做再撤销的排列组合:我实测中发现顺序错乱最容易出 bug 的就是 redo 之后又做了新操作,旧 redo 栈必须被清空。
- 超限历史记录:验证丢弃最旧快照的逻辑不会破坏后续恢复。
- 并发下同一个对象的状态一致性:至少在修改入口统一加锁,避免快照和实际状态错位。
结尾:一点个人的体会
备忘录模式我在好几个项目里反复用过,最大的感受是:它简单,但不容易写对。简单在于三个角色一目了然,不容易写对在于边界情况和封装细节非常多,稍微偷懒就可能在快照时机、深浅拷贝、历史栈管理这些地方埋雷。如果你是从零开始写一个需要撤销/重做功能的模块,我建议先花十分钟把状态边界理清楚:哪些字段需要保存、哪些可以忽略、恢复时如何保证内外部引用一致,比直接上手写代码要高效得多。最后再分享一个小技巧:你可以在业务对象里加一个 debug 方法,把当前状态序列化成字符串打印出来,每次快照和恢复前后输出一条日志。调试撤销功能的时候,这个日志能帮你省去大量靠肉眼比对对象字段的麻烦,我至今还在用这个办法排查各种状态错乱问题。
