项目文件夹

文件
2026-07-08 19:02:01 +08:00

429 行
13 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
package cache
import (
"context"
"errors"
"fmt"
"time"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/EthanCodeCraft/xlgo-core/utils"
)
// 分布式锁错误
var (
ErrLockNotHeld = errors.New("锁未被当前客户端持有")
ErrLockExpired = errors.New("锁已过期")
ErrRedisNotReady = errors.New("Redis 未初始化")
// ErrLockNotAcquired 表示锁被其它客户端持有,业务函数未执行。
ErrLockNotAcquired = errors.New("未获取到锁")
// ErrInvalidLockTTL 表示锁 TTL 小于 Redis PX 支持的 1ms 粒度或非正。
ErrInvalidLockTTL = errors.New("锁 TTL 必须大于等于 1ms")
// ErrInvalidLockRetryInterval 表示重试或续期间隔非法。
ErrInvalidLockRetryInterval = errors.New("锁重试/续期间隔必须大于 0")
// ErrLockUnexpectedResult Lua 脚本返回了非预期的结果类型(C1b:裸类型断言防护)。
ErrLockUnexpectedResult = errors.New("锁脚本返回非预期结果")
// ErrLockFuncNil 表示分布式锁业务函数为空。
ErrLockFuncNil = errors.New("锁业务函数不能为空")
)
// toInt64 将 Lua 脚本返回值安全断言为 int64(C1b:禁止裸断言 panic)。
// go-redis 对整数返回 int64,但 nil/错误响应下可能为其他类型。
func toInt64(v any) (int64, error) {
n, ok := v.(int64)
if !ok {
return 0, fmt.Errorf("脚本返回类型 %T: %w", v, ErrLockUnexpectedResult)
}
return n, nil
}
func ttlMillis(ttl time.Duration) (int64, error) {
if ttl < time.Millisecond {
return 0, ErrInvalidLockTTL
}
return int64(ttl / time.Millisecond), nil
}
// LockToken 锁令牌(用于安全释放锁)。
//
// 安全说明(C1d 设计局限):Token 是随机 UUID(非单调递增的 fencing token)。
// 本实现基于 Redis SET PX + Lua CAS,保证"持有者才能解锁/续期",但**无法防 TTL 到期后的
// 双 worker 并发**:若 worker A 因 GC/网络停滞超过 TTL,锁过期后 worker B 获得锁,
// A 恢复后仍可能写过期数据。完整的 fencing token 防护需:① 用 Redis INCR 生成单调 token,
// ② 下游存储层(DB/外部服务)记录已见最大 token 并拒绝旧 token 写入。
// 框架无法单方面保证②,需下游配合,故本类型仅提供 UUID token。对 TTL 到期敏感的场景,
// 请确保 ttl >> 业务最长执行时间,或下游实现 fencing token 校验。
type LockToken struct {
Key string // 锁的键名
Token string // 锁的唯一标识(UUID
}
// lockScript 加锁 Lua 脚本
// 返回: 1 表示成功加锁,0 表示锁已被占用
const lockScript = `
if redis.call("exists", KEYS[1]) == 0 then
redis.call("set", KEYS[1], ARGV[1], "PX", ARGV[2])
return 1
else
return 0
end
`
// unlockScript 解锁 Lua 脯本
// 只有持有正确 Token 的客户端才能解锁
// 返回: 1 表示成功解锁,0 表示 Token 不匹配(锁不属于该客户端)
const unlockScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
redis.call("del", KEYS[1])
return 1
else
return 0
end
`
// extendScript 续期 Lua 脚本
// 只有持有正确 Token 的客户端才能续期
// 返回: 1 表示成功续期,0 表示 Token 不匹配或锁不存在
const extendScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
redis.call("pexpire", KEYS[1], ARGV[2])
return 1
else
return 0
end
`
// NewLock 创建分布式锁
// 参数: key 锁名称,ttl 锁定时长
// 返回: LockToken 用于后续解锁或续期
func NewLock(ctx context.Context, key string, ttl time.Duration) (*LockToken, error) {
rdb := database.GetRedis()
if rdb == nil {
return nil, ErrRedisNotReady
}
token := utils.UUID()
ttlMs, err := ttlMillis(ttl)
if err != nil {
return nil, err
}
result, err := rdb.Eval(ctx, lockScript, []string{key}, token, ttlMs).Result()
if err != nil {
return nil, err
}
n, err := toInt64(result)
if err != nil {
return nil, err
}
if n == 1 {
return &LockToken{Key: key, Token: token}, nil
}
return nil, nil // 锁已被其他客户端持有
}
// Lock 简化的加锁函数(返回 bool)
// 注意: 使用此函数无法安全释放锁,建议使用 NewLock
func Lock(ctx context.Context, key string, ttl time.Duration) (bool, error) {
token, err := NewLock(ctx, key, ttl)
if err != nil {
return false, err
}
return token != nil, nil
}
// Unlock 安全释放锁
func Unlock(ctx context.Context, token *LockToken) error {
rdb := database.GetRedis()
if rdb == nil {
return ErrRedisNotReady
}
if token == nil {
return ErrLockNotHeld
}
result, err := rdb.Eval(ctx, unlockScript, []string{token.Key}, token.Token).Result()
if err != nil {
return err
}
n, err := toInt64(result)
if err != nil {
return err
}
if n == 0 {
return ErrLockNotHeld
}
return nil
}
// UnlockByKey 按键名释放锁(不安全,仅用于旧代码兼容)
// 注意: 此函数不检查 Token,任何客户端都能释放锁
func UnlockByKey(ctx context.Context, key string) error {
rdb := database.GetRedis()
if rdb == nil {
return ErrRedisNotReady
}
return rdb.Del(ctx, key).Err()
}
// ExtendLock 续期锁
// 参数: token 锁令牌,ttl 新的过期时间
func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error {
rdb := database.GetRedis()
if rdb == nil {
return ErrRedisNotReady
}
if token == nil {
return ErrLockNotHeld
}
ttlMs, err := ttlMillis(ttl)
if err != nil {
return err
}
result, err := rdb.Eval(ctx, extendScript, []string{token.Key}, token.Token, ttlMs).Result()
if err != nil {
return err
}
n, err := toInt64(result)
if err != nil {
return err
}
if n == 0 {
return ErrLockNotHeld
}
return nil
}
// TryLock 尝试获取锁,失败时等待重试。重试等待响应 ctx 取消(C1c 修复)。
func TryLock(ctx context.Context, key string, ttl time.Duration, retryInterval time.Duration, maxRetry int) (*LockToken, error) {
if retryInterval <= 0 {
return nil, ErrInvalidLockRetryInterval
}
for i := 0; i < maxRetry; i++ {
token, err := NewLock(ctx, key, ttl)
if err != nil {
return nil, err
}
if token != nil {
return token, nil
}
// 响应 ctx 取消,避免最长阻塞 maxRetry*retryIntervalC1c:禁止 time.Sleep 无视 ctx)。
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(retryInterval):
}
}
return nil, ErrLockNotAcquired
}
// WithLock 使用分布式锁执行函数(自动管理锁)。
// 参数: key 锁名称,ttl 锁定时长,fn 业务函数
// 注意: 如果任务执行时间超过 ttl,需要设置更长的 ttl 或使用 WithLockAutoExtend。
//
// 解锁用独立 Background ctxC1a 一致性修复):fn 返回或 panic 后,原 ctx 可能已被
// 调用方取消,用其解锁会失败导致锁泄漏到 TTL。fn panic 时 defer 也保证解锁执行。
func WithLock(ctx context.Context, key string, ttl time.Duration, fn func(context.Context) error) error {
if fn == nil {
return ErrLockFuncNil
}
token, err := NewLock(ctx, key, ttl)
if err != nil {
return err
}
if token == nil {
return ErrLockNotAcquired
}
defer func() {
unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = Unlock(unlockCtx, token)
}()
return fn(ctx)
}
// WithLockAutoExtend 使用分布式锁执行函数(自动续期)。
// 参数: key 锁名称,initialTTL 初始锁定时长,extendInterval 续期间隔,fn 业务函数
//
// 并发安全说明(C1a 修复):续期 goroutine 与父用"父关停 + 子 ack"双 channel 协调——
// 父用 close(stop) 通知子退出(close 由唯一所有者执行,安全),子用 close(finished) ack。
// 避免旧实现 done 无缓冲 + 子 defer close(done) + 父 done<-struct{}{} 的 send-on-closed panic
// ctx 取消或 ExtendLock 失败时 done 已 closed,父再 send 即 panic,Unlock 不执行、锁泄漏到 TTL)。
// Unlock 用 context.Background() 派生超时,避免原 ctx 已取消致 Unlock 失败再泄漏。
func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duration, extendInterval time.Duration, fn func(context.Context) error) error {
if fn == nil {
return ErrLockFuncNil
}
if extendInterval <= 0 {
return ErrInvalidLockRetryInterval
}
token, err := NewLock(ctx, key, initialTTL)
if err != nil {
return err
}
if token == nil {
return ErrLockNotAcquired
}
// 父关停信号(仅父 close)与子 ack 信号(仅子 close)。
stop := make(chan struct{})
finished := make(chan struct{})
go func() {
defer close(finished) // 子退出时 ack,父等待 finished
ticker := time.NewTicker(extendInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-stop:
return
case <-ticker.C:
// 续期锁(每次续期为 initialTTL)。续期失败则停止续期,fn 应尽快结束。
if err := ExtendLock(ctx, token, initialTTL); err != nil {
return
}
}
}
}()
// defer 兜底:fn panic 时也要停止续期 goroutine 并释放锁(C1a panic 路径修复)。
// 无 defer 时 fn panic 会导致 close(stop) 不执行 → 续期 goroutine 永久泄漏,且 Unlock 不执行 → 锁泄漏到 TTL。
defer func() {
close(stop)
<-finished
unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = Unlock(unlockCtx, token)
}()
// 执行业务函数。fn 必须接收 ctx,避免请求取消后业务 loader/DB/HTTP 调用继续运行。
err = fn(ctx)
return err
}
// IsLocked 检查锁是否被占用(不获取锁)。
//
// M-E 修复(失败语义统一):Redis 未初始化时返回 (false, ErrRedisNotReady) 而非 (false, nil)——
// 后者与"锁确实未被占用"不可区分,调用方可能误以为可获取锁而进入临界区(正确性 bug)。
// 调用方应 errors.Is(err, ErrRedisNotReady) 区分"Redis 不可用"与"锁未占用"。
// L-G 修复:用 .Result() 显式返回 Redis 错误(原 .Val() 吞错致故障被当"未占用")。
//
// 契约说明:锁操作有正确性影响(无锁进入临界区 = bug),故 Redis 不可用时显式返错;
// 与 cache.Get/Set 等数据操作(性能层、best-effort 静默 no-op)区分。
func IsLocked(ctx context.Context, key string) (bool, error) {
rdb := database.GetRedis()
if rdb == nil {
return false, ErrRedisNotReady
}
n, err := rdb.Exists(ctx, key).Result()
if err != nil {
return false, err
}
return n > 0, nil
}
// GetLockTTL 获取锁的剩余过期时间。
//
// M-E 修复:Redis 未初始化时返回 (0, ErrRedisNotReady) 而非 (0, nil),调用方可区分
// "Redis 不可用"与"锁不存在/已过期"(后者 TTL=-1/-2)。
func GetLockTTL(ctx context.Context, key string) (time.Duration, error) {
rdb := database.GetRedis()
if rdb == nil {
return 0, ErrRedisNotReady
}
return rdb.TTL(ctx, key).Result()
}
// ForceUnlock 强制释放锁(危险操作,仅用于管理场景)
// 注意: 此函数不检查 Token,强制删除锁
//
// M-E 修复:Redis 未初始化时返回 ErrRedisNotReady 而非 nil——原 nil 让调用方误以为
// 已解锁成功、实则从未操作。管理脚本应据此重试或告警,而非假设成功。
func ForceUnlock(ctx context.Context, key string) error {
rdb := database.GetRedis()
if rdb == nil {
return ErrRedisNotReady
}
return rdb.Del(ctx, key).Err()
}
// ===== 计数器操作 =====
// Incr 自增计数器
func Incr(ctx context.Context, key string) (int64, error) {
rdb := database.GetRedis()
if rdb == nil {
return 0, ErrRedisNotReady
}
return rdb.Incr(ctx, key).Result()
}
// IncrBy 指定增量自增
func IncrBy(ctx context.Context, key string, value int64) (int64, error) {
rdb := database.GetRedis()
if rdb == nil {
return 0, ErrRedisNotReady
}
return rdb.IncrBy(ctx, key, value).Result()
}
// Decr 自减计数器
func Decr(ctx context.Context, key string) (int64, error) {
rdb := database.GetRedis()
if rdb == nil {
return 0, ErrRedisNotReady
}
return rdb.Decr(ctx, key).Result()
}
// GetTTL 获取键的剩余过期时间
func GetTTL(ctx context.Context, key string) (time.Duration, error) {
rdb := database.GetRedis()
if rdb == nil {
return 0, ErrRedisNotReady
}
return rdb.TTL(ctx, key).Result()
}
// SetExpire 设置键的过期时间
func SetExpire(ctx context.Context, key string, ttl time.Duration) (bool, error) {
rdb := database.GetRedis()
if rdb == nil {
return false, ErrRedisNotReady
}
return rdb.Expire(ctx, key, ttl).Result()
}
// GetRaw 获取原始字符串值(不反序列化)
func GetRaw(ctx context.Context, key string) (string, error) {
rdb := database.GetRedis()
if rdb == nil {
return "", ErrRedisNotReady
}
return rdb.Get(ctx, key).Result()
}
// SetRaw 设置原始值(不序列化)
func SetRaw(ctx context.Context, key string, value string, ttl time.Duration) error {
rdb := database.GetRedis()
if rdb == nil {
return ErrRedisNotReady
}
return rdb.Set(ctx, key, value, ttl).Err()
}