บัญชีเพิ่มอย่างเดียวที่ทำให้ยอดหารบิลแม่นยำ
ยอดคงเหลือควรเป็นค่าที่คำนวณใหม่ได้และทิ้งแล้วสร้างใหม่ได้อย่างปลอดภัย นี่คือกลไกที่ทำให้ปลอดภัย ทั้งบันทึกผลรวมศูนย์ การแก้ไขที่ลงรายการแก้ และคิวส่งงานที่ส่งตามลำดับคอมมิต
ยอดคงเหลือที่คุณค่อยบวกเพิ่มคือยอดที่จะคลาดเคลื่อน Dimesum ไม่เคยบวกเพิ่มยอดใด ทุกค่าใช้จ่ายจะลงบันทึกแบบ double-entry ที่ผลรวมของรายการเป็นศูนย์พอดี และตัวเลขบนหน้าจอกลุ่มของคุณคือค่าที่คำนวณจากรายการเหล่านั้น ซึ่งเราลบทิ้งแล้วสร้างใหม่ได้ด้วยคำสั่งเดียว
คุณตรวจสอบคำกล่าวนี้ได้ เมื่อทางเดียวที่เงินจะขยับคือบันทึกแบบเพิ่มอย่างเดียว ยอดที่ผิดจะไม่ใช่ปริศนาอีกต่อไป แต่กลายเป็นคำสั่งค้นที่เรารันได้
ยอดคงเหลือคือค่าที่คำนวณได้ ไม่ใช่ตัวเลขที่คุณค่อยบวกเพิ่ม
บัญชีของ Dimesum มีสามตาราง ได้แก่ journals postings และค่าที่คำนวณไว้ balances ซึ่งอ้างอิงตามกลุ่ม สมาชิก และสกุลเงิน รายการใน postings คือความจริง รายการจะเป็นบวกเมื่อสมาชิกใส่เงินเข้ากลุ่ม และเป็นลบเมื่อใช้มูลค่าไป ยอดคงเหลือจึงเป็นเพียง SUM(amount_minor) ค่าที่คำนวณไว้มีไว้เพื่อความเร็ว ไม่ใช่เพื่อเป็นแหล่งอ้างอิงหลัก มันถูกเขียนภายในทรานแซกชันของบันทึกเอง และการที่รายการครบถ้วนทำให้มันทิ้งได้เสมอ
สองชั้นยืนยันศูนย์เดียวกัน และไม่มีชั้นไหนพอเพียงลำพัง
ใน Go buildPostings จะไม่เปิดทรานแซกชันเว้นแต่ชุดรายการจะมีผลรวมเป็นศูนย์ ใน Postgres ทริกเกอร์แบบ deferred constraint จะตรวจ SUM(amount_minor) = 0 ต่อบันทึกอีกครั้งตอนคอมมิต เพราะรายการถูกแทรกทีละแถว และการตรวจทีละแถวจะปฏิเสธรายการแรกของทุกบันทึก การ assert จับบั๊กในตัวคำนวณ ส่วนทริกเกอร์จับตัวเขียนที่ไม่เคยเรียกมัน
ชั้นที่สามคือการให้สิทธิ์ UPDATE DELETE และ TRUNCATE ถูกเพิกถอนจาก role ledger_app บนทั้งสองตาราง โค้ดที่มีบั๊กจึงเขียนทับประวัติไม่ได้แม้จะพยายาม
| ประเภทบั๊ก | ตารางยอดที่ค่อยบวกเพิ่ม | บัญชีแบบเพิ่มอย่างเดียว |
|---|---|---|
| หักลูกหนี้แล้ว แต่ไม่เคยเครดิตผู้จ่าย | ผิดเงียบไปตลอด | การตรวจผลรวมศูนย์ล้มเหลว การเขียนถูกปฏิเสธ |
| ส่วนแบ่งเปลี่ยน แต่ยอดรวมไม่เปลี่ยน | ยอดคงเหลือคลาดเคลื่อนเงียบ ๆ | บันทึกที่ไม่สมดุลคอมมิตไม่ได้ |
| ทำไมยอดของฉันเป็น 412 บาท | ตอบไม่ได้ | ทุกสตางค์ย้อนไปหาบันทึกได้ |
| การแก้ด่วนบนโปรดักชัน | การเปลี่ยนแปลงที่ไม่ถูกติดตาม | ทางเดียวคือบันทึกใหม่ที่ถูกตรวจสอบ |
การแก้ไขจะลงรายการกลับ ไม่ใช่การ UPDATE
การแก้ไขค่าใช้จ่ายไม่เขียนทับเวอร์ชันเก่าเลย บัญชีจะรับเหตุการณ์ expense.amended หนึ่งเหตุการณ์ และลงบันทึกสองรายการในทรานแซกชันเดียว คือ EXPENSE_REVERSAL ที่รายการของมันคือการกลับค่าทีละขาของเวอร์ชันที่ถูกแทนที่พอดี แล้วตามด้วย EXPENSE ใหม่สำหรับเวอร์ชันใหม่ การลบจะหยุดหลังจากรายการกลับ
การอยู่ในทรานแซกชันเดียวสำคัญพอ ๆ กับตัวบันทึกสองรายการ ถ้ารายการกลับคอมมิตลำพัง กลุ่มจะไม่มีหนี้อยู่ชั่วขณะสำหรับค่าใช้จ่ายที่ยังค้างอยู่
คีย์ idempotency ทั้งสองได้มาจากเวอร์ชัน ไม่ใช่การสร้างขึ้นใหม่ คือ expense:<id>:v<n> สำหรับเวอร์ชัน และคีย์ของเวอร์ชันก่อนหน้าบวก :reversal สำหรับการกลับค่า journals.idempotency_key เป็น UNIQUE เหตุการณ์ที่ถูกส่งซ้ำจึงพบบันทึกของมันอยู่แล้วและไม่ทำอะไร การได้มาแบบนี้คือสิ่งที่ทำให้การส่งแบบ at-least-once ปลอดภัย การลองใหม่คำนวณคีย์เดียวกับที่ครั้งแรกใช้
วอเตอร์มาร์กตามลำดับคอมมิตทำให้การแก้ไขตามหลังค่าใช้จ่ายเสมอ
ทุกเหตุการณ์ที่ Dimesum เผยแพร่จะถูกเขียนลงตาราง outbox ในทรานแซกชันเดียวกับการเขียนเชิงธุรกิจ ถ้าค่าใช้จ่ายคอมมิต ประกาศนั้นก็มีอยู่ ถ้ามันย้อนกลับ ประกาศก็ย้อนกลับด้วย บัญชีจึงไม่เคยได้ยินเรื่องค่าใช้จ่ายที่ไม่มีอยู่จริง
ลำดับการส่งคือส่วนที่ยากกว่า Id เป็น UUIDv7 และเรียงตามเวลา แต่มันเข้ารหัสเวลาที่สร้าง id ไม่ใช่เวลาที่ทรานแซกชันคอมมิต ตัวรีเลย์ที่อ่านตามลำดับ id อาจข้ามทรานแซกชันที่รับ id ก่อนแต่คอมมิตทีหลัง การแก้ไขจึงแซงค่าใช้จ่ายที่มันแก้
การแก้คือหนึ่งคอลัมน์และหนึ่งเงื่อนไข แต่ละแถว outbox มี inserted_xid xid8 DEFAULT pg_current_xact_id() และรีเลย์อ่านเฉพาะแถว WHERE inserted_xid < pg_snapshot_xmin(pg_current_snapshot()) เรียงตาม xid นั้น (ดู PostgreSQL transaction id functions) เหตุการณ์ที่มาทีหลังเชิงเหตุผลจะมี xid ที่ใหม่กว่าเสมอ เพราะมันต้องอ่านแถวก่อนหน้าจึงจะเกิดขึ้นได้
ตัวรับการแก้ไขไม่เชื่อลำดับนั้นโดยไม่ตรวจ การแก้ไขที่รายการก่อนหน้ายังไม่มีบันทึกจะถูกส่งซ้ำระหว่างที่มันยังใหม่ และถูกพักไว้ให้คนดูเมื่อเลยช่วงผ่อนผันสองนาที
เงินคือหน่วยย่อยแบบ int64 และเลขชี้กำลังไม่ได้เป็นสองเสมอ
ทุกจำนวนเงินคือค่านับหน่วยย่อยแบบ int64 บวกรหัส ISO 4217 ดังนั้น 1,234.56 บาท คือ {Minor: 123456, Currency: "THB"} จำนวนเต็มแม่นยำโดยโครงสร้าง ไม่ใช่โดยวินัย ไม่มีค่าที่แทนได้เป็นครึ่งสตางค์ จึงไม่มีการดำเนินการใดสร้างมันขึ้นมาเงียบ ๆ ได้ ตัวเสริมภาษา Python อ่าน AST ของตัวเองและทำให้เทสต์ล้มเหลวถ้าคำว่า float ปรากฏในโมดูลจำนวนเงินของมัน
การสมมติว่าหน่วยย่อยคือหนึ่งในร้อยคือกับดักที่ซ่อนอยู่ JPY ไม่มีหน่วยย่อยเลย และ KWD มีสามตำแหน่งทศนิยม อัตราที่อ้างระหว่างหน่วยหลักแล้วนำไปใช้กับหน่วยย่อยจึงผิดไปสิบเท่า ลองคิด 2,000 เยน ที่อัตรา 0.58 ผลคูณแบบตรงไปตรงมาคือ 1160 ซึ่งอ่านเป็น 11.60 บาท ทั้งที่คำตอบคือ 1,160
WRITE_OFF เป็น journal type ที่ห้า เพราะรายการของมันเหมือนกับของการชำระเงิน
การตัดหนี้ลงสองขาเดียวกับที่การชำระเงินลง คือลูกหนี้เพิ่มขึ้น เจ้าหนี้ลดลง ด้วยจำนวนเท่ากัน การรวมมันเข้ากับ SETTLEMENT ถูกปฏิเสธด้วยเหตุผลเดียวกับที่รูปแบบมันเหมือนกัน ประโยค Asha จ่ายคุณ 500 บาท กับ คุณยกหนี้ให้ Asha 500 บาท เป็นข้อเท็จจริงคนละอย่าง และฟีดที่ปนสองอย่างนี้จะบอกว่ามีคนจ่ายทั้งที่ไม่มีใครจ่าย
ดังนั้น WRITE_OFF จึงเข้าร่วม CHECK ของ journal-type เมื่อ 2026-08-21 พร้อมขาของตัวเอง การตั้งชื่อขาเหล่านี้คือครึ่งหนึ่งของประเด็น เพราะการตัดหนี้ที่ใช้ SETTLE_PAY ซ้ำจะทำให้ทุกคำสั่งค้นที่ถามว่าจ่ายไปจริงเท่าไรผิดเงียบ ๆ
| ประเภทบันทึก | ลงเมื่อ | ขา |
|---|---|---|
EXPENSE | สร้างขึ้น หรือมีเวอร์ชันใหม่มาแทน | PAID, SHARE |
EXPENSE_REVERSAL | แก้ไขหรือลบ | ขาของบันทึกก่อนหน้า กลับค่า |
SETTLEMENT | มีการยืนยันการชำระคืน | SETTLE_PAY, SETTLE_RECV |
SETTLEMENT_REVERSAL | คู่กรณีโต้แย้ง | ขา settle ทั้งสอง กลับค่า |
WRITE_OFF | เจ้าหนี้สละสิทธิ์เรียกร้อง | WRITE_OFF_FORGIVEN, WRITE_OFF_GRANTED |
สองคำสั่งย่อยเปลี่ยนกฎคงที่ให้เป็นงาน cron
evenly verify-ledger พิสูจน์กฎคงที่ทั้งสคีมาอีกครั้ง และออกด้วยค่าไม่เป็นศูนย์เมื่อพบข้อผิดพลาดใด ๆ รายงานของมันมีสี่ช่อง และคาดว่าทุกช่องจะว่าง คือบันทึกที่ผลรวมไม่เป็นศูนย์ กลุ่มที่ผลรวมไม่เป็นศูนย์ แถวที่คำนวณไว้ซึ่งต่างจาก SUM(postings) ที่คำนวณใหม่ และเหตุการณ์ที่ถูกพักไว้รอคน การสแกนทั้งหมดใช้สแนปช็อตแบบ repeatable-read เดียวกัน บันทึกที่เข้ามาระหว่างการตรวจจึงสร้างความไม่ตรงกันปลอม ๆ ไม่ได้
การสแกนแต่ละครั้งจัดกลุ่มตามสกุลเงินด้วย นอกจากตาม id และความล้มเหลวที่เลี่ยงได้คือ false negative รายการ 500 บาท กับอีกรายการหนึ่งที่เป็นลบ 500 เยน จะรวมกันเป็นศูนย์เมื่อคำสั่งค้นไม่สนใจคอลัมน์สกุลเงิน บัญชีที่เสียหายสองชั้นจึงอ่านออกมาว่าสะอาด
evenly rebuild-balances [group-id] คือทั้งการซ่อมและการซ้อม มันล้างค่าที่คำนวณไว้และคำนวณทุกแถวใหม่จากรายการ พร้อมประทับแต่ละแถวด้วยบันทึกล่าสุดที่ทำให้สมาชิกนั้นขยับ เหมือนที่การเขียนจริงทำ ความเท่ากันแบบแถวต่อแถวคือคุณสมบัติ เมื่อการสร้างใหม่กับค่าที่คำนวณไว้ตอนใช้งานจริงไม่ตรงกัน บัญชีคือฝ่ายถูก
ตรวจก่อน แล้วจึงสร้างใหม่ รายงานจะระบุยอดที่เก็บไว้และยอดที่คำนวณใหม่สำหรับทุกแถวที่คลาดเคลื่อน และการสร้างใหม่จะเขียนทับค่าที่เก็บไว้ การสร้างใหม่ก่อนจึงทำลายหลักฐาน
ทำให้ยอดคงเหลือคำนวณได้ แล้วความคลาดเคลื่อนจะกลายเป็นคำสั่งค้น
บัญชีแบบเพิ่มอย่างเดียวจะได้บันทึกที่สองก็ต่อเมื่อคุณพิสูจน์มันได้ จงสร้างตัวตรวจและการสร้างใหม่ก่อนฟีเจอร์ที่ต้องใช้มัน การสร้างใหม่ที่ไม่มีใครเคยรันคือความหวัง ไม่ใช่ทางออกฉุกเฉิน เลือกค่าที่คำนวณเกี่ยวกับเงินที่เสี่ยงที่สุดของคุณสัปดาห์นี้ เขียนคำสั่งค้นที่คำนวณมันใหม่จากต้นทาง และแจ้งเตือนตัวเองเมื่อทั้งสองไม่ตรงกัน
คำถามที่พบบ่อย
append-only ledger ในแอปหารเงินคืออะไร?
บัญชีแบบเพิ่มอย่างเดียวจะบันทึกทุกเหตุการณ์ทางการเงินเป็นชุดรายการที่ผลรวมเป็นศูนย์ และไม่เคยแก้หรือลบรายการเดิม ใน Dimesum ทั้งค่าใช้จ่าย การแก้ไข การชำระเงิน ข้อโต้แย้ง และการตัดหนี้ ต่างเพิ่มบันทึกใหม่แต่ละครั้ง ยอดคงเหลือได้มาจากการรวมรายการทั้งหมด ทุกสตางค์จึงย้อนกลับไปหาเหตุการณ์ที่ทำให้เงินขยับได้
ถ้าแก้ไขค่าใช้จ่าย append-only ledger ทำงานยังไง?
การแก้ไขจะลงสองบันทึกในทรานแซกชันเดียว คือ EXPENSE_REVERSAL ที่กลับค่ารายการเดิมทีละขา แล้วตามด้วย EXPENSE ใหม่ที่ถือเวอร์ชันแทนที่ ไม่มีการเขียนทับสิ่งใด และการลบจะลงเพียงรายการกลับเท่านั้น ทั้งสองบันทึกใช้คีย์ idempotency ที่ได้มาจากเวอร์ชันของค่าใช้จ่าย เหตุการณ์ที่ถูกส่งซ้ำจึงพบว่ามันอยู่แล้วและไม่เปลี่ยนอะไร
ทำไมต้องเก็บจำนวนเงินเป็น integer ไม่ใช้ float?
หน่วยย่อยแบบจำนวนเต็มแม่นยำโดยโครงสร้าง ขณะที่ binary floating point แทน 0.1 ให้เป๊ะไม่ได้และคลาดเคลื่อนสะสมไปตลอดประวัติของกลุ่ม Dimesum เก็บทุกจำนวนเป็นค่านับหน่วยย่อยแบบ int64 บวกรหัส ISO 4217 เลขชี้กำลังมาจากสกุลเงิน JPY ไม่มีหน่วยย่อยเลย การสมมติว่าเป็นหนึ่งในร้อยจึงผิดไป 100 เท่า
จะรู้ได้ยังไงว่ายอดหารบิลไม่คลาดเคลื่อน?
รัน evenly verify-ledger ซึ่งพิสูจน์กฎคงที่ทั้งสคีมาอีกครั้ง และออกด้วยค่าไม่เป็นศูนย์เมื่อพบข้อผิดพลาด มันจะรายงานบันทึกที่ผลรวมไม่เป็นศูนย์ กลุ่มที่ผลรวมไม่เป็นศูนย์ แถวที่คำนวณไว้ซึ่งต่างจาก SUM(postings) ที่คำนวณใหม่ และเหตุการณ์ที่ถูกพักไว้ ตั้ง cron ให้รันทุกคืน และซ่อมค่าที่ผิดด้วย evenly rebuild-balances
ทำไม write-off ต้องมี journal type ของตัวเอง?
การตัดหนี้ลงขาเหมือนกับของการชำระเงินพอดี ซึ่งนั่นเองคือเหตุผลที่ journal type ต้องต่างกัน มีแค่คำเดียวที่แยกระหว่าง Asha จ่ายคุณ 500 บาท กับ คุณยกหนี้ให้ Asha 500 บาท และฟีดที่ปนสองอย่างนี้จะบอกว่ามีคนจ่ายทั้งที่ไม่มีใครจ่าย ขาของมันจึงชื่อ WRITE_OFF_FORGIVEN และ WRITE_OFF_GRANTED เพื่อให้คำสั่งค้นเรื่องการจ่ายเงินยังถูกต้อง
บทความยอดนิยม
- ทำไมการแก้ไขค่าใช้จ่ายร่วมต้องระบุการแบ่งใหม่อ่าน 5 นาที
- บั๊กเรื่องเงินหกจุดในการแยกบิลหลายสกุลเงินอ่าน 6 นาที
- เคลียร์ค่าใช้จ่ายกลุ่มให้จบในไม่กี่การโอนอ่าน 6 นาที
- วิธีแบ่งบิลเมื่อมีจานที่ไม่ได้แชร์กันอ่าน 6 นาที
- คู่มือแบ่งค่าเช่ากับเพื่อนร่วมห้องอย่างเป็นธรรมอ่าน 6 นาที