ข้าม​ไป​ยัง​เนื้อหา

ทำไม​ต้อง​มี key-value store และ​การ​ออกแบบ wire protocol เอง

คุณ​จบ #21 (rust-from-scratch) มา​แล้ว — ownership/borrowing, struct/enum/Option/Result, trait, collection, การ​จัดการ error และ std::thread เบื้องต้น​อยู่​ใน​มือ​คุณ​ครบ คอร์ส​นี้ กิน ทักษะ​ชุด​นั้น​เข้าไป ไม่​สอน​ซ้ำ แล้ว​เอา​ไป​สร้าง​ของ​จริง: kaen-kvstore — ฐาน​ข้อมูล key-value บน​เครือข่าย​ที่​กู้​คืน​จาก​การ​ล่ม​ได้ (crash recoverycrash recoveryการ​กู้​สถานะ​หลัง crash โดย replay log แล้ว rebuild index — crash กลางคัน​แล้ว reopen กลับ​มา​ได้​สถานะ​ที่​ถูกต้อง) เขียน​ด้วย Rust std ล้วน ไม่มี async/tokio ไม่มี serde

เส้น​เรื่อง​ของ​ทั้ง​คอร์ส​คือ​มุมมอง​เดียว: ทุก​ชิ้น​ที่​เรา​ต่อ​ขึ้น​มา map กับ​แนวคิด​จริง​ของ storage engine (Bitcask / WAL / LSM) เพียง​แต่​ย่อ​ลง​มา​ใน​ระดับ toy — append-only log, hash index, write→fsync→ack, tombstone, compaction ล้วน​เป็น primitive ที่​ฐาน​ข้อมูล​จริง​ใช้ บท​นี้​วาง โครง​บน​เครือข่าย ก่อน: wire protocol ที่​เรา​ออกแบบ​เอง + server/client TCP แบบ single-threaded + store ใน​หน่วย​ความ​จำ ส่วน persistence (การ​เขียน​ลง disk) เริ่ม​บท2

📦 kaen-kvstore

code ลงมือ​ของ​คอร์ส​นี้​อยู่​ใน repo kaen-kvstore (code ตัวอย่าง​กำลัง​จัด​ทำ) — ตลอด 8 บท​เรา​สร้าง key-value store บน​เครือข่าย​ที่​กู้​คืน​จาก crash ได้ หนึ่ง​ตัว ด้วย Rust std ล้วน (ไม่มี async/tokio, ไม่มี serde — serialization เขียน​เอง​ด้วย byte แบบ length-prefixed) บท​นี้​ต่อ​โครง​บน​เครือข่าย: wire protocol + server/client TCP + store ใน​หน่วย​ความ​จำ — persistence เริ่ม​บท2 บท​นี้​ตั้ง precedent ของ honesty spine (พูด​ความ​จริง​เรื่อง​ขีด​จำกัด​ของ toy) กับ 🔗 callout อ้างอิง​ต้นทาง ให้​ทุก​บท​หลัง​จาก​นี้​เดิน​ตาม

toolchain ที่ pin ไว้ + std ที่​คอร์ส​นี้​ใช้

ทุก​บท pin ไว้​ที่ Rust stable 1.97.1 (ออก 2026-07-16) และ edition = “2024” (ค่า​ปริยาย​ของ cargo new บน 1.97.1) — เลข compiler ขยับ​ทุก ~6 สัปดาห์ เช็ก​เอง​ด้วย rustc --version ได้​เสมอ ส่วน edition = "2024" นิ่ง​เพราะ​ผูก​กับ crate ไม่ใช่​รุ่น compiler [dependencies] ใน Cargo.toml ว่างเปล่า​ตลอด​คอร์ส — ZERO external crate

ทั้ง​คอร์ส​หยิบ​ใช้ std เพียง​ไม่​กี่ module: std::net (TcpListener, TcpStream), std::thread, std::sync (Arc, Mutex, RwLock, mpsc), std::fs (File, OpenOptions, rename), std::io (Read, Write, BufReader, BufWriter, Seek, read_exact), std::time, std::collections (HashMap) — บท​นี้​แตะ​สาม​ตัว​แรก​ใน​กลุ่ม std::net/std::io/std::collections

key-value storekey-value storeฐาน​ข้อมูล​ที่​เก็บ​ข้อมูล​เป็น​คู่ key→value ค้น​ด้วย key ตรง ๆ คือ​ฐาน​ข้อมูล​ที่​เก็บ​ข้อมูล​เป็น​คู่ key → value แล้ว​ค้น​ด้วย key ตรงๆ — API ที่​เล็ก​ที่สุด​ที่​ยัง​มี​ประโยชน์​จริง​มี​แค่​สาม​คำ​สั่ง: SET key value, GET key, DELETE key เท่านั้น เรา​เลือก​มัน​เป็น​โจทย์​เพราะ​มัน​คือ ฐาน​ข้อมูล​ที่​มี​ประโยชน์​ตัว​ที่​ง่าย​ที่สุด — พื้น​ผิว API แคบ​พอที่​เรา​จะ​ไม่​เสีย​เวลา​กับ​การ parse query แต่ ข้าง​ใน กลับ​ต้อง​แก้​ปัญหา​แกน​กลาง​ของ​ฐาน​ข้อมูล​ทุก​ตัว: จะ​เก็บ​ลง disk อย่างไร​ให้​เร็ว, จะ​รับประกัน​ว่า​ข้อมูล​รอด crash ได้​อย่างไร, จะ index เพื่อ​ค้น​ให้​ไว, จะ​รับ​หลาย client พร้อม​กัน​อย่างไร, และ​จะ​เก็บกวาด​พื้นที่​โดย​ไม่​ทำลาย​ความ​ถูกต้อง​อย่างไร ทั้งหมด​นี้​คือ​สิ่ง​ที่ 7 บท​ถัด​ไป​จะ​ไล่​ที​ละ​เรื่อง

kaen-kvstore เป็น infra แบบ​ยืน​เดี่ยว ไม่​ผูก​กับ domain ธุรกิจใดๆ — คิด​เสีย​ว่า​มัน​คือ Redis/Memcached version จิ๋ว​ที่​เรา​รื้อ​ออก​มา​ต่อ​เอง​ที​ละ​ชิ้น​เพื่อ เข้าใจ ว่า​ฐาน​ข้อมูล​จริง​ทำงาน​อย่างไร ก้าว​แรก​ก่อน​จะ​พูด​เรื่อง disk เลย​ด้วย​ซ้ำ คือ: client จะ “คุย” กับ server ผ่าน​เครือข่าย​ด้วย​ภาษา​แบบ​ไหน

พอ​สอง​ฝั่ง​จะ​คุย​กัน​บน​เครือข่าย เรา​ต้อง​ตกลง “ภาษา” กัน​ก่อน — wire protocolwire protocolรูปแบบ byte ที่​ตกลง​กัน​ระหว่าง client/server บน​สาย TCP คือ​รูปแบบ byte ที่​ทั้ง client และ server ตกลง​กัน​ว่า​จะ​ส่ง​และ​ตีความ​อย่างไร​บน​สาย ประเด็น​ที่​มือใหม่​มัก​สะดุด: TCP เป็น byte stream ที่ ไม่มี ขอบเขต​ของ message คุณ​เขียน SET foo bar ลง​ไป​เป็น​ก้อน​เดียว แต่​ปลายทาง​อาจ​อ่าน​ได้​เป็น SET fo ก่อน แล้ว​ค่อย​ได้ o bar ตาม​มา หรือ​กลับ​กัน สอง write ของ​คุณ​อาจ​มา​ถึง​รวม​เป็น​ก้อน​เดียว TCP รับประกัน​แค่ ลำดับ และ ครบถ้วน ของ byte ไม่​ได้​รับประกัน​ว่า​มัน​จะ​มา​ถึง​เป็น​ก้อน​ตรง​กับ​ที่​คุณ​ส่ง

ทางออก​มาตรฐาน​คือ framing: ใส่ ความ​ยาว นำ​หน้า​ทุก​ก้อน​ข้อมูล ปลายทาง​อ่าน​ความ​ยาว​ก่อน แล้ว​จึง​รู้​ว่า​ต้อง​อ่าน byte ตาม​มา​อีก​กี่​ตัว​ถึง​จะ​ครบ1 message เรา​ออกแบบ record แบบ length-prefixed คือ [u32 length][bytes...] — 4 byte แรก​บอก​ความ​ยาว แล้ว​ตาม​ด้วย byte ตาม​จำนวน​นั้น​เป๊ะ (นี่​คือ serializationserializationการ​แปลง​ข้อมูล​ใน​หน่วย​ความ​จำเป็น byte เพื่อ​ส่ง/เก็บ (คอร์ส​นี้​เขียน​เอง ไม่​ใช้ serde) ที่​เรา​เขียน​เอง ไม่​พึ่ง serde)

จุด​สำคัญ​ที่​ทำให้ framing ทำงาน​ถูก​คือ ต้อง​อ่าน​ให้​ครบ​พอดี — และ​นี่​คือ​เหตุผล​ที่​เรา​ใช้ read_exact ไม่ใช่ read ธรรมดา ตาม​เอกสาร std: Read::read() ครั้ง​เดียว “อาจ​คืน byte น้อย​กว่า​ที่ buffer ขอ” (short read) ได้​เสมอ​บน stream ส่วน read_exact จะวน loop อ่าน​จน​เต็ม buf.len() พอดี หรือ​คืน error UnexpectedEof ถ้า​สาย​จบ​ก่อน​อ่าน​ครบ — ซึ่ง​พอดี​เป๊ะ​กับ​สิ่ง​ที่ framing ต้องการ (อ่าน 4 byte ของ​ความ​ยาว​ให้​ครบ แล้ว​อ่าน payload ให้​ครบ​ตาม​ความ​ยาว​นั้น) เรื่อง​นี้​ไม่ใช่​ของ Rust โดย​เฉพาะ — เป็น​ปัญหา on-disk/on-wire record framing ที่​ตำรา​ฐาน​ข้อมูล (Petrov ch3, DDIA ch3) พูดถึงตรงๆ

2 function นี้​คือ แกน​ที่​ใช้​ซ้ำ — บท​นี้​ใช้​มัน​ส่ง byte ลง socket, บท2 จะ​ใช้ format เดียวกัน​นี้​เขียน record ลง File บน disk ทั้ง​คู่ generic บน Write/Read จึง​ใช้ได้​กับ​ทั้ง socket และ file:

use std::io::{self, Read, Write};
// 1 frame = ความยาว u32 แบบ little-endian นำหน้า แล้วตามด้วย byte ตามจำนวนนั้นพอดี
fn write_frame<W: Write>(w: &mut W, bytes: &[u8]) -> io::Result<()> {
let len = bytes.len() as u32;
w.write_all(&len.to_le_bytes())?;
w.write_all(bytes)?;
Ok(())
}
fn read_frame<R: Read>(r: &mut R) -> io::Result<Vec<u8>> {
let mut len_buf = [0u8; 4];
r.read_exact(&mut len_buf)?; // อ่าน 4 byte ให้ครบ วน loop ข้าม short read
let len = u32::from_le_bytes(len_buf) as usize;
let mut buf = vec![0u8; len];
r.read_exact(&mut buf)?; // อ่านให้ครบ `len` byte พอดี
Ok(buf)
}

to_le_bytes/from_le_bytes แปลง​ความ​ยาว​เป็น byte แบบ little-endian คงที่ ไม่​ว่า​จะ​รัน​บน​เครื่อง​สถาปัตยกรรม​ไหน — ถ้า​ใช้ to_ne_bytes (native endian) แทน ข้อมูล​ที่ machine หนึ่ง​เขียน​อาจ machine อีก​ตัว​อ่าน​กลับ​ผิด เรา​จึง pin endianness ไว้​เลย และ​เพราะ​เรา​ใส่ความ​ยาว​นำ​หน้า ตัว value จะ​เก็บ byte อะไร​ก็ได้ — เว้น​วรรค, 0x0A (newline), byte สูง​ที่​ไม่ใช่ UTF-8 — โดย​ไม่​พัง ต่าง​จาก​การ​อ่าน​แบบ​บรรทัด (lines()) ที่​จะ​แตก​ทันที​เมื่อ value มี \n หรือ​ไม่ใช่ text

ทีนี้​ต่อ server TcpListenerTcpListenerตัว​รับ​การ​เชื่อม​ต่อ TCP ขา​เข้า​ใน `std::net` (`bind` + `incoming()`) คือ​ตัว​รับ​การ​เชื่อม​ต่อ TCP ขา​เข้า​ใน std::net: TcpListener::bind จอง port แล้ว .incoming() คืน iterator ของ​การ​เชื่อม​ต่อ​ที่​เข้า​มา — วน for ที​ละ​อัน​คือ รับ client ที​ละ​ราย store ของ​บท​นี้​คือ HashMap<Vec<u8>, Vec<u8>> ใน​หน่วย​ความ​จำ​ล้วนๆ (key และ value เป็น byte ดิบ จึง​รับ binary ได้) opcode 1 byte นำ​หน้า​ทุก​คำ​สั่ง​เพื่อ​บอกว่า​เป็น GET/SET/DELETE:

use std::collections::HashMap;
use std::io::{self, BufReader, BufWriter, Read, Write};
use std::net::{TcpListener, TcpStream};
const OP_GET: u8 = 1; const OP_SET: u8 = 2; const OP_DEL: u8 = 3;
const ST_OK: u8 = 0; const ST_NOT_FOUND: u8 = 1; const ST_ERR: u8 = 2;
fn serve(listener: TcpListener) -> io::Result<()> {
let mut store: HashMap<Vec<u8>, Vec<u8>> = HashMap::new();
for stream in listener.incoming() { // ทีละ client
let stream = stream?;
if let Err(e) = handle_connection(stream, &mut store) {
eprintln!("connection error: {e}");
}
}
Ok(())
}
fn handle_connection(stream: TcpStream, store: &mut HashMap<Vec<u8>, Vec<u8>>) -> io::Result<()> {
let mut reader = BufReader::new(stream.try_clone()?); // แยกครึ่ง read/write ของ socket เดียวกัน
let mut writer = BufWriter::new(stream);
loop {
let mut op = [0u8; 1];
match reader.read_exact(&mut op) { // EOF ตรงนี้ = client วางสายแบบสะอาด
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(()),
Err(e) => return Err(e),
}
match op[0] {
OP_GET => {
let key = read_frame(&mut reader)?;
match store.get(&key) {
Some(v) => { writer.write_all(&[ST_OK])?; write_frame(&mut writer, v)?; }
None => { writer.write_all(&[ST_NOT_FOUND])?; write_frame(&mut writer, &[])?; }
}
}
OP_SET => {
let key = read_frame(&mut reader)?;
let val = read_frame(&mut reader)?;
store.insert(key, val);
writer.write_all(&[ST_OK])?; write_frame(&mut writer, &[])?;
}
OP_DEL => {
let key = read_frame(&mut reader)?;
let ok = store.remove(&key).is_some();
writer.write_all(&[if ok { ST_OK } else { ST_NOT_FOUND }])?;
write_frame(&mut writer, &[])?;
}
_ => { writer.write_all(&[ST_ERR])?; write_frame(&mut writer, b"unknown opcode")?;
writer.flush()?; return Ok(()); }
}
writer.flush()?; // สำคัญมาก: ถ้าไม่ flush คำตอบจะค้างอยู่ใน buffer ไม่ออกไปหา client
}
}

โครง​นี้​คือ final project ของ​หนังสือ The Rust Programming Language ฉบับ​ปี 2024 ตรงๆ — และ​ตรง​นี้​มี​กับดัก​การ​อ้างอิง​ที่​ต้อง​ระวัง: ใน​ฉบับ​ปัจจุบัน (2024) project ปิด​ท้าย “Building a Multithreaded Web Server” อยู่ บท 21 (§21.1 เป็น version single-threaded ที่​ใช้ TcpListener/TcpStream/BufReader เป๊ะ​แบบ​ข้าง​บน) แต่​ใน​ทุก​ฉบับ ก่อน ปี 2024 บท​นี้​เคย​เป็น บท 20 — บท async ที่​เพิ่ม​เข้า​มา​ใหม่​ดัน​เลข​ทุก​อย่าง​ขึ้น​หนึ่ง เรา​จึง​อ้าง บท 21 เท่านั้น ไม่ใช่​บท 20

รายละเอียด​สาม​จุด​ที่ load-bearing:

  • try_clone() แยก​ครึ่ง read/writeTcpStream หนึ่ง​ตัว​ถูก try_clone ออก​เป็น2 handle ชี้ socket เดียวกัน อัน​หนึ่ง​ห่อ BufReader ไว้​อ่าน อีก​อัน​ห่อ BufWriter ไว้​เขียน เรา​จึง​ถือ​ทั้ง​ฝั่ง​อ่าน​และ​เขียน​บน socket เดียว​ได้​พร้อม​กัน
  • flush() ทุก​ตาBufWriter สะสม byte ไว้​ใน buffer ก่อน ตาม​เอกสาร std มัน​จะ​เขียน​ออก​จริง​ก็​ต่อ​เมื่อ​เรียก flush() หรือ​ตอน drop เท่านั้น server ที่​เขียน​คำ​ตอบ​ลง BufWriter แล้ว​วน​ไป​รอ​อ่าน​คำ​สั่ง​ถัด​ไป โดย​ไม่ flush จะ​ทำให้ client รอ​คำ​ตอบ​ที่​ไม่มี​วัน​มา — ค้าง​ทั้ง​คู่​ตลอด​กาล บรรทัด writer.flush()? ท้าย loop จึง​ขาด​ไม่​ได้
  • UnexpectedEof บน op byte = วาง​สาย​สะอาด — เมื่อ client ปิด​การ​เชื่อม​ต่อ read_exact บน op byte แรก​จะ​ได้ UnexpectedEof เรา match มัน​แล้ว return Ok(()) เงียบๆ (ไม่ log เป็น error) เพราะ​นี่​คือ​การ​จบ​ปกติ ไม่ใช่​ความ​ผิดพลาด

เพราะ​เรา เป็น​เจ้าของ​ทั้ง​สอง​ฝั่ง จึง​ไม่​ต้อง​มี schema library อะไร — client ใช้ opcode และ framing ชุด​เดียวกัน​แบบ​สมมาตร ทุก​คำขอ flush หลัง​ส่ง​เสร็จ เหมือน​ฝั่ง server:

use std::net::TcpStream;
struct Client { reader: std::io::BufReader<TcpStream>, writer: std::io::BufWriter<TcpStream> }
impl Client {
fn connect(addr: &str) -> io::Result<Client> {
let stream = TcpStream::connect(addr)?;
Ok(Client { reader: BufReader::new(stream.try_clone()?), writer: BufWriter::new(stream) })
}
fn get(&mut self, key: &[u8]) -> io::Result<Option<Vec<u8>>> {
self.writer.write_all(&[OP_GET])?;
write_frame(&mut self.writer, key)?;
self.writer.flush()?; // client ก็ต้อง flush เอง
let mut st = [0u8; 1]; self.reader.read_exact(&mut st)?;
let payload = read_frame(&mut self.reader)?;
match st[0] {
ST_OK => Ok(Some(payload)),
ST_NOT_FOUND => Ok(None),
_ => Err(io::Error::other("server error")), // io::Error::other เสถียรตั้งแต่ 1.74
}
}
// set()/delete() สะท้อน wire format แบบเดียวกัน และแต่ละตัว flush หลังส่งคำขอ
}

รัน​จริง​บน musl (server อยู่​บน thread เบื้องหลัง, client อยู่​บน main, คุย​กัน​ผ่าน loopback 127.0.0.1) ได้​ผล​ตาม​นี้:

SET lang=rust ; GET lang -> Some("rust")
GET nope (absent) -> None
DELETE lang -> true ; GET lang -> None ; DELETE lang again -> false
binary-safe value round-trips unchanged -> true
second client dropped -> server saw clean UnexpectedEof (no 'connection error' above)
all assertions passed

อ่านที​ละ​บรรทัด: SET แล้ว GET คืน byte เดิม​เป๊ะ; GET key ที่​ไม่มี → None; DELETE ที่​มี​อยู่ → true แล้ว GET ตาม​ได้ None, DELETE ซ้ำ → false; value ที่​มี byte พิสดาร (เว้น​วรรค, 0x0A, byte สูง 0xFF) รอด​กลับ​มา​ครบ — พิสูจน์​ว่า framing แบบ binary ปลอดภัย​จริง; และ client ตัว​ที่​สอง​ที่​ต่อ​แล้ว​วาง​สาย ทำให้ server เจอ UnexpectedEof แบบ​สะอาด​โดย​ไม่ log error

นี่​คือ​เหตุผล​ที่​เรา​ไม่​เลือก protocol แบบ​บรรทัด (text ที่​อ่าน​ด้วย nc ได้สวยๆ): มัน​ดู​เป็น​มิตร​ตอน​เดโม แต่​พัง​ทันที​เมื่อ value มี \n หรือ​ไม่ใช่ UTF-8 — ซึ่ง key-value store ต้อง รับ​ได้ length-prefixed byte framing แลก​ความ​สวยงาม​ตอน nc กับ​ความ​ถูกต้อง​แบบ binary-safe ที่​ผล​รัน​ข้าง​บน​ยืนยัน​แล้ว

sequenceDiagram
    participant C as Client
    participant S as Server (single-threaded)
    participant M as HashMap ในหน่วยความจำ
    C->>S: OP_SET | len | key | len | value   (byte บนสาย TCP)
    S->>M: store.insert(key, value)
    S-->>C: ST_OK | len=0
    C->>S: OP_GET | len | key
    S->>M: store.get(key)
    M-->>S: Some(value)
    S-->>C: ST_OK | len | value

คำ​บรรยาย​ภาพ: หนึ่ง​รอบ​คำ​สั่ง SET แล้ว GET บน wire protocol แบบ length-prefixed — client ส่ง opcode 1 byte นำ​หน้า record ที่​ใส่ความ​ยาว (len) กำกับ​ทุก​ก้อน, server ตอบ status byte (ST_OK) แล้ว​ตาม​ด้วย frame ของ value; ระหว่าง​นั้น store ที่แท้​จริง​คือ HashMap ใน​หน่วย​ความ​จำ (ยัง​ไม่​ลง disk จนบท2)

honesty spine ของ​คอร์ส​นี้​เริ่ม​ที่​นี่ — เรา​จะ​ไม่​ปิดบัง​ขีด​จำกัด​ของ​สิ่ง​ที่​เพิ่ง​สร้าง มี​สอง​เรื่อง​ที่​ต้อง​พูด​ออก​มาตรงๆ ตั้งแต่​บท​แรก:

ขีด​จำกัด​สอง​ข้อ​ของ​โครง​ใน​บท​นี้ — และ​เรา​ชี้​ล่วงหน้า​ว่า​จะ​แก้​ที่​บท​ไหน

1. server single-threaded นี้​ให้​บริการ client แบบ​เรียง​คิวfor stream in listener.incoming() รับ​ที​ละ​ราย ตราบ​ใด​ที่ client A ยัง​ไม่​วาง​สาย client B ต้อง​รอ client ที่​ช้า​เพียง​ราย​เดียว block ทุก​คน นี่​ไม่ใช่ bug แต่​เป็น​ข้อ​จำกัด​ของ model — เรา​จะ​จ่าย​คืน​ที่ บท5 ด้วย ThreadPool ที่ share store ผ่าน Arc<Mutex>/Arc<RwLock>

2. เรา​จงใจ​เลื่อน async ออก​ไป — ใน​หนังสือ The Rust Programming Language ปี 2024 server std::net แบบ blocking นี้​อยู่ บท 21 ส่วน async/await/futures อยู่​แยก​ที่ บท 17 หนังสือ​เอง​ก็​วาง server std ไว้​ฝั่ง blocking ของ​เส้น​แบ่ง เรา​เดิน​ตาม​เส้น​นั้น: concurrency ของ​คอร์ส​นี้​คือ std::thread ล้วนๆ ส่วน tokio/async เป็น​โลก runtime แยก​ที่​เลื่อน​ไป​คอร์ส #23 (Distributed Systems) — การ ตั้ง​ชื่อ​เส้น​แบ่ง นี้​ให้​ชัด​คือ​บทเรียน ไม่ใช่​ช่อง​โหว่

หลัก​คิด​ที่​จะ​ย้ำ​ตลอด​คอร์ส: “thread เป็น​ค่า​ปริยาย​ที่​ถูกต้อง​จนกว่า​จำนวน connection — ไม่ใช่ CPU — จะ​กลาย​เป็น​คอ​ขวด” async เกิด​มา​เพื่อ multiplex connection ที่​ส่วน​ใหญ่ idle นับ​หมื่น (ปัญหา C10k) บน thread ไม่​กี่​ตัว ตราบ​ใด​ที่​เรา​ยัง​ไม่​ชน​เพดาน​นั้น thread-per-connection คือ​คำ​ตอบ​ที่​ถูก​และ​ง่าย​กว่า

บท​นี้​วาง​โครง​บน​เครือข่าย​ของ kaen-kvstore: เรา​เข้าใจ​แล้ว​ว่า key-value store คือ​ฐาน​ข้อมูล​ที่​ง่าย​ที่สุด​ที่​ยัง​สอน​แกน​กลาง​ของ​ฐาน​ข้อมูล​ได้​ครบ; TCP เป็น byte stream ที่​ไม่มี​ขอบเขต message เรา​จึง​ออกแบบ wire protocol แบบ length-prefixed เอง อ่าน​ด้วย read_exact (ไม่ใช่ read ที่ short-read ได้) และ pin endianness ด้วย to_le_bytes/from_le_bytes; ต่อ server single-threaded ด้วย TcpListener::bind + incoming() โดย​ระวัง try_clone() แยก read/write และ flush() ทุก​ตา; ต่อ client ที่​เข้า​คู่​กัน​เพราะ​เรา​เป็น​เจ้าของ​ทั้ง​สอง​ฝั่ง; store บท​นี้​คือ HashMap ใน​หน่วย​ความ​จำ​ล้วนๆ; และ​พูด​ความ​จริง​สอง​ข้อ​ไว้​ล่วงหน้า — server นี้ serialize client (แก้​ที่​บท5) และ​เรา​จงใจ​เลื่อน async ไป #23 ทุก snippet compile และ​รัน​ได้​จริง​บน Rust 1.97.1 / edition 2024 / std ล้วน

บท2 เรา​ลง disk: HashMap ใน​บท​นี้​ตาย​พร้อม process — บท​หน้า​เรา​ทำให้​ทุก set/delete รอด การ​ปิด​โปรแกรม ด้วย​การ append ทุก​คำ​สั่ง​เป็น record ลง append-only log file เดียว แล้ว​เก็บ hash index ใน​หน่วย​ความ​จำ​ที่​ชี้​จาก key → offset ใน file (model Bitcask) — write_frame/read_frame ตัว​เดิม​จาก​บท​นี้​จะ​ย้าย​จาก socket ไป​เขียน​ลง File แทบ​ไม่​ต้อง​แก้​อะไร


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

เช็กความเข้าใจ — บทที่ 1

ข้อ 1 / 3

wire protocol ที่เราออกแบบในบทนี้คืออะไร?