症状: 変わっていないページの通知が大量に届く

Bing IndexNow(新規・更新があったURLを検索エンジンへ能動的に伝える仕組み)の送信状況を見ていたところ、同じURL群が繰り返し送られていることに気付きました。

対象は数十件のヘルプ記事です。デプロイのたびに、その全件が送られていました。内容を変えていないデプロイでも同じ数だけ送られます。

最初の読み違い: リビジョンが増えていないから保存されていない

原因を探すとき、最初はこう考えました。「保存されているなら記録が残るはずだ」。

コンテンツ管理システムのリビジョン履歴と更新日時を確認しました。増えていませんでした。 そこで「保存という処理自体が起きていない。通知は別の経路から来ている」と結論づけました。

これは誤りでした。

真因: 内容が同じでも保存自体は起きていた

デプロイの度に走るインポート処理を追跡すると、記事の保存処理を無条件に呼んでいました。値を変更したかどうかに関わらず、毎回すべての対象へ保存を実行します。

foreach ($articles as $article) {
  // 差分の判定を一切せず、常に保存する(是正前)
  $node->setTitle($title);
  $node->set('body', ['value' => $body_html, 'format' => 'full_html']);
  // ...
  $node->save();
}

保存を呼んでも、フィールドの値が実際に変わっていなければ、リビジョンや更新日時は増えません。 これはこのシステムの一般的な仕様です。「値が変わっていない保存」は、履歴上は何も起きなかったように見えます。

しかし保存イベント自体は発火します。保存をフックしている処理(索引更新通知への送信、検索用インデックスの再構築、キャッシュの無効化、CDNのパージ)は、値が変わったかどうかを見ません。保存が呼ばれた回数だけ、律儀に反応します。

つまり「リビジョンが増えない」と「何も起きていない」は別のことでした。前者は履歴の話で、後者は副作用の話です。私は履歴を見て、副作用が起きていないと判断していました。見ている層を間違えていました。

直し方は、隣の処理に既にあった

同じインポート処理には、カテゴリ(分類項目)を扱う別の箇所もありました。そちらは最初から差分を見てから保存していました。

$changed = ($term->getName() !== $name) || ((int) $term->getWeight() !== $weight);
if ($changed) {
  $term->setName($name);
  $term->setWeight($weight);
  $term->save();
}

記事側だけがこの判定を持っていませんでした。 同じスクリプトの中に、あるべき形の実装が既に存在していて、それが片方にだけ適用されていない状態でした。原因を探すのに新しい発想は要らず、隣を見れば済む話でした。

是正: 意味のあるフィールドをすべて比較する

記事側にも同じ形の判定を入れました。タイトル・本文・分類・並び順・公開状態・URLのエイリアスという、意味のある値をすべて比較します。

$dirty = $is_new
  || $node->getTitle() !== $title
  || (string) ($node->get('body')->value ?? '') !== $body_html
  || (string) ($node->get('body')->format ?? '') !== 'full_html'
  || (int) ($node->get('field_help_category')->target_id ?? 0) !== $tid
  || (int) ($node->get('field_help_weight')->value ?? 0) !== $weight
  || $node->isPublished() !== $status
  || (string) ($node->get('path')->alias ?? '') !== $alias;

if ($dirty) {
  $node->setTitle($title);
  $node->set('body', ['value' => $body_html, 'format' => 'full_html']);
  $node->set('field_help_category', $tid);
  $node->set('field_help_weight', $weight);
  $node->set('path', ['alias' => $alias, 'pathauto' => 0]);
  $status ? $node->setPublished() : $node->setUnpublished();
  $node->save();
}

比較する項目は、実際に保存で書き換える項目と1対1で揃えています。 一部だけ比較すると、比較していないフィールドの変更を見逃して保存をスキップしてしまいます。逆に、保存しない項目まで比較に含めると、無関係な差分で余計な保存が発生します。

副次的に止まった無駄

この修正で止まったのは、索引更新通知だけではありませんでした。

保存イベントに反応する処理は他にもあり、検索用インデックスの再構築・エンティティのキャッシュ無効化・CDNのパージも、内容の変わっていないデプロイのたびに走っていました。1箇所の無条件保存が、複数の下流処理をまとめて空振りさせていました。

原因が1つに集約されていたので、直すのも1箇所で済みました。

もう1つの誤読: 平均値が実態を隠していた

調査の途中で、もう一つ勘違いをしていました。ある期間の送信総数を日数で割って「1日あたりの平均送信数」を出し、それを根拠に「常時これくらいの量が送られ続けている」と考えていました。

これも誤りでした。 実際のグラフを見ると、ゼロの日が大半で、デプロイした日やコンテンツ作業をした日にだけ、まとまった量が送られていました。平均値は、この「普段はゼロ、特定の日に集中する」という分布の形を消してしまいます。 総量を日数で均等に割ったときの数字は、実際にはどの1日にも起きていませんでした。

暴走していたわけではありません。ただし「変化のないURLをデプロイのたびに再送していた」ことは事実で、外部サービス側の仕様上は避けるべき挙動でした。

検証: 冪等性と検出精度の両方を確かめる

修正後、実際のデータで3パターン確認しました。

実行結果
1回目全件「一致」(保存なし)
うち1件の値を直接書き換えてから実行「1件だけ更新」を検出
その直後にもう一度実行全件「一致」に戻る

同じ入力を繰り返しても結果が変わらないこと(冪等性)と、実際の変更だけを正しく検出できること(検出精度)の両方を確認しています。 片方だけでは足りません。冪等なだけなら変更点を見逃していないか分からず、検出できるだけなら繰り返し実行で何が起きるか分かりません。

まとめ

  • 保存処理を呼んでも、フィールドの値が変わっていなければ履歴(リビジョン・更新日時)は増えません。「履歴が増えない」は「何も起きていない」の証拠にはなりません
  • 保存をフックする副作用(外部通知・再索引・キャッシュ無効化・CDNパージ)は、値が変わったかどうかを見ずに、保存が呼ばれた回数だけ発火します
  • 同じ処理の中に、既に正しい形(差分を見てから保存する)が別の箇所にあるなら、まずそれと同じ形に揃えてください。新しい設計を考える前に、隣を見てください
  • 差分比較の対象は、実際に保存で書き換えるフィールドと過不足なく一致させてください。ずれると見逃しか無駄な保存のどちらかが起きます
  • 平均値は、普段ゼロで特定のタイミングに集中するという分布の形を消します。「1日あたり平均N件」という数字を実態と思い込まないでください
  • 修正の検証は、冪等性(繰り返しても結果が変わらない)と検出精度(実際の変更を見逃さない)の両方を確かめてください