connection ที่ถูกคืนเข้า pool พกค่าของผู้เช่าคนก่อนติดไปด้วย
หกบทแรกวัดอยู่ใน psql session เดียว ซึ่งเป็นที่ที่ค่าตั้งของ session ไม่มีวันเดินข้ามไปหาใคร เพราะไม่มีใครอื่นอยู่ในนั้น
แอปจริงไม่ทำงานแบบนั้น มันเก็บ connection ไว้ใช้ซ้ำ คำขอของผู้เช่ารายหนึ่งจบลง connection กลับเข้าที่เก็บ แล้วคำขอถัดไปหยิบใบเดิมนั้นออกมาใช้ต่อ
บทนี้ยิงสองคำขอติดกัน คำขอที่สองไม่ได้ส่งผู้เช่ามาเลยสักช่อง มันได้ 200 และ 3 แถว และสามแถวนั้นเป็นแถวของผู้เช่าในคำขอแรก
หกบทแรกเป็น SQL ล้วน บทนี้เป็นบทเดียวที่ยก Spring Boot 4.1.1 กับ Kotlin 2.4.10 ขึ้นมาเป็น process จริง แล้วยิงคำขอ HTTP ใส่มันจากข้างนอก ลำดับของคำขอคือหลักฐาน จึงต้องคุมลำดับนั้นจากข้างนอกแอป
ตัวเลขทุกตัวในบทนี้คุณรันเองได้ สั่ง scripts/clinic-rls/up.sh ซึ่งยก container
ชื่อ kaen-rls-pg ขึ้นที่ port 55438 แล้วสั่ง python3 scripts/clinic-rls/probe.py
ผลที่ตรึงไว้อยู่ที่ src/data/perf/clinic-rls.ts ซึ่งเป็นของที่ถูกสร้าง ไม่ใช่ของที่พิมพ์มือ
เก็บกวาดด้วย docker rm -f kaen-rls-pg
ผู้เช่ามาทาง header ไม่ได้มาทาง SecurityContext
หัวข้อที่มีชื่อว่า “ผู้เช่ามาทาง header ไม่ได้มาทาง SecurityContext”คำถามของบทนี้คือค่าที่ตั้งไว้อยู่ได้นานแค่ไหน ไม่ใช่ตัวตนถูกยืนยันอย่างไร
คอร์สคัดกรองผู้เรียก กับ คอร์ส cache
พาผู้เช่ามาทาง SecurityContext ซึ่งเป็นผลของการยืนยันตัวตนที่ทำไปแล้ว
เวทีของบทนี้ตั้งใจไม่ทำแบบนั้น ผู้เช่ามาทาง header ชื่อ X-Tenant ตรงๆ
ใครส่งอะไรมาก็ได้ค่านั้น
ความต่างนี้ต้องเขียนออกมา ไม่ใช่ปล่อยผ่าน เพราะมันเปลี่ยนว่าบทนี้พิสูจน์อะไร บทนี้ไม่ได้พิสูจน์ว่าการยืนยันตัวตนแน่นหนาแค่ไหน มันพิสูจน์ว่าค่าที่ handler ตั้งลงไปในคำขอใบหนึ่ง ยังอยู่ให้คำขอใบถัดไปใช้หรือไม่
สาม pool ในแอปตัวเดียว ต่างกันสามโทเค็น
หัวข้อที่มีชื่อว่า “สาม pool ในแอปตัวเดียว ต่างกันสามโทเค็น”code ของ handler เหมือนกันทุก byte ทั้งสามทาง สิ่งที่ต่างอยู่ในบรรทัดที่ประกาศ pool
connection poolconnection poolที่เก็บ connection ไว้ใช้ซ้ำข้ามคำขอ · connection ที่ถูกคืนเข้ามาพก GUC ติดไปด้วย · ในทรง plain-then-no-tenant ของเวที B คำขอที่สองซึ่งไม่ได้ส่งผู้เช่ามาเลย ได้ 200 พร้อม 3 แถวของผู้เช่าคนก่อน · ทรง local-then-no-tenant ที่ใช้ SET LOCAL และทรง reset-then-no-tenant ที่ล้างค่าตอนคืน connection ได้ 200 พร้อม 0 แถว ในเวทีนี้มีสามตัว อยู่ในแอปตัวเดียวกัน และผูกกับฐานข้อมูลใบเดียวกัน
probe.py ยกสามบรรทัดที่ประกาศมันมาเก็บไว้เป็นค่าที่วัดได้ตัวหนึ่ง
private fun hikari(name: String): HikariDataSource { val cfg = HikariConfig() cfg.jdbcUrl = url cfg.username = user cfg.password = pass cfg.poolName = name cfg.maximumPoolSize = 1 cfg.minimumIdle = 1 return HikariDataSource(cfg)}
val plain = Pool(hikari("plain"), sessionScope = true, resetOnRelease = false)val local = Pool(hikari("local"), sessionScope = false, resetOnRelease = false)val reset = Pool(hikari("reset"), sessionScope = true, resetOnRelease = true)sessionScope ตัดสินอาร์กิวเมนต์ตัวที่สามของ set_config ซึ่งเป็นตัวเดียวกับที่บทที่ 2
วัดอายุของมันเอาไว้แล้ว
private fun setTenant(p: Pool, tenant: String) { p.jdbc.queryForObject("select set_config('app.tenant', ?, ?)", String::class.java, tenant, !p.sessionScope)}plainตั้งค่าแบบSETธรรมดา ค่าอยู่ที่ระดับ session และ commit แล้วยังอยู่localตั้งค่าแบบ SET LOCALSET LOCALรูปที่ผูกอายุของค่าไว้กับ transaction · ทุกค่าต่อจากนี้วัดบนตาราง t_guc ใน session เดียว · ใน transaction เห็น 3 แถว หลัง commit เหลือ 0 แถว · ส่วน SET ธรรมดา หลัง commit ยังเห็น 3 แถว และ rollback คืนค่าก่อนหน้าคือ clinic-a ไม่ใช่คืนสถานะที่ไม่เคยตั้ง ค่าผูกอายุไว้กับ transactionresetใช้ handler ชุดเดียวกับplainแต่สั่งRESET app.tenantตอนคืน connection
คำขอที่สองไม่ได้ส่งผู้เช่ามาเลย
หัวข้อที่มีชื่อว่า “คำขอที่สองไม่ได้ส่งผู้เช่ามาเลย”handler ตัวที่สองไม่ได้ทำอะไรผิดรูป มันแค่ไม่ได้ตั้งค่าอะไรก่อนอ่าน
@GetMapping("/{pool}/visits")fun read(@PathVariable pool: String, @RequestHeader("X-Tenant") tenant: String): Map<String, Any?> { val p = pools.byName(pool) return p.tx.execute { _ -> setTenant(p, tenant) rows(p) }!!}
@GetMapping("/{pool}/visits-no-tenant")fun readNoTenant(@PathVariable pool: String): Map<String, Any?> { val p = pools.byName(pool) return p.tx.execute { _ -> rows(p) }!!}ตารางของเวทีนี้หว่านไว้หกแถว สามแถวเป็นของ clinic-a ชื่อ Ann Ben Cid
และอีกสามแถวเป็นของ clinic-b ชื่อ Dan Eve Fay
| ทรง | คำขอที่ 2 ตั้งผู้เช่าไหม | รหัส HTTP | แถวที่อ่านได้ | ชื่อที่ได้กลับมา |
|---|---|---|---|---|
plain-then-no-tenant | ไม่ตั้ง | 200 | 3 | Ann Ben Cid |
local-then-no-tenant | ไม่ตั้ง | 200 | 0 | ไม่มี |
reset-then-no-tenant | ไม่ตั้ง | 200 | 0 | ไม่มี |
fresh-no-tenant | ไม่ตั้ง · connection ที่ไม่เคยมีใครตั้งค่าให้ | 200 | 0 | ไม่มี |
แถวแรกคือการรั่ว คำขอแรกในลำดับนั้นส่ง X-Tenant: clinic-a มา แล้วคำขอที่สอง
ซึ่งไม่ได้ส่งอะไรมาเลย ได้ชื่อสามชื่อของ clinic-a กลับไปครบ
รหัส HTTP ของทั้งสี่ทรงเท่ากันหมด ช่องเดียวที่แยกการรั่วออกจากทรงที่ไม่รั่ว คือจำนวนแถว ซึ่งเป็นหน่วยเดียวกับที่หกบทก่อนหน้าใช้มาตลอด
ทรงตัวคุมคือ plain-both-set กับ local-both-set ซึ่งทุกคำขอตั้งผู้เช่าของตัวเอง
ทั้งสองทรงได้ 200 และ 3 แถว ทั้งฝั่ง a และฝั่ง b และฝั่ง b ได้ Dan Eve Fay
ไม่ใช่ Ann Ben Cid
หมุดที่ตรึง pool ไว้ที่ connection เดียว
หัวข้อที่มีชื่อว่า “หมุดที่ตรึง pool ไว้ที่ connection เดียว”การตรึงทำให้ข้ออ้างนิ่ง มันไม่ใช่ตัวที่สร้างการรั่ว และมันต้องถูกเขียนออกมา ไม่ใช่ซ่อนไว้
pool ที่ใหญ่กว่าหนึ่ง ทำให้การรั่วกลายเป็นเรื่องของความน่าจะเป็น คำขอที่สองอาจได้ connection ใบเดิม หรืออาจได้ใบใหม่ ตัวเลขจึงขยับได้ระหว่างรอบ เวทีนี้ตรึงไว้ที่หนึ่ง เพื่อให้ทุกค่าที่พิมพ์ออกมาซ้ำได้
หลักฐานของการตรึงมีสามชั้น และทั้งสามชั้นเป็นค่าที่วัดได้
| ที่วัด | คีย์ | ค่า |
|---|---|---|
| ค่าที่ตั้งไว้ให้ Hikari · ทั้งสาม pool | POOL | maximumPoolSize 1 · minimumIdle 1 |
| connection ที่ pool ถืออยู่จริงเมื่อยิงครบชุด | POOLAFTER | totalConnections 1 |
| backend ที่ต่างกัน ในสามคำขอติดกัน | PINEVIDENCE | distinctPidsBeforeRecycle 1 |
| backend เปลี่ยนไหม เมื่อไล่ connection ทิ้ง | PINEVIDENCE | pidChangedAtRecycle true |
ชั้นที่หนึ่งกับชั้นที่สองไม่ใช่ชั้นเดียวกัน ชั้นแรกคือค่าที่ตั้งไว้ ชั้นที่สองคือจำนวนที่มีอยู่จริง
ชั้นที่สามอ่าน pg_backend_pid() ของทุกคำขอ แล้วนับว่าได้เลขต่างกันกี่เลข
distinctPids ไม่ไวต่อขนาดของ pool การขยาย pool ให้ใหญ่กว่าหนึ่ง
ไม่ได้ทำให้ค่านี้ขยับ เพราะ client ที่ยิงทีละคำขอ ได้ connection ใบเดิมกลับมาอยู่ดี
ด่านที่มีแต่ชั้นที่สาม จึงเป็นด่านที่เขียวต่อไปได้ทั้งที่หมุดหลุด
POOLAFTER คือชั้นที่ปิดช่องนั้น มันอ่านจำนวน connection ที่ Hikari ถืออยู่จริง
หลังยิงครบทุกทรง แล้วได้ 1 ทั้งสาม pool
PINEVIDENCE ยังเก็บอีกสองค่า คือ countsBeforeRecycle ซึ่งเป็น 3 ทั้งสามคำขอ
และ countAfterRecycle ซึ่งเป็น 3 เท่ากัน สองค่านี้ยืนยันว่าการไล่ connection ทิ้ง
ไม่ได้เปลี่ยนสิ่งที่ผู้เช่ารายเดิมอ่านได้ ส่วนข้อที่ว่า backend เปลี่ยนจริง
อ่านจาก pidChangedAtRecycle ในแถวก่อนหน้า ไม่ใช่จากสองค่านี้
ฝั่งเขียน สามทรงที่ HTTP มองเห็นเหมือนกันหมด
หัวข้อที่มีชื่อว่า “ฝั่งเขียน สามทรงที่ HTTP มองเห็นเหมือนกันหมด”ฝั่งอ่านแยกกันได้ด้วยจำนวนแถว ฝั่งเขียนแยกกันไม่ได้เลยจากสิ่งที่คำขอเห็น
ตารางของเวทีนี้ตั้ง DEFAULT ของ tenant_id ให้อ่าน GUCGrand Unified Configuration (GUC)ตัวแปรตั้งค่าที่ผูกกับ session ซึ่ง policy ของคอร์สนี้อ่านคีย์ของผู้เช่าจากมัน · app role อ่านตาราง t_app ได้ 3 แถวเมื่อตั้งเป็นผู้เช่า a และ 3 แถวเมื่อตั้งเป็น b · ไม่ตั้งเลยได้ 0 แถว และผู้เช่าที่ไม่มีอยู่จริงก็ได้ 0 แถว ซึ่งเป็นจำนวน ไม่ใช่ error ตัวเดียวกับที่ policy อ่าน
ซึ่งเป็นรูปที่ตำราของ RLS สอน คำสั่ง INSERT จึงไม่เอ่ยถึงผู้เช่าเลย
create table rls_visit ( id bigserial primary key, tenant_id text not null default current_setting('app.tenant', true), patient text not null);alter table rls_visit enable row level security;alter table rls_visit force row level security;create policy p on rls_visit for all using (tenant_id = current_setting('app.tenant', true));@PostMapping("/{pool}/visits-no-tenant")fun writeNoTenant(@PathVariable pool: String, @RequestParam patient: String): Map<String, Any?> { val p = pools.byName(pool) return p.tx.execute { _ -> mapOf<String, Any?>("inserted" to insert(p, patient), "pid" to pid(p)) }!!}
private fun insert(p: Pool, patient: String): Int = p.jdbc.update("insert into rls_visit (patient) values (?)", patient)ลำดับเดียวกันทั้งสาม pool คือคำขอของ clinic-a เขียน Gil ลงหนึ่งแถว
แล้วคำขอที่ไม่ตั้งผู้เช่าเลย เขียน Hal ต่อจากนั้น
| ทรง | รหัสของคำขอที่เขียน Hal | แถวที่เขียนลง | Hal ไปอยู่ใต้ผู้เช่าไหน | ยอดรวมในฐาน |
|---|---|---|---|---|
write-plain | 200 | 1 | <clinic-a> | 8 |
write-local | 200 | 1 | <> | 8 |
write-reset | 200 | 1 | <> | 8 |
สามช่องซ้ายเท่ากันทั้งสามแถว รหัส HTTP เท่ากัน จำนวนแถวที่เขียนลงเท่ากัน
และยอดรวมในฐานเท่ากัน ช่องเดียวที่แยกทรงที่รั่วออกจากสองทรงที่ถือว่าแก้แล้ว
คือ tenant_id ของแถวที่เพิ่งลงไป ซึ่งไม่มีคำขอไหนได้เห็น
ยอดที่ผู้เช่าสองรายเห็นรวมกัน น้อยกว่ายอดในฐาน
หัวข้อที่มีชื่อว่า “ยอดที่ผู้เช่าสองรายเห็นรวมกัน น้อยกว่ายอดในฐาน”ทรงที่แก้แล้วไม่ได้หยุดการเขียน มันย้ายแถวไปไว้ในที่ที่ไม่มีใครอ่านเจอ
probe.py ปิดท้ายทุกทรงด้วยการอ่านของผู้เช่าทั้งสองราย แล้วเทียบกับยอดจริงในฐาน
ที่อ่านโดย postgres ซึ่งข้าม RLS
| ทรง | a เห็น | b เห็น | รวมที่สองรายเห็น | ยอดจริงในฐาน |
|---|---|---|---|---|
write-plain | 5 | 3 | 8 | 8 |
write-local | 4 | 3 | 7 | 8 |
write-reset | 4 | 3 | 7 | 8 |
แถวแรกคือทรงที่รั่ว ทุกแถวยังมีเจ้าของ แค่เจ้าของผิดคน Hal ตกไปอยู่กับ clinic-a
ซึ่งเป็นผู้เช่าของคำขอก่อนหน้า
สองแถวล่างคือทรงที่คนเรียกว่าแก้แล้ว และมันเป็นแถวที่อ่านยากกว่า ผลบวกของสิ่งที่สองรายเห็น น้อยกว่ายอดในฐานอยู่หนึ่งแถว
probe.py ไล่ค่าของ tenant_id ทุกค่าที่มีอยู่จริง ไม่ใช่แค่สองชื่อที่หว่านไว้
สองทรงล่างจึงได้สามกลุ่มกลับมา คือ <> 1 กับ <clinic-a> 4 และ <clinic-b> 3
แถวที่ตกใต้สตริงว่าง ไม่มีผู้เช่ารายไหนมองเห็น ถ้าถามแค่สองชื่อที่รู้จัก มันจะหายไปเงียบๆ และผลต่างของยอดรวมกับผลบวกของสองชื่อ คือที่เดียวที่มันโผล่
RESET ไม่ได้หยุดการเขียน มันย้ายที่เขียน
หัวข้อที่มีชื่อว่า “RESET ไม่ได้หยุดการเขียน มันย้ายที่เขียน”ข้อนี้ต่อตรงจากบทที่ 2 และเป็นเหตุผลที่แถวไปตกใต้สตริงว่างแทนที่จะถูกปฏิเสธ
บทที่ 2
วัดไว้แล้วว่า RESET ไม่ได้คืนสถานะ ไม่เคยตั้ง กลับมา
| สถานะของ session | คีย์ของเวที A | ค่าที่วัดได้ |
|---|---|---|
| ไม่เคยตั้งเลย | csTrueNeverSet | <NULL> |
ตั้งแล้วสั่ง RESET | csTrueAfterReset | สตริงว่าง |
ตั้งแล้วสั่ง RESET · ความยาวของค่า | csTrueAfterResetLen | 0 |
สองสถานะนั้นให้ค่าคนละตัว และความต่างเดินมาโผล่ที่ DEFAULT ของ tenant_id พอดี
- connection ที่ไม่เคยตั้งค่า DEFAULT ได้
NULLแล้วนิพจน์ของ policy เทียบNULLกับNULLซึ่งไม่เป็นจริง แถวจึงถูกปฏิเสธ - connection ที่ค่าถูกล้างไปแล้ว DEFAULT ได้สตริงว่าง แล้วนิพจน์เทียบสตริงว่าง กับสตริงว่าง ซึ่งเป็นจริง แถวจึงลงตาราง
ทรง local เดินมาที่จุดเดียวกันด้วยเหตุผลเดียวกัน SET LOCAL หมดอายุตอน commit
แล้วค่าที่เหลืออยู่ใน session ก็เป็นสตริงว่างเช่นกัน
นี่คือเหตุผลที่ write-local กับ write-reset ให้ค่าเท่ากันทุกช่อง
ทั้งสองทรงไม่ได้กันการเขียน ทั้งสองทรงเปลี่ยนผู้เช่าที่แถวไปตก
connection ที่ไม่เคยตั้งค่า ปฏิเสธการเขียนจริง
หัวข้อที่มีชื่อว่า “connection ที่ไม่เคยตั้งค่า ปฏิเสธการเขียนจริง”ทรงคุมสองทรงที่แสดงว่าการปฏิเสธหน้าตาเป็นอย่างไร เมื่อมันเกิดขึ้นจริง
| ทรง | รหัส HTTP | แถวที่เขียนลง | SQLSTATE | ยอดรวมในฐาน |
|---|---|---|---|---|
write-fresh · ปล่อยให้พังทะลุออกไป | 500 | ไม่มีค่า | ไม่ได้อ่าน | 6 |
write-caught-fresh · handler ดักไว้ | 200 | 0 | 42501 | 6 |
แถวบนคือรูปที่คนคาดหวัง คำขอพัง แถวไม่ลง ยอดในฐานยังเป็นหกแถวเท่าเดิม
แถวล่างคือรูปที่อันตรายกว่า handler ที่ดักข้อผิดพลาดไว้ คืน 200 ออกไป ทั้งที่เขียนลง 0 แถว ผู้เรียกจึงได้คำตอบที่อ่านเหมือนสำเร็จ
42501 บอกว่าแถวชน policy ก่อน ไม่ใช่ชน NOT NULL ของ tenant_id
ทั้งที่ค่าที่ DEFAULT ผลิตออกมาในทรงนี้เป็น NULL
รหัสที่แยกได้ ในที่ที่จำนวนแถวแยกไม่ออก
หัวข้อที่มีชื่อว่า “รหัสที่แยกได้ ในที่ที่จำนวนแถวแยกไม่ออก”handler ที่เข้มงวดขึ้นหนึ่งตัวอักษร แยกสอง connection ที่จำนวนแถวเท่ากันออกจากกันได้
@GetMapping("/{pool}/visits-strict")fun readStrict(@PathVariable pool: String): Map<String, Any?> { val p = pools.byName(pool) return p.tx.execute { _ -> p.jdbc.queryForObject("select current_setting('app.tenant')", String::class.java) rows(p) }!!}| ทรง | สถานะของ connection | รหัสของ handler ที่เข้มงวด | SQLSTATE ที่ดักได้ | อ่านแบบไม่ตั้งผู้เช่า |
|---|---|---|---|---|
strict-fresh | ไม่เคยมีใครตั้งค่าให้ | 500 | 42704 | 200 · 0 แถว |
strict-after-reset | เคยตั้ง แล้วถูก RESET | 200 | 00000 | 200 · 0 แถว |
ช่องขวาสุดเท่ากันทั้งสองแถว จำนวนแถวจึงบอกไม่ได้ว่า connection ใบไหนสะอาดจริง และใบไหนแค่ถูกล้างค่าทิ้ง
ช่องกลางแยกได้ เพราะ current_setting ที่ไม่ใส่อาร์กิวเมนต์ที่สอง ยกรหัสออกมา
เมื่อไม่มีใครเคยตั้งค่านั้น แต่ไม่ยกอะไรเลยเมื่อค่าเป็นสตริงว่าง
strict-after-reset ยังอ่านค่าอีกช่องไว้ด้วย คือคำขอแรกของลำดับนั้น
ซึ่งส่ง X-Tenant: clinic-a มา และได้ 200 พร้อม 3 แถว ตามปกติ
HikariCP ไม่มี hook ตอนคืน connection
หัวข้อที่มีชื่อว่า “HikariCP ไม่มี hook ตอนคืน connection”ทรง reset ในเวทีนี้ไม่ใช่ของที่ pool ให้มา มันเป็นชั้นที่แอปเขียนขึ้นเอง
connectionInitSql ของ HikariCP ทำงานตอน สร้าง connection ใหม่ ไม่ใช่ตอนคืน
ข้อนี้เป็นข้ออ้างเรื่องกลไก จึงอ้างเอกสารของ HikariCP ไม่ได้อ้าง probe
ทรง reset จึงทำด้วย DataSource ที่ห่อ Hikari ไว้อีกชั้น แล้วห่อ Connection
ด้วย proxy ที่ดัก close() เอาไว้
private fun wrap(real: Connection): Connection = Proxy.newProxyInstance( javaClass.classLoader, arrayOf(Connection::class.java) ) { _, method, args -> if (method.name == "close") { real.createStatement().use { it.execute("RESET app.tenant") } real.close() null } else { method.invoke(real, *(args ?: emptyArray())) } } as Connectionยาแก้ตัวนี้จึงไม่ได้มาฟรี มันเป็น code ที่ทีมต้องเขียนเอง ดูแลเอง และตัวเลขข้างบนบอกแล้วว่าเขียนไปแล้วก็ยังไม่ได้ปิดฝั่งเขียน
สามเส้นทางของ connection ใบเดียว
หัวข้อที่มีชื่อว่า “สามเส้นทางของ connection ใบเดียว”ทั้งสามเส้นเริ่มที่คำขอเดียวกัน และแยกกันตรงที่ค่าของ GUC เหลืออะไรตอนคืน connection
flowchart TB
R1["คำขอที่ 1 · X-Tenant clinic-a · handler สั่ง set_config"] --> R2{"pool ไหน"}
R2 -->|"plain · SET ธรรมดา"| P1["คืน connection · ค่ายังเป็น clinic-a"]
R2 -->|"local · SET LOCAL"| L1["commit จบ transaction · ค่าเหลือสตริงว่าง"]
R2 -->|"reset · proxy สั่ง RESET ก่อน close"| S1["ค่าเหลือสตริงว่าง"]
P1 --> P2["อ่านโดยไม่ส่งผู้เช่า · 200 และ 3 แถวของ clinic-a · plain-then-no-tenant"]
L1 --> L2["อ่านโดยไม่ส่งผู้เช่า · 200 และ 0 แถว · local-then-no-tenant"]
S1 --> S2["อ่านโดยไม่ส่งผู้เช่า · 200 และ 0 แถว · reset-then-no-tenant"]
P2 --> W1["เขียนโดยไม่ส่งผู้เช่า · แถวไปอยู่ใต้ clinic-a · write-plain"]
L2 --> W2["เขียนโดยไม่ส่งผู้เช่า · แถวไปอยู่ใต้สตริงว่าง · write-local และ write-reset"]
S2 --> W2
W1 --> E1["a เห็น 5 · b เห็น 3 · ยอดในฐาน 8"]
W2 --> E2["a เห็น 4 · b เห็น 3 · ยอดในฐาน 8 · หนึ่งแถวไม่มีใครเห็น"]
คำบรรยายภาพ: เส้นทางของ connection ใบเดียวกัน แยกออกเป็นสามเส้นตรงจุดที่คืนมันเข้า pool สองเส้นล่างปิดฝั่งอ่านได้ แต่ปลายทางของฝั่งเขียนเหลือแถวที่ไม่มีผู้เช่ารายไหนมองเห็น
หน่วยเดียวกันนี้ มีจุดบอดสองจุดแล้ว
หัวข้อที่มีชื่อว่า “หน่วยเดียวกันนี้ มีจุดบอดสองจุดแล้ว”บทที่ 6 ชี้จุดบอดจุดแรก บทนี้ชี้จุดที่สอง และทั้งสองจุดอ่านเหมือนกันจากข้างนอก
บทที่ 6 แสดงว่ายอดรวมค้างที่เดิมได้ ทั้งที่ทุกแถวที่เห็นเปลี่ยนเจ้าของ
บทนี้แสดงอีกรูปหนึ่งของเรื่องเดียวกัน ยอดรวมในฐานเป็น 8 เท่ากันทั้งสามทรงการเขียน ทั้งทรงที่รั่วและทรงที่ถือว่าแก้แล้ว
ด่านที่เฝ้าแต่ยอดรวม ผ่านทั้งสองกรณีไปอย่างสะอาด สิ่งที่จับได้คือการกระจายของ
tenant_id และผลต่างระหว่างยอดในฐานกับผลบวกของสิ่งที่ผู้เช่าแต่ละรายเห็น
ผลทั้งหมดในบทนี้วัดบน PostgreSQL 18.6 บน aarch64-unknown-linux-musl
กับ Spring Boot 4.1.1 และ Kotlin 2.4.10 ใน container ของคอร์สนี้เอง
probe.py รันชุดทรงทั้งชุด 3 รอบ ต่อการเรียกหนึ่งครั้ง แล้วเทียบผลทีละคีย์
รอบที่ตรึงลง src/data/perf/clinic-rls.ts ได้รายการค่าที่ขยับระหว่างรอบเป็นรายการว่าง
role ที่แอปต่อฐานข้อมูลจริงคือ kaen_pool_app ซึ่ง probe ยืนยันก่อนวัดว่า
isSuperuser เป็น false และ hasBypassRls เป็น false ตารางที่วัดมี policyCount
เป็น 1 และมีทั้ง rlsEnabled และ rlsForced เป็น true
หน่วยของหลักฐานในบทนี้เป็นจำนวนนับกับรหัสเท่านั้น คือรหัส HTTP จำนวนแถวที่อ่านได้ จำนวนแถวที่เขียนลง รหัส SQLSTATE ธงบูลีน จำนวน policy และจำนวน connection ที่ pool ถืออยู่ ทั้งบทไม่มีหน่วยเวลาสักตัว
คำถามที่บทนี้ทิ้งไว้
หัวข้อที่มีชื่อว่า “คำถามที่บทนี้ทิ้งไว้”เจ็ดบทวัดครบแล้ว เหลือคำถามว่าผู้อ่านพูดอะไรได้บ้าง โดยชี้ไปที่ตัวเลขได้ทุกคำ
บทนี้ปิดคำถามที่บทที่ 1 เปิดไว้ว่าค่าที่ policy อ่านถูกเก็บไว้ที่ไหน คำตอบคือมันถูกเก็บไว้กับ connection และ connection ถูกใช้ซ้ำ
แต่คำตอบนั้นยังไม่ใช่สัญญา สัญญาต้องบอกว่าอะไรกันได้ อะไรกันไม่ได้ และแต่ละข้อพิสูจน์ด้วยตัวเลขตัวไหนของบทไหน
บทที่ 8 รวบทั้งเจ็ดบทเป็นตารางสัญญาใบเดียว โดยไม่สร้างข้ออ้างใหม่สักข้อ และแถวที่ระบบยังทำไม่ได้ ก็ต้องอยู่ในตารางนั้นด้วย
-
PostgreSQL 18 —
SET(เข้าถึง 2026-09-06) — ความหมายของSET LOCALและอายุของค่าที่ผูกกับ transaction -
PostgreSQL 18 —
RESET(เข้าถึง 2026-09-06) — ความหมายของการล้างค่าตั้งกลับไปที่ค่าปริยาย -
PostgreSQL 18 — System Administration Functions (เข้าถึง 2026-09-06) —
set_configกับอาร์กิวเมนต์is_localและสองรูปของcurrent_setting -
HikariCP (เข้าถึง 2026-09-06) — ที่มาของข้ออ้างว่า
connectionInitSqlทำงานตอนสร้าง connection -
ตัวเลขทุกตัวในบทนี้มาจาก
scripts/clinic-rls/probe.pyซึ่งยกแอปขึ้นเป็น process จริง แล้วตรึงผลไว้ที่src/data/perf/clinic-rls.ts· ค่าของเวที A ที่บทนี้อ้างถึง มาจากscripts/clinic-rls/spike.py· ทั้งสองต้องสั่งscripts/clinic-rls/up.shก่อน
เช็กความเข้าใจ — บทที่ 7
ข้อ 1 / 3คำขอแรกส่ง `X-Tenant: clinic-a` มา แล้วคำขอที่สองบน connection เดิม ไม่ส่งผู้เช่ามาเลย ใต้ทรง `plain` ผลเป็นอย่างไร