feat(chat): implement sequence pre-allocation, versioned sync, and push worker
All checks were successful
Build Backend / build (push) Successful in 4m20s
Build Backend / build-docker (push) Successful in 1m7s

Introduce several performance and synchronization enhancements:
- Implement `SeqBufferManager` to allow sequence number pre-allocation via Redis Lua scripts, reducing atomic increment overhead.
- Add `PushWorker` to handle asynchronous message pushing using Redis Streams.
- Implement incremental conversation synchronization via `ConversationVersionLog` to allow clients to fetch only recent changes.
- Add support for Gzip compression in WebSocket communications to reduce bandwidth usage.
- Update dependency injection and configuration to support these new components.
This commit is contained in:
2026-05-17 23:38:04 +08:00
parent f63c795dcb
commit 6bf87fec46
26 changed files with 1450 additions and 82 deletions

View File

@@ -230,22 +230,24 @@ type MessageRepository interface {
// ConversationCache 会话缓存管理器
type ConversationCache struct {
cache Cache // 底层缓存
settings *ConversationCacheSettings // 配置
repo ConversationRepository // 数据仓库接口(用于 cache-aside 回源)
msgRepo MessageRepository // 消息数据仓库接口(用于消息缓存回源)
cache Cache // 底层缓存
settings *ConversationCacheSettings // 配置
repo ConversationRepository // 数据仓库接口(用于 cache-aside 回源)
msgRepo MessageRepository // 消息数据仓库接口(用于消息缓存回源)
seqBufferMgr *SeqBufferManager // seq 预分配管理器nil 则使用旧 INCR 逻辑)
}
// NewConversationCache 创建会话缓存管理器
func NewConversationCache(cache Cache, repo ConversationRepository, msgRepo MessageRepository, settings *ConversationCacheSettings) *ConversationCache {
func NewConversationCache(cache Cache, repo ConversationRepository, msgRepo MessageRepository, settings *ConversationCacheSettings, seqBufferMgr *SeqBufferManager) *ConversationCache {
if settings == nil {
settings = DefaultConversationCacheSettings()
}
return &ConversationCache{
cache: cache,
settings: settings,
repo: repo,
msgRepo: msgRepo,
cache: cache,
settings: settings,
repo: repo,
msgRepo: msgRepo,
seqBufferMgr: seqBufferMgr,
}
}
@@ -694,8 +696,16 @@ func (c *ConversationCache) ComputeAllUnreadCount(ctx context.Context, userID st
}
// GetNextSeq 获取会话的下一个 seq 值(原子递增)
// 使用 Redis INCR 实现,首次调用时从 DB 初始化并补齐差值
// 启用 seq 预分配时使用本地缓冲区 + Redis Lua 分配;否则使用旧 INCR 逻辑
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 逻辑
seqKey := MessageSeqKey(convID)
newSeq, err := c.cache.Incr(ctx, seqKey)
@@ -732,6 +742,14 @@ func (c *ConversationCache) GetNextSeq(ctx context.Context, convID string) (int6
return newSeq, nil
}
// GetNextSeqWithGroup 获取会话的下一个 seq 值(支持群聊标识,用于 seq 预分配步长区分)
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)
}
return c.GetNextSeq(ctx, convID)
}
// ============================================================
// 消息缓存方法
// ============================================================

View File

@@ -44,6 +44,7 @@ const (
keyPrefixMsgSeq = "msg_seq" // Seq 计数器
keyPrefixMsgPage = "msg_page" // 分页缓存
keyPrefixMsgIdempotent = "msg_idem" // 消息幂等键
keyPrefixSeqBuffer = "seq_buf" // Seq 预分配缓冲区 Hash
)
// PostListKey 生成帖子列表缓存键
@@ -197,6 +198,11 @@ func MessageIdempotentKey(senderID, clientMsgID string) string {
return fmt.Sprintf("%s:%s:%s", keyPrefixMsgIdempotent, senderID, clientMsgID)
}
// SeqBufferKey seq 预分配缓冲区 Hash key
func SeqBufferKey(convID string) string {
return fmt.Sprintf("%s:%s", keyPrefixSeqBuffer, convID)
}
// UserReadSeqKey 用户已读位置缓存键OpenIM 风格 SEQ_USER_READ:{convID}:{userID}
func UserReadSeqKey(convID, userID string) string {

303
internal/cache/seq_buffer.go vendored Normal file
View File

@@ -0,0 +1,303 @@
package cache
import (
"context"
"fmt"
"sync"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
redisPkg "with_you/internal/pkg/redis"
)
// SeqBufferConfig seq 预分配配置
type SeqBufferConfig struct {
Enabled bool `mapstructure:"enabled"`
PrivateBufferSize int `mapstructure:"private_buffer_size"` // 私聊预分配步长
GroupBufferSize int `mapstructure:"group_buffer_size"` // 群聊预分配步长
LockTimeoutMs int `mapstructure:"lock_timeout_ms"` // 分布式锁超时(ms)
MaxRetries int `mapstructure:"max_retries"` // 冷启动重试次数
}
// localSeqBuffer 本地 seq 缓冲区
type localSeqBuffer struct {
mu sync.Mutex
curr int64 // 当前已分配到的 seq
last int64 // 本地缓冲区的上界
}
// SeqBufferManager seq 预分配管理器
type SeqBufferManager struct {
rdb *redis.Client // 原始 Redis 客户端(用于执行 Lua 脚本)
config SeqBufferConfig
repo ConversationRepository
msgRepo MessageRepository
buffers sync.Map // convID -> *localSeqBuffer
}
// mallocLua 预分配 Lua 跟踪脚本
// KEYS[1] = seq_buf:{convID} (Hash: CURR/LAST/LOCK/TIME)
// KEYS[2] = msg_seq:{convID} (String, 兼容旧 INCR key)
// ARGV[1] = bufferSize
// ARGV[2] = lockTimeoutMs (毫秒)
// ARGV[3] = current timestamp (毫秒)
//
// 返回:
// status=1, startSeq, endSeq — 成功分配
// status=2 — 冷启动key 不存在)
// status=0 — 获取锁失败
var mallocLua = redis.NewScript(`
local bufKey = KEYS[1]
local seqKey = KEYS[2]
local bufSize = tonumber(ARGV[1])
local lockMs = tonumber(ARGV[2])
local nowMs = tonumber(ARGV[3])
-- 尝试获取分布式锁
local lockVal = redis.call('HGET', bufKey, 'LOCK')
if lockVal then
local lockTs = tonumber(lockVal)
if lockTs > 0 and (nowMs - lockTs) < lockMs then
return {0, 0, 0}
end
end
redis.call('HSET', bufKey, 'LOCK', nowMs)
redis.call('HSET', bufKey, 'TIME', nowMs)
local curr = tonumber(redis.call('HGET', bufKey, 'CURR'))
local last = tonumber(redis.call('HGET', bufKey, 'LAST'))
if curr == nil or last == nil then
-- 冷启动key 不存在
redis.call('HDEL', bufKey, 'LOCK')
return {2, 0, 0}
end
if curr < last then
-- 本地缓冲区仍有余量,直接分配一个 seq
local newCurr = redis.call('HINCRBY', bufKey, 'CURR', 1)
redis.call('HDEL', bufKey, 'LOCK')
return {1, newCurr, last}
end
-- 缓冲区耗尽,扩展 LAST
local newLast = last + bufSize
redis.call('HSET', bufKey, 'LAST', newLast)
-- 同步更新 msg_seq兼容旧 INCR key确保 READ 的值不回退)
local oldSeq = tonumber(redis.call('GET', seqKey))
if oldSeq and oldSeq > newLast then
newLast = oldSeq + bufSize
redis.call('HSET', bufKey, 'LAST', newLast)
end
redis.call('SET', seqKey, newLast)
redis.call('HDEL', bufKey, 'LOCK')
return {1, curr + 1, newLast}
`)
// initLua 冷启动初始化 Lua 跟踪脚本
// KEYS[1] = seq_buf:{convID}
// KEYS[2] = msg_seq:{convID}
// ARGV[1] = startSeq (从 DB 获取的 conv.LastSeq)
// ARGV[2] = bufferSize
// ARGV[3] = lockTimeoutMs
// ARGV[4] = current timestamp (毫秒)
var initLua = redis.NewScript(`
local bufKey = KEYS[1]
local seqKey = KEYS[2]
local startSeq = tonumber(ARGV[1])
local bufSize = tonumber(ARGV[2])
local lockMs = tonumber(ARGV[3])
local nowMs = tonumber(ARGV[4])
-- 创建 seq_buf Hash
redis.call('HSET', bufKey, 'CURR', startSeq)
redis.call('HSET', bufKey, 'LAST', startSeq + bufSize)
redis.call('HSET', bufKey, 'LOCK', 0)
redis.call('HSET', bufKey, 'TIME', nowMs)
-- 同步更新 msg_seq兼容旧 INCR key
local oldSeq = tonumber(redis.call('GET', seqKey))
if oldSeq and oldSeq > (startSeq + bufSize) then
redis.call('HSET', bufKey, 'LAST', oldSeq + bufSize)
redis.call('SET', seqKey, oldSeq + bufSize)
else
redis.call('SET', seqKey, startSeq + bufSize)
end
return {1, startSeq + 1, startSeq + bufSize}
`)
// NewSeqBufferManager 创建 seq 预分配管理器
func NewSeqBufferManager(rdb *redis.Client, cfg SeqBufferConfig, repo ConversationRepository, msgRepo MessageRepository) *SeqBufferManager {
if cfg.PrivateBufferSize <= 0 {
cfg.PrivateBufferSize = 50
}
if cfg.GroupBufferSize <= 0 {
cfg.GroupBufferSize = 200
}
if cfg.LockTimeoutMs <= 0 {
cfg.LockTimeoutMs = 5000
}
if cfg.MaxRetries <= 0 {
cfg.MaxRetries = 3
}
return &SeqBufferManager{
rdb: rdb,
config: cfg,
repo: repo,
msgRepo: msgRepo,
}
}
// GetNextSeq 获取下一个 seq从本地缓冲区分配耗尽时向 Redis 预分配新区间)
func (m *SeqBufferManager) GetNextSeq(ctx context.Context, convID string, isGroup bool) (int64, error) {
bufSize := m.config.PrivateBufferSize
if isGroup {
bufSize = m.config.GroupBufferSize
}
// 1. 检查本地缓冲区
bufVal, ok := m.buffers.Load(convID)
if ok {
buf := bufVal.(*localSeqBuffer)
buf.mu.Lock()
if buf.curr < buf.last {
seq := buf.curr + 1
buf.curr = seq
buf.mu.Unlock()
return seq, nil
}
buf.mu.Unlock()
}
// 2. 本地缓冲区耗尽或不存在,向 Redis 预分配
startSeq, endSeq, err := m.allocateFromRedis(ctx, convID, bufSize)
if err != nil {
return 0, fmt.Errorf("allocate from redis: %w", err)
}
// 3. 更新本地缓冲区
newBuf := &localSeqBuffer{curr: startSeq, last: endSeq}
m.buffers.Store(convID, newBuf)
// startSeq 已经被 Redis 分配,返回它
return startSeq, nil
}
// allocateFromRedis 向 Redis 预分配 seq 区间
func (m *SeqBufferManager) allocateFromRedis(ctx context.Context, convID string, bufferSize int) (startSeq, endSeq int64, err error) {
bufKey := SeqBufferKey(convID)
seqKey := MessageSeqKey(convID)
nowMs := time.Now().UnixMilli()
for i := 0; i < m.config.MaxRetries; i++ {
result, err := mallocLua.Run(ctx, m.rdb, []string{bufKey, seqKey}, bufferSize, m.config.LockTimeoutMs, nowMs).Int64Slice()
if err != nil {
// Redis 不可用,降级到 DB
zap.L().Warn("seq malloc lua failed, falling back to DB", zap.String("convID", convID), zap.Error(err))
if m.msgRepo != nil {
if dbSeq, dbErr := m.msgRepo.GetNextSeq(convID); dbErr == nil {
return dbSeq, dbSeq, nil
}
}
return 0, 0, fmt.Errorf("lua script error: %w", err)
}
status := result[0]
switch status {
case 1:
// 成功分配
return result[1], result[2], nil
case 2:
// 冷启动,需要从 DB 初始化
startSeq, endSeq, initErr := m.initFromDB(ctx, convID, bufferSize)
if initErr != nil {
return 0, 0, fmt.Errorf("init from db: %w", initErr)
}
return startSeq, endSeq, nil
case 0:
// 获取锁失败,短暂等待后重试
time.Sleep(time.Duration(50) * time.Millisecond)
continue
}
}
// 所有重试失败,降级到 DB
zap.L().Warn("seq malloc retries exhausted, falling back to DB", zap.String("convID", convID))
if m.msgRepo != nil {
if dbSeq, dbErr := m.msgRepo.GetNextSeq(convID); dbErr == nil {
return dbSeq, dbSeq, nil
}
}
return 0, 0, fmt.Errorf("malloc retries exhausted")
}
// initFromDB 冷启动时从 DB 加载 LastSeq 并初始化 Redis seq_buf Hash
func (m *SeqBufferManager) initFromDB(ctx context.Context, convID string, bufferSize int) (startSeq, endSeq int64, err error) {
if m.repo == nil {
return 0, 0, fmt.Errorf("conversation repository not configured")
}
conv, err := m.repo.GetConversationByID(convID)
if err != nil {
return 0, 0, fmt.Errorf("load conversation: %w", err)
}
dbLastSeq := conv.LastSeq
if dbLastSeq <= 0 {
dbLastSeq = 0
}
bufKey := SeqBufferKey(convID)
seqKey := MessageSeqKey(convID)
nowMs := time.Now().UnixMilli()
result, err := initLua.Run(ctx, m.rdb, []string{bufKey, seqKey}, dbLastSeq, bufferSize, m.config.LockTimeoutMs, nowMs).Int64Slice()
if err != nil {
return 0, 0, fmt.Errorf("init lua script error: %w", err)
}
zap.L().Info("seq buffer cold-start initialized",
zap.String("convID", convID),
zap.Int64("dbLastSeq", dbLastSeq),
zap.Int64("startSeq", result[1]),
zap.Int64("endSeq", result[2]),
)
return result[1], result[2], nil
}
// InvalidateBuffer 清除本地缓冲区(会话 seq 被外部重置时调用)
func (m *SeqBufferManager) InvalidateBuffer(convID string) {
m.buffers.Delete(convID)
}
// IsEnabled 是否启用预分配
func (m *SeqBufferManager) IsEnabled() bool {
return m.config.Enabled && m.rdb != nil
}
// NewSeqBufferManagerFromCache 从 ConversationCache 的 Cache 接口创建 SeqBufferManager
// 当 wire 中不方便注入 redis.Client 时,可通过 Cache 接口获取底层 Redis 连接
func NewSeqBufferManagerFromCache(cacheBackend Cache, cfg SeqBufferConfig, repo ConversationRepository, msgRepo MessageRepository) *SeqBufferManager {
redisCache, ok := cacheBackend.(*RedisCache)
if !ok {
zap.L().Warn("SeqBuffer: cache is not RedisCache, seq pre-allocation disabled")
return &SeqBufferManager{config: cfg, repo: repo, msgRepo: msgRepo}
}
rdb := redisCache.GetRedisClient().GetClient()
return NewSeqBufferManager(rdb, cfg, repo, msgRepo)
}
// NewSeqBufferManagerFromRedisPkg 从 pkg/redis.Client 创建 SeqBufferManager
func NewSeqBufferManagerFromRedisPkg(redisPkgClient *redisPkg.Client, cfg SeqBufferConfig, repo ConversationRepository, msgRepo MessageRepository) *SeqBufferManager {
if redisPkgClient == nil {
zap.L().Warn("SeqBuffer: redis client is nil, seq pre-allocation disabled")
return &SeqBufferManager{config: cfg, repo: repo, msgRepo: msgRepo}
}
rdb := redisPkgClient.GetClient()
return NewSeqBufferManager(rdb, cfg, repo, msgRepo)
}