帖子列表是这个项目里最先写、也是我返工最多的接口。我一开始用的是最标准的写法:前端传页码和每页条数,后端按发布时间倒序、用偏移量取一页。这个写法我在教程里见过无数次,看起来毫无问题。四个问题里有三个是我手点根本测不出来、必须靠造数据或起并发才会出现的。
一是下拉加载会重复出现同一条帖子,也会漏帖。我是写了个脚本才复现的:一个线程按页翻列表,另一个线程持续插入新帖。原因是偏移量分页的前提是「数据集在翻页期间不变」,而社区列表恰恰一直在变——翻第一页的时候有新帖插到最前面,整个列表往后移一位,于是第一页的最后一条在第二页又出现了一次;反过来如果有帖子被删,第二页就会漏掉一条。我自己在浏览器里点是永远碰不到的,因为我点的时候没有并发的写入——这也是我后来专门写这个脚本的原因。
二是我改成游标分页之后,出现了新问题:同一秒发布的多条帖子会丢。我一开始的游标只用了发布时间。如果有两条帖子的时间戳完全相同,游标是「小于这个时间」,那么另一条就被跳过了。这个在手点的时候几乎不可能碰到,但我造测试数据的脚本是循环批量插入的,同一秒里生成了大量帖子,时间戳大面积重复,一翻页就丢了一堆。
三是热度榜没法用游标。我以为游标是万能的,结果发现热度是会变的——我按热度排序、用「热度值 + 帖子标识」做游标,但我翻到第二页的时候,第一页某条帖子的点赞涨了、排序位置变了,游标的语义就崩了。这时候我才想起来:我平时刷的那些社区,热度榜翻页确实偶尔会看到重复的帖子——原来不是它们有 bug,是这个问题在按可变字段排序时无法根治。
四是作者看不到自己审核中的帖子。我给列表加了「只查已发布状态」的过滤。结果作者发完帖,列表里找不到自己的帖子,以为发布失败了,又发了一遍——出现了重复内容。
所以四个改动:页码分页改游标分页、游标补上第二排序键、热度流改用定时快照并接受轻微重复、状态过滤增加「作者本人可见自己的非公开内容」。
这个模块最想说的一句话是:分页这件事的难点不在「取第几页」,在「翻页期间数据集会变」。偏移量分页隐含了一个假设——数据集是静止的。这个假设在管理后台(数据基本不动)成立,在社区列表(一直有人发帖)完全不成立。而我照着教程抄的时候,教程没有告诉我这个假设的存在。
时间流用游标、热度流用快照,两套分页策略而不是统一一套。统一成一套(都用快照)会让时间流也承担快照的延迟(新帖要等下次重算才出现),而「刚发的帖子立刻能在最新列表里看到」是社区的基本预期。统一成另一套(都用游标)则热度流会错乱。判据是「排序键在翻页期间会不会变」:不变的可以用游标,会变的只能快照。这一条我认为是这个模块最有价值的判断,而且它是通用的——任何列表都可以先问这个问题。
接受热度流的轻微重复,而不是想办法根治。根治的思路是「为每个用户生成一份独立快照并长期保留」,但那意味着要为每个活跃用户存一份榜单,存储和复杂度都上去了,而收益只是消除偶尔的重复。我的判断是:先看主流产品怎么做的——我刷的那几个社区热度榜翻页也会偶尔重复,说明这个代价是行业公认可接受的。能说出「我知道这里有残留问题、我知道根治的代价、我选择接受」,比假装没有问题要好。
可见性条件收敛成一个构造方法,而不是每个接口自己拼。每个接口自己拼更直接、也更灵活。但状态种类会增加(我后来加了「已下架」),每加一种就要检查所有列表接口——而漏掉一处的表现是「某个入口能看到不该看到的内容」,很难被发现。收敛之后新增状态只改一处。这一条和「权限校验要收敛」是同一个道理。
踩过的坑一:偏移量分页导致重复和漏帖,而我在浏览器里手点永远碰不到。因为手点时没有并发的写入。我是写了个脚本才复现的——一个线程按页翻列表、另一个线程持续插帖,重复和漏帖立刻就出来了。教训有两层:一是偏移量分页隐含了「数据集在翻页期间不变」这个假设,而教程里从来不说这件事;二是「我手点没问题」不等于「没问题」,涉及并发的逻辑必须专门构造场景去打。我后来的习惯是:看到一个「标准写法」,先问它隐含了什么前提、我的场景满不满足,然后想办法把这个前提破掉试试。
踩过的坑二:游标只用时间,造数据时大量时间戳重复,导致丢帖。手点的时候几乎不可能碰到,但我造测试数据的脚本是循环批量插入的,同一秒生成了大量帖子,时间戳大面积重复,一翻页就丢了一堆。教训是:游标必须唯一——所以要在排序键后面追加一个唯一键。这个坑还有个意外收获:我意识到「批量造出来的数据分布」和「手动点出来的数据分布」完全不同,前者天然带着大量重复值、极端值、空值,反而更容易打出边界问题——后来我就专门用它来测。
踩过的坑三:作者看不到自己审核中的帖子,以为失败了又发一遍。我加了「只查已发布」的过滤,忘了作者本人这个视角。教训是:加过滤条件时要问「有没有哪一类用户应该看到被过滤掉的东西」——作者对自己的内容、管理员对全部内容,都是这类例外。
没做的部分:没做个性化推荐流(按用户兴趣排序)。它需要真实的用户行为数据来做特征和排序,而我这是个人项目、没有真实行为数据——用造出来的假行为数据训一个「假的推荐」,不如老实做好「最新」和「热度」两个流。取舍依据是「支撑这个功能的前提我不具备」,我认为这个理由比「技术上做不到」更能说明我知道自己在做什么。面试被问到「还能怎么优化」时,这也是现成的答案。
重复与漏帖用断言型用例,这是最能说明问题的一条:先取第一页,然后插入若干条新帖,再取第二页,断言两页之间没有重复的帖子标识、也没有跳过的帖子。报法是「模拟翻页期间插入 N 条新帖,游标分页无重复无遗漏;同场景下偏移量分页出现 M 条重复」——两个方案对比着报,比只说「改成了游标分页」有力得多。
同一时间戳的边界要专门测:造若干条时间戳完全相同的帖子,断言游标分页能完整取到全部,一条不丢。这条是踩坑的直接回归,而且用批量导入的数据最容易造。
深分页性能要报清数据量与翻页深度:「帖子表 N 条数据时,偏移量分页翻到第 X 页耗时 A;游标分页翻到同样深度耗时 B,且耗时与深度无关」。「与深度无关」这一点才是游标分页的核心收益,要明确写出来。
索引的作用要单独验证:报「加(状态, 排序时间, 帖子标识)联合索引前后的执行计划变化与耗时对比」。因为游标分页快不快是靠索引撑的,不提索引就不完整。
热度快照:断言快照有效期内多次翻页顺序稳定;并诚实说明跨越两次重算时仍可能重复,报出重算间隔(这决定了重复的概率)。
可见性:断言未登录用户与其他用户看不到「审核中」内容、作者本人能看到自己的、已删除内容的详情返回「已删除」而不是 404。
数据来源必须说清,这比数据量本身重要:我报的是「用脚本按真实分布造了数万条帖子、十几万条评论,其中故意包含大量重复时间戳」。不要声称用户量——「有几百个注册用户」会被追问从哪来的、怎么收集反馈的,全是要现编的;而「造了多少数据、字段怎么分布、为什么故意造重复时间戳」是我当场就能讲完的,追问几层都不怕。这个量级足以把游标分页的边界问题打出来,说清来源就够了。
不要报什么:不要报「接口性能提升 N 倍」——数据量小,倍数没有意义。该报的是「翻页期间插帖后游标无重复、偏移量有 M 条重复」「同时间戳数据不丢帖」「耗时与翻页深度无关」「作者可见自己的审核中内容」这几件可核对的事。
评论区是我最初最有信心的部分——我做了完整的无限层级嵌套,回复可以回复回复,理论上无限深。我当时觉得这比只能回复两层的产品「更完备」。用起来之后我发现完全不是这样,四个问题。
一是无限嵌套在手机上根本没法看。每一层缩进一点,到第四层的时候文字宽度只剩一半,到第五层就只有两三个字一行。我试着减小缩进,结果层级关系又看不出来了。这个问题不是样式能解决的,是「无限深度」和「有限屏幕宽度」本身矛盾。
二是递归查询的次数不可控。我的实现是查出一级评论,然后对每条递归查它的子评论。一个评论区有多少次查询取决于评论的嵌套形态,我完全没法预估——某个讨论比较热的帖子,打开一次详情页发了上百次查询。
三是我去看了真实数据,发现绝大多数回复只有一层。用户的实际行为是「回复某条评论」,而不是「在回复里再开一个分支讨论」。我为了支持一个几乎没人用的能力,付出了展示和查询两方面的代价。这时候我才明白主流社区只做两层不是能力不足,是它们已经知道了我刚发现的这件事。
四是评论数显示不对。帖子上显示「12 条评论」,点进去数只有 9 条。原因是我统计的时候算了已删除的评论,而展示的时候过滤掉了——两个口径不一致。
所以四个改动:无限嵌套改为两层结构、回复对象用提及目标用户表达、逐条查询回复改为一次批量查出再内存分组、统一评论计数口径,另外补了删除有回复的一级评论时保留占位。
这个模块最想说的一句话是:我一开始做无限嵌套,是因为我在追求「技术上更完备」;而两层结构才是对的,因为它同时解决了展示、查询和真实使用三个约束。复刻主流产品的价值就在这里——它们的每一个「看起来简陋」的设计背后,往往都有一个我还没踩到的坑。我是踩了之后才理解的,但正因为踩过,我能说清为什么。
count 会随评论量增长变慢,维护在帖子上并增量更新。放弃无限嵌套,改两层。无限嵌套在数据结构上更「完备」,但它同时被三个约束否掉了:展示(手机宽度有限)、性能(递归查询次数不可控)、真实需求(绝大多数回复只有一层)。代价是「回复的回复」失去了严格的层级关系——用「提及目标用户」来补,信息不丢但结构变平。判据是「这个能力的实际使用比例,值不值得为它付出展示和查询的代价」:不值得。我特意去看了自己数据库里的真实分布才下这个结论,而不是凭感觉。
不用路径枚举或闭包表这类支持任意层级的方案。它们能高效查询任意深度的子树,但那是在「确实需要任意层级」的前提下才有意义——我既然已经决定只做两层,那父级标识加一个索引就够了,引入路径字段反而增加了维护成本(移动节点、路径重写)。这一条我想强调的是:技术方案的复杂度应该匹配需求的复杂度,而不是匹配「未来可能的需求」。
删除有回复的评论时保留占位,而不是级联删除。级联删除更干净,但那会让别人写的回复也一起消失——那些回复不属于被删评论的作者。判据是「这条数据的所有权属于谁」:回复属于回复者,删除者无权处置。这一点我是想了一会儿才想清楚的,最初我以为级联删除是理所当然的。
计数维护在帖子上而不是实时 count。实时 count 永远准确、不需要维护。但它的耗时随评论量增长,而列表页要展示每个帖子的评论数——一页 20 个帖子就是 20 次 count。维护计数的代价是一致性变成需要显式保证的事,所以必须配定时校准。判据是「读的频率远高于写」:是,那就把成本转移到写侧。
踩过的坑一:无限嵌套在手机上第四层就没法看了。我试过减小缩进,结果层级关系又看不出来。这件事让我明白有些问题不是样式能解决的,是设计本身的矛盾——「无限深度」和「有限屏幕宽度」不可能同时满足。而我当初做无限嵌套的理由是「技术上更完备」,完全没有从使用的角度想过。
踩过的坑二:递归查询让热门帖的详情页发了上百次查询。我是在日志里看到的。教训是:循环或递归里发查询,次数就不再由你控制,而是由数据形态控制——而数据形态是用户决定的,你没法预估上限。我后来的习惯是写完一个接口就数一遍「这里面发了几次查询、会不会随数据量增长」。
踩过的坑三:评论数显示 12 条、点进去只有 9 条。统计时算了已删除的,展示时过滤了。教训是:同一个概念在系统里必须只有一个定义——而「评论数」这种看起来不言自明的东西,恰恰最容易在两处被算成两个意思。我后来把这个定义写进了项目文档。
没做的部分:没做评论的「热门排序」(按点赞把优质评论顶上来)。它需要评论级的点赞与热度计算,而我的评论量还不足以让排序产生明显差异——几十条评论按时间看完也就那样。取舍依据是数据量还没到需要排序的程度,先把结构和性能做对。
查询次数是这个模块最有说服力的数字:报「详情页加载评论区时,接口内的数据库查询次数从『与评论数成正比(热门帖实测上百次)』降为固定 4 次(一级评论 / 回复 / 用户 / 点赞状态各一次)」。这个数字比耗时更本质,因为它说明改造后耗时不再随评论量增长。
接口耗时要报清评论量:「某条有 N 条评论的帖子,详情页评论区耗时从 X 降到 Y」。要说明是哪条帖子、多少条评论——不说数据量的耗时对比没有意义。
层级深度的真实分布值得报出来,它是我做决策的依据:「统计自己库里的回复数据,其中约 X% 只有一层」。这个数字说明「放弃无限嵌套」是基于数据而不是偷懒。
孤儿回复用断言型用例:删除一条有回复的一级评论,断言其下回复仍可正常展示、占位显示「该评论已删除」、且占位不计入评论数。
计数口径一致性:造一批含已删除评论的数据,断言帖子上显示的评论数与详情页实际可见条数完全相等。这条是踩坑的直接回归。
计数校准:人为把帖子上的评论数改错,断言定时校准任务能把它修正回来并产生一条差异记录。
展开更多回复:断言展开后的回复与预取的那几条不重复、顺序为时间正序。
不要报什么:不要报「评论区性能提升 N 倍」——不同帖子的评论量差异很大,倍数取决于挑哪条帖子。该报的是「查询次数从与评论数成正比降为固定 4 次」「真实数据中单层回复占比」「删除后回复不成孤儿且占位不计数」「显示评论数与实际可见条数相等」这几件可核对的事。
点赞看起来是整个项目里最简单的功能:插一条记录、把帖子的点赞数加一。我就是这么写的。四个问题,其中两个是我自己完全没想到的。
一是热门帖上出现锁等待。点赞的写法是 update post set like_count = like_count + 1 where id = ?。一条帖子火了之后,很多人在同一时间点赞,所有请求都在更新同一行——数据库要串行处理,接口耗时明显上升。这个问题在我这个规模下还不至于出事,但它是「同一行热点写」的典型形态,我意识到这条路走不远。
二是列表页为了显示「我赞过没有」,对每条帖子各查一次。一页 20 个帖子就是 20 次查询。而这个逻辑写起来特别自然——渲染每个帖子的时候顺手查一下,我当时完全没觉得有问题。
三是快速连点产生重复记录。用户手抖点了两下,两条请求都通过了「查一下有没有赞过」这一步,然后都插入成功,点赞数加了两次。而这个错误没有任何报错,就是数字不对。
四是取消点赞时我漏减了计数。这个是最典型的:我把「点赞」这条路径的计数加上了,但「取消点赞」那条路径只删了明细、忘了减计数。结果是取消了赞、明细没了、但数字还在。而这个漂移只会越来越大,没有任何机制会发现它——直到我做了对账才看到。
所以四个改动:计数从直接更新数据库改为 Redis 原子自增加定时回写、明细继续入库并加(用户, 帖子)唯一索引保证幂等、列表页已赞状态改批量查询、加定时对账用明细重算并修正。
这个模块最想说的一句话是:想清楚「点赞数」和「我是否已赞」是两种完全不同的数据,设计就清楚了。点赞数允许短暂不精确——差一两个没有人会发现,也不会造成任何后果;而「我是否已赞」必须精确——如果我明明赞过却显示没赞,我会再点一次,然后变成取消,体验直接崩。前者可以走缓存、可以延迟回写;后者必须以数据库明细为准。我最初把它们当成同一件事来做,所以两头都没做好。
明细入库、计数入缓存,而不是两者都放一处。两者都放数据库:写压力集中在同一行,热点写扛不住。两者都放缓存:Redis 一旦丢数据,谁赞过谁就永远查不回来了——那是不可接受的,因为它是用户的行为记录。分层之后各自承担合适的角色:明细是事实来源、可重建;计数是派生值、可以短暂不准。判据是「这份数据丢了能不能重建」:不能重建的必须落库。
「计数允许短暂不精确」但「我是否已赞必须精确」,这是这个模块最核心的判断。点赞数差一两个没人会发现、也没有任何后果;而「我是否已赞」显示错了,用户会再点一次、结果变成取消,体验直接崩。所以前者可以走缓存加延迟回写、后者必须能降级到明细表。我最初把它们当成同一件事做,所以两头都没做好——分开之后设计一下就清楚了。
不引入消息队列做削峰。用队列异步处理点赞是很常见的思路。但我只有一台服务器,多一个中间件就多一个我得维护和排查的东西;而 Redis 原子自增本身已经很快,定时回写又把数据库的写压力削平了,队列在这里带来的额外收益很小。判据是「这个方案带来的复杂度,我一个人维护得住吗」——而且队列本身也需要对账兜底(消息可能丢),既然对账绕不开,就直接把对账做成主要保障。
对账的差异必须记录而不是静默修正。静默修正实现更简单、界面上也永远正确。但它会掩盖真正的问题——你在不停擦地,却不知道水龙头在哪。我漏减计数那条路径,就是靠差异日志找出来的:差异一直在同一个方向增长,我才想到是某条减少的路径没走。如果当初做的是静默修正,这个 bug 会一直存在。
踩过的坑一:取消点赞时漏减计数。我把「点赞」路径的计数加了,「取消点赞」路径只删明细、忘了减。而这个漂移只会越来越大,没有任何机制会发现它——因为它不报错,只是数字偏大。教训是:一旦把一个「每次都从明细算」的值改成「增量维护」,就必须把所有影响它的路径列成清单逐一处理,而且必须有对账,因为你一定会漏掉某一条。我当时是凭印象改的,所以漏了。
踩过的坑二:快速连点产生重复点赞。我的实现是「先查有没有赞过,没有就插入」。两个请求几乎同时进来,都查到「没赞过」,都插入成功。教训是:并发下「先查再写」不构成约束,唯一性必须由数据库保证。这一条和我在其他地方遇到的重复提交是同一个形态。
踩过的坑三:列表页逐条查已赞状态。一页 20 个帖子 20 次查询。而这个写法特别自然——渲染每个帖子时顺手查一下,我当时完全没觉得有问题,是数了一遍接口里的查询次数才发现的。教训是:「循环里发查询」在代码里往往长得很无辜,要靠主动去数,不会自己冒出来。
没做的部分:没做点赞的「防刷」(小号互赞、脚本批量点赞)。它需要行为特征识别和风控规则,而识别规则的阈值必须靠真实的行为分布来调——这是我这个项目不具备的前提,凭空拍一个阈值出来只会误伤。不过我做了一个不依赖行为数据的轻限制:同一用户对同一帖子的点赞与取消次数有上限,防的是最简单的反复刷。取舍依据是「能做的先做掉,需要真实数据才能做对的先不做」。
对账差异数是这个模块最有说服力的指标:报「加上对账任务后第一次运行,就发现 N 条帖子的计数与明细不一致,据此定位到『取消点赞未减计数』这条路径;修复后差异数持续为零」。这个数字同时证明了两件事:对账机制有用、以及我确实通过它找到了一个真 bug。差异是在压测数据上跑出来的——压测时点赞与取消是按比例混合发的,所以漂移会被迅速放大,比手点更容易暴露。
幂等性用并发压测断言:用多个线程对同一帖子并发提交点赞,断言明细表只有一条记录、计数只加一。要真并发,顺序调两次测不出来。
取消的幂等同样要测:连续两次取消,断言计数只减一次、不会出现负数。
查询次数:报「列表页显示已赞状态的查询次数从『与帖子数成正比(一页 20 条即 20 次)』降为固定 1 次」。这个数字很具体也容易核对。
热点写的改善要报清测法:「用 N 个并发线程对同一帖子点赞,改造前接口耗时 X(存在同一行的锁等待)、改造后 Y」。必须说明并发数、压测工具和是在什么机器上跑的,并且明确这是压测结论、不是线上观测——个人项目没有线上流量,把这一点讲在前面,后面的数字才立得住。
缓存丢失重建:清空 Redis 里的计数与集合,断言能从明细表重算回填、重建期间显示的是帖子表上的兜底值而不是 0、「我是否已赞」降级到明细表后仍然正确。最后一条最重要,因为它是「必须精确」的那一类。
回写幂等:让回写任务重复执行,断言计数不会被叠加(因为用的是覆盖而不是增量)。
不要报什么:不要报「点赞接口 QPS 提升 N 倍」——我没有那个量级的真实流量,压测数字也受机器影响。该报的是「对账第一次跑发现 N 条差异并定位到具体路径」「并发点赞明细只一条、计数只加一」「已赞状态查询次数从 20 次降为 1 次」「清空缓存后可从明细完整重建」这几件可核对的事。
update post set like_count = like_count + 1,一条帖子火了之后很多人同时点赞,所有请求都在更新同一行,数据库要串行处理,接口耗时明显上升——这是「同一行热点写」的典型形态。我这个规模下还不至于出事,但我意识到这条路走不远。还有一个关键判断我想说:「点赞数」和「我是否已赞」是两种完全不同的数据。点赞数允许短暂不精确(差一两个没人发现、也没有后果),可以走缓存加延迟回写;而「我是否已赞」必须精确——如果我明明赞过却显示没赞,我会再点一次,然后变成取消,体验直接崩,所以它在缓存不可用时必须降级到明细表查询。我最初把这两者当成同一件事做,所以两头都没做好。
注册登录是我写这个论坛时最早完成的功能,也是我最初以为最没有技术含量、后来发现问题最密集的一块。我第一版的实现是:密码用一个常见的摘要算法处理一下存进数据库,登录时比对,成功就发一个令牌。四个问题。
一是密码的存储方式不安全,而我当时以为「哈希过了就安全」。我用的是普通的摘要算法、而且没有加盐。问题有两层:第一,相同的密码会得到完全相同的结果——只要有两个用户用了同一个弱口令,从数据库里一眼就能看出来;第二,这类摘要算法本来就是为「快」设计的,批量尝试常见弱口令的代价极低。也就是说数据库一旦泄露,弱口令用户的密码基本等于明文。
二是登录失败限制只按账号计数,挡不住真正的攻击方式。我做的是「同一个账号连续失败几次就锁一段时间」。但真实的攻击不是拿一个账号试一万个密码,而是拿几个常见弱口令去试一万个账号——每个账号只失败一两次,我的限制完全不会触发。
三是会话令牌一旦发出去就没法失效。我签发的令牌里带了过期时间,服务端不保存任何状态。结果是用户改了密码之后,旧令牌在过期前依然可用——而「改密码」这个动作用户的预期恰恰是「把别人踢下去」。
四是接口的响应会泄露账号是否存在。登录时账号不存在返回「用户不存在」、密码错了返回「密码错误」;注册时已注册的邮箱直接提示「该邮箱已注册」。这两处合起来,可以被用来批量探测哪些邮箱在这个站上注册过。
所以四个改动:密码改用加盐的慢哈希、登录失败按账号与按来源双维度限制、会话令牌改为服务端可失效并支持改密后全部失效、登录与注册的响应不区分账号是否存在。
这个模块最想说的一句话是:「我哈希过了」和「我的密码存储是安全的」之间差着加盐和慢哈希两件事,而我一开始完全不知道有这个差别。这块的坑有一个共同特点:功能测试全都能过(能注册、能登录、能改密码),问题全在「数据库泄露之后会怎样」「被批量尝试时会怎样」这些不在正常路径上的假设里。而这些恰好是面试官最爱追的地方。
用加盐慢哈希而不是普通摘要,代价是登录接口会变慢一点。慢哈希本来就是「故意慢」的——而这个慢对单次登录是可以接受的(用户感觉不出来),对批量尝试却是决定性的。强度参数的取值要权衡:我是在自己的机器上试出一个「单次验证耗时在可接受范围内」的值,并且写清了这个值需要随机器性能重新评估。判据是「攻击者批量尝试的成本」和「正常用户单次登录的成本」不对称——慢哈希放大的正是这个不对称。
会话令牌选「服务端保存记录」而不是纯自包含。纯自包含的好处很明显:不需要存储、验证不用查库、天然可横向扩展。但它有一个我无法接受的缺陷:签发出去就撤不回来——而「改密码之后把其他设备踢下线」是一个基本预期。代价是每次验证都要查一次会话记录(我放在缓存里,成本可以接受)。判据是「这个凭据需不需要被提前撤销」:需要,那就不能用无状态的形式。如果是那种「短有效期、不需要撤销」的场景,我的结论会反过来。
登录失败限制做两个维度,代价是实现和调参都复杂一些。只做一个维度的问题我在前面说了——两种攻击方式各自绕过其中一个。而按来源限制有一个真实的副作用:共用出口的正常用户会互相牵连(比如同一个校园网出口)。我的缓解办法是把按来源的阈值设得比按账号宽松得多,并且限制时长更短——它的目标不是拦死,而是把批量尝试的速度压下来。
注册时是否提示「该邮箱已注册」是一个两难,我选了提示。不提示(统一返回成功、只在邮件里区分)能彻底避免探测,但它会让正常用户非常困惑——他明明注册过,却收不到任何提示,只能反复尝试。而登录和找回密码那两处我选了统一响应,因为那里不提示对正常用户几乎没有影响。判据是「不提示对正常用户的伤害有多大」:注册处伤害大、登录处伤害小。我把这个不一致明确记下来了,因为它是一个有意识的妥协,不是遗漏。
踩过的坑一:我以为「哈希过了就安全」。直到我自己在数据库里看到两个测试账号的密码哈希完全一样(因为我用了同一个弱口令)——那一刻我意识到这个存储方式泄露的信息比我想的多得多。教训是:「我哈希过了」和「我的密码存储是安全的」之间差着加盐和慢哈希两件事,而这两件事都不在功能测试的覆盖范围内。
踩过的坑二:登录失败限制只按账号,我以为已经防住了暴力破解。后来想清楚攻击方式才发现真实的攻击是拿几个常见弱口令去试大量账号,每个账号只失败一两次——我的限制一次都不会触发。教训是:做防护之前要先想清楚「攻击者实际会怎么做」,而不是按自己的直觉设限制。我的直觉版本防的是一个不太会发生的攻击。
踩过的坑三:改密码之后旧会话还能用。是我自己在两个浏览器里测试时发现的——在 A 里改了密码,B 里居然还能正常发帖。教训是:一个操作的「用户预期」经常超出它的字面含义——「改密码」的字面含义是换一个凭据,而用户的预期是「把别人踢下去」。这两者之间的差距要靠实现来补。
没做的部分:没做两步验证与第三方登录。两步验证需要另一条可靠的验证通道(短信或验证器应用)和一整套绑定与恢复流程,恢复流程做不好会把用户永久锁在外面;第三方登录则依赖外部服务的接入与账号绑定关系。取舍依据是「这两块的正确性都依赖我不完全掌握的外部条件」——而我更愿意把基础的密码与会话这一层做扎实。
密码存储的验证不适合报数字,该报「可核对的事实」:每个用户有独立的盐、用的是慢哈希、相同密码的两个账号在数据库里的哈希不同(这条可以直接查库确认)、日志与异常堆栈中不出现密码。这几条比任何数字都有说服力,而且面试官可以当场追问实现细节。
慢哈希的强度参数要报「单次验证耗时」以及是在什么机器上测的:「在我的机器上单次验证耗时约 X 毫秒」,并说明这个值需要随机器性能重新评估。这个数字直接说明我理解「慢」是这个算法的特性而不是缺陷。
登录失败限制用断言型用例,两个维度各一条:同一账号连续失败达阈值,断言被锁定且能自动解除;用同一来源对多个不同账号各失败一两次,断言按来源的限制被触发——后一条是踩坑的直接回归,它正是「只按账号」挡不住的那种攻击。
会话失效:在一个客户端改密码,断言另一个客户端的旧令牌立即失效、需要重新登录。这条要用两个真实的会话测,不能只测接口。
找回密码令牌:断言令牌用过一次后立即失效、超过有效期后失效、重置成功后该账号的全部旧会话失效。
账号探测:用不存在的账号与存在但密码错误的账号分别登录,断言两者的响应内容与状态码完全一致;并对比两者的响应耗时是否有明显差异(如果差异明显,说明仍然可以通过时间推断)。后半句容易被忽略但很值得测。
敏感操作:断言改密码、改邮箱时必须提供当前密码,仅有有效会话不足以完成。
不要报什么:不要说「保证账号安全」——安全性取决于最弱的那一环,而我不可能穷举。该报的是「相同密码的哈希不同」「单次验证耗时及测试机器」「按来源的失败限制能拦住多账号少次数的尝试」「改密后旧会话立即失效」「账号不存在与密码错误的响应完全一致」这几件可核对的事。
搜索是我在这个项目里最想「直接上搜索引擎」但最后没有上的一块。我的第一版实现是最直觉的:用字段模糊匹配,两边都加通配符。功能上完全能用——搜什么都能搜到。四个问题都是造了数据之后才出现的。
一是随数据量增长明显变慢,而且加索引没有用。我给标题字段加了索引,但搜索完全没有变快。查执行计划才明白:两边都带通配符的模糊匹配无法利用普通索引,数据库只能逐行扫描并逐个比对——索引在这种查询下形同虚设。而这一点我在小数据量下完全看不出来(几百条数据扫全表也很快)。
二是我改成全文索引之后,中文搜不到。我以为换成全文索引就解决了,结果搜中文词几乎搜不到任何结果。原因是全文索引默认是按空白和标点切分词的——这对英文有效,而中文句子里没有空格,整句话被当成了一个词。我搜「游标分页」,而库里存的是一整句话,自然匹配不上。
三是关键词高亮把页面搞坏了。我的实现是「在结果文本里把关键词替换成带标记的形式」。用户搜了一个带尖括号的关键词,替换之后那段标记被当成了页面结构的一部分——整个卡片的排版乱了。更严重的是这条路径可以被用来注入内容。
四是过短或无意义的关键词返回了海量结果。搜一个单字、或者搜一个标点,匹配到几乎全部数据——既没有意义,又是一次昂贵的查询。
所以四个改动:模糊匹配改为全文索引检索、补中文分词处理、高亮改为先转义再插入标记、对过短关键词与纯符号做前置拦截。
这个模块最想说的一句话是:「为什么我没上搜索引擎」这个问题,只有在你真的量过之后才答得好。我一开始想上,是因为「搜索就该用搜索引擎」;后来没上,是因为我实测了扫描行数和耗时,确认在我这个量级下全文索引完全够用。而更重要的是我能说出「什么时候该换」——需要复杂的相关性排序、或者数据量再上一个量级。能说出边界的「没上」,和不知道有这个东西的「没上」,在面试里是完全不同的两件事。
没上专门的搜索引擎,用数据库自带的全文索引,这是我在这个项目里最需要讲清依据的一个决定。我一开始是想上的,理由很朴素——「搜索就该用搜索引擎」。但我先做了一件事:实测当前量级下全文索引的扫描行数与耗时,结论是完全够用。而上搜索引擎的代价是:多一个要部署、要维护、要保证与数据库同步的组件——数据同步本身就是一个新的一致性问题(写入延迟、同步失败、重建索引)。判据是「引入这个组件解决的问题,我现在有没有」:没有,那就不该引入。但我把「什么时候该换」写清楚了:需要复杂的相关性排序与打分、需要同义词与拼写纠错、或数据量再上一个量级。能说出边界的「没上」,和不知道有这个东西的「没上」,在面试里是完全不同的两件事。
分词在入库时做而不是查询时实时做。查询时实时分词的好处是不占额外存储、也不用处理历史数据。但它意味着每次查询都要先分词、而且分词结果无法建索引——那就又回到了逐行扫描。入库时分词存一份「可检索文本」的代价是多一列存储、以及分词规则变更时要重扫历史数据。判据是「这个计算的结果需不需要被索引」:需要,那就必须提前算好存下来。
分词粒度选了一个中间值,而不是尽可能细。细粒度能保证「搜什么都能搜到」。但它带来的噪声很大——搜一个常见单字会匹配到大量无关内容,而这些结果对用户完全没用。粗粒度则会漏(搜两个字的词匹配不到含该词的长句)。我的取舍是偏向「宁可漏一点,也不要大量噪声」,理由是用户看到一堆无关结果会直接认为搜索不好用,而偶尔搜不到他会换个词再试。
高亮选择「先转义再插入标记」,这不是取舍而是必须。反过来的顺序(先插标记再转义)会把自己插入的标记也转义掉、高亮失效;而不转义直接插入则会让用户输入的内容破坏页面结构。只有「先转义、再插入我自己生成的标记」这一个顺序是对的。这一条我是踩了坑才想清楚顺序的重要性。
踩过的坑一:给字段加了索引但搜索完全没变快。我一开始以为是索引没生效、或者需要重建。查执行计划才明白:两边都带通配符的模糊匹配根本用不了普通索引,数据库只能逐行扫描。教训是:加索引不等于查询会走索引——查询的写法决定了索引能不能用。而我如果早一点去看执行计划,就不会在「索引为什么没生效」上绕那么久。
踩过的坑二:换成全文索引之后中文搜不到,我以为是全文索引不支持中文。实际原因是默认的分词方式按空白和标点切分——对英文有效,而中文句子里没有空格,整句被当成了一个词。教训是:一个功能「对某类输入无效」时,先去搞清它的工作前提是什么——而不是直接判定它不支持。搞清之后解法就很明确了:自己做分词,两侧规则一致。
踩过的坑三:高亮直接替换关键词,被一个带尖括号的搜索词搞坏了页面。我是自己乱输测试的时候撞出来的。教训是:任何「把用户输入拼进输出」的地方都要先转义——而搜索高亮特别容易被忽略,因为它看起来只是「加个颜色」这么无害的操作。
没做的部分:没做拼写纠错与同义词。它们需要词库和纠错模型,而且效果好不好完全取决于词库质量——我没有能力评估自己拼出来的词库好不好。取舍依据是「我无法判断它是否有效的功能,做出来也不敢用」;我做的是无结果时给出建议和热门内容引导,用一个确定有效的兜底替代一个效果不确定的功能。
模糊匹配的问题要用执行计划加扫描行数来证明,这比耗时更本质:报「数据量为 N 时,两边带通配符的模糊匹配的执行计划显示为全表扫描、扫描行数约等于总行数;改用全文索引后扫描行数下降到 M」。扫描行数是可以直接从执行计划里读出来的,没有解释空间。
耗时对比要报清数据量:「N 条数据时,模糊匹配耗时 X、全文索引耗时 Y」,并说明数据是用脚本造的、内容长度大致分布如何(因为全文检索的耗时和文本长度有关)。
中文分词的效果用断言型用例,这是最直观的一条:造一条含「游标分页」的内容,断言搜「游标分页」能搜到、搜「游标」也能搜到;并保留一条反例记录改造前搜不到——这个前后对比比任何描述都清楚。
分词粒度的取舍要报噪声:「搜一个常见单字时的结果数」——粒度过细时这个数字会非常大(接近全量),说明噪声不可接受。这个数字是我选择中间粒度的依据。
高亮的安全性是断言型用例:搜索一个带尖括号与引号的关键词,断言结果页的结构完整、这些字符以纯文本形式显示、没有产生任何可执行内容。
前置拦截:断言单字、纯符号、纯空白、超长输入都被拦在数据库之外(可以看数据库有没有收到查询),并给出了提示。
摘要与排序:断言摘要围绕命中位置截取、命中的词出现在摘要里;断言标题命中的结果排在正文命中之前。
边界判断要报依据:「在当前量级下全文索引的耗时为 X,我据此判断够用;并估算了数据量增长到多少时耗时会到不可接受的范围」。报出这个估算过程,比只说「够用」有说服力。
不要报什么:不要报「搜索准确率」——我没有标注好的评测集,这个数字算不出来。该报的是「执行计划从全表扫描变为走索引、扫描行数从 N 降到 M」「改造前后中文词能否搜到的前后对比」「搜单字时的结果数(噪声依据)」「带特殊字符的搜索词不破坏页面结构」这几件可核对的事。
「把图片存到服务器上」这件事我原以为十几行代码就能写完,实际上它是这个项目里我改动次数最多的一块,而且改动几乎全是安全和运维层面的。四个问题。
一是类型校验只看扩展名,可以被直接绕过。我的校验是「文件名以图片扩展名结尾就通过」。把任意一个文件改名成图片扩展名就能上传上去——而它被存在了一个对外可访问的目录里。这一条是我在自己乱测的时候发现的:我把一个文本文件改名传上去,居然成功了。
二是我用用户提交的文件名去拼存储路径。我想「保留原始文件名方便识别」。但文件名是用户可控的输入,里面如果带了向上跳目录的路径字符,拼出来的路径就可能指向我预期目录之外的位置。我意识到这一点之后,立刻改成了服务端重新生成文件名——用户提交的名字只作为展示用的元数据,绝不参与路径拼接。
三是所有文件都放在同一个目录,多了之后明显变慢。我造了几万个文件测试,发现列目录变得很慢、创建新文件也受影响。而这个问题在几百个文件时完全不存在。
四是静态资源没有缓存头,每次都重新下载。用户每次打开列表,同样的头像和封面都重新下载一遍——而这些图片内容根本不会变。但我一开始不敢加长期缓存,因为担心「万一图片更新了怎么让缓存失效」。
所以四个改动:类型校验改为读文件头识别真实类型加白名单、文件名由服务端重新生成、禁止用户输入参与路径拼接、按内容指纹分层建子目录并去重、用内容指纹做文件名并加长期缓存头。
这个模块最想说的一句话是:用内容指纹作为文件名这一个改动,同时解决了去重、目录分片和缓存失效三个问题。我原来不敢加长期缓存,是因为担心图片更新后缓存失效;而用内容指纹做文件名之后这个担心就消失了——内容变了指纹就变、路径也变,浏览器自然会去请求新的路径,根本不需要「失效」这个动作。而指纹的前几位又刚好可以用来分层建目录、相同内容还能天然去重。一个决定解掉三个问题,这是我在这个项目里最满意的一处设计。
用内容指纹作为文件名,这是这个模块最值得讲的一个决定,因为它一次解掉了三个问题。第一,缓存:我原来不敢加长期缓存头,担心「万一图片更新了怎么让缓存失效」;而内容指纹做文件名之后,内容变了指纹就变、路径也变,浏览器自然会去请求新路径——「失效」这个动作根本不需要存在。第二,目录分片:指纹的前几位刚好可以用来分层建子目录,分布还很均匀。第三,去重:相同内容天然得到相同路径,只存一份。代价是文件名对人不可读(看不出是什么图)——所以我把用户提交的原始文件名单独存成一个展示字段。判据是「这个标识需要承载什么」:需要承载「内容是否相同」,那就该由内容决定,而不是由上传时间或用户输入决定。
类型校验读文件头而不是看扩展名,这不是取舍而是必须。看扩展名的实现只有一行,但它提供的保护基本为零——改个名字就绕过了。而读文件头的代价也很小(读前几个字节)。我想强调的是这个坑的发现方式:我是自己乱测时把一个文本文件改名传上去、居然成功了才意识到的——正常的功能测试永远不会做这件事,因为正常用户不会改扩展名。
文件不随内容删除,靠定时清理无引用文件。随内容删除更直观、也不会留垃圾。但一旦做了按指纹去重,同一个文件可能被多条内容引用——直接删会让别人的内容图片挂掉。正确做法要么维护引用计数、要么定时扫描清理;我选了定时清理,因为引用计数在并发下要保证准确本身就是一个问题(而且我踩过计数漂移的坑)。代价是被删内容的图片会在磁盘上多留一段时间。
做防盗链但只做简单的来源校验。严格的防盗链需要签名链接与有效期。但我的图片都是公开内容(论坛帖子里的图),本来就要给所有访问者看——严格签名的收益不大,而且会让缓存失效(带签名的链接每次都不同)。简单来源校验能挡掉「别人的页面直接引用」这个主要场景。判据是「这份资源本身是不是私密的」:不是私密的,那防的只是带宽被占,简单手段就够。
踩过的坑一:类型校验只看扩展名,把文本文件改名传上去成功了。而它被存在了一个对外可访问的目录。教训是:任何来自客户端的「声明」都不能当作事实——扩展名是用户声明的类型,不是文件的真实类型。这一条推广开来就是:客户端传过来的所有东西(类型、大小、尺寸、甚至它说它压缩过了)都要在服务端重新确认一遍。
踩过的坑二:我曾经用用户提交的文件名去拼存储路径。初衷是「保留原始文件名方便识别」。后来意识到文件名里如果带向上跳目录的路径字符,拼出来的路径就可能指向我预期目录之外。教训是:用户输入绝不能参与文件路径、命令、查询语句的拼接——而文件名特别容易被忽略,因为它看起来只是一个「名字」,不像是代码的一部分。
踩过的坑三:所有文件放同一目录,几万个之后明显变慢。我是造数据测试时发现的,而几百个文件时这个问题完全不存在。教训是:文件系统也有它的量级边界,不只是数据库——我以前从来没想过「一个目录里放多少文件」也是个需要设计的问题。
没做的部分:没做对象存储与内容分发。它们能解决带宽、可用性和异地访问速度,但都是付费的外部服务,而且引入之后本地就没法完整调试了。取舍依据是「这个项目的瓶颈还没到需要它们的程度」;不过我把存储访问抽成了一层接口,说清了如果要换成对象存储,需要改的只有这一层的实现——能说出迁移路径,比直接接上更能说明我理解了这个分层的意义。
类型绕过是断言型用例,也是这个模块最重要的一条:把一个非图片文件改名成图片扩展名后上传,断言被拒绝;再把一个真实图片改成错误的扩展名上传,断言按真实类型正确处理。两条一起测才说明是「按内容判断」而不是「按名字判断」。
路径穿越:构造包含向上跳目录字符的文件名上传,断言文件仍被写在存储根目录内、且最终路径校验生效。这条要看实际落盘位置,不能只看接口返回成功。
目录分片的效果要报文件数与操作耗时:「单目录放 N 个文件时列目录耗时 X;分层后同样总数下单目录文件数降到 M、耗时 Y」。要说明是造了多少文件、在什么文件系统上测的——这个结论和文件系统有关。
去重:用同一张图片重复上传多次,断言磁盘上只有一份文件、且返回的路径相同。
引用与删除:让两条内容引用同一张图,删掉其中一条,断言图片仍然可访问(另一条内容的图没有挂掉)。这条是「按指纹去重」带来的必然风险,一定要测。
缓存效果报请求数:「重复打开列表页时,图片请求从每次都重新下载变为命中本地缓存」。可以在开发者工具的网络面板里直接看到状态变化,很容易核对。
内容变更后的缓存:替换一张图的内容,断言指纹变化导致路径变化、客户端请求的是新路径——这条证明了「不需要失效动作」这个设计成立。
服务端二次校验:绕过前端直接构造请求上传超大或异常尺寸的图片,断言被服务端拦下。这条验证的是「不信任客户端」。
不要报什么:不要说「上传是安全的」——安全取决于最弱的一环。该报的是「改名的非图片文件被拒绝、改错名的真实图片被正确识别」「含跳目录字符的文件名仍落在根目录内」「单目录文件数与列目录耗时的对比」「同图重复上传只存一份且删除一条内容不影响另一条」「内容变更后路径随之变化」这几件可核对的事。
没有匹配的内容,换个关键词试试。
项目拆解 · 技术社区论坛(个人项目 · 无实习档)· 共 6 个模块 · 数字均为示例,需替换成自己项目的真实数据