Community编程与开发github.com

Naki-404/naki-postgres

PostgreSQL 与 SQLAlchemy 2 技能,涵盖模式设计、索引、使用 EXPLAIN 进行查询调优、事务与锁、Alembic 迁移以及零停机模式变更。在需要设计表、编写或审查查询和 ORM 代码、遇到慢查询,或需要在生产数据库上执行迁移时使用。

naki-postgres 是什么?

该技能为 AI 编程助手(如 Claude Code、Cursor、Codex)提供了一套全面的 PostgreSQL 和 SQLAlchemy 2 最佳实践指南。它覆盖了从数据库模式设计、索引策略到查询调优、事务管理和零停机迁移等核心主题,旨在帮助开发者避免常见陷阱并构建高效、可扩展的数据库应用。技能明确规定了主键、数据类型和约束的命名规范,并提供了针对不同查询模式的索引选择建议。在查询调优方面,它给出了使用 `EXPLAIN (ANALYZE, BUFFERS)` 并基于真实数据进行性能分析的标准化工作流。对于模式变更,技能强调“扩展与收缩”策略,并详细说明了在大表上安全执行危险操作(如 `CREATE INDEX CONCURRENTLY`、添加 `NOT NULL` 约束)的替代方案。最后,它还提供了一个可操作的清单,用于验证设计是否符合所有最佳实践。

兼容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/Naki-404/naki-claude-kit/tree/HEAD/kit/skills/naki-postgres

Installed? Explore more 编程与开发 skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

PostgreSQL 与 SQLAlchemy 2

何时使用

  • 设计表、约束和索引时。
  • 编写 SQLAlchemy 2 模型和查询(同步或异步)时。
  • 查询性能缓慢、锁堆积或连接耗尽时。
  • 编写 Alembic 迁移,特别是针对有实时流量的表进行操作时。

对于破坏性数据变更,还应应用 naki-safety。如果项目有项目规则技能(例如 naki-myapp),请遵循它。

模式规则

  1. 主键: bigint generated always as identity 或 UUID(如果可用,使用 v7 以获得索引局部性)。如果枚举很重要,不要公开暴露顺序 ID。
  2. 类型: timestamptz(永远不要使用无时区的 timestamp),text 配合 CHECK 长度约束代替 varchar(n)(当限制可能改变时),货币使用 numeric,jsonb(而不是 json),boolean not null default false。
  3. 约束是最后一道防线: 默认使用 NOT NULL,UNIQUE,CHECK,外键需明确选择 ON DELETE 行为。
  4. 为每个用于连接或级联的外键创建索引;PostgreSQL 不会自动创建。
  5. 通过 SQLAlchemy MetaData 中的命名约定来命名约束和索引,以确保 Alembic 差异生成的稳定性。
  6. jsonb 用于真正可变的数据,而不是为了避免建模。查询频繁的键应成为真实列。
  7. 只在需要时使用软删除; 如果使用,添加部分索引 WHERE deleted_at IS NULL。

索引规则

查询模式索引
单列等值查询和范围查询B-tree
WHERE a = ? AND b > ? ORDER BY b复合索引 (a, b),等值列在前
行子集(例如开放项目)部分索引 WHERE status = 'open'
lower(email) = ?lower(email) 上的表达式索引
jsonb 包含关系 @>GIN
全文搜索,相似度 ILIKE '%x%'使用 tsvector 或 pg_trgm 的 GIN 索引
仅追加的大数据量时间序列时间戳上的 BRIN 索引
  • 每个索引都会增加写入开销和磁盘占用。移除未使用的索引(长时间窗口内 pg_stat_user_indexes.idx_scan = 0)。
  • 使用 INCLUDE (...) 的覆盖索引,实现热读取场景下的索引-only 扫描。

查询调优工作流

  1. 获取真实查询(启用 echo 或 log_min_duration_statement)和真实参数。
  2. 在生产级数据集上运行 EXPLAIN (ANALYZE, BUFFERS)。永远不要相信只有 100 行数据的开发表上的执行计划。
  3. 查找:对大表使用选择性过滤器的 Seq Scan,行估计与实际相差甚远(统计信息过时:需要 ANALYZE),遍历多行的嵌套循环,排序导致磁盘溢出,高 shared read。
  4. 修复成本最低的原因:添加或调整索引,将谓词重写为可索引形式(sargable),消除 N+1 问题,使用键集分页。
  5. 重新运行 EXPLAIN ANALYZE 并比较时间。使用 pg_stat_statements 查找总时间最长的查询。

SQLAlchemy 2 规则

  1. 使用 2.0 风格:select(),session.execute(),session.scalars(),Mapped[...] 和 mapped_column。
  2. 每个工作单元(请求、任务)使用一个会话。 禁止在多个线程或任务之间共享会话。
  3. 避免 N+1 问题: 使用 selectinload(集合)或 joinedload(多对一)显式加载关系。在异步代码中,惰性加载会引发异常;在同步代码中,设置 lazy="raise" 可以捕获它。
  4. 仅使用绑定参数。永远不要将用户输入格式化为 SQL 字符串,包括 text()。
  5. 设置连接池大小和 pool_pre_ping=True;所有应用实例的总连接数必须保持在 max_connections 以下(如果工作进程很多,请使用 PgBouncer)。
  6. 保持事务简短;在持有事务期间不要调用其他服务的网络请求。
from datetime import datetime
from sqlalchemy import ForeignKey, Index, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, selectinload

class Base(DeclarativeBase):
    pass

class Incident(Base):
    __tablename__ = "incidents"
    id: Mapped[int] = mapped_column(primary_key=True)
    status: Mapped[str] = mapped_column(String(16), index=True)
    opened_at: Mapped[datetime]
    alerts: Mapped[list["Alert"]] = relationship(back_populates="incident", lazy="raise")
    __table_args__ = (Index("ix_incidents_open", "opened_at", postgresql_where="status = 'open'"),)

async def open_incidents(session: AsyncSession, limit: int) -> list[Incident]:
    stmt = (
        select(Incident)
        .where(Incident.status == "open")
        .order_by(Incident.opened_at.desc(), Incident.id.desc())
        .limit(limit)
        .options(selectinload(Incident.alerts))
    )
    return list((await session.scalars(stmt)).all())

事务与锁

  • 默认的 READ COMMITTED 隔离级别适用于大多数场景。使用 SELECT ... FOR UPDATE 锁定将要根据读取内容进行修改的行。
  • 对于队列:FOR UPDATE SKIP LOCKED 允许多个工作进程在不相互阻塞的情况下领取任务。
  • Upsert 操作:使用 INSERT ... ON CONFLICT (key) DO UPDATE 代替先读取后插入的竞争条件写法。
  • 为应用程序角色设置 lock_timeout 和 statement_timeout,防止单个不良查询阻塞所有操作。
  • 死锁通常由不一致的行锁定顺序引起;应使用一致的顺序(例如按 id 排序)来锁定行。

Alembic 与零停机变更

始终审查自动生成的迁移;自动生成会遗漏重命名操作(它会生成删除并添加)以及某些类型变更。

对于破坏性变更,采用扩展和收缩策略:

  1. 扩展: 添加新的可空列(或带有常量默认值,在 PG 11+ 上是即时操作),新表,或使用 CONCURRENTLY 创建新索引。
  2. 部署同时读写新旧字段的代码。
  3. 回填: 在迁移之外分批处理(例如每事务 5k 行),或在单独的数据迁移中处理并记录进度。
  4. 部署仅读取新字段的代码。
  5. 收缩: 在后续版本中删除旧列。

对大型活跃表的高危操作:

操作更安全的方法
CREATE INDEXCREATE INDEX CONCURRENTLY(Alembic:在 with op.get_context().autocommit_block(): 内部设置 postgresql_concurrently=True)
添加 NOT NULL添加 CHECK (col IS NOT NULL) NOT VALID,然后 VALIDATE CONSTRAINT,最后设置 NOT NULL
添加外键使用 NOT VALID 添加,然后 VALIDATE CONSTRAINT
重命名列采用扩展和收缩策略,绝不能在旧代码仍在运行时原地重命名
更改列类型添加新列并进行回填,不要使用 ALTER TYPE 重写整个表
  • 在迁移中设置 SET lock_timeout = '5s',使它们在长时间事务后排队时快速失败。
  • 每个迁移都必须有可用的 downgrade() 或书面说明为什么无法降级。
  • 在真实数据的副本上测试迁移的升级、降级和再次升级。

检查清单

  • 使用 timestamptz,NOT NULL 默认值,命名约束,外键已建索引。
  • 查询已经参数化;SQL 中没有 f-string。
  • 无 N+1 问题;关系已显式加载。
  • 慢查询已使用 EXPLAIN (ANALYZE, BUFFERS) 在真实数据上检查过。
  • 事务简短;已设置超时。
  • 迁移已审查、可逆,在需要的地方使用了 CONCURRENTLY / NOT VALID。
  • 回填操作已分批并且可恢复。

常见错误

  • 使用 OFFSET 100000 进行分页;应在已索引的排序列上使用键集分页。
  • 使用 WHERE date(created_at) = ? 导致索引失效;应对原始列使用范围查询。
  • 使用 LIKE '%term%' 而未使用相似度索引。
  • 忘记 await session.commit(),或在循环中每行提交一次。
  • 运行 alembic revision --autogenerate 并未经审查直接提交。
  • 在调用外部 API 时保持事务打开。
  • 使用本地时间存储时间戳。

相关技能