功能点 4:LogStore 日志存储引擎 —— 源码阅读笔记
对应源码阅读计划功能点 4:LogTablet → LogSegment → .log/.index 文件的完整写入和读取路径。
笔记 4.1:LogSegment —— 段文件管理
文件:LogSegment.java、OffsetIndex.java、AbstractIndex.java
路径:fluss-server/src/main/java/org/apache/fluss/server/log/
1. LogSegment 的核心数据结构
public class LogSegment {
private final long baseOffset; // 这个段的基础偏移量 (文件名)
private final File logFile; // .log 文件
private final OffsetIndex offsetIndex; // .index 文件(偏移→位置)
private final TimeIndex timeIndex; // .timeindex 文件(时间→偏移)
private long maxTimestamp; // 段内最大时间戳
// ===== 写入接口 =====
public LogAppendInfo append(LogRecordBatch batch) {
// 1. 检查 batch 大小是否超过 maxMessageSize
// 2. 检查 batch 的 baseOffset 是否递增(防乱序)
// 3. 写入 .log 文件
// 4. 每 4KB 写入一次 .index(稀疏索引)
// 5. 更新 maxTimestamp
}
// ===== 读取接口 =====
public LogReadInfo read(
long startOffset, // 起始偏移
int maxSize, // 最大读取字节
int maxCount, // 最大读取条数
FetchIsolation isolation // READ_COMMITTED / READ_UNCOMMITTED
) {
// 1. 通过 OffsetIndex 查找 startOffset 对应的物理位置
long position = offsetIndex.lookup(startOffset);
// 2. 从 .log 文件的 position 处开始读取
// 3. 逐个读取 batch,直到达到 maxSize 或 maxCount
// 4. 返回读取结果
}
}
2. 物理文件布局
Segment 目录结构:
/data/fluss/{db}/{table}/{partition}/{bucket}/
├── 00000000000000000000.log ← Segment 0: offset [0, 9999]
├── 00000000000000000000.index ← 稀疏索引
├── 00000000000000000000.timeindex ← 时间索引(可选)
├── 00000000000000010000.log ← Segment 1: offset [10000, 19999]
├── 00000000000000010000.index
├── 00000000000000020000.log ← Segment 2: offset [20000, ...]
└── 00000000000000020000.index
命名规则:
文件名 = 20位十进制数字,表示 baseOffset(段内第一条消息的 offset)
例如:00000000000000010000.log → baseOffset = 10000
3. OffsetIndex 的二分查找
public class OffsetIndex extends AbstractIndex {
// 索引条目结构:{relativeOffset → physicalPosition}
// relativeOffset = actualOffset - baseOffset (节省存储空间)
/**
* 查找 >= targetOffset 的最小偏移量对应的物理位置
*/
public OffsetPosition lookup(long targetOffset) {
// 转为相对偏移
int relativeOffset = (int)(targetOffset - baseOffset);
// ★ 二分查找(mmap 内存映射文件)
// 索引按 offset 递增排序,O(log N)
int slot = binarySearch(relativeOffset);
if (slot < 0) {
// 精确匹配失败,返回最近的比 target 小的条目
slot = -(slot + 1) - 1;
}
// 读取该条目的物理位置
int position = readPhysicalPosition(slot);
return new OffsetPosition(targetOffset, position);
}
}
关键优化:
- 索引使用 mmap(内存映射文件)而非
RandomAccessFile——操作系统负责 page cache 管理,读取性能接近内存 - 稀疏索引:不是每条消息都建索引,而是每
log.index.interval.bytes(默认 4KB)建一条 - 相对偏移:条目中存储的是相对于 baseOffset 的差值,32位足够表示 4GB 的 Segment
4. 稀疏索引原理图
.log 文件内容:
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
│B0 │B1 │B2 │B3 │B4 │B5 │B6 │B7 │ ← 消息批次
│0~512 │512~1K│1K~1.5│1.5~2K│2~2.5K│2.5~3K│3~3.5K│3.5~4K│
└──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
.index 稀疏索引(每 4KB 一条):
┌───────────┬───────────┐
│ 消息0 │ 位置 0 │ ← 第一条
│ 消息8 │ 位置 4096 │ ← 4KB 处
│ 消息16 │ 位置 8192 │ ← 8KB 处
│ ... │ ... │
└───────────┴───────────┘
查询 offset=10 的流程:
1. 二分查找 .index → 找到 entry (消息8, 位置4096)
2. 从 .log 的位置 4096 处开始顺序扫描
3. 逐条读取 batch,直到找到 offset=10
4. 最坏情况:顺序扫描 4KB / (batchSize) 条记录
笔记 4.2:LogTablet —— Tablet 级日志管理
文件:LogTablet.java
核心结构
public class LogTablet {
private final long tabletId;
private final TablePath tablePath;
// 所有 Segment 按 baseOffset 排序
private final ConcurrentSkipListMap<Long, LogSegment> segments;
// 写入状态
private volatile long logEndOffset; // LEO (Log End Offset)
private volatile long highWatermark; // HW (High Watermark)
private volatile LogSegment activeSegment; // 当前活跃 Segment
// ===== 写入 =====
public long append(LogRecordBatch batch) {
// 1. 检查是否需要 Roll(滚动创建新 Segment)
if (activeSegment.size() >= maxSegmentSize // 大小阈值
|| activeSegment.age() >= maxSegmentAge // 时间阈值
|| batch.offset() - activeSegment.baseOffset > maxIndexSize) {
rollNewSegment();
}
// 2. 写入当前活跃 Segment
long offset = activeSegment.append(batch);
logEndOffset = offset + 1;
return offset;
}
// ===== 读取 =====
public FetchDataInfo read(long startOffset, int maxBytes, FetchIsolation isolation) {
// 1. 找到 startOffset 所在的 Segment
LogSegment segment = findSegment(startOffset);
// 2. 检查隔离级别
long maxReadable = isolation == FetchIsolation.READ_COMMITTED
? highWatermark // 只读到 HW
: logEndOffset; // 读到 LEO
// 3. 读取数据
return segment.read(startOffset, maxBytes, maxReadable);
}
// ===== 清理 =====
public void deleteSegmentsBefore(long offset) {
// 删除 baseOffset < offset 的所有 Segment
// 释放磁盘空间
}
}
关键概念:LEO 和 HW
← 已提交 (可被所有消费者读取) →
← 未提交 (仅 Leader 可见) →
│ │
┌───────────┼───────────────────────────────┼──────────┐
│ Segment 0 │ Segment 1 │ │
│ [0,9999] │ [10000, HW=15000, LEO=20000] │
└───────────┴───────────────────────────────┴──────────┘
│ │
HW (High Watermark) LEO (Log End Offset)
└─ ISR 都已确认 ─┘ └─ Leader 已写入 ─┘
- LEO (Log End Offset):Leader 已写入的最新 offset(包含未复制到 Follower 的数据)
- HW (High Watermark):所有 ISR 成员都已确认的 offset(消费者可安全读取)
Segment Roll(滚动)条件
// LogTablet 决定是否创建新 Segment
private boolean shouldRoll(LogSegment current) {
return current.size() >= maxSegmentSize // 1. 达到大小上限(默认 1GB)
|| current.age() >= maxSegmentAge // 2. 存活时间超限(默认 7 天)
|| current.indexFull(); // 3. 索引已满(32 位偏移不够用)
}
笔记 4.3:WriterStateManager —— 幂等写入
文件:WriterStateManager.java
幂等性设计
在分布式系统中,网络重试可能导致消息重复。Fluss 通过 WriterStateManager 实现生产者幂等:
public class WriterStateManager {
/**
* 每个写入器的状态
*/
static class WriterState {
long producerId; // 生产者唯一 ID(会话级别)
short producerEpoch; // 生产者 Epoch(会话隔离)
int lastSequence; // 该 producer 上一次写入的 Sequence Number
}
/**
* 验证幂等性并按顺序处理写入
*/
public WriteResult validateAndUpdate(long producerId, short epoch, int sequence) {
WriterState state = getOrCreate(producerId);
// 1. Epoch 检查:更新 Epoch → 清空旧状态
if (epoch > state.producerEpoch) {
state.reset(epoch);
} else if (epoch < state.producerEpoch) {
return WriteResult.FENCED; // 旧 Epoch 的请求,拒绝
}
// 2. Sequence 检查
if (sequence == state.lastSequence + 1) {
// 正常递增:处理
state.lastSequence = sequence;
return WriteResult.VALID;
} else if (sequence <= state.lastSequence) {
// 重复消息:忽略(已处理过)
return WriteResult.DUPLICATE;
} else {
// 跳号:数据丢失,需要抛出异常
return WriteResult.OUT_OF_ORDER;
}
}
}
设计关键:
- Epoch:每当 Producer 重启或断连重连时递增,用于隔离不同会话
- Sequence Number:单次会话内单调递增,用于检测重复和乱序
- 配合 Follower 复制时,Follower 也会维护同样的 WriterState,确保切换时幂等性不丢失
笔记 4.4:LogManager 与 LogLoader —— 生命周期与恢复
文件:LogManager.java、LogLoader.java
LogManager:所有 LogTablet 的管理者
public class LogManager {
// TabletId → LogTablet 的映射
private final Map<Long, LogTablet> logTablets;
private final File dataDir;
// 创建或获取 LogTablet
public LogTablet getOrCreateLogTablet(TablePath path, long tabletId) {
return logTablets.computeIfAbsent(tabletId, id -> {
LogTablet tablet = new LogTablet(path, id, dataDir, config);
tablet.load(); // ★ 关键:触发 LogLoader
return tablet;
});
}
// 定期清理过期 Segment
public void cleanupExpiredSegments() {
for (LogTablet tablet : logTablets.values()) {
long retentionMs = tablet.getRetentionMs();
long cutoffTime = System.currentTimeMillis() - retentionMs;
tablet.deleteSegmentsBefore(cutoffTime);
}
}
}
LogLoader:启动恢复
public class LogLoader {
/**
* 从磁盘恢复 LogTablet 的状态
* 读取所有 Segment 文件,重建 LEO 和 HW
*/
public LoadedLogOffsets load(File tabletDir) {
// 1. 扫描目录下所有 .log 文件
File[] logFiles = tabletDir.listFiles(
(dir, name) -> name.endsWith(".log")
);
Arrays.sort(logFiles); // 按文件名(baseOffset)排序
// 2. 逐个加载 Segment
long logEndOffset = 0;
List<LogSegment> loaded = new ArrayList<>();
for (File logFile : logFiles) {
long baseOffset = Long.parseLong(
logFile.getName().replace(".log", "")
);
LogSegment segment = LogSegment.open(
logFile, // .log 文件
getIndexFile(logFile), // .index 文件
baseOffset
);
loaded.add(segment);
logEndOffset = baseOffset + segment.size();
}
// 3. 从 checkpoint 文件读取已确认的 HW
long highWatermark = readCheckpoint(tabletDir);
return new LoadedLogOffsets(logEndOffset, highWatermark, loaded);
}
}
启动恢复流程
TabletServer 启动或收到 LoadTablet 指令
│
├── LogManager.getOrCreateLogTablet()
│ └── LogTablet.load()
│ └── LogLoader.load(tabletDir)
│ │
│ ├── 1. 扫描目录:列出所有 .log 文件
│ │
│ ├── 2. 按 baseOffset 排序
│ │
│ ├── 3. 逐个打开 LogSegment
│ │ ├── 打开 .log 文件
│ │ ├── mmap .index 文件
│ │ └── 读取最后一条 record 验证完整性
│ │
│ ├── 4. 计算 LEO = 最后一个 Segment 的 baseOffset + size
│ │
│ ├── 5. 从 checkpoint 文件读取 HW
│ │ 文件: {tabletDir}/recovery-point-offset-checkpoint
│ │ 内容: 最后确认刷盘的 offset
│ │
│ └── 6. 如果 HW < LEO:可能有未提交的数据
│ 如果是 Leader 且 HW < LEO:
│ → 等待 ISR 同步追上
│ 如果是 Follower:
│ → 截断到 HW(丢弃未提交数据)
│
└── 恢复完成,开始服务
阅读小结
| 已理解 | 尚未深入 |
|---|---|
| ✅ LogSegment 的 append/read 实现和物理文件布局 | ⬜ PredicateSchemaResolver 的谓词下推逻辑 |
| ✅ OffsetIndex 的 mmap + 稀疏索引 + 二分查找 | ⬜ FetchIsolation.READ_COMMITTED 的事务性保证 |
| ✅ LogTablet 的 LEO/HW 概念和 Segment Roll 条件 | ⬜ remote/RemoteLogManager 的远程日志实现 |
| ✅ WriterStateManager 的 Epoch+Sequence 幂等机制 | ⬜ 日志压缩(Log Compaction)vs 日志清理(Log Cleanup) |
| ✅ LogLoader 的启动恢复流程(扫描→排序→打开→计算LEO/HW) | ⬜ checkpoint 文件的具体格式和原子写入 |
下一步:功能点 5——深入 KvStore/RocksDB 的封装、Merge Engine 的三种策略、WAL 恢复机制。

452

被折叠的 条评论
为什么被折叠?



