Django 从入门到精通:知行博客实战(6.1) · 第 30 / 36 节

切换 MySQL 并验证差异

吴秀峰 3 次阅读 0 条评论 ↓

学习目标

把练习项目连接到独立 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