คีย์ของผู้เช่าเป็นค่าตั้งของ session และอายุของมันวัดออกมาเป็นจำนวนแถวได้
บทที่ 1 จบลงที่ตัวเลขสองตัว เจ้าของตารางใต้ FORCE ได้ 0 เมื่อไม่ตั้ง app.tenant
และได้ 3 เมื่อตั้งเป็นผู้เช่าใบใดใบหนึ่ง สองค่านั้นวัดได้แล้ว
แต่ยังไม่มีข้อไหนบอกว่าค่าที่ตั้งไปนั้นถูกเก็บไว้ที่ไหน และมันอยู่ได้นานแค่ไหน
บทนี้วัดสามอย่าง คือค่านั้นถูกอ่านอย่างไร มันจบอายุลงเมื่อไร และคำสั่งที่ล้างมันทิ้ง คืนสถานะกลับไปเป็นแบบที่ไม่เคยตั้งเลยจริงหรือไม่
คำตอบของข้อสุดท้ายคือ ไม่ และความต่างนั้นวัดได้ด้วยรหัสเดียว ไม่ใช่ด้วยจำนวนแถว
คอร์สนี้อยู่ในซีรีส์คลินิกเดียวกับ คอร์สคัดกรองผู้เรียก และ คอร์ส CORS กับ CSRF แต่หกบทแรกไม่มีแอปมาเกี่ยวข้อง มีแต่ตาราง policy และ role
ตัวเลขทุกตัวในบทนี้คุณรันเองได้ สั่ง scripts/clinic-rls/up.sh ซึ่งยก container
ชื่อ kaen-rls-pg ขึ้นที่ port 55438 แล้วสั่ง python3 scripts/clinic-rls/spike.py
เก็บกวาดด้วย docker rm -f kaen-rls-pg
app role อ่านตามค่าที่ตั้งไว้ สี่ค่าจากลำดับเดียว
หัวข้อที่มีชื่อว่า “app role อ่านตามค่าที่ตั้งไว้ สี่ค่าจากลำดับเดียว”ผู้อ่านในทรงนี้ไม่ใช่เจ้าของตารางและไม่มีธง
BYPASSRLSนิพจน์ของ policy จึงถูกเรียกใช้ทุกครั้ง
ทรงที่ ③ สร้างตาราง t_app ด้วย DDL ก้อนเดียวกับบทที่ 1 ทุกบรรทัด
แล้วผูก policy ใบเดิมเข้าไป spike.py สั่งกับตารางใบนี้แค่ enable row level security
ไม่เคยสั่ง force เลยสักครั้ง
ตารางใบนี้ไม่ต้องการ force เพราะกฎการยกเว้นที่บทที่ 1 วัดไว้ ครอบเฉพาะเจ้าของตาราง
ซึ่งเอกสารของ PostgreSQL 18 เป็นคนกำหนด ส่วนผู้อ่านในทรงนี้คือ kaen_rls_app
ซึ่งเป็น role คนละใบกับเจ้าของ
select 'KAEN|appNoGuc|'||count(*) from t_app where true;set app.tenant = 'clinic-a';select 'KAEN|appGucA|'||count(*) from t_app where true;set app.tenant = 'clinic-b';select 'KAEN|appGucB|'||count(*) from t_app where true;set app.tenant = 'clinic-zzz';select 'KAEN|appGucUnknown|'||count(*) from t_app where true;| สิ่งที่เพิ่งสั่งไปก่อนหน้า | คีย์ | แถวที่นับได้ |
|---|---|---|
ยังไม่มีใครตั้ง app.tenant ใน session นี้ | appNoGuc | 0 |
set app.tenant = 'clinic-a' | appGucA | 3 |
set app.tenant = 'clinic-b' | appGucB | 3 |
set app.tenant = 'clinic-zzz' | appGucUnknown | 0 |
ค่าที่ policy อ่านคือ GUCGrand Unified Configuration (GUC)ตัวแปรตั้งค่าที่ผูกกับ session ซึ่ง policy ของคอร์สนี้อ่านคีย์ของผู้เช่าจากมัน · app role อ่านตาราง t_app ได้ 3 แถวเมื่อตั้งเป็นผู้เช่า a และ 3 แถวเมื่อตั้งเป็น b · ไม่ตั้งเลยได้ 0 แถว และผู้เช่าที่ไม่มีอยู่จริงก็ได้ 0 แถว ซึ่งเป็นจำนวน ไม่ใช่ error ซึ่งเป็นค่าตั้งที่ผูกอยู่กับ session ส่วนฟังก์ชันที่อ่านมันคือ current_settingcurrent_setting()ฟังก์ชันที่อ่านค่าของ GUC · ใช้ได้สองรูป · ทรงที่วัดคือ session เดียวที่อ่านตาราง t_guc · รูปที่ไม่ใส่อาร์กิวเมนต์ที่สอง ยกรหัส 42704 (csNoArgNeverSet) เมื่อไม่มีใครเคยตั้งค่านั้นใน session นี้ ส่วนหลัง RESET รูปเดียวกันได้ 00000 (csNoArgAfterReset) และค่าที่ได้เป็นสตริงยาว 0 (csTrueAfterResetLen) ซึ่งมีสองรูป และหัวข้อท้ายบทวัดว่าสองรูปนั้นต่างกันอย่างไร
แถวสุดท้ายคือแถวที่คนมองข้ามบ่อยที่สุด clinic-zzz ไม่ใช่ผู้เช่าที่มีอยู่ในตาราง
แต่คำสั่งไม่ได้ล้มเหลว มันคืน 0 ซึ่งเป็นจำนวน ไม่ใช่ error
คีย์ของผู้เช่าที่พิมพ์ผิดหนึ่งตัวอักษร จึงเดินทางผ่านระบบมาถึงตรงนี้ได้อย่างเงียบสนิท แล้วออกไปเป็นหน้าจอที่ว่างเปล่า ไม่ใช่หน้าจอที่ฟ้องว่ามีอะไรผิด
อายุของค่า วัดจาก SET LOCAL เทียบกับ SET ธรรมดา
หัวข้อที่มีชื่อว่า “อายุของค่า วัดจาก SET LOCAL เทียบกับ SET ธรรมดา”ลำดับเดียว session เดียว สาม transaction และคำสั่งที่ตั้งค่าต่างกันแค่คำเดียว
ทรงที่ ⑩ สร้างตาราง t_guc ด้วย DDL ก้อนเดิมและ policy ใบเดิม
ทั้งลำดับข้างล่างรันอยู่ใน session เดียวตั้งแต่บรรทัดแรกถึงบรรทัดสุดท้าย
ข้อนี้สำคัญ เพราะสิ่งที่บทนี้วัดคืออายุของค่าภายใน session หนึ่งใบ
select 'KAEN|gucStartNeverSet|'||count(*) from t_guc where true;begin;set local app.tenant = 'clinic-a';select 'KAEN|gucInsideSetLocal|'||count(*) from t_guc where true;commit;select 'KAEN|gucAfterCommitSetLocal|'||count(*) from t_guc where true;begin;set app.tenant = 'clinic-a';select 'KAEN|gucInsideSet|'||count(*) from t_guc where true;commit;select 'KAEN|gucAfterCommitSet|'||count(*) from t_guc where true;begin;set app.tenant = 'clinic-b';select 'KAEN|gucInsideSetB|'||count(*) from t_guc where true;rollback;select 'KAEN|gucAfterRollbackSet|'||count(*) from t_guc where true;select 'KAEN|gucValueAfterRollback|'||coalesce(current_setting('app.tenant', true),'<NULL>');| ตำแหน่งในลำดับ | คีย์ | ค่าที่วัดได้ |
|---|---|---|
| ก่อน transaction แรก ยังไม่เคยตั้งค่า | gucStartNeverSet | 0 |
ใน transaction หลัง set local | gucInsideSetLocal | 3 |
หลัง commit ของ transaction นั้น | gucAfterCommitSetLocal | 0 |
ใน transaction หลัง set ธรรมดา | gucInsideSet | 3 |
หลัง commit ของ transaction นั้น | gucAfterCommitSet | 3 |
ใน transaction หลัง set เป็น clinic-b | gucInsideSetB | 3 |
หลัง rollback ของ transaction นั้น | gucAfterRollbackSet | 3 |
ค่าที่อ่านออกมาหลัง rollback | gucValueAfterRollback | clinic-a |
SET LOCALSET LOCALรูปที่ผูกอายุของค่าไว้กับ transaction · ทุกค่าต่อจากนี้วัดบนตาราง t_guc ใน session เดียว · ใน transaction เห็น 3 แถว หลัง commit เหลือ 0 แถว · ส่วน SET ธรรมดา หลัง commit ยังเห็น 3 แถว และ rollback คืนค่าก่อนหน้าคือ clinic-a ไม่ใช่คืนสถานะที่ไม่เคยตั้ง คือรูปที่ผูกอายุของค่าไว้กับ transaction gucInsideSetLocal = 3
แล้ว gucAfterCommitSetLocal = 0 คือการวัดอายุนั้นออกมาตรงๆ
ค่าหมดอายุที่ commit ไม่ได้หมดอายุที่ปลาย session
สองแถวถัดมาคือคำสั่งเดียวกันที่ถอดคำว่า local ออกไปคำเดียว แล้วได้ 3 ทั้งสองครั้ง
ค่าที่ตั้งด้วยรูปนี้อยู่ต่อหลัง commit ใน session เดิม
rollback คืนค่าก่อนหน้า ไม่ใช่คืนค่าว่าง
หัวข้อที่มีชื่อว่า “rollback คืนค่าก่อนหน้า ไม่ใช่คืนค่าว่าง”สองแถวล่างสุดเป็นคู่ที่ต้องอ่านพร้อมกัน gucInsideSetB = 3 คือค่าที่วัดได้
ตอน app.tenant เป็น clinic-b และ gucAfterRollbackSet = 3 คือค่าที่วัดได้หลัง rollback
จำนวนแถวเท่ากันทั้งสองฝั่ง แต่ gucValueAfterRollback บอกว่าค่าที่ session ถืออยู่หลัง
rollback คือ clinic-a ซึ่งเป็นค่าที่ตั้งไว้ก่อนหน้า ไม่ใช่ค่าว่าง และไม่ใช่ clinic-b
จำนวนแถวจึงไม่ใช่หลักฐานว่าผู้เช่าคนไหนถูกอ่าน ตารางนี้มีสามแถวต่อผู้เช่าหนึ่งคน เลข 3 สองตัวนั้นจึงเท่ากันได้ ทั้งที่คีย์ที่ policy อ่าน เปลี่ยนไปแล้วระหว่างสองการวัด
ข้อนี้เป็นตัวอย่างขนาดเล็กของสิ่งที่บทที่ 6 ขยายออกมาเต็มบท
current_setting สองรูป และรูปเดียวที่ส่งเสียง
หัวข้อที่มีชื่อว่า “current_setting สองรูป และรูปเดียวที่ส่งเสียง”ท่อนนี้รันใน session ใหม่ที่ยังไม่มีใครตั้ง
app.tenantแม้แต่ครั้งเดียว
ทรงที่ ⑪ ท่อนแรกไม่ได้นับแถวเลย มันวัดสิ่งที่ฟังก์ชันคืนกลับมาเมื่อไม่มีค่าให้อ่าน
spike.py เรียกคำสั่งที่คาดว่าจะพังผ่านฟังก์ชันตัวช่วยชื่อ public.kaen_try
ซึ่งคืนค่าเป็นสตริงรูป <SQLSTATE> <จำนวนแถว> และคืนจำนวนแถวเป็น -1 เมื่อคำสั่งยกข้อผิดพลาด
select 'KAEN|csNoArgNeverSet|'||public.kaen_try('select current_setting(''app.tenant'')');select 'KAEN|csTrueNeverSet|'||coalesce(current_setting('app.tenant', true),'<NULL>');| รูปที่เรียก | คีย์ | ค่าที่วัดได้ |
|---|---|---|
current_setting('app.tenant') | csNoArgNeverSet | 42704 -1 |
current_setting('app.tenant', true) | csTrueNeverSet | <NULL> |
แถวบนคือ 42704 undefined_objectSQLSTATE 42704 (undefined_object)รหัสที่ current_setting แบบไม่ใส่อาร์กิวเมนต์ที่สองยกออกมา เมื่อไม่มีใครเคยตั้งค่านั้นใน session นี้ · หลัง RESET รหัสนี้หายไป ทั้งที่จำนวนแถวที่อ่านจากตาราง t_guc เป็น 0 เท่ากันทั้งสองสถานะ คือ gucStartNeverSet และ csCountAfterReset ซึ่งเป็นรหัสที่รูปไม่ใส่อาร์กิวเมนต์ที่สองยกออกมา
เมื่อไม่มีใครเคยตั้งค่านั้นใน session นี้ ส่วน -1 มาจาก kaen_try ไม่ได้มาจาก PostgreSQL
แถวล่างคือรูปที่ใส่ true เป็นอาร์กิวเมนต์ที่สอง ซึ่งไม่ยกอะไรเลยและคืน NULL
ค่า <NULL> ที่เห็นในตารางมาจาก coalesce ในคำสั่ง ไม่ใช่สตริงที่ฟังก์ชันคืนมา
policy ทุกใบที่บทที่ 1 กับบทนี้ใช้ เป็นรูปที่สอง นิพจน์คือ tenant_id = current_setting('app.tenant', true)
ซึ่งเทียบ tenant_id กับ NULL เมื่อไม่มีใครตั้งค่า และการเทียบแบบนั้นไม่คืน true ให้แถวไหน
นี่คือเหตุผลที่ appNoGuc เป็น 0 ไม่ใช่ error คอร์สนี้เลือกรูปที่เงียบไว้ตั้งแต่ต้น
แล้ววัดราคาของการเลือกนั้นในบทที่ 7
RESET ไม่ได้คืนสถานะ “ไม่เคยตั้ง”
หัวข้อที่มีชื่อว่า “RESET ไม่ได้คืนสถานะ “ไม่เคยตั้ง””ท่อนนี้เป็น session อีกใบ ที่ตั้งค่าแล้วสั่งล้าง แล้ววัดสิ่งที่เหลืออยู่ด้วยสามคำสั่ง
set app.tenant = 'clinic-a';select 'KAEN|csCountBeforeReset|'||count(*) from t_guc where true;reset app.tenant;select 'KAEN|csNoArgAfterReset|'||public.kaen_try('select current_setting(''app.tenant'')');select 'KAEN|csTrueAfterReset|'||coalesce(current_setting('app.tenant', true),'<NULL>');select 'KAEN|csTrueAfterResetLen|'||length(coalesce(current_setting('app.tenant', true),'x'));select 'KAEN|csCountAfterReset|'||count(*) from t_guc where true;| สิ่งที่วัด | คีย์ | ค่าที่วัดได้ |
|---|---|---|
แถวที่นับได้ก่อนสั่ง reset | csCountBeforeReset | 3 |
รูปไม่ใส่อาร์กิวเมนต์ที่สอง หลัง reset | csNoArgAfterReset | 00000 1 |
รูปที่ใส่ true หลัง reset | csTrueAfterReset | สตริงว่าง |
| ความยาวของค่านั้น | csTrueAfterResetLen | 0 |
แถวที่นับได้หลัง reset | csCountAfterReset | 0 |
csNoArgAfterReset ขึ้นต้นด้วย 00000 ซึ่งแปลว่าคำสั่งเดิมที่เคยยกรหัส ไม่ยกอะไรแล้ว
และ 1 ท้ายค่านั้นคือจำนวนแถวที่ kaen_try รายงานกลับมา
แถวที่วัดความยาวมีไว้แยกสองอย่างที่หน้าตาเหมือนกัน คำสั่งนั้นใส่ 'x' ไว้ใน coalesce
ถ้าค่าที่ได้เป็น NULL ความยาวจะเป็น 1 ค่าที่วัดได้จริงคือ 0
สิ่งที่เหลืออยู่หลัง reset จึงเป็นสตริงว่าง ไม่ใช่ NULL
gucStartNeverSet = 0 และ csCountAfterReset = 0 เป็นค่าที่วัดจากตาราง t_guc
ใบเดียวกัน ด้วยคำสั่งนับรูปเดียวกัน และได้เลขเดียวกัน
แต่สถานะข้างหลังสองค่านั้นคนละสถานะ ค่าแรกมาจาก session ที่ไม่เคยมีใครตั้ง app.tenant
ส่วนค่าที่สองมาจาก session ที่ตั้งไปแล้วและสั่งล้างทิ้ง
สิ่งเดียวในบทนี้ที่แยกสองสถานะออกจากกันได้ คือรูปที่ไม่ใส่อาร์กิวเมนต์ที่สอง
มันให้ 42704 -1 กับสถานะแรก และให้ 00000 1 กับสถานะที่สอง
แผนผังของสองอายุ และสองสถานะที่ว่างไม่เหมือนกัน
หัวข้อที่มีชื่อว่า “แผนผังของสองอายุ และสองสถานะที่ว่างไม่เหมือนกัน”ทุกกล่องปลายทางในภาพนี้มีคีย์ของมันกำกับ และทุกคีย์มาจากลำดับที่อยู่ในบทนี้
flowchart TB S0["session ใหม่ · ไม่เคยมีใครตั้ง app.tenant"] --> S1["นับแถว · gucStartNeverSet = 0"] S0 --> E1["current_setting ไม่ใส่อาร์กิวเมนต์ที่สอง · csNoArgNeverSet = 42704 -1"] S0 --> E2["current_setting ที่ใส่ true · csTrueNeverSet คือ NULL"] S1 --> L1["begin แล้ว set local app.tenant = clinic-a"] L1 --> L2["ใน transaction · gucInsideSetLocal = 3"] L2 --> L3["หลัง commit · gucAfterCommitSetLocal = 0"] S1 --> P1["begin แล้ว set app.tenant = clinic-a"] P1 --> P2["ใน transaction · gucInsideSet = 3"] P2 --> P3["หลัง commit · gucAfterCommitSet = 3"] R0["session อีกใบ · set เป็น clinic-a แล้วสั่ง reset"] --> R1["current_setting ไม่ใส่อาร์กิวเมนต์ที่สอง · csNoArgAfterReset = 00000 1"] R0 --> R2["ความยาวของค่าที่เหลือ · csTrueAfterResetLen = 0"] R0 --> R3["นับแถว · csCountAfterReset = 0"]
คำบรรยายภาพ: กล่อง gucStartNeverSet กับกล่อง csCountAfterReset ให้เลขเดียวกันบนตารางเดียวกัน
แต่มาจาก session คนละใบที่อยู่คนละสถานะ กิ่งซ้ายบนกับกิ่งล่างจึงแยกออกจากกันได้ด้วยรหัสเท่านั้น
ที่วัดได้ กับที่เอกสารกำหนด
หัวข้อที่มีชื่อว่า “ที่วัดได้ กับที่เอกสารกำหนด”ตัวเลขทั้งบทมาจาก SQL ที่รันจริง ส่วนกฎว่ารูปไหนยกรหัสอะไร มาจากเอกสาร
spike.py รันชุดข้ออ้างทั้งชุดสามรอบต่อการเรียกหนึ่งครั้ง แล้วเทียบผลทีละคีย์
รอบที่รันเมื่อ 2026-09-06 ได้ 52 ข้ออ้างตรงทั้งหมด 98 ค่าที่วัดได้
และ 0 ค่าที่ขยับระหว่างรอบ
สิ่งที่บทนี้ ไม่ได้ พิสูจน์คือกลไกข้างในของ SET LOCAL บทนี้ไม่ได้แสดงว่าตัวจัดการค่าตั้ง
เก็บค่าเก่าไว้ที่ไหนระหว่าง transaction ข้ออ้างแบบนั้นอ้างเอกสารของ PostgreSQL 18
บทนี้ยังไม่ได้วัดว่าหลัง commit ของ set local ค่าที่เหลืออยู่เป็นสตริงว่างหรือเป็น NULL
spike.py ไม่ได้อ่านค่าตรงจุดนั้น มันอ่านแต่จำนวนแถว จึงไม่มีข้ออ้างเรื่องนี้ในบท
ผลทั้งหมดในบทนี้วัดบน PostgreSQL 18.6 บน aarch64-unknown-linux-musl
ใน container ของคอร์สนี้เอง major อื่นเป็นคนละการวัด และบทนี้ไม่ได้อ้างถึงมัน
หน่วยของหลักฐานในบทนี้มีสี่หน่วย คือจำนวนแถวที่ผู้อ่านนับได้ รหัส SQLSTATE
ความยาวของสตริง และค่าของ app.tenant ที่อ่านออกมาเป็นสตริง
ทั้งคอร์สไม่มีหน่วยเวลาสักตัว
คำถามที่บทนี้ทิ้งไว้
หัวข้อที่มีชื่อว่า “คำถามที่บทนี้ทิ้งไว้”รู้แล้วว่าค่านั้นอยู่ที่ไหนและอยู่ได้นานแค่ไหน ยังไม่รู้ว่ามันกันการเขียนได้แค่ไหน
ทุกตัวเลขในบทนี้มาจากคำสั่ง select ทั้งหมด ผู้อ่านเห็นสามแถวหรือศูนย์แถว
ตามค่าที่ session ถืออยู่ และไม่มีคำสั่งไหนล้มเหลวเลยสักคำสั่ง นอกจากรูปที่ตั้งใจให้ล้ม
แต่ policy ใบเดียวกันนั้นเขียนว่า for all ซึ่งครอบ INSERT UPDATE และ DELETE ด้วย
คำถามถัดไปคือคำสั่งที่ย้ายแถวข้ามผู้เช่า ถูกกันไว้ด้วยอะไร และมันส่งเสียงออกมาหรือไม่
บทที่ 3 วัดฝั่งเขียน แล้วพบว่านิพจน์ที่กันการเขียนไว้ ไม่ใช่นิพจน์ที่คุณคิดว่าเขียนไว้
-
PostgreSQL 18 —
SET(เข้าถึง 2026-09-06) — ความหมายของSET LOCALและอายุของค่าที่ผูกกับ transaction -
PostgreSQL 18 —
RESET(เข้าถึง 2026-09-06) — ความหมายของคำสั่งที่ล้างค่าตั้งของ session -
PostgreSQL 18 — System Administration Functions (เข้าถึง 2026-09-06) — ที่มาของกฎว่า
current_settingสองรูปต่างกันอย่างไร -
PostgreSQL 18 — PostgreSQL Error Codes (เข้าถึง 2026-09-06) — ที่มาของชื่อ
undefined_objectที่คู่กับรหัส42704 -
ตัวเลขทุกตัวในบทนี้มาจาก
scripts/clinic-rls/spike.pyซึ่งสร้างฐานข้อมูลกับ role ขึ้นใหม่ทุกรอบ แล้วรื้อทิ้งเมื่อจบ · ต้องสั่งscripts/clinic-rls/up.shก่อน ซึ่งยก container ของคอร์สนี้เองขึ้นที่ port 55438
เช็กความเข้าใจ — บทที่ 2
ข้อ 1 / 3ใน session เดียว สั่ง `begin` แล้ว `set local app.tenant = 'clinic-a'` นับแถวได้ 3 แล้วสั่ง `commit` · นับแถวอีกครั้งใน session เดิมทันที ได้เท่าไร