refactor(messaging): shift sequence generation responsibility to database transactions
Move the responsibility of sequence (`seq`) allocation from the application/cache layer to the database layer to ensure absolute consistency. Previously, sequences were pre-allocated via Redis/Lua, which introduced complexity in managing synchronization between the cache and the database. Key changes: - Implement `CreateMessageWithSeq` in `message_repo.go` using `SELECT FOR UPDATE` to lock the conversation row and atomically increment the `last_seq` within a single transaction. - Simplify `SeqBufferManager` by removing the complex local buffering and Lua-based pre-allocation logic, reverting to a simpler model. - Update `chat_service.go`, `group_service.go`, and `message_service.go` to remove manual sequence retrieval, relying instead on the repository's transactional allocation. - Introduce `SyncConvSeq` in `conversation_cache.go` to perform "write-through" updates to Redis, ensuring the cache remains synchronized with the database's source of truth. - Improve cold-start handling in `conversation_cache.go` with a new `syncSeqLua` script to prevent stale sequence reads.
This commit is contained in:
109
internal/cache/conversation_cache.go
vendored
109
internal/cache/conversation_cache.go
vendored
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"with_you/internal/model"
|
||||
redisPkg "with_you/internal/pkg/redis"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -31,6 +32,14 @@ redis.call('SETNX', seqKey, initVal)
|
||||
return redis.call('INCR', seqKey)
|
||||
`)
|
||||
|
||||
// syncSeqLua 写透缓存:确保 Redis seq >= DB seq,防止冷启动后读到过期值
|
||||
var syncSeqLua = redis.NewScript(`
|
||||
local current = tonumber(redis.call('GET', KEYS[1]))
|
||||
if current == nil or current < tonumber(ARGV[1]) then
|
||||
redis.call('SET', KEYS[1], ARGV[1])
|
||||
end
|
||||
`)
|
||||
|
||||
// CachedConversation 带缓存元数据的会话
|
||||
type CachedConversation struct {
|
||||
Data *model.Conversation // 实际数据
|
||||
@@ -713,86 +722,58 @@ func (c *ConversationCache) ComputeAllUnreadCount(ctx context.Context, userID st
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// GetNextSeq 获取会话的下一个 seq 值(原子递增)
|
||||
// 启用 seq 预分配时使用本地缓冲区 + Redis Lua 分配;否则使用旧 INCR 逻辑
|
||||
// GetNextSeq 获取会话的下一个 seq 值(单次 Redis INCR,原子无竞态)
|
||||
func (c *ConversationCache) GetNextSeq(ctx context.Context, convID string) (int64, error) {
|
||||
// 优先使用 seq 预分配
|
||||
// 优先使用 SeqBufferManager(已简化为单次 INCR)
|
||||
if c.seqBufferMgr != nil && c.seqBufferMgr.IsEnabled() {
|
||||
return c.seqBufferMgr.GetNextSeq(ctx, convID, false)
|
||||
}
|
||||
|
||||
// 降级到旧逻辑:使用 Lua 脚本原子执行 INCR + 首次初始化对齐,避免竞态
|
||||
seqKey := MessageSeqKey(convID)
|
||||
|
||||
// 尝试用 Lua 脚本原子递增(key 存在时 INCR,不存在时返回 0 触发冷启动)
|
||||
// 尝试 Redis INCR(key 存在时递增,不存在时返回 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_seq,INCR 返回 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
|
||||
}
|
||||
}
|
||||
// 冷启动:从 DB 加载 last_seq,SETNX+INCR 原子初始化
|
||||
return c.initSeqFromDB(ctx, convID, seqKey, rdb)
|
||||
}
|
||||
// Lua 脚本失败,降级到非原子路径
|
||||
}
|
||||
}
|
||||
|
||||
// 非原子降级路径(Redis 不可用或 Lua 不支持)
|
||||
newSeq, err := c.cache.Incr(ctx, seqKey)
|
||||
if err != nil {
|
||||
// Redis 不可用时降级到 DB
|
||||
if c.msgRepo != nil {
|
||||
if dbSeq, dbErr := c.msgRepo.GetNextSeq(convID); dbErr == nil {
|
||||
c.cache.Set(seqKey, dbSeq, c.settings.SeqTTL)
|
||||
return dbSeq, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("redis incr seq failed: %w", err)
|
||||
}
|
||||
|
||||
// 首次调用时 Redis key 不存在,INCR 从 0 返回 1,需要从 DB 补齐
|
||||
// 注意:此路径存在竞态窗口,优先使用上面的 Lua 原子路径
|
||||
if newSeq == 1 && c.repo != nil {
|
||||
conv, err := c.repo.GetConversationByID(convID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to load conversation for seq init: %w", err)
|
||||
}
|
||||
|
||||
if conv.LastSeq > 0 {
|
||||
alignedSeq, err := c.cache.IncrBySeq(ctx, seqKey, conv.LastSeq)
|
||||
if err != nil {
|
||||
return conv.LastSeq + 1, nil // 降级返回 DB 值
|
||||
}
|
||||
newSeq = alignedSeq
|
||||
// Redis 不可用,降级到 DB(SELECT FOR UPDATE 保证原子性)
|
||||
if c.msgRepo != nil {
|
||||
if dbSeq, dbErr := c.msgRepo.GetNextSeq(convID); dbErr == nil {
|
||||
return dbSeq, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 每次 INCR 后刷新 TTL,防止 seq 计数器过期导致从头开始
|
||||
_ = c.cache.Expire(ctx, seqKey, c.settings.SeqTTL)
|
||||
|
||||
return newSeq, nil
|
||||
return 0, fmt.Errorf("seq allocation failed for %s: no redis or db available", convID)
|
||||
}
|
||||
|
||||
// GetNextSeqWithGroup 获取会话的下一个 seq 值(支持群聊标识,用于 seq 预分配步长区分)
|
||||
// initSeqFromDB 冷启动:从 DB 加载 last_seq,用 SETNX+INCR 原子初始化
|
||||
func (c *ConversationCache) initSeqFromDB(ctx context.Context, convID, seqKey string, rdb *redisPkg.Client) (int64, error) {
|
||||
if c.repo == nil {
|
||||
return 0, fmt.Errorf("conversation repository not configured for seq init")
|
||||
}
|
||||
conv, dbErr := c.repo.GetConversationByID(convID)
|
||||
if dbErr != nil {
|
||||
return 0, fmt.Errorf("load conversation for seq init: %w", dbErr)
|
||||
}
|
||||
result, initErr := initSeqLua.Run(ctx, rdb.GetClient(), []string{seqKey}, conv.LastSeq).Int64()
|
||||
if initErr != nil {
|
||||
return 0, fmt.Errorf("initSeqLua failed: %w", initErr)
|
||||
}
|
||||
_ = c.cache.Expire(ctx, seqKey, c.settings.SeqTTL)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetNextSeqWithGroup 获取会话的下一个 seq 值(isGroup 参数保留兼容,简化后不区分步长)
|
||||
func (c *ConversationCache) GetNextSeqWithGroup(ctx context.Context, convID string, isGroup bool) (int64, error) {
|
||||
if c.seqBufferMgr != nil && c.seqBufferMgr.IsEnabled() {
|
||||
return c.seqBufferMgr.GetNextSeq(ctx, convID, isGroup)
|
||||
@@ -800,6 +781,22 @@ func (c *ConversationCache) GetNextSeqWithGroup(ctx context.Context, convID stri
|
||||
return c.GetNextSeq(ctx, convID)
|
||||
}
|
||||
|
||||
// SyncConvSeq 写透缓存:将 DB 刚分配的 seq 同步到 Redis,确保 Redis seq >= DB seq
|
||||
func (c *ConversationCache) SyncConvSeq(ctx context.Context, convID string, seq int64) {
|
||||
seqKey := MessageSeqKey(convID)
|
||||
|
||||
if lc, ok := c.cache.(*LayeredCache); ok && lc.IsEnabled() {
|
||||
rdb := lc.GetRedisClient()
|
||||
if rdb != nil {
|
||||
_ = syncSeqLua.Run(ctx, rdb.GetClient(), []string{seqKey}, seq).Err()
|
||||
}
|
||||
}
|
||||
|
||||
if c.seqBufferMgr != nil && c.seqBufferMgr.rdb != nil {
|
||||
_ = syncSeqLua.Run(ctx, c.seqBufferMgr.rdb, []string{seqKey}, seq).Err()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 消息缓存方法
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user