记录些Spring+题集(53)

索引的设计规范

1、索引原理

索引是帮助MySQL高效获取数据的数据结构,注意是帮助高性能的获取数据。索引好比是一本书的目录,可以直接根据页码找到对应的内容,目的就是为了加快数据库的查询速度

  • 索引是对数据库表中一列或多列的值进行排序的一种结构,使用索引可快速访问数据库表中的特定信息。

  • 索引是一种能帮助MySQL提高了查询效率的数据结构:索引数据结构

索引的存储原理大致可以概括为一句话:以空间换时间

数据库在未添加索引, 进行查询的时候默认是进行全文搜索,也就是说有多少数据就进行多少次查询,然后找到相应的数据就把它们放到结果集中,直到全文扫描完毕。

数据库添加了索引之后,通过索引快速找到数据在磁盘上的位置,可以快速地读取数据,而不用从头开始全表扫描。

一般来说索引本身也很大,不可能全部存储在内存中,因此索引往往是存储在磁盘上的文件中的(可能存储在单独的索引文件中,也可能和数据一起存储在数据文件中)。

2、索引的分类

主键索引:primary key

  • 设定为主键后,数据库自动建立索引,InnoDB为聚簇索引,主键索引列值不能为空(Null)。

唯一索引:

  • 索引列的值必须唯一,但允许有空值(Null),但只允许有一个空值(Null)。

复合索引:

  • 一个索引可以包含多个列,多个列共同构成一个复合索引。

全文索引:

  • Full Text(MySQL5.7之前,只有MYISAM存储引擎引擎支持全文索引)。

  • 全文索引类型为FULLTEXT,在定义索引的列上支持值的全文查找允许在这些索引列中插入重复值和空值。全文索引可以在Char、VarChar 上创建。

空间索引:

  • MySQL在5.7之后的版本支持了空间索引,而且支持OpenGIS几何数据模型,MySQL在空间索引这方面遵循OpenGIS几何数据模型规则。

前缀索引:

  • 在文本类型为char、varchar、text类列上创建索引时,可以指定索引列的长度,但是数值类型不能指定。

3、索引的优缺点

优点:

  • 大大提高数据查询速度。

  • 可以提高数据检索的效率,降低数据库的IO成本,类似于书的目录。

  • 通过索引列对数据进行排序,降低数据的排序成本降低了CPU的消耗。

  • 被索引的列会自动进行排序,包括【单例索引】和【组合索引】,只是组合索引的排序需要复杂一些。

  • 如果按照索引列的顺序进行排序,对 order 不用语句来说,效率就会提高很多。

缺点:

  • 索引会占据磁盘空间。

  • 索引虽然会提高查询效率,但是会降低更新表的效率。比如每次对表进行增删改查操作,MySQL不仅要保存数据,还有保存或者更新对应的索引文件。

  • 维护索引需要消耗数据库资源。

综合索引的优缺点:

  • 数据库表中不是索引越多越好,而是仅为那些常用的搜索字段建立索引效果最佳。

4、参考的索引设计规范

4.1  索引命名规范

单值索引,建议以 idx_ 为开头,字母全部小写。

例如:alter table t1 add key idx_r1(r1);

组合索引,建议以 dx_multi_ 开头,字母全部小写。

例如:alter table t1 add key idx_multi_1(r1,r2,r3) ;

唯一索引,建议以 udx_ 为开头,字母全部小写;如果是多值唯一索引,则命名方式类似 udx_multi_1 等。

例如:
alter table t1 add unique key udx_f1(r1);
或者
alter table t1 add key udx_multi_1(r1,r2,r3);

全文索引,建议以 ft_ 开头,字母全部小写,并且建议默认用 ngram 插件。

例如:alter table t1 add fulltext ft_r1(r1) with parser ngram;

前缀索引,建议以 idx_ 开头,以 _prefix 结尾。

例如: alter table t1 add key idx_r1_prefix(r1(10));

函数索引,建议以 idx_func_ 开头,字母全部小写。

例如: alter table t1 add key idx_func_r1((mod(r1,4)));

4.2 尽量选择整型列做索引

索引本身有有序的,尽量选择整型列做索引,所以,尽量不用uuid,而是使用雪花id,页段id,等整数id去建立索引 。

如果避免不了,只有字符串做索引,可以选择对字符类型做 HASH ,再基于 HASH 结果做索引;

主键列数据类型最好也是整型,避免对不规则的字符串建立主键(由于 INNODB 表即索引,所以应该避免掉。不仅仅 UUID 非有序,而是因为单个 UUID 太大)

4.3 优先建立唯一性索引

唯一性索引的值是唯一的,可以更快速的通过该索引来确定某条记录。

例如,学生表中学号是具有唯一性的字段。为该字段建立唯一性索引可以很快的确定某个学生的信息。

如果使用姓名的话,可能存在同名现象,从而降低查询速度。

4.4  为经常需要排序、分组和联合操作的字段建立索引

经常需要ORDER BY、GROUP BY、DISTINCT和UNION等操作的字段,排序操作会浪费很多时间。

如果为其建立索引,可以有效地避免排序操作。

4.5 为常作为查询条件的字段建立索引

如果某个字段经常用来做查询条件,那么该字段的查询速度会影响整个表的查询速度。

因此,为这样的字段建立索引,可以提高整个表的查询速度。

4.6 限制索引的数目

索引的数目不是越多越好。每个索引都需要占用磁盘空间,索引越多,需要的磁盘空间就越大。

修改表时,对索引的重构和更新很麻烦。

越多的索引,会使更新表变得很浪费时间。

4.7 尽量使用数据量少的索引

如果索引的值很长,那么查询的速度会受到影响。

例如,对一个CHAR(100)类型的字段进行全文检索需要的时间肯定要比对CHAR(10)类型的字段需要的时间要多。

4.9 尽量使用前缀来索引

如果索引字段的值很长,最好使用值的前缀来索引。

例如,TEXT和BLOG类型的字段,进行全文检索会很浪费时间。

如果只检索字段的前面的若干个字符,这样可以提高检索速度。

4.10 删除不再使用或者很少使用的索引

表中的数据被大量更新,或者数据的使用方式被改变后,原有的一些索引可能不再需要。

数据库管理员应当定期找出这些索引,将它们删除,从而减少索引对更新操作的影响。

4.11  最左前缀匹配原则,非常重要的原则

MySQL会一直向右匹配直到遇到范围查询(>、<、between、like)就停止匹配。

比如 a=”1” and b=”2” and c> 3 and d = 4 如果建立(a,b,c,d)顺序的索引,d是用不到索引的,如果建立(a,b,d,c)的索引则都可以用到,a,b,d的顺序可以任意调整。

注意:= 和 in 可以乱序。

比如a = 1 and b = 2 and c = 3 建立(a,b,c)索引可以任意顺序,MySQL的查询优化器会帮你优化成索引可以识别的形式。

4.12   尽量选择区分度高的列作为索引

区分度的公式是count(distinct col)/count(*),表示字段不重复的比例,比例越大我们扫描的记录数越少,唯一键的区分度是1,而一些状态、性别字段可能在大数据面前区分度就 是0。

这个比例有什么经验值吗?

使用场景不同,这个值也很难确定,一般需要join的字段我们都要求是0.1以上,即平均1条扫描10条记录。

4.13 索引列不能参与计算,保持列“干净”

比如from_unixtime(create_time) = ’2014-05-29’就不能使用到索引,原因很简单,b+树中存的都是数据表中的字段值,但进行检索时,需要把所有元素都应用函数才能比较,显然成本 太大。

所以语句应该写成 create_time = unix_timestamp(’2014-05-29’);

4.14  尽量的扩展索引,不要新建索引

比如表中已经有a的索引,现在要加(a,b)的索引,那么只需要修改原来的索引即可

4.15  考虑建立联合索引来提高查询效率

当单个索引字段查询数据很多,区分度都不是很大时,则需要考虑建立联合索引来提高查询效率

注意:选择索引的最终目的是为了使查询的速度变快。

参考文献

https://www.cnblogs.com/chenhaoyu/p/8761305.html

https://zhuanlan.zhihu.com/p/391673897

RocketMQ顺序消息,是“4把锁”实现的(顺序消费)

回顾: 什么是顺序消息

一条订单产生的三条消息:订单创建、订单付款、订单完成。上面三消息是有序的,消费时要按照这个顺序依次消费才有意义,但是不同的订单之间这些消息是可以并行消费的。

什么是顺序消息?

顺序消息是指对于一个指定的 Topic ,消息严格按照先进先出(FIFO)的原则进行消息发布和消费,即先发布的消息先消费,后发布的消息后消费。

顺序消息分为两种:

  • 分区有序消息

  • 全局有序消息

1、分区有序消息

对于指定的一个 Topic ,所有消息根据 Sharding Key 进行区块分区,同一个分区内的消息按照严格的先进先出(FIFO)原则进行发布和消费。

同一分区内的消息保证顺序,不同分区之间的消息顺序不做要求。

图片

  • 适用场景:适用于性能要求高,以 Sharding Key 作为分区字段,在同一个区块中严格地按照先进先出(FIFO)原则进行消息发布和消费的场景。

  • 示例:电商的订单创建,以订单 ID 作为 Sharding Key ,那么同一个订单相关的创建订单消息、订单支付消息、订单退款消息、订单物流消息都会按照发布的先后顺序来消费。

2、全局有序消息

对于指定的一个 Topic ,所有消息按照严格的先入先出 FIFO 的顺序来发布和消费。

全局顺序消息实际上是一种特殊的分区顺序消息,即 Topic 中只有一个分区

因此:全局顺序和分区顺序的实现原理相同,区别在于分区数量上。

因为分区顺序消息有多个分区,所以分区顺序消息比全局顺序消息的并发度和性能更高

图片

  • 适用场景:适用于性能要求不高,所有的消息严格按照 FIFO 原则来发布和消费的场景。

  • 示例:在证券处理中,以人民币兑换美元为 Topic,在价格相同的情况下,先出价者优先处理,则可以按照 FIFO 的方式发布和消费全局顺序消息。

应用开发层的实现

如何实现消息有序?

实现顺序消息所必要的条件:顺序发送、顺序存储、顺序消费。

顺序存储环节,RocketMQ 里的分区队列 MessageQueue 本身是能保证 FIFO 的。所以,在应用开发过程中,不能顺序消费消息主要有两个原因:

  • 顺序发送环节,消息发生没有序:Producer 发送消息到 MessageQueue 时是轮询发送的,消息被发送到不同的分区队列,就不能保证 FIFO 了。

  • 顺序消费环节,消息消费无序:Consumer 默认是多线程并发消费同一个 MessageQueue 的,即使消息是顺序到达的,也不能保证消息顺序消费。

我们知道了实现顺序消息所必要的条件:顺序发送、顺序存储、顺序消费。顺序存储 由 Rocketmq 完成,所以,在应用开发层,   消息的顺序需要由两个阶段保证:

  • 消息发送有序

  • 消息消费有序

图片

第一个阶段:消息发送有序

顺序消息发送时, RocketMQ 支持将 Sharding Key 相同(例如同一订单号)的消息序路由到一个队列中。

在应用开发层面,要实现顺序消息发送时,主要涉及到一个组件: 有序分区选择器 MessageQueueSelector  接口

图片

select 三个参数:

  • mqs 是可以发送的队列

  • msg是消息

  • arg是上述send接口中传入的Object对象

select 返回的是该消息需要发送到的队列。

生产环境中建议选择最细粒度的分区键进行拆分,例如,将订单ID、用户ID作为分区键关键字,可实现同一终端用户的消息按照顺序处理,不同用户的消息无需保证顺序。

图片

上述例子里,是以userid 作为分区分类标准,对所有队列个数取余,来对将相同userid 的消息发送到同一个队列中。

注意,先hash再取模,防止 不同的分区 发生数据倾斜。防止:没有hash会不均匀度,导致消费者有的 饿的饿死,汗的汗死。

第二个阶段:消息消费有序

消息的顺序需要由两个阶段保证:

  • 消息发送有序

  • 消息消费有序

RocketMQ 消费过程包括两种,分别是并发消费和有序消费

  • 并发消费

    并发消费的接口  MessageListenerConcurrently。

    并发消费是 RocketMQ 默认的处理方法,并发消费 场景,消费者使用线程池技术,可以并发消费多条消息,提升机器的资源利用率。

    默认配置是 20 个线程,所以一台机器默认情况下,同一瞬间可以消费 20 个消息。

  • 有序消费 MessageListenerOrderly

    有序消费模式 的接口是,MessageListenerOrderly。

    在消费的时候,还需要保证消费者注册MessageListenerOrderly类型的回调接口,去实现顺序消费,如果消费者采用Concurrently并行消费,则仍然不能保证消息消费顺序。

    MessageListenerOrderly  有序消息监听器

图片

下面是一个例子:

图片

顺序消费的事件监听器为 MessageListenerOrderly,表示顺序消费。

  • 并发消费消息时,当消费失败时,会默认延迟重试16次。

  • 有序消费消息时,重试次数为 Integer.MAX_VALUE,而且不延迟。

换言之,有序消费场景,如果某一条消息消费失败且重试始终失败,将会导致后续的消息无法消费,产生消息的积压。

所以,顺序消费消息时,一定要谨慎处理异常情况。防止消息队列积压。

源码层:4把锁,保证消息的有序性

特别说明:在生产端,所有消息根据 ShardingKey 进行分区,相同 ShardingKey 的消息必须被发送到同一个分区。所以,生产端的有序性,在源码层不需要太多处理。

在源码层只需要关心  消费的有序处理就行。要实现消息的顺序消费,至少要达到两个条件:

  • 第一个条件:一个分区,只能投递给同一个客户端

  • 第二个条件:一个客户端,只能同时一个线程去执行消息的消费。

第一个条件:一个分区,只能投递给同一个客户端。怎么实现呢?使用分布式锁去实现。

第二个条件:同一个客户端,只能同时一个线程去执行消息的消费。怎么实现呢?使用本地消费锁去实现。

另外,光两个锁还不够,RocketMQ 为了实现 broker 服务端分布式锁的操作安全,以及本地的操作安全,还使用了额外的两把锁去做加强,

所以,为了保证有序消息的有序投递,一共用了4把锁。

4把锁,保证消息的有序性,具体如下图所示:

图片

第一把锁:broker端的分布式锁

正常的逻辑,如果保证一个分区,分配到也仅仅分配到一个client,就需要布式锁,比如redis分布式锁。

RocketMQ没有用redis分布式锁,而是自研分布式锁,在broker中设置分布式锁,所以broker直接充当redis这些角色而已。

所以,在 RocketMQ 的 broker端:

  • 通过分布式锁,实现一个分区 queue 绑定到一个消费者client

  • 并且 broker 设置一个专门的管理器,来管理分布式锁。

图片

broker端的分布式锁通过 RebalanceLockManager 管理,存储结构为

ConcurrentMap<String/* group */, ConcurrentHashMap<MessageQueue, LockEntry>>,

该分布式锁保证:

同一个consumerGroup下同一个messageQueue只会被分配给一个consumerClient。

图片

客户端, 在开始拉消息之前,首先要获取 queue的 分布式锁。

如何获取 queue的 分布式锁呢? 客户端会通过rpc 命令去发送获取 queue的 分布式锁的请求,这个命令,在Broker端,锁定队列的请求由AdminBrokerProcessor处理器的lockBatchMQ 方法去 处理

/**
 * 批量锁队列请求
 */
private RemotingCommand lockBatchMQ(ChannelHandlerContext ctx,
    RemotingCommand request) throws RemotingCommandException {
    final RemotingCommand response = RemotingCommand.createResponseCommand(null);
    LockBatchRequestBody requestBody = LockBatchRequestBody.decode(request.getBody(), LockBatchRequestBody.class);

    // 通过再平衡锁管理器去锁消息队列,返回锁定成功的消费队列
    // 锁定失败就代表消息队列被别的消费者锁住了并且还没有过期
    Set<MessageQueue> lockOKMQSet = this.brokerController.getRebalanceLockManager().tryLockBatch(
        requestBody.getConsumerGroup(),
        requestBody.getMqSet(),
        requestBody.getClientId());

    LockBatchResponseBody responseBody = new LockBatchResponseBody();
    // 将锁定成功的队列响应回去
    responseBody.setLockOKMQSet(lockOKMQSet);

    response.setBody(responseBody.encode());
    response.setCode(ResponseCode.SUCCESS);
    response.setRemark(null);
    return response;
}

然后调用RebalanceLockManager  管理器的的tryLockBatch 方法,获取对应的分布式锁。

public Set<MessageQueue> tryLockBatch(final String group, final Set<MessageQueue> mqs,
                                      final String clientId) {

    // 存放:目前已被clientId对应的消费者  锁住的分区
    Set<MessageQueue> lockedMqs = new HashSet<MessageQueue>(mqs.size());
    // 存放:目前已被clientId 尝试加锁 而 未锁住的分区
    Set<MessageQueue> notLockedMqs = new HashSet<MessageQueue>(mqs.size());

    for (MessageQueue mq : mqs) {
        // 判断分区是否已被clientId对应的消费者锁住
        if (this.isLocked(group, mq, clientId)) {
            lockedMqs.add(mq);
        } else {
            notLockedMqs.add(mq);
        }
    }

    //clientId 尝试加锁 而 未锁住的分区  ,  存在
    if (!notLockedMqs.isEmpty()) {
        try {

            //进入重入锁,保证 分区 分配的 原子性

            this.lock.lockInterruptibly();
            try {
                // 该消费组下 分区的 分布式锁
                ConcurrentHashMap<MessageQueue, LockEntry> groupValue = this.mqLockTable.get(group);
                // 如果为空,就创建一个 新的分布式锁
                if (null == groupValue) {
                    groupValue = new ConcurrentHashMap<>(32);
                    this.mqLockTable.put(group, groupValue);
                }

                // 对于clientId 锁定的分区,开始尝试去锁定
                for (MessageQueue mq : notLockedMqs) {
                    LockEntry lockEntry = groupValue.get(mq);

                    // 为空就是该分区 还没被锁定,可以直接  锁定
                    if (null == lockEntry) {
                        lockEntry = new LockEntry();
                        lockEntry.setClientId(clientId);
                        groupValue.put(mq, lockEntry);
                        log.info(
                            "tryLockBatch, message queue not locked, I got it. Group: {} NewClientId: {} {}",
                            group,
                            clientId,
                            mq);
                    }

                    // 如果不为空,之前被我锁住,就更新锁住时间,添加到锁定队列中
                    if (lockEntry.isLocked(clientId)) {
                        lockEntry.setLastUpdateTimestamp(System.currentTimeMillis());
                        lockedMqs.add(mq);
                        continue;
                    }
                    // 到这说明 被别的消费者锁住了

                    String oldClientId = lockEntry.getClientId();
                    // 如果过期了就直接换我锁住
                    if (lockEntry.isExpired()) {
                        lockEntry.setClientId(clientId);
                        lockEntry.setLastUpdateTimestamp(System.currentTimeMillis());
                        log.warn(
                            "tryLockBatch, message queue lock expired, I got it. Group: {} OldClientId: {} NewClientId: {} {}",
                            group,
                            oldClientId,
                            clientId,
                            mq);
                        lockedMqs.add(mq);
                        continue;
                    }
                    //被其他 消费者锁定了,告警
                    //然后去 抢占下一个 分区的分布式锁

                    log.warn(
                        "tryLockBatch, message queue locked by other client. Group: {} OtherClientId: {} NewClientId: {} {}",
                        group,
                        oldClientId,
                        clientId,
                        mq);
                }
            } finally {

                // 释放重入锁,其他线程,也可以进行 分区的分配
                this.lock.unlock();
            }
        } catch (InterruptedException e) {
            log.error("putMessage exception", e);
        }
    }

    return lockedMqs;
}

第二把锁:broker端的全局锁

一个分区配备一把锁,分布式锁this.mqLockTable  是一个  ConcurrentMap。

为了保证分布式锁操作的原子性,brocker设置一个专门的管理器,来管理分布式锁。

图片

所以在broker上是两级锁。分布式锁this.mqLockTable  是一个  ConcurrentMap

    /**
     * 保存每个消费组消费队列锁定情况,
     * 以消费组名为key,每个消费组可以同时锁住同一个消费 分区,以消费组为单位保存
     * 注意,这里不以topic为key,因为每个topic都可能会被多个消费组订阅,各个消费组互不影响,
     */
private final ConcurrentMap<String/* group */, ConcurrentHashMap<MessageQueue, LockEntry>> mqLockTable =
    new ConcurrentHashMap<String, ConcurrentHashMap<MessageQueue, LockEntry>>(1024);

为什么需要额外的全局锁?

broker处理RPC命令的线程可不只有一个, 所以这里用一个全局锁,来实现 分布式锁操作的原子性

//进入重入锁,保证 分区 分配的 原子性
//clientId 尝试加锁 而 未锁住的分区  ,  存在
if (!notLockedMqs.isEmpty()) {
    try {

        //进入重入锁,保证 分区 分配的 原子性
        this.lock.lockInterruptibly();

        操作 分布式锁 this.mqLockTable 
            ....
    } finally {

        // 释放重入锁,其他线程,也可以进行 分区的分配
        this.lock.unlock();
    }
}

本地消费的两级锁

消费者消费消息时,需要保证消息消费顺序和存储顺序一致,最终实现消费顺序和发布顺序的一致。

虽然MessageListenerOrderly被称为有序消费模式,但是仍然是使用的线程池去消费消息。实际上,每一个消费者的的消费端都是采用线程池实现多线程消费的模式,即消费端是多线程消费。

MessageListenerConcurrently是拉取到新消息之后就提交到线程池去消费,而MessageListenerOrderly则是通过加分布式锁和本地锁保证同时只有一条线程去消费一个队列上的数据。

一个消费者至少需要涉及队列自动负载、消息拉取、消息消费、位点提交、消费重试等几个部分。其中,与远程分布式锁有关系的是

  • 自动负载

  • 消息拉取

两级本地锁主要涉及到的是

  • 消息消费

  • 位点提交

消息消费这块,由于涉及线程池去消费消息,所以需要设置一个专门的消费锁。

对于同一个queue,除了消费之外,还涉及位点提交等,所以,一个分区额外设计一把  分区锁。加起来,在消费者本地,也是两级锁:

图片

消费者自动负载均衡(再平衡)

一个消费者至少需要涉及队列自动负载、消息拉取、消息消费、位点提交、消费重试等几个部分。

与远程分布式锁有关系的是

  • 自动负载

  • 消息拉取

两级本地锁主要涉及到的是

  • 消息消费

  • 位点提交

MQClientInstance 客户端实例,会开启多个异步并行服务:

  • 负载均衡服务 rebalanceService :再平衡服务,专门进行 queue分区的 再平衡,再分配

  • 消息拉取服务 pullMessageService:专门拉取消息,通过内部实现类DefaultMQPushConsumerImpl 拉取

  • 消息消费线程:ConsumeMessageOrderlyService  有序消息消费

图片

RebalanceService 线程启动后,会以 20s 的频率计算每一个消费组的队列负载。

如果有新分配的队列。这时候 ConsumeMessageOrderlyService 可以尝试向Broker 申请分布式锁

客户端获取分布式锁

前面三个并行服务,首先发生作用的是rebalanceService  负载均衡服务,负责获取 责任分区。

如果不是 有序消息而是普通消息的话,rebalanceService  负载均衡服务获取到 分区后,就可以开始拉取消息了。

但是有序消息却不行, 还需要先去 获取分布式锁。

这个获取分布式锁的操作, 由另外一个 异步 ConsumeMessageOrderlyService 服务去定期获取,周期是20s。

图片

图片

RebalanceImpl#lockAll()发送同步请求,加上分布式锁

// 锁定  分配到 MessageQueue 分区
public void lockAll() {
    // 查询分配的到的分区
    // key为broker名称,value为该消费者在该broker上分配到的消息分区 , 注意,一个topic 可以在多个broker上建立分区
    HashMap<String /*BrokerName*/, Set<MessageQueue>> brokerMqs = this.buildProcessQueueTableByBrokerName();

    //按照 broker 为单位进行锁定
    Iterator<Entry<String, Set<MessageQueue>>> it = brokerMqs.entrySet().iterator();
    while (it.hasNext()) {
        Entry<String, Set<MessageQueue>> entry = it.next();
        final String brokerName = entry.getKey();
        final Set<MessageQueue> mqs = entry.getValue();

        if (mqs.isEmpty())
            continue;

        // 向该broker发送 批量锁消息分区的请求
        FindBrokerResult findBrokerResult = this.mQClientFactory.findBrokerAddressInSubscribe(brokerName, MixAll.MASTER_ID, true);
        if (findBrokerResult != null) {
            LockBatchRequestBody requestBody = new LockBatchRequestBody();
            requestBody.setConsumerGroup(this.consumerGroup);
            requestBody.setClientId(this.mQClientFactory.getClientId());
            requestBody.setMqSet(mqs);

            try {
                // 发送同步请求 ,加上分布式锁
                Set<MessageQueue> lockOKMQSet =
                    this.mQClientFactory.getMQClientAPIImpl().lockBatchMQ(findBrokerResult.getBrokerAddr(), requestBody, 1000);

                //迭代锁定的 分区
                for (MessageQueue mq : lockOKMQSet) {
                    // 获取 ProcessQueue (分区消费快照  Queue consumption snapshot)
                    ProcessQueue processQueue = this.processQueueTable.get(mq);
                    if (processQueue != null) {
                        //如果没有 锁定消费快照 ,则消费快照加锁
                        if (!processQueue.isLocked()) {
                            log.info("the message queue locked OK, Group: {} {}", this.consumerGroup, mq);
                        }

                        processQueue.setLocked(true);
                        processQueue.setLastLockTimestamp(System.currentTimeMillis());
                    }
                }
                for (MessageQueue mq : mqs) {
                    if (!lockOKMQSet.contains(mq)) {
                        ProcessQueue processQueue = this.processQueueTable.get(mq);
                        if (processQueue != null) {
                            processQueue.setLocked(false);
                            log.warn("the message queue locked Failed, Group: {} {}", this.consumerGroup, mq);
                        }
                    }
                }
            } catch (Exception e) {
                log.error("lockBatchMQ exception, " + mqs, e);
            }
        }
    }
}

获取分布式锁之后,在本地, 设置到 消费快照的 locked 标志

图片

消息拉取服务 pullMessageService

如果不是 有序消息,rebalanceService  负载均衡服务获取到 分区后,就可以开始拉取消息了。会创建消息去拉取请求,交个消息拉取服务去异步执行。

图片

pullMessage 方法中,首先判断有没有分布式锁, 没有就延迟则延迟3s后再将pullRequest重新放回拉取任务中

判断有没有分布式锁,是通过 本地快照的标志位来的。

//对应关系:topic每一个的queue在消费的时候,都会指定一个pullRequest
//可以反向导航: 通过请求,去取得那个 topic的queue
public void pullMessage(final PullRequest pullRequest) {

    ...这个方法太长了

        // 并发消费模式
        // 针对于普通消息
        if (!this.consumeOrderly) {
            ....

        } else {
            // 顺序消费模式
            // 针对于顺序消息
            // 若是是顺序消息,那么 processQueue  就是须要 上 本地快照 锁
            // 必须进行同步操作, 保障在消费端不会出现乱序
            if (processQueue.isLocked()) {

                // 如果该 消费分区 是第一次拉取消息 lockedFirst = false ,则先计算拉取偏移量
                if (!pullRequest.isLockedFirst()) {

                    // 计算从哪里开始消费
                    final long offset = this.rebalanceImpl.computePullFromWhere(pullRequest.getMessageQueue());
                    boolean brokerBusy = offset < pullRequest.getNextOffset();
                    log.info("the first time to pull message, so fix offset from broker. pullRequest: {} NewOffset: {} brokerBusy: {}",
                             pullRequest, offset, brokerBusy);
                    if (brokerBusy) {
                        log.info("[NOTIFYME]the first time to pull message, but pull request offset larger than broker consume offset. pullRequest: {} NewOffset: {}",
                                 pullRequest, offset);
                    }

                    // 设置下次拉取的offSet
                    pullRequest.setLockedFirst(true);
                    pullRequest.setNextOffset(offset);
                }
            } else {

                // 如果本地快照 锁 没被锁定,则延迟3s后再将pullRequest重新放回拉取任务中
                this.executePullRequestLater(pullRequest, pullTimeDelayMillsWhenException);
                log.info("pull message later because not locked in broker, {}", pullRequest);
                return;
            }
        }
    ...
}

有分布式锁,才拉取消息。拉取消息后,提交消费。

ConsumeMessageOrderlyService  有序消息消费

前面讲到,MQClientInstance 客户端实例,会开启多个异步并行服务:

  • 负载均衡服务 rebalanceService :再平衡服务, 专门进行 queue分区的 再平衡,再分配

  • 消息拉取服务 pullMessageService :专门拉取消息,通过内部实现类DefaultMQPushConsumerImpl 拉取

  • 消息消费线程   :ConsumeMessageOrderlyService  有序消息消费

客户端与远程分布式锁有关系的是

  • 自动负载

  • 消息拉取

两级本地锁主要涉及到的是

  • 消息消费

  • 位点提交

ConsumeMessageOrderlyService  有序消息消费 ,在他run方法中,首先获取分区操作锁, 这个是一个对象锁。然后获取  消费锁, 这是一个  ReentrantLock 锁。

@Override
public void run() {
    if (this.processQueue.isDropped()) {
        log.warn("run, the message queue not be able to consume, because it's dropped. {}", this.messageQueue);
        return;
    }
    // 获取消息 分区的对象锁
    final Object objLock = messageQueueLock.fetchLockObject(this.messageQueue);
    synchronized (objLock) {
        .....
            // 批量消费消息个数
            final int consumeBatchSize =
            ConsumeMessageOrderlyService.this.defaultMQPushConsumer.getConsumeMessageBatchMaxSize();
        // 获取消息内容
        List<MessageExt> msgs = 
            this.processQueue.takeMessags(consumeBatchSize);
        .....
    }

    long beginTimestamp = System.currentTimeMillis();
    ConsumeReturnType returnType = ConsumeReturnType.SUCCESS;
    boolean hasException = false;
    try {
        //获取消费锁
        this.processQueue.getLockConsume().lock();
        ....

            //  消费消息
            status = messageListener.consumeMessage(
            Collections.unmodifiableList(msgs), context);
    } catch (Throwable e) {
        log.warn("consumeMessage exception: {} Group: {} Msgs: {} MQ: {}",
                 RemotingHelper.exceptionSimpleDesc(e),
                 ConsumeMessageOrderlyService.this.consumerGroup,
                 msgs,
                 messageQueue);
        hasException = true;
    } finally {

        // 释放消息消费锁
        this.processQueue.getLockConsume().unlock();
    }
    .....
}

上面的代码,用到了两级锁:

  • 第三级的本地锁 LockObject:queue分区上级别的 操作锁。

这个锁的粒度更大, 不仅仅锁住 消息的消费操作,还锁住了位点的提交,以及持续消费的一批消息的操作。

图片

  • 第四级的本地锁:分区上的快照 消费锁

这个锁的粒度更小, 仅仅锁住 消息的消费操作,保证同一个消息queue 分区上的消息消费,只有一个线程能够执行,保证分区消费的次序不会打乱。

图片

4级锁的总结

图片

我们做一个关于顺序消费的总结:

通过4把锁的机制,消息队列 messageQueue 的数据都会被消费者实例单线程的执行消费;当然,假如消费者扩容,消费者重启,或者 Broker 宕机 ,顺序消费也会有一定几率较短时间内乱序,所以消费者的业务逻辑还是要保障幂等

这里还需要考虑broker 锁的异常情况,假如一个broke 队列上的消息被consumer 锁住了,万一consumer 崩溃了,这个锁就释放不了,所以broker 上的锁需要加上锁的过期时间。

注意消息的积压

在使用顺序消息时,一定要注意其异常情况的出现,对于顺序消息,当消费者消费消息失败后,消息队列 RocketMQ 版会自动不断地进行消息重试(每次间隔时间为 1 秒),重试最大值是Integer.MAX_VALUE.这时,应用会出现消息消费被阻塞的情况。

因此,建议您使用顺序消息时,务必保证应用能够及时监控并处理消费失败的情况,避免消息积压现象的发生。

万亿级消息,如何做存储设计?

系统有万亿条消息怎么存储?

本文参考 Discord blog,看看一个万亿条的存储架构,如何设计和演进。

什么是 Discord blog?

Discord 的消息存储演进给我们提供了真实案例参考。Discord是一种广受欢迎的聊天和语音通信软件,主要用于游戏社区的交流。Discord 提供了一系列功能,使用户能够创建服务器、加入频道、发送消息、进行语音通话以及分享多媒体内容。本文将详细介绍Discord的功能和编程相关的应用。

Discord的核心概念:Discord的核心概念包括服务器、频道、消息和用户。

  • 服务器(Server):服务器是Discord的顶层组织单位,类似于一个虚拟社区或组织。用户可以创建自己的服务器,并邀请其他人加入。每个服务器都有自己的成员列表、频道和权限设置。

  • 频道(Channel):频道是服务器内部的子单位,用于组织和分类不同类型的交流内容。服务器可以拥有多个频道,如文本频道(用于文字聊天)和语音频道(用于语音通话)等。

  • 消息(Message):消息是在频道中发布的内容,可以是文字、链接、表情符号和多媒体文件等。用户可以发送消息、回复消息、引用消息以及分享其他内容。

  • 用户(User):用户是Discord的注册成员,每个用户都有一个唯一的用户名和标识符。用户可以加入不同的服务器,并在这些服务器中加入频道、发送消息和进行语音通话。

本质上,Discord是一个 基于兴趣的、细分领域的 IM 平台。

Discord最初是为游戏玩家在群聊和交流而创建的。但自疫情爆发以来,许多企业、公司和初创公司发现,居家办公时使用Discord进行日常沟通非常便捷。Discord不再是仅限于游戏玩家,平台建立了不同于其他任何社交空间的新空间,封闭又开放的传播方式,Discord重点放在交流和兴趣,圈住了目标人群,是连接品牌和消费者的沃土,Discord 在国内是没办法自由注册的,所以我们还是得先准备好科技上网工具,要有稳定的浏览器环境,才可以成功注册。

图片

Discord blog 万亿消息存储架构的技术演进

Discord 消万亿消息存储架构的演变过程:

MongoDB -> Cassandra -> ScyllaDB

2015 初用的单个副本集的 MongoDB,2015 年底迁移到 Cassandra,2022 年消息量达到了万亿的级别,他们将存储迁移到 ScyllaDB。

图片

Discord 第一阶段存储架构:MongoDB

2015 年,Discord 的初始版本仅依赖于单一的 MongoDB。

消息规模在亿级别,MongoDB 已经存储了 1 亿条消息。Discord 在早期版本中依赖单一 MongoDB 数据库存储消息,但随着消息量的迅速增长,内存空间不足和延迟问题日益凸显。

这促使 Discord 必须寻找一个新的数据库解决方案,以应对日益增长的数据量和性能挑战。

内存无法再容纳更多的数据和索引,导致延迟变得不可预测。

什么是MongoDB?

MongoDB是一个文档数据库,适用于处理半结构化和非结构化数据。它具有灵活的数据模型和易于使用的API,支持复杂的查询和分布式事务。MongoDB可以在多个节点上水平扩展,以处理大量的数据请求。

优点

  1. 灵活的数据模型:MongoDB的文档模型支持灵活的数据结构,可以方便地处理半结构化和非结构化数据。

  2. 易于使用:MongoDB提供了易于使用的API和查询语言,开发人员可以快速上手。

  3. 可扩展性好:MongoDB可以在多个节点上水平扩展,以处理大量的数据请求。

缺点

  1. 不适合处理关系型数据:MongoDB不适合处理关系型数据,例如传统的表格数据。

  2. 一致性难以保证:MongoDB在分布式环境下难以保证强一致性。

  3. 存储空间浪费:MongoDB存储数据时需要较多的空间,因为每个文档都需要包含其键和值。

Mongodb是可以分布式水平扩展的,是支持万亿级的消息存储的。

那为什么在Discord IM消息的存储场景,又不适合了呢?

Discord使用mongodb的问题:内存空间不足,延迟太高。

本质上,这个和数据存储中间件的底层存储设施有关。

存储引擎的底层数据结构

首先,看看存储引擎用到的常见数据结构

存储引擎要做的事情无外乎是将磁盘上的数据读到内存并返回给应用,或者将应用修改的数据由内存写到磁盘上。目前大多数流行的存储引擎是基于B-Tree或LSM(Log Structured Merge) Tree这两种数据结构来设计的。

  • B-Tree

像Oracle、SQL Server、DB2、MySQL (InnoDB)和PostgreSQL这些传统的关系数据库依赖的底层存储引擎是基于B-Tree开发的;

  • LSM Tree

像Clickhouse、Cassandra、Elasticsearch (Lucene)、Google Bigtable、Apache HBase、LevelDB和RocksDB这些当前比较流行的NoSQL数据库存储引擎是基于LSM开发的。

两种结构,使用与不同场景,当写读比例很大的时候(写比读多),LSM树相比于B树有更好的性能

  • 插件式替换模式:

大部分中间件,想办法兼容两个场景,所以,会采用了插件式的存储引擎架构,Server层和存储层进行解耦。

所以,大部分中间件,同时支持多种存储引擎。比如,MySQL既可以支持B-Tree结构的InnoDB存储引擎,还可以支持LSM结构的RocksDB存储引擎。

比如,MongoDB采用了插件式存储引擎架构,底层的WiredTiger存储引擎还可以支持B-Tree和LSM两种结构组织数据,但MongoDB在使用WiredTiger作为存储引擎时,目前默认配置是使用了B-Tree结构。

LSM树基础知识

LSM树(Log-Structured MergeTree),日志结构合并树。

LSM树(Log-Structured MergeTree)存储引擎和B+树存储引擎一样,同样支持增、删、读、改、顺序扫描操作。而且通过批量存储技术规避磁盘随机写入问题。当然凡事有利有弊,LSM树和B+树相比,LSM树牺牲了部分读性能,用来大幅提高写性能。

LSM树,log-structured,日志结构的,日志是软件系统打出来的,就跟人写日记一样,一页一页往下写,而且系统写日志不会写错,所以不需要更改,只需要在后边追加就好了。各种数据库的写前日志也是追加型的,因此日志结构的基本就指代追加。注意他还是个 “Merge-tree”,也就是“合并-树”,合并就是把多个合成一个

传统关系型数据库使用btree或一些变体作为存储结构,能高效进行查找。

但保存在磁盘中时它也有一个明显的缺陷,那就是逻辑上相离很近但物理却可能相隔很远,这就可能造成大量的磁盘随机读写。

随机读写比顺序读写慢很多,为了提升IO性能,我们需要一种能将随机操作变为顺序操作的机制,于是便有了LSM树。LSM树能让我们进行顺序写磁盘,从而大幅提升写操作,作为代价的是牺牲了一些读性能。

LSM树核心思想的核心就是放弃部分读能力,换取写入的最大化能力

LSM Tree ,这个概念就是结构化合并树的意思,它的核心思路其实非常简单,就是**假定内存足够大,因此不需要每次有数据更新就必须将数据写入到磁盘中,而可以先将最新的数据驻留在内存中,等到积累到足够多之后,再使用归并排序的方式将内存内的数据合并追加到磁盘队尾(因为所有待排序的树都是有序的,可以通过合并排序的方式快速合并到一起)**。

日志结构的合并树(LSM-tree)是一种基于硬盘的数据结构,与B+tree相比,能显著地减少硬盘磁盘臂的开销,并能在较长的时间提供对文件的高速插入(删除)。

然而LSM-tree在某些情况下,特别是在查询需要快速响应时性能不佳。通常LSM-tree适用于索引插入比检索更频繁的应用系统。

LSM树与B树的差异

LSM树和B+树的差异主要在于读性能和写性能进行权衡。

在牺牲的同时寻找其余补救方案:

(a)LSM具有批量特性,存储延迟。当写读比例很大的时候(写比读多),LSM树相比于B树有更好的性能。因为随着insert操作,为了维护B+树结构,节点分裂。读磁盘的随机读写概率会变大,性能会逐渐减弱。

(b)B树的写入过程:对B树的写入过程是一次原位写入的过程,主要分为两个部分,首先是查找到对应的块的位置,然后将新数据写入到刚才查找到的数据块中,然后再查找到块所对应的磁盘物理位置,将数据写入去。当然,在内存比较充足的时候,因为B树的一部分可以被缓存在内存中,所以查找块的过程有一定概率可以在内存内完成,不过为了表述清晰,我们就假定内存很小,只够存一个B树块大小的数据吧。可以看到,在上面的模式中,需要两次随机寻道(一次查找,一次原位写),才能够完成一次数据的写入,代价还是很高的。

万亿级数据存储中间件的技术选型

了解了底层原理之后,尼恩给大家梳理一下,万亿级数据存储中间件的技术选型。

一、HBase

HBase是一个基于Hadoop的列存储数据库。它被广泛用于大数据环境下的实时读写操作。HBase使用Hadoop分布式文件系统(HDFS)作为存储后端,可以处理PB级别的数据。HBase使用Java编写,支持复杂的查询和分布式事务。HBase还提供了Hadoop生态系统中的许多工具和技术的集成,如Hive、Pig和Spark。

Hbase中有多个RegionServer的概念,RegionServer是 调度者,管理Regions。在Hbase中,RegionServer对应于集群中的一个节点,一个RegionServer负责管理多个Region。

一个Region代 表一张表的一部分数据,所以在Hbase中的一张表可能会需要很多个Region来存储其数据,

每个Region中的数据并不是杂乱无章 的,Hbase在管理Region的时候会给每个Region定义一个Rowkey的范围,落在特定范围内的数据将交给特定的Region,从而将负载分 摊到多个节点上,充分利用分布式的优点。

另外,Hbase会自动的调节Region处在的位置,如果一个RegionServer变得Hot(大量的请求落在这个Server管理的Region上),Hbase就会把Region移动到相对空闲的节点,依次保证集群环境被充分利用。

Region数据怎么持久化呢?

Hbase是基于Hadoop的项目,生产环境使用HDFS文件系统存储,数据是持久化在HDFS上的,对笨重的hadooop集群强依赖。

优点

  1. 易于扩展:HBase是基于Hadoop的,它可以很容易地扩展到成百上千个节点。

  2. 数据可靠性高:HBase将数据复制到多个节点上,确保在节点失效时数据不会丢失。

  3. 处理大数据量:HBase可以处理大规模数据,它的可伸缩性非常好。

  4. 写入的性能高:前面讲了LSM树,是使用写多读少场景。

缺点

  1. 配置复杂:HBase需要配置很多参数才能运行,这会增加系统管理员的工作量。

  2. 查询速度慢:HBase的查询速度较慢,特别是在批量查询时。

  3. 开发难度大:HBase需要编写Java代码,这增加了开发的难度。

  4. 模式很重:HBase是基于Hadoop的,hadoop集群是一种重型的大数据离线处理基础设施, 如果没有大数据处理需求, 这个会造成资源的浪费,运维的浪费。

二、Cassandra

Cassandra是由Facebook开发的基于列存储的分布式数据库,基于Java开发。Cassandra是基于LSM架构的分布式数据库。

Cassandra支持水平扩展,可以在多个数据中心之间进行复制,提供高可用性和数据可靠性。它被广泛用于Web应用程序、消息传递、大数据和物联网等领域。

前面讲到,LSM查询性能还是比较慢的,于是需要再做些事情来提升,怎么做才好呢?

LSM Tree优化方式:

a、Bloom filter:就是个带随机概率的bitmap,可以快速的告诉你,某一个小的有序结构里有没有指定的那个数据的。于是就可以不用二分查找,而只需简单的计算几次就能知道数据是否在某个小集合里啦。效率得到了提升,但付出的是空间代价。

b、compact:小树合并为大树:因为小树他性能有问题,所以要有个进程不断地将小树合并到大树上,这样大部分的老数据查询也可以直接使用log2N的方式找到,不需要再进行(N/m)*log2n的查询了

Cassandra 也是做了很多优化,提升了查询的性能。

Cassandra 优点

  1. 易于扩展:Cassandra可以很容易地扩展到成百上千个节点。

  2. 高可用性:Cassandra将数据复制到多个节点上,确保在节点失效时数据不会丢失。

  3. 查询速度快:Cassandra的查询速度非常快,特别是在大规模数据存储时。

  4. 写入的性能高:前面讲了LSM树,是使用写多读少场景。

Cassandra 缺点

  1. 数据一致性难以保证:Cassandra的数据一致性需要在读写之间进行权衡,难以保证强一致性。

  2. 复杂的数据模型:Cassandra的数据模型相对复杂,需要较长时间的学习和开发。

  3. 配置复杂:Cassandra需要配置很多参数才能运行,这会增加系统管理员的工作量。

Cassandra vs HBase

HBase是基于google bigtable的开源版本,而Cassandra是 Amazon Dynamo + Google BigTable的开源版本。

对比如下:

HBaseCassandra
语言JavaJava
出发点BigTableBigTable and Dynamo
LicenseApacheApache
ProtocolHTTP/REST (also Thrift)Custom, binary (Thrift)
数据分布表划分为多个region存在不同region server上改进的一致性哈希(虚拟节点)
存储目标大文件小文件
一致性强一致性最终一致性,Quorum NRW策略
架构master/slavep2p
高可用性NameNode是HDFS的单点故障点P2P和去中心化设计,不会出现单点故障
伸缩性Region Server扩容,通过将自身发布到Master,Master均匀分布Region扩容需在Hash Ring上多个节点间调整数据分布
读写性能数据读写定位可能要通过最多6次的网络RPC,性能较低。数据读写定位非常快
数据冲突处理乐观并发控制(optimistic concurrency control)向量时钟
临时故障处理Region Server宕机,重做HLog数据回传机制:某节点宕机,hash到该节点的新数据自动路由到下一节点做 hinted handoff,源节点恢复后,推送回源节点。
永久故障恢复Region Server恢复,master重新给其分配regionMerkle 哈希树,通过Gossip协议同步Merkle Tree,维护集群节点间的数据一致性
成员通信及错误检测Zookeeper基于Gossip
CAP1,强一致性,0数据丢失。2,可用性低。3,扩容方便。1,弱一致性,数据可能丢失。2,可用性高。3,扩容方便。

国内很多人对于Cassandra很陌生。有一句话说得很明白,亚洲用HBase,海外用Cassandra。基于一个数据库排名我们可以看到,Cassandra在NOSQL数据库中排在第四位,已经远远甩开了HBase。

主要的原因,应该是 hadoop 重资源、重运维。而 Cassandra 更轻量级,而且读写性能更高。

图片

Discord 第二阶段存储架构:Cassandra

通过上面的对比分析,Discord是典型的写多读少场景,果断 抛弃了基于B+树的Mongdb,选择了LSM结构的Cassandra。

没有选择hbase,估摸着因为 hbase:

  • 太重,依赖hadoop

  • 读慢,读的性能没有Cassandra高

因此,Discord 第二阶段存储架构:选择了一个数据库,这时选择了 Cassandra。

2017 年,Discord 拥有 12 个 Cassandra 节点,存储了数十亿条消息。2022 年初,Discord 拥有 177 个 Cassandra 节点,存储了数万亿条消息。

这一次,到了万亿级规模了此时,延迟再次变得难以预测,维护的成本也变得过于昂贵。

造成这一问题有几个原因:

  1. Cassandra 使用 LSM 树作为内部数据结构,读取操作比写入操作更为昂贵。在一台拥有数百名用户的服务器上,可能会出现很多并发读取,导致热点问题。

  2. 维护集群(如压缩 SSTables)会影响性能。

  3. 垃圾回收会导致明显的延迟

随着 Discord 用户基础的扩大和消息量的激增,Cassandra 的性能和维护成本问题变得日益突出。

这一问题促使 Discord 重新审视其存储策略,并最终导致了架构的演进,以适应不断变化的技术和业务需求。

通过不断优化和升级其存储系统,Discord 旨在确保用户能够享受到快速、可靠的消息服务,同时保持系统的可扩展性和成本效益。

Discord 第三阶段存储架构:C++ 版本的 Cassandra

这时,Discord 重新设计了消息存储的架构:采用基于 ScyllaDB 的存储。ScyllaDB 是用 C++ 编写的 Cassandra 兼容数据库。

ScyllaDB是KVM之父Avi Kivity带领团队用C++重写的Cassandra,官方称拥有比Cassandra多10x倍的吞吐量,并降低了延迟,是性能优异的NoSQL列存储数据库。

ScyllaDB可以算得上是数据库界的奇葩,它用c++改写了java版的Cassandra。

为什么奇葩呢?

因为大部分用其它语言改写的,都很难匹敌原系统。而ScyllaDB却相当成功,引起来了片欢呼。它的成功来源于JVM GC的无止尽的噩梦,另一部分来自于大名顶顶的KVM团队开发成员。

ScyllaDB是一个兼容Cassandra的NoSQL系统,它兼容Cassandra的数据模型和客户端协议,可以直接替换Cassandra系统。ScyllaDB实现性能优化到非常极致,它的性能是Cassandra的10多倍(3台ScyllaDB可以提供30台Cassandra集群的吞吐量,而且响应延时更低),非常牛。

ScyllaDB的优势在于:

  • 用 C++ 而非 Java 编写,消除了垃圾回收暂停的干扰。

  • 按核分片模型(Shard-per-Core model)提供更好的负载隔离,防止热分区在节点间产生级联延迟。

  • 优化了反向查询性能,以满足 Discord 的需求。

  • 节点减少到 72 个,同时将每个节点的磁盘空间增加到 9 TB。

为了进一步保护 ScyllaDB,Discord 针对数据服务还做了以下优化:

  • 在 Rust 中构建中间数据服务,限制并发流量峰值。

  • 数据服务位于应用程序接口和数据库之间,可聚合请求。

  • 即使多个用户请求相同的数据,也只需查询一次数据库。

  • Rust 提供了快速、安全的并发功能,是这种工作负载的理想选择。

优化后的Discord 系统性能显著提高:

  • ScyllaDB 的 p99 读取延迟为 15 毫秒,而 Cassandra 为 40-125 毫秒。

  • ScyllaDB 的 p99 的写延迟为 5 毫秒,而 Cassandra 为 5-70 毫秒。

该系统可轻松应对世界杯流量高峰。

Discord 通过对消息存储架构的重新设计,成功应对了随着用户数量和消息量增长所带来的性能挑战。

通过采用 Rust 和 ScyllaDB,Discord 不仅提高了系统的性能,还确保了系统的可扩展性和稳定性。

这些优化措施使得 Discord 能够在保证服务质量的同时,有效应对高并发场景,展现了其在技术演进和创新能力上的领先地位。

万亿条消息怎么存储总结

Discord 的消息存储经历了从 MongoDB 到 Cassandra,再到 ScyllaDB 的演变过程。

随着系统规模的不断扩大和性能要求的提高,Discord 不断优化其存储架构,以应对热点问题、维护成本和延迟等挑战。通过采用 ScyllaDB 和 Rust 等技术,Discord 成功构建了一个高性能、可扩展的消息存储系统,能够满足数万亿条消息的存储需求。

通过此文,大家可以看出,如果大家做IM的消息存储架构,建议大家不要用mongdb,二是使用 ScyllaDB

说说Rocketmq推模式、拉模式?

经典的推模式/拉模式

首先,明确一下业务场景

这里谈论的推拉模式,指的是 Consumer 和 Broker 之间,不是 producer与broker之间。

经典的推模式

经典的推模式,指的是消息从 Broker 推向 Consumer。Consumer 被动的接收消息,由 Broker 来主导消息的发送。作为代理人,Broker 接受完消息之后,可以立马推送给 Consumer。Consumer等着就行,消息会有broker主动推过来。所以 Consumer 的处理策略很简单。

推模式的缺点:Consumer可能就“消化不良/OOM”。

当 Broker 推送消息的速率大于Consumer消费速率时,Consumer可能就“消化不良”,出现内存积压,内存溢出,OOM,因为根本消费不过来啊。所以,经典的推模式,适用于消息量不大、Consumer消费能力强的场景。

经典的拉模式

经典的拉模式,指的是 Consumer 主动向 Broker 请求拉取消息。

上面讲到,经典的推模式,适用于消息量不大、Consumer消费能力强的场景。如果Consumer消费能力弱, 那么就改变方向好了, 由推改完拉。拉的话,主动权就在Consumer身上了, 能消化多少,吃多少。假设当前Consumer 消化不过来、消费不过来了,它可以根据一定的策略,暂停拉取甚至停止拉取,或者间隔拉取都行。

凡事有利必有弊。

拉模式的缺点:消息延迟+消息积压。 如果Consumer 隔个 2天采取拉取一批,消息就很有可能延迟,甚至出现严重的消息延迟。而且Broker 服务端大概率会消息积压。

推拉模式,如何选型?

选择推模式的消息队列中间件,主要有ActiveMQ。选择拉模式的消息队列中间件,主要有RocketMQ 和 Kafka。

虽然RocketMQ 和 Kafka都选择了拉模式。也就是允许消息延迟 + 允许消息积压。所以,选择RocketMQ 和 Kafka,就需要做好消息积压的监控。

阿里面试:如何保证RocketMQ消息有序?如何解决RocketMQ消息积压?

Rocketmq 的推模式和拉模式

Rocketmq 的客户端,也定义了两个模式:推模式和拉模式

图片

但是实际上,RocketMQ 中的 PushConsumer 推模式,仅仅是披着推模式的方法,本质还是拉模式。

RocketMQ 中的 PushConsumer 推模式

接下来,我们首先看看RocketMQ 中的 PushConsumer 推模式。

惊呆:RocketMQ顺序消息,是“4把锁”实现的(顺序消费)

介绍了  RocketMQ  拉取消息的核心流程,具体如下图所示。

图片

一个消费者至少需要涉及队列自动负载、消息拉取、消息消费、位点提交、消费重试等几个部分。

MQClientInstance 客户端实例,会开启多个异步并行服务:

  • 负载均衡服务 rebalanceService :再平衡服务.

    专门进行 queue分区的 再平衡,再分配,然后发布拉取消息的请求 pullRequest 实例。

  • 消息拉取服务 pullMessageService:专门负责拉取消息。

    从请求队列 pullRequestQueue 队列 获取一个一个的 pullRequest,通过内部实现类DefaultMQPushConsumerImpl 拉取 消息。

    注意,拉取的消息,放在另一个队列 messageQueue 缓存,拉取之前,会进行流控检查,如果这个队列满了(>1000个消息或者 >100M内存) 则延迟50ms再拉取, 当然,下一次执行拉取之前,同样也会进行流控检查

  • 消息消费线程:ConsumeMessageOrderlyService  有序消息消费, 或者 并行消息。 从messageQueue  拉取消息,进行消费。

上面设计3类线程,在3类线程之间,通过两个队列进行 同步:

  • 拉取消息的请求队列 pullRequestQueue

  • 缓存消息的队列  messageQueue

Rocketmq 的推模式,本质是一种拉模式, 只是为了让客户端不会  累死, 在拉取之前进行流控。

//   接下来就是消费者的拉取流量控制,阈值为 1000个消息, 或者 100M
// 消费者消费的太慢了,broker推送的太快了,进行 Flow control

if (cachedMessageCount > this.defaultMQPushConsumer.getPullThresholdForQueue()) {

    // 将pullRequest放入队列,只不过是经过后台的定时线程池延50 ms 迟放入,进行 Flow control
    // 流量控制, 减缓拉取消息的速度
    //     * Flow control threshold on queue level, each message queue will cache at most 1000 messages by default,
    //     * Consider the {@code pullBatchSize}, the instantaneous value may exceed the limit
    //     */
    //    private int pullThresholdForQueue = 1000;

    this.executePullRequestLater(pullRequest, PULL_TIME_DELAY_MILLS_WHEN_FLOW_CONTROL);
    if ((queueFlowControlTimes++ % 1000) == 0) {
        log.warn(
            "the cached message count exceeds the threshold {}, so do flow control, minOffset={}, maxOffset={}, count={}, size={} MiB, pullRequest={}, flowControlTimes={}",
            this.defaultMQPushConsumer.getPullThresholdForQueue(), processQueue.getMsgTreeMap().firstKey(), processQueue.getMsgTreeMap().lastKey(), cachedMessageCount, cachedMessageSizeInMiB, pullRequest, queueFlowControlTimes);
    }
    return;
}

客户端没有消息怎么办呢?

上一节讲到, RocketMQ三类线程,相互配合:在背后偷偷的帮我们去 Broker拉消息。

第一类线程  RebalanceService ,根据 topic 的队列数量和消费者个数做负载均衡,对于分配到queue 产生的 pullRequest 拉取请求,并讲请求 队列 pullRequestQueue 中。

第二类线程  PullMessageService ,不断的pullRequestQueue队列 中获取 pullRequest,然后从 broker 拉取消息。

那么,如果broker暂时没有消息,怎么办呢?

PullMessageService   把拉取请求,重新放进pullRequestQueue 队列, 大致的代码如下:

//broker 没有 新消息
case NO_NEW_MSG:
pullRequest.setNextOffset(pullResult.getNextBeginOffset());

DefaultMQPushConsumerImpl.this.correctTagsOffset(pullRequest);

// 把拉取请求,重新放进队列

DefaultMQPushConsumerImpl.this.executePullRequestImmediately(pullRequest);
break;
//broker 没有 匹配消息
case NO_MATCHED_MSG:
pullRequest.setNextOffset(pullResult.getNextBeginOffset());

DefaultMQPushConsumerImpl.this.correctTagsOffset(pullRequest);
// 把拉取请求,重新放进队列

DefaultMQPushConsumerImpl.this.executePullRequestImmediately(pullRequest);

Broker 处理拉取消息命令的  处理器,叫做 PullMessageProcessor。PullMessageProcessor 里面的 processRequest 方法是用来处理pullRequest 拉消息请求。

如果broker有消息, processRequest 方法就直接返回,如果broker没有消息, processRequest 方法怎么办呢?

我们来看一下代码。

图片

我们再来看下 suspendPullRequest 方法做了什么。

图片

这里有个broker 异步线程 PullRequestHoldService

这个线程会每 5 秒从 pullRequestTable 取PullRequest请求,然后进行检查,看看是否有新的消息

图片

检查方法是:计算 待拉取消息请求的偏移量是否小于当前消费队列最大偏移量,如果条件成立则说明有新消息了,一旦有消息,PullRequestHoldService 则会调用 notifyMessageArriving ,最终调用 PullMessageProcessor 的 executeRequestWhenWakeup() 方法重新尝试处理这个消息的请求,也就是再来一次,整个长轮询的时间默认 30 秒。

图片

简单的说就是 5 秒会检查一次消息时候到了,如果到了则调用 processRequest 再处理一次。

这里是一个定期检查的流程。除此之外,如果commitLog 有消息,也会执行唤醒的工作,做到准实时。

brocker端的 ReputMessageService 线程,不断地为 commitLog 追加数据并分发请求,构建出 ConsumeQueue 和 IndexFile 两种类型的数据,并且也会有唤醒请求的操作,来弥补每 5s 一次这么慢的延迟

PUSH 模式的应用开发

下面是 RocketMQ 推模式的一个官方示例:

public static void main(String[] args) throws InterruptedException, MQClientException {
    Tracer tracer = initTracer();

    DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("CID_JODIE_1");
    consumer.getDefaultMQPushConsumerImpl().registerConsumeMessageHook(new ConsumeMessageOpenTracingHookImpl(tracer));

    consumer.subscribe("TopicTest", "*");
    consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);

    consumer.setConsumeTimestamp("20181109221800");
    consumer.registerMessageListener(new MessageListenerConcurrently() {
        @Override
        public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
            System.out.printf("%s Receive New Messages: %s %n", Thread.currentThread().getName(), msgs);
            return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
        }
    });
    consumer.start();
    System.out.printf("Consumer Started.%n");
}

消费者会定义一个消息监听器 MessageListenerConcurrently,并且把这个监听器注册到 DefaultMQPushConsumer ,这个监听器,最终会注册到内部 DefaultMQPushConsumerImpl,当内部拉取到消息时,就会使用这个监听器来处理消息。

消费者消息处理过程

下面用并发消费方式下的同步拉取消息为例总结一下消费者消息处理过程:

第一类线程  RebalanceService ,根据 topic 的队列数量和消费者个数做负载均衡,对于分配到queue 产生的 pullRequest拉取请求,并讲请求 队列 pullRequestQueue 中。

第二类线程  PullMessageService ,不断的pullRequestQueue队列 中获取 pullRequest,然后从 broker 拉取消息。具体来说,这里调用了 DefaultMQPushConsumerImpl 类的 pullMessage 方法;pullMessage 方法调用 PullAPIWrapper 的 pullKernelImpl 方法真正去发送 PULL 请求,并传入 PullCallback 的 回调函数;拉取到消息后,调用 PullCallback 的 onSuccess 方法处理结果,会把消息放入到 缓存消息的队列  messageQueue。

第三类线程消息消费线程:ConsumeMessageOrderlyService  有序消息消费, 或者 ConsumeMessageConcurrentlyService 并行消息服务。 从messageQueue  拉取消息,进行消费。这里调用了 ConsumeMessageConcurrentlyService 的 submitConsumeRequest 方法,通过里面的 ConsumeRequest 线程来处理拉取到的消息;处理消息时调用了消费端定义的消费逻辑,也就是 MessageListenerConcurrently 的 consumeMessage 方法。

Rocketmq 拉模式/PULL 模式

下面是来自官方的一段 拉模式/PULL 模式拉取消息的代码:

DefaultLitePullConsumer litePullConsumer =
                new DefaultLitePullConsumer("lite_pull_consumer_test");
litePullConsumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
litePullConsumer.subscribe("TopicTest", "*");
litePullConsumer.start();
try {
    while (running) {
        List<MessageExt> messageExts = litePullConsumer.poll();
        System.out.printf("%s%n", messageExts);
    }
} finally {
    litePullConsumer.shutdown();
}

上面代码中写了一个死循环 , 客户端通过 PULL 模式,不断的调用poll方法,不停的去拉取消息。

从这段代码可以看出, 通过拉模式/PULL 模式 的pullRequest 请求,不是Rocketmq 源码去发出,也不用PullMessageService  线程,这个pullRequest  请求是有 客户端应用程序自己去发。

Rocketmq源码内部,拉模式消费使用DefaultMQPullConsumer/DefaultLitePullConsumerImpl,核心逻辑是先拿到需要获取消息的Topic对应的队列,然后依次从队列中拉取可用的消息。拉取了消息后就可以进行处理,处理完了需要更新消息队列的消费位置。

下面有一个更加生产化的案例

@Test
public void testPullConsumer() throws Exception {
    DefaultMQPullConsumer consumer = new DefaultMQPullConsumer("group1_pull");
    consumer.setNamesrvAddr(this.nameServer);
    String topic = "topic1";
    consumer.start();

    //获取Topic对应的消息队列
    Set<MessageQueue> messageQueues = consumer.fetchSubscribeMessageQueues(topic);
    int maxNums = 10;//每次拉取消息的最大数量
    while (true) {
        boolean found = false;
        for (MessageQueue messageQueue : messageQueues) {
            long offset = consumer.fetchConsumeOffset(messageQueue, false);
            PullResult pullResult = consumer.pull(messageQueue, "tag8", offset, maxNums);
            switch (pullResult.getPullStatus()) {
                case FOUND:
                    found = true;
                    List<MessageExt> msgs = pullResult.getMsgFoundList();
                    System.out.println(messageQueue.getQueueId() + "收到了消息,数量----" + msgs.size());
                    for (MessageExt msg : msgs) {
                        System.out.println(messageQueue.getQueueId() + "处理消息——" + msg.getMsgId());
                    }
                    long nextOffset = pullResult.getNextBeginOffset();
                    consumer.updateConsumeOffset(messageQueue, nextOffset);
                    break;
                case NO_NEW_MSG:
                    System.out.println("没有新消息");
                    break;
                case NO_MATCHED_MSG:
                    System.out.println("没有匹配的消息");
                    break;
                case OFFSET_ILLEGAL:
                    System.err.println("offset错误");
                    break;
            }
        }
        if (!found) {//没有一个队列中有新消息,则暂停一会。
            TimeUnit.MILLISECONDS.sleep(5000);
        }
    }
}

下面代码就演示了使用DefaultMQPullConsumer拉取消息进行消费的示例。核心方法就是调用consumer的pull()拉取消息。

该示例中使用的是同步拉取,即需要等待Broker响应后才能继续往下执行。如果有需要也可以使用提供了PullCallback的重载方法。同步的pull()返回的是PullResult对象,其中的状态码有四种状态,并且分别对四种状态进行了不同的处理。

只有状态为FOUND才表示拉取到了消息,此时可以进行消费。

消费完了需要调用updateConsumeOffset()更新消息队列的消费位置,这样下次通过fetchConsumeOffset()获取消费位置时才能获取到正确的位置。如果有需要,用户也可以自己管理消息的消费位置。

PushConsumer 推模式源码分析

那 PULL 模式中 poll 函数是怎么实现的呢?

跟踪源码可以看到,消息拉取的时候,DefaultLitePullConsumerImpl工作过程基本与DefaultMQPushConsumer过程相似。

DefaultLitePullConsumerImpl允许设置是否需要自动commit offset(默认自动),并且把拉取到的消息缓存在内存中,Conumser需要主动通过poll从内存中获取消息,进行业务处理。

DefaultLitePullConsumerImpl 类中的一个方法,首先根据负载均衡服务分配到的 queue分区,启动 拉取任务

图片

通过定时任务进行消息的拉取

图片

PullTaskImpl拉取到消息后,封装成ConsumeRequest ,提交的 consumeRequestCache 缓存中

图片

内存缓存consumeRequestCache 类型为BlockingQueue

图片

消费者代码中,通过循环,调用poll方法,不停地从 consumeRequestCache 拉取消息进行处理

图片

pull模式,消费者消息处理过程

总结一下,pull模式,消费者消息处理过程:

  1. 消费者启动过程中,负载均衡线程 RebalanceService 线程发现 ProcessQueueTable 消费快照发生变化时,启动消息拉取线程;

  2. 消息拉取线程PullTaskImpl 拉取到消息后,把消息放到 consumeRequestCache,然后进行下一次拉取;

  3. 消费者调用poll方法,不停地从 consumeRequestCache 拉取消息,进行业务处理。

Rocketmq的Push与Pull模式比较

1、Push模式拉取消息,拉取到消息马上推送lisener进行业务处理。

应用程序对消息的拉取过程参与度不高,可控性不足,仅仅提供消息监听器的实现。

2、Pull模式自,自主决定如何拉取消息,从什么位置拉取消息。

应用程序对消息的拉取过程参与度高,由可控性高,可以自主决定何时进行消息拉取,从什么位置offset拉取消息

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值