refactor(messaging): shift sequence generation responsibility to database transactions
All checks were successful
Build Backend / build (push) Successful in 1m59s
Build Backend / build-docker (push) Successful in 1m20s

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:
2026-05-25 14:51:46 +08:00
parent 2748c80095
commit 2084473fb5
6 changed files with 145 additions and 376 deletions

View File

@@ -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 INCRkey 存在时递增,不存在时返回 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
}
}
// 冷启动:从 DB 加载 last_seqSETNX+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 不可用,降级到 DBSELECT 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()
}
}
// ============================================================
// 消息缓存方法
// ============================================================