😱

一覧取得系APIのlimit, offsetの罠

2023/01/20に公開

はじめに

一覧取得系のAPIを実装する際、パフォーマンスの観点から取得件数を制限するため取得件数とオフセットをパラメータで受け取るということはよくあると思います.
今回はこの取得件数の制限の仕方によっては意図しない挙動をしてしまう可能性についての記事になります.

例えば、以下のように取得件数を limit というパラメータで受け取っているとします.また、今回は20で指定していますが、なにも指定がない場合はデフォルトで20とするとします.

GET /api/hoge?limit=20

このようなAPIで、取得件数のセットを、limitというパラメータがセットされていればそれを使う、セットされていなければデフォルト値を入れる、というような実装がされていたとします.

このような場合、以下のように limit に大きな値が入っていたとしても指定された 10000件のデータを取得してしまいます.

GET /api/hoge?limit=10000

そのため、パラメータにセットされていたとしても最大値と比較するというような処理が必要になってくると思います.

パフォーマンス

簡単なサンプルアプリを作ってパフォーマンス計測をしてみました.

リポジトリは以下です.
https://github.com/shake551/go-sql-limit-sample

以下のようなテーブルから全カラム一覧取得するAPIを考えます.

CREATE TABLE IF NOT EXISTS articles (
    id INTEGER UNSIGNED PRIMARY KEY AUTO_INCREMENT,
    title VARCHAR(128) NOT NULL DEFAULT "",
    content TEXT,
    created_at INTEGER NOT NULL DEFAULT 1674090479,
    updated_at INTEGER NOT NULL DEFAULT 1674090479
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

ダミーデータとして1,048,576件登録されています.

シナリオ1

まず、通常の状態を想定して、以下のようなシナリオで負荷テストを実施しました.

import http from 'k6/http';
import { sleep } from 'k6';

const VUS = 1000;

export const options = {
    stages: [
        { duration: '30s', target: VUS },
        { duration: '30s', target: 0 },
    ]
}

export default function scenarioFunc() {
    http.get(`http://localhost/article?limit=20`);
    sleep(1);
}

結果は以下のようになりました.

data_received..................: 79 MB  1.3 MB/s
data_sent......................: 2.8 MB 46 kB/s
http_req_blocked...............: avg=15.58µs min=0s     med=3µs    max=3.84ms   p(90)=7µs     p(95)=11µs
http_req_connecting............: avg=8.12µs  min=0s     med=0s     max=2.79ms   p(90)=0s      p(95)=0s
http_req_duration..............: avg=6.87ms  min=1.7ms  med=3.53ms max=229.21ms p(90)=10.66ms p(95)=18.58ms
  { expected_response:true }...: avg=6.87ms  min=1.7ms  med=3.53ms max=229.21ms p(90)=10.66ms p(95)=18.58ms
http_req_failed................: 0.00%  ✓ 0          ✗ 30307
http_req_receiving.............: avg=41.91µs min=9µs    med=34µs   max=1.65ms   p(90)=68µs    p(95)=88µs
http_req_sending...............: avg=17.09µs min=3µs    med=12µs   max=1.61ms   p(90)=28µs    p(95)=38µs
http_req_tls_handshaking.......: avg=0s      min=0s     med=0s     max=0s       p(90)=0s      p(95)=0s
http_req_waiting...............: avg=6.81ms  min=1.67ms med=3.47ms max=229.13ms p(90)=10.59ms p(95)=18.53ms
http_reqs......................: 30307  499.673548/s
iteration_duration.............: avg=1s      min=1s     med=1s     max=1.22s    p(90)=1.01s   p(95)=1.01s
iterations.....................: 30307  499.673548/s
vus............................: 24     min=24       max=995
vus_max........................: 1000   min=1000     max=1000

シナリオ2

次にlimitを大きな値に設定した以下のようなシナリオで負荷テストを実施しました.

import http from 'k6/http';
import { sleep } from 'k6';

const VUS = 1000;

export const options = {
    stages: [
        { duration: '30s', target: VUS },
        { duration: '30s', target: 0 },
    ]
}

export default function scenarioFunc() {
    http.get(`http://localhost/article?limit=10000`);
    sleep(1);
}

結果は以下のようになりました.

data_received..................: 1.3 GB 16 MB/s
data_sent......................: 238 kB 3.0 kB/s
http_req_blocked...............: avg=127.43µs min=1µs      med=8µs    max=2.67ms p(90)=365µs  p(95)=417µs
http_req_connecting............: avg=83.14µs  min=0s       med=0s     max=1.12ms p(90)=247µs  p(95)=284.44µs
http_req_duration..............: avg=17.03s   min=307.76ms med=5.88s  max=58.74s p(90)=45.71s p(95)=48.96s
  { expected_response:true }...: avg=32.49s   min=307.76ms med=36.01s max=58.74s p(90)=49.34s p(95)=51.61s
http_req_failed................: 57.95% ✓ 1224      ✗ 888
http_req_receiving.............: avg=9.68s    min=12µs     med=100µs  max=40.66s p(90)=35.68s p(95)=37.4s
http_req_sending...............: avg=48.47µs  min=4µs      med=32µs   max=899µs  p(90)=94µs   p(95)=123.44µs
http_req_tls_handshaking.......: avg=0s       min=0s       med=0s     max=0s     p(90)=0s     p(95)=0s
http_req_waiting...............: avg=7.35s    min=170.16ms med=5.77s  max=39.87s p(90)=13.02s p(95)=15.31s
http_reqs......................: 2112   26.230087/s
iteration_duration.............: avg=17.6s    min=1.31s    med=6.86s  max=59.74s p(90)=46.35s p(95)=49.55s
iterations.....................: 2072   25.733305/s
vus............................: 152    min=30      max=1000
vus_max........................: 1000   min=1000    max=1000

結果

シナリオ1と2を比較すると、http_req_duration の90パーセンタイルが 10.66ms から 45.71s と大幅に増加していました.また、http_req_failed に関しても 0.00% から 57.95% と大幅に増加していました.

今回のシナリオではシナリオ2で全てのリクエストで大きいlimitを設定している想定で、現実的に起こりうる状況ではないと思います.
しかし、上記の結果からもパフォーマンスに大きな影響があることは確認できたと思います.

最後に

今回は一覧取得系APIの取得件数の制限の仕方によってパフォーマンスに影響が出る可能性のある実装について紹介しました.
当たり前と思っている方もいるかもしれませんが、自分ではこれまで気づいていなかったので、このような記事を書きました.最後まで見ていただきありがとうございました.

Discussion