高可用高并发场景实践 (基于点赞业务)笔记 | 青训营
在项目的搭建和架构设计中,部分业务的高可用性和高并发性是一个重要的考虑方面。高可用意味着在大流量,极端场景下系统仍然能稳定地运行,甚至在部分服务器功能宕机的情况下仍然能保证功能的正常。而高并发是用户量庞大,操作频繁的系统中必须考虑的场景,稍有不慎便会导致并发问题。
下面,我将以青训营结营项目 简单抖音+后端的点赞功能为例子介绍一下高可用高并发场景的功能设计实践
基本介绍
在点赞功能中,用户对一个视频点赞后,要将点赞信息(点赞者ID,对应的视频ID 等)存储在 mysql 数据库的点赞 like 表中,同时要向 user 表中更新用户的点赞数,喜欢数,video 表中更新视频被点赞数等信息。便于用户查看相关数据时能减少查询时间,能直接返回。
存在的问题
- 大量用户同时点赞的时候,导致短时间内并发量过大,会出现 mysql 写入压力过大导致崩溃,而且从点赞到用户得知点赞成功是一系列同步操作,用户等待时间长,导致用户使用医院下降
- 用户如果多次点赞/取消点赞请求同时进入,可能会导致数据库多次执行重复操作,导致点赞数量不实甚至为负数
解决办法
-
针对每个用户使用两个分布式锁(LikeLock 和 UnLikeLock) , 使用 redis 的 setnx 实现。当收到点赞请求时,用户尝试获取 LikeLock 并释放 UnLikeLock ,若是取消点赞则相反。
//分布式锁 不能让用户连续两次点赞或者取消同一个视频的请求进入 userIdStr := strconv.FormatInt(userId, 10) videoIdStr := strconv.FormatInt(videoId, 10) lockKey := config.LikeLock + userIdStr + videoIdStr unLockKey := config.UnLikeLock + userIdStr + videoIdStr if actionType == 1 { isSuccess, _ := utils.GetRedisDB().SetNX(context.Background(), lockKey, "0", time.Duration(config.LikeLockTTL)*time.Second).Result() if isSuccess == false { log.Println("已点赞") return errors.New("-1") } else { utils.GetRedisDB().Del(context.Background(), unLockKey) } } else { isSuccess, _ := utils.GetRedisDB().SetNX(context.Background(), unLockKey, "0", time.Duration(config.LikeLockTTL)*time.Second).Result() if isSuccess == false { log.Println("已取消") return errors.New("-2") } else { utils.GetRedisDB().Del(context.Background(), lockKey) } } -
redis 中使用两个数据结构, likeset (Set key 为 用户ID ,value 为 被点赞视频的ID集合) 和 user (String key 为 用户ID, value 为序列化后的用户信息)
-
数据库检查完允许点赞的,取消点赞时,将点赞信息写入 channel 和 RabbitMQ 消息队列。然后返回给用户点赞成功。本模块的多个协程并发消费 channel 内的数据往 like video 表中写入。同时更新 redis 中 的数据(若是取消点赞操作要再执行一次集合中删除ID 避免脏数据产生:若在数据库更新之前有线程读取了数据库数据写入redis 便会使得 redis 中记录了这个视频点赞的ID这个脏数据,即 redis 中又出现了). RabbitMQ 的消费者,user 服务多个协程消费RabbitMQ 中的数据,执行更新数据库和redis 的操作
if actionType == 1 {
if isExists {
log.Printf("该视频已点赞")
//tx.Rollback()
err = errors.New("-1")
return err
}
// 查视频的作者
author, errAuthorId := models.GetVideoById(videoId)
if errAuthorId != nil {
log.Println("不能找到这个作者")
return errAuthorId
}
authorId := author.AuthorId
//mqData := models.LikeMQToVideo{UserId: userId, VideoId: videoId, ActionType: actionType}
mqData := models.LikeMQToUser{UserId: userId, VideoId: videoId, ActionType: actionType, AuthorId: authorId}
// 加入 channel
mq.LikeChannel <- mqData
jsonData, err := json.Marshal(mqData)
if err != nil {
log.Println("json序列化失败 = #{err}")
//TODO 处理失败导致的数据不一致
}
//加入消息队列
mq.LikeRMQ.Publish(string(jsonData))
return nil
} else if actionType == 2 {
if !isExists && (faInDB == nil || faInDB.Id == 0) {
log.Printf("未找到要取消的点赞记录")
err = errors.New("-2")
//tx.Rollback()
return err
}
// 查视频的作者
author, errAuthorId := models.GetVideoById(videoId)
if errAuthorId != nil {
log.Println("不能找到这个作者")
return errAuthorId
}
authorId := author.AuthorId
//mqData := models.LikeMQToVideo{UserId: userId, VideoId: videoId, ActionType: actionType}
mqData := models.LikeMQToUser{UserId: userId, VideoId: videoId, ActionType: actionType, AuthorId: authorId}
// 加入 channel
mq.LikeChannel <- mqData
jsonData, err := json.Marshal(mqData)
if err != nil {
log.Printf("json序列化失败 = #{err}")
}
//加入消息队列
mq.LikeRMQ.Publish(string(jsonData))
return nil
}
流程图如下
存在的风险
- 异步操作失败后导致的数据不一致,若 user 服务从消息队列中消费了数据库时多次写入数据库失败了,那么会导致数据不一致。当然,对于大量用户的高效体验感来说,可以牺牲极端情况下部分的数据一致性作为代价。也可以考虑使用以下的方法解决:若检测到消费数据失败即通知 视频模块回滚先前操作,或者多次重试仍然失败后存入特别日志,交给其它操作介入处理
- 大 key 问题。当一个redis 的集合中元素大于 5000 可被视为大 key . 大 key 会严重影响 redis 集群的性能。若一个用户点赞了非常多的视频,那么这个用户的 likeset 会有很多元素。这样要考虑使用 大 key 的拆分或者冷热数据区分处理。
- redis 集群中 setnx 分布式锁的失效问题。当一次获取了锁后,可能某个从节点没有及时从主节点同步数据,导致另一个请求也成功获取了锁,出现了并发问题。