异步写Mysql:轻松抗住百万QPS的秘密武器

1,906 阅读5分钟

背景:

书接前文: 轻松应对高并发和大流量:一套简单可靠的后端缓存系统

讲述了如何构建一个简单可靠还高性能的缓存系统,文中略过了一个重要的点:保证先写缓存,后写持久化数据库(Mysql)一定会成功。

偷袭.gif

本文将讨论,异步写 Mysql 的业界一般实现和一些注意事项。

系统架构(技术栈)

  1. DB:Mysql
  2. MQ:Kafka
  3. Cache:Redis

流程

async流程.png

流程如上图,这里很容易提出三个问题:

  1. 异步写 Mysql ,如何提前获取索引ID?
  2. 写 Kafka 失败了怎么办?
  3. 消费 Kafka 失败了怎么处理?

分布式ID 预生成处理

接上文,举个例子,比如: 两个请求都同时 insert 同一张表,那么因为是异步,无法使用自增ID,那么如何确认各自在表的行ID呢?

假设有上万用户同时申请创建用户操作,是否能够 Cover 住,让每一个用户都能分配到唯一不重复的ID?

业界常见做法可以参考:tech.meituan.com/2017/04/21/…

此处简单总结:

  1. (类)雪花算法:美团 leaf 、百度 uid-generator
  2. 基于号段的ID生成算法: 美团leaf 、滴滴tinyid

其中,为了实现高可用, 滴滴tinyid 的实现很有意思,在基于基础号段的做法上 :

接入两个(或多个)独立的Mysql,在每一个Mysql服务上有不同的表,但是其中一个只生产奇数,另一台只生产偶数。

总结: 轮子早已经造好,用就完了。

健壮性拓展

下游的基础服务时不时抖动一下是非常正常的, 健壮性好的服务需要 cover 住这种情况,不然就会丢失用户的请求,造成数据丢失,甚至资产损失。

那么回到问题:如果处理写 Kafka 失败的情况?———— 很简单,重试呗

  • 容器内重试

当写失败时,将kafka msg 序列化好,并保存到容器内的 channel、log 中

run起来另一个协程,不停地对上述的 channel 或 log 进行消费,将里面的消息重新发送kafka。

缺点:因为现在大多服务都是采用容器化技术,容器重启就会丢失未执行完的消息

  • 持久化

为了不丢失数据,所以这些失败需要重试的日志需要能持久化保存。

1 可以用Mysql等关系型保存。

2 或者是其他非关系型KV数据库。

3 再或者能把日志写到一个不会丢失磁盘上也行。

总之能够确保这些失败的消息能够保存。推荐用 Mysql 实现: 比较简单 且 不引入新的依赖服务

如果这里持久化失败了怎么办?

这种情况说明 Mysql、Kafka 都出现了问题,代码能做的其实已经不多了。

  1. 先上报监控并触发告警,让开发者注意到问题
  2. 打印日志。 (日志平台一般能够采样)
  3. 下载日志并手动修复。

NOTE:此时 Redis 数据依然是正确的,在 Redis 未过期的时间内,依然有缓冲。所以这也是为什么建议将 Redis 过期时间设置得稍长

  • 消费

限速 ;由于大流量,上游生产 Kafka 消息的速度非常快,Mysql 是不能承受的,因此这里如果触发了限速,那么就需要等待。

重试

这里失败说明 Mysql 暂时是不可用的,因此这里的重试选择持久化到日志文件,并由另一个服务/协程进行重试

如果重试失败,同上面一样,也是 监控+告警+手动重试

PS: 实际上在线上,几年来都没有出现过此情况。

另外也可以再引入一个第三方的依赖来进一步增强健壮性。

关于读请求的优化

至此,写请求都被改造成异步,但是读请求依然是同步,虽然 Mysql 对读请求的性能大大高于写请求,但是如果是用户数量过高,Mysql 依然无法扛住,即 缓存穿透引发雪崩

对此一般有以下几种做法: 后端:

  1. 提前进行缓存预热,即将很可能来的用户提前加载 Redis 缓存
  2. 限流,对存在较多读取 Mysql 的接口进行限流保护
  3. 适当的提高Redis的过期时间,比如在 前文所采用的 Redis 架构中,Redis Key 的过期时间为7天

前端:

  1. 错峰,手动削峰用户流量
  2. 对于某些页面,增加静态资源展示,减少对后端接口请求

中间件

  • Mysql 读写分离,用从库提升读请求的承载能力,以及根据流量考虑是否需要扩容。
  • 分库分表

其他问题

  1. 重复消费和并发消费问题 ———— Mysql 表中里面一般会保存 Modify Time,一个简单的做法是,每次更新 Mysql 时,都仅在当前消息更领先的时候更新,即 Msg.ModifyTime > DB.ModifyTime
  2. 待补充

附带代码:


func WriteRequest(){
    info, err := cache.GetInfo(ctx, uid)
    if err != nil {
       return err
    }

    // 修改info

    err = cache.SetInfo(ctx, info)
    if err != nil {
       return err
    }

    // 
    msg := &KafkaMessage{}
    err := mq.GetProducer().Publish(ctx, msg)
    if err != nil {
       // 记录到Mysql
       recordErr = mysql.RecordKafkaMsg(ctx, msg)
       if recordErr != nil {
          // 监控、日志
          log.Error()
          reporter
       }
    }
    return nil
}


func ConsumeKafkaMsg(kafkaMsg){
    //
    err := mysql.UpdateDB(kafkaMsg)

    if err != nil {
       // 写文件
       writeToFileErr := WriteToFile(kafkaMsg)
       if writeToFileErr != nil {
          reporter
          log.Error()
          // 或者是再引入第三方增强扩展性
       }
    }
    return nil
}

// 监听文件,进行重试
func WatchFile(){
    for {
       kafkaMsgBytes = reader.ReadLine()
       kafkaMsg = json(kafkaMsgBytes)
       err := mysql.UpdateDB(kafkaMsg)
       if err !=nil {
          log
          // 重新写回文件
          writeToFileErr := WriteToFile(kafkaMsg)
          if writeToFileErr!=nil{
             // 监控
             // 日志
          }
       }
    }
}