第三十二周的周报我拖到周四深夜才动笔。不是没东西写,恰恰相反,这周经历了订单模块重构收尾、报表导出功能发布、还有一次线上接口超时的排查,随便挑一件都够写两千字。但真正坐下来打开文档的时候,我反而反复删了好几版——原因是周报这东西,写浅了像流水账,写深了没人看,这个度很难拿捏。后来我想明白了,周报真正的作用不是汇报,而是把本周的决策、踩坑、数据变化这些"依据"沉淀下来。这篇就把第三十二周的工作做个完整复盘,既算给你看,更算给未来的自己看。
1. 第三十二周工作全景:先说结论再讲细节
1.1 本周核心目标与完成情况
第三十二周恰好处在我们产品线一个迭代周期的中段。上周迭代评审会定下了三个核心目标:订单模块重构的收尾、报表导出功能的正式上线、以及支付回调成功率从99.92%提升到99.95%以上。
到周五下班前盘点,三个目标里两个完成、一个延迟。订单模块重构按期合入主干,报表导出功能完成灰度发布,支付回调成功率的优化目标最终卡在99.94%,差了0.01个百分点。这个结果不算漂亮,但过程里冒出来的问题和对应的处理方式,比结果本身更有价值。
先放一张本周的周报速览表,方便你快速抓重点:
| 目标项 | 状态 | 关键进展 | 遗留问题 |
|---|---|---|---|
| 订单模块重构 | 已完成 | 核心链路重构完毕,单元测试覆盖率提升至87% | 部分旧接口需继续兼容两个版本 |
| 报表导出功能 | 已完成 | 异步导出链路上线,灰度放量到30% | 超大日期范围导出需限流保护 |
| 支付回调优化 | 进行中 | 定位到Redis连接池瓶颈并完成修复 | 还剩0.03%的失败样本待分析 |
1.2 为什么这周值得单独拿出来复盘
可能有人会觉得,"第三十二周"这个数字没什么特别含义,撑死是Q3中间某一周。但恰恰因为处在季度中段,很多问题容易在这个时间点被掩盖:上半年定的年度目标已经淡化了,Q3的阶段性压力还没完全传导下来,团队容易进入一种"匀速前进"的节奏。这种节奏之下,技术债、流程漏洞、沟通损耗会悄悄积累,等到Q4再集中爆发。
这周就挺典型。两个项目能按计划交付,靠的不是大家加班,而是前几周把设计做透了;但支付回调的偶发超时问题,恰恰是因为新功能上线的同时,共用了底层的Redis连接池,暴露出了容量规划上的盲区。所以我写这篇复盘,想把"按计划交付"背后的设计取舍,和"偶发故障"背后的排查思路都拆开讲清楚,下周也好给团队做一次内部分享。
2. 核心项目实录:异步报表导出功能的完整落地过程
2.1 需求分析与方案选型:为什么不做同步导出
报表导出这个需求,表面上看非常简单:用户在前端选择时间范围和订单状态,点击"导出",后端把符合条件的订单列表生成Excel文件返回下载。第一版设计稿里,产品同学画的流程就是同步的,前端发请求、后端查库生成Excel、直接返回文件流。
我看了之后没有直接签字,而是先拉着研发一起做了个简单的容量评估。我们的订单表主表一年大概有4000万行数据,单日订单峰值在15万左右。如果用户选的是"最近三个月+全部状态",一次导出可能要扫600万行数据,即使只取需要导出的30个字段,JVM里临时对象也会有几百MB,复杂查询的耗时按经验推算至少是20秒往上。
这在同步方案下会带来三个直接问题:第一,API网关层配置的超时时间是30秒,但数据量波动大,超过超时时间会导致客户端重试,重试又会触发重复查询,形成恶性循环;第二,同步请求会占用Tomcat的请求线程,期间连接一直挂着,线程池被导出一占,普通查询接口的响应就会跟着变慢;第三,用户等不了20秒,如果中途关掉页面,请求虽然取消了,但服务端的查询和文件生成并不会自动中断,白白消耗资源。
所以最后定的是异步导出方案:前端提交导出请求后,后端只创建一个导出任务并立即返回任务ID,前端每隔3秒轮询任务状态,任务完成后拿到文件下载地址。用户在这期间可以继续做其他操作,体验上更像"提交了一个后台任务"。方案转换的代价是前端多写一套轮询逻辑,后端多建一张任务表和一个线程池,但把这三个问题全部规避掉了。
2.2 线程池参数计算与异步任务实现
异步方案里最先要落地的是线程池。直接用Executors.newFixedThreadPool(10)当然可以,但我在这上面吃过亏。无界队列意味着高峰期的所有导出任务都会堆积在内存里,任务一多,轻则任务延迟,重则直接OOM。
这次我手动创建了一个ThreadPoolExecutor,参数是这样的:
ThreadPoolExecutor exportExecutor = new ThreadPoolExecutor( 4, 8, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(200), new ThreadFactoryBuilder().setNameFormat("export-task-%d").build(), new ThreadPoolExecutor.CallerRunsPolicy() );参数是我按线上情况算的。核心线程数设为4,是因为我们导出的机器是4核8G的容器,核心线程数不超过CPU核数,避免无谓的线程上下文切换。最大线程数放宽到8,理由是导出任务不是纯计算型任务,里面有IO等待(查数据库、写Excel、传OSS),适当增加线程可以提升吞吐。
队列容量选了200,这个数字不算拍脑袋。我们按导出的平均耗时估算过,一个任务大约需要8到12秒,4个核心线程每秒最多能处理约0.4个任务,一分钟也就24个。200的队列意味着理论上可以缓冲8分钟的任务量,对绝大多数场景足够。如果有超过这个量的突发需求,说明需要从产品层面限流,而不是盲目扩充队列。
拒绝策略这里有个讲究。我用了CallerRunsPolicy,意思是队列满了以后,新提交的任务不再进入线程池,而是由提交任务的线程自己执行。我们给导出任务建的提交线程是接口请求线程,这样做的效果是:系统负荷过高时,接口响应自然变慢,用户感知到"提交没反应"就会减少请求量,相当于一个天然的反压机制。比起丢弃任务或者直接抛异常,这个策略在导出这个场景更安全,至少任务不会丢。
任务表的DDL也值得记一笔,字段不多但都踩过坑:
CREATE TABLE export_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id VARCHAR(32) NOT NULL COMMENT '业务任务ID', user_id BIGINT NOT NULL COMMENT '提交用户', task_type TINYINT NOT NULL COMMENT '0=订单导出 1=对账单导出', params_json TEXT NOT NULL COMMENT '导出参数快照', status TINYINT NOT NULL DEFAULT 0 COMMENT '0=等待 1=处理中 2=成功 3=失败', file_url VARCHAR(512) DEFAULT NULL COMMENT '文件下载地址', error_msg VARCHAR(512) DEFAULT NULL COMMENT '失败原因', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_user_id (user_id), KEY idx_status (status), KEY idx_created_at (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;params_json这个字段一开始想用多列存储,后来改成JSON快照,好处是以后增加导出条件不需要改表结构。task_id用UUID生成,通过接口返回给前端,查询任务状态时用它做唯一标识。
2.3 生成本地文件到OSS上传:一个容易被忽视的细节
导出任务处理过程中,最容易出的问题不是查询也不是生成Excel,而是"文件放哪"。最开始有同事提议直接把生成的文件写到服务器本地磁盘,然后下载接口再从磁盘读取返回。这个方案在开发环境跑完全没问题,一上线就会翻车——我们的部署平台是无状态的,容器随时可能被重新调度,本地磁盘文件说没就没,而且多副本部署时,文件只在其中一台机器上,下载请求如果被路由到另一台机器就会404。
所以文件必须放在对象存储上。我的实现流程是:先在线程池里把数据查询出来,用EasyExcel写到本地临时目录,文件完全写好后上传到OSS,上传成功后再把file_url更新到任务表并置状态为成功。
流程里有两个小细节值得说。第一,临时的本地文件命名一定要带taskId前缀,否则不同用户同时导出时容易互相覆盖。第二,OSS的bucket要设生命周期规则,我们规定导出文件保留7天自动清理,否则用户反复导出,存储成本会无节制上涨。
上传的关键代码里,我会强调传给OSS的contentType:
// 使用阿里云OSS SDK,核心配置省略 String key = "export/" + taskId + "/" + fileName; ossClient.putObject(bucketName, key, tempFile);这里有个隐蔽的坑:如果不显式设置Content-Type,OSS默认按application/octet-stream处理,用户在浏览器点下载链接时会直接下载而不是预览,某些场景下还会出现中文文件名乱码。建议生成文件时把Content-Type设置为application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,下载的Excel文件就不会出幺蛾子。
3. 线上问题排查:支付回调偶现超时的根因与修复
3.1 问题表象:一条看起来像偶发的告警
周三下午两点多,告警群突然弹出通知:支付回调接口的P99延迟从正常值的120ms涨到接近980ms,持续了约15分钟后自行恢复。当时值班同事的第一反应是慢SQL,直接去慢查询日志里翻了半天,一条超过500ms的查询都没有。
这种"偶发、短暂、会自愈"的故障最让人头疼。你还没来得及抓现场,它自己就好了;等你放松警惕,它又不定什么时候冒出来。那两天我让值班同事保留了一个原则:任何接口异常,哪怕已经恢复,也要保留当时的线程栈和日志上下文,为后续定位做素材。事后证明这个原则救了大忙。
3.2 排查过程:从GC到Redis连接池
我当时按这个顺序排查:先看监控面板,确认所有应用实例的表现是不是一致的——如果只有一台机器异常,多半是单机问题;如果所有机器同时异常,基本可以排除GC毛刺和本地资源问题。监控显示两台实例的耗时同步上涨,所以方向锁定在共享依赖上。
接着看GC日志。CMS或G1的remark/mixed GC阶段偶尔出现几百毫秒的停顿很正常,但不会导致P99飙到接近一秒。日志显示最近没有明显的大停顿,这条线先排除。
然后看外部依赖的耗时。支付回调链路里依赖三样东西:数据库、Redis、下游支付网关。数据库没有慢SQL,支付网关的监控表现平稳,唯一异常的是Redis:那段时间Redis命令平均耗时从1ms涨到了35ms,但Redis服务器的CPU和内存指标都很健康,说明问题不在Redis本身,而在客户端——也就是应用与Redis的连接层面。
最后在Arthas里查了JedisPool的状态,active连接数长时间顶在200附近,大量线程在acquire方法上等待。到这里原因已经很清晰了:连接池被打满了。
为什么会被打满?这就要说到本周两个项目之间的互相影响。支付回调里有一个幂等校验逻辑,每次回调都会查一次Redis;而新上线的报表导出功能,在异步任务里也要读Redis做数据权限校验。两套逻辑默认共用了同一个JedisPool实例,配置里的maxTotal=200本来按旧流量是够用的,但导出功能上线后,新增的并发请求直接把连接池剩余容量吃光了。
3.3 修复思路与连接池参数调整
修复分两步走。第一步是快速止血,把连接池的maxTotal从200调到500,maxIdle从100调到200,同时把minEvictableIdleTime调短一点,加速空闲连接回收。这个调整在周三当天生效,支付回调的P99立刻回落到130ms以内。
但调整参数只是治标。更关键的是第二步:把报表导出的Redis访问拆到独立的连接池实例,从物理上隔离业务之间的资源竞争。我让同事在公共的Redis配置类里增加一个exportRedisPool的Bean,所有导出相关代码通过独立的连接池访问Redis,支付回调链路继续用原来的池子。这样即使导出任务把它的池子占满,也不会影响支付回调。
这一步做完,我又让团队在监控面板上加了连接池活跃度和等待线程数的指标,并且配置了独立告警阈值:当池子使用率超过80%且持续5分钟时,告警会提前触发,不用等接口慢到异常才被动响应。
关于参数计算,我补一下思路。连接池maxTotal不是越大越好,太大了也不一定是好事,因为每一条连接在Redis服务端都要占用内存和文件描述符。合理估算方式是:maxTotal = 高峰期QPS × 单请求平均耗时 / 1000 × (1 + 20%冗余)。我们支付回调高峰期QPS按400算,平均耗时50ms,算下来需要约24个连接,原来200其实是够的。问题在于没把新增的导出流量算进共用池里,导出的并发和支付回调叠加后,峰值瞬间超过200。所以容量规划必须按"所有共享方峰值之和"来计算,而不是只看单个链路的均值。
4. 团队协作与周报写法复盘
4.1 需求变更处理:一次"善意"加需求引发的连锁反应
周二下午,产品同学过来沟通,说希望在报表导出功能里加一个"包含退款订单"的过滤选项。这个需求听起来很小,就一个复选框的事。但真正评估下来,涉及查询SQL的改动、前端筛选项的调整、导出模板的列变动,还要补一批退款订单的测试数据,整体至少要多出两到三天的工作量。
当时团队里有两个声音:一个觉得"顺手做掉,反正导出都上线了,加个条件不算难";另一个认为应该排到下一个迭代。我最后拍板:这周不加,但把设计文档和测试用例提前准备好,下迭代开工直接复用。理由很简单:本周已经有两个核心目标在收尾,临时插入看起来再小的需求,都会打断所有人的上下文切换,还容易让支付回调优化这个本来就紧张的目标进一步延期。产品同学当时有点不情愿,事后复盘时他也承认,如果当时顺手做了,很可能导致导出功能灰度期间的回归问题没人管。
这里我总结的经验是:需求变更管理里,最危险的其实不是变更本身,而是"看起来很小的变更"。越小的变更越容易被低估、被顺手做掉,然后在不经意的地方引发连锁反应。
4.2 复盘感悟:周报写的是"决策记录",不是流水账
第三十二周的周报,我修改了三版才最终定稿。第一版是标准的流水账式写法:周一改代码,周二联调,周三排查线上问题,周四写文档。写完自己读了一遍,感觉像工作日志,完全没有信息增量,领导看完只会知道你"干了活",但不知道你"解决了什么问题"。
于是重写,改成"结果+决策+下一步"的结构。每个项目下面不再写"做了什么",而是写"遇到了什么选择、为什么这么选、结果如何、下一步要怎么做"。比如报表导出这件事,核心不是"我实现了异步导出",而是"我为什么否决了同步方案、线程池参数怎么定的、文件为什么必须上OSS"。这些才是以后可以拿出来复用、值得让团队其他人看到的内容。
我后来还养成一个习惯:每周周报里固定有一节叫"本周最重要的一个决策"。如果这一周想不起来有什么值得一提的决策,说明这一周的产出质量需要打问号。这个习惯倒逼我在日常工作中更留意自己做的选择和取舍,而不是只顾着把任务清单一项项划掉。
4.3 第三十三周的目标规划
基于这周的复盘,我对下一周的工作有四个安排。第一,继续推进支付回调成功率优化,当前99.94%距离目标还差0.01个百分点,方向是补偿机制和失败重试策略的细化,重点分析剩余失败样本里的超时原因和重复回调场景。第二,报表导出功能灰度范围从30%扩大到60%,同时盯着连接池监控,防止流量上涨后出现新的瓶颈。第三,把本周排查Redis连接池的经验整理成一份《异步任务依赖隔离规范》,下周团队内部分享,避免其他业务线踩同样的坑。第四,需求侧开启退款订单导出方案的设计评审,目标是在第三十四周进入开发。
我特别想强调第三点。很多团队总在同一个地方跌倒两次,就是因为把问题定位在"参数配小了"这个表面上,没有把"共享依赖需要做隔离"这个原则沉淀出来。一份规范文档看起来不产生代码,但它能节省未来不知道多少个小时的排查时间。
最后再分享一个我自己写周报的收尾习惯:我会在每周五下班前把周报里提到的所有数据点都核实一遍,包括监控截图、参数改动、上线记录。这不是为了应付谁,而是因为周报里写的每个数字,都可能是未来某个问题的排查线索。像这周99.94%这个数字,如果当时随手写成"基本达标",下周做分析时就不会有紧迫感去翻那0.03%的失败样本了。周报也别写太长,控制在三屏以内,因为没人愿意看超过三屏的周报。重点永远是:这周解决了什么、决策依据是什么、下一步要推到哪。