
はじめに
位置情報を扱うシステムを開発していると、「現在地から半径N km以内のデータを取得する」というクエリは避けて通れません。本記事では、このクエリのパフォーマンスを6つのシステムで比較した検証結果をまとめます。
簡単にスクリプト類をまとめたものはこちら。
検証に使ったデータは下に記載しています。
検証環境
| 項目 | 内容 |
|---|---|
| サーバー | GCP c4-standard-2(Intel Xeon / 2vCPU / 7GiB) |
| OS | Rocky Linux 9 |
| 計測方式 | 単独計測(計測中は対象システム以外を停止) |
| 測定方法 | Python time.perf_counter |
単独計測にした理由は、実際の本番環境では各システムが専用サーバーで動くことを想定しているためで、メモリも各システムが専有できる状態にしています。
検証対象システム
PostgreSQL 17 + PostGIS
地理情報システム(GIS)の標準的な選択肢です。PostGIS 拡張を追加することで、地理空間データの格納・検索が可能になります。
-- GEOGRAPHY型で定義(球面距離計算がそのまま使える) geom GEOGRAPHY(Point, 4326) NOT NULL
インデックスは GIST(R-tree)を使用します。
WHERE ST_DWithin(geom, ST_MakePoint(経度, 緯度)::geography, 距離m)
MySQL 8.4 + Spatial
MySQL 8.0 以降で標準搭載されている空間機能です。SPATIAL INDEX(R-tree)が使えます。
MySQL の SRID 4326 は地理座標系(OGC 標準)に準拠しているため、ST_GeomFromText での座標指定が POINT(緯度 経度) の順になります。他のシステムがほぼ全て (経度 緯度) の順である中、MySQL だけが逆という点は実装時に注意が必要です。
クエリは MBR(最小外接矩形)で粗く絞ってから ST_Distance_Sphere で正確に絞る二段フィルタを使っています。
WHERE MBRContains(バウンディングボックス, geom) AND ST_Distance_Sphere(geom, POINT(緯度 経度)) <= 距離m
Redis 8.0 Geospatial
インメモリのKVSですが、地理空間機能が標準で搭載されています。内部的には Geohash(52ビット精度)を使った Sorted Set として実装されており、最大 0.6% の誤差があります。
GEOSEARCH geo:stations FROMLONLAT 経度 緯度 BYRADIUS 5 km ASC
シンプルなコマンド一発で件数とIDのリストが取得できますが、属性情報(名前・住所など)は別途 Hash から取得する必要があります。実用上は N+1 問題への対策が必要な点に注意が必要です。
MongoDB 8.0 Geospatial
ドキュメント指向DBです。2dsphere インデックスを作成することで地理空間クエリが使えます。GeoJSON 形式でデータを格納します。
{ loc: { type: "Point", coordinates: [経度, 緯度] } }
今回は $nearSphere(距離順ソート付き)ではなく $geoWithin + $centerSphere を使っています。MongoDB 8.0 では $nearSphere を countDocuments() 内で使用できなくなったためです(ソートを伴うクエリの制約)。
db.stations.countDocuments({ loc: { $geoWithin: { $centerSphere: [[経度, 緯度], 半径ラジアン] } } })
Elasticsearch 9.x
本来は全文検索エンジンですが、geo_point 型と geo_distance クエリで地理空間検索が普通に使えます。内部では Lucene の BKD-tree(k次元木)を使用しており、R-tree とは異なるアプローチです。
{ "query": { "geo_distance": { "distance": "5km", "location": { "lat": 緯度, "lon": 経度 } } } }
地名テキスト検索と位置情報フィルタを組み合わせるような複合クエリが最も得意な領域です。
Pure Python(NumPy)
データベースを一切使わず、メモリ上の NumPy 配列で Haversine 公式による全件スキャンを行うベースラインです。
# Haversine 公式でベクトル演算 dist = 2 * R * np.arcsin(np.sqrt(a)) count = int(np.sum(dist <= radius_km))
データセット
| データセット | ソース | 件数 |
|---|---|---|
| stations | 駅データ.jp(無料版) | 約 10,000件 |
| amenities | Geofabrik 日本データ(OSM amenity抽出) | 約 100,000件 |
駅データは路線別にレコードが存在するため、複数路線が乗り入れる駅は同じ地点に複数のレコードがあります。amenity データはレストラン・コンビニ・病院・駐車場・銀行など 22 カテゴリを抽出しました。
測定条件
- テスト地点: 全国 16 地点(都市部 10 + 田舎・離島 6)
- 都市部: 東京、新宿、大阪、名古屋、博多、札幌、仙台、広島、那覇、金沢
- 田舎・離島: 稚内、根室、五所川原、飯田、隠岐島、宮古島
- 検索半径: 1 / 5 / 10 / 30 km
- 測定モード:
- 固定地点テスト: ウォームアップ 10 回後に本計測 100 回(キャッシュが温まった安定状態)
- ランダム地点テスト: 全国ランダム 100 地点 × 各 1 回(キャッシュが効きにくいコールド条件)
- チューニング: デフォルト設定 / チューニングあり の 2 パターン
田舎・離島を追加した理由は、ヒット数が極端に少ない場合にインデックスの絞り込み性能がどう変わるかを確認するためです。ランダム地点テストは、実際のゲームサーバーでは毎回異なる座標が来るため、その状況を再現するために追加しました。
チューニング設定
| システム | チューニング内容 |
|---|---|
| MySQL | innodb_buffer_pool_size = 5GB |
| PostgreSQL | shared_buffers = 2GB、effective_cache_size = 5GB 他 |
| Redis | maxmemory = 5GB、永続化無効 |
| MongoDB | wiredTiger cacheSizeGB = 3 |
| Elasticsearch | JVM ヒープ = 3GB |
※このぐらいのデータ数だとチューニングは意味が無いので測定結果はセットアップしたままです。
測定結果
Phase 1: 固定地点テスト(ウォームアップ済み・安定条件)
各値は全16地点の平均値。
駅データ(約10,000件)
平均レイテンシ (ms)
| システム | 1km | 5km | 10km | 30km |
|---|---|---|---|---|
| PostgreSQL+PostGIS | 0.82 | 0.85 | 0.88 | 0.97 |
| MySQL+Spatial | 0.27 | 0.81 | 1.36 | 2.84 |
| Redis+Geo | 0.05 | 0.1 | 0.16 | 0.3 |
| MongoDB+2dsphere | 0.34 | 0.42 | 0.51 | 0.71 |
| Elasticsearch | 1.7 | 1.08 | 0.96 | 0.88 |
| Pure Python(NumPy) | 0.17 | 0.16 | 0.16 | 0.16 |
p95レイテンシ (ms)
| システム | 1km | 5km | 10km | 30km |
|---|---|---|---|---|
| PostgreSQL+PostGIS | 0.83 | 0.86 | 0.89 | 0.99 |
| MySQL+Spatial | 0.32 | 0.9 | 1.39 | 2.87 |
| Redis+Geo | 0.05 | 0.11 | 0.17 | 0.32 |
| MongoDB+2dsphere | 0.38 | 0.45 | 0.54 | 0.74 |
| Elasticsearch | 2.33 | 1.24 | 1.11 | 1.01 |
| Pure Python(NumPy) | 0.18 | 0.17 | 0.17 | 0.17 |
スループット (QPS)
| システム | 1km | 5km | 10km | 30km |
|---|---|---|---|---|
| PostgreSQL+PostGIS | 1226.33 | 1188.31 | 1151.88 | 1075.43 |
| MySQL+Spatial | 4170.58 | 2840.03 | 2480.84 | 1965.83 |
| Redis+Geo | 23184.62 | 16446.48 | 14133.0 | 11618.99 |
| MongoDB+2dsphere | 2955.69 | 2573.81 | 2366.64 | 2067.3 |
| Elasticsearch | 616.8 | 946.8 | 1048.14 | 1150.13 |
| Pure Python(NumPy) | 5979.7 | 6205.72 | 6205.82 | 6206.43 |
都市部 vs 田舎・離島 比較(平均レイテンシ ms / 5km)
| システム | 都市部avg | 田舎avg | 差 |
|---|---|---|---|
| PostgreSQL+PostGIS | 0.88 | 0.8 | -0.08 |
| MySQL+Spatial | 1.18 | 0.2 | -0.98 |
| Redis+Geo | 0.13 | 0.04 | -0.09 |
| MongoDB+2dsphere | 0.49 | 0.31 | -0.18 |
| Elasticsearch | 1.15 | 0.96 | -0.19 |
| Pure Python(NumPy) | 0.16 | 0.16 | +0.0 |
amenityデータ(約100,000件)
平均レイテンシ (ms)
| システム | 1km | 5km | 10km | 30km |
|---|---|---|---|---|
| PostgreSQL+PostGIS | 0.88 | 1.27 | 3.52 | 5.86 |
| MySQL+Spatial | 0.65 | 5.95 | 12.26 | 32.0 |
| Redis+Geo | 0.08 | 0.6 | 1.25 | 3.27 |
| MongoDB+2dsphere | 0.42 | 1.17 | 2.07 | 4.89 |
| Elasticsearch | 0.82 | 0.71 | 0.69 | 0.66 |
| Pure Python(NumPy) | 2.43 | 2.43 | 2.55 | 2.56 |
p95レイテンシ (ms)
| システム | 1km | 5km | 10km | 30km |
|---|---|---|---|---|
| PostgreSQL+PostGIS | 0.93 | 1.28 | 3.59 | 5.9 |
| MySQL+Spatial | 0.68 | 5.99 | 12.31 | 32.1 |
| Redis+Geo | 0.09 | 0.71 | 1.26 | 3.31 |
| MongoDB+2dsphere | 0.45 | 1.2 | 2.11 | 4.97 |
| Elasticsearch | 0.96 | 0.82 | 0.77 | 0.73 |
| Pure Python(NumPy) | 2.46 | 2.45 | 2.58 | 2.58 |
スループット (QPS)
| システム | 1km | 5km | 10km | 30km |
|---|---|---|---|---|
| PostgreSQL+PostGIS | 1148.47 | 952.92 | 825.47 | 679.46 |
| MySQL+Spatial | 2909.67 | 1729.6 | 1448.41 | 1036.95 |
| Redis+Geo | 17456.76 | 10309.74 | 8864.37 | 6451.39 |
| MongoDB+2dsphere | 2529.81 | 1752.21 | 1562.88 | 1269.07 |
| Elasticsearch | 1225.34 | 1417.03 | 1459.56 | 1518.75 |
| Pure Python(NumPy) | 410.9 | 411.18 | 391.91 | 390.74 |
都市部 vs 田舎・離島 比較(平均レイテンシ ms / 5km)
| システム | 都市部avg | 田舎avg | 差 |
|---|---|---|---|
| PostgreSQL+PostGIS | 1.54 | 0.82 | -0.72 |
| MySQL+Spatial | 9.35 | 0.29 | -9.06 |
| Redis+Geo | 0.92 | 0.05 | -0.87 |
| MongoDB+2dsphere | 1.66 | 0.34 | -1.32 |
| Elasticsearch | 0.74 | 0.67 | -0.07 |
| Pure Python(NumPy) | 2.43 | 2.44 | +0.01 |
Phase 2: ランダム地点テスト(コールドキャッシュ条件)
全国ランダム100地点に対して各1回クエリを実行。毎回異なる座標を使うため、 キャッシュが効きにくい実際のゲームサーバーに近い条件。 固定地点テストとの差がキャッシュ効果の大きさを示す。
駅データ(約10,000件)
平均レイテンシ (ms)(ランダム100点)
| システム | 1km | 5km | 10km | 30km |
|---|---|---|---|---|
| PostgreSQL+PostGIS | 0.82 | 0.84 | 0.87 | 1.06 |
| MySQL+Spatial | 0.26 | 0.36 | 0.72 | 3.32 |
| Redis+Geo | 0.04 | 0.05 | 0.09 | 0.33 |
| MongoDB+2dsphere | 0.33 | 0.37 | 0.43 | 0.83 |
| Elasticsearch | 1.24 | 1.42 | 1.12 | 1.27 |
| Pure Python(NumPy) | 0.16 | 0.16 | 0.16 | 0.16 |
p95レイテンシ (ms)(ランダム100点)
| システム | 1km | 5km | 10km | 30km |
|---|---|---|---|---|
| PostgreSQL+PostGIS | 0.86 | 0.93 | 1.11 | 1.49 |
| MySQL+Spatial | 0.87 | 0.92 | 3.0 | 10.76 |
| Redis+Geo | 0.06 | 0.09 | 0.25 | 1.15 |
| MongoDB+2dsphere | 0.4 | 0.46 | 0.75 | 1.93 |
| Elasticsearch | 1.76 | 1.95 | 1.41 | 1.65 |
| Pure Python(NumPy) | 0.17 | 0.17 | 0.17 | 0.17 |
スループット (QPS)(ランダム100点)
| システム | 1km | 5km | 10km | 30km |
|---|---|---|---|---|
| PostgreSQL+PostGIS | 1212.2 | 1185.6 | 1152.8 | 942.1 |
| MySQL+Spatial | 3814.4 | 2754.7 | 1394.6 | 301.5 |
| Redis+Geo | 25126.9 | 19419.9 | 11544.6 | 2999.7 |
| MongoDB+2dsphere | 3042.9 | 2717.8 | 2316.8 | 1204.8 |
| Elasticsearch | 807.3 | 704.7 | 888.7 | 788.9 |
| Pure Python(NumPy) | 6179.5 | 6212.0 | 6213.8 | 6219.9 |
amenityデータ(約100,000件)
平均レイテンシ (ms)(ランダム100点)
| システム | 1km | 5km | 10km | 30km |
|---|---|---|---|---|
| PostgreSQL+PostGIS | 0.87 | 1.03 | 2.09 | 8.6 |
| MySQL+Spatial | 0.51 | 2.16 | 6.84 | 42.55 |
| Redis+Geo | 0.04 | 0.21 | 0.68 | 3.91 |
| MongoDB+2dsphere | 0.38 | 0.65 | 1.34 | 6.43 |
| Elasticsearch | 1.33 | 1.34 | 1.12 | 1.64 |
| Pure Python(NumPy) | 2.56 | 2.55 | 2.55 | 2.55 |
p95レイテンシ (ms)(ランダム100点)
| システム | 1km | 5km | 10km | 30km |
|---|---|---|---|---|
| PostgreSQL+PostGIS | 1.01 | 1.95 | 11.76 | 19.86 |
| MySQL+Spatial | 1.47 | 9.65 | 47.8 | 167.66 |
| Redis+Geo | 0.09 | 1.14 | 4.04 | 17.54 |
| MongoDB+2dsphere | 0.51 | 1.81 | 7.38 | 25.39 |
| Elasticsearch | 1.85 | 1.56 | 1.5 | 2.2 |
| Pure Python(NumPy) | 2.58 | 2.57 | 2.57 | 2.57 |
スループット (QPS)(ランダム100点)
| システム | 1km | 5km | 10km | 30km |
|---|---|---|---|---|
| PostgreSQL+PostGIS | 1154.3 | 967.0 | 477.8 | 116.3 |
| MySQL+Spatial | 1949.9 | 462.4 | 146.1 | 23.5 |
| Redis+Geo | 22146.0 | 4778.7 | 1477.1 | 255.7 |
| MongoDB+2dsphere | 2628.4 | 1548.8 | 746.3 | 155.5 |
| Elasticsearch | 749.6 | 747.8 | 890.2 | 611.5 |
| Pure Python(NumPy) | 390.7 | 391.8 | 391.8 | 392.2 |
各システムの特徴まとめ
| システム | インデックス | 距離計算 | 向いている用途 |
|---|---|---|---|
| PostgreSQL+PostGIS | R-tree (GIST) | 球面距離(高精度) | GIS的な複雑な地理処理、高精度が必要な場合 |
| MySQL+Spatial | R-tree (SPATIAL) | 球面距離近似 | 既存MySQL環境への追加、RDBと地理検索を同居させたい場合 |
| Redis+Geo | Sorted Set + Geohash | Haversine(最大0.6%誤差) | 超低レイテンシが必要、属性情報が少ない場合 |
| MongoDB+2dsphere | 2dsphere (B-tree) | 球面距離 | ドキュメントごとに属性が異なる、スキーマが流動的な場合 |
| Elasticsearch | BKD-tree (Lucene) | 球面距離 | 全文検索と地理検索の複合クエリ |
| Pure Python(NumPy) | なし(全件スキャン) | Haversine | ベースライン。小規模データの簡易実装 |
おわりに
今回の検証は「現在地から半径 N km 以内のデータ件数を取得する」というシンプルなクエリに絞っています。実際のシステムでは件数だけでなく属性情報の取得、ソート、フィルタの組み合わせなどが加わるため、ユースケースによって最適なシステムは変わります。
本記事の数値はあくまで参考として、システム選定の一材料にしていただければ幸いです。
今後の課題:空間インデックスライブラリという選択肢
今回の検証はデータベース・ミドルウェアの地理空間機能に絞りましたが、位置情報を扱う手法としては空間インデックスライブラリという別のアプローチも存在します。今回は比較対象に含めていませんが、将来的に検討したい領域として概要をまとめておきます。
S2 Geometry(Google)
Google が開発した球面幾何学ライブラリです。地球表面を階層的なセル(正方形に近い形)で分割する S2 Cell という概念を使います。
- セルは レベル0〜30 の階層構造で、レベルが上がるほど細かくなります(レベル30 は約1cm²)
- 任意の地点や領域をセルIDで表現できるため、通常の整数インデックスで地理検索が可能になります
- Google Maps・Pokémon GO などで採用実績があります
- C++・Java・Go・Python などの実装が公開されています
H3(Uber)
Uber が開発した 六角形グリッドによる空間インデックスです。地球表面を六角形のセルで分割します。
- 解像度0〜15 の階層構造(解像度15 は約1m²)
- 六角形は正方形と異なり全方向に等距離という特性があり、近傍検索との相性が良いとされています
- セルIDは64ビット整数で表現されるため、通常のデータベースのインデックスをそのまま活用できます
- Uber の配車マッチング・需要予測などで実績があります
GEOHEX
2009年に日本人開発者(@sa2da 氏)が考案・発表した六角形グリッドライブラリです。地球表面を正六角形(ヘックス)で隙間なく敷き詰め、緯度経度をヘックスコードで表現します。
- セルの大きさは レベル 0〜25 の 26 段階で調整可能で、レベルが大きいほどセルが細かくなります
- 隣接する 6 つのセル間の距離が全て均等という六角形の特性を持ち、隣接セルのコード算出が容易な設計になっています
- ライセンスはクリエイティブ・コモンズで、クレジット掲載により改変・再配布・商用利用が可能
- ドラゴンクエストウォークでの採用実績があり、スポット出現範囲やモンスター出現判定などに活用されていることが確認されています
- Java・JavaScript・Python・Ruby など多言語の実装が公開されています
H3 と比較すると、H3 は Uber が大規模な商用環境で設計・運用した実績があり、64ビット整数によるセルIDの扱いやすさ・階層構造の洗練度で優位性があります。GEOHEX は日本発でゲーム業界での採用実績があり、国内では参考にできる事例が比較的多いという特徴があります。
what3words
2013年に英国で設立された位置情報サービスです。地球上を 3 メートル四方に区切り、それぞれのマス目に固有の 3 つの単語の組み合わせを割り当てるというアプローチが特徴です。
- 地球の表面積を 3 メートル四方(9 平方メートル)で分割すると約 57 兆個のマス目になります。アルゴリズムベースのため、巨大なデータベースを必要とせずオフラインでも動作します
- 40 言語以上に対応しており、日本語では「///たいやき。そのあと。ようちえん」のような形式で位置を表現できます
- コロプラ(ドラクエウォーク開発元)の技術資料でも位置情報手法の選択肢として紹介されています
ただし what3words は商用 API は有償であり、独自のクローズドなアルゴリズムに依存するという点で、他のオープンな手法とは性格が異なります。位置情報の「人間に伝えやすい表現」としては優れていますが、サーバーサイドでの近傍検索用途には向きません。
これらのアプローチの共通のメリット
データベースの地理空間機能(PostGIS・MySQL Spatial など)を使わず、通常の整数・文字列インデックスで地理検索を実現できる点が最大の特徴です。
座標 → セルID(整数)に変換 → 通常のB-treeインデックスで検索
この方式にすることで:
- 特殊な空間インデックスに依存しない
- キャッシュとの親和性が高い(セルIDをキャッシュキーにしやすい)
- シャーディングとの相性が良い(セルIDで分散しやすい)
といったメリットが生まれます。一方で「円形範囲の近似精度」「セル境界付近の処理」「解像度の選択」といった設計上の考慮が必要になります。
これらの手法の実装と今回の6システムとの比較は、今後の課題としたいと思います。