redis 入门指南
Redis是一种流行的内存键值数据库,被广泛用于构建高性能的缓存和消息队列应用。如果你的网站访问很卡顿,那么接入redis缓存热点数据,将会使你的网站访问体验得到质的飞跃。redis的体系很庞大,本课程就简单的带着大家入个门
redis安装
docker-compose 安装
docker-compose up -dservices:
redis:
image: redis:7-alpine
container_name: redis-practice
restart: unless-stopped
ports:
- '6379:6379'
volumes:
- ./redis-data-t:/data
command:
- redis-server
- --appendonly
- 'yes'
- --requirepass
- '123456'
healthcheck:
test: ['CMD', 'redis-cli', '-a', '123456', 'ping']
interval: 10s
timeout: 5s
retries: 5
start_period: 10s连接redis
如果本地主机安装了Redis,可以用redis-cli命令去连接、测试Redis指令
命令行连接
# 完整命令
redis-cli -h 127.0.0.1 -p 6379 -a password
# 简写
redis-cli
# 认证 进入redis之后
auth passwordGo代码连接
main.go 中封装了连接初始化逻辑,返回一个可复用的 *redis.Client:
package main
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
var DB *redis.Client
func Connect() *redis.Client {
redisDB := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379", // 不写默认就是这个
Password: "123456", // 密码
DB: 1, // 默认是0
DialTimeout: time.Second, // 连接超时
})
_, err := redisDB.Ping(context.Background()).Result()
if err != nil {
panic(err)
}
fmt.Println("Redis响应:", redisDB, err)
return redisDB
}注意:这里用的是
go-redis/v9,和 v8 最大的区别是所有命令都要传入context.Context;连接参数按本地启动的 Redis 的实际配置填写 连接好之后,五大数据类型、事务、锁等操作都封装在pkg/utils.go中,每个函数统一接收*redis.Client作为参数
redis五大数据类型
string (字符串)
list (列表)
set (集合)
hash (哈希)
zset (有序集合)
1. String
字符串 String 是 Redis 最简单的数据结构,可以存储字符串、整数或者浮点数。最常见的应用场景就是对象缓存
127.0.0.1:6379> auth redis # 登录
OK
127.0.0.1:6379> set name leige # 设置
OK
127.0.0.1:6379> get name # 获取
"leige"
127.0.0.1:6379> exists name # 判断这个key是否存在
(integer) 1
127.0.0.1:6379> exists name1
(integer) 0
127.0.0.1:6379> del name # 删除这个key
(integer) 1
127.0.0.1:6379> get name
(nil)
127.0.0.1:6379> GETSET name1 lisi # 如果不存在值,则返回 nil,set是会执行的
(nil)
127.0.0.1:6379> GETSET name1 lisi # 如果存在值,获取原来的值,并设置新的值
"lisi"批量操作
127.0.0.1:6379> mset n1 zhangsan n2 lisi
OK
127.0.0.1:6379> get n1
"zhangsan"
127.0.0.1:6379> get n2
"lisi"计数:自增1,自减1
127.0.0.1:6379> get n
(nil)
127.0.0.1:6379> incr n # 自增1
(integer) 1 # 当前这个n的值
127.0.0.1:6379> get n
"1"
127.0.0.1:6379> incr n
(integer) 2
127.0.0.1:6379> get n
"2"
127.0.0.1:6379> decr n # 自减1
(integer) 1
127.0.0.1:6379> get n
"1"
127.0.0.1:6379> decr n
(integer) 0
127.0.0.1:6379> get n
"0"
127.0.0.1:6379> decr n
(integer) -1
127.0.0.1:6379> get n
"-1"自动增n,自减n
127.0.0.1:6379> get n
(nil)
127.0.0.1:6379> INCRBY n 10
(integer) 10
127.0.0.1:6379> INCRBY n 11
(integer) 21
127.0.0.1:6379> get n
"21"
127.0.0.1:6379> DECRBY n 5
(integer) 16
127.0.0.1:6379> get n
"16"过期操作
setex 单位是秒
127.0.0.1:6379> setex name 20 leige
OK
127.0.0.1:6379> get name
"leige"
127.0.0.1:6379> ttl name # 查看还有多久过期
(integer) 12
127.0.0.1:6379> ttl name # -2表示已过期
(integer) -2
127.0.0.1:6379> get name
(nil)expire 设置一个key的过期时间
如果不设置过期时间,ttl显示的结果是-1,表示永不过期
127.0.0.1:6379> set name leige
OK
127.0.0.1:6379> ttl name # 现在是永不过期
(integer) -1
127.0.0.1:6379> expire name 10
(integer) 1
127.0.0.1:6379> ttl name
(integer) 6
127.0.0.1:6379> ttl name # 现在是过期了
(integer) -22. List
redis列表就相当于go里面的切片
127.0.0.1:6379> rpush list zhangsan lisi wangwu xiaoming # 从右边向左边推入四个元素
4 # 返回列表的长度
127.0.0.1:6379> llen list # 查列表的长度
4
127.0.0.1:6379> lrange list 0 -1 # 查看列表的全部元素
zhangsan
lisi
wangwu
xiaoming
127.0.0.1:6379> rpop list # 从右边弹出一个元素
xiaoming
127.0.0.1:6379> lrange list 0 -1
zhangsan
lisi
wangwu
127.0.0.1:6379> lpop list # 从左边弹出一个元素
zhangsan
127.0.0.1:6379> lrange list 0 -1
lisi
wangwu3. Hash
redis哈希就相当于go里面的map
127.0.0.1:6379> hset dict name leige # 设置一个hash key,并且给hash里面设置一组filed value
0
127.0.0.1:6379> hget dict name # 获取hash里面对应filed的值
leige
127.0.0.1:6379> hgetall dict # 数据量大时,谨慎使用!获取在哈希表中指定 key 的所有字段和值
name
leige
127.0.0.1:6379> hkeys dict # 获取在哈希表中指定 key 的所有filed
name
127.0.0.1:6379> hmset info name leige age 23 # 批量设置
OK
127.0.0.1:6379> hkeys info
name
age
127.0.0.1:6379> HEXISTS info name # 判断某个field是否存在
1
127.0.0.1:6379> HEXISTS info name1
0
127.0.0.1:6379> HDEL info name # 删除hash中的一个filed
1
127.0.0.1:6379> hkeys info
age
127.0.0.1:6379> hlen info # 返回hash中filed的个数
1注意:设置过期只能给hash的key设置过期,hash内部的field键值对是不能单独设置过期的
4. Set
redis集合就相当于go里面的集合
127.0.0.1:6379> sadd set a b c d # 添加元素
4
127.0.0.1:6379> sadd set a b c e # 实际只有一个元素添加进去了
1
127.0.0.1:6379> scard set # 获取集合的长度
5
127.0.0.1:6379> sismember set a # 判断某个元素是不是在集合里面
1
127.0.0.1:6379> sismember set f
0
127.0.0.1:6379> smembers set # 获取集合中的数据 无序的
e
d
b
a
c
127.0.0.1:6379> srem set a # 删除集合中的某个元素 可传多个
1交集、并集和差集
sdiff差集sinter交集sunion并集
127.0.0.1:6379> sadd set1 1 2 3
3
127.0.0.1:6379> sadd set2 2 3 4
3
127.0.0.1:6379> sdiff set1 set2 # 差集
1
127.0.0.1:6379> sdiff set2 set1
4
127.0.0.1:6379> sinter set1 set2 # 交集
2
3
127.0.0.1:6379> sunion set1 set2 # 并集
1
2
3
4随机抽奖:
spop随机移除集合内元素并返回
127.0.0.1:6379> sadd order 1 2 3 4 5 6 7 8
8
127.0.0.1:6379> spop order # 随机移除一个
5
127.0.0.1:6379> spop order
7
127.0.0.1:6379> spop order 3 # 随机移除3个,redis3.2+支持Set 使用场景: 共同好友、统计网站的独立ip、标签
5. sorted set(zset 有序集合)
sorted set 增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列,member元素不能重复
127.0.0.1:6379> zadd class 88 leige 46 zhangsan 76 wangwu 36 lisi # 向有序集合添加一个或多个成员
(integer) 4
127.0.0.1:6379> zrange class 0 -1 # 通过索引,按照分数从低到高返回
1) "lisi"
2) "zhangsan"
3) "wangwu"
4) "leige"
127.0.0.1:6379> zrevrange class 0 -1 # 通过索引,按照分数从高到低返回
1) "leige"
2) "wangwu"
3) "zhangsan"
4) "lisi"
127.0.0.1:6379> zcard class # 获取有序集合的成员数
(integer) 4
127.0.0.1:6379> ZSCORE class zhangsan # 查看某个成员的分数
"46"
127.0.0.1:6379> ZRANK class wangwu # 查看成员的排名 从小到大排序
(integer) 2
127.0.0.1:6379> ZREVRANK class wangwu # 查看成员的排名 从大到小排序
(integer) 1
127.0.0.1:6379> zcount class 40 70 # 计算在有序集合中指定区间分数的成员数
(integer) 1
127.0.0.1:6379> zrangebyscore class 40 70 # 通过分数返回有序集合指定区间内的成员
1) "zhangsan"
127.0.0.1:6379> zrevrangebyscore class 100 0 # 返回有序集中指定分数区间内的成员,分数从高到低排序
1) "leige"
2) "wangwu"
3) "zhangsan"
4) "lisi"
127.0.0.1:6379> zrange class 0 -1 withscores # 把成员和分数一起显示出来
1) "lisi"
2) "36"
3) "zhangsan"
4) "46"
5) "wangwu"
6) "76"
7) "leige"
8) "88"
127.0.0.1:6379> zrem class leige # 移除一个成员
(integer) 1
127.0.0.1:6379> zrange class 0 -1
1) "lisi"
2) "zhangsan"
3) "wangwu"
127.0.0.1:6379> ZREMRANGEBYRANK class 0 0 # 移除有序集合中给定的排名区间的所有成员(第一名是0)(低到高排序)
(integer) 1
127.0.0.1:6379> zrange class 0 -1
1) "zhangsan"
2) "wangwu"
127.0.0.1:6379> ZREMRANGEBYSCORE class 40 50 # 移除有序集合中给定的分数区间的所有成员
(integer) 1
127.0.0.1:6379> zrange class 0 -1
1) "wangwu"
127.0.0.1:6379> ZSCORE class wangwu
"76"
127.0.0.1:6379> ZINCRBY class 2 wangwu # 给wangwu分数+2
"78"
127.0.0.1:6379> ZINCRBY class -2 wangwu # 给wangwu分数‑2
"76"zset 使用场景: 排行榜、订单支付超时(下单时插入,member为订单号,score为订单超时时间戳,定时任务扫描)
通过go操作五大数据类型
前面会了命令之后,使用 go-redis/v9 去操作 redis,除了每个命令都要传入
context.Context之外,用法和命令基本一致
String
// string类型基本操作
func RedisStringOperations(client *redis.Client) {
// 创建一个带超时的 context,防止网络请求卡死(推荐10秒超时)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() // 请求结束时释放 context 资源
// 1. Set 和 Get
client.Set(ctx, "name", "Lin", 0)
val, _ := client.Get(ctx, "name").Result()
fmt.Println("获取的 name 值:", val) // 输出: Lin
// 2. Exists 检查 Key 是否存在
existsName, _ := client.Exists(ctx, "name").Result()
existsName1, _ := client.Exists(ctx, "name1").Result()
fmt.Println("name 是否存在:", existsName == 1, "\nname1 是否不存在:", existsName1 == 0) // 输出: 1 0
// 3. 数值自增操作 (Redis 要求值必须是纯数字字符串)
client.Set(ctx, "age", "12", 0)
client.Incr(ctx, "age") // 12 + 1 = 13
client.IncrBy(ctx, "age", 10) // 13 + 10 = 23
ageVal, _ := client.Get(ctx, "age").Int64()
fmt.Println("自增后的 age 值:", ageVal) // 输出: 23
// 4. TTL 过期时间操作
ttl, _ := client.TTL(ctx, "name").Result()
fmt.Println("name 的当前过期时间:", ttl) // 输出: -1s (代表永不过期)
// 设置 2 秒过期时间
client.Expire(ctx, "name", 2*time.Second)
ttl, _ = client.TTL(ctx, "name").Result()
fmt.Println("设置过期后的 TTL:", ttl) // 输出: 约 2s
// 等待 2 秒让 Key 过期
time.Sleep(2 * time.Second)
ttl, _ = client.TTL(ctx, "name").Result()
fmt.Println("过期后的 TTL:", ttl) // 输出: -2s (代表 Key 不存在)
}List
// List 基础操作
func RedisListOperations(client *redis.Client) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
client.LTrim(ctx, "list", 1, 0) // 先清空,保证重复运行结果稳定
// 1. 从右边入队,插入4个元素
client.RPush(ctx, "list", "TS", "Rust", "Python", "Zig")
// 2. 获取列表长度
length := client.LLen(ctx, "list").Val()
fmt.Println("列表长度:", length) // 输出: 4
// 3. 获取列表所有元素 (索引从 0 到 -1 代表全部)
vals := client.LRange(ctx, "list", 0, -1).Val()
fmt.Println("列表所有元素:", vals)
}Hash
// Hash类型
func RedisHash(client *redis.Client) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 设置哈希字段
client.HSet(ctx, "info", "文章1", 23)
// 获取单个字段,转成 int64
val, _ := client.HGet(ctx, "info", "文章1").Int64()
fmt.Println(val) // 23
// 获取整个哈希表
fmt.Println(client.HGetAll(ctx, "info").Val()) // map[文章1:23]
// 获取所有字段名
fmt.Println(client.HKeys(ctx, "info").Val()) // [文章1]
// 判断字段是否存在
fmt.Println(client.HExists(ctx, "info", "文章1").Val()) // true
fmt.Println(client.HExists(ctx, "info", "文章2").Val()) // false
// 获取字段数量
fmt.Println(client.HLen(ctx, "info").Val()) // 1
}Set
// Set 类型
func RedisSet(client *redis.Client) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 添加元素
client.SAdd(ctx, "set", "a", "b", "c", "d")
// 判断是否是成员
fmt.Println(client.SIsMember(ctx, "set", "a").Val()) // true
// 获取所有成员(无序)
fmt.Println(client.SMembers(ctx, "set").Val()) // [b d c a]
// 准备两个集合做集合运算
client.SAdd(ctx, "set1", 1, 2, 3)
client.SAdd(ctx, "set2", 2, 3, 4)
// 差集:set1 里有但 set2 里没有的
fmt.Println(client.SDiff(ctx, "set1", "set2").Val()) // [1]
// 交集:两个集合都有的
fmt.Println(client.SInter(ctx, "set1", "set2").Val()) // [2 3]
// 并集:两个集合所有不重复的元素
fmt.Println(client.SUnion(ctx, "set1", "set2").Val()) // [1 2 3 4]
}Zset
// ZSet 类型
func RedisZSet(client *redis.Client) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 0. 先清空,保证重复运行的结果稳定
client.Del(ctx, "class")
// 1. 添加成员和分数 (传入切片)
zs := []redis.Z{
{Score: 80, Member: "张三"},
{Score: 46, Member: "李四"},
}
client.ZAdd(ctx, "class", zs...)
// 2. 按排名从小到大取全部成员 (索引 0 到 -1)
fmt.Println(client.ZRange(ctx, "class", 0, -1).Val())
// [李四 张三]
// 3. 按排名从大到小取全部成员
// V9 中 ZRevRange 已废弃,统一用 ZRangeArgs + Rev
fmt.Println(client.ZRangeArgs(ctx, redis.ZRangeArgs{
Key: "class",
Start: 0,
Stop: -1,
Rev: true, // 反转,即从大到小
}).Val())
// [张三 李四]
// 4. 按分数范围查询:0~100,从小到大
// V9 中 ZRangeByScore 已废弃(Redis 6.2.0+),改用 ZRangeArgs + ByScore
// Start/Stop 此时是分数边界,所以用字符串
fmt.Println(client.ZRangeArgs(ctx, redis.ZRangeArgs{
Key: "class",
Start: "0",
Stop: "100",
ByScore: true, // 按分数区间查,而不是按排名下标查
}).Val())
// [李四 张三]
// 5. 按分数范围查询:0~100,从大到小
// 注意:ByScore + Rev 时,Start 必须写较大的分数、Stop 写较小的分数(区间要反着写)
fmt.Println(client.ZRangeArgs(ctx, redis.ZRangeArgs{
Key: "class",
Start: "100",
Stop: "0",
ByScore: true,
Rev: true, // 反转
}).Val())
// [张三 李四]
// 6. 带分数一起查出来
// 用 ZRangeArgsWithScores 替代旧写法,返回 []redis.Z(含 Member 和 Score)
fmt.Println(client.ZRangeArgsWithScores(ctx, redis.ZRangeArgs{
Key: "class",
Start: "0",
Stop: "100",
ByScore: true,
}).Val())
// [{46 李四} {80 张三}] ← redis.Z 的字段顺序是 Score 在前、Member 在后
// 7. 查询指定成员的分数
fmt.Println(client.ZScore(ctx, "class", "李四").Val())
// 46
}redis常用命令
127.0.0.1:6379> select 2 # 切换数据库
OK
127.0.0.1:6379[2]> select 0
OK
127.0.0.1:6379> keys * # 查看所有的key
1) "zset"
2) "order"
127.0.0.1:6379> keys s* # 查看s开头的key
1) "set"
2) "set2"
3) "set1"
127.0.0.1:6379> ping # 查看服务是否运行
PONG
127.0.0.1:6379> move name 1 # 将当前数据库的key移动到数据库1当中
(integer) 1
127.0.0.1:6379[1]> PERSIST name # 移除key的过期时间,key永不过期
(integer) 1
127.0.0.1:6379[1]> type name # 查看key的数据类型
string
127.0.0.1:6379[1]> type info
hash
127.0.0.1:6379[1]> type class
zset
127.0.0.1:6379[1]> type set
set
127.0.0.1:6379[1]> type list
list
127.0.0.1:6379[1]> CLIENT LIST # 获取连接到服务器的客户端连接列表
id=4 addr=127.0.0.1:52377 fd=7 name= age=5 idle=3 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=0 obl=0 oll=0 omem=0 events=r cmd=client
127.0.0.1:6379[1]> dbsize # 获取当前数据库key的数量
(integer) 5
127.0.0.1:6379[1]> FLUSHDB # 删除当前数据库的所有key
OK
127.0.0.1:6379[1]> FLUSHALL # 删除全部数据库的所有key
OK事务
Redis 事务的本质是一组会被串行执行的命令集合,区别于传统关系型数据库的事务,Redis 事务和 MySQL 事务最大的区别在于:它保证的是执行过程不被打断,但不保证出错后能“回滚”
127.0.0.1:6379> multi # 开启事务
OK
127.0.0.1:6379> set name ff
QUEUED
127.0.0.1:6379> get name
QUEUED
127.0.0.1:6379> exec # 执行事务
1) OK
2) "ff"取消事务
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set age 12
QUEUED
127.0.0.1:6379> discard # 取消事务
OK
127.0.0.1:6379> get age # 数据未改动
(nil)如果在事务过程中,存在命令语法错误,则执行exec的时候,所有命令都不会执行
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set age 12
QUEUED
127.0.0.1:6379> x
(error) ERR unknown command 'x'
127.0.0.1:6379> get age
QUEUED
127.0.0.1:6379> exec
(error) EXECABORT Transaction discarded because of previous errors.go处理redis事务
和命令操作稍微有点不太一样
// Redis事务
func RedisPipelineAndAtomicty(client *redis.Client) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// ==========================================
// 1. Redis Pipeline (管道) 演示
// ==========================================
// 作用:把多条命令打包,一次性发给 Redis 服务器,再一次性读回结果。
// 好处:极大地减少了网络往返次数,提高了高并发下的吞吐量。
fmt.Println("=== 1. Pipeline 管道演示 ===")
// 获取一个管道对象(TxPipeline 会保证命令不被其他客户端打断,但无法回滚)
pipe := client.TxPipeline()
// 将命令写入管道(此时还没有真正发送给 Redis)
pipe.Set(ctx, "pipeline_name", "磊哥", 0)
val := pipe.Get(ctx, "pipeline_name")
// ⚠️ 注意:这里打印不出结果!因为命令还在内存管道里,没发给 Redis。
fmt.Println("[管道未提交] 结果:", val.Val()) // 输出为空
// 一次性将管道里的所有命令发送到 Redis 执行,并获取结果
_, err := pipe.Exec(ctx)
if err != nil {
fmt.Println("提交管道事务失败:", err)
return
}
// 命令执行完毕后,现在可以通过之前的 val 拿到结果了
fmt.Println("[管道已提交] 结果:", val.Val()) // 输出: 磊哥
// ==========================================
// 2. 核心对比与局限性解析 (注释详解)
// ==========================================
fmt.Println("\n=== 2. 核心对比解析 ===")
/*
🔴 Redis 原生事务 (TxPipeline) 的致命局限:
1. 无法回滚:Redis 事务没有 "ROLLBACK" 机制。如果在 EXEC 执行期间,
中间某条命令报错(例如用 Get 命令去操作一个 Hash 类型的 key),
Redis 会忽略这个错误,继续把后面所有的命令执行完。
2. 不支持条件逻辑:事务里只能排队执行命令。你不能写 "如果 key 存在就删除,
不存在就赋值" 这种带 if/else 逻辑的脚本。
👉 适用场景:多条命令之间没有逻辑依赖,仅仅为了减少网络请求次数(批量处理)。
*/
/*
🟡 为什么 Lua 脚本更常用?
Lua 脚本将 "读取 -> 业务判断 -> 写入" 打包成一个整体。
Redis 在执行 Lua 脚本时,会把其他所有客户端的命令都挂起。
👉 适用场景:实现真正的业务原子性。例如:扣减库存(先判断库存够不够,够就减)、
分布式锁的安全释放(GET 比对 value,匹配才 DEL,防止误删别人的锁)。
*/
/*
🟢 什么时候用 Lock (分布式锁)?
Lock 不是用来做数据计算的,而是用来做 "互斥资源控制" 的。
👉 适用场景:防止并发问题。例如:高并发抢购时,防止同一个用户生成多笔订单;
多个微服务同时去定时任务,只允许其中一个执行。
*/
}go处理redis原子性(Lua脚本)
Lua 脚本是 Redis 实现"读-判断-写"原子性的首选方案,被广泛用于分布式锁的安全释放
// Lua脚本
func RedisLuaScript(client *redis.Client) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 1. 模拟加锁
client.Set(ctx, "my_lock", "张三", 30*time.Second)
fmt.Println("当前锁的持有者:", client.Get(ctx, "my_lock").Val())
// 2. 编写 Lua 脚本
// 逻辑:先读锁的值(KEYS[1]),如果等于传入的值(ARGV[1])就删除它,返回1(成功);否则返回0(失败)
unlockScript := `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`
// 3. 尝试用 Lua 脚本安全释放锁
// 场景 A: 持有者确实是 "张三",应该删除成功
resultA := client.Eval(ctx, unlockScript, []string{"my_lock"}, "张三")
fmt.Println("场景A(正确持有者)释放结果:", resultA.Val()) // 输出: 1
// 4. 重新上锁
client.Set(ctx, "my_lock", "李四", 30*time.Second)
// 场景 B: "王五" 假装自己是持有者来解锁,应该失败
resultB := client.Eval(ctx, unlockScript, []string{"my_lock"}, "王五")
fmt.Println("场景B(错误持有者)释放结果:", resultB.Val()) // 输出: 0
fmt.Println("验证锁是否还在:", client.Get(ctx, "my_lock").Val()) // 输出: 李四 (锁没有被误删)
}锁(WATCH乐观锁)
多个客户端有可能同时操作同一组数据,并且该数据一旦被操作修改后,将不适用于继续操作 在操作之前锁定要操作的数据,一旦发生变化,终止当前操作
127.0.0.1:6379[1]> set price 100
OK
127.0.0.1:6379[1]> WATCH price // 检测这个key,如果key在事务期间发生了变化,则事务不成功
OK
127.0.0.1:6379[1]> MULTI
OK
127.0.0.1:6379[1]> set price 101
QUEUED
127.0.0.1:6379[1]> get price
QUEUED
127.0.0.1:6379[1]> exec // 在exec之前,再用一个客户端去修改这个price的值,exec返回nil
(nil)go处理redis锁(WATCH)
// Watch 乐观锁
func RedisWatch(client *redis.Client) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
key := "price"
// 为了方便测试,先给 price 设个初始值
client.Set(ctx, key, 100, 0)
fmt.Println("=== 乐观锁 (Watch) 机制演示 ===")
fmt.Println("初始价格:", client.Get(ctx, key).Val())
// ==========================================
// Watch 核心机制:监听 key,并在事务中提交更改
// ==========================================
err := client.Watch(ctx, func(tx *redis.Tx) error {
// 1. 读取当前值 (在 Redis 内部完成)
n, err := tx.Get(ctx, key).Int()
if err != nil && err != redis.Nil {
return err
}
// 2. 模拟业务处理耗时(比如去算账)
// 在这 3 秒钟内,如果有其他客户端修改了 price,这次事务就会自动失败。
fmt.Println(">>> 正在处理价格业务,预计耗时3秒...")
time.Sleep(3 * time.Second)
// 3. 提交更改 (price + 1)
_, err = tx.Pipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Set(ctx, key, n+1, 0)
return nil
})
return err
}, key)
// 4. 打印最终结果
if err != nil {
fmt.Println("❌ 事务提交失败!因为在这期间 price 被其他客户端修改了,自动回滚。")
} else {
fmt.Println("✅ 事务提交成功!当前价格已更新。")
}
fmt.Println("最终价格:", client.Get(ctx, key).Val())
}测试时可在
Watch内time.Sleep期间另开一个客户端执行set price 200,即可观察到exec失败(返回redis.TxFailedErr)的效果
分布式锁(SET NX 悲观锁)
面试高频。上一节的 WATCH 是乐观锁,这里的 SET NX 是悲观锁 / 互斥锁,两者是分布式锁的两种不同思路
核心原理:先把 NX 理解透
Redis 的每个 key 都是全局唯一的,同一时刻只能存一份值。所以只要约定「谁把值写进了这个 key,谁就持有锁」,就能实现互斥:想进临界区,就必须等这个 key 被删掉或过期
SET key value:无条件写入(不管里面有没有东西)SET key value NX:仅当 key 不存在时才写入(NX = Not eXists),这就是"加锁"SET key value NX EX 30:仅在 key 不存在时写入,并设置 30 秒过期SET key value XX:仅当 key 已存在时才写入(XX = eXists),常用于锁续期
为什么必须写在一条命令里:Redis 命令是单线程执行的,一条命令中途不会被打断。SET ... NX 把「检查 key 是否存在 + 写入 + 设置过期」放在一条命令中原子完成,中间插不进其他客户端的操作。如果拆成两步(先 GET 判断、再 SET),两步之间就可能被别人抢先,这就是竞态
# 加锁:成功返回 OK,失败返回 nil
127.0.0.1:6379> SET lock:order:1001 user-A NX EX 30
OK
# key 已存在,NX 条件不满足,加锁失败
127.0.0.1:6379> SET lock:order:1001 user-B NX EX 30
(nil)
# 老式写法:SETNX 只能"设置",不能同时设置过期,两步之间可能死锁
127.0.0.1:6379> SETNX lock:order:1001 user-A
(integer) 1
127.0.0.1:6379> EXPIRE lock:order:1001 30
(integer) 1三个必须知道的点:
必须带过期时间(EX):持锁的进程可能崩溃。没有过期时间的 key 会永远存在,谁也别想再拿到锁 → 死锁
value 必须每个持有者唯一:释放锁时要靠它确认"这把锁还是我的"。真实项目里用 UUID / 请求 id
解锁必须比对 value 再删:直接
DEL有可能删掉别人的锁(自己的锁已超时、又被别人拿到)。而且"比对 + 删除"也要用 Lua 保证原子性
go处理分布式锁(SET NX)
对应
pkg/utils.go中的RedisDistributedLockNX。用 go-redis/v9 的SetArgs{Mode: "NX", TTL: ...},等价于命令SET key value NX EX 30
// 分布式锁 (SET NX)
func RedisDistributedLockNX(client *redis.Client) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
key := "lock:order:1001" // 锁的 key,一般用"业务前缀 + 资源 id"命名
// 锁的 value 必须每个持有者唯一,释放时才能校验"这是不是我的锁"
// (真实项目里通常用 UUID / 请求 id,这里用时间戳模拟)
token := fmt.Sprintf("user-A-%d", time.Now().UnixNano())
// 先删掉上次运行残留的锁,保证重复运行结果稳定
client.Del(ctx, key)
fmt.Println("=== 分布式锁 (SET NX) 机制演示 ===")
// ==========================================
// 1. 错误示范:SETNX + EXPIRE 分成两条命令
// ==========================================
// 问题:两条命令之间如果程序崩溃或网络中断,过期时间没设上,
// 这个 key 就永远存在,别人再也拿不到锁 → 死锁。
// 而且 SETNX 本身也是非原子的(只能"设置",不能"设置 + 过期"一起做)。
ok, err := client.SetNX(ctx, key, token, 0).Result() // 0 表示不设过期时间
if err != nil {
fmt.Println("① SetNX 执行出错:", err)
return
}
fmt.Println("① SetNX 是否拿到锁:", ok) // true
client.Expire(ctx, key, 30*time.Second)
fmt.Println("① 补设过期时间 TTL:", client.TTL(ctx, key).Val()) // 30s
// ==========================================
// 2. 正确姿势:SET key value NX EX 一步到位(原子操作)
// ==========================================
// 用 SetArgs + Mode:"NX" + TTL,等价于命令:SET key value NX EX 30
// 加锁失败不是"报错",而是返回 redis.Nil(表示 key 已存在,没抢到)
client.Del(ctx, key) // 先释放上面的锁,方便演示
_, err = client.SetArgs(ctx, key, token, redis.SetArgs{
Mode: "NX", // 仅当 key 不存在时才设置
TTL: 30 * time.Second, // 同时设置过期时间,防止持锁者崩溃导致死锁
}).Result()
if err == redis.Nil {
fmt.Println("② 加锁失败:锁已被其他客户端持有")
} else if err != nil {
fmt.Println("② 加锁出错:", err)
return
} else {
fmt.Println("② 加锁成功,锁的持有者:", client.Get(ctx, key).Val()) // user-A-xxx
}
// ==========================================
// 3. 模拟另一个客户端来抢同一把锁,必然失败
// ==========================================
_, err = client.SetArgs(ctx, key, "user-B", redis.SetArgs{
Mode: "NX",
TTL: 30 * time.Second,
}).Result()
fmt.Println("③ 客户端B 是否抢到锁:", err != redis.Nil) // false,抢锁失败
// ==========================================
// 4. 释放锁:先比对 value,确认是自己的锁再删,否则会误删别人的锁
// ==========================================
// 复用 Lua 脚本实现"读-判断-删"的原子性,思路和 RedisLuaScript 里的解锁完全一致
unlockScript := `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`
// 场景 A: 用错误的 token 释放,应该失败,锁完好无损
resultA := client.Eval(ctx, unlockScript, []string{key}, "user-B")
fmt.Println("④ 错误持有者尝试释放:", resultA.Val()) // 0
fmt.Println(" 验证锁是否还在:", client.Get(ctx, key).Val()) // user-A-xxx
// 场景 B: 用正确的 token 释放,应该成功
resultB := client.Eval(ctx, unlockScript, []string{key}, token)
fmt.Println("④ 正确持有者释放锁:", resultB.Val()) // 1
fmt.Println(" 释放后锁是否存在:", client.Exists(ctx, key).Val()) // 0
}go-redis 的两个使用要点:
SetNX(ctx, key, token, 0)的第 4 个参数是过期时间,0表示不设过期;返回值true表示原来 key 不存在、加锁成功加锁失败不是返回
false,而是err == redis.Nil,这是 go-redis 最容易踩的坑释放锁复用 Lua 脚本,和上面
RedisLuaScript里的解锁思路完全一致
WATCH 与 SET NX 的区别
持久化
Redis是一个内存数据库,数据保存在内存中,但是我们都知道内存的数据变化是很快的,也容易发生丢失 辛好Redis还为我们提供了持久化的机制,分别是RDB(Redis DataBase)和AOF(Append Only File)
RDB:就是在不同的时间点,将redis存储的数据生成快照并存储到磁盘等介质上;
AOF:则是换了一个角度来实现持久化,那就是将redis执行过的所有写指令记录下来,在下次redis重新启动时,只要把这些写指令从前到后再重复执行一遍,就可以实现数据恢复了
redis数据落盘的完整过程:
客户端向服务端发送写操作(数据在客户端的内存中)
数据库服务端接收到写请求的数据(数据在服务端的内存中)
服务端调用write这个系统调用,将数据往磁盘上写(数据在系统内存的缓冲区中)
操作系统将缓冲区中的数据转移到磁盘控制器上(数据在磁盘缓存中)
磁盘控制器将数据写到磁盘的物理介质中(数据真正落到磁盘上)
RDB持久化
默认是开启的,默认的存储文件是 dump.rdb
dbfilename dump.rdb
dir ./触发rdb备份:
配置自动备份
save 3600 1
save 300 100
save 60 10000含义:3600秒至少1次修改;300秒至少100次修改;60秒至少10000次修改,触发bgsave
手动命令备份
save:主线程阻塞做快照,生产不建议使用bgsave:redis会fork子进程,后台异步进行快照操作,快照同时还可以响应客户端请求lastsave:获取最后一次成功生成快照的时间戳
AOF持久化
AOF默认是关闭的,需要手动开启,修改redis.conf配置文件
phpstudy环境要修改redis实际目录下的conf,软件面板内修改重启会丢失
appendonly yes
appendfilename "appendonly.aof"
# appendfsync always
appendfsync everysec
# appendfsync no
dir ./appendfsync always:每次写入立即同步磁盘,最安全,性能差appendfsync everysec:每秒同步(默认推荐),最多丢失1秒数据appendfsync no:交给操作系统刷盘,不可控,丢失数据风险大
Redis
本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。



评论交流
欢迎留下你的想法