บทความ › การเขียนโปรแกรม

เงินห้ามใช้ float ทำไม 19.99 × 100 ถึงไม่ได้ 1999 และวิธีเก็บเงินให้เป๊ะทุกขั้น

float เก็บ 0.1 ได้ไม่เป๊ะ เงินจึงเพี้ยนทีละสตางค์และสะสม เล่าวิธีให้ตัวเลขเงินเป๊ะตั้งแต่ต้นทางจนถึงฐานข้อมูล จากระบบบันทึกผลเทรดที่ใช้จริง พร้อมโค้ด Go SQL และ Python

กลาง อ่าน 7 นาที 16 ตุลาคม 2569

อ่านจบแล้วจะรู้
  • float เป็นเลขฐานสอง เก็บ 0.1 หรือ 19.99 แบบเป๊ะไม่ได้ ผลบวกจึงคลาดทีละนิดและสะสม
  • เงินต้องเป็นทศนิยมแบบเป๊ะทุกขั้น ตั้งแต่ JSON จนถึงคอลัมน์ในฐานข้อมูล ห้ามแวะผ่าน float แม้แต่ขั้นเดียว
  • ปัดเศษครั้งเดียว ที่จุดเดียว ส่วนอัตราส่วนอย่าง win rate ใช้ float ได้

ลองเปิด console ของเบราว์เซอร์แล้วพิมพ์ 0.1 + 0.2 คำตอบคือ 0.30000000000000004 ไม่ใช่ 0.3 ดูเป็นเรื่องตลกของโปรแกรมเมอร์ แต่ในระบบที่นับเงิน มันคือบั๊กที่ทำให้ยอดคลาดทีละสตางค์ แล้วค่อย ๆ สะสมจนมีคนถามว่าเงินหายไปไหน บทความนี้อธิบายว่าเกิดจากอะไร และเล่าวิธีที่ระบบบันทึกผลเทรดของเราใช้ ให้ตัวเลขเงินเป๊ะตั้งแต่ต้นทางจนถึงฐานข้อมูล

ตัวอย่างทั้งหมดทดสอบกับ Go 1.23, PostgreSQL 16, Python 3.12 และ Node.js 22

ทำไม float เก็บ 0.1 ไม่ได้

คอมพิวเตอร์เก็บ float เป็นเลขฐานสอง เศษส่วนบางตัวที่ดูง่ายในฐานสิบ กลับเขียนให้จบในฐานสองไม่ได้ เหมือนที่ 1/3 เขียนเป็นทศนิยมฐานสิบไม่จบ (0.3333…) ตัวเลข 0.1 ในฐานสองก็ซ้ำไม่รู้จบ เครื่องจึงต้องตัดเก็บแค่ค่าที่ใกล้ที่สุด

ลองดูว่า 1234.56 ถูกเก็บจริง ๆ เป็นค่าอะไร

from decimal import Decimal
print(Decimal(1234.56))
# 1234.55999999999994543031789362430572509765625

ปกติเราไม่เห็นความต่างนี้ เพราะตอนพิมพ์ออกมา ภาษาโปรแกรมจะปัดให้ดูสวย แต่พอเอาไปบวกลบคูณหารหลายครั้ง ความคลาดเล็ก ๆ จะโผล่

x = 0.0
for _ in range(10):
    x += 0.1
print(x)  # 0.9999999999999999

float ไม่ได้ “คำนวณผิด” มันคำนวณถูกกับตัวเลขที่ไม่ใช่ค่าที่เราตั้งใจตั้งแต่แรก

บั๊กจริงที่เจอบ่อย: แปลงเป็นสตางค์

หลายระบบรู้ว่า float มีปัญหา จึงแปลงเป็นสตางค์ก่อนเก็บ แต่แปลงด้วยการคูณ 100 จาก float

19.99 * 100              // 1998.9999999999998
Math.trunc(19.99 * 100)  // 1998  ← หายไป 1 สตางค์
Math.round(19.99 * 100)  // 1999

ถ้าโค้ดใช้การตัดทศนิยม (trunc หรือแปลงเป็น int ตรง ๆ ซึ่งหลายภาษาทำแบบนี้โดยปริยาย) สินค้า 19.99 บาทจะถูกบันทึกเป็น 19.98 บาท ขายวันละพันชิ้นก็หายวันละ 10 บาท ไม่มี error ไม่มีอะไรแจ้งเตือน

เงินรั่วเข้า float ตรงไหนบ้าง

เราอาจไม่ได้ประกาศตัวแปรเป็น float เลย แต่เงินก็ยังแวะผ่าน float ได้จากจุดเหล่านี้

จุดเกิดอะไร
แปลง JSONJSON.parse ใน JavaScript และ encoding/json ของ Go (ถ้าแปลงลง interface{}) ให้ตัวเลขเป็น float โดยปริยาย
คอลัมน์ฐานข้อมูลREAL, DOUBLE PRECISION, FLOAT เก็บแบบ float ตั้งแต่ต้น
ไลบรารีกลางORM หรือตัวแปลงบางตัวแปลงทศนิยมเป็น float ให้เอง
สเปรดชีตExcel คำนวณด้วย float ทั้งหมด
ต้นทางโปรแกรมบางตัว เช่น MT5 ใช้ double เก็บราคามาตั้งแต่แรก

ข้อสุดท้ายเลี่ยงไม่ได้ สิ่งที่ทำได้คือแปลงเป็นข้อความด้วยจำนวนทศนิยมที่ถูกต้อง ตั้งแต่ต้นทาง ครั้งเดียว แล้วจากนั้นห้ามกลับไปเป็น float อีก

หลักการ: ทศนิยมแบบเป๊ะทุกขั้น

เงินต้องเดินทางเป็นทศนิยมแบบเป๊ะตลอดทาง ถ้ามีขั้นไหนแวะผ่าน float แม้แต่ขั้นเดียว ความเป๊ะหายทั้งเส้น

ระบบบันทึกผลเทรดของเรารับยอดเงินจากโปรแกรมเทรดทุกไม่กี่วินาที เส้นทางของตัวเลขเงินเป็นแบบนี้

  1. โปรแกรมเทรด ส่ง JSON ที่มียอดเงินเป็นตัวเลข เช่น "balance": 1234.56
  2. Go รับ JSON แต่ไม่แปลงเป็น float เก็บเป็น json.Number ซึ่งข้างในคือข้อความ "1234.56"
  3. PostgreSQL เก็บในคอลัมน์ NUMERIC(20,5) ทศนิยมแบบเป๊ะ 5 ตำแหน่ง (ราคา forex มีทศนิยม 5 ตำแหน่ง)
  4. ตอนคำนวณสถิติ ให้ฐานข้อมูลรวมยอดด้วย NUMERIC แล้วแปลงผลเป็นข้อความด้วย ::text
  5. API ส่งกลับ เป็นข้อความ เช่น "net_profit": "1234.56000"
โปรแกรมเทรด1234.56JSON"balance": 1234.56Go: json.Number"1234.56" (ข้อความ)PostgreSQL NUMERIC(20,5)1234.56000API ส่งกลับ"1234.56000"ถ้าแปลงเป็น float641234.5599999…เพี้ยนตั้งแต่ตรงนี้
ยอดเงินเดินทางเป็นข้อความหรือทศนิยมแบบเป๊ะทุกขั้น แวะ float ขั้นเดียว ความเป๊ะหายทั้งเส้น

โค้ดแต่ละขั้น

Go: รับ JSON โดยไม่ผ่าน float ประกาศฟิลด์เงินเป็น json.Number

type Snapshot struct {
    Balance json.Number `json:"balance"`
    Equity  json.Number `json:"equity"`
}

ถ้าต้องอ่าน JSON แบบไม่รู้โครงสร้าง ให้สั่ง decoder ไม่แปลงเป็น float

dec := json.NewDecoder(r.Body)
dec.UseNumber() // ตัวเลขทุกตัวเป็น json.Number ไม่ใช่ float64

SQL: เก็บเป็น NUMERIC และส่งออกเป็นข้อความ

CREATE TABLE orders (
    profit     NUMERIC(20,5) NOT NULL,
    commission NUMERIC(20,5) NOT NULL DEFAULT 0,
    swap       NUMERIC(20,5) NOT NULL DEFAULT 0
);

SELECT coalesce(sum(profit + commission + swap), 0)::text AS net_profit
FROM orders WHERE account_id = $1;

การ cast เป็น ::text ทำให้ไดรเวอร์ฐานข้อมูลส่งค่ามาเป็นข้อความ ไม่มีโอกาสแปลงเป็น float ระหว่างทาง

Python: ใช้ Decimal และสร้างจากข้อความเสมอ

from decimal import Decimal
Decimal("19.99") * 100   # Decimal('1999.00')  ถูก
Decimal(19.99) * 100     # Decimal('1998.999999999999843680598133')  ผิด เพราะสร้างจาก float

จุดที่คนพลาดบ่อยคือสร้าง Decimal จาก float ซึ่งได้ความคลาดของ float ติดมาด้วย ต้องสร้างจากข้อความเท่านั้น

ปัดเศษ: ครั้งเดียว ที่จุดเดียว

ทศนิยมแบบเป๊ะไม่ได้แปลว่าไม่ต้องปัด เช่น ค่าเฉลี่ยกำไรต่อไม้อาจได้ทศนิยมไม่รู้จบ กติกาคือ

  • ปัดตอนท้าย ครั้งเดียว อย่าปัดทุกขั้นระหว่างทาง เพราะความคลาดจากการปัดจะสะสมเหมือน float
  • ปัดที่จุดเดียว ในระบบของเรา การหารเฉลี่ยทำในฐานข้อมูลแล้ว cast เป็น NUMERIC(20,5) ก่อนเป็นข้อความ ไม่มีโค้ดส่วนอื่นปัดซ้ำ
  • ตกลงวิธีปัดให้ชัด ปัดครึ่งขึ้น หรือปัดแบบธนาคาร (ครึ่งไปหาเลขคู่) ระบบบัญชีบางแห่งกำหนดไว้ ต้องใช้ให้ตรงกัน

แล้วอะไรใช้ float ได้

ไม่ใช่ทุกตัวเลขต้องเป๊ะ ในระบบเดียวกันนี้ อัตราส่วน อย่าง win rate, profit factor และ drawdown เป็น float เพราะความคลาดหลักทศนิยมที่ 15 ไม่มีความหมายกับคนอ่าน และไม่มีใครเอาตัวเลขพวกนี้ไปจ่ายเงิน

เส้นแบ่งง่าย ๆ คือ ถ้าตัวเลขนี้ต้องตรงกับเงินในบัญชีจริง ห้าม float ถ้าเป็นตัวชี้วัดที่ใช้ดูแนวโน้ม float ได้

ข้อผิดพลาดที่เจอบ่อย

  • แปลงเป็นสตางค์ด้วยการคูณ float แล้วตัดทศนิยม (บั๊ก 19.99 ข้างบน)
  • เก็บเงินในคอลัมน์ FLOAT เพราะ ORM ตั้งให้อัตโนมัติ
  • เทียบเงินด้วย == ระหว่างค่าที่มาจาก float เช่น 0.1 + 0.2 == 0.3 ได้ false
  • หน้าเว็บรับเงินเป็น string แล้วแปลงเป็น Number ก่อนแสดงผล ทั้งที่แสดง string ไปตรง ๆ ได้
  • ทดสอบด้วยตัวเลขกลม ๆ อย่าง 100 หรือ 50 ซึ่งไม่โผล่ปัญหา ควรมีเทสต์ด้วยค่าอย่าง 19.99, 0.1 และ 1234.56 เสมอ

คำถามที่พบบ่อย

เก็บเป็นสตางค์ (จำนวนเต็ม) แทนได้ไหม

ได้และเป็นวิธีที่ดีสำหรับเงินสกุลเดียวที่ทศนิยมตายตัว แต่ถ้าต้องเก็บราคาที่มีทศนิยม 5 ตำแหน่ง หรือหลายสกุลเงินที่ทศนิยมไม่เท่ากัน ใช้ชนิดทศนิยมของฐานข้อมูลอย่าง NUMERIC จะตรงไปตรงมากว่า

JavaScript ฝั่งหน้าเว็บต้องทำยังไง

รับเงินจาก API เป็น string แล้วแสดงผลเป็น string ไปเลย ถ้าต้องคำนวณบนหน้าเว็บจริง ๆ ให้ใช้ library ทศนิยม อย่าแปลงเป็น Number แล้วคูณหาร

คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ

อ่านต่อ

#โค้ดกลาง

Go ใน Docker: GOMAXPROCS ตาม CPU limit (Go 1.25) และ Green Tea GC (Go 1.26)

Go 1.25 ตั้ง GOMAXPROCS ตาม CPU limit ของ container เอง Go 1.26 เปิด Green Tea GC เป็นค่าเริ่มต้น สิ่งที่ต้องเช็กใน Dockerfile และ go.mod พร้อมผลวัดจริง

อ่าน 9 นาที
#โค้ดกลาง

Background job และ queue: ย้ายงานช้าออกจาก request พร้อม retry ที่ไม่ส่งซ้ำ

ส่งอีเมล ย่อรูป เรียก AI ไม่ควรทำใน request ทำ job queue ด้วย Postgres SKIP LOCKED พร้อม retry แบบ backoff, visibility timeout, dead-letter และทำให้ job รันซ้ำได้ปลอดภัย

อ่าน 15 นาที
#โค้ดเริ่มต้น

เก็บวันเวลาให้ถูก: เก็บเป็น UTC เก็บ timezone ผู้ใช้ แล้วค่อยแปลงตอนแสดง

เวลาเพี้ยน 7 ชั่วโมง วันเกิดเลื่อน นัดหมายเคลื่อนตอน DST แก้ด้วยกฎสามข้อ ใช้ timestamptz ใน Postgres, zoneinfo ใน Python, Temporal ใน JS และแปลงเป็น พ.ศ. ตอนแสดงเท่านั้น

อ่าน 13 นาที