Spark优化总结(四)——业务与架构设计

Spark优化总结(四)——业务与架构设计

1. 前言

  • 在这里,业务与架构设计是指的对于项目功能需求的业务流程分析与软件架构设计,以及如何优化好整个处理流程。对于一个分布式项目的业务与架构设计需要有较广的知识面,同时还要兼具一定的深度。而一款良好的分布式架构系统才能为项目功能起到可靠的支撑作用。
  • 由于不同的业务,其设计方案不同,没法一一举出所有的解决方案。在此,准备了一个业务需求,并一步一步地对其进行业务与架构的设计分析,以作参考。

2. 需求:实时订阅监控系统

  • 描述:某互联网公司A,每日有大量的用户行为数据(约100亿/日),数据分析师需要定点对部分重点用户进行行为分析,以确定用户行为是否异常,或者是否需要对用户提供专业服务。并且,对所选用户的行为信息要做持续订阅,具体时间由分析师决定(默认一周),以便能够快速查看不同时间段的数据。
  • 特征点:
    • 数据量:100亿/日
    • 处理逻辑:分析师在Web前端界面指定的用户名,监控系统根据用户名将对应数据过滤,并推送至WebServer,并展示
    • 数据状态:每个用户行为数据的订阅需要做过期机制,过期时间由分析师决定,默认一周
    • 行为分析:由分析师决定,监控系统不进行分析
  • 显然,功能需求较简单,而其关键点在于数据量非常大(100亿/日),难以用普通的方式处理。下面我们就一步一步的来分析整体架构设计。

3. 数据采集层、缓冲层、实时计算层的选择

3.1 数据采集层

  • 公司A的数据量非常大,拥有很多台Web服务器,会产生大量的用户行为数据。一个用户的行为数据可能在多台Web服务器上,如果要做统一分析,显然我们需要将用户行为数据统一到一个地方。
  • 如果每来一条数据,Web服务器就马上将数据发送到一个统一的地方,这样性能较差。我们可以采取批量+定时的方式,将数据推送到统一的集群中(例如满足1000条或5秒就推一次)。
  • 不过为了解耦,我们的Web服务器不应该添加往统一的集群推送数据的逻辑,因为统一的集群可能发生变化,并且执行该逻辑可能会影响Web服务的性能。因此,我们的Web服务器只需要将数据存入本地磁盘,再由其他程序采集并发送至统一的集群即可。
  • 这个采集程序,一般我们可以用Java编写,为了简便,在这里我们采用业内标准日志采集框架Flume。

3.2 缓冲层

  • 统一接收数据的集群,一般是一个缓冲层,并且还要利用它来做实时流计算处理。
  • 缓冲层通常是一些消息队列、消息中间件类似的产品,主要用于解耦、异步、削峰等功能。有了缓冲层,Web服务器和后端的大数据处理才不至于发生耦合,并且能够快速地异步处理消息,同时能够缓解数据高峰期的压力。
  • 这一类的产品,一般常用的有RabbitMQ、ActiveMQ、RocketMQ、Kafka等。
    • ActiveMQ: 是较早的产品,社区活跃度低,吞吐量一般
    • RabbitMQ: 由ErLang开发,不易维护,吞吐量一般,但实时性极高
    • RocketMQ: 分布式架构,可靠性高,吞吐量高,具有丰富的功能 (阿里出品,社区黄掉有前鉴)
    • Kafka: 社区活跃度很高,分布式架构,可靠性高,吞吐量极高,功能性一般
  • 首先由于我们的数据量非常高(100亿/日),不要求实时性极高,因此RabbitMQ、ActiveMQ可以排除了。其次,RocketMQ吞吐量不错,具有较丰富的功能,但是似乎我们不需要这些功能。可以用这一点去换取功能性一般、吞吐量更高的Kafka。
  • Kafka是大数据业内标准产品,专用于大数据的实时计算、日志采集等场景。

3.3 实时计算层

  • 做数据的实时计算,一般来说就是编写一个程序接收实时数据,并处理,再推向下游。在这里,上游我们采用的Kafka,那么实时计算层可选用的方案如下:
    • 直接编写Java程序处理数据
    • 使用Kafka提供的KafkaStream(不常用)
    • 使用Storm进行实时流处理
    • 使用SparkStreaming或者StructuredStreaming进行实时流处理
    • 使用Flink进行实时流处理(推荐)
  • 一般来说,我们需要采用较为成型的技术框架,自己编写Java程序的方案可以直接略过。KafkaStream还不太成型,业内用的也不多,可以排除。Storm的话,实时性较高,但是吞吐量比不上Spark、Flink,并且现在基本上已经被Flink取代。SparkStreaming与StructuredStreaming吞吐量较高,另外StructuredStreaming是针对SparkStreaming不足之处提出的优化版,功能更丰富。Flink专为实时流计算而生,无论是吞吐量、实时性,还是功能丰富度,都非常好。如果做实时流计算,建议采用Flink。
  • 当然,因为本文写的Spark,而StructuredStreaming在吞吐量、实时性、功能性方面都能够满足我们的需求,我们还是用StructuredStreaming做示例讲解。

3.4 目前的架构图

  • 架构示意图
    架构示意图

4. 怎样将订阅消息推送到实时计算集群?

4.1 分析

  • 实时计算集群需要根据分析师指定的用户名对数据进行过滤处理,并推送。因此,需要将WebServer获取到的用户名传给给实时计算集群,才能进行处理。而用户名是实时订阅的,可能随时取消,因此需要计算集群实时地根据订阅的用户名列表处理Kafka的数据。
  • 下面,来考虑几种设计方案。

4.2 为所有的用户名设计一个Topic?

  • 方案:为所有的用户名设计一个Topic(例如Topic_xiaoming),Spark获取到原始的用户行为数据,根据用户将行为数据分发到各个Topic。WebServer根据自己需要的用户名,去Kafka取对应的Topic数据。
  • 问题:显然,总的用户ID的量比较大,至少几千万个,会造成Kafka内Topic太多,性能低下。同时,订阅的用户ID的量相比总数据量,其实很少很少,这样做又重新在Kafka生成了原始数据量的一份新数据,浪费空间和性能。

4.3 将订阅消息存到数据库?

  • 方案:将订阅消息存到数据库,每来一次实时数据,访问一下数据库,看是否存在该用户ID。最后,将对应的用户行为数据推向下游。
  • 问题
    • 数据库选型:
      • RDMS数据库,例如MySQL、PostgreSQL。
      • NoSQL数据库,例如HBase、MongoDB。
      • 内存数据库,例如Redis。
    • 用RDMS数据库,每来一次数据,就请求一次数据库,等数据库返回结果,才能决定后续数据。我们的数据量非常大,这样太慢了,还会导致数据库压力太大(100亿次请求/日)。
    • NoSQL数据库这一块效率要高一点,并且HBase还支持海量的查询与存储(不过订阅的用户ID的量没那么大),但是还是不够快。
    • 还不行?那我用内存型的数据库Redis呢?里面维护一个存储用户ID的Set。内存型数据库是很快,但是我们的数据量太多了。即便一条数据查询时间只要1ms,那么100亿条数据,需要1000万秒。如果开启100个核处理,一天需要处理10万秒,但是一天的时间只有86400秒。更何况除了这里,其他地方还要花时间处理呢?‬
    • 另外,其实可以批量查询是否存在。但是,如果一批数据等得太多太久,实时性就非常低,一次查的数据量少,请求就比较频繁。要知道我们的数据量是100亿/日,这样做仍然太慢。
    • 总之,到数据库去查询分析师是否订阅了该用户ID的方式,会导致效率低下、数据库压力极大。

4.4 在计算节点开启Socket连接,Web服务发送订阅消息过来?

  • 方案:在计算集群的每个计算节点用子线程开启SocketServer,让Web服务发送订阅消息到该SocketServer。并且,在节点处维护一个用户ID容器,SocketServer通过接收到的消息更新该用户ID容器。Kafka每来一条数据,就在本地查询该用户ID容器内是否存在对应的用户ID。
  • 问题
    • 显然,这样做后,我们的从Kafka来的每一条数据就不用请求远程节点了,直接访问本地容器。这样,就解决了上面一个问题。但是,仍然存在一些问题。
    • 我见过的一个小型公司就是这样做的。他们有一个类似的功能需求,架构大致如下:
      架构示意图
    • 该公司只有5台计算服务器,因此采用了这种架构。但是这样做耦合度非常高,WebServer需要记住每台计算节点的IP和Socket端口。而在实际大规模生产中,计算集群节点非常多,你是不知道每次计算应用启动时用的是那几台服务器的。
    • 因此,我们需要一个消息中间件来解耦:所有的WebServer统一将订阅的用户ID数据发送至中间件,而计算节点统一从中间件获取广播的用户ID数据。

4.5 使用消息中间件

  • 正如我们前面所讲,比较主流的消息中间件一般有RabbitMQ、ActiveMQ、RocketMQ、Kafka等。另外,Redis也可以作为消息中间件使用。
  • 首先,为了不增加我们项目的复杂度,应尽量对减少额外框架的引入。因此,可以排除掉RabbitMQ、ActiveMQ、RocketMQ。而Redis作为缓存库,项目中是必然存在的(或者其他内存数据库)。
  • 现在,我们需要在Kafka与Redis之间做选择。我们的需求是将数据广播到计算节点,不需要中间件长期保存数据,订阅的数据量相对来说也不大(对吞吐量要求不高),显然将订阅的数据发送到Kafka上面没有明显的优势。如果广播的速度能更快的话,那么计算节点能够更早的对用户行为数据进行过滤,因此Redis作为内存数据库是具有优势的。
  • 我们可以通过Redis的发布/订阅模式将消息广播到计算节点,或者将数据存入Redis某个list,通过while定时轮询该list,得到订阅的用户ID数据。
  • 现在的架构如下:
    架构示意图
  • 需要注意的是,SparkStructuredStreaming以及Flink的较早版本中并没有广播流的概念,因此只能采用该方式处理动态的过滤条件数据。

5. 如何处理海量数据?

5.1 简介

  • 在此需求功能中,对数据的处理逻辑较为简单。但是,想要提高海量数据的处理速度,我们需要关注每一个细节(性能与功能),如下几个点对效率的影响极大,特别是同步锁的问题。

5.2 原始数据的格式选择与解析

  • 原始数据的格式的选择,举几个例子
    • csv: 简单,解析效率还不错,但是不支持复杂数据结构(如果数据不复杂,可以用csv)
    • json: 支持复杂数据结构,解析效率一般,使用较广泛(此处,因为原始数据已定好格式为json,我们就采用json进行演示)
    • xml: 支持复杂数据结构,解析效率一般
    • 字节流: 可以使用protobuf等方式序列化/反序列化,效率最高,但是要求发送端也支持该方式
  • 不解析不要的内容:因为我们是根据用户ID来判定是否需要该条数据的,因此,可以先只解析用户ID,确定需要后,再解析后面部分。
  • 伪代码 (此处使用的 fastjson)
    personActionRDD.flatMap { line =>
      val jsonObj: JSONObject = JSON.parseObject(line)
      val userId = jsonObj.getString("userId")
    
      if (true/*user_id在用户ID容器中*/) {
        // 如果存在,再对其他字段进行解析
        val userName = jsonObj.getString("userName")
        val action = jsonObj.getString("action")
      
        Some(userId, userName, action)
      } else {
        None
      }
    }
    

5.3 用户ID容器的选择

  • 在当前需求中,对于用户ID容器的选择的关键点是看查找一个元素是否在容器内的效率。下面列出一些常用的容器,并作分析。
  • 数组结构,例如ArrayList?
    • ArrayList使用数组实现,比实际的元素量,占用更多空间,因为需要先申请额外的数组空间(扩容)。未排序时,需要按遍历方式查找;如果已排序,可以使用二分查找法,更快。
  • 链表结构,例如LinkedList?
    • LinkedList使用链表实现,与实际的元素量占用空间相当。未排序时,需要按遍历方式查找,效率低于ArrayList(因为数组中的对象的指针在内存中是连续的);如果已排序,使用二分查找法效率有提升。
  • 二叉树结构,例如TreeSet?
    • TreeSet由TreeMap封装而成,内部为红黑树实现。查询方式同二分查找,但是因为本身数据存储是链表指针方式,速度慢于有序数组的二分查找。
  • Hash结构,例如HashSet
    • HashSet由HashMap封装而成,内部实现为数组+链表+红黑树。查找方式通过hash值在数组找到对象(链表或红黑树),再按照对象(链表或红黑树)的查找方式进行比较。效率非常高,至少是有序数组的二分查找的10倍以上。
  • 显然,HashSet的查找效率远高于其他容器,虽然会多占用一点内存,但这是值得的!

5.4 对用户ID容器进行初步封装

  • 每个计算节点的JVM只需要存一份该数据(用户ID容器),因此应该用单例模式封装该容器。
  • 同时,在该封装类初始化时,应该启动一个子线程从Redis不断地获取前端广播的用户ID数据。
  • 另外,需要注意的是,我们应该向外暴露的是判断用户ID是否存在的方法,而不是getSet()方法。如果暴露getSet方法,外部将能够修改该Set内的元素,这不是我们想要的!
  • 示例代码如下
import com.skey.spark.util.RedisUtils;
import redis.clients.jedis.JedisPubSub;

import java.util.HashSet;

/**
 * Description: 消息中心,用于封装用户ID容器,提供查询与自动更新功能
 * <br/>
 * Date: 2020/1/7 18:34
 *
 * @author ALion
 */
public class MessageCenter {

    private HashSet<String> userSet;

    public static MessageCenter getInstance() {
        return Inner.instance;
    }

    private static class Inner {
        private static final MessageCenter instance = new MessageCenter();
    }

    private MessageCenter() {
        userSet = new HashSet<>();

        // 启动子线程
        new Thread(() -> {
            // 从Redis接收用户ID信息,去更新userSet
            // 这里用订阅/发布模式做示例
            RedisUtils.subscribe(new JedisPubSub() {
                @Override
                public void onMessage(String channel, String message) {
                    updateUserSet(message)
                }
            },"user_info");
        }).start();
    }

    private void updateUserSet(String message) {
        userSet.add(message);
    }

    public boolean exist(String userId) {
       return userSet.contains(userId);
    }

}

5.5 为不同的WebServer提供订阅消息的增删功能与过期机制

  • 首先,无论是从需求还是设计角度上来看,我都需要一个订阅信息的过期机制。如果不过期,将会一直过滤下去,并且会越来越多。因此,可以在message中添加一个字段用于标识过期时间。

  • 其次,我们不应该只有增加的功能,如果WebServer能够主动取消订阅,那么可以减少我们过滤的数据量(前端不需要的就不要),降低下游的压力。因此,可以在message中添加一个字段用于标识删除。

  • 另外,增加/删除订阅的信息时,我们不能让WebServerA删除WebServerB订阅的用户ID。例如,WebServerA与WebServerB同时订阅了用户ID为"xiaoming_123"的行为数据,WebServerA不要该数据了,直接发送删除消息,会导致WebServerB也收不到"xiaoming_123"的消息。因此,可以在message中添加一个字段用于标识WebServer的Id。

  • 此时,我们可以设计一个简单的message,例如web_001|1578397403640|0|086400|xiaoming_123

    • 第一个字段:表示WebServer的Id
    • 第二个字段:表示消息发生的业务时间
    • 第三个字段:1表示添加,0表示删除
    • 第四个字段:表示多少秒后过期
    • 第五个字段:表示订阅的用户Id
  • 注意,为5个字段的设计这样的顺序是有原因的:

    • 前4个字段都是是定长的,这样解析时可以直接用substring,比split更快。同时,这4个字段由自己的服务器指定,不会出现脏数据的情况。
    • 最后一位用户Id是一个变长字段,并且可能存在我们所用的分隔符"|"(即脏数据),因此用split可能会出问题,而用substring就简单了。(当然,你也可以为每个用户设计一个ID号码,就没有脏数据问题了,不过我们先针对既有的数据进行处理)
    • 关于过期时间的长度,其限制了时间的最大值,防止过期时间过长(你也可以多加几位,获得更大的最大值)。
    • 当然,如此设计后,你删掉分隔符也没关系,留着只是为了方便看
  • 那么,我们先对message的消息进行封装。用WebServer的Id和表示订阅的用户Id共同决定唯一一个MessageBean,防止误删的情况。time与duration合并成endTime,表示失效的最终时间。示例如下:

    public class MessageBean {
        
        private String webId;
    
        private String userId;
    
        private long endTime;
    
        public MessageBean(String webId, String userId, long endTime) {
            this.webId = webId;
            this.userId = userId;
            this.endTime = endTime;
        }
    
        // 此处省略get和set方法
    
        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (o == null || getClass() != o.getClass()) return false;
    
            MessageBean that = (MessageBean) o;
    
            if (webId != null ? !webId.equals(that.webId) : that.webId != null) return false;
            return userId != null ? userId.equals(that.userId) : that.userId == null;
        }
    
        @Override
        public int hashCode() {
            int result = webId != null ? webId.hashCode() : 0;
            result = 31 * result + (userId != null ? userId.hashCode() : 0);
            return result;
        }
        
    }
    
  • 由于多增加了字段,丰富了功能,我需要重新设计容器。Kafka的每条数据只需要用用户ID来判断是否存在,但我们还要根据message来判断是否继续过滤某个用户ID。因此,需要设计两个容器:

    • HashSet<String>() 用于存储用户ID,方便快速判断是否存在
    • HashSet<MessageBean>() 用于存储message的数据,根据message更新该容器。如果只用一个容器HashSet<MessageBean>(),判断用户ID是否存在时需要走遍历的方式,会非常慢。
  • 消息解析与处理示例如下

    import com.skey.spark.bean.MessageBean;
    import com.skey.spark.util.RedisUtils;
    import redis.clients.jedis.JedisPubSub;
    
    import java.util.HashSet;
    import java.util.Iterator;
    
    /**
    * Description: 消息中心,用于封装用户ID容器,提供查询与自动更新功
    * <br/>
    * Date: 2020/1/7 18:34
    *
    * @author ALion
    */
    public class MessageCenter {
    
        private HashSet<String> userSet;
        private HashSet<MessageBean> messageSet;
    
        public static MessageCenter getInstance() {
            return Inner.instance;
        }
    
        private static class Inner {
            private static final MessageCenter instance = new MessageCenter();
        }
    
        private MessageCenter() {
            userSet = new HashSet<>();
            messageSet = new HashSet<>();
    
            // 启动子线程
            new Thread(() -> {
                // 从Redis接收用户ID信息,去更新userSet
                // 这里用订阅/发布模式做示例
                RedisUtils.subscribe(
                        new JedisPubSub() {
                            @Override
                            public void onMessage(String channel, String message) {
                                // 如果发生任何处理错误的问题,就放弃该message,等待新的
                                try {
                                    updateUserSet(message);
                                } catch (Exception e) {
                                    e.printStackTrace();
                                    // 建议用日志记录错误,并发送一个message错误的通知
                                }
                            }
                        },
                        "user_info"
                );
            }).start();
        }
    
        private void updateUserSet(String message) {
            // 解析message,例如 "web_001|1578397403640|0|086400|xiaoming_123"
            String webId = message.substring(0, 7);
            long time = Long.parseLong(message.substring(8, 21));
            String addOrDelete = message.substring(22, 23);
            int duration = Integer.parseInt(message.substring(24, 30));
            String userId = message.substring(31);
    
            MessageBean messageBean = new MessageBean(webId, userId, time + duration);
            // 根据 addOrDelete 决定是否删除 messageBean
            if ("1".equals(addOrDelete)) {
                messageSet.add(messageBean);
            } else {
                messageSet.remove(messageBean);
            }
    
            // 标识当前时间,用于判断过期数据
            long currentTime = System.currentTimeMillis();
    
            // 清空userSet,准备根据messageSet的数据重置userSet
            userSet.clear();
    
            Iterator<MessageBean> iter = messageSet.iterator();
            while (iter.hasNext()) {
                MessageBean bean = iter.next();
                if (bean.getEndTime() < currentTime) {
                    // 过期了,删除
                    iter.remove();
                } else {
                    // 重置 userSet
                    userSet.add(bean.getUserId());
                }
            }
        }
    
        public boolean exist(String userId) {
            return userSet.contains(userId);
        }
    
    }
    

5.6 读/写同步锁的问题

  • 用户ID容器同时需要被查询以及被修改,聪明的朋友应该已经想到这个问题了。因为,我们的计算节点自身处理每条Kafka数据时,需要访问用户ID容器userSet,而我们还启动了一个子线程从Redis获取message并更新该userSet。显然存在容器的并发问题。下面,我们来思考几个解决方案。
  • 使用并发类容器,例如guava中的ConcurrentHashSet?
    • 并发容器本身提供了加锁的安全机制,非常好用。需要注意的是在此处代码中:
      // 清空userSet,准备根据messageSet的数据重置userSet
      userSet.clear();
      
      Iterator<MessageBean> iter = messageSet.iterator();
      while (iter.hasNext()) {
          MessageBean bean = iter.next();
          if (bean.getEndTime() < currentTime) {
              // 过期了,删除
              iter.remove();
          } else {
              // 重置 userSet
              userSet.add(bean.getUserId());
          }
      }
      
    • 清空操作clear与添加操作add是各自独立的,而并发容器只能保证对容器的单次操作的是原子性的。因此,该方案行不通。
  • 使用同步锁 synchronized 或者 ReentrantLock ?
    • synchronized简单易用,还有锁膨胀的优化,示例如下
      public synchronized boolean exist(String userId) {
          return userSet.contains(userId);
      }
      
      synchronized(this) {
          // 清空userSet,准备根据messageSet的数据重置userSet
          userSet.clear();
      
          Iterator<MessageBean> iter = messageSet.iterator();
          while (iter.hasNext()) {
              MessageBean bean = iter.next();
              if (bean.getEndTime() < currentTime) {
                  // 过期了,删除
                  iter.remove();
              } else {
                  // 重置 userSet
                  userSet.add(bean.getUserId());
              }
          }
      }
      
    • 显然,如此操作能够保证各个线程对容器读与写的原子性。
    • 但是,我们的计算节点是多线程的,每条数据都要访问一下exist方法,上一次锁,synchronized会迅速膨胀为系统重量级锁。那这样的话,此部分的处理岂不是性能急剧下降?!!!
  • 雪中送炭,读写锁
    • 再思考一下我们对用户ID容器userSet的访问情况!
      • 对于Kafka的数据,我们是多线程处理,每条数据都需要读一下userSet
      • 对于Redis的数据,我们启动了一个子线程去更新userSet
    • 情况是多个线程在疯狂读,而少量线程在写。显然,读取userSet的线程之间并不冲突,根本不需要相互上锁!因此,我们可以使用读写锁来解决这个问题。示例如下:
      private ReentrantReadWriteLock lock;
      
      private ReentrantReadWriteLock.WriteLock writeLock;
      
      private ReentrantReadWriteLock.ReadLock readLock;
      
      private MessageCenter() {
          ……
          lock = new ReentrantReadWriteLock();
          writeLock = lock.writeLock();
          readLock = lock.readLock();
      
          // 启动子线程
          ……
      }
      
      public boolean exist(String userId) {
          readLock.lock();
          try {
              return userSet.contains(userId);
          } finally {
              readLock.unlock();
          }
      }
      
      writeLock.lock();
      try {
          // 清空userSet,准备根据messageSet的数据重置userSet
          userSet.clear();
      
          Iterator<MessageBean> iter = messageSet.iterator();
          while (iter.hasNext()) {
              MessageBean bean = iter.next();
              if (bean.getEndTime() < currentTime) {
                  // 过期了,删除
                  iter.remove();
              } else {
                  // 重置 userSet
                  userSet.add(bean.getUserId());
              }
          }
      } finally {
          writeLock.unlock();
      }
      
    • 采用读写锁,基本上能够解决此处的并发性能问题
  • 锦上添花,volatile!!!
    • 前面已经解决了并发同步的性能问题,不过当写数据时仍然会被锁住(废话!),你有没有想过不用锁来解决这个问题?
    • 再看看,我们的对于userSet的访问情况:多个线程读,单个线程写。
    • 对于这种多个线程读,只有一个线程在写的情况,我们可以完全采用voaltile修饰对象,来解决问题。示例如下:
      private volatile HashSet<String> userSet;
      
      public boolean exist(String userId) {
          return userSet.contains(userId);
      }
      
      // 新new一个HashSet
      HashSet<String> newSet = new HashSet<>();
      
      Iterator<MessageBean> iter = messageSet.iterator();
      while (iter.hasNext()) {
          MessageBean bean = iter.next();
          if (bean.getEndTime() < currentTime) {
              // 过期了,删除
              iter.remove();
          } else {
              // 此处更新newSet
              newSet.add(bean.getUserId());
          }
      }
      
      // 最后修改对象引用
      userSet = newSet;
      
    • 启动子线程从Redis获取数据更新userSet时,先新生成了一个newSet,再通过messageSet更新这个newSet。此时,如果处理Kafka数据的多个线程来调用exist,访问的是原始的userSet,没并发问题。当newSet更新完成后,通过修改userSet引用指针指向newSet,完成修改。由于userSet被volatile修饰,会立马被其他线程看见。
    • 关于用此方案时的小问题:
      • message明明来了,却没马上更新userSet,刚好处理Kafka数据的多个线程又来调用exist,拿到的是之前的userSet,并没有拿到被message更新后的userSet,可能会导致当前数据是message需要的数据却被放走。显然,这个是处理速度的问题(或者叫做时间先后的问题),并不是并发的问题,不用担心。
      • userSet = newSet;引用赋值操作是原子性的吗?是的,不过long和double有点不同,详细请看Oracle官方文档 Non-Atomic Treatment of double and long

6. 怎样返回订阅的监控信息?

  • 先看需求,业务要求订阅的数据能够持续过滤,并持久存储历史数据一段时间(这样才能方便随时回查)。因此,我们的下游服务器需要能够存储数据一段时间。
  • 同时,WebServer服务器是随时都可能变化的,这就要求我们要有一个固定的消息中间件,以用于接收用户行为数据。
  • 如果不想再增加架构复杂度的话,我们只能从Kafka与Redis中选择。
  • 因为需要存储数据一段时间,如果用Redis的话,内存满了、Redis持久化时服务挂了、节点宕机了都可能导致数据的丢失。并且,Redis本身的定位就是内存型数据缓存,缓存只是用来提升效率的,丢失时必须要有有效的恢复手段。显然,如果用户行为数据进入Redis,当丢失时将难以恢复。
  • 使用Kafka的话,数据能够存储一段时间,并且分布式架构可用性高,即使部分节点宕机也没有数据丢失的问题。同时,高吞吐量的特性可以满足分析师对大量用户行为的订阅!
  • WebServer在发送用户ID订阅消息后,既可开始监听Kafka对应的Topic,获取到用户行为数据。

7. 最终架构图示意图

架构图示意图

Thank you for listening! ^_^

发布了128 篇原创文章 · 获赞 45 · 访问量 15万+

猜你喜欢

转载自blog.csdn.net/alionsss/article/details/103882996
今日推荐