EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Linux / อ่านจากฐานสำรอง ต้องมีป้ายบอกว่าข้อมูลเก่ากี่นาที
Linux

อ่านจากฐานสำรอง ต้องมีป้ายบอกว่าข้อมูลเก่ากี่นาที

โดย Ton-Or ·12 ต.ค. 2026·อ่าน 9 นาที
อ่านจากฐานสำรอง ต้องมีป้ายบอกว่าข้อมูลเก่ากี่นาที

ระบบรายงานที่อ่านจากฐานสำรองมีข้อดีคือไม่ไปกวนฐานหลัก แต่มีข้อเสียที่อันตรายกว่ามาก คือเวลามันค้าง หน้าเว็บยังขึ้นตัวเลขสวยงามเหมือนเดิมทุกอย่าง

พอดีระบบรายงานที่ผมทำให้หน่วยงานหนึ่งไม่ได้อ่านจากฐานหลัก แต่อ่านจากฐานสำรองที่ replicate มาอีกที เหตุผลคือฐานหลักเป็นระบบงานประจำวันที่มีคนใช้กันทั้งวัน ผมไม่อยากเอา query รายงานหนัก ๆ ไปกวน วันหนึ่งมีคนโทรมาบอกว่าตัวเลขในรายงานไม่ตรงกับที่เห็นในระบบหลัก ผมไปไล่ดูถึงรู้ว่า replication หยุดไปตั้งแต่เมื่อคืน หน้าเว็บยังทำงานปกติทุกอย่าง กราฟขึ้นสวยงาม ไม่มี error สักตัว แค่ข้อมูลค้างอยู่ที่เมื่อวาน ผมเลยนั่งเขียนตัวเฝ้าขึ้นมาแล้วบังคับให้หน้าเว็บต้องมีป้ายบอกเสมอว่าข้อมูลที่กำลังดูอยู่นี้เก่าไปกี่นาที

ชื่อฐาน ชื่อตาราง และเลข IP ในบทความเป็นของสมมติ ส่วนตรรกะและค่า threshold คือของที่ใช้อยู่จริง

ปัญหาคือความเงียบ ไม่ใช่ความช้า

ทำไมระบบอ่าน-จากสำรองถึงพังแบบเงียบreplication หยุดกลางดึก ไม่มีใครดู->ฐานสำรองยังตอบ query ปกติข้อมูลครบ แค่เป็นของเมื่อวาน->หน้าเว็บเรนเดอร์สำเร็จไม่มี error ไม่มี timeout->คนตัดสินใจจากตัวเลขเก่ารู้ตัวตอนมีคนทักเท่านั้น
ระบบที่พังแล้วยังตอบ 200 คือระบบที่อันตรายที่สุด

ถ้าฐานสำรองล่มไปเลย หน้าเว็บจะพังทันทีและมีคนโทรหาผมภายในห้านาที นั่นคือกรณีที่ดี แต่กรณีที่ข้อมูลค้างเงียบ ๆ มันอยู่ได้เป็นสัปดาห์โดยไม่มีใครรู้ และระหว่างนั้นมีคนเอาตัวเลขไปใช้ตัดสินใจจริง ส่วนตัวผมถือว่าระบบอ่านจากฐานสำรองทุกตัวต้องมีตัวบอกอายุข้อมูลติดมาด้วยเสมอ ไม่มีข้อยกเว้น ไม่ใช่ของเสริมที่ค่อยทำทีหลัง

สองสัญญาณที่ต้องดูคู่กัน และวัดคนละเรื่องกัน

สัญญาณแรกคือของที่ MariaDB ให้มาเอง ดูได้จาก SHOW SLAVE STATUS

mariadb -h 10.10.10.3 -u"$DB_USER" -p"$DB_PASS" -e "SHOW SLAVE STATUS\G" \
  | grep -E 'Slave_(IO|SQL)_Running|Seconds_Behind_Master|Last_SQL_Error'

สัญญาณที่สองคือของที่ผมวัดเอง คือความสดของข้อมูลธุรกิจ เทียบวันที่ล่าสุดของตารางเดียวกันระหว่างฐานหลักกับฐานสำรอง

-- ยิงคำสั่งนี้ทั้งสองเครื่อง แล้วเอาผลมาลบกัน
SELECT MAX(closed_at) FROM core_db.tx_daily;
Seconds_Behind_Master

บอกว่า thread ตามทันไหม

วัดจากเวลาของ event ที่ SQL thread กำลังประมวลผลเทียบกับเวลาปัจจุบันของ master ถ้า IO thread หลุดไปแล้วแต่ SQL thread ไล่ของที่ค้างใน relay log จนหมด ค่านี้กลับมาเป็น 0 ได้สบาย ๆ ทั้งที่ไม่มีข้อมูลใหม่เข้ามาเลย

ความสดของข้อมูลธุรกิจ

บอกว่าข้อมูลมาถึงจริงไหม

เทียบ MAX ของคอลัมน์วันที่บนตารางที่ใช้งานจริงระหว่างสองเครื่อง ค่านี้โกหกไม่ได้เพราะมันคือสิ่งที่คนเห็นบนหน้าเว็บจริง ๆ ข้อเสียคือถ้าวันนั้นไม่มีรายการเข้ามาเลย มันก็จะดูเหมือนค้างทั้งที่ปกติดี ต้องดูคู่กับตัวแรกเสมอ

ผมย้ำตรงนี้เพราะเป็นจุดที่ทำให้ผมโดนหลอกมาแล้วครับ ตอนนั้นเปิดดู Seconds_Behind_Master เห็น 0 ก็สบายใจ แต่ข้อมูลค้างมาสองวันแล้ว สาเหตุคือ IO thread ตายไปตั้งแต่แรก ของใน relay log ถูกไล่จนหมดไปนานแล้ว ตัวเลข 0 ที่เห็นแปลว่า "ไม่มีอะไรค้างให้ทำ" ไม่ได้แปลว่า "ข้อมูลสด"

IO thread หยุด ทั้งที่ SQL thread ยังไล่ relay log จนหมดฐานหลักIO threadrelay logSQL threadฐานสำรองขาดหยุดทำงานไม่ดึง event ใหม่ว่างเปล่าถูกไล่จนหมดแล้วไม่มีอะไรค้างให้ทำSeconds_Behind_Master = 0แปลว่าไม่มีอะไรค้างให้ทำ ไม่ได้แปลว่าข้อมูลสดข้อมูลธุรกิจยังเป็นของเมื่อวานเทียบ MAX ของวันที่กับฐานหลักถึงจะเห็นสิ่งที่สัญญาณแรกบอกสิ่งที่คนเห็นบนหน้าเว็บต้องดูสองสัญญาณคู่กันเสมอ
IO thread ตายไปก่อน ส่วน SQL thread ไล่ของใน relay log จนหมดแล้ว ตัวเลขตามหลังเลยเป็น 0 ทั้งที่ไม่มีข้อมูลใหม่เข้ามาเลย ต้องเทียบความสดของข้อมูลธุรกิจอีกตัวหนึ่ง

credential ต้องมาจากข้างนอกสคริปต์เท่านั้น

ก่อนจะลงรายละเอียดตัวสคริปต์ ขอพูดเรื่องนี้ก่อนเพราะเป็นจุดที่ผมทำผิดมาเองในเวอร์ชันแรก คือใส่ user กับรหัสไว้บนหัวสคริปต์ตรง ๆ แล้วสคริปต์นั้นก็อยู่ใน repo ที่ commit ขึ้นไปเรียบร้อย

# /etc/default/replica-watch  — chmod 600 root:root ไม่อยู่ใน repo
DB_USER=monitor_ro
DB_PASS=<ใส่ค่าจริงที่นี่>
MASTER_HOST=10.10.10.2
REPLICA_HOSTS="10.10.10.3 10.10.10.4"
WHITELIST="10.10.10.7,10.10.10.2"
#!/bin/bash
# replica_watch.sh — READ-ONLY ต่อ master/replica ; เขียนผลลงฐานของแอปเราเองเท่านั้น
set -o pipefail
. /etc/default/replica-watch
: "${DB_USER:?ยังไม่ได้ตั้งค่าใน /etc/default/replica-watch}"
: "${DB_PASS:?}"

LAG_SECS_WARN=1800     # ตามหลังเกิน 30 นาที = lagging
LAG_DAYS_WARN=2        # ข้อมูลธุรกิจเก่ากว่า master เกิน 2 วัน = lagging

q(){ mariadb -h "$1" -u"$DB_USER" -p"$DB_PASS" -N -e "$2" 2>/dev/null; }
บัญชีที่ตัวเฝ้าใช้ต้องอ่านได้อย่างเดียว

สิทธิ์ที่ต้องการมีแค่ REPLICATION CLIENT สำหรับดูสถานะ กับ PROCESS สำหรับดู processlist และ SELECT เฉพาะตารางที่เอามาเทียบวันที่ ไม่ต้องมีสิทธิ์เขียนอะไรเลย ตัวเฝ้าที่มีสิทธิ์แก้ระบบได้คือช่องโหว่ที่รออยู่เฉย ๆ และสคริปต์ตัวนี้ผมตั้งใจไม่ให้มันสั่ง START SLAVE เองด้วย เพราะการกู้ replication ที่หยุดเพราะ error ต้องมีคนดูสาเหตุก่อน ไม่ใช่สั่งเดินต่อแล้วข้อมูลเพี้ยนยาว

อ่าน SHOW SLAVE STATUS ให้ถูกวิธี

ตรงนี้มีกับดักเล็ก ๆ ที่ทำให้ผมงงอยู่พักหนึ่ง คือถ้าใส่ -N เพื่อตัดหัวตารางออกตามความเคยชิน ผลของ \G จะไม่ออกมาเป็นรูปแบบ Field: value ให้ grep ได้

# ผิด: ใส่ -N แล้ว \G ไม่ออกเป็น Field: value
SS=$(mariadb -h "$H" -u"$DB_USER" -p"$DB_PASS" -N -e "SHOW SLAVE STATUS\G" 2>/dev/null)

# ถูก: ไม่ใส่ -N สำหรับ \G โดยเฉพาะ แล้วดึงค่าด้วยฟังก์ชันเล็ก ๆ
SS=$(mariadb -h "$H" -u"$DB_USER" -p"$DB_PASS" -e "SHOW SLAVE STATUS\G" 2>/dev/null)
fld(){ printf '%s\n' "$SS" | grep -m1 -E "[[:space:]]$1:" | sed "s/.*$1:[[:space:]]*//" | tr -d ' \r'; }
IO=$(fld Slave_IO_Running); SQLT=$(fld Slave_SQL_Running); SEC=$(fld Seconds_Behind_Master)
[ "$SEC" = "NULL" ] && SEC=""

ค่าที่ต้องระวังคือ Seconds_Behind_Master มันคืนคำว่า NULL มาเป็นข้อความได้ ซึ่งแปลว่า SQL thread ไม่ได้ทำงานอยู่ ถ้าเอาไปเทียบตัวเลขตรง ๆ โดยไม่ล้างก่อน bash จะตีความเป็นศูนย์แล้วบอกว่าปกติดี ซึ่งเป็นคำตอบที่ผิดที่สุดเท่าที่จะผิดได้

จับคนที่แอบเขียนลงฐานสำรอง

อีกอาการที่ทำให้ replication พังคือมีคนต่อเข้าไปเขียนบนฐานสำรองตรง ๆ ส่วนใหญ่ไม่ได้เจตนา เป็นโปรแกรมเก่าที่ config ชี้ผิดเครื่อง หรือคนที่เปิดโปรแกรมจัดการฐานข้อมูลค้างไว้แล้วเผลอกดบันทึก พอ row ฝั่งสำรองไม่ตรงกับที่ master ส่งมา SQL thread จะหยุดพร้อม duplicate key error

-- หา foreign writer: ใครกำลังยิงคำสั่งเขียนอยู่บนเครื่องนี้ นอกจาก replication
SELECT GROUP_CONCAT(DISTINCT CONCAT(SUBSTRING_INDEX(host,':',1),'/',user) SEPARATOR ', ')
FROM information_schema.processlist
WHERE db IN ('core_db','ref_db')
  AND user <> 'system user'
  AND SUBSTRING_INDEX(host,':',1) NOT IN ('10.10.10.7','10.10.10.2')
  AND command IN ('Query','Execute')
  AND info REGEXP '^[[:space:]]*(INSERT|UPDATE|DELETE|REPLACE|LOAD|ALTER|DROP|TRUNCATE|CREATE)';
วิธีนี้จับได้เฉพาะตอนที่กำลังยิงอยู่

processlist เห็นเฉพาะคำสั่งที่รันอยู่ ณ วินาทีที่เราถาม ถ้าคนเขียนยิง UPDATE สั้น ๆ ตอนตีสองแล้วตัดการเชื่อมต่อไป รอบ cron ของเราจับไม่ได้แน่นอน ตัวนี้เลยเป็นแค่ตัวช่วยจับคนที่ทำอยู่เป็นประจำ ของจริงที่ต้องทำคือตั้ง read_only บนฐานสำรอง แล้วให้บัญชีที่แอปใช้ไม่มีสิทธิ์ SUPER ส่วนตัวจับนี้ไว้หาว่า "ใคร" ตอนที่รู้แล้วว่ามีปัญหา

ผลลัพธ์ที่ได้มาต้องล้างอักขระก่อนเอาไปใส่ SQL ของเราเสมอ เพราะมันคือข้อความที่มาจากข้างนอก

# ล้าง quote กับ backslash + ตัดความยาว ก่อนเอาไปประกอบเป็น SQL
san(){ tr -d "'\"\\\\" | tr '\n' ' ' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | cut -c1-240; }

เก็บผลลงตารางเดียว แล้วให้หน้าเว็บอ่าน

ผมไม่ให้สคริปต์ยิงแจ้งเตือนเองครับ ให้มันเขียนสถานะล่าสุดลงตารางของแอปเราเอง แล้วหน้าเว็บไปอ่านตารางนั้น ข้อดีคือคนที่เปิดหน้าเว็บอยู่จะเห็นทันทีโดยไม่ต้องรอใครส่งข้อความมาบอก

CREATE TABLE replica_watch (
  id              BIGINT AUTO_INCREMENT PRIMARY KEY,
  host            VARCHAR(20)  NOT NULL UNIQUE,
  role            VARCHAR(12)  NOT NULL DEFAULT 'slave',
  io_running      VARCHAR(8)   NULL,
  sql_running     VARCHAR(8)   NULL,
  secs_behind     INT          NULL,
  lag_days        INT          NULL,
  max_biz_date    DATE         NULL,
  foreign_writers VARCHAR(255) NULL,
  status          VARCHAR(16)  NOT NULL DEFAULT 'ok',  -- ok|stopped|lagging|foreign-write|unreachable
  detail          VARCHAR(255) NULL,
  checked_at      DATETIME     NULL
) ENGINE=InnoDB;

ตรรกะการตัดสินสถานะเรียงลำดับความรุนแรงจากมากไปน้อย ตัวไหนเข้าเงื่อนไขก่อนใช้ตัวนั้น

if [ "$IO" != "Yes" ] || [ "$SQLT" != "Yes" ]; then
  ST=stopped;  DT="replication หยุด (IO=$IO SQL=$SQLT) — ตรวจสาเหตุก่อนสั่งเดินต่อ"
  [ -n "$ERR" ] && DT="$DT | err: $ERR"
elif [ -n "$FW" ]; then
  ST=foreign-write; DT="พบการเขียนลงเครื่องนี้โดยตรงจาก: $FW"
elif [ -n "$SEC" ] && [ "$SEC" -gt "$LAG_SECS_WARN" ] 2>/dev/null; then
  ST=lagging;  DT="ตามหลัง master $(( SEC / 60 )) นาที — ข้อมูลยังไม่สด"
elif [ -n "$LAG" ] && [ "$LAG" -gt "$LAG_DAYS_WARN" ] 2>/dev/null; then
  ST=lagging;  DT="ข้อมูลธุรกิจเก่ากว่า master $LAG วัน (ล่าสุด $DCH vs $MASTER_DCH)"
else
  ST=ok;       DT="ปกติ (delay ${SEC:-?}s, ห่าง ${LAG:-?} วัน)"
fi

ตัวเฝ้าของตัวเฝ้า

คำถามที่ต้องถามตัวเองเสมอเวลาทำระบบเฝ้าคือ ถ้าตัวเฝ้าตายเองใครจะรู้ คำตอบของผมคือให้ฝั่งที่อ่านผลเป็นคนเช็คอายุของผลนั้นอีกที ถ้าตารางไม่ถูกอัปเดตมานานเกินกำหนด แปลว่า cron ตายหรือเครื่องที่รันมันมีปัญหา

// อ่านผลจากตาราง คืน null = ปกติ ; คืน array = ให้ layout ขึ้นแถบเตือนบนหัวเว็บ
class ReplicaAlert
{
    public const SOURCE_HOST = '10.10.10.3';   // เครื่องที่รายงานอ่านจริง
    private const STALE_MIN  = 130;            // cron รายชั่วโมง เผื่อพลาดได้ 1 รอบ

    public static function check(): ?array
    {
        try {
            $r = DB::table('replica_watch')->where('host', self::SOURCE_HOST)->first();
        } catch (\Throwable $e) {
            return null;                        // ฐานยังไม่พร้อม อย่าให้ตัวเฝ้าทำเว็บล่ม
        }
        if (!$r) return null;

        $checked = $r->checked_at ? strtotime((string) $r->checked_at) : null;
        if ($checked && (time() - $checked) > self::STALE_MIN * 60) {
            return ['level' => 'warn',
                    'title' => 'ตัวเฝ้า replication ไม่ได้อัปเดต',
                    'msg'   => 'ตรวจล่าสุด ' . $r->checked_at . ' — ไปดู cron ที่เครื่องรายงาน'];
        }
        if (($r->status ?? 'ok') !== 'ok') {
            return ['level' => 'err',
                    'title' => 'แหล่งข้อมูลผิดปกติ: ' . $r->status,
                    'msg'   => trim((string) $r->detail) . ' · ตรวจล่าสุด ' . $r->checked_at];
        }
        return null;
    }
}
กฎเหล็กของโค้ดที่ขวางหน้าแรกทุกหน้า

สังเกตว่าผมครอบ try/catch แล้วคืน null เมื่อมีปัญหา ตัวเฝ้ามีหน้าที่เตือนไม่มีสิทธิ์ทำเว็บล่ม ถ้าตารางยังไม่ถูกสร้าง หรือ connection มีปัญหา ต้องเงียบแล้วปล่อยหน้าเว็บทำงานต่อ ตอนแรกผมไม่ได้ครอบไว้ แล้ววันที่ migrate ฐานใหม่ตารางยังไม่ถูกสร้าง หน้าเว็บขาวทั้งเว็บเพราะตัวเฝ้าเอง อันนี้คือกรณีที่อุปกรณ์ดับเพลิงเป็นต้นเพลิงเสียเอง

ค่า 130 นาทีมาจากการที่ cron รันทุกชั่วโมง เผื่อให้พลาดได้หนึ่งรอบก่อนขึ้นเตือน ถ้าตั้งชิดกว่านี้จะมีเตือนหลอกเวลาเครื่องโหลดหนักแล้ว cron เลื่อนไปนิดหน่อย ส่วนใครที่รันถี่กว่านี้ก็ปรับตามสะดวก หลักคิดเดียวกันคือเผื่อพลาดหนึ่งรอบเสมอ

# /etc/cron.d/replica-watch
0 * * * * appops /opt/app/ops/replica_watch.sh >> /var/log/replica_watch.log 2>&1

สรุปสิ่งที่ระบบนี้บอกได้ และบอกไม่ได้

1800 sเกินนี้ = lagging
2 วันข้อมูลธุรกิจห่างได้สูงสุด
130 นาทีตัวเฝ้าเงียบเกินนี้ = เตือน
4 สถานะstopped lagging foreign ok
  1. แยกให้ออกก่อนว่า Seconds_Behind_Master กับความสดของข้อมูลธุรกิจเป็นคนละเรื่อง แล้วเก็บทั้งคู่
  2. สร้างบัญชีอ่านอย่างเดียวสำหรับตัวเฝ้า เก็บรหัสไว้นอกสคริปต์และนอก repo เสมอ
  3. อย่าใส่ -N ตอนอ่าน SHOW SLAVE STATUS แบบ \G และล้างค่า NULL ก่อนเทียบตัวเลขทุกครั้ง
  4. ตั้ง read_only บนฐานสำรองก่อน แล้วค่อยใช้ processlist เป็นตัวช่วยหาว่าใครแอบเขียน
  5. ให้สคริปต์เขียนสถานะลงตารางของแอปเอง ไม่ต้องให้มันส่งแจ้งเตือนเองหรือแก้ระบบเอง
  6. ให้ฝั่งอ่านเช็คอายุของผลอีกชั้น แล้วครอบ try/catch ไว้เสมอเพื่อไม่ให้ตัวเฝ้าทำเว็บล่ม
  7. ขึ้นแถบเตือนบนหัวเว็บทุกหน้า อย่าซ่อนไว้ในหน้า admin ที่ไม่มีใครเปิด

ข้อสุดท้ายสำคัญกว่าที่คิดครับ ตอนแรกผมทำหน้า status แยกไว้หน้าหนึ่งสวยงามมาก มีทั้งตารางทั้งสีเขียวสีแดง แล้วก็ไม่มีใครเปิดเลยสักคนตลอดสองเดือน พอย้ายมาเป็นแถบแดงบนหัวทุกหน้าแทน วันที่ replication หยุดจริงมีคนถามผมภายในยี่สิบนาที นั่นคือสิ่งที่อยากได้ตั้งแต่แรก

ส่วนที่ระบบนี้บอกไม่ได้ก็มีนะครับ มันไม่รู้ว่าข้อมูลที่ replicate มาถูกต้องหรือเปล่า รู้แค่ว่ามาถึงแล้วหรือยัง ถ้าต้นทางเขียนข้อมูลผิดมาตั้งแต่แรก ตัวเฝ้าตัวนี้จะบอกว่าทุกอย่างปกติดี ใครที่ต้องการระดับนั้นต้องทำ checksum เทียบรายตารางเพิ่มอีกชั้น ซึ่งเป็นอีกเรื่องที่ยาวพอ ๆ กัน ไว้ผมทำจนใช้งานได้นิ่งแล้วจะเอามาเล่าให้ฟัง ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

อยากให้เราทำให้ไหม?

ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า

คุยเรื่องงานกับเรา

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

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