踩坑经验 TOP 10
① 没有做幂等:用户双击提交,订单创建两个 → 加唯一索引 + Token 机制
② 金额用 Float:0.1 + 0.2 ≠ 0.3 → 必须用 DECIMAL 或分(Integer)
③ 没有订单快照:商品改价后,历史订单金额也跟着变了 → 冗余商品名和单价
④ 事务范围太大:一个事务里调了第三方接口,超时导致事务回滚 → 第三方调用放事务外
⑤ 没有分页限制:列表接口没 limit,一次查 10 万条 → OOM → 必须有 maxPageSize
⑥ 大事务更新:一个事务更新 10 张表 → 锁竞争 → 拆分事务 + 消息队列异步
⑦ 忘记处理并发:库存扣减没有锁 → 超卖 → 乐观锁/分布式锁
⑧ N+1 查询:列表查了 100 条,循环里每条再查一次关联 → 101 次 SQL → JOIN 或批量查询
⑨ 接口没有超时设置:HTTP 调用第三方没有设超时 → 线程池耗尽 → 设 connectTimeout=3s, readTimeout=5s
⑩ 日志打太多/太少:info 全开 → 磁盘满 / 只打 error → 排查不了 → 分级日志 + 关键路径打 info
总结:后端设计的核心就三个字——想清楚。写代码前把数据流、异常流、并发场景都想清楚,比写完再 debug 效率高 10 倍。
🔍 TOP 10 详细展开(带代码与原因剖析)
1
没有做幂等 → 用户双击/重试导致重复下单
高频血案
用户网络慢,连点两次"提交订单"按钮;或支付回调因网络抖动被微信重发 3 次。后端没做幂等,结果:同一个用户一秒内创建了 2 个订单,扣了 2 次库存,甚至扣了 2 次款。
为什么发生?HTTP 请求是不可靠的,前端防抖挡不住"页面刷新后重试""支付平台主动重发回调"。任何"创建类"接口在外部环境看来都可能被重复触发。
❌ 反例:直接 insert,没有任何防护
@PostMapping("/api/orders")
public Result createOrder(@RequestBody CreateOrderReq req) {
// 每次请求都会创建一个新订单 —— 双击就废了
Order order = orderService.createOrder(req);
return Result.success(order);
}
✅ 正例:Token 机制 + 唯一索引双重防护
// 方案一:前端进入页面时先 GET 一个一次性 token
@PostMapping("/api/orders")
public Result createOrder(@RequestHeader("X-Idempotent-Token") String token,
@RequestBody CreateOrderReq req) {
// Redis SETNX:token 只能被消费一次,过期时间 10 分钟
Boolean ok = redisTemplate.opsForValue()
.setIfAbsent("order:token:" + token, "1", 10, TimeUnit.MINUTES);
if (!ok) throw new BizException(40902, "请勿重复提交");
return Result.success(orderService.createOrder(req));
}
// 方案二(兜底):业务唯一键。用 客户端序列号+用户ID 生成 hash,
// 作为 order_no 插入,靠数据库 UNIQUE 约束兜底,第二次会抛 DuplicateKeyException
order.setOrderNo("ORD" + userId + "-" + req.getClientSeq());
记住:幂等不是"前端按钮置灰"那么简单。生产环境必须是 Token + 业务唯一索引 双保险,前者挡 99% 的重复,后者兜底剩余 1%。
2
金额用 Float/Double → 0.1 + 0.2 ≠ 0.3
财务事故
商品 0.1 元 + 0.2 元,数据库存的 Double 算出来是 0.30000000000000004;又或者一笔大订单累计下来对账少了 3 分钱,财务直接报警。
为什么发生?Float/Double 是 IEEE 754 浮点数,0.1 在二进制下是无限循环小数,必然有精度损失。十进制看起来没问题,二进制存储时已经被截断了。
❌ 反例:Double 类型,对账时永远差几分钱
// Java 端
double total = 0.1 + 0.2;
System.out.println(total);
// 输出:0.30000000000000004 —— 卧槽?
// SQL 端
price DOUBLE COMMENT '价格' -- 灾难开始的地方
✅ 正例:DECIMAL(10,2) 或按"分"存 Long
-- 方案一:DECIMAL 精确小数(推荐业务表用这个)
-- DECIMAL(10,2) = 总共 10 位,小数 2 位,最大 99999999.99
total_amount DECIMAL(10,2) NOT NULL;
-- 方案二:按"分"存 BIGINT(金融场景更彻底)
-- 19.90 元 → 存 1990,永远没有小数问题
amount_cents BIGINT NOT NULL COMMENT '金额(分)';
// Java 端必须用 BigDecimal,运算时必须指定 RoundingMode
BigDecimal price = new BigDecimal("0.1"); // 字符串构造!别用 new BigDecimal(0.1)
BigDecimal total = price.multiply(new BigDecimal("3"))
.setScale(2, RoundingMode.HALF_UP); // 四舍五入到 2 位
三句话记忆:① 数据库永远 DECIMAL,不要 DOUBLE;② Java 永远 BigDecimal,且用字符串构造;③ 计算完一定要 setScale 指定精度和舍入模式。
3
没有订单快照 → 商品改价后历史订单金额被篡改
数据一致性
运营 7 月把 iPhone 价格从 5999 改成 4999,结果用户翻 6 月的历史订单发现也变成 4999 了——客服电话被打爆。
为什么发生?订单表只存了 product_id,展示时 JOIN 商品表取 price。商品表是"当前态",改一次影响所有历史订单。订单的本质是"那一刻的契约",必须冻结。
❌ 反例:订单表只存 product_id,金额实时算
CREATE TABLE order_item (
order_id BIGINT,
product_id BIGINT
-- 没存价格!查询时 SELECT oi.*, p.price FROM order_item oi JOIN product p ...
-- 商品改价 → 历史订单跟着变 → 灾难
);
// Service 层
public BigDecimal calcOrderAmount(Long orderId) {
// 每次都现算,今天 5999 明天 4999,对账时乱套
return orderItemDao.sumPriceByOrderId(orderId);
}
✅ 正例:下单时冗余商品名、单价、规格,订单即"快照"
CREATE TABLE order_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL, // 关联用
product_name VARCHAR(100) NOT NULL, // 快照:下单时的商品名
product_img VARCHAR(255), // 快照:主图 URL
sku_spec VARCHAR(200), // 快照:规格"金色 256G"
unit_price DECIMAL(10,2) NOT NULL, // 快照:成交单价
quantity INT NOT NULL,
INDEX idx_order_id (order_id)
);
// Service 层:把商品信息"抄"到订单里,永久冻结
OrderItem item = new OrderItem();
item.setProductId(product.getId());
item.setProductName(product.getName()); // 拷贝
item.setUnitPrice(product.getPrice()); // 拷贝
item.setSkuSpec(sku.getSpec()); // 拷贝
快照原则:凡涉及"金额、价格、名称、规格、优惠规则"的字段,订单里都要冗余存一份。商品改价改名只影响新订单,历史订单纹丝不动。这是电商最基础的设计,宁可冗余也不能丢。
4
事务范围太大 → 第三方调用超时拖垮整个事务
性能杀手
在 @Transactional 方法里调了微信支付、短信通知、推送服务。结果第三方一抖,超时 30 秒,数据库连接被事务占着不放,连接池瞬间耗尽,整个服务雪崩。
为什么发生?@Transactional 期间数据库连接不释放。第三方调用是网络 IO,时长不可控(可能几十秒)。事务里包网络调用 = 把宝贵的数据库连接借给一个可能卡死的外部依赖。
❌ 反例:一个事务里啥都干,连接被占死
@Transactional
public void payOrder(Long orderId) {
Order order = orderDao.findById(orderId);
order.setStatus(PAID);
orderDao.update(order); // ① DB 操作
payClient.callWechatPay(...); // ② 网络调用 5s —— 数据库连接白白占着!
smsClient.send(...); // ③ 网络调用 3s
pushClient.notify(...); // ④ 网络调用 2s
// 事务提交。整个过程 10s,数据库连接被占 10s
}
✅ 正例:事务只包 DB,外部调用放事务外 + 异步
// 步骤一:短事务,只改状态,毫秒级提交
@Transactional
public Long markOrderPaid(Long orderId) {
Order order = orderDao.findById(orderId);
order.setStatus(PAID);
orderDao.update(order);
return order.getId(); // 事务立刻提交,连接释放
}
// 步骤二:发 MQ,由消费者慢慢处理外部调用
public void payOrder(Long orderId) {
Long id = markOrderPaid(orderId);
messagingTemplate.convertAndSend("order.paid", id); // 立刻返回
}
// 步骤三:消费者慢慢调第三方,失败还能重试
@RabbitListener(queues = "order.paid")
public void onOrderPaid(Long orderId) {
smsClient.send(...); // 慢就慢,不占数据库连接
pushClient.notify(...);
}
事务边界原则:事务里只放"对本地数据库的写操作",所有跨进程调用(HTTP、RPC、发 MQ、写 Redis)一律放事务外。事务越短越好,理想 < 50ms。
5
列表接口没分页上限 → 一次查 10 万条直接 OOM
OOM 经典
"查我的全部订单"接口,前端没传 pageSize 时默认返回所有数据。某用户下了 10 万单(羊毛党),后端一次性把 10 万条塞进 List,JVM 老年代爆满,Full GC,服务假死。
为什么发生?开发者默认以为"正常用户就几十条数据",没考虑边界。再加上前端误传 pageSize=999999 或不传 limit,SQL 层直接 `SELECT * FROM orders WHERE user_id=?` 不带 LIMIT。
❌ 反例:信任前端的 pageSize,无硬上限
@GetMapping("/api/orders")
public Result listOrders(@RequestParam Integer pageSize) {
// 前端传 pageSize=100000,直接信任 → 灾难
List<Order> list = orderDao.findByUserId(userId, pageSize);
return Result.success(list);
}
<!-- 前端 BUG 或恶意调用 -->
GET /api/orders?pageSize=999999
✅ 正例:服务端强制硬上限,且默认值要小
@GetMapping("/api/orders")
public Result listOrders(@RequestParam(required=false) Integer page,
@RequestParam(required=false) Integer pageSize) {
// 强制边界:默认 20,最大 100,谁也别想传 10 万
int p = page == null ? 1 : Math.max(1, page);
int size = pageSize == null ? 20 : Math.min(pageSize, 100); // 硬上限 100
return Result.success(orderDao.findPage(userId, p, size));
}
// 导出类需求(确实要全量)走异步任务,别走同步接口
@PostMapping("/api/orders/export")
public Result exportOrders() {
String taskId = exportService.submitAsync(userId);
return Result.success(taskId); // 返回任务 ID,异步生成文件
}
防守底线:① 所有列表接口必有分页参数;② 服务端强制 pageSize ≤ 100;③ 真要查全量走异步导出,绝不同步返回。这是后端的"安全带"。
6
大事务更新多张表 → 锁竞争 + 长事务
性能杀手
下单时一个事务更新:用户表(扣余额)+ 商品表(扣库存)+ 订单表(建订单)+ 优惠券表(标记已用)+ 积分表(加积分)+ 日志表。结果高并发下全部锁互相等待,TPS 从 1000 掉到 50。
为什么发生?每个 UPDATE 都会加行锁,事务越长锁持有时间越长。多个事务都要改同一批表时,锁等待链形成,互相阻塞。事务越大,死锁概率越高。
❌ 反例:上帝事务,啥都往里塞
@Transactional
public void createOrder(OrderReq req) {
userDao.deductBalance(...); // 锁用户行
productDao.decreaseStock(...); // 锁商品行
couponDao.markUsed(...); // 锁优惠券行
orderDao.insert(...); // 锁订单索引
pointDao.addPoint(...); // 锁积分行
logDao.insert(...); // 锁日志表
// 整个事务持有 6 把锁,其他线程全在排队
}
✅ 正例:核心事务最小化,次要操作异步化
// 核心事务:只做"扣库存 + 建订单"这两件必须原子的事
@Transactional
public Order createOrderCore(OrderReq req) {
int rows = productDao.decreaseStock(req.getProductId(), req.getQty());
if (rows == 0) throw new BizException(40901, "库存不足");
Order order = buildOrder(req);
orderDao.insert(order);
return order; // 事务提交,锁立即释放
}
// 外围编排:扣余额、扣券、加积分等都异步
public Order createOrder(OrderReq req) {
Order order = createOrderCore(req);
// 用本地消息表 / MQ 保证最终一致
eventPublisher.publish(new OrderCreatedEvent(order));
return order;
}
// 各消费者独立处理,互不阻塞
@EventListener + @Async
public void onOrderCreated(OrderCreatedEvent e) {
pointService.addPoint(e.getUserId());
couponService.markUsed(e.getCouponId());
}
拆事务口诀:"必须原子的"放事务里(如扣库存+建订单),"可以异步的"放事务外(加积分、发短信、记日志)。锁持有时间从 100ms 降到 10ms,TPS 立刻翻倍。
7
库存扣减没锁 → 并发超卖
资损事故
秒杀场景,库存 1 件,10 个用户同时下单。代码"先查库存再扣",10 个线程都查到 stock=1,都判断够,都执行 UPDATE,结果库存变成 -9,发货时懵了——发给谁?
为什么发生?"查-判-改"是三步操作,不是原子的。线程 A 查到 1、还没扣,线程 B 也查到 1,都通过了判断,最后都扣减。典型的 TOCTOU(Time of Check to Time of Use)问题。
❌ 反例:查-判-改三步分离,超卖必然发生
public void createOrder(Long productId, Integer qty) {
Product p = productDao.findById(productId);
if (p.getStock() < qty) throw new BizException("库存不足");
// 线程 A 执行到这里时 stock=1,线程 B 也执行到这里 stock 还是 1
p.setStock(p.getStock() - qty);
productDao.update(p); // 两个线程都 update 成 stock=0,超卖!
}
✅ 正例:乐观锁(UPDATE 带 WHERE 条件原子判断)
<!-- Mapper:UPDATE 语句自带条件,靠数据库行锁保证原子 -->
<update id="decreaseStock">
UPDATE product
SET stock = stock - #{qty}
WHERE id = #{productId} AND stock >= #{qty} <!-- 关键!-->
</update>
// Service:看返回的影响行数,0 表示没扣成功
public void createOrder(Long productId, Integer qty) {
int rows = productDao.decreaseStock(productId, qty);
if (rows == 0) throw new BizException(40901, "库存不足,请重试");
orderDao.insert(buildOrder(...));
}
// 极高并发(秒杀):再加 Redis 预扣减
Long remain = redisTemplate.opsForValue().decrement("stock:"+pid, qty);
if (remain < 0) { // 扣过了,回滚
redisTemplate.opsForValue().increment("stock:"+pid, qty);
throw new BizException("售罄");
}
三档方案:① 普通并发 → DB 乐观锁(UPDATE...WHERE stock>=qty);② 高并发 → Redis 预扣减(lua 原子脚本);③ 极端秒杀 → Redis + 异步落库 + 限流。永远不要"查完再改"。
8
N+1 查询 → 列表接口拖垮数据库
性能杀手
查订单列表(1 次 SQL,得 100 条),然后 for 循环里每条订单查一次用户名、查一次商品名。100 条订单 = 201 次 SQL。原本 50ms 的接口变成 2 秒,数据库 CPU 飙满。
为什么发生?ORM(MyBatis、JPA)的延迟加载特性,加上 Service 层习惯性"循环里调 dao"。每次循环开一次数据库连接、发一次 SQL、回一次结果,单看每次都不慢,累积起来灾难。
❌ 反例:循环里调 DAO,N 条数据 = N+1 次 SQL
public List<OrderVO> listOrders(Long userId) {
List<Order> orders = orderDao.findByUserId(userId); // SQL #1,得 100 条
List<OrderVO> result = new ArrayList<>();
for (Order o : orders) {
User u = userDao.findById(o.getUserId()); // SQL #2~101
Product p = productDao.findById(o.getProductId()); // SQL #102~201
result.add(toVO(o, u, p));
}
return result; // 总共 201 次 SQL,2 秒起步
}
✅ 正例:批量查询 + 内存组装,201 次 SQL → 3 次
public List<OrderVO> listOrders(Long userId) {
List<Order> orders = orderDao.findByUserId(userId); // SQL #1
if (orders.isEmpty()) return Collections.emptyList();
// 收集所有需要的 ID,去重
Set<Long> userIds = orders.stream().map(Order::getUserId).collect(toSet());
Set<Long> productIds = orders.stream().map(Order::getProductId).collect(toSet());
// 一次性批量查(WHERE id IN (...)),1 次 SQL 拿全部
Map<Long, User> userMap = userDao.findByIds(userIds).stream()
.collect(toMap(User::getId, Function.identity())); // SQL #2
Map<Long, Product> productMap = productDao.findByIds(productIds).stream()
.collect(toMap(Product::getId, Function.identity())); // SQL #3
// 内存里组装,不再访问数据库
return orders.stream().map(o -> toVO(o,
userMap.get(o.getUserId()),
productMap.get(o.getProductId()))).collect(toList()); // 0 次 SQL
}
<!-- Mapper:用 IN 一次查多个 -->
<select id="findByIds" resultType="User">
SELECT * FROM user WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
排查技巧:打开 MyBatis/SQL 日志,看一次请求打了几条 SQL。>5 条就要警惕,>20 条基本是 N+1。修复方法:循环外批量查 IN,内存里组装。
9
HTTP 调用没设超时 → 线程池耗尽服务雪崩
雪崩元凶
调第三方支付接口,对方突然卡死不响应。本地线程在 socket.read() 上死等,默认超时可能是 60 秒甚至无限。Tomcat 200 个线程很快全卡死,整个服务对所有用户拒绝服务。
为什么发生?HttpClient / OkHttp / RestTemplate 默认超时配置往往很大或不设。再加上没做熔断(Hystrix/Sentinel),一个慢依赖把整个线程池拖垮,级联故障,全链路雪崩。
❌ 反例:用默认配置,无超时无熔断
// RestTemplate 默认无超时,或 SimpleClientHttpRequestFactory 默认 -1(无限)
@Bean
public RestTemplate restTemplate() {
return new RestTemplate(); // 第三方卡死,线程死等 60s+
}
// 业务代码
public PayResult pay(Order order) {
// 没超时,没熔断,没降级
return restTemplate.postForObject(payUrl, order, PayResult.class);
}
✅ 正例:连接超时 + 读取超时 + 熔断降级
@Bean
public RestTemplate restTemplate() {
SimpleClientHttpRequestFactory f = new SimpleClientHttpRequestFactory();
f.setConnectTimeout(3000); // 建立连接最多等 3 秒
f.setReadTimeout(5000); // 读取响应最多等 5 秒
return new RestTemplate(f);
}
// 业务层加熔断(Sentinel 示例)
@SentinelResource(value = "pay",
blockHandler = "payBlocked", // 被限流熔断时走这里
fallback = "payFallback") // 异常时走这里
public PayResult pay(Order order) {
return restTemplate.postForObject(payUrl, order, PayResult.class);
}
// 降级方法:返回友好提示,不让用户看到 500
public PayResult payFallback(Order order, Throwable e) {
log.error("支付调用失败,订单 {}", order.getId(), e);
return PayResult.retry("支付通道繁忙,请稍后重试");
}
// 配合异步重试( MQ 延迟队列)保证最终一致
三道防线:① HTTP 客户端必设 connectTimeout=3s, readTimeout=5s;② 调用方加熔断(Sentinel/Resilience4j),慢依赖快速失败;③ 关键调用加降级返回,保证用户体验。永远不要相信"第三方永远不会慢"。
10
日志要么打太多要么打太少 → 排查无门 / 磁盘爆满
排查噩梦
线上出问题查日志,发现:要么 INFO 全开每天打 50G 日志,磁盘报警;要么只打了 ERROR,业务关键路径一条 INFO 都没有,复现不了问题,只能靠猜。
为什么发生?日志是事后排查的唯一线索,但开发期往往凭感觉乱打。生产环境只看 ERROR 看不到上下文,全开 INFO 又是灾难。需要明确的日志分级策略 + 关键路径埋点。
❌ 反例:乱打日志,要么洪水要么静默
// 反例 1:循环里打 INFO,10 万次循环 = 10 万行日志
for (Order o : orders) {
log.info("processing order {}", o.getId()); // 磁盘杀手
process(o);
}
// 反例 2:异常被吞,连日志都不打
try {
doSomething();
} catch (Exception e) {
// 啥也不做 —— 线上死无对证
}
// 反例 3:DEBUG 日志在生产环境也开着
log.debug("user data: {}", JsonUtils.toJson(hugeObject));
✅ 正例:分级日志 + 关键路径 + TraceId
// 分级策略(生产环境):
// ERROR:系统异常、业务失败、需要告警人处理的
// WARN:可恢复的异常、降级、重试、接近阈值
// INFO:关键业务节点(下单、支付、登录)+ 入口出参
// DEBUG:调试细节,默认关闭,按需开启
// 关键路径打 INFO,带上 traceId 方便串联
MDC.put("traceId", TraceContext.get()); // 链路追踪 ID
public Order createOrder(OrderReq req) {
log.info("[下单] userId={}, productId={}, qty={}",
req.getUserId(), req.getProductId(), req.getQty());
try {
Order order = doCreate(req);
log.info("[下单成功] orderNo={}", order.getOrderNo());
return order;
} catch (BizException e) {
log.warn("[下单失败-业务] userId={}, reason={}",
req.getUserId(), e.getMessage()); // 业务异常用 WARN
throw e;
} catch (Exception e) {
log.error("[下单失败-系统] userId={}", req.getUserId(), e); // 带堆栈
throw new SystemException(e);
}
}
// 循环里只打 DEBUG(生产默认关),或用采样
for (Order o : orders) {
if (log.isDebugEnabled()) { // 先判断再拼字符串,省性能
log.debug("processing {}", o.getId());
}
}
日志口诀:① 入口出参打 INFO;② 业务失败打 WARN(带 traceId);③ 系统异常打 ERROR(带完整堆栈);④ 循环细节用 DEBUG + isDebugEnabled 判断;⑤ 永远带 traceId 方便串联调用链。
💡 面试加分点
这 10 个坑的回答深度直接决定后端面试评价。初级能说出"是什么";中级能说出"为什么发生"+"怎么修";高级能结合"生产场景"+"数据指标"+"监控告警"完整闭环。建议每个坑准备一个"我经历过/我设计过"的故事。