news 2026/10/24 5:56:35

Java 项目实战: 交友项目 - 发布圈子动态:业务流程和代码步骤分析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java 项目实战: 交友项目 - 发布圈子动态:业务流程和代码步骤分析

概述

发布一条动态,从客户端点下「发表」到好友能刷到,中间到底发生了什么?这里把这条链路拆成六个步骤,逐步骤给伪代码、标耗时特征和失败后果,并说清哪些环节必须同步、哪些环节可以扔到异步去,这是后面异步改造和推拉模式讨论的地基。

纲要

  • 业务场景与端到端流程
  • 发布一条动态的输入:文字 + 最多 6 张图 + 位置
  • 输出:自己的 movement 记录 + 每个好友的 timeLine 记录
  • 发布流程分步拆解(核心)
  • 接收参数 → 图片上传 OSS → 组装 Movement → Dubbo 落库 → 时间线写扩散 → 返回
  • 每步的输入输出、耗时特征、失败会怎样
  • 同步与异步的边界
  • 什么必须同步做,什么可以异步做,判断标准是什么
  • 推模式的量级分析
  • 好友 100 人 = 写 100 条时间线;大 V 场景的写放大问题
  • 推 / 拉 / 推拉结合的取舍
  • 发布链路时序图
  • 异常与边界、验证要点、常见误解澄清

业务场景与端到端流程

圈子类似微信朋友圈。用户发一条动态,内容是文字、最多 6 张图片、可选的经纬度和位置名。发布之后,所有加了好友的人打开圈子页,能在自己的时间线里刷到这条动态。

用一张流程图把原始讲解里的业务流程画出来:

抛出异常

上传成功

客户端发送发布请求
数据内容 + 上传文件

图片上传 OSS

统一异常处理
流程结束

保存动态详情表 movement

查询当前操作人的所有好友

向每个好友的时间线表
movement_timeline 写入记录

流程结束

上传 OSS 如果抛异常,流程直接终止,这一步不需要额外写处理代码,之前章节的统一异常处理已经兜住了。上传成功后才是落库:先写动态详情表,再查好友列表,最后循环往好友的时间线表插记录。

发布流程分步拆解

对应到代码,请求进入mf-app-server的MovementController(POST/movements),Service 层处理参数和图片上传,再通过 Dubbo 调到mf-dubbo-mongo的MovementApiImpl完成落库和写扩散。发布链路涉及三个模块:

mf ├── mf-app-server # 服务端应用(Controller / Service) │ └── com.mf.server │ ├── controller.MovementController │ ├── service.MovementService │ └── interceptor.UserHolder ├── mf-dubbo │ ├── mf-dubbo-interface # Dubbo 接口定义 │ │ └── com.mf.dubbo.api.MovementApi │ └── mf-dubbo-mongo # Dubbo 实现(MongoDB 落库) │ └── com.mf.dubbo │ ├── api.MovementApiImpl │ └── utils.{IdWorker, TimeLineService} └── mf-model # 实体类 └── com.mf.model.mongo.Movement

下面逐步骤拆。每步给伪代码,标注耗时特征与失败后果。

步骤一:接收参数

请求是 multipart 表单:movement对象携带文字和位置字段,imageContent是图片文件数组。

// 伪代码:Controller 层@PostMappingResponseEntitymovements(Movementmovement,MultipartFile[]imageContent){movementService.publishMovement(movement,imageContent);returnResponseEntity.ok(null);}

Service 层先做参数校验,再从拦截器写入的UserHolder里取当前登录用户 id,userId 不信任前端传值,这是常见的安全边界。

// 伪代码:参数校验与身份获取if(StringUtils.isEmpty(movement.getTextContent())){thrownewBusinessException(ErrorResult.contentError());// 文本为空,直接拒绝}LonguserId=UserHolder.getUserId();// 从登录态取发布者
  • 输入:multipart 请求(文字、位置、图片文件数组)
  • 输出:带 textContent/location 的 Movement 雏形 + userId
  • 耗时特征:可忽略,纯内存操作
  • 失败会怎样:文本为空抛 BusinessException,请求直接以错误响应结束,还没碰到任何 IO

步骤二:图片上传 OSS

每张图片通过OssTemplate.upload传到阿里云 OSS,换回访问 URL,收集进 medias 列表。

// 伪代码:逐张上传,收集 URLList<String>medias=newArrayList<>();for(MultipartFilefile:imageContent){Stringurl=ossTemplate.upload(file.getOriginalFilename(),file.getInputStream());medias.add(url);// 这里的 url 后面要存进 movement.medias}
  • 输入:本地图片流
  • 输出:OSS 访问 URL 列表
  • 耗时特征:全链路最慢的一步。每张图一次网络 IO,6 张图串行上传,弱网下轻松占掉请求耗时的大头。原型约束最多 6 张图,就是在这个上传成本上做的产品取舍
  • 失败会怎样:任一张抛异常,整个请求中断,走统一异常处理返回错误。此时还没写任何数据库,不存在脏数据,代价只是用户重发一次

步骤三:组装 Movement

把 userId、medias 填进对象。pid 和发布时间在 API 层生成,由 IdWorker 分配一个 Long 型自增序列,不是 MongoDB 自带的 ObjectId。ObjectId 只做文档主键 id,pid 是给推荐系统用的数字模型键。

// 伪代码:补齐发布者与媒体字段movement.setUserId(userId);movement.setMedias(medias);// 步骤二的产物// movement.state 默认 0(未审核)
  • 输入:Movement 雏形 + userId + medias
  • 输出:一份只差 pid/created/id 的完整 Movement
  • 耗时特征:可忽略,纯内存组装
  • 失败会怎样:这一步没有可失败的东西

步骤四:落库 movement(Dubbo 调用)

Service 通过@DubboReference调用MovementApi.publish,跨进程进入mf-dubbo-mongo服务。API 实现里先用 IdWorker 生成 pid、记录发布时间,然后mongoTemplate.save写入 movement 集合。

// 伪代码:API 层保存动态详情movement.setPid(idWorker.getNextId("movement"));// Long 自增序列,供推荐系统用movement.setCreated(System.currentTimeMillis());mongoTemplate.save(movement);// 写入 movement 集合

IdWorker 的实现值得看一眼,它不是雪花算法,而是借 MongoDB 的findAndModify做原子自增:

// 伪代码:基于 MongoDB 的序列发号Queryquery=Query.query(Criteria.where("collName").is("movement"));Updateupdate=newUpdate().inc("seqId",1);// 原子自增Sequenceseq=mongoTemplate.findAndModify(query,update,options(upsert(true),returnNew(true)),Sequence.class);returnseq.getSeqId();// 返回递增的 Long
  • 输入:组装好的 Movement
  • 输出:movement 集合里的一条持久化记录(带自动生成的 ObjectId 主键)
  • 耗时特征:一次 MongoDB 写入,通常几毫秒到几十毫秒
  • 失败会怎样:抛异常被 publish 方法里的 try-catch 捕获后打印堆栈、异常被吞掉,Controller 依然返回成功。这是个隐患:落库失败时用户以为发成功了,刷新却看不到。真实项目里这一步的失败必须往上传

步骤五:时间线写扩散

动态自己的记录只有一条,但好友打开的是「自己的时间线」,所以要把这条动态的引用复制到每个好友的时间线里。实现是查好友表(friend 集合),逐人构建MovementTimeLine写入 movement_timeline 集合:

// 伪代码:写扩散List<Friend>friends=mongoTemplate.find(Query.query(Criteria.where("userId").is(userId)),Friend.class);for(Friendfriend:friends){MovementTimeLinetimeLine=newMovementTimeLine();timeLine.setMovementId(movement.getId());// 只存引用,不复制动态内容timeLine.setUserId(friend.getUserId());// 好友自己timeLine.setFriendId(friend.getFriendId());// 发布者timeLine.setCreated(System.currentTimeMillis());mongoTemplate.save(timeLine);}

timeLine 记录存的只是 movementId 引用,读时间线时再回查 movement 集合拼内容,所以叫「引用」而非副本。

  • 输入:发布者 userId + movementId
  • 输出:N 条 timeLine 记录(N = 好友数)
  • 耗时特征:好友越多循环越多。100 个好友就是 100 次串行 MongoDB 写入,单次几毫秒,累积下来可能几百毫秒到秒级。发布者的好友量决定这一步的耗时上限,这就是写放大的直接来源
  • 失败会怎样:部分好友写成功、部分失败时,落库已经完成,时间线却缺了,这就是最终一致性问题。好在时间线少一条用户基本无感,工程上可以补一条补偿策略:失败的好友列表记下来重试,或读时间线时发现缺口按 created 补拉发布者的 movement。工程快照里的处理是 catch 后打印堆栈返回 error,没有补偿,属于已知简化

步骤六:返回成功

Controller 返回ResponseEntity.ok。客户端拿到 200,发布流程结束。

  • 耗时特征:可忽略
  • 失败会怎样:到这一步基本不会失败;真正的风险都藏在前两步的「假成功」里

步骤小结

步骤位置输入 → 输出耗时特征失败后果
接收参数app-servermultipart → Movement 雏形 + userId可忽略文本为空直接报错,无副作用
上传 OSSapp-server图片流 → URL 列表最慢,N 次网络 IO请求中断,无脏数据,可重试
组装 Movementapp-server雏形 + medias → 完整对象可忽略不会失败
落库 movementdubbo-mongoMovement → 一条记录毫秒级 DB 写异常被吞,假成功(隐患)
时间线写扩散dubbo-mongouserId → N 条 timeLine随好友数线性增长最终一致性缺口,可补偿
返回成功app-server— → 200可忽略—

同步与异步的边界

六个步骤里,真正值得架构讨论的是:哪些必须让用户等,哪些可以背后慢慢做。

  • 图片上传必须同步:medias 要拼进 Movement 一起落库,不拿到 URL 就没法组数据。它是用户「发一条带图动态」这个动作的一部分,结果立即被下游消费。
  • 落库 movement 必须同步:动态详情是这次发布的本体。写失败还给用户返回成功,是数据正确性问题,不是体验问题。
  • 时间线写扩散可以异步:timeLine 只有好友读时间线时才会被查到。发布动作完成和好友刷到之间晚几秒,用户完全无感。但它天然随好友数放大,同步做会把无关的量级成本压在发布者的请求线程上。

沉淀成判断标准:

判断维度必须同步可以异步
用户是否立即需要结果需要(要拿 URL、要确认发成功)不需要(好友晚几秒刷到无感)
失败影响核心数据缺失,不可接受时间线缺一条,可补偿
量级固定(图片上限 6 张、落库 1 次)随好友数线性放大

按这个标准,工程终态把saveTimeLine标了@Async,发布请求在落库完成后即返回,写扩散丢给线程池。改造细节见同章发布圈子动态:代码优化(异步处理的改造)。

这里有个实测容易踩的坑:@Async要生效,启动类必须配@EnableAsync。实读工程快照,TimeLineService标了@Async,但DubboMongoApplication上没看到@EnableAsync,不开启的话注解等于摆设,方法仍在调用线程同步执行,日志里也不会有新线程名。排查「为什么异步没生效」,先查这个注解。

推模式的量级分析

时间线写扩散本质是推模式(Fan-out on write):发布时主动把动态推到每个粉丝的收件箱。项目选它是因为课程场景好友量小,一人几十个好友,写 100 条 timeLine 不构成压力。但把量级放大到真实社交产品,问题立刻出来:

  • 好友 100 人:发布一条写 100 条 timeLine,毫秒级,无感。
  • 好友 1 万人(大 V):发布一条要写 1 万条,就算丢到异步线程池,也是个持续数秒到数十秒的写风暴;万级大 V 同时发布,数据库直接被打穿。

另一条路是拉模式(Fan-out on read):发布只写 movement 一条,读时间线时实时聚合「我好友的全部动态,按时间排序」。写压力没了,读压力全上来了,每次刷新都是一次跨好友的聚合查询,热用户的首页打开就是灾难。

两者的折中是推拉结合:普通用户的动态照推(量小),大 V 的动态不推、读时单独拉取合并。微博是典型的推拉结合实践。

维度推模式拉模式推拉结合
写放大高:发布写 N 条(N=粉丝数)低:只写 1 条中:普通用户推、大 V 不推
读放大低:读自己的 timeLine 即可高:读时聚合全部好友动态中
适用粉丝量级小(百级以内)粉丝少且读频低大规模混合场景
实现复杂度低:一个循环中:读逻辑聚合高:需区分人群、合并去重
一致性发布后到刷出有延迟(可异步后更明显)天然实时混合
项目的选择是(课程场景好友量小)否否

一句话收束:项目选推不是因为推更好,而是因为它的数据规模撑得起推的简单。量级一上来,「写扩散」这一步就是架构演进的第一张多米诺。

发布链路时序图

把六步放到跨服务视角上,注意时间线写扩散已经是异步(标注):

TimeLineService (@Async)MongoDBMovementApiImpl (Dubbo)阿里云 OSSMovementServiceMovementController客户端 AppTimeLineService (@Async)MongoDBMovementApiImpl (Dubbo)阿里云 OSSMovementServiceMovementController客户端 Apploop[每张图片(最多6张)-]异步线程:查好友列表 → 循环写 movement_timeline(N=好友数)POST /movements(文字+图片+位置)publishMovement(movement, imageContent)校验 textContent,UserHolder 取 userIdupload(文件流)返回 URL组装 Movement(userId + medias)movementApi.publish(movement) [Dubbo]IdWorker 生成 pid,记录 createdsave(movement) 写动态详情saveTimeLine(userId, movementId)【异步,不阻塞】返回(落库完成即返回)完成200 OK

同步链路到「落库 movement」为止;写扩散在异步线程里追,这正是终态代码与初版的分界线。

异常与边界

边界场景口径与行为说明
图片超过 6 张原型口径上限 6 张,超出应由前端拦截服务端代码未做硬校验,属于「约定靠自觉」
文本超过 140 字原型口径 140 字上限同样主要依赖前端约束,服务端只校验非空
文本为空抛 BusinessException(contentError)服务端唯一的强校验
OSS 上传失败抛异常 → 统一异常处理 → 请求失败无脏数据,用户重试即可
落库 movement 失败异常被 publish 内的 catch 吞掉,仍返回成功隐患:假成功。应让异常上抛
写扩散部分失败落库已完成,部分好友时间线缺失最终一致性:用户无感,但需补发策略(失败重试 / 读时补拉)兜底
并发发布IdWorker 的 findAndModify 原子自增保证 pid 不重复并发写各自 timeLine 无冲突
@Async 未生效启动类缺 @EnableAsync 时同步执行排查点:看线程名、看是否阻塞返回

验证要点

发布成功后,三件事可以确认链路完整:

// mongo shell:确认动态详情已落库db.movement.find({userId:NumberLong(106)}).sort({created:-1}).limit(1)// 预期:textContent、medias(OSS URL 数组)、pid(Long)、state=0// 确认写扩散条数 = 好友数db.friend.find({userId:NumberLong(106)}).count()// 假设为 Ndb.movement_timeline.find({friendId:NumberLong(106)}).count()// 预期等于 N// 确认图片可访问:把 medias 里的 URL 直接扔进浏览器或 curl -I// 预期:200,Content-Type 为图片类型

时间线记录的 friendId 是发布者,userId 是收件好友,方向别记反,读时间线时按userId=当前登录人查的就是自己的收件箱。

常见误解澄清

误解实际情况
timeLine 里存了动态全文只存 movementId 引用,读时回查 movement 集合拼装
pid 就是 MongoDB 的 _id_id 是自动的 ObjectId;pid 是 IdWorker 发的 Long 自增序列,专供推荐系统
写扩散发生在 app-server 进程发生在 mf-dubbo-mongo 服务,经 Dubbo 调用过去,@Async 线程也在那个进程
好友时间线是查的时候现算的是发布时预写好的,读时只查自己的 timeLine(推模式的收益)
publish 里 try-catch 是事务回滚注释写着「忽略事务处理」,实际是吞异常,落库失败会假成功
@Async 标了就异步必须 @EnableAsync 开启才生效,否则照旧同步执行

总结

  • 发布一条动态 = 六步:收参数 → 传 OSS → 组装 → 落库 → 写扩散 → 返回,链路横跨 app-server 与 dubbo-mongo 两个服务。
  • 全链路最慢的是图片上传(网络 IO),量级最不可控的是时间线写扩散(随好友数线性放大),这两点决定了同步/异步的切分位置。
  • 同步保核心(上传拿 URL、落库保数据在),异步保体验(时间线晚几秒无感),判断标准是「用户是否立即需要、失败影响、量级」三条。
  • 推模式适合项目这种好友量小的场景;量级上万就必须考虑拉模式或推拉结合,写扩散是后续架构演进的引子。
  • 两个实测坑:publish 吞异常导致假成功;@Async 缺 @EnableAsync 不生效。

下一篇走发布圈子动态:代码优化(异步处理的改造),看写扩散是怎么被搬进线程池的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 5:54:23

Streamlit + 多模态RAG电影推荐系统:从数据到答辩实战

又到毕设选题季&#xff0c;后台私信里问得最集中的一类项目&#xff0c;就是“大数据深度学习”方向的推荐系统。今年反复被提到的一个题目组合是&#xff1a;Streamlit 多模态RAG的电影推荐系统。这个方向确实值得做&#xff0c;它不像纯协同过滤那么老套&#xff0c;也不像…

作者头像 李华
网站建设 2026/10/24 5:54:03

libxslt-1.1.32 编译安装全攻略:依赖、配置与避坑

简介&#xff1a;libxslt-1.1.32 .tar.gz 是 XSLT 的 C 语言实现库源码包&#xff0c;面向需要处理 XML 文档转换的开发者与运维人员&#xff0c;可解决 XML 到 HTML、PDF、纯文本等格式的样式表转换需求。包内共约 2000 个文件&#xff0c;以 xsl 样式表、xml 输入样例、out 与…

作者头像 李华
网站建设 2026/10/24 5:53:53

VR生产安全学习机:让高危行业培训从“记住”到“做到”

干了这么多年安全生产培训&#xff0c;我一直有个特别深的体会&#xff1a;安全培训这件事&#xff0c;最难的不是把制度写出来&#xff0c;也不是把课排下去&#xff0c;而是让每个工人打心底里“记住”风险。传统的培训方式翻来覆去就是PPT、视频、考试卷&#xff0c;工人坐在…

作者头像 李华
网站建设 2026/10/24 5:52:52

国产大模型真实业务重构实测:代码理解、迁移与并发修复对比

最近一个月&#xff0c;我把四大国产大模型的代码能力拉到同一个真实业务重构场景里做了一轮硬碰硬的实测。起因很朴素&#xff1a;团队接手了一个遗留订单模块&#xff0c;要从老式JDBC数据访问层整体迁移到新持久层框架&#xff0c;同时把里头埋了两年的并发隐患一起解决。这…

作者头像 李华
网站建设 2026/10/24 5:52:50

分布式计算中的版本控制:从代码到数据与调度全链路治理

有次凌晨三点&#xff0c;值班电话把我从床上叫起来&#xff0c;说某个跑了两年的离线报表任务挂了。我打开调度平台&#xff0c;看了一眼日志&#xff0c;第一反应是“谁改代码了&#xff1f;”。结果查了一圈&#xff0c;代码分支和提交记录纹丝不动&#xff0c;调度配置没有…

作者头像 李华
网站建设 2026/10/24 5:51:18

el-date-picker禁用日期从原理到实战:踩坑与封装

先说个真实感受&#xff1a;用过 Element UI 的人&#xff0c;十个里有八个在el-date-picker上卡过壳&#xff0c;尤其是“限制选择范围”这种需求。刚接手时觉得简单——不就加个 disabled 嘛&#xff0c;实际一写才发现边界条件多到怀疑人生&#xff1a;开始日期选了之后结束…

作者头像 李华