FastAPI 系列 ·(四):数据库集成——SQLAlchemy 2.0 异步 ORM 与 Alembic 迁移

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.namestr 类型,product.descriptionOptional[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 继承 BaseDeclarativeBase 子类)
@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 的连接池参数相当熟悉------maximumPoolSizeconnectionTimeoutidleTimeout。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=Truepool_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 后端工程化水平的一次质的飞跃。


参考资料


下期预告

第 5 篇:认证与鉴权------JWT + OAuth2 全流程

下一篇将完善第 03 篇中留下的 get_current_user 占位函数,实现完整的 OAuth2 Password Flow:

  • 用户注册与密码 bcrypt 哈希
  • /auth/token 登录端点,颁发 JWT
  • JWT 生成与校验(python-jose)
  • 角色权限控制(Role 枚举 + require_role 依赖工厂)
  • Refresh Token 双 Token 方案
相关推荐
hikktn5 分钟前
Oracle批量UPDATE空值覆盖陷阱:CASE WHEN优雅防御方案【宗申集团】
数据库·oracle
周末也要写八哥8 分钟前
线程的生命周期之线程睡眠
java·开发语言·jvm
Han_han9198 分钟前
数据库基本操作:
数据库
炸薯条!13 分钟前
二叉树的链式表示(2)
java·数据结构·算法
J.Kuchiki21 分钟前
【PostgreSQL 内核学习:平衡 K 路归并(Balanced k-way Merge)】
数据库·学习·postgresql
徐寿春25 分钟前
什么是数据倾斜
java·guava
xieliyu.29 分钟前
MySQL 全套入门笔记:基础、库操作、数据类型
数据库·笔记·mysql
XGeFei30 分钟前
【Fastapi学习笔记(7)】—— Fastapi 中间件、前端跨域请求
笔记·学习·fastapi
lvbinemail32 分钟前
【无标题】
数据库·postgresql·zabbix·监控
李白的天不白33 分钟前
一个服务器可以搭建多个网站
java·tomcat