Redis 基础 - 优惠券秒杀《基于Redis消息队列实现》

Redis基础 - 基本类型及常用命令
Redis基础 - Java客户端
Redis 基础 - 短信验证码登录
Redis 基础 - 用Redis查询商户信息
Redis 基础 - 优惠券秒杀《非集群》
Redis 基础 - 优惠券秒杀《分布式锁(初级)》
Redis 基础 - 优惠券秒杀《分布式锁(使用Redisson)》
Redis 基础 - 优惠券秒杀《初步优化(异步秒杀)》

消息队列

消息队列(Message Queue)字面上看是存放消息的队列。

最简单的消息队列模型包括3个角色:

  • 消息队列:存储和管理消息,也被称为消息代理(Message Broker)
  • 生产者:发送消息到消息队列
  • 消费者:从消息队列获取消息并处理消息

消息队列和JDK阻塞队列的区别

  • 消息队列是JVM以外的独立服务,所以她不受JVM内存的限制
  • 消息队列不仅仅是做数据存储,她还会确保数据的安全,即存到消息队列的消息,都要去做持久化,这样的话就算服务宕机或重启,数据也不会丢失。而且她还要在消息投递给消费者以后,要求消费者做消息的确认,如果消息没有确认,那么这个消息在队列当中依然存在,下一次再投递给消费者,让她继续处理,直到成功为止,即确保消息至少被消费一次。
所以消息队列解决了使用阻塞队列时存在的两个安全问题。

Redis提供的消息队列

Redis提供了三种不同的方式来实现消息队列:

  • list结构:基于list结构模拟消息队列
  • PubSub:基本的点对点消息模型
  • Stream:比较完善的消息队列模型

基于list结构模拟消息队列(推荐指数 ★☆☆☆☆)

消息队列是存放消息的队列。而Redis的list数据结构是一个双向链表,很容易模拟出队列效果。队列是入口和出口不在一边,因此我们可以利用:LPUSH结合RPOP、或者RPUSH结合LPOP来实现。不过要注意的是,当队列中没有消息时RPOP或LPOP操作会返回null,并不像JVM的阻塞队列那样会阻塞并等待消息,因此这里应该使用BRPOP或者BLPOP来实现阻塞效果(B就是block即阻塞的意思)。

终端测试
比如打开两个Redis终端客户端,一个是生产者,一个是消费者。在消费者终端执行监听:

# li是key,20(秒)是要监听的阻塞时间。
BRPOP li 20

接着在生产者终端执行存元素:

LPUSH li e1 e2

执行后在消费者终端控制台里,就能看到立马输出了e1。当然,这时候里面还有元素e2,再执行BRPOP li 20的话就能拿到e2。然后再执行的话,由于已经没元素了,所以再次卡住了,直到有新元素为止。即利用BRPOP和LPUSH就能实现阻塞队列的效果。

和JDK的阻塞队列比起来有以下几点好处:

  • 她是JVM以外的独立的存储,所以他不依赖于JVM内存,所以不用担心存储上限的问题。
  • Redis是支持数据持久化的,所以数据安全,所以一旦数据存储到队列里,就完成了持久化,哪怕宕机
    也不会数据丢失,所以安全性比JDK的阻塞队列好。
  • 她还保证了消息的有序性,即先进先出。

不过也有一些缺点:

  • 和JDK的阻塞队列一样,也有消息丢失的问题。比如假设从Redis队列中取到一条消息,取到以后还没来得及处理就挂掉了或出现异常了,那这个消息就丢失了,因为R/LPOP这个命令拿出来后直接从队列里移除。
  • 只支持单消费者,即我发送的消息一旦有一个人拿走了,就从队列移除了,其他的人就拿不到那个消息了。即没办法实现一条消息被很多人使用的效果。

基于pubsub的消息队列(推荐指数 ☆☆☆☆☆)

pubsub(发布订阅)是Redis2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息(即支持多消费者)。

常用的命令如下:

  • SUBSCRIBE channel [channel] 订阅一个或多个频道
  • PUBLISH channel msg 向一个频道发送消息
  • PSUBSCRIBE pattern[pattern] 订阅与pattern格式匹配的所有频道。(通配符的格式是比如?是一个字符,即h?llo的话可以是hello,hallo之类的;是多个字符,比如hllo可以使hllo,heeeeello之类的;[ae]的话比如h[ae]llo时只能是hallo或hello)

终端测试

比如打开三个Redis客户端控制台,即分别代表着生产者、消费者1、消费者2。消费者1先订阅频道:

# 这里频道名是“order.q1”,其实是为了方便这么取的,比如点号没有什么特殊意义。
SUBSCRIBE order.q1

执行后就会输出:

ReadingMessage... (Please ctrl+c to quit)
1)order.q1

之后就卡在那里,即这种模式是天生的阻塞式的,ReadingMessage就是正在等着要读消息。

消费者2也订阅频道,这次也可以试试用PSUBSCRIBE,即如下:

PSUBSCRIBE order.*

执行后,和上面一样也输出如下:

ReadingMessage... (Please ctrl+c to quit)
1)order.*

接下来在生产者终端执行发布消息:

PUBLISH order.q1 hello

执行后在消费者1和消费者2的终端可以看到立即输出了“hello”。即两边都收到消息了。
如果再生产者终端再执行 PUBLISH order.q2 hello2 此时消费者1不输出,但消费者2输出“hello2”,因为消费者2订阅的是“order.*”所以匹配。

基于pubsub的消息队列的优点如下:

  • 采用发布订阅模型,支持多生产、多消费。即可以把一条消息发给多个消费者,也可以发给部分消费者。

而缺点也有,比如:

  • 不支持数据持久化。list支持数据持久化是因为list本质不是消息队列,她的本质是链表,目的本来就是要数据存储,只不过我们把他当成消息队列来用了而已。而pubsub她本身设计出来就是为了消息发送,所以当我们发送一条消息时,如果这个频道没有被任何人订阅,那么这个消息直接就丢失了。即我们发出的所有消息不会在Redis里保存。
  • 无法避免消息丢失。就像上面所说,放完了没人收,就直接丢了。
  • 消息堆积有上限,超出时数据丢失。上面说消息不在内存中保存,怎么又堆积了呢,因为当我们发送一个消息时,如果有消费者监听,会在消费者那里有一个缓存区域,把这个消息缓存下来,接下来消费者去处理,如果消费者处理过程中,比如说消费者处理的比较慢,比如处理一条消息都耗时一秒,若在这1秒内又来了十几条消息,那么这些消息都缓存在消费者那里,而消费者那里的缓存空间是有上限的,若超出,就会丢失。
所以如果可靠性要求比较高,不建议使用pubsub类型用作消息队列,还不如使用list。

基于stream的消息队列 - 单消费模式(推荐指数 ★★★☆☆)

stream是Redis5.0引入的一种新数据类型,可以实现一个功能非常完善的消息队列。由于stream是数据类型,是用来存取数据的,所以他支持数据的持久化,即在数据安全这块儿绝对是有保障的。

发送消息的命令:

xadd key [NOMKSTREAM] [MAXLEN|MINID [=|~] threshold [LIMIT count]] *|ID field value [field value...]
  • 可选参数NOMKSTREAM:如果队列不存在,是否自动创建队列,默认是自动创建。
  • 可选参数[MAXLEN|MINID [=|~] threshold [LIMIT count]]:设置消息队列的最大消息数量。若设置为1000,将来如果消息数量超过了1000,一直没有消费者去处理,那么一些旧的消息就会被剔除掉。不给值就是不设置上限。
  • 参数*|ID:消息的唯一id,代表由Redis自动生成。格式是“时间戳-递增数字”,例如1644565656-2,自己指定时要注意这个格式(建议使用)。
  • 参数field value [field value…]:发送到队列中的消息,一个field value称为一个Entry。格式就是多个key-value键值对。

由于可选参数较多,所以最简单的用法如下:

# 创建名为users的队列,并向其中发送一个消息,内容是:{name=jack,age=21},并且使用Redis自动生成id。
XADD users * name jack age 21

读取消息的方式之一(XREAD):

XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...]
  • 可选参数[COUNT count]:每次读取消息的最大数量,即可以一次读多条,也可以一次读一条。
  • 可选参数[BLOCK milliseconds]:当没有消息时,是否阻塞、阻塞时长。如果不给,就不阻塞。如果给0,就是永久等待。
  • 参数STREAMS key [key …]:要从哪个队列读取消息,key就是队列名。
  • 参数ID [ID …]:比如[COUNT count]设置1,即读1个消息,那从第几个开始读?可以指定起始的消息id,即只返回大于该id的消息,0 代表从第一个消息开始,$代表读取最新的消息(没读过的)。

终端测试

在生产者控制台中,执行:

XADD s1 * k1 v1

执行后,会输出消息id。还可以用XLEN命令查看消息的数量:

XLEN s1

执行后,输出1。

在消费者1终端执行如下:

XREAD COUNT 1 STREAMS s1 0

COUNT 1 读一条,STREAMS s1 哪个队列,0 从第一条开始读。执行后输出了k1 v1以及她的消息id。
同一个消息在消费者2里执行XREAD COUNT 1 STREAMS s1 0后也可以读。然后又在消费者1这里执行XREAD COUNT 1 STREAMS s1 0后发现还能读。也就是说STREAMS这种数据类型,一个消息你读完了以后不会删除,即这个消息是永久存在的。想读最新消息,把0替换成$就行。但发现返回nil,这是因为队列中的消息,你都读过了,没有新消息,所以返回值就是nil。

如果想等待最新的消息,可以用BLOCK(BLOCK 0,0是永久阻塞):

XREAD COUNT 1 BLOCK 0 STREAMS s1 0

执行之后,就会阻塞了。然后在生产者终端里再加一条消息:

XADD s1 * k2 v2

执行后,消费者1的窗口里立马收到并输出了k2 v2以及她的id。

在业务开发中,我们可以用循环调用XREAD阻塞方式来查询最新消息,从而实现持续监听队列的效果,伪代码如下:

while(true) {
	// 尝试读取队列中的消息,最多阻塞2秒
	Object msg = redis.execute("XREAD COUNT 1 BLOCK 2000 STREAMS users $");

	if (msg == null) continue;

	// 处理消息
	handleMessage(msg);
}

注意:
$使用XREAD是有小问题的。比如当我们指定起始id为$时,代表读取最新的消息,如果我们处理一条消息的过程中(即在执行handleMessage中的时候),又有超过1条以上的消息到达队列,则下次获取时也只能获取到最新的一条,会出现漏读消息的问题。

STREAM类型消息队列的XREAD命令特点:

  • 消息可回溯,即消息读完了之后不消失,永久的保存在队列当中。
  • 一个消息可以被多个消费者读取。
  • 可以阻塞读取。
  • 有消息漏读的风险。

基于stream的消息队列 - 消费者组(推荐指数 ★★★★☆)

在单消费模式中,如果用$用while循环获取消息时,有可能会出现漏掉消息的情况。消费者组模式可以解决这个问题。

消费者组(consumer group)是将多个消费者划分到一个组中,监听同一个队列。

  • 消息分流
    队列中的消息会分流给组内的不同消费者,而不是重复消费,从而加快消息处理的速度。即若多个消费者在一个组里,他们之间是竞争关系,凡是进入这个组的消息,大家可以去抢,这样一来处理消息的速度大大加快,从一定程度上可以避免消息堆积的问题。
  • 消息标识
    消费者组会维护一个标示,记录最后一个被处理的消息(注意:不是最新的消息,而是最后一个被处理的消息),哪怕消费者宕机重启,还会从标示之后读取消息,确保每一个消息都会被消费(因此单消费模式时漏掉消息的问题解决了)。
  • 消息确认
    消费者获取消息后,消息处于pending(翻译为待处理)状态,并存入一个pending-list。当处理完成后,需要通过XACK来确认消息,标记消息为已处理,才会从pending-list中移除(所以解决了数据丢失的问题)。

创建消费者组:

XGROUP CREATE key groupName ID [MKSTREAM]
  • 参数key:队列的名称,你要给哪个队列创建消费者组
  • 参数groupName:消费者组名称
  • 参数ID:这个组监听消息时从哪开始监听,$最后一个消息,0则代表队列中第一个消息。(队列中的消息已经存在的也想消费,就设置为0,如果只想消费最新的,就设置为$
  • 可选参数MKSTREAM:队列不存在时,自动创建队列。(不给的话,队列不存在时,无法创建消费者组)

其他常见命令:

# 删除制定的消费者组
XGROUP DESTORY key groupName

# 给指定的消费者组添加消费者
XGROUP CREATECONSUMER key groupname consumername

# 删除消费者组中的指定消费者
XGROUP DELCONSUMER key groupname consumername

一般情况下,我们并不需要自己去添加消费者,因为在组当中指定一个消费者并且监听消息的时候,如果她发现这个消费者不存在,他就会自动帮我们创建出来了,所以并不需要手动去创建。

从消费者组内读取消息(消费消息):

XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]
  • 参数GROUP group:要从哪个组消费
  • 参数consumer:消费者名称,如果消费者不存在,会自动创建一个消费者
  • 可选参数[COUNT count]:本次查询的最大数量
  • 可选参数[BLOCK milliseconds]:当没有消息时最长等待时间。给的话就阻塞并等。不给就不阻塞,没有直接返回。
  • 可选参数[NOACK]:即无需手动ACK,获取到消息后自动确认。(当我们拿到消息以后,处理完了一定要确认,但如果你这里给了NOACK,即含义就是不用消费者确认了,消息投递给消费者那一刻会自动确认,也就是说她根本不会进入pending-list,那如果我们给了NOACK,我们拿到了消息,结果是我挂了,这个消息又没有进pending-list,将来就会出问题了,消息就丢失了,因为不知道到底哪个消息失败了,所以不建议大家去设置,不配就行。)
  • 参数STREAMS key [key …]:监听哪个队列
  • 参数ID [ID …]:获取消息的起始id(跟单消费模式不一样)。
    1)“>”:从下一个未消费的消息开始。因为消费者组内会维护一个标记,标记的是上一次消费的是哪一个消息,因此XREADGROUP时指定了“>”时,他就会从那个标记的下一个消息开始,确保所有消息都会被消费。
    2)其他:根据指定id从pending-list中获取已消费但未确认的消息,例如0,是从Pending-list中的第一个消息开始。因此正常情况下,都应该给“>”,读那些未消费的消息。如果出现的异常情况,我们再从pending-list里读那些已消费但未确认的消息。

终端测试

在生产者终端执行在消息队列s1(用上面已经创建好的队列)中创建消费组g1:

XGROUP CREATE s1 g1 0

假设目前在这个队列s1中有六条消息,k1k6(v1v6)。
在消费者1窗口中执行消费消息(c1是消费者名称):

XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >

执行之后,由于这个队列中一个消息都没消费过,所以会输出k1 v1,再执行后输出k2 v2。此时在在消费者2窗口中执行消费消息:

XREADGROUP GROUP g1 c2 COUNT 1 BLOCK 2000 STREAMS s1 >

执行后,输出的是k3 v3。因为在一个组内只有一个标记,不管是谁消费的,只要消费到那里,我就给你标记。所以不管哪个消费者来,下一个消费一定是k3 v3开始。即消费者2窗口再执行时是k4 v4,然后再回到消费者1窗口消费是k5 v5。

但目前消费了这么多,但都没确认过。要读一个消息,一定要确认一个消息,代表你正常处理。目前,刚才消费的k1~k5都在pending-list里,所有都需要确认。假如“16456456456-0”是k1的id,那么执行如下:

XACK s1 g1 16456456456-0

就这样一个一个用每个消息的id执行就行,也可以一次性加多个id。

比如出现异常,这时候可能需要查看pending-list,命令结构如下:

XPENDING key group [[IDLE min-idle-time] star end count [consumer]]
  • 参数key:消息队列
  • 参数group:消息组
  • 可选参数[IDLE min-idle-time]:空闲时间,即获取消息以后,确认之前的这段时间,比如给了5000,空闲时间超过5000毫秒以上的这些消息,你才要要,别的就不要了。
  • 参数start和end:Pending-list中你想要的最小id和最大id。若指定“- +”代表所有,-是最小,+是最大。
  • 参数count:你要获取的数量
  • 可选参数[consumer]:你要获取哪个消费者的pending-list。

可以执行:XPENDING s1 g1 - + 10,即从最小到最大中取10个,返回的是所有未处理的消息id。

那如何读取pending-list的消息呢?把“>”号改为“0”,就表示读取pending-list的第一条消息。

XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 0

之后如果有未处理消息的话,可以再执行XACK确认这个消息。处理之后,pending-list里就会空了。

list pubsub stream
消息持久化 支持 不支持 支持
阻塞读取 支持 支持 支持
消息堆积处理 受限于内存空间,可以利用多消费者加快处理。 受限于消费者缓冲区 受限于队列长度,可以利用消费者组提高消费速度,减少堆积
消息确认机制 不支持 不支持 支持
消息回溯 不支持 不支持 支持

因此如果要在Redis消息队列这三种中选一种,肯定是选stream,但如果业务比较庞大,对于消息队列的要求更加严格,建议使用更加专业的消息队列,比如rabbitMQ之类的,这是因为stream虽然支持消息的持久化,但这种持久化是依赖于Redis本身持久化的,而Redis的持久化其实也不能保证万无一失,她是有丢失风险的。而且stream的消息确认机制只支持消费者的确认机制,而不支持生产者的确认机制,比如生产者在发消息的过程中丢失消息,该怎么办呢。另外还有消息的事务机制、在多消费者下的消息有序性等等这些问题都需要更加强大的消息队列去支持。不过对队列的要求并没有那么多,比如中小型企业,事实上stream已经满足业务需要了。

网友1:经典白学。
网友2:学了个寂寞。

基于Redis的Stream结构作为消息队列,实现秒杀下单

先用命令行创建一个stream类型的消息队列,名为stream.orders。

XGROUP CREATE stream.orders g1 0 MKSTREAM

这样的话创建了队列和消费者组。由于这里指定了MKSTREAM参数,所以队列不存在时,自动创建队列。

修改之前的秒杀下单lua脚本,本来的lua脚本只判断了是否具有抢购资格,而由于往队列添加消息也是Redis命令,所以可以直接在lua里加这一部分。

/resouces/seckill.lua

-- 1,参数列表
-- 1.1 优惠券id(因为要从Redis查库存)
local voucherId = ARGV[1] -- 不用KEYS是因为,这个不是key,而是要拼接才能用的
-- 1.2 用户id(因为要在set集合中查询是否存在)
local userId = ARGV[2]
-- 1.3 订单id(要把这三个id添加到消息队列中)
local orderId = ARGV[3]

-- 2,数据key
-- 2.1 库存key
local stockKey = "seckill:stock:" .. voucherId -- lua中字符串拼接是两个点
-- 2.2 订单key(set的key,value中保存购买这个优惠券的所有用户id)
local orderKey = "seckill:order:" .. voucherId

-- 3,脚本业务
-- 3.1 判断库存是否充足(tonumber是把字符串转成数字,不然没法和数字比较大小)
if (tonumber(redis.call("get", stockKey)) <= 0) then
	-- 3.2 库存不足,返回1
	return 1
end 
-- 3.2 判断用户是否下单(判断某个元素是不是set集合的成员可以用sismember命令,返回值是1或0)
if (redis.call("sismember", orderKey, userId) == 1) then
	-- 3.3 存在说明是重复下单,返回2
	return 2
end
-- 能执行到这里,就说明库存够,而且没下过单
-- 3.4 扣库存
redis.call("incrby", stockKey, -1)
-- 3.5 下单(把用户保存到set)
redis.call("sadd", orderKey, userId)

-- 3.6 发送消息到队列中 XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
-- orderId的key建议叫id,因为这里的VoucherOrder实体类(对应tb_voucher_order订单表)的字段的主键即订单id的变量时id,所以将来方便。
return 0

VoucherOrderServiceImpl.java

@Resource
prviate ISeckillVoucherService iSeckillVoucherService;

@Resource
prviate RedisIdWorker redisIdWorker;

@Resource
prviate StringRedisTemplate stringRedisTemplate;

// 注入RedissonClient
@Resource
private RedissonClient redissonClient;

private static final DefultRedisScript<Long> SECKILL_SCRIPT;

static {
	// 随着类的加载而执行,并且只会执行一次,因为这玩意(seckill.lua)加载一次可以,没必要每次都加载
	SECKILL_SCRIPT = new DefaultRedisScript<>();
	SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
	SECKILL_SCRIPT.setResultType(Long.class);// 设置返回值为long
}

// 由于要开独立线程,所以需要线程池和任务
private static final ExcutorService SECKILL_ORDER_EXCUTOR = Excutors.newSingleThreadExcutor();// 线程池

// 任务
/*
什么时候执行这个任务呢,肯定是用户秒杀抢购之前开始,因为用户一旦开始秒杀,他就会向队列里添加
新的订单,那我们任务就要去队列取出订单信息,所以他必须在队列之前执行。事实上,这个项目一启动,
用户随时可能来抢购,所以应该在这个类初始化之后赶紧的执行这个任务。可以通过spring提供的注解来做:
@PostConstruct,这个注解是在当前类初始化完毕后,去执行init方法。
*/

@PostConstruct
private void init() {
	// 类初始化完毕后,执行任务
	SECKILL_ORDER_EXCUTOR.submit(new VoucherOrderHandler());
}

private class VoucherOrderHandler implements Runnable {
	@Override
	public void run() {
		while(true) {
			try {
				// 1,获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS streams.order >
				// (消费者名字肯定是应该将来配到Yml文件里,然后不同的节点有多个消费者名字,这样就不冲突,这里先写死为c1)
				List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
					Consumer.from("g1", "c1"), // GROUP g1 c1
					StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)), // COUNT 1 BLOCK 2000
					StreamOffset.create("streams.order", ReadOffset.lastConsumed()) // streams.order >
				);// 返回值之所以是List,是因为count不一定肯定是1,还可能会读到多个。

				// 2,判断消息是否获取成功
				if (list == null || list.isEmpty()) {
					// 2.1 如果获取失败,说明没有消息,继续下一次循环
					continue;// 等待2秒后,依然是空的话,就继续循环下一次
				}
				// 3,解析消息中的订单信息
				/*MapRecord底层就是map,String是消息的id;Object, Object是因为xadd时存的
				时候是键值对形式,比如lua中 redis.call('xadd', 'stream.orders', '*', 'userId', userId,
				'voucherId', voucherId, 'id', orderId),而这里的key们都是voucherOrder的字段名*/
				MapRecord<String, Object, Object> record = list.get(0);// 因为在这里是count 1,所以直接取list中的第一个元素即可
				Map<Object, Object> values = record.getValue();// record.getId()是取消息的id
				// 把values转成voucherOrder,可以使用hutool工具类
				VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);// true是说明转的时候出错就忽略
				// 4 如果获取成功,可以创建订单
				handleVoucherOrder(voucherOrder);
				// 5,ACK确认 SACK streams.orders g1 消息的id  

stringRedisTemplate.opsForStream().acknowledge("streams.orders", "g1", record.getId());
			} catch(Exception e) {
				// 若抛异常,就会没有被ACK确认,所以要去Pendinglist去尝试取出来
				handlerPendingList();
			}
		}
	}

	private void handlerPendingList() {
		while(true) {
			try {
				// 1,获pendinglist中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 STREAMS streams.order 0
				// (消费者名字肯定是应该将来配到Yml文件里,然后不同的节点有多个消费者名字,这样就不冲突,这里先写死为c1)
				List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
					Consumer.from("g1", "c1"), // GROUP g1 c1
					StreamReadOptions.empty().count(1)), // COUNT 1 
					StreamOffset.create("streams.order", ReadOffset.from("0")) // streams.order 0
				);// 返回值之所以是List,是因为count不一定肯定是1,还可能会读到多个。

				// 2,判断消息是否获取成功
				if (list == null || list.isEmpty()) {
					// 2.1 如果获取失败,说明pending list中没有消息,结束循环
					break;// pending list里没有,就没必要继续循环
				}
				// 3,解析消息中的订单信息
				/*MapRecord底层就是map,String是消息的id;Object, Object是因为xadd时存的
				时候是键值对形式,比如lua中 redis.call('xadd', 'stream.orders', '*', 'userId', userId,
				'voucherId', voucherId, 'id', orderId),而这里的key们都是voucherOrder的字段名*/
				MapRecord<String, Object, Object> record = list.get(0);// 因为在这里是count 1,所以直接取list中的第一个元素即可
				Map<Object, Object> values = record.getValue();// record.getId()是取消息的id
				// 把values转成voucherOrder,可以使用hutool工具类
				VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);// true是说明转的时候出错就忽略
				// 4 如果获取成功,可以创建订单
				handleVoucherOrder(voucherOrder);
				// 5,ACK确认 SACK streams.orders g1 消息的id  
				stringRedisTemplate.opsForStream().acknowledge("streams.orders", "g1", record.getId());
			} catch(Exception e) {
				// 若处理pendinglist过程中抛异常,就这么放着就行,没必要再调用自己,因为就这么放着她也还会循环。
				// 如果出异常后,再循环时再出异常,担心这个情况时执行频率太高的话,可以设置睡眠
				Thread.sleep(50);// 这样的话,执行的频率就不会很高了。
			}
		}
	}
}

// 异步处理创建订单干入数据库
private void handleVoucherOrder(VoucherOrder voucherOrder) {
	// 1,获取用户(因为是其他线程异步处理,所以不能从UserHolder取)
	Long userId = voucherOrder.getUserId();

	// 2,锁对象
	/*
	从理论上讲,这里不加锁也OK,因为已经在Redis里做了并发判断了,这里再加一次锁,其实
	就是做一个兜底以防万一,万一Redis出了问题没判断成功呢,虽然这种可能性几乎没有,但是
	该做的判断还是要做一下
	*/
	RLock lock = redissonClient.getLock("lock:order:" + userId);
	
	// 3,获取锁
	boolean isLock = lock.tryLock();

	// 4,判断是否获取锁成功
	if (!isLock) {
		// 若不成功,记录日志就行,因为是异步执行,没必要返回给前端
		return;
	}

	try {
		proxy.createVoucherOrder(voucherOrder);
	} finally {
		lock.unlock();
	}
}


IVoucherOrderService proxy;// 这里定义,子线程可以用。

@Override

// @Transactional 由于这个方法里只有查的,所以不需要这个。
public Result seckillVoucher(Long voucherId) {

	// 获取用户
	Long userId = UserHolder.getUser().getId(); // 用户id

	// 获取订单id
	long order_id = redisIdWorker.nextId("order");

	// 1,执行lua脚本,结果告诉我们有没有购买的资格,而且还会发送订单信息到消息队列
	Long result = stringRedisTemplate.excute(
		SECKILL_SCRIPT,
		Collections.emptyList(),// 传空集合,因为这里的Lua中不需要keys
		voucherId.toString(),
		userId.toString(),
		String.valueOf(orderId)
	);

	// 2,判断结果是否为0
	int r = result.intValue();
	if (r != 0) {
		// 2.1 不为0,代表没有购买资格
		return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
	}

	// 2.8 获取代理对象。拿到当前对象的代理对象(获取跟事务有关的代理对象)
	// 因为在子线程里是获取不到的,所以在主线程获取这个,在子线程直接使用她
	proxy = (IVoucherOrderService)AopContext.currentProxy();

	// 3,返回订单id
	return Result.ok(orderId);
}

@Transactional // 更新的都跑到这里了,所以这里加事务
public void createVoucherOrder (VoucherOrder voucherOrder) {
	// 5,一人一单
	Long userId = voucherOrder.getUserId();

	// 5.1 查询订单
	int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();

	// 5.2 判断是否存在
	if (count > 0) {
		// 用户已经购买过了,其实不容易出现这个情况,但以防万一记个日志
		return;
	}

	// 6,更新库存
	boolean success = seckillVoucherService.update()
				.setSql("stock = stock - 1") // set stock = stock - 1
				.eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", voucher.getStock()) // where id = ? and stock = ?
				.update();
	if (!success) {
		// 库存不足。由于Redis做了判断,所以不太可能出现,但为了以防万一,可以加日志
		return;
	}

	// 7.5 订单信息写入数据库
	save(voucherOrder);
}

你可能感兴趣的:(Redis,Redis基础,Redis秒杀,Redis消息队列,stream)