FastAPI 系列 · 第 4 篇:数据库集成------SQLAlchemy 2.0 异步 ORM 与 Alembic 迁移
适合人群 :熟悉 Java Spring Boot + JPA,已完成第 01--03 篇的后端工程师
阅读时间 :约 45 分钟
一句话定位 :本篇完整打通shop-api的数据库层------从 SQLAlchemy 2.0 异步引擎、ORM Model 定义、CRUD Repository,到 Alembic Schema 迁移,建立与 Spring Data JPA + Flyway 的完整认知映射。
一、SQLAlchemy 2.0 新风格
Spring Boot 工程师进入 SQLAlchemy 生态,往往第一反应是找"JPA 的等价物"。SQLAlchemy 确实有类似 JPA 的 ORM 层,但在 2.0 版本中经历了一次彻底的 API 重塑------旧式 Query 对象被废弃,类型注解成为一等公民,异步支持得到全面强化。
理解这次变化的背景至关重要:SQLAlchemy 1.x 的 Query API 是 2005 年设计的,那时 Python 还没有类型注解,也没有 asyncio。到了 2022 年,SQLAlchemy 2.0 用现代 Python 的方式重写了整套 API。
1.1 1.x vs 2.0 核心 API 对比
| 功能 | SQLAlchemy 1.x(旧风格) | SQLAlchemy 2.0(新风格) |
|---|---|---|
| Model 字段定义 | id = Column(Integer, primary_key=True) |
id: Mapped[int] = mapped_column(Integer, primary_key=True) |
| 查询入口 | session.query(Product).filter(...) |
session.execute(select(Product).where(...)) |
| 获取单条记录 | session.query(Product).get(id) |
session.get(Product, id) 或 scalar_one_or_none() |
| 获取列表 | .all() 直接返回对象列表 |
.scalars().all() 从结果集提取 ORM 对象 |
| 关联关系懒加载 | 默认 lazy="select",透明触发 |
异步模式下必须显式 selectinload,否则报错 |
| 声明基类 | Base = declarative_base() |
class Base(DeclarativeBase): pass |
| 会话工厂 | Session = sessionmaker(engine) |
AsyncSessionLocal = async_sessionmaker(engine) |
| 计数查询 | session.query(func.count(Product.id)) |
select(func.count()).select_from(Product) |
最直观的感受:1.x 的 session.query() 是 ORM 专属的链式调用,而 2.0 的 session.execute(select(...)) 更接近直接执行 SQL 语句的心智模型------对于熟悉 JDBC prepareStatement 的 Java 工程师来说,反而更容易理解。
1.2 Mapped 类型注解语法
SQLAlchemy 2.0 引入了 Mapped[T] 泛型类型,让字段定义从"运行时魔法"变成"编译期可检查":
python
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String, Integer, Boolean, DateTime, func
from typing import Optional
from datetime import datetime
class Product(Base):
__tablename__ = "products"
# Mapped[int] 表示非空整型,primary_key=True 告诉 SQLAlchemy 这是主键
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# Mapped[str] 对应 NOT NULL VARCHAR
name: Mapped[str] = mapped_column(String(100), nullable=False)
# Mapped[Optional[str]] 对应可空字段(等价于 nullable=True)
description: Mapped[Optional[str]] = mapped_column(String(500))
# Mapped[bool] 默认值可以在 mapped_column 中指定
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Mapped[datetime] 映射到 DATETIME 类型
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
Mapped[T] 不只是装饰------IDE 和 mypy 可以从中推导出 product.name 是 str 类型,product.description 是 Optional[str] 类型,这在 1.x 时代是做不到的。
1.3 与 Spring Data JPA 的对标
java
// Java Spring Data JPA
@Entity
@Table(name = "products", indexes = {
@Index(columnList = "category, is_active", name = "idx_category_active")
})
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false, length = 100)
private String name;
@Column(name = "price", nullable = false, precision = 10, scale = 2)
private BigDecimal price;
@Column(name = "is_active")
private Boolean isActive = true;
@CreationTimestamp
@Column(name = "created_at")
private LocalDateTime createdAt;
}
python
# Python SQLAlchemy 2.0 等价写法
class Product(Base):
__tablename__ = "products"
__table_args__ = (
Index("idx_category_active", "category", "is_active"), # 等价 @Table(indexes=...)
{"mysql_charset": "utf8mb4"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# autoincrement=True ↔ @GeneratedValue(strategy = GenerationType.IDENTITY)
name: Mapped[str] = mapped_column(String(100), nullable=False)
# String(100) ↔ @Column(length = 100)
price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
# Numeric(10, 2) ↔ @Column(precision = 10, scale = 2)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now()
# server_default=func.now() ↔ @CreationTimestamp
)
| JPA 注解 | SQLAlchemy 2.0 等价 |
|---|---|
@Entity |
继承 Base(DeclarativeBase 子类) |
@Table(name="...") |
__tablename__ = "..." |
@Id + @GeneratedValue |
primary_key=True, autoincrement=True |
@Column(nullable=false) |
nullable=False(或 Mapped[str] 非 Optional) |
@Column(length=100) |
String(100) |
@CreationTimestamp |
server_default=func.now() |
@UpdateTimestamp |
onupdate=func.now() |
@Table(indexes=...) |
__table_args__ 中的 Index(...) |
@ManyToOne |
relationship("User", back_populates=...) + ForeignKey |
二、异步引擎配置(完善篇 03 的 database.py)
第 03 篇在 app/database.py 留下了一个只有 DATABASE_URL 的占位文件。本篇将它填满------这是整个数据库层的基础设施代码,地位相当于 Spring Boot 的 DataSource 配置和 EntityManagerFactory Bean。
2.1 驱动选型:aiomysql vs asyncpg
| 数据库 | 同步驱动 | 异步驱动 | SQLAlchemy 连接字符串前缀 |
|---|---|---|---|
| MySQL | pymysql |
aiomysql |
mysql+aiomysql:// |
| PostgreSQL | psycopg2 |
asyncpg |
postgresql+asyncpg:// |
| SQLite | sqlite3(内置) |
aiosqlite |
sqlite+aiosqlite:/// |
shop-api 使用 MySQL,所以选择 aiomysql。安装依赖:
bash
pip install sqlalchemy[asyncio] aiomysql alembic
2.2 create_async_engine 连接池参数
Spring Boot 工程师对 HikariCP 的连接池参数相当熟悉------maximumPoolSize、connectionTimeout、idleTimeout。SQLAlchemy 的 create_async_engine 提供了完全对应的配置:
| HikariCP 参数 | SQLAlchemy 参数 | 说明 |
|---|---|---|
maximumPoolSize |
pool_size |
连接池维持的核心连接数 |
maximumPoolSize(溢出部分) |
max_overflow |
超出 pool_size 后允许的额外连接数 |
keepaliveTime |
pool_pre_ping |
使用前发一条 SELECT 1,检测连接是否还活着 |
maxLifetime |
pool_recycle |
连接存活时间(秒),超时后强制回收 |
connectionTimeout |
pool_timeout |
从池中获取连接的等待超时 |
spring.jpa.show-sql |
echo |
是否打印生成的 SQL |
pool_pre_ping=True 和 pool_recycle=3600 这两个参数对 MySQL 尤为重要:MySQL 默认 wait_timeout=28800(8 小时),空闲连接超时后会被服务端单方面断开,下次使用时触发 OperationalError: (2006, 'MySQL server has gone away')。这两个参数组合可以彻底消除这个问题。
2.3 完整 app/database.py
python
# app/database.py
# 数据库基础设施:引擎、会话工厂、ORM 基类
# 对应 Spring Boot 中的 DataSource 配置 + EntityManagerFactory + @Entity 基础
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
# ─────────────────────────────────────────────
# 连接字符串格式:
# mysql+aiomysql://<user>:<password>@<host>:<port>/<dbname>?charset=utf8mb4
# 类比 Spring Boot application.yml:
# spring.datasource.url=jdbc:mysql://localhost:3306/shop_db?characterEncoding=utf8mb4
# ─────────────────────────────────────────────
DATABASE_URL = "mysql+aiomysql://user:password@localhost:3306/shop_db?charset=utf8mb4"
engine = create_async_engine(
DATABASE_URL,
pool_size=10, # 连接池核心大小(类比 HikariCP maximumPoolSize 的基础部分)
max_overflow=20, # 超出 pool_size 后额外允许的临时连接数
# 实际最大连接数 = pool_size + max_overflow = 30
pool_pre_ping=True, # 每次从池中取连接前发 SELECT 1,防止 MySQL 8 小时超时断连
pool_recycle=3600, # 连接生命周期 1 小时,主动回收防止 MySQL wait_timeout 报错
echo=False, # 生产环境关闭 SQL 日志(类比 spring.jpa.show-sql=false)
# 调试时可改为 True,或用 echo="debug" 打印更详细的绑定参数
)
# async_sessionmaker 是 SQLAlchemy 2.0 推荐的会话工厂
# 类比 Spring 中的 @PersistenceContext EntityManager,但这里是工厂而非实例
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False, # ⚠️ 关键配置:commit 后不自动失效 ORM 对象的属性
# 默认 True 时,commit 之后再访问对象属性会触发一次新查询
# 在异步环境中,这个新查询可能在 session 关闭后发生,导致报错
# 设为 False:commit 后对象属性保持内存中的值,安全返回给调用方
)
class Base(DeclarativeBase):
"""所有 ORM Model 的基类。
SQLAlchemy 2.0 新风格:定义一个继承 DeclarativeBase 的空类,
替代旧式的 Base = declarative_base()。
类比 Spring Data JPA:所有 @Entity 类隐式共享同一个 EntityManager 管理范围,
这里的 Base 承担类似的"统一管理入口"角色------所有继承 Base 的 Model
的表结构都会注册到 Base.metadata,供 Alembic 做 schema diff。
"""
pass
2.4 async_sessionmaker vs sessionmaker
SQLAlchemy 1.x 的 sessionmaker 返回的 Session 是同步的,其内部 I/O 操作会阻塞事件循环。async_sessionmaker 返回的 AsyncSession 将所有 I/O 操作包装为协程,可以被 await:
python
# ❌ 1.x 同步写法(会阻塞 FastAPI 的 asyncio 事件循环)
from sqlalchemy.orm import sessionmaker
Session = sessionmaker(engine)
with Session() as session:
product = session.query(Product).get(1) # 阻塞!
# ✅ 2.0 异步写法(协程,不阻塞事件循环)
async with AsyncSessionLocal() as session:
result = await session.execute(select(Product).where(Product.id == 1))
product = result.scalar_one_or_none()
三、ORM Model 定义(shop-api 三张核心表)
shop-api 的核心业务围绕三张表展开:用户(User)、商品(Product)、订单(Order)。在此基础上,订单与商品是多对多关系,通过 OrderItem 关联表承载数量和单价。
3.1 ER 图
下单
包含
被购买
User
int
id
PK
string
username
UK
string
email
UK
string
hashed_password
string
role
bool
is_active
datetime
created_at
Product
int
id
PK
string
name
decimal
price
decimal
cost_price
int
stock
string
category
bool
is_active
datetime
created_at
datetime
updated_at
Order
int
id
PK
int
user_id
FK
decimal
total_amount
string
status
datetime
created_at
datetime
updated_at
OrderItem
int
id
PK
int
order_id
FK
int
product_id
FK
int
quantity
decimal
unit_price
3.2 User Model
python
# app/models/user.py
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import String, Boolean, DateTime, func, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
# TYPE_CHECKING 块内的导入只在类型检查时生效,不会在运行时导致循环导入
if TYPE_CHECKING:
from app.models.order import Order
class User(Base):
__tablename__ = "users"
__table_args__ = (
# 唯一索引:username 和 email 各自唯一
# 类比 Spring @Table(uniqueConstraints = @UniqueConstraint(columnNames = "username"))
Index("idx_users_username", "username", unique=True),
Index("idx_users_email", "email", unique=True),
{"mysql_charset": "utf8mb4", "comment": "用户表"},
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(50), nullable=False, comment="用户名")
email: Mapped[str] = mapped_column(String(200), nullable=False, comment="邮箱")
hashed_password: Mapped[str] = mapped_column(
String(200), nullable=False, comment="bcrypt 哈希密码,不存明文"
)
role: Mapped[str] = mapped_column(
String(20), nullable=False, default="user", comment="角色:user/admin"
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
# 关联关系:一个用户对应多个订单
# 类比 Spring @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
orders: Mapped[list["Order"]] = relationship("Order", back_populates="user")
def __repr__(self) -> str:
return f"<User id={self.id} username={self.username!r}>"
3.3 Product Model
python
# app/models/product.py
from datetime import datetime
from decimal import Decimal
from typing import TYPE_CHECKING
from sqlalchemy import String, Numeric, Integer, Boolean, DateTime, Index, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
if TYPE_CHECKING:
from app.models.order_item import OrderItem
class Product(Base):
__tablename__ = "products"
__table_args__ = (
# 复合索引:按分类 + 上架状态查询商品列表是高频操作,复合索引覆盖这两个条件
# 类比 @Table(indexes = @Index(columnList = "category, is_active"))
Index("idx_category_active", "category", "is_active"),
{"mysql_charset": "utf8mb4", "comment": "商品表"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False, comment="商品名称")
price: Mapped[Decimal] = mapped_column(
Numeric(10, 2), nullable=False, comment="售价(精确小数,不用 FLOAT 避免精度丢失)"
)
cost_price: Mapped[Decimal] = mapped_column(
Numeric(10, 2), nullable=False, comment="成本价(用于毛利率计算)"
)
stock: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="库存数量")
category: Mapped[str] = mapped_column(String(50), nullable=False, comment="分类")
description: Mapped[Optional[str]] = mapped_column(
String(1000), nullable=True, comment="商品描述"
)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, comment="是否上架")
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False, comment="创建时间"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
server_default=func.now(),
onupdate=func.now(), # 每次 UPDATE 自动更新时间戳,类比 @UpdateTimestamp
nullable=False,
comment="更新时间",
)
# 关联关系:商品可出现在多个订单明细中
order_items: Mapped[list["OrderItem"]] = relationship("OrderItem", back_populates="product")
def __repr__(self) -> str:
return f"<Product id={self.id} name={self.name!r} price={self.price}>"
💡 为什么用 Numeric 而不是 Float? FLOAT 是浮点数,0.1 + 0.2 = 0.30000000000000004,用来存价格会导致财务计算出错。Numeric(10, 2) 映射到 MySQL 的 DECIMAL(10, 2),精确存储,Python 侧对应 Decimal 类型。这是处理金融数据的基本原则,Java 中同理应使用 BigDecimal 而非 double。
3.4 Order 和 OrderItem Model
python
# app/models/order.py
from datetime import datetime
from decimal import Decimal
from typing import TYPE_CHECKING
from sqlalchemy import Integer, Numeric, String, DateTime, ForeignKey, func, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
if TYPE_CHECKING:
from app.models.user import User
from app.models.order_item import OrderItem
class Order(Base):
__tablename__ = "orders"
__table_args__ = (
# 按用户查订单是高频操作,user_id 单独建索引
Index("idx_orders_user_id", "user_id"),
{"mysql_charset": "utf8mb4", "comment": "订单表"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# ForeignKey 指向 users.id,类比 Spring @ManyToOne + @JoinColumn(name="user_id")
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
total_amount: Mapped[Decimal] = mapped_column(
Numeric(12, 2), nullable=False, comment="订单总金额"
)
status: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="pending",
comment="状态:pending/paid/shipped/completed/cancelled",
)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
)
# 多对一:订单归属于某个用户
# lazy="raise" 表示异步环境下如果试图懒加载就直接报错,强迫开发者显式 selectinload
user: Mapped["User"] = relationship("User", back_populates="orders", lazy="raise")
# 一对多:订单包含多个明细行
items: Mapped[list["OrderItem"]] = relationship(
"OrderItem", back_populates="order", cascade="all, delete-orphan"
)
python
# app/models/order_item.py
# 订单明细(关联表,同时存业务字段:数量、下单时的单价)
from decimal import Decimal
from sqlalchemy import Integer, Numeric, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.models.order import Order
from app.models.product import Product
class OrderItem(Base):
__tablename__ = "order_items"
__table_args__ = (
Index("idx_order_items_order_id", "order_id"),
{"mysql_charset": "utf8mb4", "comment": "订单明细表"},
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
order_id: Mapped[int] = mapped_column(
Integer, ForeignKey("orders.id", ondelete="CASCADE"), nullable=False
)
product_id: Mapped[int] = mapped_column(
Integer, ForeignKey("products.id", ondelete="RESTRICT"), nullable=False
)
quantity: Mapped[int] = mapped_column(Integer, nullable=False, comment="购买数量")
unit_price: Mapped[Decimal] = mapped_column(
Numeric(10, 2), nullable=False, comment="下单时的单价(快照,不随商品价格变动)"
)
order: Mapped["Order"] = relationship("Order", back_populates="items")
product: Mapped["Product"] = relationship("Product", back_populates="order_items")
3.5 app/models/init.py
将所有 Model 集中在包的 __init__.py 中导出,确保 Alembic 做 autogenerate 时能发现所有表:
python
# app/models/__init__.py
# 必须在这里导入所有 Model,使它们注册到 Base.metadata
# Alembic env.py 只需 `from app import models` 即可触发所有注册
from app.models.user import User
from app.models.product import Product
from app.models.order import Order
from app.models.order_item import OrderItem
__all__ = ["User", "Product", "Order", "OrderItem"]
四、异步 CRUD 操作
有了 Model 定义,接下来是 Repository 层------负责封装所有数据库操作,屏蔽 SQL 细节。这一层在 Spring 体系中对应 JpaRepository 接口(或其实现类)。
4.1 AsyncSession 核心操作速查
| 操作 | 代码 | 对应 Spring Data JPA |
|---|---|---|
| 按主键查询 | await session.get(Product, id) |
repo.findById(id) |
| 条件查询单条 | (await session.execute(select(P).where(...))).scalar_one_or_none() |
repo.findOne(spec) |
| 条件查询列表 | (await session.execute(select(P).where(...))).scalars().all() |
repo.findAll(spec) |
| 插入 | session.add(obj); await session.flush() |
repo.save(entity) |
| 更新 | 修改对象属性后 await session.flush() |
repo.save(entity) |
| 删除 | await session.delete(obj) |
repo.delete(entity) |
| 计数 | (await session.execute(select(func.count()).select_from(P))).scalar_one() |
repo.count(spec) |
🤔 flush vs commit 的区别 :
flush()将内存中的变更同步到数据库(生成 SQL 并执行),但不提交事务;commit()提交事务,让变更对其他事务可见。Repository 层一般只做flush()(获取 id 等数据库生成的值),commit()由 Service 层或依赖注入的get_db()上下文管理器统一负责。这与 Spring@Transactional的设计哲学相同:事务边界在 Service 层,Repository 层是事务的参与者而非控制者。
4.2 条件构造:where / and_ / or_
SQLAlchemy 2.0 的条件构造比 JPA Criteria API 简洁很多:
python
from sqlalchemy import select, and_, or_, func
from app.models.product import Product
# 单条件(类比 JPQL: WHERE p.category = :category)
stmt = select(Product).where(Product.category == "electronics")
# AND 多条件(类比 Specification.and())
stmt = select(Product).where(
and_(
Product.category == "electronics",
Product.is_active == True,
Product.stock > 0,
)
)
# OR 条件
stmt = select(Product).where(
or_(
Product.name.like("%手机%"),
Product.category == "phone",
)
)
# LIKE 模糊查询(类比 JPQL LIKE :keyword)
stmt = select(Product).where(Product.name.ilike(f"%{keyword}%"))
# ilike 是大小写不敏感的 LIKE,like 是大小写敏感的
# IN 查询(类比 JPQL IN :ids)
stmt = select(Product).where(Product.id.in_([1, 2, 3, 4]))
# 范围查询
stmt = select(Product).where(
and_(Product.price >= 100, Product.price <= 500)
)
# ORDER BY + LIMIT + OFFSET(分页)
stmt = (
select(Product)
.where(Product.is_active == True)
.order_by(Product.created_at.desc()) # 类比 Sort.by(Direction.DESC, "createdAt")
.offset(offset)
.limit(limit)
)
4.3 完整 app/repositories/product_repo.py
python
# app/repositories/product_repo.py
# 商品数据访问层,封装所有针对 products 表的 CRUD 操作
# 类比 Spring Data JPA 中的 ProductRepository extends JpaRepository<Product, Long>
from typing import Optional
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.product import Product
from app.schemas.product import ProductCreate, ProductUpdate
class ProductRepository:
"""商品 Repository,封装数据库操作细节。
每个 HTTP 请求通过 get_db() 依赖获得一个新的 AsyncSession 实例,
ProductRepository 持有这个 session,保证同一请求内的所有操作在同一事务中。
"""
def __init__(self, db: AsyncSession):
self.db = db
async def find_all(
self,
offset: int = 0,
limit: int = 20,
category: Optional[str] = None,
is_active: bool = True,
) -> tuple[list[Product], int]:
"""分页查询商品列表,返回 (数据列表, 总条数) 元组。
Returns:
tuple[list[Product], int]: 商品列表和总条数(用于计算分页信息)
"""
# 构建动态查询条件(条件列表由实际参数决定)
conditions = [Product.is_active == is_active]
if category:
conditions.append(Product.category == category)
# ── 第一步:查总数 ──
# 类比 JPA: repo.count(Specification.where(...))
count_stmt = (
select(func.count())
.select_from(Product)
.where(and_(*conditions))
)
total: int = (await self.db.execute(count_stmt)).scalar_one()
# ── 第二步:查数据 ──
stmt = (
select(Product)
.where(and_(*conditions))
.order_by(Product.created_at.desc())
.offset(offset)
.limit(limit)
)
result = await self.db.execute(stmt)
products = result.scalars().all()
# scalars() 从 Row 对象中提取第一列(即 Product ORM 对象)
# .all() 返回一个列表
return list(products), total
async def find_by_id(self, product_id: int) -> Optional[Product]:
"""按 ID 查询单个商品。"""
stmt = select(Product).where(Product.id == product_id)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
# scalar_one_or_none(): 有结果返回对象,无结果返回 None,多条结果抛异常
async def find_by_name(self, name: str) -> Optional[Product]:
"""按名称精确查询(用于创建时检查重名)。"""
stmt = select(Product).where(Product.name == name)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def search_by_name(
self, keyword: str, offset: int = 0, limit: int = 20
) -> tuple[list[Product], int]:
"""按名称模糊搜索。"""
condition = and_(
Product.name.ilike(f"%{keyword}%"),
Product.is_active == True,
)
count_stmt = select(func.count()).select_from(Product).where(condition)
total = (await self.db.execute(count_stmt)).scalar_one()
stmt = (
select(Product)
.where(condition)
.order_by(Product.name)
.offset(offset)
.limit(limit)
)
result = await self.db.execute(stmt)
return list(result.scalars().all()), total
async def create(self, data: ProductCreate) -> Product:
"""创建新商品,flush 后可获取数据库生成的 id。
注意:这里只做 flush 而不 commit,事务由 Service 层或 get_db() 上下文统一提交。
类比 Spring @Transactional 方法中的 repo.save(),事务由调用方管理。
"""
product = Product(**data.model_dump())
# model_dump() 将 Pydantic Schema 转为字典,再解包为 Model 的关键字参数
self.db.add(product)
await self.db.flush()
# flush() 执行 INSERT SQL,生成 id,但不提交事务
# 此时 product.id 已可用,可安全返回给调用方
await self.db.refresh(product)
# refresh() 从数据库重新加载最新数据(确保 created_at 等 server_default 字段有值)
return product
async def update(self, product_id: int, data: ProductUpdate) -> Optional[Product]:
"""更新商品字段,只更新请求体中实际传入的字段(PATCH 语义)。"""
product = await self.find_by_id(product_id)
if not product:
return None
# exclude_unset=True: 只处理客户端实际传入的字段,未传的字段保持原值
# 类比 Spring BeanUtils.copyProperties 但只复制非 null 的属性
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(product, field, value)
await self.db.flush()
await self.db.refresh(product)
return product
async def delete(self, product_id: int) -> bool:
"""软删除(设置 is_active=False)而非物理删除。"""
product = await self.find_by_id(product_id)
if not product:
return False
product.is_active = False
await self.db.flush()
return True
async def hard_delete(self, product_id: int) -> bool:
"""物理删除(谨慎使用)。"""
product = await self.find_by_id(product_id)
if not product:
return False
await self.db.delete(product)
await self.db.flush()
return True
async def update_stock(
self, product_id: int, delta: int
) -> Optional[Product]:
"""
原子更新库存(增减操作)
注意:MySQL 不支持 UPDATE...RETURNING,需要分两步操作
delta 为正数表示增加库存,负数表示减少
"""
from sqlalchemy import update as sql_update
# 第一步:执行更新,使用数据库级原子操作防止并发问题
stmt = (
sql_update(Product)
.where(
and_(
Product.id == product_id,
Product.stock + delta >= 0, # 防止库存变为负数
)
)
.values(stock=Product.stock + delta)
)
result = await self.db.execute(stmt)
if result.rowcount == 0:
# rowcount 为 0 说明库存不足或商品不存在
return None
# 第二步:查询并返回更新后的商品
return await self.find_by_id(product_id)
4.4 关联查询:selectinload 加载关联对象
查询 Order 时通常需要同时获取 User 信息和 OrderItem 列表:
python
# app/repositories/order_repo.py(节选)
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy.orm import selectinload
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.order import Order
if TYPE_CHECKING:
from app.models.order_item import OrderItem
class OrderRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def find_by_id_with_items(self, order_id: int):
"""查询订单,同时预加载关联的 OrderItem 和 Product。
selectinload 会额外发出一条 SELECT ... IN (...) 查询,
将关联对象一次性加载到内存,避免 N+1 问题。
类比 Spring Data JPA @EntityGraph 或 fetch = FetchType.EAGER(但不污染 Model 定义)。
"""
stmt = (
select(Order)
.where(Order.id == order_id)
.options(
selectinload(Order.items).selectinload(
# 通过 . 链式加载:Order → OrderItem → Product
# OrderItem.product 是在 order_item.py 中定义的关联关系
Order.items.property.mapper.class_.product
),
)
)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def find_user_orders(
self, user_id: int, offset: int = 0, limit: int = 10
) -> tuple[list[Order], int]:
"""查询用户的订单列表(不加载明细,减少数据量)。"""
from sqlalchemy import func, and_
count_stmt = (
select(func.count()).select_from(Order).where(Order.user_id == user_id)
)
total = (await self.db.execute(count_stmt)).scalar_one()
stmt = (
select(Order)
.where(Order.user_id == user_id)
.order_by(Order.created_at.desc())
.offset(offset)
.limit(limit)
)
result = await self.db.execute(stmt)
return list(result.scalars().all()), total
五、Alembic 迁移
Alembic 是 SQLAlchemy 官方推荐的数据库迁移工具,地位相当于 Java 生态中的 Flyway 或 Liquibase,但工作方式有所不同:Flyway 要求手写 SQL 迁移文件,而 Alembic 支持自动生成 ------它会对比 ORM Model 定义与当前数据库 Schema,生成 upgrade/downgrade Python 脚本。
5.1 初始化与目录结构
bash
# 在项目根目录执行(即 shop-api/)
alembic init alembic
生成的目录结构:
shop-api/
├── alembic/
│ ├── env.py # 迁移环境配置(需要修改以支持 async)
│ ├── script.py.mako # 迁移文件模板
│ └── versions/ # 存放所有迁移版本文件
├── alembic.ini # Alembic 主配置文件
├── app/
│ ├── database.py
│ ├── models/
│ └── ...
5.2 配置 alembic.ini
ini
# alembic.ini
[alembic]
# 迁移脚本存放目录
script_location = alembic
# 数据库连接 URL(与 app/database.py 保持一致)
# 类比 Flyway 的 flyway.url 配置
# ⚠️ 生产环境建议通过环境变量注入,不要硬编码在此文件
sqlalchemy.url = mysql+aiomysql://user:password@localhost:3306/shop_db?charset=utf8mb4
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
5.3 配置 alembic/env.py(异步版本)
这是 Alembic 与 FastAPI 异步架构集成的关键文件。默认生成的 env.py 是同步的,需要改造为异步模式:
python
# alembic/env.py
# Alembic 迁移环境配置(异步版本)
# 类比 Flyway 的配置类,告诉 Alembic 去哪里连接数据库、对比哪些 Model
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# ── 加载应用的 Base 和所有 Model ──
# 这是 autogenerate 能正确检测表结构变化的前提
from app.database import Base
from app import models # noqa: F401 --- 导入触发所有 Model 向 Base.metadata 注册
config = context.config
# 读取 alembic.ini 中的日志配置
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# target_metadata 是 Alembic 做 diff 的"期望状态"
# 类比 Flyway 中你提供的 SQL 文件描述期望的 Schema
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""离线模式:只生成 SQL 脚本,不实际连接数据库。
用于需要 DBA 审核 SQL 再执行的场景:
alembic upgrade head --sql > migration.sql
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
"""在已有连接上执行迁移(同步函数,被 run_async_migrations 通过 run_sync 调用)。"""
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""异步迁移核心函数。
使用 NullPool 而非连接池------迁移是一次性操作,不需要池化。
async with connect() 确保迁移完成后连接被正确关闭。
"""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool, # 迁移工具不需要连接池
)
async with connectable.connect() as connection:
# run_sync() 将同步的 do_run_migrations 包装成异步调用
# 因为 Alembic 内部的迁移操作是同步的,这是官方推荐的桥接方式
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""在线模式:连接数据库直接执行迁移(最常用)。"""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
5.4 生成与执行迁移
bash
# ── 生成迁移文件 ──
# autogenerate: Alembic 对比 Base.metadata 与当前数据库,自动生成 diff
# 类比 Flyway 的 migrate 命令,但 Alembic 先生成脚本供人审核
alembic revision --autogenerate -m "create initial tables"
# 生成后到 alembic/versions/ 检查生成的迁移文件,确认无误再执行
# 自动生成并不完美,某些约束(如自定义触发器、数据库级别的 CHECK 约束)可能漏检
# ── 执行迁移 ──
alembic upgrade head # 升级到最新版本(类比 Flyway migrate)
# ── 其他常用命令 ──
alembic current # 查看当前数据库的迁移版本
alembic history # 查看所有迁移历史(类比 Flyway info)
alembic downgrade -1 # 回滚到上一个版本(类比 Flyway undo,但 Flyway 社区版不支持)
alembic downgrade base # 回滚到初始状态(删除所有表)
alembic upgrade +2 # 向前执行 2 个版本
# ── 生成纯 SQL 文件(供 DBA 审核)──
alembic upgrade head --sql > migration_$(date +%Y%m%d).sql
5.5 典型迁移文件示例
python
# alembic/versions/20240115_001_create_initial_tables.py
"""create initial tables
Revision ID: a1b2c3d4e5f6
Revises:
Create Date: 2024-01-15 10:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic
revision = 'a1b2c3d4e5f6'
down_revision = None # None 表示这是第一个迁移,没有前驱
branch_labels = None
depends_on = None
def upgrade() -> None:
"""升级:创建所有表。"""
# 创建 users 表
op.create_table(
'users',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('username', sa.String(length=50), nullable=False, comment='用户名'),
sa.Column('email', sa.String(length=200), nullable=False, comment='邮箱'),
sa.Column('hashed_password', sa.String(length=200), nullable=False),
sa.Column('role', sa.String(length=20), nullable=False, server_default='user'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id'),
mysql_charset='utf8mb4',
comment='用户表',
)
op.create_index('idx_users_username', 'users', ['username'], unique=True)
op.create_index('idx_users_email', 'users', ['email'], unique=True)
# 创建 products 表
op.create_table(
'products',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(length=100), nullable=False, comment='商品名称'),
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('cost_price', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('stock', sa.Integer(), nullable=False, server_default='0'),
sa.Column('category', sa.String(length=50), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id'),
mysql_charset='utf8mb4',
comment='商品表',
)
op.create_index('idx_category_active', 'products', ['category', 'is_active'])
# 创建 orders 表(依赖 users 表,必须在 users 之后创建)
op.create_table(
'orders',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('total_amount', sa.Numeric(precision=12, scale=2), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False, server_default='pending'),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='RESTRICT'),
sa.PrimaryKeyConstraint('id'),
mysql_charset='utf8mb4',
comment='订单表',
)
op.create_index('idx_orders_user_id', 'orders', ['user_id'])
# 创建 order_items 表(依赖 orders 和 products)
op.create_table(
'order_items',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('order_id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('unit_price', sa.Numeric(precision=10, scale=2), nullable=False),
sa.ForeignKeyConstraint(['order_id'], ['orders.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['product_id'], ['products.id'], ondelete='RESTRICT'),
sa.PrimaryKeyConstraint('id'),
mysql_charset='utf8mb4',
comment='订单明细表',
)
op.create_index('idx_order_items_order_id', 'order_items', ['order_id'])
def downgrade() -> None:
"""降级:按依赖关系逆序删除表。"""
# 删除顺序必须与创建顺序相反(先删有外键的子表)
op.drop_table('order_items')
op.drop_table('orders')
op.drop_table('products')
op.drop_table('users')
5.6 Alembic vs Flyway 对比
| 维度 | Alembic | Flyway |
|---|---|---|
| 迁移文件格式 | Python 脚本(upgrade/downgrade 函数) |
SQL 文件(V1__create_tables.sql) |
| 自动生成 | ✅ --autogenerate 对比 ORM Schema |
❌ 需手写 SQL |
| 回滚支持 | ✅ downgrade() 函数 |
❌ 社区版不支持 undo |
| 版本管理 | 基于有向无环图(DAG),支持分支合并 | 严格线性版本号 |
| 与 ORM 集成 | 深度集成 SQLAlchemy | 纯 SQL,与 ORM 无关 |
| 执行方式 | 命令行工具 | 命令行工具 / Maven 插件 / Spring Boot 自动执行 |
| 版本记录表 | alembic_version(单行) |
flyway_schema_history(多行历史) |
六、ClickHouse 连接预告
shop-api 的未来章节(第 11 篇)将接入 ClickHouse 做实时数据分析------订单量趋势、商品热力图、漏斗分析等 OLAP 场景。这类查询使用 MySQL 会面临全表扫描性能问题,而 ClickHouse 列式存储天生擅长聚合运算。
以下是一个简短的异步接入示例(使用 asynch 驱动),供提前了解:
python
# 安装:pip install asynch
# asynch 是 ClickHouse 官方异步 Python 驱动
import asynch
async def query_order_stats():
"""查询最近 7 天各分类的订单金额汇总。"""
async with asynch.connect(
host="localhost",
port=9000,
database="shop_analytics",
user="default",
password="",
) as conn:
cursor = await conn.cursor()
await cursor.execute(
"""
SELECT
category,
sum(total_amount) AS revenue,
count() AS order_cnt
FROM order_facts
WHERE toDate(created_at) >= today() - 7
GROUP BY category
ORDER BY revenue DESC
"""
)
rows = await cursor.fetchall()
return rows
# 📝 ClickHouse 与 MySQL 的关键差别:
# - ClickHouse 不走 SQLAlchemy ORM,直接写 SQL
# - 没有事务、没有 UPDATE/DELETE(数据只追加不修改)
# - 查询聚合极快(百亿行的 GROUP BY 秒级响应)
# 第 11 篇将详细讲解 ClickHouse 建表、数据写入和 FastAPI 集成
七、常见坑与最佳实践
7.1 N+1 查询(最常见性能陷阱)
❌ 懒加载触发 N+1
python
# ❌ 查询 10 个订单,触发 11 条 SQL(1 条查 orders + 10 条各查 user)
orders = (await db.execute(select(Order))).scalars().all()
for order in orders:
# 每次访问 order.user 都触发一次新的 SELECT FROM users WHERE id = ?
print(order.user.username) # 触发懒加载!
✅ 使用 selectinload 预加载
python
# ✅ 只发出 2 条 SQL:1 条查 orders + 1 条 SELECT ... IN (user_id_list)
from sqlalchemy.orm import selectinload
stmt = select(Order).options(selectinload(Order.user))
orders = (await db.execute(stmt)).scalars().all()
for order in orders:
print(order.user.username) # 无额外 SQL,user 已在内存中
| 加载策略 | 适用场景 | SQL 数量 |
|---|---|---|
selectinload |
一对多、多对多关联 | N(父) + 1(子 IN 查询) |
joinedload |
多对一关联(一个父对应一个子) | 1(JOIN 查询) |
subqueryload |
复杂嵌套关联 | 1 + 1(子查询) |
raiseload |
明确禁止懒加载,用于检测遗漏 | 访问时抛异常 |
7.2 async 下 lazy loading 报错:MissingGreenlet
python
# ❌ 在异步 SQLAlchemy 中触发懒加载会抛出 MissingGreenlet 异常
# 错误信息:sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called
async def get_order(order_id: int, db: AsyncSession):
order = (await db.execute(select(Order).where(Order.id == order_id))).scalar_one()
return order.user.username # 💥 MissingGreenlet!异步上下文中无法触发懒加载
# ✅ 方案一:查询时预加载
async def get_order(order_id: int, db: AsyncSession):
stmt = select(Order).where(Order.id == order_id).options(selectinload(Order.user))
order = (await db.execute(stmt)).scalar_one()
return order.user.username # ✅ 已预加载,安全访问
# ✅ 方案二:在 Model 定义中设置 lazy="raise"(推荐!)
# 这样一旦忘记 selectinload,在测试阶段就会立刻报错,而不是在生产环境发现 N+1
class Order(Base):
user: Mapped["User"] = relationship("User", back_populates="orders", lazy="raise")
💡 最佳实践 :在异步 SQLAlchemy 项目中,将所有关联关系的
lazy参数设为"raise"。这样任何遗漏selectinload的地方都会在开发测试时立刻暴露,而不是静默地产生 N+1 或等到生产环境才触发MissingGreenlet。
7.3 expire_on_commit 的陷阱
python
# ❌ 默认 expire_on_commit=True 时,commit 后访问对象属性触发新查询
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession)
# expire_on_commit 默认为 True
async def create_product(data: ProductCreate, db: AsyncSession) -> ProductResponse:
product = Product(**data.model_dump())
db.add(product)
await db.commit()
# commit 之后,product 对象的所有属性被标记为"过期"
# 下一行访问 product.id 会触发 SELECT,但此时 session 可能已被关闭
return ProductResponse.model_validate(product) # 💥 可能报 DetachedInstanceError
# ✅ 方案一:设置 expire_on_commit=False(推荐,已在 database.py 中配置)
AsyncSessionLocal = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
# ✅ 方案二:commit 后 refresh
async def create_product(data: ProductCreate, db: AsyncSession) -> ProductResponse:
product = Product(**data.model_dump())
db.add(product)
await db.commit()
await db.refresh(product) # 重新从数据库加载,确保属性最新
return ProductResponse.model_validate(product)
7.4 Alembic autogenerate 漏检场景
bash
# ❌ 直接运行 revision 而不加 --autogenerate,生成空迁移文件
alembic revision -m "add index"
# 生成的文件 upgrade() 和 downgrade() 都是空的!
# ✅ 正确:加上 --autogenerate 让 Alembic 自动对比 Schema
alembic revision --autogenerate -m "add index"
📝 autogenerate 无法自动检测的变更(需要手动添加到迁移文件):
- 存储过程、触发器、视图
- 部分列类型变更(如
String(100)→String(200),MySQL 类型相同时可能检测不到) - 服务端默认值的某些变体(
DEFAULT CURRENT_TIMESTAMP ON UPDATE) CHECK约束(SQLAlchemy 默认不生成)
7.5 连接池耗尽导致请求超时
python
# ❌ 依赖函数持有 session 却不释放(session 未被正确关闭)
async def get_db():
db = AsyncSessionLocal()
return db # 忘记 try/finally,连接泄漏!
# ✅ 使用 async with 或 try/finally 确保 session 关闭
# 第 03 篇已给出正确写法:
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
async with session.begin():
yield session
# async with 退出时自动 commit(成功)或 rollback(异常),并关闭 session
7.6 Decimal 序列化问题
python
# ❌ Pydantic 默认将 Decimal 序列化为字符串("99.99"),而非数字
class ProductResponse(BaseModel):
price: Decimal # JSON 输出: {"price": "99.99"}
# ✅ 配置 Pydantic model_config 将 Decimal 序列化为浮点数
from pydantic import BaseModel, ConfigDict
class ProductResponse(BaseModel):
model_config = ConfigDict(
from_attributes=True,
json_encoders={Decimal: float}, # Decimal → float(注意精度问题)
)
price: Decimal # JSON 输出: {"price": 99.99}
# 💡 更稳妥的方案:在 Schema 中直接用 float 类型接收 Decimal
class ProductResponse(BaseModel):
price: float # 从 Decimal 自动转换,JSON 输出数字格式
八、总结
本篇完成了 shop-api 数据库层的完整构建,核心内容回顾:
| 模块 | 关键点 | Spring Boot 对标 |
|---|---|---|
| SQLAlchemy 2.0 风格 | Mapped[T] 类型注解 + mapped_column + select() 替代 Query |
JPA 2.x @Column + Criteria API |
| 异步引擎 | create_async_engine + aiomysql + 连接池配置 |
HikariCP + DataSource Bean |
| 会话工厂 | async_sessionmaker + expire_on_commit=False |
EntityManagerFactory |
| ORM 基类 | class Base(DeclarativeBase): pass |
@Entity 隐式基础 |
| Model 定义 | __tablename__ + __table_args__ + 关联关系 |
@Entity + @Table + @OneToMany 等 |
| 分页查询 | func.count() + offset() + limit() |
Pageable + Page<T> |
| 关联加载 | selectinload(推荐)替代懒加载 |
@EntityGraph / fetch = EAGER |
| Schema 迁移 | alembic revision --autogenerate + alembic upgrade head |
Flyway migrate |
| 数据库回滚 | downgrade() 函数 |
Flyway Undo(企业版) |
🎯 一句话总结 :SQLAlchemy 2.0 + Alembic 是 Python 后端数据库层的黄金组合,异步支持让它与 FastAPI 无缝配合;
Mapped[T]类型注解让原本"运行时黑魔法"的 ORM 定义变得类型安全、IDE 友好,这是 Python 后端工程化水平的一次质的飞跃。
参考资料
- SQLAlchemy 2.0 官方文档 - ORM 快速上手
- SQLAlchemy 2.0 - 异步 I/O
- SQLAlchemy 2.0 - 关联关系加载策略
- Alembic 官方文档 - 异步迁移
- aiomysql GitHub
- asynch ClickHouse 驱动
- Spring Boot HikariCP 配置参考
下期预告
第 5 篇:认证与鉴权------JWT + OAuth2 全流程
下一篇将完善第 03 篇中留下的 get_current_user 占位函数,实现完整的 OAuth2 Password Flow:
- 用户注册与密码 bcrypt 哈希
/auth/token登录端点,颁发 JWT- JWT 生成与校验(python-jose)
- 角色权限控制(Role 枚举 +
require_role依赖工厂) - Refresh Token 双 Token 方案