怎么用 这是格式与深度的参考,不是让你照抄的模板。数字全部是示例,直接抄进简历一问就穿——必须换成你自己的真实数据,而且要能说清是怎么测出来的。下面每个模块都有「数字是怎么测的」和「面试追问」两段,写不出那两段的模块就不要往简历上写

面试题库(电商 / 短视频 / 社区 / 本地生活 / 旅游 / 企业服务 多业务场景)

无实习这一档是给谁的 完全没有实习经历时,简历上唯一能写的就是自己做的项目。但「图书馆座位预约」「学生成绩管理」这类选题面试官一年看几百份,而硬套企业项目(微服务、分布式事务、消息中间件)会被一问就穿——他很清楚学生不可能有那种环境。这一档收的是复刻主流产品的核心链路、一个人真能做出来、技术栈朴素但有真实难点的项目。这一档的模块比企业档多——因为企业项目里你只被分到一个模块,而个人项目是从注册登录到静态资源全都你自己写的,能讲的面本来就宽。下面六个模块彼此独立——只做过哪几个就只写哪几个。
怎么讲才不像课设 复刻类项目的含金量不在「我做了个论坛」,而在「我搞懂了它们为什么那样设计」:为什么主流社区的评论只做两层而不是无限嵌套、为什么热度榜翻页时会看到重复的帖子、为什么点赞数有时候不精确却没人在意。你能把这三个「看起来奇怪的设计」讲出原因,就已经超过绝大多数只会讲功能的人。反过来,「为什么我没上搜索引擎」「为什么我没用消息队列」也要能说清边界——能说清边界比堆技术栈可信得多。
只写你当场证得出来的 个人项目最容易穿的不是技术,是那些用来撑场面、但一追问就散的外部背书:「有几百个注册用户」会被问用户从哪来、怎么收集反馈、留存多少;「开源项目」「有人 star」「参赛获奖」同样如此——面试时你拿不出来,说了等于给对方一个突破口正确的立足点只有一条:我没有真实流量,但我能复现问题、能讲清每个数字的来路。用脚本造几万条数据、用压测工具起几十个并发把 bug 稳定重现,造数据的规则、压测的参数、bug 的根因,这些你能被追问五层还答得上来——它比任何用户量和背书都硬。
三条自检 一、你踩的坑能不能复现——能说清「我怎么造数据、怎么起并发、它就必然出现」;二、能说出坑的根因,尤其是「自己点点测测完全正常、造了并发或大数据量才暴露」的那种;三、每个数字都能回答「这个数是怎么来的」(造了多少数据、几个并发、在什么机器上跑)。三条都有就能写。
项目背景设定 我照着几个技术社区的样子做了一个论坛(帖子列表、正文、评论、点赞收藏、个人主页、标签)。技术栈是 Spring Boot + MySQL + Redis + Vue一台服务器,没有搜索引擎、没有消息队列、没有微服务。
数据和流量先说清楚 这是个人项目,没有真实用户量,我也不会去编一个。下面所有数字都来自我自己用脚本造的数据集和本机压测:按真实分布生成数万条帖子、十几万条评论(包括故意造出的时间戳重复、评论层级不均、单帖评论数悬殊这些边界形态),并用压测工具起几十到上百个并发复现竞争问题。我认为这比声称「有几百个用户」站得住——造数据的脚本和压测过程我能当场复述,而用户量一旦被追问「从哪来的、怎么收集反馈」就只能现编。
为什么这六块值得写 前三块(Feed 流分页、评论结构、点赞计数)是我照着主流社区的交互抄、抄到一半才发现它们那些看起来奇怪的设计全都有原因:为什么评论只做两层、为什么热度榜翻页会看到重复的帖子、为什么点赞数有时候不精确。后三块(注册登录、站内搜索、图片与静态资源)是一个人做整站绕不开、但教程里几乎不讲的部分——而它们恰好是最容易埋安全问题和性能问题的地方六块都是我自己写的,所以每一块我都能讲到根因;这也是个人项目相比「企业里被分到一个模块」的唯一优势——面比较宽,别浪费。

模块一:Feed 流的游标分页与内容可见性

  1. Feed 流的游标分页与内容可见性(用游标替代页码消除重复与漏帖 + 排序键不唯一时必须带第二游标 + 热度流为什么只能用快照 + 状态过滤与作者自见)★★★
    简历这样写 技术社区论坛(个人项目)(Spring Boot + MySQL + Redis + Vue):帖子列表初版用页码加偏移量分页,用脚本模拟「一边翻页一边有新帖插入」后稳定复现同一条帖子重复出现、以及漏帖——因为新帖插入使偏移量整体错位;改为游标分页(以「排序时间 + 帖子标识」组成游标,查询条件改为小于该游标),并发现仅用时间做游标会在同一秒发布多条时丢帖,补上第二排序键后解决;热度榜的排序键会随点赞变化,游标在这里不成立,改为定时生成榜单快照并按快照分页,同时明确「热度流允许轻微重复」这一取舍;列表按内容状态过滤(草稿、审核中、已发布、已删除)且作者本人可见自己的审核中内容,避免作者以为帖子丢了;偏移量深分页导致的慢查询在改造后消失,列表接口耗时不再随翻页深度增长。
    展开完整拆解
    为什么要这么设计

    帖子列表是这个项目里最先写、也是我返工最多的接口。我一开始用的是最标准的写法:前端传页码和每页条数,后端按发布时间倒序、用偏移量取一页。这个写法我在教程里见过无数次,看起来毫无问题。四个问题里有三个是我手点根本测不出来、必须靠造数据或起并发才会出现的。

    一是下拉加载会重复出现同一条帖子,也会漏帖。我是写了个脚本才复现的:一个线程按页翻列表,另一个线程持续插入新帖。原因是偏移量分页的前提是「数据集在翻页期间不变」,而社区列表恰恰一直在变——翻第一页的时候有新帖插到最前面,整个列表往后移一位,于是第一页的最后一条在第二页又出现了一次;反过来如果有帖子被删,第二页就会漏掉一条。我自己在浏览器里点是永远碰不到的,因为我点的时候没有并发的写入——这也是我后来专门写这个脚本的原因。

    二是我改成游标分页之后,出现了新问题:同一秒发布的多条帖子会丢。我一开始的游标只用了发布时间。如果有两条帖子的时间戳完全相同,游标是「小于这个时间」,那么另一条就被跳过了这个在手点的时候几乎不可能碰到,但我造测试数据的脚本是循环批量插入的,同一秒里生成了大量帖子,时间戳大面积重复,一翻页就丢了一堆。

    三是热度榜没法用游标。我以为游标是万能的,结果发现热度是会变的——我按热度排序、用「热度值 + 帖子标识」做游标,但我翻到第二页的时候,第一页某条帖子的点赞涨了、排序位置变了,游标的语义就崩了。这时候我才想起来:我平时刷的那些社区,热度榜翻页确实偶尔会看到重复的帖子——原来不是它们有 bug,是这个问题在按可变字段排序时无法根治。

    四是作者看不到自己审核中的帖子。我给列表加了「只查已发布状态」的过滤。结果作者发完帖,列表里找不到自己的帖子,以为发布失败了,又发了一遍——出现了重复内容。

    所以四个改动:页码分页改游标分页游标补上第二排序键热度流改用定时快照并接受轻微重复状态过滤增加「作者本人可见自己的非公开内容」

    这个模块最想说的一句话是:分页这件事的难点不在「取第几页」,在「翻页期间数据集会变」。偏移量分页隐含了一个假设——数据集是静止的。这个假设在管理后台(数据基本不动)成立,在社区列表(一直有人发帖)完全不成立而我照着教程抄的时候,教程没有告诉我这个假设的存在。

    整体链路
    按时间的流(最新 / 关注 / 标签下的帖子)→ 可以用游标 │ ├─ 游标 = 排序时间 + 帖子标识(两个字段一起) │ 只用时间的后果:同一秒发布多条时会跳过 │ 我的批量导入脚本让大量时间戳重复,一下就暴露了 │ ├─ 查询条件:(排序时间, 帖子标识) 小于游标,按两者倒序取 N+1 条 │ 取 N+1 条是为了判断「还有没有下一页」,不用再查一次总数 │ ├─ 返回下一页游标 = 本页最后一条的 (排序时间, 帖子标识) │ ├─ 索引要建成 (状态, 排序时间, 帖子标识) 的联合索引 │ 不建的后果:游标分页也会慢,因为要回表过滤状态 │ └─ 偏移量分页的两个问题(都是我踩过的) 翻页期间有新帖插入 → 列表整体后移 → 同一条帖子出现两次 翻页期间有帖子删除 → 列表整体前移 → 漏掉一条 另外深分页本身也慢(要跳过大量行) 按热度的流(热度榜)→ 游标不成立,只能快照 │ ├─ 为什么不成立 │ 热度值会随点赞评论变化 │ 我翻到第二页时,第一页某条的热度涨了、位置变了 │ 游标「小于某个热度值」的语义就崩了 │ ├─ 做法:定时(比如若干分钟)计算一次榜单,把有序的帖子标识列表 │ 存成一份快照(Redis 列表 / 有序集合) │ ├─ 分页在快照上做(按下标取区间),快照期间顺序是稳定的 │ ├─ 快照过期后重算;用户跨越两次重算翻页时仍可能看到重复 │ 这个残留问题我接受了 —— 主流社区也是这样 │ 重要的是我知道它为什么存在、以及代价是什么 │ └─ 热度公式要把「时间衰减」算进去 纯按点赞数排 → 榜单被老帖长期占据,新帖永远上不去 内容状态与可见性 │ ├─ 状态:草稿 → 审核中 → 已发布 → 已下架 / 已删除 │ ├─ 公开列表只查「已发布」 │ ├─ 但作者本人要能看到自己的「审核中」「已下架」内容 │ 不做的后果 │ 作者发完帖在列表里找不到,以为失败了,又发一遍 │ 我真的因此产生了一批重复内容 │ ├─ 实现上不要在业务代码里到处写状态条件 │ 收敛成一个「可见性条件」的构造方法,按当前用户身份生成 │ 到处手写的后果:新加一个列表接口就漏一次 │ └─ 已删除内容的详情页要返回明确的「已删除」而不是 404 因为链接可能被分享出去了,404 会让人以为链接错了 前端配合 ├─ 下拉加载用后端返回的游标,不要自己算页码 ├─ 首屏与下拉刷新重置游标;加载更多才带游标 └─ 已加载列表做本地去重兜底(按帖子标识) 因为热度流的重复无法根治,前端兜一层体验会好很多
    分步拆解
    1. 按时间的流一律改用游标分页。偏移量分页隐含「数据集静止」这个假设,而社区列表一直有人发帖,这个假设不成立——结果就是重复和漏帖。
    2. 游标必须由「排序键 + 唯一键」两个字段组成。只用时间的后果是同一秒发布多条时会跳过——我的批量导入脚本让大量时间戳重复,一下就暴露了。
    3. 取 N+1 条来判断有没有下一页。比再查一次总数便宜,而且总数在变化的数据集上本身也不准。
    4. 索引要建成(状态, 排序时间, 帖子标识)的联合索引。不建的话游标分页也会慢,因为要回表过滤状态。
    5. 要意识到偏移量分页还有第二个问题:深分页本身慢。翻到很后面时数据库要跳过大量行,而游标分页的耗时和翻页深度无关——这一点是额外收益。
    6. 按热度排序时游标不成立,必须用快照。因为热度值会变,翻页期间排序位置会动,游标的语义就崩了。
    7. 热度快照定时重算,分页在快照上按下标取区间。快照期间顺序稳定,这是「让可变数据在一段时间内变得不可变」的常见手法。
    8. 要承认热度流的重复无法根治,并说清代价。用户跨越两次重算翻页时仍可能看到重复。主流社区也是这样——重要的是知道它为什么存在。
    9. 热度公式必须包含时间衰减。纯按点赞数排的后果是榜单被老帖长期占据,新帖永远上不去,社区就死了。
    10. 公开列表只查「已发布」,但作者本人要能看到自己的非公开内容。不做的后果是作者发完帖找不到,以为失败了又发一遍,产生重复内容。
    11. 可见性条件要收敛成一个按当前用户身份生成的构造方法。到处手写状态条件的后果是新加一个列表接口就漏一次
    12. 已删除内容的详情页要返回明确的「已删除」而不是 404。链接可能被分享出去了,404 会让人以为链接错了而反复尝试。
    13. 前端下拉加载要用后端返回的游标,不要自己算页码。游标的构成逻辑属于后端,前端拼装迟早会不一致。
    14. 前端要按帖子标识做本地去重兜底。因为热度流的重复无法根治,兜一层体验会好很多。
    关键决策与取舍

    时间流用游标、热度流用快照,两套分页策略而不是统一一套。统一成一套(都用快照)会让时间流也承担快照的延迟(新帖要等下次重算才出现),而「刚发的帖子立刻能在最新列表里看到」是社区的基本预期。统一成另一套(都用游标)则热度流会错乱。判据是「排序键在翻页期间会不会变」:不变的可以用游标,会变的只能快照。这一条我认为是这个模块最有价值的判断,而且它是通用的——任何列表都可以先问这个问题。

    接受热度流的轻微重复,而不是想办法根治。根治的思路是「为每个用户生成一份独立快照并长期保留」,但那意味着要为每个活跃用户存一份榜单,存储和复杂度都上去了,而收益只是消除偶尔的重复我的判断是:先看主流产品怎么做的——我刷的那几个社区热度榜翻页也会偶尔重复,说明这个代价是行业公认可接受的能说出「我知道这里有残留问题、我知道根治的代价、我选择接受」,比假装没有问题要好。

    可见性条件收敛成一个构造方法,而不是每个接口自己拼。每个接口自己拼更直接、也更灵活。但状态种类会增加(我后来加了「已下架」),每加一种就要检查所有列表接口——而漏掉一处的表现是「某个入口能看到不该看到的内容」,很难被发现。收敛之后新增状态只改一处。这一条和「权限校验要收敛」是同一个道理。

    踩过的坑一:偏移量分页导致重复和漏帖,而我在浏览器里手点永远碰不到。因为手点时没有并发的写入。我是写了个脚本才复现的——一个线程按页翻列表、另一个线程持续插帖,重复和漏帖立刻就出来了。教训有两层:一是偏移量分页隐含了「数据集在翻页期间不变」这个假设,而教程里从来不说这件事二是「我手点没问题」不等于「没问题」,涉及并发的逻辑必须专门构造场景去打我后来的习惯是:看到一个「标准写法」,先问它隐含了什么前提、我的场景满不满足,然后想办法把这个前提破掉试试。

    踩过的坑二:游标只用时间,造数据时大量时间戳重复,导致丢帖。手点的时候几乎不可能碰到,但我造测试数据的脚本是循环批量插入的,同一秒生成了大量帖子,时间戳大面积重复,一翻页就丢了一堆教训是:游标必须唯一——所以要在排序键后面追加一个唯一键。这个坑还有个意外收获:我意识到「批量造出来的数据分布」和「手动点出来的数据分布」完全不同,前者天然带着大量重复值、极端值、空值,反而更容易打出边界问题——后来我就专门用它来测。

    踩过的坑三:作者看不到自己审核中的帖子,以为失败了又发一遍。我加了「只查已发布」的过滤,忘了作者本人这个视角教训是:加过滤条件时要问「有没有哪一类用户应该看到被过滤掉的东西」——作者对自己的内容、管理员对全部内容,都是这类例外。

    没做的部分:没做个性化推荐流(按用户兴趣排序)。它需要真实的用户行为数据来做特征和排序,而我这是个人项目、没有真实行为数据——用造出来的假行为数据训一个「假的推荐」,不如老实做好「最新」和「热度」两个流取舍依据是「支撑这个功能的前提我不具备」,我认为这个理由比「技术上做不到」更能说明我知道自己在做什么。面试被问到「还能怎么优化」时,这也是现成的答案。

    数字是怎么测的

    重复与漏帖用断言型用例,这是最能说明问题的一条:先取第一页,然后插入若干条新帖,再取第二页,断言两页之间没有重复的帖子标识、也没有跳过的帖子报法是「模拟翻页期间插入 N 条新帖,游标分页无重复无遗漏;同场景下偏移量分页出现 M 条重复」——两个方案对比着报,比只说「改成了游标分页」有力得多。

    同一时间戳的边界要专门测:造若干条时间戳完全相同的帖子,断言游标分页能完整取到全部,一条不丢。这条是踩坑的直接回归,而且用批量导入的数据最容易造。

    深分页性能要报清数据量与翻页深度:「帖子表 N 条数据时,偏移量分页翻到第 X 页耗时 A;游标分页翻到同样深度耗时 B,且耗时与深度无关」。「与深度无关」这一点才是游标分页的核心收益,要明确写出来。

    索引的作用要单独验证:报「加(状态, 排序时间, 帖子标识)联合索引前后的执行计划变化与耗时对比」。因为游标分页快不快是靠索引撑的,不提索引就不完整。

    热度快照:断言快照有效期内多次翻页顺序稳定;并诚实说明跨越两次重算时仍可能重复,报出重算间隔(这决定了重复的概率)。

    可见性:断言未登录用户与其他用户看不到「审核中」内容、作者本人能看到自己的、已删除内容的详情返回「已删除」而不是 404。

    数据来源必须说清,这比数据量本身重要:我报的是「用脚本按真实分布造了数万条帖子、十几万条评论,其中故意包含大量重复时间戳」。不要声称用户量——「有几百个注册用户」会被追问从哪来的、怎么收集反馈的,全是要现编的;而「造了多少数据、字段怎么分布、为什么故意造重复时间戳」是我当场就能讲完的,追问几层都不怕。这个量级足以把游标分页的边界问题打出来,说清来源就够了。

    不要报什么:不要报「接口性能提升 N 倍」——数据量小,倍数没有意义。该报的是「翻页期间插帖后游标无重复、偏移量有 M 条重复」「同时间戳数据不丢帖」「耗时与翻页深度无关」「作者可见自己的审核中内容」这几件可核对的事。

    面试追问
    Q:分页不就是 limit 加 offset 吗?为什么要搞游标? A:我一开始就是 limit 加 offset,这也是我在教程里见过无数次的写法,而它的问题我在浏览器里手点永远碰不到——是写了个脚本才复现的:一个线程按页翻列表、另一个线程持续插入新帖,同一条帖子重复出现、以及漏帖,立刻就出来了。根因是偏移量分页隐含了一个假设:数据集在翻页期间不变而社区列表恰恰一直在变——翻第一页的时候有新帖插到最前面,整个列表往后移一位,于是第一页的最后一条在第二页又出现了一次;反过来如果有帖子被删,第二页就会漏掉一条。改成游标分页之后,查询条件从「跳过前 N 行」变成「取排序键小于某个值的前 N 行」,翻页期间插入或删除都不会影响已确定的边界但我在这里又踩了第二个坑:我的游标只用了发布时间,结果时间戳相同的帖子会丢——因为条件是「小于这个时间」,另一条就被跳过了。手点几乎碰不到,但我造测试数据的脚本是循环批量插入的,同一秒生成了大量帖子、时间戳大面积重复,一翻页就丢了一堆。所以游标必须由「排序键 + 唯一键」两个字段组成另外游标分页还有个额外收益:耗时和翻页深度无关,而偏移量分页翻到很后面时数据库要跳过大量行。我从这件事学到两个更一般的教训:一是看到「标准写法」先问它隐含了什么前提、然后想办法把这个前提破掉试试;二是「我手点没问题」不等于「没问题」,涉及并发的逻辑必须专门构造场景去打。
    Q:既然游标分页这么好,热度榜为什么不用? A:因为游标成立的前提是「排序键在翻页期间不变」,而热度是会变的。我一开始真的用「热度值 + 帖子标识」做游标,结果我翻到第二页的时候,第一页某条帖子的点赞涨了、排序位置变了,游标「小于某个热度值」的语义就崩了——可能重复、也可能漏。这时候我才想起来,我平时刷的那几个社区,热度榜翻页确实偶尔会看到重复的帖子——原来不是它们有 bug,是这个问题在按可变字段排序时无法根治。我的做法是定时计算一次榜单,把有序的帖子标识列表存成一份快照,分页在快照上按下标取区间——本质是「让可变数据在一段时间内变得不可变」。快照有效期内顺序是稳定的;用户跨越两次重算翻页时仍可能看到重复,这个残留问题我接受了为什么不根治?根治的思路是为每个用户生成独立快照并长期保留,那意味着要为每个活跃用户存一份榜单,存储和复杂度都上去了,收益只是消除偶尔的重复——而主流产品也是这么取舍的。我觉得能说出「我知道这里有残留问题、我知道根治的代价、我选择接受」,比假装没有问题要好。顺带说一个容易被忽略的点:热度公式必须包含时间衰减,纯按点赞数排的后果是榜单被老帖长期占据、新帖永远上不去,社区就死了——这也是我照着抄的时候没想到、后来看数据才发现的。

模块二:评论区的两层结构与批量装配

  1. 评论区的两层结构与批量装配(放弃无限嵌套的三个真实原因 + 一级分页配回复预取 + 消除逐条查询 + 删除占位与计数口径)★★★
    简历这样写 评论区(Spring Boot + MySQL + Vue):初版做无限层级嵌套(按父评论标识递归查询),实际使用后发现三个问题——递归查询次数不可控、手机端缩进到第四层已无可读空间、而真实数据中绝大多数回复只有一层;参照主流社区改为两层结构(一级评论 + 其下回复),回复对象通过提及目标用户表达而非真实嵌套;接口改为一级评论分页 + 每条预取前若干条回复 + 展开更多,装配方式由「逐条查询每个评论的回复」改为一次批量查出再在内存内分组,查询次数从与评论数成正比降为固定次数;删除有回复的一级评论时保留占位而非物理删除,避免其下回复变成孤儿;统一评论计数口径(含回复且排除已删除)并使展示与统计一致,修复了「显示 12 条实际只有 9 条」的问题。
    展开完整拆解
    为什么要这么设计

    评论区是我最初最有信心的部分——我做了完整的无限层级嵌套,回复可以回复回复,理论上无限深。我当时觉得这比只能回复两层的产品「更完备」。用起来之后我发现完全不是这样,四个问题。

    一是无限嵌套在手机上根本没法看。每一层缩进一点,到第四层的时候文字宽度只剩一半,到第五层就只有两三个字一行。我试着减小缩进,结果层级关系又看不出来了。这个问题不是样式能解决的,是「无限深度」和「有限屏幕宽度」本身矛盾。

    二是递归查询的次数不可控。我的实现是查出一级评论,然后对每条递归查它的子评论。一个评论区有多少次查询取决于评论的嵌套形态,我完全没法预估——某个讨论比较热的帖子,打开一次详情页发了上百次查询。

    三是我去看了真实数据,发现绝大多数回复只有一层。用户的实际行为是「回复某条评论」,而不是「在回复里再开一个分支讨论」我为了支持一个几乎没人用的能力,付出了展示和查询两方面的代价。这时候我才明白主流社区只做两层不是能力不足,是它们已经知道了我刚发现的这件事。

    四是评论数显示不对。帖子上显示「12 条评论」,点进去数只有 9 条。原因是我统计的时候算了已删除的评论,而展示的时候过滤掉了——两个口径不一致。

    所以四个改动:无限嵌套改为两层结构回复对象用提及目标用户表达逐条查询回复改为一次批量查出再内存分组统一评论计数口径,另外补了删除有回复的一级评论时保留占位

    这个模块最想说的一句话是:我一开始做无限嵌套,是因为我在追求「技术上更完备」;而两层结构才是对的,因为它同时解决了展示、查询和真实使用三个约束。复刻主流产品的价值就在这里——它们的每一个「看起来简陋」的设计背后,往往都有一个我还没踩到的坑。我是踩了之后才理解的,但正因为踩过,我能说清为什么。

    整体链路
    数据结构:两层 │ ├─ 一级评论:直接评论帖子(parent 为空) ├─ 二级回复:挂在某条一级评论下(parent 指向一级评论) │ 即使是「回复某条回复」,parent 仍指向那条一级评论 │ 回复对象用「提及目标用户」来表达,不产生第三层 │ └─ 为什么不做无限嵌套(我做过,三个真实原因) 手机端第四层缩进后已无可读宽度,减小缩进又看不出层级 递归查询次数不可控,热门帖打开一次发了上百次查询 真实数据里绝大多数回复只有一层 —— 为几乎没人用的能力付代价 查询与装配(这一步是性能关键) │ ├─ 第一步:分页查一级评论(游标或页码都行,评论区变动没列表那么频繁) │ ├─ 第二步:拿到这批一级评论的标识,一次批量查出它们的回复 │ 不要循环里查(逐条查的后果:查询次数与评论数成正比) │ 每条一级评论只预取前若干条回复(其余走「展开更多」) │ ├─ 第三步:一次批量查出所有涉及的用户信息(作者 + 被提及的人) │ 同样不要循环查用户 —— 这是最常见的一处逐条查询 │ ├─ 第四步:在内存里按 parent 分组装配成两层结构 │ └─ 查询次数从「与评论数成正比」变成固定几次 一级评论 · 回复 · 用户信息 · 点赞状态,各一次 「展开更多回复」 ├─ 单独接口:给定一级评论标识 + 游标,返回它下面的更多回复 ├─ 回复按时间正序(对话顺序),一级评论可按时间或热度 └─ 展开后新加载的回复要和已展示的去重(按回复标识) 删除的处理 │ ├─ 一级评论有回复时:保留占位,内容替换为「该评论已删除」 │ 物理删除的后果:它下面的回复变成孤儿,要么消失要么报错 │ ├─ 一级评论没有回复时:可以物理删除或标记删除(我用标记) │ ├─ 二级回复删除:标记删除,展示时过滤 │ └─ 占位评论不参与计数,但要占据展示位置 否则回复看起来是凭空挂在那里的 计数口径(必须只有一个定义) ├─ 定义:该帖子下「未删除的一级评论 + 未删除的回复」总数 ├─ 展示与统计必须用同一个定义 │ 不一致的后果:显示 12 条,点进去数只有 9 条(我踩过) ├─ 计数不实时算(每次 count 会随评论增长变慢),维护在帖子上 └─ 增删评论时增量更新,并有定时任务用明细重算校准 其他 ├─ 评论内容要过一遍长度与敏感词校验(个人项目也会被灌广告) ├─ 楼主标识、作者高亮,让对话关系更清楚 └─ 空态区分「还没有评论」和「加载失败」
    分步拆解
    1. 结构定成两层:一级评论 + 挂在它下面的回复。即使是「回复某条回复」,父级仍指向那条一级评论,不产生第三层。
    2. 回复对象用「提及目标用户」表达,而不是真实嵌套。这样既保留了「回复谁」的信息,又不需要增加层级。主流社区都是这么做的。
    3. 不要做无限嵌套。三个真实原因:手机端第四层缩进后已无可读宽度、递归查询次数不可控、真实数据里绝大多数回复只有一层。
    4. 一级评论分页,每条只预取前若干条回复。全部回复一次返回的话,某条评论下有几百条回复就会把响应撑得很大。
    5. 回复必须一次批量查出,不能在循环里查。逐条查的后果是查询次数与评论数成正比——我最初的实现在热门帖上发了上百次查询。
    6. 用户信息也必须批量查。这是最容易漏的一处逐条查询——循环里查作者头像昵称,看起来很自然,但它同样是 N+1。
    7. 在内存里按父级分组装配。装配逻辑放内存是廉价的,而多一次查询是昂贵的。
    8. 「展开更多回复」做成单独接口,带游标。不要一次全给,也不要让前端自己算偏移。
    9. 回复按时间正序,一级评论可按时间或热度。回复是对话,倒序会让对话读不通。
    10. 展开后新加载的回复要和已展示的去重。按回复标识去重,否则预取的那几条会重复出现。
    11. 删除有回复的一级评论时必须保留占位。物理删除的后果是它下面的回复变成孤儿,要么凭空消失、要么查询报错。
    12. 占位评论不参与计数,但要占据展示位置。否则那些回复看起来是凭空挂在那里的。
    13. 评论计数必须只有一个定义,展示与统计共用。不一致的后果是显示 12 条、点进去数只有 9 条——这个是用户直接看得见的。
    14. 计数不要实时算。每次 count 会随评论量增长变慢,维护在帖子上并增量更新。
    15. 增量更新必须配定时校准。用明细重算并比对,因为增量维护一定会因为某条路径漏更新而漂移。
    16. 评论内容要过长度与敏感词校验。不校验长度的后果很直接——我造数据时故意插过一条几万字的评论,详情页直接卡住;而任何公开可写的接口都要假设会收到垃圾内容。
    关键决策与取舍

    放弃无限嵌套,改两层。无限嵌套在数据结构上更「完备」,但它同时被三个约束否掉了:展示(手机宽度有限)、性能(递归查询次数不可控)、真实需求(绝大多数回复只有一层)代价是「回复的回复」失去了严格的层级关系——用「提及目标用户」来补,信息不丢但结构变平。判据是「这个能力的实际使用比例,值不值得为它付出展示和查询的代价」:不值得。我特意去看了自己数据库里的真实分布才下这个结论,而不是凭感觉。

    不用路径枚举或闭包表这类支持任意层级的方案。它们能高效查询任意深度的子树,但那是在「确实需要任意层级」的前提下才有意义——我既然已经决定只做两层,那父级标识加一个索引就够了,引入路径字段反而增加了维护成本(移动节点、路径重写)这一条我想强调的是:技术方案的复杂度应该匹配需求的复杂度,而不是匹配「未来可能的需求」。

    删除有回复的评论时保留占位,而不是级联删除。级联删除更干净,但那会让别人写的回复也一起消失——那些回复不属于被删评论的作者判据是「这条数据的所有权属于谁」:回复属于回复者,删除者无权处置。这一点我是想了一会儿才想清楚的,最初我以为级联删除是理所当然的。

    计数维护在帖子上而不是实时 count。实时 count 永远准确、不需要维护。但它的耗时随评论量增长,而列表页要展示每个帖子的评论数——一页 20 个帖子就是 20 次 count维护计数的代价是一致性变成需要显式保证的事,所以必须配定时校准。判据是「读的频率远高于写」:是,那就把成本转移到写侧。

    踩过的坑一:无限嵌套在手机上第四层就没法看了。我试过减小缩进,结果层级关系又看不出来。这件事让我明白有些问题不是样式能解决的,是设计本身的矛盾——「无限深度」和「有限屏幕宽度」不可能同时满足。而我当初做无限嵌套的理由是「技术上更完备」,完全没有从使用的角度想过。

    踩过的坑二:递归查询让热门帖的详情页发了上百次查询。我是在日志里看到的。教训是:循环或递归里发查询,次数就不再由你控制,而是由数据形态控制——而数据形态是用户决定的,你没法预估上限。我后来的习惯是写完一个接口就数一遍「这里面发了几次查询、会不会随数据量增长」。

    踩过的坑三:评论数显示 12 条、点进去只有 9 条。统计时算了已删除的,展示时过滤了。教训是:同一个概念在系统里必须只有一个定义——而「评论数」这种看起来不言自明的东西,恰恰最容易在两处被算成两个意思。我后来把这个定义写进了项目文档。

    没做的部分:没做评论的「热门排序」(按点赞把优质评论顶上来)。它需要评论级的点赞与热度计算,而我的评论量还不足以让排序产生明显差异——几十条评论按时间看完也就那样取舍依据是数据量还没到需要排序的程度,先把结构和性能做对。

    数字是怎么测的

    查询次数是这个模块最有说服力的数字:「详情页加载评论区时,接口内的数据库查询次数从『与评论数成正比(热门帖实测上百次)』降为固定 4 次(一级评论 / 回复 / 用户 / 点赞状态各一次)」这个数字比耗时更本质,因为它说明改造后耗时不再随评论量增长。

    接口耗时要报清评论量:「某条有 N 条评论的帖子,详情页评论区耗时从 X 降到 Y」。要说明是哪条帖子、多少条评论——不说数据量的耗时对比没有意义。

    层级深度的真实分布值得报出来,它是我做决策的依据:「统计自己库里的回复数据,其中约 X% 只有一层」。这个数字说明「放弃无限嵌套」是基于数据而不是偷懒。

    孤儿回复用断言型用例:删除一条有回复的一级评论,断言其下回复仍可正常展示、占位显示「该评论已删除」、且占位不计入评论数

    计数口径一致性:造一批含已删除评论的数据,断言帖子上显示的评论数与详情页实际可见条数完全相等。这条是踩坑的直接回归。

    计数校准:人为把帖子上的评论数改错,断言定时校准任务能把它修正回来并产生一条差异记录。

    展开更多回复:断言展开后的回复与预取的那几条不重复、顺序为时间正序。

    不要报什么:不要报「评论区性能提升 N 倍」——不同帖子的评论量差异很大,倍数取决于挑哪条帖子。该报的是「查询次数从与评论数成正比降为固定 4 次」「真实数据中单层回复占比」「删除后回复不成孤儿且占位不计数」「显示评论数与实际可见条数相等」这几件可核对的事。

    面试追问
    Q:评论为什么只做两层?无限嵌套不是更灵活吗? A:我一开始做的就是无限嵌套,因为我当时觉得那样「技术上更完备」,用起来之后被三件事否掉了。第一是展示:每层缩进一点,到第四层文字宽度只剩一半,第五层一行只有两三个字;我试着减小缩进,结果层级关系又看不出来了——这不是样式能解决的,是「无限深度」和「有限屏幕宽度」本身矛盾第二是查询次数不可控:我的实现是对每条评论递归查子评论,一次请求发多少次查询取决于评论的嵌套形态,而形态是用户决定的,我没法预估上限——某个讨论比较热的帖子,打开一次详情页发了上百次查询,我是在日志里看到的。第三是我去看了自己库里的真实数据,发现绝大多数回复只有一层——用户的实际行为是「回复某条评论」,不是「在回复里再开一个分支讨论」我为了支持一个几乎没人用的能力,付出了展示和查询两方面的代价。所以改成两层:一级评论 + 挂在它下面的回复,即使是「回复某条回复」,父级仍指向那条一级评论,回复对象用「提及目标用户」来表达——信息不丢,但结构变平。这件事对我影响挺大:我原来以为主流社区只做两层是能力不足或者产品偷懒,实际是它们已经知道了我刚发现的这三件事。我也没有用路径枚举或闭包表这类支持任意层级的方案——既然决定只做两层,父级标识加索引就够了,引入路径字段反而要维护路径重写技术方案的复杂度应该匹配需求的复杂度,而不是匹配「未来可能的需求」。
    Q:删除一条有很多回复的评论,你怎么处理? A:保留占位,把内容替换成「该评论已删除」,但它下面的回复照常展示。我最初想的是级联删除(把它和它下面的回复一起删掉),因为那样数据最干净。后来想清楚了一点:那些回复是别人写的,不属于被删评论的作者,删除者无权处置——判据是「这条数据的所有权属于谁」。如果级联删,别人辛苦写的回复会凭空消失。而如果只删父评论不留占位,那些回复就变成了孤儿:要么在界面上凭空挂着(用户看不懂在回复什么),要么查询时因为找不到父级而报错。所以占位是必需的:它不参与评论计数,但要占据展示位置。「不参与计数」这一点是我后来补的——因为我踩过一个相关的坑:帖子上显示「12 条评论」,点进去数只有 9 条。原因是统计的时候算了已删除的评论,展示的时候又过滤掉了,两个口径不一致教训是同一个概念在系统里必须只有一个定义,而「评论数」这种看起来不言自明的东西恰恰最容易在两处被算成两个意思。我现在的定义写在项目文档里:该帖子下未删除的一级评论加未删除的回复的总数,展示和统计都用它。另外这个计数我没有实时 count——列表页一页 20 个帖子就是 20 次 count,耗时还随评论量增长;改成维护在帖子上增量更新,同时配一个定时任务用明细重算校准,因为增量维护一定会因为某条路径漏更新而漂移。

模块三:点赞收藏的计数与状态查询

  1. 点赞收藏的计数与状态查询(明细入库保证可重建 + 计数走缓存原子自增 + 列表页批量查已赞状态 + 定期对账修正漂移)★★★
    简历这样写 点赞与收藏(Spring Boot + MySQL + Redis):初版每次点赞都插明细并直接更新帖子上的计数字段,热门帖出现同一行并发更新的锁等待,且列表页为显示「我是否已赞」对每条帖子各查一次;改为明细仍然入库(作为唯一事实来源,保证缓存丢失后可重建)+ 计数走 Redis 原子自增 + 定时回写数据库,并用「用户与帖子」唯一索引保证幂等(快速连点不会产生重复记录);列表页的已赞状态改为一次批量查询,查询次数从与帖子数成正比降为固定一次;补上定时对账——用明细重算计数并与缓存和数据库比对,差异记录并修正,对账第一次运行即暴露出「取消点赞时漏减计数」这一条未维护的路径;同时明确「计数允许短暂不精确、但『我是否已赞』必须精确」这一取舍。
    展开完整拆解
    为什么要这么设计

    点赞看起来是整个项目里最简单的功能:插一条记录、把帖子的点赞数加一。我就是这么写的。四个问题,其中两个是我自己完全没想到的。

    一是热门帖上出现锁等待。点赞的写法是 update post set like_count = like_count + 1 where id = ?一条帖子火了之后,很多人在同一时间点赞,所有请求都在更新同一行——数据库要串行处理,接口耗时明显上升。这个问题在我这个规模下还不至于出事,但它是「同一行热点写」的典型形态,我意识到这条路走不远。

    二是列表页为了显示「我赞过没有」,对每条帖子各查一次。一页 20 个帖子就是 20 次查询。而这个逻辑写起来特别自然——渲染每个帖子的时候顺手查一下,我当时完全没觉得有问题。

    三是快速连点产生重复记录。用户手抖点了两下,两条请求都通过了「查一下有没有赞过」这一步,然后都插入成功,点赞数加了两次。而这个错误没有任何报错,就是数字不对。

    四是取消点赞时我漏减了计数。这个是最典型的:我把「点赞」这条路径的计数加上了,但「取消点赞」那条路径只删了明细、忘了减计数结果是取消了赞、明细没了、但数字还在而这个漂移只会越来越大,没有任何机制会发现它——直到我做了对账才看到。

    所以四个改动:计数从直接更新数据库改为 Redis 原子自增加定时回写明细继续入库并加(用户, 帖子)唯一索引保证幂等列表页已赞状态改批量查询加定时对账用明细重算并修正

    这个模块最想说的一句话是:想清楚「点赞数」和「我是否已赞」是两种完全不同的数据,设计就清楚了。点赞数允许短暂不精确——差一两个没有人会发现,也不会造成任何后果;而「我是否已赞」必须精确——如果我明明赞过却显示没赞,我会再点一次,然后变成取消,体验直接崩。前者可以走缓存、可以延迟回写;后者必须以数据库明细为准。我最初把它们当成同一件事来做,所以两头都没做好。

    整体链路
    数据分层:明细是事实,计数是派生 │ ├─ 明细表(用户, 帖子, 类型, 时间) │ 建 (用户, 帖子, 类型) 唯一索引 │ 它是唯一的事实来源 —— 缓存丢了能从它重建 │ 所以明细绝对不能只放缓存 │ ├─ 计数:Redis 原子自增 + 定时回写到帖子表 │ 直接 update 帖子行的问题 │ 热门帖上很多人同时点赞 → 都在更新同一行 → 串行等待 │ 这是「同一行热点写」的典型形态 │ └─ 帖子表上的计数字段保留:作为兜底展示(Redis 挂了还能显示) 点赞流程(幂等是关键) │ ├─ 插入明细,依赖唯一索引保证幂等 │ 冲突 → 说明已经赞过 → 按「已赞」正常返回,不报错 │ 只在代码里「先查有没有赞过再插」的后果 │ 快速连点两下,两个请求都查到「没赞过」,都插入成功 │ ├─ 插入成功(真的是新增)才去自增计数 │ 冲突时不能自增 —— 否则连点会让数字翻倍 │ ├─ 同步写入「该用户赞过的帖子」集合(用于状态查询) │ └─ 取消点赞:删除明细 → 删除成功才自减计数 → 移出集合 「删除成功才自减」和上面同理,靠影响行数判断 列表页的已赞状态(必须精确 · 必须批量) │ ├─ 拿到本页的帖子标识列表,一次批量查询 │ 从 Redis 集合批量判断(一次往返判断多个成员) │ 或从明细表用 IN 一次查出该用户在这批帖子上的记录 │ ├─ 逐条查的后果:一页 20 个帖子 20 次查询 │ 而这个写法特别自然 —— 渲染每个帖子时顺手查一下 │ └─ Redis 集合不可用时降级到明细表查询 因为「我是否已赞」必须精确,不能因为缓存挂了就显示错 定时回写与对账(这一环救了我) │ ├─ 回写:定时把 Redis 里的计数刷到帖子表 │ 失败要能重试,且回写本身要幂等(用覆盖而不是增量) │ ├─ 对账:定时用明细表重算真实计数,与 Redis 和数据库比对 │ 有差异 → 记录一条差异日志 → 用明细的结果修正 │ ├─ 差异不能静默修正,一定要记录 │ 静默修正的后果:你在不停擦地,但不知道水龙头在哪 │ 我漏减计数那条路径,就是靠差异日志找出来的 │ └─ 差异数应长期为零;不为零说明有某条路径没维护计数 缓存丢失的重建 ├─ Redis 重启或数据被清 → 从明细表重算并回填 ├─ 这就是「明细必须入库」的原因 └─ 重建期间读帖子表上的兜底计数,不要显示 0
    分步拆解
    1. 先分清两种数据:明细是事实,计数是派生。明细必须入库(它是缓存丢失后重建的唯一依据),计数可以放缓存。
    2. 明细表要建(用户, 帖子, 类型)唯一索引。这是幂等的根本手段——只在代码里「先查再插」挡不住快速连点。
    3. 唯一索引冲突要按「已赞」正常返回,不要报错。对用户来说重复点赞是正常操作,不该看到错误。
    4. 只有「真的新增了明细」才自增计数。用插入的影响行数判断。冲突时也自增的后果是连点让数字翻倍。
    5. 取消点赞同理:删除成功才自减。靠删除的影响行数判断,否则重复取消会让数字减到负数。
    6. 计数不要直接 update 帖子行。热门帖上很多人同时点赞会都更新同一行、串行等待——这是「同一行热点写」的典型形态。
    7. 计数走 Redis 原子自增,定时回写数据库。回写要用覆盖而不是增量,这样回写本身是幂等的、失败可以重试。
    8. 帖子表上的计数字段要保留作为兜底。Redis 不可用时还能显示一个数字,而不是显示 0(显示 0 比显示旧值糟得多)。
    9. 列表页的已赞状态必须一次批量查询。逐条查的后果是一页 20 个帖子 20 次查询——而这个写法特别自然,我当时完全没觉得有问题。
    10. 已赞状态在缓存不可用时要降级到明细表。因为「我是否已赞」必须精确——显示错会让用户再点一次,变成取消。
    11. 必须做定时对账:用明细重算真实计数,与缓存和数据库比对。这是发现「某条路径漏维护计数」的唯一方式。
    12. 对账的差异不能静默修正,一定要记录。静默修正的后果是你在不停擦地,但不知道水龙头在哪——我漏减计数那条路径,就是靠差异日志找出来的。
    13. 差异数应长期为零,把它当成一个监控指标。不为零就说明有路径没维护计数。
    14. Redis 重启后要能从明细重算回填。这是「明细必须入库」这个决定的直接价值。
    15. 收藏和点赞用同一套机制,靠类型字段区分。不要写两套——写两套的后果是其中一套的对账或幂等被漏掉。
    关键决策与取舍

    明细入库、计数入缓存,而不是两者都放一处。两者都放数据库:写压力集中在同一行,热点写扛不住。两者都放缓存:Redis 一旦丢数据,谁赞过谁就永远查不回来了——那是不可接受的,因为它是用户的行为记录分层之后各自承担合适的角色:明细是事实来源、可重建;计数是派生值、可以短暂不准。判据是「这份数据丢了能不能重建」:不能重建的必须落库。

    「计数允许短暂不精确」但「我是否已赞必须精确」,这是这个模块最核心的判断。点赞数差一两个没人会发现、也没有任何后果;而「我是否已赞」显示错了,用户会再点一次、结果变成取消,体验直接崩所以前者可以走缓存加延迟回写、后者必须能降级到明细表我最初把它们当成同一件事做,所以两头都没做好——分开之后设计一下就清楚了。

    不引入消息队列做削峰。用队列异步处理点赞是很常见的思路。但我只有一台服务器,多一个中间件就多一个我得维护和排查的东西而 Redis 原子自增本身已经很快,定时回写又把数据库的写压力削平了,队列在这里带来的额外收益很小判据是「这个方案带来的复杂度,我一个人维护得住吗」——而且队列本身也需要对账兜底(消息可能丢),既然对账绕不开,就直接把对账做成主要保障。

    对账的差异必须记录而不是静默修正。静默修正实现更简单、界面上也永远正确。但它会掩盖真正的问题——你在不停擦地,却不知道水龙头在哪我漏减计数那条路径,就是靠差异日志找出来的:差异一直在同一个方向增长,我才想到是某条减少的路径没走。如果当初做的是静默修正,这个 bug 会一直存在。

    踩过的坑一:取消点赞时漏减计数。我把「点赞」路径的计数加了,「取消点赞」路径只删明细、忘了减而这个漂移只会越来越大,没有任何机制会发现它——因为它不报错,只是数字偏大。教训是:一旦把一个「每次都从明细算」的值改成「增量维护」,就必须把所有影响它的路径列成清单逐一处理,而且必须有对账,因为你一定会漏掉某一条。我当时是凭印象改的,所以漏了。

    踩过的坑二:快速连点产生重复点赞。我的实现是「先查有没有赞过,没有就插入」。两个请求几乎同时进来,都查到「没赞过」,都插入成功。教训是:并发下「先查再写」不构成约束,唯一性必须由数据库保证。这一条和我在其他地方遇到的重复提交是同一个形态。

    踩过的坑三:列表页逐条查已赞状态。一页 20 个帖子 20 次查询。而这个写法特别自然——渲染每个帖子时顺手查一下,我当时完全没觉得有问题,是数了一遍接口里的查询次数才发现的教训是:「循环里发查询」在代码里往往长得很无辜,要靠主动去数,不会自己冒出来。

    没做的部分:没做点赞的「防刷」(小号互赞、脚本批量点赞)。它需要行为特征识别和风控规则,而识别规则的阈值必须靠真实的行为分布来调——这是我这个项目不具备的前提,凭空拍一个阈值出来只会误伤。不过我做了一个不依赖行为数据的轻限制:同一用户对同一帖子的点赞与取消次数有上限,防的是最简单的反复刷取舍依据是「能做的先做掉,需要真实数据才能做对的先不做」。

    数字是怎么测的

    对账差异数是这个模块最有说服力的指标:「加上对账任务后第一次运行,就发现 N 条帖子的计数与明细不一致,据此定位到『取消点赞未减计数』这条路径;修复后差异数持续为零」这个数字同时证明了两件事:对账机制有用、以及我确实通过它找到了一个真 bug。差异是在压测数据上跑出来的——压测时点赞与取消是按比例混合发的,所以漂移会被迅速放大,比手点更容易暴露。

    幂等性用并发压测断言:用多个线程对同一帖子并发提交点赞,断言明细表只有一条记录、计数只加一要真并发,顺序调两次测不出来。

    取消的幂等同样要测:连续两次取消,断言计数只减一次、不会出现负数。

    查询次数:报「列表页显示已赞状态的查询次数从『与帖子数成正比(一页 20 条即 20 次)』降为固定 1 次」。这个数字很具体也容易核对。

    热点写的改善要报清测法:「用 N 个并发线程对同一帖子点赞,改造前接口耗时 X(存在同一行的锁等待)、改造后 Y」。必须说明并发数、压测工具和是在什么机器上跑的,并且明确这是压测结论、不是线上观测——个人项目没有线上流量,把这一点讲在前面,后面的数字才立得住。

    缓存丢失重建:清空 Redis 里的计数与集合,断言能从明细表重算回填、重建期间显示的是帖子表上的兜底值而不是 0、「我是否已赞」降级到明细表后仍然正确最后一条最重要,因为它是「必须精确」的那一类。

    回写幂等:让回写任务重复执行,断言计数不会被叠加(因为用的是覆盖而不是增量)。

    不要报什么:不要报「点赞接口 QPS 提升 N 倍」——我没有那个量级的真实流量,压测数字也受机器影响。该报的是「对账第一次跑发现 N 条差异并定位到具体路径」「并发点赞明细只一条、计数只加一」「已赞状态查询次数从 20 次降为 1 次」「清空缓存后可从明细完整重建」这几件可核对的事。

    面试追问
    Q:点赞数放 Redis,那 Redis 挂了数据不就丢了吗? A:计数丢了没关系,因为它是派生值;关键是明细不能丢——所以明细我一直是入库的。我的分层是:明细表(用户, 帖子, 类型)是唯一的事实来源,计数是从它派生出来的Redis 重启或数据被清之后,我能从明细表重算并回填;而重建期间读帖子表上的兜底计数(定时回写刷进去的那份),不会显示 0——显示 0 比显示一个略旧的值糟糕得多判据是「这份数据丢了能不能重建」:不能重建的必须落库。反过来说,如果我把明细也只放 Redis,那「谁赞过谁」这个用户的行为记录就永远查不回来了,那是不可接受的。那为什么计数不直接放数据库?因为我最初就是 update post set like_count = like_count + 1一条帖子火了之后很多人同时点赞,所有请求都在更新同一行,数据库要串行处理,接口耗时明显上升——这是「同一行热点写」的典型形态。我这个规模下还不至于出事,但我意识到这条路走不远。还有一个关键判断我想说:「点赞数」和「我是否已赞」是两种完全不同的数据。点赞数允许短暂不精确(差一两个没人发现、也没有后果),可以走缓存加延迟回写;而「我是否已赞」必须精确——如果我明明赞过却显示没赞,我会再点一次,然后变成取消,体验直接崩,所以它在缓存不可用时必须降级到明细表查询。我最初把这两者当成同一件事做,所以两头都没做好。
    Q:你说的对账具体是干什么的?有必要吗? A:非常有必要,而且它帮我找到了一个我自己完全没发现的 bug。对账就是定时用明细表重算真实计数,和 Redis 里的、数据库帖子表上的比对,有差异就记录一条日志并用明细的结果修正为什么必要?因为我把计数从「每次都从明细 count」改成了「增量维护」——而增量维护的一致性不是天然的,它要求我把所有影响这个值的路径都处理到我漏了一条:取消点赞时只删了明细、忘了减计数。这个漂移只会越来越大,而且不报错、不抛异常,只是数字偏大——没有任何机制会发现它。我加上对账任务之后第一次运行,就看到一批帖子的计数和明细不一致,而且差异一直是同一个方向(计数偏大),我才想到是某条「减少」的路径没走——方向一致这个特征本身就是线索。这里有一个细节我认为很关键:差异必须记录,不能静默修正。静默修正实现更简单、界面上也永远正确,但它会掩盖真正的问题——你在不停擦地,却不知道水龙头在哪。如果我当初做的是静默修正,那个漏减的 bug 会一直存在。所以我把「对账差异数」当成一个监控指标,它应该长期为零,不为零就说明有路径没维护计数。顺带说为什么我没用消息队列来异步处理点赞:一是我只有一台服务器,多一个中间件就多一个我得维护和排查的东西;二是队列本身也需要对账兜底(消息可能丢、消费可能失败),既然对账绕不开,我就直接把对账做成主要保障,而不是再叠一层。

模块四:注册登录与会话安全

  1. 注册登录与会话安全(密码用加盐慢哈希而非普通摘要 + 登录失败的按账号与按来源双维度限制 + 会话令牌的过期与失效 + 找回密码令牌一次性且与旧会话联动)★★★
    简历这样写 注册登录与会话(Spring Boot + MySQL + Redis):密码存储从普通摘要算法改为加盐的慢哈希(普通摘要在弱口令上可被批量比对还原,而加盐让相同密码得到不同结果、慢哈希让批量尝试的代价大幅上升);登录失败限制从仅按账号计数改为按账号与按来源两个维度同时限制(只按账号无法阻止用同一批弱口令扫大量账号,只按来源会让共用出口的正常用户互相牵连);会话令牌改为服务端可失效的形式并设置过期,支持改密后使其他会话全部失效;找回密码的令牌做到足够随机、短有效期、一次性使用,并在重置成功后同时失效该账号的全部旧会话;注册与登录的响应做到不区分「账号不存在」与「密码错误」,避免被用来批量探测哪些账号已注册。相关风险点均在设计评审自查清单中逐项确认。
    展开完整拆解
    为什么要这么设计

    注册登录是我写这个论坛时最早完成的功能,也是我最初以为最没有技术含量、后来发现问题最密集的一块。我第一版的实现是:密码用一个常见的摘要算法处理一下存进数据库,登录时比对,成功就发一个令牌。四个问题。

    一是密码的存储方式不安全,而我当时以为「哈希过了就安全」。我用的是普通的摘要算法、而且没有加盐。问题有两层:第一,相同的密码会得到完全相同的结果——只要有两个用户用了同一个弱口令,从数据库里一眼就能看出来第二,这类摘要算法本来就是为「快」设计的,批量尝试常见弱口令的代价极低也就是说数据库一旦泄露,弱口令用户的密码基本等于明文。

    二是登录失败限制只按账号计数,挡不住真正的攻击方式。我做的是「同一个账号连续失败几次就锁一段时间」。但真实的攻击不是拿一个账号试一万个密码,而是拿几个常见弱口令去试一万个账号——每个账号只失败一两次,我的限制完全不会触发。

    三是会话令牌一旦发出去就没法失效。我签发的令牌里带了过期时间,服务端不保存任何状态。结果是用户改了密码之后,旧令牌在过期前依然可用——而「改密码」这个动作用户的预期恰恰是「把别人踢下去」。

    四是接口的响应会泄露账号是否存在。登录时账号不存在返回「用户不存在」、密码错了返回「密码错误」;注册时已注册的邮箱直接提示「该邮箱已注册」这两处合起来,可以被用来批量探测哪些邮箱在这个站上注册过。

    所以四个改动:密码改用加盐的慢哈希登录失败按账号与按来源双维度限制会话令牌改为服务端可失效并支持改密后全部失效登录与注册的响应不区分账号是否存在

    这个模块最想说的一句话是:「我哈希过了」和「我的密码存储是安全的」之间差着加盐和慢哈希两件事,而我一开始完全不知道有这个差别。这块的坑有一个共同特点:功能测试全都能过(能注册、能登录、能改密码),问题全在「数据库泄露之后会怎样」「被批量尝试时会怎样」这些不在正常路径上的假设里。而这些恰好是面试官最爱追的地方。

    整体链路
    密码存储 │ ├─ 注册:为每个用户生成独立的盐 → 用慢哈希算法处理 → 存哈希与盐 │ 为什么要加盐 │ 不加盐时相同密码得到相同结果 │ 数据库里一眼就能看出「这两个人用了同一个弱口令」 │ 也让预先算好的对照表可以直接命中 │ 为什么要慢哈希 │ 普通摘要算法是为「快」设计的 │ 批量尝试常见弱口令的代价极低 │ 慢哈希让每次尝试都变贵,批量尝试就不划算了 │ ├─ 慢哈希的强度参数要能调,并随机器性能重新评估 │ 太低 → 保护不足;太高 → 登录接口自己变慢 │ ├─ 登录:取出盐 → 同样方式处理输入 → 比对 │ 比对要用「定长时间」的方式,避免通过响应时间推断 │ └─ 数据库里绝对不存明文,日志与异常堆栈里也不能出现密码 日志打印请求参数是很常见的泄露路径 登录失败限制(两个维度都要) │ ├─ 按账号:同一账号连续失败达到阈值 → 锁定一段时间 │ 挡的是「拿一个账号试很多密码」 │ ├─ 按来源:同一来源在时间窗内失败次数达到阈值 → 限制 │ 挡的是「拿几个弱口令试很多账号」 │ 只按账号的后果:每个账号只失败一两次,限制完全不触发 │ 只按来源的后果:共用出口的正常用户互相牵连 │ ├─ 阈值与锁定时长要分开配,并记录触发日志 │ └─ 锁定要有自动解除,不要做成永久 永久锁定会把正常用户挡在外面,而我没有人工解锁的能力 会话令牌 │ ├─ 令牌要服务端可失效(存一份会话记录,而不是纯自包含) │ 纯自包含令牌的后果 │ 签发出去就无法撤回,改密码后旧令牌在过期前依然可用 │ 而「改密码」用户的预期恰恰是「把别人踢下去」 │ ├─ 会话记录:令牌标识 · 用户 · 签发时间 · 过期时间 · 设备信息 │ ├─ 支持三种失效 │ 单个会话失效(退出登录) │ 该用户全部会话失效(改密码 · 发现异常) │ 过期自动失效(定期清理过期记录) │ ├─ 令牌本身要足够随机、且只存哈希 │ 存原文的后果:数据库泄露等于所有会话被接管 │ └─ 敏感操作(改密码 · 改邮箱)要求重新验证当前密码 只靠会话有效的后果:令牌被拿到就能改密码彻底接管账号 找回密码 ├─ 令牌足够随机、短有效期、一次性使用(用完立即失效) ├─ 重置成功后同时失效该账号的全部旧会话 │ 不失效的后果:攻击者的旧会话在改密后依然有效 ├─ 无论邮箱是否注册,响应都一致(见下) └─ 发送频率要限制,否则会被用来骚扰某个邮箱 不泄露账号是否存在 ├─ 登录失败统一返回「账号或密码错误」,不区分两种原因 ├─ 找回密码统一返回「如果该邮箱已注册,将收到邮件」 ├─ 注册时的重复提示是个两难(见取舍) └─ 不统一的后果:这些接口可以被用来批量探测哪些邮箱注册过
    分步拆解
    1. 密码必须加盐,而且每个用户的盐独立。不加盐的后果是相同密码得到相同结果,数据库里一眼就能看出谁和谁用了同一个弱口令,预先算好的对照表也能直接命中。
    2. 必须用慢哈希,不能用普通摘要算法。普通摘要是为「快」设计的,批量尝试常见弱口令的代价极低——慢哈希让每次尝试都变贵。
    3. 慢哈希的强度参数要可调。太低保护不足、太高会让登录接口自己变慢,而且要随机器性能重新评估。
    4. 密码比对要用定长时间的方式。否则可能通过响应时间的差异推断信息。
    5. 日志与异常堆栈里不能出现密码。打印请求参数是很常见的泄露路径——这一条比数据库存储更容易被忽略。
    6. 登录失败限制要按账号与按来源两个维度同时做。只按账号挡不住「拿几个弱口令试很多账号」(每个账号只失败一两次,限制完全不触发);只按来源会让共用出口的正常用户互相牵连。
    7. 锁定要能自动解除,不要做成永久。永久锁定会把正常用户挡在外面,而我没有人工解锁的能力。
    8. 会话令牌要服务端可失效,不要用纯自包含的形式。纯自包含的后果是签发出去就无法撤回,改密码后旧令牌在过期前依然可用。
    9. 要支持「该用户全部会话失效」。改密码、发现异常时用,这是「把别人踢下去」这个用户预期的实现基础。
    10. 令牌本身要足够随机,而且数据库里只存哈希。存原文的后果是数据库泄露等于所有会话被接管。
    11. 过期的会话记录要定期清理。否则会话表会一直增长。
    12. 敏感操作要求重新验证当前密码。只靠会话有效的后果是令牌一旦被拿到就能改密码、彻底接管账号。
    13. 找回密码的令牌必须一次性使用。用完立即失效——不失效的话邮件被转发或截图后还能再用一次。
    14. 重置密码成功后要失效该账号的全部旧会话。不失效的后果是攻击者的旧会话在改密之后依然有效,改密码就白改了。
    15. 找回密码要限制发送频率。否则会被用来骚扰某个邮箱。
    16. 登录失败统一返回「账号或密码错误」,找回密码统一返回「如果该邮箱已注册将收到邮件」。不统一的后果是这些接口可以被用来批量探测哪些邮箱注册过。
    关键决策与取舍

    用加盐慢哈希而不是普通摘要,代价是登录接口会变慢一点。慢哈希本来就是「故意慢」的——而这个慢对单次登录是可以接受的(用户感觉不出来),对批量尝试却是决定性的强度参数的取值要权衡:我是在自己的机器上试出一个「单次验证耗时在可接受范围内」的值,并且写清了这个值需要随机器性能重新评估判据是「攻击者批量尝试的成本」和「正常用户单次登录的成本」不对称——慢哈希放大的正是这个不对称。

    会话令牌选「服务端保存记录」而不是纯自包含。纯自包含的好处很明显:不需要存储、验证不用查库、天然可横向扩展但它有一个我无法接受的缺陷:签发出去就撤不回来——而「改密码之后把其他设备踢下线」是一个基本预期代价是每次验证都要查一次会话记录(我放在缓存里,成本可以接受)判据是「这个凭据需不需要被提前撤销」:需要,那就不能用无状态的形式。如果是那种「短有效期、不需要撤销」的场景,我的结论会反过来。

    登录失败限制做两个维度,代价是实现和调参都复杂一些。只做一个维度的问题我在前面说了——两种攻击方式各自绕过其中一个而按来源限制有一个真实的副作用:共用出口的正常用户会互相牵连(比如同一个校园网出口)。我的缓解办法是把按来源的阈值设得比按账号宽松得多,并且限制时长更短——它的目标不是拦死,而是把批量尝试的速度压下来。

    注册时是否提示「该邮箱已注册」是一个两难,我选了提示。不提示(统一返回成功、只在邮件里区分)能彻底避免探测,但它会让正常用户非常困惑——他明明注册过,却收不到任何提示,只能反复尝试而登录和找回密码那两处我选了统一响应,因为那里不提示对正常用户几乎没有影响。判据是「不提示对正常用户的伤害有多大」:注册处伤害大、登录处伤害小。我把这个不一致明确记下来了,因为它是一个有意识的妥协,不是遗漏。

    踩过的坑一:我以为「哈希过了就安全」。直到我自己在数据库里看到两个测试账号的密码哈希完全一样(因为我用了同一个弱口令)——那一刻我意识到这个存储方式泄露的信息比我想的多得多教训是:「我哈希过了」和「我的密码存储是安全的」之间差着加盐和慢哈希两件事,而这两件事都不在功能测试的覆盖范围内。

    踩过的坑二:登录失败限制只按账号,我以为已经防住了暴力破解。后来想清楚攻击方式才发现真实的攻击是拿几个常见弱口令去试大量账号,每个账号只失败一两次——我的限制一次都不会触发教训是:做防护之前要先想清楚「攻击者实际会怎么做」,而不是按自己的直觉设限制。我的直觉版本防的是一个不太会发生的攻击。

    踩过的坑三:改密码之后旧会话还能用。是我自己在两个浏览器里测试时发现的——在 A 里改了密码,B 里居然还能正常发帖教训是:一个操作的「用户预期」经常超出它的字面含义——「改密码」的字面含义是换一个凭据,而用户的预期是「把别人踢下去」。这两者之间的差距要靠实现来补。

    没做的部分:没做两步验证与第三方登录。两步验证需要另一条可靠的验证通道(短信或验证器应用)和一整套绑定与恢复流程,恢复流程做不好会把用户永久锁在外面;第三方登录则依赖外部服务的接入与账号绑定关系。取舍依据是「这两块的正确性都依赖我不完全掌握的外部条件」——而我更愿意把基础的密码与会话这一层做扎实。

    数字是怎么测的

    密码存储的验证不适合报数字,该报「可核对的事实」:每个用户有独立的盐、用的是慢哈希、相同密码的两个账号在数据库里的哈希不同(这条可以直接查库确认)、日志与异常堆栈中不出现密码。这几条比任何数字都有说服力,而且面试官可以当场追问实现细节。

    慢哈希的强度参数要报「单次验证耗时」以及是在什么机器上测的:「在我的机器上单次验证耗时约 X 毫秒」,并说明这个值需要随机器性能重新评估这个数字直接说明我理解「慢」是这个算法的特性而不是缺陷。

    登录失败限制用断言型用例,两个维度各一条:同一账号连续失败达阈值,断言被锁定且能自动解除用同一来源对多个不同账号各失败一两次,断言按来源的限制被触发——后一条是踩坑的直接回归,它正是「只按账号」挡不住的那种攻击。

    会话失效:在一个客户端改密码,断言另一个客户端的旧令牌立即失效、需要重新登录这条要用两个真实的会话测,不能只测接口。

    找回密码令牌:断言令牌用过一次后立即失效、超过有效期后失效、重置成功后该账号的全部旧会话失效。

    账号探测:用不存在的账号与存在但密码错误的账号分别登录,断言两者的响应内容与状态码完全一致并对比两者的响应耗时是否有明显差异(如果差异明显,说明仍然可以通过时间推断)。后半句容易被忽略但很值得测。

    敏感操作:断言改密码、改邮箱时必须提供当前密码,仅有有效会话不足以完成。

    不要报什么:不要说「保证账号安全」——安全性取决于最弱的那一环,而我不可能穷举。该报的是「相同密码的哈希不同」「单次验证耗时及测试机器」「按来源的失败限制能拦住多账号少次数的尝试」「改密后旧会话立即失效」「账号不存在与密码错误的响应完全一致」这几件可核对的事。

    面试追问
    Q:密码你是怎么存的? A:加盐的慢哈希,每个用户一个独立的盐。但我想先说我第一版做错了什么,因为那个认知错误更能说明问题。我第一版用的是一个常见的摘要算法、而且没有加盐,当时我以为「哈希过了就安全」让我意识到不对的是一个很直观的场景:我在数据库里看到两个测试账号的密码哈希完全一样——因为我给它们用了同一个弱口令。那一刻我明白了两件事。第一,不加盐时相同密码得到相同结果,数据库里一眼就能看出「这两个人用了同一个弱口令」,而且预先算好的对照表可以直接命中——所以要为每个用户生成独立的盐。第二,这类摘要算法本来就是为「快」设计的,批量尝试常见弱口令的代价极低——所以要用慢哈希,让每次尝试都变贵关键在于这个「慢」造成的成本是不对称的:对正常用户是单次登录多几十毫秒、感觉不出来;对批量尝试的人是乘以尝试次数,直接变得不划算强度参数我是在自己机器上试出一个「单次验证耗时可接受」的值,并写清了它需要随机器性能重新评估——因为机器变快了,攻击者的成本也跟着降了。另外还有三个配套的点比对要用定长时间的方式,避免通过响应时间推断;数据库里绝对不存明文,日志和异常堆栈里也不能出现密码——打印请求参数是很常见的泄露路径,这一条比数据库存储更容易被忽略以及敏感操作(改密码、改邮箱)要求重新验证当前密码,否则令牌一旦被拿到就能彻底接管账号。
    Q:防暴力破解你做了什么?登录失败几次就锁账号吗? A:我第一版就是「同一账号连续失败几次就锁一段时间」,后来发现它防的是一个不太会发生的攻击。真实的攻击方式不是拿一个账号试一万个密码,而是拿几个常见弱口令去试一万个账号——这样每个账号只失败一两次,我的按账号限制一次都不会触发教训是:做防护之前要先想清楚「攻击者实际会怎么做」,而不是按自己的直觉设限制。所以我改成按账号与按来源两个维度同时限制:按账号挡「一个账号试很多密码」,按来源挡「几个弱口令试很多账号」按来源有一个真实的副作用我要承认:共用出口的正常用户会互相牵连(比如同一个校园网出口)。我的缓解办法是把按来源的阈值设得比按账号宽松得多、限制时长也更短——它的目标不是拦死,而是把批量尝试的速度压下来。另外锁定必须能自动解除、不能做成永久,因为永久锁定会把正常用户挡在外面,而我没有人工解锁的能力。还有一处相关的问题是接口会泄露「账号是否存在」:登录时账号不存在返回「用户不存在」、密码错了返回「密码错误」,这两个响应合起来可以被用来批量探测哪些邮箱在这个站注册过。所以登录统一返回「账号或密码错误」、找回密码统一返回「如果该邮箱已注册将收到邮件」。但注册处我做了一个有意识的妥协:仍然提示「该邮箱已注册」——因为不提示会让正常用户非常困惑(他明明注册过却收不到任何提示,只能反复尝试)判据是「不提示对正常用户的伤害有多大」,注册处伤害大、登录处伤害小,所以我在两处做了不同的选择,并把这个不一致记了下来。

模块五:站内搜索的实现与边界

  1. 站内搜索的实现与边界(模糊匹配用不了索引改为全文索引 + 中文分词决定能不能搜到 + 结果高亮与转义 + 说清什么量级该换专门的搜索引擎)★★★
    简历这样写 站内搜索(Spring Boot + MySQL 全文索引):搜索初版用字段模糊匹配,造数据后发现前置通配的模糊匹配无法利用索引、必须逐行扫描,随数据量增长明显变慢;改为全文索引检索,并针对中文补充了分词处理(默认按空格切分对中文几乎无效,导致搜两个字的词搜不到任何结果);搜索结果做关键词高亮,实现上先转义再插入标记(初版直接拼接标记,用户输入的特殊字符会破坏页面结构);对过短关键词、纯符号、超长输入做前置拦截,避免产出无意义的全量结果;无结果时给出可操作的建议而非空白页。同时在文档里写清了该方案的适用边界:当前量级下全文索引足够,一旦需要复杂相关性排序或数据量再上一个量级,就应换用专门的搜索引擎——这个边界是我实测扫描行数与耗时后给出的判断
    展开完整拆解
    为什么要这么设计

    搜索是我在这个项目里最想「直接上搜索引擎」但最后没有上的一块。我的第一版实现是最直觉的:用字段模糊匹配,两边都加通配符。功能上完全能用——搜什么都能搜到。四个问题都是造了数据之后才出现的。

    一是随数据量增长明显变慢,而且加索引没有用。我给标题字段加了索引,但搜索完全没有变快查执行计划才明白:两边都带通配符的模糊匹配无法利用普通索引,数据库只能逐行扫描并逐个比对——索引在这种查询下形同虚设而这一点我在小数据量下完全看不出来(几百条数据扫全表也很快)。

    二是我改成全文索引之后,中文搜不到。我以为换成全文索引就解决了,结果搜中文词几乎搜不到任何结果原因是全文索引默认是按空白和标点切分词的——这对英文有效,而中文句子里没有空格,整句话被当成了一个词我搜「游标分页」,而库里存的是一整句话,自然匹配不上。

    三是关键词高亮把页面搞坏了。我的实现是「在结果文本里把关键词替换成带标记的形式」。用户搜了一个带尖括号的关键词,替换之后那段标记被当成了页面结构的一部分——整个卡片的排版乱了更严重的是这条路径可以被用来注入内容。

    四是过短或无意义的关键词返回了海量结果。搜一个单字、或者搜一个标点,匹配到几乎全部数据——既没有意义,又是一次昂贵的查询。

    所以四个改动:模糊匹配改为全文索引检索补中文分词处理高亮改为先转义再插入标记对过短关键词与纯符号做前置拦截

    这个模块最想说的一句话是:「为什么我没上搜索引擎」这个问题,只有在你真的量过之后才答得好。我一开始想上,是因为「搜索就该用搜索引擎」;后来没上,是因为我实测了扫描行数和耗时,确认在我这个量级下全文索引完全够用而更重要的是我能说出「什么时候该换」——需要复杂的相关性排序、或者数据量再上一个量级。能说出边界的「没上」,和不知道有这个东西的「没上」,在面试里是完全不同的两件事。

    整体链路
    为什么模糊匹配不行 │ ├─ 两边都带通配符的模糊匹配无法利用普通索引 │ 数据库只能逐行扫描并逐个比对 │ 我给标题加了索引,搜索完全没有变快 —— 查执行计划才明白 │ └─ 小数据量下看不出来(几百条数据扫全表也很快) 所以这个问题必须造数据才能暴露 全文索引 + 中文分词 │ ├─ 对标题与正文建立全文索引 │ ├─ 中文必须处理分词(这是我踩的第二个坑) │ 全文索引默认按空白和标点切分 —— 对英文有效 │ 中文句子里没有空格 → 整句被当成一个词 → 搜两个字搜不到 │ 处理方式:入库时对内容分词后存一份「可检索文本」 │ 查询时对关键词做同样的分词处理 │ 两侧必须用同一套分词规则,否则永远匹配不上 │ ├─ 分词粒度要权衡 │ 粒度太粗 → 搜不到(「游标分页」搜不到含「游标」的内容) │ 粒度太细 → 噪声大(搜「分」匹配到大量无关内容) │ └─ 检索范围要包含标题与正文,但标题命中应排在前面 只搜标题的后果:正文里写了但标题没写的内容搜不到 输入前置处理(不合理的查询根本不该发出去) │ ├─ 关键词过短(单字)→ 拦截并提示 │ 不拦的后果:匹配到几乎全部数据,既无意义又是一次昂贵查询 │ ├─ 纯符号 / 纯空白 → 拦截 │ ├─ 超长输入 → 截断或拦截 │ ├─ 关键词中的特殊字符要按检索语法转义 │ 不转义的后果:某些字符在检索语法里有特殊含义,导致语法错误 │ └─ 前置拦截的意义:把「注定没有意义的查询」挡在数据库之外 结果高亮(这里有一个安全问题) │ ├─ 正确顺序:先对文本做转义 → 再插入高亮标记 │ ├─ 直接在原文里替换关键词的后果(我踩过) │ 用户搜一个带尖括号的关键词 │ 替换后那段标记被当成页面结构的一部分 → 卡片排版乱了 │ 更严重的是这条路径可以被用来注入内容 │ ├─ 高亮要基于分词后的词,而不是整串关键词 │ 否则搜「游标分页」时,只含「游标」的结果不会被高亮 │ └─ 摘要截取要围绕命中位置,而不是从头截固定长度 从头截的后果:命中的词可能根本不在摘要里,用户不知道为什么匹配 结果排序与空态 ├─ 排序:标题命中优先 · 其次全文相关度 · 再其次时间 ├─ 无结果时给可操作建议(换关键词 · 检查错别字 · 看热门内容) ├─ 空态要区分「没有匹配」与「查询失败」 └─ 搜索词与结果数可以记录下来(哪些词搜不到 = 内容缺口) 边界要写清楚 ├─ 当前量级下全文索引足够 —— 这是实测扫描行数与耗时后的判断 ├─ 什么时候该换专门的搜索引擎 │ 需要复杂的相关性排序与打分 │ 需要同义词 · 拼写纠错 · 多字段权重 │ 数据量再上一个量级 └─ 把边界写在文档里,而不是等别人问「你为什么不用」
    分步拆解
    1. 先认清两边带通配符的模糊匹配用不了索引。数据库只能逐行扫描——我给字段加了索引但搜索完全没变快,是查执行计划才明白的。
    2. 这个问题必须造数据才能暴露。几百条数据下扫全表也很快,小数据量会让你误以为没问题。
    3. 改用全文索引检索。这是在不引入外部组件的前提下,唯一能让搜索走索引的办法。
    4. 中文必须处理分词。全文索引默认按空白和标点切分,而中文句子里没有空格,整句被当成一个词,搜两个字的词搜不到任何结果。
    5. 入库时对内容分词存一份「可检索文本」,查询时对关键词做同样处理。两侧必须用同一套分词规则——不一致的话永远匹配不上。
    6. 分词粒度要权衡。太粗则搜不到、太细则噪声大(搜一个单字匹配到大量无关内容)。
    7. 检索范围要包含标题与正文,但标题命中排在前面。只搜标题的后果是正文里写了但标题没写的内容搜不到。
    8. 过短关键词、纯符号、纯空白要前置拦截。不拦的后果是匹配到几乎全部数据,既没有意义又是一次昂贵的查询。
    9. 关键词里的特殊字符要按检索语法转义。某些字符在检索语法里有特殊含义,不转义会直接导致语法错误。
    10. 高亮必须「先转义文本、再插入标记」,顺序不能反。直接在原文里替换的后果是用户搜一个带尖括号的关键词就能破坏页面结构,而且这条路径可以被用来注入内容。
    11. 高亮要基于分词后的词而不是整串关键词。否则搜「游标分页」时,只含「游标」的结果不会被高亮,用户不知道为什么它被匹配上了。
    12. 摘要要围绕命中位置截取,不要从头截固定长度。从头截的后果是命中的词可能根本不在摘要里。
    13. 排序按「标题命中 → 全文相关度 → 时间」。纯按时间排的话,最相关的内容可能在很后面。
    14. 无结果时给可操作的建议。「换个关键词」「检查错别字」「看看热门内容」——空白页对用户没有帮助。
    15. 空态要区分「没有匹配」与「查询失败」。查询失败显示成「没有匹配」会让用户以为真的没有。
    16. 可以记录搜索词与结果数。「哪些词搜了但没有结果」是内容缺口的直接信号,这个数据几乎免费。
    17. 把方案的适用边界写进文档。什么量级够用、什么情况该换——而不是等别人问「你为什么不用搜索引擎」。
    关键决策与取舍

    没上专门的搜索引擎,用数据库自带的全文索引,这是我在这个项目里最需要讲清依据的一个决定。我一开始是想上的,理由很朴素——「搜索就该用搜索引擎」。但我先做了一件事:实测当前量级下全文索引的扫描行数与耗时结论是完全够用而上搜索引擎的代价是:多一个要部署、要维护、要保证与数据库同步的组件——数据同步本身就是一个新的一致性问题(写入延迟、同步失败、重建索引)判据是「引入这个组件解决的问题,我现在有没有」:没有,那就不该引入。但我把「什么时候该换」写清楚了:需要复杂的相关性排序与打分、需要同义词与拼写纠错、或数据量再上一个量级。能说出边界的「没上」,和不知道有这个东西的「没上」,在面试里是完全不同的两件事。

    分词在入库时做而不是查询时实时做。查询时实时分词的好处是不占额外存储、也不用处理历史数据。但它意味着每次查询都要先分词、而且分词结果无法建索引——那就又回到了逐行扫描入库时分词存一份「可检索文本」的代价是多一列存储、以及分词规则变更时要重扫历史数据判据是「这个计算的结果需不需要被索引」:需要,那就必须提前算好存下来。

    分词粒度选了一个中间值,而不是尽可能细。细粒度能保证「搜什么都能搜到」。但它带来的噪声很大——搜一个常见单字会匹配到大量无关内容,而这些结果对用户完全没用粗粒度则会漏(搜两个字的词匹配不到含该词的长句)我的取舍是偏向「宁可漏一点,也不要大量噪声」,理由是用户看到一堆无关结果会直接认为搜索不好用,而偶尔搜不到他会换个词再试。

    高亮选择「先转义再插入标记」,这不是取舍而是必须。反过来的顺序(先插标记再转义)会把自己插入的标记也转义掉、高亮失效;而不转义直接插入则会让用户输入的内容破坏页面结构只有「先转义、再插入我自己生成的标记」这一个顺序是对的这一条我是踩了坑才想清楚顺序的重要性。

    踩过的坑一:给字段加了索引但搜索完全没变快。我一开始以为是索引没生效、或者需要重建。查执行计划才明白:两边都带通配符的模糊匹配根本用不了普通索引,数据库只能逐行扫描教训是:加索引不等于查询会走索引——查询的写法决定了索引能不能用而我如果早一点去看执行计划,就不会在「索引为什么没生效」上绕那么久。

    踩过的坑二:换成全文索引之后中文搜不到,我以为是全文索引不支持中文。实际原因是默认的分词方式按空白和标点切分——对英文有效,而中文句子里没有空格,整句被当成了一个词教训是:一个功能「对某类输入无效」时,先去搞清它的工作前提是什么——而不是直接判定它不支持。搞清之后解法就很明确了:自己做分词,两侧规则一致。

    踩过的坑三:高亮直接替换关键词,被一个带尖括号的搜索词搞坏了页面。我是自己乱输测试的时候撞出来的。教训是:任何「把用户输入拼进输出」的地方都要先转义——而搜索高亮特别容易被忽略,因为它看起来只是「加个颜色」这么无害的操作。

    没做的部分:没做拼写纠错与同义词。它们需要词库和纠错模型,而且效果好不好完全取决于词库质量——我没有能力评估自己拼出来的词库好不好取舍依据是「我无法判断它是否有效的功能,做出来也不敢用」;我做的是无结果时给出建议和热门内容引导,用一个确定有效的兜底替代一个效果不确定的功能。

    数字是怎么测的

    模糊匹配的问题要用执行计划加扫描行数来证明,这比耗时更本质:「数据量为 N 时,两边带通配符的模糊匹配的执行计划显示为全表扫描、扫描行数约等于总行数;改用全文索引后扫描行数下降到 M」扫描行数是可以直接从执行计划里读出来的,没有解释空间。

    耗时对比要报清数据量:「N 条数据时,模糊匹配耗时 X、全文索引耗时 Y」,并说明数据是用脚本造的、内容长度大致分布如何(因为全文检索的耗时和文本长度有关)。

    中文分词的效果用断言型用例,这是最直观的一条:造一条含「游标分页」的内容,断言搜「游标分页」能搜到、搜「游标」也能搜到并保留一条反例记录改造前搜不到——这个前后对比比任何描述都清楚。

    分词粒度的取舍要报噪声:「搜一个常见单字时的结果数」——粒度过细时这个数字会非常大(接近全量),说明噪声不可接受。这个数字是我选择中间粒度的依据。

    高亮的安全性是断言型用例:搜索一个带尖括号与引号的关键词,断言结果页的结构完整、这些字符以纯文本形式显示、没有产生任何可执行内容

    前置拦截:断言单字、纯符号、纯空白、超长输入都被拦在数据库之外(可以看数据库有没有收到查询),并给出了提示。

    摘要与排序:断言摘要围绕命中位置截取、命中的词出现在摘要里;断言标题命中的结果排在正文命中之前。

    边界判断要报依据:「在当前量级下全文索引的耗时为 X,我据此判断够用;并估算了数据量增长到多少时耗时会到不可接受的范围」。报出这个估算过程,比只说「够用」有说服力。

    不要报什么:不要报「搜索准确率」——我没有标注好的评测集,这个数字算不出来。该报的是「执行计划从全表扫描变为走索引、扫描行数从 N 降到 M」「改造前后中文词能否搜到的前后对比」「搜单字时的结果数(噪声依据)」「带特殊字符的搜索词不破坏页面结构」这几件可核对的事。

    面试追问
    Q:搜索为什么不用搜索引擎?用数据库做搜索不是很勉强吗? A:我一开始是想上的,理由很朴素——「搜索就该用搜索引擎」。最后没上,是因为我先量过了。我实测了当前量级下全文索引的扫描行数和耗时,结论是完全够用而上搜索引擎的代价是多一个要部署、要维护、要保证与数据库同步的组件——数据同步本身就是一个新的一致性问题(写入延迟、同步失败、索引重建),这些对我一个人维护的项目是实打实的负担。判据是「引入这个组件解决的问题,我现在有没有」:没有,那就不该引入。但我把「什么时候该换」写清楚了:需要复杂的相关性排序与打分、需要同义词与拼写纠错、多字段权重,或者数据量再上一个量级。我觉得能说出边界的「没上」,和不知道有这个东西的「没上」,是完全不同的两件事。不过我要说清「用数据库做搜索」不是原地不动。我第一版是两边带通配符的模糊匹配给字段加了索引但搜索完全没变快——我一开始以为索引没生效,查执行计划才明白这种写法根本用不了普通索引,数据库只能逐行扫描教训是:加索引不等于查询会走索引,查询的写法决定了索引能不能用。所以改成了全文索引检索。而这个问题在小数据量下完全看不出来(几百条数据扫全表也很快),必须造数据才能暴露——这也是我报数字时一定带上数据量的原因。
    Q:中文搜索有什么特别的地方? A:我换成全文索引之后,搜中文词几乎搜不到任何结果,当时我以为是全文索引不支持中文。实际原因是默认的分词方式是按空白和标点切分的——这对英文有效,因为英文单词之间有空格;而中文句子里没有空格,整句话被当成了一个词我搜「游标分页」,库里存的是一整句包含这个词的话,自然匹配不上。教训是:一个功能「对某类输入无效」时,先去搞清它的工作前提是什么,而不是直接判定它不支持。搞清之后解法就明确了:入库时对内容分词,存一份「可检索文本」;查询时对关键词做同样的分词处理这里有一个必须保证的点:两侧必须用同一套分词规则,否则永远匹配不上——我在这上面还调试了一阵,因为我改了查询侧的规则却忘了重扫历史数据。为什么分词放在入库时而不是查询时实时做?因为实时分词的结果无法建索引,那就又回到了逐行扫描——判据是「这个计算的结果需不需要被索引」,需要就必须提前算好存下来。代价是多一列存储、而且分词规则变更时要重扫历史数据。还有一个要权衡的是分词粒度太粗会漏(搜两个字的词匹配不到含它的长句),太细噪声很大——我实测过,粒度过细时搜一个常见单字的结果数接近全量,这些结果对用户完全没用我的取舍是偏向「宁可漏一点,也不要大量噪声」,理由是用户看到一堆无关结果会直接认为搜索不好用,而偶尔搜不到他会换个词再试。

模块六:图片上传与静态资源服务

  1. 图片上传与静态资源服务(类型校验不能只看扩展名 + 目录分片避免单目录文件过多 + 存储路径不可由用户输入拼接 + 缓存头与防盗链)★★★
    简历这样写 图片上传与静态资源(Spring Boot + 本地文件存储):上传校验从仅检查扩展名改为读取文件头识别真实类型 + 白名单限制(仅凭扩展名可被改名绕过,上传非图片内容);文件名由服务端重新生成而非沿用用户提交的名称,并禁止用用户输入参与拼接存储路径(原实现存在通过特殊路径字符写到预期目录之外的风险);存储从全部文件放同一目录改为按内容指纹分层建子目录(单目录文件数过多时列目录与创建文件都会变慢),同时按指纹去重(相同图片只存一份);静态资源响应补上长期缓存头并用内容指纹作为文件名(内容变则路径变,无需失效缓存),并加简单防盗链与单次请求大小限制;上传前在客户端压缩,服务端仍二次校验尺寸与大小,不信任客户端结果。
    展开完整拆解
    为什么要这么设计

    「把图片存到服务器上」这件事我原以为十几行代码就能写完,实际上它是这个项目里我改动次数最多的一块,而且改动几乎全是安全和运维层面的。四个问题。

    一是类型校验只看扩展名,可以被直接绕过。我的校验是「文件名以图片扩展名结尾就通过」。把任意一个文件改名成图片扩展名就能上传上去——而它被存在了一个对外可访问的目录里这一条是我在自己乱测的时候发现的:我把一个文本文件改名传上去,居然成功了。

    二是我用用户提交的文件名去拼存储路径。我想「保留原始文件名方便识别」。但文件名是用户可控的输入,里面如果带了向上跳目录的路径字符,拼出来的路径就可能指向我预期目录之外的位置我意识到这一点之后,立刻改成了服务端重新生成文件名——用户提交的名字只作为展示用的元数据,绝不参与路径拼接。

    三是所有文件都放在同一个目录,多了之后明显变慢。我造了几万个文件测试,发现列目录变得很慢、创建新文件也受影响而这个问题在几百个文件时完全不存在。

    四是静态资源没有缓存头,每次都重新下载。用户每次打开列表,同样的头像和封面都重新下载一遍——而这些图片内容根本不会变但我一开始不敢加长期缓存,因为担心「万一图片更新了怎么让缓存失效」。

    所以四个改动:类型校验改为读文件头识别真实类型加白名单文件名由服务端重新生成、禁止用户输入参与路径拼接按内容指纹分层建子目录并去重用内容指纹做文件名并加长期缓存头

    这个模块最想说的一句话是:用内容指纹作为文件名这一个改动,同时解决了去重、目录分片和缓存失效三个问题。我原来不敢加长期缓存,是因为担心图片更新后缓存失效;而用内容指纹做文件名之后这个担心就消失了——内容变了指纹就变、路径也变,浏览器自然会去请求新的路径,根本不需要「失效」这个动作而指纹的前几位又刚好可以用来分层建目录、相同内容还能天然去重。一个决定解掉三个问题,这是我在这个项目里最满意的一处设计。

    整体链路
    上传校验(不能信任客户端提交的任何东西) │ ├─ 大小限制:请求体大小 + 单文件大小都要限 │ 不限的后果:一个超大文件占满磁盘或打满带宽 │ ├─ 类型识别:读文件头判断真实类型,不看扩展名 │ 只看扩展名的后果 │ 把任意文件改名成图片扩展名就能传上去 │ 而它被存在了一个对外可访问的目录里 │ 我自己乱测时把一个文本文件改名传上去,居然成功了 │ ├─ 白名单而不是黑名单:只允许明确支持的几种图片类型 │ 黑名单的后果:总有你没想到的类型 │ ├─ 尺寸校验:解析出宽高,拦掉异常尺寸 │ 顺带把宽高存下来(列表布局要用,见客户端那一页) │ └─ 客户端已压缩过,但服务端仍要二次校验 不能信任客户端的处理结果 —— 请求可以被直接构造 文件名与路径(这是最容易出安全问题的地方) │ ├─ 文件名由服务端重新生成(用内容指纹) │ 用户提交的原始文件名只作为展示用的元数据存字段 │ ├─ 绝不用用户输入参与拼接存储路径 │ 文件名是用户可控的输入 │ 里面带向上跳目录的路径字符 → 可能写到预期目录之外 │ ├─ 存储根目录固定在配置里,拼接后要校验最终路径仍在根目录内 │ 双重保险:既不用用户输入拼、拼完还要校验 │ └─ 上传目录不要放在可执行的位置 即使传上去了非图片内容,也不能被当作程序执行 目录分片与去重 │ ├─ 用内容指纹的前几位分层建子目录 │ 全部文件放同一目录的后果 │ 造了几万个文件后,列目录明显变慢、创建新文件也受影响 │ 而几百个文件时这个问题完全不存在 │ ├─ 按指纹去重:相同内容只存一份 │ 同一张图被多个用户上传是很常见的(表情 · 截图) │ └─ 去重要注意引用关系:删除某条内容时不能直接删文件 因为可能还有别的内容引用同一个文件 我的做法是文件不随内容删除,靠定时清理无引用的文件 静态资源响应 │ ├─ 文件名用内容指纹 → 内容变则路径变 │ 这让长期缓存变得安全:不需要「失效」这个动作 │ 我原来不敢加长期缓存,就是担心图片更新后缓存失效 │ ├─ 加长期缓存头 │ 不加的后果:每次打开列表,同样的头像封面都重新下载 │ 而这些内容根本不会变 │ ├─ 简单防盗链:校验来源,不允许被其他站点直接引用 │ 不做的后果:别人的页面直接用我的图片,带宽由我承担 │ └─ 大图与缩略图分开(列表用缩略图,见客户端那一页) 其他 ├─ 上传失败要返回明确原因(类型不支持 / 超出大小 / 尺寸异常) ├─ 上传后未被任何内容引用的文件要定时清理 │ 用户选完图放弃发布的情况很常见 └─ 磁盘使用量要有监控,接近上限要能提前知道
    分步拆解
    1. 类型校验必须读文件头识别真实类型,不能只看扩展名。只看扩展名的后果是把任意文件改名就能传上去,而它被存在了一个对外可访问的目录里。
    2. 用白名单而不是黑名单。黑名单的问题是总有你没想到的类型。
    3. 请求体大小与单文件大小都要限制。不限的后果是一个超大文件占满磁盘或打满带宽。
    4. 要解析尺寸并拦掉异常值,顺带把宽高存下来。宽高在客户端做瀑布流布局时要用——这是一处顺手的收益。
    5. 客户端已压缩过,服务端仍要二次校验。不能信任客户端的处理结果——请求可以被直接构造,绕过前端。
    6. 文件名必须由服务端重新生成。用户提交的原始名只作为展示用的元数据存字段。
    7. 绝不用用户输入参与拼接存储路径。文件名是用户可控输入,里面带向上跳目录的路径字符就可能写到预期目录之外。
    8. 拼接后还要校验最终路径仍在根目录内。双重保险——既不用用户输入拼、拼完还要校验。
    9. 上传目录不要放在可执行的位置。即使传上去了非图片内容,也不能被当作程序执行。
    10. 用内容指纹的前几位分层建子目录。全部放同一目录的后果是几万个文件之后列目录明显变慢、创建新文件也受影响——而几百个文件时完全不存在这个问题。
    11. 按内容指纹去重,相同内容只存一份。同一张图被多个用户上传很常见(表情、截图)。
    12. 去重之后删除要小心:不能因为删了一条内容就删文件。因为可能还有别的内容引用同一个文件——我的做法是文件不随内容删除,靠定时清理无引用的文件。
    13. 用内容指纹做文件名,让长期缓存变得安全。内容变则路径变,不需要「失效缓存」这个动作。
    14. 加长期缓存头。不加的后果是每次打开列表同样的头像封面都重新下载,而这些内容根本不会变。
    15. 做简单的防盗链。不做的后果是别人的页面直接引用我的图片,带宽由我承担。
    16. 上传失败要返回明确原因。类型不支持、超出大小、尺寸异常——笼统的「上传失败」让用户不知道该怎么办。
    17. 上传后未被任何内容引用的文件要定时清理。用户选完图放弃发布的情况很常见。
    18. 磁盘使用量要有监控。接近上限要能提前知道——磁盘满了会让整个服务出各种奇怪的问题。
    关键决策与取舍

    用内容指纹作为文件名,这是这个模块最值得讲的一个决定,因为它一次解掉了三个问题。第一,缓存:我原来不敢加长期缓存头,担心「万一图片更新了怎么让缓存失效」;而内容指纹做文件名之后,内容变了指纹就变、路径也变,浏览器自然会去请求新路径——「失效」这个动作根本不需要存在第二,目录分片:指纹的前几位刚好可以用来分层建子目录,分布还很均匀。第三,去重:相同内容天然得到相同路径,只存一份。代价是文件名对人不可读(看不出是什么图)——所以我把用户提交的原始文件名单独存成一个展示字段判据是「这个标识需要承载什么」:需要承载「内容是否相同」,那就该由内容决定,而不是由上传时间或用户输入决定。

    类型校验读文件头而不是看扩展名,这不是取舍而是必须。看扩展名的实现只有一行,但它提供的保护基本为零——改个名字就绕过了而读文件头的代价也很小(读前几个字节)我想强调的是这个坑的发现方式:我是自己乱测时把一个文本文件改名传上去、居然成功了才意识到的——正常的功能测试永远不会做这件事,因为正常用户不会改扩展名。

    文件不随内容删除,靠定时清理无引用文件。随内容删除更直观、也不会留垃圾。但一旦做了按指纹去重,同一个文件可能被多条内容引用——直接删会让别人的内容图片挂掉正确做法要么维护引用计数、要么定时扫描清理我选了定时清理,因为引用计数在并发下要保证准确本身就是一个问题(而且我踩过计数漂移的坑)代价是被删内容的图片会在磁盘上多留一段时间。

    做防盗链但只做简单的来源校验。严格的防盗链需要签名链接与有效期。但我的图片都是公开内容(论坛帖子里的图),本来就要给所有访问者看——严格签名的收益不大,而且会让缓存失效(带签名的链接每次都不同)简单来源校验能挡掉「别人的页面直接引用」这个主要场景判据是「这份资源本身是不是私密的」:不是私密的,那防的只是带宽被占,简单手段就够。

    踩过的坑一:类型校验只看扩展名,把文本文件改名传上去成功了。而它被存在了一个对外可访问的目录。教训是:任何来自客户端的「声明」都不能当作事实——扩展名是用户声明的类型,不是文件的真实类型这一条推广开来就是:客户端传过来的所有东西(类型、大小、尺寸、甚至它说它压缩过了)都要在服务端重新确认一遍。

    踩过的坑二:我曾经用用户提交的文件名去拼存储路径。初衷是「保留原始文件名方便识别」。后来意识到文件名里如果带向上跳目录的路径字符,拼出来的路径就可能指向我预期目录之外教训是:用户输入绝不能参与文件路径、命令、查询语句的拼接——而文件名特别容易被忽略,因为它看起来只是一个「名字」,不像是代码的一部分。

    踩过的坑三:所有文件放同一目录,几万个之后明显变慢。我是造数据测试时发现的,而几百个文件时这个问题完全不存在教训是:文件系统也有它的量级边界,不只是数据库——我以前从来没想过「一个目录里放多少文件」也是个需要设计的问题。

    没做的部分:没做对象存储与内容分发。它们能解决带宽、可用性和异地访问速度,但都是付费的外部服务,而且引入之后本地就没法完整调试了取舍依据是「这个项目的瓶颈还没到需要它们的程度」;不过我把存储访问抽成了一层接口,说清了如果要换成对象存储,需要改的只有这一层的实现——能说出迁移路径,比直接接上更能说明我理解了这个分层的意义。

    数字是怎么测的

    类型绕过是断言型用例,也是这个模块最重要的一条:把一个非图片文件改名成图片扩展名后上传,断言被拒绝;再把一个真实图片改成错误的扩展名上传,断言按真实类型正确处理两条一起测才说明是「按内容判断」而不是「按名字判断」。

    路径穿越:构造包含向上跳目录字符的文件名上传,断言文件仍被写在存储根目录内、且最终路径校验生效这条要看实际落盘位置,不能只看接口返回成功。

    目录分片的效果要报文件数与操作耗时:「单目录放 N 个文件时列目录耗时 X;分层后同样总数下单目录文件数降到 M、耗时 Y」。要说明是造了多少文件、在什么文件系统上测的——这个结论和文件系统有关。

    去重:用同一张图片重复上传多次,断言磁盘上只有一份文件、且返回的路径相同。

    引用与删除:让两条内容引用同一张图,删掉其中一条,断言图片仍然可访问(另一条内容的图没有挂掉)这条是「按指纹去重」带来的必然风险,一定要测。

    缓存效果报请求数:「重复打开列表页时,图片请求从每次都重新下载变为命中本地缓存」。可以在开发者工具的网络面板里直接看到状态变化,很容易核对。

    内容变更后的缓存:替换一张图的内容,断言指纹变化导致路径变化、客户端请求的是新路径——这条证明了「不需要失效动作」这个设计成立。

    服务端二次校验:绕过前端直接构造请求上传超大或异常尺寸的图片,断言被服务端拦下这条验证的是「不信任客户端」。

    不要报什么:不要说「上传是安全的」——安全取决于最弱的一环。该报的是「改名的非图片文件被拒绝、改错名的真实图片被正确识别」「含跳目录字符的文件名仍落在根目录内」「单目录文件数与列目录耗时的对比」「同图重复上传只存一份且删除一条内容不影响另一条」「内容变更后路径随之变化」这几件可核对的事。

    面试追问
    Q:图片上传你做了哪些校验? A:我第一版只校验了扩展名,而这个校验的保护基本为零——我是自己乱测时把一个文本文件改名成图片扩展名传上去、居然成功了才意识到的。而它被存在了一个对外可访问的目录里。正常的功能测试永远不会做这件事,因为正常用户不会去改扩展名。所以现在的校验是几层:读文件头识别真实类型并用白名单(白名单而不是黑名单,因为黑名单总有你没想到的类型)、请求体与单文件的大小限制解析尺寸拦掉异常值(顺带把宽高存下来,客户端做瀑布流布局要用)。还有一条我认为最重要:客户端已经压缩过了,服务端仍然要二次校验——因为请求可以被直接构造、绕过前端,客户端说它压缩过了不能当作事实这一条推广开来就是:客户端传过来的所有东西(类型、大小、尺寸、甚至它声明的处理结果)都要在服务端重新确认一遍。另外还有一个和校验相邻、但更危险的问题:文件名与路径。我最初想「保留原始文件名方便识别」,就用用户提交的文件名去拼存储路径——后来意识到文件名是用户可控的输入,里面如果带向上跳目录的路径字符,拼出来的路径就可能指向我预期目录之外。修法是文件名由服务端重新生成、用户提交的原始名只作为展示用的元数据字段而且拼接后还要校验最终路径仍在存储根目录内(双重保险)上传目录也不放在可执行的位置教训是:用户输入绝不能参与文件路径、命令、查询语句的拼接——而文件名特别容易被忽略,因为它看起来只是一个「名字」,不像是代码的一部分。
    Q:静态图片你怎么处理缓存?图片更新了缓存怎么失效? A:这个问题我一开始答不上来,所以我不敢加长期缓存头——结果是用户每次打开列表,同样的头像和封面都重新下载一遍,而这些内容根本不会变。后来解法出乎意料地简单:用内容指纹作为文件名。内容变了指纹就变、路径也跟着变,浏览器自然会去请求新的路径——「失效缓存」这个动作根本不需要存在。所以我可以放心地加长期缓存头。而这一个决定还顺带解掉了另外两个问题,这是我在这个项目里最满意的一处设计。一个是目录分片:我原来把所有文件放在同一个目录,造了几万个文件之后发现列目录明显变慢、创建新文件也受影响(而几百个文件时完全不存在这个问题——我以前从来没想过「一个目录里放多少文件」也是个需要设计的问题);而指纹的前几位刚好可以用来分层建子目录,分布还很均匀另一个是去重:相同内容天然得到相同路径,只存一份——同一张图被多个用户上传是很常见的(表情、截图)代价是文件名对人不可读,所以我把用户提交的原始文件名单独存成一个展示字段。但去重带来了一个必须处理的风险同一个文件可能被多条内容引用,所以删除某条内容时不能直接删文件——否则别人内容里的图会挂掉我的处理是文件不随内容删除,靠定时清理无引用的文件没有用引用计数是因为计数在并发下要保证准确本身就是个问题,而我在点赞计数那块已经踩过计数漂移的坑了。

没有匹配的内容,换个关键词试试。

项目拆解 · 技术社区论坛(个人项目 · 无实习档)· 共 6 个模块 · 数字均为示例,需替换成自己项目的真实数据