2. 数据访问痛点:N+1查询、重复查询、大表关联、锁竞争

审批流这个场景,说白了就是「读多写少,但写的时候要查一堆」。我接手过好几个审批系统,性能瓶颈几乎都出在数据访问层。今天咱们就聊聊四个最常见的痛点——N+1查询、重复查询、大表关联、锁竞争。这些坑,我基本都踩过一遍。

2.1 N+1查询:最隐蔽的性能杀手

先看一个典型场景。你要查询某个审批单的所有审批记录,顺便带上每个审批人的姓名。

// 伪代码:典型的N+1查询
List<ApprovalRecord> records = approvalMapper.findByOrderId(orderId);
for (ApprovalRecord record : records) {
    User user = userMapper.findById(record.getApproverId()); // 循环查N次
    record.setApproverName(user.getName());
}

这段代码看起来没问题,对吧?但实际执行时,它会发出 1 + N 条SQL。先查一次审批记录列表,再对每条记录查一次用户表。如果审批单有100条记录,那就是101次数据库查询。

核心问题:循环中执行数据库查询,每次查询都有网络开销、连接池获取开销、SQL解析开销。N越大,性能越差。

我在项目中遇到过最夸张的情况——一个审批详情页加载了30秒。查下来发现,页面里有个「审批人部门」字段,每次都要查部门表。一个页面触发了200多次查询。嗯,这就是典型的N+1。

解决方案:批量查询 + IN

// 优化后:批量查询
List<ApprovalRecord> records = approvalMapper.findByOrderId(orderId);
List<Long> approverIds = records.stream()
    .map(ApprovalRecord::getApproverId)
    .collect(Collectors.toList());
List<User> users = userMapper.findByIds(approverIds); // 一次IN查询
Map<Long, String> userMap = users.stream()
    .collect(Collectors.toMap(User::getId, User::getName));
records.forEach(r -> r.setApproverName(userMap.get(r.getApproverId())));

改成这样,不管N是多少,都只需要2次查询。性能提升是数量级的。

避坑指南:我曾经在MyBatis的<collection>标签里踩过N+1的坑。如果你用ORM框架,一定要检查生成的SQL日志。看到循环查询,立刻停手改批量。

2.2 重复查询:缓存能解决,但很多人忘了用

重复查询和N+1有点像,但更隐蔽。同一个请求里,多次查询同一张表、同一个ID。比如审批流里常见的「查询当前审批人」和「查询审批人权限」——两个接口都查了用户表。

// 重复查询示例
public User getApprover(Long orderId) {
    // 查审批人ID
    Long approverId = approvalMapper.getApproverId(orderId);
    // 查用户信息
    return userMapper.findById(approverId); // 第一次查
}

public List<String> getApproverPermissions(Long orderId) {
    Long approverId = approvalMapper.getApproverId(orderId);
    User user = userMapper.findById(approverId); // 第二次查,重复!
    return permissionMapper.findByRole(user.getRoleId());
}

你想想看,一个审批详情页可能要调5、6个这样的方法。每个方法都查一次用户表,数据库压力就这么上来了。

解决方案:请求级缓存

我个人习惯用 ThreadLocal 或者 Guava Cache 做请求级缓存。同一个请求内,相同的查询只执行一次。

// 使用Guava Cache做请求级缓存
public class UserCache {
    private static final Cache<Long, User> CACHE = 
        CacheBuilder.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(1, TimeUnit.SECONDS) // 短过期时间
            .build();

    public User getUser(Long userId) {
        try {
            return CACHE.get(userId, () -> userMapper.findById(userId));
        } catch (ExecutionException e) {
            throw new RuntimeException(e);
        }
    }
}

注意:请求级缓存的过期时间一定要短。我见过有人把缓存设成10分钟,结果用户改了名字,页面还显示旧名字。审批流对数据一致性要求高,缓存时间建议不超过5秒。

2.3 大表关联:审批流里的「慢查询元凶」

审批流里最怕什么?大表关联查询。审批记录表、流程实例表、任务表……每张表都是千万级起步。一旦关联查询没走索引,那就是灾难。

-- 典型的大表关联慢查询
SELECT a.*, u.name, t.task_name
FROM approval_record a
LEFT JOIN user u ON a.approver_id = u.id
LEFT JOIN task t ON a.task_id = t.id
WHERE a.order_id = ?
  AND a.status IN (1, 2, 3)
ORDER BY a.create_time DESC;

这个SQL看起来简单,但实际执行计划可能很糟糕。尤其是 LEFT JOIN 和 ORDER BY 组合在一起,很容易触发文件排序(Using filesort)。

核心原则:大表关联,能拆就拆。不要指望数据库帮你做所有事情。

优化策略:拆分为多次小查询

策略 说明 适用场景
先查主表,再查关联表 先查审批记录,再批量查用户和任务 关联表数据量不大时
冗余字段 在审批记录表里直接存用户姓名、任务名称 字段不经常变化时
宽表 + 异步同步 建一张审批宽表,通过MQ异步同步关联数据 高并发查询场景

我个人更推荐「冗余字段」方案。审批流里用户姓名、任务名称变化频率很低,冗余到审批记录表里,查询直接走主键索引,性能提升非常明显。

2.4 锁竞争:并发审批时的「隐形炸弹」

锁竞争这个问题,平时测试很难发现。一旦上线,并发一上来,问题就暴露了。审批流里最常见的锁竞争场景:

  • 悲观锁:使用 SELECT ... FOR UPDATE 锁定审批单,防止重复审批
  • 行锁升级:更新审批记录时,锁住了大量行
  • 间隙锁:InnoDB的间隙锁导致插入阻塞
-- 典型的锁竞争SQL
BEGIN;
SELECT * FROM approval_order WHERE id = ? FOR UPDATE;
-- 业务逻辑处理...
UPDATE approval_record SET status = 2 WHERE order_id = ?;
COMMIT;

这段代码的问题在于:FOR UPDATE 锁住了整行,如果多个审批人同时操作同一个审批单,后面的请求全部排队等待。我曾经遇到过线上事故——100个审批人同时审批,结果99个超时。

解决方案:乐观锁 + 重试机制

// 乐观锁实现
public boolean approve(Long orderId, Long userId, int version) {
    int rows = approvalMapper.updateStatusWithVersion(
        orderId, userId, version, version + 1
    );
    if (rows == 0) {
        // 版本冲突,重试或返回失败
        throw new OptimisticLockException("审批单已被其他人处理");
    }
    return true;
}

// Mapper XML
<update id="updateStatusWithVersion">
    UPDATE approval_order
    SET status = 2, 
        approver_id = #{userId},
        version = #{newVersion}
    WHERE id = #{orderId}
      AND version = #{oldVersion}
      AND status = 1
</update>

避坑指南:我曾经在项目中直接用 FOR UPDATE 锁了整张审批单表。结果一个审批操作卡住了所有查询。后来改成乐观锁,并发能力提升了10倍。记住:能不用锁就不用锁,能用乐观锁就不用悲观锁。

2.5 知识体系总览

下面这张图总结了四个痛点的关系。你看,它们其实互相影响——N+1查询会导致重复查询,大表关联会加剧锁竞争。解决的时候,要通盘考虑。

数据访问四大痛点关系图 N+1查询 重复查询 大表关联 锁竞争 核心思路 批量查询 → 减少N+1 | 请求级缓存 → 消除重复查询 拆分关联 → 避免大表JOIN | 乐观锁 → 降低锁竞争