博客项目 Spring + Redis + Mysql

基础模块

1. 邮箱发送功能

最初设计的接口 (雏形)

复制代码
public interface EmailService {

    /**
     * 发送验证码邮件
     *
     * @param email 目标邮箱
     * @return 发送的code
     * @throws RuntimeException 如果发送邮件失败,将抛出异常
     */

    String sendVerificationCode(String email);

    /**
     * 校验验证码是否正确
     *
     * @param email 邮箱地址
     * @param code 用户输入的验证码
     * @return true 表示校验通过,false 为不通过
     */

    boolean checkVerificationCode(String email, String code);

    /**
     * 判断邮箱当前是否处于验证码限流状态
     *
     * @param email 邮箱地址
     * @return true 表示当前已限流,不可发送,false 表示未限流,可以发送
     */

    boolean isVerificationCodeRateLimited(String email);
}

EmailServiceImpl 实现类 ( 具体实现)

复制代码
@Service
public class EmailServiceImpl implements EmailService {
    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Value("${mail.verify-code.limit-expire-seconds}")
    private int limitExpireSeconds;

    @Override
    public String sendVerificationCode(String email) {
        // ...
    }

    @Override
    public boolean checkVerificationCode(String email, String code) {
        // ...
    }

    @Override
    public boolean isVerificationCodeRateLimited(String email) {
        // ...
    }
}
分析

先来分析实现类中的三个注入的对象的用途:

  • redisTemplate:操作redis的接口,可以类比JDBC接口

  • limitExpireSeconds:application.yaml中的配置,表示一个email在发送一条邮件后,会被限流多长时间,才能发送第二条邮件,配置文件中是60秒,可以按照自己的需求修改

  • objectMapper:Jackson的接口对象,用于JSON和对象的相互转换

sendVerificationCode 方法和"EmailTaskConsumer"方法

到这里,我们可以开始实现一个"基于Redis消息队列"的轻量级异步任务机制了。

消息队列有两个最基础的部分:

1.生产者

2.消费者

从代码层面来说,sendVerificationCode就是所谓的生产者。

生产者的工作步骤:

1.接收email参数,生成一个email对应的code

2.将email、code、以及当前的时间戳封装为一个对象(EmailTask对象)

3.将EmailTask对象序列化为JSON字符串(Redis只支持存储字符串)

4.将JSON字符串写入到消息队列中

5.防止重复请求,给邮箱设置一个限流键(这步可以不包含在sendVerificationCode中)

消费者的工作步骤:

1.每隔一段时间轮询Redis,查看消息队列中是否存在任务

2.将任务的JSON字符串从消息队列中取出,将其反序列化为EmailTask对象

3.根据EmailTask对象中的字段,填充SimpleEmailMessage对象

4.JavaMailSender发送SimpleEmailMessage对象(调用第三方服务,发邮件给用户)

到这里,生产者和消费者需要完成的步骤已经规划完毕,接下来我们看具体代码实现。

生产者部分:

首先是EmailTask对象,拥有三个字段

1.email字段:用户的email

2.code:验证码

3.timestamp:时间戳

复制代码
@Data
public class EmailTask {
    private String email;
    private String code;
    private long timestamp;
}

接下来是 sendVerificationCode 的实现

复制代码
@Override
public String sendVerificationCode(String email) {
    // 检查发送频率
    if (isVerificationCodeRateLimited(email)) {
        throw new RuntimeException("验证码发送太频繁,请 60 秒后重试");
    }

    // 生成6位随机验证码
    String verificationCode = RandomCodeUtil.generateNumberCode(6);

    // 实现异步发送邮件的逻辑
    try {

        // 创建邮件任务
        EmailTask emailTask = new EmailTask();

        // 初始化邮件任务内容
        // 1. 邮件目的邮箱
        // 2. 验证码
        // 3. 时间戳
        emailTask.setEmail(email);
        emailTask.setCode(verificationCode);
        emailTask.setTimestamp(System.currentTimeMillis());

        // 将邮件任务存入消息队列
        // 1. 将任务对象转成 JSON 字符串
        // 2. 将 JSON 字符串保存到 Redis 模拟的消息队列中
        String emailTaskJson = objectMapper.writeValueAsString(emailTask);
        String queueKey = RedisKey.emailTaskQueue();
        redisTemplate.opsForList().leftPush(queueKey, emailTaskJson);

        // 设置 email 发送注册验证码的限制
        String emailLimitKey = RedisKey.registerVerificationLimitCode(email);
        redisTemplate.opsForValue().set(emailLimitKey, "1", limitExpireSeconds, TimeUnit.SECONDS);

        return verificationCode;
    } catch (Exception e) {
        log.error("发送验证码邮件失败", e);
        throw new RuntimeException("发送验证码失败,请稍后重试");
    }
}
消费者部分

EmailTaskConsumer 的实现

复制代码
@Component
public class EmailTaskConsumer {
    
    @Autowired
    private JavaMailSender mailSender;

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Value("${spring.mail.username}")
    private String from;

    // 每 3 秒轮询一次 redis,查看是否有待发的邮件任务
    @Scheduled(fixedDelay = 3000)
    public void resume() throws JsonProcessingException {
        String emailQueueKey = RedisKey.emailTaskQueue();

        // 从队列中取任务对象
        while (true) {

            // 获取任务对象
            String emailTaskJson = redisTemplate.opsForList().rightPop(emailQueueKey);

            if (emailTaskJson == null) {  // 队列中没有任务对象,退出本次执行
                break;
            }

            // 将 redis 中的 JSON 字符串转成 emailTask 对象
            EmailTask emailTask = objectMapper.readValue(emailTaskJson, EmailTask.class);
            String email = emailTask.getEmail();
            String verificationCode = emailTask.getCode();

            // 根据 emailTask 对象中的信息
            // 填充 SimpleMailMessage 对象,然后使用 JavaMailSender 发送邮件
            SimpleMailMessage mailMessage = new SimpleMailMessage();
            mailMessage.setFrom(from);
            mailMessage.setTo(email);
            mailMessage.setSubject("卡码笔记- 验证码");
            mailMessage.setText("您的验证码是:" + verificationCode + ",有效期" + 5 + "分钟,请勿泄露给他人。");

            mailSender.send(mailMessage);

            // 保存验证码到 Redis
            // 有效时间为 5 分钟
            redisTemplate.opsForValue().set(RedisKey.registerVerificationCode(email), verificationCode, 5, TimeUnit.MINUTES);
        }
    }
}

checkVerificationCode 的实现

复制代码
@Override
public boolean checkVerificationCode(String email, String code) {
    String redisKey = RedisKey.registerVerificationCode(email);
    String verificationCode = redisTemplate.opsForValue().get(redisKey);

    if (verificationCode != null && verificationCode.equals(code)) {
        redisTemplate.delete(redisKey);
        return true;
    }
    return false;
}

checkVerificationCode的实现逻辑很简单,根据用户的email查询出redis种存储的code,和用户输入的code,比较是否相等。在验证成功后需要将Redis中的记录删除。

isVerificationCodeRateLimited 实现

复制代码
@Override
public boolean isVerificationCodeRateLimited(String email) {
    String redisKey = RedisKey.registerVerificationLimitCode(email);
    return redisTemplate.opsForValue().get(redisKey) != null;
}

检查Redis中是否存在email对应的限流键,存在则返回true表示已被限流

2. 排行榜

Spring + Mysql + Redis 异步更新

    1. 用户提交笔记时,同步写业务数据后异步发送一条消息到 Redis 列表(当作 MQ),消息是 JSON(包含 userId、score 增量、messageId、timestamp、noteId 等)。
    1. 后台消费进程用 BRPOP/阻塞弹出消息,解析后对 Redis 的 ZSET 做增量更新(ZINCRBY),ZSET 用于排行榜查询。
    1. 定时任务每小时把 Redis 中的 ZSET 批量持久化到 MySQL(通过 MyBatis 批量 upsert),以确保最终一致性和历史存档。
controller.java

接收用户提交并返回消息

复制代码
@RestController
@RequestMapping("/notes")
public class NoteController {
  private final ProducerService producer;

  @PostMapping
  public ResponseEntity<?> submitNote(@RequestBody NoteSubmitDto dto){
    // 1. 处理业务写入笔记表(略)
    // 2. 发送异步消息
    NoteMessage msg = new NoteMessage(...);
    producer.sendMessage(msg);
    return ResponseEntity.ok().build();
  }
}
Producer

(将消息 push 到 Redis 列表)

复制代码
@Service
public class ProducerService {
  private static final String QUEUE = "mq:note:queue";
  private final RedisTemplate<String, String> redis;
  private final ObjectMapper mapper = new ObjectMapper();

  public ProducerService(RedisTemplate<String, String> redis){ this.redis = redis; }

  public void sendMessage(NoteMessage msg){
    try {
      String json = mapper.writeValueAsString(msg);
      redis.opsForList().leftPush(QUEUE, json);
    } catch (Exception e) {
      // 记录失败/监控
    }
  }
}
Consumer

(后台阻塞消费,更新 ZSET)

复制代码
@Service
public class RedisConsumerService {
  private static final String QUEUE = "mq:note:queue";
  private static final String DLQ = "mq:note:dlq";
  private final RedisTemplate<String, String> redis;
  private final LeaderboardService leaderboard;
  private final ObjectMapper mapper = new ObjectMapper();
  private volatile boolean running = true;

  public RedisConsumerService(RedisTemplate<String, String> redis, LeaderboardService leaderboard) {
    this.redis = redis; this.leaderboard = leaderboard;
  }

  @PostConstruct
  public void start() {
    Thread t = new Thread(this::loop, "redis-mq-consumer");
    t.setDaemon(true);
    t.start();
  }

  private void loop() {
    while (running) {
      try {
        // 阻塞弹出(0 表示一直阻塞)
        String json = redis.opsForList().rightPop(QUEUE, 0, TimeUnit.SECONDS);
        if (json == null) continue;
        NoteMessage msg = mapper.readValue(json, NoteMessage.class);
        // 可做幂等校验(基于 messageId)
        leaderboard.incrementScore(msg.getUserId(), msg.getDelta());
      } catch (Exception e) {
        // 解析或处理失败 -> 推到死信队列并记录
        redis.opsForList().leftPush(DLQ, /* 原始消息 */);
      }
    }
  }

  @PreDestroy
  public void stop() { running = false; }
}
LeaderboardService

(封装 ZSET 操作)

复制代码
@Service
public class LeaderboardService {
  private static final String ZKEY = "leaderboard:zset";
  private final RedisTemplate<String, String> redis;

  public LeaderboardService(RedisTemplate<String, String> redis){ this.redis = redis; }

  public void incrementScore(Long userId, double delta){
    redis.opsForZSet().incrementScore(ZKEY, userId.toString(), delta);
  }

  public Set<ZSetOperations.TypedTuple<String>> topN(int n){
    return redis.opsForZSet().reverseRangeWithScores(ZKEY, 0, n - 1);
  }

  public Set<ZSetOperations.TypedTuple<String>> rangeAllWithScores(){
    return redis.opsForZSet().rangeWithScores(ZKEY, 0, -1);
  }
}
DB 定时持久化

(每小时)

复制代码
@Component
public class DbPersistScheduler {
  private final LeaderboardService leaderboardService;
  private final LeaderboardMapper mapper;

  public DbPersistScheduler(LeaderboardService leaderboardService, LeaderboardMapper mapper){
    this.leaderboardService = leaderboardService; this.mapper = mapper;
  }

  // 每小时执行一次(整点)
  @Scheduled(cron = "0 0 * * * ?")
  public void persistHourly() {
    // 读取全部或 top K(注意数据量)
    Set<ZSetOperations.TypedTuple<String>> entries = leaderboardService.rangeAllWithScores();
    if (entries.isEmpty()) return;
    List<Leaderboard> list = entries.stream()
      .map(t -> new Leaderboard(Long.valueOf(t.getValue()), t.getScore().longValue(), new Date()))
      .collect(Collectors.toList());
    // 批量 upsert(MyBatis 实现)
    mapper.batchUpsert(list);
  }
}
MyBatis Mapper + SQL
复制代码
Mapper 接口:
Apply to HallScene.ts
public interface LeaderboardMapper {
  void batchUpsert(@Param("list") List<Leaderboard> list);
  // 可加查询方法
}



mapper XML(LeaderboardMapper.xml)



<insert id="batchUpsert" parameterType="map">
  INSERT INTO leaderboard (user_id, score, updated_at)
  VALUES
  <foreach collection="list" item="item" separator=",">
    (#{item.userId}, #{item.score}, #{item.updatedAt})
  </foreach>
  ON DUPLICATE KEY UPDATE
    score = VALUES(score),
    updated_at = VALUES(updated_at)
</insert>
  • 幂等与重复消息:消息包含 messageId,消费端在 Redis/DB 中做幂等检查(如 SETNX messageId:processed 或 Redis Hash 记录已处理 id)。
  • 死信与重试:消费失败推到 mq:note:dlq,人工或单独重试流程处理。
  • 批量与限流:ZSET 的全量持久化当数据量大时会耗时,建议:
  • 只持久化 Top N(按需求),或分批(分页 ZRANGE)。
  • 按用户分区并并行持久化。
  • 持久化策略:可以每小时写入 DB 覆盖(ON DUPLICATE KEY),也可写入增量表(记录历史)。
  • 数据一致性:在关机或部署前优雅停止消费线程并将 Redis 中的变更 flush 到 DB。
  • 性能:消费端更新 ZSET 为单条 Redis 请求,若高并发可用 pipeline 或本地批队列合并多条消息后一起 ZINCRBY(减少网络往返)。
  • 监控:记录队列长度(LLEN)、consumer 错误率、持久化耗时,设置报警。