Redis实战学习笔记
Redis 实战学习笔记:缓存 · 秒杀 · 分布式锁 · 消息队列(纯享版)
本文是一份以「商户点评 / 电商秒杀类业务」为背景的 Redis 实战学习笔记整理稿,内容聚焦 Redis 技术本身:缓存设计与一致性、缓存三大问题(穿透/击穿/雪崩)、高并发秒杀、分布式锁(原生 + Redisson)、消息队列(List / PubSub / Stream)以及生产环境最佳实践。
文中示例代码为 Spring Boot + MyBatis-Plus 环境,包名统一使用com.example,与具体项目无关,可直接迁移到自己的项目中复用。
笔记内容整理自公开网络学习资料,版权归原作者所有,此处仅供学习交流使用。
目录
- 第一部分 基于 Redis 实现短信登录(会话共享)
- 第二部分 缓存应用与缓存问题治理
- 第三部分 优惠券秒杀场景:高并发与并发控制
- 第四部分 分布式锁
- 第五部分 Redisson 分布式锁
- 第六部分 秒杀性能优化(异步化)
- 第七部分 Redis 消息队列
- 第八部分 Redis 最佳实践
- 第九部分 Redis 面试题整理
第一部分 基于 Redis 实现短信登录(会话共享)
1.1 基于 Session 实现登录流程
传统的单体 Web 应用使用 Session 保存登录态,整体流程如下:
- 发送短信验证码:校验手机号 → 生成验证码 → 保存到 Session → 发送短信。
- 短信验证码登录/注册:校验手机号与验证码 → 根据手机号查用户 → 不存在则创建新用户 → 保存用户信息到 Session。
- 校验登录状态:请求携带 Cookie → 从 Session 获取用户 → 判断用户是否存在 → 存在则保存到 ThreadLocal 并放行,不存在则拦截。
1.2 实现发送短信验证码功能
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
// 4.保存验证码到 session
session.setAttribute("code", code);
// 5.发送验证码
log.debug("发送短信验证码成功,验证码:{}", code);
// 返回ok
return Result.ok();
}
登录逻辑:
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误!");
}
// 2.校验验证码
Object cacheCode = session.getAttribute("code");
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.toString().equals(code)) {
return Result.fail("验证码错误");
}
// 3.根据手机号查询用户
User user = query().eq("phone", phone).one();
// 4.判断用户是否存在,不存在则创建
if (user == null) {
user = createUserWithPhone(phone);
}
// 5.保存用户信息到 session 中
session.setAttribute("user", user);
return Result.ok();
}
1.3 实现登录拦截功能
温馨小贴士 1:Tomcat 的运行原理
当用户发起请求时,会访问 Tomcat 注册的端口。任何程序想要运行,都需要有一个线程对当前端口号进行监听,Tomcat 也不例外。当监听线程知道用户想要连接时,会由监听线程创建 socket 连接(socket 成对出现,用户和 Tomcat 通过 socket 相互传递数据)。当 Tomcat 端的 socket 接收到数据后,监听线程会从 Tomcat 的线程池中取出一个线程执行用户请求:线程找到用户访问的工程,转发到工程中的 controller → service → dao 层,并访问对应的 DB。执行完请求后统一返回,找到 Tomcat 端的 socket,将数据写回到用户端的 socket,完成请求和响应。
每个用户对应 Tomcat 线程池中的一个线程,使用完成后回收。既然每个请求都是独立的,就可以使用 ThreadLocal 做到线程隔离,每个线程操作自己的一份数据。
温馨小贴士 2:关于 ThreadLocal
无论是 ThreadLocal 的 put 方法还是 get 方法,都是先获取当前线程,再从线程中取出线程的成员变量 Map。只要线程不一样,Map 就不一样,从而做到线程隔离。
拦截器实现
拦截器需要继承 HandlerInterceptor 才能使用其中的拦截相关方法:
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取 session
HttpSession session = request.getSession();
// 2.获取 session 中的用户
Object user = session.getAttribute("user");
// 3.判断用户是否存在
if (user == null) {
// 4.不存在,拦截,返回 401 状态码
response.setStatus(401);
return false;
}
// 5.存在,保存用户信息到 ThreadLocal
UserHolder.saveUser((User) user);
// 6.放行
return true;
}
}
知识点: Tomcat 并不识别我们编写的 Controller 程序,但它识别 Servlet 程序。Spring Web 环境中提供了一个非常核心的 Servlet:DispatcherServlet(前端控制器),所有请求都会先进入 DispatcherServlet,再转给 Controller。当我们定义了拦截器后,会在执行 Controller 方法之前先拦截请求,执行 preHandle() 方法,返回 true 放行,返回 false 不放行。
让拦截器生效
配置拦截器的拦截路径(注意:这里必须用 @Resource,不能用 @Autowired):
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
// token 刷新的拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}
Controller 中使用 ThreadLocal 获取当前登录用户:
@GetMapping("/me")
public Result me(){
// 获取当前登录的用户并返回
UserDTO user = UserHolder.getUser();
return Result.ok(user);
}
1.4 隐藏用户敏感信息
如果直接把 User 对象返回给前端,用户的全部信息都会暴露,这极不安全。核心思路:编写一个 UserDTO 对象(不含敏感信息),返回前将 User 对象转换为 UserDTO。
- 登录方法处:
// 保存用户信息到 session 中
session.setAttribute("user", BeanUtils.copyProperties(user, UserDTO.class));
- 拦截器处:
// 存在,保存用户信息到 ThreadLocal
UserHolder.saveUser((UserDTO) user);
- UserHolder 处:将泛型换成 UserDTO
public class UserHolder {
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
public static void saveUser(UserDTO user) {
tl.set(user);
}
public static UserDTO getUser() {
return tl.get();
}
public static void removeUser() {
tl.remove();
}
}
1.5 Session 共享问题
每个 Tomcat 中都有一份属于自己的 Session。假设用户第一次访问第一台 Tomcat,并把信息存放到第一台的 Session 中,第二次访问被负载均衡到了第二台 Tomcat,第二台上肯定没有第一台存放的 Session,登录拦截功能就会出现问题。
早期的解决方案是 Session 拷贝:每台服务器上的 Session 修改时,同步给其他 Tomcat 服务器。但存在两个大问题:
- 每台服务器都有一份完整的 Session 数据,服务器压力过大;
- Session 拷贝可能出现延迟。
后来的方案是 基于 Redis:把 Session 换成 Redis,Redis 数据本身是共享的,可以避免 Session 共享问题。
Session 替代方案应满足三个条件: 数据共享、内存存储、key-value 结构。
1.6 Redis 代替 Session 的流程
1、设计 key 的结构
保存登录用户信息,可以使用 String 结构以 JSON 字符串保存(直观),也可以使用 Hash 结构(每个字段独立存储,可针对单个字段做 CRUD,内存占用更少):
| 方案 | KEY | VALUE |
|---|---|---|
| String | user:1 | |
| Hash | user:1 | field=name,value=Jack;field=age,value=21 |
2、设计 key 的具体细节
Session 是每个用户都有自己的 Session,但 Redis 的 key 是全局共享的,不能直接使用 code 作为 key。设计 key 需要满足两点:
- key 要具有唯一性
- key 要方便携带
如果直接用手机号存储,属于敏感数据,且容易泄露业务数据(例如当日下单量)。更合理的做法是:后台生成一个随机串 token 作为 key,前端携带 token 完成整体逻辑。
3、整体访问流程
由于放弃了 Session 技术,失去了登录凭证,需要自己生成 token 作为登录凭证返回给前端。前端在请求头中携带 token:
let commonURL = "/api";
// 设置后台服务地址
axios.defaults.baseURL = commonURL;
axios.defaults.timeout = 2000;
// request 拦截器,将用户 token 放入头中
let token = sessionStorage.getItem("token");
axios.interceptors.request.use(
config => {
if (token) config.headers['authorization'] = token
return config
},
error => {
console.log(error)
return Promise.reject(error)
}
);
1.7 基于 Redis 实现短信登录
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误!");
}
// 2.从 redis 获取验证码并校验
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
return Result.fail("验证码错误");
}
// 3.根据手机号查询用户
User user = query().eq("phone", phone).one();
// 4.判断用户是否存在
if (user == null) {
// 5.不存在,创建新用户并保存
user = createUserWithPhone(phone);
}
// 6.保存用户信息到 redis 中
// 6.1.随机生成 token,作为登录令牌
String token = UUID.randomUUID().toString(true);
// 6.2.将 User 对象转为 HashMap 存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
// 6.3.存储
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 6.4.设置 token 有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 7.返回 token
return Result.ok(token);
}
1.8 解决登录状态刷新问题
初始方案存在的问题
登录拦截器只拦截需要拦截的路径。当用户访问不需要拦截的路径时,拦截器不生效,token 的刷新动作不会执行,token 会过期,用户被迫重新登录。
优化方案:双拦截器
添加一个拦截所有路径的拦截器(order=0,先执行),把刷新 token 的逻辑放进去;第二个拦截器只负责判断 ThreadLocal 中是否有用户(order=1,后执行)。
RefreshTokenInterceptor:
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.获取请求头中的 token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true;
}
// 2.基于 TOKEN 获取 redis 中的用户
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 3.判断用户是否存在
if (userMap.isEmpty()) {
return true;
}
// 4.将查询到的 hash 数据转为 UserDTO
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 5.存在,保存用户信息到 ThreadLocal
UserHolder.saveUser(userDTO);
// 6.刷新 token 有效期
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 7.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
小贴士: BeanUtil 可实现 Bean 和 Map 之间的相互转换(
beanToMap、fillBeanWithMap)。
LoginInterceptor(简化版):
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.判断是否需要拦截(ThreadLocal 中是否有用户)
if (UserHolder.getUser() == null) {
// 没有,需要拦截,设置状态码
response.setStatus(401);
return false;
}
// 有用户,则放行
return true;
}
}
注意: 拦截器不是 Spring 容器中的 bean 对象,不能使用
@Autowired依赖注入,需要构造器传入StringRedisTemplate。
第二部分 缓存应用与缓存问题治理
2.1 什么是缓存
缓存:数据交换的缓冲区,俗称的缓存就是缓冲区内的数据,一般从数据库中获取,存储于本地代码中。
缓存的主要优点:速度快,好用。 缓存数据存储于代码中,而代码运行在内存中,内存的读写性能远高于磁盘,可以大大降低高并发访问带来的服务器读写压力。
| 缓存的作用 | 缓存的成本 |
|---|---|
| 降低后端负载 | 数据一致性成本 |
| 提高读写效率,降低响应时间 | 代码维护成本 |
| 运维成本 |
2.2 添加业务数据缓存
缓存模型和思路
标准的操作方式:查询数据库之前先查询缓存。缓存命中则直接返回;缓存未命中再查询数据库,然后将数据存入 Redis。
代码实现
Controller 层:
/**
* 根据 id 查询信息
*/
@GetMapping("/{id}")
public Result queryById(@PathVariable("id") Long id) {
return shopService.queryById(id);
}
Service 层(使用 opsForValue 实现):
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryById(Long id) {
// 1.从 redis 查询缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)) {
// 2.存在,返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 3.不存在,根据 id 查询数据库
Shop shop = getById(id);
if (shop == null) {
// 4.不存在,返回错误信息
return Result.fail("数据不存在");
}
// 5.写入 redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop));
// 6.返回
return Result.ok(shop);
}
实战作业:类型列表缓存(List 实现)
当查询的是业务类型列表(如商品分类、店铺分类等)时,使用 opsForList 存储:
@Service
public class ShopTypeServiceImpl extends ServiceImpl<ShopTypeMapper, ShopType> implements IShopTypeService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public Result queryTypeList() {
// opsForList 写法
String key = CACHE_SHOP_TYPE_KEY;
// 1.从 Redis 查询缓存,end:-1 表示取全部数据
List<String> shopTypeJson = stringRedisTemplate.opsForList().range(key, 0, -1);
// 2.有就直接返回
if (CollectionUtil.isNotEmpty(shopTypeJson)) {
List<ShopType> shopTypes = JSONUtil.toList(shopTypeJson.toString(), ShopType.class);
Collections.sort(shopTypes, ((o1, o2) -> o1.getSort() - o2.getSort()));
return Result.ok(shopTypes);
}
// 3.没有就向数据库查询
List<ShopType> shopTypes = query().orderByAsc("sort").list();
// 4.不存在,返回错误
if (CollectionUtil.isEmpty(shopTypes)) {
return Result.fail("类型不存在...");
}
// 5.存在,写入 Redis(List 类型)。使用 stream 流将每个元素单独转成 JSON
List<String> shopTypesJson = shopTypes.stream()
.map(shopType -> JSONUtil.toJsonStr(shopType))
.collect(Collectors.toList());
// 数据库读出来已经按顺序排列,为保持顺序必须从右边 push(类似队列)
stringRedisTemplate.opsForList().rightPushAll(key, shopTypesJson);
// 6.返回
return Result.ok(shopTypes);
}
}
2.3 缓存更新策略
缓存更新是 Redis 为了节约内存而设计出来的机制。内存数据宝贵,当插入太多数据时,Redis 会对部分数据进行更新(或叫淘汰更合适)。三种策略:
| 策略 | 说明 | 一致性 | 维护成本 |
|---|---|---|---|
| 内存淘汰 | 不用自己维护,Redis 内存不足时自动淘汰部分数据,下次查询时更新缓存 | 差 | 无 |
| 超时剔除 | 给缓存数据添加 TTL,到期后自动删除,下次查询时更新缓存 | 一般 | 低 |
| 主动更新 | 编写业务逻辑,在修改数据库的同时更新缓存 | 好 | 高 |
业务场景:
- 低一致性需求:使用内存淘汰机制(例如业务类型查询缓存);
- 高一致性需求:使用主动更新,并以超时剔除作为兜底方案(例如详情查询缓存)。
2.3.1 数据库缓存不一致解决方案
缓存的数据源来自数据库,而数据库的数据会发生变化。如果数据库数据变化而缓存没有同步,就会出现一致性问题(用户读到过时数据)。常见方案:
| 方案 | 解释 |
|---|---|
| Cache Aside Pattern | 缓存调用者在更新完数据库后再更新缓存,也称"双写方案" |
| Read/Write Through Pattern | 由系统本身完成,数据库与缓存的一致性问题交由系统处理 |
| Write Behind Caching Pattern | 调用者只操作缓存,其他线程异步处理数据库,实现最终一致 |
综合考虑,方案一(Cache Aside)胜出。但方案一有两个问题需要考虑:
问题 1:删除缓存还是更新缓存?
- 更新缓存:每次更新数据库都更新缓存,无效写操作较多(如果中间无人查询,只有最后一次更新有意义);
- 删除缓存:更新数据库时让缓存失效,查询时再更新缓存。减少无效操作,推荐使用。
问题 2:先操作缓存还是先操作数据库?
- 先删除缓存,再操作数据库(发生错误的概率很高):线程 1 先删缓存,线程 2 查询缓存未命中并写入旧数据,线程 1 再更新数据库,旧数据覆盖新数据;
- 先操作数据库,再删除缓存(发生错误的概率极低):推荐。
2.4 实现数据双写一致
需求: 给查询缓存添加超时剔除和主动更新策略:
- 根据 id 查询时,如果缓存未命中,则查询数据库,将结果写入缓存,并设置超时时间;
- 根据 id 修改时,先修改数据库,再删除缓存。
修改 1:queryById 设置缓存过期时间
// 查询未命中,查询数据库后写入缓存,设置 30 分钟过期
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), 30L, TimeUnit.MINUTES);
修改 2:update 先更新数据库,再删除缓存
@Override
public Result update(Shop shop) {
Long id = shop.getId();
if (id == null) {
return Result.fail("id不能为空");
}
// 1.更新数据库
updateById(shop);
// 2.删除缓存
stringRedisTemplate.delete(CACHE_SHOP_KEY + id);
return Result.ok();
}
2.5 缓存穿透问题的解决思路
缓存穿透:客户端请求的数据在缓存中和数据库中都不存在,缓存永远不会生效,这些请求都会打到数据库。
常见的解决方案有两种:
| 方案 | 缓存空对象 | 布隆过滤 |
|---|---|---|
| 优点 | 实现简单,维护方便 | 内存占用较少,没有多余 key |
| 缺点 | 额外的内存消耗;可能造成短期的不一致 | 实现复杂;存在误判可能 |
缓存空对象思路分析: 哪怕数据在数据库中不存在,也把这个数据(空值)存入 Redis。这样下次用户访问不存在的数据,在 Redis 中也能命中,不会进入数据库。注意需要设置较短的过期时间。
布隆过滤思路分析: 布隆过滤器采用哈希思想,通过一个庞大的二进制数组判断数据是否存在。判断存在则放行(访问 Redis,哪怕缓存过期,数据库中一定存在该数据,查询后回填);判断不存在则直接返回。优点是节约内存,缺点是存在误判(哈希冲突)。
2.6 编码解决缓存穿透问题
核心思路:数据不存在时不返回 404,而是把空值写入 Redis。再次查询时命中缓存,判断 value 是否为 null(空串),是则证明是穿透数据,直接返回"不存在",不再查询数据库。
public Shop queryWithPassThrough(Long id) {
// 1.从 redis 查询缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2.存在且非空,返回
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
// 3.判断命中的是否为空值(缓存穿透数据)
if (shopJson != null) {
return null;
}
// 4.不存在,根据 id 查询数据库
Shop shop = getById(id);
if (shop == null) {
// 5.将空值写入 redis(设置较短过期时间)
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 6.写入 redis(正常过期时间)
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
return shop;
}
2.7 缓存雪崩问题及解决思路
缓存雪崩:在同一时段大量的缓存 key 同时失效,或者 Redis 服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同的 Key 的 TTL 添加随机值;
- 利用 Redis 集群(哨兵模式、集群模式)提高服务的可用性;
- 给缓存业务添加降级限流策略(Nginx 或 Spring Cloud Gateway);
- 给业务添加多级缓存(Guava 或 Caffeine)。
2.8 缓存击穿问题及解决思路
缓存击穿(也叫热点 Key 问题):一个被高并发访问并且缓存重建业务较复杂的 key 突然失效了,无数请求在瞬间会给数据库带来巨大冲击。
问题产生过程: 线程 1 查询缓存未命中后本应去查数据库重建缓存,在线程 1 走完逻辑之前,线程 2、3、4 同时过来,都查不到缓存,于是同一时刻都去访问数据库,数据库压力过大。
常见的解决方案有两种:
方案一:互斥锁
使用互斥锁确保同一时间只有一个线程重建缓存。锁实现互斥性,避免数据库压力过大,但会将查询从并行变成串行,影响性能。可以使用 tryLock + double check 解决。
方案二:逻辑过期
把过期时间设置在 Redis 的 value 中(不设置 Redis 真实的过期时间),由业务逻辑自行判断。线程 1 查询缓存发现逻辑过期 → 获取互斥锁 → 开启新线程重建缓存 → 线程 1 直接返回旧数据;其他线程拿不到锁,也直接返回旧数据。等重建完成后再返回正确数据。
方案对比:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 互斥锁 | 没有额外的内存消耗;保证一致性;实现简单 | 线程需要等待,性能受影响;可能有死锁风险 |
| 逻辑过期 | 线程无需等待,性能较好 | 不保证一致性;有额外内存消耗;实现复杂 |
2.9 利用互斥锁解决缓存击穿问题(代码)
核心思路:从缓存查询不到数据后,尝试获取互斥锁。获取失败则休眠一段时间再重试;获取成功才查数据库、写 Redis、释放锁,保证只有一个线程执行操作数据库的逻辑。
锁操作工具方法(利用 Redis 的 SETNX):
setnx 的含义是:Redis 中如果没有这个 key,则插入成功返回 1(StringRedisTemplate 中返回 true);如果有这个 key 则插入失败返回 0(false)。插入成功的线程即为获得锁的线程。
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
互斥锁完整实现:
// 互斥锁方案
public Shop queryWithMutex(Long id) {
// 1.从 redis 查询缓存
String key = CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)) {
// 2.存在,返回
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return shop;
}
// 3.判断命中是否为空值(穿透数据)
if (shopJson != null) {
return null;
}
// 4.实现缓存重建
String lockKey = LOCK_SHOP_KEY + id;
Shop shop = null;
try {
boolean isLock = tryLock(lockKey);
// 5.失败,则休眠并重试
if (!isLock) {
Thread.sleep(50);
return queryWithMutex(id);
}
// 6.查询数据库
shop = getById(id);
if (shop == null) {
// 将空值写入 redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 7.写入 redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 8.释放互斥锁
unlock(lockKey);
}
return shop;
}
2.10 逻辑过期解决缓存击穿问题(代码)
思路分析:查询 Redis 判断是否命中。未命中直接返回空(不查数据库);命中后取出 value 判断过期时间:未过期直接返回;过期则开启独立线程重构数据,当前线程先返回旧数据,重构完成后释放互斥锁。
步骤一:封装数据实体(不修改原实体类,无侵入)
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
步骤二:新增保存方法(带逻辑过期时间)
public void saveShop2Redis(Long id, Long expiredSeconds) {
// 1.查询数据
Shop shop = getById(id);
// 2.封装逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expiredSeconds));
// 3.写入 redis(不设置真实过期时间)
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
步骤三:正式代码
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire(Long id) {
String key = CACHE_SHOP_KEY + id;
// 1.从 redis 查询缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2.判断是否存在
if (StrUtil.isBlank(json)) {
// 3.不存在,直接返回 null
return null;
}
// 4.命中,反序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 5.判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 5.1.未过期,直接返回
return shop;
}
// 5.2.已过期,需要缓存重建
// 6.1.获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);
// 6.2.判断是否获取锁成功
if (isLock) {
// 6.3.开启独立线程,异步重建缓存
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
this.saveShop2Redis(id, 20L);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
});
}
// 6.4.返回过期的数据(脏数据)
return shop;
}
第三部分 优惠券秒杀场景:高并发与并发控制
3.1 全局唯一 ID
秒杀场景中,用户抢购成功会生成订单保存到订单表。订单表如果使用数据库自增 ID 存在两个问题:
- id 的规律性太明显:用户或商业对手很容易猜测敏感信息(如一天内卖出了多少单);
- 受单表数据量的限制:MySQL 单表容量不宜超过 500 万,数据量过大后要拆库拆表,拆分后逻辑上仍是同一张表,id 不能重复。
全局 ID 生成器是分布式系统下生成全局唯一 ID 的工具,一般要满足以下特性:
| 特性 | 说明 |
|---|---|
| 唯一性 | 全局唯一 |
| 高可用 | 不能宕机 |
| 高性能 | 生成 ID 速度快 |
| 递增性 | 便于索引与排序 |
| 安全性 | 不泄露业务信息 |
ID 结构设计(64 位):
| 符号位(1 bit) | 时间戳(31 bit) | 序列号(32 bit) |
|---|---|---|
| 永远为 0 | 以秒为单位,可以使用 69 年 | 秒内计数器,支持每秒产生 2^32 个不同 ID |
为了增加 ID 的安全性,不直接使用 Redis 自增的数值,而是拼接时间戳等信息。
3.2 Redis 实现全局唯一 ID
@Component
public class RedisIdWorker {
/**
* 开始时间戳(2022-01-01 00:00:00)
*/
private static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列号的位数
*/
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public long nextId(String keyPrefix) {
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2.生成序列号
// 2.1.获取当前日期,精确到天
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 2.2.自增长
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.拼接并返回(时间戳左移 32 位,或上序列号)
return timestamp << COUNT_BITS | count;
}
}
测试类与 CountDownLatch
CountDownLatch(信号枪):主要作用是同步协调多线程的等待与唤醒。因为程序是异步的,分线程可能没执行完主线程就执行完了。使用 countDown() 让内部维护的变量减 1,分线程全部执行完时变量为 0,await() 不再阻塞,统计的时间就是所有分线程执行完后的时间。
@Test
void testIdWorker() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(300);
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id = " + id);
}
latch.countDown();
};
long begin = System.currentTimeMillis();
for (int i = 0; i < 300; i++) {
es.submit(task);
}
latch.await();
long end = System.currentTimeMillis();
System.out.println("time = " + (end - begin));
}
注意:
@Test导入的是org.junit.jupiter.api.Test包。执行测试类前需要先启动 Redis。
3.3 添加秒杀优惠券
优惠券分为平价券(可任意购买)和特价券/秒杀券(需要秒杀抢购)。表结构上,秒杀券除了优惠券基本信息外,还包含库存、抢购开始时间、结束时间等字段。
// Controller:新增普通券
@PostMapping
public Result addVoucher(@RequestBody Voucher voucher) {
voucherService.save(voucher);
return Result.ok(voucher.getId());
}
// Controller:新增秒杀券
@PostMapping("seckill")
public Result addSeckillVoucher(@RequestBody Voucher voucher) {
voucherService.addSeckillVoucher(voucher);
return Result.ok(voucher.getId());
}
// Service 实现:保存秒杀券并同步库存到 Redis
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
// 1.保存优惠券
save(voucher);
// 2.保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
// 3.保存秒杀库存到 Redis 中
// SECKILL_STOCK_KEY = "seckill:stock:"
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}
3.4 实现秒杀下单
下单时需要判断两点:
- 秒杀是否开始或结束(未开始 / 已结束则无法下单);
- 库存是否充足(不足则无法下单)。
初版实现:
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否已经结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
// 5.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId).update();
if (!success) {
return Result.fail("库存不足!");
}
// 6.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
Long userId = UserHolder.getUser().getId();
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
}
3.5 库存超卖问题分析
初版代码存在超卖问题:线程 1 查询库存判断大于 1,还没来得及扣减,线程 2 也查询库存发现大于 1,两个线程都会扣减库存,最终超卖。
超卖问题是典型的多线程安全问题,常见解决方案就是加锁:
| 锁类型 | 核心思想 | 举例 |
|---|---|---|
| 悲观锁 | 认为线程安全问题一定会发生,操作数据前先获取锁,确保串行执行 | Synchronized、Lock |
| 乐观锁 | 认为线程安全问题不一定发生,不加锁,更新数据时判断有没有被其他线程修改 | CAS、版本号 |
CAS 法
核心思想:更新变量时,先比较当前值是否与预期值一致,一致则更新为新值,否则更新失败。
- 比较旧值:将变量的当前值与预期旧值比较;
- 更新新值:一致则将值更新为新值;
- 返回结果:成功返回 true,否则 false。
版本号法
在数据记录中增加版本号字段,每次更新检查版本号是否符合预期,符合则更新并递增版本号,否则更新失败。
两者区别:
- 版本号法:适用于业务层复杂数据更新,适合读多写少场景;
- CAS 法:适用于底层简单变量更新,性能高,适合高并发场景,但需要解决 ABA 问题(值从 A 变 B 再变回 A,CAS 会认为没有变化);Java 中可用
AtomicStampedReference(引入版本号/时间戳)解决。
3.6 乐观锁解决超卖问题
方案一(CAS 直接比较,成功率太低):
扣减库存时,把当前查询到的库存作为条件:
// 5.扣减库存(要求库存等于查询时的库存,100 个线程只有 1 个能成功)
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.eq("stock", voucher.getStock())
.update();
失败原因:100 个线程同时拿到库存 100,一起扣减,只有 1 个人能扣减成功,其他线程扣减时库存已被修改而失败。成功率太低。
方案二(stock > 0 条件,推荐):
// 5.扣减库存(库存大于 0 才扣减)
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0)
.update();
即使多个线程同时通过库存大于 0 的检查,由于数据库更新操作的原子性,只有第一个线程能成功扣减,其他线程会发现库存不足而返回失败,避免超卖。
知识小扩展: CAS 自旋压力过大时,可以使用 Java 8 提供的
LongAdder(对 AtomicLong 的改进)。大量线程并发更新一个原子变量时,天然问题是自旋。LongAdder 采用分段 CAS 策略:同一时间大量线程操作 base 时,会拆出多个 cell 分段处理;操作 cell 竞争失败达到一定次数后,线程自动迁移尝试更新别的 cell;最后获取值时将 cell 与 base 的值汇总返回。
3.7 实现一人一单
需求: 同一个优惠券,一个用户只能下一单。在秒杀判断的基础上,增加"根据优惠券 id 和用户 id 查询订单是否存在"的逻辑。
初步代码(增加一人一单逻辑):
// 5.一人一单逻辑
// 5.1.用户 id
Long userId = UserHolder.getUser().getId();
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 5.2.判断是否存在
if (count > 0) {
return Result.fail("用户已经购买过一次!");
}
问题: 并发请求下,多个请求都查询不到订单,都执行了下单操作。乐观锁适合更新数据,而这里是插入数据,所以需要使用悲观锁。
优化一:方法加 synchronized
@Transactional
public synchronized Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
return Result.fail("用户已经购买过一次!");
}
// 扣减库存(带 stock > 0 条件)
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId).gt("stock", 0)
.update();
if (!success) {
return Result.fail("库存不足!");
}
// 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
}
优化二:控制锁粒度
锁的粒度是指锁作用的范围或对象的大小:
- 粗粒度锁:锁整个资源/对象,简单但高并发下容易性能瓶颈;
- 细粒度锁:只锁特定部分,提高并发性能。
synchronized 直接锁整个方法太粗,改为锁用户 id 维度(同一用户串行,不同用户并行):
@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
// 查询订单、扣减库存、创建订单...
}
}
优化三:锁与事务的配合
在方法内部加锁,可能导致事务还没提交,锁已经释放。因此把锁的包裹范围放到外层(在 seckillVoucher 中加锁,调用 createVoucherOrder):
synchronized (userId.toString().intern()) {
return this.createVoucherOrder(voucherId);
}
优化四:解决事务失效(Spring 代理)
this 调用是当前类对象(非 Spring 代理对象),而事务是通过 Spring 动态代理生效的,this 调用方法事务会失效(Spring 事务失效的常见原因之一)。解决:使用代理对象调用方法。
synchronized (userId.toString().intern()) {
// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
pom.xml 需要引入 aspectj:
<dependency>
<groupId>aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.5.4</version>
</dependency>
启动类加注解:
@EnableAspectJAutoProxy(exposeProxy = true)
最终代码:
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Override
public Result seckillVoucher(Long voucherId) {
// 1.查询优惠券
SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
// 2.判断秒杀是否开始
if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始!");
}
// 3.判断秒杀是否结束
if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束!");
}
// 4.判断库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
// 5.一人一单:按用户加锁(细粒度)
synchronized (userId.toString().intern()) {
// 6.通过代理对象调用,保证事务生效
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
}
}
@Transactional
public Result createVoucherOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
// 2.判断是否存在
if (count > 0) {
return Result.fail("用户已经购买过一次!");
}
// 3.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId).gt("stock", 0)
.update();
if (!success) {
return Result.fail("库存不足!");
}
// 4.创建订单
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
return Result.ok(orderId);
}
}
3.8 集群环境下的并发问题
锁失效原因分析: 部署多个 Tomcat 时,每个 Tomcat 都有自己的 JVM。服务器 A 内的线程 1、2 使用同一份代码,锁对象是同一个,可以互斥;但服务器 B 的线程 3、4 虽然锁对象写法相同,却不是同一个对象,无法与线程 1、2 互斥。这就是集群环境下 synchronized 锁失效的原因,此时需要使用分布式锁。
第四部分 分布式锁
4.1 基本原理和实现方式对比
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
核心思想:让大家都使用同一把锁。只要大家使用的是同一把锁,就能锁住线程、让程序串行执行。
分布式锁需要满足的条件:
| 条件 | 说明 |
|---|---|
| 可见性 | 多个进程之间都能感知到锁的变化(注意:不是并发编程中的内存可见性) |
| 互斥 | 最基本的条件,使程序串行执行 |
| 高可用 | 程序不易崩溃,时刻保持较高的可用性 |
| 高性能 | 加锁/释放锁的性能要高 |
| 安全性 | 安全是必不可少的一环 |
三种主流实现方式对比:
| 对比维度 | MySQL | Redis | Zookeeper |
|---|---|---|---|
| 互斥机制 | 利用 GET_LOCK / RELEASE_LOCK 函数 |
利用 SETNX 互斥命令 |
利用节点的唯一性和有序性 |
| 高可用 | 好 | 好 | 好 |
| 高性能 | 一般 | 好 | 一般 |
| 安全性 | 断开连接,自动释放锁 | 利用锁超时时间,到期释放 | 临时节点,断开连接自动释放 |
4.2 Redis 分布式锁的实现思路
实现分布式锁需要实现两个基本方法:
获取锁:
- 互斥:确保只能有一个线程获取锁;
- 非阻塞:尝试一次,成功返回 true,失败返回 false。
# 添加锁,NX 是互斥,EX 是设置超时时间
SET lock thread1 NX EX 10
释放锁:
- 手动释放;
- 超时释放:获取锁时添加一个超时时间。
# 释放锁,删除即可
DEL key
核心思路:利用 Redis 的 setnx,第一个线程进入时 Redis 中有这个 key 返回 1,表示抢到锁,执行业务后删除锁;没抢到锁的等待一定时间后重试。
4.3 实现分布式锁初级版本
锁的基本接口:
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true 代表获取锁成功;false 代表获取锁失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
SimpleRedisLock(利用 setnx 加锁 + 过期时间防死锁):
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
// 获取线程标示
String threadId = Thread.currentThread().getId() + "";
// 获取锁(setnx + 过期时间,保证原子性)
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 通过 del 删除锁
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
知识小贴士:
- 自动拆箱:将包装类对象(如
Boolean)自动转换为基本数据类型(如boolean)。拆箱时如果对象为 null,会引发空指针异常,因此推荐使用Boolean.TRUE.equals(isLock)判断。setIfAbsent同时设置 key 和过期时间,保证加锁和设置过期时间的原子性。
改造秒杀业务:
@Override
public Result seckillVoucher(Long voucherId) {
// ... 1-4 步查询优惠券、判断时间与库存(同前)...
Long userId = UserHolder.getUser().getId();
// 创建锁对象(新增代码)
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
// 获取锁对象
boolean isLock = lock.tryLock(1200);
// 加锁失败
if (!isLock) {
return Result.fail("不允许重复下单");
}
try {
// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
// 释放锁
lock.unlock();
}
}
4.4 Redis 分布式锁误删情况说明
问题: 线程 1 获取锁后发生业务阻塞,业务尚未完成,锁超时释放。此时线程 2 获取到这把锁并开始执行。若阻塞的线程 1 恢复执行并尝试删除锁,会误删线程 2 持有的锁,导致锁状态混乱。
解决方案: 删除锁之前,检查锁是否仍然属于自己。线程 1 恢复后检查发现锁已不属于自己,则不执行删除;线程 2 执行完毕后确认锁属于自己再删除。
4.5 解决 Redis 分布式锁误删问题
需求: 获取锁时存入线程标示(可以用 UUID 表示),释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致。一致则释放锁,不一致则不释放。
import cn.hutool.core.lang.UUID;
public class SimpleRedisLock implements ILock {
private String name;
private StringRedisTemplate stringRedisTemplate;
private static final String KEY_PREFIX = "lock:";
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(Long time) {
// 1.设置 key
String key = KEY_PREFIX + name;
// 2.存入 Redis,返回
String threadId = ID_PREFIX + Thread.currentThread().getId();
Boolean isLock = stringRedisTemplate.opsForValue().setIfAbsent(key, threadId, time, TimeUnit.SECONDS);
return Boolean.TRUE.equals(isLock);
}
@Override
public void unlock() {
// 1.设置 key
String key = KEY_PREFIX + name;
// 2.获取当前线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 3.获取 Redis 中的标识
String id = stringRedisTemplate.opsForValue().get(key);
if (threadId.equals(id)) {
// 4.标识相同,释放锁
stringRedisTemplate.delete(key);
}
}
}
这里的 UUID 用来区别不同的 JVM,ThreadId 用来区别同一个 JVM 中的不同线程。
4.6 分布式锁的原子性问题
更极端的误删逻辑: 线程 1 持有锁后执行业务,正准备删除锁,已经完成"判断锁属于自己",但此时锁到期了,线程 2 进来拿到了锁。线程 1 卡顿结束后直接执行删除锁那行代码——判断已经通过,条件判断没起作用。
根本原因:线程 1 的拿锁、比锁、删锁不是原子性的。
4.7 Lua 脚本解决多条命令原子性问题
Redis 提供 Lua 脚本功能:在一个脚本中编写多条 Redis 命令,确保多条命令执行时的原子性。
基本语法:
-- 执行 redis 命令
redis.call('命令名称', 'key', '其它参数', ...)
示例 1:执行 set name jack
redis.call('set', 'name', 'jack')
示例 2:先 set 再 get 并返回
-- 先执行 set name jack
redis.call('set', 'name', 'jack')
-- 再执行 get name
local name = redis.call('get', 'name')
-- 返回
return name
调用脚本:
# 调用脚本(固定参数)
EVAL "return redis.call('set', 'name', 'jack')" 0
# 脚本中的 key、value 作为参数传递:key 放入 KEYS 数组,其它参数放入 ARGV 数组
EVAL "return redis.call('set', KEYS[1], ARGV[1])" 1 name Rose
释放锁的 Lua 脚本(拿锁比锁删锁原子化):
-- 这里的 KEYS[1] 就是锁的 key,这里的 ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then
-- 一致,则删除锁
return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
4.8 调用 Lua 脚本改造分布式锁
RedisTemplate 中可以利用 execute 方法执行 Lua 脚本,参数对应关系:
| Java 参数 | EVAL 命令参数 |
|---|---|
RedisScript<T> script |
script(脚本内容) |
List<K> keys |
key [key ...] |
Object... args |
arg [arg ...] |
Java 代码:
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static {
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
public void unlock() {
// 调用 lua 脚本
stringRedisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(KEY_PREFIX + name),
ID_PREFIX + Thread.currentThread().getId());
}
小总结:基于 Redis 的分布式锁实现思路
- 利用
set nx ex获取锁,设置过期时间,保存线程标示; - 释放锁时先判断线程标示是否与自己一致,一致则删除锁(用 Lua 保证原子性)。
特性:
- 利用
set nx满足互斥性; - 利用
set ex保证故障时锁依然能释放,避免死锁,提高安全性; - 利用 Redis 集群保证高可用和高并发特性。
演进脉络: 加过期时间防死锁 → 出现误删别人锁的问题 → 拿锁比锁删锁(判断归属)→ 仍有原子性问题 → 用 Lua 脚本解决。但还剩下一个"锁不住"的问题:过期时间到了之后能否续期?这就是下一章 Redisson 要解决的。
第五部分 Redisson 分布式锁
5.1 基于 setnx 的问题
基于 setnx 实现的分布式锁存在下面的问题:
| 问题 | 说明 |
|---|---|
| 01 不可重入 | 同一个线程无法多次获取同一把锁 |
| 02 不可重试 | 获取锁只尝试一次就返回 false,没有重试机制 |
| 03 超时释放 | 锁超时释放虽可避免死锁,但业务执行耗时较长时锁会被提前释放,存在安全隐患 |
| 04 主从一致性 | 主从集群中主从同步存在延迟,主节点宕机时若从节点未同步锁数据,会出现锁丢失 |
5.2 Redisson 介绍
Redisson 是一个在 Redis 的基础上实现的 Java 驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列分布式的 Java 常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
分布式锁(Lock)和同步器(Synchronizer)包含:
- 可重入锁(Reentrant Lock)
- 公平锁(Fair Lock)
- 联锁(MultiLock)
- 红锁(RedLock)
- 读写锁(ReadWriteLock)
- 信号量(Semaphore)
- 可过期性信号量(PermitExpirableSemaphore)
- 闭锁(CountDownLatch)
官网:https://redisson.org | GitHub:https://github.com/redisson/redisson
5.3 Redisson 入门
引入依赖:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
配置 Redisson 客户端:
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
// 配置
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.150.101:6379")
.setPassword("123321");
// 创建 RedissonClient 对象
return Redisson.create(config);
}
}
使用 Redisson 的分布式锁:
@Resource
private RedissonClient redissonClient;
@Test
void testRedisson() throws Exception {
// 获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
// 尝试获取锁:获取锁的最大等待时间(期间会重试)、锁自动释放时间、时间单位
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
if (isLock) {
try {
System.out.println("执行业务");
} finally {
// 释放锁
lock.unlock();
}
}
}
在业务中注入 RedissonClient:
@Resource
private RedissonClient redissonClient;
@Override
public Result seckillVoucher(Long voucherId) {
// ... 1-4 步查询优惠券、判断时间与库存(同前)...
Long userId = UserHolder.getUser().getId();
// 创建锁对象
RLock lock = redissonClient.getLock("lock:order:" + userId);
// 获取锁对象
boolean isLock = lock.tryLock();
// 加锁失败
if (!isLock) {
return Result.fail("不允许重复下单");
}
try {
// 获取代理对象(事务)
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} finally {
// 释放锁
lock.unlock();
}
}
以下为 Redisson 原理讲解部分。
5.4 Redisson 可重入锁原理
在 JVM 的 Lock 锁中,借助底层一个 volatile 的 state 变量记录重入状态:没人持有锁 state=0,有人持有 state=1,持有者再次持有则 +1;synchronized 在 C 代码中有一个 count,原理类似。
Redisson 使用 Hash 结构存储锁:
- 大 key:表示锁是否存在(锁名称);
- 小 key:表示当前锁被哪个线程持有(id + ":" + threadId);
- value:重入次数。
加锁的 Lua 脚本(3 个参数):
KEYS[1]:锁名称ARGV[1]:锁失效时间ARGV[2]:id + ":" + threadId(锁的小 key)
-- 1.锁不存在:创建锁,计数为 1,设置过期时间
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hset', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
-- 2.锁存在且属于当前线程:重入计数 +1,重置过期时间
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
-- 3.抢锁失败,返回锁的剩余有效期
return redis.call('pttl', KEYS[1]);
exists判断锁是否存在,等于 0 表示当前锁不存在 → 写入 hash 结构Lock{id:threadId : 1};hexists判断大 key + 小 key 是否属于自己 → 属于则hincrby将 value +1,并pexpire设置过期时间;- 两个条件都不满足 → 抢锁失败,返回
pttl(锁的失效时间);源码中会进行while(true)自旋抢锁。
5.5 Redisson 锁重试和 WatchDog(看门狗)机制
lock() 方法源码解析
抢锁过程:获得当前线程,通过 tryAcquire 抢锁。如果返回 null 表示抢锁成功(或可重入完毕);否则进入 while(true) 循环再次抢锁。
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return;
}
- 带参数调用(leaseTime != -1):走
tryLockInnerAsync抢锁; - 不带参数调用:使用默认看门狗时间
lockWatchdogTimeout(默认 30 秒),通过onComplete监听抢锁结果,抢锁成功后开启看门狗线程进行续约。
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(waitTime,
commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}
// lock acquired
if (ttlRemaining == null) {
scheduleExpirationRenewal(threadId);
}
});
续约逻辑(看门狗)
锁的失效时间是 30 秒,看门狗每隔 internalLockLeaseTime / 3(即 10 秒)触发一次续约,把锁续约成 30 秒;操作成功后递归调用自己,重新设置下一个 TimerTask,完成不停续约。如果线程宕机,没人再调用 renewExpiration,等到时间后锁自然释放。
private void renewExpiration() {
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
return;
}
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock " + getName() + " expiration", e);
return;
}
if (res) {
// reschedule itself
renewExpiration();
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
5.6 Redisson 锁的 MultiLock 原理
主从一致性问题: 为了提高可用性搭建主从集群。写命令写在主机上,主机会将数据异步同步给从机。若主机还没来得及把数据同步给从机就宕机了,哨兵会选举一个 slave 变成 master,而新的 master 中并没有锁信息,锁信息丢失。
MultiLock(联锁)解决: 不使用主从,每个节点地位相同。加锁逻辑需要写入到每一个节点,只有所有服务器都写入成功,才算加锁成功。只要有一个节点拿不到,都不能算加锁成功,保证了加锁的可靠性。
加锁原理: 将多个锁添加到一个集合中,用 while 循环不停尝试拿锁,总加锁时间 = 锁个数 × 1500ms。假设 3 个锁,时间就是 4500ms。在 4500ms 内所有锁都加锁成功才算成功;若有线程加锁失败则重试;超时未释放的锁通过 del 删除已成功获取的锁。
第六部分 秒杀性能优化(异步化)
6.1 异步秒杀思路
回顾一下下单流程: 用户发起请求 → Nginx → Tomcat 串行执行:
- 查询优惠券;
- 判断秒杀库存是否足够;
- 查询订单;
- 校验是否是一人一单;
- 扣减库存;
- 创建订单。
这六步中有很多操作要访问数据库,且是一个线程串行执行,程序执行很慢。如何加速?
优化思路: 把耗时较长的下单流程从同步改为异步:
下单时,调用 Lua 脚本在 Redis 中原子性地判断库存是否充足(value > 0)且用户能否下单(Set 集合无对应记录)。均满足则扣库存、存 userId,并返回 0。返回 0 则将信息存入队列,通过异步线程下单;前端根据返回的订单 id 判断下单是否成功。
6.2 Redis 完成秒杀资格判断
需求:
- 新增秒杀优惠券的同时,将优惠券信息保存到 Redis;
- 基于 Lua 脚本判断秒杀库存、一人一单,决定用户是否抢购成功;
- 抢购成功后,将优惠券 id 和用户 id 封装后存入阻塞队列;
- 开启线程任务,不断从阻塞队列中获取信息,实现异步下单。
Redis 中存储的数据结构:
| KEY | VALUE |
|---|---|
seckill:stock:{vid} |
100(库存) |
seckill:order:{vid} |
1,2,3,5,7,8(已下单用户 id 的 Set 集合) |
完整 Lua 脚本:
-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3.订单id
local orderId = ARGV[3]
-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if (tonumber(redis.call('get', stockKey)) <= 0) then
-- 3.2.库存不足,返回 1
return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if (redis.call('sismember', orderKey, userId) == 1) then
-- 3.3.存在,说明是重复下单,返回 2
return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6.发送消息到队列中,XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0
还需要加锁吗? 判断库存充足和用户能否下单时,使用 Lua 脚本可以确保操作的原子性,因此无需额外加锁。Lua 脚本在 Redis 中原子执行,其他客户端无法在脚本执行中途插入操作,有效保证库存的准确性和一人一单的公平性。
VoucherOrderServiceImpl 调用:
@Override
public Result seckillVoucher(Long voucherId) {
// 获取用户
Long userId = UserHolder.getUser().getId();
long orderId = redisIdWorker.nextId("order");
// 1.执行 lua 脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString(), String.valueOf(orderId)
);
int r = result.intValue();
// 2.判断结果是否为 0
if (r != 0) {
// 2.1.不为 0,代表没有购买资格
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
// 3.后续将订单信息保存到队列中,由异步线程完成下单(详见 6.3)
// 返回订单 id
return Result.ok(orderId);
}
6.3 基于阻塞队列实现秒杀优化
下单时通过 Lua 脚本原子执行判断逻辑:返回 0 则把下单逻辑存入阻塞队列,异步执行。
@Service
@Slf4j
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Resource
private ISeckillVoucherService seckillVoucherService;
@Resource
private RedisIdWorker redisIdWorker;
@Resource
private RedissonClient redissonClient;
@Resource
private StringRedisTemplate stringRedisTemplate;
private IVoucherOrderService proxy;
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
// 异步处理线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
// 在类初始化之后执行
@PostConstruct
private void init() {
SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
// 用于线程池处理的任务:初始化完毕后从队列中取信息
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
// 1.获取队列中的订单信息
VoucherOrder voucherOrder = orderTasks.take();
// 2.创建订单
handleVoucherOrder(voucherOrder);
} catch (Exception e) {
log.error("处理订单异常", e);
}
}
}
}
private void handleVoucherOrder(VoucherOrder voucherOrder) {
// 1.获取用户
Long userId = voucherOrder.getUserId();
// 2.创建锁对象
RLock redisLock = redissonClient.getLock("lock:order:" + userId);
// 3.尝试获取锁
boolean isLock = redisLock.tryLock();
// 4.判断是否获得锁成功
if (!isLock) {
log.error("不允许重复下单!");
return;
}
try {
// 注意:spring 的事务放在 threadLocal 中,多线程下事务会失效,需要用代理对象
proxy.createVoucherOrder(voucherOrder);
} finally {
// 释放锁
redisLock.unlock();
}
}
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
// 1.执行 lua 脚本
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(), userId.toString()
);
int r = result.intValue();
// 2.判断结果是否为 0
if (r != 0) {
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
// 3.放入阻塞队列
orderTasks.add(voucherOrder);
// 4.获取代理对象(事务)
proxy = (IVoucherOrderService) AopContext.currentProxy();
// 5.返回订单 id
return Result.ok(orderId);
}
@Transactional
public void createVoucherOrder(VoucherOrder voucherOrder) {
Long userId = voucherOrder.getUserId();
// 1.查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
// 2.判断是否存在
if (count > 0) {
log.error("用户已经购买过了");
return;
}
// 3.扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock = stock - 1")
.eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", 0)
.update();
save(voucherOrder);
}
}
小总结:
- 秒杀业务的优化思路:先利用 Redis 完成库存余量、一人一单判断,完成抢单业务;再将下单业务放入阻塞队列,利用独立线程异步下单;
- 基于阻塞队列的异步秒杀存在的问题:
- 内存限制问题(队列在 JVM 内存中,容量有限);
- 数据安全问题(服务宕机队列数据丢失)。
为什么异步快?同步与异步的区别
| 对比维度 | 同步 | 异步 |
|---|---|---|
| 执行方式 | 阻塞式,按顺序依次执行 | 非阻塞式,可并发执行 |
| 资源利用率 | 较低,调用方等待期间资源闲置 | 较高,能充分利用系统资源 |
| 代码复杂度 | 简单直观,易于理解和调试 | 复杂,需额外处理异步逻辑 |
| 适用场景 | 实时性要求高、任务执行时间短的场景 | 耗时操作,如网络请求、文件操作等 |
| 响应速度 | 较慢,因需等待每个操作完成 | 较快,调用方无需等待操作完成即可响应 |
第七部分 Redis 消息队列
7.1 认识消息队列
什么是消息队列:字面意思就是存放消息的队列。最简单的消息队列模型包括 3 个角色:
- 消息队列:存储和管理消息,也被称为消息代理(Message Broker);
- 生产者:发送消息到消息队列;
- 消费者:从消息队列获取消息并处理消息。
使用队列的好处在于解耦: 用户下单后,先用 Redis 校验下单条件,满足则把下单请求以消息形式发送到队列,再由独立线程消费队列中的消息完成后续下单流程。下单请求的接收与处理不再紧密耦合,前端无需等待整个下单流程结束即可返回响应,加速响应速度。
7.2 基于 List 实现消息队列
Redis 的 List 数据结构是双向链表,很容易模拟出队列效果。队列的入口和出口不在一边,因此可以利用 LPUSH 结合 RPOP(或 RPUSH 结合 LPOP)实现。
注意: 当队列中没有消息时,RPOP / LPOP 会返回 null,不像 JVM 的阻塞队列那样阻塞等待消息。因此应该使用 BRPOP / BLPOP 实现阻塞效果。
# 生产者:LPUSH 入队
LPUSH queue msg
# 消费者:RPOP 出队(阻塞版本 BRPOP)
BRPOP queue 0
优点:
- 利用 Redis 存储,不受限于 JVM 内存上限;
- 基于 Redis 的持久化机制,数据安全性有保证;
- 可以满足消息有序性。
缺点:
- 无法避免消息丢失:消费者从 List 取出消息后处理时若故障(进程挂掉、网络中断),消息尚未确认消费完成(BRPOP 不支持消息确认机制),消息会丢失;主从复制场景下主节点故障转移也可能丢失未同步的消息;
- 只支持单消费者:多个消费者同时消费时,Redis 无法像专业消息队列那样公平分配消息,会出现重复消费或部分消费者长时间消费不到消息的问题。
7.3 基于 PubSub 的消息队列
Redis 2.0 引入 Pub/Sub(发布订阅)模型。消费者订阅一个或多个频道(channel),生产者向频道发送消息时,所有订阅该频道的消费者都能收到。
常用命令:
# 订阅频道
SUBSCRIBE channel [channel ...]
# 发布消息
PUBLISH channel message
# 模式匹配订阅
PSUBSCRIBE pattern [pattern ...]
优点: 采用发布订阅模型,支持多生产、多消费。
缺点:
- 不支持数据持久化:Redis 重启或故障后,之前发布的消息丢失;
- 无法避免消息丢失:消费者在消息发布期间故障可能错过消息;未订阅时发布的消息也无法获取;
- 消息堆积有上限:超出处理能力的消息被丢弃,无法保证消息完整性和可靠性。
7.4 基于 Stream 的消息队列
Stream 是 Redis 5.0 引入的新数据类型,可以实现功能非常完善的消息队列。
发送消息(XADD):
# 创建名为 users 的队列,并发送消息 {name=jack, age=21},ID 由 Redis 自动生成
127.0.0.1:6379> XADD users * name jack age 21
"1644805700523-0"
消息的唯一 id,
*代表由 Redis 自动生成,格式是"时间戳-递增数字"。NOMKSTREAM表示队列不存在时是否自动创建(默认自动创建);MAXLEN/MINID可设置队列最大消息数量。
读取消息(XREAD):
# 从第一个消息开始读取 1 条
127.0.0.1:6379> XREAD COUNT 1 STREAMS users 0
# 阻塞方式读取最新消息(没有消息时阻塞 1000ms)
127.0.0.1:6379> XREAD COUNT 1 BLOCK 1000 STREAMS users $
(nil)
(1.07s)
0代表从第一个消息开始;$代表从最新消息开始。
业务中循环调用 XREAD 阻塞方式持续监听队列:
while (true) {
// 尝试读取队列中的消息,最多阻塞 2 秒
Object msg = redis.execute("XREAD COUNT 1 BLOCK 2000 STREAMS users $");
if (msg == null) {
continue;
}
// 处理消息
handleMessage(msg);
}
XREAD 命令的特点:
- 消息可回溯:可以指定起始 ID 读取历史消息;
- 一个消息可以被多个消费者读取:消息不会因为一个消费者读取而消失;
- 可以阻塞读取:
BLOCK参数可在队列为空时阻塞等待,减少轮询频率; - 有消息漏读的风险:起始 ID 为
$时每次只读最新消息。假设队列中有 A、B、C,先读到 A,处理 A 时 B、C 到达,再次读取会直接读到 C,B 被漏读。可以使用消费者组解决。
7.5 消费者组
消费者组(Consumer Group):将多个消费者划分到一个组中,监听同一个队列。具备三个核心特点:
| 特性 | 说明 |
|---|---|
| 消息分流 | 队列中的消息分流给组内不同消费者,而不是重复消费,加快处理速度 |
| 消息标示 | 消费者组维护一个标示记录最后一个被处理的消息,消费者宕机重启后从标示之后继续读取,确保每个消息都被消费 |
| 消息确认 | 消费者获取消息后,消息处于 pending 状态并存入 pending-list;处理完成后通过 XACK 确认,标记为已处理后才从 pending-list 移除 |
创建消费者组:
XGROUP CREATE key groupName ID [MKSTREAM]
key:队列名称;groupName:消费者组名称;ID:起始 ID 标示,$代表队列中最后一个消息,0代表队列中第一个消息;MKSTREAM:队列不存在时自动创建队列。
其它常见命令:
# 删除指定的消费者组
XGROUP DESTROY key groupName
# 给指定的消费者组添加消费者
XGROUP CREATECONSUMER key groupname consumername
# 删除消费者组中的指定消费者
XGROUP DELCONSUMER key groupname consumername
从消费者组读取消息(XREADGROUP):
XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]
group:消费组名称;consumer:消费者名称,不存在会自动创建;count:本次查询的最大数量;BLOCK milliseconds:没有消息时的最长等待时间;NOACK:无需手动 ACK,获取到消息后自动确认;STREAMS key:指定队列名称;ID:获取消息的起始 ID,>表示从下一个未消费的消息开始;其它 ID(如 0)表示从 pending-list 中获取已消费但未确认的消息。
消费者监听消息的基本思路:
while (true) {
// 尝试监听队列,使用阻塞模式,最长等待 2000 毫秒
Object msg = redis.call("XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >");
if (msg == null) { // null 说明没有消息,继续下一次
continue;
}
try {
// 处理消息,完成后一定要 ACK
handleMessage(msg);
} catch (Exception e) {
while (true) {
Object msg = redis.call("XREADGROUP GROUP g1 c1 COUNT 1 STREAMS s1 0");
if (msg == null) { // null 说明没有异常消息,所有消息都已确认,结束循环
break;
}
try {
// 说明有异常消息,再次处理
handleMessage(msg);
} catch (Exception e) {
// 再次出现异常,记录日志,继续循环
continue;
}
}
}
}
STREAM 类型消息队列(XREADGROUP)的特点:
- 消息可回溯;
- 可以多消费者争抢消息,加快消费速度;
- 可以阻塞读取;
- 没有消息漏读的风险;
- 有消息确认机制,保证消息至少被消费一次。
三种消息队列实现方式对比:
| 对比维度 | List | PubSub | Stream |
|---|---|---|---|
| 消息持久化 | 支持 | 不支持 | 支持 |
| 阻塞读取 | 支持 | 支持 | 支持 |
| 消息堆积处理 | 受限于内存空间,可利用多消费者加快处理 | 受限于消费者缓冲区 | 受限于队列长度,可利用消费者组提高消费速度,减少堆积 |
| 消息确认机制 | 不支持 | 不支持 | 支持 |
| 消息回溯 | 不支持 | 不支持 | 支持 |
7.6 消息队列实现异步秒杀下单
需求:
- 创建一个 Stream 类型的消息队列,名为
stream.orders; - 修改秒杀下单 Lua 脚本:认定有抢购资格后,直接向
stream.orders添加消息(voucherId、userId、orderId); - 项目启动时,开启一个线程任务,尝试获取
stream.orders中的消息,完成下单。
修改 Lua 脚本,新增 3.6:
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6.发送消息到队列中,XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
VoucherOrderServiceImpl:
private class VoucherOrderHandler implements Runnable {
@Override
public void run() {
while (true) {
try {
// 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
);
// 2.判断订单信息是否为空
if (list == null || list.isEmpty()) {
continue;
}
// 3.解析数据
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
// 4.创建订单
createVoucherOrder(voucherOrder);
// 5.确认消息 XACK
stringRedisTemplate.opsForStream().acknowledge("stream.orders", "g1", record.getId());
} catch (Exception e) {
log.error("处理订单异常", e);
// 处理异常消息
handlePendingList();
}
}
}
private void handlePendingList() {
while (true) {
try {
// 1.获取 pending-list 中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 STREAMS s1 0
List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
Consumer.from("g1", "c1"),
StreamReadOptions.empty().count(1),
StreamOffset.create("stream.orders", ReadOffset.from("0"))
);
// 2.判断订单信息是否为空
if (list == null || list.isEmpty()) {
break;
}
// 3.解析数据并创建订单
MapRecord<String, Object, Object> record = list.get(0);
Map<Object, Object> value = record.getValue();
VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
createVoucherOrder(voucherOrder);
// 4.确认消息 XACK
stringRedisTemplate.opsForStream().acknowledge("stream.orders", "g1", record.getId());
} catch (Exception e) {
log.error("处理 pending 订单异常", e);
try {
Thread.sleep(20);
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
}
}
第八部分 Redis 最佳实践
8.1 Redis 键值设计
优雅的 key 结构
Redis 的 Key 虽然可以自定义,但最好遵循下面的最佳实践约定:
- 遵循基本格式:
[业务名称]:[数据名]:[id]; - 长度不超过 44 字节;
- 不包含特殊字符。
例如登录业务保存用户信息,key 可以设计为 login:user:10:
业务名称 数据名称 数据id
login : user : 10
这样设计的好处: 可读性强、避免 key 冲突、方便管理、更节省内存。
为什么要小于 44 字节?
Redis 的 String 类型键值,底层根据值的大小和内容采用三种编码格式:int、embstr 和 raw:
embstr编码:字符串值长度小于等于 44 字节时使用。Redis Object(robj)和对应的 SDS 结构体被分配在一块连续的内存空间中,内存占用更小,访问效率更高;raw编码:字符串值长度超过 44 字节时自动转换。Redis Object 和 SDS 被分配在两块独立的内存空间,读取需要额外的指针跳转(两次内存访问),且 SDS 单独分配容易产生内存碎片。
8.2 拒绝 BigKey
BigKey 的判定
BigKey 通常以 Key 的大小和 Key 中成员的数量来综合判定:
- Key 本身的数据量过大:一个 String 类型的 Key,值为 5 MB;
- Key 中的成员数过多:一个 ZSET 类型的 Key,成员数量为 10,000 个;
- Key 中成员的数据量过大:一个 Hash 类型的 Key,其成员的 Value 总大小为 100 MB。
推荐值:
- 单个 key 的 value 小于 10KB;
- 集合类型的 key,元素数量小于 1000。
BigKey 的危害
| 危害 | 说明 |
|---|---|
| 网络阻塞 | 对 BigKey 执行读请求时,少量 QPS 就可能占满带宽,导致 Redis 实例乃至所在物理机变慢 |
| 数据倾斜 | BigKey 所在的 Redis 实例内存使用率远超其他实例,无法使数据分片内存资源均衡 |
| Redis 阻塞 | 对元素较多的 hash、list、zset 做运算耗时较久,使主线程被阻塞 |
| CPU 压力 | 对 BigKey 的序列化和反序列化导致 CPU 使用率飙升 |
如何发现 BigKey
- redis-cli --bigkeys:遍历分析所有 key,返回整体统计信息与每个数据类型的 Top1 big key;
redis-cli -a 密码 --bigkeys
- scan 扫描:编程利用
scan扫描所有 key,用strlen、hlen等命令判断长度(不建议使用 MEMORY USAGE,该命令对 CPU 使用率较高);
@Test
void testScan() {
int maxLen = 0;
long len = 0;
String cursor = "0";
do {
// 扫描并获取一部分 key
ScanResult<String> result = jedis.scan(cursor);
cursor = result.getCursor();
List<String> list = result.getResult();
if (list == null || list.isEmpty()) {
break;
}
for (String key : list) {
// 判断 key 的类型
String type = jedis.type(key);
switch (type) {
case "string":
len = jedis.strlen(key);
maxLen = STR_MAX_LEN; // 10 * 1024
break;
case "hash":
len = jedis.hlen(key);
maxLen = HASH_MAX_LEN; // 500
break;
case "list":
len = jedis.llen(key);
maxLen = HASH_MAX_LEN;
break;
case "set":
len = jedis.scard(key);
maxLen = HASH_MAX_LEN;
break;
case "zset":
len = jedis.zcard(key);
maxLen = HASH_MAX_LEN;
break;
default:
break;
}
// 打印 BigKey
if (len >= maxLen) {
System.out.printf("Found big key : %s, type: %s, length or size: %d %n", key, type, len);
}
}
} while (!cursor.equals("0"));
}
- 第三方工具:利用 Redis-Rdb-Tools( https://github.com/sripathikrishnan/redis-rdb-tools )分析 RDB 快照文件,全面分析内存使用情况;
- 网络监控:自定义工具监控进出 Redis 的网络数据,超出预警值时主动告警(云服务器一般自带监控页面)。
如何删除 BigKey
BigKey 内存占用较多,即使删除也需要耗费很长时间,导致 Redis 主线程阻塞:
- Redis 3.0 及以下:集合类型先遍历元素逐个删除子元素,最后删除 BigKey;
- Redis 4.0 以后:使用异步删除命令
unlink。
8.3 恰当的数据类型
例 1:存储一个 User 对象,三种存储方式对比:
| 方式 | 示例 | 优点 | 缺点 |
|---|---|---|---|
| ① JSON 字符串 | user:1 → {"name":"Jack","age":21} |
实现简单粗暴 | 数据耦合,不够灵活 |
| ② 字段打散 | user:1:name → Jack;user:1:age → 21 |
灵活访问任意字段 | 占用空间大、无法统一控制 |
| ③ Hash(推荐) | user:1 → field=name,value=Jack;field=age,value=21 |
底层 ziplist 空间占用小,灵活访问任意字段 | 代码相对复杂 |
例 2:Hash 类型 key 有 100 万对 field/value(field 是自增 id),如何优化?
存在的问题:
- hash 的 entry 数量超过 500 时会使用哈希表而不是 ZipList,内存占用较多;
- 可以通过
hash-max-ziplist-entries配置 entry 上限,但 entry 过多会导致 BigKey 问题。
方案一:拆分为 string 类型 —— string 底层没有太多内存优化,内存占用较多,批量获取麻烦。
方案二:拆分为小的 hash,将 id / 100 作为 key,id % 100 作为 field(推荐):
| KEY | FIELD | VALUE |
|---|---|---|
| key:0 | id:00 ~ id:99 | value0 ~ value99 |
| key:1 | id:00 ~ id:99 | value100 ~ value199 |
| ... | ... | ... |
| key:9999 | id:00 ~ id:99 | value999900 ~ value999999 |
代码实现(拆分测试):
@Test
void testSmallHash() {
int hashSize = 100;
Map<String, String> map = new HashMap<>(hashSize);
for (int i = 1; i <= 100000; i++) {
int k = (i - 1) / hashSize;
int v = i % hashSize;
map.put("key_" + v, "value_" + v);
if (v == 0) {
jedis.hmset("test:small:hash_" + k, map);
}
}
}
8.4 批处理优化
单个命令的执行流程
一次命令的响应时间 = 1 次往返的网络传输耗时 + 1 次 Redis 执行命令耗时。
N 条命令串行执行:N 次命令的响应时间 = N 次往返的网络传输耗时 + N 次 Redis 执行命令耗时。网络往返是主要开销。
多条指令批量传输: N 次命令的响应时间 = 1 次往返的网络传输耗时 + N 次 Redis 执行命令耗时。这就是 Pipeline / MSet 的原理。
MSet
利用 mset 批量插入 10 万条数据(注意:不要在一次处理中传输太多命令,否则单次命令占用带宽过多,会导致网络阻塞):
@Test
void testMxx() {
String[] arr = new String[2000];
int j;
long b = System.currentTimeMillis();
for (int i = 1; i <= 100000; i++) {
j = (i % 1000) << 1;
arr[j] = "test:key_" + i;
arr[j + 1] = "value_" + i;
if (j == 0) {
jedis.mset(arr);
}
}
long e = System.currentTimeMillis();
System.out.println("time: " + (e - b));
}
Pipeline
MSET 只能操作部分数据类型,对复杂数据类型的批处理建议使用 Pipeline:
@Test
void testPipeline() {
// 创建管道
Pipeline pipeline = jedis.pipelined();
long b = System.currentTimeMillis();
for (int i = 1; i <= 100000; i++) {
// 放入命令到管道
pipeline.set("test:key_" + i, "value_" + i);
if (i % 1000 == 0) {
// 每放入 1000 条命令,批量执行
pipeline.sync();
}
}
long e = System.currentTimeMillis();
System.out.println("time: " + (e - b));
}
集群下的批处理
Redis 集群要求批处理命令(如 MSET 或包含多 Key 的 Pipeline)中所有 Key 必须位于同一个哈希槽(Slot),否则会执行失败。四种解决方案:
| 方案 | 实现思路 | 耗时 | 优点 | 缺点 |
|---|---|---|---|---|
| 串行命令 | for 循环遍历,依次执行每个命令 | N 次网络耗时 + N 次命令耗时 | 实现简单 | 耗时非常久 |
| 串行 slot | 客户端计算每个 key 的 slot,按 slot 分组,每组用 Pipeline,串行执行各组 | m 次网络耗时 + N 次命令耗时(m = slot 个数) | 耗时较短 | 实现稍复杂,slot 越多耗时越久 |
| 并行 slot | 按 slot 分组,各组 Pipeline 并行执行 | 1 次网络耗时 + N 次命令耗时 | 耗时非常短 | 实现复杂 |
| hash_tag | 所有 key 设置相同的 hash tag,则 slot 一定相同 | 1 次网络耗时 + N 次命令耗时 | 耗时非常短、实现简单 | 容易出现数据倾斜 |
总结与推荐:
- 方案 4(Hash Tag):性能最优且实现较简单,是首选方案,但务必谨慎设计
{hashtag}以避免数据倾斜(例如使用用户 ID 后几位作为 tag 的一部分); - 方案 3(按 Slot 分组并行执行):无法使用 Hash Tag 时的推荐方案;
- 方案 2 是方案 3 的简化版,可作过渡;
- 方案 1 性能过低,不推荐。
串行化执行代码实践:
@Test
void testMSet2() {
Map<String, String> map = new HashMap<>(3);
map.put("name", "Jack");
map.put("age", "21");
map.put("sex", "Male");
// 对 Map 数据进行分组:根据相同的 slot 放在一个分组,key 就是 slot,value 就是一组
Map<Integer, List<Map.Entry<String, String>>> result = map.entrySet()
.stream()
.collect(Collectors.groupingBy(
entry -> ClusterSlotHashUtil.calculateSlot(entry.getKey()))
);
// 串行执行 mset
for (List<Map.Entry<String, String>> list : result.values()) {
String[] arr = new String[list.size() * 2];
int j = 0;
for (int i = 0; i < list.size(); i++) {
j = i << 2;
Map.Entry<String, String> e = list.get(0);
arr[j] = e.getKey();
arr[j + 1] = e.getValue();
}
jedisCluster.mset(arr);
}
}
Spring 集群环境下批处理(RedisAdvancedClusterAsyncCommandsImpl 内部会按 Slot 分组后异步批量执行):
@Test
void testMSetInCluster() {
Map<String, String> map = new HashMap<>(3);
map.put("name", "Rose");
map.put("age", "21");
map.put("sex", "Female");
stringRedisTemplate.opsForValue().multiSet(map);
List<String> strings = stringRedisTemplate.opsForValue().multiGet(Arrays.asList("name", "age", "sex"));
strings.forEach(System.out::println);
}
8.5 服务器端优化:持久化配置
Redis 的持久化虽然可以保证数据安全,但也会带来额外开销,请遵循下列建议:
持久化建议:
- 用来做缓存的 Redis 实例尽量不要开启持久化功能;
- 建议关闭 RDB 持久化功能,使用 AOF 持久化;
- 利用脚本定期在 slave 节点做 RDB,实现数据备份;
- 设置合理的 rewrite 阈值,避免频繁的
bgrewriteaof; - 配置
no-appendfsync-on-rewrite = yes,禁止在 rewrite 期间做 AOF,避免因 AOF 引起的阻塞。
部署有关建议:
- Redis 实例的物理机要预留足够内存,应对 fork 和 rewrite;
- 单个 Redis 实例内存上限不要太大(例如 4G 或 8G),可以加快 fork 速度、减少主从同步和数据迁移压力;
- 不要与 CPU 密集型应用部署在一起;
- 不要与高硬盘负载应用(数据库、消息队列)一起部署。
8.6 服务器端优化:慢查询优化
慢查询:在 Redis 执行时耗时超过某个阈值的命令。
危害: Redis 是单线程的,客户端发出的指令都会进入 Redis 底层的队列执行。如果存在慢查询,会导致大量请求阻塞,引起报错。
配置阈值:
slowlog-log-slower-than:慢查询阈值,单位微秒,默认 10000,建议 1000;slowlog-max-len:慢查询日志长度,默认 128,建议 1000。
config get slowlog-log-slower-than
config set slowlog-log-slower-than 1000
查看慢查询日志:
slowlog len # 查询慢查询日志长度
slowlog get [n] # 读取 n 条慢查询日志
slowlog reset # 清空慢查询列表
也可以使用 Redis 客户端工具(如 RESP.app)在"慢查询日志"标签页查看。
8.7 服务器端优化:命令及安全配置
安全漏洞: Redis 绑定在 0.0.0.0:6379 会把服务暴露到公网,如果 Redis 没有做身份认证,会出现严重的安全漏洞。漏洞出现的核心原因:
- Redis 未设置密码;
- 利用了 Redis 的
config set命令动态修改 Redis 配置; - 使用 Root 账号权限启动 Redis。
安全配置建议:
- Redis 一定要设置密码;
- 禁止线上使用危险命令:
keys、flushall、flushdb、config set等,可以利用rename-command禁用;
# 将 CONFIG 命令重命名(相当于隐藏)
rename-command CONFIG b840fc02d524045429941cc15f59e41cb7be6c52
# 将命令重命名为空字符串,彻底禁用
rename-command CONFIG ""
bind:限制网卡,禁止外网网卡访问;- 开启防火墙;
- 不要使用 Root 账户启动 Redis;
- 尽量不要使用默认端口。
8.8 服务器端优化:Redis 内存划分和内存配置
当 Redis 内存不足时,可能导致 Key 频繁被删除、响应时间变长、QPS 不稳定等问题。内存使用率达到 90% 以上时需要警惕,并快速定位内存占用原因。
内存占用分类:
| 分类 | 说明 |
|---|---|
| 数据内存 | Redis 最主要的部分,存储键值信息。主要问题是 BigKey 问题、内存碎片问题 |
| 进程内存 | Redis 主进程运行本身占用的内存(代码、常量池等),约几兆,通常可忽略 |
| 缓冲区内存 | 包括客户端缓冲区(输入/输出)、AOF 缓冲区、复制缓冲区等。占用波动较大,不当使用 BigKey 可能导致内存溢出 |
查看内存分配状态:
info memory # 查看内存总体信息
memory stats # 查看内存分配详情
缓冲区内存的定位与解决:
| 缓冲区 | 说明 |
|---|---|
| 复制缓冲区 | 主从复制的 repl_backlog_buf,太小可能导致频繁全量复制。通过 repl-backlog-size 设置,默认 1mb |
| AOF 缓冲区 | AOF 刷盘前的缓存区域、rewrite 缓冲区。无法设置容量上限 |
| 客户端缓冲区 | 输入缓冲区最大 1G 且不能设置;输出缓冲区可以设置 |
客户端输出缓冲区的配置格式:
client-output-buffer-limit <class> <hard limit> <soft limit> <soft seconds>
class:客户端类型(normal 普通客户端、replica 主从复制客户端、pubsub PubSub 客户端);hard limit:缓冲区上限,超过后断开客户端;soft limit+soft seconds:超过 soft limit 并持续 soft seconds 秒后断开客户端。
默认配置:
# hard 或 soft 限制设置为 0 可禁用对应限制
client-output-buffer-limit normal 0 0 0
client-output-buffer-limit replica 256mb 64mb 60
client-output-buffer-limit pubsub 32mb 8mb 60
8.9 服务器端集群优化:集群还是主从
集群使用不当会带来的问题:
- 集群完整性问题:Redis 默认配置中,只要发现任意一个插槽不可用,整个集群就会停止对外服务。为了保证高可用,建议将
cluster-require-full-coverage配置为no(false); - 集群带宽问题:集群节点之间不断互相 Ping 检测状态,每次 Ping 携带插槽信息和集群状态信息。节点越多数据量越大,10 个节点的相关信息可能达到 1kb,带宽消耗高。解决途径:避免大集群(节点数最好少于 1000,业务庞大则建立多个集群)、避免在单个物理机运行太多 Redis 实例、配置合适的
cluster-node-timeout值; - 数据倾斜问题;
- 客户端性能问题;
- 命令的集群兼容性问题(如多 key 命令受 slot 限制);
- lua 和事务问题。
单体 Redis(主从 Redis)已经能达到万级别的 QPS,并且具备很强的高可用特性。 如果主从能满足业务需求,尽量不搭建 Redis 集群。
第九部分 Redis 面试题整理
本部分整理自实战学习过程中沉淀的 Redis 高频面试题(含参考答案),可用于面试前快速复习。
9.1 综合面试题
1. Redis 为什么这么快?
- 基于内存存储:数据主要存储在内存中,读写速度远快于磁盘(核心原因);
- 单线程:避免多线程切换带来的上下文开销,也避免线程安全问题,减少锁竞争等额外消耗;
- 高效的数据结构:针对不同场景设计优化过的数据结构,操作效率高;
- IO 多路复用:采用 epoll 等 IO 多路复用技术,高效处理大量并发连接;
- 精简的命令处理:命令处理流程简洁,大部分命令操作是原子性的。
2. Redisson 实现的分布式锁是通过什么机制防止锁超时的?
Redisson 通过看门狗机制(WatchDog)防止锁超时。线程获取锁后,若业务未执行完毕,看门狗会定期自动延长锁的过期时间,确保锁不会因业务执行时间过长而提前释放。
3. Redisson 中"看门狗机制"的原理、作用及实现方式
- 原理:线程获取锁成功后,Redisson 启动后台定时任务(看门狗)。若锁未被释放且未超时,看门狗每隔一定时间(默认 10 秒,为锁过期时间的 1/3)重置锁过期时间,使其保持有效;
- 作用:防止因业务执行时间超过锁初始过期时间,导致锁提前释放而引发并发问题;
- 实现方式:通过
RedissonLock中的定时任务实现,核心是基于 Redis 的pexpire命令延长锁有效期。
易错点:30 秒和 10 秒是两个不同概念:
- 默认锁过期时间(30 秒):调用
RLock.lock()未指定超时时间时,Redisson 给锁设置的初始过期时间(锁的"基础有效期",防止线程崩溃导致锁永久占用); - 看门狗续期间隔(10 秒):每隔锁过期时间的 1/3(30 秒的 1/3 = 10 秒)检查一次锁状态,线程仍持有锁则重新设置为 30 秒(续期)。
为什么这样设计? 续期间隔设置为过期时间的 1/3,是为了预留足够的"容错窗口":即使某次续期因网络延迟失败,后续 1-2 次续期仍有机会补救。简单说:30 秒是锁的"有效期",10 秒是"续命频率"。
4. 在优惠券秒杀中怎么用乐观锁、悲观锁解决一人多单超卖问题?
- 悲观锁:通过加锁(如 Redis 分布式锁、数据库行锁)确保同一用户只能有一个线程执行下单逻辑。例如使用 Redisson 分布式锁,以用户 ID 作为锁键,直接阻止并发下单;
- 乐观锁:不加锁,更新库存时通过版本号或条件判断实现。例如数据库库存记录添加版本号字段,更新时检查库存充足且版本号匹配并递增;一人多单可通过查询用户已下单数量,在更新时添加"用户未下单"的条件判断。
5. 集群环境下,如何基于分布式锁设计方案解决商品超卖问题?
- 锁粒度设计:以商品 ID 为锁键,同一商品的秒杀操作互斥,不同商品可并行处理;
- 使用 Redisson 分布式锁:利用其可重入、自动续期(看门狗)特性,避免集群环境下的锁失效问题;
- 加锁流程:用户下单时先尝试获取该商品的分布式锁 → 获锁成功后检查库存(不足则释放锁并返回失败)→ 库存充足则扣减库存、创建订单 → 释放锁;获锁失败提示稍候重试;
- 兜底措施:结合数据库事务和库存预扣减,确保锁机制失效时仍能通过数据库约束(唯一索引、库存非负检查)防止超卖。
6. 如何保证分布式锁的原子性(如加锁、解锁过程不被中断)?
- 加锁原子性:使用 Redis 的
SET key value NX PX timeout命令(Redisson 封装了该命令),确保"判断键是否存在"和"设置键值及过期时间"一步完成; - 解锁原子性:通过 Lua 脚本实现解锁——先判断锁是否属于当前线程(检查 value 是否匹配),再删除锁,保证两个操作原子性。例如:
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
7. 在秒杀场景中哪里用到了消息队列,为什么使用消息队列?
使用场景:用户下单流程中,Redis 校验下单条件(库存、限购)通过后,将下单请求作为消息发送到消息队列,再由独立线程消费消息完成数据库订单创建、库存扣减等操作。
使用原因:
- 解耦:下单请求的接收与处理分离,前端无需等待后端完成即可返回,提升响应速度;
- 削峰填谷:秒杀场景请求量激增,消息队列缓冲请求,避免直接冲击数据库;
- 异步处理:非核心流程可异步执行,提高主流程效率。
8. 还了解其他的消息队列的实现方式吗?(RocketMQ、RabbitMQ)
- RocketMQ:阿里开源分布式消息队列,支持高吞吐、低延迟,提供事务消息、定时消息等高级特性,适合大规模分布式系统(电商订单、支付场景);
- RabbitMQ:基于 AMQP 协议,支持多种消息路由模式(direct、topic、fanout 等),灵活性高,适合复杂消息分发场景(日志收集、系统解耦);
- 其他:Kafka(高吞吐,适合日志等大数据场景)。
9. 缓存穿透、缓存雪崩、缓存击穿的解决方案
缓存穿透(查询不存在的数据,请求直接穿透缓存访问数据库):
- 缓存空值:对不存在的 key 缓存空值(设置较短过期时间),避免重复穿透;
- 布隆过滤器:预先将存在的 key 存入布隆过滤器,请求先校验,不存在直接返回;
- 接口限流与校验:对恶意高频请求限流,校验请求参数合法性。
缓存雪崩(大量缓存 key 同时过期或 Redis 集群故障,请求集中冲击数据库):
- 过期时间随机化:为不同 key 设置随机过期时间,避免同时失效;
- 缓存集群高可用:部署 Redis 主从 + 哨兵或集群模式,避免单点故障;
- 熔断降级:缓存失效时限制数据库请求流量,返回默认值或错误提示;
- 预热缓存:提前加载热点数据到缓存,避免冷启动时的缓存缺失。
缓存击穿(热点 key 缓存重建过程被并发冲击,核心是"保护热点 key 的缓存重建过程"):
- 互斥锁(分布式锁):缓存失效时仅一个线程获取分布式锁查询数据库并重建缓存,其他线程等待锁释放后直接查缓存;
- 逻辑过期:缓存存储时在 value 中添加"逻辑过期时间"(不设置 Redis 真实过期时间),查询时判断逻辑过期:未过期直接返回;已过期先返回旧数据,同时异步启动线程重建缓存(重建时加分布式锁,避免重复重建)。
10. Redis 怎么实现分布式锁?
核心:依赖 SETNX 命令(互斥性)+ 过期时间(防死锁),结合 Lua 脚本保证原子性,进阶可用 Redisson 优化。
- 获取锁:
SET key 线程标识 NX EX 过期时间,仅当 key 不存在时创建(互斥),同时设置过期时间(防死锁); - 释放锁:先判断锁的线程标识是否为当前线程,一致则删除锁,通过 Lua 脚本将"判断 + 删除"合并为原子操作(防误删);
- 进阶优化:使用 Redisson 框架,支持可重入锁、锁重试、看门狗自动续期、主从一致性等高级特性。
9.2 Redis 面经速记
什么是缓存穿透?怎么解决?
候选人话术: 缓存穿透是指查询一个一定不存在的数据,如果从存储层查不到数据则不写入缓存,将导致这个不存在的数据每次请求都要到 DB 去查询,可能导致 DB 挂掉。这种情况大概率是遭到了攻击。解决方案通常用布隆过滤器。
介绍一下布隆过滤器
- bitmap(位图):以 bit 位为单位的数组,每个单元只能存二进制 0 或 1;
- 作用:检索一个元素是否在一个集合中;
- 存储数据:数据 id 通过多个 hash 函数获取 hash 值,把数组对应位置改为 1;
- 查询数据:使用相同 hash 函数获取 hash 值,判断对应位置是否都为 1。
候选人话术: 我们当时使用的是 Redisson 实现的布隆过滤器。底层先初始化一个比较大的二进制数组(初始都是 0),一个 key 来了之后经过 3 次 hash 计算、模数组长度找到下标,把数组中的 0 改为 1,用三个位置标明一个 key 的存在。查找过程同理。
缺点: 布隆过滤器有误判的可能,我们一般设置误判率不超过 5%(误判是必然存在的,要不就得增加数组长度)。5% 以内的误判率一般项目都能接受,不至于在高并发下压垮数据库。误判原因:走哈希思想,就可能存在哈希冲突。
什么是缓存击穿?怎么解决?
候选人话术: 缓存击穿是指对设置了过期时间的 key,缓存在某个时间点过期时,恰好这个时间点对该 key 有大量并发请求,这些请求发现缓存过期一般都会从后端 DB 加载数据并回设缓存,大并发请求可能会瞬间把 DB 压垮。
解决方案有两种:
- 互斥锁:缓存失效时不立即 load db,先用 Redis 的 setnx 设置互斥锁,操作成功返回后再 load db 并回设缓存,否则重试 get 缓存的方法;
- 逻辑过期:① 设置 key 时把过期时间字段一并存入缓存,不给 key 设置真实过期时间;② 查询时从 redis 取出数据后判断时间是否过期;③ 过期则开启另外一个线程进行数据同步,当前线程正常返回数据(非最新)。
两种方案各有利弊: 选择数据强一致性建议用分布式锁(性能不高、需要等待、可能产生死锁);选择逻辑过期则优先高可用性(性能高,但数据同步做不到强一致)。
什么是缓存雪崩?怎么解决?
候选人话术: 缓存雪崩是指设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到 DB,DB 瞬时压力过重雪崩。
与缓存击穿的区别: 雪崩是很多 key,击穿是某一个 key。
解决方案主要是将缓存失效时间分散开,比如在原有失效时间基础上增加一个随机值(如 1-5 分钟随机),这样每个缓存过期时间的重复率降低,很难引发集体失效事件。
《缓存三兄弟》口诀:
穿透无中生有 key,布隆过滤 null 隔离。
缓存击穿过期 key,锁与非期解难题。
雪崩大量过期 key,过期时间要随机。
面试必考三兄弟,可用限流来保底。
Redis 作为缓存,MySQL 的数据如何与 Redis 同步(双写一致性)?
双写一致性:修改数据库数据时同步更新缓存,保证缓存和数据库一致。
- 读操作:缓存命中直接返回;缓存未命中查询数据库,写入缓存并设定超时时间;
- 写操作:采用延迟双删:删除缓存 → 修改数据库(主库)→ 延时后删除缓存(从库)。
候选人话术(强一致场景): 项目里需要数据库与 Redis 高度一致,我们采用 Redisson 实现的读写锁保证强一致性。读的时候添加共享锁(读读不互斥,读写互斥);更新数据时添加排他锁(读写、读读都互斥),保证写数据的同时其他线程不能读数据,避免脏数据。注意读方法和写方法需要使用同一把锁。
关于延迟双删: 延迟双删在更新数据库后延时删除缓存,但延时多久不好确定,延时的过程中可能出现脏数据,不能保证强一致性,所以没有采用。
候选人话术(最终一致场景,Canal): 数据同步可以有一定延时(符合大部分业务),我们采用阿里的 Canal 组件:不需要更改业务代码,部署一个 Canal 服务。Canal 把自己伪装成 MySQL 的一个从节点,MySQL 数据更新后,Canal 读取 binlog 数据,通过 Canal 客户端获取数据更新缓存。
Redis 如何实现数据持久化?RDB 和 AOF 的区别?
候选人话术: Redis 提供两种持久化方式:RDB 和 AOF。
- RDB:快照文件,把内存数据写到磁盘上,宕机后从快照恢复数据。由主进程执行
save会阻塞所有命令;bgsave开启子进程执行避免主进程受影响。触发机制可在 redis.conf 配置(如save 900 1:900 秒内至少 1 个 key 被修改则执行 bgsave)。RDB 执行原理:fork 主进程得到子进程,子进程共享主进程内存数据(copy-on-write 写时复制:主进程读操作访问共享内存,写操作拷贝一份数据再写); - AOF:Append Only File,每个写命令都记录到 AOF 文件(命令日志)。默认关闭,需配置
appendonly yes。刷盘策略:always(同步刷盘,几乎不丢数据但性能影响大)、everysec(每秒刷盘,最多丢 1 秒数据,默认方案)、no(操作系统控制,性能最好但可能丢大量数据)。AOF 文件比 RDB 大得多,可通过bgrewriteaof重写(用最少命令达到相同效果)。
RDB 和 AOF 对比:
| 对比维度 | RDB | AOF |
|---|---|---|
| 持久化方式 | 定时对整个内存做快照 | 记录每一次执行的命令 |
| 数据完整性 | 不完整,两次备份之间会丢失 | 相对完整,取决于刷盘策略 |
| 文件大小 | 有压缩,文件体积小 | 记录命令,文件体积很大 |
| 宕机恢复速度 | 很快 | 慢 |
| 数据恢复优先级 | 低,数据完整性不如 AOF | 高,数据完整性更高 |
| 系统资源占用 | 高,大量 CPU 和内存消耗 | 低,主要是磁盘 IO(重写时占用大量 CPU 和内存) |
| 使用场景 | 可容忍数分钟数据丢失,追求更快启动速度 | 对数据安全性要求较高 |
对数据安全性要求较高时,实际开发中往往结合两者使用。
这两种方式哪种恢复得比较快?
候选人话术: RDB 是二进制文件,保存时体积小,恢复比较快,但有可能丢数据。我们通常在项目中也会用 AOF 来恢复数据,虽然 AOF 恢复速度慢一些,但丢数据风险小很多。AOF 文件里可以设置刷盘策略,我们当时设置的是每秒批量写入一次命令。
Redis 的数据过期策略有哪些?
候选人话术: Redis 提供两种数据过期删除策略:
- 惰性删除:设置 key 过期时间后不去管它,当需要该 key 时检查是否过期,过期则删除,否则返回该 key。优点:对 CPU 友好(只在使用时检查);缺点:对内存不友好(过期但一直没使用的 key 长期占用内存);
- 定期删除:每隔一段时间检查一些 key,删除其中过期的 key(从一定数量数据库中取出一定数量的随机 key 检查)。SLOW 模式为定时任务,执行频率默认 10hz,每次不超过 25ms(可修改 redis.conf 的
hz调整);FAST 模式执行频率不固定,两次间隔不低于 2ms,每次耗时不超过 1ms。优点:限制删除时长和频率减少 CPU 影响,能有效释放过期键内存;缺点:难以确定删除执行的时长和频率。
Redis 的过期删除策略是 惰性删除 + 定期删除 两种策略配合使用。
Redis 的数据淘汰策略有哪些?
数据淘汰策略:Redis 内存不够用时,向 Redis 添加新 key,Redis 按某种规则删除内存中的数据。
Redis 支持 8 种策略:
| 策略 | 说明 |
|---|---|
noeviction |
不淘汰任何 key,内存满时不允许写入新数据(默认) |
volatile-ttl |
对设置了 TTL 的 key,TTL 越小越先被淘汰 |
allkeys-random |
对全体 key,随机淘汰 |
volatile-random |
对设置了 TTL 的 key,随机淘汰 |
allkeys-lru |
对全体 key,基于 LRU 算法淘汰 |
volatile-lru |
对设置了 TTL 的 key,基于 LRU 算法淘汰 |
allkeys-lfu |
对全体 key,基于 LFU 算法淘汰 |
volatile-lfu |
对设置了 TTL 的 key,基于 LFU 算法淘汰 |
LRU(Least Recently Used)最近最少使用:用当前时间减去最后一次访问时间,这个值越大淘汰优先级越高。
LFU(Least Frequently Used)最少频率使用:统计每个 key 的访问频率,值越小淘汰优先级越高。
使用建议:
- 优先使用
allkeys-lru,适合有冷热数据区分的业务; - 访问频率差别不大、无明显冷热区分,用
allkeys-random; - 有置顶需求,用
volatile-lru,置顶数据不设置过期时间(一直不被删除); - 有短时高频访问数据,用
allkeys-lfu或volatile-lfu。
候选人话术: 默认是 noeviction,内存不足直接报错。我们在项目里设置的 allkeys-lru,把最近最常访问的数据留在缓存中。
数据库有 1000 万数据,Redis 只能缓存 20w 数据,如何保证 Redis 中的数据都是热点数据?
候选人话术: 使用 allkeys-lru(挑选最近最少使用的数据淘汰)淘汰策略,留下来的都是经常访问的热点数据。
Redis 的内存用完了会发生什么?
候选人话术: 要看 Redis 的数据淘汰策略是什么。默认配置下内存用完直接报错。我们当时设置的是 allkeys-lru 策略,把最近最常访问的数据留在缓存中。
Redis 分布式锁如何实现?
候选人话术: Redis 实现分布式锁主要利用 setnx(SET if not exists)命令。由于 Redis 是单线程的,用这个命令之后,只能有一个客户端对某一个 key 设置值,在没有过期或删除 key 的时候,其他客户端不能设置这个 key。
如何控制 Redis 实现分布式锁的有效时长?
候选人话术: Redis 的 setnx 指令不好控制这个问题,我们当时采用的是 Redisson 实现。
在 Redisson 中需要手动加锁,可以控制锁的失效时间和等待时间。当锁住的业务还没有执行完成时,Redisson 引入了看门狗机制:每隔一段时间检查当前业务是否还持有锁,持有就增加加锁的持有时间;业务执行完成后再释放锁。
还有一个好处:高并发下某个业务可能执行很快,客户 1 持有锁时客户 2 来了不会马上被拒绝,它会自旋不断尝试获取锁,客户 1 释放后客户 2 马上持有锁,性能得到提升。
Redisson 实现的分布式锁是可重入的吗?
候选人话术: 是可以重入的,这样做是为了避免死锁的产生。重入在内部就是判断是否是当前线程持有的锁,是则计数 +1,释放锁则计数 -1。存储时采用 hash 结构:大 key 按业务定制,小 key 是当前线程的唯一标识,value 是当前线程重入的次数。
Redisson 实现的分布式锁能解决主从一致性问题吗?
候选人话术: 不能。比如线程 1 加锁成功后,master 节点数据会异步复制到 slave 节点,此时持有锁的 master 宕机,slave 被提升为新的 master,线程 2 再加锁会在新 master 上加锁成功,出现两个节点同时持有一把锁的问题。
可以用 Redisson 的红锁(RedLock)解决:不能只在一个 Redis 实例上创建锁,而是要在多个 Redis 实例上创建锁(n/2 + 1),要求大多数节点上都成功创建锁。但红锁需要同时在多个节点加锁,性能低、运维成本高,官方也已废弃,一般项目中不会直接使用。
如果业务非要保证数据的强一致性,该怎么解决?
候选人话术: Redis 本身支持高可用,做到强一致性非常影响性能。如果有强一致性要求高的业务,建议使用 Zookeeper 实现的分布式锁,它可以保证强一致性。
Redis 集群有哪些方案?
候选人话术: Redis 提供的集群方案总共有三种:主从复制、哨兵模式、Redis 分片集群。
介绍一下主从同步
候选人话术: 单节点 Redis 的并发能力是有上限的,要进一步提高并发能力,可以搭建主从集群,实现读写分离。一般一主多从,主节点负责写数据,从节点负责读数据,主节点写入数据后需要把数据同步到从节点。
主从同步数据的流程
候选人话术: 主从同步分两个阶段:全量同步和增量同步。
全量同步(从节点第一次与主节点建立连接时):
- 从节点请求主节点同步数据,携带自己的 replication id 和 offset 偏移量;
- 主节点判断是否是第一次请求(依据 replication id 是否一致);
- 第一次则把主节点的 replication id 和 offset 发给从节点,让从节点与主节点信息一致;
- 主节点执行 bgsave 生成 RDB 文件发给从节点,从节点清空本地数据后加载 RDB;
- RDB 生成执行期间到达主节点的请求,以命令方式记录到缓冲区(repl_backlog 日志),最后把日志文件发给从节点,保证完全一致。后期再同步数据都依赖这个日志。
增量同步(从节点服务重启后数据不一致时):
- 从节点请求主节点同步数据;
- 主节点判断不是第一次请求,获取从节点的 offset 值;
- 主节点从命令日志(repl_backlog)中获取 offset 值之后的数据,发送给从节点进行数据同步。
怎么保证 Redis 的高并发高可用?
候选人话术: 搭建主从集群 + 哨兵模式。哨兵模式可以实现主从集群的自动故障恢复,包含对主从服务的监控、自动故障恢复、通知:
- 监控:Sentinel 不断检查 master 和 slave 是否按预期工作(基于心跳机制,每隔 1 秒向每个实例发送 ping 命令);
- 自动故障恢复:master 故障时,Sentinel 将一个 slave 提升为 master,故障实例恢复后也以新的 master 为主;
- 通知:Sentinel 充当 Redis 客户端的服务发现来源,故障转移时将最新信息推送给客户端。
主观下线与客观下线: 某个 Sentinel 节点发现某实例未在规定时间响应,则认为该实例主观下线;超过指定数量(quorum)的 Sentinel 都认为主观下线,则该实例客观下线(quorum 值最好超过 Sentinel 实例数量的一半)。
哨兵选主规则: 先判断主从节点断开时间长短(超过指定值排除)→ 再判断 slave-priority(越小优先级越高)→ 相同则判断 offset(越大优先级越高)→ 最后判断运行 id 大小(越小优先级越高)。
你们使用 Redis 是单点还是集群,采用什么集群方式?
候选人话术: 我们当时使用的是主从(1 主 1 从)+ 哨兵。一般单节点不超过 10G 内存,Redis 内存不足可以给不同服务分配独立的 Redis 主从节点。尽量不做分片集群——集群维护麻烦,节点间心跳检测和数据通信消耗大量网络带宽,而且无法使用 Lua 脚本和事务。
Redis 集群脑裂该怎么解决?
候选人话术: 脑裂是由于 master、slave 和 sentinel 处于不同的网络分区,sentinel 没有心跳感知到 master,通过选举提升了一个 slave 为 master,这样就存在两个 master。客户端还在旧 master 写入数据,新节点无法同步;网络恢复后,sentinel 将旧 master 降为 slave,从新 master 同步数据,导致旧 master 中大量数据丢失。
解决: 修改 Redis 配置,设置最少的 slave 节点个数(如 min-replicas-to-write 1,至少一个从节点才同步数据)和设置主从数据复制与同步的延迟时间(如 min-replicas-max-lag 5,延迟不能超过 5 秒),达不到要求就拒绝请求,避免大量数据丢失。
Redis 的分片集群有什么作用?
候选人话术: 分片集群主要解决海量数据存储问题。集群中有多个 master,每个 master 保存不同数据,还可以给每个 master 设置多个 slave 节点,增大集群高并发能力。每个 master 之间通过 ping 监测彼此健康状态,类似哨兵模式。客户端请求可访问集群任意节点,最终都会被转发到正确节点。
Redis 分片集群中数据是怎么存储和读取的?
候选人话术: Redis 集群引入了哈希槽的概念,有 16384 个哈希槽,集群中每个主节点绑定一定范围的哈希槽。key 通过 CRC16 校验后对 16384 取模决定放在哪个槽,通过槽找到对应的节点存储;取值逻辑一样。
Redis 是单线程的,为什么还那么快?
候选人话术:
- 完全基于内存,C 语言编写;
- 采用单线程,避免不必要的上下文切换和竞争条件;
- 使用多路 IO 复用模型,非阻塞 IO。
如 bgsave 和 bgrewriteaof 都是在后台执行,不影响主线程正常使用,不会产生阻塞。
解释一下 I/O 多路复用模型?
候选人话术: I/O 多路复用是指利用单个线程同时监听多个 Socket,在某个 Socket 可读、可写时得到通知,从而避免无效等待,充分利用 CPU 资源。目前 I/O 多路复用多采用 epoll 模式实现:在通知用户进程 Socket 就绪的同时,把已就绪的 Socket 写入用户空间,不需要挨个遍历 Socket 判断是否就绪,提升了性能。
补充知识:
- 用户空间和内核空间:用户空间只能执行受限命令(Ring3),不能直接调用系统资源,必须通过内核提供的接口访问;内核空间可以执行特权命令(Ring0),调用一切系统资源。Linux 为提高 IO 效率,在用户空间和内核空间都加入缓冲区;
- 阻塞 IO(Blocking IO):两个阶段(等待数据、从内核拷贝数据到用户空间)都必须阻塞等待;
- 非阻塞 IO(Nonblocking IO):第一阶段非阻塞(反复调用 recvfrom 直到数据就绪),第二阶段阻塞。忙等机制导致 CPU 空转、使用率暴增,性能没有提高;
- IO 多路复用(IO Multiplexing):进程调用 select 同时监听多个 socket 并阻塞等待,任意 socket 数据就绪则返回 readable,再调用 recvfrom 读取数据。
select和poll只通知有 Socket 就绪但不确定是哪个,需要逐个遍历确认;epoll会把已就绪的 Socket 写入用户空间; - Redis 网络模型:Redis 通过 IO 多路复用提高网络性能,封装不同的多路复用实现,提供统一的高性能事件库(连接应答处理器、命令请求处理器、命令回复处理器)。Redis 6.0 之后,命令回复处理器使用多线程处理回复事件,命令请求处理器中命令转换使用多线程,命令执行依然是单线程。

浙公网安备 33010602011771号