这是我参与「第五届青训营 」笔记创作活动的第17天
1.Redis基本概念
产生背景
- 数据量增长
- 读写数据压力不断增加
- 数据分成了冷热数据,热数据需要被存储到快速访问的地方
基本工作原理
- 数据从内存中读写
- 数据保存在硬盘上防止重启数据丢失
- 增量数据保存在AOF文件(Append Only File)
- 全量数据保存在RDB文件
- 单线程处理所有操作命令
当Redis启动时,会检查AOF文件,检查是否有增量数据没有持久化,如果有,将其持久化到RDB文件上
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
使用字符串数据结构,可以快速存储,同时也节省空间
len代表实际使用的空间,alloc代表预先分配的空间,flag代表数据类型,buf代表数据值
场景2:消息通知
用list作为消息队列
- 消息通知时,例如当文章更新时,将更新后的文章推送到ES,用户就能搜索到最新的文章数据
生成端使用: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实现
场景3:计数
一个用户有多项计数需求,可通过hash结构存储
以拼接成的user信息作为表名,再存储对应的键值信息,达成一种结构化的存储方式。
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
每次用户访问时都会迁移少量数据。将整个迁移过程,平摊到所有访问用户的请求过程中。
当两个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
跳跃表随着层数的增加,节点也更加细化,最底层即为完整的链表
- 查找数字7的路径:head-->3-->3-->7
- 结合dict后,可实现通过key操作跳表的功能
场景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只有未设置过才能执行成功
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
-
压缩:将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发现"两个方式结合的解决办法
容易导致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的几种使用场景及其数据结构的底层原理,并且结合大厂实践进行了进一步的性能调优。