แก้หน้าสิบครั้งยิง IndexNow สิบครั้ง: ใช้คิวกับ debounce ใน Laravel
การแจ้งเสิร์ชเอนจินไม่ใช่เรื่องของการยิงให้เร็วที่สุด แต่เป็นเรื่องของการตัดสินใจให้ถูกว่าอะไรควรแจ้ง เมื่อไร และแจ้งกี่ครั้ง โดยเอาการตัดสินใจนั้นไปไว้ตอนส่ง ไม่ใช่ตอนบันทึก
สรุปใน 1 นาที
- อาการ/ปัญหา
- ยิง IndexNow ทุกครั้งที่บันทึกหน้าเว็บ Laravel จะโดนบล็อกและเปลืองโควตา
- ใครควรอ่าน
- คนดูแลเว็บ Laravel ที่อยากให้ Bing รู้ทันทีเมื่อหน้าเปลี่ยน
- ทางแก้แบบสั้น
- เก็บ URL เข้าตารางคิวด้วย model event รอเงียบ 120 วินาที ส่งเป็นชุดจาก cron ทุก 5 นาที พร้อมกติกาถอยและตาข่ายรองจาก sitemap
พอดีเว็บ Laravel ของลูกค้ารายหนึ่งมีหน้าเนื้อหาหลายชนิด มีสองฉบับ คือ th กับ en เจ้าของอยากให้ Bing กับเสิร์ชเอนจินในเครือข่าย IndexNow (Bing, Yandex, Seznam, Naver ผ่านจุดรับกลางจุดเดียว) รู้ทันทีเมื่อมีหน้าใหม่ แก้ หรือถอด ผมคิดแบบง่ายที่สุดก่อน คือยิง IndexNow ในจังหวะที่บันทึกใน admin ผลคือแก้ติดกันสิบครั้งก็ยิงสิบครั้ง และถ้าหน้านั้นยังไม่แปลซึ่งเป็น noindex อยู่ ก็ไม่ควรแจ้งเลยด้วยซ้ำ ทางออกที่ผมใช้คือ "คิว บวก debounce บวกตัดสินใจตอนส่ง" ทั้งหมดอยู่ในคลาสเดียว และคอมเมนต์หัวไฟล์ก็เป็นสเปกของมันไปในตัว

ไฟล์คีย์ของ IndexNow เป็นไฟล์ข้อความที่วางไว้ใต้ root ของเว็บและเปิดสาธารณะโดยดีไซน์ แต่ผมไม่พิมพ์ค่าคีย์ ชื่อโดเมน หรือจำนวน URL จริงของงานนี้ลงบทความ ตัวอย่างทุกจุดใช้ example.com และ placeholder
อะไรควรเข้าคิว: model event กับคอลัมน์ที่ไม่นับ
ตัวกระตุ้นลงทะเบียนไว้ใน AppServiceProvider คู่กับ hook ของ sitemap เงื่อนไขที่ผมให้เข้าคิวมีสี่แบบ คือสร้างใหม่ เผยแพร่ ถอดจากการเผยแพร่ ลบ หรือ "คอลัมน์สาธารณะเปลี่ยน" ข้อสุดท้ายนี่แหละที่ต้องคิดให้ดี เพราะแอดมินแก้ลำดับการเรียง ติ๊ก is_featured แก้ keywords หรือแม้แต่ updated_at ขยับ ซึ่งไม่มีผลกับสิ่งที่เสิร์ชเอนจินเห็นเลย ผมเลยคำนวณ fingerprint ของคอลัมน์สาธารณะ ส่วนคอลัมน์ฝั่งแอดมินอยู่ในรายการ ignore ต่อโมเดล ไม่นับ
// app/Providers/AppServiceProvider.php (ชื่อโมเดลเป็นตัวอย่าง)
public function boot(): void
{
foreach ([Page::class, Article::class, Service::class] as $model) {
$model::saved(fn ($m) => IndexNow::onSaved($m));
$model::deleted(fn ($m) => IndexNow::onDeleted($m));
}
}
// app/Support/IndexNow.php (ย่อ)
private const IGNORE = [
Page::class => ['sort_order', 'is_featured', 'keywords', 'created_at', 'updated_at'],
];
private static function fingerprint(array $attrs, array $ignore): string
{
$public = array_diff_key($attrs, array_flip($ignore));
ksort($public);
return md5(json_encode($public, JSON_UNESCAPED_UNICODE));
}
public static function onSaved(Model $m): void
{
$ignore = self::IGNORE[$m::class] ?? ['created_at', 'updated_at'];
// saved ถูกเรียกก่อน syncOriginal เลยยังเห็นค่าเก่าอยู่ใน getOriginal()
if (! $m->wasRecentlyCreated
&& self::fingerprint($m->getOriginal(), $ignore) === self::fingerprint($m->getAttributes(), $ignore)) {
return; // แก้แต่คอลัมน์ฝั่งแอดมิน ไม่ต้องแจ้ง
}
if ($m->wasChanged('slug')) {
self::queue($m->getOriginal('slug') ? self::urlFor($m, old: true) : null, 'moved'); // URL เก่าเข้าคิวเป็นถอด
}
self::queue(self::urlFor($m), $m->wasRecentlyCreated ? 'created' : 'changed');
}
public static function queue(?string $url, string $reason): void
{
if (! $url) return;
DB::table('indexnow_urls')->upsert(
[['url' => $url, 'url_hash' => sha1($url), 'reason' => $reason,
'changed_at' => now(), 'queued_at' => now()]],
['url_hash'], // หนึ่งแถวต่อ URL
['reason', 'changed_at'] // แก้ซ้ำแค่ดันเวลาเปลี่ยนล่าสุดออกไป
);
}
ผมเพิ่มกติกาอีกสองข้อที่ไม่ใช่ model event ธรรมดา ข้อแรก เปลี่ยน slug แล้ว URL เก่าต้องเข้าคิวด้วยเหตุผล "moved" เพื่อให้เสิร์ชเอนจินมาเห็นว่าของเก่าหายไป ข้อสอง การเขียนหรือลบคำแปลอังกฤษ เข้าคิวเฉพาะ URL ฉบับ en ไม่ลาก URL ฉบับไทยไปด้วย เหตุผลที่แปลว่า "ถอดหน้า" มีห้าแบบ คือ unpublished, deleted, moved, gone, และ untranslated ซึ่ง hash ของแถวพวกนี้ผมใช้ตัวเหตุผลเอง
ตารางสองใบกับ log หนึ่งไฟล์
ทุกอย่างอยู่ในตารางสองใบ ใบแรก indexnow_urls เก็บหนึ่งแถวต่อหนึ่ง URL โดย url_hash เป็น unique ใบที่สอง indexnow_pushes เป็นล็อกของการยิงแต่ละครั้ง และมีไฟล์ storage/logs/indexnow.log อีกที
CREATE TABLE indexnow_urls (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
url VARCHAR(2048) NOT NULL,
url_hash CHAR(40) NOT NULL,
reason VARCHAR(24) NOT NULL, -- created, changed, moved, unpublished, deleted, gone, untranslated
changed_at DATETIME NOT NULL, -- เนื้อหาเปลี่ยนล่าสุดเมื่อไร ใช้ debounce
queued_at DATETIME NOT NULL,
last_pushed_at DATETIME NULL,
UNIQUE KEY uq_url_hash (url_hash)
);
CREATE TABLE indexnow_pushes (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
pushed_at DATETIME NOT NULL,
url_count INT NOT NULL,
status SMALLINT NOT NULL,
note VARCHAR(255) NULL
);
ส่วนตัวผมชอบวิธีให้ DB เป็นผู้รักษาความไม่ซ้ำ ไม่ต้องไปเขียนโค้ดเช็ก "มีใน queue แล้วหรือยัง" เอง upsert ครั้งเดียวจบ และการแก้ซ้ำสิบครั้งก็ได้แถวเดียว เวลาเปลี่ยนล่าสุดถูกดันออกไปเรื่อยๆ จน debounce นับใหม่ทุกครั้ง
กติกาตอนส่ง: debounce, min_gap, repeat_after
ค่าทั้งหมดอยู่ใน config/indexnow.php ผมเช็กจากไฟล์แล้ว สามค่าหลักคือ
- debounce 120 วินาที URL ที่เปลี่ยนล่าสุดต้องเงียบครบก่อน แก้ต่อเนื่องสิบครั้งเลยกลายเป็นยิงครั้งเดียว
- min_gap 3,600 วินาที URL ที่เนื้อหาเปลี่ยนอีก ส่งไม่เกินหนึ่งครั้งต่อช่วงนี้
- repeat_after 86,400 วินาที URL ที่ไม่ได้เปลี่ยน (push เต็ม ส่งด้วยมือ หรือตรวจ sitemap) ไม่ส่งซ้ำภายในหนึ่งวัน
- สูงสุด 10,000 URL ต่อ POST ซึ่งเป็นขีดสูงสุดของโปรโตคอล เกินก็แบ่งชุด
// เลือก URL ที่ถึงเวลาส่ง
$due = DB::table('indexnow_urls')
->where('changed_at', '<=', now()->subSeconds(config('indexnow.debounce'))) // 120
->orderBy('changed_at')
->get()
->filter(function ($r) {
if ($r->last_pushed_at === null) return true;
$since = now()->timestamp - strtotime($r->last_pushed_at);
$changed = strtotime($r->changed_at) > strtotime($r->last_pushed_at);
return $changed
? $since >= config('indexnow.min_gap') // 3600
: $since >= config('indexnow.repeat_after'); // 86400
})
->take(10000);
อีกกติกาหนึ่งที่ทำให้ผมตัดสินตอนส่งแทนตอนบันทึก คือหน้า en ที่ยังไม่ได้แปล ซึ่งเป็น noindex และไม่อยู่ใน sitemap จะไม่ถูกส่ง เพราะจะเป็นการบอกเสิร์ชเอนจินให้มาดูหน้าที่เราก็ไม่อยากให้ทำดัชนี ยกเว้นกรณีที่เคยส่งไปแล้ว กรณีนั้นผมส่งซ้ำในฐานะ "ถอด" เพื่อให้เอนจินกลับมาดึงแล้วเห็น 404 เรื่องหน้าแปลไม่ครบกับ sitemap ผมเล่าที่ฝั่งแปลไว้ใน คิวแปลเว็บขึ้น nothing to send

ตอบกลับแบบไหนถอย แบบไหนเลิก
ผมแยกผลตอบกลับของ API ออกเป็นสองกลุ่มใหญ่ คือ "ลองใหม่ได้" กับ "ลองไปก็ล้มเหมือนเดิม" ส่วน 200 กับ 202 ถือว่ารับแล้ว บันทึก last_pushed_at
| ผลตอบกลับ | ความหมาย | สิ่งที่ทำ |
|---|---|---|
| 403 และข้อความมี SiteVerificationNotCompleted | เอนจินยังไม่ได้ดึงไฟล์คีย์ มักเจอช่วงไม่กี่นาทีแรกของคีย์ใหม่ | เก็บ URL ไว้ในคิว ถอย 15 นาที |
| 429 | ยิงถี่เกิน | ถอย 60 นาที |
| 0 หรือ 5xx | ต่อไม่ได้ หรือฝั่งเอนจินมีปัญหา | ถอย 15 นาที |
| 400, 403, 422 แบบอื่น | คำขอผิดรูป คีย์ผิด URL ผิด host | บันทึกแล้วไม่ลองซ้ำ |
$status = $resp->status(); // ต่อไม่ได้ให้จับ exception แล้วใช้ 0
$body = $resp->body();
match (true) {
in_array($status, [200, 202])
=> self::markPushed($batch),
$status === 403 && str_contains($body, 'SiteVerificationNotCompleted')
=> Cache::put('indexnow:backoff', true, now()->addMinutes(15)), // URL ยังอยู่ในคิว
$status === 429
=> Cache::put('indexnow:backoff', true, now()->addMinutes(60)),
$status === 0 || $status >= 500
=> Cache::put('indexnow:backoff', true, now()->addMinutes(15)),
default
=> self::logAndDrop($batch, $status, $body), // 400/403/422 อื่น ไม่ลองซ้ำ
};
เหตุผลที่ไม่ลองซ้ำกับกลุ่มสุดท้ายคือ คำขอเดิมจะล้มเหมือนเดิมทุกครั้ง ลองไปก็เปลืองและอาจทำให้โดนบล็อกหนักขึ้น ตรงนี้ผมยึดหลักเดียวกับเรื่องหน่วง API ที่เล่าไว้ใน batch API throttle คือแยก error ที่ลองใหม่ได้ออกจากที่ไม่ควรลอง
ตาข่ายรอง: เทียบ sitemap เมื่อ fingerprint รวมเปลี่ยน
model event ไม่ครอบคลุมทุกอย่าง SQL ก้อนใหญ่ที่แก้ตารางตรงๆ บทความตั้งเวลาที่ถึงเวลาเผยแพร่เอง หรือหน้า static ใหม่ที่ไม่ใช่โมเดล พวกนี้ลอดผ่าน event ไปหมด ผมเลยมีตาข่ายชั้นที่สองใน cron เดียวกัน คือเทียบ sitemap แต่ทำเฉพาะเมื่อ fingerprint รวม (md5 ของ fingerprint รายไฟล์) เปลี่ยน ไม่ได้เทียบทุกรอบ ซึ่งถูกกว่ามากและยังจับของที่หลุดได้
public static function sitemapSafetyNet(): void
{
$files = Sitemaps::fingerprints(); // ['pages' => '...', 'articles' => '...']
$total = md5(json_encode($files));
if ($total === Cache::get('indexnow:sitemap_fp')) {
return; // ไม่เปลี่ยน ข้าม
}
foreach (Sitemaps::allUrls() as $url) {
if (! DB::table('indexnow_urls')->where('url_hash', sha1($url))->exists()) {
self::queue($url, 'changed');
}
}
Cache::forever('indexnow:sitemap_fp', $total);
}
และตัว cron เอง ตั้งแบบเรียบๆ
# crontab ของ user เว็บ (path เป็นตัวอย่าง)
*/5 * * * * cd /home/webuser/app && php artisan indexnow:push >> storage/logs/indexnow.log 2>&1
ปลอดภัยและทดสอบ
นอก production ผมปิดการส่งจริง คือ dev กับ test ยังเข้าคิวตามปกติแต่ไม่เรียก API เพื่อไม่ให้ข้อมูลทดสอบหลุดไปถึงเสิร์ชเอนจิน เว็บอีกตัวบนระบบเดียวกันเรียก submitFor() ซึ่งผูก root กับคีย์ของตัวเอง และทิ้ง URL ของ host อื่นที่ไม่ใช่ของมัน ส่วนคีย์เก็บเป็นไฟล์ที่ root ของเว็บ ตั้ง override ผ่าน .env ได้ แต่ต้องมีไฟล์คีย์ชื่อตรงกันวางอยู่จริง ไม่งั้นเจอ 403 แบบที่เล่าในตาราง
จำนวน URL ที่เคยส่งจริงของงานนี้ผมไม่ได้ตรวจ เลยไม่ได้ใส่ตัวเลขอะไรเกี่ยวกับปริมาณไว้ในบทความ ตัวเลขที่ลงมีเฉพาะค่ากำหนดใน config ซึ่งเช็กจากไฟล์แล้ว
บทเรียนห้าข้อ
ยิงใน event ตรงๆ
ง่ายที่สุด แต่แก้สิบครั้งยิงสิบครั้ง ยิงหน้าที่ไม่ควรแจ้ง และ error ของ API ไปล้มการบันทึกในหน้าแอดมินได้
คิว debounce ตัดสินตอนส่ง
event ทำแค่ upsert แถวเดียว ส่วนจะส่งอะไรเมื่อไรตัดสินตอน cron รันด้วยกติกาที่แก้ได้ใน config
หนึ่ง อย่ายิงใน event ตรงๆ ให้เข้าคิวแล้วตัดสินตอนส่ง สอง ตัดสินให้ชัดว่า "อะไรไม่นับเป็นการเปลี่ยน" ด้วยรายการคอลัมน์ ignore สาม การแจ้ง "ถอด" สำคัญเท่า "เพิ่ม" ถ้าหน้าหายแล้วไม่บอก เสิร์ชเอนจินก็ยังชี้ไปหาของที่ไม่มี สี่ แยก error ที่ลองใหม่ได้ออกจากที่ไม่ควรลอง ห้า ต้องมีตาข่ายรองเสมอ เพราะ event ไม่ครอบคลุม bulk SQL ส่วนตัวผมถ้าจะเพิ่มอะไรอีกก็คงเป็นหน้าสรุปในแดชบอร์ดว่าคิวค้างอยู่เท่าไร ตามความจำเป็นของแต่ละที่ เรื่องไหนผมอธิบายตกหล่นหรือกติกาตัวเลขไม่ตรงกับ config รุ่นใหม่ เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย