概述
圈子这一章把「发一条动态」背后的一整套存储设计摊开了:MongoDB 存详情、时间线表做收件箱、Redis 存推荐列表,三套存储各管一段。这里做全章复盘,知识地图、五个可复用的架构套路、十二个实测踩过的坑、自检清单和四个动手练习,最后指向下一章的圈子互动。
纲要
- 知识地图:六条主线从发布到互动的完整链路
- 可复用套路清单:五个能搬去任何 Feed 系统的方案
- 关键坑回顾:十二个实测复现过的坑(坑表)
- 自检清单:合上讲义能答上来才算过
- 技术练习:四个「改哪里 / 怎么验证」明确的动手项
- 下一章预告:圈子互动
知识地图
先把全章 six 条主线放一张图里,箭头是数据流向:
六条主线各自的核心结论:
| 主线 | 存储与关键设计 | 一句话结论 |
|---|---|---|
| 发布动态 | movement集合,state=0未审默认值,pid由 IdWorker 自增 | 详情只存一份;写扩散发生在发布之后 |
| 发布异步化 | @Async起步,MQ 改造收尾 | 时间线投递不能阻塞发布主链路,但要配套幂等与吞异常策略 |
| 好友动态 | movement_timeline收件箱,查询方按friendId查 | 推模式:发布时写每个好友的收件箱,读时只查自己的表 |
| 推荐动态 | RedisMOVEMENTS_RECOMMEND_{userId}存逗号分隔 pid 串 | 顺序即推荐排序,冷启动$sample兜底,分页只能在内存做 |
| 我的动态 / 单条详情 | 按userId查movement;_id用于详情、pid用于推荐 | 两套 id 的分工在详情页交汇 |
| 互动 | comment集合三合一 + Redis Hash 记状态 | 评论/点赞/喜欢共用一张表靠commentType区分,细节在下一章 |
存储分层的意图要说透:MongoDB 管「内容真相」,时间线表管「可见性」,Redis 管「推荐序」。动态本体永远只有movement里那一份,时间线表里存的是引用(movementId),推荐列表里存的是pid。这带来一个必然结果,跨存储组装:不管哪条查询主线,最后一步都要拿 id 回movement反查详情,再拼UserInfo补头像昵称,才凑得出前端要的MovementsVo。
读写两条路径
把六条主线再压缩一次,其实只有两条路径。写路径只有一条:POST /movements→ OSS 传图 →movement落详情(含 pid 分配)→ 异步给每个好友投递时间线引用。读路径有三条分支,入口不同、第一步不同,第二步以后完全一样:
三条分支在「第一步」上的差异正是存储分工的体现:好友动态解决可见性(谁能看),推荐动态解决排序(先看谁),我的动态解决归属(谁发的)。而它们共享「回查详情 + 组装 VO」的后半段,说明内容真相始终只有一份。理解了这个结构,下一章互动数据的读法(评论列表按publishId挂到详情页)也只是再加一条读分支而已。
可复用套路清单
这五条不依赖项目的业务,做任何 Feed 流系统都用得上:
- 推模式时间线写扩散:发布时把动态引用写进每个好友的收件箱(
movement_timeline),读路径退化为单表分页查询;用读频次的降低换写时一次for循环,适合好友数可控的社交产品。 - MQ 异步解耦发布链路:时间线投递从发布主流程里摘出去(
@Async起步、RabbitMQ 收尾),发布接口的耗时只包含详情落库;配套要做消息体约定、唯一索引幂等、消费吞异常防无限重投。 - 三合一互动存储:评论、点赞、喜欢共用
comment集合,commentType区分类型,读写逻辑复用一套分页与计数;代价是所有写删条件必须带全(publishId, commentType, userId),漏一个就误伤。 - Redis Hash 互动标记:一条动态一个 Hash(key),一个用户一个 field,把「百万级 key」压成「万级 field」;
hasLiked回填时批量HMGET一次搞定。 - 跨存储组装:id 列表先收集,再一次性回查详情与用户资料,内存拼 VO;禁止在循环里逐条远程调用,N+1 是这条套路的唯一大敌。
关键坑回顾
这一章的坑不是理论推演,全部在测试篇里实测复现过。十二行排一遍,修复成本从一行代码到一个删除策略不等:
| 坑 | 现象 | 根因 | 修复思路 |
|---|---|---|---|
| multipart 默认 1MB | 多图发布直接报超限异常 | Spring Boot 单文件默认上限 1MB | max-file-size: 5MB、max-request-size: 30MB,容得下 6 张图 |
| publish 吞异常假成功 | Mongo 挂了接口照样 200 | publish里catch (Exception e)只printStackTrace | 详情落库失败必须上抛或补偿,别把失败藏进日志 |
@Async无@EnableAsync | 时间线不投递,且无任何报错 | 注解代理没开启,方法同步执行 | 启动类加@EnableAsync,条件三件:注解、public 方法、跨 Bean 调用 |
| 白名单未落地(avatar 绕过人脸) | 任意图片不经历审核进动态 | 头像接口的 MIME 白名单 + 人脸检测没有搬到发布链路,state=0又默认未审 | 发布前复用头像的校验套路,并接内容审核任务消费state |
movement.getId()回填时机 | 异步链路拿到 null id | ObjectId 在save之后才回填,早于 save 引用会空 | 先 save 再取 id 传给异步任务 |
异步线程拿不到UserHolder | 异步方法里取登录人报错 | ThreadLocal 不跨线程 | 主线程取完 userId 作为参数传入 |
| 默认线程池无上限 | 高并发下线程膨胀 | @Async默认执行器不复用线程 | 换有界线程池并显式命名 |
| timeLine 残留投递 | 取关后还能刷到对方动态 | 发布时快照式投递,取关不清movement_timeline | 取关时deleteMany({userId:对方, friendId:我}),或查询时按现好友列表过滤 |
| 引用失效空洞 | 删动态后分页少一条、counts 对不上 | 时间线引用还在,in回查movement少一条 | 删动态时同步删时间线引用,查询侧不补救 |
| counts 写死 0 | PageResult.counts恒为 0 | findByUserId直接new PageResult(page, pagesize, 0l, ...) | 补一次count查询回填 |
| page=0 负 skip | page=0请求 500 | skip((page-1)*pagesize)算出 -10,Mongo 服务端报错 | 入口Math.max(page, 1)钳制 |
| Map 接参类型不安全 | key 拼错编译期无感,运行时 NPE;Integer 转 Long 炸 | @RequestBody Map绕过了类型系统 | 换 DTO 接参,字段类型编译期锁定 |
这十二个坑有三个共同点,值得记下来当 review 清单的视角:都发生在存储边界(上传、落库、跨表、跨线程)、失败都偏安静(不报错或报错难懂)、教学快照故意不修(留作分析素材)。
自检清单
合上讲义逐条自问,答不上来的回去翻对应篇目:
movement集合里_id和pid各自被谁使用?为什么推荐系统要用自增 pid 而不是 ObjectId?- 发布动态时
state默认值是多少?谁来把它改成 1? - 好友动态为什么查
movement_timeline而不是直接查好友的movement?推模式省了什么、付出了什么? - 推荐动态的 pid 串存在 Redis 的什么结构里?key 怎么按用户隔离?冷启动用户查不到 key 时走什么兜底?
- 推荐动态的分页为什么只能在内存做,不能下推给 Mongo?
- 时间线表里的
userId和friendId各是什么语义?查询条件用的是哪一个? - 删除一条动态后,时间线里的引用会发生什么?用户端看到什么现象?
@Async生效的三个条件是什么?默认线程池有什么问题?comment集合为什么把三种互动放一张表?写删时的安全边界是哪几个字段?
技术练习
四个练习都是把前几篇测试篇里指出的缺陷亲手修掉,每项都给出改哪里、怎么验证。
练习一:补上 @EnableAsync
改哪里:mf-dubbo-mongo的启动类DubboMongoApplication,类上加org.springframework.scheduling.annotation.EnableAsync;顺手定义一个有界线程池替换默认执行器:
@Bean(name="timeLineExecutor")publicExecutortimeLineExecutor(){ThreadPoolTaskExecutorexecutor=newThreadPoolTaskExecutor();executor.setCorePoolSize(4);executor.setMaxPoolSize(8);executor.setQueueCapacity(200);executor.setThreadNamePrefix("timeline-");executor.initialize();returnexecutor;}TimeLineService.saveTimeLine的@Async上指定@Async("timeLineExecutor")。
怎么验证:发一条动态,在saveTimeLine里打一条带Thread.currentThread().getName()的日志。修复前线程名是主线程(http-nio 执行线程),修复后变成timeline-1;再查movement_timeLine确认投递记录存在。对照验证:把注解删掉重发,日志回到主线程名,亲眼看一次「代码看着异步、跑起来同步」。
练习二:修掉 publish 的吞异常
改哪里:MovementApiImpl.publish的catch (Exception e)。最小改法是失败上抛:
}catch(Exceptione){log.error("发布动态失败, userId={}",movement.getUserId(),e);thrownewRuntimeException("发布动态失败",e);}注意抛出后timeLineService.saveTimeLine的异步调用不会执行,这正是期望行为:详情都没存成,不该给好友投递引用。
怎么验证:停掉 MongoDB(或临时把集合名改成不存在的值触发异常),Postman 发POST /movements。修复前返回 200 但库里无数据(假成功);修复后返回 500,日志里有完整堆栈和 userId。恢复 Mongo 后重新发布,一切正常。
练习三:给发布链路补白名单校验
改哪里:MovementService.publishMovement,在 OSS 上传循环之前,对每个MultipartFile做三道校验,判空、大小、MIME 白名单(只认image/jpeg、image/png),校验套路直接抄 02 章头像上传的实现:
privatestaticfinalList<String>ALLOWED_TYPES=Arrays.asList("image/jpeg","image/png");privatevoidcheckImage(MultipartFilefile){if(file==null||file.isEmpty()){thrownewBusinessException(ErrorResult.error("图片为空"));}if(!ALLOWED_TYPES.contains(file.getContentType())){thrownewBusinessException(ErrorResult.error("仅支持 jpg、png 格式"));}}这练习解决的是「avatar 绕过人脸」那行坑:头像接口有白名单 + 人脸检测,发布链路却裸奔,任何文件都能借imageContent进 OSS 并挂到动态上。完整方案还要接内容审核消费state,本练习只做上传入口这一层。
怎么验证:Postman 把一个.html文件改名为.jpg上传,修复前 200 且 OSS 里能找到该文件;修复后返回 500(业务异常),OSS 无新对象。再用正常 jpg 回归一遍确认误伤为零。
练习四:清理取关后的残留投递
改哪里:取关(删除好友)的服务方法里,环信删联系人、删friend双记录之后,追加清理时间线:
//清理取关对象投递到自己收件箱的动态引用Queryquery=Query.query(Criteria.where("userId").is(friendId)//发布人是被取关的人.and("friendId").is(currentUserId));//收件箱是我的mongoTemplate.remove(query,MovementTimeLine.class);方向别写反:要删的是「对方发布、投给我」的记录;我此前转发到他收件箱的记录删不删取决于产品语义,练习里保持单向清理即可。
怎么验证:A、B 互加好友,A 发两条动态;B 查GET /movements能看到两条。B 取关 A 后再查,A 的动态应全部消失;mongosh 里db.movement_timeline.find({userId: A的id, friendId: B的id})应为空。反向回归:A 未取关 B,A 的列表里 B 的动态应仍在。
常见误解澄清
| 误解 | 实际情况 |
|---|---|
| 时间线表里存的是动态内容 | 只存movementId引用,内容真相只有movement一份 |
@Async加了就异步 | 必须@EnableAsync+ public 方法 + 跨 Bean 调用,三条件缺一即同步执行且无报错 |
| 推荐动态的分页由 MongoDB 完成 | pid 串在 Redis,分页用 Stream 在内存做,Mongo 只做pid in回查 |
| 发布动态会先经过审核 | state默认 0(未审),工程快照里没有审核任务,实际等于免审上线 |
| 取关后动态自然不可见 | 快照式投递不会自动回收,残留记录要靠取关时清理或查询时过滤 |
官方文档
- Spring Data MongoDB - Query & Skip/Limit ,
skip/limit分页与负数 skip 的边界行为 - RabbitMQ Tutorials , 发布链路 MQ 改造的异步模型
- MongoDB $sample , 推荐动态冷启动随机采样
总结
圈子这一章的真实主线不是 CRUD,而是存储分工:movement存真相、movement_timeline管可见性、Redis 管推荐序,三条查询主线最后都汇到「跨存储组装 VO」这一个动作上。十二个坑里最值得带走的是三个习惯:异步必须验证真的异步了、失败不能吞进日志、投递出去的引用要有人负责回收。下一章圈子互动接着把comment集合的三合一设计、点赞的 Redis Hash 标记和取消互动的误删坑展开,互动数据一动,本章的 counts 写死 0 也就到了该还账的时候。