在SaaS后台中,用户搜索、订单查询、客户检索和日志筛选几乎是最常见的操作之一。数据量较小时,一条 LIKE '%关键词%' 查询通常感觉不到问题,但随着租户数量、数据规模和并发请求增加,搜索接口可能逐渐从几十毫秒变成数百毫秒甚至数秒。真正需要解决的并不是“LIKE语句慢”这么简单,而是搜索条件、索引结构、排序方式、分页策略以及多租户数据模型共同造成的查询成本。
最典型的问题是前导通配符。假设查询为 WHERE name LIKE '%张%',普通B-Tree索引通常无法像 LIKE '张%' 那样利用索引进行有效的范围定位,因为数据库不知道“张”会出现在字符串的什么位置。给 name 建立前缀索引,例如 name(5),也不能从根本上解决 %张% 的包含式搜索问题。前缀索引主要适用于需要利用字段开头部分进行索引定位的场景,而不是任意位置的关键词搜索。
因此,第一步不是盲目增加索引,而是先明确搜索需求。如果业务实际上只需要“以某个字符串开头”,可以使用 LIKE 'abc%' 并配合合适的B-Tree索引。如果需要搜索完整文本,则可以考虑MySQL FULLTEXT索引;对于中文等语言,还必须根据MySQL版本、解析器和实际分词需求验证搜索效果。FULLTEXT也不是所有 LIKE '%xxx%' 查询的直接替代品,两者的匹配语义并不完全相同。如果业务需要任意子串、复杂分词、拼写纠错、相关性排序或者跨字段搜索,专用搜索系统可能更加合适。
查询字段上还要避免不必要的函数转换。例如对索引字段执行 LOWER(name)、DATE(created_at) 等操作,可能使普通索引难以直接发挥作用。不过这也不是绝对规则,MySQL支持函数索引等机制时,可以根据具体版本和查询方式进行设计。真正判断索引是否有效,应该使用 EXPLAIN 或 EXPLAIN ANALYZE 查看实际执行计划,而不是仅凭SQL语句猜测。
模糊搜索遇到JOIN时,问题通常会进一步复杂。比如客户搜索同时要求匹配订单、联系人或者其他业务表,如果直接连接大量数据再进行过滤和排序,数据库可能需要处理远多于最终结果的数据。更有效的方法通常是先确定查询逻辑,再决定是JOIN、半连接、EXISTS、派生表还是其他方案,而不是简单认为“子查询一定比JOIN快”。现代MySQL优化器会对部分子查询和JOIN进行重写,因此两种写法最终可能得到相近甚至相同的执行计划。
分页也是搜索系统经常出现的瓶颈。LIMIT 10 OFFSET 100000 的问题并不是OFFSET本身违反了什么规则,而是数据库为了找到第100001条记录,通常仍然需要处理前面大量符合条件并参与排序的数据。随着页码越来越深,扫描、排序和回表成本可能不断增加。
对于“下一页”或者“加载更多”这种连续浏览场景,可以采用Keyset Pagination,也就是通常所说的游标分页。例如按照递增的 id 查询,可以使用类似 WHERE tenant_id = ? AND id > ? ORDER BY id LIMIT 50 的方式。这里的关键不是PHP代码里的 $last_id,而是数据库能够利用与查询条件和排序顺序匹配的索引,例如 (tenant_id, id)。如果按照 created_at 排序,则通常需要考虑 (tenant_id, created_at, id) 这样的复合索引,并把时间和唯一ID共同作为游标,避免多个记录拥有相同时间戳时出现重复或遗漏。
但游标分页并不能解决 %关键词% 本身的搜索成本。如果数据库每次仍然需要扫描大量记录才能找到符合条件的数据,那么把OFFSET换成游标并不会自动让模糊搜索变快。搜索条件和分页条件必须一起设计。
缓存同样不能成为模糊搜索的默认答案。Redis可以有效降低热点查询的数据库压力,但后台搜索关键词通常具有高度离散性,用户输入的每个关键词都可能不同。如果大量搜索请求都是一次性的,把每个关键词都写进缓存,可能只是在制造大量低命中率缓存数据。布隆过滤器也不是通用的“搜索加速器”,它更适合判断某个元素是否可能存在,不能直接替代数据库对复杂条件的搜索。
所谓缓存穿透、击穿和雪崩,确实需要在高并发系统中考虑,但应该根据实际缓存架构处理。例如对热点查询设置合理TTL、对空结果进行短时间缓存、对高价值热点采用请求合并或Single Flight机制,以及在缓存失效时避免大量请求同时回源。只有当搜索确实存在稳定的热点模式时,缓存才可能产生明显收益。
另一个常见误区是把普通SELECT搜索与数据库锁竞争联系起来。InnoDB普通一致性读通常不会因为多个用户同时执行 LIKE 查询就产生所谓的“行锁竞争”。真正可能产生锁等待的情况,更多来自 UPDATE、DELETE、INSERT、SELECT ... FOR UPDATE 等写入或锁定读,以及长事务和相关索引范围锁。单纯因为大量用户搜索,就建议把事务隔离级别降低到READ UNCOMMITTED,并不是合理的通用优化方案。
READ COMMITTED也不会产生脏读。它的主要特点是一个事务中的普通读取可以看到其他已经提交的修改,而不会读取尚未提交的数据。至于是否出现不可重复读、幻读等现象,则需要结合具体隔离级别、语句类型和事务行为分析。对于搜索接口,通常更重要的是避免不必要的长事务,而不是为了提高搜索速度随意改变整个数据库的隔离级别。
多租户SaaS还有一个比性能更重要的问题,就是数据隔离。共享数据库、共享表的架构中,搜索条件通常应该明确包含 tenant_id,例如 WHERE tenant_id = ? AND name LIKE '%张%'。这不仅是权限控制要求,也是索引设计的重要组成部分。如果查询经常按照租户和其他字段进行过滤,就应该根据实际访问模式设计复合索引,例如 (tenant_id, status, created_at) 或 (tenant_id, created_at, id)。不能简单地认为“tenant_id单独建索引就够了”。
对于大型SaaS系统,还需要考虑租户之间的数据倾斜。一个租户可能只有几千条记录,另一个租户却有数千万条记录。如果所有租户共享同一套查询路径,少数超大租户可能明显增加数据库负载。这时可以根据真实数据规模考虑分表、分库、归档、专用搜索索引等架构,而不是一看到多租户就立即进行水平分表。分区表也不能自动解决模糊搜索问题,是否采用应该根据分区键、查询条件、数据生命周期以及维护成本综合判断。
一个实际可行的优化过程,通常从执行计划开始。先使用慢查询日志和Performance Schema找出真正消耗资源的搜索SQL,再通过 EXPLAIN ANALYZE观察实际扫描行数、访问路径、排序、临时表以及回表情况。然后分别测试B-Tree、FULLTEXT、复合索引、Keyset Pagination或者搜索引擎方案,而不是一次性把所有优化技术全部加入系统。
例如一个SaaS客户列表如果主要按照 tenant_id + created_at + id 浏览,同时偶尔按照客户姓名进行前缀搜索,那么数据库索引可以分别围绕这两种访问模式设计;如果业务要求在姓名、公司名称、地址和备注中进行任意关键词搜索,则继续堆叠B-Tree索引往往不会得到理想结果,此时应该重新评估FULLTEXT或者OpenSearch、Elasticsearch等专用搜索方案。
性能优化最终需要用数据验证。不能因为某次测试从500毫秒降到200毫秒,就直接宣布“性能提升60%”,除非测试环境、数据量、并发数、缓存状态和测试方法都保持可比较。更有价值的指标包括P50、P95和P99延迟、QPS、扫描行数、磁盘IO、CPU、Buffer Pool命中情况以及数据库连接池使用率。对于SaaS系统,还应该观察不同租户规模下的查询表现,避免平均值掩盖超大租户造成的尾延迟。
PHP本身通常不是模糊搜索性能的第一瓶颈。真正决定系统能否稳定运行的,是搜索需求是否与数据结构匹配,数据库是否能够通过索引快速缩小候选集,以及排序和分页是否能够控制扫描范围。对于小规模数据,简单的SQL可能已经足够;当数据规模和搜索复杂度增加后,再逐步引入复合索引、全文搜索、Keyset Pagination、缓存或者专用搜索服务。
一个成熟的SaaS搜索系统,不应该追求“任何关键词都用一种SQL解决”。前缀搜索、精确搜索、范围筛选、全文搜索和任意子串搜索,本来就是不同的问题。先确定搜索语义,再选择索引和存储方案,最后通过真实执行计划和压力测试验证,通常比不断给数据库增加索引更加有效。
PHP SaaS 后台模糊搜索有哪些常见性能问题
图片说明:示意图 图片来源:Public Domain(公有领域)
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP