我记得几年前带一个项目功能需求排得密密麻麻当时大家最怕的是业务逻辑写错结果上线之后真正捅娄子的全是那些看起来不核心的地方测试形同虚设、安全留了暗门、性能崩得莫名其妙、无障碍压根没人提。后来我带团队复盘发现所有项目最后都死在同四类坑上——测试、安全、性能、无障碍。这四个词功能验收时一个都看不见上线后一个比一个致命。这篇博文就是把我这些年踩过的坑、以及身边同事反复栽进去的典型问题梳理了一遍适合刚入行的工程师对照自查也适合带团队的同学拿去当代码评审的检查清单。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(fUser {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(fUser {username} login failed) logger.info(fUser {username} submitted ID card {mask(id_card)})另外提醒一句日志不是越详细越好。只记录排查问题真正需要的信息其他的别打。这个原则写进团队规范比事后清理日志靠谱得多。2.4 依赖库不更新最省事的漏洞入口如果说前面几个坑是主动犯错那这个坑就是被动挨打。很多项目的依赖一旦装上就再没更新过。你看着程序运行得好好的其实底下用的某个开源库早被人发现了好几个已知漏洞而这些漏洞的修复补丁也早就发布了——只是你没有升级。这类问题的标准做法是引入依赖扫描工具在 CI 里加一道依赖漏洞检查的关卡。目前主流的思路是 SCA软件成分分析类的工具比如 GitHub Dependabot、GitLab 依赖扫描或者 OWASP Dependency-Check 这类开源方式它们会拉取漏洞库如 CVE 数据对比项目依赖版本发现已知漏洞就报警。这事最容易被忽略的原因是升级依赖会引入破坏性变更。很多团队怕升级后跑不起来就一直停在旧版本结果漏洞越积越多。我的建议是不要等出事了才升要养成定期小步升级的习惯。每个迭代顺手升一两个依赖遇到破坏性变更趁早处理远比某天被迫从老版本一次性跳到新版本来得轻松。我见过真实案例某个团队因为三个月没升级依赖被扫描工具通报了十多个已知漏洞紧急加班升级跑坏了一堆接口风险反而比平时高得多。3. 性能怕的不是慢是不知道为什么慢性能问题最让人头疼的地方在于它不像功能 Bug 那样错了就是错了而是好像还行但总觉得不对劲。更麻烦的是性能问题往往不是单一原因造成的一张页面慢可能是接口慢、可能是数据库慢、可能是前端渲染慢也可能是网络慢。所以性能优化的第一原则是先度量再动手。凭感觉优化是浪费时间用数据定位才是效率。3.1 N1 查询一条列表页拖垮数据库这是所有性能坑里最普遍的一个。名字听着专业实际上场景非常简单你在列表页展示 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批量查出所有用户再在内存里做关联。我判断一个接口有没有 N1 问题的土办法特别简单看日志里同一时间段的 SQL 数量。如果页面一刷新日志里蹦出几十上百条查询基本可以确诊。还有一种情况是 ORM 的懒加载在序列化时被触发——你可能只查了一条订单但转 JSON 时关联属性被逐一访问把整张表都查了一遍。这个坑尤其隐蔽我在项目里踩过不止一次。提示MySQL 调优的热门话题很多但大多数列表页慢的问题根因不是 MySQL 配置不对而是代码发起了太多低效查询。先把 N1 解决掉再谈索引和配置顺序不能反。3.2 内存泄漏程序越来越慢最后卡死内存泄漏是性能问题里最慢性病的一种。它不是立刻报错而是程序跑得越久内存占用越高最终在某次高峰流量里直接卡死或者崩溃。服务端最常见的内存泄漏来源有三类事件监听器/回调没有移除每次操作都注册一个监听但从没注销对象就越积越多。全局缓存无限增长往静态的 Map 里塞数据只进不出时间一长把内存吃光。资源没有关闭数据库连接、文件流、网络连接用了不关尤其在高并发时连接泄漏的速度会非常可观。举个典型的例子把数据塞进全局表但从不考虑清理public class CacheHolder { public static final MapString, 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 srcchart.png / !-- 只有图标的按钮没有名称 -- buttonspan classicon-trash/span/button !-- 输入框没有对应的 label -- input typetext placeholder请输入手机号 /这些问题在视觉上一点毛病都没有但屏幕阅读器听到的是图片按钮编辑框。用户完全不知道这个按钮是干嘛的、输入框要填什么。修复方式也简单img srcchart.png alt2025年第一季度销售额趋势图 / button aria-label删除这条订单span classicon-trash/span/button label forphone手机号/label input typetext idphone placeholder请输入手机号 /核心原则叫感知等价让读屏软件听到的信息和视觉用户看到的信息一样完整。表格要有标题表单要有标签对话框要告诉读屏软件弹窗打开了关闭时也要通知弹窗已关闭。顺带提一句移动端。移动端的无障碍适配有自己的复杂性Android 平台从很早就开始完善无障碍相关的 API比如 TalkBack 读屏需要的内容描述、无障碍焦点、触摸探索的适配这些都不是加个属性就完事的需要开发在真机上打开 TalkBack 实际体验一遍。包括涉及到无障碍服务权限的申请场景尤其要注意无障碍权限的用途必须说清楚不然用户出于安全考虑不会授权。这其实是安全和无障碍的一次碰撞——一个好的无障碍功能既要好用也要让用户感觉安全可信。4.4 把无障碍放进开发流程而不是最后补无障碍做不好最大的原因是它总是在最后才被想起来。设计稿做完了、前端写完了、测试也过了然后有人说客户要求无障碍达标这时候再回头改代价是最大的。我把无障碍真正融入项目的经验是三步设计评审加检查项对比度、字号、可点击区域大小建议最小 44x44 像素在出图阶段就解决掉。开发阶段用工具扫Chrome 的 Lighthouse 直接跑一轮无障碍审计大部分明显的低级问题缺 alt、缺 label、对比度不足都能自动扫出来。真机测试让读屏软件读一遍电脑上用 VoiceOver / NVDA手机上用 TalkBack / VoiceOver把主要流程登录、下单、支付完整走一遍。这个体验比任何工具扫描都真实。这三个步骤加起来每个迭代可能只多花半天时间但能避免产品上线后发现完全不能操作的尴尬。说实话无障碍改造不是技术难题而是你是否有意识的问题——技术和方案都是公开的、现成的缺的只是把它当作一等公民来对待的习惯。我这些年最大的一个感受就是这四类坑归根结底都是同一种心态坑——从把功能做完跨越到把质量交付之间的那段差距。测试、安全、性能、无障碍没有一样是功能需求里的必要条件但每一样都是产品能不能真正被放心使用的充分条件。写代码不是画句号画完功能只是画了一半另一半在你看不见的地方。
阅读完成 · 觉得有帮助?