THAIPO.ai
กลับไปหน้าบล็อก

Landing page ที่นับเลขของตัวเอง

10 พฤศจิกายน 2569 · ThaiPo

มีอยู่ช่วงหนึ่ง landing page ของเรามีบรรทัดสร้างความน่าเชื่อถือที่เขียนว่า "12,000+" เป็นตัวเลขที่น่ารักมาก กลมๆ มั่นใจ ห้าหลักแบบสบายๆ และไม่มีใครนับอะไรเลยเพื่อให้ได้มันมา ข้างๆ กันมี stat tile สามช่อง ที่ถ้าจะพูดให้ใจดีหน่อยก็คือของประดับ

ส่วนที่น่าอายคือเราทำโปรดักต์ที่มี database ตัวเลขจริงอยู่ห่างไปแค่ query เดียวมาตลอด และบางตัวดีกว่าตัวเลขปลอมด้วยซ้ำ เราเลยลบของประดับทิ้ง แล้วให้ landing page อ่านตัวเลขของตัวเองจาก production โพสต์นี้เล่าว่าทำยังไง และทำไมมันต้องคิดมากกว่า select count(*) หนึ่งบรรทัด

ตัวการ์ตูนหน้าเว็บหน้าตาเป็นมิตรกำลังดีดลูกคิดนับเลข ข้างๆ มี stat tile เปล่าสี่ช่องเรียงกันรอตัวเลขของตัวเอง

สี่ช่อง จริงทุกช่อง

  • ข้อความที่แปลไปแล้ว นับตั้งแต่ต้น 640,000+ ในวันที่ ship
  • ผู้คนในแชทที่มีการแปล จำนวนคนไม่ซ้ำกันที่เราเห็นในแชทที่เราแปลให้: 3,000+ พร้อมบรรทัดที่สองสำหรับคนที่เห็นในช่วง 30 วันล่าสุด 1,500+
  • ภาษา ทั้งแปลเป็นไทยและแปลจากไทย ตัวเลขหลักคือจำนวนภาษาที่เรามีให้ บรรทัดข้างใต้คือจำนวนภาษาที่มี traffic จริงในเดือนนี้ ซึ่งตอนเปิดตัวคือ 11 การมีภาษาให้เลือกเป็นคำกล่าวอ้าง การมีคนใช้มันคือหลักฐาน
  • ชื่อและคำศัพท์ที่เรียนรู้มา 25,000+ ไม่นับชุดคำศัพท์ที่เรา ship เอง เพราะไม่มีแชทของใครสอนคำพวกนั้นให้เรา

ตัวเลขพวกนี้ใหญ่ไหม ถ้าเทียบกับมาตรฐานของบรรทัดที่แต่งขึ้นเองก็ไม่ แต่ผู้เข้าชมได้กลิ่น "trusted by 12,000+" ตั้งแต่ยังไม่ทันเลื่อนจอ ส่วน 1,500 คนที่ active ในเดือนนี้เป็นตัวเลขที่มีชีพจร ตัวเลขจริงเล็กๆ ชนะตัวเลขปลอมใหญ่ๆ และยังมีข้อดีเพิ่มอีกอย่างคือมันขึ้นเองได้

Query เดียว ไม่ต้องเดินท่อใหม่

ทั้งหมดนี้คือ SQL statement เดียว เป็น scalar subquery ไม่กี่ตัว บน table ที่โปรดักต์เก็บไว้อยู่แล้วเพื่อให้ตัวเองทำงานได้ เช่น usage counter, สมาชิกของแชท, setting รายแชท และ glossary ที่เรียนรู้มา เราไม่ได้เพิ่ม event stream ไม่ได้เพิ่ม analytics table และไม่ได้เพิ่ม nightly job ทุกอย่างที่มันอ่านคือ state ที่มีอยู่แล้ว ไม่ว่า landing page จะอยากเอาไปอวดหรือไม่ก็ตาม

เรารัน statement นี้กับ production ก่อน ship ด้วย TypeScript ยินดี typecheck function ที่ห่อ query ให้ แต่ไม่มีความเห็นใดๆ ต่อ string ข้างใน ชื่อ column ที่สะกดผิดจึงลอยผ่านทุกด่าน แล้วไปพังต่อหน้าผู้เข้าชมคนแรก

Landing page ต้องไม่รอ vanity metric เด็ดขาด

query เร็วๆ ไม่มีปัญหา จนถึงวันที่ database ยุ่งแล้วมันใช้เวลา 8 วินาที บนหน้าที่มี traffic สูงที่สุดของเรา เพื่อรับใช้ตัวเลขหนึ่งตัวในกล่องสีครีม กฎจึงเป็นแบบนี้: ถามอย่างมากชั่วโมงละครั้ง ไม่รอนาน ไม่ fail

const TTL_MS = 60 * 60 * 1000;
const WAIT_MS = 1500;
let memo: { at: number; value: LandingStats } | null = null;
let inflight: Promise<LandingStats> | null = null;

export async function getLandingStats(): Promise<LandingStats> {
  if (!dbConfigured()) return FLOOR;
  const last = memo?.value ?? FLOOR;
  if (memo && Date.now() - memo.at < TTL_MS) return memo.value;
  inflight ??= load()
    .catch(() => last)
    .finally(() => {
      inflight = null;
    });
  // A stale or first read never holds the page: past WAIT_MS it renders the
  // last known figures and the refresh finishes in the background.
  return Promise.race([inflight, new Promise<LandingStats>((res) => setTimeout(() => res(last), WAIT_MS))]);
}

ไม่กี่บรรทัด สี่พฤติกรรม:

  • Memoise ราย process หนึ่งชั่วโมง ค่าที่ยังสดคืนกลับทันที และ database ไม่รู้เรื่องด้วยซ้ำ
  • Query เดียว ไม่ว่าจะมีผู้เข้าชมกี่คน inflight ??= แปลว่าทุกคนที่มาถึงระหว่าง refresh จะใช้ promise ตัวเดียวกัน traffic ที่ถาโถมเข้ามาตอน cache ยังเย็นมีราคาแค่หนึ่ง query ไม่ใช่คนละหนึ่ง
  • งบ 1.5 วินาที หน้าเพจให้ query วิ่งแข่งกับ timer ถ้า timer ชนะ หน้าเพจ render ตัวเลขล่าสุดที่รู้ แล้ว query ไปจบใน background ให้ผู้เข้าชมคนถัดไป เป็น stale while revalidating ที่มีช่วงผ่อนผันสั้นๆ ก่อน
  • Floor FLOOR คือชุดตัวเลขจริงจากวันเปิดตัวที่ hard-code ไว้ และปัดลงมาแล้ว cold start ที่ช้า หรือ query ที่ fail ก่อนจะเคยสำเร็จสักครั้ง จะมาลงที่ตรงนี้ และเพราะยอดรวมส่วนใหญ่มีแต่โตขึ้น กรณีแย่ที่สุดตามปกติคือหน้าเพจขายเราต่ำกว่าความจริง

ทำไมใช้ตัวแปรระดับ module ไม่ใช้ cache ของ framework เพราะหน้านี้ render ราย request มันอ่าน user agent ของผู้เข้าชม เพื่อส่งคนที่อยู่ใน browser ของแอปโซเชียล ไปยังเส้นทาง sign-up ที่ใช้ได้ในนั้น ซึ่งทำให้มันหลุดจาก static rendering ไม่มี snapshot ตอน build ให้แขวนตัวเลขไว้ cache จึงอยู่ใน process และเครื่อง web แต่ละเครื่องเก็บของตัวเอง เครื่องจะเห็นไม่ตรงกันได้อย่างมากหนึ่งชั่วโมง กับตัวเลขที่กำลังจะถูกปัดอยู่แล้ว

ปัดลง โดยตั้งใจ

// "643,017" reads as invented precision and changes on every load; round
// down to two significant figures so the tile is always true: 640,000+.
export function roundDown(n: number): string {
  if (n < 100) return String(Math.max(0, Math.floor(n)));
  const unit = 10 ** (Math.floor(Math.log10(n)) - 1);
  return (Math.floor(n / unit) * unit).toLocaleString("en-US") + "+";
}

ตัวเลขที่นับเป๊ะๆ มีปัญหาสองข้อ มันดูเหมือนแต่งขึ้น ซึ่งไม่ยุติธรรมแต่ก็จริง และมันกระตุก: reload หน้าแล้วตัวเลขขยับ ซึ่งดึงสายตาไปหาสิ่งที่ไม่ควรดูพอดี เลขนัยสำคัญสองตัว ปัดลงเสมอ มีเครื่องหมายบวกต่อท้าย แก้ได้ทั้งสองข้อ tile ไม่มีวันพูดเกินจริง และจะเปลี่ยนก็ต่อเมื่อมีอะไรที่ควรค่าแก่การสังเกตเกิดขึ้น

test ตรึงขอบไว้: 999,999 แสดงเป็น 990,000+ ไม่มีวันเป็นหนึ่งล้านที่ปัดขึ้น และ 1,000,000 ของจริงมีสิทธิ์พูดเต็มปาก จำนวนที่ต่ำกว่า 100 แสดงตามจริง เพราะ "10+" ภาษาในตอนที่หมายถึง 11 มันก็แค่เล่นตัว

รอยย่นที่ต้องสารภาพ

วันเปิดตัว หน้าเพจแสดงตัวเลขสดจาก database และไม่มีทางรู้จากหน้าเพจได้เลยว่ามันสด floor คือตัวเลขวันเปิดตัวที่ปัดลงแล้ว query คืนตัวเลขวันเปิดตัว ซึ่ง tile ก็เอาไปปัดลง สองเส้นทางให้หน้าเพจที่เหมือนกันทุกประการ

feature นี้จึง ship ในสภาพที่การทำงานได้สมบูรณ์แบบกับการพังสนิท ดูเหมือนกันจากข้างนอก และหลักฐานเดียวที่หาได้คือรอให้ tile สักช่องขยับ เป็น bug ประเภทหายากที่วิธีแก้คือความอดทน เรารัน query ด้วยมือมาแล้ว จึงรู้ว่ามันใช้ได้ แต่ตัวหน้าเพจเองตีหน้าตายกับเรื่องนี้ไม่เปลี่ยน

Tile อยู่ตรงไหน

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

สรุป

ถ้าโปรดักต์ของคุณมี database landing page ของคุณไม่จำเป็นต้องมีตัวเลขที่แต่งขึ้น มันต้องการ query ที่ cache ไว้หนึ่งตัว timeout หนึ่งตัว floor หนึ่งตัว และความถ่อมตัวพอจะปัดลง ตัวเลขปลอมจบชีวิตตั้งแต่วันที่คุณพิมพ์มัน ตัวเลขจริงเล็กกว่า และมันยังนับต่ออยู่

ThaiPo ออกแบบ สร้าง และดูแลโดยนักพัฒนาคนเดียว Steven Acreman

ลองใช้ในแชทของคุณเอง

ThaiPo อยู่ใน LINE และแปลทุกข้อความให้สองทาง ฟรีตลอดไป เริ่มใช้ฟรี หรือ เพิ่มเพื่อน @thaipo.ai ใน LINE แล้วบอทจะส่งลิงก์สมัครให้ในแชทเลย

ThaiPo is operated by Marine Entertainment Co., Ltd. · Docs · Blog · Privacy · Terms · Contact