后端工作习惯与暗规则大全

那些后端"默认知道"但从来不会写在文档里的规则和习惯

这些"暗规则"是新手和专家之间的隐形鸿沟。读完这篇,你能少踩80%的坑

暗规则 工作习惯 经验总结 防御性编程 最佳实践

这些规则不是"教科书"上的内容,而是后端开发者在生产环境中用血泪换来的经验。每一条背后都有真实的事故故事。对于前端出身的你,理解这些暗规则能帮你快速建立"后端直觉"。

🔴 为什么需要"暗规则"?一个真实故事
新人小张入职第一天,写了这样一行代码:String name = userMap.get("name").toString();
代码 review 时老员工没注意就合并了。上线第二天凌晨 3 点告警炸了—— userMap.get("name") 返回了 null,调用 .toString() 直接抛 NullPointerException, 整个订单查询接口雪崩。

老员工修了 5 秒钟:String.valueOf(userMap.get("name"))——一行代码的区别。 但小张不明白:为什么 Map.get() 会返回 null?文档没写啊!

答案:生产环境的 Map 是从 Redis 反序列化来的,Redis 里根本没有 "name" 这个 key。 后端代码的每一个外部输入都可能为 null——这不是理论,是每天线上都在发生的事。 这篇文档就是把这些"老员工以为你知道但其实没人告诉过你"的规则全部写出来。

1. NPE(空指针异常)防御术

NPE是Java后端最常见的线上异常。后端老手的代码里充满了空值检查——这不是胆小,是被打怕了。

场景新手写法老手写法为什么
从Map取值String name = map.get("name").toString();String name = String.valueOf(map.get("name"));map.get可能返回null,调.toString()就NPE
调用第三方接口int code = response.getCode();int code = response != null ? response.getCode() : -1;第三方接口可能返回null response
字符串比较if (status.equals("ACTIVE"))if ("ACTIVE".equals(status))常量放前面,status为null也不会NPE
List遍历for (Item i : list) { ... }if (CollectionUtils.isNotEmpty(list)) { for ... }list可能为null,遍历null会NPE
Optional使用user.getName()Optional.ofNullable(user).map(User::getName).orElse("unknown")链式调用中任何一环为null都不崩
JSON解析String id = json.get("id").asString();String id = json.has("id") ? json.get("id").asString() : null;JSON结构可能和你预期的不一样
// 后端老手的"空值防御链" public UserInfo getUserInfo(Long userId) { // 1. 入参校验 if (userId == null || userId <= 0) { throw new IllegalArgumentException("userId不能为空"); } // 2. 查缓存(可能为null) UserInfo cached = (UserInfo) redis.get("user:" + userId); if (cached != null) { return cached; // 缓存命中 } // 3. 查DB(可能为null) UserInfo user = userMapper.selectById(userId); if (user == null) { log.warn("用户不存在, userId={}", userId); return null; // 或者抛业务异常 } // 4. 写缓存(防穿透:空值也缓存) redis.set("user:" + userId, user, 300, TimeUnit.SECONDS); return user; }
⚡ 暗规则 #1:后端代码中每个外部输入都要校验。什么是"外部输入"?①用户传入的参数 ②数据库查出来的数据 ③第三方接口返回的数据 ④Redis/缓存读出来的数据 ⑤消息队列消费的消息。这些数据都不可信——可能为null、可能格式不对、可能是脏数据。对每一个外部输入做校验和防御,是后端的生存本能。

2. 日志习惯:线上排查的生命线

场景新手写法老手写法为什么
方法入口不记日志log.info("开始处理, userId={}, action={}", userId, action)知道"什么时候谁做了什么"
异常catche.printStackTrace()log.error("处理失败, userId={}", userId, e)printStackTrace打到stdout不进日志文件
外部调用不记日志log.info("调用支付接口, req={}", req); ... log.info("支付返回, resp={}", resp)出问题时知道是自己的bug还是第三方的bug
条件分支不记日志log.info("走缓存分支, key={}", key)排查时知道代码走了哪个分支
日志级别全用info或全用errorinfo=正常流程,warn=可预期异常,error=不可预期异常错误的级别会导致告警失准
占位符log.info("用户:" + user)log.info("用户:{}", user)字符串拼接在日志级别不输出时浪费性能
日志是线上排查的唯一线索。当线上出问题的时候,你不能像本地调试那样打断点。你能做的只有看日志。所以:
1. 关键路径必须有日志(方法入口、外部调用、异常catch)
2. 日志要有上下文(userId、orderId等关键参数)
3. 日志格式要规范(JSON格式,方便ELK采集和搜索)
4. 敏感信息不能记日志(密码、身份证号要脱敏)
⚡ 暗规则 #2:日志里永远不要打印完整密码/身份证号/银行卡号。脱敏后再打。因为日志会被采集到ELK/Loki等日志系统,如果日志里有明文密码,整个系统就变成了"密码库"。正确做法:log.info("用户注册, phone={}", PhoneUtil.mask(phone)),输出138****8000

3. 索引检查:上线前的必修课

后端老手提交代码前一定会做的事——检查每一条SQL是否走索引:

📋 上线前SQL检查清单

检查项怎么检查标准
WHERE条件有没有索引EXPLAIN看key列key不能是NULL
扫描行数是否合理EXPLAIN看rows列rows应该接近实际返回行数,不能差几个数量级
有没有filesortEXPLAIN看Extra列不能有"Using filesort"(内存排序很慢)
有没有temporary tableEXPLAIN看Extra列不能有"Using temporary"(临时表很慢)
JOIN的连接条件有没有索引看JOIN的两表的ON条件列被JOIN的表的连接列必须有索引
LIKE查询检查LIKE的模式LIKE 'xxx%'走索引,LIKE '%xxx'不走索引
有没有SELECT *审查SQL不要SELECT *,只查需要的列
// 后端老手写完Mapper后的标准流程 @Autowired private OrderMapper orderMapper; public List<Order> queryOrders(Long userId, String status) { // 1. 写完SQL后,立刻在DB客户端里跑EXPLAIN // EXPLAIN SELECT * FROM orders // WHERE user_id = #{userId} AND status = #{status} // ORDER BY created_at DESC // 2. 确认EXPLAIN结果: // type = ref (走了索引) ✅ // key = idx_user_status_created ✅ // rows = 50 (扫描50行) ✅ // Extra = Using where (无filesort) ✅ // 3. 确认通过后再用 return orderMapper.selectByUserIdAndStatus(userId, status); }
⚡ 暗规则 #3:EXPLAIN是后端最重要的工具之一。每一条上线SQL都应该跑一遍EXPLAIN确认索引使用情况。特别是JOIN查询、子查询、LIKE查询——这三类是最容易全表扫描的。养成"写完SQL必EXPLAIN"的习惯,能避免80%的慢SQL问题。

4. 时间估算:为什么后端总是"差3倍"

任务新人估时老手估时差异原因
写一个CRUD接口1小时3小时新人只算"写代码",老手包含:参数校验+异常处理+日志+单元测试+Swagger文档
对接第三方支付半天2-3天新人只算"调接口",老手包含:签名验证+回调处理+幂等+对账+异常重试+沙箱测试
修改一个已有接口30分钟2小时新人只算"改代码",老手包含:读代码理解逻辑+确认影响范围+兼容旧版本+自测+回归测试
上线一个新功能1天3天新人只算"写+部署",老手包含:DB迁移+灰度发布+监控配置+回滚方案+线上验证
后端估时的隐藏成本
1. 理解代码:改老代码前要先读懂(30%时间)
2. 写测试:自测+单元测试(25%时间)
3. 调试:跑通各种异常场景(20%时间)
4. 文档:接口文档+部署文档(10%时间)
5. 联调:和前端/其他后端对接(15%时间)
真正"写代码"只占20-30%的时间。新人估时只算"写代码"的时间,所以总是差3-5倍。
⚡ 暗规则 #4:估时的乘3法则:你直觉觉得要多久 × 3 = 实际需要的时间。这不是悲观,而是包含了所有隐藏成本。如果乘3后还做不完,说明需求理解有偏差或技术方案有问题——这才是该停下来重新评估的信号。

5. API契约:前后端协作的核心

暗规则说明违反后果
响应结构统一所有接口返回 {code, message, data} 结构前端解析逻辑混乱,每个接口都要特殊处理
错误码有层次2xx成功,4xx客户端错误,5xx服务端错误前端无法判断是用户操作错误还是系统故障
时间用ISO 86012026-04-15T14:30:00+08:00 而不是时间戳时区处理错误,不同地区显示不同时间
金额用分不用元存储和传输用整数(分),显示时转换浮点精度丢失(0.1+0.2≠0.3)
分页参数统一page从1开始,size默认20,max100前端不知道传什么参数,接口行为不一致
字段命名一致全用camelCase或全用snake_case,不要混用前端解析困难,容易拼错字段名
版本管理URL带版本号 /api/v1/...升级接口时破坏旧客户端
幂等设计POST接口支持幂等key,防重复提交用户快速双击导致重复下单/重复支付
⚡ 暗规则 #5:金额永远用整数存储。不要用double/float存金额!0.1 + 0.2在浮点数里不等于0.3。
正确做法:用整数存"分"(199分=1.99元),或者用BigDecimal。
这是后端的铁律——如果你看到代码里用double存金额,那是一个定时炸弹。

6. 部署清单:上线前必做的10件事

#检查项怎么验证
1代码已通过Code Review并合并GitLab MR状态=Merged
2所有单元测试通过CI流水线绿色
3数据库迁移脚本已验证在staging环境跑过且不锁表
4接口文档(YAPI)已更新前端已确认接口定义
5回滚方案已准备记录了上一个稳定版本的镜像tag
6监控告警已配置接口RT、错误率、QPS都有告警规则
7新功能的Feature Flag已配置出问题可以秒级关闭功能
8日志级别正确生产环境不要用DEBUG级别
9发布窗口合适不要周五下午!不要大促期间!
10已通知相关方在工作群发了上线公告
⚡ 暗规则 #6:永远不要在周五下午5点后发布。这是后端世界的不成文规矩。周五下午发布 = 如果出问题,你需要周末加班修。如果必须周五发,至少留3小时窗口期 + 有人值班。最佳发布时间:周二到周四下午2-4点。

7. 数据库暗规则

暗规则说明违反后果
不用SELECT *只查需要的列传输冗余数据、无法用覆盖索引、表结构变更时可能报错
大表DDL用ONLINE DDLALTER TABLE加ALGORITHM=INSTANTDDL锁表导致服务不可用
事务要短不要在事务里调外部接口长时间持有事务=长时间持有锁=性能下降
不用外键应用层保证数据一致性外键影响写入性能,且分库分表后无法使用
批量操作分批INSERT 1000条要分10批大批量操作锁表、事务日志暴涨
软删除用状态字段is_deleted=1 而不是DELETE数据可恢复,审计有据可查
所有表都要有created_at/updated_at自动维护时间戳排查问题时不知道数据什么时候变的
字符集用utf8mb4不要用utf8(MySQL的utf8是3字节的)存emoji或生僻字时报错

8. 缓存暗规则

暗规则说明
缓存必须设TTL不要永久缓存——否则数据不一致永远自愈不了
缓存key要有命名空间user:info:123 而不是 123——避免不同业务的key冲突
防缓存穿透查不到的数据也缓存null值(短TTL),防止恶意请求打穿缓存
防缓存击穿热点key失效时用互斥锁(只允许一个请求重建缓存)
防缓存雪崩TTL加随机偏移(300±60秒),防止大量key同时过期
大Value拆分单个Redis Value不超过10KB,大对象用Hash或分片
缓存更新用"延迟双删"删缓存→更新DB→延迟500ms→再删缓存(解决并发不一致)

9. 并发暗规则

暗规则说明
共享资源必须加锁多个线程同时修改同一个变量=数据不一致
锁要设超时tryLock(3, TimeUnit.SECONDS)——不要死等,防止死锁
锁要在finally里释放否则异常时锁泄漏,其他线程永远拿不到锁
锁的粒度要小锁userId而不是锁整个方法——减少锁竞争
锁的顺序要一致多个锁的获取顺序必须全局一致——否则死锁
数据库层面用乐观锁version字段+CAS,比悲观锁(select for update)性能好
分布式锁用Redisson不要自己实现分布式锁(容易有bug),Redisson已经处理了各种边界情况
// 分布式锁的标准写法(后端老手的模板) RLock lock = redisson.getLock("order:create:lock:" + userId); try { // 1. 尝试获取锁,最多等3秒,锁自动过期10秒 boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS); if (!locked) { // 2. 拿不到锁的快速失败,不要死等 throw new BizException("操作太频繁,请稍后重试"); } // 3. 业务逻辑(持有锁期间) doBusinessLogic(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BizException("操作被中断"); } finally { // 4. finally里释放锁——关键! if (lock.isHeldByCurrentThread()) { lock.unlock(); } }
⚡ 暗规则 #7:finally块里释放锁时,一定要先检查 isHeldByCurrentThread()。因为锁可能已经因为超时自动释放了,此时你再unlock会抛IllegalMonitorStateException。这个检查不是多余的——是防御性编程。

总结:后端"暗规则"的底层逻辑

这些暗规则表面上都是技术细节,但底层逻辑只有一个:"永远假设最坏的情况会发生"

✅ 假设输入可能是null → 所以空值检查
✅ 假设SQL可能慢 → 所以EXPLAIN检查
✅ 假设缓存可能不一致 → 所以延迟双删
✅ 假设锁可能泄漏 → 所以finally释放
✅ 假设上线可能出问题 → 所以准备回滚
✅ 假设周五发布可能加班 → 所以不在周五发布

这就是后端的防御性思维——不是悲观,而是对生产环境的敬畏。每一行代码都可能被数万用户访问,每一个bug都可能造成真金白银的损失。这种"假设最坏"的思维习惯,是新手和专家之间最大的区别。

类比——老司机开车:新手开车只看前方。老司机开车同时看:前方路况、后视镜、盲区、导航预告、仪表盘。不是因为老司机紧张,而是因为他知道每个方向都可能出状况。后端开发也一样——你写的代码要同时考虑:正常流程、异常流程、并发场景、网络故障、数据库故障、缓存故障。这就是"防御性编程"的本质。