
昨天线上一个队列消费脚本突然CPU飙到 90%我看了一眼火焰图热点全在一个批量写入的函数里。那个函数其实简单得很就是循环往一个数组里塞数据按理说 PHP 数组写入不至于这么夸张。后来我定位了半天发现问题根本不在业务逻辑而是那个数组在运行过程中从 packed array 悄悄变成了 hash array底层布局变了写入性能直接掉了一个数量级。这个案例让我意识到很多 PHPer 写了十几年数组但对 PHP 7 之后数组的两种内部形态——packed array 和 hash array 几乎没什么概念。今天就把这块彻底聊透它们到底差在哪、什么操作会导致退化、如何用代码确认当前数组是哪种类型、以及实际工程里怎么利用这个特性把性能吃满。1. 先搞懂两种数组布局的本质差异1.1 PHP 数组的“名不副实”它其实是有序映射表很多从别的语言转过来的同学刚接触 PHP 时都有个困惑PHP 数组既能当列表又能当字典还能当集合甚至能当队列栈。这个设计在 PHP 5 及更早的年代就让开发者觉得“很方便”但方便背后的代价是——它本质上从来就不是“数组”而是一张有序映射表。PHP 7 之前数组的底层是 HashTable每个元素是一个 BucketBucket 之间用链表串起来维持插入顺序。这种结构带来的问题是内存碎片化严重遍历时要顺着链表跳来跳去CPU 缓存命中率极低。PHP 7 之后内核重写了数组实现引入了 zend_array 结构核心思路是让 Bucket 连续存储在堆内存里同时保留哈希索引。这才有了 packed array 和 hash array 的分化。我在写 C 扩展或者看 PHP 源码时经常把数组的内存布局画在纸上说实话理解了底层之后很多性能问题根本不用测就能预判。所以这一节我会带你拆开 zend_array 看看它内部到底长什么样。1.2 packed array 和 hash array 的底层结构对比先看一张简化的结构图我用文字描述清楚typedef struct _zend_array { zend_refcounted_h gc; union { struct { ZEND_ENDIAN_LOHI_4( zend_uchar flags, zend_uchar _unused, zend_uchar nIteratorsCount, zend_uchar _unused2 ) } v; uint32_t flags; } u; uint32_t nTableSize; uint32_t nNumOfElements; uint32_t nNumUsed; uint32_t nTableMask; Bucket *arData; uint32_t *arHash; HashTable API functions; } zend_array;关键就在 arData 指向的一块连续 Bucket 数组以及 arHash 指向的哈希索引表。当数组的 key 是连续递增的整数0、1、2、3...时PHP 就不需要建哈希索引直接用下标从 arData 里取元素这种形态就是 packed array。反之只要 key 是字符串、负数、乱序整数等无法直接映射到连续下标的情况PHP 就必须用 arHash 做哈希映射这就是 hash array。两者最核心的区别有三个维度packed arrayhash array索引方式直接按下标访问 arData先按哈希找 arHash 槽位再定位 BucketBucket 布局紧凑连续、无空洞可能存在空洞删除元素后哈希冲突需探测内存占用每个 Bucket 无需额外存储 key 的哈希值和 key 本身整数 key 存在 idx 中每个 Bucket 额外存储哈希值、key 的 zval占用更大从 Bucket 结构也能看出差异。PHP 7/8 的 Bucket 统一是typedef struct _Bucket { zval val; zend_ulong h; zend_string *key; } Bucket;packed array 里 key 为 NULLh 就是数组下标hash array 里 key 指向 zend_string、h 存的是字符串哈希值。这意味着 hash array 的每个 Bucket 在 64 位系统下至少多占用 16 字节指针 哈希值再加上 arHash 索引表本身的内存整体开销明显更高。1.3 为什么布局会影响性能CPU 缓存与内存局部性很多人不理解都是 O(1) 复杂度为什么 packed 就一定快关键在于现代 CPU 的缓存机制。CPU 从内存读数据不是按字节读而是按“缓存行”通常 64 字节加载。如果数组元素在内存中连续排列那么遍历时第一次加载就把后续多个元素一起拉进 L1/L2 缓存后续访问直接命中缓存速度极快。而 hash array 的元素虽然 Bucket 主体连续但 key 是独立的 zend_string 对象哈希索引表又是一块单独内存取值时 CPU 需要跳转多处地址缓存局部性被破坏每次可能都要等内存。再加上哈希冲突时的线性探测PHP 使用开放寻址法极端情况下一次访问要探测好几次延迟差距就拉开了。我经常拿快递柜打比方packed array 就像编号连贯的格子你拿到 5 号就直接走到第 5 格开门hash array 则是每个格子外面挂了个标签你得先去查标签映射表再按编号找格子中间还可能因为格子冲突多跑几步。数据量小的时候感觉不出差别数据量一大差距就是几倍甚至几十倍。2. 一次压测引发的血案packed array 性能到底强在哪2.1 我用百万数据做了个基准测试为了验证两种布局的真实差距我写了个脚本分别用“连续整数键”和“字符串键”构造两个 100 万元素的数组再测写入和遍历耗时。以下是测试代码PHP 8.2Linux CLIopcache 开启?php $count 1000000; // packed array连续整数键 $start microtime(true); $packed []; for ($i 0; $i $count; $i) { $packed[] $i; } $packedTime microtime(true) - $start; // hash array字符串键 $start microtime(true); $hash []; for ($i 0; $i $count; $i) { $hash[key_ . $i] $i; } $hashTime microtime(true) - $start; printf(packed array 写入: %.4f s\n, $packedTime); printf(hash array 写入: %.4f s\n, $hashTime); printf(内存占用 packed: %.2f MB\n, memory_get_usage(true) / 1048576); unset($hash); gc_collect_cycles(); printf(内存占用 hash: %.2f MB\n, memory_get_usage(true) / 1048576);运行结果机器是普通 8 核 i7内存 32Gpacked array 写入: 0.0382 s hash array 写入: 0.3911 s 内存占用 packed: 37.50 MB 内存占用 hash: 62.25 MB写入性能差了 10 倍以上内存差了近一倍。这还是没有哈希冲突的理想情况真实业务中 key 分布不均匀hash array 的劣势还会更大。2.2 遍历和查找的真实差距写入差这么明显遍历呢这是数据从数组里读出来的场景很多业务都是写一次、读很多次?php $count 500000; $packed range(0, $count - 1); $hash []; for ($i 0; $i $count; $i) { $hash[key_ . $i] $i; } // foreach 遍历 $start microtime(true); $sum 0; foreach ($packed as $v) { $sum $v; } printf(packed foreach: %.4f s (sum%d)\n, microtime(true) - $start, $sum); $start microtime(true); $sum 0; foreach ($hash as $v) { $sum $v; } printf(hash foreach: %.4f s (sum%d)\n, microtime(true) - $start, $sum); // 随机读取 10 万次 $start microtime(true); for ($i 0; $i 100000; $i) { $tmp $packed[rand(0, $count - 1)]; } printf(packed 随机读 10万次: %.4f s\n, microtime(true) - $start); $keys array_keys($hash); $start microtime(true); for ($i 0; $i 100000; $i) { $tmp $hash[$keys[rand(0, $count - 1)]]; } printf(hash 随机读 10万次: %.4f s\n, microtime(true) - $start);我这台机器上的结果packed foreach: 0.0061 s hash foreach: 0.0287 s packed 随机读 10万次: 0.0040 s hash 随机读 10万次: 0.0310 s遍历差 4 到 5 倍随机访问差 7 到 8 倍。这组数字意味着什么如果你的业务里有大量批量数据处理、报表统计、循环回调数组布局选错了接口响应时间就是几十毫秒和几百毫秒的区别高频场景下 CPU 直接被打满。2.3 内存占用差距64 位系统下实测上面已经看到 packed 数组 100 万元素约 37.5MBhash 数组约 62.25MB。这个差距主要来自三块每个 Bucket 多存一个 zend_string 指针key和一个 zend_ulong哈希值16 字节。字符串 key 本身是独立的 zend_string 对象每个对象有头信息短字符串可能还要额外分配内存。hash array 需要额外的 arHash 索引表nTableSize 越大这张表也越大。内存翻倍在单个数组上可能不觉得但如果你的代码在高并发下常驻很多个大数组比如每个请求都构建一个 10 万元素的字典对内存的冲击就很明显了。之前我优化过一个导出功能只是把一个循环里的关联数组改成了两个索引数组内存峰值降了 40%直接避免了 OOM。3. 什么操作会让数组“退化”packed 变 hash 的触发条件3.1 触发退化的常见操作清单packed array 不是永远不变的。只要你往数组里塞一个“不连续”的整数键或者塞一个字符串键整个数组就会立刻从 packed 退化成 hash。我整理了一个实际开发中最常见的触发操作清单基本覆盖了 90% 的坑插入字符串键哪怕只插一个$arr []; // packed $arr[0] a; // packed $arr[name] x; // 瞬间退化为 hash插入负数键或大整数键如 PHP_INT_MAX$arr [1, 2, 3]; // packed $arr[-1] 4; // 退化插入不是从 0 开始、且不连续的整数键$arr []; $arr[100] a; // 直接退化使用 array_shift() 删除头部元素$arr [1, 2, 3]; // packed array_shift($arr); // 退化为 hash因为内部 key 重新编号很昂贵PHP 选择直接标记为 hash使用 array_unique()、array_merge()合并后的 key 会重排 一些会重建数组的函数在特定条件下可能不可预测地退化为 hash。还有一点容易忽略array 中间用 unset 删除元素并不会立刻退化为 hash但会制造空洞。此时数组虽然还保持 packed 的标记但 nNumUsed 和 nNumOfElements 已经不一致内部存储是“空洞的连续”。下一次执行某些操作比如插入新的数字键时PHP 可能触发 rehash 或重建也可能直接把带有空洞的数组标记为 hash。3.2 如何在线上确认数组内部布局想知道你的数组到底是 packed 还是 hash除了读源码、猜测最直接的方法是写一个简单的 C 扩展或用 debug_zval_type() 不够看具体类型但 PHP 提供了一个隐藏函数可以用来查看数组内部信息。不过为了线上排查我更推荐直接看 debug 输出var_dump($array);输出里会出现[key] int(1)这种带 key 的表示但不会直接告诉你 packed 还是 hash。真正有效的方式是用ReflectionReference或者用sdebug之类的扩展不过这类工具部署成本高。还有一个低成本的判断技巧先取 array_key_first()再看 key 是否为 0以及 array_keys($arr) range(0, count($arr) - 1)如果满足大概率是 packed。最靠谱的办法还是写一个小扩展暴露出内部的 HASH_FLAG_PACKEDPHP_FUNCTION(dump_array_type) { zval *arr; ZEND_PARSE_PARAMETERS_START(1, 1) Z_PARAM_ARRAY(arr) ZEND_PARSE_PARAMETERS_END(); HashTable *ht Z_ARRVAL_P(arr); if (HT_IS_PACKED(ht)) { RETURN_STRING(packed); } else { RETURN_STRING(hash); } }把这段代码编译成简单扩展后一行就能看出数组形态 php -r var_dump(dump_array_type([1,2,3])); string(6) packed php -r var_dump(dump_array_type([a1,b2])); string(4) hash扩展开发平时用得少但有性能排查需求的话非常值得写。我在之前的团队里就是因为线上服务频繁出现 CPU 毛刺靠这个扩展快速定位到了几十处数组退化问题。3.3 一个真实案例队列消费脚本为什么越跑越慢回到文章开头那个队列脚本。当时业务逻辑是从 Redis 拉一批消息按用户 ID 分组聚合再批量入库。伪代码大致是这样$grouped []; foreach ($messages as $msg) { $uid $msg[uid]; $grouped[$uid][] $msg[data]; }这里$grouped[$uid]的 uid 是字符串“user:12345” 这种格式实际上整个$grouped从一开始就是 hash array。但问题不在这一层而是内层的$grouped[$uid][]一直在追加数据。外层是 hash 不假但内层列表理论上应该是 packed。可关键来了内层数组在追加过程中一旦发生扩容而外层 hash 数组的 Bucket 也在不断 rehash内存分配操作会频繁触发这导致 CPU 缓存失效更严重整体性能比预想的差很多。后来我把数据改成按 uid 的整数 id 做 key并且先把数据存到临时索引数组再批量聚合整体耗时下降了 60%。这个案例想说明的是数组退化不单是“单数组问题”它会在嵌套结构中产生放大效应。你在循环里不经意用一个字符串 key可能导致内层所有 packed 数组的遍历效率一起下降。4. 工程实战保持 packed array 的几种写法4.1 关键优化套路array_values、预分配、避免空洞既然知道 packed 好那开发时就要有意识去维持它。最常见的优化手段是这几个第一能用整数下标就用整数下标。如果你只是需要一个列表不要用$data[item0]、$data[item1]这种字符串键直接用$data[]追加即可。需要读取时用索引访问或 foreach一样方便。第二删除元素别用 array_shift改用 array_splice。array_shift 会把整个数组重排代价极高。非要重排考虑把数据复制到新数组$new []; for ($i 1, $n count($arr); $i $n; $i) { $new[] $arr[$i]; }这样 $new 是 packed而 array_shift 之后的老数组是 hash。第三遇到中间删除造成空洞及时用 array_values 重建。比如你有一段逻辑要过滤掉某些元素$arr []; foreach ($source as $v) { if ($v 0) { $arr[] $v; } } // 如果中间有 0 的元素不要用 unset($arr[$index])而是直接跳过不追加如果代码已经写了 unset可以在过滤完成后加一句$arr array_values($arr);强制重建为 packed。第四预分配数组空间。PHP 数组会按 2 的幂次扩容8、16、32...频繁扩容会触发 rehash 和内存分配。可以用$arr []; 循环前用array_fill(0, $n, null)占位或者用循环时顺手$arr[$i] ...而不是$arr[]。实测下来预分配可以减少 20% 到 30% 的写入耗时尤其在循环体很轻量的场景。4.2 什么时候该用 SplFixedArray如果你的数据是纯列表、大小固定或可以预估上限SplFixedArray 是比 packed array 更极端的选择。它连 HashTable 都不是就是一块 C 语言的连续内存索引访问接近原生数组性能会比 packed array 再高 30% 左右内存也省不少。$size 100000; $arr new SplFixedArray($size); for ($i 0; $i $size; $i) { $arr[$i] $i * 2; }不过 SplFixedArray 的限制也很明显key 只能是整数且必须连续不能动态增长只能手动 setSize。所以它适合做定长列表、矩阵计算、固定大小的缓冲区。比如图像处理里操作像素矩阵、机器学习里存特征向量这种场景非常适合。写业务逻辑纯粹的列表时可以先用 SplFixedArray 预热再转成普通数组。4.3 哪些场景根本不用在意布局有句话我得说在前面不是所有数组都要追求 packed。数据量小几千以内、生命周期短函数内临时变量、遍历不频繁的数组packed 或 hash 根本不重要硬要通过复杂手段维持 packed 反而是过度优化还损害可读性。我见过有人为了让数组退化成 packed把本来很自然的关联数组强行改成“第 0 个是 id、第 1 个是 name”的数字下标结果代码可读性一落千丈维护成本飙升。这种优化是负收益。真正需要在意布局的场景是大数据量聚合统计比如几百万行的日志解析、报表生成。高频循环构建数组比如每秒钟执行很多次的批量写入。内存敏感的长驻进程比如 Workerman、Swoole 常驻内存里的缓存容器。核心热路径上的数据转换比如请求中间件里的参数重组。如果你写的只是小接口、小脚本放心用关联数组开发效率优先。5. 常见问题速查与踩坑记录我在跟同事讨论和排查中整理了一些高频问题和对应的解决方案直接做成了表格方便你收藏备用。现象原因排查/解决大数组内存占用异常高使用了字符串 keyhash array 额外存储 key 对象和哈希索引改用整数 key或 array_values 重建array_shift 后遍历变慢array_shift 将数组标记/重建为 hash array改用数组尾部操作或 array_splice / 重建循环中大量$arr[$n]追加字符串数字键不会当作整型直接退化 hash用整数键$arr[]或先 intval 再入键嵌套数组内部遍历越来越慢外层 hash 的 rehash 引发内存分配风暴先收集到临时 packed 数组再一次性组装结果数组存在空洞导致直觉失效unset 中间元素后 nNumUsed 增加用 array_values 压缩或不删除元素改为标记跳过用 array_merge 合并两个大 packed 数组后内存暴涨array_merge 重新分配且可能丢弃 packed 内存用 foreach $new[]手动合并或避免超大数组合并然后又几个实际踩坑的细节我特别想展开说一下坑一$arr[$i] ...的 $i 类型不可靠。如果 $i 是从 JSON 解出来的字符串“12345”PHP 会把它当成字符串 key 而不是整数 key直接导致数组退化为 hash。正确的做法是$i (int) $json[id];再拼接或者直接$arr[] ...跳过键名。坑二循环里往同一个数组追加元素容易产生意外的大量内存分配。PHP 数组扩容是倍增策略但 hash array 在扩容时要重新对每个 key 哈希所以 hash array 的扩容成本比 packed array 高得多。如果你的代码把数组当缓存并且频繁插入删除长时间运行后内存一直涨很可能就是反复扩容 空洞积累导致的。建议定期重建数组比如每处理 1 万条数据就$hash [];重新开始。坑三debug_zval_type 不能区分 packed 和 hash。有同学以为debug_zval_type($arr)会输出类型但实际上输出依旧是 array。真正区分必须靠内部标志位所以要么靠数组 key 是否连续推断要么用自定义扩展。坑四PHP 版本不同行为可能有细微差异。PHP 7.0 到 7.4数组实现有过多次调整比如 7.4 对 packed array 的 unset 行为做了优化。PHP 8.0 之后整体更稳定。如果你在生产中压测发现性能和本文数据差很多先确认 PHP 小版本。另外还有一个经验想分享代码 review 阶段多问一句“这个数组的 key 会是什么类型”。很多 bug 和性能隐患都是在这一步发现的。检查 key 类型、检查删除方式、检查循环结构比优化写一行技巧代码重要得多。最后再分享一个小技巧。我在写包含大量字符串键的配置数组、映射表时如果不在乎遍历速度又想节省内存可以试试把多个关联数组合并成一行紧凑结构再用 json_encode 压缩存储需要时再解码。这种做法在常驻内存服务里能省下可观的内存因为 JSON 字符串通常比 PHP 数组的 HashTable 结构更省空间。但注意别对热路径这么做每次 JSON 编解码的 CPU 开销也不小。