Skip to content

后端服务如何组织 ​

很多后端项目一开始只是“接收请求、查数据库、返回结果”,几个月后就变成了这样:

  • 同一段下单规则,在接口里写了一遍,在定时任务里又复制了一遍,后来两边改得不一样;
  • 数据库里出现了各种奇怪的数据:负数的数量、格式不对的手机号;
  • 短信服务一慢,下单接口跟着超时;
  • 线上有用户报错,却怎么也找不到对应的日志。

这些问题的根源都一样:每一层该做的事情没有分清楚。这篇跟着一次请求走一遍,把每一层的职责讲清楚。

读完这篇你会

  • 说清一次请求经过的每一层分别负责什么
  • 把业务规则从控制器里抽出来,让它能被多种入口复用
  • 判断哪些工作应该放进后台异步任务

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,就能直接找到完整的处理过程。详见 错误处理与请求追踪。

排查思路 ​

  1. 按请求 ID 还原现场:从入口日志开始,依次看校验、权限、业务、数据库每一步的记录,找到第一个异常的环节。
  2. 接口慢:在每一层记录耗时(或使用链路追踪工具),看时间花在了数据库查询、外部调用还是业务计算上。
  3. 数据不对:检查数据是从哪个入口写入的,那个入口有没有经过同样的校验和业务服务。
  4. 规则不一致:全局搜索关键的业务判断(比如优惠计算),看是不是在多个地方各写了一份。

检查清单 ​

  • [ ] 控制器只做解析、调用、转换,不写业务规则
  • [ ] 业务服务不依赖 req / res,能被多种入口复用
  • [ ] 所有输入在入口处用 schema 校验并转换类型
  • [ ] 权限判断在执行业务操作之前完成
  • [ ] 耗时、可延后的工作放进可重试的后台任务
  • [ ] 每个请求都有贯穿日志的请求 ID

延伸阅读 ​

为复用而记录,为理解而整理。