
我最早接触到 IndexedDB 的时候满脑子想的都是getAll数据反正都存在本地一把梭全读出来不就行了。直到有次负责的数据模块记录量从几千条涨到十几万条页面在读取数据的那一瞬间直接白屏任务管理器里内存曲线像过山车一样我才开始认真研究这个一直被我忽略的 API——游标。IndexedDB 游标IDBCursor本质上是一个基于记录顺序的流式遍历接口它不像getAll那样一次性返回整个数组而是一条一条地读取记录用完一条再取下一条。这个差别在数据量大时非常明显内存占用、渲染卡顿、事务控制全都跟着变了。这篇文章我打算把游标从原理到实践完整梳理一遍重点说清楚为什么它是大批量数据遍历的最优解、四种方向怎么选、哪些坑我踩过以及怎么写出不卡页面的遍历代码。不管你是刚接触 IndexedDB 的新手还是已经用过getAll想要进一步优化性能的开发者这篇内容应该都能帮上忙。1. 为什么说 IndexedDB 游标才是大批量数据遍历的正解1.1 getAll 的一次性烦恼内存与渲染双高先说说最直观的写法很多第一次用 IndexedDB 的朋友都会这样做function getAllLogs(db) { return new Promise((resolve, reject) { const tx db.transaction(logs, readonly); const store tx.objectStore(logs); const req store.getAll(); req.onsuccess () resolve(req.result); req.onerror () reject(req.error); }); }这种写法在数据量小的时候非常舒服代码简单逻辑直接。但问题在于getAll会一次性把对象存储里的所有记录反序列化成 JavaScript 对象再作为一个大数组返回。假设一条记录是一份包含几十个字段的日志对象几万条记录堆在一起瞬时内存占用很吓人。浏览器在返回数组的那一刻不但要完成大量结构体解析还要在内存里维护一整套数组引用关系GC 压力也会上来。渲染端的表现更明显。如果你把getAll的数据直接forEach往页面里塞几万条 DOM 节点会让页面卡到没法看。即使你只做数据统计不需要渲染一次性加载一万条和三万条数据的耗时差距也不是线性的对象越大、字段越深反序列化成本越高。那getAll是不是就完全不能用了也不是。几百条、最多两三千条的小数据集getAll反而是更好的选择因为这个量级的内存和解析开销完全可以接受代码又短又清晰。真正的分水岭在于你的数据量是不是还会继续增长以及你是否需要边遍历边做修改。如果数据规模可能膨胀或者你根本不需要把所有数据都留在内存里那么游标就是更好的选择。1.2 游标的“流式”不是玄学看它怎么走游标为什么能解决getAll的问题我的理解是它改变了“数据一次到位”的思路改成了一条一条地取。你可以把游标想象成翻一本很厚的书getAll是先拿手机把整本书从头到尾每一页都拍下来然后你对着照片翻游标则是你手里只拿当前这一页纸看完了一页翻到下一页手里永远是那一页前面的内容看完就丢不用记一整本。IndexedDB 底层是基于键值存储和索引树组织的游标本质上是沿着这棵索引树或者记录顺序移动的指针。每次openCursor请求成功触发onsuccess回调时你拿到的是当前这“一页”也就是一条记录。处理完后调用cursor.continue()游标才会挪到下一条然后再次触发onsuccess。当游标走到结尾时回调里的cursor会变成null这时候遍历就结束了。这里有个小细节很多人没注意如果你不需要记录内容只需要游标走到某个位置或者只想挨个拿到主键可以改用openKeyCursor。它返回的是IDBCursor不包含value省去了整个对象反序列化的开销适合只需要 key 做统计、去重的场景。在几十万条数据上openKeyCursor比openCursor要快不少因为浏览器不用帮你把每条记录的值从存储中解码出来。2. IndexedDB 游标 API 实操从 openCursor 到四种方向控制2.1 最小可用示例打开游标、读取记录、移向下一条我先把一个最常用的游标遍历封装写在这里这是整个游标用法的骨架function walkAllLogs(db) { return new Promise((resolve, reject) { const tx db.transaction(logs, readonly); const store tx.objectStore(logs); const req store.openCursor(); const result []; req.onsuccess function () { const cursor req.result; if (!cursor) { resolve(result); return; } result.push(cursor.value); // 关键调用 continue 驱动游标走向下一条 cursor.continue(); }; req.onerror function () { reject(req.error); }; }); }拆开看这个示例里有三件事很关键。第一openCursor()不传参数时表示从对象存储的第一条记录开始遍历方向是升序。它返回的是一个IDBRequest真正的游标对象在req.result里。这里有个容易混淆的点很多人以为openCursor返回的就是游标其实不是你需要等请求成功从req.result里取。第二每次onsuccess触发时cursor要么是一条记录对应的游标对象要么是null。只有cursor null才代表遍历到了最后。而在拿到游标对象期间cursor.key是当前记录的主键cursor.value是当前记录的值对象这两个字段在你需要做条件判断时非常有用。第三cursor.continue()是驱动游标前进的唯一关键动作忘记调用它游标就停在原地不动后续记录永远取不到。如果你在一个onsuccess回调里做了很多复杂的同步处理一定要确保在所有操作结束后调用continue()。在只读事务模式下游标默认具备读取能力如果你想把遍历到的数据返回出去直接收集到数组或者累加变量里即可。注意不要在一个onsuccess里重复调用continue()同一个请求上连续调用多个继续操作会直接抛InvalidStateError这个后面坑位小节再说。2.2 四种遍历方向prev 倒序与 unique 去重的实际意义openCursor的第二个参数是方向它一共有四个可选值next、prev、nextunique、prevunique。默认是next很多人用了一年游标也只用了这一个值另外三个在特定场景下非常有用。方向顺序适用场景next主键升序最常规的全量遍历、日志正序导出prev主键降序时间倒序取最近 N 条、最新记录优先处理nextunique索引键升序重复值只取第一条对索引去重、统计唯一值prevunique索引键降序重复值只取第一条按索引反向去重取每组里的“最新”一条这里有一个很多人搞混的知识点nextunique和prevunique里的“唯一”并不是对整个表去重而是针对索引键去重。你打开的是对象存储本身的游标时主键一定是唯一的所以这两个方向与next、prev没有区别只有当你打开的是索引游标index.openCursor时同一个索引键对应多条记录这时候unique方向才有真正的意义。prev方向的实用场景很多。比如我要在页面上展示某个表里最新创建的 100 条记录按主键倒序最常规的做法是const req store.openCursor(null, prev); let count 0; req.onsuccess function () { const cursor req.result; if (!cursor || count 100) return; // 处理 cursor.value count; cursor.continue(); };当取够 100 条时直接不再调用continue()遍历自然结束事务也会在事件循环空闲时自动提交。这样做的好处是你不用先正序扫一遍全部数据再 slice 最后一百条游标会直接从那棵 B 树的末尾往回走取满即停效率非常高。注意if (!cursor || count 100) return;这段里当cursor null时不做continue不报错当count 100时是主动收手也不报错。但如果你在条件不满足时忘了continue那就会出现“拿到一条就停住”的诡异现象。2.3 keyRange 范围控制让游标只扫描真正需要的数据游标默认是从头走到尾但如果你的数据量很大而你需要处理的只是其中一段区间比如 ID 从 1000 到 2000 的记录或者某个时间点之后的记录那一定要学会用IDBKeyRange把游标的活动范围框住。IDBKeyRange提供了四个构造方法// 精确匹配某个 key const exactRange IDBKeyRange.only(key-001); // 下界 value第二个参数 true 表示排除边界 const lowerRange IDBKeyRange.lowerBound(1000, false); // 上界 value const upperRange IDBKeyRange.upperBound(2000, true); // 区间包含下界排除上界 const boundRange IDBKeyRange.bound(1000, 2000, false, true);用法很简单把 range 作为openCursor的第一个参数传进去就行const range IDBKeyRange.bound(1000, 2000, false, false); const req store.openCursor(range);这里有一个值得注意的细节bound(lower, upper, lowerOpen, upperOpen)的第三个和第四个参数分别控制下界和上界是否开放也就是是否排除边界值本身。很多人容易搞反想取“1000 到 2000 之间包含两端”结果写成bound(1000, 2000, true, true)边界值就丢了。便宜的做法是两端都写false或者干脆用小范围测试几条数据确认结果。结合索引使用 keyRange 效果更好。比如你想查createTime大于某个时间戳的所有记录因为createTime是索引键你可以const index store.index(by_createTime); const range IDBKeyRange.lowerBound(someTimestamp, true); const req index.openCursor(range);这样游标会直接定位到目标索引位置开始遍历完全不碰之前的数据。经验是能缩范围就缩范围少一次回调就少一次主线程消耗。无差别全表扫描只应该是最后的兜底手段绝不能在知道目标范围时还让游标白白跑一遍。3. 游标实战场景更新删除、索引遍历与分批流水线3.1 遍历时直接改数据update 和 delete 的用法与边界游标不只是能读数据它还能在遍历过程中直接更新和删除当前记录。这才是它相对于getAll的另一个核心优势你不需要把整个数组读回来改完再整体写回而是边走边处理。要实现写操作事务模式必须是readwrite。示例场景给所有未归档的历史日志打上archived: true标记。function archiveAllLogs(db) { return new Promise((resolve, reject) { const tx db.transaction(logs, readwrite); const store tx.objectStore(logs); const req store.openCursor(); let count 0; req.onsuccess function () { const cursor req.result; if (!cursor) { resolve(count); return; } const record cursor.value; if (!record.archived) { record.archived true; cursor.update(record); count; } cursor.continue(); }; req.onerror function () { reject(req.error); }; }); }这里有两个容易踩的坑。第一个是更新方式。cursor.update(newValue)会整体替换当前记录它不会像对象扩展运算符那样帮你做字段合并。我在需求中经常看到有人直接cursor.update({ archived: true })结果整条记录被替换成了一个只有archived字段的新对象其他字段全丢了。正确做法是先取出cursor.value在已有对象上修改属性再把整个对象传回update。第二个是索引冲突。如果你在某个唯一索引上更新了索引键的值而新值已经存在于另一条记录更新会抛出ConstraintError。这个错误在onsuccess里是看不出来的你要给cursor.update返回的IDBRequest单独挂onsuccess/onerror才能捕获到。我通常会把更新请求的错误处理单独写好避免静默失败。删除操作相对简单一些直接在当前游标上调用cursor.delete()即可。但批量删除一千条和删除一万条的事务耗时会明显不一样如果你在同一个事务里一口气删除几万条记录索引树重建的成本和写放大问题会让事务时间变得很长甚至可能出现存储层卡顿。后面我单独说怎么用分批流水线处理这种大操作。3.2 用索引游标实现按业务字段排序与去重对象存储本身是按主键排序的但实际业务很少只要求按主键遍历更多的是按时间、按状态、按用户字段排序。这种需求不能直接openCursor搞定你得借助索引。假设我有一个订单对象存储每条订单有createTime字段。为了按创建时间倒序读取我先在onupgradeneeded里建好索引const store db.createObjectStore(orders, { keyPath: id }); store.createIndex(by_createTime, createTime, { unique: false });然后打开索引游标const index store.index(by_createTime); const req index.openCursor(null, prev); req.onsuccess function () { const cursor req.result; if (!cursor) return; // 注意此时 cursor.key 是 createTime不是 id // cursor.primaryKey 才是记录真正的主键 id const order cursor.value; // 处理订单 cursor.continue(); };这里有个非常基础但也容易混淆的点在索引游标中cursor.key是索引键的值比如createTime而cursor.primaryKey是对象存储的主键比如id。只有当你直接打开对象存储游标时key才等于主键。我在排查问题的时候见过不少人在索引游标里用cursor.key去定位记录结果拿到的不是主键导致后续查询全错。再提到 unique 方向的实战价值。场景是每条日志可能有多个操作版本同一个操作编号opId对应多条记录而你现在只想按操作编号升序取每个编号的第一条记录。这时候直接使用索引的nextunique方向就能实现const index store.index(by_opId); const req index.openCursor(null, nextunique);游标会自动跳过相同opId的后续记录一次遍历下来你拿到的就是一组去重后的结果省掉了手动哈希去重的代码。反向按时间顺序取每组最新一条就用prevunique。索引游标还有一个进阶接口cursor.continuePrimaryKey(key, primaryKey)它只对索引游标有效作用是把游标跳到“索引键等于指定 key且主键大于指定 primaryKey”的位置。这个接口适合在分组遍历时跳过一部分记录。比如说你在按opId遍历时已经处理过opId OP-001的某条记录下次想直接跳到同一opId下另一条主键更大的记录用continuePrimaryKey就非常高效不用一条一条continue过去。3.3 让游标分批处理海量数据避免一次扛走十万条写到这里我想专门讲一个我自己在项目里反复用到的模式游标分批处理流水线。这个模式的主要目的是让长任务不阻塞主线程也给 UI 上展示进度条留出机会。核心思路很简单不要把整个数据集的遍历放在一个事务里而是每批只处理 N 条达到阈值就结束当前事务把控制权还给浏览器等下一帧或者下一个宏任务再开新事务继续扫。这样可以避免长时间占用事务导致的锁定压力也能让页面在大数据处理期间保持响应。下面这段是一个分段删除指定范围内旧日志的封装直接可用async function deleteOldLogsInBatches(db, range, batchSize 500) { let total 0; while (true) { const count await deleteBatch(db, range, batchSize); total count; if (count batchSize) break; // 休眠一帧让出主线程 await new Promise((resolve) setTimeout(resolve, 0)); } return total; } function deleteBatch(db, range, limit) { return new Promise((resolve, reject) { const tx db.transaction(logs, readwrite); const store tx.objectStore(logs); const req store.openCursor(range); let count 0; req.onsuccess function () { const cursor req.result; if (!cursor || count limit) { resolve(count); return; } count; cursor.delete(); cursor.continue(); }; req.onerror function () { reject(req.error); }; }); }这段代码里最核心的地方是if (!cursor || count limit)。当删除数量到达上限时我不再调用continue()当前区块遍历自然停止limit数量的删除请求已经发出事务会在事件循环空闲后自动提交。函数返回count后外层循环继续开启新的readwrite事务从原来的位置重新定位游标继续删除直到实际删除数小于batchSize说明已经没有更多数据可删。这个方法的优点是每批事务都很短删除过程可中断、可恢复。配合进度条你还能给用户显示“已处理 X 条”体验好很多。批大小我一般定在 500 到 2000 之间太小会导致事务频繁开启反而慢太大会让单批处理时间过长UI 还是会掉帧。具体数值可以在自己的数据规模上测一下原则是让每一批的处理时间控制在 50ms 以内。4. 游标常见坑位排查事务自动提交、死循环与卡顿优化4.1 事务自动提交游标“突然不走了”的元凶游标使用中最隐蔽、也最让人抓狂的一个坑是 IndexedDB 的事务自动提交机制。这不是游标本身的问题而是整个事务模型的特点但游标遍历在这种机制下暴露得最明显。IndexedDB 的事务生命周期是这样的当当前事件循环任务执行完毕且事务内没有挂起的请求时浏览器会自动提交事务。这意味着你不能在游标的onsuccess回调里“等一下”再继续遍历比如这样写就是错误的req.onsuccess function () { const cursor req.result; if (!cursor) return; // 错误示范先发一个网络请求之后再继续游标 fetch(/some-api).then(() { cursor.continue(); }); };这个写法的问题在于onsuccess回调执行结束后当前任务完成事务内已经没有挂起的 IDB 请求了浏览器会立刻自动提交事务。等fetch回来再调用cursor.continue()事务早就没了你会收到一个TransactionInactiveError告诉你事务已失效。为什么很多人遇到的“游标只遍历了几条就断了”大多是这个原因因为他们可能在游标回调里塞了await、setTimeout、某个异步统计函数。解决思路有三种在游标遍历阶段只做同步的轻量数据收集全部遍历完成后再统一交给异步逻辑处理把遍历切分成多个短事务每个事务内部保持同步处理批与批之间再做异步等待如果一定要在每一条记录上做异步操作那就放弃单游标的连续性改为批量拉取后离线处理或者用分批事务逐个批次衔接。我自己的原则很简单事务内部不要出现任何不知道何时完成的异步操作。游标回调里能写的只有两件事收集数据、调用continue。其他事情都放到事务外面做。4.2 忘记 continue 的连锁反应与死循环排查如果说事务自动提交是游标使用中最隐蔽的坑那么continue相关的坑就是最常见、也最容易自查的。先说“忘记调continue”的情况。新手最容易犯现象是游标回调只触发了一次拿到第一条记录后就再也不动了。因为onsuccess不会自动触发第二次游标只有在调用continue或者advance之后才会移动。这种问题很好定位在回调里打个日志看它是不是只打了一次。然后是“调用了多次continue”。在同一个onsuccess回调里你不能连续调用两次cursor.continue()这会在第二次调用时抛出InvalidStateError。原因很简单游标一次只能执行一个移动请求你还没等它走到下一条位置就让它再走一次显然是冲突的。如果需要跳过多条记录你应该用cursor.advance(n)或者设计好条件分支确保每个回调只走一条路径。还有一种是逻辑写出来的死循环。比如你在onsuccess里判断某个条件然后调用cursor.continue(key)跳转回之前的位置甚至原地打转就会导致回调不断触发、页面卡死。这里的关键是你要明白continue(key)的参数含义它让游标跳到大于等于这个 key 的第一条记录。如果你传入的 key 小于等于当前 key游标可能会在原地反复横跳。比如当前游标cursor.key为 100你调用cursor.continue(50)游标会跳到大于等于 50 的第一条也就是还是 100于是回调再次触发你又调用continue(50)无限循环就产生了。排查死循环的方法有两个一是在回调入口放置一个计数器超过一定次数主动退出并打印日志二是打开浏览器的调试工具把console.log(cursor.key)打出来观察 key 是不是在循环往复。不要相信自己的直觉先打日志再下结论。4.3 游标内同步重活导致卡顿的优化笔记游标虽然是流式遍历但别忘了它的回调是跑在浏览器主线程上的。你在onsuccess里做的任何同步操作都会直接影响页面渲染和交互响应。有的项目里每条记录是一个几十 KB 的 JSON 字符串需要解析、清洗、再拼接到结果里几万条跑下来页面必然卡成幻灯片。我复盘过这种卡顿优化方向基本都是这四条第一尽量使用openKeyCursor。如果你不需要记录值本身只是拿主键做范围判断或者统计完全可以用只返回 key 的游标省掉 value 反序列化的整块成本。第二把大字段挪到单独的 store。IndexedDB 没有“只查询部分字段”的能力游标取出来的就是整条记录。如果一条记录只有几个小字段常被遍历而一个大 JSON 字段不常用我会把大字段拆出去独立存储让小表游标跑得飞快必要的时候再根据主键去大表里单独取。第三游标回调里只做“搬运”。遍历收集阶段只做判断是否满足条件、把需要的字段 push 到数组、continue()。不要在里面做字符串拼接、正则匹配、加密、DOM 操作。收集完的数据后面想怎么折腾都行但别占用游标回调节点。第四把重处理拆到多个宏任务里。比如统计一万条记录里的字段分布可以每一帧只处理 200 条用requestIdleCallback或者setTimeout(fn, 0)分片执行。这样虽然总耗时会变长但用户看到的是一个能点、能滑的页面而不是一个白屏卡死几十秒的页面。我在本地数据集上简单测过这么一组数据十万条小日志记录getAll一次性取值内存瞬时峰值的量级明显高于游标逐条遍历整体完成时间接近但页面响应体验差异非常大。这种对比在不同设备和不同数据形态下会有差异但方向是一致的——数据规模越大流式处理带来的平滑性优势越明显。5. 游标高频问题速查与我的使用建议5.1 高频问题速查表我把这几年遇到的和身边同事经常问到的游标问题整理成了下面这个表格方便你遇到问题时快速对照。现象常见原因解决思路游标只取了第一条就不再触发回调里忘了调cursor.continue()检查每个成功回调是否只有一条前进路径调用continue报InvalidStateError同一个onsuccess里重复调用每个回调只调用一次跳多条改用advance报TransactionInactiveError游标回调里做了异步操作事务被自动提交事务内保持同步异步操作放到事务外或分批事务中取到一部分后游标“消失”onsuccess里await了网络请求或定时器把数据处理拆到遍历结束之后统一执行页面卡死控制台一直在刷日志死循环通常是用continue(key)跳回了当前位置打印cursor.key观察移动轨迹加次数上限更新记录后数据缺字段cursor.update()调整的是整个对象不是合并字段先取cursor.value修改再整体写回索引游标按字段排序后分页不准对cursor.key和主键的关系理解有误索引游标中key是索引键primaryKey才是主键想倒序取最新 N 条但跑完全量没用prev方向或没在取够后停止openCursor(null, prev)取够后不再continue大批量删除导致事务执行很久单事务内删了上万条记录分批事务每批用完即止批量之间让出主线程5.2 三条值得长期保留的使用经验第一小数据用getAll大数据用游标别“一刀切”。我自己的参考阈值大概是五千条数据形态简单时可以放到一两万但只要超过了这个量级我第一反应就是上游标。这不是说getAll一定慢而是游标的流式特性在数据膨胀时能保证内存和渲染都处于可控状态。第二把“遍历”和“处理”分开。游标的职责是高效地找到目标记录把数据交到你手上至于后面的计算、渲染、网络上报这些重活不要塞进游标回调里。我见过太多因为想在回调里顺手做点事导致的卡顿和事务中断最省心的做法就是回调里只压榨数据其余事后面再说。第三批量是万金油。不管是批量删除、批量导出、还是批量修改某段数据把一个大操作拆成若干个短事务来做至少有三个好处进度可控、出错点可定位、主线程不会被长时间占死。配合setTimeout或者requestIdleCallback让出主线程用户看到的是一个有反馈、可交互的页面而不是一个“未响应”的标签页。我在实际项目里最深的一次教训就是在游标回调里尝试等待一个网络请求完成之后再继续。那次之后我才真正理解 IndexedDB 的事务机制也把“面试里常考的事务自动提交”和真实开发中“为什么游标只走了一半”联系了起来。后面再遇到数据量大、逻辑复杂的遍历需求我第一件事永远都是先想清楚这个任务能不能拆成多个短事务拆完再看游标怎么走。掌握游标的核心不是背 API而是理解它背后的顺序访问模型和事务生命周期这两件事想通了大部分坑自然就绕开了。