缓存是提升性能的法宝,但缓存一致性却是个让人头大的问题。电商系统里,一个商品改了价格,你需要同时清掉商品详情缓存、列表页缓存、分类页缓存、推荐位缓存,漏掉一个就会让用户看到旧数据。以前的做法是把涉及到的缓存键全都手动写一遍,或者给缓存加版本号,但商品有几千个,版本号键的管理费时费力。ThinkPHP 8 的缓存标签解决了这个痛点——你把相关联的缓存打上同一个标签,清的时候指哪打哪,一网打尽。
缓存之外,库存扣减是另一个并发重灾区。多个用户同时下单最后一件商品,如果只在应用层做判断,大概率会出现超卖。ThinkPHP 8 的原子锁提供了一个基于 Redis 或数据库的锁机制,保证同一时刻只有一个请求能执行扣减操作。和缓存标签搭配起来,既能保证库存查询的实时性,又能安全地完成扣减流程。
本文用一个完整的商品库存扣减场景,从缓存标签的多键清理,到原子锁的获取与释放,再到两者结合构建一个可落地的方案,把这两个特性的实战用法串起来。
缓存标签:给缓存打上分组烙印
ThinkPHP 8 的缓存标签需要配合支持标签的缓存驱动使用,比如 Redis 或 Memcached。文件缓存不支持标签。使用标签时,在设置缓存时用tag方法指定一个或多个标签名,后续可以按标签批量清除。
假设我们有一个商品模块,每次查询商品详情后缓存起来,同时商品在列表页和推荐位也有缓存。这些缓存的键值分散在不同地方,但都属于同一个商品。我们用标签把它们归拢:
use thinkfacadeCache;
class ProductService
{
public function getDetail(int $productId): array
{
$cacheKey = "product:detail:{$productId}";
// 尝试从缓存读取
$product = Cache::get($cacheKey);
if ($product) {
return $product;
}
// 从数据库查询
$product = Product::find($productId)->toArray();
// 缓存并打上标签
Cache::tag(['product', "product:{$productId}"])
->set($cacheKey, $product, 3600);
return $product;
}
public function getHotList(): array
{
$cacheKey = "product:hot_list";
$list = Cache::get($cacheKey);
if ($list) {
return $list;
}
$list = Product::where('is_hot', 1)
->limit(10)
->select()
->toArray();
// 热门列表也打上product标签
Cache::tag(['product', 'hot_list'])
->set($cacheKey, $list, 600);
return $list;
}
}
当商品被编辑(价格变动、下架等),需要清除所有与此商品相关的缓存,包括详情缓存和它出现在其中的列表缓存。传统做法需要知道所有缓存键,挨个Cache::delete,但有了标签,你只需清除标签即可:
public function updateProduct(int $productId, array $data): void
{
Product::where('id', $productId)->update($data);
// 清除所有打上 "product:{$productId}" 标签的缓存(详情、列表等)
Cache::tag("product:{$productId}")->clear();
// 如果还影响了整个product相关的其他缓存(比如下拉列表),可以再清一个更大范围的标签
Cache::tag('product')->clear(); // 实际项目中按需使用
}
标签是可以在set时指定多个的,清除时也可以指定多个标签。清除操作只会影响同时拥有这些标签的缓存项。这比清空全部缓存或者维护键名列表要优雅得多,而且 Redis 原生支持集合运算,性能开销很低。
原子锁:保证扣减操作的互斥性
现在关注库存扣减的并发安全性。用户在秒杀或抢购时,多个请求几乎同时到达,进行“检查库存是否大于零、扣减库存”的操作,如果不加锁,就会发生超卖。
ThinkPHP 8 的原子锁封装了底层的SET NX或GET_LOCK等机制,使用起来就是一个Lock对象。基本流程:申请锁、执行业务逻辑、释放锁。
use thinkfacadeCache;
class StockService
{
public function deduct(int $productId, int $quantity = 1): bool
{
$lockKey = "lock:stock:{$productId}";
// 尝试获取锁,等待最多5秒,锁有效期为10秒
$lock = Cache::lock($lockKey, 10);
try {
if (!$lock->get()) {
// 未获取到锁,说明有其他请求正在扣减
throw new RuntimeException('系统繁忙,请稍后重试');
}
// --- 临界区 ---
$stock = $this->getCurrentStock($productId);
if ($stock dec('stock', $quantity)
->update();
// 扣减成功后清除该商品的缓存(使用了缓存标签)
Cache::tag("product:{$productId}")->clear();
return true;
} finally {
// 无论如何都要释放锁
$lock->release();
}
}
private function getCurrentStock(int $productId): int
{
// 从数据库或缓存中获取最新库存
return Product::where('id', $productId)->value('stock');
}
}
锁的有效期设为 10 秒,是为了防止程序崩溃导致锁永不释放。正常情况下finally会释放锁,但如果进程在finally之前被 kill,锁会在 10 秒后自动过期,不会留下死锁。Redis 驱动下的原子锁基于 Lua 脚本实现,保证了获取和释放的原子性。
如果业务并发量很高,可以在获取锁时设置一个wait参数,让未获取到锁的请求自动排队等待一段时间,而不是直接拒绝:
// 最多等待3秒,每100毫秒重试一次获取锁
if (!$lock->get(3, 100)) {
throw new RuntimeException('系统繁忙');
}
这样在高并发下,少量请求会排队执行,而不是直接报错,在秒杀场景下可以提高一点转化率。但要控制好等待时间,防止请求堆积。
缓存标签与原子锁的配合策略
上面的代码片段已经展示了扣减成功后立即清除商品缓存。但还有一个细节:在锁的内部,我们读的是数据库的最新库存,而不是缓存中的库存。这是因为在并发扣减时,缓存里的库存值可能已经过期,如果以缓存为准,容易误判。所以通常的做法是:在锁内直接查数据库,保证数据一致性;锁外优先读缓存(缓存未命中再查库)。调整后的getCurrentStock方法可以这样:
private function getCurrentStock(int $productId): int
{
$cacheKey = "product:stock:{$productId}";
$stock = Cache::get($cacheKey);
if ($stock !== null) {
// 将缓存中的库存同步到数据库?不需要,缓存仅用于展示,扣减以数据库为准
// 这里我们选择锁内直接查库,所以缓存库存用于列表展示,锁内不依赖它
}
return Product::where('id', $productId)->value('stock');
}
而对于商品详情页展示的库存数量,可以用缓存标签来缓存,在扣减成功后清除该标签,下次请求会重新查库生成新的缓存值。
完整流程:从下单到库存扣减
下面把整个下单流程串起来,展示缓存标签和原子锁在实际业务中的位置。假设下单接口接收商品ID和数量,业务逻辑依次是:验证商品是否存在、检查库存、创建订单、扣减库存、清除缓存。
use thinkfacadeCache;
use appmodelProduct;
use appmodelOrder;
use thinkexceptionValidateException;
class OrderController
{
public function create()
{
$productId = input('post.product_id');
$quantity = input('post.quantity', 1);
// 1. 快速校验商品存在(可以走缓存)
$product = Product::field('id,stock,price')
->cache("product:basic:{$productId}", 600)
->find($productId);
if (!$product) {
return json(['code' => 1, 'msg' => '商品不存在']);
}
// 2. 获取库存锁,执行扣减
$lockKey = "lock:stock:{$productId}";
$lock = Cache::lock($lockKey, 10);
try {
if (!$lock->get(3, 100)) {
return json(['code' => 2, 'msg' => '下单繁忙,请稍后再试']);
}
// 锁内重新获取最新库存(避免缓存延迟)
$currentStock = Product::where('id', $productId)->value('stock');
if ($currentStock 3, 'msg' => '库存不足']);
}
// 3. 创建订单
$order = Order::create([
'product_id' => $productId,
'quantity' => $quantity,
'amount' => $product->price * $quantity,
'status' => 'pending',
]);
// 4. 扣减库存
Product::where('id', $productId)
->dec('stock', $quantity)
->update();
// 5. 清除商品相关所有缓存
Cache::tag("product:{$productId}")->clear();
Cache::tag('hot_list')->clear(); // 热门列表也可能受影响
return json(['code' => 0, 'msg' => '下单成功', 'data' => $order]);
} finally {
$lock->release();
}
}
}
这里有两个缓存动作:第一个是在最前面用cache()方法快速获取商品基本信息(带过期时间),加快了校验速度;第二个是在库存扣减成功后用标签清除所有与该商品相关的缓存,确保后续请求不会命中旧数据。原子锁则夹在中间,守护着最关键的库存读写区间。
缓存标签在多节点下的注意事项
如果应用部署在多台服务器上,Redis 作为共享缓存驱动,缓存标签和原子锁都能正常工作。但是要注意 Redis 的版本:缓存标签依赖 Redis 的集合操作,一般 Redis 2.8 以上就支持,但建议用较新版本。原子锁依赖SET NX和 Lua 脚本,同样要求 Redis 版本不要太老。
另外,如果锁的粒度太细(比如每个商品一个锁),在热点商品(如秒杀品)上会产生明显的串行化瓶颈。这时可以考虑对库存扣减做分段优化——比如把库存拆分成多个桶,每个桶一个锁,请求随机分配到一个桶扣减,提高并行度。但这就超出了缓存标签和原子锁的基础用法,属于业务架构层面的优化。
小结
缓存标签解决了缓存键多、清不干净的痛点,它把缓存的清理从“逐个点名”变成了“按组清理”,让缓存一致性维护成本大幅下降。原子锁则在并发场景下提供了简单可靠的互斥保护,不需要自己实现 Redlock 等复杂算法。这两种机制在 ThinkPHP 8 中都用统一的 Facade 接口暴露,代码风格一致。
在商品库存扣减这个典型场景里,它们一个负责“读”的效率,一个负责“写”的安全,配合得当就能构建一个既快又稳的缓存层。如果你的项目目前还在用Cache::clear()全量清缓存,或者用文件锁、数据库行锁来防止超卖,可以考虑切换到这套方案,代码量不大,效果却能实实在在地反映在性能和正确性上。

