门店收银平时正常,一到饭点扫码买单就转圈,每单平均多等两三秒,顾客排起队。先给结论:瓶颈几乎都在数据库,是几条没走索引的慢查询,在高峰期把连接池占满了。这次我们把结账接口 P95 从 6 秒压到 400 毫秒,平均响应回到 300 毫秒以内。下面是六个排查步骤。
客户在商场里开了三家直营餐饮小店,饭点每个柜台每小时要走 150 到 200 笔,扫码自助点餐和柜台收银都有。
日常的点单、收银和会员台账,跑在唯顿收银系统(线下门店智慧收银系统)上;结账时的会员积分和券核销这一段,是我们自己另外接的逻辑。
先把这段边界交代清楚,是因为这次卡顿最后就定位在我们自己写的那段核销查询上,跟日常的收银台账没有关系。
不要一上来就加机器、改代码,先把“慢在哪”测出来。
我们在结账链路上加了埋点,把“提交订单”这个接口拆成几段计时:参数校验、库存扣减、券核销、积分、写流水。一拉数据就很清楚——接口自身逻辑耗时不大,大头都耗在数据库等待上,且饭点明显恶化。
同时看数据库侧的慢查询日志和连接数。两条曲线一对:慢查询一增多,活跃连接数就跟着往上顶,后面排队的请求只能干等。这一步把问题从“系统卡”收敛成了“某些 SQL 在高峰期拖慢了数据库”。
开启并查看慢查询日志(MySQL 为例):
# 临时开启慢查询记录,阈值设为 0.5 秒
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 0.5;
# 再用 EXPLAIN 看清单里耗时最高的那条语句从慢查询日志里挑出耗时最高、出现最频繁的几条,逐一用 EXPLAIN 看执行计划。
我们当时最可疑的是券核销里的一条查询:结账时要查这张券的历史核销记录,结果一次结账就要执行,单次耗时 1.2 秒。
-- 原始写法:按会员手机号查券,手机号字段是字符串,
-- 但传入时被当成了数字,发生隐式类型转换
SELECT *
FROM coupon_record
WHERE phone = 13800000000
AND status = 1;
EXPLAIN SELECT * FROM coupon_record WHERE phone = 13800000000 AND status = 1;
-- type = ALL 表示全表扫描
-- rows = 很大 预计扫描行数
-- key = NULL 没有用到任何索引type=ALL、key=NULL、rows 很大,就是典型的全表扫描。数据量小的时候无感,饭点并发一上来,几十条这样的查询同时扫表,数据库 CPU 直接被打满。
很多时候索引明明加了,却没被用上。常见有这么几种:
WHERE DATE(create_time)=...、WHERE amount+0>10,索引用不上;(phone,status),查询却只按 status 过滤;LIKE '%123' 无法走普通索引。把参数类型改对、把函数挪到等号右侧(或新增生成列),再按查询条件补齐联合索引:
-- 1) 传入值加引号,消除隐式转换
-- 2) 建立联合索引,并尽量用覆盖索引避免回表
ALTER TABLE coupon_record
ADD INDEX idx_phone_status (phone, status);
-- 如果只需要 id 和券号,可直接让索引覆盖查询列,省去回表
SELECT id, coupon_no
FROM coupon_record
WHERE phone = '13800000000'
AND status = 1;改完再 EXPLAIN,应能看到 type=ref、key=idx_phone_status、rows 明显变小、Extra 出现 Using index。这一条改完,单次查询就从 1.2 秒降到了几毫秒。
慢查询本身慢,还会连锁拖垮连接池,这是高峰期雪崩的关键。
连接被慢查询长期占用。 结账请求每个都要拿数据库连接,慢查询一条占住连接几百毫秒到几秒,饭点并发一来,连接池很快被占满,新请求排队等待连接,表现就是“转圈”。
大事务和行锁等待。 扣库存、核销券这类操作要加行锁,如果事务里还夹了远程调用、写了一堆无关数据,事务时间被拉长,锁也就一直不释放,其他结账请求被堵在锁等待上。
合理设置连接池、把事务边界收紧:
// HikariCP:按数据库规格和并发设置连接数,不是越大越好
HikariConfig config = new HikariConfig();
config.setJdbcUrl(url);
config.setMaximumPoolSize(20); // 结合数据库 max_connections 评估
config.setConnectionTimeout(1000); // 拿不到连接快速失败,别无限堆积
config.addDataSourceProperty("slow_query_timeout", "1000");
// 事务方法里只放必须原子提交的数据库操作,
// 远程调用、消息通知、日志上报都挪到事务外原则是:事务里只做“要么全成、要么全回滚”的数据库操作,其余一律移出去,让连接和锁尽快释放。
单点修好后,再排查几类会在高峰放大问题的写法。
我们把 N+1 改成批量、报表迁到只读库后,高峰期单笔结账的 SQL 数量从十几条降到个位数,数据库压力进一步下降。
坑1:只盯接口耗时,不抓慢查询。 看到接口慢就想加机器、加缓存,结果根因是全表扫描。务必先开慢查询日志、用 EXPLAIN 定位到具体 SQL。
坑2:索引加了却不生效。 隐式类型转换、列上用函数、不满足最左前缀,都会让索引形同虚设。改完一定要用 EXPLAIN 复核执行计划。
坑3:忽略回表开销。 SELECT * 即使走了索引还要回表取整行,高并发下开销不小;只需要少量列时用覆盖索引。
坑4:事务里夹远程调用。 事务时间被网络抖动拉长,连接和行锁被长时间占住,极易在高峰引发连锁等待。事务边界要尽量短。
坑5:报表和在线交易共用一个库。 月底对账大查询一跑,前台收银就跟着卡。读写分离、报表异步化,把在线交易和分析负载隔开。
门店收银高峰期卡顿,排查路径其实很固定:先量化耗时、再抓慢查询、确认索引生效、检查连接池与锁、最后清理 N+1 和报表争抢。这套顺序走下来,瓶颈基本都能定位到具体的几条 SQL。
这次整改后,三家店在随后的饭点高峰平稳运行,结账 P95 稳定在 400 毫秒上下,排队明显减少。收银是门店生意的“最后一米”,卡顿顾客感知最直接,建议把慢查询监控和结账耗时告警长期开着,别等顾客排起队才回头排查。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。