仿微博社交平台系统设计[四]--使用springevent事件驱动模型(观察者模式)结合redis bitmap 运用 实现每日数据统计

观察者模式

当对象间存在一对多关系时,则使用观察者模式(Observer Pattern)。比如,当一个对象被修改时,则会自动通知它的依赖对象。观察者模式属于行为型模式。

主要解决:一个对象状态改变给其他对象通知的问题,而且要考虑到易用和低耦合,保证高度的协作。

何时使用:一个对象(目标对象)的状态发生改变,所有的依赖对象(观察者对象)都将得到通知,进行广播通知。

如何解决:使用面向对象技术,可以将这种依赖关系弱化。

优点: 1、观察者和被观察者是抽象耦合的。 2、建立一套触发机制。

缺点: 1、如果一个被观察者对象有很多的直接和间接的观察者的话,将所有的观察者都通知到会花费很多时间。 2、如果在观察者和观察目标之间有循环依赖的话,观察目标会触发它们之间进行循环调用,可能导致系统崩溃。 3、观察者模式没有相应的机制让观察者知道所观察的目标对象是怎么发生变化的,而仅仅只是知道观察目标发生了变化。

Spring Boot 之事件(Event)

Spring的事件通知机制是一项很有用的功能,使用事件机制我们可以将相互耦合的代码解耦,从而方便功能的修改与添加。本文我来学习并分析一下Spring中事件的原理。

举个例子,假设有一个添加评论的方法,在评论添加成功之后需要进行修改redis缓存、给用户添加积分等等操作。当然可以在添加评论的代码后面假设这些操作,但是这样的代码违反了设计模式的多项原则:单一职责原则、迪米特法则、开闭原则。一句话说就是耦合性太大了,比如将来评论添加成功之后还需要有另外一个操作,这时候我们就需要去修改我们的添加评论代码了。

在以前的代码中,我使用观察者模式来解决这个问题。不过Spring中已经存在了一个升级版观察者模式的机制,这就是监听者模式。通过该机制我们就可以发送接收任意的事件并处理。

Spring 官方文档翻译如下 :

ApplicationContext 通过 ApplicationEvent 类和 ApplicationListener 接口进行事件处理。 如果将实现 ApplicationListener 接口的 bean 注入到上下文中,则每次使用 ApplicationContext 发布 ApplicationEvent 时,都会通知该 bean。 本质上,这是标准的观察者设计模式。

Spring的事件(Application Event)其实就是一个观察者设计模式,一个 Bean 处理完成任务后希望通知其它 Bean 或者说 一个Bean 想观察监听另一个Bean的行为。

Spring 事件只需要几步:

  • 自定义事件,继承 ApplicationEvent
  • 定义监听器,实现 ApplicationListener 或者通过 @EventListener 注解到方法上
  • 定义发布者,通过 ApplicationEventPublisher

实际代码:

创建event文件夹

并创建event object类和handle类,一个handle类可以对应多个object类。

@Data
@AllArgsConstructor
@NoArgsConstructor
public class EverydayStatisticEventObject {

  private Integer id;

  private String os;

  private String proxy;

  private StatisticEventType statisticEventType;

}

创建枚举类 处理不同的事件类型,运用观察者模式

public enum StatisticEventType {
   
  //注册数统计
  REGISTER_COUNTER,
  //活跃数统计
  ACTIVE_COUNTER,
  //裂变数统计
  FISSION_COUNTER,
  //播放数统计
  PLAYED_COUNTER,
  //广告点击数统计
  ADCLICK_COUNTER;

  private StatisticEventType() {
  }
}

在事务service类中注入

@Autowired
  private ApplicationEventPublisher publisher;

处理完相应的业务逻辑后,调取publish操作,将事务发布出去

其一

public LoginLog increaseLoginLog(String ip, int uid, String username) {
    User user = mixinsService.getUser(uid);
    LoginLog loginLog = new LoginLog();
    loginLog.setLoginIp(ip);
    loginLog.setLoginTime(new Date());
    loginLog.setUid(uid);
    loginLog.setUsername(username);
    loginLog.setProxy(user.getProxy());
    loginLog.setChannel(user.getChannel());
    loginLog.setUserType(user.getUserType());
    loginLog.setOs(user.getOs());
    LoginLog log = loginLogRepository.save(loginLog);
    
    //发布事件
    publisher.publishEvent(new EverydayStatisticEventObject(log.getUid(), log.getOs(), log.getProxy(),StatisticEventType.ACTIVE_COUNTER));
    ChannelDailyDataManager.fireEvent(new UserActiveEvent(user.getChannel()));
    return log;
  }

Google Guava Cache缓存

Google Guava Cache是一种非常优秀本地缓存解决方案,提供了基于容量,时间和引用的缓存回收方式。基于容量的方式内部实现采用LRU算法,基于引用回收很好的利用了Java虚拟机的垃圾回收机制。其中的缓存构造器CacheBuilder采用构建者模式提供了设置好各种参数的缓存对象,缓存核心类LocalCache里面的内部类Segment与jdk1.7及以前的ConcurrentHashMap非常相似,都继承于ReetrantLock,还有六个队列,以实现丰富的本地缓存方案。

Guava Cache与ConcurrentMap的区别

Guava Cache与ConcurrentMap很相似,但也不完全一样。最基本的区别是ConcurrentMap会一直保存所有添加的元素,直到显式地移除。相对地,Guava Cache为了限制内存占用,通常都设定为自动回收元素。在某些场景下,尽管LoadingCache 不回收元素,它也是很有用的,因为它会自动加载缓存。

//bitmap的偏移量offset生产,offset越大,占用内存越多,所以以每日第一个id作为minid,作为被减数
//使用guava cache缓存机制获取最小id,设置过期时间为每一天,每天清空一次
private LoadingCache<String, Integer> minId = CacheBuilder.newBuilder().expireAfterWrite(1L, TimeUnit.DAYS).build(new CacheLoader<String, Integer>() {
    @Override
    public Integer load(String s) throws Exception {
      Date date = LocalDate.parse(StringUtils.substringAfter(s, "@")).toDate();
      if (ACTIVE_COUNTER.startsWith(s)) {
        LoginLog loginLog = loginLogRepository.getTopByLoginTimeBeforeOrderByIdDesc(date);
        if (loginLog != null) {
          return loginLog.getId();
        }
      } else if (PLAYED_COUNTER.startsWith(s)) {
        ViewHistory viewHistory = viewHistoryRepository.getTopByViewtimeBeforeOrderByIdDesc(date);
        if (viewHistory != null) {
          return viewHistory.getId();
        }
      } else if (ADCLICK_COUNTER.startsWith(s)) {
        AdvClickHistory advClickHistory = advClickHistoryRepository.getTopByCreateTimeBeforeOrderByIdDesc(date);
        if (advClickHistory != null) {
          return advClickHistory.getId();
        }
      }
      return 0;
    }
  });

#位图的基本介绍

#概念

什么是位图?BitMap,大家直译为位图. 我的理解是:位图是内存中连续的二进制位(bit),可以用作对大量整形做去重和统计.

引入一个小栗子来帮助理解一下:

假如我们要存储三个int数字 (1,3,5),在java中我们用一个int数组来存储,那么占用了12个字节.但是我们申请一个bit数组的话.并且把相应下标的位置为1,也是可以表示相同的含义的,比如

下标01234567
二进制值01010100

可以看到,对应于1,3,5为下标的bit上的值为1,我们或者计算机也是可以get到1,3,5这个信息的.

优势

那么这么做有什么好处呢?感觉更麻烦了鸭,下面这种存储方式,在申请了bit[8]的场景下才占用了一个字节,占用内存是原来的12分之一,当数据量是海量的时候,比如40亿个int,这时候节省的就是10几个G的内存了.

这就引入了位图的第一个优势,占用内存小.

再想一下,加入我们现在有一个位图,保存了用户今天的签到数据.下标可以是用户的ID.

A:

用户ID01234567 
二进制值010101000

这代表了用户(1,3,5)今天签到了.

当然还有昨天的位图,

B:

用户ID01234567 
二进制值011100010

这代表了用户(1,2,3,7)昨天签到了.

我们现在想求:

  1. 昨天和今天都签到的用户.
  2. 昨天或者今天签到的用户.

在关系型数据库中存储的话,这将是一个比较麻烦的操作,要么要写一些表意不明的SQL语句,要么进行两次查询,然后在内存中双重循环去判断.

而使用位图就很简单了,A & BA | B 即可.上面的操作明显是一个集合的与或操作,而二进制天然就支持逻辑操作,且众所周知猫是液体.错了,众多周知是计算机进行二进制运算的效率很高.

这就是位图的第二个优点: 支持与或运算且效率高.

哇,这么完美,那么哪里可以买到呢?,那么有什么缺点呢?

不足

当然有,位图不能很方便的支持非运算,(当然,关系型数据库支持的也不好).这句话可能有点难理解.继续举个例子:

我们想查询今天没有签到的用户,直接对位图进行取非是不可以的.

对今天签到的位图取非得到的结果如下:

用户ID01234567 
二进制值101010111

这意味着今天(0,2,4,6,7)用户没有签到吗?不是的,存在没有7(任意数字)号用户的情况,或者他注销了呢.

这是因为位图只能表示布尔信息,即true/false.他在这个位图中,表示的是XX用户今天有签到或者没有签到,但是不能额外的表达,xx用户存在/不存在这个状态了.

但是我们可以曲线救国,首先搞一个全集用户的位图.比如:

全集:

用户ID01234567 
二进制值111110100

然后用全集的位图和签到的位图做异或操作,相同则为0,不相同则为1.

在业务的逻辑为: 用户存在和是否签到两个bool值,共四种组合.

  1. 用户存在,且签到了. 两个集合的对应位都为1,那么结果就为0.
  2. 用户存在,但是没签到. 全集对应位为1,签到为0,所以结果是1.
  3. 用户不存在,那么必然没可能签到, 两个集合的对应位都是0,结果为0.

所以结果中,为1的只有一种可能:用户存在且没有签到,正好是我们所求的结果.

A ^ 全集:

用户ID01234567 
二进制值101010100

此外,位图对于稀疏数据的表现不是很好,(当然聪明的大佬们已经基本解决掉了这个问题).原生的位图来讲,如果我们只有两个用户,1号和100000000号用户,那么直接存储int需要8个字节也就是32个bit,而用位图存储需要1亿个bit.当数据量少,且跨度极大也就是稀疏的时候,原生的位图不太适合.

点击这里跳转到稀疏数据的解决方案

总结

那么我们来做一下总结:

位图是用二进制位来存储整形数据的一种数据结构,在很多方面都有应用,尤其是在大数据量的场景下,节省内存及提高运算效率十分实用.

他的优点有:

  1. 节省内存.

    -> 因此在大数据量的时候更加显著.

  2. 与或运算效率高.

    ->可以快速求交集和并集.

缺点有:

  1. 不能直接进行非运算.

    -> 根本原因是位图只能存储一个布尔信息,信息多了就需要借助全量集合等数据辅助.

  2. 数据稀疏时浪费空间.

    -> 这个不用很担心,后面会讲到大佬们的解法,基本可以解决掉.

  3. 只能存储布尔类型.

    -> 有限制,但是业务中很多数据都可以转换为布尔类型.比如上面的例子中, 业务原意:用户每天的签到记录,以用户为维度. 我们可以转换为: 每天的每个用户是否签到,就变为了布尔类型的数据.

应用场景

应用场景其实是很考验人的,不能学以致用,在程序员行业里基本上就相当于没有学了吧…

经过自己的摸索以及在网上的浏览,大致见到了一些应用场景,粗略的写出来,方便大家理解并且以后遇到类似的场景可以想到位图并应用他!

用户签到/抢购等唯一限制

用户签到每天只能一次,抢购活动中只能购买一件,这些需求导致的有一种查询请求,给定的id做没做过某事.而且一般这种需求都无法接受你去查库的延迟.当然你查一次库之后在redis中写入:key = 2345 , value = 签到过了.也是可以实现的,但是内存占用太大.

而使用位图之后,当2345用户签到过/抢购过之后,在redis中调用setbit 2019-07-01-签到 2345 1即可,之后用户的每次签到/抢购请求进来,只需要执行相应的getbit即可拿到是否放行的bool值.

这样记录,不仅可以节省空间,以及加快访问速度之外,还可以提供一些额外的统计功能,比如调用bitcount来统计今天签到总人数等等.统计速度一般是优于关系型数据库的,可以用来做实时的接口查询等.

用户标签等数据

大数据已经很普遍了,用户画像大家也都在做,这时候需要根据标签分类用户,进行存储.方便后续的推荐等操作.

而用户及标签的数据结构设计是一件比较麻烦的事情,且很容易造成查询性能太低.同时,对多个标签经常需要进行逻辑操作,比如喜欢电子产品的00后用户有哪些,女性且爱旅游的用户有哪些等等,这在关系型数据库中都会造成处理的困难.

可以使用位图来进行存储,每一个标签存储为一个位图(逻辑上,实际上你还可以按照尾号分开等等操作),在需要的时间进行快速的统计及计算. 如:

用户012345678
爱旅游100100100

可以清晰的统计出,0,3,6用户喜欢旅游.

用户012345678
00后110000100

用户0,1,6是00后.

那么对两个位图取与即可得到爱旅游的00后用户为0,6.

 

大家都知道的是一个字节用的是8个二进制位来存储的,也就是8个0或者1,即一个字节可以存储十进制0~127的数字,也即包含了所有的数字、英文大小写字母以及标点符号。

1Byte=8bit

1KB=1024Byte

1MB=1024KB

1GB=1024MB

位数组在redis存储世界里,每一个字节也是8位,初始都是:

位数组在redis存储世界里,每一个字节也是8位,初始都是:

0 0 0 0 0 0 0 0

而位操作就是在对应的offset偏移量上设置0或者1,比如将第3位设置为1,即:

0 0 0 0 1 0 0 0
#对应redis操作即:
setbit key 3 1

在此基础上,如果要在偏移量为13的位置设置1,即:

setbit key 13 1
#对应redis中的存储为:
0 0 1 0 | 0 0 0 0 | 0 0 0 0 | 1 0 0 0

Bitmaps介绍

  • Redis提供的Bitmaps这个“数据结构”可以实现对位的操作。Bitmaps本身不是一种数据结构,实际上就是字符串,但是它可以对字符串的位进行操作。
  • 可以把Bitmaps想象成一个以位为单位数组,数组中的每个单元只能存0或者1,数组的下标在bitmaps中叫做偏移量
  • 单个bitmaps的最大长度是512MB,即2^32个比特位。
  • bitmaps的最大优势是节省存储空间。例如,在一个以自增id代表不同用户的系统中,我们只需要512MB空间就可以记录40亿用户的某个单一信息(比如,用户是否希望接收新闻邮件)。

Bitmaps使用场景 

1.各种实时分析(Real time analytics of all kinds)。
2.存储与对象ID关联的布尔信息,要求高效且高性能(Storing space efficient but high performance boolean information associated with object IDs.)。

Bitmaps常用命令

1.设置值

命令:setbit key offset value

setbit命令接收两个参数,

第一个参数表示你要操作的是第几个bit位,第二个参数表示你要将这个位设为何值,可选值只有0,1两个。
如果所操作的bit位超过了当前字串的长度,reids会自动增大字串长度。

2 获取值

命令:getbit key offset

getbit只是返回特定bit位的值。如果试图获取的bit位在当前字串长度范围外,该命令返回0。 

3 获取Bitmaps指定范围值为1的个数

命令:bitcount key [start] [end] 

查看某一天是否有打卡!

在这里插入图片描述

统计操作,统计打卡的天数!

在这里插入图片描述

用Redis bitmap统计活跃用户、留存

对于个int型的数来说,若用来记录id,则只能记录一个,而若转换为二进制存储,则可以表示32个,空间的利用率提升了32倍.对于海量数据的处理,这样的存储方式会节省很多内存空间.对于未登陆的用户,可以使用Hash算法,把对应的用户标识哈希为一个数字id.对于一亿个数据来说,我们也只需要1000000000/8/1024/1024大约12M空间左右.

而Redis已经为我们提供了SETBIT的方法,使用起来非常的方便,我们在item页面可以不停地使用SETBIT命令,设置用户已经访问了该页面,也可以使用GETBIT的方法查询某个用户是否访问。最后通过BITCOUNT统计该网页每天的访问数量。

优点: 占用内存更小,查询方便,可以指定查询某个用户,对于非登陆的用户,可能不同的key映射到同一个id,否则需要维护一个非登陆用户的映射,有额外的开销。

//使用观察者模式,根据不同的type来判断不同的事务
public String progressChanged(EverydayStatisticEventObject registerEventObject) {
    String Type = "";
    StatisticEventType eventType = registerEventObject.getStatisticEventType();
    switch (eventType) {
      case REGISTER_COUNTER:
        Type = REGISTER_COUNTER;
        break;
      case ACTIVE_COUNTER:
        Type = ACTIVE_COUNTER;
        break;
      case FISSION_COUNTER:
        Type = FISSION_COUNTER;
        break;
      case PLAYED_COUNTER:
        Type = PLAYED_COUNTER;
        break;
      case ADCLICK_COUNTER:
        Type = ADCLICK_COUNTER;
        break;
      default:
        break;
    }
    return Type;
  }

  //事件监听器
  //异步
  @EventListener
  @Async
  public void registerCountEvent(EverydayStatisticEventObject registerEventObject) {


    String date = LocalDate.now().toString(STATISTIC_DATE_FMT);
    String type = progressChanged(registerEventObject);
    
    //数据库主键id 减去当天第一个id 这样每天的偏移量都是从一开始可以有效减少偏移量对内存的占用。
    int offset = registerEventObject.getId() + 1 - minId.getUnchecked(StringUtils.join(type, "@", date));

    String key = StringUtils.join(STATISTIC_CACHE_KEY_PREFIX, type,
      date, ":", registerEventObject.getOs());


    setBitmap(offset, key);

    String proxyKey = StringUtils.join(STATISTIC_CACHE_KEY_PREFIX, type,
      date, ":", registerEventObject.getProxy(), ":", registerEventObject.getOs());

    setBitmap(offset, proxyKey);

        
       /* redisTemplate.execute((RedisCallback) connection -> {
            Long count = connection.bitCount(key.getBytes());

            log.info("key={},count = {},offset={}",key,count,offset);
            return true;
        });

        redisTemplate.execute((RedisCallback) connection -> {
            Long count = connection.bitCount(proxyKey.getBytes());

            log.info("proxyKey={},count = {},offset={}",proxyKey,count,offset);
            return true;
        });*/
  }

private void setBitmap(int offset, String key) {

    byte[] bitKey = key.getBytes();

    redisTemplate.execute((RedisCallback) connection -> {
      boolean exists = connection.getBit(bitKey, offset);
      if (!exists) {
        connection.setBit(bitKey, offset, true);
        //设置过期时间 每天的数据统计 只保留2天
        connection.expire(bitKey, 60L * 60 * 24 * 2);  //2 days
        return true;
      }
      return false;
    });
  }

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值