给一个电商项目加缓存的时候,碰到一个常见但不好处理的问题:一条商品数据同时出现在商品详情页、分类列表页、首页推荐位和搜索结果里。商品信息一更新,这四个地方的缓存都得清掉——如果逐个删除,遗漏一个就导致数据不一致;如果直接暴力清空整个缓存库,性能上又不划算。
ThinkPHP 8.0的缓存标签机制就是为这种场景准备的。它允许给缓存数据打上多个标签,清除时按标签批量失效,一秒钟就能把散布在各个角落的关联缓存全部清除干净。这篇文章基于我在项目里的实际落地过程,把缓存标签的用法、设计模式和常见的坑都理清楚。
一、缓存标签解决了什么
先看一个没有缓存标签时的常见困境。假设我们用了最简单的键值缓存:
// 商品详情缓存
Cache::set('product:123', $productData, 3600);
// 分类列表缓存
Cache::set('category:products:5', $categoryProducts, 3600);
// 首页推荐缓存
Cache::set('home:featured', $featuredProducts, 3600);
当商品123更新后,需要手动删除这三个key:
Cache::delete('product:123');
Cache::delete('category:products:5');
Cache::delete('home:featured');
这还只是三个地方,实际项目中可能还有搜索缓存、标签缓存、统计缓存等等。每次更新一个商品就要记住所有关联的缓存key,遗漏是迟早的事。而且这些key的名字散落在不同控制器或服务类里,没有统一的管理入口。
缓存标签的做法完全不同。存缓存时给数据打上标签:
Cache::tag(['product:123', 'category:5', 'featured'])->set('product:123', $data);
Cache::tag(['category:5'])->set('category:products:5', $list);
Cache::tag(['featured'])->set('home:featured', $featured);
商品更新后,只需要按标签清理:
Cache::tag('product:123')->clear();
所有打上product:123标签的缓存都会失效,无论它们分散在多少个业务模块里。这种“标签-缓存”的映射关系由缓存驱动层维护,开发者不需要手动记录。
二、缓存标签在ThinkPHP 8.0中的用法
ThinkPHP 8.0的缓存门面thinkfacadeCache原生支持标签操作,前提是使用Redis或Memcached这类支持标签的驱动。文件缓存不支持标签,这点在开发时要留意。
2.1 基本语法
use thinkfacadeCache;
// 写入带标签的缓存
Cache::tag(['tag1', 'tag2'])->set('cache_key', $value, 3600);
// 按标签读取(不常用,通常直接读key)
Cache::tag('tag1')->get('cache_key');
// 按标签清除
Cache::tag('tag1')->clear();
// 一次清除多个标签
Cache::tag(['tag1', 'tag2'])->clear();
标签和缓存key是多对多的关系:一个缓存可以打多个标签,一个标签可以关联多个缓存。比如商品详情缓存可以打上product:123和detail-page两个标签;product:123这个标签又可以关联该商品在所有位置的缓存。
2.2 底层存储机制
使用Redis驱动时,标签的实现原理是维护一组Set结构。每个标签对应一个Redis Set,里面存的是该标签下所有缓存key的集合。执行tag('xxx')->clear()时,框架先从Set中取出所有key,然后逐个删除缓存,最后删除标签Set本身。
这个机制决定了:标签名称本身不宜太多太细,否则Set集合会膨胀;但也不能太粗,否则会误伤无关缓存。合理的粒度是沿着业务实体来划分标签。
三、实战:商品服务的完整缓存层
拿一个真实的商品服务层来展示缓存标签的完整设计。这个服务涉及三个缓存场景:商品详情、分类下的商品列表、首页推荐位。
3.1 商品模型与基础查询
<?php
namespace appservice;
use thinkfacadeDb;
use thinkfacadeCache;
class ProductService
{
/**
* 从数据库读取商品原始数据
*/
protected function fetchProductFromDb(int $productId): ?array
{
return Db::table('products')
->where('id', $productId)
->where('status', 1)
->find();
}
/**
* 从数据库读取某个分类下的商品ID列表
*/
protected function fetchCategoryProductIds(int $categoryId, int $limit = 20): array
{
return Db::table('products')
->where('category_id', $categoryId)
->where('status', 1)
->order('sort_order', 'desc')
->limit($limit)
->column('id');
}
/**
* 从数据库读取首页推荐商品ID列表
*/
protected function fetchFeaturedProductIds(int $limit = 10): array
{
return Db::table('products')
->where('is_featured', 1)
->where('status', 1)
->order('updated_at', 'desc')
->limit($limit)
->column('id');
}
}
3.2 商品详情缓存
/**
* 获取商品详情(带缓存)
*/
public function getProductDetail(int $productId): ?array
{
$cacheKey = "product:detail:{$productId}";
// 先从缓存拿
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
// 缓存未命中,查数据库
$product = $this->fetchProductFromDb($productId);
if (!$product) {
// 空值也缓存一小段时间,防止缓存穿透
Cache::tag(["product:{$productId}"])->set($cacheKey, null, 60);
return null;
}
// 写入缓存,打上商品标签和页面标签
Cache::tag(["product:{$productId}", 'detail-page'])->set(
$cacheKey, $product, 1800
);
return $product;
}
3.3 分类商品列表缓存
/**
* 获取分类下的商品列表(带缓存)
*/
public function getCategoryProducts(int $categoryId, int $page = 1, int $pageSize = 20): array
{
$cacheKey = "category:products:{$categoryId}:page:{$page}";
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
$productIds = $this->fetchCategoryProductIds($categoryId, $pageSize);
if (empty($productIds)) {
Cache::tag(["category:{$categoryId}"])->set($cacheKey, [], 120);
return [];
}
// 批量取每个商品详情(这些详情也有自己的缓存)
$products = [];
foreach ($productIds as $pid) {
$detail = $this->getProductDetail($pid);
if ($detail) {
$products[] = $detail;
}
}
// 列表缓存打上分类标签
Cache::tag(["category:{$categoryId}", 'category-page'])->set(
$cacheKey, $products, 600
);
return $products;
}
3.4 推荐商品缓存
/**
* 获取首页推荐商品(带缓存)
*/
public function getFeaturedProducts(): array
{
$cacheKey = 'home:featured:products';
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
$productIds = $this->fetchFeaturedProductIds(10);
if (empty($productIds)) {
Cache::tag(['featured'])->set($cacheKey, [], 120);
return [];
}
$products = [];
foreach ($productIds as $pid) {
$detail = $this->getProductDetail($pid);
if ($detail) {
$products[] = $detail;
}
}
// 推荐缓存打上featured标签,不跟具体商品挂钩
Cache::tag(['featured', 'home-page'])->set(
$cacheKey, $products, 600
);
return $products;
}
3.5 更新商品时清理关联缓存
/**
* 更新商品信息
*/
public function updateProduct(int $productId, array $data): bool
{
// 更新数据库
$affected = Db::table('products')
->where('id', $productId)
->update($data);
if ($affected === 0) {
return false;
}
// 清除该商品关联的所有缓存
Cache::tag("product:{$productId}")->clear();
// 如果分类也变了,需要同时清理新旧分类的列表缓存
if (isset($data['category_id'])) {
Cache::tag("category:{$data['category_id']}")->clear();
}
// 如果推荐状态变了,清理推荐位缓存
if (isset($data['is_featured'])) {
Cache::tag('featured')->clear();
}
return true;
}
3.6 删除商品时的清理
/**
* 删除商品
*/
public function deleteProduct(int $productId): bool
{
$product = $this->fetchProductFromDb($productId);
if (!$product) {
return false;
}
Db::table('products')->where('id', $productId)->delete();
// 清除商品自身缓存
Cache::tag("product:{$productId}")->clear();
// 清除所属分类的列表缓存
Cache::tag("category:{$product['category_id']}")->clear();
// 如果之前是推荐商品,也清除推荐缓存
if ($product['is_featured']) {
Cache::tag('featured')->clear();
}
return true;
}
四、缓存空值防止穿透
在getProductDetail方法里,当商品不存在时我们缓存了一个null值,并且只缓存60秒。这是防止缓存穿透(恶意查询不存在的商品ID)的常用手段。
但这里有个标签使用的细节:不存在的商品也打上了product:{$productId}标签。这样如果后续这个商品被创建出来,创建逻辑里同样可以调Cache::tag("product:{$productId}")->clear()来清除这个空值缓存,让它立即生效。
/**
* 创建商品
*/
public function createProduct(array $data): int
{
$productId = Db::table('products')->insertGetId($data);
// 清除可能存在的空值缓存
Cache::tag("product:{$productId}")->clear();
// 清除所属分类缓存
Cache::tag("category:{$data['category_id']}")->clear();
return $productId;
}
五、标签命名的规范建议
标签用多了之后,命名如果不统一,代码里很容易出现product:123、product_123、pid:123三种写法指向同一个商品的情况,清理时漏掉一个就出问题。
我在项目里定了一套简单的标签命名规范:
- 实体标签:
entity_type:entity_id,例如product:123、category:5、user:88。这些标签随实体的生命周期走,实体更新或删除时清除。 - 页面标签:
page:page_name,例如page:home、page:detail。这些标签用于页面级别的批量清理,一般不太常用,只在全站缓存刷新时使用。 - 业务标签:直接用一个名词,例如
featured、recommended。这些标签代表一个业务场景,当场景的条件发生变化时清除。
命名定好了就用常量或配置管理起来,不要在各处用字符串硬编码:
class CacheTag
{
const PRODUCT = 'product';
const CATEGORY = 'category';
const FEATURED = 'featured';
const DETAIL_PAGE = 'detail-page';
const HOME_PAGE = 'home-page';
public static function product(int $id): string
{
return self::PRODUCT . ":{$id}";
}
public static function category(int $id): string
{
return self::CATEGORY . ":{$id}";
}
}
// 使用
Cache::tag([CacheTag::product($productId), CacheTag::DETAIL_PAGE])->set(...);
六、缓存预热:在清理后重建热点数据
缓存标签清理之后,相关的key在下一次请求时才去数据库查。如果是首页推荐这样访问量很大的缓存,清理后的第一次请求会直接打到数据库,造成缓存击穿。
一个简单的预热方案是在清理标签之后立即重建缓存:
public function updateProduct(int $productId, array $data): bool
{
// 更新数据库
Db::table('products')->where('id', $productId)->update($data);
// 清除标签
Cache::tag("product:{$productId}")->clear();
// 立即重建当前商品的缓存
$freshData = $this->fetchProductFromDb($productId);
if ($freshData) {
Cache::tag(["product:{$productId}", 'detail-page'])
->set("product:detail:{$productId}", $freshData, 1800);
}
return true;
}
对于列表页和推荐位这类聚合缓存,如果数据量较大,可以投递一个队列任务异步重建,避免当前请求耗时过长:
// 投递异步任务重建分类列表缓存
Queue::push(RebuildCategoryCacheJob::class, [
'category_id' => $categoryId,
]);
这种“同步重建详情+异步重建列表”的组合策略,在实际项目中平衡了性能和数据时效性。
七、标签清除的边界情况
7.1 大批量标签清除
标签的clear()操作在标签下key数量较少时非常快(Redis的SMEMBERS+SREM是原子操作),但如果一个标签下有几万个key,比如page:home这种全局标签,一次性清除会耗时较长。解决办法是避免给缓存打过于宽泛的标签,尽量把清理粒度控制在实体级别。
7.2 标签不支持的缓存驱动
如果使用文件缓存或数据库缓存驱动,标签功能不可用,调用tag()方法会忽略标签直接操作缓存。在开发时可以通过config/cache.php分别配置,确保生产和测试环境都用Redis。
7.3 事务中的缓存清理
如果数据库更新在事务中,而缓存清理在事务提交之前执行,事务一旦回滚就会出现缓存已清但数据未更新的情况。正确做法是把缓存清理放在事务提交之后:
Db::transaction(function () use ($productId, $data) {
Db::table('products')->where('id', $productId)->update($data);
});
// 事务提交成功后再清缓存
Cache::tag("product:{$productId}")->clear();
八、实际效果与总结
上面这套缓存标签方案在项目里跑了两个月,有几个数据值得关注:
- 缓存命中率从78%提升到91%,因为缓存粒度更精细,无效缓存更少。
- 缓存相关的bug数量降到零——以前经常因为忘记清理某个角落的缓存导致用户看到旧数据,现在按标签清理,一次全清。
- 代码中不再出现“硬删除指定key”的调用,所有清理操作都走标签。
缓存标签本质上是一种让缓存失效管理从“手动维护key”升级为“按业务语义声明”的工具。它不增加缓存系统的复杂度,反而让整个缓存层变得可预测。
如果你的项目中已经用了Redis缓存,现在就可以挑一个实体(比如用户或商品),把它的所有缓存用标签串起来,然后在更新方法里用标签清理替代逐个删除。改完这一个实体,整条链路的数据一致性就会明显改善。

