【基于 Swoole+Hyperf 的微服务实战】第八周·周四:分布式锁与并发控制
今天我们进入第八周周四,主题是 分布式锁与并发控制。昨天我们的 Saga 协调器已经能够保证分布式事务的最终一致性,但在高并发场景下(如秒杀),单纯的数据库行锁可能成为瓶颈,并且微服务架构中服务间竞争资源需要更通用的锁机制。今天我们将引入 Redis 分布式锁,通过 hyperf/redis-lock 组件实现对共享资源的安全访问,防止超卖等并发问题,并实现一个简单的秒杀接口,亲身体验锁的威力。

今日目标
一、环境准备(约 20 分钟)
继续在 hyperf-app 容器中操作,确保 Redis 容器正常运行。
docker-compose exec swoole bash
cd /var/www/hyperf-app
安装锁组件
composer require hyperf/redis-lock
该组件依赖 hyperf/redis(已安装)。无需额外发布配置,锁的配置通过 Redis 连接池即可。
二、知识核心:分布式锁与 Redis 实现(约 1 小时)
1. 为什么需要分布式锁?
在单体应用中,我们可以使用语言级别的锁(如 synchronized)或数据库行锁来保证并发安全。但在微服务中,多个实例可能同时操作同一个资源(如扣减库存),需要跨进程的锁。分布式锁应满足:
- 互斥性:同一时刻只有一个客户端持有锁。
- 防死锁:即使持有锁的进程崩溃,锁也能被自动释放(通过 TTL)。
- 高可用:锁服务本身不能单点故障(如 Redis 集群或红锁)。
- 可重入性:同一线程可多次获取同一把锁(Hyperf 支持)。
2. Redis 分布式锁演进
阶段一:SETNX + EXPIRE(不推荐)
// 问题:SETNX 和 EXPIRE 不是原子操作,若 SETNX 后进程崩溃,则锁永不过期
阶段二:SET key value NX EX timeout(原子操作)
Redis 2.6.12 起支持 SET key value NX EX seconds,获取锁和设置过期时间原子执行。
$ok = $redis->set('lock:product:1', $uniqueValue, ['NX', 'EX' => 30]);
阶段三:锁续期(看门狗)
若任务执行时间超过锁的 TTL,锁会自动释放,导致其他进程获取锁,产生并发问题。需要后台协程定期续期。
阶段四:RedLock 算法
在多个独立的 Redis 实例上依次获取锁,大多数成功才算获取成功,防止单点 Redis 故障导致锁失效。
3. hyperf/redis-lock 组件
基于以上原理,hyperf/redis-lock 提供了:
- Hyperf\\Redis\\Lock\\RedisLock:基本的锁实现,支持续期(WatchDog)。
- #[RedisLock] 注解:可应用于方法,自动加锁释放。
- 配置红锁:通过 RedisLockFactory 或配置文件指定多个 Redis 连接。
我们将在秒杀场景中使用注解方式,简单高效。
三、实战:秒杀接口加锁防超卖(约 2.5 小时)
步骤 1:秒杀业务准备
我们需要一个高并发场景来体现锁的作用。设计一个秒杀接口 POST /seckill,逻辑为:扣减指定商品的库存(直接扣减 total_stock,不使用冻结模式,简化演示)。不加锁时必然超卖,加锁后库存正确。
创建 app/Controller/SeckillController.php:
<?php
namespace App\\Controller;
use Hyperf\\DbConnection\\Db;
use Hyperf\\HttpServer\\Annotation\\Controller;
use Hyperf\\HttpServer\\Annotation\\RequestMapping;
use Hyperf\\Redis\\Lock\\RedisLock;
use Hyperf\\Di\\Annotation\\Inject;
use Hyperf\\Redis\\Redis;
#[Controller(prefix: '/seckill')]
class SeckillController extends AbstractController
{
#[Inject]
private Redis $redis;
#[RequestMapping(path: 'buy', methods: 'post')]
public function buy()
{
$productId = $this->request->input('product_id', 1);
// 无锁版本(用于对比)
return $this->doBuyWithoutLock($productId);
}
private function doBuyWithoutLock(int $productId)
{
// 查询当前库存
$stock = Db::table('products')->where('id', $productId)->value('total_stock');
if ($stock <= 0) {
return ['code' => 0, 'message' => '库存不足'];
}
// 模拟并发间隙
\\Swoole\\Coroutine::sleep(0.001);
// 扣减
Db::table('products')->where('id', $productId)->decrement('total_stock');
return ['code' => 200, 'message' => '购买成功'];
}
}
测试:将商品库存设为 10,并发 100 请求,不加锁时最终库存可能为负数。
步骤 2:使用 RedisLock 加锁
修改 buy 方法,使用注解方式自动加锁:
use Hyperf\\Redis\\Lock\\Annotation\\RedisLock;
#[RequestMapping(path: 'buy', methods: 'post')]
#[RedisLock(key: 'lock:seckill:#{product_id}', ttl: 5)]
public function buy()
{
$productId = (int)$this->request->input('product_id', 1);
return $this->doBuyWithLock($productId);
}
private function doBuyWithLock(int $productId)
{
$stock = Db::table('products')->where('id', $productId)->value('total_stock');
if ($stock <= 0) {
return ['code' => 0, 'message' => '库存不足'];
}
\\Swoole\\Coroutine::sleep(0.001);
Db::table('products')->where('id', $productId)->decrement('total_stock');
return ['code' => 200, 'message' => '购买成功'];
}
说明:
- #[RedisLock] 注解支持 SpEL 表达式,#{product_id} 会从请求参数中获取,生成锁键 lock:seckill:1。
- ttl 设置锁的有效期 5 秒,防止死锁。
- 当方法执行完毕或异常时,锁自动释放(通过 AOP 切面)。
如果未引入 hyperf/redis-lock 的自动配置,可能需要注册切面,但该组件已自动完成。
步骤 3:测试锁效果
初始化库存:
UPDATE products SET total_stock = 100 WHERE id = 1;
使用 ab 或 wrk 并发测试:
# 无锁版本(注释掉 RedisLock 注解)
wrk -t4 -c100 -d5s –latency -s post.lua http://localhost:9501/seckill/buy
# post.lua 可构造 POST 请求,或用 ab -n 500 -c 50 -p data.txt …
观察最终库存:SELECT total_stock FROM products WHERE id=1; 无锁版本会小于 0 或远小于预期。加锁版本库存精确归零。
Redis 监控:
在压测期间,使用 redis-cli monitor 或 keys *lock* 观察锁的创建和删除。
步骤 4:锁续期与可重入
RedisLock 支持 watchDog 参数,当任务执行时间可能超过 ttl 时,可以开启续期。但注解模式默认不开启,需手动创建 RedisLock 实例。
示例手动加锁(演示):
$lock = $this->redis->lock('lock:product:' . $productId, 5);
if ($lock->get()) {
try {
// 业务
} finally {
$lock->release();
}
}
lock() 方法返回一个锁对象,内部实现包含 WatchDog。
可重入性:同一协程再次获取相同锁时自动支持,不会死锁。
步骤 5:Redis 红锁(RedLock)配置(概念与实现)
在多个 Redis 实例上获取锁可以提高可靠性。hyperf/redis-lock 支持红锁,需要在 config/autoload/redis.php 中配置多个连接(或使用 Redis Cluster),然后通过 RedisLockFactory 创建。
简单配置多个 Redis 实例(开发环境可模拟不同端口),在 redis.php 中添加额外连接,然后使用:
$factory = $this->container->get(\\Hyperf\\Redis\\Lock\\RedisLockFactory::class);
$lock = $factory->make('lock:redlock:product:1', 5, ['connection' => ['default', 'redis2']]);
由于我们开发环境只有一个 Redis,今天重点是理解红锁思路:向 N 个独立 Redis 实例依次获取锁,若成功数 >= N/2+1,且总耗时小于锁有效期,则获取成功。
四、成果测试与并发验证(约 1 小时)
1. 无锁超卖验证
- 设置库存 10。
- 发送 100 并发请求。
- 查询库存 total_stock,值可能为负。
2. 加锁一致性验证
- 重置库存 10。
- 同样 100 并发请求。
- 库存归零,订单表中成功订单数等于 10。
性能对比:
加锁会引入少量延迟(Redis 网络往返),但保证了数据正确性。记录加锁前后的 QPS 和 P99 延迟,分析取舍。
3. 锁超时模拟
将锁 TTL 设为 2 秒,在业务代码中 sleep(5),观察锁是否在 2 秒后释放,其他请求是否能获取锁(造成并发)。再开启 WatchDog,验证锁是否被续期直到任务结束。
4. 测试清单
| 无锁超卖 | 并发请求无锁接口 | 库存超卖,最终 total_stock < 0 |
| 注解加锁防超卖 | 并发请求加锁接口 | 库存扣减至 0,无超卖 |
| 锁自动释放 | 方法正常结束或异常,Redis 中锁消失 | keys 不再包含锁键 |
| 锁重入 | 同一方法内再次获取同一锁 | 不阻塞 |
| 红锁配置 | 使用多个 Redis 连接 | 当部分 Redis 故障时锁仍可用(需多实例) |
| 死锁预防 | 杀死持锁进程,等待 TTL 后锁释放 | 锁过期自动删除 |
五、今日作业与学习产出
- 将秒杀与 Saga 冻结库存逻辑结合:下单时用锁保护冻结库存操作,避免超卖。
- 编写一个锁监控命令,用于查看当前所有活跃锁。
- 画出 Redis 分布式锁的状态转换图(获取、续期、释放、过期)。
- 对比 Redis 锁与 ZooKeeper 锁的实现复杂度及适用场景。
- 实现 基于 Lua 脚本的原子库存扣减(不依赖锁),比较性能。
- 研究 Swoole\\Lock 或 etcd 实现的分布式锁,与 Redis 锁进行压力测试对比。
通过今天的学习,你已经掌握了高并发下保证数据一致性的关键武器——分布式锁。现在你的 Saga 流程即使在秒杀场景下也能安全运行,不会出现超卖等数据异常。明天我们将结合分布式会话和跨服务认证优化,让整个系统的安全认证体系更加成熟。
网硕互联帮助中心



评论前必须登录!
注册