本文已参与「新人创作礼」活动,一起开启掘金创作之路。
在 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 服务便可。
本文仅讨论一种可行方案,对于大体积消息,不止本文探讨的方法可以使用,本文提供的方案也不是最优解。