fix(cache): enhance sequence generation atomicity and concurrency safety
All checks were successful
Build Backend / build (push) Successful in 2m20s
Build Backend / build-docker (push) Successful in 2m12s

Implement Lua scripts for atomic sequence incrementing and cold-start initialization in Redis to prevent race conditions. Improve the sequence buffer manager with CAS-like logic to prevent interval leakage during concurrent allocations. Update the message repository to use `SELECT ... FOR UPDATE` and conditional updates for `last_seq` to ensure database consistency during high concurrency.

- Add `nextSeqLua` and `initSeqLua` for atomic Redis operations
- Implement thread-safe buffer management in `seq_buffer.go`
- Add row-level locking in `message_repo.go`
- Update service layers to support group-aware sequence retrieval
This commit is contained in:
2026-05-25 14:08:06 +08:00
parent 3fc8b38184
commit 2748c80095
6 changed files with 109 additions and 26 deletions

View File

@@ -13,6 +13,24 @@ import (
"go.uber.org/zap"
)
// nextSeqLua 原子递增 seqkey 存在时 INCR 返回新值,不存在时返回 0 触发冷启动
var nextSeqLua = redis.NewScript(`
local seqKey = KEYS[1]
if redis.call('EXISTS', seqKey) == 1 then
return redis.call('INCR', seqKey)
else
return 0
end
`)
// initSeqLua 冷启动原子初始化SETNX 防止覆盖 + INCR 递增,返回第一个可用 seq
var initSeqLua = redis.NewScript(`
local seqKey = KEYS[1]
local initVal = tonumber(ARGV[1])
redis.call('SETNX', seqKey, initVal)
return redis.call('INCR', seqKey)
`)
// CachedConversation 带缓存元数据的会话
type CachedConversation struct {
Data *model.Conversation // 实际数据
@@ -700,17 +718,48 @@ func (c *ConversationCache) ComputeAllUnreadCount(ctx context.Context, userID st
func (c *ConversationCache) GetNextSeq(ctx context.Context, convID string) (int64, error) {
// 优先使用 seq 预分配
if c.seqBufferMgr != nil && c.seqBufferMgr.IsEnabled() {
// 判断是否群聊(通过会话类型判断,这里先按私聊处理,由上层传入 isGroup
// 由于接口只传 convID这里默认私聊逻辑群聊的 isGroup 由调用方决定
return c.seqBufferMgr.GetNextSeq(ctx, convID, false)
}
// 降级到旧 INCR 逻辑
// 降级到旧逻辑:使用 Lua 脚本原子执行 INCR + 首次初始化对齐,避免竞态
seqKey := MessageSeqKey(convID)
// 尝试用 Lua 脚本原子递增key 存在时 INCR不存在时返回 0 触发冷启动)
if lc, ok := c.cache.(*LayeredCache); ok && lc.IsEnabled() {
rdb := lc.GetRedisClient()
if rdb != nil {
result, err := nextSeqLua.Run(ctx, rdb.GetClient(), []string{seqKey}).Int64()
if err == nil {
if result > 0 {
// key 已存在INCR 成功
_ = c.cache.Expire(ctx, seqKey, c.settings.SeqTTL)
return result, nil
}
// result == 0: 冷启动key 不存在,需从 DB 初始化
if c.repo != nil {
conv, dbErr := c.repo.GetConversationByID(convID)
if dbErr != nil {
return 0, fmt.Errorf("failed to load conversation for seq init: %w", dbErr)
}
initVal := conv.LastSeq // SETNX 使用 last_seqINCR 返回 last_seq + 1
initResult, initErr := initSeqLua.Run(ctx, rdb.GetClient(), []string{seqKey}, initVal).Int64()
if initErr != nil {
// 初始化失败,降级到非原子路径
zap.L().Warn("initSeqLua failed, falling back", zap.Error(initErr))
} else {
_ = c.cache.Expire(ctx, seqKey, c.settings.SeqTTL)
return initResult, nil
}
}
}
// Lua 脚本失败,降级到非原子路径
}
}
// 非原子降级路径Redis 不可用或 Lua 不支持)
newSeq, err := c.cache.Incr(ctx, seqKey)
if err != nil {
// Redis 不可用时降级到 DB,拿到值后回写 Redis 以便下次命中缓存
// Redis 不可用时降级到 DB
if c.msgRepo != nil {
if dbSeq, dbErr := c.msgRepo.GetNextSeq(convID); dbErr == nil {
c.cache.Set(seqKey, dbSeq, c.settings.SeqTTL)
@@ -721,6 +770,7 @@ func (c *ConversationCache) GetNextSeq(ctx context.Context, convID string) (int6
}
// 首次调用时 Redis key 不存在INCR 从 0 返回 1需要从 DB 补齐
// 注意:此路径存在竞态窗口,优先使用上面的 Lua 原子路径
if newSeq == 1 && c.repo != nil {
conv, err := c.repo.GetConversationByID(convID)
if err != nil {