MySQL如何合理组织用户数据规避单表行大小限制问题
结论先说:必须拆分多表设计,绝对不能继续沿用当前单表挂载所有数组/JSON关联数据的方案,65KB行上限是硬约束,业务跑半年到一年大概率触发故障,即使没触发上限性能也会差到没法用。
先算最直观的账:按用户连续使用5年、每天登录的场景,光登录日期就有1800+条,存成JSON字符串大概20KB;学习日程如果每天学10张闪卡,5年就是1.8万条记录,存成JSON至少500KB,早就远超InnoDB单条行65KB的上限。哪怕用TEXT/LONGTEXT类型触发InnoDB行溢出,把大字段存在额外的溢出页,每次查询用户信息都要拉取几百KB的大字段,缓存命中率、IO性能都会崩,完全没有扩展性。
具体表结构设计
核心思路是:主表只存用户本身的固定、短字段属性,所有一对多、多对多的关联数据全部分拆成独立关联表,彻底避免单条记录无限膨胀。
1. 保留users主表
只放用户基础属性,所有数组、JSON类变长关联数据全部移出:
CREATE TABLE users ( user_id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, email VARCHAR(100) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, nickname VARCHAR(50) DEFAULT NULL, avatar_url VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 其他短长度、固定属性的用户配置、基础信息放这里 INDEX idx_username(username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2. 用户已学题集表user_learned_sets
用户和题集是多对多关系,用关联表存储,还能额外记录学习时间等扩展信息:
CREATE TABLE user_learned_sets ( id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, user_id INT UNSIGNED NOT NULL, set_id INT UNSIGNED NOT NULL, first_learned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_set (user_id, set_id), -- 避免重复记录同一题集 FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE, -- 关联已有的题集主表question_sets FOREIGN KEY (set_id) REFERENCES question_sets(set_id) ON DELETE CASCADE, INDEX idx_user_id(user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
查某个用户学过的所有题集,直接按user_id查这个表就行,比解析JSON数组快几个数量级。
3. 用户好友关系表user_friends
好友是用户和用户之间的多对多关系,单独拆表:
CREATE TABLE user_friends ( id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, user_id INT UNSIGNED NOT NULL, friend_user_id INT UNSIGNED NOT NULL, added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 记录加好友时间 UNIQUE KEY uk_user_friend (user_id, friend_user_id), FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE, FOREIGN KEY (friend_user_id) REFERENCES users(user_id) ON DELETE CASCADE, INDEX idx_user_id(user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
判断两个用户是不是好友、查某个用户的好友列表,直接走索引查询,不需要拉取整个好友数组解析。
4. 用户登录记录表user_login_logs
不要存日期数组,每次登录插一条记录,DATE类型比字符串省一半以上空间,查询统计更方便:
CREATE TABLE user_login_logs ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, -- 数据量大,用BIGINT避免溢出 user_id INT UNSIGNED NOT NULL, login_date DATE NOT NULL, login_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 如果只需要记录当天是否登录,加下面的唯一键避免同一天重复插入 UNIQUE KEY uk_user_login_date (user_id, login_date), INDEX idx_user_id(user_id), INDEX idx_login_date(login_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
算连续登录天数、月度登录频次这类统计,直接SQL聚合就行,不需要拉全量日期数组到应用层计算。
5. 用户学习记录表user_study_records
把原来嵌套在JSON里的每条卡片学习记录拆成独立行,彻底解决JSON无限膨胀的问题:
CREATE TABLE user_study_records ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, user_id INT UNSIGNED NOT NULL, study_time DATETIME NOT NULL, -- 对应原JSON里的时间key card_id INT UNSIGNED NOT NULL, -- 对应原JSON里的卡片ID(551/552这类) study_type VARCHAR(20) NOT NULL, -- 对应原JSON里的type,比如Flashcards set_id INT UNSIGNED NOT NULL, -- 对应原JSON里的setid created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_user_study_time(user_id, study_time), -- 按时间查学习日程直接走这个索引 INDEX idx_user_set(user_id, set_id), FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE, FOREIGN KEY (set_id) REFERENCES question_sets(set_id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
折中方案(如果不想完全拆碎学习记录)
如果90%的场景都是按天拉取用户学习日程,不需要单条卡片维度的复杂筛选,可以不用把学习记录拆到单卡片维度,按天粒度存JSON即可,单条记录最多几KB,永远碰不到65KB上限:
CREATE TABLE user_daily_study ( id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, user_id INT UNSIGNED NOT NULL, study_date DATE NOT NULL, schedule_data JSON NOT NULL, -- 仅存当天的所有学习卡片数据 UNIQUE KEY uk_user_date (user_id, study_date), INDEX idx_user_id(user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
原方案的其他隐性问题
除了行大小上限,原方案还有两个致命问题:
- 并发更新冲突:不管是更新学习进度、加好友还是记录登录,都需要先读整行的JSON/数组,修改后整行写回,两个端同时操作很容易出现覆盖丢失数据。
- 查询性能极差:所有筛选、统计操作都不能走索引,要么全量拉取大字段到应用层解析,要么写复杂的JSON函数查询,数据量到10万用户级别就会出现大量慢查询。
内容的提问来源于stack exchange,提问作者script ing

