feat: 实现 Redis 令牌桶限流器
- RedisLimiter 基于 Lua 脚本保证原子性 - Lua 脚本实现完整令牌桶算法(填充、消耗、TTL) - fail-open 策略:Redis 故障时允许请求通过 - FormatKey 辅助函数格式化限流 key - 完整单元测试(10 个测试用例,使用 miniredis) - 测试覆盖:首次请求、耗尽、不同用户、补充、容量上限、零速率、TTL、故障降级
This commit is contained in:
228
backend/internal/ratelimit/redis_bucket_test.go
Normal file
228
backend/internal/ratelimit/redis_bucket_test.go
Normal file
@@ -0,0 +1,228 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/hhs/camtalk/internal/config"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// setupMiniRedis 创建一个内存 Redis 实例用于测试。
|
||||
func setupMiniRedis(t *testing.T) (*miniredis.Miniredis, *redis.Client) {
|
||||
mr, err := miniredis.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: mr.Addr(),
|
||||
})
|
||||
|
||||
t.Cleanup(func() {
|
||||
client.Close()
|
||||
mr.Close()
|
||||
})
|
||||
|
||||
return mr, client
|
||||
}
|
||||
|
||||
func TestRedisLimiter_Allow_FirstRequest(t *testing.T) {
|
||||
_, client := setupMiniRedis(t)
|
||||
|
||||
cfg := config.RateLimitConfig{
|
||||
Enabled: true,
|
||||
Query: config.BucketConfig{Capacity: 5, Rate: 0.2},
|
||||
}
|
||||
limiter := NewRedisLimiter(client, cfg)
|
||||
|
||||
ctx := context.Background()
|
||||
allowed, retryAfter := limiter.Allow(ctx, "user1:query")
|
||||
|
||||
assert.True(t, allowed)
|
||||
assert.Equal(t, time.Duration(0), retryAfter)
|
||||
}
|
||||
|
||||
func TestRedisLimiter_Allow_ConsumeUntilEmpty(t *testing.T) {
|
||||
_, client := setupMiniRedis(t)
|
||||
|
||||
cfg := config.RateLimitConfig{
|
||||
Enabled: true,
|
||||
Query: config.BucketConfig{Capacity: 3, Rate: 0.2},
|
||||
}
|
||||
limiter := NewRedisLimiter(client, cfg)
|
||||
|
||||
ctx := context.Background()
|
||||
key := "user1:query"
|
||||
|
||||
// 连续消耗 3 个令牌
|
||||
for i := 0; i < 3; i++ {
|
||||
allowed, _ := limiter.Allow(ctx, key)
|
||||
assert.True(t, allowed, "request %d should be allowed", i+1)
|
||||
}
|
||||
|
||||
// 第 4 个请求应被拒绝
|
||||
allowed, retryAfter := limiter.Allow(ctx, key)
|
||||
assert.False(t, allowed)
|
||||
assert.Greater(t, retryAfter, time.Duration(0))
|
||||
}
|
||||
|
||||
func TestRedisLimiter_Allow_DifferentKeys(t *testing.T) {
|
||||
_, client := setupMiniRedis(t)
|
||||
|
||||
cfg := config.RateLimitConfig{
|
||||
Enabled: true,
|
||||
Query: config.BucketConfig{Capacity: 2, Rate: 1.0},
|
||||
}
|
||||
limiter := NewRedisLimiter(client, cfg)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// user1 消耗 2 个令牌
|
||||
allowed, _ := limiter.Allow(ctx, "user1:query")
|
||||
assert.True(t, allowed)
|
||||
allowed, _ = limiter.Allow(ctx, "user1:query")
|
||||
assert.True(t, allowed)
|
||||
|
||||
// user1 第 3 个被拒绝
|
||||
allowed, _ = limiter.Allow(ctx, "user1:query")
|
||||
assert.False(t, allowed)
|
||||
|
||||
// user2 应该不受影响
|
||||
allowed, _ = limiter.Allow(ctx, "user2:query")
|
||||
assert.True(t, allowed)
|
||||
}
|
||||
|
||||
func TestRedisLimiter_Allow_RefillAfterWait(t *testing.T) {
|
||||
_, client := setupMiniRedis(t)
|
||||
|
||||
cfg := config.RateLimitConfig{
|
||||
Enabled: true,
|
||||
Query: config.BucketConfig{Capacity: 2, Rate: 10.0}, // 每秒 10 个令牌
|
||||
}
|
||||
limiter := NewRedisLimiter(client, cfg)
|
||||
|
||||
ctx := context.Background()
|
||||
key := "user1:query"
|
||||
|
||||
// 消耗 2 个令牌
|
||||
limiter.Allow(ctx, key)
|
||||
limiter.Allow(ctx, key)
|
||||
|
||||
// 真实等待 150ms(Lua 脚本使用系统时间)
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// 应该补充了至少 1 个令牌
|
||||
allowed, _ := limiter.Allow(ctx, key)
|
||||
assert.True(t, allowed)
|
||||
}
|
||||
|
||||
func TestRedisLimiter_Allow_CapacityLimit(t *testing.T) {
|
||||
_, client := setupMiniRedis(t)
|
||||
|
||||
cfg := config.RateLimitConfig{
|
||||
Enabled: true,
|
||||
Query: config.BucketConfig{Capacity: 3, Rate: 1.0},
|
||||
}
|
||||
limiter := NewRedisLimiter(client, cfg)
|
||||
|
||||
ctx := context.Background()
|
||||
key := "user1:query"
|
||||
|
||||
// 真实等待让桶"溢出"
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// 但最多只能消耗 capacity 个令牌
|
||||
for i := 0; i < 3; i++ {
|
||||
allowed, _ := limiter.Allow(ctx, key)
|
||||
assert.True(t, allowed, "request %d should be allowed", i+1)
|
||||
}
|
||||
|
||||
// 第 4 个应被拒绝
|
||||
allowed, _ := limiter.Allow(ctx, key)
|
||||
assert.False(t, allowed)
|
||||
}
|
||||
|
||||
func TestRedisLimiter_Allow_ZeroRate(t *testing.T) {
|
||||
_, client := setupMiniRedis(t)
|
||||
|
||||
cfg := config.RateLimitConfig{
|
||||
Enabled: true,
|
||||
Query: config.BucketConfig{Capacity: 1, Rate: 0.0},
|
||||
}
|
||||
limiter := NewRedisLimiter(client, cfg)
|
||||
|
||||
ctx := context.Background()
|
||||
key := "user1:query"
|
||||
|
||||
// 第一个通过
|
||||
allowed, _ := limiter.Allow(ctx, key)
|
||||
assert.True(t, allowed)
|
||||
|
||||
// 第二个被拒绝,retryAfter 应该很大
|
||||
allowed, retryAfter := limiter.Allow(ctx, key)
|
||||
assert.False(t, allowed)
|
||||
assert.Greater(t, retryAfter, 1*time.Hour)
|
||||
}
|
||||
|
||||
func TestRedisLimiter_Allow_KeyTTL(t *testing.T) {
|
||||
mr, client := setupMiniRedis(t)
|
||||
|
||||
cfg := config.RateLimitConfig{
|
||||
Enabled: true,
|
||||
Query: config.BucketConfig{Capacity: 5, Rate: 1.0},
|
||||
}
|
||||
limiter := NewRedisLimiter(client, cfg)
|
||||
|
||||
ctx := context.Background()
|
||||
key := "user1:query"
|
||||
|
||||
// 第一次请求
|
||||
limiter.Allow(ctx, key)
|
||||
|
||||
// 验证 key 已设置 TTL
|
||||
ttl := mr.TTL(key)
|
||||
assert.Greater(t, ttl, time.Duration(0))
|
||||
assert.LessOrEqual(t, ttl, 600*time.Second)
|
||||
}
|
||||
|
||||
func TestRedisLimiter_Allow_FailOpen(t *testing.T) {
|
||||
mr, client := setupMiniRedis(t)
|
||||
|
||||
cfg := config.RateLimitConfig{
|
||||
Enabled: true,
|
||||
Query: config.BucketConfig{Capacity: 1, Rate: 1.0},
|
||||
}
|
||||
limiter := NewRedisLimiter(client, cfg)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 关闭 Redis 模拟故障
|
||||
mr.Close()
|
||||
|
||||
// 应该 fail-open(允许请求)
|
||||
allowed, retryAfter := limiter.Allow(ctx, "user1:query")
|
||||
assert.True(t, allowed)
|
||||
assert.Equal(t, time.Duration(0), retryAfter)
|
||||
}
|
||||
|
||||
func TestRedisLimiter_Stop(t *testing.T) {
|
||||
_, client := setupMiniRedis(t)
|
||||
|
||||
cfg := config.RateLimitConfig{
|
||||
Enabled: true,
|
||||
Query: config.BucketConfig{Capacity: 1, Rate: 1.0},
|
||||
}
|
||||
limiter := NewRedisLimiter(client, cfg)
|
||||
|
||||
// Stop 应该不会 panic(即使多次调用)
|
||||
limiter.Stop()
|
||||
limiter.Stop()
|
||||
}
|
||||
|
||||
func TestFormatKey(t *testing.T) {
|
||||
key := FormatKey("user123", "query")
|
||||
assert.Equal(t, "ratelimit:user123:query", key)
|
||||
}
|
||||
Reference in New Issue
Block a user