有状态高可用:LSM存储引擎示例
10.5 持久化例子:LSM的存储引擎
本节以一个简化的Java版本的版本LSM数据引擎实现来说明如何实现WAL和快照。例子代码可以如下克隆
git clone https://gitee.com/distributed-system-arc/lsm-java如果运行单元测试
mvn test也可以运行性能测试,了解LSM-Tree的读写性能
mvn clean package
java -jar jmh/target/jmh.jar PutTest GetTestLSM-Tree(Log-Structured Merge-Tree)日志结构合并树,此存储引擎专门用于写多读少的业务场景,典型的在NOSQL中实现:RocksDB、LevelDB、SlateDB,Cassandra、Clickhouse,TiKV、InfluxDB,Prometheus TSDB等,Apache Doris和Apache IOT也是基于LSM变种实现。
注:例子参考了https://github.com/brianxiadong/java-lsm-tree 以及 https://skyzh.github.io/mini-lsm/00-overview.html的rust实现
注:SlateDB作者也是DDIA第二版的作者
10.5.1 LSM 介绍
LSM-Tree(Log-Structured Merge-Tree,日志结构合并树)是一种面向写优化的存储结构,核心思想是把随机写转化为顺序写,通过immutable Memtable缓冲和快照的分层合并来获得极高的写入吞吐。在执行PUT操作时候它不会修改内存或者快照的的数据,而是顺序添加。当执行Get操作时候,它只需要读取到内存或者快照中的最新数据即可。当删除数据时候,顺序添加一个Value为空的键值对。
它的架构如下图
LSM-Tree的各个组件说明如下,每个组件都是为了高速写入而设计:
- MemTable :内存有序表(本例子使用ConcurrentSkipListMap实现),新数据写到内存,并记录到预写日志里。
- WAL: 写入操作先追加到 WAL,防止宕机丢数据,采用顺序 IO有极高的性能。
- Immutable MemTable : 系统检测到当前的MemTable容量超过一定阈值,将生成空的MemTable代替当前使用的 MemTable。 MemTable转为Immutable MemTable 后不再接受操作。等待后台线程刷新到磁盘。刷新成功后,删除此Immutable Memtable以及对应的WAL。可以理解Immutable MemTable 是一个生成快照前的缓冲区。
- SSTable(Sorted String Table):后台线程定时检测到有Immutable MemTable,并成对应的一个SSTable文件。文件包含了MemTable内存Key-Value序列化数据。
- SSTable合并:SSTable文件个数达到一定数量,将合并生成一个新的SSTable文件。SSTable采用分层管理,每一层的多个SSTable文件会再次合并生成一个下一层的新的SSTable。如下MemTable合并生成SSTable-0-L0(这里L0指的是第一层),SSTable-1-L0等多个第一层文件,后台程序将检查此层文件,达到个数阈值,比如4个L0层文件后,将合并生成L1层SSTable。限于篇幅,本文不介绍SSTable合并。可以参考本书例子源码LSMTree.performCompaction
如果没有Immutable MemTable而是直接使用MemTable生成快照,必须停止操作影响存储引擎的性能
下面代码片段展示了MemTable对象,重要的字段如下
- data,用于内存中Key—Value 读写。这里使用ConcurrentSkipListMap是因为 快照文件里,需要保证Key的有序。
- log,预写日志,PUT或者Del操作,需要调用此类记录操作内容
public class MemTable {
private final ConcurrentSkipListMap<String, KeyValue> data;
private final int maxSize;
private volatile int currentSize;
private WriteAheadLog log
public MemTable(int maxSize) {
this.data = new ConcurrentSkipListMap<>();
this.maxSize = maxSize;
this.currentSize = 0;
}
public void put(String key, String value) {
wal.append(WriteAheadLog.LogEntry.put(key, value));
KeyValue kv = new KeyValue(key, value);
data.put(key, kv);
}
public void delete(String key){...}
public String get(String key){}
}LSM-Tree是存储引擎,负责了数据的读取和更新,以及快照等操作。
public class LSMTree implements AutoCloseable {
private final String dataDir;
private final int memTableMaxSize;
// 内存数据
private MemTable activeMemTable;
private List<MemTable> immutableMemTables = new CopyOnWriteArrayList<>();
// 磁盘数据
private List<SSTable> ssTables = new CopyOnWriteArrayList<>();
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(3);
public LSMTree(String dataDir, int memTableMaxSize){
this.dataDir = dataDir;
this.memTableMaxSize = memTableMaxSize;
// 初始化目录
createDirectoryIfNotExists(dataDir);
//恢复数据,获得最大一个wal日志编号
Integer index = recover();
WriteAheadLog wal = new WriteAheadLog(dataDir ,index+1);
// 初始化组件
this.activeMemTable = new MemTable(memTableMaxSize,wal);
// 初始化压缩策略
this.compactionStrategy = new CompactionStrategy(dataDir, 4, 10);
}
public String get(String key){...}
public void put(String key, String value)(...)
public void recover(){...}- dataDir: 日志和快照的存储目录
- memTableMaxSize: MemTable的容量,以字符为单位计算。
- activeMemTable:当前使用的MemTable
- immutableMemTables:不可变的MemTable存放在此,等待快照到磁盘上
- ssTables: 多个快照文件对应的类。
- scheduler:定时调度器,用于定时生成快照文件,合并快照文件,以及检测MemTable容量
10.5.2 LSM读和写
当对LSM-Tree进行写操作的时候,流程如下
- 获得当前激活的MemTable
- 执行Memtable.put操作,即调用
ConcurrentSkipListMap.put - 记录WAL日志,比如命令
set name xiandafu的WAL日志内容是set|name|xiandafu|时间戳\r\n - 如果是删除操作,则添加一个值为空的数据,比如
set name ""
如下代码片段显示LSMTree.put
public void put(String key, String value) throws IOException {
if (key == null || value == null) {
throw new IllegalArgumentException("Key and value cannot be null");
}
activeMemTable.put(key, value);
}当对LSM-Tree进行读操作的时候,则有可能需要从前激活的Memtable和冻结的Memtable中查询,如果未查询到,则需要按照SSTable层次,依次查询SSTable。在构建SSTable时候,构建了一个布隆过滤器索引。因此不需要遍历整个文件
- 从当前MemTable中查询,如果查询到非空值,则返回。如果查询到空值,表示已经删除
- 如果从Memtable未查询到,则按照时间倒序遍历Immutable MemTable。
- 如果Immutable MemTable 未查询到,则从文件SSTable中,逐级查找。先查找层级一的所有SSTable文件,然后层级二的所有SSTable文件等
- SSTable文件包含此文件所有的Key的索引信息,使用的是布隆过滤器,用于判断Key是否存在此文件中。如果返回存在,则在遍历此文件。
查询SSTable时候,需要注意是
- 布隆过滤器能快速判断Key一定不存在和可能存在,如果不存在,则需要查询下一个SSTable文件。
- 由于存储采用的是有序排序,因此能帮助快速判断此Key是否在此文件中。
// 1. 首先查询活跃MemTable
String value = activeMemTable.get(key);
if (value != null) {
return value;
}
// 2. 查询不可变MemTable(按时间倒序)
for (int i = immutableMemTables.size() - 1; i >= 0; i--) {
value = immutableMemTables.get(i).get(key);
if (value != null) {
return value;
}
}
// 3. 查询SSTable(按创建时间倒序)
List<SSTable> sortedSSTables = new ArrayList<>(ssTables);
sortedSSTables.sort((a, b) -> Long.compare(b.getCreationTime(), a.getCreationTime()));
for (SSTable ssTable : sortedSSTables) {
value = ssTable.get(key);
if (value != null) {
return value;
}
}10.5.3 WAL
类WriteAheadLog负责顺序记录操作日志,每一个MemTable对应一个WAL,当一个MemTable的生成SSTable后,此WAL文件可以删除。
public class WriteAheadLog {
//日志文件名称
private String filePath;
//wal的索引
private Integer index;
BufferedWriter writer;
public WriteAheadLog(String filePath,Integer indexLog) throws IOException {
this.filePath = filePath;
this.index = indexLog;
FileOutputStream fos = new FileOutputStream(getFile(),true);
//强制刷新到磁盘
fos.getChannel().force(false);
writer = new BufferedWriter(new OutputStreamWriter(fos, StandardCharsets.UTF_8));
}
/**
* 追加日志条目
*/
public void append(LogEntry entry) throws IOException {
writer.write(entry.toString());
writer.newLine();
writer.flush();
}
public List<LogEntry> recover() throws IOException{}
}- 构造方法:创建一个只追加,且强制刷新到磁盘的日志文件。如果存储模型允许丢失少量数据,就不需要强制刷盘,比如Redis默认是1秒刷一次。
- append: 添加一条日志,日志之间使用回车换行符号隔开
- recover:用于从wal日志中恢复数据。
ist<LogEntry> entries = new ArrayList<>();
File file = /*日志文件*/
try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
String line;
while ((line = reader.readLine()) != null) {
LogEntry entry = LogEntry.fromString(line);
if (entry != null) {
entries.add(entry);
}
}
}
return entries10.5. 4 后台线程
后台有3个线程,用于执行从MemeTable到SSTable的转化
- startInactiveMemTable,定时检测LSMTree.activeMemTable容量,如果超过阈值,则使用新的Memtable代替,原来的MemTable将添加到LSMTree.immutableMemTables列表中
- flushImmutableMemTable。定时将immutableMemTables列表中的最早的MemTable刷新到磁盘上,生成SSTable
- startBackgroundCompaction:将多个磁盘上的SSTable文件定时合并
protected void startBackGroud(){
//memetable->inactiveMemtable
this.scheduler.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
startInactiveMemTable();
}
},10,1, TimeUnit.SECONDS);
//inactiveMemtable->sstables
this.scheduler.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
flushImmutableMemTable();
}
},15,10, TimeUnit.SECONDS);
// sstable compaction
this.scheduler.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
startBackgroundCompaction();
}
},30,60, TimeUnit.SECONDS);
}10.5.5 SSTable
LSM生成的多个个快照文件的类对应了SSTable,记录在LSMTree.ssTables中。SSTable对象记录文件位置,同时还在数据写入磁盘后,构造了一个布隆过滤器。用于在SSTable中快速查找Key是否存在
public class SSTable {
private final String filePath;
private final BloomFilter bloomFilter;
private final long creationTime;
public SSTable(String filePath, List<KeyValue> sortedData) throws IOException {
this.filePath = filePath;
this.creationTime = System.currentTimeMillis();
this.bloomFilter = new BloomFilter(sortedData.size(), 0.01);
writeToFile(sortedData);
}
//数据写入磁盘
private void writeToFile(List<KeyValue> sortedData){}
//查找key,首先查找BloomFilter,如果返回true,在遍历文件
public String get(String key) {}其查找方法先使用布隆过滤器,然后遍历文件内容查找
public String get(String key) {
// 首先检查布隆过滤器
if (!bloomFilter.mightContain(key)) {
return null;
}
try (DataInputStream dis = new DataInputStream(
new BufferedInputStream(new FileInputStream(filePath)))) {
//遍历文件查找,此时性能最差
}10.5.6 故障恢复
当LSM重启后,需要依次读取快照文件和预习日志文件,构成LSMTree。参考LSMTree.recover方法
private Integer recover() throws IOException {
// 恢复SSTable
File dir = new File(dataDir);
File[] sstFiles = dir.listFiles((d, name) -> name.endsWith(".db"));
if (sstFiles != null&&sstFiles.length!=0) {
Arrays.sort(sstFiles, (a, b) -> Long.compare(a.lastModified(), b.lastModified()));
for (File file : sstFiles) {
SSTable ssTable = new SSTable(file.getAbsolutePath());
ssTables.add(ssTable);
}
}
// 恢复wal
File[] walFiles = dir.listFiles((d, name) -> name.endsWith(".log"));
Integer index = 0;
if (walFiles != null&&walFiles.length!=0) {
Arrays.sort(walFiles, (a, b) -> Long.compare(a.lastModified(), b.lastModified()));
for(int i=0;i<walFiles.length;i++){
File walFile = walFiles[i];
//wal_xxx.log
index = Integer.parseInt(walFile.getName()
.substring(4,walFile.getName().length()-4));
WriteAheadLog writeAheadLog = new WriteAheadLog(dataDir,index);
MemTable memTable = new MemTable(memTableMaxSize,writeAheadLog);
memTable.recover();
this.immutableMemTables.add(memTable);
}
}
return index;
}10.5.7 性能
在我的Mac Pro 6核机器上,按照写读操作占比20%、50%,80%进行测试,参考GetPutMixedTest
Benchmark (putMix) Mode Cnt Score Error Units
GetPutMixedTest.getOrPutRandom 20 thrpt 2 451408.434 ops/s
GetPutMixedTest.getOrPutRandom 50 thrpt 2 161078.750 ops/s
GetPutMixedTest.getOrPutRandom 80 thrpt 2 105165.865 ops/s可见在读写混合操作里,如果是写占比20%,吞吐量为45万TPS。如果是写占比80%,吞吐量是10万TPS
悲观情况下,如果完全从磁盘中读取数据,参考GetFromSSTableTest,当文件存储1万条数据时候,TPS为2805。当文件里存储10万条数据,TPS下降到322。
Benchmark (max) Mode Cnt Score Error Units
GetFromSSTableTest.getRandom 1024 thrpt 2 30555.159 ops/s
GetFromSSTableTest.getRandom 10240 thrpt 2 2805.760 ops/s
GetFromSSTableTest.getRandom 102400 thrpt 2 322.453 ops/s