主表 + 扩展表设计模式
一、解决什么问题
随着业务迭代,核心表的字段会不断膨胀:
- 初始建表 20 个字段
- 第一年迭代加到 50 个
- 第二年加到 80 个
- 第三年加到 100+ 个
带来的问题:
| 问题 | 影响 |
|---|---|
| 单行数据过宽 | InnoDB 页(16KB)能存的行数减少,查询扫描 IO 增大 |
| DDL 风险 | ALTER TABLE 加字段可能锁表(MySQL 5.6 以前),大表加字段耗时长 |
| 职责不清 | 50 个字段混在一起,哪些是核心字段、哪些是扩展功能不清晰 |
| 查询性能 | SELECT * 拉取大量不需要的字段,浪费网络和内存 |
| 并发冲突 | 不同业务更新同一行的不同字段,行锁竞争加剧 |
主表 + 扩展表通过垂直拆分,将高频核心字段和低频扩展字段分离,解决以上问题。
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、核心设计思路
┌────────────────────────────┐ 1:1 ┌──────────────────────────────┐
│ 主表 (master) │ ──────────→ │ 扩展表 (extend) │
├────────────────────────────┤ ├──────────────────────────────┤
│ id (PK) │ │ id (PK) │
│ order_no │ │ master_id (FK/UK) │
│ status │ │ master_code │
│ amount │ │ extra_field_1 │
│ create_time │ │ extra_field_2 │
│ ... (核心高频字段) │ │ ... (扩展低频字段) │
└────────────────────────────┘ └──────────────────────────────┘
关键约束
- 一对一关系 :扩展表的
master_id加唯一索引 - 可选关系:不是每条主表记录都有扩展记录(按需创建)
- 关联字段冗余 :通常同时存
master_id和master_code,方便按 ID 或编号查询
三、拆分策略
3.1 按更新频率拆分
| 主表(高频读写) | 扩展表(低频更新) |
|---|---|
| 状态、金额、时间 | 审计信息、重试次数、外部系统状态 |
| 每次操作都会更新 | 只在特定场景更新 |
3.2 按业务维度拆分
| 主表(核心业务) | 扩展表A(物流相关) | 扩展表B(财务相关) |
|---|---|---|
| 订单号、客户、金额 | 物流公司、运单号、签收状态 | 发票号、税率、开票状态 |
3.3 按数据生命周期拆分
| 主表(创建时确定) | 扩展表(后续补充) |
|---|---|
| 下单时的固定数据 | 发货后才产生的数据 |
| 修改极少 | 异步回写 |
四、与其他方案的对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 垂直拆分(扩展表) | 结构清晰、核心表轻量、独立维护 | JOIN 查询、分布式事务 | 字段多且可按维度拆分 |
| JSON 字段 | 灵活、不改表结构 | 索引困难、无类型校验、查询复杂 | 动态属性、不确定字段 |
| EAV 模型 | 极度灵活、无限扩展 | 查询极慢、无类型安全、代码复杂 | CMS/表单引擎 |
| 宽表不拆 | 简单、无 JOIN | 表膨胀、DDL 风险、职责不清 | 字段少且稳定 |
| 水平分表 | 解决数据量大 | 不解决字段多的问题 | 行数超千万级 |
五、代码示例(通用:用户主表 + 扩展表)
5.1 数据库表结构
sql
-- 用户主表:核心高频字段
CREATE TABLE t_user (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(64) NOT NULL COMMENT '用户名',
phone VARCHAR(20) NOT NULL COMMENT '手机号',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态 1启用 0禁用',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_phone (phone),
INDEX idx_username (username)
) COMMENT '用户主表';
-- 用户扩展表:低频/后补充字段
CREATE TABLE t_user_extend (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL COMMENT '用户ID',
user_name VARCHAR(64) COMMENT '用户名(冗余,便于按名称查询)',
avatar_url VARCHAR(512) COMMENT '头像URL',
bio VARCHAR(1000) COMMENT '个人简介',
vip_level TINYINT DEFAULT 0 COMMENT 'VIP等级',
vip_expire_time DATETIME COMMENT 'VIP过期时间',
last_login_ip VARCHAR(50) COMMENT '最后登录IP',
last_login_time DATETIME COMMENT '最后登录时间',
login_count INT DEFAULT 0 COMMENT '累计登录次数',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE INDEX uk_user_id (user_id)
) COMMENT '用户扩展表';
5.2 实体类
java
/**
* 用户主表实体.
*/
@Data
@Entity
@Table(name = "t_user")
public class UserEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "username", nullable = false)
private String username;
@Column(name = "phone", nullable = false)
private String phone;
@Column(name = "status", nullable = false)
private Integer status;
@Column(name = "create_time")
private Date createTime;
@Column(name = "update_time")
private Date updateTime;
}
/**
* 用户扩展表实体.
*/
@Data
@Entity
@Table(name = "t_user_extend")
public class UserExtendEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
/** 关联用户主表ID. */
@Column(name = "user_id", nullable = false)
private Integer userId;
/** 冗余用户名,便于查询. */
@Column(name = "user_name")
private String userName;
/** 头像URL. */
@Column(name = "avatar_url")
private String avatarUrl;
/** 个人简介. */
@Column(name = "bio")
private String bio;
/** VIP等级. */
@Column(name = "vip_level")
private Integer vipLevel;
/** VIP过期时间. */
@Column(name = "vip_expire_time")
private Date vipExpireTime;
/** 最后登录IP. */
@Column(name = "last_login_ip")
private String lastLoginIp;
/** 最后登录时间. */
@Column(name = "last_login_time")
private Date lastLoginTime;
/** 累计登录次数. */
@Column(name = "login_count")
private Integer loginCount;
@Column(name = "create_time")
private Date createTime;
@Column(name = "update_time")
private Date updateTime;
}
5.3 Repository
java
public interface UserRepository extends JpaRepository<UserEntity, Integer> {
UserEntity findByPhone(String phone);
}
public interface UserExtendRepository extends JpaRepository<UserExtendEntity, Integer> {
/** 通过用户ID查找扩展信息. */
UserExtendEntity findByUserId(Integer userId);
/** 批量查找扩展信息. */
List<UserExtendEntity> findByUserIdIn(List<Integer> userIds);
}
5.4 Service 层(核心:按需创建扩展记录)
java
@Service
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final UserExtendRepository userExtendRepository;
public UserServiceImpl(UserRepository userRepository,
UserExtendRepository userExtendRepository) {
this.userRepository = userRepository;
this.userExtendRepository = userExtendRepository;
}
/**
* 注册用户(只创建主表记录,扩展表按需延迟创建).
*/
@Override
@Transactional(rollbackFor = Exception.class)
public UserEntity register(RegisterRequest request) {
UserEntity user = new UserEntity();
user.setUsername(request.getUsername());
user.setPhone(request.getPhone());
user.setStatus(1);
user.setCreateTime(new Date());
return userRepository.saveAndFlush(user);
// 注意:注册时不创建扩展表记录,首次需要时才创建
}
/**
* 记录用户登录信息(写入扩展表,不存在则创建).
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void recordLogin(Integer userId, String loginIp) {
UserExtendEntity extend = getOrCreateExtend(userId);
extend.setLastLoginIp(loginIp);
extend.setLastLoginTime(new Date());
extend.setLoginCount(
extend.getLoginCount() == null ? 1 : extend.getLoginCount() + 1);
userExtendRepository.saveAndFlush(extend);
}
/**
* 升级VIP(写入扩展表).
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void upgradeVip(Integer userId, Integer level, Date expireTime) {
UserExtendEntity extend = getOrCreateExtend(userId);
extend.setVipLevel(level);
extend.setVipExpireTime(expireTime);
userExtendRepository.saveAndFlush(extend);
}
/**
* 查询用户详情(主表 + 扩展表合并返回).
*/
@Override
public UserDetailResponse getUserDetail(Integer userId) {
UserEntity user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("用户不存在"));
UserExtendEntity extend = userExtendRepository.findByUserId(userId);
UserDetailResponse response = new UserDetailResponse();
response.setUserId(user.getId());
response.setUsername(user.getUsername());
response.setPhone(user.getPhone());
response.setStatus(user.getStatus());
// 扩展信息可能不存在
if (extend != null) {
response.setAvatarUrl(extend.getAvatarUrl());
response.setBio(extend.getBio());
response.setVipLevel(extend.getVipLevel());
response.setLastLoginTime(extend.getLastLoginTime());
}
return response;
}
/**
* 获取或创建扩展记录(核心模式:懒创建).
*/
private UserExtendEntity getOrCreateExtend(Integer userId) {
UserExtendEntity extend = userExtendRepository.findByUserId(userId);
if (extend == null) {
extend = new UserExtendEntity();
extend.setUserId(userId);
UserEntity user = userRepository.findById(userId).orElse(null);
if (user != null) {
extend.setUserName(user.getUsername());
}
}
return extend;
}
}
5.5 MyBatis 查询(JOIN 方式一次查出)
xml
<resultMap id="UserDetailResultMap" type="com.example.dto.UserDetailResponse">
<id column="user_id" property="userId"/>
<result column="username" property="username"/>
<result column="phone" property="phone"/>
<result column="status" property="status"/>
<result column="avatar_url" property="avatarUrl"/>
<result column="bio" property="bio"/>
<result column="vip_level" property="vipLevel"/>
<result column="last_login_time" property="lastLoginTime"/>
</resultMap>
<!-- 主表 LEFT JOIN 扩展表 -->
<select id="getUserDetail" resultMap="UserDetailResultMap">
SELECT
u.id AS user_id,
u.username,
u.phone,
u.status,
e.avatar_url,
e.bio,
e.vip_level,
e.last_login_time
FROM t_user u
LEFT JOIN t_user_extend e ON e.user_id = u.id
WHERE u.id = #{userId}
</select>
<!-- 批量查询用户列表(含扩展信息) -->
<select id="listUsers" resultMap="UserDetailResultMap">
SELECT
u.id AS user_id,
u.username,
u.phone,
u.status,
e.vip_level,
e.last_login_time
FROM t_user u
LEFT JOIN t_user_extend e ON e.user_id = u.id
WHERE u.status = #{status}
ORDER BY u.id DESC
LIMIT #{offset}, #{pageSize}
</select>
六、扩展表的创建时机策略
| 策略 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| 主表创建时同步创建 | INSERT 主表后立即 INSERT 扩展表 | 后续无需判空 | 浪费空间(很多记录扩展字段全为 null) |
| 首次使用时懒创建 | 第一次需要写扩展字段时才创建 | 节省空间 | 读取时需判空、写入时需查是否存在 |
| 特定流程触发创建 | 如发货后由物流流程创建 | 职责清晰 | 其他流程想写入时需二次判断 |
七、多扩展表的组织方式
当一个主表需要多个维度的扩展时:
┌─────────────────┐
│ order_master │
└───────┬─────────┘
│
┌────┼────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ order_extend │ │ order_logist │ │ order_finance│
│ (业务扩展) │ │ (物流状态) │ │ (财务信息) │
└──────────────┘ └──────────────┘ └──────────────┘
命名建议:
| 命名 | 含义 | 适用 |
|---|---|---|
xxx_extend |
通用扩展(杂项字段) | 单一扩展表 |
xxx_subtable |
子表(可能一对多) | 需要区别于严格一对一 |
xxx_logistics |
物流维度 | 按业务领域命名 |
xxx_finance |
财务维度 | 按业务领域命名 |
xxx_extra |
额外信息 | 同 extend |
八、注意事项
| 问题 | 解决 |
|---|---|
| JOIN 性能 | 扩展表 master_id 加唯一索引,LEFT JOIN 性能接近单表查询 |
| 事务一致性 | 主表和扩展表在同一个事务中操作,用 @Transactional |
| 扩展表不存在记录 | 用 LEFT JOIN 查询,Service 层对 null 做防御处理 |
| 字段归属不明确 | 建立规范:创建时确定的放主表,后续补充的放扩展表 |
| 扩展表也膨胀了 | 按业务维度继续拆分为多个扩展表,或考虑 JSON 字段 |
| 删除级联 | 删除主表记录时同步删除扩展表(或用外键 CASCADE) |
| 冗余字段同步 | 扩展表冗余的 master_code 等字段,在主表变更时注意同步 |
九、JSON 字段方案(替代方案对比)
MySQL 5.7+ 支持 JSON 类型,可以作为轻量级扩展方案:
sql
-- 不建扩展表,在主表加 JSON 字段
ALTER TABLE t_user ADD COLUMN extra_info JSON DEFAULT NULL COMMENT '扩展信息';
-- 写入
UPDATE t_user SET extra_info = JSON_SET(
COALESCE(extra_info, '{}'),
'$.vipLevel', 3,
'$.lastLoginIp', '192.168.1.1'
) WHERE id = 1;
-- 查询
SELECT id, username,
JSON_UNQUOTE(JSON_EXTRACT(extra_info, '$.vipLevel')) AS vip_level
FROM t_user WHERE id = 1;
-- 条件过滤(需要虚拟列+索引才有好性能)
ALTER TABLE t_user ADD COLUMN vip_level INT
GENERATED ALWAYS AS (JSON_EXTRACT(extra_info, '$.vipLevel')) VIRTUAL;
CREATE INDEX idx_vip_level ON t_user(vip_level);
| 维度 | 扩展表 | JSON 字段 |
|---|---|---|
| 类型安全 | 强(列有类型) | 弱(运行时解析) |
| 索引能力 | 天然支持 | 需要虚拟列 |
| ORM 映射 | 标准 Entity | 需要 TypeHandler 或手动解析 |
| 灵活性 | 加字段需 DDL | 直接写入新 key |
| 可读性 | SQL 直观 | JSON 函数嵌套复杂 |
| 适用 | 字段确定、需要索引/查询 | 字段动态、纯展示不查询 |
十、决策流程图
新增一个业务字段
│
├── 核心业务必须字段?(如订单号、金额)
│ └── 是 → 放主表
│
├── 需要频繁查询/过滤?
│ └── 是 → 放扩展表(可加索引)
│
├── 纯展示、不需要查询过滤?
│ └── 是 → 考虑 JSON 字段
│
├── 已有对应维度的扩展表?
│ └── 是 → 放已有扩展表
│
└── 无对应扩展表且字段不止一个?
└── 是 → 新建扩展表