先说为什么要做开关

配置开关这件事,很多系统都在做,但真正上线时能不能救命,关键不在于有没有一个布尔值,而在于变更是不是可控。

业务里最常见的使用场景通常是下面这几类:

1、新逻辑先随代码上线,默认关闭

2、新下游能力先对白名单或小流量开放

3、活动期间按比例逐步放量

4、线上异常时快速关闭新路径,退回旧链路

这一篇只讲业务里常用的开关设计、灰度放量和快速回滚,不展开配置中心原理。

开关先分层

如果所有配置都混成一堆 true 和 false,后面基本很难维护。

更常见也更稳的分法一般是:

1、保护性开关:立即止血,比如关闭消费者入口、关闭某个动作执行

2、策略开关:控制某段新逻辑是否启用,比如新签名校验、新路由策略

3、参数配置:灰度比例、超时时间、批量大小、重试次数

4、范围配置:白名单活动、渠道、租户、用户集合

可以先把配置对象收一下:

1
2
3
4
5
6
7
8
9
@Data
public class PrizeSwitchConfig {

private boolean consumerClose = false;
private boolean signCheckEnable = false;
private int grayPercent = 0;
private Set<Long> activityWhitelist = Collections.emptySet();
private Set<String> userWhitelist = Collections.emptySet();
}

只有先把这些层次分开,灰度和回滚才不会互相打架。

保护性开关先能止血

保护性开关不是为了“控制功能”,而是为了线上出事时先止血。

像消费者入口、批量任务、回调处理这类地方,通常都值得留一个硬开关。

1
2
3
4
5
6
7
8
9
public void onMessage(Message message, Channel channel) throws IOException {
if (prizeSwitchConfig.isConsumerClose()) {
log.warn("consumer closed by config, messageId={}", message.getMessageProperties().getMessageId());
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
return;
}

doConsume(message, channel);
}

这类开关至少要满足三点:

1、有明确默认值,环境切换时不会飘

2、改完后尽快生效,不要还得重启服务

3、关闭后系统还能保持稳定,不会出现“关了比不关更糟”

新逻辑别一把包住主流程

最容易翻车的做法,是把整条主流程全包在新开关里。

1
2
3
4
5
6
7
public void sendPrize(SendPrizeRequest request) {
if (switchConfig.isNewFlowEnable()) {
newPrizeService.send(request);
return;
}
oldPrizeService.send(request);
}

这种写法不是不能用,但如果新旧流程耦合得太深,后面很容易出现:

1、旧逻辑已经没人维护了

2、新旧数据格式不兼容

3、回滚时只能关功能,退不回旧链路

更稳的方式通常是只把新增逻辑包起来,旧主链路尽量保持稳定。

1
2
3
4
5
6
7
private void checkSendPrizeSign(SendPrizeRequest request) {
if (!switchConfig.isSignCheckEnable()) {
return;
}

signChecker.check(request);
}
1
2
3
4
public PrizeResult sendPrize(SendPrizeRequest request) {
checkSendPrizeSign(request);
return oldPrizeService.send(request);
}

这样做的价值更直接:问题一出来,可以先把新增逻辑摘掉,而不是把整条业务主干一起切断。

灰度不能只有一个百分比

很多系统说自己支持灰度,实际实现只有一句“percent = 10”。

这种做法最大的问题是:你根本不知道这 10% 到底是谁。

更可用的灰度至少要支持下面几种范围控制:

1、按活动

2、按渠道

3、按租户

4、按用户白名单

5、按 hash 百分比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public boolean hitGray(Long activityId, String userId, PrizeSwitchConfig config) {
if (config.getActivityWhitelist().contains(activityId)) {
return true;
}

if (config.getUserWhitelist().contains(userId)) {
return true;
}

int percent = config.getGrayPercent();
if (percent <= 0) {
return false;
}
if (percent >= 100) {
return true;
}

int hash = Math.abs(userId.hashCode()) % 100;
return hash < percent;
}

再往前一步,灰度最好和监控一起看,而不是只改比例不看结果。

至少要观察:

1、成功率

2、超时率

3、异常量

4、核心下游的响应时间

没有这些观测,“灰度”很多时候就只是慢一点全量。

配置读取本身要兜底

很多线上问题不是配置值错了,而是配置服务本身有抖动,导致主流程跟着异常。

所以读取配置时,我一般会一起考虑:

1、有没有默认值

2、值解析失败怎么办

3、本地有没有快照或缓存

4、配置刷新失败时是否保留上一版有效值

1
2
3
4
5
6
7
8
9
10
11
12
13
public int getExpireTime() {
int defaultValue = 30;
try {
Config config = configService.getConfigObject("order_expire_time");
if (config == null || StringUtils.isBlank(config.getCodeValue())) {
return defaultValue;
}
return Integer.parseInt(config.getCodeValue());
} catch (Exception e) {
log.warn("get expire time config failed, use default", e);
return defaultValue;
}
}

配置读取没有兜底时,线上问题经常会从“某个灰度配置没拿到”,升级成“主链路直接不可用”。

回滚的前提是旧链路还在

很多人理解回滚,只想到“把开关关掉”。

但真正线上有效的回滚,至少要满足一个前提:旧逻辑还在、还能跑、数据还能接住。

如果新逻辑已经:

1、改写了核心状态

2、依赖了只在新逻辑里存在的新字段

3、切到了新的外部依赖

那你就算把开关关了,也不一定真的退得回去。

更稳的做法通常是双写或兼容一段时间。

1
2
3
4
5
6
7
8
9
10
11
12
public PrizeResult routeSend(SendPrizeRequest request) {
if (!switchConfig.isNewPrizeRouteEnable()) {
return oldPrizeService.send(request);
}

try {
return newPrizeService.send(request);
} catch (Exception e) {
log.error("new route failed, fallback old route, request={}", request, e);
return oldPrizeService.send(request);
}
}

不是所有场景都适合自动回退,但至少上线前要先问清楚:5 分钟后线上有问题,这条能力能不能不发版直接退回旧路径。

开关优先埋在高风险位置

并不是每一行代码都值得埋开关。

从经验看,更值得优先埋开关的地方通常是:

1、新下游调用

2、新消费者入口

3、新签名、拦截、鉴权逻辑

4、新缓存代理、路由代理、规则代理

5、大流量批量任务

这些点一旦出问题,优先级通常不是先分析根因,而是先控住影响面。

放量顺序别乱

一个更稳的上线顺序通常是:

1、先补默认关闭态和配置兜底

2、再补灰度范围控制

3、再补监控、日志和告警

4、先开白名单

5、再按比例逐步放量

6、最后全量

这样做的关键是,任何一步有问题,都能快速停在当前阶段,而不是只能再发一版止血。

常见坑

1、把所有开关都做成布尔值

一旦要按比例、按范围放量,原来的布尔设计马上不够用。

2、开关改了不能快速生效

真正出故障时,等重启再生效,价值已经打了很大折扣。

3、新逻辑开了,旧链路已经删了

这种情况下所谓“回滚”其实只是关闭功能,不是真正回退。

4、灰度只放量,不看指标

没有成功率、异常率和耗时观测,灰度就只是更慢地扩散风险。

5、配置解析异常没有兜底

一个数字配置值写错,就把主流程直接带崩,这类事故很常见。

收一下

配置开关、灰度发布和快速回滚真正要解决的,不是“能不能配”,而是“线上变更能不能控住”。

真正值得保留的常用实践,核心就这几类:

1、开关按用途分层,不要混成一堆布尔值

2、保护性开关要能立即止血

3、策略开关尽量只包新增逻辑

4、灰度要有明确范围和观测指标

5、配置读取本身必须有兜底

6、快速回滚的前提是旧链路仍然可用