有名テック企業の技術ブログを、ひとつのフィードで。
フィード
112件
はじめに こんにちは。Merpay の Payment & Customer Platform で会計システムを開発・運用する Accounting チームで Backend Engineer をしている @mewuto です。本記事は「Merpay & Mercoin Tech Openness Month 2026」の11日目の記事です。 マネージドサービスの世代移行では、コードを変えていなくても、デフォルト値の違いだけでシステムの振る舞いが変わることがあります。ある月末の早朝、Cloud Functions の世代移行が引き金となり、会計イベント基盤で Cloud Pub/Sub のメッセージが大量に滞留して、新規の会計データを Cloud Spanner に登録できないインシデントが発生しました。 直接の引き金は、Cloud Functions を 1st gen から 2nd gen(Cloud Run ベース)へ移行した際に --max-instances を明示しておらず、Cloud Functions のスケール上限が「無制限」(1st genのデフォルト)から「100」(2nd genのデフォルト)へ下がっていたことでした。しかし、障害がここまで大きくなったのは、この引き金だけが原因ではありません。本記事では、1000万件規模に達したこの滞留がどのように構成されていたのかを解き、Cloud Run と Spanner、そして監視の各面で私たちが講じた対策を紹介します。 1. 月末ピークを支える会計イベント基盤 まず、私たちが運用する会計システムの全体像を説明します。会計システムは、社内の各マイクロサービスが発行する「会計イベント」を集約し、Cloud Spanner に記録する基盤です。この会計データは送信元サービスとの突合(リコンサイル)を経て、加盟店への精算や月次の会計締めといった後続処理の前提となるため、取りこぼしや遅延がそのまま業務影響に直結します。 会計システムは、次のような非同期パイプラインでイベントを受け取ります。送信元から Spanner までは、おおむね一方向の流れです。 この構成には、今回の障害を理解するうえで重要な特性が2つあります。 1つ目は、すべてが非同期で動く点です。送信元マイクロサービスはイベントを発行したら応答を待たず、配信・リトライ・整合性の担保はすべて Pub/Sub 以降のパイプラインが引き受けます。特に Pub/Sub から Cloud Run へは push 型サブスクリプションで配信されるため、消費側である Cloud Run とその書き込み先である Spanner の処理能力が、そのままパイプライン全体のスループット上限になります。 2つ目は、負荷が月末に集中する点です。業務特性上、月末・月初の締めに合わせてトランザクションが一気に押し寄せ、平常時の数十倍のスパイクが発生します。 なお、Pub/Sub に届いたメッセージは、この push 経路とは別の pull 型サブスクリプションを通じて Dataflow でも読み取られ、並行して Cloud Storage(GCS)に保存されています。本処理の滞留に巻き込まれないこのGCS上のデータを使って後の復旧を行いました。 2. 1000万件の滞留はどのように発生したか インシデントは、ある月末最終営業日の早朝に発生しました。6時ごろから Pub/Sub のメッセージが滞留しはじめ、Spanner への新規登録が滞り、新規の会計データを受け付けられない状態が断続的に続きました。復旧と再発を繰り返し、収束までには丸一日以上を要しました。 データロスそのものは、先ほど触れた GCS上のデータから再投入することで回避できました。しかし会計システムでは、送信元サービスと DB の間でリコンサイルが完了したものだけが、加盟店精算や月次の会計締めといった後続処理の対象になります。Spanner に登録されなかったデータはリコンサイル未完了のまま残り、締め日と重なったことで、広範な業務影響につながりました。 この滞留が深刻なのは、一度始まるとインフラリソースの上限でスケールが頭打ちになり、処理が遅れるほど次の処理も遅れる悪循環に陥るからです。会計イベントが高い密度で連続して到着すると、maxInstances=100 で頭打ちになった Cloud Run はこれを処理しきれません。上限まで張り付いた Cloud Run が一斉に書き込むことでSpanner CPUが逼迫し、1件あたりの書き込み時間が延びます。すると、処理スループットがさらに落ち、滞留はさらに進んでしまいます。やがて滞留時間が eventMaxAge(メッセージを破棄するまでの最大保持期間)を超えると、メッセージは Spanner に登録されないまま破棄されてしまいます。 3. きっかけは 2nd gen 移行時のデフォルト値 滞留の原因を調べていくと、今回の障害の1ヶ月以上前に行った Cloud Functions の世代移行に行き着きました。私たちは関数を 1st gen から 2nd gen(Cloud Run ベース)へ移行しましたが、このときデプロイコマンドで --max-instances を明示しておらず、世代によってデフォルト値が変わることを、十分に意識できていませんでした。 この「明示しなかった」ことが、スケール上限を静かに引き下げていました。下の表のとおり、1st gen ではスケール上限が実質無制限だったのに対し、2nd gen のデフォルトは 100 です。 Version デフォルトの max instances 1st gen 無制限(プロジェクト Quota の範囲) 2nd gen 100 コードもデプロイスクリプトも変えていないのに、移行しただけで上限が実質無制限から 100 へ下がっていたことになります。平常時は 100 インスタンスで十分だったため、移行後しばらくは問題が表面化せず、私たちの体感としては、ある月末に突如として障害が発生したように見えました。しかし実際は、監視がなかったために気づけていなかっただけでした。インシデント後に振り返ると、上限に迫っていた月もあり、水面下ではすでに限界の兆候が出ていたのです。 4. 障害を大きくした複数の要因 --max-instances の指定漏れは引き金ではありましたが、それだけでこの障害を説明することはできません。調べてみると、2nd gen移行後に迎えた月末のピーク時でも条件はほとんど同じだったからです。maxInstances は 100、eventMaxAge も同じ 21分で、トラフィック総量も3時間で約17〜19GBとほぼ変わりませんでした。 それでも、そのときは障害は起きていませんでした。決定的に違ったのは、トラフィックの「連続性」です。合間に負荷が落ち着く「谷」があり、その短い谷の間に Spanner CPU と未 ack の蓄積がリセットされていました。ところが障害当日は、この谷がないままピークが約40分間続き、システムは回復の契機をついに得られませんでした。 整理すると、今回の障害は3つの要因が連鎖して成り立っていました。まず maxInstances=100 という処理上限が滞留を発生させ、次にトラフィックの連続性がその滞留を固定化し、最後に eventMaxAge によるメッセージのドロップ、という連鎖により実害となりました。いずれか1つでも条件が違えば被害の規模は抑えられたはずで、だからこそ私たちは対策を1箇所に絞らず、制御できる Cloud Run と Spanner の両方に講じることにしました。 5. Cloud Run の処理能力を引き上げる 最初に着手したのは、直接の引き金となった Cloud Run のスケール上限です。あわせて、1インスタンスあたりの処理効率も見直しました。本番に反映した設定値は次のとおりです。 パラメータ 変更前 変更後 max-instances 100(暗黙のデフォルト) 1000 min-instances 0(暗黙のデフォルト) 1 concurrency 1(暗黙のデフォルト) 10 cpu デフォルト 1 memory 512MB 1Gi ここでの本質は、値を大きくしたこと以上に、必要なパラメータをすべて明示したことにあります。今回の引き金は、暗黙のデフォルト値に依存していたことでした。そこで max-instances をはじめとするスケール関連のパラメータをデプロイ定義に明示し、世代やデフォルトの変化に左右されない状態にしました。あわせて、 min-instances を 1 にしてコールドスタートを避け、concurrency を 10 に引き上げ、それに見合うよう CPU とメモリも増強しています。なお max-instances は、書き込み先である Spanner への負荷も考えて、一度に上げきらず段階的に引き上げました。 concurrency を 1 から引き上げることができたのは、ハンドラ内の共有状態を見直し、複数リクエストを同時に処理しても安全だと確認できたためです。Spanner クライアントは内部にコネクションプールを持ち並行アクセスに耐えられます。また、その初期化は sync.Once によって一度だけ行われます。こうした前提を確かめたうえで同時処理数を増やし、必要なインスタンス数そのものを抑えました。 6. Spanner autoscaler の監視軸を Total CPU へ広げる Cloud Run の処理能力を上げると、今度は書き込み先である Spanner がボトルネックになります。Spanner には以前から autoscaler を導入していましたが、障害のさなか、CPU が高負荷であるにもかかわらず PU(Processing Unit。Spanner の計算容量の単位)はまったくスケールアップしていませんでした。autoscaler があるのにスケールしない、という一見不可解な状況でした。 原因は、autoscaler が見ていた指標にありました。Spanner の CPU 使用率には High / Medium / Low の優先度があり、その合算が Total CPU 使用率です。当時の autoscaler は High priority CPU 使用率だけを見ており、障害時は Total CPU が 100% に張り付く一方で、High priority 単体では閾値(60%)の超過が続かず、起動条件を満たしませんでした。この死角は月末ピークに限りません。たとえばリアルタイム性を求めない大量データの削除ジョブは、意図的に Low priority で実行するため、Total は高いのに High は低いという同じ状態を容易に作り出します。優先度別の CPU だけを見る監視は、こうしたケースを構造的に取りこぼすのです。 しかし、これは autoscaler の設定変更では解決できませんでした。当時、OSSである mercari/spanner-autoscaler には、Total CPU でスケールする機能そのものが存在しなかったからです。そこで、OSS をメンテナンスしている SREチームに相談し、High priority と Total を OR 条件で評価する dual CPU scaling mode を実装してもらい、v0.8.0 として取り込みました。設定した閾値は次のとおりです。 パラメータ 変更前 変更後 Total CPU 閾値 (監視なし) 70% High priority CPU 閾値 60% 55% Total CPU 閾値は新設で、これが今回の障害への直接の対策です。値は Google Cloud の throughput 最適化推奨(〜70%)に合わせました。High priority CPU 閾値は、より早くスケールアップを起動するために 60% から 55% へ下げています。下げすぎると平常時のコストが増えるため、より低い 50% 等は避け、Google Cloud の推奨(regional で 65% 以下)に収まる 55% を選びました。これにより autoscaler は High priority(55%) と Total(70%) のいずれかが閾値を超えればスケールアップするようになり、「Total は逼迫しているのに High が閾値未満だからスケールしない」という障害時の挙動は原理的に起こらなくなりました。 また、スケール条件に加えて、運用面でも2つの調整を行いました。1つは、早朝5時から7時のスケールダウンを禁止することです。月末ピークの立ち上がりで、せっかく確保した PU が削られてしまうのを防ぎます。もう1つは、スケジュールによる事前の PU 確保です。月末早朝や大規模なリコンサイルが走る月など、負荷が読める期間にはあらかじめ PU を確保しておき、リアクティブなスケールアップだけに頼らない構えにしました。 7. 上限への接近を検知する監視を整える 今回の障害には、予兆を早期に捉える機会も、発生を即座に検知する機会も逃してしまったという課題があります。スケール上限に達してもそれを知らせるアラートがなく、水面下で限界に近づいていた兆候も見逃していました。そこでDatadog に2つの監視を追加しました。 1つは、Cloud Run のインスタンス数が上限の 40% / 60% に達した時点で警告・重大として通知する監視です。これにより、メッセージのドロップが始まる前に、上限への接近そのものを検知できます。もう1つは、Pub/Sub の未配信メッセージ数が閾値を超えたら通知する監視で、滞留という現象を直接とらえます。後者は誤検知を避けるため、当初は保守的な閾値から始め、定常状態の理解が進むにつれて段階的に下げていく方針にしています。 設定値を最適化するだけでは不十分です。これ自体は当たり前かもしれませんが、どれだけ良い値を入れても、その値への接近を検知できなければ、次の同じ障害は防げません。キャパシティの設計と、その接近を知る監視をセットで整える。この当たり前を当たり前に徹底することこそが大切だと、今回あらためて実感しました。特に、システムをゼロから作った当人ではなく、引き継いで運用していることのほうが多い現場では、こうした設定や前提を定期的に振り返り、整え直す文化を持つことが欠かせません。 8. まとめ 今回の障害は、1つのデプロイフラグを明示しなかったことから始まりました。しかし振り返れば、本当の教訓はもっと一般的なところにあります。 第一に、マネージドサービスの世代移行では、自分が変更していないデフォルト値こそが、負荷時に問題として表面化します。スケール上限・並行数・タイムアウトのような、平常時には効かないパラメータは、移行のたびに明示しておくべきでした。第二に、障害の原因は単一とは限りません。「直近の月は同じ条件で起きなかった」という事実がトラフィックの連続性という決定的な要因を浮かび上がらせたように、トリガー・増幅・被害化のそれぞれに目を向けることが再発防止には欠かせません。そして第三に、設定の最適化と、その状態に気づくための監視は、つねに一体で考える必要があります。 マネージドサービスの便利なデフォルト値は、平常時には何も語りません。だからこそ、ピーク時に初めて顔を出すその振る舞いを、移行のたびに確かめておく価値があります。この記事が、みなさんが運用するサービスの設定を今一度見直す、ひとつのきっかけになれば幸いです。 次の記事は um(うめ)さんです。引き続きお楽しみください。
こんにちは、メルペイiOSエンジニアのkubomiです。 この記事は Merpay & Mercoin Tech Openness Month 2026 の 10日目の記事です。 生成AIによって、エンジニアが短時間でプロトタイプをつくれる場面はかなり増えました。最近、小規模なプロジェクトで「初回ミーティングの前に、動くものをつくり切ってしまう」という進め方を試したところ、意思決定のスピードが劇的に変わりました。私はこのやり方を "Build First, Discuss Later(まずつくる、議論は後)”と呼んでいます。この記事では、その具体的な進め方と、実践を通じて私自身に起きたマインドセットの変化を紹介します。 よくある開発フローと、その課題 私たちの現場では、開発に取りかかる前に、まず関係者の認識をそろえておくのが一般的です。具体的には、最初にプロダクトマネージャー(PdM)が大まかな仕様を用意し、それをもとにキックオフミーティングを開いて詳細を議論します。議論を経てPdMが仕様を固め、エンジニアはその仕様をもとに見積もりを出して実装に入ります。 ただ、議論して仕様を固めたつもりでも、いざつくり始めると「あれ、ここの挙動どうするんだっけ?」という疑問が次々と出てくることがあります。そのたびにPdMへ確認したり、追加のミーティングを開いたりすると、少しずつコミュニケーションの往復が増えていきます。 "Build First, Discuss Later" という提案 そこで私が実践したのが、プロセスの順番をあえて逆転させる "Build First, Discuss Later" です。仕様が固まる前に、まず動くプロトタイプをつくってしまうという発想で、ミーティングはその動くものを土台に議論を進めます。 従来は「議論して仕様を固めてからつくる」流れでしたが、これを「先につくり、その動くものを見ながら議論する」へと入れ替えます。実際に触れる画面があると、抽象的な仕様書をめぐる議論よりもはるかに早く、関係者の認識がそろっていきます。 ただし、何にでもこの進め方を使うわけではありません。何日もかかるような大きな実装でこれをやると、方針が変わったときの手戻りが大きすぎます。私の場合は、数時間から1日以内でつくれるくらいの小規模な施策に限定しています。そのくらいの規模なら、悩んで待つより、とりあえずつくってしまったほうが圧倒的に速い、という実感があります。 ミーティングにプロトタイプを持ち込む3つのステップ 私は "Build First, Discuss Later" を、ミーティングの前・中・後という3つの場面に分けて実践しています。ここでは、アプリの画面にバナーを追加した事例を例に、それぞれの場面で意識していることを順に紹介します。 ミーティング前:自分のベスト案でつくり切る ミーティング前は、手に入る計画書や仕様書をAIと一緒に読み込み、「なぜつくるのか(Why)」「何をつくるのか(What)」を自分なりに解釈します。この段階で最も大事なのは、完璧な実装をつくることではなく、どこが曖昧なのかを目に見える形にすることだと考えています。実際、詳細が決まっていないことがほとんどですが、曖昧な点にぶつかっても立ち止まりません。PdMに質問する代わりに、いったん自分が考えるベストな案でつくり切り、迷ったポイントはミーティングのアジェンダに整理しておきます。 たとえば、バナー追加の事例では、リリースに必要な最小限の機能に絞って早くリリースするか、将来使い回せる再利用性を優先するか迷いながらも、まず最小限の機能で動くプロトタイプをつくりました。UIデザインがまだない場合も、既存の画面部品を組み合わせた仮の見た目で形にしました。 ミーティング中:動くものを見ながら論点を解消する ミーティング中は、その動くプロトタイプを見せながら議論し、可能な限りその場で論点を解消します。意見が分かれそうな箇所には、あらかじめA案とB案を用意し、「私はこういう理由でA案を推します」と推奨案まで添えておきます。バナーの例では、期日を踏まえて、リリースに必要な機能に絞った設計プランと、将来の再利用まで見据えた設計プランを提示し、PdMはその場で前者のプランに合意できました。細かな仕様も、プロトタイプを見ながらサクサクと決まっていきました。判断の材料がそろっているため、議論は驚くほど早く前に進みます。 ミーティング後:決まった内容をすぐ反映する ミーティング後は、決まった内容を仕様書に反映し、実装を微修正したうえで品質保証(QA)のテストに回します。大きなつくり直しが起きにくく、初回ミーティングの直後にはリリースが見えている、という状態になりました。バナーの例では、私がつくった仮の見た目をデザイナーが本番デザインへブラッシュアップし、実装側はそれを反映する微修正で済みました。 "Build First, Discuss Later" で起きた3つの変化 この進め方を試してみると、ミーティングの進み方やPdMとのやり取りがかなり変わりました。特に大きかった変化は、次の3つです。 1つ目は、ミーティングがほぼ1回で完結するようになったことです。うまくいけば、初回ミーティングが終わった時点で仕様も実装もほぼ固まっており、開発見積もりすら不要になることもあります。その後の往復も大きく減りました。 2つ目は、議論が速く、かつ正確になったことです。実際に動くものを見せながら「この画面の挙動はこれでよいですか」と確認できるため、言葉だけのやり取りで生じがちな認識のズレが起きにくくなりました。 3つ目は、PdMの負担が軽くなったことです。エンジニアが具体的な仕様の案まで持っていくので、PdMは方針を確認するだけで済みます。特にPdMが複数プロジェクトを兼務しているような状況では、その確認コストを減らせるだけでも大きな価値があります。 「待つエンジニア」から「提案するエンジニア」へ こうした変化は、単に開発プロセスやコミュニケーションを効率化しただけでなく、私自身のエンジニアとしてのマインドセットにも影響を与えました。 以前の私は、決まった要件を正しく実装することがエンジニアの主な役割だと思っていました。けれど、PdMが持つWhyと大まかなWhatを起点に、まず動くものをつくってみると、「これは要らないかもしれない」「こっちの方がお客さまに価値を届けられるのでは」といった議論を、自分から持ち込めるようになりました。 いちばん大きかったのは、「私はこれがいいと思う」というアイデアを持ってミーティングに参加できるようになったことです。ただ仕様を待つのではなく、要件定義の段階から意見を出し、仕様を決めていく側に少しずつ入っていけるようになりました。 そうやって関わっていると、「この機能は今いちばん自分が詳しい」というオーナーシップも自然と生まれてきます。自分の提案が仕様に反映され、動くものを通じてプロダクトの方向性が決まっていく。その過程に関われるようになって、プロダクトづくりが前より一層楽しくなりました。 生成AIによって「まずつくってみる」ハードルが下がったことで、エンジニアが上流の議論に入りやすくなったと感じています。プロトタイプをつくってミーティングに持ち込むことは、単に開発を速くするだけではなく、エンジニアがより主体的にプロダクトづくりに関わるためのきっかけにもなるのだと思います。 まとめ "Build First, Discuss Later" は、先に動くプロトタイプをつくり、それを見ながら議論することで、意思決定を速くする進め方です。みなさまも、「仕様待ちで開発が始められない」「仕様が曖昧で手戻りが多い」と感じたら、自分なりのプロトタイプを会議に持ち込んでみてください。会話が前に進むだけでなく、プロダクトづくりの楽しさも少し違って見えてくると思います。 次の記事は mewutoさんです。引き続きお楽しみください。
こんにちは。メルペイでソフトウェアエンジニアをしている @sapuri です。この記事は Merpay & Mercoin Tech Openness Month 2026 の 9日目の記事です。 はじめに 本記事は、2026年4月27日の Background Job Talk 〜 Temporal 活用と独自実装の舞台裏編〜 で発表した「内製ワークフローエンジンの設計とメルカリでの活用事例」を記事化したものです。 マイクロサービスアーキテクチャのような分散システムでは、複数のサービスにまたがる処理のデータ整合性をどう保つか、いわゆる分散トランザクションの扱いが大きな課題となります。 メルカリでは、この課題を Saga パターンによる結果整合性で解決するために、自社でワークフローエンジンを開発して運用しています。 このワークフローエンジンは、もともとメルコインの決済基盤における分散トランザクション管理のために開発したものです。メルペイの Payment Service で得た知見も取り入れながら設計し、現在はメルカリグループ内の複数のユースケースで利用が広がっています。 この記事では、内製に至った背景とワークフローエンジンの具体的な設計、社内での活用事例について紹介します。 分散トランザクション管理の課題と Saga パターン メルカリでは主にマイクロサービスアーキテクチャを採用しています。 そのため、お客さまがアプリで1つの操作をすると、そのリクエストは基本的に複数のサービスをまたいで処理されます。 例えば、メルカリアプリからビットコインを購入するときの決済リクエストでは、取引データの作成、メルコインの日本円残高の減算、メルペイのポイントの減算、ビットコイン残高の加算、取引データの更新といった複数の処理が関わります。 この決済リクエストは、1つのトランザクションとして扱う必要があります。つまり、一連の処理をすべて成功させるか、すべて失敗させるかのどちらかに寄せる必要があります。 しかし、各サービスがそれぞれデータベースを持っているため、単純にロールバックすることはできません。この点を考慮せずに実装すると、エラーのタイミングによってデータの不整合が発生します。 例えば、このような不整合が起こりえます。 決済が失敗したのにメルコインの日本円残高が減っている 残高は減ったがビットコイン残高が加算されない ビットコインと交換できているのに取引が完了扱いになっていない また、Two-Phase Commit のような分散トランザクションでは長期間リソースをロックするため、サービスの可用性が下がる可能性があります。 そのため、メルカリでは結果整合性のアプローチで、このような分散トランザクションを解決しています。 Saga この結果整合性を実現するためのアーキテクチャの1つとして、Saga というパターンがあります。 Saga は、トランザクションを複数の小さなトランザクションに分割して順次実行することで長時間のロックを不要にします。途中でリトライ不可能なエラーが出た場合は、成功済みの処理に対する補償トランザクションを逆順で実行します。 先ほどの暗号資産購入の例で、途中のビットコイン残高を増やす処理でリトライできないエラーが発生した場合を考えます。 この場合、この時点までに成功した処理を取り消す補償トランザクションを逆順に実行します。すでにメルコインの残高とメルペイのポイントが減らされているので、まずポイントを戻し、その次にメルコイン残高を戻し、最後に取引データを失敗として更新します。 このように実装することで、途中のどこで失敗しても結果整合性を保って処理を完了させることができます。 このあたりの話は以前の記事でも紹介しているので、興味のある方はそちらもぜひご覧ください。 メルコイン決済基盤における分散トランザクション管理 | メルカリエンジニアリング ワークフローエンジンの検討 実装の方針が決まったので、実際に Saga パターンを実装するためにワークフローエンジンの導入を検討しました。 主に検討したツールは、GCP Workflows、Cadence、Temporal です。メルカリでは主に GCP を使ってサービスを構築しているため、まず GCP Workflows を検討しました。ただ、各処理を HTTP のエンドポイントとして実装する必要があり、ユニットテストがやりにくいという懸念がありました。また、YAML ではなく Go のコードでワークフローを記述したいという要望もありました。 Cadence と Temporal も検討しましたが、メルカリでは Cloud Spanner をメインに使っているため、Spanner に対応していなかったことから採用できませんでした。また、Temporal はシステムの規模が大きく、仕組みも比較的複雑なため、運用面にも不安がありました。 このように、既存ツールでは要件を満たせなかったため、自社でワークフローエンジンを開発することにしました。 開発時には、Cadence / Temporal のインターフェースの良さを取り入れつつ、メルペイの Payment Service ですでに実績があった「DB への実行状態の永続化 x インメモリキュー x Worker での実行管理」のアーキテクチャを再利用する方針にしました。また、Go 専用で必要な機能のみに絞ることで、数人の兼務メンテナーでも運用できる規模にしています。 ワークフローエンジンの設計 アーキテクチャ このワークフローエンジンは、アプリケーションサーバーと同じ Pod でデプロイされることを想定しています。 Go runtime で動作し、利用者は SDK として扱います。 アプリケーションは Manager というインターフェースを使ってワークフローエンジンを操作します。主に Register と Execute という2種類のインターフェースを使います。 アプリケーションはワークフローを普通の Go の関数として実装するので、その関数の内容を事前にワークフローエンジンに登録する必要があります。 manager.RegisterWorkflow() が呼び出されると、Manager は Registry というインメモリの領域に関数を格納します。 manager.Workflow().Execute() は、実際にワークフローを実行するインターフェースです。 呼び出されると、Manager は Engine Server という gRPC サーバーに対して Workflow や Activity を作成するリクエストを送ります。 Engine Server は関数名や引数、実行状態を DB に保存し、インメモリキューである Channel に WorkflowStarted イベントを publish します。 その後、Worker という goroutine が WorkflowStarted イベントを subscribe し、Registry から実行する関数を取得して、Go のリフレクションを使って実行します。 実行が完了すると Worker は Engine Server に完了を報告し、Engine Server は結果を保存して WorkflowCompleted イベントを publish します。 その後、Worker が WorkflowCompleted イベントを subscribe し、アプリケーションに関数の実行結果を返却します。 もしその結果がエラーだった場合は、後述する ErrorMarshaler というインターフェースで、その Workflow を完了させるかどうかを判定します。 ここまで説明したコンポーネントの役割を整理します。 Manager: SDK のエントリーポイント。アプリケーションは Workflow()、Activity()、RegisterWorkflows() などを呼び出します。 Engine Server: Create、Complete、List などの gRPC API を提供するサーバー。DB に Workflow や Activity の I/O と状態を保存します。 Channel: Workflow や Activity の状態遷移イベントのハブとなるインメモリキュー。 Workers: Workflow や Activity を実行する goroutine 群。Channel から状態遷移イベントを購読し、イベントの種別に応じた処理を実行します。 Registry: Register された関数をインメモリで保持します。 Recovery Worker: Engine Server に対して定期的に未完了の Workflow と Activity を List してリトライします。 コードサンプル アプリケーション側の実装イメージは次のようになります。 func (s *Service) createExchangeWorkflow(ctx context.Context, params *CreateExchangeParams) (*CreateExchangeResult, error) { saga := workflow.NewSaga(s.wm) if err := s.wm.Activity(s.authorizeBalance, params.Balance).ExecuteWait(ctx); err != nil { return nil, err } saga.AddCompensation(s.cancelBalance, params.Balance) if err := s.wm.Activity(s.authorizePoint, params.Point).ExecuteWait(ctx); err != nil { if !isCompletableError(err) { return nil, err } if cerr := saga.Execute(ctx, func(e execution.Execution) error { return e.Wait(ctx) }); cerr != nil { return nil, fmt.Errorf("failed to execute compensation activities: %w, orig_err: %v", cerr, err) } return nil, err } return &CreateExchangeResult{}, nil } まず、createExchangeWorkflow という関数が定義されています。 この関数は、残高を確保する authorizeBalance という Activity と、ポイントを確保する authorizePoint という Activity を順に実行して結果を返す処理です。 特徴的なのは、それぞれの Activity を実行した直後に、この SDK が提供する Saga の AddCompensation インターフェースで補償トランザクションを登録している点です。 これにより、authorizeBalance Activity が成功した後に authorizePoint Activity が失敗した場合は、authorizeBalance を取り消す処理である cancelBalance という関数が補償トランザクションとして実行されます。 エラーハンドリング このワークフローエンジンでは、3種類のエラーを定義しています。 Completable Error: Workflow や Activity を失敗として完了させてよい、想定されたエラーです。例として、残高不足や利用制限があります。 Retryable Error: リトライ対象のエラーです。 Incompletable Error: Workflow を完了させずに停止し、Recovery Worker が後でリトライするエラーです。 Completable Error は、明示的に完了できるエラーだけを完了扱いにするための仕組みです。 クライアント側で ErrorMarshaler というインターフェースを実装したエラーとして定義される 該当しないエラーはすべて未完了として実行を停止し、Recovery Worker によってリトライされる 明示的に Completable Error を返さない限り Workflow は完了しない アプリケーションが意図していない異常な状態で Workflow が完了しない設計になる type ErrorMarshaler interface { MarshalCompletableError(error) ([]byte, error) UnmarshalCompletableError(marshaledErr []byte) error } 具体例として、ドメインのカスタムエラー型に Completable() というメソッドを定義し、それを使って ErrorMarshaler を実装します。 このようなカスタムエラー型を作っておくことで、ビジネスロジックで特定のエラーコードを含むエラーを返すと、ワークフローエンジンで完了可能なエラーとして処理されます。 type Error struct { code ErrorCode msg string } func (e *Error) Error() string { return e.msg } func (e *Error) Completable() bool { return e.code == ErrCodeCompletable } type workflowError struct { Code ErrorCode Msg string } type workflowErrorMarshaler struct{} func (workflowErrorMarshaler) MarshalCompletableError(err error) ([]byte, error) { var aerr *Error if !errors.As(err, &aerr) { return nil, err } if !aerr.Completable() { return nil, err } return json.Marshal(&workflowError{ Code: aerr.code, Msg: aerr.msg, }) } func (workflowErrorMarshaler) UnmarshalCompletableError(data []byte) error { var werr workflowError if err := json.Unmarshal(data, &werr); err != nil { return err } return &Error{ code: werr.Code,
こんにちは。今年4月に入社したメルペイ Loyalty & Santa(Growth Platform)チームでBackend Engineerをしている@mikupoです。この記事は「Merpay&Mercoin Tech Openness Month 2026」の8日目の記事です! はじめに 私が所属しているSantaチームは、メルカリ・メルペイにおけるポイント還元やキャンペーンの基盤となるシステムを開発・運用しています。Santaの処理はすべて非同期で、Pub/SubのPull型 subscriptionを中心に構成されています。 Santaの開発で課題になっていたのが、QAプロセスです。検証に使える開発環境(Dev環境)はチームに1つしかなく、複数の開発(Pull Request 以降、 PR) が重なるとQAを並列に進められませんでした。実装は終わっているのに、検証環境が空くのを待つ、そんな状況が発生していました。 PRごとに独立した環境を用意できれば、この待ち時間はなくせます。ただ、Santaでそれを実現するのは簡単ではありませんでした。Pull型のsubscriptionでは、複数のconsumerが同じsubscriptionに接続している場合、どのconsumerがどのmessageを受け取るかをpublisher側から指定できません。そのため、「このイベントはこのPR環境へ」と狙って届けることができません。 社内にはすでに、こうした課題に使えそうな仕組みもいくつかありました。ただ、それらを Santa に取り入れるには、本番の非同期処理の作りに大きく手を入れる必要がありました。今回達成したいのは QA の改善であり、そのために本番の非同期処理のかたちを大きく作り変えるのは避けたいと考えました。 本記事では、この制約のなかで「Pull型を保ったまま、PRごとに環境を分ける」 をどう実現したのかを紹介します。 Santaについてくわしく知りたい方は、「メルペイのキャンペーンを支えるサンタの秘密」をご確認ください。 PR単位の検証環境が必要になった背景 従来、Santaチームの検証に使えるDev環境は1つしかありませんでした。複数のPRを同じDev環境へ同時に反映すると、不具合が起きたときに原因となった変更を切り分けづらくなります。変更同士がconflictする場合は、そもそも並行してQAを進められませんでした。 このボトルネックを減らすための選択肢として、SantaではPull Request Replication Controller(PRRC)に注目しました。PRRCとは、GitHubのPRを起点としてDev環境とは別に、PRごとの検証環境(PRRC環境)を作成するためのKubernetes custom controllerを使った仕組みです。 PRRCによってPRごとの検証環境を作る道筋は見えました。次に検討したのが、社内で使われているPub/Sub gRPC Pusherです。Pub/Sub messageをgRPC requestとして扱えれば、既存のDynamic Service Routingと組み合わせてPRRC環境へルーティングできるように見えました しかし、SantaのPub/Sub処理はPull型subscriptionを前提に作られています。今回解決したかったのはQAプロセスのボトルネックであり、本番の非同期処理の仕組みを大きく変えることではありませんでした。一方で、gRPC Pusherをそのまま使うには、既存のPub/Sub handlerをgRPC endpointとして受けられる形に変える必要があります。そのため、QA環境の改善として取り組むには、本番環境への影響や変更範囲が大きくなりすぎると判断しました。 そのため、私たちは既存のPull型Pub/Subを保ったままPR単位の検証環境を実現するために、Santa側で満たすべき要件を整理しました。 Pull型Pub/Subを保ったままPR単位の検証環境を作るための要件 既存のgRPC Pusherをそのまま使うのではなく、SantaのPull型 subscriptionを保ったままPR単位の検証環境を実現するには、まず満たすべき要件を整理する必要がありました。PRRC環境を実際の検証に使える状態にするには、messageを意図した環境で受け取れることと、PRRC向けの文脈を後続処理へ引き継げることが重要でした。 ここでは、この2つの要件を順に説明します。 要件1:Dev環境とPRRC環境でmessageを分けて受け取れること 1つ目の要件は、Dev環境で確認したいmessageと、特定のPRRC環境で確認したいmessageを分けて受け取れることです。PRごとの検証環境を作れたとしても、それぞれの環境で確認したいmessageが混ざってしまうと、どのPRの変更による挙動なのかを切り分けづらくなります。 messageが混ざる原因は、Pull型subscriptionの受け取り先をpublisher側から指定できないことにあります。図1のように、PRRC環境のconsumerをDev環境と同じsubscriptionにつなぐと、複数のconsumerが同じsubscriptionからmessageを受け取る構成になります。そのため、Dev環境で確認したいmessageをPRRC環境が受け取ったり、PRRC環境で確認したいmessageをDev環境が受け取ったりする可能性があります。 このため、PR単位の検証環境として使うには、messageを環境ごとに分けて受け取れる仕組みが必要になります。 要件2:PRRC環境向けのルーティング情報を後続処理へ引き継げること 2つ目の要件は、messageがどのPRRC環境向けなのかを、後続の処理にも引き継げることです。Santaの処理はPub/Sub messageを受け取って終わりではなく、処理の途中で他のmicroserviceをgRPCで呼び出したり、別のPub/Sub messageをpublishしたりします。 そのため、SantaのDev環境とPRRC環境でmessageを分けて受け取れるだけでは不十分です。結合テストでは、Santaから呼び出すmicroservice側にもPRRC環境が用意されている場合があります。その場合、Santaからの呼び出しも通常のDev環境ではなく、対応するmicroserviceのPRRC環境へルーティングできる必要があります。 図2のように、このルーティングを実現するには、Santaが受け取ったmessageに付与されたルーティング情報を、後続のgRPC呼び出しやPub/Sub publishにも引き継ぐ必要があります。つまり、message自体にルーティング情報を持たせ、Santa内の処理でもその情報を落とさない仕組みが必要になります。 実現方法の検討 ここまで整理した2つの要件を満たすには、Pub/Sub messageにルーティング情報を持たせたうえで、その情報をどの段階で使ってmessageを振り分けるかを決める必要がありました。大きく分けると、consumerがmessageを受け取ったあとに判断する方法、Topic自体を分ける方法、subscriptionのfilterで受け取るmessageを分ける方法があります。 私たちは、それぞれの方法について、Pull型subscriptionを維持できるか、対象外のmessageを余計にpullしないか、PRRCごとの運用負荷が大きくなりすぎないか、という観点で比較しました。 最初に検討したのは、consumer側でmessage attributeを見て、対象外のmessageを処理しない方法でした。この方法は実装範囲が小さく見えますが、対象外のmessageも一度pullしてしまいます。対象外のmessageをackすると本来処理すべき環境に届かず、nackするとredeliveryが繰り返される可能性があります。 TopicをDev用とPRRC用に分ける方法も検討しました。この方法では、あらかじめPRRC用のTopicとsubscriptionのペアを用意しておき、PRごとにどのペアを使うかを割り当てます。Topic単位で分離できるため構成は直感的ですが、PRごとの割り当てを手動で管理する必要があります。そのため、割り当て忘れや重複割り当てが起きやすく、PRRC環境が増えるほど運用負荷が高くなります。 これらに対して、subscription filterを使う方法では、consumerがmessageをpullする前に、subscription側で受け取るmessageを分けられます。さらに、振り分けに使うmessage attributeをそのままルーティング情報として扱えるため、後続のPub/Sub publishやgRPC呼び出しにも同じ情報を引き継げます。つまり、要件1の「messageを環境ごとに分けて受け取ること」と、要件2の「ルーティング情報を後続処理へ引き継ぐこと」を、同じmessage attributeを軸に実現できます。 最終的な方針 最終的な方針は、Pull型subscriptionを維持したまま、pubsub messageのattributeで配送先を分けることです。各messageのattributeにルーティング情報を付け、subscription側はその値をfilterの条件にします。こうすると、同じtopicに届いたmessageでも、条件に一致したsubscriptionだけがmessageを受け取ります。 図のように、たとえば PR番号1234 を検証する場合、その message の attribute には、PR番号に対応するルーティング情報(PR1234)を付与します。Dev環境のsubscriptionはこのmessageを受け取らず、PR1234用のsubscriptionだけが受け取ります。 さらにこのルーティング情報は、Santaがpublishするmessageにも引き継げます。受け取ったPub/Sub messageのattributeからルーティング情報を読み取り、contextに格納し、後続のpublishや外部microserviceの呼び出しへ渡します。これにより、PRRC の文脈が処理の途中で途切れません。 一方で、外部サービスの subscription にそのまま filterを足すことはできませんでした。Santa は、ポイント還元などのトリガーとなるイベントを、決済をはじめとする他のmicroservice から Pub/Sub で受け取っています。これらの外部 subscriptionは発行側の microservice(=別チーム)の管理範囲にあり、Santa が直接 filterを足すと、別チームが管理するリソースに手を入れることになってしまいます。そこで外部 subscription については proxy sidecar を挟み、Santa 側の proxy topicへ再 publish する構成にしました。Santa の Pod は外部 subscription を直接 pullせず、proxy topic に対して作った Dev / PRRC 用の filtered subscriptionを読みます。 この方針により、Pull型subscriptionを維持したまま、Pub/Sub messageを意図した環境だけに届けられ、しかも変更をSantaの管轄内だけで完結できます。一方で、PRRCごとにsubscriptionを作る必要があるため、その自動作成・削除の設計が新たに必要になりました。 まとめ 本記事では、Pull型Pub/Subを維持したまま、Pub/Sub message attributeとfiltered subscriptionを使ってSantaにPRRC環境を導入した取り組みを紹介しました。 従来は、QA期間が重複する場合には統合環境を用意するか、複雑なプロジェクト同士の場合は直列で進めてQA待ちが発生するしかありませんでした。しかし、実際にこの仕組みを用いることで、直近の大きな2つのプロジェクトではQAを並列化して進めることができました。また、共有設定を変更するテストを直列でしか実施できなかったのが、QAメンバーの人数に合わせて3つ4つとスケールできるようになり、QA期間の短縮にも貢献できました。 自分たちのチームの状況も踏まえながら、よりチームに合った解決策を模索し、それを実現できたのは、チームの皆さんの協力があってこそです。Loyalty & Incentiveチームの皆様、ありがとうございました! 次の記事は sapuriさんの「内製ワークフローエンジンの設計とメルカリでの活用事例」です。引き続きお楽しみください。
こんにちは。メルペイのフロントエンドエンジニアの @mattsuu です。この記事は「Merpay & Mercoin Tech Openness Month 2026」の7日目の記事です。 EGP Code は、ランディングページ(LP)を AI と作る社内向けのエディタです。作成背景については AI と作る HTML ベースの LP エディタ EGP Code を内製した理由 という記事で紹介しました。本記事では、その内部で動いている 4 つの仕組みを紹介します。 EGP Code が扱うのは、HTML と状態や動きを担う少数の <egp-*>(独自の Web Components)を混ぜた 1 枚のページです。 <section class="p-6 text-center"> <h1>春のキャンペーン</h1> <p>応募受付中</p> <egp-button>応募する</egp-button> </section> この HTML を AI エージェントとの対話やエディタで編集し、プレビューで確認して公開します。紹介する 4 つの仕組みは、(1) エージェントの再帰ループ、(2) Firestore を介したリアルタイム反映、(3) ブラウザだけで完結するテストランナー、(4) プレビューと HTML を結ぶ対応表です。 1 つの指示がユーザーから届いてプレビューに反映されるまでの流れと、それぞれの仕組みが効く場所は次のとおりです。 仕組み 1: 文脈とツールを束ねるエージェントの再帰ループ 「フォントサイズを 24px にして」「文字を太くして」のように特定の要素への簡単な指示なら、その要素を特定して CSS を更新したり文言を調整したりするだけなので、ほぼ 1 回の操作で終わります。一方で複数の要素にまとめて指示したり、API を使った画面やテストを作ったり、Lint・テストのエラーを直したりする場合は、推論とツール実行を何度か往復することになります。 エージェントは「推論 → ツール実行 → 結果を会話履歴に追加 → 再び推論」という再帰ループで動きます。ここでいうツールとは、AI が必要と判断したときに呼べる関数の定義と説明のことです。HTML を書き換える、ファイルを読む、Lint で検証する、テストを走らせる、といった操作をツールとして用意しておき、モデルがその中から必要なものを選んで呼び、戻り値を見て次の手を考えます。 1 回の入力に対して 4 つの情報をまとめて渡します。システムプロンプトで AI エージェントに役割やコード生成のルールといった前提を与え、それにユーザーの指示と選択要素と現在の HTML、これまでの会話履歴、そして使えるツールの一覧を教えます。このうち現在の HTML・仕様・テストといったページの状態は、種類ごとに XML タグで区切ってまとめます。 会話履歴の持ち方は、開発当初 OpenAI API 側に任せていました。しかし全社的に ZDR(Zero Data Retention) を適用することになり、プロバイダ側に会話を残せなくなったため、いまは履歴をすべて自前で記録し、毎回のリクエストに載せて送るステートレス方式にしています。履歴の肥大化を防ぐため、一定量を超えたら要約させてコンテキストを圧縮しています。 // 1 ラウンドの推論 const stream = await client.responses.stream({ ...args, input: sessionBuffer.getItems(), // 自前で管理している履歴全体を送る previous_response_id: undefined, store: false, // プロバイダ側に会話を保存させない }); ループの工夫をいくつか紹介します。 1 つ目は、ツールの失敗の扱いです。ここでいう失敗とは、apply_patch の差分が当たらない、Lint がエラーを返す、テストが落ちるなど、ツールが期待どおりに完了しなかった状態を指します。こうした失敗ではループを止めず、エラーの内容をそのまま結果としてエージェントに返すことで自己修正させています。 2 つ目は、find_skill ツールによる情報の出し分けです。社内 API の使い方などのドキュメントは、最初は ID と一行説明の一覧だけを見せておき、本文は必要になったタイミングで読み込みます。たとえば「商品一覧を表示したい」という指示が来ると、エージェントはまず一覧から関連しそうなものを探し、find_skill でそのドキュメント本文を取得します。エージェントは取得したドキュメントを読み、正しい引数で API を呼びます。 この推論とツール実行の往復を繰り返し、最後にエージェントが apply_patch で HTML を差分更新すると 1 つの指示が編集として完成します。 ループの途中で方向を変える Real-time steering ここまでは、1 つの指示を最後まで処理してから次を受け取る前提でした。ですが実際には、処理の途中で「やっぱり色は青にして」と方針を変えたり、「ついでにフッターも直して」と指示を足したくなることがあります。完了を待たずに割り込みで指示を足し、走っているループに後から反映する仕組みを用意しています。こうした仕組みは Real-time steering とも呼ばれます。 ユーザーが処理中に指示を足したときの流れは、次のようになります。 エージェントが処理中(画面がローディング中)にユーザーがメッセージを送ると、クライアントはそれを通常の指示ではなく、割り込みメッセージとして送信します。サーバは受け取った割り込みメッセージを、 Firestore の通常の会話履歴とは別のサブコレクションに、いま走っているリクエストの ID を添えて書き込みます。エージェントは、自分が処理しているリクエストの ID に一致する割り込みだけを読み取ります。 // 自分のリクエスト宛の割り込みメッセージだけを読み、読んだら消す const consumeSteeringMessages = async (conversationId, requestId) => { const snapshot = await steeringRef .where('requestId', '==', requestId) // このリクエスト宛だけを対象にする .orderBy('timestamp', 'asc') .get(); const messages = snapshot.docs.map((doc) => doc.data()); await deleteDocs(snapshot.docs); // 読んだら消す return messages; }; ID で絞るので別のリクエスト宛の割り込みを拾うことはなく、読んだら消すので同じ指示が二重に効いたり取りこぼしたりすることもありません。処理のループに差し込むため、割り込みが来たときにエージェントが何をしようとしていたかで、対応が 2 つに分かれます。 1 つ目は、ツールを呼ぼうとしていた場合です。そのツールを実行せず、戻り値の代わりに「ツールは実行していません。処理中に新しい指示が届きました」という内容を返します。 // ツール実行の直前に割り込みを確認する const steering = await consumeSteeringMessages(conversationId, requestId); if (steering.length > 0 && toolCalls.length > 0) { for (const toolCall of toolCalls) { // ツールは実行せず、戻り値の代わりに割り込みを差し込む buffer.pushMessage({ role: 'tool', tool_call_id: toolCall.id, content: '[CONVERSATION_STEERING] 処理中に新しい指示が届きました ...', }); } continue; // 計画を立て直すため、もう一度推論へ } 2 つ目は、ツールを呼ばずに、ユーザーへの返信メッセージを作り終えていた場合です。本来ならこれを見せてループが終わるところですが、割り込みが届いたので止めるべきツールがありません。この返信はまだ画面に出していないので破棄し、直前のユーザーの指示に割り込みメッセージを足して送り直すことで、続きの作業を依頼します。 // ツールがない場合は、画面に出していない返信を捨てて指示を足す if (steering.length > 0 && toolCalls.length === 0) { buffer.pop(); // まだ画面に出していない返信を捨てる const priorUserTurn = buffer.pop(); // 直前のユーザーの指示を取り出す buffer.pushMessage({ role: 'user', content: `${priorUserTurn.content}\\n[CONVERSATION_STEERING] 処理中に新しい指示が届きました ...`, }); continue; } いずれの場合も、すでに実行した副作用を巻き戻すわけではなく、これから実行するはずだったツールを止めたり、まだ画面に出していない返信を捨てたりして、計画を組み直しています。 仕組み 2: Firestore を指示の受け渡し場所にしたリアルタイム反映 仕組み 1 で見たように、エージェントは複数のツールを往復させて指示に応えるため、編集には時間がかかることがあります。処理が終わるまで画面が何も更新されないと、利用者は反映されたかどうか分からないまま待つことになります。 そこで Firestore SDK を利用して、変更をリアルタイムに受け取れるようにしています。サーバ側は 1 つの操作が終わるたびにその内容を Firestore へ書き込み、ブラウザ側はそれをサブスクライブして即座に検知・反映します。これで自前で WebSocket を張らずに、編集の途中経過をそのままプレビューへ反映できます。 エージェントが何かを書き換えると、サーバは会話ごとのコレクションに、次のようなドキュメントを 1 件追加します。 { "status": "PENDING", "requests": [ { "action": "setHtmlSchema", "payload": "<body> ...更新後の HTML... </body>", "reason": "ユーザーの依頼を反映" } ] } ブラウザ側の JS は、Firestore の SDK(onSnapshot)でこのコレクションをサブスクライブしており、PENDING のドキュメントが届くと requests の各操作をエディタの状態へ反映します。たとえば setHtmlSchema なら、エディタが表示している HTML を新しいものに置き換えて、プレビューを再描画します。 ブラウザが requests の操作を反映し終えると、その JS が status を COMPLETED に書き戻します。HTML 差し替えなどの「反映だけ」のアクションは、投げたら終わりで結果を待ちません。一方でテスト実行のように結果が必要なアクションでは、ドキュメントが COMPLETED になるまで一定間隔で読み直して、書き込まれた結果を取り出します。取り出した結果はツールの戻り値としてエージェントに返り、それを見て次のツール呼び出しを決めます。 仕組み 3: ブラウザだけで完結するテストランナー 応募ボタンを押したときの API 呼び出しやその結果に応じた表示の切り替え、リンクによる画面遷移といった LP の動作を手動で確認するのは手間がかかります。そこで EGP Code では、こうした動作をテストで確かめられるようにしています。 テストはブラウザ上のエディタで直接書いたり、AI に書かせたりできます。これらのテストは、サーバや CI ではなく、プレビューと同じブラウザの中で実行します。ただし Jest や Vitest は Node.js 上で動くツールなので、そのままブラウザには読み込めません。そこで test / it / describe / expect を提供する小さなランナーを自作しました。アサーションは単体で使える @vitest/expect、DOM 操作は Testing Library をそのまま利用しています。 // 自作の test 関数でテストを定義する test('エントリーボタンで API が呼ばれる', async () => { // Testing Library でユーザーのクリックを再現する await userEvent.click(screen.getByText('エントリー')); // @vitest/expect で結果を検証する expect(mockEntry).toHaveBeenCalledWith({ campaign: 'X' }); }); テストが社内 API を呼ぶこともありますが、本番に飛ばすわけにはいきません。そこで iframe 内で window.fetch を差し替え、リクエストはすべてモック関数に通します。モックしていない呼び出しはエラーになるので、本番へ漏れることはありません。 テストの実行は、この iframe を作るエディタのページ(ホスト)と iframe の postMessage のやり取りで進みます。 ホストは iframe を作ってテスト用 HTML を流し込み、iframe 側の初期化(window.fetch の差し替えなど)が済むのを待ってから実行を指示します。先に指示が届くと取りこぼすため、必ず「準備完了」を待つようにします。結果は仕組み 2 のアクションでエージェントへ戻ります。失敗していれば、仕組み 1 で触れた自己修正がここで働き、内容を読んで実装を直してもう一度走らせます。 仕組み 4: プレビューの要素と HTML の位置を結ぶ対応表 ここまではエージェント主導の編集を見てきましたが、人が直接手を入れる場面もあります。Code タブを開くと Monaco エディタで HTML を直接編集でき、プレビューでリアルタイムに確認できます。プレビューの要素をクリックしてエージェントへ指示を出したり、Monaco の対応行へジャンプするには、「プレビューの要素と HTML 上の位置」を対応づける仕組みが必要です。 <img src="https://storage.googleapis.com/prd-engineering-asset/2026/06/1aa073c8-preview-jump-ja.png" alt="EGP Code のエディタ。左
こんにちは。メルコインのフロントエンド(FE)エンジニアとしてインターンをしている@nanacomです。この記事は「Merpay & Mercoin Tech Openness Month 2026」の7日目の記事です。 はじめに インターンではFEに限らず、要件定義からバックエンド(BE)開発まで、1つのプロジェクトに幅広く取り組みました。その中で、メルコインの社内ツールを開発する際に、2つのAPIの結果を日時降順にマージして返すエンドポイントを実装するケースに直面しました。 結果を結合して並べ替えるだけならシンプルですが、マージした一覧にもページネーションを提供しようとすると、各ソースのカーソルをどこまで進めるべきかが複雑になります。本記事では、「マージ結果として採用された件数」と「各データソース側で進めるべきカーソル」のズレにどう対処したかを紹介します。具体的には、データ取得とカーソル確定を分離する「2フェーズ取得パターン」と、各ソースのカーソルを1つのトークンに束ねる「複合ページネーショントークン」の2つの設計を取り上げます。 前提:対象とするユースケース マイクロサービスアーキテクチャでは、BFF(Backend For Frontend)で複数のサービスからデータを集約して一覧表示することがよくあります。今回対象としたのは、2つの独立したデータソース(A, B)のデータをマージするケースです。いずれも日時降順にソートされたデータを返し、それぞれがカーソルベースのページネーションAPIを提供しています。カーソルベースのページネーションとは、前回の取得結果の末尾を示すトークン(カーソル)を次のリクエストに渡すことで、続きのデータを取得する方式です。 この2つのソースの結果を日時降順にマージした一覧をクライアントに返しつつ、その一覧自体にもページネーションを提供する必要がありました。つまり、各ソースが独立して管理するカーソルを、BFF側でどう扱うかが設計上の焦点でした。 売買と入出金をマージした一覧表示(※表示データはすべてダミーです) 素朴なアプローチとその限界 この設計上の焦点に対して、私たちはまず2つの素朴なアプローチを検討しました。いずれも限界があり、最終的な設計への動機となりました。 アプローチ1:全件取得してソート 最も単純な方法は、両ソースから全件を取得し、アプリケーション側でソートしてからページごとに切り出す方法です。しかし、データ数が増えるとメモリ使用量とレイテンシーが線形に増加するため、スケールしません。 アプローチ2:各ソースからpageSize件取得してマージ 各ソースからそれぞれ pageSize 件を取得し、マージして上位 pageSize 件を選択する方法です。データ取得量を抑えられるため現実的ですが、ここで1つの問題が発生します。 例として pageSize=5 のとき、Aから [A1..A5]、Bから [B1..B5] が返ってきたとします(いずれも日時降順)。これらをマージして上位5件を作ると、マージ結果に含まれるのがAから3件(A1,A2,A3)、Bから2件(B1,B2)になるとします。 次のページでは本来、AはA4から、BはB3から取得を再開する必要があります。しかし各ソースAPIが返すカーソルは「返却リスト末尾の次」を指すため、手元のカーソルはA6(= Aを5件進めた次)やB6(= Bを5件進めた次)を指してしまいます。マージ結果に必要な再開位置(A4/B3)と、手元のカーソル(A6/B6)が一致しません。 (図1)各ソースから取得 (pageSize=5) Source A: [A1][A2][A3][A4][A5] -> cursorA = A6 Source B: [B1][B2][B3][B4][B5] -> cursorB = B6 マージして上位5件を採用すると、実際に消費したのは Aが3件 / Bが2件 になります(採用: A1 A2 A3 / B1 B2)。 このとき次ページで「本当に再開したい位置」と「手元のカーソル」がズレます。 ソース 次ページで本当は 手元のカーソル A A4 から再開 A6 を指す B B3 から再開 B6 を指す これが、本記事で解決する核心的な課題です。次のセクションでは、この課題に対して理想的にはどう解決すべきかを考え、そのうえで私たちが採った設計方針を説明します。 理想の解決策と現実の制約 カーソルベースAPIでは、返却件数とカーソルの進行量が常に一致します。pageSize=5 でリクエストすれば5件返り、カーソルも5件分進みます。しかし今回のように複数ソースのデータをマージするケースでは、5件取得しても実際に採用するのは一部だけです。この「取得件数」と「消費件数」のズレが根本原因です。 仮に各ソースのAPIがカーソルではなくタイムスタンプによる範囲指定をサポートしており、かつソース内のタイムスタンプが一意であれば、この問題は発生しません。例えば、以下のように、マージ結果で最後に消費したアイテムの日時を基準に次ページを取得できます。 GET /orders?before=2025-01-01T10:00:00Z&limit=5 GET /transfers?before=2025-01-01T10:00:00Z&limit=5 この方式であれば、各ソースの消費済み最終タイムスタンプを1つのトークンに含めるだけで、BFF側に状態を持たずに1回のリクエストでページネーションを実現できます。また before で過去方向に切るため、新しいデータが追加されてもページ跨ぎの重複が起きません。 しかし、各マイクロサービスのAPI仕様を変更するのは現実的ではないため、既存仕様のままBFF層で解決する方法を検討しました。 設計方針の決定 BFF層での解決策として、トークンへの情報埋め込み、サーバー側キャッシュ、データ取得とカーソル確定の分離という3つの方法を検討しました。設計のシンプルさとステートレス性を重視した結果、3つ目の「2フェーズ取得」方式を採用しました。 方法1:トークンに情報を詰め込む(拡張複合トークン) 各ソースのカーソルを1つのトークンに束ねて返す際に、カーソルだけでなく、次ページを再開するために必要な情報をまるごとトークン内に埋め込む設計です。例えば「Aから何件/Bから何件消費したか」のようなメタ情報も含め、JSONにまとめてBase64エンコードして返します。 { "cursorA": "abc123", "cursorB": "def456", "consumedA": 3, "consumedB": 2 } この方式だと、クライアントが次のリクエストでトークンをそのまま返すことで、サーバーはトークンをデコードするだけで「次ページの再開位置(A4/B3など)」を復元できます。 しかし、既存のソースAPIがカーソルベースの仕組みを提供している中で独自にオフセット等も管理すると、「カーソルの意味」が二重になり設計が複雑化するため、採用しませんでした。 方法2:Redisなどで「使わなかったデータ」を保持する(サーバー側キャッシュ) 各ソースから pageSize 件ずつ取得してマージした結果、採用されなかった"余り"のデータ(例:A4, A5 / B3, B4, B5)をサーバー側で保持しておく設計です。例えばユーザー(またはリクエスト)単位のセッションキーでRedisに格納します。 session:user123 → { unusedA: [A4, A5], unusedB: [B3, B4, B5] } 次のページのリクエストが来たら、 まずRedisに残っているデータを先に使ってマージし 足りない分だけ各ソースAPIから追加取得する という流れにすれば、カーソルのズレ問題を回避できます。 しかし、サーバー側に状態を持つことになり、社内ツールの規模に対してインフラの運用コストが見合わないため、採用しませんでした。 方法3(採用):データ取得とカーソル確定を分離する(2フェーズ取得) 上記2つの方法では、1回のAPI呼び出しでデータ取得とカーソル確定を同時に済ませようとしています。発想を変え、データを取得してマージするフェーズと、消費件数に基づいてカーソルを確定するフェーズを分けることで、この問題を解決します。サーバーはステートレスのまま、既存APIの仕組みをそのまま活かせます。API呼び出し回数は増えますが、最もシンプルな設計です。 許容するトレードオフ ただし、この方式では2回のAPI呼び出しの間に多少の時間差が生じます。そのわずかな間に対象データが追加された場合、次ページに重複したデータが現れる可能性があります。 私たちはこの問題を、以下の理由から許容可能なトレードオフと判断しました。 影響は「ページを跨ぐ際の重複表示」に限定される 対象がリアルタイムに頻繁に更新されるデータではないため、発生頻度は低い 完全な整合性を保証するには、各ソースのAPI仕様変更が必要になり、コストに見合わない この判断のもと、以降のセクションで方法3の具体的な実装を説明します。 2フェーズ取得パターン 前のセクションで述べた方法3を、具体的にどう実装したかを説明します。データを取得してマージするフェーズと、消費件数に対応するカーソルを確定するフェーズに分けて設計しました。 フェーズ1:取得とマージ ソースA、ソースBからそれぞれ pageSize 件を並行して取得する 日時降順でマージし、合計 pageSize 件を取り出す ソースAとソースBそれぞれで、実際に消費した件数を記録する この処理は、Go の container/heap を使ったストリーミングマージとして実装できます。各ソースの先頭要素をヒープに入れ、日時が最も新しいものを1つずつ取り出しながら pageSize 件を集めます。以下のコードのとおり、各ソースのインデックス(indexA, indexB)がそのまま消費件数を表します。 func Merge(pageSize int32, itemsA, itemsB []*Item) ([]*Item, int32, int32) { indexA, indexB := 0, 0 result := []*Item{} h := &timeHeap{} heap.Init(h) if len(itemsA) > 0 { heap.Push(h, &record{source: SourceA, time: itemsA[0].Timestamp}) } if len(itemsB) > 0 { heap.Push(h, &record{source: SourceB, time: itemsB[0].Timestamp}) } for h.Len() > 0 && len(result) < int(pageSize) { r := heap.Pop(h).(*record) switch r.source { case SourceA: result = append(result, itemsA[indexA]) indexA++ if indexA < len(itemsA) { heap.Push(h, &record{source: SourceA, time: itemsA[indexA].Timestamp}) } case SourceB: result = append(result, itemsB[indexB]) indexB++ if indexB < len(itemsB) { heap.Push(h, &record{source: SourceB, time: itemsB[indexB].Timestamp}) } } } return result, int32(indexA), int32(indexB) } 戻り値の indexA と indexB が、フェーズ2でカーソルを正確に進めるための入力になります。 フェーズ2:カーソルの確定 ソースA、ソースBそれぞれにおいて、フェーズ1と同じ開始位置から消費件数分だけ再取得し、進んだ位置のページネーショントークンを取得する(cursorA, cursorB) pageToken を cursorA:cursorB(参照:次のセクション)とすることで、次ページの取得時に正しい位置からデータを取得できる なお、一方のソースのデータがもう一方より古い場合など、フェーズ1でデータが返ってきたにもかかわらずマージで1件も採用されないケースがあります。この場合は、そのソースのカーソルを前回の位置のまま保持し、次ページのリクエストで再び同じデータを取得してマージの対象にします。また、フェーズ1でデータが0件だった場合は、そのソースを枯渇と判定し、ターミナルトークン _ を設定します。 (図2)フェーズ1:取得とマージ(消費件数を記録) Source A ──(pageSize件)──┐ ├→ Merge → Top N Source B ──(pageSize件)──┘ │ 消費件数を記録 (A=3件, B=2件) (図3)フェーズ2:カーソルの確定(消費件数分だけ進める) Source A ──(消費3件)──→ cursorA Source B ──(消費2件)──→ cursorB → 複合トークン: "cursorA:cursorB" 複合ページネーショントークン設計 2フェーズ取得パターンにより、各ソースで消費件数分だけ進んだカーソルを取得できるようになりました。次に、これらのカーソルをクライアントにどのように渡すかを設計します。今回の一覧取得APIでは、pageToken を各ソースのカーソルを結合した複合トークンとして設計します。 "cursorA:cursorB" 片方のソースが完全に尽きた場合は、ターミナルトークン _ で表現します。トークンがターミナルトークン _ だった場合、API呼び出しをスキップできます。これにより、初回リクエストから片方のソースが枯渇した状態まで、以下のようにページネーショントークンで表現することができます。 トークン 意味 "" (空文字) 初回リクエスト "cursorA:cursorB" 両ソースとも継続あり "_:cursorB" ソースAは枯渇、Bのみ継続 "cursorA:_" ソースBは枯渇、Aのみ継続 "_:_" → "" に変換 全データ取得済み(次ページなし) この複合トークンと2フェーズ取得パターンを組み合わせることで、サーバー側に状態を持たずに、マージした一覧のページネーションを実現できます。 まとめ 本記事では、カーソルベースAPIを持つ複数データソースから一覧を構築する際に直面した「マージで実際に消費した件数」と「APIが返すカーソル位置」のズレという課題と、その解決策を紹介しました。 最初は1回のAPI呼び出しで全てを済ませようとしていましたが行き詰まり、「データを取得するフェーズ」と「カーソルを確定するフェーズ」に分離することで解決できました。1つの処理が複数の責務を担って複雑になったとき、フェーズを分けて各ステップの役割を単純化するアプローチは、ページネーションに限らず設計全般で有効な考え方だと感じています。 このような設計上のトレードオフを実際に手を動かしながら考えられたのは、インターン期間中の貴重な経験でした。FEに限らず幅広く関わらせていただいたことに感謝しています。本当にありがとうございました! 次の記事は@mikupoさんです。引き続きお楽しみください。
こんにちは。メルペイ Payment & Customer Platform (PCP) チームでBackend Engineerをしている@imamuです。この記事は「Merpay&Mercoin Tech Openness Month 2026」の6日目の記事になります。 はじめに 「事業者請求払い」とは、事業者(加盟店やビジネスパートナー)向けの後払い決済の仕組みです。取引が発生した時点ではプラットフォーム側が代金を立替え、後からまとめて請求・回収します。事業者にとっては都度の支払いが不要になり、取引のたびにキャッシュフローを気にせず利用できるという利点があります。 立替えて後から請求する以上、プラットフォーム側には一つの重要な責務が生まれます。事業者ごとに「いくらまで立替えてよいか」= 与信上限を安全に管理することです。これが破綻すると、回収できない債権が積み上がり事業全体のリスクになります。 本記事では、事業者請求払いを実現するために与信ドメインをCredit Serviceとして切り出した際の4つの設計ポイントを紹介します。 背景:従来の請求払いの課題 事業者請求払いは、取引時に代金を立替え、後からまとめて請求・回収する仕組みです。概念的には、次のようなライフサイクルになります。 従来、この請求払いを実現する手段は主に3つありました。 1. 外部のあと払いサービス いくつかのプロダクトは外部のあと払いサービスを使って与信管理・請求を実現していましたが、プロダクト特性に応じたリスク管理やパートナーに応じた柔軟な請求、入金イベントをトリガーにした精算や会計連携のシステム化が難しい側面があり、システム外での運用が避けられませんでした。 2. 外部の請求代行サービス 請求書の発行・送付といった請求業務のみを外部サービスに委ねる方法も取られていました。しかし、与信枠を自社で管理していないため上限を超過しても取引自体は通ってしまうリスクがありました。 3. クレジットカードによる代理決済 一般的な法人カードで利用上限の範囲内で建替え、請求する運用も使われていましたが、取引前に「いまいくらまで使えるか」を自社システムで把握できず、取引が成立した後の決済段階で初めて残高不足エラーが顕在化し、お客さまの体験にも悪影響が出ていました。 目的:なぜ内製の与信管理が必要だったか 前節の3つの手段はいずれも、請求払いそのものは実現できていました。しかし、プロダクトごとに運用や要件が異なる状態では、事業者ごとの与信をプロダクト横断で一元管理できないという共通の課題が残ります。事業者請求払いは複数のプロダクトでの利用が見込まれるため、プラットフォームとしては次の不変条件を常に満たす必要があります。 プロダクト横断で保有する債権の合計 < 事業者の与信上限 例えば事業者の与信上限が 100 のとき、プロダクトAの利用 60 とプロダクトBの利用 60 が同時に成立して合計 120 になる、といった状態を許してはなりません。プロダクトごとに与信判断が分散していると、この不変条件は容易に破られてしまいます。 そこで私たちは与信管理をプラットフォーム側で提供し、次の3点を満たすことを目的にしました。 プロダクト横断で与信・債権を一元管理すること 不変条件「債権合計 < 与信上限」を常に満たせる 取引前に安全に通すこと いま使える額を判断し、必要に応じて与信枠を確保したうえで取引を開始できる 請求・入金・精算プロセスまでシームレスにつなぐこと 取引の状態変化に追従し、後続の請求・入金・精算へ自然につなげる 設計:4つの設計ポイント 設計は次のようなサービス連携のユースケースを想定して組み立てました。 まずPayment Serviceが各プロダクトから決済リクエストを受け付けます。これはPayment Serviceが決済手段の提供と決済リソースの状態管理を責務としているためです。そして、Credit Serviceに与信枠の利用を依頼し、Credit Serviceが債権管理を担うDebt Serviceに債権を登録します。この債権を集約してInvoice Serviceが請求書を発行します。この連携を以下の4つの観点で設計しました。 1. 与信管理の責務を1サービスに凝集する まず決めたのは「与信ドメインをどのサービスが持つか」です。 すでに存在するPayment Serviceは決済手段の提供と決済リソースの状態管理を責務としています。ここに与信枠の管理や利用可能額の計算といった与信ドメインの知識を持ち込むと、Payment Serviceが本来のスコープを超えて肥大化してしまいます。そこで、与信に関わる以下の責務を全てCredit Serviceに凝集しました。 与信上限の決定(事業者審査の結果として上限を確定する) 与信枠の作成・更新(プロダクトごとに任意に切れる枠を管理する) 利用可能額の計算(いまいくらまで使えるかを算出する) 与信枠の利用(与信を消費し、債権の登録を依頼する) 審査(枠を決める)と利用(枠を使う)をあえて分離せず一体で持つことにしました。こうすることで、Payment Serviceは「与信枠を利用する」という一つの操作を呼ぶだけでよくなり、利用可能額のチェック・与信の消費・債権登録という一連の処理がサービス内に閉じます。また、横断的な与信上限(目的の不変条件)をアトミックに守るには、与信の利用を1サービスに集約する以外に方法がありません。このように、責務の凝集は明確なドメイン境界と不変条件から導いた設計判断です。 2. 与信と請求を分離する 次に与信の単位と請求の単位は同じではないという前提を設計に組み込みました。 与信は「どの枠でいくら使えるか」を、事業者が任意に切った枠(CreditLine)の単位で管理したい 請求は「どの宛先にまとめて請求書を出すか」を、請求先(InvoiceAccount)の単位で管理したい この2つを密結合にしてしまうと、「請求の単位を変えたいだけ」で与信側まで作り直すことになります。両者は本来別々の関心事なので、疎結合に保つ必要があります。 実現方法は次のとおりです。Credit Serviceは与信を消費して債権を登録するとき、その債権に「どの請求先に属するか」「どの与信枠に属するか」という集計のための情報を持たせてDebt Serviceに渡します。Debt Serviceは、債権を複数の軸で集計・一覧できる仕組みを備えており、以下のようにそれぞれのサービスが債権の情報を参照することができます。 Credit Serviceは「与信枠の軸」で未返済の債権から利用額を計算する Invoice Serviceは「請求先の軸」で指定期間の債権から請求書を発行する これにより与信は事業者全体で管理しつつ、請求は支店ごとに行うようなユースケースに対応できます。ポイントは債権の集計軸をDebt Serviceが中心的な概念として扱うことにあります。Credit Serviceは与信枠の軸だけを、Invoice Serviceは請求先の軸だけを関心に持ち、軸ごとの集計はDebt Serviceに委ねます。結果として、与信の粒度(CreditLine)と請求の粒度(InvoiceAccount)を独立して設計[1]できます。 3. 事業者ごとに複数の与信枠を持てる 与信枠は事業者単位で1つではなく、1事業者が複数の与信枠(CreditLine)を持てる構造にしました。店舗ごと・部門ごとといった、事業者で運用したい任意の単位で枠を切れます。これによってプロダクト毎に柔軟に利用制限を行うことができます。 ただし、複数の枠を切っても事業者全体の与信上限(CreditLimit)は超えられません。そこで最終的な利用可能額は、与信枠の利用可能額と事業者全体の利用可能額の小さい方で決まります。 最終的な利用可能額 = min(CreditLine の利用可能額, 事業者 CreditLimit の利用可能額) ここで、似て非なる2つの「上限」を区別しておきます。 CreditLimit CreditLine 意味 絶対に超えて立て替えない上限 クライアントが切れる実用枠 決め方 与信審査の結果 クライアントが任意設定 クライアントの変更 不可 更新可能 「審査で決まる硬い上限」と「運用で柔軟に切れる枠」を分けることで、安全性と柔軟性を両立しています。 4. 与信の利用はライフサイクルに沿って状態を持つ 与信枠の利用は、一度の操作で完結するものではありません。実際の取引は、与信を押さえてから確定するまでに時間差があり、途中で取り消されることもあります。そこで与信の利用を、複数のフェーズを持つ与信トランザクション(CreditTransaction)として管理します。 取引開始時点で与信枠を押さえる確保(Authorize)、取引確定時点で債権として確定する確定(Capture)、確定前に押さえた与信を解放する取消(Cancel)という3つのフェーズを扱います。これにより、取引の途中で状態が変化しても、与信の消費と解放を一貫したモデルで管理できます。 確保のタイミングを選択可能に(ReservationPolicy) 与信をいつ・どう押さえたいかはプロダクトによって異なります。そこで確保の仕方を ReservationPolicy として選べるようにし、同じ Credit Service を要件に合わせて使い分けられるようにしました。 確保の仕方 動作 仮確保(PROVISIONAL) 依頼時に仮の債権で与信を押さえ、確定で正式な債権にする/取消で解放する 確定時に確保(CHECK_THEN_CAPTURE) 依頼時は利用可能額の確認だけ行い、確定時にまとめて確保する 即時確保(IMMEDIATE) 依頼した時点で確保と確定を同時に行う 確定後の取消にも対応する(Reversal) 運用上は、与信を確保して取引が確定した後に取消が発生することがあります。確定後は与信を解放するだけでは済まず、すでに登録された債権をどう扱うかが問題になるため、確定後の取消を Reversal としてサポートしました。 Reversal では、取消したい金額をその時点の債権残額に応じて未返済分と返済済み分に切り分けます。未返済分は債権そのものを取り消し、返済済み分は債権を取り消せないため、返金すべき金額としてレスポンスで返して呼び出し元(Payment Service)が返金できるようにします。これにより、確保→確定→取消・返金という取引のライフサイクル全体に対して一貫した扱いができます。 全体像:プラットフォーム全体で一気通貫に ここまで見てきたCredit Serviceは、単独で成り立つものではありません。プラットフォームには、Payment(決済)・Debt(債権)・Invoice(請求)・Bank(入金)・Balance(残高)・Settlement(精算)といった、それぞれ明確な責務を持つサービスがすでに存在していました。そこにCredit Serviceが新たに加わることで、各サービスが自分の責務に集中したまま連携し、決済・与信管理・請求・精算がプラットフォーム全体で一気通貫に実現できるようになりました。 この構成によって、新しいプロダクトがシームレスに事業者請求払いに対応することができます。あるプロダクトで事業者請求払いを利用したくなったら、必要なのは 与信枠(CreditLine)と請求先(InvoiceAccount)を新たに作るだけです。プラットフォームの各サービスに手を入れることなく、そのプロダクトの取引が、与信照会から決済・請求・返済までのライフサイクルに自然に乗ります。 終わりに 与信管理マイクロサービスができたことによってPayment Platformとして事業者請求払いを実現できるようになりました。 Payment Platformは今後も進化していきますが、「各サービスが明確な責務を持ち、疎結合に連携する」という設計方針そのものは変わりません。この方針のもとに安全かつ拡張可能な決済基盤を今後も実現していきたいと考えています。 次の記事は nanacomさんとmattsuuさんです。引き続きお楽しみください。 [1]この「債権を任意の軸で集計する仕組み」自体も独立した設計テーマであり、別記事で詳しく紹介される予定です。
はじめに こんにちは。メルペイのAccountingチームでBackend Engineerをしている@hokaoです。 この記事は、Merpay & Mercoin Tech Openness Month 2026 の 5 日目の記事です。 会計データに誤りがあった場合、元のデータを残したまま打ち消すための記録を別途追加するのが会計上の一般的な手法です。本稿では、これをシステムとしてどう扱ったかを設計と実装の観点から紹介します。 背景 Accountingチームでは、会計データを扱うシステムを開発しています。メルカリグループ全体で発生するお金の移動を伴う取引を記録・集計するシステムで、会計イベントの保存と経理向けのレポーティングを責務としています。 会計データは、取引の事実を証明する証拠としての役割を持ちます。そのため、一度記録したデータを後から改変・削除することは原則として許されません。誤りがあったとしても元データを残したまま、打ち消すための記録を別途追加することで修正します。 しかし、私たちのシステムにはこの打ち消すための機能が存在していませんでした。誤って登録された会計データが見つかるたびに、その件数・金額などの情報を手作業で特定し、経理に連携して対応してもらう必要がありました。この運用には、対応コストの大きさや作業ミスのリスクといった構造的な課題がありました。 以降では、会計ドメインの前提を整理した上で、この課題を解決するために導入した打ち消し機能の設計と実装を順に説明します。 会計ドメインの前提 会計では、すべての取引を借方と貸方の 2 つに分けて記録する複式簿記という方式が使われています。借方と貸方それぞれに勘定科目・日付・金額を記載したものが「仕訳」で、これが会計データの最小単位になります。 仕訳に誤りがあった場合、元の仕訳の借方と貸方を入れ替えた「逆仕訳」を計上して打ち消します。 簡単な例として、ある仕入取引を次のように記録していたとします。 借方: 仕入 100 円 貸方: 現金 100 円 この記録が誤りだった場合、逆仕訳は次のようになります。 借方: 現金 100 円 貸方: 仕入 100 円 元の仕訳と逆仕訳を合算すると、勘定科目ごとに借方と貸方が打ち消し合い、金額がゼロになります。元データは残したまま、後から追加した記録によって取引を実質的に打ち消す形になります。 ここで説明したのは、逆仕訳がなぜ打ち消しとして成立するのかという会計上の考え方です。実際の会計レポートでは、必ずしも借方と貸方を足し合わせて相殺しているわけではなく、レポートによっては逆仕訳の金額を符号反転させて打ち消しを表現しています。詳しくは後述します。 逆仕訳の設計と実装 スキーマでの逆仕訳の表現 私たちの会計システムでは、上流のマイクロサービスから受け取った取引はまず会計イベントとして記録され、そこから仕訳が作成される構成になっています。それぞれ AccountingEvents と JournalEntries というテーブルで管理されています。 会計イベント (AccountingEvents) には、上流のマイクロサービスから会計の入力として受け取った取引が記録され、会計処理の種類や取引の中身を保持します。これに対して仕訳 (JournalEntries) は、会計イベントに仕訳ルールを適用して必要な属性が確定した、正式な会計記録です。1 つの会計イベントから、仕訳ルールの適用によって複数の仕訳が作成されることもあります。 逆仕訳の会計イベントには、必ず打ち消し対象となる元の会計イベントが存在します。そのため、AccountingEvents に元の会計イベントへの参照 (OriginalTransactionId) を持たせて関係性を表現します。この参照を持つイベントから作成される仕訳はすべて逆仕訳になります。 また、登録時にはこの参照を辿って、次のようなバリデーションを行います。 元の会計イベントが存在するか 元の会計イベントと整合する勘定科目か 元の会計イベントが既に他の逆仕訳によって打ち消されていないか 会計レポートには JournalEntries から集計するものと、分析用に AccountingEvents から集計するものがあります。どちらの場合でも JOIN なしで逆仕訳の判定ができるよう、JournalEntries には、その仕訳が逆仕訳かどうかを表すフラグ (IsReversal) を持たせています。このフラグは本質的には AccountingEvents の OriginalTransactionId から決まる情報ですが、JournalEntries でも独立に判定できるよう別途持たせています。 同じ会計イベントに対する逆仕訳の会計イベントが複数存在してはいけないため、上述のアプリケーション側のバリデーションに加えて、DB 側にも一意性制約を設けています。具体的には、OriginalTransactionId カラムに NULL_FILTERED オプションを指定した UNIQUE INDEX を張っています。これにより、OriginalTransactionId が NULL のイベント (通常の会計イベント) は重複扱いされず、NULL でない値だけに一意性制約がかかります。 これらをまとめると、スキーマの該当箇所は次のようになります。 CREATE TABLE AccountingEvents ( EventId STRING(100) NOT NULL, AccountingCode STRING(MAX) NOT NULL, OriginalTransactionId STRING(100), -- ... ) PRIMARY KEY (EventId); CREATE NULL_FILTERED INDEX AccountingEventsByOriginalTransactionId ON AccountingEvents(OriginalTransactionId); CREATE TABLE JournalEntries ( Id STRING(100) NOT NULL, EventId STRING(100) NOT NULL, IsReversal BOOL NOT NULL, -- ... ) PRIMARY KEY (Id); 仕訳作成での借方/貸方の入れ替え 逆仕訳の仕訳作成は、元の取引と同じ仕訳ルールを再利用しつつ、ルールから取り出した借方と貸方の属性をコード側で入れ替えて行います。 会計イベントには取引の方向を表す ItemKey という識別子が含まれ、X.to.Y の形式を取ります。仕訳ルールもこの ItemKey をキーに登録されています。逆仕訳イベントでは ItemKey が反転して Y.to.X の形で届くため、ルールを検索する際は ItemKey を一度元の向きに戻します。 ルールを取得した後、逆仕訳イベントの場合に限り、ルールから取り出した借方と貸方の各属性をコード側で入れ替えます。簡略化した擬似コードで示すと次のようになります。 originalRule := getOriginalEventRule(ev) debitAccountingTitleCode := originalRule.DebitAccountingTitleCode creditAccountingTitleCode := originalRule.CreditAccountingTitleCode debitXxx := originalRule.DebitXxx creditXxx := originalRule.CreditXxx // ... if isReversal { debitAccountingTitleCode, creditAccountingTitleCode = creditAccountingTitleCode, debitAccountingTitleCode debitXxx, creditXxx = creditXxx, debitXxx // ... } debit := JournalEntry{ AccountingTitleCode: debitAccountingTitleCode, Xxx: debitXxx, // ... IsReversal: isReversal, } credit := JournalEntry{ AccountingTitleCode: creditAccountingTitleCode, Xxx: creditXxx, // ... IsReversal: isReversal, } 仕訳ルールが借方と貸方の対称な構造を持っているため、ItemKey の反転とそれに続く借方と貸方の入れ替えという 2 つの操作だけで逆仕訳を作成でき、実装はシンプルな修正で済みました。 会計レポートでの逆仕訳の打ち消し 会計レポートに打ち消しを反映するには、集計クエリで逆仕訳または逆仕訳の会計イベントを判定し、正しく金額を計算する必要があります。 JournalEntries から集計するクエリでは IsReversal フラグで判定し、分析用に AccountingEvents から集計するクエリでは OriginalTransactionId IS NOT NULL で判定します。 会計ドメインの前提で述べたとおり、本来の逆仕訳は、勘定科目ごとに借方と貸方を足し合わせて相殺することで打ち消しを実現します。ただし、会計レポートには借方または貸方のどちらか一方だけを集計するものもあり、そうしたレポートでは足し合わせによる相殺は成立しません。そのため、集計クエリで逆仕訳または逆仕訳の会計イベントの金額を符号反転して合算するアプローチをとっています。なお、逆仕訳の金額自体は元の仕訳と同じ正の値で保存しています。 会計レポートは、対象とするテーブルや集計の意味合いがそれぞれ異なるため、共通化が難しく、もともと個別に実装されています。逆仕訳の打ち消しを反映するためには、その個別実装それぞれに修正を入れる必要がありました。例えば WHERE 句の絞り込み条件を、逆仕訳の会計イベントも拾えるように拡張するなどです。すべてのレポートに対してこのような修正を加えていったため、実装と検証には時間がかかりました。 逆仕訳バッチによる運用の自動化 加えて、誤って登録された会計データを訂正するために、逆仕訳を作成するためのバッチを新たに実装しました。このバッチは、対象となる取引の ID リストを受け取り、それぞれについて、上流のマイクロサービスの API を介して打ち消し用の取引を作成します。それが会計イベントとして本システムに登録され、逆仕訳が自動的に作成されます。 個別の取引で失敗が発生した場合はログに記録しつつ、残りの処理は継続します。また、冪等性は上流のマイクロサービス側で保証されているため、同じ ID リストで再実行することも可能です。 これにより、誤ったデータの打ち消しをシステム上で自動的に逆仕訳として反映できるようになり、これまで手作業で行っていた特定・連携の負荷と作業ミスのリスクが大きく軽減されました。 おわりに 本稿では、会計データの訂正を支える逆仕訳機能の設計と実装を紹介しました。 会計のように不変性の制約が強いドメインで開発していると、ドメインの原則が設計判断をそのまま導いてくれる場面が多く、その面白さを今回の機能開発でも改めて感じました。 今回の機能開発は、Accountingチームが抱える運用負荷削減という大きな取り組みの一環でもあります。会計システムは、会社が財務状況を正しく把握するための基盤であり、事業の成長に合わせてスケールできる状態に保ち続ける必要があります。これからも、持続的な開発ができるよう取り組んでいきたいと考えています。 次の記事は imamu さんです。引き続きお楽しみください。
こんにちは、Merpay の Payment Core チームと Payment Solution チームで Engineering Manager (EM) をやっている komatsu です。普段は決済基盤や決済体験の開発をするチームを見ていたり、最近は PCP Foundation というチームを発足して Individual Contributor (IC) として基盤の整備や AI 周りのツールの導入も行っています。 この記事は Merpay & Mercoin Tech Openness Month 2026 の 4 日目の記事です。 この記事では、私たちの Payment Platform が「決済」と「会計」のあいだに置いている共通言語 MoneyFlow と、それを支えるために開発したツール群 mfgen (MoneyFlow Generator; /ˌemefˈdʒen/) について紹介します。一見すると別物に見える「システムとしての決済基盤」と「上場企業として厳密さが求められる会計」ですが、実際には深く結びついています。私たちは、その複雑な関係を整理し、経理・PdM・エンジニアといった異なる立場の人々が共通の理解を持てるよう、MoneyFlow という共通言語を整備してきました。この一年ほどで MoneyFlow は徐々に組織へ浸透し、現在では同じフォーマットで記述し、同じフォーマットでレビューする開発プロセスが少しずつ定着しつつあります。本記事では、その取り組みの背景と、それを支える mfgen の仕組みについて深掘りしていきます。 決済プラットフォームと会計のつながり メルカリグループの Payment Platform は、メルカリのマーケットプレイス (Consumer to Consumer; CtoC) だけでなく、メルカリShops やメルペイ、メルコイン、メルカリモバイル、メルカリAds、グローバルアプリなど、「お金が動くすべてのプロダクト」の土台となり、決済にまつわる汎用的な機能を提供しています。決済そのものについての記事は多く上がっているので、興味があれば メルカリのグローバル展開を支えるPayment Platformの進化 や Payment に関するその他の記事 もあわせてご一読ください。 また、Payment Platform チームが掲げる理念の一つに「会計も含めた決済ソリューション」というものがあります。決済の一回一回は、お客さまや Payment Platform の利用者 (つまりプロダクトのマイクロサービス) から見れば「支払いが完了した」という体験です。しかし会社や経理から見れば、それは会計上の仕訳として正しく記録しなければならない事象でもあります。そこで Payment Platform は、外向きの決済 API を提供するだけでなく、その裏側で「お金がどう動き、どの勘定にどう記録されるか」までを引き受け、記帳や会計レポートの発行を通した社内向けの統合的な決済ソリューションとして存在しています。これによってプロダクトはビジネスロジックの開発に集中し、会計にまつわる大半の連携を Payment Platform に委ねることができます [^1]。 たとえば、メルカリのシンプルな購入ひとつを取っても、段階ごとにお金が動き、それぞれに対応する会計上の記録が発生します。お客さまが支払う場面では、残高を使う場合は購入者の資金移動口座からエスクロー決済の預かり金へと振り替え、メルペイのクレジットを使う場合はあと払い債権を計上し、他社クレジットカードを使う場合は PSP (Payment Service Provider) に対する未収入金として処理します。そして取引が完了して売上が立つ段階では、預かり金を取り崩し、出品者の資金移動口座と手数料収入へと振り分けます。 これはまだ単純な例ですが、実際には、コンビニ決済のような非同期な決済手段、キャンセルや返金、資金移動口座の開設有無、クーポン等による値引き、為替が絡む決済——こうした条件の組み合わせやタイミングの違いが絡み合い、仕訳はもっと複雑になります。特に、ひとつの API 呼び出しが複数の勘定に影響することもあれば、逆に、会計上はひとつの動きが複数の API にまたがることもある、という点も複雑性を増す要因となっています。この「ズレ」があるために、エンジニアと経理が直接話しても認識が噛み合わず、議論が長引くということが頻繁に起きていました。エンジニアが「決済 API のフロー」として見ているものを、経理は「仕訳」として見ており、同じ事象を、まったく異なる言語で眺めているからです。 MoneyFlow という共通言語 このすれ違いの根本にあるのは、Payment Platform のエンジニアと経理のあいだに横たわるドメイン的な距離です。両者は使う言葉からして異なり、エンジニアが API やエンドポイントで語るのに対し、経理は勘定科目や仕訳で語ります。前提とする知識も、システムの内部構造と会計基準というように噛み合いません。そのうえ、API と勘定科目が必ずしも 1:1 で対応しないため、片方の言葉をそのまま翻訳しても意味が通じないことがままあります。 この距離を埋めるために、Payment Platform で直近約一年にわたって運用しているのが MoneyFlow です。これはシステム上のお金の動きを表現するためのフロー図であり、同時に、エンジニア・経理・Product Manager (PdM) の理解を揃えるための共通言語でもあります。MoneyFlow は主に三つの要素で構成します。一つ目の Actor は取引の主体(誰が価値を出し、誰が受け取るのか)で、User (購入者や出品者)や Partner (加盟店さま等取引の相手方。社外事業者に限らず、会社として Mercari / Merpay / Mercoin もシステム上は Partner) が該当します。二つ目の Account は各 Actor が持つ勘定 (ledger) で、USER_FUNDS や PARTNER_SALES、USER_DEBT、PARTNER_CLEARING_SALES などがあります。三つ目の Flow はおおむね 1 回の API 呼び出しに相当するお金の移動を表し、Source (from) と Target (to) を持つことで、その決済でお金がどこからどこへ動くのかを示します。さらに各 Flow は、勘定科目に対応する accounting code を持ちます。 こうした図として表現することで、エンジニアは価値交換を行う API を記述でき、経理は Flow を中心とした会計観点でのお金の動きを把握できます。図の Actors section は Actor の一覧とそれぞれが持つ Account (ledger) を表現し、MoneyFlow section は商流ごと・API ごとのお金の動きを表現します。 この一年ほどをかけて、MoneyFlow は徐々に組織に浸透してきました。現在では、経理・PdM・エンジニアが同じフォーマットで書き、同じフォーマットでレビューする、といった開発の工程が少しずつスタンダードになりつつあります。著者自身もプロジェクトや開発の最初のフェーズでこれを行うことで、ステークホルダーと認識を合わせやすくなったと実感しており、Design Doc に並ぶ重要なドキュメントだと感じています。さらに、エンジニアリングと会計の両面を表現する概念を持ったことで、エンジニアは会計のドメインを、経理はエンジニアのドメインを理解しやすくなりました。エンジニアが勘定科目について経理に相談したり、経理が API 名を使って商流を表現したりと、お互いの歩み寄りがかなり加速したと感じています。 mfgen CLI — DSL による MoneyFlow の標準化 組織への浸透が進む一方で、MoneyFlow は最初からフォーマットが決まっていたわけではありません。初期は Draw.io (Diagrams.net) で手描きしていました。これはこれで描けるのですが、いくつかの課題がありました。まず、書き方が人によって異なり、解像度や粒度が書き手に依存するため、図にばらつきが出ます。次に、そもそもどう書けばよいか分からない人がほとんどで、書ける人が限られていました。書ける人がいないプロジェクトでは、会計観点の考慮漏れを見落とすこともありました。さらに、Draw.io は XML で表現できるものの human-friendly な宣言的記法が存在せず、同じフォーマットで安定して描画することや AI agent との親和性に欠けていました。 そこでまず取り組んだのが、MoneyFlow を YAML の DSL として定義することでした。そして、その YAML から Draw.io (および PNG 画像) を生成する CLI ツール mfgen を開発しました。YAML は次のようなイメージです。 name: Example Payment Flow actors: - id: user name: User type: USER - id: partner name: Partner type: PARTNER accounts: - id: user_funds owner: user account_type: USER_FUNDS currency: JPY - id: partner_sales owner: partner account_type: PARTNER_SALES currency: JPY flows: - id: MF1 name: Payment api: Payment.CreateCharge currency: JPY accounting_code: xyz from: - id: user_funds amount: 1000 to: - id: partner_sales amount: 1000 描画のクライアントとしては依然として Draw.io を使っているため、CLI の実装の中身はかなり地味なものです。YAML をパースし、描画に必要な座標を計算し、Draw.io の XML を組み立てる——レイアウトのための座標計算や XML 生成を、ひとつずつ実装した形になります。この段階では、validation を強めに設けて一貫性を高めることと、AI agent による生成を簡単にすることを目的としました。具体的には、次のチェックを行います。 必須フィールドを検証する ID の重複を検出する 参照整合性を確認する (actors の owner、flow の from / to などが実在するか) セマンティクスを検証する (from の合計と to の合計が一致するか、PARTNER_DEBT / USER_DEBT に debt_type があるか、など) さらに、GitHub Actions で YAML を自動変換して Draw.io 形式および画像でアップロードする CI を組みました。これによってレビューや共有、過去の MoneyFlow の管理が簡単になり、MoneyFlow が蓄積されることで次の MoneyFlow 作成時の AI による精度も向上していきました。 mfgen Web — リアルタイム共同編集の実現 DSL 化によって標準化は大きく進みましたが、mfgen CLI にも残された課題がありました。変換が YAML → Draw.io の片方向であるため、図を見ながら直接編集して保存する、という使い方ができません。ミーティング中に Draw.io を直接編集したりコメントを付けたりすると、それを YAML に取り込むコストが発生します。そして、Draw.io の表現力そのものが上限になる場面もありました。 そこで開発したのが mfgen Web です。これは社内にホストしている Web ツールで、「Draw.io のような同時編集」と「YAML による宣言的な定義」の両方を実現します。具体的には、三つの編集方法を備えています。Canvas 上で直接描く方法では、Draw.io のようにノードを配置してつなぎます。MoneyFlow に特化した編集機能では、Actor / Account / Flow といったドメイン概念をそのまま編集できます。そして YAML を直接記述する方法では、エディタで宣言的に書けます。 これらはすべて双方向に同期します。YAML を更新すれば図に即座に反映され、図をドラッグすれば YAML にも反映されます。さらに、複数人が同じ MoneyFlow をリアルタイムで共同編集でき、メンバーが作成した MoneyFlow はカタログとして蓄積されていくため、チームのドキュメンテーションとしても活用できる形になりました。 この手の内製ツールは Vibe Coding でおおよそ作れてしまう時代ですが、技術的なポイントを簡単に紹介します。 サーバは役割の異なる 2 つに分かれています。api サーバ (Hono) が SPA の配信と REST API を担い、collab サーバ (Hocuspocus) が WebSocket での同時編集を担います。設計上のポイントは次のとおりです。 Single Source of Truth として Y.Doc (Conflict-free Replicated Data Type; CRDT) を採用する。サーバが権威ある Y.Doc を保持して各クライアントと同期し、YAML はサーバ側で Y.Doc から投影して生成する派生物とすることで、クライアントが直接書き込むことはありません。これにより、YAML を入力とする CLI 版 mfgen とのデータ互換性を保っています。 「追記ログ + スナップショット」方式で永続化する。編集は append-only な update ログに追記し、2-10 秒程度の debounce で Y.Doc 全体のスナップショットと生成した YAML を Postgres に書き込み、古い update を圧縮 (compaction) します。 Canvas 上の座標を YAML から除外する。位置情報を YAML から外すことで、既存 CLI と等価なデータに保っています。 Origin タグ (5 種) で編集の出どころを区別する。「UI 操作」「YAML 編集」「ドラッグ操作」「他クライアントからの更新」「初期同期」をタグで見分け、双方向同期で起こりがちな無限ループを防ぎつつ、Undo の対象も制御しています。 Redis (Memorystore) の pub/sub で collab サーバ間のメッセージを fan-out する (@hocuspocus/extension-redis)。複数レプリカ運用に備えた仕組みで、単一レプリカであれば Redis なしでも動作します。 技術スタックは TypeScript / Bun に統一しています。フロントエンドは React + Vite + @xyflow/react + CodeMirror + Yjs、サーバは Hono + Hocuspocus + Drizzle ORM、データストアは Postgres と Memorystore Redis という構成です。これらを専用の Google Cloud プロジェクト上で、api / collab それぞれ独立したサービスとして運用しています。 mfgen Web はまだ開発したばかりで活用事例はありませんが、Web 版になったことで、MoneyFlow はより実践的なツールになると感じています。今後さらに開発を続け、エディタ内に Claude Code SDK などで AI を搭載したり、経理によるレビュー後にシステムへ登録するところまで E2E で自動化できるようになれば、会計がプロダクト開発のブロッカーにならず、基盤として素早いサポートができるようになると信じています。 まとめ この記事では、Payment Platform が「会計も含めた決済ソリューション」であること、そしてエンジニアと経理のあいだを繋ぐ共通言語 MoneyFlow と、それを実践的に運用するための mfgen について紹介しました。MoneyFlow を共通の出発点に置いたことで、これまで噛み合わなかったエンジニアと経理の議論は、同じ図を指しながら進められるようになりました。考慮漏れが減り、お互いがお互いのドメインに歩み寄る——そうした定性的な変化が、この直近多く見られるようになったと感じています。 著者自身、会計はまだ勉強中の身ですが、システムと結びつけて考えるとかなり理解しやすくなる、というのが正直な実感です。そして、こうした内製ツールは AI の力を借りて本当に手軽に作れる時代になりました。私が所属する PCP Foundation チームでは、今後もこうした組織横断のツール開発とメンテナンスを通じて、チームの垣根を越えた生産性の向上につなげていきたいと思っています。 [^1]: 決済 API を利用するタイミングと会計連携のタイミングが異なる場合はプロダクトが直接行っているが、この深掘りと進化はまた別の機会に。 次の記事は hokao さんの「会計システムにおける訂正機能の設計と実装」です。引き続きお楽しみください。
こんにちは、いつも心に冪等性 sinmetalです。「Merpay & Mercoin Tech Openness Month 2026」の3日目の記事です。 本記事では、MySQLで動いていた「お客さまが所持するクーポン一覧取得」クエリをSpannerへ移行した際に直面したパフォーマンス問題と、その解決までの過程を紹介します。 DBごとのアーキテクチャの差 MySQLとSpannerはどちらもSQLを利用できますが、アーキテクチャが大きく異なるので、テーブルやクエリの設計は異なります。移行する時は差異があるSQLを修正するだけでなく、各DBのアーキテクチャまで意識して、実装を見直す必要があります。 MySQLのアーキテクチャ MySQLはPrimary InstanceがWriteを担当するのでWriteをスケールさせたいなら、Primary Instanceのマシンを強化します。 ReadはRead Replicaを増やすことでもスケールさせることができます。 Read Replicaは独立しているので、OLAPのような特定のクエリを特定のInstanceで実行することもできます。 Spannerのアーキテクチャ Spannerはデータを分割してSplitに保存します。Splitはデータサイズや負荷で自動的に増減し、データの分散範囲も調整されます。Writeに対してもスケールできますが、Read Replicaが無いので特定のクエリを特定のInstanceで処理するようなことはできません。Read Replicaだけを増やすということもできないので、Read性能だけを増やすこともできません。WriteもReadもどちらもAuto Scaleして分散して処理するのが強みです。 メルカリのクーポン機能の移行 クーポン機能の中に自分が所持しているクーポンの一覧を取得するAPIがあります。その中で実行されていたクエリのチューニングを行いました。 まずはMySQLの実装をそのまま移行したので、以下のようなクエリになっていました。 MySQLなら、それほど問題になるようなクエリではないかもしれませんが、Spannerの実行計画を見るとResidual Condition(Residual Conditionはインメモリで処理する必要がある状態です。後述※1)が必要、Sortが必要など非常に厳しい状態です。 SELECT co.CouponOwnerID, co.CouponID, co.Expire, co.CreatedAt FROM CouponOwners co JOIN Coupons c ON co.CouponID = c.CouponID WHERE co.UserID = @userID AND c.IsActive = @isActive AND co.Expire > @now AND c.StartDate <= @now AND co.Used = @isUsed AND c.Type IN UNNEST(@couponType) AND c.MarketPlace = @marketPlace ORDER BY co.CreatedAt DESC +-----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | ID | Query_Execution_Plan | +-----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | *0 | Distributed Union (distribution_table: CouponOwnersByUserIDUsedExpireCreatedAtDESC, execution_method: Row, preserve_subquery_order: true, split_ranges_aligned: false) | | 1 | +- Serialize Result (execution_method: Row) | | 2 | +- Sort (execution_method: Row) | | *3 | +- Distributed Cross Apply (execution_method: Row) | | 4 | +- [Input] Create Batch (execution_method: Batch) | | 5 | | +- RowToDataBlock | | 6 | | +- Local Distributed Union (execution_method: Row) | | *7 | | +- Filter Scan (execution_method: Row, seekable_key_size: 3) | | *8 | | +- Index Scan (Index: CouponOwnersByUserIDUsedExpireCreatedAtDESC, execution_method: Row, scan_method: Row) | | 34 | +- [Map] Cross Apply (execution_method: Row) | | 35 | +- [Input] KeyRangeAccumulator (execution_method: Row) | | 36 | | +- DataBlockToRow | | 37 | | +- Batch Scan (Batch: $v2, execution_method: Batch, scan_method: Batch) | | 46 | +- [Map] Local Distributed Union (execution_method: Row) | | *47 | +- Filter Scan (execution_method: Row, seekable_key_size: 0) | | *48 | +- Table Scan (Table: Coupons, execution_method: Row, scan_method: Row) | +-----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ Predicates(identified by ID): 0: Split Range: (($UserID = @userid) AND ($Used = @isused) AND ($Expire > @now)) 3: Split Range: ($CouponID_1 = $CouponID) 7: Residual Condition: ($UserID = @userid) 8: Seek Condition: (IS_NOT_DISTINCT_FROM($UserID, @userid) AND ($Used = @isused)) AND ($Expire > @now) 47: Residual Condition: (($IsActive = @isactive) AND ($MarketPlace = @marketplace) AND ($StartDate <= @now) AND ($Type IN @coupontype(array))) 48: Seek Condition: ($CouponID_1 = $batched_CouponID') DB Schema CREATE TABLE Coupons ( CouponID STRING(36) NOT NULL, Type STRING(36) NOT NULL, IsActive BOOL NOT NULL, StartDate TIMESTAMP NOT NULL, MarketPlace STRING(255) NOT NULL, ) PRIMARY KEY(CouponID); CREATE INDEX CouponsByIsActiveStartDateTypeMarketPlaceID ON Coupons(IsActive, StartDate, Type, MarketPlace); CREATE TABLE CouponOwners ( CouponOwnerID STRING(36) NOT NULL, Used STRING(10) NOT NULL, UserID INT64, CouponID STRING(36) NOT NULL, Expire TIMESTAMP NOT NULL, CreatedAt TIMESTAMP NOT NULL OPTIONS ( allow_commit_timestamp = true ), UpdatedAt TIMESTAMP NOT NULL OPTIONS ( allow_commit_timestamp = true ), ) PRIMARY KEY(CouponOwnerID); CREATE INDEX CouponOwnersByUserIDUsedExpireCreatedAtDESC ON CouponOwners(UserID, Used, Expire, CreatedAt DESC) STORING (CouponID); 実行計画には現れない問題もありました。 HotSpotです。 Coupons Tableはクーポンの情報が書かれているマスタテーブルなのですが、件数はそれほど多くありません。 SpannerはRead Replicaが無いため、小さなTableや特定のRowへの高頻度の読み取りがMySQLと比べると不得意です。対象のRowが存在するSplitに負荷が集中してしまうからです。 特に負荷が集中するものとしてメルカリのすべてのお客さまに配信する 超メルカリ市土日限定クーポン がありました。 自分が所持しているクーポンの一覧を取得するとCoupons Tableの該当のRowがJOINのために参照されます。 すべてのお客さまが持っていて、しかも、超メルカリ市という大きなキャンペーンの中で、特定の土日にだけ使えて、クーポン付与のタイミングでプッシュ通知も行われることもあり、高い確率でHotRowになります。 Note: HotRowとは? アクセスが集中していてそれ以上分割不能なRowのことを指します。 HotRowが発生しているかはHot Spot Statisticsを見ると分かります。 開発環境でチェックする場合は、負荷テストを行い、Hot Split Statisticsをチェックします。 https://docs.cloud.google.com/spanner/docs/introspection/hot-split-statistics?hl=en#hot_row パフォーマンスチューニング これらの問題を解決するためにアーキテクチャを見直しました。 やったことは大きく分けて3つです。 Coupons TableのJOINをやめて、アプリ側に処理を寄せた ORDER BYを削除した NULLを許可しているColumnのFilter条件を調整してResidual Conditionが発生しないようにした Coupons TableのJOINをやめて、Coupons Tableはアプリケーション側で参照するようにし、更に一定期間メモリ上にキャッシュするようにしました。 ValkeyやRedisに保存することも検討しましたが、アクティブな状態のクーポンの数は数十程度であること、変更はほぼないことで、メモリ上に持ってしまって良いだろうと判断しました。 今後、他にもキャッシュしたいものが増えれば、専用のインフラを用意して、実装し直すかもしれません。 キャッシュを入れたことで、HotRowが発生しづらくなり、クエリもCouponOwners Table単体で処理できるようになり、JOINも不要になりました。 次に ORDER BY co.CreatedAt DESC を無くしました。 Filter条件として Expire > @now があるので、CreatedAtのSortがあると単一のIndexをSeek Conditionで読むだけという処理にはできません。 ExpireをResidual ConditionでFilter Scanするか、CreatedAtをSortするかを選択することになります。 1人のお客さまが持っているクーポンの数は多くても10程度なので、Sortしてしまっても良いかなとは思いましたが、呼び出し回数が非常に多いAPIなので、アプリケーション側に処理を寄せています。代わりにアプリケーション側の負担が増えているので、どちらがよいかは悩ましいところです。今後の状況によってはSortをSpanner側に戻すこともあるかもしれません。 もう一工夫している点として、UserIDのFilter条件 (co.UserID IS NULL AND @userID IS NULL) を追加しています。 これはCouponOwners.UserIDはNOT NULL制約がないため、@userIDにNULLが入る可能性をSpannerが考慮して、Filter ScanとしてResidual Condition: ($UserID = @userid)を追加してしまうのを抑制するためです。(元の実行計画の*7)(後述※2) 結果としてクエリの実行計画は非常にシンプルなものにできました。 SELECT co.CouponOwnerID, co.CouponID, co.Expire, co.CreatedAt FROM CouponOwners co WHERE (co.UserID = @userID) OR (co.UserID IS NULL AND @userID IS NULL) AND co.Expire > @now AND co.Used = @isUsed +----+-----------------------------------------------------------------------------------------------------------------------------------------+ | ID | Query_Execution_Plan | +----+-----------------------------------------------------------------------------------------------------------------------------------------+ | *0 | Distributed Union (distribution_table: CouponOwnersByUserIDUsedExpireCreatedAtDESC, execution_method: Ro
こんにちは。メルペイのフロントエンドエンジニアの @mattsuu です。この記事は「Merpay & Mercoin Tech Openness Month 2026」の 2 日目です。 私たちのチームは、マーケターや PM 向けの社内ツール群 Engagement Platform (EGP) を開発しています。ランディングページ (LP) の作成・公開もその一機能で、過去に WYSIWYG コンポーネントエディタ EGP Pages について同じチームから紹介記事を出しています。 今回はその後継としてゼロから作り直した EGP Code を紹介します。AI エージェントと対話しながら LP を作るための、HTML ベースの社内向け LP エディタです。見た目を生成するだけでなく、本番運用に必要なところまで踏み込んで同じ編集体験の中に組み込んでいるのが特徴で、すでに 10 件以上の本番 LP がこの仕組みで作られています。 v0、Gemini Canvas、Claude Design、Figma Make など、AI で UI を作れるツールはすでに数多くありますが、見た目は作れても本番 LP として運用するには、API 連携・品質保証・Native 連携といった社内固有の課題が残ります。EGP Code は、このギャップを埋めるために内製しました。 EGP Pages と AI 編集における課題 EGP Pages は、ブロックを選んで組み合わせるノーコードの WYSIWYG コンポーネントエディタです。ドラッグ&ドロップで Layout や Text といった 40 種類以上のコンポーネントから、ページを組み立てます。マーケターがエンジニアの手を借りずに LP を作れるという目的に対して非常によく機能しており、いまも多くの LP が EGP Pages で作られています。 転機になったのは、AI でページを編集したいというニーズが出てきたことです。EGP Pages は人がドラッグ&ドロップで組み立てる前提で設計されており、AI が扱う際にデータ構造が問題になります。例えば、ボタンを押すと数字が増えるページの JSON ツリーは次のようになります。 { "components": [{ "id": "root", "elements": [ { "id": "1", "tagName": "Context", "props": { "value": [ { "name": "count", "type": "code", "value": "0" }, { "name": "increment", "type": "code", "value": "(count) => count + 1" } ]}}, { "id": "2", "tagName": "Layout", "props": { "children": [":=element.3", ":=element.4"] }}, { "id": "3", "tagName": "Text", "props": { "value": "Count: ${context.count}" }}, { "id": "4", "tagName": "Action", "props": { "label": "+1", "onTriggerAction": [{ "type": "SET_CONTEXT", "payload": { "count": "${context.increment(context.count)}" }}]}} ] }] } 人がエディタ越しに触る分には、この構造でも問題ありませんが、LLM に直接編集を任せようとすると課題が見えてきます。 ツリー構造が独自: ":=element.3" のような独自記法を AI に都度教える必要があり、プロンプトが長くなります。 ロジックが分散: 状態・条件・動作・表示が Context / When / Action / Text に散らばり、挙動の把握にツリー全体を辿る必要があります。 JSON 文字列の中に JavaScript が埋まっている: テンプレートリテラルか eval される式かが描画コンポーネント次第で、正しい解釈が難しくなります。 さらにこの JSON ツリーは等価な HTML のおよそ 2 倍のトークンを消費し、編集のたびに API コストとコンテキスト消費が膨らみます。加えてテスト基盤がなく、AI の編集結果を公開前に機械的に検証する手段がありませんでした。これは EGP Pages の設計が悪かったわけではなく、ノーコード時代に最適化された正しい設計でした。ただ AI に編集させるという前提が加わったことで設計を問い直す必要が出てきたのです。 HTML ベースで作り直す 選択肢は2つありました。既存の JSON 表現を AI 向けに改善するか、AI 前提でゼロから作り直すかです。私たちは後者を選び、ページの表現を HTML ベースにしました。HTML は人にも LLM にも馴染みがあり、独自の JSON ツリーや参照記法を毎回プロンプトで教える必要がないからです。 先ほどのカウンターページは、EGP Code では次にようになります。 <body> <egp-script timing="page-loaded"> rx.count = 0; </egp-script> <egp-script> rx.increment = () => { rx.count = (rx.count ?? 0) + 1; }; </egp-script> <p><egp-text>Count: {{rx.count}}</egp-text></p> <egp-button :onclick="rx.increment">+1</egp-button> </body> 機能は同じですが、コード量もトークン消費もおおよそ半分です。ただし、素の HTML だけでは、状態管理や条件分岐といった動的な振る舞いは表現できません。かといって <script> で自由に JavaScript を書かせると、ページの挙動を追いづらくなります。 そこで、状態管理・条件分岐・繰り返しといった動的な部分だけを、少数の Web Components (<egp-*>) に閉じ込めました。「静的な部分は普通の HTML、動的な部分だけ Web Components」という切り分けによって、EGP Pages のように状態・条件・動作が散らばらない構造になっています。 見た目のスタイリングには Tailwind CSS を採用しています。Web 開発者に馴染みのある書き方に寄せることで、人も AI も独自の作法を覚えずに済みます。また、副次的な効果として、外部ライブラリへの依存が最小限になり、LP ごとに独自パッケージが混ざりません。ランタイムは中央で管理する少数のものだけで動くため、npm パッケージ起因のサプライチェーンリスクが問題になる中でも安全面で利点があります。 使い方 EGP Code では、ほとんどの操作を AI エージェントとのチャットで進めます。「LP を作りたい」のように要件が固まっていない依頼では、エージェントはいきなり作り始めるのではなく、文脈に応じた質問を返してくれます。対象デバイス、カラーテーマ、入れたいセクションといった項目を選択肢から答えていきます。 回答を送ると、エージェントが HTML やテストをまとめて生成し、たたき台となる LP ができあがります。 大まかな見た目ができたら、ボタンやテキストなどの要素を直接選んで仕上げます。対象をクリックして「文字サイズを大きくして」「文言を〜に変えて」のように頼めば、位置を説明しなくてもエージェントが直す箇所を正確に把握します。複数箇所にまとめてコメントを付けたり、参考にしたい画像を貼って渡すことも可能です。 実運用に必要な 3 つの仕組み LP と聞くと、文章と画像が並んだ静的なページを思い浮かべるかもしれませんが、実際には、エントリー状況で CTA ボタンを切り替えたり、お客さまの属性で見せ方が変わったりと動きを伴う LP も少なくありません。 そのため、見た目だけが整っていれば十分というわけではなく、社内 API との連携、タップ時の分析ログ送出、公開前に表示や挙動を検証する仕組み、アプリと Web の遷移差を吸収する仕組みなど、周辺の仕組みもあわせて必要になります。 EGP Code では、こうした仕組みを編集体験の中に組み込んでいます。以降では、この 3 つの仕組みを順に紹介します。 社内 API 連携と Logging LP からは、商品一覧の取得やエントリー状況の確認といった社内 API への呼び出しが頻繁に発生します。しかし、社内 API は AI ツールの学習データに含まれていません。EGP Code では、このギャップを「使い方をその場で AI に教える」仕組みで埋めています。 例えば「商品一覧を出す API は?」と聞くと、エージェントは候補の社内 API を用途つきで挙げてくれます。 このやり取りの裏側では、エージェントが「関連する API を探す → 使い方を理解する → 型付きで実装する」という流れで動いています。 この流れを実現するために、いくつかの工夫をしています。まず、社内 API の使い方を、API ごとに Markdown ファイルに記載しています。実際には、次のようなファイルが並んでいます。 api-searchExampleItems.md api-postExampleEntry.md api-getExampleSegment.md api-getExampleRecommendations.md runtime-event-log.md ... すべての API の説明をプロンプトに乗せてしまうと不要にトークンを消費してしまいます。そこで各ドキュメントのタイトルと用途だけを渡しておき、AI が必要と判断したときにだけその本文を読み込ませる形にしています。 次に、社内 API は型付きの薄いラッパー関数越しに呼び出します。LP から見えるのは関数呼び出しだけで、認証ヘッダ・サービス分岐・パスの違いはラッパーが吸収します。誤った使い方があれば Lint が検知します。 分析ログの送出も同じドキュメント参照のしくみで扱っています。 ログが必要になったタイミングで、エージェントが対応するドキュメントを読み込み、API 呼び出しと同じ流れでログ用のコードを生成します。このしくみによって、AI に「商品一覧を出して」と頼むだけで、社内 API を正しく呼んだ動的な LP が組み上がります。 エディタ内で完結するテストと品質保証 AI が生成した API 呼び出しが正しく動いているか、ボタンが期待どおりに反応するかを、変更のたびに人が目視で確認するのは現実的ではありません。そこで EGP Code は、エディタ内にテストの仕組みを内蔵しています。 エンジニア以外にとってテストは馴染みがなく、自分で作成するのはハードルの高い作業です。そこで、テストを直接書く代わりに、実現したい振る舞いを自然な言葉で書ける Spec タブを用意しています。ここに、LP の仕様を書き残していきます。 あとは AI とのチャットで「@SPEC.md を元にテストを書いて」と頼むと、文脈に応じてテストが自動で生成されます。テストはエディタ向けに自作した Jest 風の API で書いており、内蔵のモックサーバで fetch を差し替えられるので、本番 API がなくても動的ページの挙動をエディタ内で再現できます。 // ブラウザ上でそのまま実行される test('エントリーボタンで API が呼ばれる', async () => { render(html); await userEvent.click(screen.getByText('エントリー')); expect(mockEntry).toHaveBeenCalledWith({ campaign: 'X' }); }); テストが失敗すると、その結果は AI にフィードバックされ、AI が自己修正します。仕様を書く → AI が実装とテストを生成する → ブラウザで挙動を確かめるという流れがエディタ内で完結するため、静的な LP から API を使った動的な LP まで、同じ仕組みで品質を担保しながら作ることが可能です。 アプリと Web の差を吸収する Native 連携 キャンペーンページなどの LP は、Web ブラウザだけでなく、メルカリアプリ内の WebView でも開かれます。このとき通常のリンクのままだと、アプリ内では外部ブラウザが開いてしまい、アプリのネイティブ画面へ遷移できません。これを LP ごとに userAgent 判定や Native bridge の呼び出しで書くのは現実的ではありません。そこで、その差をプラットフォーム側で吸収し、LP を作る側は専用の Web Components を使うだけで済むようにしました。 <egp-link href="https://jp.mercari.com/search?keyword=camera"> カメラを探す </egp-link> このリンクをクリックすると環境を自動で判定して、アプリ内なら Native bridge 経由でネイティブ画面へ、Web なら通常のリンクとして開きます。 まとめ さまざまな AI ツールによって UI を作りやすくなっていますが、実運用の LP に乗せるには、API 連携・品質保証・Native 連携といった作り込みが必要になります。EGP Code は、そこをプラットフォーム側に組み込むことで、UI づくりから運用までを同じ編集体験の中でつなげようとしています。 実際に次のような新しい進め方が出てきています。 PM とフロントエンドエンジニアだけで、仕様策定から実装・リリースまでを完遂 バックエンドエンジニアが API から LP まで 1 人で構築 マーケターが静的な LP を 1 人で制作 テストや API 連携を含む動的な LP は、まだ非エンジニアだけで完結させるには難しい部分が残っています。それでも、誰がどこまで担えるかの境界は少しずつ動いていて、いずれはこうした動的な LP も非エンジニアだけで作れるようになると考えています。 次の記事は @sinmetalさんの「MySQLからSpannerに移行した時のQueryチューニング」です。引き続きお楽しみください。
こんにちは。メルペイ Payment & Customer Platform(PCP)チームでBackend Engineerをしている @ryuyama です。この記事は「Merpay & Mercoin Tech Openness Month 2026」の1日目の記事です。 はじめに メルカリでは、2025年9月30日にグローバル版のメルカリアプリをリリースしました。このアプリは台湾・USをはじめとして、3年以内に50カ国へ展開することを目指しており、メルカリのグローバル展開を加速させるための重要なマイルストーンとなりました。 この記事では、メルカリ グローバルアプリ(以下、グローバルアプリ)のリリースに向けて、Payment Platformがどのような課題に向き合い、どのようにアーキテクチャを進化させてきたのかを紹介します。 Payment Platformに課せられたミッション グローバルアプリ対応プロジェクトが立ち上がった際に、Payment Platformに課せられたミッションは、「3年以内に50カ国展開できる決済・会計システムを作ること」でした。 そこで、Payment Platformのシステムを大きく「決済」と「会計」のドメインに分け、達成すべきゴールを整理しました。 決済基盤としては、主に以下のような対応が求められました。 50カ国の通貨・決済手段への対応 決済画面の多言語対応 複数のPSP(決済事業者)との接続 為替レートを考慮した金額計算 不正利用やチャージバックのリスクへの対応 会計基盤としては、以下のような状態をゴールと置きました。 通貨ごと、国・地域ごとの売上、返金、決済手数料を正しく集計できること PSP(決済事業者)からの入金と、メルカリ側の取引データを照合できること 在庫変動や税務関連の会計イベントを、外部システムから会計システムへ正しい粒度・タイミングで連携できること 既存の国内向け会計システムとの整合性を保ちながら、新しい会計基盤へ移行できること また、これらを一度きりのゴールとして実装するのではなく、グローバル展開のコストとスピードを強く意識する必要がありました。例えば、通貨や決済手段の追加をできるだけ迅速に行い、展開国を早く増やせること。不正対策、税務、会計などの各国・地域ごとの最適化を、それぞれ独立して進められることが求められました。 Payment Platformの進化 既存のPayment Platform 先ほど掲げたミッションを達成するために、現在のアーキテクチャにある課題洗い出しを行いました。Payment Platformは、日本のメルカリアプリの決済基盤をベースにして誕生し、メルペイの成長とともに進化してきました。 決済のレイヤーでは、Payment Serviceというマイクロサービスが存在します。Payment Serviceでは、メルカリで商品を購入し、取引が完了するとお金が移動するという一連の取引を表現した Escrow というリソースや、お金の動きに注目した Charge、さらに3Dセキュアの普及や高度化する決済手段への対応のために Source というリソースが開発されてきました。 会計のレイヤーでは、Balance Service(残高サービス)やAccounting Service(会計サービス)といったサービスが存在します。Balance Serviceはお客さまの残高や加盟店の売上残高の管理を、Accounting Serviceは会計イベントの保存と経理向けのレポーティングを責務としています。 これらはいずれも、これまでのメルカリグループ全体の成長を支えてきた重要なサービスです。 一方で、グローバル対応に向けて、特に「展開コスト」と「展開スピード」という観点において、いくつかの課題がありました。 グローバル展開に向けた課題 PSPや決済手段の追加にPayment Serviceが必要 1つ目の課題は、Payment Serviceにおいて、PSP(決済事業者)との接続ロジックと Source リソースが密結合していたことです。 グローバル展開では、国や地域によって利用される決済手段が異なります。また、接続すべきPSPも国や地域ごとに変わる可能性があります。 そのため、新しいPSPや決済手段を追加するたびにPayment Serviceのロジックへ変更が入る状態では、展開国が増えるほど開発・運用コストが増大してしまいます。 また、Source が特定の決済手段と密結合していたため、新しい決済手段を追加する際には、新しいリソース定義や既存リソースへの影響を慎重に検討する必要がありました。 国内向けの決済基盤としては十分に機能していた設計でも、50カ国展開を前提にすると、決済手段やPSPの追加をより小さな変更範囲で実現できる構成が必要でした。 残高と会計イベントの整合性がアーキテクチャで保証できていない 2つ目の課題は、残高管理と会計イベントの整合性です。 既存の構成では、Balance Serviceが残高を管理し、Accounting Serviceが会計イベントを保存していました。しかし、両者の整合性は会計レイヤー自体で保証されているわけではなく、Payment Service側のサービス間トランザクション管理に依存していました。 グローバル展開では、通貨ごと、国・地域ごと、PSPごとの売上、返金、決済手数料を正しく集計できることが求められます。また、PSPからの入金とメルカリ側の取引データを照合できることや、在庫変動・税務関連の会計イベントを外部システムから会計システムへ正しい粒度・タイミングで連携できることも必要になります。 そのため、単に決済処理の結果を保存するだけではなく、後続の照合やレポーティング、既存の国内向け会計システムとの整合性を見据えた会計基盤が必要でした。 この課題は、グローバル対応が始まる以前から、メルカリのグローバルなマーケットプレイスの実現というビジョンに向けての課題だと認識されていました。これらの課題は次世代の会計システムであるBalance V2やBookkeeperとして、メルコインやメルカリ ハロの立ち上げに合わせて導入され、実運用の中で磨き上げられていました。一方で、メルカリのマーケットプレイスに関連する領域では既存システムからの移行コストが高く、導入の機会を待っている状況でした。 現在のアーキテクチャ Payment Platformでは、グローバル対応に合わせて2つの大きな意思決定を行いました。 1つ目は、PSP(決済事業者)と接続するための処理をPayment ServiceからPSPとの接続を責務とするPayment Provider Serviceに切り出し、PSPとの接続ロジックや決済手段に関するロジックを決済リソース管理から分離することです。 2つ目は、会計システムにBalance V2とBookkeeperを利用し、Payment Serviceのトランザクション管理による整合性の管理から、残高と会計イベントが整合したモデルで扱われるアーキテクチャへ移行することです。 Payment Serviceが決済リソースのライフサイクル管理に集中する 新しいアーキテクチャでは、Payment Serviceの責務をより明確にしました。 Payment Serviceは注文や取引に紐づく決済リソースのライフサイクル管理に集中し、決済の作成、オーソリ、キャプチャ、キャンセルなどといった状態遷移を管理します。 一方で、どのPSPのAPIをどのように呼び出すか、各決済手段にどのようなパラメータが必要でPSPから返ってくるレスポンスやWebhookをどのように処理するかといった詳細はPayment Provider Serviceに切り出しました。 これにより、Payment Serviceは特定のPSPや決済手段に強く依存せず、より安定した決済リソース管理の責務に集中できるようになりました。 Balance V2 / Bookkeeperで残高と会計イベントの一貫性を担保する 会計基盤には、Balance V2とBookkeeperを利用しました。 Balance V2とBookkeeperでは、残高の変動と会計イベントを一貫したモデルとして扱います。これにより、残高更新と会計イベントの記録が別々のデータとして管理されるのではなく、複式簿記のように、「現在の残高(ストック)」と「残高が変化した理由(フロー)」を対応づけて保存されるようになりました。 このアーキテクチャにより、Payment Service側で残高と会計イベント間の整合性を保つ複雑なサービス間トランザクション管理を担う必要が減り、Payment Serviceは決済リソースの管理により集中できるようになりました。 終わりに 今回のプロジェクトでは、グローバル展開に必要な決済・会計基盤の土台を作ることができました。 記事執筆時点では台湾・USへのローンチが完了し、クレジットカード決済・Apple Pay・Google Payなどの決済手段にも対応しています。 また、Payment Platformで実現した抽象化を利用して、カートを使ったまとめ買い機能などの新しい購入体験も日本国内に先立ってグローバルアプリで提供することができました。 一方で、今後取り組むべき課題もまだ残っています。 例えば、日本のメルカリで日本円で売られている商品を海外通貨で販売する時の為替変換の仕組みは、現時点ではグローバルアプリ専用の実装に近く、社内のPayment Platformを利用しているチームにとって十分になめらかな体験になっているとは言えません。また、グローバルアプリ内でのポイントや残高の利用サポートも、今後進めていく必要があります。 そして、より長期的には、今回整備した基盤を日本国内のメルカリにも適用するプロジェクトも進行しています。今回整備したグローバル向けの基盤や設計を国内向けの基盤にも還元することで、Payment Platform全体をより柔軟で拡張しやすい基盤へ進化させていきたいと考えています。 次の記事は mattsuuさんです。引き続きお楽しみください。
こんにちは。メルペイ Engineering Engagement チームの @mikichin です。 メルカリグループは「あらゆる価値を循環させ、あらゆる人の可能性を広げる」をミッションに、さまざまなサービスを展開しています。 メルペイは単なる決済サービスではなく、新しい「信用」を基盤として、それに基づく循環型社会、なめらかな社会を創ることを、メルコインはテクノロジーによって、さまざまな価値観の境界線を打ち破り、誰もが暗号資産・デジタル資産などあらゆる価値を簡単に交換できる世界の実現を目指しています。 そのためには、お客さま・企業・金融機関など、さまざまなステークホルダーに対して「OPENNESS」な姿勢で向き合うことで、もっと身近なものに変えていきたいと考えています。 本企画は、技術も「OPENNESS」にしていこうという考えのもと、2019年にスタートしました。 「Merpay & Mercoin Tech Openness Month 2026」では、メルペイ・メルコイン・メルカリモバイルの開発をしているエンジニアたちの取り組みをご紹介します。 各エンジニア組織がテクノロジーでお客さまの課題解決を実現することを大切にし、その挑戦の中で得た知見を6月1日から約1ヶ月間に渡り公開していきます!技術、開発設計や思想、組織ストラクチャー、Tips、その他最近の取り組みなど、幅広くお伝えします。 2019年はこちら 2020年はこちら 2021年はこちら 2022年はこちら 2023年はこちら 2025年はこちら ▼公開予定表 (こちらは、後日、各記事へのリンク集になります) Title Author メルカリのグローバル展開を支えるPayment Platformの進化 @ryuyama AI と作る HTML ベースの LP エディタ EGP Code を内製した理由Why we built EGP Code, an HTML-based AI editor for landing pages @mattsuu MySQLで実装されたクエリをSpannerに移行した時に行ったパフォーマンスチューニング @sinmetal 決済プラットフォームと経理を繋ぐ MoneyFlow @komatsu 会計システムにおける訂正機能の設計と実装 @hokao 事業者請求払いのための与信管理マイクロサービスの設計 Designing a Credit-Management Microservice for Partner Invoice Payment @imamu カーソルベースAPIのデータをマージするページネーション設計 @nanacom AI と作る LP エディタ EGP Code を支える 4 つの仕組み Four Mechanisms Behind EGP Code, an AI-Powered Landing Page Editor @mattsuu Pub/Sub drivenなmicroserviceにPR単位の検証環境を導入した話 @mikupo 内製ワークフローエンジンの設計とメルカリでの活用事例 @sapuri Build First, Discuss Later|初回ミーティングに動くプロトタイプを持ち込んだら、意思決定が爆速になった Build First, Discuss Later: How Prototypes Speed Up Product Decisions @kubomi Cloud Functions 世代移行で発生した1000万件のメッセージ滞留:Pub/Sub × Cloud Run × Spanner のチューニング @mewuto 修正PRを食べてレビュースキルが賢くなる:Claude Codeによる自己改善サイクル @um(うめ) メルペイのキャンペーン基盤をルールベース汎用システムに書き直して Otoku Revolutionするまで — Santa Service の Rulebase 移行の話 @hasegway TiDB – BQ連携データパイプライン @orfeon About my AI work setup @cyan Product Engineerとして働く @anzai Otoku Revolutionのすべてをお話します @yutaro 任意の単位での債権の色付けと与信の分割を実現するDebt View @kobaryo TBD @abcdefuji Growth Platform体制の振り返り @yo-gawa Next Payment @haoyu TBD @becosuke どんな知見が得られるのか、毎日が楽しみです。 Merpay & Mercoin Tech Openness Month 2026 の1日目は、メルペイ Payment Platform @ryuyama が執筆予定です。 ひとつでも気になる記事がある方は、この記事をブックマークしておくか、 エンジニア向け公式Xをフォロー&チェックしてくださいね!
はじめに こんにちは。MercariでPMインターンをしている菊池翔吾です。 インターン期間中に mercari-pm-agent というClaude CodeのSkillを開発しました。PMが行う「問題の発見→データ収集→PRD作成→UIモック」の一連のワークフローを、1つのセッション内で処理するエージェントです。 この記事では、PMのワークフローをClaude Code上でどのように実装したか——Skillの設計と、MCP(Model Context Protocol)を使ったNotion・Slack・Looker・Figmaとの接続方法——を中心に紹介します。 背景:メルカリPMの情報収集ワークフローと課題 メルカリのPMが意思決定を行うには、複数のツールを横断して状況を把握する必要があります。 Notionで中期戦略・KPI目標の方向性を確認する Slackで社内の改善要望やフィードバックを検索する Lookerでユーザー行動の定量指標を確認する Figmaで対象画面の現状デザインを確認する これらを統合してPRD(製品要求仕様書)に落とし込む 各ツールへのアクセス自体は難しくありませんが、ツールを横断しながら「どのデータが今の判断に関係するか」を整理する作業には一定の時間がかかります。PMが本来時間を使うべきは、集めた情報をもとに深く考え、意思決定し、関係者と対話することのはずです。情報収集にかかる時間を、思考と意思決定に充てられるようにしたい——それがこのツールを作った動機です。 mercari-pm-agentの概要 mercari-pm-agent は、Claude CodeのSkillとして実装したPM支援エージェントです。 PMがプロダクト上のビジネス課題を自然言語で入力すると、以下のステップが自動的に進みます。 処理の流れ 実装:Claude Code SkillsでPMワークフローを定義する Claude Code Skillsとは Claude Code Skillsは、Claude Codeの振る舞いをMarkdownファイルで定義する仕組みです。SKILL.md にエージェントの動作手順・制約・ツールへのアクセス方法を記述することで、特定の業務フロー専用のエージェントを構築できます(公式ガイド)。 コードを書かずにエージェントの振る舞いを定義できる点が特徴です。PM向けSkillの実装例としては phuryn/pm-skills も参考にしました。ただし、後述するように「Markdownを書くだけ」では精度は出ません。振る舞いの制約設計と評価サイクルが重要です。 ファイル構成:関心の分離をプロンプト設計に適用する mercari-pm-agent/ ├── [SKILL.md](http://skill.md/) # エージェントの振る舞い定義(英語) └── references/ ├── [prd-template.md] # PRDテンプレート ├── [prd-checklist.md] # PRD品質チェックリスト(9項目) ├── [ui-and-figma.md] # UI Spec・Figma Makeプロンプトテンプレート ├── [laplace-guide.md] # データ解釈ガイド ├── [data-sources.md] # データソース一覧・使い方 └── [quick-reference.md] # 出力チェックリスト 初期は全ての定義を SKILL.md 1ファイルに集約していましたが、後述する評価スキルによるスコアリングを通じて、ファイルが長くなるほど出力精度が低下するという問題を確認しました。 これはLLMの特性と関係しています。コンテキストが長くなると、モデルが文脈の中で関連情報に適切に注目できなくなる現象(いわゆる「Lost in the Middle」問題)が知られており、Anthropicのプロンプトエンジニアリングガイドでもプロンプトを簡潔に保つことが推奨されています。 対応として、振る舞いの定義(SKILL.md本体)と参照データ・テンプレート(references/)を分離しました。ソフトウェア開発における「関心の分離(Separation of Concerns)」をプロンプト設計に適用したアプローチです。SKILL.mdはエージェントが「何をどの順序でするか」のみを保持し、具体的なデータやテンプレートは必要なタイミングでreferencesから参照する設計です。この構造変更だけでスコアが明確に改善しました。 なお、SKILL.mdは英語で記述しています。Claudeへの指示として英語の方が精度が高いためです。 MCP接続:複数ツールをエージェントに繋ぐ mercari-pm-agent の中核的な価値は、Step 2のデータ収集を自動化する点にあります。ここではMCP(Model Context Protocol)を使ったツール接続の設計について説明します。 MCPとは MCPはAnthropicが策定したオープンプロトコルで、LLMアプリケーションが外部ツールやデータソースに接続するための標準仕様です。MCPサーバーを通じて、Claude CodeからNotion・Slack・Lookerなどの外部サービスをツールとして呼び出せるようになります。 接続しているMCPサーバー MCPサーバー 種別 取得できる情報 用途 Notion MCP 公式(Notion提供) 戦略ドキュメント・KPIダッシュボード 中期戦略との整合性確認 Slack MCP 社内独自実装 社内フィードバックチャンネルの投稿 改善要望・現場の声の収集 Socrates 社内独自実装(BigQuery・Lookerベース) CVR等の指標データ 定量的な課題の裏付け Figma MCP 社内独自実装 デザインファイルのコンポーネント情報 既存デザインの取得・UI Specへの反映 並列クエリと堅牢性の設計 Step 2(データ収集)では、これら複数のMCPを並列でクエリします。data-sources.md に以下のルールを記述しています。 - Pull in parallel during Data Enrichment — do not wait for one source before querying another. (データ収集フェーズでは並列で参照する。1つのソースの完了を待たないこと) - If a source is unavailable, skip silently and mark it in the output. (ソースが利用不可の場合は、出力にその旨を明記してスキップする) 直列での順次参照に比べてユーザーの待ち時間を削減するためです。また、いずれかのMCPが利用不可の状態でも処理が止まらないようフォールバック設計を入れています。 セキュリティ上の考慮 Slack MCPのセットアップには社内VPN接続とUser Tokenによる認証が必要です。トークンはClaude Codeの設定ファイルに環境変数として渡す形にしており、チャット上でトークン文字列が露出しない設計にしています。また、SlackのUser Tokenは7日で失効するため、更新用のスクリプトを別途用意しています。 開発で大事にしたこと 評価基準を先に決める——プロンプトのTDD 実装を始める前に、まず「エージェントの出力をどう評価するか」の基準を定義しました。 課題の理解精度(問題の本質を正しく捉えているか) 仕様の具体性(実装可能なレベルで記述されているか) 実現可能性(技術的・リソース的に妥当か) UXの妥当性(お客さまにとって使いやすいか) これはソフトウェア開発におけるテスト駆動開発(TDD)に近い発想です。LLMベースのエージェントは「動くかどうか」より「正しく動くかどうか」の判定が難しい。評価軸を先に定義することで、プロトタイプの改善サイクルを感覚ではなく基準で回せるようになりました。実際のWeb改善課題を収集して評価データセットを作り、反復的に精度を上げていきました。 LLMの「それらしい嘘」を制約として防ぐ LLMを業務フローに組み込む上で最も危険なのは、「根拠のないそれらしい情報」の生成です。データが存在しない状況でも、モデルは自然に「それっぽい数値」を出力します。PMがその数値を信じてPRDに記載してしまうと、意思決定の根拠がフィクションになります。 これは「嘘をつくな」とプロンプトで命令するだけでは解決しません。モデルがデータ不足を認識したとき、どう振る舞うかを制約として設計する必要があります。 Data integrity rules: Unconfirmed data must be labeled "Not provided" or "To be validated" (未確認のデータは "Not provided" または "To be validated" とラベルすること) Never fabricate numbers or sources (数値や出典を捏造しないこと) さらに、PMの確認なしに次のステップへ自動的に進むことを禁じました。 You are NOT allowed to infer completeness. Only explicit confirmation from the PM allows progression. (完了を推測して次へ進むことを禁じる。PMの明示的な確認があった場合のみ次へ進める) これにより、エージェントが「それらしい流れ」で自動進行するのではなく、常にPMが意思決定のドライバーである状態を維持します。 スキルをスキルで評価する——自動評価パイプライン 設計したルールが実際に機能しているかを検証するため、評価専用のスキル(skill-creator-max)を別途作成しました。mercari-pm-agent に対してテストケースを投げ、出力の品質をスコアリングして返すエージェントです。このスコアを使った反復改善の中から、前述の「SKILL.mdは短いほど精度が上がる」という知見が得られ、ファイル分割の設計変更につながりました。 まとめ mercari-pm-agent の開発を通じて得た、Claude Code Skillsを使ったエージェント設計の主な知見をまとめます。 Skillの設計は「振る舞いの仕様書」を書くことに近い。 命令ではなく制約の設計が重要で、LLMが「どう振る舞うべきでないか」を明示することが精度に直結する。 MCPによる外部ツール接続は並列設計で。 直列参照はユーザー体験を悪化させる。フォールバック設計とあわせて、接続の堅牢性を考慮する必要がある。 プロンプト設計にも関心の分離が有効。 コンテキストが長くなるほど精度が下がる。振る舞い定義と参照データの分離は、ソフトウェア設計の原則をLLM設計に適用した結果として機能した。 評価基準は実装より先に作る。 LLMエージェントの品質評価は主観に陥りやすい。評価軸を先に定義し、評価専用のエージェントを作ることで客観的な改善サイクルが回せる。 mercari-pm-agent はClaude CodeのSkillとして実装しているため、MCP設定が済んでいれば /mercari-pm-agent のコマンド1つで起動できます。 PMの業務効率化やClaude Code Skillsを使ったエージェント設計に興味のある方の参考になれば幸いです。
DBRE (DataBase Reliability Engineering)チームの taka-h です。 2025年10月のTiDB User Dayにおいて、オートスケールについて取組み中(P. 81)であることをご紹介しました。この記事では、その後のオートスケールの取り組み状況についてお伝えします。 結論としては、2025年11月時点で、DBREが管理するTiDB移行済みの全クラスタでTiDBの水平方向オートスケール導入が完了し、その後も安定稼働しています。 次の画像は、メルカリ内のとあるCluster(l1-2)で、オートスケールがCPU利用率の平均値を60パーセント前後に保つように動作している様子を示したものです。 なお、TiDB Cloudにはいくつかの製品ラインアップがありますが、本記事での以後のTiDB Cloudという言葉は、いわゆるサーバーレスではない「TiDB Cloud Dedicated」を指すものとします。 背景:なぜTiDB Cloudでオートスケールが必要だったか メルカリでは、最初にTiDBのオートスケールの実装をすることを決め、初期の実装ターゲットをTiDBに絞ってオートスケールの導入を進めました。 まずはこの経緯を簡単に説明します。 実装決定に至った経緯 最初に既存のライブラリの実装状況を調査しました。 TiDBをKubernetes上でホスティングするためのライブラリとしては、tidb-operatorがあります。このライブラリでオートスケールが実装されていれば、我々が利用しているTiDBのマネージドサービスであるTiDB Cloudでもオートスケールが近々実現されることが想定されます。 オートスケール導入を検討した当時は、次の状況でした。 当初実装されていたオートスケールの機能が削除されていた TiDB Cloudの開発ロードマップにオートスケールの機能が入っていない (最新の tidb-operator v2ではCRDのsub resourcesに対応しています) また、TiDB Cloudは自動での水平/垂直のオートスケールの機能が提供されていない一方で、スケール変更についてAPIが提供されています。 https://docs.pingcap.com/tidbcloud/api/v1beta/#tag/Cluster/operation/UpdateCluster APIがあれば、オートスケールは実現のための必要条件を満たすので、まずはオートスケールの実現のため実装を進めることとしました。 実装対象の選定 オートスケールの実装を決めた後、最初にオートスケール化の対象を決める必要があります。 メルカリにおけるTiDB Cloudのコストの構成としては、オートスケール導入前の時点で、大雑把にTiKVが6割程度、TiDBが2割程度でありました。TiKVが実データを持つのに対し、TiDBはステートレスでオートスケールに対する考慮事項が少なく、比較的容易に実現できますので、まずは実装対象をTiDBとし、要件を定義の上実装することにしました。 https://docs.pingcap.com/tidb/stable/tidb-architecture/ 方針1:Kubernetes HPA活用, しかしマネージドサービス特有の壁があった (オートスケール v1) 実装範囲を決めた後、最初はKubernetes HPA(Horizontal Pod Autoscaler)を活用して実装する方針としました。ここではその最初の方針決定の経緯、そして方針変更に至った経緯を順に説明します。 要件を定義の上LLMで実装する予定でしたので、フルカスタム実装でもよかったのですが、主に、社内でElasticsearchのオートスケールが実現されていたため、これと同様にKubernetesのNativeのオートスケールの機能を活用して実現することに決定しました。 具体的には、実績があり安心できるという理由の他には、Kubernetes Nativeのオートスケールを利用すると、次の利点がありました。 考慮すべき様々な一般的な考慮事項が既に満たされており、これを活用することでその実装が不要になり、開発すべき機能のスコープが絞られる 例) スラッシング回避、一度に増減できるノード数上限、など datadog経由のメトリクス連携を既存のオートスケールの機能でできる クレデンシャルのセットアップなども追加で不要 運用が他のKubenetesのものとそろう 既存のパターンとの違い: Podが管理するClusterに存在しない 既存のElasticsearchのオートスケールと今回のTiDB Cloudでは、前提条件が大きく異なります。既存のパターンは「自分たちが運用するKubernetesクラスタ上でPodが動いている」ことを前提に、HPAがPodのスケールを制御していました。 一方で、TiDB Cloudを利用する場合、実際のPodはTiDB Cloud上で動作しており、メルカリが管理するKubernetesクラスタ上には存在しません。このため、既存パターンと同様にHPAを適用しようとすると「スケール対象として参照できるPodがない」という問題に直面します。 そこで、TiDB Clusterを表すCustom Resourceを定義し、これをHPAのスケール対象として扱う方針で検討を進めました(次節で説明します)。 実際のPodがない対象へのHPA ここでやりたかったのは、TiDB CloudのTiDBノード数変更を、KubernetesのHPAの仕組みに乗せて自動化することです。そのために、TiDB Clusterを表すCustom Resourceを定義し、HPAがそのreplicasを増減できる構成を検討しました。 しかし、HPAがCustom Resourceをスケール対象として扱うには、scale subresource(/scale)を提供する必要があり、あわせて labelSelectorPath の定義が求められます。labelSelectorPath は、本来スケール対象に紐づくPod群を特定するための情報です。 https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#scale-subresource TiDB Cloudでは該当Podが自クラスタに存在しないため、この前提を満たせず、通常の形ではHPAが成立しませんでした。一方で、External Metricsで AverageValue を使う場合に限り、結果的にselectorが評価に使われない挙動があり、labelSelectorPath をダミーにしても動作しました(ただし仕様保証がなく、運用上のリスクが残ります)。 https://docs.cloud.google.com/kubernetes-engine/docs/tutorials/autoscaling-metrics : autoscaling/v1beta1(旧形式) metrics: - type: External external: metricName: pubsub.googleapis.com|subscription|num_undelivered_messages metricSelector: matchLabels: resource.labels.subscription_id: my-subscription targetAverageValue: "2" : autoscaling/v2 (現行推奨) metrics: - type: External external: metric: name: pubsub.googleapis.com|subscription|num_undelivered_messages selector: matchLabels: resource.labels.subscription_id: my-subscription target: type: AverageValue averageValue: "2" 方針2: フルカスタムへ切り替え、運用可能な形へ収束 (オートスケール v2) ステージング環境までは、何とかこの実装で動作はしたのですが、External Metrics連携が想定通りに動作せず、次の理由からCustom Resourceを定義し、reconcileのループを回すところだけ残し、その他をフルカスタム実装に切り替えることにしました。 Native HPAの機能を活用している既存の実装は、現状利用している特定のパターンが将来的に利用できなくなる可能性が払拭できない デバッグが困難を極めた LLMでオートスケールのような「よくある」実装が非常に容易になっている フルカスタム実装に切替えてから、オートスケールで考慮すべき一般的な考慮事項についての学びを1つ1つ獲得しながら、下記の方針で、迅速にそして大きな問題なくオートスケールを導入完了できました。 最低限担保すべき範囲が明確になっており、そこが正確に実装できていること 想定外であった場合に、それを検知する実装/設計になっていること つまり、当初は実装精度が十分ではなく、オートスケールの制御精度もやや低い状態でした。そこで初期は目標値を保守的に設定し、まずは一定のコスト削減を達成しました。あわせて、目標値との差分(猶予)を確保しつつ、想定外を検知できる仕組みを用意しました。そのうえで、運用しながら段階的に精度を高めていきました。精度が低い状態を許容するには、想定外の事態や異常の検知が十分に検討され、適切に実装されていることが前提になります。 最低限担保すべき範囲 オートスケールでは、ある指定されたメトリックが目標値になるようリソースを増減させます。 運用開始時に、この目標値に余裕を持たせる前提であれば、 最小ノード数を一定以下にしないこと 一度に可能な増減ノード数を制御できていること ノード増減操作の連続操作に制約を与えること が期待通りに動作していれば、提供するサービス品質に影響が発生するということはないと考えました。 想定外を検知する実装/設計 最低限担保する範囲を厳密に守りながら、実装速度を優先し、運用中に改善を重ねていく前提で開発を進める方針でしたので、運用中の改善を許容するためには「異常な状態」をうまく検知できる必要があり、そのために必要なメトリクス/アラートについて、事前に多くの検討を行いました。 ここでの検討事項には、次の3つのポイントがありました。 1. 制御するパラメータに対する適切なアラート設計/設定 現時点ではCPU利用率を一定範囲にコントロールすることを目標にオートスケールを運用しています。当たり前ですが、利用率が低すぎれば、それはリソースが有効に活用できておらず、利用率が高すぎれば、サービスの提供品質に問題がでる、こういったことが起きない範囲にコントロールすることを目指します。 オートスケールでの制御目標値 実際のアラートの閾値(Cluster/Node) 上記のような値を適切な上下関係に設定し、運用を行うこととしました。 2. 依存関係のモニタリング オートスケールが依存しているAPIの品質/問題に対して異常検知を行うことが重要であると考えました。今回は現状のメトリクスを把握するためのdatadogのAPI、そしてTiDB CloudのAPIが該当します。 例えば、APIレスポンスが遅くなったり、特定のエラーコードが一定以上発生したり、データ点が欠損したり、あるいは、変更操作を起こった際の所要時間が通常より長くなっていないか、などを観測する、といったことです。 3. オートスケールの想定内・想定外の規定 オートスケールでは、ノードを追加したり、削除したり、変更操作を随時行っています。 一方で、あるノードの予期せぬ再起動、といったことも発生します。 外部から観測できるメトリクス(Clusterの状態や、NodeのUptimeなど)から、どのような状態/変化が想定通りか、あるいは想定外かを考え、規定しています。 これらを可能にした周辺改善 オートスケールは、当たり前のように実施しなければならない項目ではありますが、それを実現するために、メルカリ社そしてTiDB Cloudの提供元であるPingCAP社の様々な改善がありました。 一番大きなものは、TiDB CloudのTiDBのスケール操作(スケールアップ/スケールダウン/スケールイン)がgracefulではなかった点を、メルカリ/PingCAP社で改善したことです(TiDB User Day 2025 P.36~)。 そして、TiDB CloudのdatadogのCPUメトリクスの値の信頼性が十分でなく、オートスケールなどに利用するのが難しい、といった問題への対処、また、オートスケールをAPIで実施する際の権限制御の改善、などがありました。 度重なる要望に対して、改善を積み重ねていただいたPingCAP社には改めて感謝します。 今後: TiKVへの展開の難しさと次の一手 現在、より大きなコスト割合を占めるTiKVのスケーリングに取り組んでいます。 TiKVはデータを保持するため、これを水平スケールする際には、データの偏りをなくすためのリバランスが必要です。水平スケールの戦略を取るためには、スケールインの際には削除対象のノードのすべてのデータを他のノードに移動してからではないと、ノードを削除できません。 現在、「1日の中で負荷が上下し、TiKVで必要なリソースが変化するので、これに最適なリソースにし、コストを抑制しながら必要なリソースを必要なだけ確保したい」という要望があります。データのリバランスは、既存のノードの性能にも影響を与える可能性があり、そのため一般的にはその速度がある程度抑制されています。水平スケールに求めるスケール速度が、このような1日の中でノードを増減する必要がある、という場合においてスケールインは不向きであるといえるでしょう。また、逆にもう少し長いスパンでのスケールの自動追従であれば、コストに対するインパクトがそこまでないため実現の重要性は下がるでしょう。 これに対して、リバランスの速度を調整する、という選択肢も考えられます。しかし、現状ではTiDBではこのリバランスの設定を含む、TiKVの設定変更に対してAPIが提供されておらず、現状取りうる手段は、サポートチケット経由での設定の変更依頼となります。 そこで、まずは垂直スケールの機能の導入を目指しています。 TiKVの垂直スケールにおけるメモリ有効活用 今後も追加で様々な課題が見つかるかとは思いますが、現状、大きなインスタンスクラスでTiKVを運用した場合、メモリを有効活用できない課題がありましたので、それを解決しています。 MySQLやその他のデータストアや、システムでもよくある問題ですが、データベースをホストしているノードの安定稼働のために、データベース以外、あるいは主に利用するリソース以外で利用するリソースを一定割合確保するため、主に利用するメモリの利用率を保守的に設定することがあります。 TiDBにおいては、TiKVのblock_cache.capacity, memory_usage_limitといった設定が主に積極的に活用したいリソースでありますが、現状のTiKVの実装状況で発生している拘束条件として、これらのパラメータについては、初期値は固定の割合で指定されるものの、初期値ではない値に変更する場合に、利用するメモリ利用量を数値で指定する必要がありました。 https://docs.pingcap.com/tidb/stable/tikv-configuration-file/#memory-usage-limit https://docs.pingcap.com/tidb/stable/tikv-configuration-file/#capacity 先述の
はじめに こんにちは。メルカリのAI Securityエンジニアの@hi120kiです。 メルカリでは、AI AgentサービスDevinを社内の複数チームに展開しています。Devinは自律的にコードの調査・作成・PR提出までをこなせるサービスですが、組織として運用するうえでは管理上の課題がいくつかあります。 本記事ではAI SecurityチームがAI Agent Platformチームと協力し、Devin Enterprise APIを活用したカスタムTerraformプロバイダーと自動管理ツール群を自作しました。これにより、メンバーと権限の管理・シークレットローテーション・APIキーのライフサイクル管理・監査の仕組みを構築した取り組みについて紹介します。 Enterprise運用の課題 メルカリではDevinのEnterpriseプランを採用しています。Remote環境で動作するAI Agentを組織的に運用するためにOktaによるSSO、監査ログ、権限の管理、チームごとの環境分離が必須要件であり、これらを満たすために選定しました。 Devin EnterpriseではCoreプランやTeamプランのように1つのOrganizationを共有するのではなく、Enterpriseという管理基盤から複数のOrganizationを一元管理します。メルカリには複数のビジネス領域にまたがる多数のチームがあり、各チームが扱う情報を分離して保護する必要があります。そのためチームや目的に応じてOrganizationを割り当てています。 ただし、10以上のOrganizationと多数の利用者を抱える環境では、次の課題が生じます。 権限管理の課題 メンバーのOrganizationへのアサインが手動操作に依存 「誰がどのOrganizationに所属しているか」の状態管理が困難 シークレット管理の課題 各Organizationにサードパーティサービスごとの認証情報を個別に設定する必要 シークレットを手動で一斉ローテーションする手間 アクセス権の課題 Devin APIキーの有効期限管理が標準機能として提供されておらず、各Organization内に長期間未ローテーションのAPIキーが残存するリスク Devinの活用が広がるほど管理するOrganizationも増え、これらの課題の負担は拡大します。以前はWeb UIでの手作業に頼っていましたが、2025年末以降DevinがEnterprise向けAPIをv2からv3へ拡充したことで、ほとんどの管理操作をAPI経由で自動化できるようになりました。これを受け、Go言語とGitHub Actionsを用いた管理基盤を内製しています。 Devin APIの概要 Devinはv3 として最新のEnterprise管理向けAPIを提供しています。Enterprise・Organization単位のMember・Role管理や、各OrganizationのSecret・Knowledgeを操作できます。v3 APIで以下の自動管理機能を実現しました。 カスタムTerraformプロバイダー シークレットの一斉ローテーション Google Cloudサービスアカウントキーのローテーション セキュリティ管理基盤との連携 APIキー管理のみv2 APIを使用しています。v2 APIでは複数OrganizationにまたがるAPIキーの作成・取得・削除が可能で、以下を実施しています。 利用者が発行したAPIキーの定期無効化 社内AgentのDevin Wiki利用向けAPIキー管理 これらのAPI仕様はREST形式のAPIとしてDevinの公式ドキュメントにリクエストおよびレスポンスの詳細な仕様とともにドキュメント化されており、一般的なREST APIクライアントを実装することでそれぞれの機能を呼び出すことができます。今回これらのREST APIクライアントは、メルカリ社内で広く用いられているGo言語を用いてそれぞれのAPIが関数に対応するように実装し再利用しやすいように整備しました。 以下の章からそれぞれの管理機能の詳細を紹介します。 1. カスタムTerraformプロバイダー 管理基盤の中核は、Terraform Plugin Frameworkで構築したカスタムTerraformプロバイダーによるOrganizationおよびメンバー管理です。 メルカリではGoogle Cloudをはじめリソース管理にTerraformを広く利用しており、エンジニアが日常的に扱っている点から採用しました。DevinをInfrastructure as Codeで管理すると、メンバー追加や権限変更にPRレビューを挟める・Organizationやメンバーの状態をコードで把握できるようになります。公式のTerraformプロバイダーは現時点で提供されていないため自作しました。 利用者や管理者は各チーム用のOrganizationをTerraformで定義します。ACU(Agent Compute Unit)上限もここで設定し、チームごとの利用量を制御します。max_cycle_acu_limit はOrganization全体のACU上限、max_session_acu_limit は1セッションあたりの上限で、想定外のコスト超過を防ぎます。 resource "devin_organization" "mercari_example_team" { name = "mercari-example-team" max_cycle_acu_limit = 500 max_session_acu_limit = 250 } またメンバーのOrganizationへのアサインもTerraformで宣言的に管理します。 # メンバー定義(メールアドレスで参照) data "devin_member" "mercari_example_team" { for_each = toset([ "user-1@example.com", "user-2@example.com", "user-3@example.com", ]) email = each.value } # Organizationへのアサイン resource "devin_organization_member" "mercari_example_team" { for_each = data.devin_member.mercari_example_team user_id = each.value.user_id org_id = devin_organization.mercari_example_team.org_id org_role_id = "mercari_org_member" } Organizationの追加やACU上限の変更、メンバーの追加・削除は、Terraformコードの変更→PRレビュー→マージという通常の開発フローで行います。terraform plan の出力で「誰がどのOrganizationに追加/削除されるか」が明確にわかり、意図しない権限変更を防げます。 このTerraformプロバイダーではDevin Knowledgeも管理できます。KnowledgeはDevinにおけるAgent Skillのような存在です。メルカリのDevin環境では各チームが別々のOrganizationに分かれており、互いの利用状況を閲覧できません。セキュリティ面では望ましい分離ですが、活用ノウハウの共有が難しくなります。Knowledgeをプロバイダーで管理できるようにし、チーム間での活用ノウハウの配布を可能にしました。 2. シークレットの一斉ローテーション DevinはSessionごとに独立した仮想マシンを起動するため、初期状態ではGitHub等ソースコード管理サービスへの権限しか持ちません。クラウド環境やチケット管理サービスなどへ接続するには、APIキー等の認証情報を個別に設定する必要があります。 一方、DevinはAI Agentとして与えられたAPIキーを自由に扱えるうえ、Organization内のメンバーはSession内部のファイルシステムやシェルにアクセスできるため認証情報の取り扱いには注意が必要です。そこでメルカリでは、Devinに設定するAPIキー群を管理者が一元管理し、短い間隔で定期ローテーションすることで、長期間有効な認証情報がDevin上に残らないようにしています。 ただし手動でのローテーションは負担が大きく、以前は多数のOrganizationの複数Secretをローテーションするだけでかなりの時間を要していました。しかしDevinが2026年1月にSecret管理機能をv3 APIへ追加したことで、これらの操作を自動化できるようになりました。現在のローテーション手順は以下のとおりです。 Devin管理者がそれぞれのサービスで認証情報をローテーションする 新しい認証情報を事前に作成済みのGoogle Cloud Secret Managerに追加する 自動化をGitHub Actions経由で起動する ローテーションが実行され、Secret Managerから各Organizationに配布される これにより、最小限の作業で10以上のOrganizationのシークレットを一斉ローテーションできるようになりました。 3. Google Cloudサービスアカウントキーのローテーション メルカリでは主にGoogle Cloudを利用しておりライブラリの取得やテスト環境との接続にはGoogle Cloudの権限をDevinに付与する必要があります。しかしDevinは現在Workload Identity Federationに対応できるようなOIDCトークン発行機能がないため、サービスアカウントキーを用いる必要があります。 しかし前提として、メルカリではGoogle Cloud公式のベストプラクティスに従い、Organization Policyでサービスアカウントキーの発行を一律禁止しています。このためDevin専用のGoogle Cloud Projectを設け、さらにiam.serviceAccountKeyExpiryHoursを追加のOrganization Policyとして設定しました。これにより、自動化が停止した場合でもサービスアカウントキーは一定期間で無効化されます。 この仕組みのうえで、Organizationごとに個別のサービスアカウントキーを定期ローテーションしながら付与しています。 4. セキュリティ管理基盤との連携 Devin Enterprise採用の要件の一つに監査ログがあります。メルカリではAI Security およびThreat Detection and ResponseチームのAnnaがDevin v3 APIを通じて内製セキュリティ監視プラットフォームとの連携を構築しました。 この連携では、Admin権限を持つEnterprise Service UserとEnterprise Audit Logsエンドポイントを利用しています。これはv2 APIにおけるエンドポイントとは異なりページネーションがあるため、すべての監査ログを正確に取得することができます。これによりGoogle CloudのCloud Run Job を使って5分おきにAPIを取得し、前回取り込んだ最後の監査ログのタイムスタンプ以降の新規監査ログをすべて取得したうえでGoogle CloudのPubSubトピックへと転送しています。そして転送された監査ログはセキュリティ調査のためのBigQueryに保存されます。 5. 利用者が発行したAPIキーの定期無効化 Enterprise全体のAPIキーを全件取得し、作成から一定期間が経過したキーを自動で無効化します。Devinの標準機能にはないセキュリティポリシーを、APIで独自に実装しました。 これらのAPIキーは主にDevin MCPの接続に用いられます。APIキー経由で間接的にソースコードを取得できるため、厳格な管理が求められます。AI Agentを複数利用する開発環境では、使わなくなったAgentの設定ファイルに認証情報が残る・個人のAPIキーを複数人が利用する自作Agentに設定して社内公開してしまう、といった事態が起こりえます。 一定期間経過したAPIキーを自動無効化することで、利用中のAgentだけがAPIキーを保持する状態を維持し、複数人で共有するAgentには、次章で紹介するGoogle Cloud Secret Manager経由のAPIキーを利用させることで、Agentが持つ権限の可視化も実現しました。 6. 社内AgentのDevin Wiki利用向けAPIキー管理 メルカリでは各チームの開発用Organizationとは別に、Devin Wiki用のOrganizationを運用しています。Devin WikiはDevin MCP経由でリポジトリの内容を取得したり、自然言語で検索したりできます。 ソースコードの探索をAI Agentが直接行うとコンテキストを大量に消費します。ソースコード調査が必要な場面ではDevinに処理を委託することで、コンテキスト消費を抑えられます。 ただしDevin MCPの利用にはAPIキーが必要で、前章のとおり一定期間で自動無効化されます。例外となるAPIキーを設けることもできますが、目的外利用を完全には防げません。そこでAPIキーを短い間隔で定期的に再作成し、Google Cloud Secret Managerに保存する自動化を構築しました。 これにより、Devin MCPを利用するAI AgentのサービスアカウントをTerraform上で一元管理し利用状況を可視化するとともに、APIキーの定期再作成による目的外利用の防止も実現しました。 resource "google_secret_manager_secret" "shared_wiki_api_key" { secret_id = "shared-wiki-api-key" } resource "google_secret_manager_secret_iam_member" "shared_wiki_api_key" { for_each = toset(local.accessor_service_accounts_shared_wiki_api_key) secret_id = google_secret_manager_secret.shared_wiki_api_key.secret_id role = "roles/secretmanager.secretAccessor" member = "serviceAccount:${each.value}" } locals { accessor_service_accounts_shared_wiki_api_key = [ "agent-1@---.iam.gserviceaccount.com", "agent-2@---.iam.gserviceaccount.com", ] } 管理操作を動かすCIパイプライン これらの管理操作はすべてGitHub Actionsで自動化しています。SaaS管理向けに独自管理ツールを作る場合、長期的なメンテナンスが避けられません。組織変更時の引き継ぎも考慮すると、依存関係を小さく保ち、メンテナンスしやすい技術・プラットフォームを選ぶ必要があります。 Secret ManagerやサービスアカウントはGoogle Cloud上に置きつつも、処理の実行にはGitHub Actionsを選びました。リポジトリ内の自動化がデプロイなしで直接動作するためメンテナンスの手間が減り、不要なクラウドリソースを持たないことでコストと管理・引き継ぎ時の認知負荷も抑えられます。また定期実行に加え手動トリガー(workflow_dispatch)にも対応しており、緊急時のシークレットローテーションを即座に実行できます。 一方、GitHub Actionsは自由に実行できてしまうため、権限管理や<a href="https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulese
目次 はじめに プロジェクトの発足 直面した「2つの壁」 完璧なインポートは幻想(技術の壁) 金融事業ゆえの厳しい制約(コンプライアンスの壁) 現状と今後 もし過去に戻れるとしたら? Confluence導入時まで戻れたら… Confluence→Notion移行プロジェクト開始時に戻れたら… おわりに はじめに こんにちは。Engineering Officeのkikoです。 今、私はCentral Knowledge Management Committeeのメンバーとして、社内のドキュメント基盤をConfluenceからNotionへ移行するプロジェクトを推進しています。 当プロジェクトはまだ完遂していませんが、3月末に一つの節目を迎えたので、同Committeeメンバーのt-yamaさんと共に、これまでの振り返りをまとめました。 これから社内ドキュメントツールを導入しようとしている方や、他ツールからNotionへの大規模移行を検討している方にとって、少しでも役立てば幸いです。 プロジェクトの発足 このプロジェクトは、単なるツールの乗り換えではなく、メルカリ全体のKnowledge Management(ナレッジマネジメント)の再構築として発足しました。(メルカリのNotion導入についてのブログ) これまでConfluence含め複数のツールに分散していた社内ドキュメントを、AI/LLMとの親和性が高いNotionに集約することで、以下のメリットがあると考えています。 ツールとして構造が一元化されるため、AIがコンテキストにしやすい AIや他ツールとの連携コストを下げられる ドキュメンテーションの重複やメンテナンスコストを減らせる ツールの利用自体のナレッジを社内で共有、蓄積しやすくなる ツールの違いによる、チーム間のナレッジのシェアや、働き方の違いといった分断を減らせる 将来別のツールに移行する場合にコストを低くできる 直面した「2つの壁」 Confluenceからの移行の検討が始まったのは、2025年7月のこと。 まずは、VP、PM Office、リスク・コンプライアンスチーム等へヒアリングしました。ヒアリングの結果、「一部移行を見送るドキュメントは在るものの、全体としては大きなブロッカーはない」と判断し、本格始動しました。しかし、プロジェクトを進めるうちに、2つの大きな壁にぶつかることになります。参考までに、それぞれに対する判断と対策についても合わせて付記します。 1. 完璧なインポートは幻想(技術の壁) ↑ガイドラインに、移行に関する情報・Tipsを記載 エクスポート・インポートの過程で、情報の欠損やレイアウト崩れが発生しました(我々が調べた当時、50項目以上)。これは、ConfluenceとNotionが別ツールである限り仕様として当然に起きるものです。ただ、その量と範囲は結構なもの…ITチームは泥臭い検証を繰り返しました。 判断:手間をかけずに100%完璧な移行は不可能。移行後に必ず手作業が発生すること前提で進める。 対策:欠損箇所(マクロや特殊な表など)を特定。移行前に修正すべき点や、インポート後に対応すべき点、修正のベストプラクティスなどをまとめたガイドラインを整備。 2. 金融事業ゆえの厳しい制約(コンプライアンスの壁) プロジェクト中盤、メルペイ・メルコインといった金融事業において、特定のドキュメントに求められるコンプライアンス要件が、現時点のNotion標準機能ではクリアしきれないことが判明しました。 判断:対象ドキュメントの移行は全社の移行とは分ける。 対策:メルペイ・メルコイン側で担当者を立ててもらい、全社の移行とは別で対象ドキュメントの要件定義・ソリューション考案を進めてもらう。 現状と今後 2つの大きな壁はありましたが、当初から対象外としていたものや別ルート対応になったものを除き、今後も利用する社内ドキュメントはほぼNotionへのインポートが完了。3月末に、Confluenceへの常時アクセスが不要なライセンスの棚卸しが完了しました。 ただ、Confluenceには、情報のオーナーがいない等の理由でNotionへ移行しなかったものの、情報資産として有用なドキュメントが多数残っています。これらの情報を、必要な時にいつでも閲覧できるよう、一時・恒久アクセス付与の仕組みを提供しています。 最終的には、Confluence内のドキュメントは閉架図書のように取り扱う形を目指しています。 もし過去に戻れるとしたら? さて、「もし最初から今の知見があったら何をするか?」を考えると、やり直したいポイントはいくつかあります。戻るポイントを、Confluence導入時とプロジェクト開始時のそれぞれに設定して考えてみました。 Confluence導入時まで戻れたら… どんなツールでも、導入時から完璧な運用ができるわけではありません。何年も運用していれば前提が変わってくるもので、それは導入時に何かしていれば防げたという類のものでもないと思います。 ただ、もし今からConfluence導入時期に戻れるとしたら、Confluenceはもっと自由度を抑えた運用設定にしておくべきだったと思います。弊社のConfluence利用はユーザの自由度が高く、スペース作成やAPIの発行など、ルールはあってもシステム的に制御できている状態ではありませんでした。良く言えば「自由」、悪く言えば「制御できていなかった」状態だったと思います。 自由度が高いと、ユーザが意図せずセキュリティ上リスクのある設定をしてしまったり、例外的な使い方やエッジケースが大量に生まれてしまうというデメリットもあります。例えばAPIトークンはユーザ自身で発行できたため、個人やチーム単位でConfluenceとのAPI連携を前提とした仕組み・運用が多数存在していました。その結果、Notionへの移行が決まると、「同じようにAPIを使わせてほしい」「システム連携も対応してほしい」という声が自然と出てきました。 Notion導入時にこうしたルールを再度整備し運用に落とし込んだところ、「前より不便になった」「自由ではなくなった」という声も一部からありました。ただ、これはNotionが厳しいのではなく、前が自由すぎたのが問題だったと考えています。 とはいえ、最初は自由であとから制限をかけるというのは、反発を生みやすいものです。これはツール移行に限らず、プラットフォーム運用全般に言えることだと思います。組織が成熟していく過程で、後からルールを導入するのは仕方がない面もあります。ただ、自由な期間が長いほど反発は大きくなるので、できるだけ早いタイミングでルールを整備しておくのが望ましいと思います。 Confluence→Notion移行プロジェクト開始時に戻れたら… 今回のような大規模な社内ドキュメントツールの移行は何度もやるようなものではありません。全員が初めての経験で、ノウハウも正解もない中、手探りで進めていくことになります。 その中で一番感じたのは、Confluenceの現状に対する解像度をもっと早く高めておくべきだったということです。ConfluenceとNotionは別のサービスである以上、完璧な移行はできず、人的リソースや期限もあるため必然的にベストエフォートでの対応になります。何に力を入れて、何を割り切るか——その判断には、ある問題がどれだけのユーザに影響するのか、メジャーな話なのかエッジケースなのかを見極める必要があります。 しかし実際には、その見極めに必要な情報が足りないことが多くありました。本当はエッジケースなので割り切った方がよいのに、ニーズの総数がわからないために決断できない、ということがよく発生しました。例えばマクロが移行できないとわかったとき、そもそも何のマクロが会社全体でどのくらい使われているのかがすぐには把握できませんでした。このあたりは後からわかるようになりましたが、技術面だけでなく、ユーザが実際にどう使っているか、どのスペースの更新頻度が高いか、特殊な要件があるかといったユースケースの理解を早期に深めておけば、判断はもっと速くできたと思います。 解像度の問題に加えて、「やった方がよいのはわかっているが、リソースが足りなくてできない」という場面もかなり多くありました。例えば、自前で移行ツールを作れば、移行できない問題やエッジケースに部分的に対応できるかもしれません。しかし、ツールの冪等性の担保や、逆に新たな移行不能要素が生まれないかの検証など、別のリスクが発生します。それが起きたときにプロジェクトが破綻しないかというリスク許容度も含めると、安易には踏み切れません。やれたら良いけれど余裕がない——でも周りから見ると「なぜやらないのか」と思われてしまう——というジレンマは常にありました。 このように移行プロジェクトでは、技術的な対応に加えて、現場での使われ方の把握、リソース管理、ベストエフォートの判断、ユーザへのコミュニケーションなど、さまざまな観点での対応力が求められます。そのため、移行専任のプロジェクトとして適切なメンバーをアサインすることが非常に重要だと感じています。 おわりに 今回のプロジェクトは、社内ドキュメントツールの管理・運用の仕組みを考え直す良い機会になりました。 また、移行のやり方に唯一の正解はありませんが、少なくとも言えるのは次の1点です。 「欠損や崩れが起きることを前提に、準備と運用設計をしておくほど、移行は楽になる。」 今回の経験をもとに、今後もNotionの環境構築・設計に力を入れていきたいと思います。
DBRE (DataBase Reliability Engineering)チームの taka-h です。 大規模なデータ更新や削除は、やりたいこと自体はSQLで表現できても、そのまま一度に実行すると運用上のリスクが高くなります。例えば大きなトランザクションが発生すると、レプリケーション遅延やDB負荷の増大、UNDOログの肥大化などにつながり、結果としてサービス影響を招く可能性があります。 そこで私たちは、UPDATE/DELETEのような「最終的にやりたい操作」をSQLに近い形で記述しつつ、実行時には安全な単位に分割して処理できる汎用ツールを実装しました。さらに、実行中に処理速度などの設定を変更できることや、監視結果に応じて自動で一時停止できることなど、実運用で必要になる制御も組み込んでいます。 本記事では、なぜこの問題が起きるのか、従来どのように回避してきたのか、そして今回のツールがどのように安全性と運用性を両立するのかを紹介します。最後に、ツールのREADMEも公開するので、同様の課題を持つ方が自分たちの環境に合わせて実装する際の叩き台として使えるはずです。 なおこのツールは、社内の次のようなデータベース運用の支援を前提とします。 データをアーカイブ/削除する データをバックフィルする データを一括で更新する 大規模データの更新/削除操作における課題 小規模なデータベースであれば、目的のSQLをそのまま実行しても問題にならないことがあります。一方で、一定以上の規模のデータを扱う場合は、同じSQLでも“そのまま一括実行する”こと自体がリスクになります。 主な理由は、処理対象が多いと大きなトランザクションが発生しやすく、その副作用がDB全体に波及するためです。具体的には、変更の伝播(レプリケーションなど)に遅延が発生したり、DBが高負荷になったり、UNDOログが肥大化して回復や性能に影響が出たりします。 このような場合の従来の方針は、「対象を小分けにして処理する」でした。たとえば、対象の主キーをある程度の件数に分割し、短いトランザクションを繰り返すようなSQLを作成してもらったり、専用の使い切りのスクリプトを都度用意して対応していました。 BEGIN; -- 対象の主キーを少量ずつ指定して処理する DELETE FROM items WHERE id IN (...); COMMIT; SLEEP ...; ただし、毎回使い切りのスクリプトを作ったり、対象主キーを取り出して分割したりするのは手間です。依頼者側に“安全な形のSQL”を組み立ててもらう必要が出るなど、運用コストが積み上がっていきます。 そこで、この問題に対して汎用的な解決策を提供するツールを実装しました。 解決策: 汎用化ツール このツールでは、利用者は「最終的に達成したい条件」をSQLに近い形で記述します。一方で実行時には、その条件に合致する対象を主キー単位で取得し、バッチに分割して短いトランザクションを繰り返すことで、安全にUPDATE/DELETEを進められるようにしています。 また、実運用では「削除や更新の進捗」とは独立に、DB全体が高負荷になったり、想定外の問題が発生したりします。そのため、状況に応じて処理速度や挙動を調整できること、そして必要なら自動的に一時停止できることが重要です。 この要件に対して本ツールでは、処理間隔やバッチサイズなどの設定を実行中に変更できる機能を持たせています。これは、MySQLのオンラインスキーマ変更ツールである gh-ost が「実行中に操作を制御できる」点で運用上便利なのと同じ発想です。さらに、監視結果に応じて自動で処理を一時停止する仕組みも組み込んでいます。 最終的なコンフィグ例は上図の通りです。実行したい条件(SQLに近い記述)と、どう安全に実行するか(運用上の関心事項)を分離して設定できます。また、processingに属する項目の多くは実行中に変更可能です。 このツールは主に生成AIを利用して実装し、動作確認のうえ社内で既に利用しています。コード自体のOSSとしての公開にはふみきれなかったのですが、次の章でこのツールのREADME.mdを公開します。これをご利用の環境に合わせた要件の追加、修正をしていただいた上で、生成AIを利用し同様のツールが利用できるようになることを期待しています。 もし試してみて有用だった点や改善アイデアがあれば、SNSなどで議論いただけると嬉しいです。また、「メルカリのDBREチームの公開したREADME.mdで作ってみた」ということで宣伝いただけるとありがたいです。 最後に、現在メルカリでは、この記事の発行者の所属する DBREチーム の EM(Engineering Manager) を募集しています。詳しくはこちらをご覧ください。 汎用データ更新ツールのREADME.md # data-updater A tool for batch data operations (UPDATE, DELETE, or NULL) on database records using primary keys with configurable conditions. ## Features - Cursor-based batch processing with configurable batch size - **Three operation types**: UPDATE, DELETE, and NULL (before_sql only) - **Parallel execution**: SELECT and UPDATE operations run concurrently for better performance - **Replica support**: Route SELECT queries to replica database to reduce primary load - **JOIN support**: Complex queries with multiple tables to identify target records - **Before SQL hooks**: Execute SQL before each batch (archiving, audit logging) - **Custom ORDER BY**: Process records in custom order - Interactive commands for runtime control (similar to gh-ost) - **YAML-based configuration**: All settings in a single configuration file - Real-time status monitoring with ETA - Pause/resume functionality - Dynamic configuration updates - Socket-based remote control interface - **Failed ID tracking**: Records failed updates and displays summary on exit - For batch-level failures: Records only first and last ID of the failed batch - For partial updates: Logs the discrepancy but doesn't track individual IDs - Writes detailed report to file if >100 failures - **Automatic resume**: Saves progress to status file after each batch - Automatically resumes from last successful position on restart - No need to manually track progress or specify resume points - Status files are adapter/table specific for multiple concurrent jobs ## Install ```bash go install github.com/xxx/cmd/data-updater ``` ## Quick Start 1. Create a configuration file: ```yaml # config.yaml database: host: localhost port: 3306 user: myuser password: mypassword database: mydatabase options: charset: utf8mb4 parseTime: "true" processing: batch_size: 1000 interval: 1s adapter: table_name: users pk_columns: - user_id update_sql: "status = 'processed', updated_at = NOW()" where_clause: "status = 'pending'" ``` 2. Run the tool: ```bash # Normal mode - executes updates data-updater --config config.yaml # Debug mode - SELECT only, no updates data-updater --config config.yaml --debug # Resume from specific ID data-updater --config config.yaml --resume-from "12345" # Show version data-updater -v ``` ## Operation Types The tool supports three operation types: ### UPDATE (default) Updates records matching the specified conditions. ```yaml adapter: table_name: users pk_columns: ["user_id"] operation: update # or omit (default) update_sql: "status = 'processed', updated_at = NOW()" where_clause: "status = 'pending'" ``` ### DELETE Deletes records matching the specified conditions. **Important**: The DELETE operation permanently removes data. Always test with --debug mode first. ```yaml adapter: table_name: old_logs pk_columns: ["id"] operation: delete where_clause: "created_at < '2023-01-01'" ``` ### NULL Executes only before_sql without UPDATE or DELETE. Useful for archiving, copying, or transforming data. ```yaml adapter: table_name: items pk_columns: ["id"] operation: "null" before_sql: | INSERT INTO archived_items (id, name, created_at, archived_at) SELECT id, name, created_at, NOW() FROM items WHERE id IN (?) where_clause: "status = 'inactive'" ``` ## Configuration All settings are managed through a YAML configuration file: ### Database Configuration ```yaml database: host: localhost # Database host (default: localhost) port: 3306 # Database port (default: 3306) user: myuser # Database user (required) password: mypassword # Database password (required) database: mydatabase # Database name (required) options: # MySQL connection options (optional) charset: utf8mb4 parseTime: "true" loc: UTC timeout: 30s # Replica configuration (optional) replica_host: replica-db.example.com # SELECT queries go here replica_port: 3306 # Defaults to primary port replica_user: replica_user # Defaults to primary user replica_password: replica_password # Defaults to primary password ``` When replica_host is configured: - SELECT queries (fetching PKs, COUNT) are routed to replica - UPDATE/DELETE operations always use primary - SELECT FOR UPDATE (pessimistic locking) uses primary ### Processing Configuration ```yaml processing: batch_size: 1000 # Number of rows per batch interval: 1s # Time between batches (e.g., 1s, 500ms, 2m) debug_mode: false # Log queries without executing updates pipeline_buffer: 1 # Buffer size for parallel SELECT/UPDATE pessimistic_locking: true # Use SELECT FOR UPDATE (default: true) lock_retry_count: 3 # Number of lock acquisition retries ``` ### Adapter Configuration ```yaml adapter: table_name: users # Target table (required) table_alias: u # Alias for main table (required when using joins) pk_columns: # Primary key column(s) (required) - user_id operation: update # "update" (default), "delete", or "null" update_sql: "status = 'processed'" # SET clause (required for update) before_sql: "..." # SQL to execute before operation (required for null) where_clause: "status = 'pending'" # Additional WHERE (optional) join_clause: "..." # JOIN statements (optional) order_by: "created_at" # Custom ORDER BY (optional, defaults to PK) ``` ### Interactive Control ```yaml interactive: enabled: true # Enable socket-based control socket_path: "/tmp/data-updater.sock" # Unix socket path ``` ### Status File (Automatic Resume) ```yaml status_file: enabled: true # Enable automatic resume path: "/var/lib/status" # Custom path (optional) ``` ## Advanced Features ### JOIN Support Use JOINs for complex queries that need to reference multiple tables: ```yaml adapter: table_name: items table_alias: i pk_columns: ["id"] operation: delete join_clause: | LEFT JOIN transaction_evidences te ON te.item_id = i.id where_clause: | i.status = 'cancel' AND te.id IS NULL ``` **How it works:** 1. SELECT query uses JOINs + WHERE to fetch PKs 2. DELETE/UPDATE query only uses primary keys (no JOINs) ### Before SQL (Pre-operation Hook) Execute SQL before each batch within the same transaction: ```yaml adapter: table_name: items pk_columns: ["id"] operation: delete before_sql: | INSERT INTO deleted_item_ids (id, created, deleted) SELECT id, created, NOW() FROM items WHERE id IN (?) where_clause: "status = 'cancel'" ``` **Notes:** - Use IN (?) placeholder - expanded to all PKs in the batch - For composite keys: (col1, col2) IN (?) - Executed atomica
はじめに 現在メルカリでは CoreDB と呼ばれる巨大な MySQL を TiDB に移行しています[^1]. この記事内でも紹介されていますが, 私たちは移行するために MySQL と TiDB を DM というツールで差分同期を行っています. 本記事ではこの DM を利用しつつ DDL(Data Definition Language) をどの様に実行しているかについて紹介します. メルカリでの MySQL への DDL 実行 まず, メルカリにおける MySQL への DDL 実行は下記の通り場合分けして実行しています: それぞれの条件について簡単に解説しますが, 基本的に source – replica の replication 遅延を最小限に抑えるために場合分けしています. メタデータのみの変更 まず最初の条件は「メタデータのみの変更かどうか」です. これは Online DDL[^2] のページで [In Place] & ![Rebuilds Table] & [Permit Concurrent DML] & [Only Modifies Metadata] なものが該当します. 例えば Table 名の変更や Column の default 値の変更, ENUM の追加[^3] などです. これはテーブルの再構築などが不要で一瞬で完了するためそのまま source 側で実行します. metadata のみの変更でも注意すること metadata のみの変更とはいえ注意すべきこと, それは DDL とクエリのロック競合です. 公式ドキュメント[^4]にあるとおり MySQL では table へのアクセス/変更時に一貫性を保証するために metadata lock を取得しますが, この metadata lock が DDL とアプリケーションが発行するクエリで競合し意図しない影響を及ぼす可能性があります: -- session 1 mysql> BEGIN; Query OK, 0 rows affected (0.00 sec) mysql> SELECT * FROM foo; Empty set (0.00 sec) -- // このまま transaction を保持したままにする -- session 2 mysql> ALTER TABLE foo ALTER COLUMN id SET DEFAULT 0, ALGORITHM=INSTANT; -- // metadata のみの変更にもかかわらず実行がブロックされる -- session 3 mysql> SELECT * FROM foo; -- // DDL の後続のクエリも待たされる このように session 2 移行の同一テーブルへのアクセスがブロックされていますが, ここで processlist と metadata lock の関係を見てみます: mysql> SELECT -> t.PROCESSLIST_ID AS process_id, -> t.PROCESSLIST_USER AS user, -> t.PROCESSLIST_DB AS db, -> t.PROCESSLIST_TIME AS time, -> t.PROCESSLIST_STATE AS state, -> t.PROCESSLIST_INFO AS query, -> ml.LOCK_TYPE, -> ml.LOCK_DURATION, -> ml.LOCK_STATUS -> FROM performance_schema.metadata_locks ml -> JOIN performance_schema.threads t -> ON ml.OWNER_THREAD_ID = t.THREAD_ID -> WHERE ml.OBJECT_TYPE = 'TABLE' -> AND ml.OBJECT_SCHEMA = 'test' -> AND ml.OBJECT_NAME = 'foo' -> AND t.PROCESSLIST_ID IS NOT NULL -> ORDER BY ml.LOCK_STATUS, process_id; +------------+------+------+------+---------------------------------+------------------------------------------------------------------+-------------------+---------------+-------------+ | process_id | user | db | time | state | query | LOCK_TYPE | LOCK_DURATION | LOCK_STATUS | +------------+------+------+------+---------------------------------+------------------------------------------------------------------+-------------------+---------------+-------------+ | 8 | root | test | 516 | NULL | NULL | SHARED_READ | TRANSACTION | GRANTED | | 9 | root | test | 514 | Waiting for table metadata lock | alter table foo alter column id set default 0, ALGORITHM=INSTANT | SHARED_UPGRADABLE | TRANSACTION | GRANTED | | 9 | root | test | 514 | Waiting for table metadata lock | alter table foo alter column id set default 0, ALGORITHM=INSTANT | EXCLUSIVE | TRANSACTION | PENDING | | 16 | root | test | 512 | Waiting for table metadata lock | SELECT * FROM foo | SHARED_READ | TRANSACTION | PENDING | +------------+------+------+------+---------------------------------+------------------------------------------------------------------+-------------------+---------------+-------------+ この様に DDL(id=9)が Exclusive lock を取ろうとして親の transaction(id=8)を待っていて, DDL の後続(id=16)が更に待たされている事がわかります. これを避けるために, たとえ metadata lock のものであっても下記のように lock_wait_timeout を十分短い値に指定することでこの例の後続のクエリになるべく影響を与えない様に実行することが重要です. 例えばここでは 5s に設定した場合のそれぞれの挙動を確認します: -- session 1 mysql> BEGIN; Query OK, 0 rows affected (0.00 sec) mysql> SELECT * FROM foo; Empty set (0.02 sec) -- session 2 mysql> SET SESSION lock_wait_timeout=5; mysql> SELECT NOW(); ALTER TABLE foo ALTER COLUMN id SET DEFAULT 0, ALGORITHM=INSTANT; SELECT NOW(); +---------------------+ | NOW() | +---------------------+ | 2026-03-05 01:59:32 | +---------------------+ 1 row in set (0.01 sec) ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction +---------------------+ | NOW() | +---------------------+ | 2026-03-05 01:59:37 | +---------------------+ 1 row in set (0.00 sec) -- session 3 mysql> SELECT NOW(); SELECT * FROM foo; SELECT NOW(); +---------------------+ | NOW() | +---------------------+ | 2026-03-05 01:59:34 | +---------------------+ 1 row in set (0.01 sec) Empty set (3.03 sec) +---------------------+ | NOW() | +---------------------+ | 2026-03-05 01:59:37 | +---------------------+ 1 row in set (0.00 sec) 1秒未満で完了するか 続いての条件は「1秒未満で完了するかどうか」です. これについては具体的なケースを紹介することは難しいのですが, 例えば CREATE TABLE 文, 空もしくは十分少ないレコード数に対するメタデータの変更で完結しない DDL[^5] などがこれに当たります. ここでなぜ「1秒未満」という条件を付与しているかについてですが, MySQL の DDL はたとえ online としても replication を構成しているときには下記の制限があります[^6]: Long running online DDL operations can cause replication lag. An online DDL operation must finish running on the source before it is run on the replica. Also, DML that was processed concurrently on the source is only processed on the replica after the DDL operation on the replica is completed. online DDL としても replica で実行される前に source で完了している必要があり, また source で並行で実行されている DML は replica での DDL が完了した後に実行されます. つまり, source で並行実行されている DML は replica では DDL が完了するまでブロックされるということです. これは source 側で(online) DDL による source 側の table definition の変更と並行実行されている DML の対応が replica 側でも同様にして再現される必要があります. そのため replica 側でも table defintion の変更と DML の整合性を担保するためにこのような仕組みとなっています. 冒頭の記事のようにメルカリのデータサイズは非常に巨大で中には DDL 完了に1日以上かかるものもあり, そのようなテーブルへの DDL による replication 遅延を防ぐために, 基本的に即座に終わらない DDL はそのまま実行しないようにしています. 実際に DDL が 1s 未満で完了するかどうかは経験則に基づくことが多いですが, 例えばサービスで稼働していないホストで SET sql_log_bin=0 を実行してバイナリログに出力しないようにして実測するなどで計測可能です. また, DDL 自体が 1s 未満で完了するとしても先の metadata lock による影響は考慮する必要があるため, この場合も同様に lock_wait_timeout を調整する必要があります. gh-ost で対応可能か 時間のかかる DDL を実行する場合, メルカリでは gh-ost を採用しています. gh-ost を利用できるかどうかについてはリソース的な制限(テーブルコピーを伴うため余剰なディスクサイズが必要, 通常の更新と gh-ost による backfill/差分同期による replication 遅延影響)や機能的な制限(UNIQUE KEY の一部に ENUM が含まれる場合の性能劣化[^7])がない限り gh-ost を利用しており, DDL 実行オペレーションで最も数の多いケースです. もしそれらを満たさない場合には Rolling Upgrade, つまり全 replica に対して DDL を実行し最後に source を切り替えるといったことを行います. なぜ ENUM を含む場合に性能劣化するのか MySQL の公式ドキュメントによると ENUM は文字列順ではなく内部 index 順でソートされますが, これは Go 言語[^8]の文字列によるハンドリングと異なります. この ENUM の取り扱いの不一致による iteration 時のデータ欠損を避けるために, gh-ost では UNIQUE KEY に ENUM が含まれる場合には CONCAT(...) により明示的な文字列として取り扱われます[^9]. この時, ORDER BY CONCAT(...) ASC が実行されることになり結果として iteration のたびに Creating Sort Index が発生し性能, 負荷ともに劣化する可能性があります. DM を用いた DDL 実行 ここから TiDB の話に移ります. 前述の通りメルカリでは移行に伴う停止時間をなるべく短くするために DM を用いて MySQL と TiDB で差分同期をしつつ切り替えを進めています: この時, 前述の条件 3 の場合にどのような挙動になるかを考えてみます. こちらのドキュメントの通り, DM には online-ddl というフラグがあり pt-osc や gh-ost といった online migration tool のユースケースをカバーしています. ここからは gh-ost を例にして説明していきます. まず, gh-ost で test database の foo table に DDL が適応される流れについて説明します: メタデータテーブル(ghc) を作成 Create /* gh-ost */ table test._foo_ghc realtable をもとに切り替え後のテーブル(gho)を作成 Create /* gh-ost */ table test._foo_gho like test.foo gho に DDL の適応 ALTER /* gh-ost */ table test._foo_gho ... realtable から gho への backfill backfill 完了後 realtable から gho への差分同期 差分同期完了後 RENAME 文を使って cutover RENABLE TABLE foo TO _foo_del, _foo_gho TO foo このフローで online-ddl が有効化されている場合に DM はどのような挙動になるでしょうか. メタデータテーブル(ghc) を作成 DM は ghc テーブルを作成しない realtable をもとに切り替え後のテーブル(gho)を作成 DM は gho テーブルを作成しない, その代わりにメタデータテーブル dm_meta.{task_name}_onlineddl を初期化する DELETE FROM dm_meta.{task_name}_onlineddl WHERE id = {server_id} and ghost_schema = {ghost_schema} and ghost_table = {ghost_table}; gho に DDL の適応 DM が実行される DDL をメタデータテーブル dm_meta.{task_name}_onlineddl に保存する この DDL は後に利用される realtable から gho への backfill DM は realtable への更新のみ TiDB に同期する gho への更新はすべて破棄される backfill 完了後 realtable から gho への差分同期 4 と同様に realtable への更新のみ同期 差分同期完了後 RENAME 文を使って cutover DM は cutover の RENAME 文を分割し, gho table から realtable への RENAME 実行の際に下記を実施する 3 で保存した DDL を取得 DDL の gho を realtable に置換 置換された DDL を実行 ALTER table test.foo ...; このようにして DM は OnlineDDL ツールを利用する際に realtable から gho への同期に伴う処理を削減しています(online-ddl=false</co
MySQLと高い互換性を持つデータベースのTiDBでは、DDLが高速かつオンラインで実施されとても有用です。メルカリの運用における気付きとして得られた、主に実行の速度制御とmodify columnの完了時間見積もりの学びについてお伝えします。 背景 メルカリではMySQLと高い互換性を持つTiDBを利用しているため、DDLはオンラインで実行でき、現状のところ大きな問題なく動作しています。 先日、数十億レコード程度のテーブルのALTERを実施した際、実行の完了時刻が予測できない、と感じた事象がありました。この記事ではこの問題について調査して得られた学びを共有したいと思います。DDLにはさまざまなバリエーションがあり、本記事が適用できないケースがあることにご留意ください。 なお、本記事中についてはTiDB CloudのDedicated(Serverlessでない方)を前提にしております。TiDB Cloudではなく、TiDBのSoftware版でもおそらく同じことがいえると思います。 実行中のTiDB障害 本記事では、DDLの実行完了の予測について取り扱いたいのですが、その前にDDLの進捗情報の永続化について整理をするために、まずはTiDBのDDLの障害耐性について整理したいと思います。 長時間のDDL実行における関心事項として、DDL中に障害が発生するとどうなるか、という懸念があげられます。 結論から言えば、障害が発生してもDDLは継続しますが、これにはDDLをどこまで実行したかの状態を永続化して保持し、かつ障害発生時に他のノードで実行を再開する仕組みが必要です。 TiDBでのDDLおよびDDL owner TiDBでのDDL実行においてはDDL ownerという概念があり、あるTiDBがDDL Ownerの役割を担い、そのTiDBが主体となり実際にデータを保存するTiKVに対するDDLの実処理を実行します https://docs.pingcap.com/best-practices/ddl-introduction/#tidb-ddl-module https://pingcap.github.io/tidb-dev-guide/understand-tidb/ddl.html#execution-in-the-tidb-ddl-owner DDL実行中にDDL ownerであるTiDBに障害が発生した場合どうなるでしょうか? DDL ownerは、etcdベースで選出がおこなわれ、通常はDDL ownerが定期的にKeepaliveのような死活情報をetcdに報告することにより、etcdリース期間が延長されています。したがって、DDL owner障害時には、このKeepaliveが途絶えることによりetcdリースが失効し、新ownerが選出されます。 したがって、TiDB障害時にもDDLの処理は継続します。 メルカリでも、長時間のDDL中に(意図せず)TiDBのスケールダウンを行ってしまう事象が発生しましたが、その際にも実際に処理が正常に継続することを確認できました。 ただし、TiDBで実行中のプロセス一覧(information_schema.cluster_processlist)からは、ALTERのプロセスは確認できなくなる一方で、ADMIN SHOW DDL JOBSを確認すると処理が継続している、という状況でした。 DDLの実行状況の永続化 次に、DDLの状態の永続化です。DDLに関連するテーブルとしては https://docs.pingcap.com/tidb/stable/mysql-schema/#system-tables-related-to-ddl-statements があり、このうちバックフィルの情報はmysql.tidb_ddl_reorgに以下のように保持されます。 mysql> DESC mysql.tidb_ddl_reorg; +-------------+----------+------+------+---------+-------+ | Field | Type | Null | Key | Default | Extra | +-------------+----------+------+------+---------+-------+ | job_id | bigint | NO | MUL | NULL | | | ele_id | bigint | YES | | NULL | | | ele_type | blob | YES | | NULL | | | start_key | blob | YES | | NULL | | | end_key | blob | YES | | NULL | | | physical_id | bigint | YES | | NULL | | | reorg_meta | longblob | YES | | NULL | | +-------------+----------+------+------+---------+-------+ 7 rows in set (0.00 sec) これは次に処理すべきキーの値をstart_keyとして永続化する、という方法によっているようです。これにより処理が中断された場合、この情報を元に再開、継続することが可能となります。 また、実行中の設定や進捗の確認に関しては、mysql.tidb_ddl_jobに永続化されます。 こちらの詳細は記事の後半で再度内容を紹介します。 なお、こちらはDDLの実行が完了すると、mysql.tidb_ddl_historyに移動するため注意が必要です。 mysql> desc mysql.tidb_ddl_job; +------------+------------+------+------+---------+-------+ | Field | Type | Null | Key | Default | Extra | +------------+------------+------+------+---------+-------+ | job_id | bigint | NO | PRI | NULL | | | reorg | int | YES | | NULL | | | schema_ids | mediumtext | YES | | NULL | | | table_ids | mediumtext | YES | | NULL | | | job_meta | longblob | YES | | NULL | | | type | int | YES | | NULL | | | processing | int | YES | | NULL | | +------------+------------+------+------+---------+-------+ 7 rows in set (0.00 sec) mysql> desc mysql.tidb_ddl_history; +-------------+------------+------+------+---------+-------+ | Field | Type | Null | Key | Default | Extra | +-------------+------------+------+------+---------+-------+ | job_id | bigint | NO | PRI | NULL | | | job_meta | longblob | YES | | NULL | | | db_name | char(64) | YES | | NULL | | | table_name | char(64) | YES | | NULL | | | schema_ids | mediumtext | YES | | NULL | | | table_ids | mediumtext | YES | | NULL | | | create_time | datetime | YES | | NULL | | +-------------+------------+------+------+---------+-------+ 7 rows in set (0.00 sec) DDL実行中の速度制御 大容量テーブルに対する物理的なDDL(Reorg DDL)は、TiKVのリソース(CPUやI/O)を消費しながらデータのバックフィルを行うため、実行に時間がかかったり、リソースを過剰に消費するので、これらのコントロールが必要になることがあります。 すでに実行中のDDLジョブの速度を動的にコントロールするにはADMIN ALTER DDL JOBSを使用します。 ADMIN ALTER DDL JOBS 現在の設定値や、どのような操作をするかにより設定可能な項目などは異なりますが、MODIFY COLUMNに区分される実態の再構築(Full Reorg)が必要なDDLでは、 THREAD: DDLジョブのワーカー数 BATCH_SIZE: ワーカーが1回のバッチで処理する行数 の変更が有効です。 ADMIN ALTER DDL JOBS <job_id> THREAD = 16, BATCH_SIZE = 1024; これらのパラメータはTiKVのリソースの消費量と、DDLの実行速度に影響し、負荷を抑えたい場合は、一時的なDDLの中断/再開も可能ですし、スレッド数を減らして実行することもできます。逆にTiKVのキャパシティーに余裕があり、高速に完了させたい場合にスレッド数やバッチサイズを増やしたりすることが有効です。 また、これらの初期値として、 tidb_ddl_reorg_worker_cnt tidb_ddl_reorg_batch_size を変更することが可能です。 通常、ADMIN ALTER DDL JOBS を実行するとその設定変更は即時で反映されることが期待されます。1点注意が必要なのは、特定のバージョン(v8.5.2, v8.5.3 など)において、特定のDDL(MODIFY COLUMN のcharからvarcharへの変更など)に対してADMIN ALTER DDL JOBSを実行しても、ワーカー数などの設定が実質的に反映されない不具合が報告されています。 関連Issue: #63201 ADMIN ALTER DDL JOBS can’t adjust the modify column job concurrency 関連PR: #63605 ddl: fix dynamic parameter adjustment failure in txn and local ingest mode こちらに関しては、設定変更後に、PAUSE/RESUMEをすれば設定が反映されることを確認しています。 PAUSE: https://docs.pingcap.com/tidb/stable/sql-statement-admin-pause-ddl/ RESUME: https://docs.pingcap.com/tidb/stable/sql-statement-admin-resume-ddl/ DDLを実行の際はご利用のバージョンを確認し、必要に応じてPAUSE/RESUMEを試してみてください。 また、現状設定している値の確認に関しては先ほどの、mysql.tidb_ddl_jobから確認できます。 mysql> SELECT job_id, JSON_EXTRACT(CONVERT(UNHEX(TRIM(LEADING '0x' FROM HEX(job_meta))) USING utf8mb4), '$.reorg_meta.concurrency') AS concurrency, JSON_EXTRACT(CONVERT(UNHEX(TRIM(LEADING '0x' FROM HEX(job_meta))) USING utf8mb4), '$.reorg_meta.batch_size') AS batch_size FROM mysql.tidb_ddl_job WHERE job_id = 149; +--------+-------------+------------+ | job_id | concurrency | batch_size | +--------+-------------+------------+ | 149 | 8 | 1024 | +--------+-------------+------------+ 1 row in set (0.00 sec) DDL完了時間の見積もり DDLについては実行完了時間の見積もりができることが望ましいと思います。 まず、前提条件としてTiDBでDDLを発行した際、ADMIN SHOW DDL JOBSコマンドで、information_schema.ddl_jobsのROW_COUNTという値が観測可能であり、これはDDLの実行に伴って値が増えていきます。 : ALTER文の実行 mysql> ALTER TABLE test_alter_row_count MODIFY COLUMN value INT NOT NULL; Query OK, 0 rows affected (0.28 sec) : ADMIN SHOW DDL JOBSコマンドで実行結果/実行中の状況が確認可能 mysql> admin show ddl jobs 1; +--------+---------+----------------------+---------------+--------------+-----------+----------+-----------+----------------------------+----------------------------+----------------------------+--------+----------+ | JOB_ID | DB_NAME | TABLE_NAME | JOB_TYPE | SCHEMA_STATE | SCHEMA_ID | TABLE_ID | ROW_COUNT | CREATE_TIME | START_TIME | END_TIME | STATE | COMMENTS | +--------+---------+----------------------+---------------+--------------+-----------+----------+-----------+----------------------------+----------------------------+----------------------------+--------+----------+ | 151 | mercari | test_alter_row_count | modify column | public | 116 | 143 | 100000000 | 2026-02-27 01:45:35.549000 | 2026-02-27 01:45:35.549000 | 2026-02-27 02:03:25.748000 | synced | | +--------+---------+----------------------+---------------+--------------+-----------+----------+-----------+----------------------------+----------------------------+----------------------------+--------+----------+ 1 row in set (0.00 sec) この値を利用してどのように、完了時刻見積もりができるか、をバージョンごとに確認したのが本エントリの内容です。 v8.5.3まで TiDB v8.5.3で下記のテーブルを作成し、1000行のデータにインデックスを作成していきました CREATE TABLE test_alter_row_count ( id BIGINT UNSIGNED NOT NULL, value INT UNSIGNED NOT NULL, label VARCHAR(255) NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id) CLUSTERED ); -- 1000行を挿入後、value カラムを参照するセカンダリインデックスを 0〜4 本作成 インデックス作成後に、INT UNSIGNEDから I
こんにちは。メルカリ Engineering Office の mikichin です。 2月21日に開催された「Go Conference mini in Sendai 2026」にメルカリはZunda Sponsorをしておりました。今回は参加レポートをお届けします! 「Go Conference mini in Sendai 2026」について 「Go Conference mini in Sendai 2026」はプログラミング言語 Go に関する地域カンファレンスで、東北地方のエンジニアコミュニティの成長と連携を促進し、地域を超えて共に前進していくことを目指すイベントです。 開催概要 日時:2026年2月21日(土) 場所:アーバンネットビル仙台中央 カンファレンスルーム 公式サイト:https://sendaigo.jp/ メルカリメンバーの登壇 なんと、今回のカンファレンスでは4名の登壇がありました! 登壇後の感想とあわせて、登壇資料を共有します。 はじめての Go 〜 きっかけは TinyGo だった / @mikichin まさか Tech PR のわたしが、Go というプログラミング言語を中心としたカンファレンスに登壇する日が来るとは思っていませんでした。とても貴重な機会をいただき、ありがとうございました 🙌 今回の発表が同じように開発をしてみたいと思う誰かの後押しになったり、エンジニアのみなさんが質問をされたときに非エンジニアの人のわからないレベル感が参考になったりしたらうれしいです。 Goに新機能を提案し実装されるまでのフロー徹底解説 〜将来、あなたのアイデアがGoに入るかもしれない。/ @pooh 登壇した時にもお伝えしたのですが、「なぜプロポーザルを出すのか?」「ワクワクしない!?自分の提案、自分のコードがGoに取り込まれるのは楽しいよね!」というのがこの登壇の動機でした。 AI時代になり、自分でコードを書く機会が急激に減ってきています。 Goに貢献する、Goに自分の思いが入っているということを経験できる最後のタイミングかもしれません。 ぜひ!興味を持った人がトライする機会になればいいなと思いました。 仙台は牛タンが美味しかった! AI時代を見据えたコードカバレッジ計測ツールの開発 / @goccy 4年ぶりの仙台での登壇でした。出身地で登壇できるのは感慨深いもので、また地元を盛り上げるために良い発表ができるよう、頑張りたいと強く思いました。 仙台はグルメも最高だし泊まりで来ている場合は終電も気にしなくていいのが最高なはずなのですが、毎回帰省を兼ねていて終電前で実家に帰ってしまうので、次回は会場の近くでホテルをとって朝まで飲み明かすのも良いなと思いつつ、体力が心配。 TODO からはじまるコントリビュート 〜 TinyGo / @micchie 資料リンクはこちら 普段はカンファレンスの主催や運営に携わることが多いのですが、やはりスピーカーとして参加するのは格別です。この発表をきっかけに、5/30 に開催される Women Who Go Tokyo 10th Anniversary イベントの参加者も増えて、2度美味しいです。 ちなみにこの LT のネタはまだ未解決なので、次の技術書典までには修正してマージされたいところです。 会場内の様子 今回のイベントでは、スポンサーブースだけではなくKitchen Senoue(実行委員長 Senoueさんが振る舞う芋煮)や にがおえりんごさんがイラストを書いてくれるブースなどもありました! TinyGo Keeb のブースでは、「電車でTinyGO!」の展示があり遊んでみました。わたしは「電車でGO!」自体遊んだことがなかったので、初体験。全然うまく駅に停車できませんでしたw 「電車でTinyGO!」はプレステの電車でGO用に作られたコントローラーをBVEというPCゲームで遊べるように TinyGo でキーボード化したものとのことです。(参照元:Go Conference mini in Sendai 2026に参加しました) セッションだけではなく、いろいろ楽しめるコンテンツがたくさんありました! まとめ いままで、カンファレンススタッフやスポンサーブースの担当として参加することが多かったのですが、今回スピーカーとしての参加ということで、自分の発表がおわるまではそわそわしたり感想を直接いただいたりと、いつもとは違った経験ができてよかったです! また、今回4名の登壇者がいたので、各セッションの応援に行ったりと終始カンファレンスを楽しむことができました。 そして、なんといっても地方カンファレンスというのはいいですね!その土地ならではのごはんも堪能して大満足です。 最後に、「Go Conference mini in Sendai 2026」の企画運営、おつかれさま & ありがとうございました!また、次回を楽しみにしています!
MySQLに高い互換性を持つデータベースのTiDBには、古いデータを自動的に削除するTTL(Time to Live)の機能があります。本記事では、これを活用しコスト削減および障害耐性の向上を実現した事例を紹介します 背景 メルカリでは、商品に対してコメントを付与することができ、値引きの依頼だったり、さまざまなやり取りを行うことができます。このコメントが、例えば公序良俗に違反すると想定される場合、お客さまがそれを「通報」する機能がありますが、メルカリ内部でも自動的にこれらを検知する仕組みが古くから実装され、運用に利用されています。 これらのコメントのデータは、歴史的経緯から古くはMySQL、現在はTiDBに格納されています。 自動検知の機能自体は、事前定義されたあるキーワードがコメントに含まれるかどうか、いわゆる「全文検索」によって実現されています。 : 全文検索クエリのイメージ SELECT table1.* FROM table1 FORCE INDEX(idx_xxx_xxx) \ WHERE (column1 like ''%a%'' OR column1 like ''%b%'' … ) \ AND (column1 not like ''%c%'' OR column1 not like ''%d%'' … ) AND ... \ ORDER BY created DESC LIMIT 10; またこの機能を動作させるには、多くのコメントに対し高頻度で多数の全文検索を実行する必要があり、このような使い方はもちろんMySQLにはあまり向かないものです。しかし、TiDBに移行した際にさらにこの負荷の影響が目立つようになりました。 初期の対応 TiDB User Day 2025でResource Controlの導入について紹介(発表資料)しており、詳細は割愛しますが、対象のクエリにヒント句でResource Groupを割当て、Burstableでない制約を加えることにより、TiKVでの優先度の制御と、TiDBでの多少の負荷の平滑化を実現していました。 しかしこの負荷は、定期的に実行されかつ実行間隔が短いものでしたので、実行を完了しなければいけないタイムウィンドウの制約が比較的厳しいものであることから、平滑化による制御の効果が限定的でした。 また、Resource Controlによる制御は、TiDBへのOLTPのワークロードに関しては比較的うまくいくものの、TiKVに対しては主に優先度でしか制御できず、リソースの隔離が不十分であるため、アプローチを変えて対応することにしました。 検討したアプローチ 問題のあるコメントの検知に関しては、コメントのデータが追記式で、後から編集できない(更新されない)というデータの性質上、ある程度最近のデータにのみクエリを発行できれば良いため、最近のデータだけをうまく処理するという方針を軸に、主に下記の観点から2点のアプローチを主に比較しました 既存のTiDBからデータが簡易に生成できる コストが妥当 クライアントの変更が少ない a. TiDBからTiCDCへ流通させたデータにKSQLを発行する TiCDCダウンストリームをKSQL DBとして活用 TiDBにはTiCDCという、TiDBの実データを保存するTiKVの変更を取得し、ダウンストリームのデータベースに変更を伝播させるための、いわゆるCDC(Change Data Capture)の便利な機能があります。 MySQLであれば、例えばdebeziumといったソフトウェアがこれにあたり、TiCDCは変更イベントを以下のような様々なプロトコルで出力可能な柔軟さを持っています。 Avro Debezium Canal-JSON このTiCDCを使って、Debezium形式で下流のKafkaにデータを流せば、KSQLというストリームデータに対してSQLを発行できる機能を活用し、インメモリでクエリを発行できる可能性があります。 KSQLの発行先であるKSQL DBのレコードはイミュータブルになります。 また、KSQLは全文検索に特化した機能はありませんが、全文検索のクエリも発行できます。 KSQL自体はすでに社内で活用/運用実績がある状況でした。 b. TTLにより一部のデータを保持する専用のTiDB Clusterを作成する まずは、必要なテーブルのみを持つTiDB Clusterを作成します。 TiCDCのレプリケーションタスクはChangefeedと呼ばれますが、テーブルの絞り込みには、このChangefeedのTable Filterという機能が活用できます。本ケースでは、コメントを保存するテーブルのみを保持するClusterを用意することになります。 次に、このテーブルのデータを最新のデータのみ保持するようにします。 TiDBには、Spanner同様、古いデータを自動的に削除するTTLの機能があります。(MySQLにはありません)。この機能を活用すると最近のデータ以外は不要、という要件を簡易に達成できます。 TTLの機能をCluster単体で利用した場合は、特に追加の考慮は必要ありませんが、TiCDCを経由したデータに対して利用した場合は、追加の考慮が必要です。 すなわち、上流には存在する一方、下流には(TTLで削除されたため)存在しない、というデータが発生し、全てのイベントを伝播した場合、例えば、下流に存在しないデータに対しての削除が伝播し、エラーになるといったことが発生する可能性に対処する必要があります。 この問題に対しては、ChangefeedにEvent Filterという機能があり、特定のDELETEやUPDATEを無視する、といった設定が可能です。 : 上記のデータセットでEvent Filterがないとエラーになるクエリの例 DELETE FROM comments WHERE id=1 /* すでに下流にはデータがない */; UPDATE comments SET comment="good morning" where id=2 /* すでに下流にはデータがない */; さらに、TTLパラメータにより、保持するデータがメモリに収まるようコントロールできれば、実質的に多くのケースでインメモリに近い形で処理可能です。 なお、メルカリではTiDBのマネージドサービスである、TiDB Cloudを利用しており、ここで紹介したChangefeedのTable Filterおよび Event FilterはTiDB Cloudでも利用可能な機能です。 手法の決定 主にクライアントへの変更要求が少ないことから、TTLを用いる手法を選択することにしました。 万が一問題が発生した場合の切戻し、および暫定対処に関しても、クエリの発行先を、元のClusterに戻し、元のClusterのリソースを増やしてあげれば良いだけである、という便利さもあります。 ここで改めて、隔離した一部のデータセットを作成し、そこにクエリを発行するというアプローチを取った利点を確認します。 利点1: 構築が容易/短期で可能 特筆すべきこととして、TTLが十分短ければ、初期構築が非常に迅速、かつ容易に行えることが言えます。 既存のデータベースからある一部のデータを保持する別のデータベースを作成する際に、最も手間となるのは、既存のデータベースからのエクスポート/リストアから始まる初期データの作成ではないかと思います。 しかし、Event Filterで所定の構成をすれば、TTLが短い場合、下流に空のデータベースを用意し、その時点からデータの複製を始めれば、保持すべきTTLを待つだけでデータの用意が完了します。 利点2: 障害単位が独立し個別調整の余地が出る リソースを完全に独立させた専用のClusterを用意することで、障害の単位も分離でき、さらに、決まった使われ方をする独立したClusterに対して、積極的にリソースを活用するようチューニングが可能です。 もちろん、設定が同一ではない、非対称なものが生まれるのは運用における認知負荷が上がるという問題はあり、構成変更により得られる効果とのバランスをチームで納得のいくものにする必要があります。 結果 変更前後のTiKVの負荷について示します。 A) 変更前のTiKVの負荷 B) 変更後のTiKVの負荷(ピーキーな負荷が隔離され利用量が平滑化された) C) 新規TiKV with TTLの負荷 このように負荷は別のClusterに分離され、上位のスペックで運用していた既存のClusterの負荷が減少し、かつ安定したことから、既存のClusterのノード数を減らすことができました。 新規に別のClusterを作成しているものの、トータルコストとしても削減でき、障害に関するリスクも分離できました。 追記:BのグラフのCPU利用率はAより高くなっていますが、BのグラフをTiKVノードを減らした後に作成したためです。負荷が平滑化され、上下の激しい負荷が別Clusterに分離されたという文脈でご参照ください まとめ 本記事では、Changefeedのフィルター機能(Table Filter, Event Filter)、TiDBのTTLを活用し、一部のデータに対して、高頻度で発行される全文検索の負荷を、独立したClusterに分離することでコスト削減をした事例を紹介しました。 このアプローチでは、アプリケーションの変更および、構築、共に少ない変更ですみ、結果として早期に移行を完了することができました。 メルカリでは、TiDBを利用していますが、初期導入のフェーズから、この記事のような継続的改善のフェーズに入っています。現在、様々な取り組みをしており、近々別の記事で紹介していく予定です。 関連/補足 本論とは少しそれる、細かな検討事項について補足します。 TiDBの全文検索については、一部のリージョン、一部のサービスプロバイダ、一部のサービス提供形態で、試験的に利用できるものが提供されています。現状では選択肢には入りませんでした。また、商品に対するコメントについては、ある商品のコメントを表示する、コメントを書き込むというのが主な用途であり、そういった点からも元データとしてはこのデータのみを全文検索エンジンに格納するのは相応しくないと考えています。 https://docs.pingcap.com/tidbcloud/vector-search-full-text-search-sql TiKV MVCC In-Memory Engineについては、B案の一部として試しました。今回のケースが、In Memory Engineに向いたケースではなく、「構成変更により得られる効果とのバランス」が悪かったため、今回は採用には至りませんでした。
こんにちは、メルカリCTOの木村(@kimuras)です。 今年は、ついに開催されたメルカリ主催のエンジニアイベント 「mercari GEARS 2025」 にて、Keynoteを担当しました。本記事では、その内容を改めて文章としてまとめ、皆さんにお伝えしたいと思います。メルカリがAI-Native Companyになることを宣言して以来、エンジニアリング組織に限らず、全社としてどのようにAI-Native化を進めていくのか、その指針についてお話しします。 ご存じのとおり、AI活用による生産性向上は、まだまだ不確実性の高い領域です。さらに新たな技術が次々と登場することで、今日述べる内容も、近い将来には更新が必要になるかもしれません。しかし、だからこそ「現時点で我々がどこを目指し、どのような方向性で進んでいるのか」を言語化し、共有しておくことは、この変革期において非常に重要だと考えています。 AI-Native化の推進は決して簡単ではありませんが、本記事の内容が、同じように挑戦されている皆様の一助になれば幸いです。 目次 全社をあげたAI-Native化とは 現状: 確かな変化、しかし「まだ道半ば」 発想の転換:「人間前提」から「AI前提」へ AI-Native化の前提条件:Knowledge Management AI-Centricな開発:Agent-Spec Driven Development(ASDD) 全社的なAI化:AI Task Force 未来のビジョン:AI化の先にあるもの 今後に向けて 全社をあげたAI-Native化 「プロダクト、仕事のやり方、組織すべてをAI中心に再構築し、AIの進化を最大限に活用することで、これまでにない成果を目指す」 (参考: 新年度のテーマは「Back to Startup」と「AI-Native」。12周年を迎えたメルカリが目指すこれからの姿 | mercan (メルカン)) これは、社長である山田からの強い決意表明でした。 AIへの対応が遅れれば、競争環境の中で後れを取るリスクは避けられません。同時に、私たち一人ひとりの成長においても、大きな変化が求められています。AIによる生産性向上は、従来の評価基準では実現が難しかった新たな価値創造を可能にします。変化を柔軟に受け入れ、成長し続けられる人にとって、これは自身の価値を飛躍的に引き上げる絶好の機会でもあります。 そして、AI-Nativeな働き方を全社に浸透させるということは、単にAIツールを一律に導入することではありません。これまでの働き方をゼロベースで見直し、業務そのものをAIを前提とした形へ抜本的に進化させていくことが重要だと考えています。 現状: 確かな変化、しかし「まだ道半ば」 メルカリではすでに、社員の95%がAIツールを活用し、コード生成の約70%をAIが担うまでに進化しています。開発スピードも前年比で64%向上しました。数字だけを見れば、AIは確実に浸透しているように思えるかもしれません。 しかし、私たちは現在の状態を“AI-Native”だとは捉えていません。むしろ、ここからこそ本当の変革が始まると考えています。 最近よく議論されているように、「本当に生産性は飛躍的に向上したのか?」という問いに対して、私自身もまだ改善の余地は大きく残っていると感じています。そして、コーディングの生産性が向上しただけでは、組織全体の生産性は十分には高まりません。同じくらい重要なのは、コーディング以外の業務プロセスも含めて、AI前提の働き方に転換していくことです。 発想の転換:「人間前提」から「AI前提」へ 前述のとおり、AIを前提とした働き方を実現するためには、単にツールを導入するだけでは不十分です。私たちは、仕事に対する考え方そのものを根本から塗り替える必要があると考えています。 これまで私たちが直面してきた限界は、「人が行うこと」を前提に組み立てられたプロセスや仕組みによって生じていました。AI-Nativeな働き方とは、そうした前提条件そのものを問い直し、業務を“人が実行するタスク”から“AIと人が最適に協働するタスク”へと再設計していくことにほかなりません。 これまでの限界 これまでの仕事や組織のデザインは、多くが“人間前提”で設計されてきました。 つまり、人間の時間・集中力・処理能力といった制約を起点に、「その条件の中でどう成果を最大化するか」を軸に最適化されてきたのです。 たとえば、1日8時間労働、週休2日、1チーム8名構成、週次の定例会議、こうした組織の“当たり前”はすべて、人間の能力と限界を前提として形づくられてきました。現在も一般的な働き方であり、私たち自身もその枠組みの中で働いています。 しかし、AIを前提とした働き方とは本来どういう姿なのか。 そこを抜本的に考え直し、AI活用を進めながら、これまでの常識を一つひとつ疑い、新しい標準をつくっていく必要があります。 たとえば、 1人が複数ロールを効率的にこなせるようになれば、チームは8人よりも少ない人数のほうが、むしろ高い成果を出せるかもしれません。 AIによって単純作業が減り、人はより連続的で深い思考に集中できるようになると、脳の疲労はこれまで以上に高まる可能性があります。その場合、1日8時間労働ではなく、短時間で高集中の働き方のほうが高い生産性を生み出すことも考えられます。 このように、AI前提の働き方は、従来の“当たり前”を根本から再設計することを意味します。 AI前提の世界観 では、AIを前提とした働き方とは、どのような発想の転換なのでしょうか。 これまで新たなプロジェクトや事業を立ち上げる際には、前述のような「人間の限界」を前提として設計するのが一般的でした。特に人的リソースは最も大きな制約であり、どれだけ人を確保できるかが計画の起点になっていました。 しかし、AIが前提となる世界では、この出発点が根本的に変わります。 仕事や組織のあり方を、“人の限界から逆算する”のではなく、ビジョンや提供したい価値から逆算して設計することが重要になります。 すなわち、 「何を実現し、どんな価値をお客さまに届けたいのか」 という問いからスタートし、その実現のためにAIをワークフローへ組み込み、最適な仕組み・チーム構成・役割・データのあり方などを再設計していくという考え方です。AIはミッションを前進させる創造的なパートナーであり、チームの一員として存在します。 これこそが、私たちが目指す“AI-Native”の世界です。 AI-Native化の前提条件:Knowledge Management なぜKnowledge Managementが重要なのか 私たちもAI Coding Assistantをはじめ、さまざまなAIツールを導入してきましたが、当初想定していたほど生産性が向上しないという課題がありました。 その背景には、AI化を進める前に取り組むべき、より本質的な要素が欠けていたことがあります。それが、適切な情報、すなわちコンテクストの整備です。 特にAI Agentは、十分なコンテクストが与えられなければ期待どおりに機能しません。単純な指示だけでは精度の高い成果物を生成できず、手直しが何度も発生し、かえって時間がかかってしまうことすらあります。最終的には品質が低いアウトプットになってしまうケースも少なくありません。だからこそ、タスクに関わる背景知識、関連するレギュレーション、過去の意思決定プロセスや議論のログなどをコンテクストとして適切に提供することが不可欠です。 そうすることでAI Agentは、状況や歴史的文脈、そしてAI Agent利用者が求めるゴールを正確に理解し、より期待に近い成果物を生成できるようになります。結果として、AIを効果的かつ継続的に活用できる環境が実現します。 どのようなコンテクストが必要か 必要となるコンテクストは、AI Agentに解かせたい課題によって大きく異なります。したがって、「この情報さえあればよい」という万能のテンプレートは存在しません。ただし、コーディングを例に挙げると、以下のような情報は特に有効です。 Microservicesの依存関係 コーディング規約 設計書 関連する開発における過去の意思決定ログ コードレビューでの議論内容 これらのコンテクストをAI Agentに提供することで、依存関係を正しく考慮しながら、これまでの方針や意思決定と整合性のある設計やコード生成が可能になります。 最も重要なコンテクスト:意思決定情報 私たちが最も重要だと考えるコンテクストの一つが、意思決定情報です。 意思決定に関するコンテクストは本来きわめて重要ですが、実際には十分に整理されていないケースが多く見られます。SlackやGitHub、ミーティングメモなど複数のツールに議論が散在し、必要な情報を必要なときに取り出すことが困難になっているのが現状です。会議で決まった内容が適切に共有されず、後から意思決定の経緯をたどれない場合も少なくありません。 当社でもDesign Docにアーキテクチャや意思決定事項をまとめていますが、その判断に至るまでの議論は依然としてさまざまなツールに分散しています。その結果、「なぜその判断に至ったのか」という重要な背景が抜け落ちるリスクがあり、散在した情報を後から統合するには非常に大きな手間がかかります。 しかし、こうした意思決定情報はプロジェクトを適切に進めるうえで不可欠であり、AI Agentにとっても極めて重要なコンテクストです。AI Agentは、今後コーディングだけでなく、仕様書作成、デザイン、QA、リーガルチェック、セキュリティチェックなど、より広範な領域で活用されるようになります。その際に必要なのは、次のような情報です。 このプロジェクトの目的・狙いは何か 何が許容でき、何が許容できないのか 過去にどのような議論があり、何を重視して意思決定してきたのか AI Agentがこうした背景を理解しているかどうかで、アウトプットの品質は大きく変わります。適切な意思決定コンテクストが提供されれば、AI Agentは状況を正確に把握し、より高品質で一貫性のある成果を生成できるようになります。 この後に紹介するAI Agent Spec Driven(ASDD)でもSpecを決定した議論を録音しておき、それをコンテクストとしてSpecに提供することで、より高性能にAI Agentを活用できると紹介されています。 最初のSpecだけでは表現しきれていなかった背景やニュアンスが、レビュー時の対話には多く含まれています。この文字起こしログをCoding Agentに読ませてSpecを改善させることで、当初の記述では表現できていなかった文脈や設計の抜け漏れを補足し、より精度の高いSpecへと昇華させることができます。 (参考: Agent Specで小さく素早く回すメルカリモバイル開発現場) Knowledge Managementへの取り組み 現実には、こうした情報は十分に整理されておらず、多くが暗黙知のまま埋もれてしまっています。そこで私たちは、議論の記録や意思決定をできる限り構造化し、いつでもコンテクストとして活用できる状態にするため、Knowledge Managementの強化に取り組んでいます。 AIを前提とした働き方をつくると同時に、情報管理のあり方そのものを抜本的に見直しているところです。これが実現すれば、トップダウンの意思決定と、現場からの学びや提案といったボトムアップの動きがよりスムーズにつながり、全社としての意思決定速度も大きく向上します。 こうしたAI-Readable(本稿では、データが整備されており、AIエージェントが容易にコンテクストとして参照できる状態のデータを「AI-Readable」と定義します)なデータマネジメントを実現するため、私たちは現在、Notionに情報を極力集約する取り組みを進めています。 目的は二つあります。 散在している情報を一つの文書管理基盤に集約し、必要なときにコンテクストを簡単に取り出せる状態にすること 情報の残し方そのものを再設計し、AIによる議事録作成の標準化や、多様な情報資産の構造化などを通じて、AIが扱いやすいナレッジ体系をゼロから構築すること これにより、人間にとってもAI Agentにとっても理解しやすく再利用しやすい組織の記憶をつくることを目指しています。 この方向性を元に、現在、メルカリはNotionをCentral Knowledge Baseとして位置付け、ナレッジの中央管理型への移行を進めています。本記事の主旨と離れるので、細かくは記載しませんが、ツール選定に関しては、フロー情報(議事録など、メンテしない情報)とストック情報の両方に強いという点や、AIとの親和性の高さが大きなポイントでした (参考: メルカリが、AI時代にナレッジマネジメントに投資したわけ) AI-Centricな開発:Agent-Spec Driven Development(ASDD) 現状の課題 私たちはすでに、ほぼ100%のエンジニアがAI Coding Assistantを活用しており、コード生成量も60%以上増加しています。しかし、冒頭でも述べたように、これはあくまでスタート地点にすぎません。 本質的にAIを活用するためには、開発プロセス全体をゼロベースで見直し、AIを前提とした開発プロセスへと再構築する必要があります。AI Coding Assistantの活用を進める中で、利用率は大きく向上したものの、以下のような課題が明らかになりました。 プロンプト品質のばらつき レギュレーションがないため、人によってプロンプトの質が大きく異なり、短時間で高品質なコードを生成できるケースがある一方、指示の反復が必要で、結果としてAIなしより遅くなるケースもありました。 コンテクスト収集の困難さ プロンプトの書き方を理解していても、必要なコンテクストを正確に収集できず、適切な情報量をAIに与えられないことが多く発生しました。 生成コード品質のばらつき 一部ではレビューしやすい高品質なコードが生成される一方で、品質が低くレビューが困難だったり、バグの温床になりやすいコードが出力されるケースも見られました。 使用ツールのばらつき 当初はCursor を全社導入しましたが、その後Claude Codeなど新たなCoding Assistantが登場し、エンジニアによって利用ツールが異なる状況が生まれました。そのため、ベストプラクティスを集約し、共有することが難しくなりました。 これらの課題の根底にあるのは、AIの使い方に関する共通レギュレーションが存在しないことです。そのため、チームや個人によってアウトプットの品質が大きくばらついてしまう状況が生まれていました。 目指す開発プロセス 私たちが目指す開発プロセスは、先に挙げた課題を解決したうえで、すべての開発プロセスにAIのポテンシャルが最大限活かされている状態です。 具体的には、次のような姿を想定しています。 コーディングだけでなく、スペック作成、デザイン、コードレビュー、QA/テストなど、あらゆる工程でAI Agentが活用され、開発全体が最適化されていること 各プロセスに必要なコンテクストが適切に整理・提供され、AI Agentが効率よくタスクを遂行できること 前述の課題が解消され、統一された開発プロセスとして標準化され、すべてのエンジニアが安定してAgentic Codingを実践できること これらを実現することで、特定のツールに依存しない、共通化されたAgentic Codingのワークフローが全エンジニアに浸透している状態をゴールとしています。 Doubleプロジェクト:ASDDの実現 私たちが現在進めているのが、DoubleプロジェクトにおけるAgent-Spec Driven Development(ASDD)です。「Double」という名称は、生産性を“2倍にする”というプロジェクトの目的に由来しています。 ASDDは、AI Agentに必要なコンテクストを正しく与え、適切なプロンプトで誰でも開発を進められるようにするための、AIフレンドリーな仕様フォーマットを整備する取り組みです。 そしてこれは、単なる仕様書ではありません。AI Agentやエンジニアが実際にコードを書けるレベルまで落とし込むための、実装指向の設計書を作成するためのテンプレートです。抽象的なアーキテクチャ設計ではなく、既存のコードベースやプロジェクトの流儀に完全にフィットした形で、以下のような具体要素を明確に定義します。 API定義 データモデル DBスキーマ 処理フロー テストシナリオ 具体的な実装手順(TODO) マイクロサービス間の依存関係 このテンプレートの目的は、「誰が・どのタスクを・どのファイルで・どのコードスタイルで実装するか」を明確にし、誰がAI Agentを使っても、同じ品質・同じ規約で実装できる状態をつくることです。言い換えれば、ASDDはチーム全体の実装プロセスを“再現可能な工程”にするための詳細仕様テンプレートです。 また、AI Agentが正確にコーディングできるよう、タスクの粒度を小さく保つなどの工夫もテンプレートに組み込んでいます。 (参考: Agent Specで小さく素早く回すメルカリモバイル開発現場) ASDDの活用範囲 ASDDのポイントは、コーディングだけのSpecではないということです。このSpecは全プロセスでAgentをフルに活用するためのベースとなります。 開発領域: バックエンド開発 フロントエンド開発 モバイル開発 品質保証: QAのテストケース自動生成 AI Review その他の領域: カスタマーサポートのスペック調査 リスクマネジメント コンプライアンスチェック PJ Aurora:UI自動生成 さらに、このAgent Specは、UIを自動生成するためのAI Agentの仕様としても活用されます。UI向けのAI Agentは「PJ Aurora」というプロジェクトとして開発が進められています。 Auroraは、プロンプトを与えるだけで、内製のデザインシステムを活用しながらUIを自動生成できる、非常に先進的な取り組みです。デザインの一貫性を保ちつつ、UI作成にかかる工数を大幅に削減できる点が大きな特徴です。 また本プロジェクトでは、生成されたUIが社内のデザイン規約に準拠しているかを自動でチェックするAI Agentの開発も進
メルペイ インターンでの挑戦と学び:EGP Cardsと向き合った3ヶ月間 こんにちは。メルペイのGrowth Platformでフロントエンド・エンジニアとしてインターンをしている@Yusaku(宮田 優作)です。 この記事は、Merpay & Mercoin Advent Calendar 2025 の25日目の記事です。 私は2025年10月からインターンを開始し、今月で3ヶ月目になりました(図1)。 この記事では、インターン期間中に取り組んだタスクと得た学びについて紹介します。 図1:オフィスで撮影した私の写真 チームについて 私が所属する Growth Platform Frontend チームは、Engagement Platform(通称EGP)という社内向けマーケティングツールを開発しています。 このツールを使うことで、マーケターや プロジェクトマネージャーは、ポイントやクーポンなどのインセンティブ配布、ランディングページ(LP)の作成・公開、キャンペーン作成といった CRM業務をコーディング不要で簡単に行うことができます(図2)。 図2:EGPのノーコードエディタ(EGP Content) 今回のインターンではEGP Cardsという機能の向上に取り組みました。EGP Cardsは、Web・iOS・Androidのクロスプラットフォームで利用できるカード型のコンポーネントを作成・公開する機能です。 EGP Cardsは、いわゆるWebページのエディタ機能(EGP Pages)とは異なり、サーバがUIの構造を返却するというServer Driven UIアーキテクチャを採用しています。エディタ上で作成されたコンポーネントのコンテンツはJSONファイルとして保存され、各プラットフォームで共通のUIとして描画されます(図3)。 Server Driven UIとEGP Cardsのアーキテクチャについては、同じくGrowth Platformチームの@togamiさん、@Stefanさんの記事をそれぞれご覧ください。 WYSIWYGウェブページビルダーを支える技術とServer Driven UIへの拡張 Supercharging User Engagement: How Mercari is Using Server-Driven UI to Reduce Time-to-Market 図3:EGP Cardsの編集画面 タスク1 – Dry Run for EGP Cards タスク概要:Dry Runとは? Dry Runとは、変数を設定し、そこにデータを挿入することで、状態をエミュレートできる機能のことです。これにより、API呼び出しを記述したり実機で動作確認を行ったりする前に、コンテンツの挙動を確認することができます。 このタスクでは、EGP CardsでDry Run機能を利用できるようにする実装を行いました(図4)。 図4:今回実装した、EGP CardsのDry Run機能 動作の仕組み Dry Run機能は、以下の流れで動作します。 利用者がDry Runを有効化し、フィールドにモックデータを入力する エディタが構造ツリーを再帰的に走査し、動的にJavaScriptのコードを評価して変数を値に置換する 置換された値がキャンバス上に表示される 実装の流れ 以下の流れで実装を進めました。 EGP Pagesに既に実装されていたDry Run機能について、コードリーディングやロギングを行い、実装ロジックを理解する EGP Cards特有の仕様を理解した上で、同様に利用できるDry Run機能を実装する コーディングの際には、EGP PagesとEGP Cardsで共通利用できそうな処理を探し、適度にファイルを切り出すことで、可読性・保守性を意識した実装を心がけました。 タスク2 – Content Agent Improvement for EGP Card 背景:Content Agentの課題 EGPのノーコードエディタ(EGP Content)は、CardsのほかにもPagesやE-mailsなど、複数の種類のコンテンツを扱うことができます。 また、EGP ContentにはAIエージェント(通称 Content Agent)が導入されており、対話を通じてコンテンツの要約や書き換えを行うことができます(図5)。 一方で、当時のContent Agentは、コンテンツ種別ごとのエディタ仕様を十分に理解していないという課題がありました。その結果、UIが崩れたコンテンツを生成してしまい、利用者の期待通りのアウトプットを提供できない可能性がありました。 図5:Content Agentの会話処理パイプライン 実装の流れ この課題を解決するため、以下の流れで実装を進めました。 EGP Cardsの仕様やデータ構造を記述したプロンプトを作成する 作成したプロンプトを、Content AgentのAgent Layerで条件付きで注入する EGP Cardsには、メディアクエリに対応していないことや、すべての要素がFlexで構成されていることなど、いくつかの制約があります。これらの制約や期待される出力をプロンプトに明示することで、Content AgentがCardsに適したコンテンツを生成できるようにしました(図6)。 図6:EGP CardsでContent Agentを利用する様子 学んだこと チーム開発におけるアウトプットの出し方を学んだこと Dry Run 機能の実装を通じて、チーム開発におけるアウトプットの出し方について多くの学びがありました。実装の正しさや機能の完成度だけでなく、Pull Request(PR)の切り方やレビューの受け方が、チーム全体の開発効率や生産性に大きく影響することを実感しました。 具体的には、バグ修正やリファクタリングであっても、PRのサイズが大きくなりすぎる場合やタスクのスコープ外に及ぶ場合には、別のPRとして切り出すことでレビューコストを抑えられることを学びました。また、コードやPRコメントには、なぜその実装にしたのか、どのような選択肢があり、何をしない判断をしたのかといった実装意図を明示することが重要です。これにより、レビュワーとの認識齟齬を防ぎ、建設的なレビューにつながると感じました。 レビューを受けた際にも、指摘内容をすぐに修正として反映するのではなく、まずはレビュワーの意図を正しく理解することが重要です。場合によっては背景や前提条件をすり合わせた上で議論することで、より良い設計や実装にたどり着けることを学びました。これらの経験を通じて、個人としてコードを書く力だけでなく、チームで価値を届けるためのコミュニケーションや姿勢の重要性を強く認識しました。 メルカリカルチャーを実体験として理解できたこと メルカリでは、情報の透明性やフラットな意思疎通によって、個人に大きな裁量が与えられていると言われることが多いです。実際にインターンを通じて、その点を強く実感しました。一方で、個人的に特に印象に残ったのは、英語を前提としたグローバルな開発環境です。 これまで参加してきたインターンはすべて日本語環境だったため、ドキュメントやコミュニケーション、議論の場がすべて英語になる経験は非常に新鮮でした。グローバルなチームである以上、英語でのスムーズな意思疎通が求められることは理解していましたが、実際に業務の中でそれを実践し、議論や開発を進められたことに大きなやりがいを感じました。 実際の業務では、英語で書かれたREADMEや仕様書を読み込んだ上でPull Requestを作成し、設計意図や懸念点を英語で説明・議論しました。認識の齟齬を防ぐため、必要に応じて日本語での補足も行いながら、主体的にコミュニケーションを取ることを意識しました。この経験を通じて、メルカリのカルチャーは単なるスローガンではなく、日々の業務に根付いたものだと感じました。 技術的挑戦を通じて、学びの広がりを再認識できたこと これまで私は、フロントエンドを主な技術領域として、インターンや個人開発に取り組んできました。そのため、フロントエンド領域における学びは、徐々に頭打ちになりつつあるのではないかと感じていました。 しかし、EGPというツールに触れたことで、その考えは大きく変わりました。EGPは非常にインタラクティブでリッチなUIを持つだけでなく、その裏側では、ノーコードによるコンテンツの作成・配信の仕組みや、安全かつ効率的にAI Agentとやりとりを行うための設計など、複雑で奥深いロジックが支えられていることを知りました。 タスクでは、ある程度抽象度のある状態で要件を受け取り、自分で具体的な実装タスクへ分解した上で設計・実装を進めました。また、EGPの使い方をキャッチアップしている最中に、イメージのプレビュー機能があることで利用者体験が向上するのではないかといった改善提案も行いました。 さらに、Content Agentの改善では、今回のCards向け実装に閉じることなく、将来的にPagesやE-mailsなど他のContent typeにも展開しやすいよう、Typeごとにプロンプトを切り出す設計とし、可読性や拡張性を意識しました。エンジニアがプロダクトの将来を見据えて設計・実装することが、利用者体験の向上や業務効率化につながり、結果として事業価値に直結する点は、メルカリならではの魅力だと感じています。 おわりに 今回のインターンでは、EGP Cardsという機能の向上に取り組みました。インターンを通じて、技術的なスキルだけでなく、プロダクトの価値やチームとの関わり方を含めてエンジニアリングに向き合う姿勢を学ぶことができました。 実務を通して得たこれらの学びを、今後の開発や自身の成長につなげていきたいと考えています。最後までお読みいただきありがとうございました。