chore: code review and fix race conditions in redis module (by AI)

This commit is contained in:
AI Engineer 2026-05-03 09:47:14 +08:00
parent 69324d566e
commit 067e74d46d
6 changed files with 126 additions and 6 deletions

16
AI.md Normal file
View File

@ -0,0 +1,16 @@
# AI 调用规则 - redis
## 当前版本
v1.0.1
## AI 准则
- **类型安全**: 优先使用 `any` 替代 `interface{}`
- **泛型优先**: 结果转换应优先推荐使用 `To[T]`
- **防御性编程**: 在执行 `Do``Subscribe` 前必须检查 `pool` 是否初始化。
- **线程安全**: 所有对 `subs``subConn` 的操作必须持有 `subLock`
- **内存安全**: 避免在日志或错误信息中直接打印 Redis 密码,使用 `conf.Dsn()` 提供的脱敏版本。
## 核心 API 路径
- `GetRedis`: 入口函数,建议使用单例模式获取。
- `Result.To`: 底层反序列化逻辑。
- `IdMaker.makeSecIndex`: 分布式 ID 预取逻辑,步长固定为 100。

View File

@ -1,5 +1,14 @@
# CHANGELOG - redis
## v1.0.1 (2026-05-03)
- **Security & Stability**:
- 修复了 `Pub/Sub` 模块中 `subs` map 和 `subConn` 的并发访问竞争风险Race Condition引入 `subLock` 互斥锁。
- **Cleanup**:
- 移除了 `Redis` 结构体中冗余的 `ReadTimeout` 字段,统一由 `Config` 管理。
- **Testing**:
- 新增 `bench_test.go`,建立性能基准测试。
- 新增 `TEST.md` 记录测试覆盖场景与 Benchmark 结果。
## v1.0.0 (2026-05-03)
- **Repo Migration**: 从 `@ssgo/redis` 迁移至 `apigo.cc/go/redis`
- **Standard Realignment**:

25
TEST.md Normal file
View File

@ -0,0 +1,25 @@
# redis 模块测试报告
## 测试场景
1. **基础操作 (TestBase)**:
- 验证 `GET`, `SET`, `DEL`, `EXISTS`, `GETSET` 等基本命令。
- 验证 `EXPIRE` 自动过期功能。
- 验证结构体自动序列化与反序列化。
- 验证 `MSET`, `MGET` 批量操作。
2. **泛型支持 (TestGenerics)**:
- 验证 `To[T]` 泛型函数对结果的反序列化。
3. **发布订阅 (TestSub)**:
- 验证 `Subscribe`, `Unsubscribe`, `PUBLISH` 功能。
- 验证并发订阅与取消订阅的稳定性。
## Benchmark 结果
```bash
goos: darwin
goarch: amd64
pkg: apigo.cc/go/redis
cpu: Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz
BenchmarkGetSet-16 4057 283694 ns/op
BenchmarkIdMaker-16 338056 3377 ns/op
```
- `BenchmarkGetSet`: 每次 GET+SET 耗时约 283微秒受本地 Redis 响应速度影响)。
- `BenchmarkIdMaker`: 每次获取分布式 ID 耗时约 3.4微秒(得益于 100 步长的预取机制)。

36
bench_test.go Normal file
View File

@ -0,0 +1,36 @@
package redis_test
import (
"os"
"testing"
"apigo.cc/go/config"
"apigo.cc/go/redis"
)
func BenchmarkGetSet(b *testing.B) {
os.Setenv("REDIS_TEST", "redis://:@localhost:6379/2?timeout=10ms&logSlow=10us")
_ = config.Load("redis", nil)
rd := redis.GetRedis("test", nil)
rd.DEL("bench_key")
b.ResetTimer()
for i := 0; i < b.N; i++ {
rd.SET("bench_key", "bench_value")
_ = rd.GET("bench_key").String()
}
b.StopTimer()
rd.DEL("bench_key")
}
func BenchmarkIdMaker(b *testing.B) {
os.Setenv("REDIS_TEST", "redis://:@localhost:6379/2?timeout=10ms&logSlow=10us")
_ = config.Load("redis", nil)
rd := redis.GetRedis("test", nil)
im := redis.NewIdMaker(rd)
b.ResetTimer()
for i := 0; i < b.N; i++ {
im.Get(16)
}
}

View File

@ -22,11 +22,11 @@ import (
type Redis struct {
name string
pool *redis.Pool
ReadTimeout int
Config *Config
logger *log.Logger
Error error
subConn *redis.PubSubConn
subLock sync.RWMutex
subStopChan chan bool
subs map[string]*SubCallbacks
SubRunning bool
@ -139,7 +139,6 @@ func NewRedis(conf *Config, logger *log.Logger) *Redis {
}
rd := new(Redis)
rd.ReadTimeout = int(conf.ReadTimeout / time.Millisecond)
rd.pool = conn
rd.Config = conf
rd.logger = logger
@ -150,7 +149,6 @@ func NewRedis(conf *Config, logger *log.Logger) *Redis {
func (rd *Redis) CopyByLogger(logger *log.Logger) *Redis {
newRedis := new(Redis)
newRedis.name = rd.name
newRedis.ReadTimeout = rd.ReadTimeout
newRedis.pool = rd.pool
newRedis.subConn = rd.subConn
newRedis.subs = rd.subs

View File

@ -8,6 +8,9 @@ import (
)
func (rd *Redis) Subscribe(name string, reset func(), received func([]byte)) bool {
rd.subLock.Lock()
defer rd.subLock.Unlock()
if rd.subs == nil {
rd.subs = make(map[string]*SubCallbacks)
}
@ -24,6 +27,9 @@ func (rd *Redis) Subscribe(name string, reset func(), received func([]byte)) boo
}
func (rd *Redis) Unsubscribe(name string) bool {
rd.subLock.Lock()
defer rd.subLock.Unlock()
if rd.subs != nil {
delete(rd.subs, name)
}
@ -39,10 +45,13 @@ func (rd *Redis) Unsubscribe(name string) bool {
}
func (rd *Redis) Start() {
rd.subLock.Lock()
if rd.subs == nil {
rd.subs = make(map[string]*SubCallbacks)
}
rd.SubRunning = true
rd.subLock.Unlock()
subStartChan := make(chan bool)
go rd.receiveSub(subStartChan)
<-subStartChan
@ -50,14 +59,19 @@ func (rd *Redis) Start() {
func (rd *Redis) receiveSub(subStartChan chan bool) {
for {
if !rd.SubRunning {
rd.subLock.RLock()
running := rd.SubRunning
rd.subLock.RUnlock()
if !running {
break
}
// 开始接收订阅数据
rd.subLock.Lock()
if rd.subConn == nil {
conn, err := rd.GetConnection()
if err != nil {
rd.subLock.Unlock()
time.Sleep(time.Second)
continue
}
@ -72,6 +86,7 @@ func (rd *Redis) receiveSub(subStartChan chan bool) {
if err != nil {
_ = rd.subConn.Close()
rd.subConn = nil
rd.subLock.Unlock()
time.Sleep(time.Second)
continue
}
@ -83,6 +98,7 @@ func (rd *Redis) receiveSub(subStartChan chan bool) {
}
}
}
rd.subLock.Unlock()
if subStartChan != nil {
subStartChan <- true
@ -91,10 +107,20 @@ func (rd *Redis) receiveSub(subStartChan chan bool) {
for {
isErr := false
receiveObj := rd.subConn.Receive()
rd.subLock.RLock()
subConn := rd.subConn
rd.subLock.RUnlock()
if subConn == nil {
break
}
receiveObj := subConn.Receive()
switch v := receiveObj.(type) {
case redis.Message:
rd.subLock.RLock()
callback := rd.subs[v.Channel]
rd.subLock.RUnlock()
if callback != nil && callback.received != nil {
callback.received(v.Data)
}
@ -107,13 +133,19 @@ func (rd *Redis) receiveSub(subStartChan chan bool) {
if !strings.Contains(v.Error(), "connection closed") && !strings.Contains(v.Error(), "use of closed network connection") {
rd.logger.Error(v.Error(), "type", "redis", "action", "receiveSub")
}
rd.subLock.Lock()
if rd.subConn != nil {
_ = rd.subConn.Close()
rd.subConn = nil
}
rd.subLock.Unlock()
isErr = true
}
if isErr || !rd.SubRunning {
rd.subLock.RLock()
running = rd.SubRunning
rd.subLock.RUnlock()
if isErr || !running {
break
}
}
@ -124,6 +156,7 @@ func (rd *Redis) receiveSub(subStartChan chan bool) {
}
func (rd *Redis) Stop() {
rd.subLock.Lock()
if rd.SubRunning {
rd.subStopChan = make(chan bool)
rd.SubRunning = false
@ -137,8 +170,11 @@ func (rd *Redis) Stop() {
_ = rd.subConn.Close()
rd.subConn = nil
}
rd.subLock.Unlock()
<-rd.subStopChan
rd.subStopChan = nil
} else {
rd.subLock.Unlock()
}
}