原文地址:https://medium.com/me-n-stack-development/why-your-api-is-slow-10-backend-performance-problems-senior-developers-notice-first-02cc707622d0
原文作者:Gopi C K
编译:闫虹志

几乎每个后端开发者的职业生涯里,都会遇到这样一个时刻:某个在 localhost 上跑得堪称完美的 API,一上生产环境就突然拉胯。开发时请求只要 80 毫秒,上线后却要磨蹭 2 秒;原本秒开的页面开始让人等得心焦;用户怀疑应用出了故障,于是连点两次。人的本能反应是怪服务器、怪数据库、怪云厂商,甚至怪编程语言。但以我的经验来看,真正的问题往往没那么戏剧化——大多数慢 API 并不是毁于某个惊天大 bug,而是被少数几个工程决策慢慢拖垮的:随着流量和数据增长,这些决策悄无声息地变得代价高昂。
如果要给每位初级后端开发者一条建议,我会说:不要因为觉得代码还能更快就去优化它。先搞清楚时间究竟花在了哪里,再动手。资深开发者未必能逐行写出更快的代码,但他们更擅长识别瓶颈。他们知道,一条数据库查询可能比一个精心优化的循环更重要;把五次网络调用压成一次,可能比换算法更有效;加上缓存,有时比重写整个服务带来的提升更大。性能工程始于测量,而不是猜测。
用户调用 API 时,请求很少会把全部时间都花在业务逻辑上。在客户端拿到任何响应之前,请求可能要依次穿过负载均衡器、身份验证中间件、应用代码、数据库查询、外部服务、缓存查找、序列化以及网络传输。如果总响应时间是 1.5 秒,应用逻辑可能只占其中的 100 毫秒。所以光盯着一个函数说“这段代码很快”,几乎说明不了整个请求是否真的快。
第一条实用经验,是学会从请求时间线的角度思考问题。假设有一个名为 GET /api/products 的端点。请求到达服务器后,依次完成身份验证、执行控制器、查询数据库、获取关联数据、将结果序列化成 JSON,最后再把响应传回客户端。别把这一连串动作当成单个操作,而应该把它拆成可以逐个测量的环节。一旦你清楚地看到那 900 毫秒究竟耗在了哪里,优化就会从猜谜变成一个正经的工程问题。
1. N+1 查询问题是最昂贵的错误之一
N+1 查询问题会让应用在早期开发阶段看起来完全健康,最后却随着数据量增长而崩溃。它的模式很简单:先执行一次查询拿回一个集合,再针对集合里的每一项各执行一次查询。比如先取出 100 个用户,再挨个查他们的订单,你就已经把一次数据库交互变成了 101 次查询。开发环境里只有五个用户时一切正常,但在真实数据面前,成本会迅速膨胀。
以社交应用为例:你先拿到 50 篇帖子,再分别去查每篇帖子的作者。初级开发者看到这段代码,可能会想:“我不过是在数据库里查询自己需要的信息啊。”问题不在于正确性,而在于往返次数。数据库操作涉及网络通信、查询解析、执行、锁行为以及结果传输。反复执行这些操作,很容易把一个原本合理的端点拖垮。更好的选择可能是用 join、聚合管道、预加载、批处理,或者精心设计查询,用更少的操作拿到所需信息。
这正是数据库查询日志和性能分析大显身手的地方。在改任何东西之前,先统计一次请求实际执行了多少条查询。你可能会惊讶地发现,一个看似简单的请求竟然发起了几十甚至几百条查询。资深开发者通常会在动手修改应用层代码之前,先寻找重复的查询模式。因为把 100 次数据库操作降到 3 次,带来的收益远远大于优化十行 JavaScript、Java、Python、Go 或者其他语言的代码。
2. 缺少数据库索引会让快速的应用显得像坏了
索引是每个后端开发者都应该吃透的最简单的数据库概念之一,因为它能彻底改变查询性能。假设应用里存了几百万个用户,并且经常按邮箱地址查找用户。没有合适的索引时,数据库可能得把表或集合翻个底朝天,才能找到匹配的记录。有了适当的索引,数据库定位数据就高效得多。但得记住:索引不是魔法,它必须围绕应用实际执行的查询来设计。
初学者常犯的一个错误是,因为索引能加快读取速度,就到处加索引。这只是真相的一半。索引会占用存储空间,也会抬高写入成本,因为数据每次发生变化,数据库都得维护这些索引结构。正确的问题不是“我该给哪些列建索引?”,而是“哪些查询重要到值得建索引?我的应用依赖哪些访问模式?”这种思维上的转变,更接近经验丰富的后端工程师设计数据库的方式。
一个实用的习惯是借助数据库的查询分析工具排查慢查询。查看执行计划,弄清楚数据库到底是在扫描整个数据集,还是在有效地利用索引。如果你经常按 user_id 筛选、又按 created_at 排序,单列索引未必够用。根据数据库和访问模式的不同,复合索引可能更合适。具体方案取决于工作负载,但习惯始终不变:围绕真实查询模式,而不是假设,来设计数据库结构。
3. 返回过多数据同样是性能缺陷
后端一个出人意料的常见问题,是发送远多于客户端实际需要的数据。假设某个端点返回用户资料,前端只需要姓名、头像和账户状态,后端却把整个用户文档都丢了出来——偏好设置、内部元数据、审计信息、设置项、时间戳,还有各种大字段。从技术上看 API 工作正常,但从性能角度看,每个多余的字节都有成本。服务器必须检索、序列化并传输它,客户端也必须接收并处理它。
在列表场景下,这一点更加致命。用户在屏幕上只能看到 10 项内容时,发送 100 个大型对象纯属浪费。分页不只是一项 UI 功能,也是一种重要的后端性能技术。别返回几千条记录,而应该返回大小可控的一页结果,让客户端在需要时再请求更多。面对大型数据集,基于游标的分页通常比基于偏移量的分页更可靠,尤其是在数据频繁变化的时候。
另一项实用技术是有意识地设计响应结构。别让每个端点都原样返回数据库对象。存储模型和 API 契约承担着不同的职责。设计良好的 API 只返回消费者需要的字段,而不是泄露内部实现细节。这么做还能顺带提升安全性——暴露的数据越少,意外返回敏感或内部信息的机会就越少。
4. 过多的外部 API 调用会摧毁响应速度
就算数据库优化得无可挑剔,应用仍可能因为依赖其他服务而变得迟缓。假设某个订单端点必须依次调用支付服务、配送服务、税务服务、推荐服务和分析服务,才能把响应返回给用户。每一次外部调用单独看可能都很快,但它们全都引入了网络延迟和另一个故障点。一个需要串行完成五次外部调用的请求,哪怕自己的服务器几乎没干什么活,也可能慢得让人难以忍受。
资深后端开发者带来的一条重要经验是:追问每项操作是否真的必须待在关键请求路径上。有些事情必须在响应用户之前完成,另一些则不必。发送分析事件、生成通知、创建推荐或更新次要系统,可能都不需要阻塞主请求。这些任务通常可以通过消息队列或后台 worker 异步处理。用户可以快速拿到重要结果,系统则在另一个流程中完成次要工作。
关键思想并不是“一律异步”,而是搞清楚哪些操作至关重要、哪些不是。如果用户在等付款确认,支付验证必须立刻完成。如果用户只是创建一篇帖子,而你还想计算推荐分数,那推荐计算大概率不该待在同一条同步路径上。好的后端架构,往往取决于你能否判断出哪些事情不必现在就做。
5. 代码可能很快,网络却未必如此
后端开发者有时会花好几个小时优化应用代码,却对网络延迟视而不见。现代应用就算看起来不像分布式系统,实际上也已经是分布式系统了。前端可能托管在一个区域,后端在另一个区域,数据库在别处,外部服务又在另一个地方。每一道网络边界都会引入延迟。当多次调用按顺序发生时,哪怕每次延迟都很小,累积起来也会变得相当可观。
这正是网络往返次数至关重要的原因。假设前端需要完成五次独立的 API 调用才能显示一个页面。每个端点可能都在 100 毫秒内响应,但根据请求是串行、并行,还是被其他依赖阻塞,整体体验仍可能慢得多。有时候,更好的方案不是把每个 API 都调快一点,而是减少渲染页面所需的调用次数。
这也意味着 API 设计不再只是路由层面的事情,而是一个架构问题。后端开发者应该琢磨客户端实际怎么使用 API。如果移动应用需要十份数据才能渲染一个界面,强迫它发出十个独立请求可能并不理想。根据产品需求,聚合端点或 Backend for Frontend 层可以把相关数据组合成更实用的响应。最好的 API 不只在理论上整洁,还应该贴合消费者的真实需求。
6. 缓存威力强大,但糟糕的缓存会制造更严重的问题
缓存是改善后端性能最有效的技术之一,因为它能避免重复执行昂贵的工作。如果同一份信息被请求几千次,通常没理由每次都重新计算或重新查数据库。Redis 等缓存可以临时存储经常访问的数据,让应用以快得多的速度提供这些数据。对于读取频率远高于变更频率的数据来说,这尤其有用。
不过,缓存也是最容易引入各种令人费解的缺陷的技术之一。最经典的问题就是数据过期:数据库里的值已经变了,缓存里却还躺着旧值。于是用户更新了资料却依然看到旧信息,因为应用“忠诚地”返回了先前缓存的内容。正因如此,经验丰富的工程师才会反复强调:缓存失效值得严肃的设计考量。只有明确了数据何时进入缓存、在那里待多久,以及底层数据变化时会发生什么,缓存策略才算完整。
给初级开发者的一条实用准则是:别经不住诱惑去缓存一切。应该从那些明显存在重复访问、且成本可测量的工作负载入手。选策略之前,先理解数据的新鲜度要求。产品目录、配置数据、公共内容或计算成本高昂的结果,都可能是合适的候选对象。而必须始终反映最新状态的信息,可能需要完全不同的处理方式。目标不是把缓存用到极致,而是在保证行为正确的前提下减少不必要的工作。
7. 数据量变大后,低效循环会变得代价高昂
应用层性能问题依然重要,尤其是开发者不小心引入扩展性很差的算法时。处理十条记录的循环微不足道,可要是让同样的逻辑去处理 50 万条记录,就可能成为一个严重问题。一个经典错误是在另一个循环内部反复搜索数组。开发者可能写出了简单直观、运行正确的代码,但随着数据集增长,整体复杂度会急剧恶化。
重点不在于为了面试死记硬背 Big-O 表示法,而在于学会识别操作将如何随规模增长。如果你反复扫描一个大型列表来查找某一项,或许可以用 map、哈希表、set、索引查找或数据库查询拿到更高效的方案。后端系统处理的数据规模,会让这些差异产生实实在在的影响。你不需要过早优化每个小函数,但应该学会判断一个算法何时会在真实工作负载下成为瓶颈。
另一个资深开发者的习惯,是知道什么时候不该在应用代码里解决问题。开发者有时会从数据库取回一个庞大数据集,然后在应用内完成筛选、排序和聚合。根据工作负载的不同,数据库本身可能更擅长高效执行这些操作。数据库拥有查询规划器、索引、聚合机制和优化过的执行策略。应用逻辑与数据库逻辑之间的正确边界取决于具体系统,但盲目地把一切都搬进应用代码,几乎从来不是好策略。
8. 同步处理可能阻塞整个应用
后端服务器通常会并发处理许多请求,但昂贵的同步工作仍可能造成麻烦。假设某个端点接收上传的文件,并在响应之前立即完成图像处理、压缩、报告生成、数据库更新和邮件发送。就算这些操作在技术上成功了,用户也被迫等待一些本不需要在请求期间完成的工作。
这正是后台任务大展拳脚的地方。服务器不必在 HTTP 请求里包办一切,而是可以接收任务、向队列投递一条消息,再由 worker 去执行成本高昂的操作。API 可以快速返回响应,后台进程则继续处理。这种模式常用于发送邮件、生成报告、处理媒体、调整图片尺寸、运行数据管道,以及执行其他无须阻塞用户的任务。
这里需要特别区分异步编程和后台任务架构。仅仅使用异步函数,并不会自动让昂贵的工作消失。如果请求仍然必须等结果,用户依旧会感受到延迟。更深层的架构问题是:必须完成这项工作,才能把请求视为已经完成吗?一旦你开始提出这个问题,你就是在设计系统,而不只是编写端点。
9. 缺少连接池会损害数据库性能
为每个请求新建一个数据库连接,是一种会产生无谓开销的经典错误。数据库连接并不免费。反复创建、协商、维护和关闭连接,会引入额外工作,也会给数据库服务器增加压力。因此,生产应用通常使用连接池来维护可复用的连接,并在传入的请求之间分配它们。
连接池是初学者有时会忽略的话题,因为本地开发环境让一切显得很简单。一个只有五次请求的本地应用不会暴露真实后果。但在生产流量下,连接管理至关重要。连接太少会导致不必要的等待;连接太多则可能压垮数据库。正确的连接池大小取决于数据库、应用架构、工作负载和部署环境。因此,从教程里照搬一个随意的配置,不能替代对工作负载的理解。
另一条重要经验是:数据库性能是系统层面的问题。即便应用配置了良好的连接池,一条慢查询也可能长期占用连接。当这种情况在高流量下发生时,请求就会开始排队等待可用连接。突然之间,一个看似应用延迟的问题,实际上成了查询性能与连接管理共同导致的问题。这就是可观测性如此重要的原因:你需要足够的信息来理解哪些资源正在等待,以及它们为什么等待。
10. 缺乏可观测性,会让性能问题变得比实际更难处理
初级与资深后端开发者之间最大的差别之一,往往不在代码本身,而在于能否系统地调查问题。当 API 变慢时,资深工程师不会立刻去随意修改函数,而是会先问:是每个请求都延迟,还是只有一部分?问题是不是在一次部署后开始出现的?数据库变慢了吗?外部 API 是否超时?CPU 使用率是不是过高?内存占用是否在持续增长?请求是不是在等连接?
这正是结构化日志、指标、追踪和性能分析工具不可或缺的原因。你需要了解端点延迟、错误率、请求量、数据库延迟、外部服务延迟、缓存命中率、队列深度、CPU 利用率、内存使用量,以及与系统相关的其他信号。一旦拿到这些测量数据,性能调试就会科学得多。你可以识别变化、验证假设、部署改进,然后测量结果。
一个特别实用的概念是延迟百分位数。平均响应时间可能掩盖严重问题。假设大部分请求在 100 毫秒内完成,却有一小部分需要 5 秒。平均值可能看起来还能接受,但相当一部分用户的体验却糟糕透顶。p50、p95 和 p99 等指标有助于揭示响应较慢的用户实际经历了什么。你无需立刻成为可观测性专家,但学会超越平均值去思考,会大幅提升你分析后端性能的能力。
没有测量,就停止优化
初级开发者最容易掉进的陷阱之一,就是过早优化。听说 Redis 快,于是加上 Redis;听说 Go 效率高,于是用 Go 重写 Node.js 服务;了解到微服务,于是把应用拆成十个服务;听说数据库查询慢,于是给每个字段都加上索引。这些做法听起来可能技术含量很高,但架构应该解决真实存在的问题,而不是想象中的问题。
更好的做法是先建立基线。在动手做重大变更之前,测量当前性能。找出缓慢的端点,把请求拆成多个环节,判断问题究竟出在数据库、应用、网络、外部服务、序列化还是基础设施。做出一项有意义的变更,然后再次测量。如果延迟改善了,你就有了证据。如果没有,你同样获得了宝贵的信息:瓶颈在别处。
这种思维方式之所以重要,是因为优化往往伴随着权衡。读取更快可能需要更多内存;更多索引可能拖慢写入;更多缓存可能增加复杂性;更多服务可能提高独立扩缩容的能力,却让部署和调试更加困难;后台处理可能减少用户感知的延迟,却引入了最终一致性。几乎从来不存在一种放之四海皆准的“最快架构”。优秀的工程实践,是根据系统需求找到恰当的平衡。
在你开始第一份后端工作之前,我想告诉你的事
在你还无法构建和调试一个可靠的 CRUD 应用之前,别沉迷于高级系统设计。先学习 HTTP 请求的工作原理,理解状态码、header、身份验证、校验、数据库查询、事务、索引、错误处理、日志记录和测试,然后动手构建一些真实的东西。一旦亲身经历过项目随数据增长而变慢,缓存、队列、复制和水平扩展这些概念就会变得容易理解得多。
构建一些能够自然暴露性能问题的项目。创建一个具备搜索、筛选、分页、身份验证、文件上传、后台任务,以及包含真实规模数据的数据库的应用。然后,故意让某个环节变慢,再着手调查。加入一条低效查询并测量它;移除一个索引并比较查询计划;引入 N+1 查询并观察数据库操作次数;接入一个缓慢的外部服务,看看它如何拖累端点。这些实验远比单纯阅读定义更有教育意义。
此外,还要学会正确使用工具。你应该知道如何检查网络请求、阅读服务器日志、分析应用代码性能、检查数据库查询、测试端点和监控资源使用情况。一个能迅速回答“那 800 毫秒到底花在了哪里?”的后端开发者,远比只能背诵十个系统设计流行词、却无法调试缓慢端点的人更有价值。
最重要的是记住:性能优化并不意味着让一切都尽可能快,而是让系统在真正重要的规模下足够快、足够可靠。一个小型内部应用不需要全球性社交网络的架构。初创公司不应该仅仅因为大公司采用分布式系统,就照搬一套分布式系统。先从简单的设计开始,测量真实行为,理解瓶颈,并在问题确实需要时再引入复杂性。
你应该尽早养成的资深开发者习惯
当某个东西变慢时,不要问“我能优化什么?”,而要问“时间花在了哪里?”仅仅改变这一种思考方式,就会改善你处理后端开发问题的方法。它会促使你测量而不是猜测,调查而不是重写,理解整个系统而不是只盯着单个函数。
一个缓慢的 API 往往不是由某一行糟糕透顶的代码引起的,而更可能源自十个小决定:查询过多、缺少索引、响应体过大、网络调用过多、缓存不当、算法低效、同步工作、连接管理问题,以及缺乏可观测性。单独来看,这些概念都不难。真正的挑战,是学会理解它们在应用进入真实环境后如何相互作用。
如果你是一名初级后端开发者,不必因为无法立刻掌握每个分布式系统概念而感到压力。你的目标应该是培养敏锐的直觉:责怪数据库之前先检查查询;重写端点之前先测量它;添加缓存之前先理解数据;购买更多基础设施之前,先减少不必要的工作。养成这些习惯后,系统设计会变得容易得多。因为你不会再盯着架构图发呆,而会把它理解为一系列真实的工程权衡。
你今天构建的后端系统,可能只有一台服务器、一个数据库和少数几个用户。这完全没问题。重要的是,你要理解系统为什么能够运行、它最终会在哪里遇到困难,以及当那一天到来时,该如何找出瓶颈。优秀的后端开发者并不是那些能神奇地杜绝一切性能问题的人,而是那些知道如何找到问题、清晰解释问题、谨慎修复问题,并证明修复确实有效的人。如果你也在排查类似的性能瓶颈,不妨带着你的测量数据和排查思路来云栈社区一起聊聊,真实的案例讨论往往比单独啃文档更有启发。