用户主页看起来就是查几张表拼起来。但它是全站访问量最高的页面之一,而且数据来自五六个不同的服务,四个问题很快暴露。
一是串行取数导致整页很慢。第一版是依次调用:查资料、查粉丝数、查关注数、查内容数、查认证信息。五次调用串行加起来,任何一个慢的都会拖累整体,而其中有一个统计服务本来就不快。
二是一个源挂了整个主页打不开。认证服务出了故障,主页直接返回 500——而认证标识只是主页上的一个小图标,它取不到完全不该影响用户看主页。
三是隐私设置的判断散在各处。用户可以设置「不公开粉丝列表」。主页接口判了一次,粉丝列表接口又判了一次,两处的逻辑还不完全一样——结果是主页上不显示粉丝数,但直接调粉丝列表接口却能拿到数据。这是个越权漏洞。
四是改完资料刷新还是旧的。为了性能,资料加了缓存。用户改完昵称回到主页,看到的还是旧昵称,他以为没保存成功。
所以四个改动:并行取数并设总超时预算、区分关键源与非关键源、非关键源失败降级、隐私判定收敛到一处、资料与计数分层缓存、自己看自己跳过缓存。
这个模块最想说的一句话是:聚合接口的可用性等于所有依赖的可用性之积,除非你显式地做降级。五个依赖各自可用性很高,串起来之后整体可用性明显下降。而降级的前提是先想清楚「哪个源是关键的、哪个不是」——这个区分不做,就只能一律失败。
并行取数的代价是下游压力更集中。串行时下游的请求是错开的,并行会同时到达。对下游来说峰值 QPS 更高。但这是必须付的代价——主页的延迟要求决定了不能串行。缓解办法是缓存和限流:大部分请求命中缓存不会到下游,以及对下游调用设并发上限。
非关键源失败返回占位而不是 0,接受「界面上有个横线不太好看」。返回 0 界面完整但传递了错误信息(用户以为自己粉丝数是 0)。占位符诚实但不美观。取舍原则和数据看板缺失值不补零完全一致:可视化的第一要求是不误导。
「自己看自己跳过缓存」会让本人访问的性能差一些。但本人访问的量占比很小(相比别人看他的主页),而这一小部分请求的正确性对用户的信心影响很大。这是典型的「小成本换大体感」。
踩过的坑一:隐私设置各接口各判一次,出现越权。主页接口判了「不公开粉丝列表」所以不返回粉丝数,但粉丝列表接口是另一个同学写的,他没判,直接调就能拿到全部粉丝。这是安全问题。教训和导出那边的权限漏洞一模一样:同一条规则在多处实现必然会漏。修法是收敛成一个可见性判定方法。
踩过的坑二:认证服务故障导致主页全站打不开。一个小图标的数据源,拖垮了整个主页。这件事让我理解了「聚合接口的可用性是依赖可用性的乘积」——五个 99.9% 的依赖串起来是 99.5%,而且任何一个的故障都会放大到整页。做聚合接口的第一件事应该是列出依赖清单并标注哪些是关键的。
踩过的坑三:改完昵称刷新还是旧的,用户反复保存了五次。产生了五条一样的修改记录。修法是本人访问跳过缓存。这个坑说明缓存的正确性问题往往表现为「用户不信任系统」而不是「数据错了」——数据其实没错,只是延迟,但用户的反应是重复操作。
没做的部分:没做主页数据的预聚合(把多源数据定时合并成一张宽表)。它能大幅降低实时聚合的开销,但引入了数据同步的延迟和一致性问题,而且对于「自己看自己要实时」这个需求是冲突的。当前的并行加缓存已经满足延迟要求,预聚合是量级再上一个台阶后才需要考虑的。
延迟改善要报尾延迟而不是平均值。并行化解决的是「最慢的那个源拖累整体」,这个问题主要体现在 P95 和 P99 上,平均值改善不明显。要报改造前后的 P95 对比,并说清压测的 QPS 和缓存命中率(命中率高的话延迟主要看缓存,测不出并行的价值)。
降级有效性用故障注入验证:让某个非关键源返回错误或超时,断言主页接口仍然返回 200 且该字段带「不可用」标记;让关键源失败,断言接口返回失败。两个方向都要测,只测第一个无法证明「关键源真的是关键的」。
隐私一致性的验证:设置「不公开粉丝列表」后,逐个调用主页接口、粉丝列表接口、搜索接口,断言全部拒绝。这类用例要进回归,因为它防的是越权。
缓存一致性:改资料后立即以本人身份请求,断言拿到新值;以他人身份请求,断言在缓存过期时间内可能是旧值(这是设计上接受的)。把「接受的不一致窗口」明确写出来,比声称「强一致」诚实。
不要报什么:不要报「主页 QPS 提升」——这个模块没有提升吞吐,它改善的是延迟和可用性。该报的是「P95 下降」「单源故障不影响整页」「隐私判定各接口一致」这三件事。
get 一下,任何一个抛异常就整体失败——这是默认行为,不是我主动选择的。而正确的做法是先列依赖清单并标注:哪些缺了页面就没意义(基础资料),哪些缺了页面仍然可用(认证标识、勋章、计数)。标注完之后,非关键的就该有降级返回值而不是抛异常。我从这里总结出一条:聚合接口的可用性等于所有依赖可用性的乘积,除非你显式做降级——五个 99.9% 的依赖串起来只有 99.5%,而且任何一个的抖动都会放大到整页。所以做聚合接口的第一件事应该是画依赖清单并标注关键性,而不是先写调用代码。这个习惯我后来一直保留着。
第一版的实现是最直觉的:把词库读成一个 List,遍历每个词做一次 indexOf,命中就拦。词库两千条的时候完全能跑,涨到几万条之后四个问题一起爆。
一是耗时不可接受。几万条词,每条都在文本里找一遍,单次检测要几十毫秒。而发帖、发评论、改简介都要过这个检测,它成了写操作的瓶颈。更糟的是这个耗时和词库规模成正比,运营每加一批词,全站的发布接口就慢一点。
二是挡不住变形写法。用户在敏感词中间插一个符号、用全角字符、用繁体、或者重复某个字,逐字匹配就完全失效了。而这类变形是最常见的规避手段。
三是误拦正常内容。词库里有个词是「色情」,而「绝色情侣」这个正常短语里包含它。用户发一条正常的内容被拦了,还不知道为什么——因为出于安全考虑我们不会告诉他命中了哪个词。他的反应是投诉或者放弃发布。
四是词库改动要重启。词库在启动时加载到内存。运营发现一个新的违规词,要等下一次发版才能生效——而这类需求通常是紧急的(正在被刷)。
所以四个改动:换成前缀树一次扫描(耗时与词库规模解耦)、检测前做文本归一化(抹掉变形)、按词分级处置而不是一律拦截(配白名单解决误判)、词库热加载并支持灰度(新词先观察再启用)。
这个模块最想说的一句话是:「命中即拦」这个默认行为本身就是错的。敏感词的判定天然有误差——同一个词在不同上下文里含义完全不同。把「检测」和「处置」分成两件事、让处置分级,才能在拦住违规和不误伤正常之间取到平衡。我最初把它们合成一件事,所以只能在「拦得太松」和「拦得太严」之间选,两边都被投诉。
indexOf 的耗时与词库规模成正比,而前缀树扫描一次文本就能得到全部命中,耗时与词库规模基本解耦。这是「朴素实现在小数据量下能跑、量级上来必须换」的典型例子。选前缀树而不是正则或者第三方服务。正则在几万条词的规模下性能更差且容易写出灾难性回溯;第三方内容安全服务准确率更高,但有网络延迟、有调用成本、而且我们需要的「运营自己维护词库」它做不到。前缀树的代价是要自己实现和维护(不算复杂),换来的是本地毫秒级、无外部依赖、词库完全可控。判据是「是否需要运营自主可控」和「延迟要求」——如果只需要通用违规识别,第三方服务是更好的选择,而且我们后来确实把第三方服务作为第二道叠加在上面。
归一化做到什么程度,止步于「不引入过多新误判」。可以做得更激进(转拼音、做形近字映射),但每加一层归一化都会带来新的误判——转拼音之后同音的正常词全部会命中。我们的做法是激进的归一化只用于「低危记录」等级,不用于拦截:宁可漏一些变形写法,也不要为了抓变形而误伤大量正常内容。这个取舍的依据仍然是两类错误的代价不对称:误拦是用户立刻感知的伤害,漏过还有人工审核兜着。
分级处置的代价是人工审核的量增加。中危送审意味着审核队列变长。但这是把「机器判不了的判断」交给了能判的人,而不是让机器硬判。如果人工资源不够,正确的做法是收紧中危的范围(把更多词降到低危),而不是把中危改成直接拦截——后者是用误伤用户来节省审核成本。
踩过的坑一:词库涨到几万条后发布接口明显变慢,而且是渐进式恶化没人注意到。运营每周加一批词,接口每周慢一点,直到有一天有人反馈「发帖要转好久」。排查时才发现是敏感词检测占了大头。教训是「与配置规模成正比的耗时」是个隐形炸弹——它不会突然爆发,而是慢慢恶化,所以要给这类操作单独埋点监控,而不是只看接口总耗时。
踩过的坑二:加了一个通用词导致全站大面积误拦。运营加了一个两字词,这个词在很多正常语境里都会出现,上线后大量正常内容被拦,用户投诉量当天激增。这件事直接推动了「新词先灰度只记录」的机制。教训是:任何会立刻全站生效的配置变更,都应该有一个「先观察不生效」的阶段。
踩过的坑三:在原树上直接增删词导致并发读到中间状态。某个瞬间检测结果不稳定(同一段文本一会儿命中一会儿不命中)。修法是构建新树后原子替换引用。这类「读写共享数据结构」的问题在缓存和配置热更新场景很常见,通用解法就是「构建新的、原子替换」而不是「原地修改」。
没做的部分:没做语义级的判断(同一个词在不同上下文的含义)。这需要模型能力,超出范围。但分级处置给它留了位置:中危送人工,未来可以换成模型辅助判断。
检测耗时要按「词库规模 × 文本长度」两个维度报。因为前缀树的收益正是「与词库规模解耦」,只报一个平均耗时看不出这个收益。做法是分别用两千词和几万词的词库,各测不同长度的文本,报出「逐条查找随词库规模线性增长、前缀树基本持平」这个对比曲线。这是最有说服力的证据。
误判率的测法要说清样本来源:取一批真实的正常内容(已通过人工审核确认无违规),跑检测,统计被判为高危拦截的比例。这个比例就是误拦率。样本必须是真实内容而不是自己造的,否则测不出真实语境下的误判。要报样本量。
漏检率不好测,要诚实说明。需要一批「确实违规但检测未命中」的样本,而这批样本本身就是靠人工发现的,不完备。我的报法是「用历史上被人工审核判定违规但检测未拦截的案例回归」,并说明这只能覆盖已知的漏检模式,不代表真实漏检率。
变形写法的覆盖:准备一组变形样本(插符号、全角、繁体、重复字),断言归一化后都能命中。这是断言型用例,进回归。
热加载的验证:改词库后不重启,断言新词在指定时间内生效(消息路径);再断言消息丢失时定时兜底也能生效(把消息通道停掉测)。后一条容易漏但很重要。
不要报什么:不要报「拦截准确率 99%」——准确率的分母是什么、谁标注的、标注一致性如何,这些说不清就不该报。该报的是「耗时与词库规模解耦的对比曲线」「真实正常内容样本上的误拦率」「变形样本全部命中」这三件可核对的事。
indexOf 完全够用,我最初就是这么写的。问题出在词库涨到几万条之后——耗时和词库规模成正比,单次检测要几十毫秒,而发帖、发评论、改简介都要过它,它成了所有写操作的瓶颈。更麻烦的是这是渐进式恶化:运营每周加一批词,接口每周慢一点,没有明显的故障点,直到有人反馈「发帖要转好久」才发现。前缀树的核心收益是扫描文本一次就能得到全部命中,耗时与词库规模基本解耦——运营再加词也不会让接口变慢。所以这不是「为了用高级算法而用」,而是量级把朴素实现淘汰了。我觉得这类判断在工程里很常见,关键是要能说清「什么时候朴素实现不成立」——这里的临界点就是词库规模成为耗时的主要因子。顺带说一个相关的教训:与配置规模成正比的耗时是个隐形炸弹,要单独埋点监控,不能只看接口总耗时。
拉黑功能第一版只做了一件事:在关系表里插一条「A 拉黑 B」,然后在信息流里过滤掉 B 的内容。上线之后发现它几乎没起到作用,四个问题。
一是单向生效等于没生效。A 拉黑了 B,A 看不到 B 的内容了。但 B 仍然能看到 A 的全部内容、能评论、能私信、能在 A 的帖子下面继续骚扰。而用户拉黑的目的恰恰是「不要再被这个人打扰」。只做单向是完全误解了这个功能的意图——用户想要的不是「我不看他」,是「我们互不相干」。
二是判定性能不行。信息流一页返回几十条内容,每条要判断「作者是否被当前用户拉黑」。逐条查库就是几十次数据库往返,信息流接口的延迟明显上升。而信息流是访问量最大的接口。
三是漏了大量入口。我只在信息流做了过滤。结果是搜索还能搜到、通知里还能收到对方点赞、粉丝列表里还能看到对方、私信还能收到。用户拉黑之后依然被各种打扰,他的反馈是「拉黑功能是假的」。
四是取回后再过滤导致分页数量不准。查 20 条、过滤掉 5 条、返回 15 条,但分页参数还是按 20 走的——用户看到「还有更多」但每页数量忽多忽少,翻到某页可能是空的。
所以四个改动:双向生效、按用户维度整体缓存屏蔽集合并批量判定、把影响面列成清单逐一接入、过滤尽量下推到查询条件。
这个模块最想说的一句话是:判断一个功能有没有做对,标准是「用户的目的达成了吗」而不是「需求描述实现了吗」。需求写的是「支持拉黑」,我实现了拉黑,但用户的目的是「不再被打扰」,而单向拉黑完全没达成这个目的。这个认知偏差是我在这个项目里最大的收获。
双向生效带来一个副作用:被拉黑方能推断出自己被拉黑了。他会发现看不到对方的内容了。有些产品选择「静默单向」来避免这个尴尬(对方不知道自己被拉黑)。我们的判断是保护被骚扰者优先于避免尴尬——如果为了不让骚扰者察觉而允许他继续接触受害者,那这个功能就失去了意义。缓解办法是不显式提示「你被拉黑了」,只是内容不可见(表现上和「对方注销了」「内容被删了」类似),保留一点模糊性。
整体缓存屏蔽集合 vs 每次批量查库。整体缓存的收益是判定零成本,代价是要维护缓存一致性(关系变更要失效双方)。批量查库不用维护一致性,但每次请求都有一次数据库往返。对信息流这种超高频接口,缓存是必须的。判据是「判定的调用频率」——信息流每次请求都要判几十次,缓存的收益远超维护成本。
过滤下推的代价是「屏蔽集合大时 SQL 条件过长」。IN 里几百个 ID 会让查询计划变差。所以我们的策略是分档:集合小的下推到查询条件;集合大的改为后筛加补齐。这不是「选一种方案」而是「按数据特征选」,因为用户的屏蔽规模差异极大。
踩过的坑一:单向拉黑上线后用户反馈「拉黑没用,他还在骚扰我」。这件事让我意识到我实现的是需求描述而不是用户目的。需求写的是「支持拉黑」,我做了拉黑;但用户的目的是「不再被这个人打扰」,单向拉黑完全没达成。这是我在这个项目里最大的收获:接需求时要多问一句「用户想解决什么问题」,而不是只看要实现什么功能。
踩过的坑二:只失效了操作方的缓存。A 拉黑 B 之后,A 看不到 B 了,但 B 那边的屏蔽集合缓存没更新,B 仍然能看到 A 的内容。表现上又变成了「单向生效」。这个 bug 很隐蔽,因为从操作者的视角看一切正常。教训是「双向的关系变更,两边的缓存都要动」——凡是涉及两个实体的关系,都要检查是不是两边都处理了。
踩过的坑三:只在信息流做了过滤,其他七八个入口全漏了。用户拉黑之后照样收到对方的点赞通知、在搜索里搜到对方、在粉丝列表看到对方。修法是把影响面列成清单逐一接入,并把这份清单固化成新功能的检查项。这个坑的通用教训是:「可见性」这类横切规则,必须有一份显式的影响面清单,否则每加一个功能就多一个漏洞。
没做的部分:没做「屏蔽某个话题/关键词」这种内容级屏蔽。用户提过(不想看某类内容),但它和拉黑是两套机制(一个是关系维度,一个是内容维度),实现和存储都不同。混在一个功能里会让语义混乱,应该作为独立功能来做。
双向生效的验证要从两个视角各测一遍:A 拉黑 B 后,以 A 的身份断言看不到 B 的内容;以 B 的身份断言也看不到 A 的内容、无法评论、无法私信。第二个视角最容易漏——我踩的两个坑(单向语义、只失效一边缓存)都是因为只从操作者视角测。
影响面清单的验证要逐项过:八个入口(信息流、评论、搜索、私信、通知、粉丝列表、主页、@提及)各写一条断言。报法是「N 个入口逐一验证通过」并列出清单——列出清单本身就是证明你想全了。
判定性能:报「信息流一页的关系判定产生多少次数据库查询」,改造前是每条一次(几十次),改造后是零次(走缓存)。这个次数对比比耗时对比更有说服力,因为它直接解释了原因。
缓存一致性:拉黑后立即以双方身份请求,断言两边都在指定时间内生效(我们的要求是秒级)。要说清这个时间要求是怎么定的——拉黑是用户在被骚扰时的紧急操作,生效延迟不能大。
分页准确性:构造一个「屏蔽了一部分作者」的场景,断言每页返回的条数一致、且翻完所有页没有重复没有遗漏。这条防的是后筛导致的分页问题。
不要报什么:不要报「骚扰投诉下降 N%」——投诉量受很多因素影响,归因不清。该报的是「双向生效在两个视角都验证通过」「八个入口逐一接入」「关系判定的数据库查询次数从几十降到零」这三件可核对的事。
没有匹配的内容,换个关键词试试。
项目拆解 · 社区互动周边(后端 · 实习级)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据