PostgreSQL 约定
如何应用
有两种模式。在编写任何内容之前,请决定应用哪种模式。
模式 A — 你选择名称。 用户请求了 schema、实体或迁移,但没有指定名称。直接应用下面的约定。
模式 B — 名称已存在或由用户指定。 用户指定了名称,或者现有代码、迁移或数据库中的名称与这些约定不符。永远不要静默重命名。请报告并询问。
报告格式,使用用户的语言,在进行更改之前:
命名:
必须修复
- `"UserProfile"` (表) → `user_profiles` — 带引号的 PascalCase 必须在每次查询中都加引号
- `created` timestamp DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') → `created_at timestamptz DEFAULT now()` — 当会话 TimeZone 不是 UTC 时,值会变化
推荐
- `users.user_email` → `users.email` — 表名已经提供了上下文
然后问一个问题:全部应用、仅应用“必须修复”项,或保持原样。等待回答。
报告规则:
- 必须修复 = 导致错误、破坏查询或工具:带引号的大小写混合标识符、保留字、超过 63 字节的名称、没有时区的
timestamp、错误的时间默认值、json/float/money用于需要jsonb/numeric的数据、缺少外键索引、事务外的锁。 - 推荐 = 样式和一致性。
- 仅报告当前任务涉及的对象。如果你在其他地方注意到其他偏差,添加一行:“还有 N 个类似偏差位于 <位置> — 列出它们?”
- 已应用的迁移或实时数据库中的对象:重命名是一个破坏性变更。仅提出建议;如果获得批准,则作为新迁移实施。永远不要编辑已应用的迁移。
- 如果代码库一致地使用不同的约定,则以该约定为准。提一次差异,然后遵循项目约定,不再标记每个出现的问题。
- ORM 命名约定(例如 EFCore.NamingConventions)产生的名称按原样接受。
- 一旦用户确认了不符合约定的名称,就使用它,并且在本会话中不再提起。
通用规则
snake_case,小写,永远不要使用带引号的标识符。- 每个标识符最多 63 字节;更长的名称会被静默截断。有意义地缩短,而不是直接截断。
- 表名使用复数:
users、orders、order_items。 - 列名使用单数,不加表前缀:
users.email,而不是users.user_email。 - 使用完整单词,除非是常见缩写:
id、url、ip、sku。 - 永远不要使用保留字或歧义词作为名称:
user、order、group、table、limit、offset、desc、end、date、time、timestamp。 - 没有类型或对象前缀:不要用
tbl_users、str_name。 - 布尔值应使用肯定形式:
is_deleted,而不是is_not_deleted。
标准列
| 用途 | 名称 | 类型 |
|---|---|---|
| 主键 | id | bigint generated always as identity 或 uuid v7 |
| 创建 / 更新 | created_at, updated_at | timestamptz not null default now() |
| 软删除 | deleted_at | timestamptz null |
| 审计 | created_by, updated_by | 指向 users.id 的外键 |
| 标志 | is_active, is_verified, has_children, can_edit | boolean not null default false |
| 状态 | status | text + check 约束、枚举或查找表 |
| 仅日期 | birth_date, due_on | date |
| 有效期 | expires_at, valid_from, valid_to | timestamptz |
| 金额 | amount, unit_price, total_amount + currency_code | numeric(19,4) + char(3),或 amount_minor bigint |
| 计数器 | items_count, retry_count | integer / bigint |
| 排序 | sort_order | integer |
| 业务键 | code, slug, external_id | text + 唯一约束 |
| 租户 | tenant_id | 外键 |
| 并发 | version 或系统 xmin | integer / xid |
| 自由格式数据 | metadata, payload, settings | jsonb |
后缀具有含义:_at 表示时刻,_on/_date 表示日历日期,_id 表示引用,_count 表示计数器,_url、_path、_hash。
键和约束
| 对象 | 模式 | 示例 |
|---|---|---|
| 主键 | pk_<table> | pk_orders |
| 外键 | fk_<table>_<ref_table>_<column> | fk_orders_customers_customer_id |
| 唯一约束 | uq_<table>_<cols> | uq_users_email |
| 替代键 (EF Core) | ak_<table>_<cols> | ak_products_sku |
| 检查约束 | ck_<table>_<rule> | ck_orders_amount_positive |
| 排除约束 | ex_<table>_<cols> | ex_bookings_room_id_period |
| 索引 | ix_<table>_<cols> | ix_orders_customer_id_created_at |
| 唯一索引 | ux_<table>_<cols> | ux_users_email |
| 部分索引 | ix_<table>_<cols>_<condition> | ix_users_email_not_deleted |
| 按方法索引 | ix_<table>_<col>_<method> | ix_products_tags_gin |
| 触发器 | trg_<table>_<action> | trg_orders_set_updated_at |
| 触发器函数 | 动词 + 对象 | set_updated_at() |
| 序列 | PostgreSQL 默认 | orders_id_seq |
- 外键列:
<被引用表名,单数>_id,或者当角色重要时使用角色名称:author_id、sender_id、recipient_id、parent_id。 - 外键模式包含列名,因此指向同一张表的多个外键保持唯一:
fk_messages_users_sender_id、fk_messages_users_recipient_id。 - PostgreSQL 不会自动为外键列创建索引。每个外键都需要一个索引,除非已有一个复合索引以其开头。EF Core 按惯例创建这些索引;Drizzle、Prisma、TypeORM、Knex 和 sqlc 不会。
关系
| 种类 | 形状 | 示例 |
|---|---|---|
| 一对多 | 子表上的外键 | order_items.order_id → orders.id |
| 一对一 | 外键 + 唯一约束,或主键 = 外键 | user_profiles.user_id |
| 多对多 | 连接表 <a>_<b>,复合主键 | user_roles (user_id, role_id),pk_user_roles |
| 带属性的多对多 | 有意义的实体名称 | memberships,enrollments |
| 自引用 | parent_id 或角色 | categories.parent_id,employees.manager_id |
多态对(commentable_type + commentable_id)不能有外键。优先使用多个可空的特定外键列,并配合检查约束确保恰好设置了一个,或者使用单独的表。
Schema、视图、函数、类型
| 对象 | 模式 | 示例 |
|---|---|---|
| Schema | 域或模块 | auth,billing,catalog,audit |
| 视图 | v_<name> | v_active_users |
| 物化视图 | mv_<name> | mv_daily_sales |
| 函数 / 过程 | 动词 + 对象 | get_user_balance,calculate_order_total |
| 枚举类型 | <entity>_<attribute> | order_status |
| 枚举值 | 小写 snake_case | pending,paid,refunded |
| 数据库角色 | <app>_<access> | shop_app_rw,shop_app_ro,shop_migrator |
枚举值不能在重建类型的情况下删除。对于会发生变化的集合,优先使用 text + check 约束或查找表。
类型和默认值
- 时刻使用
timestamptz,默认值now()。在timestamptz列上使用CURRENT_TIMESTAMP AT TIME ZONE 'UTC'是一个错误。 - 使用
text而不是varchar(n),除非长度是一个业务规则;在这种情况下优先使用check (char_length(col) <= n)。 - 使用
jsonb,永远不要用json。 - 金额使用
numeric;永远不要用real、double precision或money。 - 新的标识列使用
generated always as identity,而不是serial/bigserial。 - UUID 主键使用 v7:PostgreSQL 18 的
uuidv7(),.NET 9 的Guid.CreateVersion7(),Node 的uuid包v7(),Go 的github.com/google/uuiduuid.NewV7()。随机的 v4 键会碎片化 B-tree 索引。 gen_random_uuid()自 PostgreSQL 13 起内置;不需要uuid-ossp和pgcrypto扩展。- 对于不区分大小写的唯一性,使用
citext或lower(email)的唯一索引。
并发和查询模式
- 乐观并发,EF Core + Npgsql:一个配置为
IsRowVersion()的uint属性映射到xmin。 - 乐观并发,Node 和 Go:
version integer not null default 0,通过where id = $1 and version = $2和version = version + 1更新;零受影响行表示冲突。在 sqlc 中使用:execrows获取受影响的行数。 select ... for update和for update skip locked仅能在显式事务内使用;在自动提交模式下,锁会立即释放。- 咨询锁:事务内使用
pg_try_advisory_xact_lock。仅在专用的、保持连接直到解锁的连接上使用会话级别的pg_advisory_lock;使用连接池时,加锁和解锁可能落在不同的连接上。 - 大型或无限列表使用键集分页:
where (created_at, id) < ($1, $2) order by created_at desc, id desc limit $3。offset仅用于小型有界列表。 - 未来表的授权需要
alter default privileges,而不仅仅是grant ... on all tables。
各种 ORM 的 snake_case 设置
| 技术栈 | 设置 |
|---|---|
| EF Core + Npgsql | EFCore.NamingConventions, UseSnakeCaseNamingConvention() |
| Drizzle | casing: 'snake_case' |
| Prisma | 字段上使用 @map,模型上使用 @@map |
| TypeORM | 来自 typeorm-naming-strategies 的 SnakeNamingStrategy |
| Knex / Objection | knexSnakeCaseMappers() |
| Go + sqlc | 无;Go 名称会自动从 snake_case 生成 |
如果项目使用了 ORM 但未配置 snake_case,在首次迁移前将其报告为“必须修复”;在迁移已存在后,将其报告为“推荐”,并附带迁移成本。
Go + sqlc
sqlc 是 SQL 优先的:schema 来自迁移文件,因此上述所有规则都直接适用于它们。
- 将
sqlc.yaml中的schema指向迁移目录(支持 goose、golang-migrate、dbmate 和 atlas 格式;会忽略 down 迁移)。切勿在迁移旁边保留单独的手写 schema 文件。 - 复数 snake_case 表名会生成单数 Go 结构体:
order_items→OrderItem。枚举order_status→ 类型OrderStatus,常量OrderStatusPending。 - 使用
sql_package: pgx/v5。将uuid→github.com/google/uuid.UUID覆盖,如果需要,将timestamptz→time.Time覆盖,而不是使用pgtype包装器。 - 使用
rename修复首字母缩写(url: URL,ip: IP),而不是重命名列。 - 查询名称使用 PascalCase 动词 + 对象,加上结果种类:
| 用途 | 示例 |
|---|---|
| 单行 | -- name: GetUserByEmail :one |
| 多行 | -- name: ListOrdersByCustomer :many |
| 插入 | -- name: CreateOrder :one + returning * |
| 更新 | -- name: UpdateOrderStatus :execrows |
| 删除 | -- name: DeleteSession :exec |
| 批量加载 | -- name: CreateOrderItems :copyfrom |
- 使用
@name或sqlc.arg(name)命名参数,对于可空参数使用sqlc.narg(name);位置参数$1会生成Column1风格的 Go 字段。 - 对于连接,使用
sqlc.embed(table)而不是选择带有临时别名列的查询。 - 在应该能承受 schema 变化的查询中,永远不要使用
select *,除了写操作中的returning *。