设计模式:备忘录模式

欢迎你来读这篇博客,这篇博客主要是关于备忘录模式
其中包括备忘录模式的核心思想、适用场景、优缺点、撤销与恢复、快照管理、与命令模式/原型模式/状态模式/事件溯源的区别,以及 Java 后端开发中配置版本回滚的完整案例。

序言

在软件开发中,我们经常会遇到一个很朴素但很重要的需求:

当前状态可能会被修改,但我希望必要时能恢复到之前的某个状态。

比如:

  • 文本编辑器撤销上一步;
  • 表单编辑时恢复草稿;
  • 配置中心修改后回滚;
  • 工作流节点状态恢复;
  • 游戏存档和读档;
  • 审批单修改前保存快照;
  • 后台管理配置变更保留历史版本;
  • 订单复杂操作前保存状态,以便失败后补偿。

这些场景背后的共同点是:

需要保存对象某一时刻的状态,并在将来恢复。

备忘录模式就是为了解决这类问题:

在不破坏对象封装性的前提下,捕获并保存对象内部状态,以便以后恢复到该状态。

简单说:

备忘录模式就是给对象拍一张“状态快照”。

对象后面怎么变都没关系,只要快照还在,就可以回到过去。

当然,别幻想什么都能回到过去。代码里的状态可以恢复,线上误删数据就不一定了。所以备忘录模式能救的是对象状态,不是凌晨三点没备份的你。

正文

chapter 1:什么是备忘录模式

备忘录模式,英文是 Memento Pattern,属于行为型设计模式。

它的定义是:

在不破坏封装性的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便以后将对象恢复到原先保存的状态。

这句话里面有三个关键点:

  1. 保存对象内部状态
  2. 不破坏对象封装
  3. 以后可以恢复状态

备忘录模式一般包含三个角色:

  1. Originator 发起人:需要保存和恢复状态的对象。
  2. Memento 备忘录:保存发起人某一时刻的状态。
  3. Caretaker 管理者:负责保存备忘录,但不应该修改或理解备忘录内部状态。

结构如下:

1
2
3
4
5
6
7
8
9
10
Originator
│ createMemento()

Memento

Caretaker
│ save(memento)
│ get()

Memento

发起人负责创建快照,也负责根据快照恢复自己。

管理者负责保存快照,但不应该直接修改快照内容。

chapter 2:为什么需要备忘录模式

假设我们有一个文章编辑器。

1
2
3
4
5
6
7
8
public class ArticleEditor {

private String title;

private String content;

private String author;
}

用户编辑文章时,希望支持撤销。

如果没有备忘录模式,可能会在外部直接保存字段:

1
2
3
String oldTitle = editor.getTitle();
String oldContent = editor.getContent();
String oldAuthor = editor.getAuthor();

然后恢复时:

1
2
3
editor.setTitle(oldTitle);
editor.setContent(oldContent);
editor.setAuthor(oldAuthor);

这种方式的问题是:

  • 外部对象需要知道编辑器内部有哪些字段;
  • 如果字段增加,外部保存逻辑也要改;
  • 封装性被破坏;
  • 状态保存和恢复逻辑分散;
  • 容易漏字段;
  • 复杂对象恢复很麻烦。

备忘录模式的做法是:

1
ArticleMemento memento = editor.createMemento();

恢复时:

1
editor.restore(memento);

外部管理者只保存 memento,不关心里面到底有什么字段。

这样对象状态的保存和恢复都由对象自己控制。

chapter 3:备忘录模式解决的核心问题

备忘录模式主要解决的是:

如何在不暴露对象内部细节的前提下,保存并恢复对象状态。

它特别强调“封装性”。

不好的做法是让外部知道对象内部字段:

1
2
3
4
oldTitle = editor.getTitle();
oldContent = editor.getContent();
oldStatus = editor.getStatus();
oldTags = editor.getTags();

好的做法是让对象自己创建快照:

1
ArticleMemento memento = editor.createMemento();

外部只管保存快照。

对象自己知道如何恢复:

1
editor.restore(memento);

这就是备忘录模式和普通 DTO 复制的区别。

chapter 4:备忘录模式的核心角色

1. Originator 发起人

发起人是状态真正所属的对象。

它负责:

  • 创建备忘录;
  • 从备忘录恢复状态;
  • 维护自己的业务状态。

例如:

1
2
3
4
ArticleEditor
ConfigDocument
GameRole
WorkflowContext

2. Memento 备忘录

备忘录保存发起人某一时刻的状态。

它通常应该是不可变对象。

例如:

1
2
3
4
5
6
7
8
public class ArticleMemento {

private final String title;

private final String content;

private final String author;
}

3. Caretaker 管理者

管理者负责保存备忘录。

例如:

  • 历史栈;
  • 版本列表;
  • 数据库快照表;
  • Redis 快照;
  • 文件存档。

它不应该修改备忘录内部状态。

1
2
3
4
public class History {

private final Deque<ArticleMemento> snapshots = new ArrayDeque<>();
}

chapter 5:最简单的备忘录模式示例

先写一个文章编辑器。

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
33
34
35
36
public class ArticleEditor {

private String title;

private String content;

private String author;

public ArticleEditor(String title, String content, String author) {
this.title = title;
this.content = content;
this.author = author;
}

public void update(String title, String content, String author) {
this.title = title;
this.content = content;
this.author = author;
}

public ArticleMemento createMemento() {
return new ArticleMemento(title, content, author);
}

public void restore(ArticleMemento memento) {
this.title = memento.getTitle();
this.content = memento.getContent();
this.author = memento.getAuthor();
}

public void print() {
System.out.println("title = " + title
+ ",content = " + content
+ ",author = " + author);
}
}

备忘录对象:

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
public class ArticleMemento {

private final String title;

private final String content;

private final String author;

public ArticleMemento(String title, String content, String author) {
this.title = title;
this.content = content;
this.author = author;
}

public String getTitle() {
return title;
}

public String getContent() {
return content;
}

public String getAuthor() {
return author;
}
}

历史管理者:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.util.ArrayDeque;
import java.util.Deque;

public class ArticleHistory {

private final Deque<ArticleMemento> history = new ArrayDeque<>();

public void save(ArticleMemento memento) {
history.push(memento);
}

public ArticleMemento undo() {
if (history.isEmpty()) {
throw new IllegalStateException("No history memento");
}

return 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
public class MementoDemo {

public static void main(String[] args) {
ArticleEditor editor = new ArticleEditor(
"设计模式",
"今天开始写设计模式",
"SuperMario"
);

ArticleHistory history = new ArticleHistory();

history.save(editor.createMemento());

editor.update(
"设计模式:备忘录模式",
"备忘录模式用于保存和恢复对象状态",
"SuperMario"
);

editor.print();

editor.restore(history.undo());

editor.print();
}
}

这个例子体现了备忘录模式的基本流程:

  1. 修改前保存快照;
  2. 修改对象状态;
  3. 需要撤销时恢复快照。

chapter 6:为什么备忘录对象最好不可变

备忘录对象保存的是历史状态。

历史状态一旦创建,最好不要再被修改。

所以备忘录对象一般设计成不可变对象:

1
2
3
4
5
6
7
8
public class ArticleMemento {

private final String title;

private final String content;

private final String author;
}

不建议提供 setter:

1
2
3
public void setTitle(String title) {
this.title = title;
}

原因很简单:

快照如果能被修改,就不再是可靠快照。

如果管理者或其他对象可以修改备忘录,恢复时就可能恢复到一个被污染的状态。

这就像你存档之后,游戏还偷偷改了存档文件。读档时发现自己从勇者变成村口 NPC,心态当场爆炸。

chapter 7:备忘录模式和封装性

备忘录模式非常强调封装。

理想情况下,Caretaker 不应该知道备忘录里保存了什么。

它只负责保存和取出。

1
2
history.save(editor.createMemento());
editor.restore(history.undo());

管理者不应该这样写:

1
2
memento.setTitle("xxx");
memento.setContent("yyy");

也不应该根据备忘录内部字段做复杂业务判断。

因为备忘录内部结构属于发起人对象。

外部只保存,不解释。

在 Java 中,如果想更强地保护封装,可以把备忘录定义为发起人内部类。

chapter 8:使用内部类保护备忘录

可以把备忘录定义成 ArticleEditor 的静态内部类。

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
33
34
35
36
37
38
39
40
41
42
43
44
45
public class ArticleEditor {

private String title;

private String content;

private String author;

public ArticleEditor(String title, String content, String author) {
this.title = title;
this.content = content;
this.author = author;
}

public void update(String title, String content, String author) {
this.title = title;
this.content = content;
this.author = author;
}

public Memento createMemento() {
return new Memento(title, content, author);
}

public void restore(Memento memento) {
this.title = memento.title;
this.content = memento.content;
this.author = memento.author;
}

public static class Memento {

private final String title;

private final String content;

private final String author;

private Memento(String title, String content, String author) {
this.title = title;
this.content = content;
this.author = author;
}
}
}

这里 Memento 的构造方法是 private

外部无法随便创建备忘录。

只能通过:

1
editor.createMemento()

创建。

这样可以更好地保护快照创建逻辑。

不过 Java 中内部类字段如果暴露 getter,外部仍然可以读到。

是否需要完全隐藏,要看业务场景。

chapter 9:支持多次撤销

前面的历史管理者只支持撤销到上一个状态。

如果要支持多次撤销,可以使用栈。

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
import java.util.ArrayDeque;
import java.util.Deque;

public class MementoHistory<T> {

private final Deque<T> undoStack = new ArrayDeque<>();

public void save(T memento) {
undoStack.push(memento);
}

public T pop() {
if (undoStack.isEmpty()) {
throw new IllegalStateException("No memento");
}

return undoStack.pop();
}

public boolean isEmpty() {
return undoStack.isEmpty();
}

public int size() {
return undoStack.size();
}
}

使用:

1
2
3
4
5
6
7
8
9
10
11
12
MementoHistory<ArticleMemento> history = new MementoHistory<>();

history.save(editor.createMemento());

editor.update("v1", "content v1", "Mario");

history.save(editor.createMemento());

editor.update("v2", "content v2", "Mario");

editor.restore(history.pop());
editor.restore(history.pop());

每次修改前先保存状态。

撤销时从栈顶取出最近一次快照。

chapter 10:支持撤销和重做

如果要支持撤销和重做,需要两个栈:

  • undoStack:撤销栈;
  • redoStack:重做栈。
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
33
34
35
import java.util.ArrayDeque;
import java.util.Deque;

public class UndoRedoHistory<T> {

private final Deque<T> undoStack = new ArrayDeque<>();

private final Deque<T> redoStack = new ArrayDeque<>();

public void save(T memento) {
undoStack.push(memento);

redoStack.clear();
}

public T undo(T currentMemento) {
if (undoStack.isEmpty()) {
throw new IllegalStateException("No undo memento");
}

redoStack.push(currentMemento);

return undoStack.pop();
}

public T redo(T currentMemento) {
if (redoStack.isEmpty()) {
throw new IllegalStateException("No redo memento");
}

undoStack.push(currentMemento);

return redoStack.pop();
}
}

使用思路:

1
2
3
4
5
6
7
8
9
history.save(editor.createMemento());

editor.update(...);

ArticleMemento previous = history.undo(editor.createMemento());
editor.restore(previous);

ArticleMemento next = history.redo(editor.createMemento());
editor.restore(next);

这里有一个细节:

撤销前要把当前状态放入 redoStack,重做时才能回来。

这就是很多编辑器撤销/重做的基本思想。

chapter 11:备忘录模式案例背景:配置版本回滚

前面的文章编辑器适合理解原理。

下面讲一个 Java 后端更常见的场景:配置版本回滚。

假设我们做一个配置管理系统。

配置包括:

  • 配置 key;
  • 配置 value;
  • 描述;
  • 是否启用;
  • 更新时间;
  • 更新人。

每次修改配置前,都要保存一份快照。

如果新配置有问题,可以回滚到某个历史版本。

这就是备忘录模式非常典型的后端落地。

chapter 12:定义配置文档 ConfigDocument

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import java.time.LocalDateTime;

public class ConfigDocument {

private final String configKey;

private String configValue;

private String description;

private boolean enabled;

private String updatedBy;

private LocalDateTime updatedAt;

public ConfigDocument(String configKey,
String configValue,
String description,
boolean enabled,
String updatedBy) {
if (configKey == null || configKey.isBlank()) {
throw new IllegalArgumentException("configKey can not be blank");
}

this.configKey = configKey;
this.configValue = configValue;
this.description = description;
this.enabled = enabled;
this.updatedBy = updatedBy;
this.updatedAt = LocalDateTime.now();
}

public ConfigSnapshot createSnapshot(String snapshotBy) {
return new ConfigSnapshot(
configKey,
configValue,
description,
enabled,
updatedBy,
updatedAt,
snapshotBy,
LocalDateTime.now()
);
}

public void restore(ConfigSnapshot snapshot, String operator) {
if (!this.configKey.equals(snapshot.getConfigKey())) {
throw new IllegalArgumentException("snapshot configKey not match");
}

this.configValue = snapshot.getConfigValue();
this.description = snapshot.getDescription();
this.enabled = snapshot.isEnabled();
this.updatedBy = operator;
this.updatedAt = LocalDateTime.now();
}

public void update(String configValue,
String description,
boolean enabled,
String operator) {
this.configValue = configValue;
this.description = description;
this.enabled = enabled;
this.updatedBy = operator;
this.updatedAt = LocalDateTime.now();
}

public String getConfigKey() {
return configKey;
}

public String getConfigValue() {
return configValue;
}

public String getDescription() {
return description;
}

public boolean isEnabled() {
return enabled;
}

public String getUpdatedBy() {
return updatedBy;
}

public LocalDateTime getUpdatedAt() {
return updatedAt;
}
}

这个类是 Originator。

它负责:

  • 创建快照;
  • 根据快照恢复;
  • 更新自身状态。

注意:

1
2
createSnapshot()
restore()

这两个方法都在 ConfigDocument 内部。

外部不需要知道如何复制和恢复字段。

chapter 13:定义配置快照 ConfigSnapshot

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import java.time.LocalDateTime;

public class ConfigSnapshot {

private final String configKey;

private final String configValue;

private final String description;

private final boolean enabled;

private final String originalUpdatedBy;

private final LocalDateTime originalUpdatedAt;

private final String snapshotBy;

private final LocalDateTime snapshotAt;

public ConfigSnapshot(String configKey,
String configValue,
String description,
boolean enabled,
String originalUpdatedBy,
LocalDateTime originalUpdatedAt,
String snapshotBy,
LocalDateTime snapshotAt) {
this.configKey = configKey;
this.configValue = configValue;
this.description = description;
this.enabled = enabled;
this.originalUpdatedBy = originalUpdatedBy;
this.originalUpdatedAt = originalUpdatedAt;
this.snapshotBy = snapshotBy;
this.snapshotAt = snapshotAt;
}

public String getConfigKey() {
return configKey;
}

public String getConfigValue() {
return configValue;
}

public String getDescription() {
return description;
}

public boolean isEnabled() {
return enabled;
}

public String getOriginalUpdatedBy() {
return originalUpdatedBy;
}

public LocalDateTime getOriginalUpdatedAt() {
return originalUpdatedAt;
}

public String getSnapshotBy() {
return snapshotBy;
}

public LocalDateTime getSnapshotAt() {
return snapshotAt;
}
}

这个类是 Memento。

它保存配置某一刻的状态。

字段全部是 final,对象不可变。

chapter 14:定义快照仓库 ConfigSnapshotRepository

真实项目中,快照通常要持久化到数据库。

这里用内存模拟。

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
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;

public class ConfigSnapshotRepository {

private final List<ConfigSnapshot> snapshots = new ArrayList<>();

public void save(ConfigSnapshot snapshot) {
snapshots.add(snapshot);

System.out.println("保存配置快照,configKey = " + snapshot.getConfigKey()
+ ",snapshotAt = " + snapshot.getSnapshotAt());
}

public Optional<ConfigSnapshot> findLatest(String configKey) {
return snapshots.stream()
.filter(snapshot -> snapshot.getConfigKey().equals(configKey))
.max(Comparator.comparing(ConfigSnapshot::getSnapshotAt));
}

public List<ConfigSnapshot> findAll(String configKey) {
return snapshots.stream()
.filter(snapshot -> snapshot.getConfigKey().equals(configKey))
.sorted(Comparator.comparing(ConfigSnapshot::getSnapshotAt).reversed())
.toList();
}
}

这个类是 Caretaker 的一种实现。

它负责保存快照和查询快照。

它不负责修改快照。

chapter 15:定义配置服务 ConfigService

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import java.util.HashMap;
import java.util.Map;

public class ConfigService {

private final Map<String, ConfigDocument> configStore = new HashMap<>();

private final ConfigSnapshotRepository snapshotRepository;

public ConfigService(ConfigSnapshotRepository snapshotRepository) {
this.snapshotRepository = snapshotRepository;
}

public void createConfig(String key,
String value,
String description,
boolean enabled,
String operator) {
ConfigDocument document = new ConfigDocument(
key,
value,
description,
enabled,
operator
);

configStore.put(key, document);

System.out.println("创建配置,key = " + key + ",value = " + value);
}

public void updateConfig(String key,
String value,
String description,
boolean enabled,
String operator) {
ConfigDocument document = getConfig(key);

ConfigSnapshot snapshot = document.createSnapshot(operator);

snapshotRepository.save(snapshot);

document.update(value, description, enabled, operator);

System.out.println("更新配置,key = " + key + ",value = " + value);
}

public void rollbackLatest(String key, String operator) {
ConfigDocument document = getConfig(key);

ConfigSnapshot snapshot = snapshotRepository.findLatest(key)
.orElseThrow(() -> new IllegalStateException("No snapshot found, key = " + key));

document.restore(snapshot, operator);

System.out.println("回滚配置,key = " + key + ",value = " + document.getConfigValue());
}

public ConfigDocument getConfig(String key) {
ConfigDocument document = configStore.get(key);

if (document == null) {
throw new IllegalArgumentException("config not found, key = " + key);
}

return document;
}
}

这个服务中:

  • 修改配置前先保存快照;
  • 然后更新配置;
  • 回滚时取最新快照;
  • 通过 document.restore(snapshot) 恢复配置。

chapter 16:客户端使用配置回滚

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
33
34
35
36
37
38
39
40
41
42
public class ConfigMementoDemo {

public static void main(String[] args) {
ConfigSnapshotRepository snapshotRepository = new ConfigSnapshotRepository();

ConfigService configService = new ConfigService(snapshotRepository);

configService.createConfig(
"order.timeout.seconds",
"30",
"订单超时时间",
true,
"admin"
);

configService.updateConfig(
"order.timeout.seconds",
"60",
"订单超时时间调整为 60 秒",
true,
"admin"
);

configService.updateConfig(
"order.timeout.seconds",
"120",
"订单超时时间调整为 120 秒",
true,
"admin"
);

ConfigDocument current = configService.getConfig("order.timeout.seconds");

System.out.println("当前配置值:" + current.getConfigValue());

configService.rollbackLatest("order.timeout.seconds", "rollback-admin");

ConfigDocument afterRollback = configService.getConfig("order.timeout.seconds");

System.out.println("回滚后配置值:" + afterRollback.getConfigValue());
}
}

输出类似:

1
2
3
4
5
6
7
8
创建配置,key = order.timeout.seconds,value = 30
保存配置快照,configKey = order.timeout.seconds
更新配置,key = order.timeout.seconds,value = 60
保存配置快照,configKey = order.timeout.seconds
更新配置,key = order.timeout.seconds,value = 120
当前配置值:120
回滚配置,key = order.timeout.seconds,value = 60
回滚后配置值:60

这就是备忘录模式在后端配置回滚中的典型应用。

chapter 17:为什么修改前保存快照

在配置更新里,一般是在修改前保存快照。

1
2
3
4
5
ConfigSnapshot snapshot = document.createSnapshot(operator);

snapshotRepository.save(snapshot);

document.update(...);

这样快照保存的是“旧状态”。

如果新配置有问题,可以恢复旧状态。

如果修改后再保存快照,那保存的是新状态,不适合做回滚。

当然,不同场景也可以同时保存修改前和修改后:

1
2
beforeSnapshot
afterSnapshot

这更适合审计和变更对比。

chapter 18:备忘录模式和审计日志

备忘录和审计日志看起来有点像,但目标不一样。

对比项 备忘录 审计日志
核心目的 恢复状态 记录谁在什么时候做了什么
保存内容 对象状态快照 操作行为、操作人、时间、结果
是否用于回滚 不一定
是否要求完整状态 通常需要 不一定
示例 配置快照 管理员修改配置日志

配置系统里,两者可以同时存在。

例如:

  • 快照表用于回滚;
  • 审计表用于追责和查看变更记录。

不要把审计日志当成备忘录。

如果日志只写了:

1
admin 修改了配置 order.timeout.seconds

那是没法恢复配置值的。

chapter 19:备忘录模式和命令模式的区别

备忘录模式和命令模式都能支持撤销,但它们的角度不同。

对比项 备忘录模式 命令模式
核心目的 保存和恢复对象状态 将请求封装成对象
撤销方式 恢复之前的状态快照 执行 undo 或补偿命令
关注点 状态 操作
典型对象 Memento Command
示例 恢复配置旧版本 撤销取消订单命令

备忘录模式适合:

1
回到某个历史状态

命令模式适合:

1
撤销某次操作

有些场景两者可以结合。

例如文本编辑器:

  • 命令模式封装每次编辑操作;
  • 备忘录模式保存编辑前后的文本状态。

chapter 20:备忘录模式和原型模式的区别

备忘录模式和原型模式都和复制对象状态有关。

但目的不同。

对比项 备忘录模式 原型模式
核心目的 保存状态以便恢复 复制对象创建新对象
关注点 历史状态 对象创建
是否强调封装 强调 不一定
是否有管理者 通常有 Caretaker 通常有原型工厂或客户端
示例 保存配置快照 复制报表模板

原型模式是:

1
我想复制一个新对象。

备忘录模式是:

1
我想保存当前状态,以后可能恢复。

复制只是手段,恢复才是备忘录模式的目的。

chapter 21:备忘录模式和状态模式的区别

状态模式关注对象行为随状态变化而变化。

备忘录模式关注保存和恢复状态。

对比项 备忘录模式 状态模式
核心目的 保存和恢复对象状态 根据状态改变对象行为
关注点 状态快照 状态行为
是否需要历史 通常需要 不一定
示例 回滚配置版本 订单 CREATED/PAID/SHIPPED 状态流转

订单状态机适合状态模式。

订单修改前保存快照适合备忘录模式。

chapter 22:备忘录模式和事件溯源的区别

事件溯源,Event Sourcing,是另一种保存历史的方式。

它不直接保存对象快照,而是保存导致状态变化的一系列事件。

例如订单:

1
2
3
4
OrderCreated
OrderPaid
OrderShipped
OrderCancelled

通过重放事件,可以还原状态。

备忘录模式保存的是状态快照:

1
订单当前完整状态

对比如下:

对比项 备忘录模式 事件溯源
保存内容 状态快照 状态变化事件
恢复方式 直接恢复快照 重放事件恢复
存储成本 快照可能较大 事件较小但数量多
历史解释能力 一般 很强
实现复杂度 较低 较高
典型场景 配置回滚、草稿恢复 金融账本、审计强系统

如果只是做配置版本回滚,备忘录模式就够了。

如果需要完整追溯每次状态变化的业务含义,可以考虑事件溯源。

别为了一个配置回滚引入完整事件溯源。用牛刀杀鸡还嫌鸡不够架构,这就过了。

chapter 23:备忘录模式和数据库快照表

在后端项目中,备忘录经常落地为数据库快照表。

例如配置快照表:

1
2
3
4
5
6
7
8
9
10
config_snapshot
id
config_key
config_value
description
enabled
original_updated_by
original_updated_at
snapshot_by
snapshot_at

每次修改配置前插入一条快照记录。

回滚时选择某个快照,把配置表恢复到该状态。

这种设计非常常见。

它就是备忘录模式的持久化版本。

chapter 24:Spring Boot 中落地备忘录模式

在 Spring Boot 项目中,可以这样分层:

1
2
3
4
5
6
Controller
-> ConfigApplicationService
-> ConfigRepository
-> ConfigSnapshotRepository
-> ConfigDocument
-> ConfigSnapshot

示例:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
@Service
public class ConfigApplicationService {

private final ConfigRepository configRepository;

private final ConfigSnapshotRepository snapshotRepository;

public ConfigApplicationService(ConfigRepository configRepository,
ConfigSnapshotRepository snapshotRepository) {
this.configRepository = configRepository;
this.snapshotRepository = snapshotRepository;
}

@Transactional(rollbackFor = Exception.class)
public void updateConfig(UpdateConfigCommand command) {
ConfigDocument document = configRepository.findByKey(command.getConfigKey())
.orElseThrow(() -> new IllegalArgumentException("config not found"));

ConfigSnapshot snapshot = document.createSnapshot(command.getOperator());

snapshotRepository.save(snapshot);

document.update(
command.getConfigValue(),
command.getDescription(),
command.isEnabled(),
command.getOperator()
);

configRepository.save(document);
}

@Transactional(rollbackFor = Exception.class)
public void rollback(RollbackConfigCommand command) {
ConfigDocument document = configRepository.findByKey(command.getConfigKey())
.orElseThrow(() -> new IllegalArgumentException("config not found"));

ConfigSnapshot snapshot = snapshotRepository.findById(command.getSnapshotId())
.orElseThrow(() -> new IllegalArgumentException("snapshot not found"));

document.restore(snapshot, command.getOperator());

configRepository.save(document);
}
}

这个结构中:

  • ConfigDocument 是发起人;
  • ConfigSnapshot 是备忘录;
  • ConfigSnapshotRepository 是管理者的一部分;
  • ConfigApplicationService 负责编排用例和事务。

chapter 25:UpdateConfigCommand 示例

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
33
34
35
36
37
38
39
40
41
42
43
44
public class UpdateConfigCommand {

private final String configKey;

private final String configValue;

private final String description;

private final boolean enabled;

private final String operator;

public UpdateConfigCommand(String configKey,
String configValue,
String description,
boolean enabled,
String operator) {
this.configKey = configKey;
this.configValue = configValue;
this.description = description;
this.enabled = enabled;
this.operator = operator;
}

public String getConfigKey() {
return configKey;
}

public String getConfigValue() {
return configValue;
}

public String getDescription() {
return description;
}

public boolean isEnabled() {
return enabled;
}

public String getOperator() {
return operator;
}
}

回滚命令:

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
public class RollbackConfigCommand {

private final String configKey;

private final Long snapshotId;

private final String operator;

public RollbackConfigCommand(String configKey, Long snapshotId, String operator) {
this.configKey = configKey;
this.snapshotId = snapshotId;
this.operator = operator;
}

public String getConfigKey() {
return configKey;
}

public Long getSnapshotId() {
return snapshotId;
}

public String getOperator() {
return operator;
}
}

这里也能看到命令模式和备忘录模式可以组合使用。

命令表达“修改配置”和“回滚配置”这两个操作。

备忘录保存配置的历史状态。

chapter 26:快照应该保存全量还是增量

备忘录模式落地时,一个常见问题是:

快照保存全量状态,还是只保存变化字段?

1. 全量快照

全量快照保存对象完整状态。

优点:

  • 恢复简单;
  • 逻辑清晰;
  • 不依赖其他版本;
  • 查询某个版本方便。

缺点:

  • 占用存储更多;
  • 对大对象不友好。

2. 增量快照

增量快照只保存变化字段。

优点:

  • 存储更省;
  • 适合大对象或频繁变更。

缺点:

  • 恢复复杂;
  • 需要合并多个增量;
  • 容易出错;
  • 查询历史版本更麻烦。

3. 实践建议

普通配置、表单、草稿、规则对象,优先全量快照。

只有对象非常大、变更非常频繁时,再考虑增量快照。

不要一开始就为了省一点存储,把恢复逻辑搞成考古现场。

chapter 27:快照数量如何控制

备忘录模式可能产生大量快照。

如果每次修改都保存一份,长期运行后快照表会越来越大。

常见控制策略:

1. 限制最近 N 个版本

例如每个配置只保留最近 20 个快照。

1
只保留最近 20 个版本

2. 按时间清理

例如只保留 90 天内的快照。

1
snapshot_at >= now - 90 days

3. 关键版本永久保存

普通修改版本定期清理,但发布版本永久保存。

4. 手动标记版本

用户可以标记某个版本为“稳定版本”,不被清理。

5. 冷数据归档

历史快照归档到低成本存储。

实践中要结合业务重要性和存储成本。

chapter 28:快照恢复时要注意什么

恢复快照不是简单复制字段。

真实项目中要考虑很多问题。

1. 快照是否属于当前对象

必须校验:

1
2
3
if (!configKey.equals(snapshot.getConfigKey())) {
throw new IllegalArgumentException("snapshot not match");
}

否则可能拿错快照。

2. 当前对象是否允许回滚

有些状态不允许回滚。

例如已经发布到外部系统的配置,可能需要审批后回滚。

3. 回滚本身是否也要生成快照

通常建议回滚前也保存当前状态。

这样回滚之后还能再恢复回来。

4. 是否需要记录审计日志

回滚是高风险操作,必须记录:

  • 谁回滚;
  • 回滚到哪个版本;
  • 回滚前状态;
  • 回滚原因;
  • 操作时间。

5. 是否需要通知下游

配置回滚后,可能需要通知应用刷新缓存。

例如:

  • 发布配置变更事件;
  • 刷新本地缓存;
  • 通知网关;
  • 通知业务服务。

备忘录模式只负责状态保存和恢复。

这些工程问题还要额外处理。

chapter 29:复杂对象快照中的深拷贝问题

如果对象内部包含可变集合或引用对象,创建快照时要注意深拷贝。

错误示例:

1
2
3
4
5
6
7
8
public class RuleConfig {

private List<String> rules;

public RuleSnapshot createSnapshot() {
return new RuleSnapshot(rules);
}
}

如果 RuleSnapshot 直接保存 rules 引用,那么原对象后续修改 rules,快照也会被影响。

正确做法:

1
2
3
public RuleSnapshot createSnapshot() {
return new RuleSnapshot(List.copyOf(rules));
}

快照中也要防止被修改:

1
2
3
4
5
6
7
8
9
10
11
12
public class RuleSnapshot {

private final List<String> rules;

public RuleSnapshot(List<String> rules) {
this.rules = List.copyOf(rules);
}

public List<String> getRules() {
return rules;
}
}

快照必须是稳定的历史记录。

不要让它和原对象共享可变内部状态。

chapter 30:备忘录模式的优点

1. 支持状态恢复

对象可以恢复到历史状态。

2. 不破坏封装

状态保存和恢复由对象自己完成,外部不需要知道内部细节。

3. 简化撤销和回滚

非常适合撤销、重做、配置回滚、草稿恢复。

4. 历史状态可管理

可以通过 Caretaker 保存多个快照。

5. 职责清晰

Originator 管状态,Memento 存状态,Caretaker 管历史。

chapter 31:备忘录模式的缺点

1. 可能占用大量内存或存储

如果对象很大,快照很多,成本会很高。

2. 深拷贝复杂

复杂对象创建快照时要考虑引用对象和集合。

3. 快照管理复杂

需要考虑快照数量、清理策略、版本选择、权限控制。

4. 恢复逻辑可能有业务限制

不是所有状态都能随便恢复。

5. 容易和审计日志混淆

快照用于恢复,日志用于记录行为,两者不是一回事。

chapter 32:适用场景

备忘录模式适合以下场景。

1. 需要撤销或重做

例如文本编辑器、图形编辑器、表单编辑。

2. 需要保存草稿

例如文章草稿、配置草稿、流程草稿。

3. 需要版本回滚

例如配置中心、规则引擎、模板管理。

4. 需要保存对象历史状态

例如审批单、工作流上下文、复杂表单。

5. 不希望暴露对象内部状态

外部只保存备忘录,不直接访问对象字段。

chapter 33:不适合使用的场景

以下场景不适合使用备忘录模式。

1. 对象状态很小且不需要恢复

没有恢复需求,就没必要保存快照。

2. 对象非常大且变化频繁

快照成本可能太高。

3. 状态不可逆或恢复意义不大

例如短信已经发出、外部支付已经完成。

4. 更适合事件溯源的场景

如果业务需要完整追踪每次变化原因和事件流,事件溯源可能更合适。

5. 只需要审计,不需要恢复

那用审计日志即可,不一定要快照。

chapter 34:真实项目中的实践建议

1. 修改前保存快照

大多数回滚场景都应该在修改前保存旧状态。

2. 快照对象尽量不可变

字段 final,集合使用不可变副本。

1
2
List.copyOf(list)
Map.copyOf(map)

3. 快照恢复要校验归属

不要让 A 对象恢复 B 对象的快照。

4. 回滚前也保存当前状态

这样可以支持“回滚后再恢复”。

5. 快照表要有清理策略

不要无限增长。

6. 区分快照和审计日志

快照用于恢复,审计用于追踪行为。

7. 大对象考虑增量快照或压缩

但不要过早复杂化。

8. 分布式配置回滚要通知缓存刷新

快照恢复只是数据库状态变了,应用缓存也要刷新。

9. 回滚操作要做权限控制

不是所有人都应该能回滚生产配置。

10. 不要把备忘录当万能后悔药

外部系统动作、真实资金动作、消息发送这类行为,不一定能靠快照恢复。

chapter 35:完整案例代码汇总

配置文档

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import java.time.LocalDateTime;

public class ConfigDocument {

private final String configKey;

private String configValue;

private String description;

private boolean enabled;

private String updatedBy;

private LocalDateTime updatedAt;

public ConfigDocument(String configKey,
String configValue,
String description,
boolean enabled,
String updatedBy) {
if (configKey == null || configKey.isBlank()) {
throw new IllegalArgumentException("configKey can not be blank");
}

this.configKey = configKey;
this.configValue = configValue;
this.description = description;
this.enabled = enabled;
this.updatedBy = updatedBy;
this.updatedAt = LocalDateTime.now();
}

public ConfigSnapshot createSnapshot(String snapshotBy) {
return new ConfigSnapshot(
configKey,
configValue,
description,
enabled,
updatedBy,
updatedAt,
snapshotBy,
LocalDateTime.now()
);
}

public void restore(ConfigSnapshot snapshot, String operator) {
if (!this.configKey.equals(snapshot.getConfigKey())) {
throw new IllegalArgumentException("snapshot configKey not match");
}

this.configValue = snapshot.getConfigValue();
this.description = snapshot.getDescription();
this.enabled = snapshot.isEnabled();
this.updatedBy = operator;
this.updatedAt = LocalDateTime.now();
}

public void update(String configValue,
String description,
boolean enabled,
String operator) {
this.configValue = configValue;
this.description = description;
this.enabled = enabled;
this.updatedBy = operator;
this.updatedAt = LocalDateTime.now();
}

public String getConfigKey() {
return configKey;
}

public String getConfigValue() {
return configValue;
}

public String getDescription() {
return description;
}

public boolean isEnabled() {
return enabled;
}

public String getUpdatedBy() {
return updatedBy;
}

public LocalDateTime getUpdatedAt() {
return updatedAt;
}
}

配置快照

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import java.time.LocalDateTime;

public class ConfigSnapshot {

private final String configKey;

private final String configValue;

private final String description;

private final boolean enabled;

private final String originalUpdatedBy;

private final LocalDateTime originalUpdatedAt;

private final String snapshotBy;

private final LocalDateTime snapshotAt;

public ConfigSnapshot(String configKey,
String configValue,
String description,
boolean enabled,
String originalUpdatedBy,
LocalDateTime originalUpdatedAt,
String snapshotBy,
LocalDateTime snapshotAt) {
this.configKey = configKey;
this.configValue = configValue;
this.description = description;
this.enabled = enabled;
this.originalUpdatedBy = originalUpdatedBy;
this.originalUpdatedAt = originalUpdatedAt;
this.snapshotBy = snapshotBy;
this.snapshotAt = snapshotAt;
}

public String getConfigKey() {
return configKey;
}

public String getConfigValue() {
return configValue;
}

public String getDescription() {
return description;
}

public boolean isEnabled() {
return enabled;
}

public String getOriginalUpdatedBy() {
return originalUpdatedBy;
}

public LocalDateTime getOriginalUpdatedAt() {
return originalUpdatedAt;
}

public String getSnapshotBy() {
return snapshotBy;
}

public LocalDateTime getSnapshotAt() {
return snapshotAt;
}
}

快照仓库

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
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;

public class ConfigSnapshotRepository {

private final List<ConfigSnapshot> snapshots = new ArrayList<>();

public void save(ConfigSnapshot snapshot) {
snapshots.add(snapshot);

System.out.println("保存配置快照,configKey = " + snapshot.getConfigKey()
+ ",snapshotAt = " + snapshot.getSnapshotAt());
}

public Optional<ConfigSnapshot> findLatest(String configKey) {
return snapshots.stream()
.filter(snapshot -> snapshot.getConfigKey().equals(configKey))
.max(Comparator.comparing(ConfigSnapshot::getSnapshotAt));
}

public List<ConfigSnapshot> findAll(String configKey) {
return snapshots.stream()
.filter(snapshot -> snapshot.getConfigKey().equals(configKey))
.sorted(Comparator.comparing(ConfigSnapshot::getSnapshotAt).reversed())
.toList();
}
}

配置服务

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import java.util.HashMap;
import java.util.Map;

public class ConfigService {

private final Map<String, ConfigDocument> configStore = new HashMap<>();

private final ConfigSnapshotRepository snapshotRepository;

public ConfigService(ConfigSnapshotRepository snapshotRepository) {
this.snapshotRepository = snapshotRepository;
}

public void createConfig(String key,
String value,
String description,
boolean enabled,
String operator) {
ConfigDocument document = new ConfigDocument(
key,
value,
description,
enabled,
operator
);

configStore.put(key, document);

System.out.println("创建配置,key = " + key + ",value = " + value);
}

public void updateConfig(String key,
String value,
String description,
boolean enabled,
String operator) {
ConfigDocument document = getConfig(key);

ConfigSnapshot snapshot = document.createSnapshot(operator);

snapshotRepository.save(snapshot);

document.update(value, description, enabled, operator);

System.out.println("更新配置,key = " + key + ",value = " + value);
}

public void rollbackLatest(String key, String operator) {
ConfigDocument document = getConfig(key);

ConfigSnapshot snapshot = snapshotRepository.findLatest(key)
.orElseThrow(() -> new IllegalStateException("No snapshot found, key = " + key));

document.restore(snapshot, operator);

System.out.println("回滚配置,key = " + key + ",value = " + document.getConfigValue());
}

public ConfigDocument getConfig(String key) {
ConfigDocument document = configStore.get(key);

if (document == null) {
throw new IllegalArgumentException("config not found, key = " + key);
}

return document;
}
}

客户端

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
33
34
35
36
37
38
39
40
41
42
public class ConfigMementoDemo {

public static void main(String[] args) {
ConfigSnapshotRepository snapshotRepository = new ConfigSnapshotRepository();

ConfigService configService = new ConfigService(snapshotRepository);

configService.createConfig(
"order.timeout.seconds",
"30",
"订单超时时间",
true,
"admin"
);

configService.updateConfig(
"order.timeout.seconds",
"60",
"订单超时时间调整为 60 秒",
true,
"admin"
);

configService.updateConfig(
"order.timeout.seconds",
"120",
"订单超时时间调整为 120 秒",
true,
"admin"
);

ConfigDocument current = configService.getConfig("order.timeout.seconds");

System.out.println("当前配置值:" + current.getConfigValue());

configService.rollbackLatest("order.timeout.seconds", "rollback-admin");

ConfigDocument afterRollback = configService.getConfig("order.timeout.seconds");

System.out.println("回滚后配置值:" + afterRollback.getConfigValue());
}
}

chapter 36:一句话总结

备忘录模式的本质是:

在不破坏对象封装性的前提下,保存对象某一时刻的状态,并在需要时恢复到该状态。

它特别适合:

  • 撤销;
  • 重做;
  • 草稿保存;
  • 配置回滚;
  • 版本快照;
  • 游戏存档;
  • 表单恢复;
  • 复杂对象状态恢复。

备忘录模式最关键的不是“复制字段”,而是:

谁有权创建快照,谁有权恢复状态,谁只负责保管快照。

好的备忘录模式像可靠存档:保存稳定、恢复准确、边界清楚。

坏的备忘录模式像截图备份数据库:看起来有历史,真要恢复时只能干瞪眼。

参考资料

  • Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software.
  • Robert C. Martin. Agile Software Development, Principles, Patterns, and Practices.
  • Joshua Bloch. Effective Java.
  • Martin Fowler. Patterns of Enterprise Application Architecture.
  • Eric Evans. Domain-Driven Design: Tackling Complexity in the Heart of Software.
  • Refactoring Guru: Memento Pattern.
  • SourceMaking: Memento Design Pattern.

启示录

富贵岂由人,时会高志须酬。

能成功于千载者,必以近察远。


设计模式:备忘录模式
https://allendericdalexander.github.io/2026/04/01/java/design/19memento-pattern-blog/
作者
AtLuoFu
发布于
2026年4月1日
许可协议