FirestoreにおけるN+1問題と向きあう(前編)
N+1問題とは
リスト形式のデータを取得する際に、、、
リスト(1件)+リストの要素数(N件)のクエリ・リクエストを発行すること。
パフォーマンス低下によるユーザ体験やインフラの運用コストに大きな悪影響を与える。
SQLを手書きしていれば防ぎやすいが、ORMを正しく使えてなかったり、そもそもJOINができないNoSQLで大きな課題になる。
SELECT id, name FROM users;
SELECT title FROM books WHERE user_id = 1;
SELECT title FROM books WHERE user_id = 2;
SELECT title FROM books WHERE user_id = 3;
コード上だと問題を発見しにくい。
const users = await fetch(`/users`)
for (const user of users) {
const book = await fetch(`/books/${user.id}`)
}
対処としては、JOINするクエリを書くか、1+1クエリにする。
SELECT name, title FROM users LEFT JOIN books ON user_id = users.id;
1+1クエリ。ORMやGQLのdataloaderがよくやるパターン
SELECT id, name FROM users;
SELECT title, user_id FROM books WHERE user_id IN (1, 2, 3);
どちらが良いかはcase by case。
JOINのクエリの結果は重複した情報を多く含む形になりがち。
Aさん,jsの本
Aさん,goの本
Aさん,sqlの本
Bさん,すごいえっちな本
WHERE IN句は、client side joinの処理が必要。
一度にINに指定できる数に制限があるDBも。(firestore:10 30 oracle:1000?)
JOINできないしWHERE INもまともに使えないfirestoreでどう対処するかずっと課題だった。
複雑すぎるクエリはBigQueryに頼るしかないが、BQも課題が多い。
データが同期されるまでの遅れ、トランザクション処理不可能、テストの実装が難しい、、、
最近新たな閃きが生まれたので聞いてほしい
generator
ジェネレーター関数
iterable protocolとiterator protocolに準拠したオブジェクトを返す。
for of文
でイテレートできる。
関数内でyield
演算子を使ってイテレートさせる要素を生成してく。
- 産出、生産する
- 歩留
- 譲る、差し出す
- 停止する
function* generator() {
yield 1;
yield 2;
yield 3;
}
const gen = generator()
console.log(gen.next().value) // 1
console.log(gen.next().value) // 2
console.log(gen.next().value) // 3
console.log(gen.next().done) // true
console.log(gen.next().value) // undefined
for (const e of generator()) console.log(e)
//--
function* generator() {
for (const n of [1, 2, 3]) yield n;
}
function* generator() {
yield* [1, 2, 3];
}
async function* generator() {
yield await fetch("/users/1");
yield await fetch("/users/2");
yield await fetch("/users/3");
}
for await (const e of generator()) console.log(e)
実践!
修正する前のコード
以下はfirebase authのデータをfirestoreに書き写すバッチの実装。
- listUsersは一度に1000件までのデータしか取得できない。
- 再帰的にlistUsers呼び出しながら全てのデータを読み取ってから書き込み処理に移る。
- メモリに全てのデータを乗せてしまう。
const listAllUsers = async (users: UserRecord[] = [], nextPageToken?: string) => {
const result = await getAuth().listUsers(1000, nextPageToken)
users.push(...result.users)
if (result.pageToken) await listAllUsers(users, result.pageToken)
return users
}
const writeAuthProviderIdsToCollection = async () => {
const batchWriter = new BatchWriter()
for (const user of await listAllUsers()) {
await batchWriter.write(batch => {
batch().set(db.userAuthSummary.doc(user.uid), {<*略*>})
})
}
await batchWriter.commit()
}
generatorを使って修正したコード
export async function* listUsers(
nextPageToken?: string,
limit: number = 1000,
): AsyncGenerator<UserRecord> {
const { users, pageToken } = await getAuth().listUsers(limit, nextPageToken)
for (const user of users) yield user
if (pageToken) yield* listUsers(pageToken, limit)
}
export async function writeAuthProviderIdsToCollection() {
const batchWriter = new BatchWriter()
for await (const user of listUsers()) {
await batchWriter.write(batch => {
batch().set(db.userAuthSummary.doc(user.uid), {<*略*>})
})
}
await batchWriter.commit()
}
-
yield*
はイテレーターを渡せるyield
-
async generator
はfor await
文でイテレートする - 末尾再帰に注意
もう一つ実践例
const customers = await collection("customers").get()
for (const customer of customers) {
console.log(customer)
}
export async function* paginate<T>(
query: Query<T>,
limit: number = 1000,
): AsyncGenerator<QueryDocumentSnapshot<T>> {
const { docs, size } = await query.limit(limit).get()
logger.debug(paginate.name, docs.at(0)?.ref.path, docs.at(-1)?.ref.path)
for (const doc of docs) yield doc
if (size === limit) yield* $paginate(query.startAfter(docs.at(-1)))
}
const query = getFirestore().collection("customers")
for await (const customer of paginate(query)) {
console.log(customer)
}
2つのクエリを結合(UNION ALL)するジェネレータがカッコよく書ける
この例なら最近実装されたcompound queries(or
, and
)で書くべき。
async function* customers() {
yield* paginate(db.customers.where(/* 条件1 */))
yield* paginate(db.customers.where(/* 条件2 */))
}
N+1問題の話をするはずが、逆に1クエリをNクエリに置き換える話になっていて非常に矛盾している( ´・ω・`)
だがここからが本題で、generatorを使って実装したpaginateをさらに応用してN+1問題を対処する話がしたかった。
つづく・・・
Discussion