ฐานข้อมูล, schema และการเชื่อมต่อ
เซิร์ฟเวอร์ PostgreSQL ตัวเดียวเก็บ table ได้มากกว่าหนึ่งชุด และจัดระเบียบเป็นชั้น ๆ การเข้าใจลำดับชั้นนี้ — cluster, database, schema, table — ช่วยไขข้อสงสัยว่าข้อมูลของคุณอยู่ที่ไหนและตั้งชื่ออย่างไร บทเรียนนี้พาเดินลงไปทีละชั้น สร้างแต่ละชั้น และพิสูจน์ว่าชื่อถูก resolve อย่างไร
รันบน PostgreSQL 18.6 (
postgres:18.6, aarch64, Docker Desktop บน Mac, กันยายน 2026) โปรแกรม driver:pg8.23.0 (Node 24.13.0),psycopg3.3.6,pgxv5.11.0 (Go 1.27.1),sqlx0.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"]
โหลดชุดข้อมูลของคอร์ส (fence เดียวกับบทแรก) แล้วถามเซิร์ฟเวอร์ว่าคุณอยู่ที่ไหน:
-- sql/setup.sqlCREATE 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.sqlSELECT 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”database คือหน่วยของการแยกขาด คุณให้แต่ละแอปพลิเคชันมี database ของตัวเองเพื่อไม่ให้ table ชนกัน การสร้างคือ statement เดียว
-- sql/create-database.sqlDROP DATABASE IF EXISTS shop; -- lets you re-run the lessonCREATE DATABASE shop;await pool.query('CREATE DATABASE shop');conn.autocommit = Truecur.execute("CREATE DATABASE shop")_, err := conn.Exec(ctx, "CREATE DATABASE shop")sqlx::query("CREATE DATABASE shop") .execute(&pool) .await?;NOTICE: database "shop" does not exist, skippingDROP DATABASECREATE DATABASEเพื่อเริ่มใช้ database ใหม่ คุณต้องเชื่อมต่อใหม่ไปยังมัน ใน psql คือ \c shop (ด้านล่าง: database เปล่า public ว่าง) ใน driver คุณเปิดการเชื่อมต่อใหม่โดยใส่ shop เป็น database ใน connection string
\c shopSELECT current_database();\dtYou 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.sqlSELECT 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 25001BEGIN;CREATE DATABASE tx_demo;BEGINERROR: CREATE DATABASE cannot run inside a transaction blockการสร้าง schema และ schema public
หัวข้อที่มีชื่อว่า “การสร้าง schema และ schema public”ภายใน database, schema ช่วยจัดกลุ่ม table ที่เกี่ยวข้องและหลีกเลี่ยงชื่อชนกัน ทุก database ใหม่เริ่มต้นด้วย schema หนึ่งตัวที่มีอยู่แล้วชื่อ public และถ้าคุณไม่ระบุ table ของคุณจะไปอยู่ที่นั่น สร้าง schema ที่สอง archive ที่เก็บสำเนาเก่าของ books:
-- sql/create-schema.sqlCREATE SCHEMA archive;await pool.query('CREATE SCHEMA archive');cur.execute("CREATE SCHEMA archive")_, err := conn.Exec(ctx, "CREATE SCHEMA archive")sqlx::query("CREATE SCHEMA archive") .execute(&pool) .await?;table ชื่อเดียวกันสองตัวใน schema ต่างกันทำได้ เพราะ qualified name ต่างกัน:
-- sql/archive-books.sqlCREATE 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 TABLEINSERT 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 ของมันเอง
ชื่อแบบ qualified และ search_path
หัวข้อที่มีชื่อว่า “ชื่อแบบ qualified และ search_path”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.sqlSHOW 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.sqlSET search_path TO archive, public;SHOW search_path;RESET search_path;await pool.query('SET search_path TO archive, public');cur.execute("SET search_path TO archive, public")_, err := conn.Exec(ctx, "SET search_path TO archive, public")sqlx::query("SET search_path TO archive, public") .execute(&pool) .await?;SET search_path----------------- archive, public(1 row)
RESETเมื่อ archive อยู่หน้าสุดใน path ชื่อเปล่า ๆ books ตอนนี้หมายถึงอีก table หนึ่ง คำเดียวกัน ข้อมูลต่างกัน:
-- sql/search-path-resolve.sqlSET 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.0archive, 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)
Role กับ database
หัวข้อที่มีชื่อว่า “Role กับ database”สองแนวคิดเรื่องบัญชีหลักสับสนกันง่าย database คือภาชนะเก็บข้อมูลของคุณ role คือบัญชีที่เชื่อมต่อและได้รับสิทธิ์ให้ทำงานกับข้อมูลนั้น — ใน PostgreSQL role เป็นทั้ง user และ group สองสิ่งนี้เป็นอิสระต่อกัน: role เดียวเป็นเจ้าของและเชื่อมต่อได้หลาย database และ database เดียวมีหลาย role ใช้ได้ คุณล็อกอินเป็น role postgres ซึ่งเป็น superuser ตลอดโมดูลนี้ ระบบจริงสร้าง role ที่จำกัดสิทธิ์ต่อแอปพลิเคชัน สร้างหนึ่งตัว (รหัสผ่านเป็น placeholder ไม่ใช่ secret จริง):
-- sql/roles.sqlCREATE ROLE app_user LOGIN PASSWORD '<app-password>';\du app_userCREATE ROLE List of roles Role name | Attributes-----------+------------ app_user |schema public เปลี่ยนไปใน PostgreSQL 15
หัวข้อที่มีชื่อว่า “schema public เปลี่ยนไปใน PostgreSQL 15”บทเรียนเก่า ๆ บอกว่า “ทุกคนสร้าง table ใน public ได้” สิ่งนั้นไม่จริงอีกต่อไปตั้งแต่ PostgreSQL 15: CREATE บน public ไม่ถูกให้กับทุก role อีก role ใหม่สร้าง table ที่นั่นไม่ได้ (SQLSTATE 42501):
-- @expect-error 42501BEGIN;SET ROLE app_user;CREATE TABLE public.scratch (id int);BEGINSETERROR: permission denied for schema publicLINE 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.sqlCREATE 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 SCHEMASETCREATE TABLEINSERT 0 1 current_user | notes--------------+------- app_user | 1(1 row)
RESETrole เป็นของทั้ง cluster ดังนั้น role ที่เป็นเจ้าของ object จะ drop ตรง ๆ ไม่ได้ เซิร์ฟเวอร์บอกว่าอะไรพึ่งพามันอยู่ และ DROP OWNED BY เคลียร์ให้ (พร้อมล้าง database shop ของบทเรียนด้วย):
-- @expect-error 2BP01DROP ROLE app_user;ERROR: role "app_user" cannot be dropped because some objects depend on itDETAIL: owner of schema appowner of table app.notes-- sql/cleanup.sqlDROP OWNED BY app_user;DROP ROLE app_user;DROP DATABASE shop;DROP OWNEDDROP ROLEDROP DATABASEReading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”งานบำรุงรักษาที่ “ดูใน 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 booksDELETE 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.sqlBEGIN;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;BEGINSETDELETE 1 archive_left | public_left--------------+------------- 1 | 4(1 row)
ROLLBACKไม่มีอะไรล้มเหลว จึงไม่มีอะไรเตือนใคร: archive สูญเสียข้อมูลและ table จริงยังมี row ที่ขายไม่ได้ วิธีแก้คือใส่ schema นำหน้าทุกชื่อใน SQL บำรุงรักษา (หรือไม่ override search_path เลย):
-- sql/qualified-delete.sqlBEGIN;DELETE FROM public.books WHERE copies_sold = 0 RETURNING id, title;ROLLBACK;BEGIN id | title----+---------------- 4 | Unlinked Draft(1 row)
DELETE 1ROLLBACKMental Model
หัวข้อที่มีชื่อว่า “Mental Model”ชื่อถูก 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 เปลี่ยนเฉพาะการเชื่อมต่อนั้นตัวเดียว
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
| หนึ่ง database ต่อหนึ่งแอป | แยกกันชัดเจน mental model เรียบง่าย | connection pool แยกกัน ใช้ทรัพยากรมากขึ้น |
| หลาย schema ใน database เดียว | ใช้ connection pool ร่วมกัน join ข้าม schema ได้ | ต้องจัดการการตั้งชื่อและสิทธิ์เพื่อไม่ให้ชนกัน |
ชื่อแบบ qualified (schema.table) | ไม่กำกวม — เห็นทันทีว่า table อยู่ที่ไหน | พิมพ์มากขึ้น |
พึ่ง search_path | เขียนสั้น กระชับ | เปราะบาง เพราะผลเปลี่ยนตาม session (พิสูจน์ด้านบน) |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “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 จึงล้มทันที