老炮踩坑录 · V05 · 老兵复盘系列
基于「企业融合评估平台」真实源码,用真实事故换来的十条军规
关键词:资源释放 · 显式传参 · 默认配置 · 静默失败 · 弱类型容器 · 硬编码 · 越权
👋 欢迎阅读
🏠个人主页:知守观
📘我的专栏:老炮踩坑录
💻当前内容:十条军规
文章目录
- 前言
- 军规一:谁申请,谁释放,释放写进 finally
- 军规二:数据通过参数传递,别用静态容器隐式存取
- 军规三:关键行为,不许交给默认值
- 军规四:异常要快速失败,别静默处理
- 军规五:偶发问题,先逼成必现
- 军规六:Map<String, Object> 等于放弃编译期类型检查
- 军规七:穷举业务类型前,先问第 5 个什么时候来
- 军规八:改 yml 和升版本,按改代码处理
- 军规九:写代码前,先想好出事靠哪行日志查
- 军规十:数据串了,比系统挂了严重
- 十条速查
- 老炮点评
前言
上一篇代码审查清单发出去,有人留言:你这也太较真了,这些规矩到底是怎么来的?
还能怎么来,一个 bug 一个 bug 换来的。
离职以后我把「企业融合评估平台」的代码拉回本地,连着写了几篇复盘。写着写着发现一件后背发凉的事:这些坑换个形式,跟我十年前踩的几乎一模一样。框架换了三茬,犯的错没变过。
具体技术忘了可以查文档,踩过的坑会变成直觉——看到某种写法就知道要出事,写完某行代码手自己就去补 finally。
这篇把这些直觉整理成十条规矩,每条都尽量配上真实的事故。只有空道理、拿不出真实事故的条目,我绝不写。
军规一:谁申请,谁释放,释放写进 finally
F05 那个串数据的事故,根子上就是一句话:全项目搜threadLocal.remove,0 个结果。
set 的人和 remove 的人最好是同一个,在同一个方法里,中间隔着 try:
threadLocal.set(ctx);try{businessLogic();}finally{threadLocal.remove();}文件流、连接、锁、临时文件,全是一个道理。我听过的理由五花八门:“方法马上就返回了”“线程结束不就没了吗”“正常流程肯定能走到清理那行”。
方法返回了,线程没死。它在线程池里接着服务下一个人。正常流程走得到清理,抛异常的流程呢?
Java 7 以后能 try-with-resources 的就别手写 finally,少一次证明自己细心的机会:
try(BufferedReaderreader=Files.newBufferedReader(path,StandardCharsets.UTF_8)){returnreader.lines().toList();}军规二:数据通过参数传递,别用静态容器隐式存取
方法参数是显式的数据传递方式。static 变量、单例字段、ThreadLocal 里存请求级数据,都是隐式存取。
显式传递时,顺着方法签名就能追踪数据来源。隐式存取不行——F05 里 Session 怎么从请求线程跑到异步线程的?五个文件各自AsyncService.threadLocal.get(),没有任何一条调用关系告诉你它什么时候被 set 过。
// 隐式存取:签名上根本看不出这方法依赖当前登录用户publicvoidsubmitReport(ReportDTOdto){Map<String,Object>ctx=AsyncService.threadLocal.get();StringenterpriseId=(String)ctx.get("enterpriseid");// ...}// 显式传参:依赖什么,全写在签名上publicvoidsubmitReport(ReportDTOdto,Operatoroperator){StringenterpriseId=operator.enterpriseId();// ...}显式传参写起来麻烦。多一个参数,上层就要多传一层,有时候要改七八个方法签名,我也嫌烦。但多写参数的代价是改代码当下的十分钟;隐式存取的代价,是某个深夜对着日志想破头"这数据到底从哪来的"。
异步线程要用上下文,就把上下文当参数构造进任务;框架级透传交给 TaskDecorator 统一做,做完照样 remove。F05 里写过,不重复。
军规三:关键行为,不许交给默认值
@EnableAsync往启动类上一贴,线程池呢?没人配。那个项目跑起来碰巧是不池化的执行器,每个任务新建线程——F05 里分析过这个"碰巧"有多脆弱,任何人加一行配置,线程一开始复用,就会串数据。
我维护着一份自己的"默认值怀疑清单":
- 线程池,核心数、上限、队列、拒绝策略、线程名前缀,自己声明
- 字符编码,裸
getBytes()、裸new String(bytes)一律不许提 MR - 时区,涉及时间的存储和计算明确到 ZoneId,跨时区的业务敢用默认时区,它就敢凌晨三点给你出账
- JSON 反序列化,自动类型推断能关就关
// 今天在 Windows 开发是 GBK,明天扔 Linux 容器是 UTF-8,行为随环境漂移newString(file.getBytes())// 任何机器、任何版本、任何环境,结果都一样newString(file.getBytes(StandardCharsets.UTF_8))默认值是框架作者按他当时的场景做的决定。版本升一级,场景换一个,他就改了,不会有人通知你。把系统安全押在默认行为不变上,这是在赌。
线程名前缀单独说一句:出事翻日志,async-3和http-nio-8080-exec-7能帮你省下半小时。几乎不增加工作量。
军规四:异常要快速失败,别静默处理
F05 里有段兜底:ThreadLocal 取得到就用,取不到再从 request 取。听着很稳。实际效果是——线程上还留着上一个用户的残留 Session,代码直接拿来用,不抛异常,不打日志,把 A 企业的数据展示给 B 企业。
// 静默使用:取到错误数据时没有任何报错和日志if(ctx!=null&&ctx.containsKey("session")){session=(HttpSession)ctx.get("session");}else{session=request.getSession();}同类写法还有很多:catch (Exception e) { return null; }、远程调用失败返回空集合、批处理某条出错跳过继续跑。
我的规矩:降级可以,但必须留日志;动手前先想清楚"用错"和"没有",哪个代价大。
取不到企业信息,大不了让用户重试一次,这是"没有"。把别家企业信息展示出来,是数据安全事故。这种地方我的选择是 fail fast,直接抛异常:
Operatoroperator=OperatorContext.current();if(operator==null){thrownewIllegalStateException("异步任务缺少操作人上下文,拒绝执行");}500 页面,用户骂两句,异常抛出来了,日志里有堆栈,五分钟有人修。静默发生的 bug 会在系统里潜伏几个月,直到客服群炸锅才被发现。
catch 块同理,至少要记录带异常对象的完整日志:
log.error("报告提交失败, enterpriseId={}, paperType={}",enterpriseId,paperType,e);注意e放在最后一个参数位,SLF4J 会把它当 Throwable 打全堆栈。只打e.getMessage(),堆栈信息就丢了。
军规五:偶发问题,先逼成必现
“我本地好好的”、“重启就好了”、“就出现过一次”。听到这三句话,我不会把问题当成已经解决——bug 还在,只是还没暴露。
概率性 bug 不会自己消失,它只会等流量涨上来。F05 的串数据在真实线程池下是概率问题,我写复现时把线程池压成核心 1、最大 1、无界队列,两个任务排着队用同一条线程,三十行代码,必现:
ThreadPoolExecutorpool=newThreadPoolExecutor(1,1,0,TimeUnit.SECONDS,newLinkedBlockingQueue<>());稳定复现的办法就那几个:线程池缩到 1、队列塞满触发拒绝策略、超时改到 1 毫秒、关键位置 sleep 住等另一条线程撞上来、时钟拨到月末零点。
写复现的过程本身就是成本最低的问题分析。为了让它在一个 main 方法里跑起来,你得把线程模型、生命周期、调用顺序全梳理清楚。经常是复现代码刚写完还没运行,根因就已经找到了。
说不出复现路径的结论,只是没有依据的猜测,这样的内容我一个字都不会写进故障报告。
军规六:Map<String, Object> 等于放弃编译期类型检查
F05 里的上下文长这样:ThreadLocal<Map<String, Object>>。五个文件用字符串 key 取值,取出来再强转:
Map<String,Object>ctx=AsyncService.threadLocal.get();session=(HttpSession)ctx.get("session");StringenterpriseId=(String)ctx.get("enterpriseid");enterpriseid手滑拼成enterpriseId,编译照样通过。哪天有人把 value 换成 JSONObject,ClassCastException 在生产环境等你。想重命名?IDE 的引用查找对字符串 key 无能为力。
JSON 在系统边界上躲不掉:HTTP 入参、第三方返回、缓存序列化。我的转换点定在系统入口——进来第一时间转成具体类,系统内部流转只用具体类型。
publicrecordOperator(StringuserId,StringenterpriseId,Stringtoken){}Operatoroperator=newOperator(userId,enterpriseId,token);// 往下传的每一步,编译器都会做类型检查reportService.submit(dto,operator);嫌类多?一个 record 一行的事。编译器能提前发现的错误,比这一行代码值钱多了。
军规七:穷举业务类型前,先问第 5 个什么时候来
这个项目四种诊断模型,代码里写的是paperid == 0/1/2/3,switch 散落各处。产品一说"加第 5 个模型",一数,7 个文件要动。这个案子我之前专门写过一篇,这里只给判定动作:
grep-rn"case 0:\|case 1:\|case 2:"--include=*.java src/if/switch 的分支里出现带业务含义的字面量,分支数还跟着产品功能往上涨,这样的代码迟早出问题。拆法不复杂,枚举 + 策略,Spring 启动时把实现收集到一张注册表里:
publicinterfacePaperScorer{PaperTypetype();ScoreResultscore(ApplyInfoapply);}@Component@RequiredArgsConstructorpublicclassPaperScorerRegistry{privatefinalList<PaperScorer>scorers;privateMap<PaperType,PaperScorer>index;@PostConstructvoidinit(){index=scorers.stream().collect(Collectors.toMap(PaperScorer::type,Function.identity()));}publicPaperScorerget(PaperTypetype){PaperScorerscorer=index.get(type);if(scorer==null){thrownewIllegalArgumentException("未注册的模型类型: "+type);}returnscorer;}}第 5 个模型来的时候,新增一个实现类,旧代码一行不动。
我不鼓吹见 switch 就改。业务上确定不会再加的两三个分支,if 写着最直白,硬套设计模式反而是过度设计。判断标准只有一个问题:这类业务类型产品以后会不会加?诊断模型、审批类型、计费套餐,这类明显还会扩展的类型,从第一次写代码就留好扩展点。
军规八:改 yml 和升版本,按改代码处理
让 F05 从"碰巧安全"滑向"必然串数据",只需要一行配置:
spring:task:execution:pool:core-size:8没有编译报错,没有红线。review 时所有人的注意力都在 Java 文件上,yml 扫一眼就过。配置是不享受编译器保护的代码,危险度只高不低。
我的做法:
- 配置进 Git,谁改的、为什么改,commit message 里见
- review 时 yml 和 Java 同等待遇,涉及线程池、超时、连接串的改动,多问一句影响面
- 测试环境配置跟生产同构,靠"测试环境碰巧没配"跑通的测试,不算数
- 依赖升级先翻 changelog 里的 default behavior change,Spring Boot 这种大版本,默认线程池、连接池、字符集都可能换
线上系统的真实行为 = 代码 × 配置 × 版本。任何一个变量偷偷变了,事故最后都记在你头上。
军规九:写代码前,先想好出事靠哪行日志查
F05 那个 bug 难查,一半原因是日志帮不上忙。异步任务拿着谁的 enterpriseid、用谁的 token 调的远程接口,日志里一概没有。出了问题只能靠时间戳瞎猜。
现在我写关键路径之前,会先问自己:这地方半年后出问题,我靠哪行日志还原现场?答不上来,先补日志再写逻辑。
我要的日志能回答四件事:谁的请求、用谁的身份、干了什么、结果如何。
log.info("报告提交开始, operatorId={}, enterpriseId={}, paperType={}",operator.userId(),operator.enterpriseId(),dto.getPaperType());traceId 放 MDC,日志 pattern 里用%X{traceId}输出。异步线程要手动透传,在 TaskDecorator 里跟上下文一起传过去、用完一起清掉。不然一次请求在日志里断成两截,前半截在 http 线程,后半段没有 traceId,拿日志拼调用链路根本拼不起来。
再补半条:企业名称、身份证、手机号打日志前先脱敏。日志文件的访问权限比数据库宽得多,别让日志成为数据泄露的途径。
军规十:数据串了,比系统挂了严重
十条里这条排最后,分量最重。
系统抛 500,用户看到错误页,重试或者骂客服,影响是一次性的。A 企业看到 B 企业的评估报告、附件传进别家企业的目录、拿着别人的 token 去调远程接口——这叫越权,是数据安全事故,要报备、要通知客户、要有人担责。
所以涉及"当前是谁"的地方,我宁可系统不干活,也不许它猜:
Operatoroperator=OperatorContext.current();if(operator==null){// 绝不 new 一个默认值,绝不沿用上一次残留的,直接拒绝thrownewIllegalStateException("无操作人上下文,拒绝执行企业数据操作");}手工执行 SQL 是另一个重灾区。我的习惯:UPDATE/DELETE 先写成 SELECT,查出来的行数和内容核对完,再把 SQL 改成写操作,where 原封不动:
-- 先看清楚要动哪些行SELECTid,enterprise_idFROMapply_infoWHEREstatus=3ANDcreate_time<'2022-01-01';-- 核对完再更新,where 条件一个字不改UPDATEapply_infoSETstatus=4WHEREstatus=3ANDcreate_time<'2022-01-01';没有备份和回滚脚本的批量更新,我不碰生产库。
还有 SQL 注入——V04 那篇审查清单里,这个项目就查出过字符串拼接的标准注入写法。用户输入想进 SQL,只有参数化这一条路,没有"这个接口是内网的""这个字段前端写死的"这种例外。内网一台机器被攻破,攻击者就能继续访问同网络里的其他系统,前端传的东西抓个包就能改。
做企业系统,数据库里存的是客户的经营数据。权限和数据隔离上出一次事故,就是数据安全事故,这类地方不允许"差不多就行"。
十条速查
| # | 军规 | 识别信号 | 当场动作 |
|---|---|---|---|
| 1 | 谁申请谁释放 | set/open/lock 之后清理只写在正常分支 | finally 或 try-with-resources |
| 2 | 显式传参 | static 容器里存请求级数据 | 改成方法参数,依赖写在签名上 |
| 3 | 不信默认值 | 裸 getBytes、裸 @EnableAsync | 字符集/时区/线程池全部显式声明 |
| 4 | 异常快速失败 | catch 后 return null、异常静默处理 | 记录日志,先比"用错"和"没有"的代价 |
| 5 | 逼成必现 | “就出现一次”“重启就好了” | 缩线程池、改超时、写最小复现 |
| 6 | 保留编译期类型检查 | Map/JSONObject 在系统内部流转 | 入口转具体类,record 也行 |
| 7 | 第 5 个怎么办 | switch 分支里是业务字面量 | 枚举 + 策略注册表 |
| 8 | 配置也是代码 | yml 改动没人细看 | 入库、同环境 review、升级看默认值变化 |
| 9 | 先想靠哪行日志查 | 关键路径没有身份和参数 | 补四要素,traceId 透传异步线程 |
| 10 | 越权重于宕机 | 取不到身份还继续往下跑 | 拒绝执行;写库先 SELECT 核对;只用参数化 SQL |
老炮点评
十条要再压缩,就两件事。
- 让数据流动可追踪:参数显式传、类型有编译期检查、日志留痕、配置入库。出问题时,可以顺着调用链追到源头。
- 让错误尽早暴露:finally 里完成资源清理、异常直接抛出并记录、偶发问题先稳定复现、身份存疑就拒绝服务。问题在开发阶段爆炸,成本是一杯咖啡;在生产线上爆炸,成本是一屋子人通宵。
军规也不是铁板一块。这十条我自己都破过——赶一个明确不会延期的演示,硬编码过;为了兼容老接口,Map 也传过。区别在于,破的时候我清楚自己在破哪条、欠的什么债、打算什么时候还。不知道自己在违规的人,没有"例外"可讲。
工具和框架会一直换,我入行那会儿 EJB 还是先进生产力。换不掉的,是这些摔出来的本能。
下期预告:《技术选型的三条铁律:我踩过坑才总结出来的》
框架、中间件、工具,选的时候都说没问题,问题往往一年后才出现。三条铁律,每条背后都是一次真实项目里付出过代价的选型决定,下期讲。
如果本文对你有帮助,欢迎:
👍 点赞 | ⭐ 收藏 | 👤 关注 | 💬 留言
我是老炮,18 年 Java 老兵,仍在一线。关注「Java老炮踩坑录」,不错过每一篇真实案例,少踩坑。