☰
全栈实战:用Flask+SQLAlchemy构建图书借阅系统,解决建模与查询优化
2026/10/13 3:47:01 网站建设 项目流程

“第3章”到底该写什么,我纠结了很久。前两章把Python基础语法、虚拟环境、Flask最小应用都过了一遍,读者手里已经有了一个能跑起来的极简后端,但离“全栈”还差着十万八千里。这一章如果再讲路由、讲装饰器,那和文档没什么区别;直接上前后端分离、Redis、Celery,又容易把新手劝退。所以我把第3章定位成一个转折点:用一个小而完整的图书借阅系统,把数据库建模、ORM、REST API、模板渲染、Fetch交互全都串起来。这篇文章就是第3章的实战记录,我尽量把那段时间踩过的坑、调整过的设计、以及最后为什么这么定方案的原因都写清楚。不管你是刚学完Python基础、想看看真实项目长什么样的新手,还是写过不少脚本但没系统做过Web项目的进阶读者,这一章的内容应该都能给你一点参考。

1. 为什么第3章要先把数据库做成真正的骨架

1.1 前两章到底折腾了什么

第1章和第2章里,我们做的事情其实不多:搭好虚拟环境,用Flask写了一个返回"hello world"的接口,然后学会了用蓝图拆一下路由,用render_template渲染了一个写死的HTML页面。读者的普遍反馈是“太简单了,没意思”。但我坚持这么设计是有原因的——Python全栈的第一道坎从来不是语法,而是“当项目稍微复杂一点,你还能不能组织好代码”。前两章把“组织代码”的最小框架给了大家,这一章开始往里填真东西。

所谓“真东西”,就是数据库。一个连数据都存不住的Web应用,说破天也只是个玩具。第3章做的图书借阅系统虽然演示性质浓厚,但它包含了真实业务系统最常见的几个要素:资源清单、用户身份、借还事务、列表查询、状态变更。这些东西一旦落到数据库里,就会自然引出表结构设计、外键关系、查询性能、数据一致性等一系列在书本上要单独讲好几章的问题。而全栈实战的好处是:你不用抽象地学这些概念,而是在写一个具体功能的时候,被现实逼着去解决它们。

1.2 一个全栈项目最容易被忽视的“建模前置工作”

很多初学者拿到需求,第一反应是“开写”,第二反应是“建表”。他们跳过了最关键的一步:把业务流程里那些动词、名词、状态转折全部列出来。这步做得越细,后面写的API和页面就越顺。

我自己做这个图书借阅系统时,第一步没有碰数据库,而是在纸上画了一个最简单的借书流程:读者来借书,系统检查书还有没有库存,有则登记借阅记录并把库存减一,没有则拒绝;还书时找登记记录,更新状态的还书时间、把库存加回来。就这么一个流程,已经包含了几类关键信息:书(图书资源的静态属性)、人(借阅者)、事件(借了哪本、什么时候借的、什么时候要还、实际还了没)。这三类信息对应到数据库,就是三张表:图书表、用户表、借阅记录表。三张表之间的关系也很清晰:一个用户可以借多本书,一本书可以被多个人先后借走,所以用户和图书是多对多关系,而借阅记录表就是中间表,同时记录了每次借阅事件的时间与状态。

这个过程我建议每个读者都自己走一遍,哪怕只是拿张纸画一画。因为后续所有API、所有页面、所有查询优化,都是围着这三张表转的。数据库模型不是凭空设计的,它是对业务逻辑的一次“数据化翻译”。

2. 三张表和它们的关系:图书借阅系统的数据模型设计

2.1 从业务场景推导表结构

第3章最初的版本,我只想了两张表:图书和用户。写借阅功能时才发现,如果不把“某次借阅”单独记录下来,根本没法回答“这本书现在在谁手上”“谁快超期了”这类问题。所以第三张表其实是业务场景逼出来的,而不是一开始就规划好的。这也是我想通过第3章传递的一个观念:模型设计是迭代出来的,别指望一次到位。

最终我定下的三张表结构如下:

字段图书表 books用户表 users借阅记录表 borrows
主键ididid
核心字段title, author, isbnname, emailbook_id, user_id
状态/库存stockrolestatus, borrow_date, due_date, return_date
扩展字段cover_url, created_atcreated_atcreated_at

图书表的stock字段很关键,新手特别容易忽略它。很多人会想:“书籍是不是在borrows表里有个is_borrowed标记就够了?”其实不行。如果一本书有三本库存,那你就需要有三条可借的状态,或者用一个数字库存配合“当前被借出数量”来计算。我的方案是直接用stock记录总库存,当借出成功时stock -= 1,归还时stock += 1。如果当天图书总量不变,库存数字就能很直观地反映剩余可借数量,后续写前端也省事。

users表的role字段当时只分了普通用户和管理员。为了演示权限控制,我在后面写API时给管理员的删除/下架操作加了判断。这里不展开,但建模时预留一个角色字段,后面对功能扩展会非常友好。

borrows表是核心中的核心。status我设计成字符串,只有两个取值:borrowed和returned。很多教程会建议用枚举或整型映射,但对一个小型演示项目来说,字符串可读性好、写起来直观,后续改成枚举也不难迁移。due_date用来计算是否逾期,return_date在归还时写入,为空表示还没还。这几个字段组合起来,页面上的“借阅中”“已归还”“即将逾期”三种列表就都能查出来。

2.2 用SQLAlchemy定义模型时我踩的第一个坑

表结构定了,接下来是用Python代码把它变成SQLAlchemy模型。我的第3章技术栈选了SQLAlchemy 2.x配合Flask-SQLAlchemy扩展。之所以用ORM而不是直接写SQL,是因为后续MVT多表查询时,ORM的关联属性能省掉大量手工JOIN的样板代码。

模型定义本身不算复杂,但第一个坑很快就出现了:模型之间循环导包。我把用户、图书、借阅三个模型放在三个文件里,借阅模型需要import用户和图书模型,而图书模型又想在关系函数里引用借阅模型,形成了循环依赖。解决办法是使用TYPE_CHECKING做类型检查时的条件导入,运行时则通过字符串引用关系目标:

from __future__ import annotations from datetime import datetime from typing import TYPE_CHECKING from sqlalchemy import ForeignKey, String from sqlalchemy.orm import Mapped, mapped_column, relationship if TYPE_CHECKING: from .book import Book from .user import User class Borrow(Base): __tablename__ = "borrows" id: Mapped[int] = mapped_column(primary_key=True) book_id: Mapped[int] = mapped_column(ForeignKey("books.id")) user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) borrow_date: Mapped[datetime] = mapped_column(default=datetime.now) due_date: Mapped[datetime] = mapped_column() return_date: Mapped[datetime | None] = mapped_column(nullable=True) status: Mapped[str] = mapped_column(String(20), default="borrowed") book: Mapped[Book] = relationship(back_populates="borrows") user: Mapped[User] = relationship(back_populates="borrows")

几句话解释这个模型:Mapped[int]是SQLAlchemy 2.x的声明方式,类型注解直接告诉ORM这一列是什么类型;Mapped[datetime | None]表示这个字段可空,归还前它就是None;relationship是ORM的灵魂,定义了模型在Python层面的关联关系。定义好模型后,查询一条借阅记录时可以直接borrow.book.title取出书名,不再需要手工关联查询,这就是ORM最实用的价值。

第二个坑是datetime.now和datetime.now()的区别。default=datetime.now传的是函数对象,每次插入记录时执行获得当前时间;如果写成default=datetime.now(),那它只在定义模型时执行一次,以后所有记录的时间都会固定成那一刻。这个细节在书里经常一笔带过,但真写错以后,所有数据的创建时间都会一模一样,而且特别难发现。我在实战里吃过这个亏,代码里专门检查了好几遍。

2.3 Alembic迁移:自动生成的脚本绝不是不用看的

模型定义好了,下一步是把模型变成数据库里的真实表。这里我用的工具是Alembic。有很多新手直接db.create_all()一把梭,在原型阶段我完全不反对,但第3章既然要演示“全栈项目该怎么组织”,就应该把数据库版本管理从一开始就引进来。理由很简单:第3章后段我们大概率会改表结构,如果每次改完都drop_all再重建,数据全部清零,那这个项目永远没法积累真实数据。

Alembic的使用流程其实很固定:初始化、配置连接、生成迁移脚本、应用迁移。初始化那步会生成一个alembic/目录和一个alembic.ini。我需要在env.py里把模型元数据关联进去,否则Alembic无法感知我们定义的表:

from app import db from app.models import Book, User, Borrow # noqa: F401 target_metadata = db.metadata

然后生成并执行迁移:

alembic revision --autogenerate -m "create three tables" alembic upgrade head

自动生成的迁移脚本,绝大多数情况下不能直接执行,这是我的肺腑之言。Alembic靠对比“数据库当前状态”和“模型元数据”来生成ALTER或CREATE语句,但它看不懂我们的意图。比如给字段加默认值,自动脚本有时候只生成DDL,未必包含服务端默认值;再比如索引命名,自动生成的名字可读性很差。所以每次生成完,我都会把脚本打开看一遍,确认每一行Upgrade函数里的操作与模型定义一致。这个习惯看着笨,但在后面第5章加索引、改字段类型时帮我挡了好几次灾。

3. REST API从零到能用的实战过程

3.1 项目结构划分与蓝图注册

三张表落库以后,后端就开始进入真正写代码的阶段。我在第3章按照“既然做全栈,目录结构就得往真实项目靠拢”的原则,重新整理了项目结构:

app/ __init__.py models/ __init__.py book.py user.py borrow.py api/ __init__.py books.py users.py borrows.py templates/ static/

每个API模块通过Flask蓝图创建,最后在app/__init__.py里统一注册。这样做的直接好处是:书、用户、借阅三个模块完全解耦。我写图书接口时,不需要为借阅接口的代码分心;以后扩展会员模块,只需新增一个members.py,注册一下,不动其他文件。

蓝图的注册也比较简洁。以借阅接口为例,我们在borrows.py里定义:

from flask import Blueprint bp = Blueprint("borrows", __name__, url_prefix="/api/borrows") @bp.get("/") def list_borrows(): ...

然后在app/__init__.py里:

from .api.books import bp as books_bp from .api.users import bp as users_bp from .api.borrows import bp as borrows_bp app.register_blueprint(books_bp) app.register_blueprint(users_bp) app.register_blueprint(borrows_bp)

Blueprint的url_prefix参数非常方便,它可以统一给整个模块的路由加前缀。这样图书相关的接口都是/api/books/...,借阅相关的都是/api/borrows/...,前端一看URL就知道调的是哪个模块。

3.2 核心API逐个写过去

第3章的API必须覆盖核心业务,所以除了最基本的“增删改查”,我还写了借书、还书这种“行为型”接口。“查”是最简单的,但要考虑查询条件和分页;借书和还书则牵涉状态流转,比普通CRUD复杂得多。

以还书接口为例,逻辑看起来很简单:找到借阅记录,把return_date设为当前时间、status改成returned、对应图书库存加一。但实际写的时候有一个很容易忽略的边界:如果这本书已经被还了,再调一次还书接口怎么办?如果借阅记录根本不存在呢?这些都必须在接口里明确处理,返回合适的错误码和提示信息,而不是让程序崩溃。我用一个简单的状态判断解决了:

@bp.post("/<int:borrow_id>/return") def return_book(borrow_id): borrow = Borrow.query.get_or_404(borrow_id) if borrow.status == "returned": return {"message": "这本书已经归还过了"}, 400 book = borrow.book book.stock += 1 borrow.return_date = datetime.now() borrow.status = "returned" db.session.commit() return {"message": "归还成功", "borrow_id": borrow.id}

get_or_404是Flask-SQLAlchemy提供的一个便捷方法,查不到记录直接返回404,省了我手动判断None的代码。归还成功之前给前端一个明确的错误信息,是因为在实际页面里,用户可能重复点击“还书”按钮,没有这个判断,第二次点击就会把库存加重复。

借书接口则要更仔细一些。核心逻辑是“先验证书有库存,再检查用户有没有逾期未还,最后锁定库存、生成借阅记录”:

@bp.post("/") def create_borrow(): data = request.get_json() book = Book.query.get_or_404(data.get("book_id")) user = User.query.get_or_404(data.get("user_id")) if book.stock <= 0: return {"message": "图书库存不足"}, 400 overdue = Borrow.query.filter_by( user_id=user.id, status="borrowed" ).filter(Borrow.due_date < datetime.now()).first() if overdue: return {"message": "请先归还逾期图书"}, 400 borrow = Borrow( book_id=book.id, user_id=user.id, borrow_date=datetime.now(), due_date=datetime.now() + timedelta(days=30), ) book.stock -= 1 db.session.add(borrow) db.session.commit() return {"message": "借阅成功"}, 201

这里我把“库存不足”和“有逾期未还”两个限制条件排了优先级。正常业务流程应该是先讲能不能借,再讲为什么不能借,给出的提示信息越具体,前端就越容易处理。实际测试中,这样的接口返回JSON格式的message,配合HTTP状态码,前端Fetch可以快速判断成功还是失败,然后决定是弹出提示还是刷新列表。

timedelta(days=30)是借阅时长为30天。这个值我在参数上预留了修改空间,以后如果业务改成15天,只需要改这一处,不用去改表结构。

3.3 JSON序列化翻车现场:datetime不是字符串

接口写完后,满心欢喜地启动服务器、打开浏览器,访问借阅列表接口。结果屏幕上出现了一行BigError。错误信息大约是Object of type datetime is not JSON serializable。这是一个所有Python Web开发者都会遇到的老朋友——Flask的jsonify并不能直接序列化datetime对象,我的借阅记录里都是borrow_date、due_date、return_date这样的时间字段,等于整个接口都输出不了。

我当时没有急着给所有日期字段做字符串转换,而是先想了一个更一劳永逸的办法:给每个模型写一个to_dict方法,把需要输出的字段都转成可JSON序列化的类型:

def to_dict(self): return { "id": self.id, "book_title": self.book.title, "user_name": self.user.name, "borrow_date": self.borrow_date.strftime("%Y-%m-%d") if self.borrow_date else None, "due_date": self.due_date.strftime("%Y-%m-%d") if self.due_date else None, "return_date": self.return_date.strftime("%Y-%m-%d") if self.return_date else None, "status": self.status, }

让每个模型都提供to_dict,而不是在接口里写一堆临时字典组装,有三个好处:模型输出的字段格式统一;时间格式统一控制;接口代码可以保持简洁。缺点也有,就是模型里会多出一些表现层逻辑。对小型项目来说,利大于弊。

这个坑真的值得单独拿出来讲。因为新手写接口时最容易“一看数据正常就直接跳过去”,但浏览器不会骗你,只要有一个字段序列化失败,整个接口就是废的。遇到这类问题,一定要去定位“哪个字段是特殊的”,datetime是最常见的一种,还有Decimal、UUID,都需要类似处理。第3章里我能在前端联调前就把这个问题解决,后续写页面时少了很多来回折腾。

4. 前端页面与后端接口的第一次握手

4.1 用Jinja2模板渲染列表页

回到全栈的大主题上。第3章虽然已写了将近一半的API代码,但这只是后端的一半。要让外行也能直观感受到“这个系统能用了”,必须把数据变成浏览器里看得见的页面。

我选择先用Flask自带的Jinja2模板做服务端渲染,原因是整套技术栈最少、心智负担最低。我们写的Flask应用天然支持模板,不用额外搭Node服务,不用考虑跨域CORS,页面直接由后端拼好发过去。列表页效果很快就能做出来:

<table> <thead> <tr> <th>书名</th> <th>作者</th> <th>库存</th> <th>操作</th> </tr> </thead> <tbody> {% for book in books %} <tr> <td>{{ book.title }}</td> <td>{{ book.author }}</td> <td>{{ book.stock }}</td> <td><button onclick="borrowBook({{ book.id }})">借阅</button></td> </tr> {% endfor %} </tbody> </table>

为了让这个页面在浏览器里正常展示,对应的视图函数需要把数据准备好:

@app.get("/") def index(): books = Book.query.all() return render_template("index.html", books=books)

render_template会去templates/目录找模板文件,把传入的books列表暴露给Jinja2模板使用。这里的核心是,模板语言里的一层包裹{{ }}是输出变量,{% %}是逻辑控制块,语法上和Python很像但又有区别。新手最容易在这里犯的错误是:在{{ }}里写复杂表达式,比如调用方法忘了加括号,或者想在循环里修改变量但发现作用域是局部的。我的建议是:模板里只做轻量级的展示逻辑,复杂的计算放到视图函数里完成。

4.2 页面上的“动态操作”用Fetch配合完成

说完静态的列表页,自然就到了最关键的“借书”按钮。点击借书按钮,不应当刷新整个页面,而是通过异步请求把数据送到后端。这正是Fetch API发挥作用的场景。先看代码,再解释每一步的意图:

<script> async function borrowBook(bookId) { const userId = 1; // 演示环境默认一个用户 const response = await fetch("/api/borrows/", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ book_id: bookId, user_id: userId }) }); const result = await response.json(); if (response.ok) { alert(result.message); } else { alert("借阅失败:" + result.message); } location.reload(); } </script>

这段代码有几个细节值得注意。第一,fetch默认的请求方式是GET,要发起POST请求必须显式指定method: "POST"。第二,发送JSON数据时,Content-Type请求头必须写成application/json,Flask端request.get_json()才能正确解析请求体。第三,response.ok是Fetch提供的一个布尔属性,等价于HTTP状态码落在200到299区间,我正是用它在浏览器端区分成功和失败。

页面中的按钮没有使用传统的<form>提交,而是用onclick触发JavaScript函数,这样体验上更接近现代单页应用,用户不会因为一次点击就按F5反复加载全页。当然小数点后面的细节是,如果borrowBook执行中出现网络错误,页面会reset吗?不会,Fetch的异常会在Promise里抛出,我需要额外用try...catch包一层。第3章里我只处理了最基本的情况,但每一步都留了注释。

这个阶段最让我满意的是“前后端第一次握手”成功的感觉——点击借阅按钮,POST请求发出去,后端改库存、建记录,前端收到JSON消息弹出“借阅成功”,再刷新页面时表格里的库存数字真的少了一点。全栈的概念在这一刻第一次变得具体起来:数据从数据库出发,经过ORM模型、视图函数、模板渲染或JSON接口,最后变成用户在浏览器里看到的反馈。

4.3 浏览器开发者工具才是我真正的主力调试器

新手联调时最常见的现象是:页面点了没反应,第一反应就是去翻后端日志。我第3章里学到的最重要方法论是:先看浏览器开发工具。

打开F12,切到“网络”标签,刷新页面,每一个请求都会按顺序列出来。如果我点击“借阅”按钮后接口没生效,我通常会立刻看这里有没有发出POST请求。如果请求压根没发出去,说明JavaScript报错了,切到“Console”标签看JS异常;如果请求正常发出但返回红色状态码,再去看响应体里的message字段,问题基本就定位了。

有一次我点击借阅按钮后接口返回400,Response Body里写着“库存不足”,这时候问题显然不在前端,而在后端数据状态。我用SQLite终端查了一下那本书的stock,发现确实为0,原来是我前面手动往数据库里插入测试数据时搞错了库存数字。这个排查链路如果顺序颠倒,就会白白在JS代码里找半小时bug。

浏览器开发工具的网络面板还可以直接复制请求为cURL,这样我可以在终端里单独重放这个POST请求,排除前端干扰去测试后端逻辑。第3章的后半段我基本上靠“浏览器看请求 + 终端重放 + SQLite查询”三个工具来回切换,把一系列小bug逐个清理干净。

5. 借阅列表越写越慢:N+1查询与索引优化实战

5.1 慢查询出现时的排查路径

当我的书籍列表只有十几本书时,列表接口响应速度是毫秒级的,完全感觉不到数据库的存在。可当某次我为了做演示,用脚本批量插入了500条书目和2000条借阅记录后,借阅列表页面明显变慢了。界面要等一两秒才能出来。这个延迟不足以让项目崩溃,但对一个全栈教程来说,正好引入真正的性能优化话题。

排查路径很简单:先把问题定位到接口,再用打印语句或日志记录SQL执行时间。借助Flask-SQLAlchemy的eager_load提示,查N条记录时到底发了多少次SQL。如果接口里循环操作数据库,那基本可以断定是典型“N+1查询”问题。

这次慢查询的根子,就是N+1。我原来的借阅列表接口写法是:

@bp.get("/") def list_borrows(): borrows = Borrow.query.all() result = [b.to_dict() for b in borrows] return {"items": result}

这段代码看起来没有任何问题,但to_dict里访问了self.book.title和self.user.name。对于每一条借阅记录,SQLAlchemy默认惰性加载关联对象:使用book.title时才去查一次图书表,使用user.name时再去查一次用户表。如果列表有20条借阅记录,那就产生了1条查询借阅主表 + 20条查图书 + 20条查用户的SQL,一共41条。记录越多,额外查询越多,放大效应越严重。这也是“N+1查询”名称的由来:一次业务操作,实际产生了N条额外的数据库查询。

5.2 N+1问题的本质与解决办法

理解N+1的原因后,解决办法其实一句话就能说清:把关联查询提前到主查询里一并完成,别让ORM在运行时去“慢慢发现”。SQLAlchemy提供joinedload方法,专门解决这个场景:

from sqlalchemy.orm import joinedload @bp.get("/") def list_borrows(): borrows = Borrow.query.options( joinedload(Borrow.book), joinedload(Borrow.user), ).all() result = [b.to_dict() for b in borrows] return {"items": result}

joinedload会通过一次SQL的LEFT OUTER JOIN,把图书和用户信息一并查出来,填进ORM对象上对应的关联属性。这样无论列表里有20条还是2000条记录,数据库查询次数都只有一次。虽然JOIN本身会带来一点点额外时间,但和几十上百次重复查询相比,收益非常明显。

这个优化做完,列表接口从加载需要一两秒,变得几乎瞬间返回。我还是用了相同数量级的测试数据对比,最终结果是查询次数从2001次降到了1次,耗时大约从1500毫秒降到80毫秒。对一个演示项目来说,可能没有十几倍几十倍的量级差异,但N+1问题在大项目中只会更严重,第3章用一个小系统把这个经典问题完整演练了一遍,我觉得很值。

5.3 加索引前后的实测对比

列表页快了以后,我又发现另一个潜在问题:查询“某个用户当前借了哪些书”这个操作,在数据量变多时会成为另一个瓶颈。因为borrows表按user_id和status过滤,没有任何索引时,数据库要全表扫描。

解决方式很直接:加复合索引。

class Borrow(Base): __table_args__ = ( Index("ix_borrows_user_status", "user_id", "status"), )

配合Alembic迁移生成和应用这个索引:

alembic revision --autogenerate -m "add index on borrows user_id status" alembic upgrade head

我特意设计了这个索引字段组合,是因为最频繁的查询条件就是“查某用户在借的书”:WHERE user_id = ? AND status = 'borrowed'。数据库用索引可以直接定位匹配行,不需要扫描整张表。实际测量中,在100万条借阅数据规模的测试库里,这个查询从几百毫秒降到了个位数毫秒。这是第3章里最直观的“索引价值”证明了。

不过这里有个加分项,我必须提醒自己同时提醒读者:索引不是建得越多越好。每个索引都会拖慢写入速度、占用存储空间。判断的依据永远是“实际的查询模式”,而不是“把所有字段都建上索引”。第3章里我只建了这一个复合索引,算是树立了一个克制用索引的榜样。

6. 第3章收尾前我做的三件小事

6.1 补了一遍接口测试

当所有功能都能在浏览器里跑通以后,我停了一下。表面上看项目已经“能用”,但对一个全栈实战来说,光靠浏览器手工点几遍远远不够。我决定把核心接口的测试用pytest补上,至少保证以后改代码不会一脚踩爆基础功能。

测试主要覆盖两类场景:正常流程和异常分支。正常流程包括借一本有库存的书、归还一本书;异常分支包括借不存在书、借库存为0的书、重复归还。每写一个测试,我都会先模拟真实的请求客户端,构造JSON数据,然后断言返回状态码和响应内容。

def test_borrow_success(client, sample_book, sample_user): response = client.post("/api/borrows/", json={ "book_id": sample_book.id, "user_id": sample_user.id, }) assert response.status_code == 201 data = response.get_json() assert data["message"] == "借阅成功" def test_borrow_out_of_stock(client, sample_book, sample_user): sample_book.stock = 0 db.session.commit() response = client.post("/api/borrows/", json={ "book_id": sample_book.id, "user_id": sample_user.id, }) assert response.status_code == 400

这些测试代码在项目里既是回归保护,也是文档。以后有人看到test_borrow_out_of_stock,立刻就能明白接口对“库存不足”场景的期望行为。全栈项目发展到后期最怕的就是改了A模块坏了B模块,有了测试,至少核心链路不会瞬间崩塌。

6.2 认真处理事务边界与回滚逻辑

测试过程中还真揪出一个隐藏bug。某次送数据模拟环境,我故意让借书接口在“库存减1”之后、db.session.commit()之前抛出一个异常(模拟数据库断连之类的极端情况)。理论上,事务应该回滚,库存不应该被扣。但实际测试时发现,由于代码里没有显式try...rollback,Flask-SQLAlchemy的session在请求结束时如何处理异常状态,有时候并不直观。

这个问题的教训是:凡是涉及多步写操作(这里对应“库存减一”和“新增借阅记录”),就应该把它们放在同一个事务里,并显式处理异常回滚:

try: db.session.add(borrow) book.stock -= 1 db.session.commit() except Exception: db.session.rollback() raise

这段代码虽然多写了几行,但保证了“要么借阅记录创建成功且库存扣减,要么什么都不变”,这是数据库一致性的核心。第3章里我专门用测试代码验证了这个场景:强制异常后,库存恢复原值,借阅记录不存在,一切回到初始状态。

很多教程只讲“写代码时要commit”,但不讲“出错时怎么回滚”,等真到了生产环境,遇到半路失败的情况,数据就会留下脏状态。把事务边界处理好,是这个阶段最重要的工程习惯之一。

6.3 一些写给自己的提交清单

每次项目到一个里程碑,我都会整理一份提交清单,当作“这个阶段结束”的标记。第3章结束时我的清单长这样:

  • 数据库三张表结构已确认,迁移脚本可重复执行
  • 图书、用户、借阅三个模块的API均已覆盖核心场景,异常分支有返回信息
  • 借阅列表接口已解决N+1查询,慢查询优化有实测数据支撑
  • 前后端已通过Jinja2模板和Fetch完成基本联调
  • pytest覆盖了借书、还书、库存异常三条关键链路
  • 项目目录结构清晰,蓝图注册规范,后续扩展可直接添加新模块

这份清单没有一项是“炫技”的,全是保证项目能持续演进的底线性要求。我每次写新章节前都会把上一章的清单拿出来过一遍,避免欠技术债。第3章到这里,从数据库建模、REST API到模板与Fetch联调,再到性能优化和测试,整个图书借阅系统已经具备了真实项目的基本雏形。按个人经验来说,能从头到尾把这么一个闭环项目走完,比单纯刷十遍语法题都要有用得多。后续如果再给我时间扩展这个系列,我希望沿着“权限管理”和“部署上线”两个方向继续写,让这个演示项目真正能在服务器上为别人服务。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询