django orm本身不支持自动按月建表,必须由应用层控制;可行方案是继承抽象基类、动态构造子类并注册,写入前手动路由到对应月份表,跨月查询需用原生sql union或逐月合并。

MySQL分表必须由应用层控制,Django ORM本身不支持自动按月建表
Django的ORM设计上就假设每张模型对应一张固定表名的物理表,db_table 是静态字符串,不支持运行时动态拼接。试图在 Meta.db_table 里写 f"logs_{timezone.now().strftime('%Y%m')}" 会导致迁移失败、admin报错、查询混乱——因为迁移系统、反射机制、缓存键都依赖确定的表名。
真正可行的路径只有一条:放弃“一个Model对应多张物理表”的幻想,改用“一个逻辑表 + 多个继承子类 + 手动路由”的模式。核心是把分表逻辑从ORM元数据中剥离,交给业务代码和数据库操作前的判断。
- 所有按月分表的模型必须继承自同一个抽象基类(带
abstract = True) - 每个具体月份的子类显式声明
db_table,如logs_202409,且需提前创建好物理表(不能靠makemigrations) - 读写操作前,根据时间字段计算目标表名,再决定使用哪个子类
- 跨月查询必须手动
union或用原生 SQL,Django的QuerySet无法跨模型合并
如何生成并注册按月子类(避免硬编码)
手动为每个月写一个模型类不现实。需要用 Python 的 type() 动态构造类,并注入到 Django 的模型注册系统中。关键点在于:类必须在 Django 启动完成、AppConfig 加载后才可注册,否则 apps.get_model() 找不到。
推荐放在 apps.py 的 ready() 方法里做初始化,例如:
def ready(self, *args, **kwargs):
from django.apps import apps
from datetime import datetime, timedelta
# 取最近12个月 + 未来3个月,覆盖常用范围
base_date = datetime.now()
for i in range(-12, 3):
month_date = base_date + timedelta(days=i*30)
table_name = f"logs_{month_date.strftime('%Y%m')}"
class_name = f"LogEntry_{month_date.strftime('%Y%m')}"
# 动态创建模型类
model_class = type(
class_name,
(LogEntryBase,), # 继承抽象基类
{
"Meta": type("Meta", (), {"db_table": table_name, "managed": False}),
"__module__": "myapp.models",
}
)
# 注册进 Django 模型系统
apps.register_model("myapp", model_class)
注意:managed = False 表示不参与迁移;表结构必须与基类完全一致,否则字段映射出错。
- 每次部署或重启都要重新注册,不能只注册一次
- 类名必须全局唯一,否则
get_model()会返回错误类 - 如果用了
select_related或反向关系,这条路基本走不通——跨表外键在 MySQL 里无法约束,Django 也不支持
写入时如何自动路由到当月表
不能依赖信号或重写 save(),因为 save() 调用时可能已错过时间计算时机(比如批量导入含历史时间的数据)。最稳妥的方式是封装一个写入函数,强制要求业务层调用它:
def write_log_entry(entry_data: dict) -> None:
dt = entry_data.get("created_at") or timezone.now()
table_suffix = dt.strftime("%Y%m")
model_class = apps.get_model("myapp", f"LogEntry_{table_suffix}")
instance = model_class(**entry_data)
instance.save()
这个函数要处理两个常见坑:
- 目标表不存在时,
save()报OperationalError: (1146, "Table 'db.logs_202413' doesn't exist")—— 必须提前用SHOW TABLES LIKE 'logs_202413'检查并自动建表(通过原始 SQL 或django.db.connection.cursor()) - 并发写入同一新表时,可能多个请求同时触发建表,导致
1050 Table already exists错误,需加 try/except 忽略该错误 - 如果
entry_data包含外键 ID,确保关联表存在且 ID 有效;分表后外键实际已失效,只能靠业务逻辑保证
查询跨月数据时为什么不能用 filter() 直接拼接
你不能写 LogEntry_202408.objects.filter(...) | LogEntry_202409.objects.filter(...),因为 | 是 QuerySet 的 OR 操作,不是 UNION,它会生成 LEFT JOIN 或子查询,结果错乱且极慢。真实需求是 UNION ALL。
正确做法只有两种:
- 用
django.db.connection.cursor()写原生 SQL,手动拼接SELECT ... FROM logs_202408 UNION ALL SELECT ... FROM logs_202409,再用dictfetchall()解析结果 - 逐月查询再 Python 层合并(适合数据量小、月份少的场景),但要注意排序和分页无法下推,容易 OOM
另外,count() 在 UNION 场景下必须包一层 SELECT COUNT(*) FROM (UNION ...) AS t,Django 不会帮你做这层包装。
最易被忽略的一点:MySQL 的 UNION 要求各子句字段名、类型、顺序严格一致。如果你某个月表加了新字段而其他表没同步,查询直接失败。分表不是一劳永逸,表结构变更必须全量同步到所有已有月份表。
Python免费学习笔记(深入):立即使用
在学习笔记中,你将探索 Python 的核心概念和高级技巧!











