事件溯源把系统中每一次状态变更都记录为一条不可修改的事件,通过重放事件流来重建当前状态或追溯历史。SQLite作为进程内嵌入式数据库,不需要独立服务进程,通过单文件就能提供ACID事务,非常适合中小型服务、边缘节点或者本地调试环境下的事件流存储。本文围绕如何用SQLite落地事件溯源中的事件流,讲解表结构、写入方式、查询模式以及性能注意点。

事件流表结构与写入事务设计
在SQLite中实现事件流,最核心的是设计一张只追加(append-only)的事件表。每条事件应包含全局自增序号、聚合根标识、事件类型、版本号、载荷与写入时间。全局序号可以使用INTEGER PRIMARY KEY AUTOINCREMENT,它保证即使在并发连接下也能产生单调递增且唯一的事件ID,方便后续按区间拉取。聚合根标识用于把事件按业务实体分组,版本号则用来做乐观并发控制,防止同一聚合根出现乱序或重复版本。
写入时必须把同一业务操作产生的事件放在一个事务里提交,否则可能出现部分事件落盘导致状态无法重放。SQLite默认在写操作时锁全库,因此建议使用WAL模式提升并发读能力。下面是一段创建事件表并批量插入的示例,其中包含防重复版本校验:
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS event_stream (
id INTEGER PRIMARY KEY AUTOINCREMENT,
aggregate_id TEXT NOT NULL,
aggregate_type TEXT NOT NULL,
version INTEGER NOT NULL,
event_type TEXT NOT NULL,
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(aggregate_id, version)
);
BEGIN TRANSACTION;
INSERT INTO event_stream (aggregate_id, aggregate_type, version, event_type, payload, created_at)
VALUES ('order_1001', 'Order', 1, 'OrderCreated', '{"user":1,"item":5}', strftime('%s','now'));
INSERT INTO event_stream (aggregate_id, aggregate_type, version, event_type, payload, created_at)
VALUES ('order_1001', 'Order', 2, 'OrderPaid', '{"amount":99}', strftime('%s','now'));
COMMIT;
上面的UNIQUE约束能在写入时由数据库帮我们挡掉同一聚合根相同版本的重复事件,省去应用层额外查询。如果批量插入中途失败,事务回滚可以保证事件流要么全有要么全无。对于高频写入,可以改为每批次攒够几百条再开启事务提交,减少fsync次数,但需注意进程崩溃可能丢失内存中未提交批次,应结合业务容忍度权衡。
事件流查询与增量订阅实现
事件溯源系统通常有两种读取模式:一种是按聚合根重放,即从事件表选出某aggregate_id的全部事件按version排序后回放;另一种是按时间或序号做全局增量订阅,用于投影构建或跨服务同步。针对前者,应在aggregate_id和version上建索引,否则随着单表数据膨胀,全表扫描会非常慢。针对后者,全局id本身就是主键索引,按id大于某值查询效率极高。
下面示例展示两类典型查询。第一类取某订单全部事件;第二类从指定事件序号之后拉取最新一批事件,模拟消费者位移:
-- 重放单个聚合根 SELECT id, event_type, payload, version FROM event_stream WHERE aggregate_id = 'order_1001' ORDER BY version ASC; -- 增量订阅:取上次消费到的id之后的事件 SELECT id, aggregate_id, event_type, payload FROM event_stream WHERE id > 1024 ORDER BY id ASC LIMIT 500;
在真实项目中,增量订阅往往由后台线程循环轮询,每次记录最大id作为检查点。由于SQLite读不加锁(WAL下),轮询不会阻塞写入。若事件量极大,可按月或按聚合类型做分区表,将冷数据归档到独立SQLite文件,热表只保留近期事件,这样索引更小、查询更快。但要注意跨分区重放需要应用层合并结果,复杂度会上升。
性能边界与运维注意点
SQLite单文件在写入吞吐量上有天然天花板,因为同一时刻只有一个写者能持有写锁。对于日均百万级事件,若峰值写入集中,可能出现SQLITE_BUSY。解决办法包括:使用WAL并调大wal_autocheckpoint、在应用层做写入合并、或将写操作串行化到单一消费者队列。相比分布式消息队列,SQLite优势是零运维与强事务,不适合做高并发公共事件总线,但作为服务私有事件库非常合适。
另一个常被忽视的是页面大小和空闲页回收。默认page_size为4096,对大量小事件可改到8192或16384提升局部性。事件表只追加不更新,删除历史需用DELETE加VACUUM,但VACUUM会锁表并翻倍空间,应在低峰期操作。若采用快照优化,可定期把聚合根最新状态存到快照表,重放时先从快照开始再补后续事件,大幅减少回放条数。示例如下:
CREATE TABLE IF NOT EXISTS aggregate_snapshot ( aggregate_id TEXT PRIMARY KEY, last_version INTEGER NOT NULL, state TEXT NOT NULL, updated_at INTEGER NOT NULL ); -- 重放时先读快照 SELECT last_version, state FROM aggregate_snapshot WHERE aggregate_id = 'order_1001'; -- 再读大于last_version的事件 SELECT id, event_type, payload FROM event_stream WHERE aggregate_id = 'order_1001' AND version > 2 ORDER BY version ASC;
综合来看,用SQLite做事件溯源事件流,重点在于把写控住、把索引建对、把冷数据移走。只要聚合根粒度合理、写入不恶性突发,它能以极低资源占用撑起完整事件溯源能力,是验证架构与小规模落地的实用选择。