IndexedDB 事务模型详解:读写锁、版本迁移与游标遍历
各位开发者朋友,大家好!今天我们来深入探讨一个常被忽视但极其重要的 Web API —— IndexedDB。它是一个浏览器端的 NoSQL 数据库,广泛用于离线应用、缓存数据和本地持久化存储场景。在实际开发中,我们经常遇到的问题包括:如何安全地并发访问数据?如何优雅升级数据库结构?以及如何高效遍历大量数据?
这些问题的答案都藏在 IndexedDB 的核心机制之中——事务模型。本讲座将围绕三个关键点展开:
- 读写锁(Read-Write Locking)
- 版本迁移(Version Migration)
- 游标遍历(Cursor Traversal)
我们将结合真实代码示例,从理论到实践逐步剖析,帮助你构建更健壮、可维护的 IndexedDB 应用。
一、事务模型基础:为什么需要事务?
在传统关系型数据库中,事务是保证一致性的重要手段。而在 IndexedDB 中,事务同样至关重要,因为它是唯一能确保操作原子性和隔离性的机制。
IndexedDB 事务类型
| 类型 | 描述 | 允许的操作 |
|---|---|---|
readonly |
只读事务 | get, getAll, index.get, cursor 遍历等 |
readwrite |
读写事务 | 所有 readonly 操作 + put, add, delete |
versionchange |
版本变更事务 | 仅在数据库升级时使用,可修改对象仓库结构 |
✅ 注意:每个事务必须显式调用
.commit()或.rollback(),否则会因未完成而失败。
// 示例:创建一个只读事务
const request = indexedDB.open("MyDatabase", 1);
request.onsuccess = function(event) {
const db = event.target.result;
const transaction = db.transaction("users", "readonly");
const store = transaction.objectStore("users");
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = function(e) {
const cursor = e.target.result;
if (cursor) {
console.log(cursor.key, cursor.value);
cursor.continue(); // 继续遍历
}
};
};
读写锁机制(Concurrency Control)
IndexedDB 使用“乐观并发控制”策略,即多个事务可以同时存在,但如果发生冲突(比如两个 write 事务试图修改同一记录),则后提交的事务会被回滚。
锁粒度说明:
- 对象仓库级别锁:一个事务持有某个对象仓库的锁时,其他事务无法获取该仓库的写锁。
- 跨事务隔离:不同事务之间默认是“可重复读”(Repeatable Read),避免脏读和不可重复读。
举个例子:
// 事务 A:读取用户数据
const txA = db.transaction("users", "readonly");
const storeA = txA.objectStore("users");
storeA.get(1).onsuccess = function(e) {
console.log("Tx A read:", e.target.result); // 输出 { id: 1, name: "Alice" }
};
// 事务 B:尝试删除用户 1(注意:这是 write)
const txB = db.transaction("users", "readwrite");
const storeB = txB.objectStore("users");
storeB.delete(1);
// 如果 txA 在 txB 提交前已经执行完,则不会出错;
// 如果 txB 已经锁定并准备提交,txA 尝试修改或删除可能失败(取决于实现细节)
⚠️ 实际行为受浏览器实现影响(如 Chrome 和 Firefox 行为略有差异)。建议尽量避免交叉操作,尤其是在高并发环境下。
二、版本迁移:从 v1 到 v2 的平滑升级
随着业务发展,你可能会发现现有数据库结构不再满足需求。例如新增字段、更改索引或删除废弃表。这时就需要进行版本迁移。
版本迁移流程
- 打开数据库时指定新版本号(大于当前版本)。
- 触发
onupgradeneeded回调。 - 在回调中对对象仓库进行增删改操作(不能直接插入数据!)。
- 完成后再正常读写数据。
关键限制
onupgradeneeded是唯一允许修改对象仓库定义的地方。- 不允许在事务中执行
transaction.commit()或transaction.rollback()。 - 所有操作必须在同一个事务中完成(通常是
versionchange事务)。
const newVersion = 2;
const request = indexedDB.open("MyDatabase", newVersion);
request.onupgradeneeded = function(event) {
const db = event.target.result;
const oldVersion = event.oldVersion;
console.log(`Upgrading from version ${oldVersion} to ${newVersion}`);
// 如果是首次创建(oldVersion === 0)
if (oldVersion === 0) {
const usersStore = db.createObjectStore("users", { keyPath: "id" });
usersStore.createIndex("name", "name", { unique: false });
usersStore.createIndex("email", "email", { unique: true }); // 唯一索引
}
// 如果是从 v1 升级到 v2
if (oldVersion < 2) {
// 添加新的字段(注意:旧数据不会自动填充)
const usersStore = db.objectStore("users");
usersStore.createIndex("age", "age", { unique: false });
// 如果你想迁移旧数据(比如补全 age 字段)
const tx = event.target.transaction;
const store = tx.objectStore("users");
store.openCursor().onsuccess = function(e) {
const cursor = e.target.result;
if (cursor) {
const user = cursor.value;
if (!user.age) {
user.age = 0; // 默认年龄
store.put(user, cursor.key);
}
cursor.continue();
}
};
}
};
request.onsuccess = function(event) {
const db = event.target.result;
console.log("Database upgraded successfully!");
};
迁移常见陷阱
| 问题 | 解决方案 |
|---|---|
| 索引不一致导致查询失败 | 使用 createIndex 显式声明索引 |
| 数据丢失(尤其是旧版本无默认值) | 在 onupgradeneeded 中手动遍历并修复数据 |
多次调用 open() 导致意外触发升级 |
确保版本号递增且逻辑正确 |
💡 推荐做法:每次版本升级都加日志输出,并测试兼容性(特别是移动端 Safari 的表现)。
三、游标遍历:高效处理大批量数据
当你的对象仓库中有成千上万个记录时,getAll() 一次性加载会导致内存溢出或卡顿。此时应使用 游标(Cursor) 来逐条遍历数据。
游标类型
| 类型 | 用途 | 是否支持方向移动 |
|---|---|---|
IDBCursor |
标准游标(按主键顺序) | ✅ 支持 continue() / advance() |
IDBCursorWithValue |
包含 value 的游标 | ✅ 同上 |
IDBCursorDirection |
控制遍历方向(next/prev/nextunique/prevunique) | ✅ |
基础遍历方式
function traverseAllUsers() {
const request = indexedDB.open("MyDatabase", 1);
request.onsuccess = function(event) {
const db = event.target.result;
const transaction = db.transaction("users", "readonly");
const store = transaction.objectStore("users");
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = function(e) {
const cursor = e.target.result;
if (cursor) {
console.log(`User ID: ${cursor.key}, Name: ${cursor.value.name}`);
cursor.continue(); // 移动到下一个元素
} else {
console.log("End of traversal.");
}
};
};
}
高级技巧:分页与条件过滤
分页遍历(模拟 LIMIT/OFFSET)
function paginateUsers(pageSize = 10, page = 0) {
const offset = page * pageSize;
let count = 0;
const request = indexedDB.open("MyDatabase", 1);
request.onsuccess = function(event) {
const db = event.target.result;
const transaction = db.transaction("users", "readonly");
const store = transaction.objectStore("users");
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = function(e) {
const cursor = e.target.result;
if (cursor && count < offset) {
count++;
cursor.continue();
return;
}
if (cursor && count >= offset && count < offset + pageSize) {
console.log(`Page ${page}: User ID=${cursor.key}, Name=${cursor.value.name}`);
count++;
cursor.continue();
} else if (!cursor) {
console.log(`Finished pagination at page ${page}.`);
}
};
};
}
条件遍历(使用范围查询)
function findUsersByAgeRange(minAge, maxAge) {
const request = indexedDB.open("MyDatabase", 1);
request.onsuccess = function(event) {
const db = event.target.result;
const transaction = db.transaction("users", "readonly");
const store = transaction.objectStore("users");
// 使用索引进行范围查询
const index = store.index("age");
const range = IDBKeyRange.bound(minAge, maxAge);
const cursorRequest = index.openCursor(range);
cursorRequest.onsuccess = function(e) {
const cursor = e.target.result;
if (cursor) {
console.log(`Found user: ${cursor.value.name} (age=${cursor.value.age})`);
cursor.continue();
} else {
console.log("No more users in this age range.");
}
};
};
}
// 调用示例:查找年龄在 18~65 之间的用户
findUsersByAgeRange(18, 65);
性能优化建议
| 场景 | 最佳实践 |
|---|---|
| 大数据集遍历 | 使用游标而非 getAll(),避免内存压力 |
| 快速筛选 | 使用索引(index)配合 openCursor,而不是全表扫描 |
| 并行处理 | 若需批量更新,可在游标内开启子事务(谨慎使用) |
| 错误处理 | 加入 .onerror 监听,防止游标异常中断 |
// 错误处理示例
const cursorRequest = store.openCursor();
cursorRequest.onerror = function(e) {
console.error("Cursor error:", e.target.error);
};
cursorRequest.onsuccess = function(e) {
const cursor = e.target.result;
if (cursor) {
try {
// 处理每条数据
processUser(cursor.value);
cursor.continue();
} catch (err) {
console.error("Error processing user:", err);
cursor.continue(); // 继续下一条,不中断整个遍历
}
}
};
四、综合案例:构建一个带版本升级和游标遍历的用户管理系统
让我们整合前面的知识点,写出一个完整的 IndexedDB 用户管理模块:
class UserManager {
constructor(dbName = "UserDB", version = 1) {
this.dbName = dbName;
this.version = version;
this.db = null;
}
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onupgradeneeded = (event) => {
const db = event.target.result;
const oldVersion = event.oldVersion;
if (oldVersion === 0) {
const usersStore = db.createObjectStore("users", { keyPath: "id" });
usersStore.createIndex("name", "name", { unique: false });
usersStore.createIndex("email", "email", { unique: true });
}
if (oldVersion < 2) {
const usersStore = db.objectStore("users");
usersStore.createIndex("age", "age", { unique: false });
}
};
request.onsuccess = (event) => {
this.db = event.target.result;
resolve(this.db);
};
request.onerror = (event) => {
reject(event.target.error);
};
});
}
async getAllUsers() {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction("users", "readonly");
const store = transaction.objectStore("users");
const results = [];
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = (e) => {
const cursor = e.target.result;
if (cursor) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results);
}
};
cursorRequest.onerror = (e) => reject(e.target.error);
});
}
async addUser(user) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction("users", "readwrite");
const store = transaction.objectStore("users");
const request = store.add(user);
request.onsuccess = () => resolve(request.result);
request.onerror = (e) => reject(e.target.error);
});
}
}
// 使用示例
async function main() {
const manager = new UserManager("UserDB", 2);
await manager.init();
// 添加用户
await manager.addUser({ id: 1, name: "Alice", email: "[email protected]", age: 25 });
await manager.addUser({ id: 2, name: "Bob", email: "[email protected]", age: 30 });
// 获取所有用户(游标遍历)
const users = await manager.getAllUsers();
console.log("All users:", users);
}
main();
这个例子展示了:
- 自动版本检测与升级
- 安全的数据添加(写事务)
- 游标遍历实现数据获取(避免内存爆炸)
总结:掌握 IndexedDB 的三大支柱
| 技术点 | 核心价值 | 开发者收益 |
|---|---|---|
| 读写锁 | 保证并发安全性 | 避免竞态条件,提升稳定性 |
| 版本迁移 | 结构演进能力 | 支持长期维护和迭代 |
| 游标遍历 | 高效大数据处理 | 减少内存占用,改善性能体验 |
通过理解这些底层机制,你可以写出更健壮、高性能的 IndexedDB 应用,无论是做 PWA、离线编辑器还是复杂本地缓存系统,都能游刃有余。
记住一句话:IndexedDB 不是 SQL,但它足够强大;学会它的事务模型,就是掌握了前端数据持久化的黄金法则。
谢谢大家!欢迎在评论区提问,我们一起探讨更多实战技巧。