关联表使用sum()查询过慢,如何优化无需手动维护字段?
这问题我太有共鸣了——手动维护汇总字段不仅容易出错,还平白增加了业务逻辑的复杂度。咱们来看看几个既能保留快速查询的优势,又不用手动更新total_xp_points的办法:
1. 给xp_points表加覆盖索引(最基础、改动最小)
你的慢查询核心问题是每次都要全表扫描xp_points来分组求和。给xp_points表建一个联合覆盖索引,让数据库直接从索引里拿到需要的分组和求和数据,不用回表查询原始数据:
CREATE INDEX idx_user_xp ON xp_points(user_id, xp_points);
这个索引包含了user_id(关联和分组用)和xp_points(求和用)两个字段,数据库执行SUM(xp_points)时可以直接从索引读取,不用访问表的主数据块。加上这个索引后,你的原始查询速度应该会大幅提升,接近直接查total_xp_points的速度。
2. 使用数据库的生成列(Generated Columns)(自动维护,查询超快)
如果你的数据库支持生成列(比如MySQL 5.7+、PostgreSQL 12+),可以直接在users表中添加一个自动计算的生成列,替代手动维护的字段:
MySQL示例(STORED类型,磁盘存储,查询更快):
ALTER TABLE users ADD COLUMN total_xp_points INT AS ( (SELECT COALESCE(SUM(xp_points), 0) FROM xp_points WHERE xp_points.user_id = users.id) ) STORED;
PostgreSQL示例:
ALTER TABLE users ADD COLUMN total_xp_points INT GENERATED ALWAYS AS ( (SELECT COALESCE(SUM(xp_points), 0) FROM xp_points WHERE xp_points.user_id = users.id) ) STORED;
STORED类型会把计算结果存在磁盘上,查询时直接读取,速度和你手动维护的字段一样快;- 当
xp_points表的数据变化时,数据库会自动更新这个字段,完全不用你操心; - 如果怕占用磁盘空间,也可以用
VIRTUAL类型(MySQL),每次查询时实时计算,但速度会比STORED稍慢,但远快于原始关联查询。
之后你就可以直接用那个4.6ms的快速查询语句了:
SELECT users.id as user_id, fullname, total_xp_points FROM users WHERE total_xp_points > 3 LIMIT 100;
3. 用触发器自动维护total_xp_points字段(实时更新,兼容旧版本数据库)
如果你的数据库不支持生成列,触发器是个不错的替代方案——当xp_points表发生插入、更新、删除操作时,自动同步更新users表的total_xp_points:
MySQL触发器示例:
-- 插入新积分时更新用户总积分 DELIMITER // CREATE TRIGGER update_xp_after_insert AFTER INSERT ON xp_points FOR EACH ROW BEGIN UPDATE users SET total_xp_points = total_xp_points + NEW.xp_points WHERE id = NEW.user_id; END // DELIMITER ; -- 更新积分时同步调整总积分 DELIMITER // CREATE TRIGGER update_xp_after_update AFTER UPDATE ON xp_points FOR EACH ROW BEGIN UPDATE users SET total_xp_points = total_xp_points - OLD.xp_points + NEW.xp_points WHERE id = NEW.user_id; END // DELIMITER ; -- 删除积分时扣除对应总积分 DELIMITER // CREATE TRIGGER update_xp_after_delete AFTER DELETE ON xp_points FOR EACH ROW BEGIN UPDATE users SET total_xp_points = total_xp_points - OLD.xp_points WHERE id = OLD.user_id; END // DELIMITER ;
触发器会自动帮你维护total_xp_points字段,你只需要确保初始时这个字段的值是正确的(可以跑一次初始求和更新),之后就不用管了。不过要注意:触发器会增加xp_points表的写入开销,如果你的写入量特别大,需要权衡一下。
4. 创建物化视图(适合数据更新不频繁的场景)
如果你的XP积分数据不是实时更新(比如每天更新一次),可以用物化视图预计算好总积分,定时刷新:
PostgreSQL物化视图示例:
-- 创建物化视图 CREATE MATERIALIZED VIEW user_xp_summary AS SELECT users.id as user_id, users.fullname, COALESCE(SUM(xp_points.xp_points), 0) AS total_xp_points FROM users LEFT JOIN xp_points ON users.id = xp_points.user_id GROUP BY users.id, users.fullname; -- 创建唯一索引提升查询速度 CREATE UNIQUE INDEX idx_user_xp_summary ON user_xp_summary(user_id); -- 定时刷新(比如每天凌晨2点) REFRESH MATERIALIZED VIEW user_xp_summary;
MySQL替代方案(用定时任务+普通表):
MySQL没有原生物化视图,可以创建一个普通表,然后用事件调度器定时跑求和语句更新表数据:
-- 创建汇总表 CREATE TABLE user_xp_summary ( user_id INT PRIMARY KEY, fullname VARCHAR(255), total_xp_points INT ); -- 定时更新(每天凌晨2点) DELIMITER // CREATE EVENT update_user_xp_summary ON SCHEDULE EVERY 1 DAY STARTS '2024-01-01 02:00:00' DO BEGIN TRUNCATE TABLE user_xp_summary; INSERT INTO user_xp_summary SELECT users.id as user_id, users.fullname, COALESCE(SUM(xp_points.xp_points), 0) AS total_xp_points FROM users LEFT JOIN xp_points ON users.id = xp_points.user_id GROUP BY users.id, users.fullname; END // DELIMITER ;
这个方案适合对实时性要求不高的场景,查询速度和直接查users表的total_xp_points一样快,而且不会影响写入性能。
内容的提问来源于stack exchange,提问作者Martin Zeltin

