后端反模式:7个代价高昂的错误及其实用解决方案
了解如何识别并修复七种常见的后端工程错误,从臃肿的控制器到未处理的异常,从而避免其在生产环境中引发问题。
在开始学习后端开发时,人们很容易认为真正的挑战在于掌握更多工具。
Node.js。
Express。
PostgreSQL。
Redis。
Docker。
消息队列。
系统设计。
但在开发了多款应用之后,一个不同的真相便显现出来。
掌握更多工具并不会自动让你成为更出色的后端工程师。
真正的成长大多来自于犯错、分析错误原因,并确保同类错误不再发生。
以下是七种值得借鉴的后端开发错误,以及更有效的解决方式。
1. 将所有功能都放在控制器中
这往往是人们最先犯的错误之一。
一个接口最初可能看起来像这样:
app.post("/orders", async (req, res) => {
const { userId, productId, quantity } = req.body;
const user = await db.users.findUnique({
where: { id: userId }
}); if (!user) {
return res.status(404).json({
message: "User not found"
});
} const product = await db.products.findUnique({
where: { id: productId }
}); if (!product) {
return res.status(404).json({
message: "Product not found"
});
} if (product.stock < quantity) {
return res.status(400).json({
message: "Not enough stock"
});
} const order = await db.orders.create({
data: {
userId,
productId,
quantity
}
}); await sendEmail(user.email); return res.status(201).json(order);
});
它能正常工作。
但看看这个函数现在要处理的所有内容:
- 验证
- 数据库查询
- 业务规则
- 库存检查
- 订单创建
- 邮件处理
- HTTP响应
随着项目规模不断扩大,承担如此多职责的函数会逐渐变成难以管理的庞大逻辑块。
解决办法就是拆分这些职责。
Request
↓
Controller
↓
Service
↓
Repository
↓
Databas
控制器的职责是处理HTTP请求。
服务层负责业务逻辑。
数据访问层负责与数据的交互。
这并不意味着小型应用就需要六层抽象结构。
它的含义是系统的每个部分都应拥有明确界定的职责。
2. 信任前端
这种做法可能会引入错误,更糟糕的是还会造成安全漏洞。
假设前端发送了如下数据:
{
"price": 10,
"quantity": 2
}
人们很容易想直接用客户端发送的价格来计算订单总额。
不要这么做。
没有任何东西能阻止恶意客户端发送:
{
"price": 1,
"quantity": 100
}
前端由用户控制,而非你。
你的后端需要验证并执行真正重要的规则。
例如:
const product = await productRepository.findById(
productId
);
const total = product.price * quantity;
定价的权威应来自后端,而非客户端。
同样的谨慎态度也需应用到客户端可能试图影响的其它多个领域:
- 用户角色
- 权限
- 折扣
- 库存
- 支付金额
- 账户状态
- 资源所有权
把前端视为塑造用户使用产品体验的工具即可。
这并非安全边界。
3. 不良的错误处理方式
早期,错误处理通常如下所示:
try {
// something
} catch (error) {
console.log(error);
return res.status(500).json({
message: "Something went wrong"
});
}
使用通用兜底处理方式本身并无问题。
问题在于在所有情况下都依赖它。
用户记录缺失并不一定属于500级错误。
请求格式错误也不一定属于500级错误。
邮箱重复同样不一定是500级错误。
后端需要区分不同的故障类型。
例如:
400 → Invalid request
401 → Authentication required
403 → Not allowed
404 → Resource not found
409 → Conflict
422 → Validation failure
500 → Unexpected server error
所选择的特定状态码取决于API的规范,但保持一致性比具体的方案更重要。
结构化的错误响应也有帮助。
例如:
{
"success": false,
"message": "User already exists",
"code": "USER_ALREADY_EXISTS"
}
采用这种格式后,前端无需猜测出了什么问题。
4. 硬编码配置
在尝试部署之前,这种错误似乎并无大碍。
例如:
const databaseUrl =
"postgresql://user:password@localhost:5432/app";
或者:
const jwtSecret = "my-secret";
应完全避免这种模式。
您运行的每个环境都需要独立的设置。
您可能会遇到:
Development
↓
localhost
Staging
↓
staging databaseProduction
↓
production database
相反,应采用基于环境的配置方式:
DATABASE_URL=
REDIS_URL=
JWT_SECRET=
PAYMENT_API_KEY=
EMAIL_API_KEY=
并且绝不要将敏感信息提交到版本控制系统中。
.env 文件适用于本地开发,但生产环境需要合适的敏感信息与配置管理工具。
核心原则是:
您的代码不应与特定于环境的配置值紧密绑定。
5. 在真正需要之前就进行扩展
这是许多开发者在某个阶段都会陷入的陷阱,当时很容易为其找借口。
一个团队启动新项目后立刻会想到:
“如果明天有1000万人出现会怎样?”
于是他们就决定:
Microservices
Kafka
Redis
Kubernetes
Multiple databases
API Gateway
Event-driven architecture
从理论上讲,该系统能够处理大规模需求。
但实际上只有五名用户。
这并非稳健的架构,只是多了些目前并不需要的复杂性。
对于大多数项目而言,从简单开始更为合理:
Client
↓
Node.js Application
↓
PostgreSQL
只有当有明确理由时,才引入新组件:
Need caching?
→ Redis
Need background jobs?
→ Queue + WorkerNeed more API capacity?
→ Multiple instances + Load BalancerDatabase becoming a bottleneck?
→ Optimize queries / indexes / architecture
让架构随着实际且已验证的需求逐步发展。
不要仅仅因为某些视频声称“真正的”高级工程师都这么做,就盲目采用分布式系统。
6. 拦截处理缓慢的任务请求
这种情况会严重破坏使用 API 的体验。
想象这样的场景:
app.post("/order", async (req, res) => {
const order = await createOrder(); await sendEmail(); await generateInvoice(); await notifyWarehouse(); await updateAnalytics(); return res.json(order);
});
用户只能等待这五个步骤依次完成。
如果哪怕只有一个外部调用多耗时五秒,整个响应时间就会随之增加五秒。
更好的做法是只在请求中处理那些确实需要立即完成的操作。
其余任务都可以放入队列中进行后台处理。
Client
↓
API
↓
Create Order
↓
Queue Jobs
↓
Response
接下来是:
Queue
↓
Worker
├── Send Email
├── Generate Invoice
├── Notification
└── Analytics
正是在这种场景下,BullMQ 与 Redis 的组合才能发挥其作用。
但这里还有另一个容易被忽视的要点:
后台任务必须具备幂等性,并能妥善处理失败情况。
如果某个任务意外执行了两次,可能带来的风险包括:
- 向客户多次收费
- 发送重复的通知
- 在数据库中写入重复的记录
仅仅将任务放入队列并不能解决这个问题。
必须从设计阶段就考虑到这一点。
7. 生产环境中的盲目操作
这种错误通常在出现问题之前都难以被发现。
想象一下,某个正在运行的 API 突然开始出现错误。
乍看之下服务器似乎没有问题。
代码也看起来正常。
但实际存在的问题是:
No useful logs
No request IDs
No metrics
No error tracking
No database monitoring
此时,唯一的办法就是猜测。
调试过程就变成了接连不断的无奈摇头:
“也许是 Redis 崩溃了?”“也许是数据库运行速度变慢了?”“也许是支付服务出现了问题?”
作为工程师,身处这种状况实在令人不安。
至少,有用的日志是必不可少的。
例如:
{
"level": "error",
"requestId": "req_123",
"route": "/orders",
"userId": "user_456",
"message": "Payment provider timeout"
}
有了这样的输出,就能准确追踪问题出在什么地方以及原因是什么。
随着系统的不断发展,可观测性通常会扩展到涵盖以下方面:
- 应用程序日志
- 错误追踪
- CPU和内存使用情况
- 数据库层面的指标
- API响应时间
- 队列积压量
- 外部API的故障情况
- 健康检查端点
如果无法了解系统的运行状况,它就无法保持健康状态。
更深层的启示
仔细分析后就会发现,几乎所有这些问题都源于同一个根本性的习惯。
许多决策所依据的问题往往是:
“这个功能能用吗?”
而实际上应该问的是:
“这个功能是否易于修改、调试以及在生产环境中运行?”
这种思维方式的转变几乎改变了软件开发的方方面面。
在认为API开发完成之前需要验证的内容
在将某个后端功能标记为已完成之前,最好先进行以下检查。
代码
- 每一层是否有明确且单一的职责?
- 业务逻辑能否独立进行测试?
- 控制器是否保持适度简洁?
安全性
- 所有传入的输入是否都经过验证?
- 权限检查是否在服务器端执行?
- 敏感信息是否得到妥善保护?
数据库
- 查询效率是否较高?
- 是否存在合适的索引?
- 在需要处是否使用了事务处理?
- 是否存在隐藏的N+1查询问题?
性能
- 那些可以避免的顺序调用是否已被消除?
- 繁重的任务是否被转至后台进程处理?
- 使用缓存能否提升性能?
可靠性
- 如果外部API出现故障,有什么备用方案?
- 重试机制是否设置合理?
- 后台任务是否可以安全地多次运行?
- 如果Redis或数据库无法访问,会怎样?
运维
- 当出现问题时,能否查明原因?
- 日志是否真正有用?
- 是否有健康检查机制?
完美的系统并非目标。
但必须清楚出问题时究竟发生了什么。
七大常见错误一览
| 错误类型 | 更好的处理方式 |
|---|---|
| 所有功能都集中在控制器中 | 将职责分配到不同层次 |
| 轻信前端传来的数据 | 在服务器端对所有数据进行验证 |
| 采用千篇一律的错误处理方式 | 使用统一的错误处理策略 |
| 使用硬编码的敏感信息 | 妥善管理环境和配置 |
| 过早进行扩展 | 根据实际瓶颈再行扩展 |
| 耗时的操作阻塞请求处理 | 将其放到后台任务中处理 |
| 无法了解生产环境状况 |
核心要点
人们普遍认为,提升后端开发者的水平意味着掌握更多工具和技术。
更准确的看法是,关键在于理解权衡取舍。
这个操作应该是同步还是异步?
这些数据值得缓存吗?
这个查询需要建立索引吗?
这应该成为一个独立的服务吗?
如果 Redis 停止工作,有什么应对方案?
如果数据库运行速度变得极慢,有什么应对方案?
如果某个任务意外被执行了两次,有什么应对方案?
如果依赖的外部 API 出现故障,有什么应对方案?
掌握这些问题的答案远比单纯知道如何安装另一个软件包重要得多。
因为生产系统的优劣并非取决于一切正常时的表现。
真正的工程能力体现在出现问题时。
今天能识别的每一个错误,都能减少日后需要处理的紧急故障。
相关阅读
- 破坏前端可靠性的常见API契约错误 — 了解十种常见的后端API设计缺陷,从不一致的响应格式到脆弱的分页机制,这些缺陷如何削弱前端的信任度以及相应的解决方法。