สร้างเว็บเซอร์วิสที่มีความพร้อมใช้งานสูงโดยไม่ใช้ฐานข้อมูล
(blog.screenshotbot.io)- Screenshotbot ใช้ สถานะใน RAM ของโปรเซสเว็บเซิร์ฟเวอร์เป็นที่เก็บข้อมูล โดยไม่ต้องมี DB แยกต่างหาก และกู้คืนสถานะหลังความขัดข้องด้วยสแนปช็อตและทรานแซกชันล็อก
- ลดความซับซ้อนของ ประสบการณ์พัฒนาแบบโปรเซสเดียว ด้วยการลด SQL serialization, การวิ่งไปกลับกับ DB และบริการงานเบื้องหลังแยกต่างหาก พร้อมใช้ดัชนีในหน่วยความจำและการประมวลผลแบบเธรด
- ความพร้อมใช้งานสูงทำได้ด้วย อัลกอริทึมฉันทามติ Raft โดยจำลองทรานแซกชันล็อกไปยังเซิร์ฟเวอร์ 3 เครื่อง และเมื่อผู้นำล่ม ผู้นำใหม่จะมารับคำขอต่อภายในไม่กี่วินาที
- การติดตั้งจริงผสมผสาน Common Lisp, bknr.datastore, โอเพนซอร์ส bknr.cluster, Baidu Braft และ EFS โดยการรีสตาร์ตเซิร์ฟเวอร์ปกติเกิดขึ้นเพียงราวทุก 1–2 เดือน
- แม้ CI ของลูกค้าองค์กรชื่อดังจะเรียก API หลายร้อยครั้งต่อ commit และ Pull Request แต่บนเครื่อง 4 คอร์ 16GB การใช้ CPU ก็อยู่สูงสุดราว 20% และส่วนใหญ่เกิดจากการประมวลผลภาพ
โครงสร้างที่ปฏิบัติต่อสถานะใน RAM เหมือนฐานข้อมูล
- เว็บเซอร์วิสแบบดั้งเดิมมักเลือกใช้เว็บเฟรมเวิร์กอย่าง Rails, Django, Node ร่วมกับฐานข้อมูลอย่าง MySQL, PostgreSQL, MongoDB
- ในช่วง 10 ปีที่ผ่านมา มีการเปลี่ยนแปลงของสภาพแวดล้อมที่ทำให้แนวทางนี้ควรถูกทบทวน
- ดิสก์เร็วขึ้นด้วย NVMe
- ดิสก์อย่าง EBS/EFS มีความทนทานมากขึ้น
- RAM ราคาถูกลงจนข้อมูลของสตาร์ทอัพส่วนใหญ่สามารถอยู่ใน RAM ได้
- สามารถเช่าเครื่องที่มีหลายร้อยคอร์ได้
- ปี 2014 มีการเผยแพร่ อัลกอริทึมฉันทามติ Raft และเริ่มมี implementation ที่แข็งแรงมากขึ้น
- สถาปัตยกรรมนี้รวมเว็บเซอร์วิสกับอินสแตนซ์ฐานข้อมูลเข้าด้วยกัน โดยใช้สถานะหน่วยความจำของโปรเซสเสมือนเป็นฐานข้อมูล
- แทนที่จะ serialize แล้วบันทึกข้อมูลแบบ SQLite ก็จัดการอ็อบเจ็กต์และฟิลด์ใน RAM โดยตรงในฐานะสถานะแอปพลิเคชัน
Explore: วิธีทำให้การวนรอบพัฒนาผลิตภัณฑ์ช่วงแรกง่ายขึ้น
- เมื่อข้อมูลทั้งหมดอยู่ใน RAM ก็ไม่ต้อง serialize เป็น SQL query และยังลดรูปแบบที่ฟรอนต์เอนด์หลายเซิร์ฟเวอร์ต้องมาเกาะ DB กลางตัวเดียว
- เมื่อโหลดเพิ่มขึ้น ก็รับมือได้ด้วยการใช้เซิร์ฟเวอร์เครื่องใหญ่ขึ้นที่มี RAM และ CPU มากกว่าเดิม
- ดัชนีสามารถใช้โครงสร้างอย่าง แฮชเทเบิลในหน่วยความจำ แทน B-tree ที่ออกแบบมารองรับ latency ของดิสก์
- ใน Screenshotbot ดัชนีที่ใช้ functional collections ตาม บทความนี้ มีความสำคัญต่อการสเกล
- เพราะการดึงข้อมูลคือการอ่านจาก RAM จึงไม่ต้องมีโครงสร้างพิเศษหรือ Async-IO เพื่อลดการวิ่งไปกลับกับ DB
- งานเบื้องหลังกลายเป็นเธรดที่รันอยู่ในโปรเซสใหญ่เดียวกัน และการจัดการ concurrency ส่วนใหญ่ก็แก้ได้ด้วย mutex และ condition variable ในหน่วยความจำ
การกู้คืนเมื่อขัดข้อง: สแนปช็อตและทรานแซกชันล็อก
- เพื่อรองรับการที่โปรเซสแครช จะมีการบันทึกสถานะ RAM ทั้งหมดเป็น สแนปช็อต เป็นระยะ
- การเปลี่ยนแปลงหลังสแนปช็อตล่าสุดจะถูกบันทึกลงดิสก์เป็น ทรานแซกชันล็อก ก่อน แล้วจึงค่อยเปลี่ยนสถานะในหน่วยความจำ
foo.setBar(2)จะบันทึกทรานแซกชันว่าฟิลด์barของfooถูกเปลี่ยนเป็น 2 ก่อน แล้วจึงค่อยแก้ฟิลด์จริงnew Foo()จะบันทึกทรานแซกชันว่ามีการสร้างอ็อบเจ็กต์ Foo แล้วจึงคืนค่าอ็อบเจ็กต์ใหม่
- ตอนรีสตาร์ตจะอ่านสแนปช็อตก่อน แล้ว replay ทรานแซกชันล็อกเพื่อกู้คืนสถานะ
- การเปลี่ยนแปลงดัชนีไม่จำเป็นต้องใส่ในทรานแซกชันล็อก
- เช่น ถ้าฟิลด์
barของFooมีดัชนีsetBarก็จะอัปเดตดัชนีนั้น และการอัปเดตนี้จะเกิดขึ้นอีกครั้งทั้งตอนโหลดสแนปช็อตหรือ replay ทรานแซกชัน
- เช่น ถ้าฟิลด์
รูปแบบโค้ดที่ทำให้โปรเซสเดียวเป็นไปได้
- เนื่องจากทุกคำขอถูกประมวลผลในโปรเซสเดียวกัน จึงสามารถเก็บ closure ไว้ในหน่วยความจำของเซิร์ฟเวอร์และใช้จัดการคำขอหน้าเว็บได้
- URL รูปแบบ
https://screenshotbot.io/n/nnnnnnnของ Screenshotbot จะถูกแมปไปยัง closure ภายใน - เพราะ closure ถือ reference ไปยังอ็อบเจ็กต์อยู่แล้ว จึงลดความจำเป็นในการส่ง object ID หรือ serialize อ็อบเจ็กต์ทุกครั้งที่เปลี่ยนหน้า
- เป้าหมายของการดีบัก โปรไฟล์ และมอนิเตอร์ก็แคบลงเหลือเพียงบริการเดียว
- ไม่มีจุดสังเกต DB แยกต่างหากอย่าง MySQL slow query log
- ถ้าบริการตัวเดียวล่ม เว็บไซต์ก็ล่มด้วย แต่ก็มีมุมมองว่าถ้ามีเพียงบริการเดียวและเซิร์ฟเวอร์เดียว โอกาสล้มเหลวก็ยิ่งต่ำ
- ถ้าเซิร์ฟเวอร์ตาย AWS ก็สามารถยกเซิร์ฟเวอร์ใหม่ขึ้นมาได้ภายในไม่กี่นาที
- โค้ดทดสอบก็ง่ายขึ้นเพราะไม่ต้อง mock ฐานข้อมูล
Expand: ทำ High Availability ด้วย Raft
- หากลูกค้าที่มีความเสี่ยงสูงต้องการความพร้อมใช้งานระดับ 99.999% โครงสร้างแบบเซิร์ฟเวอร์เดียวก็ยังไม่พอ
- หากเซิร์ฟเวอร์ล่ม อาจต้องรอ AWS ยกเครื่องใหม่ขึ้นมาหลายนาที
- โปรเซสอาจใช้เวลาหลายนาทีในการกู้คืนดิสก์สแนปช็อต
- ระหว่าง redeploy บริการอาจหยุดไปหลายนาทีจากการรีสตาร์ต
- อัลกอริทึมฉันทามติ Raft จะจำลองทรานแซกชันล็อกของเว็บเซิร์ฟเวอร์/ฐานข้อมูลที่เป็น finite-state machine
- เมื่อจำลองไปยัง 3 เครื่อง หากผู้นำล่ม จะมีการเลือกผู้นำใหม่ภายในไม่กี่วินาทีและให้บริการคำขอต่อได้ทันที
- ทำให้สามารถเปลี่ยนบริการที่เรียบง่ายให้เป็นฐานข้อมูลที่มีความพร้อมใช้งานสูงได้ โดยแทบไม่ต้องเปลี่ยนวิธีที่นักพัฒนาเขียนโค้ด
- ในการตั้งค่าที่อิง Raft ยังสามารถทำ rolling deploy ได้โดยไม่ต้องปิดเซิร์ฟเวอร์
Extract: การชาร์ดและคอขวดที่คาดไว้
- เมื่อถึงช่วงที่ต้องรองรับลูกค้าองค์กรขนาดใหญ่เพิ่มขึ้น ก็สามารถใช้ sharding แบบเดียวกับที่องค์กรใหญ่ใช้กับฐานข้อมูลได้
- แต่ละ shard สามารถเป็นคลัสเตอร์ของตัวเองได้
- ตอนนี้ Screenshotbot ให้คลัสเตอร์เฉพาะสำหรับลูกค้าองค์กรแต่ละรายอยู่แล้ว
- Meta เปลี่ยนมาใช้ Raft เพื่อจัดการ replication ของ MySQL cluster และ Screenshotbot ก็ใช้แนวทางคล้ายกันแต่ไม่มีฐานข้อมูลแยก
- คอขวดหลักที่เป็นไปได้คือการสเกลของ commit-thread
- read thread สามารถขนานได้ดี
- commit-thread ตัวเดียวจะนำทรานแซกชันมา apply ทีละรายการ
- เนื่องจาก Raft commit หลายทรานแซกชันลงดิสก์พร้อมกัน latency ของดิสก์จึงไม่ใช่ประเด็นสำคัญ
- จุดที่น่ากังวลคือถ้าต้นทุน CPU ของการ apply ทรานแซกชันสูงเกินสมรรถนะของคอร์เดียว
- ในกรณีนั้นอาจต้องโปรไฟล์ต้นทุนของ commit แล้วย้ายงานบางส่วนออกจาก transaction thread หรือพิจารณา sharding
สแตกจริงของ Screenshotbot
- Screenshotbot ใช้ Common Lisp
- รุ่นแรกใช้ MySQL แต่เพราะจัดการ concurrency ด้วย MySQL ได้ยาก จึงเปลี่ยนไปใช้ bknr.datastore
- bknr.datastore เป็นไลบรารีสำหรับ Common Lisp ที่ให้โครงสร้างแบบเดียวกับที่อธิบายในช่วง Explore
- ในโครงสร้างนี้ เธรดภายในโปรเซสเดียวจะจัดการคำขอเว็บ จึงต้องการมัลติเธรดที่แข็งแรง
- ด้วยเหตุนี้จึงมองว่า Ruby และ Python ไม่เหมาะ
- เพราะเก็บ closure ไว้ในหน่วยความจำของเซิร์ฟเวอร์ จึงรีสตาร์ตเซิร์ฟเวอร์บ่อย ๆ ได้ยาก
- ถ้ารีสตาร์ตก็จะเสีย closure เหล่านั้นไป
- จึงใช้ hot reloading เพื่ออัปเดตโค้ดในโปรเซสที่กำลังรันแทน
- Common Lisp มีฟีเจอร์อย่าง
reinitialize-instanceซึ่งเป็นมาตรฐานสำหรับอัปเดตอ็อบเจ็กต์เดิมเมื่อมีการเปลี่ยนคำจำกัดความของคลาส
คลัสเตอร์ การเก็บไฟล์ และขนาดการปฏิบัติการ
- ปัจจุบันการรีสตาร์ตเซิร์ฟเวอร์มักเกิดขึ้นเพียงประมาณทุก 1–2 เดือน
- เมื่อจำเป็นต้องรีสตาร์ต จะทำ rolling restart ใน Raft cluster
- ใช้คลัสเตอร์ 3 เซิร์ฟเวอร์ต่อหนึ่งการติดตั้ง และโครงสร้างนี้ยอมให้มีเซิร์ฟเวอร์ล่มได้ 1 เครื่อง
- ตอนนี้ยังไม่ใช้ Kubernetes และมองว่ายังไม่จำเป็น
- implementation ของ Raft เป็นไลบรารีภายในที่สร้างบน bknr.datastore
- เปิดซอร์สเป็น bknr.cluster
- ภายในใช้ Braft ของ Baidu
- Braft จัดการสแนปช็อตแบบเบื้องหลัง ทำให้เซิร์ฟเวอร์ยังให้บริการคำขอได้ต่อแม้ระหว่างสร้างสแนปช็อต
- ไฟล์ภาพหรือ blob ที่ไม่ควรใส่ใน datastore จะเก็บไว้ใน EFS ที่แชร์โดยทั้งสามเซิร์ฟเวอร์
- EFS คือ NFS แบบ high availability
- มองว่าทำงานง่ายกว่า S3 เพราะไม่ต้องจัดการเงื่อนไขผิดพลาดแยกต่างหาก
- และเพราะเป็นการเขียนลงดิสก์โดยไม่โต้ตอบกับเซิร์ฟเวอร์ภายนอก จึงทดสอบได้ง่ายกว่า
ประสิทธิภาพปัจจุบันและขอบเขตการใช้งาน
- Screenshotbot รองรับลูกค้าองค์กรขนาดใหญ่หลายราย รวมถึงลูกค้าที่เป็นที่รู้จักอย่างมากรายหนึ่ง
- ระบบทำงานใน CI ของลูกค้ารายนั้น และได้รับ API request หลายร้อยครั้งต่อทุก commit และ Pull Request
- แม้มีโหลดระดับนี้ การประมวลผลคำขอก็ยังใช้เพียงเครื่อง 4 คอร์ 16GB
- เซิร์ฟเวอร์ replica ก็ใช้เครื่องระดับใกล้เคียงกันและส่วนใหญ่แทบว่างงาน
- การใช้ CPU สูงสุดอยู่ที่ราว 20%
- การใช้ CPU ส่วนใหญ่เกิดจากการประมวลผลภาพ
- สิ่งสำคัญคือไม่ควรออกแบบโดยสมมติขนาดที่ใหญ่เกินความจำเป็น
- หากเลือกใช้ Common Lisp ก็สามารถใช้การตั้งค่าที่เกี่ยวข้องได้ผ่าน Screenshotbot OSS
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
โครงสร้างนี้โดยคร่าว ๆ คล้ายกับวิธีที่ HashiCorp สร้าง Nomad, Consul และ Vault (ผมเป็นหนึ่งในผู้ดูแล Nomad) แน่นอนว่าเป็น โครงสร้างที่แปลก แต่พอคุ้นแล้ว developer experience ก็ค่อนข้างดี
สถานะในหน่วยความจำจะจัดไว้ในรูปแบบที่ต้องการก็ได้ จึงสร้างฟังก์ชัน indexing และ query ที่ปรับให้เข้ากับแอปพลิเคชันเองได้ จะใช้ SQLite แบบ
:memory:กับ Raft FSM ก็ได้ แต่ถ้าสร้างหรือหา transaction store ในหน่วยความจำได้ (เราใช้ go-memdb ของเราเอง) การอ่านสถานะก็กลายเป็นแค่การเรียกฟังก์ชัน การป้องกัน stale read หรือ write skew ก็เรียบง่าย ทุกอ็อบเจ็กต์ที่เขียนมี Raft index อยู่แล้ว จึงสร้าง API แบบ “query อ็อบเจ็กต์ foo จาก follower แต่ให้รอจนถึง index อย่างน้อย 123” ได้ มันช่วยถอด “เวทมนตร์” จำนวนมากที่ปกติมักผลักไปให้ RDBMS หรือ external store ออกมาอย่างไรก็ตาม ผมยังระมัดระวังที่จะเลือกโครงสร้างนี้ให้สตาร์ทอัพใหม่ที่อยู่นอกสาย “infra” เพราะโดยเนื้อแท้แล้วเท่ากับกำลังสร้าง ฐานข้อมูลของตัวเอง ต้องเลือกหรือเขียนองค์ประกอบพื้นฐานเองให้ดี เช่น RPC ระหว่าง node, persistence ลงดิสก์, transaction state store ในหน่วยความจำ การอัปเกรดยากเป็นพิเศษ เพราะโค้ดใหม่อาจพยายามเขียน entity ที่ node เวอร์ชันเก่าไม่เข้าใจลงใน Raft log หรือแย่กว่านั้น วิธีประมวลผลอาจเปลี่ยนจน node เก่าเข้าใจผิดได้ ไม่มีอะไรได้มาฟรี ๆ
:memory:กับ Raft FSM ก็ได้” นั้นเคยเป็นดีไซน์พื้นฐานที่ rqlite[1] ใช้ในช่วงประมาณ 7 ปีแรก แต่ rqlite ย้ายไปใช้ SQLite บนดิสก์ แล้ว และเมื่อใช้ WAL mode กับPRAGMA synchronous=OFF[2] ก็เร็วเท่ากับหรือใกล้เคียงพอ ๆ กับการเขียนลง RAM อีกทั้งยังหลีกเลี่ยงข้อจำกัดของฐานข้อมูล SQLite แบบ:memory:ได้ด้วย หนึ่งในนั้นคือขีดจำกัดขนาดสูงสุด 2GB จริง ๆ ควรใช้โหมดดิสก์มาตั้งแต่แรก เพิ่งมารู้เอาตอนนี้เนื่องจาก rqlite ใช้ Raft library[3] เดียวกับ Nomad คุณอาจรู้อยู่แล้วบางส่วน
ปัญหาเรื่องการอัปเกรดมีอยู่จริง อยากรู้ว่าใน Nomad เจอกันบ่อยในภาคสนามหรือไม่ ตลอด 10 ปีของการพัฒนา rqlite การเพิ่ม Raft Entry type ใหม่เกิดขึ้นน้อยมาก และมีครั้งเดียวที่ผู้ใช้จริงต้องเจอ วิธีรับมืออย่างหนึ่งคือ deploy เวอร์ชันที่เข้าใจ type ใหม่แต่จะไม่เขียนมันเด็ดขาดก่อน แล้วเมื่อเวอร์ชันนั้นถูกติดตั้งครบแล้วจึงอัปเกรดเป็นเวอร์ชันที่ใช้ type ใหม่นั้นจริง ๆ อย่างไรก็ดี ผมยังไม่เคยทำแบบนั้นจริง และผู้ใช้ปลายทางก็ต้องมีวินัยด้วย
[1] https://www.rqlite.io
[2] อาจฟังดูอันตราย แต่ในดีไซน์ปัจจุบันของ rqlite เมื่อเริ่มต้นระบบจะสร้างฐานข้อมูล SQLite หลักขึ้นใหม่ทั้งหมดจาก Raft log (Raft log จะ fsync ทุกครั้งที่มีการเขียน) ดังนั้นแม้ฐานข้อมูล SQLite จะเสียหายจากไฟดับหรือเหตุอื่น ก็ไม่สำคัญมากนัก เพราะฐานข้อมูล SQLite ไม่ใช่ authoritative data store ของ rqlite
[3] https://github.com/hashicorp/raft
อย่างไรก็ตาม เมื่อเริ่มใช้ replication ในช่วง Expand ก็จริงว่าจะมีโจทย์ทางวิศวกรรมตามมา แต่ทั้งหมดเป็นปัญหาที่แก้ได้ ใน Common Lisp การ hot reload โค้ดได้ก็ช่วยให้ migration บางอย่างง่ายขึ้นมากด้วย
เมื่อหลายสิบปีก่อน PG เคยเขียนไว้ว่า ที่ Viaweb เขาไม่ได้ใช้ฐานข้อมูล และรู้สึกแปลกที่เว็บแอปดูเหมือนกลายเป็นฟรอนต์เอนด์ของฐานข้อมูล ทั้งที่แอปเดสก์ท็อปไม่เป็นแบบนั้น[0] HN ก็ไม่ได้ใช้ฐานข้อมูลเช่นกัน
แต่พอแอปเดสก์ท็อปและมือถือยุคใหม่มักใช้ฐานข้อมูล โดยมากคือ SQLite เรื่องนี้จึงไม่ตรงตามเดิมอีกต่อไป เพราะปรากฏว่าการจัดเก็บและสืบค้นข้อมูลแบบรีเลชันนัลมีประโยชน์มากในแอปพลิเคชันหลากหลายมาก
[0] https://www.paulgraham.com/vwfaq.html
ใน vwfaq ยังมีเนื้อหาว่าอ่านข้อมูลจากดิสก์ และยังมีคำว่า “เริ่มโปรเซสที่จะตอบสนองต่อคำขอ HTTP” ด้วย ตรงนี้ “ฐานข้อมูล” ดูเหมือนจะหมายถึงเซิร์ฟเวอร์แยกต่างหากที่ทำหน้าที่ทำให้ข้อมูลคงอยู่ถาวร และสถาปัตยกรรมที่ต้องสื่อสารกับอีกเซิร์ฟเวอร์เพื่อดึงข้อมูลนั้นมา
ถ้าใช้คำนิยามนี้ แน่นอนว่า SQLite ก็ไม่ถูกนับเป็นฐานข้อมูล อีกทั้งถ้าคุณอ่านข้อมูลจากดิสก์อยู่แล้ว ก็แปลว่าคุณกำลังใช้ฐานข้อมูล หรือกำลังทำเลเยอร์ความคงอยู่ของข้อมูลขึ้นเองแบบเฉพาะกิจอยู่ ประเด็นคือ ถ้าอ่านข้อมูลจาก SQLite ตอนเริ่มแอป จะยังถือว่าใช้ฐานข้อมูลอยู่หรือไม่
ปัญหาของวิธีคิดแบบนี้คือมันมองข้ามข้อเท็จจริงที่ว่าแก่นของฐานข้อมูลคือการจัดเก็บและดึงข้อมูลในรูปแบบที่สะดวก โดยไม่ต้องใส่ใจกับรายละเอียดระดับล่าง การเก็บข้อมูลไว้ในฐานข้อมูลไม่ได้แปลว่าต้องมีอินสแตนซ์ Postgres สักตัวรันอยู่ที่ไหนสักแห่งแล้วดึงข้อมูลผ่านเว็บ ถ้าคุณเก็บข้อมูลทั้งหมดไว้ในหน่วยความจำ และมีโปรเซสที่บันทึกสแนปช็อตลงดิสก์ด้วยโครงสร้างข้อมูลแบบ log-structured ก็ขอแสดงความยินดีด้วย คุณเพิ่งสร้าง ฐานข้อมูลของตัวเอง ขึ้นมาแล้ว
อย่างที่สอง ในช่วง 20 ปีที่ผ่านมา การสืบค้น RDBMS ง่ายขึ้นมาก มี ORM และ row mapper สารพัดแบบที่ช่วยลดโค้ดซ้ำๆ
ยังมีฟีเจอร์ขั้นสูงอย่างการค้นหาแบบ full-text ที่มีประโยชน์กับแอปเดสก์ท็อปและมือถือด้วย ทุกวันนี้การใช้ RDBMS ในแอปเดสก์ท็อปเป็นทางเลือกที่ดี
เว็บเติบโตขึ้นมากแล้ว และแนวปฏิบัติแบบนี้จำนวนมากคงใช้ไม่ได้ในวันนี้ ถ้าผมผลักการแก้ไขแบบสดเข้าเครื่องโปรดักชันระหว่างคุยกับลูกค้า โดยมีภาระการทดสอบระดับปัจจุบัน หลายคนแถวนี้คงสงสัยสภาพจิตใจของผมแน่
ผมเข้าใจความอยากทดลองสิ่งที่น่าสนใจ แต่การทำแบบนี้เพียงเพื่อไม่ต้องเรียนรู้ส่วนพื้นฐานที่สุดของ MySQL หรือ Postgres ดูเหมือนเสียเวลาอย่างมหาศาล แค่สร้างบนสิ่งเหล่านั้นก็จบ โดยเฉพาะถ้ารันบน public cloud
ผมไม่คล้อยตามเรื่องความหน่วงจาก round trip ที่เพิ่มขึ้นหรือปัญหา concurrency อย่างหลังมีทางแก้ง่ายๆ เช่นการจูนพื้นฐาน หรือแยกลูกค้าที่สร้างภาระรบกวนออกไป ในบทความอื่นของบล็อกพวกเขาพูดถึงความเป็นไปได้ที่จะเพิ่มข้อมูลวันละ 10 ล้านแถวและโจทย์เรื่องการทำดัชนี แต่นั่นแทบไม่ใช่อะไรเลยจริงๆ ต่อให้มากกว่านั้น 10 เท่า ผมก็ไม่คิดว่าเป็นเหตุผลพอที่จะต้องวิศวกรรม โซลูชันเฉพาะทาง ขึ้นมา
จนกว่าจะจำเป็นจริงๆ แนวคิด “แย่กว่าย่อมดีกว่า” ก็ถูกต้อง และเมื่อถึงเวลานั้นคุณจะรู้แน่ๆ ตอนนั้นคุณจะรู้คอขวดด้วย จึงรับมือได้อย่างฉลาดกว่าการทำเกินจำเป็นตั้งแต่ต้น
แต่การที่บล็อกเกอร์ยกเหตุผลไม่เลือก SQLite ว่าอาจมีฟีเจอร์ที่ไม่จำเป็นนั้น ชวนงุนงงมาก มันไร้สาระและไม่ช่วยยืนยันอะไรเลย
บทความอ่านเหมือนเริ่มจากวิธีแก้ที่แย่สำหรับ ปัญหาสมมติ แล้วพยายามอย่างสิ้นหวังด้วยเหตุผลฝืนๆ เพื่อปฏิเสธวิธีแก้ที่ชัดเจน
ผมสงสัยว่าความคิดผมผิดหรือไม่ หรือความพยายามเพิ่มเติมที่ดูเป็นที่ถกเถียงแต่ไม่จำเป็นนี้ จริงๆ แล้วเป็นส่วนหนึ่งของผลิตภัณฑ์และเป็นวิธีประสบความสำเร็จในพื้นที่นี้
เริ่มด้วยประโยคว่า “ไม่ได้หมายถึงอะไรอย่าง SQLite ที่ข้อมูลยังคงถูก serialize อยู่” แต่สุดท้ายกลับจบที่ transaction log ที่ทำเองซึ่งก็ยังต้อง serialize และต้อง replicate อยู่ดี มันดูแปลก ๆ เพราะการ replicate ฐานข้อมูลก็ทำแบบนั้นมาตั้งแต่แรกอยู่แล้ว
ถ้าโหลดทั้งหมดไปลงที่เซิร์ฟเวอร์เครื่องเดียว ก็แค่รันฐานข้อมูลบนเซิร์ฟเวอร์นั้น แล้วลืมเรื่อง “สถาปัตยกรรมพิเศษเพื่อลด round trip ไปฐานข้อมูล” ไปได้เลย ถ้าข้อมูลทั้งหมดใส่ใน RAM ได้ ก็ใช้ ramdisk กับฐานข้อมูลถ้าต้องการ แล้ว replicate ไปยัง storage ถาวรด้วยเครื่องมือมาตรฐาน นั่นแหละที่เรียบง่ายจริง ๆ
ดังนั้นโดยพื้นฐานแล้วมันคล้าย https://litestream.io/ การมีการตั้งค่า Raft แบบชัดเจนอาจทำให้ failover เร็วกว่าได้ ผมไม่ใช่ผู้ใช้ Litestream เลยไม่รู้ความต่างเล็ก ๆ น้อย ๆ แต่ฟังดูคล้ายกันมาก
นอกเหนือจากการทำให้เรียบง่ายเกินไปแบบนี้แล้ว ตัวไอเดียเองผมค่อนข้างชอบ และคิดว่าบทความโน้มน้าวแนวคิดได้ค่อนข้างดี สำหรับหลายระบบ ต่อให้ประสบความสำเร็จแบบผิดปกติ มันก็น่าจะขยายได้เพียงพอรองรับธุรกิจส่วนใหญ่หรือทั้งหมด และประสิทธิภาพก็น่าจะดีอย่างเหลือเชื่อเมื่อเทียบกับทางเลือกแทบทุกแบบ
ลอง cold start ฐานข้อมูล แล้วรัน
selectที่ค่อนข้างใหญ่สองครั้งก็เห็นได้ฐานข้อมูลแบบ transaction นั้นเรียบง่ายในช่วง Expand และ Extract แต่ในช่วง Explore มันกลายเป็นภาระเพิ่มเติม เพราะทำให้ไปโฟกัสปัญหาโครงสร้างพื้นฐานแทนที่จะเป็นผลิตภัณฑ์ ในช่วง Explore ยังไม่มีลูกค้า จึงไม่มีข้อมูล และความน่าเชื่อถือของข้อมูลก็ไม่สำคัญ
วิธีของ bknr.datastore ที่เก็บทุกอย่างไว้ในหน่วยความจำ (ไม่มี replication) นั้นเรียบง่ายในช่วง Explore แต่พอเข้าสู่ช่วง Expand ก็จะเกิดภาระด้านปฏิบัติการเพื่อรับประกันความสอดคล้องของข้อมูล
อย่างไรก็ตาม กว่าจะถึงช่วง Expand คุณก็ได้ validate ผลิตภัณฑ์แล้ว และเขียนโค้ดไว้มากแล้ว การเขียนใหม่ทั้งหมดให้ใช้ฐานข้อมูลแบบ transaction จึงไม่สมเหตุสมผล และการเพิ่ม Raft replication เข้าไปข้างบนนั้นง่ายกว่า
ตรรกะของบทความนี้ชวนงง นี่คือวิธีทำให้แอปพลิเคชันที่มี state เรียบง่ายและเร็วขึ้นจริงหรือ?
สมมติฐานอ่อน แต่ข้อกล่าวอ้างแรงเกินไป ผู้เขียนขยายความยากของ serialization ให้ดูเกินจริงเพื่อทำให้ข้ออ้างที่อ่อนดูแข็งแรงขึ้น
คนแบบนี้มักทำงานด้วยยาก ดีใจที่พวกเขาหาสตาร์ทอัพให้ตัวเองไปจมได้ จะได้ไม่ต้องให้ผมรับมือ
เวลาเริ่มโปรเจกต์ใหม่ โครงสร้างข้อมูลมักเป็น “รายการของ item ที่มี attribute” เช่นตอนนี้ผมกำลังเขียนแอปฟิตเนส ข้อมูลคือรายการท่าออกกำลังกาย และแต่ละท่ามีชื่อ คำอธิบาย URL วิดีโอ และ attribute อื่น ๆ
ปกติผมจะเริ่มด้วยการใส่ item เหล่านั้นไว้ในไฟล์ YAML ในไดเรกทอรี
dataจริง ๆ แล้วมันเป็น dialect ของ YAML ที่ทำเอง โดยตัดความแปลกของ YAML ดั้งเดิมออกไป ค่าแต่ละตัวเป็นสตริง และไม่มีการแปลง type แบบวิเศษ การสร้าง item ใหม่ก็คือใส่ข้อมูลด้วยvim crunches.yamlเท่านั้น และการแก้ไขกับลบก็ง่ายมากในโครงสร้างข้อมูลแบบนี้เมื่อโปรเจกต์ใหญ่ขึ้น ก็มักสร้าง schema ของ DB แล้ว moved item เหล่านั้นไปไว้ใน MariaDB หรือ SQLite
ครั้งนี้ผมคิดจะย้าย item (ท่าออกกำลังกาย) ไปเป็น คอลัมน์ JSON ใน SQLite DB โดยเก็บ attribute ทั้งหมดของ item หนึ่งไว้ในฟิลด์ JSON เดียว และเขียนตัวสำรวจ DB เล็ก ๆ ที่ช่วยให้แก้ไขฟิลด์ JSON ได้เหมือน YAML เป้าหมายคือรักษาความสะดวกในการแก้ไขข้อมูลที่มนุษย์อ่านได้ไว้
การเขียนตัวสำรวจ DB น่าจะค่อนข้างตรงไปตรงมา ใช้ ncurses เล็กน้อยเพื่อไล่ดูตาราง เลือกตารางหนึ่ง ไล่ดูแถว แทรก·ลบแถว เวลาแก้ไขฟิลด์ก็เปิด Vim ถ้าฟิลด์เป็น JSON ก็แปลงเป็น YAML ก่อนส่งให้ Vim และเมื่อผู้ใช้ออกจาก Vim ก็แปลงกลับเป็น JSON
สิ่งที่อธิบายช่วงต้นบทความโดยพื้นฐานแล้วคล้ายกับวิธีที่ เครื่อง NUMA เคยทำงาน (เช่น SGI Altix หรือ UV) อีกทั้งข้อดีที่พวกเขาอ้างคือ latency ต่ำ และสามารถ parallelize งานด้วย multithreading บน RAM ขนาดใหญ่ได้ clustering เกิดขึ้นมาเป็นทางเลือกต้นทุนต่ำแทนเครื่องราคากว่าล้านดอลลาร์ มีความคล้ายกับ persistence ของ AS/400 ด้วย ซึ่งแอปแค่เขียนลงหน่วยความจำแล้วมันถูก map ลงดิสก์อย่างโปร่งใส
ตอนนี้จึงเหมือนย้อนเวลากลับไปสู่ข้อดีของเครื่อง NUMA แบบคลัสเตอร์ด้วยฮาร์ดแวร์ราคาถูก ระหว่างทางก็มีการปรับปรุง และบทความก็อ่านสนุก
เทคนิคอีกอย่างในอดีตคือการเอา TCP/IP stack ออกจากภายในคลัสเตอร์เพื่อตัดปัญหาที่เกี่ยวข้องออกไป โซลูชันอย่าง Active Messages เป็นชั้นบาง ๆ เหนือฮาร์ดแวร์ มีการออกแบบเราเตอร์เครือข่ายที่มี strong consistency ฝังอยู่ด้วย สิ่งที่พวกเขาทำได้มีค่อนข้างมาก
ถ้าขยายใหญ่ขึ้นก็มี โอกาสด้านฮาร์ดแวร์ ด้วย ฝั่ง CPU นั้น SGI ทำไว้สองอย่าง เครื่อง NUMA ขยายจำนวน CPU และ RAM ในระบบเดียว และเสียบ FPGA เข้ากับ memory bus โดยตรงเพื่อใช้เป็น accelerator แบบปรับแต่งเอง สุดท้าย งานวิจัยวิทยาการคอมพิวเตอร์บางฉบับปรับ instruction set ของโปรเซสเซอร์ เครือข่ายภายในชิป ฯลฯ เพื่อตัดหรือลดคอขวดของ multithreading ชิปอย่าง OpenPiton เพิ่มจำนวนคอร์ด้วยคอร์ที่เปิดกว้างและปรับแต่งได้ (เช่น 32 คอร์)
“ลองจินตนาการดูว่าเราจะสร้างสิ่งเจ๋ง ๆ ได้มากแค่ไหน หากไม่จำเป็นต้อง serialize ข้อมูลเป็น SQL query” นั้นมีอยู่แล้วในการนำ actor model[0] ไปใช้งานที่สุกงอมเพียงพอ เช่น Akka Event Sourcing[1] และยังครอบคลุมปัญหาต่อไปนี้ด้วย
“แต่ส่วนสำคัญคือจะกู้คืนอย่างไรเมื่อ process crash คำตอบง่ายมาก ก็แค่ snapshot RAM ทั้งหมดเป็นระยะ ๆ”
แก้ได้ในสาระสำคัญโดยไม่จำเป็นต้องสร้าง “สถาปัตยกรรมใหม่สำหรับการพัฒนาเว็บ” นอกจากนี้ยังมีความพยายามโอเพนซอร์สที่ใช้ actor เพื่อสำรวจโปรโตคอล RAFT ที่นี่[2] และที่นี่[3]
0 - https://en.wikipedia.org/wiki/History_of_the_Actor_model
1 - https://doc.akka.io/docs/akka/current/typed/persistence.html
2 - https://github.com/Michael-Dratch/RAFT_Implementation
3 - https://github.com/invkrh/akka-raft
หากเลือกสิ่งอย่าง Cosmos DB, MongoDB, DynamoDB เป็น persistence provider ก็สามารถ query สถานะที่ persist ไว้ได้ด้วย
https://learn.microsoft.com/en-us/dotnet/orleans/grains/grai...
https://learn.microsoft.com/en-us/dotnet/orleans/grains/tran...
https://learn.microsoft.com/en-us/dotnet/orleans/grains/even...
ความคิดแรกที่ผุดขึ้นมาคือ “อ๋อ นี่เป็นวิธีที่ผมเคยทำตอนเขียนด้วย Common Lisp นี่นา น่าสนุกดีที่มีคนค้นพบเทคนิคนั้นอีกครั้ง”
แต่จริง ๆ ไม่ใช่ แค่ยังมี ชาว Lisp คนอื่น ๆ อยู่ด้วยเท่านั้น
“RAM ถูกมาก” อาจเป็นความเข้าใจผิดที่ใหญ่ที่สุดอย่างหนึ่งของนักพัฒนา
SSD ดีขึ้น 100~10000 เท่าเมื่อวัดตาม throughput หรือ IOPS และประสิทธิภาพต่อดอลลาร์ของ vCPU ก็เพิ่มขึ้น 20~50 เท่า จาก 45/32nm ตอนนี้มาถึง 5nm/3nm แล้ว และจำนวนคำสั่งที่ประมวลผลได้ต่อ clock ก็สูงขึ้นมาก
แต่ ราคา RAM แทบไม่ได้ลดลงเท่า CPU หรือ SSD เลย มันอาจเร็วขึ้นมาก สามารถใส่หน่วยความจำได้มากขึ้นด้วยชิปความหนาแน่นสูงกว่า และจำนวน channel ก็อาจเพิ่มจาก dual เป็น 8 หรือ 12 channel แต่ถ้าดูราคา spot ของ DRAM ช่วงปี 2008~2022 ราคา DRAM ต่ำสุดเคยอยู่ระดับเดียวกันที่ประมาณ 2.8 ดอลลาร์/GB ถึงสามครั้ง ในช่วงเดียวกัน ตามวัฏจักรมันแกว่งขึ้นลงไปถึง 6~8 ดอลลาร์/GB กล่าวคือ หากคุณซื้อ DRAM ในช่วงจุดต่ำสุดหรือจุดสูงสุดตลอดราว 15 ปีที่ผ่านมา โดยไม่คิดเงินเฟ้อ ราคาก็น่าจะใกล้เคียงกันในระดับประมาณ ±10~20%
กว่าจะทะลุกำแพง 2.8 ดอลลาร์/GB ได้ก็ช่วงกลางปี 2022 โดยร่วงลงไปใกล้ 1 ดอลลาร์/GB แล้วกลับมาตั้งหลักอยู่ราว 2 ดอลลาร์/GB สำหรับ DDR5
ตอนนี้ใส่ RAM 4TB ในเซิร์ฟเวอร์เครื่องเดียวได้แล้วก็จริง แต่นั่นไม่ได้แปลว่า DRAM ถูกมหาศาล นักพัฒนาทั่วไปหรือนักพัฒนาใน Big Tech มีรายได้มากกว่าในปี 2010 มาก จึงรู้สึกว่า RAM เอื้อมถึงได้ง่ายขึ้นมาก ความจริงคือแม้ในจุดต่ำสุดของช่วง 15 ปีที่ผ่านมา ราคา DRAM ก็ลดลงอย่างมากแค่เกิน 2 เท่านิดหน่อยเท่านั้น และมีความเป็นไปได้สูงที่ราคา DRAM จะพุ่งขึ้นอีกครั้งภายใน 1~2 ปี
ยกตัวอย่างง่าย ๆ ถ้าเมื่อ 20 ปีก่อน node หนึ่งถูกจำกัดที่ RAM 16GB หากต้องการ RAM 4TB ในระบบ ก็ต้องใช้ 256 node (ไม่นับ overhead ของ OS แต่ละตัว)
ทุกวันนี้ node เดียวสามารถบรรจุ 4TB ทั้งหมดนั้นไว้ใน chassis เดียวได้
ต้นทุนรวมของชิป RAM เองอาจไม่ได้เปลี่ยนไป แต่ต้นทุนในการใช้งาน RAM นั้นจริง ๆ ในระบบกายภาพลดลงอย่างมาก