
关系型数据库与 NoSQL 在刷题系统中的选择MySQL、MongoDB 与 Redis 的分工一、深度引言与场景痛点用 MySQL 存刷题历史用 MongoDB 存题目标签逻辑乱了7 月设计刷题系统时我在数据库选型上犹豫了很久。这个系统的数据可以分为三类用户数据账号、统计是典型的关系型数据题目数据题目描述、标签、题解是半结构化的文档型数据用户每日活跃状态签到、在线时长是热数据需要高频读写。直觉告诉我应该不同类型的数据用不同的数据库但直觉没有告诉我边界在哪里——用户的刷题记录它既是关系型数据与用户 ID 关联又是大量产生的时间序列数据每天几十上百条。这种情况下存 MySQL 还是 MongoDB本文通过刷题系统的实际数据类型拆解 MySQL、MongoDB、Redis 三种数据库的分工边界。核心结论是不是每一类数据都需要专门的数据库选型的关键在于识别数据的访问模式和一致性需求。二、底层机制与原理深度剖析三种数据库的设计哲学MySQL 的选择逻辑数据之间有严格的关联关系需要 ACID 事务保证一致性。用户的刷题记录需要和用户表、题目表做 JOIN 查询查询我本周做过的所有动态规划题这是关系型数据库的强项。MongoDB 的选择逻辑数据结构不确定或频繁变化文档之间相对独立。题目的标签可能是动态的——今天加了字节跳动面试题明天又加了高频题。如果用 MySQL你需要维护 N 对 M 的关系表Schema 变更成本高。MongoDB 的文档模型天然支持这种动态字段。Redis 的选择逻辑数据需要极低延迟 1ms访问且允许一定的数据丢失缓存数据可从持久化存储恢复。排行榜ZSET、签到BITMAP、会话String/Hash——这些数据的热度和延迟要求是 Redis 的天然场景。三种数据库不是谁更好的比较关系而是谁更匹配当前数据的访问特征的匹配关系。三、生产级代码实现与最佳实践分层数据架构实现 刷题系统的分层数据架构 每种数据类型精确匹配一种数据库不搞万能解决方案 from typing import Dict, List, Optional from dataclasses import dataclass from datetime import datetime, date import json # MySQL 层关系型数据 MySQL 负责用户信息、刷题记录、用户统计 —— 需要 JOIN 查询和事务的数据 # SQL Schema 设计 MYSQL_SCHEMA -- 用户表标准的用户账户信息 CREATE TABLE users ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_username (username) ); -- 刷题提交记录表核心业务数据需要按用户和日期联合查询 CREATE TABLE submissions ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, problem_id VARCHAR(20) NOT NULL, language VARCHAR(10), passed BOOLEAN DEFAULT FALSE, time_spent_seconds INT, -- 做题耗时 submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES users(id), -- 联合索引按用户日期查询是最频繁的访问模式 INDEX idx_user_date (user_id, submitted_at), INDEX idx_problem (problem_id) ); -- 用户统计表高频更新的汇总数据 CREATE TABLE user_stats ( user_id BIGINT PRIMARY KEY, total_solved INT DEFAULT 0, current_streak INT DEFAULT 0, -- 连续打卡天数 longest_streak INT DEFAULT 0, total_submissions INT DEFAULT 0, -- 为保证数据一致性和 submissions 表在同一事务中更新 FOREIGN KEY (user_id) REFERENCES users(id) ); # MySQL 数据访问实现 class MySQLDataAccess: MySQL 层的 DAO —— 所有需要 JOIN 和事务的查询都走这里 def get_weekly_problems_by_topic( self, user_id: int, topic: str ) - List[Dict]: 查询本周做过的某个类型的所有题目 这里需要 JOIN submissions 和 topics 表 是关系型数据库最自然的查询方式 # 生产环境中这里使用 SQLAlchemy 或原生 SQL query SELECT DISTINCT s.problem_id, s.passed, s.submitted_at FROM submissions s JOIN problem_topics t ON s.problem_id t.problem_id WHERE s.user_id %s AND t.topic %s AND s.submitted_at DATE_SUB(NOW(), INTERVAL 7 DAY) ORDER BY s.submitted_at DESC # cursor.execute(query, (user_id, topic)) return [] # 示例 # MongoDB 层文档型数据 MongoDB 负责题目详情、题解内容 —— 半结构化、读多写少的数据 dataclass class ProblemDocument: 题目文档 —— MongoDB 中一道题的完整表示 problem_id: str title: str difficulty: str # easy/medium/hard description_html: str # 完整题目描述HTML 格式 tags: List[str] # 动态标签可能随时增减 similar_problems: List[str] # 相关题目 ID 列表 solutions: List[Dict] # 题解列表嵌套文档 # 嵌套文档的优势不需要 JOIN一次查询拿回所有关联数据 def to_mongo_doc(self) - Dict: 序列化为 MongoDB 文档 —— 利用文档模型的灵活性 return { _id: self.problem_id, title: self.title, difficulty: self.difficulty, description_html: self.description_html, tags: self.tags, similar_problems: self.similar_problems, solutions: self.solutions, updated_at: datetime.utcnow(), } class MongoDBDataAccess: MongoDB 层 —— 处理无 Schema 约束的文档数据 def search_by_tags(self, tags: List[str]) - List[Dict]: 按标签搜索题目 —— MongoDB 的数组查询是原生能力 MySQL 做同样操作需要 tag 关联表 JOIN query {tags: {$all: tags}} # 同时包含所有指定标签 # db.problems.find(query).limit(20) return [] def add_solution(self, problem_id: str, solution: Dict): 向题目文档中追加一条题解 MongoDB 文档模型的优势不需要 ALTER TABLE # db.problems.update_one( # {_id: problem_id}, # {$push: {solutions: solution}} # ) pass # Redis 层缓存与热数据 Redis 负责排行榜、会话、签到 —— 高频访问、允许最终一致性 class RedisDataAccess: Redis 层 —— 处理所有低延迟、高并发的数据 def update_leaderboard(self, user_id: int, score: int): 更新排行榜 —— 使用 Sorted Set 时间复杂度O(log N)毫秒级响应 # redis.zadd(leaderboard:weekly, {str(user_id): score}) def get_leaderboard(self, top_n: int 100) - List[tuple]: 获取排行榜前 N 名 —— ZREVRANGE 直接返回 如果用 MySQL 做同样的事需要 ORDER BY LIMIT 在千万行数据上扫描 # return redis.zrevrange(leaderboard:weekly, 0, top_n - 1, withscoresTrue) return [] def mark_daily_checkin(self, user_id: int): 每日签到 —— 使用位图 一个用户 365 天的签到数据仅需 46 字节 # day_of_year datetime.now().timetuple().tm_yday # redis.setbit(fcheckin:{2026}, user_id, 1) pass def cache_session(self, session_id: str, user_data: Dict, ttl: int 3600): 缓存用户会话 —— 设置过期时间 # redis.setex(fsession:{session_id}, ttl, json.dumps(user_data)) pass三层数据架构的精髓不在于用了多少种数据库而在于每种数据找到了最匹配的存储方式。排行榜用 Redis Sorted Set1 行命令在 MySQL 中需要复杂的 SQL。题目标签搜索用 MongoDB 的数组查询原生支持在 MySQL 中需要额外的关联表和 JOIN。四、边界分析与架构权衡全用 MySQL 行不行对于一个用户量 100 的刷题系统全用 MySQL 是完全可以的。引入多种数据库的代价运维复杂度、数据一致性协调、学习成本远超它们的收益。这个判断是本文最重要的 trade-off 结论。只有当你遇到以下真实瓶颈时才引入新数据库排行榜查询超过 500ms且请求量大引入了 Redis 做排行榜缓存题目的标签和分类频繁变化导致 MySQL Schema 变更困难引入 MongoDB用户活跃数据的高频写入影响主库性能引入 Redis 做写入缓冲在没有遇到这些瓶颈之前多数据库 过度设计。一个实习生开发的小型系统MySQL 能搞定所有需求。多数据库架构是给已经遇到了瓶颈的系统的升级方案而不是给还没开始开发的系统的初始设计。结论数据库选型的核心原则是用最少的数据库种类满足真实的数据需求——而不是为每一类数据分配一个专用数据库。MySQL 作为主力存储足以覆盖小型刷题系统 90% 的需求。MongoDB 解决的是结构不确定的文档数据问题Redis 解决的是低延迟热数据问题。这两个需求不是必然存在的。对实习生来说掌握MySQL 作为主力数据库Redis 作为缓存层这个组合已经覆盖了个人项目和中小型后端系统的绝大多数场景。MongoDB 的引入是在数据确实不适合关系模型时才会考虑的选项。选型时不妨问自己一个问题如果只用 MySQL 来实现会遇到什么不可接受的困难如果答案是没有——那就只用 MySQL。