数据库设计与 ORM 实践——个人博客项目复盘

2026年6月23日 blogTech 16 分钟阅读 4 次阅读
📖 文章摘要

基于 SQLite + SQLAlchemy 的博客系统数据库设计实践,涵盖 ER 设计、ORM 技巧、分页查询、级联策略和踩坑记录。

数据库设计与 ORM 实践

基于 SQLite + SQLAlchemy 的个人博客系统数据库设计总结


一、技术选型:为什么选 SQLite?

个人博客的数据库选型其实没什么悬念——单用户、低并发、零运维,SQLite 是最优解

对比一下常见选项:

维度 SQLite MySQL / PostgreSQL 说明
安装 零配置 需安装服务进程 SQLite 只是一个文件
运维 备份、调优、连接池 一个人的博客不需要 DBA
性能 单机足够 高并发优势 日 PV < 1000 没区别
备份 cp 即可 mysqldump / pg_dump 复制文件就完成备份
功能 不支持并发写 完整事务支持 个人博客只有一个人在后台写

核心结论:不要为了用数据库而用数据库。 如果你的项目只有你一个人操作、数据量在百万以下、不需要分布式——SQLite 是正确选择,MySQL 反而是过度工程。


二、ER 图(文字版)

┌───────────┐    ┌────────────┐    ┌───────────┐
│  Category  │1──N│  Article    │N──M│    Tag     │
│           │    │            │    │           │
│ id        │    │ id         │    │ id        │
│ name      │    │ title      │    │ name      │
│ slug      │    │ slug       │    │ slug      │
│ desc      │    │ content_md │    └───────────┘
└───────────┘    │ content_htm│
                 │ category_id│─┐
                 │ tags (M:N) │ │
                 │ is_publishe│ │
                 │ view_count │ │
                 └────────────┘ │
                                │
┌───────────┐    ┌────────────┐ │
│  Comment   │N──1│  Article    │─┘
│           │    └────────────┘
│ id        │
│ article_id│─┘
│ parent_id │──┐(自引用,楼中楼预留)
│ author    │  │
│ content   │  │
│ is_approved│ │
└───────────┘  │

┌───────────┐  │  ┌──────────────┐
│   Admin   │    │  SiteSetting   │
│           │    │               │
│ id        │    │ key (PK)     │
│ username  │    │ value        │
│ password_h│    └──────────────┘
│ nickname  │
└───────────┘

┌───────────┐    ┌──────────────┐
│   Share    │1──N│ DownloadLog   │
│           │    │               │
│ id        │    │ share_id     │
│ file_name │    │ downloaded_at│
│ password_h│    └──────────────┘
│ download_l│
│ expire_at │
└───────────┘

8 张表,覆盖了博客的所有功能。


三、模型设计要点

3.1 文章表的多态字段设计

class Article(Base):
    __tablename__ = "articles"

    id = Column(Integer, primary_key=True, index=True)
    title = Column(String(200), nullable=False)
    slug = Column(String(200), unique=True, nullable=False, index=True)
    content_md = Column(Text, nullable=False)
    content_html = Column(Text, default="")
    summary = Column(String(500), default="")
    cover_image = Column(String(500), default="")
    category_id = Column(Integer, ForeignKey("categories.id", ondelete="SET NULL"), nullable=True)
    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)

    category = relationship("Category", back_populates="articles")
    tags = relationship("Tag", secondary=article_tag, back_populates="articles")

几个实用设计:

meta 字段(JSON 类型): 存放扩展属性而不改表结构。访问次数、阅读时长等临时性数据都往这里放,避免了频繁加列的麻烦。

content_md + content_html 双字段: Markdown 原文存一份,渲染后的 HTML 缓存一份。每次查询都实时渲染 Markdown 太贵了,写入时渲染一次就能让查询快一个数量级。

slug 唯一索引: URL 中用可读的标识而不是数字 ID,对 SEO 和用户体验都好:

  • blog.vue2.xyz/p/database-design
  • blog.vue2.xyz/p/42

3.2 多对多关系:文章 ↔ 标签

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),
)

SQLAlchemy 的 secondary 参数让多对多关系像一对多一样易用:

# 查询文章的所有标签
article.tags  # 直接返回 Tag 对象列表

# 给文章打标签
article.tags = [tag1, tag2, tag3]

框架自动维护中间表 article_tag,不需要手写增删关联记录的代码。

3.3 自引用关系:评论楼中楼

class Comment(Base):
    parent_id = Column(Integer, ForeignKey("comments.id", ondelete="SET NULL"), nullable=True)
    replies = relationship("Comment", backref="parent", remote_side=[id])

remote_side=[id] 告诉 SQLAlchemy「哪个字段是父记录的主键」,否则它无法区分方向。

注意: 自引用关系上不能设 cascade="all, delete-orphan"——SQLAlchemy 不支持。删除父评论时需要手动处理子评论。

3.4 键值对配置表

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

最简单的配置存储模式。不需要为每个配置项建列,不用改表结构就能新增配置:

site_name       → My Blog
author_name     → Leap
author_avatar   → data:image/...
background_url  → https://...
social_links    → [{"platform":"github","url":"..."}]

读取时一次查完转成字典:

settings = {row.key: row.value for row in db.query(SiteSetting).all()}

四、SQLAlchemy 实践技巧

4.1 会话管理

# database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base

engine = create_engine(
    DATABASE_URL,
    connect_args={"check_same_thread": False}  # SQLite 多线程必需
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()

FastAPI 中的依赖注入模式:

# main.py
def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

# routers/articles.py
@router.get("/articles")
def list_articles(db: Session = Depends(get_db)):
    return db.query(Article).all()

这样每个请求自动获取和关闭会话,不会泄漏连接。

4.2 分页查询

page = 1
size = 24
offset = (page - 1) * size

articles = db.query(Article).filter(
    Article.is_published == True
).order_by(
    Article.is_top.desc(),    # 置顶优先
    Article.published_at.desc()  # 最新在前
).offset(offset).limit(size).all()

total = db.query(Article).filter(
    Article.is_published == True
).count()

Pydantic 返回模型要设定默认值,否则 NULL 字段炸 500:

class ArticleListItem(BaseModel):
    cover_image: str = ""      # NOT Optional[str],否则 None 进来就炸
    is_top: bool = False
    summary: str = ""

这是踩过的坑——Pydantic v2 严格模式下,数据库 NULL 直接报 ValidationError

4.3 级联删除策略

外键 ondelete 说明
article.category_id SET NULL 删除分类,文章保留(分类设为空)
comment.article_id CASCADE 删除文章,评论一起删
article_tag (中间表) CASCADE 删除文章或标签,关联记录自动清除

SQLAlchemy 的 cascade 参数控制的是 Python 层面的级联,而 ondelete 是数据库层面的。两者最好保持一致。


五、数据库迁移的策略

没有用 Alembic——个人博客不值得上迁移工具。我的策略:

小改动: 直接加列,给默认值:

view_count = Column(Integer, default=0)  # 新增字段
# 已有数据库添加列
conn.execute("ALTER TABLE articles ADD COLUMN view_count INTEGER DEFAULT 0")

大改动: 导数据脚本:

# 从旧表读出 → 处理 → 写入新表
old_data = conn.execute("SELECT * FROM articles_old").fetchall()
for row in old_data:
    conn.execute("INSERT INTO articles (...) VALUES (...)", processed_row)
conn.execute("DROP TABLE articles_old")

这个策略的前提是:你清楚自己在做什么,并且有备份。 如果团队协作或数据重要,还是上 Alembic。


六、踩坑最快报错

1. SQLite 并发写入限制

SQLite 不支持并发写入——写操作会锁整个数据库。但对个人博客完全不是问题:

  • 前台只有读操作(查文章、看评论)
  • 写操作只有后台管理员一个人
  • 不会出现两个人同时写的情况

2. `check_same_thread=False`

SQLite 默认不允许跨线程使用同一个连接。FastAPI 是异步框架,请求可能在不同线程中执行,必须设置:

engine = create_engine(URL, connect_args={"check_same_thread": False})

不设就报 SQLite objects created in a thread can only be used in that same thread

3. Windows 文件锁

Windows 上 SQLite 文件被打开时不能删除或重命名。部署脚本中不要 os.remove + os.rename 更新数据库,改为 shutil.copy2 覆盖写入。


七、总结

原则 说明
选型务实 单用户博客不需要 MySQL,SQLite 够用且零运维
双字段缓存 写时渲染 Markdown→HTML,读时直接给 HTML
Slug 优先 URL 用可读标识而不是数字 ID
JSON 扩展 一个 meta 字段代替频繁改表
默认值保底 Pydantic schema 全字段给默认值,防 NULL 炸 API
迁移从简 小改直接 SQL,大改写脚本,不上 Alembic
最后更新:2026年6月29日CC BY-NC-SA 4.0

评论

暂无评论,来写第一条吧

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