Redis - 大厂程序员是怎么用的

190 阅读10分钟

Redis - 大厂程序员是怎么用的

虽然青训营伴学笔记创作活动已经结束了,但是我还是想以这种写文章的方式来记录一下自己的学习过程,一方面可以起到敦促的作用,另一方面可以对学习内容进行简单的记录,供之后复盘学习使用。

一、本堂课重点内容

本堂课对 Redis 进行了初步的介绍,从是什么、为什么、怎么做三个方面展开对 Redis 的原理及应用详细讲解,最后结合 Redis 的实际应用场景,讲述了在字节跳动使用 Redis 需要注意的事项。

二、详细知识点介绍

1. Redis 概述

Redis是一种高性能的开源内存数据库,用于存储和检索数据。它支持多种数据结构,如字符串、哈希表、列表、集合和有序集合等,可以通过网络进行访问。Redis还提供了一些高级功能,如事务、持久性、复制和Lua脚本等,可以满足各种不同的应用程序需求。

为什么需要 Redis?

随着数据量的不断增长,数据读写压力的不断增加,数据表从单表演进到了分库分表,MySQL也从单机演进到了集群,但仍不能够满足日益发展的数据需求。

所以在实际应用中,Redis和MySQL一般结合使用,充分发挥各自的优势。例如,可以将MySQL用于数据的持久化存储和复杂查询,而将Redis用于缓存和高速读写。这样可以提高系统的性能和可扩展性,并减少MySQL的负担。同时,Redis和MySQL之间也可以进行数据同步和备份,保证数据的可靠性和一致性。

Redis 的基本工作原理

  1. 内存存储:Redis将所有数据存储在内存中,因此读写速度非常快。同时,Redis还支持数据持久化,可以将数据保存到磁盘上,以便在重启时恢复数据。
  2. 基于键值的存储:Redis是一种键值存储系统,每个数据都是通过唯一的键来访问。这种存储方式非常简单,可以方便地存储各种类型的数据。
  3. 多种数据结构支持:Redis支持多种数据结构,如字符串、哈希表、列表、集合和有序集合等,这些数据结构可以方便地满足不同应用场景的需求。
  4. 基于网络的访问:Redis是一种网络服务,客户端可以通过TCP/IP协议与Redis进行通信。Redis使用一种简单的协议来处理客户端请求,协议非常简单,易于实现客户端程序。
  5. 原子性操作:Redis支持原子性操作,这意味着在同一时间只有一个客户端能够对同一键执行操作。这可以避免数据竞争问题,并保证系统的数据一致性。
  6. 发布订阅模式:Redis支持发布订阅模式,可以方便地实现消息传递和事件驱动等功能。

2. Redis 应用案例

连续签到

// addContinuesDays 为用户签到续期
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"))
		}
	}
}
  1. 根据用户ID生成签到记录的Redis Key。
  2. 使用Redis的INCR命令,将该Key对应的值自增1,表示用户连续签到天数增加了1。
  3. 使用Redis的EXPIREAT命令,设置该Key的过期时间为后天的0点,即48小时后。
  4. 使用getUserCheckInDays函数获取用户的当前连续签到天数,并打印用户续签后的连续签到天数和过期时间。

消息通知

用 list 作为消息队列

image.png

当文章更新时,将更新后的文章推送到ES,用户就能够搜索到最新的文章数据。

计数

对于一个用户有多项计数需求的情况,可以使用Redis的哈希表(hash)数据结构来实现。具体来说,可以将用户ID作为哈希表的键(key),不同的计数器类型作为哈希表的字段(field),对应的计数值作为哈希表的值(value)。

  1. 定义哈希表的键(key),通常可以使用用户ID来作为键。
  2. 对于每个计数器类型,使用Redis的HSET命令将计数值存储到哈希表中。例如,可以使用HSET命令将用户的文章计数值存储到哈希表的"article"字段中,将用户的评论计数值存储到哈希表的"comment"字段中等等。
  3. 可以使用Redis的HGET命令获取指定字段的计数值,或者使用HGETALL命令获取整个哈希表的计数信息。

排行榜

// 添加用户积分
func addUserScore(ctx context.Context, userID int64, score float64) error {
    key := "user_scores"
    return RedisClient.ZAdd(ctx, key, &redis.Z{
        Score:  score,
        Member: userID,
    }).Err()
}

// 获取用户排名
func getUserRank(ctx context.Context, userID int64) (int64, error) {
    key := "user_scores"
    rank, err := RedisClient.ZRevRank(ctx, key, userID).Result()
    if err == redis.Nil {
        // 用户不存在排行榜中
        return -1, nil
    }
    return rank, err
}

// 获取前 N 名用户
func getTopUsers(ctx context.Context, n int64) ([]int64, error) {
    key := "user_scores"
    res, err := RedisClient.ZRevRange(ctx, key, 0, n-1).Result()
    if err != nil {
        return nil, err
    }
    users := make([]int64, len(res))
    for i, member := range res {
        users[i] = member.Member.(int64)
    }
    return users, nil
}

限流

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)
	}
}
  1. 获取当前时间所在的秒数作为限流的 key。
  2. 使用 Redis 中的 Incr 命令对该 key 的值进行自增操作。
  3. 判断当前计数器的值是否超过了限流的阈值,如果超过了,就说明需要限流,请求被阻止。
  4. 如果计数器的值没有超过限流的阈值,就说明流量未达到限制,请求可以继续处理。
  5. 请求处理完毕后,使用 Redis 中的 Decr 命令对计数器的值进行自减操作。

image.png

分布式锁

可以使用 Redis 的 setnx 实现,利用了两个特性:

  • 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)
		}
	}
}
  1. 尝试获取锁:使用 Redis 的 SetNX 命令尝试在 Redis 中创建指定的锁资源。SetNX 命令会将键值对存储到 Redis 中,如果该键值对不存在,则存储成功,并且返回 true,表示获取锁成功;否则返回 false,表示获取锁失败。
  2. 成功获取锁:当获取锁成功时,执行业务逻辑代码。在这个例子中,模拟了一个 10 毫秒的业务逻辑耗时。
  3. 释放锁:业务逻辑执行完毕后,使用 Redis 的 DEL 命令删除该锁资源。

3. Redis 使用注意事项

  • 大Key:Value大于10KB就是大Key,使用大Key将导致Redis系统不稳定

解决方法:

  1. 分割大 key:将大 key 拆分成多个小的 key 存储,例如将一个大的列表分割成多个小的列表。这种方法适用于那些可以分割的数据结构。
  2. 使用 Redis 的数据结构:例如使用 Hash 替换多个 String,或者使用 Set 替换多个 List,这样可以减少内存使用。
  3. 删除不必要的数据:删除过期数据或者不必要的数据,可以减少内存占用。
  4. 压缩存储数据:对于大的 value,可以使用压缩算法进行压缩,减少内存占用。
  • 热Key:一个Key的QPS特别高,将导致Redis实例出现负载突增,负责均衡流量不均的情况。导致单实例故障

解决方法:

  1. 数据预热:在 Redis 启动之前,提前将一些热门数据加载到内存中,避免服务启动后出现热 Key 导致的访问压力。
  2. 增加缓存层:在 Redis 之前增加一层缓存,如 Memcached,将热门数据缓存到这一层,减轻 Redis 的访问压力。
  3. 增加副本:将热门 Key 的副本分散到多个 Redis 节点上,减轻单个节点的访问压力。
  • 慢查询:大Key、热Kye的读写;一次操作过多的Key(mset/hmset/sadd/zadd)

  • 导致缓存穿透、缓存雪崩的场景及避免方案

缓存穿透是指查询一个不存在的key,由于缓存中没有对应的值,每次请求都会穿透到后端数据库,可能导致数据库压力过大甚至宕机。

缓存雪崩是指缓存中大量的key在同一时间失效,导致大量请求直接访问数据库,可能导致数据库压力过大甚至宕机。

常见的避免缓存穿透和缓存雪崩的方案包括:

  1. Bloom Filter 避免缓存穿透:使用 Bloom Filter 过滤掉不存在的 key,如果 Bloom Filter 认为 key 存在,则继续查询缓存,否则直接返回。
  2. 常规缓存使用过期时间,避免缓存雪崩:在缓存中设置随机的过期时间,使得不同缓存的过期时间不同,避免缓存同时失效导致的雪崩效应。
  3. 加锁,避免缓存雪崩:在缓存失效时,使用分布式锁锁住对该 key 的访问,等待缓存更新完成后释放锁,避免多个请求同时访问数据库。
  4. 永不过期,避免缓存雪崩:将热点数据设置为永不过期,避免缓存同时失效导致的雪崩效应。
  5. 限流,避免缓存雪崩:在缓存失效时,限制对数据库的请求并发数,避免瞬间对数据库的压力过大。
  6. 使用多级缓存,避免缓存穿透和缓存雪崩:使用多级缓存,例如本地缓存和分布式缓存,减少单一缓存的压力,避免缓存穿透和缓存雪崩。

三、课后个人总结

这堂课对于一个刚刚入门 Redis 的小白菜来说难度有点稍大,尤其是 Redis 的应用案例里的功能实现。不过本堂课学到的知识还是不少的,大致了解了 Redis 的基本原理,对 Redis 有了新的认识,同时对其的应用案例也有了更加深入的理解,同时也认识到了 Redis 在使用时需要注意的问题及针对性的解决方案,整体上有了一个更为系统、有序的 Redis 知识框架,日后还需要结合实践来多多练习巩固!

最近学习了很多知识,真的发现需要学习的内容越学越多,这边 MySQL 还没有吃透,Redis 又来凑热闹发😭,继续加油吧~

从头越,苍山如海,残阳如血!

四、引用参考