- One Million Checkboxes ซึ่งเปิดตัวเมื่อวันที่ 26 มิถุนายน 2024 เป็นเว็บไซต์ที่ทุกคนควบคุมเช็กบ็อกซ์ 1 ล้านช่องชุดเดียวกันแบบเรียลไทม์ และก่อนปิดตัวลง 2 สัปดาห์ให้หลัง ได้ประมวลผลการติ๊กไปมากกว่า 650 ล้านครั้ง
- สถานะจริง ๆ มีขนาดเพียง 1 ล้านบิต หรือ 125KB แต่สถาปัตยกรรมเริ่มต้นที่สร้างด้วย nginx, Flask/gunicorn และ Redis pubsub ไปถึงขีดจำกัดอย่างรวดเร็วเมื่อเจอทราฟฟิกที่เกินคาด
- เมื่อผู้คนนับหมื่นหลั่งไหลเข้ามาจาก Hacker News, Reddit, Mastodon และ Twitter ปัญหาต่าง ๆ ก็เผยออกมาต่อเนื่อง ทั้ง Redis connection หมด แบนด์วิดท์พุ่งสูง การขาด input validation และการนำอัปเดตเก่ามาใช้
- การรับมือเน้นมาตรการที่ทำได้เร็ว เช่น ขยายเซิร์ฟเวอร์และ Redis, ประมวลผลอัปเดตเป็น batch, ลดขนาดรูปแบบข้อมูลที่ส่ง, ตั้ง เพดานแบนด์วิดท์ 250Mbit/s ด้วย Linux
tcและสคริปต์รีสตาร์ตโปรเซส - หลังจากนั้นย้ายแบ็กเอนด์ไปเป็น Go จนเสถียรขึ้น และช่วงท้ายใช้ Redis Lua script จัดการตรรกะการ freeze เช็กบ็อกซ์แบบ atomic ก่อนที่เว็บไซต์จะจบลงเมื่อวันที่ 11 กรกฎาคม 2024 เวลา 4:35 PM ตามเวลาฝั่งตะวันออกของสหรัฐฯ
เว็บไซต์และการออกแบบช่วงแรก
- One Million Checkboxes(OMCB) เป็นเว็บไซต์ที่เปิดตัวเมื่อวันที่ 26 มิถุนายน 2024 โดยมีเช็กบ็อกซ์แบบ global จำนวน 1 ล้านช่อง
- เมื่อผู้ใช้คนหนึ่งเปิดหรือปิดเช็กบ็อกซ์ การเปลี่ยนแปลงจะแสดงบนหน้าจอของผู้ใช้ทุกคนทันที
- ใช้เวลาสร้าง 2 วัน และคาดว่าจะมีผู้ใช้มากสุดเพียงระดับไม่กี่ร้อยคน
- กระแสตอบรับจริงใหญ่กว่าที่คาดไว้มาก
- ภายในไม่กี่ชั่วโมงหลังเปิดตัว มีผู้คนนับหมื่นเข้ามาและปรับเช็กบ็อกซ์ไปหลายล้านช่อง
- ทราฟฟิกมาจาก Hacker News, /r/InternetIsBeautiful, Mastodon, Twitter
- ไม่กี่วันต่อมาก็ถูกนำเสนอใน Washington Post และ New York Times
- log ช่วงต้นของวันแรกบางส่วนไม่เหลืออยู่
- เพราะตอนแรกเก็บไว้เฉพาะ log ล่าสุด 1 ล้านรายการของแต่ละวัน
- เริ่มเสถียรขึ้นตั้งแต่วันที่สอง และวันนั้นมีการติ๊กมากกว่า 50 ล้านครั้ง
- ก่อนเว็บไซต์ปิด ยอดติ๊กสะสมเกิน 650 ล้านครั้ง
สถาปัตยกรรมเดิมที่มี Redis เป็นศูนย์กลาง
- สถานะของเช็กบ็อกซ์ถูกแทนด้วย 1 ล้านบิต
- กล่องที่ถูกติ๊กเป็น
1กล่องที่ไม่ถูกติ๊กเป็น0 - ขนาดสถานะทั้งหมดคือ 125KB
- client เก็บ bitset และอ้างอิงตอน render
- กล่องที่ถูกติ๊กเป็น
- client ถูกออกแบบมาเพื่อหลีกเลี่ยงภาระบน DOM
- ไม่ใส่องค์ประกอบทั้ง 1 ล้านรายการลงใน DOM ทั้งหมด
- ใช้ react-window เพื่อ render เฉพาะเช็กบ็อกซ์ที่เห็นบนหน้าจอปัจจุบันกับ buffer ขนาดเล็กเท่านั้น
- โครงสร้างเซิร์ฟเวอร์เป็นแบบที่คำนึงถึงการ scale แนวนอนอย่างง่าย
- nginx ให้บริการ static content และส่งต่อ API request กับ websocket connection ไปยัง Flask server
- Flask server เป็นสอง instance ที่รันด้วย gunicorn
- Redis ทำหน้าที่เก็บสถานะเช็กบ็อกซ์และเป็น message queue
- วิธีใช้ Redis ก็ตรงไปตรงมา
- เปลี่ยนสถานะเช็กบ็อกซ์แต่ละช่องด้วย primitive สำหรับจัดการ bit ของ Redis
- เมื่อ client ส่งเหตุการณ์การติ๊ก Flask จะ flip bit ใน Redis และบันทึก event ลง pubsub
- Flask server ทั้งสองตัวอ่าน pubsub แล้วแจ้งการเปลี่ยนแปลงให้ client ที่เชื่อมต่อกับตัวเอง
- snapshot ของสถานะทั้งหมดเป็นกลไกสำหรับชดเชยอัปเดตที่ตกหล่น
- ใช้เพื่อทำให้ client ที่พลาดอัปเดตเพราะแท็บอยู่เบื้องหลังกลับมาตรงกัน
- implementation แรกส่งสถานะทั้งหมดทุก 30 วินาที
หลักการ scale
- ต้องสามารถคำนวณเพดานค่าใช้จ่ายได้
- หลีกเลี่ยงวิธี auto scale แบบไม่จำกัดที่ทำให้ค่าใช้จ่ายพุ่ง
- เลือกยอมให้ระบบพังเมื่อมีโหลดเกินคาด
- สมมติว่าความนิยมจะอยู่ไม่นาน
- ให้ความสำคัญกับการรับมือที่ทำได้ในไม่กี่ชั่วโมง มากกว่าโซลูชันที่สมบูรณ์ซึ่งใช้เวลาหลายวันหรือหลายสัปดาห์
- ยอมรับ technical debt ที่เกิดขึ้นระหว่างนั้น
- การเลือกเทคโนโลยีเน้นความเรียบง่ายและดูแลเองได้โดยตรง
- เลือกโครงสร้างที่สามารถ SSH เข้าเซิร์ฟเวอร์ไปรันคำสั่งและ debug ได้
- ใช้ dependency ที่ดูแลและ debug เองได้เป็นหลัก
- ประสบการณ์หลักของเว็บไซต์คือ การ sync แบบ global
- ไม่ว่าจะเลื่อนไปที่ไหน ต้องเห็นการเปลี่ยนแปลงทันที
- ไม่ scale ด้วยวิธีส่งเฉพาะเช็กบ็อกซ์ที่ผู้ใช้กำลังดูอยู่
วันแรก: เพิ่มเซิร์ฟเวอร์และคอขวดที่ Redis
- ภายใน 30 นาทีหลังเปิดตัว โหลดพุ่งสูงขึ้น และแม้เว็บไซต์ยังทำงานอยู่ แต่ก็อยู่ในสภาพที่ยืนระยะได้ยาก
- วิธีปรับปรุงที่ชัดเจนที่สุดคือเพิ่มเซิร์ฟเวอร์
- nginx สามารถ reverse proxy ไปยัง Flask instance บน VM อื่นได้ง่าย และสถานะก็อยู่ใน Redis อยู่แล้ว
- เซิร์ฟเวอร์ตัวที่สองถูกเพิ่มราว 12:30 PM และโหลดก็แตะ 100% ทันที
- ตอนแรกคาดว่าเพิ่มเซิร์ฟเวอร์หนึ่งหรือสองเครื่องก็น่าจะพอ
- แต่ในความเป็นจริง ทราฟฟิกเพิ่มขึ้นตามการขยายระบบ
- ขึ้นอันดับ 1 บน Hacker News และกิจกรรมบน Twitter ก็พุ่งขึ้นมาก
- connection ระหว่าง Flask server กับ Redis กลายเป็นคอขวด
- ไม่มี Redis connection pool และ Redis เข้าใกล้ภาวะ connection ไม่พอ
- จึงเปลี่ยนไปส่งอัปเดตแบบรวมเป็น batch
- ไม่ได้คำนึงถึงความเข้ากันได้กับ client เดิม โดยมองว่าผู้ใช้จะ refresh หน้าเอง
- มีการเพิ่ม Redis connection pool ด้วย แต่ในชุด gunicorn กับ Flask มันทำงานได้ไม่เรียบร้อยนัก
- ถึงอย่างนั้นก็ดูเหมือนช่วยลดจำนวน connection ไปยัง Redis ได้
- หลังจากนั้นไม่ได้ขุดปัญหานี้ต่อเชิงลึก และย้ายไปทำ Go แทน
- rate limit สำหรับการสร้าง session ถูกถอดออก
- สถานะของ rate limit เก็บอยู่ใน Redis และ Redis กำลังจะหมด connection
- ปัญหาไม่ได้อยู่ที่ session ใหม่พุ่งจำนวนมาก แต่อยู่ที่การส่งข้อมูลจำนวนมากจาก session เดียว
- มองว่าเป็นมาตรการที่เสี่ยงในระยะสั้นแต่ยอมรับได้
- Redis instance ก็ถูกอัปเกรดเป็นสเปกที่ใหญ่ขึ้น
- ใช้ Digital Ocean managed Redis อยู่
- ขยายจาก instance ขนาดเล็ก 1 shared CPU, 2GB RAM เป็น 4 dedicated CPU, 32GB RAM
- การ resize ใช้เวลาประมาณ 30 นาที
ปัญหาแบนด์วิดท์และการลดปริมาณข้อมูลที่ส่ง
- ช่วงแรกไม่ได้คำนึงถึงค่าแบนด์วิดท์มากพอ
- Digital Ocean คิดเงิน $0.01 ต่อ GB เมื่อเกินแบนด์วิดท์ฟรี
- จากงานก่อนหน้ามีแบนด์วิดท์ฟรี 1TB และคิดว่า OMCB ไม่น่าจะมีผลมาก
- snapshot ของสถานะทั้งหมดอาจกินแบนด์วิดท์อย่างรวดเร็ว
- 1 ล้านบิตเท่ากับ 1Mbit
- ถ้าส่งให้ 1,000 คนทุก 30 วินาที จะอยู่ที่ประมาณ 2GB ต่อนาที หรือ 120GB ต่อชั่วโมง
- ตัวเลขนี้ยังไม่รวม incremental update
- การตรวจสอบแบนด์วิดท์และตั้งเพดานค่าใช้จ่ายทำบนกล่อง nginx
- ใช้
ip -s link show dev eth0ตรวจสอบจำนวน byte ที่ส่ง - เพราะใช้ nginx reverse proxy ตัวเดียว จึงอนุมานแหล่งที่มาของแบนด์วิดท์ได้ง่าย
- ใช้
- การลดปริมาณข้อมูลที่ส่งดำเนินไปสองทาง
- ลดความถี่ของ snapshot สถานะทั้งหมด
- ลดขนาดรูปแบบข้อมูลของ incremental update
- รูปแบบ batch update ถูกบีบให้สั้นลงมาก
- รูปแบบเดิมเป็นรายการ dict เช่น
{ "index": 123, "value": true } - รูปแบบสุดท้ายเป็นคู่ของ array index ที่เป็น true และ array index ที่เป็น false ในรูป
[[123, 125], [124]] - วิธีนี้สั้นกว่า implementation เดิม 5 เท่า
- รูปแบบเดิมเป็นรายการ dict เช่น
- ใช้ Linux
tcตั้ง hard cap เพื่อป้องกันค่าใช้จ่ายพุ่ง- จำกัดทราฟฟิกของ public interface
eth0ไว้ที่ 250Mbit/s - เทียบได้กับราว 2GB ต่อนาที หรือเกือบ 3TB ต่อวัน
- ที่ราคา $0.01 ต่อ GB จึงหลีกเลี่ยงสถานการณ์ที่ค่าใช้จ่ายพุ่งจนควบคุมไม่ได้ข้ามคืน
- จำกัดทราฟฟิกของ public interface
วันที่สอง: input validation ที่ตกหล่นและ Redis replica
- เช้าวันถัดมาเว็บไซต์ล่ม และสาเหตุคือ ขาด input validation
- ไม่ได้กันเช็กบ็อกซ์ที่มี index เกิน 1 ล้าน
- มีคนไปปรับเช็กบ็อกซ์ที่ index ระดับหลายร้อยล้าน
- ทำให้ดูเหมือนจำนวนกล่องที่ถูกติ๊กถึง 1 ล้านและระบบตัดสินว่าเว็บไซต์จบแล้ว
- ข้อมูล Redis ก็ใหญ่ขึ้นโดยไม่จำเป็น
- มี 0 หลายล้านตัวถูกเพิ่มระหว่าง bit ที่ 1 ล้านกับ bit ที่ 100 ล้าน
- ข้อมูลที่ส่งไป client ใหญ่ขึ้น 100 เท่า
- การกู้คืนทำได้รวดเร็ว
- หยุด nginx
- คัดลอกเฉพาะ 1 ล้านบิตแรกของ bitset เดิมไปเป็น bitset ใหม่
- เก็บ bitset เดิมไว้สำหรับ debug
- เปลี่ยนโค้ดให้ชี้ไปที่ bitset ใหม่และเพิ่ม input validation
- การโหลดหน้าแรกก็ช้าลงด้วย
- Redis มีโหลดสูง และ bug ใน connection pool ก็ทำให้สร้าง connection มากเกินไป
- แทนที่จะ debug ปัญหา connection pool จึงเพิ่ม Redis replica เพื่อกระจายโหลดและ connection ของ primary
- ต้องหา private IP ของ replica ด้วยตนเอง
- ตามคู่มือของ Digital Ocean prefix
replica-ใช้ได้กับ public DNS แต่ใช้ไม่ได้กับ private DNS - มองว่าการใช้ public IP มีความเสี่ยงที่จะวิ่งผ่าน public internet และโดนคิดค่าแบนด์วิดท์
- ลองเชื่อมต่อไปยัง address ที่อยู่ใกล้ private IP ของ primary และเซิร์ฟเวอร์อื่น ๆ จนเจอ private IP ของ replica ในครั้งที่สามหรือสี่
- จากนั้น hardcode IP ดังกล่าวไว้
- ตามคู่มือของ Digital Ocean prefix
การรีสตาร์ตโปรเซสและการแก้ stale update
- Flask process crash ต่อเนื่อง และสาเหตุดูเหมือนเป็น Redis connection ไม่พอ
- แทนที่จะ debug แบบละเอียด จึงสร้าง bash script สำหรับตรวจจำนวน Flask process ที่กำลังรัน
- หาก process ที่รันอยู่มีน้อยกว่า 3 ตัว ให้รีสตาร์ต systemd unit
- ใส่สคริปต์ไว้ใน crontab
- ปรับ nginx config ไปพร้อมกันด้วย
- เปลี่ยนให้เซิร์ฟเวอร์ที่ down ถูกถอดออกจาก rotation ชั่วคราว
- หลังเปลี่ยนนี้เว็บไซต์ก็เสถียรขึ้น
- การ sync สถานะของ client มี bug แบบ stale update
- client ได้รับทั้ง incremental update และ full-state snapshot
- เพราะทั้งสองแบบไม่มี timestamp จึงอาจนำ incremental update เก่ามาใช้หลังได้รับ snapshot ใหม่แล้ว
- ผลคือผู้ใช้อาจเห็นสถานะที่ผิดโดยสิ้นเชิงจนกว่าจะถึง full-state snapshot รอบถัดไป
- มีการเพิ่มการบรรเทาด้วย timestamp
- ใส่ timestamp ให้ full-state snapshot
- ใส่ timestamp ให้แต่ละ update ที่บันทึกลง Redis pubsub
- batch ที่ส่งให้ client ใส่ timestamp สูงสุดในบรรดา incremental update ที่รวมอยู่
- client ถูกเปลี่ยนให้ทิ้ง batch ที่เก่ากว่า full-state snapshot ล่าสุด
- วิธีแก้นี้ยังไม่สมบูรณ์
- หากใน batch มี update ใหม่แม้เพียงหนึ่งรายการ ส่วนใหญ่ของ batch อาจเป็น update เก่าแต่ก็ยังถูกนำไปใช้ได้
- ถึงอย่างนั้นก็ดีขึ้นมากจากเดิม
เขียนใหม่ด้วย Go และทำให้เสถียร
- เช้าวันถัดมาเว็บไซต์ยังอยู่ และหลังจากนั้นก็โฟกัสที่การเขียนแบ็กเอนด์ใหม่
- ตอนนั้นมีอีเมลจาก Washington Post มาถึงแล้วด้วย
- พร้อมกันนั้นก็คิดแผนว่าจะปิดเว็บไซต์อย่างไร
- แผนปิดคือให้กล่องที่ถูกติ๊ก freeze หากไม่ถูกยกเลิกการติ๊กอย่างรวดเร็ว
- การเปลี่ยนแปลงนี้อาจทำให้กิจกรรมพุ่งและต้องทำงานกับเซิร์ฟเวอร์เพิ่ม
- ไม่มั่นใจว่าโครงสร้างเดิมบน Flask จะรับไหวหรือไม่
- เขียนแบ็กเอนด์ใหม่เป็น Go ร่วมกับเพื่อนชื่อ Eliot
- ตั้งแต่วันอาทิตย์ 2 PM ถึง 2 AM ช่วยกันคุย implementation และ port แบ็กเอนด์ทั้งหมด
- ย้ายโดยไม่ได้เปลี่ยนโครงสร้างมากนัก
- จุดที่ติดขัดรวมถึงการหา Go
socketiolibrary ที่รองรับ protocol เวอร์ชันล่าสุด
- ประสิทธิภาพดีขึ้นอย่างมาก
- scale ได้ดีเกินไปจน bot สามารถอัดทราฟฟิกเข้ามามากเกินไปได้
- จึงต้องมี rate limit ที่ดีกว่าเดิม
- คืนวันอาทิตย์มี DDoS ด้วย
- รับมือโดยวางเว็บไซต์ไว้หลัง Cloudflare และปรับ nginx config เล็กน้อย
ตรรกะการปิดเว็บไซต์
- หลังเขียนใหม่ด้วย Go เว็บไซต์ทำงานได้เสถียร
- จากนั้นใช้เวลาหนึ่งสัปดาห์รับมือกับการสัมภาษณ์และความสนใจที่เข้ามา
- แล้วจึงเริ่มงานปิดเว็บไซต์
- วิธีปิดคือการ freeze เช็กบ็อกซ์
- หากกล่องที่ถูกติ๊กไม่ถูกยกเลิกการติ๊กอย่างรวดเร็ว จะเข้าสู่สถานะ frozen
- เมื่อเวลาผ่านไป เว็บไซต์ทั้งหมดจะเข้าสู่สถานะ frozen โดยสมบูรณ์
- เพิ่มสถานะเข้าไปใน Redis
- เพิ่ม hashtable สำหรับเก็บเวลาล่าสุดที่เช็กบ็อกซ์แต่ละช่องถูกติ๊ก
- เป็นสถานะที่ใหญ่เกินกว่าจะส่งให้ client แต่เก็บใน Redis ได้ไม่มีปัญหา
- เก็บค่า
time_to_freezeด้วย
- ตัดสินสถานะ freeze ตอน uncheck
- ถ้า
now - last_checked > time_to_freezeจะไม่ uncheck - แต่จะอัปเดต
frozen_bitsetเพื่อระบุว่าเช็กบ็อกซ์นั้นอยู่ในสถานะ frozen frozen_bitsetถูกกระจายไปยัง client ด้วยวิธีเดียวกับสถานะ checked- client จะ disable เช็กบ็อกซ์ที่เปิด frozen bit อยู่
- ถ้า
- เพิ่มงานแยกเพื่อให้ freeze ได้แม้ไม่มีใคร uncheck
- ค้นหา bit ที่ควรถูก freeze เป็นระยะ ๆ แต่ยังไม่ได้ถูกแสดงว่า frozen แล้วทำให้เป็นสถานะ frozen
- ใส่ตรรกะที่เกี่ยวข้องไว้ใน Redis Lua script เพื่อให้รันแบบ atomic
- ทำให้หลีกเลี่ยง race condition ได้ง่าย
- การเปลี่ยนแปลงเพื่อปิดถูกนำไปใช้หลังเปิดตัว 2 สัปดาห์กับอีก 1 วัน
- วันที่ 11 กรกฎาคม 2024 เวลา 4:35 PM ตามเวลาฝั่งตะวันออกของสหรัฐฯ box 491915 ถูกติ๊ก และเว็บไซต์ก็จบลง
ค่าใช้จ่ายและบทเรียนที่ได้
- ค่าใช้จ่ายในการรันเว็บไซต์อยู่ที่ประมาณ $850
- donation ใกล้เคียงกับค่าใช้จ่ายนี้พอสมควร
- สรุปได้ว่าไม่ได้ขาดทุนมาก
- พอใจกับการเลือก Redis และ nginx
- มองว่า Redis และ nginx เป็นเทคโนโลยีที่มีประโยชน์มาก
- การดูแลเองทำให้ debug และแก้ไขได้ง่าย
- แต่การควบคุม managed Redis instance ได้ไม่เต็มที่ก็ไม่สะดวกอยู่บ้าง
- การไม่ออกแบบการ scale ขนาดใหญ่ยาว ๆ ตั้งแต่แรกถือว่าเป็นการตัดสินใจที่ดี
- มองว่ายากที่จะคาดเดาว่าอะไรจะไปได้ดีบนอินเทอร์เน็ต
- ถ้าใช้เวลาหลายสัปดาห์คิดเรื่อง scale ตั้งแต่แรก อาจไม่ได้เปิดตัวเลย
- การมีผู้ใช้จำนวนมากเข้ามาช่วยเป็นแรงจูงใจในการดูแลระบบและช่วยจัดลำดับความสำคัญ
- ยังยืนยันว่ามีความต้องการต่อปฏิสัมพันธ์แบบไม่ระบุตัวตนที่มีขอบเขตจำกัด
- ผู้คนสนใจเว็บไซต์ที่ให้โต้ตอบกับคนแปลกหน้าในรูปแบบที่จำกัด
- ทำให้มั่นใจมากขึ้นว่าจะสร้างเว็บไซต์ประเภทนี้ต่อไป
1 ความคิดเห็น
ความคิดเห็นใน Hacker News
เป็นบทความที่มีอะไรให้เรียนรู้มากมาย พร้อมความรู้เชิงประวัติศาสตร์เกี่ยวกับระบบแบบกระจาย
ถ้าไม่นับพื้นที่จัดเก็บ ดูเหมือนว่าจะเจอ จุดหยุดชะงักและจุดล้มเหลว แทบทุกประเภท และดีที่ได้เห็นกระบวนการแก้ปัญหา
ไม่รู้มาก่อนว่า Redis รองรับ Lua พอเห็นแบบนี้ก็อยากลองใช้เป็นที่เก็บสถานะทางเลือกดู
แบนด์วิดท์เป็นหนึ่งในเรื่องที่ไม่พอใจที่สุดของบริการคลาวด์ เพราะไม่มี ขีดจำกัดแบบตายตัว ที่ช่วยกันไม่ให้ค่าใช้จ่ายเกินได้
แต่ทั้งสองอย่างก็ไม่ใช่ปัญหาใหญ่ และโปรเจกต์ที่พื้นที่จัดเก็บไม่ใช่ปัญหาสำคัญนี่ค่อนข้างน่าทึ่ง สำหรับผมถือเป็นประสบการณ์ใหม่
แบนด์วิดท์นี่น่าปวดหัวจริง ๆ ประมาณสองวันต้องคอยตึงเครียด ดูจำนวนไบต์ขาออกของ NIC แล้วคำนวณใหม่อยู่เรื่อย ๆ และการไม่มี hard cap ก็น่ากลัวมาก ถึงแม้ Digital Ocean จะมีราคาค่อนข้างสมเหตุสมผลแล้วก็ตาม
ผมยังไม่เคยใช้บริการ serverless ยอดนิยม แต่เข้าใจว่าที่นั่นค่าบริการแบนด์วิดท์ค่อนข้างหนัก
และ Lua ใน Redis นั้นทรงพลังจริง ๆ ถ้ายอมแลกกับประสิทธิภาพที่ลดลงเล็กน้อย ก็ช่วยข้ามปัญหายาก ๆ ที่มี race condition เยอะได้มาก และทำงานด้วยแล้วสนุก
เป็นบทความที่ยอดเยี่ยม และเว็บไซต์ก็น่ายินดีด้วย
แต่โดยส่วนตัว ผมมองว่า บทความที่เขียนนี้ เป็นส่วนที่น่าภูมิใจที่สุด
ผมคิดว่าประเด็นสำคัญคือท่อนที่ว่า “การสร้างเว็บไซต์ภายในสองวันโดยแทบไม่สนใจเรื่อง scalability เป็นการตัดสินใจที่ดี”
โดยเฉพาะเป็นสิ่งที่วิศวกรช่วงต้นอาชีพควรเรียนรู้ scalability ไม่ใช่ปัญหาจนกว่ามันจะกลายเป็นปัญหา
และเมื่อถึงจุดที่มันเป็นปัญหา มันกลับเป็นปัญหาที่ดี แถมแก้ไม่ได้ยากอย่างที่คิด
ผมเคยเห็นหลายระบบที่ microservice กลายเป็น “ตัวเลือกที่ชัดเจน” ไม่ใช่เพราะต้องการสเกลหรือแยกทีม แต่เพราะนักพัฒนาแค่อยากทำแบบนั้น
การสเกลระบบแบบนั้นเป็นงานทรมานจริง ๆ
บทความที่เกี่ยวข้องเมื่อไม่นานมานี้: One Million Checkboxes - https://news.ycombinator.com/item?id=40800869 - มิถุนายน 2024, 305 ความคิดเห็น
โปรเจกต์แบบนี้สนุกดี
ประมาณ 6 ปีก่อนผมปล่อย Pixmap บน Android ซึ่งเป็น แอปแก้ไขพิกเซลแบบร่วมมือกัน ขนาดเล็กที่รองรับกริดใหญ่ขึ้นอย่าง 1024x1024
ผมทำคิวสำหรับนำแต่ละ event ไปใช้กับภาพ PNG และเมื่อ client เชื่อมต่อก็โหลด PNG เริ่มต้น จากนั้น event การวาดแต่ละพิกเซลจะรับเพียง object เล็ก ๆ หนึ่งตัว
แบบนี้ทำให้การโหลดครั้งแรกใช้ประโยชน์จากการบีบอัดภาพได้ และชุดการเปลี่ยนแปลงหลังจากนั้นก็มีขนาดเล็กมาก อีกทั้ง event ทั้งหมดถูกเก็บไว้ใน log จึงสามารถ “กรอภาพย้อนกลับ” ได้ด้วย [0]
[0] 22mb: https://blog.winricklabs.com/images/pixmap-rewind-demo.gif
ตอนนี้เลยกำลังลองเล่นกับ canvas ที่ระบุพิกัดผ่านการเรียก API ได้
https://x.com/RussTheMagic/status/1816749136487588311
บทความดีมาก อยากรู้ว่าสุดท้ายแล้ว มีค่าใช้จ่ายเท่าไร
ค่าใช้จ่ายรวมประมาณ 850 ดอลลาร์ และแทบจะพอดีกับเงินบริจาค
หลังย้ายไป Go แล้ว ผมพลาดที่ไม่ได้ลด infra ลงให้เหมาะสม และจริง ๆ แล้ว replica Redis ตัวที่สองที่เพิ่มเข้ามาก็น่าจะเอาออกได้ ถ้าโฟกัสเรื่องค่าใช้จ่าย คิดว่าน่าจะลดลงครึ่งหนึ่งได้
แต่เงินบริจาคแทบจะครอบคลุมค่าใช้จ่ายอยู่แล้ว และมีเรื่องอื่นให้ทำมากเกินไป เลยไม่ได้โฟกัสกับมันมากนัก
หลังปิดเว็บไซต์แล้ว ผมยังคง infra ไว้อีกพักเพื่อเตรียมกราฟและอื่น ๆ เลยมีค่าใช้จ่ายเพิ่มอีกเล็กน้อย ตอนนี้ขาดทุนอยู่นิดหน่อยแต่ไม่มาก
ในฐานะคนที่กำลังเรียนแบ็กเอนด์ใหม่ ๆ ผมสงสัยว่ามีสถาปัตยกรรมทางเลือกที่ง่ายกว่าสำหรับโปรเจกต์นี้ไหม
ถ้ามีวิธีที่ง่ายกว่านี้ในการโฮสต์ สถานะบิต 1 ล้านบิต และซิงก์กับ client ก็คงดี วิธีแก้บางส่วนในบทความเข้าใจยาก
โปรเจกต์ของผู้เขียนยอดเยี่ยมมาก
ผมอยากอธิบายเทคโนโลยีที่ใช้ให้ยาวกว่านี้ แต่บทความก็ยาวมากอยู่แล้ว เลยรู้สึกว่าใส่เพิ่มได้ยาก
ถ้ามีคำถาม ผมยินดีตอบ
เอาจริง ๆ ผมไม่ค่อยแน่ใจว่าจะทำให้สถาปัตยกรรมง่ายกว่านี้มากได้อย่างไร น่าจะมีบริการที่ใช้กับงานแบบนี้ได้ แต่ผมมองว่านั่นใกล้เคียงกับการโยนความซับซ้อนไปให้คนอื่นมากกว่า
สุดท้ายสิ่งที่ต้องมีก็คือฐานข้อมูลสำหรับติดตามกล่องที่ถูกเช็ก วิธีใส่ข้อมูลลงฐานข้อมูล วิธีแจ้งสถานะปัจจุบันให้ client รู้ วิธีให้ client แจ้ง server และอัปเดตสถานะเมื่อเช็กกล่อง วิธีแจ้ง client เมื่อกล่องถูกเช็กหรือยกเลิก และวิธีไม่ render องค์ประกอบ DOM 1 ล้านชิ้นตลอดเวลา
ในที่นี้ผมใช้ Redis เก็บสถานะการเช็ก และเพื่อความเรียบง่ายก็เก็บ 1 ล้านบิตตรง ๆ แล้วส่งบิตทั้ง 1 ล้านบิตให้ client ข้อมูลไม่ได้ใหญ่มากจึงดี
ใช้ Flask กับ WebSocket จัดการ event การเช็กและ update ส่งทั้งการ update กล่องเดี่ยวและการ update กล่องทั้ง 1 ล้านกล่อง และใช้ react-window เพื่อหลีกเลี่ยงปัญหา rendering
ส่วน nginx static content กับ reverse proxy ที่เหลือ ส่วนใหญ่เป็นกลไกเพื่อให้สเกลง่ายขึ้น ดังนั้นแม้ไม่มีรายละเอียดเหล่านั้นก็ implement ได้และเว็บก็ทำงานได้ เพียงแต่จะรับโหลดเท่าเดิมไม่ได้
แทนที่จะใช้ฐานข้อมูล ก็เก็บชุดบิตไว้ในไฟล์แล้ว mmap เอา แทนที่จะใช้ reverse proxy ก็ให้แอปพลิเคชันจัดการ HTTP request และการเชื่อมต่อ WebSocket โดยตรงได้
มันคือเว็บเซิร์ฟเวอร์ไม่กี่ตัวที่มี cache กับคิว publish/subscribe อยู่ด้านหลัง
อาจทำทั้งหมด ในหน่วยความจำ บนโฮสต์ใหญ่เครื่องเดียวได้ แต่ถ้ารองรับ demand ไม่ทันหรือล้มเหลวด้วยเหตุผลใดก็ตาม ก็จะตันสนิท
ยกเว้นวิธีที่สเกลไม่ได้อย่างการมี global list ของ boolean 1 ล้านตัว อยู่ในโปรเซสเดียวกับ backend API
(checked, start_x, start_y, end_x, end_y)ก็พอแล้ว ไม่ใช่วิธีที่ชัดเจนมากเหรอเจ๋งดี
สงสัยว่าบทความถัดไปจะเป็น การวิเคราะห์สถิติ ว่า checkbox ไหนถูกเช็กน้อยที่สุดหรือมากที่สุดหรือเปล่า
ยังจำได้ว่ารู้สึกเศร้าเล็กน้อยตอนเลื่อนลงไปไกล ๆ แล้วเลือก checkbox อันหนึ่ง จากนั้นมันก็ถูกยกเลิกแทบจะทันที
ก่อนหน้านั้นยังมีอีกเรื่องหนึ่งเกี่ยวกับเว็บไซต์ที่ต้องเล่า
สงสัยว่าเกมยังมีชีวิตอยู่ไหม
เข้า https://onemillioncheckboxes.com/ แล้วไม่มีอะไรถูกเช็กเลย และใน JS console เห็นแค่นี้
{"total":0,"totalGold":0,"totalRed":0,"totalGreen":0,"totalPurple":0,"totalOrange":0,"recentlyChecked":false}ตัวอย่างที่ตรงข้ามสุด ๆ กับ implementation ที่สเกลได้ คือ implementation checkbox 1 ล้านอันในโค้ดน้อยกว่า 1000 ตัวอักษร มีเวอร์ชัน Deno
https://gist.github.com/jeff-hykin/4cdebafd8698298d021f103e2...