すべての記事

ロックを入れても壊れる理由──同時実行されているのは"データ"ではなく"判断"だった

ロックを入れても壊れる理由──同時実行されているのは"データ"ではなく"判断"だった

ロックを入れても壊れる理由──同時実行されているのは"データ"ではなく"判断"だった

排他制御は完璧だった。WALモード有効。トランザクション保証。でも「同じ商品に2回通知」が起きた──壊れていたのはデータの整合性ではなく、判断のタイミングだった。


① 導入:対策は完璧だった

排他制御を実装した。
WALモードを有効にした。
トランザクション境界を設定した。

でも、「同じ商品に2回通知」が起きた。

データベースのロックは正常だった。
競合状態の対策も入っている。
リトライロジックも実装済み。

それなのに、同じ商品の通知が重複する。

# ログの証拠
[INFO] [ハードオフ] 新商品検知: 1件が上位に挿入
  新1位: CANON EOS Kiss X7 ダブルズームキット・34800円
[INFO] ChatWork通知送信成功 (ルーム: 385402385)

# 30秒後
[INFO] [ハードオフ] 新商品検知: 1件が上位に挿入
  新1位: CANON EOS Kiss X7 ダブルズームキット・34800円
[INFO] ChatWork通知送信成功 (ルーム: 385402385)

同じ商品。同じルーム。2回。


② 何が起きていたか(事実だけ)

観測された事実

• SQLiteのロック: 正常動作

• トランザクション: ACID保証

• 通知履歴の書き込み: 成功

• 重複通知防止ロジック: 実装済み

• 結果: 重複通知が発生

実装を確認する。

# SQLiteNotificationHistory の実装
def should_notify(self, product_key: str, cooldown_hours: int = 6) -> bool:
    cutoff = datetime.now() - timedelta(hours=cooldown_hours)
    try:
        with self._get_connection() as conn:
            result = conn.execute(
                "SELECT notified_at FROM notifications "
                "WHERE product_key = ? AND notified_at > ?",
                (product_key, cutoff)
            ).fetchone()
            return result is None  # ← 履歴があればFalse
    except Exception as e:
        return False

def add_notification(self, product_key: str, site_name: str) -> None:
    try:
        with self._get_connection() as conn:
            conn.execute(
                "INSERT OR REPLACE INTO notifications "
                "(product_key, site_name, notified_at) VALUES (?, ?, ?)",
                (product_key, site_name, datetime.now())
            )
    except Exception as e:
        pass

コードは正しい。
WALモードも有効。
トランザクション管理も実装されている。

conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("BEGIN")
# ... 処理 ...
conn.commit()

技術的対策は完璧だった。


③ 当時の判断(ここが肝)

正直に書く。

当時の私の認識:

  • 「ロックを取れば安全」
  • 「トランザクションで保証される」
  • 「データの整合性 = システムの正しさ」

この判断は、当時は合理的だった

データベースの教科書に書いてある。
排他制御の実装パターンも標準的。
ACID特性も満たしている。

問題ない。

実際、データの整合性は守られていた。
通知履歴テーブルに不正なデータはない。
トランザクション境界も正しい。

データは壊れていなかった。


④ 壊れた瞬間

核心。

データが守られても、判断は守られない。

タイムラインで見る

T=0秒: スクレイパーA実行開始

商品データ取得 → メモリに保持

T=5秒: スクレイパーB実行開始

商品データ取得 → メモリに保持(Aと同じデータ)

T=12秒: A が差分検知システムを呼ぶ

判断: 「新商品だ」→ 通知履歴に書き込み

T=14秒: B が差分検知システムを呼ぶ

判断: 「新商品だ」→ 通知履歴に書き込み(重複)

問題はここ。

# 差分検知の処理フロー
def detect_new_products(self, site_name, products, ...):
    # 1. スナップショット読み込み(ロック取得)
    snapshot_data = self._load_snapshot_file(snapshot_path)
    
    # 2. 判断:新商品かどうか
    if current_first_key == remembered_first_key:
        return []  # 変更なし
    
    # 3. 新商品リストを返す
    new_products = products[:previous_first_position]
    
    # 4. スナップショット更新(ロック解放)
    self._update_snapshot(...)
    
    # 5. 通知クールダウン適用
    return self._apply_notification_cooldown(...)

ロックが保護しているのは _load_snapshot_file_update_snapshot

でも、判断(2番)は保護されていない

スクレイパーAが「新商品だ」と判断する。
スクレイパーBも「新商品だ」と判断する。

両方とも正しい。
でも、両方とも通知する必要はない。

データは正しく読まれた。
データは正しく書かれた。
トランザクションは成功した。

でも、判断のタイミングがズレた


⑤ 本質:ロックは何を守るのか

答えはこれ。

ロックが守るもの

データの読み書き → 守る

トランザクション境界 → 守る

整合性制約 → 守る

判断のタイミング → 守らない

ロックは「いつ読むか」を制御できる。
でも、「いつ判断するか」は制御できない。

# ロックの範囲
with file_lock:
    data = read_file()     # ← 保護される
    write_file(data)       # ← 保護される

# 判断の実行
decision = analyze(data)   # ← 保護されない
notify(decision)           # ← 保護されない

判断は、ロックの外で実行される。

2つのプロセスが同じデータを読んだら、
2つのプロセスが同じ判断を下す。

これは正しい動作だ。

でも、業務的には「1回だけ通知してほしい」。

データ整合性は保証された。
でも、判断の一意性は保証されなかった。


⑥ 一般化(判断のタイミング問題)

ここで初めて外に広げる。

製造業・在庫管理でも同じ構造:

  • 在庫を確認した → 発注した → その間に在庫が変わった
  • 承認判定をした → 実行した → その間に条件が変わった
  • 検証をした → 保存した → その間に前提が変わった

データベースのロックは、
「読んでいる瞬間」と「書いている瞬間」 を守る。

でも、
「判断している時間」 は守らない。

# よくある実装
def process_order():
    stock = get_stock()           # ← ロック1: 読み取り
    if stock > 0:
        time.sleep(5)             # ← 判断・処理に5秒
        update_stock(stock - 1)   # ← ロック2: 書き込み

# 問題
# 2つのプロセスが同時に get_stock() すると、
# 両方とも stock > 0 と判断する
# → 在庫1個なのに2件の注文が通る

これは排他制御の問題ではない。
「判断をいつ実行するか」の設計 の問題だ。


⑦ 結論(解決策は出しすぎない)

教えない。
断定だけ。

ロックはデータを守る。
でも、判断を守らない。

「データの整合性」と「判断の正しさ」は、
別の問題だ。

壊れていたのは、排他制御でもトランザクションでもなく、
「いつ判断するか」を定義していない構造 だった。


前回の記事

「正情報が嘘になった瞬間──異常なしのログが信用できない理由」

エラーなし。ログ正常。プロセス完走。
でも「結果が信用できない」。

壊れていたのはデータでも実装でもなく、
「成功の定義が1bitだったこと」だった。