我记得几年前带一个项目,功能需求排得密密麻麻,当时大家最怕的是业务逻辑写错,结果上线之后真正捅娄子的,全是那些"看起来不核心"的地方:测试形同虚设、安全留了暗门、性能崩得莫名其妙、无障碍压根没人提。后来我带团队复盘,发现所有项目最后都死在同四类坑上——测试、安全、性能、无障碍。这四个词,功能验收时一个都看不见,上线后一个比一个致命。这篇博文就是把我这些年踩过的坑、以及身边同事反复栽进去的典型问题梳理了一遍,适合刚入行的工程师对照自查,也适合带团队的同学拿去当代码评审的检查清单。
1. 测试:绿灯全亮,不代表真的测过
我见过太多项目,CI 跑得特别勤快,覆盖率报告花花绿绿,结果一上线还是被用户骂"一改就坏"。问题通常不在"有没有测试",而在"测试到底在测什么"。很多人觉得写了测试就安全了,但测试这东西有个特性:写得不好,比不写更害人——它会给你一种虚假的安全感,让你在重构和交付时大胆往前冲,然后精准地撞上一个压根没被覆盖到的 Bug。
1.1 断言写得太弱:测试跑通,Bug 漏光
这是最普遍的一坑。我先说个反面例子。之前接手过一个支付模块,接口测试里写了这么一段:
def test_create_order(): response = client.post("/orders", json={"product_id": 1}) assert response.status_code == 200看起来测了,实际上等于没测。status_code == 200只能说明"服务器没有抛异常",但订单金额算错了、商品库存没扣、返回的 order_id 是空的——这些真正要命的问题,一个都验不出来。后来我把断言改成这样:
def test_create_order_success(): response = client.post("/orders", json={"product_id": 1, "quantity": 2}) body = response.json() assert response.status_code == 200 assert body["order_id"] is not None assert body["total_amount"] == 199.98 # 商品单价99.99,数量2 assert body["stock_remaining"] == 98 # 库存从100扣到98区别很明显:测试必须验证"业务结果",而不是验证"请求没报错"。光看状态码,是拿测试当摆设;把关键字段、计算结果、副作用都断言一遍,才算真正锁住行为。
还有一个我常提醒团队的点:断言不要只写"存在"和"不为空",要写"等于什么"。我见过有人断言assert "token" in response.json(),这种测试过不过全看实现心情,重构时把一个字段改名,测试依然绿,用户却全挂了。强弱断言,差的是对行为的锁定力度。
1.2 测试长在本地:换台机器就红
这个坑的经典症状是:本地跑得好好的,一上 CI 就红,或者同事拉下来跑直接崩。根因基本都是测试对环境有隐式依赖——有人读了当前目录下的文件,有人连了本地数据库,有人依赖系统时间是"今天"。
我之前排查过一个案例,一个测试在本地永远通过,在 CI 上随机失败。后来发现测试代码里写了这样一行:
with open("test_data.json", "r") as f: data = json.load(f)这个路径在本地恰好存在,在 CI 的干净环境里压根没有。还有更隐蔽的:测试里用了datetime.now()去比对某个"今天"的字段,结果在跨时区的 CI 机器上直接差了 8 小时。
解决思路是三层:
- 测试数据必须显式构造,不能指望外部文件、外部服务、真实网络。该用 mock 就用 mock,该用内存数据库就用内存数据库(SQLite / H2)。
- 测试与时间、随机数解耦,用固定时间点、固定种子,所有可能变化的输入都显式传入。
- 环境差异用容器化抹平,本地跑和 CI 跑都走同一个 Docker 镜像,我从用上容器之后,"在我机器上是好的"这种话在团队里基本绝迹了。
提示:一个测试如果必须"靠运气"才能稳定通过,那它本身就是一个待修的 Bug。测试不稳定带来的后果不只是 CI 变慢,而是团队会开始无视红灯,最后红灯亮了也没人管,等于测试体系整体失效。
1.3 覆盖率是结果,不是目标
很多团队喜欢定 KPI:"覆盖率必须达到 80%。"这句话的副作用是,代码里会长出大量"为了覆盖率而写"的测试。长这样:
def test_get_user_empty(): response = client.get("/users/") assert response.status_code == 200调用一遍,什么都不验证,覆盖率数字倒是上去了,但一点保护作用都没有。更气人的是,这类测试还会拖慢 CI、增加维护成本,改接口时还得跟着改一堆没营养的用例。
我的建议是:覆盖率只当参考线,真正要盯的是核心路径和异常路径有没有测到。支付流程的金额计算、权限校验的拒绝分支、外部服务超时和重试、数据边界(金额为 0、数量为负数、字符串超长),这些地方才是 Bug 高发区。我习惯在评审测试时问三个问题:
- 如果这段逻辑写错了,哪个测试会红?
- 如果接口没做权限校验,哪个测试会拦住它?
- 如果数据库查不到数据,哪个测试覆盖了这条分支?
能答上来,测试才是真有用;答不上来,覆盖率再高也是数字游戏。
2. 安全:漏洞常常出在最不起眼的默认配置里
我碰到过不少开发者,一聊安全就说"我们公司有专门的安全团队""这块有防火墙扛着""我们用了 HTTPS 应该没问题"。但现实是,绝大多数漏洞既不是被高级黑客攻破的,也不是加密算法不够强,而是最基础的东西没做到位——密钥躺在代码仓库里、接口不校验权限、日志里打印了明文密码。这些事一点都不"高级",但每一条都能让前面的努力全部白费。
2.1 敏感信息进代码库:秘密在提交记录里"裸奔"
这是我见过频率最高的问题。开发者图方便,把 API Key、数据库密码、第三方密钥直接写进配置文件,甚至写死在代码里,然后提交进 Git。你看着是"本地能用就行",实际上密钥已经跟着仓库同步到所有人的电脑上,只要仓库被推送过公共平台,或者某个开发者的电脑被入侵,所有密钥立刻暴露。
最典型的是这种写死在代码里的模式(我换个说法举例子):
MYSQL_PASSWORD = "root123456" STRIPE_SECRET_KEY = "sk_live_xxxxx"这不是行业黑话,这是每天在真实仓库里发生的事情。我处理过一个事故:公司的支付密钥被提交到公共代码托管平台,几分钟后有人在后台看到恶意订单。排查时发现,密钥在三个月前就被某位同事带进了第一次提交,之后每次同步都在泄漏。
正确做法三件事,缺一不可:
- 代码库里零密钥:所有敏感信息走环境变量或专用密钥管理服务(Vault、云厂商的 Key Management 之类的机制)。
- 轮换一遍密钥:如果怀疑历史上有泄漏,别只改当前配置,把真实的密钥全部作废、重新生成。我见过有人只改了配置没改真实密钥,等于白忙。
- 清理历史提交:光删当前文件没用,密钥还在 Git 历史里。要么用工具重写历史(
git filter-repo这类方式),要么直接把仓库历史封存、换新仓库,把轮换后的密钥牢牢锁在环境变量里。
提示:写代码的时候可以养成习惯——提交前扫一眼 diff,凡是
password、secret、token后面跟了真实字符串的,立刻停手。团队里也可以加一道提交前的密钥扫描,别等泄露了才后悔。
2.2 只在乎"能不能进",不在乎"进去了能干什么"
很多人对权限的理解就是"登录就能访问、不登录就返回 401"。但登录只是第一道门,真正的安全问题在门里面:用户 A 能不能看用户 B 的数据?普通用户能不能执行管理员操作?
举一个我实际排过的线上问题。一个订单查询接口,前端按order_id请求详情,后端大概是这么写的:
@app.route("/order/<int:order_id>") def get_order(order_id): order = db.query(Order).get(order_id) return jsonify(order.to_dict())表面看没问题——必须登录才能访问。但实际上,任何一个登录用户只要把order_id从 1 往上遍历,就能看到平台上所有订单,包括别人的收货地址和手机号。这就是典型的水平越权。还有一种垂直越权:普通用户直接调用管理员的接口地址,如果后端没校验角色,就拿到了本不该有的权限。
修复方式其实不复杂,但容易漏:
@app.route("/order/<int:order_id>") def get_order(order_id): order = db.query(Order).get(order_id) # 校验:订单必须属于当前登录用户,或当前用户是管理员 if order.user_id != current_user.id and not current_user.is_admin: abort(403) return jsonify(order.to_dict())关键在于:权限校验必须写在服务端,而且每个接口都要做,不能指望前端把按钮藏起来就是安全。我评审代码时一般会专门问一句:"这个接口,用户把自己的参数改成别人的,会发生什么?"很多开发者从来没想过这个问题,而这恰恰是安全测试最爱的切入点。
2.3 日志里的定时炸弹:明文密码和完整个人信息
先看一个"经典事故"。某个系统的登录接口打了日志,方便排查问题:
logger.info(f"User {username} login with password {password}")这种日志在开发环境可能没人在意,但上了生产环境,日志会进入集中收集系统,会被多个团队查看,会被备份保留好几年。密码一旦进了日志,等于明文存储。我见过更离谱的:有人把用户身份证号、银行卡号、完整的家庭住址全部打进了日志,然后日志文件又被误传到了某个公共存储桶。后果我就不说了,反正当时整个团队都过不好年。
安全日志的正确写法:脱敏、脱敏、再脱敏。密码永远不该被记录(正确做法是记"哪个用户登录失败/成功"这个事件本身),身份证号手机号只保留后四位,Token 只记录前几位便于关联。统一用脱敏函数包一层:
def mask(value: str, visible: int = 4) -> str: if not value: return "" return "*" * (len(value) - visible) + value[-visible:] logger.info(f"User {username} login failed") logger.info(f"User {username} submitted ID card {mask(id_card)}")另外提醒一句:日志不是越详细越好。只记录排查问题真正需要的信息,其他的别打。这个原则写进团队规范,比事后清理日志靠谱得多。
2.4 依赖库不更新:最省事的漏洞入口
如果说前面几个坑是"主动犯错",那这个坑就是"被动挨打"。很多项目的依赖一旦装上就再没更新过。你看着程序运行得好好的,其实底下用的某个开源库早被人发现了好几个已知漏洞,而这些漏洞的修复补丁也早就发布了——只是你没有升级。
这类问题的标准做法是引入依赖扫描工具,在 CI 里加一道"依赖漏洞检查"的关卡。目前主流的思路是 SCA(软件成分分析)类的工具,比如 GitHub Dependabot、GitLab 依赖扫描,或者 OWASP Dependency-Check 这类开源方式,它们会拉取漏洞库(如 CVE 数据),对比项目依赖版本,发现已知漏洞就报警。
这事最容易被忽略的原因是"升级依赖会引入破坏性变更"。很多团队怕升级后跑不起来,就一直停在旧版本,结果漏洞越积越多。我的建议是:不要等出事了才升,要养成定期小步升级的习惯。每个迭代顺手升一两个依赖,遇到破坏性变更趁早处理,远比某天被迫从老版本一次性跳到新版本来得轻松。我见过真实案例,某个团队因为三个月没升级依赖,被扫描工具通报了十多个已知漏洞,紧急加班升级,跑坏了一堆接口,风险反而比平时高得多。
3. 性能:怕的不是慢,是"不知道为什么慢"
性能问题最让人头疼的地方在于,它不像功能 Bug 那样"错了就是错了",而是"好像还行,但总觉得不对劲"。更麻烦的是,性能问题往往不是单一原因造成的,一张页面慢,可能是接口慢、可能是数据库慢、可能是前端渲染慢,也可能是网络慢。所以,性能优化的第一原则是:先度量,再动手。凭感觉优化是浪费时间,用数据定位才是效率。
3.1 N+1 查询:一条列表页拖垮数据库
这是所有性能坑里最普遍的一个。名字听着专业,实际上场景非常简单:你在列表页展示 100 条订单,每条订单要查一次用户信息。代码大概是这样:
orders = db.query(Order).limit(100).all() for order in orders: user = db.query(User).get(order.user_id) # 循环里查了100次数据库! order.user_name = user.name程序看到的是 1 次列表查询 + 100 次明细查询,数据库看到的却是 101 次网络往返。在本地环境因为延迟低,你可能毫无感觉;上了生产环境,页面超过几百毫秒甚至直接超时,就是这种"循环查库"的典型症状。
正确做法是预加载。如果用 ORM,直接让框架帮你把关联数据一次性取出:
orders = db.query(Order).options(joinedload(Order.user)).limit(100).all()这样生成的 SQL 会带LEFT JOIN,一次查询把所有数据拿回来,101 次变成 1 次。如果不用 ORM,也可以自己写 SQL 用IN批量查出所有用户,再在内存里做关联。
我判断一个接口有没有 N+1 问题的土办法特别简单:看日志里同一时间段的 SQL 数量。如果页面一刷新,日志里蹦出几十上百条查询,基本可以确诊。还有一种情况是 ORM 的懒加载在序列化时被触发——你可能只查了一条订单,但转 JSON 时关联属性被逐一访问,把整张表都查了一遍。这个坑尤其隐蔽,我在项目里踩过不止一次。
提示:MySQL 调优的热门话题很多,但大多数列表页慢的问题,根因不是 MySQL 配置不对,而是代码发起了太多低效查询。先把 N+1 解决掉,再谈索引和配置,顺序不能反。
3.2 内存泄漏:程序越来越慢,最后卡死
内存泄漏是性能问题里最"慢性病"的一种。它不是立刻报错,而是程序跑得越久,内存占用越高,最终在某次高峰流量里直接卡死或者崩溃。服务端最常见的内存泄漏来源有三类:
- 事件监听器/回调没有移除:每次操作都注册一个监听,但从没注销,对象就越积越多。
- 全局缓存无限增长:往静态的 Map 里塞数据,只进不出,时间一长把内存吃光。
- 资源没有关闭:数据库连接、文件流、网络连接用了不关,尤其在高并发时,连接泄漏的速度会非常可观。
举个典型的例子,把数据塞进全局表,但从不考虑清理:
public class CacheHolder { public static final Map<String, Object> CACHE = new HashMap<>(); public void put(String key, Object value) { CACHE.put(key, value); // 只进不出 } }如果这个 Map 的 key 是用户 ID 或者 UUID,那每个新请求都会往里面塞一条,内存只涨不降,用不了多久就 OOM。要修复,要么用带过期策略的缓存实现(如 Caffeine、Guava Cache),要么给 Map 设定上限和淘汰策略。
排查内存泄漏,我通常的流程是:
- 先把堆转储抓下来(
jmap或对应的语言工具),看哪些对象占了最多内存。 - 用分析工具(MAT、VisualVM 这类)看支配树,找到"谁引用着这些对象,导致它无法被回收"。
- 对照代码,找到那个"只进不出"的集合或没被解除的监听器。
核心思路一句话:内存泄漏排查不是猜出来的,是看堆栈看出来的。你盯着代码看三天也未必能找到那个隐藏的引用,但把堆转储分析一遍,答案通常几十分钟就出来了。
3.3 性能优化的正确姿势:先度量,再动手
我踩过的最大的坑,就是"凭感觉优化"。曾经有个接口慢,我觉得是数据库慢,加了一堆索引,没用;后来发现瓶颈是某个第三方服务在同步调用,超时重试了三轮。还有一次,我觉得是 JSON 序列化慢,结果 profile 一看,90% 的时间花在字符串拼接上。这些教训让我养成了一个习惯:一切优化动作之前,先拿到数据。
度量工具分两层:
- 应用层:语言自带的 profiler(比如 Python 的 cProfile、Java 的 JFR),能定位到具体哪个函数在耗时。
- 系统层:APM 工具(比如 SkyWalking、Pinpoint 这类)可以监控整个请求链路,看时间花在哪个服务、哪个数据库调用上。
我以为值得记录的一个真实优化案例是这样的:一个报表接口花了 10 秒,按照经验第一反应是数据库查询慢。先跑了一次 SQL 分析,发现查询本身只要 200 毫秒。再往下看,发现接口在循环里调用了另一个统计接口,一共 50 次,每次都 200 毫秒,加起来正好 10 秒。修复方式是把这个循环调用合并成一次批量查询,接口直接降到 300 毫秒。整个过程没有做任何"高深"的优化,全靠 profile 数据指路。
| 常见性能症状 | 首选排查方向 |
|---|---|
| 接口整体慢 | APM 看链路耗时分布,确认是服务端还是依赖外部接口 |
| 数据库 CPU 飙升 | 慢查询日志 +EXPLAIN看索引使用情况 |
| 内存持续上涨 | 堆转储分析,找无法回收的引用 |
| 并发一高就超时 | 看线程池/连接池配置和等待队列长度 |
| 前端白屏/卡顿 | 浏览器 DevTools 看网络请求和渲染性能 |
4. 无障碍:最后一公里,最容易被砍掉的需求
如果说测试、安全、性能还能在评审会上被拿出来说一说,那"无障碍"基本属于被遗忘的角落。很多团队直到被客户投诉、被合规部门点名,才想起要补。但我做了一阵子无障碍改造后发现:无障碍不是"给特殊人群做的额外功能",而是"对所有用户都更友好的基础质量"。它是"最后一公里"的事情,却被压在优先级的最下面,这本身就是最大的坑。
4.1 颜色对比度:看不见的,不只是色盲用户
先说一个看起来很小的坑:对比度不足。设计稿里的浅灰色文字配白色背景,在设计师的高分屏上清晰好看,但到了阳光下的手机屏幕上,或者用户电脑屏幕老化之后,就变成一片"看不清"。
无障碍标准(WCAG)里有个硬指标:正常文字的对比度至少要达到4.5:1,大号文字可以放宽到 3:1。很多人以为这只是一个数字,实际上是很多真实用户在使用你产品时的"可读性底线"。我可以用一个直观例子说明:#767676的灰色文字在白色背景上对比度大约是 4.4:1,勉强接近及格线;但#999999对比度只有 2.8:1,视觉正常的人看着可能觉得还行,但视力正常但环境刺眼的人,以及轻微视力障碍的人,看起来就是费劲。
实操上不用人眼估色值,直接用现成工具测。浏览器 DevTools 自带的对比度检查、设计工具里的对比度插件,都能直接给出数值。团队可以在设计评审阶段就加一条检查项:任何文本颜色都需要通过对比度检查再进入开发。
我特别想强调:对比度这事,不是设计师一个人的责任,开发在实现时也容易出错。比如我用了一个opacity: 0.5把文字变浅,虽然设计稿写的是标准色值,但半透明降到背景上之后,实际对比度可能就不合格了。代码里如果拿不准,就把颜色值固定,别用透明度去"调淡"文字。
4.2 键盘导航:鼠标做不到的操作,等于给一部分用户关了门
很多开发者没意识到,有一批用户是不用鼠标的——有人由于肢体障碍只能用键盘操作,有人用的是屏幕阅读器,靠 Tab 键和快捷键在页面里跳转。如果你的页面用鼠标才能操作,这些用户就彻底"卡住"了。
键盘导航最常见的三个问题:
- Tab 顺序混乱:页面结构里不该可聚焦的元素(比如一个 div 的 click 事件)抢了 Tab 焦点,或者焦点顺序和视觉顺序不一致。
- 焦点不可见:键盘用户 Tab 到一个按钮上,但没有任何视觉提示(focus outline 被
outline: none掉了),用户根本不知道光标在哪。 - 没有跳转链接:长页面里键盘用户要从顶部一路 Tab 几百下才能到正文,没有"跳过导航"的快捷方式。
修复方式其实不难。第一,别随便干outline: none,或者去掉之后一定要补一个自定义的焦点样式:加大边框、加深颜色、加阴影,让焦点清晰可见。第二,用正确的语义标签,按钮就用button,链接就用a,不要用div模拟按钮再去挂事件——语义正确,键盘操作和屏幕阅读器的很多问题会自动解决。第三,页面顶部加一个"跳到正文"的隐藏链接(skip link),键盘用户按一下就能直达主要内容。
4.3 屏幕阅读器:标签写对了,体验才成立
屏幕阅读器用户通过一个叫"读屏软件"的工具来理解页面,它会朗读元素的内容和角色。问题在于,开发者经常只看到视觉效果,完全忽略读屏软件听到的是什么。
典型翻车现场:
<!-- 图片没有 alt,读屏软件只会念文件名或干脆跳过 --> <img src="chart.png" /> <!-- 只有图标的按钮没有名称 --> <button><span class="icon-trash"></span></button> <!-- 输入框没有对应的 label --> <input type="text" placeholder="请输入手机号" />这些问题在视觉上一点毛病都没有,但屏幕阅读器听到的是:"图片""按钮""编辑框"。用户完全不知道这个按钮是干嘛的、输入框要填什么。修复方式也简单:
<img src="chart.png" alt="2025年第一季度销售额趋势图" /> <button aria-label="删除这条订单"><span class="icon-trash"></span></button> <label for="phone">手机号</label> <input type="text" id="phone" placeholder="请输入手机号" />核心原则叫"感知等价":让读屏软件听到的信息,和视觉用户看到的信息一样完整。表格要有标题,表单要有标签,对话框要告诉读屏软件"弹窗打开了",关闭时也要通知"弹窗已关闭"。
顺带提一句移动端。移动端的无障碍适配有自己的复杂性,Android 平台从很早就开始完善无障碍相关的 API,比如 TalkBack 读屏需要的内容描述、无障碍焦点、触摸探索的适配,这些都不是"加个属性就完事"的,需要开发在真机上打开 TalkBack 实际体验一遍。包括涉及到无障碍服务权限的申请场景,尤其要注意:无障碍权限的用途必须说清楚,不然用户出于安全考虑不会授权。这其实是安全和无障碍的一次碰撞——一个好的无障碍功能,既要好用,也要让用户感觉安全可信。
4.4 把无障碍放进开发流程,而不是最后补
无障碍做不好,最大的原因是它总是在最后才被想起来。设计稿做完了、前端写完了、测试也过了,然后有人说"客户要求无障碍达标",这时候再回头改,代价是最大的。
我把无障碍真正融入项目的经验是三步:
- 设计评审加检查项:对比度、字号、可点击区域大小(建议最小 44x44 像素),在出图阶段就解决掉。
- 开发阶段用工具扫:Chrome 的 Lighthouse 直接跑一轮无障碍审计,大部分明显的低级问题(缺 alt、缺 label、对比度不足)都能自动扫出来。
- 真机测试让读屏软件读一遍:电脑上用 VoiceOver / NVDA,手机上用 TalkBack / VoiceOver,把主要流程(登录、下单、支付)完整走一遍。这个体验比任何工具扫描都真实。
这三个步骤加起来每个迭代可能只多花半天时间,但能避免产品上线后发现"完全不能操作"的尴尬。说实话,无障碍改造不是技术难题,而是"你是否有意识"的问题——技术和方案都是公开的、现成的,缺的只是把它当作一等公民来对待的习惯。
我这些年最大的一个感受就是:这四类坑归根结底都是同一种"心态坑"——从"把功能做完"跨越到"把质量交付"之间的那段差距。测试、安全、性能、无障碍,没有一样是功能需求里的必要条件,但每一样都是"产品能不能真正被放心使用"的充分条件。写代码不是画句号,画完功能只是画了一半,另一半在你看不见的地方。