做Destoon 7.0这块有几年了,最常被问到的一句话就是:“数据量到一两百万之后,前台列表页打开要好几秒,后台翻页更夸张,是不是该换服务器了?”说实话,大概率不用换。MySQL 5.7在处理几百万行这个量级时,本身还远没到瓶颈,真正拖垮速度的,往往是SQL写法、索引设计和几个关键配置参数没跟上。
这篇文章把我实际优化Destoon 7.0配合MySQL 5.7的完整思路整理出来:怎么定位拖慢响应的SQL、MySQL 5.7哪些参数值得动、Destoon 7.0的表结构该怎么补索引,以及缓存层、Web服务器层面怎么配合,才能让单台服务器在几百万数据下真正跑出“秒级”的体验。适合正在维护Destoon站点、或者打算在现有基础上做二次开发的开发者和运维朋友参考。
1. 两百万行数据就卡到转圈?先弄清楚问题出在哪
1.1 典型的“数据量上去了”卡顿场景
先别急着看优化方案,我们得先接受一个事实:卡顿不是均匀分布的,它有非常明确的触发场景。我接触过的Destoon站点,绝大多数卡顿都集中在这几类操作上:
一是前台列表页翻到中后段。比如供应信息列表,前20页还挺顺畅,翻到60页往后,页面加载时间肉眼可见地拉长,甚至直接超时。二是后台关键词搜索。运营人员输入一个产品词点搜索,白屏三四秒,体感很糟。三是会员中心自己的记录查询。会员明明只有几千条数据,但他登录后拉取自己发布的供应信息时,响应也不快。四是定时任务或者生成静态页的时间段,MySQL CPU直接飙到100%,整站跟着卡。
这些场景看起来各不一样,但归到数据库层面,本质都是同一类问题:大量查询在扫描大表,而表上要么没有能匹配的索引,要么SQL写法让索引失效,要么MySQL的配置参数压根没为这个数据量做过调整。
1.2 Destoon 7.0 的核心数据表与存储引擎问题
Destoon 7.0是一套典型的B2B信息发布系统,核心表无非这么几张:destoon_sell(供应信息)、destoon_company(公司库)、destoon_member(会员表)、destoon_article(文章),外加一堆关联和统计表。这类系统的特点是表结构相对扁平,字段多,但单行数据量不大,真正庞大的是行数。
这里必须要提一个最容易被忽视的坑:Destoon早期版本默认建表用的存储引擎是MyISAM。MyISAM在并发读写的场景下有一个致命问题——表级锁。也就是说,只要有一条写入请求在排队,后面所有读请求都得等锁。几百万行数据之后,后台定时刷新信息、用户发布供应信息、搜索引擎蜘蛛抓取这些操作叠在一起,表锁冲突会非常严重,MySQL的CPU很多消耗在等锁而不是真正的查询上。
所以我的建议是:优化第一步不是加索引,而是先确认存储引擎。检查方式很简单,执行:
SELECT table_name, engine FROM information_schema.tables WHERE table_schema = 'destoon';如果看到核心表是MyISAM,建议全部转成InnoDB:
ALTER TABLE destoon_sell ENGINE=InnoDB;一张一张表转太慢,可以先用SQL拼出批量转换语句,再复制到命令行执行:
SELECT CONCAT('ALTER TABLE ', table_name, ' ENGINE=InnoDB;') FROM information_schema.tables WHERE table_schema = 'destoon' AND engine = 'MyISAM';转引擎要注意两点:第一,要在低峰期做,几百万行的表转换期间会锁表;第二,转换前确认所有外键和全文索引的使用情况,虽然Destoon基本不用外键,但MyISAM的全文索引在InnoDB里语法不兼容,需要单独处理。
1.3 用慢查询日志定位“真正的凶手”
很多人喜欢凭感觉优化,上来就把my.cnf的参数改一遍,然后重启MySQL,发现没效果,再改一遍。这种做法效率极低。正确顺序是:先打开慢查询日志,让MySQL告诉我们瓶颈在哪。
在MySQL 5.7里,可以动态开启,不需要重启:
SET GLOBAL slow_query_log = ON; SET GLOBAL slow_query_log_file = '/var/log/mysql/mysql-slow.log'; SET GLOBAL long_query_time = 1; SET GLOBAL log_queries_not_using_indexes = ON;这里把long_query_time设为1秒,意思就是超过1秒的SQL都会被记录下来。log_queries_not_using_indexes这个参数很关键,它能把所有没走索引的查询也写进日志,哪怕执行时间没到1秒。在几百万数据量的表上,没走索引的全表扫描就是定时炸弹,必须暴露出来。
日志跑个半天一天,用分析工具或者直接翻日志看,通常都能找到几条“显眼包”。我在一个实际项目中看到的典型慢SQL是这样的:
SELECT * FROM destoon_sell WHERE keyword LIKE '%水泥%' ORDER BY addtime DESC LIMIT 10;用EXPLAIN一看,type=ALL,key=NULL,扫描行数两百多万。问题一目了然:LIKE '%关键词%'这种写法因为前导通配符,索引完全失效,MySQL只能从第一行扫到最后一行,再把结果排序。
所以我一直强调:优化的第一步永远是定位,第二步才是方案。拿到慢查询日志,把Top 10的SQL罗列出来,再逐个分析,远比闭着眼改参数可靠。
2. MySQL 5.7参数调优:同一台机器,换个配置能差好几倍
2.1 innodb_buffer_pool_size:给InnoDB足够的内存
如果说索引是快慢查询的分水岭,那么innodb_buffer_pool_size就是InnoDB的“内存工作台”。MySQL每次读数据,会先把数据页放到buffer pool里,下次再读同一份数据就直接从内存取;写操作也会先在buffer pool里缓存,再统一刷盘。这个值设小了,数据频繁在磁盘和内存之间交换,性能自然起不来。
怎么估算?把InnoDB表的数据量和索引量加起来,就是buffer pool的“最低消费”。执行这条SQL查看:
SELECT table_schema, ROUND(SUM(data_length + index_length) / 1024 / 1024 / 1024, 2) AS total_size_gb FROM information_schema.tables WHERE engine = 'InnoDB' GROUP BY table_schema;假设总数据量+索引量是10GB,那innodb_buffer_pool_size最好设在12GB左右,留20%的余量给后续增长。同时要守住一条红线:不能超过物理内存的70%。因为MySQL除了buffer pool,还要给排序缓冲、连接线程、临时表分配内存;服务器上还跑着PHP-FPM、Nginx,它们也要内存。
在16GB内存的机器上,比较稳妥的做法是设10GB:
[mysqld] innodb_buffer_pool_size = 10G innodb_buffer_pool_instances = 8顺带说一句,MySQL 5.7支持在线调整buffer pool,不用重启:
SET GLOBAL innodb_buffer_pool_size = 10 * 1024 * 1024 * 1024;但线上环境我还是推荐写进my.cnf,避免重启后配置丢失。innodb_buffer_pool_instances设为8或16,可以减少大缓存下的并发锁竞争。
2.2 写入密集型场景的三个参数:刷新策略、binlog与数据文件
Destoon系统的特点是读写混合,后台发布信息、会员更新资料、定时任务跑批,这些都是写入操作。InnoDB写入慢,很多时候不是磁盘不行,而是MySQL为了保证崩溃安全做了太多额外工作。
第一个值得调的是innodb_flush_log_at_trx_commit。默认值是1,意思是每次事务提交都要把redo log刷到磁盘,最安全但最慢。如果业务允许在极端情况下丢失最后一两秒的事务日志,可以改成2,让MySQL先把日志写到操作系统缓存,再每秒统一刷盘。对Destoon这类内容发布系统来说,我通常设成2,写入性能提升非常明显。
第二个是sync_binlog。如果开启了binlog,默认sync_binlog=1意味着每次提交都同步刷binlog到磁盘,这个磁盘IO开销很大。在不要求严格主从一致性的场景下可以设成0,或者至少别和innodb_flush_log_at_trx_commit=1同时用,否则写入速度会被拖得很惨。
第三个是innodb_flush_method=O_DIRECT。这个参数的意思是InnoDB绕过操作系统缓存,直接读写磁盘。很多人不理解为什么要绕过系统缓存——因为InnoDB自己有buffer pool,已经做了一层缓存,再叠加OS层面的page cache会造成双重缓存,浪费内存还增加IO路径。改成O_DIRECT之后,磁盘读写效率通常会更高。
[mysqld] innodb_flush_log_at_trx_commit = 2 sync_binlog = 0 innodb_flush_method = O_DIRECT innodb_file_per_table = ONinnodb_file_per_table=ON要提一下:让每张表使用独立的表空间文件,表删数据后空间能够收缩回收到操作系统层,方便管理大表,也避免共享表空间越来越大无法回收。
2.3 临时表、排序和连接缓存:别小看这些“会话级”参数
除了全局和磁盘层面的配置,还有一组参数直接影响单条SQL的执行速度——临时表、排序和连接缓冲区。这组参数对列表页按时间排序、多表关联这些场景影响特别大。
先说临时表。Destoon后台做统计报表,或者前台做复杂筛选时,MySQL可能需要在内存里建临时表来存中间结果。tmp_table_size和max_heap_table_size决定了内存临时表的上限,超过这个阈值,MySQL就会把临时表写到磁盘上,性能断崖式下降。我通常设置如下:
[mysqld] tmp_table_size = 64M max_heap_table_size = 64M再说排序和连接。sort_buffer_size是每个会话在做ORDER BY时分配的内存,join_buffer_size是JOIN时用于缓存关联数据的区域。这两个值设太大会造成内存浪费,因为它们是会话级的——每个连接都单独分配一份。设太小则排序和关联频繁使用磁盘临时文件。我通常设为:
[mysqld] sort_buffer_size = 4M join_buffer_size = 2M从16M降到4M是很多人的直觉反方向。但实测中,4M的排序缓冲配合索引优化的效果,往往比盲目调到32M更好,因为大排序缓冲在并发高时内存很快就被吃满,反而引起系统swap。所以这里的原则是:够用就好,别贪。
2.4 连接数校准:别让max_connections成为隐形杀手
max_connections这个参数也很有意思。很多人在卡顿的时候第一反应是“连接数不够了”,直接调到2000。但连接数越大,MySQL要维护的线程上下文就越多,内存开销也越大。如果服务器内存本来就不宽裕,调大连接数反而更容易触发OOM。
正确做法是看实际使用情况:
SHOW STATUS LIKE 'Max_used_connections';这个值表示历史上最高同时使用的连接数。如果Max_used_connections是80,那max_connections设成150就已经有接近一倍的余量了,完全没必要设到1000。同时建议观察:
SHOW STATUS LIKE 'Threads_connected';如果这个值长期很稳定,说明连接池工作正常;如果波动剧烈,反而要考虑PHP-FPM侧的连接复用问题。
2.5 配置改完怎么验证:用之前的慢SQL对比
参数调完之后,别急着宣布胜利。我的习惯是拿优化前慢查询日志里的典型SQL,重新跑一遍,记录耗时,作为最直观的验证标准。
比如优化前SELECT * FROM destoon_sell WHERE keyword LIKE '%水泥%' ORDER BY addtime DESC LIMIT 10;跑了3.8秒,优化后如果还是2秒以上,那说明配置层面的调整已经到头了,接下来必须进入索引和SQL改写环节。
这里要特别说清楚一个认知:配置优化能提升整体吞吐和基础效率,但解决不了“索引缺失”和“烂SQL”的根源问题。就好比把马路从两车道拓宽到六车道,但如果路口始终只开一条通道,该堵还是堵。真正决定单条SQL快慢的,是能不能走对索引、走多少行数。
3. 索引策略:在Destoon现有表结构上,把索引花在刀刃上
3.1 先给核心表做一次索引体检
参数调完之后,紧接着就是索引体检。我一般先看每张核心表现有索引:
SHOW INDEX FROM destoon_sell;你会发现Destoon 7.0默认表结构里,有些表只有主键索引,有些表建了一堆单列索引但实际查询组合起来根本用不上。典型问题有两个:一是索引缺口,列表页查询要用的复合字段组合没有对应索引;二是冗余索引,比如已经有了idx_catid和idx_status,又建了idx_catid_status,但两者字段重叠度很高,写入时白白多维护一份索引。
在几百万行数据表上,冗余索引不只是浪费空间,还会拖慢INSERT和UPDATE。因为每次写入都要同步更新所有索引,索引越多,写入越慢。
3.2 梳理业务最常用的查询模式
体检完之后,不要急着建索引。先想清楚业务到底怎么查数据。Destoon 7.0的列表页和后台查询,翻来覆去就那么几类模式:
第一类是按栏目查最新信息。前台栏目页大多执行类似这样的查询:
SELECT * FROM destoon_sell WHERE catid = 5 AND status = 3 ORDER BY addtime DESC LIMIT 20;第二类是按会员查他发布的供应信息。会员中心列表和公司主页的供应Tab都是这种模式:
SELECT * FROM destoon_sell WHERE userid = 10086 ORDER BY addtime DESC LIMIT 10;第三类是后台按状态筛选。比如查看待审核、已通过的数据:
SELECT * FROM destoon_sell WHERE status = 1 ORDER BY addtime DESC LIMIT 20;第四类是组合条件搜索。Destoon 7.0后台搜索框经常会在SQL里拼上多个字段条件:
SELECT * FROM destoon_sell WHERE catid = 5 AND status = 3 AND keyword LIKE '%水泥%' ORDER BY addtime DESC LIMIT 10;把这四类模式列出来,再看现有的索引,基本就能判断该补什么了。
3.3 复合索引怎么建:等值字段放前,排序字段放后
根据上面的查询模式,我通常会建下面几个复合索引:
ALTER TABLE destoon_sell ADD INDEX idx_catid_status_time (catid, status, addtime) USING BTREE; ALTER TABLE destoon_sell ADD INDEX idx_userid_time (userid, addtime) USING BTREE; ALTER TABLE destoon_sell ADD INDEX idx_status_time (status, addtime) USING BTREE;这里要讲清楚复合索引的字段顺序。MySQL的复合索引遵循最左前缀原则,索引的字段顺序决定了它能匹配多少查询条件。把等值条件catid、status放在前面,把排序字段addtime放最后,是因为索引内部同时维护了字段顺序和大小顺序:先按catid排序,再按status排序,最后按addtime排序。这样MySQL可以直接沿着索引顺序定位到对应栏目、对应状态的最新数据,然后取出前20条,连排序操作都省了。
如果你把顺序反过来建成(addtime, catid, status),那查询时catid条件就没法走索引前导列,索引价值和自建一个addtime单列索引差不多,完全浪费了。
3.4 索引失效的三个高频场景
建了索引不等于万事大吉,我见过太多“建了索引但没走”的案例。有三个高频场景必须注意。
第一个是前导通配符LIKE '%关键词%'。MySQL对%关键字%这种写法基本不走索引,因为搜索引擎无法从字符串中间开始匹配B+树。除非改成LIKE '关键词%'这种前缀匹配,否则索引形同虚设。对Destoon这种内容系统,全站搜索我后面会单独说,MySQL层面的优化空间很小。
第二个是隐式类型转换。比如userid字段在表里是INT类型,但你传入的查询参数是VARCHAR的'10086',MySQL会尝试把字段转成字符串来匹配。问题在于字段上发生了类型转换,索引就失效了。更常见的反例是自定义字段,一部分数据存成VARCHAR,另一部分存成INT,查询时一旦MySQL做了隐式转换,索引直接罢工。解决方案是统一字段类型,并把查询参数保持和字段类型一致。
第三个是函数包裹字段。比如用WHERE DATE_FORMAT(addtime, '%Y-%m-%d') = '2024-01-01'来查某天的数据,看着挺合理,但函数让addtime字段没法走索引。正确写法是条件范围:
SELECT * FROM destoon_sell WHERE addtime >= '2024-01-01 00:00:00' AND addtime < '2024-01-02 00:00:00';这样既走了索引,结果还更准确。
3.5 大表加索引的操作时机与工具
几百万行的大表上加索引可不是一条ALTER TABLE那么简单。MySQL 5.7虽然默认使用ALGORITHM=INPLACE,不像老版本那样锁表锁那么久,但在线加索引依然需要扫描全表去构建B+树,这期间对性能和IO也是不小的冲击。
我建议的稳妥方案是在业务低峰期执行,如果条件允许,优先使用专业工具在线变更结构:
pt-online-schema-change h=localhost,u=db_user,p=db_pass,D=destoon,t=destoon_sell \ --alter "ADD INDEX idx_catid_status_time (catid, status, addtime)" --executept-online-schema-change的原理很巧妙:它会创建一张新表,在旧表上建立触发器同步增量数据,然后分批把旧表数据复制到新表,最后切换表名。整个过程对线上查询基本无感,几百万行的表也能在业务不中断的情况下完成索引变更。不过要注意,如果服务器磁盘空间紧张,最好先留出至少1.2倍原表大小的空闲空间,因为全量复制期间旧表和新表同时存在。
4. 慢查询日志挖出的典型SQL问题与改写方案
4.1 深翻页的代价与延迟关联改写
加完索引之后,大多数列表页和后台查询已经能跑进秒级。但还有一个隐藏很深的性能黑洞:深翻页。
Destoon后台列表翻到100页之后,SQL长这样:
SELECT * FROM destoon_sell WHERE status = 3 ORDER BY addtime DESC LIMIT 300000, 20;表面上看只返回20条数据,但MySQL必须扫描前30万行,排序后丢弃30万行,才能拿到最后的20行。在InnoDB里,ORDER BY addtime DESC还会触发一次文件排序,如果status = 3的结果集有几十万行,这个排序的开销是实打实的。
有没有办法让深翻页“跳过去”?有。常见的手段是延迟关联,或者说“先取主键,再回表数据”。改写后的SQL长这样:
SELECT s.* FROM destoon_sell s INNER JOIN ( SELECT itemid FROM destoon_sell WHERE status = 3 ORDER BY addtime DESC LIMIT 300000, 20 ) t ON s.itemid = t.itemid ORDER BY s.addtime DESC;内层子查询只取itemid一个字段,并且强行走idx_status_time覆盖索引。由于索引已经按status和addtime排好序,内层查询只需要非常轻量地扫描索引叶子节点,定位到第30万行之后的位置。外层再通过主键itemid去原表取那20行完整数据。实测中,这个改写能把深翻页从4秒以上降到0.3秒左右。
这里还有个细节:内层取完20个itemid之后,外层ORDER BY s.addtime DESC有没有必要?严格来说,INNER JOIN的结果集只有20行,无论如何排序都很快,但为了保持和原查询一致的返回顺序,我还是会加上。而且这个排序不会影响性能,因为20行直接在内存里就排完了。
4.2 模板里循环查库的N+1问题
这个坑在Destoon二次开发里特别常见,我甚至在一些从别人手里接过的项目里反复看到。问题出在模板或业务逻辑里这种写法:
foreach ($sellids as $id) { $db->query("SELECT * FROM destoon_sell WHERE itemid=$id"); }如果$sellids有20个ID,就意味着发20次SQL请求,每次都要做网络往返、SQL解析、查询计划生成。这在数据量小的时候无所谓,数据量一大,积少成多,页面响应时间直接翻倍。
正确的做法是改成IN查询:
$ids_str = implode(',', array_map('intval', $sellids)); $db->query("SELECT * FROM destoon_sell WHERE itemid IN ($ids_str)");一次查询全部返回,然后在PHP里按原来的ID顺序重组数据。开销从20次查询变成1次,响应时间从2秒降到0.2秒,效果立竿见影。
这个问题的本质是N+1查询模型:先查了N条主记录,然后又为每条记录各执行一次查询。Destoon的标签模板里如果看到foreach里嵌数据库查询,基本都要警惕。
4.3 列表总数COUNT的烦恼怎么办
再说一个所有内容系统都会遇到的问题:列表页底部的“共N条记录”。这个总数在InnoDB里可太贵了,因为InnoDB没有像MyISAM那样维护一个准确的行数计数器,每次COUNT(*)都要老老实实扫行。
几百万行表上做COUNT(*),即便走了索引也要几百毫秒,做一次两次还行,每次列表页都做,服务器CPU直接起飞。更麻烦的是Destoon后台列表页通常会同时执行SELECT COUNT(*)和SELECT *,两条SQL加起来才是完整的页面请求。
怎么处理?我推荐三个方向,按成本从低到高:
第一,能不做精确计数就不做。前台列表页改成“上一页/下一页”模式,不显示总页数和总记录数,体验没差多少,数据库压力骤减。第二,用缓存维护计数。发布新供应信息时在Redis里对对应栏目的计数器INCR,删除时DECR,列表页直接读缓存值。第三,如果非要实时精确值,确保COUNT(*)走最小的覆盖索引。比如列表页查询条件是status=3,那COUNT(*)就能走idx_status_time索引,因为它只包含status和addtime两个字段,扫描的索引页比全表小得多。
4.4 多表JOIN的类型一致性细节
Destoon系统里最常用的关联查询是供应信息关联公司表,比如前台公司主页展示他的最新供应信息:
SELECT s.* FROM destoon_sell s INNER JOIN destoon_company c ON s.userid = c.userid WHERE c.catid = 25 ORDER BY s.addtime DESC LIMIT 10;这类JOIN本身没问题,但有一个非常隐蔽的坑:关联字段的类型和排序规则不一致。比如destoon_sell.userid是INT类型,destoon_company.userid也是INT类型,没问题;但如果有人在二次开发时把某个自定义关联字段建成了VARCHAR,关联时MySQL就会做隐式转换,索引失效,JOIN直接变成逐行全表扫描。
还有一个同样隐蔽的坑是字符集。如果一张表是utf8,另一张表是utf8mb4,JOIN时MySQL要先做字符集转换,索引同样无法使用。所以在建表时就要统一规范:核心关联字段类型一致,表字符集一致。Destoon 7.0默认是utf8,如果你新加了utf8mb4的表参与JOIN,就会出现这个尴尬问题。我建议直接把所有核心表统一到utf8mb4,兼容性和性能都有保障。
5. 缓存和Web服务器:给优化链路加上最后一道加速器
5.1 Destoon 7.0自带缓存机制的正确打开方式
索引、参数、SQL改写都做完了,下一步是上缓存。Destoon 7.0后台系统设置里自带缓存配置,支持文件缓存、Memcache和Redis。文件缓存就是往服务器磁盘写缓存文件,适合单机小规模,但缓存文件一多,目录查找和IO开销也不小。真正要上规模,我建议直接用Redis。
后台配置缓存方式为Redis之后,Destoon会自动把一部分热点数据存到Redis里。这里有个关键点:配置完缓存后,要到后台“更新缓存”一次,让系统重新生成缓存数据,否则新配置不生效。另外要注意Redis的淘汰策略,如果服务器内存有限,建议设置maxmemory,并选择allkeys-lru淘汰策略,避免Redis把整台机器的内存吃光。
5.2 缓存能解决什么,不能解决什么
这个问题我必须讲清楚,因为太多人把缓存当万能药。缓存最擅长解决的是“重复查询”问题:同一份数据被大量用户反复请求,第一次从数据库读,后续直接走Redis内存,数据库根本没压力。
但对Destoon的搜索场景——用户每次输入的关键词都不一样——缓存基本帮不上忙。每个关键词都是一条独立查询,缓存命中率几乎为零。这种场景下,MySQL的LIKE '%关键词%'再优化也有限,因为索引失效的本质是搜索需求本身需要全文索引能力,而不是关系型数据库的精确匹配。
所以遇到搜索卡顿,我的建议是别在MySQL一棵树上吊死。可以给Destoon接上专业的全文检索组件,把供应信息、公司名称等核心数据同步到搜索组件里,前台搜索请求直接打到搜索组件上,响应时间稳定在几十毫秒级别。这才是搜索秒开的正解。
5.3 PHP执行环境和OPcache:容易被忽略的加速项
数据库优化做得再漂亮,如果PHP执行环境本身太弱,页面照样快不起来。Destoon 7.0是PHP程序,每个请求都要执行成千上万行PHP代码。如果PHP版本还停留在5.x,性能确实拖后腿;如果用的是Apache的mod_php模式,Apache进程会占用大量内存,并发一高就和MySQL抢资源。
我强烈建议把PHP升级到7.4或更高版本,配合PHP-FPM运行。单看PHP 7对比PHP 5的基准测试,性能就能提升近一倍,这是在不动任何代码的情况下最划算的升级。然后开启OPcache,这一点经常被忽略:
[opcache] opcache.enable = 1 opcache.memory_consumption = 256 opcache.max_accelerated_files = 20000 opcache.validate_timestamps = 0memory_consumption=256这个值对Destoon这种规模的项目基本够用;validate_timestamps=0是让OPcache不检查源码文件是否有修改,直接使用缓存。注意这个选项适合正式环境,开发环境建议开启validate_timestamps=1,否则代码改了不生效。
5.4 Nginx和反向缓存:静态化与伪静态之外的选择
Web服务器层面,最理想的方案是Nginx配合反向代理缓存。Destoon支持生成静态页,但内容更新后需要重新生成,时效性不好控制。伪静态则只是URL好看,数据库压力一点没减。真正的平衡点是Nginx的fastcgi_cache,它把PHP动态请求的响应结果缓存起来,下一次同样的请求直接由Nginx返回,完全不经过PHP和MySQL。
配置思路大致是这样:对Destoon首页、栏目列表页这类变更频率不高的页面,可以设置缓存有效期为10到30分钟。会员中心、后台管理这类页面则必须排除掉,绝不能缓存,否则用户A的登录状态可能会被返回给用户B,那是严重事故。
实际配置的时候,通常在nginx.conf的http段定义缓存路径和缓存维度,然后在server段用location匹配规则区分缓存和直通。调试完成后,可以用curl -I查看响应头里的X-Cache状态,确认命中。这个配置能给网站带来质的飞跃,尤其是面向搜索引擎和不少重复访客的列表页,命中率可以做到非常高,MySQL侧的压力近乎归零。
6. 实测记录与踩坑提醒
6.1 一个典型案例的优化前后对比
前面讲的都是方法和原理,可能有点抽象。我拿一个实际处理过的项目来说说具体数字——当然,细节做了一些脱敏,思路是完全真实的。
那是一个B2B信息展示平台,Destoon 7.0,单台服务器配置是16GB内存、SSD磁盘,运行MySQL 5.7和PHP-FPM。核心表destoon_sell当时积累了约320万行数据,destoon_company约80万行。优化之前的体感非常糟糕:前台搜索“某种原料”这类关键词平均耗时3.8秒,后台供应列表翻到110页之后,页面加载超过12秒,运营基本没法工作。
优化过程就是按本文的顺序走一遍:
| 优化项 | 优化前 | 优化后 |
|---|---|---|
| 存储引擎 | MyISAM | InnoDB |
| innodb_buffer_pool_size | 默认128M | 10G |
| 核心表索引 | 主键+少量单列索引 | 复合索引覆盖栏目、状态、排序 |
| 后台深翻页SQL | 直接LIMIT 300000,20 | 延迟关联改写 |
| 缓存 | 文件缓存 | Redis缓存 |
| PHP | 5.6 + mod_php | 7.4 + PHP-FPM + OPcache |
最终实测结果是:前台关键词搜索结果从3.8秒降到0.5秒左右,即使搜索词不同出现缓存未命中,数据库侧也能在1秒内完成;后台列表翻到110页耗时从12秒降到0.6秒;整站高峰期MySQL CPU占用从90%以上回落到30%以下。单台服务器,几百万数据量,秒级响应,完全做得到。
6.2 翻车现场一:buffer pool调太大引发的swap
第一次给一台16GB内存的服务器调优,我把innodb_buffer_pool_size直接设成了12GB。从数字上看,数据量确实需要这么大,但我忽略了一件事:这台机器上还跑着PHP-FPM,默认的pm.max_children配置允许同时开几百个PHP进程。高峰期PHP进程占掉2GB内存,MySQL的12GB buffer pool加上连接线程、排序缓冲,整台机器内存直接耗尽,开始疯狂swap。
后来free -m一看,Swap使用了好几个GB,磁盘IO全部耗在换页上,站点假死。紧急把buffer pool调回10GB,同时调整了PHP-FPM的进程池上限,才恢复正常。这个教训很直接:调优不是只盯单个参数,而是要看整台服务器的资源预算。MySQL的内存规划,必须把业务进程的占用算进去。
6.3 翻车现场二:顺手删了一个“多余”索引
第二件让我记忆深刻的事,是我手动清理所谓“冗余索引”时闯的祸。当时看到destoon_sell表上同时有idx_catid和idx_catid_status两个索引,觉得前者完全被后者覆盖,就顺手删了。
结果第二天后台栏目列表查询从0.2秒直接掉回3秒。查了慢查询日志才知道,某条查询只用了catid一个条件做前缀匹配,而idx_catid_status因为第二个字段是status,在最左前缀规则下虽然也能用,但优化器在统计信息里选择了另一个更窄的索引路径,那条路径被我删掉了,于是全表扫描。
这次事故让我养成一个习惯:删任何索引之前,先用pt-index-usage或者至少观察几天慢查询日志,确认这个索引真的没有被任何一条查询使用。索引不是越少越好,而是“不多不少”才好,冗余的可以清理,但看起来冗余实际被依赖的,绝对不能手滑。
6.4 翻车现场三:打开query_cache以为能提速
早期做优化的时候,我也踩过查询缓存的坑。MySQL 5.7虽然还支持query_cache_type,但这东西在高并发读写下弊大于利。很多教程还在说“打开查询缓存能加速”,实际在Destoon这种读多写也多的场景里,每次表数据变动,所有关联该表的query cache条目都会被清空。写入越频繁,缓存失效就越频繁,还要额外维护缓存锁,反而拖慢整体性能。
我在测试环境试过打开query_cache_type=1,结果写入和读混合的业务场景下性能反而下降了10%,果断关闭。MySQL 5.7起官方都已经标注query cache是deprecated特性,到8.0直接移除了,大家也不要再踩这个坑。
6.5 优化之后的日常维护建议
优化不是一次性工作,做完就撒手不管,很快会被新需求、新SQL拖回原形。我建议的维护套路是:
第一,定期分析慢查询日志。每周跑一次pt-query-digest /var/log/mysql/mysql-slow.log,看Top 10慢SQL有什么变化,有没有新冒出来的问题。第二,监控几个关键指标。MySQL的Table_locks_waited、Innodb_buffer_pool_hit_rate、Threads_connected,这几个值能反映锁竞争、缓存命中率和连接压力。第三,历史数据归档。几百万行表里很多是两三年前的过期供应信息,按时间归档到独立的历史表,或者做分区表,让主查询面对的数据体量尽量精简。第四,代码评审时卡SQL规范。二次开发新增的SQL必须有EXPLAIN记录,不能上来就SELECT *加LIKE '%...%'。
我个人在实际项目中感受很深的一点是:Destoon 7.0在MySQL 5.7上要跑出百万级秒级响应,从来不是“换服务器”一劳永逸。慢查询日志先定位、配置参数做基线、索引按查询模式重建、SQL按需改写、缓存最后补一刀,这套组合拳打下来,单台机器完全撑得住。
最后再分享一个小习惯:每次优化完,把核心查询的SQL和EXPLAIN结果整理成一个文档,顺手把建索引语句记录到迁移脚本里。这样以后谁动了索引、谁改了SQL,都能对着记录快速看出问题。技术优化本身不复杂,难的是让它在后续不断迭代的代码里也保持下去。