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:
@@ -56,6 +56,10 @@ type ChatService interface {
|
||||
|
||||
// 仅保存消息到数据库,不发送实时推送(供群聊等自行推送的场景使用)
|
||||
SaveMessage(ctx context.Context, senderID string, conversationID string, segments model.MessageSegments, replyToID *string) (*model.Message, error)
|
||||
|
||||
// 批量查询(解决 N+1 问题)
|
||||
GetUnreadCountBatch(ctx context.Context, userID string, convIDs []string) (map[string]int64, error)
|
||||
GetLastMessagesBatch(ctx context.Context, convIDs []string) (map[string]*model.Message, error)
|
||||
}
|
||||
|
||||
// chatServiceImpl 聊天服务实现
|
||||
@@ -333,6 +337,19 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
|
||||
Status: model.MessageStatusNormal,
|
||||
}
|
||||
|
||||
// 从 Redis 获取下一个 seq
|
||||
if s.conversationCache != nil {
|
||||
seq, err := s.conversationCache.GetNextSeq(ctx, conversationID)
|
||||
if err != nil {
|
||||
zap.L().Warn("redis get next seq failed, falling back to DB",
|
||||
zap.String("convID", conversationID),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else {
|
||||
message.Seq = seq
|
||||
}
|
||||
}
|
||||
|
||||
// 使用事务创建消息并更新seq
|
||||
if err := s.repo.CreateMessageWithSeq(message); err != nil {
|
||||
return nil, fmt.Errorf("failed to save message: %w", err)
|
||||
@@ -397,6 +414,20 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
|
||||
}
|
||||
|
||||
if s.conversationCache != nil {
|
||||
// 使用 Redis Hash 预计算未读数
|
||||
for _, p := range participants {
|
||||
if p.UserID == senderID {
|
||||
continue
|
||||
}
|
||||
if incrErr := s.conversationCache.IncrementUnread(context.Background(), p.UserID, conversationID); incrErr != nil {
|
||||
zap.L().Warn("increment unread from redis hash failed",
|
||||
zap.String("userID", p.UserID),
|
||||
zap.String("convID", conversationID),
|
||||
zap.Error(incrErr),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(participants)*2)
|
||||
for _, p := range participants {
|
||||
keys = append(keys,
|
||||
@@ -559,11 +590,18 @@ func (s *chatServiceImpl) MarkAsRead(ctx context.Context, conversationID string,
|
||||
return fmt.Errorf("failed to update last read seq: %w", err)
|
||||
}
|
||||
|
||||
// 2. DB 写入成功后,失效缓存(Cache-Aside 模式)
|
||||
// 2. DB 写入成功后,清除 Redis 未读数
|
||||
if s.conversationCache != nil {
|
||||
if clearErr := s.conversationCache.ClearUnread(ctx, userID, conversationID); clearErr != nil {
|
||||
zap.L().Warn("clear unread from redis hash failed",
|
||||
zap.String("userID", userID),
|
||||
zap.String("convID", conversationID),
|
||||
zap.Error(clearErr),
|
||||
)
|
||||
}
|
||||
// 失效参与者缓存,下次读取时会从 DB 加载最新数据
|
||||
s.conversationCache.InvalidateParticipant(conversationID, userID)
|
||||
// 失效未读数缓存
|
||||
// 失效未读数缓存(旧 cache-aside 键)
|
||||
s.conversationCache.InvalidateUnreadCount(userID, conversationID)
|
||||
// 失效会话列表缓存
|
||||
s.conversationCache.InvalidateConversationList(userID)
|
||||
@@ -612,8 +650,13 @@ func (s *chatServiceImpl) GetUnreadCount(ctx context.Context, conversationID str
|
||||
return 0, fmt.Errorf("failed to get participant: %w", err)
|
||||
}
|
||||
|
||||
// 优先使用缓存
|
||||
// 优先从 Redis Hash 读取
|
||||
if s.conversationCache != nil {
|
||||
count, err := s.conversationCache.GetUnreadCountFromHash(ctx, userID, conversationID)
|
||||
if err == nil {
|
||||
return count, nil
|
||||
}
|
||||
// 降级到旧缓存路径
|
||||
return s.conversationCache.GetUnreadCount(ctx, userID, conversationID)
|
||||
}
|
||||
|
||||
@@ -622,6 +665,12 @@ func (s *chatServiceImpl) GetUnreadCount(ctx context.Context, conversationID str
|
||||
|
||||
// GetAllUnreadCount 获取所有会话的未读消息总数
|
||||
func (s *chatServiceImpl) GetAllUnreadCount(ctx context.Context, userID string) (int64, error) {
|
||||
// 优先从 Redis Hash 读取
|
||||
if s.conversationCache != nil {
|
||||
if total, err := s.conversationCache.GetTotalUnread(ctx, userID); err == nil {
|
||||
return total, nil
|
||||
}
|
||||
}
|
||||
return s.repo.GetAllUnreadCount(userID)
|
||||
}
|
||||
|
||||
@@ -798,6 +847,19 @@ func (s *chatServiceImpl) SaveMessage(ctx context.Context, senderID string, conv
|
||||
Status: model.MessageStatusNormal,
|
||||
}
|
||||
|
||||
// 从 Redis 获取下一个 seq(SaveMessage 方法)
|
||||
if s.conversationCache != nil {
|
||||
seq, err := s.conversationCache.GetNextSeq(ctx, conversationID)
|
||||
if err != nil {
|
||||
zap.L().Warn("redis get next seq failed, falling back to DB",
|
||||
zap.String("convID", conversationID),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else {
|
||||
message.Seq = seq
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.repo.CreateMessageWithSeq(message); err != nil {
|
||||
return nil, fmt.Errorf("failed to save message: %w", err)
|
||||
}
|
||||
@@ -821,3 +883,13 @@ func (s *chatServiceImpl) SaveMessage(ctx context.Context, senderID string, conv
|
||||
|
||||
return message, nil
|
||||
}
|
||||
|
||||
// GetUnreadCountBatch 批量获取用户在多个会话中的未读数
|
||||
func (s *chatServiceImpl) GetUnreadCountBatch(ctx context.Context, userID string, convIDs []string) (map[string]int64, error) {
|
||||
return s.repo.GetUnreadCountBatch(ctx, userID, convIDs)
|
||||
}
|
||||
|
||||
// GetLastMessagesBatch 批量获取每个会话的最后一条消息
|
||||
func (s *chatServiceImpl) GetLastMessagesBatch(ctx context.Context, convIDs []string) (map[string]*model.Message, error) {
|
||||
return s.repo.GetLastMessagesBatch(ctx, convIDs)
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ type GroupService interface {
|
||||
TransferOwner(userID string, groupID string, newOwnerID string) error
|
||||
GetUserGroups(userID string, page, pageSize int) ([]model.Group, int64, error)
|
||||
GetMemberCount(groupID string) (int, error)
|
||||
GetMemberCountBatch(ctx context.Context, groupIDs []string) (map[string]int64, error)
|
||||
|
||||
// 成员管理
|
||||
InviteMembers(userID string, groupID string, memberIDs []string) error
|
||||
@@ -106,25 +107,36 @@ type GroupMembersResult struct {
|
||||
|
||||
// groupService 群组服务实现
|
||||
type groupService struct {
|
||||
groupRepo repository.GroupRepository
|
||||
userRepo repository.UserRepository
|
||||
messageRepo repository.MessageRepository
|
||||
requestRepo repository.GroupJoinRequestRepository
|
||||
notifyRepo repository.SystemNotificationRepository
|
||||
wsHub *ws.Hub
|
||||
cache cache.Cache
|
||||
groupRepo repository.GroupRepository
|
||||
userRepo repository.UserRepository
|
||||
messageRepo repository.MessageRepository
|
||||
requestRepo repository.GroupJoinRequestRepository
|
||||
notifyRepo repository.SystemNotificationRepository
|
||||
wsHub *ws.Hub
|
||||
cache cache.Cache
|
||||
conversationCache *cache.ConversationCache
|
||||
}
|
||||
|
||||
// NewGroupService 创建群组服务
|
||||
func NewGroupService(groupRepo repository.GroupRepository, userRepo repository.UserRepository, messageRepo repository.MessageRepository, requestRepo repository.GroupJoinRequestRepository, notifyRepo repository.SystemNotificationRepository, wsHub *ws.Hub, cacheBackend cache.Cache) GroupService {
|
||||
convRepoAdapter := cache.NewConversationRepositoryAdapter(messageRepo)
|
||||
msgRepoAdapter := cache.NewMessageRepositoryAdapter(messageRepo)
|
||||
conversationCache := cache.NewConversationCache(
|
||||
cacheBackend,
|
||||
convRepoAdapter,
|
||||
msgRepoAdapter,
|
||||
cache.DefaultConversationCacheSettings(),
|
||||
)
|
||||
|
||||
return &groupService{
|
||||
groupRepo: groupRepo,
|
||||
userRepo: userRepo,
|
||||
messageRepo: messageRepo,
|
||||
requestRepo: requestRepo,
|
||||
notifyRepo: notifyRepo,
|
||||
wsHub: wsHub,
|
||||
cache: cacheBackend,
|
||||
groupRepo: groupRepo,
|
||||
userRepo: userRepo,
|
||||
messageRepo: messageRepo,
|
||||
requestRepo: requestRepo,
|
||||
notifyRepo: notifyRepo,
|
||||
wsHub: wsHub,
|
||||
cache: cacheBackend,
|
||||
conversationCache: conversationCache,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +311,11 @@ func (s *groupService) GetMemberCount(groupID string) (int, error) {
|
||||
return int(count), nil
|
||||
}
|
||||
|
||||
// GetMemberCountBatch 批量获取多个群的成员数量
|
||||
func (s *groupService) GetMemberCountBatch(ctx context.Context, groupIDs []string) (map[string]int64, error) {
|
||||
return s.groupRepo.GetMemberCountBatch(ctx, groupIDs)
|
||||
}
|
||||
|
||||
// UpdateGroup 更新群组信息
|
||||
func (s *groupService) UpdateGroup(userID string, groupID string, updates map[string]any) error {
|
||||
// 检查群组是否存在
|
||||
@@ -453,6 +470,18 @@ func (s *groupService) broadcastMemberJoinNotice(groupID string, targetUserID st
|
||||
Status: model.MessageStatusNormal,
|
||||
Category: model.CategoryNotification,
|
||||
}
|
||||
// 从 Redis 获取下一个 seq
|
||||
if s.conversationCache != nil {
|
||||
seq, seqErr := s.conversationCache.GetNextSeq(context.Background(), conv.ID)
|
||||
if seqErr != nil {
|
||||
zap.L().Warn("redis get next seq failed, falling back to DB",
|
||||
zap.String("convID", conv.ID),
|
||||
zap.Error(seqErr),
|
||||
)
|
||||
} else {
|
||||
msg.Seq = seq
|
||||
}
|
||||
}
|
||||
if err := s.messageRepo.CreateMessageWithSeq(msg); err != nil {
|
||||
zap.L().Warn("保存入群提示消息失败",
|
||||
zap.String("component", "broadcastMemberJoinNotice"),
|
||||
@@ -1366,7 +1395,19 @@ func (s *groupService) MuteMember(userID string, groupID string, targetUserID st
|
||||
Category: model.CategoryNotification,
|
||||
}
|
||||
|
||||
// 保存消息并获取 seq
|
||||
// 从 Redis 获取下一个 seq
|
||||
if s.conversationCache != nil {
|
||||
seq, seqErr := s.conversationCache.GetNextSeq(context.Background(), conv.ID)
|
||||
if seqErr != nil {
|
||||
zap.L().Warn("redis get next seq failed, falling back to DB",
|
||||
zap.String("convID", conv.ID),
|
||||
zap.Error(seqErr),
|
||||
)
|
||||
} else {
|
||||
msg.Seq = seq
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.messageRepo.CreateMessageWithSeq(msg); err != nil {
|
||||
zap.L().Warn("保存禁言消息失败",
|
||||
zap.String("component", "MuteMember"),
|
||||
|
||||
@@ -86,6 +86,19 @@ func (s *MessageService) SendMessage(ctx context.Context, senderID, receiverID s
|
||||
Status: model.MessageStatusNormal,
|
||||
}
|
||||
|
||||
// 从 Redis 获取下一个 seq
|
||||
if s.conversationCache != nil {
|
||||
seq, seqErr := s.conversationCache.GetNextSeq(context.Background(), conv.ID)
|
||||
if seqErr != nil {
|
||||
zap.L().Warn("redis get next seq failed, falling back to DB",
|
||||
zap.String("convID", conv.ID),
|
||||
zap.Error(seqErr),
|
||||
)
|
||||
} else {
|
||||
msg.Seq = seq
|
||||
}
|
||||
}
|
||||
|
||||
// 使用事务创建消息并更新seq
|
||||
err = s.messageRepo.CreateMessageWithSeq(msg)
|
||||
if err != nil {
|
||||
@@ -284,6 +297,16 @@ func (s *MessageService) GetConversationParticipants(conversationID string) ([]*
|
||||
return s.messageRepo.GetConversationParticipants(conversationID)
|
||||
}
|
||||
|
||||
// GetParticipantsBatch 批量获取多个会话的参与者列表
|
||||
func (s *MessageService) GetParticipantsBatch(ctx context.Context, convIDs []string) (map[string][]*model.ConversationParticipant, error) {
|
||||
return s.messageRepo.GetParticipantsBatch(ctx, convIDs)
|
||||
}
|
||||
|
||||
// GetMyParticipantsBatch 批量获取用户在多个会话中的参与者信息
|
||||
func (s *MessageService) GetMyParticipantsBatch(ctx context.Context, convIDs []string, userID string) (map[string]*model.ConversationParticipant, error) {
|
||||
return s.messageRepo.GetMyParticipantsBatch(ctx, convIDs, userID)
|
||||
}
|
||||
|
||||
// ParseConversationID 辅助函数:直接返回字符串ID(已经是string类型)
|
||||
func ParseConversationID(idStr string) (string, error) {
|
||||
return idStr, nil
|
||||
|
||||
@@ -94,6 +94,7 @@ type pushTask struct {
|
||||
userID string
|
||||
message *model.Message
|
||||
priority int
|
||||
recordID int64 // DB record ID for crash recovery
|
||||
}
|
||||
|
||||
// NewPushService 创建推送服务
|
||||
@@ -152,21 +153,31 @@ func (s *pushServiceImpl) PushToUser(ctx context.Context, userID string, message
|
||||
}
|
||||
}
|
||||
|
||||
// 极光推送也失败,加入推送队列等待重试
|
||||
// 极光推送也失败,先持久化推送记录再入内存队列
|
||||
expiredAt := time.Now().Add(DefaultExpiredTime)
|
||||
record := &model.PushRecord{
|
||||
UserID: userID,
|
||||
MessageID: message.ID,
|
||||
PushChannel: model.PushChannelJPush,
|
||||
PushStatus: model.PushStatusPending,
|
||||
MaxRetry: MaxRetryCount,
|
||||
ExpiredAt: &expiredAt,
|
||||
}
|
||||
if err := s.pushRepo.Create(record); err != nil {
|
||||
return fmt.Errorf("failed to persist push record: %w", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case s.pushQueue <- &pushTask{
|
||||
userID: userID,
|
||||
message: message,
|
||||
priority: priority,
|
||||
recordID: record.ID,
|
||||
}:
|
||||
return nil
|
||||
default:
|
||||
// 队列已满,直接创建待推送记录
|
||||
_, err := s.CreatePushRecord(ctx, userID, message.ID, model.PushChannelJPush)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create pending push record: %w", err)
|
||||
}
|
||||
return errors.New("push queue is full, message queued for later delivery")
|
||||
// 队列已满,记录已持久化,等待重试协程处理
|
||||
return errors.New("push queue is full, message persisted for later delivery")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,10 +446,50 @@ func (s *pushServiceImpl) GetUserDevices(ctx context.Context, userID string) ([]
|
||||
|
||||
// StartPushWorker 启动推送工作协程
|
||||
func (s *pushServiceImpl) StartPushWorker(ctx context.Context) {
|
||||
s.recoverPendingPushes()
|
||||
go s.processPushQueue()
|
||||
go s.retryFailedPushes()
|
||||
}
|
||||
|
||||
// recoverPendingPushes 从数据库恢复未处理的推送记录
|
||||
func (s *pushServiceImpl) recoverPendingPushes() {
|
||||
records, err := s.pushRepo.GetPendingPushes(100)
|
||||
if err != nil {
|
||||
zap.L().Error("failed to recover pending push records", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
recovered := 0
|
||||
for _, record := range records {
|
||||
if record.IsExpired() {
|
||||
s.pushRepo.UpdateStatus(record.ID, model.PushStatusExpired)
|
||||
continue
|
||||
}
|
||||
|
||||
message, err := s.messageRepo.GetMessageByID(record.MessageID)
|
||||
if err != nil {
|
||||
s.pushRepo.MarkAsFailed(record.ID, "message not found during recovery")
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case s.pushQueue <- &pushTask{
|
||||
userID: record.UserID,
|
||||
message: message,
|
||||
priority: int(PriorityNormal),
|
||||
recordID: record.ID,
|
||||
}:
|
||||
recovered++
|
||||
default:
|
||||
// 队列已满,保留 pending 状态等待下次恢复
|
||||
}
|
||||
}
|
||||
|
||||
if recovered > 0 {
|
||||
zap.L().Info("recovered pending push records", zap.Int("count", recovered))
|
||||
}
|
||||
}
|
||||
|
||||
// StopPushWorker 停止推送工作协程
|
||||
func (s *pushServiceImpl) StopPushWorker() {
|
||||
close(s.stopChan)
|
||||
@@ -464,8 +515,8 @@ func (s *pushServiceImpl) processPushTask(task *pushTask) {
|
||||
// 获取用户活跃设备
|
||||
devices, err := s.deviceRepo.GetActiveByUserID(task.userID)
|
||||
if err != nil || len(devices) == 0 {
|
||||
// 没有可用设备,创建待推送记录
|
||||
s.CreatePushRecord(ctx, task.userID, task.message.ID, model.PushChannelJPush)
|
||||
// 没有可用设备,保留记录为 pending 等待重试
|
||||
s.pushRepo.UpdateStatus(task.recordID, model.PushStatusFailed)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -480,6 +531,8 @@ func (s *pushServiceImpl) processPushTask(task *pushTask) {
|
||||
err := s.pushViaJPushBatch(mobileDevices, payload.Title, payload.Content, payload.Extras)
|
||||
if err == nil {
|
||||
s.batchCreatePushedRecords(ctx, task.userID, task.message.ID, mobileDevices)
|
||||
// 原始 pending 记录标记为已推送(被批量记录替代)
|
||||
s.pushRepo.UpdateStatus(task.recordID, model.PushStatusPushed)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -508,6 +561,8 @@ func (s *pushServiceImpl) processPushTask(task *pushTask) {
|
||||
}
|
||||
s.pushRepo.Update(record)
|
||||
}
|
||||
// 原始 pending 记录标记为已处理
|
||||
s.pushRepo.UpdateStatus(task.recordID, model.PushStatusPushed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ type UserService interface {
|
||||
|
||||
// 用户查询
|
||||
GetUserByID(ctx context.Context, id string) (*model.User, error)
|
||||
GetUsersByIDs(ctx context.Context, ids []string) (map[string]*model.User, error)
|
||||
GetUserPostCount(ctx context.Context, userID string) (int64, error)
|
||||
GetUserPostCountBatch(ctx context.Context, userIDs []string) (map[string]int64, error)
|
||||
GetUserByIDWithFollowingStatus(ctx context.Context, userID, currentUserID string) (*model.User, bool, error)
|
||||
@@ -386,6 +387,11 @@ func (s *userServiceImpl) GetUserByID(ctx context.Context, id string) (*model.Us
|
||||
return s.userRepo.GetByID(id)
|
||||
}
|
||||
|
||||
// GetUsersByIDs 批量获取用户信息
|
||||
func (s *userServiceImpl) GetUsersByIDs(ctx context.Context, ids []string) (map[string]*model.User, error) {
|
||||
return s.userRepo.GetUsersByIDs(ctx, ids)
|
||||
}
|
||||
|
||||
// GetUserPostCount 获取用户帖子数(实时计算)
|
||||
func (s *userServiceImpl) GetUserPostCount(ctx context.Context, userID string) (int64, error) {
|
||||
return s.userRepo.GetPostsCount(userID)
|
||||
|
||||
Reference in New Issue
Block a user