一、分布式Id:订单id
在分布式架构下,传统生成Id的方式已经不再适用,应该生成全局唯一的
分布式Id需要满足的五个特性:全局性、唯一性、安全性、可用性、高性能
@Component
public class RedisIdWorker {
@Autowired
private StringRedisTemplate stringRedisTemplate;
private final long COUNT_BITS = 32L;
private final long BEGIN = 1652197856L;
public long nextId(String keyPrefix){
//1.获取当前时间戳与项目运行时间戳的差值
long timeFiled = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) - BEGIN;
//2.生成序列化号
String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
return timeFiled << COUNT_BITS | count;
}
}
每个业务每天都重新开始计数,求出当前时间与项目运行时间戳的差值,将其左移32位在对count进行 | 操作后转化为十进制
该方法的思路:一个long类型有8个字节,用四个字节(32位)存放时间信息(136.102208 年),四个字节存放这一天的订单数量。那么这种方法从项目开始计时,能够正确运行2^32 秒,每天最多能生成2^32个id。
其实我们也可以只用16位来存储当前count,剩下48位来存放时间信息,如果一天内达不到2^32次方的订单id生成量,不如节省下来位数存储时间信息让项目运行的更久。
二、优惠券下单
我们先来看如下的流程:
这个流程咋一看好像没有问题,但实际上它涉及到一个并发问题:
库存是一个公共资源,线程对它就行读-写操作时如果不加锁则会出现超卖问题
这个问题如何解决呢?当然是通过加锁来解决,锁大致分为乐观锁和悲观锁,我们来探讨两者的差异
乐观锁
乐观锁的思想也适用于分布式项目
对于优惠券秒杀这种写多读少的业务,推荐使用悲观锁,乐观锁成功率低反而更加消耗性能
一人一单
业务流程
并发问题
使用jdk提供的锁只在它所在的jvm有效,无法锁住其它jvm,由于该项目是一个分布式项目,我们需要采取分布式锁解决该问题
分布式锁
实现方式
MySQL实现方式:通过行锁或表锁的机制达到互斥性,但是它把锁的压力给到了数据库,而数据库又是相当脆弱的部分无法面对高并发场景,所以使用MySQL实现互斥锁的业务并发量不能太大。具体看这篇博客:https://cloud.tencent.com/developer/article/1580632
Redis实现分布式锁
上面这一套流程存在一个问题,它是由锁标识一致所导致的,当线程一因为业务超时导致锁过期时,线程二获取到了锁,等到线程一执行完后释放掉的锁此时已经是线程二的了,线程三看锁没了又去获取锁…
所以设置一个锁标识是非常重要的,而并发是以线程为单位的,每个线程都有一个自己的锁标识就ok啦,那拿什么给每个线程充当锁标识呢?UUID可以嘛?理论上可以,但如果使用uuid的话,每上一次锁就要生成一个uuid,是非常消耗性能的操作,有没有更优化的操作呢?
其实每个线程都有一个天然的锁标识,那就是线程id,但是不同jvm的线程id可能重复,我们可以将uuid设置为静态变量作为锁标识的前缀,用线程id作为锁标识的后缀,这样不同机器的uuid是不同的,可以保证一致性,同时uuid作为静态变量每台机器只需要创建一次,不需要每次上锁都创建,提高了性能
Redis实现简单的分布式锁
public class SimpleRedisLock {
private String key;
private StringRedisTemplate stringRedisTemplate;
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
public SimpleRedisLock(String key, StringRedisTemplate stringRedisTemplate) {
this.key = key;
this.stringRedisTemplate = stringRedisTemplate;
}
public boolean tryLock(long timeSeconds){
String id = ID_PREFIX + Thread.currentThread().getId();
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, id, timeSeconds, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
public void unlock(){
String val = stringRedisTemplate.opsForValue().get(key);
if(val != null && val.equals(ID_PREFIX + Thread.currentThread().getId())){
stringRedisTemplate.delete(key);
}
}
}
其实上述代码还存在一点小问题,那就是unlock操作,由于它不是原子性操作,那么当它从redis的锁中取值后,redis锁过期了,那么虽然它可以执行if内的语句,但此时它的锁已经过期了,它有可能会删除其它线程的锁
解决办法也很简单,就是想法让unlock中的操作变成原子操作,一说这个很多人第一反应是加锁,但jvm和redis是两个独立运行的进程,没法加锁,我们可以通过让redis执行lua脚本来保证操作的原子性
unlock.lua
-- 锁的key
local key = KEYS[1]
-- 线程标识符
local threadId = ARGV[1]
-- 判断标识符是否是当前线程
if(redis.call("get",key) == threadId) then
return redis.call("del",key)
end
return 0
setnx实现分布式锁存在的问题
Redission提供的分布式锁
自动续约
我们回忆一下,之前使用setnx充当分布式锁的时候为什么要设置过期时间呢?是防止由于redis宕机导致这个锁无法释放成为死锁,那么当cpu资源紧张且某个业务执行时间比较长的时候,很可能锁就被自动释放导致其它线程也进入到该临界区,存在并发安全问题,那么Redission的出现完美的解决了该问题!
Redission内置了WatchDog机制,默认每隔十秒检查一遍锁,若存在给它重新设置30秒过期时间,如果redis宕机了,那么WatchDog会停止续约操作,锁会在30秒后自动释放
WatchDog机制只有在未设置过期时间时才有效,也就是未设置leaseTime才生效
可重入
可重入机制是利用hashmap的特性,使用field标识锁标识,val标识锁的重入次数,释放锁时将val-1,若val=0,则删除key
主从一致性
当我们的项目使用了redis集群时,当我们对主节点执行setnx进行上锁的时候,从节点还未来得及同步主节点数据时,主节点宕机导致锁丢失,我们可以采用redission提供的multilock的机制来解决该问题
锁丢失
-
那么 Redisson 是如何解决上述问题的呢?既然导致主从一致性问题发生的主要原因是主从同步延时问题,Redisson 干脆直接舍弃了主从节点,所有 Redis 节点都是独立的节点,相互之间无任何关系,都可以做读写操作。此时,我们想获取锁就必须依次向多个 Redis 都去获取锁(之前直接向 Master 节点获取就可以),多个 Redis 节点都保存锁的标识,才算获取成功
-
这样一来,由于所有节点都是独立的,所以避免了主从一致性问题;又由于所有的节点都保存了锁标识,即使由一个节点宕机,其他的节点也保存有锁的标识,保证了高可用,并且可用性会随着节点的增多而增高
-
此外,我们还以为给这些独立的节点再加上从节点 Slave,即使一个独立节点宕机了导致其对应的从节点变成新的主节点,且节点上锁标识丢失了也没有关系,因为我们只有在每一个节点都拿到锁才算成功, 尽管可以在这个空虚的节点上获取到锁,但在其他节点上是获取不到的,最终仍然是失败,因此只要有任意一个节点存货,其他线程就不可能拿到锁,就不会出现锁失效问题。这样,既保留了主从同步机制,又确保了 Redis 集群的高可用特性,同时还避免了主从一致所引发的锁失效问题,这个方案就叫做 mutilLock
简而言之,就是对多个redis节点进行上锁,必须全部上锁成功才算成功,哪怕有一个节点的锁没有释放当前线程都无法获得锁
秒杀优化
优化前
单线程处理数据的校验以及订单的创建,而从判断秒杀库存->订单创建这个过程中我们需要采用加锁的机制来保证不发生超卖问题和一人一单的正确性,这样做虽然可行,但却无法面对高并发场景,因为加锁的过程太长,并且加锁范围内有好几个队数据库的操作,业务太重,影响用户体验
优化后
将数据校验的业务用redis处理,处理成功后返回给客户端,而对数据库的操作额外使用消费者线程在后台中处理,既能保证数据一致性,又大大加快响应速度。
数据校验
接口定义:
@PostMapping(“seckill/{id}”)
public Result seckillVoucher(@PathVariable(“id”) Long voucherId)
业务流程
- 根据voucherId在redis中查询库存,若缓存不存在则刷新缓存
- 校验该voucherId对应的set是否包含userId
- 若包含则直接返回结果,告诉用户请勿重复下单
- 若不包含则判断库存是否大于0,若大于0则-1,并将用户id添加到set中,向消息队列发送消息
注意:上述流程涉及到并发安全问题,在看下面代码前可以思考一下加锁的位置、对谁上锁以及锁的释放
代码如下
public Result secKillVoucher2(long id) {
//1.设置key
String stock_key = RedisConstants.SECKILL_STOCK_KEY + id;
String order_key = "seckill:order:" + id;
long order_id = idWorker.nextId("order");
//1.1判断redis中是否有存放stock的key
if(StrUtil.isBlank(stringRedisTemplate.opsForValue().get(stock_key))) {
//1.2 若不存在该key则刷新缓存
Integer stock = cacheClient.queryDataByMutex(RedisConstants.SECKILL_STOCK_KEY, id, Integer.class, (v_id) -> {
SeckillVoucher voucher = seckillVoucherService.getById(v_id);
return voucher.getStock();
}, 10, TimeUnit.MINUTES);
}
//2.判断该用户id是否存在set中
Long userId = UserHolder.getUser().getId();
RLock lock = redissonClient.getLock("lock:user:" + userId);
boolean isLock = lock.tryLock();
if(!isLock){
return Result.fail("请无重复下单");
}
try {
Boolean flag = stringRedisTemplate.opsForSet().isMember(order_key, userId.toString());
if (flag) {
return Result.fail("请无重复下单");
}
long add = stringRedisTemplate.opsForSet().add(order_key, userId.toString());
//3.校验库存是否够
Integer stock = Integer.valueOf(stringRedisTemplate.opsForValue().get(stock_key));
if (stock > 0) {
stringRedisTemplate.opsForValue().decrement(stock_key);
} else {
return Result.fail("库存不足");
}
//4.封装好订单信息发送到消息队列
HashMap<String, String> map = new HashMap<>();
map.put("id", String.valueOf(order_id));
map.put("userId", String.valueOf(userId));
map.put("voucherId", String.valueOf(id));
stringRedisTemplate.opsForStream().add("stream.orders", map);
return Result.ok(order_id);
}catch (Exception e){
log.error(e.getMessage());
return Result.fail(e.getMessage());
}finally {
System.out.println("seckill unlock");
if(lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
上述代码大家觉得有问题吗,我对它做过压测,同一用户的请求一秒1000的并发量是没有问题的,而不同用户访问时,会出现超卖问题,因为上锁的范围是针对UserId的,不同用户之间互不干扰,我们可以通过更改锁的对象,使用voucherId作为锁,但这样会大大降低并发量,接下来我将介绍一种更好的解决方案:lua脚本
lua脚本
Lua也算一门古老的语言了,玩魔兽世界的玩家应该对它不陌生,WOW的插件就是用Lua脚本编写的。在高并发的网络游戏中Lua大放异彩被广泛使用。
Lua广泛作为其它语言的嵌入脚本,尤其是C/C++,语法简单,小巧,源码一共才200多K,这可能也是Redis官方选择它的原因。
为什么使用lua解决并发问题:因为在lua中的操作是原子性的,redis一旦执行某个lua脚本,在执行完成之前是不会执行其它请求的
那么我们可以将上述代码的锁范围内的业务逻辑写在lua脚本中,让lua脚本来保证它们的串行执行
代码
@Override
public Result secKillVoucher(long id) {
//1.设置key
String stock_key = RedisConstants.SECKILL_STOCK_KEY + id;
String order_key = "seckill:order:" + id;
long order_id = idWorker.nextId("order");
//1.1判断redis中是否有存放stock的key
if(StrUtil.isBlank(stringRedisTemplate.opsForValue().get(stock_key))) {
//1.2 若不存在该key则刷新缓存
Integer stock = cacheClient.queryDataByMutex(RedisConstants.SECKILL_STOCK_KEY, id, Integer.class, (v_id) -> {
SeckillVoucher voucher = seckillVoucherService.getById(v_id);
return voucher.getStock();
}, 10, TimeUnit.MINUTES);
}
//2.执行lua脚本,判断是否符合条件,若符合条件则发送到消息队列
Long res = stringRedisTemplate.execute(SECKILL_SCRIPT, Arrays.asList(order_key, stock_key), UserHolder.getUser().getId().toString(),String.valueOf(order_id),String.valueOf(id));
//3. 条件校验失败
if(res.intValue() != 0){
return Result.fail("下单失败,请勿重复下单");
}
return Result.ok(order_id);
}
lua脚本
--获取key
local order_key = KEYS[1]
local order_stock_key = KEYS[2]
--获取用id
local user_id = ARGV[1]
-- 获取订单id
local order_id = ARGV[2]
-- 获取优惠券id
local voucher_id = ARGV[3]
-- 判断库存是否足够
if(tonumber(redis.call("get",order_stock_key)) <= 0) then
return 1
end
-- 判断用户id是否存在该商品的用户列表中
if( redis.call("sismember",order_key,user_id) == 1) then
return 2
end
-- 库存-1
redis.call("incrby",order_stock_key,-1)
-- 将userId添加进该商品的用户列表
redis.call("sadd",order_key,user_id)
-- 向消息队列发送消息
redis.call("xadd","stream.orders","*","id",order_id,"userId",user_id,"voucherId",voucher_id)
return 0
消息队列:Stream
在redis中,有个Stream类型的数据,可以说是为了消息队列而生的,若是项目不太大但又需要使用消息队列,我们可以使用redis的Stream类型来充当消息队列,它的优点是配置简单,不会额外增加运维成本、使用方便
基本的使用语法我已经写在了另一篇博客中:https://blog.csdn.net/qq_42861526/article/details/124753721
接下来我主要给大家讲一下Stream的特点
消费者组
Stream和消费者组通常是一起出现的,我们可以为Stream创建一个或多个消费者组,每个消费者组包含一个或多个消费者,消费者组之间共享消息,同一个消费者组下的消费者竞争消息
特点
- 消息分流:队列中的消息会分流给消费者组中不同的消费者,不会让他们重复消费,提高消息处理速度
- 消息标示:每个消费者组会维护一个标示,记录它最后处理过的消息,哪怕它宕机后重启,也能从标示之后开始消费
- 消息确认:消费者获取消费后,消息会变成pending状态并添加到pending-list中,当消费者对该消息执行XACK后,该消息才会重pending-list中移除
读取、解析消息
private class VoucherHandler implements Runnable{
@Override
public void run() {
try {
while(true) {
//1.从消息队列中取出订单
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
);
//2.判断消息是否为空
if(list == null || list.isEmpty()){
continue;
}
//3.解析消息
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> map = record.getValue();
VoucherOrder order = BeanUtil.fillBeanWithMap(map, new VoucherOrder(), true);
//4.创建订单
createVoucherOrder(order);
stringRedisTemplate.opsForStream().acknowledge("stream.orders","g1",record.getId());
}
} catch (Exception e) {
log.error("处理订单异常");
log.error(e.getMessage());
handlePendingMsg();
}
}
}
private void handlePendingMsg(){
try {
while(true) {
//1.从消息队列中取出订单
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create("stream.orders", ReadOffset.from("0"))
);
//2.判断消息是否为空
if(list == null || list.isEmpty()){
break;
}
//3.解析消息
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> map = record.getValue();
VoucherOrder order = BeanUtil.fillBeanWithMap(map, new VoucherOrder(), true);
//4.创建订单
createVoucherOrder(order);
stringRedisTemplate.opsForStream().acknowledge("stream.orders","g1",record.getId());
}
} catch (Exception e) {
try {
Thread.sleep(1000);
} catch (InterruptedException interruptedException) {
interruptedException.printStackTrace();
}
log.error("处理订单异常");
}
}
消费消息
@Transactional
public void createVoucherOrder(VoucherOrder order) {
log.debug("创建订单......");
//1.userid设置锁
RLock lock = redissonClient.getLock("lock:order:user:" + order.getUserId());
boolean tryLock = lock.tryLock();
if(!tryLock){
log.debug("请勿重复下单");
return ;
}
try{
//2. 查看该用户是否抢过该优惠券
Integer count = query().eq("user_id", order.getUserId()).eq("voucher_id", order.getVoucherId()).count();
if (count > 0) {
log.debug("请勿重复下单");
return ;
}
//3.扣减库存
System.out.println("扣减库存");
boolean success = seckillVoucherService.update().setSql("stock = stock-1").eq("voucher_id", order.getVoucherId()).gt("stock", 0).update();
//3.1库存不足
if(!success){
log.debug("库存不足");
return;
}
//4.保存订单到数据库
save(order);
}catch (Exception e){
e.printStackTrace();
}
finally {
lock.unlock();
}
}
问题:我们已经在发送消息时保证了并发安全,为什么在处理消息时还采用加锁和数据校验?
答:因为在redis主从同步的集群下,我们判断我们的用户id是否存在voucherId对应set中的操作是一个读操作,它会去从节点读取,若主节点已经添加了这个userId,而从节点还没来得及同步消息,那么代码会继续往下执行,将同样的消息发送到消息队列中
但在我们这个项目中,由于使用lua脚本来保证执行的原子性,即使在主从集群下,lua脚本首先会发给主节点,主节点再将脚本分发给从节点一起执行,所以主从的所有节点一次只能执行一个lua脚本请求,不会出现上面所说的情况,我们添加锁和数据校验只是为了增强程序的健壮性,因为执行消费消息的线程是后台执行的,它并不要求响应速度,所以额外增加一点业务也无伤大雅