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 结构而言,一般有三个部分构成:PrevLen,Encoding,Entry-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 了。