这一页是后端踩坑合集:每一条都是我们真实付出过排查成本的坑,按「症状 → 根因 → 铁律」拆开讲。写后端的工程师和给 AI 助手喂上下文的 IT 负责人都该通读一遍——这些坑的共同点是:代码看起来没错,错在环境、约定和习惯。
读完你会知道:
- 为什么定时任务会「半夜错点跑」,以及时区策略为什么必须写成文档而不是口头约定
- 数据库迁移编号为什么会分叉,手写迁移的唯一安全姿势
- 三类「偶发、难复现」的连接层问题(Session 掉登录 / 驱动线程安全 / 大 header 被拒)各自的真面目
- 序列化层的两个隐形杀手:长浮点数和响应缓存
- 几条从第一天就该定死的约定:响应码、文件命名、ORM 预取
这些坑有个共同规律:单看任何一段代码都是对的,坑在两个系统的假设不一致——Django 假设一套时区、调度器假设另一套;开发库生成一版迁移、生产库长着另一版;后端吐一个数、前端的解析库理解成另一个东西。所以每条的「铁律」都不是修 bug 的技巧,而是消灭「不一致」的约定。约定要写进项目文档(我们写在 CLAUDE.md 和部署文档里),让人和 AI 助手都能查到。
- 症状:配置写的凌晨 2 点跑的任务,实际上午 10 点才跑;或者反过来,白天的任务半夜把人吵醒。查代码怎么看都是对的。
- 根因:Django 项目设了
USE_TZ = False、时区用本地时间(我们是 Asia/Shanghai),但 Celery Beat 这类调度器默认按 UTC 理解 crontab 表达式。两边假设打架,差值正好是时区偏移(东八区就是 8 小时)。更阴险的是:如果有人「聪明地」在配置里手工减了 8 小时补偿,后来另一个人修了调度器时区设置,所有任务又集体偏移一次。 - 铁律:项目第一天就选定一套时区策略(
USE_TZ用不用、调度器时区设成什么),写进项目文档;此后 Beat 的小时一律按本地时间写,不做任何心算换算。见到配置里出现hour=18 # 实际是凌晨2点这种注释,就是策略没统一的信号,立刻停下来治理。
定时任务还有「双调度系统并存」的历史坑,展开见 定时任务:双系统并存的教训。
- 症状:本地
makemigrations生成0042_xxx.py,推上去发现生产库的迁移历史里已经有一个别的0042;或者生产migrate直接报错「表/字段已存在」。 - 根因:多个环境(多台开发机、多个分支、开发与生产)各自跑过
makemigrations,同一个编号被生成了两次,迁移历史从此分叉。Django 的迁移是一条链,链一分叉,后面每一次合并都是雷。 - 铁律:手写或补写迁移,必须先看生产库的最新编号,按它的下一号创建——我们的做法干脆是登上生产环境查完编号再写文件。迁移文件视为只追加(append-only):已经进了任何共享环境的迁移,不改、不删、不重排;发现分叉,用新迁移向前修,绝不回头改历史。
- 症状:运维重启了一次 Redis,几分钟内所有用户都被踢回登录页,客服群炸了。
- 根因:Session 存在 Redis 的缓存库里(
SESSION_ENGINE走 cache),而缓存库没开持久化——重启即清空,所有会话灰飞烟灭。这不是 bug,是当初选型时没人把「重启」这个场景想清楚。 - 铁律:二选一,但必须明确地选:要么给存 Session 的 Redis 开持久化(RDB/AOF),要么接受「重启踢全员」并把它写进运维文档、每次重启前提前公告。最糟的状态是没人知道会发生什么,重启完才发现。
- 症状:并发一上来,偶发诡异的数据库报错——查询结果串了、连接状态异常,单线程压测又完全复现不了。
- 根因:数据库驱动的线程安全配置没开。纯 Python 驱动(比如 pymysql)配上多线程的应用服务器(比如 uWSGI 开了多线程),如果线程支持参数没启用,多个线程就可能踩同一个连接。
- 铁律:确认驱动 + 应用服务器的线程配置是一致的(uWSGI 记得
enable-threads=true这类开关),然后上线前用真实并发压测一轮——这类问题只在并发下现形,功能测试永远测不出来。
- 症状:同一套登录接口,浏览器里好好的,放进飞书/企业微信这类 IM 的内嵌 webview 里就必然失败,而且报错常常发生在业务代码之前,日志里几乎没线索。
- 根因:IM 的 webview(尤其安卓端)会在请求里塞很长的 header(UA、鉴权透传、cookie 一大串),超过了应用服务器的请求 buffer 默认值,请求直接在入口层被拒,根本没走到 Django。
- 铁律:把入口层 buffer 调大(uWSGI 的
buffer-size从默认 4KB 提到 64KB 这个量级),并且把这个参数连同原因写进部署文档——它属于「不写下来,下次重搭环境必再踩」的知识。
- 症状:同一个接口,大部分数据正常,个别记录让前端页面直接崩掉,或者金额字段莫名显示成一串字符串。后端日志干干净净。
- 根因:前端为了处理超长整数用了 json-bigint 一类的解析库,这类库遇到位数很长的浮点数(比如除法算出来的
13.333333333333334)会把它转成字符串保精度。后端一个没 round 的除法,前端一个当数字用的字符串,toFixed一调就崩。 - 铁律:后端出参里的浮点数必须 round(两位小数或按口径定),不许把裸除法结果直接吐给前端;钱这种字段,更进一步用「分」为单位的整数或定点数表达。这条要写进接口约定,让 AI 助手写新接口时也自动遵守。
- 症状:后端明明加了字段、本地自测有值,上线后前端就是拿不到;过了几十分钟又「自己好了」。
- 根因:列表接口有后端缓存(为了压 TTFB 加的),发布只更新了代码,没清对应的缓存键——线上吐的还是旧结构的缓存数据,直到缓存自然过期。
- 铁律:给带缓存的接口加字段,必须同步清对应缓存键,把「清哪些键」写进该接口的文档;同时坚持响应结构平铺(不嵌套包一层套一层),平铺的结构让缓存键可枚举、可 grep,清缓存才不会漏。
- 症状:前端判断请求是否成功,要写
code === '0' || code === 0,新同学每次都问为什么。 - 根因:项目早期没定死响应码类型,一部分接口返回字符串
'0',一部分返回整数,失败码又是负整数——历史一层层叠上来,谁也不敢动。 - 铁律:响应码的类型和取值第一天定死一种(我们后来统一为:成功是字符串
'0',失败用整数负数),写进编码规范;存量不一致的老接口列清单批量清,不要指望「以后慢慢改」——以后只会更多。
- 症状:新写的视图模块,import 进来后调用的却「不是它」——函数找不到、行为对不上,单独跑文件又正常。
- 根因:仓库里已经存在一个同名模块(可能在另一个包路径下),Python 的模块解析让其中一个遮蔽了另一个。我们踩的实例:日报视图起名
daily_report就撞了已有名字,被迫改叫work_report_view才解开。 - 铁律:视图文件名全仓唯一;新建任何模块前,先全库搜一遍这个名字。这条成本几乎为零,但省下的排查时间以小时计。
- 症状:一个平平无奇的列表页要 3 秒才出来,数据量并不大。
- 根因:N+1 查询——列表循环里逐行取关联对象,一页 100 行就是几百次数据库往返。ORM 把这件事藏得太好,写的时候完全无感。
- 铁律:ORM 关联预取(Django 的
select_related/prefetch_related)是写代码的习惯,不是事后优化:凡是列表接口里要用到关联对象,查询的那一行就带上预取。给 AI 助手的编码规范里也要写明这条,否则它生成的列表接口同样会 N+1。
- 症状:导出功能本地好好的,生产环境一点导出就 500。
- 根因:两个都遇到过——生产容器里根本没装重型表格库(openpyxl 之类,镜像瘦身时被裁了),或者数据量一大、整个工作簿在内存里构建把进程撑爆。
- 铁律:管理后台的导出,CSV + UTF-8 BOM 走天下:不依赖任何重型库、流式输出内存友好,带 BOM 后 Excel 打开中文不乱码。真需要多 sheet、带格式的报表再单独立项,别让「顺手导个数据」的需求背上重依赖。
上面每条的三行式浓缩,贴给你的 AI 助手当红线清单正合适:
- 时区:定时任务错点 8 小时 →
USE_TZ=False与调度器 UTC 假设打架 → 时区策略写进文档,Beat 按本地时间写不换算。 - 迁移:makemigrations 冲突 / 生产报已存在 → 多环境各自生成迁移编号分叉 → 手写迁移按生产库最新编号建,迁移文件只追加。
- Session:重启 Redis 全员掉登录 → Session 在缓存库没持久化 → 开持久化,或接受并公告。
- 大数浮点:前端偶发崩 / 金额变字符串 → json-bigint 把长浮点转字符串 → 出参浮点必 round,钱用分或定点。
- 响应缓存:加字段前端看不到 → 接口缓存没清 → 加字段必清缓存键;响应平铺让键可枚举。
- 同名遮蔽:import 后行为诡异 → 同名模块相互遮蔽 → 视图文件名全仓唯一,新名先全库搜。
- 驱动并发:多线程偶发连接错乱 → 驱动线程安全配置没开 → 配置对齐,上线前并发压测一轮。
- 大 header:IM 内嵌页登录必失败 → header 超入口层 buffer 默认值 → buffer 调大,参数写进部署文档。
- 导出:生产导 Excel 就 500 → 容器缺重型库 / 内存爆 → CSV + UTF-8 BOM 走天下。
- N+1:列表页 3 秒 → 循环里逐行查关联 → select_related/prefetch 是习惯不是优化。
- code 混存:成功判断写两种 → 早期字符串/整数混用 → 第一天定死一种,老的批量清。
- 定时任务:双系统并存的教训 — 时区坑之外,调度系统本身的选型教训
- 部署链路与部署坑 — buffer 参数、热重启这类「入口层知识」的归宿
- 技术选型与取舍:为什么单体 Django 够用 — 这些坑背后的技术栈全貌
- 管理端前端坑:缓存 / 路由 / 组件 — json-bigint、响应平铺这些坑的前端半边
- 数据口径:最贵的一类坑 — 比技术坑更贵的一类坑