news 2026/10/11 1:55:14

18年Java开发总结的十条军规:每条都配一个真实翻车现场(附速查表)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
18年Java开发总结的十条军规:每条都配一个真实翻车现场(附速查表)

老炮踩坑录 · 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老炮踩坑录」,不错过每一篇真实案例,少踩坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/11 1:55:11

第1章,[Win32 章节]:编程语言与框架选择

专栏导航 上一篇&#xff1a;第1章&#xff0c;[Win32 章节]&#xff1a;API 及 内存管理模式 回到目录 下一篇&#xff1a;第1章&#xff0c;[Win32 章节]&#xff1a;编程环境与 MSDN 本专栏课件 关于本专栏课件的获取方法&#xff0c;请参考下述课节。 参考课节&#x…

作者头像 李华
网站建设 2026/10/11 1:53:17

我常用的RFID计算工具,免费又好用

做射频的人&#xff0c;经常要算东西。 链路预算、波长、自由空间损耗、馈线损耗、功率换算…… 新手容易犯两个错误&#xff1a;要么硬背公式&#xff0c;每次都要翻笔记&#xff1b;要么凭感觉估&#xff0c;估错了就踩坑。 我刚开始也是这样。后来攒了几个小工具&#xf…

作者头像 李华
网站建设 2026/10/11 1:52:57

LeetCode 15:三数之和|排序 + 双指针,如何避免重复答案?

目录LeetCode 15&#xff1a;三数之和&#xff5c;排序 双指针&#xff0c;如何避免重复答案&#xff1f;一、题目要求二、思路&#xff1a;三个数字&#xff0c;真的需要同时寻找吗&#xff1f;三、排序 双指针&#xff1a;寻找剩下两个数字1. 当前总和太小2. 当前总和太大3…

作者头像 李华
网站建设 2026/10/11 1:52:53

智诺方AI|论文案例分析章节,淡化AIGC行文痕迹章节改法

智诺方AI&#xff5c;论文案例分析章节降重降AIGC&#xff0c;案例论述避免同质化写作&#xff5c;官网https://www.znfai.cn&#xff0c;微信服务号搜一搜 智诺方AI 案例分析是经管、法学、新闻等学科论文常用写作形式&#xff0c;依靠典型案例展开分析论证。案例章节包含案例…

作者头像 李华
网站建设 2026/10/11 1:52:00

数字式失真度测量仪检定装置应用解决方案 数字式失真仪校准器

在失真度仪的日常校准中&#xff0c;一线计量人员经常要重复拧动谐波幅度旋钮、盯着电压表把基波与谐波调到目标比值。SYN6708型失真度仪校准器依据JJG 802-2019《失真度仪校准器检定规程》研制&#xff0c;采用基波加二次谐波法&#xff0c;把过去依赖人手反复微调的谐波电压校…

作者头像 李华