ข้าม​ไป​ยัง​เนื้อหา

connection ที่​ถูก​คืน​เข้า pool พก​ค่า​ของ​ผู้​เช่า​คน​ก่อน​ติด​ไป​ด้วย

หกบท​แรก​วัด​อยู่​ใน psql session เดียว ซึ่ง​เป็น​ที่​ที่​ค่า​ตั้ง​ของ session ไม่มี​วัน​เดิน​ข้าม​ไป​หา​ใคร เพราะ​ไม่มี​ใคร​อื่น​อยู่​ใน​นั้น

แอป​จริง​ไม่​ทำงาน​แบบ​นั้น มัน​เก็บ connection ไว้​ใช้​ซ้ำ คำขอ​ของ​ผู้​เช่า​ราย​หนึ่ง​จบ​ลง connection กลับ​เข้า​ที่​เก็บ แล้ว​คำขอ​ถัด​ไป​หยิบ​ใบ​เดิม​นั้น​ออก​มา​ใช้​ต่อ

บท​นี้​ยิง​สอง​คำขอ​ติด​กัน คำขอ​ที่​สอง​ไม่​ได้​ส่ง​ผู้​เช่า​มา​เลย​สัก​ช่อง มัน​ได้ 200 และ 3 แถว และ​สาม​แถว​นั้น​เป็น​แถว​ของ​ผู้​เช่า​ใน​คำขอ​แรก

📦 clinic — บท​เดียว​ใน​คอร์ส​ที่​มี​แอป​จริง​อยู่​ใน​ทรง

หกบท​แรก​เป็น 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

คำถาม​ของ​บท​นี้​คือ​ค่าที่​ตั้ง​ไว้​อยู่​ได้​นาน​แค่​ไหน ไม่ใช่​ตัว​ตน​ถูก​ยืนยัน​อย่างไร

คอร์ส​คัด​กรอง​ผู้​เรียก กับ คอร์ส cache พา​ผู้​เช่า​มา​ทาง SecurityContext ซึ่ง​เป็น​ผล​ของ​การ​ยืนยัน​ตัว​ตน​ที่​ทำ​ไป​แล้ว

เวที​ของ​บท​นี้​ตั้งใจ​ไม่​ทำ​แบบ​นั้น ผู้​เช่า​มา​ทาง header ชื่อ X-Tenant ตรงๆ ใคร​ส่ง​อะไร​มา​ก็ได้​ค่า​นั้น

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

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 ยก​สาม​บรรทัด​ที่​ประกาศ​มัน​มา​เก็บ​ไว้​เป็น​ค่าที่​วัด​ได้​ตัว​หนึ่ง

สาม pool ใน​แอป​ตัว​เดียว · ทุก​ตัว​ถูก​ตรึง​ไว้​ที่ connection เดียว
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 วัด​อายุ​ของ​มัน​เอา​ไว้​แล้ว

handler ตั้ง​ค่า​ผู้​เช่า · sessionScope = true คือ SET ธรรมดา
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 ไม่ใช่​คืน​สถานะ​ที่​ไม่​เคย​ตั้ง ค่า​ผูก​อายุ​ไว้​กับ transaction
  • reset ใช้ 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ไม่​ตั้ง2003Ann Ben Cid
local-then-no-tenantไม่​ตั้ง2000ไม่มี
reset-then-no-tenantไม่​ตั้ง2000ไม่มี
fresh-no-tenantไม่​ตั้ง · connection ที่​ไม่​เคย​มี​ใคร​ตั้ง​ค่า​ให้2000ไม่มี

แถว​แรก​คือ​การ​รั่ว คำขอ​แรก​ใน​ลำดับ​นั้น​ส่ง 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 ใบ​เดิม หรือ​อาจ​ได้​ใบ​ใหม่ ตัวเลข​จึง​ขยับ​ได้​ระหว่าง​รอบ เวที​นี้​ตรึง​ไว้​ที่​หนึ่ง เพื่อ​ให้​ทุก​ค่าที่​พิมพ์​ออก​มา​ซ้ำ​ได้

หลัก​ฐาน​ของ​การ​ตรึง​มี​สาม​ชั้น และ​ทั้ง​สาม​ชั้น​เป็น​ค่าที่​วัด​ได้

ที่​วัดคีย์ค่า
ค่าที่​ตั้ง​ไว้​ให้ Hikari · ทั้ง​สาม poolPOOLmaximumPoolSize 1 · minimumIdle 1
connection ที่ pool ถือ​อยู่​จริง​เมื่อ​ยิง​ครบ​ชุดPOOLAFTERtotalConnections 1
backend ที่​ต่าง​กัน ใน​สาม​คำขอ​ติด​กันPINEVIDENCEdistinctPidsBeforeRecycle 1
backend เปลี่ยน​ไหม เมื่อ​ไล่ connection ทิ้งPINEVIDENCEpidChangedAtRecycle true

ชั้น​ที่​หนึ่ง​กับ​ชั้น​ที่​สอง​ไม่ใช่​ชั้น​เดียวกัน ชั้น​แรก​คือ​ค่าที่​ตั้ง​ไว้ ชั้น​ที่​สอง​คือ​จำนวน​ที่​มี​อยู่​จริง ชั้น​ที่​สาม​อ่าน pg_backend_pid() ของ​ทุก​คำขอ แล้ว​นับ​ว่า​ได้​เลข​ต่าง​กัน​กี่​เลข

ทำไม​ชั้น​ที่​สอง​ถึง​ต้อง​มี

distinctPids ไม่​ไว​ต่อ​ขนาด​ของ pool การ​ขยาย pool ให้​ใหญ่​กว่า​หนึ่ง ไม่​ได้​ทำให้​ค่า​นี้​ขยับ เพราะ client ที่​ยิง​ที​ละ​คำขอ ได้ connection ใบ​เดิม​กลับ​มา​อยู่ดี

ด่าน​ที่​มี​แต่​ชั้น​ที่​สาม จึง​เป็น​ด่าน​ที่​เขียว​ต่อ​ไป​ได้​ทั้ง​ที่​หมุด​หลุด POOLAFTER คือ​ชั้น​ที่​ปิด​ช่อง​นั้น มัน​อ่าน​จำนวน connection ที่ Hikari ถือ​อยู่​จริง หลัง​ยิง​ครบ​ทุก​ทรง แล้ว​ได้ 1 ทั้ง​สาม pool

PINEVIDENCE ยัง​เก็บ​อีก​สอง​ค่า คือ countsBeforeRecycle ซึ่ง​เป็น 3 ทั้ง​สาม​คำขอ และ countAfterRecycle ซึ่ง​เป็น 3 เท่า​กัน สอง​ค่า​นี้​ยืนยัน​ว่าการ​ไล่ connection ทิ้ง ไม่​ได้​เปลี่ยน​สิ่ง​ที่​ผู้​เช่า​ราย​เดิม​อ่าน​ได้ ส่วน​ข้อ​ที่​ว่า backend เปลี่ยน​จริง อ่าน​จาก pidChangedAtRecycle ใน​แถว​ก่อน​หน้า ไม่ใช่​จาก​สอง​ค่า​นี้

ฝั่ง​อ่าน​แยก​กัน​ได้​ด้วย​จำนวน​แถว ฝั่ง​เขียน​แยก​กัน​ไม่​ได้​เลย​จาก​สิ่ง​ที่​คำขอ​เห็น

ตาราง​ของ​เวที​นี้​ตั้ง DEFAULT ของ tenant_id ให้​อ่าน GUCGrand Unified Configuration (GUC)ตัวแปร​ตั้ง​ค่าที่​ผูก​กับ session ซึ่ง policy ของ​คอร์ส​นี้​อ่าน​คีย์​ของ​ผู้​เช่า​จาก​มัน · app role อ่าน​ตาราง t_app ได้ 3 แถว​เมื่อ​ตั้ง​เป็น​ผู้​เช่า a และ 3 แถว​เมื่อ​ตั้ง​เป็น b · ไม่​ตั้ง​เลย​ได้ 0 แถว และ​ผู้​เช่า​ที่​ไม่มี​อยู่​จริง​ก็ได้ 0 แถว ซึ่ง​เป็น​จำนวน ไม่ใช่ error ตัว​เดียว​กับ​ที่ policy อ่าน ซึ่ง​เป็น​รูป​ที่​ตำรา​ของ RLS สอน คำ​สั่ง INSERT จึง​ไม่​เอ่ย​ถึง​ผู้​เช่า​เลย

ตาราง​ของ​เวที B · probe อ่าน​รูป​นี้​ออก​มา​จาก catalog แล้ว​เก็บ​เป็น​ค่าที่​วัด​ได้
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));
การ​เขียน · คำ​สั่ง​ไม่​เอ่ย​ถึง​ผู้​เช่า และ handler ก็​ไม่​ได้​ตั้ง​ค่า​อะไร
@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-plain2001<clinic-a>8
write-local2001<>8
write-reset2001<>8

สาม​ช่อง​ซ้าย​เท่า​กัน​ทั้ง​สาม​แถว รหัส HTTP เท่า​กัน จำนวน​แถว​ที่​เขียน​ลง​เท่า​กัน และ​ยอด​รวม​ใน​ฐาน​เท่า​กัน ช่อง​เดียว​ที่​แยก​ทรง​ที่​รั่ว​ออก​จาก​สอง​ทรง​ที่​ถือว่า​แก้​แล้ว คือ tenant_id ของ​แถว​ที่​เพิ่ง​ลง​ไป ซึ่ง​ไม่มี​คำขอ​ไหน​ได้​เห็น

ยอด​ที่​ผู้​เช่า​สอง​ราย​เห็น​รวม​กัน น้อย​กว่า​ยอด​ใน​ฐาน

หัวข้อ​ที่​มีชื่อ​ว่า “ยอด​ที่​ผู้​เช่า​สอง​ราย​เห็น​รวม​กัน น้อย​กว่า​ยอด​ใน​ฐาน”

ทรง​ที่​แก้​แล้ว​ไม่​ได้​หยุด​การ​เขียน มัน​ย้าย​แถว​ไป​ไว้​ใน​ที่​ที่​ไม่มี​ใคร​อ่าน​เจอ

probe.py ปิด​ท้าย​ทุก​ทรง​ด้วย​การ​อ่าน​ของ​ผู้​เช่า​ทั้ง​สอง​ราย แล้ว​เทียบ​กับ​ยอด​จริง​ใน​ฐาน ที่​อ่าน​โดย postgres ซึ่ง​ข้าม RLS

ทรงa เห็นb เห็นรวม​ที่​สอง​ราย​เห็นยอด​จริง​ใน​ฐาน
write-plain5388
write-local4378
write-reset4378

แถว​แรก​คือ​ทรง​ที่​รั่ว ทุก​แถว​ยัง​มี​เจ้าของ แค่​เจ้าของ​ผิด​คน Hal ตก​ไป​อยู่​กับ clinic-a ซึ่ง​เป็น​ผู้​เช่า​ของ​คำขอ​ก่อน​หน้า

สอง​แถว​ล่าง​คือ​ทรง​ที่​คน​เรียก​ว่า​แก้​แล้ว และ​มัน​เป็น​แถว​ที่​อ่าน​ยาก​กว่า ผล​บวก​ของ​สิ่ง​ที่​สอง​ราย​เห็น น้อย​กว่า​ยอด​ใน​ฐาน​อยู่​หนึ่ง​แถว

probe.py ไล่​ค่า​ของ tenant_id ทุก​ค่าที่​มี​อยู่​จริง ไม่ใช่​แค่​สอง​ชื่อ​ที่​หว่าน​ไว้ สอง​ทรง​ล่าง​จึง​ได้​สาม​กลุ่ม​กลับ​มา คือ <> 1 กับ <clinic-a> 4 และ <clinic-b> 3

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

ข้อ​นี้​ต่อ​ตรง​จาก​บท​ที่ 2 และ​เป็น​เหตุผล​ที่​แถว​ไป​ตก​ใต้​สตริง​ว่าง​แทนที่​จะ​ถูก​ปฏิเสธ

บท​ที่ 2 วัด​ไว้​แล้ว​ว่า RESET ไม่​ได้​คืน​สถานะ ไม่​เคย​ตั้ง กลับ​มา

สถานะ​ของ sessionคีย์​ของ​เวที Aค่าที่​วัด​ได้
ไม่​เคย​ตั้ง​เลยcsTrueNeverSet<NULL>
ตั้ง​แล้ว​สั่ง RESETcsTrueAfterResetสตริง​ว่าง
ตั้ง​แล้ว​สั่ง RESET · ความ​ยาว​ของ​ค่าcsTrueAfterResetLen0

สอง​สถานะ​นั้น​ให้​ค่า​คนละ​ตัว และ​ความ​ต่าง​เดิน​มา​โผล่​ที่ DEFAULT ของ tenant_id พอดี

  • connection ที่​ไม่​เคย​ตั้ง​ค่า DEFAULT ได้ NULL แล้ว​นิพจน์​ของ policy เทียบ NULL กับ NULL ซึ่ง​ไม่​เป็น​จริง แถว​จึง​ถูก​ปฏิเสธ
  • connection ที่​ค่า​ถูก​ล้าง​ไป​แล้ว DEFAULT ได้​สตริง​ว่าง แล้ว​นิพจน์​เทียบ​สตริง​ว่าง กับ​สตริง​ว่าง ซึ่ง​เป็น​จริง แถว​จึง​ลง​ตาราง

ทรง local เดิน​มา​ที่​จุด​เดียวกัน​ด้วย​เหตุผล​เดียวกัน SET LOCAL หมด​อายุ​ตอน commit แล้ว​ค่าที่​เหลือ​อยู่​ใน session ก็​เป็น​สตริง​ว่าง​เช่น​กัน

นี่​คือ​เหตุผล​ที่ write-local กับ write-reset ให้​ค่า​เท่า​กัน​ทุก​ช่อง ทั้ง​สอง​ทรง​ไม่​ได้​กัน​การ​เขียน ทั้ง​สอง​ทรง​เปลี่ยน​ผู้​เช่า​ที่​แถว​ไป​ตก

ทรง​คุม​สอง​ทรง​ที่​แสดง​ว่าการ​ปฏิเสธ​หน้าตา​เป็น​อย่างไร เมื่อ​มัน​เกิด​ขึ้น​จริง

ทรงรหัส HTTPแถว​ที่​เขียน​ลงSQLSTATEยอด​รวม​ใน​ฐาน
write-fresh · ปล่อย​ให้​พัง​ทะลุ​ออก​ไป500ไม่มี​ค่าไม่​ได้​อ่าน6
write-caught-fresh · handler ดัก​ไว้2000425016

แถว​บน​คือ​รูป​ที่​คน​คาด​หวัง คำขอ​พัง แถว​ไม่​ลง ยอด​ใน​ฐาน​ยัง​เป็น​หก​แถว​เท่า​เดิม

แถว​ล่าง​คือ​รูป​ที่​อันตราย​กว่า handler ที่​ดัก​ข้อ​ผิด​พลาด​ไว้ คืน 200 ออก​ไป ทั้ง​ที่​เขียน​ลง 0 แถว ผู้​เรียก​จึง​ได้​คำ​ตอบ​ที่​อ่าน​เหมือน​สำเร็จ

42501 บอก​ว่า​แถว​ชน policy ก่อน ไม่ใช่​ชน NOT NULL ของ tenant_id ทั้ง​ที่​ค่าที่ DEFAULT ผลิต​ออก​มา​ใน​ทรง​นี้​เป็น NULL

รหัส​ที่​แยก​ได้ ใน​ที่​ที่​จำนวน​แถว​แยก​ไม่​ออก

หัวข้อ​ที่​มีชื่อ​ว่า “รหัส​ที่​แยก​ได้ ใน​ที่​ที่​จำนวน​แถว​แยก​ไม่​ออก”

handler ที่​เข้ม​งวด​ขึ้น​หนึ่ง​ตัว​อักษร แยก​สอง connection ที่​จำนวน​แถว​เท่า​กัน​ออก​จาก​กัน​ได้

handler ที่​เข้ม​งวด · current_setting ที่​ไม่​ใส่​อาร์กิวเมนต์​ที่​สอง
@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ไม่​เคย​มี​ใคร​ตั้ง​ค่า​ให้50042704200 · 0 แถว
strict-after-resetเคย​ตั้ง แล้ว​ถูก RESET20000000200 · 0 แถว

ช่อง​ขวา​สุด​เท่า​กัน​ทั้ง​สอง​แถว จำนวน​แถว​จึง​บอก​ไม่​ได้​ว่า connection ใบ​ไหน​สะอาด​จริง และ​ใบ​ไหน​แค่​ถูก​ล้าง​ค่า​ทิ้ง

ช่อง​กลาง​แยก​ได้ เพราะ current_setting ที่​ไม่​ใส่​อาร์กิวเมนต์​ที่​สอง ยก​รหัส​ออก​มา เมื่อ​ไม่มี​ใคร​เคย​ตั้ง​ค่า​นั้น แต่​ไม่​ยก​อะไร​เลย​เมื่อ​ค่า​เป็น​สตริง​ว่าง

strict-after-reset ยัง​อ่าน​ค่า​อีก​ช่อง​ไว้​ด้วย คือ​คำขอ​แรก​ของ​ลำดับ​นั้น ซึ่ง​ส่ง X-Tenant: clinic-a มา และ​ได้ 200 พร้อม 3 แถว ตาม​ปกติ

ทรง reset ใน​เวที​นี้​ไม่ใช่​ของ​ที่ pool ให้​มา มัน​เป็น​ชั้น​ที่​แอป​เขียน​ขึ้น​เอง

connectionInitSql ของ HikariCP ทำงาน​ตอน สร้าง connection ใหม่ ไม่ใช่​ตอน​คืน ข้อ​นี้​เป็น​ข้อ​อ้าง​เรื่อง​กลไก จึง​อ้าง​เอกสาร​ของ HikariCP ไม่​ได้​อ้าง probe

ทรง reset จึง​ทำ​ด้วย DataSource ที่​ห่อ Hikari ไว้​อีก​ชั้น แล้ว​ห่อ Connection ด้วย proxy ที่​ดัก close() เอา​ไว้

ทรง reset · proxy ที่​สั่ง RESET ก่อน​คืน connection จริง
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 ที่​ทีม​ต้อง​เขียน​เอง ดูแล​เอง และ​ตัวเลข​ข้าง​บน​บอก​แล้ว​ว่า​เขียน​ไป​แล้ว​ก็​ยัง​ไม่​ได้​ปิด​ฝั่ง​เขียน

ทั้ง​สาม​เส้น​เริ่ม​ที่​คำขอ​เดียวกัน และ​แยก​กัน​ตรง​ที่​ค่า​ของ 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` ผลเป็นอย่างไร