背景
在实际的业务场景中,偶尔会遇到消息队列切换的问题,例如:
- 上游的旧队列因为某种原因准备废弃了,需要下游切换到新的队列。
- 上游的某一类消息之前投递到队列A,后续切换到队列B。
- .......
我们希望在处理这种队列切换问题时候不会影响到后续逻辑,例如:
- 监听到消息将数据写入数据库,不能因为消息切换导致有一两条数据没写到数据库,也不能将同一条数据重复写入多次。
- 监听到消息后,根据消息内容进行内部处理后,自己也发送一条或多条消息,也不能重复发送消息或者不发送消息
我们期望新输出/旧输出完全一致,即不丢失消息也不重复发送消息
核心问题
两个消息队列消费的offset不同。 导致从旧队列切换到新队列时候,会出现消息的丢失或者重复发送
- old offset < new offset
-
- 从老链路切换到新链路:整体的offset由old offset直接拨回到new offset,会丢失[old-new]之间的消息,对应图中的3、4
- old offset > new offset
-
- 从老链路切换到新链路:整体的offset由old offset直接拨回到new offset,会重复发送[old-new]之间的消息,对应图中的3、4
- old offset = new offset
-
- 只存在于某个瞬间,可以认为不存在该情况
解决方案
解决的方法也很简单,就是让最终的输出消费的offset永远是领先的那个。
为了达成这一目的,可以将整个切流分为三个阶段:
- 阶段1:new/old链路进行diff,对外只输出old链路数据,首要目标是验证old链路和new链路的一致性。
此阶段主要利用redis进行保存new/old链路的请求并对比,确保新旧链路没有diff。输出依旧使用old链路的输出
- 阶段2:在阶段1的基础上,确保新旧链路对外输出一致时,old链路和new链路那条链路先被消费就输出那条链路,这个步骤的目标时是确保输出的offset是new/old两个链路中offset最前面的。
-
- 阶段2还需要处理阶段1的一些遗留消息,在阶段1结束时候,存在两种情况:
-
- new offset > old offset:此时[old offset,new offset]这部分数据还需要消费并输出,否则会丢失这部分消息
- new offset < old offset:此时[new offset, old offset]这部分数据则不需要输出,否则会造成消息重复发送
- 阶段3:在阶段2的基础上,对外只输出new链路的数据,将旧链路关闭即可。
-
- new offset > old offset:对外输出的offset也是new offset,确保不会丢失和重复发送消息
- old offset> new offset:对外输出的offset也是new offset,新链路的一些消息会被去重掉,确保不会重复发送消息。
代码
git@github.com:heucoder/mq_change.git
- 整体流程
- 首先走旧链路,进行diff
- 满足completeStageOld后,进行新/旧链路mix阶段
- 在mix阶段跑一段时间后,当第一阶段的old链路请求全部发送完成后,就可以完全切换到new链路
//diff的数量小于5,并且old阶段的请求数量大于10000,可以切换到mix阶段
func (m *MqChange) completeStageOld() bool {
if m.StageOldManager.DiffNum <= 1 &&
m.StageOldManager.OldNum >= 5 &&
m.StageOldManager.NewNum >= 5 {
return true
}
return false
}
//切换到mix阶段后,old阶段的所有旧请求都发送完了,此时可以切换到新链路
func (m *MqChange) completeStageMix() bool {
return m.StageOldManager.OldNum >= m.StageOldManager.NewNum
}
func (m *MqChange) Handle(ctx context.Context, r Req) (Req, bool) {
if !m.completeStageOld() {
return m.stageOldLink(ctx, r)
} else {
if m.completeStageMix() { //人工确认,走新链路
return m.stageNewLink(ctx, r)
}
return m.stageMixLink(ctx, r)
}
}
- 阶段1
无论新旧链路都做diff,只返回旧链路数据
//走老链路,新旧链路diff,只输出老链路
func (m *MqChange) stageOldLink(ctx context.Context, r Req) (Req, bool) {
m.StageOldManager.diff(ctx, r) //diff
if r.IsOriginal() {
m.StageOldManager.OldNum++
return r, true
}
m.StageOldManager.NewNum++
return nil, false
}
- 阶段2
//新旧链路谁先到走哪个
func (m *MqChange) stageMixLink(ctx context.Context, r Req) (Req, bool) {
if !r.IsOriginal() {
if m.StageOldManager.isExist(ctx, r) { //new<old,新链路都干掉
return nil, false
}
if m.StageMixManager.isExist(ctx, r) {
return nil, false
}
return r, true
}
if m.StageOldManager.isExist(ctx, r) { //阶段1时候new>old,此时old正在追平new
m.StageOldManager.OldNum++
}
//old<new的时候,旧链路需要追平第一阶段,之后第二阶段谁来的早消费谁,晚的就不消费了
if m.StageMixManager.isExist(ctx, r) {
return nil, false
}
return r, true
}
- 阶段3
//走新链路
func (m *MqChange) stageNewLink(ctx context.Context, r Req) (Req, bool) {
if !r.IsOriginal() {
if m.StageMixManager.isExist(ctx, r) { //二阶段old链路已经发送过了,不需要重复发送
return nil, false
}
return r, true
}
return nil, false
}
- demo
func producer() {
for i := 0; i < 20; i++ {
time.Sleep(time.Millisecond * time.Duration(rand.Intn(100)+50))
mq <- i
}
close(mq)
}
func newProducer() {
for i := 0; i < 20; i++ {
time.Sleep(time.Millisecond * time.Duration(rand.Intn(100)+10))
newMq <- i*i + 1
}
close(newMq)
}
func process() (int, bool) {
v, ok := <-mq
return v * v, ok
}
func newProcess() (int, bool) {
v, ok := <-newMq
return v - 1, ok
}
func handle(ctx context.Context) {
go producer() //生产消息
go func() { //消费消息
for {
v, ok := process() //旧逻辑
if !ok {
break
}
// resCh <- v
diffReq := &DiffReq{
v: v,
isOriginal: true,
}
r, pass := mqChange.Handle(ctx, diffReq)
if !pass {
continue
}
diffReq = r.(*DiffReq)
resCh <- fmt.Sprintf("old:%v", diffReq.v)
}
}()
}
func newHandle(ctx context.Context) {
go newProducer() //生产消息
go func() { //消费消息
for {
v, ok := newProcess() //新逻辑
if !ok {
break
}
// resCh <- v
diffReq := &DiffReq{
v: v,
isOriginal: false,
}
r, pass := mqChange.Handle(ctx, diffReq)
if !pass {
continue
}
diffReq = r.(*DiffReq)
resCh <- fmt.Sprintf("new:%v", diffReq.v)
}
}()
}
func output() {
i := 0
for {
v, ok := <-resCh
if !ok {
break
}
fmt.Printf("i:%v, val:%v\n", i, v)
i++
}
}
效果如下,确保没有多发或者少发送数据:
展望
- 这种方式没有办法保证消息是有序的,因为在mix阶段时候,可能存在old链路消息还没被发送,但是new链路的消息已经发送了,此时会先消费new链路的,也就会导致消息整体的有序性被打乱。也可以从上一节中的demo结果看出