消息持久化存储引擎
题目
实现一个进程内消息持久化存储引擎,要求包含以下功能:
-
发送消息功能
-
根据一定的条件做查询或聚合计算,包括
A. 查询一定时间窗口内的消息 B. 对一定时间窗口内的消息属性某个字段求平均,以及求和
例子:t表示时间,时间窗口(1000, 1002)表示: t>1000 & t<1002
消息内容简化成两个字段,一个是业务字段a(整数),一个是时间戳(long)。
1800内接收20亿条,每条50B,总共100G,每秒大概100W条,50M
SSD读写300M/s
思路
初版
-
分片:因为内存保存不下全部Msg,需要分片存储和读取。参考kafka的时间戳索引概念,按照时间戳分片&落盘,落盘文件名即为分片index。比如插入ts=0x 0111时,如果分区个数为4,则index=ts>>2 = 01
-
当某一个分片满了之后先不落盘,因为有可能出现数据晚到,当晚到的数据到达是,对应的分片如果已经落盘了,还需要重新读出来、放入该分片中,再落盘(当然也可以直接追加到文件尾部,但是还不如一次性把文件写好效率高)。所以记录一个waterMark,这里代表已经落盘的shardId,当最新的分片Id超过waterMark2个时,再落盘waterMark++;
-
分片内为了保证顺序,使用索引,可以用跳表存储Msg(顺序插入时TreeSet红黑树旋转开销大)。查询时,定位文件分片+文件内部二分
-
t可以相同,如何存储a,body?,所以每个t要对应一个List<Long> 和 List<body>
-
-
底层存储:
-
t,a,body可以分别存储到不同的文件,在查询avg时,不需要查询body
-
t,a,body分片的粒度可以不同,考虑:t作为索引,是应该尽量保存到内存?还是可以缩小粒度,但是几乎不存储索引,而是让更多的body保存到内存,可以减少IO?
-
nio 落盘
-
面向块,而不是字节
-
通道(Channel),一种新的原生I/O抽象概念
-
内存映射的文件
-
通过Selector多路复用、非阻塞的I/O能力实现可伸缩的服务器架构
-
-
序列化:Java自带序列化性能差、冗余多。考虑直接保存原始数据而不是对象。
-
-
查询
-
lru:LRU或LFU,但是发现会发生大量minorGC,原因是生成了过多对象。所以就题目来讲,不需要使用对象封装消息,直接对t(long)或者body(byte数组)进行操作。
-
为何何最终采用LRU
-
LFU淘汰一定时期内被访问次数最少的页, 但是比赛的getT是平均的,最近访问的最少,反而后面更有可能被访问
-
LRU淘汰最近最少使用的页,不考虑访问次数
-
-
实例代码
消息写入:
// 保存A
int shardId = getShardAId(msgT);
NavigableMap<Long, List<Long>> targetShardA;
// 当分片还未创建,则生成分片
if (!ShardAHasCreated.get(shardId)) {
ConcurrentSkipListMap<Long, List<Long>> newShard = new ...
ShardAHasNotSaved.put(shardId, newShard);
// t-->t时刻的所有a , BlockingQueue<NavigableMap<Long, List<Long>>>, SaveAJob线程会负责将该queue落盘
shardsA2SaveQueue.put(newShard);
ShardAHasCreated.set(shardId);
}
targetShardA = ShardAHasNotSaved.get(shardId);
if (targetShardA == null) {// 数据晚到,但是分片早已落盘,则应当load
}
List<Long> arrA = targetShardA.computeIfAbsent(msgT, t -> new ArrayList<>());
arrA.add(message.getA());
// 保存Body
shardId = getShardBodyId(msgT);
NavigableMap<Long, List<byte[]>> targetShardBody;
if (!ShardBoydHasCreated.get(shardId)) {
ConcurrentSkipListMap<Long, List<byte[]>> newShard = new ..
ShardBodyHasNotSaved.put(shardId, newShard);
shardsBody2SaveQueue.put(newShard);
ShardBoydHasCreated.set(shardId);
}
targetShardBody = ShardBodyHasNotSaved.get(shardId);
if (targetShardBody == null){// 数据晚到,但是分片早已落盘,则应当load
}
List<byte[]> arrBody = targetShardBody.computeIfAbsent(msgT, t -> new ArrayList<>());
arrBody.add(message.getBody());
其中,获取分片的方法如下
private static int getShardAId(long t) {
//取t的高 SHARD_A_SHIFT 位,作为分片的index
return (int) (t >> SHARD_A_SHIFT);
}
private static long SHARD_A_NUM = 1L << 46;
private static int SHARD_A_SHIFT = 64 - (int) (Math.log(SHARD_A_NUM) / Math.log(2D));
private static int getShardBodyId(long t) {
return (int) (t >> SHARD_BODY_SHIFT);
}
private static long SHARD_BODY_NUM = 1L << 46;
private static int SHARD_BODY_SHIFT = 64 - (int) (Math.log(SHARD_BODY_NUM) / Math.log(2D));
消息落盘
private static final int SAVE_WAITING_INTERVAL_SHARD_NUM = 2;// 当第i+n个shard创建后,才落盘第i个分区
private static class SaveAJob implements Runnable {
public static final String SHARD_A_FILE_PREFIX = SAVE_PATH + "A";
private static final BitSet shardHasSaved = new BitSet();
public void run() {
while (true) {
StringBuilder sb = new StringBuilder();
NavigableMap<Long, List<Long>> shard = shardsA2SaveQueue.take();
while(shard.size()==0)
Thread.sleep(50);
int shard2SaveId = getShardAId(shard.firstKey());
while (shard2SaveId > getShardAId(maxT) - SAVE_WAITING_INTERVAL_SHARD_NUM)
Thread.sleep(50);
saveShard(ShardAHasNotSaved.get(shard2SaveId),
sb.append(SHARD_A_FILE_PREFIX).append(shard2SaveId).toString());
shardHasSaved.set(shard2SaveId);
ShardAHasNotSaved.remove(shard2SaveId);
sb.setLength(0);
}
}
private void saveShard(NavigableMap<Long, List<Long>> shard, String fileName) throws IOException {
Instant s = Instant.now();
ByteBuffer buffer = ByteBuffer.allocateDirect(4096);
FileChannel outFile = FileChannel.open(Paths.get(fileName), StandardOpenOption.APPEND,
StandardOpenOption.CREATE);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(shard);
byte[] shardBytes = baos.toByteArray();
for (int offset = 0; offset < shardBytes.length; offset += 4096) {
buffer.put(shardBytes, offset, shardBytes.length - offset < 4096 ? shardBytes.length - offset : 4096);
buffer.flip();
outFile.write(buffer);
buffer.clear();
}
outFile.close();
long l = Duration.between(s, Instant.now()).toMillis();
}
数据查询
public List<Message> getMessage(long aMin, long aMax, long tMin, long tMax) {
if (tMin < minT) tMin = minT;
if (tMax > maxT) tMax = maxT;
List<Message> res =
LongStream.rangeClosed(tMin, tMax)
.parallel()
.boxed()
.flatMap(t -> {
int shardId = getShardAId(t);
NavigableMap<Long, List<Long>> targetShard = loadShardA(shardId);
if (targetShard == null)
return null; //如果t不连续,有可能整个分片不存在
List<Long> longs = targetShard.get(t);
if (longs == null)
return null; //如果t不连续,有可能某个t不存在任何Msg
int shardIdB = getShardBodyId(t);
NavigableMap<Long, List<byte[]>> targetBodyShard = loadShardBody(shardIdB);
List<byte[]> bytes = targetBodyShard.get(t);
return IntStream.range(0, longs.size())
.mapToObj(i -> new Message(longs.get(i), t, bytes.get(i)));
})
.filter(msg -> msg.getA() >= aMin && msg.getA() <= aMax)
.collect(toList());
return res;
}
private static NavigableMap<Long, List<Long>> loadShardA(int shardId) {
shardACount.increment();
if (!SaveAJob.shardHasSaved.get(shardId)) {//还未落盘
return ShardAHasNotSaved.get(shardId);
}
return cache4A.get(shardId);
}
private static final LinkedHashLRUCache<Integer, NavigableMap<Long, List<Long>>> cache4A =
new LinkedHashLRUCache<>(SHARD_A_CACHE_SIZE)
{
@Override
protected NavigableMap<Long, List<Long>> load(Integer shardId) {
NavigableMap<Long, List<Long>> targetShard = null;
targetShard = doLoadAShard(SaveAJob.SHARD_A_FILE_PREFIX + shardId);
return targetShard;
}
};
private static NavigableMap<Long, List<Long>> doLoadAShard(String fileName) throws IOException, ClassNotFoundException {
NavigableMap<Long, List<Long>> navigableMap;
FileChannel fileChannel = FileChannel.open(Paths.get(fileName), StandardOpenOption.READ);
ByteBuffer buffer = ByteBuffer.allocate(4096);
byte[] src = new byte[(int) fileChannel.size()];
int index = 0;
while (fileChannel.read(buffer) != -1) {
buffer.flip();
byte[] bytes = new byte[buffer.remaining()];
buffer.get(bytes);
for (int i = 0; i < bytes.length; i++) {
src[index++] = bytes[i];
}
buffer.clear();
}
ByteArrayInputStream bais = new ByteArrayInputStream(src);
ObjectInputStream ois = new ObjectInputStream(bais);
navigableMap = (NavigableMap<Long, List<Long>>) ois.readObject();
fileChannel.close();
return navigableMap;
}
二版
学习kafka: 不缓存,直接落盘,顺序写,
-
这样就可以直接利用os页缓存,减少数据从JVM内存拷贝到页缓存的过程。
-
不占用JVM内存
发现效果一般,同步落盘还是异步落盘?
后期使用索引、学习ssd特性、读kafka源码
最终获得决赛83/200名
总结与待优化:
插入
- 分线程存储不同分片&文件,则不需要做排序。搜索时直接合并即可,但要读取多个文件。
- 参考kafka的索引算法,分为baseIndex和relativeIndex,根据前者定位分片文件,根据后者直接定位到分片文件内的偏移量
存储
- 存储时做压缩
- 异步落盘
查询
- 根据比赛测试程序阶段,在批量读取的时候,提前批量message对象