18年老兵的十条开发军规:每条背后都是一个翻车现场

老炮踩坑录 · V05 · 老兵复盘系列

基于「企业融合评估平台」真实源码,用真实事故换来的十条军规

关键词:资源释放 · 显式传参 · 默认配置 · 静默失败 · 弱类型容器 · 硬编码 · 越权

👋 欢迎阅读

🏠个人主页: 知守观

📘我的专栏: 老炮踩坑录

💻当前内容:十条军规

前言

上一篇代码审查清单发出去,有人留言:你这也太较真了,这些规矩到底是怎么来的?

还能怎么来,一个 bug 一个 bug 换来的。

离职以后我把「企业融合评估平台」的代码拉回本地,连着写了几篇复盘。写着写着发现一件后背发凉的事:这些坑换个形式,跟我十年前踩的几乎一模一样。框架换了三茬,犯的错没变过。

具体技术忘了可以查文档,踩过的坑会变成直觉------看到某种写法就知道要出事,写完某行代码手自己就去补 finally。

这篇把这些直觉整理成十条规矩,每条都尽量配上真实的事故。只有空道理、拿不出真实事故的条目,我绝不写。

军规一:谁申请,谁释放,释放写进 finally

上一篇ThreadLocal串数据的事故 ,根子上就是一句话:全项目搜 threadLocal.remove,0 个结果。

set 的人和 remove 的人最好是同一个,在同一个方法里,中间隔着 try:

csharp 复制代码
threadLocal.set(ctx);
try {
    businessLogic();
} finally {
    threadLocal.remove();
}

文件流、连接、锁、临时文件,全是一个道理。我听过的理由五花八门:"方法马上就返回了""线程结束不就没了吗""正常流程肯定能走到清理那行"。

方法返回了,线程没死。它在线程池里接着服务下一个人。正常流程走得到清理,抛异常的流程呢?

Java 7 以后能 try-with-resources 的就别手写 finally,少一次证明自己细心的机会:

ini 复制代码
try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
    return reader.lines().toList();
}

军规二:数据通过参数传递,别用静态容器隐式存取

方法参数是显式的数据传递方式。static 变量、单例字段、ThreadLocal 里存请求级数据,都是隐式存取。

显式传递时,顺着方法签名就能追踪数据来源。隐式存取不行------上一篇ThreadLocal串数据的事故 里 Session 怎么从请求线程跑到异步线程的?五个文件各自 AsyncService.threadLocal.get(),没有任何一条调用关系告诉你它什么时候被 set 过。

typescript 复制代码
// 隐式存取:签名上根本看不出这方法依赖当前登录用户
public void submitReport(ReportDTO dto) {
    Map<String, Object> ctx = AsyncService.threadLocal.get();
    String enterpriseId = (String) ctx.get("enterpriseid");
    // ...
}
​
// 显式传参:依赖什么,全写在签名上
public void submitReport(ReportDTO dto, Operator operator) {
    String enterpriseId = operator.enterpriseId();
    // ...
}

显式传参写起来麻烦。多一个参数,上层就要多传一层,有时候要改七八个方法签名,我也嫌烦。但多写参数的代价是改代码当下的十分钟;隐式存取的代价,是某个深夜对着日志想破头"这数据到底从哪来的"。

异步线程要用上下文,就把上下文当参数构造进任务;框架级透传交给 TaskDecorator 统一做,做完照样 remove。上一篇ThreadLocal串数据的事故 里写过,不重复。

军规三:关键行为,不许交给默认值

@EnableAsync 往启动类上一贴,线程池呢?没人配。那个项目跑起来碰巧是不池化的执行器,每个任务新建线程------上一篇ThreadLocal串数据的事故里分析过这个"碰巧"有多脆弱,任何人加一行配置,线程一开始复用,就会串数据。

我维护着一份自己的"默认值怀疑清单":

  • 线程池,核心数、上限、队列、拒绝策略、线程名前缀,自己声明
  • 字符编码,裸 getBytes()、裸 new String(bytes) 一律不许提 MR
  • 时区,涉及时间的存储和计算明确到 ZoneId,跨时区的业务敢用默认时区,它就敢凌晨三点给你出账
  • JSON 反序列化,自动类型推断能关就关
javascript 复制代码
// 今天在 Windows 开发是 GBK,明天扔 Linux 容器是 UTF-8,行为随环境漂移
new String(file.getBytes())
​
// 任何机器、任何版本、任何环境,结果都一样
new String(file.getBytes(StandardCharsets.UTF_8))

默认值是框架作者按他当时的场景做的决定。版本升一级,场景换一个,他就改了,不会有人通知你。把系统安全押在默认行为不变上,这是在赌。

线程名前缀单独说一句:出事翻日志,async-3 和 http-nio-8080-exec-7 能帮你省下半小时。几乎不增加工作量。

军规四:异常要快速失败,别静默处理

上一篇ThreadLocal串数据的事故里有段兜底:ThreadLocal 取得到就用,取不到再从 request 取。听着很稳。实际效果是------线程上还留着上一个用户的残留 Session,代码直接拿来用,不抛异常,不打日志,把 A 企业的数据展示给 B 企业。

ini 复制代码
// 静默使用:取到错误数据时没有任何报错和日志
if (ctx != null && ctx.containsKey("session")) {
    session = (HttpSession) ctx.get("session");
} else {
    session = request.getSession();
}

同类写法还有很多:catch (Exception e) { return null; }、远程调用失败返回空集合、批处理某条出错跳过继续跑。

我的规矩:降级可以,但必须留日志;动手前先想清楚"用错"和"没有",哪个代价大。

取不到企业信息,大不了让用户重试一次,这是"没有"。把别家企业信息展示出来,是数据安全事故。这种地方我的选择是 fail fast,直接抛异常:

csharp 复制代码
Operator operator = OperatorContext.current();
if (operator == null) {
    throw new IllegalStateException("异步任务缺少操作人上下文,拒绝执行");
}

500 页面,用户骂两句,异常抛出来了,日志里有堆栈,五分钟有人修。静默发生的 bug 会在系统里潜伏几个月,直到客服群炸锅才被发现。

catch 块同理,至少要记录带异常对象的完整日志:

lua 复制代码
log.error("报告提交失败, enterpriseId={}, paperType={}", enterpriseId, paperType, e);

注意 e 放在最后一个参数位,SLF4J 会把它当 Throwable 打全堆栈。只打 e.getMessage(),堆栈信息就丢了。

军规五:偶发问题,先逼成必现

"我本地好好的"、"重启就好了"、"就出现过一次"。听到这三句话,我不会把问题当成已经解决------bug 还在,只是还没暴露。

概率性 bug 不会自己消失,它只会等流量涨上来。上一篇ThreadLocal串数据的事故 里串数据在真实线程池下是概率问题,我写复现时把线程池压成核心 1、最大 1、无界队列,两个任务排着队用同一条线程,三十行代码,必现:

ini 复制代码
ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>());

稳定复现的办法就那几个:线程池缩到 1、队列塞满触发拒绝策略、超时改到 1 毫秒、关键位置 sleep 住等另一条线程撞上来、时钟拨到月末零点。

写复现的过程本身就是成本最低的问题分析。为了让它在一个 main 方法里跑起来,你得把线程模型、生命周期、调用顺序全梳理清楚。经常是复现代码刚写完还没运行,根因就已经找到了。

说不出复现路径的结论,只是没有依据的猜测,这样的内容我一个字都不会写进故障报告。

军规六:Map<String, Object> 等于放弃编译期类型检查

上一篇ThreadLocal串数据的事故 里的上下文长这样:ThreadLocal<Map<String, Object>>。五个文件用字符串 key 取值,取出来再强转:

ini 复制代码
Map<String, Object> ctx = AsyncService.threadLocal.get();
session = (HttpSession) ctx.get("session");
String enterpriseId = (String) ctx.get("enterpriseid");

enterpriseid 手滑拼成 enterpriseId,编译照样通过。哪天有人把 value 换成 JSONObject,ClassCastException 在生产环境等你。想重命名?IDE 的引用查找对字符串 key 无能为力。

JSON 在系统边界上躲不掉:HTTP 入参、第三方返回、缓存序列化。我的转换点定在系统入口------进来第一时间转成具体类,系统内部流转只用具体类型。

vbnet 复制代码
public record Operator(String userId, String enterpriseId, String token) {}
​
Operator operator = new Operator(userId, enterpriseId, token);
// 往下传的每一步,编译器都会做类型检查
reportService.submit(dto, operator);

嫌类多?一个 record 一行的事。编译器能提前发现的错误,比这一行代码值钱多了。

军规七:穷举业务类型前,先问第 5 个什么时候来

这个项目四种诊断模型,代码里写的是 paperid == 0/1/2/3,switch 散落各处。产品一说"加第 5 个模型",一数,7 个文件要动。这个案子我之前专门写过一篇,这里只给判定动作:

ini 复制代码
grep -rn "case 0:|case 1:|case 2:" --include=*.java src/

if/switch 的分支里出现带业务含义的字面量,分支数还跟着产品功能往上涨,这样的代码迟早出问题。拆法不复杂,枚举 + 策略,Spring 启动时把实现收集到一张注册表里:

typescript 复制代码
public interface PaperScorer {
    PaperType type();
    ScoreResult score(ApplyInfo apply);
}
​
@Component
@RequiredArgsConstructor
public class PaperScorerRegistry {
    private final List<PaperScorer> scorers;
    private Map<PaperType, PaperScorer> index;
​
    @PostConstruct
    void init() {
        index = scorers.stream()
                .collect(Collectors.toMap(PaperScorer::type, Function.identity()));
    }
​
    public PaperScorer get(PaperType type) {
        PaperScorer scorer = index.get(type);
        if (scorer == null) {
            throw new IllegalArgumentException("未注册的模型类型: " + type);
        }
        return scorer;
    }
}

第 5 个模型来的时候,新增一个实现类,旧代码一行不动。

我不鼓吹见 switch 就改。业务上确定不会再加的两三个分支,if 写着最直白,硬套设计模式反而是过度设计。判断标准只有一个问题:这类业务类型产品以后会不会加? 诊断模型、审批类型、计费套餐,这类明显还会扩展的类型,从第一次写代码就留好扩展点。

军规八:改 yml 和升版本,按改代码处理

让ThreadLocal串数据的事故 从"碰巧安全"滑向"必然串数据",只需要一行配置:

yaml 复制代码
spring:
  task:
    execution:
      pool:
        core-size: 8

没有编译报错,没有红线。review 时所有人的注意力都在 Java 文件上,yml 扫一眼就过。配置是不享受编译器保护的代码,危险度只高不低。

我的做法:

  • 配置进 Git,谁改的、为什么改,commit message 里见
  • review 时 yml 和 Java 同等待遇,涉及线程池、超时、连接串的改动,多问一句影响面
  • 测试环境配置跟生产同构,靠"测试环境碰巧没配"跑通的测试,不算数
  • 依赖升级先翻 changelog 里的 default behavior change,Spring Boot 这种大版本,默认线程池、连接池、字符集都可能换

线上系统的真实行为 = 代码 × 配置 × 版本。任何一个变量偷偷变了,事故最后都记在你头上。

军规九:写代码前,先想好出事靠哪行日志查

上一篇ThreadLocal串数据的事故 那个 bug 难查,一半原因是日志帮不上忙。异步任务拿着谁的 enterpriseid、用谁的 token 调的远程接口,日志里一概没有。出了问题只能靠时间戳瞎猜。

现在我写关键路径之前,会先问自己:这地方半年后出问题,我靠哪行日志还原现场?答不上来,先补日志再写逻辑。

我要的日志能回答四件事:谁的请求、用谁的身份、干了什么、结果如何。

less 复制代码
log.info("报告提交开始, operatorId={}, enterpriseId={}, paperType={}",
        operator.userId(), operator.enterpriseId(), dto.getPaperType());

traceId 放 MDC,日志 pattern 里用 %X{traceId} 输出。异步线程要手动透传,在 TaskDecorator 里跟上下文一起传过去、用完一起清掉。不然一次请求在日志里断成两截,前半截在 http 线程,后半段没有 traceId,拿日志拼调用链路根本拼不起来。

再补半条:企业名称、身份证、手机号打日志前先脱敏。日志文件的访问权限比数据库宽得多,别让日志成为数据泄露的途径。

军规十:数据串了,比系统挂了严重

十条里这条排最后,分量最重。

系统抛 500,用户看到错误页,重试或者骂客服,影响是一次性的。A 企业看到 B 企业的评估报告、附件传进别家企业的目录、拿着别人的 token 去调远程接口------这叫越权,是数据安全事故,要报备、要通知客户、要有人担责。

所以涉及"当前是谁"的地方,我宁可系统不干活,也不许它猜:

csharp 复制代码
Operator operator = OperatorContext.current();
if (operator == null) {
    // 绝不 new 一个默认值,绝不沿用上一次残留的,直接拒绝
    throw new IllegalStateException("无操作人上下文,拒绝执行企业数据操作");
}

手工执行 SQL 是另一个重灾区。我的习惯:UPDATE/DELETE 先写成 SELECT,查出来的行数和内容核对完,再把 SQL 改成写操作,where 原封不动:

sql 复制代码
-- 先看清楚要动哪些行
SELECT id, enterprise_id FROM apply_info WHERE status = 3 AND create_time < '2022-01-01';
​
-- 核对完再更新,where 条件一个字不改
UPDATE apply_info SET status = 4 WHERE status = 3 AND create_time < '2022-01-01';

没有备份和回滚脚本的批量更新,我不碰生产库。

还有 SQL 注入------V04 那篇审查清单里,这个项目就查出过字符串拼接的标准注入写法。用户输入想进 SQL,只有参数化这一条路,没有"这个接口是内网的""这个字段前端写死的"这种例外。内网一台机器被攻破,攻击者就能继续访问同网络里的其他系统,前端传的东西抓个包就能改。

做企业系统,数据库里存的是客户的经营数据。权限和数据隔离上出一次事故,就是数据安全事故,这类地方不允许"差不多就行"。

十条速查

老炮点评

十条要再压缩,就两件事。

  • 让数据流动可追踪:参数显式传、类型有编译期检查、日志留痕、配置入库。出问题时,可以顺着调用链追到源头。
  • 让错误尽早暴露:finally 里完成资源清理、异常直接抛出并记录、偶发问题先稳定复现、身份存疑就拒绝服务。问题在开发阶段爆炸,成本是一杯咖啡;在生产线上爆炸,成本是一屋子人通宵。

军规也不是铁板一块。这十条我自己都破过------赶一个明确不会延期的演示,硬编码过;为了兼容老接口,Map 也传过。区别在于,破的时候我清楚自己在破哪条、欠的什么债、打算什么时候还。不知道自己在违规的人,没有"例外"可讲。

工具和框架会一直换,我入行那会儿 EJB 还是先进生产力。换不掉的,是这些摔出来的本能。


延伸阅读 如果你还没看过这个项目的代码审查清单,可以翻一下 《老炮的代码审查清单:我 Review 代码时看什么》。


下期预告:《技术选型的三条铁律:我踩过坑才总结出来的》

框架、中间件、工具,选的时候都说没问题,问题往往一年后才出现。三条铁律,每条背后都是一次真实项目里付出过代价的选型决定,下期讲。

如果本文对你有帮助,欢迎:

👍 点赞 | ⭐ 收藏 | 👤 关注 | 💬 留言

我是老炮,18 年 Java 老兵,仍在一线。关注「Java老炮踩坑录」,不错过每一篇真实案例,少踩坑。

相关推荐
韩振方1 小时前
为什么 curl -I 显示 200,页面打开还是报错?
后端
小稀土1231 小时前
9 类行业怎么用私有化部署研发管理工具:涉密、制造、金融、科研院所一次盘全
后端
懒洋洋不想起床1 小时前
DEX 与未来交易市场:为什么去中心化金融正在进入新阶段
后端
东风微鸣1 小时前
Rook-Ceph 数据修复实战
后端
imDwAaY1 小时前
WebSocket 和 SSE 有什么区别?从实时通信到 AI 流式输出
后端·websocket
三千星1 小时前
Java开发者转型AI工程化Week 7:从散落知识点到可上线的应用
后端
胡写代码1 小时前
雪花 ID 传到前端就变了个数?我用全局 Long 转 String 一次收口
前端·后端
BBmmo1 小时前
我的 Java 学习笔记 · 第 11 篇:Condition.await 为什么必须放在 while 里
java
SimonKing1 小时前
一只离线鼠鼠,干翻了一堆在线格式转换网站
java·后端·程序员