ชื่อไทย 200 ชื่อ เรียง 13 ทาง ได้ 2 ลำดับ และ 188 ตำแหน่งไม่ตรงกัน
คุณมีรายชื่อ 200 ชื่อ คุณเขียน ORDER BY name แล้วส่งขึ้น production
วันหนึ่งมีคนถามว่าทำไม ไกรสร ถึงไปอยู่เกือบท้ายสุด
คุณเปิดดู มันอยู่ตำแหน่งที่ 194 จาก 200 จริง แล้วคุณเปิดดูรายการเดียวกันบนเครื่องของเพื่อนร่วมทีม ชื่อเดียวกันนั้นอยู่ตำแหน่งที่ 17
query ประโยคเดียวกัน ตารางใบเดียวกัน ชื่อชุดเดียวกัน ห่างกัน 177 ตำแหน่ง
หน้านี้เอาคลังชื่อไทย 200 ชื่อไปเรียงด้วย 13 ทางที่นักเขียนโปรแกรมเจอจริง บน SQLite, PostgreSQL 18 และ .NET แล้วนับว่าผลไม่ตรงกันกี่ตำแหน่ง คำตอบคือ 188 จาก 200 หรือ 94% ของรายการ
และธีสิสของหน้านี้อยู่ตรงนี้ — ไม่มีทางไหนในสิบสามทางนั้นเป็นการเลือกเรื่องภาษาไทย
ไม่มีใครนั่งลงแล้วตัดสินใจว่าจะเรียงชื่อไทยแบบไหน สิ่งที่ตัดสินแทนพวกเขาคือ base image
ของคอนเทนเนอร์ กับการที่มีคนพิมพ์ CompareOrdinal แทนที่จะไม่พิมพ์อะไรเลย
สระหน้าห้าตัวคือรอยร้าว
หัวข้อที่มีชื่อว่า “สระหน้าห้าตัวคือรอยร้าว”ทำไมชื่อที่ขึ้นต้นด้วย เ แ โ ใ ไ ถึงถูกดันไปกองท้ายรายการทั้งหมด
ภาษาไทยเขียนสระห้าตัวนี้ไว้ ก่อน พยัญชนะที่มันออกเสียง ตาม
เกษม เขียน เ ก่อน ก แต่อ่านว่า กะ-เษม สายตาเห็น เ มาก่อน หูได้ยิน ก มาก่อน
ฐานข้อมูลที่เรียงตาม codepoint เห็นแต่สายตา มันไม่รู้เรื่องเสียง
สระหน้าทั้งห้าอยู่ที่ U+0E40 ถึง U+0E44 ส่วนพยัญชนะไทยทั้งหมดอยู่ที่ U+0E01 ถึง U+0E2E ตัวที่สูงที่สุดคือ ฮ ที่ U+0E2E ซึ่งยังต่ำกว่า เ อยู่ดี
ผลคือชื่อที่ขึ้นต้นด้วยสระหน้า ทุกชื่อ ต้องเรียงหลังชื่อที่ขึ้นต้นด้วยพยัญชนะ ทุกชื่อ ไม่ใช่บางครั้ง แต่ทุกครั้ง เพราะมันเป็นผลของตัวเลขล้วน ๆ
ในคลังนี้มีชื่อสระหน้า 42 ชื่อ เรียงตาม codepoint แล้วมันไปกองอยู่ตำแหน่งที่ 159 ถึง 200 ติดกันเป็นบล็อกเดียว ไม่มีชื่อพยัญชนะแทรกเลยสักตัว
เรียงอีกแบบหนึ่ง มันกระจายอยู่ตั้งแต่ตำแหน่งที่ 12 ถึง 200
ไกรสร เกษม แก้วตา โกศล ใจดี เป็นชื่อไทยที่พบทั่วไป ไม่ใช่ตัวอย่างที่หามาให้พัง
ชื่อที่ขยับไกลที่สุดในคลังนี้ขยับ 177 ตำแหน่ง และทั้งคลังขยับเฉลี่ย 33.4 ตำแหน่ง
188 จาก 200
หัวข้อที่มีชื่อว่า “188 จาก 200”สองลำดับตรงกันแค่ 12 ตำแหน่ง และตำแหน่งที่ตรงกันเกือบทั้งหมดอยู่หัวรายการ
ลำดับทั้งสองเริ่มต้นเหมือนกันเป๊ะ ชื่อที่ขึ้นต้นด้วย ก ทั้งกลุ่มอยู่ตำแหน่งเดียวกันทั้งสองแบบ แล้วมันก็แยกทางกันที่ตำแหน่งที่ 12 จากตรงนั้นไปมันตรงกันอีกแค่ตำแหน่งเดียว คือตำแหน่งสุดท้ายของรายการ
| ตำแหน่ง | เรียงตาม codepoint | เรียงผ่าน ICU |
|---|---|---|
| 10 | กิตติศักดิ์ | กิตติศักดิ์ |
| 11 | กุลธิดา | กุลธิดา |
| 12 | ขจร | เกศริน |
| 13 | ขจรศักดิ์ | เกษม |
| 14 | ขนิษฐา | เกียรติศักดิ์ |
| 15 | ขวัญชัย | แก้วตา |
| 16 | ขวัญเรือน | โกศล |
| 17 | คณิตา | ไกรสร |
ฝั่งขวาแทรก เกศริน เกษม เกียรติศักดิ์ เข้ามาต่อจากกลุ่ม ก
เพราะมันอ่านว่า ก ตามด้วยเสียงสระ ส่วนฝั่งซ้ายยังไล่พยัญชนะต่อไปที่ ข
ชื่อที่ขยับไกลที่สุดแปดตัวเป็นชื่อสระหน้าทั้งหมด
| ชื่อ | ตำแหน่งเมื่อเรียงตาม codepoint | ตำแหน่งเมื่อเรียงผ่าน ICU | ขยับ |
|---|---|---|---|
| ไกรสร | 194 | 17 | 177 |
| โกศล | 185 | 16 | 169 |
| แก้วตา | 179 | 15 | 164 |
| ใจงาม | 191 | 39 | 152 |
| ใจดี | 192 | 40 | 152 |
| เกศริน | 159 | 12 | 147 |
| เกษม | 160 | 13 | 147 |
| เกียรติศักดิ์ | 161 | 14 | 147 |
ไม่มีคู่ไหนเลยที่ ICU บอกว่าเท่ากัน — คู่ที่เท่ากันมี 0 คู่ แปลว่าลำดับที่ได้ไม่ได้ขึ้นกับลำดับที่ชื่อเรียงอยู่ใน file ต้นทาง มันขึ้นกับกฎล้วน ๆ
สิบสามทาง สองลำดับ
หัวข้อที่มีชื่อว่า “สิบสามทาง สองลำดับ”ทางที่ต่างกันสิบสามทาง ตกลงไปอยู่แค่สองลำดับ และเส้นแบ่งไม่ได้อยู่ที่เอนจิน
ตารางนี้คือหัวใจของหน้านี้ อ่าน column ขวาสุดก่อน แล้วค่อยย้อนกลับมาอ่านว่าอะไรพาไปที่นั่น
| ทาง | ที่มา | ลำดับที่ได้ |
|---|---|---|
SQLite · ORDER BY name | collation BINARY คือค่าเดียวที่มีให้ | codepoint |
.NET · string.CompareOrdinal | ท่าที่ต้องพิมพ์เพิ่มถึงจะได้ | codepoint |
PostgreSQL 18 บน postgres:18-alpine · ORDER BY name | ไม่ได้ตั้ง collation | codepoint |
PostgreSQL 18 บน alpine · COLLATE "C" | สั่งให้เทียบทีละ byte ตรง ๆ | codepoint |
PostgreSQL 18 บน postgres:18 · COLLATE "C" | สั่งให้เทียบทีละ byte ตรง ๆ | codepoint |
PostgreSQL 18 บน postgres:18 · ORDER BY name | ไม่ได้ตั้ง collation | ICU |
PostgreSQL บน alpine · COLLATE "th-TH-x-icu" | สั่ง ICU เอง | ICU |
PostgreSQL บน Debian · COLLATE "th-TH-x-icu" | สั่ง ICU เอง | ICU |
.NET · CompareInfo.GetCompareInfo("th-TH") | สั่ง culture เอง | ICU |
.NET · Array.Sort(string[]) | ไม่ได้ใส่ตัวเทียบเลย | ICU |
.NET · LINQ OrderBy(x => x) | ไม่ได้ใส่ตัวเทียบเลย | ICU |
.NET · CompareInfo.GetCompareInfo("en-US") | culture อังกฤษ ไม่ใช่ไทย | ICU |
.NET · CultureInfo.InvariantCulture | culture กลาง ไม่ผูกกับภาษาไหน | ICU |
5 ทางให้ลำดับ codepoint และ 8 ทางให้ลำดับที่ผ่าน ICU รวมแล้วได้ 2 ลำดับ ไม่ใช่ 13 ลำดับ
สองแถวที่น่าสนใจที่สุดคือสองแถวสุดท้าย culture อังกฤษกับ culture กลาง ให้ลำดับเดียวกับ culture ไทยเป๊ะทุกตำแหน่ง
นั่นบอกว่ากฎที่สลับสระหน้ากับพยัญชนะไม่ได้อยู่ในส่วนเฉพาะของภาษาไทย มันอยู่ในตารางกลาง ที่ ICU ใช้กับทุกภาษา นักเขียน C# จึงได้ลำดับนั้นมาโดยไม่ต้องรู้ว่าตัวเองได้มา
Array.Sort ใน C# ให้ลำดับที่ผ่าน ICU ส่วน ORDER BY ใน SQLite ให้ลำดับ codepoint
แอปตัวเดียวกันที่เรียงรายชื่อในหน่วยความจำหน้าหนึ่ง แล้วเรียงด้วย query อีกหน้าหนึ่ง จะแสดงสองลำดับให้ผู้ใช้คนเดียวกัน โดยที่ไม่มีบรรทัดไหนใน code ดูผิด
ผมเกือบเขียนหน้านี้ผิด
หัวข้อที่มีชื่อว่า “ผมเกือบเขียนหน้านี้ผิด”การวัดครั้งแรกให้คำตอบที่ใช้กับคนส่วนใหญ่ไม่ได้ และสิ่งที่จับได้คือการไม่เชื่อผลของตัวเอง
ตอนแรกผมวัด PostgreSQL บน postgres:18-alpine เพราะสคริปต์ที่รีโปนี้มีอยู่แล้วใช้ image นั้น
ผลออกมาว่า ORDER BY name ที่ไม่ตั้งอะไรเลยให้ลำดับ codepoint
ผมกำลังจะเขียนว่า “PostgreSQL ที่ไม่ตั้ง collation ให้ลำดับ codepoint” ซึ่งอ่านแล้วสมเหตุสมผล
แล้วผมนึกได้ว่า alpine ใช้ musl ส่วน image หลักของ PostgreSQL ใช้ glibc สองตัวนี้เป็น C library คนละตัว และ collation ของ libc มาจาก C library ไม่ได้มาจาก PostgreSQL
ผมจึงสตาร์ต image ที่สองแล้ววัดซ้ำ ผลกลับกันคนละทาง
| image | datcollate | provider | locale ไทยใน libc | ORDER BY name ให้ |
|---|---|---|---|---|
postgres:18-alpine | en_US.utf8 | libc | 0 | codepoint |
postgres:18 | en_US.utf8 | libc | 0 | ICU |
PostgreSQL 18.6 เท่ากันทั้งสองฝั่ง datcollate เท่ากัน provider เท่ากัน
และทั้งสอง image ไม่มี locale ภาษาไทยติดตั้งไว้เลยสักตัว
ต่างกันที่ image ที่ pull มาอย่างเดียว
ถ้าผมหยุดที่การวัดครั้งแรก หน้านี้จะบอกคำตอบที่ผิดให้กับทุกคนที่รัน PostgreSQL บน Debian ซึ่งเป็น image หลักที่ Docker Hub แจก
scripts/pg-transaction-spike/up.sh ตรึง postgres:18-alpine ไว้ และบทความอื่นบนไซต์นี้ใช้มัน
probe ตัวใดก็ตามที่วัดเรื่อง ลำดับของข้อความ ผ่านสคริปต์นั้นจะได้คำตอบที่ไม่ตรงกับ PostgreSQL ที่คนส่วนใหญ่รัน — ข้อนี้ไม่กระทบ probe ที่วัด WAL หรือ opcode เพราะตัวเลขพวกนั้นไม่ได้ขึ้นกับ collation
หน้านี้จึงมี up-glibc.sh ของตัวเอง และวัดทั้งสองฝั่งแทนที่จะแก้สคริปต์เดิม
”ก็แค่ตั้ง collation สิ”
หัวข้อที่มีชื่อว่า “”ก็แค่ตั้ง collation สิ””ข้อแก้ตัวนี้ตายที่ SQLite ซึ่งไม่มี collation ให้ตั้งตั้งแต่แรก
sqlite3 ที่มากับ Python มี collation อยู่ 3 ตัว
คือ BINARY, NOCASE, RTRIM ทั้งสามตัวเรียงตาม codepoint เหมือนกันหมด
สั่ง COLLATE "th-TH-x-icu" ลงไปตรง ๆ แล้วมันตอบว่า
no such collation sequence: th-TH-x-icucompile_options ของมันไม่มี ICU อยู่เลย และนี่คือค่าปกติ
SQLite ที่แจกมากับ runtime ส่วนใหญ่ไม่ได้คอมไพล์ ICU มาด้วย เพราะ ICU ใหญ่กว่าตัว SQLite เอง
แปลว่าสำหรับ SQLite ทางแก้ไม่ใช่การตั้งค่า ทางเลือกมีสามทาง
- คอมไพล์ SQLite ใหม่โดยเปิด
SQLITE_ENABLE_ICU - ลงส่วนขยาย ICU แล้วโหลดตอน runtime
- ลงทะเบียน collation ของตัวเองจากฝั่งแอป แล้วเรียก ICU ที่นั่น
ทั้งสามทางไม่ใช่การแก้ config และไม่มีทางไหนทำได้จากใน SQL
ส่วน PostgreSQL ตั้งได้จริง เขียน COLLATE "th-TH-x-icu" ต่อท้าย ORDER BY
หรือประกาศ collation ไว้ที่ column หรือสร้างฐานข้อมูลด้วย ICU provider ตั้งแต่ต้น
ถ้าคุณย้ายจาก SQLite ไป PostgreSQL แล้วไม่เขียน COLLATE เพิ่ม
สิ่งที่ตัดสินลำดับให้คุณคือ base image ตามตารางข้างบน
ย้ายไป Debian แล้วลำดับเปลี่ยน ย้ายไป alpine แล้วลำดับเหมือนเดิม ทั้งสองกรณีคุณไม่ได้ตัดสินใจอะไรเกี่ยวกับภาษาไทยเลย
ลำดับไหนถูก
หัวข้อที่มีชื่อว่า “ลำดับไหนถูก”หน้านี้ไม่ตอบคำถามนั้น เพราะมันตอบด้วยการวัดไม่ได้
ลำดับที่ผ่าน ICU ไม่ได้ “ถูก” มันทำตามกฎ collation ที่ Unicode กับ CLDR กำหนดไว้ ส่วนลำดับ codepoint ก็ไม่ได้ “พัง” มันทำตามสิ่งที่มันสัญญาไว้เป๊ะ คือเทียบตัวเลขทีละตัว
สิ่งที่พูดได้อย่างมีหลักฐานมีสองข้อ
ข้อแรก ลำดับ codepoint ดันชื่อสระหน้าทั้ง 42 ชื่อไปกองท้ายเป็นบล็อกเดียว และเหตุผลคือสระหน้าถูกเขียนไว้ก่อนพยัญชนะที่มันออกเสียงตาม ซึ่งไม่ใช่เรื่องบังเอิญของคลังนี้
ข้อสอง ผู้อ่านภาษาไทยที่เปิดรายชื่อขึ้นมาแล้วเห็น ไกรสร อยู่ตำแหน่งที่ 194
จะรายงานว่าเป็นบั๊ก และเขาจะรายงานถูก เพราะไม่มีใครตั้งใจให้มันเป็นแบบนั้น
ถ้าคุณจะอ้างว่า “เรียงแบบพจนานุกรม” ในเอกสารของทีม ให้บอกด้วยว่าพจนานุกรมฉบับไหน ICU ทำตาม CLDR ซึ่งเป็นกฎชุดหนึ่ง ไม่ใช่กฎชุดเดียวที่มีอยู่
หนี้ที่หน้านี้มาจ่าย
หัวข้อที่มีชื่อว่า “หนี้ที่หน้านี้มาจ่าย”คอร์สหนึ่งบนไซต์นี้เขียนข้อจำกัดนี้ไว้เองเมื่อปีที่แล้ว แล้วไม่มีใครวัดมัน
บทเรียนเรื่อง access path ของคอร์ส query engine เขียนไว้ตรง ๆ ว่า
S_OFกับR_OFเก็บรหัสตามลำดับที่ประกาศไว้ใน code ลำดับนั้นยืนแทน collation ของ column จริง ไม่ใช่ลำดับพจนานุกรมภาษาไทย
ประโยคนั้นถูกต้อง และมันเป็นการยอมรับว่าของเล่นในบทเรียนนั้นเลี่ยงปัญหาไป แต่ไม่มีหน้าไหนบนไซต์นี้เคยวัดว่าปัญหาที่เลี่ยงไปนั้นใหญ่แค่ไหน
ตอนนี้วัดแล้ว 188 จาก 200 ตำแหน่ง
อ่านบทเรียนนั้นได้ที่ access path ของ query engine ส่วนเรื่องภาษาไทยกับ code ในแง่มุมอื่น อยู่ที่ ภาษาไทยทำให้ code ของคุณพัง
สิ่งที่ควรทำต่อ
หัวข้อที่มีชื่อว่า “สิ่งที่ควรทำต่อ”สามข้อ เรียงตามลำดับที่ให้ผลเร็วที่สุด
เขียนลำดับที่ต้องการลงไปตรง ๆ อย่าปล่อยให้ค่า default ตัดสิน
ใน PostgreSQL คือ COLLATE ที่ column หรือที่ query ใน .NET คือระบุ StringComparison ทุกครั้ง
ตรึง base image ไว้ให้แน่น ถ้าลำดับสำคัญกับแอปของคุณ การเปลี่ยนจาก alpine เป็น Debian คือการเปลี่ยนพฤติกรรม ไม่ใช่แค่การเปลี่ยนขนาดของ image
ทดสอบด้วยชื่อที่ขึ้นต้นด้วยสระหน้า ชุดทดสอบที่มีแต่ สมชาย กับ สมหญิง จะเขียวตลอด
ใส่ ไกรสร เกษม แก้วตา ใจดี ลงไป แล้วเทสต์จะเริ่มบอกความจริง
ชื่อไทย 200 ชื่อ เรียง 13 ทาง ได้ 2 ลำดับ ต่างกัน 188 ตำแหน่ง ตรงกันแค่ 12 ตำแหน่ง
สิ่งที่ตัดสินว่าคุณได้ลำดับไหน ไม่ใช่เอนจิน และไม่ใช่การตัดสินใจเรื่องภาษาไทย
ตัวเลขทุกตัวมาจาก scripts/thai-collation/ และคลังชื่อถูกตรึงด้วย sha256
แขน SQLite วัดสดทุกครั้งที่เกตของรีโปรัน ส่วนแขน PostgreSQL กับ .NET ถูกตรึงไว้ เพราะ runner ของ CI ไม่มี Docker
SQLite 3.53.4 · PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) · .NET 10.0.10 บน macOS 26.6.1