软删除是逻辑标记而非物理移除,仅用 is_deleted 字段不够,需全局拦截查询、关联加载及事件监听以确保所有数据入口均尊重该标记。

什么是软删除,为什么不能只靠 is_deleted 字段
软删除本质是逻辑标记而非物理移除,但光加个 is_deleted 布尔字段远远不够。SQLAlchemy 不会自动识别这个字段的语义,所有查询(包括 session.query(Model)、Model.query.all()、关联加载)默认都会拉出已标记删除的记录。必须显式干预查询构建过程,否则“软”就变成了“假装删了”。
常见错误现象:model.delete() 后查出来还在;外键关联对象仍能加载已软删的父记录;admin 后台列表里出现“已删除”的脏数据。
用 query_class + 自定义 Query 实现全局过滤
最直接可控的方式是重写模型的默认查询类,让每次 Model.query 或 session.query(Model) 都自动加上 is_deleted == False 条件。
实操建议:
- 定义一个继承
Query的子类,在__iter__或all()调用前统一 apply filter - 在模型中通过
query_class属性指定该类 - 确保
is_deleted字段类型为Boolean,且有默认值False
class SoftDeleteQuery(Query):
def __iter__(self):
return super().filter(self._mapper_zero().is_deleted == False).__iter__()
class User(Base):
tablename = 'users'
id = Column(Integer, primary_key=True)
name = Column(String(50))
is_deleted = Column(Boolean, default=False)
query_class = SoftDeleteQuery
注意:这种方式对 session.query(User) 无效(它不走 User.query),需配合 session.query(User).with_polymorphic('*') 或改用 User.query 风格。
用 SQLAlchemy 2.0+ 的 select() + with_only_columns 替代旧式 query
SQLAlchemy 2.0 推荐使用 select() 构建查询,此时无法复用 query_class。需手动封装一个带过滤的查询构造器,或借助 Event 拦截 select()。
更稳妥的做法是定义一个类方法:
class User(Base):
# ... 字段定义同上
@classmethod
def not_deleted(cls):
return select(cls).where(cls.is_deleted == False)
使用:
stmt = User.not_deleted().where(User.name.contains('john'))
result = session.execute(stmt).scalars().all()
这样既清晰又可控,避免隐式行为。如果项目已大量使用 select(),硬塞 query_class 反而增加理解成本。
外键关联和 joinedload 时如何保持软删除语义
软删除最大的坑不在主表,而在关联——比如查订单时,joinedload(Order.user) 会把已软删的用户也一起加载进来,破坏业务一致性。
解决方案分两层:
- 对一对一/多对一外键,在关系定义中加
primaryjoin条件,例如:user = relationship('User', primaryjoin='and_(Order.user_id == User.id, User.is_deleted == False)') - 对一对多(如 User.orders),在
relationship中加viewonly=True+ 单独定义一个not_deleted_orders属性,用select()+where显式过滤 - 避免在
lazy='joined'场景下依赖默认关联,容易漏掉 is_deleted 判断
真正难处理的是嵌套三层以上的关联(比如 Order → Item → Supplier),这时候推荐用 CTE 或物化视图预过滤,而不是靠 ORM 动态拼条件——性能和可读性都会迅速恶化。
软删除不是加个字段就完事,关键在于所有数据入口(CRUD、关联、批量操作、raw SQL)是否都尊重这个标记。最容易被忽略的是事件监听器(如 @event.listens_for(User, 'before_insert'))和 Alembic 迁移脚本里的初始数据插入——它们常常绕过模型层逻辑,直接写入 is_deleted = True 却没配好默认值或索引。










