Java-08 深入浅出 Mybatis 数据库多对多关系设计:中间表、映射与性能优化

TL;DR

  • 场景:开发者需要设计数据库多对多关系,但面临中间表创建、ORM映射配置、查询性能优化等实际痛点
  • 结论:通过中间表实现多对多关联,使用MyBatis的collection标签进行结果映射,配合索引优化和批量操作,可实现高效的数据访问
  • 产出:完整的建表SQL示例、MyBatis XML映射配置、Java实体类代码、性能优化策略(索引优化、避免N+1查询、Redis缓存、分页查询)

版本矩阵

功能 版本/年份 状态 说明
MyBatis 3.5+ ✅ 已验证 持久层框架,支持高级结果映射
Lombok 1.18+ ✅ 已验证 简化Java实体类代码
InnoDB MySQL 5.6+ ✅ 已验证 默认存储引擎,支持事务
索引优化 通用 ✅ 已验证 外键字段添加索引提升查询性能
覆盖索引 通用 ✅ 已验证 避免回表查询提升性能
Redis缓存 通用 ✅ 推荐 频繁查询但变化不频繁的数据缓存
水平分表 通用 ⚠️ 参考 超大规模数据场景
分库 通用 ⚠️ 参考 多数据库实例分布式部署
软删除 通用 ✅ 已验证 保留历史记录支持审计
批量操作 通用 ✅ 已验证 大规模插入更新提升效率
延迟加载 MyBatis ✅ 已验证 按需获取关联数据
JOIN查询 通用 ✅ 已验证 避免N+1查询问题

在数据库设计中,"多对多"关系是指两张表中的记录可以互相关联,多条记录可以关联多条记录。例如,"学生"表和"课程"表之间的关系可能是多对多,因为一个学生可以选修多门课程,而一门课程也可能被多名学生选修。

要实现多对多的关系,通常需要使用一个中间表(关联表)。这个中间表起到桥梁的作用,将两个表的记录通过其主键关联起来。

多对多关系的特点

  • 双向性:A 可以关联多个 B,同时 B 也可以关联多个 A。
  • 需要中间表:为了表示这种关系,通常使用一个中间表来维护关联。
  • 灵活性高:多对多关系非常适合用来表示复杂的业务逻辑,尤其是在需要动态添加或修改关系时。

多对多关系的实际应用场景

多对多关系在现实业务系统中非常常见,以下列举几个典型场景:

学生与课程

一个学生可以选修多门课程,一门课程也可以被多名学生选修。中间表 选课表 除了记录学生 ID 和课程 ID 外,通常还会包含选课时间、成绩等额外信息。

用户与角色(权限系统)

一个用户可以拥有多个角色(如管理员、编辑、普通用户),一个角色也可以分配给多个用户。这是 RBAC(基于角色的访问控制)模型的核心设计。

商品与标签

一个商品可以被打上多个标签(如"热销"、"新品"、"限时优惠"),一个标签也可以关联多个商品,方便用户通过标签快速筛选商品。

文章与分类

一篇文章可以属于多个分类(如"技术"、"教程"、"Java"),一个分类下也可以包含多篇文章,便于内容组织和导航。

多对多关系的扩展

添加额外字段

中间表可以扩展为更多功能。例如,可以为选课添加时间戳、成绩字段、是否通过等。

索引优化

为中间表中的外键添加索引,提高查询性能。

ORM 框架支持

现代框架(如 Hibernate、Django ORM)支持多对多关系的自动管理。在代码中只需要声明模型的关系,底层会自动生成并管理中间表。

查询模型

用户表和角色的关系,一个用户有多个角色,一个角色被多个用户使用。 多对多的查询的需求,查询用户同时查询出该用户所有的角色。

创建表

wzk_user_role

sql 复制代码
CREATE TABLE `wzk_user_role` (
  `id` int(11) NOT NULL,
  `user_id` int(11) NOT NULL,
  `role_id` int(11) NOT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;

wzk_role

sql 复制代码
CREATE TABLE `wzk_role` (
  `id` int(11) NOT NULL,
  `rolename` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;

插入数据

wzk_user_role

sql 复制代码
INSERT INTO wzk_user_role
VALUES (1, 1, 1);

INSERT INTO wzk_user_role
VALUES (2, 2, 2);

wzk_role

sql 复制代码
INSERT INTO wzk_role
VALUES (1, 'ADMIN');

INSERT INTO wzk_role
VALUES (2, 'USER');

查询语句

sql 复制代码
SELECT u.*, r.*, r.id rid FROM wzk_user u 
LEFT JOIN wzk_user_role ur ON u.id = ur.user_id
INNER JOIN wzk_role r ON ur.role_id = r.id;

执行结果如下所示:

创建类

WzkUser

这里需要加入新的字段。

java 复制代码
package icu.wzk.model;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.Date;
import java.util.List;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class WzkUser {
    private int id;
    private String username;
    private String password;
    private Date birthday;
    private List<WzkOrder> orderList;
    private List<WzkRole> roleList;
}

WzkRole

java 复制代码
package icu.wzk.model;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class WzkRole {
    private Integer id;
    private String rolename;
}

UserMapper

java 复制代码
package icu.wzk.mapper;

import icu.wzk.model.WzkUser;

import java.util.List;

public interface UserMapper {
    List<WzkUser> findAll();
    List<WzkUser> findAllUserAndRole();
}

UserMapper.xml

xml 复制代码
<resultMap id="userRoleMap" type="icu.wzk.model.WzkUser">
    <result column="id" property="id"></result>
    <result column="username" property="username"></result>
    <result column="password" property="password"></result>
    <result column="birthday" property="birthday"></result>
    <collection property="roleList" ofType="icu.wzk.model.WzkRole">
        <result column="rid" property="id"></result>
        <result column="rolename" property="rolename"></result>
    </collection>
</resultMap>

<select id="findAllUserAndRole" resultMap="userRoleMap">
    SELECT u.*, r.*, r.id rid
    FROM wzk_user u
    LEFT JOIN wzk_user_role ur ON u.id = ur.user_id
    INNER JOIN wzk_role r ON ur.role_id = r.id;
</select>

编写代码

java 复制代码
package icu.wzk;

import icu.wzk.mapper.UserMapper;
import icu.wzk.model.WzkUser;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;

import java.io.IOException;
import java.io.InputStream;
import java.util.List;

public class WzkIcu10 {
    public static void main(String[] args) throws IOException {
        InputStream resourceAsStream = Resources.getResourceAsStream("sqlMapConfig.xml");
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder()
                .build(resourceAsStream);
        SqlSession sqlSession = sqlSessionFactory.openSession();
        UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
        List<WzkUser> dataList = userMapper.findAllUserAndRole();
        dataList.forEach(System.out::println);
        sqlSession.close();
    }
}

运行结果

运行上面的代码,控制台输出的结果如下所示:

shell 复制代码
WzkUser(id=1, username=wzk, password=icu, birthday=Mon Nov 11 00:00:00 CST 2024, orderList=null, roleList=[WzkRole(id=1, rolename=ADMIN)])
WzkUser(id=2, username=wzk2, password=icu2, birthday=Mon Nov 11 00:00:00 CST 2024, orderList=null, roleList=[WzkRole(id=2, rolename=USER)])
24/11/12 18:02:33 DEBUG jdbc.JdbcTransaction: Resetting autocommit to true on JDBC Connection [com.mysql.cj.jdbc.ConnectionImpl@15b204a1]

注意事项

  • 索引优化:为中间表的外键字段创建索引,提升查询性能。
  • 关系约束:使用外键约束维护数据一致性,避免孤立数据。
  • 批量操作:在大规模插入、更新时,尽量使用批量操作,提升效率。
  • 软删除:中间表可以设计"软删除"标志位,用于保留历史记录。

性能优化建议

除了索引优化和批量操作外,以下策略也能显著提升多对多关系的查询性能:

使用覆盖索引

在中间表的联合查询中,尽量让索引覆盖所有查询字段,避免回表查询。例如,在 wzk_user_role 表上建立 (user_id, role_id) 的联合索引。

避免 N+1 查询

在使用 ORM 框架时,注意避免循环查询导致的 N+1 问题。可以使用 JOIN 查询或批量加载(Batch Fetch)来一次性获取所有关联数据。

合理使用缓存

对于频繁查询但变化不频繁的多对多关系数据(如角色权限),可以引入 Redis 等缓存中间件,减少数据库压力。

分页查询优化

当关联表数据量较大时,避免一次性加载所有关联数据。使用延迟加载(Lazy Loading)或分页查询,按需获取数据。

大表的分表与分库

问题背景

在超大规模应用中,单表可能变得过于庞大,导致性能问题。

解决方法

  • 垂直分表:将额外字段分离,减少单表宽度。
  • 水平分表:将记录按学生或课程拆分到多个表。
  • 分库:将不同表分布在多个数据库实例上。

日志和监控

问题背景

多对多关系中的数据修改频繁,容易引入错误,例如重复记录、孤立记录等。

解决方法

  • 日志记录:记录每次数据操作的详细信息。
  • 监控工具:定期检查中间表是否存在孤立记录或重复记录。

暂时小结

优化多对多关系模型需要从多个方面入手:

  • 提升性能:通过索引、分区、批量操作等方式优化查询和更新。
  • 保证数据一致性:通过外键约束和事务处理避免数据异常。
  • 提高灵活性:通过软删除和日志记录保留历史数据,支持恢复和审计。
  • 应对大数据场景:通过分区、分表或分库设计,确保系统扩展性。

错误速查卡

症状 根因 定位 修复
N+1查询问题 ORM循环查询导致大量SQL执行 查看SQL日志中重复的SELECT语句 使用JOIN查询或Batch Fetch批量加载
查询速度慢 中间表缺少索引 EXPLAIN分析查询计划 为外键字段添加联合索引
关联数据为null resultMap配置错误 检查collection标签的property和ofType 确认映射字段名与数据库列名匹配
重复记录 中间表缺少唯一约束 检查数据是否存在多条相同关联 添加唯一索引(user_id, role_id)
孤立记录 外键约束未启用或数据删除顺序错误 检查中间表数据完整性 使用外键约束并设置级联删除
批量插入慢 单条插入而非批量操作 查看插入操作的SQL数量 使用批量INSERT语句或事务包裹
分页数据错乱 关联数据与分页偏移量不匹配 检查分页查询的LIMIT和OFFSET 使用游标分页或延迟加载
缓存数据不一致 数据库更新后未清除缓存 检查缓存TTL和更新逻辑 更新后主动清除或使用CacheAside模式
水平分表后查询复杂 跨表查询需要UNION或路由 检查查询SQL执行计划 引入分布式数据库中间件
事务锁竞争 高并发下中间表行锁 查看InnoDB锁等待日志 优化事务范围,减少锁持有时间

作者:武子康的个人博客

相关推荐
明月_清风2 小时前
二进制序列化入门——为什么二进制比文本更快、更小?
后端·protobuf·messagepack
无极低码2 小时前
wsdl转client使用wsimport,高版本openjdk不支持使用 JAX-WS
java
明夜之约2 小时前
Spring Cloud Gateway 深度解析:从路由原理到生产级网关实战
java·spring·spring cloud·gateway
咕白m6252 小时前
Excel 工作表名称读取(Python 实现)
后端·python
Simon523142 小时前
Spring Bean----5.27学习小记
java·学习·spring
雪隐2 小时前
AI股票小助手00-导言
人工智能·后端
ZJH__GO2 小时前
java项目-流水线线程池
java·开发语言
●VON2 小时前
鸿蒙NEXT ArkUI进阶:用CustomBuilder打造高定制化品牌页签栏
java·华为·harmonyos·鸿蒙·新特性
长安不见2 小时前
从 Codex 的防御式写法说起:Redisson 分布式锁该怎么用
后端