อย่าเก็บเงินเป็น float: ทำไมต้องคิดเป็นสตางค์ (integer) ทั้งระบบ
เงินที่เก็บเป็นทศนิยม/float พังเงียบๆ จาก rounding และ binary floating point. เก็บเป็นหน่วยเล็กสุดเป็น integer (สตางค์) เสมอ แล้วค่อย format ตอน display — แล้วให้ ledger เป็น source of truth ที่ reconcile ได้เป๊ะ
อ่าน ~8 นาที
0.1 + 0.2 ≠ 0.3: ทำไม float พังกับเงิน และเกิดตรงไหนบ้าง
ผมเคยทำ wallet ให้ระบบ SaaS ไทยแห่งหนึ่ง ที่มีการเติมเงิน หักเงิน คืนเงิน และแบ่งบิลกันในกระเป๋าเดียว ตอนแรกทุกอย่างดูปกติ ยอดตรง เทสต์ผ่าน จนวันหนึ่งยอดรวมของ transaction ทั้งหมดกับ balance ที่แสดงเริ่มเพี้ยนไปทีละเศษสตางค์ ไม่ error ไม่ crash แค่ตัวเลขค่อยๆ ลอยห่างจากความจริง
ต้นตอคือเก็บเงินเป็น float. คอมพิวเตอร์เก็บทศนิยมเป็น binary floating point ซึ่งแทนค่าฐานสิบหลายค่าไม่ได้เป๊ะ ลองเปิด console แล้วพิมพ์:
> 0.1 + 0.2
0.30000000000000004 // ไม่ใช่ 0.3
> (0.1 + 0.2 === 0.3)
false // เงินคุณเพิ่งหายไปในอากาศ
ปัญหานี้ไม่โผล่ตอนบวกเลขกลมๆ ครั้งเดียว มันโผล่ตอนสะสม: คิดดอกเบี้ยทบ, หารบิลหลายคน, ลดราคาเป็นเปอร์เซ็นต์ (price * 0.93 ได้ค่าที่ปัดไม่ลงตัว), แปลงสกุลเงิน. ทุกครั้งที่ค่าที่แทนไม่ได้เป๊ะไหลผ่าน operation ต่อเนื่อง error เล็กๆ จะทบกันจนยอดรวมไม่ตรง. และเพราะมันไม่ทำให้โปรแกรม error คุณจะรู้ตัวก็ต่อเมื่อลูกค้าทักว่ายอดไม่ตรง — ซึ่งสายไปแล้ว
หลัก: เก็บเป็นหน่วยเล็กสุดเป็น integer (สตางค์)
Thesis ของบทความนี้คือ: เงินต้องเก็บเป็นจำนวนเต็มของหน่วยเล็กที่สุดเสมอ. เงินบาทหน่วยเล็กสุดคือสตางค์ ฉะนั้น 125.50 บาท เก็บเป็น 12550 (สตางค์) ไม่ใช่ 125.50. ในโลก cents ก็เก็บ 12550 แทน $125.50 เหมือนกัน
ทำไมได้ผล: integer บวกลบคูณได้เป๊ะเสมอ ไม่มี rounding error สะสม. 12550 + 2000 = 14550 ตรงเป๊ะทุกครั้ง ไม่มีวัน .00000004 โผล่. Business logic ทั้งหมด — บวก หัก เทียบยอด เช็คว่าเงินพอไหม — ทำบน integer ล้วนๆ
แล้วทศนิยมหายไปไหน? มันไม่ใช่เรื่องของ logic เลย มันเป็นเรื่องของการแสดงผล. หน่วยบาท/สตางค์เป็นแค่วิธี format ให้คนอ่าน เกิดที่ boundary ขาออกที่เดียว:
// เก็บและคำนวณเป็น integer เสมอ (หน่วย = สตางค์)
const balanceSatang = 12550;
// format เฉพาะตอนจะโชว์ให้คนดู
function formatTHB(satang) {
const baht = Math.floor(satang / 100);
const remainder = String(satang % 100).padStart(2, '0');
return `${baht.toLocaleString('th-TH')}.${remainder} บาท`;
}
formatTHB(12550); // "125.50 บาท"
หลักที่ transferable: float ห้ามเข้าใกล้เงินเลย ไม่ว่าจะเป็น column ใน DB, field ใน API, หรือตัวแปรกลางทาง. ถ้าเห็น Float / Double / Number ที่แทนเงิน ให้ถือว่านั่นคือบั๊กที่ยังไม่ระเบิด
จุดที่ต้อง round: กฎการปัดต้อง deterministic
ถึงเก็บเป็น integer แล้ว บางที่ก็หนีการปัดไม่พ้น — เช่นคิด VAT 7%, ดอกเบี้ย, หรือส่วนลด ที่ผลลัพธ์ออกมาเป็นเศษของสตางค์. จุดสำคัญคือปัดที่จุดเดียว ด้วยกฎเดียว ที่กำหนดไว้ชัด ไม่ใช่ปล่อยให้แต่ละที่ปัดตามใจ
มีสองกฎที่เจอบ่อย: half-up (0.5 ปัดขึ้นเสมอ — แบบที่คนทั่วไปเรียนมา) กับ banker's rounding / half-even (0.5 ปัดไปหาเลขคู่ที่ใกล้ที่สุด — งานบัญชี/การเงินชอบใช้เพราะ bias เป็นศูนย์เมื่อปัดหลายครั้ง). ไม่ว่าจะเลือกอันไหน สิ่งที่ห้ามคือ "บางที่ half-up บางที่ปัดลง" เพราะยอดจะไม่มีวันตรงกัน
// คิด VAT 7% บนราคา 10,000 สตางค์ (100.00 บาท) — ผลได้ 700 สตางค์พอดี
// แต่ถ้าราคา 333 สตางค์: 333 * 0.07 = 23.31 → ต้องปัด
function vat(amountSatang, ratePermille /* 70 = 7% */) {
// คูณก่อนหารเสมอ กันเศษหายกลางทาง
const raw = amountSatang * ratePermille; // 333 * 70 = 23310
// half-up ที่จุดเดียว: บวกครึ่งตัวหารก่อนหาร
return Math.floor((raw + 500) / 1000); // (23310+500)/1000 = 23
}
สังเกตว่าผมคูณก่อนหารทีหลัง เสมอ และปัดเป็น integer ครั้งเดียวตอนจบ. อย่าคำนวณเป็นทศนิยมกลางทางแล้วค่อยปัด เพราะจุดที่ float เข้ามาคือจุดที่ error เริ่มสะสม
หารเงินไม่ลงตัว: ใครได้เศษ 1 สตางค์
คำถามคลาสสิก: แบ่ง 100 สตางค์ให้ 3 คน ได้คนละ 33.33... สตางค์ ซึ่งเป็นไปไม่ได้เพราะสตางค์คือหน่วยเล็กสุด. ถ้าให้คนละ 33 รวมได้ 99 — หายไป 1 สตางค์. เศษนี้จะโยนทิ้งไม่ได้ เพราะผลรวมต้องเท่ากับก้อนเดิมเป๊ะ ไม่งั้น ledger จะไม่ balance
วิธีที่ถูกคือแจก quotient ให้ทุกคนเท่ากัน แล้วกระจายเศษ (remainder) ทีละสตางค์ให้คนแรกๆ จนหมด:
// แบ่ง total (สตางค์) ให้ n ส่วน โดยผลรวมต้องเท่า total เป๊ะ
function splitEvenly(totalSatang, n) {
const base = Math.floor(totalSatang / n); // ทุกคนได้เท่านี้เป็นอย่างน้อย
const remainder = totalSatang % n; // เศษที่ต้องแจกต่อ
return Array.from({ length: n }, (_, i) =>
// i คนแรก (เท่ากับจำนวนเศษ) ได้เพิ่มคนละ 1 สตางค์
base + (i < remainder ? 1 : 0)
);
}
splitEvenly(100, 3); // [34, 33, 33] → รวม = 100 เป๊ะ
หลักที่ transferable: เมื่อหารเงินไม่ลงตัว invariant คือผลรวมคงที่ ไม่ใช่ทุกคนได้เท่ากัน. ใครได้เศษเป็น policy (คนแรก, สุ่ม, หรือร้านค้ากิน) แต่ผลรวมห้ามเพี้ยน. เขียนเทสต์ยืนยันว่า sum(split) === total เสมอ
Ledger เป็น source of truth: balance คือผลรวม ไม่ใช่ field
บั๊กที่เจ็บที่สุดในระบบเงินคือเก็บ balance เป็น column แล้วเขียนทับทุกครั้งที่มี transaction. มันดูเร็วดี แต่ถ้ามีสอง request วิ่งพร้อมกัน (race), หรือ update สำเร็จแต่ transaction log ไม่ได้เขียน, ยอดจะเพี้ยนแบบกู้คืนไม่ได้ เพราะคุณทับความจริงเก่าทิ้งไปแล้ว
Pattern ที่ถูกคือ ledger เป็น source of truth: ทุกการเคลื่อนไหวของเงินคือ transaction record ที่ append เข้าไป ไม่เคยแก้ ไม่เคยลบ. balance ไม่ใช่สิ่งที่เก็บ แต่เป็นสิ่งที่คำนวณจากผลรวมของ transaction ทั้งหมด (ทุกตัวเป็น integer สตางค์):
// balance = ผลรวม integer ของ movement ทั้งหมด ไม่ใช่ field ที่เขียนทับ
function getBalance(transactions) {
return transactions.reduce((sum, tx) => sum + tx.amountSatang, 0);
// deposit = +, spend = -, refund = + ... ทุกตัว integer ล้วน
}
บทเรียนจากงานจริงที่สำคัญมาก: เวลาจะหา "balance ล่าสุด" จาก ledger ที่มี workflow แบบ pending→completed ต้องเรียงตามเวลาที่ commit จริง ไม่ใช่เวลาที่สร้าง record. ผมเคยเจอเคสที่ refund ถูกสร้าง (createdAt) ก่อน แต่ถูก approve (ยอดมีผลจริง) ทีหลัง พอ sort ด้วย createdAt เลยได้ balance ผิด. การ sort ต้องสะท้อนลำดับที่เงินเคลื่อนจริง ไม่ใช่ลำดับที่แถวถูกแทรก
Reconciliation: ตรวจ drift ทุกครั้ง เจอ = มีบั๊ก
ถ้าคุณเก็บ cached balance ไว้ (เพื่อ performance) ก็ยังทำได้ แต่ cached balance ต้องถือเป็นของที่พิสูจน์ได้ ไม่ใช่ของที่เชื่อ. วิธีพิสูจน์คือ reconcile: คำนวณผลรวมจาก ledger แล้วเทียบกับ cached balance เป็นรอบๆ. เพราะทุกอย่างเป็น integer การเทียบจึงต้องตรงเป๊ะ — ไม่มี "ใกล้เคียงพอ" แบบ float
// reconcile: ผลรวม ledger ต้องเท่า cached balance เป๊ะ
function assertReconciled(walletId, cachedBalance, transactions) {
const computed = getBalance(transactions);
if (computed !== cachedBalance) {
// drift แม้แต่ 1 สตางค์ = มีบั๊ก (race, missing tx, ปัดผิด)
throw new Error(
`Balance drift on ${walletId}: cached=${cachedBalance} ledger=${computed}`
);
}
}
นี่คือข้อดีที่ integer มอบให้ฟรีๆ: drift ตรวจจับได้ทันที. ถ้าเก็บเป็น float คุณจะเจอ computed = 12549.9999998 vs cached = 12550 แล้วต้องมานั่งเถียงว่าต่าง 0.0000002 นี่บั๊กหรือ epsilon. พอเป็น integer คำตอบชัด: ต่าง = บั๊ก จบ
Boundary discipline: parse ขาเข้า, format ขาออก ที่เดียว
เส้นเลือดที่ทำให้ทั้งระบบสะอาดคือวินัยที่ขอบ (boundary). โลกภายนอก — ฟอร์ม, API request, ไฟล์ import — พูดเป็นหน่วยบาททศนิยม. โลกภายในพูดเป็นสตางค์ integer. การแปลงเกิดที่ขอบเท่านั้น ไม่ปนเข้าไปใน logic:
// ขาเข้า: parse "125.50" (บาท) → 12550 (สตางค์) ที่ชั้น boundary
function parseBaht(input) {
const n = Number(String(input).trim());
if (!Number.isFinite(n) || n < 0) throw new Error('invalid amount');
// ปัดเป็นสตางค์ทันที ไม่ปล่อย float ทะลุเข้าไป
return Math.round(n * 100);
}
parseBaht("125.50"); // 12550
// จากจุดนี้เข้าไป ทั้งระบบเห็นแต่ integer
กฎง่ายๆ: ขาเข้า parse เป็น integer สตางค์ทันทีที่รับ ขาออก format กลับเป็นบาทตอนจะแสดงเท่านั้น ตรงกลางทั้งหมดเป็น integer ล้วน. ถ้า business logic ตรงไหนต้องแตะทศนิยม แปลว่า boundary รั่ว ให้ดันการแปลงกลับไปที่ขอบ. ระบบเงินที่ดีคือระบบที่ float ปรากฏได้แค่ในสองไฟล์: ตัว parser ขาเข้า กับ formatter ขาออก
สรุป
- เงิน = integer ของหน่วยเล็กสุดเสมอ (สตางค์/cents) ห้าม float แตะเงินใน DB, API, หรือตัวแปรกลางทาง — float พังเงียบๆ จาก rounding สะสม
- ทศนิยมเป็นเรื่อง display เท่านั้น — business logic (บวก หัก เทียบ) ทำบน integer ล้วน แปลงหน่วยที่ boundary ไม่ใช่ใน logic
- ปัดที่จุดเดียว ด้วยกฎเดียว — เลือก half-up หรือ banker's rounding แล้วใช้ให้เหมือนกันทั้งระบบ, คูณก่อนหารทีหลังเสมอ
- หารไม่ลงตัว กระจายเศษทีละสตางค์ — invariant คือผลรวมคงที่ ไม่ใช่ทุกคนได้เท่ากัน
- Ledger เป็น source of truth — balance = ผลรวม integer ของ transaction ที่ append-only ไม่ใช่ field ที่เขียนทับ, และ sort ตามเวลา commit จริงไม่ใช่เวลาสร้าง record
- Reconcile ทุกรอบ — เทียบผลรวม ledger กับ cached balance ให้ตรงเป๊ะ, drift แม้ 1 สตางค์ = มีบั๊ก ต้องหา ไม่ใช่กลบ




