对话不丢、刷新即续:IndexedDB 持久化 AI 会话历史的工程实践
一、刷新即丢历史:AI 会话为何必须前端持久化
某 AI 助手产品上线两周,客服收到一类高频反馈:用户聊到一半刷新页面,整段对话没了。排查发现,前端把会话历史存在内存里,刷新即清空,服务端又没做全量持久化。这事我见过太多团队栽进去——把 AI 对话当成一次性请求,忽略历史本身就是用户资产。
AI 对话的特殊性在于上下文连续。多轮对话依赖前面几轮的提问与回答,丢一段历史意味着模型失去上下文,回答质量断崖式下降。用户切换标签页、断网恢复、跨设备继续聊,都需要历史可重建。
服务端持久化是标配,但只靠服务端不够。第一,网络抖动时前端拿不到历史,刷新就空白。第二,离线场景下用户仍想查阅旧对话。第三,频繁拉取历史增加服务端压力与首屏延迟。把会话历史在前端也持久化一份,能解决离线可读、快速恢复、降低请求量三个问题。
IndexedDB 是浏览器里唯一支持大容量结构化存储的方案。localStorage 容量上限 5MB 上下,存不下几百轮带富文本的对话。IndexedDB 容量可达数百 MB 甚至更高,支持索引与事务,适合做会话仓库。
二、事务模型与索引设计:会话存储的底层机制
IndexedDB 的核心是"数据库—对象仓库—索引—记录"四层结构。一个数据库下可有多个对象仓库(类似表),每个仓库可建多条索引。所有读写必须在事务中完成,事务提交后才落盘。
事务模型的关键是作用域。开启事务时指定涉及的仓库列表,浏览器据此加锁。同一仓库上多个事务默认串行,避免并发写冲突。写操作要么全部成功,要么全部回滚——这对会话这种"多消息原子写入"场景是天然契合。
索引设计决定查询效率。会话历史的典型查询有三种:按会话 ID 拉取列表(会话概览)、按会话 ID 分页加载消息(聊天窗口滚动)、按时间范围检索(全局搜索)。对应索引:sessionId 建索引支持会话内消息分页,updatedAt 建索引支持会话列表按最近更新排序,createdAt 建索引支持时间范围检索。
大对象分片存储是另一关键。单条消息若包含长文本、附件引用、工具调用结果,体积可能达数十 KB。若整体写入一条记录,更新单字段也要重写整条。把消息内容拆成 messages 与 message_parts 两个仓库,消息元数据与内容分片分离,更新更轻量。
版本迁移是持久化的必经之路。需求演进会改 schema,IndexedDB 通过 onupgradeneeded 回调处理。迁移必须幂等——同一版本重复执行不破坏数据,且要处理从旧版到新版的多步跳转。
综上,事务把多消息写入原子化,索引让分页查询走 O(log n)。从四层结构、事务作用域到索引设计与版本迁移,这套机制共同构成会话持久化的工程基础。
三、生产级会话持久化仓库:事务兜底与版本迁移
下面给出一个可复用的会话仓库。它支持分页加载、批量写入、版本迁移与事务错误兜底。
/**
* AI 会话持久化仓库
* 仓库结构:sessions(会话元数据)、messages(消息元数据)、message_parts(消息内容分片)
* 索引:messages.sessionId、sessions.updatedAt、messages.createdAt
*/
const DB_NAME = 'ai-chat';
const DB_VERSION = 2;
export interface ChatMessage {
id: string;
sessionId: string;
role: 'user' | 'assistant' | 'system' | 'tool';
createdAt: number;
// 内容分片单独存,更新单字段不必重写整条
parts: Array<{ type: 'text' | 'image' | 'tool_call'; payload: unknown }>;
}
export interface ChatSession {
id: string;
title: string;
createdAt: number;
updatedAt: number;
}
export class ChatHistoryRepo {
private dbPromise: Promise<IDBDatabase> | null = null;
/** 懒加载打开数据库,迁移逻辑集中在 onupgradeneeded */
private open(): Promise<IDBDatabase> {
if (this.dbPromise) return this.dbPromise;
this.dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (e) => {
const db = req.result;
// 老版本事件对象里没有 transaction,用 req.transaction 兜底
const tx = req.transaction!;
this.migrate(db, tx, e.oldVersion, e.newVersion ?? DB_VERSION);
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
// 多标签升级冲突时明确报错,避免静默卡死
req.onblocked = () => reject(new Error('DB upgrade blocked by another tab'));
});
return this.dbPromise;
}
/**
* 版本迁移:必须幂等,按 oldVersion 分段处理
* v1:建 sessions 与 messages 仓库
* v2:新增 message_parts 仓库与 messages.sessionId 索引
*/
private migrate(db: IDBDatabase, tx: IDBTransaction, oldV: number, newV: number) {
if (oldV < 1) {
const sessions = db.createObjectStore('sessions', { keyPath: 'id' });
sessions.createIndex('updatedAt', 'updatedAt');
const messages = db.createObjectStore('messages', { keyPath: 'id' });
messages.createIndex('sessionId', 'sessionId');
messages.createIndex('createdAt', 'createdAt');
}
if (oldV < 2) {
// v2 拆出 message_parts,存大体积内容分片
if (!db.objectStoreNames.contains('message_parts')) {
const parts = db.createObjectStore('message_parts', { keyPath: ['messageId', 'idx'] });
parts.createIndex('messageId', 'messageId');
}
}
}
/** 追加一条消息:消息元数据与分片在同一事务内写入,保证原子性 */
async appendMessage(msg: ChatMessage): Promise<void> {
const db = await this.open();
return new Promise((resolve, reject) => {
const tx = db.transaction(['messages', 'message_parts', 'sessions'], 'readwrite');
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error ?? new Error('tx aborted'));
tx.oncomplete = () => resolve();
const msgStore = tx.objectStore('messages');
// 元数据只存轻量字段,重内容下沉到 parts
msgStore.put({
id: msg.id,
sessionId: msg.sessionId,
role: msg.role,
createdAt: msg.createdAt,
});
const partStore = tx.objectStore('message_parts');
msg.parts.forEach((part, idx) => {
partStore.put({ messageId: msg.id, idx, type: part.type, payload: part.payload });
});
// 同步更新会话的 updatedAt,用于会话列表排序
tx.objectStore('sessions').put({
id: msg.sessionId,
title: msg.role === 'user' ? String(msg.parts[0]?.payload ?? '').slice(0, 30) : '',
createdAt: msg.createdAt,
updatedAt: msg.createdAt,
} as ChatSession);
});
}
/**
* 分页加载会话消息:走 sessionId 索引,按 createdAt 升序
* 用 cursor + advance 跳过已加载页,避免全量遍历
*/
async loadMessages(sessionId: string, page = 0, pageSize = 20): Promise<ChatMessage[]> {
const db = await this.open();
return new Promise((resolve, reject) => {
const tx = db.transaction(['messages', 'message_parts'], 'readonly');
const idx = tx.objectStore('messages').index('sessionId');
const result: ChatMessage[] = [];
const skip = page * pageSize;
let advanced = false;
const req = idx.openCursor(IDBKeyRange.only(sessionId), 'next');
req.onerror = () => reject(req.error);
req.onsuccess = () => {
const cursor = req.result;
if (!cursor) {
// 元数据加载完,再批量拉分片
this.fillParts(tx, result).then(() => resolve(result)).catch(reject);
return;
}
if (!advanced && skip > 0) {
cursor.advance(skip);
advanced = true;
return;
}
const val = cursor.value;
result.push({ …val, parts: [] });
if (result.length >= pageSize) {
this.fillParts(tx, result).then(() => resolve(result)).catch(reject);
return;
}
cursor.continue();
};
});
}
/** 批量填充消息分片,减少往返 */
private fillParts(tx: IDBTransaction, msgs: ChatMessage[]): Promise<void> {
const store = tx.objectStore('message_parts');
return Promise.all(msgs.map(m => new Promise<void>((res, rej) => {
const r = store.index('messageId').getAll(IDBKeyRange.only(m.id));
r.onsuccess = () => {
m.parts = r.result.map((p: any) => ({ type: p.type, payload: p.payload }));
res();
};
r.onerror = () => rej(r.error);
}))).then(() => undefined);
}
/** 过期清理:按 createdAt 删除早于 cutoff 的消息,级联删 parts */
async pruneBefore(cutoff: number): Promise<number> {
const db = await this.open();
return new Promise((resolve, reject) => {
const tx = db.transaction(['messages', 'message_parts'], 'readwrite');
const idx = tx.objectStore('messages').index('createdAt');
const range = IDBKeyRange.upperBound(cutoff, true);
let count = 0;
const req = idx.openCursor(range);
req.onerror = () => reject(req.error);
req.onsuccess = () => {
const cursor = req.result;
if (!cursor) return; // 遍历完毕,等 tx.oncomplete
const msgId = cursor.value.id;
// 级联删分片:用 messageId 索引定位
const partReq = tx.objectStore('message_parts').index('messageId')
.openKeyCursor(IDBKeyRange.only(msgId));
partReq.onsuccess = (e) => {
const c = (e.target as IDBRequest).result;
if (!c) return;
tx.objectStore('message_parts').delete(c.primaryKey);
c.continue();
};
cursor.delete();
count++;
cursor.continue();
};
tx.oncomplete = () => resolve(count);
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error ?? new Error('prune aborted'));
});
}
}
关键点有三处。其一,迁移按 oldVersion 分段,幂等可重入。其二,消息元数据与分片分仓库,更新单字段不重写整条。其三,分页用 cursor.advance 跳过已加载页,避免全量遍历。事务失败统一走 onerror 与 onabort 兜底,调用方捕获即可重试。
四、持久化的代价:存储膨胀、迁移风险与隐私边界
IndexedDB 持久化并非无损。
第一类代价是存储膨胀。AI 对话富文本、工具调用结果、附件引用体积大,几个月积累下来可达数百 MB。浏览器配额超限后写入直接失败。必须有过期清理策略——按时间或按条数裁剪,并把清理放到 requestIdleCallback 里,避免抢主线程。某产品曾因无清理,半年后用户首屏加载 IndexedDB 耗时 1.2 秒。
第二类代价是迁移风险。版本迁移若写错,可能丢历史数据。迁移代码必须幂等,且上线前在本地全量回归旧版本库。生产环境建议加 schema 版本埋点,发现用户卡在旧版时主动提示刷新。
第三类代价是隐私边界。对话历史常含敏感信息,前端持久化意味着数据留在用户设备。企业版与合规场景需提供"一键清除全部"入口,并在用户登出时按策略清理。多设备同步要避免把 A 设备的私聊历史泄露到 B 设备。
第四类代价是多标签并发。同一站点在多标签打开时,IndexedDB 升级会互相阻塞,触发 onblocked。处理方式是提示用户关闭其他标签,或用 BroadcastChannel 协调单写多读。
适用边界:需要离线可读、快速恢复、跨会话检索的 AI 产品收益最高。一次性问答、强匿名场景则不必持久化,避免引入存储与隐私负担。
五、总结
IndexedDB 把 AI 会话历史留在前端,解决离线可读、快速恢复与请求量三个问题。落地建议:第一,按会话、消息、分片三仓库拆分,元数据轻、内容下沉。第二,索引按 sessionId、updatedAt、createdAt 设计,覆盖分页、列表、检索三类查询。第三,版本迁移集中在 onupgradeneeded,按 oldVersion 分段且幂等。第四,写入用读写事务原子化,失败走 onerror 与 onabort 兜底。第五,过期清理配合 requestIdleCallback,避免配额超限与首屏卡顿。这条路在万轮对话与离线恢复场景下能跑通,回报是值得的。


