Press "Enter" to skip to content

Yac 3.0 发布:常见缓存负载吞吐提升 42%

Yac 2.4.0 发布时,我做的重点是小值:null、布尔值、小整数、短字符串、空数组,能不进入 values 区就不进去。

当时叫 embedded。做法很简单:slot 里的 val 本来是一个指针,8 字节对齐,低 3 bit 永远空着,拿它们做类型标记,剩下的 bit 放数据。这样一个小值不必分配 block,不必写 values 区,也不必再拷贝一次。

但这个方案有个硬伤:扣掉类型标记后,val 只剩 61 bit payload。整数够用,true/false/null 更没问题,短字符串也能塞一点;double 不行。

double 要保留完整的 IEEE 754 64 bit。少 3 bit 就不是原来的数了,不能为了省一个 block 把 3.1415926 读回来变成别的东西。2.4 的 embedded 因此只能让 0.0 走特例,其他 double 还是得进 values 区。

我后来又想了一下:Yac 的 key 上限是 48 字节,但实际 key 大多数根本用不满。比如 foo、alloptions、is_blog_installed,后面还空着一大截。既然 slot 已经固定分了 48 字节给 key,为什么不把足够短的 key 后面那段空间拿来放 value?

于是有了 3.0 的 inline。

Short values Inline

inline 不是再往 val 里硬塞 bit,而是把 value 紧跟在 key 后面放进同一个 slot。只要:

strlen($key) + strlen($value) <= 48

就不用进 values 区。

这样除了短字符串,double 也能 inline:

$yac->set('pi', 3.1415926);     // key 很短,8 字节 double 直接放 slot 尾部
$yac->set('status', 'ok');      // 短字符串也一样
$yac->set('feature:new_checkout', true);

key 越短,留给 value 的空间越大;key 恰好 48 字节,inline 就没有空间,照旧分配 values block。这个限制很自然,也不用改变 slot 布局。

带来的收益和 embedded 一样:少一次分配,少一次拷贝,少占一点 values 内存。对 WordPress 特别有用,短 option、短状态、空结果之类的东西不会再挤占真正热数据的位置。

3.0.0 继续往下做,另一个重点是读路径。

在 16 个 worker、100:1 读写比、160MB 共享内存、关闭压缩的测试里,Yac 3.0 相比 2.4.2,在 6B 到 8KB 的常见值范围内,几何平均吞吐提升 42.3%;布尔值、整数、开关和短值这类常见 PHP 负载,最高能到 95.4%。

这里说的是直接压缓存的 aggregate ops/s,不是页面速度提升 42%。一个页面里还有 PHP、数据库、模板、网络和一堆插件,缓存只占一部分。这个先说清楚,免得标题又被理解成“博客页面快 42%” 😂

Versioned Snapshot

Yac 的 key 区是一张共享 hash 表,每个条目一个 slot。以前读一个 slot 时,除了取 key 和元数据,还要经过一次协调流程;3.0 把它换成了 versioned snapshot。

这个思路来自 Linux 内核常用的 seqcount/seqlock:writer 更新前把 sequence 改成奇数,完成后改回偶数;reader 前后各读一次 sequence,中间拷贝数据。两次读到的数相同且为偶数,说明这次拿到的是完整快照;否则重试。

seq1 = read_seq();
if (seq1 & 1) {
    retry;
}

copy_slot();
seq2 = read_seq();

if (seq1 != seq2) {
    retry;
}

PHP-FPM 的典型场景本来就是读远多于写。没有写入相撞时,reader 不需要做多余的协调动作;真的撞上了,也只是重读 slot,不会返回半截 key、TTL 或 value metadata。

这个改动本身并不花哨,但它是 3.0 最主要的来源之一。

Epoch-based CRC

Yac 的 values 区会循环复用。一个 reader 拿着 value block 读数据时,另一个 worker 可能正在让某个 segment 进入下一轮使用。

以前,Yac 读 block 时会做 CRC-32C 校验,宁可把可疑数据当 miss,也不把错误的数据交给 PHP。这个行为不能因为性能优化就删掉。

3.0 加了 epoch/segment state。reader 能证明 block 在整个读取期间都稳定时,就不算 CRC;证明不了时,仍然走 CRC fallback。

也就是说,快路径少做事,边界情况照样验。

New serializer

之前的Yac用php serialize, 或者json_encode来做序列化,但是这些通用的方法要考虑序列化的结果要能导入导出,要能易读。

考虑到Yac的场景,序列化少,反序列化多,并且不需要是易读,索性我就在Yac 3.0,做了一个Yac自己的serializer,在反序列化追求更好的性能。

支持的 array 和 stdClass,包括内容可支持的引用值,会用 Yac 内部 serializer;不支持的对象和值,仍然回退到 PHP 原生序列化。

能快的走快路,不能快的照旧。

以前的 yac.serializer、JSON、MsgPack、igbinary 三套可选 serializer,以及相应的编译选项,在 3.0 里都没了:

; 3.0 起删除,不要再配置
; yac.serializer = igbinary

Yac::has(),以及属性判断

3.0 新增了 Yac::has():

$yac = new Yac();
$yac->set('flag', false);

var_dump($yac->has('flag'));    // bool(true)
var_dump($yac->has('missing')); // bool(false)

false、null 都可以是正常缓存值,用 get() 判断“到底有没有”总是不够顺手。现在直接用 has() 就行。

更重要的是,它不更新 atime 和 hits。想做存在性判断,就别顺手把条目搞成“刚被访问过”。

属性形式也一样:

$yac->set('n', null);
$yac->set('f', false);
$yac->set('e', '');

isset($yac->n);                 // false
isset($yac->f);                 // true
empty($yac->n);                 // true
empty($yac->f);                 // true
empty($yac->e);                 // true
property_exists($yac, 'n');     // true
property_exists($yac, 'none');  // false

从 3.0 开始,这些检查也不会影响 LRU 信息。

dump() 多了几本账

dump() 是我自己排查缓存时最常用的东西。3.0 里它多了三个字段:

  • kind:值类型;
  • loc:值放在哪里;
  • enc:值用什么方式编码。
Array
(
    [kind] => 5
    [loc] => 1
    [enc] => 0
    [key] => foo
)

loc 里,YAC_LOC_BLOCK 是 values 区的 block,YAC_LOC_VALWORD 是 slot value word,YAC_LOC_INLINE 是 slot 内的短字符串。

enc 里,YAC_ENC_NONE 表示没有序列化,YAC_ENC_PHP 表示 PHP serializer,YAC_ENC_YAC 表示 Yac 内部 serializer。

老的 embedded 字段停在 2.4.2,3.0 看 loc 就够了。

还有一个小变化:带 prefix 的实例,dump() 现在返回调用方实际传入的 key,不再带内部 prefix。

$yac = new Yac('app:');
$yac->set('foo', 'bar');

// 3.0 dump() 里:
// [key] => foo

性能

测试环境:MacBook Pro M5 Pro、PHP 8.5、16 个 worker、100:1 读写比;Yac/APCu 均使用 160MB 共享内存;压缩全部关闭。数字是所有 worker 的 aggregate ops/s,不是单进程跑出来再相加。

Value size Yac 3.0 APCu 5.1.28 Memcached 3.4.0 Yac / APCu Yac / Memcached
6 B 129.5M 1.19M 0.105M 108.4x 1237.9x
64 B 99.1M 1.23M 0.103M 80.6x 964.8x
1 KB 39.4M 1.29M 0.106M 30.6x 371.8x
8 KB 15.5M 1.43M 0.098M 10.8x 158.1x
16 KB 9.51M 1.36M 0.093M 7.0x 101.9x

可以看到,Yac 3.0 在全部测试尺寸上都明显领先 APCu 和本地 Memcached:相对 APCu 快 7.0x 到 108.4x,相对 Memcached 快 101.9x 到 1237.9x。

而与 Yac 2.4.2 相比,3.0 在 6B 到 8KB 的匹配负载中,几何平均吞吐提升 42.3%;布尔值、整数、开关和短值等 val-word 路径,最高提升 95.4%。

这组数据只用来说明 Yac 本身的读写能力。实际结果会受 CPU、PHP 版本、worker 数、key 分布、读写比、values 内存大小和你的数据形态影响。自己的机器、自己的负载,还是自己跑一遍最靠谱。

16KB 这类更大的值,我没有拿来凑平均数。这个尺寸下更容易碰到机器内存带宽上限,结果反映的是平台,而不只是缓存实现。

升级

当前正式版是 3.0.1。3.0.0 的主要读路径改造、API 和兼容性变化都已经包含在其中。

pecl upgrade yac

# 或者
pie install laruence/yac

# 源码安装指定版本
curl -LO https://github.com/laruence/yac/archive/refs/tags/3.0.1.tar.gz
tar xf 3.0.1.tar.gz && cd yac-3.0.1
phpize && ./configure && make && sudo make install

升级前建议搜一下配置:

grep -R "yac.serializer|enable-json|enable-msgpack|enable-igbinary" /etc/php* 2>/dev/null

找到就删掉。依赖 dump() 做分析的脚本,也要从 embedded 切到 loc,并注意 3.0 返回的 key 不再带 prefix。

Windows DLL 目前提供到 PHP 8.5:https://github.com/laruence/yac/releases/

最后

Yac 还是那个 Yac, 但是速度快了40%以上,升级试试吧。

项目地址:https://github.com/laruence/yac

Be First to Comment

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.