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

BigQuery SQLでややこしい処理を書くための小ネタ集

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.

BigQuery SQLでややこしい処理を書くための小ネタ集

Avatar for NISHIKAWA, Daisuke

NISHIKAWA, Daisuke

November 13, 2025
Tweet

More Decks by NISHIKAWA, Daisuke

Other Decks in Technology

Transcript

  1. 3 自己紹介 ▪ 西川 大亮 ▪ 2019/4 DeNA入社、2020/4 GO転籍(当時Mobility Technologies)

    ▪ 前職: 中堅SIer ▪ 14年研究所、3年コンサル ▪ 顧客向け技術紹介と火消しが主な仕事 (Projが燃える匂いには敏感) ▪ 趣味 ▪ ゴルフ(年数回) ▪ 競馬予想(サボり気味) ▪ 設計欲を満たせるゲームにハマりやすい ▪ やっていること ▪ タクシーアプリ『GO』(主に乗務員端末)の挙動解析 ▪ 課題解決:原因調査→類型化→自動検知 ▪ 性能改善:現状把握→KPI定義→レポート+指針策定 ▪ システムよりも人間系の解析が中心
  2. 7 やりたいこと • 1時間単位のイベント数の推移をグラフで見たい 処理方針 • truncで丸めて集計するとログがない時刻で欠損 ◦ グラフにすると山と山を繋いだ形になりNG ◦

    テーブルでも欠損があるとかなり見づらい • 対策: 期間を全パターン生成して(unnest~)、集 計結果を流し込む ◦ ただし期間の定義が必要 • 複数のイベントログでfull outer joinするのはな んとなく全部埋まってそうに見えて欠損があるの で欠損を埋める目的ではアンチパターン 集計時に欠損時刻を埋める① 時刻 ユーザID 11/13 13:30 1 11/13 13:45 2 11/13 15:10 3 … … 時刻(1時間毎) 件数 11/13 13:00 2 11/13 14:00 0 11/13 15:00 1 … … イベントログ 1時間毎のイベント件数 素直に集計すると この0は出ない select timestamp_trunc(ts, hour) as ymdh, count(1) as cnt, from event_log group by ymdh order by ymdh -- debug select ymdh, ifnull(cnt, 0) as cnt, from ( select timestamp_trunc(ts, hour) as ymdh, from unnest(generate_timestamp_array( '2025-10-01', '2025-11-01', interval 1 hour)) as ts ) left outer join ( select timestamp_trunc(ts, hour) as ymdh, count(1) as cnt, from event_log group by ymdh ) using(ymdh) order by ymdh -- debug 欠損時刻あり 欠損時刻なし
  3. 9 SQLでパラメータの組み合わせを作る • 似たような小ネタとしてパラメータをSQLで作ってしまう方法 ◦ 書き捨てなのでテーブルにしたくない場合とか • パラメータを配列で用意してcross join で組み合わせる

    • 開始日を1週間1日ずつ取り、集計期間を5、7、14日に取った例 select start_date, date_add(start_date, interval days day) as end_date, days, -- debug from unnest(generate_date_array('2025-11-06', '2025-11-12')) as start_date cross join unnest([5, 7 ,14]) as days start_date end_date days 2025/11/06 2025/11/11 5 2025/11/06 2025/11/13 7 2025/11/06 2025/11/20 14 2025/11/07 2025/11/12 5 … … … これで7x3=21パターンのパラメータ ができる
  4. 11 マスタの変更履歴から状態を復元する① やりたいこと • 端末ログに対応する車両を知りたい ◦ 端末は入れ替わりがある ◦ ある時点での端末IDに紐づく車両IDが必要になる 車両ID

    更新時刻 端末ID 車両番号 事業所ID … 1 2025/11/12 101 001 1003 1 2025/06/01 109 001 1003 1 2023/01/01 109 025 1001 1 2022/10/01 96 025 1001 2 2025/11/05 113 01 2001 … … … … … 車両マスタ更新履歴 †マスタの変更履歴を全て残す方法をSlowly Changing Dimensions Type 4と呼ぶようです 端末ID 車両ID 開始 終了 101 1 2025/11/12 109 1 2025/06/01 2025/11/12 109 1 2023/01/01 2025/06/01 96 1 2022/10/01 2023/01/01 113 2 2025/11/05 … … … … 端末IDー車両IDペア(重複あり) 処理方針 • 現時点のマスタでは不十分。変更履歴†から復元する • 更新時刻のleadを取る ◦ ただし同じペアが複数行出てしまう(赤枠) select -- start_tsは閉区間, end_tsは開区間 car_id, device_id, updated_at as start_ts, lead(updated_at) over (partition by car_id order by updated_at) as end_ts, from car_history order by car_id, start_ts desc -- debug
  5. 12 マスタの変更履歴から状態を復元する② 端末ID 車両ID 開始 終了 101 1 2025/11/12 109

    1 2023/01/01 2025/11/12 96 1 2022/10/01 2023/01/01 113 3 2025/11/05 … … … … 端末IDー車両IDペア(重複なし) やりたいこと • 端末ログに対応する車両を知りたい • ただし、人が見るので変更がない期間は 1行にまとめて欲しい 車両ID 更新時刻 端末ID 車両番号 事業所ID … 1 2025/11/12 101 001 1003 1 2025/06/01 109 001 1003 1 2023/01/01 109 025 1001 1 2022/10/01 96 025 1001 2 2025/11/05 113 01 2001 … … … … … 車両マスタ更新履歴 処理方針 • 連続する同一状態に同じ番号を振る ◦ 1つ前の履歴と比較して変更があっ たらフラグを立てる ◦ フラグの累積和を取り番号とする 端末ID 車両ID 更新時刻 # 101 1 2025/11/12 2 109 1 2025/06/01 1 109 1 2023/01/01 1 96 1 2022/10/01 0 113 3 2025/11/05 0 … … … … ユーザごとのフラグの累積和
  6. 13 マスタの変更履歴から状態を復元する③ with car_device_pair as ( select *, sum(is_changed) over

    (partition by car_id order by updated_at rows between unbounded preceding and current row) as serial_no, from ( select car_id, device_id, if (lag(device_id) over w != device_id, 1, 0) as is_changed, updated_at, ifnull(lead(updated_at) over w, '3000-01-01') as lead_updated_at, from car_history window w as (partition by car_id order by updated_at) ) ) select car_id, device_id, min(updated_at) as start_ts, max(lead_updated_at) as end_ts, from car_device_pair group by car_id, device_id, serial_no order by car_id, start_ts -- debug 1つ前の履歴と比較†して変更があったらフラグを立てる フラグの累積和を取り番号を作る この番兵がないと直近の期間が作られないので注意 ※分析関数(ここだとlagとsum)は入れ子にできないので 1ステップずつ副問になってしまうのは残念な所 †複数項目あるならformat('%t %t %t', val1, val2, val3) のように1つの値にまとめると楽
  7. 14 イベントログから期間を作る① やりたいこと • ログイン/ログアウトイベントからログ イン期間を作る ◦ エリア出入りやアイテム脱着も同様 • ただしログインセッションIDはない

    時刻 ユーザID 11/13 13:30 1 11/13 13:45 2 11/13 15:10 3 … … ログインログ ログアウトログ 時刻 ユーザID 11/13 17:10 3 11/13 17:55 1 11/13 18:10 4 … … ログイン ログアウト ユーザID 11/13 13:30 11/13 17:55 1 11/13 13:45 2 11/13 15:10 11/13 17:10 3 … … ログイン期間 ユーザID 時刻 種別 # 1 11/12 18:55 ログアウト 0 1 11/13 13:30 ログイン 1 1 11/13 17:55 ログアウト 1 1 11/14 13:10 ログイン 2 1 11/14 13:11 ログイン 2 1 11/14 18:11 ログアウト 2 1 11/14 18:12 ログアウト 2 1 11/15 13:00 ログイン 3 処理方針 • 2つのイベントログをを1つにつなげてセッション番号を降る ◦ ログイン期間なら(1つ前が)ログアウト→ログイン時にフラグを立てる ◦ フラグの累積和を取りログインセッション番号を作る • 同種のログが連続した場合は最長になる ◦ 異常値の考慮は必須(ログにクリーンさを期待してはいけない) ユーザごとのフラグの累積和
  8. 15 イベントログから期間を作る① with event_log as ( select user_id, ts, 'login'

    as tp, from login union all select user_id, ts, 'logout' as tp, from logout ) select user_id, min(if(tp='login', ts, NULL)) as login_ts, max(if(tp='logout', ts, NULL)) as logout_ts, from ( select *, sum(is_start) over ( partition by user_id order by ts rows between unbounded preceding and current row ) as session_no, from ( select *, if (lag(tp) over (partition by user_id order by ts) = 'logout' and tp = 'login', 1, 0) as is_start, from event_log ) ) group by user_id, session_no having login_ts is not NULL -- 最初のログがログアウトを除外 order by user_id, login_ts -- debug 2つのイベントログを1つにつなげる ログアウト→ログイン時にフラグ1を立てる フラグの累積和を取りログインセッション番号を作る 単にmin(ts)とmax(ts)だと集計期間の都合で ログインがないケースで問題となる
  9. 16 種類の異なる状態を組み合わせる やりたいこと • ログイン中のエリア滞在時間が知りたい ◦ ログインとエリア出入りは独立事象なので境界値 はバラバラになる ◦ 結合後に境界値の調整が必要

    ログイン エリア内 エリア内 エリア内 select --どちらも開始が閉区間、終了が開区間 l.car_id, greatest(l.login_ts, a.enter_ts) as start_ts, least(l.logout_ts, a.exit_ts) as end_ts, from login_session as l inner join in_area as a on l.car_id = a.car_id and l.login_ts < a.exit_ts and a.enter_ts < l.logout_ts ※ どちらの式にも開区間の変数があるので等号はつかない 処理方針 • 結合条件 ◦ 一方が他方に少しでもかかっていたら結合する • 境界値の調整 ◦ 両者が成立する範囲に切り落とす
  10. 17 深夜にエリアにいたログイン時間のみ集計する やりたいこと • …はタイトルそのまま ◦ 労働基準法における深夜は22~5時 ログイン エリア内 エリア内

    エリア内 深夜 処理方針 • 同様に深夜期間を作って先程の結果と組み合わせる ◦ 結合条件や境界値の調整も同じ • 固定値だからとhourを抜いてif で何とかしようとす るとハマる ◦ 開始時刻と終了時刻のそれぞれで22時前、0時前、 5時前、での場合分けが必要 ◦ 加えて24時間超経過したときの対応も必要 with late_night as ( select timestamp_add(ts, interval 22 hour) as late_nighit_start_ts, timestamp_add(ts, interval 24+5 hour) as late_nighit_end_ts, from unnest(generate_timestamp_array( '2025-10-31', '2025-11-07', interval 1 day)) as ts ) select car_id, greatest(start_ts, late_nighit_start_ts) as start_ts, least(end_ts, late_nighit_end_ts) as end_ts, from ( select --どちらも開始が閉区間、終了が開区間 l.car_id, greatest(l.login_ts, a.enter_ts) as start_ts, least(l.logout_ts, a.exit_ts) as end_ts, from login_session as l inner join in_area as a on l.car_id = a.car_id and l.login_ts < a.exit_ts and a.enter_ts < l.logout_ts ) inner join late_night as n on n.late_nighit_start_ts < end_ts and start_ts < n.late_nighit_end_ts ※ ここまでtimestampで書いてしまいましたが、JSTで扱いたいのでdatetimeで書いた方が良いです。 元データはtimestampなので、どこでdatetimeに切り替えるかは結構悩ましいです。 1つ前のスライドの コード
  11. 19 場所をラフにグルーピングする やりたいこと • 車両がよく使う待機場所を知りたい 処理方針 • 緯度経度そのままだと集計が難しい ◦ 測定誤差

    ◦ そもそも全く同じ所に停まらない • uber/h3-jsを使う ◦ GCSに置いて create function の (option library=~)で指定する(安全性担保) ◦ h3.geoToH3をラップした永続関数を作成 ◦ h3 indexが取れる with location as ( select * from unnest([ struct(35.680444 as lat, 139.768493 as lon, 1 as no), struct(35.681449, 139.765817, 2) ]) ) select *, func.geoToH3(lat, lon, 8) as h3_res_08, func.geoToH3(lat, lon, 9) as h3_res_09, func.geoToH3(lat, lon, 10) as h3_res_10, from location ※ 東京駅の丸の内口(黄)と八重洲口(紫)はresolusion=10(緑)だと離れた ブロック、9(青)だと隣接ブロック、8(黄)だと統合される
  12. 20 一番近い設備を見つける やりたいこと • EVタクシーの充電場所を知りたい ◦ 充電器の位置情報はある ◦ 車両の充電記録にも位置情報がある ◦

    ただし車両の充電記録には充電器のIDはない ◦ 2つの位置は完全一致しない • 紐づける処理での総当りは避けたい ◦ 距離が近い組み合わせだけで評価したい 処理方針 • uber/h3-jsのh3.kRingで隣接するh3 indexが取れる ◦ 充電器に近いエリアが定義できる • 車両の緯度経度をh3 indexに変換して結合すればOK ◦ ここから車両と充電器の距離を求めて確定させる ◦ フィルターをh3ブロックの直径以下にして偏りを防ぐ with location as ( select * from unnest([ struct(35.680444 as lat, 139.768493 as lon, 1 as charger_id), struct(35.681449, 139.765817, 2) ]) ) select * except(kring_array) from ( select *, func.kRing(func.geoToH3(lat, lon, 12), 1) as kring_array from location ) cross join unnest(kring_array) as kring_h3_index ※ 東京駅の丸の内口(黄)と八重洲口(紫)のresolusion=12での自身と隣接ブロック 一度この範囲でフィルター後に距離を求める