square/ttcache

基于标签原型继承的缓存库

v3.3.2 2024-04-05 19:32 UTC

README

PHP

TTCache 或标签树缓存是一个缓存实现,它构建了一个递归的标签树,并将它们应用于正在缓存的值。这允许递归缓存,即清除树内部深处的值也会清除依赖于它的任何缓存值。

这在生成递归数据结构时很有用,例如 json 文档、html 文档或 xml 文档。

关注: Longhorn PHP 2023 对 TTCache 的讨论

安装

composer require square/ttcache:^2.0

集成

Laravel

在 Laravel 中,如果你打算使用 DI 容器访问它,请确保将 TTCache 创建为一个单例。在你的服务提供者

$this->app->singleton(TTCache::class);

上下文

假设你正在尝试渲染一个 json 对象,但结果的部分来自于昂贵的计算。以下是一个例子

{
    "id": "b5d7c58d-63c6-443e-8144-6b9cab8aceb6",
    "name": "Shoe Shine",
    "inventory": {
        "store-1": {
            "shipping": 9,
            "pickup": 9
        },
        "store-2": {
            "...": "..."
        }
    },
    "price": 900
}

库存和价格可能相当昂贵。

生成此结果的可能代码如下所示

class ProductController
{
    public function show($productId)
    {
        $p = Product::find($productId);
        $stores = Store::all();

        return [
            'id' => $p->id,
            'name' => $p->name,
            'inventory' => $this->inventory($p, $stores),
            'price' => $this->price($p)
        ]
    }

    public function inventory($product, $stores)
    {
        foreach ($stores as $store) {
            yield $store->id => $this->singleStoreInventory($product, $store);
        }
    }

    public function singleStoreInventory($product, $store)
    {
        // expensive computation
    }

    public function price($product)
    {
        // expensive computation
    }
}

计算价格的结果仅依赖于 $product 的数据,库存信息依赖于 $store$product

TTCache 允许你缓存计算的多部分,以及缓存整个结果,并且只需在需要时使应失效的部分失效。

简单使用标签

因此,相同的代码可以重写如下

public function show($productId)
{
    $p = Product::find($productId);
    $stores = Store::all();

    return $this->ttCache->remember('show:cachekey:'.$productId, tags: ['products:'.$productId, 'stores:'.$stores[0]->id, 'stores:'.$stores[1]->id], fn () => [
        'id' => $p->id,
        'name' => $p->name,
        'inventory' => $this->inventory($p, $stores),
        'price' => $this->price($p)
    ])->value();
}

缓存的值将具有标签 products:1, stores:1, stores:2。清除这些标签中的任何一个都会清除 show:cachekey:1 标签值,迫使我们重新计算结果。

你可以这样做

$p->save();
$ttCache->clearTags(['products:'.$p->id]);

这很好,但我们能做得更好。

标签树

TTCache 允许将中间结果作为构建父值的一部分进行缓存。在这种情况下,我们将 show 函数简化为

public function show($productId)
{
    $p = Product::find($productId);
    $stores = Store::all();

    return $this->ttCache->remember('show:cachekey:'.$productId, tags: ['products:'.$productId], fn () => [
        'id' => $p->id,
        'name' => $p->name,
        'inventory' => $this->inventory($p, $stores),
        'price' => $this->price($p)
    ])->value();
}

我们已删除基于商店的标签,因为商店信息没有直接用于此部分代码,而是由其他方法使用。我们现在将更新这些方法

public function singleStoreInventory($product, $store)
{
    $this->ttCache->remember(__METHOD__.':'.$product->id.':'.$store->id, tags: ['product:'.$product->id, 'store:'.$store->id], function () {
        // expensive computation
    })->value();
}

public function price($product)
{
    $this->ttCache->remember(__METHOD__.':'.$product->id, tags: ['product:'.$product->id], function () {
        // expensive computation
    })->value();
}

现在,如果某个商店的库存更新了,我们将清除与该商店相关的任何标签

$store->save();
$ttCache->clearTags('store:'.$store->id);

这样做不会删除 price() 的缓存计算,也不会删除其他商店的库存计算。但它将清除 show() 中的主要缓存值,键为 show:cachekey:1。即使缓存的值没有直接标记为 store:1 标签,它也会将其删除。标签树是在连续嵌套调用 remember 时为你构建的。

再上一层

如果我们记住前面的代码,我们可以在一个中间件中将此提升一个层次,该中间件将根据 URL 缓存结果。我们还不了解响应是如何生成的,以及哪些数据和标签将参与构建此响应。

class CachingMiddleware
{
    public function handle($request, $next)
    {
        $url = $request->url();
        $cachekey = sha1($url);
        return $this->ttCache->remember($cachekey, fn () => $next($request))->value();
    }
}

这一层的缓存并不知道最终将使用哪些标签来生成响应。然而,当它调用上面的示例代码时,它最终会被标记为product:1, store:1, store:2,清除这些标签中的任何一个都会导致基于URL直接缓存的响应被清除。

缓存结果信息

有时知道值是否从缓存中检索到是有用的。这可以用于遥测以验证你获得缓存命中/未命中的频率。有时在返回值之前获取应用于该值的标签很有用。这可以在你的应用位于支持Surrogate-Keys的CDN时使用,你希望使用这些标签作为Surrogate-Keys,以便CDN可以缓存响应,并且你可以正确地使其失效。

public function show($productId)
{
    $p = Product::find($productId);
    $stores = Store::all();

    $cacheResult = $this->ttCache->remember('show:cachekey:'.$productId, tags: ['products:'.$productId, 'stores:'.$stores[0]->id, 'stores:'.$stores[1]->id], fn () => [
        'id' => $p->id,
        'name' => $p->name,
        'inventory' => $this->inventory($p, $stores),
        'price' => $this->price($p)
    ]);

    if ($cacheResult->isMiss()) {
        $this->trackCacheMiss();
    }

    $response = new Response($cacheResult->value());
    $response->header('Surrogate-Keys', join(',', $cacheResult->tags()));

    return $response;
}

缓存错误

当缓存抛出异常时,TTCache会捕获它并继续从代码中计算结果。然而,Result将携带异常信息以及存在错误的事实,这样你就可以正确地监控、跟踪或记录这些实例。

public function show($productId)
{
    $cacheResult = $this->ttCache->remember('cachekey', tags: [], fn () => 'computed value');

    if ($cacheResult->hasError()) {
        \Log::error('caching error', ['error' => $cacheResult->error()]);
    }
}

处理集合

有时当你对一个项目的集合进行操作并缓存对那些项目应用函数的结果时,你只会有一小部分这些项目不在缓存中。严格使用->remember调用,这意味着一个包含200个项目的集合,其中2个不在缓存中,仍然需要访问缓存198次以检索其他缓存的值。根据现有集合的大小,这可能可以接受,也可能是性能瓶颈。对于成为性能瓶颈的情况,ttcache提供了->load($keys)方法,它允许预加载一组值,然后可以从内存中检索,而不必进行昂贵的分布式缓存之旅。

$collection = BlogPosts::all();
$t->remember('full-collection', 0, [], function () use ($collection) {
    $posts = [];
    $keys = [];

    // Create an array all the caching keys for items in the collection
    // This is actually a map of `entity_id` => `cache_key`
    foreach ($collection as $post) {
        $keys[$post->id] = __CLASS__.':blog-collection:'.$post->id;
    }
    // Pre-load them into memory
    $this->tt->load($keys);

    // Run through the collection as usual, making calls to `->remember` that will either resolve in memory
    // Or will set a new value in cache for those items that couldn't be resolved in memory
    foreach ($collection as $post) {
        $key = __CLASS__.':blog-collection:'.$post->id;
        $posts[] = $this->tt->remember($key, 0, ['post:'.$post->id], fn () => "<h1>$post->title</h1><hr /><div>$post->content</div>")->value();
    }

    return $posts;
})->value();

高级

为了进一步提高性能,你可能想了解哪些键没有被加载,然后能够通过单个调用加载所有必需的实体。load方法返回一个LoadResult对象,它让你知道在缓存中成功找到的是什么或什么没有找到。

$postIds = [1, 2, 3, 4, 5, 6];
$t->remember('full-collection', 0, [], function () use ($postIds) {
    $posts = [];
    $keys = [];

    // Create an array all the caching keys for items in the collection
    // This is actually a map of `entity_id` => `cache_key`
    foreach ($postIds as $postId) {
        $keys[$postId] = __CLASS__.':blog-collection:'.$postId;
    }
    // Pre-load them into memory
    $loadResult = $this->tt->load($keys);
    // Since we passed our keys to `load` as a map of `entity_id` => `cache_key`, we can retrieve the entity ids here.
    $missing = BlogPosts::whereIn('id', array_keys($loadResult->missingKeys()));

    // Run through the collection as usual, making calls to `->remember` that will either resolve in memory
    // Or will set a new value in cache for those items that couldn't be resolved in memory
    foreach ($postIds as $postId) {
        $key = __CLASS__.':blog-collection:'.$post->id;
        $posts[] = $this->tt->remember($key, 0, ['post:'.$post->id], fn () => "<h1>{$missing[$postId]->title}</h1><hr /><div>{$missing[$postId]->content}</div>")->value();
    }

    return $posts;
})->value();

绕过某些结果缓存

某些结果应该不被缓存。例如,一个缓存完整HTTP响应的中间件默认情况下也会缓存错误响应。如果错误是由于与其他服务的临时连接错误引起的,我们不希望缓存这种类型的结果。为此,你可以用BypassCache ReturnDirective包装你的返回值。

class CachingMiddleware
{
    public function handle($request, $next)
    {
        $url = $request->url();
        $cachekey = sha1($url);
        return $this->ttCache->remember($cachekey, function () use ($next, $request) {
            $response = $next($request);
            if ($response->getStatusCode() >= 200 && $response->getStatusCode() < 300) {
                return $response;
            }

            return new BypassCache($response);
        });
    }
}

这样做将确保错误响应永远不会进入缓存。

可继承的标签(~全局标签)

有时你希望整个标签化缓存值的层次结构共享一个公共标签。例如,在一个SaaS应用中,你可能希望将所有账户的缓存值标记为账户的ID,这样如果出现问题时,你可以轻松清除它们的整个缓存值集,确保从头开始。

这可以通过使用HeritableTags来实现。使用可继承的标签,你可以将此代码(标签需要在每个级别应用)转换为以下代码

$tt->remember('key:level:1', 0, ['account:123'], function () {
    //... code ...
    $tt->remember('key:level:2', 0, ['account:123'], function () {
        // ... more ... code ...
        $tt->remember('key:level:3', 0, ['account:123'], function () {
            // ... even ... more ... code ...
            $tt->remember('key:level:4', 0, ['account:123'], function () {
                // ... wow ... that's ... a ... lot ... of ... code ...
            });
        });
    });
});

为以下

$tt->remember('key:level:1', 0, [new HeritableTag('account:123')], function () {
    //... code ...
    $tt->remember('key:level:2', 0, [], function () {
        // ... more ... code ...
        $tt->remember('key:level:3', 0, [], function () {
            // ... even ... more ... code ...
            $tt->remember('key:level:4', 0, [], function () {
                // ... wow ... that's ... a ... lot ... of ... code ...
            });
        });
    });
});

现在只需要应用一次标签,它将自动添加到任何级别的子缓存值中。

清除整个缓存

有时你可能想要或需要清除每个缓存的值。例如,你正在更改返回嵌套缓存值的格式或代码。这些缓存值是深度嵌套的,有成百万个,这使得生成和清除每个标签变得困难。

让我们探索清除缓存大部分内容的方法

全局标签(不推荐)

虽然这种方法不推荐,但它有助于理解推荐的方法。

一种简单的做法是在根节点添加一个HeritableTag,这将是一个缓存版本。例如,对remember的第一次调用可以使用

$tt->remember('key', 0, [new HeritableTag('cache-global')], function () {
    // ... code ...
});

然后将对每个缓存中的值应用cache-global

$tt->clearTags('cache-global');

这将使每个缓存的值失效。

为什么不推荐这样做

根据你的情况,在短短一秒内清除整个缓存可能会产生巨大的影响。如果你的代码不能在没有缓存支撑的情况下处理接收到的代码量,那么你的服务可能会中断,或者库无响应,或者进程的CPU使用率会急剧上升等等...

亲身经历过这种情况,我们强烈反对使用这种全局标签。

你可能甚至无法控制何时清除缓存。如果你的分布式缓存需要为其他值腾出空间并决定从存储中删除这个特定的标签,那么突然之间所有的缓存数据都会消失,你的系统将处于红色警报状态。

ShardingTags

我们建议使用分片标签。如果再次以SaaS平台为例,每个账户的缓存都有完全独立的标签,也许你已经根据账户ID添加了基于继承标签的HeritableTag

ShardingTagHeritableTag,它会哈希一个值并将其关联到给定数量的任何一个分片,创建一个如shard:1shard:18的标签。然后你可以逐个清除这些分片标签,给系统缓存一些时间再次热身,避免灾难性事件。

$tt->remember('key', 0, [new ShardingTag('shard', $account->id, 20)], function () {
    // ... code ...
});

由于ShardingTagHeritableTag,这将确保在此调用中缓存的任何值都应用了相同的标签。当你需要谨慎地清除整个缓存时,你可以

for ($i = 0; $i < 20; $i++) {
    $tt->clearTags('shard:'.$i);
    sleep(60);
}

这将每分钟清除5%(1/20)的缓存。