Django ORM 是我最喜欢的 ORM,它自带了全套数据库管理的解决方案,开箱即用。但是到了某一家公司里就有些水土不服。比如分享了如何 在你家公司使用 Django Migrate。这次我们来说说外键。
什么是外键
关系型数据库之所以叫「关系型」,因为维护数据之间的「关系」是它们的一大 Feature。
外键就是维护关系的基石。
比如我们创建两个表,一个是 students
学生表,一个是 enrollments
选课表。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
MySQL root@(none):foreignkey_example1> select * from students; +----+------+ | id | name | +----+------+ | 1 | 张三 | | 2 | 李四 | +----+------+ 2 rows in set Time: 0.002s MySQL root@(none):foreignkey_example1> select * from enrollments; +----+------------+--------+ | id | student_id | course | +----+------------+--------+ | 1 | 1 | 数学 | | 2 | 2 | 语文 | | 4 | 1 | 英语 | +----+------------+--------+ 3 rows in set Time: 0.002s |
选课表的 student_id
和 student.id
关联。那么外键在这里为我们做了什么呢?
enrollments
创建的 SQL 如下:
1 2 3 4 5 6 7 8 |
CREATE TABLE `enrollments` ( `id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `course` varchar(50) NOT NULL, PRIMARY KEY (`id`), KEY `student_id` (`student_id`), CONSTRAINT `enrollments_ibfk_1` FOREIGN KEY (`student_id`) REFERENCES `students` (`id`) ) |
其中 CONSTRAINT enrollments_ibfk_1 FOREIGN KEY (student_id) REFERENCES
就是外键的意思。这样确保 enrollments
表中的 student_id
必须来自 students
表中的 id
。enrollments.student_id
里的值,必须是 students.id
表中已经存在的值。
否则数据库会报错,防止插入无效的数据。
如果我们试图插入一条不存在的 student_id
,数据库会拒绝插入:
1 2 3 |
MySQL root@(none):foreignkey_example1> INSERT INTO enrollments (student_id, course) VALUES (3, '英语'); -> (1452, 'Cannot add or update a child row: a foreign key constraint fails (`foreignkey_example1`.`enrollments`, CONSTRAINT `enrollments_ibfk_1` FOREIGN KEY (`student_id`) REFERENCES `students` (`id`))') |
使用外键的好处有:
- 数据库帮我们维护数据的完整性,不会存在孤儿数据,不会因为编程错误插入错误数据;
- 可以实现级联删除,比如
ON DELETE CASCADE
,上面的例子中当我们从students
表删除 id=2 的学生,在enrollments
表相关的数据也同事会被删除; - 清晰的业务逻辑表达,在数据库表定义就有二者的关联关系,在语义上就比较好维护。还有一些数据库工具可以直接根据我们表定义中的 FOREIGN KEY 关系来画出来表之间的关系,在入手一个新的项目的时候,非常有用。

为什么 DBA 不喜欢外键?
很多大公司的数据库都是禁用外键的,FOREIGN KEY (student_id) REFERENCES
这种 DDL 语句执行会直接失败。这样,数据库的表从结构上看不再有关系,每一个表都是独立的表而已,enrollments
表的 student_id
Column 只是一个 INT 值,不再和其他的表关联。
为什么要把这个好东西禁用呢?
主要原因是不好维护。修改表结构和运维的时候,因为外键的存在,都会有很多限制。分库分表也不好实现。如果每一个表都是一个单独的表,没有关系,那 DBA 运维起来就方便很多了。
外键也会稍微降低性能。因为每次更新数据的时候,数据库都要去检查外键约束。
退一步讲,其实数据的完整性可以通过业务来保证,级联删除这些东西也做到业务的逻辑代码中。这样看来,使用外键就像是把一部分业务逻辑交给数据库去做了,本质上和存储过程差不多。
所以,互联网公司的数据库一般都是没有 REFERENCES
权限的。
Revoke REFERENCE 权限如下这样操作:
1 |
REVOKE REFERENCES ON testdb1_nofk.* FROM 'testuser1'@'localhost'; |
这样之后,如果在执行 Django migration 的时候,会遇到权限错误:
1 |
django.db.utils.OperationalError: (1142, "REFERENCES command denied to user 'testuser1'@'localhost' for table 'testdb1_nofk.django_content_type'") |
Django migration 如何不使用外键
在声明 Model 的时候,使用 ForeignKey
要设置 db_constraint=False
2。这样在生成的 migration 就不会带外键约束了。
Django migration 如何全局禁用外键
每一个 ForeignKey 都要写这个参数,太繁琐了。况且,Django 会内置一些 table 存储用户和 migration 等信息,对这些内置 table 修改 DDL 比较困难。
Django 的内置 tables:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
+-------------------------------+ | Tables_in_test_nofk | +-------------------------------+ | auth_group | | auth_group_permissions | | auth_permission | | auth_user | | auth_user_groups | | auth_user_user_permissions | | django_admin_log | | django_content_type | | django_migrations | | django_session | +-------------------------------+ |
在 Github 看到一个项目3,发现 Django 的 ORM 里面是有 feature set 声明的,其实,我们只要修改 ORM 的 MySQL 引擎,声明数据库不支持外键,ORM 在生成 DDL 的时候,就不会带有 FOREIGN KEY REFERENCE 了。
核心的原理是继承 Django 的 MySQL 引擎,写自己的引擎,改动内容其实就是一行 supports_foreign_keys = False
。
具体的方法如下。
新建一个 mysql_engine
,位置在 Django 项目的目录下,和其他的 app 平级。这样 mysql_engine
就可以在 Django 项目中 import 了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
├── project_dir │ ├── __init__.py │ ├── __pycache__ │ ├── asgi.py │ ├── settings │ ├── urls.py │ └── wsgi.py ├── manage.py ├── app_dir │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ ├── models.py │ ├── serializers.py │ ├── tests.py │ └── views.py └── mysql_engine ├── base.py └── features.py |
我们要写自己的 mysql engine。为什么不直接使用 django_psdb_engine 项目呢?因为 django_psdb_engine 是继承自 Django 原生的 engine,就无法使用 django_prometheus4 的功能了。ORM 扩展的方式是继承,这就导致如果两个功能都是继承自同一个基类,那么只能在两个功能之间二选一了,或者自己直接基于其中一个功能去实现另一个功能。所以不如链式调用好,如 CoreDNS5 的 plugin,可以包装无限层,接口统一,任意插件可以在之间插拔。Django 自己的 middleware 机制也是这样。
engine 里面主要写两个文件。
base.py
1 2 3 4 5 6 7 |
from django_prometheus.db.backends.mysql.base import DatabaseWrapper as MysqlDatabaseWrapper from .features import DatabaseFeatures class DatabaseWrapper(MysqlDatabaseWrapper): vendor = 'laixintao' features_class = DatabaseFeatures |
features.py
1 2 3 4 5 6 7 |
from django_prometheus.db.backends.mysql.base import ( DatabaseFeatures as MysqlBaseDatabaseFeatures, ) class DatabaseFeatures(MysqlBaseDatabaseFeatures): supports_foreign_keys = False |
最后,在 settings.py
中,直接把 ENGINE
改成自己的这个包 "ENGINE": "mysql_engine"
。
1 2 3 4 5 6 7 8 9 10 11 12 |
DATABASES = { "default": { "ENGINE": "mysql_engine", "NAME": "testdb1_nofk", "USER": "testuser1", 'HOST': 'localhost', 'PORT': '', 'OPTIONS': { 'unix_socket': '/tmp/mysql.sock', }, } } |
这样之后就完成了。
python manage.py makemgirations
命令不受影响。
python manage.py migrate
命令现在不会对 ForeignKey 生成 REFERENCE
了。
Django 的 migrate 可以正常执行,即使 Django 内置的 table 也不会带有 REFERENCE。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
python3 corelink/manage.py migrate Operations to perform: Apply all migrations: admin, auth, contenttypes, meta, sessions Running migrations: Applying contenttypes.0001_initial... OK Applying auth.0001_initial... OK Applying admin.0001_initial... OK Applying admin.0002_logentry_remove_auto_add... OK Applying admin.0003_logentry_add_action_flag_choices... OK Applying contenttypes.0002_remove_content_type_name... OK Applying auth.0002_alter_permission_name_max_length... OK Applying auth.0003_alter_user_email_max_length... OK Applying auth.0004_alter_user_username_opts... OK Applying auth.0005_alter_user_last_login_null... OK Applying auth.0006_require_contenttypes_0002... OK Applying auth.0007_alter_validators_add_error_messages... OK Applying auth.0008_alter_user_username_max_length... OK Applying auth.0009_alter_user_last_name_max_length... OK Applying auth.0010_alter_group_name_max_length... OK Applying auth.0011_update_proxy_permissions... OK Applying auth.0012_alter_user_first_name_max_length... OK |
查看一个 table 的创建命令:
1 2 3 4 5 6 7 8 9 10 |
MySQL root@(none):testdb1_nofk> show create table auth_user_groups \G ***************************[ 1. row ]*************************** Table | auth_user_groups Create Table | CREATE TABLE `auth_user_groups` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` int NOT NULL, `group_id` int NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `auth_user_groups_user_id_group_id_94350c0c_uniq` (`user_id`,`group_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci |
可以确认是没有 REFERENCE 的。
- chartdb 工具:https://app.chartdb.io/,其他类似的工具还有很多,比如 https://dbdiagram.io/ ↩︎
- db_constraint=False 文档:https://docs.djangoproject.com/en/5.2/ref/models/fields/#django.db.models.ForeignKey.db_constraint ↩︎
- https://github.com/planetscale/django_psdb_engine ↩︎
- https://github.com/korfuri/django-prometheus ↩︎
- https://www.kawabangga.com/posts/4728 ↩︎
typo: “禁用外间的”
感谢,已修正