From 7ceb13d438543ba5c14ed2fa4761908fa866c1f5 Mon Sep 17 00:00:00 2001 From: Yun Date: Wed, 17 Sep 2025 19:30:18 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=8D=95=E5=85=83=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 5 + go.sum | 12 ++ lockx.go | 4 + lockx_test.go | 473 ++++++++++++++++++++++++++++++++++++++++++++++++++ options.go | 20 ++- 5 files changed, 513 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 1e002f6..88ad6c5 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,14 @@ go 1.20 require ( github.com/go-redis/redis/v8 v8.11.5 github.com/google/uuid v1.6.0 + github.com/stretchr/testify v1.11.1 ) require ( github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 735ea23..13e4e4e 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= @@ -10,8 +12,18 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781 h1:DzZ89McO9/gWPsQXS/FVKAlG02ZjaQ6AlZRBimEYOd0= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM= golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lockx.go b/lockx.go index 090eab1..d98226c 100644 --- a/lockx.go +++ b/lockx.go @@ -212,3 +212,7 @@ func (g *GlobalLock) IsClosed() bool { defer g.closeLock.RUnlock() return g.isClosed } + +func (l *GlobalLock) GetValue() string { + return l.value +} diff --git a/lockx_test.go b/lockx_test.go index eb9927b..f69109d 100644 --- a/lockx_test.go +++ b/lockx_test.go @@ -2,12 +2,16 @@ package lockx_test import ( "context" + "errors" "fmt" "sync" "testing" "time" "github.com/go-redis/redis/v8" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" "github.com/yuninks/lockx" ) @@ -64,3 +68,472 @@ func TestLockx(t *testing.T) { wg.Wait() } + +// MockLogger 用于测试的模拟日志器 +type MockLogger struct { + mock.Mock +} + +func (m *MockLogger) Errorf(ctx context.Context, format string, args ...interface{}) { + m.Called(ctx, format, args) +} + +func (m *MockLogger) Infof(ctx context.Context, format string, args ...interface{}) { + m.Called(ctx, format, args) +} + +// 测试工具函数 +func setupTestRedis(t *testing.T) (redis.UniversalClient, func()) { + + client := redis.NewClient(&redis.Options{ + Addr: "127.0.0.1" + ":" + "6379", + Password: "123456", // no password set + DB: 0, // use default DB + }) + + return client, func() { + client.Close() + } +} + +func TestNewGlobalLock(t *testing.T) { + redisClient, teardown := setupTestRedis(t) + defer teardown() + + t.Run("成功创建锁", func(t *testing.T) { + ctx := context.Background() + lock, err := lockx.NewGlobalLock(ctx, redisClient, "test-key") + + require.NoError(t, err) + assert.NotEmpty(t, lock.GetValue()) + assert.False(t, lock.IsClosed()) + }) + + t.Run("创建锁时UUID生成失败", func(t *testing.T) { + // 这个测试需要模拟uuid.NewV7()失败,在实际中较难触发 + // 通常可以跳过或者使用mock来测试 + t.Skip("很难模拟UUID生成失败的情况") + }) +} + +func TestGlobalLock_Lock(t *testing.T) { + redisClient, teardown := setupTestRedis(t) + defer teardown() + + ctx := context.Background() + + t.Run("成功获取锁", func(t *testing.T) { + lock, err := lockx.NewGlobalLock(ctx, redisClient, "test-key-success") + require.NoError(t, err) + + success, err := lock.Lock() + require.NoError(t, err) + assert.True(t, success) + + defer lock.Unlock() + }) + + t.Run("获取已存在的锁失败", func(t *testing.T) { + // 第一个客户端获取锁 + lock1, err := lockx.NewGlobalLock(ctx, redisClient, "test-key-conflict") + require.NoError(t, err) + + success1, err := lock1.Lock() + require.NoError(t, err) + assert.True(t, success1) + + defer lock1.Unlock() + + // 第二个客户端尝试获取同一个锁 + lock2, err := lockx.NewGlobalLock(ctx, redisClient, "test-key-conflict") + require.NoError(t, err) + + success2, err := lock2.Lock() + require.NoError(t, err) + assert.False(t, success2) + + defer lock2.Unlock() + }) + + t.Run("Redis连接失败", func(t *testing.T) { + // 创建无效的Redis客户端 + invalidClient := redis.NewClient(&redis.Options{ + Addr: "invalid:6379", + }) + + _, err := lockx.NewGlobalLock(ctx, invalidClient, "test-key-fail") + require.NoError(t, err) + + // 设置短超时 + ctxShort, cancel := context.WithTimeout(ctx, 100*time.Millisecond) + defer cancel() + + lockWithCtx, err := lockx.NewGlobalLock(ctxShort, invalidClient, "test-key-fail") + require.NoError(t, err) + + success, err := lockWithCtx.Lock() + assert.Error(t, err) + assert.False(t, success) + }) +} + +func TestGlobalLock_Try(t *testing.T) { + redisClient, teardown := setupTestRedis(t) + defer teardown() + + ctx := context.Background() + + t.Run("尝试获取可用锁成功", func(t *testing.T) { + _, err := lockx.NewGlobalLock(ctx, redisClient, "test-try-success") + require.NoError(t, err) + + // 使用自定义选项 + customLock, err := lockx.NewGlobalLock(ctx, redisClient, "test-try-custom", + lockx.WithMaxRetryTimes(2), + lockx.WithRetryInterval(50*time.Millisecond), + ) + require.NoError(t, err) + + success, err := customLock.Try() + require.NoError(t, err) + assert.True(t, success) + + defer customLock.Unlock() + }) + + t.Run("尝试获取被占用的锁失败", func(t *testing.T) { + // 先获取锁 + lock1, err := lockx.NewGlobalLock(ctx, redisClient, "test-try-busy") + require.NoError(t, err) + + success1, err := lock1.Lock() + require.NoError(t, err) + assert.True(t, success1) + + // 另一个客户端尝试获取 + lock2, err := lockx.NewGlobalLock(ctx, redisClient, "test-try-busy", + lockx.WithMaxRetryTimes(2), + lockx.WithRetryInterval(10*time.Millisecond), + ) + require.NoError(t, err) + + success2, err := lock2.Try() + require.NoError(t, err) + assert.False(t, success2) + + lock1.Unlock() + lock2.Unlock() + }) + + t.Run("上下文取消", func(t *testing.T) { + ctxCancel, cancel := context.WithCancel(ctx) + lock, err := lockx.NewGlobalLock(ctxCancel, redisClient, "test-try-cancel") + require.NoError(t, err) + + cancel() // 立即取消 + + success, err := lock.Try() + assert.True(t, errors.Is(err, context.Canceled)) + assert.False(t, success) + }) +} + +func TestGlobalLock_Unlock(t *testing.T) { + redisClient, teardown := setupTestRedis(t) + defer teardown() + + ctx := context.Background() + + t.Run("成功释放锁", func(t *testing.T) { + lock, err := lockx.NewGlobalLock(ctx, redisClient, "test-unlock-success") + require.NoError(t, err) + + success, err := lock.Lock() + require.NoError(t, err) + assert.True(t, success) + + err = lock.Unlock() + assert.NoError(t, err) + assert.True(t, lock.IsClosed()) + + // 验证锁确实被释放了 + val, err := redisClient.Get(ctx, "test-unlock-success").Result() + assert.Equal(t, redis.Nil, err) + assert.Empty(t, val) + }) + + t.Run("重复释放锁", func(t *testing.T) { + lock, err := lockx.NewGlobalLock(ctx, redisClient, "test-unlock-twice") + require.NoError(t, err) + + success, err := lock.Lock() + require.NoError(t, err) + assert.True(t, success) + + err = lock.Unlock() + assert.NoError(t, err) + + // 再次释放应该不会报错 + err = lock.Unlock() + assert.NoError(t, err) + }) + + t.Run("释放其他客户端的锁", func(t *testing.T) { + lock1, err := lockx.NewGlobalLock(ctx, redisClient, "test-unlock-other") + require.NoError(t, err) + + success, err := lock1.Lock() + require.NoError(t, err) + assert.True(t, success) + + // 另一个客户端尝试释放(使用不同的value) + lock2, err := lockx.NewGlobalLock(ctx, redisClient, "test-unlock-other") + require.NoError(t, err) + + err = lock2.Unlock() + assert.Error(t, err) + assert.Contains(t, err.Error(), "lock was already released or owned by another client") + + lock1.Unlock() + lock2.Unlock() + }) +} + +func TestGlobalLock_Refresh(t *testing.T) { + redisClient, teardown := setupTestRedis(t) + defer teardown() + + ctx := context.Background() + + t.Run("自动刷新保持锁", func(t *testing.T) { + lock, err := lockx.NewGlobalLock(ctx, redisClient, "test-refresh", + lockx.WithExpiry(2*time.Second), + lockx.WithRefreshPeriod(500*time.Millisecond), + ) + require.NoError(t, err) + + success, err := lock.Lock() + require.NoError(t, err) + assert.True(t, success) + + // 等待一段时间,确保刷新机制工作 + time.Sleep(3 * time.Second) + + // 检查锁仍然存在 + val, err := redisClient.Get(ctx, "test-refresh").Result() + require.NoError(t, err) + assert.Equal(t, lock.GetValue(), val) + + // 检查TTL应该还在 + ttl, err := redisClient.TTL(ctx, "test-refresh").Result() + require.NoError(t, err) + assert.True(t, ttl > 0 && ttl <= 2*time.Second) + + lock.Unlock() + }) + + t.Run("锁丢失时停止刷新", func(t *testing.T) { + mockLogger := new(MockLogger) + mockLogger.On("Errorf", mock.Anything, mock.Anything, mock.Anything).Return() + + lock, err := lockx.NewGlobalLock(ctx, redisClient, "test-refresh-lost", + lockx.WithLogger(mockLogger), + lockx.WithExpiry(1*time.Second), + ) + require.NoError(t, err) + + success, err := lock.Lock() + require.NoError(t, err) + assert.True(t, success) + + // 手动删除锁来模拟锁丢失 + err = redisClient.Del(ctx, "test-refresh-lost").Err() + require.NoError(t, err) + + // 等待刷新周期 + time.Sleep(1500 * time.Millisecond) + + // 验证日志被调用 + mockLogger.AssertCalled(t, "Errorf", mock.Anything, mock.Anything, mock.Anything) + + lock.Unlock() + }) +} + +func TestGlobalLock_Concurrency(t *testing.T) { + redisClient, teardown := setupTestRedis(t) + defer teardown() + + ctx := context.Background() + + t.Run("多 goroutine 竞争锁", func(t *testing.T) { + const numGoroutines = 10 + var successCount int + var wg sync.WaitGroup + var mu sync.Mutex + + wg.Add(numGoroutines) + + for i := 0; i < numGoroutines; i++ { + go func(id int) { + defer wg.Done() + + lock, err := lockx.NewGlobalLock(ctx, redisClient, "concurrent-test") + require.NoError(t, err) + + success, err := lock.Lock() + require.NoError(t, err) + + if success { + mu.Lock() + successCount++ + mu.Unlock() + + // 持有锁一段时间 + time.Sleep(100 * time.Millisecond) + lock.Unlock() + } + }(i) + } + + wg.Wait() + + // 应该只有一个goroutine成功获取锁 + assert.Equal(t, 1, successCount) + }) +} + +func TestGlobalLock_ContextCancellation(t *testing.T) { + redisClient, teardown := setupTestRedis(t) + defer teardown() + + t.Run("上下文超时", func(t *testing.T) { + ctxShort, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + lock, err := lockx.NewGlobalLock(ctxShort, redisClient, "test-context-timeout") + require.NoError(t, err) + + // 等待上下文超时 + time.Sleep(200 * time.Millisecond) + + // 尝试操作应该失败 + success, err := lock.Lock() + assert.Error(t, err) + assert.False(t, success) + }) + + t.Run("上下文取消时自动释放锁", func(t *testing.T) { + ctxCancel, cancel := context.WithCancel(context.Background()) + lock, err := lockx.NewGlobalLock(ctxCancel, redisClient, "test-context-cancel") + require.NoError(t, err) + + success, err := lock.Lock() + require.NoError(t, err) + assert.True(t, success) + + // 取消上下文 + cancel() + + // 等待自动清理 + time.Sleep(5 * time.Second) + + // 检查锁应该被释放 + val, err := redisClient.Get(context.Background(), "test-context-cancel").Result() + assert.Equal(t, redis.Nil, err) + assert.Empty(t, val) + }) +} + +func TestGlobalLock_CustomOptions(t *testing.T) { + redisClient, teardown := setupTestRedis(t) + defer teardown() + + ctx := context.Background() + + t.Run("自定义配置选项", func(t *testing.T) { + mockLogger := new(MockLogger) + + lock, err := lockx.NewGlobalLock(ctx, redisClient, "test-custom-options", + lockx.WithLockTimeout(5*time.Second), + lockx.WithExpiry(10*time.Second), + lockx.WithRefreshPeriod(2*time.Second), + lockx.WithMaxRetryTimes(5), + lockx.WithRetryInterval(200*time.Millisecond), + lockx.WithLogger(mockLogger), + ) + require.NoError(t, err) + + success, err := lock.Lock() + require.NoError(t, err) + assert.True(t, success) + + // 检查TTL是否正确设置 + ttl, err := redisClient.TTL(ctx, "test-custom-options").Result() + require.NoError(t, err) + assert.True(t, ttl > 8*time.Second && ttl <= 10*time.Second) + + lock.Unlock() + }) +} + +func TestGlobalLock_EdgeCases(t *testing.T) { + redisClient, teardown := setupTestRedis(t) + defer teardown() + + ctx := context.Background() + + t.Run("空键名", func(t *testing.T) { + lock, err := lockx.NewGlobalLock(ctx, redisClient, "") + require.NoError(t, err) + + success, err := lock.Lock() + require.NoError(t, err) + assert.True(t, success) // Redis允许空键名 + + lock.Unlock() + }) + + t.Run("非常长的键名", func(t *testing.T) { + longKey := string(make([]byte, 1000)) // 很长的键名 + lock, err := lockx.NewGlobalLock(ctx, redisClient, longKey) + require.NoError(t, err) + + success, err := lock.Lock() + require.NoError(t, err) + assert.True(t, success) + + lock.Unlock() + }) +} + +// 基准测试 +func BenchmarkGlobalLock(b *testing.B) { + t := testing.T{} + redisClient, teardown := setupTestRedis(&t) + defer teardown() + + ctx := context.Background() + + b.Run("获取释放锁", func(b *testing.B) { + for i := 0; i < b.N; i++ { + lock, err := lockx.NewGlobalLock(ctx, redisClient, fmt.Sprintf("benchmark-%d", i)) + if err != nil { + b.Fatal(err) + } + + success, err := lock.Lock() + if err != nil { + b.Fatal(err) + } + if !success { + b.Fatal("Failed to acquire lock") + } + + err = lock.Unlock() + if err != nil { + b.Fatal(err) + } + } + }) +} diff --git a/options.go b/options.go index f9eeee5..e39d43a 100644 --- a/options.go +++ b/options.go @@ -35,7 +35,7 @@ func InitOption(opts ...Option) { type Option func(*option) -func WithTimeout(t time.Duration) Option { +func WithLockTimeout(t time.Duration) Option { return func(o *option) { o.lockTimeout = t } @@ -53,6 +53,24 @@ func WithExpiry(expiry time.Duration) Option { } } +func WithRefreshPeriod(period time.Duration) Option { + return func(o *option) { + o.RefreshPeriod = period + } +} + +func WithMaxRetryTimes(times int) Option { + return func(o *option) { + o.MaxRetryTimes = times + } +} + +func WithRetryInterval(interval time.Duration) Option { + return func(o *option) { + o.RetryInterval = interval + } +} + type Logger interface { Errorf(ctx context.Context, format string, v ...any) Infof(ctx context.Context, format string, v ...any)