第四届阿里中间件比赛-mq优化

3,112 阅读7分钟

前言


我最近很喜欢去看天池的比赛,虽然过往很多年前了,18年吧那一期,刚好我大三,当时我比较少关注这些比赛。为什么这类我会忽然关注呢? 因为无论是日常面试那些高大上的知识理论,还是我们日常了解的东西,它都没有一个应用场景来真正落地实践,比如说高并发,很多都是日常几十qps,我上家商城能达到上w的qps,之前阿里面试官搞过淘宝活动,大概上千万并发。

理论是这么一个理论,但是每个层级、不同的场景,它对应的技术方案也是不一样的,这才是实践的意义。

第四届阿里中间件比赛


链接:tianchi.aliyun.com/competition…

  • 复赛 **:《Apache RocketMQ消息缓存设计 》

    **赛题描述:
    Apache RocketMQ作为的一款分布式的消息中间件,历年双十一承载了万亿级的消息流转,为业务方提供高性能低延迟的稳定可靠的消息服务。随着业务的逐步发展和云上的输出,单机队列数量的逐步增加,给RocketMQ带来了新的挑战。复赛的题目要求设计一个单机百万队列以上的存储引擎,单机内存有限,需要充分利用数据结构与存储技术,最大化吞吐量。

    题目描述:
    持续更新地址:code.aliyun.com/middlewarer…

  • 2.1 题目内容
    实现一个进程内的队列引擎,单机可支持100万队列以上。

  • 2.2 语言限定
    JAVA和C++

  • 3. 程序目标
    仔细阅读demo项目中的QueueStore,DefaultQueueStoreImpl,DemoTester三个类。

  • 你的coding目标是重写DefaultQueueStoreImpl,并实现以下接口: abstract void put(String queueName, String message); abstract Collection get(String queueName, long offset, long num);

  • 4.参赛方法说明
    在阿里天池找到"中间件性能挑战赛",并报名参加
    在code.aliyun.com注册一个账号,并新建一个仓库名,并将大赛官方账号middlewarerace2018添加为项目成员,权限为reporter
    fork或者拷贝本仓库的代码到自己的仓库,并实现自己的逻辑
    在天池提交成绩的入口,提交自己的仓库git地址,等待评测结果
    坐等每天10点排名更新
    4. 测试环境描述
    测试环境为4c8g的ECS,限定使用的最大JVM大小为4GB(-Xmx4g)。带一块500G左右大小的SSD磁盘。

  • 5. 程序校验逻辑
    校验程序分为三个阶段: 1.发送阶段 2.索引校验阶段 3.顺序消费阶段 请详细阅读DemoTester以理解评测程序的逻辑。

  • 5.1. 程序校验规模说明
    1.各个阶段线程数在20 ~ 30左右 2.发送阶段:消息大小在50字节左右,消息条数在20亿条左右,也即发送总数据在100G左右 3.索引校验阶段:会对所有队列的索引进行随机校验;平均每个队列会校验1~2次; 4.顺序消费阶段:挑选20%的队列进行全部读取和校验; 5.发送阶段最大耗时不能超过1800s;索引校验阶段和顺序消费阶段加在一起,最大耗时也不能超过1800s;超时会被判断为评测失败。

  • 6. 排名规则
    在结果校验100%正确的前提下,按照平均tps从高到低来排名

  • 7. 第二/三方库规约
    仅允许依赖JavaSE 8 包含的lib
    可以参考别人的实现,拷贝少量的代码
    我们会对排名靠前的代码进行review,如果发现大量拷贝别人的代码,将扣分

    8.作弊说明
    所有消息都应该进行按实际发送的信息进行存储,可以压缩,但不能伪造。 如果发现有作弊行为,比如通过hack评测程序,绕过了必须的评测逻辑,则程序无效,且取消参赛资格。

实践


初始代码

第一次性能测试

首先我们拉取代码,然后跑了一下测试

1331679740559_.pic.jpg

代码

image.png

思路就是典型的map,根据topic来保存message,我们知道每个topic下面消息都是顺序性,为了保证线程安全性加上了synchronized

优化思路

这个我从之前抽奖项目的灵感来的,之前我们抽奖也是一把大锁,然后里面逻辑咔咔咔搞,然后释放。那这样流量一进来,大家都卡在那,所以考虑到锁粒度细化。

这里我们从put方法开始,你说有必要每个操作都锁住吗,明显不合适的,我们只有topic对应的message才需要上锁,所以细化到topic级别。另外呢,get请求也没有必要上锁,因为它是按顺序去拉数据的,比如说我有个水管,每秒加10米,你自己有个计算器,每秒过来度这水管从第3节到第5节到数字,加不加锁都无所谓。

所以我们定了两个方向,一个是put锁细化到topic级别,一个是get请求去锁。

image.png

image.png

第二次性能测试

image.png

tps提高了很多,那我们继续优化,我们将代码丢给gpt同学分析下还有哪些优化

image.png

我敏锐的看到了第3点,就是subList在大列表查询的时候性能比较差的,里面提到了分片,那我们就朝着分片的思路来搞搞吧~

优化

message内容分片存储,怎么分片呢?很场景就是根据什么id然后取余,然后平均落到表,但是我们场景不一样,这些message需要顺序存储的,让我想起二维数组List<List<byte[]>>,那我们就开干吧~

image.png

首先我们定义了2个关键东西,一个是二维消息表,然后是每个列表最多储存量,比如说800,我有1000条数据,就是两组,第一组800条,第二组200条,以此递增。

image.png

put方法我们加上可重入锁,然后塞入消息,我们看下里面怎么分组塞入的

public void add(List<List<byte[]>> lists, byte[] element) {
    int lastListIndex = lists.size() - 1;
    if (lists.isEmpty() || lists.get(lastListIndex).size() == length) {
        lists.add(new ArrayList<>(length / 10));
        lastListIndex++;
    }
    List<byte[]> lastList = lists.get(lastListIndex);
    lastList.add(element);
}

逻辑:如果这个数组为空,或者当前数组大小等于最大的数量800,那么给它加上一组,然后index+1。很好理解吧,比如说饭店坐满了,你来了2个人,那老板加多一桌子。

然后我们往队列里面塞数据即可,这个简单吧,其实比较复杂的是get方法。

image.png

public Collection<byte[]> get(List<List<byte[]>> lists, long offset, long num) {
    long firstIndex = offset / length;

    int listSize = lists.size() == 0 ? 0 : lists.size() - 1;

    List<byte[]> list = new ArrayList<>(10);

    if (firstIndex > listSize) {
        return list;
    }

    long maxOffset = offset + num;

    long lastIndex = maxOffset / length;

    if (lastIndex >= listSize) {
        lastIndex = listSize;
    }

    long first = firstIndex;

    while (first <= lastIndex) {
        if (first == firstIndex && firstIndex == lastIndex) {
            list.addAll(lists.get((int) first).subList((int) offset % length, (int) (maxOffset == 0 ? 0 : maxOffset % length == 0 ? lists.get((int) first).size() : (maxOffset % length > lists.get((int) first).size() ? lists.get((int) first).size() : maxOffset % length))));
            break;
        }

        if (first == firstIndex) {
            list.addAll(lists.get((int) first).subList((int) offset % length, length));
        } else if (first < lastIndex) {
            list.addAll(lists.get((int) first));
        } else {
            list.addAll(lists.get((int) first).subList(0, (int) (maxOffset == 0 ? 0 : maxOffset % length == 0 ? lists.get((int) first).size() : maxOffset % length)));
        }

        first++;
    }

    return list;
}

首先我们要知道offset在哪个组里面对吧,然后offset+num又在哪个组,然后我们对极端情况做了处理,比如说你拿到组数都 超出了最后一组数量,那就要兜底下。

然后我们知道first、last第几组之后开始遍历,有很多情况,比如说两个值都一样在一组,或者说last在最后一组,我们在里面很多判断就是为了解决这些情况的,写法比较丑了点,哈哈。

第三次性能测试

1391679758697_.pic.jpg

1381679758199_.pic.jpg

在我们对message分片之后,写入跟读取性能都有很高的提升,还有优化吗?

优化

其实还有的就是数组初始大小,我们都知道list扩容是复制过去,比较耗费性能的,所以我们在new的时候给了初始的大小,但是比较有趣的是比如list会存放大概1000条数据,我如果写1000的话性能反而差,如果折中500,性能好一点,这个有点奇怪的。

image.png

总结


通过对实践的实践经验,理解到理论虽然很重要,但是真正将它转化为一个落地应用的技术方案才是最重要的。我们参与的一个代码性能优化的过程,包括最初的map的实现和细粒度锁优化,以及后来的分片存储优化。最后通过优化后的代码,显著提高了系统的 TPS。