Facebook Dataloader

简介

DataLoader 是facebook推出的一款通用工具,可为传统应用层与持久层之间提供一款缓存批处理的操作。JS、Java、Ruby、Go等主流语言都有开源三方库支持。尤其在Graphql兴起后,DataLoader被广泛地应用于解决N+1查询问题

机制

DataLoader的实现原理很简单:就是把每一次load推迟到nextTick中集中处理。在现实开发中其主要有两点应用:

  1. 批处理操作
batch.png
  1. 内存级别的缓存
cache.png

案例

以下以nodejs中调用dynamoose api为例具体介绍一下DataLoader在dynamodb查询时的一些使用方法。

批处理 (Batching)

先看一下传统DAO设计实现中的模版方法。我们通过name来获取user的信息。

// #UserDao.ts

import {ModelConstructor} from 'dynamoose';
import {DataSchema, userModel} from './User.schema'; 

export default class UserDao {

  private readonly model: ModelConstructor<DataSchema, string>;

  constructor() {
    this.model = userModel; //Dynamoose user schema model
  }

  public getUser(name: string) {
    console.log('Get user:', name);
    return this.model.get(name);
  }
}

当我们调用getUser方法时:

import UserDao from './UserDao';

async function run(user) {
  const users = await Promise.all([
    user.getUser('Garlic'),
    user.getUser('Onion'),
    ]);

  console.log('return:', users);
}

run(new UserDao());

打印结果是:

Get user: Garlic
Get user: Onion
return: [ Model-user { name: 'Garlic' }, Model-user { name: 'Onion' } ]

显然,dynamodb被访问了两次。再看一下使用DataLoader后的情况。

// #UserLoader.ts

import Dataloader = require('dataloader');
import {DataSchema, userModel} from './User.schema';

const BatchLoadFn = (names: [string]) => {
  console.log('Get Keys:', names);
  return userModel.batchGet(names);
};

export default class UserLoader {

  private readonly loader: Dataloader<string, DataSchema>;

  constructor() {
    this.loader = new Dataloader<string, DataSchema>(BatchLoadFn);
  }

  public getUser(name: string) {
    return this.loader.load(name);
  }
}

DataLoader初始化时必须传入一个BatchLoadFn,在dataloader/index.d.ts里找到如下定义:

type BatchLoadFn<K, V> = (keys: K[]) => Promise<Array<V | Error>>;

BatchLoadFn的参数是数组且返回是个包在Promise里的数组。因此可以直接调用dynamoose的batchGet方法。

再次调用上述的run方法

async function run(user) {
  const users = await Promise.all([
    user.getUser('Garlic'),
    user.getUser('Onion'),
    ]);

  console.log('return:', users);
}

run(new UserLoader());

看一下输出结果:

Get Keys: [ 'Garlic', 'Onion' ]
return: [ Model-user { name: 'Garlic' }, Model-user { name: 'Onion' } ]

返回一样,但是两次get方法被合并成了一次batchGet了。

不过,在使用dynamoose的batchGet的时候,会出现一些奇妙的bug;稍微改动一下getUser的顺序,把GarlicOnion换一下。

async function run(user) {
  const users = await Promise.all([
    user.getUser('Onion'),
    user.getUser('Garlic'),
    ]);

  console.log('return:', users);
}

返回变成了:

# run(new UserDao());
Get user: Onion
Get user: Garlic
return: [ Model-user { name: 'Onion' }, Model-user { name: 'Garlic' } ]

---

# run(new UserLoader());
Get Keys: [ 'Onion', 'Garlic' ]
return: [ Model-user { name: 'Garlic' }, Model-user { name: 'Onion' } ]

userLoader返回的内容错了,先返回了Garlic,后返回Onion。这个是很多NoSql数据库搜索算法共通的问题。

改动一下BatchLoadFn,将batchGet的返回结果按name排序。

const BatchLoadFn: any = (names: [string]) => {
  console.log('Get Keys:', names);
  return userModel.batchGet(names)
    .then((users) => {

      const usersByKey: object = users.reduce(
        (acc, user) => Object.assign(acc, {[user.name]: user}), {});

      return names.map((name) => usersByKey[name]);
    });
};

OK,这下输出正常了。

Get Keys: [ 'Onion', 'Garlic' ]
return: [ Model-user { name: 'Onion' }, Model-user { name: 'Garlic' } ]

缓存

async function run(user) {
  const users = await Promise.all([
    user.getUser('Onion'),
    user.getUser('Onion'),
    ]);

  console.log('return:', users);
}

设想一下,如果两次getUser都是Onoin会怎么样?

Get Keys: [ 'Onion' ]
return: [ Model-user { name: 'Onion' }, Model-user { name: 'Onion' } ]

结果是一次getUser后,DataLoader会把数据缓存到内存里,下一次get相同的User时,就不会再调用BatchLoadFn了。事实上,DataLoader缓存的是Promise。如下:

assert(user.getUser('Onion') === user.getUser('Onion')) // true

Dataloader在默认机制下是启动cache的,也可以选择关闭cache。

new Dataloader<KeySchema, DataSchema>(BatchLoadFn, {cache: false});
//duplicated keys in batchGet may occur error.

在出错或是更新时也可调用clear方法清除cache。

public getUser(name: string) {
  return this.loader.load(name)
    .catch((e) => {
      this.loader.clear(name);
      return e;
    });
}

此外,在初始化Dataloader时可以自定义cache策略:new DataLoader(batchLoadFn [, options])

Option Key Type Default Description
cache Boolean true 设置为false则停用cache
cacheKeyFn Function key => key cacheKeyFn返回只能是string或number, 如key为object,可设为key => JSON.stringify(key)
cacheMap Object new Map() 自定义cache算法, 如DataloaderCacheLru

API

DataLoader并不是一个超级工具,代码也只有300多行,而且相当部分是注释。它只提供了5个API,基本只能完成loadByKey相关的操作。

  1. load(key: K): Promise<V>;

  2. loadMany(keys: K[]): Promise<V[]>;

  3. clear(key: K): DataLoader<K, V>;

  4. clearAll(): DataLoader<K, V>;

  5. prime(key: K, value: V): DataLoader<K, V>;

Graphql

DataLoader被广泛应用于Graphql的resolver中,

# Define in graphql type def
type User {
  name: String
  friends: [User]
}

# Query in front-end
{
  user(name: "Onion") {
    name
    friends {
      name
      friends {
        name
      }
    }
  }
}
# user.resolver.ts
Query: {
  user: (root, {name}) => {
    return userDao.getUser(name);
  }
}
User: {
  friends: (root) => {
    return Promise.all( root.friends.map( (name) => userDao.getUser(name) ) );
  }
}

Onion朋友的朋友中必然有Onion自己。Graphql支持嵌套查询,假如直接调用传统UserDao的getUser方法, 数据库查询单个Onion的次数将会是1+len(friends)。

若将上述代码中的userDao换成userLoader,Onion的数据库访问就只有一次了。这就解决了N+1查询的问题。

Query: {
  user: (root, {name}) => {
    return userLoader.getUser(name);
  }
}
User: {
  friends: (root) => {
    return Promise.all( root.friends.map( (name) => userLoader.getUser(name) ) );
  }
}

小结

今天大体介绍了一下DataLoader的机制和使用方法。在现实开发中我们可以将dataloader专门作为一层架构,对应用层做cache,对数据层做batch。甚至有项目将DataLoader与redis集成(redis-dataloader)。
我参与的其中一个项目在使用DataLoader优化Graphql查询后,DB访问数减少了3/4。尤其是用到DynamoDB这类按查询收费的服务时,DataLoader不仅可以加速前端访问速度,还可以极大地减少后端运维成本。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,794评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,050评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,587评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,861评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,901评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,898评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,832评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,617评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,077评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,349评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,483评论 1 345
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,199评论 5 341
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,824评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,442评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,632评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,474评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,393评论 2 352

推荐阅读更多精彩内容

  • 昨晚孩子发烧,我也一晚上没睡。白天送去医院检查、打点滴,看看孩子逐渐长大,感觉自己最近陪他太少了。中午染头发后去公...
    TA77范丽萍阅读 166评论 0 0
  • 微商是个全民皆商的平台!而健康产品将会是未来发展的一个主导作用!不管是那一个年代,那一个发展阶段,健康体系都应该与...
    草根的命运LOVE阅读 216评论 5 2
  • 一.成功要素雷达分析图 二.6个月后达成目标 1.个人健康达标/体重68㎏,现81㎏,每月减重目标2㎏!通过晚餐...
    壵_0268阅读 208评论 0 0
  • 在昨天到小半月之前,感觉自己还有那么一点纠结,大姨妈遇上徒增的工作量(白天处理各种外联事件,到了下班开始上...
    Imnice阅读 180评论 0 0
  • 一个人无助站在马路中间不知道往哪走了 人来人往 没方向没目标 有特别想要去的地方只是有 特别想要去的地方 ...
    姝疏阅读 97评论 0 0