编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 面向对象设计

  • 常见设计原则

  • 巧学设计模式

    • README
    • 单例模式设计思想
    • 工厂模式设计思想
    • 建造者模式设计思想
    • 原型模式设计思想
    • 静态代理设计模式
    • 动态代理设计模式
    • 适配器模式设计思想
    • 装饰者模式设计思想
    • 外观模式设计思想
    • 桥接模式设计思想
    • 组合模式设计思想
    • 享元模式设计思想
    • 观察者模式设计思想
    • 策略者模式设计思想
    • 模版模式设计思想
    • 迭代器模式设计思想
    • 职责链模式设计思想
    • 命令模式设计思想
    • 状态模式设计思想
    • 备忘录模式设计思想
      • 01.案例引入:运营误操作订单回滚
        • 1.1 痛点现场
        • 1.2 直觉实现复现
        • 1.3 问题根源拆解
        • 1.4 引出本篇主角
      • 02.三次失败探索
        • 2.1 尝试方案 A:手工备份每个字段
        • 2.2 尝试方案 B:直接 clone 整个对象
        • 2.3 尝试方案 C:外部类持有备份 Map
        • 2.4 终于引出备忘录模式
      • 03.备忘录模式基础介绍
        • 3.1 从失败中提炼的需求
        • 3.2 备忘录模式的标准骨架
        • 3.3 典型使用场景
      • 04.三种实现对比
        • 4.1 实现核心要点
        • 4.2 实现 A:内部类快照——白箱模式
        • 4.3 实现 B:窄接口 + 宽接口——黑箱模式
        • 4.4 实现 C:增量快照 + 命令联动
        • 4.5 三种实现速查表
      • 05.用前用后效果对比
        • 5.1 代码维度对比
        • 5.2 能力维度对比
        • 5.3 核心收益
      • 06.反面踩坑实录
        • 6.1 踩坑 A:快照持有可变引用——等于没拍
        • 6.2 踩坑 B:大对象全量快照内存爆炸
        • 6.3 踩坑 C:多线程下快照与恢复穿插
        • 6.4 踩坑 D:敏感字段泄露到快照
        • 6.5 替代方案汇总
      • 07.决策树与选型
        • 7.1 该不该用备忘录模式
        • 7.2 选哪种实现方式
        • 7.3 选型清单速查
      • 08.总结与延伸
        • 8.1 设计思想沉淀
        • 8.2 模式联动边界
        • 8.3 思考题与延伸
    • 中介者模式设计思想
    • 访问者模式设计思想
    • 解释器模式设计思想
    • 23种设计模式概括
    • 技术写作模板
  • 系统架构设计

  • 编程
  • 巧学设计模式
杨充
2020-02-27
目录

备忘录模式设计思想

# 20.备忘录模式设计思想

📚 本篇按照「事故复盘 → 失败探索 → 模式登场 → 实现对比 → 效果对比 → 反面踩坑 → 选型决策」的节奏展开,建议按顺序阅读。

# 目录介绍

  • 01.案例引入:运营误操作订单回滚
    • 1.1 痛点现场
    • 1.2 直觉实现复现
    • 1.3 问题根源拆解
    • 1.4 引出本篇主角
  • 02.三次失败探索
    • 2.1 尝试方案A:手工备份每个字段
    • 2.2 尝试方案B:直接 clone 整个对象
    • 2.3 尝试方案C:外部类持有备份
    • 2.4 终于引出备忘录模式
  • 03.备忘录模式基础介绍
    • 3.1 从失败中提炼需求
    • 3.2 备忘录模式的标准骨架
    • 3.3 典型使用场景
  • 04.三种实现对比
    • 4.1 实现核心要点
    • 4.2 实现A:内部类快照(白箱)
    • 4.3 实现B:窄接口 + 宽接口(黑箱)
    • 4.4 实现C:增量快照 + 命令联动
    • 4.5 三种实现速查表
  • 05.用前用后效果对比
    • 5.1 代码维度对比
    • 5.2 能力维度对比
    • 5.3 核心收益
  • 06.反面踩坑实录
    • 6.1 踩坑A:快照持有可变引用
    • 6.2 踩坑B:大对象全量快照内存爆炸
    • 6.3 踩坑C:多线程快照与恢复穿插
    • 6.4 踩坑D:敏感字段泄露到快照
    • 6.5 替代方案汇总
  • 07.决策树与选型
    • 7.1 该不该用备忘录模式
    • 7.2 选哪种实现方式
    • 7.3 选型清单速查
  • 08.总结与延伸
    • 8.1 设计思想沉淀
    • 8.2 模式联动边界
    • 8.3 思考题与延伸

# 01.案例引入:运营误操作订单回滚

本篇主线:在不破坏对象封装的前提下,拍快照 + 回滚

# 1.1 痛点现场

运营后台的订单编辑页,一次提交会改动:价格、SKU、收货地址、备注、发货单号。某日下午,运营手抖把一个已发货订单的地址和物流单号批量覆盖成了错误值——涉及 2300 单。客服瞬间被用户投诉淹没:"我的快递发到别人家了!"

第一反应是改回来——但订单有 20+ 个字段,没人记得被覆盖前的值是什么。DBA 翻 binlog 恢复了 3 小时,期间 2300 个用户持续投诉。

定位到代码——编辑接口直接 setXxx,无任何快照:

public void update(OrderEditReq req) {
    this.price = req.price;
    this.address = req.address;
    this.remark = req.remark;
    this.trackingNo = req.trackingNo;
    // 20+ 个字段直接覆盖,无历史记录
}
1
2
3
4
5
6
7

# 1.2 直觉实现复现

第一版补救想法——改之前保存"旧值":

class Order {
    BigDecimal price;
    String address, remark, trackingNo;
    // ……20+ 个字段

    public void update(OrderEditReq req) {
        BigDecimal oldPrice = this.price;
        String oldAddr  = this.address;
        String oldRmk   = this.remark;
        String oldTn    = this.trackingNo;
        // ……手动备份所有字段
        this.price = req.price; this.address = req.address; // ……
        // 想撤回?怎么传回去?暴露 20+ 个 setter?
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

💭 反思:为什么一个"改错回滚"的功能需要 DBA 翻 3 小时 binlog?核心问题不是"没保存旧值"——而是 快照的管理方式和对象的封装被破坏。字段少时手工备份可行,20+ 个字段就会漏;暴露 getter/setter 回滚等于破坏封装。

# 1.3 问题根源拆解

flowchart LR
    E[运营编辑] --> O[Order 对象]
    O -.撤回.-> Q1[❌ 旧值在哪?<br/>手工备份漏字段]
    O -.暴露 setter.-> Q2[❌ 封装破坏<br/>任何地方都能改]
    O -.写成 Map.-> Q3[❌ 类型安全丢失<br/>String 存储 BigDecimal]
    style Q1 fill:#fee
    style Q2 fill:#fee
    style Q3 fill:#fee
1
2
3
4
5
6
7
8
隐患 现象 业务影响
手工备份不可靠 5 个字段记得存,加到 20 个字段就会漏 回滚后状态不一致
暴露 setter 破坏封装 为了恢复把私有字段暴露,谁都能改 数据安全防线被打破
撤销栈无处安放 产品要"撤回 N 次",存什么、怎么存、谁管 无法支持多步撤销
敏感字段泄露 加密密钥、签名等字段被快照带出对象 安全风险

核心矛盾:业务上"需要保存历史状态并恢复",但代码层面快照管理和对象封装是冲突的——外部持有快照就能读内部字段。

# 1.4 引出本篇主角

备忘录模式的核心思想:对象自己负责"拍快照"和"从快照恢复"——快照本身是一个只有对象自己看得懂的不透明对象。外部只负责保管快照,需要时交还给对象让它自己 restore。

class Order {
    private String address;
    private double amount;
    // ……

    public Memento save() { return new Memento(address, amount, /*...*/); }
    public void restore(Memento m) { this.address = m.address; this.amount = m.amount; }

    public static class Memento {
        private final String address;      // 外部看不到
        private final double amount;
        private Memento(String a, double am) { address = a; amount = am; }
    }
}

// 看守者:只管存快照,不关心里面有什么
Deque<Order.Memento> history = new ArrayDeque<>();
history.push(order.save());   // 改之前拍
order.update(req);             // 改
history.push(order.save());   // 再拍
order.restore(history.pop());  // 撤回
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
flowchart LR
    O[Originator<br/>Order] -->|save| M[Memento 快照<br/>不透明对象]
    C[Caretaker<br/>历史栈] -->|保管| M
    C -->|交还| O
    O -->|restore| O
    style M fill:#fdf6e3
    style C fill:#e6f3ff
1
2
3
4
5
6
7

先别急着看实现——下一节我们看看新人通常会先尝试哪些方案。


# 02.三次失败探索

备忘录模式不是凭空发明的——它是前人走过 3 条死路之后才提炼出来的。

# 2.1 尝试方案 A:手工备份每个字段

// 方案A:改之前逐一保存旧值
public void update(OrderEditReq req) {
    BigDecimal oldPrice = this.price;
    String oldAddr  = this.address;
    // ……20+ 个字段逐一备份
    this.price = req.price; this.address = req.address; // ……
}
1
2
3
4
5
6
7

🧪 验证:

// 场景:新增加一个字段 "discountAmount"
// 开发在 update() 方法里新增了 this.discountAmount = req.discountAmount;
// 但忘了加备份代码 oldDiscount = this.discountAmount;
// → 回滚时 discountAmount 没恢复,状态不一致
1
2
3
4

❌ 失败原因:手工备份不强制。字段从 5 个涨到 20 个——必漏。编译期没有任何提示说"你还差一个字段没备份"。

💡 反思:备份和恢复必须成对、强制——不能靠开发者记忆。

# 2.2 尝试方案 B:直接 clone 整个对象

// 方案B:clone 整个 Order 作为备份
public class Order implements Cloneable {
    public Order clone() {
        Order copy = (Order) super.clone();
        copy.items = new ArrayList<>(this.items);  // 深拷贝可变引用
        return copy;
    }
}

Order backup = order.clone();  // 改之前整个复制
order.update(req);
order = backup;                // 撤回 = 整个替换
1
2
3
4
5
6
7
8
9
10
11
12

🧪 验证:

// 问题1:Order 引用了不可序列化对象(线程池、连接、事件总线)
// → clone 时抛异常或复制了不该复制的引用
// 问题2:整个对象被暴露给了外部——caretaker 能读到所有字段
// 问题3:大对象每次全量 clone 性能开销大
1
2
3
4

❌ 失败原因:① clone 不可靠(浅拷贝陷阱、不可序列化引用);② 整个对象暴露给外部——破坏封装;③ 大对象全量复制内存/CPU 开销高。

💡 反思:快照应该只包含需要恢复的状态字段,不是整个对象的副本。

# 2.3 尝试方案 C:外部类持有备份 Map

// 方案C:外部 History 类持有备份 Map
class OrderHistory {
    private Map<Long, Map<String, Object>> backups = new HashMap<>();
    
    public void backup(Order o) {
        Map<String, Object> fields = new HashMap<>();
        fields.put("price", o.getPrice());   // 暴露 getter
        fields.put("address", o.getAddress());
        backups.put(o.getId(), fields);
    }
    public void restore(Order o) {
        Map<String, Object> fields = backups.get(o.getId());
        o.setPrice((BigDecimal) fields.get("price"));  // 类型强转
        o.setAddress((String) fields.get("address"));
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

🧪 验证:

// 问题1:类型安全丢失——Map<String, Object> 存储所有类型
//   → fields.get("price") 返回 Object,强制转 BigDecimal,编译期不保
// 问题2:外部类知道 Order 的内部结构——新增字段要同步改 OrderHistory
// 问题3:Order 必须暴露所有字段的 getter——封装彻底破坏
1
2
3
4

❌ 失败原因:① 类型安全丢失(Map<String, Object>);② 外部类了解 Order 内部结构——强耦合;③ Order 的封装被 getter 破坏。

💡 反思:必须让 Order 自己负责快照的创建和恢复——外部只管保管,不关心内容。

# 2.4 终于引出备忘录模式

三次失败之后,需求清单收敛了:

必须满足 来自哪一次失败
① 备份恢复强制成对,不漏字段 2.1 手工备份
② 不暴露对象内部状态给外部 2.2 clone 暴露 / 2.3 getter 暴露
③ 类型安全,编译期校验 2.3 Map<String, Object>
④ 支持多步撤销栈 1.2 真实事故

备忘录模式的标准答案:

// ① Order 自己负责 save/restore——字段备份强制成对
class Order {
    private String address; private double amount; // ② private 字段不暴露
    // ③ 内部类 Memento——字段类型安全
    public Memento save() { return new Memento(address, amount); }
    public void restore(Memento m) { this.address = m.address; this.amount = m.amount; }
    
    public static class Memento {
        private final String address;          // ② 外部拿不到
        private final double amount;            // ③ 编译期类型安全
        private Memento(String a, double am) { address = a; amount = am; }
    }
}

// ④ Caretaker 只管栈——支持多步撤销
Deque<Order.Memento> history = new ArrayDeque<>();
history.push(order.save());
order.restore(history.pop());
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

短短几行,快照创建和恢复都在 Order 内部完成,外部只管存——不漏字段、不破坏封装、类型安全。


# 03.备忘录模式基础介绍

# 3.1 从失败中提炼的需求

回顾 02 节的三次失败和 01 节的事故,备忘录模式的设计约束:

约束 来自 代码体现
① 备份恢复强制成对 2.1 手工备份漏字段 save() / restore() 都在 Originator 内
② 不暴露内部状态 2.2 clone / 2.3 Map Memento 内部类 + private 字段 + 包级构造器
③ 类型安全 2.3 Map<String,Object> Memento 字段用具体类型(String/BigDecimal)
④ 支持多步撤销 01 事故 Caretaker 用 Deque<Memento> 栈

备忘录模式:在不违背封装原则的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便之后将该对象恢复到原先保存的状态。

# 3.2 备忘录模式的标准骨架

// Originator:被快照的对象
class Originator {
    private String state;                                    // ① 内部状态

    public Memento save() {                                  // ① 创建快照
        return new Memento(state);
    }
    public void restore(Memento m) {                         // ① 从快照恢复
        this.state = m.getState();
    }

    // Memento:快照本身(内部静态类)
    public static class Memento {
        private final String state;
        private Memento(String s) { state = s; }             // ② 包级私有
        private String getState() { return state; }           // ② 仅 Originator 可读
    }
}

// Caretaker:管理者——只存快照,不读内容          // ③ 窄接口
class Caretaker {
    private final Deque<Originator.Memento> history = new ArrayDeque<>();
    public void backup(Originator o) { history.push(o.save()); }
    public void undo(Originator o)  { if (!history.isEmpty()) o.restore(history.pop()); }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
classDiagram
    class Originator {
        -state
        +save() Memento
        +restore(Memento)
    }
    class Memento {
        -state
        ~getState()
    }
    class Caretaker {
        -history: Deque
        +backup(Originator)
        +undo(Originator)
    }
    Originator --> Memento : creates/uses
    Caretaker --> Memento : stores
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

三句话记住:Originator 创快照+恢复 → Memento 不透明 → Caretaker 只管存取。 差异全在快照策略——全量 vs 增量 vs 联动命令——这就是下一节三种实现的分岔。

# 3.3 典型使用场景

场景 快照对象 解决了什么
编辑器 Ctrl+Z 编辑命令 + 行级快照 支持多步撤销重做
数据库事务 undo log 每次修改的旧值 事务回滚的数据基础
Git stash 工作区快照入栈 暂存未提交改动
游戏存档 角色状态快照 读档回到存档点
订单编辑回滚 Order.Memento 运营误操作一键恢复
浏览器后退 页面状态栈 返回上一页保持状态
Redis RDB BGSAVE 快照 持久化 + 灾难恢复

# 04.三种实现对比

# 4.1 实现核心要点

三种写法本质上是在 封装强度 / 内存开销 / 恢复粒度 上的不同取舍。实现备忘录只需两行骨架:

Originator.Memento m = obj.save();   // ① 拍快照
obj.restore(m);                       // ② 恢复
1
2

差异全在"Memento 里存什么、Caretaker 怎么管、恢复粒度多细"。下面按演进顺序逐一展开。

# 4.2 实现 A:内部类快照——白箱模式

设计权衡:用"Originator 可以读 Memento 内部"换"实现最简单"

// 实现A:Memento 字段全部 private,但通过 getState() 让 Originator 读
public class Order {
    private String address;
    private double amount;
    private List<String> tags;

    public Memento save() {
        return new Memento(address, amount, new ArrayList<>(tags));  // 深拷贝可变字段
    }
    public void restore(Memento m) {
        this.address = m.address;     // Originator 直接读 Memento 字段
        this.amount  = m.amount;
        this.tags    = new ArrayList<>(m.tags);
    }

    public static class Memento {
        private final String address;
        private final double amount;
        private final List<String> tags;
        private Memento(String a, double am, List<String> t) {
            address = a; amount = am; tags = t;
        }
    }
}

// Caretaker
Deque<Order.Memento> history = new ArrayDeque<>();
history.push(order.save());
order.setAddress("新地址");
history.push(order.save());
order.restore(history.pop());  // 回到"新地址"
order.restore(history.pop());  // 回到最初
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

优点:实现极简,Originator 和 Memento 在同一文件内。缺点:白箱——Memento 字段对 Originator 可见但对外不可见;可变引用必须深拷贝。适用:对象字段少(<15 个)、同一 package 内使用。

# 4.3 实现 B:窄接口 + 宽接口——黑箱模式

设计权衡:用"接口隔离 + 类型转换"换"Caretaker 完全无法读快照内容"

// 实现B:对外暴露窄接口 Memento(无任何方法),对内宽接口 MementoImpl
public interface Memento {}                            // 窄接口:空接口,外部只能持有

public class Order {
    private String address;
    private double amount;

    public Memento save() { return new MementoImpl(address, amount); }
    public void restore(Memento m) {
        MementoImpl impl = (MementoImpl) m;           // 内部转换拿到宽接口
        this.address = impl.address;
        this.amount = impl.amount;
    }

    private static class MementoImpl implements Memento {  // 宽接口:只有 Order 知道
        final String address;
        final double amount;
        MementoImpl(String a, double am) { address = a; amount = am; }
    }
}

// Caretaker 持有的只是 Memento 接口——无法读任何内容
Deque<Memento> history = new ArrayDeque<>();
history.push(order.save());    // 拿到的是 Memento 接口——只有 Object 的方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

优点:Caretaker 完全无法读快照内容——封装最强。缺点:restore 时需要类型转换;Memento 接口需要额外定义。适用:跨模块使用、需要严格封装隔离的场景。

# 4.4 实现 C:增量快照 + 命令联动

设计权衡:用"只存变更 + 依赖命令"换"内存极省 + 真正可撤销"

// 实现C:命令模式 + 备忘录——执行前自动拍快照
interface Cmd {
    void execute();
    default void undo() { throw new UnsupportedOperationException(); }
}

class UpdateAddressCmd implements Cmd {
    private final Order order;
    private final String newAddr;
    private Order.Memento snapshot;          // 执行前拍的快照

    public void execute() {
        snapshot = order.save();              // ① 执行前拍快照
        order.setAddress(newAddr);
    }
    public void undo() {
        order.restore(snapshot);              // ② 撤销 = 回快照
    }
}

// Caretaker 升级为命令栈
Deque<Cmd> cmdHistory = new ArrayDeque<>();
cmdHistory.push(new UpdateAddressCmd(order, "新地址"));
cmdHistory.peek().execute();
// 想撤销
cmdHistory.pop().undo();      // order 回到执行前状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

优点:与命令模式天然联动,撤销就是回快照——比反向操作 undo() 可靠得多(参考 18 篇命令模式踩坑 A)。缺点:需要命令模式配合;粒度是"一次命令"。适用:已使用命令模式的系统、需要精确撤销到任意步骤。

# 4.5 三种实现速查表

实现方式 封装强度 内存开销 实现复杂度 适用场景 推荐度
实现A:内部类白箱 ⭐⭐⭐ ⭐⭐⭐ ⭐ 单模块/小对象 ⭐⭐⭐⭐
实现B:窄接口黑箱 ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐ 跨模块/强封装 ⭐⭐⭐⭐
实现C:增量命令联动 ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐ 已用命令模式 ⭐⭐⭐⭐⭐

📌 一句话决策:小对象快照→实现A,跨模块封装→实现B,命令模式联动→实现C。


# 05.用前用后效果对比

用 1.1 节订单编辑回滚场景做基准。

# 5.1 代码维度对比

// ❌ 用前:手工备份 + 暴露 setter
BigDecimal oldPrice = order.getPrice();  // 暴露 getter
String oldAddr = order.getAddress();
order.update(req);
// 想撤回?order.setPrice(oldPrice); order.setAddress(oldAddr); // 暴露 setter

// ✅ 用后:备忘录模式
history.backup(order);
order.update(req);
history.undo(order);  // 一行撤回,不暴露任何字段
1
2
3
4
5
6
7
8
9
10

# 5.2 能力维度对比

维度 ❌ 手工备份 + 暴露 setter ✅ 备忘录模式
封装性 getter/setter 全部暴露 Memento 私有不透明
备份完整性 依赖开发者记忆,必漏字段 save/restore 强制成对
类型安全 手动强转或全用 Object 编译期校验
多步撤销 需要自己维护栈+字段映射 Caretaker 栈天然支持
新增字段成本 改备份+恢复两处,易漏 只改 save/restore 成对方法
敏感字段保护 全部暴露无保护 Memento 可选择不存
DBA 介入 2300 单翻 binlog 3 小时 1 秒撤回

# 5.3 核心收益

备忘录模式的本质:把"状态保存和恢复"的逻辑内聚在对象自身,快照作为不透明对象交给外部保管。这正是为什么数据库 undo log 只记录旧值镜像、为什么 Git stash 只存差异、为什么 IDE 撤销栈只存编辑命令——任何"需要回到过去某个状态"的场景,让对象自己拍快照+自己恢复,才能让"封装不破 / 备份不漏 / 类型安全 / 多步撤销"同时成立。


# 06.反面踩坑实录

备忘录模式不是银弹——以下 4 个坑几乎每个团队都踩过。

# 6.1 踩坑 A:快照持有可变引用——等于没拍

public class Order {
    private List<OrderItem> items;
    
    public Memento save() {
        return new Memento(items);  // ❌ 直接引用!快照和外部分享同一个 List
    }
    // 外部修改了 items → 快照里的 items 也跟着变了 → 恢复无效
}
1
2
3
4
5
6
7
8

💣 事故:某订单系统快照里的 items 和外部分享引用,运营编辑商品时快照悄悄被改,回滚后发现商品还是改后的。

✅ 正解:快照里做深拷贝——new ArrayList<>(items) 或序列化反序列化;不可变集合 List.copyOf()。

# 6.2 踩坑 B:大对象全量快照内存爆炸

// 订单 50 个字段,用户频繁编辑,每 3 秒拍一张快照
history.push(order.save());  // 每张快照 ~2KB
// 100 个用户 × 20 步历史 × 2KB = 4MB → 还好
// 10000 个用户 × 50 步历史 × 5KB = 2.5GB → 内存告警
1
2
3
4

💣 事故:某 CMS 系统富文本编辑器每 5 秒拍全量快照,1000 并发编辑下内存 32GB 打满,频繁 Full GC。

✅ 正解:增量快照(只存变更字段+版本号)+ 快照上限(最多保留 N 步)+ 超时持久化(落 Redis/磁盘)。

# 6.3 踩坑 C:多线程下快照与恢复穿插

// 线程A:拍快照
Memento m = order.save();
// 线程B:改了 address
order.setAddress("上海");
// 线程A:恢复 → address 被覆盖为快照里的值 → 线程B 的改动丢了
order.restore(m);
1
2
3
4
5
6

💣 事故:某电商后台订单同时被两个客服编辑,一人点了"撤回"把另一人的修改覆盖了。

✅ 正解:快照+恢复加锁或乐观锁(版本号);UI 层提示"订单已被他人修改,刷新后重试"。

# 6.4 踩坑 D:敏感字段泄露到快照

public class Order {
    private String signKey;     // 加密签名密钥
    private String cardToken;   // 支付 token
    
    public Memento save() {
        return new Memento(signKey, cardToken, address, amount);  // ❌ 敏感字段入快照
    }
}
1
2
3
4
5
6
7
8

💣 事故:某支付系统快照被序列化到日志,支付 token 泄露,被爬虫抓取后批量盗刷。

✅ 正解:save() 时跳过敏感字段或脱敏;敏感字段标记 @transient;快照对象不序列化到日志。

# 6.5 替代方案汇总

你的需求 推荐方案
只需撤销一步、对象小 ✅ 直接存旧值 oldXxx,别引入 Memento
需要完整状态重建+审计 ✅ Event Sourcing(事件溯源)
分布式对象状态回滚 ✅ Saga 补偿 + 分布式快照
对象巨大只改几个字段 ✅ 增量快照 + diff 记录
前端表单回退 ✅ 浏览器 History API / Redux time travel

# 07.决策树与选型

# 7.1 该不该用备忘录模式

flowchart TD
    Start([我的对象需要撤销/回滚吗]) --> Q1{需要多步撤销<br/>(N 步历史)?}
    Q1 -->|是| Yes1[✅ 备忘录模式 + 栈]
    Q1 -->|否| Q2{对象字段 > 10 个<br/>且持续新增?}
    Q2 -->|是| Yes2[✅ 备忘录模式<br/>强制成对不漏字段]
    Q2 -->|否| Q3{需要保护对象封装<br/>不给外部暴露字段?}
    Q3 -->|是| Yes3[✅ 备忘录模式<br/>窄接口 + 宽接口]
    Q3 -->|否| No1[❌ 直接存旧值变量即可]
    
    style Yes1 fill:#dfd
    style Yes2 fill:#dfd
    style Yes3 fill:#dfd
    style No1 fill:#fee
1
2
3
4
5
6
7
8
9
10
11
12
13

# 7.2 选哪种实现方式

flowchart TD
    Start([选择备忘录实现]) --> Q1{已经用了命令模式吗?}
    Q1 -->|是| OptC[实现C:增量快照 + 命令联动]
    Q1 -->|否| Q2{跨模块使用<br/>需严格封装隔离?}
    Q2 -->|是| OptB[实现B:窄接口 + 宽接口<br/>黑箱模式]
    Q2 -->|否| OptA[实现A:内部类快照<br/>白箱模式]

    style OptC fill:#e6ffe6
    style OptB fill:#fff4e6
    style OptA fill:#e6f3ff
1
2
3
4
5
6
7
8
9
10

# 7.3 选型清单速查

场景 该用吗 推荐方式
订单编辑多步撤销 ✅ 该用 实现A:内部类快照
编辑器 Ctrl+Z ✅ 该用 实现C:命令联动
跨服务状态回滚 ❌ 别用 Saga 补偿
只需回滚 1 步/对象小 ❌ 别用 直接存旧值
支付系统+命令模式 ✅ 该用 实现C:增量命令联动
游戏存档读档 ✅ 该用 实现A + 持久化

# 08.总结与延伸

# 8.1 设计思想沉淀

阶段 学到了什么
01 事故 痛点是模式诞生的土壤——2300 单误操作翻 3 小时 binlog
02 三次失败 手工备份/整个 clone/外部 Map 都不够——快照创建和恢复必须内聚在对象自身
03 模式基础 三角色:Originator 创快照+恢复、Memento 不透明、Caretaker 只管存取
04 三种实现 白箱/黑箱/命令联动本质是"封装强度/内存开销/恢复粒度"的权衡
05 效果对比 封装不破 + 备份不漏 + 类型安全 + 多步撤销
06 反面踩坑 可变引用浅拷贝、大对象内存爆炸、多线程穿插、敏感字段泄露
07 决策树 工程师的成熟度:知道什么时候直接存旧值就够了

🔑 一句话核心:

备忘录 = 不开盒地存档读档。对象自己拍快照+自己恢复,外部只管保管。

# 8.2 模式联动边界

flowchart LR
    备忘录 -.undo 靠快照恢复.-> 命令
    备忘录 -.用 clone 实现快照.-> 原型
    状态 -.切换前后存档.-> 备忘录
1
2
3
4
模式 关系 一句话区别
命令 联动 命令 undo() 拍快照=-真正可撤销(反之纯反向操作易漏步)
原型 底层配合 备忘录的快照常用 clone 实现(但备忘录不暴露完整对象)
状态 联动 状态切换前后存档 → 状态机也能回退
事件溯源 替代 备忘录存"当前快照",事件溯源存"完整事件流"——前者更轻

什么时候不该用备忘录:

  • 只需撤销 1 步且字段少——直接存旧值变量
  • 对象巨大且内存敏感——增量快照或 Event Sourcing
  • 分布式对象——Saga 补偿 + 分布式事务
  • 需要完整审计历史——Event Sourcing 更合适

# 8.3 思考题与延伸

💭 三道思考题:

  1. 我们让单个订单支持撤销了。如果整个购物车(多个商品+优惠+地址)都要"一键回到 5 分钟前"——每件商品各自拍快照,还是把购物车整体作为 Originator?(提示:粒度 vs 复杂度权衡)

  2. 如果快照栈有 100 步历史,每次 restore 都会创建一个新的 Memento 对象——怎么用享元模式优化?(提示:不可变 Memento 天然享元化)

  3. 备忘录的快照对象被序列化存入 Redis——如何保证反序列化后 Memento 的包级私有字段不被外部访问?(提示:回看 4.3 黑箱模式 + 序列化代理)

📚 延伸阅读:

  • Git 内部原理:Commit/Tree/Blob 的快照模型
  • MySQL InnoDB undo log:数据库事务回滚的备忘录实现
  • Redux Time Travel:前端状态快照与回放
  • Java Serialization Proxy:序列化中保护封装

上一篇 19.状态模式 → 本篇 → 21.中介者模式:对象之间的交互也开始失控了。

上次更新: 2026/06/17, 11:43:57
状态模式设计思想
中介者模式设计思想

← 状态模式设计思想 中介者模式设计思想→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式