Java业务开发常见错误100例:从线上事故到代码评审清单
1. 为什么业务代码的错误总是同一类刚带团队那两年我有个坏习惯每次代码评审都盯着格式和命名真正出问题的地方反而放过了。后来线上接连几次事故——金额少算一分、库存超卖、定时任务重复执行、事务没回滚——复盘时发现根因惊人地相似全是些教科书上写过、但没人真当回事的地方。于是我开始做一件事把每次故障、每个代码评审里挑出的问题按错误现象、触发条件、根因、正确写法四条记录下来攒到一百条左右就有了这份《Java高手笔记之业务开发常见错误100例》的雏形。它不是语法手册也不是框架文档而是一份从真实业务场景里长出来的翻车清单。这份笔记能解决什么问题简单说它帮你把能跑和跑得稳之间的那条鸿沟填上。业务开发的难点从来不是写不出功能而是功能在并发、异常、边界数据、框架代理、数据库隔离级别这些变量叠加之后还能不能保持正确。它适合三类人看刚工作一两年、CRUD写得飞快但没经历过线上事故的同学工作三五年、开始负责核心链路、需要把代码质量往上提一档的开发者以及做技术评审、需要一份可对照的检查项的团队负责人。下面我按领域把最有价值的几十条拆开讲剩下的以清单和速查表形式给你你完全可以当成自己项目的评审依据。2. 数值与类型最基础的地方翻车最狠2.1 金额千万别用doubleBigDecimal也用不对这是业务开发里出现频率最高的一类错误没有之一。double是二进制浮点数无法精确表示0.1这类十进制小数0.1 0.2 0.3的结果是false做几万笔订单累加后误差会被放大成对不上的账。所以金额必须用BigDecimal。但换成BigDecimal只是第一步真正踩坑的是这几种写法// 错误一用double构造精度在进入BigDecimal之前已经丢了 BigDecimal bad new BigDecimal(0.1); // 0.1000000000000000055511151231257827... BigDecimal good new BigDecimal(0.1); // 0.1 BigDecimal alsoGood BigDecimal.valueOf(0.1); // 0.1内部走Double.toString // 错误二除法不指定精度除不尽直接抛异常 BigDecimal r1 new BigDecimal(10).divide(new BigDecimal(3)); // ArithmeticException: Non-terminating decimal expansion // 正确写法 BigDecimal r2 new BigDecimal(10) .divide(new BigDecimal(3), 2, RoundingMode.HALF_UP); // 3.33还有一个极少被注意的点BigDecimal的equals会比较scale小数位数new BigDecimal(1.0).equals(new BigDecimal(1.00))返回false而compareTo返回0。所以在做金额相等判断、或者把金额当Map的key时用equals会出现看起来一样却不相等的诡异现象。我的建议很明确金额比较一律用compareTo需要作为key时先stripTrailingZeros()再转字符串。舍入模式的选择也要跟业务方对齐。HALF_UP是四舍五入HALF_EVEN是银行家舍入财务系统里这两种结果可能差一分钱而一分钱在对账时就是一条差异记录。这类参数不要自己拍脑袋定写进接口文档让业务方确认。2.2 包装类型的相等判断与缓存陷阱Integer的比较是最经典的坑。装箱时-128到127之间的值会复用IntegerCache里的对象超出范围就新建对象于是出现小数字相等、大数字不等的诡异现象Integer a 127, b 127; System.out.println(a b); // true Integer c 128, d 128; System.out.println(c d); // false System.out.println(c.equals(d)); // true真实业务里这个坑往往藏在ID比较上。用户ID、订单ID动辄上亿用比较两个Long大概率返回false导致明明是同一个人却判断为不同。更隐蔽的是混合类型比较Integer和int比较会自动拆箱比的是值这时候结果又是对的于是代码在某处能跑、换个地方就不行排查起来极其费劲。统一原则所有包装类型的值比较一律用equals或者先转成基本类型再比。ID类字段建议直接定义成long或Long并在团队规范里写死比较方式别给后来人留想象空间。2.3 自动拆箱引发的空指针自动拆箱的NPE有几种典型形态其中三元表达式那条最容易让人看不懂堆栈Integer status null; // 三元表达式两端一个是Integer一个是int整体类型提升为intnull拆箱直接NPE int result flag ? status : 0; // Map取值直接赋给基本类型 MapString, Integer map new HashMap(); int count map.get(notExist); // NPE // 方法参数是int传入Integer的null public void update(long id) { } update(someIntegerNull); // NPE这类问题的排查成本在于异常堆栈指向的是那一行表达式而不是真正为null的变量来源。我的处理习惯是凡是可能为null的包装类型在使用前一律用Objects.requireNonNullElse给默认值或者在业务语义上明确这个字段不允许为空在入口处就校验掉。别指望后面每个使用点都记得判空人一定会忘。2.4 日期时间的时区与可变性java.util.Date是可变的一个方法把Date传出去调用方改了它你手里的值也变了这种共享可变对象引发的问题极难复现。新项目一律用java.time包下的LocalDateTime、LocalDate、Instant它们不可变且线程安全。时区是另一个高频雷区。服务器时区、数据库时区、前端展示时区三者不一致时会出现入库时间比实际早8小时跨天统计口径错乱。我的做法是数据库统一存UTC时间戳或者明确约定存东八区时间接口层传输用带时区的ISO-8601字符串或时间戳展示层再做本地化转换。三个环节必须写进技术方案口头约定等于没有约定。3. 集合与并发单机跑得好上线就崩3.1 集合操作的几个隐形陷阱Arrays.asList返回的是固定长度的视图底层就是原数组调用add会抛UnsupportedOperationExceptionArrayList.subList返回的也是视图原列表结构一变子列表再用就抛ConcurrentModificationException。这两个方法的名字太有迷惑性看起来像转换实际是包装。遍历中删除元素也是老生常谈但依然高发// 错误foreach底层是Iterator直接remove会抛异常 for (String s : list) { if (s.startsWith(tmp)) list.remove(s); } // 正确用Iterator.remove IteratorString it list.iterator(); while (it.hasNext()) { if (it.next().startsWith(tmp)) it.remove(); } // 或者 JDK8 的 removeIf list.removeIf(s - s.startsWith(tmp));HashMap的初始容量也值得说一句。很多人写new HashMap(16)以为能装16个元素不扩容实际上负载因子0.75装到第13个就扩容了。要装n个元素初始容量应该给(int)(n / 0.75f) 1。这个细节在批量组装返回结果时很有用能省掉几次rehash。3.2 线程池别用Executors的快捷方法Executors.newFixedThreadPool和newSingleThreadExecutor用的是无界队列LinkedBlockingQueue任务堆积时内存会一路涨到OOMnewCachedThreadPool的最大线程数是Integer.MAX_VALUE同样有风险。生产环境我一律手写ThreadPoolExecutor把七个参数都显式写出来ThreadPoolExecutor pool new ThreadPoolExecutor( 8, // corePoolSize 32, // maximumPoolSize 60L, TimeUnit.SECONDS, // keepAliveTime new LinkedBlockingQueue(2000), // 有界队列必须给容量 new ThreadFactory() { // 自定义线程名排查问题靠它 private final AtomicInteger seq new AtomicInteger(1); public Thread newThread(Runnable r) { return new Thread(r, order-async- seq.getAndIncrement()); } }, new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略按业务选 );参数怎么定核心线程数按任务类型来CPU密集型给核数 1IO密集型给核数 * 2起步再根据压测的QPS和单任务耗时调。队列容量决定的是能容忍多长的等待队列太长会导致任务延迟高、故障时积压严重太短又会频繁触发拒绝策略。我的经验是队列容量控制在高峰期10秒左右的处理量既吸收突发流量又不会让延迟失控。拒绝策略的选择也是业务决策。CallerRunsPolicy让提交任务的线程自己执行天然形成背压适合不能丢任务的场景DiscardPolicy静默丢弃只适合日志、监控这类可丢数据。选错了要么丢订单要么把上游拖垮。3.3 共享变量的可见性与原子性// 错误多线程下 stop 可能永远读不到最新值 private boolean stop false; // 正确 private volatile boolean stop false;volatile保证可见性和有序性但不保证原子性。count这种操作即使加了volatile也不安全因为它是读-改-写三步。计数器场景用AtomicInteger或LongAdder高并发下LongAdder性能更好代价是读取时是近似值。复合操作比如判断存在则更新用ConcurrentHashMap.computeIfAbsent这类原子方法别自己写先get再put那是典型的竞态。还有一个特别容易被忽略的点ThreadLocal用完必须remove。线程池里的线程是复用的上一次请求放进去的用户上下文、租户ID不清掉下一次请求就可能读到别人的身份信息这既是数据错乱也是安全问题。我见过最离谱的案例是租户ID串了导致A客户看到了B客户的数据。3.4 SimpleDateFormat 不是线程安全的SimpleDateFormat内部有个可变的Calendar字段多线程共用一个实例会解析出错误日期甚至抛NumberFormatException。这个类在很多老项目里被写成static常量隐患埋了好几年。换DateTimeFormatter不可变、线程安全或者每次方法内new一个。同样的道理适用于DecimalFormat它也是非线程安全的。4. 数据库与事务业务正确性的最后一道防线4.1 Transactional 失效的六种场景事务注解失效是面试高频题也是线上事故高频原因。列几个真实遇到的场景现象原因正确做法同类内部方法调用事务不生效绕过代理调的是this拆到另一个Bean或注入自身代理方法非public事务不生效代理无法拦截改成public异常被catch未抛出不回滚代理感知不到异常重新抛出或手动回滚抛的是受检异常不回滚默认只回滚RuntimeException指定rollbackFor Exception.class数据库引擎不支持不回滚如MyISAM无事务确认InnoDB多数据源未指定事务管理器只回滚其中一个库事务管理器不匹配显式指定transactionManager第一条尤其隐蔽。this.methodB()这种调用在IDE里看起来毫无问题跑起来也不报错只有出故障时你才发现整个方法压根没在事务里。我的建议是所有带事务注解的方法都放在Service层最外层内部拆出的方法不加注解靠调用链路保证事务边界。还有个大事务问题。事务里做远程调用、发消息、跑循环几十次DB操作会导致事务持有时间长、锁等待、连接池被打满。原则是事务要短把校验、查询、远程调用放在事务外事务内只保留必要的写操作。4.2 索引失效与深分页where条件里对索引列做函数运算、隐式类型转换、前导模糊匹配都会让索引失效。比如where date(create_time) 2024-01-01改成范围查询create_time 2024-01-01 00:00:00 and create_time 2024-01-02 00:00:00就能走索引。再比如varchar字段传了数字MySQL会做隐式转换索引直接废掉——这种问题在ORM拼参数时特别容易发生。深分页是另一个典型limit 1000000, 20会扫描前一百万行再丢弃。优化方式是游标分页记住上一页最后一条的IDwhere id lastId limit 20或者延迟关联先用覆盖索引查出ID再回表。前者要求排序字段稳定且唯一后者在小范围ID上效率更高。4.3 并发更新的丢失更新问题库存扣减、余额变更这类场景两个请求同时读到库存100各自减1写回99实际应该98。解决方案有三种悲观锁select ... for update、乐观锁版本号或CAS更新、分布式锁。我一般优先用乐观锁因为它是无锁的性能好// 一条SQL完成判断与更新靠影响行数判断是否成功 int rows jdbc.update( update stock set quantity quantity - ?, version version 1 where sku_id ? and quantity ? and version ?, num, skuId, num, version); if (rows 0) { // 扣减失败重试或返回 }注意where里的quantity ?这一条就把超卖挡住了即使版本号判断被绕过库存也不会扣成负数。这种在SQL层面做约束的思路比在Java里判断要可靠得多因为它天然是原子的。4.4 幂等唯一索引是最后一道保险消息重复消费、用户重复点击、网络重试都会导致同一业务被执行多次。常见的幂等方案是先查后写但这个方案在并发下不可靠——两个请求同时查都说不存在然后都插入。真正的兜底是数据库唯一索引给业务单号建唯一索引插入重复时抛DuplicateKeyException捕获后按已处理返回。这比分布式锁更简单也更可靠因为它是数据库层面的强约束。5. 框架与接口Spring的便利背后是黑盒5.1 依赖注入与循环依赖字段注入Autowired直接标在字段上写起来省事但会让类无法在脱离容器的情况下实例化、单元测试困难、循环依赖问题被掩盖。构造器注入能暴露循环依赖让设计问题在启动时就暴露出来。我的项目里现在基本都用构造器注入配合final字段一个类依赖超过四个组件时就该考虑是不是职责太重了。循环依赖在Spring里能靠三级缓存解决一部分但那是救火不是设计允许。A依赖B、B依赖A说明这两个类的职责边界有问题正确的做法是抽出第三个类或者用事件解耦。5.2 AOP 自调用与 Async 失效和事务失效同源Async、Cacheable、自定义切面全都依赖代理。同类内部调用直接走this注解形同虚设。这个问题在日志切面、限流切面上同样存在。排查方法是启动后看下Bean是不是被CGLIB代理了类名带$$或者在切面里打断点确认有没有进去。5.3 参数校验与全局异常处理Controller层用Valid做参数校验但嵌套对象要加Valid才能级联生效集合元素要加Valid配合NotNull。分组校验用groups区分新增和修改场景。这些细节漏一个非法参数就会穿透到Service然后在某个深层方法里抛出一个看不懂的NPE。全局异常处理要区分业务异常和系统异常。业务异常如余额不足返回明确错误码和提示系统异常如NPE返回统一兜底文案同时打全量日志并告警。最忌讳的是把所有异常都catch住返回操作失败线上出了问题连堆栈都找不到。5.4 Long类型传给前端的精度丢失JavaScript的Number是双精度浮点安全整数范围是-2^531到2^53-1。雪花算法生成的19位ID超过这个范围前端拿到后末几位会变成0导致详情查不到。解决方案是把Long序列化成String返回JsonSerialize(using ToStringSerializer.class) private Long id;或者在全局Jackson配置里统一处理。这个问题在前后端联调时经常被误判成后端数据错了实际是序列化环节丢的精度。6. 日志、异常与可观测性6.1 异常处理的几种错误姿势catch (Exception e) { }空捕获是最恶劣的一种问题被彻底吞掉。e.printStackTrace()在容器环境里输出到标准错误日志系统收不到等于没打。正确做法是用日志框架记录并且带上业务上下文try { orderService.create(cmd); } catch (BusinessException e) { log.warn(创建订单失败, userId{}, skuId{}, reason{}, cmd.getUserId(), cmd.getSkuId(), e.getMessage()); throw e; } catch (Exception e) { log.error(创建订单异常, cmd{}, cmd, e); // 异常对象作为最后一个参数 throw new SystemException(创建订单失败, e); }注意log.error里异常对象要作为最后一个参数直接传不要拼进字符串否则只会打印toString堆栈全丢。另外日志里不要打敏感信息手机号、身份证、银行卡要脱敏密码和token绝对不能出现。6.2 日志级别与占位符用{}占位符而不是字符串拼接避免在日志级别不满足时仍然执行拼接虽然影响很小但养成习惯。级别使用上error留给需要人介入的问题warn留给可自愈的异常info记录关键业务节点下单、支付、状态变更debug放详细参数。别把所有东西都打成info日志量上来之后既贵又难查。6.3 可观测性链路ID分布式环境下一个请求会经过多个服务没有统一链路ID排查问题时只能靠时间戳猜。落地很简单网关生成traceId放进MDC日志格式里带上%X{traceId}跨服务调用时通过Header透传。这个改造量不大但对排查效率的提升是数量级的。7. 常见问题速查表把高频问题整理成一张表方便评审时对照类别典型错误快速判断方法修复方向数值金额用double搜代码里的double字段和BigDecimal构造换BigDecimal用字符串构造数值BigDecimal用equals比较搜.equals(改compareTo类型包装类型用搜Integer/Long的改equals或转基本类型集合foreach里remove搜forremove组合改removeIf集合Arrays.asList后add搜asList包一层new ArrayList并发用Executors创建线程池搜Executors.手写ThreadPoolExecutor并发ThreadLocal未remove搜set未配对removefinally里remove事务同类调用事务方法看Transactional方法是否被this调用拆Bean事务异常被吞搜catch空块抛出或手动回滚数据库函数包裹索引列看explain的type改范围查询数据库深分页看limit偏移量游标分页或延迟关联接口Long传前端精度丢失看返回JSON里的长ID序列化为String日志printStackTrace全局搜改log.error日志异常未传对象看log.error参数异常对象放最后8. 我踩坑之后固化下来的几个习惯第一凡是涉及钱、库存、积分的计算写完必须自己构造边界用例跑一遍0、负数、超大值、除不尽、精度边界。这一步花五分钟能省掉后面几小时的排查。第二涉及并发写的地方先问自己一句两个请求同时进来会怎样。如果答案是不确定那就还没写完。加唯一索引、加版本号、加条件更新选一个能兜底的方案。第三事务方法里绝不做远程调用和耗时操作。这条我现在当成硬性规范来推评审时看到就直接打回。第四所有日志里出现异常的地方必须有业务上下文。一个只有堆栈的error日志在半夜排查时基本等于没有。第五交付前用explain看一眼核心SQL的执行计划。这一步不需要DBA开发自己就能做能挡住大部分慢查询。这份笔记我一直在更新因为业务在变框架在变但错误的形态其实高度收敛——无非是对语言特性的误解、对框架黑盒的想当然、对业务并发的低估。把这三类问题摸透一百例也好两百例也好你都能自己往里加。