RocketMQ 单个消息大于 4M 的切割与发送

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

在 RocketMQ 配置中,有个配置项 maxMessageSize 配置项来更改发送与接收消息的大小限制,这里讨论一种不改变这一配置项的处理方式。

在 RoketMQ 文档中,示例了一种大批量消息切割的编码方式,但这种方式只能处理消息总量大于4M但单个消息小于4M的情况,对于单个消息本身就大于4M的情况则不能适用,本文就这种情况讨论一种切割方式。

实现基础

对于单个消息切割来说,切割完成之后由 producer 发送给 consumer 端,需要在 consumer 端进行消息拼接复原,我们使用 MessageBuilder 构建消息,然后使用 rocketMQTemplate 来发送消息,以异步发送 asyncSend 为例,考察发送前对消息做了何种处理,来决定消息切割前的准备,源码中由如下片段:

public static Message convertToRocketMessage(MessageConverter messageConverter, String charset, String destination, org.springframework.messaging.Message<?> message) {
    Object payloadObj = message.getPayload();
    byte[] payloads;
    try {
        if (null == payloadObj) {
            throw new RuntimeException("the message cannot be empty");
        }
        if (payloadObj instanceof String) {
            payloads = ((String)payloadObj).getBytes(Charset.forName(charset));
        } else if (payloadObj instanceof byte[]) {
            payloads = (byte[])((byte[])message.getPayload());
        } else {
            String jsonObj = (String)messageConverter.fromMessage(message, payloadObj.getClass());
            if (null == jsonObj) {
                throw new RuntimeException(String.format("empty after conversion [messageConverter:%s,payloadClass:%s,payloadObj:%s]", messageConverter.getClass(), payloadObj.getClass(), payloadObj));
            }
            payloads = jsonObj.getBytes(Charset.forName(charset));
        }
    } catch (Exception var7) {
        throw new RuntimeException("convert to RocketMQ message failed.", var7);
    }
    return getAndWrapMessage(destination, message.getHeaders(), payloads);
}
复制代码

上述源码是发送前处理消息载荷的必要步骤,可以看到,消息载荷最终都转为 byte [] 传送,故应先转化为 byte [] 再切割。

切割与发送

明确切割的方式后,需先将消息转为 byte [],这里用 com.alibaba.fastjson.JSON 中的 getBytes() 方式获取,参考文档中的切割类,构建适合的 Iterator 实现:

public class SplitMessage implements Iterator<byte []> {
    // 切割大小
    public final int SPLIT_SIZE = 1024 * 1024 * 4 - 80;
    public final byte [] message;
    private int cursor = 0;
    public SplitMessage(byte [] message){
        this.message = message;
    }
    @Override
    public boolean hasNext(){
        return cursor < size();
    }
    @Override
    public byte [] next(){
        byte [] r;
        int len;
        if(cursor < size() - 1){
            len = SPLIT_SIZE;
        } else {
            len = message.length - cursor * SPLIT_SIZE;
        }
        r = new byte[len];
        for(int i = 0 ; i < len; i++){
            r[i] = message[i + cursor * SPLIT_SIZE];
        }
        cursor++;
        return r;
    }
    public int size(){
        int s = message.length / SPLIT_SIZE;
        int y = message.length % SPLIT_SIZE;
        if(y != 0){
            s++;
        }
        return s;
    }
}
复制代码

发送的方法可为

public String sendRefundExmMessage(Map<String, String> map, int id){
    byte[] m = JSON.toJSONString(map).getBytes();
    SplitMessage sm = new SplitMessage(m);
    int len = sm.size();
    int i = 0;
    while (sm.hasNext()){
        byte [] now = sm.next();
        // 消息头设置总长度与本消息位置,为了 costumer 端拼接
        Message msg = MessageBuilder.withPayload(now).setHeader(MessageConst.PROPERTY_KEYS, id + "-" + i + "-" + len).build();
        // 异步发送
        rocketMQTemplate.asyncSend("testTopic:tag", msg, new SendCallback(){
            @Override
            public void onSuccess(SendResult sendResult){
                // 成功处理
            }
            @Override
            public void onException(Throwable throwable){
                // 错误处理
            }
        });
        i++;
    }
    return "done";
}
复制代码
接收与拼接

由于是异步发送,故每条消息到达 consumer 端顺序不一,故需要存入缓存,然后判断是否全部接收,consumer 可以如下编写:

@Component
@RocketMQMessageListener(
    consumerGroup = "test",
    topic = "testTopic",
    selectorExpression = "tag",
    messageModel = MessageModel.CLUSTERING,
    selectorType = SelectorType.TAG
)
public class ContentConsumer implements RocketMQListener<MessageExt> {
    // 注入 redis 服务
    @Autowired
    private RedisTemplate redisTemplate;
    @Override
    public void onMessage(MessageExt msg) {
        String key = msg.getKeys();
        String[] keyValues = key.split("-");
        int id = new Integer(keyValues[0]).intValue();
        boolean repeat = false;
        try {
            // 查看缓存中消息是否被消费
            repeat = redisTemplate.opsForValue().get(id) == null ? false : (boolean) redisTemplate.opsForValue().get(id);
        } catch (Exception e) {
            // 错误处理
        }
        if (repeat) {
            // 如果已被消费,避免重复消费
            return;
        } else {
            // 获取当前消息片段位置
            int cursor = new Integer(keyValues[1]).intValue();
            // 获取消息片段总数
            int size = new Integer(keyValues[2]).intValue();
            if (cursor < size - 1) {
                // 如果位置在最后一条之前便存入缓存
                redisTemplate.opsForValue().set(id + "-" + cursor, msg.getBody());
                // 设置保存时间
                redisTemplate.boundValueOps(id + "-" + cursor).expire(5L, TimeUnit.MINUTES);
            } else {
                // 如果是最后一条片段,便进行下一步处理
                byte[] all = new byte[0];
                for (int i = 0; i < size - 1; i++) {
                    // 每 500 毫秒一次查询 redis,查看值钱的消息是否接收
                    int max = 100;
                    while (redisTemplate.opsForValue().get(id + "-" + i) == null && max > 0) {
                        try {
                            Thread.sleep(500L);
                            max--;
                        } catch (InterruptedException e) {
                            // 报错
                        }
                    }
                    // 50 秒后如果消息数不满总数,则返回报错(这里为了方便只返回)
                    if (max == 0) return;
                    // 获取到第 i 条消息便做拼接
                    byte[] temp = (byte[]) redisTemplate.opsForValue().get(id + "-" + i);
                    all = ByteBuffer.allocate(all.length + temp.length).put(all).put(temp).array();
                    redisTemplate.delete(id + "-" + i);
                }
                all = ByteBuffer.allocate(all.length + msg.getBody().length).put(all).put(msg.getBody()).array();
                JSONObject json = JSON.parseObject(new String(all));
                Map<String, String> map = (Map<String, String>) json;
                // 消息处理
                
                // 设置消息消费成功标识
                redisTemplate.opsForValue().set(id, true);
                redisTemplate.boundValueOps(id).expire(5L, TimeUnit.MINUTES);
            }
        }
    return;
    }
}
复制代码

上述消费端主要需要有一种合适的方式来查验和拼接消息,这里提供的方式仅供参考,这个方法可以使消费端扩展到多台服务器部署,只要共用一个 redis 服务便可。

本文仅讨论一种可行方案,对于大体积消息,不止本文探讨的方法可以使用,本文提供的方案也不是最优解。

Guess you like

Origin juejin.im/post/7050484983894376484