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

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

实习级这一档是干什么的 关注关系的推拉分发、内容安全的机审人审串联、长连接这些核心链路,实习生大概率碰不到。这一档收的是社区体系周边、实习生真的会被分派、但仍然有明确技术含量的活。下面三个模块彼此独立——只做过一个就只写一个。
怎么讲才不显得 low 「我做了个敏感词过滤,用 replace 把词换成星号」和「词库有几万条,逐条 indexOf 一次检测要几十毫秒还挡不住变形写法,改成前缀树一次扫描,并且把『命中即拦』改成『分级:高危拦、中危送审、低危记录』」——同一件事,后者面试官会顺着追问。这一档的破解办法是找到「量级上来之后朴素实现就不成立」的那一层:几万词的匹配、几百万关系的判定、多源数据的一致性。
三条自检 一、能说出不这么做会怎样(逐条匹配接口直接超时、黑名单不双向生效被拉黑的人还能骚扰、资料多源不一致用户看到自己的粉丝数忽高忽低);二、能说出你踩过的具体坑;三、能说出量级(多少词、多少关系、多少 QPS)。三条都有就能写。
项目背景设定 社区产品的用户与内容侧,Java + Spring Boot + MySQL + Redis。核心的内容分发与审核链路由正式同学负责,我接的是围绕用户与文本的三块支撑能力。
为什么这三块值得写 它们的共同点是朴素实现在小数据量下完全能跑,量级上来之后必须换方案:几万条敏感词不能逐条匹配、几百万条屏蔽关系不能逐条查库、多个数据源的用户资料不能各自查各自缓存。「什么时候朴素实现不成立、换成什么」这个判断是这三块的核心价值,也是面试里能讲出深度的地方。

模块一:用户资料与主页数据聚合

  1. 用户资料与主页聚合(多源并行取数 + 部分失败降级 + 隐私设置统一生效 + 计数与资料分层缓存)★★
    简历这样写 用户主页数据聚合(Spring Boot + 并行取数 + 多级缓存 + 隐私规则收敛):主页需要聚合基础资料、粉丝与关注数、内容数、认证信息等多个来源,改为并行取数并设总超时预算(原串行调用,任一源慢则整页慢);非关键源失败时降级为缺省展示而非整页失败(认证标识取不到不应导致主页打不开);隐私设置(是否公开粉丝列表、是否允许被搜索)由统一的可见性判定收敛,避免各接口各判一次导致口径不一致;资料与计数分层缓存(资料变更少可长缓存,计数变化频繁用短缓存并容许最终一致),自己看自己的主页跳过缓存取实时值(否则改完昵称刷新还是旧的)。改造后主页接口的尾延迟明显下降,单一数据源故障不再导致主页整体不可用。
    展开完整拆解
    为什么要这么设计

    用户主页看起来就是查几张表拼起来。但它是全站访问量最高的页面之一,而且数据来自五六个不同的服务,四个问题很快暴露。

    一是串行取数导致整页很慢。第一版是依次调用:查资料、查粉丝数、查关注数、查内容数、查认证信息。五次调用串行加起来,任何一个慢的都会拖累整体,而其中有一个统计服务本来就不快。

    二是一个源挂了整个主页打不开。认证服务出了故障,主页直接返回 500——而认证标识只是主页上的一个小图标,它取不到完全不该影响用户看主页。

    三是隐私设置的判断散在各处。用户可以设置「不公开粉丝列表」。主页接口判了一次,粉丝列表接口又判了一次,两处的逻辑还不完全一样——结果是主页上不显示粉丝数,但直接调粉丝列表接口却能拿到数据。这是个越权漏洞。

    四是改完资料刷新还是旧的。为了性能,资料加了缓存。用户改完昵称回到主页,看到的还是旧昵称,他以为没保存成功。

    所以四个改动:并行取数并设总超时预算区分关键源与非关键源、非关键源失败降级隐私判定收敛到一处资料与计数分层缓存、自己看自己跳过缓存

    这个模块最想说的一句话是:聚合接口的可用性等于所有依赖的可用性之积,除非你显式地做降级。五个依赖各自可用性很高,串起来之后整体可用性明显下降。而降级的前提是先想清楚「哪个源是关键的、哪个不是」——这个区分不做,就只能一律失败。

    整体链路
    取数:并行 + 总超时预算 │ ├─ 关键源(缺了主页就没意义) │ 基础资料:昵称 / 头像 / 简介 │ 失败 → 整个接口返回失败(这个降级不了) │ ├─ 非关键源(缺了主页仍然可用) │ 粉丝数 / 关注数 / 内容数 / 认证标识 / 勋章 │ 失败或超时 → 该字段返回缺省值并标记「暂不可用」 │ 前端按标记显示占位而不是显示 0(0 是错误信息) │ ├─ 并行发起,总超时预算固定 │ 不是每个源各自超时相加,而是整体预算 │ 预算用完 → 未返回的按非关键处理 │ └─ 关键源可以先返回,非关键源慢的走「先渲染再补」 主页先出来,计数稍后填上,比整页等着好 隐私可见性:收敛到统一判定 │ ├─ 一个方法回答「访问者 A 能不能看到用户 B 的某项内容」 │ 入参:访问者身份 + 目标用户 + 内容类型 │ 内部综合:目标用户的隐私设置 / 拉黑关系 / 是否本人 / 是否管理员 │ ├─ 主页接口、粉丝列表接口、搜索接口都调它 │ 各判一次的后果:主页不显示但直接调列表接口能拿到(越权) │ └─ 判定结果可缓存但要短,隐私设置改了要能快速生效 缓存分层(按变化频率和一致性要求分开) │ ├─ 基础资料:变化少 → 较长缓存 │ 变更时主动失效(用户改资料 → 删该用户的资料缓存) │ ├─ 计数类:变化频繁 → 短缓存,容许最终一致 │ 粉丝数差几秒没人在意,但不能差很多 │ ├─ 隐私设置:短缓存(改了要快速生效,否则是安全问题) │ └─ 自己看自己的主页 → 跳过缓存取实时 否则改完昵称刷新还是旧的,用户以为没保存成功 这一条成本很低但直接影响用户对「保存是否成功」的判断 热点用户 ├─ 大 V 的主页访问量极高,缓存命中率也高(问题不大) └─ 但缓存失效瞬间可能击穿 → 用单飞(同一 key 只放一个请求穿透)
    分步拆解
    1. 先区分关键源与非关键源,这是降级的前提。基础资料缺了主页没意义(关键);认证标识、勋章缺了主页仍然可用(非关键)。不做这个区分就只能一律失败,聚合接口的可用性会等于所有依赖可用性之积。
    2. 并行取数并设「总超时预算」而不是各自超时。各自超时会累加——五个源各设两秒,最坏情况十秒。总预算是固定的,用完就按非关键处理。
    3. 非关键源失败时返回缺省值并带「不可用」标记,不要返回 0。粉丝数显示 0 是一个错误的事实;显示占位符或「-」才是诚实的。这和数据看板不补零是同一个原则。
    4. 隐私可见性判定必须收敛成一个方法,所有接口都调它。各判一次必然不一致——我们真实出现过「主页不显示粉丝数,但直接调粉丝列表接口能拿到全部数据」的越权
    5. 可见性判定要综合多个因素:隐私设置、拉黑关系、是否本人、是否管理员。这些因素只在一处考虑,加新因素时也只改一处。
    6. 缓存按变化频率分层。资料变化少可以长缓存;计数变化频繁只能短缓存;隐私设置必须短缓存——改了不能快速生效就是安全问题。
    7. 资料变更时主动失效缓存,不要只等过期。用户改昵称后主动删掉他的资料缓存。
    8. 自己看自己的主页跳过缓存。这一条成本极低但很重要:用户改完资料立刻会去主页看效果,看到旧数据会以为没保存成功,可能反复保存。
    9. 计数容许最终一致但要有上界。粉丝数差几秒没人在意,但如果缓存时间太长(比如几分钟),用户会发现「有人关注了我但粉丝数没变」。要在性能和体感之间取一个平衡值。
    10. 热点用户的缓存失效要防击穿。大 V 主页缓存到期的瞬间,大量请求同时穿透到下游。用单飞(同一 key 只放一个请求去查,其余等结果)
    11. 先渲染再补的策略要和前端约定好。关键源先返回让主页出来,非关键的计数稍后补。前端要能处理「先没有后有」的字段,不能因为字段缺失就报错。
    12. 所有下游调用都要有降级返回值,而不是抛异常向上传。抛异常的话聚合层还是得 catch,不如在调用封装层就返回「不可用」的语义值,聚合逻辑更清晰。
    关键决策与取舍

    并行取数的代价是下游压力更集中。串行时下游的请求是错开的,并行会同时到达。对下游来说峰值 QPS 更高。但这是必须付的代价——主页的延迟要求决定了不能串行。缓解办法是缓存和限流:大部分请求命中缓存不会到下游,以及对下游调用设并发上限。

    非关键源失败返回占位而不是 0,接受「界面上有个横线不太好看」。返回 0 界面完整但传递了错误信息(用户以为自己粉丝数是 0)。占位符诚实但不美观。取舍原则和数据看板缺失值不补零完全一致:可视化的第一要求是不误导。

    「自己看自己跳过缓存」会让本人访问的性能差一些。但本人访问的量占比很小(相比别人看他的主页),而这一小部分请求的正确性对用户的信心影响很大。这是典型的「小成本换大体感」。

    踩过的坑一:隐私设置各接口各判一次,出现越权。主页接口判了「不公开粉丝列表」所以不返回粉丝数,但粉丝列表接口是另一个同学写的,他没判,直接调就能拿到全部粉丝。这是安全问题。教训和导出那边的权限漏洞一模一样:同一条规则在多处实现必然会漏。修法是收敛成一个可见性判定方法。

    踩过的坑二:认证服务故障导致主页全站打不开。一个小图标的数据源,拖垮了整个主页。这件事让我理解了「聚合接口的可用性是依赖可用性的乘积」——五个 99.9% 的依赖串起来是 99.5%,而且任何一个的故障都会放大到整页。做聚合接口的第一件事应该是列出依赖清单并标注哪些是关键的。

    踩过的坑三:改完昵称刷新还是旧的,用户反复保存了五次。产生了五条一样的修改记录。修法是本人访问跳过缓存。这个坑说明缓存的正确性问题往往表现为「用户不信任系统」而不是「数据错了」——数据其实没错,只是延迟,但用户的反应是重复操作。

    没做的部分:没做主页数据的预聚合(把多源数据定时合并成一张宽表)。它能大幅降低实时聚合的开销,但引入了数据同步的延迟和一致性问题,而且对于「自己看自己要实时」这个需求是冲突的。当前的并行加缓存已经满足延迟要求,预聚合是量级再上一个台阶后才需要考虑的。

    数字是怎么测的

    延迟改善要报尾延迟而不是平均值。并行化解决的是「最慢的那个源拖累整体」,这个问题主要体现在 P95 和 P99 上,平均值改善不明显。要报改造前后的 P95 对比,并说清压测的 QPS 和缓存命中率(命中率高的话延迟主要看缓存,测不出并行的价值)。

    降级有效性用故障注入验证:让某个非关键源返回错误或超时,断言主页接口仍然返回 200 且该字段带「不可用」标记;让关键源失败,断言接口返回失败。两个方向都要测,只测第一个无法证明「关键源真的是关键的」。

    隐私一致性的验证:设置「不公开粉丝列表」后,逐个调用主页接口、粉丝列表接口、搜索接口,断言全部拒绝。这类用例要进回归,因为它防的是越权。

    缓存一致性:改资料后立即以本人身份请求,断言拿到新值;以他人身份请求,断言在缓存过期时间内可能是旧值(这是设计上接受的)。把「接受的不一致窗口」明确写出来,比声称「强一致」诚实。

    不要报什么:不要报「主页 QPS 提升」——这个模块没有提升吞吐,它改善的是延迟和可用性。该报的是「P95 下降」「单源故障不影响整页」「隐私判定各接口一致」这三件事。

    面试追问
    Q:一个小图标的数据源故障导致整页打不开,这个问题你觉得根本原因是什么? A:根本原因是我没有区分依赖的重要性,把所有下游调用都当成了必须成功的。写代码时是顺着「这个页面需要哪些数据」列出来的,每个都 get 一下,任何一个抛异常就整体失败——这是默认行为,不是我主动选择的。而正确的做法是先列依赖清单并标注:哪些缺了页面就没意义(基础资料),哪些缺了页面仍然可用(认证标识、勋章、计数)。标注完之后,非关键的就该有降级返回值而不是抛异常。我从这里总结出一条:聚合接口的可用性等于所有依赖可用性的乘积,除非你显式做降级——五个 99.9% 的依赖串起来只有 99.5%,而且任何一个的抖动都会放大到整页。所以做聚合接口的第一件事应该是画依赖清单并标注关键性,而不是先写调用代码。这个习惯我后来一直保留着。
    Q:隐私判定收敛到一个方法,会不会太重?每次都要综合那么多因素。 A:性能上不重,因为那些因素本来就都要查——隐私设置、拉黑关系、是否本人,无论是收敛还是分散都得查一遍,收敛只是把它们放到了一起,而且可以一次性批量查。反而分散实现会重复查询(主页接口查一次隐私设置,粉丝列表接口又查一次)。真正的收益是正确性:同一条规则只有一处实现,加新因素时只改一处。我们踩过的坑是主页接口判了、粉丝列表接口没判,直接调就能拿到全部粉丝——这是实实在在的越权。而且这类问题不会被测出来,因为每个接口单独测都「符合它自己的逻辑」。另外要补一点:判定结果可以缓存,但缓存时间必须短——隐私设置改了不能快速生效就是安全问题。这是「性能与安全的取舍里,安全优先」的一个具体体现。

模块二:敏感词检测服务

  1. 敏感词检测服务(前缀树一次扫描 + 文本归一化防变形 + 分级处置而非一律拦截 + 词库热加载)★★★
    简历这样写 文本敏感词检测服务(Spring Boot + 前缀树匹配 + 文本归一化 + 词库热加载):词库达数万条后逐条子串查找的耗时不可接受,改为前缀树一次扫描,检测耗时与词库规模基本解耦;检测前做文本归一化(全角转半角、繁简统一、去除零宽与重复填充字符)以应对插入符号与变形写法;处置从「命中即拦」改为按词分级(高危直接拦截、中危转人工审核、低危仅记录),避免正常内容被误拦;引入白名单短语解决正常词包含敏感词导致的误判;词库支持热加载与灰度(新词先只记录不拦截,观察误判后再启用)。改造后单次检测耗时降到毫秒级,误拦导致的用户申诉明显下降。
    展开完整拆解
    为什么要这么设计

    第一版的实现是最直觉的:把词库读成一个 List,遍历每个词做一次 indexOf,命中就拦。词库两千条的时候完全能跑,涨到几万条之后四个问题一起爆。

    一是耗时不可接受。几万条词,每条都在文本里找一遍,单次检测要几十毫秒。而发帖、发评论、改简介都要过这个检测,它成了写操作的瓶颈。更糟的是这个耗时和词库规模成正比,运营每加一批词,全站的发布接口就慢一点。

    二是挡不住变形写法。用户在敏感词中间插一个符号、用全角字符、用繁体、或者重复某个字,逐字匹配就完全失效了。而这类变形是最常见的规避手段。

    三是误拦正常内容。词库里有个词是「色情」,而「绝色情侣」这个正常短语里包含它。用户发一条正常的内容被拦了,还不知道为什么——因为出于安全考虑我们不会告诉他命中了哪个词。他的反应是投诉或者放弃发布。

    四是词库改动要重启。词库在启动时加载到内存。运营发现一个新的违规词,要等下一次发版才能生效——而这类需求通常是紧急的(正在被刷)。

    所以四个改动:换成前缀树一次扫描(耗时与词库规模解耦)、检测前做文本归一化(抹掉变形)、按词分级处置而不是一律拦截(配白名单解决误判)、词库热加载并支持灰度(新词先观察再启用)。

    这个模块最想说的一句话是:「命中即拦」这个默认行为本身就是错的。敏感词的判定天然有误差——同一个词在不同上下文里含义完全不同。把「检测」和「处置」分成两件事、让处置分级,才能在拦住违规和不误伤正常之间取到平衡。我最初把它们合成一件事,所以只能在「拦得太松」和「拦得太严」之间选,两边都被投诉。

    整体链路
    词库加载(支持热更新) │ ├─ 词条结构:词 · 等级(高/中/低)· 分类 · 是否启用 · 灰度标记 │ 等级决定处置方式,不是所有命中都要拦 │ ├─ 启动时构建前缀树;变更时后台重建并原子替换引用 │ 重建期间旧树继续服务,切换是一次引用赋值 │ 不要在原树上增删 —— 并发下会读到中间状态 │ └─ 变更来源:运营后台改动 → 发消息通知各实例重建 也要有定时兜底轮询(消息丢了不至于永远不更新) 检测前的文本归一化(抹掉变形) │ ├─ 全角转半角、大小写统一 ├─ 繁体转简体 ├─ 去除零宽字符与不可见字符(最常见的插入规避手段) ├─ 去除标点与空白(可配置,因为会带来新的误判) ├─ 压缩连续重复字符(「敏敏感感词词」→「敏感词」) └─ 归一化要记录原文与归一化后的位置映射 否则命中之后无法定位到原文的位置(高亮、截取上下文都要用) 匹配:前缀树一次扫描 │ ├─ 把全部词构建成一棵前缀树(共享公共前缀) │ ├─ 扫描文本一次即可得到全部命中 │ 耗时与文本长度相关,与词库规模基本解耦 │ 这是替换逐条 indexOf 的核心收益 │ └─ 命中结果包含:词 · 等级 · 在原文中的位置 误判抑制 │ ├─ 白名单短语:命中的敏感词若被白名单短语包含则忽略 │ 「色情」命中,但所在片段是「绝色情侣」→ 属白名单,放过 │ 白名单也进前缀树,匹配后做区间包含判断 │ └─ 上下文长度阈值:极短文本的命中可信度更低(可配置更宽松) 分级处置(检测与处置分开,这是关键) │ ├─ 高危(明确违法违规)→ 直接拦截,返回不可发布 ├─ 中危(可能违规,看上下文)→ 允许发布但进人工审核队列 ├─ 低危(擦边、争议)→ 允许发布,仅记录用于统计与后续策略 └─ 一律拦截的后果:要么放过高危,要么误伤大量正常内容 因为同一套阈值不可能同时满足两类词 灰度上新词 ├─ 新词先标记为「仅记录」,观察一段时间的命中量与误判 ├─ 命中量异常高 → 大概率是误判(这个词太通用),需要调整 └─ 观察通过后再改为拦截或送审 可观测 ├─ 按词统计命中量(找出高频误判词) ├─ 按等级统计处置量 └─ 记录被拦截的内容摘要(脱敏)供事后复核申诉
    分步拆解
    1. 前缀树替代逐条查找,这是量级上来后的必然选择。逐条 indexOf 的耗时与词库规模成正比,而前缀树扫描一次文本就能得到全部命中,耗时与词库规模基本解耦。这是「朴素实现在小数据量下能跑、量级上来必须换」的典型例子。
    2. 词库重建要原子替换引用,不能在原树上增删。并发下会读到中间状态(树正在被修改)。做法是后台构建新树,构建完成后一次引用赋值切换,旧树等没有引用后自然回收。
    3. 热加载要有消息通知 + 定时兜底两条路。消息可能丢,只靠消息会导致某个实例永远用着旧词库。定时轮询版本号作为兜底,成本很低。
    4. 文本归一化是防变形的关键,而且必须在匹配前做。全角转半角、繁简统一、去零宽字符、压缩重复字符。不做归一化的话,插一个不可见字符就能绕过全部检测。
    5. 归一化必须记录位置映射。因为命中之后要定位到原文位置(用于高亮、截取上下文、给审核员看)。只做归一化不记映射,就只知道「命中了」不知道「在哪」。
    6. 去除标点要可配置,因为它会带来新误判。去掉标点后「你好。色彩」可能变成「你好色彩」从而命中「你好色」。这是归一化的固有副作用,所以要能按场景开关。
    7. 白名单短语解决「正常词包含敏感词」的误判。做法是白名单也进前缀树,命中敏感词后判断它是否落在某个白名单短语的区间内。这是最有效的误判抑制手段,因为这类误判占了误拦的大头。
    8. 检测与处置必须分开,词要分级。高危拦截、中危送审、低危记录。「命中即拦」的话,同一套标准不可能同时满足「拦住明确违规」和「不误伤擦边正常内容」——只能在两种错误里选一个,而分级让两者可以兼顾。
    9. 中危送人工审核而不是拦截,这一条最有价值。因为「可能违规」的判断需要上下文,机器判不了但人能判。允许先发布再审核(或先审核再发布,看业务容忍度),比直接拦掉好得多。
    10. 新词必须灰度:先只记录不拦截。观察一段时间的命中量——命中量异常高通常说明这个词太通用、会大量误判。这个观察成本很低但能避免一次全站范围的误拦事故。
    11. 按词统计命中量,用来发现误判词。某个词每天命中几万次,几乎肯定是误判(真正的违规内容不会这么高频)。这个统计是词库治理的主要依据。
    12. 被拦截的内容要留摘要(脱敏)供申诉复核。用户申诉时要能查到他当时发的是什么、命中了什么词。没有这个记录,申诉只能靠用户描述,处理不了。
    关键决策与取舍

    选前缀树而不是正则或者第三方服务。正则在几万条词的规模下性能更差且容易写出灾难性回溯;第三方内容安全服务准确率更高,但有网络延迟、有调用成本、而且我们需要的「运营自己维护词库」它做不到。前缀树的代价是要自己实现和维护(不算复杂),换来的是本地毫秒级、无外部依赖、词库完全可控。判据是「是否需要运营自主可控」和「延迟要求」——如果只需要通用违规识别,第三方服务是更好的选择,而且我们后来确实把第三方服务作为第二道叠加在上面。

    归一化做到什么程度,止步于「不引入过多新误判」。可以做得更激进(转拼音、做形近字映射),但每加一层归一化都会带来新的误判——转拼音之后同音的正常词全部会命中。我们的做法是激进的归一化只用于「低危记录」等级,不用于拦截:宁可漏一些变形写法,也不要为了抓变形而误伤大量正常内容。这个取舍的依据仍然是两类错误的代价不对称:误拦是用户立刻感知的伤害,漏过还有人工审核兜着。

    分级处置的代价是人工审核的量增加。中危送审意味着审核队列变长。但这是把「机器判不了的判断」交给了能判的人,而不是让机器硬判。如果人工资源不够,正确的做法是收紧中危的范围(把更多词降到低危),而不是把中危改成直接拦截——后者是用误伤用户来节省审核成本。

    踩过的坑一:词库涨到几万条后发布接口明显变慢,而且是渐进式恶化没人注意到。运营每周加一批词,接口每周慢一点,直到有一天有人反馈「发帖要转好久」。排查时才发现是敏感词检测占了大头。教训是「与配置规模成正比的耗时」是个隐形炸弹——它不会突然爆发,而是慢慢恶化,所以要给这类操作单独埋点监控,而不是只看接口总耗时。

    踩过的坑二:加了一个通用词导致全站大面积误拦。运营加了一个两字词,这个词在很多正常语境里都会出现,上线后大量正常内容被拦,用户投诉量当天激增。这件事直接推动了「新词先灰度只记录」的机制。教训是:任何会立刻全站生效的配置变更,都应该有一个「先观察不生效」的阶段。

    踩过的坑三:在原树上直接增删词导致并发读到中间状态。某个瞬间检测结果不稳定(同一段文本一会儿命中一会儿不命中)。修法是构建新树后原子替换引用。这类「读写共享数据结构」的问题在缓存和配置热更新场景很常见,通用解法就是「构建新的、原子替换」而不是「原地修改」。

    没做的部分:没做语义级的判断(同一个词在不同上下文的含义)。这需要模型能力,超出范围。但分级处置给它留了位置:中危送人工,未来可以换成模型辅助判断。

    数字是怎么测的

    检测耗时要按「词库规模 × 文本长度」两个维度报。因为前缀树的收益正是「与词库规模解耦」,只报一个平均耗时看不出这个收益。做法是分别用两千词和几万词的词库,各测不同长度的文本,报出「逐条查找随词库规模线性增长、前缀树基本持平」这个对比曲线。这是最有说服力的证据。

    误判率的测法要说清样本来源:取一批真实的正常内容(已通过人工审核确认无违规),跑检测,统计被判为高危拦截的比例。这个比例就是误拦率。样本必须是真实内容而不是自己造的,否则测不出真实语境下的误判。要报样本量。

    漏检率不好测,要诚实说明。需要一批「确实违规但检测未命中」的样本,而这批样本本身就是靠人工发现的,不完备。我的报法是「用历史上被人工审核判定违规但检测未拦截的案例回归」,并说明这只能覆盖已知的漏检模式,不代表真实漏检率。

    变形写法的覆盖:准备一组变形样本(插符号、全角、繁体、重复字),断言归一化后都能命中。这是断言型用例,进回归。

    热加载的验证:改词库后不重启,断言新词在指定时间内生效(消息路径);再断言消息丢失时定时兜底也能生效(把消息通道停掉测)。后一条容易漏但很重要。

    不要报什么:不要报「拦截准确率 99%」——准确率的分母是什么、谁标注的、标注一致性如何,这些说不清就不该报。该报的是「耗时与词库规模解耦的对比曲线」「真实正常内容样本上的误拦率」「变形样本全部命中」这三件可核对的事。

    面试追问
    Q:敏感词过滤不就是字符串匹配吗,为什么要搞前缀树? A:词库两千条时逐条 indexOf 完全够用,我最初就是这么写的。问题出在词库涨到几万条之后——耗时和词库规模成正比,单次检测要几十毫秒,而发帖、发评论、改简介都要过它,它成了所有写操作的瓶颈。更麻烦的是这是渐进式恶化:运营每周加一批词,接口每周慢一点,没有明显的故障点,直到有人反馈「发帖要转好久」才发现。前缀树的核心收益是扫描文本一次就能得到全部命中,耗时与词库规模基本解耦——运营再加词也不会让接口变慢。所以这不是「为了用高级算法而用」,而是量级把朴素实现淘汰了。我觉得这类判断在工程里很常见,关键是要能说清「什么时候朴素实现不成立」——这里的临界点就是词库规模成为耗时的主要因子。顺带说一个相关的教训:与配置规模成正比的耗时是个隐形炸弹,要单独埋点监控,不能只看接口总耗时。
    Q:命中敏感词就拦掉不是最安全吗?为什么要分级? A:因为「安全」和「可用」在这里是冲突的,而一律拦截是在用大量误伤换取安全。具体的问题是:敏感词判定天然有误差——同一个词在不同上下文含义完全不同,而且正常词会包含敏感词(比如「绝色情侣」里包含一个敏感词)。如果命中即拦,你就只能在两种错误里选一个:词库定得严,大量正常内容被误拦(用户立刻感知、会投诉、会流失);词库定得松,明确违规的内容放过去(合规风险)。而分级让两者可以兼顾:高危词(明确违法违规)直接拦,中危词(可能违规、要看上下文)允许发布但送人工审核,低危词只记录用于统计。关键判断是「机器判不了的交给能判的人,而不是让机器硬判」。我们真实踩过的坑是运营加了一个很通用的两字词,上线后大面积误拦、投诉量当天激增——这件事之后还加了「新词先灰度只记录」的机制。如果人工审核资源不够,正确做法是收紧中危范围,而不是把中危改成直接拦截——后者是用误伤用户来省审核成本。

模块三:黑名单与屏蔽关系

  1. 黑名单与屏蔽关系(双向生效 + 批量判定替代逐条查库 + 影响面清单化 + 过滤下推到查询层)★★★
    简历这样写 拉黑与屏蔽关系(Spring Boot + Redis 集合缓存 + 批量判定 + 统一可见性过滤):拉黑改为双向生效(原仅单向,被拉黑方仍可查看与评论对方内容继续骚扰);关系判定由逐条查库改为按用户维度整体缓存其屏蔽集合并批量判定(信息流一页要判几十个作者,逐条查库是几十次往返);把屏蔽的影响面清单化——信息流、评论区、搜索结果、私信、通知、粉丝列表逐一接入统一过滤,避免「主流程过滤了但通知里还能收到对方消息」;过滤尽量下推到查询条件而非取回后再筛(后筛会导致分页数量不准);关系变更后主动失效相关缓存并保证秒级生效。改造后拉黑后仍被骚扰的反馈不再出现,信息流接口因关系判定产生的数据库查询大幅减少。
    展开完整拆解
    为什么要这么设计

    拉黑功能第一版只做了一件事:在关系表里插一条「A 拉黑 B」,然后在信息流里过滤掉 B 的内容。上线之后发现它几乎没起到作用,四个问题。

    一是单向生效等于没生效。A 拉黑了 B,A 看不到 B 的内容了。但 B 仍然能看到 A 的全部内容、能评论、能私信、能在 A 的帖子下面继续骚扰。而用户拉黑的目的恰恰是「不要再被这个人打扰」。只做单向是完全误解了这个功能的意图——用户想要的不是「我不看他」,是「我们互不相干」。

    二是判定性能不行。信息流一页返回几十条内容,每条要判断「作者是否被当前用户拉黑」。逐条查库就是几十次数据库往返,信息流接口的延迟明显上升。而信息流是访问量最大的接口。

    三是漏了大量入口。我只在信息流做了过滤。结果是搜索还能搜到、通知里还能收到对方点赞、粉丝列表里还能看到对方、私信还能收到。用户拉黑之后依然被各种打扰,他的反馈是「拉黑功能是假的」。

    四是取回后再过滤导致分页数量不准。查 20 条、过滤掉 5 条、返回 15 条,但分页参数还是按 20 走的——用户看到「还有更多」但每页数量忽多忽少,翻到某页可能是空的。

    所以四个改动:双向生效按用户维度整体缓存屏蔽集合并批量判定把影响面列成清单逐一接入过滤尽量下推到查询条件

    这个模块最想说的一句话是:判断一个功能有没有做对,标准是「用户的目的达成了吗」而不是「需求描述实现了吗」。需求写的是「支持拉黑」,我实现了拉黑,但用户的目的是「不再被打扰」,而单向拉黑完全没达成这个目的。这个认知偏差是我在这个项目里最大的收获。

    整体链路
    关系模型 │ ├─ block 表:blocker · blocked · 时间 · 来源(手动/系统处置) │ 只存单向记录,双向语义在判定时体现 │ └─ 判定语义:A 与 B 之间存在任一方向的拉黑 → 双方互不可见 这一条是「双向生效」的实现方式 不要插两条记录 —— 那样取消拉黑时要删两条,容易漏 判定的性能:整体缓存 + 批量判定 │ ├─ 按用户缓存其「屏蔽集合」(我拉黑的人 + 拉黑我的人的并集) │ 一次取回,之后的判定都是内存中的集合查找 │ ├─ 信息流一页几十个作者 → 一次批量判定,零次数据库往返 │ 逐条查库是几十次往返,这是原实现的主要开销 │ ├─ 集合规模:普通用户很小,极少数用户可能较大 │ 超过阈值的用户不缓存全集,改为按需批量查(避免大 key) │ └─ 关系变更 → 主动失效双方的缓存(两边都要失效) 只失效操作方的缓存是常见疏漏,被拉黑方那边还是旧集合 影响面清单(逐一接入,这是最容易漏的部分) │ ├─ 信息流 / 推荐 过滤掉互相拉黑的作者的内容 ├─ 评论区 过滤掉对方的评论,且对方不能在我的内容下评论 ├─ 搜索结果 用户搜索与内容搜索都要过滤 ├─ 私信 双向禁止发起与接收 ├─ 通知 对方的点赞关注收藏不产生通知 ├─ 粉丝与关注列表 互相不可见(且拉黑通常应自动解除关注关系) ├─ 主页 互相无法访问对方主页(或显示受限提示) └─ @提及与话题 对方无法在内容中提及我 清单化的意义:任何一处漏了,用户就仍然被打扰 新增内容型功能时要把这份清单当成检查项 过滤的位置:尽量下推到查询条件 │ ├─ 下推:查询时就带上「作者不在屏蔽集合中」的条件 │ 分页数量准确,不会出现「每页数量忽多忽少」 │ ├─ 后筛:取回再过滤(某些场景不得不用,比如推荐引擎不支持传排除集) │ 此时必须做「补齐」:过滤掉几条就多取几条,直到凑满一页 │ 并且要有上限,避免屏蔽极多时无限翻页 │ └─ 屏蔽集合很大时不适合下推(IN 条件过长) 改为后筛加补齐,或者在推荐侧提前排除 取消拉黑 ├─ 删除关系记录 + 失效双方缓存 ├─ 已被拦下的历史通知不补发(避免一次涌入大量旧通知) └─ 关注关系不自动恢复(拉黑时解除的关注不应自动回来)
    分步拆解
    1. 双向生效是这个功能的正确语义,不是增强。用户拉黑的目的是「不再被打扰」,单向只做到了「我不看他」,而他仍然能看我、评论我、私信我——目的完全没达成。实现上只存单向记录,判定时看「任一方向存在拉黑」即视为互不可见。
    2. 不要为了双向而插两条记录。插两条的问题是取消拉黑时要删两条,漏删一条就变成了「取消了但还是看不到」,而且这种脏数据很难发现。单条记录加双向判定语义更可靠。
    3. 按用户整体缓存屏蔽集合,判定在内存里做。信息流一页几十个作者,逐条查库是几十次往返;整体缓存之后是一次取回加内存查找。这是这个模块最主要的性能改动。
    4. 缓存的是「我拉黑的人 + 拉黑我的人」的并集。因为判定是双向的,两个方向都要考虑。只缓存自己拉黑的人会漏掉「别人拉黑我」这一半。
    5. 集合过大的用户要特殊处理。极少数用户可能拉黑了很多人,整体缓存会形成大 key。做法是超过阈值就不缓存全集,改为按需批量查询。
    6. 关系变更要失效双方的缓存,不只是操作方。A 拉黑 B,A 和 B 的屏蔽集合都变了。只失效 A 的是常见疏漏,结果是 B 那边还能看到 A 的内容。
    7. 影响面必须列成清单逐一接入。信息流、评论、搜索、私信、通知、粉丝列表、主页、@提及。漏任何一处,用户就仍然被打扰——而他的反馈会是「拉黑功能是假的」,不会告诉你具体漏在哪。
    8. 这份清单要作为新功能的检查项。以后新增任何「能看到别人内容」或「能给别人发消息」的功能,都要过一遍这份清单。否则新功能就成了新的骚扰通道。
    9. 过滤尽量下推到查询条件。取回后再筛会导致分页数量不准(每页忽多忽少、某页可能全空)。下推之后分页是准确的。
    10. 不得不后筛时必须做补齐。过滤掉几条就多取几条凑满一页,并且要设补齐次数上限——屏蔽极多的用户可能一直凑不满,无限补齐会变成慢查询。
    11. 拉黑通常应自动解除关注关系。拉黑一个人却还关注着他,语义上是矛盾的。但取消拉黑时不要自动恢复关注——用户当初的意图是不想再关注了。
    12. 取消拉黑后不要补发历史通知。拉黑期间被拦下的点赞、评论通知,取消后一次涌入几十条会很困扰。这些通知的时效性已经过了,丢弃是合理的。
    关键决策与取舍

    双向生效带来一个副作用:被拉黑方能推断出自己被拉黑了。他会发现看不到对方的内容了。有些产品选择「静默单向」来避免这个尴尬(对方不知道自己被拉黑)。我们的判断是保护被骚扰者优先于避免尴尬——如果为了不让骚扰者察觉而允许他继续接触受害者,那这个功能就失去了意义。缓解办法是不显式提示「你被拉黑了」,只是内容不可见(表现上和「对方注销了」「内容被删了」类似),保留一点模糊性。

    整体缓存屏蔽集合 vs 每次批量查库。整体缓存的收益是判定零成本,代价是要维护缓存一致性(关系变更要失效双方)。批量查库不用维护一致性,但每次请求都有一次数据库往返。对信息流这种超高频接口,缓存是必须的。判据是「判定的调用频率」——信息流每次请求都要判几十次,缓存的收益远超维护成本。

    过滤下推的代价是「屏蔽集合大时 SQL 条件过长」。IN 里几百个 ID 会让查询计划变差。所以我们的策略是分档:集合小的下推到查询条件;集合大的改为后筛加补齐。这不是「选一种方案」而是「按数据特征选」,因为用户的屏蔽规模差异极大。

    踩过的坑一:单向拉黑上线后用户反馈「拉黑没用,他还在骚扰我」。这件事让我意识到我实现的是需求描述而不是用户目的。需求写的是「支持拉黑」,我做了拉黑;但用户的目的是「不再被这个人打扰」,单向拉黑完全没达成这是我在这个项目里最大的收获:接需求时要多问一句「用户想解决什么问题」,而不是只看要实现什么功能。

    踩过的坑二:只失效了操作方的缓存。A 拉黑 B 之后,A 看不到 B 了,但 B 那边的屏蔽集合缓存没更新,B 仍然能看到 A 的内容。表现上又变成了「单向生效」。这个 bug 很隐蔽,因为从操作者的视角看一切正常。教训是「双向的关系变更,两边的缓存都要动」——凡是涉及两个实体的关系,都要检查是不是两边都处理了。

    踩过的坑三:只在信息流做了过滤,其他七八个入口全漏了。用户拉黑之后照样收到对方的点赞通知、在搜索里搜到对方、在粉丝列表看到对方。修法是把影响面列成清单逐一接入,并把这份清单固化成新功能的检查项。这个坑的通用教训是:「可见性」这类横切规则,必须有一份显式的影响面清单,否则每加一个功能就多一个漏洞。

    没做的部分:没做「屏蔽某个话题/关键词」这种内容级屏蔽。用户提过(不想看某类内容),但它和拉黑是两套机制(一个是关系维度,一个是内容维度),实现和存储都不同。混在一个功能里会让语义混乱,应该作为独立功能来做。

    数字是怎么测的

    双向生效的验证要从两个视角各测一遍:A 拉黑 B 后,以 A 的身份断言看不到 B 的内容;以 B 的身份断言也看不到 A 的内容、无法评论、无法私信第二个视角最容易漏——我踩的两个坑(单向语义、只失效一边缓存)都是因为只从操作者视角测。

    影响面清单的验证要逐项过:八个入口(信息流、评论、搜索、私信、通知、粉丝列表、主页、@提及)各写一条断言。报法是「N 个入口逐一验证通过」并列出清单——列出清单本身就是证明你想全了。

    判定性能:「信息流一页的关系判定产生多少次数据库查询」,改造前是每条一次(几十次),改造后是零次(走缓存)。这个次数对比比耗时对比更有说服力,因为它直接解释了原因。

    缓存一致性:拉黑后立即以双方身份请求,断言两边都在指定时间内生效(我们的要求是秒级)。要说清这个时间要求是怎么定的——拉黑是用户在被骚扰时的紧急操作,生效延迟不能大。

    分页准确性:构造一个「屏蔽了一部分作者」的场景,断言每页返回的条数一致、且翻完所有页没有重复没有遗漏。这条防的是后筛导致的分页问题。

    不要报什么:不要报「骚扰投诉下降 N%」——投诉量受很多因素影响,归因不清。该报的是「双向生效在两个视角都验证通过」「八个入口逐一接入」「关系判定的数据库查询次数从几十降到零」这三件可核对的事。

    面试追问
    Q:拉黑做成双向,被拉黑的人会发现自己被拉黑了,这样好吗? A:他确实能推断出来(看不到对方内容了)。有些产品为此选择「静默单向」——对方不知道自己被拉黑,但仍然能看到你。我们的判断是保护被骚扰者优先于避免尴尬:如果为了不让骚扰者察觉而允许他继续接触受害者,这个功能就失去了意义——用户拉黑的目的是「不再被打扰」,不是「我眼前干净就行」。缓解办法是不显式提示「你被拉黑了」,只是内容不可见,表现上和「对方注销了」「内容被删了」类似,保留一点模糊性,不主动羞辱对方。另外还有一个更实际的理由:单向拉黑在功能上是自欺欺人的——我们上线单向版本后用户的反馈就是「拉黑没用,他还在骚扰我」,说明这个功能没有解决任何问题。做一个用户认为无效的功能,比不做更糟,因为它消耗了用户的信任。
    Q:屏蔽影响了那么多入口,你怎么保证以后新功能不漏? A:靠一份显式的影响面清单,并把它固化成新功能的检查项。清单上有八项:信息流、评论、搜索、私信、通知、粉丝列表、主页、@提及。以后新增任何「能看到别人内容」或「能给别人发消息」的功能,评审时必须回答「这里的屏蔽过滤接了吗」。更进一步的做法是把过滤能力做成一个统一的接口(给我一批用户 ID,返回当前用户可见的那些),新功能调它而不是自己写过滤逻辑——这样「接入」的成本足够低,人才会真的去接。如果接入很麻烦,那再多的清单和评审也会被绕过。这一条和权限收敛、脱敏收敛是同一个思路:横切规则必须提供一个便宜的统一入口,否则它一定会在某个新功能里被漏掉。我在这个项目里踩的坑(只在信息流过滤、漏了七八个入口)就是因为最初没有统一入口,每个功能各自实现过滤。

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

项目拆解 · 社区互动周边(后端 · 实习级)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据