Redis基础 - 基本类型及常用命令
Redis基础 - Java客户端
Redis 基础 - 短信验证码登录
Redis 基础 - 用Redis查询商户信息
Redis 基础 - 优惠券秒杀《非集群》
Redis 基础 - 优惠券秒杀《分布式锁(初级)》
Redis 基础 - 优惠券秒杀《分布式锁(使用Redisson)》
Redis 基础 - 优惠券秒杀《初步优化(异步秒杀)》
消息队列(Message Queue)字面上看是存放消息的队列。
最简单的消息队列模型包括3个角色:
所以消息队列解决了使用阻塞队列时存在的两个安全问题。
Redis提供了三种不同的方式来实现消息队列:
消息队列是存放消息的队列。而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的阻塞队列比起来有以下几点好处:
不过也有一些缺点:
pubsub(发布订阅)是Redis2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息(即支持多消费者)。
常用的命令如下:
终端测试
比如打开三个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的消息队列的优点如下:
而缺点也有,比如:
所以如果可靠性要求比较高,不建议使用pubsub类型用作消息队列,还不如使用list。
stream是Redis5.0引入的一种新数据类型,可以实现一个功能非常完善的消息队列。由于stream是数据类型,是用来存取数据的,所以他支持数据的持久化,即在数据安全这块儿绝对是有保障的。
发送消息的命令:
xadd key [NOMKSTREAM] [MAXLEN|MINID [=|~] threshold [LIMIT count]] *|ID field value [field 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 ...]
终端测试
在生产者控制台中,执行:
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命令特点:
在单消费模式中,如果用$用while循环获取消息时,有可能会出现漏掉消息的情况。消费者组模式可以解决这个问题。
消费者组(consumer group)是将多个消费者划分到一个组中,监听同一个队列。
创建消费者组:
XGROUP CREATE key groupName ID [MKSTREAM]
$
最后一个消息,0则代表队列中第一个消息。(队列中的消息已经存在的也想消费,就设置为0,如果只想消费最新的,就设置为$
)其他常见命令:
# 删除制定的消费者组
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 ...]
终端测试
在生产者终端执行在消息队列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]]
可以执行: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:学了个寂寞。
先用命令行创建一个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);
}