纲要
- 业务需求分析
- 好友在线状态
- 群成员在线状态
- 状态变更的感知机制
- 方案设计
- 基于
Redis的集中式状态存储 - 登录/退出时更新
Hash缓存 - 查询在线状态:先获取关系列表,再批量查缓存
- 状态变更通知方案对比(
WebSocket推送 vs 客户端轮询/按需拉取)
- 基于
- 代码实现
- 项目结构介绍
- 配置
Redis连接与常量定义 - 登录成功后写入在线状态
- 好友在线状态查询接口
- 群成员在线状态查询接口
- 测试与验证
- 总结
用户在线状态业务分析
在即时通讯(IM)系统中,用户在线状态是最基础的功能之一。当用户登录后,好友列表中对应的头像会从灰色变为彩色,表示上线;退出或断开连接后恢复灰色,表示离线。看似简单的功能,背后却涉及登录状态记录、关系链查询、状态变更通知等多方面设计。
好友状态
好友在线状态的核心点包括:
- 用户登录/退出时的处理:服务器需要记录当前在线用户,并在用户离线时及时清除记录。
- 获取全部好友在线状态:用户登录后,应能够立即知晓所有好友的在线情况。
- 状态变更传播:用户 A 的好友 B 状态发生变化时,A 需要感知到。
群成员状态
群成员状态的复杂性更高:
- 群数量多、成员规模大,无法直接套用好友的实时推送方式。
- 用户不会同时关注所有群,实际活跃的仅有少数几个。
- 更适合采用按需拉取 或延迟拉取:在用户打开某个群会话时,才去获取该群成员的在线状态;并可设置一定时间间隔进行增量刷新。
技术选型与方案设计
采用 Go-Zero 微服务框架构建,将用户状态数据存放于 Redis,选择 Hash 数据结构:
- Key : 固定常量,如
user:online:status(代表整套在线状态哈希表)。 - Field: 用户ID。
- Value : 状态信息或标志位(如
1表示在线,可扩展为设备信息等)。
这样设计的好处是查询单个或批量用户状态时非常高效,利用 HMGET 或 HGETALL 即可。
登录/退出时的状态维护流程
Redis User RPC API 服务 客户端 Redis User RPC API 服务 客户端 #mermaid-svg-v6PgIQZQmG73FzaA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-v6PgIQZQmG73FzaA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-v6PgIQZQmG73FzaA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-v6PgIQZQmG73FzaA .error-icon{fill:#552222;}#mermaid-svg-v6PgIQZQmG73FzaA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-v6PgIQZQmG73FzaA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-v6PgIQZQmG73FzaA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-v6PgIQZQmG73FzaA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-v6PgIQZQmG73FzaA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-v6PgIQZQmG73FzaA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-v6PgIQZQmG73FzaA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-v6PgIQZQmG73FzaA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-v6PgIQZQmG73FzaA .marker.cross{stroke:#333333;}#mermaid-svg-v6PgIQZQmG73FzaA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-v6PgIQZQmG73FzaA p{margin:0;}#mermaid-svg-v6PgIQZQmG73FzaA .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-v6PgIQZQmG73FzaA text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-v6PgIQZQmG73FzaA .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-v6PgIQZQmG73FzaA .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-v6PgIQZQmG73FzaA .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-v6PgIQZQmG73FzaA .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-v6PgIQZQmG73FzaA #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-v6PgIQZQmG73FzaA .sequenceNumber{fill:white;}#mermaid-svg-v6PgIQZQmG73FzaA #sequencenumber{fill:#333;}#mermaid-svg-v6PgIQZQmG73FzaA #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-v6PgIQZQmG73FzaA .messageText{fill:#333;stroke:none;}#mermaid-svg-v6PgIQZQmG73FzaA .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-v6PgIQZQmG73FzaA .labelText,#mermaid-svg-v6PgIQZQmG73FzaA .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-v6PgIQZQmG73FzaA .loopText,#mermaid-svg-v6PgIQZQmG73FzaA .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-v6PgIQZQmG73FzaA .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-v6PgIQZQmG73FzaA .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-v6PgIQZQmG73FzaA .noteText,#mermaid-svg-v6PgIQZQmG73FzaA .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-v6PgIQZQmG73FzaA .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-v6PgIQZQmG73FzaA .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-v6PgIQZQmG73FzaA .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-v6PgIQZQmG73FzaA .actorPopupMenu{position:absolute;}#mermaid-svg-v6PgIQZQmG73FzaA .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-v6PgIQZQmG73FzaA .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-v6PgIQZQmG73FzaA .actor-man circle,#mermaid-svg-v6PgIQZQmG73FzaA line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-v6PgIQZQmG73FzaA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 登录请求登录验证登录成功,返回用户IDHSET user:online:status {userId} 1登录成功退出请求HDEL user:online:status {userId}退出成功
好友在线状态查询流程
用户登录后,API 服务需要返回所有好友的在线状态:
- 调用社交服务获取好友ID列表。
- 批量查询
Redis中user:online:status的对应字段。 - 组装结果返回。
Redis Social RPC API 服务 客户端 Redis Social RPC API 服务 客户端 #mermaid-svg-tiZJxzsMeYjLnddG{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-tiZJxzsMeYjLnddG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-tiZJxzsMeYjLnddG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-tiZJxzsMeYjLnddG .error-icon{fill:#552222;}#mermaid-svg-tiZJxzsMeYjLnddG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-tiZJxzsMeYjLnddG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-tiZJxzsMeYjLnddG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-tiZJxzsMeYjLnddG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-tiZJxzsMeYjLnddG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-tiZJxzsMeYjLnddG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-tiZJxzsMeYjLnddG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-tiZJxzsMeYjLnddG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-tiZJxzsMeYjLnddG .marker.cross{stroke:#333333;}#mermaid-svg-tiZJxzsMeYjLnddG svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-tiZJxzsMeYjLnddG p{margin:0;}#mermaid-svg-tiZJxzsMeYjLnddG .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-tiZJxzsMeYjLnddG text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-tiZJxzsMeYjLnddG .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-tiZJxzsMeYjLnddG .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-tiZJxzsMeYjLnddG .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-tiZJxzsMeYjLnddG .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-tiZJxzsMeYjLnddG #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-tiZJxzsMeYjLnddG .sequenceNumber{fill:white;}#mermaid-svg-tiZJxzsMeYjLnddG #sequencenumber{fill:#333;}#mermaid-svg-tiZJxzsMeYjLnddG #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-tiZJxzsMeYjLnddG .messageText{fill:#333;stroke:none;}#mermaid-svg-tiZJxzsMeYjLnddG .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-tiZJxzsMeYjLnddG .labelText,#mermaid-svg-tiZJxzsMeYjLnddG .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-tiZJxzsMeYjLnddG .loopText,#mermaid-svg-tiZJxzsMeYjLnddG .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-tiZJxzsMeYjLnddG .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-tiZJxzsMeYjLnddG .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-tiZJxzsMeYjLnddG .noteText,#mermaid-svg-tiZJxzsMeYjLnddG .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-tiZJxzsMeYjLnddG .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-tiZJxzsMeYjLnddG .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-tiZJxzsMeYjLnddG .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-tiZJxzsMeYjLnddG .actorPopupMenu{position:absolute;}#mermaid-svg-tiZJxzsMeYjLnddG .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-tiZJxzsMeYjLnddG .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-tiZJxzsMeYjLnddG .actor-man circle,#mermaid-svg-tiZJxzsMeYjLnddG line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-tiZJxzsMeYjLnddG :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 查询好友在线状态获取好友列表 (userId)好友ID列表HMGET user:online:status id1 id2 ...在线状态列表好友在线状态 Map
群成员在线状态查询流程
与好友类似,但查询的是指定群的成员ID,同样通过社交服务获取成员列表,然后批量查 Redis。客户端在打开群会话时触发请求,可配合定时器按需刷新。
代码实现
项目结构
假设项目名为 im,包含 api(网关)、rpc(用户服务、社交服务)等模块。与在线状态相关的改动主要集中在 api 层的业务逻辑和配置文件。
dir
im/
├── api/
│ ├── etc/
│ │ └── im-api.yaml
│ ├── internal/
│ │ ├── config/
│ │ │ └── config.go
│ │ ├── logic/
│ │ │ ├── loginlogic.go
│ │ │ ├── friendsonlinelogic.go
│ │ │ └── groupmemberonlinelogic.go
│ │ ├── svc/
│ │ │ └── servicecontext.go
│ │ └── types/
│ │ └── types.go
│ ├── im.api
│ └── im.go
├── rpc/
│ ├── user/
│ └── social/
└── go.mod
配置文件与依赖注入
在 im-api.yaml 中添加 Redis 配置:
yaml
Name: im-api
Host: 0.0.0.0
Port: 8888
Redis:
Host: 127.0.0.1:6379
Pass: ""
Type: node
UserRpc:
Etcd:
Hosts:
- 127.0.0.1:2379
Key: user.rpc
SocialRpc:
Etcd:
Hosts:
- 127.0.0.1:2379
Key: social.rpc
在 internal/config/config.go 中定义对应的配置结构体:
go
package config
import (
"github.com/zeromicro/go-zero/core/stores/redis"
"github.com/zeromicro/go-zero/zrpc"
)
type Config struct {
rest.RestConf
Redis redis.RedisConf
UserRpc zrpc.RpcClientConf
SocialRpc zrpc.RpcClientConf
}
在 internal/svc/servicecontext.go 中初始化 Redis 和 RPC 客户端:
go
package svc
import (
"github.com/zeromicro/go-zero/core/stores/redis"
"github.com/zeromicro/go-zero/zrpc"
"im/api/internal/config"
"im/rpc/user/userclient"
"im/rpc/social/socialclient"
)
type ServiceContext struct {
Config config.Config
Redis *redis.Redis
UserRpc userclient.User
SocialRpc socialclient.Social
}
func NewServiceContext(c config.Config) *ServiceContext {
return &ServiceContext{
Config: c,
Redis: redis.MustNewRedis(c.Redis),
UserRpc: userclient.NewUser(zrpc.MustNewClient(c.UserRpc)),
SocialRpc: socialclient.NewSocial(zrpc.MustNewClient(c.SocialRpc)),
}
}
定义常量
在 internal/consts/consts.go(或直接放在 logic 层)中定义 Redis 在线状态 Key:
go
package consts
const UserOnlineStatusKey = "user:online:status"
登录成功后设置在线状态
假设登录逻辑在 internal/logic/loginlogic.go,修改登录成功后的处理:
go
package logic
import (
"context"
"fmt"
"im/api/internal/consts"
"im/api/internal/svc"
"im/api/internal/types"
"im/rpc/user/userclient"
"github.com/zeromicro/go-zero/core/logx"
)
type LoginLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic {
return &LoginLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *LoginLogic) Login(req *types.LoginReq) (resp *types.LoginResp, err error) {
// 调用用户 RPC 进行登录验证
loginResp, err := l.svcCtx.UserRpc.Login(l.ctx, &userclient.LoginRequest{
Username: req.Username,
Password: req.Password,
})
if err != nil {
return nil, err
}
userId := loginResp.UserId
// 登录成功,将用户 ID 写入 Redis 在线状态哈希表
_, err = l.svcCtx.Redis.Hset(consts.UserOnlineStatusKey, fmt.Sprintf("%d", userId), "1")
if err != nil {
l.Errorf("set user online status failed: %v", err)
// 写入失败可根据业务决定是否让登录失败,这里仅记录日志并允许登录继续
}
return &types.LoginResp{
UserId: userId,
Token: loginResp.Token,
}, nil
}
'''
**退出登录**的处理类似,调用 `HDEL` 移除字段即可:
'''go
func (l *LogoutLogic) Logout(req *types.LogoutReq) error {
_, err := l.svcCtx.Redis.Hdel(consts.UserOnlineStatusKey, fmt.Sprintf("%d", req.UserId))
return err
}
好友在线状态查询接口
在 im.api 文件中定义请求/响应类型和接口:
go
// 好友在线状态查询(无需请求参数,从 token 中获取当前用户 ID)
@server(
jwt: Auth
)
get /friends/online (FriendsOnlineReq) returns (FriendsOnlineResp)
对应 types/types.go:
go
type FriendsOnlineReq struct {
}
type FriendsOnlineResp struct {
OnlineStatus map[int64]bool `json:"onlineStatus"` // key: 好友 ID, value: 是否在线
}
在 internal/logic/friendsonlinelogic.go 中实现:
go
package logic
import (
"context"
"fmt"
"im/api/internal/consts"
"im/api/internal/svc"
"im/api/internal/types"
"im/rpc/social/socialclient"
"github.com/zeromicro/go-zero/core/logx"
)
type FriendsOnlineLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewFriendsOnlineLogic(ctx context.Context, svcCtx *svc.ServiceContext) *FriendsOnlineLogic {
return &FriendsOnlineLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *FriendsOnlineLogic) FriendsOnline(req *types.FriendsOnlineReq) (resp *types.FriendsOnlineResp, err error) {
// 从上下文中获取当前登录用户 ID(由 JWT 中间件注入)
userId := l.ctx.Value("userId").(int64)
// 1. 调用社交服务获取好友 ID 列表
friendsResp, err := l.svcCtx.SocialRpc.GetFriendList(l.ctx, &socialclient.GetFriendListReq{
UserId: userId,
})
if err != nil {
return nil, err
}
friendIds := friendsResp.FriendIds
if len(friendIds) == 0 {
return &types.FriendsOnlineResp{OnlineStatus: map[int64]bool{}}, nil
}
// 2. 构建 Redis HMGET 所需的字段列表
fields := make([]string, len(friendIds))
for i, id := range friendIds {
fields[i] = fmt.Sprintf("%d", id)
}
// 3. 批量查询在线状态
vals, err := l.svcCtx.Redis.Hmget(consts.UserOnlineStatusKey, fields...)
if err != nil {
return nil, err
}
// 4. 组装结果
onlineStatus := make(map[int64]bool, len(friendIds))
for i, id := range friendIds {
onlineStatus[id] = vals[i] != ""
}
return &types.FriendsOnlineResp{OnlineStatus: onlineStatus}, nil
}
群成员在线状态查询接口
与好友在线状态类似,但需要传入群 ID,并调用社交服务获取群成员列表。定义 API 和类型:
go
// im.api 中增加
@server(
jwt: Auth
)
post /group/members/online (GroupMembersOnlineReq) returns (GroupMembersOnlineResp)
go
// types.go
type GroupMembersOnlineReq struct {
GroupId int64 `json:"groupId"`
}
type GroupMembersOnlineResp struct {
OnlineStatus map[int64]bool `json:"onlineStatus"`
}
Logic 实现:
go
package logic
import (
"context"
"fmt"
"im/api/internal/consts"
"im/api/internal/svc"
"im/api/internal/types"
"im/rpc/social/socialclient"
)
type GroupMembersOnlineLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGroupMembersOnlineLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GroupMembersOnlineLogic {
return &GroupMembersOnlineLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GroupMembersOnlineLogic) GroupMembersOnline(req *types.GroupMembersOnlineReq) (resp *types.GroupMembersOnlineResp, err error) {
// 调用社交服务获取群成员 ID 列表
membersResp, err := l.svcCtx.SocialRpc.GetGroupMembers(l.ctx, &socialclient.GetGroupMembersReq{
GroupId: req.GroupId,
})
if err != nil {
return nil, err
}
memberIds := membersResp.MemberIds
if len(memberIds) == 0 {
return &types.GroupMembersOnlineResp{OnlineStatus: map[int64]bool{}}, nil
}
// 构建字段列表,HMGET 查询
fields := make([]string, len(memberIds))
for i, id := range memberIds {
fields[i] = fmt.Sprintf("%d", id)
}
vals, err := l.svcCtx.Redis.Hmget(consts.UserOnlineStatusKey, fields...)
if err != nil {
return nil, err
}
onlineStatus := make(map[int64]bool, len(memberIds))
for i, id := range memberIds {
onlineStatus[id] = vals[i] != ""
}
return &types.GroupMembersOnlineResp{OnlineStatus: onlineStatus}, nil
}
测试与验证
启动所有服务后,使用 curl 或 Postman 进行测试。
- 登录用户 A,返回
userId: 1001。 - 调用
GET /friends/online,查看好友在线状态(此时可能全部离线)。 - 使用另一个账号 B(
userId: 1002)登录,再次调用 A 的好友在线状态接口,可看到 B 变为在线。 - 退出 B,再次查询,B 变为离线。
- 使用群成员在线接口
POST /group/members/online,传入groupId,获得该群成员的在线情况。
测试过程中需确保 Redis 正常运行,且 RPC 服务返回正确的数据。
总结
利用 Go-Zero 微服务框架和 Redis Hash 结构,我们可以高效地实现 IM 系统的用户在线状态维护。该方案的优势在于:
- 低延迟 :全部操作基于内存缓存,查询批量在线状态只需一次
HMGET。 - 架构清晰:状态存储与业务逻辑解耦,API 层通过 RPC 获取关系链数据,再与缓存交互。
- 易扩展 :
Hash的字段值可以扩展为 JSON,记录设备类型、登录时间等丰富信息。
对于状态变更的实时性要求,可进一步引入 WebSocket 或 Server-Sent Events 进行推送;在群聊场景中结合按需拉取,能够很好地平衡性能与用户体验。