การให้หมายเลขใบแจ้งหนี้ที่ปลอดภัยต่อการประมวลผลพร้อมกันและป้องกันหมายเลขซ้ำ/ช่องว่าง
เรียนรู้รูปแบบปฏิบัติสำหรับการให้หมายเลขใบแจ้งหนี้ที่ปลอดภัยต่อการประมวลผลพร้อมกัน เพื่อให้ผู้ใช้หลายคนสามารถสร้างใบแจ้งหนี้หรือบัตรโดยไม่เกิดหมายเลขซ้ำหรือช่องว่างที่ไม่คาดคิด

ปัญหาเกิดอะไรขึ้นเมื่อสองคนสร้างระเบียนพร้อมกัน
ลองนึกภาพออฟฟิศที่วุ่นวายตอน 16:55 น. สองคนทำใบแจ้งหนี้เสร็จและกดบันทึกภายในเวลาไม่กี่วินาที หน้าจอทั้งสองแสดง “Invoice #1042” ชั่วคราว ระเบียนหนึ่งบันทึกได้ อีกระเบียนล้มเหลว หรือแย่กว่านั้น ทั้งคู่ถูกเก็บด้วยหมายเลขเดียวกัน นี่เป็นอาการที่พบได้บ่อยที่สุดในโลกจริง: หมายเลขซ้ำที่ปรากฏเฉพาะเมื่อมีภาระพร้อมกันสูง
พฤติกรรมเดียวกันเกิดกับตั๋วการสนับสนุน สองเอเยนต์สร้างตั๋วใหม่ให้ลูกค้ารายเดียวกันพร้อมกัน แล้วระบบพยายาม “หยิบหมายเลขถัดไป” โดยดูจากระเบียนล่าสุด หากคำขอทั้งสองอ่านค่า “ล่าสุด” เดียวกันก่อนที่จะมีการเขียน ทั้งคู่ก็อาจเลือกหมายเลขถัดไปเดียวกันได้
อาการที่สองละเอียดกว่า: หมายเลขข้ามหาย คุณอาจเห็น #1042 แล้วตามด้วย #1044 โดยไม่มี #1043 นี่มักเกิดหลังจากมีข้อผิดพลาดหรือการลองใหม่ คำขอหนึ่งสำรองหมายเลขไว้ แต่การบันทึกล้มเหลวเพราะการตรวจสอบความถูกต้อง, หมดเวลา, หรือผู้ใช้ปิดแท็บ หรืองานพื้นหลังลองใหม่หลังจากการเชื่อมต่อสะดุดและดึงหมายเลขใหม่แม้ว่าการพยายามแรกจะบริโภคหมายเลขไปแล้ว
สำหรับใบแจ้งหนี้ เรื่องนี้สำคัญเพราะการระบุหมายเลขเป็นส่วนหนึ่งของเส้นทางการตรวจสอบ บัญชีคาดหวังว่าแต่ละใบแจ้งหนี้จะถูกระบุอย่างเอกลักษณ์ และลูกค้าอาจอ้างถึงหมายเลขในการชำระเงินหรืออีเมลซัพพอร์ต สำหรับตั๋ว หมายเลขคือที่ทุกคนใช้ในบทสนทนา รายงาน และการส่งออก หมายเลขซ้ำสร้างความสับสน หมายเลขหายอาจทำให้เกิดคำถามในระหว่างการตรวจทาน แม้ว่าจะไม่มีการทุจริตเกิดขึ้นก็ตาม
จุดสำคัญที่ควรกำหนดแต่ต้นคือ: วิธีการให้หมายเลขบางอย่างไม่สามารถเป็นทั้ง "ปลอดภัยต่อการประมวลผลพร้อมกัน" และ "ไม่มีช่องว่าง" ได้พร้อมกันเสมอ การให้หมายเลขที่ปลอดภัยต่อการประมวลผลพร้อมกัน (ไม่มีหมายเลขซ้ำ แม้มีผู้ใช้หลายคน) ทำได้และควรยึดถือเป็นสิ่งจำเป็น การให้หมายเลขแบบไม่มีช่องว่างก็ทำได้เช่นกัน แต่ต้องมีกฎเพิ่มและมักเปลี่ยนวิธีจัดการร่าง ข้อผิดพลาด และการยกเลิก
วิธีที่ดีในการกำหนดปัญหาคือถามว่าคุณต้องการรับประกันอะไรจากหมายเลขของคุณ:
- ต้องไม่ซ้ำเลย (เอกลักษณ์เสมอ)
- ควรเพิ่มขึ้นโดยส่วนใหญ่ (เป็นผลดี)
- ต้องไม่ข้ามหมายเลข (เฉพาะเมื่อออกแบบให้เป็นเช่นนั้น)
เมื่อคุณเลือกกฎได้ การเลือกโซลูชันทางเทคนิคก็จะง่ายขึ้นมาก
ทำไมเกิดหมายเลขซ้ำและช่องว่าง
แอปส่วนใหญ่ทำตามรูปแบบง่ายๆ: ผู้ใช้กดบันทึก แอปขอหมายเลขใบแจ้งหถัดไป แล้วแทรกระเบียนใหม่ด้วยหมายเลขนั้น มันดูปลอดภัยเพราะทำงานดีเมื่อมีคนทำคนเดียว
ปัญหาเริ่มเมื่อมีการบันทึกเกิดขึ้นเกือบพร้อมกัน คำขอทั้งสองอาจมาถึงขั้นตอน “รับหมายเลขถัดไป” ก่อนที่จะมีการแทรกถาวร หากทั้งคู่เห็นค่า "ถัดไป" เดียวกัน ทั้งสองจะพยายามเขียนหมายเลขเดียวกัน นั่นคือ race condition: ผลลัพธ์ขึ้นกับจังหวะเวลา ไม่ใช่ตรรกะ
ไทม์ไลน์ทั่วไปมีลักษณะดังนี้:
- คำขอ A อ่านหมายเลขถัดไป: 1042
- คำขอ B อ่านหมายเลขถัดไป: 1042
- คำขอ A แทรกใบแจ้งหนี้ 1042
- คำขอ B แทรกใบแจ้งหนี้ 1042 (หรือล้มเหลวหากมี unique rule บล็อกไว้)
หมายเลขซ้ำเกิดเมื่อไม่มีอะไรในฐานข้อมูลหยุดการแทรกครั้งที่สอง หากคุณตรวจแค่ "หมายเลขนี้ถูกใช้หรือไม่?" ในโค้ดแอป คุณยังอาจแพ้การแข่งขันระหว่างการตรวจและการแทรก
ช่องว่างเป็นปัญหาอีกแบบหนึ่ง เกิดเมื่อระบบของคุณ "สำรอง" หมายเลข แต่ระเบียนนั้นไม่เคยกลายเป็นใบแจ้งหนี้หรือตั๋วที่ถูกคอมมิต สาเหตุทั่วไปคือการชำระเงินล้มเหลว พบข้อผิดพลาดการตรวจสอบความถูกต้องช้ากว่า หมดเวลา หรือผู้ใช้ปิดแท็บหลังจากหมายเลขถูกกำหนด แม้การแทรกจะล้มเหลวและไม่มีอะไรถูกบันทึก หมายเลขอาจถูกบริโภคแล้ว
การประมวลผลพร้อมกันที่ซ่อนอยู่ทำให้เรื่องเลวร้ายขึ้น เพราะไม่ใช่แค่ "สองคนกดบันทึก" เสมอไป คุณอาจมี:
- ไคลเอนต์ API สร้างระเบียนแบบขนาน
- การนำเข้ารันเป็นแบตช์
- งานพื้นหลังสร้างใบแจ้งหนี้ตอนกลางคืน
- การลองใหม่จากแอปมือถือที่เครือข่ายไม่เสถียร
ดังนั้นสาเหตุรากคือ: (1) การชนเวลาเมื่อคำขอหลายรายการอ่านค่าตัวนับเดียวกัน และ (2) หมายเลขถูกจัดสรรก่อนที่คุณจะแน่ใจว่าทรานแซกชันจะสำเร็จ แผนการสำหรับการให้หมายเลขที่ปลอดภัยต่อการประมวลผลพร้อมกันต้องตัดสินใจว่าผลลัพธ์ใดที่คุณยอมรับได้: ไม่มีซ้ำ, ไม่มีช่องว่าง, หรือทั้งสอง และในเหตุการณ์ใดบ้าง (ร่าง, การลองใหม่, การยกเลิก)
ตัดสินใจกฎการให้หมายเลขก่อนเลือกโซลูชัน
ก่อนออกแบบการให้หมายเลขที่ปลอดภัยต่อการประมวลผลพร้อมกัน ให้เขียนลงไปว่าหมายเลขต้องหมายความว่าอย่างไรตามธุรกิจ ความผิดพลาดทั่วไปคือเลือกวิธีการทางเทคนิคก่อน แล้วพบว่ากฎทางบัญชีหรือกฎหมายคาดหวังสิ่งต่างไป
เริ่มจากแยกสองเป้าหมายที่มักสับสนกัน:
- Unique: ไม่มีใบแจ้งหนี้หรือตั๋วสองรายการที่มีหมายเลขเดียวกัน
- Gapless: หมายเลขไม่ซ้ำและต่อเนื่องอย่างเคร่งครัด (ไม่มีหมายเลขหาย)
ระบบจริงหลายระบบมักมุ่งที่ Unique เท่านั้นและยอมรับช่องว่าง ช่องว่างอาจเกิดจากเหตุปกติ: ผู้ใช้เปิดร่างและทิ้งไป การชำระเงินล้มเหลวหลังจากสำรองหมายเลข หรือระเบียนถูกสร้างแล้วถูกยกเลิก สำหรับตั๋ว ช่องว่างมักไม่เป็นปัญหาเลย แม้สำหรับใบแจ้งหนี้ หลายทีมยอมรับช่องว่างได้ถ้ามีเส้นทางการตรวจสอบอธิบายได้ (voided, canceled, test ฯลฯ) การให้หมายเลขแบบ gapless ทำได้ แต่บังคับให้มีกฎเพิ่มและมักเพิ่มแรงเสียดทาน
ต่อมา ตัดสินใจขอบเขตของตัวนับ คำวลีเล็กๆ เปลี่ยนการออกแบบมาก:
- ใช้ลำดับเดียวทั่วทั้งระบบ หรือแยกตาม company/tenant?
- รีเซ็ตทุกปี หรือไม่เคยรีเซ็ต?
- มีซีรีส์ต่างกันสำหรับใบแจ้งหนี้ vs เครดิตโน้ต vs ตั๋ว?
- ต้องการฟอร์แมตที่คนอ่านง่าย (prefix, ตัวคั่น) หรือแค่หมายเลขภายใน?
ตัวอย่างชัดเจน: SaaS ที่มีหลายบริษัทลูกค้าอาจต้องการหมายเลขใบแจ้งหนี้ที่ไม่ซ้ำต่อบริษัทและรีเซ็ตต่อปี ในขณะที่ตั๋วไม่รีเซ็ตและไม่ซ้ำทั่วทั้งระบบ นั่นคือสองตัวนับต่างกันด้วยกฎต่างกัน แม้ UI จะดูคล้ายกัน
ถ้าคุณต้องการจริงๆ ให้เป็น Gapless ให้กำหนดอย่างชัดเจนว่าอนุญาตเหตุการณ์ใดหลังจากหมายเลขถูกกำหนด เช่น ใบแจ้งหนี้สามารถลบได้ไหม หรือแค่ยกเลิกได้หรือไม่ ผู้ใช้สามารถบันทึกร่างโดยไม่มีหมายเลข แล้วมอบหมายหมายเลขเฉพาะเมื่ออนุมัติขั้นสุดท้ายหรือไม่ ทางเลือกเหล่านี้มักสำคัญกว่าวิธีในฐานข้อมูล
เขียนกฎเป็นข้อสั้นๆ ก่อนสร้าง:
- ประเภทระเบียนใดใช้ลำดับนี้?
- อะไรทำให้หมายเลขถือว่า “ถูกใช้” (ร่าง, ส่ง, ชำระแล้ว)?
- ขอบเขตคืออะไร (global, per company, per year, per series)?
- จัดการ voids และ corrections อย่างไร?
ใน AppMaster กฎแบบนี้ควรอยู่ใกล้กับโมเดลข้อมูลและกระบวนการธุรกิจ เพื่อทีมจะนำพฤติกรรมเดียวกันไปใช้ทุกที่ (API, เว็บ UI, มือถือ) โดยไม่แปลกใจ
แนวทางที่พบบ่อยและแต่ละแบบรับประกันอะไรบ้าง
เมื่อคนพูดถึง “การให้หมายเลขใบแจ้งหนี้” มักผสมสองเป้าหมายต่างกัน: (1) ไม่สร้างหมายเลขซ้ำสองครั้ง และ (2) ไม่มีช่องว่าง ส่วนใหญ่รับประกันข้อนแรกได้ง่าย ข้อสองยากกว่าเพราะช่องว่างอาจปรากฏเมื่อใดก็ตามที่ทรานแซกชันล้มเหลว ร่างถูกทิ้ง หรือระเบียนถูกยกเลิก
แนวทาง 1: Database sequence (เร็ว ได้ความเป็นเอกลักษณ์)
Sequence ของ PostgreSQL เป็นวิธีง่ายที่สุดเพื่อให้ได้หมายเลขที่ไม่ซ้ำและเพิ่มขึ้นภายใต้ภาระสูง มันปรับขนาดได้ดีเพราะฐานข้อมูลออกค่า sequence ได้อย่างรวดเร็ว แม้มีผู้ใช้จำนวนมากสร้างระเบียนพร้อมกัน
สิ่งที่ได้: ความเป็นเอกลักษณ์และการเรียงลำดับ (เพิ่มขึ้นโดยส่วนใหญ่) สิ่งที่ไม่ได้: ไม่มีช่องว่าง ถ้าแทรกล้มเหลวหลังจากหมายเลขถูกใช้ หมายเลขนั้นจะถูก "เผา" และคุณจะเห็นช่องว่าง
แนวทาง 2: Unique constraint พร้อมการลองใหม่ (ให้ฐานข้อมูลตัดสิน)
ที่นี่คุณสร้างหมายเลขที่คาดว่าจะเป็นแล้วบันทึก และอาศัย UNIQUE constraint เพื่อปฏิเสธการซ้ำ หากเกิดชน คุณลองใหม่ด้วยหมายเลขใหม่
วิธีนี้ใช้ได้ แต่จะดังขึ้นภายใต้การประมวลผลพร้อมกันสูง คุณอาจเห็นการลองใหม่มากขึ้น ทรานแซกชันล้มเหลวมากขึ้น และยอดที่ยากต่อการดีบัก นอกจากนี้ไม่รับประกัน gapless เว้นแต่จะรวมกับกฎการสำรองที่เคร่งครัด ซึ่งเพิ่มความซับซ้อน
แนวทาง 3: แถวตัวนับพร้อมการล็อก (มุ่งสู่ gapless)
ถ้าคุณต้องการจริงๆ ให้เป็น gapless รูปแบบที่ใช้กันทั่วไปคือมีตารางตัวนับเฉพาะ (แถวหนึ่งสำหรับแต่ละขอบเขต เช่น ต่อปีหรือแต่ละบริษัท) คุณล็อกแถวนั้นในทรานแซกชัน เพิ่มค่า แล้วใช้ค่าที่ได้
นี่คือสิ่งที่ใกล้เคียงที่สุดกับ gapless ในการออกแบบฐานข้อมูลปกติ แต่มันมีต้นทุน: มันสร้าง "hot spot" เดียวที่ผู้เขียนทุกคนต้องรอ และเพิ่มความเสี่ยงจากความผิดพลาดในการดำเนินงาน (ทรานแซกชันยาว หมดเวลา และ deadlock)
แนวทาง 4: บริการสำรองหมายเลขแยกต่างหาก (สำหรับกรณีพิเศษ)
บริการ "numbering service" แยกต่างหากสามารถรวมกฎข้ามแอปหรือฐานข้อมูลหลายตัว เหมาะเมื่อคุณมีหลายระบบที่ต้องออกหมายเลขและไม่สามารถรวมเขียนกันได้
การแลกเปลี่ยนคือความเสี่ยงด้านการดำเนินงาน: คุณเพิ่มบริการอีกตัวที่ต้องถูกต้อง มีความพร้อมใช้งานสูง และสอดคล้อง
คิดภาพการรับประกันแบบปฏิบัติได้สำหรับการให้หมายเลขที่ปลอดภัยต่อการประมวลผลพร้อมกัน:
- Sequence: ไม่ซ้ำ, เร็ว, ยอมรับช่องว่าง
- Unique + retry: ไม่ซ้ำ, เรียบง่ายเมื่อโหลดต่ำ, อาจเกิด thrash เมื่อโหลดสูง
- แถวตัวนับล็อก: สามารถเป็น gapless ได้, ช้าภายใต้การประมวลผลพร้อมกันสูง
- บริการแยก: ยืดหยุ่นข้ามระบบ, มีความซับซ้อนและความเสี่ยงมากที่สุด
ถ้าคุณสร้างนี้ในเครื่องมือ no-code เช่น AppMaster ทางเลือกเดิมยังใช้ได้: ความถูกต้องอยู่ที่ฐานข้อมูล ตรรกะแอปช่วยจัดการการลองใหม่และข้อความผิดพลาด แต่การรับประกันสุดท้ายควรมาจากคอนสเตรนต์และทรานแซกชัน
ทีละขั้นตอน: ป้องกันการซ้ำด้วย sequence และ unique constraint
ถ้าจุดประสงค์หลักของคุณคือป้องกันการซ้ำ (ไม่ต้องการรับประกันว่าไม่มีช่องว่าง) รูปแบบที่ง่ายและเชื่อถือได้คือ: ให้ฐานข้อมูลสร้างไอดีภายใน และบังคับความเป็นเอกลักษณ์บนหมายเลขที่ผู้ใช้เห็น
เริ่มจากแยกแนวคิดสองอย่าง ใช้ค่าที่ฐานข้อมูลสร้าง (identity/sequence) เป็น primary key สำหรับการ join แก้ไข และการส่งออก เก็บ invoice_no หรือ ticket_no เป็นคอลัมน์แยกที่แสดงให้ผู้ใช้เห็น
การตั้งค่าที่เป็นประโยชน์ใน PostgreSQL
นี่คือวิธีการ PostgreSQL ทั่วไปที่เก็บตรรกะ "หมายเลขถัดไป" ไว้ในฐานข้อมูล ซึ่งการประมวลผลพร้อมกันจะถูกจัดการอย่างถูกต้อง
-- Internal, never-shown primary key
create table invoices (
id bigint generated always as identity primary key,
invoice_no text not null,
created_at timestamptz not null default now()
);
-- Business-facing uniqueness guarantee
create unique index invoices_invoice_no_uniq on invoices (invoice_no);
-- Sequence for the visible number
create sequence invoice_no_seq;
ตอนนี้สร้างหมายเลขสำหรับแสดงขณะแทรก (ไม่ใช่โดยการทำ "select max(invoice_no) + 1") หนึ่งในรูปแบบง่ายๆ คือฟอร์แมตค่าจาก sequence ภายใน INSERT:
insert into invoices (invoice_no)
values (
'INV-' || lpad(nextval('invoice_no_seq')::text, 8, '0')
)
returning id, invoice_no;
แม้จะมีผู้ใช้ 50 คนกด "Create invoice" พร้อมกัน แต่แต่ละการแทรกจะได้ค่า sequence ที่ต่างกัน และ unique index จะป้องกันการซ้ำโดยไม่ได้ตั้งใจ
ทำอย่างไรเมื่อเกิดการชน
กับ sequence ธรรมดา การชนเกิดขึ้นไม่บ่อย มักเกิดเมื่อคุณเพิ่มกฎพิเศษ เช่น "รีเซ็ตต่อปี" "ต่อ tenant" หรือหมายเลขที่ผู้ใช้แก้ไขได้ นั่นเป็นเหตุผลที่ยังคงต้องมี unique constraint
ที่ระดับแอป ให้จัดการข้อผิดพลาด unique-violation ด้วยลูปลองใหม่เล็กๆ เก็บให้เรียบและมีขอบเขต:
- พยายามแทรก
- ถ้าได้ unique constraint error บน invoice_no ให้ลองใหม่
- หยุดหลังจากลองไม่กี่ครั้งแล้วแสดงข้อผิดพลาดที่ชัดเจน
วิธีนี้ใช้ได้ดีเพราะการลองใหม่จะถูกทริกเกอร์เฉพาะเมื่อเกิดเหตุผิดปกติ เช่น ทางเดินโค้ดต่างกันให้หมายเลขรูปแบบเดียวกัน
ลดช่องหน้าต่างของ race
อย่าคำนวณหมายเลขใน UI และอย่าสำรองหมายเลขโดยอ่านก่อนแล้วค่อยแทรก ทีละขั้นตอนให้สร้างหมายเลขให้ใกล้กับการเขียนฐานข้อมูลที่สุด
ถ้าคุณใช้ AppMaster กับ PostgreSQL ให้แบบจำลอง id เป็น identity primary key ใน Data Designer เพิ่ม unique constraint สำหรับ invoice_no และสร้าง invoice_no ระหว่าง create flow เพื่อให้เกิดพร้อมกับการ INSERT แบบนั้นฐานข้อมูลจะเป็นแหล่งความจริง และปัญหาการประมวลผลพร้อมกันจะถูกจำกัดในส่วนที่ PostgreSQL แข็งแรงที่สุด
ทีละขั้นตอน: สร้างตัวนับแบบไม่มีช่องว่างด้วยการล็อกแถว
ถ้าคุณต้องการจริงๆ ให้ไม่มีช่องว่าง คุณสามารถใช้ตารางตัวนับเชิงธุรกรรมและล็อกแถว แนวคิดง่าย: มีเพียงทรานแซกชันเดียวเท่านั้นที่สามารถรับหมายเลขถัดไปสำหรับขอบเขตหนึ่งๆ ดังนั้นหมายเลขจะถูกแจกตามลำดับ
ก่อนอื่น ตัดสินใจขอบเขต หลายทีมต้องการลำดับแยกตามบริษัท ต่อปี หรือซีรีส์ ตัวนับเก็บหมายเลขล่าสุดสำหรับแต่ละขอบเขต
นี่คือรูปแบบปฏิบัติสำหรับการให้หมายเลขแบบ concurrency-safe โดยใช้การล็อกแถวใน PostgreSQL:
- สร้างตาราง เช่น
number_countersโดยมีคอลัมน์เช่นcompany_id,year,series,last_numberและคีย์เฉพาะบน(company_id, year, series) - เริ่มทรานแซกชันฐานข้อมูล
- ล็อกแถวตัวนับสำหรับขอบเขตด้วย
SELECT last_number FROM number_counters WHERE ... FOR UPDATE - คำนวณ
next_number = last_number + 1อัพเดตแถวตัวนับเป็นlast_number = next_number - แทรกแถวใบแจ้งหนี้หรือตั๋วโดยใช้
next_numberแล้ว commit
กุญแจคือ FOR UPDATE ภายใต้ภาระ คุณจะไม่ได้หมายเลขซ้ำ และคุณก็จะไม่เห็นช่องว่างจาก "ผู้ใช้สองคนได้หมายเลขเดียวกัน" เพราะทรานแซกชันที่สองจะไม่สามารถอ่านและเพิ่มแถวตัวนับเดียวกันได้จนกว่าทรานแซกชันแรกจะ commit (หรือ rollback) แทนที่คำขอที่สองจะต้องรอ นั่นคือราคาของการไม่มีช่องว่าง
การเริ่มต้นขอบเขตใหม่
คุณยังต้องมีแผนสำหรับครั้งแรกที่ขอบเขตปรากฏ (บริษัทใหม่, ปีใหม่, ซีรีส์ใหม่) ตัวเลือกสองอย่างที่พบได้ทั่วไป:
- สร้างแถวตัวนับล่วงหน้า (เช่น สร้างแถวสำหรับปีถัดไปในเดือนธันวาคม)
- สร้างตามต้องการ: พยายาม insert แถวตัวนับด้วย
last_number = 0และถ้ามันมีอยู่แล้ว ให้ fallback ไปที่ flow ล็อกแล้วเพิ่มปกติ
ถ้าคุณสร้างในเครื่องมือ no-code เช่น AppMaster เก็บลำดับ "ล็อก, เพิ่ม, แทรก" ไว้ภายในทรานแซกชันเดียวในตรรกะธุรกิจของคุณ เพื่อให้มันเกิดขึ้นทั้งหมดหรือไม่มีเลย
กรณีขอบ: ร่าง, บันทึกล้มเหลว, การยกเลิก, และการแก้ไข
ปัญหาการให้หมายเลขมักปรากฏในส่วนที่ยุ่งเหยิง: ร่างที่ไม่ถูกโพสต์, การบันทึกที่ล้มเหลว, ใบแจ้งหนี้ที่ถูก void, และระเบียนที่ถูกแก้ไขหลังจากมีผู้เห็นหมายเลขแล้ว ถ้าคุณต้องการการให้หมายเลขที่ปลอดภัยต่อการประมวลผลพร้อมกัน คุณต้องมีกฎชัดเจนว่าเมื่อใดหมายเลขถือว่า “จริง”
การตัดสินใจที่ใหญ่ที่สุดคือจังหวะเวลา ถ้าคุณกำหนดหมายเลขเมื่อใดก็ตามที่คนคลิก "New invoice" คุณจะได้ช่องว่างจากร่างที่ทิ้ง ถ้าคุณกำหนดเฉพาะเมื่อใบแจ้งหนี้เสร็จสมบูรณ์ (posted, issued, sent หรือตามความหมายในธุรกิจ) คุณจะเก็บหมายเลขได้แน่นขึ้นและอธิบายง่ายขึ้น
การบันทึกล้มเหลวและการ rollback คือที่ความคาดหวังมักขัดกับพฤติกรรมฐานข้อมูล ด้วย sequence ปกติ เมื่อหมายเลขถูกใช้แล้วมันถูกใช้ ถึงแม้ว่าทรานแซกชันจะล้มเหลว นั่นเป็นพฤติกรรมปกติและปลอดภัย แต่สามารถสร้างช่องว่างได้ ถ้านโยบายของคุณต้องการ gapless หมายเลขต้องถูกมอบหมายเฉพาะในขั้นสุดท้ายและเฉพาะเมื่อทรานแซกชัน commit นั่นหมายถึงปกติแล้วต้องล็อกแถวตัวนับเดียว เขียนหมายเลขสุดท้าย และ commit เป็นหน่วยเดียว ถ้าขั้นตอนไหนล้มเหลว จะไม่มีอะไรถูกมอบหมาย
การยกเลิกและ void แทบจะไม่ควร "ใช้หมายเลขซ้ำ" เก็บหมายเลขไว้และเปลี่ยนสถานะ ผู้สอบบัญชีและลูกค้าคาดหวังว่าประวัติจะคงที่ แม้เอกสารจะถูกแก้ไข
การแก้ไขง่ายกว่า: เมื่อหมายเลขปรากฏนอกระบบ ถือว่ามันถาวร อย่าเปลี่ยนหมายเลขใบแจ้งหนี้หรือตั๋วหลังจากที่แชร์ ส่งออก หรือพิมพ์แล้ว ถ้าต้องการแก้ไข ให้สร้างเอกสารใหม่และอ้างถึงตัวเดิม (เช่น เครดิตโน้ตหรือเอกสารทดแทน) แต่ไม่เขียนประวัติเดิมใหม่
ชุดกฎปฏิบัติที่ทีมหลายทีมยอมรับ:
- ร่างไม่มีหมายเลขสุดท้าย (ใช้ internal ID หรือ "DRAFT")
- มอบหมายหมายเลขเฉพาะตอน "Post/Issue" ภายในทรานแซกชันเดียวกับการเปลี่ยนสถานะ
- Voids และ cancellations เก็บหมายเลขไว้ แต่มีสถานะและเหตุผลชัดเจน
- หมายเลขที่พิมพ์/ส่งอีเมลแล้วจะไม่เปลี่ยน
- การนำเข้ารักษาหมายเลขเดิมและอัพเดตตัวนับให้เริ่มหลังค่าสูงสุดที่นำเข้าแล้ว
การย้ายข้อมูลและการนำเข้าต้องการความระมัดระวังเป็นพิเศษ ถ้าคุณย้ายจากระบบอื่น ให้นำหมายเลขใบแจ้งหนี้เดิมเข้ามาในสภาพเดิม แล้วตั้งตัวนับให้เริ่มหลังค่าสูงสุดที่นำเข้า นอกจากนี้ตัดสินใจว่าจะจัดการกับฟอร์แมตรวม (เช่น prefix ต่างกันตามปี) อย่างไร โดยปกติจะดีกว่าถ้าจัดเก็บ "display number" ให้ตรงกับที่เคยเป็น และเก็บ primary key ภายในแยกต่างหาก
ตัวอย่าง: helpdesk สร้างตั๋วเร็ว แต่หลายรายการเป็นร่าง กำหนดหมายเลขตั๋วเฉพาะเมื่อเอเยนต์กด "Send to customer" นั่นหลีกเลี่ยงการใช้หมายเลขกับร่างที่ทิ้ง และทำให้ลำดับที่เห็นสอดคล้องกับการสื่อสารจริงกับลูกค้า ในเครื่องมือ no-code อย่าง AppMaster แนวคิดเดียวกัน: เก็บร่างเป็นระเบียนโดยไม่มีหมายเลขสาธารณะ แล้วสร้างหมายเลขสุดท้ายในขั้นตอน "submit" ของกระบวนการธุรกิจที่ทำการ commit สำเร็จ
ความผิดพลาดที่พบบ่อยซึ่งทำให้เกิดซ้ำหรืือช่องว่างที่น่าตกใจ
ปัญหาการให้หมายเลขส่วนใหญ่เกิดจากแนวคิดเดียว: ถือหมายเลขเป็นค่าที่จะแสดง ไม่ใช่ state ที่แชร์ เมื่อหลายคนบันทึกพร้อมกัน ระบบต้องมีที่เดียวที่ชัดเจนในการตัดสินหมายเลขถัดไป และกฎเดียวชัดเจนว่าทำอย่างไรเมื่อเกิดข้อผิดพลาด
ข้อผิดพลาดคลาสสิกคือการใช้ SELECT MAX(number) + 1 ในโค้ดแอป มันดูดีในการทดสอบผู้ใช้คนเดียว แต่สองคำขอสามารถอ่าน MAX เดียวกันก่อนใครคนใดคนหนึ่ง commit ทั้งคู่จะสร้างค่า next เดียวกัน และคุณได้หมายเลขซ้ำ แม้คุณเพิ่ม "เช็คแล้วลองใหม่" คุณยังอาจสร้างโหลดเพิ่มและยอดที่ยากจะคาดการณ์เมื่อทราฟฟิกสูง
แหล่งที่มาของหมายเลขซ้ำอีกอย่างคือการสร้างหมายเลขที่ฝั่งไคลเอนต์ (เบราว์เซอร์หรือมือถือ) ก่อนบันทึก ไคลเอนต์ไม่รู้ว่าผู้ใช้คนอื่นกำลังทำอะไรและไม่สามารถสำรองหมายเลขได้อย่างปลอดภัยถ้าการบันทึกล้มเหลว หมายเลขที่ไคลเอนต์สร้างเหมาะกับป้ายชั่วคราว เช่น "Draft 12" แต่ไม่เหมาะกับรหัสใบแจ้งหนี้หรือตั๋วอย่างเป็นทางการ
ช่องว่างทำให้ทีมประหลาดใจที่คิดว่า sequence เป็น gapless ใน PostgreSQL sequences ออกแบบมาสำหรับความเป็นเอกลักษณ์ ไม่ใช่ความต่อเนื่องสมบูรณ์ หมายเลขสามารถข้ามได้เมื่อทรานแซกชัน rollback เมื่อคุณ prefetch IDs หรือเมื่อฐานข้อมูลรีสตาร์ท นั่นเป็นพฤติกรรมปกติ ถ้าความต้องการจริงๆ ของคุณคือ "ไม่มีหมายเลขซ้ำ" sequence + unique constraint มักเป็นคำตอบที่เหมาะสม ถ้าคุณต้องการจริงๆ ให้ไม่มีช่องว่าง คุณต้องใช้รูปแบบอื่น (มักเป็นการล็อกแถว) และยอมรับการแลกเปลี่ยนในระดับ throughput
การล็อกก็ย้อนกลับมาทำร้ายได้เมื่อกว้างเกินไป การล็อกทั่วทั้งระบบสำหรับการให้หมายเลขบังคับให้ทุกการสร้างต้องเข้าคิว แม้คุณจะแบ่งตัวนับตามบริษัท สาขา หรือประเภทเอกสารได้ก็ตาม ซึ่งจะทำให้ช้าลงและทำให้ผู้ใช้รู้สึกว่าการบันทึก "ติด" แบบสุ่ม
นี่คือความผิดพลาดที่ควรตรวจสอบเมื่อทำการใช้งานการให้หมายเลขที่ปลอดภัยต่อการประมวลผลพร้อมกัน:
- ใช้
MAX + 1(หรือ "หาเลขล่าสุด") โดยไม่มีคอนสเตรนต์ระดับฐานข้อมูล - สร้างหมายเลขสุดท้ายที่ฝั่งไคลเอนต์ แล้วพยายาม "แก้ขัด" ทีหลัง
- คาดหวังว่า PostgreSQL sequences จะไม่มีช่องว่าง แล้วมองช่องว่างเป็นข้อผิดพลาด
- ล็อกตัวนับร่วมเดียวสำหรับทุกอย่าง แทนที่จะ partition ตัวนับตามความเหมาะสม
- ทดสอบแค่ผู้ใช้คนเดียว ทำให้ race condition ไม่ปรากฏจนกว่าจะเปิดใช้งานจริง
เคล็ดลับทดสอบจริง: รันการทดสอบการประมวลผลพร้อมกันที่สร้าง 100 ถึง 1,000 ระเบียนพร้อมกัน แล้วตรวจหาหมายเลขซ้ำและช่องว่างที่ไม่คาดคิด ถ้าคุณสร้างในเครื่องมือ no-code อย่าง AppMaster กฎเดียวกันใช้ได้: ตรวจสอบให้แน่ใจว่าหมายเลขสุดท้ายถูกมอบหมายในทรานแซกชันฝั่งเซิร์ฟเวอร์ ไม่ใช่ใน flow ของ UI
การตรวจสอบด่วนก่อนส่งออกสู่ผู้ใช้
ก่อนปล่อยการให้หมายเลขใบแจ้งหนี้หรือตั๋ว ให้ทำการตรวจสอบด่วนในส่วนที่มักพังเมื่อมีการใช้งานจริง เป้าหมายเรียบง่าย: แต่ละระเบียนได้หมายเลขธุรกิจเพียงหมายเลขเดียว และกฎของคุณยังคงถูกต้องแม้เมื่อ 50 คนกด "Create" พร้อมกัน
นี่คือเช็คลิสต์ก่อนส่งออกสำหรับการให้หมายเลขที่ปลอดภัยต่อการประมวลผลพร้อมกัน:
- ยืนยันว่าฟิลด์หมายเลขธุรกิจมี unique constraint ในฐานข้อมูล (ไม่ใช่แค่การเช็คที่ UI) นี่คือแนวป้องกันสุดท้ายหากสองคำขอชนกัน
- ตรวจสอบว่าหมายเลขถูกมอบหมายภายในทรานแซกชันฐานข้อมูลเดียวกับการบันทึกระเบียน ถ้าการมอบหมายหมายเลขกับการบันทึกแยกข้ามคำขอ คุณจะเห็นหมายเลขซ้ำในที่สุด
- ถ้าต้องการหมายเลขแบบไม่มีช่องว่าง ให้มอบหมายหมายเลขเฉพาะเมื่อระเบียนถูกยืนยัน (เช่น เมื่อออก invoice ไม่ใช่ตอนสร้างร่าง) ร่าง ฟอร์มที่ถูกทิ้ง และการชำระเงินล้มเหลวเป็นสาเหตุหลักของช่องว่าง
- เพิ่มกลยุทธ์การลองใหม่สำหรับการชนที่เกิดได้ยาก แม้มีการล็อกแถวหรือ sequence คุณอาจเจอ serialization error, deadlock, หรือ unique violation ในกรณีมุมมอง การลองใหม่แบบเรียบง่ายพร้อม backoff ระยะสั้นมักเพียงพอ
- ทดสอบความทนทานด้วยการสร้างพร้อมกัน 20 ถึง 100 รายการผ่านทุกจุดเชื่อมต่อ: UI, API สาธารณะ, และ bulk imports ทดสอบรูปแบบจริงเช่น surges, เครือข่ายช้า, และ double submits
วิธีการตรวจสอบด่วน: จำลองช่วงเวลาสนับสนุนที่วุ่นวาย: สองเอเยนต์เปิดฟอร์ม "New ticket" หนึ่งส่งจากเว็บแอป ในขณะที่ job นำเข้าตั๋วจากกล่องอีเมลในเวลาเดียวกัน หลังรัน ตรวจสอบว่าหมายเลขทั้งหมดไม่ซ้ำ อยู่ในรูปแบบที่ถูก และความล้มเหลวไม่ทิ้งระเบียนที่บันทึกครึ่งกลาง
ถ้าคุณสร้างเวิร์กโฟลว์ใน AppMaster หลักการเดียวกันใช้ได้: เก็บการมอบหมายหมายเลขไว้ในทรานแซกชันฐานข้อมูล พึ่งพาคอนสเตรนต์ของ PostgreSQL และทดสอบทั้งการกระทำจาก UI และจุดเชื่อมต่อ API ที่สร้างเอนทิตีเดียวกัน นี่คือจุดที่ทีมหลายทีมรู้สึกปลอดภัยในการทดสอบด้วยตนเอง แต่ประหลาดใจในวันแรกที่ผู้ใช้จริงเข้ามาใช้พร้อมกัน
ตัวอย่าง: ตั๋ว helpdesk ที่วุ่นวายและสิ่งที่ควรทำต่อ
ลองนึกถึงศูนย์ช่วยเหลือที่เอเยนต์สร้างตั๋วทั้งวัน ในขณะที่ integration สร้างตั๋วจากแชตและอีเมลด้วย ทุกคนคาดหวังหมายเลขตั๋วเช่น T-2026-000123 และคาดหวังว่าหมายเลขแต่ละหมายเลขชี้ไปยังตั๋วเดียวเท่านั้น
วิธีเบสิคคือ: อ่าน "หมายเลขตั๋วล่าสุด" เพิ่ม 1 แล้วบันทึกตั๋ว ใต้ภาระสองคำขอสามารถอ่าน "หมายเลขล่าสุด" เดียวกันก่อนใครสักคนบันทึก ทั้งสองคำนวณหมายเลขถัดไปเดียวกัน และคุณได้หมายเลขซ้ำ หากคุณพยายาม "แก้" โดยการลองใหม่หลังล้มเหลว คุณมักจะสร้างช่องว่างโดยไม่ได้ตั้งใจ
ฐานข้อมูลสามารถหยุดการซ้ำแม้โค้ดแอปของคุณจะเรียบง่าย เพิ่ม unique constraint บนคอลัมน์ ticket_number แล้วเมื่อสองคำขอพยายามใช้หมายเลขเดียวกัน การแทรกหนึ่งรายการจะล้มเหลวและคุณสามารถลองใหม่ได้สะอาด นี่คือแก่นของการให้หมายเลขที่ปลอดภัยต่อการประมวลผลพร้อมกันด้วย
การให้หมายเลขแบบ gapless เปลี่ยนเวิร์กโฟลว์ หากคุณต้องการไม่มีช่องว่าง ปกติคุณจะไม่สามารถมอบหมายหมายเลขสุดท้ายเมื่อสร้างตั๋วครั้งแรก (ร่าง) แทนที่จะสร้างตั๋วด้วยสถานะ Draft และ ticket_number เป็น NULL มอบหมายหมายเลขเฉพาะเมื่อ finalized เพื่อให้การบันทึกล้มเหลวและร่างที่ทิ้งไม่เผาจำนวน
การออกแบบตารางง่ายๆ อาจเป็นดังนี้:
- tickets: id, created_at, status (Draft, Open, Closed), ticket_number (nullable), finalized_at
- ticket_counters: key (เช่น "tickets_2026"), next_number
ใน AppMaster คุณสามารถแบบจำลองนี้ใน Data Designer ด้วยชนิด PostgreSQL แล้วสร้างตรรกะใน Business Process Editor:
- Create Ticket: insert ticket with status=Draft and no ticket_number
- Finalize Ticket: start a transaction, lock the counter row, set ticket_number, increment next_number, commit
- Test: run two “Finalize” actions at the same time and confirm you never get duplicates
สิ่งที่ควรทำต่อ: เริ่มจากกฎของคุณ (unique เท่านั้น vs gapless จริงๆ) ถ้าคุณยอมรับช่องว่างได้ sequence ของฐานข้อมูล + unique constraint มักเพียงพอและทำให้ flow ง่าย ถ้าต้องการ gapless ย้ายการให้หมายเลขไปยังขั้นตอน finalization และถือว่าร่างเป็นสถานะสำคัญ จากนั้นทดสอบโหลดด้วยหลายเอเยนต์ที่กดพร้อมกันและ integration ที่ส่งชุดข้อมูล เพื่อเห็นพฤติกรรมก่อนผู้ใช้จริงจะเห็น


