Redis 实现仓储单据异步提交技术方案

一、核心目标

  1. 解决大单据量提交(网关超时>10秒)问题,将请求响应时间控制在500ms内
  2. 基于现有 Redis/MySQL 实现,无额外中间件成本
  3. 保证数据可靠性:单据不丢失、不重复处理、状态一致
  4. 支持高并发:适配日均1000-10万条单据提交,峰值50+条/秒

二、整体架构

复制代码
前端 → API网关 → 单据提交服务(Spring Boot)→ MySQL(存单据)→ Redis(消息队列+状态缓存)→ 单据处理服务(多实例)→ 业务执行(库存更新/流水生成)
↓                                                                 ↑
单据查询服务 ← Redis(状态缓存)← MySQL(更新状态)← 处理结果回调

三、核心模块设计

(一)单据提交服务(生产端)

职责

接收前端请求 → 基础校验 → 生成单据ID → 落地MySQL → 发送Redis队列 → 快速响应

关键逻辑

  1. 单据ID生成:业务前缀(IN/OUT)+ 时间戳(毫秒)+ 4位随机数,确保全局唯一
  2. 幂等性保障:
    • 前端:提交按钮防抖(3秒内不可重复点击)
    • 后端:MySQL 建唯一索引 uk_bill_no_submitter(业务单号+提交人),防止重复提交
  3. 数据落地顺序:先存MySQL(状态PENDING),再发Redis队列,确保数据不丢失
  4. 核心代码片段:
java 复制代码
// 1. 校验+生成ID
validateBill(dto); // 幂等+格式校验
String billId = "IN_" + System.currentTimeMillis() + RandomUtils.nextInt(1000, 9999);

// 2. 落地MySQL
billMapper.insert(Bill.builder().id(billId).status("PENDING").data(JSON.toJSONString(dto)).build());

// 3. 发Redis队列(List结构,LPUSH入队)
redisTemplate.opsForList().leftPush("warehouse:bill:queue", billId);

// 4. 缓存状态(Redis有效期24小时)
redisTemplate.opsForValue().set("warehouse:bill:status:" + billId, "PENDING", 24, TimeUnit.HOURS);

// 5. 快速响应
return Result.success("单据提交成功", billId);

(二)Redis 队列核心设计

数据结构选型(4个核心Key)

Redis类型 Key名称 用途 操作方式
List warehouse:bill:queue 主队列(待处理单据ID) LPUSH入队、BRPOP阻塞出队
Sorted Set warehouse:bill:retry 重试队列(处理失败单据) ZADD(score=下次重试时间戳)、ZRANGEByScore查询
List warehouse:bill:dead 死信队列(重试3次失败) LPUSH入队、人工导出处理
String warehouse:bill:lock:{billId} 分布式锁(防重复处理) SETNX(过期30秒)、删除释放锁

Redis 配置要求(保证可靠性)

  • 开启 AOF 持久化(appendonly yes),同步策略 appendfsync everysec
  • 开启 RDB 快照(save 60 1000),双重保障数据不丢失
  • 内存策略 maxmemory-policy allkeys-lru,避免OOM

(三)单据处理服务(消费端)

职责

  • 阻塞读取Redis队列 → 分布式锁防重复 → 业务处理 → 状态更新 → 失败重试/死信

核心流程(多实例部署)

  1. 主队列消费(单线程阻塞,避免空轮询):
java 复制代码
@PostConstruct
public void startConsume() {
    new Thread(() -> {
        while (true) {
            try {
                // 1. BRPOP阻塞读取(无消息时阻塞,0=永久阻塞)
                String billId = redisTemplate.opsForList().rightPop("warehouse:bill:queue", 0, TimeUnit.SECONDS);
                if (StringUtils.isEmpty(billId)) continue;

                // 2. 分布式锁:防止重复处理(30秒过期,覆盖最大处理耗时)
                String lockKey = "warehouse:bill:lock:" + billId;
                String lockValue = UUID.randomUUID().toString();
                Boolean lockSuccess = redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, 30, TimeUnit.SECONDS);
                if (!lockSuccess) {
                    // 锁失败:放回队列,重新排队
                    redisTemplate.opsForList().leftPush("warehouse:bill:queue", billId);
                    continue;
                }

                try {
                    // 3. 业务处理(核心逻辑)
                    processBill(billId);
                } catch (Exception e) {
                    // 4. 失败:入重试队列
                    handleRetry(billId, e);
                } finally {
                    // 5. 释放锁(避免死锁)
                    if (lockValue.equals(redisTemplate.opsForValue().get(lockKey))) {
                        redisTemplate.delete(lockKey);
                    }
                }
            } catch (Exception e) {
                log.error("消费异常", e);
                Thread.sleep(1000); // 异常后休眠,避免循环报错
            }
        }
    }).start();
}
  1. 业务处理核心步骤:
java 复制代码
private void processBill(String billId) {
    // 1. 查询单据(仅处理PENDING状态)
    Bill bill = billMapper.selectById(billId);
    if (bill == null || !"PENDING".equals(bill.getStatus())) return;

    // 2. 更新状态为"处理中"
    bill.setStatus("PROCESSING");
    billMapper.updateById(bill);
    redisTemplate.opsForValue().set("warehouse:bill:status:" + billId, "PROCESSING");

    // 3. 核心业务(入库/出库:库存更新、流水生成)
    BillDTO dto = JSON.parseObject(bill.getData(), BillDTO.class);
    inventoryService.updateStock(dto); // 库存操作(加事务)
    generateStockFlow(billId, dto); // 生成流水

    // 4. 处理成功:更新状态为SUCCESS
    bill.setStatus("SUCCESS");
    bill.setFinishTime(new Date());
    billMapper.updateById(bill);
    redisTemplate.opsForValue().set("warehouse:bill:status:" + billId, "SUCCESS");
}
  1. 重试/死信处理:
java 复制代码
private void handleRetry(String billId, Exception e) {
    Bill bill = billMapper.selectById(billId);
    int retryCount = bill.getRetryCount() == null ? 0 : bill.getRetryCount() + 1;

    if (retryCount <= 3) {
        // 重试:10秒后重新入队(Sorted Set按时间排序)
        long nextRetryTime = System.currentTimeMillis() + 10 * 1000;
        redisTemplate.opsForZSet().add("warehouse:bill:retry", billId, nextRetryTime);
        // 更新重试次数和失败原因
        bill.setRetryCount(retryCount);
        bill.setFailReason(e.getMessage().substring(0, 200));
        billMapper.updateById(bill);
        redisTemplate.opsForValue().set("warehouse:bill:status:" + billId, "RETRY_" + retryCount);
    } else {
        // 死信:入死信队列,状态设为FAIL
        redisTemplate.opsForList().leftPush("warehouse:bill:dead", billId);
        bill.setStatus("FAIL");
        bill.setFailReason("重试3次失败:" + e.getMessage().substring(0, 200));
        billMapper.updateById(bill);
        redisTemplate.opsForValue().set("warehouse:bill:status:" + billId, "FAIL");
    }
}
  1. 重试队列消费(单独线程):
java 复制代码
@PostConstruct
public void startRetryConsume() {
    new Thread(() -> {
        while (true) {
            try {
                // 查询当前时间前的重试消息
                long now = System.currentTimeMillis();
                Set<String> billIds = redisTemplate.opsForZSet().rangeByScore("warehouse:bill:retry", 0, now);
                if (CollectionUtils.isEmpty(billIds)) {
                    Thread.sleep(2000);
                    continue;
                }
                // 放回主队列重新消费
                for (String billId : billIds) {
                    redisTemplate.opsForZSet().remove("warehouse:bill:retry", billId);
                    redisTemplate.opsForList().leftPush("warehouse:bill:queue", billId);
                }
            } catch (Exception e) {
                log.error("重试队列消费异常", e);
                Thread.sleep(1000);
            }
        }
    }).start();
}

(四)单据查询服务

职责

  • 提供状态查询接口,支持前端轮询获取单据进度

核心逻辑(缓存优先)

java 复制代码
@GetMapping("/{billId}/status")
public Result getStatus(@PathVariable String billId) {
    // 1. 优先查Redis缓存
    String status = redisTemplate.opsForValue().get("warehouse:bill:status:" + billId);
    if (StringUtils.isNotEmpty(status)) {
        Bill bill = billMapper.selectById(billId);
        return Result.success(status, bill.getFailReason());
    }
    // 2. 缓存未命中查MySQL
    Bill bill = billMapper.selectById(billId);
    if (bill == null) return Result.fail("单据不存在");
    // 3. 缓存结果
    redisTemplate.opsForValue().set("warehouse:bill:status:" + billId, bill.getStatus(), 24, TimeUnit.HOURS);
    return Result.success(bill.getStatus(), bill.getFailReason());
}

四、关键技术保障

1. 数据可靠性(不丢失)

  • 单据先存MySQL再入队,Redis崩溃可从MySQL恢复
  • Redis开启AOF+RDB双持久化,队列数据不丢失
  • 死信队列兜底,重试3次失败仍可人工处理

2. 避免重复处理

  • Redis rightPop 原子性,多实例不会重复获取同一单据
  • 分布式锁(SETNX)+ 单据状态校验(仅处理PENDING),双重防重复
  • MySQL乐观锁(可选),更新状态时校验版本号

3. 高并发支持

  • Redis List 单实例TPS万级,支撑高并发提交
  • 处理服务多实例部署,水平扩展消费能力
  • 批量处理(重试队列批量放回主队列),提升效率

五、运维监控要点

1. 核心监控指标

指标 监控对象 告警阈值
主队列长度 LLEN warehouse:bill:queue >500条
死信队列长度 LLEN warehouse:bill:dead >10条
处理失败率 失败单据数/总单据数 >5%
Redis 内存使用率 INFO memory → used_memory_ratio >80%
单据处理时长 处理完成时间-提交时间 >5分钟

2. 日常运维操作

  • 定期清理Redis过期缓存(状态缓存24小时自动过期)
  • 每日检查死信队列,处理失败单据(导出后修复重新入队)
  • 监控Redis持久化日志,确保AOF/RDB正常生成
  • 处理服务多实例部署(建议2-3个),避免单点故障

六、常见问题与解决方案

问题场景 原因分析 解决方案
单据提交后状态一直PENDING 1. Redis队列未消费;2. 处理服务宕机 1. 检查消费线程是否运行;2. 重启处理服务;3. 手动触发队列消费
重复处理单据 分布式锁失效/状态校验遗漏 1. 检查锁过期时间(≥业务最大耗时);2. 强化状态校验(仅处理PENDING)
Redis队列堆积 消费能力不足 1. 增加处理服务实例;2. 优化业务处理逻辑(如批量处理)
单据丢失 先入队后存MySQL,Redis崩溃 调整顺序:先存MySQL再入队;定时任务扫描MySQL未入队单据重新入队

七、方案优势总结

  1. 零额外成本:复用现有Redis/MySQL,无需引入MQ
  2. 高可靠:双持久化+死信队列+状态校验,无消息丢失/重复处理
  3. 高性能:Redis原子操作+多实例消费,支撑高并发
  4. 易落地:开发成本低,核心逻辑清晰,运维简单
相关推荐
风的归宿555 小时前
监控利器:java异常监控
后端
踏浪无痕5 小时前
我们是如何把登录系统从“一行JWT”升级成企业级SSO的?
后端·面试·架构
步步为营DotNet5 小时前
深入理解IAsyncEnumerable:异步迭代的底层实现与应用优化
java·服务器·数据库
qq_479875435 小时前
protobuf[1]
java·开发语言
装不满的克莱因瓶5 小时前
【Java架构 搭建环境篇三】Linux安装Git详细教程
java·linux·运维·服务器·git·架构·centos
运维@小兵5 小时前
使用Spring-ai实现同步响应和流式响应
java·人工智能·spring-ai·ai流式响应
CoderYanger5 小时前
A.每日一题——3432. 统计元素和差值为偶数的分区方案
java·数据结构·算法·leetcode·1024程序员节
Geoking.5 小时前
JDK 版本与 Java 版本的关系
java·开发语言
huohuopro5 小时前
java基础深度学习 #1
java·开发语言·java基础