1. 为什么业务代码的错误总是同一类
刚带团队那两年,我有个坏习惯:每次代码评审都盯着格式和命名,真正出问题的地方反而放过了。后来线上接连几次事故——金额少算一分、库存超卖、定时任务重复执行、事务没回滚——复盘时发现根因惊人地相似,全是些"教科书上写过、但没人真当回事"的地方。于是我开始做一件事:把每次故障、每个代码评审里挑出的问题,按"错误现象、触发条件、根因、正确写法"四条记录下来,攒到一百条左右,就有了这份《Java高手笔记之业务开发常见错误100例》的雏形。它不是语法手册,也不是框架文档,而是一份从真实业务场景里长出来的"翻车清单"。
这份笔记能解决什么问题?简单说,它帮你把"能跑"和"跑得稳"之间的那条鸿沟填上。业务开发的难点从来不是写不出功能,而是功能在并发、异常、边界数据、框架代理、数据库隔离级别这些变量叠加之后还能不能保持正确。它适合三类人看:刚工作一两年、CRUD写得飞快但没经历过线上事故的同学;工作三五年、开始负责核心链路、需要把代码质量往上提一档的开发者;以及做技术评审、需要一份可对照的检查项的团队负责人。下面我按领域把最有价值的几十条拆开讲,剩下的以清单和速查表形式给你,你完全可以当成自己项目的评审依据。
2. 数值与类型:最基础的地方翻车最狠
2.1 金额千万别用double,BigDecimal也用不对
这是业务开发里出现频率最高的一类错误,没有之一。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,整体类型提升为int,null拆箱直接NPE int result = flag ? status : 0; // Map取值直接赋给基本类型 Map<String, 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会抛UnsupportedOperationException;ArrayList.subList返回的也是视图,原列表结构一变,子列表再用就抛ConcurrentModificationException。这两个方法的名字太有迷惑性,看起来像"转换",实际是"包装"。
遍历中删除元素也是老生常谈但依然高发:
// 错误:foreach底层是Iterator,直接remove会抛异常 for (String s : list) { if (s.startsWith("tmp")) list.remove(s); } // 正确:用Iterator.remove Iterator<String> 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,任务堆积时内存会一路涨到OOM;newCachedThreadPool的最大线程数是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密集型给核数 + 1,IO密集型给核数 * 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会扫描前一百万行再丢弃。优化方式是游标分页(记住上一页最后一条的ID,where 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^53+1到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 | 搜for+remove组合 | 改removeIf |
| 集合 | Arrays.asList后add | 搜asList | 包一层new ArrayList |
| 并发 | 用Executors创建线程池 | 搜Executors. | 手写ThreadPoolExecutor |
| 并发 | ThreadLocal未remove | 搜set未配对remove | finally里remove |
| 事务 | 同类调用事务方法 | 看@Transactional方法是否被this调用 | 拆Bean |
| 事务 | 异常被吞 | 搜catch空块 | 抛出或手动回滚 |
| 数据库 | 函数包裹索引列 | 看explain的type | 改范围查询 |
| 数据库 | 深分页 | 看limit偏移量 | 游标分页或延迟关联 |
| 接口 | Long传前端精度丢失 | 看返回JSON里的长ID | 序列化为String |
| 日志 | printStackTrace | 全局搜 | 改log.error |
| 日志 | 异常未传对象 | 看log.error参数 | 异常对象放最后 |
8. 我踩坑之后固化下来的几个习惯
第一,凡是涉及钱、库存、积分的计算,写完必须自己构造边界用例跑一遍:0、负数、超大值、除不尽、精度边界。这一步花五分钟,能省掉后面几小时的排查。
第二,涉及并发写的地方,先问自己一句"两个请求同时进来会怎样"。如果答案是"不确定",那就还没写完。加唯一索引、加版本号、加条件更新,选一个能兜底的方案。
第三,事务方法里绝不做远程调用和耗时操作。这条我现在当成硬性规范来推,评审时看到就直接打回。
第四,所有日志里出现异常的地方,必须有业务上下文。一个只有堆栈的error日志,在半夜排查时基本等于没有。
第五,交付前用explain看一眼核心SQL的执行计划。这一步不需要DBA,开发自己就能做,能挡住大部分慢查询。
这份笔记我一直在更新,因为业务在变,框架在变,但错误的形态其实高度收敛——无非是对语言特性的误解、对框架黑盒的想当然、对业务并发的低估。把这三类问题摸透,一百例也好,两百例也好,你都能自己往里加。