接続は「当たり前」ではなく「贅沢品」である
半年前に私はフィールドサービス向けアプリを再構築し、標準的なREST重視의モデルから完全なオフラインファースト・アーキテクチャへと移行しました。私の使命は、ローディングスピナー(待機中の回転アイコン)を排除することでした。ユーザーは、重要なデータを保存しようとしている最中に「接続がありません」というエラーを見つめるべきではありません。現場では、モバイルデータ通信は不安定です。技術者たちは、5Gが神話でしかないような電波の遮断された地下室や、離れた作業現場を移動します。
標準的なアーキテクチャでは、サーバーが「真実のソース(Source of Truth)」であると想定されています。私たちはそれを逆転させました。私たちの世界では、ローカルのSQLiteデータベースが即時の権威となります。サーバーはグローバルなアグリゲーターおよびバックアップとして機能します。このパラダイムシフトは単なる技術的なものではなく、ユーザーの時間をいかに尊重するかという考え方の変化です。
SQLiteが業界標準であり続けるのには理由があります。それは目に見えず、設定も不要で、ほぼすべてのデバイスに存在しています。本当の課題はストレージではなく、同期ロジックにあります. 2人の技術者が別々の地下室から同時に同じ空調ユニットを更新したらどうなるでしょうか?本番環境で180日間運用した結果、答えは明確です。UIのラグは消失し、「データ消失」に関するサポートチケットは40%減少しました。
同期環境のセットアップ
これを再現するために、私はPythonベースのスタックを使用しています。このロジックはNode.jsやSwiftにも簡単に移植可能です。ローカルのSQLiteインスタンスを使用し、リモートのPostgreSQLサーバーをセントラルハブとして利用します。
まずはコアとなる依存関係をインストールしましょう。クリーンなデータベース抽象化のためにdatasetを、分散システムで実際に機能する識別子のためにulid-pyを使用します。
pip install sqlalchemy dataset ulid-py requests
私は標準的な整数よりもULID(Universally Unique Lexicographically Sortable Identifiers)を好んで使用します。ULIDはデバイスをまたいで一意であり、時間順にソート可能です。これにより、ネットワーク接続がない状態で50台のデバイスが同時にレコードを作成しても、IDの衝突を防ぐことができます。
import dataset
from ulid import ULID
# ローカルストレージの初期化
local_db = dataset.connect('sqlite:///local_storage.db')
table = local_db['work_orders']
# 'dirty'フラグを使用したレコード作成
work_order_id = str(ULID())
table.insert({
'id': work_order_id,
'title': 'HVACシステムの修理 - ユニット 4B',
'status': 'pending',
'last_updated': 1717845600,
'sync_status': 'dirty' # 'dirty'が同期ワーカーをトリガーする
})
同期エンジン
私たちの実装は「ダーティフラグ(Dirty Flag)」システムに依存しています。SQLiteとPostgreSQLの両方のすべてのテーブルに、last_updated(Unixタイムスタンプ)とローカルのsync_statusフィールドを含める必要があります。
1. 変更のトラッキング
変更が行われるとステータスが変わります。ユーザーがレコードを編集するたびに、ローカルのsync_statusが「dirty」に切り替わります。これがバックグラウンドワーカーへの合図となり、次回のハンドシェイク時にこの行を優先的に処理します。
2. プッシュ・プル ループ
同期は、ローカルの変更をプッシュするフェーズと、他のデバイスからのリモート更新をプルするフェーズの2つの明確な段階で実行されます。
def sync_with_server():
# フェーズ1: ローカルの編集内容をプッシュ
dirty_records = table.find(sync_status='dirty')
for record in dirty_records:
try:
response = requests.post('https://api.myserver.com/sync', json=record)
if response.status_code == 200:
table.update(dict(id=record['id'], sync_status='synced'), ['id'])
except Exception as e:
print(f"プッシュ失敗: {e}")
# フェーズ2: 最新のグローバルな変更をプル
last_sync_time = get_last_sync_timestamp()
remote_changes = requests.get(f'https://api.myserver.com/changes?since={last_sync_time}').json()
for remote_record in remote_changes:
local_record = table.find_one(id=remote_record['id'])
if not local_record or remote_record['last_updated'] > local_record['last_updated']:
table.upsert(remote_record, ['id'])
table.update(dict(id=remote_record['id'], sync_status='synced'), ['id'])
3. コンフリクトの解消
コンフリクト解消は、オフラインファーストにおける最大の難題です。しかし、月間3,000件以上のワークオーダーのうち95%は、「最後のリクエストが優先(Last Write Wins: LWW)」で問題なく処理できました。タイムスタンプを比較し、最新のバージョンを保持します。複雑なテキストフィールドについては、最終的にdiff-mergeを追加しましたが、ソリューションを過剰に作り込む前に、まずはLWWをマスターすることをお勧めします。
検証:データの整合性を維持する
オフラインファーストへの移行は、新たな死角を生みます。ユーザーのアクティビティを検証するために、サーバーログだけに頼ることはできません。私たちは定期的にELKスタックにアップロードされるローカルヘルスログを実装しました。
同期レイテンシのトラッキング
私たちは、ローカルでの作成からサーバーへの到達までの時間差である「同期レイテンシ」を監視するためのダッシュボードを構築しました。全デバイスの平均が30分を超える場合、通常はバックグラウンドワーカーのバグか、地域的なネットワーク障害を指し示しています。
週次の整合性監査
整合性監査は、静かなデータの腐敗を防ぎます。週に一度、クライアントはすべてのローカルIDのハッシュを計算してサーバーに送信します。ハッシュが一致しない場合、システムは詳細な照合プロセスをトリガーします。これにより、先月も、同期中のネットワーク切断によって一部のレコードが不完全になったエッジケースを3件特定できました。
# 基本的な整合性チェックのロジック
def verify_integrity():
local_count = len(table)
local_hash = calculate_db_hash(table)
status = requests.post('https://api.myserver.com/verify', json={
'count': local_count,
'hash': local_hash
})
if status.json().get('mismatch'):
trigger_reconciliation()
このアーキテクチャの設計は骨が折れますが、その見返りは、軽快で弾力性のあるプロダクトです。ユーザーはSQLiteからPostgreSQLへの同期ロジックなど気にしません。彼らはただ、コンクリートの地下室でもアプリが動くことを望んでいるのです。ローカルデータベースをプライマリインターフェースとして扱うことで、現実世界の混沌の中でも生き残るソフトウェアを構築できるのです。

