概述
发布一条动态,从客户端点下「发表」到好友能刷到,中间到底发生了什么?这里把这条链路拆成六个步骤,逐步骤给伪代码、标耗时特征和失败后果,并说清哪些环节必须同步、哪些环节可以扔到异步去,这是后面异步改造和推拉模式讨论的地基。
纲要
- 业务场景与端到端流程
- 发布一条动态的输入:文字 + 最多 6 张图 + 位置
- 输出:自己的 movement 记录 + 每个好友的 timeLine 记录
- 发布流程分步拆解(核心)
- 接收参数 → 图片上传 OSS → 组装 Movement → Dubbo 落库 → 时间线写扩散 → 返回
- 每步的输入输出、耗时特征、失败会怎样
- 同步与异步的边界
- 什么必须同步做,什么可以异步做,判断标准是什么
- 推模式的量级分析
- 好友 100 人 = 写 100 条时间线;大 V 场景的写放大问题
- 推 / 拉 / 推拉结合的取舍
- 发布链路时序图
- 异常与边界、验证要点、常见误解澄清
业务场景与端到端流程
圈子类似微信朋友圈。用户发一条动态,内容是文字、最多 6 张图片、可选的经纬度和位置名。发布之后,所有加了好友的人打开圈子页,能在自己的时间线里刷到这条动态。
用一张流程图把原始讲解里的业务流程画出来:
上传 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-server | multipart → Movement 雏形 + userId | 可忽略 | 文本为空直接报错,无副作用 |
| 上传 OSS | app-server | 图片流 → URL 列表 | 最慢,N 次网络 IO | 请求中断,无脏数据,可重试 |
| 组装 Movement | app-server | 雏形 + medias → 完整对象 | 可忽略 | 不会失败 |
| 落库 movement | dubbo-mongo | Movement → 一条记录 | 毫秒级 DB 写 | 异常被吞,假成功(隐患) |
| 时间线写扩散 | dubbo-mongo | userId → 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 即可 | 高:读时聚合全部好友动态 | 中 |
| 适用粉丝量级 | 小(百级以内) | 粉丝少且读频低 | 大规模混合场景 |
| 实现复杂度 | 低:一个循环 | 中:读逻辑聚合 | 高:需区分人群、合并去重 |
| 一致性 | 发布后到刷出有延迟(可异步后更明显) | 天然实时 | 混合 |
| 项目的选择 | 是(课程场景好友量小) | 否 | 否 |
一句话收束:项目选推不是因为推更好,而是因为它的数据规模撑得起推的简单。量级一上来,「写扩散」这一步就是架构演进的第一张多米诺。
发布链路时序图
把六步放到跨服务视角上,注意时间线写扩散已经是异步(标注):
同步链路到「落库 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 不生效。
下一篇走发布圈子动态:代码优化(异步处理的改造),看写扩散是怎么被搬进线程池的。