一、批量发货状态缓存
1.1 解决什么问题
批量发货这一场景中,用户一次性勾选多条订单并点击“一键发货”时,系统面临的技术挑战其实相当直接:

- 必须防止重复发货——用户手抖多点了一下,或者消息队列重试机制介入,同一张单据不能被发送两次。
- 前端需要实时获知哪些单据正在处理中——这样用户能清晰看到进度,或者按钮自动置灰,从源头阻止重复提交。
- 处理完成后,状态标记要及时清理释放。
在数据库里加一个状态字段固然可行,但频繁的读写操作和并发查询对数据库性能压力较大。Redis作为内存级缓存,天然适合承载这种“短生命周期、高频读写”的临时状态管理任务。
1.2 核心设计思路
用户触发批量发货 ↓遍历每一条待发货单据 ↓检查 Redis key 是否存在(是否正在处理中) ├── 存在 → 跳过,返回"正在处理中" └── 不存在 → 写入 Redis key(标记占位) → 执行发货逻辑 ↓ 发货成功 → 删除 key 或设短过期 发货失败 → 删除 key,返回错误
1.3 涉及的 Redis 数据结构和命令
| 操作 | Redis 命令 | 说明 |
|---|---|---|
| 标记正在处理 | SET key value EX ttl NX | NX 参数保证互斥性,EX 参数防止死锁 |
| 检查是否处理中 | GET key | 返回值非空则跳过处理 |
| 处理完成清除 | DEL key | 释放占用标记 |
| 批量检查 | MGET key1 key2 ... | 一次查询多个单据的当前状态 |
1.4 代码示例(通用)
import org.springframework.data.redis.core.StringRedisTemplate;import org.springframework.stereotype.Service;import ja va.util.ArrayList;import ja va.util.List;import ja va.util.concurrent.TimeUnit;@Servicepublic class BatchTaskService { private final StringRedisTemplate redisTemplate; // key 前缀 private static final String PROCESSING_KEY_PREFIX = "batch:task:processing:"; // 标记过期时间(防止异常未清除导致永久阻塞) private static final long EXPIRE_SECONDS = 300; public BatchTaskService(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } /** * 批量处理任务. * * @param taskIds 待处理的任务ID列表 * @return 处理结果 */ public BatchResult processBatch(List taskIds) { List successList = new ArrayList<>(); List skippedList = new ArrayList<>(); List failedList = new ArrayList<>(); for (String taskId : taskIds) { String redisKey = PROCESSING_KEY_PREFIX + taskId; // 尝试标记为处理中(SET NX EX:不存在才设置 + 过期时间) Boolean acquired = redisTemplate.opsForValue() .setIfAbsent(redisKey, "processing", EXPIRE_SECONDS, TimeUnit.SECONDS); if (Boolean.FALSE.equals(acquired)) { // key 已存在,说明正在被其他线程/实例处理 skippedList.add(taskId); continue; } try { // 执行业务逻辑 doProcess(taskId); successList.add(taskId); } catch (Exception e) { failedList.add(taskId); } finally { // 无论成功失败都清除标记,允许后续重试 redisTemplate.delete(redisKey); } } return new BatchResult(successList, skippedList, failedList); } /** * 查询哪些任务正在处理中(前端轮询用). */ public List listProcessingTasks(List taskIds) { List keys = taskIds.stream() .map(id -> PROCESSING_KEY_PREFIX + id) .collect(Collectors.toList()); List values = redisTemplate.opsForValue().multiGet(keys); List processingIds = new ArrayList<>(); for (int i = 0; i < taskIds.size(); i++) { if (values != null && values.get(i) != null) { processingIds.add(taskIds.get(i)); } } return processingIds; } private void doProcess(String taskId) { // 具体业务逻辑... }}
1.5 进阶:带进度的批量状态
如果前端希望展示一个“已处理 3/10”这样的动态进度信息,借助 Redis Hash 结构来实现会更加优雅:
/** * 批量任务进度跟踪. */@Servicepublic class BatchProgressService { private final StringRedisTemplate redisTemplate; private static final String PROGRESS_KEY_PREFIX = "batch:progress:"; public BatchProgressService(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } /** 初始化批次进度. */ public String initBatch(List taskIds) { String batchId = UUID.randomUUID().toString(); String progressKey = PROGRESS_KEY_PREFIX + batchId; // Hash 结构:field=taskId, value=状态 Map statusMap = taskIds.stream() .collect(Collectors.toMap(id -> id, id -> "PENDING")); redisTemplate.opsForHash().putAll(progressKey, statusMap); redisTemplate.expire(progressKey, 1, TimeUnit.HOURS); return batchId; } /** 更新单个任务状态. */ public void updateStatus(String batchId, String taskId, String status) { String progressKey = PROGRESS_KEY_PREFIX + batchId; redisTemplate.opsForHash().put(progressKey, taskId, status); } /** 查询批次进度. */ public Map getProgress(String batchId) { String progressKey = PROGRESS_KEY_PREFIX + batchId; Map
1.6 关键设计考量
| 考量点 | 说明 |
|---|---|
| 过期时间 | 必须设定,防止应用崩溃后 key 永久残留,阻塞后续正常操作 |
| 幂等性 | 加锁失败时不报错而是直接跳过,配合重试机制实现最终一致性 |
| key 设计 | 业务前缀:模块:唯一标识,例如 batch:delivery:SO202401010001 |
| 清除时机 | 成功或失败后均删除标记(允许重试),或成功后短暂保留以防重复成功回调 |
| 集群环境 | 确保所有应用实例连接同一个 Redis 集群,保证 key 的全局可见性一致 |
二、错误信息暂存
2.1 解决什么问题
自动发货场景下的痛点则集中在另一方面:
- 发货流程是异步触发的,没有前端页面实时等待查看结果。
- 失败信息需要先找个地方临时存放,再由定时任务定期捞取并推送告警通知。
- 这些错误信息的时效性很短,当天处理完毕即可,不值得持久化到数据库。
- 而且短时间内可能产生大量错误日志,写入性能必须足够强悍。
Redis 的 List 结构正好能派上用场:左进右出,支持批量读取,还能设置过期时间自动清理,与这个场景的需求非常贴合。
2.2 核心设计思路
自动发货失败 ↓格式化错误信息(单号 + 客户码 + requestId + 失败原因) ↓RPUSH 写入 Redis List ↓设置 key 1天过期(EXPIRE) ↓定时任务每分钟 LPOP/LRANGE 取出 → 推送钉钉/企微
2.3 涉及的 Redis 数据结构和命令
| 操作 | Redis 命令 | 说明 |
|---|---|---|
| 追加错误 | RPUSH key message | 在 List 尾部追加,时间复杂度 O(1) |
| 批量读取 | LRANGE key 0 -1 | 取出列表中所有消息 |
| 逐条消费 | LPOP key | 从头部弹出一条数据 |
| 批量消费 | LPOP key count (Redis 6.2+) | 一次弹出 N 条记录 |
| 设置过期 | EXPIRE key seconds | 自动清理过期数据,避免堆积 |
| 查看队列长度 | LLEN key | 监控消息积压情况 |
2.4 代码示例(通用)
import org.springframework.data.redis.core.StringRedisTemplate;import org.springframework.stereotype.Service;import ja va.time.LocalDateTime;import ja va.time.format.DateTimeFormatter;import ja va.util.List;import ja va.util.concurrent.TimeUnit;/** * 异步任务错误信息暂存服务. * 用于暂存自动任务的失败信息,供定时任务消费后推送告警. */@Servicepublic class ErrorMessageBufferService { private final StringRedisTemplate redisTemplate; // Redis key private static final String ERROR_BUFFER_KEY = "error:buffer:auto-task"; // 过期时间(防止无人消费时无限增长) private static final long EXPIRE_HOURS = 24; // 单次消费最大条数 private static final int BATCH_SIZE = 50; public ErrorMessageBufferService(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } /** * 写入错误信息. * * @param taskId 任务标识 * @param errorMsg 错误原因 * @param context 上下文信息(如 requestId) */ public void recordError(String taskId, String errorMsg, String context) { String timestamp = LocalDateTime.now() .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); // 格式化消息内容 String message = String.format("[%s] 任务:%s, 上下文:%s, 失败原因:%s", timestamp, taskId, context, errorMsg); try { // 追加到 List 尾部 redisTemplate.opsForList().rightPush(ERROR_BUFFER_KEY, message); // 刷新过期时间(每次写入都续期,保证最后一条写入后24小时过期) redisTemplate.expire(ERROR_BUFFER_KEY, EXPIRE_HOURS, TimeUnit.HOURS); } catch (Exception e) { // Redis 操作失败不影响主流程,仅记录日志 log.warn("错误信息写入Redis失败, taskId={}", taskId, e); } } /** * 批量消费错误信息(定时任务调用). * * @return 本次消费的错误消息列表 */ public List consumeErrors() { // 先查长度,取 min(length, BATCH_SIZE) 条 Long length = redisTemplate.opsForList().size(ERROR_BUFFER_KEY); if (length == null || length == 0) { return List.of(); } int count = (int) Math.min(length, BATCH_SIZE); // LRANGE 读取 + LTRIM 删除(两步操作,实际可用 Lua 保证原子性) List messages = redisTemplate.opsForList().range(ERROR_BUFFER_KEY, 0, count - 1); redisTemplate.opsForList().trim(ERROR_BUFFER_KEY, count, -1); return messages; } /** * 查看当前积压的错误数量(监控用). */ public long getPendingCount() { Long size = redisTemplate.opsForList().size(ERROR_BUFFER_KEY); return size != null ? size : 0; }}
2.5 定时任务消费 + 推送告警
import org.springframework.scheduling.annotation.Scheduled;import org.springframework.stereotype.Component;import ja va.util.List;/** * 定时消费错误缓存并推送告警. */@Componentpublic class ErrorAlertJob { private final ErrorMessageBufferService errorBufferService; private final AlertService alertService; public ErrorAlertJob(ErrorMessageBufferService errorBufferService, AlertService alertService) { this.errorBufferService = errorBufferService; this.alertService = alertService; } /** * 每分钟执行一次,消费错误消息并批量推送. */ @Scheduled(fixedDelay = 60000) public void consumeAndAlert() { List errors = errorBufferService.consumeErrors(); if (errors.isEmpty()) { return; } // 拼接为一条告警消息 StringBuilder content = new StringBuilder(); content.append("⚠️ 自动任务失败告警(").append(errors.size()).append("条)nn"); for (String error : errors) { content.append("• ").append(error).append("n"); } // 推送到钉钉/企微/飞书 alertService.sendToWebhook(content.toString()); }}
2.6 原子消费的 Lua 脚本方案
LRANGE 与 LTRIM 这两步操作在并发消费场景下,存在数据丢失或重复消费的风险。通过 Lua 脚本将它们封装在一起,能够保证操作的原子性:
/** * 原子性批量弹出 List 元素. */public ListatomicBatchPop(String key, int count) { String luaScript = "local result = redis.call('lrange', KEYS[1], 0, ARGV[1] - 1) " + "if #result > 0 then " + " redis.call('ltrim', KEYS[1], #result, -1) " + "end " + "return result"; DefaultRedisScript script = new DefaultRedisScript<>(luaScript, List.class); List
result = redisTemplate.execute( script, Collections.singletonList(key), String.valueOf(count)); return result != null ? result : List.of();}
2.7 使用模板(配置化消息格式)
在实际项目中,消息格式最好做成可配置的。模板可以存放在数据库或 Redis 中,运行时动态填充参数,这样在调整格式时无需改动代码,灵活性更高:
/** * 模板化错误消息. */public class ErrorMessageTemplate { /** * 根据模板和参数生成最终消息. * * @param template 模板,如 "任务{1}失败,客户{2},原因:{3}" * @param args 参数数组 * @return 填充后的消息 */ public static String format(String template, String[] args) { String result = template; for (int i = 0; i < args.length; i++) { result = result.replace("{" + (i + 1) + "}", args[i]); } return result; }}// 使用String template = configService.getTemplate("auto-delivery-error");String message = ErrorMessageTemplate.format(template, new String[]{ orderCode, sellerCode, requestId, exception.getMessage()});errorBufferService.recordError(orderCode, message, requestId);
三、两种场景的对比
| 维度 | 批量状态缓存 | 错误信息暂存 |
|---|---|---|
| 数据结构 | String(每个任务独立一个 key) | List(所有错误共用一个 key) |
| 生命周期 | 任务处理完毕即删除标记 | 定时消费后删除,或自然过期释放 |
| 并发模式 | 写少读多(前端轮询进度) | 写多读少(定时批量消费) |
| 一致性要求 | 强(状态标记必须准确无误) | 弱(少量丢失可接受) |
| 失败影响 | Redis 写入失败可能导致重复处理 | Redis 写入失败仅丢失告警,不影响主流程 |
| 典型 key 模式 | batch:delivery:{orderCode} | error:buffer:auto-delivery |
四、注意事项汇总
| 问题 | 解决方案 |
|---|---|
| Redis 宕机时标记丢失 | 状态缓存:降级为数据库 SELECT FOR UPDATE;错误暂存:降级为本地日志输出 |
| List 无限增长 | 设置 EXPIRE 自动过期 + 消费任务监控积压量并触发告警 |
| 并发消费重复 | 采用单消费者模式,或使用 Lua 脚本实现原子弹出 |
| 消息格式变更 | 模板配置化,修改模板无需改动代码和重启服务 |
| 跨机房同步 | 错误暂存场景可容忍延迟,采用异步复制;状态缓存需同机房 Redis 保证实时性 |
