0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

配送申請の二重送信を防ぐ:PythonとSQLiteで冪等キーを実装・テストする

0
Posted at

再送を「もう一件の申請」にしない

モバイルで配送申請を送った直後に通信が切れると、利用者には登録できたか分かりません。ボタンを無効にするだけでは、再読み込みや通信の再試行を止められません。サーバー側で「同じ操作の再送」を区別する必要があります。

筆者は韓国向け配送代行サービスKimfastの関係者です。この記事は配送申請という題材を使った独立した学習用コードです。Kimfastの本番実装、障害事例、性能測定を公開するものではありません。顧客データも使いません。

先に決める3つの動作

入力 返す結果
まだ保存されていないキー 新しい申請ID
同じアカウント・同じキー・同じ内容 前回と同じ申請ID
同じアカウント・同じキー・違う内容 競合エラー

キーは送信を押すたびではなく、一つの申請操作について生成し、結果が確定するまで再利用します。新しい申請には新しいキーを使います。アカウントIDは認証済みセッションからサーバーが決めます。クライアントに自由に指定させません。

同じ操作を再送したときの分岐図

最小構成:SQLiteに申請とキーを同時に保存する

次を idempotency_demo.py として保存します。標準ライブラリだけで動きます。例では保存された行そのものを申請とみなし、外部APIや決済は呼びません。

"""Educational example only; no connection to Kimfast production."""
import hashlib
import json
import sqlite3
import uuid
from contextlib import closing


def initialize(path):
    with closing(sqlite3.connect(path)) as db:
        db.execute("""CREATE TABLE IF NOT EXISTS requests (
            account TEXT NOT NULL, request_key TEXT NOT NULL,
            payload_hash TEXT NOT NULL, application_id TEXT NOT NULL,
            PRIMARY KEY (account, request_key))""")
        db.commit()


def submit(path, account, request_key, payload):
    if not account or not request_key:
        raise ValueError("account and request_key are required")
    canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"),
                           ensure_ascii=False, allow_nan=False)
    digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
    with closing(sqlite3.connect(path, isolation_level=None, timeout=5)) as db:
        db.execute("BEGIN IMMEDIATE")
        try:
            existing = db.execute(
                "SELECT payload_hash, application_id FROM requests "
                "WHERE account=? AND request_key=?",
                (account, request_key)).fetchone()
            if existing:
                if existing[0] != digest:
                    raise ValueError("same key used with a different payload")
                application_id, created = existing[1], False
            else:
                application_id, created = str(uuid.uuid4()), True
                db.execute("INSERT INTO requests VALUES (?, ?, ?, ?)",
                           (account, request_key, digest, application_id))
            db.commit()
            return application_id, created
        except Exception:
            db.rollback()
            raise

PRIMARY KEY (account, request_key) が一意性の境界です。BEGIN IMMEDIATE で書き込みトランザクションを開始し、確認と作成を同じトランザクションに入れます。この短い処理の中でネットワーク通信はしません。

JSONのキー順はそろえますが、これは業務上の同値性を完全に判定する仕組みではありません。たとえば 1 と 1.0、配列の順序は別扱いです。実サービスでは、入力の型・単位・必須項目を検証し、正規化した申請データを渡します。

まず再送と競合を試す

from idempotency_demo import initialize, submit

initialize("demo.db")
payload = {"origin": "AU", "destination": "KR", "parcels": 1}
first = submit("demo.db", "demo-account", "action-001", payload)
retry = submit("demo.db", "demo-account", "action-001", payload)
assert first[0] == retry[0]
assert first[1] is True and retry[1] is False

try:
    submit("demo.db", "demo-account", "action-001", {**payload, "parcels": 2})
except ValueError as error:
    print(error)  # same key used with a different payload

同じデータでも別のキーなら別の申請を作ります。内容が似ているだけで勝手に統合すると、利用者が意図した2回目の注文を消してしまうためです。

同時再送も確認する

テスト用の新しいDBで、別々の接続から同じ操作を8回呼びます。

from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from tempfile import TemporaryDirectory
import sqlite3

with TemporaryDirectory() as tmp:
    path = str(Path(tmp) / "concurrency.db")
    initialize(path)
    with ThreadPoolExecutor(max_workers=4) as pool:
        results = list(pool.map(
            lambda _: submit(path, "demo-account", "shared", payload),
            range(8),
        ))
    assert len({result[0] for result in results}) == 1
    assert sum(result[1] for result in results) == 1
    with sqlite3.connect(path) as db:
        assert db.execute("SELECT COUNT(*) FROM requests").fetchone()[0] == 1

2026年9月28日のローカル実行では、再送、JSONキー順、内容変更、アカウント分離、新規キー、空キー・NaN、同時再送の7つのunittestが通過しました。これは性能や本番可用性の保証ではありません。

ローカルで確認した7つのテストと結果

本番に持ち込む前に残ること

  • 認証・認可、CSRF対策、入力スキーマ、キー長とリクエストサイズの上限。
  • キーの保存期間と再利用ルール。期限後に再送された場合の扱い。
  • DBロックやタイムアウトを、成功・削除・未登録と即断しないこと。結果照会の導線を用意します。
  • 決済や送り状APIなど外部処理との整合性。DBの冪等性だけでは外部処理の二重実行を防げません。必要に応じてoutbox、外部サービス側の冪等キー、照合処理を別に設計します。
  • 申請IDの保存と実際の申請作成が別テーブルなら、両方を同じトランザクションで扱います。

UIでも「申請を受け付けた」と「発送が完了した」を分けて表示することが大切です。配送代行には申請、倉庫での確認、料金確認など異なる段階があります。題材の背景はKimfastのオーストラリア配送案内で確認できます。これは韓国向けサービスの案内であり、日本向け配送を提供するという意味ではありません。

参考資料・制作について

  • Python公式 sqlite3 ドキュメント:接続、トランザクション、パラメータを使ったSQL。
  • 図は本記事の処理と実行結果を説明するために作成したものです。実際の倉庫や顧客の写真ではありません。
  • 本文と例の作成にAIを使用しました。コードは上記の範囲でローカル実行しました。
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?