一、背景
为了控制发版带来的影响面等问题,我们公司基建团队自研灰度发布流程,目前几乎所有服务发版都会严格先走灰度发布验证再上线。当前已支持http、gRPC等接口调用方式进行灰度流量转发,使用消息队列进行业务实现的场景的暂不支持。
ps: 参考过网上诸多消息队列灰度的方案,比较热门的有vivo鲁班RocketMQ系列的文章,但无一例外的是改造量都很大,且需要借助外部配置中心作为流量开关配置,存在很强的定制化和侵入性,而本文所提及的方案设计很好的利用了RocketMQ原生的特性、无外部依赖、只需要改造client端代码(改动量特别小)即可完美的支持灰度消息。
二、当前痛点
上图是普通业务灰度流程,通常只保证RPC服务之间调用灰度。但正是与引进灰度发布的原因一样,消息队列场景当前不支持灰度验证。因为不管是灰度集群还是线上集群都同时平均消费订阅主题下的队列,在现在的流程中即使当前链路是灰度链路,消息的发送目标依然是不可控的,可能前往灰度集群监听的分区、也可能进入线上集群监听的分区。所以如果涉及到消费逻辑的变更,就需要开发人员在代码设计中做较多的兼容逻辑,但无论怎样的兼容逻辑,只能保证新业务不影响线上,而无法保证灰度流量精准进入灰度消费客户端,从而无法进行严谨有效的灰度验证。同时对开发人员对MQ需要有一定的深入了解,才能避免在平时业务变更时因为某个细节上的变更导致出现不可控的线上业务影响。所以,MQ支持灰度成为一直迫切需要解决但没有很好方案的一个架构问题。
三、目标
以尽量小的改造,结合现有的基建架构,提供一种最快、最简易、最安全的MQ灰度接入方式,包括但不限于以下技术、环境:
- 技术语言: JAVA、PHP、Node、Golang
- 环境:云上版MQ、开源版MQ
- 地域:云上版所有地域(开源版无地域差别)
- 消息类型:普通、顺序、事务、延时/定时等消息
四、基础概念
1.RocketMQ存储架构
2. RocketMQ消息收发主要逻辑
3.负载均衡实现
相关概念点
CommitLog:消息体实际存储的地方,当我们发送的任一业务消息的时候,它最终会存储在commitLog上。MQ在Broker进行集群部署(这里为也简洁,不涉及主从部分)时,同一业务消息只会落到集群的某一个Broker节点上。而这个Broker上的commitLog就会存储所有Topic路由到它的消息,当消息数据量到达1个G后会重新生成一个新的commitLog。
Topic:消息主题,表示一类消息的逻辑集合。每条消息只属于一个Topic,Topic中包含多条消息,是MQ进行消息发送订阅的基本单位。属于一级消息类型,偏重于业务逻辑设计。
Tag:消息标签,二级消息类型,每一个具体的消息都可以选择性地附带一个Tag,用于区分同一个Topic中的消息类型,例如订单Topic, 可以使用Tag=tel来区分手机订单,使用Tag=iot来表示智能设备。在生产者发送消息时,可以给这个消息指定一个具体的Tag, 在消费方可以从Broker中订阅获取感兴趣的Tag,而不是全部消息(注:严谨的拉取过程,并不全是在Broker端过滤,也有可能部分在消费方过滤,在这里不展开描述)。
Queue:实际上Topic更像是一个逻辑概念供我们使用,在源码层级看,Topic以Queue的形式分布在多个Broker上,一个topic往往包含多条Queue(注:全局顺序消息的Topic只有一条Queue,所以才能保证全局的顺序性),Queue与commitLog存在映射关系。可以理解为消息的索引,且只有通过指定Topic的具体某个Queue,才能找到消息。(注:熟悉kafka的同学可以类比partition)。
消费组及其ID:表示一类Producer或Consumer,这类Producer或Consumer通常生产或消费同应用域的消息,且消息生产与消费的逻辑一致。每个消费组可以定义全局维一的GroupID来标识,由它来代表消费组。不同的消费组在消费时互相隔离,不会影响彼此的消费位点计算。
负载均衡(这里只谈4.0架构,5.0使用POP模式): RockeMQ使用客户端负载均衡来完成队列分配。集群消费模式下,每个group可能有多个客户端实例,他们在启动时、下线时、运行时每间隔20秒等三处地方,会获取当前group的客户端在线信息以及主题队列情况,然后根据预先设置的负载均衡策略进行队列分配。会保证同一时刻一个队列只能被一个group下的客户端所消费。
消费位点: 同一个group下不同客户端各自拉取不同的队列,集群消费模式下,group对topic下的所有队列的消费进度维护在服务端,关系结构即: 1group->n客户端->n队列→n位点进度,位点进度结构为双层map结构,首层map key为group和topic的组合key,第二层map key为队列id,value为进度数字,每个客户端会间隔一定时间或者优雅下线时向broker上报自己的队列消费位点。
五、方案比较
1.影子topic
2.灰度tag
3.灰度header
与灰度tag流程差不多,只是把标记做在userProperty上,消费端会收到全量的消息,再自己过滤。
4.影子group
5.灰度分区
六、灰度分区设计
灰度分区设计实际上利用到以下几点:
devops: 现有流程中,灰度服务的pod容器内部会有 CANARY_RELEASE:canary 的环境变量。
MQ客户端心跳上报: 源码中,RocketMQ客户端启动时会想向所有产生订阅关系的broker发送心跳,心跳中带有clientId,该值主要由实例名、容器ip等组成,可以利用canary环境变量做一层额外的注入
MQ客户端重平衡: 源码中,每隔20秒/客户端上下线,都会触发一次客户端重平衡,RocketMQ提供了默认几种策略,同时支持扩展,我们可以自定义该策略,加入灰度分区平衡逻辑。
MQ客户端发送方: 源码中,RocketMQ发送方每次发送消息都会轮询队列发送,同时加入重试和故障规避的策略,可以通过重写该类来做扩展。
1.消费者灰度前
2.消费者灰度中
3.上线
针对以上流程,这里说明下针对发送方以及消费方的改造逻辑。
发送方: 无论何时,只要自己当前环境是灰度(DEVOPS_RELEASE_TYPE=canary)或者当前是灰度链路,则会根据broker分组选择每个集群的最后一个分区作为灰度队列,否则选取其他分区发送,根据RocketMQ的源码,自定义发送策略即可实现。
/**
* mq消息发送故障策略(重写覆盖了rocketmq)
*
* @author jayron
*/
@Slf4j
public class MQFaultStrategy {
private final LatencyFaultTolerance<String> latencyFaultTolerance = new LatencyFaultToleranceImpl();
private boolean sendLatencyFaultEnable = false;
private long[] latencyMax = {
50L, 100L, 550L, 1000L, 2000L, 3000L, 15000L};
private long[] notAvailableDuration = {
0L, 0L, 30000L, 60000L, 120000L, 180000L, 600000L};
private final ConcurrentHashMap<TopicPublishInfo, TopicPublishInfoCache> topicPublishInfoCacheTable = new ConcurrentHashMap<>();
public long[] getNotAvailableDuration() {
return notAvailableDuration;
}
public void setNotAvailableDuration(final long[] notAvailableDuration) {
this.notAvailableDuration = notAvailableDuration;
}
public long[] getLatencyMax() {
return latencyMax;
}
public void setLatencyMax(final long[] latencyMax) {
this.latencyMax = latencyMax;
}
public boolean isSendLatencyFaultEnable() {
return sendLatencyFaultEnable;
}
public void setSendLatencyFaultEnable(final boolean sendLatencyFaultEnable) {
this.sendLatencyFaultEnable = sendLatencyFaultEnable;
}
private TopicPublishInfoCache checkCacheChanged(TopicPublishInfo topicPublishInfo) {
if (topicPublishInfoCacheTable.containsKey(topicPublishInfo)) {
return topicPublishInfoCacheTable.get(topicPublishInfo);
}
synchronized (this) {
TopicPublishInfoCache cache = new TopicPublishInfoCache();
List<MessageQueue> canaryQueues = MessageStorage.getCanaryQueues(topicPublishInfo.getMessageQueueList());
List<MessageQueue> normalQueues = MessageStorage.getNormalQueues(topicPublishInfo.getMessageQueueList());
Collections.sort(canaryQueues);
Collections.sort(normalQueues);
cache.setCanaryQueueList(canaryQueues);
cache.setNormalQueueList(normalQueues);
topicPublishInfoCacheTable.putIfAbsent(topicPublishInfo, cache);
}
return topicPublishInfoCacheTable.get(topicPublishInfo);
}
/**
* 队列选择策略
* 如果当前是灰度环境,则发送到灰度分区
* 如果开启了故障规避,则选择一个可用的消息队列(不包含灰度分区)
* 如果没有开启故障规避,则选择一个消息队列(不包含灰度分区)
*
* @param tpInfo 消息队列信息
* @param lastBrokerName 上次发送失败的brokerName
* @return 选择的消息队列
*/
public MessageQueue selectOneMessageQueue(final TopicPublishInfo tpInfo, final String lastBrokerName) {
List<MessageQueue> messageQueueList = tpInfo.getMessageQueueList();
TopicPublishInfoCache topicPublishInfoCache = checkCacheChanged(tpInfo);
//灰度的场景下,发送消息到灰度分区
if (MessageStorage.isCanaryRelease()) {
MessageQueue messageQueue = selectDefaultMessageQueue(tpInfo, lastBrokerName, topicPublishInfoCache.getCanaryQueueList());
if (log.isDebugEnabled()) {
log.debug("canary context,send message to canary queue:{}", messageQueue