即时通讯系统中的消息并不是一条写入数据库后就不再变化的普通记录。
一条文本消息发送后,可能继续经历送达、已读、撤回、引用、收藏和合并转发;群聊中的消息还可能与成员禁言、群公告、@成员、群角色变更等操作关联;红包、文件、名片等特殊消息,又需要同步外部业务状态。
如果把这些变化全部写成分散的 if else,消息处理逻辑很快会变得难以维护。新增一种消息类型时,开发人员往往需要同时修改发送接口、会话列表、消息详情、撤回逻辑和通知逻辑,任何一个位置遗漏都会造成状态不一致。
更适合即时通讯系统的做法,是围绕消息状态机建立统一事件模型。Spring Boot 负责业务编排,MySQL 保存消息事实与状态,Redis 保存短期事件数据和会话状态,WebSocket 则承担事件下发。
消息表不应承担全部业务含义
很多项目最初会建立一张 im_message 表,把所有信息都放进去:
消息内容
发送者
接收者
是否已读
是否撤回
是否删除
是否收藏
红包状态
文件状态
引用内容
转发记录
随着功能增加,这张表中的字段会不断膨胀。
例如,收藏是用户个人行为,同一条消息可能被部分用户收藏,也可能被其他用户忽略。如果直接在消息表中增加 is_collected,就无法表达不同用户的收藏状态。
已读状态也存在类似问题。单聊消息可以由接收者阅读,群聊消息则可能对应几十个甚至更多成员,无法用一个 is_read 字段表示所有人的阅读情况。
因此,消息主体只保存稳定事实:
- 谁发送了消息;
- 消息属于哪个会话;
- 消息类型是什么;
- 消息正文是什么;
- 当前消息是否有效;
- 消息创建时间和序号。
已读、收藏、撤回操作、群成员提醒等变化,应拆分为独立状态或事件记录。

先区分消息命令和消息事件
在事件驱动结构中,客户端提交的是命令,服务端产生的是事件。
命令表示用户希望执行什么操作,例如:
发送文本消息
撤回一条消息
阅读某个会话
收藏一条消息
领取一个红包
修改群公告
禁言群成员
事件表示服务端已经确认发生了什么,例如:
javascript
消息已创建
消息已撤回
会话已读位置已更新
消息已收藏
红包已领取
群公告已变更
群成员已被禁言
二者不能混为一谈。
用户提交"撤回消息"命令,不代表消息一定能够撤回。服务端还需要校验消息是否存在、操作者是否为发送者、是否超过允许时间、群管理员是否具备操作权限。
只有全部校验通过后,系统才能产生"消息已撤回"事件。
可以为命令建立统一接口:
javascript
public interface ImCommand {
String getCommandId();
Long getOperatorId();
String getCommandType();
Long getTimestamp();
}
不同业务命令实现该接口:
javascript
@Data
public class RevokeMessageCommand implements ImCommand {
private String commandId;
private Long operatorId;
private String commandType = "REVOKE_MESSAGE";
private Long timestamp;
private Long messageId;
private String conversationId;
}
commandId 用于避免客户端重复提交同一个操作。
用户网络不稳定时,撤回命令可能发送两次。服务端可以根据 commandId 判断该命令是否已经处理,避免重复生成撤回事件。
使用状态机限制不合理的消息变化
消息不能在任意状态之间随意切换。
例如,一条已经撤回的消息不能再次进入正常状态;已经被物理清理的消息也不能继续引用;文件上传失败时,不应生成可下载状态。
可以把消息核心状态定义为:
javascript
public enum MessageState {
CREATED,
AVAILABLE,
REVOKED,
EXPIRED,
DELETED
}
其中:
CREATED表示消息主体已经生成;AVAILABLE表示消息可以正常展示;REVOKED表示消息已撤回;EXPIRED适用于过期文件、临时位置或失效业务卡片;DELETED表示消息进入服务端清理状态。
状态之间的转换必须由明确事件触发。
javascript
CREATED → AVAILABLE
AVAILABLE → REVOKED
AVAILABLE → EXPIRED
REVOKED → DELETED
EXPIRED → DELETED
不允许出现:
javascript
REVOKED → AVAILABLE
DELETED → AVAILABLE
EXPIRED → CREATED
Spring Boot 中可以使用状态转换校验器集中处理:
javascript
@Component
public class MessageStateMachine {
private static final Map<MessageState, Set<MessageState>> TRANSITIONS =
new EnumMap<>(MessageState.class);
static {
TRANSITIONS.put(
MessageState.CREATED,
EnumSet.of(MessageState.AVAILABLE, MessageState.DELETED)
);
TRANSITIONS.put(
MessageState.AVAILABLE,
EnumSet.of(
MessageState.REVOKED,
MessageState.EXPIRED,
MessageState.DELETED
)
);
TRANSITIONS.put(
MessageState.REVOKED,
EnumSet.of(MessageState.DELETED)
);
TRANSITIONS.put(
MessageState.EXPIRED,
EnumSet.of(MessageState.DELETED)
);
TRANSITIONS.put(
MessageState.DELETED,
EnumSet.noneOf(MessageState.class)
);
}
public void check(MessageState current, MessageState target) {
Set<MessageState> targets = TRANSITIONS.get(current);
if (targets == null || !targets.contains(target)) {
throw new IllegalStateException(
"不允许的消息状态变化:" + current + " -> " + target
);
}
}
}
撤回、过期和删除逻辑不再分别判断大量状态,而是统一经过状态机校验。
以后增加"仅发送者可见""等待审核"等状态时,也可以在同一个位置维护转换规则。

消息类型和消息状态需要分开建模
文本、图片、文件、红包、名片和自定义表情属于消息类型。
正常、撤回、过期和删除属于消息状态。
这两个维度不能混在一个字段中。
例如,不应使用以下枚举:
javascript
TEXT
IMAGE
FILE
REVOKED
READ
COLLECTED
因为 TEXT 描述内容类型,而 REVOKED 描述生命周期状态,二者可以同时存在。一条文本消息可以是正常状态,也可以是撤回状态。
合理的数据结构可以拆分为:
javascript
message_type = FILE
message_state = AVAILABLE
文件失效后变为:
javascript
message_type = FILE
message_state = EXPIRED
消息类型决定客户端使用哪种组件渲染,消息状态决定这条内容是否可以继续展示和操作。
红包消息同样如此:
javascript
message_type = RED_PACKET
message_state = AVAILABLE
business_state = FINISHED
红包已经领完,并不代表聊天消息被撤回。会话中仍然可以显示红包卡片,只是卡片状态变为已领取或已结束。
因此,红包状态应由钱包或红包业务表维护,消息表只关联红包业务编号。
撤回消息本质上是新增事件
消息撤回不建议直接覆盖原消息正文,更不应该立即删除数据库记录。
服务端接收到撤回命令后,可以执行以下过程:
javascript
读取原消息
校验操作者
校验撤回时间
检查当前状态
执行状态转换
写入撤回事件
推送会话更新
撤回事件可以采用统一 JSON:
javascript
{
"eventId": "evt_20260717_89321",
"eventType": "MESSAGE_REVOKED",
"conversationId": "group_90018",
"messageId": 78263196,
"operatorId": 10018,
"occurredAt": 1784269800000
}
客户端收到事件后,根据 messageId 找到原消息,把内容区域替换为撤回提示。
这样处理有几个直接作用。
第一,已经加载原消息的客户端能够更新显示。
第二,尚未加载原消息的客户端查询历史记录时,可以直接读取撤回状态。
第三,后台可以保留必要的操作记录,便于群管理和异常排查。
群管理员撤回成员消息时,可以在事件中增加操作角色:
javascript
{
"operatorRole": "GROUP_ADMIN"
}
客户端便可以根据产品规则显示"管理员撤回了一条成员消息",而不需要修改原始消息内容。

引用回复只记录关联关系
引用回复经常被实现成复制原消息全文。
这种方式会造成大量重复数据,而且原消息撤回后,引用区域的显示规则也不容易统一。
更合适的结构是保存:
javascript
当前消息编号
被引用消息编号
引用摘要
引用消息类型
其中,被引用消息编号用于建立真实关联,摘要用于避免每次展示时都查询完整原消息。
例如,用户引用一条文件消息进行回复:
javascript
{
"text": "这个版本可以保留",
"quote": {
"messageId": 78263196,
"messageType": "FILE",
"summary": "产品需求说明书.pdf"
}
}
如果原文件之后失效,消息状态机可以把文件消息更新为 EXPIRED,但引用回复仍然保留当时的文件名称摘要。
如果原消息被撤回,客户端可以根据服务端规则决定显示摘要,或者显示"引用消息已撤回"。
引用关系本身不参与原消息状态转换,它只是消息之间的关联。
已读未读不属于消息主体状态
将消息状态设计成:
javascript
UNREAD
READ
REVOKED
看似简单,实际会产生冲突。
因为撤回描述的是消息自身状态,已读描述的是用户与消息之间的关系。一条群消息可以被部分成员阅读,也可能仍有其他成员未读。
因此,已读位置应该放在用户会话关系中:
javascript
user_id
conversation_id
last_read_seq
last_delivered_seq
update_time
用户进入会话后,上报自己已经阅读到的最大消息序号。
服务端只更新更大的序号:
javascript
UPDATE im_user_conversation
SET last_read_seq = :readSeq,
update_time = NOW()
WHERE user_id = :userId
AND conversation_id = :conversationId
AND last_read_seq < :readSeq;
未读数量可以根据会话最新序号和用户已读序号计算。
群聊中的 @成员 则可以建立单独的提醒事件表:
javascript
message_id
conversation_id
mentioned_user_id
handled
用户读完普通消息后,普通未读数量可能已经清零,但未处理的 @ 提醒仍然可以保留,直到用户真正进入对应消息位置。

收藏操作应该生成用户级事件
收藏不是消息公共状态。
用户 A 收藏某条图片消息,不应该影响用户 B 的消息显示。
可以建立 im_user_favorite 表:
javascript
id
user_id
message_id
message_type
content_snapshot
create_time
收藏命令通过校验后,产生 MESSAGE_FAVORITED 事件。
取消收藏则产生 MESSAGE_UNFAVORITED 事件。
保存内容快照是为了应对原消息撤回、文件过期或群聊解散等情况。快照不必复制所有字段,只保留收藏列表展示所需的信息。
文件收藏可以保存文件名、文件大小和资源编号;名片收藏可以保存用户昵称和头像快照;合并转发记录可以保存标题和消息摘要。
消息状态机仍然只管理原消息生命周期,收藏模块管理用户自己的收藏关系。
群管理操作也可以沿用事件模型
群公告、禁言、成员移除和管理员调整虽然不是聊天内容,但都会改变会话行为。
例如,管理员对某成员设置禁言后,服务端可以生成:
javascript
GROUP_MEMBER_MUTED
事件内容包含:
javascript
群编号
成员编号
操作者编号
禁言开始时间
禁言结束时间
成员发送消息时,权限校验模块读取当前群成员状态。如果仍在禁言时间内,就拒绝创建消息事件。
群公告更新后,可以生成 GROUP_NOTICE_UPDATED 事件。客户端收到后更新群公告区域,也可以根据配置在会话中插入一条系统消息。
这种系统消息仍然遵循统一消息结构,只是:
javascript
message_type = SYSTEM
系统消息可以进入会话历史,但不允许普通成员执行撤回、引用或红包领取等操作。
群成员通过扫一扫加入群聊时,也可以生成成员加入事件。二维码本身只提供带签名的入群参数,最终是否允许加入,仍由群规则和成员状态决定。

事件处理器不要直接堆在Controller中
WebSocket 或 HTTP Controller 的职责应尽量简单:
javascript
解析请求
识别命令类型
完成身份认证
交给命令处理器
返回处理结果
具体业务由对应处理器负责。
例如:
javascript
public interface CommandHandler<T extends ImCommand> {
String supportType();
void handle(T command);
}
Spring Boot 启动时,可以把所有处理器注册到路由表:
javascript
@Component
public class CommandHandlerRegistry {
private final Map<String, CommandHandler<?>> handlers;
public CommandHandlerRegistry(List<CommandHandler<?>> handlerList) {
this.handlers = handlerList.stream()
.collect(Collectors.toMap(
CommandHandler::supportType,
Function.identity()
));
}
public CommandHandler<?> getHandler(String commandType) {
CommandHandler<?> handler = handlers.get(commandType);
if (handler == null) {
throw new IllegalArgumentException(
"不支持的命令类型:" + commandType
);
}
return handler;
}
}
发送消息、撤回消息、收藏消息、更新已读位置和修改群公告分别使用不同处理器。
这样新增消息操作时,不需要继续扩展一个几百行的 Controller,也不会让红包、文件、群管理等逻辑互相影响。
事件表用于保存关键变化
不是所有 WebSocket 通知都需要永久保存。
例如,输入中状态、语音通话中的音量变化、临时心跳等事件只具有短期意义,可以放在 Redis 或直接推送。
以下事件则适合持久化:
- 消息创建;
- 消息撤回;
- 群公告变更;
- 群成员禁言;
- 群成员移除;
- 红包领取结果;
- 文件失效;
- 会话清理;
- 管理员操作。
事件表可以采用如下字段:
javascript
event_id
event_type
aggregate_type
aggregate_id
operator_id
event_data
occurred_at
aggregate_type 用于区分消息、群聊、红包或文件。
aggregate_id 表示当前事件作用于哪一个业务对象。
例如,一次消息撤回事件:
javascript
aggregate_type = MESSAGE
aggregate_id = 78263196
一次群公告更新事件:
javascript
aggregate_type = GROUP
aggregate_id = 90018
事件数据可以使用 JSON 保存变化内容,但经常用于查询的字段仍应单独建列,避免所有筛选都依赖 JSON 解析。

Redis只保存短期状态和事件分发信息
事件模型不代表所有事件都必须放入消息队列。
在结构相对简单的 Spring Boot 项目中,可以先使用数据库事务保存消息与事件记录,再通过 Redis 发布订阅或 Stream 分发更新。
Redis 适合保存:
javascript
命令幂等标识
短期事件队列
会话最新序号
群成员状态缓存
撤回事件缓存
临时通话信令
在线会话状态
MySQL 继续作为最终数据来源,保存消息主体、群成员关系、事件记录、用户收藏和已读位置。
当 Redis 中的短期事件丢失时,客户端仍然可以通过消息状态和事件表重新同步。
这条边界很重要。Redis 用于加快事件传播,MySQL 用于保留已经确认发生的业务事实。
i18n提示应由事件代码驱动
事件模型还能减少多语言处理中的重复文案。
服务端不直接返回固定中文提示,而是返回事件代码和参数:
javascript
{
"eventType": "GROUP_MEMBER_MUTED",
"params": {
"memberName": "Alex",
"durationSeconds": 600
}
}
客户端根据 i18n 语言包转换成对应文字。
简体中文可以显示:
javascript
Alex 已被禁言10分钟
繁体中文、英文或其他语言使用相同事件代码,只替换语言资源。
消息撤回、群公告更新、红包领取、文件失效和成员加入都可以采用相同模式,服务端无需为每种语言保存一份系统消息正文。

一条消息在系统中的完整状态轨迹
以群聊文件消息为例,它可能经历以下过程:
javascript
创建文件消息命令
→ 文件消息已创建
→ 消息状态进入AVAILABLE
→ 群成员收到消息事件
→ 部分成员更新已读位置
→ 用户收藏文件消息
→ 文件资源到期
→ 消息状态进入EXPIRED
→ 收藏列表保留文件快照
如果文件消息在有效期内被管理员撤回,则轨迹变为:
javascript
文件消息已创建
→ 消息状态进入AVAILABLE
→ 管理员提交撤回命令
→ 状态机校验转换
→ 生成MESSAGE_REVOKED事件
→ 消息状态进入REVOKED
→ 会话成员更新本地展示
文本、自定义表情、名片、红包和合并转发都可以使用相同的事件框架。区别只在消息内容和业务状态,命令处理、状态校验、事件生成和客户端更新流程保持一致。