注:文章来自于 Write-Ahead Log,作者是在Thoughtworks工作的 Martin Fowler。文章主要写了「预写日志」这一在现代分布式数据库中的常用技术。
预写日志
通过将每个状态更改以命令的形式持久化到日志中,而无需将数据结构刷新到磁盘,来提供持久性保证。
也就是:操作日志。
问题
即时在存储数据的服务器出故障的情况下,也需要数据强完整性的保证。一旦服务器同意执行一个操作,他就应该能执行下去,就算服务器失败重启,丢失所有的内存信心。
解决方案
Figure 1: Write Ahead Log
将每个状态变更以命令的形式存储到硬盘文件中。为每个服务器进程维护一个日志,并按顺序追加。单个日志的顺序追加操作简化了服务重启后的日志处理和后续联机处理工作。每个日志条目都有唯一的标志符。唯一日志标识符有助于在日志上执行某些其他操作,如分段日志( Segmented Log ) 或使用低水位线( Low-Water Mark )清理日志等。日志更新可以使用单一更新队列( Singular Update Queue)来实现。
典型的日志条目结构如下所示:
class WALEntry…
private final Long entryId;
private final byte[] data;
private final EntryType entryType;
private long timeStamp;
每次重新启动时都可以读取该文件,并且可以通过重放所有日志条目来恢复状态。
考虑一个简单的内存kv存储:
class KVStore…
private Map<String, String> kv = new HashMap<>();
public String get(String key) {
return kv.get(key);
}
public void put(String key, String value) {
appendLog(key, value);
kv.put(key, value);
}
private Long appendLog(String key, String value) {
return wal.writeEntry(new SetValueCommand(key, value).serialize());
}
put操作表示为命令,该命令在更新内存哈希映射之前被序列化并存储在日志中。
class SetValueCommand…
final String key;
final String value;
final String attachLease;
public SetValueCommand(String key, String value) {
this(key, value, "");
}
public SetValueCommand(String key, String value, String attachLease) {
this.key = key;
this.value = value;
this.attachLease = attachLease;
}
@Override
public void serialize(DataOutputStream os) throws IOException {
os.writeInt(Command.SetValueType);
os.writeUTF(key);
os.writeUTF(value);
os.writeUTF(attachLease);
}
public static SetValueCommand deserialize(InputStream is) {
try {
DataInputStream dataInputStream = new DataInputStream(is);
return new SetValueCommand(dataInputStream.readUTF(), dataInputStream.readUTF(), dataInputStream.readUTF());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
这可以确保一旦put方法成功返回,即使持有KVStore的进程崩溃,也可以通过在启动时读取日志文件来恢复其状态。
class KVStore…
public KVStore(Config config) {
this.config = config;
this.wal = WriteAheadLog.openWAL(config);
this.applyLog();
}
public void applyLog() {
List<WALEntry> walEntries = wal.readAll();
applyEntries(walEntries);
}
private void applyEntries(List<WALEntry> walEntries) {
for (WALEntry walEntry : walEntries) {
Command command = deserialize(walEntry);
if (command instanceof SetValueCommand) {
SetValueCommand setValueCommand = (SetValueCommand)command;
kv.put(setValueCommand.key, setValueCommand.value);
}
}
}
public void initialiseFromSnapshot(SnapShot snapShot) {
kv.putAll(snapShot.deserializeState());
}
实现考虑
在实现预写日志时,有一些重要的注意事项。最重要的是要确保写入日志文件的条目实际上保存在物理介质上。所有编程语言中提供的文件处理库都提供了一种机制,强制操作系统“刷新”对物理介质的文件更改。在使用冲洗机制时,需要考虑权衡。
刷新磁盘上的每一个日志写操作可以提供很强的持久性保证(这是拥有日志的首要目的),但这严重限制了性能,并可能很快成为瓶颈。如果刷新延迟或异步完成,则会提高性能,但如果服务器在刷新条目之前崩溃,则有可能丢失日志中的条目。大多数实现使用批处理等技术来限制刷新操作的影响。
另一个注意事项是确保在读取日志时检测到损坏的日志文件。为了处理这个问题,日志条目通常使用CRC记录写入,然后在读取文件时可以验证CRC记录。
单个日志文件可能变得难以管理,并且可能会快速占用所有存储空间。为了解决这个问题,使用了分段日志和低水位线等技术。
预写日志仅支持追加的。由于这种行为,在客户端通信失败和重试的情况下,日志可能包含重复的条目。应用日志条目时,需要确保忽略重复项。如果最终状态类似于HashMap,其中对同一密钥的更新是幂等的,则不需要特殊的机制。如果不是,则需要实现某种机制,用唯一标识符标记每个请求并检测重复项。
例子
- 所有一致性算法服务如zookeeper和raft中的日志实现都类似于预写日志
- kafka中的存储实现
- 所有数据库,包括像Cassandra这样的nosql数据库,都使用预写日志技术来保证持久性
发现还有一些人进行了翻译,罗列如下:
分布式系统设计模式 - 预写日志(Write Ahead Log)_张哈希的博客-CSDN博客
分布式系统模式2-Write-Ahead Log_XMEHB的博客-CSDN博客