ページネーションのバグを解消した話

この記事は、Merpay Tech Openness Month 2023 の3日目の記事です。

こんにちは。メルペイBackendエンジニアの@yushi0010です。

私が所属するPartner Platformチームでは社内向け管理ツールを開発しています。この記事では、そのツール内でのページネーションで起きたバグを解消した話を紹介します。

概要

今回のページネーションを利用していた管理ツールの検索ページでは、あるテーブルが持つカラムに対して条件を指定し、その条件に合うレコードを取得して一覧表示する機能がありました。しかし、ある特定の条件下でどれだけ次ページに遷移するボタンをクリックしてもページ遷移が行われないというバグが発生しました。

バグが起きた状況

どのようにしてページ遷移が行われなくなったのかを説明するために、その時の状況を共有します。

まず、検索の対象とするテーブルは以下のようなスキーマです。

table (
  id INT64 NOT NULL,
  month DATE NOT NULL,
  status1 INT64 NOT NULL,
  status2 INT64 NOT NULL,
  (中略)
  created_at TIMESTAMP NOT NULL,
  updated_at TIMESTAMP NOT NULL,
)

それぞれのカラムに入る値について、monthはDate型で表現されていますが年月だけの情報を保持しており、何日なのかという情報は必要がないため全て1日で固定されています。また、status1status2はカテゴリカルな値が入り、とりうる値の範囲はせいぜい0から9までの一桁に収まるくらいです。

このスキーマに対して条件を指定して一覧表示をさせていました。実際の条件は以下のような内容です。

month が2023年5月より以前になっている
status1 が (0, 1, 3) のどれかである
status2 が (1, 2, 4, 5) のどれかである

ページネーションを実現するアルゴリズムとしては、典型的なものとしてOFFSET句を利用するパターンと、前のページの最終行の情報をカーソルとして保持し次のページでそのカーソル以降のレコードを表示させるパターンが主に考えられます。今回のコードでは後者を使用していました。
また、カーソルとして使用したカラムはmonthstatus1status2created_atの4つです。その4つのカラムでOrdey Byさせた後、ページで表示させる件数+1つのレコードを取得してその+1つめのレコードの値をカーソルとし、ページ遷移するときにはそのカーソルを含むそれ以降のレコードを取得するという実装になっていました。

例えば一つのページに50件を表示させたいとき、
1ページ目を取得する場合は、

SELECT * FROM table
ORDER BY month, status1, status2, created_at
LIMIT 51;

で51件取得し、50件をページに表示させ、51件目をカーソルの値に使用していました。

次に2ページ目を取得する場合は、先ほどの51件目のカーソル以降(51件目を含む)となるレコードを取得すれば良いので、

SELECT * FROM table
WHERE (month > @cursor_month)
OR (month = @cursor_month AND status1 > @cursor_status1)
OR (month = @cursor_month AND status1 = @cursor_status1 AND status2 > @cursor_status2)
OR (month = @cursor_month AND status1 = @cursor_status1 AND status2 = @cursor_status2 AND created_at >= @cursor_created_at)
ORDER BY month, status1, status2, created_at
LIMIT 51;

で取得をします。

バグが起きた原因

以上のようなコードによってページネーションロジックが実装されていましたが、どのようなことが原因で前述のバグが発生していたでしょうか?

自分で考えてみたい人はスクロールをここで一旦ストップしてください。ここまでに共有した情報の中にそのバグを発生させていた原因が含まれています。以下でその原因を示します。

予想はつきましたでしょうか?
今回のバグの原因となっていたのは、カーソルとして使用していたカラムの組み合わせがユニークではないことでした。

実装当初の想定では、created_atを含む4つのカラムを組み合わせてカーソルを作成することで、カーソルは各レコードにわたってユニークになるだろうと考えていました。しかし、実際にはデータマイグレーションの際に一括Insertをしたことでcreated_atを含む4つのカラムが全て同じになっているレコードがページの表示件数以上に存在していました。

ではユニークでないレコードが大量に存在することで、どのようにページ遷移が出来なくなるのでしょうか。

例えばページの表示件数が50件で、カーソル(month, status1, status2, created_at)(2023年5月, 4, 2, 2023年4月10日)となるユニークでないレコードが51件より多く存在する場合を考えてみます。
ページ遷移を行っていたところ51件目がユニークでないレコードとなり、カーソルが(2023年5月, 4, 2, 2023年4月10日)となってしまいました。このとき、次ページに遷移するときに取得するレコードは (2023年5月, 4, 2, 2023年4月10日) が含まれるので、先ほど取得したはずのユニークでないレコードが再度取得され、このユニークでないレコードは51件以上存在するのでカーソルも再度(2023年5月, 4, 2, 2023年4月10日)に設定されます。これ以降はどれだけページを次に遷移をさせても同じ情報が取得され続けます。このようにしてページネーションロジックはエラーなく動作しているもののページが遷移できないバグに陥りました。

このバグを発生させないためにはカーソルの値が常にユニークでなければならないので、今回このバグの解決策としてとった対応は、カーソル(month, status1, status2, created_at)に レコードごとにユニークな値であるidカラムをcreated_atの代わりに含めて(month, status1, status2, id)とすることで、カーソルが重複してしまうレコードが存在しないようにしました。

バグからの学び

今回の実装でよくなかったところは、ページネーションで利用されるカーソルにユニークなカラムが含まれていなかったことはもちろんなのですが、created_atにレコード作成日より大きな意味を持たせてしまったことにあると考えています。
ページネーションロジック実装時にはcreated_atに対してカーソルで利用するユニークなカラムという役割を持たせましたが、データマイグレーションを行う人はcreated_atにそのような役割があるということが認識することができず、created_atが同じ値となるようにレコードの一括挿入を行いました。
一括挿入時以外においてはcreated_atが重複することはないと仮定したとして(実際には重複することが十分考えられます)カーソルとして利用はできそうです。しかし、一般的に作成日として認識されているカラムに対してそれ以上の意味を持たせることで、そのカラムの使い方に齟齬が生じ、それが原因となって今回のようにバグが発生することが考えられます。

カラムの使い方に限らず、一般的な利用方法について共通の認識があるものに対してどうしても特別な意味を持たせたいときには、ドキュメントやコメントによってその意図を伝える方法が考えられます。しかし、利用者がそのドキュメントを確認して実装者が想定する意図を汲み取ってくれるとは限りませんし、そもそもそれを認識しなければならないという利用者への不要な負担を強いる状況を発生させています。よって特別な意味を持たせることは避けるべきであり、意味を持たせる用の項目を別で新たに定義するべきだと学びました。

まとめ

この記事では、メルペイの管理ツールのページネーションに発生したバグの概要、原因、そこからの学びを紹介しました。

明日の記事はBackendエンジニアの@komatsuさんです。引き続きお楽しみください。

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加