无损切换消息队列消费的一种解决方案

407 阅读5分钟

背景

在实际的业务场景中,偶尔会遇到消息队列切换的问题,例如:

  • 上游的旧队列因为某种原因准备废弃了,需要下游切换到新的队列。
  • 上游的某一类消息之前投递到队列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

  • 整体流程
  1. 首先走旧链路,进行diff
  2. 满足completeStageOld后,进行新/旧链路mix阶段
  3. 在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结果看出