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

ฐานข้อมูล, schema และการเชื่อมต่อ

เซิร์ฟเวอร์ PostgreSQL ตัวเดียวเก็บ table ได้มากกว่าหนึ่งชุด และจัดระเบียบเป็นชั้น ๆ การเข้าใจลำดับชั้นนี้ — cluster, database, schema, table — ช่วยไขข้อสงสัยว่าข้อมูลของคุณอยู่ที่ไหนและตั้งชื่ออย่างไร บทเรียนนี้พาเดินลงไปทีละชั้น สร้างแต่ละชั้น และพิสูจน์ว่าชื่อถูก resolve อย่างไร

รันบน PostgreSQL 18.6 (postgres:18.6, aarch64, Docker Desktop บน Mac, กันยายน 2026) โปรแกรม driver: pg 8.23.0 (Node 24.13.0), psycopg 3.3.6, pgx v5.11.0 (Go 1.27.1), sqlx 0.9.0 (Rust 1.98.1)

เซิร์ฟเวอร์ที่รันอยู่หนึ่งตัวเรียกว่า cluster cluster มี database หนึ่งตัวหรือมากกว่า ซึ่งแยกจากกันโดยสิ้นเชิง: การเชื่อมต่อหนึ่งครั้งคุยกับ database เดียวในแต่ละครั้ง ภายในแต่ละ database มี schema ซึ่งเป็นกลุ่มที่มีชื่อของ table (และ object อื่น) ภายในแต่ละ schema คือ table แผนภาพแสดงการซ้อนกัน

flowchart TD
  CL["Cluster: one server"] --> DB1["Database: appdb"]
  CL --> DB2["Database: postgres"]
  DB1 --> S1["Schema: public"]
  DB1 --> S2["Schema: archive"]
  S1 --> T1["Table: authors"]
  S1 --> T2["Table: books"]
  S2 --> T3["Table: books"]
cluster ซ้อน database, schema และ table อย่างไร

โหลดชุดข้อมูลของคอร์ส (fence เดียวกับบทแรก) แล้วถามเซิร์ฟเวอร์ว่าคุณอยู่ที่ไหน:

-- sql/setup.sql
CREATE TABLE authors (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
born int
);
CREATE TABLE books (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
author_id bigint REFERENCES authors (id),
published int,
copies_sold int NOT NULL DEFAULT 0
);
CREATE TABLE sales (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
book_id bigint REFERENCES books (id),
region text NOT NULL,
copies int NOT NULL
);
INSERT INTO authors (name, born) VALUES ('Mara Linde', 1968), ('Owen Pryce', 1975), ('Sela Vance', 1990);
INSERT INTO books (title, author_id, published, copies_sold) VALUES
('Quiet Harbors', 1, 2003, 450), ('The Glass Orchard', 1, 2018, 1250), ('Tidal Notes', 2, 2011, 600), ('Unlinked Draft', NULL, 2020, 0);
INSERT INTO sales (book_id, region, copies) VALUES
(2, 'East', 900), (3, 'West', 400), (1, 'West', 400), (3, 'East', 200), (2, 'West', 150), (1, 'East', 50);
-- sql/layers.sql
SELECT current_database() AS database, current_schema() AS schema;
\dn
\dt
database | schema
-------------------------------------+--------
b1_databases_schemas_and_connecting | public
(1 row)
List of schemas
Name | Owner
--------+-------------------
public | pg_database_owner
(1 row)
List of tables
Schema | Name | Type | Owner
--------+---------+-------+----------
public | authors | table | postgres
public | books | table | postgres
public | sales | table | postgres
(3 rows)

database ใหม่มี schema เดียวพอดีคือ public เป็นเจ้าของโดย role พิเศษ pg_database_owner (หมายถึง: ใครก็ตามที่เป็นเจ้าของ database) และ table ไปอยู่ที่นั่น

database คือหน่วยของการแยกขาด คุณให้แต่ละแอปพลิเคชันมี database ของตัวเองเพื่อไม่ให้ table ชนกัน การสร้างคือ statement เดียว

-- sql/create-database.sql
DROP DATABASE IF EXISTS shop; -- lets you re-run the lesson
CREATE DATABASE shop;
NOTICE: database "shop" does not exist, skipping
DROP DATABASE
CREATE DATABASE

เพื่อเริ่มใช้ database ใหม่ คุณต้องเชื่อมต่อใหม่ไปยังมัน ใน psql คือ \c shop (ด้านล่าง: database เปล่า public ว่าง) ใน driver คุณเปิดการเชื่อมต่อใหม่โดยใส่ shop เป็น database ใน connection string

\c shop
SELECT current_database();
\dt
You are now connected to database "shop" as user "postgres".
current_database
------------------
shop
(1 row)
Did not find any tables.

pg_database คือ catalog เบื้องหลัง \l:

-- sql/list-databases.sql
SELECT datname, pg_get_userbyid(datdba) AS owner FROM pg_database WHERE datname = 'shop';
datname | owner
---------+----------
shop | postgres
(1 row)

คุณรัน CREATE DATABASE ภายใน transaction block ไม่ได้ นั่นคือเหตุผลที่ snippet Python เปิด autocommit โปรแกรม driver ท้ายบทพิสูจน์ไว้ และนี่คือคำตอบของเซิร์ฟเวอร์เอง (SQLSTATE 25001):

-- @expect-error 25001
BEGIN;
CREATE DATABASE tx_demo;
BEGIN
ERROR: CREATE DATABASE cannot run inside a transaction block

ภายใน database, schema ช่วยจัดกลุ่ม table ที่เกี่ยวข้องและหลีกเลี่ยงชื่อชนกัน ทุก database ใหม่เริ่มต้นด้วย schema หนึ่งตัวที่มีอยู่แล้วชื่อ public และถ้าคุณไม่ระบุ table ของคุณจะไปอยู่ที่นั่น สร้าง schema ที่สอง archive ที่เก็บสำเนาเก่าของ books:

-- sql/create-schema.sql
CREATE SCHEMA archive;

table ชื่อเดียวกันสองตัวใน schema ต่างกันทำได้ เพราะ qualified name ต่างกัน:

-- sql/archive-books.sql
CREATE TABLE archive.books (LIKE public.books);
INSERT INTO archive.books (id, title, published, copies_sold) VALUES
(1, 'Quiet Harbors (1st edition)', 2003, 0), (2, 'Salt Road', 1998, 120);
\dt archive.*
CREATE TABLE
INSERT 0 2
List of tables
Schema | Name | Type | Owner
---------+-------+-------+----------
archive | books | table | postgres
(1 row)

ใน pgAdmin: schema ใหม่จะปรากฏใต้ Schemas ใน browser tree หลัง refresh database และสร้างได้โดยคลิกขวาที่ Schemas DBeaver แสดงการจัดกลุ่มเดียวกันใน navigator ของมันเอง

table มี qualified name เต็มในรูป schema.table เช่น archive.books คุณเขียน qualified name ได้เสมอและ PostgreSQL จะรู้ชัดว่าหมายถึง table ไหน เมื่อคุณเขียนชื่อแบบไม่ qualified เช่น books PostgreSQL จะไล่ดู search_path — รายการ schema แบบเรียงลำดับ — และใช้ตัวแรกที่ตรง ค่าเริ่มต้นคือ "$user", public: schema ที่ตั้งชื่อตาม user ปัจจุบันถ้ามี (ไม่มี จึงถูกข้าม) แล้วจึง public output จริง:

-- sql/search-path.sql
SHOW search_path;
SELECT current_schemas(false) AS resolved;
SELECT count(*) AS books_seen FROM books;
search_path
-----------------
"$user", public
(1 row)
resolved
----------
{public}
(1 row)
books_seen
------------
4
(1 row)

current_schemas(false) แสดงว่า path resolve เป็นอะไรตอนนี้: เหลือแค่ public เพราะ "$user" ถูกข้าม คุณเปลี่ยน path สำหรับ session ได้:

-- sql/set-search-path.sql
SET search_path TO archive, public;
SHOW search_path;
RESET search_path;
SET
search_path
-----------------
archive, public
(1 row)
RESET

เมื่อ archive อยู่หน้าสุดใน path ชื่อเปล่า ๆ books ตอนนี้หมายถึงอีก table หนึ่ง คำเดียวกัน ข้อมูลต่างกัน:

-- sql/search-path-resolve.sql
SET search_path TO archive, public;
SELECT count(*) AS books_seen FROM books;
SELECT count(*) AS public_books FROM public.books;
SELECT relname, relnamespace::regnamespace AS schema FROM pg_class WHERE relname = 'books';
RESET search_path;
SET
books_seen
------------
2
(1 row)
public_books
--------------
4
(1 row)
relname | schema
---------+---------
books | public
books | archive
(2 rows)
RESET

สอง row ไม่ใช่สี่: books ที่ไม่ qualified คือ archive.books ขณะที่ public.books ยังมีสี่ SET มีผลตลอด session — และ session คือหนึ่งการเชื่อมต่อ นี่คือ driver ทั้งสี่รัน snippet ข้างบนตรง ๆ (ครอบด้วยโปรแกรมเล็ก ๆ ที่เชื่อมต่อก่อน):

$ node dsc.mjs # pg 8.23.0 (pool of 2; the second line is two concurrent SHOW search_path calls)
{ search_path: 'archive, public' }
[ 'archive, public', '"$user", public' ]
$ python dsc.py # psycopg 3.3.6 (first line: CREATE DATABASE without autocommit)
ActiveSqlTransaction CREATE DATABASE cannot run inside a transaction block
('archive, public',)
$ go run . # pgx v5.11.0
archive, public
$ cargo run --bin dsc # sqlx 0.9.0
("archive, public",)

output ของ Node มีบทเรียนซ่อนอยู่ ด้วย connection pool บรรทัดที่สองแสดง SHOW search_path สองครั้งพร้อมกันที่รันบนการเชื่อมต่อใน pool สองตัวที่ต่างกัน: มีเฉพาะการเชื่อมต่อที่รัน SET เท่านั้นที่เห็น archive, public อีกตัวยังเป็นค่าเริ่มต้น state ของ session (search_path, SET ROLE, temp table) ไม่ได้เป็นของ pool แต่เป็นของการเชื่อมต่อหนึ่งตัว ใช้ชื่อแบบ qualified เต็มใน SQL ของแอป หรือกำหนด path ต่อ role หรือ database ด้วย ALTER ROLE ... SET search_path (docs)

สองแนวคิดเรื่องบัญชีหลักสับสนกันง่าย database คือภาชนะเก็บข้อมูลของคุณ role คือบัญชีที่เชื่อมต่อและได้รับสิทธิ์ให้ทำงานกับข้อมูลนั้น — ใน PostgreSQL role เป็นทั้ง user และ group สองสิ่งนี้เป็นอิสระต่อกัน: role เดียวเป็นเจ้าของและเชื่อมต่อได้หลาย database และ database เดียวมีหลาย role ใช้ได้ คุณล็อกอินเป็น role postgres ซึ่งเป็น superuser ตลอดโมดูลนี้ ระบบจริงสร้าง role ที่จำกัดสิทธิ์ต่อแอปพลิเคชัน สร้างหนึ่งตัว (รหัสผ่านเป็น placeholder ไม่ใช่ secret จริง):

-- sql/roles.sql
CREATE ROLE app_user LOGIN PASSWORD '<app-password>';
\du app_user
CREATE ROLE
List of roles
Role name | Attributes
-----------+------------
app_user |

บทเรียนเก่า ๆ บอกว่า “ทุกคนสร้าง table ใน public ได้” สิ่งนั้นไม่จริงอีกต่อไปตั้งแต่ PostgreSQL 15: CREATE บน public ไม่ถูกให้กับทุก role อีก role ใหม่สร้าง table ที่นั่นไม่ได้ (SQLSTATE 42501):

-- @expect-error 42501
BEGIN;
SET ROLE app_user;
CREATE TABLE public.scratch (id int);
BEGIN
SET
ERROR: permission denied for schema public
LINE 1: CREATE TABLE public.scratch (id int);
^

\dn+ แสดงสิทธิ์การเข้าถึงเบื้องหลัง pg_database_owner (เจ้าของ database) มี Usage และ Create ส่วน =U หมายความว่าทุก role (PUBLIC) มีเพียง Usage:

-- sql/public-acl.sql
\dn+ public
List of schemas
Name | Owner | Access privileges | Description
--------+-------------------+----------------------------------------+------------------------
public | pg_database_owner | pg_database_owner=UC/pg_database_owner+| standard public schema
| | =U/pg_database_owner |
(1 row)

รูปแบบที่สะอาดคือ schema ของตัวเองสำหรับ role ของแอป:

-- sql/own-schema.sql
CREATE SCHEMA app AUTHORIZATION app_user;
SET ROLE app_user;
CREATE TABLE app.notes (id int, body text);
INSERT INTO app.notes VALUES (1, 'created by app_user');
SELECT current_user, count(*) AS notes FROM app.notes;
RESET ROLE;
CREATE SCHEMA
SET
CREATE TABLE
INSERT 0 1
current_user | notes
--------------+-------
app_user | 1
(1 row)
RESET

role เป็นของทั้ง cluster ดังนั้น role ที่เป็นเจ้าของ object จะ drop ตรง ๆ ไม่ได้ เซิร์ฟเวอร์บอกว่าอะไรพึ่งพามันอยู่ และ DROP OWNED BY เคลียร์ให้ (พร้อมล้าง database shop ของบทเรียนด้วย):

-- @expect-error 2BP01
DROP ROLE app_user;
ERROR: role "app_user" cannot be dropped because some objects depend on it
DETAIL: owner of schema app
owner of table app.notes
-- sql/cleanup.sql
DROP OWNED BY app_user;
DROP ROLE app_user;
DROP DATABASE shop;
DROP OWNED
DROP ROLE
DROP DATABASE

งานบำรุงรักษาที่ “ดูใน archive ก่อน แล้วค่อย public” ดูไม่มีพิษภัย อ่านสองบรรทัดที่สำคัญ:

🐛 หา bug ในโค้ดนี้

-- maintenance job written by an assistant
-- AI comment: "look in archive first, then public"
SET search_path TO archive, public;
-- purge unsold books
DELETE FROM books WHERE copies_sold = 0;
เฉลย

เจตนาคือเคลียร์หนังสือที่ขายไม่ได้ออกจากแคตตาล็อกจริง แต่ archive อยู่หน้าสุดใน path books จึง resolve เป็น archive.books และ statement รันโดยไม่มี error กับ table ที่ผิด รันจริงใน transaction ที่ rollback: มันลบหนึ่ง row จาก archive.books และ public.books ไม่ถูกแตะ

-- sql/shadowed-delete.sql
BEGIN;
SET LOCAL search_path TO archive, public;
DELETE FROM books WHERE copies_sold = 0;
SELECT (SELECT count(*) FROM archive.books) AS archive_left,
(SELECT count(*) FROM public.books) AS public_left;
ROLLBACK;
BEGIN
SET
DELETE 1
archive_left | public_left
--------------+-------------
1 | 4
(1 row)
ROLLBACK

ไม่มีอะไรล้มเหลว จึงไม่มีอะไรเตือนใคร: archive สูญเสียข้อมูลและ table จริงยังมี row ที่ขายไม่ได้ วิธีแก้คือใส่ schema นำหน้าทุกชื่อใน SQL บำรุงรักษา (หรือไม่ override search_path เลย):

-- sql/qualified-delete.sql
BEGIN;
DELETE FROM public.books WHERE copies_sold = 0 RETURNING id, title;
ROLLBACK;
BEGIN
id | title
----+----------------
4 | Unlinked Draft
(1 row)
DELETE 1
ROLLBACK

ชื่อถูก resolve สองขั้น: ถ้ามี schema นำหน้า นั่นคือคำตอบ ถ้าไม่มี ให้เดิน search_path จากซ้ายไปขวาแล้วเอา schema แรกที่มีชื่อนั้น path เป็นของ การเชื่อมต่อ ไม่ใช่ของแอปพลิเคชัน นิสัยที่ปลอดภัยทั้งหมดจึงเป็นการตัดขั้นที่สองทิ้ง: ใส่ schema นำหน้าชื่อ ปล่อย path เป็นค่าเริ่มต้น และให้ role ของแต่ละแอปมี schema ของตัวเอง

  • การเชื่อมต่อหนึ่งครั้งชี้ไปที่ database เดียวเสมอ หากจะสลับ ให้เชื่อมต่อใหม่ (\c)
  • database ใหม่มาพร้อม schema public ตั้งแต่ PostgreSQL 15 โดยค่าเริ่มต้นมีแต่เจ้าของเท่านั้นที่สร้าง object ที่นั่นได้
  • ชื่อที่ไม่ qualified ถูก resolve ผ่าน search_path รูปแบบ schema.table ไม่กำกวมและปลอดภัยที่สุดใน database ที่ใช้ร่วมกัน
  • CREATE DATABASE รันใน transaction block ไม่ได้ (25001) เครื่องมือจึงต้องรันมันเดี่ยว ๆ ด้วย autocommit
  • role (บัญชี) และ database (ภาชนะข้อมูล) เป็นคนละแนวคิด role เป็นของทั้ง cluster และ DROP ROLE ต้องทำ DROP OWNED BY ก่อนเมื่อ role เป็นเจ้าของ object
  • SET search_path บนการเชื่อมต่อใน pool เปลี่ยนเฉพาะการเชื่อมต่อนั้นตัวเดียว
ตัวเลือกBenefitCost
หนึ่ง database ต่อหนึ่งแอปแยกกันชัดเจน mental model เรียบง่ายconnection pool แยกกัน ใช้ทรัพยากรมากขึ้น
หลาย schema ใน database เดียวใช้ connection pool ร่วมกัน join ข้าม schema ได้ต้องจัดการการตั้งชื่อและสิทธิ์เพื่อไม่ให้ชนกัน
ชื่อแบบ qualified (schema.table)ไม่กำกวม — เห็นทันทีว่า table อยู่ที่ไหนพิมพ์มากขึ้น
พึ่ง search_pathเขียนสั้น กระชับเปราะบาง เพราะผลเปลี่ยนตาม session (พิสูจน์ด้านบน)
  • “schema ก็แค่โฟลเดอร์” มันคือ namespace ที่มีส่วนใน name resolution และสิทธิ์ table ชื่อ books สองตัวใน schema ต่างกันคือคนละ table (พิสูจน์ด้านบน)
  • “search_path เป็นการตั้งค่าของ database” ค่าที่ใช้อยู่เป็นต่อ session ค่าเริ่มต้นผูกกับ role หรือ database ได้ แต่การเชื่อมต่อใน pool ก็ยังอยู่ในสถานะต่างกันได้
  • “ทุก role สร้าง table ใน public ได้” จริงก่อน PostgreSQL 15 ปัจจุบันล้มเหลวด้วย permission denied for schema public
  • “role superuser ใช้กับแอปได้” สร้าง role ที่จำกัดสิทธิ์เท่าที่แอปต้องใช้ เพื่อลดความเสียหายหาก credential รั่ว
  • “CREATE DATABASE เหมือน DDL ทั่วไป” มันรันใน transaction block ไม่ได้ เครื่องมือ migration ที่ครอบทุกอย่างด้วย transaction จึงล้มทันที
schema ใดมีอยู่ในทุก database ใหม่โดยค่าเริ่มต้น?
search_path ควบคุมอะไร?
role กับ database สัมพันธ์กันอย่างไร?
role ใหม่รัน CREATE TABLE public.scratch (id int) บน PostgreSQL 18 จะเกิดอะไรขึ้น?
งานหนึ่งรัน SET search_path TO archive, public; DELETE FROM books WHERE copies_sold = 0; และทั้งสอง schema มี table books table ไหนได้รับผลกระทบ?