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:
@@ -14,6 +14,76 @@ import (
|
||||
"with_you/internal/service"
|
||||
)
|
||||
|
||||
// enrichConversations 批量填充会话列表响应数据(解决 N+1 问题)
|
||||
func (h *MessageHandler) enrichConversations(ctx context.Context, convs []*model.Conversation, userID string) []*dto.ConversationResponse {
|
||||
if len(convs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
convIDs := make([]string, len(convs))
|
||||
for i, c := range convs {
|
||||
convIDs[i] = c.ID
|
||||
}
|
||||
|
||||
// 批量查询:4-5 次查询替代 N*5 次
|
||||
unreadCounts, _ := h.chatService.GetUnreadCountBatch(ctx, userID, convIDs)
|
||||
lastMessages, _ := h.chatService.GetLastMessagesBatch(ctx, convIDs)
|
||||
myParticipants, _ := h.messageService.GetMyParticipantsBatch(ctx, convIDs, userID)
|
||||
allParticipants, _ := h.messageService.GetParticipantsBatch(ctx, convIDs)
|
||||
|
||||
// 收集需要额外查询的 ID
|
||||
var groupIDs []string
|
||||
var userIDs []string
|
||||
for _, conv := range convs {
|
||||
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
|
||||
groupIDs = append(groupIDs, *conv.GroupID)
|
||||
}
|
||||
}
|
||||
for _, participants := range allParticipants {
|
||||
for _, p := range participants {
|
||||
if p.UserID != userID {
|
||||
userIDs = append(userIDs, p.UserID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
memberCounts, _ := h.groupService.GetMemberCountBatch(ctx, groupIDs)
|
||||
usersMap, _ := h.userService.GetUsersByIDs(ctx, userIDs)
|
||||
|
||||
// 组装响应
|
||||
result := make([]*dto.ConversationResponse, len(convs))
|
||||
for i, conv := range convs {
|
||||
unreadCount := unreadCounts[conv.ID]
|
||||
lastMessage := lastMessages[conv.ID]
|
||||
|
||||
myP := myParticipants[conv.ID]
|
||||
isPinned := myP != nil && myP.IsPinned
|
||||
notificationMuted := myP != nil && myP.NotificationMuted
|
||||
|
||||
var resp *dto.ConversationResponse
|
||||
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
|
||||
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned, notificationMuted)
|
||||
if mc, ok := memberCounts[*conv.GroupID]; ok {
|
||||
resp.MemberCount = int(mc)
|
||||
}
|
||||
} else {
|
||||
participants := allParticipants[conv.ID]
|
||||
var users []*model.User
|
||||
for _, p := range participants {
|
||||
if p.UserID == userID {
|
||||
continue
|
||||
}
|
||||
if u, ok := usersMap[p.UserID]; ok {
|
||||
users = append(users, u)
|
||||
}
|
||||
}
|
||||
resp = dto.ConvertConversationToResponse(conv, users, int(unreadCount), lastMessage, isPinned, notificationMuted)
|
||||
}
|
||||
result[i] = resp
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MessageHandler 消息处理器
|
||||
type MessageHandler struct {
|
||||
chatService service.ChatService
|
||||
@@ -80,37 +150,8 @@ func (h *MessageHandler) GetConversations(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := make([]*dto.ConversationResponse, len(filteredConvs))
|
||||
for i, conv := range filteredConvs {
|
||||
// 获取未读数
|
||||
unreadCount, _ := h.chatService.GetUnreadCount(c.Request.Context(), conv.ID, userID)
|
||||
|
||||
// 获取最后一条消息
|
||||
var lastMessage *model.Message
|
||||
messages, _, _ := h.chatService.GetMessages(c.Request.Context(), conv.ID, userID, 1, 1)
|
||||
if len(messages) > 0 {
|
||||
lastMessage = messages[0]
|
||||
}
|
||||
|
||||
// 群聊时返回member_count,私聊时返回participants
|
||||
var resp *dto.ConversationResponse
|
||||
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
|
||||
isPinned := myParticipant != nil && myParticipant.IsPinned
|
||||
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
|
||||
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
|
||||
// 群聊:实时计算群成员数量
|
||||
memberCount, _ := h.groupService.GetMemberCount(*conv.GroupID)
|
||||
// 创建响应并设置member_count
|
||||
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned, notificationMuted)
|
||||
resp.MemberCount = memberCount
|
||||
} else {
|
||||
// 私聊:获取参与者信息
|
||||
participants, _ := h.getConversationParticipants(c.Request.Context(), conv.ID, userID)
|
||||
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned, notificationMuted)
|
||||
}
|
||||
result[i] = resp
|
||||
}
|
||||
// 批量填充会话数据(解决 N+1 问题)
|
||||
result := h.enrichConversations(c.Request.Context(), filteredConvs, userID)
|
||||
|
||||
// 更新 total 为过滤后的数量
|
||||
response.Paginated(c, result, int64(len(filteredConvs)), page, pageSize)
|
||||
@@ -437,37 +478,8 @@ func (h *MessageHandler) HandleGetConversationList(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
result := make([]*dto.ConversationResponse, len(filteredConvs))
|
||||
for i, conv := range filteredConvs {
|
||||
// 获取未读数
|
||||
unreadCount, _ := h.chatService.GetUnreadCount(c.Request.Context(), conv.ID, userID)
|
||||
|
||||
// 获取最后一条消息
|
||||
var lastMessage *model.Message
|
||||
messages, _, _ := h.chatService.GetMessages(c.Request.Context(), conv.ID, userID, 1, 1)
|
||||
if len(messages) > 0 {
|
||||
lastMessage = messages[0]
|
||||
}
|
||||
|
||||
// 群聊时返回member_count,私聊时返回participants
|
||||
var resp *dto.ConversationResponse
|
||||
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
|
||||
isPinned := myParticipant != nil && myParticipant.IsPinned
|
||||
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
|
||||
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
|
||||
// 群聊:实时计算群成员数量
|
||||
memberCount, _ := h.groupService.GetMemberCount(*conv.GroupID)
|
||||
// 创建响应并设置member_count
|
||||
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned, notificationMuted)
|
||||
resp.MemberCount = memberCount
|
||||
} else {
|
||||
// 私聊:获取参与者信息
|
||||
participants, _ := h.getConversationParticipants(c.Request.Context(), conv.ID, userID)
|
||||
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned, notificationMuted)
|
||||
}
|
||||
result[i] = resp
|
||||
}
|
||||
// 批量填充会话数据(解决 N+1 问题)
|
||||
result := h.enrichConversations(c.Request.Context(), filteredConvs, userID)
|
||||
|
||||
response.Paginated(c, result, int64(len(filteredConvs)), page, pageSize)
|
||||
}
|
||||
@@ -1017,37 +1029,8 @@ func (h *MessageHandler) GetConversationsByCursor(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
items := make([]*dto.ConversationResponse, len(filteredItems))
|
||||
for i, conv := range filteredItems {
|
||||
// 获取未读数
|
||||
unreadCount, _ := h.chatService.GetUnreadCount(c.Request.Context(), conv.ID, userID)
|
||||
|
||||
// 获取最后一条消息
|
||||
var lastMessage *model.Message
|
||||
messages, _, _ := h.chatService.GetMessages(c.Request.Context(), conv.ID, userID, 1, 1)
|
||||
if len(messages) > 0 {
|
||||
lastMessage = messages[0]
|
||||
}
|
||||
|
||||
// 群聊时返回member_count,私聊时返回participants
|
||||
var resp *dto.ConversationResponse
|
||||
myParticipant, _ := h.getMyConversationParticipant(conv.ID, userID)
|
||||
isPinned := myParticipant != nil && myParticipant.IsPinned
|
||||
notificationMuted := myParticipant != nil && myParticipant.NotificationMuted
|
||||
if conv.Type == model.ConversationTypeGroup && conv.GroupID != nil && *conv.GroupID != "" {
|
||||
// 群聊:实时计算群成员数量
|
||||
memberCount, _ := h.groupService.GetMemberCount(*conv.GroupID)
|
||||
resp = dto.ConvertConversationToResponse(conv, nil, int(unreadCount), lastMessage, isPinned, notificationMuted)
|
||||
resp.MemberCount = memberCount
|
||||
} else {
|
||||
// 私聊:获取参与者信息
|
||||
participants, _ := h.getConversationParticipants(c.Request.Context(), conv.ID, userID)
|
||||
resp = dto.ConvertConversationToResponse(conv, participants, int(unreadCount), lastMessage, isPinned, notificationMuted)
|
||||
}
|
||||
items[i] = resp
|
||||
}
|
||||
|
||||
// 批量填充会话数据(解决 N+1 问题)
|
||||
items := h.enrichConversations(c.Request.Context(), filteredItems, userID)
|
||||
response.Success(c, &dto.ConversationCursorPageResponse{
|
||||
Items: items,
|
||||
NextCursor: result.NextCursor,
|
||||
|
||||
@@ -53,7 +53,7 @@ func (h *QRCodeHandler) WSEvents(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ch, cancel, replay := h.qrcodeService.GetWSHub().Subscribe(sessionID, 0)
|
||||
ch, cancel := h.qrcodeService.GetWSHub().Subscribe(sessionID, 0)
|
||||
defer cancel()
|
||||
|
||||
w := c.Writer
|
||||
@@ -82,13 +82,6 @@ func (h *QRCodeHandler) WSEvents(c *gin.Context) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 发送历史事件
|
||||
for _, ev := range replay {
|
||||
if !writeEvent(ev) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 心跳
|
||||
heartbeat := time.NewTicker(25 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
@@ -140,14 +140,15 @@ func (h *WSHandler) HandleWebSocket(c *gin.Context) {
|
||||
// 3. 创建客户端
|
||||
clientID := atomic.AddUint64(&h.clientSeq, 1)
|
||||
client := &ws.Client{
|
||||
ID: clientID,
|
||||
UserID: userID,
|
||||
Send: make(chan []byte, defaultUserBufferSize),
|
||||
Quit: make(chan struct{}),
|
||||
ID: clientID,
|
||||
UserID: userID,
|
||||
Send: make(chan []byte, defaultUserBufferSize),
|
||||
Quit: make(chan struct{}),
|
||||
PendingAcks: make(map[string]time.Time),
|
||||
}
|
||||
|
||||
// 4. 注册客户端
|
||||
replayEvents, regErr := h.wsHub.Register(client)
|
||||
regErr := h.wsHub.Register(client)
|
||||
if regErr != nil {
|
||||
zap.L().Warn("WebSocket registration rejected",
|
||||
zap.String("user_id", userID),
|
||||
@@ -165,23 +166,18 @@ func (h *WSHandler) HandleWebSocket(c *gin.Context) {
|
||||
zap.Uint64("client_id", clientID),
|
||||
)
|
||||
|
||||
// 5. 发送历史回放消息
|
||||
go func() {
|
||||
for _, ev := range replayEvents {
|
||||
msg := ws.ResponseMessage{
|
||||
EventID: ev.ID,
|
||||
Type: ev.Type,
|
||||
TS: ev.TS,
|
||||
Payload: ev.Payload,
|
||||
}
|
||||
data, _ := json.Marshal(msg)
|
||||
select {
|
||||
case <-client.Quit:
|
||||
return
|
||||
case client.Send <- data:
|
||||
}
|
||||
// 5. 提示客户端进行 seq 同步
|
||||
syncMsg := ws.ResponseMessage{
|
||||
EventID: h.wsHub.NextID(),
|
||||
Type: "sync_required",
|
||||
TS: time.Now().UnixMilli(),
|
||||
}
|
||||
if syncData, err := json.Marshal(syncMsg); err == nil {
|
||||
select {
|
||||
case <-client.Quit:
|
||||
case client.Send <- syncData:
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 6. 启动读写goroutine
|
||||
go h.writePump(conn, client)
|
||||
@@ -322,6 +318,8 @@ func (h *WSHandler) handleMessage(client *ws.Client, msg *ws.Message) {
|
||||
h.handleRead(ctx, client, msg.Payload)
|
||||
case "recall":
|
||||
h.handleRecall(ctx, client, msg.Payload)
|
||||
case "ack":
|
||||
h.handleAck(client, msg.Payload)
|
||||
// 通话信令
|
||||
case "call_invite":
|
||||
h.handleCallInvite(ctx, client, msg.Payload)
|
||||
@@ -511,6 +509,17 @@ func (h *WSHandler) handleRecall(ctx context.Context, client *ws.Client, payload
|
||||
|
||||
const defaultUserBufferSize = 128
|
||||
|
||||
// handleAck 处理客户端ACK确认
|
||||
func (h *WSHandler) handleAck(client *ws.Client, payload json.RawMessage) {
|
||||
var req struct {
|
||||
MessageID string `json:"message_id"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &req); err != nil || req.MessageID == "" {
|
||||
return
|
||||
}
|
||||
h.wsHub.AckMessage(client.UserID, req.MessageID)
|
||||
}
|
||||
|
||||
// isVerified 检查用户是否已通过身份认证
|
||||
func (h *WSHandler) isVerified(ctx context.Context, client *ws.Client) bool {
|
||||
user, err := h.userRepo.GetByID(client.UserID)
|
||||
|
||||
Reference in New Issue
Block a user