簡介
DataLoader 是facebook推出的一款通用工具,可為傳統(tǒng)應(yīng)用層與持久層之間提供一款緩存和批處理的操作。JS、Java、Ruby、Go等主流語言都有開源三方庫支持。尤其在Graphql興起后,DataLoader被廣泛地應(yīng)用于解決N+1查詢問題。
機(jī)制
DataLoader的實現(xiàn)原理很簡單:就是把每一次load推遲到nextTick中集中處理。在現(xiàn)實開發(fā)中其主要有兩點應(yīng)用:
- 批處理操作

- 內(nèi)存級別的緩存

案例
以下以nodejs中調(diào)用dynamoose api為例具體介紹一下DataLoader在dynamodb查詢時的一些使用方法。
批處理 (Batching)
先看一下傳統(tǒng)DAO設(shè)計實現(xiàn)中的模版方法。我們通過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);
}
}
當(dāng)我們調(diào)用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());
打印結(jié)果是:
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的參數(shù)是數(shù)組且返回是個包在Promise里的數(shù)組。因此可以直接調(diào)用dynamoose的batchGet方法。
再次調(diào)用上述的run方法
async function run(user) {
const users = await Promise.all([
user.getUser('Garlic'),
user.getUser('Onion'),
]);
console.log('return:', users);
}
run(new UserLoader());
看一下輸出結(jié)果:
Get Keys: [ 'Garlic', 'Onion' ]
return: [ Model-user { name: 'Garlic' }, Model-user { name: 'Onion' } ]
返回一樣,但是兩次get方法被合并成了一次batchGet了。
不過,在使用dynamoose的batchGet的時候,會出現(xiàn)一些奇妙的bug;稍微改動一下getUser的順序,把Garlic和Onion換一下。
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返回的內(nèi)容錯了,先返回了Garlic,后返回Onion。這個是很多NoSql數(shù)據(jù)庫搜索算法共通的問題。
改動一下BatchLoadFn,將batchGet的返回結(jié)果按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);
}
設(shè)想一下,如果兩次getUser都是Onoin會怎么樣?
Get Keys: [ 'Onion' ]
return: [ Model-user { name: 'Onion' }, Model-user { name: 'Onion' } ]
結(jié)果是一次getUser后,DataLoader會把數(shù)據(jù)緩存到內(nèi)存里,下一次get相同的User時,就不會再調(diào)用BatchLoadFn了。事實上,DataLoader緩存的是Promise。如下:
assert(user.getUser('Onion') === user.getUser('Onion')) // true
Dataloader在默認(rèn)機(jī)制下是啟動cache的,也可以選擇關(guān)閉cache。
new Dataloader<KeySchema, DataSchema>(BatchLoadFn, {cache: false});
//duplicated keys in batchGet may occur error.
在出錯或是更新時也可調(diào)用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 |
設(shè)置為false則停用cache |
| cacheKeyFn | Function | key => key |
cacheKeyFn返回只能是string或number, 如key為object,可設(shè)為key => JSON.stringify(key)
|
| cacheMap | Object | new Map() |
自定義cache算法, 如DataloaderCacheLru |
API
DataLoader并不是一個超級工具,代碼也只有300多行,而且相當(dāng)部分是注釋。它只提供了5個API,基本只能完成loadByKey相關(guān)的操作。
load(key: K): Promise<V>;
loadMany(keys: K[]): Promise<V[]>;
clear(key: K): DataLoader<K, V>;
clearAll(): DataLoader<K, V>;
prime(key: K, value: V): DataLoader<K, V>;
Graphql
DataLoader被廣泛應(yīng)用于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支持嵌套查詢,假如直接調(diào)用傳統(tǒng)UserDao的getUser方法, 數(shù)據(jù)庫查詢單個Onion的次數(shù)將會是1+len(friends)。
若將上述代碼中的userDao換成userLoader,Onion的數(shù)據(jù)庫訪問就只有一次了。這就解決了N+1查詢的問題。
Query: {
user: (root, {name}) => {
return userLoader.getUser(name);
}
}
User: {
friends: (root) => {
return Promise.all( root.friends.map( (name) => userLoader.getUser(name) ) );
}
}
小結(jié)
今天大體介紹了一下DataLoader的機(jī)制和使用方法。在現(xiàn)實開發(fā)中我們可以將dataloader專門作為一層架構(gòu),對應(yīng)用層做cache,對數(shù)據(jù)層做batch。甚至有項目將DataLoader與redis集成(redis-dataloader)。
我參與的其中一個項目在使用DataLoader優(yōu)化Graphql查詢后,DB訪問數(shù)減少了3/4。尤其是用到DynamoDB這類按查詢收費的服務(wù)時,DataLoader不僅可以加速前端訪問速度,還可以極大地減少后端運維成本。