BCD を取り回すライブラリを Rust で書いた

エンジニアの佐野です。文字列および数値を BCD と相互変換するライブラリを Rust で書きました。

github.com

crates.io にも公開しました https://crates.io/crates/bcd-convert

BCD

BCD は Binary-coded decimal の略で日本語では二進化十進表現などとも呼ばれます。

カンムの業務で言うとクレカ決済のデータを扱っているとたびたび出現します*1。例えばカード決済のデータをやりとりする ISO8583 というプロトコルではカード番号は 0x40, 0x19, 0x24, 0x99, 0x99, 0x99, 0x99, 0x99 のようなバイト列で表現されたりするのですが、このような処理をする際は1バイトの上位4ビットと下位4ビットに現れる数字列を分解して 4019249999999999 と解釈してカード番号を得ます*2

このライブラリを使うと BCD <-> 文字列は次のように相互変換できます。

#[test]
fn test_bcd_to_str() {
    let bcd = BcdNumber(vec![0x40, 0x19, 0x24, 0x99, 0x99, 0x99, 0x99, 0x99]);
    let s = bcd.to_string();
    assert_eq!(s, "4019249999999999");
}

#[test]
fn test_str_to_bcd() {
    let s = "4019249999999999";
    let bcd = s.parse::<BcdNumber>().unwrap();
    assert_eq!(
        bcd,
        BcdNumber(vec![0x40, 0x19, 0x24, 0x99, 0x99, 0x99, 0x99, 0x99])
    );
}

詳しい使い方は Docs.rsGitHub を見ていただくとして、 u64 や [u8] のような生バイト列との相互変換もサポートしています。

なぜ Rust

カンムのメイン言語は Go, TypeScript, Python なのですがたまには新しい言語を学びたくなってくるものです。その一環として自分は Go で書かれた決済システムの一部を Rust で書き換えるという個人プロジェクトを始めました*3。ゴールは Rust への書き換えと私自身の満足です。

チュートリアルを1.5周ほどやったあとに AI に手伝ってもらいながら書いたのですがまだ勘所がわからず四苦八苦している状態です。もっとうまい書き方や「らしい」書き方があるかもしれません。

おわり

*1:このような形式で送受信するケースがある

*2:これはもちろん存在しないカード番号で今後も発行されることもないカード番号です

*3:カンムでは今のところ Rust をプロダクションに導入する計画はなく、あくまでも私が自分の目標のために始めました。忙しくなったを言い訳にして頓挫する可能性もあります。

技術者倫理のキホンという本を読みました

はじめまして。プラットフォームチームの高山です。

この記事は カンム Advent Calendar 2024 の18日目の記事です。

adventar.org

最近読んだ本がなかなか興味深かったので、ちょっとご紹介できればと思います。

「技術者・研究者のための 技術者倫理のキホン」と言う本です。

この世の中、様々な方の研究があり、それに付随する形で技術の発達・進展があります。そして、それを実装していく際に「倫理」みたいなものは大変大事だなぁと思っています。

エンジニアとしても「それはちょっと…」みたいな企画・仕様に出くわす時はたまにあります。その際、自分がその気持ちをきちんと共有し、線引きをすることも大事な仕事だと思っています。改めて知識を整理する上でも、この本を読んでみることにしました。

全体的にはタイトルどおり、技術者が持つべき倫理観について様々な視点から書かれているのですが、ここでは「4章のモラル」について抜粋して書きたいと思います。 ただ、この章の内容は倫理観というより、チームとしてどのようにしていくのが良いか、というところに繋がる感じだったので、その点を念頭に置いてお読みくださいませmm

モラルには8種類ある

そもそもモラルとは、手元のMacに付属しているスーパー大辞林

道徳。倫理。また,人生・社会に対する精神的態度。

とあります。

そのモラルを、この本では「〜であってはいけない」という意味で以下の8つ示しています

  • 利己主義:他人よりも自分を優先し、個人的な利益を追求すること

  • 自己欺瞞:自分の言い訳を信じ込み、現実を歪めて自己を欺くこと

  • 意思薄弱:正義や正しい判断を実行する勇気が欠け、他人の意見に流されること

  • 無知:知識や知恵を得る努力を怠り、適切な判断を下すことができないこと

  • 自己本位:他人の視点や意見を無視し、自分だけの立場や利益を優先すること

  • 狭い視野:物事の一面のみに固執し、全体的な視野を持たずに判断すること

  • 権威追従:自らの判断力を放棄し、権威や上司の意向に従うこと

  • 集団思考(浅慮):集団内の価値観や意見を優先し、独自の判断を怠ること

田中和明. (2023). 技術者・研究者のための 技術者倫理のキホン. 秀和システム. p.52

このように8つのモラルが挙げられているのですが、このうち、利己主義〜権威追従までの7つは自分自身のお話です。 自分が何か行動をしよう(した)時に、このような事に陥っていないか?陥っているとしたら、上のモラルと照らし合わせてどのようにギャップを埋めて行けばいいか?を心に置きつつ、自分の改善を少しづつでも続けるのが大事だなと思います。(とはいえ、人間なので難しい面はいっぱいありますが💦)

ただ、8つ目にある「集団思考(浅慮)」に関しては自分自身だけではありません。集団(組織 or チーム)全体の話になってくると思います。

集団思考(浅慮)とは何だろう?

みなさんも経験があるかもしれませんが、組織やチーム内で意思決定をする際、一人ひとりは冷静に考えられるはずなのに、なぜか全体ではおかしな方向に進んでしまうことはないでしょうか。

これが「集団思考(浅慮)」と呼ばれる現象です。組織の結束力が高まるほど、メンバーは内輪の雰囲気に流され、十分な検証や異なる意見の考慮を省略してしまう傾向が強まってしまうことです。

主な特徴として、下記のような事があげられています。

  • 過大な自己評価とオールマイティ幻想:集団内では、組織やグループに対する過大な自己評価や、自分たちの判断力があらゆる状況に対応できるというオールマイティ幻想が広がる。

  • 集団への自己弁護と外部への偏見:集団内では自己弁護が行われ、集団外部への偏見が生じることがある。他の意見や視点を排除し、集団内の考えが優越的であると信じ込むことがある。

  • 組織の均一性の維持:集団浅慮の集団は、組織内での均一性を維持しようとする。外れた意見や行動がないかを監視し、集団の結束を保つために誘導することがある。

  • 代替案の不考慮と情報の偏り:集団内では、代替案を考慮せず、目標や対策の選択肢の危険性を無視することがある。情報の収集が不十分で、偏りのあるデータを元に判断がされることがある。

  • 事態に対する計画の不足:集団思考の状態では、非常事態に対する十分な計画が策定されず、適切な対応ができない可能性がある。

田中和明. (2023). 技術者・研究者のための 技術者倫理のキホン. 秀和システム. p.68

エンジニア視点で集団思考(浅慮)を考える

先ほどの特徴として挙げられている事をエンジニア視点で考えた時に、どこかあてはまる事はないでしょうか?

あくまで個人的意見にはなりますが「全てそのまま」はあてはまらずとも、形を変えて1つ、ないしは2つは経験としてはあるのでは?と思っています。

インフラ寄りの例にはなるのですが、よくありがちな例として

  • 障害発生時の1次対応が、思い込みからあらぬ方向にいってしまう
  • 作業手順や障害対応手順などのドキュメントに関するレビューが機能していない(レビューによる指摘がされない、もしくは指摘しづらい)
  • 外部の人から指摘を受けた改善すべき点に大きな抵抗力が出てきてしまう

というようなことがあるかなと思います。

もちろん、上記の例において、だれも悪意はもっている訳ではないと思います。ただ、そのような場合でも意図せずとも発生することはあると思います。

できるだけ集団思考(浅慮)を避けるには

ではでは、どのようにすれば、集団思考(浅慮)から避けれるのでしょうか?

個人的には「Team GeekGoogleギークたちはいかにしてチームを作るのか」で書かれているHRTの原則が大事だと思っています。

  • H(Humility:謙虚)
    • 世界の中心は君ではない。君は全知全能ではないし、絶対に正しいわけでもない。常に自分を改善していこう。
  • R(Respect :謙虚)
    • 一緒に働く人のことを心から思いやろう。相手を1人の人間として扱い、その能力や功績を高く評価しよう。
  • T(Trust:信頼)
    • 自分以外の人は有能であり、正しいことをすると信じよう。そうすれば、仕事を任せることができる。

Brian W. Fitzpatrick (著), Ben Collins-Sussman (著), 及川 卓也 (解説), 角 征典 (翻訳). (2013). Team GeekGoogleギークたちはいかにしてチームを作るのか. オライリージャパン p.15

この原則の上で、心理的安全性を保ち、多様な意見を促す仕組みを作ることが集団思考を避けることに繋がると感じています。

たとえば、今まで、自分がよくやっていた取り組みとしては下記のようなものがあります。

  • ファシリテーターのローテーション
    • 常に同じ人がファシリテーションを行うと、その人が「進行役としての権威」を持ちやすくなり、参加者も無意識にその人の期待する方向へ発言を寄せてしまう傾向がどうしても生じやすくなります。これを避けるために、ファシリテーターをローテーションすることにより、チーム内でよりフラットな関係性を築きやすくなります。
  • 非難なきポストモーテム文化(Blameless Postmortems)
    • 障害発生後の振り返りは、「誰が悪いか」ではなく、「何が起こり、なぜそうなったか」を冷静に明らかにする場の空気づくりをしましょう。批判や個人攻撃を避けることで、メンバーは安心して失敗や問題点を共有しやすくなるかなと思います。
  • 小さく始め、成功体験を積む
    • 大きな改善提案よりも、小さく改善して、少しづつ成功体験を繰り返していくことで、多様な意見をどのように改善に結びつけていくのが良いか、という仕組み作りをチームで行なっていけるようになっていきます。

その組織(チーム&メンバー)によって最適な取り組みは様々ですし、何が正解!というのは一概に言えるものではないです。が、ちょっとした仕組みと気遣いで、色々なことが少しづついい方向に変わっていくような取り組み方はきっとあるはずなので、一気に変えずに、一つ一つ少しづつ進めるのがいいかなと思います。

まとめ

当初の本のテーマから、少しエンジニアチームの例に目線を移した話になってしまいましたが、「技術者・研究者のための 技術者倫理のキホン」には品質管理からコンプライアンスBCP、安全対策といったITエンジニアにとっても割と身近なテーマが扱われていますので、興味がある方は一度書店で手に取ってみて、年末年始の帰省など移動の際に読んでみてはいかがでしょうか〜

採用について

カンムではこんな僕たちと一緒に働いてくれるエンジニアを募集しています! カンムテックブログを見て、少しでも興味を持たれた方、ぜひぜひ応募してください!一緒に働きましょう!

team.kanmu.co.jp

Redash を少し便利に使う Chrome Extensions を書いた

エンジニアの佐野です。菅原が Redash について言及したのでそれに乗っかって自分も簡単な記事を書きます。

tech.kanmu.co.jp

まず Redash の基本的な使い方としては次の通りです。

  1. https://your-redash-domain/queries/new にアクセスする。
  2. データソース *1 を選択してクエリを書く。
  3. クエリを実行する。

ここで2のデータソースを選択する際なのですが、カンムは Redash を多用しているため大量のデータソースが存在し、都度用途に応じて選択する必要があります。次の画像が存在するデータソース一覧です。

例えば画像中でマスクしていない BigQuery (DWH) と readonly(RR) なのですが以下のような用途・特徴となっています。

データソース名 接続先 特徴 用途 主な利用者
BigQuery (DWH) Google BigQuery リアルタイム性はない *2 が、アナリティクスを含めバンドルカードのほぼすべてのデータが入っている ヘビーなクエリを投げて分析したいケース、カンムのほぼすべての DB を JOIN したいケース データチーム、マーケチーム
readonly(RR) バンドルカードのメインの DB *3 リアルタイムのデータにアクセスできる リアルタイム性が必要なデータを参照したいケース、メイン DB のデータのみで完結するような分析用途 バンドルカードのエンジニア

このデータソースは上の画像の通りプルダウンメニューから選択することになっているのですが、データソース名の辞書順でソートされて表示される仕様になっています。Redash のソースコードでいうと以下の箇所です。クエリ新規作成画面にアクセスしたときに Redash のサーバ側でソート済のデータソースのリストが画面に返されてそれが表示されるようになっています。

github.com

ここでクエリを書く際に面倒なことがあります。ある人は BigQuery をよく使う、またある人は readonly(RR) をよく使う...といったように人によってよく使うデータソースが異なります。加えて Redash 自体にユーザごとにデフォルトのデータソースを設定するような機能はありません。辞書順でソートされるのであればデータソース名に 1, 2, 3_... のようなプレフィックスをつけて頻繁に使われるものが上にくるようにすればいいと思うかもしれませんが、それは人によって異なるのでそれでは解決しません。

そこでこの問題を解決するために簡単な Chrome Extensions を書きました。Extension の設定画面にデフォルトのデータソースを設定すると、クエリの新規作成画面に飛んだときにそれが自動で選択状態になるといったものです。

github.com

私自身は日々のデータ分析や調査で頻繁に Redash からクエリします。そのときに毎度毎度自分がよく使う readonly(RR) を選択するのがかなり面倒でした。この Extension によって少なくとも自分自身のペインは解消されました。

JavaScript のようなフロントエンドの技術に関してはかなり素人なのでとりあえず動くものを書いたというのがこの Extension です。TypeScript への書き直しであったり PR を投げてもらえるととてもありがたいです。ちなみにそんなヨカタの自分がこの Extension の開発でちょっとだけ苦労した点としては、プルダウンメニューを示す要素が最初から画面にあるわけではなかったという点でしょうか...。Redash のクエリ新規作成画面は動的に描画されるようです。最初は「プルダウンメニューでターゲットとなるデータソース名を選択済にしてしまうコードを書けばさくっとできるだろう...そんくらいなら俺でも秒で書けるぞ。」と思ったのですがそうもいかなくて、 DOM の変化を監視してプルダウンメニューが表れたらそれをクリックさせる、というようにしました。これももっとスマートなやりかたがあったらご指摘いただけると幸いです。

今日は簡単ですが以上です。

おわり

*1:接続先の DB を表す Redash 上の概念。スプレッドシートCSV ファイルをデータソースとすることもできる。

*2:日次や月次で BigQuery にデータを同期している。

*3:正体は Amazon Aurora PostgreSQL の reader ノード。

ジョブキューのパフォーマンス問題の経験から学んだ PostgreSQL の内部理解

ソフトウェアエンジニアの新田です。

これは カンム Advent Calendar 2024 の8日目の記事です。昨日はフロントエンドチームのEMの佐藤さんの記事 チームが成長するたびに変わっていった朝会の話 - カンムテックブログ でした。 私は現在プラットフォームチームに所属しており、おなじようにチームで朝会をしているので非常に参考になりました。積極的にパクっていこうと思います。

さて、カンムではプロダクトのデータベースに Amazon Aurora PostgreSQL を採用しています。

ここで少し特徴的なのは、このデータベースは、アプリケーションのデータ管理だけでなく、ジョブキューの役割も担っています。このジョブキューには、 Que の Go 実装である kanmu/qg (以下 qg )を使っています。

qg を用いることで、トランザクション内でジョブをエンキューできるようになり、我々が主に開発しているカード決済などのプロダクトにおいて欠かせない、複数のサービス間のデータの整合性を効率的に担保しています。そのため、トランザクショナルなジョブキューは我々プロダクトの重要な技術基盤になっています。

この qg について説明した資料には以下のようなものがあります。

今回の記事は、この qg を用いたジョブキューの運用中に直面した、「デキュー時のパフォーマンス劣化」という問題についてお話しします。また、問題を正しく理解するために学んだ、 PostgreSQL の MVCC や Snapshot といった内部の仕組みについてもお話しします。

システムの概要

システムアーキテクチャの概要

今回問題が起きたサービスのデータベースとワークロードについて説明します。

データベースは Aurora クラスタです。クラスタは、 writer インスタンス (読み書き) と reader インスタンス (読み取り専用) で構成されています。このデータベースを使った複数のワークロードが動作しています。

  1. API ワークロード (以下 api)

    モバイルアプリや外部システムからのリクエストを受け付け、必要な処理を実行します。

  2. Worker ワークロード (以下 worker)

    非同期ジョブを処理する専用のワークロードです。これにより、リクエスト処理とジョブ実行を分離し、効率的なリソース管理を実現しています。

ジョブは que_jobs テーブルで管理されており、ジョブのエンキューは que_jobs テーブルへのインサートになります。この操作は api と worker の両方から行われます。たとえば、api は外部リクエストを受けた際にジョブをエンキューします。 worker は他のジョブをエンキューしたり、エラーになったジョブをリトライするために再度エンキューしたりします。

ジョブのデキューは worker が実行する必要のあるジョブのレコードを que_jobs テーブルから探します。このとき、 worker 同士が同じジョブを取得することを防ぐためにロックを獲得します。ここで、他の worker のクエリをブロックしないようロックを獲得する方法として pg_advisory_lock と Recursive CTE を組み合わせたアルゴリズムが用いられています。

worker は、ジョブ処理を正常に完了すると、 que_jobs テーブルから該当のレコードを削除します。

qg のロック獲得SQL

以下は、 qg がジョブをデキューする際に、ロックを獲得しながら1件のジョブを取得するための SQL です。

WITH RECURSIVE job AS (
  SELECT (j).*, pg_try_advisory_lock((j).job_id) AS locked
  FROM (
    SELECT j
    FROM que_jobs AS j
    WHERE queue = $1::text
    AND run_at <= now()
    ORDER BY priority, run_at, job_id
    LIMIT 1
  ) AS t1
  UNION ALL (
    SELECT (j).*, pg_try_advisory_lock((j).job_id) AS locked
    FROM (
      SELECT (
        SELECT j
        FROM que_jobs AS j
        WHERE queue = $1::text
        AND run_at <= now()
        AND (priority, run_at, job_id) > (job.priority, job.run_at, job.job_id)
        ORDER BY priority, run_at, job_id
        LIMIT 1
      ) AS j
      FROM job
      WHERE NOT job.locked
      LIMIT 1
    ) AS t1
  )
)
SELECT queue, priority, run_at, job_id, job_class, args, error_count
FROM job
WHERE locked
LIMIT 1

このSQLがどのように動作するかをステップごとに説明します。

まず全体像として WITH RECURSIVE job AS ... とあるように Recursive CTE を構成しています。 Recursive CTE は、最初に評価される初期ステップと、以降繰り返し評価される再帰ステップを UNION または UNION ALL オペレータで接続して構成されます。

それでは各部分の説明をしていきます。

初期ステップ

  SELECT (j).*, pg_try_advisory_lock((j).job_id) AS locked
  FROM (
    SELECT j
    FROM que_jobs AS j
    WHERE queue = $1::text
    AND run_at <= now()
    ORDER BY priority, run_at, job_id
    LIMIT 1
  ) AS t1

que_jobs テーブルから条件を満たすジョブを1件取得します。

このときに取得できたジョブの job_id に基づいて pg_try_advisory_lock を実行し、ロックを試みます。 pg_try_advisory_lock はロックを獲得できた場合は true を返し、すでに他のセッションにロックされている場合は false を返します。

再帰ステップ

  UNION ALL (
    SELECT (j).*, pg_try_advisory_lock((j).job_id) AS locked
    FROM (
      SELECT (
        SELECT j
        FROM que_jobs AS j
        WHERE queue = $1::text
        AND run_at <= now()
        AND (priority, run_at, job_id) > (job.priority, job.run_at, job.job_id)
        ORDER BY priority, run_at, job_id
        LIMIT 1
      ) AS j
      FROM job
      WHERE NOT job.locked
      LIMIT 1
    ) AS t1
  )

初期ステップでロックを獲得できなかった場合 (WHERE NOT job.locked )、次のジョブを探索してロックを試みます。このときに初期ステップで取得したジョブよりも後に位置するジョブを探索範囲とし、優先度の高い (qg では priory の値が低いほど優先度は高い) ジョブを1件取得します。条件に合うジョブがあれば pg_try_advisory_lock でロックを試みます。

結果の選択

SELECT queue, priority, run_at, job_id, job_class, args, error_count
FROM job
WHERE locked
LIMIT 1

再起的に探索したジョブの中から、ロックに成功した1件を取得します。

このようにして、 pg_try_advisory_lock によるロック試行を行うことで、複数の worker が同じジョブを処理することを防いでいます。

発生した問題について

qg のパフォーマンス悪化

日々の運用では、未処理ジョブが滞留せずに処理されているかを DataDog でモニタリングしています。ある日、大量のジョブが滞留したまま消化できていない状況が発生し、アラートが鳴りました。

調査したところ、大量のジョブ自体は毎日ほぼ同じ時間にエンキューされるもので、普段の投入量と大きな変化はありません。しかし、普段と比較して worker がジョブのデキューして処理する時間が著しく長くなっていました。

worker のログを確認したところ、ジョブ取得処理の時間 (lock_time) が通常よりも長くなっていることが判明しました。さらに、 DataDog で監視している dead tuples のメトリクス (postgresql.dead_rows) を確認したところ、 que_jobs テーブルの dead tuples が増え続けていることがわかりました。

とある時間帯から増え続ける dead tuples

dead tuples とは、 PostgreSQL において更新や削除操作によって古くなり不要となったレコードです(内部的には「タプル」と呼ばれます)。これらのタプルは即座に削除されるわけではなく、 VACUUM によってクリーンアップされます。

この dead tuples の増加が原因で、 qg のパフォーマンスが劣化することは既知の問題として把握していました。 詳細については以下の文献が非常に参考になりますが、本記事でも後ほど PostgreSQL の内部構造を踏まえて解説します。

原因の特定

さて、この状況を改善するには、 VACUUM による dead tuples の回収が必要です。本来、 autovacuum がこれを自動的に処理するはずですが、どうやら適切に機能していないようでした。

ここで、 pg_stat_acitivity を用いてアクティブなクエリを確認したところ、意図していないクエリが発行されていることが判明しました。 (後になって気づいたのですが、このクエリは Performance Insights でも目立つ形で表示されていました。当時は余裕がなく見落としていましたが、こうした状況ではどこを確認すべきかを形式化・習慣化する必要があると感じました…)

意図していないのは、そのクエリのユーザ (postgreSQL の user) です。それは社内で利用している BI ツール Redash 用に作成されたユーザでした。本来、 Redash からは reader インスタンスに向けてクエリを実行することを意図していましたが、過去に筆者が Aurora データベースを Redash に新しいデータソースとして登録する際に、誤ってクラスタエンドポイント(writer インスタンスを指す)を参照してしまっていたことが原因でした。正しくは reader エンドポイント (reader インスタンスを指す) を設定する必要があります。この設定ミスを修正し、 reader エンドポイントを指すように変更しました。

該当のクエリは、 Redash クライアントとの接続がすでに切れているにもかかわらず、 pg_stat_activity で観測され続けていました。このクエリの wait_event は io:BufFileWrite となっていました。このクエリは、大量のテーブルスキャンと CTE を組み合わせる、まさにデータ分析のためのアドホックなクエリで、非常に重い処理でした。

さらに、あろうことか、このクエリのユーザの設定には statement_timeout などのクエリタイムアウトの設定をしていなかったため、クライアント接続が切れても PostgreSQL のバックエンドプロセス自体はクエリの処理を続行していました。

問題の解消までの流れ

以下は、問題発生時に手動で実行した VACUUM のログです。 (VACUUM VERBOSE que_jobs というSQLを実行):

INFO:  vacuuming "service_x.que_jobs"
INFO:  "que_jobs": found 0 removable, 768195 nonremovable row versions in 15623 out of 15623 pages
DETAIL:  679473 dead row versions cannot be removed yet, oldest xmin: 1226078887
There were 1005 unused item identifiers.
Skipped 0 pages due to buffer pins, 0 frozen pages.
0 pages are entirely empty.
CPU: user: 0.08 s, system: 0.00 s, elapsed: 0.08 s.

0 removable とは削除可能な(不要とみなせる)タプルは0件であることを表しています。これは VACUUM で削除できる dead tuples が存在しないことを示しています。

768195 nonremovable row versions とは削除できないタプルが 768,195 件存在することを表しています。多くの行が nonremovable である原因は、これらがまだ参照されているためです (後述する oldest xmin と関連します)。 in 15623 out of 15623 pages はこのテーブルの全ページ(15,623ページ)がスキャンされたことを示します。

679473 dead row versions cannot be removed yet は、 679,473件の dead tuples が削除不能な状態にあります。これらタプルは他のクエリが参照できる状態にあるため削除できないのです。

そして oldest xmin: 1226078887 が、アクティブなクエリのなかで、最も古い xmin のスナップショットを保持するクエリが保持する xmin を示しています。

このログから読み取れることは以下です。

  1. 大量の dead tuples が削除不能に陥ってる

    679,473件の dead tuples が削除できず、 que_jobs テーブルの肥大化を引き起こしています。

  2. VACUUM が効いていない

    削除可能なタプルが 0 件であるため、 VACUUM を実行しても que_jobs の dead tuples は回収されず、 qg のパフォーマンスは改善されないままです。

このログの oldest xmin が指し示す xmin 1226078887 を保持するクエリこそ、まさに pg_stat_activity で見つけていたクエリでした。これは pg_stat_activity のカラム backend_xmin の値も 1226078887 と一致していました。

VACUUM を阻むクエリであることを確信したので、 pg_terminate_backend を実行して当該クエリのプロセスを強制的に終了させました。プロセス終了を確認後、再度 VACUUM を手動で実行したところ、今度は大量に dead tuples をクリーンアップしてくれました。

一気に回収される dead tuples

そして、 worker のデキューのパフォーマンスは無事正常状態に戻りました。

ここまでが当時発生した問題の原因の特定から解消までのオペレーションの流れになります。

que_jobs テーブルとは全く関係のないクエリであっても、おなじ DB インスタンス上で長時間実行されることで、 que_jobs テーブルの dead tuples を回収できずにテーブルを肥大化させ続け、結果的に qg のデキューパフォーマンスや DB の負荷上昇につながってしまっていました。

再発防止のための対策

この反省を踏まえて、以下の対策を進めています。

  1. Redash で writer エンドポイントを指定しても writer インスタンスに接続できないようにする。

    今回の問題は Redash が writer インスタンスに向けてしまった設定ミスから始まりました。

    そこで、プラットフォームチームの菅原さんが Redash コンテナの /etc/hosts を書き換えるアイデアで対応してくれました。 writer エンドポイントのホスト名に対応するIPアドレスをテスト用の無効なIPアドレス (TEST-NET-1) を指定することで、誤って writer エンドポイントを設定したとしても実際に接続することはできないようになります。

  2. PostgreSQL ユーザの statement_timeout を設定する。

    今回、クエリにタイムアウト設定がなかったため、長時間クエリが残り続けました。 PostgreSQL の statement_timeout を適切に設定し、長時間クエリを自動的に停止するようにして再発を防ぎます。

  3. デキューアルゴリズムの改善

    上述した文献にも紹介されるように、現在の qg では qg_advisory_lock と Recursive CTE を使用していますが、これらは dead tuples に影響を受けやすい設計です。

    たとえば比較的新しい PostgreSQL ジョブキュー実装である https://github.com/riverqueue/river では、より新しいロック獲得方法の SELECT FOR UPDATE SKIP LOCKED を利用するなどといった効率的な方法が採用されています。

    今後のサービスの拡大に伴い扱うジョブの増加に耐えられるよう、 qg の改善もしくは river のような技術の採用を検討していきたいと考えています。

PostgreSQL 内部の仕組み

今回取り上げた問題は、 実行時間の長いクエリによって VACUUM が効果を発揮しないPostgreSQL データベース運用の典型的なケースです。恥ずかしながら、今回の問題をきっかけに、自分の知識不足を痛感しました。

問題の根本原因を深く理解するため、改めて PostgreSQL の内部構造に目を向け、 MVCC (Multi-Version Concurrency Control)Snapshot といった仕組みを基礎から見直しました。それに加えて、 VACUUM や dead tuples が何を意味しているのか、我々のプロダクトの運用に対しどのように影響を与えるのかを理解することに努めました。

オススメの資料

この記事でもこのあと説明しますが、以下の資料は PostgreSQLトランザクション、MVCC、スナップショットなどをわかりやすく丁寧に説明しています。ぜひ読んでいただくことをおすすめします。

MVCC を実現するタプルの xmin, xmax

PostgreSQL では一貫性を保ちながら複数トランザクションの平行処理を効率化する仕組みとして MVCC (Multi-Version Concurrency Control) を採用しています。

DELETE の SQL が実行されても、実際に削除対象であるタプルをその場で削除することはなく、削除マークを付け加えるだけです。 UPDATE の場合は、削除マークを付け加え、更新後の値を保持する新しいタプルを作成します。

とあるトランザクションが DELETE しても、そのトランザクションが COMMIT されるまでのあいだ、他の進行中のトランザクションからすればそのタプルはまだ使用可能である必要があるためです。

各タプルは、データそのものとメタデータ(タプルヘッダ)を持っています。このヘッダに格納されている xmin, xmax が、タプルの可視性を判定する基盤となっています。

  • INSERT 時にセットされるのが xmin です。
  • DELETE 時にセットされるのが xmax です。
  • UPDATE では、既存タプルの xmax に値をセットし、新しいタプルに xmin に値をセットします。

xmax が設定されているものこそが、更新や削除により不要となったタプル、 dead tuple です。

スナップショット

スナップショットは、クエリが「どのデータを可視とするか」を決定するためのデータ構造です。

スナップショットの実態は SnapshotData という構造体です (code)。いろんなメンバがありますが、今回取り上げたいのは xmin, xmax, 進行中のトランザクションの配列 xip です。

typedef struct SnapshotData
{
    // ...
    TransactionId xmin;
    TransactionId xmax;
    TransactionId *xip;
    // ...
} SnapshotData;

スナップショットのこれらのメンバは可視性判定時に以下のように振る舞います。

具体例を用いて説明します。以下のテーブル fruit に3つのタプルがあるとします。

name xmin xmax
apple 101 105
banana 102 null
cherry 104 null

ここで、進行中のトランザクションが2つあるとします。 (XID=104, XID=105)

このとき、 XID=103 のトランザクション上で以下のスナップショットが作成されたと考えます。

  • xmin=103
  • xmax=106
  • xip=[104, 105]

このスナップショット情報とタプルの情報を突き合わせることで、このスナップショットからのタプルの可視性を判定できます。

  • apple タプル (xmin=101, xmax=105)
    • xmin=101 : XID < 103 のため挿入は完了している。
    • xmax=105 : 進行中のトランザクションのため、削除はこのスナップショットからは見えない。
    • 結果 : 可視
  • banana タプル (xmin=102, xmax=nulll)
    • xmin=102 : XID < 103 のため挿入は完了している。
    • xmax=null : どのトランザクションからも削除されていない。
    • 結果 : 可視
  • cherry タプル (xmin=104, xmax=nulll)
    • xmin=104 : 進行中のトランザクションのため、挿入はこのスナップショットからは見えない。
    • 結果 : 不可視

このようにして、タプルのメタデータである xminxmax とスナップショットの xminxmax を用いた「可視性」の判定によって、 PostgreSQLトランザクションごとに一貫性のあるデータビューを提供していることがわかります。

スナップショット作成のタイミング

弊社の PostgreSQL データベースでは、トランザクション分離レベルを READ COMMITTED に設定しています。この場合、トランザクションではないクエリにおいても、クエリを実行する際にスナップショットを作成します

この説明が正しいか実際の PostgreSQLソースコードを確認してみます。

SQL を処理する exec_simple_query() の中では、すべてのクエリで GetTransactionSnapshot() が呼び出されます。この関数内では、現在のトランザクション分離レベルに応じて動作が異なります。

  • READ COMMITTED の場合
    • 各クエリで常に新しいスナップショットを作成するため、GetSnapshotData() が毎回呼び出されます。
  • REPEATABLE READ の場合
    • 最初のスナップショットを取得した後は、それをキャッシュして再利用します。

この仕組みによってトランザクションの分離レベルに応じたスナップショット管理が実現されていることがわかります。

https://github.com/postgres/postgres/blob/792b2c7e6d926e61e8ff3b33d3e22d7d74e7a437/src/backend/utils/time/snapmgr.c#L280

VACUUM が dead tuples を回収できなかった理由

Redash から実行されたクエリは、スナップショットをその時点で作成し (READ COMMITTED) 、それ以降に作成された que_jobs テーブルのタプルがたとえ DELETE で削除されていても、そのクエリからは可視であったためでした。

よって、 VACUUM は que_jobs テーブルの多くのタプルは Redash から実行されたクエリにとって可視であることを理由にタプルのクリーンアップをしない判断をとっていたのです。

qg のクエリパフォーマンスへの影響

タプルの xmin, xmax はタプルのヘッダ上に含まれています。つまり、タプルが live なのか dead なのかを確認するためにはタプルにデータアクセスする必要があります。インデックス自体にはタプルの可視性といった情報は保持されていません。

qg のロック獲得SQLを振り返ってみると、 WHERE 句で絞る条件には queue と run_at しか用いられていません。

        WHERE queue = $1::text
        AND run_at <= now()

そのため、それらの条件を満たすが既に dead であるレコードに対してもインデックスの走査対象となり、可視性の確認のために都度タプルをフェッチする非効率なことが行われているのです。

まとめ

以上、この記事では、 PostgreSQL を用いたジョブキュー qg の運用において発生したデキューパフォーマンス問題を題材に、問題解決の過程、および得られた学びについて共有させていただきました。 具体的には、 VACUUM が dead tuples を回収できなかった原因を MVCC やスナップショットの仕組みに基づいて解析し、 dead tuples の増加に伴いデキューパフォーマンスが劣化する原因を可視性の判定にタプルのフェッチが必要となるオーバーヘッドの観点から説明しました。

この問題を通じて、運用上のいろいろな教訓や学びを得ることができました。今後もプロダクトの成長とともにこのジョブキュー基盤の利用は多くなってくるので、改善を重ねて信頼性の高い基盤づくりをしていきたいと思います。 この記事がどなたかの役に立てば幸いです。

カンムではバックエンドエンジニアを募集しています。ご興味ある方はぜひご連絡ください!

team.kanmu.co.jp

チームが成長するたびに変わっていった朝会の話

こんにちは、カンムのフロントエンドチームでエンジニアリングマネージャーをしている佐藤です。

これは カンム Advent Calendar 2024 の7日目の記事です。昨日は always_allokay によるお世話になっているライブラリをちょっと見てみるシリーズ、shopspring/decimalの記事でした!

この記事ではチームで行っている朝会の様子や改善してきたことを紹介します。

背景

バンドルカードチームでは、今年の頭から各職能チーム(Unit)のメンバーが異なる目的を持ったチーム(いわゆるフィーチャーチーム)に属し、それぞれが独自のプロジェクトやタスクに取り組む形でプロダクト開発を進めています。フロントエンドチームメンバーもそれぞれがそれらのチームに属す形になっています。チーム数などは実際とは異なりますが、構成としては以下の図のような一般的なイメージのものです。進捗確認や細かい機能のアサインなどのやり取りはフィーチャーチーム内でスムーズに行われています。

目的

フィーチャーチームの誕生前から存在していたフロントエンドチームでの朝会では、各メンバーの「直近の仕事の予定や、今の困りごとを相談する」という目的で実施され、チームの知識の平均化や、気軽なコミュニケーションの場などとして一定の機能を果たしてきました。

他にも、朝会での何気ない一言が他のメンバーに引っかかり、潜在的な問題を早めに発見できる役割もあります。基本的にはフィーチャーチームでの各プロジェクトの仕様や進め方は各メンバーが決めていくという体制ですが、フロントエンド観点で最低限抑えておくべきものなどを広い視点で拾うことができる良い場だと感じています。

なお、ここまでわかりやすさのため「朝会」と書いていましたが、実施は午後の15時にしています。これは、午後の集中力が落ちてくるタイミングで気分転換を図るという狙いである時点からそうしています。様々なライフステージのメンバーがいるなかでも参加率も高く好評でした。

課題

概ね良い部分もありつつ、フロントエンドチームへの人数の増加や、それぞれのフィーチャーチームにおける中期的なプロジェクトが本格化したことで、議論の幅や深さが増してきました。
プロジェクトの状況や課題、それらの実装など共有するトピックが格段に増えたことで、単純に時間を大幅にオーバーすることが増えたり、あとの方に話すメンバーの持ち時間が足りなくなるという部分に課題感がありました。

そういったこともあり、何度か手法の見直しを行ってきたので少し紹介します。

試行錯誤の過程

とりあえず全部吐き出す

まずは各メンバーが実装から仕様まで様々なそこそこ大きなトピックを話す機会が増えたということで、もともと30分だったMTG時間を1時間に拡大し、思う存分話すようにしてみました。
仕様の議論から実装の詳細まで幅広いトピックを扱う場として機能し、他のチームの状況の共有やそこそこ深い議論ができるのは非常に良い点でした。

一方で、内容が詳細になりすぎたり、情報量が多すぎて議論への集中が難しくなる場面も多々ありました。また、そういった内容を毎日1時間のMTGで話すのは次第にヘビーに感じられるようになっていました。

ゆるい時間制限とファシリテーションの導入

課題を感じながらも上記の運用を続けた後に、大きな問題であった時間不足に対処するため、MTG時間を30分に戻し、1人あたり5分程度を目安とする時間制限を設けてみました。

もともとは話し始める人を入社順にしていましたが、後の方になると時間が足りなくなるという課題があったので、日ごとに話し始める人兼ファシリテーターがおおむね輪番になるようにしました。

また、ファシリテーターが固定だと聞き役も偏ってしまう状態でもあったため、持ち回りで担当することで、自然な会話が生まれるようにする意味もあります。ファシリテーターの選出には、朝会に使用しているNotionの適当なデータベースに組んだNotion数式をとりあえずと思って使いましたが、意外と使い勝手もよくそのまま使用しています。

このあたりを進めることである程度共有の効率が上がりました。とはいえ時間制限が曖昧だったため、結局時間オーバーになることが多く、どの程度の詳細さで話すべきかという悩みは解消されませんでした。なにより、盛り上がっているトピックの話をファシリテーターが中断するのはかなり勇気が必要でした。

厳密な時間管理と議論コーナーの設置

そこで、厳密な3分制限を導入し、時間管理を徹底してみることにしました。また、それだけだとあまり変化がないので話し足りない内容や少し議論したいトピックのために、一通り全員が話し終わったあとに議論や雑談のコーナーを設けました。これまでも少し大きなトピックの場合は別でMTGを設定するようにはしていましたが、朝会で盛り上がった内容を改めてMTGとして設定する塩梅が結構難しく、この変更でそのあたりのバランスがとれたと思っています。

盛り上がった会話だったり、あらかじめ話したい話題を書いておき、後で取り上げている

この形式により、共有の効率が飛躍的に向上し、綺麗に時間内に全員が発言できるようになりました。詳細な議論も後半で行えるため、話す粒度の調整も容易になりました。

時間制限に関して特に大きくやり方を変えたわけではなく、曖昧になっていた認識を揃えたこと、後続で話せるコーナーを設けたことで自然にあとで議論する流れが作れています。 これは今のところよく機能していて、多少のメンバー増加が伴っても問題はなさそうです。

色々と試したが、いまはこんな感じでタイマーをNotionページに埋め込んでいる

終わりに

この一年間でチームメンバーが急増し、それに伴って場が機能しているかを意識する機会が増えました。特に朝会はその中の一つであり、メンバー全員が価値を感じられる場を模索してきました。

こうして改めて振り返ると、ルールの設計はもう少し最初からいい感じにできたな、と感じる部分もあります。とはいえ、それ以上に重要だったのは自分を含めたメンバーが場に対してどのような点に価値を見出しているのかを理解し、それを上手く残していくことだったとも感じます。こうした過程を経ていったんの最適解を見つけるのも案外良かったのではと思っています。

現状としては情報共有の場以上に良い形になっていると感じますが、朝会に限らずこういった場は組織体制の変化に応じて柔軟に形式を見直していく必要があると思います。 似たような組織体制や課題を抱えるチームの参考になれば幸いです。

カンムでは引き続き、フロントエンドエンジニアを募集しています。興味のある方は、ぜひご連絡ください。

team.kanmu.co.jp

使っているライブラリを見てみよう(shopspring/decimal編)

こんにちは. Poolでエンジニアをしている @always_allokay です。

こちらはカンム Advent Calendar 2024 の6日目の記事です。 昨日はprinさんによるボボステの記事でした。

タイトルの通り、お世話になっているライブラリをちょっと見てみるシリーズです。(今はじめました)
今回は、shopspring/decimal について見ていきます。

shopspring/decimalとはなにか

まずはライブラリのREADMEをみます。

Arbitrary-precision fixed-point decimal numbers in go.

Note: Decimal library can "only" represent numbers with a maximum of 231 digits after the decimal point.

記載の通り、「Goでの任意精度固定小数点10進数」を実現するライブラリです。

浮動小数点はよく聞くワードですが、固定小数点とはなにか?という疑問が湧きます。 詳細は以下の資料にゆずりますが、数値を格納するときに、整数部と小数部の桁数をあらかじめ決めてある、というのが固定小数点です。 とてもわかりやすくて良いスライドなのでぜひ一読をお勧めします。

speakerdeck.com

なにを解決するライブラリか?

さきほどのスライドでも言及がありますが、コンピュータでは最終的に2進数でデータを扱う都合上、小数点以下を厳密に表現することができない、というよく知られた問題があります。
日常的にプログラミングする分には問題ない精度であることがほとんどなのですが、金融や決済などの領域では、致命的な誤差になりえます。 これを解決(≒ほとんどの場合に問題ないように)するためのライブラリです。
カンムでも、決済や利率計算の際に誤差がでないように利用しています。

ライブラリの構造

それではさっそくみていきます。まずはrepository全体を概観。

$ tree .
.
├── CHANGELOG.md
├── LICENSE
├── README.md
├── const.go
├── const_test.go
├── decimal-go.go
├── decimal.go
├── decimal_bench_test.go
├── decimal_test.go
├── go.mod
└── rounding.go

1 directory, 11 files
$ wc -l *
      76 CHANGELOG.md
      45 LICENSE
     139 README.md
      63 const.go
      34 const_test.go
     415 decimal-go.go
    2339 decimal.go
     314 decimal_bench_test.go
    3649 decimal_test.go
       3 go.mod
     160 rounding.go
    7237 total

全体でもたった11ファイル、テストコードを除けば、3000行弱のソースコードで、実現されていることがわかります。

github.com

// Decimal represents a fixed-point decimal. It is immutable.
// number = value * 10 ^ exp
type Decimal struct {
    value *big.Int

    // NOTE(vadim): this must be an int32, because we cast it to float64 during
    // calculations. If exp is 64 bit, we might lose precision.
    // If we cared about being able to represent every possible decimal, we
    // could make exp a *big.Int but it would hurt performance and numbers
    // like that are unrealistic.
    exp int32
}

ライブラリの基礎となるdecimal.Decimal structに注目すると、Decimalは、*big.Int型のvalueとint32型のexpの二つからなることがわかりました。 そして、このライブラリが提供する最大精度もexpがint32であることに起因することがわかります。 であれば、これをより大きな数値を扱える型にするとよいのではと安直に思ってしまいますが、それに関する2つのトレードオフについてコメントされています。

  • ひとつは、64bitにする場合について。計算途中で、float64にキャストする部分があり、そこで桁溢れを起こさないためとありますね。 オーバーフローを起こすと、精度云々ではなく数値がぶっ壊れてしまうのでこれは納得。
    • 実際にfloat64にcastしている箇所は以下でした。 github.com
  • もうひとつは、big.Intなどとする場合について。こちらは性能が損なわれてしまうことと、そこまで大きな数値を扱うケースが現実的ではないことから使わないと判断されているようです。

一つ賢くなりました。

さらに、Add, Sub, Mul, Divなどの四則演算をみていくと、実はMulメソッドは、不正確な数値を返しそうになる場合panicを発生させていることを発見しました。 普段使ってるメソッドにも知らないことがあるものですね。とはいえ、現実に扱う数値では遭遇することはなさそうです。

github.com

そのほかの発見としては、丸め処理にも、通常の四捨五入以外にも、BankerRouding、CashRouding, RoundDown, RoundUpという丸め処理があることがわかりました。
それぞれ、近い方の偶数に丸める、最小通貨単位に丸める、0に近づくように丸める、0から遠ざかるように丸めるという処理です。
現在は利用していないのですが、CashRoundingは実は任意の円単位(千円、万円、億円 etc)に数字を丸めるときに使えるかもしれません。

得られた知見

  • shopspring/decimalは、231桁の精度まで担保している。これは言い換えると小数点以下2,147,483,647桁≒小数点以下21億桁の精度なので、現実的には十分な精度である。
  • 丸め処理にはBankerRouding, CashRoudingをはじめ多くのバリエーションがあること。

一言

もうちょい深掘りしたかったのですが、アルゴリズムとか最適化らへんは歯が立たなかったです。大学生ぶりにアークタンジェントとかみました。 つぎは標準ライブラリや準標準(x/exp/...)のライブラリを読みたいですね。

カンムはソフトウェアエンジニアを募集しています

team.kanmu.co.jp

最近のRedash

SREの菅原です。 この記事はカンム Advent Calendar 2024の4日目の記事です。

最近のRedashの開発状況について、知っている範囲ですこし書いてみたいと思います。


redash.io

Redashといえば様々なデータソースをSQLを使って可視化できるBIツールで、カンムでも業務のデータ分析に使われています。 ただ、一昔前にRedashがはやっていた頃に比べると、最近ではトレンドからは外れたような印象があります。

実際、SaaS Redashが終了した2021年から2023年の4月あたりのGitHubのアクティビティを見ると、活動が停滞しています。

Contributors to getredash/redash · GitHub

この頃、CVE-2023-0286の対応のため、私はRedashのDockerイメージのベースをDebian busterからbullseyeに更新しようとしたのですが、そもそもDockerイメージをビルドするCIが壊れていたので、それを直すPull Requestを作成してたりしました。

github.com


2023年4月にRedashがコミュニティ主導のプロジェクトになるアナウンスがされます。

github.com

これを契機に開発が再び活発になり、古いIssueやPull Requestが整理され、新しいメンテナの方々によって依存ライブラリのバージョンアップなどのPull Requestがどんどんマージされるようになりました。

あわせてGitHub Actionsへの移行、CIの改修、docker composeまわりの改善も進み、開発しやすい体制が整っていきます。

私もいくつかPull Requestを作成してマージしてもらいました(懸念だったベースイメージの更新もできました)。


2024年は前年ほどではないものの粛々と開発は続いています。

ずっとWarningを出していた開発停止ライブラリが削除されたり

github.com

ARM64のDockerイメージの作成されたり。

github.com

また開発ドキュメントの整備も進んでいます。

github.com


懸念は安定版がなかなかリリースされないことです。 (最新の安定版のリリースは3年前)
以下のディスカッションで議論はされていますが、外からはメンテナンスチームの動きがあまり見えないので、気になるところではあります。

github.com

プレビュー版のリリースは活発なので、もし来年の前半ぐらいまでに安定版がリリースされないのであれば、プレビュー版に移行していくしかないかな…となんとなく考えています。

まとめ

ほとんど更新が止まったかに思えたOSSが再びよみがえっていく様子を見られたのはなかなかうれしいことでした。 まだ懸念点はあるものの、コミッターには日本人の方もおり、改善のPull Requestも受け入れられやすい状況にあるのではないかと思います。

カンムでは引き続きRedashを使っていく予定なので、今後もRedashコミュニティに多少なりとも貢献できればと考えています。