接上一篇笔记:https://blog.csdn.net/weixin_44780078/article/details/130208505
对于商城项目,每个商城都会有优惠券。而订单表如果使用数据库自增ID就存在一些问题:
1、id的规律性太明显。
2、受到单表数据量的限制。
全局id生成器,是一种在分布式系统下用来生成全局唯一id的工具,一般要满足下列特性:
考虑到redis的 incr 命令,因此可以使用redis来生成id。但是为了增加id的安全性,我们可以不直接使用redis的自增的数值,而是拼接一些其他数值。
id的组成部分:
@Component
public class RedisIdWorker {
// 开始秒数-2022年1月1日0时0分0秒
private static final long BEGIN_TIMESTAMP = 1640995200L;
// 序列号位数
private static final int COUNT_BITS = 32;
@Autowired
private StringRedisTemplate stringRedisTemplate;
public long nextId(String keyPrefix) {
// 1.生成时间戳,以秒为单位
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC); // 当前秒数
long timestamp = nowSecond - BEGIN_TIMESTAMP; // 当前秒数 - 2022年1月1日0时0分0秒的秒数
// 2.生成序列号
// 获取当前日期,精确到天
String date = now.format(DateTimeFormatter.ofPattern("yyyymmdd"));
long count = stringRedisTemplate.opsForValue().increment("incr:" + keyPrefix + ":" + date);
// 3.拼接并返回
return timestamp << COUNT_BITS | count;
}
}
全局唯一id生成策略:
实现优惠券秒杀的下单功能。
下单时需要判断两点:
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1.查询数据库
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { // 未开始
return Result.fail("尚未开始");
}
// 3.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) { // 已结束
return Result.fail("已结束");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足");
}
// 5.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id",voucherId).update();
if (!success) {
return Result.fail("扣减失败");
}
// 6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(UserHolder.getUser().getId());
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
log.info("抢购成功,订单id===>{}",orderId);
return Result.ok(orderId);
}
超卖问题:使用JMeter压力测试,库存50,线程200,发现数据库中订单有59条,库存变成了-9。
这是因为在多线程并发的场景下,线程之间不可能完全按照顺序执行,普通情况下可能存在线程1:查询库存->库存大于0->扣减库存。特殊情况:线程1:查询库存->线程2:查询库存->线程1,2查询的库存都大于0->线程1扣除库存->线程2扣除库存。这时候就会出现超卖问题。
解决方案:加锁
悲观锁伪代码:加上 synchronized 关键字
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1.查询数据库
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { // 未开始
return Result.fail("尚未开始");
}
// 3.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) { // 已结束
return Result.fail("已结束");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足");
}
// 5.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id",voucherId).update();
if (!success) {
return Result.fail("扣减失败");
}
// 6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(1001l);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
log.info("抢购成功,订单id==={}",orderId);
return Result.ok(orderId);
}
由于百度看到一篇博客,强调synchronized不要和@Transactional一起使用,博客链接:https://blog.csdn.net/weixin_42822484/article/details/107923220 因此本次演示我把 synchronized 关键字加在controller层演示。
controller层代码:
@PostMapping("/seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
try {
synchronized (this) {
return voucherOrderService.seckillVoucher(voucherId);
}
} catch (Exception e) {
e.printStackTrace();
return Result.fail("synchronized遇到错误");
}
}
加上互斥锁后,再次使用JMeter进行压力测试,发现超卖问题得到解决。
乐观锁伪代码:乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种。
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
// 1.查询数据库
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { // 未开始
return Result.fail("尚未开始");
}
// 3.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) { // 已结束
return Result.fail("已结束");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足");
}
// 5.扣减库存
boolean success = seckillVoucherService.update()
// 解决位置
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id",voucherId) // where voucher_id = ?
.gt("stock",0) // and stock > 0
.update();
if (!success) {
return Result.fail("扣减失败");
}
// 6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
// Long userId = UserHolder.getUser().getId();
voucherOrder.setId(orderId);
voucherOrder.setUserId(1001l);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 7.返回订单id
log.info("抢购成功,订单id==={}",orderId);
return Result.ok(orderId);
}
但是悲观锁和乐观锁,都各有优缺点:
对于秒杀的优惠券,应该设置一人只能抢一张的功能。以免所有优惠券被一人独享,失去推广宣传的意义。
加入依赖:
<dependency>
<groupId>org.aspectjgroupId>
<artifactId>aspectjweaverartifactId>
dependency>
启动类加上注解:
@EnableAspectJAutoProxy(exposeProxy = true) // 开启AOP功能
实现类:
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询数据库
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { // 未开始
return Result.fail("尚未开始");
}
// 3.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) { // 已结束
return Result.fail("已结束");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足");
}
// 5.一人一单
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId, userId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId, Long userId) {
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) { // 已存在,拒绝再次抢购
return Result.fail("已秒杀过优惠卷,拒绝再次抢购");
}
// 6.扣减库存
boolean success = seckillVoucherService.update()
// 解决位置
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id",voucherId) // where voucher_id = ?
.gt("stock",0) // and stock > 0
.update();
if (!success) {
return Result.fail("扣减失败");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 8.返回订单id
log.info("抢购成功,订单id===>{}",orderId);
return Result.ok(orderId);
}
虽然代码中使用了 synchronized 同步锁,但是在集群模式下多线程还是会出现线程安全问题:
正常情况:
集群模式下:由于是集群,因此就有多台jvm,线程1、2为一台jvm,线程3、4为一台jvm,不同jvm之间 synchronized 锁是互不影响的,因此线程1和线程3都会获取锁成功,因此又出现了线程安全问题,因此需要一种能在不同jvm之间实现同步锁,这就是 分布式锁。
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
多线程模式下演示图:
要想实现分布式锁,必须满足的条件有:多进程可见、互斥、高可用、高性能、安全性等等。分布式锁的核心是实现多进程之间互斥,而满足这一点的方式有很多,常见的有以下三种:
分布式锁 | MySql | Redis | Zookeeper |
---|---|---|---|
互斥 | 利用mysql本身的互斥锁机制 | 利用setnx这样的互斥命令 | 利用节点的唯一性和有序性实现互斥 |
高可用 | 好 | 好 | 好 |
高性能 | 一般 | 好 | 一般 |
安全性 | 断开连接,自动锁解锁 | 利用锁超时时间,到期释放 | 断开节点,断开连接自动释放 |
基于redis的分布式锁:
实现分布式锁时要实现的两个基本方法:
// 利用setnx的互斥特性
setnx lock thread1
// 添加超时到期
expire thread1 10
// 释放锁,删除即可
del thread1
ILock.java
/**
* redis实现分布式锁
*/
public interface ILock {
/**
* @param timeoutSec 锁的有效时间,过期自动释放
* @return
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unLock();
}
SipmleRedisLock.java
public class SipmleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
public SipmleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final String KEY_PREFIX = "lock:";
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标识
String threadId = String.valueOf(Thread.currentThread().getId());
// 获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
// 这里涉及到Boolean自动拆箱的问题
return Boolean.TRUE.equals(success);
}
@Override
public void unLock() {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
一人一单抢购-伪代码
@Service
@Slf4j
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private RedisIdWorker redisIdWorker;
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询数据库
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) { // 未开始
return Result.fail("尚未开始");
}
// 3.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) { // 已结束
return Result.fail("已结束");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足");
}
// 5.一人一单
Long userId = UserHolder.getUser().getId();
// 采用分布式锁
SipmleRedisLock lock = new SipmleRedisLock("order:" + userId, stringRedisTemplate);
boolean isLock = lock.tryLock(1200);
if (!isLock) {
// 获取锁失败
return Result.fail("不允许重复下单");
}
try {
// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId, userId);
} finally {
lock.unLock();
}
}
@Transactional
public Result createVoucherOrder(Long voucherId, Long userId) {
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) { // 已存在,拒绝再次抢购
return Result.fail("已秒杀过优惠卷,拒绝再次抢购");
}
// 6.扣减库存
boolean success = seckillVoucherService.update()
// 解决位置
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id",voucherId) // where voucher_id = ?
.gt("stock",0) // and stock > 0
.update();
if (!success) {
return Result.fail("扣减失败");
}
// 7.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
// 8.返回订单id
log.info("抢购成功,订单id===>{}",orderId);
return Result.ok(orderId);
}
}
在获取锁时存入现场标识(可以用UUID表示)。
在释放锁时先获取锁中的线程标识,判断是否与当前线程标识一致。如果一致则释放锁,不一致则不释放锁。
改造后的分布式锁代码:SipmleRedisLock.java
public class SipmleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
public SipmleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
// 这里涉及到Boolean自动拆箱的问题
return Boolean.TRUE.equals(success);
}
@Override
public void unLock() {
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取线程中的锁标识
String lockId = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
if (threadId.equals(lockId)) {
// 释放锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,它的基本语法可以参考网站: https://www.runoob.com/lua/lua-tutorial.html
redis中提供了调用lua的函数:redis.call(‘命令名称’,‘key’,‘其它参数’,…)
比如一下lua脚本:
redis.call('set','name','jack')
local name = redis.call('get','name')
return name
redis调用Lua脚本:
// 0代表传参的数量
EVAL "return redis.call('set', 'name', 'jack')" 0
如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:
EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 name Rose
所以采用Lua脚本执行以下操作:
因此采用lua脚本来实现分布式锁:
lua脚本如下:
-- 比较线程标识与锁中的标识是否一致
if (redis.call('get', KEYS[1]) == ARGV[1]) then
-- 释放锁
return redis.call('del', KEYS[1])
end
return 0
SipmleRedisLock.java
public class SipmleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
public SipmleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
// 静态块先加载lua脚本,避免每次线程获取锁时都去调用脚本
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 获取锁
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
// 这里涉及到Boolean自动拆箱的问题
return Boolean.TRUE.equals(success);
}
@Override
public void unLock() {
// 获取线程中的锁标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 调用lua脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
threadId
);
}
}
redis分布式锁总结:
基于Redis的分布式锁实现思路:
利用set nx ex获取锁,并设置过期时间,保存线程标识;
释放锁时先判断线程标识是否与自己一致,一致则删除锁;
特性:
利用set nx满足互斥性;
利用set ex保证故障时锁依然能释放,避免死锁,提高安全性利用Redis集群保证高可用和高并发特性;
基于setnx实现的分布式锁存在下面的问题:
基于这些问题,此处引入Redisson:
Redisson是一个在Redis的基础上实现的Java驻内存数据网格 (In-Memory Data Grid)。它不仅提供了一系列的分布的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
官网地址: https://redisson.org
GitHub地址: https://github.com/redisson/redisson
因此以后使用分布式锁可直接使用Redisson
<dependency>
<groupId>org.redissongroupId>
<artifactId>redissonartifactId>
<version>3.16.8version>
dependency>
@Configuration
public class RedisConfig {
@Bean
public RedissonClient redissonClient() {
// 配置类
Config config = new Config();
// 加redis地址,这里加了单点的地址,也可以使用config,useClusterServers()添加集群地址
config.useSingleServer().
setAddress("redis://192,168,91,8:6379").
setPassword("123456");
// 创建客户端
return Redisson.create(config);
}
}
RLock lock = redissonClient.getLock("lockName"); // 指定锁的名字
boolean isLock = lock.tryLock(); // 加锁
lock.unlock(); // 释放锁
上面已介绍了setnx分布式锁存在的如下问题:
现详细介绍 Redisson 是如何解决这四种问题的!
Redisson可重入锁采用hash结构的key-value进行存储,由于value可存入多个字段,以key为锁名,value存储当前线程标识和重入次数:
释放锁时并不直接删除该锁,而是对重入次数进行减一,直到次数为0时才删除。加锁与释放锁的流程图如下:
Redisson底层用Lua脚本来保证了锁各操作的多条 redis 命令的原子性。
/**
* @param waitTime 失败重试时间
* @param leaseTime 锁自动失效释放时间
* @param unit 传入的时间单位
*/
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;
这三个参数可以都不传,也可以只传waitTime和TimeUnit ,也可以同时传这三个参数。
下面以传递waitTime和TimeUnit两个参数一步步进入底层代码进行分析:
步骤一:同时按住ctrl+alt+鼠标左键,点击tryLock,就会进入底层源码:
步骤三:此处代码讲解:
time:把失败重试时间转为毫秒;
current:记录当前时间,毫秒;
threadId:当前线程标识;
ttl:此处的ttl是重点代码,因此进入tryAcquire继续分析。
发现进入tryAcquireOnceAsync()方法后是根据 leaseTime 来做判断,其实在这个类中已经先对 leaseTime 进行了处理:没传值时默认就是 -1,传了值默认就是30秒,并且有一个定时任务每隔10秒重置 leaseTime 等于30(下面有讲解)。
继续点击 internalLockLeaseTime,进入后发现传了 leaseTime 参数后,只要不为-1, internalLockLeaseTime 默认都是30秒,并且替代 leaseTime :
步骤五:回到步骤四的 private RFuture tryAcquireAsync方法,此处的 internalLockLeaseTime 默认是30秒,并且替代 了传入的 leaseTime :
进入满足条件的 tryLockInnerAsync:最终看到操作redis的lua脚本,lua脚本此处是写死的,我们一句句分析:
// 可能大家对lua语法不太熟悉,此处我转换成熟悉的if形式供大家分析
if (redis.call('exists', KEYS[1]) == 0) { // 判断步骤一传入的锁name是否存在,等于0表示不存在
/**
* 不存在就存入hash结构
* key(KEYS[1]):步骤一传入的锁name
* value(ARGV[2])- field:线程标识
* 重入次数+1
*/
redis.call('hincrby', KEYS[1], ARGV[2], 1);
/**
* 设置key的过期时间(ARGV[1]):waitTime
*/
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil; // 获取锁成功,返回nil,就是null
}
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) { // 如果锁存在,并且判断锁标识是否是当前线程的。等于1标识锁存在
/**
* 存在且属于当前线程,重入次数+1
*/
redis.call('hincrby', KEYS[1], ARGV[2], 1);
/**
* 设置过期时间:waitTime
*/
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil; // 获取锁成功,返回nil,就是null
}
// 如果获取锁失败,证明已存在锁,则返回锁的过期时间
// pttl:返回key有效期的毫秒值
return redis.call('pttl', KEYS[1]);
经过上述分析,就得到了步骤三的 ttl,如果 ttl 为null,返回true代表获取锁成功,否则返回的是锁的过期时间(毫秒值)。
如果传入的等待时间 - 获取锁失败耗时还有空余,则代表还有时间去尝试重新获取锁,但此时也不是立即就去重新获取,因为加锁的那个线程可能还在执行业务代码,锁还未释放,立即重新获取势必也会失败。subscribe代码代表订阅加锁的那个线程的释放锁信号。
如果释放了锁,就可以一直循环去重新获取,没释放并就等待,可重试问题就这样解决了。
由于锁失效问题是由主从同步不一致导致,因此我们取消主从节点,把redis各节点改为平行节点,这样就算某一个节点宕机,锁依然还是有效的。当其他线程来获取锁时,只有所有节点的锁都获取锁成功,才算获取锁成功;所有节点释放锁成功,才算释放锁成功。这个由多个锁组成的新锁在redision中也有一个新的名字:multiLock(连锁)。
// 模拟三台redis集群
lock1 = redissonClient1.getLock("lockName");
lock2 = redissonClient2.getLock("lockName");
lock3 = redissonClient3.getLock("lockName");
lock = redissonClient1.getMultiLock(lock1, lock2, lock3);
// 获取锁
boolean isLock = lock.tryLock();
// 释放锁
lock.unlock();
由于秒杀优惠卷的整个流程步骤较多,容易造成效率低下,因此把部分步骤迁移至redis中做处理,由于redis的性能较高,能提升整个业务的效率。
并且在redis中采用Lua脚本来保证多条redis命令的原子性:
需求:
对于阻塞队列,此处采用Redis中的stream作为消息队列,实现异步下单:
秒杀伪代码改造:
// TODO
消息队列,字面意思就是存放消息的队列。
Redis的 list 数据结构是一个双向链表,很容易模拟出队列效果。队列是入口和出口不在一边,因此我们可以利用: LPUSH 结合 RPOP、或者 RPUSH 结合 LPOP来实现。不过要注意的是,当队列中没有消息时RPOP或LPOP操作会返回null,并不像JVM的阻塞队列那样会阻塞并等待消息。因此这里应该使用 BRPOP 或者 BLPOP 来实现阻塞效果。
blpop 或 brpop监听消息的时候,需要设置一个有效时间:
lpush q1 a b c // 先依次存入队列q1,元素为 a b c
brpop q1 10 // 取的时候需要设置有效时间,不然会报错
优点:
缺点:
Pubsub (发布订阅) 是Redis2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。
subscribe channel :订阅一个或多个频道
publish channel msg :向一个频道发送消息
psubscribe pattern : 订阅与pattern格式匹配的所有频道
基于PubSub的消息队列有哪些优缺点?
优点:
缺点:
Stream 是 Redis 5.0引入的一种新数据类型,可以实现一个功能非常完善的消息队列,支持数据持久化。
发送消息命令:xadd
例如:
需要提供key,消息ID方案,消息内容,其中消息内容为key-value型数据。 ID,最常使用 *,表示由Redis生成消息ID,这也是强烈建议的方案。 field string [field string…],就是当前消息内容,由1个或多个key-value构成。
读取消息方式之一:xread
例如:使用xread读取第一个消息
xread读消息时分为阻塞和非阻塞模式,使用BLOCK选项可以表示阻塞模式,需要设置阻塞时长。非阻塞模式下,读取完毕(即使没有任何消息)立即返回,而在阻塞模式下,若读取不到内容,则阻塞等待。
以阻塞的方式读取最新消息:
在实际开发中,我们可以循环调用xread阻塞方式来查询最新消息,从而实现持续监听队列的效果,伪代码如下:
while (true) {
// 以阻塞的方式读取队列中的消息,最多等待2秒
Object msg = redis.execute("xread count 1 block 2000 streams user $");
if (msg == null) {
continue;
}
// 处理消息
handleMessage(msg);
}
但是,当我们指定id为 $ 时,代表读取最新的消息,如果我们处理一条消息的过程中,又有超过1条以上的消息到达队列,则下次获取时也只能获取到最新的一条,会出现 漏读 消息的问题。
stream 类型消息队列的 xread 命令特点:
由此可见还是存在多种弊端,因此,需要寻找另一种方案:消费者组。
消费者组(Consumer Group):当多个消费者同时消费一个消息队列时,就会重复的消费相同的消息,假如消息队列中有10条消息,三个消费者都会重复去消费这10条消息。因此将多个消费者划分到一个组中,监听同一个队列。消费者组具备下列特点:
127.0.0.1:6379> MULTI // muti是原子操作,保证以下5条指令同时执行
OK
127.0.0.1:6379> XADD mq * msg 1
QUEUED
127.0.0.1:6379> XADD mq * msg 2
QUEUED
127.0.0.1:6379> XADD mq * msg 3
QUEUED
127.0.0.1:6379> XADD mq * msg 4
QUEUED
127.0.0.1:6379> XADD mq * msg 5
QUEUED
127.0.0.1:6379> exec
1) "1683960419497-0"
2) "1683960419497-1"
3) "1683960419497-2"
4) "1683960419497-3"
5) "1683960419497-4"
// mq:队列名
// mqGroup :消费组名
// 参数0,表示该组从第一条消息开始消费
127.0.0.1:6379> XGROUP CREATE mq mqGroup 0
OK
127.0.0.1:6379> XREADGROUP group mqGroup consumerA count 1 streams mq > // 此处的 > 代表读取队列中的消息,写成0代表读取Pending-list中有问题的消息
1) 1) "mq"
2) 1) 1) "1683960419497-0"
2) 1) "msg"
2) "1"
// 消费者A继续消费第二条,再次执行即可
127.0.0.1:6379> XREADGROUP group mqGroup consumerA count 1 streams mq >
1) 1) "mq"
2) 1) 1) "1683960419497-1"
2) 1) "msg"
2) "2"
127.0.0.1:6379> XREADGROUP group mqGroup consumerB count 1 streams mq >
1) 1) "mq"
2) 1) 1) "1683960419497-2"
2) 1) "msg"
2) "3"
// 消费者B继续消费第四条,再次执行即可
127.0.0.1:6379> XREADGROUP group mqGroup consumerB count 1 streams mq >
1) 1) "mq"
2) 1) 1) "1683960419497-3"
2) 1) "msg"
2) "4"
127.0.0.1:6379> XREADGROUP group mqGroup consumerC count 1 streams mq >
1) 1) "mq"
2) 1) 1) "1683960419497-4"
2) 1) "msg"
2) "5"
// xack: 进行消息确认,后面跟id
127.0.0.1:6379> xack mq mqGroup 1683962319384-0 1683962319384-1 1683962319384-2 1683962319384-3 1683962319384-4
(integer) 5
// mq: 队列名
// mqGroup: 消费者组
127.0.0.1:6379> xgroup destroy mq mqGroup
(integer) 1
进行消息读取过后,需要进行消息确认才算完成,没有进行确认的消息会进入Pending-list。
127.0.0.1:6379> XPENDING mq mqGroup
1) (integer) 5 // 表示有5个已读取但未处理的消息
2) "1683966537372-0" // 起始ID
3) "1683966537372-4" // 结束ID
4) 1) 1) "consumerA"
2) "2" // 消费者A有2个
2) 1) "consumerB"
2) "2" // 消费者B有2个
3) 1) "consumerC"
2) "1" // 消费者C有1个
127.0.0.1:6379> XPENDING mq mqGroup - + 10
1) 1) "1683966537372-0"
2) "consumerA"
3) (integer) 254181 // 从读取到现在经历了254181毫秒
4) (integer) 1 // 读取的次数
2) 1) "1683966537372-1"
2) "consumerA"
3) (integer) 252139
4) (integer) 1
3) 1) "1683966537372-2"
2) "consumerB"
3) (integer) 247445
4) (integer) 1
4) 1) "1683966537372-3"
2) "consumerB"
3) (integer) 246054
4) (integer) 1
5) 1) "1683966537372-4"
2) "consumerC"
3) (integer) 241850
4) (integer)
127.0.0.1:6379> XPENDING mq mqGroup - + 10 consumerA
1) 1) "1683966537372-0"
2) "consumerA"
3) (integer) 404400
4) (integer) 1
2) 1) "1683966537372-1"
2) "consumerA"
3) (integer) 402358
4) (integer) 1
127.0.0.1:6379> xack mq mqGroup 1683966537372-0 1683966537372-1 1683966537372-2 1683966537372-3 1683966537372-4
(integer) 5
127.0.0.1:6379> XPENDING mq mqGroup - + 10
(empty array)
stream 消息队列基于消费者组特定总结:
List、PubSub、Stream三种消息队列比较:(但是redis 的这三种消息队列都是应对基本简单的项目,如果项目庞大复杂,还是推荐更专业的RabbitMQ、RocketMQ等等)