Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Persisting Massive Data with Rails ~Designing f...

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on. →

Persisting Massive Data with Rails ~Designing for 60 billion rows, and fighting the bottlenecks~ Railsで挑む大量データ永続化 〜600億件を永続化するための設計とボトルネックとの戦い〜

Kaigi on Rails 2026 登壇資料

Avatar for Shoichi Ochi

Shoichi Ochi

October 16, 2026

More Decks by Shoichi Ochi

Other Decks in Technology

Transcript

  1. Persisting Massive Data with Rails Designing for 60 billion rows,

    and fighting the bottlenecks Shoichi Ochi (@occhi) Kaigi on Rails 2026 2026-10-17 Railsで挑む大量データ永続化 〜600億件を永続化するための設計とボトルネックとの戦い〜
  2. Once That Is Settled, How Do We Think About the

    Design? 扱い方が決まったら、設計はどう考えればいいか? 4
  3. About Me Shoichi Ochi SmartBank, Inc. Software Engineer Career: ITANDI,

    Inc. → GMO Beauty, Inc. → SmartBank, Inc. Hobby: Weight training @ochi11181101 @sho-work 6
  4. 77

  5. Even Complex Technical Problems Can Be Solved by Steadily Sorting

    Out Requirements and Constraints and Identifying and Analyzing Bottlenecks 複雑な課題でも、特別な解決策が要るとは限らない。要件・制約を整理し、ボトルネックの特定・分析を積み重 ねれば解決できる 8
  6. Project Overview This project Goal Use phone numbers' contract status

    for things like fraud prevention Requirements Real-time reads / monthly import within a day Constraints Limited extra budget and engineering effort Bottlenecks ? (we check with a thought experiment next) 目的は、電話番号の契約状況を不正利用対策などに使うこと。要件は「リアルタイムに読む」「毎月 1日以内に 取り込む」。予算も工数も限られる 11
  7. The Phone Number Data • • Preconditions ◦ Every month,

    a vendor uploads compressed CSV files to S3 Delivered CSV characteristics ◦ More than 250 million phone numbers ◦ Holds up to 20+ years of contract status ◦ Contains the date and status at each contract-status change ベンダーから毎月、圧縮 CSVが S3 に届く。電話番号は 2.5億件超で、最大 20年以上の契約状況が入っている 12
  8. The Delivered CSV Has One Row per Phone Number (~250M

    Rows) 納品CSVは1電話番号 = 1行。最新の状態と、状態が変わった時点の履歴を横に持っていて、全部で約 2.5億 行 13
  9. Storing Every Month's State Means 60 Billion Rows in Theory

    毎月の状態を 1行ずつ持つと、変化のない月も 1行ずつ。2.5億番号 × 240か月 = 理論上600億行 14
  10. Do We Need to Store 60 Billion Rows? Consider cases

    that need data for months where the contract status did not change 本当に600億件も保存する必要がある?契約状況が変わっていない月のデータが要るケースを考えてみる 15
  11. Cases Where We Also Want to Search Months with No

    Change in Contract Status 納品データにあるのは、状態が変わった時点だけ。例えば、 2025年8月に契約中だった電話番号をすべて取 りたいとする 16
  12. Cases Where We Also Want to Search Months with No

    Change in Contract Status 2025年8月のレコードはどれにもない。でも前後の変化点を見れば、その時点の状態は分かる 17
  13. Cases Where We Also Want to Search Months with No

    Change in Contract Status 2つ目と3つ目が契約中。こうした変化点の間の月も検索するなら、月ごとの状態も保存しておくべき? 19
  14. Steps of the Thought Experiment Back-of-the-envelope estimation 1. Estimate the

    rows written per second 2. Measure a reference value on our in-house DB 3. From [1.] and [2.], work out what system setup is needed 作り込む前に概算で桁をつかむ( Back-of-the-envelope estimation)。3つの手順で考える https://highscalability.com/google-pro-tip-use-back-of-the-envelope-calculations-to-choo/ 22
  15. 1. Estimate the Rows per Second Needed to Write 60

    Billion Rows in a Day 600億件を1日(86,400秒)で書くには、約 69.4万行/秒が必要 23
  16. 2. Measure How Fast Our Existing In-House DB Can Write

    • • • DB: Amazon Aurora MySQL Measured with parallel Bulk Upsert Sustained throughput (saturated with more parallelism): about 10,000 – 50,000 rows/sec 既存の Aurora MySQL に、並列 Bulk Upsert で限界まで書き込んで計測。持続できるのは約 1万〜5万行/ 秒 24
  17. With One Writer, a Full Build Does Not Fit in

    a Day 1 Writer の限界は約1万〜5万行/秒。必要な約69.4万行/秒には全然届かない 25
  18. Converted at the Aurora MySQL Performance We Just Measured, How

    Much Do We Need? いまの Aurora の性能で換算すると、十数〜数十 Writer 相当の構成が必要になる 26
  19. 3. Work Out What System Setup Reaches the Required Rows

    per Second 方向は2つ。A:分散DBなど別のデータストアに変える。 B:Aurora を分割する 27
  20. But Is That Setup Really Realistic? • • The additional

    infrastructure budget becomes huge ◦ A dozen to several dozen writers' worth (in Aurora terms), and 6–12 TB of storage Implementation effort and operations would be painful ◦ A. Change the data store: migrating the DB and new operational know-how ◦ B. Split Aurora: shard routing, re-runs, failure recovery, and operations でもどちらも、予算・容量・実装・運用がかなり大変。本当に現実的? 28
  21. Our Requirements, Constraints, and Bottlenecks This project Requirements Real-time reads

    / monthly import within a day Constraints Limited extra budget and engineering effort Bottlenecks Writing 60B rows a day is unrealistic ここまでを整理すると、ボトルネックは「 600億件を1日で書き込むのは非現実的」 31
  22. Project Overview, Again This project Goal Use phone number usage

    for things like fraud prevention Requirements Real-time reads / monthly import within a day Constraints Limited extra budget and engineering effort Bottlenecks Writing 60B rows a day is unrealistic プロジェクトの要件・制約・ボトルネックをおさらい 35
  23. Project Overview, Again This project Goal Use phone number usage

    for things like fraud prevention Requirements Real-time reads / monthly import within a day Constraints Limited extra budget and engineering effort Bottlenecks Writing 60B rows a day is unrealistic ここからは、要件のうち「履歴をリアルタイムに読み取る」に注目する 36
  24. (Recap) Cases Where We Also Want to Search Months with

    No Change in Contract Status (再掲)最初に考えたユースケースをもう一度。 2025年8月に契約中だった電話番号を、全部取りたい 38
  25. We Did Not Need to Search Across Phone Numbers by

    a Month with No Change in Contract Status でも読み取り要件を確かめると、契約状況が変わっていない期間のデータが要るユースケースはなかった 39
  26. Store Only the Changes • • Store the points where

    the state changed, not the state in every month This brings the number of rows down to a few billion 毎月の状態ではなく、状態が変わった時点だけを保存する。これで数十億件くらいまで減る 40
  27. Still Billions of Rows, So See If We Can Cut

    Further Does each history entry need to be its own record? まだ数十億件あるので、さらに削れないか。履歴の 1件1件を、別々のレコードにする必要はある? 41
  28. Think from the Unit We Want to Read • There

    is no requirement to search or aggregate across numbers by the history's month or status → We do not need to keep each history entry as its own record 履歴の年月やステータスで電話番号を横断して検索・集計する要件はない。だから履歴を 1件ずつ別レコード にしなくていい 42
  29. The Final Data Model: Keep the History as a List

    • • • One phone number = one document The history is held as a list The number of stored items stays at the number of phone numbers 1電話番号 = 1ドキュメントにして、履歴はリストで持つ。保存件数は電話番号の数まで減らせる Works well if you don't need indexes on month or status! There are trade-offs, though... 43
  30. At ~2,900 Records/sec, the Thought Experiment Says It Looks Realistic

    • Needed: ◦ 250M ÷ 86,400 sec ≈ ~2,900 records/sec • Thought experiment measurement: ◦ ~10K–50K records/sec (single writer) • Note: ◦ Each record is ~4× larger than in the thought experiment, so expect slightly lower write throughput. → It looks feasible within one writer 必要なのは約 2,900件/秒。思考実験の計測( 1万〜5万件/秒)から見て、 1 Writer でいけそう 46
  31. What We Know So Far: Down to 250M Records, and

    Now Realistic Bottlenecks This project After the data model Writing 60B records a day (~694K/sec) Writing 250M records a day (~2,900/sec) → Revising the data model made the bottleneck manageable データモデルを見直したことで、ボトルネックが現実的な範囲に収まった 47
  32. The Architecture We Are Aiming At (Deciding the Data Store)

    決めたいのは、この「 ?」の部分 50
  33. We Compare Data Stores Against These Requirements and Constraints Item

    Criterion Reads Fetch within 500 ms Writes Import 250M rows within a day Infra budget ~$1,000/month (extra) Build effort ~1 person-month Ops burden Keep monthly ops simple Requirement Constraint 比べる基準は要件と制約。要件は読み取り・書き込み、制約は予算・工数・運用 52
  34. Thinking About the Requirements • Reads: fetch within 500 ms

    • Writes: import 250M rows within a day (~2,900 rows/sec needed) 要件は2つ。500ms以内に読めること、 2.5億件を1日以内(約2,900件/秒)で取り込めること 54
  35. Checking Whether Each Candidate Meets the Read and Write Requirements

    * Details are in the appendix 3つの候補が、読み取り・書き込みの要件を満たせるかを調べた。詳しい根拠は Appendix に載せている 55
  36. Every Candidate Is Expected to Meet the Requirements Aurora MySQL

    DynamoDB Elasticsearch Reads ◯ ◯ ◯ Writes ◯ ◯ ◯ → All three are expected to meet them 要件は、3候補とも満たせる 56
  37. Thinking About the Constraints • Extra infrastructure budget: ~$1,000/month •

    Development effort: ~1 person-month • Operational overhead: keep monthly operations simple 制約は3つ。追加予算は月約 1,000ドル、開発工数は約 1人月、毎月の運用を複雑にしない 59
  38. Constraint (Cost): Every Candidate Fits the Budget Billed for Within

    budget? Aurora MySQL DynamoDB Elasticsearch Added storage and I/O Import or writes + storage VCUs + storage ◦ ◦ ◦ → Every candidate can meet the cost constraint コストは課金の仕方こそ違うけど、どれも予算内に収まりそう。ここでも決まらない 60
  39. Aurora MySQL: Built on Existing Assets, but Writer Load Needs

    Managing MySQL は既存の Rails・ActiveRecord 資産で作れる。でも既存サービスと同じ Writer に大量に書くので、 負荷の管理が要る 62
  40. Elasticsearch: Import Needs Design, and Ops Must Handle Bulk API

    Partial Failures Elasticsearch は運用実績あり。でも Bulk API はアトミックじゃないので、部分失敗を拾って再試行する仕組 みが要る https://www.rkenmi.com/posts/atomic-operations-with-elasticsearch 64
  41. Compared, Only Development Effort Differs Aurora MySQL DynamoDB Elasticsearch Development

    effort ◯ ✕ △ Operational overhead △ ✕ △ 並べてみると、運用はどれも手間がかかる。差がついたのは開発工数 65
  42. We Adopted Aurora MySQL, Prioritizing Development Effort • The three

    did not differ on requirements or cost • We chose MySQL in terms of development effort and operational overhead → The downside, higher write load on the DB, we decided to handle by running in low-traffic hours 3候補とも、要件・コストでは差がつかなかった。開発工数と運用負担から MySQL を選び、書き込み負荷はト ラフィックの少ない時間帯に逃がす 66
  43. What the Batch Design Must Handle: Requirements, Constraints, Bottlenecks This

    project This section Requirements Real-time reads / monthly import within a day 250M rows within a day / redo safely Constraints Limited extra budget and engineering effort — Bottlenecks Writing 250M records Aurora write speed and load → Design the batch from the requirements and bottlenecks バッチ設計で見るのは、要件( 1日以内・安全にやり直せる)とボトルネック(既存 Aurora への書き込み) 70
  44. Premises • Delivered to S3 as compressed CSV • Batches

    run as ECS tasks 前提は2つ。納品は S3 の圧縮CSVで、バッチは ECS タスクで動かす 71
  45. Two Questions Arise from the Requirements and Bottlenecks Q1 Processing

    time Q2 Recovery Question Can we import within a day? How do we handle re-runs? Driven by Requirement, bottleneck Requirement この流れに要件とボトルネックを当てると、問いが 2つ出てくる。1日以内に取り込めるか、リランをどうするか 73
  46. Q1. Can We Import Within a Day? Q1 Processing time

    Q2 Recovery Question Can we import within a day? How do we handle re-runs? Driven by Requirement, bottleneck Requirement まずは Q1、1日以内に取り込めるか 74
  47. Q1: To Import Within a Day, We Need Parallelism •

    Needed: 250M ÷ 86,400 sec ≈ ~2,900 rows/sec • Measured with serial writes: ~1,000 rows/sec 必要なのは約 2,900件/秒。でも直列で書くと約 1,000件/秒しか出ない 76
  48. Q1: To Import Within a Day, We Need Parallelism •

    Needed: 250M ÷ 86,400 sec ≈ ~2,900 rows/sec • Measured with serial writes: ~1,000 rows/sec → It cannot be done without parallel import (3 or more in parallel) 直列だと約70時間かかる。並列で取り込まないと間に合わない( 3並列以上) 77
  49. The Answer to Q1 Q1 Processing time Q2 Recovery Question

    Can we import within a day? How do we handle re-runs? Solution Bulk Upsert in parallel — Q1 の答えは、並列で Bulk Upsert すること 78
  50. Q2. How Do We Handle Re-runs? Q1 Processing time Q2

    Recovery Question Can we import within a day? How do we handle re-runs? Driven by Requirement, bottleneck Requirement 次は Q2、リランをどうするか 79
  51. On Re-runs, We Want Idempotency and to Skip What Is

    Already Done リランのときは、冪等にして、終わったところは飛ばしたい。性質は「冪等性」と「再開可能性」の 2つ https://www.oreilly.com/library/view/designing-data-intensive-applications/9781491903063/ 80
  52. Idempotence: With Bulk Upsert, the Same Input Gives the Same

    Result • Write with Bulk Upsert keyed on the phone number • Importing the same data again gives the same result 電話番号をキーに Bulk Upsert で書けば、同じデータを何度取り込んでも結果は変わらない 82
  53. Idempotence: With Bulk Upsert, the Same Input Gives the Same

    Result • Write with Bulk Upsert keyed on the phone number • Importing the same data again gives the same result → But reprocessing everything still costs time ただし、これだけだと止まるたびに全件をやり直すコストが残る 83
  54. Resumability We want to resume partway, so how do we

    do it? 次は再開可能性。途中から再開できるようにしたい 84
  55. Resumability: How to Record How Far We Got Human Readable

    Track by byte range Track by split file Not which row By files done どこまで終わったかの記録は、バイト位置だと何行目か分からないけど、分割ファイルなら何個終わったかで 分かる 85
  56. Resumability: How to Record How Far We Got Human Readable

    Track by byte range Track by split file △ ◯ → Make the split file the unit for resuming partway だから、分割ファイルを途中再開の単位にした 86
  57. Answers to the Two Questions Q1 Processing time Q2 Recovery

    Question Can we import within a day? How do we handle re-runs? Solution Bulk Upsert in parallel Idempotence: Bulk Upsert / Resumability: records per file 2つの問いの答えがそろった。並列 Bulk Upsert と、冪等性・ファイル単位の記録 87
  58. The Bottleneck in the Implementation: a Huge Compressed CSV 実装で向き合ったボトルネックは、解凍前で約

    3GBある巨大な圧縮 CSV。そのままメモリに載せると OOM に なる 89
  59. Handling the Huge Compressed CSV: Read It Sequentially by Streaming

    ファイル全体をメモリに載せず、少しずつ読み込んで解凍・分割する(ストリーミング) 90
  60. All Three Bottlenecks Happened in the Import Batch 答え合わせ:分割ファイル単位の取り込みが OOM

    に、並列取り込みが Deadlock と CPU 使用率の上昇に つながった。全部、取り込みバッチで起きた 93
  61. Containers Killed by OOM • • • What happened: ◦

    the import batch's ECS task ran out of memory... Analysis: ◦ we ran a memory profiler the hard way and found the cause Cause: ◦ we were loading the entire split CSV being imported into memory 取り込みバッチのメモリが足りなくなった。 Memory Profiler を地道に回したら、分割 CSVを丸ごとメモリに載 せていた 95
  62. How We Fixed the OOM • • Read the split

    files in chunks Peak memory no longer depends on the size of the file 分割ファイルをチャンクで読むようにしたら、ピークメモリがファイルサイズに依存しなくなった 96
  63. Parallel Bulk Upsert Produced a Flood of Deadlocks • •

    • What happened: ◦ Frequent deadlocks during bulk upserts Analysis: ◦ Investigated locks using performance_schema.data_locks Cause: ◦ Under REPEATABLE READ, X locks on the PRIMARY supremum blocked each other's insert intention locks, causing circular waits 並列 Bulk Upsert で Deadlock が大量発生。 data_locks を見ると、REPEATABLE READ で supremum に X ロックが残っていた Scan the QR code to access the GitHub repository with the reproduction script! 98
  64. How a Gap Lock Causes a Deadlock (One Example) 2つのトランザクションが末尾の

    gap にロックを持ったまま、お互いの INSERT を待って循環待ちになる https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html 99
  65. How We Fixed the Deadlocks • • • Lowered that

    Tx's isolation level: REPEATABLE READ → READ COMMITTED No more X lock on the supremum pseudo-record Downside: read skew can occur ◦ Given our requirements, this problem cannot happen, so we accepted it 分離レベルを READ COMMITTED に変えて、supremum のロックを取らないようにした。読み取りスキュー は今回の要件では起きないので許容 https://speakerdeck.com/occhi/prevent-concurrency-problems-a-practical-introduction-to-transactions?slide=101 100
  66. As Is (REPEATABLE READ): an X Lock Stays on the

    PRIMARY Supremum REPEATABLE READ だと、PRIMARY の supremum に X ロックが残る 102
  67. To Be (READ COMMITTED): the Lock on the Supremum Is

    Gone READ COMMITTED にすると、supremum へのロックが消える 103
  68. Rising CPU on the RDBMS • • What happened: ◦

    As write traffic from users grew, the Writer DB's CPU usage kept climbing What we found: ◦ We were Bulk Upserting all 250M rows every month ◦ Of each monthly import, fewer than 5 million rows actually need updating ユーザーの書き込みが増えるにつれて Writer の CPU が上がり続けた。毎月 2.5億件全部を書いていたが、 毎月の取り込みで実際に更新したいのは 500万件未満だった 105
  69. How We Fixed the Rising CPU Usage • • Approach:

    ◦ Narrowed down the Bulk Upsert targets ◦ Changed DB writes from 4 parallel to serial Details ◦ Narrowed down what to update inside the decompress-and-split batch, greatly reducing the amount written ▪ Change in update targets: from all 250M rows to fewer than 5 million ◦ This let the serial import finish within a day 分割バッチで更新対象を絞って、書き込む量を大幅に減らした。これで直列でも 1日以内に取り込めるように なった Happy to talk about how we chose what to update at the after-party! 106
  70. Solve Even Complex Problems by Going Back to the Basics

    複雑な課題も、基本に立ち返って解決しよう。要件・制約を整理し、ボトルネックを特定し、根拠をもって設計・ 実装を決める 108
  71. Appendix: Aurora MySQL: Reads and Writes Were Both Checked by

    Measurement Requirement Conclusion Evidence Reads Under 500ms Our measurement (index lookup) Writes ~10K–50K rows/sec per writer Our reference test → Expected to meet the requirements 付録:MySQL は読み取りを実測済み。書き込みも参考計測から要件を満たせる見込み 110
  72. Appendix: DynamoDB: Expected to Meet the Requirements, per the Official

    Docs Requirement Conclusion Evidence Reads One item by primary key (single-digit ms) AWS: GetItem / On-demand Writes Reference: 4,000 writes/sec AWS: On-demand capacity mode → Expected to meet them, per official sources 付録:DynamoDB は主キーで取れて、公式ドキュメント上は新しいテーブルでも毎秒 4,000件書ける 111
  73. Appendix: Elasticsearch: Expected to Meet the Requirements, per the Official

    Docs and Benchmark Requirement Conclusion Evidence Reads One doc by ID; even complex searches P50 36ms, P99 316ms Get API / Stress test Writes Reference: ~51,700 docs/sec Elastic Search Labs: Serverless stress test → Expected to meet them, per official sources 付録:Elasticsearch も ID で取れて、公式ベンチマークでは秒間 5万件超の取り込み(条件は今回と違う) 112
  74. Appendix: Cost Estimates at Selection Time (with Assumptions) • •

    • • • All are estimates from selection time. Their assumptions differ, so totals are not compared side by side Aurora MySQL: storage and I/O added to the existing cluster ◦ Expected to fit in $1,000/month; not yet calculated from measurements DynamoDB (update the existing table): ~$186/month for writes + storage ◦ Assuming items of 1KB or less, 1 WRU each, ~260M items DynamoDB (S3 Import): priced on uncompressed size; ~$1.37 came from the compressed size and is not settled Elasticsearch (Elastic Cloud Serverless): ~$105/month ◦ Search VCU ~$78, ingest ~$10, storage ~$17 (assumed VCU usage) 付録:選定時のコスト概算(前提条件つき) 113
  75. Appendix: Relational + JSON: Still One Row per Phone Number

    付録:Relational + JSON なら、1電話番号 = 1行のまま載る 115
  76. Appendix: Graph: It Can Represent This, but Uses None of

    Its Strengths 付録:Graph でも表現はできるが、強みを使わない 118
  77. Appendix: Premise: We Launch Batches in Two Ways, and Both

    Start ECS Tasks 付録:社内のバッチの起動方式は 2種類。どちらも ECS タスクを起動する 119
  78. Appendix: Airflow Controls the Order of the Two Batches 付録:Airflow

    で、分割バッチ → 取り込みバッチの順に実行する 121
  79. Appendix: We Parallelize with Multiple Threads Managing start and completion

    Multiple processes Multiple threads Needs a separate mechanism Stays within one process's thread pool → We chose threads: management stays inside the import batch 付録:並列化の手段は複数スレッドにした。管理が取り込みバッチの中で完結するから 122
  80. Appendix: We Set the Degree of Parallelism by Measuring Import

    Time and DB Load 3 parallel 4 parallel Import time ~22 hours ~14 hours DB CPU usage ~10% higher ~15% higher → Balancing import time and DB load, we chose 4 付録:並列度は、取り込み時間と DB 負荷を計測して 4並列にした 123