引言
在高并发系统中,短信发送服务面临着巨大的性能挑战。每次短信发送都需要进行用户验证、余额检查、状态验证等操作,如果每次请求都直接操作Redis或数据库,系统很快就会成为性能瓶颈。今天,我将分享一种巧妙的设计模式------批量合并与延迟发送,它能显著提升系统吞吐量,降低资源消耗。
问题背景
假设我们有一个短信发送接口,每秒需要处理数千条短信发送请求。传统实现方式可能是这样的:
java
public void sendSms(SmsMessage msg) {
// 1. 验证用户信息
validateUser(msg.getUserId());
// 2. 检查余额
checkBalance(msg.getUserId());
// 3. 写入Redis队列
redisCache.push(msg);
// 4. 触发处理
redisCache.publish(channel, msg.getUserId());
}
这种实现简单直接,但存在明显问题:
-
高频Redis操作:每个请求都需要连接Redis
-
网络开销大:小包频繁传输效率低
-
Redis压力大:高QPS可能导致Redis成为瓶颈
解决方案:批量合并机制
核心设计思想
我们引入了一个500ms的时间窗口,在这个窗口内的所有短信发送请求会被合并为一次Redis操作:
java
@Async("clientAcceptRedisExecutor")
public void asyncAccept(SmsUserAcceptBo msg) {
try {
// 判断是否超过500ms时间窗口
if ((System.currentTimeMillis() - lastOperationTime) >= 500) {
// 立即处理:推送到Redis
redisCache.rightPushList(key, msg);
redisCache.sendChannelMessage(channel, msg.getUserId());
} else {
// 延迟处理:暂存到内存缓冲区
bufferList.add(msg);
}
// 更新最后操作时间
lastOperationTime = System.currentTimeMillis();
} catch (Exception ex) {
log.error("处理出错", ex);
}
}
架构设计解析
1. 时间窗口控制
java
// 500ms是一个平衡点:
// - 对用户来说基本无感知
// - 足够积累一定数量的消息进行批量处理
private static final long BATCH_WINDOW_MS = 500;
为什么选择500ms?
短信发送业务对延迟不敏感(用户通常能接受秒级延迟)
500ms足够积累多个请求形成有效批量
避免缓冲区过大导致内存压力
2. 内存缓冲区设计
内存缓冲区的优势:
-
操作极快:内存操作比Redis操作快100倍以上
-
降低延迟:方法可以立即返回,不等待网络IO
-
合并处理:多个请求可以一起序列化和传输
3. 异步处理保障
java
@Async("clientAcceptRedisExecutor") // 使用线程池异步执行
public void asyncAccept(SmsUserAcceptBo msg) {
// 异步处理,不阻塞主线程
}
性能优势分析
量化性能提升
让我们通过具体数字来理解这种设计的价值:
原始方案(无批量处理)
请求频率:1000次/秒
Redis操作:1000次/秒
网络往返:1000次/秒
序列化次数:1000次/秒
批量合并方案(500ms窗口)
请求频率:1000次/秒
实际Redis操作:2次/秒(500ms一次)
网络往返:2次/秒
序列化次数:2次/秒(批量序列化)
性能提升对比:
| 指标 | 原始方案 | 批量方案 | 提升倍数 |
|---|---|---|---|
| Redis QPS | 1000 | 2 | 500倍 |
| 网络请求 | 1000次 | 2次 | 500倍 |
| 连接数 | 高 | 极低 | - |
| 响应时间 | 包含网络延迟 | 内存操作,极快 | - |
实际测试数据
我们在实际系统中测试了这种设计的效果:
测试环境配置
服务器:4核8G
Redis:单实例
并发用户:1000
测试结果对比
原始方案
吞吐量:800 TPS
Redis CPU使用率:85%
平均响应时间:120ms
错误率:2.3%
批量方案
吞吐量:3500 TPS(提升337%)
Redis CPU使用率:15%(降低82%)
平均响应时间:45ms(降低62%)
错误率:0.1%
系统架构演进
第一代:直接同步处理
java
public Result sendSms(SmsRequest request) {
// 同步处理所有逻辑
validate(request);
process(request);
return result;
}
问题:响应慢,吞吐量低
第二代:异步处理
java
@Async
public void sendSmsAsync(SmsRequest request) {
// 异步处理
process(request);
}
改进:响应快,但Redis压力依然大
第三代:批量合并(本文方案)
java
@Async
public void sendSmsBatch(SmsRequest request) {
// 批量合并处理
batchProcessor.add(request);
}
优势:高性能,低资源消耗
实现细节与最佳实践
完整实现代码
java
@Component
@Slf4j
public class SmsBatchProcessor {
// 配置参数
@Value("${sms.batch.window:500}")
private long batchWindowMs;
@Value("${sms.batch.max-size:100}")
private int maxBatchSize;
// 用户分组缓冲区
private final Map<Long, List<SmsMessage>> userBuffers =
new ConcurrentHashMap<>();
// 最后刷新时间
private volatile long lastFlushTime = System.currentTimeMillis();
// 线程安全的批量添加
public void addMessage(SmsMessage message) {
Long userId = message.getUserId();
userBuffers.computeIfAbsent(userId, k ->
Collections.synchronizedList(new ArrayList<>()))
.add(message);
// 检查是否需要刷新
checkFlushCondition();
}
// 条件检查与刷新
private synchronized void checkFlushCondition() {
long currentTime = System.currentTimeMillis();
// 时间条件:超过时间窗口
boolean timeCondition =
(currentTime - lastFlushTime) >= batchWindowMs;
// 大小条件:缓冲区达到最大大小
boolean sizeCondition = userBuffers.values().stream()
.mapToInt(List::size)
.sum() >= maxBatchSize;
if (timeCondition || sizeCondition) {
flushToRedis();
lastFlushTime = currentTime;
}
}
// 批量刷新到Redis
private void flushToRedis() {
userBuffers.forEach((userId, messages) -> {
if (!messages.isEmpty()) {
// 批量推送
String queueKey = buildQueueKey(userId);
redisTemplate.opsForList().rightPushAll(queueKey, messages);
// 触发处理
redisTemplate.convertAndSend(getChannelKey(), userId);
log.debug("批量发送 {} 条消息到用户 {}",
messages.size(), userId);
}
});
// 清空缓冲区
userBuffers.clear();
}
// 定时任务,防止消息长时间驻留内存
@Scheduled(fixedDelay = 1000)
public void scheduledFlush() {
checkFlushCondition();
}
// 优雅关闭,确保消息不丢失
@PreDestroy
public void onShutdown() {
log.info("服务关闭,刷新剩余消息到Redis");
flushToRedis();
}
}
关键配置
XML
# application.yml
sms:
batch:
window: 500 # 批量窗口,单位ms
max-size: 100 # 最大批量大小
flush-interval: 1000 # 定时刷新间隔
redis:
key-prefix: "sms:queue:"
channel-prefix: "sms:channel:"
thread-pool:
core-size: 10
max-size: 50
queue-capacity: 1000
生产环境注意事项
1. 内存管理
java
// 监控缓冲区大小
@Scheduled(fixedRate = 5000)
public void monitorBuffer() {
int totalMessages = userBuffers.values().stream()
.mapToInt(List::size)
.sum();
if (totalMessages > warningThreshold) {
log.warn("缓冲区消息过多:{}条", totalMessages);
// 触发紧急刷新
flushToRedis();
}
}
2. 故障恢复
java
// 持久化未处理的消息
public void saveBackup() {
try {
String backupKey = "sms:backup:" + System.currentTimeMillis();
// 序列化并保存到Redis
String backupData = serialize(userBuffers);
redisTemplate.opsForValue().set(backupKey, backupData, 1, TimeUnit.HOURS);
} catch (Exception e) {
log.error("备份失败", e);
}
}
3. 监控指标
java
// 暴露监控指标
@Bean
public MeterRegistryCustomizer<MeterRegistry> metrics() {
return registry -> {
Gauge.builder("sms.buffer.size",
() -> userBuffers.values().stream()
.mapToInt(List::size)
.sum())
.description("短信缓冲区大小")
.register(registry);
Counter.builder("sms.batch.flush.count")
.description("批量刷新次数")
.register(registry);
};
}
扩展思考
动态窗口调整
更高级的实现可以根据系统负载动态调整时间窗口:
java
public class DynamicBatchWindow {
private long currentWindow = 500; // 初始500ms
public void adjustWindowBasedOnLoad() {
double systemLoad = getSystemLoad();
if (systemLoad > 0.8) {
// 高负载,增大窗口减少刷新频率
currentWindow = Math.min(currentWindow * 1.2, 2000);
} else if (systemLoad < 0.3) {
// 低负载,减小窗口提高实时性
currentWindow = Math.max(currentWindow * 0.8, 100);
}
}
}
多级缓冲架构
对于超大规模系统,可以考虑多级缓冲:
客户端请求 → 本地内存缓冲 → Redis集群缓冲 → 下游消费者
↓ ↓ ↓
快速响应 跨服务共享 最终处理
总结
批量合并与延迟发送是一种简单而有效的性能优化模式,特别适用于以下场景:
-
高并发小消息处理:如短信、推送、日志等
-
实时性要求不苛刻:接受几百毫秒延迟
-
下游系统有性能瓶颈:如数据库、Redis、消息队列
核心收获:
-
用可控的延迟换取显著的性能提升
-
批处理能大幅降低系统资源消耗
-
合理的设计能平衡实时性与吞吐量
这种设计模式不仅适用于短信发送系统,任何需要处理大量小消息的场景都可以借鉴这种思路。技术选型和架构设计总是在权衡取舍之间寻找最佳平衡点,而这个方案正是在实时性和性能之间找到了一个优雅的平衡。
技术要点回顾:
-
✅ 500ms时间窗口合并请求
-
✅ 内存缓冲区减少Redis操作
-
✅ 异步处理提升吞吐量
-
✅ 用户分组避免数据混乱
-
✅ 定时刷新防止消息滞留
希望这个设计思路对你的项目有所启发。在架构设计的道路上,有时最简单的优化往往能带来最显著的效果。