一、为什么电商SaaS的发布这么难

先说一个真实场景。

某电商ERP系统,服务几万家小微商家。周三晚上11点,开发团队发布了一个库存同步模块的优化版本。改动不大,就改了3个文件,代码review也过了,测试环境跑了一遍没问题。

结果第二天早上9点,客服电话被打爆:

  • "我的淘宝订单库存没同步过来!"
  • "拼多多那边显示有货,实际已经卖完了!"
  • "我刚才超卖了20单,怎么办?"

排查下来,原因是新版本的库存扣减逻辑和旧版本的订单创建逻辑不兼容。新版本用Redis做库存预扣,旧版本直接写数据库。两个版本同时运行时,同一件商品的库存被扣了两次。

这就是灰度发布要解决的核心问题:新版本和旧版本会同时运行一段时间,如果它们之间不兼容,就会出事故。

对于电商SaaS来说,发布比传统软件难在几个地方:

不能停机。 几万家商家同时在线做生意,你说"今晚停机2小时升级",商家直接跑路。发布必须在业务运行时完成,而且对商家完全透明。

数据不能丢。 一笔订单从创建到完成,要经过库存扣减、物流分配、财务记账。发布过程中如果数据丢了或者乱了,商家直接损失真金白银。

回滚要快。 万一新版本有问题,必须在几分钟内回滚,不能等几个小时。每多一分钟,就有更多商家受影响。

多租户隔离。 不同商家用的功能模块不一样,有的只用订单管理,有的还用了财务模块。发布时要考虑不同租户的兼容性。

这些挑战决定了,电商SaaS的发布不能靠"直接替换",必须有一套灰度机制。


二、灰度发布的整体架构

灰度发布的核心思路是让新版本和旧版本同时运行,逐步把流量从旧版本切到新版本,出问题就回滚。

┌─────────────────────────────────────────────────────────────────────┐
│                        灰度发布架构全景                               │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  用户请求                                                            │
│     ↓                                                               │
│  ┌──────────────┐                                                   │
│  │  API Gateway  │ ← 灰度路由规则(按租户/比例/标签)                  │
│  │  (Nginx/APISIX)│                                                  │
│  └──────┬───────┘                                                   │
│         │                                                           │
│    ┌────┴────                                                      │
│    ↓         ↓                                                      │
│  ┌──────┐  ┌──────┐                                                │
│  │ 旧版  │  │ 新版  │                                                │
│  │ v1.2 │  │ v1.3 │                                                │
│  │ 90%  │  │ 10%  │  ← 流量比例可动态调整                            │
│  └──┬───┘  └──┬───┘                                                │
│     │         │                                                     │
│     └────┬────┘                                                     │
│          ↓                                                          │
│  ┌──────────────┐                                                   │
│  │   共享数据库   │ ← 数据层必须向前兼容(后面详细讲)                  │
│  │  MySQL + Redis │                                                  │
│  └──────────────┘                                                   │
│                                                                     │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │  监控与决策                                                    │  │
│  │  • 错误率监控(Prometheus + Grafana)                          │  │
│  │  • 业务指标监控(订单量、库存同步延迟)                          │  │
│  │  • 自动回滚触发器(错误率>阈值自动切回旧版)                     │  │
│  └──────────────────────────────────────────────────────────────┘  │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

整个流程分四步:部署新版本 → 小流量验证 → 逐步放量 → 全量切换。每一步都有检查点,任何一步出问题都可以回滚。


三、流量路由:怎么控制哪些请求走新版本

流量路由是灰度发布的第一道关卡。核心问题是:怎么决定哪些请求走新版本,哪些走旧版本?

常见的路由策略有三种:

3.1 按比例路由

最简单的方式,按百分比分配流量。比如先让5%的请求走新版本,观察没问题再逐步提高到10%、30%、50%、100%。

# APISIX路由规则示例
routes:
  - uri: /api/v1/orders/*
    plugins:
      traffic-split:
        rules:
          - weighted_upstreams:
              - upstream_id: "order-service-v1"  # 旧版
                weight: 95                        # 95%流量
              - upstream_id: "order-service-v2"  # 新版
                weight: 5                         # 5%流量

按比例路由的优点是简单,缺点是不可控——你不知道这5%的请求具体是哪些商家。万一这5%恰好都是大客户,出了问题影响就很大。

3.2 按租户路由(推荐)

电商SaaS更推荐按租户(商家)路由。可以指定哪些商家先试用新版本,出问题只影响这些商家,而且可以选内部员工或者关系好的商家做第一批灰度用户。

/**
 * 灰度路由拦截器
 * 根据租户ID决定请求走哪个版本
 */
@Component
public class GrayRouteInterceptor implements HandlerInterceptor {
    
    @Autowired
    private GrayConfigService grayConfigService;
    
    @Override
    public boolean preHandle(HttpServletRequest request, 
                             HttpServletResponse response, 
                             Object handler) {
        // 1. 从请求中获取租户ID
        String tenantId = TenantContext.getCurrentTenantId();
        
        // 2. 查询灰度配置
        GrayRule rule = grayConfigService.getGrayRule(tenantId);
        
        if (rule == null) {
            // 没有灰度规则,走默认版本(旧版)
            request.setAttribute("route_version", "v1");
            return true;
        }
        
        // 3. 判断是否命中灰度规则
        if (shouldRouteToNewVersion(tenantId, rule)) {
            request.setAttribute("route_version", "v2");
            // 记录灰度日志,用于后续分析
            grayConfigService.logGrayRequest(tenantId, "v2");
        } else {
            request.setAttribute("route_version", "v1");
        }
        
        return true;
    }
    
    /**
     * 判断是否路由到新版本
     * 支持多种灰度策略:白名单、按比例、按标签
     */
    private boolean shouldRouteToNewVersion(String tenantId, GrayRule rule) {
        // 策略1:白名单(指定租户强制走新版)
        if (rule.getWhitelist() != null && rule.getWhitelist().contains(tenantId)) {
            return true;
        }
        
        // 策略2:黑名单(指定租户强制走旧版)
        if (rule.getBlacklist() != null && rule.getBlacklist().contains(tenantId)) {
            return false;
        }
        
        // 策略3:按租户标签(比如"beta用户"标签的走新版)
        if (rule.getTenantTags() != null) {
            Set<String> tenantTags = getTenantTags(tenantId);
            if (!Collections.disjoint(tenantTags, rule.getTenantTags())) {
                return true;
            }
        }
        
        // 策略4:按比例(对剩余租户按比例分配)
        if (rule.getPercentage() != null && rule.getPercentage() > 0) {
            // 用租户ID的hash值做确定性分配
            // 同一个租户始终走同一个版本,避免体验不一致
            int hash = Math.abs(tenantId.hashCode() % 100);
            return hash < rule.getPercentage();
        }
        
        return false;
    }
}

按租户路由的关键设计:同一个租户始终走同一个版本。这是通过租户ID的hash值实现的——hash值固定,所以同一个租户不会这次走新版、下次走旧版,避免了数据不一致的问题。

3.3 按功能特性路由(Feature Flag)

更细粒度的控制,不是整个服务灰度,而是某个具体功能灰度。比如新版本的库存同步逻辑改了,但订单创建逻辑没改,那就只对库存同步做灰度。

/**
 * Feature Flag服务
 * 控制具体功能的灰度开关
 */
@Service
public class FeatureFlagService {
    
    // 从配置中心动态读取Feature Flag
    @Autowired
    private NacosConfigClient configClient;
    
    /**
     * 检查某个功能是否对当前租户开启
     * @param featureKey 功能标识,如 "inventory_sync_v2"
     * @param tenantId 租户ID
     */
    public boolean isEnabled(String featureKey, String tenantId) {
        // 1. 获取该功能的灰度配置
        FeatureConfig config = configClient.getFeatureConfig(featureKey);
        
        if (config == null || !config.isEnabled()) {
            return false;  // 功能未开启
        }
        
        // 2. 检查是否在全量阶段(所有租户都开启)
        if (config.isFullyRolledOut()) {
            return true;
        }
        
        // 3. 检查白名单
        if (config.getWhitelist().contains(tenantId)) {
            return true;
        }
        
        // 4. 按比例灰度
        int hash = Math.abs((featureKey + tenantId).hashCode() % 100);
        return hash < config.getRolloutPercentage();
    }
}

// 业务代码中使用
@Service
public class InventorySyncService {
    
    @Autowired
    private FeatureFlagService featureFlagService;
    
    public void syncInventory(Order order) {
        String tenantId = order.getTenantId();
        
        if (featureFlagService.isEnabled("inventory_sync_v2", tenantId)) {
            // 新版库存同步逻辑(Redis预扣 + 异步落库)
            syncInventoryV2(order);
        } else {
            // 旧版库存同步逻辑(直接写数据库)
            syncInventoryV1(order);
        }
    }
}

Feature Flag的好处是灰度粒度更细,可以针对单个功能做灰度,而且开关是动态的,改配置就能生效,不需要重新发布。


四、数据兼容:灰度发布最容易被忽略的坑

前面说的那个"库存被扣两次"的事故,根本原因就是数据不兼容。新版本和旧版本同时运行时,如果它们对数据的读写方式不一样,就会出问题。

4.1 数据库变更的兼容原则

灰度发布期间,数据库变更必须遵循向前兼容原则:

┌──────────────────────────────────────────────────────────────┐
│                    数据库变更兼容规则                           │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  ✅ 可以做的(向前兼容):                                      │
│  • 新增表                                                    │
│  • 新增字段(必须有默认值)                                     │
│  • 新增索引                                                   │
│  • 新增枚举值                                                 │
│                                                              │
│  ❌ 不能做的(会破坏兼容性):                                    │
│  • 删除字段(旧版还在用)                                       │
│  • 修改字段类型(旧版读写会报错)                                 │
│  • 修改字段名(旧版找不到字段)                                   │
│  • 删除表(旧版还在用)                                         │
│  • 修改字段约束(比如NOT NULL改NULL)                           │
│                                                              │
│  ⚠️ 需要特殊处理的:                                            │
│  • 修改业务逻辑(新旧版逻辑不一致)                               │
│  • 修改数据格式(比如日期格式从yyyy-MM-dd改成时间戳)             │
│                                                              │
└──────────────────────────────────────────────────────────────┘

核心原则就一条:新版本必须能读写旧版的数据格式,旧版也必须能读写新版本写入的数据。

4.2 字段新增的实战案例

假设新版本需要给订单表加一个"物流渠道"字段:

-- ✅ 正确的做法:新增字段,给默认值
ALTER TABLE orders 
ADD COLUMN logistics_channel VARCHAR(50) DEFAULT 'standard' 
COMMENT '物流渠道';

-- 这样旧版代码读这个字段时拿到的是默认值'standard',不会报错
-- 新版代码写入时填实际的物流渠道
-- ❌ 错误的做法:新增字段,不给默认值
ALTER TABLE orders 
ADD COLUMN logistics_channel VARCHAR(50) NOT NULL;

-- 旧版代码插入订单时不会填这个字段,会报NOT NULL约束错误
-- 整个系统直接挂掉

4.3 业务逻辑变更的双写方案

如果新版本的业务逻辑和旧版本差异很大(比如前面说的库存扣减逻辑从直接写库改成Redis预扣),不能简单地让两个版本同时运行。这时候需要用双写方案:

/**
 * 库存服务:双写模式
 * 新旧版本同时写,以新版本为准,旧版本做兜底
 */
@Service
public class InventoryService {
    
    @Autowired
    private RedisTemplate<String, Integer> redisTemplate;
    
    @Autowired
    private InventoryMapper inventoryMapper;
    
    /**
     * 扣减库存(灰度期间双写)
     */
    public boolean deductStock(String skuId, int quantity, String tenantId) {
        // 1. 先写Redis(新版逻辑)
        String key = "inventory:" + skuId;
        Long remaining = redisTemplate.opsForValue().decrement(key, quantity);
        
        if (remaining == null || remaining < 0) {
            // Redis扣减失败或库存不足,回滚
            redisTemplate.opsForValue().increment(key, quantity);
            return false;
        }
        
        // 2. 同时写数据库(旧版逻辑,兜底)
        try {
            int rows = inventoryMapper.deductStock(skuId, quantity);
            if (rows == 0) {
                // 数据库扣减失败(库存不足),回滚Redis
                redisTemplate.opsForValue().increment(key, quantity);
                return false;
            }
        } catch (Exception e) {
            // 数据库写入失败,记录日志,以Redis为准
            log.error("数据库库存扣减失败,以Redis为准: skuId={}, error={}", skuId, e.getMessage());
            // 告警:数据库写入失败需要人工介入
            alertService.sendAlert("数据库库存扣减失败", skuId, e);
        }
        
        return true;
    }
    
    /**
     * 查询库存(灰度期间优先读Redis)
     */
    public int getStock(String skuId) {
        // 优先从Redis读(新版数据源)
        Integer redisStock = redisTemplate.opsForValue().get("inventory:" + skuId);
        if (redisStock != null) {
            return redisStock;
        }
        
        // Redis没有,降级到数据库(旧版数据源)
        return inventoryMapper.getStock(skuId);
    }
}

双写的关键点:

  • 写的时候两个都写,保证新旧版本都能看到数据。
  • 读的时候优先读新版本的数据源,读不到再降级到旧版本。
  • 如果其中一个写入失败,要能回滚另一个,保证数据一致。
  • 双写只是过渡方案,全量切换后要清理掉旧版本的写入逻辑。

五、监控告警:怎么发现灰度出了问题

灰度发布不是把流量切过去就完了,监控才是灰度发布的核心。没有监控的灰度等于盲飞。

5.1 监控指标体系

┌──────────────────────────────────────────────────────────────┐
│                     灰度监控三层指标                            │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  第一层:系统指标(基础)                                       │
│  • 请求成功率(目标:>99.9%)                                  │
│  • 响应时间P99(目标:<500ms)                                 │
│  • 错误率(目标:<0.1%)                                       │
│  • CPU/内存使用率                                             │
│                                                              │
│  第二层:业务指标(关键)                                       │
│  • 订单创建成功率                                              │
│  • 库存同步延迟(目标:<3秒)                                   │
│  • 库存超卖次数(目标:0)                                      │
│  • 财务对账差异(目标:0)                                      │
│                                                              │
│  第三层:对比指标(灰度特有)                                    │
│  • 新版本 vs 旧版本的错误率对比                                  │
│  • 新版本 vs 旧版本的响应时间对比                                │
│  • 新版本 vs 旧版本的业务成功率对比                              │
│                                                              │
└──────────────────────────────────────────────────────────────┘

5.2 自动回滚机制

监控不只是看,还要能自动触发回滚。当新版本的关键指标超过阈值时,系统应该自动把流量切回旧版本。

/**
 * 灰度发布监控与自动回滚服务
 */
@Service
public class GrayReleaseMonitor {
    
    @Autowired
    private PrometheusClient prometheusClient;
    
    @Autowired
    private GrayRouteService grayRouteService;
    
    @Autowired
    private AlertService alertService;
    
    // 回滚阈值配置
    private static final double ERROR_RATE_THRESHOLD = 0.02;      // 错误率>2%触发回滚
    private static final double LATENCY_P99_THRESHOLD = 2000;     // P99延迟>2秒触发回滚
    private static final int CONSECUTIVE_FAILURES = 3;            // 连续3次超标才回滚(防误判)
    
    /**
     * 定时检查灰度版本的健康状态
     * 每30秒执行一次
     */
    @Scheduled(fixedRate = 30000)
    public void checkGrayVersionHealth() {
        GrayReleaseStatus status = grayRouteService.getCurrentGrayStatus();
        
        if (status == null || !status.isGrayEnabled()) {
            return;  // 没有灰度发布在进行
        }
        
        // 1. 获取新版本的关键指标
        Map<String, Double> metrics = prometheusClient.getMetrics(
            "order_service", 
            status.getNewVersion(),
            "last_5_minutes"
        );
        
        double errorRate = metrics.get("error_rate");
        double latencyP99 = metrics.get("latency_p99");
        double orderSuccessRate = metrics.get("order_success_rate");
        
        // 2. 检查是否超过阈值
        List<String> violations = new ArrayList<>();
        
        if (errorRate > ERROR_RATE_THRESHOLD) {
            violations.add(String.format("错误率 %.2f%% 超过阈值 %.2f%%", 
                errorRate * 100, ERROR_RATE_THRESHOLD * 100));
        }
        
        if (latencyP99 > LATENCY_P99_THRESHOLD) {
            violations.add(String.format("P99延迟 %.0fms 超过阈值 %.0fms", 
                latencyP99, LATENCY_P99_THRESHOLD));
        }
        
        if (orderSuccessRate < 0.99) {
            violations.add(String.format("订单成功率 %.2f%% 低于阈值 99%%", 
                orderSuccessRate * 100));
        }
        
        // 3. 连续超标计数
        if (!violations.isEmpty()) {
            status.incrementConsecutiveFailures();
            log.warn("灰度版本指标异常 [连续{}次]: {}", 
                status.getConsecutiveFailures(), violations);
            
            // 4. 连续超标达到阈值,触发自动回滚
            if (status.getConsecutiveFailures() >= CONSECUTIVE_FAILURES) {
                autoRollback(status, violations);
            }
        } else {
            // 指标正常,重置连续超标计数
            status.resetConsecutiveFailures();
        }
    }
    
    /**
     * 自动回滚
     */
    private void autoRollback(GrayReleaseStatus status, List<String> violations) {
        log.error("🚨 触发自动回滚!原因: {}", violations);
        
        // 1. 将流量100%切回旧版本
        grayRouteService.setTrafficRatio(status.getOldVersion(), 100);
        grayRouteService.setTrafficRatio(status.getNewVersion(), 0);
        
        // 2. 发送告警
        alertService.sendCriticalAlert(
            "灰度发布自动回滚",
            String.format("新版本 %s 指标异常,已自动回滚到旧版本 %s。异常原因: %s",
                status.getNewVersion(), status.getOldVersion(), violations)
        );
        
        // 3. 记录回滚事件(用于事后复盘)
        grayRouteService.recordRollbackEvent(status, violations);
    }
}

自动回滚的设计要点:

  • 连续超标才回滚:单次超标可能是网络抖动,连续3次超标才触发回滚,避免误判。
  • 回滚要快:流量切换是配置变更,秒级生效,不需要重新部署。
  • 回滚后告警:自动回滚后必须通知到负责人,不能静默回滚。
  • 记录回滚事件:每次回滚都要记录原因和当时的指标,用于事后复盘。

六、灰度发布的完整流程

把前面说的串起来,一个完整的灰度发布流程是这样的:

第1天:准备阶段
├── 1. 代码合并到发布分支
├── 2. 数据库变更(只加字段/表,不删不改)
├── 3. 部署新版本到灰度环境(不接流量)
└── 4. 在灰度环境做冒烟测试

第2天:小流量验证(5%)
├── 1. 通过Feature Flag指定内部员工租户走新版本
── 2. 观察30分钟,检查系统指标和业务指标
├── 3. 内部员工手动验证核心功能
└── 4. 如果指标正常,进入下一阶段

第3天:扩大灰度(20%)
├── 1. 通过租户标签选择"beta用户"走新版本
── 2. 观察2小时,对比新旧版本指标
├── 3. 收集beta用户反馈
└── 4. 如果指标正常,进入下一阶段

第4天:大规模灰度(50%)
├── 1. 按比例路由,50%流量走新版本
├── 2. 持续监控,重点关注业务指标
├── 3. 如果有问题,立即回滚
└── 4. 如果指标正常,准备全量

第5天:全量切换(100%)
├── 1. 100%流量切到新版本
├── 2. 保留旧版本实例(不销毁),观察24小时
├── 3. 24小时后无异常,销毁旧版本实例
└── 4. 清理双写逻辑和Feature Flag

整个流程5天,任何一个阶段出问题都可以回滚。 看起来慢,但对于一个服务几万家商家的系统来说,这个速度是合理的。发布事故的成本远高于多等几天。


七、几个容易踩的坑

1. 数据库变更不兼容

这是灰度发布最常见的事故原因。新版本加了字段不给默认值、改了字段类型、删了旧版还在用的表。记住一个原则:灰度期间,数据库只能做加法,不能做减法。

2. 缓存和数据库不一致

新版本改了缓存的key格式或者序列化方式,旧版本读不出来。解决方案是缓存key加版本号,新旧版本用不同的key,或者在灰度期间做缓存双写。

3. 消息队列的消息格式变了

新版本改了消息的JSON结构,旧版本消费者解析失败。解决方案是消息格式向前兼容——新字段加默认值,旧字段不删除,或者用两个不同的Topic。

4. 回滚时忘了回滚数据库

代码回滚了,但数据库变更没回滚。如果数据库变更是向前兼容的(只加了字段),那没问题。但如果做了不兼容的变更,回滚代码后系统会直接挂掉。所以数据库变更一定要提前想好回滚方案。

5. 灰度时间太短

有些团队灰度只跑10分钟就全量了,这根本不够。有些问题是慢性的——比如内存泄漏、连接池耗尽、定时任务冲突,这些要跑几个小时甚至几天才会暴露。建议每个灰度阶段至少观察2小时,关键业务观察24小时。

6. 忽略了定时任务

灰度发布通常只考虑了HTTP请求的路由,但定时任务(比如每天凌晨的库存同步、财务对账)也会同时运行新旧版本。如果两个版本都执行了同一个定时任务,数据就会重复处理。解决方案是定时任务加分布式锁,或者在灰度期间只让一个版本执行定时任务。


八、技术选型速查表

模块选型备选方案选择理由
流量路由APISIXNginx + LuaAPISIX原生支持traffic-split插件,动态调整比例
配置中心NacosApolloNacos和Spring Cloud生态集成好,支持动态配置
Feature Flag自研 + NacosLaunchDarkly自研更灵活,和现有系统集成度高
监控Prometheus + GrafanaZabbixPrometheus适合云原生环境,Grafana可视化好
告警钉钉机器人企业微信/邮件钉钉响应快,支持@指定人
分布式锁Redis(Redisson)ZooKeeperRedis性能好,Redisson封装完善
部署Kubernetes + HelmDocker ComposeK8s支持滚动更新和回滚,适合微服务

九、总结

灰度发布这件事,技术本身不复杂,复杂的是细节。

流量路由怎么切、数据怎么兼容、监控指标怎么定、回滚阈值怎么设、定时任务怎么处理——每一个环节都有坑。但这些东西没有捷径,只能一个一个踩过去。

核心原则就三条:

数据向前兼容。 灰度期间新旧版本同时运行,数据库只能做加法,不能做减法。

监控驱动决策。 不是看时间到了就放量,是看指标正常才放量。指标异常就回滚,不要犹豫。

回滚要快。 流量切换秒级生效,代码回滚分钟级完成。回滚后再排查问题,不要在故障现场debug。

做SaaS的都知道,发布是最高危的操作。灰度发布不是让发布变得简单,而是让发布变得可控——出了问题能及时发现,发现之后能快速回滚,回滚之后影响范围最小。

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐