数据库设计与 ORM 实践:6 张表如何支撑一个博客

2026年6月19日 blogTech 8 分钟阅读 6 次阅读
📖 文章摘要

6 张 ORM 表的设计决策:文章-标签多对多、自引用评论、JSON 扩展字段、SQLite 路径兼容。本文详解每个字段的设计意图。

数据库设计:6 张表支撑一个博客

博客数据库共 6 张表,用 SQLAlchemy ORM 管理。后端使用 SQLite 数据库,文件路径 data/blog.db

第 1 张表:Article(文章)

核心表,承载博客的全部内容:

class Article(Base):
    __tablename__ = "articles"
    id = Column(Integer, primary_key=True)
    title = Column(String(200), nullable=False)
    slug = Column(String(200), unique=True, nullable=False)
    content_md = Column(Text, nullable=False)    # Markdown 原文
    content_html = Column(Text, default="")      # 渲染后的 HTML 缓存
    summary = Column(String(500), default="")    # 摘要
    cover_image = Column(String(500), default="")
    category_id = Column(Integer, ForeignKey("categories.id"))
    is_published = Column(Boolean, default=False)
    is_top = Column(Boolean, default=False)
    allow_comment = Column(Boolean, default=True)
    view_count = Column(Integer, default=0)
    meta = Column(JSON, default=dict)            # 扩展字段
    created_at = Column(DateTime, default=datetime.utcnow)
    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
    published_at = Column(DateTime, nullable=True)

设计要点:

  • slug 而非 id 做 URL 标识 — slug 对 SEO 友好(/p/redis-cache 优于 /p/42),且文章删除后 slug 不会重用
  • content_html 缓存 — Markdown 渲染在写入时完成并缓存,读取时直接返回 HTML,避免每次请求都渲染
  • meta JSON — 预留扩展,未来可追加自定义字段而不改表结构
  • published_at 与 created_at 分离 — 文章可以创建为草稿,发布时才写入 published_at
  • Pydantic v2 注意 — cover_image 和 meta 必须默认空字符串/空对象,不能为 NULL,否则 API 返回 500

第 2-3 张表:Category 和 Tag(分类与标签)

两者结构相似,都包含 type 字段预留扩展:

class Category(Base):
    __tablename__ = "categories"
    id = Column(Integer, primary_key=True)
    name = Column(String(100), nullable=False)
    slug = Column(String(100), unique=True, nullable=False)
    description = Column(String(500), default="")
    type = Column(String(50), default="blog")

多对多关系通过 article_tag 中间表实现:

article_tag = Table(
    "article_tag", Base.metadata,
    Column("article_id", Integer, ForeignKey("articles.id", ondelete="CASCADE"), primary_key=True),
    Column("tag_id", Integer, ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
)

选择多对多而非一对多是因为一篇文章可以有多个标签,一个标签也可以归属多篇文章。

第 4 张表:Comment(评论)

class Comment(Base):
    __tablename__ = "comments"
    id = Column(Integer, primary_key=True)
    article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"))
    parent_id = Column(Integer, ForeignKey("comments.id", ondelete="SET NULL"))
    author = Column(String(100), nullable=False)
    content = Column(Text, nullable=False)
    is_approved = Column(Boolean, default=False)
    is_pinned = Column(Boolean, default=False)
    created_at = Column(DateTime, default=datetime.utcnow)

parent_id 字段预留给楼中楼回复,使用自引用外键。注意:自引用关系不能加 cascade="all, delete-orphan",否则 SQLAlchemy 报错。ondelete="SET NULL" 更安全——父评论删除时子评论保留。

第 5 张表:Admin(管理员)

class Admin(Base):
    id = Column(Integer, primary_key=True)
    username = Column(String(100), unique=True, nullable=False)
    password_hash = Column(String(200), nullable=False)

密码使用 SHA256 哈希存储。为什么不加盐?单用户博客的泄露风险低,且 passlib 在 bcrypt 5.x 下有兼容问题(见踩坑记录)。

第 6 张表:SiteSetting(站点配置)

简单的 key-value 表:

class SiteSetting(Base):
    key = Column(String(100), primary_key=True)
    value = Column(Text, default="")

常用配置项:site_name、author_name、author_avatar、author_tags、social_links、background_url。全部通过后台管理界面修改,无需改代码。

SQLite 的注意事项

路径兼容

SQLite URL 在 Windows 和 Linux 下的路径格式不同。使用 .as_posix() 统一转正斜杠:

DATABASE_URL = f"sqlite:///{(DATA_DIR / 'blog.db').as_posix()}"

WAL 模式

SQLite 默认的 journal 模式在并发读写时性能较差。虽然本博客并发量不大,但 WAL(Write-Ahead Logging)模式可以显著提升读性能:

PRAGMA journal_mode=WAL;

备份

单文件数据库最大的好处——备份就是复制:

cp data/blog.db backup/blog-$(date +%Y%m%d).db
最后更新:2026年6月29日CC BY-NC-SA 4.0

评论

暂无评论,来写第一条吧

© 2026 My Blog. Built with Nuxt.js + FastAPI.