APIスロットリングの実現方法

ある期間でのあるWeb APIに対するアクセス回数上限を与えたときのスロットリングについて、一例ではあるがRack::Attack (v6.3.1)で採用されているしくみについて調べた。

ここでは、ある期間において特定のクライアントからのアクセスを一定回数以下に制限することをスロットリングと呼ぶことにする*1。あるAPIにスロットリングをかけるときは、ある期間において経過した時間を計算しながら、その期間中にあるクライアントからエンドポイントに来たアクセスの回数をサーバサイドのキャッシュに記録する必要がある。

結論

スロットリングしたい期間をperiod(単位は秒)とする。

periodで与えられる期間だけ有効なカウンタ」を作ることができれば、そのカウンタにアクセス回数を記録することで、あるクライアントから上限を超える回数のアクセスが来ているかどうか検証できる。つまりスロットリングが実現できる。

カウンタの実現方法

特定期間において経過した時間を計算する

剰余を使ってタイマーを作ることで実現する。

まず、アクセスが来た時点でのUNIX時間t*2を取得する。このとき、tperiodで割ったときの剰余rを計算すると、当然のことながら0 <= r < periodがつねに成り立つ。つまり、1秒ごとに数値が増え、period - 1の次は0に戻るようなタイマーができる。Rubyで書くと次のようになる。

r = t % period # 1秒ごとに増加するタイマー。値は0からperiod - 1を繰り返す

そして、periodからrの値を引くと、1秒ごとにperiodから数値が減り、1の次は値がperiodに戻るようなタイマーができる。

(period - r).to_i # 1秒ごとに減少するタイマー。値はperiodから1を繰り返す

アクセス回数の記録時に、このタイマーから得られる値ををキャッシュの有効期限として設定することで、その期間における残り時間を表現できる。

なお、Rack::Attackでは実装上の理由でタイマーの最大値に1秒のバッファを持たせている*3

特定期間のアクセス回数を記録する

除算を使って、特定の期間だけ得られるキーを作り、そのキーを使ってキャッシュにアクセス回数を記録する。

先ほど取得したUNIX時間tperiodで割ったときの商qを仮に毎秒計算してみると、剰余が0 <= r < periodになる連続した範囲ごとにqの値が同じになる。

q = (t / period).to_i # periodの期間中はつねに同じ値

つまり、ある期間の間はqの値が同じになるので、アクセス元の情報とqの組み合わせをその期間のクライアントからのアクセス回数のキーとすると、あるクライアンのある期間におけるアクセス回数データを一意に特定できる。このキャッシュの有効期限を、先ほど計算した有効期限として設定すればよい。

以上より、periodで与えられる期間だけ有効なカウンタを作ることができる。このカウンタを利用してスロットリングを実現する。

参考

  • Rack::Attack 6.3.1
    • Rack::Attack::Throttle
      • matched_by?から呼び出しているcache.countが「periodで与えられる期間だけ有効なカウンタ」での計数を実現している
    • Rack::Attack::Cache
      • key_and_expirtyで時刻に基づいて経過時間とキャッシュのキーを計算し、do_countでカウンタを操作している

*1:Rack::Attackではthrottlingと呼んでいる

*2:RubyではTIme.now.to_iで取得する

*3:https://github.com/rack/rack-attack/pull/85

ポモドーロ・テクニックを2か月間やってみての感想

2か月ほどポモドーロ・テクニックを使って仕事をしてみたので感想を書きます。

前提

仕事ではWebアプリケーション*1*2の開発が主な業務。最近、会社の勤務体制が原則として在宅勤務に移行した*3

なぜ始めたか

在宅勤務はとても便利だが、家の中でずっと同じ景色で仕事しているので、なんとなく気分として新鮮味がなくなってきていた。

また、これは在宅勤務の前からだが、仕事が進んでいない感覚に陥ることがたまにあった。実際進んでいないわけでもないのだが、これについては、いろいろな仕事をちょっとずつやっている状態だとこういう感覚になるのではという仮説があった。

上記のような経験があったので改善したいと思い、よく見るポモドーロ・テクニックに手を出してみることにした。higeponさんの記事にも影響を受けている。

第4回 プロダクティビティの鬼:継続は力なり―大器晩成エンジニアを目指して|gihyo.jp … 技術評論社

取り組みかた

原典を読む

ポモドーロ・テクニックといえば25分ずつ作業するぐらいのイメージだったので、原典を読んだ。原典はFancesco Cirilloの"The Pomodoro Technique"で、今回は邦訳を読んだ。

ポモドーロ・テクニック入門 | CCCメディアハウス - 書籍

主に「個人としての目標達成」の章を読んだ。とりあえず一人で使う予定だったので「チームとしての目標達成」は流し読みしかしていない。

具体的な方法論に加えて、テクニックについての考案者の思想がいろいろと書いてある。どんどん生まれては過ぎ去っていく時間を「ポモドーロ」という単位で抽象化して取り出すことで、時間が過ぎ去ることに対する不安感を軽減し、一定のリズムに基づいて作業を進められようになり*4、測定やふりかえりもやりやすくなるというのが本質らしい。

ここから後ろの内容はこの本に書いてある用語や方法に基づきます。

原理主義的にやる

だまされたと思って、ひとまず原理主義的にやる。「使う道具をシンプルに保つこと」が強調されていたので、あえて高度なツールなどは使っていない。原理主義的にやるときには次のものを用意する:

  • iOSビルトインの時計アプリのタイマー
  • 「今日やること」(To Do Today)シート
  • 「仕事の在庫」(Activity Inventory)シート
  • 「記録」(Record)シート

Cirillo氏はキッチンタイマーがいいと主張しているが、持っていないのでiOSのものを使う。

「今日やること」シートは大学ノートの1ページを1日分として管理している。ここに優先順位の昇順に並べたタスクの一覧、ポモドーロ完了/中断、突発的なタスクの一覧などを書いていく。ポモドーロを始めてしばらくしてペースがつかめてきたら1日の最初にタスクをポモドーロ数で見積もるようにしたほうが、ポモドーロの目的からして好ましいのだが、最初の数週間はとりあえず記録だけしていた。

今日やることシートの例

「仕事の在庫」と「記録」はとりあえずNotionで管理している。両方Notionのdatabaseを使っているが、「仕事の在庫」のほうは単純なタスクリストになっていてNotionである必要もそれほどない。ただ、たびたび追加/削除するので紙ではなくソフトウェア的に管理している。「記録」は毎日の終わりにその日のタスク、見積もったポモドーロ数、実際のポモドーロ数を記録するのだが、これが原理主義的にやるとき一番面倒な作業のように思える。ただ、これがあることで、あるタスクが見積もりとどれぐらいずれているかを見て認識を補正し、やたらとポモドーロ数を消費するタスクを効率的にやる方法を考えるきっかけになる。

感想

中断や休憩を意識するようになる

原理主義的にやる場合、とにかく今やっていること以外のことを考えたり突発的なタスクが舞い込むと中断の印を「今日やること」に記録する必要があり、そのまま集中力を失うとポモドーロ中断なので、嫌でも中断という事柄について意識するようになる。具体的にはSlackが強敵で、作業中はSlackのウインドウを閉じるようにしているが、無意識にウインドウを開こうとするときがたびたびある。このことに気づけたこと自体がよかったとも言える。

また、原理主義だと休憩を25分ごとに3分から5分取ることになる。このときは逆に仕事のことを考えてはいけないので、できるだけ机から離れて家の中を歩いたり水を飲んで休むようにしている。これは結構よくて、毎回のポモドーロで仕事の新鮮味を取り戻すのに役立っている。

4セットという原則を守るのが難しい

ポモドーロは25分作業+5分程度休憩を4セット実行するのが原則*5だが、これを守るのはけっこう難しい。まず、1日のスケジュールを見て、できるだけ4セット連続で実行できるように作業を配置しないといけない*6。また、あるタスクが大きすぎるときは1日で完了できる粒度に分け、反対に小さすぎるときは他のタスクと組み合わせる必要があるので、仕事を始める前に、タスクの分割と優先順位をあれこれ考えるようになる。その代わり、計画を立てて「今日やること」シートに記録することで、確実に仕事が進んでいることが可視化されるので、仕事が進んでいない感覚の軽減には役立っている。また、4セットごとに長めの休憩や軽い散歩をしている。

一方で、事前に計画しても、突発タスクでポモドーロを無視して作業してしまい、リズムが乱れてしまうことがある。このあたりは中断要因を自分で制御できるようになることもテクニックの狙いの一つなので、うまくやっていく必要がある。

その他、先日までいわゆる機能追加のようなプロジェクトに入っていた。このようなケースでは事前にプロジェクトとしてのバックログを作っているので、その中で着手しているタスク、次に着手するタスク、コードレビューをうまく配置すれば、あとは原則にしたがい集中してポモドーロをこなしていけばいいので、かなり捗る感覚があった。

時間と戦ってはいけない

これは原典に書いてあった文句の受け売りだが、時間と戦う、すなわちポモドーロ数(=作業時間)を稼ごうとした時点で負けらしい。要は、雑なポモドーロを続けるよりは、一つ一つのポモドーロの質を高めるほうが好ましいということ。

実際、仕事が完了したかどうかと、完了したポモドーロの数は関係がない。たとえば、ポモドーロの終盤での中断が繰り返されると、仕事はそれなりに進むが、その仕事の完了ポモドーロ数は0のままということになる。そういう状態になっているときは、自分自身の状態や作業の進めかたを気にかけたほうがいいということだろうと思った。また、ポモドーロ・テクニック自体、作業時間を区切って集中するための方法としての側面が強調されがちで、実際能率を上げる効果があるが、作業の進めかたを考えたり、ふりかえったりするための手段という点もかなり考慮されているということに、実際やってみて気がついた。

おわりに

まだ続いているので、飽きるまでは続けます。

*1:https://shop-pro.jp

*2:https://colorme-repeat.jp

*3:https://pepabo.com/news/press/202006011200

*4:本の中では「時間依存の反転」と呼んでいる

*5:残存体力によっては3セットでもよい

*6:難しいときは1ポモドーロだけ分割してしまうことがある。ミーティングなどもポモドーロに算入したほうがいいのかもしれない

意図せず関連先のカラムでwhereしつつeager loadしたらクエリのパフォーマンスが極端に悪化した事例

問題

15個ぐらいのさまざまなクエリパラメータを検索条件として受け付けることができる一覧取得API(「Item取得API」とする)があった。

そのAPIで取得するItemは複数の関連を持っていた。また、関連先の取得時にN+1問題の対策が不十分だったので、取得するデータで必要な関連先を漏れなくincludesでeager loadした結果、ほとんどのケースでパフォーマンスが改善していた。

しかし、あるクエリパラメータ(qとする)を使うときだけ極端にAPIのパフォーマンスが悪化するという現象が見られた。

先に結論

よく言われていることですが、関連先の取得方法が複雑になりそうなら、preload/eager_loadで読み込み方法を明示的に指定して、意図したクエリを作るようにしましょう。

原因

元々のコードは次のようなイメージ*1:

# ItemsController
def index
  relation = current_user.items

  # クエリパラメータに基づくたくさんのwhere ...
  if params[:q]
    relation = relation.where(genre: { q: params[:q] })
  end
  # さらに続く...

  # 関連先を漏れなくincludes
  relation = relation.includes(
    :genre,
    :sub_items,
    user: :store,
    category: {
      user: :store,
      :sub_items
    }
  )

  # JSONにしてrenderして終わり
end

qは関連先テーブルのカラムをWHEREの条件として用いるときのパラメータ。そのパラメータをAPIに渡してwhereで絞り込みをかけるときだけ、関連先テーブルのカラムでWHEREすることになる。

このとき、Item取得と同時に関連先のテーブルの情報で絞り込みをかける必要があるので、includesはLEFT JOINを発行するeager_loadの挙動となる。すると、includesは指定したすべての関連先を(たとえ他の関連先テーブルのカラムでWHEREするわけではなくても)LEFT JOINで読み込むことになる*2*3

関連先のテーブルすべてをLEFT JOINすると、テーブルの構造が原因で本来取りたいデータセットの行数の3乗程度の数のデータを取得しようとして、DB側でメモリを使い尽くして*4エラーになっていた。

もう少し詳しい経緯

同じ関連先を複数回includesの引数としていた

モデルItemの関連先どうしも関連を持っていた。たとえば、Itemが持つsub_itemscategoryについて、それらどうしやuserへ関連を持つイメージ。これにより、includesの引数に同じ関連先が複数回出てくることがあった:

relation.includes(
  :genre,
  :sub_items, # 1回目
  user: :store, # 1回目
  category: {
    :sub_items # 2回目
    user: :store, # 2回目
  }
)

例えばuserstoreは関連元Itemのメソッドでuser.store.foo?のようにアクセスされるのに加えて、Category#barでもuser.store.bar?のようにアクセスされていた。このとき、たとえばcategoryのネストした関連先指定を省くと、Category#barの呼び出しが複数回あるときに都度クエリが発行されてしまうので、上のように書かざるをえない*5

同じテーブルが複数回LEFT JOINされていた

上記のように関連先を指定していてeager_loadを使うとき、includesへの指定の仕方が2通りあることから、sub_itemsは2通りの結合条件でLEFT JOINされる。細かい点は説明しないが、テーブルの構成的に、このように2回LEFT JOINすると本来取りたい行数の2乗の数データが取得されてしまっていた*6。実際の事例ではもう少し込み入った感じになっていて、実際に取りたい行数の3乗程度の行数を取得しようとしていた。

解決法

解決法は簡単で、問題となったクエリパラメータを使うテーブル以外の関連先テーブルはつねにpreloadを明示的に指定するだけ。qでの絞り込み用のカラムを持つ関連先テーブルはincludesのままにする。

def index
  relation = current_user.items

  # ...
  if params['q']
    relation = relation.where(genre: { q: params['q'] })
  end
  # ...

  # whereで使わない関連先を漏れなくpreload
  relation = relation.preload(
    :sub_items,
    user: :store,
    category: {
      user: :store,
      :sub_items
    }
  )

  # whereで使われるかもしれないのでincludes
  relation = relation.includes(:genre)

  # JSONにしてrenderして終わり
end

問題のパラメータを指定しなければpreloadで読み込んでくれるし、必要なときはeager_loadになる。

今回は、テストの規模ではそこまで遅くなることに気付けなかったのと、問題のパラメータ指定時のクエリを見ていなかったのが敗因。単純なケースならincludesのほうが考えることが少なくて便利だが、関連先の取得方法が複雑になってきたら横着せずにpreloadや(今回は使わなかったが)eager_loadで関連先の読み込み方法を明示的に指定したほうがよい。

*1:出てくるモデルや関連はすべて仮のもの

*2:https://github.com/rails/rails/blob/7b5cc5a5dfcf38522be0a4b5daa97c5b2ba26c20/activerecord/lib/active_record/relation/finder_methods.rb#L379

*3:ちなみに他のクエリパラメータだけ使うときは、関連元テーブルitemsのカラムでWHEREしており、関連先テーブルは複数のクエリで取得するpreloadの挙動になっていた

*4:数十万〜の行数を一発で取ろうとしていた

*5:必ずItemの関連から引くようにするなどより大きい範囲でコードを改善をするのがベターだが、いまはそれができないという仮定

*6:いわゆるproductとproduct variantとoptionのような構成でproductとproduct variantそれぞれにoptionをLEFT JOINしたのに近い cf. https://shopify.dev/docs/admin-api/rest/reference/products/product-variant