先说
今天分享一个之前写的基于redis和kafka实现类似银行叫号
前言
之前作过某风控系统,系统内会设计调用大量的三方数据,导致每一笔风控审核的时候会出现某一笔的消费延迟导致上游的消费速度显著下降,从而出现严重的卡单(kafka)。
确定方案
之前通过kafka消费主要是,并发消费,其类比的生活模式是早期银行排队,也就是kafka队列类似银行的多个窗口。假设银行规定,排队之后不能更换窗口,一个数据类似是来银行排队处理的人,比如如果某一个人出现处理速度过慢,那么有可能出现某一些窗口空闲而其他窗口排队很长的情况,所以当时讨论解决的方案就是将排队改成叫好的模式。当窗口出现可用资源就主动获取资源。
实现
分区和消费数据的流转
数据的获取和流转主要的mq架构还是基于kafka,使用redis来存储kafka的partition,在消费端额外实现一个类似叫号的程序(使用CompletableFuture新启一个线程,通过while循环不断监控redis的资源。大致的代码如下)
@Override
public void run() {
while(true){
log.info("进入while"+(new Date()).toString());
String message =null;
try {
Map<String,Object> messagePattern = configService.getMessagePattern();
if(!(Boolean)messagePattern.get("enable")){
break;
}
log.info("准备获取message"+(new Date()).toString());
message = messageHandlePattern.getAndAck(10L, TimeUnit.SECONDS,this::consume);
});
if(StringUtils.isEmpty(message)){
continue;
}
/**
* 消费之前监控
*/
Date outTime = new Date();
List<Integer> list =redisUtil.rangeUniqueQueue(THIRD_PARTY_RISK_TOPIC_PARTITIONS_LIST,0L,-1L);;
if(list == null){
list = Collections.emptyList();
}
List<String> finalList = list.stream().map(Object::toString).collect(Collectors.toList());
String finalMessage = message;
} catch (Exception e) {
log.error("{}:{}:{} 处理消息失败",port,THIRD_PARTY_RISK_TOPIC,message,e);
}
}
}
在数据流转这块代码实现中遇到的一些问题以及解决方案:
0.存储分区的数据格式选择
一开始选择redis存储分区的结构,我想了好几个方案,比如redis的队列list,list并不保证不可重复,在重平衡时重置redis消费分区,可能会导致redis分区出现重复现象,这样对于分区的数据分布是不均匀,kafka的消费性能没有被高效利用,所以我想到了set,zset可以通过score实现自定义的出入算法,但是zset效率相比list略低,并且对于我们分区的栈出入规则,list更加符合我们的当下需求,而为了解决本身的list平衡导致的问题,我使用了lua脚本打包了先查后加的命令。
"if redis.call('EXISTS',KEYS[1]) == 1 then\n" +
" redis.call('LREM',KEYS[1],0,ARGV[1])\n" +
"end\n" +
"return redis.call('LPUSH',KEYS[1],ARGV[1])\n";
1.如何保证在集群服务重启中保证 redis 分区资源重新赋值。
kafka本身提供了重平衡的监听器ConsumerRebalanceListener,提供了平衡前(停止消费之后)和平衡后(平衡完成开始消费之前)方法入口,我们只要在平衡前清空redis,并且在平衡后将每一个节点分配的分区发送到redis就可以保证服务重启redis数据不会重复和丢失了。
数据的持久化
保证数据不丢失时非常重要的,这块我研究了kafka和rocketmq的一些实现思路,大致说下自己的实现方案
0.数据文件页的命名
指定每一个数据文件存放指定的大小文件,如果达到指定的消费数量,创建新文件进行数据存放,并且通过自增数字后缀作为文件后缀,为此我写了一个字符串的数据加减算法
/**
* 字符串数字加一
* @param num
* @return
*/
private static String stringNumIncr(String num) {
StringBuffer sb = new StringBuffer();
int add = 0, i = num.length() - 1, remainder = 0;
int param = 1;
while (i >= 0 || add != 0) {
int x = i >= 0 ? num.charAt(i) - '0' : 0;
int result = x + param;
add = result / 10;
remainder = result % 10;
param = 0 + add;
sb.append(remainder);
i--;
}
sb.reverse();
return sb.toString();
}
/**
* 字符串数字减一
* @param num
* @return
*/
private static String stringNumDecr(String num) {
if (num.equals("0")) {
return "0";
}
StringBuffer sb = new StringBuffer();
int param = 1, i = num.length() - 1;
boolean borrow = false;
while (i >= 0) {
//计算本次请求减数大小
int minus = param + (borrow ? 1 : 0);
int x = num.charAt(i) - '0';
if (x < minus) {
borrow = true; // 借一位
x += 10;
} else {
borrow = false;
}
int result = x - minus;
param = 0;
sb.append(result);
i--;
}
if(sb.toString().equals("0")){
return sb.toString();
}
sb.reverse();
return sb.toString().replaceFirst("0*", "");
}
1.文件存储数据
持久化必然使用文件存储,所以第一个问题时io效率,普通的io是随机io,读写效率低下,rocketmq使用时顺序读写,至于为什么顺序读写比随机io效率更高大家可以去网上取搜下专门的讲解。jdk nio为我们提供了磁盘顺序读写的方案,使用MappedByteBuffer 操作系统提供的一个内存映射的机制的类可以映射磁盘数据到jvm内存,并且可以指定起止位置,刷新文件到磁盘则由操作系统完成,代码只需要操作内存数据。
2.数据定长
需要顺序读写数据,必定要求数据是一个固定的长度,比如我这边设置长度就是20,这样可以每次写数据时指定具体的数据大小。
3.数据拉取
通过poll方法,不断从文件读取数据到本地的quene中,这样提高消费的速度
public void poll() throws Exception {
synchronized (READ_INDEX) {
if (BLOCKING_QUEUE.size() >= QUEUE_MAX_SIZE) {
return;
}
if (READ_INDEX.get() == PRE_FILE_SIZE) {
READ_PAGE.set(MessageFileHandlePattern.stringNumIncr(READ_PAGE.get()));
READ_INDEX.set(0L);
}
String filePath = MESSAGE_DATA_DIR + key + "_" + READ_PAGE.get();
File file = new File(filePath);
if (!file.exists()) {
return;
}
if (READ_INDEX.get() == 0L) {
if(dataRFileChannel != null && dataRFileChannel.isOpen()){
dataRFileChannel.close();
}
dataRFileChannel = new RandomAccessFile(file, "r").getChannel();
}
if(dataRFileChannel==null || !dataRFileChannel.isOpen() ){
dataRFileChannel = new RandomAccessFile(file,"r").getChannel();
}
List<String> datas = fileRead(dataRFileChannel, READ_INDEX.get(), PRE_POLL_SIZE);
BLOCKING_QUEUE.addAll(datas);
READ_INDEX.addAndGet(datas.size() * MESSAGE_SIZE);
if(readRWFileChannel == null||!readRWFileChannel.isOpen()){
readRWFileChannel = new RandomAccessFile( new File(INDEX_DATA_DIR + key + READ_FILE_NAME_SUFFIX),"rw").getChannel();
}
fileCoverWrite(readRWFileChannel,READ_PAGE.get() + "_" + READ_INDEX.get());
}
}
4.偏移量索引
如kafka一样我们需要自己消费数据的偏移量,也就是数据消费到了第几个,下一次消费的时候从偏移量读取,
5.数据的读写
顺序读写代码
/**
* 消息追加写
* @param fileChannel 文件通道
* @param postion 写入起始位置
* @param content 写入的内容
* @throws Exception
*/
private void fileWrite(FileChannel fileChannel , Long postion, String content) throws Exception {
// 操作系统提供的一个内存映射的机制的类
MappedByteBuffer map = fileChannel.map(FileChannel.MapMode.READ_WRITE, postion, MESSAGE_SIZE);
byte[] bytes = new byte[MESSAGE_SIZE];
System.arraycopy(content.getBytes(), 0, bytes, 0, content.getBytes().length);// 数组拷贝
map.position(0);
map.put(bytes, 0, MESSAGE_SIZE);// 存操作
}
/**
* 数据顺序读取
* @param fileChannel 读取对应的文件
* @param postion 读取起始位置
* @param size 一次读取数据大小
* @return
* @throws Exception
*/
private List<String> fileRead(FileChannel fileChannel ,Long postion, Integer size) throws Exception {
List<String> list = new ArrayList<>();
long maxSize = fileChannel.size() ;
long restSize = maxSize - postion < 0?0:maxSize - postion; //如果小于0 说明已经读取完了
long reqireSize = MESSAGE_SIZE * size ;
if(restSize==0){
return Collections.emptyList();
}
if (restSize < reqireSize) {
reqireSize = restSize ;
}
MappedByteBuffer map = fileChannel.map(FileChannel.MapMode.READ_ONLY, postion, reqireSize);
byte[] byteArr = new byte[MESSAGE_SIZE];
for (int i = 0; i < reqireSize / MESSAGE_SIZE; i++) {
//从position位置开始相对读,读length个byte,并写入dst下标从offset到offset+length的区域
map.get(byteArr, 0, MESSAGE_SIZE);
if (byteArr == null || byteArr.length == 0) {
break;
}
String data = new String(byteArr).trim();
if(StringUtils.isEmpty(data)){
break;
}
list.add(data);
}
return list;
}
6.数据归档
将已经消费的数据进行规定,并且我将数据文件、索引文件、归档文件分文件夹存放,避免数据堆积导致io压力过大
后言
中间出现过因为读写没有关闭io流导致的linux最大文件句柄被耗尽