Redis实战学习笔记

Redis 实战学习笔记:缓存 · 秒杀 · 分布式锁 · 消息队列(纯享版)

本文是一份以「商户点评 / 电商秒杀类业务」为背景的 Redis 实战学习笔记整理稿,内容聚焦 Redis 技术本身:缓存设计与一致性、缓存三大问题(穿透/击穿/雪崩)、高并发秒杀、分布式锁(原生 + Redisson)、消息队列(List / PubSub / Stream)以及生产环境最佳实践。
文中示例代码为 Spring Boot + MyBatis-Plus 环境,包名统一使用 com.example,与具体项目无关,可直接迁移到自己的项目中复用。
笔记内容整理自公开网络学习资料,版权归原作者所有,此处仅供学习交流使用。


目录


第一部分 基于 Redis 实现短信登录(会话共享)

1.1 基于 Session 实现登录流程

传统的单体 Web 应用使用 Session 保存登录态,整体流程如下:

  1. 发送短信验证码:校验手机号 → 生成验证码 → 保存到 Session → 发送短信。
  2. 短信验证码登录/注册:校验手机号与验证码 → 根据手机号查用户 → 不存在则创建新用户 → 保存用户信息到 Session。
  3. 校验登录状态:请求携带 Cookie → 从 Session 获取用户 → 判断用户是否存在 → 存在则保存到 ThreadLocal 并放行,不存在则拦截。
flowchart TD A[发送短信验证码] --> A1[提交手机号] --> A2{校验手机号} -->|符合| A3[生成验证码] --> A4[保存验证码到 Session] --> A5[发送验证码] A2 -->|不符合| A6[返回错误] B[短信验证码登录/注册] --> B1[提交手机号和验证码] --> B2{校验验证码} -->|一致| B3[根据手机号查询用户] --> B4{用户是否存在} -->|不存在| B5[创建新用户] --> B6[保存用户到数据库] B4 -->|存在| B7[保存用户到 Session] C[校验登录状态] --> C1[请求携带 Cookie] --> C2[从 Session 获取用户] --> C3{用户是否存在} -->|有| C4[保存用户到 ThreadLocal] --> C5[放行] C3 -->|没有| C6[拦截]

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 服务器。但存在两个大问题:

  1. 每台服务器都有一份完整的 Session 数据,服务器压力过大
  2. 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 需要满足两点:

  1. key 要具有唯一性
  2. key 要方便携带

如果直接用手机号存储,属于敏感数据,且容易泄露业务数据(例如当日下单量)。更合理的做法是:后台生成一个随机串 token 作为 key,前端携带 token 完成整体逻辑。

3、整体访问流程

flowchart LR subgraph login_check[校验登录状态] A[请求携带 Token] --> B[从 Redis 获取用户<br/>以随机 token 为 key] --> C{用户是否存在} C -->|有| D[保存用户到 ThreadLocal] --> E[放行] C -->|没有| F[拦截] end subgraph login_register[登录注册] G[提交手机号和验证码] --> H{校验验证码} -->|一致| I[根据手机号查询用户] --> J{用户是否存在} J -->|不存在| K[创建新用户<br/>保存到数据库] J -->|存在| L[保存用户到 Redis] --> M[返回 token 给客户端] end Redis[(Redis)] B -.-> Redis L -.-> Redis

由于放弃了 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,后执行)。

flowchart LR A[客户端请求] --> B[拦截器1:拦截一切路径<br/>1.获取token 2.查询Redis的用户<br/>3.保存到ThreadLocal 4.刷新token有效期 5.放行] B --> C[拦截器2:拦截需要登录的路径<br/>查询ThreadLocal的用户<br/>不存在则拦截,存在则继续] C --> D[Controller]

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 之间的相互转换(beanToMapfillBeanWithMap)。

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。

flowchart TD A[提交业务 id] --> B[从 Redis 查询缓存] B --> C{缓存是否命中} C -->|命中| D[返回数据] C -->|未命中| E[根据 id 查询数据库] E --> F{数据是否存在} F -->|存在| G[将数据写入 Redis<br/>设置过期时间] --> D F -->|不存在| H[返回 404]

代码实现

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 再更新数据库,旧数据覆盖新数据;
  • 先操作数据库,再删除缓存(发生错误的概率极低):推荐。
flowchart TD subgraph e1[方案一:先删除缓存,再操作数据库<br/>错误概率高] T1[线程1: 1.删除缓存] --> T2[线程2: 2.查询缓存未命中<br/>查询数据库] T2 --> T3[线程2: 3.写入缓存<br/>旧数据] T1 --> T4[线程1: 4.更新数据库 v=20] end subgraph e2[方案二:先操作数据库,再删除缓存<br/>错误概率极低] S1[线程1: 1.查询缓存未命中<br/>查询数据库] S2[线程2: 2.更新数据库 v=20] S3[线程2: 3.删除缓存] S4[线程1: 4.写入缓存] S1 --> S2 --> S3 --> S4 end

2.4 实现数据双写一致

需求: 给查询缓存添加超时剔除和主动更新策略:

  1. 根据 id 查询时,如果缓存未命中,则查询数据库,将结果写入缓存,并设置超时时间;
  2. 根据 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 服务宕机,导致大量请求到达数据库,带来巨大压力。

解决方案:

  1. 给不同的 Key 的 TTL 添加随机值
  2. 利用 Redis 集群(哨兵模式、集群模式)提高服务的可用性;
  3. 给缓存业务添加降级限流策略(Nginx 或 Spring Cloud Gateway);
  4. 给业务添加多级缓存(Guava 或 Caffeine)。

2.8 缓存击穿问题及解决思路

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

问题产生过程: 线程 1 查询缓存未命中后本应去查数据库重建缓存,在线程 1 走完逻辑之前,线程 2、3、4 同时过来,都查不到缓存,于是同一时刻都去访问数据库,数据库压力过大。

常见的解决方案有两种:

方案一:互斥锁

使用互斥锁确保同一时间只有一个线程重建缓存。锁实现互斥性,避免数据库压力过大,但会将查询从并行变成串行,影响性能。可以使用 tryLock + double check 解决。

flowchart TD subgraph t1[线程1] A1[1.查询缓存未命中] --> A2[2.获取互斥锁成功] --> A3[3.查询数据库重建缓存] --> A4[4.写入缓存] --> A5[5.释放锁] end subgraph t2[线程2] B1[1.查询缓存未命中] --> B2[2.获取互斥锁失败] --> B3[3.休眠一会儿再重试] --> B4[4.重试] --> B5[5.缓存命中] end

方案二:逻辑过期

把过期时间设置在 Redis 的 value 中(不设置 Redis 真实的过期时间),由业务逻辑自行判断。线程 1 查询缓存发现逻辑过期 → 获取互斥锁 → 开启新线程重建缓存 → 线程 1 直接返回旧数据;其他线程拿不到锁,也直接返回旧数据。等重建完成后再返回正确数据。

flowchart TD subgraph t1a[线程1] C1[1.查询缓存,发现逻辑时间已过期] --> C2[2.获取互斥锁成功] --> C3[3.开启新线程] --> C4[4.返回过期数据] end subgraph t2a[新线程-重建] D1[1.查询数据库重建缓存数据] --> D2[2.写入缓存,重置逻辑过期时间] --> D3[3.释放锁] end subgraph t3a[线程3] E1[1.查询缓存,发现逻辑时间已过期] --> E2[2.获取互斥锁失败] --> E3[3.返回过期数据] end subgraph t4a[线程4] F1[1.命中缓存,并且没有过期] end

方案对比:

方案 优点 缺点
互斥锁 没有额外的内存消耗;保证一致性;实现简单 线程需要等待,性能受影响;可能有死锁风险
逻辑过期 线程无需等待,性能较好 不保证一致性;有额外内存消耗;实现复杂

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 判断过期时间:未过期直接返回;过期则开启独立线程重构数据,当前线程先返回旧数据,重构完成后释放互斥锁。

flowchart TD A[开始] --> B[提交 id] --> C[从 Redis 查询缓存] C --> D{缓存是否命中} D -->|未命中| E[返回空] D -->|命中| F{缓存是否过期} F -->|未过期| G[返回数据] F -->|过期| H{尝试获取互斥锁} H -->|否| G H -->|是| I[开启独立线程] --> J[根据 id 查询数据库] --> K[将数据写入 Redis<br/>设置逻辑过期时间] --> L[释放互斥锁]

步骤一:封装数据实体(不修改原实体类,无侵入)

@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 存在两个问题:

  1. id 的规律性太明显:用户或商业对手很容易猜测敏感信息(如一天内卖出了多少单);
  2. 受单表数据量的限制: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 实现秒杀下单

下单时需要判断两点:

  1. 秒杀是否开始或结束(未开始 / 已结束则无法下单);
  2. 库存是否充足(不足则无法下单)。
flowchart TD A[开始] --> B[提交优惠券 id] --> C[查询优惠券信息] C --> D{秒杀是否开始} D -->|否| E[返回异常结果] D -->|是| F{库存是否充足} F -->|否| E F -->|是| G[扣减库存] --> H[创建订单] --> I[返回订单 id]

初版实现:

@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 法

核心思想:更新变量时,先比较当前值是否与预期值一致,一致则更新为新值,否则更新失败。

  1. 比较旧值:将变量的当前值与预期旧值比较;
  2. 更新新值:一致则将值更新为新值;
  3. 返回结果:成功返回 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 查询订单是否存在"的逻辑。

flowchart TD A[提交优惠券 id] --> B[查询优惠券信息] --> C{秒杀是否开始} -->|是| D{库存是否充足} C -->|否| X[返回异常] D -->|否| X D -->|是| E[根据优惠券 id 和用户 id 查询订单] --> F{订单是否存在} F -->|存在| X F -->|不存在| G[扣减库存] --> H[创建订单] --> I[返回订单 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 锁失效的原因,此时需要使用分布式锁

flowchart LR subgraph JVM1 T1[线程1<br/>获取互斥锁成功] --> T1a[查询订单/判断/插入] T2[线程2<br/>获取互斥锁失败<br/>等待锁释放] --> T2a[查询订单/判断/插入] end subgraph JVM2 T3[线程3<br/>获取互斥锁成功] --> T3a[查询订单/判断/插入] T4[线程4<br/>获取互斥锁失败<br/>等待锁释放] --> T4a[查询订单/判断/插入] end JVM1 -.互斥范围仅限本 JVM.-> JVM2

第四部分 分布式锁

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) 自旋抢锁。
flowchart TD A[开始] --> B{锁是否存在} B -->|否| C[获取锁并添加线程标示<br/>设置锁有效期] --> Z[执行业务] B -->|是| D{锁标示是否是自己} D -->|否| E[获取锁失败] D -->|是| F[锁计数 +1<br/>重置锁有效期] --> Z Z --> G{判断锁计数是否为 0} G -->|否| H[释放锁<br/>锁计数 -1] G -->|是| I[锁已释放]

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 中并没有锁信息,锁信息丢失

flowchart LR A[Java应用] -->|1.获取锁 SET lock thread1 NX EX 10| B[Redis Master<br/>lock = thread1] B -.主从同步.-> C[Redis Slave] B -.宕机.-> D[Redis Master<br/>锁失效]

MultiLock(联锁)解决: 不使用主从,每个节点地位相同。加锁逻辑需要写入到每一个节点,只有所有服务器都写入成功,才算加锁成功。只要有一个节点拿不到,都不能算加锁成功,保证了加锁的可靠性。

加锁原理: 将多个锁添加到一个集合中,用 while 循环不停尝试拿锁,总加锁时间 = 锁个数 × 1500ms。假设 3 个锁,时间就是 4500ms。在 4500ms 内所有锁都加锁成功才算成功;若有线程加锁失败则重试;超时未释放的锁通过 del 删除已成功获取的锁。

flowchart TD A[客户端] --> B[RedissonMultiLock<br/>LockList.add lock1 lock2 lock3] B --> C{while true tryLock} C -->|成功| D[return] C -->|失败| E[总加锁时间 = size * 1500ms<br/>遍历所有 lock 异步加锁 tryLock 4500ms -1] E --> F{4500ms 内全部加锁成功} F -->|是| D F -->|否| G[unlockInner acquiredLocks<br/>释放已获取的锁,return false]

第六部分 秒杀性能优化(异步化)

6.1 异步秒杀思路

回顾一下下单流程: 用户发起请求 → Nginx → Tomcat 串行执行:

  1. 查询优惠券;
  2. 判断秒杀库存是否足够;
  3. 查询订单;
  4. 校验是否是一人一单;
  5. 扣减库存;
  6. 创建订单。

这六步中有很多操作要访问数据库,且是一个线程串行执行,程序执行很慢。如何加速?

优化思路: 把耗时较长的下单流程从同步改为异步

flowchart LR A[移动端请求] --> B[Nginx] --> C[Redis<br/>判断秒杀库存<br/>校验一人一单] C --> D[保存优惠券id、用户id、订单id<br/>到消息队列/阻塞队列] D --> E[Tomcat 异步读取队列信息<br/>查询优惠券→判断库存→查询订单→校验一人一单→减库存→创建订单] E --> F[(MySQL 集群)] C -.-> G[返回订单 id 给前端]

下单时,调用 Lua 脚本在 Redis 中原子性地判断库存是否充足(value > 0)且用户能否下单(Set 集合无对应记录)。均满足则扣库存、存 userId,并返回 0。返回 0 则将信息存入队列,通过异步线程下单;前端根据返回的订单 id 判断下单是否成功。

6.2 Redis 完成秒杀资格判断

需求:

  1. 新增秒杀优惠券的同时,将优惠券信息保存到 Redis;
  2. 基于 Lua 脚本判断秒杀库存、一人一单,决定用户是否抢购成功;
  3. 抢购成功后,将优惠券 id 和用户 id 封装后存入阻塞队列;
  4. 开启线程任务,不断从阻塞队列中获取信息,实现异步下单。

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);
  • 生产者:发送消息到消息队列;
  • 消费者:从消息队列获取消息并处理消息。
flowchart LR A[生产者<br/>判断秒杀时间和库存<br/>校验一人一单<br/>发送优惠券id和用户id] --> B[(Message Queue)] --> C[消费者<br/>接收消息完成下单]

使用队列的好处在于解耦: 用户下单后,先用 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 ...]
flowchart LR A[生产者<br/>publish order.queue msg1] --> B[(Message Queue)] B --> C[消费者1<br/>subscribe order.queue<br/>msg1] B --> D[消费者2<br/>psubscribe order.*<br/>msg1]

优点: 采用发布订阅模型,支持多生产、多消费。

缺点:

  • 不支持数据持久化: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 命令的特点:

  1. 消息可回溯:可以指定起始 ID 读取历史消息;
  2. 一个消息可以被多个消费者读取:消息不会因为一个消费者读取而消失;
  3. 可以阻塞读取BLOCK 参数可在队列为空时阻塞等待,减少轮询频率;
  4. 有消息漏读的风险:起始 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 消息队列实现异步秒杀下单

需求:

  1. 创建一个 Stream 类型的消息队列,名为 stream.orders
  2. 修改秒杀下单 Lua 脚本:认定有抢购资格后,直接向 stream.orders 添加消息(voucherId、userId、orderId);
  3. 项目启动时,开启一个线程任务,尝试获取 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 类型键值,底层根据值的大小和内容采用三种编码格式:intembstrraw

  • 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

  1. redis-cli --bigkeys:遍历分析所有 key,返回整体统计信息与每个数据类型的 Top1 big key;
redis-cli -a 密码 --bigkeys
  1. scan 扫描:编程利用 scan 扫描所有 key,用 strlenhlen 等命令判断长度(不建议使用 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"));
}
  1. 第三方工具:利用 Redis-Rdb-Tools( https://github.com/sripathikrishnan/redis-rdb-tools )分析 RDB 快照文件,全面分析内存使用情况;
  2. 网络监控:自定义工具监控进出 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 的持久化虽然可以保证数据安全,但也会带来额外开销,请遵循下列建议:

持久化建议:

  1. 用来做缓存的 Redis 实例尽量不要开启持久化功能;
  2. 建议关闭 RDB 持久化功能,使用 AOF 持久化;
  3. 利用脚本定期在 slave 节点做 RDB,实现数据备份;
  4. 设置合理的 rewrite 阈值,避免频繁的 bgrewriteaof
  5. 配置 no-appendfsync-on-rewrite = yes,禁止在 rewrite 期间做 AOF,避免因 AOF 引起的阻塞。

部署有关建议:

  1. Redis 实例的物理机要预留足够内存,应对 fork 和 rewrite;
  2. 单个 Redis 实例内存上限不要太大(例如 4G 或 8G),可以加快 fork 速度、减少主从同步和数据迁移压力;
  3. 不要与 CPU 密集型应用部署在一起;
  4. 不要与高硬盘负载应用(数据库、消息队列)一起部署。

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 没有做身份认证,会出现严重的安全漏洞。漏洞出现的核心原因:

  1. Redis 未设置密码;
  2. 利用了 Redis 的 config set 命令动态修改 Redis 配置;
  3. 使用 Root 账号权限启动 Redis。

安全配置建议:

  1. Redis 一定要设置密码
  2. 禁止线上使用危险命令:keysflushallflushdbconfig set 等,可以利用 rename-command 禁用;
# 将 CONFIG 命令重命名(相当于隐藏)
rename-command CONFIG b840fc02d524045429941cc15f59e41cb7be6c52

# 将命令重命名为空字符串,彻底禁用
rename-command CONFIG ""
  1. bind:限制网卡,禁止外网网卡访问;
  2. 开启防火墙;
  3. 不要使用 Root 账户启动 Redis;
  4. 尽量不要使用默认端口。

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 服务器端集群优化:集群还是主从

集群使用不当会带来的问题:

  1. 集群完整性问题:Redis 默认配置中,只要发现任意一个插槽不可用,整个集群就会停止对外服务。为了保证高可用,建议将 cluster-require-full-coverage 配置为 nofalse);
  2. 集群带宽问题:集群节点之间不断互相 Ping 检测状态,每次 Ping 携带插槽信息和集群状态信息。节点越多数据量越大,10 个节点的相关信息可能达到 1kb,带宽消耗高。解决途径:避免大集群(节点数最好少于 1000,业务庞大则建立多个集群)、避免在单个物理机运行太多 Redis 实例、配置合适的 cluster-node-timeout 值;
  3. 数据倾斜问题
  4. 客户端性能问题
  5. 命令的集群兼容性问题(如多 key 命令受 slot 限制);
  6. lua 和事务问题

单体 Redis(主从 Redis)已经能达到万级别的 QPS,并且具备很强的高可用特性。 如果主从能满足业务需求,尽量不搭建 Redis 集群。


第九部分 Redis 面试题整理

本部分整理自实战学习过程中沉淀的 Redis 高频面试题(含参考答案),可用于面试前快速复习。

9.1 综合面试题

1. Redis 为什么这么快?

  1. 基于内存存储:数据主要存储在内存中,读写速度远快于磁盘(核心原因);
  2. 单线程:避免多线程切换带来的上下文开销,也避免线程安全问题,减少锁竞争等额外消耗;
  3. 高效的数据结构:针对不同场景设计优化过的数据结构,操作效率高;
  4. IO 多路复用:采用 epoll 等 IO 多路复用技术,高效处理大量并发连接;
  5. 精简的命令处理:命令处理流程简洁,大部分命令操作是原子性的。

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. 缓存穿透、缓存雪崩、缓存击穿的解决方案

缓存穿透(查询不存在的数据,请求直接穿透缓存访问数据库):

  1. 缓存空值:对不存在的 key 缓存空值(设置较短过期时间),避免重复穿透;
  2. 布隆过滤器:预先将存在的 key 存入布隆过滤器,请求先校验,不存在直接返回;
  3. 接口限流与校验:对恶意高频请求限流,校验请求参数合法性。

缓存雪崩(大量缓存 key 同时过期或 Redis 集群故障,请求集中冲击数据库):

  1. 过期时间随机化:为不同 key 设置随机过期时间,避免同时失效;
  2. 缓存集群高可用:部署 Redis 主从 + 哨兵或集群模式,避免单点故障;
  3. 熔断降级:缓存失效时限制数据库请求流量,返回默认值或错误提示;
  4. 预热缓存:提前加载热点数据到缓存,避免冷启动时的缓存缺失。

缓存击穿(热点 key 缓存重建过程被并发冲击,核心是"保护热点 key 的缓存重建过程"):

  1. 互斥锁(分布式锁):缓存失效时仅一个线程获取分布式锁查询数据库并重建缓存,其他线程等待锁释放后直接查缓存;
  2. 逻辑过期:缓存存储时在 value 中添加"逻辑过期时间"(不设置 Redis 真实过期时间),查询时判断逻辑过期:未过期直接返回;已过期先返回旧数据,同时异步启动线程重建缓存(重建时加分布式锁,避免重复重建)。

10. Redis 怎么实现分布式锁?

核心:依赖 SETNX 命令(互斥性)+ 过期时间(防死锁),结合 Lua 脚本保证原子性,进阶可用 Redisson 优化。

  1. 获取锁SET key 线程标识 NX EX 过期时间,仅当 key 不存在时创建(互斥),同时设置过期时间(防死锁);
  2. 释放锁:先判断锁的线程标识是否为当前线程,一致则删除锁,通过 Lua 脚本将"判断 + 删除"合并为原子操作(防误删);
  3. 进阶优化:使用 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 提供两种数据过期删除策略:

  1. 惰性删除:设置 key 过期时间后不去管它,当需要该 key 时检查是否过期,过期则删除,否则返回该 key。优点:对 CPU 友好(只在使用时检查);缺点:对内存不友好(过期但一直没使用的 key 长期占用内存);
  2. 定期删除:每隔一段时间检查一些 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 的访问频率,值越小淘汰优先级越高。

使用建议:

  1. 优先使用 allkeys-lru,适合有冷热数据区分的业务;
  2. 访问频率差别不大、无明显冷热区分,用 allkeys-random
  3. 有置顶需求,用 volatile-lru,置顶数据不设置过期时间(一直不被删除);
  4. 有短时高频访问数据,用 allkeys-lfuvolatile-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 的并发能力是有上限的,要进一步提高并发能力,可以搭建主从集群,实现读写分离。一般一主多从,主节点负责写数据,从节点负责读数据,主节点写入数据后需要把数据同步到从节点。

主从同步数据的流程

候选人话术: 主从同步分两个阶段:全量同步增量同步

全量同步(从节点第一次与主节点建立连接时):

  1. 从节点请求主节点同步数据,携带自己的 replication id 和 offset 偏移量;
  2. 主节点判断是否是第一次请求(依据 replication id 是否一致);
  3. 第一次则把主节点的 replication id 和 offset 发给从节点,让从节点与主节点信息一致;
  4. 主节点执行 bgsave 生成 RDB 文件发给从节点,从节点清空本地数据后加载 RDB;
  5. RDB 生成执行期间到达主节点的请求,以命令方式记录到缓冲区(repl_backlog 日志),最后把日志文件发给从节点,保证完全一致。后期再同步数据都依赖这个日志。

增量同步(从节点服务重启后数据不一致时):

  1. 从节点请求主节点同步数据;
  2. 主节点判断不是第一次请求,获取从节点的 offset 值;
  3. 主节点从命令日志(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 是单线程的,为什么还那么快?

候选人话术:

  1. 完全基于内存,C 语言编写;
  2. 采用单线程,避免不必要的上下文切换和竞争条件;
  3. 使用多路 IO 复用模型,非阻塞 IO。

bgsavebgrewriteaof 都是在后台执行,不影响主线程正常使用,不会产生阻塞。

解释一下 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 读取数据。selectpoll 只通知有 Socket 就绪但不确定是哪个,需要逐个遍历确认;epoll 会把已就绪的 Socket 写入用户空间;
  • Redis 网络模型:Redis 通过 IO 多路复用提高网络性能,封装不同的多路复用实现,提供统一的高性能事件库(连接应答处理器、命令请求处理器、命令回复处理器)。Redis 6.0 之后,命令回复处理器使用多线程处理回复事件,命令请求处理器中命令转换使用多线程,命令执行依然是单线程
posted @ 2026-09-18 14:20  H杰  阅读(0)  评论(0)    收藏  举报