Administrator
发布于 2020-03-02 / 784 阅读
12

从单体迁移到微服务的灰度方案

32 万行单体,我们用了 5 个月切成微服务,一次没停过机

去年 10 月启动的拆分,到今年 2 月底核心链路切完。这期间系统一天没停过,用户侧没有感知到任何一次发布异常。回过头看,技术选型上没什么新鲜的,真正难的是"怎么一步步把流量搬过去,以及搬错了怎么退回来"。

先说被拆的那个东西

shop-all.war(Spring MVC 4.3 + MyBatis 3.4 + JDK 7)
├── 商品模块      3.1 万行
├── 订单模块      6.8 万行
├── 库存模块      2.2 万行
├── 促销/优惠券   4.4 万行
├── 用户/会员     3.9 万行
├── 支付/对账     5.1 万行
├── 后台管理      6.2 万行
└── 公共/工具     1.1 万行
      合计 32.8 万行,war 包 187 MB

部署形态是 8 台物理机,每台 Tomcat 8.5。发一次版要全量回归,测试组三个人跑三天。任何一个模块改一行,整个 war 重新打、8 台机器轮着重启,前后 40 分钟。

两个方案的取舍

当时摆在我面前的是两条路:

方案周期风险我们的判断
大爆炸重写预计 9 到 12 个月新旧系统并行期间业务还要迭代,需求永远追不上否定
绞杀者模式按模块逐个切,每个 3 到 6 周数据双写、链路变长选择

绞杀者(Strangler Fig)这个名字来自 Martin Fowler 2014 年那篇博客,指的是一种藤蔓从树冠开始生长,最后把宿主树完全包住取而代之。落到我们这里就是:在单体前面加一层代理,新功能或者被拆出来的模块走新服务,其余流量继续打给单体,逐步把单体架空

最关键的一点:整个过程中系统是可用状态,不需要"切一次大的"。

流量切换:三层都要能切

入口层,用 Gateway 按规则分流

我们在 Nginx 后面加了 Spring Cloud Gateway(Hoxton.SR1),所有 /api/** 的流量先进网关。网关里挂了一个自定义的灰度 Filter:

@Component
public class GrayRouteFilter implements GlobalFilter, Ordered {

    @Autowired
    private GrayRuleService ruleService;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String path = exchange.getRequest().getPath().value();
        String uid = exchange.getRequest().getHeaders().getFirst("X-User-Id");

        GrayRule rule = ruleService.match(path, uid);
        if (rule != null && rule.isHit()) {
            // 打到新的微服务集群
            exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR,
                    URI.create("lb://order-service"));
            exchange.getRequest().mutate()
                    .header("X-Gray-Tag", rule.getTag()).build();
        }
        // 否则走默认的 lb://shop-monolith
        return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        return RouteToRequestUrlFilter.LOAD_BALANCER_CLIENT_FILTER_ORDER - 1;
    }
}

getOrder() 这个值是必须的,比负载均衡 Filter 早一步执行才能改掉目标服务。

灰度规则存在 Nacos 的配置里,支持热更新,改完不用重启网关:

gray:
  rules:
    - path: /api/order/**
      service: order-service
      strategy: PERCENT
      percent: 5              # 5% 流量
    - path: /api/order/**
      service: order-service
      strategy: UID_MOD
      mod: 100
      range: [0, 4]           # uid % 100 落在 0-4 的用户
    - path: /api/order/**
      service: order-service
      strategy: WHITELIST
      uids: [10086, 10087]    # 内部测试账号

三种策略的用途不一样:白名单给测试,UID 取模给"稳定灰度"(同一个用户每次都落在新服务上,体验一致),百分比给放量。上线初期我们只用白名单加取模,百分比是最后一个阶段才开的。

为什么用 UID 取模而不是随机百分比?因为下单是个多步骤流程:加购物车、下单、支付、查订单。如果按请求随机分流,用户这一次下单在新服务、下一次查询在老服务,数据可能看不到。取模能保证同一个用户的完整链路都在同一边。

服务层,单体里用 Feign 反向调用

拆到一半的时候会出现一种情况:新服务需要查老系统的数据,或者老系统需要调新服务的能力。我们的处理是给单体也装上了 Nacos 客户端和 Feign,让它既能被新服务调,也能主动调出去。

// 单体里的老代码,逐步把内部方法调用换成 Feign
@Service
public class OrderServiceImpl {

    @Autowired
    private InventoryClient inventoryClient;   // 指向新拆出来的库存服务

    public void createOrder(OrderDTO dto) {
        // 老:inventoryDao.deduct(...)
        // 新:
        inventoryClient.deduct(dto.getSkuId(), dto.getQty());
        ...
    }
}

数据层:这是最难的部分

我的原则是先共享库,后拆库。一开始就拆库会引入跨库事务,那是另一个量级的复杂度。

第一步,新服务和单体连同一个 MySQL 实例,但严格划分表所有权

shop 库
├── t_order / t_order_item      → 归属 order-service,只有它能写
├── t_stock / t_stock_flow      → 归属 inventory-service,只有它能写
├── t_coupon / t_coupon_use     → 归属 promotion-service,只有它能写
└── t_user / t_user_address     → 暂时归属单体,order-service 只能读

约定写进文档,也做了兜底:给每个服务配的 MySQL 账号只有自己表的写权限,其他表只有 SELECT。这样即使有人想偷懒直接改别的表,也会报错。

GRANT SELECT, INSERT, UPDATE, DELETE ON shop.t_order* TO 'order_svc'@'%';
GRANT SELECT ON shop.t_user* TO 'order_svc'@'%';

第二步才是物理拆库,这个放到最后做,而且用了双写加校验的办法。下面单独讲。

数据双写,以及怎么保证双写是对的

拆订单库的时候,新老库要同时写一段时间。双写最大的问题不是写不进去,而是两边写的结果不一致,而且你不知道

我们的做法:

@Transactional
public void createOrder(OrderDTO dto) {
    // 主写新库
    Long orderId = newOrderMapper.insert(buildOrder(dto));

    // 影子写老库,失败只告警不回滚
    try {
        oldOrderMapper.insert(buildOrder(dto, orderId));
    } catch (Exception e) {
        log.error("双写失败 orderId={}", orderId, e);
        shadowFailMapper.record("t_order", orderId);   // 记下来供补偿
    }
}

这里有个设计决策我纠结了很久:双写失败到底要不要回滚主写。最后定了不回滚。理由是双写阶段老库只是影子,主数据源已经是新库;因为影子写失败就把用户的下单请求打回去,不划算。代价是要有补偿和对账。

对账任务每 5 分钟跑一次,比对最近 2 小时的数据:

@Scheduled(fixedDelay = 300_000)
public void checkOrder() {
    long end = System.currentTimeMillis();
    long start = end - 2 * 3600 * 1000;

    List<Long> newIds = newOrderMapper.selectIdsBetween(start, end);
    List<Long> oldIds = oldOrderMapper.selectIdsBetween(start, end);

    Set<Long> missing = new HashSet<>(newIds);
    missing.removeAll(oldIds);

    if (!missing.isEmpty()) {
        log.warn("双写缺失 {} 条", missing.size());
        alertService.ding("订单双写缺失: " + missing.size());
        // 自动补写
        for (Long id : missing) {
            shadowFailMapper.record("t_order", id);
        }
    }
}

实际跑下来,双写的不一致率约 0.004%,主要是老库偶尔的死锁和超时。补偿任务兜住了。

双写跑了 3 周,对账连续 7 天零差异之后,才把读流量切到新库,再过 1 周停掉影子写。

回滚预案:每一步都要有退路

这是我最花时间的部分。拆分过程中出问题的概率很高,如果没有秒级回滚能力,就不敢往前走。

我们定的四步流程,每步都有对应的回滚动作:

阶段灰度比例观察时长出问题怎么退
1 白名单内部 8 个账号1 天Nacos 改配置,删掉 uids,15 秒生效
2 UID 取模1%2 天把 range 改成空数组
3 取模放量5% → 20% → 50%每档 2 天回退到上一档
4 全量100%保留开关 1 个月开关关掉,全回单体

第 4 步强调"保留开关一个月"。全量切到新服务之后,单体的代码和部署依然保留,网关开关随时能切回去。这个开关我们真的用了一次。

12 月 18 号,切全量第 3 天,监控发现下单 P99 从 300 ms 涨到 1.8 秒。查下来是订单服务调库存服务的 Feign 连接池配小了(默认没开 httpclient,短连接)。当时的处理不是去改配置,而是先把开关切回单体,15 秒恢复,然后慢慢排查。这个顺序很重要,止血优先于诊断。

踩过的四个坑

一、灰度标识在异步链路里丢了

网关打上 X-Gray-Tag 之后,同步调用链路上没问题,但一旦经过 MQ 就丢了。解决办法是把 tag 塞进消息的属性里,消费者取出来放进 ThreadLocal:

@RocketMQMessageListener(topic = "ORDER_PAID_TOPIC", consumerGroup = "point-consumer")
public class PointConsumer implements RocketMQMessageListenerExt<MessageExt> {

    @Override
    public void onMessage(MessageExt msg) {
        String tag = msg.getProperty("X-Gray-Tag");
        GrayContext.set(tag);
        try {
            pointService.add(...);
        } finally {
            GrayContext.clear();
        }
    }
}

二、跨库 JOIN

拆库之后,原来的 SELECT o.*, u.nickname FROM t_order o JOIN t_user u ON ... 直接没了。只能拆成两次查询在应用层拼,或者做字段冗余。我们订单列表页冗余了 user_nickname,代价是改昵称后订单页不实时更新——和产品确认过,可以接受。

三、本地事务变成了分布式

原来下单减库存在同一个数据库事务里,拆开之后跨了两个服务两个库。这个我在 Seata 那篇里详细写了,结论是核心链路用 Seata AT,非核心用最终一致加补偿。

四、单体的数据库连接池

拆出去的服务各自开了连接池,单体那边还是老配置(Druid maxActive=200)。结果总连接数暴涨,MySQL 的 max_connections=500 被打满过一次。教训是拆之前先盘一遍总连接数。

5 个月的数字

指标拆分前拆分后
发版耗时40 分钟(全量 8 台)单服务 3 到 5 分钟
回归测试3 天按服务 4 到 8 小时
单次发布影响面全站单个服务
下单 P99310 ms295 ms
可用性99.92%99.95%
服务数17(还剩用户、后台管理没拆)

性能上几乎没有提升,这一点要诚实。微服务的收益主要在研发效率和发布安全上,不是性能。

下篇预告

这篇先把《从单体迁移到微服务的灰度方案》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考