黑马点评总结

一、短信登录功能的两种实现

1、基于session实现短信验证码登录

1.1关于cookie和session

CookieSession 都是用于跟踪用户会话状态的机制,但它们在存储位置、安全性和使用场景上有显著区别。

Cookie

Cookie 是存储在客户端浏览器中e的小型文本文件,用于保存用户信息。它的工作原理如下:

浏览器第一次发送请求到服务器时,服务器创建一个包含用户信息的 Cookie,并将其发送到浏览器。浏览器保存 Cookie,并在后续请求中自动携带该 Cookie。服务器通过 Cookie 中携带的数据区分不同的用户。

Cookie 的特点和缺陷包括:

存储位置:保存在客户端浏览器中。数据类型:只能存储字符串类型的数据。安全性:较低,容易被拦截或篡改。存储限制:单个 Cookie 的大小不能超过 4KB,每个站点最多保存 20 个 Cookie。

Session

Session 是存储在服务器端的会话数据,用于保存特定用户的会话信息。它的工作原理如下:

浏览器第一次发送请求到服务器时,服务器创建一个 Session,并生成一个唯一的 Session ID。服务器将 Session ID 发送到浏览器,并保存在 Cookie 中。浏览器在后续请求中携带该 Session ID,服务器通过 Session ID 查找对应的会话数据。

Session 的特点和缺陷包括:

存储位置:保存在服务器端。数据类型:可以存储任意类型的数据。安全性:较高,不容易被篡改。存储限制:没有固定限制,但会占用服务器资源,访问量大时可能增加服务器压力。

结合使用

在实际开发中,CookieSession 常常结合使用。例如,通过 Cookie 存储一个 Session ID,而具体的会话数据保存在服务器端的 Session 中。这种方式既能利用 Cookie 的便利性,又能保证数据的安全性。

总之,CookieSession 各有优缺点,选择使用哪种机制取决于具体的应用场景和需求。

1.2短信验证码登录校验的流程

image-20250330221331194

发送短信验证码

提交手机号,校验手机号是否合法。

若不合法,重新输入。

若合法,则会生成一个验证码,并将其保存到session中,然后发送验证码给用户。

短信验证码登录、注册

输入手机号和验证码。首先校验输入的验证码是否和收到的验证码一致。

若不一致,重新输入。

若一致,则根据手机号查询用户是否存在。

​ 若不存在(注册)就新建用户,并将其保存到数据库。

​ 若存在,就将用户保存到session。

校验登录状态

客户端发送请求,并携带cookie。根据cookie存储的session id,找到对应的session。从session中获取用户

若用户不存在,则拦截。

若用户存在,则将用户保存到threadlocal中,并放行。

ThreadLocal:为每个线程提供一份单独存储空间,只有在线程内才能获取对应的值

1.3配置拦截器

image-20250330222255038

1.4session集群共享问题

  • 什么是Session集群共享问题

    在分布式集群环境中,会话(Session)共享是一个常见的挑战。默认情况下,Web 应用程序的会话是保存在单个服务器上的,当请求不经过该服务器时,会话信息无法被访问。

  • Session集群共享问题造成哪些问题

    • 服务器之间无法实现会话状态的共享。比如:在当前这个服务器上用户已经完成了登录,Session中存储了用户的信息,能够判断用户已登录,但是在另一个服务器的Session中没有用户信息,无法调用显示没有登录的服务器上的服务

  • 如何解决Session集群共享问题

    • 方案一Session拷贝(不推荐)

      Tomcat提供了Session拷贝功能,通过配置Tomcat可以实现Session的拷贝,但是这会增加服务器的额外内存开销,同时会带来数据一致性问题

    • 方案二Redis缓存(推荐)

      Redis缓存具有Session存储一样的特点,基于内存、存储结构可以是key-value结构、数据共享

  • Redis缓存相较于传统Session存储的优点

    • 高性能和可伸缩性:Redis 是一个内存数据库,具有快速的读写能力。相比于传统的 Session 存储方式,将会话数据存储在 Redis 中可以大大提高读写速度和处理能力。此外,Redis 还支持集群和分片技术,可以实现水平扩展,处理大规模的并发请求。

    • 可靠性和持久性:Redis 提供了持久化机制,可以将内存中的数据定期或异步地写入磁盘,以保证数据的持久性。这样即使发生服务器崩溃或重启,会话数据也可以被恢复。

    • 丰富的数据结构:Redis 不仅仅是一个键值存储数据库,它还支持多种数据结构,如字符串、列表、哈希、集合和有序集合等。这些数据结构的灵活性使得可以更方便地存储和操作复杂的会话数据。

    • 分布式缓存功能:Redis 作为一个高效的缓存解决方案,可以用于缓存会话数据,减轻后端服务器的负载。与传统的 Session 存储方式相比,使用 Redis 缓存会话数据可以大幅提高系统的性能和可扩展性。

    • 可用性和可部署性:Redis 是一个强大而成熟的开源工具,有丰富的社区支持和活跃的开发者社区。它可以轻松地与各种编程语言和框架集成,并且可以在多个操作系统上运行。

    PS:但是Redis费钱,而且增加了系统的复杂度

2、基于Redis实现短信验证码登录

uuid: 通用唯一标识符 128b
token:令牌

2.1、短信验证码登录校验流程

image-20250330222740167

短信验证码登录、注册

用户提交手机号和验证码,进行校验(以手机号为key,读取redis中对应的验证码)。

若不一致,就重新输入。

若一致根据手机号来查询用户。

​ 若用户不存在,就创建新的用户,并将其保存到数据库中。再以随机的token为key,用户为对应的value,存储到redis中。

​ 若用户存在,就以随机的token为key,用户为对应的value,存储到redis中。

最终返回token给客户端。

校验 登录状态

用户请求并携带token,以token为key,从redis中获取对应的用户数据。

​ 若没有存在,就拦截。

​ 若存在,就保存用户到threadlocal,并放行。

2.2、Redis代替Session需要考虑的问题

  • Hash 结构与 String 结构类型的比较

    • String 数据结构是以 JSON 字符串的形式保存,更加直观,操作也更加简单,但是 JSON 结构会有很多非必须的内存开销,比如双引号、大括号,内存占用比 Hash 更高

    • Hash 数据结构是以 Hash 表的形式保存,可以对单个字段进行CRUD,更加灵活

  • Redis替代Session需要考虑的问题

    • 选择合适的数据结构,了解 Hash 比 String 的区别

    • 选择合适的key,为key设置一个业务前缀,方便区分和分组,为key拼接一个UUID,避免key冲突防止数据覆盖

    • 选择合适的存储粒度(设置合适的TTL),对于验证码这类数据,一般设置TTL为3min即可,防止大量缓存数据的堆积,而对于用户信息这类数据可以稍微设置长一点,比如30min,防止频繁对Redis进行IO操作

2.3 配置拦截器

image-20250330224002053

单独配置一个拦截器用户刷新Redis中的token:在基于Session实现短信验证码登录时,我们只配置了一个拦截器,这里需要另外再配置一个拦截器专门用户刷新存入Redis中的 token,因为我们现在改用Redis了,为了防止用户在操作网站时突然由于Redis中的 token 过期,导致直接退出网站,严重影响用户体验。那为什么不把刷新的操作放到一个拦截器中呢,因为之前的那个拦截器只是用来拦截一些需要进行登录校验的请求,对于哪些不需要登录校验的请求是不会走拦截器的,刷新操作显然是要针对所有请求比较合理,所以单独创建一个拦截器拦截一切请求,刷新Redis中的Key

二、店铺数据查询

1、redis缓存(什么是?为什么?)

//什么是缓存
缓存就是数据交换的缓冲区(称作Cache [ kæʃ ] ),是存贮数据的临时地方,一般读写性能较高。

image-20250330224438673

为什么要使用redis缓存?

1、速度快(数据存储在内存)

在传统的数据库查询中,每次访问数据库都需要进行磁盘 I/O 操作,这会显著增加响应时间。通过使用 Redis 缓存,可以将频繁访问的数据存储在内存中,从而避免频繁的磁盘 I/O 操作。例如,当用户第一次访问某个数据时,系统会从数据库中读取并将其存储在 Redis 缓存中。下次访问时,直接从缓存中读取数据,速度会快很多

2、支持高并发

直接操作缓存能够承受的请求是远远大于直接访问数据库的,这样,部分用户请求可以直接从缓存中获取数据,而不需要经过数据库,从而减轻数据库的负载。

3、丰富的数据类型

支持丰富数据类型,这样使得redis可以灵活的应用于不同的业务场景。

4、事务支持和持久化

Redis 支持部分事务功能,可以确保在某些情况下操作的原子性。此外,Redis 还提供了数据持久化功能,可以将内存中的数据定期保存到磁盘,确保数据的可靠性

2、根据 id 查询商铺缓存

image-20250330225209770

对于店铺的详细数据,这种数据变化比较大,店家可能会随时修改店铺的相关信息(比如宣传语,店铺名等),所以对于这类变动较为频繁的数据,我们是直接存入Redis中,并且设置合适的有效期(后面还会进行优化,确保Redis和MySQL的数据一致性,以及解决缓存常见的三大问题)

3、查询店铺类型

对于店铺类型数据,一般变动会比较小,所以这里我们直接将店铺类型的数据持久化存储到Redis

4、使用缓存产生的问题及解决方案

4.1数据一致性问题

缓存的使用降低了后端负载提高了读写的效率降低了响应的时间,这么看来缓存是不是就一本万利呢?答案是否定的!并不是说缓存有这么多优点项目中就可以无脑使用缓存了,我们还需要考虑缓存带来的问题,比如:缓存的添加提高了系统的维护成本,同时也带来了数据一致性问题……总的来讲,系统使用引入缓存需要经过前期的测试、预算,判断引入缓存后带来的价值是否会超过引入缓存带来的代价

4.2常见的缓存更新策略

image-20250330225856465

4.3 redis内存淘汰策略

image-20241123153102239

image-20241123154723902

image-20241123154806110

4.3如何保证数据一致性?

延迟双删

image-20241123180037701

删除缓存,修改数据库之后延迟一小会儿,再删除缓存

有可能一个线程删除了缓存,修改数据库之前另一个线程又读取了数据库修改前的东西然后放入缓存,所以第一个线程修改后要再删除一次

关于延迟双删的第一个问题:先删缓存还是先修改数据库?

针对删除缓存,修改数据据这一步有两种方案,这两种方案都可能产生的问题:数据不一致问题

关于延迟双删的第二个问题:为什么要删除两次缓存?

降低数据不一致性出现的可能。

关于延迟双删的第三个问题:为什么要延时双删?

因为一般情况下数据库是主从模式,他是读写分离的,更新主库中的数据后,需要延时一会儿,将主库中的数据同步到从库。

而延时时间不好把控,也可能有脏数据的风险(发生数据不一致性)

分布式锁(互斥锁)

在写数据或者读数据时,添加一个互斥锁。可以保证数据的绝对一致性,但是性能低

image-20241123182812158

读写锁(共享锁、排他锁)

image-20241123183038399

使用读写锁,可以保证数据的强一致性,但是性能较低(在写数据时会阻塞其他线程来读数据)

基于MQ的异步通知

可以允许数据短暂不一致,保证数据的最终一致性

image-20241123183940929

在修改数据库中的数据后,会发一条消息给MQ,缓存服务监听MQ,接收到其中的消息,来更新缓存(主要通过保证MQ的可靠性来实现)

4.4缓存穿透

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。可能会造成数据库宕机。

image-20250330231644756

解决方法

  • 缓存空对象

    • 优点:实现简单,维护方便

    • 缺点:额外的内存消耗,可能造成短期的不一致

image-20250330232005306

  • 布隆过滤

    • 优点:内存占用较少,没有多余key

    • 缺点:实现复杂,而不是非常准确的。理论情况下添加到集合中的元素越多,误报的可能性就越大。并且,存放在布隆过滤器的数据不容易删除。

image-20241123170623368

当一个元素加入布隆过滤器中的时候,会进行如下操作:
使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。
根据得到的哈希值,在位数组中把对应下标的值置为 1。

当我们需要判断一个元素是否存在于布隆过滤器的时候,会进行如下操作:
对给定元素再次进行相同的哈希计算;
得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。

image-20250330232425002

上面两种方式都是被动的解决缓存穿透方案,此外我们还可以采用主动的方案预防缓存穿透,比如:增强id的复杂度避免被猜测id规律做好数据的基础格式校验加强用户权限校验做好热点参数的限流

4.5缓存雪崩

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

image-20250330232517527

  • 缓存雪崩的常见解决方案

    针对 Redis 服务不可用(宕机)的情况:

    1. Redis 集群:采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。Redis Cluster 和 Redis Sentinel 是两种最常用的 Redis 集群实现方案。(哨兵模式、集群模式)

    2. 多级缓存:设置多级缓存,例如本地缓存+Redis 缓存的二级缓存组合,当 Redis 缓存出现问题时,还可以从本地缓存中获取到部分数据。

    针对大量缓存同时失效的情况:

    1. 设置随机失效时间(可选):为缓存设置随机的失效时间,例如在固定过期时间的基础上加上一个随机值,这样可以避免大量缓存同时到期,从而减少缓存雪崩的风险。(ttl添加随机值)

    2. 提前预热(推荐):针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。

    3. 持久缓存策略(看情况):虽然一般不推荐设置缓存永不过期,但对于某些关键性和变化不频繁的数据,可以考虑这种策略

4.6缓存击穿

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

image-20250330232855199

缓存击穿的常见解决方案

  1. 永不过期(不推荐):设置热点数据永不过期或者过期时间比较长。

  2. 提前预热(推荐):针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。

  3. 加互斥锁(看情况:保证数据高一致性,性能差):在缓存失效后,通过设置互斥锁确保只有一个请求去查询数据库并更新缓存。

  4. 逻辑过期 (看情况:保证高可用性与性能):不设置过期时间,在存储数据时新增一个过期时间的字段

image-20241123172415495

逻辑过期:在线程1查询缓存时,如果发现逻辑时间过期,就会获取一个互斥锁(用于缓存重建),然后新开一个线程2来重建缓存数据,并由线程2来释放锁,线程1在开启线程2之后并不需要等待线程2重建成功,可以直接返回过期数据。

线程3:发现逻辑时间过期,也要获取锁来构建缓存,但是线程1已经获取了,所以线程2就返回过期数据
线程4:在重建数据成功后,返回的是最新的数据

所以:逻辑过期可用性与性能高,数据一致性不高

三、优惠券秒杀

1、自增id存在的问题

当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题:

id的规律性太明显,容易出现信息的泄露,被不怀好意的人伪造请求

受单表数据量的限制,MySQL中表能够存储的数据有限,会出现分库分表的情况,id不能够一直自增

当ID规律过于明显时,存在以下一些缺点:

1、安全性问题:如果ID规律太明显,可能会使系统容易受到恶意攻击,例如暴力破解等。攻击者可以通过分析ID规律来推断出其他用户的ID,从而进行未授权的访问或操纵。
2、隐私泄露风险:如果ID规律太明显,可能导致用户的个人信息或敏感数据被曝光。攻击者可以根据规律推测出其他用户的ID,并通过这些ID获取到相应的数据,进而侵犯用户的隐私。
3、数据可预测性:当ID规律太明显时,使用这些规律的攻击者可以很轻易地猜测出其他实体(如订单、交易等)的ID。这可能破坏系统的数据安全性和防伪能力。
4、扩展性受限:如果ID规律太明显,可能会对系统的扩展性造成一定影响。当系统需要处理大量并发操作时,如果ID规律过于明显,可能导致多个操作同时对同一资源进行竞争,从而增加冲突和性能瓶颈。
5、维护困难:当ID规律太明显时,系统可能需要额外的资源和机制来保持规律的更新和变化,以确保安全性和数据完整性。这会增加系统的复杂度,并给维护带来挑战。
在MySQL中,表最多可以存储的记录数取决于多个因素,包括数据库版本、操作系统和硬件配置等。下面是一些常见的限制:

行数限制:在MySQL 5.7及之前的版本中,InnoDB和XtraDB存储引擎的行数限制为最大约为64亿,即4,294,967,295 (2^32 -1)行。而在MySQL 8.0及以后的版本中,它们的行数限制可达到理论上的最大值,大约是1844万亿(2^64-1)行。
数据库文件大小限制:每个InnoDB表的存储大小受到所使用文件系统的限制。对于InnoDB表,默认情况下,数据库文件的大小限制取决于操作系统和文件系统,通常在几TB或更高。但是,这也可能受到特定的操作系统和文件系统的限制。
硬件资源限制:实际上,表的记录数还受到可用硬件资源,如磁盘空间、内存和处理能力的限制。当数据库文件较大时,磁盘空间变得关键,而在执行查询时,内存和处理能力可影响读写性能。
业界流传是500万行。超过500万行就要考虑分表分库了。阿里巴巴《Java 开发手册》提出单表行数超过 500 万行或者单表容量超过 2GB,就需要考虑分库分表了。

那么该如何解决呢?我们需要使用分布式ID(也可以叫全局唯一ID),分布式ID满足以下特点:

1、全局唯一性:分布式ID保证在整个分布式系统中唯一性,不会出现重复的标识符。这对于区分和追踪系统中的不同实体非常重要。
2、高可用性:分布式ID生成器通常被设计为高可用的组件,可以通过水平扩展、冗余备份或集群部署来确保服务的可用性。即使某个节点或组件发生故障,仍然能够正常生成唯一的ID标识符。
3、安全性:分布式ID生成器通常是独立于应用程序和业务逻辑的。它们被设计为一个单独的组件或服务,可以被各种应用程序和服务所共享和使用,使得各个应用程序之间的ID生成过程互不干扰。
4、高性能:分布式ID生成器通常要求在很短的时间内生成唯一的标识符。为了实现低延迟,设计者通常采用高效的算法和数据结构,以及优化的网络通信和存储策略。
5、递增性:分布式ID通常可以被设计成可按时间顺序排序,以便更容易对生成的ID进行索引、检索或排序操作。这对于一些场景,如日志记录和事件溯源等,非常重要。

2、分布式(全局)id的实现

分布式ID的实现方式:

  1. UUID

  2. Redis自增

  3. 数据库自增

  4. snowflake算法(雪花算法)

这里我们使用自定义的方式实现:时间戳+序列号+数据库自增

为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息,比如时间戳、UUID、业务关键词

image-20250331174213544

  • 符号位:1bit,永远为0(表示正数)

  • 时间戳:31bit,以秒为单位,可以使用69年(2^31 / 3600 / 24 / 365 ≈ 69)

  • 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID

3、优惠秒杀接口的实现

下单业务本质上就是修改优惠券表中的number字段,再在order表中新增一条订单数据;

业务流程

image-20250331174657181

提交优惠券id,查询优惠券信息,判断秒杀是否开始。

若未开始,则返回异常。

若已经开始,则判断库存是否充足。

​ 若库存不充足,则返回异常。

​ 若库存充足,则扣减库存,生成订单,并返回订单id。

4、单体下一人多单超卖问题

4.1一人多单超卖问题的解决方案

通过分布式ID+事务成功完成了优惠券秒杀功能,但是在高并发的场景下可能会失败。(通过Jmeter 来进行压力测试)

为什么会出现超卖问题?

image-20250331175211443

线程1查询库存,发现库存充足,创建订单,然后准备对库存进行扣减,但此时线程2和线程3也进行查询,同样发现库存充足,然后线程1执行完扣减操作后,库存变为了0,线程2和线程3同样完成了库存扣减操作,最终导致库存变成了负数!这就是超卖问题的完整流程

超卖问题的常见解决方案:

  • 悲观锁,认为线程安全问题一定会发生,因此操作数据库之前都需要先获取锁,确保线程串行执行。常见的悲观锁有:synchronized、lock

  • 乐观锁,认为线程安全问题不一定发生,因此不加锁,只会在更新数据库的时候去判断有没有其它线程对数据进行修改,如果没有修改则认为是安全的,直接更新数据库中的数据即可,如果修改了则说明不安全,直接抛异常或者等待重试。常见的实现方式有:版本号法、CAS操作、乐观锁算法

悲观锁和乐观锁的比较

  • 悲观锁比乐观锁的性能低:悲观锁需要先加锁再操作,而乐观锁不需要加锁,所以乐观锁通常具有更好的性能。

  • 悲观锁比乐观锁的冲突处理能力低:悲观锁在冲突发生时直接阻塞其他线程,乐观锁则是在提交阶段检查冲突并进行重试。

  • 悲观锁比乐观锁的并发度低:悲观锁存在锁粒度较大的问题,可能会限制并发性能;而乐观锁可以实现较高的并发度。

  • 应用场景:两者都是互斥锁,悲观锁适合写入操作较多、冲突频繁的场景;乐观锁适合读取操作较多、冲突较少的场景。

4.2乐观锁解决一人多单超卖问题

实现方式一:版本号法

image-20250331180124038

首先我们要为 tb_seckill_voucher 表新增一个版本号字段 version ,线程1查询完库存,在进行库存扣减操作的同时将版本号+1,线程2在查询库存时,同时查询出当前的版本号,发现库存充足,也准备执行库存扣减操作,但是需要判断当前的版本号是否是之前查询时的版本号,结果发现版本号发生了改变,这就说明数据库中的数据已经发生了修改,需要进行重试(或者直接抛异常中断)

实现方式二:CAS法

image-20250331180209710

CAS法类似与版本号法,但是不需要另外在添加一个 version 字段,而是直接使用库存替代版本号,线程1查询完库存后进行库存扣减操作,线程2在查询库存时,发现库存充足,也准备执行库存扣减操作,但是需要判断当前的库存是否是之前查询时的库存,结果发现库存数量发生了改变,这就说明数据库中的数据已经发生了修改,需要进行重试(或者直接抛异常中断)

注意:上述乐观锁使用有弊端

我们只要发现数据修改就直接终止操作了(不正确),我们只需要修改一下判断条件,即只要库存大于0就可以进行修改,而不是库存数据修改我们就终止操作。

5、单体下一人一单超卖问题

5.1一人一单超卖问题的解决方案

业务逻辑修改,防止黄牛批量刷券

image-20250331183147054

问题原因:出现这个问题的原因和前面库存为负数数的情况是一样的,线程1查询当前用户是否有订单,当前用户没有订单准备下单,此时线程2也查询当前用户是否有订单,由于线程1还没有完成下单操作,线程2同样发现当前用户未下单,也准备下单,这样明明一个用户只能下一单,结果下了两单,也就出现了超卖问题。

解决方案:一般这种超卖问题可以使用下面两种常见的解决方案

1、悲观锁

2、乐观锁

5.2悲观锁解决一人一单超卖问题

乐观锁需要判断数据是否修改,而当前是判断当前是否存在,所以无法像解决库存超卖一样使用CAS机制,但是可以使用版本号法,但是版本号法需要新增一个字段,所以这里为了方便,就直接演示使用悲观锁解决超卖问题

image-20250331183543564

6、集群下一人一单超卖问题

6.1集群下使用synchronized的问题

synchronized是本地锁,只能提供线程级别的同步,每个JVM中都有一把synchronized锁,不能跨 JVM 进行上锁,当一个线程进入被 synchronized 关键字修饰的方法或代码块时,它会尝试获取对象的内置锁(也称为监视器锁)。如果该锁没有被其他线程占用,则当前线程获得锁,可以继续执行代码;否则,当前线程将进入阻塞状态,直到获取到锁为止。而现在我们是创建了两个节点,也就意味着有两个JVM,所以synchronized会失效!

6.2分布式锁

  • 分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁

前面sychronized锁失效的原因是由于每一个JVM都有一个独立的锁监视器,用于监视当前JVM中的sychronized锁,所以无法保障多个集群下只有一个线程访问一个代码块。所以我们直接将使用一个分布锁,在整个系统的全局中设置一个锁监视器,从而保障不同节点的JVM都能够识别,从而实现集群下只允许一个线程访问一个代码块

image-20250331184302012

分布式锁的特点:

  • 多线程可见

  • 互斥。分布式锁必须能够确保在任何时刻只有一个节点能够获得锁,其他节点需要等待。

  • 高可用。分布式锁应该具备高可用性,即使在网络分区或节点故障的情况下,仍然能够正常工作。(容错性)当持有锁的节点发生故

    障或宕机时,系统需要能够自动释放该锁,以确保其他节点能够继续获取锁。

  • 高性能。分布式锁需要具备良好的性能,尽可能减少对共享资源的访问等待时间,以及减少锁竞争带来的开销。

  • 安全性。(可重入性)如果一个节点已经获得了锁,那么它可以继续请求获取该锁而不会造成死锁。(锁超时机制)为了避免某个节点因故障或其他原因无限期持有锁而影响系统正常运行,分布式锁通常应该设置超时机制,确保锁的自动释放。

分布式锁的常见实现方式

image-20250331184548534

  • 基于关系数据库:可以利用数据库的事务特性和唯一索引来实现分布式锁。通过向数据库插入一条具有唯一约束的记录作为锁,其他进程在获取锁时会受到数据库的并发控制机制限制。

  • 基于缓存(如Redis):使用分布式缓存服务(如Redis)提供的原子操作来实现分布式锁。通过将锁信息存储在缓存中,其他进程可以通过检查缓存中的锁状态来判断是否可以获取锁。

  • 基于ZooKeeper:ZooKeeper是一个分布式协调服务,可以用于实现分布式锁。通过创建临时有序节点,每个请求都会尝试创建一个唯一的节点,并检查自己是否是最小节点,如果是,则表示获取到了锁。

  • 基于分布式算法:还可以利用一些分布式算法来实现分布式锁,例如Chubby、DLM(Distributed Lock Manager)等。这些算法通过

    在分布式系统中协调进程之间的通信和状态变化,实现分布式锁的功能。

  • setnx指令的特点:setnx只能设置key不存在的值,值不存在设置成功,返回 1 ;值存在设置失败,返回 0

  • #Redis分布式锁原理:基于setnx命令–>key存在的情况下,不更新value,而是返回0 #那么利用key是唯一的特性来加锁,比如一人一单业务,key名称精确到userId,那么同一个用户无论发多少次请求,能成功创建键值的只有一个,因为setnx命令,后面的请求在获取锁创建键值就会失败

获取锁

方式一:

# 添加锁
setnx [key] [value]
# 为锁设置过期时间,超时释放,避免死锁
expire [key] [time]

方式二:这种方式更加推荐,因为将上面两个指令变成一个指令,从而保障指令的原子性

# 添加锁
set [key] [value] ex [time] nx

释放锁:

# 释放锁(除了使用del手动释放,还可超时释放)
del [key]

6.3分布式锁解决超卖问题

使用Redis的setnx指令实现分布式锁解决超卖问题

业务流程

image-20250331185137009

6.4分布式锁优化

误删问题优化

本次优化主要解决了锁超时释放出现的超卖问题

问题描述

当线程1获取锁后,由于业务阻塞,线程1的锁超时释放了,这时候线程2趁虚而入拿到了锁,然后此时线程1业务完成了,然后把线程2刚刚获取的锁给释放了,这时候线程3又趁虚而入拿到了锁,这就导致又出现了超卖问题

image-20250331190810325

解决逻辑如下图

我们为分布式锁添加一个线程标识,在释放锁时判断当前锁是否是自己的锁,是自己的就直接释放,不是自己的就不释放锁,从而解决多个线程同时获得锁的情况导致出现超卖

image-20250331190824555

解决后业务流程

image-20250331190944899

原子性问题优化Lua脚本

本次优化主要解决了释放锁时的原子性问题。说到底也是锁超时释放的问题

当线程1获取锁,执行完业务然后并且判断完当前锁是自己的锁时,但就在此时发生了阻塞,结果锁被超时释放了,线程2立马就趁虚而入了,获得锁执行业务,但就在此时线程1阻塞完成,由于已经判断过锁,已经确定锁是自己的锁了,于是直接就删除了锁,结果删的是线程2的锁,这就又导致线程3趁虚而入了,从而继续发生超卖问题

image-20250331185622419

判断锁的操作和释放锁的操作得成一个原子性操作,一起执行,要阻塞都阻塞,要通过都通过

该如何保障 判断锁 和 释放锁 这连段代码的原子性呢?

[Lua快速入门笔记客](https://blog.csdn.net/qq_66345100/article/details/131617253?spm=1001.2014.3001.5501)

Redis 提供了 Lua 脚本功能,在一个脚本中编写多条 Redis 命令,确保多条命令执行时的原子性。

我们将释放锁的操作写到Lua脚本中去,直接调用脚本。

释放锁的业务逻辑是这样的:

①获取锁中的线程标识

②判断是否与指定的标识(当前线程标识)一致

③如果一致则释放锁(删除)

④如果不一致则什么都不做

注意:虽然Redis在单个Lua脚本的执行期间会暂停其他脚本和Redis命令,以确保脚本的执行是原子的,但如果Lua脚本本身出错,那么无法完全保证原子性。也就是说Lua脚本中的Redis指令出错,会发生回滚以确保原子性,但Lua脚本本身出错无法保障原子性

-- 这里的 KEYS[1] 就是锁的 key,这里的 ARGV[1] 就是当前线程标识
-- 获取锁中的线程标识 get key
local id = redis.call('get', KEYS[1]);
-- 比较线程标识与锁中的标识是否一致
if (id == ARGV[1]) then
    -- 释放锁 del key
    return redis.call('del', KEYS[1])
end
return 0

优化后仍然会出现的问题

经过优化1和优化2,我们实现的分布式锁已经达到生产可用级别了,但是还不够完善,比如:

分布式锁不可重入:不可重入是指同一线程不能重复获取同一把锁。比如,方法A中调用方法B,方法A需要获取分布式锁,方法B同样需要获取分布式锁,线程1进入方法A获取了一次锁,进入方法B又获取一次锁,由于锁不可重入,所以就会导致死锁

分布式锁不可重试获取锁只尝试一次就返回false,没有重试机制,这会导致数据丢失,比如线程1获取锁,然后要将数据写入数据库,但是当前的锁被线程2占用了,线程1直接就结束了而不去重试,这就导致数据发生了丢失

分布式锁超时释放锁超时释放机机制虽然一定程度避免了死锁发生的概率,但是如果业务执行耗时过长,期间锁就释放了,这样存在安全隐患。锁的有效期过短,容易出现业务没执行完就被释放,锁的有效期过长,容易出现死锁,所以这是一个大难题!

我们可以设置一个较短的有效期,但是加上一个 心跳机制 和 自动续期:在锁被获取后,可以使用心跳机制并自动续期锁的持有时间。通过定期发送心跳请求,显示地告知其他线程或系统锁还在使用中,同时更新锁的过期时间。如果某个线程持有锁的时间超过了预设的有效时间,其他线程可以尝试重新获取锁。

主从一致性问题:如果 Redis 提供了主从集群,主从延同步在延迟,当主机宕机时,如果从机同步主机中的数据,则会出现锁失效

6.5 Redisson分布式锁(可重入)

Redisson就是一个使用Redis解决分布式问题的方案的集合(redis框架)

tryLock方法介绍
tryLock():它会使用默认的超时时间和等待机制。具体的超时时间是由 Redisson 配置文件或者自定义配置决定的。

tryLock(long time, TimeUnit unit):它会在指定的时间内尝试获取锁(等待time后重试),如果获取成功则返回 true,表示获取到了锁;如果在指定时间内(Redisson内部默认指定的)未能获取到锁,则返回 false。

tryLock(long waitTime, long leaseTime, TimeUnit unit):指定等待时间为watiTime,如果超过 leaseTime 后还没有获取锁就直接返回失败

总的来讲自上而下,tryLock的灵活性逐渐提高,无参tryLock时,waitTime的默认值是-1,代表不等待,leaseTime的默认值是30,unit默认值是 seconds ,也就是锁超过30秒还没有释放就自动释放

可重入锁的原理

image-20250331192617335

Redisson内部释放锁,并不是直接执行del命令将锁给删除,而是将锁以hash数据结构的形式存储在Redis中,每次获取锁,都将value的值+1,每次释放锁,都将value的值-1,只有锁的value值归0时才会真正的释放锁,从而确保锁的可重入性

image-20250331192731704

Redisson分布式锁原理

如何解决可重入问题:利用hash结构记录线程id和重入次数。

如何解决可重试问题:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制。

如何解决超时续约问题:利用watchDog,每隔一段时间(releaseTime / 3),重置超时时间。

如何解决主从一致性问题:利用Redisson的multiLock,多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功

缺陷:运维成本高、实现复杂

7、秒杀优化

最开始我们的遇到自增ID问题,我们通过实现分布式ID解决了问题;后面我们在单体系统下遇到了一人多单超卖问题,我们通过乐观锁解决了;我们对业务进行了变更,将一人多单变成了一人一单,结果在高并发场景下同一用户发送相同请求仍然出现了超卖问题,我们通过悲观锁解决了;由于用户量的激增,我们将单体系统升级成了集群,结果由于锁只能在一个JVM中可见导致又出现了,在高并发场景下同一用户发送下单请求出现超卖问题,我们通过实现分布式锁成功解决集群下的超卖问题;由于我们最开始实现的分布式锁比较简单,会出现超时释放导致超卖问题,我们通过给锁添加线程标识成功解决了;但是释放锁时,判断锁是否是当前线程 和 删除锁两个操作不是原子性的,可能导致超卖问题,我们通过将两个操作封装到一个Lua脚本成功解决了;为了解决锁的不可重入性,我们通过将锁以hash结构的形式存储,每次释放锁都value-1,获取锁value+1,从而实现锁的可重入性,并且将释放锁和获取锁的操作封装到Lua脚本中以确保原子性。最最后,我们发现可以直接使用现有比较成熟的方案Redisson来解决上诉出现的所有问题🤣,什么不可重试、不可重入、超市释放、原子性等问题Redisson都提供相对应的解决方法(。^▽^)

所以现在锁的优化基本上已经到了极致,我们现在就要对性能稳定性进行进一步的优化

7.1 异步秒杀优化

同步(Synchronous)是指程序按照顺序依次执行,每一步操作完成后再进行下一步。在同步模式下,当一个任务开始执行时,程序会一直等待该任务完成后才会继续执行下一个任务。
异步(Asynchronous)是指程序在执行任务时,不需要等待当前任务完成,而是在任务执行的同时继续执行其他任务。在异步模式下,任务的执行顺序是不确定的,程序通过回调、事件通知等方式来获取任务执行的结果。

显然异步的性能是要高于同步的,但是会牺牲掉一定的数据一致性,所以也不是无脑用异步,要根据具体业务进行分析,这里的下单是可以使用异步的,因为下单操作比较耗时,后端操作步骤多,可以进行拆分

之前同步的秒杀业务流程

image-20250331193501929

这个流程是同步执行的,同步是比较耗费时间的,我们直接将同步变成异步,从而大幅提高秒杀业务的性能,具体如何做呢?我们可以将一部分的工作交给Redis,并且不能直接去调用Redis,而是通过开启一个独立的子线程去异步执行,从而大大提高效率

image-20250331193603577

实现

image-20250331193824472image-20250331193852540

7.2消息队列优化

前面我们使用 Java 自带的阻塞队列 BlockingQueue 实现消息队列,这种方式存在以下几个严重的弊端:

1、信息可靠性没有保障,BlockingQueue 的消息是存储在内存中的,无法进行持久化,一旦程序宕机或者发生异常,会直接导致消息丢失
2、消息容量有限,BlockingQueue 的容量有限,无法进行有效扩容,一旦达到最大容量限制,就会抛出OOM异常

这里我们可以选择采用其它成熟的的(和之前分布式锁一样)MQ,比如:RabbitMQ、RocketMQ、Kafka等,但是本项目是为了学习Redis而设计的,所以这里我们将要学习如何使用Redis实现一个相对可靠的消息队列

image-20250331194654965

基于redis实现的消息队列

image-20250331194417190

四、达人探店

1、Set实现点赞功能

问题

现在存在一个问题,一个用户可以无限点赞,这显然是不合理的,所以我们需要对点赞功能进行一个优化,实现一人只能点赞一次。

对于点赞这种高频变化的数据,如果我们使用MySQL是十分不理智的,因为MySQL慢、并且并发请求MySQL会影响其它重要业务,容易影响整个系统的性能,继而降低了用户体验。那么如何我们要使用Redis,那么我们又该选择哪种数据结构才更加合理呢?

方法

这里我推荐使用Set,因为Set类型的数据结构具有

  1. 不重复,符合业务的特点,一个用户只能点赞一次

  2. 高性能,Set集合内部实现了高效的数据结构(Hash表)

  3. 灵活性,Set集合可以实现一对多,一个用户可以点赞多个博客,符合实际的业务逻辑

当然也可以选择使用Hash(Hash占用空间比Set更小),如果想要点赞排序也可以选用Sorted Set

image-20250331195900717

2、SortedSet实现点赞排行榜

image-20250331195959120

在平常我们所使用的软件中(比如微信、QQ、抖音)的点赞功能都会默认按照时间顺序对点赞的用户进行一个排序,后点赞的用户会排在最前面,而Set是无需的,无法满足这个需求,虽然 List有序,但是不唯一,查找效率也比较低,所以也不推荐使用,此时我们就可以选择使用SortedSet这个数据结构,它完美的满足了我们所有的需求:唯一、有序、查找效率高。

相较于Set集合,SortedList有以下不同之处:

对于Set集合我们可以使用 isMember方法判断用户是否存在,对于SortedList我们可以使用ZSCORE方法判断用户是否存在
Set集合没有提供范围查询,无法获排行榜前几名的数据,SortedList可以使用ZRANGE方法实现范围查询

如果我们的需求是,先点赞的排在前面,后点赞的排在后面该如何实现?

在MySQL中如果我们使用in进行条件查询,我们的查询默认是数据库顺序查询,数据库中的记录默认都是按照ID自增的,所以查出来的结果默认是按照ID自增排序的

解决方法:

select id, phone,password,nick_name,icon,create_time,update_time
from tb_user
where id in(1, 5)
order by field(id, 5, 1)

注意:根据某一个字段进行排序,oder by字段的id顺序即为查询的顺序

五、好友关注

1、Set实现共同关注

我们想要查询出两个用户的共同关注对象,这就需要使用求交集,对于求交集,我们可以使用Set集合

2、Feed流关注推送

什么是Feed流

关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。Feed流是一种基于用户个性化需求和兴趣的信息流推送方式,常见于社交媒体、新闻应用、音乐应用等互联网平台。Feed流通过算法和用户行为数据分析,动态地将用户感兴趣的内容以流式方式呈现在用户的界面上。

image-20250331200723526

Feed流产品有两种常见模式:

  • 时间排序(Timeline):不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈

    优点:信息全面,不会有缺失。并且实现也相对简单

    缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低

  • 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户

    优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷

    缺点:如果算法不精准,可能起到反作用