你的 ORM 正在慢慢杀死你的应用:一个老后端的血泪吐槽
我见过太多团队在应用性能崩溃时疯狂加机器、加缓存、加 Redis,以为是架构不够高大上。结果呢?机器加了三倍,数据库还是慢得像蜗牛。最后排查一圈,问题80%都出在 ORM 上。
今天我不写什么"ORM 入门教程",那种东西烂大街了。我来扒一扒 ORM 那些让人又爱又恨的设计,以及怎么在真实项目里不被它坑死。
第一宗罪:N+1 查询,你的数据库在无声尖叫
先问个问题:你在代码里写过类似这样的东西吗?
users = User.all()
for user in users:
print(user.company.name)
看起来很正常对吧?但如果你去数据库监控里看执行日志,你会发现它执行了:1次查 users 表,然后 N 次查 companies 表(N = users 的数量)。这就是臭名昭著的 N+1 问题。
你一个"简单"的循环,让数据库多执行了几十甚至几百条 SQL。数据库在日志里疯狂重试,你的应用却浑然不觉,还觉得 ORM 真方便真好用。
正确的做法:
users = User.query.options(joinedload(User.company)).all()
或者用 select_in 预加载,把 N+1 优化成 2 条 SQL。这是基本功,但据我观察,至少一半的 Python/Java 后端都踩过这个坑,而且踩完还不知道问题在哪。
第二宗罪:事务边界被悄悄模糊
ORM 的事务封装让一切看起来都很简单。你不需要写 BEGIN、COMMIT,ORM 自动帮你管了。但问题来了——
当你在一个业务方法里调用多个 ORM 操作时,ORM 会把它们放进同一个事务吗?大多数情况下会。但当你在业务逻辑里夹杂了第三方 API 调用、定时任务、消息队列的时候,事务边界就变得模糊了。
我见过最离谱的案例:业务代码在一个方法里做了三件事——更新订单状态、发消息给 MQ、调用仓储服务减库存。结果运维同事在某个高峰期发现数据库里订单状态变了,但库存没扣掉。查了半天发现是仓储服务超时抛异常了,事务已经 commit 了一半。
教训:事务边界必须显式管理,永远不要假设 ORM 会帮你做对。重要操作用 with_for_update() 显式加锁,业务层代码要能清晰看到事务范围。
第三宗罪:批量操作时,你以为的优化其实是假象
当你需要插入或更新 10000 条记录时,你大概会这么写:
for item in items:
db.session.add(Item(**item))
db.session.commit()
看起来只 commit 了一次,很合理对吧?但 ORM 在这个过程中会为每一条记录维护一个持久化对象快照,这个内存开销在数据量大的时候会非常恐怖。
真实案例:我之前参与的一个数据导入功能,导入 5 万条订单记录。用 ORM 的方式跑,跑了 40 分钟还报内存溢出。后来换成原生 SQL:
values = [tuple(item.values()) for item in items]
placeholders = ",".join(["%s"] * len(items[0]))
sql = f"INSERT INTO orders (field1, field2, ...) VALUES {placeholders}"
cursor.executemany(sql, values)
结果:2.3 秒,内存占用稳定在 80MB。
这不是说永远不要用 ORM,而是说当你需要批量操作时,原生 SQL 是你最好的朋友。
第四宗罪:关系管理让表结构越来越难懂
ORM 的关系抽象(has_many、belongs_to、many_to_many)让新手也能快速上手,但代价是:你的数据库表结构设计变得越来越不直观。
我见过一个项目,用 ORM 定义了 12 张表之间的关系链,代码看起来清晰明白。但实际去查数据库时,发现有三张表实际上是冗余的——它们只是 ORM 用来实现 many_to_many 的"连接表",数据团队每次做报表都要多 join 三张表,查询时间翻倍。
一个建议:始终让你的数据库表结构设计独立于 ORM 定义。画 ER 图的时候用数据库思维,而不是 ORM 思维。ORM 是工具,不是数据模型本身。
第五宗罪:分页被悄悄"优化"成了全表扫描
很多 ORM 框架的分页 API 是这样的:
users = User.query.offset(10000).limit(20).all()
你觉得这是从第 10000 行取 20 条?数据库会聪明地跳过前 10000 行?错了。
在 MySQL 里,OFFSET 越大,查询越慢,因为数据库还是要扫描到第 10000 行然后扔掉它。OFFSET 100000 和 OFFSET 0 的查询时间可能差几十倍。
正确姿势:用游标分页代替偏移量分页。
users = User.query.filter(User.id > last_id).limit(20).all()
基于 ID 的游标分页,时间复杂度恒定在 O(1),不管翻到第几页,查询时间都差不多。这是 Pinterest、YouTube 这些大厂在用的方案,不是什么黑科技。
怎么和 ORM 和平共处
说了这么多 ORM 的坏话,不是让你抛弃它。ORM 在快速原型、CRUD 业务、团队协作方面确实有巨大价值。问题是:你要知道它的边界在哪,在边界处果断换档。
我的经验:
- 用 ORM 处理单对象读写、简单查询、关系不复杂业务。这部分 ORM 体验最好,出错概率也低。
- 用原生 SQL 处理批量操作、复杂报表、深度优化场景。特别是涉及到几十张表 join 的分析型查询,ORM 生成的 SQL 往往不够智能。
- 始终监控你的 SQL 执行日志。在开发环境里打开 ORM 的 SQL 日志输出,每一个慢查询背后都是一个被忽视的 N+1 或全表扫描。
- 定期做数据库执行计划分析。用
EXPLAIN看你的查询有没有走索引,扫描了多少行。ORM 写的 SQL 不一定是最优的,你要有能力识别和修正。
最后说一句
ORM 是工具,工具本身没有对错。但如果你只会用它,而不懂它背后发生了什么,那你就成了它的奴隶。数据库不是你的 ORM 的私有财产,它是一个独立的系统,有自己的语言(SQL),自己的优化器,有自己的游戏规则。
尊重数据库,它才会尊重你的应用性能。
下次再遇到性能问题,别急着加机器。先打开数据库日志,看看 ORM 背后在干什么。
你会惊讶的。