LoginSignup
0
0

Prisma で DB に優しくないクエリを発行しすぎた話

Posted at

はじめに

趣味のプロジェクトにて、ORM として Prisma を利用する中で気をつけるべき点があったのでメモとしてまとめようと思います。

> npx prisma version
Environment variables loaded from .env
prisma                  : 5.7.0
@prisma/client          : 5.7.0
...

下記ドキュメントを参考に、Prisma が発行したSQLクエリをログへ出力するにようしています。

export const prisma = new PrismaClient({
  log: [{ level: 'query', emit: 'event' }],
});

prisma.$on('query', (e) => {
  logger.debug(
    `Query: ${e.query} ${e.params && `with params: ${e.params}`} ${
      e.duration && `in ${e.duration}ms`
    }`,
  );
});

チューニング

メンバーシップ限定の回答が投稿された際、そのメンバーシップを購読しているユーザー全員にサイト内通知を送る処理にて問題は起きました...
問題というのは、上記の処理を実装する過程で、DBに優しくない(1分かかってタイムアウトするほどの)SQLクエリを発行するコードを書いてしまったということです。
結果的には、該当コードを修正し、10秒程度にまで短縮できたので、チューニング前後のコードとログを比較していきます。

チューニング前のひどいコード

  for (const membership of memberships) {
    const notificationsPromises = membership.membershipSubscription.map(
      async (subscriber) => {
        // ユーザーが既に同じ通知を受け取っていないかチェック
        const existingNotification = await prisma.notification.findFirst({
          where: {
            typeId: NotificationType.NewMembershipComment,
            recipientId: subscriber.userId,
            topicId,
            commentId,
          },
        });

        // ユーザーがサイト内通知の受信をキャンセルしていないかチェック
        const hasMemberCanceledInternalNotification =
          !!(await prisma.notificationCancellation.findUnique({
            where: {
              userId_channelId_typeId: {
                userId: subscriber.userId,
                channelId: NotificationChannel.Internal,
                typeId: NotificationType.NewMembershipComment,
              },
            },
          }));

        // サイト内通知を個別に作成
        if (!hasMemberCanceledInternalNotification && !existingNotification) {
          return prisma.notification
            .create({
              data: {
                typeId: NotificationType.NewMembershipComment,
                recipientId: subscriber.userId,
                url: `/topics/${topicId}/${commentId}`,
              },
            });
        }
      },
    );

    await Promise.all(notificationsPromises);
  }

上記の処理を実行した際のログがこちらです。1分以上かかった結果、タイムアウトしてしまって終了しています。
before.png

チューニング前は、複数あるメンバーシップの各購読者に対して、

  1. その購読者が既に同じ通知を受け取っていないか
  2. その購読者がサイト内通知の受信をキャンセルしていないか

の2点を個別にチェックし、2点とも問題なければサイト内通知を個別に作成する、という処理の流れでした。
こうすることで、非効率なSQLクエリを発行してしまっていました。

チューニング後のコード

  // 通知対象となり得るユーザーたちのIDを取得
  const userIds = memberships.flatMap((membership) =>
    membership.membershipSubscription.map(
      (subscription) => subscription.userId,
    ),
  );

  // ユーザーが既に同じ通知を受け取っていないか、サイト内通知の受信をキャンセルしていないかまとめてチェック
  const [cancellations, existingNotifications] = await Promise.all([
    prisma.notificationCancellation.findMany({
      where: {
        userId: { in: userIds },
        channelId: NotificationChannel.Internal,
        typeId: NotificationType.NewMembershipComment,
      },
    }),
    prisma.notification.findMany({
      where: {
        typeId: NotificationType.NewMembershipComment,
        recipientId: { in: userIds },
        topicId,
        commentId,
      },
    }),
  ]);

  // 上記のうち、通知対象とはならなかったユーザーたちのIDを取得
  const canceledUserIds = new Set(cancellations.map((c) => c.userId));
  const notifiedUserIds = new Set(
    existingNotifications.map((en) => en.recipientId),
  );

  const notificationsToCreate = [];

  for (const membership of memberships) {
    for (const subscription of membership.membershipSubscription) {
      // 通知対象のユーザーでなければスキップ
      if (
        canceledUserIds.has(subscription.userId) ||
        notifiedUserIds.has(subscription.userId)
      ) {
        continue;
      }

      notificationsToCreate.push({
        typeId: NotificationType.NewMembershipComment,
        recipientId: subscription.userId,
        url: `/topics/${topicId}/${commentId}`,
      });
    }
  }

  // サイト内通知をまとめて作成
  await prisma.notification.createMany({
    data: notificationsToCreate,
  });

上記の処理を実行した際のログがこちらです。886msかけて正常に終了しています。
after.png

チューニング後は、複数あるメンバーシップの購読者たちに対して、

  1. その購読者が既に同じ通知を受け取っていないか
  2. その購読者がサイト内通知の受信をキャンセルしていないか

の2点をまとめてチェックした上で、通知対象とはならないユーザーたちのIDを取得し、createMany()メソッドを使ってサイト内通知を一括で作成する、という処理の流れにしています。
こうすることで、不要なDBアクセスを減らしてパフォーマンスを向上させることができました。

おわりに

もっとパフォーマンスが上がる書き方等ありましたら、ご指摘いただけますと幸いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0