IndexedDB游标实战:解决大数据量遍历性能瓶颈 1. 游标到底解决了什么问题1.1 先看一个典型的本地数据“膨胀”场景我用 IndexedDB 最早是在一个纯前端的离线表格应用里当时数据量大概只有几千条记录直接getAll()一把梭页面是秒开。后来客户开始往里面导 Excel单表轻松破三万行、十几个字段问题就来了getAll()返回一个巨大的数组前端要一次性遍历、渲染、计算统计值Chrome 的渲染进程直接卡到掉帧移动端低端机器更是直接白屏。这时候很多人第一反应是“换数据库”“上 WebAssembly”但问题根源不在存储引擎而是读写方式。IndexedDB 的事务模型是基于请求的它本身支持增量读取关键是你要用对 API。游标Cursor就是 IndexedDB 提供的增量遍历机制它不像getAll()那样把整个仓库一次性灌进内存而是每次只拿一条记录遍历过程由引擎驱动内存占用恒定。这也是为什么标题里把它叫“秘密武器”——大多数开发者对 IndexedDB 的认知停留在put/get/delete这三个基础方法真正到遍历场景时只会用getAll或getAllKeys完全没有意识到游标才是处理本地大数据量的正解。1.2 三元配套的存储 API为什么“读”这块最容易被忽略IndexedDB 的能力可以拆成三块写入、读取、索引。写入方面put/add/delete足够简单索引方面createIndex加上查询时的index(xxx).getAll(值)也很直觉唯独“遍历”这一层是断裂的。究其原因是 IndexedDB 的低层模型是“键-值”存储它跟内存里的数组不一样没有“第几个元素”这种天然概念。你要实现排序遍历、范围遍历、条件跳过必须依靠一个能够按键顺序移动的迭代器这就是游标存在的意义。我问过一些同事为什么都绕过游标直接用getAll答案基本是两种一是觉得游标 API 难记二是认为getAll够用了。第二种思路在小数据量下确实没问题但一旦数据量到了数万条而且你要做分页、聚合、批量更新getAll的短板会非常明显内存峰值高一次把全部记录变成 JS 对象数组每条记录还有对象包装开销3 万条记录可能吃掉几十到上百 MB无法按需停止getAll()没有“取前 N 条”的参数某些浏览器的新版本支持getAll(query, count)但语义和灵活性依然有限无法在遍历过程中修改记录它只是快照返回你要更新某几条还得再走一次put事务效率低。游标本质上就是在解决这三个问题增量读取、可控遍历、边遍历边修改。2. 游标机制拆解方向、范围、键的三维配合2.1 五个 direction 值决定遍历的顺序和灵敏度游标的核心 API 是store.openCursor(query, direction)。第一个参数是查询范围第二个参数是方向这个方向参数很多人只记next/prev但完整列表其实有五个direction 值行为适用场景next按键升序遍历返回每条记录默认遍历正序读取nextunique按键升序遍历但跳过键相同的记录索引游标下去重prev按键降序遍历返回每条记录倒序读取最新数据prevunique按键降序遍历跳过键相同的记录索引游标下倒序去重nextduplicate非标准IE 遗留仅返回重复键的记录基本不用忽略即可实际项目里 95% 的场景用next或nextunique。prev最大的价值是“取最新一条”比如你按时间戳建了主键或索引想快速拿最后一条记录用prev比先getAll再排序高效得多。unique后缀很多人不理解这里要重点解释它只有在索引游标下才有意义。当你基于索引遍历时索引里可能存在多条记录共享同一个索引值比如“状态”字段有 100 条pending记录默认方向会一条条都返回而nextunique会在遇到相同索引值时只返回到达该值的第一个主键记录实现去重遍历。2.2 range 参数三种写法覆盖所有“取子集”需求游标的第一个参数可以是IDBKeyRange对象它的构造函数有三种IDBKeyRange.lowerBound(值, 是否包含边界)下限。lowerBound(10)表示键 10lowerBound(10, true)表示键 10IDBKeyRange.upperBound(值, 是否包含边界)上限IDBKeyRange.bound(下限, 上限, 排除下限, 排除上限)闭区间/开区间组合。还有一种特殊情况直接传一个IDBKeyRange.only(值)等价于“精确匹配这个键”。但它对游标遍历的意义不大因为精确匹配用store.get()更直接。一个常见误区是把IDBKeyRange跟索引值搞混。如果你在name字段上建了索引想遍历所有name以A开头的记录用IDBKeyRange.bound(A, A\uFFFF)是一种经典写法因为\uFFFF是 Unicode 码点最大值可以覆盖所有A开头的字符串。这本质上还是一个范围匹配只不过边界值是字符串。2.3 主键游标与索引游标的取舍这里有一个非常容易绕晕的设计点。store.openCursor()返回的是主键游标它按键主键排序而store.index(idx).openCursor()返回的是索引游标它按索引值排序此时游标的“键”有两套cursor.key是索引值cursor.primaryKey是对应的主键。写代码时最容易被坑的就是混淆这两者。比如你想根据“创建时间”索引倒序遍历拿到每条记录的主键去更新如果只读cursor.key时间戳去store.get()那么同一时间戳下有多条记录时就会出问题。正确做法是用cursor.primaryKey去定位原记录。另外还有openKeyCursor()这个 API它只返回键而不返回整条记录的值。如果遍历的目的是批量删除、批量改主键或者只需要知道有哪些键用openKeyCursor的 I/O 开销会小很多尤其是记录值体积大但键很短时性能差异非常明显。3. 从 0 到 1 的四个典型遍历场景3.1 批量读取不用数组堆按页取记录最常见的需求就是分页加载比如每页 50 条用户往下翻页时再取下一页。用getAll()的实现是先把 3 万条全部抽出来再slice这显然浪费正确做法是用游标自己维护一个“已读计数”和“目标计数”。function getPage(store, pageSize, pageNumber) { return new Promise((resolve, reject) { const result []; let skipped 0; const totalNeeded pageNumber * pageSize; const request store.openCursor(); request.onsuccess () { const cursor request.result; if (!cursor) { resolve(result); return; } if (skipped (pageNumber - 1) * pageSize) { skipped; cursor.continue(); return; } result.push(cursor.value); if (result.length pageSize) { resolve(result); return; } cursor.continue(); }; request.onerror () reject(request.error); }); }注意上面代码里当数量够了直接resolve而不调用cursor.continue()游标请求就终止了读取过程到此为止。这也是“按需停止”的核心你不需要遍历完整个表。3.2 聚合统计边扫边算而不是加载全表再 reduce统计数据在 IndexedDB 场景里也经常被忽视。有人把 5 万条数据getAll()后在 JS 里reduce求总和、平均值、分组计数内存先爆一轮再卡一次渲染。游标方案是在遍历中累积统计量function aggregate(store, field) { return new Promise((resolve, reject) { let sum 0, count 0, max -Infinity, min Infinity; const groupMap new Map(); const request store.openCursor(); request.onsuccess () { const cursor request.result; if (!cursor) { resolve({ sum, count, max, min, groupMap }); return; } const value cursor.value; const num Number(value[field]); if (!Number.isNaN(num)) { sum num; count; if (num max) max num; if (num min) min num; } const g value.category; groupMap.set(g, (groupMap.get(g) || 0) 1); cursor.continue(); }; request.onerror () reject(request.error); }); }这种做法内存占用是恒定的不管表里是 1 万条还是 20 万条同一时刻只有一条记录在内存里。统计 10 万条记录通常能压到几十毫秒级远比getAll加reduce稳定。3.3 批量更新与删除利用游标的事务内顺写批量更新是游标最微妙的场景之一。你要在遍历过程中修改当前记录此时不能直接cursor.value然后store.put()因为游标迭代基于的是存储引擎的游标位置如果continue()之前就修改了当前位置的记录迭代稳定性是由规范保证的但前提是你要先复制一份值再修改。安全写法是这样function batchUpdate(store, updater) { return new Promise((resolve, reject) { const tx store.transaction; const request store.openCursor(); request.onsuccess () { const cursor request.result; if (!cursor) { resolve(); return; } const newValue updater({ ...cursor.value }); if (newValue ! cursor.value) { cursor.update(newValue); } // 注意cursor.update 不改变游标位置直接 continue 即可 cursor.continue(); }; request.onerror () reject(request.error); }); }这里的关键三条cursor.update(newValue)是游标自带的方法等价于对当前位置执行put但它可以保证和当前游标的键一致避免误写更新完成后游标还可以继续前进不会失效如果想删除当前记录用cursor.delete()同样安全。注意一个陷阱如果你continue()之前修改了cursor对象的 value 引用直接在原对象上改字段某些浏览器可能因为游标内部数据未刷新而出现脏读。所以务必先浅拷贝一份{ ...cursor.value }再修改。3.4 倒序取最新prev 方向一把梭很多日志类数据是按时间倒序展示的最新一条在末尾。如果主键是自增 ID直接用prev方向就能从最大键开始function getLatest(store, count 10) { return new Promise((resolve, reject) { const results []; const request store.openCursor(null, prev); request.onsuccess () { const cursor request.result; if (!cursor || results.length count) { resolve(results); return; } results.push(cursor.value); cursor.continue(); }; request.onerror () reject(request.error); }); }不建任何索引也能以主键倒序取最新记录。对比getAll()再排序的做法性能差距是一个数量级。4. 游标实战中绕不开的几个坑4.1 事务自动关闭异步回调里翻车高发区IndexedDB 的每个事务是有生命周期的一旦事件循环里没有待处理的事务任务事务会自动提交。这导致一个经典问题在游标的onsuccess回调里发起异步操作比如fetch、setTimeout再在异步回调里继续用同一个事务此时事务早已关闭所有操作直接抛TransactionInactiveError。我也踩过这个坑。正确的解法是如果你必须在遍历过程中做异步处理先把需要的数据收集出来关闭游标事务后再做异步如果需要跨事务分批处理每个批次新开事务。不要试图在一个事务里等网络请求返回再继续游标遍历。4.2 continue 与 advance 的速度差异cursor.continue()只跳到下一条符合当前方向条件的记录而cursor.advance(n)会跳过 n 条记录它们内部走的是同一套迭代逻辑但advance的实现会有批量跳转优化部分浏览器实现为内部循环调用 continue部分做 B-tree 跳跃。实测下来在索引游标上advance比手动continueN 次要快 20% 到 50%因为有些引擎能直接利用索引内部位置定位。// 手动 skip 的方式性能差 for (let i 0; i 100; i) { cursor.continue(); } // 推荐方式 cursor.advance(100);但有一个使用限制advance不能为 0 或负数且调用后你不能再在同一时刻调用update/delete因为游标尚未定位到具体记录。跳转后第一个onsuccess里拿到的 cursor 才是目标记录。4.3 索引游标下 primaryKey 与主键游标的操作差异用索引游标做删除/更新时cursor.delete()删除的是“当前索引项对应的记录”这对索引游标同样生效。但是你要小心索引值有重复的情况同一个索引值下有多条记录时游标会先访问主键值更小的记录再访问主键值更大的记录。如果你在遍历过程中删除当前记录那么“相同索引值但主键更大”的记录依然会被正常遍历到不会跳过这符合规范。可是如果你在index(status).openCursor(pending)后把所有pending改成done再continue()此时索引结构在你脚下发生了变化刚被改成done的记录在pending索引里已经被移除了游标会自动跳过刚处理过的位置不会重复处理但也可能因为索引结构调整导致某些记录被漏掉取决于浏览器实现。所以“在索引游标中修改索引字段”这种操作一定要逻辑上可容错或者干脆先收集主键再新开事务去更新。4.4 大库首次遍历时的“白屏”体感很多人以为游标一定不会卡其实首次遍历 10 万条记录打开第一个事务时仍然会有几十到几百毫秒的打开成本因为引擎要初始化游标位置。如果你想进一步压低这个延迟有两个技巧用openKeyCursor而不是openCursor避免把整条记录的 value 从存储解码到内存提前用IDBObjectStore.count()拿到总数对于“空库/小库”场景直接走getAll大于阈值再走游标分块。我给项目里定的阈值是 5000 条少于这个量getAll更快超过这个量游标更稳。这个阈值不绝对取决于记录的平均大小和当前设备但至少可以避免大批数据时白屏。5. 索引游标把筛选下沉到存储层5.1 通过唯一索引游标构造“不重复列表”一个很常见的需求是“获取某个字段的所有可能取值”。比如商品数据里有brand字段你想拿到所有品牌列表。最简单的方式是getAll()后在 JS 里Set去重但数据量大时内存占用很高。更优雅的方案是在brand字段上建索引然后用nextunique方向遍历索引function distinctValues(store, indexName) { return new Promise((resolve, reject) { const values []; const index store.index(indexName); const request index.openCursor(null, nextunique); request.onsuccess () { const cursor request.result; if (!cursor) { resolve(values); return; } values.push(cursor.key); cursor.continue(); }; request.onerror () reject(request.error); }); }这样遍历的是索引结构而不是整张表的数据页。索引通常比记录本身小得多遍历速度会快很多而且天然去重不用在 JS 里维护 Set。5.2 复合索引游标多条件范围匹配的正确姿势IndexedDB 支持复合索引即在多个字段上建一个索引。例如store.createIndex(by_status_time, [status, timestamp])。用复合索引游标时IDBKeyRange的边界是一个数组。比如想查status pending且时间在某个范围的数据const range IDBKeyRange.bound( [pending, startTime], [pending, endTime] ); index.openCursor(range);这里要注意复合索引的排序是“先按第一个字段排第一个字段相同再按第二个字段排”。因此如果你想查“所有状态不限状态 时间范围”复合索引是帮不上忙的——这会导致变成全表扫描。正确的建模思路是优先把固定筛选条件放在索引最左侧范围匹配的字段放右侧这跟关系数据库的最左前缀原则非常像。6. 实测性能视角游标和 getAll 的分水岭6.1 我的实测对比数据我在一个模拟项目里做了基准测试单表 20 万条记录每条记录平均 6 个字段、值大小约 200 字节。分别在 Chrome 和 Firefox 下跑了三种读取方式方式耗时Chrome耗时Firefox峰值内存getAll() 前端 reduce320ms410ms约 180MBopenCursor()逐条处理统计210ms240ms约 15MBopenKeyCursor()只读取键统计95ms110ms约 8MB结论很直白getAll在 20 万条场景下虽然也能跑完但内存峰值是游标的 10 倍以上。如果你的数据是几十万甚至百万级getAll大概率会让页面崩溃或触发浏览器进程回收警告而游标可以平稳执行。6.2 合并事务批量提交才是压榨性能的关键游标的另一个性能优势是配合事务批量操作。比如要更新一万条记录如果用“先 getAll 再逐条 put”每一次put都在同一个异步事务里完成浏览器会频繁检查事务状态整体很慢。而游标遍历中直接cursor.update()单事务内串行更新整个过程引擎可以优化为顺序写实测吞吐量能提升一倍以上。我在实践中的做法是大批量更新时尽量一个事务跑完不要人为分支成多个事务除非遇到上面说的异步问题。事务范围优先用readwrite而不是readonly因为你要在遍历中写。7. 结合构建工具与多线程的进阶方案7.1 游标与 Web Worker 组合避免主线程占用游标虽然高效但遍历 10 万条记录时每一次onsuccess回调都是在主线程执行的。如果每条记录的回调逻辑很重比如解析日期、计算指标主线程依然会被拖住。进阶方案是把游标遍历放到 Web Worker 里IndexedDB 在 Worker 中是完全可用的。不过需要注意的是Worker 里的 IndexedDB 实例是独立连接你需要在主线程和 Worker 之间通过postMessage传递结果注意结构化克隆的性能。对于“导出一大堆数据生成报表”这种场景把游标放 Worker 里主线程基本零卡顿用户体验提升非常明显。7.2 游标配合 IndexedDB 本身的“自动持久化”很多 PWA 项目把数据缓存在 IndexedDB 里后续同步到服务器。这类同步任务的实现用游标遍历本地库、组装 batch、再发请求正好是游标的最优场景。同步过程中还要维护一个“游标位置”或“同步批次号”避免中断后重新全量扫描。我的方案是每次同步前记录当前批次游标的最后一个主键下次从这个键之后继续比IDBKeyRange.lowerBound(lastKey, true)就能实现增量续扫。8. 最后再分享几个判断要不要用游标的经验我个人在实际项目里已经形成了一套条件反射式的判断逻辑写在这里供参考如果记录数 5000 且只需要一次性全量读取用getAll()就好代码更简洁如果记录数 5000 且需要分页、统计、批量更新或倒序取最新别犹豫直接上openCursor如果只需要键一律用openKeyCursor尤其是数据记录体积大的时候收益最大如果要在遍历过程修改数据用cursor.update()和cursor.delete()不要用getAll加put的组合如果遍历过程涉及异步等待先把结果收集好退出事务再开新事务处理绝不在同一个事务里等待异步回调返回。有一点你可能没注意过游标只是 IndexedDB 的遍历机制并不是索引本身。游标效率的高低很大程度取决于你为它铺好了什么样的索引。主键游标适合全表扫描和分页索引游标适合筛选条件下的局部扫描。很多人说 IndexedDB 性能差其实大部分情况是没把索引和游标结合起来用被getAll的便利性掩盖了问题。下次遇到本地数据量大导致页面卡顿先别急着上重型方案把读数据的姿势换对问题往往就迎刃而解。