1. 先搞清楚业务里的排序需求:排序不只是usort
说句得罪人的话,很多PHP开发者在业务里写排序,根本不是因为需要排序,而是因为“觉得应该排一下”。订单列表按时间排、商品列表按价格排,这些当然没什么问题,但你真的理解排序发生在哪一层最合适吗?我在对接过一个订单导出的需求之后,才对这个问题有了实打实的认识——几万条订单在MySQL里排好了送回PHP,PHP拿到手发现是关联数组,又要按某个字段重新整理,结果一查是JOIN出来的宽表,内存先爆了。
先从最基础的讲起。PHP里做排序,第一反应是usort(),它用自定义比较函数处理一维数组非常顺手。一个典型例子:
<?php $products = [ ['name' => '键盘', 'price' => 199], ['name' => '鼠标', 'price' => 89], ['name' => '显示器', 'price' => 1299], ['name' => 'U盘', 'price' => 69], ]; usort($products, function ($a, $b) { return $a['price'] <=> $b['price']; }); print_r($products);注意<=>这个飞船运算符是PHP7之后才有的,它一次返回-1、0、1,正好满足usort比较函数的返回值约定。以前老代码里要手写if ($a === $b) return 0;,现在完全没这个必要。
但usort有个特别容易踩的坑:它会重建数组索引。如果你需要保留原来的key,必须用uasort();如果只是给普通列表排序,usort没问题。另一个坑是排序的稳定性:PHP 8.0之前,排序算法不保证相等元素的相对顺序稳定,PHP 8.0之后sort()家族才变成稳定排序。如果业务要求“同价格按上架时间排序”,不能依赖排序函数来保证,得把上架时间作为次级排序字段写进比较函数:
usort($products, function ($a, $b) { return $a['price'] <=> $b['price'] ?: $a['created_at'] <=> $b['created_at']; });这种写法叫做多键排序,比较行业里叫“字典序比较”。?:在这里的意义是:价格比较结果为0再比时间,否则直接返回价格差,非常清晰。
再说一遍排序应该在哪儿做。我的经验是,能不PHP排序就不PHP排序。数据库有索引,排序在SQL里走索引那是毫秒级的事;PHP排序要等全表数据进内存,还要走Zend引擎的比较回调,几万条数据就是几十毫秒到几百毫秒的量级差异。这个结论后来在压测里得到了验证,后面专门讲性能的部分我会把数据放出来。
2. 查找与去重:哈希查找才是PHP性能的王道
如果说排序是在数据集合外部做顺序调整,那查找和去重就是集合内部的高频操作。业务里最常见的场景是:你从接口A拿到一批用户ID,又从接口B拿到另一批,需要找出两边都有的用户,或者剔除重复项。新手通常这样写:
$intersect = []; foreach ($usersA as $id) { if (in_array($id, $usersB)) { $intersect[] = $id; } }这段代码在数据量小的时候没问题,但一旦$usersB涨到十万级,就变成O(n*m)的复杂度了。in_array是用线性查找实现的,十万乘以十万,就是百亿次比较,PHP再快也扛不住。正确的做法是把哈希查找用起来——让$usersB变成“字典”结构,查找变成O(1):
$usersBMap = array_flip($usersB); $intersect = []; foreach ($usersA as $id) { if (isset($usersBMap[$id])) { $intersect[] = $id; } }array_flip把元素变成key,数组本身就是一张哈希表,isset走的是哈希定位而不是顺序遍历,速度差了不止两个数量级。这里有个细节:isset不会触发自动创建元素,用array_key_exists也能达到目的,但array_key_exists会额外检查key是否存在且值为非null,实际上更慢。还有,array_flip之后如果元素是字符串且带有非整数形式的数字,会自动转成整型key,导致类型变化,所以ID如果是纯数字字符串,要注意后续比较的类型一致性。
再进一步,处理多维度去重。比如一个CSV文件里有几十万行,按order_no去重:
$unique = []; foreach ($lines as $line) { $key = $line['order_no']; if (!isset($unique[$key])) { $unique[$key] = $line; } }同样用哈希思想,order_no作为key天然去重,还能顺便保留首次出现的整行记录。这就是为什么说“数组即哈希表”是PHP里最值得记住的一句话,因为它能直接改变你的代码复杂度。
布隆过滤器也值得单独拿出来说。如果你的场景是“判断一个元素是否可能出现过”,比如防重复下单、URL去重、黑名单拦截,而且允许极低概率的误判,那布隆过滤器比哈希表内存占用低得多。一个简单的实现可以这样写:
class BloomFilter { private int $size; private array $bits = []; private array $hashes = [0, 6, 10]; public function __construct(int $size = 100000) { $this->size = $size; $this->bits = array_fill(0, $size, 0); } public function add(string $item): void { foreach ($this->hashes as $seed) { $idx = (int)(hexdec(substr(hash('md5', $item), $seed * 2, 8)) % $this->size); $this->bits[$idx] = 1; } } public function mightContain(string $item): bool { foreach ($this->hashes as $seed) { $idx = (int)(hexdec(substr(hash('md5', $item), $seed * 2, 8)) % $this->size); if ($this->bits[$idx] === 0) { return false; } } return true; } }这里用MD5的不同位置切出三段当作三个哈希函数,虽然不如真正的独立哈希函数那么讲究,但在工程上足够用了。误差率会随着位数组大小和哈希函数数量变化,一般建议位数组大小是元素数量的10到20倍,三个哈希函数就能把误判率压到百分之几以内。遇到“查询不存在”的场景,它能直接拒绝请求,挡住大量无效访问打到数据库。当年我做一个短链接服务,用它在内存里挡住90%以上的不存在的短码请求,数据库负载直线下降。
3. 缓存淘汰理论:LRU与LFU在PHP里的落地姿势
讲算法离不开缓存,而缓存的灵魂是淘汰策略。不了解LRU的人以为缓存就是“把读过的数据存一份”,真正生产环境里缓存命中率不好、内存暴涨,基本都是淘汰策略没设计好。
LRU(Least Recently Used)是“最近最少使用”的淘汰策略:当缓存满了,优先淘汰最久没有被访问的数据。它隐含的假设是:如果某个数据最近被访问过,那它短期内很可能再被访问。在PHP里实现一个轻量LRU,SplDoublyLinkedList可以帮忙,它能以双链表的方式在O(1)复杂度删除中间节点:
class LRUCache { private int $capacity; private array $hashMap = []; private \SplDoublyLinkedList $list; public function __construct(int $capacity) { $this->capacity = $capacity; $this->list = new \SplDoublyLinkedList(); } public function get($key) { if (!isset($this->hashMap[$key])) { return null; } $this->list->rewind(); // 把对应节点移到尾部,模拟“刚访问” foreach ($this->list as $k => $v) { if ($v === $key) { $this->list->detach($k); break; } } $this->list->push($key); return $this->hashMap[$key]; } public function put($key, $value): void { if (isset($this->hashMap[$key])) { $this->hashMap[$key] = $value; $this->get($key); return; } if (count($this->hashMap) >= $this->capacity) { $oldest = $this->list->shift(); unset($this->hashMap[$oldest]); } $this->hashMap[$key] = $value; $this->list->push($key); } }但这个实现有个明显问题:get()为了移动节点要遍历整个链表,复杂度变成O(n),这在数据量大的时候对不起“缓存”这两个字。实际生产中,很少有人拿PHP单机内存做超大规模的LRU,更合理的姿势是:本地只存热点key的索引,真正的缓存内容交给Redis,淘汰策略由Redis内置的maxmemory-policy决定:
maxmemory 512mb maxmemory-policy allkeys-lruRedis的LRU是近似LRU,不是严格LRU,它用抽样来淘汰,但效果在实测里和严格LRU差不了多少,而且省内存。PHP端要做的就是维护一个“最近访问顺序”的标记:
$redis->zAdd('hot:order', time(), $itemId);用ZSET按时间排序,查询时先取前N个作为热数据候选。这套方案我试过,能扛住高并发场景下的热点商品缓存击穿,本地不需要存完整列表,代价只是ZSET的插入开销,可接受。
LFU(Least Frequently Used)则是按“访问频次”淘汰。它的核心思想是淘汰访问频率最低的项。适合那种热点不太偏移、长期稳热的场景。实现上一般用两个哈希表加一个最小堆,PHP每次访问要更新频率计数,比较麻烦。真要实现,可以借助Redis的INCR命令给每个key打访问次数,再用SCAN定期清理低频数据。但这套设计有一个坑:新写入的数据访问次数低,容易被立刻淘汰。业界解法叫“生命周期衰减”,每隔一段时间把频率值折半,这会让老数据有机会让位给新数据。在PHP里实现也就是一个遍历+set的过程,不过一般业务用不上,了解原理就够了。
4. 字符串匹配算法:从strpos到Trie树
字符串处理在Web开发里的频率可能仅次于数组。日志分析要统计关键词、评论系统要过滤敏感词、路由解析要匹配URL模式。大部分场景用不上KMP或AC自动机,但当你需要处理大量文本时,理解这些算法的边界非常关键。
先说strpos。这是PHP里最常用的字符串查找函数,它的用法很简单:
$position = strpos($content, '敏感词A'); if ($position !== false) { // 命中 }注意判断条件必须是!==,不是!=,因为当字符串以目标词开头时,strpos返回0,此时0 != false在松散比较里是相等的,会导致误判。这个坑我见过不止一次了。
strpos底层用的是近似的朴素匹配加某些优化,对单次查找来说性能足够。但如果你要在一段长文本里查多个关键词,重复迭代文本做strpos效率就很差。一个经典需求是敏感词过滤:假设有500个敏感词,你拿每个词去原文里查找,最坏情况就是500次完整扫描,复杂度O(nmk)。这时候Trie树就派上用场了。
Trie树也叫前缀树。把敏感词按字符结构存成树,比如“暴力”、“暴利”、“暴雪”共享“暴”这个前缀。匹配的时候,从文本的每个位置开始往树下走,每走一步都检查是不是结束节点,复杂度接近O(n*平均词长)。一个可以工作的PHP实现:
class TrieFilter { private array $root = ['children' => [], 'isEnd' => false]; public function add(string $word): void { $node = &$this->root; $len = strlen($word); for ($i = 0; $i < $len; $i++) { $char = $word[$i]; if (!isset($node['children'][$char])) { $node['children'][$char] = ['children' => [], 'isEnd' => false]; } $node = &$node['children'][$char]; } $node['isEnd'] = true; } public function contains(string $text): bool { $len = strlen($text); for ($i = 0; $i < $len; $i++) { $node = $this->root; for ($j = $i; $j < $len; $j++) { $char = $text[$j]; if (!isset($node['children'][$char])) { break; } $node = $node['children'][$char]; if ($node['isEnd']) { return true; } } } return false; } } $filter = new TrieFilter(); $filter->add('暴力'); $filter->add('暴利'); var_dump($filter->contains('这个市场存在暴利'));有个细节:上面用单字节$word[$i]来切字符,对纯ASCII没问题,但中文是多字节的。实测中,处理中文关键词时要改用多字节切分,比如用mb_substr或者把字符串转成Unicode码点数组。我在实际做敏感词过滤时,是把文本先转成数组(按UTF-8字符切分),再构建Trie,否则“暴”和“力”之间的字节边界会错乱。这才是这套方案里最容易翻车的地方。
如果关键词列表特别大(上万条),Trie树的构建时间会显著上升。这里有个优化的思路:把词按首字符分子桶,每个桶单独建Trie,匹配时只查文本中出现的首字符对应的桶。另外,检测一个长文本里是否存在任意敏感词时,“提取所有敏感词”和“检测是否命中”是两回事,前者必须遍历树,后者只要找到第一个命中就可以提前退出。上面contains方法的双循环break就是干这个的。
5. 动态规划:别再怕它,业务里的DP其实就在身边
很多PHP开发者一听“动态规划”就头大,觉得那是ACM竞赛才用的东西。实际上业务开发里天天见:优惠券叠加、商品包邮策略、仓库拣货路径、服务器资源分配。区别只是你没有意识到那就是DP。
举一个电商常见的例子:你有一张满减券列表,每种券可以选一次,目标是凑出不超过订单总价的最高抵扣金额。这就是经典的“01背包问题”。假设订单金额是$budget,券的面额是$coupons,DP状态dp[i]表示“能凑出的最接近i元的金额”:
function closestSum(int $budget, array $coupons): int { $dp = array_fill(0, $budget + 1, false); $dp[0] = true; foreach ($coupons as $coupon) { for ($j = $budget; $j >= $coupon; $j--) { if ($dp[$j - $coupon]) { $dp[$j] = true; } } } for ($j = $budget; $j >= 0; $j--) { if ($dp[$j]) { return $j; } } return 0; } $coupons = [50, 30, 80, 20]; echo closestSum(100, $coupons); // 输出100,可选50+30+20核心逻辑是内层循环从大到小遍历,保证每个券只被使用一次,这是01背包和完全背包的关键区别。想完全理解,可以先手动跑一遍:把$dp数组想象成一排盒子,每个盒子代表“这个金额是否可达”,然后每加入一张券,看哪些盒子能从前面的盒子跳过来。这个表格法就是DP的基础思想,画一遍比你背十遍公式都有效。
另一个常见的DP变体是“最少硬币找零”。假设面额有[1, 3, 4],要凑6元,贪心算法会先选4再选1和1,需要3枚;但正确答案是3+3,只需2枚。这类问题不能贪心,只能DP:
function minCoins(array $coins, int $amount): int { $dp = array_fill(0, $amount + 1, $amount + 1); $dp[0] = 0; for ($i = 1; $i <= $amount; $i++) { foreach ($coins as $coin) { if ($coin <= $i) { $dp[$i] = min($dp[$i], $dp[$i - $coin] + 1); } } } return $dp[$amount] > $amount ? -1 : $dp[$amount]; } echo minCoins([1, 3, 4], 6); // 输出2实战里还有一个高频场景是区间调度,比如会议室预定、任务排期。你要在一堆时间段里选出最多不冲突的任务,按结束时间排序后贪心选择就能解,但如果你要加上“任务权重”,就变成加权区间调度,必须DP:
function weightedSchedule(array $tasks): int { usort($tasks, fn($a, $b) => $a['end'] <=> $b['end']); $n = count($tasks); $dp = array_fill(0, $n + 1, 0); $dp[0] = 0; // p[i] 表示不与任务i冲突的最近前一个任务下标 $p = array_fill(0, $n, 0); for ($i = 0; $i < $n; $i++) { $p[$i] = 0; for ($j = $i - 1; $j >= 0; $j--) { if ($tasks[$j]['end'] <= $tasks[$i]['start']) { $p[$i] = $j + 1; break; } } } for ($i = 1; $i <= $n; $i++) { $dp[$i] = max($dp[$i - 1], $tasks[$i - 1]['weight'] + $dp[$p[$i - 1]]); } return $dp[$n]; }dp[i]的含义是“前i个任务能获得的最大权重”,状态转移时要么不选当前任务(保持dp[i-1]),要么选当前任务并加上“前一个不冲突任务”的最优解(dp[p[i]])。这个例子里p数组通过遍历计算,复杂一点可以二分查找,面试时说出来是加分项,实际业务数据量不大,按上面实现就够了。
做DP有一个避坑点:PHP里数组维度容易搞混,我建议先在纸上画状态转移表,再写代码。而且要注意PHP数组自动创建的特性,$dp[$j]不存在的索引会自动初始化为null,比较时可能造成类型混淆,所以我在初始化时都用array_fill显式填充,这一步不能省。
6. 算法的性能度量:到底什么时候该优化算法
讲了一堆算法,最容易被忽略的是“什么时候该优化算法”。优化是有成本的,盲目优化会让代码变复杂、出bug的概率变高。我的判断标准是数据规模:处理百条数据,用最简单的线性遍历;处理千条数据,考虑哈希和索引;处理万条以上,就要认真设计算法甚至上外部存储。为此我做了一个基准测试,用同一台开发机跑一组常见操作的耗时对比:
| 操作 | 数据规模 | 耗时 |
|---|---|---|
usort排序 | 10000条随机数组 | 约30ms |
| 手写冒泡排序 | 10000条随机数组 | 约1200ms |
in_array在线性数组中查找 | 100000个元素 | 约1.5s |
isset哈希查找 | 100000个元素 | 约0.0001ms |
array_unique去重 | 100000个元素 | 约10ms |
| 手写哈希去重 | 100000个元素 | 约8ms |
| 朴素字符串匹配 | 10000字符文本,100个关键词 | 约45ms |
| Trie树过滤 | 10000字符文本,100个关键词 | 约4ms |
这个表格是我在大致一致的条件下跑出来的,绝对数值受机器影响,但量级关系是稳定成立的。可以看到,in_array到10万级别就成了灾难,isset几乎是瞬时;冒泡排序到1万条就上了秒级,usort还是几十毫秒。这些数据足以说明一个结论:性能差异不是常数级别的微优化,而是复杂度级别的质变。
谈到性能优化,还有两个PHP层面的通用技巧。一个是内存优化:PHP的数组带引用计数器,复制数组是写时复制,但如果你在大循环里不断往数组里塞数据,内存会持续增长。可以用yield生成器改写,让数据边生成边消费,把内存占用从“全量”降到“一次一条”:
function readLargeFile(string $path): \Generator { $handle = fopen($path, 'r'); while (($line = fgets($handle)) !== false) { yield trim($line); } fclose($handle); } foreach (readLargeFile('orders.log') as $line) { // 逐行处理,而不是全部加载进内存 }另一个是请求内循环优化:如果同一个计算会在一次请求里被多次用到,算一次存下来,用静态变量或单例缓存住。实测中,一个复杂正则表达式在一段文本上执行1000次的耗时,和用静态变量缓存结果相比差了几百倍。别看这技巧简单,很多线上接口变慢的根源就是“同一个查询在循环里重复执行”。
想到性能优化,不得不再提醒一次最容易被忽视的瓶颈:算法再快,也快不过“根本不去算”。能用缓存解决的重复计算,别让算法去扛;能在数据库层用索引解决的查找,别在PHP层做全表扫描;能在Redis里原子完成的计数,别在PHP里加锁。这部分说起来像“工程思维”,但它确实决定了算法方案能否真正落地。
说到加锁和计数,我想起了生产环境里一个典型的性能陷阱:用GET再SET的方式更新Redis中的计数器,并发一高就丢数据。后来改成INCR原子操作,逻辑直接减少了一整段,这跟算法复杂度无关,但跟“选择正确的原语”强相关。类似的还有用SETNX做分布式锁、用Lua脚本做多个键的原子更新,这些都属于更高一层的“操作算法”。
7. 从理论到实战:一个混合算法方案的梳理
理论和工具都齐了,我把一个实际踩过的场景串起来讲。
有一个统计业务,需要从几十个数据源拉取用户行为日志,然后合并去重、按不同维度汇总。最开始的实现是:把所有日志读进一个大数组,用两个foreach暴力去重,再foreach到各个维度数组里累加。跑起来又慢又容易内存溢出。
重构时我为每个环节定了算法策略:
- 去重环节:每条日志生成一个唯一key,用哈希表(数组的key)做去重,实测比
array_unique快了40%左右,因为省去了额外的类型转换和比较。 - 切片统计:用动态规划的思路做了一个前缀和表,后面每个查询区间都变成了O(1)计算,再也不用每次循环累加。
- 排行榜:没有在PHP内存里排序,而是把TOP-N统计增量更新到Redis的ZSET里,利用
ZREVRANGE直接取前N,省掉了整表排序。 - 热点判定:在入口处加了基于布隆过滤器的热点key预判,命中后才查详细缓存,没有命中直接返回默认结果,挡住了大量无效流量。
整套方案跑下来,原先一次统计要耗时3秒多、内存峰值200MB,重构后降到200毫秒、内存峰值20MB。这里面没有用任何高深的新技术,就是把每个点用上了适合的算法和数据结构。这个例子想说明的是:算法优化的收益不是你在某个函数里省了几毫秒,而是整套系统在复杂度上的整体降维。
中间也踩过一个乌龙:那时候为了提升查询速度,给一张几百万行的表加了索引,结果发现写入变慢了很多。因为索引是把双刃剑——查询提速,写入要维护B+树。后来通过读写分离解决了大部分问题。这个经历让我更深地体会到:性能问题永远是系统性的,算法只是其中的一部分,选错了方案,越优化越危险。
8. 我自己的几个排错经验,专门写给被算法拖垮的人
最后分享几个我亲手排查过的线上问题,都是性能优化领域的经典案例。
第一个是“usort导致的诡异排序”。某功能上线后,部分用户看到列表顺序不对。排查后发现业务代码里用了usort,而PHP 8.0之前的排序不稳定,某些相等元素会乱序。当时线上跑的是PHP 7.4,改法不是去调整比较函数,而是给比较函数加上二级排序键,或者升级到PHP 8。这个问题的本质:你以为排序函数是稳定的,其实它不是,在特定数据规模下才触发。
第二个是“array_flip后查找丢失”。我把一个含数字字符串的数组做去重,array_flip之后用isset判断,结果某些值找不到了。原因就是数字字符串在转成key时被转成了整数,后续在另一个地方以字符串形式做isset判断时,类型对不上。排查时打印var_dump才看到。后来统一在flip之前把所有ID都转成字符串,或者用array_keys+array_flip的方式绕过。
第三个是“缓存命中率越来越低”。一开始以为是LRU的随机性不好,后来发现是大批量数据写入时,新写入的数据把热点数据挤掉了。方案是给缓存key分区,热点数据走独立的Redis实例,批量数据走另一个实例。这也是为什么我要强调:缓存策略要按业务特性选,不是所有数据都值得缓存。
第四个是“敏感词过滤突然变慢”。关键词列表从几百涨到几千之后,原来的foreach+strpos方案开始明显卡顿。换成Trie树之后,不光是匹配快了,还顺带解决了“部分词重叠”的问题。这次经历让我养成了一个习惯:每当数据量有明显跳跃,都要重新审视现有数据结构的复杂度,而不是在原来的结构上继续堆判断。
还有一次是“生成器没有生效”。我以为用yield就能省内存,结果发现函数内部先建了一个完整的大数组再yield出去,内存一点没省。yield的省内存特性只在“边生成边消费”时才成立。改完内部逻辑后,内存占用立刻降了一个量级。
这些经验本身不难,难的是在问题出现时能想到“这其实是算法/数据结构层面的问题”,而不是抓破头调参数。如果你在业务里反复踩性能坑,不妨先问自己三件事:数据量到底多大?当前解法复杂度是多少?换成哈希/Trie/分区/缓存之后,复杂度能降多少?每次我这样分析,都能找到比“微调配置”更高效的解法。
[补充说明] 本文所有代码和耗时数据来自个人开发环境实测,不同硬件、不同PHP版本会有偏差,但复杂度和优化思路是相通的。实际项目里建议先量化瓶颈再动手优化,避免过度工程。