モバイルで綴ったデータをサーバへ同期する管を作っている連載の、設計まわりの記録です。
ここで言う「web」は、綴ったものを web でも見られるように開発中のアプリ(紡Book)のことです。
綴Photo は、モバイルアプリと web、その間に立つ backend でできています。モバイルで綴ったデータを backend に送る「同期」を作るとき、1つの綴り(メモ)に IDを2本 持たせることにした。なぜ2本いるのか、それがどんな保険になるのかを整理しておく。
なぜ1つの綴りに2つのIDがいるのか
2本の正体はこう。
- clientMemoryId(UUID)— モバイルが、綴った瞬間に手元で作る。ネットワークに繋がる前から存在する「端末側の名前」。
- memory_id(サーバが採番する連番)— その綴りが初めてサーバに届いたとき、サーバが付ける「サーバ側の名前」。
なぜ片方じゃ足りないか。端末は、綴った時点ではサーバの採番IDをまだ知りません(送ってみないと貰えません)。逆にサーバは、端末のローカルなデータ行を直接は知りません。だから 両者がそれぞれ永久に変わらない名前を持って、それを突き合わせる 形にした。端末側の名前があるから、サーバに届く前から「これは同じ綴り」と言い切れます。
// モバイル側の同期用の列(drift)。説明のため並べて示す(実際は clientMemoryId/serverId は綴り(Meaning)側、syncState は写真(Photo)側の別テーブル)
TextColumn get clientMemoryId => text()(); // 端末が綴った瞬間に作るUUID
IntColumn get serverId => integer().nullable(); // 同期後にサーバから貰うID・未同期はnull
TextColumn get syncState => text().withDefault(const Constant('pending'))(); // pending/synced(失敗時は error を立てず再送)冪等:同じ綴りを2回送っても1個しかできない
2本のうち、効くのは送るときに一緒に渡す clientMemoryId の方。サーバはこれを「もう受け取ったやつ?」の鍵に使います。
綴Photo は実装を AI に手伝ってもらいながら進めていて、この同期処理の核も AI に書いてもらった。「送る側がこう動く」とだけ伝えて出てきたのが下のコードだ。IDを2本にするのはベストプラクティスを踏まえた実装で、自分はそれが何をしていて・どこまで保険が閉じているか(後述の片肺の話)を後から辿って理解した。
# backend 側。AI に書いてもらった同期処理の核
meaning = Meaning.objects.filter(owner=user, client_memory_id=client_memory_id).first() # clientMemoryId を鍵にDBを検索 — 「この名前のやつ、もう持ってる?」
if meaning is not None:
# もう持っている → 新しく作らず "duplicated" を返す(既存の memory_id を教える)
continueここを「2回送っても平気」にしておきたかったのが、IDを2本にした一番の理由だった。端末側の名前(clientMemoryId)が無いと、サーバは「さっきのと同じ綴りか、別の新しい綴りか」を判定できません。
いまは片肺:サーバ側の保険だけが効いている
実装の現状も書いておく。この保険はまだ半分しか閉じていない。
- 効いている:サーバの冪等。同じ
clientMemoryIdの再送で二重に作られません(テストで確認した)。 - まだ:モバイルが、返ってきた
memory_idを自分のserverId列に書き戻したり、syncStateをpending → syncedに更新する処理。今の同期は「送って、結果を集計するだけ」で、端末側のDBは触っていません。
// 今の同期サービス。送って結果を集めるだけで、DBには書き戻していない
final json = await _apiClient.post(endpoint, body: {'items': chunk});
all.addAll(parseSyncResponse(json).results);つまり今は、端末が「これはもう同期済み」と覚えていないので、毎回ぜんぶ送り直し、サーバの冪等だけで二重を防いでいます。スキーマ(serverId・syncState)は受け皿として用意してあるので、次はここに書き戻すループを閉じる。
いまこの状態が何を意味するかも書いておく。端末側が「どの綴りはもう同期済みか」を覚えていないので、アプリを再インストールしたり機種変更したりすると、端末のローカルDBが空から出発します。その状態でサーバに接続しても「どの serverId が自分の何に対応するか」が繋げられません。clientMemoryId を返ってきた serverId と紐づけて書き戻すループを閉じて、初めてこの再接続が成立します。今はサーバの冪等が「二重を防ぐ」保険として機能しているが、「端末の記憶が飛んでも繋ぎ直せる」保険はまだ開いたままだ。
そうして初めて「端末を新しくしても、clientMemoryId で照合して重複なく繋ぎ直せる」という当初の保険が本当に効きます。
実装は「冪等キー」という定石だった、と後から腹落ちした
この同期処理は AI に手伝ってもらって組んだもので、IDを2本持たせて端末側の名前(clientMemoryId)を再送の鍵にする形も、ベストプラクティスを踏まえて実装されていた。これが idempotency key(冪等キー) という名前の付いた定番のやり方だと、あとから辿って知った。
一番わかりやすいのが決済の Stripe で、リクエストに Idempotency-Key を自分で付けて送ります。通信が切れて再送しても、同じキーなら二重に課金されません。今回サーバがやっている「この名前のやつ、もう持ってる?」と発想は同じだ。端末で先に作ってあとから同期する種類のアプリで、端末生成のIDを使うのも珍しくありません。
自分で思いついた奇策ではなく、すでに広く使われている定石だと分かると、安心してその上に次の機能を積める。今回あらためて、何をしていて・なぜこれで二重が防げるのかを一つずつ辿って、ようやく腹落ちした。
もう一つ、記事本文では触れなかったが効いているのが、この列に張ってあるユニーク制約だ。
class Meta:
constraints = [
models.UniqueConstraint(
fields=["owner", "client_memory_id"],
name="uniq_meaning_owner_client_memory_id",
)
]コード側の「もう持ってる?」チェックだけだと、同じ再送が同時に2回届いたとき、両方が「無い」をすり抜けて二重に作られる隙間が残ります。DBにユニーク制約を張っておくと、2本目の挿入はDBが弾きます。「冪等っぽく見える」と「並行でも冪等」の差はここにあって、列が1つ増えるコスト(UUID1個ぶん・写真ファイルの重さに比べれば誤差)は、この保証と釣り合っています。
この「同時に2回届いたとき」の流れを、端末・サーバー・DB のやり取りとして図にすると、こうなる。①② で端末が同じ clientMemoryId を送り、サーバーは両方に「無い」を返してしまいます(すり抜け)。③ で1本目が入り、④ の2本目をユニーク制約が弾きます。
この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。