目录
一、全局唯一ID
(一)概述
(二)全局ID生成器
(三)全局唯一ID生成策略
1. UUID (Universally Unique Identifier)
2. 雪花算法(Snowflake)
3. 数据库自增
4. Redis INCR/INCRBY
5.总结
(四)Redis实现全局唯一ID
1.工具类
2.测试类
3.关于countdownlatch
二、实现优惠券秒杀下单
三、超卖问题
(一)超卖问题出现的原因
(二)乐观锁解决超卖问题
1.悲观锁
2.乐观锁
3.乐观锁的CAS
4.CAS的自旋
4.1如何缓解自旋压力
(三)超卖问题总结
四、超领问题(一人一单)
(一)需求说明
(二)悲观锁解决单机情况的超领问题
(三)特别说明!!!
(四)集群模式下的超领问题
1.模拟集群模式
2.断点调试
3.运行结果
4.结果分析——锁监视器
一、全局唯一ID
(一)概述
每个店铺都可以发布优惠券:
当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:
- id的规律性太明显
- 受单表数据量的限制
场景分析一:如果我们的id具有太明显的规则,用户或者说商业对手很容易猜测出来我们的一些敏感信息,比如商城在一天时间内,卖出了多少单,这明显不合适。
场景分析二:随着我们商城规模越来越大,mysql的单表的容量不宜超过500W,数据量过大之后,我们要进行拆库拆表,但拆分表了之后,他们从逻辑上讲他们是同一张表,所以他们的id是不能一样的, 于是乎我们需要保证id的唯一性。
(二)全局ID生成器
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:唯一性、高可用、高性能、递增性、安全性。
为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:
ID的类型:Long类型,8个字节,64位
ID的组成部分:
- u符号位:1bit,永远为0,表示一个正数
- u时间戳:31bit,以秒为单位,可以使用69年
- u序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
(三)全局唯一ID生成策略
常见的全局唯一ID生成策略及其优缺点和适用场景:
1. UUID (Universally Unique Identifier)
实现方式
UUID通常是一个128位的值,可以通过多种算法生成,如基于时间戳、随机数或MAC地址等。
优点
简单易用:无需额外的基础设施支持。
高可用性:由于其生成机制,几乎可以保证全球唯一性。
无中心化管理:可以在任何地方独立生成,非常适合分布式环境。
缺点
长度较长:标准的UUID为128位,占用较多存储空间。
性能问题:对于某些高性能要求的应用场景,生成速度可能成为瓶颈。
不可排序:UUID不具备自然的时间顺序,不利于按时间排序查询。
使用场景
适合于对唯一性有严格要求但对排序和性能要求不高的场景,例如日志记录、会话标识等。
2. 雪花算法(Snowflake)
实现方式
Twitter Snowflake是一种分布式ID生成算法,生成64位的整型ID,包含时间戳、机器ID、数据中心ID和序列号。
优点
高效:能够快速生成大量唯一的ID。
有序性:生成的ID按时间顺序递增,有利于数据库索引优化。
可扩展性:支持分布式部署,每个节点都可以独立生成ID。
缺点
依赖时钟同步:如果服务器之间的时间不同步,可能会导致ID冲突。
复杂度较高:需要考虑机器ID分配、数据中心配置等问题。
使用场景
适合于高并发环境下需要快速生成有序ID的场景,如订单编号生成、用户ID生成等。
3. 数据库自增
实现方式
利用关系型数据库提供的自增字段功能来生成ID。
优点
简单直接:实现起来非常简单,直接利用数据库特性。
天然有序:自增ID天然具有顺序性,便于后续处理。缺点
单点故障风险:如果使用单一数据库实例,则存在单点故障的风险。
不适合分布式系统:在分布式环境下,难以保证全局唯一性且性能受限。使用场景
适合于小型应用或不需要高度分布式的场景。对于需要极高可靠性的大型分布式系统,通常需要结合其他技术(如分段分配)使用。
4. Redis INCR/INCRBY
实现方式
通过Redis的INCR或INCRBY命令来生成自增ID。
优点
高性能:Redis作为内存数据库,操作速度快。
原子性:INCR操作是原子性的,确保ID唯一性。
易于扩展:可以方便地与现有Redis集群集成。
缺点
有限范围:虽然Redis支持64位整数,但对于某些超大规模的应用仍可能不足。
外部依赖:增加了对Redis服务的依赖,若Redis出现故障会影响ID生成。使用场景
适用于中等到大规模应用中需要高效生成唯一ID的情况,尤其是那些已经使用了Redis作为缓存或其他用途的项目。
5.总结
- UUID:适合去中心化的应用,对唯一性和简易性有较高要求的场合。
- 雪花算法:适用于高并发、分布式环境下的有序ID生成需求。
- 数据库自增:简单直接,但更适合于非分布式或者规模较小的应用。
- Redis INCR:提供了高效的ID生成方案,并且容易与现有的Redis架构集成,适合中到大规模应用。
选择哪种方法取决于具体的应用需求、系统架构以及性能要求等因素。在实际开发过程中,也可以根据具体情况混合使用上述方法以达到最佳效果。
(四)Redis实现全局唯一ID
Redis自增ID策略:
- 每天一个key,方便统计订单量
- ID构造是时间戳+计数器
1.工具类
@Component
@Slf4j
public class RedisIdWorker {
@Resource
private StringRedisTemplate stringRedisTemplate;
// 初始时间戳
private static final long BEGIN_TIMESTAMP = 1640995200L;
// 位数
private static final int COUNT_BITS = 32;
public long nextId(String keyPrefix) {
// 1.生成时间戳
// 获取当前时间戳,单位为秒,使用当前时间戳减去初始时间戳
LocalDateTime now = LocalDateTime.now();
long nowEpochSecond = now.toEpochSecond(ZoneOffset.UTC);
long currentTimeStamp = nowEpochSecond - BEGIN_TIMESTAMP;
// 2.生成序列号,使用redis的自增长
// 2.1 获取当前日期,精确到天,好处是:避免超过32位的上限,和方便按照日期查询
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 这里不会存在空指针问题,
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.使用位运算拼接并返回
/**
* 或运算:
* 将两个数转为二进制,对于每个位,如果两个相应的位有一个为 1,则结果位为 1;否则为 0
* 这里左移32位后,剩余的32位全部为0,使用或运算,存放的就全部是序列号了
*/
return currentTimeStamp << COUNT_BITS | count;
}
public static void main(String[] args) {
LocalDateTime time = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
long epochSecond = time.toEpochSecond(ZoneOffset.UTC);
System.out.println(epochSecond); // 1640995200
}
}
2.测试类
@SpringBootTest
class HmDianPingApplicationTests {
@Resource
private CacheClient cacheClient;
@Resource
private RedisIdWorker redisIdWorker;
// 线程池
private ExecutorService es = Executors.newFixedThreadPool(500);
@Test
void testIdWorker() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(300);
Runnable task = () -> {
// 生成100个id
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id = " + id);
}
latch.countDown();
};
long start = System.currentTimeMillis();
// 将任务提交300次,会生成30000个id
for (int i = 0; i < 300; i++) {
es.submit(task);
}
latch.await();
long end = System.currentTimeMillis();
System.out.println("time=" + (end - start)); // time=1978毫秒
}
}
3.关于countdownlatch
countdownlatch名为信号枪:主要的作用是同步协调在多线程的等待于唤醒问题
我们如果没有CountDownLatch ,那么由于程序是异步的,当异步程序没有执行完时,主线程就已经执行完了,然后我们期望的是分线程全部走完之后,主线程再走,所以我们此时需要使用到CountDownLatch。
CountDownLatch 中有两个最重要的方法
1、countDown
2、await
await 方法 是阻塞方法,我们担心分线程没有执行完时,main线程就先执行,所以使用await可以让main线程阻塞,那么什么时候main线程不再阻塞呢?当CountDownLatch 内部维护的 变量变为0时,就不再阻塞,直接放行,那么什么时候CountDownLatch 维护的变量变为0 呢,我们只需要调用一次countDown ,内部变量就减少1,我们让分线程和变量绑定, 执行完一个分线程就减少一个变量,当分线程全部走完,CountDownLatch 维护的变量就是0,此时await就不再阻塞,统计出来的时间也就是所有分线程执行完后的时间。
运行结果:
二、实现优惠券秒杀下单
下单时需要判断两点:
- 秒杀是否开始或结束,如果尚未开始或已经结束则无法下单
- 库存是否充足,不足则无法下单
@Override
public Result seckillVoucher(Long voucherId) {
// 查询优惠券是否存在
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
// 查询秒杀是否开始
LocalDateTime beginTime = seckillVoucher.getBeginTime();
if (beginTime.isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始");
}
// 查询秒杀是否结束
LocalDateTime endTime = seckillVoucher.getEndTime();
if (endTime.isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束");
}
// 判断库存是否充足
Integer stock = seckillVoucher.getStock();
if (stock < 1) {
return Result.fail("库存不足");
}
// 扣减库存
seckillVoucher.setStock(stock - 1);
boolean success = seckillVoucherService.updateById(seckillVoucher);
if (!success){
return Result.fail("库存不足");
}
// 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 用户id
voucherOrder.setUserId(UserHolder.getUser().getId());
// 代金券id
voucherOrder.setVoucherId(voucherId);
// 创建订单详情
save(voucherOrder);
// 返回订单id
return Result.ok(orderId);
}
三、超卖问题
(一)超卖问题出现的原因
假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。
(二)乐观锁解决超卖问题
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:
1.悲观锁
认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。例如Synchronized、Lock都属于悲观锁。
2.乐观锁
认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时,去判断有没有其他线程对数据做了修改。
- 如果没有修改,则认为是安全的,才会进行数据更新;
- 如果已经被其它线程修改,说明发生了线程安全问题,此时可以重试或者抛异常。
乐观锁会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过,当然乐观锁还有一些变种的处理方式比如cas
3.乐观锁的CAS
CAS(Compare-And-Swap)是一个底层的原子操作,它可以被用来实现乐观锁。CAS操作能够确保只有当预期值与内存中的当前值相等时,才会进行更新,否则更新失败。这种特性非常适合于实现乐观锁,特别是在无锁编程中。
在本项目中,CAS不设置版本号,因为版本号的操作和库存的操作是一样的,所以使用stock库存代替版本号。
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId)
.gt("stock", 0) // where voucher_id = ? and stock > 0
.update();
if (!success) {
return Result.fail("库存不足");
}
4.CAS的自旋
“CAS操作本质上是一个原子操作,它尝试比较内存位置的当前值与预期值是否相同,如果相同,则更新为新值;否则,不进行任何修改,并返回失败。
当多个线程同时试图对同一变量执行CAS操作时,只有一个线程能够成功更新该变量,其余线程将收到失败的结果。为了实现乐观锁或其他无锁算法,这些失败的线程通常会进入一个循环,反复尝试直到成功更新为止。这个过程被称为“自旋”。
4.1如何缓解自旋压力
退避策略:可以在每次CAS失败后引入短暂的休眠或等待时间(如使用
Thread.yield()
或Thread.sleep(n)
),以减少CPU的占用率。这种方式可以降低自旋频率,但也会增加总的延迟。限制重试次数:设定一个最大重试次数,超过该次数则采取其他措施,比如回退并重新排队或者抛出异常让上层逻辑处理。
结合锁机制:在极端高争用的情况下,考虑切换回传统的锁机制(如互斥锁),尽管这会牺牲一些并发性,但可以避免过度的自旋压力。
优化数据结构设计:通过优化共享数据结构的设计来减少热点争用点,例如分片存储、局部化访问等方法,可以有效降低CAS操作的竞争程度。
使用更高级别的同步工具:现代编程语言和框架提供了许多高级别的同步原语(如读写锁、信号量等),它们能够在不同场景下提供更好的性能和灵活性。
(三)超卖问题总结
1.悲观锁:添加同步锁,让线程串行执行
- 优点:简单粗暴
- 缺点:性能一般
2.乐观锁:不加锁,在更新时判断是否有其它线程在修改
- 优点:性能好
- 缺点:存在成功率低的问题
四、超领问题(一人一单)
(一)需求说明
需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单。
乐观锁是在更新数据的时候使用的,而这里的领取优惠券是插入数据,每个用户领取一个优惠券会新增一条优惠券订单,因此需要使用悲观锁来解决。从查询订单,到判断订单,最后到新增订单都要放在锁里面。
(二)悲观锁解决单机情况的超领问题
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
// 查询优惠券是否存在
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
if (seckillVoucher == null) {
return Result.fail("优惠券不存在");
}
// 查询秒杀是否开始
LocalDateTime beginTime = seckillVoucher.getBeginTime();
if (beginTime.isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始");
}
// 查询秒杀是否结束
LocalDateTime endTime = seckillVoucher.getEndTime();
if (endTime.isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束");
}
// 判断库存是否充足
Integer stock = seckillVoucher.getStock();
if (stock < 1) {
return Result.fail("库存不足");
}
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
// 获取和事务有关的代理对象
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
// 返回订单id
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
// 同一个用户加锁
Long userId = UserHolder.getUser().getId();
// 一人一单
long count = query()
.eq("user_id", userId)
.eq("voucher_id", voucherId)
.count();
if (count > 0) {
return Result.fail("用户已经购买过一次了");
}
// 扣减库存
// seckillVoucher.setStock(stock - 1);
// boolean success = seckillVoucherService.updateById(seckillVoucher);
/**
* 为什么有两个 update()
* 第一个 update() 实际上是 MyBatis-Plus 提供的一个便捷入口,用来开始构建更新操作的链式调用。
* 第二个 update() 是真正执行数据库更新的方法,它基于之前通过链式调用定义的所有条件和设置来进行更新。
*/
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1") // set stock = stock - 1
.eq("voucher_id", voucherId)
.gt("stock", 0) // where voucher_id = ? and stock > 0
.update();
if (!success) {
return Result.fail("库存不足");
}
// 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
// 订单id
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
// 用户id
voucherOrder.setUserId(userId);
// 代金券id
voucherOrder.setVoucherId(voucherId);
// 创建订单详情
save(voucherOrder);
return Result.ok(orderId);
}
}
public interface IVoucherOrderService extends IService<VoucherOrder> {
Result seckillVoucher(Long voucherId);
Result createVoucherOrder(Long voucherId);
}
引入依赖:
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
启动类暴露代理对象:
@EnableAspectJAutoProxy(exposeProxy = true) // 暴露代理对象
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {
public static void main(String[] args) {
SpringApplication.run(HmDianPingApplication.class, args);
}
}
(三)特别说明!!!
![]()
首先,这里使用了悲观锁,保证必须先获取锁,再执行调用的事务操作,最后才会释放锁,保证了安全性,不会发生事务未提交,锁就被释放的情况。
其次, 在同一个类内直接调用另一个带有@Transactional注解的方法,如果这个调用是在同一个实例内完成的(即非代理调用),则事务不会生效。这是因为直接调用未经过代理对象,所以Spring无法插入事务管理逻辑。Spring的事务管理是基于AOP实现的,Spring AOP默认使用的是JDK动态代理,它只能代理接口中的方法或公开的方法(即public方法)。
解决方案就是:通过
AopContext.currentProxy()
在同一个类内获取代理对象并调用目标方法,以此来确保事务管理等AOP增强能够在自我调用的情况下也生效。IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy(); // 返回订单id return proxy.createVoucherOrder(voucherId);
并且,使用代理对象必须暴露代理对象,在启动类上添加@EnableAspectJAutoProxy(exposeProxy = true)
最后,引入org.aspectj:aspectjweaver依赖主要是为了支持 AspectJ 的编织功能,特别是在Spring应用中启用 AspectJ 的加载时编织。
(四)集群模式下的超领问题
1.模拟集群模式
启动这两个类,模拟两个节点的集群
修改nginx.conf文件:
保存后,CMD窗口重新加载nginx
nginx.exe -s reload
重复刷新该链接:http://localhost:8080/api/voucher/list/1
8081和8082的控制台都会有输出,轮询访问到两个端口,当前nginx已经有了负载均衡的效果了。
2.断点调试
在这两个地方打断点:
ApiFox设置两个一样的接口,分别访问
3.运行结果
会注意到两次访问都会进入锁内,判断count=0,放行所有断点后,优惠券会有两个订单,同时库存也会减少2个
4.结果分析——锁监视器
由于现在我们部署了多个tomcat,每个tomcat都有一个属于自己的jvm,那么假设在服务器A的tomcat内部,有两个线程,这两个线程由于使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的。
但是如果现在是服务器B的tomcat内部,又有两个线程,但是他们的锁对象写的虽然和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2实现互斥。
这就是集群环境下,syn锁失效的原因,在这种情况下,我们就需要使用分布式锁来解决这个问题。