แบบจำลองโครงสร้างองค์กรใน PostgreSQL: adjacency lists กับ closure
เปรียบเทียบ adjacency list กับ closure table ในการออกแบบโครงสร้างองค์กรบน PostgreSQL พร้อมตัวอย่างชัดเจนสำหรับการกรอง รายงาน และการตรวจสิทธิ์

สิ่งที่ผังองค์กรต้องรองรับ
ผังองค์กรคือแผนที่ว่ารายงานใครให้ใคร และทีมต่าง ๆ รวมกันเป็นแผนกอย่างไร เมื่อคุณออกแบบโครงสร้างองค์กรใน PostgreSQL คุณไม่ได้แค่เก็บ manager_id ในแต่ละคนเท่านั้น แต่ต้องรองรับงานจริง: การเรียกดูโครงสร้าง, รายงาน และกฎการเข้าถึง
ผู้ใช้ส่วนใหญ่คาดหวังสามอย่างให้รู้สึกว่าเป็นทันที: การสำรวจองค์กร การค้นหาคน และการกรองผลลัพธ์ให้เป็น "พื้นที่ของฉัน" พวกเขายังคาดหวังให้การอัปเดตปลอดภัย เมื่อผู้จัดการเปลี่ยน แผนภูมิต้องอัปเดตทุกที่โดยไม่ทำให้รายงานหรือสิทธิ์พัง
ในทางปฏิบัติ แบบจำลองที่ดีต้องตอบคำถามที่เกิดซ้ำได้ไม่กี่ข้อ:
- สายบังคับบัญชาของคนนี้คืออะไร (ขึ้นไปจนถึงระดับบนสุด)?
- ใครอยู่ภายใต้ผู้จัดการนี้ (ผู้รายงานโดยตรงและ subtree ทั้งหมด)?
- คนกลุ่มกันเป็นทีมและแผนกอย่างไรสำหรับแดชบอร์ด?
- การปรับโครงสร้างเกิดขึ้นโดยไม่สะดุดอย่างไร?
- ใครเห็นอะไรได้บ้าง ตามรูปทรงขององค์กร?
มันซับซ้อนกว่าแค่ต้นไม้ธรรมดาเพราะองค์กรเปลี่ยนบ่อย ทีมย้ายข้ามแผนก ผู้จัดการสลับกลุ่ม และบางมุมมองไม่ได้เป็นเพียง "คนรายงานคน" ตัวอย่าง: คนหนึ่งอยู่ในทีม และทีมเป็นของแผนก สิทธิ์เพิ่มอีกชั้น: รูปร่างขององค์กรกลายเป็นส่วนหนึ่งของโมเดลความปลอดภัย ไม่ใช่แค่ไดอะแกรม
คำศัพท์บางคำช่วยให้การออกแบบชัดเจน:
- โหนดคือรายการหนึ่งในลำดับชั้น (บุคคล ทีม หรือแผนก)
- แม่คือโหนดที่อยู่เหนือโดยตรง (ผู้จัดการ หรือแผนกที่เป็นเจ้าของทีม)
- บรรพบุรุษคือโหนดใด ๆ ที่อยู่เหนือในระยะทางใดก็ได้ (ผู้จัดการของผู้จัดการของคุณ)
- ทายาทคือโหนดใด ๆ ที่อยู่ด้านล่างในระยะทางใดก็ได้ (ทุกคนที่อยู่ใต้คุณ)
ตัวอย่าง: ถ้าแผนกขาย (Sales) ย้ายไปอยู่ใต้ VP คนใหม่ สองอย่างนี้ต้องยังคงเป็นจริงทันที แดชบอร์ดยังกรอง "ทั้งแผนก Sales" ได้ และสิทธิ์ของ VP คนใหม่ครอบคลุม Sales โดยอัตโนมัติ
การตัดสินใจก่อนเลือกโครงสร้างตาราง
ก่อนตัดสินใจ schema ชัดเจนว่ามีคำถามอะไรที่แอปของคุณต้องตอบทุกวัน "ใครรายงานใคร?" เป็นเพียงจุดเริ่มต้น หลายผังองค์กรยังต้องบอกว่าใครเป็นหัวหน้าแผนก ใครอนุมัติการลาของทีม และใครสามารถดูรายงานได้
จดคำถามที่หน้าจอและการตรวจสอบสิทธิ์จะถาม หากคุณบอกคำถามไม่ได้นั่นแปลว่าจะได้สกีมาที่ "ดูเหมาะ" แต่ยากต่อการคิวรี
การตัดสินใจที่กำหนดทุกอย่าง:
- คิวรีไหนต้องเร็ว: ผู้จัดการโดยตรง สายบังคับบัญชาจนถึง CEO subtree ทั้งหมดใต้หัวหน้า หรือ "ทุกคนในแผนกนี้"?
- เป็นต้นไม้แบบเคร่งครัด (ผู้จัดการคนเดียว) หรือองค์กรแบบเมทริกซ์ (หลายผู้จัดการหรือหัวหน้า)?
- แผนกเป็นโหนดในลำดับชั้นเดียวกับคนหรือเป็น attribute แยก (เช่น
department_idในแต่ละคน)? - ใครสามารถอยู่หลายทีมได้หรือไม่ (shared services, squads)?
- สิทธิ์ไหลอย่างไร: ลงต้นไม้ ขึ้นต้นไม้ หรือทั้งสอง?
การเลือกเหล่านี้กำหนดว่า "ข้อมูลที่ถูกต้อง" เป็นอย่างไร หาก Alex นำทั้งฝ่าย Support และ Onboarding manager_id เดียวหรือกฎ "หนึ่งหัวหน้าต่อทีม" อาจไม่พอ คุณอาจต้องการตารางเชื่อม (leader-to-team) หรือกฎชัดเจนเช่น "ทีมหลักหนึ่งทีม บวกทีม dotted-line"
แผนกเป็นอีกทางเลือกหนึ่ง หากแผนกเป็นโหนด คุณสามารถแสดง "แผนก A รวมทีม B รวมคน C" หากแผนกเป็นแยก คุณจะกรองด้วย department_id = X ซึ่งเริ่มง่ายแต่จะแตกเมื่อทีมข้ามแผนก
สุดท้าย นิยามสิทธิ์เป็นภาษาง่าย ๆ "ผู้จัดการดูเงินเดือนคนใต้ได้ทั้งหมด แต่ไม่ใช่เพื่อนร่วมงาน" เป็นกฎลงต้นไม้ "ใครก็ได้ดูสายบังคับบัญชาของตัวเอง" เป็นกฎขึ้นต้นไม้ ตัดสินใจแต่เนิ่น ๆ เพราะมันเปลี่ยนว่าแบบจำลองลำดับชั้นใดจะเหมาะ และแบบใดจะทำให้เกิดคิวรีแพงภายหลัง
Adjacency list: สกีมาง่ายสำหรับผู้จัดการและทีม
ถ้าคุณต้องการส่วนประกอบให้น้อยที่สุด adjacency list เป็นจุดเริ่มต้นคลาสสิก แต่ละคนเก็บพอยน์เตอร์ไปยังผู้จัดการโดยตรง และต้นไม้สร้างจากการตามพอยน์เตอร์เหล่านั้น
การตั้งค่าขั้นต่ำเป็นแบบนี้:
create table departments (
id bigserial primary key,
name text not null unique
);
create table teams (
id bigserial primary key,
department_id bigint not null references departments(id),
name text not null,
unique (department_id, name)
);
create table employees (
id bigserial primary key,
full_name text not null,
team_id bigint references teams(id),
manager_id bigint references employees(id)
);
คุณอาจข้ามตารางแยกและเก็บ department_name และ team_name เป็นคอลัมน์บน employees ได้ ซึ่งเริ่มเร็วกว่า แต่ยากรักษาความสะอาด (สะกดผิด เปลี่ยนชื่อทีม และการรายงานที่ไม่สอดคล้อง) ตารางแยกทำให้การกรองและกฎสิทธิ์อธิบายได้ง่ายขึ้น
ใส่เกราะป้องกันตั้งแต่ต้น ข้อมูลลำดับชั้นที่ผิดพลาดแก้ยากทีหลัง ขั้นต่ำสุดป้องกันการเป็นผู้จัดการตัวเอง (manager_id <> id) และตัดสินใจว่าผู้จัดการสามารถอยู่นอกทีมหรือแผนกเดียวกันได้หรือไม่ และว่าต้องการ soft deletes หรือการเปลี่ยนแปลงเชิงประวัติศาสตร์หรือไม่ (สำหรับการตรวจสอบสายงาน)
กับ adjacency list การเปลี่ยนแปลงส่วนใหญ่เป็นการเขียนธรรมดา: เปลี่ยนผู้จัดการอัปเดต employees.manager_id และย้ายทีมอัปเดต employees.team_id (มักจะพร้อมกับผู้จัดการ) ข้อเสียคือการเขียนเล็กน้อยอาจมีผลกระทบมาก downstream ผลรวมรายงานเปลี่ยน และกฎ "ผู้จัดการเห็นรายงานทั้งหมด" ต้องตามสายใหม่
ความเรียบง่ายนี้คือจุดแข็งใหญ่ของ adjacency list จุดอ่อนปรากฏเมื่อคุณกรองบ่อย ๆ ด้วย "ทุกคนใต้ผู้จัดการนี้" เพราะมักต้องพึ่งการคิวรี recursive เพื่อเดินต้นไม้ทุกครั้ง
Adjacency list: คิวรีทั่วไปสำหรับการกรองและรายงาน
กับ adjacency list คำถามผังองค์กรที่มีประโยชน์หลายอย่างจะกลายเป็นคิวรีแบบ recursive ถ้าคุณออกแบบผังองค์กรใน PostgreSQL แบบนี้ นี่คือรูปแบบที่คุณจะใช้บ่อย
ผู้รายงานโดยตรง (ระดับเดียว)
กรณีที่ง่ายที่สุดคือทีมโดยตรงของผู้จัดการ:
SELECT id, full_name, title
FROM employees
WHERE manager_id = $1
ORDER BY full_name;
นี่เร็วและอ่านง่าย แต่ลงได้แค่ระดับเดียว
สายบังคับบัญชา (ขึ้นไป)
เพื่อแสดงว่าใครคือผู้ที่คนหนึ่งรายงาน (ผู้จัดการ ผู้จัดการของผู้จัดการ เป็นต้น) ใช้ recursive CTE:
WITH RECURSIVE chain AS (
SELECT id, full_name, manager_id, 0 AS depth
FROM employees
WHERE id = $1
UNION ALL
SELECT e.id, e.full_name, e.manager_id, c.depth + 1
FROM employees e
JOIN chain c ON e.id = c.manager_id
)
SELECT *
FROM chain
ORDER BY depth;
นี้รองรับการอนุมัติ เส้นทางการยกระดับ และ breadcrumb ของผู้จัดการ
subtree ทั้งหมด (ลงไป)
เพื่อให้ได้ทุกคนใต้หัวหน้า (ทุกระดับ) พลิกการ recursion:
WITH RECURSIVE subtree AS (
SELECT id, full_name, manager_id, department_id, 0 AS depth
FROM employees
WHERE id = $1
UNION ALL
SELECT e.id, e.full_name, e.manager_id, e.department_id, s.depth + 1
FROM employees e
JOIN subtree s ON e.manager_id = s.id
)
SELECT *
FROM subtree
ORDER BY depth, full_name;
รายงานที่พบได้บ่อยคือ "ทุกคนในแผนก X ใต้หัวหน้า Y":
WITH RECURSIVE subtree AS (
SELECT id, department_id
FROM employees
WHERE id = $1
UNION ALL
SELECT e.id, e.department_id
FROM employees e
JOIN subtree s ON e.manager_id = s.id
)
SELECT e.*
FROM employees e
JOIN subtree s ON s.id = e.id
WHERE e.department_id = $2;
คิวรี adjacency list อาจเสี่ยงกับสิทธิ์เพราะการตรวจสอบการเข้าถึงมักขึ้นกับเส้นทางเต็ม (ผู้ดูเป็นบรรพบุรุษของคนนี้หรือไม่?) ถ้า endpoint ใดลืม recursion หรือใช้ฟิลเตอร์ผิดที่ คุณอาจเปิดเผยแถว นอกจากนี้ให้ระวังปัญหาข้อมูลเช่นวงจรและผู้จัดการที่หายไป ระเบียนผิดเพียงเล็กน้อยสามารถทำให้ recursion พังหรือส่งผลลัพธ์ไม่คาดคิด ดังนั้นการตรวจสิทธิ์ต้องมีการป้องกันและข้อจำกัดที่ดี
Closure table: มันเก็บลำดับชั้นทั้งหมดอย่างไร
Closure table เก็บความสัมพันธ์บรรพบุรุษ-ทายาททุกคู่ ไม่ใช่แค่ลิงก์ผู้จัดการโดยตรง แทนที่จะเดินต้นไม้ทีละก้าว คุณสามารถถามว่า: "ใครอยู่ใต้หัวหน้านี้?" แล้วได้คำตอบทั้งหมดด้วยการ join เดียว
คุณมักเก็บสองตาราง: หนึ่งสำหรับโหนด (คนหรือทีม) และหนึ่งสำหรับเส้นทางลำดับชั้น
-- nodes
employees (
id bigserial primary key,
name text not null,
manager_id bigint null references employees(id)
)
-- closure
employee_closure (
ancestor_id bigint not null references employees(id),
descendant_id bigint not null references employees(id),
depth int not null,
primary key (ancestor_id, descendant_id)
)
Closure table เก็บคู่เช่น (Alice, Bob) หมายถึง "Alice เป็นบรรพบุรุษของ Bob" ยังเก็บแถวที่ ancestor_id = descendant_id พร้อม depth = 0 แถวตัวเองนั้นดูแปลกในตอนแรก แต่ทำให้หลายคิวรีสะอาดขึ้น
depth บอกว่าห่างกันเท่าไร: depth = 1 คือผู้จัดการโดยตรง depth = 2 คือผู้จัดการของผู้จัดการ เป็นต้น นี่สำคัญเมื่อผู้รายงานโดยตรงควรจัดการต่างจากผู้รายงานทางอ้อม
ประโยชน์หลักคือการอ่านที่คาดเดาได้และรวดเร็ว:
- การค้นหา subtree ทั้งหมดเร็ว (ทุกคนใต้ผู้อำนวยการ)
- สายบังคับบัญชาง่าย (ผู้จัดการทั้งหมดเหนือใครสักคน)
- คุณแยกความสัมพันธ์โดยตรงกับทางอ้อมได้โดยใช้
depth
ต้นทุนคือการบำรุงรักษาเมื่ออัปเดต หาก Bob เปลี่ยนผู้จัดการจาก Alice เป็น Dana คุณต้องสร้างแถว closure ใหม่สำหรับ Bob และทุกคนใต้ Bob วิธีทั่วไปคือ: ลบเส้นทางบรรพบุรุษเก่าสำหรับ subtree นั้น แล้วแทรกเส้นทางใหม่โดยการรวมบรรพบุรุษของ Dana กับทุกโหนดใน subtree ของ Bob และคำนวณ depth ใหม่
Closure table: คิวรีทั่วไปสำหรับการกรองที่เร็ว
Closure table เก็บทุกคู่บรรพบุรุษ-ทายาทไว้ล่วงหน้า (มักเป็น org_closure(ancestor_id, descendant_id, depth)) นั่นทำให้การกรององค์กรเร็วเพราะคำถามส่วนใหญ่กลายเป็นการ join เดียว
เพื่อแสดงทุกคนใต้ผู้จัดการ ให้ join หนึ่งครั้งแล้วกรองด้วย depth:
-- Descendants (everyone in the subtree)
SELECT e.*
FROM employees e
JOIN org_closure c
ON c.descendant_id = e.id
WHERE c.ancestor_id = :manager_id
AND c.depth > 0;
-- Direct reports only
SELECT e.*
FROM employees e
JOIN org_closure c
ON c.descendant_id = e.id
WHERE c.ancestor_id = :manager_id
AND c.depth = 1;
สำหรับสายบังคับบัญชา (บรรพบุรุษทั้งหมดของพนักงานคนหนึ่ง) พลิกการ join:
SELECT m.*
FROM employees m
JOIN org_closure c
ON c.ancestor_id = m.id
WHERE c.descendant_id = :employee_id
AND c.depth > 0
ORDER BY c.depth;
การกรองกลายเป็นคาดเดาได้ ตัวอย่าง: "ทุกคนใต้หัวหน้า X แต่เฉพาะในแผนก Y":
SELECT e.*
FROM employees e
JOIN org_closure c ON c.descendant_id = e.id
WHERE c.ancestor_id = :leader_id
AND e.department_id = :department_id;
เพราะลำดับชั้นคำนวณไว้ล่วงหน้า การนับก็ทำได้ตรงไปตรงมา (ไม่ต้อง recursion) ช่วยแดชบอร์ดและตัวนับสิทธิ์ที่จำกัด และทำงานร่วมกับการแบ่งหน้าและการค้นหาได้ดีเพราะคุณสามารถใช้ ORDER BY, LIMIT/OFFSET และฟิลเตอร์ตรงบนชุดทายาท
แต่ละแบบมีผลอย่างไรต่อสิทธิ์และการตรวจสอบการเข้าถึง
กฎองค์กรทั่วไปคือ: ผู้จัดการดู (และบางครั้งแก้ไข) ทุกอย่างภายใต้ตน การเลือกสกีมาจะเปลี่ยนว่าคุณต้องจ่ายค่าใช้จ่ายแบบใดบ่อยแค่ไหนในการหาว่า "ใครอยู่ใต้ใคร"
กับ adjacency list การตรวจสิทธิ์มักต้อง recursion หากผู้ใช้เปิดหน้าที่แสดงพนักงาน 200 คน คุณมักจะสร้างชุดทายาทด้วย recursive CTE แล้วกรองแถวเป้าหมายตามมัน
กับ closure table กฎเดียวกันมักตรวจสอบได้ด้วยการทดสอบการมีอยู่แบบง่าย: "ผู้ใช้ปัจจุบันเป็นบรรพบุรุษของพนักงานนี้หรือไม่?" ถ้าใช่ อนุญาต
-- Closure table permission check (conceptual)
SELECT 1
FROM org_closure c
WHERE c.ancestor_id = :viewer_id
AND c.descendant_id = :employee_id
LIMIT 1;
ความเรียบง่ายนี้สำคัญเมื่อคุณนำ row-level security (RLS) มาใช้ ที่ซึ่งทุกคิวรีรวมกฎว่า "คืนเฉพาะแถวที่ผู้ดูเห็นได้" กับ adjacency list นโยบายมักฝัง recursion และ tuning ยากกว่า กับ closure table นโยบายมักเป็น EXISTS (...) ที่ตรงไปตรงมา
ขอบเขตพิเศษเป็นที่ที่ตรรกะสิทธิ์มักพังมากที่สุด:
- การรายงานแบบ dotted-line: คนหนึ่งมีผู้จัดการสองคนจริง ๆ
- ผู้ช่วยและผู้ได้รับมอบหมาย: การเข้าถึงไม่ขึ้นกับลำดับชั้น ดังนั้นเก็บสิทธิ์เฉพาะ (มักมีวันหมดอายุ)
- การเข้าถึงชั่วคราว: สิทธิ์ผูกเวลาควรไม่ถูกรวมในโครงสร้างองค์กร
- โครงการข้ามทีม: ให้สิทธิ์ตามสมาชิกโครงการ แทนที่จะตามสายการจัดการ
ถ้าคุณสร้างสิ่งนี้ใน AppMaster closure table มักจับคู่ได้ดีในโมเดลข้อมูลแบบภาพและทำให้การตรวจสอบสิทธิ์ง่ายขึ้นทั้งเว็บและมือถือ
ข้อแลกเปลี่ยน: ความเร็ว ความซับซ้อน และการบำรุงรักษา
ตัวเลือกใหญ่ที่สุดคือคุณจะปรับปรุงอะไร: การเขียนที่ง่ายและสกีมาขนาดเล็ก หรือต้องการการอ่านที่เร็วสำหรับ "ใครอยู่ใต้ผู้จัดการนี้" และการตรวจสิทธิ์
Adjacency lists ทำให้ตารางเล็กและการอัพเดตง่าย ต้นทุนปรากฏในฝั่งอ่าน: subtree มักหมายถึง recursion นั่นอาจรับได้ถ้าองค์กรของคุณเล็ก UI โหลดแค่ไม่กี่ระดับ หรือฟิลเตอร์ตามลำดับชั้นใช้ในไม่กี่ที่
Closure tables พลิกการแลกเปลี่ยน การอ่านเร็วเพราะคุณตอบ "ทายาททั้งหมด" ด้วย join ปกติ การเขียนซับซ้อนขึ้นเพราะการย้ายหรือการปรับโครงสร้างอาจต้องแทรกและลบหลายแถว
ในการทำงานจริง การแลกเปลี่ยนมักเป็นดังนี้:
- ประสิทธิภาพการอ่าน: adjacency ต้อง recursion; closure ส่วนใหญ่เป็น join และยังเร็วเมื่อองค์กรโตขึ้น
- ความซับซ้อนการเขียน: adjacency อัปเดต
parent_idเดียว; closure อัปเดตหลายแถวเมื่อย้าย - ขนาดข้อมูล: adjacency โตตามคน/ทีม; closure โตตามความสัมพันธ์ (ในกรณีแย่ ๆ ประมาณ N^2 สำหรับต้นไม้ลึก)
การทำดัชนีมีความสำคัญทั้งสองแบบ แต่เป้าหมายต่างกัน:
- Adjacency list: ทำดัชนี pointer พ่อแม่ (
manager_id) รวมถึงฟิลเตอร์ที่ใช้บ่อยเช่นธง "active" - Closure table: ทำดัชนี
(ancestor_id, descendant_id)และdescendant_idเดี่ยวสำหรับการค้นหาที่ใช้บ่อย
กฎง่าย ๆ: ถ้าคุณไม่ค่อยกรองตามลำดับชั้นและการตรวจสิทธิ์เป็นแค่ "ผู้จัดการเห็นผู้รายงานโดยตรง" adjacency list มักเพียงพอ หากคุณมักรันรายงาน "ทุกคนใต้ VP X" กรองตามต้นไม้แผนก หรือบังคับสิทธิ์ตามลำดับชั้นในหลายหน้าจอ closure table มักคุ้มค่ากับการบำรุงรักษาเพิ่มเติม
ขั้นตอน: ย้ายจาก adjacency list ไป closure table
คุณไม่จำเป็นต้องเลือกแบบในวันแรก เส้นทางปลอดภัยคือเก็บ adjacency list (manager_id หรือ parent_id) และเพิ่ม closure table เคียงข้าง จากนั้นย้ายการอ่านทีละจุด เพื่อลดความเสี่ยงขณะตรวจสอบพฤติกรรมใหม่ในการคิวรีและการตรวจสิทธิ์
เริ่มด้วยการสร้าง closure table (มักชื่อ org_closure) มีคอลัมน์อย่าง ancestor_id, descendant_id, และ depth แยกจากตาราง employees หรือ teams เดิมเพื่อให้คุณ backfill และตรวจสอบได้โดยไม่แตะฟีเจอร์ปัจจุบัน
การเปิดใช้งานขั้นปฏิบัติ:
- สร้าง closure table และดัชนีในขณะที่ยังคงเก็บ adjacency list เป็นแหล่งความจริง
- backfill แถว closure จากความสัมพันธ์ผู้จัดการปัจจุบัน รวมทั้งแถวตัวเอง (แต่ละโหนดเป็นบรรพบุรุษของตัวเองที่ depth 0)
- ตรวจสอบด้วย spot checks: เลือกผู้จัดการไม่กี่คนและยืนยันว่าชุดผู้ใต้บังคับบัญชาตรงทั้งสองแบบ
- ย้ายเส้นทางการอ่านก่อน: รายงาน ตัวกรอง และสิทธิ์ลำดับชั้นอ่านจาก closure table ก่อนที่จะเปลี่ยนการเขียน
- คอยอัปเดต closure ในทุกการเขียน (re-parent, hire, move team) เมื่อเสถียรแล้ว เลิกใช้คิวรีแบบ recursive
เมื่อยืนยัน ให้เน้นกรณีที่มักทำให้สิทธิ์ผิด: การเปลี่ยนผู้จัดการ ผู้นำระดับบนสุด และผู้ใช้ที่ไม่มีผู้จัดการ
ถ้าคุณสร้างใน AppMaster คุณสามารถให้ endpoint เก่าใช้งานไปก่อนขณะที่เพิ่ม endpoint ใหม่ที่อ่านจาก closure table แล้วสลับทันทีเมื่อผลตรงกัน
ข้อผิดพลาดทั่วไปที่ทำให้การกรองหรือสิทธิ์พัง
วิธีที่เร็วที่สุดที่จะทำลายฟีเจอร์องค์กรคือปล่อยให้ลำดับชั้นไม่สอดคล้อง ข้อมูลอาจดูโอเคทีละแถว แต่ความผิดพลาดเล็ก ๆ สามารถทำให้ตัวกรองผิด หน้าโหลดช้า หรือการรั่วของสิทธิ์
ปัญหาคลาสสิกคือสร้างวงจรโดยไม่ตั้งใจ: A เป็นผู้จัดการของ B และต่อมามีใครสักคนตั้ง B เป็นผู้จัดการของ A (หรือวงจรยาวผ่าน 3-4 คน) คิวรีแบบ recursive อาจวิ่งไม่รู้จบ คืนแถวซ้ำ หรือตรงเวลาออก สิ่งนี้จะเกิดขึ้นได้แม้กับ closure table ก็สามารถปนเปื้อนแถวบรรพบุรุษ/ทายาทได้
ปัญหาทั่วไปอีกอย่างคือ drift ของ closure: คุณเปลี่ยนผู้จัดการของใครสักคน แต่แค่เปลี่ยนความสัมพันธ์โดยตรงแล้วลืม rebuild แถว closure สำหรับ subtree นั้น ต่อมาฟิลเตอร์เช่น "ทุกคนใต้ VP นี้" จะคืนผลผสมระหว่างโครงสร้างเก่าและใหม่ มักยากจะสังเกตเพราะหน้าโปรไฟล์แต่ละคนยังดูถูกต้อง
ผังองค์กรยังยุ่งเมื่อแผนกและเส้นการรายงานผสมกันโดยไม่มีข้อกำหนดชัดเจน แผนกมักเป็นการจัดกลุ่มบริหาร ในขณะที่เส้นการรายงานเกี่ยวกับผู้จัดการ ถ้าคุณทำให้สองอย่างนี้เป็นต้นไม้เดียวกัน คุณอาจเจอพฤติกรรมแปลก ๆ เช่น "การย้ายแผนก" ทำให้การเข้าถึงเปลี่ยนโดยไม่ตั้งใจ
สิทธิ์พังบ่อยที่สุดเมื่อการตรวจสอบมองแค่ผู้จัดการโดยตรง หากคุณอนุญาตเมื่อ viewer is manager of employee คุณจะพลาดสายบังคับบัญชาทั้งหมด ผลคือบล็อกมากเกินไป (ผู้จัดการข้ามชั้นไม่เห็นองค์กรของตน) หรือแชร์มากเกินไป (ใครบางคนได้สิทธิ์เพราะถูกตั้งเป็นผู้จัดการโดยตรงชั่วคราว)
หน้ารายการที่ช้าบ่อยมาจากการรันการกรองแบบ recursive ในทุกคำขอ (ทุก inbox ทุกรายการ ticket ทุกการค้นหาพนักงาน) หากฟิลเตอร์เดียวกันใช้ทุกที่ คุณควรมีเส้นทาง precomputed (closure table) หรือชุด ID พนักงานที่อนุญาตแบบแคช
การป้องกันปฏิบัติที่แนะนำ:
- บล็อกวงจรด้วยการตรวจสอบก่อนบันทึกการเปลี่ยนผู้จัดการ
- ตัดสินใจว่า "แผนก" หมายถึงอะไร และแยกมันจากเส้นการรายงาน
- ถ้าใช้ closure table ให้ rebuild แถวทายาทเมื่อเปลี่ยนผู้จัดการ
- เขียนกฎสิทธิ์สำหรับสายทั้งหมด ไม่ใช่แค่ผู้จัดการโดยตรง
- คำนวณขอบเขตองค์กรที่ใช้ในหน้า list ล่วงหน้า แทนที่จะคำนวณ recursion ทุกครั้ง
ถ้าคุณสร้างแผงแอดมินใน AppMaster ให้ถือว่า "เปลี่ยนผู้จัดการ" เป็น workflow ที่ต้องระวัง: ตรวจสอบ มองผลกระทบต่อข้อมูลลำดับชั้น แล้วปล่อยให้กระทบตัวกรองและการเข้าถึงได้เมื่อทุกอย่างผ่านการตรวจสอบ
การตรวจสอบด่วนก่อนปล่อย
ก่อนเรียกผังองค์กรว่า "เสร็จ" ให้แน่ใจว่าคุณอธิบายการเข้าถึงเป็นภาษาง่าย ๆ ได้ หากใครถามว่า "ใครเห็นพนักงาน X และทำไม?" คุณควรชี้ไปยังกฎเดียวและคิวรี (หรือวิว) เดียวที่พิสูจน์ได้
ประสิทธิภาพเป็นการตรวจสอบถัดมา กับ adjacency list "แสดงทุกคนใต้ผู้จัดการนี้" กลายเป็นคิวรี recursive ที่ความเร็วขึ้นกับความลึกและการทำดัชนี กับ closure table การอ่านมักเร็ว แต่คุณต้องเชื่อเส้นทางการเขียนว่าจะรักษาตารางให้ถูกต้องหลังการเปลี่ยนแปลงทุกครั้ง
รายการตรวจสอบสั้น ๆ ก่อนปล่อย:
- เลือกพนักงานคนหนึ่งและตามสิทธิ์จากต้นจนจบ: สายไหนให้สิทธิ์ และบทบาทใดปฏิเสธ
- ทดสอบ benchmark คิวรี subtree ของผู้จัดการโดยใช้ขนาดคาดหวัง (เช่น 5 ระดับลึกและ 50,000 พนักงาน)
- บล็อกการเขียนที่ผิด: ป้องกันวงจร การเป็นผู้จัดการตัวเอง และโหนดไร้พ่อแม่ด้วยข้อจำกัดและการตรวจสอบภายใน transaction
- ทดสอบความปลอดภัยการปรับโครงสร้าง: ย้าย ผสาน การเปลี่ยนผู้จัดการ และ rollback เมื่อบางอย่างล้มเหลวกึ่งกลาง
- เพิ่มการทดสอบสิทธิ์ที่ยืนยันทั้งการอนุญาตและการปฏิเสธสำหรับบทบาทจริง (HR, ผู้จัดการ, หัวหน้าทีม, ฝ่ายสนับสนุน)
สถานการณ์ปฏิบัติที่ต้องตรวจสอบ: เจ้าหน้าที่ซัพพอร์ตดูได้เฉพาะพนักงานในแผนกที่ได้รับมอบหมาย ในขณะที่ผู้จัดการดู subtree ทั้งหมดของตน หากคุณสามารถจำลองผังองค์กรใน PostgreSQL และพิสูจน์กฎทั้งสองด้วยการทดสอบ คุณก็ใกล้จะปล่อยแล้ว
ถ้าคุณสร้างเป็นเครื่องมือภายในใน AppMaster เก็บการตรวจสอบเหล่านี้เป็นการทดสอบอัตโนมัติรอบ endpoint ที่คืนรายการองค์กรและโปรไฟล์พนักงาน ไม่ใช่แค่คิวรีฐานข้อมูล
สถานการณ์ตัวอย่างและขั้นตอนต่อไป
ลองนึกถึงบริษัทที่มีสามแผนก: Sales, Support และ Engineering แต่ละแผนกมีสองทีม และแต่ละทีมมีหัวหน้า Sales Lead A อนุมัติส่วนลดสำหรับทีมของตน Support Lead B ดูตั๋วทั้งหมดในแผนกของตน และ VP of Engineering ดูทุกอย่างใน Engineering
จากนั้นเกิดการปรับโครงสร้าง: ทีม Support หนึ่งทีมย้ายไปอยู่ใต้ Sales และเพิ่มผู้จัดการคนใหม่ระหว่างผู้อำนวยการฝ่ายขายกับหัวหน้าทีมสองคน ในวันถัดมา มีคำขอการเข้าถึงว่า: "ให้ Jamie (นักวิเคราะห์ฝ่ายขาย) ดูบัญชีลูกค้าทั้งหมดของแผนก Sales แต่ไม่ใช่ Engineering"
ถ้าคุณโมเดลผังองค์กรใน PostgreSQL ด้วย adjacency list สกีมาจะเรียบง่าย แต่งานของแอปจะย้ายไปที่คิวรีและการตรวจสิทธิ์ ฟิลเตอร์เช่น "ทุกคนใต้ Sales" มักต้อง recursion เมื่อคุณเพิ่มการอนุมัติ (เช่น "เฉพาะผู้จัดการในสายเท่านั้นที่อนุมัติได้") ขอบเขตหลังการปรับโครงสร้างจะเริ่มมีผล
กับ closure table การปรับโครงสร้างต้องงานเขียนมากขึ้น (อัพเดตแถวบรรพบุรุษ/ทายาท) แต่ฝั่งอ่านจะตรงไปตรงมา ฟิลเตอร์และสิทธิ์มักเป็น join ง่าย ๆ: "ผู้ใช้เป็นบรรพบุรุษของพนักงานคนนั้นหรือไม่?" หรือ "ทีมนี้อยู่ใน subtree แผนกนี้หรือไม่?"
สิ่งนี้สะท้อนโดยตรงในหน้าจอที่คนสร้าง: ตัวเลือกคนที่กรองตามแผนก การกำหนดเส้นทางการอนุมัติไปยังผู้จัดการที่ใกล้ที่สุดเหนือผู้ขอ รายงานผู้ดูแลสำหรับแดชบอร์ดแผนก และการตรวจสอบที่อธิบายว่าทำไมการเข้าถึงถึงมีอยู่ในวันที่กำหนด
ขั้นตอนต่อไป:
- เขียนกฎสิทธิ์เป็นภาษาง่าย ๆ (ใครเห็นอะไร และทำไม)
- เลือกโมเดลที่ตรงกับการตรวจสอบที่พบบ่อยที่สุด (อ่านเร็ว vs เขียนง่าย)
- สร้างเครื่องมือแอดมินภายในที่ทดสอบการปรับโครงสร้าง คำขอเข้าถึง และการอนุมัติแบบครบวงจร
ถ้าคุณต้องการสร้างแผงแอดมินและพอร์ทัลที่ตระหนักถึงโครงสร้างองค์กรได้เร็ว AppMaster (appmaster.io) อาจเหมาะ: มันให้คุณโมเดลข้อมูลที่ใช้ PostgreSQL ลงมือทำตรรกะการอนุมัติแบบภาพ และส่งมอบเว็บและแอปมือถือจาก backend เดียวกัน
คำถามที่พบบ่อย
ใช้ adjacency list เมื่อองค์กรของคุณเล็ก การอัพเดตเกิดบ่อย และหน้าส่วนใหญ่ต้องการแค่รายงานโดยตรงหรือระดับไม่กี่ชั้น ใช้ closure table เมื่อคุณต้องการบ่อย ๆ ฟีเจอร์เช่น “ทุกคนใต้หัวหน้าคนนี้” กรองตามแผนก หรือสิทธิ์ที่อิงโครงสร้างในหลายหน้าจอ เพราะการอ่านจะกลายเป็นการ join ธรรมดาและคงที่เมื่อขยายตัว
เริ่มด้วยคอลัมน์ employees(manager_id) แล้วดึงรายงานโดยตรงด้วยคำสั่งง่าย ๆ WHERE manager_id = ? เพิ่มการค้นหาแบบ recursive ก็ต่อเมื่อฟีเจอร์จำเป็นต้องใช้แหล่งข้อมูลบรรพบุรุษเต็มหรือ subtree ทั้งหมด เช่น การอนุมัติ ตัวกรอง “องค์กรของฉัน” หรือแดชบอร์ดข้ามชั้น
บล็อกการตั้งค่าตัวเองเป็นผู้จัดการด้วยเงื่อนไขเช่น manager_id <> id และตรวจสอบการอัพเดตเพื่อไม่ให้ตั้งผู้จัดการเป็นคนที่อยู่ใน subtree ของพนักงานเอง ในทางปฏิบัติ วิธีที่ปลอดภัยที่สุดคือเช็คความเป็นบรรพบุรุษก่อนบันทึกการเปลี่ยนแปลงผู้จัดการ เพราะวงจรเดียวสามารถทำให้การค้นหาแบบ recursive พังและทำให้ตรรกะสิทธิ์เสียหายได้
ค่าเริ่มต้นที่ดีคือถือว่าแผนกเป็นการจัดกลุ่มเชิงบริหาร และเส้นการรายงานเป็นต้นไม้ของผู้จัดการที่แยกต่างหาก นั่นช่วยป้องกันไม่ให้การย้ายแผนกเปลี่ยนผู้ที่ใครรายงานต่อโดยไม่ได้ตั้งใจ และทำให้การกรองเช่น “ทุกคนใน Sales” ชัดเจนแม้เส้นการรายงานจะไม่ตรงกับขอบเขตแผนก
โดยทั่วไปเก็บผู้จัดการหลักบนแถวพนักงานและเก็บความสัมพันธ์แบบ dotted-line แยกต่างหาก เช่น ตารางความสัมพันธ์ผู้จัดการรองหรือการแมป “ผู้นำทีม” วิธีนี้ช่วยหลีกเลี่ยงการทำลายการคิวรีพื้นฐานในขณะที่ยังอนุญาตกฎพิเศษเช่นการเข้าถึงตามโครงการหรือการมอบอำนาจการอนุมัติ
คุณต้องลบเส้นทางบรรพบุรุษเก่า ๆ สำหรับ subtree ของพนักงานที่ย้ายออก แล้วแทรกเส้นทางใหม่โดยการรวมบรรพบุรุษของผู้จัดการใหม่กับทุกโหนดใน subtree ของพนักงานนั้น และคำนวณ depth ใหม่ ให้ทำทั้งหมดใน transaction เดียวเพื่อไม่ให้เกิดการอัพเดตครึ่งกลางหากมีข้อผิดพลาดเกิดขึ้น
สำหรับ adjacency list ให้ทำดัชนีที่ employees(manager_id) เพราะแทบทุกคิวรีเกี่ยวกับโครงสร้างเริ่มจากตรงนั้น และเพิ่มดัชนีสำหรับฟิลเตอร์ที่ใช้บ่อยเช่น team_id หรือ department_id สำหรับ closure table ดัชนีที่สำคัญคือคีย์หลัก (ancestor_id, descendant_id) และดัชนีแยกที่ descendant_id เพื่อให้การตรวจสอบ “ใครเห็นแถวนี้ได้?” ทำได้เร็ว
รูปแบบทั่วไปคือ EXISTS บน closure table: อนุญาตเมื่อ viewer เป็นบรรพบุรุษของพนักงานเป้าหมาย นี่ทำงานได้ดีร่วมกับ row-level security เพราะฐานข้อมูลสามารถบังคับกฎได้อย่างสอดคล้อง แทนที่จะให้ API แต่ละจุดจำตรรกะ recursive เดียวกัน
เก็บประวัติการเปลี่ยนแปลงอย่างชัดเจน โดยปกติด้วยตารางแยกที่บันทึกการเปลี่ยนแปลงผู้จัดการพร้อมวันที่มีผล แทนที่จะเขียนทับผู้จัดการปัจจุบันและทำให้สูญเสียอดีต วิธีนี้ทำให้คุณตอบคำถามว่า “ใครรายงานให้ใครในวันที่ X” ได้โดยไม่ต้องเดา และทำให้รายงานและการตรวจสอบสอดคล้องหลังการปรับโครงสร้าง
เก็บ manager_id เป็นแหล่งความจริงไว้เหมือนเดิม สร้าง closure table ควบคู่ไปกับมัน และ backfill แถว closure จากต้นไม้ปัจจุบัน ย้ายเส้นทางการอ่านก่อน (ตัวกรอง แดชบอร์ด การตรวจสิทธิ์) แล้วให้การเขียนอัปเดตทั้งสองที่ หลังจากยืนยันผลแล้วจึงเลิกใช้การค้นหาแบบ recursive


