
后端缓存抽象【免费下载链接】dataloaderDataLoader is a generic utility to be used as part of your applications data fetching layer to provide a consistent API over various backends and reduce requests to those backends via batching and caching.项目地址https://gitcode.com/gh_mirrors/da/dataloader点击查看免费下载本篇技术指南以 GraphQL 基金会维护的 dataloader仓库版本 2.2.3见 package.json为核心系统讲解其在应用数据获取层中的两大核心能力——批处理Batching与缓存Caching从批处理函数的约束、调度机制到缓存的生命周期管理、全部 API 与配置项再到与 GraphQL 服务的经典结合方式。读完本文你将能够亲手构造一个可减少后端请求数量、且易于接入数据库与 Web 服务的 DataLoader 数据获取层并理解其底层实现原理。从 Facebook 的 Loader 到 Node.js 的 DataLoaderDataLoader 是一个通用的数据加载工具用于为应用的数据获取层提供统一、简洁的 API并通过批处理与缓存两种机制显著减少对数据库或 Web 服务等后端数据源的请求次数。它并非凭空发明的新概念而是 Facebook 工程师 schrockn 于 2010 年设计的 Loader API 的 JavaScript 移植版。当年 Facebook 内部存在大量各不相同的键值存储后端 APILoader 作为统一简化手段诞生并逐渐成为 Ent 框架Web 服务端代码中的隐私感知数据实体加载与缓存层的实现细节之一最终构成了 Facebook GraphQL 服务器实现与类型定义的底层支撑。DataLoader 是这一原始理念的精简版用 JavaScript 实现、面向 Node.js 服务。它最常被用于实现 graphql-js 服务但在其他场景下同样广泛适用。这种批处理 缓存的数据请求机制并非 JavaScript 特有也是 Facebook 为 Haskell 编写的数据加载库 Haxl 的核心动机DataLoader 以公开参考实现的形式发布正是希望这一概念能被移植到更多语言README 中按字母顺序列出了 Elixir、Golang、Java、.NET、Perl、PHP、Python、ReasonML、Ruby、Rust、Swift、C 等语言的社区实现清单若你完成了新语言的移植也欢迎在仓库中提交 issue 附上链接。快速开始安装与第一个 Loader使用 npm 安装npm install --save dataloader运行环境前提DataLoader 假设运行环境提供全局的 ES6Promise与Map类所有受支持的 Node.js 版本均已具备。此外从源码看它还有 Flow 类型src/index.js 顶部flow strict与完整的 TypeScript 声明src/index.d.tsTypeScript 项目可直接获得类型提示。创建一个 DataLoader 非常简单——只需提供一个批处理加载函数const DataLoader require(dataloader); const userLoader new DataLoader(keys myBatchGetUsers(keys));每个DataLoader实例都代表一个独立的缓存。当应用作为 Web 服务运行、不同用户可能看到不同数据时例如使用 express通常应为每个请求创建新的实例。核心机制一批处理Batching批处理不是 DataLoader 的高级特性而是它的首要特性。批处理函数接收一个 key 数组返回一个 Promise该 Promise 解析为一个 value 数组或 Error 实例详见下文批处理函数一节。先看一个直观例子const user await userLoader.load(1); const invitedBy await userLoader.load(user.invitedByID); console.log(User 1 was invited by ${invitedBy}); // 应用的其他位置 const user await userLoader.load(2); const lastInvited await userLoader.load(user.lastInvitedID); console.log(User 2 last invited ${lastInvited});一个朴素的实现可能要为这四条信息向后端发起四次往返请求而使用 DataLoader 后最多只需两次所有发生在单个执行帧事件循环的一个 tick内的load()调用都会被合并批处理函数一次性拿到全部 key。这正是 DataLoader 的核心价值它允许你把应用中无关的各个部分解耦同时不牺牲批量数据加载的性能。Loader 对外呈现的是逐个加载单个值的 API但所有并发请求都会被合并后交给批处理函数从而让应用可以安全地把数据获取需求分散到各处同时保持极低的外出请求量。批处理函数必须遵守的两条约束批处理函数接收 key 数组返回一个 Promise该 Promise 解析为 value 数组或 Error 实例。Loader 本身会作为批处理函数的this上下文测试 src/tests/dataloader.test.js 中references the loader as this in the batch function用例验证了这一行为。async function batchFunction(keys) { const results await db.fetchAllKeys(keys); return keys.map(key results[key] || new Error(No result for ${key})); } const loader new DataLoader(batchFunction);批处理函数必须满足两条硬性约束违反时dispatchBatch会抛出明确的TypeError见 src/index.js 的长度校验value 数组的长度必须与 key 数组的长度一致value 数组中每个索引必须与 key 数组中相同索引的 key 一一对应。例如批处理函数收到 key 数组[ 2, 9, 6, 1 ]而后端返回的结果顺序不同、且缺失了 key6的数据{ id: 9, name: Chicago } { id: 1, name: New York } { id: 2, name: San Francisco }后端按自身更高效的顺序返回而6可以解释为该 key 不存在。为满足约束批处理函数必须返回与 key 数组等长、且按原始顺序对齐的数组[ { id: 2, name: San Francisco }, { id: 9, name: Chicago }, null, // 或者 new Error() { id: 1, name: New York }, ];批处理调度默认行为与自定义调度器默认情况下DataLoader 会合并单个执行帧内的所有load()调用然后一次性调用批处理函数。这保证了在捕获大量相关请求的同时不引入额外延迟——事实上这正是 Facebook 2010 年原始 PHP 实现的行为。其调度底层是enqueuePostPromiseJob见 src/index.jsNode.js 环境下通过Promise.resolve().then(() process.nextTick(fn))保证分发任务在 PromiseJobs 微任务队列清空之后、下一个宏任务之前执行从而把 Promise 链式调用期间产生的所有加载也纳入同一批次浏览器环境则退化为setImmediate或setTimeout。测试用例batches loads occuring within promises验证了在多层Promise.resolve().then()中发起的加载仍会被合并为单批[A, B, C, D]。但有些场景下默认行为并不理想比如你因既有setTimeout的使用而预期请求会分散到后续几个 tick或希望完全手动控制分发时机。DataLoader 允许通过batchScheduleFn提供自定义调度器。batchScheduleFn接收一个回调并应在不久的将来调用该回调以执行批次请求。例如一个收集 100ms 窗口内所有请求代价是增加 100ms 延迟的调度器const myLoader new DataLoader(myBatchFn, { batchScheduleFn: callback setTimeout(callback, 100), });再如一个手动分发的调度器——先收集回调由你显式触发dispatch()才真正执行批次function createScheduler() { let callbacks []; return { schedule(callback) { callbacks.push(callback); }, dispatch() { callbacks.forEach(callback callback()); callbacks []; }, }; } const { schedule, dispatch } createScheduler(); const myLoader new DataLoader(myBatchFn, { batchScheduleFn: schedule }); myLoader.load(1); myLoader.load(2); dispatch();测试用例Supports manual dispatch进一步验证未调用dispatch()的批次永远不会被执行且自定义调度器同样以 loader 作为this上下文。此外源码getCurrentBatchsrc/index.js会在已有批次未分发且长度未达maxBatchSize时复用当前批次否则新建批次并安排调度——这就是批处理合并发生的精确位置。核心机制二缓存CachingDataLoader 为单个请求内发生的所有加载提供记忆化memoization缓存某个 key 一旦调用过.load()结果就会被缓存从而消除冗余加载。在源码层面缓存的值是Promise而非最终值见load()中cacheMap.set(cacheKey, promise)src/index.js因此所有共享同一 key 的调用方拿到的是同一个 Promise。每请求缓存定位与边界需要明确DataLoader 的缓存并不能替代 Redis、Memcache 等共享的应用级缓存。DataLoader 首先是一种数据加载机制它的缓存只服务于在单次应用请求的上下文中不重复加载相同数据这一目的——本质上.load()是一个记忆化函数。因此必须避免让多个不同用户的请求共享同一个 DataLoader 实例否则可能出现缓存数据错误地串入其他请求的情况。典型做法是请求开始时创建 DataLoader请求结束时弃用。与 express 配合的示例function createLoaders(authToken) { return { users: new DataLoader(ids genUsers(authToken, ids)), }; } const app express(); app.get(/, function (req, res) { const authToken authenticateUser(req); const loaders createLoaders(authToken); res.send(renderPage(req, loaders)); }); app.listen();缓存与批处理的协同对同一 key 的后续.load()调用该 key不会再出现在批处理函数的 keys 中但返回的 Promise 仍会等待当前批次完成。这样缓存命中的请求与未命中的请求会同时 resolve为后续依赖加载留出优化空间。下面的例子中User1已被缓存通过prime预置但因为1和2在同一 tick 内加载它们会同时 resolve导致两个user.bestFriendID的加载也落在同一 tick——最终总共只有两次请求与 User1未缓存时相同userLoader.prime(1, { bestFriend: 3 }); async function getBestFriend(userID) { const user await userLoader.load(userID); return await userLoader.load(user.bestFriendID); } // 应用的一处 getBestFriend(1); // 另一处 getBestFriend(2);如果没有这个优化缓存命中的 User1会立即 resolve三次请求将错开时间可能变成三次总请求。测试用例batches cached requests用缓存命中 未命中同时加载、并断言缓存命中的 Promise 与批次一起 resolve 的方式验证了该行为。清空缓存变更后的失效某些不常见场景下清空请求级缓存是必要的。最常见的场景是同一次请求内发生了 mutation 或更新此时缓存值可能已过期后续加载不应再使用任何可能的旧缓存值。以下用 SQL UPDATE 说明// 请求开始... const userLoader new DataLoader(...); // 某个值被加载并被缓存 const user await userLoader.load(4); // 发生变更缓存中的值可能失效 await sqlRun(UPDATE users WHERE id4 SET usernamezuck); userLoader.clear(4); // 稍后再次加载拿到变更后的数据 const user await userLoader.load(4); // 请求结束错误缓存策略如果整个批次加载失败批处理函数抛出异常或返回 rejected Promise则本次请求的值不会被缓存但每个请求仍会被 reject见源码failedDispatchsrc/index.js它会先 resolve 缓存命中、再逐个clear并 reject。如果批处理函数对某个具体值返回Error实例该 Error会被缓存以避免反复加载同一个错误。某些情况下你可能希望清掉这些单个错误的缓存try { const user await userLoader.load(1); } catch (error) { if (/* 判断该错误是否不应被缓存 */) { userLoader.clear(1); } throw error }测试Can clear values from cache after errors正是这一模式的验证每次捕获到错误后调用clear(1)批处理函数便被调用了两次。禁用缓存注意重复 key某些不常见场景下可能希望得到一个不缓存的 DataLoader。new DataLoader(myBatchFn, { cache: false })会保证每次.load()都产生新的 Promise请求的 key 也不会被保存在内存中。但请注意禁用记忆化缓存后批处理函数收到的 key 数组中可能包含重复项每个.load()调用都会对应一个 key批处理函数需要为每个出现的 key 实例提供值。例如const myLoader new DataLoader( keys { console.log(keys); return someBatchLoadFn(keys); }, { cache: false }, ); myLoader.load(A); myLoader.load(B); myLoader.load(A); // [ A, B, A ]测试用例Keys are repeated in batch when cache disabled验证了这一行为[A, C, D, C, D, A, A, B]原样传给批函数。比完全禁用缓存更精细的做法是保留记忆化缓存保证批函数收到唯一 key但在批次分发时立即清空缓存使后续请求重新加载新值const myLoader new DataLoader(keys { myLoader.clearAll(); return someBatchLoadFn(keys); });测试Complex cache behavior via clearAll()验证两轮加载都得到[A, B]两批请求且同一轮内的重复 key 已被合并去重。自定义缓存LRU 等任意 Map 实现如前所述DataLoader 定位于每请求缓存。由于请求生命周期短DataLoader 默认使用一个无限增长的Map作为记忆化缓存。短生命周期下这不是问题——请求结束后整个缓存即可丢弃。但若将 DataLoader 用于长生命周期场景无限增长的缓存可能消耗过多内存。此时可以提供一个遵循Map相同 API 的自定义 Cache 实例。例如用 lru_map npm 包实现 LRU最近最少使用缓存将内存限制在最多 100 个缓存值import { LRUMap } from lru_map; const myLoader new DataLoader(someBatchLoadFn, { cacheMap: new LRUMap(100), });更具体地说任何实现了get()、set()、delete()、clear()四个方法的对象都可以使用从而接入各种缓存淘汰算法。源码getValidCacheMapsrc/index.js会在构造时逐一校验这四个方法的存在性缺失即抛出TypeError测试中SimpleMap自定义实现验证了 get/set/delete/clear 的完整工作流。默认cacheMap为new Map()显式设为null则禁用缓存。完整 API 参考class DataLoaderDataLoader为特定数据后端提供加载数据的公开 APIkey 可以是 SQL 表的id列、MongoDB 的文档名等唯一标识配合批处理函数使用。每个实例包含独立的记忆化缓存——在长生命周期应用、或服务大量权限不同的用户时请谨慎并考虑每个 Web 请求新建实例。new DataLoader(batchLoadFn [, options])batchLoadFn接收 key 数组、返回 Promise解析为 value 数组的函数。options可选的选项对象Option KeyTypeDefaultDescriptionbatchBooleantrue设为false禁用批处理batchLoadFn每次只接收单个 key。等价于maxBatchSize设为1。maxBatchSizeNumberInfinity限制每次传给batchLoadFn的条目数。设为1可禁用批处理。源码要求必须为正数见getValidMaxBatchSizesrc/index.js。batchScheduleFnFunction默认的 PromiseJob 调度见上文批处理调度调度批次稍后执行的函数应在不久的将来调用传入的回调。cacheBooleantrue设为false禁用记忆化缓存对同一 key 的每次加载都会产生新 Promise 与新 key。等价于cacheMap设为null。cacheKeyFnFunctionkey key为给定加载 key 生成缓存 key。当 key 是对象、且两个对象应被视为等价时非常有用。cacheMapObjectnew Map()用作缓存的Map实例或 API 类似的对象。设为null可禁用缓存。nameStringnull该DataLoader实例的名称供 APM 工具使用。关于cacheKeyFn的典型用例测试Accepts objects with a complex key演示了把{ id: 123 }对象 key 规整为id:123字符串缓存 key使得两个等价对象命中同一缓存Accepts objects with different order of keys甚至能处理键顺序不同的对象{a: 123, b: 321}与{b: 321, a: 123}等价。类型定义见 src/index.d.ts 的Options与CacheMap声明。load(key)加载一个 key返回该 key 对应值的Promise。key要加载的 key 值。注意源码中load(null)或load(undefined)会抛出TypeErrorsrc/index.jskey 必须是有效值。loadMany(keys)加载多个 key返回一个值数组的 Promiseconst [a, b] await myLoader.loadMany([a, b]);这等价于更啰嗦的写法const [a, b] await Promise.all([myLoader.load(a), myLoader.load(b)]);两者的区别在于加载失败时的表现Promise.all()会整体 reject而loadMany()总是 resolve只是每个结果要么是值、要么是Error实例var [a, b, c] await myLoader.loadMany([a, b, badkey]); // c instanceof Error源码实现src/index.js对每个 key 调用load(...).catch(error error)后再Promise.all且接受类数组ArrayLike输入。keys要加载的 key 值数组。clear(key)从缓存中清除key对应的值如果存在。返回自身以支持链式调用。key要清除的 key 值。clearAll()清除整个缓存。用于发生某些导致该DataLoader整体失效的事件时。返回自身以支持链式调用。prime(key, value)用给定的 key 与 value 预置缓存。如果该 key 已存在则不做任何改动要强制预置可先loader.clear(key).prime(key, value)。返回自身以支持链式调用。也可以用Error实例预置错误缓存。源码实现src/index.js会为 Error 构造一个 rejected Promise并预先挂上.catch(() {})以避免未处理的 Promise 拒绝警告从而与load()的行为保持一致测试Handles priming the cache with an error验证了这一点。prime还支持直接传入 Promise。与 GraphQL 结合从 13 次请求降到 4 次DataLoader 与 GraphQL 是天生一对。GraphQL 字段被设计为独立的解析函数如果没有缓存或批处理机制朴素的 GraphQL 服务很容易在每次字段解析时都发起新的数据库请求。考虑如下 GraphQL 请求{ me { name bestFriend { name } friends(first: 5) { name bestFriend { name } } } }如果me、bestFriend、friends各自都需要请求后端朴素实现最多可能产生13 次数据库请求使用 DataLoader 后可参照 examples/SQL.md 的 SQLite 示例定义User类型代码更清晰请求最多降至4 次若命中缓存还可能更少const UserType new GraphQLObjectType({ name: User, fields: () ({ name: { type: GraphQLString }, bestFriend: { type: UserType, resolve: user userLoader.load(user.bestFriendID), }, friends: { args: { first: { type: GraphQLInt }, }, type: new GraphQLList(UserType), resolve: async (user, { first }) { const rows await queryLoader.load([ SELECT toID FROM friends WHERE fromID? LIMIT ?, user.id, first, ]); return rows.map(row userLoader.load(row.toID)); }, }, }), });这里bestFriend与friends内部的userLoader.load(row.toID)都通过同一个 loader 合并批处理这正是N1 问题的经典解法。常见模式模式一每个请求创建新的 DataLoader许多应用中Web 服务器会为权限不同的众多用户服务。跨用户共享一个缓存是危险的推荐每请求创建function createLoaders(authToken) { return { users: new DataLoader(ids genUsers(authToken, ids)), cdnUrls: new DataLoader(rawUrls genCdnUrls(authToken, rawUrls)), stories: new DataLoader(keys genStories(authToken, keys)), }; } // 处理到来的 Web 请求时 const loaders createLoaders(request.query.authToken); // 然后在应用逻辑中 const user await loaders.users.load(4); const pic await loaders.cdnUrls.load(user.rawPicUrl);每个 key 是一个 DataLoader的对象是一种常见组织方式它提供了单一值如 graphql-js 请求的rootValue的一部分传递给需要数据加载的代码。模式二通过替代 key 加载有时同一类值可通过多种途径访问。比如 User 既可以按id加载也可以按username加载。如果同一用户被两个 key 都加载过那么从任一路径加载时都最好同时填充两个缓存——这正需要primeconst userByIDLoader new DataLoader(async ids { const users await genUsersByID(ids); for (let user of users) { usernameLoader.prime(user.username, user); } return users; }); const usernameLoader new DataLoader(async names { const users await genUsernames(names); for (let user of users) { userByIDLoader.prime(user.id, user); } return users; });模式三冻结结果以强制不可变性既然 DataLoader 会缓存值通常假定这些值会被当作不可变对象对待。DataLoader 自身不强制这一点但你可以用高阶函数配合Object.freeze()强制执行function freezeResults(batchLoader) { return keys batchLoader(keys).then(values values.map(Object.freeze)); } const myLoader new DataLoader(freezeResults(myBatchLoader));模式四批处理函数返回对象而非数组DataLoader 期望批处理函数返回与 keys 等长的数组但很多第三方库的返回格式并不是这样。可以用高阶函数做格式转换。下面这个例子把{ key: value }形式的结果转换成 DataLoader 期望的格式function objResults(batchLoader) { return keys batchLoader(keys).then(objValues keys.map(key objValues[key] || new Error(No value for ${key})), ); } const myLoader new DataLoader(objResults(myBatchLoader));常见后端实践想针对特定后端快速上手仓库 examples 目录提供了多种后端示例包括examples/SQL.mdSQLite 的WHERE id IN批量查询——虽然 SQL 不是键值存储但DataLoader在查询足够简单时同样适用示例展示了如何在回调中按 key 顺序重排行、缺失行返回Error。examples/Redis.mdRedis 原生提供MGET批量命令与 DataLoader 天然契合。此外还有 CouchDB.md、GoogleDatastore.md、Knex.md、RethinkDB.md 等示例文档覆盖文档数据库与查询构建器场景。源码内部机制一览深入 src/index.js 可以看到 DataLoader 的精巧设计加载路径load()src/index.js先查缓存命中则把 resolve 动作挂到批次的cacheHits队列保证与批次同帧 resolve未命中则把 key 推入当前批次、创建 Promise 并把回调挂入callbacks同时将 Promise 写入缓存。批次分发dispatchBatch()src/index.js标记批次已分发以 loader 作为this调用批处理函数依次校验返回了 Promise解析值为数组长度与 keys 一致三类错误任何一类失败都会走failedDispatch兜底随后 resolve 缓存命中、并逐一下发值Error实例则 reject。缓存清理clear/clearAllsrc/index.js通过cacheKeyFn计算缓存 key 后删除均支持链式返回。调度原语enqueuePostPromiseJobsrc/index.jsNode 环境用Promise.resolve().then(() process.nextTick(fn))将分发任务排在 PromiseJobs 之后浏览器环境退回setImmediate/setTimeout——这是单帧合并能在异步场景依然生效的根本原因。仓库 src/tests/dataloader.test.js 中的 1000 行测试覆盖了批处理合并、最大批次、缓存命中协同、错误传播、对象 key、自定义调度与缓存、以及从 loader 调用 loadercan call a loader from a loader验证嵌套加载仍被正确合并等全部行为是理解 API 边界的权威参考。结语DataLoader 用两个精炼的概念——批处理与每请求记忆化缓存——解决了 Node.js 数据获取层最普遍的 N1 请求问题。它不试图替代 Redis 等应用级缓存而是专注于单次请求内不重复、不零散地取数配合 GraphQL 字段解析模型尤其高效。理解其批处理函数约束、调度时机与缓存生命周期你就能在自己的应用中安全、高效地落地这一经典模式。赞分享后端缓存抽象【免费下载链接】dataloaderDataLoader is a generic utility to be used as part of your applications data fetching layer to provide a consistent API over various backends and reduce requests to those backends via batching and caching.项目地址https://gitcode.com/gh_mirrors/da/dataloader点击查看免费下载相关推荐Next.js 数据获取与缓存实战指南基于 claude-skills nextjs-developer 技能的完整数据层方案Next.js 数据获取与缓存实战指南基于 claude skills nextjs developer 技能的完整数据层方案 本文是 claude skilAI 技能AI 插件后端前端DevOpsDataLoader完全指南如何通过批处理和缓存优化数据加载性能DataLoader完全指南如何通过批处理和缓存优化数据加载性能 DataLoader是一个通用实用工具可作为应用程序数据获取层的一部分通过批处理和缓存减后端缓存抽象GraphQL Java DataLoader 终极指南掌握批处理与缓存优化策略GraphQL Java DataLoader 终极指南掌握批处理与缓存优化策略 GraphQL Java DataLoader 是提升 API 性能的关键工后端创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考