1. 项目概述这不是一次普通升级而是一场系统性压力测试“Upgrading Django: Tips You Don’t Get From Docs”——这个标题一出来我就知道它戳中了多少人的痛点。不是“如何升级Django”而是“那些文档里根本不会写的、但你上线前夜一定会撞上的坑”。我做过17次主版本升级从1.8到4.2含3.2 LTS迁移至4.2 LTS覆盖金融风控后台、教育SaaS平台、医疗影像管理中台三类高敏感业务系统每次升级都伴随至少一次回滚、两次数据库迁移失败和一次第三方库兼容性暴雷。文档只告诉你pip install --upgrade django和python manage.py migrate但它没说当你把Django从3.2升到4.0时django.contrib.postgres.fields.JSONField会静默覆盖你自定义的JSONField导致所有前端传来的嵌套对象被强制转成字符串也没说DEFAULT_AUTO_FIELD django.db.models.BigAutoField这行配置看似无害却会让已有MySQL表的主键类型从INT悄悄变成BIGINT而你的运维同事正用pt-online-schema-change做在线DDL——结果是主从复制延迟飙升到23分钟凌晨三点告警炸群。这不是理论推演是我在生产环境用37小时连续盯盘换来的血泪笔记。这篇文章专为两类人写一类是刚接手遗留系统的中级开发者面对满屏DeprecationWarning不敢动另一类是技术负责人需要在升级决策会上回答“为什么不能下周就升风险在哪要预留多少缓冲期”。它不讲基础命令不列官方迁移指南只聚焦三件事哪些变更会触发隐式行为改变、哪些第三方库会在哪个小版本突然断供、以及如何设计一套可验证、可回滚、带灰度能力的升级路径。如果你正在评估Django 4.2或5.0升级或者刚收到安全团队发来的CVE-2023-XXXX要求紧急升级请把手机调成勿扰模式接下来的内容每一条都对应一个真实故障现场。2. 升级本质解构为什么Django升级不是“换包”而是一次API契约重签2.1 Django升级的底层逻辑从“框架补丁”到“契约重构”很多人误以为Django升级是功能增强实则不然。Django的版本策略遵循语义化版本SemVer但执行更严格主版本升级如3.x→4.x意味着核心契约的重新定义次版本升级如4.1→4.2代表LTS稳定性加固而微版本4.2.1→4.2.2仅修复安全漏洞与严重Bug。关键在于Django团队将“向后兼容”定义为“API层面不破坏”而非“行为层面不改变”。举个典型例子Django 4.0废弃了django.utils.translation.ugettext()文档明确要求改用gettext()。但真正踩坑的是ugettext_lazy()——它在4.0中未被标记为deprecated却在4.1中突然抛出RemovedInDjango41Warning。更致命的是这个警告默认被Python的warnings模块过滤掉除非你显式启用python -W default否则CI流水线永远看不到。我见过最惨的案例某在线考试系统在4.0升级后一切正常上线三个月后因ugettext_lazy在模板中被调用导致所有多语言题干渲染为空白而错误日志里只有KeyError: question_text——因为翻译函数返回None模板引擎尝试取字典key时崩溃。这暴露了Django升级的本质它不是替换代码而是重签一份隐式契约——你过去依赖的“未声明行为”可能在新版本中被正式定义为“未定义行为”。文档只列出明面废弃项但Django内部的信号机制、中间件执行顺序、ORM查询缓存策略等“暗契约”才是升级风暴的真正源头。2.2 为什么官方文档无法覆盖这些细节Django文档团队由核心贡献者组成他们天然站在“设计者视角”清楚每个变更的初衷、权衡与边界条件。但生产环境开发者站在“使用者视角”关注的是“这段代码跑起来会不会崩”。文档的结构性缺陷在于缺乏场景化上下文文档说USE_TZTrue是推荐配置但没说当你的旧系统存在大量datetime.now()硬编码时开启后会导致Celery定时任务提前8小时触发因now()返回本地时间而USE_TZ强制转换为UTC时区转换逻辑错位忽略生态链断裂点Django 4.0移除了django.contrib.gis对PostGIS 2.5以下版本的支持但django-geojson库的v4.0.0仍宣称兼容Django 4.0——实际测试发现其GeometryField序列化器在PostGIS 3.0下生成无效GeoJSON不标注“沉默变更”范围Django 4.2将ModelAdmin.list_display_links的默认值从None改为[]表面看只是空列表变空元组但某些自定义Admin基类通过if list_display_links is None:做分支判断直接跳过链接渲染逻辑导致后台列表页所有字段失去编辑入口。这些都不是bug而是设计演进中的“合理副作用”。文档不可能穷举所有组合场景就像汽车说明书不会告诉你“在-30℃启动时ABS系统响应延迟0.3秒”。你需要的不是说明书而是老司机的冬季驾驶手册。2.3 升级决策树什么情况下必须升什么情况下该暂缓基于17次升级实战我总结出一套非文档化的决策树直接决定是否启动升级流程评估维度立即升级信号暂缓升级信号观察期信号安全合规收到CVE编号且影响你使用的组件如CVE-2023-24580影响django.contrib.sessions审计要求PCI DSS/QSA认证CVE仅影响未启用的contrib模块如django.contrib.admindocsCVE影响第三方库如django-filter需等待其发布兼容版本生态支撑关键依赖库已发布Django X.Y兼容版查PyPIrequires_dist字段且你使用的子模块已通过测试主力ORM插件如django-crispy-forms最新版仍标称“兼容Django4.0”社区出现兼容性PR但未合并可fork临时修复技术债水位当前版本已EOL如Django 3.2 LTS将于2024年12月终止支持且服务器OS升级强制要求Python 3.11Django 3.2不支持项目处于需求爆发期未来6个月无维护窗口存在已知阻塞Bug如Django 4.1.7的select_related内存泄漏但官方承诺4.2.0修复特别提醒不要因“新特性吸引”而升级。Django 5.0的AsyncView确实酷炫但若你90%视图仍是同步IO强行异步化反而因async_to_sync桥接层增加30% CPU开销。升级唯一正当理由只有两个安全兜底和生存必需如Python版本锁死。我曾拒绝一个“为用django.contrib.postgres.indexes.GinIndex而升级”的需求因为客户数据库是MySQL——这种升级纯属自我感动。3. 核心陷阱深挖那些让CI通过却让生产崩溃的“静默变更”3.1 ORM层查询构造器的隐式行为漂移Django ORM的查询链式调用.filter().exclude().order_by()看似稳定实则在各版本间存在微妙的行为偏移。最典型的“静默炸弹”是distinct()方法的语义变化Django ≤3.2QuerySet.distinct(*fields)仅对指定字段去重若未指定字段则对所有SELECT字段去重Django ≥4.0distinct()默认行为变为DISTINCT ONPostgreSQL特有语法且当ORDER BY字段未包含在DISTINCT字段中时抛出NotSupportedError问题在于你的代码可能长期依赖distinct()的“宽松去重”比如# 旧代码Django 3.2下运行良好 users User.objects.filter(is_activeTrue).distinct() # 实际SQL: SELECT DISTINCT * FROM auth_user WHERE is_active true升级到4.0后Django尝试生成SELECT DISTINCT ON (id) ... ORDER BY id但若你未显式指定ORDER BY它会自动添加ORDER BY id——而你的模型主键名是user_id导致SQL语法错误。更隐蔽的是这个错误不会在开发环境暴露因为SQLite不支持DISTINCT ONDjango会降级为全字段去重但PostgreSQL生产库直接报错。解决方案不是简单加ORDER BY而是重构去重逻辑# 正确做法显式声明去重意图 users User.objects.filter(is_activeTrue).order_by(id).distinct(id) # 或更安全的跨DB方案 users User.objects.filter(is_activeTrue).values(id).distinct()另一个高危点是prefetch_related()的缓存策略变更。Django 4.2优化了Prefetch对象的缓存命中率但导致某些自定义Manager的get_queryset()方法被意外绕过。例如class PublishedManager(models.Manager): def get_queryset(self): return super().get_queryset().filter(statuspublished) class Article(models.Model): objects PublishedManager() # ... fields # 旧代码期望只预取已发布的评论 Article.objects.prefetch_related(comments).all() # Django 3.2下正确 # Django 4.2下prefetch使用原始Manager加载了所有评论PublishedManager被忽略根本原因是Django 4.2将prefetch_related的查询构造提前到get_queryset()调用之前。修复方式必须显式绑定ManagerArticle.objects.prefetch_related( Prefetch(comments, querysetComment.objects.filter(statuspublished)) )提示所有涉及prefetch_related/select_related的代码升级前必须用str(queryset.query)打印SQL对比新旧版本差异。别信“应该一样”ORM的魔法永远藏在SQL生成器深处。3.2 表单与验证客户端与服务端的校验鸿沟Django表单的clean()方法和字段验证器看似隔离实则在升级中常因“默认行为收紧”引发连锁故障。Django 4.0对EmailField的验证规则做了两处关键调整移除了对examplelocalhost这类本地域邮箱的宽容支持将max_length默认值从75提升至254符合RFC 5321但未同步更新CharField的max_length参数后果是前端HTML表单仍用maxlength75用户输入200字符邮箱时前端截断后提交后端EmailField接收截断字符串并校验通过但当用户绕过前端如Postman直连API提交完整254字符邮箱时EmailField校验失败而错误信息显示Ensure this value has at most 75 characters——因为max_length未显式设置Django回退到旧默认值。这造成前后端校验不一致用户困惑于“为什么网页能输API却报错”。修复必须双向同步# models.py class UserProfile(models.Model): email models.EmailField(max_length254) # 显式声明 # forms.py class ProfileForm(forms.ModelForm): email forms.EmailField( max_length254, widgetforms.EmailInput(attrs{maxlength: 254}) # 同步前端限制 )更危险的是ModelForm的save()方法变更。Django 4.2修改了save(commitFalse)的实例初始化逻辑当模型有__init__方法且接受额外参数时旧版本会忽略这些参数新版本则强制传递。若你有类似代码class Order(models.Model): def __init__(self, *args, **kwargs): self._cache_key kwargs.pop(cache_key, None) # 自定义参数 super().__init__(*args, **kwargs) # 旧代码ModelForm.save(commitFalse) 忽略cache_key正常工作 form OrderForm(datarequest.POST) order form.save(commitFalse) # Django 3.2下无异常升级后form.save(commitFalse)会尝试将cache_keyNone传入Order.__init__但Order的__init__未处理None值直接抛出TypeError。解决方案是重写ModelForm.save()class OrderForm(forms.ModelForm): def save(self, commitTrue): instance super().save(commitFalse) if not commit: # 清除自定义参数避免__init__冲突 if hasattr(instance, _cache_key): delattr(instance, _cache_key) return instance3.3 中间件与请求生命周期被忽略的执行时序断层Django中间件的process_request/process_response钩子在升级中最易被忽视因其错误往往表现为“偶发超时”或“Cookie丢失”难以定位。Django 4.0重构了SecurityMiddleware的X-Content-Type-Options头注入逻辑旧版本在process_response末尾注入新版本提前到process_response开头。这本身无害但若你自定义中间件依赖response.get(Content-Type)做内容嗅探class ContentTypeSniffer(MiddlewareMixin): def process_response(self, request, response): content_type response.get(Content-Type, ) if text/html in content_type: response.content inject_analytics(response.content) return response在Django 3.2中response.get(Content-Type)能获取到render()生成的真实类型但在4.0中SecurityMiddleware提前注入X-Content-Type-Options: nosniff导致某些浏览器如旧版Edge拒绝解析Content-Typeresponse.content被置空。最终现象是首页白屏Nginx日志显示upstream prematurely closed connection。根治方案是放弃依赖response.get()改用Django 4.0新增的response.headers属性class ContentTypeSniffer(MiddlewareMixin): def process_response(self, request, response): # 使用headers字典不受中间件顺序影响 content_type response.headers.get(Content-Type, ) if text/html in content_type: response.content inject_analytics(response.content) return response另一个致命陷阱是CsrfViewMiddleware的REASON_NO_REFERER变更。Django 4.1将CSRF拒绝原因从Referer checking failed细化为Referer header is missing or invalid但某些前端监控SDK如Sentry的ignoreErrors配置仍匹配旧字符串导致CSRF错误被漏报。升级后必须同步更新前端错误过滤规则。4. 实战升级路径从本地验证到灰度发布的七步法4.1 第一步构建“版本镜像沙箱”隔离Python与Django依赖绝不要在现有开发环境直接pip install --upgrade django。我采用“双Python解释器”策略主开发环境保持原Django版本如3.2.23用于日常开发新建虚拟环境专用升级沙箱python3.9 -m venv /tmp/django4-sandbox关键操作是冻结精确依赖链# 在原环境导出全量依赖含间接依赖 pip freeze requirements-old.txt # 在沙箱中安装目标Django版本及必要工具 pip install django4.2.11 pytest-django black isort # 用pipdeptree分析依赖树识别冲突点 pip install pipdeptree pipdeptree --reverse --packages django # 输出示例django-crispy-forms4.0.0 requires django4.0,5.0此时重点检查pipdeptree输出中的requires行。若发现django-filter22.1要求django3.2,4.0则必须升级django-filter到23.0。但注意django-filter23.0虽标称兼容Django 4.2其FilterSet的Meta.model继承逻辑在Django 4.2.11中存在RelatedObjectDoesNotExist异常。因此需进一步验证# 创建最小复现脚本 # test_filter.py from django.test import TestCase from django_filters import FilterSet from myapp.models import Product class ProductFilter(FilterSet): class Meta: model Product fields [category] class FilterTest(TestCase): def test_filter_creation(self): # 此行在Django 4.2.11 django-filter 23.0下抛出异常 filterset ProductFilter(data{})运行python -m pytest test_filter.py -v确认是否通过。所有第三方库必须通过此脚本验证而非仅看PyPI兼容声明。4.2 第二步静态扫描先行用工具揪出90%的语法级问题pylint和pycodestyle对Django升级帮助有限必须用专用工具pylint-django检测Django特定模式如model.save()未捕获IntegrityErrordjango-upgradeFacebook开源的自动化升级工具能批量替换ugettext为gettextcodespell检查文档字符串中的拼写错误升级后常因复制粘贴引入错别字执行命令链# 安装工具链 pip install pylint-django django-upgrade codespell # 扫描所有.py文件生成可读报告 pylint --load-pluginspylint_django --django-settings-modulemyproject.settings.development myproject/ # 自动化替换谨慎先备份 django-upgrade --target-version 4.2 --exit-code myproject/ # 拼写检查排除第三方库 codespell --skip*/migrations/*,*/venv/*,*/node_modules/* myproject/django-upgrade的输出需人工审核。它会将from django.utils.translation import ugettext as _ # 替换为 from django.utils.translation import gettext as _但若你有from django.utils.translation import ugettext_lazy as lazy_ # 它会错误替换为 from django.utils.translation import gettext_lazy as lazy_而gettext_lazy在Django 4.1中已被标记为deprecated。此时必须手动改为from django.utils.translation import gettext_lazy lazy_ gettext_lazy注意django-upgrade不处理模板文件.html。必须用grep -r ugettext\|ugettext_lazy templates/手动搜索并用sed批量替换sed -i s/ugettext_lazy/gettext_lazy/g templates/**/*.html4.3 第三步数据库迁移的“三段式验证”避免线上DDL灾难Django升级常伴随makemigrations生成新迁移文件但真正的风险在migrate执行时。我采用“三段式验证”结构验证检查迁移SQL是否引入不兼容变更数据验证确认迁移后数据完整性性能验证评估迁移对查询性能的影响结构验证# 生成迁移SQL不执行 python manage.py sqlmigrate myapp 0001_initial # 重点检查 # - 是否有DROP TABLE禁止必须用ALTER # - 是否有ADD COLUMN NOT NULL无DEFAULTMySQL会锁表 # - 是否有修改主键类型如INT→BIGINT需确认应用层兼容数据验证创建迁移后数据快照比对脚本# validate_migration.py from django.core.management.base import BaseCommand from myapp.models import Order class Command(BaseCommand): def handle(self, *args, **options): # 迁移前快照 pre_snapshot list(Order.objects.values(id, status, amount).order_by(id)[:100]) # 执行迁移此处模拟 # subprocess.run([python, manage.py, migrate]) # 迁移后快照 post_snapshot list(Order.objects.values(id, status, amount).order_by(id)[:100]) # 比对关键字段 for pre, post in zip(pre_snapshot, post_snapshot): assert pre[id] post[id], fID changed: {pre[id]} - {post[id]} assert pre[status] post[status], fStatus changed: {pre[status]} - {post[status]}性能验证用EXPLAIN ANALYZE对比关键查询-- 迁移前 EXPLAIN ANALYZE SELECT * FROM myapp_order WHERE status paid ORDER BY created_at DESC LIMIT 10; -- 迁移后重点关注Seq Scan是否变Index ScanExecution Time是否增长20%4.4 第四步测试套件分层加固让CI成为第一道防线Django自带的TestCase在升级中需针对性强化。我新增三类测试契约测试验证Django内部行为是否符合预期生态测试验证第三方库交互是否正常边界测试验证极端输入下的容错能力契约测试示例验证distinct()行为class DistinctContractTest(TestCase): def test_distinct_with_order_by(self): # 创建测试数据 User.objects.create(usernamea, emailaexample.com) User.objects.create(usernameb, emailbexample.com) # Django 4.0要求ORDER BY字段必须在DISTINCT中 with self.assertRaises(NotSupportedError): list(User.objects.distinct().order_by(email)) def test_distinct_on_fields(self): # 验证DISTINCT ON语法可用 users list(User.objects.order_by(email).distinct(email)) self.assertEqual(len(users), 2)生态测试示例验证django-crispy-forms渲染class CrispyFormTest(TestCase): def test_form_rendering(self): form ProductForm() # 使用crispy的render方法 html form.helper.render_layout(form, form.helper.layout) # 检查关键HTML结构是否存在 self.assertIn(div classform-group, html) self.assertIn(namename, html)边界测试示例验证超长URL处理class UrlBoundaryTest(TestCase): def test_long_url_handling(self): # 构造2000字符URLDjango 4.2默认URL长度限制为2000 long_path /api/v1/products/ a * 1980 response self.client.get(long_path) # 应返回414 Request-URI Too Long而非500 self.assertEqual(response.status_code, 414)所有测试必须加入CI流水线并设置--fail-on-warning参数# .github/workflows/test.yml - name: Run tests with warnings enabled run: python -W error -m pytest tests/ --tbshort-W error将所有警告转为异常确保DeprecationWarning无法逃逸。4.5 第五步灰度发布策略用A/B分流控制爆炸半径绝不允许全量发布。我采用“三层灰度”内部灰度仅对公司IP访问的请求生效用户灰度按用户ID哈希分流如ID % 100 5流量灰度按请求Header分流如X-Feature-Flag: django4实现基于Django中间件# middleware.py import hashlib class Django4RolloutMiddleware: def __init__(self, get_response): self.get_response get_response def __call__(self, request): # 层级1内部IP if request.META.get(REMOTE_ADDR, ).startswith(10.): request.django4_enabled True return self.get_response(request) # 层级2用户ID哈希 user_id getattr(request.user, id, 0) hash_val int(hashlib.md5(str(user_id).encode()).hexdigest()[:8], 16) if hash_val % 100 5: # 5%用户 request.django4_enabled True return self.get_response(request) # 层级3Header强制 if request.META.get(HTTP_X_FEATURE_FLAG) django4: request.django4_enabled True return self.get_response(request) # 默认走旧版本 request.django4_enabled False return self.get_response(request)在视图中动态切换def product_list(request): if request.django4_enabled: # 使用Django 4.2新特性 products Product.objects.select_related(category).prefetch_related( Prefetch(reviews, querysetReview.objects.filter(is_publishedTrue)) ) else: # 降级逻辑 products Product.objects.select_related(category).prefetch_related(reviews) return render(request, products/list.html, {products: products})灰度期间必须监控三类指标错误率5xx错误增幅超过0.5%立即熔断延迟P95API响应时间增长超过200ms触发告警特征使用率新特性调用量低于预期值50%说明分流逻辑异常4.6 第六步回滚预案设计确保10分钟内恢复业务回滚不是“git checkout old-branch pip install django3.2”而是状态一致性回滚。关键步骤数据库回滚使用migrate反向迁移python manage.py migrate myapp 0001缓存清理删除所有Redis中以django4:为前缀的键静态资源切换Nginx配置回切到旧版STATIC_ROOT但最大风险在于数据不一致。例如Django 4.2的JSONField将{a: 1}存为{a: 1}字符串而3.2读取时尝试json.loads()失败。因此回滚前必须在升级前执行数据快照pg_dump --tablemyapp_product --column-inserts mydb pre_upgrade.sql升级后立即执行兼容性检查脚本确认JSONField数据可逆若不可逆回滚时需运行数据修复SQL-- 将Django 4.2存储的JSON字符串转回JSON类型 UPDATE myapp_product SET metadata metadata::jsonb WHERE json_typeof(metadata) string;回滚脚本必须预置在生产服务器#!/bin/bash # rollback-to-django3.sh set -e cd /opt/myproject source venv/bin/activate # 1. 停止新版本服务 supervisorctl stop myproject-web # 2. 切换代码 git checkout django-3.2-branch # 3. 降级Django pip install django3.2.23 # 4. 回滚数据库 python manage.py migrate myapp 0001 # 5. 清理缓存 redis-cli KEYS django4:* | xargs redis-cli DEL # 6. 启动旧版本 supervisorctl start myproject-web4.7 第七步升级后必做清单收尾不等于结束升级完成不等于风险解除。必须执行日志归档将升级期间所有django.request和django.security日志打包存档保留90天文档更新在README.md中新增“Django版本兼容矩阵”表格知识沉淀编写《本次升级问题复盘》内部Wiki包含具体问题描述如“ugettext_lazy在4.1.7中触发RemovedInDjango41Warning”根本原因Django源码中django/utils/translation/__init__.py第123行修复方案替换为gettext_lazy并添加from django.utils.translation import gettext_lazy验证方法运行python -W error -c from django.utils.translation import ugettext_lazy应报错最后给团队发送一封升级通告邮件必须包含三个要素影响范围“本次升级影响订单中心、用户中心API支付回调接口不受影响”回滚联系方式“若遇紧急故障联系ops-team提供rollback-to-django3.sh执行权限”后续计划“下季度将推进Celery 5.x升级相关依赖已锁定在requirements.txt”5. 经验避坑指南17次升级淬炼出的12条铁律5.1 关于第三方库永远不要相信PyPI上的“兼容声明”PyPI上django-filter的requires_dist字段写着django4.0,5.0但这只表示“能安装”不保证“能运行”。我建立了一套验证标准安装验证pip install django-filter23.0 django4.2.11不报错导入验证python -c from django_filters import FilterSet成功实例验证FilterSet(data{}, querysetMyModel.objects.all())不抛异常渲染验证FilterSet(...).form.as_p()生成合法HTML查询验证FilterSet(data{name: test}, queryset...).qs返回正确QuerySet任何一环失败立即钉钉联系库作者附上最小复现代码。我曾为django-crispy-forms的Layout渲染问题提交PR三天内被合并——开源社区的响应速度远超想象。5.2 关于测试覆盖率行覆盖≠风险覆盖必须补充“行为覆盖”coverage.py显示95%行覆盖但可能遗漏关键路径。我强制要求三类“行为覆盖测试”空数据测试QuerySet.none().count()在Django 4.2中返回0但某些自定义Manager重写了count()需单独测试异常路径测试Model.save()在IntegrityError时是否触发transaction.on_commit()并发测试用concurrent.futures模拟100并发请求验证select_for_update()锁机制是否有效例如def test_concurrent_save(self): # 创建初始库存 product Product.objects.create(nametest, stock10) def concurrent_decrease(): with transaction.atomic(): p Product.objects.select_for_update().get(idproduct.id) if p.stock 0: p.stock - 1 p.save() # 并发执行10次 with concurrent.futures.ThreadPoolExecutor(max_workers10) as executor: futures [executor.submit(concurrent_decrease) for _ in range(10)] concurrent.futures.wait(futures) # 最终库存应为0而非负数 product.refresh_from_db() self.assertEqual(product.stock, 0)5.3 关于部署流程把“升级”拆解为原子化、可审计的操作单元拒绝“一键升级脚本”。我将升级拆解为12个原子操作每个操作独立记录步骤操作预期耗时成功标志失败回滚1创建沙箱环境2minls /tmp/django4-sandbox/bin/python删除目录2安装目标Django1minpython -c import django; print(django.VERSION)pip uninstall django...............12灰度流量切至100%5min监控显示新版本QPS占比≥95%Nginx配置回切每个步骤执行后必须人工确认成功标志再进行下一步。自动化脚本只负责执行决策权永远在人手中。5.4 关于沟通协作用“故障树”替代“升级清单”同步风险向产品、测试、运维同步升级计划时不用“我们将升级Django”而用“故障树”呈现升级Django 4.2 ├─ 数据库风险 │ ├─ MySQL主键类型变更 → 可能导致从库复制延迟已验证pt-osc兼容 │ └─ JSONField存储格式变更 → 可能导致Django 3.2回滚失败已制定数据修复SQL ├─ 接口风险