หนึ่งคนหนึ่งเสียง: integrity, concurrency และ audit trail ในระบบที่โกงไม่ได้
ระบบโหวต/นับคะแนน มี correctness เป็นข้อบังคับ ไม่ใช่ nice-to-have — นับซ้ำครั้งเดียว หรือแก้ผลย้อนหลังได้เงียบ ๆ แค่ครั้งเดียว ระบบก็ตาย บทความนี้ว่าด้วยวิธีบังคับ one-person-one-vote ด้วย unique constraint + atomic mutation, ทำ audit trail แบบ append-only ที่ตรวจย้อนได้ และทำ tally ให้ reproducible จนผู้ตรวจ recompute ได้เองโดยไม่ต้องเชื่อ server
อ่าน ~9 นาที
correctness เป็นข้อบังคับ: นับซ้ำ/แก้ย้อนหลัง = ระบบตาย
ผมเคยทำระบบที่ตัวเลข "ประมาณถูก" ก็พอ เช่น dashboard analytics พลาดไปสองสามรายการไม่มีใครตาย แต่พอมาแตะระบบประเภท โหวต / นับคะแนน / ลงมติ กติกามันเปลี่ยนไปคนละโลก เพราะ correctness ที่นี่ไม่ใช่ feature ที่ทำได้ก็ดี มันคือ ข้อบังคับ ที่ถ้าพังแค่ครั้งเดียว ความเชื่อถือของทั้งระบบก็จบ
invariant ที่ห้ามละเมิดมีอยู่สองข้อหลัก: (1) หนึ่ง identity ออกเสียงได้ไม่เกินหนึ่งครั้ง — นับซ้ำไม่ได้ และ (2) เสียงที่ลงไปแล้วห้ามถูกแก้ย้อนหลังแบบเงียบ ๆ — ผลต้องตรวจย้อนได้เสมอ. สังเกตว่าทั้งสองข้อไม่ได้พูดถึง UI สวย หรือ latency ต่ำ มันพูดถึง "ความจริงที่พิสูจน์ได้" ล้วน ๆ. บทความนี้ผมจะไล่ทีละชั้นว่าจะบังคับสองข้อนี้ด้วยกลไกจริงยังไง ไม่ใช่แค่หวังว่า code จะทำถูก
One-person-one-vote: unique constraint ที่ระดับ DB ไม่ใช่แค่ app check
กับดักแรกที่เกือบทุกคนตกคือคิดว่า "เช็คใน application ก่อน insert ก็พอ" — ดึงมาดูว่า user นี้โหวตยัง ถ้ายังก็ insert. ปัญหาคือ app check เป็นแค่ คำแนะนำ ไม่ใช่ การบังคับ. bug ที่ไหนสักที่, endpoint ที่ลืม guard, หรือ request สองอันที่วิ่งพร้อมกัน ก็ทะลุได้หมด. ผู้ตัดสินสุดท้ายว่า "ห้ามซ้ำ" ต้องเป็น database ไม่ใช่ code path ใด code path หนึ่งที่ต้องคอยจำให้เรียกทุกครั้ง
วิธีที่ทนทานคือประกาศ unique constraint บนคู่ที่ห้ามซ้ำจริง ๆ คือ (election_id, voter_id) แล้วปล่อยให้ engine เป็นคนปฏิเสธการ insert ที่สองเอง:
-- ผู้ตัดสินสุดท้ายอยู่ที่ schema ไม่ใช่ app logic
CREATE UNIQUE INDEX one_vote_per_voter
ON votes (election_id, voter_id);
-- ถ้ามี insert ที่สองของ voter เดิมใน election เดิม
-- DB จะโยน unique violation ทันที ไม่ว่าจะมาจาก path ไหน
ข้อดีคือมันบังคับ ทุกทาง เข้าถึง table นี้ ไม่ว่าจะเป็น API หลัก, script migration, งาน import, หรือ dev ที่ยิง query ตรง ๆ. unique index ไม่สนว่าใครเรียก มันแค่ปฏิเสธ. นี่คือความต่างระหว่าง "policy ที่เขียนไว้ในเอกสาร" กับ "policy ที่ enforce ได้จริงในทุกกรณี"
Race: check-then-insert พังใต้ concurrency → atomic upsert/unique index
สมมติคุณมี unique index แล้ว แต่ยังเขียน code แบบ "SELECT ดูก่อนว่ามีไหม ถ้าไม่มีค่อย INSERT" — อันนี้ยังมี race อยู่ดี เพราะระหว่าง SELECT กับ INSERT มีช่องว่างให้ request อีกอันแทรกเข้ามาได้. สอง request ของ voter คนเดียวกดพร้อมกัน (double-click, retry, สองแท็บ) จะ SELECT เห็น "ยังไม่มี" ทั้งคู่ แล้วต่างคนต่าง INSERT
ข่าวดีคือ ถ้ามี unique index อยู่ อันหนึ่งจะ insert สำเร็จ อีกอันจะโดน unique violation — ระบบไม่พัง. แต่ประเด็นคือ อย่าไปพึ่ง SELECT-ก่อน ให้ยิง INSERT ตรง ๆ แล้วจัดการ conflict เป็นคำตอบว่า "โหวตซ้ำ" แทน. นี่คือแนวคิดเดียวกับการดัน check+mutate ให้เป็นคำสั่งเดียวที่ผมย้ำเสมอในเรื่อง race condition — ต่างกันแค่ที่นี่ "ความซ้ำ" คือสิ่งที่ห้าม ไม่ใช่ "ยอดติดลบ":
-- ✅ ให้ DB serialize ให้: insert ตรง ๆ แล้วอ่านผล conflict
INSERT INTO votes (election_id, voter_id, choice_id)
VALUES ($1, $2, $3)
ON CONFLICT (election_id, voter_id) DO NOTHING
RETURNING id;
-- ถ้า RETURNING ไม่คืนแถว = ชนกับเสียงเดิม = "คุณโหวตไปแล้ว"
-- ไม่มี SELECT-then-INSERT ให้เกิด race, DB เป็นคนตัดสิน
ถ้าไม่คืน row แปลว่ามีเสียงของ voter นี้อยู่แล้ว เราตอบ "โหวตซ้ำ" กลับไปได้อย่างมั่นใจ ที่สำคัญคือ เราไม่ได้เขียน locking logic เองเลย เราได้ correctness มาจาก engine ที่ serialize การชนบน unique key ให้. หลักคิด transferable: ทุกครั้งที่คุณเจอ "เช็คก่อนว่ามีไหม แล้วค่อยทำ" บน resource ที่แข่งกัน ให้สงสัยไว้ก่อนว่ามัน race — แล้วย้ายการตัดสินไปที่ constraint ระดับ DB
Append-only audit trail: ทุก event เขียนเพิ่ม ห้าม update/delete
invariant ข้อสองคือ "ห้ามแก้ย้อนหลังแบบเงียบ ๆ" ซึ่งบังคับได้ด้วยการออกแบบ store ให้เป็น append-only — ทุก action คือ event ใหม่ที่ถูกเขียนต่อท้าย ไม่มีการ UPDATE ทับ ไม่มี DELETE ลบทิ้ง. ถ้าต้อง "ยกเลิก" เสียง ก็ append event ยกเลิกเข้าไปอีกแถว ไม่ใช่ไปลบแถวเดิม. ประวัติทั้งหมดจึงยังอยู่ครบ ตรวจย้อนได้ว่าเกิดอะไรขึ้นตามลำดับเวลา
ผมเคยเจอบทเรียนนี้ตอนทำระบบเงินที่ใช้ ledger เป็น source of truth: พอมี append-only log ที่ทุกการเปลี่ยนแปลงถูกจดเป็นรายการใหม่ การ debug ว่า "ยอดมันเพี้ยนตอนไหน ใครทำ" กลายเป็นเรื่องง่าย เพราะไม่มีข้อมูลไหนหายไป. สิ่งที่ทำให้ append-only แข็งแรงกว่าตารางที่แก้ทับได้คือ ผูก event เข้าเป็นสายด้วยแฮชของ event ก่อนหน้า ทำให้แก้ตรงกลางย้อนหลังโดยไม่ทิ้งร่องรอยแทบเป็นไปไม่ได้:
// แต่ละ event ผูกกับ event ก่อนหน้าด้วย hash → แก้ย้อนหลังจะทำให้ chain พัง
function appendEvent(prevHash, payload) {
const record = {
seq: nextSeq(), // ลำดับเรียงเวลา ห้ามซ้ำ
ts: Date.now(),
payload, // เช่น { type: 'VOTE_CAST', voterHash, choiceId }
prevHash, // แฮชของ event ก่อนหน้า
};
record.hash = sha256(record.prevHash + JSON.stringify(record.payload) + record.seq);
return db.events.insertOne(record); // insert เท่านั้น ไม่มี update/delete
}
ผลคือถ้าใครแอบแก้ payload ของ event กลางสาย แฮชของมันจะเปลี่ยน และ prevHash ของ event ถัดไปทั้งหมดจะไม่ตรง — chain พังทันทีตอนตรวจ. audit trail จึงไม่ได้แค่ "จดไว้" แต่ "จดไว้แบบที่แก้ย้อนหลังแล้วจับได้"
Reproducible tally: นับใหม่จาก log ต้องได้เลขเดิมเสมอ
กับดักคลาสสิกคือเก็บ "ผลรวม" ไว้เป็น counter ที่ +1 ทุกครั้งที่มีคนโหวต แล้วถือว่า counter นั้นคือผล. ปัญหาคือ counter เป็น derived state ที่เชื่อไม่ได้ ถ้ามัน drift ไปจากความจริง (จาก bug, จาก race, จากการแก้มือ) คุณจะไม่มีทางรู้เลย เพราะไม่มีอะไรให้เทียบ
หลักที่ถูกคือ ผลต้องคำนวณได้ (derive) จาก event log เสมอ ไม่ใช่เก็บเป็นตัวเลขลอย ๆ. counter มีได้ในฐานะ cache เพื่อความเร็ว แต่ source of truth คือ log และคุณต้องนับใหม่จาก log แล้วได้เลขเดิมทุกครั้ง — นี่คือ property ที่เรียกว่า reproducible tally:
// ผลคือฟังก์ชันบริสุทธิ์ของ event log — input เดิม → output เดิมเสมอ
function tally(events) {
const counted = new Map(); // choiceId -> จำนวน
const seenVoters = new Set(); // กันนับซ้ำระดับ tally อีกชั้น
for (const e of events.sort((a, b) => a.seq - b.seq)) {
if (e.payload.type !== 'VOTE_CAST') continue;
if (seenVoters.has(e.payload.voterHash)) continue; // 1 voter นับครั้งเดียว
seenVoters.add(e.payload.voterHash);
const c = e.payload.choiceId;
counted.set(c, (counted.get(c) ?? 0) + 1);
}
return counted;
}
// เอา counter ที่เก็บไว้มาเทียบกับ tally(events) — ต้องตรงเป๊ะ
// ไม่ตรงเมื่อไหร่ = มีอะไรผิด และเรารู้ทันที
พอ tally เป็นฟังก์ชันบริสุทธิ์ของ log แล้ว การตรวจสอบก็เป็นแค่ "เอา counter ที่ระบบโชว์ มาเทียบกับผลที่นับใหม่จาก log" ถ้าไม่ตรงคือมีปัญหา และเรารู้ทันทีแทนที่จะเชื่อตัวเลขไปเรื่อย ๆ
แยก identity จาก ballot: กันโยง 'ใครโหวตอะไร' แต่ยังกันโหวตซ้ำ
ความตึงที่น่าสนใจคือ เราต้อง "รู้ว่าใครโหวตแล้ว" (เพื่อกันซ้ำ) แต่ในหลายระบบก็ "ต้องไม่รู้ว่าใครเลือกอะไร" (ความลับของบัตร). สองอย่างนี้ดูขัดกัน แต่แยกได้ด้วยการเก็บเป็น สอง store ที่ไม่ผูกกัน:
- Roll (บัญชีผู้มีสิทธิ์) — เก็บแค่ว่า voter คนไหน "ใช้สิทธิ์ไปแล้ว" (flag) โดยไม่มีข้อมูลว่าเลือกอะไร. unique constraint กันซ้ำอยู่ที่นี่
- Ballot box (กล่องบัตร) — เก็บแค่ choice ที่ถูกเลือก โดยไม่มี field ที่ชี้กลับไปหา voter จริง ๆ
กุญแจคือ อย่าเก็บ foreign key ตรง ๆ ระหว่างสอง store. ในกล่องบัตรให้ใช้ค่าที่ derive มาแบบ one-way (เช่น hash ของ voter+salt เฉพาะ election) เป็น voterHash — มันยังทำหน้าที่ "กันนับซ้ำใน tally" ได้ (เพราะ voter เดิมได้ hash เดิม) แต่ย้อนกลับจาก hash ไปหา voter จริงทำไม่ได้ในทางปฏิบัติ. ผลคือได้ทั้งสองอย่าง: กันโหวตซ้ำ และไม่โยง "ใครเลือกอะไร" เข้าด้วยกัน
Verifiability: ให้ผู้ตรวจ recompute ได้โดยไม่ต้องเชื่อ server
ชั้นสุดท้ายที่แยกระบบ "โกงไม่ได้" ออกจากระบบ "เชื่อว่าไม่โกง" คือ verifiability — ผู้ตรวจภายนอกต้องพิสูจน์ผลได้เองโดยไม่ต้องเชื่อคำพูดของ server. ถ้าวิธีเดียวที่จะรู้ว่าผลถูกคือ "server บอกว่าถูก" แปลว่าคุณยังไม่มีความน่าเชื่อถือจริง มีแค่ความไว้ใจ
ของสามอย่างที่ทำให้ verifiability เกิดขึ้นจริง และผมมองว่าเป็น checklist ได้เลย:
- เปิด event log (ที่ sanitize แล้ว) ให้ตรวจได้ — พร้อม hash chain ให้ผู้ตรวจ replay แล้วยืนยันว่าไม่มีใครแก้กลางสาย
- tally เป็น deterministic function สาธารณะ — ใครเอา log ไปรันเองก็ต้องได้เลขเดียวกับที่ระบบประกาศ
- ให้แต่ละ voter เช็คได้ว่าเสียงตัวเองถูกนับ — เช่นคืน receipt (tracking code) ตอนโหวต แล้วเปิดให้ค้นว่า code นั้นอยู่ในกล่องบัตรที่เอาไปนับจริง
จุดร่วมของทั้งสามคือ ย้าย "ความจริง" ออกจากตัว server ไปไว้ในสิ่งที่ตรวจซ้ำได้ด้วยตัวเอง. server กลายเป็นแค่ผู้เก็บและเผยแพร่ log ไม่ใช่ผู้ที่เราต้องเชื่อคำพูด. นี่คือความต่างเชิงหลักการระหว่างระบบที่ integrity แข็งแรง กับระบบที่แค่ "หวังว่าคนคุม DB จะไม่ทำอะไรแปลก ๆ"
สรุป
- ระบบโหวต/นับคะแนน มี correctness เป็น ข้อบังคับ — invariant สองข้อคือ นับซ้ำไม่ได้ และ แก้ย้อนหลังเงียบ ๆ ไม่ได้
- One-person-one-vote บังคับด้วย unique constraint ระดับ DB บน
(election_id, voter_id)ไม่ใช่ app check ที่ต้องคอยจำเรียก - อย่าเขียน SELECT-then-INSERT (race) ให้ INSERT ตรง ๆ + ON CONFLICT แล้วอ่านผลชนเป็นคำตอบว่า "โหวตซ้ำ" — DB serialize การชนให้เอง
- Audit trail เป็น append-only: ทุก action คือ event ใหม่ ไม่มี update/delete และ ผูกด้วย hash chain เพื่อจับการแก้ย้อนหลัง
- ผลต้อง reproducible: derive จาก log เสมอ, counter เป็นได้แค่ cache, นับใหม่แล้วต้องได้เลขเดิมทุกครั้ง
- แยก roll (กันซ้ำ) ออกจาก ballot box (ความลับบัตร) ด้วย one-way hash ไม่เก็บ FK ตรง ๆ
- เป้าหมายสูงสุดคือ verifiability — เปิด log + tally function + receipt ให้ผู้ตรวจ recompute เองได้ โดยไม่ต้องเชื่อ server




