后端服务如何组织
很多后端项目一开始只是“接收请求、查数据库、返回结果”,几个月后就变成了这样:
- 同一段下单规则,在接口里写了一遍,在定时任务里又复制了一遍,后来两边改得不一样;
- 数据库里出现了各种奇怪的数据:负数的数量、格式不对的手机号;
- 短信服务一慢,下单接口跟着超时;
- 线上有用户报错,却怎么也找不到对应的日志。
这些问题的根源都一样:每一层该做的事情没有分清楚。这篇跟着一次请求走一遍,把每一层的职责讲清楚。
读完这篇你会
- 说清一次请求经过的每一层分别负责什么
- 把业务规则从控制器里抽出来,让它能被多种入口复用
- 判断哪些工作应该放进后台异步任务
1. 原理:一次请求的旅程
text
客户端请求
→ ① 路由:这个请求交给谁处理
→ ② 解析与校验:参数齐不齐、格式对不对
→ ③ 认证与授权:你是谁、你能不能做这件事
→ ④ 业务服务:按业务规则完成操作
→ ⑤ 数据访问 / 外部服务:读写数据库、调用第三方
→ ⑥ 响应:把结果转换成接口格式
(贯穿全程:日志、指标、错误处理)可以把它想成一家餐厅:前台(路由)接单并分派;服务员核对订单内容(校验);门口确认你是会员、能不能点会员菜(认证授权);厨师按菜谱做菜(业务服务);仓库提供食材(数据层);需要外送的,交给外卖员稍后送达(异步任务)。
每个角色只做自己的事。厨师不需要知道顾客是从哪个门进来的,同一道菜,堂食和外卖都按同一份菜谱做。
2. 每一层负责什么
| 层 | 负责 | 不该做 |
|---|---|---|
| 路由 / 控制器 | 解析请求、调用服务、转换响应 | 写业务规则、直接拼 SQL |
| 校验 | 检查参数,转换成明确的类型 | 依赖数据库判断的业务规则 |
| 认证授权 | 确认身份、判断权限 | 信任客户端传来的身份字段 |
| 业务服务 | 业务规则、事务边界、调用数据层 | 读取 HTTP 请求对象、拼响应格式 |
| 数据访问 | 查询和持久化 | 包含业务判断 |
| 异步任务 | 耗时、可延后、可重试的工作 | 用户必须立即知道结果的工作 |
3. 坑一:业务规则写在控制器里,换个入口就得复制一遍
现象:下单接口里写了“检查库存 → 计算优惠 → 创建订单”的逻辑。后来要做“定时自动续订”,只好把这段代码复制到定时任务里。半年后,接口里的优惠规则改了,定时任务里的忘了改,续订订单的价格算错了。
原因:业务规则和 HTTP 处理绑在一起,只有 HTTP 请求能用它。
修复:控制器保持“薄”,只做三件事:解析输入、调用服务、转换响应。规则放进业务服务,任何入口都调用同一个服务:
js
// 控制器:只负责 HTTP 相关的事
async function createOrderHandler(req, res) {
const input = parseCreateOrder(req.body) // 校验并转换
const order = await orderService.create(input, req.user)
res.status(201).json(toOrderResponse(order))
}
// 定时任务:复用同一个业务服务
async function renewSubscriptions() {
for (const sub of await subscriptionRepo.findDue()) {
await orderService.create(toRenewalInput(sub), sub.owner)
}
}怎么避免再犯:业务服务的参数和返回值只用普通数据(对象、数字、字符串),不接收 req、res。这样它天然就能被 HTTP、定时任务、消息消费者、测试代码共用。
4. 坑二:入口不校验,脏数据进了数据库
现象:商品数量出现了 -3,手机号字段里存着 "null",金额字段里有字符串 "12.5元"。下游的报表、对账全都出了问题。
原因:接口直接把请求体写进数据库,没有任何校验。前端校验可以被绕过,任何人都能直接调用接口。
修复:在入口处用 schema 描述合法的输入,校验并转换类型,不合法就直接拒绝:
js
import { z } from 'zod'
const CreateOrderInput = z.object({
productId: z.string().min(1),
quantity: z.number().int().min(1).max(99),
couponCode: z.string().max(32).optional()
})
function parseCreateOrder(body) {
const result = CreateOrderInput.safeParse(body)
if (!result.success) {
throw new ValidationError(result.error.flatten().fieldErrors) // 统一转成 400/422
}
return result.data // 之后的代码拿到的一定是合法、类型正确的数据
}校验管的是“格式是否合法”;“库存够不够”“优惠券能不能用”这类需要查数据的判断,属于业务规则,放在业务服务里。
5. 坑三:在请求里同步调用第三方,一慢全慢
现象:注册接口平时 200ms 返回,某天突然大量超时。排查发现,注册成功后要同步发送欢迎短信,而短信服务商那天响应很慢。
原因:用户其实只关心“注册成功了没有”,短信晚几秒到完全没关系。但代码在请求里同步等待短信发送完成,第三方的问题直接传导给了用户。
修复:区分“用户正在等待的结果”和“可以稍后完成的工作”:
| 放在请求里同步完成 | 放进后台任务 |
|---|---|
| 参数校验、权限判断 | 发短信、发邮件、推送通知 |
| 核心数据写入(创建账号、扣库存) | 生成报表、处理图片和文件 |
| 用户必须立刻知道的结果 | 同步数据到其他系统 |
后台任务不是“扔出去就不管”:任务要持久化(服务重启不丢)、有重试次数上限、失败要告警、重复执行不能产生副作用。具体做法见 消息队列与异步任务。
6. 坑四:线上报错,找不到对应的日志
现象:用户反馈“提交失败”,开发在日志系统里按时间搜索,同一秒内有几百条日志,无法确定哪条属于这个用户的这次请求。
修复:在入口处为每个请求生成一个唯一的请求 ID,记录到这次请求的所有日志里,并在响应中返回给客户端。用户反馈问题时提供这个 ID,就能直接找到完整的处理过程。详见 错误处理与请求追踪。
排查思路
- 按请求 ID 还原现场:从入口日志开始,依次看校验、权限、业务、数据库每一步的记录,找到第一个异常的环节。
- 接口慢:在每一层记录耗时(或使用链路追踪工具),看时间花在了数据库查询、外部调用还是业务计算上。
- 数据不对:检查数据是从哪个入口写入的,那个入口有没有经过同样的校验和业务服务。
- 规则不一致:全局搜索关键的业务判断(比如优惠计算),看是不是在多个地方各写了一份。
检查清单
- [ ] 控制器只做解析、调用、转换,不写业务规则
- [ ] 业务服务不依赖
req/res,能被多种入口复用 - [ ] 所有输入在入口处用 schema 校验并转换类型
- [ ] 权限判断在执行业务操作之前完成
- [ ] 耗时、可延后的工作放进可重试的后台任务
- [ ] 每个请求都有贯穿日志的请求 ID
延伸阅读
- REST API 设计:接口层怎么设计
- 服务边界与业务编排:业务服务内部怎么组织
- 认证、授权与会话:身份和权限的细节
- 如何学习 PHP:从 0 到 1 做一个项目:用 PHP 亲手把这些层搭一遍