Givepo
博客

后端架构详解:REST API、微服务与可扩展数据库

如何设计开发者乐于使用的 REST API,微服务何时值得付出代价,如何设计能承受高负载的数据库,以及让后端系统保持快速和可靠的模式。

Givepo 团队
阅读约 7 分钟
Illustration of a client sending a GET request to an API gateway that connects to three services and a database后端与 API

每一个让人感觉快速、可靠的应用背后,都有一个设计良好的后端。后端掌管业务规则、守护数据,并在无人察觉的情况下每秒响应成千上万个请求。尽早把架构做对,可以省下日后数月的返工;做错了,就会表现为故障、缓慢的页面,以及开发周期远超预期的功能。

本文梳理最关键的几个决策:如何设计 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 端点,它在边界处校验输入并返回正确的状态码:

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)是一个被广泛支持的标准:

JSON
{
  "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,自动执行,绝不在生产环境中手工修改。
SQL
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 (…) 在一次查询中获取关联数据。同时要使用连接池,因为每个请求都新建数据库连接既慢,又会很快耗尽服务器资源。

行之有效的扩展模式

  1. 保持应用服务器无状态(会话存放在数据库或缓存中,文件存放在对象存储中),这样就能在负载均衡器后面按需运行任意数量的副本。
  2. 缓存开销大的读取操作并设置较短的过期时间,事先确定数据变化时每个缓存项如何失效。
  3. 把耗时的工作移到后台队列:邮件、报表、图片处理以及对第三方的调用。API 立即响应,由 worker 随后处理。
  4. 读多写少时增加只读副本,并把报表查询发往副本而不是主库。
  5. 对公开端点进行限流,保护系统免受滥用和失控客户端的影响。
  6. 数据分区或分片放在最后,只有在一个调优良好的数据库确实无法支撑时才考虑。

可靠性与可观测性

分布式系统会以局部的、令人困惑的方式出错,所以设计时就要考虑到这一点。为每个对外调用设置超时。只对幂等操作进行重试,并使用指数退避加随机抖动,避免客户端同时重试。使用熔断器,停止调用明显已经宕机的依赖。

然后让系统可观测:带请求 ID 的结构化日志,覆盖延迟、流量、错误和饱和度的指标,以及展示时间在各服务间如何消耗的分布式追踪,例如使用 OpenTelemetry。告警应基于用户能感受到的症状,例如错误率和延迟,而不是每一次 CPU 尖峰。

要点回顾

  • 围绕资源、准确的状态码和统一的错误格式来设计 API。
  • 从模块化单体开始;在确有需要时沿业务边界拆分服务。
  • 让数据库保证数据完整性,为真实查询建立索引,避免 N+1 查询。
  • 通过无状态服务器、缓存和队列来扩展,并从一开始就内建超时、重试和可观测性。

总结

可扩展的后端并非来自什么奇特的技术,而是来自处处一致执行的“平凡”决策:清晰的 API 契约、系统各部分之间干净的边界、建模良好的数据库,以及能及早暴露问题的运维习惯。把这些做好,扩容就只是一次配置变更,而不是一次重写。

延伸阅读

  • #Architecture
  • #Databases
  • #Microservices
  • #REST API
  • #TypeScript
作者
Givepo 团队

Givepo 是一家 IT 公司,专注于开发网站、移动应用和定制软件,助力您的业务持续发展。

分享本文

免费 30 分钟咨询

与我们的团队沟通您的想法、需求范围和预算。

预约咨询

更多文章

博客
软件工程
·阅读约 7 分钟

整洁架构与 SOLID 原则:编写长期易维护的软件

为什么代码库越大越难改,以及整洁架构、依赖规则和 SOLID 原则如何让业务逻辑易于测试和修改,附完整的 TypeScript 示例。

查看详情
网站开发
·阅读约 6 分钟

使用 Next.js、React 和 TypeScript 进行现代 Web 开发:最佳实践

Server Components、渲染策略、类型安全的数据与 Core Web Vitals。一份实用指南,教你用当今大多数团队选择的技术栈构建快速、易维护的 Web 应用。

查看详情
人工智能与机器学习
·阅读约 7 分钟

人工智能与机器学习实战:它们在哪里创造真正的商业价值

拨开炒作的迷雾。了解机器学习和生成式 AI 的真实工作原理、它们当下在哪些场景创造可衡量的价值,以及如何让第一个 AI 项目真正上线,而不是止步于演示。

查看详情