黑马点评项目总结
黑马点评项目总结
一、项目概述
黑马点评项目整体是前后端分离框架,前端采用 Vue 实现,后端基于 SpringBoot 构建单体服务,接入 Nginx 做反向代理、负载均衡与动静分离,MySQL 用来做数据持久化存储,Redis 作为核心组件,用于实现缓存、分布式锁、消息队列、高频数据存储等功能,核心解决本地生活场景下高并发访问、秒杀超卖、缓存异常等业务痛点。
二、核心业务流程
用户通过手机号验证码完成登录后,可实现附近商户查询、限时优惠券秒杀、探店笔记发布与点赞、博主关注与共同关注查看、每次签到与连续签到统计等核心功能,后端通过 Redis 的多种数据结构对高并发接口做全链路优化。保证服务在大流量场景下的可用性。
三、技术亮点与难点
3.1 Nginx 反向代理与负载均衡
Nginx 中的三层核心配置
- 第一是反向代理,将前端发起的
/api开头的后端请求转发到下游的 Tomcat 服务节点。 - 第二是负载均衡,配备了两个 Tomcat 服务节点,采用默认的轮询策略,将请求均匀分发到两个节点上,实现流量打散。
- 第三是动静分离,将前端的 Vue 打包文件、图片、JS/CSS 静态资源直接由 Nginx 托管,同时配置了本地缓存,不用转发到 Tomcat ,大幅降低后端压力。
做负载均衡的原因
- 分摊服务器压力,提升项目的整体并发承载能力。
- 做服务容灾,当其中一台 Tomcat 服务宕机,另一台还能正常承接用户请求,提升服务的整体可用性。
Nginx 的负载均衡策略
- 轮询(Round Robin):默认策略,依次将请求分发到每个服务器节点。
- 加权轮询(Weighted Round Robin):根据服务器节点的性能分配权重,性能更好的节点分配更多请求。
- IP 哈希(IP Hash):根据客户端IP地址进行哈希计算,将同一IP的请求分发到同一服务器节点,适用于需要会话保持的场景。
3.2 短信登录
基于 Session 实现登录

集群的 Session 共享问题
- Session 共享问题:多态Tomcat服务器不共享Session存储空间,当请求切换到不同tomcat服务器时会导致数据丢失。
- 解决方案:使用Redis来存储Session数据。
基于Redis实现共享Session登录
-
之前使用Session时由于每个请求都有独立的Session空间,所以保存验证码时key使用
code字段即可,但在使用Redis存储Session时,key需要包含用户的唯一标识以区分不同用户的Session。 -
用户登录成功后,使用UUID生成一个随机
token作为key值存放到Redis中,value为用户信息,同时设置30分钟的过期时间。

-
还有没有其他的解决方法:
- 基于Cookie的Token机制,不再使用服务端保存Session,而是通过客户端保存Token(如JWT)。
- Token中包含用户的认证信息(如用户ID、权限等),并通过签名验证其完整性和真实性。
- 每次请求,客户端将Token放在Cookie或HTTP头中发送到服务
双层拦截器设计解决登录状态刷新问题
- 设计第一个拦截器的原因:当Session请求变多时,每一个Session都要单独进行校验,浪费资源,所以使用拦截器进行校验。
- 单拦截器存在的问题:只有访问拦截器需要拦截的接口时才会刷新token有效期,如果访问其他窗口则不会刷新,导致用户登录失效。
- 解决方法:设置双拦截器
- 第一个拦截器:拦截一切路径,获取token并去查询用户信息,查到了就存入ThreadLocal的UserHolder中,同时刷新token有效期。不论是否查到用户,都放行。
- 第二个拦截器:只拦截需要登录状态的接口,判断ThreadLocal中的用户是否存在。

拦截器+ThreadLocal做登录校验和权限刷新
- 拦截器执行流程,拦截器按
order优先级执行:- 第一层
RefreshTokenInterceptor:在所有Controller方法执行前,先执行preHandle方法:从请求header里获取token,去Redis查询用户信息,查到了就存入ThreadLocal,同时刷新token有效期,始终返回true放行;请求完全结束后,会执行afterCompletion方法,移除ThreadLocal里的用户信息。 - 第二层
LoginInterceptor,在RefreshTokenInterceptor之后执行,preHandle方法里只做一件事:判断ThreadLocal里是否有用户信息,没有就返回401状态码拦截请求,有就返回true放行,执行后续的Controller方法。
- 第一层
- SpringMVC拦截器完整的执行链路:preHandle(请求前) -> Controller方法 -> postHandle(请求后) -> afterCompletion(请求完全结束后)。
ThreadLocal的作用:ThreadLocal为每个线程提供独立的变量副本,保证在多线程环境下数据的隔离性和安全性。在本项目中,ThreadLocal用于存储当前请求的用户信息,确保每个请求线程都能独立访问和修改自己的用户数据,避免了多线程环境下的数据冲突和安全问题。- 注意:
ThreadLocal必须在请求完全结束后使用remove方法手动清除,否则可能会导致内存泄漏问题。
3.3 商户查询缓存
标准的查询缓存流程

缓存相关问题
- 缓存穿透
- 缓存击穿
- 缓存雪崩
解决缓存穿透问题
- 缓存穿透问题是指客户端请求的数据在缓存和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库
- 解决方案:
- 缓存空值:对数据库查询为空的结果,作缓存空值处理,设置2分钟的短过期时间,防止同一个无效ID重复打库。
- 布隆过滤器:布隆过滤器底层使用位数组+多个布隆hash函数,可以判断一个元素是否存在于集合中。
- 如果布隆过滤器判断元素不存在,则一定不存在,直接返回错误响应。
- 如果布隆过滤器判断元素存在,则可能存在,需要继续查询数据库验证。

解决缓存击穿问题
- 缓存击穿问题是指某个热点key在缓存中突然过期,短时间内大量请求同时打到数据库,造成数据库压力过大。
- 解决方案:
- Redis互斥锁:对普通热点key,当缓存未命中时,只有拿到锁的一个线程能去查库重建缓存,其他线程自旋等待重试。
- 逻辑过期:对超高并发的秒杀热点key,缓存中不设置物理过期时间,而是在value中存一个逻辑过期时间,用户请求时发现数据逻辑过期,就获取锁开启独立线程异步重建缓存,当前线程直接返回旧的缓存数据,用户请求永远只走缓存。
- 互斥锁实现简单,能保证数据强一致性,适合对数据一致性要求高的普通业务场景;逻辑过期实现复杂,性能高,适合秒杀这种对服务可用性要求高于一致性的高并发业务。
解决缓存雪崩问题
- 缓存雪崩问题是指大量key在同一时间过期,或者Redis服务宕机,导致大量请求同时打到数据库,造成数据库压力过大。
- 解决方案:
- 随机过期时间:给缓存key设置一个随机的过期时间,避免大量key在同一时间过期。
- 双层缓存:在Redis前面再加一层本地缓存,先查询本地缓存,命中则直接返回,未命中再查询Redis,减少对Redis的压力。
- Redis集群:使用Redis集群部署,提升Redis的整体可用性和抗压能力。
3.4 优惠券秒杀
优惠券秒杀下单流程

秒杀中的问题
- 超卖问题
- 一人一单的线程安全问题
全局唯一ID
用户抢购时会生成订单并保存到tb_voucher_order表中,而订单表如果使用数据库自增id就会存在一些问题:
- id规律太明显:用户或者商业对手容易根据id猜测出一些敏感信息
- 受单表数据量限制:当订单表数据量过大时,可能会进行分库分表,这时就需要保证id的唯一性
全局唯一ID生成策略
- UUID
- Redis自增
- Snowflake算法(雪花算法):1位符号位 + 41位时间戳 + 10位机器id + 12位序列号
- 数据库自增
- 项目中使用:1位符号位 + 31位时间戳 + 32位序列号
乐观锁解决超卖问题
超卖问题
在高并发请求下,多个用户同时抢购同一商品可能会导致库存不足的情况。简单来说就是:判断库存是否充足操作和扣减库存操作不是原子性的,可能会出现多个线程同时判断库存充足并扣减库存,最终导致超卖。
常见解决方法
- 悲观锁:如
synchronized、lock - 乐观锁:如版本号法、CAS操作
CAS(Compare and Swap)算法介绍
CAS操作是一个原子操作,可以保证线程安全。它包含三个参数:内存地址V、旧的预期值A、新值B。CAS执行过程如下:
- 比较:比较地址V中的值与预期值A是否相等
- 判断:相等则说明没有发生其他线程的修改
- 交换:将地址V中的值更新为新值B
- CAS问题:
- ABA问题:如果一个线程在执行CAS操作时,另一个线程修改了值又改回原值,CAS操作会误以为没有发生修改,导致数据不一致。解决方法:引入版本号或标记位等机制。
- 自旋问题:当CAS操作失败时,线程会不断重试,可能会导致CPU资源浪费。解决方法:设置重试次数或使用适当的等待策略。
- 只能保证一个变量的原子操作:CAS只能保证单个变量的原子操作,如果需要对多个变量进行原子操作,可能需要使用其他同步机制,如锁。
项目实现
在扣减库存时,在update语句的where条件中加了stock>0的判断,也就是.setSql(“stock = stock - 1”).eq(“voucher_id”, voucherId).gt(“stock”, 0),只有库存大于0时,扣减才会生效,本质是无锁化的CAS思想,对比库存是否满足条件,满足才执行更新。
- 优点:实现简单,无锁的开销,没有死锁风险。
- 缺点:高并发下冲突率极高,大量请求扣减失败,秒杀成功率极低,而且所有请求都直接打到数据库,会给数据库造成极大的压力,只能用于低并发场景。
分布式锁解决一人一单问题
一人一单问题
- 在高并发请求下,可能会出现同一个用户同时发起多个抢购请求,导致生成多条订单记录。
- 解决方法:乐观锁比较适合更新操作,而现在是插入操作,不方便实用乐观锁,所以使用悲观锁。
项目中使用
- 最开始使用synchronized锁,但是发现加锁只能解决单机环境下的一人一单安全问题,集群模式下就不行了。
- 原因:synchronized是jvm层面的锁,而我们部署了多个tomcat服务器,每个tomcat都有一个属于自己的jvm,所以在不同的tomcat内部锁的对象不一样,导致synchronized锁失效。
- 解决方案:使用分布式锁。
分布式锁解决一人一单问题
- 首先使用Redis的
set nx实现分布式锁:获取锁 -> 互斥访问,释放锁 -> 手动释放或超时释放。- 存在的问题:锁误删问题:当线程1阻塞时会释放锁,这时线程2获取锁成功,线程1恢复后继续执行删除任务,导致线程2的锁被误删
- 解决方法:给锁添加标识,只有持有锁的线程才能释放锁
- 在获取锁时存入线程标识
- 在释放锁时先获取锁中的线程标识,判断是否与当前线程标识一致,一致才执行删除操作
- 依旧存在的问题:判断锁归属和删除锁不是原子操作,仍然可能存在误删问题
- 解决方法:Lua脚本解决锁误删问题
- 使用Lua脚本,将判断锁是否属于当前线程 + 删除锁操作写在一个脚本里,彻底解决原子性问题。
- 基于
set nx实现的分布式锁存在的问题:- 不可重入:同一个线程无法多次获取同一把锁
- 不可重试:获取锁只尝试一次就返回false,没有重试机制
- 超时释放:锁超时释放虽然可以避免死锁,但如果是业务执行耗时较长,也会导致锁释放,存在安全隐患
- 主从一致性:如果Redis提供了主从集群,主从同步存在延迟,当主宕机时,如果从并同步主中的锁数据,则会出现锁实现
- 解决方法:使用Redission分布式锁。
- 基于
- 引入Redission分布式锁,解决锁的可重入、自动续期、重试等复杂问题,完美适配秒杀场景的并发需求。
整体流程梳理
synchronized锁存在集群问题 -> 基于Redis的set nx实现分布式锁 -> 利用线程标识解决所误删问题 -> 使用Lua脚本实现原子性操作彻底解决所误删问题 -> 引入Redission分布式锁解决锁的可重入、重试、自动续期等问题。
3.5 秒杀优化
秒杀优化整体流程
- 原流程存在的问题:查询优惠卷、查询订单、减库存以及创建订单这四个步骤都需要访问数据库,所以耗时比较高,而我们又是串行执行的这几个步骤,所以在高并发情况下效果会比较差
- 解决方法:将耗时比较短的逻辑判断放入到redis中,比如是否库存足够,比如是否一人一单,这样的操作,只要这种逻辑可以完成,就意味着我们是一定可以下单完成的,我们只需要进行快速的逻辑判断,然后将真正的下单操作放到异步线程中去执行即可


Redis Stream消息队列
消息队列模型包含三个角色
- 消息队列:存储和管理消息,也称消息代理
- 生产者:发送消息到消息队列
- 消费者:从消息队列接收和处理消息
Redis提供了三种不同的方式实现消息队列
- list结构:基于List结构模拟消息队列
- PubSub:基本的点对点消息模型
- Stream:比较完善的消息队列模型
- 详见Redis的三种消息队列实现方式
为什么选择使用Stream
- List结构:只能用LPUSH+BRPOP实现简单的消息队列,优点是实现简单,支持阻塞读取,但是消息消费完就会被删除,不支持消息回溯、不支持消费者组,消费者宕机就会丢失消息,也无法实现多消费者负载均衡。
- PubSub发布订阅模式:支持多生产多消费,但是消息是发布即忘的,不支持持久化,消费者离线就会丢失所有消息,也没有消息确认机制,消息丢失风险极高,完全不适合秒杀订单这种不能丢的场景。
- Stream结构:是Redis 5.0引入的专门的消息队列结构,支持消息持久化、消息回溯、消费者组模式,自带ACK消息确认机制和pending-list,消费者处理完消息后执行XACK确认,未确认的消息会进入pending-list,故障恢复后可以重新处理,保证消息至少被消费一次,不会丢失,同时支持多消费者争抢消息,实现负载均衡,完美匹配秒杀订单的场景需求。
| List | PubSub | Stream | |
|---|---|---|---|
| 消息持久化 | 支持 | 不支持 | 支持 |
| 阻塞队列 | 支持 | 支持 | 支持 |
| 消息堆积处理 | 受限于内存空间,可以利用多消费者加快处理 | 受限于消费者缓冲区 | 受限于队列长度,可以利用消费者组提高消费速度,减少堆积 |
| 消息确认机制 | 不支持 | 不支持 | 支持 |
| 消息回溯 | 不支持 | 不支持 | 支持 |
3.6 点赞与点赞排行
ZSet实现点赞功能
ZSet设计
- key:
blog:liked:{笔记ID} - value:点赞用户ID
- score:点赞时间戳。
- 用时间戳作为score既能保证用户ID的唯一性,又能记录点赞的先后顺序,实现按点赞时间排序的排行榜。
需求
- 同一个用户只能点赞一次,再次点击则取消点赞
- 如果当前用于已经点赞,则点赞按钮高亮显示
实现
- 点赞时,使用
ZADD key 时间戳 用户ID命令,把用户ID加入ZSet,同时给数据库中的笔记点赞数+1 - 取消点赞时,使用
ZREM key 用户ID命令,把用户ID从ZSet中移除,同时给数据库中的笔记点赞数-1 - 判断用户是否已经点赞,使用
ZSCORE key 用户ID命令,如果返回非null则说明已经点赞,返回null则说明未点赞
排行榜实现
- 需求:展示给笔记点赞的前5位用户,形成点赞排行
- 实现:使用
ZRANGE key 0 4命令,获取ZSet中score最高的前5个用户ID,再根据ID列表查询用户信息,用ORDER BY FIELD(id,...)保持原有顺序展示在前端。
3.7 关注与共同关注
Set实现关注功能
Set设计
- key:
follows:{用户ID} - value:被关注用户ID
- Set集合还自带去重能力,关注/取关用
SADD/SREM命令,都是O(1)时间复杂度,操作性能极高,完全适配关注场景的高频操作
共同关注
- 使用
SINTER follows:{当前用户ID} follows:{目标用户ID}命令,获取两个用户的关注集合的交集,即共同关注的用户ID列表。
3.8 附近商铺查询
Redis GEO实现附近商户查询
Redis GEO是Redis 3.2版本引入的地理空间数据结构,专门用来存储和查询经纬度坐标数据,我在项目里的具体实现是:
- 数据预热:项目启动时,把数据库里的所有商户,按商户类型分组,同类型的商户用GEOADD命令,把商户ID、经度、纬度存入同一个GEO集合,Key设计为shop:geo:{类型ID}
- 附近商户查询:用户查询指定类型的附近商户时,获取用户当前的经纬度,用GEOSEARCH命令,以用户坐标为圆心,指定5公里为半径,查询范围内的商户ID,同时返回商户和用户的距离
- 结果处理:拿到商户ID列表后,根据ID查询商户详情,把距离设置到商户信息里,按距离排序后返回给前端
Redis GEO底层原理
- Redis GEO的底层是用
SortedSet实现的,核心采用Geohash算法,把经度和纬度的二维坐标,通过区间二分编码,转换成一个一维的52位整数字符串,然后把这个字符串作为SortedSet的score值。这样就能通过score的范围查询,找到地理空间上距离相近的元素,也就是附近的商户,同时还能通过Geohash编码计算两个坐标之间的距离。
3.9 用户签到与UV统计
Bitmap实现用户签到
把每一个bit位对应当月的每一天,形成了映射关系。用0和1标示业务状态,这种思路就称为位图(BitMap)。
我们按月来统计用户签到信息,签到记录为1,未签到则记录为0

HyperLogLog实现UV统计
UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。相关算法原理大家可以参考:HyperLogLog 算法的原理讲解以及 Redis 是如何应用它的
Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb,内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。
