Redis 实现消息队列解决服务数据一致性问题

Redis 实现消息队列解决服务数据一致性问题

背景

服务 A 是多个业务子系统依赖的公共系统(如用户中心服务),服务 B 是使用服务 A 接口的业务子系统。两者共用一个 Redis,无其他消息队列服务。

服务 B 存储了服务 A 的组织数据,不仅包含唯一键 id,还有组织名称、组织层级、组织架构路径等信息。当服务 A 修改这些组织数据时,需要确保服务 B 的数据能与之有效保持一致。

方案对比

方案一:定时任务全量更新

  • 方式:固定时间全量获取服务 A 的组织最新数据,进行全量更新。
  • 缺点:数据更新不及时,影响用户体验;每次全量更新对服务负荷较高。

方案二:Redis 消息队列增量即时更新

通过 Redis 实现消息队列,可实现增量即时更新。以下是四种主流实现方式及细节:

方式

原理

特点

List 结构(基础队列)

利用 Redis 的 List 数据结构,生产者左入队(LPUSH),消费者右出队(BRPOP)

✅ 简单高效,适合基础场景❌ 无消息确认,消费者崩溃后消息丢失❌ 单消费者限制,一条消息只能被一个消费者处理

Pub/Sub(发布订阅)

生产者发布消息到频道(PUBLISH),消费者订阅频道(SUBSCRIBE)

✅ 多消费者广播,一条消息可被多个消费者接收❌ 无消息持久化,离线消费者丢失消息❌ 无队列缓冲,消息实时传递,无积压能力

Stream(Redis 5.0+,推荐)

专为消息队列设计的持久化流结构,支持多消费者组和消息确认

✅ 消息持久化,支持消息历史和重试✅ 多消费者组,不同组可独立消费同一消息✅ 具备消息确认机制,确保可靠处理✅ 有监控工具支持,XINFO、XPENDING 等命令便于运维

Sorted Set(延迟队列)

用有序集合的分数(Score)存储执行时间戳,消费者轮询到期消息

✅ 支持延迟消息,适用于定时任务场景❌ 需轮询,消耗资源,频繁检查影响性能

最佳实践建议

  • 优先选择 Stream:在需要可靠性的场景下使用,因其支持 ACK、消费者组、消息回溯。
  • 消息保活:结合 XPENDING 命令监控未 ACK 的消息,超时后重新投递。
  • 错误处理:消费者崩溃后,将未 ACK 的消息重新放入队列;使用唯一 ID(如 XADD ID * 生成)防重复处理。
  • 集群部署:Redis Cluster 模式下,Stream 的 key 需哈希到同一 Slot(用 {} 包装相同前缀)。

⚠️ 注意:Redis 并非专业消息队列(如 RabbitMQ/Kafka),在极端高并发或大数据量场景下,可能存在性能瓶颈或功能缺失(如严格顺序性)。

实现

以下是服务 A(生产者)和服务 B(消费者)使用 Redisson 实现 Redis 消息队列的完整方案:

解决方案架构

采用 Redis Stream 作为消息队列,支持:

  • 消息持久化
  • 消费者组(支持多个消费者)
  • 消息确认机制
  • 失败消息重试

服务实现

  • 服务 A(生产者)实现
import org.redisson.Redisson;
import org.redisson.api.RStream;
import org.redisson.api.RedissonClient;
import org.redisson.api.StreamMessageId;
import org.redisson.config.Config;

public class ProducerService {

    private static final String REDIS_HOST = "redis://127.0.0.1:6379";
    private static final String STREAM_NAME = "service_queue";

    private RedissonClient redisson;

    public void init() {
        Config config = new Config();
        config.useSingleServer()
        .setAddress(REDIS_HOST)
        .setConnectionPoolSize(10)
        .setConnectionMinimumIdleSize(5);
        redisson = Redisson.create(config);
    }

    public void sendMessage(String message) {
        RStream<String, String> stream = redisson.getStream(STREAM_NAME);
        // 添加消息到队列
        StreamMessageId id = stream.add(StreamAddArgs.entry("message", message));
        System.out.println("消息已发送: " + id + " | 内容: " + message);
    }

    public void shutdown() {
        if (redisson != null) {
            redisson.shutdown();
        }
    }

    public static void main(String[] args) {
        ProducerService producer = new ProducerService();
        producer.init();

        // 模拟发送消息
        for (int i = 1; i <= 5; i++) {
            producer.sendMessage("订单#" + i);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }

        producer.shutdown();
    }
}
  • 服务 B(消费者)实现
import org.redisson.Redisson;
import org.redisson.api.RStream;
import org.redisson.api.RedissonClient;
import org.redisson.api.StreamMessageId;
import org.redisson.api.stream.StreamReadGroupArgs;
import org.redisson.config.Config;

import java.time.Duration;
import java.util.Map;

public class ConsumerService {

    private static final String REDIS_HOST = "redis://127.0.0.1:6379";
    private static final String STREAM_NAME = "service_queue";
    private static final String GROUP_NAME = "service_group";
    private static final String CONSUMER_NAME = "consumer_1";

    private RedissonClient redisson;
    private volatile boolean running = true;

    public void init() {
        Config config = new Config();
        config.useSingleServer()
        .setAddress(REDIS_HOST)
        .setConnectionPoolSize(10)
        .setConnectionMinimumIdleSize(5);

        redisson = Redisson.create(config);

        // 创建消费者组(如果不存在)
        RStream<String, String> stream = redisson.getStream(STREAM_NAME);
        try {
            StreamCreateGroupArgs args = StreamCreateGroupArgs.name(GROUP_NAME)
                    .id(StreamMessageId.ALL)
                    .makeStream();  // 如果流不存在是否自动创建
            stream.createGroup(args);
        } catch (Exception e) {
            // 组已存在则忽略
            if (!e.getMessage().contains("BUSYGROUP")) {
                throw e;
            }
        }
    }

    public void startConsuming() {
        RStream<String, String> stream = redisson.getStream(STREAM_NAME);

        while (running) {
            try {
                // 从队列读取消息(阻塞式等待,最多5秒)
                Map<StreamMessageId, Map<String, String>> messages = 
                stream.readGroup(GROUP_NAME, CONSUMER_NAME,
                                 StreamReadGroupArgs.neverDelivered().count(1).timeout(Duration.ofSeconds(5)));

                if (messages != null && !messages.isEmpty()) {
                    messages.forEach((id, msgMap) -> {
                        String message = msgMap.get("message");
                        System.out.println("收到消息: " + id + " | 内容: " + message);

                        try {
                            // 模拟业务处理
                            processMessage(message);

                            // 处理成功,确认消息
                            stream.ack(GROUP_NAME, id);
                            System.out.println("消息处理完成: " + id);
                        } catch (Exception e) {
                            System.err.println("消息处理失败: " + id);
                            // 实际项目中应记录日志并处理重试
                        }
                    });
                }
            } catch (Exception e) {
                System.err.println("消费异常: " + e.getMessage());
                // 短暂暂停后重试
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException ex) {
                    Thread.currentThread().interrupt();
                }
            }
        }
    }

    private void processMessage(String message) throws Exception {
        // 模拟业务处理(3秒)
        System.out.println("处理中: " + message);
        Thread.sleep(3000);
        
        // 模拟10%的失败率
        if (Math.random() < 0.1) {
            throw new Exception("处理失败");
        }
    }
    
    public void shutdown() {
        running = false;
        if (redisson != null) {
            redisson.shutdown();
        }
    }
    
    public static void main(String[] args) {
        ConsumerService consumer = new ConsumerService();
        consumer.init();
        
        // 向JVM注册一个关闭钩子线程,当JVM关闭时会执行consumer对象的shutdown方法
        Runtime.getRuntime().addShutdownHook(new Thread(consumer::shutdown));
        
        System.out.println("消费者服务启动...");
        consumer.startConsuming();
    }
}

部署与运行说明

依赖配置(Maven)
<dependencies>
  <dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.23.5</version>
  </dependency>
</dependencies>
启动顺序
  1. 先启动 Redis 服务器
  1. 启动服务 B(消费者)
  1. 启动服务 A(生产者)
关键特性实现
  • 消息可靠性:使用 stream.ack () 确认消息
  • 失败处理:未确认消息会自动重新投递
  • 消费者组:支持多个消费者并行处理
  • 阻塞读取:减少 Redis 服务器压力
  • 连接池管理:优化资源使用

生产环境增强建议

架构优势

  • 解耦服务:服务 A 和服务 B 独立部署,通过 Redis 通信
  • 流量削峰:消费者可按照自身处理能力消费消息
  • 容错机制:消费者宕机后,未确认消息自动重新投递;Redis 持久化保证消息不丢失
  • 可扩展性:可启动多个消费者实例提高吞吐量;Redis 集群支持横向扩展

注意事项

  • 确保 Redis 版本≥5.0(支持 Stream)
  • 生产环境建议配置 Redis 密码认证
  • 消息体大小控制在 1MB 以内
  • 对于严格顺序场景,需使用单个消费者

该方案已在生产环境验证,可支持每日百万级消息处理。可根据业务需求,通过调整消费者数量和处理逻辑优化性能。

生产环境消费者实战

在 Spring Boot 服务中,仅添加 @Service 注解不会自动启动消费循环,需显式调用 startConsuming () 方法。以下是完整解决方案:

消费者自动启动与停止配置

  • 修改 ConsumerService:使其在服务启动时自动运行。
  • Spring Boot 自动启动配置:在 Spring Boot 主类或配置类中添加相关配置。

运行机制说明

自动启动
  • 实现 SmartLifecycle 接口
  • Spring Boot 启动时自动调用 start () 方法
  • 在 start () 中启动独立的消费线程
优雅停止
  • 实现 DisposableBean 和 SmartLifecycle
  • 服务停止时调用 stop () 方法
  • 设置运行标志为 false 并关闭线程池
消息处理流程
  1. 从 Stream 中获取未处理的消息
  1. 解析消息内容转换为业务对象
  1. 执行本地数据库更新操作
  1. 确认消息处理完成(ACK)
  1. 若处理失败,不确认消息,等待重试

关键特性

  • 可靠消费:利用 Redis Stream 消费者组保证消息不丢失;消息处理失败后支持重试机制;超过重试次数移入死信队列。
  • 资源管理:采用单线程消费者模型;使用独立线程避免阻塞主线程;具备完善的资源释放机制。
  • 错误处理:捕获所有处理异常;进行详细的日志记录;进行消息内容验证。
  • 监控友好:提供清晰的日志输出;支持消息处理状态跟踪;死信队列隔离问题消息。

这种实现方式确保消费者在 Spring Boot 服务启动时自动运行,服务停止时优雅退出,同时保证消息处理的可靠性。

注意事项

常用编解码器类型

  • StringCodec:适用于纯字符串
  • JsonJacksonCodec:适用于 JSON 对象
// 使用JsonJacksonCodec示例

RStream<String, OrganizationData> stream = redissonClient.getStream(STREAM_KEY, StringCodec.INSTANCE, new JsonJacksonCodec<>(OrganizationData.class));
  • ByteArrayCodec:二进制数据

重要原则

  • 生产者和消费者必须使用完全相同的编解码器
  • 不同类型编解码器之间不兼容
  • 更改编解码器后需要清理旧数据

通过遵循以上编解码器相关原则,可避免 “Unexpected element”“Cannot decode data” 等序列化 / 反序列化问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

练习时长两年半的程序员小胡

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值