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

@@ -13,6 +13,7 @@ import (
"with_you/internal/pkg/ws"
"with_you/internal/repository"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
)
@@ -48,6 +49,9 @@ type ChatService interface {
// 消息同步
GetSyncData(ctx context.Context, userID string) ([]dto.SyncDataItem, error)
// 增量同步(版本日志)
GetSyncByVersion(ctx context.Context, userID string, sinceVersion int64, limit int) (*dto.SyncVersionResponse, error)
// 消息扩展功能
RecallMessage(ctx context.Context, messageID string, userID string) error
DeleteMessage(ctx context.Context, messageID string, userID string) error
@@ -80,6 +84,11 @@ type chatServiceImpl struct {
conversationCache *cache.ConversationCache
uploadService *UploadService
versionLogRepo repository.ConversationVersionLogRepository
versionLogEnabled bool
versionLogMaxGap int64
pushWorker *PushWorker
redisClient *redis.Client
}
// NewChatService 创建聊天服务
@@ -91,6 +100,10 @@ func NewChatService(
cacheBackend cache.Cache,
uploadService *UploadService,
pushSvc PushService,
seqBufferMgr *cache.SeqBufferManager,
pushWorker *PushWorker,
redisClient *redis.Client,
versionLogRepo ...repository.ConversationVersionLogRepository,
) ChatService {
// 创建适配器
convRepoAdapter := cache.NewConversationRepositoryAdapter(repo)
@@ -102,9 +115,10 @@ func NewChatService(
convRepoAdapter,
msgRepoAdapter,
cache.DefaultConversationCacheSettings(),
seqBufferMgr,
)
return &chatServiceImpl{
impl := &chatServiceImpl{
repo: repo,
userRepo: userRepo,
sensitive: sensitive,
@@ -113,7 +127,17 @@ func NewChatService(
cache: cacheBackend,
conversationCache: conversationCache,
uploadService: uploadService,
pushWorker: pushWorker,
redisClient: redisClient,
}
if len(versionLogRepo) > 0 && versionLogRepo[0] != nil {
impl.versionLogRepo = versionLogRepo[0]
impl.versionLogEnabled = true
impl.versionLogMaxGap = 1000
}
return impl
}
func (s *chatServiceImpl) publishToUsers(userIDs []string, event string, payload any) {
@@ -123,6 +147,115 @@ func (s *chatServiceImpl) publishToUsers(userIDs []string, event string, payload
s.wsHub.PublishToUsers(userIDs, event, payload)
}
// writeVersionLog 写入版本日志(异步,失败仅 warn
func (s *chatServiceImpl) writeVersionLog(ctx context.Context, userID, conversationID, changeType string) {
if !s.versionLogEnabled || s.versionLogRepo == nil {
return
}
maxV, err := s.versionLogRepo.GetMaxVersion(ctx, userID)
if err != nil {
zap.L().Warn("writeVersionLog: get max version failed", zap.String("userID", userID), zap.Error(err))
return
}
vlog := &model.ConversationVersionLog{
UserID: userID,
ConversationID: conversationID,
Version: maxV + 1,
ChangeType: changeType,
}
if err := s.versionLogRepo.Create(ctx, vlog); err != nil {
zap.L().Warn("writeVersionLog: create failed", zap.String("userID", userID), zap.Error(err))
}
}
// writeVersionLogBatch 批量写入版本日志(异步,失败仅 warn
func (s *chatServiceImpl) writeVersionLogBatch(ctx context.Context, userIDs []string, conversationID, changeType string) {
if !s.versionLogEnabled || s.versionLogRepo == nil {
return
}
logs := make([]*model.ConversationVersionLog, 0, len(userIDs))
for _, uid := range userIDs {
maxV, err := s.versionLogRepo.GetMaxVersion(ctx, uid)
if err != nil {
zap.L().Warn("writeVersionLogBatch: get max version failed", zap.String("userID", uid), zap.Error(err))
continue
}
logs = append(logs, &model.ConversationVersionLog{
UserID: uid,
ConversationID: conversationID,
Version: maxV + 1,
ChangeType: changeType,
})
}
if len(logs) > 0 {
if err := s.versionLogRepo.BatchCreate(ctx, logs); err != nil {
zap.L().Warn("writeVersionLogBatch: batch create failed", zap.Error(err))
}
}
}
// GetSyncByVersion 增量同步:按版本号获取会话变更
func (s *chatServiceImpl) GetSyncByVersion(ctx context.Context, userID string, sinceVersion int64, limit int) (*dto.SyncVersionResponse, error) {
if !s.versionLogEnabled || s.versionLogRepo == nil {
return nil, errors.New("version log sync is not enabled")
}
if limit <= 0 {
limit = 100
}
currentVersion, err := s.versionLogRepo.GetMaxVersion(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to get current version: %w", err)
}
resp := &dto.SyncVersionResponse{
CurrentVersion: currentVersion,
}
if currentVersion == 0 {
resp.FullSync = true
return resp, nil
}
gap := currentVersion - sinceVersion
if gap > s.versionLogMaxGap {
resp.FullSync = true
return resp, nil
}
vlogs, err := s.versionLogRepo.GetByVersion(ctx, userID, sinceVersion, limit+1)
if err != nil {
return nil, fmt.Errorf("failed to get version logs: %w", err)
}
hasMore := len(vlogs) > limit
if hasMore {
vlogs = vlogs[:limit]
}
changes := make([]dto.SyncChangeItem, 0, len(vlogs))
for _, vl := range vlogs {
item := dto.SyncChangeItem{
ConversationID: vl.ConversationID,
ChangeType: vl.ChangeType,
Version: vl.Version,
}
conv, cErr := s.repo.GetConversation(vl.ConversationID)
if cErr == nil && conv != nil {
item.LastMessageAt = conv.UpdatedAt.Unix()
if maxSeq, sErr := s.conversationCache.GetConvMaxSeq(ctx, vl.ConversationID); sErr == nil {
item.MaxSeq = maxSeq
}
}
changes = append(changes, item)
}
resp.Changes = changes
resp.HasMore = hasMore
return resp, nil
}
// GetOrCreateConversation 获取或创建私聊会话
func (s *chatServiceImpl) GetOrCreateConversation(ctx context.Context, user1ID, user2ID string) (*model.Conversation, error) {
conv, err := s.repo.GetOrCreatePrivateConversation(user1ID, user2ID)
@@ -205,6 +338,8 @@ func (s *chatServiceImpl) DeleteConversationForSelf(ctx context.Context, convers
s.conversationCache.InvalidateConversationList(userID)
}
go s.writeVersionLog(context.Background(), userID, conversationID, "hide")
return nil
}
@@ -387,6 +522,13 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
// 获取会话中的参与者并发送消息
participants, err := s.getParticipants(ctx, conversationID)
if err == nil {
// 版本日志:为所有参与者写入 new_msg
pids := make([]string, 0, len(participants))
for _, p := range participants {
pids = append(pids, p.UserID)
}
go s.writeVersionLogBatch(context.Background(), pids, conversationID, "new_msg")
// 先更新未读计数器,再推送 WS 事件,确保事件中的 total_unread 已包含新消息
// 注意:已移除 IncrementUnread未读数完全由 maxSeq - readSeq 算术计算
if s.conversationCache != nil {
@@ -405,38 +547,63 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
}
}
targetIDs := make([]string, 0, len(participants))
for _, p := range participants {
if conv.Type == model.ConversationTypePrivate && p.UserID == senderID {
continue
// PushWorker 异步推送 or inline fallback
if s.pushWorker != nil && s.pushWorker.IsEnabled() {
streamMsg := &PushStreamMsg{
ConversationID: conversationID,
SenderID: senderID,
MessageID: message.ID,
ConvType: string(conv.Type),
Timestamp: time.Now().UnixMilli(),
}
if pubErr := PublishToPush(context.Background(), s.redisClient, "msg_push", streamMsg); pubErr != nil {
zap.L().Warn("push stream publish failed, falling back to inline push",
zap.String("conversationID", conversationID),
zap.Error(pubErr),
)
s.inlinePush(ctx, conv, participants, senderID, conversationID, message)
}
targetIDs = append(targetIDs, p.UserID)
}
detailType := "private"
if conv.Type == model.ConversationTypeGroup {
detailType = "group"
}
if len(targetIDs) > 20 && conv.Type == model.ConversationTypeGroup {
s.wsHub.PublishToUsersConcurrent(targetIDs, "chat_message", map[string]any{
"detail_type": detailType,
"message": dto.ConvertMessageToResponse(message),
})
} else {
s.publishToUsers(targetIDs, "chat_message", map[string]any{
"detail_type": detailType,
"message": dto.ConvertMessageToResponse(message),
})
s.inlinePush(ctx, conv, participants, senderID, conversationID, message)
}
for _, p := range participants {
if p.UserID == senderID {
continue
}
if totalUnread, uErr := s.GetAllUnreadCount(ctx, p.UserID); uErr == nil {
s.publishToUsers([]string{p.UserID}, "conversation_unread", map[string]any{
"conversation_id": conversationID,
"total_unread": totalUnread,
})
}
}
return message, nil
}
// inlinePush 内联推送原有逻辑PushWorker 失败时的 fallback
func (s *chatServiceImpl) inlinePush(ctx context.Context, conv *model.Conversation, participants []*model.ConversationParticipant, senderID, conversationID string, message *model.Message) {
targetIDs := make([]string, 0, len(participants))
for _, p := range participants {
if conv.Type == model.ConversationTypePrivate && p.UserID == senderID {
continue
}
targetIDs = append(targetIDs, p.UserID)
}
detailType := "private"
if conv.Type == model.ConversationTypeGroup {
detailType = "group"
}
if len(targetIDs) > 20 && conv.Type == model.ConversationTypeGroup {
s.wsHub.PublishToUsersConcurrent(targetIDs, "chat_message", map[string]any{
"detail_type": detailType,
"message": dto.ConvertMessageToResponse(message),
})
} else {
s.publishToUsers(targetIDs, "chat_message", map[string]any{
"detail_type": detailType,
"message": dto.ConvertMessageToResponse(message),
})
}
for _, p := range participants {
if p.UserID == senderID {
continue
}
if totalUnread, uErr := s.GetAllUnreadCount(ctx, p.UserID); uErr == nil {
s.publishToUsers([]string{p.UserID}, "conversation_unread", map[string]any{
"conversation_id": conversationID,
"total_unread": totalUnread,
})
}
}
@@ -482,8 +649,6 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
}(offlineTargetIDs, sender, groupID, groupAvatar)
}
}
return message, nil
}
func (s *chatServiceImpl) getConversation(ctx context.Context, conversationID string) (*model.Conversation, error) {