type
status
date
Apr 22, 2024 08:34 AM
slug
summary
category
tags
password
icon
全局唯一ID自增ID存在的问题特点设计ID其他ID生成策略优惠券秒杀下单注意事项接口流程实现超卖问题正常情况超卖情况解决方案乐观锁悲观锁悲观锁和乐观锁的比较总结一人一单实现流程代码实现实现细节上面方案存在的问题单机情况下分布式情况下问题分析
全局唯一ID
自增ID存在的问题
当用户抢购时,就会生成订单并保存到
tb_voucher_order
这张表中,而订单表如果使用数据库自增ID就存在一些问题:id
的规律性太明显,容易出现信息的泄露,被不怀好意的人伪造请求
- 受单表数据量的限制,MySQL中表能够存储的数据有限,会出现分库分表的情况,id不能够一直自增
说明:
- 当ID规律过于明显时,存在以下一些缺点:
- 安全性问题:如果ID规律太明显,可能会使系统容易受到恶意攻击,例如暴力破解等。攻击者可以通过分析ID规律来推断出其他用户的ID,从而进行未授权的访问或操纵。
- 隐私泄露风险:如果ID规律太明显,可能导致用户的个人信息或敏感数据被曝光。攻击者可以根据规律推测出其他用户的ID,并通过这些ID获取到相应的数据,进而侵犯用户的隐私。
- 数据可预测性:当ID规律太明显时,使用这些规律的攻击者可以很轻易地猜测出其他实体(如订单、交易等)的ID。这可能破坏系统的数据安全性和防伪能力。
- 扩展性受限:如果ID规律太明显,可能会对系统的扩展性造成一定影响。当系统需要处理大量并发操作时,如果ID规律过于明显,可能导致多个操作同时对同一资源进行竞争,从而增加冲突和性能瓶颈。
- 维护困难:当ID规律太明显时,系统可能需要额外的资源和机制来保持规律的更新和变化,以确保安全性和数据完整性。这会增加系统的复杂度,并给维护带来挑战。
- 在MySQL中,表最多可以存储的记录数取决于多个因素,包括数据库版本、操作系统和硬件配置等。下面是一些常见的限制:
- 行数限制:在
MySQL 5.7
及之前的版本中,InnoDB和XtraDB存储引擎的行数限制为最大约为64亿( ),即4 , 294 , 967 , 295 4,294,967,2954,294,967,295行。而在MySQL 8.0
及以后的版本中,它们的行数限制可达到理论上的最大值,大约是1844万亿()行。
- 数据库文件大小限制:每个InnoDB表的存储大小受到所使用文件系统的限制。对于InnoDB表,默认情况下,数据库文件的大小限制取决于操作系统和文件系统,通常在几TB或更高。但是,这也可能受到特定的操作系统和文件系统的限制。
- 硬件资源限制:实际上,表的记录数还受到可用硬件资源,如磁盘空间、内存和处理能力的限制。当数据库文件较大时,磁盘空间变得关键,而在执行查询时,内存和处理能力可影响读写性能。
- 业界流传是500万行。超过500万行就要考虑分表分库了。阿里巴巴《Java 开发手册》提出单表行数超过 500 万行或者单表容量超过 2GB,就需要考虑分库分表了
特点
那么该如何解决呢?我们需要使用分布式ID(也可以叫全局唯一ID),一般要满足下列特性:
- 唯一性
- 高可用
- 高性能
- 递增性
- 安全性
设计ID
为了增加ID的安全性,可以不直接使用Redis自增的数值,而是拼接一些其它信息:
ID的组成部分:
- 符号位:1bit,永远为0(表示正数)
- 时间戳:31bit,以秒为单位,可以使用69年(/3600/24/365≈69)
- 序列号:32bit,秒内的计数器,支持每秒产生个不同ID
其他ID生成策略
- 全局唯一ID生成策略:
- UUID
- Redis自增
- snowflake算法
- 数据库自增
- Redis自增ID策略:
- 每天一个key,方便统计订单量
- ID构造是时间戳+计数器
优惠券秒杀下单
注意事项
- 下单时需要判断两点:
- 秒杀是开始或结束,如果尚未开始或已经结束则无法下单
- 存是否充足,不足则无法下单
接口流程
实现
这种方案在单线程下是可行的,但是在高并发多线程下就会出现超卖的问题!!!
超卖问题
正常情况
超卖情况
解决方案
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁。
乐观锁
认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。【经常使用数据库表中的
vision
字段来表示某些数据是否已经被其他的线程修改】- 如果没有修改则认为是安全的,自己才更新数据。
- 如果已经被其它线程修改说明发生了安全问题,此时可以等待重试或抛出异常。
常见的乐观锁有版本号法、CAS
操作、乐观锁算法。
乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:
- 版本号法
CAS
法
本质:用库存(stock)来代替版本(version)
扩展:CAS
- CAS(Compare and Swap)是一种并发编程中常用的原子操作,用于解决多线程环境下的数据竞争问题。它是乐观锁算法的一种实现方式。
- CAS操作包含三个参数:内存地址V、旧的预期值A和新的值B。CAS的执行过程如下:
- 比较(Compare):将内存地址V中的值与预期值A进行比较。
- 判断(Judgment):如果相等,则说明当前值和预期值相等,表示没有发生其他线程的修改。
- 交换(Swap):使用新的值B来更新内存地址V中的值。
- CAS操作是一个原子操作,意味着在执行过程中不会被其他线程中断,保证了线程安全性。如果CAS操作失败(即当前值与预期值不相等),通常会进行重试,直到CAS操作成功为止。
- CAS操作适用于精细粒度的并发控制,可以避免使用传统的加锁机制带来的性能开销和线程阻塞。然而,CAS操作也存在一些限制和注意事项:
- ABA问题:CAS操作无法感知到对象值从A变为B又变回A的情况,可能会导致数据不一致。为了解决ABA问题,可以引入版本号或标记位等机制。
- 自旋开销:当CAS操作失败时,需要不断地进行重试,会占用CPU资源。如果重试次数过多或者线程争用激烈,可能会引起性能问题。
- 并发性限制:如果多个线程同时对同一内存地址进行CAS操作,只有一个线程的CAS操作会成功,其他线程需要重试或放弃操作。
- 在Java中,提供了相关的CAS操作支持,如
AtomicInteger
、AtomicLong
、AtomicReference
等类,可以实现基于CAS操作的线程安全操作。
悲观锁
认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。
- 常见的悲观锁有
Synchronized
、Lock
都属于悲观锁。
悲观锁和乐观锁的比较
- 悲观锁比乐观锁的性能低:悲观锁需要先加锁再操作,而乐观锁不需要加锁,所以乐观锁通常具有更好的性能。
- 悲观锁比乐观锁的冲突处理能力低:悲观锁在冲突发生时直接阻塞其他线程,乐观锁则是在提交阶段检查冲突并进行重试。
- 悲观锁比乐观锁的并发度低:悲观锁存在锁粒度较大的问题,可能会限制并发性能;而乐观锁可以实现较高的并发度。
- 应用场景:两者都是互斥锁,悲观锁适合写入操作较多、冲突频繁的场景;乐观锁适合读取操作较多、冲突较少的场景。
总结
超卖这样的线程安全问题,解决方案有哪些?
- 悲观锁:添加同步锁,让线程串行执行
- 优点:简单粗暴
- 缺点:性能一般
- 乐观锁:不加锁,在更新时判断是否有其它线程在修改
- 优点:性能好
- 缺点:存在成功率低的问题
一人一单
实现流程
本质还是“加锁”!
代码实现
实现细节
- 锁的范围尽量小。
synchronized
尽量锁代码块,而不是方法,锁的范围越大性能越低。
- 锁的对象一定要是一个不变的值。我们不能直接锁
Long
类型的userId
,每请求一次都会创建一个新的userId
对象,synchronized
要锁不变的值,所以我们要将Long
类型的userId
通过toString()
方法转成String
类型的userId
,toString()
方法底层(可以点击去看源码)是直接new
一个新的String
对象,显然还是在变,所以我们要使用intern()
方法从常量池中寻找与当前字符串值一致的字符串对象,这就能够保障一个用户 发送多次请求,每次请求的 userId 都是不变的,从而能够完成锁的效果(并行变串行)。也就是说通过intern()
可以将字符串放入常量池中,对于一个JVM实例中,常量池中的常量唯一,这样多个线程就可以基于这个唯一常量加锁。
- 我们要锁住整个事务,而不是锁住事务内部的代码。如果我们锁住事务内部的代码会导致其它线程能够进入事务,当我们事务还未提交,锁一旦释放,仍然会存在超卖问题
- Spring的
@Transactional
注解要想事务生效,必须使用动态代理。Service中一个方法中调用另一个方法,另一个方法使用了事务,此时会导致@Transactional
失效,所以我们需要创建一个代理对象,使用代理对象来调用方法。 - 让代理对象生效的步骤:
①引入AOP依赖,动态代理是AOP的常见实现之一
②暴露动态代理对象,默认是关闭的,在启动类上加上下面的代码。
上面方案存在的问题
通过加锁可以解决在单机情况下的一人一单的安全问题,但是在集群模式下就不行了。
单机情况下
分布式情况下
分布式锁是由共享存储系统维护的变量,多个客户端可以向共享存储系统发送命令进行加 锁或释放锁操作。Redis 作为一个共享存储系统,可以用来实现分布式锁。
问题分析
由于现在我们部署了多个tomcat,每个tomcat都有一个属于自己的jvm,那么假设在服务器A的tomcat内部,有两个线程,这两个线程由于使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的,但是如果现在是服务器B的tomcat内部,又有两个线程,但是他们的锁对象写的虽然和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2实现互斥,这就是 集群环境下syn锁失效的原因,在这种情况下,我们就需要使用分布式锁来解决这个问题。
- 作者:Frank
- 链接:https://blog.franksteven.me//article/redis-seckill
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。