1.Redis是什么
随着数据量增大,mysql由单机单表逐步分机分表,MySQL从单机演进出了集群。但是就算是mysql集群,随着读写压力的不断增长也不能满足业务的相关需求。
并且数据分成冷数据和热数据存储,冷数据访问可能无人问津,但是热点数据可能这段时间很多人都在搜索访问,给予mysql的压力非常大。
为了应对上面需求,研发出来了Redis,Redis 是基于内存的数据库,mysql需要将数据从磁盘读取到内存,而Redis本身就在内存当中,因此省去了这部分开销。
此外Redis使用 I/O 多路复用模型同时监听客户端连接,并且单线程队列方式运行,省去线程切换消耗的种种开销。
2.Redis的应用案例
下面我们会介绍一些常见的Redis的应用案例介绍:
2.1连续签到
连续签到,每天进行签到就会计数+1,如果有一天没有签到,那么则数据清0。 这里面使用了Redis的String数据结构,其结构如下图所示:
其具体实现结果如下图所示 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来进行构建,其结构如下所示:
关于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数据结构的实现的,如下图所示
这里包含两种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,示意图如下:
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,如下图所示。
- 压缩
将value压缩后写入redis,读取时解压后再使用。如果是数据JSOn的话可以对其数据进行序列化的操作。
解决热key的方法:
1.设置Localcache
在访问Redis前,在业务服务侧设置局部缓存,降低访问Redis的QPS,局部缓存中缓存过。
期或未命中则从Redis中将数据更新到LocalCache.常见的局部缓存如Golang的Bigcache
其结构如图所示
2. 拆分
将key:value这一个热Key复制写入多份,例如keyI:value,key2 value,访问的时候访问多个key,但value是同一个。通过拆分将qps分散到不同实例上,降低负载。代价是,更新时需要更新多个ky,存在数据短暂不一致的风险。
- 使用Redis代理的热Key承载能力。 字节跳动的Redis访问代理就具备热Key承载能力。本质上是结合了“热Key发现"、“局部缓存"两个功能。
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)使用缓存集群,避免单机宕机造成的缓存雪崩。