バッテリー最適化がデータの正しさを壊した話 — 22時間でFK違反1,427回

| 開発記録 | 位置情報

タグ: #パフォーマンス #SQLite #Flutter #個人開発

常時位置情報を扱うアプリでバッテリー消費を削減しようとした結果、DBの外部キー違反が無限リトライする構造を作ってしまいました。実機ログの「22時間で1,427回失敗・batch成功率24%」という数字から学んだ、最適化とデータ整合性の話です。

常時 GPS のアプリはバッテリーとの戦い

位置情報を常時扱うアプリを運用しています。この種のアプリの宿命がバッテリー消費で、目標として 30〜50% の削減を掲げて最適化に着手しました。結論から言うと、攻めた最適化は 2 連続で Revert され、生き残ったのは地味な施策だけでした。その失敗の記録です。

失敗1: 動的ポーリング — 賢くしたら壊れた

最初の案は行動タイプ別に取得間隔を変える動的ポーリングでした。静止中は 300 秒、徒歩は 30 秒、乗り物は 15 秒。理屈は完璧に見えます。

ところが投入後、位置の記録精度が下がり、状態遷移からの復帰も遅れる症状が出ました。取得間隔を伸ばすということは「状態が変わった瞬間を見逃す」ということでもある。静止判定中にユーザーが歩き出しても、次のサンプルは最大 300 秒後です。翌日に Revert しました。

失敗2: batch insert — 無限リトライ製造機

次の案は DB 書き込みのバッチ化です。位置レコードを1件ずつ INSERT する代わりに溜めてまとめて書く。I/O 回数が減ってバッテリーに優しいはず……でした。

実機ログを分析して青ざめました。22 時間で FOREIGN KEY constraint failed が 1,427 回、未処理キューに 1,433 件滞留、バッチ成功率 24%

原因は失敗時の設計にありました。バッチが失敗すると、そのレコード群をキューの先頭に戻して再試行する実装にしていたのです。ところが失敗の原因が「親レコードが既に削除された古い ID を参照している」だった場合、そのレコードは何度リトライしても永久に失敗する。先頭に戻すので後続も詰まる。リトライ設計の教科書的なアンチパターンを、自分の手で実装していました。

生き残った最適化: ロジックを変えず I/O だけ削る

最終的に残ったのは、保存されるデータを 1 ビットも変えない施策だけです。

  • 位置ストリームが生きている間は、保険用の定期取得をスキップ(直近 25 秒以内に有効な位置があれば早期 return)
  • 行動レコードの終了時刻 UPDATE を 10 秒に 1 回へスロットリング。「最終的な終了時刻は次のサンプルで上書きされる値なので、途中の書き込みが遅延しても最終値は変わらない」という正当性を確認した上で

もうひとつの学び: ログがなければ気づけなかった

この failure loop は、クラッシュもエラーダイアログも出しません。ユーザーから見れば「なんとなく電池の減りが早い」だけ。発見できたのは、DB 書き込みの失敗を件数付きでローカルログに残していたからです。「22 時間で 1,427 回」という数字が取れて初めて、感覚ではなく構造の問題だと確信できました。バックグラウンドで黙々と動く処理には、成功件数と失敗件数のカウンタを最初から仕込んでおく。地味ですが、これがなければ今も 24% の成功率で回り続けていたはずです。

教訓

最適化には「正しさを変えないことが証明できる範囲」から手を付けるべきでした。動的ポーリングは挙動を変え、バッチ化はエラーハンドリングという新しい複雑さを持ち込んだ。どちらも「速くなったが壊れた」。パフォーマンス改善の PR には、削減された数値と同じ熱量で「何が変わらないことを確認したか」を書くべきだと痛感しています。