技术分享
Redis实现秒杀业务
00 分钟
2024-4-11
2024-4-22
type
status
date
Apr 22, 2024 08:34 AM
slug
summary
category
tags
password
icon

全局唯一ID

自增ID存在的问题

当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:
  • id的规律性太明显,容易出现信息的泄露,被不怀好意的人伪造请求
  • 受单表数据量的限制,MySQL中表能够存储的数据有限,会出现分库分表的情况,id不能够一直自增
说明:
  • 当ID规律过于明显时,存在以下一些缺点:
      1. 安全性问题:如果ID规律太明显,可能会使系统容易受到恶意攻击,例如暴力破解等。攻击者可以通过分析ID规律来推断出其他用户的ID,从而进行未授权的访问或操纵。
      1. 隐私泄露风险:如果ID规律太明显,可能导致用户的个人信息或敏感数据被曝光。攻击者可以根据规律推测出其他用户的ID,并通过这些ID获取到相应的数据,进而侵犯用户的隐私。
      1. 数据可预测性:当ID规律太明显时,使用这些规律的攻击者可以很轻易地猜测出其他实体(如订单、交易等)的ID。这可能破坏系统的数据安全性和防伪能力。
      1. 扩展性受限:如果ID规律太明显,可能会对系统的扩展性造成一定影响。当系统需要处理大量并发操作时,如果ID规律过于明显,可能导致多个操作同时对同一资源进行竞争,从而增加冲突和性能瓶颈。
      1. 维护困难:当ID规律太明显时,系统可能需要额外的资源和机制来保持规律的更新和变化,以确保安全性和数据完整性。这会增加系统的复杂度,并给维护带来挑战。
  • 在MySQL中,表最多可以存储的记录数取决于多个因素,包括数据库版本、操作系统和硬件配置等。下面是一些常见的限制:
      1. 行数限制:在MySQL 5.7及之前的版本中,InnoDB和XtraDB存储引擎的行数限制为最大约为64亿),即4 , 294 , 967 , 295 4,294,967,2954,294,967,295行。而在MySQL 8.0及以后的版本中,它们的行数限制可达到理论上的最大值,大约是1844万亿)行。
      1. 数据库文件大小限制:每个InnoDB表的存储大小受到所使用文件系统的限制。对于InnoDB表,默认情况下,数据库文件的大小限制取决于操作系统和文件系统,通常在几TB或更高。但是,这也可能受到特定的操作系统和文件系统的限制。
      1. 硬件资源限制:实际上,表的记录数还受到可用硬件资源,如磁盘空间、内存和处理能力的限制。当数据库文件较大时,磁盘空间变得关键,而在执行查询时,内存和处理能力可影响读写性能。
  • 业界流传是500万行。超过500万行就要考虑分表分库了。阿里巴巴《Java 开发手册》提出单表行数超过 500 万行或者单表容量超过 2GB,就需要考虑分库分表了

特点

那么该如何解决呢?我们需要使用分布式ID(也可以叫全局唯一ID),一般要满足下列特性:
  • 唯一性
  • 高可用
  • 高性能
  • 递增性
  • 安全性

设计ID

为了增加ID的安全性,可以不直接使用Redis自增的数值,而是拼接一些其它信息:
notion image
ID的组成部分:
  • 符号位:1bit,永远为0(表示正数)
  • 时间戳:31bit,以秒为单位,可以使用69年(/3600/24/365≈69)
  • 序列号:32bit,秒内的计数器,支持每秒产生个不同ID

其他ID生成策略

  1. 全局唯一ID生成策略:
      • UUID
      • Redis自增
      • snowflake算法
      • 数据库自增
  1. Redis自增ID策略:
      • 每天一个key,方便统计订单量
      • ID构造是时间戳+计数器

优惠券秒杀下单

注意事项

  • 下单时需要判断两点:
      1. 秒杀是开始或结束,如果尚未开始或已经结束则无法下单
      1. 存是否充足,不足则无法下单

接口流程

notion image

实现

notion image
这种方案在单线程下是可行的,但是在高并发多线程下就会出现超卖的问题!!!

超卖问题

正常情况

notion image

超卖情况

notion image

解决方案

超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁。

乐观锁

认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。【经常使用数据库表中的vision字段来表示某些数据是否已经被其他的线程修改】
  • 如果没有修改则认为是安全的,自己才更新数据。
  • 如果已经被其它线程修改说明发生了安全问题,此时可以等待重试或抛出异常。
常见的乐观锁有版本号法、CAS操作、乐观锁算法。
乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:
  1. 版本号法
    1. notion image
  1. CAS
    1. 本质:用库存(stock)来代替版本(version)
      notion image
扩展:CAS
  • CAS(Compare and Swap)是一种并发编程中常用的原子操作,用于解决多线程环境下的数据竞争问题。它是乐观锁算法的一种实现方式。
  • CAS操作包含三个参数:内存地址V、旧的预期值A和新的值B。CAS的执行过程如下:
      1. 比较(Compare):将内存地址V中的值与预期值A进行比较。
      1. 判断(Judgment):如果相等,则说明当前值和预期值相等,表示没有发生其他线程的修改。
      1. 交换(Swap):使用新的值B来更新内存地址V中的值。
  • CAS操作是一个原子操作,意味着在执行过程中不会被其他线程中断,保证了线程安全性。如果CAS操作失败(即当前值与预期值不相等),通常会进行重试,直到CAS操作成功为止。
  • CAS操作适用于精细粒度的并发控制,可以避免使用传统的加锁机制带来的性能开销和线程阻塞。然而,CAS操作也存在一些限制和注意事项:
      1. ABA问题:CAS操作无法感知到对象值从A变为B又变回A的情况,可能会导致数据不一致。为了解决ABA问题,可以引入版本号或标记位等机制。
      1. 自旋开销:当CAS操作失败时,需要不断地进行重试,会占用CPU资源。如果重试次数过多或者线程争用激烈,可能会引起性能问题。
      1. 并发性限制:如果多个线程同时对同一内存地址进行CAS操作,只有一个线程的CAS操作会成功,其他线程需要重试或放弃操作。
  • 在Java中,提供了相关的CAS操作支持,如AtomicIntegerAtomicLongAtomicReference等类,可以实现基于CAS操作的线程安全操作。

悲观锁

认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。
  • 常见的悲观锁有SynchronizedLock都属于悲观锁。

悲观锁和乐观锁的比较

  • 悲观锁比乐观锁的性能低:悲观锁需要先加锁再操作,而乐观锁不需要加锁,所以乐观锁通常具有更好的性能。
  • 悲观锁比乐观锁的冲突处理能力低:悲观锁在冲突发生时直接阻塞其他线程,乐观锁则是在提交阶段检查冲突并进行重试。
  • 悲观锁比乐观锁的并发度低:悲观锁存在锁粒度较大的问题,可能会限制并发性能;而乐观锁可以实现较高的并发度。
  • 应用场景:两者都是互斥锁,悲观锁适合写入操作较多、冲突频繁的场景;乐观锁适合读取操作较多、冲突较少的场景。

总结

💡
超卖这样的线程安全问题,解决方案有哪些?
  1. 悲观锁:添加同步锁,让线程串行执行
      • 优点:简单粗暴
      • 缺点:性能一般
  1. 乐观锁:不加锁,在更新时判断是否有其它线程在修改
      • 优点:性能好
      • 缺点:存在成功率低的问题

一人一单

实现流程

💡
本质还是“加锁”!
notion image

代码实现

实现细节

  1. 锁的范围尽量小。synchronized尽量锁代码块,而不是方法,锁的范围越大性能越低。
  1. 锁的对象一定要是一个不变的值。我们不能直接锁 Long 类型的 userId,每请求一次都会创建一个新的 userId 对象,synchronized 要锁不变的值,所以我们要将 Long 类型的 userId 通过 toString()方法转成 String 类型的 userIdtoString()方法底层(可以点击去看源码)是直接 new 一个新的String对象,显然还是在变,所以我们要使用 intern() 方法从常量池中寻找与当前字符串值一致的字符串对象,这就能够保障一个用户 发送多次请求,每次请求的 userId 都是不变的,从而能够完成锁的效果(并行变串行)。也就是说通过intern()可以将字符串放入常量池中,对于一个JVM实例中,常量池中的常量唯一,这样多个线程就可以基于这个唯一常量加锁。
  1. 我们要锁住整个事务,而不是锁住事务内部的代码。如果我们锁住事务内部的代码会导致其它线程能够进入事务,当我们事务还未提交,锁一旦释放,仍然会存在超卖问题
  1. Spring的@Transactional注解要想事务生效,必须使用动态代理。Service中一个方法中调用另一个方法,另一个方法使用了事务,此时会导致@Transactional失效,所以我们需要创建一个代理对象,使用代理对象来调用方法。
      • 让代理对象生效的步骤:
        • ①引入AOP依赖,动态代理是AOP的常见实现之一
          ②暴露动态代理对象,默认是关闭的,在启动类上加上下面的代码。

上面方案存在的问题

💡
通过加锁可以解决在单机情况下的一人一单的安全问题,但是在集群模式下就不行了。

单机情况下

notion image
notion image

分布式情况下

notion image
分布式锁是由共享存储系统维护的变量,多个客户端可以向共享存储系统发送命令进行加 锁或释放锁操作。Redis 作为一个共享存储系统,可以用来实现分布式锁。

问题分析

由于现在我们部署了多个tomcat,每个tomcat都有一个属于自己的jvm,那么假设在服务器A的tomcat内部,有两个线程,这两个线程由于使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的,但是如果现在是服务器B的tomcat内部,又有两个线程,但是他们的锁对象写的虽然和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2实现互斥,这就是 集群环境下syn锁失效的原因,在这种情况下,我们就需要使用分布式锁来解决这个问题。

评论
Loading...