IndexedDB 事务模型:读写锁、版本迁移与游标(Cursor)遍历

IndexedDB 事务模型详解:读写锁、版本迁移与游标遍历

各位开发者朋友,大家好!今天我们来深入探讨一个常被忽视但极其重要的 Web API —— IndexedDB。它是一个浏览器端的 NoSQL 数据库,广泛用于离线应用、缓存数据和本地持久化存储场景。在实际开发中,我们经常遇到的问题包括:如何安全地并发访问数据?如何优雅升级数据库结构?以及如何高效遍历大量数据?

这些问题的答案都藏在 IndexedDB 的核心机制之中——事务模型。本讲座将围绕三个关键点展开:

  1. 读写锁(Read-Write Locking)
  2. 版本迁移(Version Migration)
  3. 游标遍历(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 的平滑升级

随着业务发展,你可能会发现现有数据库结构不再满足需求。例如新增字段、更改索引或删除废弃表。这时就需要进行版本迁移。

版本迁移流程

  1. 打开数据库时指定新版本号(大于当前版本)。
  2. 触发 onupgradeneeded 回调。
  3. 在回调中对对象仓库进行增删改操作(不能直接插入数据!)。
  4. 完成后再正常读写数据。

关键限制

  • 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,但它足够强大;学会它的事务模型,就是掌握了前端数据持久化的黄金法则。

谢谢大家!欢迎在评论区提问,我们一起探讨更多实战技巧。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注