Redis 大厂程序员怎么用 学习笔记 | 青训营

54 阅读9分钟

1.Redis是什么

随着数据量增大,mysql由单机单表逐步分机分表,MySQL从单机演进出了集群。但是就算是mysql集群,随着读写压力的不断增长也不能满足业务的相关需求。

并且数据分成冷数据和热数据存储,冷数据访问可能无人问津,但是热点数据可能这段时间很多人都在搜索访问,给予mysql的压力非常大。

为了应对上面需求,研发出来了Redis,Redis 是基于内存的数据库,mysql需要将数据从磁盘读取到内存,而Redis本身就在内存当中,因此省去了这部分开销。

此外Redis使用 I/O 多路复用模型同时监听客户端连接,并且单线程队列方式运行,省去线程切换消耗的种种开销。

2.Redis的应用案例

下面我们会介绍一些常见的Redis的应用案例介绍:

2.1连续签到

连续签到,每天进行签到就会计数+1,如果有一天没有签到,那么则数据清0。 这里面使用了Redis的String数据结构,其结构如下图所示:

image.png 其具体实现结果如下图所示 Redis的 string本质是由链表来实现的,一般用在存储计数或者Session等场景上面。

func addContinuesDays(ctx context.Context, userID int64) {
	key := fmt.Sprintf(continuesCheckKey, userID)
	// 1. 连续签到数+1
	err := RedisClient.Incr(ctx, key).Err()
	if err != nil {
		fmt.Errorf("用户[%d]连续签到失败", userID)
	} else {
		expAt := beginningOfDay().Add(48 * time.Hour)
		// 2. 设置签到记录在后天的0点到期
		if err := RedisClient.ExpireAt(ctx, key, expAt).Err(); err != nil {
			panic(err)
		} else {
			// 3. 打印用户续签后的连续签到天数
			day, err := getUserCheckInDays(ctx, userID)
			if err != nil {
				panic(err)
			}
			fmt.Printf("用户[%d]连续签到:%d(天), 过期时间:%s", userID, day, expAt.Format("2006-01-02 15:04:05"))
		}
	}
}

这段代码使用Incr来对签到天数进行了加一的操作,然后获得了后天的0点时间,设置如果后天0点没有签到的话,这个数据过期。最后我们将今天的连续签到的天数进行打印的操作。

2.2消息通知

下面一个使用例子是用Iist作为消息队列。 比如当文章更新时,将更新后的文章推送到ES,用户就能搜索到最新的文章数据。 其中ES是了Elastic Search,他使用倒排索引的方式进行快速搜索,倒排索引是实现“单词-文档矩阵”的一种具体存储形式,通过倒排索引,可以根据单词快速获取包含这个单词的文档列表。

下面我们简单介绍一下如何使用Redis进行消息通知的过程吧。这是一个简单的生产者消费者的一个流程。

func Ex04(ctx context.Context) {
	eventLogger := &common.ConcurrentEventLogger{}
	cInst := common.NewConcurrentRoutine(1, eventLogger)
	cInst.Run(ctx, Ex04Params{}, ex04ConsumerPop)
	eventLogger.PrintLogs()
}

其中 common.NewConcurrentRoutine new了一个并发执行器,routineNums是消费端的数量,接下来并发执行用户自定义函数,最后可以按照日志时间打印日志

而与此对应的消费端则使用 RedisClient.BRPop 或者 RedisClient.LRange 来取得相关数据,可以FIFO或者一次取得n个相关的数据。

这里面使用一个数据结构QuickList来进行构建,其结构如下所示:

image.png 关于list数据结构而言:

  • list 实际上是一个前后都可以插入的链表,
  • 如果key不存在,创建新的链表且如果移除了所有的值,空链表,也代表不存在
  • 在两边插入或者改动值,效率最高。

2.3计数

下面是一个经常需要使用的需求,计数需求。我们日常业务中很多需要计算业务相关技术需求,比如抖音点赞,我们不能每点一次赞,都去数据库查询一下count数据库相关数目吧,这对数据库的相关的压力太大了,redis可以很好的解决这个问题。

func Ex06InitUserCounter(ctx context.Context) {
	pipe := RedisClient.Pipeline()
	userCounters := []map[string]interface{}{
		{"user_id": "1556564194374926", "got_digg_count": 10693, "got_view_count": 2238438, "followee_count": 176, "follower_count": 9895, "follow_collect_set_count": 0, "subscribe_tag_count": 95},
                
	for _, counter := range userCounters {
		uid, err := strconv.ParseInt(counter["user_id"].(string), 10, 64)
		key := GetUserCounterKey(uid)
		

		fmt.Printf("设置 uid=%d, key=%s\n", uid, key)
	}
	
}

对于这种情况,我们往往的设置了几种情况,上面是一个简单的例子初始化相关用户,当我们触发了关注操作,系统会自动调用相应的方法,用户的follower_count的value进行加一操作。

这是使用Redis的hash数据结构的实现的,如下图所示

image.png 这里包含两种hash方法,rehash和渐进式rehash。

rehash操作是将ht[O]中的数据,全部迁移到ht[1]中。数据量小的场景下直接将数据从ht[O]拷贝到ht[1]速度是较快的。数据量大的场景,例如存有上百万的KV时,迁移过程将会明显阻塞用户请求。

为了解决上面的问题,就提出了渐进式rehash:其基本原理就是,每次用户访问时都会迁移少量数据。将整个迁移过程,平摊到所有的访问请求过程中。将瞬间的上百万数据迁移转换成为了每小时10万的迁徙,数据访问压力大大减少。

2.4排行榜

下面简单介绍一下排行榜功能的实现:

func Ex06_2(ctx context.Context, args []string) {
	arg1 := args[0]
	switch arg1 {
	case "init":
		Ex062InitUserScore(ctx)
	case "rev_order":
		GetRevOrderAllList062(ctx, 0, -1)
	case "order_page":
		pageSize := int64(2)
		offset := int64(0)
		var err error
		if len(args[1]) > 0 {
			offset, err = strconv.ParseInt(args[1], 10, 64)
			if err != nil {
				panic(err)
			}
		}
		GetOrderListByPage062(ctx, offset, pageSize)
	case "get_rank":
		GetUserRankByName062(ctx, args[1])
	case "get_score":
		GetUserScoreByName062(ctx, args[1])
	case "add_user_score":
		score, err := strconv.ParseInt(args[2], 10, 64)
		if err != nil {
			panic(err)
		}
		AddUserScore062(ctx, args[1], score)
	}
	return
}

其数据结构是Zset来进行实现的ZskipList,示意图如下:

image.png

2.5 限流

限流是指要求1秒内放行的请求为N,超过N则禁止访问。比如我们秒杀的时候需要10个物品,只能放行10个请求,剩下的只能禁止访问。 其实现结构具体如下图所示:

func ex03Work(ctx context.Context, cInstParam common.CInstParams) {
	routine := cInstParam.Routine
	eventLogger := cInstParam.ConcurrentEventLogger
	key := ex03LimitKey(time.Now())
	currentQPS, err := RedisClient.Incr(ctx, key).Result()
	if err != nil || err == redis.Nil {
		err = RedisClient.Incr(ctx, ex03LimitKey(time.Now())).Err()
		if err != nil {
			panic(err)
		}
	}
	if currentQPS > ex03MaxQPS {
		// 超过流量限制,请求被限制
		eventLogger.Append(common.EventLog{
			EventTime: time.Now(),
			Log:       common.LogFormat(routine, "被限流[%d]", currentQPS),
		})
		// sleep 模拟业务逻辑耗时
		time.Sleep(50 * time.Millisecond)
		err = RedisClient.Decr(ctx, key).Err()
		if err != nil {
			panic(err)
		}
	} else {
		// 流量放行
		eventLogger.Append(common.EventLog{
			EventTime: time.Now(),
			Log:       common.LogFormat(routine, "流量放行[%d]", currentQPS),
		})
		atomic.AddInt32(&accessQueryNum, 1)
		time.Sleep(20 * time.Millisecond)
	}
}

可以看出我们当未达到限制的时候,我们进行流量放行,如果达到了限制我们直接请求限制并且将其报告给相关的日志系统。

3.Redis的注意事项

3.1 大key和热key

大Key标准:

  • String类型value的字节数大于10KB即为大key
  • ash/Set/Zset/Iist等复杂数据结构类型元素个数大于5000个或总value字节数大于10MB即为大key 热key标准
  • 用户访问一个Key的QPS特别高, 热key没有明确的标准,QPS超过500就有可能被识别为热Key

大key和热key的危害如下:

大key读取成本高,并且容易导致慢查询,主从复制异常,服务阻塞无法正常响应请求。并且服务器容易请求Redis超时报错。热key容易导致实例出现CPU负载突增或者不均的情况。

针对上面的问题,分别进行应对。 对于大key

  • 拆分

将一个大key拆分成为多个小key,如下图所示。

image.png

  • 压缩

将value压缩后写入redis,读取时解压后再使用。如果是数据JSOn的话可以对其数据进行序列化的操作。

解决热key的方法:

1.设置Localcache 在访问Redis前,在业务服务侧设置局部缓存,降低访问Redis的QPS,局部缓存中缓存过。 期或未命中则从Redis中将数据更新到LocalCache.常见的局部缓存如Golang的Bigcache 其结构如图所示 image.png 2. 拆分 将key:value这一个热Key复制写入多份,例如keyI:value,key2 value,访问的时候访问多个key,但value是同一个。通过拆分将qps分散到不同实例上,降低负载。代价是,更新时需要更新多个ky,存在数据短暂不一致的风险。

image.png

  1. 使用Redis代理的热Key承载能力。 字节跳动的Redis访问代理就具备热Key承载能力。本质上是结合了“热Key发现"、“局部缓存"两个功能。

image.png

3.2慢查询

下面是一些容易导致Redis慢查询的操作: (1)批量操作一次性传入过多的key/value,如mset/hmset/sadd/zadd等On)操作 建议单批次不要超过100,超过100之后性能下降明显。 (2)Zset大部分命令都是Olog(n),当大小超过5k以上时,简单的zadd/zrem也可能导致慢查询 (3)操作的单个value:过大,超过10KB。也即,避免使用大Key (4)对大key的delete/expire操作也可能导致慢查询,Redis4.O之前不支持异步删除unlink,大key删除会阻塞Redis

3.3 缓存穿透、缓存雪崩

缓存穿透:热点数据查询绕过缓存,直接查询数据库 缓存雪崩:大量缓存同时过期

如何减少缓存穿透 (1)缓存空值 如一个不存在的userlD。这个id在缓存和数据库中都不存在。则可以缓存一个空值,下次再查缓存直接反空值。 (2)布隆过滤器 通过bloom filter:算法来存储合法Key,得益于该算法超高的压缩率,只需占用极小的空间就能存储大量key值 如何避免缓存雪崩 (1)缓存空值 将缓存失效时间分散开,比如在原有的失效时间基础上增加一个随机值,例如不同Ky过期时间 可以设置为10分1秒过期,10分23秒过期,10分8秒过期。单位秒部分就是随机时间,这样过期时间就分散了。 对于热点数据,过期时间尽量设置得长一些,冷门的数据可以相对设置过期时间短一些。 (2)使用缓存集群,避免单机宕机造成的缓存雪崩。