ロックを入れても壊れる理由──同時実行されているのは"データ"ではなく"判断"だった
排他制御は完璧だった。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件の注文が通る
これは排他制御の問題ではない。
「判断をいつ実行するか」の設計 の問題だ。
⑦ 結論(解決策は出しすぎない)
教えない。
断定だけ。
ロックはデータを守る。
でも、判断を守らない。
「データの整合性」と「判断の正しさ」は、
別の問題だ。
壊れていたのは、排他制御でもトランザクションでもなく、
「いつ判断するか」を定義していない構造 だった。