在即时通讯客户端里,聊天记录通常先落本地数据库,再异步同步到服务端。SQLite在这一层承担的任务不是简单的文件写入,而是要同时应对顺序写入、倒序分页、关键词搜索和高频状态更新。默认配置下SQLite确实能跑通功能,但会话数变多、单表消息量上升以后,打开列表会明显变慢,滚动历史时也容易出现卡顿和锁等待。

这些问题多半不是SQLite本身能力不足,而是表结构、事务边界和查询方式没有按照聊天场景设计。下面从几个关键角度展开。
一、先设计一张适合分页和同步的消息表
聊天记录表的结构会直接影响后续所有查询。常见做法是给每个会话建一张表,但会话数量不可控,跨会话搜索也会变得很麻烦,所以实际项目通常使用一张统一消息表,通过conversation_id区分不同会话。
除基础内容字段外,消息状态和客户端消息ID是两个容易忽略的字段。status用于标记发送中、已送达、已读、发送失败等状态,本地重发和界面展示都依赖它;client_msg_id由客户端生成,保证消息在重试和去重时能定位到同一条记录。
下面是推荐的表结构,时间戳统一使用整数毫秒,避免字符串比较和时区转换问题。
CREATE TABLE im_message (
msg_id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
sender_id TEXT NOT NULL,
receiver_id TEXT NOT NULL DEFAULT '',
content TEXT,
content_type INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 0,
client_msg_id TEXT NOT NULL,
create_time INTEGER NOT NULL,
update_time INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX uk_msg_client ON im_message(client_msg_id);
CREATE INDEX idx_msg_conv_time ON im_message(conversation_id, create_time DESC);
CREATE INDEX idx_msg_conv_sender ON im_message(conversation_id, sender_id, create_time DESC);
复合索引conversation_id + create_time DESC专门服务会话内倒序分页,唯一索引client_msg_id则保证重复消息不会产生两条记录。sender_id附加在索引里可以加速按发送者过滤,但会增加写入成本,如果不需要按人筛选可以省略。
二、开启WAL模式,让读写尽量不互相阻塞
SQLite默认的journal_mode=delete模式下,写事务会阻塞读事务。聊天场景边收消息边翻历史记录时,很容易触发SQLITE_BUSY或database is locked。WAL模式把新写入追加到独立的-wal文件,旧数据保留在库文件中,读请求可以直接读取旧快照,从而实现一写多读。
WAL不是没有代价,-wal文件会随着写入不断增长,需要checkpoint把数据合并回主库。synchronous=NORMAL能减少写入时的fsync次数,在崩溃时可能丢失最后一个事务但不会损坏数据库,这对聊天记录这种可重新拉取的数据通常可以接受。busy_timeout设置后遇到锁会等待而不是立刻报错。
配置代码要在打开数据库后首次执行,并且需要保持连接复用,不要每次操作都重新打开数据库。
PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000; PRAGMA wal_autocheckpoint=1000;
wal_autocheckpoint控制自动合并阈值,达到1000页后由SQLite自动执行checkpoint。如果希望更主动地控制,可以在应用进入后台时手动执行PRAGMA wal_checkpoint(TRUNCATE);来缩小-wal文件。
三、批量写入与事务边界
逐条插入消息时,每条INSERT都会启动一次事务并同步到磁盘。写入1000条消息可能要花数秒。把这些INSERT放进同一个事务后,SQLite只需在提交时执行一次fsync,通常能带来几十倍的性能提升。
事务开始时推荐使用beginTransactionNonExclusive或BEGIN IMMEDIATE,避免多个写事务都先读取再升级造成冲突。预编译语句可以显著减少SQL解析开销,参数绑定还能避免拼接字符串带来的转义问题。
SQLiteDatabase db = helper.getWritableDatabase();
db.beginTransactionNonExclusive();
try {
SQLiteStatement stmt = db.compileStatement(
"INSERT OR REPLACE INTO im_message(conversation_id, sender_id, receiver_id, content, content_type, status, client_msg_id, create_time) VALUES(?, ?, ?, ?, ?, ?, ?, ?)"
);
for (Message msg : messages) {
stmt.clearBindings();
stmt.bindString(1, msg.conversationId);
stmt.bindString(2, msg.senderId);
stmt.bindString(3, msg.receiverId);
stmt.bindString(4, msg.content);
stmt.bindLong(5, msg.contentType);
stmt.bindLong(6, msg.status);
stmt.bindString(7, msg.clientMsgId);
stmt.bindLong(8, msg.createTime);
stmt.executeInsert();
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
批量大小不是越大越好,事务持锁时间过长会让其他写线程等待。通常以50到200条为一批比较合适,收到消息后先写入内存队列,攒够一批再提交。如果应用被杀,内存中尚未落库的数据可能丢失,因此需要根据可靠性要求决定是否在每条消息到达后立即提交,或者依赖服务端补拉。
四、倒序分页与FTS5全文搜索
聊天列表默认展示最新一页,滚动到顶部再加载更早消息。使用LIMIT 20 OFFSET 200这种写法在OFFSET增大时仍需要扫描前200条再丢弃,深分页成本会越来越高。基于游标的分页让前端传入当前页最小的msg_id,查询只处理小于该ID的20条记录。
SELECT msg_id, sender_id, content, status, create_time FROM im_message WHERE conversation_id = ? AND msg_id < ? ORDER BY msg_id DESC LIMIT 20;
因为自增主键单调递增,用msg_id作为游标比create_time更可靠。不同消息可能在同一毫秒内到达,时间戳相同会导致漏数据或重复数据。
关键词搜索可以借助FTS5虚拟表。FTS5为每行建立倒排索引,MATCH查询比LIKE '%关键词%'快得多。先创建虚拟表并同步已有数据,之后在消息写入事务中同时更新虚拟表。
CREATE VIRTUAL TABLE im_message_fts USING fts5(
content,
sender_id,
content='im_message',
content_rowid='msg_id',
tokenize='unicode61'
);
INSERT INTO im_message_fts(rowid, content, sender_id)
SELECT msg_id, content, sender_id FROM im_message;
SELECT m.msg_id, m.content, m.create_time
FROM im_message_fts f
JOIN im_message m ON m.msg_id = f.rowid
WHERE im_message_fts MATCH ?
ORDER BY bm25(im_message_fts)
LIMIT 20;
注意FTS5默认的unicode61分词器对英文和数字效果较好,对中文聊天内容通常不会按词切分,可能退化为整段匹配。中文环境可以接入ICU扩展或使用外部分词方案,否则用LIKE做兜底虽然简单,但在大表上性能较差。
五、消息清理与备份恢复
聊天记录不可能无限保存,尤其移动端本地库膨胀后,分页、全文索引和备份时间都会变长。一般会设置每个会话保留最近若干条,或者只保留近几个月的记录。删除时也要分批进行,避免一次性锁库。
DELETE FROM im_message
WHERE msg_id IN (
SELECT msg_id FROM im_message
WHERE conversation_id = ? AND create_time < ?
ORDER BY create_time DESC
LIMIT 500
);
这个示例只删500条,实际使用时可以循环执行到删完为止,并在每批之间短暂让出写锁。删除大量数据后SQLite文件不会自动缩小,可以执行PRAGMA incremental_vacuum或VACUUM回收空间。VACUUM会重写整个库,执行期间会锁库,建议在应用空闲或后台任务中进行。
备份本地库时,直接复制db文件可能因为WAL未合并而丢失最近写入,先执行wal_checkpoint再复制比较安全。较新版本SQLite支持VACUUM INTO直接生成一份干净的备份文件,示例指向C:\backup\chat.db,备份目录需要事先存在。
PRAGMA wal_checkpoint(TRUNCATE); VACUUM INTO 'C:\backup\chat.db';
最后,这些优化需要放在同一个数据库连接和统一的存储层中执行。如果上层ORM每次操作都创建新连接,PRAGMA和事务配置很容易失效,解决锁问题也会变得困难。

