雅鉴生活优化总结
1. 导入项目
导入sql——-导入后端项目 浏览器验证——导入前端项目 启动nginx 浏览器验证

2 登录模块
发送验证码——->登录——>登录校验
2.1 基于Session实现登录


存用户的时候注意,User—–>UserDTO,防止前端暴露太多敏感信息。

2.2 集群的session共享问题

2.3 基于Redis实现共享session登录
Redis满足数据共享,内存存储,key value。



在session中存储是session.setAttribute(“code”,code); session.setAttribute(“user”,user);
session.getAttribute(“code”); session.getAttribute(“user”);
session中存储用的是”code”,是因为每个用户都有自己的session,所以每个用户用自己的sessionid去获取自己的code。
但redis的数据是共享的,就不能用“code”,key对每个用户来说应该唯一便携。
手机号满足但不行,因为是敏感数据,所以用UUID随机生成一个Token。
存验证码用Redis的string,存用户用Redis的Hash(需要注意要把user用户转HashMap存储,对象转Map确保其中的每个value都是string)。



2.4 登录校验优化
在Redis中给token设置有效期与session的不同之处:
- Redis中设置的有效期:是从登录那一刻开始无论有没有人访问,到30分钟自动剔除。
- Session中设置的有效期:一直访问一直有效,一直无人访问30分钟后剔除。
故采用Redis方式需要在拦截其中加入刷新登录token令牌存活时间的逻辑。
但还有一个问题是:现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以需要优化。


- 优化:

- RefreshTokenInterceptor和LoginInterceptor中的StringRedisTemplate不是注解加入,因为该类不是由spring容器管理,这俩拦截器不加@Component声明成Bean是因为会导致性能下降。
- 而MvcConfig可以注解加入是因为@Configuration表明该类是配置类,支持依赖注入。 MvcConfig让拦截器生效。
2.5 黑名单功能
这个黑名单功能主要是:防止短信验证码接口被恶意调用,通过限制用户获取验证码的频率和总次数来保护系统安全。
- 频率限制:
① 同一手机号在1分钟内只能获取一次验证码。
② 通过 Redis 锁机制实现:每次请求时,检查 Redis 中是否已有锁,若存在则返回失败,若不存在则生成验证码并设置1分钟过期的锁。- 手机号总次数限制:
① 同一手机号1小时内最多获取10次验证码,超过次数将该手机号加入黑名单,24小时后自动移除。
② 使用 Redis 存储验证码请求次数。- IP地址总次数限制:
① 同一IP地址1小时内最多请求50次验证码,超过限制则加入IP黑名单。
② 客户端IP地址位于数据包IP头部的源地址,那么我们可以在每次请求到来时将这个地址存下来,然后采用和手机号黑名单相同的思路来实现这个功能。
③ 由于客户端可能使用代理或频繁切换网络,导致 IP 地址不断变化,从而使基于 IP 地址的限制效果变差。为了解决这一问题,我们可以通过获取客户端的设备唯一识别码(如 MAC 地址或设备 ID)来进行识别,并将其放在 HTTP 请求头中。服务器可以利用该识别码来标识设备,进而限制恶意请求。




IP黑名单应用的场景是:短信发送接口被同一个人使用不同的手机号多次恶意调用。


如何避免误封和提高用户体验?
第一次违规可以限制几小时,第二次违规则加长黑名单时间。
对于误封的用户,要提供申诉机制。
对于被加入黑名单的用户,给出明确的提示或通知,告知被封禁的原因
String.valueOf Integer.parseInt



2.6 登录模块自测面试题
1. 介绍一下你做的登录模块
登录模块主要分为三个部分,主要包括登录功能、会话保持、黑名单功能。
登录功能是基于Redis实现,没有选择传统模式下的Session是由于本项目是多台服务器。
这个登录模块主要包括发送验证码和登录验证两个核心流程。
- 发送验证码:校验手机号格式,检查获取验证码的频率,检查手机号和IP黑名单,更新黑名单计数和设置锁,生成6位验证码并存入Redis,设置1分钟有效期。
- 登录验证:校验手机号格式,从Redis获取验证码并校验,根据手机号查询用户,不存在则自动注册,使用UUID生成随机Token,将用户信息转为HashMap存入Redis,设置Token有效期,返回Token给前端,保证了用户信息不直接在网络中传输,保护了隐私。
由于redis设置有效期的方式与session的不同,为提升用户体验,实现Token自动续期,采用两个拦截器协同工作:
RefreshTokenInterceptor:
拦截所有请求,排除登录和验证码接口;实现 token 自动刷新
获取请求头中的token,根据token从Redis获取用户信息,将用户信息保存到ThreadLocal,刷新token有效期,放行所有请求(包括未登录请求)
LoginInterceptor:
拦截需要登录的接口,排除公共接口(商铺、优惠券等);进行登录校验
判断ThreadLocal中是否有用户,无用户则拦截并返回401状态码,有用户则放行

用户信息通过 ThreadLocal 在线程间传递。通过ThreadLocal实现了在任意业务代码中便捷获取当前登录用户信息
还实现了基于Redis BitMap的用户签到功能:

2. 生成token为什么使用UUID?为什么UUID具有唯一性?UUID有多长?
为什么使用UUID?
- UUID(通用唯一标识符)是一个唯一的标识符,它能够生成全球唯一的字符串。我们在生成 token 时使用 UUID,是因为每个用户的 token 必须是唯一的,不能重复。UUID 能保证这一点,不会与其他任何 token 冲突。
为什么UUID具有唯一性?
- UUID 之所以具有唯一性,是因为它的生成依赖于多个因素,比如:
- 时间:生成 UUID 时,通常会使用生成时的时间戳。
- 机器标识符:有些版本的 UUID 会用机器的唯一标识(如 MAC 地址)来保证全球唯一。
- 随机数:有的 UUID 版本使用随机数,增加其不重复的可能性。
- 这些因素结合在一起,使得 UUID 的生成几乎保证了全局唯一。
UUID有多长?
- UUID 的长度是 128 位,通常以 32 个十六进制字符表示,格式为 8-4-4-4-12,共 36 个字符(包括连字符)。



3. 介绍一下基于Cookie - Session的会话保持实现的具体流程?为什么要使用redis + token这种方式,使用了redis + token之后有什么变化?
会话保持就是在用户登录一个网站后,系统能够记住该用户在后续的访问中,确保用户无需重新登录。
基于 Cookie-Session 的会话保持具体流程:
- 客户端发送请求到服务端;
- 服务端收到请求后生成session,并将用户会话信息(如用户ID、登录状态等)存储在服务器中;
- 服务器将生成的 Session ID 通过 Set - Cookie 头部发送到客户端,这个 Session ID 用来标识用户的会话;
- 客户端浏览器会将 Session ID 存储在 Cookie 中,并在后续请求时自动携带;
- 每次客户端发起请求时,浏览器会将 Session ID 作为 Cookie 发送给服务器,服务器根据 Session ID 查找用户会话信息,从而验证用户身份;
- 会话到期或用户退出时,服务器删除该 Session 数据,用户需要重新登录。
为什么要使用redis + token这种方式?
- 我们的项目是集群环境的,如果使用 Cookie-Session 方式,会遇到 会话不一致 的问题。例如,用户第一次访问第一台 Tomcat 服务器并存储了会话数据,但第二次访问时,可能会被路由到另一台 Tomcat 服务器,导致找不到之前存储的 Session,进而导致登录拦截问题。
- 早期的解决方案是 Session 拷贝,即每次某台服务器的 Session 更新时,都会同步到其他服务器。这样虽然可以实现 Session 共享,但存在以下两个问题:
每台服务器都需要存储一份完整的 Session 数据,导致服务器压力过大。
Session 拷贝 存在延迟,可能造成会话同步不及时,影响用户体验。
- 使用redis+ token这种方式,解决了多个Tomcat服务器之间session共享问题。
使用了redis + token之后有什么变化?
- 头部存放位置由Cookie字段变为Authorization字段,Cookie用于会话管理,Authorization用于存储令牌,放在Cookie里面可以自动发送,但是这样不能跨域(多服务器),更好的做法是放在HTTP Header 的Authorization字段中。
- 将session的存储位置从服务器缓存更改为redis,redis是分布式存储系统,解决了session共享的问题。
4. JWT方案介绍
- JWT(JSON Web Token)是一种用于身份验证和信息交换的开放标准,它通过自包含的 Token 来避免服务器存储会话信息,适合分布式系统和微服务架构。
- 它由三部分组成,分别是头部(Header)、载荷(Payload)和签名(Signature)。其中,签名是用于验证令牌的完整性和可信任性。

- 认证流程 :
- 用户登录成功后,服务器创建JWT
- 服务器将JWT返回给客户端
- 客户端存储JWT(通常在localStorage或sessionStorage中)
- 客户端在后续请求中通过Authorization头部发送JWT
- 服务器验证JWT的签名和有效期
- 如果有效,服务器处理请求并返回响应
- - 无状态认证 :
- 服务器不需要存储会话信息,JWT本身包含了验证所需的所有信息,从而实现无状态认证
- 每个请求都是独立的,服务器只需验证token的有效性
5. 方案对比:JWT(Java Web Token)也可以解决session共享问题,而且还不需要服务端存储,为什么不用JWT?
- Redis+Token方案在会话管理上更灵活,可以随时使令牌失效,而 JWT 生成后不能主动失效,除非维护一个黑名单,增加了额外的存储负担。
- 此外,使用 Redis + Token 方案的会话续期更为简单,只需要延长 Redis 中的键的过期时间,拦截器也可以自动续期;而 JWT 需要实现刷新令牌机制,增加了实现的复杂性。
- 安全性方面,Redis + Token 令牌存储在客户端,UUID不包含敏感信息,即使泄露也不会暴露用户数据;而JWT 的 Payload 部分只是 Base64 编码,没有加密,信息暴露的风险更高。
- 尽管 JWT 不需要服务端存储会话数据,但每次请求都需要进行加密和解密操作,可能会比 Redis 查询更消耗 CPU,且 JWT 的令牌体积通常较大,增加了网络传输负担。
- 此外,项目中的用户签到功能、黑名单机制和拦截器自动续期等功能都与Redis无缝集成,这导致即使采用JWT,为了这些功能仍然需要维护Redis基础设施,也导致系统架构变得更复杂。
6. 拦截器和过滤器的区别是什么?为什么要用拦截器不使用过滤器?如果同时配置了过滤器和拦截器,哪个先执行,哪个后执行?
拦截器和过滤器的区别
- 过滤器属于Servlet规范,拦截器属于Spring框架
- 过滤器可拦截所有请求(包括静态资源),拦截器只拦截Controller请求
- 过滤器在Servlet前后执行,拦截器在Controller前后执行
- 拦截器可获取Spring上下文和方法参数;过滤器只能获取request和response
- 拦截器有前置、后置、完成三个切点,过滤器只有doFilter一个方法
- 拦截器能够访问 Controller 上下文,可以获取更多的信息,如方法参数和返回值等。它有三个执行点(前、中、后),可以在请求的不同阶段执行不同的逻辑,提供更高的灵活性。
- 拦截器可以通过 注解 或 配置 灵活地拦截特定的 URL 模式,还能设置多个拦截器并控制它们的执行顺序,确保复杂的业务需求得到满足。
- 拦截器可以直接注入 Spring 容器中的 Bean,并且能够利用 Spring AOP 特性,更容易实现复杂的功能和与 Spring 框架的深度集成。
- 过滤器会拦截所有请求,包括静态资源,这可能带来不必要的性能损耗。而拦截器只拦截 Controller 请求,更有针对性,不会影响静态资源请求的性能。
- 拦截器非常适合处理 登录验证、权限控制 等业务逻辑,而项目中像 Token 自动续期 这种功能需要访问 Spring 上下文,更适合使用拦截器来处理。
- 执行顺序:过滤器先执行,然后请求进入 Spring 的 拦截器,处理完控制器方法后,拦截器再执行,最后由过滤器处理响应。
7. 为什么要使用双重拦截器,只用一个拦截器不行吗?这两个拦截器的作用分别是什么?
如果只用一个拦截器,会带来的问题:
- 拦截器要实现登录校验和token刷新功能;
- 登录校验有一部分公共接口不拦截,但这些不拦截的接口就会导致token刷新不生效;
- 如果所有接口都强制登录,用户每次访问都需要进行登录校验,且有一些功能需要用户不登陆也可以访问的。
而 双重拦截器 方案的优势在于:
- 职责明确:RefreshTokenInterceptor负责 Token 续期和用户信息传递, LoginInterceptor只负责登录状态校验,逻辑更加清晰,容易维护。
- 性能提升:LoginInterceptor(排除了大量公共接口),避免了重复的登录校验,提高了系统的吞吐量和响应速度。
- 用户体验优化:通过自动续期 Token,用户无需频繁登录,提升了用户体验。
- 代码简洁:每个拦截器职责单一,代码更简洁,维护和扩展更加容易。
- 第一个拦截器:RefreshTokenInterceptor
全局拦截所有请求( addPathPatterns(“/**”) )
获取并解析Token;查询Redis获取用户信息;将用户信息保存到ThreadLocal;自动刷新Token有效期;无条件放行所有请求;- 第二个拦截器:LoginInterceptor
仅拦截需要登录的接口(排除了大量公共接口)
判断ThreadLocal中是否存在用户;不存在则拦截并返回401状态码;存在则放行请求
8. 什么是threadlocal?什么情况需要用到threadlocal?把用户信息存到Threadlocal中会有什么问题?你怎么解决这个问题?
- ThreadLocal 是 Java 提供的一个类,它使得每个线程都有 独立的变量副本,不同线程之间无法访问对方的副本,避免了并发环境下多个线程共享数据导致的冲突和问题。
- 简单来说,当一个共享变量是共享的,但是需要每个线程互不影响,相互隔离,就可以使用ThreadLocal。
- 跨层传递信息:如果需要在同一个线程中的多层应用间传递数据,而不想显式地传递参数,可以使用 ThreadLocal。
- 线程隔离:存储线程不安全的工具对象,如 SimpleDateFormat,每个线程都有自己独立的副本,避免线程间的共享和冲突。
- 性能优化:避免在每个方法调用中频繁创建、销毁对象。例如,数据库连接、线程池等资源可以通过 ThreadLocal 为每个线程提供独立的副本,避免线程间的共享和重复创建
在黑马点评项目中,ThreadLocal主要用于在用户登录后,将用户信息存储在当前线程中,这样在处理该用户的请求过程中,任何需要用户信息的地方都可以直接从ThreadLocal获取,而不需要重复解析Token或查询数据库。
把用户信息存到ThreadLocal中会有什么问题?
- ThreadLocal是Java中提供的线程本地存储机制,可以利用该机制将数据缓存在某个线程内部,该线程可以在任意时刻、任意方法中获取缓存的数据。ThreadLocal用于操作ThreadLocalMap的工具类,Map的key为ThreadLocal对象,Map的value为需要缓存的值。
- ThreadLocal允许每个线程独立和存储访问数据。
每个线程有自己独立的 ThreadLocalMap,其中 ThreadLocal 对象作为 key,需要缓存的数据作为 value 存储。这样每个线程可以在任意时刻、任意方法中获取和修改它存储的值,而不会受到其他线程影响。- 如果在线程池中使用ThreadLocal会造成内存泄漏,是因为当ThreadLocal对象使用完之后,应该要把设置的key,value,也就是Entry对象进行回收,但线程池中的线程不会回收,而线程对象是通过强引用指向ThreadLocalMap,ThreadLocalMap也是通过强引用指向Entry对象。线程不被回收,Entry对象也就不会被回收,从而出现内存泄漏。
- 由此也导致了线程池环境下的数据混乱,信息泄露,也增加了内存占用。
如何解决这些问题?
- 及时清理 :在使用完ThreadLocal对象后,一定要调用remove()方法清理数据。
在黑马点评项目中,通过拦截器的afterCompletion方法确保请求结束后清理ThreadLocal- 使用try-finally结构 :在使用ThreadLocal的地方,使用try-finally结构确保即使发生异常也能清理ThreadLocal:
9. 你为什么用redis做黑名单,用redis做黑名单有什么优势吗?
分布式环境指的是系统部署在多个服务器上,每个服务器运行着相同的应用程序(服务实例)。服务器A、B、C运行着我们的应用。
为什么需要分布式?
高可用性:如果一台服务器宕机,其他服务器可以继续提供服务
负载均衡:多台服务器可以分担用户请求,提高系统整体处理能力
扩展性:当用户量增加时,可以方便地添加更多服务器
- 高性能考虑:Redis是内存数据库,读写性能极高,黑名单的查询和更新操作都是直接访问Redis,避免了数据库IO;项目使用Redis存储验证码黑名单,响应时间在毫秒级
- 运维便利性:Redis提供了TTL机制,黑名单记录24小时后自动过期,无需手动维护黑名单的清理工作,降低了系统维护成本;
- 分布式支持:适用于分布式环境,它确保了无论用户访问哪个服务器,都能得到一致的黑名单处理。
在我们的项目中:如果用户A在服务器1上频繁请求验证码;服务器1将用户A加入黑名单;用户A立即切换到服务器2;服务器2也能看到用户A在黑名单中;用户A无法通过切换服务器来绕过黑名单限制。
3. 商户缓存
缓存(Cache):一种具备高效读写能力的数据暂存区域
缓存作用:降低后端负载,提高读写效率,降低响应时间。
缓存成本:开发成本,一致性问题。
缓存模型和思路:查询数据库之前先查询缓存,如果缓存数据存在,则直接从缓存中返回,如果缓存数据不存在,再查询数据库,然后将数据存入redis。

代码思路:如果缓存有,则直接返回,如果缓存不存在,则查询数据库,然后存入redis。
3.1 缓存更新策略
缓存更新是redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向redis插入太多数据,此时就可能会导致缓存中的数据过多,所以redis会对部分数据进行更新,或者把他叫为淘汰更合适。







实现商铺和缓存与数据库双写一致
修改ShopController中的业务逻辑,满足下面的需求:
- 根据id查询店铺时,如果缓存未命中,则查询数据库,将数据库结果写入缓存,并设置超时时间。(超时剔除)
- 根据id修改店铺时,先修改数据库,再删除缓存。(主动更新)
3.2 缓存穿透问题及解决思路
缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,不断发起这样的请求,给数据库带来巨大压力。


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

3.4 缓存击穿问题及解决思路
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
常见的解决方案有两种:
- 互斥锁:因为锁能实现互斥性。在缓存失效时,加锁控制只有一个线程能去数据库加载数据,其他线程等待。
一个线程查库,其它等待,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行。- 逻辑过期:缓存中的数据不直接删除,而是加一个“过期时间”字段;用户请求过来即使数据逻辑上过期,也先返回旧数据;后台启动一个线程去异步更新缓存。
容忍短时间数据不一致。




3.5 自测面试题
1.为什么要用redis做一层缓存,相比直接查mysql有什么优势?
用 Redis 做缓存的主要目的是提高性能、减轻数据库压力、提升用户体验。
- 提高性能:
Redis 是内存数据库,数据读写在内存里完成,读写速度非常快,通常是微秒级;
而 MySQL 是磁盘数据库,响应在毫秒级,速度慢很多;
所以 Redis 可以显著提升接口的响应速度,带来“秒开”的用户体验。- 抗并发能力强,减轻数据库压力
在高并发场景下,如果所有请求都打到 MySQL,数据库容易崩溃;
Redis 每秒能处理几十万次请求 远超 MySQL,能有效保护数据库,尤其适合热点数据- 具备多种防护机制,提升系统稳定性
Redis 可以设置缓存过期时间、预加载热点数据、防止缓存击穿、穿透和雪崩等问题;
这些防护手段,MySQL 是做不到的,Redis 可以增强系统的抗风险能力。Redis 是高并发系统中必不可少的一环,让系统又快又稳,是性能优化的重要手段。
2. 如何保证redis和mysql的数据一致性?
常见的两种更新缓存策略是
第一种方式:“先更新数据库再删除缓存”,更适合低并发系统。
A 删除缓存并不是为了“自己重建缓存”,而是为了让后续请求走数据库并刷新缓存。
但由于 B 在 A 删除前把旧数据提前写了进去,A 删除的就是那个旧值,等于白删了。
如果是低并发,这个概率非常小,基本不会遇到上面的问题,因为更新数据库的时间更久,删除缓存的时间短,所以可以使用“先更新数据库,再删缓存”的写法。第二种方式:“延迟双删”,即“先删除缓存,再更新数据库,延迟一段时间后再次删除缓存”,更安全更适合高并发系统
1
2
3
4
5// 示例逻辑
redis.del("user:123"); // 第一次删除缓存
db.updateUser(...); // 更新数据库
Thread.sleep(500); // 延迟
redis.del("user:123"); // 第二次删除缓存,防止并发问题
- 先删缓存,再更新数据库,这时候 另一个线程可能刚好查询了数据库的旧数据并写入缓存,导致缓存又变成了旧数据,数据回滚。而且数据库操作更费时,很可能有其他线程进来。
- 先更新数据库,在更新缓存,会让无效写操作变多,让缓存只在需要的时候去更新。
3. 如何保证缓存与数据库的操作的同时成功或失败?事务相关的八股,很多内容
- 数据库和缓存是两个独立的系统,不能用传统事务直接保证它们的一致性。通常推荐使用“延迟双删”策略,先更新数据库,再删缓存,并延迟一段时间再删一次,防止并发写入旧缓存导致数据不一致。
- 如果业务对一致性要求更高,可以通过将缓存更新逻辑放入消息队列来异步处理,利用消息的可靠性保障最终一致性。对于极端场景也可以考虑分布式事务方案,如Seata或TCC等,但复杂度和性能成本较高,一般业务不推荐使用。
- 把缓存更新逻辑放入消息队列的核心目的是保证在数据库更新成功之后再去更新缓存,避免缓存提前写入旧数据的问题。虽然不能保证强一致,但通过消息的可靠性机制(持久化、重试、幂等处理等),我们可以实现最终一致性,兼顾性能与数据正确性。
4. 缓存穿透、缓存击穿、缓存雪崩,什么是缓存xx?如何解决这个问题?这几个解决方案各自的优势和缺点分别是什么?
5. 缓存三兄弟的其他问法
现在有一个场景,假如有一个key即将过期了,但是此时有100万个请求访问存入这个key的数据,这种情况该怎么办?
这是典型的缓存击穿场景。如果热点 key 快过期,又有大量并发请求,会导致缓存同时失效、数据库瞬间崩溃。
- 我们可以通过加锁控制只有一个线程查询数据库(互斥锁),其他线程等一等或者返回旧数据;
- 也可以用逻辑过期方案,缓存数据附带一个时间戳,过期后异步刷新缓存;
缓存数据结构加上expireTime字段,数据过期后先返回旧数据,然后后台异步刷新缓存- 还可以通过定时预热热点数据等手段提前避免。
实际开发中我们会结合这些方式使用,兼顾性能和实时性。
现在有一个场景,假如有大量的key同时过期,但是这些key的访问频率很高,一瞬间会给数据库造成过大压力,该怎么办?
这个是缓存雪崩的问题,也就是大量热点 key 同时过期,造成请求都打到数据库上。
- 我们通常会在设置缓存时加一个随机值,打散过期时间;
- 另外我们也会定时刷新热点数据,
对访问频率高的 key做定时预加载,比如每 5 分钟自动刷新一次缓存可以通过定时任务(Quartz、xxl-job)来完成- 或者用逻辑过期的方式让用户先拿旧数据再异步更新。
- 严重情况下还可以配合限流和熔断,防止系统被打垮。
现在有一个场景,有大量的请求进来,访问一个并不存在的key,且这个数据也不存在于数据库中,该怎么办?
这个是缓存穿透的问题,也就是用户请求的数据既不在缓存中,也不在数据库中,导致每次都查库,数据库压力很大。常见的做法是:
- 缓存空值,让后续请求直接命中;
- 或者使用布隆过滤器提前拦截非法请求,避免请求落到数据库上。
- 在接口层也可以加参数校验和限流机制,作为补充防护手段。
商户缓存模块总结:这个模块就一个内容,redis做缓存,但是涉及到很多的八股,开背。
4. 优惠券下单
4.1 优惠券添加和下单


全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具。
使用全局ID生成器生成订单号。
- 全局唯一ID生成器:由符号位、时间戳和序列号组成;
64位数字,较短;有序,按时间递增,适合做数据库索引;高性能,适合高并发场景- UUID:128位字符串,较长,占用更多存储空间;无序,随机分布;生成简单,但不保证有序;

1 | |
每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购
tb_voucher:优惠券的基本信息,优惠金额、使用规则等;
tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才填写这些信息


下单

1 | |
4.2 普通优惠券下单流程:乐观锁
库存超卖问题:乐观锁的方式扣库存
假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。






1 | |
普通优惠券不用一人一单
自测题
实现思路:后端收到下单请求后,先查询数据库库存是否足够,然后在扣减库存时将库存大于购买数量作为where语句中的条件,如果库存符合则扣减库存成功,如果库存不符合下单失败,返回库存不足的错误信息。

1. 为什么使用乐观锁?
商城购物场景中大多数的业务其实并不是秒杀限购类物品,而是普通的购物,如果每一个商品的下单过程都使用分布式锁来走业务流程会造成很多的性能开销,为了提高性能,对于普通类商品可以使用乐观锁来走进行下单流程呢,乐观锁是假设线程安全问题不一定非会发生,在实际的业务场景中,普通类商品确实如此,很少会有冲突,符合乐观锁的预设,因此想要考虑对普通商品使用乐观锁来提高性能。
2. 乐观锁可以用于分布式环境吗,在分布式环境下会导致超卖吗?在分布式环境下有什么问题?
- 乐观锁确实可以用在分布式环境,尤其是多个服务节点连接同一个数据库时,它通过版本号或条件判断,避免并发修改时的数据冲突,从而防止超卖。
- 但它也有一些问题:比如它只能在提交数据时判断冲突,不能提前阻止多个节点同时读到相同的库存。在高并发下,多节点可能几乎同时读取并尝试更新相同库存,虽然最终只有一个成功,但前面的逻辑都跑了一遍,可能导致逻辑混乱、资源浪费。
- 更复杂的是,如果是多数据库(分库)架构,每个库之间不共享数据,乐观锁就只能控制本库不超卖,但无法全局控制库存,这就可能发生跨库超卖。
- 所以在高并发和分布式场景下,通常不会单独使用乐观锁,而是配合一些控制手段,比如:
分布式锁:在流程开始前就加锁,避免多个请求并发处理;
消息队列:将请求排队处理,削峰填谷;
Redis Lua脚本:把“库存校验 + 扣减 + 下单”打包成一步执行,确保原子性。
这些手段配合乐观锁,能更好地保证数据一致性和系统稳定性。
数据库实例只有一个:你的整个项目,无论有多少个服务、多少个Tomcat、多少个线程,都连接到同一个数据库上。
如果是多个数据库,就算每个库自己用了乐观锁,也不能保证总库存不超卖,因为每个库只管自己的数据,不知道其他库的情况。
3. 乐观锁可以优化掉版本号吗?
- 可以,乐观锁不一定非要用“版本号”字段,也可以用其他字段来实现类似的效果。
- 就像我们项目中,为了不增加表字段,我们直接用库存字段本身来做乐观锁控制。具体做法是:先查询库存是否充足;然后在更新时加上条件:
stock > 0,也就是只有当库存大于0时才会执行扣减操作。- 这相当于把“版本号控制”内嵌在库存字段的判断中。虽然没有单独的
version*字段,但原理类似——只要数据*不满足预期,就更新失败,从而避免并发问题。
4. 为什么要优化掉版本号,有什么优势吗?
- 其实优化掉版本号主要是为了简化数据库设计。因为在实际项目中,不太可能为了代码逻辑,特意给用户表或业务表加一个“版本号”字段,这样会让数据库结构变得复杂、维护成本高。
- 而像“库存”这种字段,本身就具备版本控制的特性——每次更新都会变化,我们完全可以直接用它作为版本判断的依据。比如在更新库存时加一个条件:
stock > 0,这其实就起到了乐观锁的作用。- 这种方式好处是:不需要改数据库结构,还能充分利用已有字段,如果以后业务逻辑需要调整,也不用大动数据库,非常灵活。
5. 这里的条件判断为什么要使用stock >= buyNumber,而不是stock = 查询到的库存呢?
1 | |
- 主要是出于性能优化的考虑。
- 举个例子:假设库存充足是10,现在有10个用户并发下单。如果用
stock = 查询到的库存作为判断条件,那只有一个请求能成功,其他 9 个失败后还要重新查一次库存,再尝试一次。这样一来,查询就会反复进行,形成多轮查询 和 更新,最终会造成 查询次数是 O(n²) 级别,非常消耗数据库资源。- 而用
stock >= buyNumber的判断条件,多个并发可以同时进入扣减逻辑,只要满足库存条件就能更新成功,每个请求最多只查一次、更新一次,不会反复重试,系统整体效率更高。
4.3 限购优惠券下单流程:悲观锁
需求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单
但是目前的情况是,一个人可以无限制的抢这个优惠卷,所以我们应当增加一层逻辑,让一个用户只能下一个单
具体操作逻辑如下:比如时间是否充足,如果时间充足,则进一步判断库存是否足够,然后再根据优惠卷id和用户id查询是否已经下过这个订单,如果下过这个订单,则不再下单,否则进行下单。
增加一人一单逻辑

这段代码处理的是秒杀下单逻辑,特点是:
- 并发特别高(很多人同时抢);
- 库存很少(先到先得);
- 每人只能抢一次(要限制一人一单);
- 下单过程涉及多个步骤(检查库存、校验、扣库存、写订单);
VoucherOrderServiceImpl
1 | |
没有加锁,并发下多个线程会同时读到 count == 0,同时执行插入逻辑,导致:用户多次下单(违反一人一单);库存被超扣
虽然用了.gt(“stock”,0)乐观锁去 扣减库存(这一小段是安全的),但是整段下单流程并不是原子性的,所以会出现下面两种经典问题:
假设两个线程同时过来抢同一张券:都查了一下数据库,发现这个用户还没下过单;然后两个线程几乎同时执行“创建订单”的代码;最终这个人就下了两单。这就违反了一人只能抢一单的规则。
1
2
3
4
5
6// 为什么会这样?因为你是:
// 第一步:判断有没有下过单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 第二步:再去下单
save(voucherOrder);
// 这两个步骤之间有时间差!线程 A 和 B 都觉得自己可以下单,所以都成功了。库存超卖:乐观锁只保护了库存扣减那一行;
乐观锁只能保证某一行操作的原子性,不能保证整个业务流程的一致性。所以,如果不加悲观锁保护整个下单流程,就可能出现重复下单、超卖等问题,特别是在高并发秒杀场景下。
如果想彻底保证一人一单、库存准确,就需要在整个下单逻辑前加悲观锁来保护整体流程。
为了彻底保证原子性,你可以考虑在整个下单逻辑前加一把“悲观锁”:
- 用 Java 的 synchronized(适用于单体项目)
- 用 Redis 分布式锁(适用于集群)
- 或者用数据库的 SELECT … FOR UPDATE(适用于单库事务)
更新操作通常使用乐观锁,是因为更新的是已有的数据,通过版本号或条件判断来控制并发,失败了可以重试,性能更好。而插入操作一旦成功就不可撤销,比如避免重复插入或唯一索引冲突,因此需要使用悲观锁,先锁定资源,防止并发写入,确保数据一致性和安全性
加入悲观锁避免并发问题



锁粒度细化







先提交事务再释放锁

让事务生效
上面这个调用不是通过 Spring 的代理对象完成的 → 所以事务不会生效(Spring 的事务是基于 AOP 动态代理实现的)。

这里的核心是用 AopContext.currentProxy() 获取当前对象的代理类,从而让 @Transactional 能生效。这就解决了事务失效的问题。
- 为防止用户并发下单导致重复下单或库存超卖,通过 synchronized(userId.toString().intern()) 控制每个用户的并发。
- 同时为了保证订单创建的事务一致性,我们使用 Spring 的 AOP 代理方式,通过 AopContext.currentProxy() 获取代理对象再调用带有 @Transactional 的方法,从而避免了事务失效的问题。
- 这样我们既控制了锁粒度,又保障了数据的一致性,是一种轻量但高效的并发解决方案。
4.4 限购优惠券下单:集群环境下的并发问题:分布式锁解决
- 由于现在我们部署了多个tomcat,每个tomcat都有一个属于自己的jvm,
- 那么假设在服务器A的tomcat内部,有两个线程,这两个线程由于使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的,
- 但是如果现在是服务器B的tomcat内部,又有两个线程,但是他们的锁对象写的虽然和服务器A一样,但是锁对象却不是同一个,虽然线程3和线程4可以实现互斥,但是却无法和线程1和线程2实现互斥,
- 这就是 集群环境下,syn锁失效的原因,在这种情况下,我们就需要使用分布式锁来解决这个问题。
每个JVM都有自己独立的内存空间,即使代码中定义的锁对象看起来一样(例如都是对某个类的实例加锁),在不同的JVM中,他们是不同的对象。就是说,节点A的JVM中的锁和节点B的JVM中的锁,在内存中是相互独立的。




1. 实现1:利用 Redis 的 String 类型实现分布式锁

- 加锁逻辑
定义一个类SimpleRedisLock实现ILock接口,利用Redis实现分布式锁功能。


- 释放锁

- 修改业务



解决Redis分布式锁误删情况
- 持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,
- 这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是误删别人锁的情况说明

解决方案:
- 解决方案就是在每个线程释放锁的时候,去判断一下当前这把锁是否属于自己,如果属于自己,则不进行锁的删除,
- 假设还是上边的情况,线程1卡顿,锁自动释放,线程2进入到锁的内部执行逻辑,此时线程1反应过来,然后删除锁,但是线程1,一看当前这把锁不是属于自己,于是不进行删除锁逻辑,
- 当线程2走到删除锁逻辑时,如果没有超过自动释放锁的时间点,则判断当前这把锁是属于自己的,于是删除这把锁。



添加锁



释放锁

Lua脚本解决分布式锁的原子性问题
- 线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中,比如他已经拿到了当前这把锁确实是属于他自己的,正准备删除锁,但是此时他的锁到期了,
- 那么此时线程2进来,但是线程1他会接着往后执行,当他卡顿结束后,他直接就会执行删除锁那行代码,相当于条件判断并没有起到作用,这就是删锁时的原子性问题,
- 之所以有这个问题,是因为线程1的拿锁,比锁,删锁,实际上并不是原子性的,我们要防止刚才的情况发生。

Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言。
这里重点介绍Redis提供的调用函数,语法如下:
1 | |
例如,我们要先执行set name Rose,再执行get name,则脚本如下:
1 | |



最终我们操作redis的拿锁比锁删锁的lua脚本就会变成这样
1 | |
利用Java代码调用Lua脚本改造分布式锁
脚本执行是原子的,Redis 保证脚本在执行期间不会被其他命令打断,确保原子性。
lua脚本本身并不需要大家花费太多时间去研究,只需要知道如何调用,大致是什么意思即可,所以在笔记中并不会详细的去解释这些lua表达式的含义。
我们的RedisTemplate中,可以利用execute方法去执行lua脚本,参数对应关系就如下图股

1 | |
保证 Redis 解锁操作的“判断+删除”是原子操作,防止竞争条件和误删别人锁的问题。
| 代码部分 | 作用 |
|---|---|
| DefaultRedisScript | 加载 Lua 脚本对象 |
| unlock.lua 脚本 | 原子判断 + 删除 |
| stringRedisTemplate.execute(…) | 实际调用 Redis 脚本执行 |
| 参数 KEYS / ARGV | 分别是要操作的 key 和校验用的 value |
一路走来,利用添加过期时间,防止死锁问题的发生,
但是有了过期时间之后,可能出现误删别人锁的问题,
这个问题我们开始是利用删之前 通过拿锁,比锁,删锁这个逻辑来解决的,也就是删之前判断一下当前这把锁是否是属于自己的,
但是现在还有原子性问题,也就是我们没法保证拿锁比锁删锁是一个原子性的动作,最后通过lua表达式来解决这个问题
但是目前还剩下一个问题锁不住,我们可以给他续期一下,比如续个30s,是不是后边的问题都不会发生了,那么续期问题怎么解决呢,可以依赖于我们接下来要学习redission。

- 大多数自定义实现的分布式锁,底层确实就是基于 Redis 的 SETNX 命令(或它的升级版 SET key value NX EX 10)来实现的。
- 不过只用 SETNX 是远远不够的,因为它只能解决“最基本的互斥”,还存在一堆问题(不可重入、不可重试、超时释放、自动续期、主从一致性等),所以“真正的分布式锁”要做更多工作。

2. 实现2:利用redisson实现分布式锁
SETNX是 Redis 的一个命令,意思是:如果这个键(key)不存在,就设置它。如果已经存在,就啥也不干,返回失败。
你只是靠 “有没有这个 key” 来判断锁的状态,这会带来很多问题!


redission快速入门


引入依赖
1 | |
配置Redisson客户端
1 | |
如何使用Redission的分布式锁
1 | |
在 VoucherOrderServiceImpl
注入RedissonClient
1 | |
redission可重入锁原理



redission锁重试和WatchDog机制


可重试机制原理
当线程尝试获取锁失败时,Redisson 不会让线程无休止地尝试,而是采用一种智能的重试策略,结合 Redis 的发布 - 订阅机制,在一定时间内等待锁释放的信号,然后再次尝试获取锁
- 获取锁尝试:线程调用 tryLock(waitTime, leaseTime, unit) 方法尝试获取锁,Redisson 会通过 Lua 脚本向 Redis 发起加锁请求,该脚本会检查锁是否已经被其他线程持有。
如果锁未被持有,线程成功获取锁;
如果锁已被持有,脚本会返回锁的剩余过期时间。- 进入等待状态:如果获取锁失败,线程会根据传入的等待时间参数,进入等待状态。
在等待期间,线程会订阅 Redis 上的一个特定频道,该频道用于接收锁释放的消息。- 监听锁释放信号:当持有锁的线程释放锁时,Redisson 会向该频道发布一条消息。
等待的线程会接收到这个消息,意味着锁已经被释放。- 再次尝试获取锁:接收到锁释放消息后,等待的线程会再次尝试获取锁。
如果在等待时间内成功获取到锁,则继续执行后续业务逻辑;
如果等待时间结束仍未获取到锁,则返回获取锁失败的结果。
看门狗机制
为了解决“锁因过期被提前释放”而导致数据不一致的问题,Redisson 引入了 Watch Dog(看门狗)机制。
- 当线程使用
lock()方法获取锁时,如果没有指定锁的过期时间,Redisson 会默认将锁的有效期设置为 30 秒。
与此同时,它会自动启动一个后台线程——看门狗线程,每隔 10 秒检查一次这把锁是否仍然由当前线程持有。
如果检查到锁还没释放,它就会通过 Redis 执行 Lua 脚本,自动将锁的过期时间重新设置为 30 秒,从而实现“自动续期”,确保锁在业务逻辑执行完之前不会过期。- 如果线程在业务执行过程中显式调用了
unlock()方法释放锁,或者线程因异常退出,Redisson 会立即关闭这个看门狗线程,停止对锁的续期操作。- 这个机制可以动态维持锁的有效性,避免提前过期的风险,同时又不会让锁无限存在,防止死锁。

redission锁的MutiLock原理

为了解决这个问题,redission提出来了MutiLock锁,使用这把锁咱们就不使用主从了,每个节点的地位都是一样的, 这把锁加锁的逻辑需要写入到每一个主丛节点上,只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获得锁的时候,只要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性。


自测题
1. 利用redis的string数据结构实现分布式锁
主要思路为,
- 在Redis中存放一个锁,key中包含优惠券id,value是当前线程标识的string数据(如 UUID + Thread ID),用于标识锁的持有者。在下单之前必须要获取这个锁才可以操作数据库,否则必须等待。
- 在实现分布式锁时要注意加锁和释放锁的原子性。
- ①使用 Redis 的
SET key value NX EX命令,该命令可以在设置锁的同时指定过期时间,从而保证“加锁”和“防止死锁”的原子性。- ②在释放锁时使用Lua脚本保证拿锁、比锁、删锁流程具有原子性,防止误删其他线程的锁。
2. 利用redisson实现分布式锁
思路与上述基本一致,但是以上手动实现的锁不具有可重入和自动续期的功能。
- 可重入锁:使用hash结构来存储锁,其中hash结构的key表示这把锁是否存在,field表示当前这把锁被哪个线程持有,value表示重入次数。
- 可重试锁:当线程尝试获取锁失败时,Redisson 不会让线程无休止地尝试,而是采用一种智能的重试策略,结合 Redis 的发布 - 订阅机制,在一定时间内等待锁释放的信号,然后再次尝试获取锁
- 自动续期:watchdog机制:再开启一个线程去检查业务是否完成,如果未完成,每过10s就进行一次续约,续约时间为30s。
- 主从一致性
:MultiLock来确保“所有地方都加锁成功。
4.5 秒杀优惠券下单流程:redis预热 + Lua脚本
之前优惠券秒杀业务是同步下单 串行操作,现在优化变 异步下单。


分离成两部分,
- 主线程判断是否有购买****资格(抢单流程),查数据库库存和用户ID费时,在Redis中判断
- 另一独立线程是下单流程,减库存和创建订单是对MySQL的写操作,费时。
- 两个独立的线程,程序怎么知道给谁创建订单?把id存储到队列中,独立线程从队列中读取。
- Redis判断完后返回订单Id,用户拿到订单id付款抢单成功。虽订单未真正创建,但可以确保独立线程能创建订单。
具体流程为,用户发起秒杀请求后,后端服务器直接提交给redis,redis根据缓存中的订单和商品信息判断购买资格,然后直接返回结果给后端服务器,如果购买成功,需要在redis中进行库存的预减,最后将下单是否成功的信息返回给服务器,服务器拿到信息后将下单业务放入Kafka消息队列后,将订单号返回给用户。而真正创建订单的是消息队列的消费者。
如果kafka挂了怎么办?
可以做一个兜底方案,在用户下单成功之后,将订单信息保存到服务器的缓存中。如果kafka挂了,先保证服务器缓存中保存的订单号信息已经全部写入数据库中,然后将Kafka相关的业务流程停掉,相当于创建订单过程不再是异步操作。
优点:减少数据库查询次数,提高了性能;使用消息队列,解耦业务功能
缺点:缓存商品信息,增加了redis的内存占用,实现更加复杂,成本更高
异步下单时候,并发量过大,消息队列消息堆积怎么办?
- 生产速度大于消费速度,这样可以适当增加分区,增加consumer数量,提升消费TPS;
- consumer消费性能低,查一下是否有很重的消费逻辑(比如拿到消息后写HDFS或HBASE这种逻辑就挺重的),看看是否可以优化consumer TPS;
- 确保consumer端没有因为异常而导致消费阻塞;
- 如果使用的是消费者组,确保没有频繁地发生rebalance。



