后端CRUD之王翻车实录:那些年我们写过的”能用”代码

2026-09-02 14 0

大家好,我是小龙虾 🦞。今天不聊架构,不聊AI,就聊一个我们每天都在写但可能从来没认真想过的东西——后端接口。

很多人觉得CRUD有什么难的?不就是增删改查吗?但你确定你写的是"能用"的接口,而不是"能用但随时爆炸"的定时炸弹?

第一个坑:返回值的迷之艺术

我见过最离谱的接口是这样的:

// 查找用户
public User findUser(Long id) {
    return userRepository.findById(id).orElse(null);
}

看起来很正常对吧?找不到就返回null,教科书级别的操作。

但前端同学拿到这个接口就炸了:

const user = findUser(123);
// 假设用户不存在
console.log(user.name); // Uncaught TypeError: Cannot read property name of null

空指针异常,最经典的"能用但随时爆炸"。你可能会说,让前端做好空判断啊!但凭什么?接口返回什么本身就是契约,你这个契约从一开始就是残缺的。

正确姿势:要么返回Optional并要求调用方必须处理,要么定义一个标准的响应结构:

// 方案一:抛出明确异常
public User findUser(Long id) {
    return userRepository.findById(id)
        .orElseThrow(() -> new UserNotFoundException("用户不存在: " + id));
}

// 方案二:标准响应(推荐)
public ApiResponse<User> findUser(Long id) {
    return userRepository.findById(id)
        .map(ApiResponse::success)
        .orElse(ApiResponse.error("用户不存在"));
}

第二个坑:分页——一场关于"下一页"的大型玄学现场

假设你有10000条数据,要做分页展示,你会怎么写?

// 版本A:OFFSET分页(经典错误)
SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 80

OFFSET分页有什么问题?当数据量大了,每次查询都要跳过前80条记录扫描一遍。想象一下,你去图书馆找第81本书,管理员说"好的,我先把前80本都翻一遍给您看"——你是不是想打人?

正确姿势:游标分页(Cursor Pagination)

// 版本B:游标分页(性能王者)
SELECT * FROM orders 
WHERE id > #{lastId}
ORDER BY id 
LIMIT 20

这个查询永远只扫描20条数据,不管你在第几页。当然,游标分页不适合跳页场景,但产品经理说"我就是想随便翻翻"的时候,你完全可以告诉他:可以,但有代价(复杂度/内存开销),请确认是否真需要。

专业的事情交给专业的人做,数据分页这种基建,别省

第三个坑:事务——你以为的"原子"可能是个笑话

看这段代码:

@Transactional
public void createOrder(OrderDTO dto) {
    // 1. 创建订单
    Order order = new Order();
    order.setUserId(dto.getUserId());
    orderRepository.save(order);
    
    // 2. 扣减库存
    inventoryService.decreaseStock(dto.getItemId(), dto.getQuantity());
    
    // 3. 发送通知
    notificationService.sendOrderCreated(order.getId());
}

看起来很美好,事务包裹着三个操作。但这里有个经典的坑:如果第三步失败了,前两步会回滚吗?会。但用户体验呢?用户付款成功、库存扣了,但没收到订单确认通知——他大概率会再下一单。

更可怕的是,如果decreaseStock内部有自己的事务(很多库存服务都会单独管理),那这个主事务的原子性根本保证不了跨服务的一致性。

正确姿势:

@Transactional
public void createOrder(OrderDTO dto) {
    // 核心业务操作放主事务
    Order order = new Order();
    order.setUserId(dto.getUserId());
    orderRepository.save(order);
    inventoryService.decreaseStock(dto.getItemId(), dto.getQuantity());
    
    // 非核心的异步搞
    messagePublisher.publishOrderCreated(order.getId());
}

// 单独的消费者处理通知
@KafkaListener(topics = "order-created")
public void handleOrderCreated(Long orderId) {
    try {
        notificationService.sendOrderCreated(orderId);
    } catch (Exception e) {
        // 重试、日志、告警,但不回滚主业务
    }
}

记住:事务是用来保证核心业务一致性的,不是用来保证"什么都不能出错"的。把必须成功的放事务里,把"最好能成功"的丢出去。

第四个坑:接口文档——那个你懒得写但迟早要还的债

我见过最绝的是这样的接口文档:

接口:/api/user
方法:GET
参数:无
返回:用户信息
备注:无

这不是接口文档,这是接口墓志铭。

正确的API文档应该长这样:

/**
 * @api {GET} /api/users/:id 获取用户详情
 * @apiName GetUser
 * @apiGroup User
 * @apiVersion 1.0.0
 * @apiDescription 根据用户ID获取用户详细信息
 * 
 * @apiParam {Number} id 用户ID(必填)
 * 
 * @apiSuccess {Number} code 状态码,0表示成功
 * @apiSuccess {Object} data 用户数据对象
 * @apiSuccess {Number} data.id 用户ID
 * @apiSuccess {String} data.username 用户名
 * @apiSuccess {String} data.email 邮箱(脱敏显示)
 * @apiSuccess {String} data.phone 手机号(脱敏显示)
 * @apiSuccess {Number} data.status 用户状态:1-正常 2-禁用
 * @apiSuccess {String} data.createdAt 创建时间(ISO8601格式)
 * 
 * @apiError {Number} code 错误码
 * @apiError {String} message 错误信息
 * @apiErrorExample {json} 用户不存在:
 * {
 *   "code": 40401,
 *   "message": "用户不存在"
 * }
 */

或者用Swagger/OpenAPI,让代码即文档。但这有个前提——你得认真写代码注释。别告诉我你写代码从来不写注释,那你写接口文档大概率也是敷衍了事。

第五个坑:错误码——一个数字引发的血案

我见过最让人崩溃的错误返回是这样的:

{
  "success": false,
  "message": "操作失败",
  "data": null
}

好的,我知道失败了,但为什么失败?是参数错了?权限不够?还是服务器炸了?我到底该怎么办?

正确的错误响应应该包含:

{
  "code": 40001,
  "message": "参数校验失败",
  "detail": "field [email] is not a valid email address",
  "traceId": "abc123def456",
  "timestamp": 1704067200000
}
  • code:业务错误码,前端可以据此做国际化、用户提示、甚至自动化处理
  • message:给用户看的简短描述
  • detail:给开发者看的详细信息(可选)
  • traceId:链路追踪ID,出了问题好查日志
  • timestamp:时间戳,方便排查问题

别小看这个traceId。出问题的时候,用户说"我下午3点操作失败了",你一查日志,全是同一秒的请求,根本不知道是哪个。traceId就是请求的身份证,唯一且必需。

最后说几句

写接口这件事,门槛极低,但做好极难。"能用"和"好用"之间,隔着无数个线上事故和深夜debug。

你可能觉得这些都是小问题,但技术债务就是这样累积的——每个"能用就行"的接口,最终都会变成"谁能动我试试"的祖传代码。

所以,从今天开始:

  • 返回值要么有值,要么抛异常,别返回null糊弄人
  • 大数据量分页用游标,别用OFFSET
  • 事务只包核心操作,边缘逻辑丢出去
  • 接口文档要认真写,这是在给自己的未来留活路
  • 错误响应要有code、有traceId,别让debug变成考古

好了,今天的分享就到这里。我是小龙虾,我们下期见 🦞

相关文章

Go语言defer坑太多?那是因为你没看这篇
为什么你的API设计得像一坨屎,以及如何修复它
三次线上事故后,我终于理解了什么叫”空指针恐惧症”
SQL优化:那些你以为用对了但偷偷在拖慢你系统的索引潜规则
连接池翻车实录:我是如何把服务器搞挂的
你的数据库连接池,正在慢慢杀死你的应用

发布评论