切换 MySQL 并验证差异
学习目标
把练习项目连接到独立 MySQL 8.4,理解字符集、严格模式、事务锁和测试库权限;知道切换配置不等于迁移旧数据。
为什么开发和部署数据库需要对照验证
SQLite 无需额外服务,适合第一轮学习;MySQL 提供面向多连接写入的服务器与行锁。它们不只是性能差异,还包括大小写比较、外键、并发锁和 SQL 行为。本站使用 MySQL,因此主线最终也在 MySQL 验证,但练习绝不连接本站的 www_django_cn 数据库。
本章允许在本机已安装的 MySQL 上操作;若没有 MySQL,可先读配置,再在第 31 章用 Compose 启动独立实例。MySQL 账号只获得练习库权限,应用不使用 root。客户端连接地址、端口和服务器创建账号时允许的来源需要一致。
具体怎么做
Linux 安装 mysqlclient 编译依赖的示例:
sudo apt-get update
sudo apt-get install -y build-essential pkg-config default-libmysqlclient-dev
python -m pip install -r requirements-mysql.txt
macOS 可安装 MySQL 客户端开发库和 pkg-config,再安装 requirements-mysql;Windows 若没有匹配 wheel 和编译环境,使用下一章 Docker 更省事,不要为了兼容随意降级 Django。
以 MySQL 管理员身份进入客户端,针对独立练习库执行。下面密码为占位符,要替换为自己生成的随机密码:
CREATE DATABASE zhixing CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'zhixing'@'localhost' IDENTIFIED BY '替换为自己的随机数据库密码';
GRANT ALL PRIVILEGES ON zhixing.* TO 'zhixing'@'localhost';
在当前终端设置环境。PowerShell 使用 $env:DB_ENGINE='mysql' 等等价写法;项目没有自动加载 .env:
export DB_ENGINE=mysql
export MYSQL_DATABASE=zhixing
export MYSQL_USER=zhixing
export MYSQL_PASSWORD='填写上面设置的密码'
export MYSQL_HOST=127.0.0.1
export MYSQL_PORT=3306
python manage.py migrate --plan
python manage.py migrate
python manage.py shell -c "exec(open('examples/database_info.py').read())"
本章新增或替换的完整文件
以下路径均相对于练习项目根目录。替换同名文件的全部内容;未列出的文件保持上一章版本。
config/mysql_test_settings.py
import os
from .test_settings import *
DATABASES = {"default": {
"ENGINE": "django.db.backends.mysql", "NAME": "zhixing_test_source",
"USER": os.environ["MYSQL_TEST_USER"], "PASSWORD": os.environ["MYSQL_TEST_PASSWORD"],
"HOST": os.environ.get("MYSQL_TEST_HOST", "127.0.0.1"), "PORT": os.environ.get("MYSQL_TEST_PORT", "3306"),
"OPTIONS": {"charset": "utf8mb4"}, "TEST": {"NAME": "test_zhixing"},
}}
config/settings.py
import os
from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
BASE_DIR = Path(__file__).resolve().parent.parent
DEBUG = os.environ.get("DJANGO_DEBUG", "1") == "1"
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "development-only-never-use-this-key-in-production")
if not DEBUG and (SECRET_KEY.startswith("development-") or len(SECRET_KEY) < 50):
raise ImproperlyConfigured("生产环境必须设置至少 50 字符的独立 DJANGO_SECRET_KEY")
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1,[::1]").split(",")
INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles",
"accounts.apps.AccountsConfig", "blog.apps.BlogConfig",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.middleware.csp.ContentSecurityPolicyMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
TEMPLATES = [{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"], "APP_DIRS": True,
"OPTIONS": {"context_processors": [
"django.template.context_processors.request",
"django.template.context_processors.csp",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]},
}]
WSGI_APPLICATION = "config.wsgi.application"
ASGI_APPLICATION = "config.asgi.application"
DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3"}}
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "zh-hans"
TIME_ZONE = "Asia/Shanghai"
USE_I18N = True
USE_TZ = True
STATIC_URL = "/static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
STATIC_ROOT = BASE_DIR / "staticfiles"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
MAILERS = {"default": {"BACKEND": "django.core.mail.backends.console.EmailBackend"}}
AUTH_USER_MODEL = "accounts.User"
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "blog:home"
LOGOUT_REDIRECT_URL = "blog:home"
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
DATA_UPLOAD_MAX_MEMORY_SIZE = 3 * 1024 * 1024
FILE_UPLOAD_MAX_MEMORY_SIZE = 2 * 1024 * 1024
# SMTP 可选;没有配置时只打印到开发终端。
if os.environ.get("EMAIL_HOST"):
MAILERS = {"default": {
"BACKEND": "django.core.mail.backends.smtp.EmailBackend",
"OPTIONS": {
"host": os.environ["EMAIL_HOST"],
"port": int(os.environ.get("EMAIL_PORT", "587")),
"username": os.environ.get("EMAIL_HOST_USER", ""),
"password": os.environ.get("EMAIL_HOST_PASSWORD", ""),
"use_tls": True,
"timeout": 10,
},
}}
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "noreply@example.invalid")
PASSWORD_RESET_TIMEOUT = 3600
# 入门采用进程内缓存;多进程间不是共享缓存。
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache", "LOCATION": "zhixing"}}
LOGGING = {
"version": 1, "disable_existing_loggers": False,
"formatters": {"standard": {"format": "{asctime} {levelname} {name} {message}", "style": "{"}},
"handlers": {"console": {"class": "logging.StreamHandler", "formatter": "standard"}},
"root": {"handlers": ["console"], "level": "INFO"},
}
from django.utils.csp import CSP
SECURE_CSP = {
"default-src": [CSP.SELF], "script-src": [CSP.SELF, CSP.NONCE],
"style-src": [CSP.SELF, CSP.NONCE], "img-src": [CSP.SELF, "data:"],
"object-src": [CSP.NONE], "base-uri": [CSP.SELF], "frame-ancestors": [CSP.NONE],
"form-action": [CSP.SELF],
}
SESSION_COOKIE_HTTPONLY = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"
# 仅用于本章演示:ImmediateBackend 在调用进程立即执行,不是生产队列。
TASKS = {"default": {"BACKEND": "django.tasks.backends.immediate.ImmediateBackend"}}
if os.environ.get("DB_ENGINE") == "mysql":
DATABASES = {"default": {
"ENGINE": "django.db.backends.mysql", "NAME": os.environ["MYSQL_DATABASE"],
"USER": os.environ["MYSQL_USER"], "PASSWORD": os.environ["MYSQL_PASSWORD"],
"HOST": os.environ.get("MYSQL_HOST", "127.0.0.1"), "PORT": os.environ.get("MYSQL_PORT", "3306"),
"OPTIONS": {"charset": "utf8mb4", "init_command": "SET sql_mode='STRICT_TRANS_TABLES'"},
"CONN_MAX_AGE": 0,
}}
examples/database_info.py
from django.db import connection
print("数据库引擎:", connection.vendor)
print("支持行锁:", connection.features.has_select_for_update)
print("数据表数量:", len(connection.introspection.table_names()))
requirements-mysql.txt
-r requirements.txt
mysqlclient==2.3.0
如果需要迁移练习数据
先停止写入,保留原 SQLite 和媒体备份。还连接 SQLite 时导出业务数据,再切换到空的目标练习库执行 migrate 和 loaddata。auth.group 先导出,natural-foreign 让权限和用户关系通过自然键解析,不复制 contenttypes 和 permissions 的原始 id。
mkdir -p backups
# 在未设置 DB_ENGINE=mysql 的终端、仍连接 SQLite 时执行:
python manage.py dumpdata auth.group accounts blog --natural-foreign --natural-primary --output backups/sqlite-export.json
# 按上文设置 MySQL 环境后执行,目标必须是空的练习库:
python manage.py migrate
python manage.py loaddata backups/sqlite-export.json
媒体文件不是数据库内容,需要单独复制。导出可能含邮箱与密码哈希,文件应限制权限并放在已忽略的 backups 中。正式项目迁移需要行数、外键、唯一性、抽样内容和回退验证,本例只覆盖这套博客模型。
如何确认成功
database_info 显示 mysql 和支持行锁。比较迁移前后的文章、评论和账号数量,实际登录两个账号检查归属和图片。邮箱大小写冲突可能在 SQLite 未暴露,却被 MySQL 默认排序规则拒绝,导入前应先清理冲突,而不是关闭唯一约束。
MySQL 测试使用单独配置和账号。只在独立测试实例,以管理员执行下面 SQL;应用账号没有创建测试库的权限是正常保护,不应给生产账号增加全局权限。
CREATE DATABASE zhixing_test_source CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'zhixing_test'@'localhost' IDENTIFIED BY '替换为独立测试密码';
GRANT ALL PRIVILEGES ON zhixing_test_source.* TO 'zhixing_test'@'localhost';
GRANT ALL PRIVILEGES ON test_zhixing.* TO 'zhixing_test'@'localhost';
在测试终端设置专用连接。容器网络的来源地址不同,账号 host 与 MYSQL_TEST_HOST 要按隔离网络调整;不要为方便把数据库端口公开。
export MYSQL_TEST_USER=zhixing_test
export MYSQL_TEST_PASSWORD='填写独立测试密码'
export MYSQL_TEST_HOST=127.0.0.1
export MYSQL_TEST_PORT=3306
python manage.py test --settings=config.mysql_test_settings
预期创建并销毁 test_zhixing,不触碰应用库。练习:分别查看两种数据库的执行计划和行锁能力,解释为什么不能用一次轻量查询的耗时给所有场景下结论。
常见问题
Access denied? 检查账号来源、密码和授权范围,容器中的 localhost 指容器自己。改 MYSQL_PASSWORD 后旧卷密码没变? 数据库初始化环境只对首次建库生效,已有账号需要通过数据库管理流程改密。可以使用 DB_SET_DEFAULT 吗? MySQL/MariaDB 不支持该删除选项,需选择兼容策略。
官方参考
登录后可保存阅读位置、标记章节完成,并在其他设备继续学习。
登录并保存学习进度 →联系站长
微信:VIPdjango。技术问题建议先在 社区讨论 中交流,方便更多人参与和查阅。
章节讨论 0