3. 数据安全与性能并重:RabbitMQ在选课系统中的异步处理

回顾上文

在上文中我们使用Gin+Mysql+Redis带来了性能上的突破,在末尾我们还留下了一个问题。相信大家也猜到了是数据安全的问题,其中我们只是单纯的使用go func进行异步的写入到数据库,但是那面服务挂掉导致所有的go协程停止掉。这时就导致了数据的丢失,不能及时的写入到数据库。

现存的问题

  • 无法确认消息是否有效的写入到数据库。

本文解决

本文将引入消息队列来进行处理异步操作,将数据通过消息队列写入到数据库,但是消息队列也会存在消息的丢失等问题。---凡是技术必有好坏。

能学到什么

  • Go操作RabbitMQ
  • 如何确保消息不丢失
  • 如何避免消息的重复消费

涉及技术

  • Gin
  • Mysql
  • Redis
  • RabbitMQ

架构设计

业务改进

方式一

由于我们存在选课和退课的两个操作,那么是不是可以使用两个队列进行处理呢?选课队列和退课队列,分别在启动两个后台消费线程进行消费。那我们使用消息队列的什么消费模式(工作模式,发布订阅模式,路由模式,主题模式)呢?根据此业务选择路由模式 是无疑的,多个人可以消费,且是消息只能被消费一次。其实工作模式 也行。但是路由模式提高并发执行。

创建交换机和路由

选课队列
go 复制代码
func (s *Select) Declare() error {
    err := s.channel.ExchangeDeclare(variable.SelectExchange, variable.SelectKind,
       true, false, false, false, nil,
    )
    if err != nil {
       return err
    }
    _, err = s.channel.QueueDeclare(variable.SelectQueue, true,
       false, false, false, nil,
    )
    if err != nil {
       return err
    }
    // 将队列绑定到交换机上
    err = s.channel.QueueBind(variable.SelectQueue, variable.SelectRoutingKey,
       variable.SelectExchange, false, nil)
    if err != nil {
       return err
    }
    return nil
}
退课队列
go 复制代码
func (s *Back) Declare() error {
    err := s.channel.ExchangeDeclare(variable.BackExchange, variable.BackKind,
       true, false, false, false, nil,
    )
    if err != nil {
       return err
    }
    _, err = s.channel.QueueDeclare(variable.BackQueue, true,
       false, false, false, nil,
    )
    if err != nil {
       return err
    }
    // 将队列绑定到交换机上
    err = s.channel.QueueBind(variable.BackQueue, variable.BackRoutingKey,
       variable.BackExchange, false, nil)
    if err != nil {
       return err
    }
    return nil
}

创建消费者

选课队列
go 复制代码
func (s *Select) Consumer() {
    //接收消息
    results, err := SelectConsumer.channel.Consume(
       variable.SelectQueue,
       variable.SelectRoutingKey,
       false, // 关闭自动应答
       false, //
       false,
       false,
       nil,
    )
    if err != nil {
       logger.Logger.Error("消息接收失败", err)
    }
    //启用后台协程处理消息
    go func() {
       for res := range results {
          var msg *mqm.CourseReq
          err := json.Unmarshal(res.Body, &msg)
          if err != nil {
             logger.Logger.Error("消息反序列化失败", err)
             continue
          }
          // 扣减库存操作
          if err := database.Client.Transaction(func(tx *gorm.DB) error {
             // 2.4 扣减课程库存
             if err := tx.Model(&models.Course{}).
                Where("id=?", msg.CourseID).
                Update("capacity", gorm.Expr("capacity - 1")).Error; err != nil {
                logger.Logger.Info("更新课程容量失败", err)
                return err
             }
             // 2.5 创建选课记录
             if err := tx.Create(&models.UserCourse{
                UserID:   msg.UserID,
                CourseID: msg.CourseID,
             }).Error; err != nil {
                logger.Logger.Info("创建选课记录失败", err)
                return err
             }
             return nil // 成功,无错误返回
          }); err != nil {
             err := res.Nack(false, true)
             if err != nil {
                logger.Logger.Error("消息确认失败", err)
                return
             }
             logger.Logger.Info("事务回滚", err)
             return
          }
          err = res.Ack(false)
          if err != nil {
             logger.Logger.Error("消息确认失败", err)
             return
          }
       }
    }()
}
退课队列
go 复制代码
func (s *Back) Consumer() {
    //接收消息
    results, err := BackConsumer.channel.Consume(
       variable.BackQueue,
       variable.BackRoutingKey,
       false, // 关闭自动应答
       false, //
       false,
       false,
       nil,
    )
    if err != nil {
       logger.Logger.Error("消息接收失败", err)
    }
    //启用后台协程处理消息
    go func() {
       for res := range results {
          var msg *mqm.CourseReq
          err := json.Unmarshal(res.Body, &msg)
          if err != nil {
             logger.Logger.Error("消息反序列化失败", err)
             continue
          }

          // 扣减库存操作
          if err := database.Client.Transaction(func(tx *gorm.DB) error {
             if err := tx.Model(&models.Course{}).
                Where("id=?", msg.CourseID).
                Update("capacity", gorm.Expr("capacity + 1")).Error; err != nil {
                logger.Logger.Info("更新课程容量失败", err)
                return err
             }
             if err := tx.Where("user_id=? and course_id=?", msg.UserID, msg.CourseID).Delete(&models.UserCourse{}).Error; err != nil {
                logger.Logger.Info("删除选课记录失败", err)
                return err
             }

             return nil
          }); err != nil {
             if err := res.Nack(false, true); err != nil {
                logger.Logger.Error("消息确认失败", err)
                return
             }
             logger.Logger.Info("事务回滚", err)
             return
          }
          if err := res.Ack(false); err != nil {
             logger.Logger.Error("消息确认失败", err)
             return
          }
       }
       logger.Logger.Info("消息接收协程退出")
    }()
}

创建生产者

选课队列
go 复制代码
func (s *Select) Product(msg *mqm.CourseReq) {
    bytes, err := json.Marshal(msg)
    if err != nil {
       logger.Logger.Error("消息序列化失败", err)
       return
    }
    err = s.channel.Publish(
       variable.SelectExchange,
       variable.SelectRoutingKey,
       false,
       false, amqp.Publishing{
          ContentType: "text/plain",
          Body:        bytes,
       })
    if err != nil {
       logger.Logger.Error("消息发送失败", err)
       return
    }
}
退课队列
go 复制代码
func (s *Back) Product(msg *mqm.CourseReq) {
    bytes, err := json.Marshal(msg)
    if err != nil {
       logger.Logger.Error("消息序列化失败", err)
       return
    }
    err = s.channel.Publish(
       variable.BackExchange,
       variable.BackRoutingKey,
       false,
       false, amqp.Publishing{
          ContentType: "text/plain",
          Body:        bytes,
       })
    if err != nil {
       logger.Logger.Error("消息发送失败", err)
       return
    }
}

问题

暂时无法在飞书文档外展示此内容

这里存在顺序执行错误,假设用户是串行请求,然后后端把用户操作放入对应队列。但是此时select队列发生阻塞、网络抖动等问题。那么与此同时back队列也在消费,那么这时就发生了错误了。因为并不是顺序的当back消费完了,此时select才开始消费那么就发送了顺序错误,明明是需要退课的但是最后是选课了。

如何解决呢?

首先我们需要分析造成错误的原因?原因就是消费顺序错误了导致最终的数据也不一致,那执行顺序我们可以进行加锁进行控制访问顺序吗?显然不不行的,因为是不同的两个消费者进行消费,无法确定谁先消费。那么如何解决呢?其实我们可以就使用一个队列来进行顺序执行,即使不通过锁也能确保执行顺序,先来先处理的原则。

方式二

创建交换机

go 复制代码
func (s *Select) Declare() error {
    err := s.channel.ExchangeDeclare(variable.SelectExchange, variable.SelectKind,
       true, false, false, false, nil,
    )
    if err != nil {
       return err
    }
    _, err = s.channel.QueueDeclare(variable.SelectQueue, true,
       false, false, false, nil,
    )
    if err != nil {
       return err
    }
    // 将队列绑定到交换机上
    err = s.channel.QueueBind(variable.SelectQueue, variable.SelectRoutingKey,
       variable.SelectExchange, false, nil)
    if err != nil {
       return err
    }
    return nil
}

创建消费者

go 复制代码
func (s *Select) Consumer() {
    //接收消息
    results, err := SelectConsumer.channel.Consume(
       variable.SelectQueue,
       variable.SelectRoutingKey,
       false, // 关闭自动应答
       false, //
       false,
       false,
       nil,
    )
    if err != nil {
       logger.Logger.Error("消息接收失败", err)
       return
    }
    //启用后台协程处理消息
    go func() {
       for res := range results {
          var msg *mqm.CourseReq
          var err error
          err = json.Unmarshal(res.Body, &msg)
          if err != nil {
             logger.Logger.Error("消息反序列化失败", err)
             continue
          }
          switch msg.Type {

          case mqm.SelectType:
             err = database.Client.Transaction(func(tx *gorm.DB) error {
                // 2.4 扣减课程库存
                if err := tx.Model(&models.Course{}).
                   Where("id=?", msg.CourseID).
                   Update("capacity", gorm.Expr("capacity - 1")).Error; err != nil {
                   logger.Logger.Info("更新课程容量失败", err)
                   return err
                }
                // 2.5 创建选课记录
                if err := tx.Create(&models.UserCourse{
                   UserID:   msg.UserID,
                   CourseID: msg.CourseID,
                }).Error; err != nil {
                   logger.Logger.Info("创建选课记录失败", err)
                   return err
                }
                random := rand.Int()
                if random&1 == 0 {
                   return errors.New("模拟事务错误")
                }
                return nil // 成功,无错误返回
             })
          case mqm.BackType:
             err = database.Client.Transaction(func(tx *gorm.DB) error {
                if err := tx.Model(&models.Course{}).
                   Where("id=?", msg.CourseID).
                   Update("capacity", gorm.Expr("capacity + 1")).Error; err != nil {
                   logger.Logger.Info("更新课程容量失败", err)
                   return err
                }
                if err := tx.Where("user_id=? and course_id=?", msg.UserID, msg.CourseID).Delete(&models.UserCourse{}).Error; err != nil {
                   logger.Logger.Info("删除选课记录失败", err)
                   return err
                }
                random := rand.Int()
                if random&1 == 0 {
                   return errors.New("模拟事务错误")
                }
                return nil
             })
          }
          if err != nil {
             logger.Logger.Error("事务失败", err)
             if err := res.Nack(false, true); err != nil {
                logger.Logger.Error("消息确认失败", err)
             }
             continue
          }
          // 扣减库存操作
          err = res.Ack(false)
          if err != nil {
             if err := res.Nack(false, true); err != nil {
                logger.Logger.Error("消息确认失败", err)
             }
             logger.Logger.Error("消息确认失败", err)
             continue
          }
       }
    }()
}

创建生产者

go 复制代码
func (s *Select) Product(msg *mqm.CourseReq) {
    bytes, err := json.Marshal(msg)
    if err != nil {
       logger.Logger.Error("消息序列化失败", err)
       return
    }
    err = s.channel.Publish(
       variable.SelectExchange,
       variable.SelectRoutingKey,
       false,
       false, amqp.Publishing{
          ContentType: "text/plain",
          Body:        bytes,
       })
    if err != nil {
       logger.Logger.Error("消息发送失败", err)
       return
    }
}

测试一下

果不其然已测试就出错,一写就废。那我们究竟看一下是什么情况呢?

模拟一下队列执行

问题

针对于我们使用了一个队列进行处理,虽然解决了顺序执行的问题,但是如果其中某一个消息执行错误了呢?错误了我们该怎么解决呢?如果不解决的话是不是就造成数据不一致了,如果我们遇到了错误不断的尝试执行直到正确为止呢?是不是就一直占用了队列造成了阻塞,如果一直执行不成功那么就不是变成了**forerver run queue** 这样显然不行。那么如果执行错误的消息我们还可以先丢入到队尾,避免阻塞了其他消息的执行。问题就是出现在这里,如果放入到了队尾就可能造成消息的顺序错误了。

如何解决呢?

首先我们需要知道是什么问题造成消息顺序执行错误的,显然这是由于某个消息的执行错误,我们将消息丢入了队尾后续执行,这里就是导致顺序错误的原因。

经典的消息队列问题
  • 消息丢失

  • 消息重复消费

  • 消息堆积

其实我们这里有点像消息重复消费了,但是我们这里是需要确保消息一定是被顺序执行一次,其实对于重复消费之类的问题他的要求是消息只能被执行一次,多次执行是幂等的,且不强调顺序。那么我们该如何解决这个问题呢?

仔细想一下我们的目的是顺序执行,假设用户发送了:选课=》退课=》选课=》退课=》选课,的请求其实无论中途出现了什么错误,最终的结果肯定是以用户最后一次发送的操作为主,那么我们这里是不是可以设置一个执行顺序,为每个操作分配一个全局唯一的顺序ID(例如,递增序列号或时间戳)。这将帮助跟踪操作的顺序,这样以便于知道先后顺序。如果中途有一个操作错误了被丢入到了队尾或者处理异常的队列,是不是在之后我们可以通过判断在此之前是否有执行过,如果有的话,那么这一次的消息可以被看作为失效了,我们就不去执行它,因为前面已经有更后的消息执行过了。

方式三

画图

在这里我们进行模拟处理队列里的消息,如果消息执行错误我们将消息放入死信队列 不进行放会原来的队列,而是放入死信队列,当抢课阶段完成时,我们通过人工补偿的方式确保数据的最终一致。根据消息放入的时间来判断死信队列的消息是否失效,如果失效了什么都不做。如果消息是最新的那么我们就进行更新数据库且更新最后的执行时间,这样一来我们就能确保消息的执行顺序了。那就有人开始问了?如果死信队列中某条消息执行错误了,那岂不是一个死信循环了,错误就放入到死信的末尾。其实方式技术比有缺陷,都到了人工补偿这一步了,只能通过人工的手段进行干预。

lua 复制代码
+----------------+       +---------+       +-------------+
| 用户操作      | ---> | 消息队列 | ----> | 正常处理   |
+----------------+       +---------+       +-------------+
                    |       |         |
                    |       | 失败     |
                    v       v         v
            +-----------+       +---------+
            | 死信队列  |       | 人工补偿|
            +-----------+       +---------+
                    |
                    | 检查顺序ID
                    | 和有效性
                    v
            +-----------+
            | 无效消息  | 丢弃
            +-----------+
                    |
                    | 最新消息
                    v
            +-------------+
            | 数据库更新 | 顺序执行
            +-------------+

暂时无法在飞书文档外展示此内容

暂时无法在飞书文档外展示此内容

修改后代码

消费者
go 复制代码
func (s *Select) Consumer() {
    results, err := SelectConsumer.channel.Consume(
       variable.SelectQueue,
       variable.SelectRoutingKey,
       false, // 关闭自动应答
       false,
       false,
       false,
       nil,
    )
    if err != nil {
       logger.Logger.Error("消息接收失败", err)
       return
    }

    go func() {
       for res := range results {
          var msg *mqm.CourseReq
          err := json.Unmarshal(res.Body, &msg)
          if err != nil {
             logger.Logger.Error("消息反序列化失败", err)
             res.Reject(false)
             continue
          }

          err = database.Client.Transaction(func(tx *gorm.DB) error {
             switch msg.Type {
             case mqm.SelectType:
                if err := updateCourseCapacityAndUserCourse(tx, msg, true); err != nil {
                   return err
                }
             case mqm.BackType:
                if err := updateCourseCapacityAndUserCourse(tx, msg, false); err != nil {
                   return err
                }
             default:
                return fmt.Errorf("未知的消息类型: %s", msg.Type)
             }

             // 模拟事务错误
             if rand.Int()&1 == 0 {
                return errors.New("模拟事务错误")
             }

             return nil
          })

          if err != nil {
             logger.Logger.Error("事务失败", err)
             res.Reject(false)
             continue
          }

          // 消息确认
          if err := res.Ack(false); err != nil {
             logger.Logger.Error("消息确认失败", err)
          }
       }
    }()
}

func updateCourseCapacityAndUserCourse(tx *gorm.DB, msg *mqm.CourseReq, selectAction bool) error {
    capacityOp := gorm.Expr("capacity - 1")
    if !selectAction {
       capacityOp = gorm.Expr("capacity + 1")
    }

    if err := tx.Model(&models.Course{}).
       Where("id=?", msg.CourseID).
       Update("capacity", capacityOp).Error; err != nil {
       logger.Logger.Debug("更新课程容量", err)
       return err
    }

    var userCourse models.UserCourse
    // 存在就更新不存在就进行创建,以便于记录每次操作的时间
    if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).
       Where("user_id=? and course_id=?", msg.UserID, msg.CourseID).
       First(&userCourse).Error; err != nil {
       if !errors.Is(err, gorm.ErrRecordNotFound) {
          return err
       }

       userCourse = models.UserCourse{
          UserID:    msg.UserID,
          CourseID:  msg.CourseID,
          CreatedAt: msg.CreatedAt,
          IsDeleted: !selectAction,
       }

       if err := tx.Create(&userCourse).Error; err != nil {
          logger.Logger.Debug("创建/更新选课记录", err)
          return err
       }
    } else {
       userCourse.CreatedAt = msg.CreatedAt
       userCourse.IsDeleted = !selectAction
       if err := tx.Save(&userCourse).Error; err != nil {
          logger.Logger.Debug("创建/更新选课记录", err)
          return err
       }
    }

    return nil
}
补偿代码
go 复制代码
func TestHandlerDeadQueue(t *testing.T) {
    //接收消息
    results, err := mq.Client.Consume(
       variable.DeadQueue,
       variable.DeadRoutingKey,
       false, // 关闭自动应答
       false, //
       false,
       false,
       nil,
    )
    if err != nil {
       logger.Logger.Error("消息接收失败", err)
       return
    }
    // 获取死信队列

    for res := range results {
       var msg *mqm.CourseReq
       var err error
       err = json.Unmarshal(res.Body, &msg)
       if err != nil {
          if err := res.Nack(false, true); err != nil {
             logger.Logger.Error("消息拒绝失败", err)
          }
          logger.Logger.Error("消息反序列化失败", err)
          continue
       }
       err = database.Client.Transaction(func(tx *gorm.DB) error {
          if err := updateCourseCapacity(tx, msg, msg.Type == mqm.SelectType); err != nil {
             logger.Logger.Info("更新课程容量失败", err)
             return err
          }
          if err := updateUserCourseState(tx, msg, msg.Type == mqm.SelectType); err != nil {
             logger.Logger.Info("更新用户课程状态失败", err)
             return err
          }
          return nil // 成功,无错误返回
       })
       if err != nil {
          logger.Logger.Error("事务处理失败", err)
          // 放回队列
          err := res.Nack(false, true)
          if err != nil {
             logger.Logger.Error("消息拒绝失败", err)
          }
          continue
       }
       if err := res.Ack(false); err != nil {
          logger.Logger.Error("消息确认失败", err)
       }
    }
}
func updateCourseCapacity(tx *gorm.DB, msg *mqm.CourseReq, selectAction bool) error {
    capacityOp := gorm.Expr("capacity - 1")
    if !selectAction {
       capacityOp = gorm.Expr("capacity + 1")
    }

    if err := tx.Model(&models.Course{}).
       Where("id=?", msg.CourseID).
       Update("capacity", capacityOp).Error; err != nil {
       logger.Logger.Debug("更新课程容量", err)
       return err
    }
    return nil
}
func updateUserCourseState(tx *gorm.DB, msg *mqm.CourseReq, selectAction bool) error {
    var userCourse models.UserCourse
    if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).
       Where("user_id=? and course_id=? ", msg.UserID, msg.CourseID).
       First(&userCourse).Error; err != nil {
       if !errors.Is(err, gorm.ErrRecordNotFound) {
          return err
       }
       // 不存在
       if err := tx.Create(&models.UserCourse{
          UserID:    msg.UserID,
          CourseID:  msg.CourseID,
          CreatedAt: msg.CreatedAt, // 创建时记录创建时间
          IsDeleted: !selectAction,
       }).Error; err != nil {
          logger.Logger.Info("创建选课记录失败", err)
          return err
       }
       return nil
    }
    // 存在,还是判断msg是否在创建时间之前,不在的话,不更新。
    if err := tx.Model(&models.UserCourse{}).
       Where("user_id=? and course_id=? and created_at < ?", msg.UserID, msg.CourseID, msg.CreatedAt).
       Update("is_deleted", !selectAction).
       Update("created_at", msg.CreatedAt).Error; err != nil {
       logger.Logger.Info("更新选课记录失败", err)
       return err
    }
    return nil
}

补偿主要逻辑

这里我们采用了软删除的方式,通过IsDelete进行标识是否为有效,这样一来我们就能更好的利用···CreatedAt字段记录每次操作的时间,以便于判断消息是否有效性。

go 复制代码
func updateUserCourseState(tx *gorm.DB, msg *mqm.CourseReq, selectAction bool) error {
    var userCourse models.UserCourse
    if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).
       Where("user_id=? and course_id=? ", msg.UserID, msg.CourseID).
       First(&userCourse).Error; err != nil {
       if !errors.Is(err, gorm.ErrRecordNotFound) {
          return err
       }
       // 不存在
       if err := tx.Create(&models.UserCourse{
          UserID:    msg.UserID,
          CourseID:  msg.CourseID,
          CreatedAt: msg.CreatedAt, // 创建时记录创建时间
          IsDeleted: !selectAction,
       }).Error; err != nil {
          logger.Logger.Info("创建选课记录失败", err)
          return err
       }
       return nil
    }
    // 存在,还是判断msg是否在创建时间之前,不在的话,不更新。
    if err := tx.Model(&models.UserCourse{}).
       Where("user_id=? and course_id=? and created_at < ?", msg.UserID, msg.CourseID, msg.CreatedAt).
       Update("is_deleted", !selectAction).
       Update("created_at", msg.CreatedAt).Error; err != nil {
       logger.Logger.Info("更新选课记录失败", err)
       return err
    }
    return nil
}

存在,如果符合条件的话就进行更新(说明消息肯定是最新的),若不符合条件的话不会执行更新操作(说明消息肯定是被重新放入到死信队列,或者从死信队列放入末尾的)。

scss 复制代码
if err := tx.Model(&models.UserCourse{}).
       Where("user_id=? and course_id=? and created_at < ?", msg.UserID, msg.CourseID, msg.CreatedAt).
       Update("is_deleted", !selectAction).
       Update("created_at", msg.CreatedAt).Error; err != nil {
       logger.Logger.Info("更新选课记录失败", err)
       return err
    }

这样一来我们就解决了消息放回导致消息顺序执行错误的问题。

测试

模拟用户请求

看这里出现了错误,可以看到这里的课程到达了121门,我们的课程容量总共也就100门

这里也出现了超卖的现象

再来看到rabbitmq这里,这里死信队列里面存在24条消息。

补偿操作

进行手动补偿操作,过滤掉失效的消息,执行背后来的最新消息

在查看数据库,这里已经正常了,刚刚好100门。

补偿了之前超卖的情况

数据库与缓存选课对比一下

用户2

用户5

用户15

可以看到以上的数据库与缓存是达到了最终一致性的。

问题又来了,如何确保消息不丢失呢?

其实这就是一个经典的消息队列如何确保消息可靠性。

从多方分析

生产方到消息队列

暂时无法在飞书文档外展示此内容

1.1 生产者确认机制

当消息从生产方到消息队列,当消息队列接收到消息时可以进行回复一个确认机制(ACK),如果没有接收到的话,消息队列可以发送一个否认包(NACK),那么这时并非消息队列造成的丢失而是消息在发送过程中失败了,这时生成方需要重新发送。

1.2 事务机制

在生产者发送消息前,可以启动一个事务。当所有需要在当前事务中处理的消息都发送完毕后,生产者进行提交事务。若生产者发现消息未发送完整或者失败的话,可以进行回滚事务。虽然事务机制提供了强一致性的保障,但在大多数情况下,发布确认结合消息持久化和其他最佳实践,就能在保证较高消息可靠性的同时,维持较好的系统性能。

消息队列的持久化

消息队列持久化机制

当消息成功发送到消息队列时,如果数据丢失了,那么就是消息队列方的问题(RabbitMQ宕机),其实此时的数据还是处于在内存中的,还未进行落盘操作。如何确保消息队列的持久化呢?

2.1 交换机持久化

可以在声明交换机时指定交换机进行持久化 Durable=True

2.2 消息队列持久化

可能你会发现,重启RabbitMQ服务后,Exchange不丢失了,但是队列和消息丢失了,那么如何解决队列不丢失呢?答案也是设置durable参数。

声明队列时指定持久化。

2.3 消息持久化

RabbitMQ重启后,Exchange和Queue都不丢失了,但是存储在Queue里的消息却仍然会丢失,那么如何保证消息不丢失呢?答案是设置消息的投递模式为2,即代表持久化。

消费者消息确认

需要关闭自动确认机制

3.1 消费确认机制

当消费者进行从消息队列获取消息时,如果消息消费成功时可以发布确认包,来告诉消息队列这条消息消费成功可以进行标记删除了(非是fanout模式下)。异常时可以指定消息回到消息队列末尾还是带到消息队列重新启动时再续进行消费。

消息队列重试机制

4.1 代码层重试机制

当消息异常时,可以通过代码层上的进行重试,但是一定要控制重试的次数,避免造成恶性循环。

4.2 返回队列重试

当消息异常时,可以进行发送Nack包,且将消息放回队尾,直到下一次继续执行。

总结

这期我们解决了上期留下的异步处理导致消息可靠性丢失,最终造成一致性问题。

消费顺序

我们通过引入消息队列来进行解决消息可靠性问题。

  1. 起初我们通过两个消息队列分别处理选课和退课操作,但是如果当其中一个队列网络抖动或者阻塞了,就会造成消费顺序错误,最终导致数据不一致。
  2. 为了解决消息顺序问题,我们仅使用一个队列进行处理,由于队列的FIFO特性,这样就可以避免了消息队列出现执行错误的顺序。但是我们通过模拟事务失败进行消息放回消息队列又出现顺序问题了。
  3. 为了解决消息放回队列造成顺序执行错误的问题,我们通过给每个消息创建一个时间戳和死信队列。如果消息错误我们将消息返回死信队列。当选课阶段完成时,若存在异常我们进行手动补偿操作。之后我们可以通过消息的创建时间来判断是否失效。如果失效了,就只进行归还库存操作。如果消息是最新的,那么我们就更新数据库的操作时间戳且归还库存。

这样一来我们就解决了消息队列执行顺序造成数据不一致的问题。

消息持久化

在文末我们还提出了如何确保消息不丢失。

  1. 生产方到消息队列

  2. 消息队列持久化

  3. 消费者确认机制

  4. 重试机制

这样一来我们完成了一个单机可靠的选课系统。接下来我们需要从项目角度与业务去考虑项目的优化点。

相关推荐
014-code11 分钟前
RabbitMQ 生产端可靠投递(confirm、return、重试)
分布式·消息队列·rabbitmq
014-code20 分钟前
RabbitMQ 消费端幂等实战(重复消息、去重、重放怎么处理)
分布式·消息队列·rabbitmq
8Qi82 小时前
微服务通信:同步 vs 异步与MQ选型指南
java·分布式·微服务·云原生·中间件·架构·rabbitmq
redaijufeng3 小时前
SpringBoot中整合RabbitMQ(测试+部署上线 最完整)
spring boot·rabbitmq·java-rabbitmq
糖炒栗子03263 小时前
后端消息投递可靠性:基于 RabbitMQ 的“双重防线-幂等闭环”模式
java·后端·rabbitmq
jwt7939279375 小时前
RabbitMQ HAProxy 负载均衡
rabbitmq·负载均衡·ruby
鬼先生_sir19 小时前
RabbitMQ 全面解析(完整版)
分布式·rabbitmq
Mgx20 小时前
我在 Mac 写了个服务,硬要它在 18 岁高龄的 Windows 服务器上跑,结果…
go
yuweiade1 天前
使用 Docker 部署 RabbitMQ 的详细指南
docker·容器·rabbitmq
IT莫染1 天前
Spring Boot 集成 RabbitMQ MQTT 协议实现消息通信
rabbitmq