EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Web / แก้หน้าสิบครั้งยิง IndexNow สิบครั้ง: ใช้คิวกับ debounce ใน Laravel
Web

แก้หน้าสิบครั้งยิง IndexNow สิบครั้ง: ใช้คิวกับ debounce ใน Laravel

โดย Ton-Or ·1 พ.ย. 2026·อ่าน 12 นาที
แก้หน้าสิบครั้งยิง 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 นับใหม่ทุกครั้ง

คิวก่อน ตัดสินใจตอนส่งบันทึกโมเดลสร้าง แก้ ถอด ลบmodel eventเทียบ fingerprintupsert ลงคิวurl_hash uniqueรอให้เงียบ120 วินาทีcron ทุก 5 นาทีartisan indexnow:pushPOST ไม่เกิน 10,000รวมเป็นชุดเดียวล็อกผลการส่งindexnow_pushesตาข่ายรอง ถ้า fingerprint รวมของ sitemap เปลี่ยนเทียบรายการ URL แล้วเข้าคิวเพิ่ม จับหน้าที่ลอดจาก model eventเก็บตกเรื่องที่ event ไม่ครอบคลุม
การบันทึกแค่ใส่ URL เข้าตาราง ส่วนการตัดสินว่าจะส่งอะไรเมื่อไรเกิดตอน cron ทำงาน และมี sitemap เป็นตาข่ายชั้นที่สอง

กติกาตอนส่ง: debounce, min_gap, repeat_after

ค่าทั้งหมดอยู่ใน config/indexnow.php ผมเช็กจากไฟล์แล้ว สามค่าหลักคือ

120 sdebounce ต้องเงียบก่อนส่ง
3,600 smin_gap เนื้อหาเปลี่ยนซ้ำ
86,400 srepeat_after URL ไม่เปลี่ยน
10,000URL สูงสุดต่อ POST
  1. debounce 120 วินาที URL ที่เปลี่ยนล่าสุดต้องเงียบครบก่อน แก้ต่อเนื่องสิบครั้งเลยกลายเป็นยิงครั้งเดียว
  2. min_gap 3,600 วินาที URL ที่เนื้อหาเปลี่ยนอีก ส่งไม่เกินหนึ่งครั้งต่อช่วงนี้
  3. repeat_after 86,400 วินาที URL ที่ไม่ได้เปลี่ยน (push เต็ม ส่งด้วยมือ หรือตรวจ sitemap) ไม่ส่งซ้ำภายในหนึ่งวัน
  4. สูงสุด 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 ที่ลองใหม่ได้ออกจากที่ไม่ควรลอง

ตอบกลับแบบไหนทำอะไร403 SiteVerificationNotCompletedเก็บในคิว ถอย 15 นาที429ถอย 60 นาที0 หรือ 5xxถอย 15 นาที400 403 422 แบบอื่นบันทึกแล้วไม่ลองซ้ำ คำขอเดิมล้มเหมือนเดิมหลังส่ง URL หนึ่งครั้ง ช่วงห้ามส่งซ้ำ (ตามสเกล 24 ชั่วโมง)min_gap 3,600 s เนื้อหาเปลี่ยนอีก ส่งได้เมื่อพ้นช่วงสั้นนี้ส่งแล้วrepeat_after 86,400 s URL ที่ไม่เปลี่ยนไม่ส่งซ้ำก่อนครบช่วงนี้
ผลตอบกลับที่ลองใหม่ได้ถอยตามเวลา ผลที่ลองไปก็ล้มเหมือนเดิมจดแล้วเลิก ส่วนเส้นเวลาล่างคือช่วงห้ามส่งซ้ำของ URL หนึ่งตัว

ตาข่ายรอง: เทียบ 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

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