投票中台的存储进化史

668 阅读11分钟

本文正在参加「技术专题19期 漫谈数据库技术」活动

注意:由于一些数据不方便对外暴露,因此将具体数值用代号替代脱敏,如果有需要看真实数字的字节内部同学请移步tech.bytedance.net/articles/71…

背景

随着飞书的业务发展,群投票功能的缺点逐渐显现出来:包括功能单一,性能较差。这就会使得用户现有场景体验不好,且无法满足一些多功能的用户使用场景。相对竞品来说产品力较弱。而为了支持业务场景的演化,在技术侧需要进行一波大规模的重构,今天本文就针对其中存储层的重构设计进行一次分享。

存储演变史

业务场景

需要支持超大群(xxxx人)场景的高并发投票场景。

存储模型

为了支持投票功能,我们将投票选项详情和投票人详情分为了两张表进行存储,表结构可看下方:

投票选项详情

image.png

投票人详情

image.png

存储演化

整个投票业务包括很多功能,包括发起投票、进行投票、关闭投票状态等等很多操作。在其中的性能瓶颈就是【进行投票】这一步骤。在一个群中,每有一个人进行投票,首先会去更新投票人详情,记录用户投了哪些选项。同时还需要去更新投票选项详情,记录某一个选项的得票数、投票人等等。为了支持大群(xxx人)乃至超大群(xxxx人)的投票性能,投票一共经历了三个阶段的存储演化。

数据一致性问题

在大群/超大群的场景下,对同一投票下各选项的获票总数、投票人等信息的查询并发极高,mysql无法支持,所以需要增加redis缓存。而由于某个选项的投票人数可能为数万,因此不能只能增加一个缓存用来存储选项投票人信息,然后用此缓存的投票人总数来做该选项的投票总数。

主要存在以下问题导致数据一致性难以保证:

  • 由于某个选项的投票人数可能上万,无法缓存全量数据,所以不能直接以投票人个数记得做投票总数,缓存的内容需要分别包括投票人、投票总数,需要同步变更,二者间需要保证数据一致

  • 投票场景下数据更新极为频繁,如果每次更新均清空缓存等待重建,则缓存命中率会很低,作用有限,需要采用更新库同时也更新缓存的方式,数据不一致的风险大

  • 选项新增投票人时,更新缓存数据需要依赖已有数据,update存做设计一次get和一次set,这两个不是原子操作,处理不好极易导致数据不一致

场景举例

image.png

可见上图,出现不一致的场景是这样的:

  1. UserA和UserB在同一时刻针对同一投票实体Vote_Entity_1分别进行了投票行为(VOTE)和拉取投票结果行为(PULLVOTERESULT)。

  2. 在t1时刻,userB的请求开始进行拉取投票结果,此时发现缓存中没有结果(可能刚刚被另一个投票行为清除),于是去数据库中拉取投票结果,我们假设这里的投票结果是:

image.png

  1. 在t2时刻,userA的请求开始进行数据层的处理。在一个事务内,先更新vote_option_stat表里的score字段,记录选项投票人总数,再将数据vote_Entity1_index_2=userA插入数据表vote_detail,此时数据表vote_detail中存储的结果为:

image.png

  1. 在t3时刻,userA的请求将vote_detail_cache缓存清掉,等待下次读时更新缓存。请求结束。

  2. 在t4时刻,UserB的请求继续处理,将刚才从数据库中读到的结果set到cache中去,请求结束。于是此时vote_detail_cache中的结果为:

image.png

我们可以看到,在t4时刻数据库中存储的结果为Result_t2,而缓存中存储的结果为Result_t3。此时即出现了数据不一致的情况。在下一次有人来投票前,如果有人去拉取投票结果,显示的结果里就会缺掉UserA的投票结果。

阶段一:redis锁->QPS只能支撑到比较低的水平

为了解决这个数据不一致的问题,我们想到的一个直观的做法是用一个redis锁来保证数据一致性。将数据库操作和对缓存的操作通过一个redis锁(vote_id+index)来绑定为一个事务,将对数据库的操作和对缓存的操作变成原子性操作。在一边在对投票人信息进行操作时,会对数据库和缓存加一把锁,另一边不可以在没有获得锁的情况下对其进行读取和写入。这样即可解决数据一致性的问题。

在此种策略下,我们进行了压力测试,从图2中可以看到投票的QPS只能支撑到比较低的水平,这显然无法满足业务需求。

image.png

图2

阶段二:去掉锁 ->QPS提升到4倍

在策略一无法满足业务需求的情况下,我们想到了另一个方法来保证一致性:

image.png

因为更新vote_option_stat中的score字段和将投票人信息插入vote_detail表是可以用Mysql本身的事务机制来保证数据一致性的,因此我们可以信任score字段。在此基础上,只需要验证缓存中的user数量是否与score字段相等,即可判断缓存是否是最新的,如果不是最新的,就去数据库再读一次最新的正确的结果。在这样的情况下,即使在图1中提到的t4阶段将缓存写错了,那么下次有读请求来的时候就会读到正确的结果,并且将其纠正。

在此种策略下,我们进行了第二次的压测,从图3中可以看到此次可以支持QPS提升到了之前的3~4倍而不出现失败请求。

image.png

图3

阶段三:使用redis存储score,QPS增长到几十倍

在第二种策略下,QPS虽然已经上涨了3~4倍,但是依旧无法支持我们的业务场景。在进行性能分析后,发现因为读取操作会大量地读取访问数据表vote_xxxx_stat,而且在投票时对此表进行update操作对数据库压力也较大。因此QPS在4倍时,数据库压力已经很高,开始出现失败。

所以显而易见地是,瓶颈变成了vote_xxxx_stat表。

因此我们做了第三个优化:

image.png

在这种方案下,score用redis来存储。redis过期时通过扫描vote_xxxx_detail表来获取兜底数据。在这种优化下,可以看图4,QPS可以最多支持到30倍而没有任何请求失败。已经达到了业务预期。(即使xxx人的群,以30倍的QPS进行投票,最多几分钟即可处理完毕,短时间内集中的投票可通过MQ进行削峰处理)。

image.png

图4

数据库小优化-组合索引

在查询时发现一个比较频繁的查询场景是:查询某个人针对某个投票是否进行过投票行为,而之前的索引只针对vote_id加了索引。这就会使得查询时速度较慢,因此考虑到这个高频场景,我们增加了一个组合索引,这使得查询速度上升100+倍。

  • 创建索引语句
create index idx_uid_xxxx_vote
    on vote_xxxx_detail (user_xxxx_id, vote_xxxx_id);

实验对比

  • 实验环境:

image.png

  • 执行语句
select * from vote_xxxx_detail WHERE vote_xxxx_id =xxxxxxxxxxx and user_xxxx_id= yyyyyyyyyyyyyyyyy
  • 实验结果
数据集大小(条)无索引查询时间(s)有索引查询时间(s)速度提升(倍)
10w0.240.0124
100w0.4100.0582
1000w5.2280.40130.7

注:

  1. 查询时间为进行了10次查询后取的平均时间。

  2. 实际线上表数据量大概是数百万~一千万条级别。

附:实验脚本

我在做实验时一开始采用gorm语句,一条一条插入的方式,后来发现插入速度实在过慢,平均15s只能插入100条数据,要达到实验所需的1kw条数据,需要跑400+个小时。因此研究了一下搞了一个简单的批量插入数据的小脚本,用于生成实验数据,也贴在后面供有兴趣自己做实验的同学们参考。

PS: gorm没有支持批量插入的命令,因此需要自己拼凑原生sql语句进行插入。

package main

import (
   "fmt"
   "math/rand"
   "reflect"
   "strings"
   "time"

   _ "github.com/go-sql-driver/mysql"

   "code.byted.org/gopkg/gorm"
)

type VoteXXXDetail struct {
    保密
}

func main() {
   DB, err := gorm.Open("mysql", "username:password@tcp(127.0.0.1:3306)/tablename")
   if err != nil {
      fmt.Println(err)
      return
   }
   //设置数据库最大连接数
   DB.DB().SetConnMaxLifetime(100)
   //设置上数据库最大闲置连接数
   DB.DB().SetMaxIdleConns(100)

   //验证连接
   if err := DB.DB().Ping(); err != nil {
      fmt.Println("open database fail")
      return
   }
   fmt.Println("connect success")
   y := 0
   i := 0
   timeStart := time.Now()
   voteXXXDetails := make([]interface{}, 0, 100)
   for ; i < 9000000; i++ {
      voteDetail := VoteDetail{
         ID:                 rand.Uint64(),
         Uid:                rand.Uint64(),
         VoteId:             rand.Uint64(),
         VoteOptionIndexStr: "1",
         Encrypted:          uint8(1),
         CreateTime:         timeStart,
         TenantID:           int64(6178367128361),
      }
      voteXXXDetails = append(voteXXXDetails, voteXXXDetail)
      if i%100 == 0 {
         trans := DB.Begin()

         err := BatchCreateModelsByPage(trans, voteXXXDetails, "vote_detail")
         if err != nil {
            fmt.Printf("insert voteXXXdetail failed:%+v, error: %v \n", voteXXXDetails, err)
            trans.Rollback()
         } else {
            trans.Commit()
            timeCost := time.Now().Sub(timeStart)
            fmt.Printf("insert %v lines completed. time cost: %v s \n", y*100, timeCost.Seconds())
            y += 1
            voteDetails = []interface{}{}
         }
      }
   }
   fmt.Printf("insert all data completed. sum %v lines", i)
}

// GetBranchInsertSql 获取批量添加数据sql语句
func GetBranchInsertSql(objs []interface{}, tableName string) string {
   if len(objs) == 0 {
      return ""
   }
   fieldName := ""
   var valueTypeList []string
   fieldNum := reflect.TypeOf(objs[0]).NumField()
   fieldT := reflect.TypeOf(objs[0])
   for a := 0; a < fieldNum; a++ {
      name := fieldT.Field(a).Tag.Get("json")
      // 添加字段名
      if a == fieldNum-1 {
         fieldName += fmt.Sprintf("`%s`", name)
      } else {
         fieldName += fmt.Sprintf("`%s`,", name)
      }
      // 获取字段类型
      if fieldT.Field(a).Type.Name() == "string" {
         valueTypeList = append(valueTypeList, "string")
      } else if strings.Index(fieldT.Field(a).Type.Name(), "uint") != -1 {
         valueTypeList = append(valueTypeList, "uint")
      } else if strings.Index(fieldT.Field(a).Type.Name(), "int") != -1 {
         valueTypeList = append(valueTypeList, "int")
      } else if strings.Index(fieldT.Field(a).Type.Name(), "Time") != -1 {
         valueTypeList = append(valueTypeList, "time")
      }
   }
   var valueList []string
   for _, obj := range objs {
      objV := reflect.ValueOf(obj)
      v := "("
      for index, i := range valueTypeList {
         if index == fieldNum-1 {
            v += GetFormatField(objV, index, i, "")
         } else {
            v += GetFormatField(objV, index, i, ",")
         }
      }
      v += ")"
      valueList = append(valueList, v)
   }
   insertSql := fmt.Sprintf("insert into `%s` (%s) values %s", tableName, fieldName, strings.Join(valueList, ",")+";")
   return insertSql
}

// GetFormatField 获取字段类型值转为字符串
func GetFormatField(objV reflect.Value, index int, t string, sep string) string {
   v := ""
   if t == "string" {
      v += fmt.Sprintf("'%s'%s", objV.Field(index).String(), sep)
   } else if t == "uint" {
      v += fmt.Sprintf("%d%s", objV.Field(index).Uint(), sep)
   } else if t == "int" {
      v += fmt.Sprintf("%d%s", objV.Field(index).Int(), sep)
   } else if t == "time" {
      time := objV.Field(index).Interface().(time.Time)
      v += fmt.Sprintf("'%s'%s", time.Format("2006-01-02 15:04:05"), sep)
   }
   return v

}

// GetColumnName 获取字段名
func GetColumnName(jsonName string) string {
   for _, name := range strings.Split(jsonName, ";") {
      if strings.Index(name, "column") == -1 {
         continue
      }
      return strings.Replace(name, "column:", "", 1)
   }
   return ""
}

// BatchCreateModelsByPage 分页批量插入
func BatchCreateModelsByPage(tx *gorm.DB, dataList []interface{}, tableName string) (err error) {
   if len(dataList) == 0 {
      return
   }
   // 如果超过一百条, 则分批插入
   size := 100
   page := len(dataList) / size
   if len(dataList)%size != 0 {
      page += 1
   }
   for a := 1; a <= page; a++ {
      var bills = make([]interface{}, 0)
      if a == page {
         bills = dataList[(a-1)*size:]
      } else {
         bills = dataList[(a-1)*size : a*size]
      }
      sql := GetBranchInsertSql(bills, tableName)
      err = tx.Exec(sql).Error // ignore_security_alert
      if err != nil {
         fmt.Println(fmt.Sprintf("batch create data error: %v, sql: %s, tableName: %s", err, sql, tableName))
         return
      }
   }
   return
}

限流器--根据流量模型设计

整个投票服务内除了存储侧,还有一个核心性能瓶颈就是推送。每次进行发布投票、投票、重发投票都会进行推送。将核心接口的流量模型总结如下。

投票流量模型

image.png 依据上方的流量模型,可以看到推送量的主要影响因素为x和n。

而对于数据库压力的主要因素为y,z和m,n,其中z也很大程度上取决于y。

因此合理的做法即是在每个接口根据上述流量模型,单独制作不同的限流策略。限流器本质是计数,即可根据不同接口的参数来进行累积计数,同时考虑推送压力和数据库压力,假设推送压力的因子为a,数据库压力的因子为b,一次发布投票请求来时,就应该计数:

Limit = a(x+1)+b(y+1) = ax+by+a+b, 忽略常数可以简化为limit = ax+by根据群人数和发布选项数来进行限流。

注意:由于一些数据不方便对外暴露,因此将具体数值用代号替代脱敏,如果有需要看真实数字的字节内部同学请移步tech.bytedance.net/articles/71…

参考文档

MySQL索引性能测试-阿里云开发者社区

MySQL必知必会11:索引-提高查询速度_yuping_zhu的博客-CSDN博客_mysql索引提高

本文正在参加「技术专题19期 漫谈数据库技术」活动