高可用高并发场景实践 (基于视频点赞业务)笔记 | 青训营

644 阅读4分钟

高可用高并发场景实践 (基于点赞业务)笔记 | 青训营

在项目的搭建和架构设计中,部分业务的高可用性和高并发性是一个重要的考虑方面。高可用意味着在大流量,极端场景下系统仍然能稳定地运行,甚至在部分服务器功能宕机的情况下仍然能保证功能的正常。而高并发是用户量庞大,操作频繁的系统中必须考虑的场景,稍有不慎便会导致并发问题。

下面,我将以青训营结营项目 简单抖音+后端的点赞功能为例子介绍一下高可用高并发场景的功能设计实践

基本介绍

在点赞功能中,用户对一个视频点赞后,要将点赞信息(点赞者ID,对应的视频ID 等)存储在 mysql 数据库的点赞 like 表中,同时要向 user 表中更新用户的点赞数,喜欢数,video 表中更新视频被点赞数等信息。便于用户查看相关数据时能减少查询时间,能直接返回。

存在的问题

  1. 大量用户同时点赞的时候,导致短时间内并发量过大,会出现 mysql 写入压力过大导致崩溃,而且从点赞到用户得知点赞成功是一系列同步操作,用户等待时间长,导致用户使用医院下降
  2. 用户如果多次点赞/取消点赞请求同时进入,可能会导致数据库多次执行重复操作,导致点赞数量不实甚至为负数

解决办法

  1. 针对每个用户使用两个分布式锁(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)
    		}
    	}
    
  2. redis 中使用两个数据结构, likeset (Set key 为 用户ID ,value 为 被点赞视频的ID集合) 和 user (String key 为 用户ID, value 为序列化后的用户信息)

  3. 数据库检查完允许点赞的,取消点赞时,将点赞信息写入 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

	}

流程图如下

流程图.jpg

存在的风险

  1. 异步操作失败后导致的数据不一致,若 user 服务从消息队列中消费了数据库时多次写入数据库失败了,那么会导致数据不一致。当然,对于大量用户的高效体验感来说,可以牺牲极端情况下部分的数据一致性作为代价。也可以考虑使用以下的方法解决:若检测到消费数据失败即通知 视频模块回滚先前操作,或者多次重试仍然失败后存入特别日志,交给其它操作介入处理
  2. 大 key 问题。当一个redis 的集合中元素大于 5000 可被视为大 key . 大 key 会严重影响 redis 集群的性能。若一个用户点赞了非常多的视频,那么这个用户的 likeset 会有很多元素。这样要考虑使用 大 key 的拆分或者冷热数据区分处理。
  3. redis 集群中 setnx 分布式锁的失效问题。当一次获取了锁后,可能某个从节点没有及时从主节点同步数据,导致另一个请求也成功获取了锁,出现了并发问题。