做了5年后端,我整理了10个最让我怀疑人生的奇葩需求

40 阅读1分钟

做了5年后端,我整理了10个最让我怀疑人生的奇葩需求

参与奇葩需求大赏话题征文

做了5年后端开发,我见过凌晨四点的月亮,也见过比月亮还离谱的需求文档。

有时候真的怀疑产品经理是不是偷偷在测试我们的忍耐极限。今天就来盘一盘那些让我在工位上直接怀疑人生的需求,看看你能撑到第几个不掀桌子。


1. "这个接口要同时支持分页和不分页,但代码只能写一份"

产品经理的原话是:"前端有时候需要分页,有时候又想要全部数据,你能不能写一个接口,传个参数控制一下?"

我心想这不就是加个 pageSize 参数的事嘛,简单。

然后他补了一句:"但是不分页的时候,数据格式要和分页的不一样,而且要能兼容老版本。"

我当时的表情:🤯

// 于是就有了这种"优雅"的代码
@GetMapping("/api/orders")
public ResponseEntity<?> getOrders(
    @RequestParam(required = false) Integer page,
    @RequestParam(required = false) Integer pageSize) {
    
    if (page != null && pageSize != null) {
        // 分页模式
        Page<Order> orderPage = orderService.findAll(PageRequest.of(page - 1, pageSize));
        return ResponseEntity.ok(Map.of(
            "code", 200,
            "data", Map.of(
                "list", orderPage.getContent(),
                "total", orderPage.getTotalElements(),
                "page", page,
                "pageSize", pageSize
            ),
            "message", "success"
        ));
    } else {
        // 不分页模式 - 老版本返回格式完全不同
        List<Order> allOrders = orderService.findAll();
        return ResponseEntity.ok(Map.of(
            "success", true,
            "result", allOrders,
            "msg", "查询成功"
        ));
    }
}

后来这个接口在三个版本之后被废弃了,因为产品经理离职了,新来的产品经理说:"干嘛搞这么复杂,统一分页不就行了?"

我:😶


2. "删除功能要有,但数据不能真的删"

这是我在一家电商公司遇到的真实需求。

产品经理:"用户点删除订单,订单要从列表里消失,但后台要能查到。"

我:"那不就是软删除吗?加个 is_deleted 字段就行。"

产品经理:"对,但是用户的回收站里也要能看到,而且30天后自动清空,而且清空的时候有些订单要保留,而且保留的规则是——"

我赶紧打断:"等等,让我拿个本子记一下。"

最终的需求矩阵是:

订单类型删除后用户可见30天后特殊规则
普通订单✅ 回收站永久删除
拼团订单✅ 回收站不删除拼团失败可退款
秒杀订单❌ 直接消失永久删除不可恢复
会员订单✅ 回收站永久删除保留90天
-- 光删除逻辑就写了这么一坨
UPDATE orders 
SET 
    is_deleted = CASE 
        WHEN order_type = 'flash_sale' THEN 1  -- 秒杀直接删
        WHEN order_type = 'member' THEN 2       -- 会员标记待清理
        ELSE 1 
    END,
    deleted_at = CASE 
        WHEN order_type = 'flash_sale' THEN NOW()
        WHEN order_type = 'group_buy' THEN NULL -- 拼团不设置删除时间
        ELSE NOW()
    END,
    recycle_days = CASE 
        WHEN order_type = 'member' THEN 90
        WHEN order_type = 'group_buy' THEN 0
        ELSE 30
    END
WHERE order_id = #{orderId};

后来运维告诉我,那个定时清理的 cron job 跑了半年从来没成功过,因为每次要清理的时候数据库就锁表了。


3. "这个导出功能要支持Excel,但数据量可能有100万行"

产品经理:"客户说需要导出订单数据。"

我:"好的,导出Excel,没什么问题。"

产品经理:"嗯,客户说他们有时候一年有100多万条订单,要全部导出。"

我:"……你说的是Excel对吧?Excel最大行数是1048576行。"

产品经理:"对啊,所以100万行刚好吧?"

我说不是行数的问题,而是——100万行Excel文件大概200MB,你确定客户打得开?

最后折中方案是:超过10万行走异步导出,生成多个文件并打包成zip,发邮件通知下载链接。

@Service
public class ExportService {
    
    private static final int MAX_ROWS_PER_FILE = 100_000;
    private static final int SYNC_THRESHOLD = 10_000;
    
    public void exportOrders(ExportRequest request) {
        long totalCount = orderRepository.count(request.getFilter());
        
        if (totalCount <= SYNC_THRESHOLD) {
            // 同步导出,直接返回
            byte[] excel = generateExcel(request, 0, (int) totalCount);
            saveAndNotify(request.getUserId(), excel);
        } else {
            // 异步导出,拆分成多个文件
            int fileCount = (int) Math.ceil((double) totalCount / MAX_ROWS_PER_FILE);
            asyncExport(request, fileCount, totalCount);
        }
    }
    
    private void asyncExport(ExportRequest request, int fileCount, long totalCount) {
        // 创建导出任务
        ExportTask task = exportTaskRepository.save(ExportTask.builder()
            .userId(request.getUserId())
            .status("PROCESSING")
            .totalFiles(fileCount)
            .progress(0)
            .build());
        
        // 分批处理
        for (int i = 0; i < fileCount; i++) {
            int offset = i * MAX_ROWS_PER_FILE;
            byte[] part = generateExcel(request, offset, MAX_ROWS_PER_FILE);
            // 上传到OSS
            String url = ossService.upload(part, "export/" + task.getId() + "/part_" + i + ".xlsx");
            task.addFileUrl(url);
            task.setProgress((i + 1) * 100 / fileCount);
        }
        
        // 打包成zip
        byte[] zip = zipService.compress(task.getFileUrls());
        String downloadUrl = ossService.upload(zip, "export/" + task.getId() + "/all.zip");
        
        // 发邮件通知
        emailService.send(request.getUserId(), "导出完成", "下载链接:" + downloadUrl);
    }
}

后来客户说:"其实我们只需要最近3个月的数据,之前都是随口一说。"


4. "接口响应时间不能超过200ms,但数据来自三个微服务"

这是最经典的一个。

产品经理拿着竞品分析报告来找我:"你看,竞品的这个页面加载只要200ms,我们的为什么不行?"

我打开DevTools一看,这个页面后端调了三个微服务:用户服务、订单服务、商品服务。

"这三个服务之间需要串行调用,用户服务100ms,订单服务150ms,商品服务80ms,加起来就330ms了。"

产品经理:"那你能不能并行调用?"

我:"……可以,但这改动量不小。"

产品经理:"那不能超过200ms,你看着办。"

行,我改。

// 改之前:串行调用,330ms
public PageData getPageData(Long userId) {
    UserInfo user = userService.getUser(userId);        // 100ms
    List<Order> orders = orderService.getOrders(userId); // 150ms
    List<Product> products = productService.getHot();    // 80ms
    return new PageData(user, orders, products);         // 总计 330ms
}

// 改之后:并行调用 + 缓存
public PageData getPageData(Long userId) {
    CompletableFuture<UserInfo> userFuture = 
        CompletableFuture.supplyAsync(() -> 
            userCacheService.getOrLoad(userId));          // 命中缓存: 5ms
    
    CompletableFuture<List<Order>> orderFuture = 
        CompletableFuture.supplyAsync(() -> 
            orderService.getRecentOrders(userId, 10));    // 限制10条: 50ms
    
    CompletableFuture<List<Product>> productFuture = 
        CompletableFuture.supplyAsync(() -> 
            productCacheService.getHotProducts());        // 本地缓存: 2ms
    
    CompletableFuture.allOf(userFuture, orderFuture, productFuture).join();
    
    // 总计约 50ms,满足要求!
    return new PageData(userFuture.get(), orderFuture.get(), productFuture.get());
}

上线后产品经理很满意:"你看,我就说可以做到200ms以内吧。"

我指着监控面板上的缓存命中率曲线:"你看,这个99%的命中率,是用Redis集群和本地缓存双层扛着的,机器成本涨了30%。"

产品经理:"那没事,反正不是我付钱。"


5. "这个接口要同时支持GET和POST,而且参数名还不一样"

不要笑,这是真实发生的。

某个前端同事离职了,交接文档里写的是用GET请求调用某个接口,新来的前端同事接手后,发现代码里已经是POST了。

然后产品经理的解决方案是:"你的接口同时支持GET和POST不就行了?"

// 产品经理眼中的"简单兼容"
@RequestMapping(value = "/api/user/profile", method = {RequestMethod.GET, RequestMethod.POST})
public Result getUserProfile(
    @RequestParam(required = false) String userId,     // GET用
    @RequestBody(required = false) UserProfileRequest request  // POST用
) {
    String uid;
    if (request != null && request.getUserId() != null) {
        uid = request.getUserId();  // POST: body里取
    } else if (userId != null) {
        uid = userId;               // GET: query参数
    } else {
        throw new BadRequestException("userId is required");
    }
    return Result.success(userService.getProfile(uid));
}

后来有一天,前端同事在群里问:"为什么这个接口POST请求的userId有时候取不到?"

我一看日志,他把参数放在了 @RequestParam 里,但 Content-Typeapplication/json

这口锅,后端背了。


6. "日志要打印所有请求参数,但是敏感信息不能打"

数据安全部门的要求,合情合理。但问题是——

"哪些字段是敏感信息?"

"手机号、身份证、银行卡号、密码、地址、真实姓名、邮箱……"

"那基本上所有字段都是敏感信息了?"

"也不是,你可以脱敏嘛,手机号中间四位打星号,身份证中间几位打星号……"

于是我写了一个脱敏工具类,对接了二十多个接口。

public class SensitiveDataMasker {
    
    private static final Set<String> SENSITIVE_FIELDS = Set.of(
        "phone", "mobile", "idCard", "bankCard", "password", 
        "realName", "email", "address", "idNumber"
    );
    
    public static String mask(String fieldName, String value) {
        if (value == null || value.isEmpty()) return value;
        
        return switch (fieldName) {
            case "phone", "mobile" -> 
                value.replaceAll("(\\d{3})\\d{4}(\\d{4})", "$1****$2");
            case "idCard", "idNumber" -> 
                value.replaceAll("(\\d{4})\\d{10}(\\d{4})", "$1**********$2");
            case "bankCard" -> 
                value.replaceAll("(\\d{4})\\d+(\\d{4})", "$1****$2");
            case "email" -> 
                value.replaceAll("(.)(.*)(@.*)", "$1***$3");
            case "password" -> "******";
            default -> "***";
        };
    }
}

然后有一天,测试提了个Bug:"订单详情页的用户姓名显示三个星号,用户投诉了。"

我一看,realName 字段映射错误,脱敏规则把 "张三" 变成了 "***"。

但用户说的是:"我叫张三,不是三颗星!"


7. "这个需求很简单,就是加个按钮"

所有开发者的噩梦词:"这个需求很简单"。

产品经理指着原型图:"这里加个按钮,点击后弹一个确认框,确认后调用后端接口,然后刷新页面。"

我:"嗯,听起来确实简单。"

产品经理:"对,但是——"

我就知道有"但是"。

"——但是这个按钮要在用户首次登录后7天内显示,且用户必须是VIP,且用户的订单数超过3单,且用户的账户余额大于100,且用户没有点击过'不再提醒',且这个按钮的文案要根据用户性别动态变化,女用户显示'领取专属优惠',男用户显示'查看权益',且点击后的弹窗样式要跟品牌色一致,且弹窗里要展示用户当前可用的优惠券列表,且优惠券按过期时间排序,且已经过期的要用灰色显示但不要隐藏,且——"

"停!!!"我打断了他,"这叫'加个按钮'?"

// 这就是"加个按钮"背后的逻辑
public ButtonConfig getButtonConfig(Long userId) {
    User user = userService.getById(userId);
    LocalDate now = LocalDate.now();
    
    // 首次登录后7天内
    if (user.getFirstLoginDate().plusDays(7).isBefore(now)) {
        return ButtonConfig.hidden();
    }
    
    // 必须是VIP
    if (!user.isVip()) {
        return ButtonConfig.hidden();
    }
    
    // 订单数超过3单
    if (orderService.countByUserId(userId) <= 3) {
        return ButtonConfig.hidden();
    }
    
    // 账户余额大于100
    if (user.getBalance().compareTo(new BigDecimal("100")) <= 0) {
        return ButtonConfig.hidden();
    }
    
    // 没有点击过"不再提醒"
    if (userPreferenceService.hasDismissed(userId, "VIP_BUTTON")) {
        return ButtonConfig.hidden();
    }
    
    // 动态文案
    String text = "女".equals(user.getGender()) 
        ? "领取专属优惠" 
        : "查看权益";
    
    return ButtonConfig.builder()
        .visible(true)
        .text(text)
        .coupons(couponService.getAvailableCoupons(userId))
        .build();
}

这个"简单"需求,我写了300多行代码,提测了3次,因为产品经理每次测试都发现新的"但是"。


8. "数据库查询太慢了,能不能优化一下?"

DBA找我:"这个SQL跑了3秒,用户投诉了。"

SELECT * FROM orders 
WHERE status IN ('PAID', 'SHIPPED', 'DELIVERED') 
AND created_at BETWEEN '2025-01-01' AND '2025-12-31'
AND (user_id IN (SELECT id FROM users WHERE level >= 3) 
     OR total_amount > 1000)
ORDER BY created_at DESC;

我:"这个查询确实慢,主要问题是 IN 子查询和 OR 条件。"

DBA:"那能不能优化成10ms以内?"

我:"……5000万数据量的表,10ms?你认真的?"

最后我做了这些优化:

-- 1. 加索引
CREATE INDEX idx_orders_status_created ON orders(status, created_at);
CREATE INDEX idx_orders_user_amount ON orders(user_id, total_amount);

-- 2. 用 UNION 替代 OR(MySQL 5.7 对 OR 优化不佳)
SELECT * FROM orders 
WHERE status IN ('PAID', 'SHIPPED', 'DELIVERED') 
AND created_at BETWEEN '2025-01-01' AND '2025-12-31'
AND user_id IN (SELECT id FROM users WHERE level >= 3)
UNION
SELECT * FROM orders 
WHERE status IN ('PAID', 'SHIPPED', 'DELIVERED') 
AND created_at BETWEEN '2025-01-01' AND '2025-12-31'
AND total_amount > 1000;

优化后从 3 秒降到 200ms,DBA 说:"还是不够,产品说要 10ms。"

最后方案是加 Elasticsearch,把订单数据同步到 ES 里,查询走 ES。响应时间降到了 30ms。

但引入 ES 后又带来了数据一致性问题,于是又上了 Canal + MQ 做数据同步。

本来一个"慢查询"的优化,最后变成了一个微服务架构升级项目。


9. "这个接口要给第三方用,但不想让他们看到全部数据"

这是一个开放平台的需求。

产品经理:"我们要把订单查询接口开放给合作商家。"

我:"好的,做权限控制,商家只能查自己的订单。"

产品经理:"对,但是每个商家能看到的字段还不一样。A商家能看到价格,B商家不能看价格但能看到用户手机号,C商家不能看价格也不能看手机号但能看到订单备注……"

我:"……一共有多少商家?"

产品经理:"目前20个,但以后会更多。"

我硬着头皮设计了一个字段级别的权限控制:

@Service
public class FieldPermissionService {
    
    // 商家-字段权限映射
    private static final Map<String, Set<String>> MERCHANT_FIELD_PERMISSIONS = Map.of(
        "MERCHANT_A", Set.of("orderId", "productName", "price", "status", "createdAt"),
        "MERCHANT_B", Set.of("orderId", "productName", "userPhone", "status", "createdAt"),
        "MERCHANT_C", Set.of("orderId", "productName", "remark", "status", "createdAt"),
        "MERCHANT_D", Set.of("orderId", "productName", "status", "createdAt")
        // ... 20个商家
    );
    
    public Map<String, Object> filterFields(String merchantId, Order order) {
        Set<String> allowedFields = MERCHANT_FIELD_PERMISSIONS
            .getOrDefault(merchantId, Set.of("orderId", "productName", "status"));
        
        Map<String, Object> original = objectMapper.convertValue(order, Map.class);
        Map<String, Object> filtered = new HashMap<>();
        
        for (String field : allowedFields) {
            if (original.containsKey(field)) {
                filtered.put(field, original.get(field));
            }
        }
        return filtered;
    }
}

后来产品经理说:"这个字段权限改成了配置化,商家可以在后台自己勾选哪些字段可见。"

我默默地把硬编码的 Map 改成了查数据库,然后又加了一个审批流程:商家选字段 → 平台审核 → 生效。

原本一个简单的接口开放,变成了一个完整的权限管理系统。


10. "把这个系统从MySQL迁移到MongoDB,但不要停服"

最后一个暴击。

CTO在技术分享会上听说了MongoDB,觉得我们电商系统的订单数据很适合用文档数据库存储。

CTO:"订单数据本身就是嵌套结构,用MongoDB比MySQL更合适。"

我:"确实,但迁移成本很高,而且——"

CTO:"这个季度完成,不能停服。"

我:"……"

两个月,我做了双写方案:

┌─────────┐     ┌──────────┐     ┌─────────┐
│  App    │────▶│  Router  │────▶│  MySQL  │ (主)
└─────────┘     └──────────┘     └─────────┘
                      │
                      │ 异步双写
                      ▼
                ┌──────────┐
                │  MongoDB │ (影子库)
                └──────────┘
@Transactional
public Order createOrder(OrderRequest request) {
    // 1. 写 MySQL(主库)
    Order order = orderMapper.insert(request);
    
    // 2. 异步写 MongoDB
    mongoTemplate.insertAsync(order);
    
    // 3. 记录双写日志
    syncLogService.record(order.getId(), "MYSQL", "SUCCESS");
    syncLogService.record(order.getId(), "MONGO", "PENDING");
    
    return order;
}

// 定时对账任务
@Scheduled(cron = "0 0/30 * * * ?")
public void reconciliation() {
    List<Long> diffIds = syncLogService.findDiff();
    for (Long id : diffIds) {
        Order mysqlOrder = orderMapper.findById(id);
        mongoTemplate.save(mysqlOrder); // 补偿写入
        syncLogService.markSynced(id);
    }
}

上线后一切正常,直到有一天,一个读接口从MongoDB切换过去后,响应时间从不稳定到超时。

排查发现:MongoDB的某个分片挂了,但因为双写方案没有做 MongoDB 的故障转移,导致部分请求超时。

最后紧急切回了 MySQL,CTO 说:"那还是先用 MySQL 吧,MongoDB 的事以后再说。"

我:😐


写在最后

做了这么多年后端,我发现一个规律:越简单的需求描述,背后越复杂。

"加个按钮" = 权限控制 + 动态文案 + 生命周期管理 + 用户偏好 + AB测试

"优化一下" = 索引优化 + 缓存设计 + 数据同步 + 架构升级

"兼容一下" = 接口版本管理 + 数据格式转换 + 回归测试

但吐槽归吐槽,这些"奇葩需求"某种程度上也逼着我们成长。没有那个"简单"的导出需求,我可能永远不会去学异步任务和消息队列。没有那个"200ms"的要求,我也不会去深入研究缓存策略。

当然,如果产品经理能先把需求想清楚再提,那就更好了。 🫠


你遇到过哪些让你怀疑人生的奇葩需求?评论区聊聊,看看谁的经历更离谱。

🏷️ 本文参与奇葩需求大赏话题征文