“把 REST 全部重写成 GraphQL"是一个听起来很爽、执行起来很惨的计划。真实世界的迁移从来不是重写,而是共存、替换、清理三步走:新客户端走 GraphQL,旧客户端继续用 REST,字段级地逐个搬移,直到 REST 只剩下空壳再下线。本文给出一套可落地的渐进迁移方法:绞杀者模式的落地形态、双栈并存的适配层、客户端切换、埋点与灰度、回滚策略与迁移后的清理。技术选型的对比可先阅读 https://plumephp.com/graphql-vs-rest-vs-rpc/;BFF 层的迁移形态可参考 https://plumephp.com/graphql-bff-pattern-microfrontends/。
一、为什么迁移必须是渐进的
1.1 大爆炸重写的失败模式
| 失败模式 | 表现 |
|---|---|
| 双写不一致 | 新老系统数据分叉 |
| 功能对不齐 | 新系统缺了边缘 case |
| 客户端不同步 | 前端无法一次切完 |
| 团队疲劳 | 长期分支、长期不交付 |
1.2 渐进迁移的三个原则
- 增量替换:每次只搬一个领域/一组字段,可独立上线。
- 可回滚:任何一步都能退回 REST,不需要"回滚整个项目”。
- 可观测:每一步的流量占比、错误率、延迟都可量化。
1.3 迁移的四个阶段
| 阶段 | 目标 | 出口条件 |
|---|---|---|
| 并存 | GraphQL 与 REST 同时在线 | GraphQL 能覆盖核心读 |
| 迁移 | 字段/领域逐个搬移 | 新客户端 100% 走 GraphQL |
| 收口 | 旧客户端强制升级 | REST 流量趋零 |
| 下线 | 移除 REST | 无调用、无依赖 |
一句话总结:迁移的敌人不是技术,而是"一次性替换"的诱惑——把大目标切成可独立交付的小步骤,才是唯一可靠的路径。
二、绞杀者模式与双栈并存
2.1 绞杀者的拓扑
┌──────────────┐
Client ─┤ 边缘路由/BFF ├─┬─► GraphQL Gateway ──► 新服务
└──────────────┘ └─► REST (legacy) ──► 旧服务
边缘层按"路由规则 + 客户端标识"决定走哪条路。GraphQL Gateway 可以反向适配 REST:把 REST 端点包装成 GraphQL 字段,从而在不动后端的前提下先提供 GraphQL 接口。
2.2 用 REST 数据源支撑 GraphQL
// GraphQL 字段 → 调用既有 REST 服务(迁移期最常见)
const resolvers = {
Query: {
user: async (_p, { id }, ctx) => {
const res = await ctx.rest.get(`/api/v1/users/${id}`);
return normalizeUser(res); // REST DTO → GraphQL 类型
},
},
};
2.3 双栈并存的三种形态
| 形态 | 说明 | 适用阶段 |
|---|---|---|
| GraphQL 包装 REST | 后端不动,前端先切 | 并存 |
| 双读单写 | 读走 GraphQL,写仍 REST | 迁移 |
| 单栈 GraphQL | 读写全 GraphQL | 收口 |
2.4 适配层的位置
# 边缘路由规则:按客户端版本分流
routes:
- match: { header: { x-client: "web/2.*" } }
backend: graphql-gateway
- match: { header: { x-client: "web/1.*" } }
backend: rest-legacy
- match: { path_prefix: "/api/v1" }
backend: rest-legacy
一句话总结:绞杀者模式的精髓是"用新接口包装旧实现",让前端先享受到 GraphQL 的收益,后端服务可以慢慢迁。
三、字段级迁移与适配层
3.1 从端点粒度到字段粒度
REST 的迁移单位是端点,GraphQL 的迁移单位是字段。一个 REST 端点 GET /orders/:id 可能对应 GraphQL 的多个字段,可以逐个搬移。
| REST 端点 | 对应 GraphQL 字段 | 迁移顺序 |
|---|---|---|
GET /orders/:id | order { id total } | 1(读) |
GET /orders/:id/items | order { items } | 2 |
POST /orders | createOrder | 3(写) |
PATCH /orders/:id | updateOrder | 4 |
3.2 适配层的两种写法
// 写法 A:字段级 resolver 直接调 REST
const resolvers = {
Order: {
items: (order, _a, ctx) => ctx.rest.get(`/api/v1/orders/${order.id}/items`),
},
};
// 写法 B:用 DataLoader 批量调 REST,避免 N+1
new DataLoader<string, OrderItem[]>(async (orderIds) => {
const res = await ctx.rest.post('/api/v1/orders/batch-items', {
ids: [...orderIds],
});
return groupByOrderId(res, orderIds);
});
3.3 数据模型对齐
// REST DTO 与 GraphQL 类型往往不一致,需要显式映射
interface RestOrderDTO {
order_id: string; // 下划线命名
total_amount: number; // 分为单位
buyer: { uid: string; name: string };
}
function normalizeOrder(dto: RestOrderDTO): Order {
return {
id: dto.order_id,
total: dto.total_amount / 100, // 分 → 元,注意精度
buyerId: dto.buyer.uid,
};
}
3.4 避免"翻译层变成新包袱"
适配层是临时脚手架,必须带删除计划:
/**
* @migration rest-orders
* @remove_after 2026-12-31
* @owner team-commerce
* REST 端点 /api/v1/orders 下线后删除此适配器
*/
一句话总结:字段级迁移让"部分迁移"成为合法状态——但要给每一层适配器打上"临时"标签和移除期限,否则脚手架会变成永久建筑。
四、客户端切换策略
4.1 客户端是迁移的真正瓶颈
后端可以今天就支持 GraphQL,但只要有一个客户端还在用 REST,REST 就不能下线。因此迁移的节奏由最慢的客户端决定。
| 客户端 | 切换难度 | 策略 |
|---|---|---|
| Web(可强制刷新) | 低 | 直接切 + 特性开关 |
| 移动 App | 高 | 分版本灰度,等用户升级 |
| 第三方开放 API | 最高 | 长期双栈 + 契约承诺 |
| 内部服务 | 低 | 协调发版 |
4.2 特性开关驱动切换
// 客户端:用特性开关控制数据源
const useGraphQL = flags.isEnabled('graphql-orders', { userId });
const { data } = useGraphQL
? useGraphQLQuery(GetOrderDocument, { variables: { id } })
: useRestQuery(`/api/v1/orders/${id}`);
4.3 分阶段放量
# 灰度配置:按用户百分比放量
flag: graphql-orders
rollout:
- stage: 1
percent: 5
duration: 2d
- stage: 2
percent: 25
duration: 3d
- stage: 3
percent: 100
4.4 契约对齐:响应等价性
// 迁移期:并行调用双栈,比对响应是否等价(影子流量)
async function shadowCompare(orderId: string) {
const [rest, gql] = await Promise.all([
restClient.get(`/api/v1/orders/${orderId}`),
gqlClient.query({ query: GetOrderDocument, variables: { id: orderId } }),
]);
const diff = compareDeep(normalizeOrder(rest), gql.data.order);
if (diff) metrics.migrationMismatch.inc({ field: diff.field });
}
一句话总结:客户端切换靠"特性开关 + 灰度放量 + 影子比对",而不是"某天全量切换"——把风险切碎到每一批用户。
五、埋点与灰度发布
5.1 必须埋的三类点
| 类别 | 指标 | 用途 |
|---|---|---|
| 流量占比 | REST vs GraphQL 请求数 | 判断迁移进度 |
| 质量 | 错误率、P95 延迟 | 判断是否可放量 |
| 一致性 | 影子比对不一致率 | 判断正确性 |
5.2 在网关层统计流量占比
// 边缘中间件:打标并上报
function tagAndReport(req, res, next) {
const source = req.path.startsWith('/graphql') ? 'graphql' : 'rest';
const client = req.headers['x-client'] ?? 'unknown';
metrics.requestTotal.inc({ source, client });
res.on('finish', () => {
metrics.latency.observe({ source }, res.getHeader('x-response-time'));
});
next();
}
5.3 迁移进度看板
-- 按客户端统计 REST 与 GraphQL 的流量占比
SELECT
client_version,
COUNT(*) FILTER (WHERE source = 'rest') AS rest_calls,
COUNT(*) FILTER (WHERE source = 'graphql') AS gql_calls,
ROUND(100.0 * COUNT(*) FILTER (WHERE source = 'graphql') / COUNT(*), 1) AS gql_pct
FROM request_log
WHERE ts > now() - interval '7 days'
GROUP BY client_version
ORDER BY client_version;
5.4 放量的门禁条件
| 条件 | 阈值 |
|---|---|
| 错误率 | 不高于 REST 基线 |
| P95 延迟 | 不高于 REST 基线 +10% |
| 不一致率 | < 0.1% |
| 观察窗口 | ≥ 48 小时 |
一句话总结:没有埋点的迁移就是盲飞——流量占比、错误率、一致性三个指标是放量决策的唯一依据。
六、回滚与风险控制
6.1 回滚的三个层次
| 层次 | 手段 | 恢复时间 |
|---|---|---|
| 流量回滚 | 特性开关关掉 GraphQL | 秒级 |
| 版本回滚 | 回退网关/服务镜像 | 分钟级 |
| 数据回滚 | 修复不一致数据 | 小时级 |
6.2 让回滚成为"默认能力"
// 关键:GraphQL 路径必须与 REST 路径读同一份数据
// 迁移期禁止"双写"——双写是不一致的最大来源
// ✅ 读双栈、写单栈
// ❌ 写双栈(除非有事务保障)
6.3 迁移期的写入策略
# 写操作迁移必须最谨慎:一次只迁一个 mutation
mutation CreateOrder($input: CreateOrderInput!) {
createOrder(input: $input) {
id
status
}
}
| 阶段 | 读 | 写 |
|---|---|---|
| 并存 | 双栈(灰度) | REST |
| 迁移 | GraphQL 为主 | 逐个 mutation 迁移 |
| 收口 | GraphQL | GraphQL |
6.4 事故预案
## 迁移事故预案(orders 领域)
- **触发条件**:GraphQL 路径错误率 > 1% 或 P95 > 500ms 持续 5 分钟
- **第一动作**:关闭特性开关 `graphql-orders`,回退到 REST
- **通知**:值班 + 迁移负责人 + 业务方
- **复盘**:24 小时内产出不一致数据清单与修复方案
一句话总结:回滚能力必须在迁移开始前就建好——特性开关、单栈写入、影子比对,这三样是"敢迁"的底气。
七、组织与流程
7.1 迁移是一个跨职能项目
| 角色 | 职责 |
|---|---|
| 后端 | GraphQL Schema、resolver、适配层 |
| 前端 | 客户端切换、特性开关接入 |
| 数据 | 埋点、看板、一致性监控 |
| SRE | 灰度、回滚、容量 |
| 产品 | 排期、验收、外部沟通 |
7.2 迁移的节奏管理
# 迁移看板(每个领域一张卡)
domain: orders
status: migrating
rest_endpoints_total: 8
rest_endpoints_migrated: 5
gql_traffic_pct: 62
blocking_clients: ["mobile/3.1", "partner-api"]
target_date: 2026-12-15
7.3 避免"永久迁移"
| 反模式 | 后果 | 对策 |
|---|---|---|
| 无退出条件 | 双栈永久存在 | 每个阶段定义出口条件 |
| 适配层无期限 | 脚手架固化 | @remove_after 注释 |
| 无人负责收尾 | REST 永不删除 | 指定 owner + 截止日 |
| 只看技术不看客户端 | 无法收口 | 客户端切换纳入排期 |
一句话总结:迁移项目失败往往不是技术失败,而是"没人负责收尾"——给每个阶段设出口条件、给每个适配层设删除期限。
八、迁移完成后的清理
8.1 下线 REST 的前置检查
# 1. 确认零流量(按客户端、按端点)
grep 'GET /api/v1/orders' access.log | wc -l # 应为 0
# 2. 确认无内部依赖(服务间调用)
rg -l '/api/v1/orders' services/
# 3. 确认无定时任务/脚本依赖
rg -l '/api/v1/orders' scripts/ cron/ jobs/
8.2 清理清单
| 项 | 检查 |
|---|---|
| 路由配置 | 移除 REST 路由 |
| 适配层代码 | 删除并移除 @migration 标记 |
| 文档 | 更新 API 文档与 SDK |
| 监控 | 移除 REST 相关告警 |
| 契约 | 通知第三方(如有) |
8.3 迁移后的收益回收
// 清理后:删除适配层,Schema 直接映射领域模型
// 从"REST DTO → 适配 → GraphQL 类型"简化为"领域模型 → GraphQL 类型"
GraphQL 的收益不是"接口变酷",而是减少客户端与服务端的往返、统一数据获取、让前端自主演进。这些收益只有在迁移真正完成、适配层被清理之后才会完全兑现。迁移之后,Schema 的长期演进与客户端缓存策略的调优,才真正成为日常工程工作。
REST 到 GraphQL 的迁移是一场"拆弹"而非"爆破"。它需要把大目标切成可回滚的小步、用特性开关控制风险、用埋点驱动决策、用期限管理收尾。当 REST 端点真正下线、适配层被清理,迁移才算完成——在此之前,它只是一个进行中的项目。
一句话总结
REST → GraphQL 迁移的正确姿势:新接口包装旧实现(绞杀者)、字段级搬移(增量)、特性开关灰度(可控)、埋点驱动放量(可量化)、适配层限期清理(可收尾)。
FAQ
Q1: 应该一次迁移所有领域,还是逐个领域?
A: 逐个领域。领域是最小可独立交付的单元,也是团队责任的天然边界。一次迁一个领域,可以让每个领域独立灰度、独立回滚、独立验收。
Q2: 迁移期要不要"双写"(同时写 REST 和 GraphQL)?
A: 强烈不建议。双写会引入数据不一致,且难以保证原子性。正确做法是"读可以双栈,写保持单栈"——写操作一次只迁一个 mutation,且必须有事务或幂等保障。
Q3: 影子比对会不会增加太多成本?
A: 短期会增加一倍读流量,但收益是可量化的正确性保证。建议只对高频、关键的读路径开启影子比对,并按百分比采样(如 10%),而非全量。
Q4: 移动 App 用户不升级怎么办?
A: 这是"长尾客户端"问题。策略是:对旧版本 App 继续提供 REST(或通过 BFF 适配),设置明确的"最低支持版本",到期限后强制升级。不要为了极少数旧版本无限期维持双栈。
Q5: 第三方开放 API 也要迁移到 GraphQL 吗?
A: 不一定。对外开放的 API 迁移成本极高(契约承诺、文档、SDK)。更务实的做法是:内部迁移到 GraphQL,对外继续用 REST(由 GraphQL 反向适配生成),两者共享同一份领域逻辑。
相关阅读
- https://plumephp.com/graphql-schema-versioning/ —— 迁移后的 Schema 演进策略
- API 架构演进与路线图 —— 迁移在架构演进中的位置
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。