作者: Unmesh Joshi
译者: java达人
来源: https://martinfowler.com/articles/patterns-of-distributed-systems/
通过将每个状态更改作为命令添加到append only 日志中,从而提供持久性保证,而无需将数据结构刷新到磁盘。
即使在服务器存储数据失败的情况下,也需要强大的持久性保证。服务器确认执行某个操作后,即使它故障并失去所有的内存状态,也应该执行该操作。
将每个状态更改作为命令存储在硬盘上的文件中。为每个服务器进程维护一个日志,该日志被顺序附加。单个日志按顺序附加,简化了重新启动时的日志处理和后续联机操作(当日志附加新命令时)。每个日志条目都有一个唯一的标识符。唯一的日志标识符有助于对日志执行某些其他操作,例如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;
可以在每次重新启动时读取文件,并且可以通过重放所有日志条目来恢复状态。考虑一个简单的内存键值存储:
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操作表示Command,在更新内存哈希之前将其序列化并存储在日志中。
class SetValueCommand…
final String key;
final String value;
public SetValueCommand(String key, String value) {
this.key = key;
this.value = value;
}
@Override
public byte[] serialize() {
try {
var baos = new ByteArrayOutputStream();
var dataInputStream = new DataOutputStream(baos);
dataInputStream.writeInt(Command.SetValueType);
dataInputStream.writeUTF(key);
dataInputStream.writeUTF(value);
return baos.toByteArray();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static SetValueCommand deserialize(InputStream is) {
try {
DataInputStream dataInputStream = new DataInputStream(is);
return new SetValueCommand(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());
}
实现Log时,有一些重要的注意事项。重要的是要确保写入日志文件的条目保留在物理介质上。所有编程语言中提供的文件处理库都提供了一种机制,可以强制操作系统将文件更改“flush”到物理介质。使用flush机制时有一点需要权衡考虑。
flush每个写入磁盘的日志可提供强大的持久性保证(这是将日志放在首位的主要目的),但这会严重限制性能,并很快成为瓶颈。如果flush延迟处理或异步完成,则可以提高性能,但是如果在flush条目之前服务器崩溃,则可能会丢失日志中的条目。大多数实现使用诸如批处理之类的技术来限制flush操作的影响。
另一个注意事项是确保在读取日志时检测到损坏的日志文件。为了解决这个问题,通常在日志条目中写入CRC记录,然后在读取文件时可以对其进行验证。
单个日志文件可能变得难以管理,并且可能很快消耗所有存储空间。为了解决此问题,使用了Segmented Log和Low-Water Mark之类的技术。
预写日志是append-only的。因此,在客户端通信失败和重试的情况下,日志可能包含重复的条目。应用日志条目时,需要确保忽略重复项。如果最终状态是类似HashMap的状态,其中对同一key的更新是幂等的,则不需要特殊的机制。如果不是,则需要实现某种机制,用唯一标识符标记每个请求并检测重复项。
•所有共识算法(例如Zookeeper和RAFT)中的日志实现类似于预写日志
•Kafka中的存储实现遵循与数据库中的提交日志类似的结构
•所有数据库,包括像Cassandra这样的nosql数据库,都使用预写日志技术来保证持久性