内容社区发展到一定阶段后,系统中的一次用户操作往往不再只修改一张数据表。
用户点赞一篇图文,除了新增点赞记录,还会改变内容统计、作者获赞数量和消息提醒;用户评论一条短视频,系统需要保存评论、更新评论数,并向内容作者发送通知;用户收藏商品后,个人中心的收藏管理、商品详情页和其他登录终端也应同步展示最新状态。
当项目同时包含图文发布、短视频、多图上传、话题标签、用户关系、即时通讯和在线交易时,真正复杂的部分并不是把接口逐个写出来,而是保证不同模块在高并发、网络重试和服务异常情况下仍能得到相对一致的结果。
以 Spring Boot 内容社区源码为背景,从事件驱动、事务记录、幂等消费和状态机几个方面,讨论内容、互动、通知和订单之间的数据流转方式。

从一次评论操作开始拆分系统行为
假设用户在发现页看到一篇内容,并提交一条评论。
从页面上看,这只是一次普通的评论请求。进入服务端后,至少会产生以下变化:
javascript
保存评论记录
更新内容评论数量
生成评论通知
更新作者未读提醒
刷新内容详情缓存
通过 WebSocket 推送消息
记录用户互动行为
如果所有逻辑都写进同一个接口,评论服务就需要直接依赖内容服务、通知服务、缓存组件和 WebSocket 推送组件。
代码可能会逐渐变成下面这种调用结构:
javascript
CommentController
└── CommentService
├── commentRepository.insert()
├── contentService.increaseCommentCount()
├── notificationService.create()
├── redisService.deleteContentCache()
├── websocketService.push()
└── behaviorService.save()
这种结构在初期比较直观,但后续增加评论回复、系统通知、点赞提醒、关注提醒和内容推荐行为时,评论服务的职责会不断扩大。
更合理的做法是让评论模块只完成评论业务本身,然后发布一个"评论已创建"的领域事件。其他模块根据需要订阅该事件,各自完成统计、通知和行为记录。
业务事件不等于直接调用异步方法
在 Spring Boot 项目中,可以通过事件对象表达一次已经完成的业务事实。
javascript
public record CommentCreatedEvent(
Long eventId,
Long commentId,
Long contentId,
Long authorId,
Long senderId,
Long createdAt
) {
}
这个事件只描述发生了什么,不负责决定所有后续操作。
内容统计模块收到事件后增加评论数,通知模块生成评论通知,用户行为模块记录互动数据,WebSocket 模块再根据用户在线状态决定是否实时推送。
事件驱动可以减少模块之间的直接依赖,但不能简单地在数据库事务中调用一个异步线程。
例如,评论记录尚未提交时就发送异步事件,一旦数据库事务回滚,通知可能已经生成,内容作者会看到一条实际不存在的评论提醒。
因此,业务数据和待发布事件需要具备一致的落库顺序。

使用本地事件表保存待处理任务
一种常见做法是在保存业务数据的同时,向事件表写入一条记录。
评论数据和事件记录位于同一个数据库事务中。只要事务提交成功,事件就不会丢失;事务回滚时,两条记录会一起撤销。
事件表可以包含以下字段:
javascript
event_id
event_type
aggregate_id
event_body
event_status
retry_count
next_retry_time
created_time
processed_time
评论服务的核心代码可以写成:
javascript
@Transactional(rollbackFor = Exception.class)
public Long createComment(CreateCommentCommand command) {
ContentComment comment = new ContentComment();
comment.setContentId(command.contentId());
comment.setUserId(command.userId());
comment.setReplyUserId(command.replyUserId());
comment.setContent(command.content());
comment.setStatus(CommentStatus.NORMAL);
commentRepository.insert(comment);
CommentCreatedEvent event = new CommentCreatedEvent(
idGenerator.nextId(),
comment.getId(),
comment.getContentId(),
command.contentAuthorId(),
command.userId(),
System.currentTimeMillis()
);
DomainEventRecord record = new DomainEventRecord();
record.setEventId(event.eventId());
record.setEventType("COMMENT_CREATED");
record.setAggregateId(comment.getId());
record.setEventBody(jsonService.toJson(event));
record.setEventStatus(EventStatus.PENDING);
record.setRetryCount(0);
eventRepository.insert(record);
return comment.getId();
}
这里没有直接更新未读数,也没有立即调用 WebSocket。评论服务只保证评论与事件同时写入数据库。
后续可以由 Quartz 定时扫描待处理事件,也可以通过独立的事件发布任务发送到消息队列。即使消息组件暂时不可用,事件仍保留在数据库中,恢复后可以继续处理。
这类方式比较适合需要独立部署、二次开发的内容社区项目,因为事件表结构清晰,排查数据问题时也能看到完整处理状态。
事件消费必须考虑重复执行
事件进入消费端后,并不能假设每条消息只会执行一次。
网络超时、消费者重启、任务重试或消息确认失败,都可能导致同一个事件被再次处理。
假设评论事件第一次已经让评论数增加了 1,但消费结果尚未成功确认,系统随后重试该事件。如果没有幂等控制,评论数会再次增加,最终与实际评论记录数量不一致。
通知模块同样如此。一次评论可能生成两条完全相同的评论通知,用户在安卓 APP 和 H5 网页端都会看到重复提醒。
一种实现方式是建立事件消费记录表,以事件编号和消费者名称作为唯一键。
javascript
@Transactional(rollbackFor = Exception.class)
public void handleCommentCreated(CommentCreatedEvent event) {
String consumerName = "content-statistics-consumer";
boolean inserted = consumedEventRepository.tryInsert(
event.eventId(),
consumerName
);
if (!inserted) {
return;
}
contentStatisticsRepository.increaseCommentCount(
event.contentId(),
1
);
redisTemplate.delete(
"content:statistics:" + event.contentId()
);
}
tryInsert 依赖数据库唯一索引实现。如果事件已经被当前消费者处理过,再次插入时会失败,业务逻辑直接结束。
不同消费者可以独立记录消费状态。例如,统计模块处理成功,不代表通知模块也已成功。通过消费者名称区分后,各模块可以分别重试。
这种幂等机制同样适用于点赞、收藏、关注、评论回复、系统通知和订单状态变化。

内容统计不要和行为记录混在一起
内容详情页通常会展示点赞数、评论数和收藏数,用户主页还会展示作品数量、关注数量、粉丝数量等信息。
这些统计值可以单独维护,但不能代替真实关系数据。
例如,用户点赞内容时,应先写入点赞关系表:
javascript
content_id
user_id
created_time
status
并为 content_id + user_id 建立唯一约束,防止客户端重复提交造成重复点赞。
内容点赞总数属于统计结果,可以通过事件异步更新。用户是否已经点赞,则应从关系数据或用户维度缓存中读取。
这两类数据的含义不同:
javascript
点赞关系回答"谁点赞了哪条内容"
点赞统计回答"这条内容共有多少点赞"
收藏、关注和粉丝关系也是同样的思路。
个人中心中的收藏管理、关注列表和粉丝列表,应以真实关系表为准;发现页和用户主页展示的统计数量,则可以读取缓存或统计表。
这样即使统计数据短时间延迟,也不会影响用户取消收藏、取消关注等关键操作。
Redis缓存失效应由业务事件触发
内容社区中的热门数据通常会放入 Redis,例如:
javascript
内容详情
用户主页摘要
商品详情
内容互动统计
热门搜索词
发现页候选内容
缓存并不是写入之后一直等待过期。内容被编辑、下架或删除时,相关缓存需要及时清理;点赞、评论和收藏发生变化后,统计缓存也需要刷新。
如果每个模块都自行拼接缓存键,后续修改缓存结构时容易出现遗漏。
可以让缓存处理模块订阅业务事件:
javascript
CONTENT_UPDATED
CONTENT_DELETED
COMMENT_CREATED
COMMENT_DELETED
LIKE_CHANGED
COLLECTION_CHANGED
USER_PROFILE_UPDATED
GOODS_UPDATED
例如,资料编辑完成后发布 USER_PROFILE_UPDATED 事件,缓存模块删除用户主页摘要,内容卡片再次读取作者资料时会重新构建缓存。
这样资料编辑、用户主页、作品列表和评论区头像都能逐步获取一致的数据,不需要由用户模块直接调用多个页面服务。
对于高频统计,可以使用"数据库关系记录 + Redis实时值 + 定时校准"的方式。
Redis 提供较快的读写速度,Quartz 定时任务再按照真实关系表校准点赞数、收藏数和关注数,避免长期误差。

WebSocket只承担实时传输
单聊、评论通知、点赞提醒、关注提醒和系统通知都可以通过 WebSocket 实时发送,但 WebSocket 不应成为消息的唯一存储位置。
用户可能处于离线状态,也可能在微信小程序退到后台后断开连接。如果消息只发送到连接通道,没有写入数据库,用户重新登录后就无法恢复这些通知。
比较稳定的顺序是:
javascript
业务事件产生
通知记录写入数据库
更新用户未读数量
检测用户在线连接
在线则通过 WebSocket 推送
离线则等待下次拉取
用户在任意终端读取消息时,服务端更新统一的已读状态。安卓 APP、苹果 APP、微信小程序和 H5 网页端重新获取会话或通知列表后,都能得到相同结果。
单聊消息也可以采用类似结构:
javascript
先持久化消息
再更新会话摘要
然后尝试实时推送
最后等待客户端确认
图片消息只保存对象存储地址和资源信息,表情消息保存表情编号或资源地址。消息类型不同,但已读未读、会话排序和离线恢复可以复用同一套基础逻辑。

草稿与正式内容需要不同的数据约束
图文发布、短视频发布和多图上传都支持草稿保存时,草稿不应该提前进入正式内容链路。
草稿可以包含未完成的标题、正文、话题标签、图片列表和商品关联信息,但不参与以下流程:
javascript
内容搜索
热门排序
内容推荐
关注页分发
附近页展示
用户作品公开列表
只有正式发布并通过内容审核后,系统才生成 CONTENT_PUBLISHED 事件。
搜索模块收到事件后建立 EasyES 索引,推荐模块加入候选集合,关注关系模块可以向粉丝的关注页写入新的内容标识。
如果内容审核未通过,则保持不可见状态,不生成公开分发事件。用户仍可以在作品管理中看到审核状态,并重新编辑后提交。
这种状态隔离可以避免草稿内容被搜索到,也能减少发布流程中的条件判断。
内容状态适合使用显式状态机
一条内容可能经历草稿、审核中、已发布、已隐藏、审核拒绝和已删除等状态。
如果业务代码中大量使用零散的 if 判断,很容易出现不允许的状态跳转。例如,已经删除的内容被重新审核,或者审核中的内容直接进入推荐列表。
可以明确允许的状态变化:
javascript
DRAFT → REVIEWING
REVIEWING → PUBLISHED
REVIEWING → REJECTED
REJECTED → DRAFT
PUBLISHED → HIDDEN
HIDDEN → PUBLISHED
PUBLISHED → DELETED
HIDDEN → DELETED
内容状态改变后,再根据新状态发布对应事件。
PUBLISHED 负责建立索引和进入公开列表,HIDDEN 负责从搜索与推荐中移除,DELETED 则清理缓存、搜索索引和公开关系。
作品管理页面仍可以根据业务要求保留部分历史记录,但发现页、关注页和附近页不再返回不可见内容。

商品交易不能复用内容状态
内容社区中允许内容关联商品后,内容模块和商城模块会产生连接,但两者不应共用同一套状态字段。
内容是否发布,与商品是否上架是两个独立状态。即使内容仍然公开,关联商品也可能已经售罄或下架。
商品详情、购物车、立即购买、订单管理和在线支付应由商城业务独立处理。内容卡片只保存商品编号,在展示时读取商品当前状态。
创建订单时,还需要保存商品快照,包括:
javascript
商品名称
商品规格
成交单价
购买数量
商品图片
优惠信息
如果订单只保存商品编号,用户支付后商家修改商品名称或价格,历史订单内容也会随之变化。
支付流程则适合使用订单状态机:
javascript
WAIT_PAY
PAID
WAIT_DELIVERY
DELIVERED
COMPLETED
CLOSED
REFUNDING
REFUNDED
支付回调可能重复到达,因此更新订单状态时同样需要幂等判断。只有订单仍处于待支付状态,才能从 WAIT_PAY 转为 PAID。
订单支付完成后可以发布 ORDER_PAID 事件,由库存、通知和用户订单统计分别消费,而不是在支付回调接口中直接执行所有后续逻辑。
四端共用后台的关键是服务端状态统一
安卓 APP、苹果 APP、微信小程序和 H5 网页端共用一套后台时,客户端不应该各自维护业务真相。
例如,用户在 H5 中取消收藏后,服务端需要修改统一的收藏关系,并产生收藏变更事件。苹果 APP 再次获取收藏管理列表时,应读取新的服务端结果,而不是继续使用本地旧状态。
浏览记录也应以用户账号为维度保存。用户在微信小程序浏览内容,随后打开安卓 APP,个人中心仍可以展示这条记录。
客户端本地缓存只用于加快页面展示,不能决定最终状态。点赞、收藏、关注、购物车和订单都需要以服务端响应为准。
对于重复提交,可以通过请求编号、业务唯一键或版本号进行控制。
尤其是网络较差时,移动端可能自动重试接口。没有幂等设计的情况下,一次加入购物车可能被执行多次,一次评论也可能生成多条重复记录。
数据查询可以采用读模型降低接口复杂度
用户主页往往需要同时返回资料、作品数量、获赞数量、关注数和粉丝数。如果每次进入页面都临时连接多张表并聚合计算,访问量增加后查询成本会逐步上升。
可以建立面向页面读取的用户统计表:
javascript
user_id
published_content_count
received_like_count
following_count
follower_count
collection_count
updated_time
这些数据通过内容发布事件、点赞事件和关注事件逐步更新。
真实关系仍保存在业务表中,统计表只负责页面读取。当统计结果出现异常时,可以通过 Quartz 定时任务重新计算。
内容详情页、用户主页和商品列表都可以采用类似的读模型思路。写入过程保持严格,读取过程根据页面结构组织数据。
这比在每个接口中临时拼接大量跨模块查询更容易维护,也更适合前后端分离和移动端响应式页面。

一条业务链路的完整落地顺序
将前面的设计组合起来,用户评论一条带商品信息的短视频时,后台可以按照下面的顺序处理:
javascript
1. 校验用户身份和内容状态
2. 保存评论关系
3. 同事务写入评论创建事件
4. 事件发布任务读取待处理记录
5. 统计模块增加内容评论数
6. 通知模块保存评论提醒
7. 缓存模块清理内容统计缓存
8. 行为模块记录用户互动
9. WebSocket模块尝试实时推送
10. 各消费者保存幂等处理记录
11. Quartz任务处理失败事件和统计校准
用户在页面上只看到一条评论,但系统内部会经过多个相互独立的处理步骤。
这种拆分不要求所有结果在同一毫秒内完成,而是保证核心数据先可靠落库,后续模块能够重复执行、失败重试并最终完成。
在内容社区源码中,图文、短视频、搜索、瀑布流、互动提醒、即时通讯和商城交易并不是简单的功能组合。真正决定系统稳定性的,是业务状态能否清晰表达、事件是否会丢失、重复请求是否安全,以及不同终端是否始终以统一的服务端数据为准。