后端 CRUD 写到手残?我从“增删改查机器”到“架构师”的骚操作进化论

2026-08-23 10 0

干过后端的都知道,日常工作80%的时间在写CRUD——Create、Read、Update、Delete。听起来很low,但等你真的在生产环境里被一个慢查询卡了3秒、被一个并发bug炸了服务、被一个“诡异”的空指针搞得凌晨两点修bug,你就会明白:CRUD写得好的人,才是真正的隐世高手。

今天不聊高大上的分布式、不聊K8s,就聊点接地气的——我在CRUD实战里踩过的坑,以及怎么用一些“奇技淫巧”把它们优雅地填掉。

一、你的查询为什么慢?先问自己这三个问题

很多人写SELECT * FROM users WHERE id = 1,觉得这没什么好优化的。没错,单条查询是没什么好优化。但当你在一个列表接口里循环查用户信息的时候——

// 反面教材,请勿模仿
for (Long userId : userIds) {
    User user = userDao.findById(userId); // N+1查询,100个id就是101次DB往返
}

这就是经典的N+1问题。数据库往返次数直接决定了你接口的响应时间上限。100个用户ID,100次数据库查询,DB的连接池在哭泣,你的接口在超时边缘疯狂试探。

解法是什么?批量查询

// 正确姿势:一次查完
List<User> users = userDao.findByIds(userIds); // 1次查询,O(1)
Map<Long, User> userMap = users.stream()
    .collect(Collectors.toMap(User::getId, u -> u));

这个改动看起来简单,但实测一个列表接口从2.3秒降到了87毫秒。你说值不值?

二、空指针:那个你永远预测不到的男人

Java程序员最熟悉的异常是什么?NullPointerException。而且这家伙的特点是:它不在编译时报错,只在运行时给你惊喜。更可怕的是,它经常不是你的代码写的,是某个第三方SDK、某个同事的代码、或者你自己三个月前写的代码。

我见过最离谱的一个bug是这样的:

// 某个用户的nickname是null
String displayName = user.getNickname(); // null
return "欢迎 " + displayName + "!"; // 正常

// 但如果拿去当key……
Map<String, Object> cache = new HashMap<>();
cache.put(displayName, userInfo); // key是null
// 后面查cache.get(anotherUser.getNickname())死活查不到

解决方案:防御性编程+Optional

// 用Optional包装
Optional.ofNullable(user.getNickname())
    .orElse("神秘访客");

// 或者更彻底的:建立统一的用户名获取逻辑
public String getDisplayName(User user) {
    return Optional.ofNullable(user)
        .map(User::getNickname)
        .orElse(
            Optional.ofNullable(user.getEmail())
                .orElse("用户" + user.getId())
        );
}

别觉得这小题大做。等你在生产环境遇到一个“为什么张三能看到李四的数据”的客诉时,你就会明白:每一行防御性代码,都是在给自己买保险。

三、事务:要么不写,要么写对

事务这个事儿,很多人知道怎么用,但不知道什么时候该用、怎么用才对。常见的两大流派:

流派一:事务大满贯——整个方法加个@Transactional,all in。好处是省心,坏处是锁的范围太大,并发量一上来数据库直接躺平。

流派二:完全不管——能查的不查,能删的先执行。结果就是数据不一致,用户付了钱订单没生成,或者库存扣了订单状态还是待支付。

我的经验是:事务边界要尽量小,只包裹核心的数据变更操作

@Transactional(isolation = Isolation.READ_COMMITTED)
public void createOrder(Long userId, List<Long> productIds) {
    // 1. 校验用户状态——不走DB,不用包在事务里
    User user = userService.getUser(userId);
    Assert.check(user != null, "用户不存在");
    
    // 2. 校验库存和锁库存——这个要在事务里
    for (Long productId : productIds) {
        int updated = productDao.lockStock(productId, 1);
        if (updated == 0) throw new BizException("库存不足");
    }
    
    // 3. 创建订单——核心操作,必须在事务里
    Order order = orderDao.save(buildOrder(userId, productIds));
    
    // 4. 发消息通知下游——异步,不在事务里
    messageQueue.publish("order.created", order);
}

看到了吗?事务只包了真正需要原子性的部分:锁库存和建订单。让数据库干它该干的事,别让它干不该干的活。

四、分页:你真的会分页吗?

很多人写分页是这样的:

SELECT * FROM orders 
WHERE user_id = 123 
ORDER BY created_at DESC 
LIMIT 20 OFFSET 1000

数据量小的时候没问题。等订单表到了1000万行,OFFSET 1000意味着数据库要把前1020行都扫一遍、然后扔掉前1000行——这是纯粹的I/O浪费。

正确姿势:游标分页(Keyset Pagination),不用OFFSET。

// 第一次查询
SELECT * FROM orders 
WHERE user_id = 123 AND id < #{lastId}
ORDER BY id DESC 
LIMIT 20

// 记住了最后一行的id=5566
// 下一次查询
SELECT * FROM orders 
WHERE user_id = 123 AND id < 5566
ORDER BY id DESC 
LIMIT 20

不管翻到第几页,查询时间都是稳定的O(1)。缺点是只能顺序翻页,不能跳页。但说实话,真正需要跳页的场景少之又少。大多数“跳页”需求都是产品经理拍脑袋想出来的,实际上用户根本不用那个功能。

五、幂等:接口没做幂等,等着被重试搞死

HTTP协议是“发出去不管”的模型。客户端发了一个请求,网络抖了一下,客户端没收到响应——怎么办?重试!好,正常情况下没问题。但如果这个请求是扣库存呢?

// 没有幂等的库存扣减
public void deductStock(Long productId, Integer quantity) {
    Product product = productDao.findById(productId);
    product.setStock(product.getStock() - quantity);
    productDao.save(product);
}
// 客户端超时重试,扣了两次!超卖!

解法:唯一请求ID+幂等表

public void deductStock(String idempotencyKey, Long productId, Integer quantity) {
    // 1. 尝试插入幂等记录
    int inserted = idempotencyDao.tryInsert(idempotencyKey);
    if (inserted == 0) return; // 已处理过,直接返回
    
    // 2. 扣库存(带乐观锁)
    int updated = productDao.deductWithVersion(productId, quantity);
    if (updated == 0) throw new BizException("库存不足或并发冲突");
}

这样即使用户的网络“抽搐”式地重试了5次,库存也只会扣一次。幂等是后端接口的自我修养,不做幂等的后端不是好后端。

六、优雅的日志:不是在代码里打System.out.println

我知道很多人调试的时候喜欢在代码里加System.out.println,上线之前再“删掉”——但问题是,你删得干净吗?

// 你的代码里是不是有这种
System.out.println("result = " + result);
System.out.println("进来了");
// 这些不会打日志文件,会直接往stdout喷
// 在生产环境里,你根本收不到这些输出

用结构化日志,用MDC。日志要回答三个问题:谁、做了什么、结果是什么

// 好的日志实践
MDC.put("traceId", traceId); // 每个请求一个traceId,全程追踪

log.info("订单创建|userId={}|orderId={}|amount={}|status={}",
    userId, orderId, amount, "PENDING");

try {
    paymentService.pay(order);
    log.info("支付成功|orderId={}|cost={}ms", 
        orderId, System.currentTimeMillis() - start);
} catch (PaymentException e) {
    log.error("支付失败|orderId={}|error={}",
        orderId, e.getMessage());
    throw e;
}

结构化日志可以导入ELK或者任何日志系统,出问题的时候一搜traceId,整个请求链路一目了然。别让排错变成考古挖掘。

七、总结:写出好CRUD,需要点理想主义

说了这么多,其实核心就三点:

1. 心里要有数据流。 写之前先想清楚这条数据从哪来、到哪去、谁会用、并发是多少。不懂业务的CRUD码农,永远只能写最低效的代码。

2. 为自己的代码买保险。 防御性编程、幂等、事务边界——这些都是短期费时间、长期省时间的投资。技术债这种东西,晚还不如早还。

3. 追求“可读”而不是“能跑”。 代码是写给人看的,顺便给机器执行。你三个月后回看自己的代码,如果看不懂了,那不是三个月后的你变笨了,是现在的你写得不够清楚。

CRUD是基本功,但基本功扎实的人,永远比“会搭框架、会画架构图”的人稀缺。共勉。

有问题欢迎留言,扯淡和讨论都欢迎。

相关文章

当群里突然@你,空气突然凝固了
AI圈最近在搞什么:从让程序员进群聊天,到用线性代数省掉一个亿
你的 ORM 正在慢慢杀死你的应用:一个老后端的血泪吐槽
月薪3800,钱包却死得比我还惨:我是如何把记账变成悬疑小说的
休息日躺平实录:我是如何把咸鱼人设修炼到极致的
当小龙虾遇上OpenClaw:我和这个赛博打工人相处的日子

发布评论