系统兼容性设计

2019年某电商平台App发布新版本后,大量用户反映无法登录。

技术团队排查后发现:新版本API修改了用户登录的返回格式,添加了一个新字段。但老版本App没有解析这个字段,导致解析失败。

更严重的是:服务端没有做版本兼容,所有老版本用户都无法登录。

这次故障持续了3小时,影响了约50万用户无法登录。

【面试官手记】

接口兼容性是微服务架构中最重要的问题之一。我面试过的候选人里,能说清楚"接口兼容性原则"的不超过30%,能说出"版本管理策略"的不超过20%。兼容性的关键是接口契约不变

一、接口兼容性的四大原则 🔴

1.1 四大原则

接口兼容性四大原则:

1. 扩展优于修改
   - 新增字段,不删除/修改已有字段
   - 旧客户端忽略新字段

2. 必填改选填
   - 字段从必填改选填时,需要兼容处理
   - 新版本可选,旧版本发送默认值

3. 枚举兼容
   - 枚举值只增不减
   - 老客户端遇到新枚举值时忽略

4. 签名兼容
   - 修改签名需要做参数兼容
   - 使用Optional参数代替必填参数

1.2 常见破坏兼容的场景

破坏兼容性的场景:

1. 删除字段
   - 旧客户端无法解析

2. 修改字段类型
   - String改Integer,旧客户端解析报错

3. 修改字段含义
   - 字段语义变化,客户端逻辑错误

4. 修改必填/选填
   - 必填改选填:服务端可能不传
   - 选填改必填:旧客户端不传

1.3 面试追问

面试官:接口不兼容了怎么办?

候选人:两个方向:

一是保留旧接口,同时提供新接口。

二是做版本管理,新版本接口走新逻辑,老版本保持兼容。

面试官:怎么设计兼容性更好的接口?

候选人:四个原则:

一是扩展优于修改,只新增字段不删除。

二是枚举只增不减。

三是字段类型尽量用String兼容。

四是用可选参数代替必填参数。

二、版本管理策略 🔴

2.1 URL版本

URL版本:
http://api.example.com/v1/users
http://api.example.com/v2/users

优点:清晰直观
缺点:URL变化,需要多套接口维护

2.2 Header版本

Header版本:
GET /users
API-Version: 2024-01-01

优点:URL不变
缺点:需要解析Header

2.3 代码实现

// 版本路由
@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("/v1/{id}")
    public UserVO getUserV1(@PathVariable Long id) {
        // V1逻辑
        return userService.getUserV1(id);
    }

    @GetMapping("/v2/{id}")
    public UserVO getUserV2(@PathVariable Long id) {
        // V2逻辑
        return userService.getUserV2(id);
    }

    @GetMapping("/{id}")
    public UserVO getUser(@PathVariable Long id,
                          @RequestHeader(value = "API-Version", required = false) String version) {
        if ("v1".equals(version)) {
            return userService.getUserV1(id);
        } else if ("v2".equals(version)) {
            return userService.getUserV2(id);
        } else {
            return userService.getUserLatest(id);
        }
    }
}

三、参数演进策略 🟡

3.1 新增参数

// 新增参数:旧客户端不传,服务端要有默认值
public class QueryRequest {
    private String keyword;         // 旧参数
    private Integer pageNum;        // 旧参数
    private Integer pageSize;       // 旧参数
    private String sortField;       // 新增参数:默认值"create_time"
    private String sortOrder;        // 新增参数:默认值"desc"
}

// 兼容处理
public PageResult query(QueryRequest request) {
    // 设置默认值
    if (request.getSortField() == null) {
        request.setSortField("create_time");
    }
    if (request.getSortOrder() == null) {
        request.setSortOrder("desc");
    }
    // 业务逻辑
}

3.2 修改参数

// 参数改名:保留旧参数,新增新参数,兼容处理
public class QueryRequest {
    private String keyword;      // 旧参数
    private String searchKey;    // 新参数:替代keyword
}

// 兼容处理
public PageResult query(QueryRequest request) {
    String keyword = request.getKeyword();
    if (keyword == null) {
        keyword = request.getSearchKey();  // 兼容
    }
    // 业务逻辑
}

3.3 枚举演进

// 枚举演进:只增不减
public enum OrderStatus {
    PENDING("待支付"),      // 旧枚举
    PAID("已支付"),         // 旧枚举
    SHIPPED("已发货"),      // 新增
    COMPLETED("已完成"),    // 新增
    CANCELLED("已取消");    // 新增

    // 旧客户端不认识新枚举值的兼容处理
    public static OrderStatus from(String code) {
        for (OrderStatus status : values()) {
            if (status.getCode().equals(code)) {
                return status;
            }
        }
        return null;  // 旧客户端遇到新枚举值,返回null
    }
}

四、响应兼容策略 🟡

4.1 扩展字段

// 响应扩展:新增字段不删除旧字段
public class UserVO {
    private Long id;           // 旧字段
    private String name;     // 旧字段
    private String avatar;     // 新增字段

    // Jackson序列化时,旧客户端会忽略avatar字段
    @JsonIgnore
    private String internal;  // 内部字段,不返回
}

// 响应示例
{
    "id": 123456,
    "name": "张三",
    "avatar": "https://example.com/avatar.jpg"  // 新增字段
}

4.2 响应字段修改

// 字段改名:保留旧字段,新增新字段
public class UserVO {
    private Long id;
    private String name;
    private String nickname;   // 新字段,替代name

    // Jackson序列化时保留两个字段
    @JsonProperty("nickname")
    public String getNickname() {
        return nickname != null ? nickname : name;  // 兼容
    }
}

4.3 JSON Schema兼容

// 使用JSON Schema描述字段兼容性
{
  "type": "object",
  "properties": {
    "id": {
      "type": "integer",
      "description": "用户ID"
    },
    "name": {
      "type": "string",
      "deprecated": true,  // 标记为废弃
      "description": "请使用nickname替代"
    },
    "nickname": {
      "type": "string",
      "description": "用户昵称"
    }
  }
}

五、数据库兼容性 🟡

5.1 字段兼容性

-- 新增字段:允许NULL或有默认值
ALTER TABLE users ADD COLUMN nickname VARCHAR(64) DEFAULT '' COMMENT '昵称';

-- 修改字段类型:尽量兼容
-- 错误:ALTER TABLE users MODIFY COLUMN age VARCHAR(20);
-- 正确:ALTER TABLE users ADD COLUMN age_str VARCHAR(20);

-- 删除字段:先标记废弃,再删除
-- 第一步:代码层面忽略
-- 第二步:发布后确认无调用
-- 第三步:ALTER TABLE DROP COLUMN

5.2 版本化Schema

-- 使用版本化的Schema管理数据库变更

-- V1.0
CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    user_id BIGINT,
    amount DECIMAL(15,2),
    status TINYINT,
    create_time DATETIME
);

-- V1.1:新增字段
ALTER TABLE orders ADD COLUMN shipping_fee DECIMAL(15,2) DEFAULT 0;

-- V1.2:新增字段
ALTER TABLE orders ADD COLUMN discount DECIMAL(15,2) DEFAULT 0;

六、生产避坑 🟡

6.1 兼容性设计的五大坑

坑1:接口契约不明确

问题:没有API文档,不知道哪些字段是契约
场景:删除字段后才发现有客户端依赖
解决方案:
- 建立API契约文档
- 定期审计接口使用情况

坑2:枚举值删除

问题:删除枚举值后,旧客户端无法解析
场景:OrderStatus.DELETED被删除
解决方案:
- 枚举只增不减
- 用状态值0表示已删除

坑3:字段类型修改

问题:String改Integer,旧客户端解析报错
场景:userId从String改成Long
解决方案:
- 尽量使用String兼容
- 或保留两个字段

坑4:必填参数变更

问题:选填改必填,旧客户端不传
场景:pageNum变成必填
解决方案:
- 不要把选填改必填
- 或设置默认值

坑5:没有灰度发布

问题:新版本接口没有灰度就全量发布
场景:接口有bug影响所有用户
解决方案:
- 灰度发布1% → 10% → 50% → 100%
- 观察异常率再决定是否全量

6.2 兼容性检查清单

发布前检查:
- [ ] 新增字段有默认值
- [ ] 枚举只增不减
- [ ] 字段类型未修改
- [ ] 必填参数未改为选填
- [ ] 老客户端仍可正常解析
- [ ] 灰度发布验证通过

七、真实面试回放 🟡

面试官:接口要新增一个字段,旧客户端不升级怎么办?

候选人(小张):三个原则:

一是新增字段不能是必填。旧客户端不传,服务端要能处理。

二是新增字段要有默认值。比如新字段叫nickname,旧客户端不传,服务端默认空字符串。

三是枚举新增不能删除。旧客户端遇到不认识的枚举值要能忽略。

面试官:如果旧接口的字段要改名呢?

小张:两个步骤:

一是新旧字段共存一段时间。比如name改nickname,保留name,新增nickname。

二是两个字段都支持。比如查询时两个字段都有效,写入时写入nickname。

面试官:接口要删除一个字段,但有老客户端还在用怎么办?

小张:不能直接删除。要分三步走:

一是在代码层面忽略这个字段,不返回它。

二是等待所有客户端升级。

三是确认没有客户端依赖后再删除。

【面试官手记】

小张这场面试的亮点:

  1. 知道新增字段的兼容性处理

  2. 知道字段改名的平滑迁移方法

  3. 知道删除字段的正确步骤

接口兼容性是P6工程师必备技能,能完整回答的候选人,说明有实际项目经验。

接口兼容性的核心是扩展优于修改。记住三个要点:

  1. 字段兼容:只增不减,有默认值
  2. 枚举兼容:只增不减,忽略未知值
  3. 版本管理:保留旧接口,新接口做兼容

接口契约一旦发布,就要保持兼容。