Cloud Bigtable で位置情報を扱ってみる

Toshiyuki (Topy) Isobe
google-cloud-jp
Published in
32 min readApr 2, 2020

--

はじめに

最近 IoT の文脈であらゆるものがインターネットに接続され、位置情報 (緯度・経度からなる情報) が含まれたデータを扱う例も増えてきているように思います。また、位置情報を使ったゲームなんかも増えてきていますね。
本記事では以下のようなワークロードを仮定して、これを GCP で実現するにはどうしたら良いかを考えていきたいと思います。

  • 複数種類の移動オブジェクト (車、トラック、バイク、歩行者、タクシー) から、毎秒数十万レコードに及ぶ位置情報を含むデータが生成される
  • 生成されるデータはそれぞれタイムスタンプを持っている
  • これらのデータをリアルタイムに永続化しつつ、可能な限り低いレイテンシ (~1sec) で時間と位置情報を条件にしたクエリに応答する必要がある

今回は大量に発生したデータを高スループットで書き込みつつ、低レイテンシで読み出せる Cloud Bigtable を使って位置情報を取り扱ってみたいと思います。

GCP のストレージ サービス

GCP には多くのストレージ サービスがあります。以下にそれらを簡単な特徴と共に書き出してみました。

GCP のストレージ サービスと特徴

現在のところ、BigQuery と Cloud SQL (PostgreSQL) では位置情報を使ったクエリ (例えば、ある地点から 1 km 以内にあるレコードを抽出するクエリ) を扱うことができます。

今回仮定したワークロードにおいても、例えば後から数年分の蓄積データの傾向を分析する、といった場合には BigQuery が最適でしょうし、トランザクションを含んだ複雑なアプリケーションの場合は Cloud SQL (PostgreSQL) が良いかもしれません

しかし、今回は非常に大きな書き込みスループットが要求される上、低レイテンシで一定のクエリに応答する必要があります。このようなワークロードに対応する場合、たとえ組み込みで地理情報を扱う機能はなくとも、Cloud Bigtable が合っているように思えます。

次からは Cloud Bigtable でどのようにこのワークロードを実現するかを見ていきましょう。

Cloud Bigtable について

Bigtable はキー・バリューからなるいわゆる NoSQL です。

データの入った行 (Row) に対して、一意に行を特定する行キー (Row Key) と、列 (Column) をまとめる列ファミリー (Column Family) があります。
あえて一般的なリレーショナル データベース (RDB) との対応関係を考えると次のようになります。

しかし、RDB では主キー以外にもインデックスを張って高速にクエリを行うことができますが、 Bigtable では行キー以外のインデックスを作ることができません。 また、RDB では複数行に跨ったトランザクションが可能ですが、Bigtable では単一行のアトミック性のみが保証されます

その代わりに、ペタバイトのデータでもノードを増やすことでパフォーマンスが向上するスケーラビリティと、低レイテンシでありながら非常に高い読み取り / 書き込みスループットを実現しています。

Bigtable のアーキテクチャについてより詳しく知りたい方は、以下のドキュメントをご覧ください。 https://cloud.google.com/bigtable/docs/overview#architecture

インスタンスの作成

それでは実際に Bigtable を使ってみます。まずはインスタンスを作りましょう。 ここでは gcloud コマンドを使っていますが、もちろん Cloud Console からボタンをぽちぽちすることで簡単に作ることもできます。

$ gcloud bigtable instances create geo-sample --cluster=geo-sample-c1 --cluster-zone=asia-east1-a --display-name=geo-sample --cluster-num-nodes=1 --cluster-storage-type=SSD --instance-type=PRODUCTION

今回はノード数 1 で geo-sample というインスタンスを作成しました。

テーブルの設計

次にテーブルを作っていきます。Bigtable のドキュメントにあるガイドを見ながら検討してきましょう。
私が今回仮定したワークロードは以下のようなものです。

  • 複数種類の移動オブジェクト (車、トラック、バイク、歩行者、タクシー) から、毎秒数十万レコードに及ぶ位置情報を含むデータが生成される

さて、もし今回私が RDB を使うとしたら、おそらく各種類毎にテーブルを作成するでしょう。安直に car_data, track_data, pedestrian_data, motorcycle_data, taxi_data と名付けるかもしれませんね。さて、Bigtable も同様にテーブルを分けるべきでしょうか?
ドキュメントを見てみましょう。

他のデータベース システムでは、件名と列数に基づいて複数のテーブルにデータを格納する場合があります。Cloud Bigtable では、すべてのデータを 1 つの大きなテーブルに格納する方が適しています。各データセットに使用する一意の行キー接頭辞を割り当てます。これにより、関連データが連続した範囲に格納され、行キー接頭辞でこの範囲に対するクエリを実行できます。

Bigtable では、データを複数の小さなテーブルに分けてしまうと、逆にオーバーヘッドが増加し、パフォーマンスが低下するおそれがあります。可能な限りテーブルをまとめ、大きなテーブルに仕立てるのが良さそうです。

ここでは、一つの大きなテーブル “Geo-Positions” を作成し、各行キーの接頭辞として pedestrian, car, track, motorcycle, taxi とつけることにします。

Go 言語用の Cloud SDK を使ってプログラムからテーブルを実際に作成してみます。もちろん、Cloud SDK は様々な言語向けに提供されていますから、皆さんはお好きな言語を使うことができます。
(以降のコードはサンプルです。短くするために簡略化しています)

package mainimport (
"context"
"log"
"cloud.google.com/go/bigtable"
)
const (
project = "PROJECT"
instance = "geo-sample"
)
func createTable(ctx context.Context, tableName string) error {
// Admin クライアントの作成
adminClient, err := bigtable.NewAdminClient(ctx, project, instance)
if err != nil {
return err
}
defer adminClient.Close()
// テーブルの作成
return adminClient.CreateTable(ctx, tableName)
}
func main() {
ctx := context.Background()
if err := createTable(ctx, "Geo-Positions"); err != nil {
log.Fatalf("failed to create a table: %+v", err)
}
}

次は列と列ファミリを定義しましょう。これらのデータにはどのような列が必要でしょうか?
もしかしたら、オブジェクトの種類毎に持っているデータが異なる場合があるかもしれません (というより、そういったケースの方が多いでしょう)。その場合はどうすれば良いでしょうか? もう一度ドキュメントを見てみましょう。

Cloud Bigtable テーブルはスパースであるため、行ごとに必要なだけ列修飾子を作成できます。行内の空のセルによって領域が消費されることはありません。 HBase とは異なり、Cloud Bigtable では、優れたパフォーマンスを維持しながら 100 個までの列ファミリーを使用できます。したがって、互いに関連する複数の値を行に格納する場合はいつでも、それらの値を 1 つの列ファミリーにまとめることをおすすめします。列ファミリー内にデータをまとめることにより、単一または複数のファミリーからデータを取得できます。

各データに必要な列を列ファミリーとしてまとめて作れば良さそうですね。例えば次のようなテーブル構造です。

RDB の設計に慣れた人には空白の列が眩しく映るかもしれませんが、Bigtable のテーブルはスパースです。ドキュメントにある通りこれらの空セルによって領域が消費されることはなく、多くの場合で合理的な設計となります。

今回は適当に生成したダミーデータを用いるため、単に位置情報 (緯度・経度) と時間を格納する “pos” という列ファミリーを作成しておきます。
以下のコードでは先程作成したテーブルに対して列ファミリーを作成しています。

func createColumnFamily(ctx context.Context, tableName string, columnFamily string) error {
// Admin クライアントの作成
adminClient, err := bigtable.NewAdminClient(ctx, project, instance)
if err != nil {
return err
}
defer adminClient.Close()
// 列ファミリーの作成
return adminClient.CreateColumnFamily(ctx, tableName, columnFamily)
}
func main() {
ctx := context.Background()
if err := createColumnFamily(ctx, "Geo-Positions", "pos"); err != nil {
log.Fatalf("failed to create a column family: %+v", err)
}
}

これでデータを入れるテーブルはできました。

行キーの設計

もっとも重要なパートに入ってきました。行キーの設計です。
先に述べた通り Bigtable では行キーのみにインデックスが張られます。Bigtable からデータを読み出す主な方法は次の 3 つです。

  • 特定の行キーを指定して、1 行のみ読み出す
  • 行キーの接頭辞 (prefix) を指定して、複数行を読み出す
  • 行キーの範囲 (range) を指定して、複数行を読み出す

以上の 3 つで読み出せない場合、非常に低速なテーブルのフルスキャンを行い、クライアント側で処理するしかなくなります。行キーの設計がなぜ重要か、ご理解いただけると思います。

また、ドキュメントを見るともう 1 つ気をつけるべき点があります。

読み取りと書き込みは(テーブルの行スペース全体に)均等に分散されるのが理想的です。

これはどういうことでしょうか?

Bigtable では、行キーによってソートされたデータが各ノードに分散して保存されます。このとき、近い行は同じノードに格納されます。 従って、一つのノードにアクセスが集中してしまうような行キーの設計にすると、いくらノードを増やしてもアクセスが少数のノードに集中し、パフォーマンスが向上しません

良くない行キーの例をいくつかドキュメントから引用しましょう。

ドメイン名

標準の非リバース ドメイン名を行キーとして使用しないでください。標準のドメイン名はドメインの一部に属するすべての行を検索するには非効率的です(たとえば、company.com に関連するすべての行が、services.company.comproduct.company.com などの異なる行範囲に属するといった場合)。

シーケンシャル数値 ID

お使いのシステムで、アプリケーションの各ユーザーに数値 ID が割り当てられているとします。このような場合には、テーブルの行キーとしてユーザーの数値 ID を使いたくなるかもしれません。しかし、新規のユーザーのほうがアクティブなユーザーになる可能性が高いため、このような方法では、大半のトラフィックがごく少数のノードに集中してしまいます。

タイムスタンプ

データを記録日時に基づいて検索する頻度が高い場合は、行キーの一部にタイムスタンプを含める方法をおすすめします。ただし、タイムスタンプを単独で行キーにする方法は、大半の書き込みが特定の 1 ノードに集中してしまうため、おすすめできません。同じ理由で、タイムスタンプを行キーの先頭に配置するのも避けてください。

これらの例から分かる通り、行キーを考える時は、キーをソートしたときに満遍なくアクセスされるようになっているかを考えると良いでしょう。

それでは、今回のケースに適した行キーはどのようなものでしょうか?
今一度、仮定したワークロードを見てみましょう。

  • 複数種類の移動オブジェクト (車、トラック、バイク、歩行者、タクシー) から、毎秒数十万レコードに及ぶ位置情報を含むデータが生成される
  • 生成されるデータはそれぞれタイムスタンプを持っている
  • これらのデータをリアルタイムに永続化しつつ、可能な限り低いレイテンシ (~1sec) で時間と位置情報を条件にしたクエリに応答する必要がある

オブジェクトの種類毎に、時間と位置でクエリができる必要がありそうです。どのクエリでもまずオブジェクトの種類で限定するものとすると、行キーの候補としては

  1. [種類]#[位置]#[タイムスタンプ]
  2. [種類]#[タイムスタンプ]#[位置]

のどちらかが良さそうです (# は単なる区切り文字です)。
それでは、1 と 2 のどちらがより良いでしょうか?

仮に、今回のワークロードでは時系列のデータの内、最近のデータにアクセスすることが多いものとしましょう (例えば直近 30 分のデータに頻繁にアクセスする)。
その場合は、2 の形式の行キーでは特定の行 (接頭辞が [種類]#[タイムスタンプ]) にアクセスが集中してしまうことが予想されます。
1 の形式の行キーではまず [位置] でアクセスが分散されますから、今回は 1 の形式の行キーを採用します。

(※ もちろん、特定の地域に対するクエリが集中するケースでは 2 の方が良い可能性があります。どのようなクエリが予想されるかを入念に調査してください。)

位置情報の取り扱い

さて、これまであえて無視していたことが一つあります。行キーに含まれる [位置] とは何でしょうか?

これを単に緯度・経度の値としてしまうと、今回のワークロードに必要な “ある地点から半径 x km 以内” という条件でクエリすることができなくなります。緯度・経度を何らかのソート可能な値に変換して、Bigtable で行キーを用いた検索をできるようにする必要があります。

これにはいくつかの方法があると思いますが、よくある方法は地球の表面を分割し、それぞれにインデックスを順番につけていく、というものがあります。例えば有名なものに Geohash があります。

Geohash は以下の画像のように、ある領域に含まれる地点に一意なハッシュを付与するというもので、このハッシュ文字列をソートしたときに近い領域は、地理的にも近いという特徴があります。

Google Maps API で可視化した Geohash

また、Geohash 以外にも以下のような手法があります。

ワークロードによって適した手法は異なりますが、今回は H3 を使ってみたいと思います。

H3 は Uber の開発した手法で、次のように地球の表面を六角形で分割し、それぞれにインデックスを付与します。

https://eng.uber.com/h3/ より引用

H3 についての詳細はこちらを参照してください。

H3 を使って前章で定義した行キーを作る関数は次のようなものになります。

import (
...略...
"github.com/uber/h3-go"
)
type geoPosition struct {
kind string
lat float64
lon float64
timestamp time.Time
}
func createH3IndexFromGeo(lat float64, lon float64, res int) string {
// 緯度・経度を H3 インデックス文字列に変換
geo := h3.GeoCoord{
Latitude: lat,
Longitude: lon,
}
return h3.ToString(h3.FromGeo(geo, res))
}
func composeRowKey(p geoPosition, res int) string {
h3 := createH3IndexFromGeo(p.lat, p.lon, res)
// [種類]#[H3 インデックス]#[タイムスタンプ] の形式にフォーマット
return fmt.Sprintf("%s#%s#%d", p.kind, h3, p.timestamp.Unix())
}

実際の行キーをいくつか表示してみます。

car#892f5aade43ffff#1584192883
pedestrian#892f5a32d87ffff#1585401812
taxi#892f5a32dabffff#1584289496

これで、位置情報を指定して各行を検索することができそうです。

ダミーデータの書き込み

それでは実際に Bigtable に適当なダミーデータを書き込んでいきます。

ダミーデータとして、およその日本本土内の緯度・経度と2020年3月中のタイムスタンプをランダムに生成しています (位置が海の上のものもあるじゃないか!というツッコミはご容赦ください)。

// 定数
const (
maxLat = 45.3122 // Souya
minLat = 26.0430 // Arasaki
maxLon = 145.4858 // Noshappu
minLon = 127.3811 // Oominezaki
)
var from time.Time = time.Date(2020, 3, 1, 0, 0, 0, 0, time.UTC)
var to time.Time = time.Date(2020, 3, 31, 23, 59, 59, 0, time.UTC)
var kinds []string = []string{"pedestrian", "car", "track", "taxi", "motorcycle"}func generateRandomPositions(num int, maxLat float64, minLat float64, maxLon float64, minLon float64, from time.Time, to time.Time) ([]geoPosition, error) {
if maxLat > 90.0 || minLat < -90.0 || maxLat <= minLat {
return []geoPosition{}, fmt.Errorf("out of range latitude: max %f, min %f", maxLat, minLat)
}
if maxLon > 180.0 || minLon < -180.0 || maxLon <= minLon {
return []geoPosition{}, fmt.Errorf("out of range longitude: max %f, min %f", maxLon, minLon)
}
if from.Unix() > to.Unix() {
return []geoPosition{}, fmt.Errorf("invalid timestamp range: from %v, to %v", from, to)
}
rand.Seed(time.Now().UnixNano()) positions := make([]geoPosition, num)
for i := 0; i < num; i++ {
// 緯度、経度、タイムスタンプを一定範囲内でランダムに生成
lat := rand.Float64()*(maxLat-minLat) + minLat
lon := rand.Float64()*(maxLon-minLon) + minLon
kind := kinds[rand.Intn(len(kinds))]
timestamp := time.Unix(rand.Int63n(to.Unix()-from.Unix())+from.Unix(), 0)
positions[i] = geoPosition{
kind: kind,
lat: lat,
lon: lon,
timestamp: timestamp,
}
}
return positions, nil
}

これらのデータを Bigtable に書き込みます。

func ingestDummyRows(ctx context.Context, num int, tableName string, res int) error {
// Bigtable クライアントの生成
client, err := bigtable.NewClient(ctx, project, instance)
if err != nil {
return err
}
defer client.Close()
tbl := client.Open(tableName)
// ランダムなダミーデータの生成
positions := generateRandomPositions(num, maxLat, minLat, maxLon, minLon, from, to)
muts := make([]*bigtable.Mutation, num)
keys := make([]string, num)
for i, position := range positions {
// 行キーの作成
key := composeRowKey(position, res)

// 列の値はバイト配列に変換
latBytes := make([]byte, 8)
binary.LittleEndian.PutUint64(latBytes, math.Float64bits(position.lat))
lonBytes := make([]byte, 8)
binary.LittleEndian.PutUint64(lonBytes, math.Float64bits(position.lon))
timeBytes := []byte(position.timestamp.Format(time.RFC3339))
keys[i] = key

// 列ファミリと列を指定して値をセット
muts[i].Set("pos", "lat", bigtable.Time(position.timestamp), latBytes)
muts[i].Set("pos", "lon", bigtable.Time(position.timestamp), lonBytes)
muts[i].Set("pos", "time", bigtable.Time(position.timestamp), timeBytes)
}
// ダミーデータの書き込みの実行
rowErrs, err := tbl.ApplyBulk(ctx, keys, muts)
if err != nil {
return err
}
if rowErrs != nil {
for _, rowErr := range rowErrs {
log.Printf("failed to write a row: %+v", rowErr)
}
}

return nil
}

今回はこの方法で 1 億件の行を書き込みました。

$ cbt -project PROJECT -instance geo-sample count Geo-Positions
100000000

クエリ

さて、それでは書き込んだダミーデータに対してクエリを投げてみましょう。

次のようなクエリを投げてみます。

  • 東京駅 (35.6812, 139.7671) から半径 1 km 以内に
  • 2020年3月15日0時0分から 2020年3月31日23時59分の間に存在した
  • 車 (car) のデータ

まず、H3 において “東京駅から半径 1 km 以内” の領域を含む H3 のインデックスをリストします。

ここで出てくる H3 の関数 h3.KRing() は、指定したインデックスの六角形からの距離 k にある六角形領域 (例えば k=1 であれば隣接している 6 つの六角形) のインデックスを取得することができます。

// Tokyo station
const (
tokyoLat = 35.6812
tokyoLon = 139.7671
)
// c.f. https://h3geo.org/#/documentation/core-library/resolution-table
var h3EdgeLengthByResolutions map[int]float64 = map[int]float64{
0: 1107712.591000,
...略...
9: 174.375668,
...略...
15: 0.509713,
}
func listH3IndicesWithin(lat float64, lon float64, dist float64, res int) []string {
// 指定したインデックス
geo := h3.GeoCoord{
Latitude: lat,
Longitude: lon,
}
h := h3.FromGeo(geo)
// 指定したインデックスからの距離 k を算出
m := math.Sqrt(3.0) * 0.5 * h3EdgeLengthByResolutions[res]
k := (dist-m)/(2*m) + 1
// k < 1 であれば dist 内の領域は指定のインデックス一つにのみ含まれる
if k < 1 {
return []string{h3.ToString(h)}
}
// 指定したインデックスからの距離 k に含まれるすべてのインデックスを取得
kint := int(math.Round(k))
kRing := h3.KRing(h, kint)
indices := make([]string, len(kRing))
for i, index := range kRing {
indices[i] = h3.ToString(index)
}
return indices
}

実際に解像度 (resolution) を 9 (1 つの六角形の 1 辺の長さが 約 174 m) として H3 インデックスを抽出してみると、61 のインデックスが取得できました。

[892f5a32d97ffff 892f5a32d93ffff 892f5a32d83ffff ...略... 892f5aadedbffff 892f5aad3afffff 892f5aad3abffff]

これに対して、さらにクエリ条件である時間 (2020年3月15日0時0分から 2020年3月31日23時59分の間) も加味し、読み出したい行キーの範囲を作ります。

type keyRange struct {
begin string
end string
}
func fetchAndPrintRowsWithin(ctx context.Context, lat float64, lon float64, dist float64, timeFrom time.Time, timeTo time.Time, res int) error {
// Bigtable クライアントの生成
client, err := bigtable.NewClient(ctx, project, instance)
if err != nil {
return err
}
defer client.Close()
tbl := client.Open(tableName)
// lat, lon から dist 内にある全インデックスを取得
indices := listH3IndicesWithin(lat, lon, dist, res)
keyRanges := make([]keyRange, len(indices))
for i, index := range indices {
// 取得した全インデックスとタイムスタンプ範囲の組み合わせで行キーの範囲を生成
keyRanges[i] = keyRange{
begin: fmt.Sprintf("%s#%s#%d", "car", index, timeFrom.Unix()),
end: fmt.Sprintf("%s#%s#%d", "car", index, timeTo.Unix()),
}
}
(cont.)

実際の行範囲は次のようなものです。

car#892f5a32d97ffff#1584230400 ~ car#892f5a32d97ffff#1585699199
car#892f5a32d93ffff#1584230400 ~ car#892f5a32d93ffff#1585699199
car#892f5a32d83ffff#1584230400 ~ car#892f5a32d83ffff#1585699199
...略...

この行キーの範囲を使って実際にクエリを実行します。

(cont.)  // Start timer
start := time.Now()
// 先程の行キーの範囲から bigtable.RowRange を作成
rowSet := make([]bigtable.RowRange, len(keyRanges))
for i, keyRange := range keyRanges {
rowSet[i] = bigtable.NewRange(keyRange.begin, keyRange.end)
}
// RowRange を使って実際に Bigtable からの読み取りを実行
rows := make([]bigtable.Row, 0, 100)
if err := tbl.ReadRows(ctx, bigtable.RowRangeList(rowSet), func(row bigtable.Row) bool {
rows = append(rows, row)
return true
}, bigtable.RowFilter(bigtable.PassAllFilter())); err != nil {
return err
}
// Stop timer
elapsed := time.Since(start)
(cont.)

ここで得られた行は H3 のインデックスに含まれる行であり、厳密に東京駅から半径 1 km 以内にあるものではありません。
荒い結果で十分であればこのままでも良いですが、より正確に実際の位置が東京駅から半径 1 km 以内かどうかを調べて結果を修正します。

(cont.)  // 取得してきた位置情報が実際に東京駅から 1 km 以内かを確認
// c.f. https://github.com/kellydunn/golang-geo
p := geo.NewPoint(lat, lon)
results := make([]bigtable.Row, 0, len(rows))
for _, row := range rows {
pos := row["pos"]
var lat2 *float64
var lon2 *float64
// Bigtable にはバイト配列として入っているので、浮動小数点にデコード
for _, column := range pos {
if column.Column == "pos:lat" {
bits := binary.LittleEndian.Uint64(column.Value)
f := math.Float64frombits(bits)
lat2 = &f
} else if column.Column == "pos:lon" {
bits := binary.LittleEndian.Uint64(column.Value)
f := math.Float64frombits(bits)
lon2 = &f
}
}
if lat2 != nil && lon2 != nil {
p2 := geo.NewPoint(*lat2, *lon2)
d := p.GreatCircleDistance(p2)
// 実際に <= 1 km のものだけを結果として採用
if d <= dist*0.001 {
results = append(results, row)
}
}
}
// 結果の表示
for _, result := range results {
printRow(result)
}
fmt.Printf("Response Time: %s\n", elapsed)
return nil
}

クエリの結果を見てみましょう。

car#892f5a32d87ffff#1585401812: 
pos:lat -> 35.681728 @ 2020-03-28 13:23:32 +0000 UTC
pos:lon -> 139.763941 @ 2020-03-28 13:23:32 +0000 UTC
pos:time -> 2020-03-28T13:23:32Z @ 2020-03-28 13:23:32 +0000 UTC
car#892f5a32d97ffff#1584313236:
pos:lat -> 35.680290 @ 2020-03-15 23:00:36 +0000 UTC
pos:lon -> 139.768497 @ 2020-03-15 23:00:36 +0000 UTC
pos:time -> 2020-03-15T23:00:36Z @ 2020-03-15 23:00:36 +0000 UTC
car#892f5a32da7ffff#1584499663:
pos:lat -> 35.682576 @ 2020-03-18 02:47:43 +0000 UTC
pos:lon -> 139.756833 @ 2020-03-18 02:47:43 +0000 UTC
pos:time -> 2020-03-18T02:47:43Z @ 2020-03-18 02:47:43 +0000 UTC
car#892f5a32dabffff#1584289496:
pos:lat -> 35.686896 @ 2020-03-15 16:24:56 +0000 UTC
pos:lon -> 139.759789 @ 2020-03-15 16:24:56 +0000 UTC
pos:time -> 2020-03-15T16:24:56Z @ 2020-03-15 16:24:56 +0000 UTC
car#892f5a32dbbffff#1585398750:
pos:lat -> 35.685460 @ 2020-03-28 12:32:30 +0000 UTC
pos:lon -> 139.763134 @ 2020-03-28 12:32:30 +0000 UTC
pos:time -> 2020-03-28T12:32:30Z @ 2020-03-28 12:32:30 +0000 UTC
car#892f5a32dd7ffff#1585607649:
pos:lat -> 35.686725 @ 2020-03-30 22:34:09 +0000 UTC
pos:lon -> 139.773918 @ 2020-03-30 22:34:09 +0000 UTC
pos:time -> 2020-03-30T22:34:09Z @ 2020-03-30 22:34:09 +0000 UTC
car#892f5aad36fffff#1584304613:
pos:lat -> 35.684548 @ 2020-03-15 20:36:53 +0000 UTC
pos:lon -> 139.776720 @ 2020-03-15 20:36:53 +0000 UTC
pos:time -> 2020-03-15T20:36:53Z @ 2020-03-15 20:36:53 +0000 UTC
car#892f5aad377ffff#1585484478:
pos:lat -> 35.679900 @ 2020-03-29 12:21:18 +0000 UTC
pos:lon -> 139.776210 @ 2020-03-29 12:21:18 +0000 UTC
pos:time -> 2020-03-29T12:21:18Z @ 2020-03-29 12:21:18 +0000 UTC
Response Time: 84.08152ms

1 億件の行の中から、2020年3月15日0時0分から2020年3月31日23時59分の間に、東京駅から半径 1 km 以内にあった車のデータ 8 件を抽出することができました。
せっかくなので Google Maps API で位置を可視化してみましょう。

今回は H3 の解像度として 9 という比較的細かな粒度でクエリを実行しており、このクエリには 61 ものインデックスの検索が含まれています。
ワークロードに応じて解像度を調整することで、精度とパフォーマンスのトレードオフを調整することができます

また、今回はある地点から一定距離以内に含まれているかという簡単なクエリを試しましたが、アプリケーション層 (今回は H3) で処理を行い、最終的に Bigtable から読み出すべきインデックスに落とし込むことで、もっと複雑な位置情報のクエリも実現できるでしょう。

まとめ

本記事ではスケーラビリティに優れた、低レイテンシで高スループットな Cloud Bigtable を使って大量の位置情報を扱う例をご紹介しました。

GCP には Cloud Bigtable 以外にもユニークなストレージ サービス (BigQuery や Cloud Spanner) があります。是非 cloud.google.com から様々なサービスを試してみてください!

--

--

Toshiyuki (Topy) Isobe
google-cloud-jp

Customer Engineer@Google Cloud. All views and opinions are my own.