我该怎么用我的Redis(一) | 青训营

118 阅读7分钟

Redis基本的工作原理

  • 数据从内存中读写
  • 数据保存到硬盘上防止重启数据丢失
    • 增量数据保存到AOF文件
    • 全量数据保存到RDB文件
  • Redis使用单线程来处理所有的操作命令

我们业务当中一般有这样的一个查询链路的模型:

查询链路

Redis的使用

我们以 Go 为例:

我们使用库github.com/redis/go-redis/v9作为 Redis Client,并且初始化他:

package redis

import (
	"GuGoTik/src/constant/config"
	"github.com/redis/go-redis/extra/redisotel/v9"
	"github.com/redis/go-redis/v9"
	"strings"
)

var Client redis.UniversalClient

func init() {
	addrs := strings.Split(config.EnvCfg.RedisAddr, ";")
	Client = redis.NewUniversalClient(&redis.UniversalOptions{
		Addrs:    addrs,
		Password: config.EnvCfg.RedisPassword,
		DB:       config.EnvCfg.RedisDB,
	})

	if err := redisotel.InstrumentTracing(Client); err != nil {
		panic(err)
	}

	if err := redisotel.InstrumentMetrics(Client); err != nil {
		panic(err)
	}
}

这个地方使用了Otel组件,你可以选择关闭它

Redis使用案例

连续签到

业务场景

  • 连续签到,如果断签,那么签到计数归0
  • 连续签到指的是每天必须在23:59:59前进行签到

设置对应的模型

  • Key:cc_uid_xxx
  • Value: 254
  • expireAt:后天的0点

业务代码

[collapse status="false" title="代码"]

package example

import (
	"context"
	"fmt"
	"strconv"
	"time"
)

var ctx = context.Background()

const continuesCheckKey = "cc_uid_%d"

// Ex01 连续签到天数
func Ex01(ctx context.Context, params []string) {
	if userID, err := strconv.ParseInt(params[0], 10, 64); err == nil {
		addContinuesDays(ctx, userID)
	} else {
		fmt.Printf("参数错误, params=%v, error: %v\n", params, err)
	}
}

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

// getUserCheckInDays 获取用户连续签到天数
func getUserCheckInDays(ctx context.Context, userID int64) (int64, error) {
	key := fmt.Sprintf(continuesCheckKey, userID)
	days, err := RedisClient.Get(ctx, key).Result()
	if err != nil {
		return 0, err
	}
	if daysInt, err := strconv.ParseInt(days, 10, 64); err != nil {
		panic(err)
	} else {
		return daysInt, nil
	}
}

// beginningOfDay 获取今天0点时间
func beginningOfDay() time.Time {
	now := time.Now()
	y, m, d := now.Date()
	return time.Date(y, m, d, 0, 0, 0, 0, time.Local)
}

[/collapse]

数据结构

本案例中用到了 String,他需要做到的事情是:

  • 结构足够简单
  • 修改和新增足够的快

数据结构

使用场景

  • 存储字符串、数字、二进制数据
  • 配合 expireAt 使用
  • 存储技术、Session

消息通知

业务场景

使用 List 作为消息队列

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

业务代码

package example

// setnx 分布式锁
import (
	"context"
	"fmt"
	"strconv"
	"time"

	"gitee.com/wedone/redis_course/example/common"
)

const resourceKey = "syncKey"      // 分布式锁的key
const exp = 800 * time.Millisecond // 锁的过期时间,避免死锁

// EventLog 搜集日志的结构
type EventLog struct {
	eventTime time.Time
	log       string
}

// Ex02Params Ex02的自定义函数
type Ex02Params struct {
}

// Ex02 只是体验SetNX的特性,不是高可用的分布式锁实现
// 该实现存在的问题:
// (1) 业务超时解锁,导致并发问题。业务执行时间超过锁超时时间
// (2) redis主备切换临界点问题。主备切换后,A持有的锁还未同步到新的主节点时,B可在新主节点获取锁,导致并发问题。
// (3) redis集群脑裂,导致出现多个主节点
func Ex02(ctx context.Context) {
	eventLogger := &common.ConcurrentEventLogger{}
	// new一个并发执行器
	cInst := common.NewConcurrentRoutine(10, eventLogger)
	// 并发执行用户自定义函数work
	cInst.Run(ctx, Ex02Params{}, ex02Work)
	// 按日志时间正序打印日志
	eventLogger.PrintLogs()
}

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 的数据结构 QuickList 大体上使用的是 双向链表,但是每一个节点除了 Next, Prev 外还有一个 Entry 用于存储多个数据,也就是Redis中的一个节点是可以存储多个数据的。

Redis 的 List 结构经过了很长的演变才变成了 QuickList。

LinkedList

在 Redis 3.2 版本前,List的底层数据结构使用 LinkedList 或者 ZipList 实现,优先使用 ZipList 实现。

ZipList 的实现需要满足以下两个条件:

  • List 的每个元素的占用的字节小于 64 字节。
  • List 的元素数量小于 512 个。

链表的节点使用以下结构来表示:

typedef struct listNode {
    // 前驱节点
    struct listNode *prev;
    // 后驱节点
    struct listNode *next;
    // 指向节点的值
    void *value;
} listNode;

特性如下:

  • 双端:链表节点带有 prev 和 next 指针,获取某个节点的前置节点和后继节点的复杂度都是 O(1)。
  • 无环:表头节点的 prev 指针和尾节点的 next 指针都指向 NULL,对链表的访问以 NULL 为结束。
  • 带表头指针和表尾指针:通过 list 结构的 head 指针和 tail 指针,程序获取链表的头节点和尾节点的复杂度为 O(1)。
  • 使用 list 结构的 len 属性来对记录节点数量,获取链表中节点数量的复杂度为 O(1)。

ZipList

但是 LinkedList 有两个指针,在数据量小的情况下,指针的占用会超过数据占用的空间,且其为链表结构,在内存中不连续的,遍历的效率低下,因此出现了 ZipList。

ZipList 中可以包含多个 Entry 节点,每个节点可以存放整数或者字符串数据。

数据结构

对于 Entry 结构而言,一般有三个部分构成:PrevLenEncodingEntry-Data

对于 PrevLen 而言,其记录的是前一个 Entry 所占用的字节数,依靠这个可以实现逆序遍历

对于 Encoding 而言,其表示的是当前 Entry 的类型和长度,当前 entry 的长度和值是根据保存的是 int 还是 string 以及数据的长度共同来决定。前两位用于表示类型,当前两位值为 “11” 则表示 entry 存放的是 int 类型数据,其他表示存储的是 string。

对于 Entry-Data 而言,其是实际存放数据的区域,需要注意的是,如果 entry 中存储的是 int 类型,encoding 和 entry-data 会合并到 encoding 中,没有 entry-data 字段。

但是他也有不足之处:

  • 不能保存过多的元素,否则查询性能会大大降低,O(N) 时间复杂度。
  • ziplist 存储空间是连续的,当插入新的 entry 时,内存空间不足就需要重新分配一块连续的内存空间,引发连锁更新的问题。

同时,每个 Entry 都用 PrevLen 记录了上一个 Entry 的长度,那么如果在 A,B之间插入了一个 C 时,那么就会导致连锁更新 PrevLen

QuickList

typedef struct quicklistNode {
    // 前序节点指针
    struct quicklistNode *prev;
    // 后序节点指针
    struct quicklistNode *next;
    // 指向 ziplist 的指针
    unsigned char *zl;
    // ziplist 字节大小
    unsigned int sz;
    // ziplst 元素个数
    unsigned int count : 16;
    // 编码格式,1 = RAW 代表未压缩原生ziplist,2=LZF 压缩存储
    unsigned int encoding : 2;
    // 节点持有的数据类型,默认值 = 2 表示是 ziplist
    unsigned int container : 2;
    // 节点持有的 ziplist 是否经过解压, 1 表示已经解压过,下一次操作需要重新压缩。
    unsigned int recompress : 1;
    // ziplist 数据是否可压缩,太小数据不需要压缩
    unsigned int attempted_compress : 1;
    // 预留字段
    unsigned int extra : 10;
} quicklistNode;

因此就有了我们所说的 QuickList 了。