面试场景题:基于Redisson、RocketMQ和MyBatis的定时短信发送实现
问题描述
场景题要求:有7台服务器,需要在每天早上10点定时向数据库中的用户表中的用户发送短信,满足以下条件:
- 消息不重复:确保每个用户只收到一条短信。
- 失败追踪:如果发送失败,需记录是哪个用户失败。
- 断点续传:下次发送时,从上一次失败的用户开始继续发送。
技术栈要求:使用 Redisson (分布式锁)、RocketMQ (消息队列)和 MyBatis(ORM)。以下是我的设计和代码实现。
解决方案
技术选型与思路
- 定时任务 :使用 Spring 的
@Scheduled
实现每天10点的定时触发。 - 分布式锁 :使用 Redisson 的
RLock
确保7台服务器中只有一台执行发送任务。 - 消息队列:使用 RocketMQ 存储短信任务,解耦生产和消费,并通过消息状态追踪失败。
- ORM:使用 MyBatis 管理数据库操作,维护用户发送状态表。
数据库表设计
sql
CREATE TABLE message_status (
user_id VARCHAR(50) PRIMARY KEY,
status ENUM('PENDING', 'SENDING', 'SUCCESS', 'FAILED') DEFAULT 'PENDING',
last_attempt_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
message_content VARCHAR(255)
);
代码实现
1. MyBatis 配置
首先配置 MyBatis,包括实体类、Mapper 接口和 XML 映射文件。
实体类:MessageStatus
java
public class MessageStatus {
private String userId;
private String status;
private Timestamp lastAttemptTime;
private String messageContent;
// Getters and Setters
public String getUserId() { return userId; }
public void setUserId(String userId) { this.userId = userId; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Timestamp getLastAttemptTime() { return lastAttemptTime; }
public void setLastAttemptTime(Timestamp lastAttemptTime) { this.lastAttemptTime = lastAttemptTime; }
public String getMessageContent() { return messageContent; }
public void setMessageContent(String messageContent) { this.messageContent = messageContent; }
}
Mapper 接口:MessageStatusMapper
java
@Mapper
public interface MessageStatusMapper {
@Select("SELECT * FROM message_status WHERE status IN ('PENDING', 'FAILED')")
List<MessageStatus> findPendingOrFailed();
@Update("UPDATE message_status SET status = #{status}, last_attempt_time = NOW() WHERE user_id = #{userId}")
void updateStatus(@Param("userId") String userId, @Param("status") String status);
}
MyBatis 配置类
java
@Configuration
@MapperScan("com.example.mapper") // 替换为实际包名
public class MyBatisConfig {
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean();
factoryBean.setDataSource(dataSource);
return factoryBean.getObject();
}
}
2. 配置类(Redisson 和 RocketMQ)
java
@Configuration
public class AppConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
return Redisson.create(config);
}
@Bean
public DefaultMQProducer rocketMQProducer() throws MQClientException {
DefaultMQProducer producer = new DefaultMQProducer("smsProducerGroup");
producer.setNamesrvAddr("localhost:9876");
producer.start();
return producer;
}
@Bean
public DefaultMQPushConsumer rocketMQConsumer() throws MQClientException {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("smsConsumerGroup");
consumer.setNamesrvAddr("localhost:9876");
consumer.subscribe("smsTopic", "*");
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
return consumer;
}
}
3. 定时任务与消息生产
使用 Redisson 分布式锁控制任务执行,通过 MyBatis 查询用户并推送消息到 RocketMQ。
java
@Service
public class SmsSenderService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private DefaultMQProducer producer;
@Autowired
private MessageStatusMapper messageStatusMapper;
@Scheduled(cron = "0 0 10 * * ?") // 每天10点触发
public void sendSmsTask() {
RLock lock = redissonClient.getLock("smsSendLock");
try {
if (lock.tryLock(0, 30, TimeUnit.MINUTES)) { // 尝试获取锁,30分钟超时
List<MessageStatus> users = messageStatusMapper.findPendingOrFailed();
for (MessageStatus user : users) {
String userId = user.getUserId();
String content = user.getMessageContent();
// 更新状态为发送中
messageStatusMapper.updateStatus(userId, "SENDING");
// 发送到RocketMQ
Message msg = new Message("smsTopic", "smsTag", userId.getBytes(), content.getBytes());
producer.send(msg);
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock(); // 释放锁
}
}
}
}
4. 消息消费与失败处理
使用 RocketMQ 消费者处理消息,发送短信并通过 MyBatis 更新状态。
java
@Service
public class SmsConsumerService {
@Autowired
private DefaultMQPushConsumer consumer;
@Autowired
private MessageStatusMapper messageStatusMapper;
@PostConstruct
public void initConsumer() throws MQClientException {
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
for (MessageExt msg : msgs) {
String userId = new String(msg.getKeys());
String content = new String(msg.getBody());
try {
// 模拟发送短信
sendSms(userId, content);
// 发送成功,更新状态
messageStatusMapper.updateStatus(userId, "SUCCESS");
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
} catch (Exception e) {
// 发送失败,更新状态
messageStatusMapper.updateStatus(userId, "FAILED");
return ConsumeConcurrentlyStatus.RECONSUME_LATER; // 重试
}
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
consumer.start();
}
private void sendSms(String userId, String content) {
// 模拟短信发送,实际应调用短信服务API
System.out.println("Sending SMS to " + userId + ": " + content);
if (new Random().nextInt(10) < 2) { // 模拟20%失败率
throw new RuntimeException("SMS send failed");
}
}
}
5. 主应用类
java
@SpringBootApplication
@EnableScheduling
public class SmsApplication {
public static void main(String[] args) {
SpringApplication.run(SmsApplication.class, args);
}
}
运行流程
- 每天10点,
SmsSenderService
的定时任务触发。 - 通过 Redisson 分布式锁确保只有一台服务器执行。
- 使用 MyBatis 查询状态为
PENDING
或FAILED
的用户,推送消息到 RocketMQ。 SmsConsumerService
消费消息,调用短信发送逻辑。- 发送成功更新状态为
SUCCESS
,失败更新为FAILED
,下次任务从失败用户继续。
满足需求分析
- 消息不重复:Redisson 分布式锁保证单台服务器执行,MyBatis 通过主键和状态管理确保幂等性。
- 失败追踪 :失败记录在
message_status
表的status
和last_attempt_time
中。 - 断点续传 :通过 MyBatis 查询
FAILED
和PENDING
状态实现从失败点继续。
总结
通过 Redisson、RocketMQ 和 MyBatis,我实现了一个可靠的分布式短信发送系统。Redisson 提供高效分布式锁,RocketMQ 保证消息可靠传递,MyBatis 简化数据库操作并支持状态管理,完美满足题目需求。如果用户量增加,可以通过 RocketMQ 分区扩展吞吐量。这是一个兼顾并发、可靠性和可扩展性的设计。