Redis实战(上)|青训营

191 阅读11分钟

为什么需要Redis

image.png

Redis基本工作原理

  1. 数据从内存中读写

  2. 数据保存到硬盘上防止重启数据丢失

    • 增量数据保存到AOF文件
    • 全量数据保存到RDB文件
  3. 单线程处理所有操作命令

image.png

image.png

Redis客户端常用命令

字符串命令

  • set 设置存储在给定键中的值
  • get 获取存储在给定键中的值
  • del 删除存储在给定键中的值(这个命令可以用于所有类型)

image.png

列表命令

  • rpush 将给定值推入列表的右端
  • lrange 获取列表在给定范围上的所有值
  • lindex 获取列表在给定位置上的单个元素
  • lpop 从列表的左端弹出一个值,并返回被弹出的值

image.png

集合命令

  • sadd 将元素添加到集合中
  • srem 从集合中移除元素
  • sismember 快速的检查一个元素是否已存在与集合中
  • smembers 获取集合中包含的所有元素

image.png

散列表

  • hset 在散列里面关联给定的键值对
  • hget 获取指定散列键的值
  • hgetall 获取散列包含的所有键值对
  • hdel 如果给定键存在与散列里面,那么移除这个键

image.png

有序集合

  • zadd 将一个带有给定分值的成员添加到有序集合里面
  • zrange 根据元素在有序排列中所处的位置,从有序集合里面获取多个元素
  • zrangebyscore 获取有序集合中在给定分值范围内的所有元素
  • zrem 如果给定成员存在与有序集合,那么移除这个项目

image.png

Redis应用案例

前期准备

首先需要确保系统内下载了Redis服务端,官网提供了Windows和Linux的下载方式,因为本人电脑使用的是Window,所以下载了Window版的,开启Redis-server后,通过后台我们需要确保Redis服务已开启,如下图:

image.png

搭建Go环境Redis客户端

通过go get -u github.com/go-redis/redis可以下载Go开发依赖包

go-redis包自带了连接池,会自动维护redis连接,因此创建一次client即可,不要查询一次redis就关闭client

Redis连接测试

package example
import "github.com/go-redis/redis"
var RedisClient *redis.Client
func initClient() (err error) {
    RedisClient = redis.NewClient(&redis.Options{
        Addr:     "localhost:6379",
        Password: "",
        DB:       0,
    })
    _, err = RedisClient.Ping().Result()
    if err != nil {
        return err
    }
    return nil
}

连续签到

用户每日有一次签到的机会,如果断签,连续签到计数将归0,连续签到的定义;每天必须在23:59:59前签到

  • Key: cc_uid_11658948334101
  • value:252
  • expireAt:后天的0点

用到的数据结构:string

image.png 这个例子主要用到是最简单的redis存储方式,即可以直接给与对应的key值既可,可以通过incr函数进行自增,程序复现代码以及运行结果如下:

package example
import (
    "context"
    "fmt"
    "strconv"
    "time"
)
const continuesCheckKey = "cc_uid_%d"
var ctx = context.Background()
// 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("参数错误,param=%v,error:%v\n", params, err)
    }
}
// addContinuesDays 为用户签到续期
func addContinuesDays(ctx context.Context, userID int64) {
    key := fmt.Sprintf(continuesCheckKey, userID)
    err := RedisClient.Incr(ctx, key).Err()
    if err != nil {
        fmt.Printf("用户[%d]连续签到失败", userID)
    } else {
        expAT := beginningOfDay().Add(48 * time.Hour)
        //设置签到记录在后天的0点到期
        if err := RedisClient.ExpireAt(ctx, key, expAT).Err(); err != nil {
            panic(err)
        } else {
            //打印用户签到后的连续签到天数
            day, err := getUserCheckInDays(ctx, userID)
            if err != nil {
                panic(err)
            }
            fmt.Printf("用户[%d]连续签到,%d天,过期时间:%s\n", userID, day, expAT.Format("2006-01-02 15:04:05"))
        }
    }
​
}
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
    }
}
func beginningOfDay() time.Time {
    now := time.Now()
    y, m, d := now.Date()
    return time.Date(y, m, d, 0, 0, 0, 0, time.Local)
}

image.png

消息通知

用list作为消息队列,使用场景:消息通知。例如当文章更新时,将更新后的文章推送到S,用户就能搜索到最新的文章数据

image.png 这一部分的实现需要我们去监听我们想看的列表,所以需要去构建并发线程的思想,为了更好的去查看协程的执行情况,我们应该定义一个日志搜集器,来监听协程执行过程中信息,对于这个搜集器的定义,主要有以下几个需要注意的点:

  • 在总体设计上,我们只对外使用一个ConcurrentEventLogger结构,该结构中会存储一个类型为 EventLog的切片
  • EventLog结构主要是有一个当前时间和日志信息,通过保存这两个消息,可以打印出对应的消息
  • 为ConcurrentEventLogger结构封装了几个主要使用的关键接口
  • 用于初始化搜集器的接口NewConcurrentEventLogs
  • 用于追加日志的结构Append
  • 用于将所有日志正序输出的接口PrintLogs
  • 用于构建一条结构化的日志语句的接口LogFormat
package common
import (
    "context"
    "fmt"
    "sort"
    "time"
)
// 定义并发日志收集器
type ConcurrentEventLogger struct {
    eventlogs []EventLog
}
// EventLog 搜集日志的结构
type EventLog struct {
    EventTime time.Time
    Log       string
}
func NewConcurrentEventLogs(ctx context.Context, logsLength int) *ConcurrentEventLogger {
    if logsLength <= 0 {
        logsLength = 32
    }
    logContainer := make([]EventLog, 0, logsLength)
    return &ConcurrentEventLogger{eventlogs: logContainer}
}
// Append 追加日志
func (ceLog *ConcurrentEventLogger) Append(mLog EventLog) {
    ceLog.eventlogs = append(ceLog.eventlogs, mLog)
}
// PrintLogs 日志按时间正序输出
func (ceLog *ConcurrentEventLogger) PrintLogs() {
    sort.Slice(ceLog.eventlogs, func(i, j int) bool {
        return ceLog.eventlogs[i].EventTime.Before(ceLog.eventlogs[j].EventTime)
    })
    for i, _ := range ceLog.eventlogs {
        fmt.Println(ceLog.eventlogs[i].Log)
    }
}
//LogFormat 包含通用日志前缀 [2022-11-27T12:36:00.213454+08:00] routine[5]
func LogFormat(routine int,format string,a ...any)string{
    tpl:="[%s] routine[%d]"+format
    sr:=[]any{time.Now().Format(time.RFC3339Nano),routine}
    sr=append(sr, a...)
    return fmt.Sprintf(tpl,sr...)
}

定义并发协程类,这个结构为主要的执行结构,因为我们需要通过这个结构实现并发执行,首先看一下主要的结构体

// 并发执行器对象定义
type ConCurrentRoutine struct {
    routineNums           int                    //定义并发协程的数量
    concurrentEventLogger *ConcurrentEventLogger //并发日志搜集器
}

通过该结构体我们可以看出其实很简单,只记录了协程数量以及一个搜集器,这个搜集器是所有协程所共享的,一般对于协程的封装来说我们会给外部暴露一个初始化接口以及一个运行接口,初始化简单,无非就是初始化结构体中的变量

// NewConcurrentRoutine 初始化一个并发执行器
func NewConcurrentRoutine(routineNums int, concurrentEventLog *ConcurrentEventLogger) *ConCurrentRoutine {
    return &ConCurrentRoutine{
        routineNums: routineNums, concurrentEventLogger: concurrentEventLog,
    }
}

对于Run接口的定义呢,需要详细考虑,首先在这里我们需要开启线程所以&sync.WaitGroup{}结构必不可少,其次协程运行后,改去执行什么内容呢,其实对于协程的设计者而言并不知道,因为我们只是想更好的封装这个结构体,并不会考虑具体的执行,所以这里我们可以推给用户去做,则定义一个回调函数即可,正对函调函数的参数呢,自由定义,这里采用了一个新的结构体

// CInstParams 定义传入callBack的参数
type CInstParams struct {
    Routine               int //协程编号
    ConcurrentEventLogger *ConcurrentEventLogger
    CustomParams          interface{} //用户自定义参数
}
​
type callBack func(ctx context.Context, params CInstParams) //定义一个用户自定义执行函数
// Run 并发执行用户自定义函数 workFun
func (cInst *ConCurrentRoutine) Run(ctx context.Context, customParams interface{}, workFun callBack)

通过参数可以看出主要需要的是一个自由变量,主要用于提供给回调函数的使用中,回调函数是更重要的参数,其实现过程如下:

func (cInst *ConCurrentRoutine) Run(ctx context.Context, customParams interface{}, workFun callBack) {
    wg := &sync.WaitGroup{}
    for i := 0; i < cInst.routineNums; i++ {
        wg.Add(1)
        go func(mCtx context.Context, mRoutine int, mParams interface{}) {
            defer wg.Done()
            workFun(mCtx, CInstParams{Routine: mRoutine, ConcurrentEventLogger: cInst.concurrentEventLogger, CustomParams: mParams})
        }(ctx, i, customParams)
    }
}

以上完成了并发线程的所有东西,接下来就需要监听Redis中的队列了,下面这个接口则是这个功能的重头戏,通过它我们可以实现阻塞,直至有消息进来为止

RedisClient.BRPop(ctx, 0, ex04ListenList).Result()

这段代码中BRPop 是 Redis 中的一条命令,用于阻塞连接,直到一个指定列表中有元素可用,然后从列表中弹出该元素。其对应的参数含义为:

  • ctx 是上下文对象,表示操作的上下文。通常用于管理超时、截止时间和操作的取消。
  • 0 是超时值,以秒为单位。在这种情况下,0 表示该命令将无限期地阻塞,直到列表中有元素可用。
  • ex04ListenList是我们要监听的列表的名称

定义函数主体,主要是初始化日志打印器以及协程结构体

package example
import (
    "context"
    "fmt"
    "time""main.go/example/common"
)
const Ex02ListenList = "my_First_list"type Ex02Params struct {
}
func Ex02(ctx context.Context) {
    eventLogger := &common.ConcurrentEventLogger{}
    //new 一个并发执行器
    //routineNums是消费者的数量 多消费的场景,可以使用
    cInst := common.NewConcurrentRoutine(1, eventLogger)
    //并发执行用户自定义函数work
    cInst.Run(ctx, Ex02Params{}, ex02ConsumerPop)
    //按日志时间正序打印日志
    eventLogger.PrintLogs()
}
// ex02ConsumerPop 使用rpop逐条消费队列中的信息,数据从队列中移除
// 生成端使用:lpush my_First_list AA BB
func ex02ConsumerPop(ctx context.Context, cInstParam common.CInstParams) {
    routine := cInstParam.Routine
    for {
        items, err := RedisClient.BRPop(ctx, 0, Ex02ListenList).Result()
        if err != nil {
            panic(err)
        }
        fmt.Println(common.LogFormat(routine, "读取文章[%s]标题,正文,发送到ES更新索引", items[1]))
        //将文章推送至ES
        time.Sleep(1 * time.Second)
    }
}

image.png

List数据结构QuickList

QuickList由一个双向链表和listpack实现

image.png

image.png

计数

一个用户有多项计数需求,可通过hash结构存储

image.png

这个案例主要是针对Redis中哈希表的练习,给结构体主要通过一个key键值,存储value值,这个value一般都是通过map进行存储,先看一下函数主体的实现:

func Ex03(ctx context.Context, args []string) {
    if len(args) == 0 {
        fmt.Printf("args can NOT be empty\n")
        os.Exit(1)
    }
    arg1 := args[0]
    switch arg1 {
    case "init":
        Ex03InitUserCount(ctx)
    case "get":
        userID, err := strconv.ParseInt(args[1], 10, 64)
        if err != nil {
            panic(err)
        }
        GetUserCounter(ctx, userID)
    case "incr_like":
        userID, err := strconv.ParseInt(args[1], 10, 64)
        if err != nil {
            panic(err)
        }
        IncrByUserLike(ctx, userID)
    case "incr_collect":
        useID, err := strconv.ParseInt(args[1], 10, 64)
        if err != nil {
            panic(err)
        }
        IncrByUserCollect(ctx, useID)
    case "decr_like":
        userID, err := strconv.ParseInt(args[1], 10, 64)
        if err != nil {
            panic(err)
        }
        DecrByUserLike(ctx, userID)
    case "decr_collect":
        userID, err := strconv.ParseInt(args[1], 10, 64)
        if err != nil {
            panic(err)
        }
        DecrByUserCollect(ctx, userID)
    }
}

通过这段代码可以看出,对redis中的一些调用过程进行了封装,接下来我们逐个实现函数的调用过程,init函数主要是初始化写入的数据,这一部分按照之前的学习可以通过HSET方法对数据进行存储,这样做是可以的,但为了减少对redis的调用,加快写入,引入了RedisClient.Pipeline()方法,这是redis的一个流水线对象,可以用于在一次网络往返中发送多个命令,从而提高效率,配合它的主要方式为:

  • pipe.Del() //从 Redis 中删除指定的键
  • pipe.HMSet() //将多个字段值对一次性设置到一个哈希(Hash)数据结构中
  • pipe.Exec() //实际发送这些命令到 Redis 服务器并执行它们

具体的实现过程如下

func Ex03InitUserCount(ctx context.Context) {
    //这段代码创建了一个用于执行多个 Redis 命令的流水线(Pipeline)对象。
    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)
        //从 Redis 中删除指定的键
        rw, err := pipe.Del(ctx, key).Result()
        if err != nil {
            fmt.Printf("del %s,rw=%d\n", key, rw)
        }
        //将多个字段值对一次性设置到一个哈希(Hash)数据结构中
        _, err = pipe.HMSet(ctx, key, counter).Result()
        if err != nil {
            panic(err)
        }
        fmt.Printf("设置 uid=%d ,key =%s\n", uid, key)
    }
    //批量执行上面for循环设置好的hmset命令
    //使用 pipe.Exec(ctx) 方法来实际发送这些命令到 Redis 服务器并执行它们
    _, err := pipe.Exec(ctx)
    if err != nil {
        _, err = pipe.Exec(ctx)
        if err != nil {
            panic(err)
        }
    }
}

Get:获取主键uid中对应的数据,这一部分的处理也采用流水线对象的方式实现,通过pipe.HGetAll获取一个哈希数据结构中的所有字段和对应的值,通过执行pipe.Exec获得主键对应的对象,cmder.(*redis.MapStringStringCmd)这个转换数据类型,具体实现如下:

func GetUserCounter(ctx context.Context, userID int64) {
    pipe := RedisClient.Pipeline()
    GetUserCounterKey(userID)
    //个命令用于获取一个哈希(Hash)数据结构中的所有字段和对应的值
    pipe.HGetAll(ctx, GetUserCounterKey(userID))
    cmders, err := pipe.Exec(ctx)
    if err != nil {
        panic(err)
    }
    for _, cmder := range cmders {
        //将cmder转换为*redis.MapStringStringCmd类型
        counterMap, err := cmder.(*redis.MapStringStringCmd).Result()
        if err != nil {
            panic(err)
        }
        for field, value := range counterMap {
            fmt.Printf("%s:%s\n", field, value)
        }
​
    }
}

对于具体数据的自增自减主要是通过redis自带的HIncrBy以及HDecrBy实现,这一块的代码逻辑较为简单,主要是封装change接口即可

func change(ctx context.Context, userID int64, field string, incr int64) {
    rediskey := GetUserCounterKey(userID)
    before, err := RedisClient.HGet(ctx, rediskey, field).Result()
    if err != nil {
        panic(err)
    }
    beforeInt, err := strconv.ParseInt(before, 10, 64)
    if err != nil {
        panic(err)
    }
    if beforeInt+incr < 0 {
        fmt.Printf("禁止变更计数,计数变更后小于0,%d+(%d)=%d\n", beforeInt, incr, beforeInt+incr)
    }
    fmt.Printf("user_id: %d\n更新前\n%s = %s\n--------\n", userID, field, before)
    _, err = RedisClient.HIncrBy(ctx, rediskey, field, incr).Result()
    if err != nil {
        panic(err)
    }
​
    count, err := RedisClient.HGet(ctx, rediskey, field).Result()
    if err != nil {
        panic(err)
    }
    fmt.Printf("user_id: %d\n更新后\n%s = %s\n--------\n", userID, field, count)
}

Hash数据结构dict

  • rehash:rehash操作是将ht[O]中的数据全部迁移到ht[1]中。数据量小的场景下直接将数据从ht[o]拷贝到ht[1]速度是较快的。数据量大的场景,例如存有上百万的KV时,迁移过程将会明显阻塞用户请求
  • 渐进式rehash:为避免出现这种情况,使用了rehash方案。基本原理就是,每次用户访问时都会迁移少量数据。将整个迁移过程,平摊到所有的访问用不请求过程中

image.png

对于后几个结构以及实战应用,将在下一次文章中体现。