Go语言操作Redis

Go语言操作Redis

在项目开发中redis的使用也比较频繁,本文介绍了Go语言中go-redis库的基本使用。

Redis介绍

Redis是一个开源的内存数据库,Redis提供了多种不同类型的数据结构,很多业务场景下的问题都可以很自然地映射到这些数据结构上。除此之外,通过复制、持久化和客户端分片等特性,我们可以很方便地将Redis扩展成一个能够包含数百GB数据、每秒处理上百万次请求的系统。

Redis支持的数据结构

Redis支持诸如字符串(string)、哈希(hashe)、列表(list)、集合(set)、带范围查询的排序集合(sorted set)、bitmap、hyperloglog、带半径查询的地理空间索引(geospatial index)和流(stream)等数据结构。

Redis应用场景

  • 缓存系统,减轻主数据库(MySQL)的压力。
  • 计数场景,比如微博、抖音中的关注数和粉丝数。
  • 热门排行榜,需要排序的场景特别适合使用ZSET。
  • 利用 LIST 可以实现队列的功能。
  • 利用 HyperLogLog 统计UV、PV等数据。
  • 使用 geospatial index 进行地理位置相关查询。

准备Redis环境

读者可以选择在本机安装 redis 或使用云数据库,这里直接使用Docker启动一个 redis 环境,方便学习使用。

使用下面的命令启动一个名为 redis507 的 5.0.7 版本的 redis server环境。

go 复制代码
docker run --name redis507 -p 6379:6379 -d redis:5.0.7

注意: 此处的版本、容器名和端口号可以根据自己需要设置。

启动一个 redis-cli 连接上面的 redis server。

go 复制代码
docker run -it --network host --rm redis:5.0.7 redis-cli

go-redis库

安装

Go 社区中目前有很多成熟的 redis client 库,比如github.com/gomodule/re...github.com/redis/go-re...,读者可以自行选择适合自己的库。本文使用 go-redis 这个库来操作 Redis 数据库。

使用以下命令下安装 go-redis 库。

安装v9版本:

go 复制代码
go get github.com/redis/go-redis/v9

连接

在项目中导入 go-redis库(请根据实际情况导入自己需要的版本)。

go 复制代码
import "github.com/redis/go-redis/v9"

需要特别注意的是,在2023年,go-redis 库的仓库所有权转移到了Redis官方组织,并发布了 v9 版本 。这是一个重要且不兼容的变更

普通连接模式

go-redis 库中使用 redis.NewClient 函数连接 Redis 服务器。

go 复制代码
package main

import (
	"context"
	"fmt"
	"github.com/redis/go-redis/v9" // 使用 v9 版本,需提前 go get
)

// 全局 Redis 客户端
var rdb *redis.Client

// initClient 初始化 Redis 连接
func initClient() error {
	rdb = redis.NewClient(&redis.Options{
		Addr:     "localhost:16379", // Redis 地址
		Password: "",                // 无密码
		DB:       0,                 // 使用默认数据库
		PoolSize: 100,               // 连接池大小
	})

	// 使用 Ping 测试连接(需传入 context)
	_, err := rdb.Ping(context.Background()).Result()
	return err
}

func main() {
	// 初始化客户端
	if err := initClient(); err != nil {
		fmt.Printf("init redis client failed, err:%v\n", err)
		return
	}
	fmt.Println("connect redis success...")

	// 程序退出时释放连接资源
	defer rdb.Close()

	// 此处可添加业务逻辑,例如执行 Redis 命令
	// 示例:设置一个键值对
	ctx := context.Background()
	err := rdb.Set(ctx, "test_key", "hello redis", 0).Err()
	if err != nil {
		fmt.Printf("set key failed: %v\n", err)
		return
	}

	val, err := rdb.Get(ctx, "test_key").Result()
	if err != nil {
		fmt.Printf("get key failed: %v\n", err)
		return
	}
	fmt.Printf("get test_key: %s\n", val)
}

除此之外,还可以使用 redis.ParseURL 函数从表示数据源的字符串中解析得到 Redis 服务器的配置信息。

go 复制代码
opt, err := redis.ParseURL("redis://<user>:<pass>@localhost:6379/<db>")
if err != nil {
	panic(err)
}

rdb := redis.NewClient(opt)

举例:

go 复制代码
opt, err := redis.ParseURL("redis://admin:admin123@localhost:6379/1")
// 等价于:
rdb := redis.NewClient(&redis.Options{
    Addr:     "localhost:6379",
    Username: "admin",
    Password: "admin123",
    DB:       1,
})
部分 说明 示例
redis:// 协议前缀,固定 redis://
<user> 用户名(Redis 6.0+ 支持 ACL 时使用) myuser
<password> 密码 mypass123
<host> 主机地址 localhost127.0.0.1
<port> 端口号 6379
<db> 数据库编号(可选,默认为 0) 01
  1. URL 中的特殊字符需要 URL 编码,如果密码包含 @/,需要转义:
go 复制代码
// 密码是 "p@ssw/rd"
// 需要编码为 "p%40ssw%2Frd"
redis.ParseURL("redis://:p%40ssw%2Frd@localhost:6379/0")
  1. 不能配置所有选项,ParseURL 只支持基础配置(地址、密码、DB),像 PoolSizeTimeout 等高级配置仍需手动设置:
go 复制代码
opt, _ := redis.ParseURL("redis://localhost:6379/0")
opt.PoolSize = 100        // 补充配置
opt.ReadTimeout = 3 * time.Second
rdb := redis.NewClient(opt)
TLS连接模式(加密连接)

如果使用的是 TLS 连接方式,则需要使用 tls.Config 配置。

go 复制代码
rdb := redis.NewClient(&redis.Options{
    TLSConfig: &tls.Config{
        MinVersion: tls.VersionTLS12,
        // Certificates: []tls.Certificate{cert},
        // ServerName: "your.domain.com",
    },
})
Redis Sentinel(哨兵)模式

使用下面的命令连接到由 Redis Sentinel 管理的 Redis 服务器。

go 复制代码
rdb := redis.NewFailoverClient(&redis.FailoverOptions{
    MasterName:    "master-name",
    SentinelAddrs: []string{":9126", ":9127", ":9128"},
})

完整示例:

  1. 配置文件准备

Sentinel 配置文件(sentinel.conf):

go 复制代码
# sentinel-1.conf
# ============================================
# Sentinel 自身监听的端口
# 客户端(Go 程序)和其他 Sentinel 通过此端口连接
# 【注意】每个 Sentinel 节点必须使用不同的端口
# ============================================
port 9126

# ============================================
# 监控主节点配置(最核心的配置)
# 格式:sentinel monitor <主节点名称> <IP> <端口> <法定人数>
# 
# 参数详解:
#   - mymaster     : 主节点别名(自定义),客户端连接时需使用此名称
#   - 127.0.0.1    : Redis 主节点的 IP 地址
#   - 6379         : Redis 主节点的端口
#   - 2            : 法定人数(quorum)
#                    至少需要 2 个 Sentinel 认为主节点挂了才触发故障转移
#                    建议设置为 Sentinel 总数的一半以上(如 3 个节点设为 2)
# 
# 【重要】所有 Sentinel 节点的此配置必须完全一致
# ============================================
sentinel monitor mymaster 127.0.0.1 6379 2

# ============================================
# 主观下线判定时间(毫秒)
# 如果 Sentinel 在 5000ms(5秒)内未收到主节点响应,
# 则认为该节点"主观下线"(SDOWN)
# 
# 注意:
#   - 这只是单个 Sentinel 的判断,称为"主观下线"
#   - 需要达到法定人数(quorum)确认后才会变成"客观下线"(ODOWN)
#   - 生产环境建议设置为 10000-30000ms,避免网络波动误判
# ============================================
sentinel down-after-milliseconds mymaster 5000

# ============================================
# 故障转移超时时间(毫秒)
# 如果故障转移在 60000ms(60秒)内未完成,视为超时失败
# 
# 这个时间内包括:
#   1. 选举新的主节点
#   2. 配置从节点指向新主
#   3. 通知客户端更新配置
# 
# 建议根据业务容忍度调整,通常设为 60000-180000ms(60-180秒)
# ============================================
sentinel failover-timeout mymaster 60000

# sentinel-2.conf(类似,端口改 9127)
# sentinel-3.conf(类似,端口改 9128)
  1. 客户端连接代码
go 复制代码
package main

import (
	"context"
	"fmt"
	"github.com/redis/go-redis/v9"
)

func main() {
	// 创建 Sentinel 客户端
	rdb := redis.NewFailoverClient(&redis.FailoverOptions{
		MasterName:       "mymaster",           // 必须和 Sentinel 配置一致
		SentinelAddrs:    []string{             // Sentinel 节点列表
			"localhost:9126",
			"localhost:9127", 
			"localhost:9128",
		},
		Password:         "your_password",      // Redis 密码(如有)
		SentinelPassword: "sentinel_password",  // Sentinel 密码(如有)
		PoolSize:         100,                  // 连接池大小
	})

	// 测试连接
	ctx := context.Background()
	err := rdb.Ping(ctx).Err()
	if err != nil {
		fmt.Printf("连接失败: %v\n", err)
		return
	}
	fmt.Println("连接 Redis Sentinel 成功!")

	// 正常使用 Redis 命令
	err = rdb.Set(ctx, "key", "value", 0).Err()
	if err != nil {
		fmt.Printf("设置失败: %v\n", err)
		return
	}
	
	val, err := rdb.Get(ctx, "key").Result()
	if err != nil {
		fmt.Printf("获取失败: %v\n", err)
		return
	}
	fmt.Printf("获取值: %s\n", val)

	defer rdb.Close()
}

一般线上公司都会使用哨兵模式。

Redis Cluster(集群)模式

使用下面的命令连接到 Redis Cluster,go-redis 支持按延迟或随机路由命令。

go 复制代码
rdb := redis.NewClusterClient(&redis.ClusterOptions{
    Addrs: []string{":7000", ":7001", ":7002", ":7003", ":7004", ":7005"},

    // 若要根据延迟或随机路由命令,请启用以下命令之一
    // RouteByLatency: true,
    // RouteRandomly: true,
})

不管是普通模式还是哨兵模式还是集群模式,主要看运维怎么配置的,运维怎么配,怎么连。

基本使用

执行命令

下面的示例代码演示了 go-redis 库的基本使用。

go 复制代码
package main

import (
	"context"
	"fmt"
	"time"
	"github.com/redis/go-redis/v9"
)

// 全局 Redis 客户端(假设已初始化)
var rdb *redis.Client

// doCommand go-redis 基本使用示例
func doCommand() {
    // 1. 创建带超时的 Context
    ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
    defer cancel()

    // ============================================
    // 2. Set 操作
    // ============================================

    // ✅ 方式一:使用 Err() 只关心错误(最常用)
    err := rdb.Set(ctx, "key", 10, time.Hour).Err()
    if err != nil {
        fmt.Printf("Set 失败: %v\n", err)
        return
    }
    fmt.Println("Set 成功")

    // ✅ 方式二:使用 Result() 同时获取状态和错误
    status, err := rdb.Set(ctx, "key", 20, time.Hour).Result()
    if err != nil {
        fmt.Printf("Set 失败: %v\n", err)
        return
    }
    fmt.Printf("Set 状态: %s\n", status) // 输出: Set 状态: OK

    // ============================================
    // 3. Get 操作
    // ============================================

    // ✅ 方式一:使用 Result() 同时获取值和错误(推荐)
    val, err := rdb.Get(ctx, "key").Result()
    if err != nil {
        if err == redis.Nil {
            fmt.Println("key 不存在")
        } else {
            fmt.Printf("Get 失败: %v\n", err)
        }
        return
    }
    fmt.Printf("Get 值: %s\n", val)

    // ✅ 方式二:先获取命令对象,再分别获取值和错误
    cmder := rdb.Get(ctx, "key")
    fmt.Printf("cmder.Val(): %s\n", cmder.Val()) // 获取值
    if err := cmder.Err(); err != nil {          // 获取错误
        fmt.Printf("cmder.Err(): %v\n", err)
    } else {
        fmt.Println("cmder.Err(): nil")
    }

    // ✅ 方式三:使用 Int() 直接获取整数(如果值是数字)
    intVal, err := rdb.Get(ctx, "key").Int()
    if err != nil {
        fmt.Printf("Get Int 失败: %v\n", err)
        return
    }
    fmt.Printf("Get Int 值: %d\n", intVal)
}

执行任意命令

go-redis 还提供了一个执行任意命令或自定义命令的 Do 方法,特别是一些 go-redis 库暂时不支持的命令都可以使用该方法执行。具体使用方法如下。

go 复制代码
// doDemo rdb.Do 方法使用示例
func doDemo() {
	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel()

	// 直接执行命令获取错误
	err := rdb.Do(ctx, "set", "key", 10, "EX", 3600).Err()
	fmt.Println(err)

	// 执行命令获取结果
	val, err := rdb.Do(ctx, "get", "key").Result()
	fmt.Println(val, err)
}

SET 命令详解

参数 说明
ctx Context 上下文,控制超时
"set" 命令名 Redis SET 命令
"key" Key 键名
10 Value 值(会被存储为字符串 "10")
"EX" 选项 设置过期时间(秒)
3600 过期时间 3600 秒 = 1 小时

redis.Nil

go-redis 库提供了一个 redis.Nil 错误来表示 Key 不存在的错误。因此在使用 go-redis 时需要注意对返回错误的判断。在某些场景下我们应该区别处理 redis.Nil 和其他不为 nil 的错误。

go 复制代码
// getValueFromRedis redis.Nil判断
func getValueFromRedis(key, defaultValue string) (string, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
    defer cancel()

    val, err := rdb.Get(ctx, key).Result()
    if err != nil {
        // 如果返回的错误是key不存在
        // 注意:errors.Is(err, redis.Nil) 和 err == redis.Nil 不等效
        if errors.Is(err, redis.Nil) {
            return defaultValue, nil
        }
        // 出其他错了
        return "", err
    }
    return val, nil
}

为什么推荐使用 errors.Is

  1. 应对错误包装 :在实际项目中,为了给错误添加上下文(比如在哪个函数、操作了什么 key),我们常用 fmt.Errorf 包装错误。这时 == 就会失效,而 errors.Is 可以正确识别。
  2. Go 官方推荐 :从 Go 1.13 开始,errors.Is 就是官方推荐的错误判断方式,它能更好地配合错误链的工作机制。

使用 %w 包装错误:

go 复制代码
// 包装:保留原始错误
err := fmt.Errorf("上下文信息: %w", originalErr)

// 示例
originalErr := redis.Nil
wrappedErr := fmt.Errorf("查询用户失败: %w", originalErr)

其他示例

ZSET = 集合 + 分数 + 自动排序

  • 成员唯一不重复
  • 分数决定排位次
  • 增删改查都高效
  • 排行榜场景最合适

zset示例

下面的示例代码演示了如何使用 go-redis 库操作 zset。

go 复制代码
// zsetDemo 操作zset示例
func zsetDemo() {
    // key
    zsetKey := "language_rank"
    // value
    // 注意:v8版本使用[]*redis.Z;此处为v9版本使用[]redis.Z
    languages := []redis.Z{
        {Score: 90.0, Member: "Golang"},
        {Score: 98.0, Member: "Java"},
        {Score: 95.0, Member: "Python"},
        {Score: 97.0, Member: "JavaScript"},
        {Score: 99.0, Member: "C/C++"},
    }
    ctx, cancel := context.WithTimeout(context.Background(), 2000*time.Millisecond)
    defer cancel()

    // ZADD 添加成员
    err := rdb.ZAdd(ctx, zsetKey, languages...).Err()
    if err != nil {
        fmt.Printf("zadd failed, err:%v\n", err)
        return
    }
    fmt.Println("zadd success")

    // ZIncrBy 给某个成员加分数 把Golang的分数加10
    newScore, err := rdb.ZIncrBy(ctx, zsetKey, 10.0, "Golang").Result()
    if err != nil {
        fmt.Printf("zincrby failed, err:%v\n", err)
        return
    }
    fmt.Printf("Golang's score is %f now.\n", newScore)
    // Golang's score is 100.000000 now.

    // ZRevRangeWithScores 获取分数排名范围内的成员 取分数最高的3个(0 1 2)
    ret := rdb.ZRevRangeWithScores(ctx, zsetKey, 0, 2).Val()
    for _, z := range ret {
        fmt.Println(z.Member, z.Score)
        // C/C++ 99  Java 98   JavaScript 97
    }

    // ZRangeBy 获取分数范围内的成员 取95~100分的(不包括95)
    op := &redis.ZRangeBy{
        Min: "95",
        Max: "100",
    }
    ret, err = rdb.ZRangeByScoreWithScores(ctx, zsetKey, op).Result()
    if err != nil {
        fmt.Printf("zrangebyscore failed, err:%v\n", err)
        return
    }
    for _, z := range ret {
        fmt.Println(z.Member, z.Score)
        // C/C++ 99  Java 98  JavaScript 97
    }
    
    // ZRevRank 获取排名(从 0 开始)
    rank, err := rdb.ZRevRank(ctx, zsetKey, "Golang").Result()
    fmt.Printf("Golang 排名: %d\n", rank)  // 输出: 0

    // ZScore 获取指定成员的分数
    score, err := rdb.ZScore(ctx, zsetKey, "Python").Result()
    fmt.Printf("Python 分数: %f\n", score) // 输出: 95.000000

    // ZRem 删除一个成员
    deleted, err := rdb.ZRem(ctx, zsetKey, "JavaScript").Result()
    // 删除多个成员
    deleted, err := rdb.ZRem(ctx, zsetKey, "Java", "Python").Result()

    // ZCard 获取ZSET长度
    count, err := rdb.ZCard(ctx, zsetKey).Result()
    fmt.Printf("成员数量: %d\n", count) // 5
}

扫描或遍历所有key

在Redis中可以使用KEYS prefix* 命令按前缀查询所有符合条件的 key,go-redis库中提供了Keys方法实现类似查询key的功能。

例如使用以下命令查询以user:为前缀的所有key(user:cart:00user:order:2023等)。

go 复制代码
vals, err := rdb.Keys(ctx, "user:*").Result()

但是如果需要扫描数百万的 key ,那速度就会比较慢。这种场景下你可以使用Scan命令来遍历所有符合要求的 key。

go 复制代码
// scanKeysDemo1 按前缀查找所有key示例
func scanKeysDemo1() {
    ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
    defer cancel()

    var cursor uint64
    for {
        var keys []string
        var err error
        // 将redis中所有以prefix:为前缀的key都扫描出来
        keys, cursor, err = rdb.Scan(ctx, cursor, "prefix:*", 0).Result()
        if err != nil {
            panic(err)
        }

        for _, key := range keys {
            fmt.Println("key", key)
        }

        if cursor == 0 { // no more keys
            break
        }
    }
}

针对这种需要遍历大量key的场景,go-redis中提供了一个简化方法------Iterator,其使用示例如下。

go 复制代码
// scanKeysDemo2 按前缀扫描key示例
func scanKeysDemo2() {
    ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
    defer cancel()
    // 按前缀扫描key
    iter := rdb.Scan(ctx, 0, "prefix:*", 0).Iterator()
    for iter.Next(ctx) {
        fmt.Println("keys", iter.Val())
    }
    if err := iter.Err(); err != nil {
        panic(err)
    }
}

例如,我们可以写出一个将所有匹配指定模式的 key 删除的示例。

go 复制代码
// delKeysByMatch 按match格式扫描所有key并删除
func delKeysByMatch(match string, timeout time.Duration) {
    ctx, cancel := context.WithTimeout(context.Background(), timeout)
    defer cancel()

    iter := rdb.Scan(ctx, 0, match, 0).Iterator()
    for iter.Next(ctx) {
        err := rdb.Del(ctx, iter.Val()).Err()
        if err != nil {
            panic(err)
        }
    }
    if err := iter.Err(); err != nil {
        panic(err)
    }
}

此外,对于 Redis 中的 set、hash、zset 数据类型,go-redis 也支持类似的遍历方法。

go 复制代码
iter := rdb.SScan(ctx, "set-key", 0, "prefix:*", 0).Iterator()
iter := rdb.HScan(ctx, "hash-key", 0, "prefix:*", 0).Iterator()
iter := rdb.ZScan(ctx, "sorted-hash-key", 0, "prefix:*", 0).Iterator(

Pipeline

Pipeline (管道)是 Redis 提供的一种批量执行命令的机制,允许客户端一次性向服务器发送多个命令,而不需要等待每个命令的单独响应。

go 复制代码
┌─────────────────────────────────────────────────────────────────┐
│                      普通模式(多次网络往返)                    │
├─────────────────────────────────────────────────────────────────┤
│  客户端 ──→ 命令1 ──→ 服务端                                   │
│  客户端 ←── 响应1 ←── 服务端  ← 等待                          │
│  客户端 ──→ 命令2 ──→ 服务端                                   │
│  客户端 ←── 响应2 ←── 服务端  ← 等待                          │
│  客户端 ──→ 命令3 ──→ 服务端                                   │
│  客户端 ←── 响应3 ←── 服务端                                   │
│                                                                 │
│  总耗时 = 3 × (网络RTT + 命令执行时间)                         │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                      Pipeline 模式(一次批量)                  │
├─────────────────────────────────────────────────────────────────┤
│  客户端 ──→ 命令1,命令2,命令3 ──→ 服务端                      │
│  客户端 ←── 响应1,响应2,响应3 ←── 服务端                      │
│                                                                 │
│  总耗时 = 1 × (网络RTT + 3 × 命令执行时间)                    │
│  节省了 2 次网络 RTT!                                         │
└─────────────────────────────────────────────────────────────────┘
  • 普通模式:每条命令都需等待响应,才能发送下一条(往返延迟累加)
  • Pipeline 模式:多条命令打包发送,统一接收响应(往返延迟仅一次)
go 复制代码
func pipelineWithCounter() {
    ctx := context.Background()
    key := "pipeline_counter"
    
    pipe := rdb.Pipeline()
    
    // 添加命令
    incr := pipe.Incr(ctx, key)
    // key 对应的值增加 1
    // Incr操作的是 String 类型,不是 ZSET
    // 它会把 key 对应的字符串值解析成数字,然后 +1
    // 如果 key 不存在,会先初始化为 0,再 +1
    expire := pipe.Expire(ctx, key, time.Hour) 
    // 设置过期时间1小时 1 小时后自动删除
    
    // 执行
    cmds, err := pipe.Exec(ctx)
    
    // 或者你也可以使用Pipelined 方法,它会在函数退出时调用 Exec
    cmds, err := rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error {
        incr = pipe.Incr(ctx, key)
        expire = pipe.Expire(ctx, key, time.Hour)
        return nil
    })

    if err != nil {
        fmt.Printf("pipeline exec failed: %v\n", err)
        return
    }
    
    // 检查整体结果
    fmt.Printf("执行了 %d 条命令\n", len(cmds))
    
    // 检查具体命令
    if err := incr.Err(); err != nil {
        fmt.Printf("INCR 失败: %v\n", err)
    } else {
        fmt.Printf("计数器新值: %d\n", incr.Val())
    }
    
    if err := expire.Err(); err != nil {
        fmt.Printf("EXPIRE 失败: %v\n", err)
    } else {
        fmt.Printf("过期时间设置成功: %v\n", expire.Val())
    }
}

pipe.Incr(ctx, key) 应用:限流(最常用)

go 复制代码
// 每个用户每分钟最多访问 100 次
func checkRateLimit(userID string) bool {
    key := fmt.Sprintf("rate:user:%s", userID)
    
    // 每次访问 +1
    count := rdb.Incr(ctx, key).Val()
    
    // 第一次访问设置过期时间
    if count == 1 {
        rdb.Expire(ctx, key, time.Minute)
    }
    
    return count <= 100  // 超过 100 就拒绝
}

// 某个用户瞬间刷了 1000 次请求
// Incr 会返回 1,2,3,4... 直到 101 就返回 false
// 没有 Incr,你怎么统计访问次数?

既然有用,为什么Incr操作的是字符串呢? 核心原因:Redis 的数据类型设计

Redis 的 5 种基本数据类型中,并没有整数类型

数据类型 说明
String 字符串(最基础)
Hash 哈希表
List 列表
Set 集合
ZSET 有序集合

简单一句话 :Redis 用 String 统一天下,遇到整数自动优化,所以 Incr 操作 String 既简单又高效!

我们可以遍历 pipeline 命令的返回值依次获取每个命令的结果。下方的示例代码中使用pipiline一次执行了100个 Get 命令,在pipeline 执行后遍历取出100个命令的执行结果。

在那些我们需要一次性执行多个命令的场景下,就可以考虑使用 pipeline 来优化。

事务

Redis 是单线程执行命令的,因此单个命令始终是原子的,但是来自不同客户端的两个给定命令可以依次执行,例如在它们之间交替执行。但是,Multi/exec能够确保在multi/exec两个语句之间的命令之间没有其他客户端正在执行命令。

在这种场景我们需要使用 TxPipeline 或 TxPipelined 方法将 pipeline 命令使用 MULTIEXEC包裹起来。

go 复制代码
// TxPipeline demo
pipe := rdb.TxPipeline()
incr := pipe.Incr(ctx, "tx_pipeline_counter")
pipe.Expire(ctx, "tx_pipeline_counter", time.Hour)
_, err := pipe.Exec(ctx)
fmt.Println(incr.Val(), err)

// TxPipelined demo
var incr2 *redis.IntCmd
_, err = rdb.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
	incr2 = pipe.Incr(ctx, "tx_pipeline_counter")
	pipe.Expire(ctx, "tx_pipeline_counter", time.Hour)
	return nil
})
fmt.Println(incr2.Val(), err)

上面代码相当于在一个RTT下执行了下面的redis命令:

go 复制代码
MULTI
INCR pipeline_counter
EXPIRE pipeline_counts 3600
EXEC
go 复制代码
// 场景:需要先检查再操作(必须原子)
func transferMoney(from, to string, amount int) error {
    ctx := context.Background()
    
    // ❌ 错误:可能被其他客户端穿插
    // from余额可能被修改,导致扣款错误
    balance := rdb.Get(ctx, from).Val()
    if balance < amount {
        return errors.New("余额不足")
    }
    rdb.DecrBy(ctx, from, amount)
    rdb.IncrBy(ctx, to, amount)
    
    // ✅ 正确:使用 TxPipeline 保证原子性
    pipe := rdb.TxPipeline()
    get := pipe.Get(ctx, from)
    pipe.DecrBy(ctx, from, amount)
    pipe.IncrBy(ctx, to, amount)
    _, err := pipe.Exec(ctx)
    
    if err != nil {
        return err
    }
    // 但注意:get 的结果可能在 Exec 之后才拿到
    return nil
}

性能对比

go 复制代码
// 1. 普通 Pipeline(最快,但不安全)
pipe := rdb.Pipeline()
pipe.Incr(ctx, "counter")
pipe.Expire(ctx, "counter", time.Hour)
pipe.Exec(ctx)

// 2. TxPipeline(稍慢,但安全)
txPipe := rdb.TxPipeline()
txPipe.Incr(ctx, "counter")
txPipe.Expire(ctx, "counter", time.Hour)
txPipe.Exec(ctx)

// 3. 多次独立请求(最慢,不安全)
rdb.Incr(ctx, "counter")
rdb.Expire(ctx, "counter", time.Hour)

Watch

我们通常搭配 WATCH命令来执行事务操作。从使用WATCH命令监视某个 key 开始,直到执行EXEC命令的这段时间里,如果有其他用户抢先对被监视的 key 进行了替换、更新、删除等操作,那么当用户尝试执行EXEC的时候,事务将失败并返回一个错误,用户可以根据这个错误选择重试事务或者放弃事务。

Watch方法接收一个函数和一个或多个key作为参数。

go 复制代码
Watch(fn func(*Tx) error, keys ...string) error

下面的代码片段演示了 Watch 方法搭配 TxPipelined 的使用示例。

go 复制代码
// 场景:如果余额大于100,才扣款
func deductIfSufficient(userID string, amount int) error {
    ctx := context.Background()
    key := fmt.Sprintf("balance:%s", userID)
    
    // Watch 监控 key 的变化
    // 如果 watch 的 key 在事务执行前被其他客户端修改,事务会失败
    err := rdb.Watch(ctx, func(tx *redis.Tx) error {
        // 获取当前余额
        balance, err := tx.Get(ctx, key).Int()
        if err != nil && err != redis.Nil {
            return err
        }
        
        // 检查余额是否充足
        if balance < amount {
            return fmt.Errorf("余额不足: %d < %d", balance, amount)
        }
        
        // 使用 TxPipeline 执行更新(在事务中执行)
        // 此时如果 balance 被其他客户端修改,EXEC 会失败
        _, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
            // 扣减余额
            pipe.Set(ctx, key, balance-amount, 0)
            return nil
        })
        return err
    }, key) // 监控的 key 列表
    
    // 如果返回 redis.TxFailedErr,说明 Watch 的 key 被修改了
    if err == redis.TxFailedErr {
        return fmt.Errorf("余额已被其他客户端修改,请重试")
    }
    return err
}

Watch 的工作原理

go 复制代码
客户端 A(你的代码)          客户端 B(其他用户)
    │                           │
    │ WATCH balance:user123     │
    │ GET balance:user123       │
    │ balance = 1000            │
    │                           │
    │                           ├── GET balance:user123
    │                           ├── balance = 1000
    │                           ├── SET balance:user123 900  ← 先执行了!
    │                           │
    │ MULTI                     │
    │ SET balance:user123 900   │
    │ EXEC                      │
    │                           │
    │ ❌ 执行失败!              │
    │ (因为 balance 被修改了)   │
    └───────────────────────────┘

最后我们来看一个 go-redis 官方文档中使用 GETSETWATCH命令实现一个 INCR 命令的完整示例。

go 复制代码
// 此示例演示如何使用 GET + SET + WATCH 手动实现 INCR 命令
// 通过乐观锁机制保证并发安全

// 设置5秒超时
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

const routineCount = 100 // 并发 goroutine 数量

// increment 是一个自定义对 key 进行递增(+1)的函数
// 使用 GET + SET + WATCH 实现,效果等同于 Redis 原生 INCR 命令
increment := func(key string) error {
    // txf 是事务函数,会被 Watch 包裹执行
    txf := func(tx *redis.Tx) error {
        // 1. 获取当前值(如果 key 不存在则返回 0)
        n, err := tx.Get(ctx, key).Int()
        // 意思就是err有值,并且err不是key不存在,大概就是网络断连、超时等等
        // err == redis.Nil就是ke不存在
        if err != nil && err != redis.Nil {
            return err
        }

        // 2. 在本地进行递增操作(此时还未写入 Redis)
        n++

        // 3. 仅在监视的 Key 没有被其他客户端修改的情况下执行
        // TxPipelined 会将命令包裹在 MULTI/EXEC 中
        _, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
            // 设置新值
            pipe.Set(ctx, key, n, 0)
            return nil
        })
        return err
    }

    // 4. 乐观锁重试机制:最多重试 100 次
    // 如果事务失败(key 被其他客户端修改),则重试
    for retries := routineCount; retries > 0; retries-- {
        // Watch 会监控 key,如果在事务执行期间 key 被修改,事务会失败
        err := rdb.Watch(ctx, txf, key)
        
        // 如果错误不是 TxFailedErr,说明是其他错误(如网络问题),直接返回
        if err != redis.TxFailedErr {
            return err
        }
        // 如果是 TxFailedErr,说明乐观锁冲突,继续重试
    }
    // 重试次数用尽
    return errors.New("increment reached maximum number of retries")
}

// 5. 开启 100 个 goroutine 并发调用 increment
// 相当于对 key 执行 100 次递增,最终结果应该是 100
var wg sync.WaitGroup
wg.Add(routineCount)
for i := 0; i < routineCount; i++ {
    go func() {
        defer wg.Done()
        if err := increment("counter3"); err != nil {
            fmt.Println("increment error:", err)
        }
    }()
}
wg.Wait()

// 6. 获取最终结果
n, err := rdb.Get(ctx, "counter3").Int()
fmt.Println("最终结果:", n, err) // 输出:最终结果:100 <nil>

在这个示例中使用了 redis.TxFailedErr 来检查事务是否失败。

相关推荐
名字还没想好☜1 天前
Go 并发实战:用 channel 实现 worker pool
java·数据库·后端·golang·go
星航夜空的帆舟2 天前
ADK 会话与状态管理:三级状态、内存服务与多存储后端
go·agent
CodeStats2 天前
【编程语言】深度梳理C/C++、Java、Python、Go、Rust的区别
java·linux·c语言·c++·python·rust·go
用户6757049885022 天前
Go 程序员进阶必读:这 24 个经典的 Golang 错误,连老手都会栽跟头!
后端·go
大P哥阿豪2 天前
Go panic & recover:深入Go语言的异常拯救之道
开发语言·后端·golang·go
星航夜空的帆舟2 天前
ADK 模型抽象与多模型集成:从 Gemini 到 Ollama
go·agent
XCAI8888883 天前
性价比高的郑州geo哪个靠谱
go
Sinclair3 天前
认识安企CMS-安装安企CMS的环境要求
go
老鹰8623 天前
实战 dig:Go 编译时依赖注入的完整教程与迁移指南
go