每一个让人感觉快速、可靠的应用背后,都有一个设计良好的后端。后端掌管业务规则、守护数据,并在无人察觉的情况下每秒响应成千上万个请求。尽早把架构做对,可以省下日后数月的返工;做错了,就会表现为故障、缓慢的页面,以及开发周期远超预期的功能。
本文梳理最关键的几个决策:如何设计 API、如何组织服务、如何建模数据,以及如何在流量增长时保持系统平稳运行。
设计开发者乐于使用的 REST API
REST 把系统建模为资源(名词),客户端通过标准 HTTP 方法(动词)来操作它们。一致性就是全部意义所在:开发者学会一个端点后,就应该能猜出其余的端点。
GET /v1/orders列出订单,通过查询参数进行筛选和分页。GET /v1/orders/{id}返回单个订单。POST /v1/orders创建订单,返回201 Created和Location响应头。PATCH /v1/orders/{id}更新订单的部分字段;PUT则整体替换。DELETE /v1/orders/{id}删除订单,返回204 No Content。
状态码是接口契约的一部分,请准确使用:
- 成功时使用
200 OK、201 Created和204 No Content。 - 请求格式错误时用
400 Bad Request,格式正确但校验失败时用422 Unprocessable Content。 - 调用方未认证时用
401 Unauthorized,已认证但无权限时用403 Forbidden。 404 Not Found,重复记录等状态冲突用409 Conflict,触发限流时用429 Too Many Requests。5xx只用于服务端自身的故障,绝不用于错误的输入。
下面是一个使用 Express 的 TypeScript 端点,它在边界处校验输入并返回正确的状态码:
import express from 'express'
import { z } from 'zod'
import { orders } from './orders'
const app = express()
app.use(express.json())
const CreateOrder = z.object({
customerId: z.string().uuid(),
items: z.array(z.object({ sku: z.string().min(1), quantity: z.number().int().positive() })).min(1),
})
app.post('/v1/orders', async (req, res) => {
const parsed = CreateOrder.safeParse(req.body)
if (!parsed.success) {
return res.status(422).json({
type: 'https://api.example.com/problems/validation',
title: 'The request body is invalid',
status: 422,
errors: parsed.error.issues.map((issue) => ({ path: issue.path.join('.'), message: issue.message })),
})
}
const order = await orders.create(parsed.data)
res.status(201).location(`/v1/orders/${order.id}`).json(order)
})统一的错误、分页与版本管理
所有错误都以相同的结构返回,客户端就能统一处理。Problem Details 格式(RFC 9457)是一个被广泛支持的标准:
{
"type": "https://api.example.com/problems/out-of-stock",
"title": "Item is out of stock",
"status": 409,
"detail": "SKU TS-1042 has 0 units available; 3 were requested.",
"instance": "/v1/orders"
}- 分页:对于数据量大或变化快的集合,优先使用基于游标的分页(
?after=ord_123&limit=50)。基于偏移量的分页在客户端翻页期间有新行插入时,会漏掉或重复数据。 - 版本管理:把主版本号放在路径中(
/v1/),同一版本内只新增字段。删除或重命名字段属于破坏性变更。 - 幂等性:允许客户端在
POST请求中携带Idempotency-Key请求头,这是支付类 API 普及的做法,这样重试的请求永远不会重复创建订单或重复扣款。
单体还是微服务?
单体是一个可独立部署的应用。微服务把系统拆分为多个小服务,每个服务拥有自己的数据并独立部署。微服务常被宣传为现代化的默认选择,但它们是用运维复杂度换取代码复杂度:
- 收益:独立部署,单独扩展热点模块,团队之间互不干扰。
- 代价:网络调用可能失败或变慢,数据分散在多个数据库中且没有共享事务,需要分布式追踪和调试,以及多得多的基础设施需要运维。
对大多数产品来说,正确的起点是模块化单体:只有一个部署单元,但内部有严格的边界(订单、计费、商品目录),模块之间只通过定义好的接口通信。如果日后某个模块需要独立扩展或发布,它的边界已经足够清晰,可以直接拆分成服务。
当团队规模或扩展需求真正需要时,再沿着业务边界拆分系统,而不是因为架构图看起来更漂亮。
当服务之间需要通信时,如果调用方需要立即得到结果,就使用同步调用(REST 或 gRPC);如果其他服务只需要响应某件事,例如“订单已创建”“已收到付款”,就通过消息代理使用异步事件。为了可靠地发布事件,应在业务变更的同一个数据库事务中把事件写入 outbox 表,再由独立进程转发给消息代理。否则,两步之间一旦崩溃,数据和事件就会不一致。
可扩展的数据库设计
大多数性能问题都是数据库问题。以下几条原则效果显著:
- 先建模业务领域,再考虑查询。从规范化设计开始,让每个事实只存放在一个地方;只在经过测量、确有需要的查询上有意识地反规范化。
- 让数据库来执行规则:
NOT NULL、外键、UNIQUE和CHECK约束能捕获应用代码遗漏的错误。 - 用整数(以分为单位)或 decimal 类型存储金额,绝不使用浮点数。
- 为真实的查询建立索引,并用
EXPLAIN进行验证。每个索引都会加快读取,但会拖慢写入。 - 通过版本化的迁移脚本变更 schema,自动执行,绝不在生产环境中手工修改。
CREATE TABLE orders (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL REFERENCES customers (id),
status TEXT NOT NULL CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled')),
total_cents BIGINT NOT NULL CHECK (total_cents >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Serves "latest orders for a customer" without scanning the whole table
CREATE INDEX orders_customer_created_idx ON orders (customer_id, created_at DESC);警惕 N+1 查询问题:加载 50 个订单,再为每个订单单独查询一次客户信息。应改为用 join 或批量 WHERE id IN (…) 在一次查询中获取关联数据。同时要使用连接池,因为每个请求都新建数据库连接既慢,又会很快耗尽服务器资源。
行之有效的扩展模式
- 保持应用服务器无状态(会话存放在数据库或缓存中,文件存放在对象存储中),这样就能在负载均衡器后面按需运行任意数量的副本。
- 缓存开销大的读取操作并设置较短的过期时间,事先确定数据变化时每个缓存项如何失效。
- 把耗时的工作移到后台队列:邮件、报表、图片处理以及对第三方的调用。API 立即响应,由 worker 随后处理。
- 读多写少时增加只读副本,并把报表查询发往副本而不是主库。
- 对公开端点进行限流,保护系统免受滥用和失控客户端的影响。
- 数据分区或分片放在最后,只有在一个调优良好的数据库确实无法支撑时才考虑。
可靠性与可观测性
分布式系统会以局部的、令人困惑的方式出错,所以设计时就要考虑到这一点。为每个对外调用设置超时。只对幂等操作进行重试,并使用指数退避加随机抖动,避免客户端同时重试。使用熔断器,停止调用明显已经宕机的依赖。
然后让系统可观测:带请求 ID 的结构化日志,覆盖延迟、流量、错误和饱和度的指标,以及展示时间在各服务间如何消耗的分布式追踪,例如使用 OpenTelemetry。告警应基于用户能感受到的症状,例如错误率和延迟,而不是每一次 CPU 尖峰。
要点回顾
- 围绕资源、准确的状态码和统一的错误格式来设计 API。
- 从模块化单体开始;在确有需要时沿业务边界拆分服务。
- 让数据库保证数据完整性,为真实查询建立索引,避免 N+1 查询。
- 通过无状态服务器、缓存和队列来扩展,并从一开始就内建超时、重试和可观测性。
总结
可扩展的后端并非来自什么奇特的技术,而是来自处处一致执行的“平凡”决策:清晰的 API 契约、系统各部分之间干净的边界、建模良好的数据库,以及能及早暴露问题的运维习惯。把这些做好,扩容就只是一次配置变更,而不是一次重写。
延伸阅读
- #Architecture
- #Databases
- #Microservices
- #REST API
- #TypeScript




