この記事は NTTドコモソリューションズ Advent Calendar 2025 12日目の記事です。
はじめに
NTTドコモソリューションズの黒田佳祐といいます、よろしくお願いします。2025年7月1日に社名がNTTコムウェアからNTTドコモソリューションズに変わりました。
ふだんはデータベースエンジニアとして、おもにオープンソースのデータベースである
PostgreSQL に関する技術調査・技術サポートに取り組んでおり、コミュニティ活動にも参加しています。
今年のベストゲームは「ELDEN RING NIGHTREIGN」とさせてください、隠者が好きです。
データベースを使う理由をあらためて考える
データベースエンジニアとして、そもそも、データベースを使う理由というのを考えることがあります。定番の回答としては「扱えるデータ量が多い」ということですが…実際どれだけのデータ量を扱えるものなのかは気になるところです。
今回はPostgreSQLの限界を見てみたくなったので、1テーブルのレコード数の限界に挑戦してみたいとおもいます。
目標は1兆(1,000,000,000,000)レコードとします!
PostgreSQLのレコード数の上限は決まっている?
まずは下調べです。PostgreSQLのマニュアルでは、「PostgreSQLの制限」として、さまざまな上限がまとめられています。
付録K PostgreSQLの制限 - PostgreSQL文書
テーブル当たりの行が4,294,967,295ページに収まるタプルの数 として定義されていますね。
PostgreSQLは、データをブロック(ページともいいます)単位で扱い、デフォルトでは8192バイト(8kB)となっています。変更は可能なのですが、ソースコードからビルドする際に指定する必要があるので、RPMでインストールする場合は変更できません。なので、ブロックサイズは8192バイトで考えていきます。
テーブル当たりの行が「4,294,967,295ページ(ブロック)に収まる数」なので、計算すると4,294,967,295*8192=32(TB)となります。1テーブルあたりの最大レコード数は、ブロックサイズが8kBの場合は32TBにおさまるレコード数、ということですね。
残念ながら、いま動かせるマシンに32TBを超える記憶領域を持つサーバがないので、この上限まで迫ることは難しそうです…非常に小さいサイズのレコードであれば1兆レコードに到達できないかを考えてみます。
bool型の1カラムだけを持つテーブルでサイズを試算してみる
とにかく1レコードあたりのデータ量を減らしたいので、格納サイズが1バイトで済むbool型を1カラムだけ持つテーブルを作成して、10万レコードのテストデータを投入した場合のページ数を実測してみます。
postgres=# CREATE TABLE a(z bool);
CREATE TABLE
postgres=# INSERT INTO a SELECT '0' FROM generate_series(1,100000);
INSERT 0 100000
postgres=# ANALYZE a;
ANALYZE
postgres=# select relpages from pg_class where relname = 'a';
relpages
----------
443
10万レコードで443ページ(約3.5MB)となりました。単純計算で、1兆レコードをめざして10,000,000倍すると、44億3千万ページ(4,430,000,000)になります…制限事項の「4,294,967,295ページ」を超えてしまいますね。1カラム・1バイトのデータしか持たないテーブルでもブロックサイズを変更しない限り1兆レコードの壁は超えられないようです。
目標を見直して100億(10,000,000,000)レコードをいれよう
1兆レコードというのはあまりにも目標が大きすぎたようです。現実は非情です。
目標を見直して、100億(10,000,000,000)レコードをめざして実際にデータを投入してみたいと思います。
データ投入方法を考える
とはいっても100億レコードでも相当な量なので、投入方法には工夫が必要です。
PostgreSQL本体に大量データを投入する際、本体付属機能で最も高速な方法としては COPY コマンドです。やはりこれを使うことになるでしょう。
PostgreSQLは、更新内容を先行書き込みログ(WAL,Write Ahead Log)に書き出すことでデータの一貫性を確実にしますが、そのぶん更新処理に時間がかかります。テーブル作成時にUNLOGGEDオプションを付与すると、そのテーブルではWAL書き出しをスキップできてしまい、非常に高速にデータを投入できます。
UNLOGGEDなテーブルはWALを書き出さないためレプリケーションはできませんし、クラッシュまたは異常停止(immediateモードでの停止もふくむ)すると自動的にデータファイルが切り詰められ、データはすべて消えてしまいます。今回のように、消えてしまっても問題のない、一時的なテーブルでのみ利用するようにしましょう。
というわけで、やることとしてはシンプルですね。UNLOGGEDなテーブルに対してひたすらCOPYしていくことになります。
データをいれてみる
機種名: HPE ProLiant DL380 Gen10 Plus
CPU: Xeon Gold 6326 2.90GHz 2P/32C
MEM: 256GB
HDD: 1TB
RHEL 9.4
PostgreSQL 18.1
# ではじまる行はコメント
$ ではじまる行はOS上のpostgresユーザで実行するコマンド例
postgres=# ではじまる行はpsqlでpostgresデータベースに接続後に実行するコマンド例
# 高速化のためチェックサムも無効化してデータベースクラスタを初期化します
$ initdb -D $PGDATA --encoding=UTF8 --no-locale --no-data-checksums
# 共有バッファ引き上げ、autovacuumは無効化しておきます
# 件数を増やした後の検索に時間がかかるのでパラレルクエリ関連のプロセス数を増やしておきます
$ vi $PGDATA/postgresql.conf
shared_buffers = 64GB
autovacuum = off
max_worker_processes = 32
max_parallel_workers = 16
max_parallel_workers_per_gather = 16
# まずは元のデータがないとはじまらないので、UNLOGGEDなテーブル作成
# そのあと100万レコードのデータをgenerate_seriesで生成してINSERTします
postgres=# CREATE UNLOGGED TABLE a(z bool);
postgres=# INSERT INTO a SELECT '0' FROM generate_series(1,1000000);
# このデータをCSV形式で抽出します
postgres=# COPY a TO '/tmp/a_1m.csv' (format csv);
# 抽出したCSVでのCOPYを99回繰り返します。これでまずは1億レコードです
$ for i in {1..99}
do
psql -c "COPY a FROM '/tmp/a_1m.csv' (format csv)"
done
ちなみに、100万レコードのINSERTやCOPY TO/FROMの処理時間はおおむね以下のようになりました。generate_seriesで生成するよりは、準備済のファイルをCOPYする方がやはり高速ですね。
postgres=# INSERT INTO a SELECT '0' FROM generate_series(1,1000000);
INSERT 0 1000000
時間: 345.088 ミリ秒
postgres=# COPY a TO '/tmp/a_1m.csv' (format csv);
COPY 1000000
時間: 92.452 ミリ秒
postgres=# COPY a FROM '/tmp/a_1m.csv' (format csv);
COPY 1000000
時間: 131.971 ミリ秒
1億レコードまで蓄積できたら、1億レコード分のCSVファイルをCOPY TOで作成します。
postgres=# COPY a TO '/tmp/a_100m.csv' (format csv);
COPY FROMにかかる時間はほぼレコード数に比例しているようです。100万レコードで約130ミリ秒だったのが、100倍の1億レコードで約13000ミリ秒(約13秒)となっています。
postgres=# COPY a TO '/tmp/a_100m.csv' (format csv);
COPY 100000000
時間: 8462.939 ミリ秒(00:08.463)
postgres=# COPY a FROM '/tmp/a_100m.csv' (format csv);
COPY 100000000
時間: 13252.718 ミリ秒(00:13.253)
あとはこの1億レコードのCSVファイルのCOPYを99回繰り返します。1億レコードで約13秒、100億レコードでは約1300秒(約21分40秒)で終わりました。意外と早いです。
$ for i in {1..99}
do
psql -c "COPY a FROM '/tmp/a_100m.csv' (format csv)"
done
実際にSELECTする前に、ひとまず統計情報をみてみます。統計情報は実際にテーブルを参照しないので、大規模なテーブルの情報でも高速に参照できます。100億レコードのINSERT(n_tup_ins)によって、100億レコードのアクティブなレコード(n_live_tup)が存在することを確認できます。
postgres=# select relid, relname, n_tup_ins, n_live_tup from pg_stat_user_tables;
-[ RECORD 1 ]----------+------------------------------
relid | 16384
relname | a
n_tup_ins | 10000000000
n_live_tup | 10000000000
実際のテーブルファイルの状況は以下のようになります。「16384」が、今回作成したaテーブルのリレーションIDですね。PostgreSQLは、各テーブルをファイルで保持し、1GBを超えたらファイルを分割するため、このようにずらっと1GBのファイルが並ぶことになります。合計338ファイルあるので、100億レコード投入時点でこのテーブルは約338GBとなっています。
$ ls -l $PGDATA/base/5
-rw------- 1 postgres postgres 1073741824 11月 27 10:32 16384
-rw------- 1 postgres postgres 1073741824 11月 27 10:32 16384.1
-rw------- 1 postgres postgres 1073741824 11月 27 10:32 16384.2
-rw------- 1 postgres postgres 1073741824 11月 27 10:32 16384.3
-rw------- 1 postgres postgres 1073741824 11月 27 10:33 16384.4
...中略、16384.5~16384.333まで1GBのファイルが並びます
-rw------- 1 postgres postgres 1073741824 11月 27 11:38 16384.334
-rw------- 1 postgres postgres 1073741824 11月 27 11:38 16384.335
-rw------- 1 postgres postgres 1073741824 11月 27 11:38 16384.336
-rw------- 1 postgres postgres 627400704 11月 27 11:38 16384.337
データを検索してみる
ここまで増やして検索がちゃんと動くのかを見てみます。まずは全件をカウントするSQLの実行計画をみてみましょう。
postgres=# explain(analyze,buffers) select count(*) from a;
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------------------------
Finalize Aggregate (cost=59790909.70..59790909.71 rows=1 width=8) (actual time=2179513.168..2179514.589 rows=1.00 loops=1)
Buffers: shared hit=85088 read=44162763 dirtied=17617200 written=17614032
-> Gather (cost=59790908.66..59790909.67 rows=10 width=8) (actual time=2179513.002..2179514.576 rows=11.00 loops=1)
Workers Planned: 10
Workers Launched: 10
Buffers: shared hit=85088 read=44162763 dirtied=17617200 written=17614032
-> Partial Aggregate (cost=59789908.66..59789908.67 rows=1 width=8) (actual time=2179436.583..2179436.584 rows=1.00 loops=11)
Buffers: shared hit=85088 read=44162763 dirtied=17617200 written=17614032
-> Parallel Seq Scan on a (cost=0.00..56681497.13 rows=1243364613 width=0) (actual time=29.726..2143730.187 rows=909090909.09 loops=11)
Buffers: shared hit=85088 read=44162763 dirtied=17617200 written=17614032
Planning:
Buffers: shared hit=28
Planning Time: 0.595 ms
Execution Time: 2179514.644 ms
(14 行)
約2180秒(約36分20秒)かかってしまいました。データ投入後の最初のSELECTには、ヒントビットと呼ばれるフラグを更新する処理も動くので、その書き込みも発生していると思われますが…それにしても時間かかってますね。
postgres=# explain(analyze,buffers) select count(*) from a;
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------------------------
Finalize Aggregate (cost=59790909.70..59790909.71 rows=1 width=8) (actual time=1249911.943..1249913.233 rows=1.00 loops=1)
Buffers: shared hit=94592 read=44153259
-> Gather (cost=59790908.66..59790909.67 rows=10 width=8) (actual time=1249911.494..1249913.226 rows=11.00 loops=1)
Workers Planned: 10
Workers Launched: 10
Buffers: shared hit=94592 read=44153259
-> Partial Aggregate (cost=59789908.66..59789908.67 rows=1 width=8) (actual time=1249899.573..1249899.574 rows=1.00 loops=11)
Buffers: shared hit=94592 read=44153259
-> Parallel Seq Scan on a (cost=0.00..56681497.13 rows=1243364613 width=0) (actual time=16.724..1214291.239 rows=909090909.09 loops=11)
Buffers: shared hit=94592 read=44153259
Planning Time: 0.038 ms
Execution Time: 1249913.254 ms
(12 行)
2回目は約1250秒(約20分50秒)で完了しましたが、それでも相当時間がかかっています。UNLOGGEDテーブルに対してCOPYでまとめてデータを投入したとはいえ、100億レコードのデータ投入とほぼ同じくらい、件数をカウントするだけでも時間がかかってしまうことになります。
この規模のデータを1つのテーブルにまとめてしまうと、非常に取り回しが大変になることがイメージできるかと思います。インデックスをつくったとしてもインデックス自体も非常に大きなサイズになりますし、この規模のテーブルから必要な情報のみを抽出したり、特定のレコードだけ更新しようと思うと、かなり苦労することになります。
まとめ
- PostgreSQLのテーブルあたりのレコード数自体に上限はないが、テーブルサイズ32TB(デフォルトのブロックサイズの場合)の上限がある
- 1バイト1カラムしか持たないテーブルであっても、1兆レコードで試算すると32TBの上限を超えてしまう。複数カラムを持つテーブルではもっと少ないレコード数でテーブルサイズ上限を超えてしまう
- 1テーブルに1兆レコードをいれるには、ブロックサイズの変更や、超大規模な記憶領域を用意する必要があり大変
- 1テーブルに100億レコードいれても、PostgreSQL自体は問題なく動く
- しかし、100億レコードのテーブルに対する件数カウントや検索処理には非常に時間がかかってしまい、取り回しが大変になる
たくさんのレコードを扱うならパーティション分割を考えよう
レコード数を増やすのは意外と簡単でも、取り扱いに難がある…という、実際の運用でもありそうな結末となりました。想定以上のペースでレコードが増え続けてしまい、データ自体は格納できているけど、参照・更新が非常に遅くてどうにもならん、というケースですね。
サーバやストレージの性能など、環境に大きく依存するところではありますが、これまでのトラブル対応の経験からも、100万レコードを超える見込みがあるならパーティション分割を検討する価値はあると思います。想定以上のデータ量になる可能性もありますし、ログテーブルなど日時情報を持つテーブルでは特に重要ですね。
適切にパーティション分割をしてやれば、数百万・数千万・数億を超えるようなデータを扱う場合でも、スムーズに検索・更新ができる…はず!もちろん、それに見合ったリソースを持つ環境は必要となりますが。
PostgreSQLでどんどん大量のデータを扱っていきましょう。
記載されている会社名、製品名、サービス名は、各社の商標または登録商標です。