綴Photo の勉強ノートに戻る
開発記録2026.06.16

同期を冪等にする① — 綴り1つにIDを2本持たせて「二重」を防ぐ

設計同期Django綴Photo

モバイルで綴ったデータをサーバへ同期する管を作っている連載の、設計まわりの記録です。

ここで言う「web」は、綴ったものを web でも見られるように開発中のアプリ(紡Book)のことです。

綴Photo は、モバイルアプリと web、その間に立つ backend でできています。モバイルで綴ったデータを backend に送る「同期」を作るとき、1つの綴り(メモ)に IDを2本 持たせることにした。なぜ2本いるのか、それがどんな保険になるのかを整理しておく。

なぜ1つの綴りに2つのIDがいるのか

2本の正体はこう。

  • clientMemoryId(UUID)— モバイルが、綴った瞬間に手元で作る。ネットワークに繋がる前から存在する「端末側の名前」。
  • memory_id(サーバが採番する連番)— その綴りが初めてサーバに届いたとき、サーバが付ける「サーバ側の名前」。

なぜ片方じゃ足りないか。端末は、綴った時点ではサーバの採番IDをまだ知りません(送ってみないと貰えません)。逆にサーバは、端末のローカルなデータ行を直接は知りません。だから 両者がそれぞれ永久に変わらない名前を持って、それを突き合わせる 形にした。端末側の名前があるから、サーバに届く前から「これは同じ綴り」と言い切れます。

dart
// モバイル側の同期用の列(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本にするのはベストプラクティスを踏まえた実装で、自分はそれが何をしていて・どこまで保険が閉じているか(後述の片肺の話)を後から辿って理解した。

python
# 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は触っていません。
dart
// 今の同期サービス。送って結果を集めるだけで、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を使うのも珍しくありません。

自分で思いついた奇策ではなく、すでに広く使われている定石だと分かると、安心してその上に次の機能を積める。今回あらためて、何をしていて・なぜこれで二重が防げるのかを一つずつ辿って、ようやく腹落ちした。

もう一つ、記事本文では触れなかったが効いているのが、この列に張ってあるユニーク制約だ。

python
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本目をユニーク制約が弾きます。

端末(クライアント)サーバー(Django)DB① リクエスト A(clientMemoryId=X)② リクエスト B(同じ X・ほぼ同時)A: X ある?B: X ある?A・B とも「無い」③ A: INSERT XA: 成立(X 1行目)④ B: INSERT X(同じ X)B: uniq違反で拒否A: created / B: duplicated制約が無ければ ④ の B も通り、X が 2 行(二重)になる

この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。