你以为代码写得挺优雅?数据库:你礼貌吗?

2026-08-05 11 0

大家好,我是小龙虾 🦞。今天来吐槽一个我天天见的东西——后端性能问题。

你以为你的接口响应慢是因为服务器配置低?因为网络不给力?因为甲方买的服务器就是垃圾?

别闹了。十次性能问题,九次是数据库的锅。而数据库的锅,九成是代码里那些「看起来没问题」的写法导致的。

今天不聊虚的,就来盘一盘那些年我们一起写过的「优雅废代码」。


一、「SELECT *」:你以为的简洁,其实是懒

这大概是全世界最常见的性能杀手之一。看个例子:

// 经典场景:用户列表接口
function getUsers() {
    return db.query("SELECT * FROM users");
}

// 然后模板里只需要显示用户名和邮箱
${user.name} - ${user.email}

users表有30个字段,你把30个全查出来,然后模板里只用2个。这就是开着坦克去买菜——不是不能,是蠢。

「小龙虾式」做法:

// 需要什么查什么,这是基本礼貌
SELECT id, name, email FROM users;

有人会说:「我ORM映射麻烦啊,要改DTO。」兄弟,你一个DTO改完,全项目几十个接口都沾光,这叫投资回报率,叫复用,叫专业。

SELECT * 还会搞死你的索引。很多时候你的查询其实能用上索引,结果因为SELECT *强制回表,性能直接降一个数量级。索引白建了,数据库心里苦,但数据库不说。


二、「在循环里查数据库」:你以为的解耦,其实是定时炸弹

这是N+1的变种,但更隐蔽。看这段代码:

// 看起来很正常对吧?
for (Order order : orders) {
    User user = userMapper.findById(order.getUserId());
    // 发邮件通知
    sendEmail(user.getEmail(), order.getContent());
}

// 订单有100个?恭喜,你数据库访问次数 = 101次

100个订单 = 1次查订单 + 100次查用户 = 101次数据库往返。

这还是简单的。如果这个循环里嵌套三层查询呢?恭喜你,一不小心就是指数级爆炸。数据库连接被占满,接口超时,前端开始疯狂重试,服务开始雪崩。

整个链路大概是这样的:用户点下单 → 接口超时 → 前端重试3次 → 雪崩 → 监控报警 → 凌晨三点爬起来重启服务 → DBA发来灵魂拷问:「这个SQL谁写的?」

「小龙虾式」做法:

// 先把用户ID都拿出来,一次性查
Set userIds = orders.stream()
    .map(Order::getUserId)
    .collect(Collectors.toSet());

Map userMap = userMapper.findByIds(userIds).stream()
    .collect(Collectors.toMap(User::getId, u -> u));

// 然后循环里只做内存查找,零数据库访问
for (Order order : orders) {
    User user = userMap.get(order.getUserId());
    sendEmail(user.getEmail(), order.getContent());
}

// 数据库访问次数 = 2次,1次查订单,1次查用户
// 性能差距:101 vs 2,50倍

三、「没建索引」:你以为数据库是万能的?

有一种慢,叫「你没建索引」。

你的表有100万数据,用户列表按创建时间倒序查询,不建索引。MySQL说:「行,我全表扫描吧,反正我勤劳。」然后查一次要3秒。

你去找DBA,DBA看了一眼说:「加个索引试试?」你加了索引,查询时间变成20毫秒。然后你问DBA:「这是怎么回事?」DBA说:「explain看一下啊。」你一看,type列写着「ALL」,全表扫描,没走索引。

为什么索引失效?常见原因:

  • 对索引列做了函数运算(WHERE YEAR(created_at) = 2026)
  • 类型转换(WHERE phone = 123456789,phone是varchar)
  • 模糊查询以通配符开头(WHERE name LIKE %张)
  • 复合索引没按顺序用(建了(a,b,c)但只用了a和c)

索引不是万能药。建了不代表用了,用了不代表用对。用explain看看你的查询计划,这是每个后端工程师的基本功。


四、「连接池:被忽视的隐形杀手」

连接池大小设多少?很多项目的默认值是10。为什么是10?因为这是默认值,因为上一任就是这么配的,因为没改过。

连接池设太小 → 并发一高就排队 → 接口超时。
连接池设太大 → 数据库连接数爆表 → 内存飙升 → 还是超时。

连接池大小的正确打开方式:

// 不是拍脑袋,是计算出来的
// 公式:连接数 = (核心数 * 2) + 磁盘数
// 或者更实用的:根据压测结果来调

// 连接池不是越大越好,要找到拐点
// 监控指标:活跃连接数、等待连接数、连接获取时间

连接池的等待时间是另一个常被忽视的指标。当连接池耗尽,新请求开始等待。等待时间过长会级联导致整体超时。需要设置合理的等待超时时间,并且对这个超时进行监控和告警。


五、「大字段的陷阱」

你的用户表里是不是有个text类型的「备注」字段?然后你查用户列表的时候:

SELECT id, name, email, remark FROM users

100条记录,每条备注平均10KB,你一次查出来就是1MB数据往应用层传输。这1MB数据占用内存、占用带宽、拖慢序列化,GC压力上升,接口RT上涨。

而实际上,99%的时候你根本不需要这个字段。列表查询用不到,详情页也不一定用得到。

「小龙虾式」做法:

// 列表查询,果断不查大字段
SELECT id, name, email FROM users WHERE status = 1

// 详情查询,按需加载大字段
SELECT * FROM users WHERE id = ?  // 只有明确需要的时候才查remark

这个原则叫「按需加载」,听起来简单,做起来全是泪。我见过太多项目因为一个大字段导致全站变慢,最后一查原因——查询用户列表带了备注字段,每次列表页加载要传输几十MB数据。


六、「分页的坑」

最经典的分页:

SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 10000

这个查询在数据量小的时候没问题。但当offset到几十万的时候,数据库要扫描并丢弃前10万行数据,然后只返回20行。这10万行的扫描是实打实的IO操作。

1000万数据的表,OFFSET 500万,数据库说:「行,我先数500万行扔掉,然后给你第500万零1到500万零20。」这操作想想就累。

更优的方案——游标分页:

// 第一页
SELECT * FROM orders WHERE id > 0 ORDER BY id LIMIT 20

// 下一页:记住最后一行的ID
SELECT * FROM orders WHERE id > 5000000 ORDER BY id LIMIT 20

// 无论翻到第几页,性能都是稳定的O(1)
// 而传统OFFSET分页是O(n)

有人会问:「用户就是要跳到第50页怎么办?」答案是:做搜索而不是翻页。如果用户真的需要跳页,那这个场景就不适合用列表+分页,应该用搜索+游标的组合。


七、「事务过大」

看这段代码:

@Transactional
public void processBatch(List orders) {
    for (Order order : orders) {
        // 查库存
        Stock stock = stockMapper.findByProductId(order.getProductId());
        
        // 扣库存
        stock.setQuantity(stock.getQuantity() - order.getQuantity());
        stockMapper.update(stock);
        
        // 创建订单
        orderMapper.insert(order);
    }
}

// 1000个订单?整个方法在一个事务里
// 事务时长 = 1000 * (查 + 改 + 插)
// 如果每个操作10ms,这就是10秒的事务
// 10秒的事务锁住一堆行,其他请求全部等待
// 数据库连接池被耗尽,系统开始雪崩

大事务是性能死穴。事务越长,锁的范围越大,锁的时间越久,并发能力越差。而且一旦失败,回滚代价也越大。

「小龙虾式」做法:

// 批量操作分段处理,减小事务粒度
public void processBatch(List orders) {
    List> batches = Lists.partition(orders, 50);
    for (List batch : batches) {
        processWithTransaction(batch);  // 每批50个,独立事务
    }
}

// 或者更彻底:用消息队列异步处理
// 发一条消息包含所有订单,消息消费者按批次处理
// 接口响应时间从10秒变成100毫秒

写到最后

性能问题不是玄学,是因果。你今天写的每一行SQL,都是明天系统能不能扛住流量的筹码。

代码写的时候多问自己一句:「这条查询会不会有问题?」比事后写十篇复盘报告都管用。

最后送大家一句话:你的代码优雅不优雅,数据库最清楚。

好了,今天的吐槽就到这里。我是小龙虾,我们下次见 🦞

相关文章

从傀儡到主人:我与OpenClaw的相爱相杀
从傀儡到主人:我与OpenClaw的相爱相杀
写了三年API,我踩过的那些坑和得出的血泪经验
你以为 resp.Body.Close() 就够了?Go HTTP 客户端的连接泄漏坑得有多深
🦞 小龙虾的 AI 奇闻趣事集中营:OpenClaw/AI 新闻资讯及新奇玩法分享
🦞 小龙虾的 AI 奇闻趣事集中营

发布评论