offset 改为按主键游标,写文件改为分批流式落盘,堆内存占用与数据量基本解耦。导入因有副作用而与导出不同,采用先全量校验、再分批落库两段式,避免「前一半成功后一半失败」的半成品数据,校验不通过时生成带行号列名与原因的错误文件供运营自行修正重传;以文件内容摘要做幂等,重复上传不产生重复数据。导出的数据范围与列表接口共用同一套权限条件构造(原各写一份导致越权),脱敏按导出用途分级。改造后单次导出 30 万行可稳定完成,导入相关的求助工单明显下降。
第一版导出是最直觉的写法:一个接口查出全部数据、写成 Excel、直接返回。数据几千行时完全没问题,跑了三个月,随着订单量增长四个问题一起冒出来。
一是接口超时。网关和浏览器都有超时限制,导十几万行要跑一两分钟,用户看到「请求失败」但服务端其实还在跑。更糟的是他会重试,点三次就有三个任务在同时查库。
二是内存打爆。数据查进 List、Excel 库在内存里构建完整工作簿、再一次性写出,同一份数据在内存里存在了两三份。有一次运营同时导两个大报表,服务直接 OOM 重启,影响了整个后台。这是我踩过最严重的一次。
三是分页越翻越慢。改成 limit offset, size 循环之后,offset 很大时数据库要扫过前面所有行再丢掉,翻到第五十页时单页耗时是第一页的几十倍。
四是过程不可见。用户点了导出,页面转圈,不知道要等多久、也不知道是不是已经挂了。
所以导出改成「一个任务」而不是「一次请求」:请求只创建任务并立刻返回,后台线程池慢慢干,结果落对象存储。四个改动对应四个问题:任务化解决超时和重复提交、流式写入解决内存、游标分页解决越翻越慢、进度上报解决不可见。
然后是导入,它的坑更深,因为我一开始把它当成导出的镜像来写。读文件、循环、逐行插库——上线第一周就出了半成品数据:一千行的文件第六百行价格填成负数,抛异常中断,前五百九十九行已经进库了,而运营不知道哪些进去了。他重传整个文件,前面那些又插一遍,变成重复数据。
根源是导出和导入的性质完全不同:导出只读,失败重跑无代价,幂等天然成立;导入有副作用,一次失败会留痕,重跑必须考虑已经做过的部分。想清楚这个不对称之后方案就明确了——先全量校验、全部通过才开始写,把「部分成功」这个最难处理的状态尽量消灭;校验失败输出带行号的错误文件,把定位问题的能力交给运营;用文件内容摘要做幂等,重复上传直接返回上次结果。
第三件事是权限和脱敏,这块是被别人发现的。列表页有权限过滤(区域运营只看自己区域),但导出接口是我另写的方法,权限条件漏了——我用管理员账号测,看不出问题。上线两周后区域运营导出时发现表里有别的区域的订单。本质原因是同一条规则在两个地方各写了一遍。而且界面上手机号是掩码,导出的 Excel 里却是全码,后来有人把那份表转发给了外部物流商。
where id > lastId order by id limit n,每批的代价恒定。前提是排序字段有索引且唯一;如果业务要按时间排序,用「时间 + 主键」复合游标,避免同一时间戳的行被跳过或重复。in 查询通常能让校验耗时降一个量级。为什么不用「同步导出 + 调大超时」这个更省事的方案。把网关超时从 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%」(安全没有这种量纲)。
接手之前,通知是这样写的:订单状态变更的代码里直接调短信 SDK,发货的代码里再调一次,退款的代码里又调一次。每个地方各写一份,参数各拼一份,文案硬编码在代码里。四个问题很快暴露。
一是重复轰炸用户。支付成功的消息队列消费者做了重试,重试时又发了一次短信。有个用户一次支付收到了五条「支付成功」短信,投诉到客服。查出来是消费重试三次加上另一处代码也监听了同一个事件。这是最严重的一个问题,因为它直接伤害用户。
二是改文案要发版。运营想把「您的订单已发货」改成带商品名的版本,因为文案在代码里,改一个字要走一次发布流程。这类需求每周都有,开发和运营都很痛苦。
三是短信费用失控。所有通知都发短信,因为「短信最可靠」。月底账单出来,大部分短信是在通知一些用户压根不关心的状态变更(比如「订单已进入仓库拣货」)。而这些用户当时正在 App 里,站内信就够了。
四是没有退订,被合规提醒了。营销类的通知没有退订入口,用户想不收只能拉黑号码。这在合规上是有要求的,不是体验优化。
所以重构的思路是把「业务发生了什么」和「怎么通知」彻底分开:业务只负责发布一个领域事件(订单已支付、订单已发货),它不知道也不该知道会发几条短信;通知服务订阅事件,查配置决定发哪些渠道、用哪个模板、要不要受退订和免打扰限制。
四个改动对应四个问题:事件驱动 + 幂等键取事件 ID(解决重复)、模板配置化(解决发版)、渠道分级降级(解决费用)、按类型区分可退订(解决合规)。
其中最关键的判断是幂等键怎么取。我最初想用「用户 + 消息内容」的哈希做去重,但这是错的——同一个用户先后收到两条内容相同的「订单已发货」,可能真的是两个订单。正确的键是业务事件 ID(订单号 + 事件类型 + 事件发生的版本或时间戳),它才能精确表达「这是同一件事的重复通知」。
smsClient.send(...),那说明没解耦。业务只发事件,「发不发短信」是通知服务的决定。这样做的额外收益是:以后要加一个渠道(比如企业微信),业务代码一行都不用改。{orderNo} 原样发给用户——这个事故很常见而且很丢脸。为什么走事件驱动而不是让业务直接调通知服务的接口。直接调接口更简单,链路也更短(少一次消息投递)。但它有两个问题:一是业务和通知的可用性耦合——通知服务挂了,如果是同步调用,业务的下单流程会跟着受影响;二是业务方要知道「这个动作要发通知」,等于把通知的决策权分散回了业务代码里。事件驱动的代价是引入了消息队列的复杂度和最终一致性(通知可能晚几秒),但通知本来就不要求强实时,这个代价可以接受。判据是「通知失败该不该影响主业务」——答案是不该,那就必须异步解耦。
渠道分级降级 vs 让运营在配置里自由勾选渠道。后者更灵活,运营想发什么就发什么。但实际结果是运营会把所有渠道都勾上——因为对他来说多发没坏处,成本不在他的考核里。所以我们把降级规则做成代码里的固定策略,运营只能配「这个事件重要不重要」,重要性决定是否允许短信补发。这是有意收回一部分自由度,理由是成本责任和配置权限不在同一个人手上。这个取舍我觉得值得说,因为它体现的是对真实组织行为的判断,不只是技术选择。
频次上限设成硬拦截还是只告警,我们选了硬拦截加告警。硬拦截有风险——万一某个用户真的一天下了很多单,正常通知会被拦掉。但对比另一边的风险(上游 bug 导致给用户发几十条短信,既花钱又招投诉),宁可拦掉少数正常通知。而且拦掉的会记录并告警,可以人工补发。这类「防事故」的闸门应该倾向于宁可误拦。
踩过的坑一:幂等键最初用消息内容做哈希,导致该发的没发。用户两个订单先后发货,两条通知内容完全一样(模板里没带订单号),第二条被幂等挡掉了。用户投诉说没收到发货通知。教训是幂等键必须表达「业务事件的唯一性」,而消息内容只是事件的一种呈现——呈现相同不代表事件相同。这个坑让我理解了幂等键的设计要从业务语义出发,不能图省事拿手边的数据去哈希。
踩过的坑二:模板里的变量没渲染,把 {userName} 发给了用户。原因是模板里加了一个新变量,但调用方没传。修法是渲染时校验变量完整性,缺任何一个就拦下不发并告警,而不是发一个残缺的消息出去。这里的判断是:不发比发错好——用户没收到通知会来问,收到一条乱码消息会觉得这个平台不专业,后者更伤。
踩过的坑三:夜间免打扰只挡了短信,忘了挡推送。凌晨三点给用户推了一条「订单已进入仓库」。修法之外更值得说的是:这个 bug 是因为免打扰的判断散在了两个渠道各自的发送逻辑里,短信那边加了,推送那边没加。又是「同一条规则在多处实现」这个模式——和导出的权限漏洞是同一类错误。后来把免打扰、退订、频次三道过滤统一提到发送之前的一个必经环节。
踩过的坑四:只统计了「调用渠道接口成功」,以为送达率很高。后来接了回执才发现有一部分是空号、停机、被运营商拦截的。「发送成功」和「用户收到」是两个指标,混在一起报会得出一个虚高的数字——而且这个数字会让你误判「通知链路很健康」,从而错过真实的问题。
没做的部分:没做用户偏好的细粒度配置(比如「我只想收发货通知,不想收物流中转」)。方向是对的,但配置项一多用户压根不会去设,收益有限。当时的判断是先把「营销可退订、交易不可退」这个粗粒度做扎实,细粒度等有明确需求再说。另外也没做消息聚合(把同一用户短时间内的多条通知合并成一条),这个在通知量更大的产品里是必需的。
「重复通知不再出现」用构造用例验证,不用线上统计。造一个场景:同一个业务事件重复投递多次(模拟消息队列重试),断言只产生一条通知记录、只调用渠道一次。同时要测反面:两个不同订单的同类事件,断言各自都发了——这一条防的是幂等过度拦截,也就是我踩过的那个坑。两个方向都测才算测完。
短信占比:报「短信条数 / 总通知条数」的变化,而不是报省了多少钱。理由是费用受单价和业务量影响,占比才反映策略本身的效果。还要说清统计口径是哪些事件类型——如果只统计了本来就不发短信的那些,这个数字没有意义。
送达率要和发送成功率分开报。发送成功率是「调渠道接口返回成功的比例」,送达率是「拿到成功回执的比例」,两者之间的差值本身就是个有价值的指标——差值大说明号码质量或渠道质量有问题。只报一个数字会掩盖这件事。
「通知相关发版次数减少」:这是可维护性的改善,报绝对次数的变化(改文案从每次发版变成配置生效)。这个陈述是可核对的,比任何百分比可信。
频次上限的验证:构造一个用户短时间内触发大量同类事件的场景,断言超出上限后被拦截、且产生了告警记录。拦截本身要能被观测到,否则你不知道它有没有在误伤正常用户。
不要报什么:不要报「通知到达率 99%」——除非你真的接了全部渠道的回执并且知道分母是什么,这个数字很容易被质疑。不要报「节省成本 N 万」——那需要财务口径,而且和业务量强相关。该报的是「短信占比下降」和「重复通知的投诉不再出现」,前者是可算的比例,后者是可核对的事实。
log.info,出问题无法回答「谁在什么时候改了什么」,改为注解加切面统一采集——业务方法只声明操作类型与对象,采集与落库全在切面完成;记录变更前后的值而不只是操作类型(「把价格从 99 改成 9」比「修改了商品」有用得多),差异按字段级比对后存储;写入异步且不阻塞主流程,但队列满或落库失败时降级写本地文件,不静默丢弃;敏感字段复用导出那套脱敏策略;审计表只允许插入(应用账号无更新删除权限),按月分区并定期归档冷数据,配合必要索引支撑按人、按对象、按时间的查询。上线后一次错误改价的追溯从翻日志数小时变为后台直接查到操作人与改动前后值。
做这个模块的直接起因是一次事故:一个商品的价格被改错了,从 99 改成 9,卖了一百多单才被发现。复盘时要回答三个问题——谁改的、什么时候改的、改之前是多少——结果三个都答不上来。
当时的现状是:只有零散的 log.info("更新商品:" + id),散在各个方法里,格式不统一、内容不全、而且日志文件按天滚动只留几天。那次我们翻了几个小时日志,最后是靠一个同事回忆「好像是运营小张说要改活动价」才定位到人。这个过程说明的不是日志级别不够,而是压根没有审计这件事。
四个具体问题:
一是采集不全。写日志靠开发自觉,新写的接口经常忘。而且忘了这件事在事后才会暴露——出事时发现「这个操作压根没记」。
二是只记了「做了什么」,没记「改成了什么」。「更新商品 12345」这条日志对追溯几乎没用,因为它不包含改动内容。真正需要的是「价格字段从 99 改成了 9」。
三是写日志影响主流程。有一次日志表因为没有索引变得很慢,插入耗时上去了,而日志是同步写在业务事务里的,结果商品保存接口整体变慢。这个因果关系当时排查了很久才发现。
四是日志能被改。应用账号有整库的读写权限,理论上能改能删审计记录。这在审计上是不成立的——可以被篡改的记录没有证明力。
所以四个改动:注解加切面统一采集(不依赖开发记得写)、记录字段级的变更前后(让记录真的能用于追溯)、异步落库但失败要有兜底(不影响主流程又不丢)、数据库层面限制只能插入(保证不可篡改)。
这个模块最想说的一句话是:审计和日志是两回事。日志是给开发看的、可以丢、格式随意;审计是给追责用的,要求完整、不可篡改、可长期检索。把审计当成「多打几条日志」来做,出事时一定不够用——我们那次就是。
为什么不用数据库触发器或者 Binlog 来做审计。这两个方案确实能保证「不漏」,而且不侵入业务代码。但它们有个共同的问题:只能拿到数据变化,拿不到业务语义。Binlog 能告诉你「商品表某行的 price 从 99 变成 9」,但它不知道这是谁在什么入口做的、是改价操作还是活动生效、有没有审批单号。而追溯时最需要的恰恰是这些语义。所以我们选了应用层切面,代价是可能漏(有人写新接口忘了加注解),缓解办法是把注解检查放进代码评审的清单里。如果是要做数据合规层面的全量变更追踪,那 Binlog 是对的方案——两者目标不同,可以并存。这个区分我会主动说,因为面试官很可能拿 Binlog 来问。
异步写入的三级降级,为什么不直接用消息队列。用队列更可靠(持久化、有重试),也是更「标准」的做法。但对当时的量级来说,引入队列意味着审计链路多依赖一个组件,而这个组件挂了审计一样会丢,只是把可靠性问题转移了。内存队列加本地文件兜底的组合,在单机故障时也能保住数据(文件在磁盘上),而且不依赖外部组件。取舍依据是「多引入一个依赖换来的可靠性提升有多少」——如果审计量涨到内存队列扛不住,那就该上队列了,这是个随量级变化的决定,不是永久结论。
只存变化的字段,不存全量快照。存全量快照的好处是能完整还原任意时刻的对象状态。但代价是审计表膨胀极快(一个几十字段的商品,改一个价格要存两份完整对象),而且查看时要自己找哪里变了。我们选了存 diff,代价是无法还原完整历史状态——如果业务真需要「查看这个商品三个月前的完整样子」,那是版本化的需求,应该在业务表上做版本,不该靠审计表兜。把「追溯谁改了什么」和「还原历史版本」区分成两个需求,是这个取舍的关键。
踩过的坑一:前置快照让一个高频接口明显变慢。我一开始给所有写操作都开了「记录变更前后」,其中有个高频的库存更新接口,每次多一次查询,QPS 高的时候压力很明显。修法是把它做成注解上的开关,按操作类型决定要不要前置快照——关键写操作要,高频的过程性更新不要。教训是「统一采集」和「无差别采集」是两件事,统一的是机制,采集的粒度应该按价值分级。
踩过的坑二:异步写入丢了记录,而且是事后才知道。某次发布重启,内存队列里还没落库的记录全没了。更糟的是当时没有任何告警,我们是几天后查一个操作时发现那个时间段有空洞才意识到。修法有两条:加本地文件兜底、以及在应用关闭时把队列里剩余的记录刷完再退出(优雅停机)。第二条容易被忘,但它是重启场景下唯一的保障。
踩过的坑三:审计表没有分区也没有归档,半年后查询开始超时。而且这时候做分区改造要停机迁移,成本比一开始就分区高得多。教训是「只增不删的表从第一天就要考虑分区和归档」——它的增长是确定的、单向的,不会自己变小,晚做只会更贵。
踩过的坑四:应用账号有删除权限,一次误操作的脚本删掉了一批审计记录。虽然是测试环境,但它说明「代码里没写删除逻辑」不等于删不了。修法是收紧数据库账号权限——这是唯一可靠的保证,而不是靠约定和代码审查。
没做的部分:没做审计记录的完整性校验(比如链式哈希,让任何篡改都能被检测)。金融和医疗行业会要求这个,我们的场景判断投入产出比不够——先靠权限保证不可改,链式校验是更高一档的需求。但我知道它防的是不同的事:权限防的是「应用被误用」,链式哈希防的是「有人绕过应用直接改库」。
「追溯从数小时变为直接查到」:这不是性能指标,是能力的有无,所以我会用一个具体事件来陈述——上线后发生过一次改价争议,在后台按对象 ID 查操作历史,几分钟就定位到操作人、时间和改动前后的值。描述这个过程比给一个「效率提升 N 倍」的数字可信,因为改造前那件事压根没法量化(当时是靠人回忆定位的)。
采集覆盖率:报「加了注解的写接口数 / 全部写接口数」,这是个可数的比例。而且要说清没覆盖的是哪些以及为什么(比如内部定时任务的批量更新单独走了另一套记录)。诚实说出覆盖率不是 100% 比声称全覆盖要好,因为面试官一定会问「有没有漏的」。
对主流程的影响:压测同一个业务接口,对比「关闭审计」「开启审计但不记变更前后」「开启并记变更前后」三种配置下的 P95。第三种一定会有额外开销(多一次查询),要如实报——这正好是我把它做成开关的理由。能说出「哪种配置多了多少开销,所以我按操作类型分级」,比声称「异步所以零开销」更有说服力,后者一被追问前置快照就穿了。
异步不丢的验证用故障注入:模拟落库失败,断言记录被写到了本地文件;模拟应用关闭,断言队列里剩余记录被刷完。后一条是我踩过坑之后才补的用例,它验证的是优雅停机,很容易被忘。
表增长与查询耗时:报日均新增行数和典型查询(按对象查历史、按人查当天操作)的耗时。前者用来说明为什么必须分区归档,后者说明索引设计是否够用。要说清表的当前量级,脱离量级谈查询耗时没有意义。
不要报什么:不要报「审计零丢失」——你只能说有本地兜底和优雅停机,极端情况(磁盘满、机器宕机)仍然可能丢。不要报「不影响主流程」这种绝对说法——前置快照是有开销的,该说的是「开销可控且按操作类型分级」。这类诚实的表述在面试里反而是加分的。
log.info;那次改价事故我们翻了几个小时日志、最后靠同事回忆才定位到人,我才明白审计和日志是两回事——日志给开发看、可以丢、格式随意;审计给追责用,要求完整、不可篡改、可长期检索。这个区分决定了三个具体设计:必须记变更前后的值(不然记了也没用)、必须靠数据库权限保证不可改(能被改的记录没有证明力)、必须有兜底(出事时它必须在)。技术难度确实不高,但每个决定背后都有明确的理由,而这些理由是从一次真实的失败里长出来的。我认为面试里这类经历比复杂度更有说服力,因为它证明的是判断力而不是熟练度。
没有匹配的内容,换个关键词试试。
项目拆解 · 运营后台支撑(后端 · 实习级)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据