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

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

实习级这一档是干什么的 支付、库存、派单调度这些核心链路,实习生大概率碰不到,硬往简历上写会被追问穿。这一档收的是实习生真的会被分派、但仍然有明确技术含量的活。下面三个模块是运营后台的标配,而且它们彼此独立——你可能只做过其中一个,那就只写那一个,三个模块不是必须凑齐。
怎么讲才不显得 low,这是这一档最要紧的事 同一件事有两种讲法。「我做了一个订单导出功能,用 EasyExcel 写文件」——面试官听完没有任何追问的欲望,因为它只描述了动作。「导出量级从几千涨到几十万之后同步接口撑不住,我改成异步任务,分页从 offset 换成游标、写文件从内存攒改成流式,并且补了任务卡死的兜底扫描」——这里面有问题、方案、取舍,面试官会顺着问,而顺着问恰好是你能答的。差别不在做了什么,在于有没有把「为什么必须这么做」想清楚。
三条自检,每个模块都过一遍 一、能说出不这么做会怎样(不改异步会超时、通知不做幂等会重复轰炸用户、审计不落库出了事查不到人);二、能说出你踩过的具体坑(哪次把内存打爆了、哪次给用户连发了五条短信);三、能说出量级(多少行、每天多少条通知、日志表多大)。三条都有就可以写,缺一条先去补,不要靠形容词撑。
项目背景设定 电商平台的运营后台,Java + Spring Boot + MySQL + Redis + 消息队列 + 对象存储。这一页收的是后台的三块支撑能力,不是核心交易链路:运营和财务要能把数据搬进搬出订单状态变化要能通知到用户后台的每一次操作要能查到是谁做的
为什么这三块值得写 它们的技术含量不在业务复杂度上,在约束上:导出受内存和超时约束、通知受幂等和合规约束、审计受「不能影响主流程又不能丢」约束。约束明确的问题恰好最好讲——有明确的对错,不是「我用了某个框架」能糊过去的。而且三块各自都有一个「第一版这么写、上线出事、于是改成那样」的真实过程,这个过程比结果值钱。

模块一:批量数据导出与导入

  1. 批量数据导出与导入(任务化异步 + 游标分页 + 两段校验 + 权限与脱敏收敛)★★★
    简历这样写 运营后台数据导出与批量导入(Spring Boot + 异步任务 + 游标分页 + 流式读写 + 对象存储):导出原为同步接口,数据量增长后出现超时与内存占用过高,改为任务化异步——提交即返回任务号、前端轮询进度、结果存对象存储并给限时下载链接;分页由 offset 改为按主键游标,写文件改为分批流式落盘,堆内存占用与数据量基本解耦。导入因有副作用而与导出不同,采用先全量校验、再分批落库两段式,避免「前一半成功后一半失败」的半成品数据,校验不通过时生成带行号列名与原因的错误文件供运营自行修正重传;以文件内容摘要做幂等,重复上传不产生重复数据。导出的数据范围与列表接口共用同一套权限条件构造(原各写一份导致越权),脱敏按导出用途分级。改造后单次导出 30 万行可稳定完成,导入相关的求助工单明显下降。
    展开完整拆解
    为什么要这么设计

    第一版导出是最直觉的写法:一个接口查出全部数据、写成 Excel、直接返回。数据几千行时完全没问题,跑了三个月,随着订单量增长四个问题一起冒出来。

    一是接口超时。网关和浏览器都有超时限制,导十几万行要跑一两分钟,用户看到「请求失败」但服务端其实还在跑。更糟的是他会重试,点三次就有三个任务在同时查库。

    二是内存打爆。数据查进 List、Excel 库在内存里构建完整工作簿、再一次性写出,同一份数据在内存里存在了两三份。有一次运营同时导两个大报表,服务直接 OOM 重启,影响了整个后台。这是我踩过最严重的一次。

    三是分页越翻越慢。改成 limit offset, size 循环之后,offset 很大时数据库要扫过前面所有行再丢掉,翻到第五十页时单页耗时是第一页的几十倍

    四是过程不可见。用户点了导出,页面转圈,不知道要等多久、也不知道是不是已经挂了。

    所以导出改成「一个任务」而不是「一次请求」:请求只创建任务并立刻返回,后台线程池慢慢干,结果落对象存储。四个改动对应四个问题:任务化解决超时和重复提交、流式写入解决内存、游标分页解决越翻越慢、进度上报解决不可见。

    然后是导入,它的坑更深,因为我一开始把它当成导出的镜像来写。读文件、循环、逐行插库——上线第一周就出了半成品数据:一千行的文件第六百行价格填成负数,抛异常中断,前五百九十九行已经进库了,而运营不知道哪些进去了。他重传整个文件,前面那些又插一遍,变成重复数据。

    根源是导出和导入的性质完全不同:导出只读,失败重跑无代价,幂等天然成立;导入有副作用,一次失败会留痕,重跑必须考虑已经做过的部分。想清楚这个不对称之后方案就明确了——先全量校验、全部通过才开始写,把「部分成功」这个最难处理的状态尽量消灭;校验失败输出带行号的错误文件,把定位问题的能力交给运营;用文件内容摘要做幂等,重复上传直接返回上次结果。

    第三件事是权限和脱敏,这块是被别人发现的。列表页有权限过滤(区域运营只看自己区域),但导出接口是我另写的方法,权限条件漏了——我用管理员账号测,看不出问题。上线两周后区域运营导出时发现表里有别的区域的订单。本质原因是同一条规则在两个地方各写了一遍。而且界面上手机号是掩码,导出的 Excel 里却是全码,后来有人把那份表转发给了外部物流商。

    整体链路
    导出:提交(同步,毫秒返回) │ ├─ 校验筛选条件:时间范围必填且跨度有上限 │ 不限范围的导出等于全表扫描,这条是硬约束 │ ├─ 预估行数 → 超阈值直接拒绝并提示缩小范围 │ ├─ 查重:同人 + 同条件 + 未完成任务已存在 → 返回原任务号 │ └─ 落任务记录 → 返回任务号 导出:执行(异步线程池) │ ├─ 抢占任务:条件更新状态(多实例下只有一个抢到) │ ├─ 循环取数按主键游标,不用 offset │ where id > #{lastId} and 业务条件 order by id limit #{size} │ ├─ 权限条件由统一方法构造,与列表接口共用同一份 │ buildScope(user, bizType) → 直接进 where,不查完再筛 │ ├─ 每批:转换 → 脱敏 → 追加写入临时文件(不在内存攒完整结果) │ 脱敏按「用途 + 字段」查策略:原样 / 掩码 / 哈希 / 不输出 │ 未配策略的字段默认掩码并告警(默认安全) │ ├─ 每 N 批更新一次进度,不是每批都写库 │ └─ 完成 → 上传对象存储 → 状态改完成 导出:下载(三道校验) └─ 任务归属(任务号可猜,必须校验)→ 状态已完成 → 文件未过期 通过则签发分钟级授权链接并重定向,不走服务端中转 导入:第一段校验(全程只读,可以随便失败) │ ├─ 流式逐行读,不把整个文件读进内存 │ ├─ 格式校验:必填 / 类型 / 长度 / 枚举 / 数值范围 │ ├─ 业务校验批量查库,不逐行查 │ 收集本批全部编码 → 一次 in 查询 → 内存比对 │ 逐行查库是一千行一千次往返,最常见的性能坑 │ ├─ 文件内自查重:同一份文件里两行相同编码也要报错 │ └─ 有任何一行不通过 → 生成错误文件(原行 + 行号 + 列名 + 原因) 不写入任何业务数据 导入:第二段落库 │ ├─ 分批提交,每批一个事务(不是整个文件一个大事务) │ ├─ 某批失败 → 重试;超次数 → 置「部分完成」 │ 并输出已成功的行号区间,让人知道确切边界 │ └─ 幂等:文件内容摘要 + 业务键;业务键上另有唯一索引兜底 兜底(定时扫描) ├─ 执行中超过最长时长 → 判定卡死,置失败并告警 ├─ 完成超过保留期 → 删文件,任务记录保留供审计 └─ 本地临时目录残留 → 清理(进程被杀时不会自己消失)
    分步拆解
    1. 入口先卡范围。时间范围必填、跨度有上限、预估行数超阈值直接拒绝。这是所有防护里最有效的一条——把问题挡在产生之前,而且拒绝比跑一小时再失败对用户更友好。
    2. 提交要查重。同人同条件已有未完成任务就返回原任务号。用户连点三次是常态,不查重就是三倍数据库压力。
    3. 分页必须用游标。where id > lastId order by id limit n,每批的代价恒定。前提是排序字段有索引且唯一;如果业务要按时间排序,用「时间 + 主键」复合游标,避免同一时间戳的行被跳过或重复。
    4. 分批查询和分批写入是两件事,只做前者不够。我最初以为改了分页就没问题,内存还是涨——因为 Excel 库仍然在内存里构建完整工作簿。换成流式写入的 API 之后内存才降下来。
    5. 下载走对象存储的限时链接,不走服务端中转。让请求先到应用服务器再读文件转发,内存和带宽问题就又回来了。
    6. 必须有「卡死任务」的兜底扫描。进程重启、线程异常退出、下游卡住,都会留下永远停在「执行中」的任务。没有终态的状态机必须有外部兜底——我们卡过一整天,因为没有异常、没有错误日志、接口也没超时(它就是在等),监控上什么都看不到。
    7. 导入的两段式:校验全部通过才开始写。代价是多读一遍文件,收益是把「部分成功」压缩到极小。多读一遍是几秒机器时间,处理半成品是几十分钟人工加出错风险,这个对比很清楚。
    8. 业务校验必须批量查库。一千行循环里每行一次 select 就是一千次往返,改成批量 in 查询通常能让校验耗时降一个量级。
    9. 错误文件是导入这块最有价值的产出。不要只返回「第 600 行错误」,要生成和原文件结构一致、末尾多两列(错误列名、原因)的文件,运营下载下来直接改、改完重传。这才叫把工作真正交出去,否则每次导入都要开发帮忙定位。错误要一次收集全(但设上限),不要遇到第一个就返回。
    10. 幂等键用文件内容摘要而不是文件名。名字一样内容改了要能重导,内容一样改了名字不该重导。另外唯一索引挡的是数据重复,挡不住「重复的操作」这件事——第二次上传时报一堆唯一键冲突,用户会以为都失败了,实际第一次已经成功。这两道防线作用不同,都要有。
    11. 权限条件必须有唯一构造入口。列表、导出、统计都调同一个方法。各写一份必然会漏,而漏的那份就是越权通道。范围只从登录态取,绝不接受前端传参;条件要进 SQL 而不是查完再筛。
    12. 脱敏按「导出用途」分级,不按字段一刀切。同一个手机号字段,内部对账要全码、给外部合作方要掩码。用途做成枚举、策略集中配置、脱敏放在写文件前作为一道必经管道(放在各自查询里必然有的做有的没做)。未配策略的字段默认掩码并告警——默认掩码的错误是「有人抱怨看不到数据」,默认原样的错误是「数据裸奔很久没人发现」,代价完全不对称。
    关键决策与取舍

    为什么不用「同步导出 + 调大超时」这个更省事的方案。把网关超时从 30 秒调到 5 分钟看起来一行配置就解决了,但浏览器和中间层还有各自的超时、长时间占着连接和线程会让正常接口跟着慢、而且失败了没有任何补救(跑四分钟在最后一步挂了只能从头再来)。调超时只是把问题推后。但要说清前提:如果数据量确实只有几千行,同步导出是完全正确的选择,不要为了显得复杂而上异步。我们改造的触发点是明确的——同步已经撑不住了。

    为什么用数据库表存任务,不引入消息队列。任务表已经满足全部需求:要查任务列表和进度(表天然支持,队列还得另存状态)、要幂等查重(表加唯一索引即可)、量级很小(一天几十上百个任务)。引入队列多一个组件要维护,状态还是得落表。取舍依据是量级和查询需求,不是哪个技术更先进——如果涨到每分钟几百个任务,那就该上队列了。

    导入的两段式代价是读两遍文件,我认为值得。边校验边写只读一遍,但必然产生半成品,而半成品需要人工去库里确认哪些进去了、手工删或补,过程中还可能删错。判据是「两种代价谁更贵」。顺带说为什么不用「整个文件一个大事务」来彻底消灭部分成功:大事务长时间持锁、回滚代价高、可能撑爆事务日志。与其准备好回滚一切,不如让失败先不发生——用校验前置把失败概率压到很低,再配分批小事务。

    游标分页的代价是排序不自由。结果顺序固定(按主键或时间),不能支持用户自定义排序。我们接受这个限制——导出文件用户拿到 Excel 里自己排就行,为了任意排序而回到 offset 不值得。主动说出这一条,说明你知道自己方案的边界。

    踩过的坑一:修了列表的权限,忘了修导出;补上之后三个月,另一个同学加新导出接口又漏了一次。第一次是我不小心,第二次证明这不是个人失误的问题——是这个设计允许人忘。于是才有了收敛成单一入口这件事。我从这里学到的是:这类问题不能靠「下次仔细一点」解决,把规则收敛成单一入口,是让「不小心」不再产生后果的唯一办法。另外一条更便宜的教训是:测试账号里必须有一个受限范围的账号,这个 bug 用管理员测永远测不出来。

    踩过的坑二:掩码之后数据不能用了。手机号掩成 138****1234,运营拿去核对客户就不够了。但他真实的需求是「判断是不是同一个人」,给一个稳定哈希就够,不需要全码。所以脱敏方式不能只有掩码,要有哈希(同值同结果、可比对、不可反推)。脱敏不是简单打星号,要看下游拿它干什么。

    踩过的坑三:Excel 的类型问题占了初期错误反馈的大头,而且不是运营填错。导出时手机号变成科学计数法、订单号 007 变成 7;导入时 12.00 读出来是 12、日期读出来是个数字。修法是导出时显式指定文本格式,导入时做统一的类型归一,并且把「无法解析」和「值不合法」分成两种提示——前者让他检查单元格格式,后者让他改内容,指引完全不同。

    没做的部分:没做导出任务的优先级(财务的大报表会堵住运营的小报表),也没做导入的预览确认。后者的替代方案是「支持按本次任务批量撤销」——事后可撤销往往比事前多一次确认更实用,因为确认页大部分人是直接点下一步的。

    数字是怎么测的

    「30 万行可稳定完成」:在测试库造够量数据(不要用生产数据),跑完整流程若干次,看是否都能到完成状态、耗时是否稳定、以及堆内存曲线。关键是跑多次而不是一次成功就报——内存问题往往是偶发的。要说清机器配置和单批大小。

    内存占用:我的口径是「堆内存占用与数据量基本解耦」而不是给一个具体 MB 数,因为它和批大小、字段数、机器配置都有关。而「解耦」这个说法本身是可验证的:导 3 万行和导 30 万行的内存峰值应该接近,这个对比才是证据,比绝对值有说服力。

    导出期间对正常业务的影响:一边跑导出一边压测业务接口,对比 P95。这个测试是必要的——异步化解决了导出自己的超时,但它仍然在查同一个数据库。如果测出明显抬升,说明还需要限速或走只读库。这一点我会主动说,因为它是面试官很可能追问的方向。

    游标 vs offset:两种分页各导同一批数据,记录每一页的耗时。offset 方案耗时随页数明显上升,游标方案平稳。给这条曲线的形状比给总耗时更有说服力,因为它直接解释了为什么必须换。

    导入的半成品「不再出现」:这是正确性问题,用构造用例——造一个「第 N 行有业务错误」的文件,断言数据库里没有任何本次导入的数据。这是可以写成自动化断言的,比统计线上数据可靠。报法是「构造的错误文件用例全部无数据落库」,不编百分比。

    幂等验证要正反两面:同一文件连传两次,断言第二次直接返回上次结果且无新增数据;然后改动文件里一个字符再传,断言这次正常处理——后一个容易被忘,但它才能证明幂等键不是过度拦截。

    越权不用统计指标,用断言型回归用例。准备超管、受限范围运营、无权限三种账号,对每个导出场景各跑一遍,断言结果范围严格属于该账号可见范围。这类用例进 CI 且不允许失败——越权是零容忍的,没有百分比的概念。脱敏要正反都测:给外部的断言是掩码,内部对账的断言是全码,只测正面会漏掉「脱敏过度」这种没人会报但让功能不可用的问题

    不要报什么:不要报「性能提升 N 倍」(取决于拿哪一页比)、「支持无限量导出」(一定有上限而且你该知道在哪)、「零失败」(任务失败是正常的,该报的是失败会重试、超次数会置终态并告警这个机制)、「安全性提升 N%」(安全没有这种量纲)。

    面试追问
    Q:这个功能听起来不复杂,你觉得技术含量在哪? A:含量不在业务逻辑上,在资源约束上。业务逻辑就是查数据写文件,确实简单;但数据量一上来,内存、超时、数据库压力这三个约束会同时把你逼到重新设计。而且它们有先后顺序——我最初以为改了分页(数据库)就好了,结果内存还是爆,才发现查询和写入是两条独立的路径。这个「以为解决了但没解决」的过程是我学到最多的地方。另一层含量是状态机的完备性:任何异步任务都必须回答「卡住了怎么办」,我们卡了一整天没人知道,就是因为缺了外部兜底。这个教训在后来做别的异步流程时一直在用。
    Q:你反复说导入和导出不对称,具体不对称在哪几点? A:四点。一、幂等性:导出天然幂等(只读,跑十次结果一样),导入必须显式做。二、失败的代价:导出失败重跑就行,导入可能已经写了一半,留下需要人工处理的状态。三、错误的归属:导出失败基本是系统问题(超时、内存),导入失败大部分是数据问题也就是用户填错了——所以导入必须把错误清楚地反馈给用户,导出只需要给自己留日志。四、校验的必要性:导出不需要校验数据(库里的数据本来就合法),导入必须把校验做成一等公民。四点里最关键的是第三点——它决定了导入功能的主要工作量其实在错误反馈上,而不在写入逻辑上。这一点我最初完全没预料到。
    Q:越权那个 bug,如果重来一次你会怎么避免? A:两件事。一是从一开始就把权限条件的构造抽成唯一方法,任何查询必须经过它——不是「我记得加」,而是「不经过它就查不了数据」。二是测试账号里必须有一个受限范围的账号,而不是全程用管理员测。第二条其实更关键也更便宜:它不需要任何架构改动,只需要改测试习惯,而且它能发现的问题不止权限一类(数据范围、可见字段、按钮权限都能覆盖)。我现在做任何带数据范围的功能,第一件事就是拿受限账号跑一遍。另外要补一句:收敛成单一入口时要留一个受控的出口——有些查询确实需要跨范围(比如跨区域汇总报表),做法是让统一方法支持显式声明「跨范围」且这个声明需要单独的权限点,而不是允许绕过统一方法自己拼条件。如果绕过很容易,半年后就又散了。
    Q:如果运营就是需要全量手机号,业务上确实合理,你怎么处理? A:不在技术上阻止,但让这件事可见和可追溯。三条:这个用途单独申请权限点(不是所有运营默认都有)、每次这类导出单独打标记并记录审批人、加频次和数量限制(正常核对不会一天导十万条)。核心判断是:合理的需求不该被技术手段堵死,但风险高的操作必须留下痕迹和边界。如果一味用技术限制去对抗真实的业务需求,结果一定是业务方找别的路绕过去——比如让开发直接从库里导一份给他,那反而更失控,因为连审计都没有了。这一条我觉得是做内部系统最重要的一个认知。

模块二:消息通知中心

  1. 消息通知中心(事件驱动解耦 + 按渠道分级降级 + 模板与代码分离 + 退订与免打扰)★★★
    简历这样写 统一消息通知服务(Spring Boot + 消息队列 + 多渠道适配 + 模板配置化 + 幂等去重):原先各业务模块直接调用短信与推送 SDK,散落且无法统一治理,重构为事件驱动——业务只发布领域事件,通知服务订阅后决定发什么、发几个渠道;幂等键取业务事件 ID 而非消息内容(重试与消息重投不会重复轰炸用户);多渠道按成本与到达率分级降级而非并发群发(站内信必发、推送优先、短信仅在推送不可达且事件重要时补发),单次通知的短信占比明显下降;文案与变量做成可配置模板,改文案不再发版;按消息类型区分可退订与不可退订(营销类可退、交易类不可退),并支持免打扰时段与频次上限。上线后重复通知的投诉不再出现,通知相关的发版次数明显减少。
    展开完整拆解
    为什么要这么设计

    接手之前,通知是这样写的:订单状态变更的代码里直接调短信 SDK,发货的代码里再调一次,退款的代码里又调一次。每个地方各写一份,参数各拼一份,文案硬编码在代码里。四个问题很快暴露。

    一是重复轰炸用户。支付成功的消息队列消费者做了重试,重试时又发了一次短信。有个用户一次支付收到了五条「支付成功」短信,投诉到客服。查出来是消费重试三次加上另一处代码也监听了同一个事件。这是最严重的一个问题,因为它直接伤害用户。

    二是改文案要发版。运营想把「您的订单已发货」改成带商品名的版本,因为文案在代码里,改一个字要走一次发布流程。这类需求每周都有,开发和运营都很痛苦。

    三是短信费用失控。所有通知都发短信,因为「短信最可靠」。月底账单出来,大部分短信是在通知一些用户压根不关心的状态变更(比如「订单已进入仓库拣货」)。而这些用户当时正在 App 里,站内信就够了。

    四是没有退订,被合规提醒了。营销类的通知没有退订入口,用户想不收只能拉黑号码。这在合规上是有要求的,不是体验优化。

    所以重构的思路是把「业务发生了什么」和「怎么通知」彻底分开:业务只负责发布一个领域事件(订单已支付、订单已发货),它不知道也不该知道会发几条短信;通知服务订阅事件,查配置决定发哪些渠道、用哪个模板、要不要受退订和免打扰限制。

    四个改动对应四个问题:事件驱动 + 幂等键取事件 ID(解决重复)、模板配置化(解决发版)、渠道分级降级(解决费用)、按类型区分可退订(解决合规)。

    其中最关键的判断是幂等键怎么取。我最初想用「用户 + 消息内容」的哈希做去重,但这是错的——同一个用户先后收到两条内容相同的「订单已发货」,可能真的是两个订单。正确的键是业务事件 ID(订单号 + 事件类型 + 事件发生的版本或时间戳),它才能精确表达「这是同一件事的重复通知」。

    整体链路
    业务侧(只发事件,不关心怎么通知) │ └─ 发布领域事件:orderPaid / orderShipped / refundDone … 事件里带业务 ID + 事件类型 + 必要的业务字段 业务代码不出现任何渠道相关的东西 通知服务:接收 │ ├─ 幂等:键 = 业务ID + 事件类型(不是消息内容的哈希) │ 同内容不等于同一件事:两个订单都发货了,内容一样但要各发一条 │ 命中已处理 → 直接丢弃,不再往下走 │ ├─ 查通知配置:这个事件类型要通知谁、发哪些渠道、用哪个模板 │ 配置在库里,新增一种通知不需要改代码 │ └─ 落一条通知记录(待发送),后续状态都挂在它上面 通知服务:过滤(发之前的三道拦) │ ├─ 1 退订:按消息类型判定 │ 营销类 可退订,退订了就不发 │ 交易类 不可退订(订单、支付、安全提醒属于必要告知) │ 这个区分是合规要求,不是产品偏好 │ ├─ 2 免打扰时段:夜间不发推送和短信,站内信照常落 │ 到点后补发,或者按事件重要性决定丢弃还是延后 │ └─ 3 频次上限:同一用户单日同类通知的条数上限 防止某个业务异常刷屏(比如状态机抖动来回变更) 通知服务:渠道分级降级(不是并发群发) │ ├─ 站内信 必发,成本近乎为零,作为兜底留痕 │ ├─ 推送 优先尝试;有设备令牌且用户未关闭推送权限 │ ├─ 短信 仅在「推送不可达 + 事件重要」时补发 │ 不可达包括:无设备令牌 / 推送失败 / 用户关闭了通知权限 │ └─ 邮件 长内容与需留证的场景(对账单、发票) 判断依据是「成本 × 到达率 × 事件重要性」,不是「都发一遍更保险」 发送与回执 │ ├─ 渠道适配层:各渠道 SDK 差异关在里面,对上暴露统一接口 │ 统一的入参、统一的错误码、统一的重试语义 │ ├─ 失败分类处理: │ 可重试 网络抖动 / 渠道限流 → 退避重试 │ 不可重试 号码格式错 / 用户已注销 → 直接置失败,不重试 │ └─ 异步接收渠道回执 → 更新送达状态(发送成功 ≠ 用户收到) 可观测 ├─ 按事件类型 / 渠道统计:发送量 · 成功率 · 送达率 · 单条成本 ├─ 短信占比作为核心成本指标持续盯 └─ 同一用户单日通知数的分布,长尾异常要能发现
    分步拆解
    1. 业务代码里不能出现任何渠道相关的东西。这是解耦的判据——如果业务代码里还有 smsClient.send(...),那说明没解耦。业务只发事件,「发不发短信」是通知服务的决定。这样做的额外收益是:以后要加一个渠道(比如企业微信),业务代码一行都不用改。
    2. 幂等键必须是业务事件 ID,不能是消息内容。同内容不代表同一件事——用户有两个订单都发货了,两条通知内容一样但都该发。键取「业务 ID + 事件类型」,必要时加事件版本号(状态机可能对同一订单多次触发同类事件)。
    3. 幂等要挡在最前面。在查配置、渲染模板之前就判重,避免做无用功。用 Redis 存幂等键加过期时间,配一张通知记录表作为持久依据。
    4. 渠道是分级降级,不是并发群发。「都发一遍更保险」是最贵也最招人烦的做法。顺序是站内信必发(兜底且留痕)→ 推送优先(免费且体验好)→ 短信补发(只在推送不可达且事件重要时)。判断依据是成本乘到达率乘事件重要性。
    5. 「推送不可达」要能判断,这是降级能省钱的前提。无设备令牌、令牌失效、用户在系统设置里关了通知权限、推送服务返回失败——这几种都算不可达。其中「用户关了通知权限」需要客户端上报,服务端自己判断不出来,这是要和客户端约定的接口。
    6. 模板要和代码分离,变量用占位符。文案存库、变量声明清楚、渲染时校验变量是否齐全。缺变量要在发送前拦下并告警,不能把 {orderNo} 原样发给用户——这个事故很常见而且很丢脸。
    7. 退订按消息类型区分,这是合规要求。营销推广类必须可退订且退订入口要明显;交易和安全类(订单状态、支付结果、登录异常)属于必要告知,不可退订。把这两类混在一个开关里是错的——用户退订营销之后收不到发货通知会来投诉。
    8. 免打扰时段只影响推送和短信,站内信照常落。因为站内信不打扰用户,他自己打开才看到。到点后是补发还是丢弃,按事件重要性决定:发货通知补发,「拣货中」这类过程通知直接丢弃。
    9. 频次上限是防事故的,不是防打扰的。正常业务不会给一个用户一天发几十条,但状态机抖动、消息重复投递、循环触发都会。这个上限是最后一道闸,触发时要告警——它响了说明上游有 bug。
    10. 失败要分可重试和不可重试。网络抖动、渠道限流可以退避重试;号码格式错误、用户已注销是终态,重试只是浪费配额。不区分就会出现「一个错号码被重试几十次」。
    11. 发送成功不等于用户收到。短信和推送的渠道回执是异步回来的,要单独更新送达状态。只统计「调用渠道接口成功率」会得出一个虚高的数字——渠道接了但没送到的情况不少(空号、停机、被拦截)。
    12. 渠道适配层要统一错误码和重试语义。各家 SDK 的错误码定义完全不同,如果不做映射,重试逻辑就得为每个渠道写一遍。这一层是加新渠道时唯一需要动的地方。
    关键决策与取舍

    为什么走事件驱动而不是让业务直接调通知服务的接口。直接调接口更简单,链路也更短(少一次消息投递)。但它有两个问题:一是业务和通知的可用性耦合——通知服务挂了,如果是同步调用,业务的下单流程会跟着受影响;二是业务方要知道「这个动作要发通知」,等于把通知的决策权分散回了业务代码里。事件驱动的代价是引入了消息队列的复杂度和最终一致性(通知可能晚几秒),但通知本来就不要求强实时,这个代价可以接受。判据是「通知失败该不该影响主业务」——答案是不该,那就必须异步解耦。

    渠道分级降级 vs 让运营在配置里自由勾选渠道。后者更灵活,运营想发什么就发什么。但实际结果是运营会把所有渠道都勾上——因为对他来说多发没坏处,成本不在他的考核里。所以我们把降级规则做成代码里的固定策略,运营只能配「这个事件重要不重要」,重要性决定是否允许短信补发。这是有意收回一部分自由度,理由是成本责任和配置权限不在同一个人手上。这个取舍我觉得值得说,因为它体现的是对真实组织行为的判断,不只是技术选择。

    频次上限设成硬拦截还是只告警,我们选了硬拦截加告警。硬拦截有风险——万一某个用户真的一天下了很多单,正常通知会被拦掉。但对比另一边的风险(上游 bug 导致给用户发几十条短信,既花钱又招投诉),宁可拦掉少数正常通知。而且拦掉的会记录并告警,可以人工补发。这类「防事故」的闸门应该倾向于宁可误拦。

    踩过的坑一:幂等键最初用消息内容做哈希,导致该发的没发。用户两个订单先后发货,两条通知内容完全一样(模板里没带订单号),第二条被幂等挡掉了。用户投诉说没收到发货通知。教训是幂等键必须表达「业务事件的唯一性」,而消息内容只是事件的一种呈现——呈现相同不代表事件相同。这个坑让我理解了幂等键的设计要从业务语义出发,不能图省事拿手边的数据去哈希。

    踩过的坑二:模板里的变量没渲染,把 {userName} 发给了用户。原因是模板里加了一个新变量,但调用方没传。修法是渲染时校验变量完整性,缺任何一个就拦下不发并告警,而不是发一个残缺的消息出去。这里的判断是:不发比发错好——用户没收到通知会来问,收到一条乱码消息会觉得这个平台不专业,后者更伤。

    踩过的坑三:夜间免打扰只挡了短信,忘了挡推送。凌晨三点给用户推了一条「订单已进入仓库」。修法之外更值得说的是:这个 bug 是因为免打扰的判断散在了两个渠道各自的发送逻辑里,短信那边加了,推送那边没加。又是「同一条规则在多处实现」这个模式——和导出的权限漏洞是同一类错误。后来把免打扰、退订、频次三道过滤统一提到发送之前的一个必经环节。

    踩过的坑四:只统计了「调用渠道接口成功」,以为送达率很高。后来接了回执才发现有一部分是空号、停机、被运营商拦截的。「发送成功」和「用户收到」是两个指标,混在一起报会得出一个虚高的数字——而且这个数字会让你误判「通知链路很健康」,从而错过真实的问题。

    没做的部分:没做用户偏好的细粒度配置(比如「我只想收发货通知,不想收物流中转」)。方向是对的,但配置项一多用户压根不会去设,收益有限。当时的判断是先把「营销可退订、交易不可退」这个粗粒度做扎实,细粒度等有明确需求再说。另外也没做消息聚合(把同一用户短时间内的多条通知合并成一条),这个在通知量更大的产品里是必需的。

    数字是怎么测的

    「重复通知不再出现」用构造用例验证,不用线上统计。造一个场景:同一个业务事件重复投递多次(模拟消息队列重试),断言只产生一条通知记录、只调用渠道一次。同时要测反面:两个不同订单的同类事件,断言各自都发了——这一条防的是幂等过度拦截,也就是我踩过的那个坑。两个方向都测才算测完。

    短信占比:「短信条数 / 总通知条数」的变化,而不是报省了多少钱。理由是费用受单价和业务量影响,占比才反映策略本身的效果。还要说清统计口径是哪些事件类型——如果只统计了本来就不发短信的那些,这个数字没有意义。

    送达率要和发送成功率分开报。发送成功率是「调渠道接口返回成功的比例」,送达率是「拿到成功回执的比例」,两者之间的差值本身就是个有价值的指标——差值大说明号码质量或渠道质量有问题。只报一个数字会掩盖这件事。

    「通知相关发版次数减少」:这是可维护性的改善,报绝对次数的变化(改文案从每次发版变成配置生效)。这个陈述是可核对的,比任何百分比可信。

    频次上限的验证:构造一个用户短时间内触发大量同类事件的场景,断言超出上限后被拦截、且产生了告警记录。拦截本身要能被观测到,否则你不知道它有没有在误伤正常用户。

    不要报什么:不要报「通知到达率 99%」——除非你真的接了全部渠道的回执并且知道分母是什么,这个数字很容易被质疑。不要报「节省成本 N 万」——那需要财务口径,而且和业务量强相关。该报的是「短信占比下降」和「重复通知的投诉不再出现」,前者是可算的比例,后者是可核对的事实。

    面试追问
    Q:幂等键为什么不能用消息内容?听起来内容一样就是重复啊。 A:因为内容相同不代表事件相同。我踩过这个坑:用户有两个订单先后发货,模板里没带订单号,两条通知内容完全一样,第二条被幂等挡掉了,用户投诉没收到发货通知。幂等键要表达的是「业务上这是同一件事」,而消息内容只是这件事的一种呈现——呈现相同是巧合。正确的键是「业务 ID + 事件类型」,必要时加事件版本号,因为状态机可能对同一订单多次触发同类事件(比如拆单后分批发货)。这件事让我总结出一个判断方法:设计幂等键时问自己「如果两条数据的键相同,业务上它们是不是必须只发生一次」,如果答案不是斩钉截铁的是,那这个键就选错了。
    Q:为什么不把渠道选择开放给运营配,而要写死在代码里?这样不够灵活。 A:这是有意收回自由度,理由是成本责任和配置权限不在同一个人手上。开放给运营配的实际结果是所有渠道都被勾上——对他来说多发没坏处(覆盖更全、KPI 更好看),而短信费用不在他的考核里。所以我们把降级策略固定在代码里,只让运营配「这个事件重要不重要」,重要性决定是否允许短信补发。这样运营仍然有表达业务意图的空间,但没有直接支配成本的权力。我觉得这类设计判断在做内部系统时很常见也很重要:不能只看「谁最了解业务」就把配置权给谁,还要看「谁承担后果」。如果后来成本纳入了运营的考核,那这个决定就该重新讨论——它是基于当时的组织现实做的,不是一个永恒正确的技术结论。
    Q:通知服务挂了怎么办?用户收不到订单状态会投诉。 A:分两层。第一层是事件不能丢——业务发的领域事件在消息队列里,通知服务挂了消息还在队列里堆着,恢复后继续消费。所以关键约束是「消费成功才确认」,不能收到就确认。这也是选事件驱动而不是同步调接口的一个理由:同步调用时通知服务挂了,那次通知就永久丢了。第二层是站内信作为兜底留痕——即使推送和短信都失败了,站内信是落在自己库里的,用户打开 App 还能看到,客服也能查到「系统确实通知过」。这一点在处理投诉时很有用。另外要说清一个边界:如果队列堆积很久,恢复后要不要把过时的通知全发出去?我们的处理是按事件重要性判断——发货通知补发,「拣货中」这类过程通知直接丢弃,因为凌晨积压的通知在早上一次性推给用户,比不推更糟
    Q:这个模块和导出那个模块,有什么共同的经验? A:有一条很明确的:同一条规则在多个地方各自实现,一定会漏。导出那边是权限条件在列表和导出各写一份,漏了导致越权;这边是免打扰的判断散在短信和推送各自的发送逻辑里,短信加了推送没加,凌晨给用户推了消息。两次都不是「忘了写」这么简单,是设计允许人忘。所以两个模块最后的修法是同一个方向——把规则提到一个必经环节,让不经过它就走不下去:导出那边是统一的权限条件构造方法,这边是发送前的三道统一过滤。我从这两件事里带走的习惯是,做任何横切的规则(权限、脱敏、限流、免打扰)时,第一个问题都是「这条规则有几处实现」,如果超过一处,就先想办法收敛,而不是在每处都仔细一点。

模块三:操作日志与审计

  1. 操作日志与审计(切面统一采集 + 异步写入不丢 + 变更前后留痕 + 大表治理)★★
    简历这样写 后台操作日志与审计(Spring AOP + 异步落库 + 本地兜底 + 表分区与归档):原先只有零散的 log.info,出问题无法回答「谁在什么时候改了什么」,改为注解加切面统一采集——业务方法只声明操作类型与对象,采集与落库全在切面完成;记录变更前后的值而不只是操作类型(「把价格从 99 改成 9」比「修改了商品」有用得多),差异按字段级比对后存储;写入异步且不阻塞主流程,但队列满或落库失败时降级写本地文件,不静默丢弃;敏感字段复用导出那套脱敏策略;审计表只允许插入(应用账号无更新删除权限),按月分区并定期归档冷数据,配合必要索引支撑按人、按对象、按时间的查询。上线后一次错误改价的追溯从翻日志数小时变为后台直接查到操作人与改动前后值
    展开完整拆解
    为什么要这么设计

    做这个模块的直接起因是一次事故:一个商品的价格被改错了,从 99 改成 9,卖了一百多单才被发现。复盘时要回答三个问题——谁改的、什么时候改的、改之前是多少——结果三个都答不上来。

    当时的现状是:只有零散的 log.info("更新商品:" + id),散在各个方法里,格式不统一、内容不全、而且日志文件按天滚动只留几天。那次我们翻了几个小时日志,最后是靠一个同事回忆「好像是运营小张说要改活动价」才定位到人。这个过程说明的不是日志级别不够,而是压根没有审计这件事。

    四个具体问题:

    一是采集不全。写日志靠开发自觉,新写的接口经常忘。而且忘了这件事在事后才会暴露——出事时发现「这个操作压根没记」。

    二是只记了「做了什么」,没记「改成了什么」。「更新商品 12345」这条日志对追溯几乎没用,因为它不包含改动内容。真正需要的是「价格字段从 99 改成了 9」。

    三是写日志影响主流程。有一次日志表因为没有索引变得很慢,插入耗时上去了,而日志是同步写在业务事务里的,结果商品保存接口整体变慢。这个因果关系当时排查了很久才发现。

    四是日志能被改。应用账号有整库的读写权限,理论上能改能删审计记录。这在审计上是不成立的——可以被篡改的记录没有证明力。

    所以四个改动:注解加切面统一采集(不依赖开发记得写)、记录字段级的变更前后(让记录真的能用于追溯)、异步落库但失败要有兜底(不影响主流程又不丢)、数据库层面限制只能插入(保证不可篡改)。

    这个模块最想说的一句话是:审计和日志是两回事。日志是给开发看的、可以丢、格式随意;审计是给追责用的,要求完整、不可篡改、可长期检索。把审计当成「多打几条日志」来做,出事时一定不够用——我们那次就是。

    整体链路
    采集(注解 + 切面,不靠开发自觉) │ ├─ 业务方法上加注解:@AuditLog(type="商品改价", target="#id") │ 业务只声明「这是什么操作、对象是谁」 │ 谁操作的 / 什么时候 / 从哪个 IP / 入参出参,全由切面取 │ ├─ 需要变更前后值的操作,切面在方法执行前后各取一次快照 │ 前置:按 target 查一次当前对象 │ 后置:方法成功后再查一次 │ 两份做字段级 diff,只存真正变了的字段 │ ├─ 「为什么改」切面取不到,必须业务传 │ 改价的原因、审批单号这类,由入参显式带过来 │ 这是切面的能力边界,要说清楚 │ └─ 方法抛异常 → 也要记一条(失败的操作同样是审计对象) 脱敏(复用导出那套策略,不另写一份) └─ 变更前后的值里可能有手机号、地址 按「审计」这个用途查脱敏策略,统一处理 同一条规则不要在审计里再实现一遍 写入(异步不阻塞,但不丢) │ ├─ 切面里只把审计对象丢进内存队列,立刻返回 │ 绝不在业务事务里同步插审计表 │ ├─ 消费线程批量落库(攒批插入,减少往返) │ └─ 三级降级,重点是不静默丢弃: 队列满 → 直接写本地文件,后续补录 落库失败 → 退避重试,超次数写本地文件 本地也失败 → 打错误日志 + 告警(此时已经是很严重的问题) 存储(约束在数据库层面,不靠代码自觉) │ ├─ 应用账号只有 insert 与 select 权限,没有 update / delete │ 可以被改的记录没有证明力,这条必须由权限保证 │ ├─ 按月分区,冷分区定期归档到低成本存储 │ 审计要长期留存,但热查询只涉及最近一段 │ └─ 索引按真实查询场景建: 按操作人 + 时间 · 按对象类型 + 对象ID · 按操作类型 + 时间 不要为了「以后可能查」把所有字段都加索引,写入会被拖慢 查询(要能自助,否则又变成找开发) ├─ 后台提供查询界面:按人 / 按对象 / 按时间 / 按操作类型 ├─ 对象详情页挂一个「操作历史」入口,直接看这条数据被谁改过 └─ 查询本身也要记审计(谁查了谁的操作记录)
    分步拆解
    1. 用注解加切面,不靠开发在每个方法里手写。手写必然会漏,而且漏的那个恰好可能是出事的那个。注解的好处是声明式的、评审时能一眼看出哪些接口没加
    2. 切面能自动取到的和不能取到的要分清。操作人、时间、IP、入参、方法名、耗时、成功与否,这些切面都能取。但「为什么做这个操作」取不到,必须业务显式传——改价原因、审批单号。把这条边界说清楚,比声称「切面全自动」要诚实。
    3. 变更前后的值是这个模块的核心价值。「更新了商品」几乎没用,「价格从 99 改成 9」才能追溯。实现是切面在方法前后各查一次对象,做字段级 diff。只存变了的字段——存全量对象会让审计表膨胀得很快,而且看的时候要自己找哪里变了。
    4. 前置快照有代价,要按操作类型开关。它多一次查询,对高频接口是实打实的开销。所以注解上要能声明「这个操作需不需要记录变更前后」——查询类不需要,改价这类关键写操作必须要。
    5. 失败的操作也要记。「某人尝试改价但因为没权限失败了」这条记录在安全审计上很有价值。只记成功的操作会漏掉攻击和越权尝试的痕迹。
    6. 脱敏复用导出那套策略,不要在审计里再写一份。变更前后的值里可能有手机号和地址。同一条规则在两处实现就会不一致——这是我在导出模块已经踩过的坑,不能再踩一次。
    7. 写入必须异步,绝不能在业务事务里同步插。我们踩过:审计表变慢导致商品保存接口整体变慢,而这个因果关系很难排查(业务代码里看不出有慢查询)。异步之后审计的性能问题就不会传导到业务。
    8. 异步不等于可以丢,这是这个模块最容易做错的地方。很多实现是「丢进线程池,失败就打个日志」,那审计记录实际上是不可靠的,而出事时你需要它一定在。正确做法是三级降级:队列满写本地文件、落库失败重试后写本地文件、本地也失败才告警。本地文件后续可以补录,代价是需要一个补录的工具或脚本。
    9. 批量落库减少往返。审计量通常远大于业务量(一次业务操作可能对应多条审计),攒批插入是必要的。批大小和刷新间隔都要可配。
    10. 不可篡改要靠数据库权限保证,不能靠「代码里没写删除方法」。应用账号只给 insert 和 select。这一条是审计和普通日志的本质区别——如果记录能被改,它在追责场合就没有证明力。
    11. 按月分区加归档。审计要长期留存(合规可能要求数年),但热查询只涉及最近一段。不做分区的话表会大到查询和维护都困难。归档策略要和法务确认留存期限,不能自己定。
    12. 索引按真实查询场景建,不要贪多。三个组合索引通常够用:操作人加时间、对象类型加对象 ID、操作类型加时间。审计表是写多读少,每多一个索引都在拖慢写入,而写入侧的压力比查询大得多。
    13. 要提供自助查询界面,并在对象详情页挂「操作历史」入口。如果查审计还得找开发写 SQL,那这个模块的价值只实现了一半。而且查询本身也要记审计——谁查了谁的记录,这在敏感场景下是必要的。
    关键决策与取舍

    为什么不用数据库触发器或者 Binlog 来做审计。这两个方案确实能保证「不漏」,而且不侵入业务代码。但它们有个共同的问题:只能拿到数据变化,拿不到业务语义。Binlog 能告诉你「商品表某行的 price 从 99 变成 9」,但它不知道这是谁在什么入口做的、是改价操作还是活动生效、有没有审批单号。而追溯时最需要的恰恰是这些语义。所以我们选了应用层切面,代价是可能漏(有人写新接口忘了加注解),缓解办法是把注解检查放进代码评审的清单里。如果是要做数据合规层面的全量变更追踪,那 Binlog 是对的方案——两者目标不同,可以并存。这个区分我会主动说,因为面试官很可能拿 Binlog 来问。

    异步写入的三级降级,为什么不直接用消息队列。用队列更可靠(持久化、有重试),也是更「标准」的做法。但对当时的量级来说,引入队列意味着审计链路多依赖一个组件,而这个组件挂了审计一样会丢,只是把可靠性问题转移了。内存队列加本地文件兜底的组合,在单机故障时也能保住数据(文件在磁盘上),而且不依赖外部组件。取舍依据是「多引入一个依赖换来的可靠性提升有多少」——如果审计量涨到内存队列扛不住,那就该上队列了,这是个随量级变化的决定,不是永久结论。

    只存变化的字段,不存全量快照。存全量快照的好处是能完整还原任意时刻的对象状态。但代价是审计表膨胀极快(一个几十字段的商品,改一个价格要存两份完整对象),而且查看时要自己找哪里变了。我们选了存 diff,代价是无法还原完整历史状态——如果业务真需要「查看这个商品三个月前的完整样子」,那是版本化的需求,应该在业务表上做版本,不该靠审计表兜。把「追溯谁改了什么」和「还原历史版本」区分成两个需求,是这个取舍的关键。

    踩过的坑一:前置快照让一个高频接口明显变慢。我一开始给所有写操作都开了「记录变更前后」,其中有个高频的库存更新接口,每次多一次查询,QPS 高的时候压力很明显。修法是把它做成注解上的开关,按操作类型决定要不要前置快照——关键写操作要,高频的过程性更新不要。教训是「统一采集」和「无差别采集」是两件事,统一的是机制,采集的粒度应该按价值分级。

    踩过的坑二:异步写入丢了记录,而且是事后才知道。某次发布重启,内存队列里还没落库的记录全没了。更糟的是当时没有任何告警,我们是几天后查一个操作时发现那个时间段有空洞才意识到。修法有两条:加本地文件兜底、以及在应用关闭时把队列里剩余的记录刷完再退出(优雅停机)。第二条容易被忘,但它是重启场景下唯一的保障。

    踩过的坑三:审计表没有分区也没有归档,半年后查询开始超时。而且这时候做分区改造要停机迁移,成本比一开始就分区高得多。教训是「只增不删的表从第一天就要考虑分区和归档」——它的增长是确定的、单向的,不会自己变小,晚做只会更贵。

    踩过的坑四:应用账号有删除权限,一次误操作的脚本删掉了一批审计记录。虽然是测试环境,但它说明「代码里没写删除逻辑」不等于删不了。修法是收紧数据库账号权限——这是唯一可靠的保证,而不是靠约定和代码审查。

    没做的部分:没做审计记录的完整性校验(比如链式哈希,让任何篡改都能被检测)。金融和医疗行业会要求这个,我们的场景判断投入产出比不够——先靠权限保证不可改,链式校验是更高一档的需求。但我知道它防的是不同的事:权限防的是「应用被误用」,链式哈希防的是「有人绕过应用直接改库」。

    数字是怎么测的

    「追溯从数小时变为直接查到」:这不是性能指标,是能力的有无,所以我会用一个具体事件来陈述——上线后发生过一次改价争议,在后台按对象 ID 查操作历史,几分钟就定位到操作人、时间和改动前后的值。描述这个过程比给一个「效率提升 N 倍」的数字可信,因为改造前那件事压根没法量化(当时是靠人回忆定位的)。

    采集覆盖率:「加了注解的写接口数 / 全部写接口数」,这是个可数的比例。而且要说清没覆盖的是哪些以及为什么(比如内部定时任务的批量更新单独走了另一套记录)。诚实说出覆盖率不是 100% 比声称全覆盖要好,因为面试官一定会问「有没有漏的」。

    对主流程的影响:压测同一个业务接口,对比「关闭审计」「开启审计但不记变更前后」「开启并记变更前后」三种配置下的 P95。第三种一定会有额外开销(多一次查询),要如实报——这正好是我把它做成开关的理由。能说出「哪种配置多了多少开销,所以我按操作类型分级」,比声称「异步所以零开销」更有说服力,后者一被追问前置快照就穿了。

    异步不丢的验证用故障注入:模拟落库失败,断言记录被写到了本地文件;模拟应用关闭,断言队列里剩余记录被刷完。后一条是我踩过坑之后才补的用例,它验证的是优雅停机,很容易被忘。

    表增长与查询耗时:日均新增行数典型查询(按对象查历史、按人查当天操作)的耗时。前者用来说明为什么必须分区归档,后者说明索引设计是否够用。要说清表的当前量级,脱离量级谈查询耗时没有意义。

    不要报什么:不要报「审计零丢失」——你只能说有本地兜底和优雅停机,极端情况(磁盘满、机器宕机)仍然可能丢。不要报「不影响主流程」这种绝对说法——前置快照是有开销的,该说的是「开销可控且按操作类型分级」。这类诚实的表述在面试里反而是加分的。

    面试追问
    Q:为什么不用 Binlog 或者数据库触发器?那样不会漏。 A:因为它们只能拿到数据变化,拿不到业务语义。Binlog 能告诉我「商品表某行的 price 从 99 变成 9」,但拿不到这是谁在什么入口做的、是人工改价还是活动自动生效、有没有审批单号、当时的请求 IP——而追溯时最需要的恰恰是这些。触发器还有个额外问题:它跑在数据库里,会增加事务耗时,而且逻辑藏在数据库里很难维护和版本管理。所以我选了应用层切面,代价是可能漏(新接口忘加注解),缓解办法是把注解检查放进代码评审清单,以及定期用反射扫一遍写接口看哪些没加。但我要补一句:如果目标是数据合规层面的全量变更追踪(任何途径改的数据都要留痕,包括 DBA 直接改库),那 Binlog 才是对的方案——两者目标不同、可以并存。我这个模块解决的是「业务操作的追责」,不是「数据变更的全量捕获」。
    Q:异步写入,那审计记录可能丢,出了事故正好丢了那条怎么办? A:这个风险是真实存在的,我不会说「不会丢」。我做的是把丢的概率压到很低,并且让丢这件事可被发现。具体三层:队列满或落库失败时降级写本地文件(磁盘比内存可靠,后续能补录);应用关闭时把队列刷完再退出(这条是我踩过坑之后补的——某次发布重启,队列里的记录全没了,而且几天后才发现);本地也写失败才告警,此时已经是很严重的问题。更重要的是第三点:要有办法发现空洞。我们的做法是审计记录带连续的序号,定期扫描断号——因为「丢了但不知道」比「丢了」更危险,前者会让你在事故复盘时才发现证据没了。至于为什么不同步写以彻底避免丢:同步写会把审计的性能问题传导到业务,我们踩过这个坑(审计表慢导致商品保存接口变慢)。这是个明确的取舍——业务可用性优先于审计完整性,但要为完整性做足兜底。
    Q:这个模块技术上最简单,你为什么觉得它值得写进简历? A:因为它是三个模块里唯一由一次真实事故驱动的,而且它改变了我对「日志」这件事的理解。做之前我以为审计就是多打几条 log.info;那次改价事故我们翻了几个小时日志、最后靠同事回忆才定位到人,我才明白审计和日志是两回事——日志给开发看、可以丢、格式随意;审计给追责用,要求完整、不可篡改、可长期检索。这个区分决定了三个具体设计:必须记变更前后的值(不然记了也没用)、必须靠数据库权限保证不可改(能被改的记录没有证明力)、必须有兜底(出事时它必须在)。技术难度确实不高,但每个决定背后都有明确的理由,而这些理由是从一次真实的失败里长出来的。我认为面试里这类经历比复杂度更有说服力,因为它证明的是判断力而不是熟练度。

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

项目拆解 · 运营后台支撑(后端 · 实习级)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据