バッテリー最適化がデータの正しさを壊した話 — 22時間でFK違反1,427回
| 開発記録 | 位置情報
タグ: #パフォーマンス #SQLite #Flutter #個人開発
常時位置情報のアプリでバッテリーを削ろうとして、DB の外部キー違反が無限リトライする構造を自分で作っていました。実機ログは「22 時間で 1,427 回失敗・バッチ成功率 24%」。攻めた最適化が 2 連続で Revert され、地味な施策だけが生き残るまでの記録です。
常時 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 には、削れた数値と同じ熱量で「何が変わらないことを確認したか」を書く。次からそうします。電池を守ってデータを落としていたら、本末転倒でした。