全局唯一id
当用户抢购时,就会生成订单并保存到订单表中,而订单表如果使用数据库自增id就会存在一些问题:
-
id的规律性太明显
-
受单表数据量限制,如果订单很多,多张表存储还采用自增就会出现订单id重复。
全局ID生成器:
使用redis来生成全局唯一id。
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足以下特性:
-
唯一性、递增性、安全性
-
高可用、高性能
为了增加id的安全性,我们可以不直接使用redis自增的数值,而是拼接一些其他的信息:
把一个Long类型,64位用来构成id
-
符合位:1bit,永远为0
-
时间戳:31bit,以秒为单位,可以使用69年,确定一个标准时间,用当前时间减去标准时间得到秒值作为时间戳
-
序列号:32bit,秒内的计数器,支持每秒产生2的32次方个不同ID
全局唯一ID生成策略:
-
UUID
-
Redis自增
-
雪花算法64位
Redis自增ID策略:
-
每天一个key,方便统计订单量
-
ID构造是 时间戳+计数器

秒杀下单基本流程:
-
查询优惠卷信息,判断时间是否到秒杀时间
-
判断库存是否充足
-
都满足则扣减库存
-
创建订单,返回订单id
并发问题导致超卖:
当库存为1时,线程a执行扣减库存操作前,其他线程同时查询了库存并拿到库存为1,导致问题发生。
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:
-
悲观锁 :认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行,比如Synchronized、Lock都属于悲观锁
-
乐观锁 :认为线程安全问题不一定发生,因此不加锁,只是在更新数据时去判断有没有其他线程对数据做了修改。如果没有修改则认为是安全的,自己才更新数据,如果已经发生修改说明发生了安全问题,此时可以重试或异常。
乐观锁实现方式:
-
版本号法:在操作的记录加上版本号字段,每次查询库存同步查出来版本号,后续扣减库存时,同步增加版本号,同时判断版本号是否为最初拿到的版本号。
set store=store-1,version=version+1 where id=xx and version =当初查出来的版本号 -
cas法:也就是版本号的升级,我们都是比较版本号跟最初是否发生变化,其实可以直接拿库存跟最初是否发生变化来实现
set store=store-1 where id=xxx and store=最初查出来的库存 因为库存一定是会变化的,所以可以根据这个来实现乐观锁的弊端 :当同一时刻有大量线程拿到同一个初始值的时候,就会出现大量的线程它不能去执行操作,这个时候就需要去重试,也就是再重新去拿初始值,然后再比较,在执行。因此在并发量很高的时候,乐观锁的效率其实不高,因为要大量的自旋重试
所以在扣减库存的时候,同一时刻大量线程拿到同一初始值,就会导致很多扣减库存的操作执行不了,及时下载库存大于0,属于就需要改造一下,不再判断必须等于原来初始值,只需要让条件变为库存大于0即可
set store=store-1 where id=xxx and store>0
实现一人一单:
要实现一个人只能买一单,也就是说在扣减库存之前我们先判断一下订单哩是否有这个userid跟优惠卷id这条记录就可以了。
但这时候也有并发问题,也就是在同一时刻大量线程都判断了订单里还没有这条记录,就会导致一人多单。
这个时候就需要加锁,由于订单还没创建没有初始值,就无法使用乐观锁,就只能上悲观锁Synchronize 。
上悲观锁需要注意的就是锁的对象以及锁的范围,这里锁的范围就是 判断是否存在、减库存,创建订单 ,锁的对象就是userid 因为我们不能让同一个用户多次执行下单操作,所以对用户id上锁。这里锁的对象就是:userid.toString.instren() 把这个这个userid的值作为字符串放到常量值,锁住这个值,后面只要这个值一样拿到的就是同一个对象。
前面说锁的范围包括了多个数据库操作,就要上事务,但是我们事务的模块也是只针对那个方法,所以这里要注意事务的失效,不能单纯的使用this. 去调用方法,需要拿到这个类对应的代理对象,然后调用这个方法。
jvm自带的锁在集群模式下的问题:
当服务部署在集群模式下的时候,多个请求被负载均衡到不同的服务上,这时候每个服务对应的jvm是不同的,拿到的锁监视器也是不同的。这时候锁就失效了。
就需要分布式的锁,不再依赖jvm内部的锁。
JVM只能保证单个服务内的线程互斥,无法保证多个服务间的线程互斥。
Redis实现分布式锁:
分布式锁:满足分布式系统或集群模式下多进程可见并互斥的锁。
实现分布式锁时需要实现的两个基本方法:
-
获取锁:
-
互斥:确保只能有一个线程获取锁
-
非阻塞:尝试一次,成功返回true,失败返回false
set lock thread1 nx ex 10
-
-
释放锁:
-
手动释放
-
超时释放:获取锁时添加一个超时时间
del key
-
-
尝试获取锁
-
获取锁成功则执行业务,执行完业务后释放锁
-
获取锁失败则返回或等待
-
业务超时或服务宕机则自动释放锁
改进误释放其他线程的锁问题:
-
在获取锁时存入线程标识(使用UUID表示)
-
在释放锁时先获取锁中的线程标识,判断是否与当前线程标识一致
-
如果一致则释放锁
-
不一致则不释放锁
-
改进无原子性带来的并发问题:
当线程判断后又因为特殊原因阻塞,恢复后直接删除锁,导致误删其他线程的锁。
核心原因:判断锁是否属于当前线程跟释放锁的操作不是原子操作。
Redis的Lua脚本:
能够在一个脚本中编写多条redis命令,确保多条命令执行时的原子性。
Lua脚本的读取:
我们可以在resource路径下定义一个lua脚本的文件,把要执行的lua脚本配置进去,来避免硬编码,后续只需去读取特定文件即可:
private static final DefaultRedisScript
UNLOCK_SCRIPT;
static{
UNLOCK_SCRIPT =new DefaultRedisScript();
UNLOCK_SCRIPT.setLocation(new ClassPathResoure("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
Lua脚本的调用:
RedisTemplate.execute(UNLOCK_SCRIPT,keys,args)
keys为lua脚本中要用到的key,集合类型
args 为要用到的参数,可变参数类型
Redisson:
在基础功能上解决:不可重入、不可重试、超时释放、主从一致性
使用Redisson:


Redisson可重入锁原理:
利用hash结构,在存储value的时候 filed存储线程标识,value存储锁计数。
再尝试获取锁的时候判断锁标识是否是自己,如果是自己则将锁计数加1。
释放锁的时候锁计数-1
全部过程采用lua脚本的形式执行。

Redisson的锁重试机制和锁续期(watchDog机制):
Redisson默认尝试获取锁时间:-1,也就是不尝试,直接失败
Redisson默认看门狗续期时间:30秒
-
可重入:利用hash结构记录线程id和冲入次数
-
可重试:利用信号量和Pubsub功能实现等待、唤醒,获取锁失败的重试机制
-
超时续约:利用watchDog,每隔一段时间(releaseTime/3),重置超时时间,当有指定锁自动释放时间时,看门狗机制会失效。当释放锁时,会取消锁续期。
Redis优化秒杀业务:
在判断库存是否充足和一人一单的环节,之前都是读取数据库,我们可以尝试把数据拿到redis中,通过redis1来做判断提高性能。
这里只需要记录一下库存,再用set结构记录userid,如果id已存在在集合里就说明已经下单。
对于数据库的写操作,我们可以缩减流程,不再业务中同步完成,因为这些环节用户是感知不到的,我们可以通过消息队列异步的去处理这些写操作。
·