feat(core): optimize performance and reliability through batching and redis-backed unread counts
This commit introduces several significant architectural improvements to enhance system performance, scalability, and reliability:
- **Performance Optimization (N+1 Problem Resolution)**: Implemented batching mechanisms across `MessageHandler`, `ChatService`, `GroupService`, `MessageService`, and `UserService`. This replaces multiple individual database/cache queries with single batch operations for fetching unread counts, last messages, participants, and user information.
- **Enhanced Unread Count Management**: Migrated unread count tracking to a Redis Hash-based approach (`unread#️⃣{userID}`). This allows for atomic increments/decrements and efficient retrieval of both individual conversation unread counts and total unread counts for a user.
- **Improved Message Sequencing**: Integrated Redis-based sequence (`seq`) pre-allocation for messages to ensure strict ordering and reduce database contention during high-concurrency message creation.
- **Push Service Reliability**: Refactored the push notification system to include persistent `PushRecord` tracking. Added a recovery mechanism (`recoverPendingPushes`) to reload pending notifications from the database upon service startup, ensuring better delivery guarantees.
- **WebSocket Reliability**: Updated the WebSocket hub to move away from in-memory history replay in favor of a client-driven synchronization model (`sync_required` event and `ack` handling), reducing memory overhead and improving connection stability.
- **Cache Layer Enhancements**: Added `HIncrBy` and `IncrBySeq` to the `Cache` interface and its implementations (`RedisCache`, `LayeredCache`) to support the new unread and sequence management logic.
This commit is contained in:
6
internal/cache/cache.go
vendored
6
internal/cache/cache.go
vendored
@@ -48,6 +48,8 @@ type Cache interface {
|
||||
HGetAll(ctx context.Context, key string) (map[string]string, error)
|
||||
// HDel 删除 Hash 字段
|
||||
HDel(ctx context.Context, key string, fields ...string) error
|
||||
// HIncrBy 原子递增 Hash 字段的值
|
||||
HIncrBy(ctx context.Context, key string, field string, incr int64) (int64, error)
|
||||
|
||||
// ==================== Sorted Set 操作 ====================
|
||||
// ZAdd 添加 Sorted Set 成员
|
||||
@@ -68,6 +70,8 @@ type Cache interface {
|
||||
// ==================== 计数器操作 ====================
|
||||
// Incr 原子递增(返回新值)
|
||||
Incr(ctx context.Context, key string) (int64, error)
|
||||
// IncrBySeq 原子递增指定值(用于 seq 对齐)
|
||||
IncrBySeq(ctx context.Context, key string, delta int64) (int64, error)
|
||||
// Expire 设置过期时间
|
||||
Expire(ctx context.Context, key string, ttl time.Duration) error
|
||||
}
|
||||
@@ -95,7 +99,7 @@ var settings = Settings{
|
||||
NullTTL: 5 * time.Second,
|
||||
JitterRatio: 0.1,
|
||||
PostListTTL: 30 * time.Second,
|
||||
ConversationTTL: 60 * time.Second,
|
||||
ConversationTTL: 120 * time.Second,
|
||||
UnreadCountTTL: 30 * time.Second,
|
||||
GroupMembersTTL: 120 * time.Second,
|
||||
DisableFlushDB: true,
|
||||
|
||||
146
internal/cache/conversation_cache.go
vendored
146
internal/cache/conversation_cache.go
vendored
@@ -74,19 +74,23 @@ type ConversationCacheSettings struct {
|
||||
MessageListTTL time.Duration // 消息分页列表缓存 (5min)
|
||||
MessageIndexTTL time.Duration // 消息索引缓存 (30min)
|
||||
MessageCountTTL time.Duration // 消息计数缓存 (30min)
|
||||
|
||||
// Seq 缓存配置
|
||||
SeqTTL time.Duration // Seq 计数器缓存 (24h)
|
||||
}
|
||||
|
||||
// DefaultConversationCacheSettings 返回默认配置
|
||||
func DefaultConversationCacheSettings() *ConversationCacheSettings {
|
||||
return &ConversationCacheSettings{
|
||||
DetailTTL: 5 * time.Minute,
|
||||
ListTTL: 60 * time.Second,
|
||||
ParticipantTTL: 5 * time.Minute,
|
||||
DetailTTL: 15 * time.Minute,
|
||||
ListTTL: 120 * time.Second,
|
||||
ParticipantTTL: 15 * time.Minute,
|
||||
UnreadTTL: 30 * time.Second,
|
||||
MessageDetailTTL: 30 * time.Minute,
|
||||
MessageListTTL: 5 * time.Minute,
|
||||
MessageIndexTTL: 30 * time.Minute,
|
||||
MessageCountTTL: 30 * time.Minute,
|
||||
SeqTTL: 24 * time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,6 +484,142 @@ func (c *ConversationCache) InvalidateUnreadCount(userID, convID string) {
|
||||
c.cache.Delete(UnreadDetailKey(userID, convID))
|
||||
}
|
||||
|
||||
// IncrementUnread 递增用户在某会话的未读数(Redis Hash + 总数计数器)
|
||||
func (c *ConversationCache) IncrementUnread(ctx context.Context, userID, convID string) error {
|
||||
hashKey := UnreadHashKey(userID)
|
||||
totalKey := UnreadTotalKey(userID)
|
||||
|
||||
_, err := c.cache.HIncrBy(ctx, hashKey, convID, 1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hincrby unread failed: %w", err)
|
||||
}
|
||||
|
||||
_, err = c.cache.Incr(ctx, totalKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("incr unread total failed: %w", err)
|
||||
}
|
||||
|
||||
_ = c.cache.Expire(ctx, hashKey, c.settings.UnreadTTL*10)
|
||||
_ = c.cache.Expire(ctx, totalKey, c.settings.UnreadTTL*10)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearUnread 清零用户在某会话的未读数
|
||||
func (c *ConversationCache) ClearUnread(ctx context.Context, userID, convID string) error {
|
||||
hashKey := UnreadHashKey(userID)
|
||||
totalKey := UnreadTotalKey(userID)
|
||||
|
||||
// 读取当前未读数
|
||||
currentStr, err := c.cache.HGet(ctx, hashKey, convID)
|
||||
if err != nil {
|
||||
currentStr = "0"
|
||||
}
|
||||
current := int64(0)
|
||||
if v, parseErr := fmt.Sscanf(currentStr, "%d", ¤t); parseErr != nil || v != 1 {
|
||||
current = 0
|
||||
}
|
||||
|
||||
// 清零该会话未读数
|
||||
if err := c.cache.HSet(ctx, hashKey, convID, "0"); err != nil {
|
||||
return fmt.Errorf("hset clear unread failed: %w", err)
|
||||
}
|
||||
|
||||
// 减少总未读数
|
||||
if current > 0 {
|
||||
c.cache.IncrBySeq(ctx, totalKey, -current)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUnreadCountFromHash 从 Redis Hash 获取单个会话未读数
|
||||
func (c *ConversationCache) GetUnreadCountFromHash(ctx context.Context, userID, convID string) (int64, error) {
|
||||
hashKey := UnreadHashKey(userID)
|
||||
val, err := c.cache.HGet(ctx, hashKey, convID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var count int64
|
||||
fmt.Sscanf(val, "%d", &count)
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// GetAllUnreadCounts 从 Redis Hash 获取用户所有会话的未读数
|
||||
func (c *ConversationCache) GetAllUnreadCounts(ctx context.Context, userID string) (map[string]int64, error) {
|
||||
hashKey := UnreadHashKey(userID)
|
||||
all, err := c.cache.HGetAll(ctx, hashKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string]int64, len(all))
|
||||
for k, v := range all {
|
||||
var count int64
|
||||
fmt.Sscanf(v, "%d", &count)
|
||||
result[k] = count
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetTotalUnread 从 Redis 获取用户总未读数
|
||||
func (c *ConversationCache) GetTotalUnread(ctx context.Context, userID string) (int64, error) {
|
||||
totalKey := UnreadTotalKey(userID)
|
||||
|
||||
redisCache, ok := c.cache.(*RedisCache)
|
||||
if ok {
|
||||
val, err := redisCache.GetRedisClient().GetClient().Get(ctx, normalizeKey(totalKey)).Int64()
|
||||
if err != nil {
|
||||
return 0, nil
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
|
||||
raw, ok := c.cache.Get(totalKey)
|
||||
if !ok {
|
||||
return 0, nil
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case int64:
|
||||
return v, nil
|
||||
case string:
|
||||
var n int64
|
||||
fmt.Sscanf(v, "%d", &n)
|
||||
return n, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// GetNextSeq 获取会话的下一个 seq 值(原子递增)
|
||||
// 使用 Redis INCR 实现,首次调用时从 DB 初始化并补齐差值
|
||||
func (c *ConversationCache) GetNextSeq(ctx context.Context, convID string) (int64, error) {
|
||||
seqKey := MessageSeqKey(convID)
|
||||
|
||||
newSeq, err := c.cache.Incr(ctx, seqKey)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("redis incr seq failed: %w", err)
|
||||
}
|
||||
|
||||
// 首次调用时 Redis key 不存在,INCR 从 0 返回 1
|
||||
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 {
|
||||
// INCR 已返回 1,需要补齐 delta 使总值为 last_seq + 1
|
||||
alignedSeq, err := c.cache.IncrBySeq(ctx, seqKey, conv.LastSeq)
|
||||
if err != nil {
|
||||
return conv.LastSeq + 1, nil // 降级返回 DB 值
|
||||
}
|
||||
newSeq = alignedSeq
|
||||
}
|
||||
|
||||
_ = c.cache.Expire(ctx, seqKey, c.settings.SeqTTL)
|
||||
}
|
||||
|
||||
return newSeq, nil
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 消息缓存方法
|
||||
// ============================================================
|
||||
|
||||
12
internal/cache/keys.go
vendored
12
internal/cache/keys.go
vendored
@@ -26,6 +26,8 @@ const (
|
||||
PrefixUnreadSystem = "unread:system"
|
||||
PrefixUnreadConversation = "unread:conversation"
|
||||
PrefixUnreadDetail = "unread:detail"
|
||||
PrefixUnreadHash = "unread:hash"
|
||||
PrefixUnreadTotal = "unread:total"
|
||||
|
||||
// 用户相关
|
||||
PrefixUserInfo = "users:info"
|
||||
@@ -118,6 +120,16 @@ func UnreadDetailKey(userID, conversationID string) string {
|
||||
return fmt.Sprintf("%s:%s:%s", PrefixUnreadDetail, userID, conversationID)
|
||||
}
|
||||
|
||||
// UnreadHashKey 生成用户未读数 Hash 缓存键
|
||||
func UnreadHashKey(userID string) string {
|
||||
return fmt.Sprintf("%s:%s", PrefixUnreadHash, userID)
|
||||
}
|
||||
|
||||
// UnreadTotalKey 生成用户总未读数计数器键
|
||||
func UnreadTotalKey(userID string) string {
|
||||
return fmt.Sprintf("%s:%s", PrefixUnreadTotal, userID)
|
||||
}
|
||||
|
||||
// UserInfoKey 生成用户信息缓存键
|
||||
func UserInfoKey(userID string) string {
|
||||
return fmt.Sprintf("%s:%s", PrefixUserInfo, userID)
|
||||
|
||||
16
internal/cache/layered_cache.go
vendored
16
internal/cache/layered_cache.go
vendored
@@ -294,6 +294,14 @@ func (c *LayeredCache) HDel(ctx context.Context, key string, fields ...string) e
|
||||
return c.redis.HDel(ctx, key, fields...)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) HIncrBy(ctx context.Context, key string, field string, incr int64) (int64, error) {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return 0, ErrKeyNotFound
|
||||
}
|
||||
c.local.Delete(key)
|
||||
return c.redis.HIncrBy(ctx, key, field, incr)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) ZAdd(ctx context.Context, key string, score float64, member string) error {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return nil
|
||||
@@ -354,6 +362,14 @@ func (c *LayeredCache) Incr(ctx context.Context, key string) (int64, error) {
|
||||
return c.redis.Incr(ctx, key)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) IncrBySeq(ctx context.Context, key string, delta int64) (int64, error) {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return 0, ErrKeyNotFound
|
||||
}
|
||||
c.local.Delete(key)
|
||||
return c.redis.IncrBySeq(ctx, key, delta)
|
||||
}
|
||||
|
||||
func (c *LayeredCache) Expire(ctx context.Context, key string, ttl time.Duration) error {
|
||||
if !c.enabled || c.redis == nil {
|
||||
return nil
|
||||
|
||||
13
internal/cache/redis_cache.go
vendored
13
internal/cache/redis_cache.go
vendored
@@ -222,6 +222,12 @@ func (c *RedisCache) HDel(ctx context.Context, key string, fields ...string) err
|
||||
return c.client.HDel(ctx, key, fields...)
|
||||
}
|
||||
|
||||
// HIncrBy 原子递增 Hash 字段的值
|
||||
func (c *RedisCache) HIncrBy(ctx context.Context, key string, field string, incr int64) (int64, error) {
|
||||
key = normalizeKey(key)
|
||||
return c.client.HIncrBy(ctx, key, field, incr)
|
||||
}
|
||||
|
||||
// ==================== RedisCache Sorted Set 操作 ====================
|
||||
|
||||
// ZAdd 添加 Sorted Set 成员
|
||||
@@ -281,6 +287,13 @@ func (c *RedisCache) Incr(ctx context.Context, key string) (int64, error) {
|
||||
return c.client.Incr(ctx, key)
|
||||
}
|
||||
|
||||
// IncrBySeq 原子递增指定值(用于 seq 对齐)
|
||||
func (c *RedisCache) IncrBySeq(ctx context.Context, key string, delta int64) (int64, error) {
|
||||
key = normalizeKey(key)
|
||||
rdb := c.client.GetClient()
|
||||
return rdb.IncrBy(ctx, key, delta).Result()
|
||||
}
|
||||
|
||||
// Expire 设置过期时间
|
||||
func (c *RedisCache) Expire(ctx context.Context, key string, ttl time.Duration) error {
|
||||
key = normalizeKey(key)
|
||||
|
||||
Reference in New Issue
Block a user