ThinkPHP 8 缓存标签与原子锁实战:高并发库存扣减的正确方案

2026-07-30 0 745

缓存是提升性能的法宝,但缓存一致性却是个让人头大的问题。电商系统里,一个商品改了价格,你需要同时清掉商品详情缓存、列表页缓存、分类页缓存、推荐位缓存,漏掉一个就会让用户看到旧数据。以前的做法是把涉及到的缓存键全都手动写一遍,或者给缓存加版本号,但商品有几千个,版本号键的管理费时费力。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 NXGET_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()全量清缓存,或者用文件锁、数据库行锁来防止超卖,可以考虑切换到这套方案,代码量不大,效果却能实实在在地反映在性能和正确性上。

ThinkPHP 8 缓存标签与原子锁实战:高并发库存扣减的正确方案
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP 8 缓存标签与原子锁实战:高并发库存扣减的正确方案 https://www.taomawang.com/server/thinkphp/2460.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务