release: v0.2.4 二级索引 + MVCC + BloomFilter + WAL全同步 + 内存预算
This commit is contained in:
+170
-39
@@ -2,13 +2,7 @@
|
||||
* AriaEngine — 自研页面式存储引擎主类
|
||||
* @module engine/aria/index
|
||||
*
|
||||
* 实现 IStorageEngine 接口。
|
||||
*
|
||||
* v0.2.1: 完整持久化
|
||||
* - Schema 存入 __aria_schemas
|
||||
* - SSTable 元数据存入 __aria_lsm_meta
|
||||
* - WAL 恢复包含行数据
|
||||
* - 启动时自动加载 Schema + SSTable
|
||||
* v0.2.4: 二级索引 + MVCC 集成 + 生产加固
|
||||
*/
|
||||
|
||||
import type { IStorageEngine } from '../interface';
|
||||
@@ -26,6 +20,8 @@ import { WALRecordType, type WALRecord } from './types';
|
||||
import { CheckpointManager } from './wal/checkpoint';
|
||||
import { IndexedDBBackend, MemoryBackend, type IStorageBackend } from './store/backend';
|
||||
import { OPFSBackend } from './store/opfs_backend';
|
||||
import { MVCCManager } from './transaction/mvcc';
|
||||
import { BloomFilter } from './index/bloom';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AriaEngine
|
||||
@@ -35,7 +31,7 @@ export class AriaEngine implements IStorageEngine {
|
||||
readonly name = 'aria';
|
||||
|
||||
private config!: Required<AriaEngineConfig>;
|
||||
private lsm!: LSM;
|
||||
private lsm!: LSM; // 主键索引 LSM
|
||||
private wal!: WAL;
|
||||
private checkpointManager!: CheckpointManager;
|
||||
private backend!: IStorageBackend;
|
||||
@@ -47,9 +43,14 @@ export class AriaEngine implements IStorageEngine {
|
||||
private tablePKs: Map<string, string> = new Map();
|
||||
private opCounter = 0;
|
||||
|
||||
// 事务
|
||||
// 二级索引:table.colKey → LSM
|
||||
private secondaryIndexes: Map<string, LSM> = new Map();
|
||||
|
||||
// MVCC 事务
|
||||
private mvcc: MVCCManager = new MVCCManager();
|
||||
private currentTxnId: number | null = null;
|
||||
private txnSnapshot: Map<string, Record<string, unknown>> | null = null;
|
||||
private gcCounter = 0;
|
||||
|
||||
constructor(config: AriaEngineConfig = {}) {
|
||||
this.config = { ...DEFAULT_ARIA_CONFIG, ...config };
|
||||
@@ -76,7 +77,7 @@ export class AriaEngine implements IStorageEngine {
|
||||
// 2. 构建 SSTableStore
|
||||
const sstableStore = this.createSSTableStore();
|
||||
|
||||
// 3. 初始化 LSM
|
||||
// 3. 初始化主 LSM(PK 索引)
|
||||
this.lsm = new LSM({
|
||||
memtableSizeThreshold: this.config.memtableSizeThreshold,
|
||||
levelSizeMultiplier: this.config.levelSizeMultiplier,
|
||||
@@ -185,6 +186,25 @@ export class AriaEngine implements IStorageEngine {
|
||||
this.schemas.set(schema.name, schema);
|
||||
this.tablePKs.set(schema.name, this.getPK(schema));
|
||||
|
||||
// 为索引列创建二级索引 LSM
|
||||
const sstableStore = this.createSSTableStore();
|
||||
for (const [colName, colDef] of Object.entries(schema.columns)) {
|
||||
if (colDef.index || colDef.unique || colDef.primaryKey) {
|
||||
const idxKey = `${schema.name}:idx:${colName}`;
|
||||
if (!this.secondaryIndexes.has(idxKey)) {
|
||||
const idxLsm = new LSM({
|
||||
memtableSizeThreshold: this.config.memtableSizeThreshold,
|
||||
levelSizeMultiplier: this.config.levelSizeMultiplier,
|
||||
blockSize: this.config.pageSize,
|
||||
bloomBitsPerKey: this.config.bloomFilterBitsPerKey,
|
||||
sstableStore,
|
||||
});
|
||||
await idxLsm.init();
|
||||
this.secondaryIndexes.set(idxKey, idxLsm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.persistSchemas();
|
||||
|
||||
this.wal.append({
|
||||
@@ -263,10 +283,13 @@ export class AriaEngine implements IStorageEngine {
|
||||
// Within transaction: buffer to snapshot
|
||||
this.txnSnapshot.set(key, validated);
|
||||
} else {
|
||||
// Direct write to LSM
|
||||
// Direct write to LSM (PK index)
|
||||
this.lsm.put(key, validated);
|
||||
}
|
||||
|
||||
// 更新二级索引
|
||||
this.updateSecondaryIndexes(tableName, pkValue, validated, null);
|
||||
|
||||
pks.push(pkValue);
|
||||
|
||||
this.wal.append({
|
||||
@@ -280,6 +303,7 @@ export class AriaEngine implements IStorageEngine {
|
||||
|
||||
this.opCounter += rows.length;
|
||||
await this.checkpointManager.tick();
|
||||
this.tryGC();
|
||||
return pks;
|
||||
}
|
||||
|
||||
@@ -373,6 +397,9 @@ export class AriaEngine implements IStorageEngine {
|
||||
key: String(row[pkCol]),
|
||||
data: updated,
|
||||
});
|
||||
|
||||
// 更新二级索引
|
||||
this.updateSecondaryIndexes(tableName, String(row[pkCol]), updated, row);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +434,9 @@ export class AriaEngine implements IStorageEngine {
|
||||
tableName,
|
||||
key: String(row[pkCol]),
|
||||
});
|
||||
|
||||
// 移除二级索引
|
||||
this.updateSecondaryIndexes(tableName, String(row[pkCol]), null, row);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,34 +534,6 @@ export class AriaEngine implements IStorageEngine {
|
||||
});
|
||||
}
|
||||
|
||||
private tryIndexLookup(
|
||||
tableName: string,
|
||||
query: QueryPlan,
|
||||
): Record<string, unknown>[] | null {
|
||||
if (!query.where) return null;
|
||||
const pkCol = this.tablePKs.get(tableName)!;
|
||||
|
||||
for (const [col, condition] of Object.entries(query.where)) {
|
||||
if (col !== pkCol) continue;
|
||||
|
||||
// 等值条件
|
||||
if (typeof condition !== 'object' || condition === null) {
|
||||
const key = `${tableName}:${condition}`;
|
||||
const value = this.lsm.get(key);
|
||||
return value ? [{ ...value, [pkCol]: condition }] : [];
|
||||
}
|
||||
|
||||
const cond = condition as Record<string, unknown>;
|
||||
if ('$eq' in cond) {
|
||||
const key = `${tableName}:${cond.$eq}`;
|
||||
const value = this.lsm.get(key);
|
||||
return value ? [{ ...value, [pkCol]: cond.$eq }] : [];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private getPK(schema: TableSchema): string {
|
||||
for (const [name, col] of Object.entries(schema.columns)) {
|
||||
if (col.primaryKey) return name;
|
||||
@@ -686,10 +688,139 @@ export class AriaEngine implements IStorageEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// 二级索引
|
||||
// =======================================================================
|
||||
|
||||
/** 更新行的二级索引条目 */
|
||||
private updateSecondaryIndexes(
|
||||
tableName: string, pkValue: string,
|
||||
newRow: Record<string, unknown> | null,
|
||||
oldRow: Record<string, unknown> | null,
|
||||
): void {
|
||||
const schema = this.schemas.get(tableName);
|
||||
if (!schema) return;
|
||||
|
||||
for (const [colName, colDef] of Object.entries(schema.columns)) {
|
||||
if (!colDef.index && !colDef.unique && !colDef.primaryKey) continue;
|
||||
const idxKey = `${tableName}:idx:${colName}`;
|
||||
const idxLsm = this.secondaryIndexes.get(idxKey);
|
||||
if (!idxLsm) continue;
|
||||
|
||||
// 删除旧值
|
||||
if (oldRow) {
|
||||
const oldVal = oldRow[colName];
|
||||
if (oldVal !== undefined && oldVal !== null) {
|
||||
idxLsm.delete(`${String(oldVal)}:${pkValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 插入新值
|
||||
if (newRow) {
|
||||
const newVal = newRow[colName];
|
||||
if (newVal !== undefined && newVal !== null) {
|
||||
idxLsm.put(`${String(newVal)}:${pkValue}`, { pk: pkValue });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 通过二级索引快速查找 */
|
||||
private tryIndexLookup(
|
||||
tableName: string,
|
||||
query: QueryPlan,
|
||||
): Record<string, unknown>[] | null {
|
||||
if (!query.where) return null;
|
||||
const schema = this.schemas.get(tableName);
|
||||
if (!schema) return null;
|
||||
const pkCol = this.tablePKs.get(tableName)!;
|
||||
|
||||
for (const [col, condition] of Object.entries(query.where)) {
|
||||
// 跳过 $and/$or/$not 逻辑组合
|
||||
if (col === '$and' || col === '$or' || col === '$not') continue;
|
||||
|
||||
const colDef = schema.columns[col];
|
||||
const hasIndex = colDef && (colDef.index || colDef.unique || colDef.primaryKey);
|
||||
if (!hasIndex && col !== pkCol) continue;
|
||||
|
||||
// PK 等值 → 主 LSM 精确查找
|
||||
if (col === pkCol) {
|
||||
if (typeof condition !== 'object' || condition === null) {
|
||||
const key = `${tableName}:${condition}`;
|
||||
const value = this.lsm.get(key);
|
||||
return value ? [{ ...value, [pkCol]: condition }] : [];
|
||||
}
|
||||
const cond = condition as Record<string, unknown>;
|
||||
if ('$eq' in cond) {
|
||||
const key = `${tableName}:${cond.$eq}`;
|
||||
const value = this.lsm.get(key);
|
||||
return value ? [{ ...value, [pkCol]: cond.$eq }] : [];
|
||||
}
|
||||
}
|
||||
|
||||
// 二级索引查找
|
||||
const idxKey = `${tableName}:idx:${col}`;
|
||||
const idxLsm = this.secondaryIndexes.get(idxKey);
|
||||
if (!idxLsm) continue;
|
||||
|
||||
// $eq → 精确查找
|
||||
if (typeof condition !== 'object' || condition === null) {
|
||||
return this.indexScanToRows(tableName, pkCol, idxLsm, col, String(condition), String(condition));
|
||||
}
|
||||
const c = condition as Record<string, unknown>;
|
||||
if ('$eq' in c) {
|
||||
const v = String(c.$eq);
|
||||
return this.indexScanToRows(tableName, pkCol, idxLsm, col, v, v);
|
||||
}
|
||||
// $in → 多次精确查找
|
||||
if ('$in' in c && Array.isArray(c.$in)) {
|
||||
const results: Record<string, unknown>[] = [];
|
||||
for (const val of c.$in) {
|
||||
const rows = this.indexScanToRows(tableName, pkCol, idxLsm, col, String(val), String(val));
|
||||
results.push(...rows);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
// $gt / $gte / $lt / $lte → 范围扫描
|
||||
if ('$gt' in c || '$gte' in c || '$lt' in c || '$lte' in c) {
|
||||
const startKey = c.$gt ? `${String(Number(c.$gt) + 1)}:` : c.$gte ? `${String(c.$gte)}:` : `${col}:`;
|
||||
const endKey = c.$lt ? `${String(Number(c.$lt) - 1)}:\uffff` : c.$lte ? `${String(c.$lte)}:\uffff` : `${col}:\uffff`;
|
||||
return this.indexScanToRows(tableName, pkCol, idxLsm, col, startKey, endKey);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 从索引扫描结果恢复完整行 */
|
||||
private indexScanToRows(
|
||||
tableName: string, pkCol: string, idxLsm: LSM,
|
||||
_col: string, startKey: string, endKey: string,
|
||||
): Record<string, unknown>[] {
|
||||
const entries = idxLsm.rangeScan(startKey, endKey);
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
for (const [, idxEntry] of entries) {
|
||||
const pk = (idxEntry as any).pk as string;
|
||||
if (!pk) continue;
|
||||
const row = this.lsm.get(`${tableName}:${pk}`);
|
||||
if (row) rows.push({ ...row, [pkCol]: pk });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// =======================================================================
|
||||
// 辅助
|
||||
// =======================================================================
|
||||
|
||||
/** 每 10 次 gc 计数器触发一次 MVCC 垃圾回收 */
|
||||
private tryGC(): void {
|
||||
this.gcCounter++;
|
||||
if (this.gcCounter >= 10) {
|
||||
this.mvcc.gc(100);
|
||||
this.gcCounter = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private ensureOpen(): void {
|
||||
if (!this.opened) throw new DatabaseError('AriaEngine not opened', 'DB_NOT_OPEN');
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { IndexEntry, SSTableMeta } from '../types';
|
||||
import { BloomFilter } from './bloom';
|
||||
|
||||
const SSTABLE_MAGIC = 0x53535442; // "SSTB"
|
||||
|
||||
@@ -17,6 +18,7 @@ export class SSTableReader {
|
||||
private indexEntries: IndexEntry[] = [];
|
||||
private entryCount = 0;
|
||||
private meta: SSTableMeta;
|
||||
private bloomFilter: BloomFilter | null = null;
|
||||
|
||||
constructor(data: Uint8Array, meta: SSTableMeta) {
|
||||
this.data = data;
|
||||
@@ -31,6 +33,9 @@ export class SSTableReader {
|
||||
|
||||
/** 精确查找 key */
|
||||
get(targetKey: string): Record<string, unknown> | null {
|
||||
// Bloom Filter 快速否定
|
||||
if (this.bloomFilter && !this.bloomFilter.mayContain(targetKey)) return null;
|
||||
|
||||
const blockIdx = this.locateBlock(targetKey);
|
||||
if (blockIdx < 0) return null;
|
||||
|
||||
@@ -175,10 +180,23 @@ export class SSTableReader {
|
||||
|
||||
const indexOffset = this.view.getUint32(footerOffset, false);
|
||||
const indexSize = this.view.getUint32(footerOffset + 4, false);
|
||||
const bloomOffset = this.view.getUint32(footerOffset + 8, false);
|
||||
const bloomSize = this.view.getUint32(footerOffset + 12, false);
|
||||
const bloomHashCount = this.view.getUint32(footerOffset + 16, false);
|
||||
this.entryCount = this.view.getUint32(footerOffset + 20, false);
|
||||
|
||||
// 解析索引块
|
||||
this.parseIndexBlock(indexOffset, indexSize);
|
||||
|
||||
// 加载 Bloom Filter
|
||||
if (bloomOffset > 0 && bloomSize > 0 && bloomOffset + bloomSize <= this.data.byteLength) {
|
||||
try {
|
||||
const bloomBytes = this.data.slice(bloomOffset, bloomOffset + bloomSize);
|
||||
this.bloomFilter = BloomFilter.fromData(bloomBytes, bloomHashCount || 10);
|
||||
} catch {
|
||||
// 损坏的 bloom filter 不影响读取(仅跳过快速否定优化)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private parseIndexBlock(offset: number, _size: number): void {
|
||||
|
||||
@@ -92,8 +92,12 @@ export class SSTableBuilder {
|
||||
|
||||
const indexBlockSize = this.estimateIndexBlockSize(indexEntries);
|
||||
|
||||
// 写入到 buffer
|
||||
const finalSize = totalSize + indexBlockSize + SSTABLE_FOOTER_SIZE;
|
||||
// 序列化 bloom filter 以获取其大小
|
||||
const bloomData = bloomFilter.serialize();
|
||||
const bloomSize = bloomData.byteLength;
|
||||
|
||||
// 写入到 buffer(包含 bloom block)
|
||||
const finalSize = totalSize + indexBlockSize + bloomSize + SSTABLE_FOOTER_SIZE;
|
||||
const buf = new ArrayBuffer(finalSize);
|
||||
const view = new DataView(buf);
|
||||
|
||||
@@ -108,12 +112,17 @@ export class SSTableBuilder {
|
||||
const indexOffset = offset;
|
||||
offset = this.writeIndexBlock(view, offset, indexEntries);
|
||||
|
||||
// ---- Bloom Filter Block ----
|
||||
const bloomOffset = offset;
|
||||
new Uint8Array(view.buffer).set(bloomData, offset);
|
||||
offset += bloomSize;
|
||||
|
||||
// ---- Footer ----
|
||||
const footerOffset = offset;
|
||||
view.setUint32(footerOffset, indexOffset, false); // index_offset
|
||||
view.setUint32(footerOffset + 4, indexBlockSize, false); // index_size
|
||||
view.setUint32(footerOffset + 8, 0, false); // bloom_offset (embedded in footer)
|
||||
view.setUint32(footerOffset + 12, 0, false); // bloom_size
|
||||
view.setUint32(footerOffset + 8, bloomOffset, false); // bloom_offset
|
||||
view.setUint32(footerOffset + 12, bloomSize, false); // bloom_size
|
||||
view.setUint32(footerOffset + 16, bloomFilter.getHashCount(), false);
|
||||
view.setUint32(footerOffset + 20, this.entries.length, false);
|
||||
view.setUint32(footerOffset + 24, SSTABLE_MAGIC, false);
|
||||
|
||||
@@ -280,6 +280,10 @@ export interface AriaEngineConfig {
|
||||
compression?: boolean;
|
||||
/** 存储后端 */
|
||||
storageBackend?: 'indexeddb' | 'opfs' | 'memory';
|
||||
/** WAL 大小阈值(字节,超过则强制 checkpoint,默认 16MB) */
|
||||
walSizeThreshold?: number;
|
||||
/** 最大内存预算(MB,默认 64) */
|
||||
maxMemoryMB?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_ARIA_CONFIG: Required<AriaEngineConfig> = {
|
||||
@@ -289,8 +293,10 @@ export const DEFAULT_ARIA_CONFIG: Required<AriaEngineConfig> = {
|
||||
levelSizeMultiplier: DEFAULT_LEVEL_SIZE_MULTIPLIER,
|
||||
bloomFilterBitsPerKey: DEFAULT_BLOOM_BITS_PER_KEY,
|
||||
walEnabled: true,
|
||||
walSyncMode: 'batch',
|
||||
walSyncMode: 'full',
|
||||
checkpointInterval: 1000,
|
||||
compression: false,
|
||||
storageBackend: 'indexeddb',
|
||||
walSizeThreshold: 16 * 1024 * 1024, // 16MB
|
||||
maxMemoryMB: 64,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user