第五届字节跳动青训营Class17笔记 | 青训营笔记

119 阅读10分钟

这是我参与「第五届青训营 」笔记创作活动的第17天

1.Redis基本概念

产生背景

  • 数据量增长
  • 读写数据压力不断增加
  • 数据分成了冷热数据,热数据需要被存储到快速访问的地方

基本工作原理

image.png
  • 数据从内存中读写
  • 数据保存在硬盘上防止重启数据丢失
  • 增量数据保存在AOF文件(Append Only File)
  • 全量数据保存在RDB文件
  • 单线程处理所有操作命令

当Redis启动时,会检查AOF文件,检查是否有增量数据没有持久化,如果有,将其持久化到RDB文件上

image.png

2.Redis实际应用

场景1:掘金每日连续签到

用户每日有一次签到的机会,如果断签,连续签到计数将归零

  • Key:cc_uid_1165894833417101
  • value:252
  • expireAt:后天的0点
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"))
		}
	}
}

String数据结构

  • 可以存储字符串、数字、二进制数据
  • 通常和expire配合使用
  • 场景:存储计数、session

使用字符串数据结构,可以快速存储,同时也节省空间

image.png

len代表实际使用的空间,alloc代表预先分配的空间,flag代表数据类型,buf代表数据值

场景2:消息通知

用list作为消息队列

  • 消息通知时,例如当文章更新时,将更新后的文章推送到ES,用户就能搜索到最新的文章数据

image.png

生成端使用:lpush ex04_list_0 AA BB
生成端使用:rpush ex04_list_0 AA BB 
消费端:
方法1 lrange ex04_list_0 -3 -1 // 从FIFO队尾中一次消费3条信息
方法2 rpop ex04_list_0 3

List数据结构Quicklist

Quicklist由一个双向链表和listpack实现

image.png

image.png

场景3:计数

一个用户有多项计数需求,可通过hash结构存储

以拼接成的user信息作为表名,再存储对应的键值信息,达成一种结构化的存储方式。

image.png

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},
		{"user_id": "1111", "got_digg_count": 19, "got_view_count": 4},
		{"user_id": "2222", "got_digg_count": 1238, "follower_count": 379},
	}
	for _, counter := range userCounters {
		uid, err := strconv.ParseInt(counter["user_id"].(string), 10, 64)
		key := GetUserCounterKey(uid)
		rw, err := pipe.Del(ctx, key).Result()
		if err != nil {
			fmt.Printf("del %s, rw=%d\n", key, rw)
		}
		_, err = pipe.HMSet(ctx, key, counter).Result()
		if err != nil {
			panic(err)
		}

		fmt.Printf("设置 uid=%d, key=%s\n", uid, key)
	}
	// 批量执行上面for循环设置好的hmset命令
	_, err := pipe.Exec(ctx)
	if err != nil { // 报错后进行一次额外尝试
		_, err = pipe.Exec(ctx)
		if err != nil {
			panic(err)
		}
	}
}

pipeline:当遇到需要一次写入多个数据的场景,为了减少网络传输的损耗,pipeline可以通过类似golang HMSet的接口,来通过for循环先初始化管道内容,再通过Exec来一次性发往服务端

需要获取消息时使用HGetAll即可获取Hash表内的全部数据

Hash数据结构dict

rehash

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

渐进式rehash

每次用户访问时都会迁移少量数据。将整个迁移过程,平摊到所有访问用户的请求过程中。

image.png

当两个K-V hash到同一个槽位时,该槽位会形成一个链表,查找时遍历链表,当数据量过大、桶装满时就会触发扩容机制。

rehash过程(平摊机制):dict同时维护ht[0]和ht[1],用户访问到的是ht[0],当用户执行查询操作时,会同时进行拷贝操作,这样当用户进行足够多的查询之后,dict完成了扩容。

场景4:排行榜

积分变化时,排名要实时变更

  • 结合dict后,可实现通过key操作跳表的功能

使用RedisZreverserange倒排序命令,实现排序

// GetUserRankByName 获取用户排名
func GetUserRankByName(ctx context.Context, name string) {
	rank, err := RedisClient.ZRevRank(ctx, Ex06RankKey, name).Result()
	if err != nil {
		fmt.Errorf("error getting name=%s, err=%v", name, err)
		return
	}
	fmt.Printf("name=%s, 排名=%d\n", name, rank+1)
}

// AddUserScore 改变用户得分
func AddUserScore(ctx context.Context, name string, score float64) {
	num, err := RedisClient.ZIncrBy(ctx, Ex06RankKey, score, name).Result()
	if err != nil {
		panic(err)
	}
	fmt.Printf("name=%s, add_score=%f, score=%f\n", name, score, num)
}

zset数据结构 zskiplist

image.png

跳跃表随着层数的增加,节点也更加细化,最底层即为完整的链表

  • 查找数字7的路径:head-->3-->3-->7
  • 结合dict后,可实现通过key操作跳表的功能

image.png

场景5:限流

  • 要求1秒内放行的请求为N,超过N则禁止访问

实现:设置一个key作为用户短时间内连续访问数据的记录,设置一个限流时间范围的时间戳,当其value超过QPS时进行拦截

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)
	}
}

场景6:分布式锁

并发场景中,要求一次只能有一个协程执行,执行完成后,其他等待中的协程才能够执行

使用redis来实现的原理:Redis是单线程执行命令,setnx只有未设置过才能执行成功 image.png

func ex02Work(ctx context.Context, cInstParam common.CInstParams) {
	routine := cInstParam.Routine
	eventLogger := cInstParam.ConcurrentEventLogger
	defer ex02ReleaseLock(ctx, routine, eventLogger)
	for {
		// 1. 尝试获取锁
		// exp - 锁过期设置,避免异常死锁
		acquired, err := RedisClient.SetNX(ctx, resourceKey, routine, exp).Result() // 尝试获取锁
		if err != nil {
			eventLogger.Append(common.EventLog{
				EventTime: time.Now(), Log: fmt.Sprintf("[%s] error routine[%d], %v", time.Now().Format(time.RFC3339Nano), routine, err),
			})
			panic(err)
		}
		if acquired {
			// 2. 成功获取锁
			eventLogger.Append(common.EventLog{
				EventTime: time.Now(), Log: fmt.Sprintf("[%s] routine[%d] 获取锁", time.Now().Format(time.RFC3339Nano), routine),
			})
			// 3. sleep 模拟业务逻辑耗时
			time.Sleep(10 * time.Millisecond)
			eventLogger.Append(common.EventLog{
				EventTime: time.Now(), Log: fmt.Sprintf("[%s] routine[%d] 完成业务逻辑", time.Now().Format(time.RFC3339Nano), routine),
			})
			return
		} else {
			// 没有获得锁,等待后重试
			time.Sleep(100 * time.Millisecond)
		}
	}
}

func ex02ReleaseLock(ctx context.Context, routine int, eventLogger *common.ConcurrentEventLogger) {
	routineMark, _ := RedisClient.Get(ctx, resourceKey).Result()
	if strconv.FormatInt(int64(routine), 10) != routineMark {
		// 其它协程误删lock
		panic(fmt.Sprintf("del err lock[%s] can not del by [%d]", routineMark, routine))
	}
	set, err := RedisClient.Del(ctx, resourceKey).Result()
	if set == 1 {
		eventLogger.Append(common.EventLog{
			EventTime: time.Now(), Log: fmt.Sprintf("[%s] routine[%d] 释放锁", time.Now().Format(time.RFC3339Nano), routine),
		})
	} else {
		eventLogger.Append(common.EventLog{
			EventTime: time.Now(), Log: fmt.Sprintf("[%s] routine[%d] no lock to del", time.Now().Format(time.RFC3339Nano), routine),
		})
	}
	if err != nil {
		fmt.Errorf("[%s] error routine=%d, %v", time.Now().Format(time.RFC3339Nano), routine, err)
		panic(err)
	}
}

其中一个细节是redis在此处SetNx存放的是自己的协程编号,目的是使得这个锁只能自己释放

高可用上可能存在的问题

  • 业务超时解锁,导致并发问题。业务执行时间超过锁超时时间
  • redis主备切换临界点问题。主备切换后,A持有的锁还未同步到新的主节点时,B可在新主节点获取锁,导致并发问题。
  • redis集群脑裂,导致出现多个主节点

3.Redis使用注意事项

大Key

定义

  • String类型:value的字节数大于10KB即为大key
  • Hash/Set/Zset/list等复杂数据结构类型:元素个数大于5000个或总value字节数大于10MB即为大key

危害

  • 读取成本高
  • 容易导致慢查询(过期、删除)
  • 主从复制异常、服务堵塞,无法响应正常请求

业务侧使用大Key的表现

请求Redis超时报错

消除大Key的方法

  • 拆分:将大Key拆分为小key。例如一个String拆分成多个String

image.png

  • 压缩:将value压缩后写入redis,读取时解压后再使用。压缩算法可以是gzip、snappy、lz4等。通常情况下,一个压缩算法压缩率高,则解压耗时就长。需要对实际数据测试后,选择一个合适的算法,如果存储的是JSON字符串,可以考虑使用MessagePack进行序列化(MessagePack的核心逻辑为用表示该数据的最少字节数来压缩数据)

  • 集合类结构hash、list、set、zset:拆分,用hash取余、位掩码的方式决定放在哪个key中

  • 区分冷热:如榜单列表场景使用zset,只存储前10页的数据,后续数据走db

热Key

定义

用户访问一个Key的QPS特别高,导致Server实例出现CPU负载突增或者不均的情况

假设场景:把全局配置存到了Redis中,导致每一次访问都会访问某一台redis主机,造成木桶效应

解决热Key的方法

  • 设置Localcache:在访问Redis前,在业务服务侧设置Localcache,降低访问Redis的QPS。LocalCache中缓存过期或者未命中,则从Redis中将数据更新到LocalCache。Java的Guava、Golang的Bigcache就是这类LocalCache

  • 拆分:将key-value这一个热key复制写入多份,例如key1:value,key2:value,访问的时候访问多个key,但value是同一个,以此将qps分散到不同实例上,降低负载。代价是更新时需要更新多个key,存在数据短暂不一致的风险。

  • 字节跳动将"Localcache"和"热key发现"两个方式结合的解决办法 image.png

容易导致redis慢查询的操作

  • 批量操作一次传入过多的key/value,如meset/hmset/sadd/zadd等O(n)操作,建议单批操作不要超过100,超过100之后性能下降明显
  • zset大部分命令都是O(log(n)),当大小超过5k以上时,简单的zadd/zrem也可能导致慢查询
  • 操作的单个value过大,超过10KB
  • 对大key的delete、expire操作也可能导致慢查询。Redis 4.0之前不支持异步删除unlink,大key删除会阻塞Redis。

缓存穿透、缓存雪崩

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

缓存穿透的危害

  • 查询一个不存在的数据,会直接打到db,使得程序宕机
  • 缓存过期时,一个热key的过期会导致大量请求同时击穿至db

减少缓存穿透

  • 缓存空值:如一个不存在的userID。这个id在缓存和数据库中都不存在,则可以缓存一个空值,下次再查直接返回空值
  • 布隆过滤器:通过bloom filter算法来存储合法key

避免缓存雪崩

  • 将缓存失效时间分散开,在原有失效时间的基础上增加一个随机值,热点数据过期时间尽量设置长一些,冷门数据可以相对设置过期时间短一些
  • 使用缓存集群,避免单机宕机造成雪崩。

总结

本节课程讲解了关于Redis的几种使用场景及其数据结构的底层原理,并且结合大厂实践进行了进一步的性能调优。

引用

gitee.com/wedone/redi…