← 返回面试目录

🏗️ 后端 API 与数据库设计实战

需求分析 → 表设计 → 索引规划 → API设计 → 代码分层 · 手把手全流程

需求分析 表结构设计 API规范 索引规划 代码分层

后端设计全流程

1. 需求分析
核心功能 + 边界条件
2. 数据建模
ER图 + 表结构
3. 索引规划
查询场景 → 索引
4. API 设计
RESTful + 规范
5. 代码分层
Controller→Service→DAO
6. 测试上线
单元测试 → 灰度

后端设计的核心:先想清楚数据流和业务流程,再动手写代码。需求分析 → 数据建模 → 索引规划 → API 设计 → 代码分层,每一步都有明确目标。最忌讳跳过分析直接写代码,后期返工成本极高。

需求分析(写代码前必做)

需求分析 Checklist

拿到需求后,先回答这 8 个问题:

核心功能是什么?一句话说清楚
用户是谁?普通用户 / 管理员 / 系统
正常流程:用户怎么操作?每一步做什么?
异常流程:如果出错了怎么办?(网络断了、数据不存在、权限不够)
并发量预估:QPS 大概多少?会不会有大促场景?
数据量预估:一天多少数据?一年多少?需要分表吗?
安全要求:需要权限控制吗?敏感数据要加密吗?
兼容性:老数据怎么迁移?老 API 要兼容吗?

案例:电商订单系统 — 需求分析

核心功能:用户在线下单购买商品,支付,发货,收货。

正常流程:选商品 → 加入购物车 → 下单 → 支付 → 等待发货 → 确认收货

异常流程:

• 商品售罄怎么办?→ 库存为 0 前端置灰,后端也要校验

• 支付失败怎么办?→ 订单标记为"待支付",15分钟未支付自动取消

• 用户取消订单?→ 已支付未发货可退,已发货不退

• 同一商品多人同时下单?→ 库存扣减要原子操作

数据量:日均 5000 单,峰值 QPS ≈ 50,一年约 180 万条

表结构设计

设计原则

主键:用自增 ID 或雪花 ID,不要用业务字段做主键
字段命名:蛇形命名法(snake_case),如 order_id、created_at
必须字段:id、created_at、updated_at、is_deleted(逻辑删除)
金额字段:用 DECIMAL(10,2),不要用 FLOAT/DOUBLE
状态字段:用 TINYINT + 注释说明含义,不要用 enum
时间字段:用 DATETIME 或 TIMESTAMP(注意时区)
字符串长度:按实际需求设,不要什么都 VARCHAR(255)

案例:电商订单表结构

-- 商品表
CREATE TABLE product (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(50) NOT NULL COMMENT '商品名',
  price DECIMAL(10,2) NOT NULL COMMENT '价格',
  category VARCHAR(20) COMMENT '分类:电子/服饰/食品',
  stock INT DEFAULT 0 COMMENT '库存',
  status TINYINT DEFAULT 1 COMMENT '1上架 0下架',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) COMMENT='商品表';

-- 订单表
CREATE TABLE orders (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  order_no VARCHAR(32) UNIQUE NOT NULL COMMENT '订单号',
  user_id BIGINT NOT NULL,
  total_amount DECIMAL(10,2) NOT NULL,
  status TINYINT COMMENT '0待支付 1已支付 2已发货 3已完成 4已取消 5已退款',
  pay_time DATETIME COMMENT '支付时间',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  INDEX idx_user_id (user_id),
  INDEX idx_status_created (status, created_at)
) COMMENT='订单表';

-- 订单明细表(一个订单可能包含多个商品)
CREATE TABLE order_item (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  order_id BIGINT NOT NULL,
  product_id BIGINT NOT NULL,
  product_name VARCHAR(50) COMMENT '冗余:下单时的商品名',
  quantity INT NOT NULL DEFAULT 1,
  unit_price DECIMAL(10,2) COMMENT '冗余:下单时的单价',
  INDEX idx_order_id (order_id)
) COMMENT='订单明细';

为什么要冗余 product_name 和 unit_price?因为商品可能改价改名,但订单里的数据不能变。这是订单快照思维——下单那一刻的数据要冻住。

索引规划

索引设计思路

原则:先想清楚有哪些查询场景,再建索引。

查询场景SQL建什么索引
用户查自己的订单 WHERE user_id = ? idx_user_id (user_id)
后台查某状态的订单 WHERE status = ? ORDER BY created_at idx_status_created (status, created_at)
按订单号查 WHERE order_no = ? UNIQUE (order_no) ← 已经建了
订单详情 WHERE order_id = ? idx_order_id (order_id)

常见错误:不是越多索引越好!索引多了写入变慢。一张表 5~6 个索引就很多了。只给真正在 WHERE / ORDER BY / JOIN 中用到的字段建索引。

API 设计规范

RESTful API 设计原则

操作MethodURL说明
查商品列表GET/api/products查询用 GET
查商品详情GET/api/products/{id}路径参数
创建订单POST/api/orders创建用 POST
查订单详情GET/api/orders/{id}幂等查询
取消订单PATCH/api/orders/{id}/cancel部分更新用 PATCH
删除商品DELETE/api/products/{id}逻辑删除

统一返回格式

// 成功
{
  "code": 200,
  "message": "success",
  "data": { ... }
}

// 失败
{
  "code": 40001,
  "message": "库存不足",
  "data": null
}

// 分页
{
  "code": 200,
  "data": {
    "list": [...],
    "total": 128,
    "page": 1,
    "pageSize": 20
  }
}

错误码设计

错误码段含义示例
400xx参数错误40001=缺少必填项 / 40002=格式错误
401xx认证错误40101=未登录 / 40102=Token过期
403xx权限错误40301=无权限 / 40302=资源不属于你
404xx资源不存在40401=商品不存在 / 40402=订单不存在
409xx业务冲突40901=库存不足 / 40902=重复下单
500xx系统错误50001=内部异常 / 50002=第三方超时

代码分层架构

Controller
参数校验+路由
Service
业务逻辑
DAO/Mapper
数据库操作

各层职责(严格分开)

职责不该做的事
Controller 接收参数、参数校验(@Valid)、调用 Service、包装返回 ❌ 不要写业务逻辑!不要直接操作 DAO!
Service 核心业务逻辑、事务控制、调用多个 DAO 组合 ❌ 不要处理 HTTP 相关的东西(Request/Response)
DAO/Mapper 纯数据库操作(CRUD) ❌ 不要写业务判断!只管数据的读写

一个完整示例:创建订单

// Controller 层:只做参数校验和路由
@PostMapping("/api/orders")
public Result<OrderVO> createOrder(@RequestBody @Valid CreateOrderReq req) {
  OrderVO order = orderService.createOrder(req);
  return Result.success(order);
}

// Service 层:业务逻辑
@Transactional
public OrderVO createOrder(CreateOrderReq req) {
  // 1. 校验商品和库存
  Product product = productDao.findById(req.getProductId());
  if (product == null) throw new BizException(40401, "商品不存在");
  if (product.getStock() < req.getQuantity()) throw new BizException(40901, "库存不足");

  // 2. 扣库存(乐观锁)
  int rows = productDao.decreaseStock(req.getProductId(), req.getQuantity());
  if (rows == 0) throw new BizException(40901, "库存不足,请重试");

  // 3. 创建订单
  Order order = new Order();
  order.setOrderNo(generateOrderNo());
  order.setTotalAmount(product.getPrice() * req.getQuantity());
  order.setStatus(OrderStatus.UNPAID);
  orderDao.insert(order);

  // 4. 创建订单明细
  OrderItem item = new OrderItem();
  item.setOrderId(order.getId());
  item.setProductName(product.getName()); // 冗余快照
  item.setUnitPrice(product.getPrice()); // 冗余快照
  orderItemDao.insert(item);

  return OrderVO.from(order);
}

踩坑经验 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 个坑的回答深度直接决定后端面试评价。初级能说出"是什么";中级能说出"为什么发生"+"怎么修";高级能结合"生产场景"+"数据指标"+"监控告警"完整闭环。建议每个坑准备一个"我经历过/我设计过"的故事。