🤖

FirestoreにおけるN+1問題と向きあう(前編)

2024/02/09に公開

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さん,すごいえっちな本

https://www.amazon.co.jp/すごいHaskellたのしく学ぼう-Miran-Lipovača/dp/4274068854/ref=sr_1_1?adgrpid=52029352503&gclid=CjwKCAiAlJKuBhAdEiwAnZb7lVcm2HegUaPQBELnqZnqn91nSAuCK3MRWDj7nN9y-ZZ5Uj4Fgb-0URoCmXkQAvD_BwE&hvadid=679022532199&hvdev=c&hvlocphy=1009492&hvnetw=g&hvqmt=e&hvrand=11641025944369944777&hvtargid=kwd-333611264130&hydadcr=27270_14738616&jp-ad-ap=0&keywords=すごいhaskellたのしく学ぼう&qid=1707435727&sr=8-1

WHERE IN句は、client side joinの処理が必要。
一度にINに指定できる数に制限があるDBも。(firestore:10 30 oracle:1000?)

JOINできないしWHERE INもまともに使えないfirestoreでどう対処するかずっと課題だった。
複雑すぎるクエリはBigQueryに頼るしかないが、BQも課題が多い。
データが同期されるまでの遅れ、トランザクション処理不可能、テストの実装が難しい、、、

最近新たな閃きが生まれたので聞いてほしい

generator

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/function*
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Generator

ジェネレーター関数
iterable protocolとiterator protocolに準拠したオブジェクトを返す。
for of文でイテレートできる。

関数内でyield演算子を使ってイテレートさせる要素を生成してく。

https://eow.alc.co.jp/search?q=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 generatorfor 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問題を対処する話がしたかった。

つづく・・・

https://zenn.dev/shuji_koike/articles/eb8216c16a10bb

Discussion