之前在做后台系统功能清单梳理时,我又看到了那个编号为 149 的需求单:一个上线了三个多月、调用量几乎为零的“操作日志导出 PDF”功能。当初产品评审时它被描述成“管理员需要定期导出日志留档”,开发也投入了 3 人天,结果上线后真正用到的次数一只手数得过来。这种“没用的功能”在很多项目里都不少见,但很少会有人正视它:既没有人主动提下线,也不敢轻易删,最后它就像一块没有症状的病灶,安静地躺在代码库里,持续消耗着维护成本。
这篇文章就围绕“没用的功能”这个话题展开,从它到底是怎么产生的、如何用数据判断一个功能是否真的没用、到决定下线后如何安全地从代码中移除,结合 Spring Boot 示例工程做一次完整实战。不管你是后端开发、前端开发,还是经常参与需求评审的产品和技术负责人,这篇文章的思路都能直接用。
1. 背景与核心概念
1.1 从编号 149 说起:一个功能的诞生到沉寂
为了便于讨论,先假设这样一个典型场景。
某个后台管理系统里,运营团队在某一轮迭代中提出需求:希望在操作日志模块增加“导出 PDF”功能,理由是“领导需要定期导出操作记录用于归档”。这个需求经过评审后进入开发排期,分配给后端工程师。后端用了一周时间实现导出逻辑、处理中文 PDF 字体、设计模板,然后测试通过、上线发布。
三个月后,团队做功能使用率盘点,发现这个接口的累计调用次数只有 23 次。进一步分析发现,其中 20 次还是测试环境联调时打的,生产环境实际只有 3 次调用。
这就是一个非常典型的“没用的功能”:它有明确的需求来源,有完整的开发过程,甚至都经过了测试,但上线后并没有真实用户使用。它占据了代码量、测试用例、接口文档、权限配置等多个位置,却没有产生对应的业务价值。
1.2 没用的功能不代表“没有价值”
在讨论这个话题前,要先区分一个容易混淆的概念:一个功能“没用”,并不是说它一定没有价值。
从不同视角看,“没用”有三种含义:
- 用户视角的没用:用户根本不点、不用,在页面上完全感知不到它的存在。比如某个后台的一个复杂筛选组件,90% 的用户只用默认条件。
- 业务视角的没用:功能本身被使用了,但没有带来业务结果。比如让用户填写一份超长的调查问卷,填的人不少,但最终没有形成任何运营决策。
- 技术视角的没用:功能被调用了,但完全可以被另一个成熟方案替代。比如自己实现了一套文件预览工具,而项目里其实已经引入了 OSS 自带的预览能力。
这三类“没用”需要不同的处理方式。用户不用,可能需要优化入口或干脆下线;业务不产生结果,可能需要重新设计指标;技术重复,可能需要做能力收敛和替换。
1.3 为什么“没用功能”是开发者的隐形负担
一个没用的功能,伤害往往不会立刻暴露。它更像是慢性消耗。
第一是维护成本。只要功能还活着,它就要参与编译、测试、回归。某个老功能使用了一个已经停止维护的第三方库,每次升级依赖都要为它单独做兼容,这就是最典型的隐性成本。
第二是认知负担。新接手项目的开发者在阅读代码时,会以为每个功能都有其存在的理由。如果一个导出 PDF 的功能其实根本没人在用,新人可能会在它基础上继续开发“导出 Excel”,导致代码越来越臃肿。
第三是故障面。只要代码还在,它就可能出问题。一个没人用的功能报错了,凌晨触发报警,值班人员爬起来排查,最后发现这个接口根本没人调用,这种情况在真实项目中非常常见。
第四是测试成本。自动化用例里如果覆盖了这个功能,每次 CI 都要为它支付时间成本;如果不覆盖,它又会成为一段不敢动的“神秘代码”。
所以,识别和清理没用的功能,不是简单的“删代码”,而是对项目和团队效率的一次投资。
2. 无用功能是怎么一步步出现的
想要清理没用的功能,先得知道它们是怎么来的。从我的经验看,无用功能通常是在四个环节里被“制造”出来的。
2.1 需求评审阶段:伪需求与需求蔓延
很多没用的功能,在需求阶段就有征兆。
最常见的是“伪需求”:提出方描述了一个场景,但这个场景本身是否真实存在没有验证。比如“管理员需要导出 PDF”,看起来没问题,但实际工作中管理员可能更习惯直接在系统里看,或者根本不需要存档。
需求蔓延则更隐蔽。一个 CRM 项目原本只需要三个状态:新建、跟进、关闭。评审时有人提出“加一个暂缓状态吧,万一以后用得到”,于是状态变成了四个,后来变成八个。每个状态都要处理流转规则、权限、统计报表,最后其中三个状态上线后一次都没被使用过。
这里要意识到一个问题:需求评审时如果只讨论“能不能做”,不讨论“做了之后怎么验证有没有用”,那大概率会做出没人用的功能。
2.2 产品设计阶段:照搬竞品与过度设计
还有一种常见的来源是“对标竞品”。
看到竞品做了某个功能,自己也跟着做,这是很多业务团队的惯性思维。但竞品做这个功能,背后可能有完全不同的用户群体、使用场景和运营策略。照搬下来的功能,往往和自己的用户习惯脱节。
过度设计也容易制造没用功能。典型表现有:为根本不存在的用户角色设计权限粒度;为一个只有几十条数据的表设计分页、筛选、排序、导出全套能力;在用户量还是个位数的时候就开始做消息队列、分布式事务和复杂的缓存策略。
这些设计在纸面上很完整,但和真实的业务发展阶段不匹配。
2.3 技术实现阶段:预埋能力与过度封装
技术侧同样会制造“没用功能”。
一种是“预埋能力”。开发者在实现某个需求时,出于“以后可能用得上”的考虑,顺手把参数做得非常通用,支持多种类型、多个来源、多个回调地址。但后续业务根本没有这个需求,这些参数就一直使用默认值。表面上这是“扩展性好”,实际上这些参数会污染接口文档、增加调用方理解成本,还可能被错误使用。
另一种是重复造轮子。团队内已经存在一个统一的文件处理服务,但某个项目为了“减少耦合”,自己又写了一套上传下载逻辑。这种重复能力短期看不出问题,但一旦基础组件升级,两套逻辑都要改。
2.4 组织协作层面:缺少数据反馈闭环
最后一个原因是团队没有建立“功能使用反馈”的机制。
很多功能上线后,团队关心的是“有没有 Bug”“响应时间是多少”,却很少关心“用户到底有没有在用”。如果产品经理不去看埋点数据,开发不看接口调用日志,那么这个功能无论有没有人用,都不会有人发现。
时间一长,产品经理换人了,开发也换了,新接手的人面对一个老功能,不知道该不该删,于是继续保留。无用功能就这样沉淀下来了。
3. 如何识别一个功能“没用”
识别没用功能不能靠感觉,要靠证据。证据通常来自三类:数据、代码、用户反馈。
3.1 用数据说话:核心指标怎么选
判断一个功能是否有用,最直接的就是看“使用频率”。不同功能需要定义不同指标。
- 对于后台管理类功能,可以看接口调用量、活跃用户数、调用成功率。
- 对于 C 端页面功能,可以看 PV/UV、点击率、停留时长、下一步转化率。
- 对于工具类功能(如导出、导入),可以看执行次数、导出条数、用户分布。
- 对于报表类功能,可以看访问人数、使用时长、分享/下载次数。
关键不是指标多,而是要在功能上线前就把“有用”定义清楚。比如导出 PDF 功能,如果上线三个月、月活管理员中只有不到 1% 使用,那就基本可以判定为边缘功能。
3.2 埋点是基础:接口访问统计的常见方式
判断功能是否有人用,需要数据。数据从哪来?常见有三种方式。
第一种是前端埋点。在页面按钮点击、路由切换时上报事件,可以精准知道用户点了哪里。适合判断页面级功能的使用情况。
第二种是后端访问日志。通过中间件统一打印每个接口的访问情况,或者由网关层统一统计。适合判断接口级功能是否被调用。
第三种是数据库流水。如果某个功能每次操作都会写一条记录(比如操作日志、调用记录),可以直接查表统计。这种方式最准确,但要求业务代码本身具备记录能力。
实际项目中通常会把前后端数据结合使用。前端埋点能反映“用户点了”,后端日志能反映“接口真的被调了”,两者对比还能发现前端按钮暴露了但接口调用失败之类的问题。
3.3 代码层面的识别信号
除了数据,代码本身也会暴露问题。
- 一个功能没有对应的测试用例,说明开发时就不受重视。
- 一个功能的日志级别长期是 debug,说明很少有人关注它的运行状态。
- 一个功能在最近几次迭代中频繁被改动,但改动原因都是“兼容性调整”而不是“新增业务需求”。
- 一个功能对外提供的接口,调用量日志里常年为零。
- 一个功能的实现代码里大量使用
if (xxx == null) return;这类防御式判断,说明它对异常场景很敏感,但真实用户可能根本没触发过。
另外,还可以借助静态分析工具(比如 Java 项目里的 ArchUnit 或 IDE 自带依赖分析)找出那些“没有被其他模块引用”的类和方法。虽然不引用不代表没用,但配合数据看,会更有说服力。
3.4 用户反馈与客服工单
有些功能虽然没人主动用,但在某些时刻会被“想起来”。用户反馈渠道就是捕捉这些信息的地方。
如果客服系统里经常出现“这个功能在哪里”“点了没反应”“导出后打不开”之类的工单,说明用户是有使用意愿的,只是功能体验不好,这种情况应该优化而不是下线。
反过来,如果用户根本不知道有这个功能,或者在调研中说“没听说过”,那就说明功能的存在感和使用场景都很弱,进一步验证了“没用”的判断。
4. 功能评估矩阵:决定“下线”还是“保留”
识别出一个功能疑似没用之后,不要急着删除。我们需要一套评估机制来判断功能到底应该下线、保留,还是优化。
4.1 评估维度
我在项目里常用下面六个维度。
- 使用频率:核心指标。可以看日活、月活、调用次数。区分高频、低频、极低频。
- 业务价值:这个功能是核心业务链路中的一环,还是锦上添花?哪怕用的人少,如果是合规必需,就绝不能下。
- 维护成本:代码复杂度、依赖数量、是否引入第三方服务、是否阻塞发版。
- 替代方案:是否存在替代功能或替代渠道。比如导出 PDF 没人用,但导出 Excel 使用率很高,那说明用户只是不喜欢 PDF,而不是不需要导出。
- 依赖关系:该功能被哪些模块依赖,反向依赖哪些模块。链路下游有强依赖时不能简单下线。
- 合规与安全:某些功能即使没人用,也必须存在。比如审计日志、数据保留策略、风控校验。
4.2 打分表模板
可以把维度做成一张打分表,每个维度 1 到 5 分。
| 维度 | 1 分 | 3 分 | 5 分 |
|---|---|---|---|
| 使用频率 | 近 30 天几乎无调用 | 偶有低频调用 | 高频使用 |
| 业务价值 | 非核心链路,可用可不 | 辅助业务但非必需 | 核心链路必备 |
| 维护成本 | 复杂度高,依赖多 | 中等复杂度 | 简单稳定 |
| 替代方案 | 有现成替代方案 | 部分可替代 | 无替代 |
| 依赖关系 | 无依赖或被依赖方少 | 有少量依赖 | 强依赖,不可摘除 |
| 合规与安全 | 无合规要求 | 有一定关联 | 审计/合规强要求 |
打分后综合判断:如果“使用频率”低,且“替代方案”充足,同时“合规与安全”不是强要求,那这个功能就具备下线条件。如果“合规与安全”高,哪怕没人用也需要保留,不能乱删。
4.3 三种决策:下线、保留、优化
评估结果无非三种。
- 下线:确认没价值、没依赖、没合规要求,可以进入下线流程。
- 保留:使用频率低但它是核心链路或合规要求的一部分,保留但标注为“低频高价值功能”。
- 优化:功能本身有价值,但入口太深、体验太差导致没人用。这种情况需要做导航调整、交互优化或入口引导,而不是下线。
回到编号 149 那个导出 PDF 功能,按这个矩阵打分:使用频率 1 分,业务价值 2 分,维护成本 3 分,替代方案 4 分,依赖关系 3 分,合规与安全 2 分。综合下来,属于可以下线的类型。
5. 实战案例:用 Spring Boot 实现一个“无用功能扫描器”
为了把上面的方法论落地,我们用一个 Spring Boot 示例工程来演示两个关键能力:
- 统计每个接口的调用次数,输出低频接口列表。
- 通过配置开关,将某个疑似无用的功能快速下线,而不影响其他模块。
这里的示例以常见环境为例,版本需要根据你的项目实际情况调整,重点演示配置思路。
5.1 环境与项目结构
- 开发工具:IntelliJ IDEA
- 构建工具:Maven
- 框架:Spring Boot 2.x / 3.x
- JDK:8 或 11 及以上
- 数据库:不需要,示例直接用内存统计
项目结构如下:
src/main/java/com/example/uselessfeature ├── UselessFeatureApplication.java ├── config │ └── WebConfig.java ├── interceptor │ └── ApiUsageInterceptor.java ├── controller │ ├── OperationLogController.java │ └── FeatureStatusController.java └── task └── ApiUsageReportTask.java假设项目里已经有一个导出 PDF 的接口,对应功能编号 149。
5.2 实现接口调用统计
第一步,创建一个拦截器,在每次请求进入 Controller 前对接口调用次数进行累加。为了简单,这里使用内存 Map 保存数据。
// 文件路径:src/main/java/com/example/uselessfeature/interceptor/ApiUsageInterceptor.java package com.example.uselessfeature.interceptor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; public class ApiUsageInterceptor implements HandlerInterceptor { private final Map<String, AtomicLong> usageMap = new ConcurrentHashMap<>(); @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (handler instanceof HandlerMethod handlerMethod) { String key = handlerMethod.getBeanType().getSimpleName() + "#" + handlerMethod.getMethod().getName(); usageMap.computeIfAbsent(key, k -> new AtomicLong()).incrementAndGet(); } return true; } public Map<String, Long> getUsageSnapshot() { Map<String, Long> snapshot = new ConcurrentHashMap<>(); usageMap.forEach((key, value) -> snapshot.put(key, value.get())); return snapshot; } public void reset() { usageMap.clear(); } }说明:
- 这里使用
HandlerMethod判断请求是否映射到了具体的方法,排在非 Controller 请求。 - key 由
类名#方法名组成,避免 URL 中携带路径参数导致统计失准。 - 使用
ConcurrentHashMap和AtomicLong,保证并发下数据正确。
第二步,通过配置类注册这个拦截器。
// 文件路径:src/main/java/com/example/uselessfeature/config/WebConfig.java package com.example.uselessfeature.config; import com.example.uselessfeature.interceptor.ApiUsageInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebConfig implements WebMvcConfigurer { private final ApiUsageInterceptor apiUsageInterceptor; public WebConfig(ApiUsageInterceptor apiUsageInterceptor) { this.apiUsageInterceptor = apiUsageInterceptor; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(apiUsageInterceptor) .addPathPatterns("/**"); } }注意这里需要把ApiUsageInterceptor注册为 Spring Bean,否则无法注入。可以在类上加@Component注解:
// 文件路径:src/main/java/com/example/uselessfeature/interceptor/ApiUsageInterceptor.java @Component public class ApiUsageInterceptor implements HandlerInterceptor { // 其余代码同上 }第三步,创建一个定时任务,每天输出一次接口调用统计。
// 文件路径:src/main/java/com/example/uselessfeature/task/ApiUsageReportTask.java package com.example.uselessfeature.task; import com.example.uselessfeature.interceptor.ApiUsageInterceptor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.util.Map; @Component public class ApiUsageReportTask { private static final Logger log = LoggerFactory.getLogger(ApiUsageReportTask.class); private final ApiUsageInterceptor apiUsageInterceptor; public ApiUsageReportTask(ApiUsageInterceptor apiUsageInterceptor) { this.apiUsageInterceptor = apiUsageInterceptor; } @Scheduled(cron = "0 0 2 * * ?") public void report() { Map<String, Long> snapshot = apiUsageInterceptor.getUsageSnapshot(); log.info("========== API Usage Report =========="); snapshot.entrySet().stream() .sorted(Map.Entry.<String, Long>comparingByValue().reversed()) .forEach(entry -> log.info("API: {}, count: {}", entry.getKey(), entry.getValue())); log.info("======================================"); } }在主启动类上记得开启定时任务支持:
// 文件路径:src/main/java/com/example/uselessfeature/UselessFeatureApplication.java package com.example.uselessfeature; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableScheduling; @EnableScheduling @SpringBootApplication public class UselessFeatureApplication { public static void main(String[] args) { SpringApplication.run(UselessFeatureApplication.class, args); } }到这里,一个最简单的接口调用统计工具就完成了。定时任务每天凌晨两点输出一次统计结果,开发同学可以从中找到长期为 0 或极低的接口。
5.3 实现低频接口报表
日志毕竟不方便查看。更实用的做法是暴露一个查询接口,让开发和测试能够随时查看当前 Top N 低频接口。
// 文件路径:src/main/java/com/example/uselessfeature/controller/FeatureStatusController.java package com.example.uselessfeature.controller; import com.example.uselessfeature.interceptor.ApiUsageInterceptor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @RestController @RequestMapping("/api/feature-status") public class FeatureStatusController { private final ApiUsageInterceptor apiUsageInterceptor; public FeatureStatusController(ApiUsageInterceptor apiUsageInterceptor) { this.apiUsageInterceptor = apiUsageInterceptor; } @GetMapping("/low-usage") public List<Map.Entry<String, Long>> lowUsage(@RequestParam(defaultValue = "10") int topN) { return apiUsageInterceptor.getUsageSnapshot().entrySet().stream() .sorted(Map.Entry.<String, Long>comparingByValue()) .limit(topN) .collect(Collectors.toList()); } }这时候可以请求:
curl "http://localhost:8080/api/feature-status/low-usage?topN=5"预期输出类似:
[ { "key": "OperationLogController#exportPdf", "value": 0 }, { "key": "OperationLogController#exportExcel", "value": 3 } ]从这个结果就能直观看到,exportPdf接口调用量为 0,而exportExcel接口有 3 次调用。这说明用户存在“导出”需求,但对 PDF 这个格式不感兴趣。
5.4 通过配置开关安全下线功能
确认某个功能没用之后,最稳妥的做法不是立刻删除代码,而是先“下线”再观察。这里推荐用配置开关控制。
在application.yml中加入开关:
feature: export-pdf: enabled: false在 Controller 中判断开关状态:
// 文件路径:src/main/java/com/example/uselessfeature/controller/OperationLogController.java package com.example.uselessfeature.controller; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/operation-logs") public class OperationLogController { @Value("${feature.export-pdf.enabled:false}") private boolean exportPdfEnabled; @GetMapping("/export-pdf") public ResponseEntity<String> exportPdf() { if (!exportPdfEnabled) { return ResponseEntity.status(404).body("功能已下线"); } // 这里写真正的 PDF 导出逻辑 return ResponseEntity.ok("export pdf success"); } @GetMapping("/export-excel") public ResponseEntity<String> exportExcel() { // 保留正常功能 return ResponseEntity.ok("export excel success"); } }这样做的好处是:
- 不需要重新发布代码就能切回功能,只需要修改配置后重启或借助配置中心动态刷新。
- 如果开关关闭后用户反馈强烈,可以立刻恢复。
- 如果开关关闭后持续两周没有任何人反馈,再考虑删除代码。
如果想做得更彻底,也可以使用 Spring 的条件注解,在下线时直接不创建对应的 Bean。比如把 PDF 导出服务拆成一个独立类:
// 文件路径:src/main/java/com/example/uselessfeature/service/OperationLogPdfExportService.java package com.example.uselessfeature.service; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; @Service @ConditionalOnProperty(name = "feature.export-pdf.enabled", havingValue = "true", matchIfMissing = false) public class OperationLogPdfExportService { public String export() { return "export pdf success"; } }当配置为false时,Spring 容器根本不会创建这个 Bean,比在方法里判断开关更干净。
5.5 运行与验证
启动项目,访问:
curl "http://localhost:8080/api/operation-logs/export-pdf"当feature.export-pdf.enabled=false时,返回 404:
功能已下线把配置改为true再重启,重新访问,返回:
export pdf success通过这个流程,一个功能在被删除前,会经历一个“可恢复下线”的观察期。这个观察期很有价值,它能帮助团队确认“真的没有人在用”。
6. 功能下线的最稳操作流程
有了识别工具和开关机制,下面整理一套标准的功能下线流程。不管是小功能还是大模块,都建议按这个顺序执行。
6.1 下线前检查清单
在真正下线前,至少确认下面这些问题:
- 功能近 30 天是否有真实调用?统计口径要排除测试环境、自动化脚本和内部联调。
- 是否有前端入口引导用户访问这个功能?如果有,前端需要同步移除入口。
- 是否被其他接口或服务调用?尤其是内部 API 调用,不能只看前端。
- 数据库表、配置项、缓存 key 是否被该功能独占?如果是,需要考虑数据迁移和清理。
- 是否有合规或审计要求?存在争议时,先和法务/合规确认。
- 是否有灰度发布条件?开关方案是否已经就绪。
- 如果下线后出现问题,回滚方案是什么?
这七项确认完,再动代码。
6.2 灰度下线:先开关,再降级,最后删除
推荐的步骤是:
- 通过配置中心把功能开关切到关闭状态,观察线上日志和用户反馈。
- 观察期建议持续 1 到 2 个业务周期。如果功能是月报相关,建议观察一整月。
- 在观察期内不要急着删除代码,也不要急着删数据库字段。
- 确认无人反馈后,再移除前端入口,删除后端接口逻辑。
- 最后清理无用依赖、无用配置项和相关测试用例。
每一步都要有小版本发布,不要在一个版本里同时做所有事。
6.3 回滚方案
无论流程多严谨,都可能有意外。比如你认定没人用的功能,某个客户刚好每个季度用一次,这一季度正好撞上你下线的窗口。
所以回滚方案必须提前定:
- 如果只是开关关闭,那么重新打开开关即可,最快。
- 如果已经删除了代码,就需要从 Git 历史中恢复,并把上一个版本重新发布。
- 如果删除了数据库字段,回滚会麻烦得多。因此建议数据库变更延后到代码删除后的下一个版本。
总之,数据库变更永远比代码变更慢一步,这是功能下线的重要原则。
6.4 常见问题与排查
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 开关配置不生效 | 配置中心未刷新,或使用了错误 key | 检查配置中心和本地配置优先级,先重启验证 |
| 功能下线后其他报表报错 | 该功能被其他报表统计逻辑引用 | 下线前用代码搜索确认所有引用点 |
| 统计数据显示有调用但实际没人用 | 定时任务、监控探针、内部调用产生访问 | 排除非用户来源,按用户维度或会话维度过滤 |
| 接口已删除但前端入口还在 | 前端和后端发布不同步 | 先下线前端入口,再删除后端接口 |
| 删除代码后需要恢复 | Git 版本管理不清晰 | 用独立 commit 提交删除,方便 revert |
7. 工程建议:让“没用功能”从源头变少
清理功能是事后补救。更好的做法是从源头上减少无用功能的产生。
7.1 需求评审阶段添加“验证指标”
每一次需求评审,除了问“做什么”和“怎么做”,还应该问“怎么判断这个功能成功了”。
比如导出 PDF 功能,成功指标可以是“上线一个月内,有 10 个以上管理员使用”。如果连验证指标都定不出来,说明需求方自己也没想清楚这个功能的价值,这时候就要谨慎了。
7.2 用 MVP 思维替代大而全
很多功能第一次做的时候,不需要把所有选项、所有权限、所有导出格式都做出来。先提供一个最小可用版本,验证用户是否需要这个功能,再决定是否补齐能力。
这和开发里的“先跑通再优化”是一个道理,只是把思路从“代码实现”迁移到了“产品功能”上。
7.3 定期做功能审计
把功能审计加入版本节奏。比如每两个迭代做一次,每次审计时间控制在半天以内。
审计时拿上个月的接口调用数据,列出调用量最低的 20 个接口,逐个过一遍。这一步不需要技术负责人参与,普通开发看完数据后就能给出初步判断。有争议的再拉上产品和测试一起讨论。
7.4 代码删除也是一种重构
删除代码时,不要只删表面的一层。建议连同以下内容一起处理:
- 接口 Controller 方法。
- Service 层业务逻辑。
- 对应实体类字段或独立表。
- 配置项和常量类中的相关定义。
- 前端页面入口和路由。
- 测试用例和测试数据。
- 不再使用的第三方依赖。
删除后跑一遍全量测试,再人工回归关联模块。把删除动作做成一个小而清晰的 PR, commit message 写清楚“移除功能编号 149:操作日志导出 PDF”。
7.5 文档同步与知识沉淀
功能下线后,更新相关文档很关键。至少包括:
- 接口文档中标记该接口已下线或移除。
- 产品需求文档中标注状态为“已下线”及原因。
- 团队 Wiki 中如果有功能清单,同步移除。
- 如果是因为“没有价值”而下线,可以简单写一句复盘,提醒后人不要再做类似需求。
这一步看起来很琐碎,但能避免半年后有人再次提出同样的需求。
8. 最后整理一份“功能下线检查单”
文章的最后,把最实操的部分沉淀成一份检查单。以后遇到疑似没用的功能,直接对照执行就行。
- [ ] 拉取近 30 天接口调用数据,排除测试和内部调用。
- [ ] 确认功能无合规、审计、安全要求。
- [ ] 全局搜索代码,确认无上下游依赖。
- [ ] 通过配置开关关闭功能,并开启观察期。
- [ ] 观察期至少覆盖一个完整业务周期。
- [ ] 观察期间无用户反馈和异常日志。
- [ ] 删除前端入口、后端逻辑、依赖和配置。
- [ ] 数据库字段变更延后到代码删除后的下一版本。
- [ ] 更新接口文档、需求文档和团队 Wiki。
- [ ] 独立提交,完善 commit message,方便随时 revert。
回到最开始的编号 149,它现在已经不是一个“没用的功能”,而是一个提醒我们保持克制的例子。每个功能在进入代码库之前,都应该先回答三个问题:用户真的需要它吗?有数据支撑吗?如果删掉它,谁会受到影响?
希望这篇文章能帮你在下一次项目梳理中,更果断也更安全地识别和下线那些没用的功能。