
概述发布一条动态从客户端点下「发表」到好友能刷到中间到底发生了什么这里把这条链路拆成六个步骤逐步骤给伪代码、标耗时特征和失败后果并说清哪些环节必须同步、哪些环节可以扔到异步去这是后面异步改造和推拉模式讨论的地基。纲要业务场景与端到端流程发布一条动态的输入文字 最多 6 张图 位置输出自己的 movement 记录 每个好友的 timeLine 记录发布流程分步拆解核心接收参数 → 图片上传 OSS → 组装 Movement → Dubbo 落库 → 时间线写扩散 → 返回每步的输入输出、耗时特征、失败会怎样同步与异步的边界什么必须同步做什么可以异步做判断标准是什么推模式的量级分析好友 100 人 写 100 条时间线大 V 场景的写放大问题推 / 拉 / 推拉结合的取舍发布链路时序图异常与边界、验证要点、常见误解澄清业务场景与端到端流程圈子类似微信朋友圈。用户发一条动态内容是文字、最多 6 张图片、可选的经纬度和位置名。发布之后所有加了好友的人打开圈子页能在自己的时间线里刷到这条动态。用一张流程图把原始讲解里的业务流程画出来抛出异常上传成功客户端发送发布请求数据内容 上传文件图片上传 OSS统一异常处理流程结束保存动态详情表 movement查询当前操作人的所有好友向每个好友的时间线表movement_timeline 写入记录流程结束上传 OSS 如果抛异常流程直接终止这一步不需要额外写处理代码之前章节的统一异常处理已经兜住了。上传成功后才是落库先写动态详情表再查好友列表最后循环往好友的时间线表插记录。发布流程分步拆解对应到代码请求进入mf-app-server的MovementControllerPOST/movementsService 层处理参数和图片上传再通过 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里取当前登录用户 iduserId 不信任前端传值这是常见的安全边界。// 伪代码参数校验与身份获取if(StringUtils.isEmpty(movement.getTextContent())){thrownewBusinessException(ErrorResult.contentError());// 文本为空直接拒绝}LonguserIdUserHolder.getUserId();// 从登录态取发布者输入multipart 请求文字、位置、图片文件数组输出带 textContent/location 的 Movement 雏形 userId耗时特征可忽略纯内存操作失败会怎样文本为空抛 BusinessException请求直接以错误响应结束还没碰到任何 IO步骤二图片上传 OSS每张图片通过OssTemplate.upload传到阿里云 OSS换回访问 URL收集进 medias 列表。// 伪代码逐张上传收集 URLListStringmediasnewArrayList();for(MultipartFilefile:imageContent){StringurlossTemplate.upload(file.getOriginalFilename(),file.getInputStream());medias.add(url);// 这里的 url 后面要存进 movement.medias}输入本地图片流输出OSS 访问 URL 列表耗时特征全链路最慢的一步。每张图一次网络 IO6 张图串行上传弱网下轻松占掉请求耗时的大头。原型约束最多 6 张图就是在这个上传成本上做的产品取舍失败会怎样任一张抛异常整个请求中断走统一异常处理返回错误。此时还没写任何数据库不存在脏数据代价只是用户重发一次步骤三组装 Movement把 userId、medias 填进对象。pid 和发布时间在 API 层生成由 IdWorker 分配一个 Long 型自增序列不是 MongoDB 自带的 ObjectId。ObjectId 只做文档主键 idpid 是给推荐系统用的数字模型键。// 伪代码补齐发布者与媒体字段movement.setUserId(userId);movement.setMedias(medias);// 步骤二的产物// movement.state 默认 0未审核输入Movement 雏形 userId medias输出一份只差 pid/created/id 的完整 Movement耗时特征可忽略纯内存组装失败会怎样这一步没有可失败的东西步骤四落库 movementDubbo 调用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 的序列发号QueryqueryQuery.query(Criteria.where(collName).is(movement));UpdateupdatenewUpdate().inc(seqId,1);// 原子自增SequenceseqmongoTemplate.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 集合// 伪代码写扩散ListFriendfriendsmongoTemplate.find(Query.query(Criteria.where(userId).is(userId)),Friend.class);for(Friendfriend:friends){MovementTimeLinetimeLinenewMovementTimeLine();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_timelineN好友数POST /movements文字图片位置publishMovement(movement, imageContent)校验 textContentUserHolder 取 userIdupload(文件流)返回 URL组装 MovementuserId mediasmovementApi.publish(movement) [Dubbo]IdWorker 生成 pid记录 createdsave(movement) 写动态详情saveTimeLine(userId, movementId)【异步不阻塞】返回落库完成即返回完成200 OK同步链路到「落库 movement」为止写扩散在异步线程里追这正是终态代码与初版的分界线。异常与边界边界场景口径与行为说明图片超过 6 张原型口径上限 6 张超出应由前端拦截服务端代码未做硬校验属于「约定靠自觉」文本超过 140 字原型口径 140 字上限同样主要依赖前端约束服务端只校验非空文本为空抛 BusinessExceptioncontentError服务端唯一的强校验OSS 上传失败抛异常 → 统一异常处理 → 请求失败无脏数据用户重试即可落库 movement 失败异常被 publish 内的 catch 吞掉仍返回成功隐患假成功。应让异常上抛写扩散部分失败落库已完成部分好友时间线缺失最终一致性用户无感但需补发策略失败重试 / 读时补拉兜底并发发布IdWorker 的 findAndModify 原子自增保证 pid 不重复并发写各自 timeLine 无冲突Async 未生效启动类缺 EnableAsync 时同步执行排查点看线程名、看是否阻塞返回验证要点发布成功后三件事可以确认链路完整// mongo shell确认动态详情已落库db.movement.find({userId:NumberLong(106)}).sort({created:-1}).limit(1)// 预期textContent、mediasOSS URL 数组、pidLong、state0// 确认写扩散条数 好友数db.friend.find({userId:NumberLong(106)}).count()// 假设为 Ndb.movement_timeline.find({friendId:NumberLong(106)}).count()// 预期等于 N// 确认图片可访问把 medias 里的 URL 直接扔进浏览器或 curl -I// 预期200Content-Type 为图片类型时间线记录的 friendId 是发布者userId 是收件好友方向别记反读时间线时按userId当前登录人查的就是自己的收件箱。常见误解澄清误解实际情况timeLine 里存了动态全文只存 movementId 引用读时回查 movement 集合拼装pid 就是 MongoDB 的 _id_id 是自动的 ObjectIdpid 是 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 不生效。下一篇走发布圈子动态代码优化异步处理的改造看写扩散是怎么被搬进线程池的。