Java 转 go 学习 - Redis(2)

文章目录

  • [1. Pipeline](#1. Pipeline)
  • [2. 事务](#2. 事务)
  • [3. 结构体存储](#3. 结构体存储)
  • [4. Pub/Sub 发布订阅](#4. Pub/Sub 发布订阅)
  • [5. Lua 脚本](#5. Lua 脚本)
  • [6. 滑动窗口](#6. 滑动窗口)
  • [7. 小结](#7. 小结)

本系列文章:


1. Pipeline

正常情况下命令执行的流程就是:发一条命令 → 等待 Redis 返回 → 再发下一条。

但是如果需要执行多条命令就需要进行多次命令发送,也就是有多次网络往返的开销,Pipeline 就是解决这个问题,会把 多条命令打包一次发给 Redis → 一次性拿回所有结果,下面是 pipeline 的两种写法。

  1. 通过 Pipeline() 先获取管道,再通过 Set 方法添加要设置的 k-v。
  2. 通过 Pipelined 方法直接在匿名函数里面设置要添加的 k-v。
go 复制代码
func (s *RedisService) PipelineSet(ctx context.Context, kv map[string]string, expire time.Duration) error {
	pipe := s.rdb.Pipeline()
	defer pipe.Discard()

	for k, v := range kv {
		pipe.Set(ctx, k, v, expire)
	}
	// 统一执行命令
	_, err := pipe.Exec(ctx)
	return err
}

func (s *RedisService) PipelinedSet(ctx context.Context, kv map[string]string, expire time.Duration) error {
	// 更加间接的写法
	_, err := s.rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error {
		for k, v := range kv {
			pipe.Set(ctx, k, v, expire)
		}
		return nil
	})
	return err
}

执行的例子和结果如下。

go 复制代码
func (s *RedisService) RunDemo(ctx context.Context) error {
	if err := s.PipelineSet(ctx, map[string]string{
		"demo:pipe:k1": "v1",
		"demo:pipe:k2": "v2",
	}, 60*time.Second); err != nil {
		return err
	}
	fmt.Println("PipelineSet: ok")

	if err := s.PipelinedSet(ctx, map[string]string{
		"demo:pipelined:k1": "v1",
		"demo:pipelined:k2": "v2",
	}, 60*time.Second); err != nil {
		return err
	}
	fmt.Println("PipelinedSet: ok")

	return nil
}

2. 事务

Redis 的事务能确保原子性,也就是所有命令要么全部执行,要么全部不执行,但是出错了没办法回滚,下面是 Go 使用事务的几个命令。

  • TxPipeline 开启事务。
  • Exec 执行事务。
  • Discard 取消事务。

下面来看下事务的简单用法。

go 复制代码
func (s *RedisService) TxPipelineSet(ctx context.Context, kv map[string]string, expire time.Duration) error {
	// 开启事务
	pipe := s.rdb.TxPipeline()
	// 记得取消事务
	defer pipe.Discard()

	// 设置执行命令
	for k, v := range kv {
		pipe.Set(ctx, k, v, expire)
	}
	// 执行事务
	_, err := pipe.Exec(ctx)
	return err
}

那如果有多个事务同时执行,在当前事务执行的时候其他事务已经把当前事务读到的 key 修改了,这种情况下就会造成 value 的覆盖,这种情况下我们可以用 Watch + 事务 来监听某个 key,如果在事务执行前发现 key 已经修改过,事务就会直接失败,我们可以重试确保安全。

go 复制代码
func (s *RedisService) WatchIncr(ctx context.Context, key string, delta int64) error {
	return s.rdb.Watch(ctx, func(tx *redis.Tx) error {
		// 先获取当前值
		current, err := tx.Get(ctx, key).Int64()
		if err != nil && err != redis.Nil {
			return err
		}
		// 要更新的值
		next := current + delta

		// CAS 更新, 通过匿名函数, 简单写法
		_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
			pipe.Set(ctx, key, strconv.FormatInt(next, 10), 0)
			return nil
		})
		return err
	// 监听 key	
	}, key)
}

这里通过 Watch 对 key 监听,所以外层调用需要检测是否失败,如果失败需要有重试机制。

go 复制代码
// 简单重试
for i := 0; i < 10; i++ {
    err := WatchIncr(ctx, key, delta)
    if err == nil {
        break
    }
}

下面是两种用法的简单测试示例。

go 复制代码
func (s *RedisService) RunDemo(ctx context.Context) error {

	if err := s.TxPipelineSet(ctx, map[string]string{
		"demo:txpipe:k1": "tv1",
		"demo:txpipe:k2": "tv2",
	}, 60*time.Second); err != nil {
		return err
	}
	fmt.Println("TxPipelineSet: ok")

	if err := s.SetString(ctx, "demo:tx:counter", "0"); err != nil {
		return err
	}
	// 这里就不重试了
	if err := s.WatchIncr(ctx, "demo:tx:counter", 5); err != nil {
		return err
	}
	counter, err := s.GetString(ctx, "demo:tx:counter")
	if err != nil {
		return err
	}
	fmt.Println("WatchIncr counter:", counter)
	
	// TxPipelineSet: ok
	// WatchIncr counter: 5

	return nil
}

3. 结构体存储

实际项目里面我们都会将结构体转成 JSON 存到 Redis,然后读取的时候再把 JSON 从 Redis 中读出来,然后还原成结构体。

go 复制代码
func (s *RedisService) SetJSON(ctx context.Context, key string, value any, expire ...time.Duration) error {
	data, err := json.Marshal(value)
	if err != nil {
		return fmt.Errorf("json marshal failed: %w", err)
	}

	ttl := time.Duration(0)
	if len(expire) > 0 && expire[0] > 0 {
		ttl = expire[0]
	}

	return s.rdb.Set(ctx, key, data, ttl).Err()
}

func (s *RedisService) GetJSON(ctx context.Context, key string, dst any) error {
	raw, err := s.rdb.Get(ctx, key).Result()
	if err != nil {
		return err
	}

	if err := json.Unmarshal([]byte(raw), dst); err != nil {
		return fmt.Errorf("json unmarshal failed: %w", err)
	}
	return nil
}

这两个方法就是通用的 get 和 set 方法,结构体类型都用 any 来表示,也就是 interface{}。下面是调用的示例。

go 复制代码
type UserProfile struct {
	Name  string `json:"name"`
	Age   int    `json:"age"`
	Phone string `json:"phone"`
}

func (s *RedisService) RunDemo(ctx context.Context) error {
	user := UserProfile{Name: "Alice", Age: 20, Phone: "18800000000"}
	if err := s.SetJSON(ctx, "demo:json:user:1", user, 60*time.Second); err != nil {
		return err
	}
	fmt.Println("SetJSON: ok")

	var got UserProfile
	if err := s.GetJSON(ctx, "demo:json:user:1", &got); err != nil {
		return err
	}
	fmt.Println("GetJSON:", got)
	return nil

	// SetJSON: ok
	// GetJSON: {Alice 20 18800000000}
}

4. Pub/Sub 发布订阅

一般来说都用 RocketMQ/Kafka 等消息队列,不过既然学了 Redis 也可以看下 Go 语言怎么使用发布订阅模式,下面是订阅的代码。

go 复制代码
func (s *RedisService) SubscribeSingle(ctx context.Context, channel string) (*redis.PubSub, error) {
	// 订阅单个通道
	pubSub := s.rdb.Subscribe(ctx, channel)
	// 等待订阅确认
	if _, err := pubSub.Receive(ctx); err != nil {
		_ = pubSub.Close()
		return nil, err
	}
	return pubSub, nil
}

func (s *RedisService) SubscribeMulti(ctx context.Context, channels ...string) (*redis.PubSub, error) {
	// 订阅多个通道
	pubSub := s.rdb.Subscribe(ctx, channels...)
	// 等待订阅确认
	if _, err := pubSub.Receive(ctx); err != nil {
		_ = pubSub.Close()
		return nil, err
	}
	return pubSub, nil
}

func (s *RedisService) PSubscribe(ctx context.Context, patterns ...string) (*redis.PubSub, error) {
	// 订阅通道, 模糊匹配
	pubSub := s.rdb.PSubscribe(ctx, patterns...)
	// 等待订阅确认
	if _, err := pubSub.Receive(ctx); err != nil {
		_ = pubSub.Close()
		return nil, err
	}
	return pubSub, nil
}

然后就是发布消息的方法。

go 复制代码
// 发布消息
func (s *RedisService) Publish(ctx context.Context, channel, payload string) error {
	return s.rdb.Publish(ctx, channel, payload).Err()
}

我们可以通过 Channel() 方法来获取到各个 Sub 方法返回的通道,然后通过 Select 来监听。

go 复制代码
func receiveOneMessageAsync(msgCh <-chan *redis.Message, timeout time.Duration) <-chan messageResult {
	resultCh := make(chan messageResult, 1)
	go func() {
		msg, err := receiveOneMessage(msgCh, timeout)
		resultCh <- messageResult{msg: msg, err: err}
	}()
	return resultCh
}

最后再来看下主程序的示例代码和输出。

go 复制代码
func (s *RedisService) RunDemo(ctx context.Context) error {
	// 订阅 demo:pubsub:single 通道
	singleSub, err := s.SubscribeSingle(ctx, "demo:pubsub:single")
	if err != nil {
		return err
	}
	defer singleSub.Close()
	// 发布消息到 demo:pubsub:single
	if err := s.Publish(ctx, "demo:pubsub:single", "hello-single"); err != nil {
		return err
	}
	// goroutine 获取消息输出到 singleResult 这个只读通道, 然后在这里获取结果
	singleResult := <-receiveOneMessageAsync(singleSub.Channel(), 2*time.Second)
	if singleResult.err != nil {
		return singleResult.err
	}
	fmt.Printf("Single channel => channel=%s payload=%s\n", singleResult.msg.Channel, singleResult.msg.Payload)

	// 订阅 demo:pubsub:c1, demo:pubsub:c2 通道
	multiSub, err := s.SubscribeMulti(ctx, "demo:pubsub:c1", "demo:pubsub:c2")
	if err != nil {
		return err
	}
	defer multiSub.Close()
	// goroutine 获取消息输出到 multiSub 这个只读通道, 然后在这里获取结果
	if err := s.Publish(ctx, "demo:pubsub:c2", "hello-multi"); err != nil {
		return err
	}
	multiResult := <-receiveOneMessageAsync(multiSub.Channel(), 2*time.Second)
	if multiResult.err != nil {
		return multiResult.err
	}
	fmt.Printf("Multi channel => channel=%s payload=%s\n", multiResult.msg.Channel, multiResult.msg.Payload)

	// 订阅 demo:pubsub:user: 开头的通道
	patternSub, err := s.PSubscribe(ctx, "demo:pubsub:user:*")
	if err != nil {
		return err
	}
	defer patternSub.Close()
	// goroutine 获取消息输出到 patternSub 这个只读通道, 然后在这里获取结果
	if err := s.Publish(ctx, "demo:pubsub:user:1001", "hello-pattern"); err != nil {
		return err
	}
	patternResult := <-receiveOneMessageAsync(patternSub.Channel(), 2*time.Second)
	if patternResult.err != nil {
		return patternResult.err
	}
	fmt.Printf("Pattern channel => pattern=%s channel=%s payload=%s\n", patternResult.msg.Pattern, patternResult.msg.Channel, patternResult.msg.Payload)

	return nil

	// Single channel => channel=demo:pubsub:single payload=hello-single
	// Multi channel => channel=demo:pubsub:c2 payload=hello-multi
	// Pattern channel => pattern=demo:pubsub:user:* channel=demo:pubsub:user:1001 payload=hello-pattern
}

5. Lua 脚本

Lua 的作用就不多说了,把多条命令打包成一个脚本来执行,下面主要来看下如何执行 lua 脚本。

go 复制代码
func (s *RedisService) LuaIncrOrInit(ctx context.Context, key string, init, delta int64, expireSec int64) (int64, error) {
	// 计数, 如果不存在 key 就设置 key, 存在就 +1, 返回 inc 的结果
	const script = `
		local exists = redis.call("EXISTS", KEYS[1])
		if exists == 0 then
		  redis.call("SET", KEYS[1], ARGV[1], "EX", ARGV[3])
		  return tonumber(ARGV[1])
		end
		return redis.call("INCRBY", KEYS[1], ARGV[2])
`
	// 执行 lua 脚本
	res, err := s.EvalLua(ctx, script, []string{key}, init, delta, expireSec)
	if err != nil {
		return 0, err
	}
	switch v := res.(type) {
	case int64:
		return v, nil
	case string:
		return strconv.ParseInt(v, 10, 64)
	default:
		return 0, fmt.Errorf("unexpected lua result type: %T", res)
	}
}
  • KEYS1:传入的第一个键(推荐用 KEYS 传所有键)
  • 后面的 init、delta、expireSec 作为 ARGV 传进去

下面是调用示例。

go 复制代码
func (s *RedisService) RunDemo(ctx context.Context) error {
	luaKey := "demo:lua:counter"
	if _, err := s.Del(ctx, luaKey); err != nil {
		return err
	}
	v1, err := s.LuaIncrOrInit(ctx, luaKey, 100, 5, 60)
	if err != nil {
		return err
	}
	v2, err := s.LuaIncrOrInit(ctx, luaKey, 100, 5, 60)
	if err != nil {
		return err
	}
	fmt.Printf("LuaIncrOrInit => first=%d second=%d\n", v1, v2)

	// LuaIncrOrInit => first=100 second=105
	return nil
}

6. 滑动窗口

下面我们看下用 Redis 实现一个滑动窗口,主要用的就是 zset,score 就是时间,下面使用 pipeline 来统计计数,实际上用 lua 表达式会比较好。

go 复制代码
func (l *SlidingWindowLimiter) Allow(ctx context.Context, userKey string) (bool, int64, error) {
	redisKey := l.prefix + userKey
	nowMs := time.Now().UnixMilli()
	windowStartMs := nowMs - l.window.Milliseconds()
	member := fmt.Sprintf("%d-%d", nowMs, time.Now().UnixNano())
	// 开启事务
	pipe := l.rdb.TxPipeline()
	// 删除 [0, nowMs - l.window.Milliseconds()) 区间的计数
	pipe.ZRemRangeByScore(ctx, redisKey, "0", strconv.FormatInt(windowStartMs, 10))
	// 计数 + 1
	pipe.ZAdd(ctx, redisKey, redis.Z{Score: float64(nowMs), Member: member})
	// 然后统计计数
	countCmd := pipe.ZCard(ctx, redisKey)
	// 设置过期时间, time.Second 是留一点缓冲
	pipe.Expire(ctx, redisKey, l.window+time.Second)
	if _, err := pipe.Exec(ctx); err != nil {
		return false, 0, err
	}

	// 满了
	current := countCmd.Val()
	if current > l.limit {
		_, _ = l.rdb.ZRem(ctx, redisKey, member).Result()
		return false, 0, nil
	}
	remaining := l.limit - current
	if remaining < 0 {
		remaining = 0
	}

	return true, remaining, nil
}

接下来看下调用示例。

go 复制代码
func RunSlidingWindowDemo(ctx context.Context, rdb *redis.Client) error {
	limiter := NewSlidingWindowLimiter(rdb, "sliding:demo:", 5, 10*time.Second)
	user := "user:1001"

	for i := 1; i <= 8; i++ {
		allowed, remain, err := limiter.Allow(ctx, user)
		if err != nil {
			return err
		}
		fmt.Printf("request=%d allowed=%v remaining=%d\n", i, allowed, remain)
		time.Sleep(500 * time.Millisecond)
	}

	// request=1 allowed=true remaining=4
	// request=2 allowed=true remaining=3
	// request=3 allowed=true remaining=2
	// request=4 allowed=true remaining=1
	// request=5 allowed=true remaining=0
	// request=6 allowed=false remaining=0
	// request=7 allowed=false remaining=0
	// request=8 allowed=false remaining=0

	return nil
}

7. 小结

redis 就先学到这里,主要演示了下一些常用的方法,后面继续学习 mysql 的相关内容。

如有错误,欢迎指出!!!

相关推荐
Misnearch2 小时前
agent架构学习
学习·架构
li星野2 小时前
【学习记录】USB连接与枚举全解析:从D+/D-上拉到pyOCD烧录
学习
我命由我123452 小时前
Photoshop - Photoshop 使用魔棒工具选择单独的区域
学习·ui·职场和发展·求职招聘·职场发展·学习方法·photoshop
JWASX2 小时前
Java 转 go 学习 - kitex(1)
学习·golang
li星野2 小时前
HAVE — have / has / had (One-Page Notes)
学习
传奇开心果编程2 小时前
【声明式UI开发实用技术学与练】第8课 状态提升与下放
学习·flutter·react native·ui·swiftui·composer
谢亮_vipxieliang2 小时前
Go select 多路复用:从语法到实战的完整指南
开发语言·后端·golang
阳光九叶草LXGZXJ11 小时前
达梦数据库-报错-15-列【XXX】长度超出定义
linux·运维·数据库·sql·学习
mlidongfeng14 小时前
【学习】mKernel 分析
学习