openmartin

openmartin 查看完整档案

北京编辑  |  填写毕业院校  |  填写所在公司/组织 xingzuoshe.cn 编辑
编辑

古典占星 。师从台湾杨国正老师。|| 占星看盘请私信。|| 伟大的灵魂都是雌雄同体 || 开放心态,契约精神

个人动态

openmartin 收藏了文章 · 8月21日

卡方检验原理及应用

卡方检验,或称x2检验,被誉为二十世纪科学技术所有分支中的20大发明之一,它的发明者卡尔·皮尔逊是一位历史上罕见的百科全书式的学者,研究领域涵盖了生物、历史、宗教、哲学、法律。之前做文本分类项目用过卡方值做特征选择(降维),后来听内部培训,另一个部门说他们有用卡方检验做异常用户的检测,于是就想把卡方检验再温习一次,同时把卡方检验和特征选择串起来理解。

无关性假设

举个例子,假设我们有一堆新闻标题,需要判断标题中包含某个词(比如吴亦凡)是否与该条新闻的类别归属(比如娱乐)是否有关,我们只需要简单统计就可以获得这样的一个四格表:

组别属于娱乐不属于娱乐合计
不包含吴亦凡192443
包含吴亦凡341044
合计533487 

通过这个四格表我们得到的第一个信息是:标题是否包含吴亦凡确实对新闻是否属于娱乐有统计上的差别,包含吴亦凡的新闻属于娱乐的比例更高,但我们还无法排除这个差别是否由于抽样误差导致。那么首先假设标题是否包含吴亦凡新闻是否属于娱乐是独立无关的,随机抽取一条新闻标题,属于娱乐类别的概率是:(19 + 34) / (19 + 34 + 24 +10) = 60.9%

理论值四格表

第二步,根据无关性假设生成新的理论值四格表:

组别属于娱乐不属于娱乐合计
不包含吴亦凡43 * 0.609 = 26.243 * 0.391 = 16.843
包含吴亦凡44 * 0.609 = 26.844 * 0.391 = 17.244

显然,如果两个变量是独立无关的,那么四格表中的理论值与实际值的差异会非常小。

x2值的计算

x2的计算公式为:

x2计算公式

其中A为实际值,也就是第一个四格表里的4个数据,T为理论值,也就是理论值四格表里的4个数据。

x2用于衡量实际值与理论值的差异程度(也就是卡方检验的核心思想),包含了以下两个信息:

  • 实际值与理论值偏差的绝对大小(由于平方的存在,差异是被放大的)

  • 差异程度与理论值的相对大小

对上述场景可计算x2值为10.01。

卡方分布的临界值

既然已经得到了x2值,我们又怎么知道x2值是否合理?也就是说,怎么知道无关性假设是否可靠?答案是,通过查询卡方分布的临界值表。

这里需要用到一个自由度的概念,自由度等于V = (行数 - 1) * (列数 - 1),对四格表,自由度V = 1

V = 1,卡方分布的临界概率是:

卡方分布临界值

显然10.01 > 7.88,也就是标题是否包含吴亦凡新闻是否属于娱乐无关的可能性小于0.5%,反过来,就是两者相关的概率大于99.5%。

应用场景

卡方检验的一个典型应用场景是衡量特定条件下的分布是否与理论分布一致,比如:特定用户某项指标的分布与大盘的分布是否差异很大,这时通过临界概率可以合理又科学的筛选异常用户。

另外,x2值描述了自变量与因变量之间的相关程度:x2值越大,相关程度也越大,所以很自然的可以利用x2值来做降维,保留相关程度大的变量。再回到刚才新闻分类的场景,如果我们希望获取和娱乐类别相关性最强的100个词,以后就按照标题是否包含这100个词来确定新闻是否归属于娱乐类,怎么做?很简单,对娱乐类新闻标题所包含的每个词按上述步骤计算x2值,然后按x2值排序,取x2值最大的100个词。

来自:建造者说

查看原文

openmartin 赞了文章 · 8月21日

卡方检验原理及应用

卡方检验,或称x2检验,被誉为二十世纪科学技术所有分支中的20大发明之一,它的发明者卡尔·皮尔逊是一位历史上罕见的百科全书式的学者,研究领域涵盖了生物、历史、宗教、哲学、法律。之前做文本分类项目用过卡方值做特征选择(降维),后来听内部培训,另一个部门说他们有用卡方检验做异常用户的检测,于是就想把卡方检验再温习一次,同时把卡方检验和特征选择串起来理解。

无关性假设

举个例子,假设我们有一堆新闻标题,需要判断标题中包含某个词(比如吴亦凡)是否与该条新闻的类别归属(比如娱乐)是否有关,我们只需要简单统计就可以获得这样的一个四格表:

组别属于娱乐不属于娱乐合计
不包含吴亦凡192443
包含吴亦凡341044
合计533487 

通过这个四格表我们得到的第一个信息是:标题是否包含吴亦凡确实对新闻是否属于娱乐有统计上的差别,包含吴亦凡的新闻属于娱乐的比例更高,但我们还无法排除这个差别是否由于抽样误差导致。那么首先假设标题是否包含吴亦凡新闻是否属于娱乐是独立无关的,随机抽取一条新闻标题,属于娱乐类别的概率是:(19 + 34) / (19 + 34 + 24 +10) = 60.9%

理论值四格表

第二步,根据无关性假设生成新的理论值四格表:

组别属于娱乐不属于娱乐合计
不包含吴亦凡43 * 0.609 = 26.243 * 0.391 = 16.843
包含吴亦凡44 * 0.609 = 26.844 * 0.391 = 17.244

显然,如果两个变量是独立无关的,那么四格表中的理论值与实际值的差异会非常小。

x2值的计算

x2的计算公式为:

x2计算公式

其中A为实际值,也就是第一个四格表里的4个数据,T为理论值,也就是理论值四格表里的4个数据。

x2用于衡量实际值与理论值的差异程度(也就是卡方检验的核心思想),包含了以下两个信息:

  • 实际值与理论值偏差的绝对大小(由于平方的存在,差异是被放大的)

  • 差异程度与理论值的相对大小

对上述场景可计算x2值为10.01。

卡方分布的临界值

既然已经得到了x2值,我们又怎么知道x2值是否合理?也就是说,怎么知道无关性假设是否可靠?答案是,通过查询卡方分布的临界值表。

这里需要用到一个自由度的概念,自由度等于V = (行数 - 1) * (列数 - 1),对四格表,自由度V = 1

V = 1,卡方分布的临界概率是:

卡方分布临界值

显然10.01 > 7.88,也就是标题是否包含吴亦凡新闻是否属于娱乐无关的可能性小于0.5%,反过来,就是两者相关的概率大于99.5%。

应用场景

卡方检验的一个典型应用场景是衡量特定条件下的分布是否与理论分布一致,比如:特定用户某项指标的分布与大盘的分布是否差异很大,这时通过临界概率可以合理又科学的筛选异常用户。

另外,x2值描述了自变量与因变量之间的相关程度:x2值越大,相关程度也越大,所以很自然的可以利用x2值来做降维,保留相关程度大的变量。再回到刚才新闻分类的场景,如果我们希望获取和娱乐类别相关性最强的100个词,以后就按照标题是否包含这100个词来确定新闻是否归属于娱乐类,怎么做?很简单,对娱乐类新闻标题所包含的每个词按上述步骤计算x2值,然后按x2值排序,取x2值最大的100个词。

来自:建造者说

查看原文

赞 26 收藏 40 评论 2

openmartin 收藏了文章 · 1月21日

惨遭DruidDataSource和Mybatis暗算,导致OOM

先遭DruidDataSource袭击

事发

一个平凡的工作日,我像往常一样完成产品提出的需求的业务代码,突然收到了监控平台发出的告警信息。本以为又是一些业务上的bug导致的报错,一看报错发现日志写着java.lang.OutOfMemoryError: Java heap space

接着我远程到那台服务器上,但是卡的不行。于是我就用top命令查了一下cpu信息,占用都快要到99%了。再看看GC的日志发现程序一直在Full GC,怪不得cpu占用这么高。

这里就推测是有内存泄漏的问题导致GC无法回收内存导致OOM。为了先不影响业务,就先让运维把这个服务重启一下,果然重启后服务就正常了。

分析日志

先看一下报错日志详细写了一些什么错误信息,虽然一般OOM问题日志不能准确定位到问题,但是已经打开日志平台了,看一下作为参考也是不亏的。

看到日志中写的OOM事发场景是在计算多个用户的总金额的时候出现的,大致伪代码如下:

/**
 * OrderService.java
 */

// 1. 根据某些参数获取符合条件的用户id列表
List<Long> customerIds = orderService.queryCustomerIdByParam(param); 

// 2. 计算这些用户id的金额总和
long principal = orderMapper.countPrincipal(customerIds);
<!-- 

 OrderMapper.xml

-->

<!-- 3. 在OrderMapper的xml文件中写mybatis的sql逻辑 -->
<select id="countPrincipal" resultType="java.lang.Long">
    select
    IFNULL(sum(remain_principal),0)
    from
    t_loan where
    <if test="null != customerIds and customerIds.size > 0">
        customer_id in
        <foreach collection="customerIds" item="item" index="index" open="("
                 close=")" separator=",">
            #{item}
        </foreach>
    </if>
</select>

这部分感觉出问题的原因是由于计算金额总额时,查询参数customerIds太多了。由于前段时间业务的变更,导致在参数不变的情况下,查询出的customerIds列表由原来的几十几百个id变成了上万个,就我看的报错信息这里的日志打印出来这个list的大小就有三万多个customerId。不过就算查询条件为三万多个而导致sql执行的比较慢,但是这个方法只有内部的财务系统才会调用,业务量没那么大,也不应该导致OOM的出现啊。
所以接着再看一下JVM打印出来的Dump文件来定位到具体的问题。

分析Dump文件

得益于在JVM参数中加了-XX:+HeapDumpOnOutOfMemoryError 参数,在发生OOM的时候系统会自动生成当时的Dump文件,这样我们可以完整的分析“案发现场”。这里我们使用Eclipse Memory Analyzer工具来帮忙解析Dump文件。

Snipaste_2020-01-08_16-58-07

从Overview中的饼图可以很明显的看到有个蓝色区域占了最大头,这个类占了245.6MB的内存。再看左侧的说明写着DruidDataSource,好的,罪魁祸首就是他了。

Snipaste_2020-01-08_17-15-18

再通过Domainator_Tree界面可以看到是com.alibaba.druid.pool.DruidDataSource类下的com.alibaba.druid.stat.JdbcDataSourceStat$1对象里面有个LinkedHashMap,这个Map持有了600多个Entry,其中大约有100个Entry大小为2000000多字节(约2MB)。而Entry的key是String对象,看了一下String的内容大约都是select IFNULL(sum remain_principal),0) from t_loan where customer_id in (?, ?, ?, ? ...,果然就是刚才错误日志所提示的代码的功能。

问题分析

由于计算这些用户金额的查询条件有3万多个所以这个SQL语句特别长,然后这些SQL都被JdbcDataSourceStat中的一个HashMap对象所持有导致无法GC,从而导致OOM的发生。
嗯,简直是教科书般的OOM事件。

处理

接下来去看了一下JdbcDataSourceStat的源码,发现有个变量为LinkedHashMap<String, JdbcSqlStat> sqlStatMap的Map。并且还有个静态变量和静态代码块:

private static JdbcDataSourceStat global;

static {
    String dbType = null;
    {
    String property = System.getProperty("druid.globalDbType");
    if (property != null && property.length() > 0) {
        dbType = property;
    }
    }
    global = new JdbcDataSourceStat("Global", "Global", dbType);
}

这就意味着除非手动在代码中释放global对象或者removesqlStatMap里的对象,否则sqlStatMap就会一直被持有不能被GC释放。

已经定位到问题所在了,不过简单的从代码上看无法判定这个sqlStatMap具体是有什么作用,以及如何使其释放掉,于是到网上搜索了一下,发现在其Github的Issues里就有人提出过这个问题了。每个sql语句都会长期持有引用,加快FullGC频率

sqlStatMap这个对象是用于Druid的监控统计功能的,所以要持有这些SQL用于展示在页面上。由于平时不使用这个功能,且询问其他同事也不清楚为何开启这个功能,所以决定先把这个功能关闭。

根据文档写这个功能默认是关闭的,不过被我们在配置文件中开启了,现在去掉这个配置就可以了

<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"
      init-method="init" destroy-method="close">
    ...
    <!-- 监控 -->
    <!-- <property name="filters" value="state"/> -->
</bean>

修改完上线后一段时间后没有发生OOM了,这时再通过jmap -dump:format=b,file=文件名 [pid]命令生成Dump文件,会发现内存占用恢复正常,并且也不会看到com.alibaba.druid.pool.DruidDataSource类下有com.alibaba.druid.stat.JdbcDataSourceStat$1的占用。证明这个OOM问题已经被成功解决了。

再遭Mybatis暗算

事发

又是一个平凡的工作日,线上告警又出现报错,我一看日志又是OOM。我以为上次DruidDataSource问题还没解决干净,但是这次的现象却有点不一样。首先是这次只告警了一次,不像上次一直在告警。然后远程到服务器看cpu和内存占用正常,业务也没有受影响,所以这次也不用重启服务了。

分析

这次告警的错误日志还是指向着上次DruidDataSource导致OOM异常的位置,由于对其印象深刻,所以这次直接看看Dump文件(由于Dump文件比较大,线上的被清除了,而我也忘记备份,所以这份文件是我时候场景还原的时候生成的)。

Snipaste_2020-01-08_22-50-07

这次没有明显的一个特别大的占用对象了,看来这次的问题确实和上次有所不一样。再去看看Domainator_Tree界面的具体分析。

Snipaste_2020-01-08_23-01-12

虽然没有出现一个对象占用内存,但是可以看到有十几个线程都占用近20M的内存大小,加起来就要占用300多M的内存了。再看一下这些线程中内存占用是怎样的。

Snipaste_2020-01-09_18-10-29

从这个线程的高占用内存情况来看,有几个是String类型的,是拼接SQL后的语句,这些是必不可少的。

还有两个高内存占用对象是org.apache.ibatis.scripting.xmltags.DynamicContext$ContextMaporg.apache.ibatis.builder.SqlSourceBuilder$ParameterMappingTokenHandler

从这两个对象的内容看似乎是Mybatis拼接SQL的时候生成的占位符和参数对象。就比如下面的这个查询语句

List<Long> customerIds = orderService.queryCustomerIdByParam(param); 

long principal = orderMapper.countPrincipal(customerIds);

所以虽然用于查询的参数为Long的类型,即使这个List有三万多个其本身也不会占用很大的内存,但是Mybatis在拼接SQL的时候,会把Long类型的对象包装成一个通用的对象类型(类似于AbstractItem的感觉),并且会给每一个通用对象类型起一个别名(比如__frch_item_1, __frch_item_2这样),然后存放在Map中在拼接SQL的时候使用。又由于拼接SQL字符串还是比较消耗资源,当参数多SQL长的时候还是需要一定的时间的,这时候Map就会持有较长时间,一旦有较多线程同时做这种操作,内存占用就高,很容易发生OOM

查看Mybatis源码分析

首先看org.apache.ibatis.scripting.xmltags.DynamicContext$ContextMap,他是DynamicContext的一个变量,变量名为bindings,是DynamicContext的内部类,继承了HashMap。并且DynamicContext类里用了bind()方法包装了HashMapput()方法。

Snipaste_2020-01-11_15-37-41

再利用IDEA的Usages of查看功能,看看哪些方法调用了bind()方法。

Snipaste_2020-01-12_15-49-45

可以看到有三个类调用bind()方法,这里只用关注org.apache.ibatis.scripting.xmltags.ForEachSqlNode这个类,因为我们在Mybatisxml里用了foreach关键字来实现SQL的in查询功能。那我们大致来看一下ForEachSqlNode这个类有什么特别的地方可能导致oom的。

ForEachSqlNode实现了SqlNode接口并实现了apply()方法,这个方法是拼接SQL语句的核心,下面是apply()方法的代码,我为一些关键步骤加了中文注释。

  @Override
  public boolean apply(DynamicContext context) {
      // bindings就是上面说到的占用大内存的对象
    Map<String, Object> bindings = context.getBindings();
    final Iterable<?> iterable = evaluator.evaluateIterable(collectionExpression, bindings);
    if (!iterable.iterator().hasNext()) {
      return true;
    }
    boolean first = true;
    // SQL的开始字符串
    applyOpen(context);
    int i = 0;
    // 遍历参数
    for (Object o : iterable) {
      DynamicContext oldContext = context;
      if (first || separator == null) {
        context = new PrefixedContext(context, "");
      } else {
        context = new PrefixedContext(context, separator);
      }
      int uniqueNumber = context.getUniqueNumber();
      // Issue #709
      if (o instanceof Map.Entry) {
        // 如果是Map对象则用key value的形式
        @SuppressWarnings("unchecked")
        Map.Entry<Object, Object> mapEntry = (Map.Entry<Object, Object>) o;
        applyIndex(context, mapEntry.getKey(), uniqueNumber);
        applyItem(context, mapEntry.getValue(), uniqueNumber);
      } else {
        // 以数量i作为key
        applyIndex(context, i, uniqueNumber);
        applyItem(context, o, uniqueNumber);
      }
      // FilteredDynamicContext动态生成SQL
      contents.apply(new FilteredDynamicContext(configuration, context, index, item, uniqueNumber));
      if (first) {
        first = !((PrefixedContext) context).isPrefixApplied();
      }
      context = oldContext;
      i++;
    }
    // SQL的结束字符串
    applyClose(context);
    context.getBindings().remove(item);
    context.getBindings().remove(index);
    return true;
  }

在每个遍历的时候applyIndex()applyItem()方法就会将参数和参数的占位符,以及参数SQL前后缀调用上面说的bind()方法存在bindings里。

  private void applyIndex(DynamicContext context, Object o, int i) {
    if (index != null) {
      context.bind(index, o);
      context.bind(itemizeItem(index, i), o);
    }
  }
  private void applyItem(DynamicContext context, Object o, int i) {
    if (item != null) {
      context.bind(item, o);
      context.bind(itemizeItem(item, i), o);
    }
  }

接着用FilteredDynamicContext处理占位符,这是ForEachSqlNode的一个内部类,继承了DynamicContext类,主要重写了appendSql()方法。

  private static class FilteredDynamicContext extends DynamicContext {
    ...

    @Override
    public void appendSql(String sql) {
      GenericTokenParser parser = new GenericTokenParser("#{", "}", content -> {
        String newContent = content.replaceFirst("^\\s*" + item + "(?![^.,:\\s])", itemizeItem(item, index));
        if (itemIndex != null && newContent.equals(content)) {
          newContent = content.replaceFirst("^\\s*" + itemIndex + "(?![^.,:\\s])", itemizeItem(itemIndex, index));
        }
        return "#{" + newContent + "}";
      });

      delegate.appendSql(parser.parse(sql));
    }

appendSql()用正则来查找替换#{}占位符里的内容,但这里也不是真正的绑定参数,只是替换刚才存在bindings里面的占位符号,例如__frch_item_1, __frch_item_2(在Dump文件中看到的)。

问题分析

由此可以看出问题是,Mybatisforeach拼接SQL的性能较差,尤其是通过正则之类的操作匹配占位符时需要较多的时间。同时又持有查询参数和占位符在ContextMap中无法被GC释放,所以一旦并发量上去就很容易内存占用过多导致OOM

场景复现

这个问题在本地很容易复现,我们先创建数据库表

CREATE TABLE user
(
    id int(11) PRIMARY KEY NOT NULL,
    name varchar(50)
);

创建一个SpringBoot+Mybatis的工程项目。并且模拟线上的JVM配置,在IDEA设置这个工程的VM Option参数-Xmx512m -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError

写出对应模拟线上的foreach语句

<select id="countUser" resultType="long">
    select
    IFNULL(sum(1),0)
    from user where
    <if test="ids != null and ids.size() > 0">
        id in
        <foreach collection="ids" item="item" index="index" open="("
                 close=")" separator=",">
            #{item}
        </foreach>
    </if>
</select>

再写单元测试

@Test
public void count() {
    AtomicInteger count = new AtomicInteger(0);
    for (int threadId = 0; threadId < 50; threadId++) {
        // 起50个线程并发调用countUser()方法
        int finalThreadId = threadId;
        new Thread(() -> {
            long userCount = userMapper.countUser(createIds(10000 + finalThreadId));
            log.info("thread:{}, userCount:{}", finalThreadId, userCount);
            count.getAndAdd(1);
        }).start();
    }

    // 等待50个查询线程跑完
    while (count.get() < 50) {
    }
    log.info("end!!!!");
}

private List<Long> createIds(int size) {
    List<Long> ids = new ArrayList<>();
    for (int i = 0; i < size; i++) {
        ids.add((long) i);
    }
    return ids;
}

接着运行单元测试。由于在JVM配置加了-XX:+PrintGCDetails参数,所以在控制台会显示GC日志,不一会就会看见很多的Full GC,然后程序就会出现OOM报错。

Snipaste_2020-01-16_23-20-45

处理

由于问题是Mybatis通过foreach拼接长SQL字符串性能太差导致的,所以解决思路有两种

  1. 通过拆分in的查询条件来减少foreach每次拼接SQL的长度
@Test
public void count2() {
    AtomicInteger count = new AtomicInteger(0);
    for (int threadId = 0; threadId < 50; threadId++) {
        // 起50个线程并发调用countUser()方法
        int finalThreadId = threadId;
        new Thread(() -> {
            List<Long> totalIds = createIds(100000 + finalThreadId);
            long totalUserCount = 0;
            //使用guava对list进行分割,按每1000个一组分割
            List<List<Long>> parts = Lists.partition(totalIds, 1000);
            for (List<Long> ids : parts) {
                totalUserCount += userMapper.countUser(ids);
            }
            log.info("thread:{}, userCount:{}", finalThreadId, totalUserCount);
            count.getAndAdd(1);
        }).start();
    }

    // 等待50个查询线程跑完
    while (count.get() < 50) {
    }
    log.info("end!!!!");
}

这样每次拼接查询SQL的时候只用循环1000次,很快就可以把资源释放掉,就不会引起OOM,但是这种方法还是会生成很多不必要的数据占用内存,频繁触发GC,浪费资源。

  1. 不使用Mybatisforeach来拼接in条件的SQL

既然Mybatisforeach性能不好,那我们通过Java层面自己拼接in条件,特别是针对这种查询条件也比较单一的,更适合自己拼接。

@Test
public void count3() {
    AtomicInteger count = new AtomicInteger(0);
    for (int threadId = 0; threadId < 50; threadId++) {
        // 起50个线程并发调用countUser()方法
        int finalThreadId = threadId;
        new Thread(() -> {
            List<Long> ids = createIds(100000 + finalThreadId);
            StringBuilder sb = new StringBuilder();
            for (long i : ids) {
                sb.append(i).append(",");
            }
            // 查询条件使用String字符串
            long userCount = userMapper.countUserString(sb.toString());
            log.info("thread:{}, userCount:{}", finalThreadId, userCount);
            count.getAndAdd(1);
        }).start();
    }

    // 等待50个查询线程跑完
    while (count.get() < 50) {
    }
    log.info("end!!!!");
}
<select id="countUserString" resultType="long">
    select
    IFNULL(sum(1),0)
    from user where
    <if test="null != ids and ids !=''">
        id in (#{ids})
    </if>
</select>

这样就能大大减少使用foreach而生成的对象,同时减少拼接SQL的时间,避免OOM发生的同时优化性能。

后记

这两次遇到OOM问题解决起来还算比较轻松的,除了后续分析得当以外,也离不开预先的环境配置。在服务JVM参数中增加-XX:+HeapDumpOnOutOfMemoryError-XX:+PrintGCDetails参数,可以在发生OOM的时候输出dump文件,并且能有GC日志查看GC的具体情况,这些都是对于OOM问题非常有帮助的。


原文地址:惨遭DruidDataSource和Mybatis暗算,导致OOM

查看原文

openmartin 赞了文章 · 1月21日

惨遭DruidDataSource和Mybatis暗算,导致OOM

先遭DruidDataSource袭击

事发

一个平凡的工作日,我像往常一样完成产品提出的需求的业务代码,突然收到了监控平台发出的告警信息。本以为又是一些业务上的bug导致的报错,一看报错发现日志写着java.lang.OutOfMemoryError: Java heap space

接着我远程到那台服务器上,但是卡的不行。于是我就用top命令查了一下cpu信息,占用都快要到99%了。再看看GC的日志发现程序一直在Full GC,怪不得cpu占用这么高。

这里就推测是有内存泄漏的问题导致GC无法回收内存导致OOM。为了先不影响业务,就先让运维把这个服务重启一下,果然重启后服务就正常了。

分析日志

先看一下报错日志详细写了一些什么错误信息,虽然一般OOM问题日志不能准确定位到问题,但是已经打开日志平台了,看一下作为参考也是不亏的。

看到日志中写的OOM事发场景是在计算多个用户的总金额的时候出现的,大致伪代码如下:

/**
 * OrderService.java
 */

// 1. 根据某些参数获取符合条件的用户id列表
List<Long> customerIds = orderService.queryCustomerIdByParam(param); 

// 2. 计算这些用户id的金额总和
long principal = orderMapper.countPrincipal(customerIds);
<!-- 

 OrderMapper.xml

-->

<!-- 3. 在OrderMapper的xml文件中写mybatis的sql逻辑 -->
<select id="countPrincipal" resultType="java.lang.Long">
    select
    IFNULL(sum(remain_principal),0)
    from
    t_loan where
    <if test="null != customerIds and customerIds.size > 0">
        customer_id in
        <foreach collection="customerIds" item="item" index="index" open="("
                 close=")" separator=",">
            #{item}
        </foreach>
    </if>
</select>

这部分感觉出问题的原因是由于计算金额总额时,查询参数customerIds太多了。由于前段时间业务的变更,导致在参数不变的情况下,查询出的customerIds列表由原来的几十几百个id变成了上万个,就我看的报错信息这里的日志打印出来这个list的大小就有三万多个customerId。不过就算查询条件为三万多个而导致sql执行的比较慢,但是这个方法只有内部的财务系统才会调用,业务量没那么大,也不应该导致OOM的出现啊。
所以接着再看一下JVM打印出来的Dump文件来定位到具体的问题。

分析Dump文件

得益于在JVM参数中加了-XX:+HeapDumpOnOutOfMemoryError 参数,在发生OOM的时候系统会自动生成当时的Dump文件,这样我们可以完整的分析“案发现场”。这里我们使用Eclipse Memory Analyzer工具来帮忙解析Dump文件。

Snipaste_2020-01-08_16-58-07

从Overview中的饼图可以很明显的看到有个蓝色区域占了最大头,这个类占了245.6MB的内存。再看左侧的说明写着DruidDataSource,好的,罪魁祸首就是他了。

Snipaste_2020-01-08_17-15-18

再通过Domainator_Tree界面可以看到是com.alibaba.druid.pool.DruidDataSource类下的com.alibaba.druid.stat.JdbcDataSourceStat$1对象里面有个LinkedHashMap,这个Map持有了600多个Entry,其中大约有100个Entry大小为2000000多字节(约2MB)。而Entry的key是String对象,看了一下String的内容大约都是select IFNULL(sum remain_principal),0) from t_loan where customer_id in (?, ?, ?, ? ...,果然就是刚才错误日志所提示的代码的功能。

问题分析

由于计算这些用户金额的查询条件有3万多个所以这个SQL语句特别长,然后这些SQL都被JdbcDataSourceStat中的一个HashMap对象所持有导致无法GC,从而导致OOM的发生。
嗯,简直是教科书般的OOM事件。

处理

接下来去看了一下JdbcDataSourceStat的源码,发现有个变量为LinkedHashMap<String, JdbcSqlStat> sqlStatMap的Map。并且还有个静态变量和静态代码块:

private static JdbcDataSourceStat global;

static {
    String dbType = null;
    {
    String property = System.getProperty("druid.globalDbType");
    if (property != null && property.length() > 0) {
        dbType = property;
    }
    }
    global = new JdbcDataSourceStat("Global", "Global", dbType);
}

这就意味着除非手动在代码中释放global对象或者removesqlStatMap里的对象,否则sqlStatMap就会一直被持有不能被GC释放。

已经定位到问题所在了,不过简单的从代码上看无法判定这个sqlStatMap具体是有什么作用,以及如何使其释放掉,于是到网上搜索了一下,发现在其Github的Issues里就有人提出过这个问题了。每个sql语句都会长期持有引用,加快FullGC频率

sqlStatMap这个对象是用于Druid的监控统计功能的,所以要持有这些SQL用于展示在页面上。由于平时不使用这个功能,且询问其他同事也不清楚为何开启这个功能,所以决定先把这个功能关闭。

根据文档写这个功能默认是关闭的,不过被我们在配置文件中开启了,现在去掉这个配置就可以了

<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"
      init-method="init" destroy-method="close">
    ...
    <!-- 监控 -->
    <!-- <property name="filters" value="state"/> -->
</bean>

修改完上线后一段时间后没有发生OOM了,这时再通过jmap -dump:format=b,file=文件名 [pid]命令生成Dump文件,会发现内存占用恢复正常,并且也不会看到com.alibaba.druid.pool.DruidDataSource类下有com.alibaba.druid.stat.JdbcDataSourceStat$1的占用。证明这个OOM问题已经被成功解决了。

再遭Mybatis暗算

事发

又是一个平凡的工作日,线上告警又出现报错,我一看日志又是OOM。我以为上次DruidDataSource问题还没解决干净,但是这次的现象却有点不一样。首先是这次只告警了一次,不像上次一直在告警。然后远程到服务器看cpu和内存占用正常,业务也没有受影响,所以这次也不用重启服务了。

分析

这次告警的错误日志还是指向着上次DruidDataSource导致OOM异常的位置,由于对其印象深刻,所以这次直接看看Dump文件(由于Dump文件比较大,线上的被清除了,而我也忘记备份,所以这份文件是我时候场景还原的时候生成的)。

Snipaste_2020-01-08_22-50-07

这次没有明显的一个特别大的占用对象了,看来这次的问题确实和上次有所不一样。再去看看Domainator_Tree界面的具体分析。

Snipaste_2020-01-08_23-01-12

虽然没有出现一个对象占用内存,但是可以看到有十几个线程都占用近20M的内存大小,加起来就要占用300多M的内存了。再看一下这些线程中内存占用是怎样的。

Snipaste_2020-01-09_18-10-29

从这个线程的高占用内存情况来看,有几个是String类型的,是拼接SQL后的语句,这些是必不可少的。

还有两个高内存占用对象是org.apache.ibatis.scripting.xmltags.DynamicContext$ContextMaporg.apache.ibatis.builder.SqlSourceBuilder$ParameterMappingTokenHandler

从这两个对象的内容看似乎是Mybatis拼接SQL的时候生成的占位符和参数对象。就比如下面的这个查询语句

List<Long> customerIds = orderService.queryCustomerIdByParam(param); 

long principal = orderMapper.countPrincipal(customerIds);

所以虽然用于查询的参数为Long的类型,即使这个List有三万多个其本身也不会占用很大的内存,但是Mybatis在拼接SQL的时候,会把Long类型的对象包装成一个通用的对象类型(类似于AbstractItem的感觉),并且会给每一个通用对象类型起一个别名(比如__frch_item_1, __frch_item_2这样),然后存放在Map中在拼接SQL的时候使用。又由于拼接SQL字符串还是比较消耗资源,当参数多SQL长的时候还是需要一定的时间的,这时候Map就会持有较长时间,一旦有较多线程同时做这种操作,内存占用就高,很容易发生OOM

查看Mybatis源码分析

首先看org.apache.ibatis.scripting.xmltags.DynamicContext$ContextMap,他是DynamicContext的一个变量,变量名为bindings,是DynamicContext的内部类,继承了HashMap。并且DynamicContext类里用了bind()方法包装了HashMapput()方法。

Snipaste_2020-01-11_15-37-41

再利用IDEA的Usages of查看功能,看看哪些方法调用了bind()方法。

Snipaste_2020-01-12_15-49-45

可以看到有三个类调用bind()方法,这里只用关注org.apache.ibatis.scripting.xmltags.ForEachSqlNode这个类,因为我们在Mybatisxml里用了foreach关键字来实现SQL的in查询功能。那我们大致来看一下ForEachSqlNode这个类有什么特别的地方可能导致oom的。

ForEachSqlNode实现了SqlNode接口并实现了apply()方法,这个方法是拼接SQL语句的核心,下面是apply()方法的代码,我为一些关键步骤加了中文注释。

  @Override
  public boolean apply(DynamicContext context) {
      // bindings就是上面说到的占用大内存的对象
    Map<String, Object> bindings = context.getBindings();
    final Iterable<?> iterable = evaluator.evaluateIterable(collectionExpression, bindings);
    if (!iterable.iterator().hasNext()) {
      return true;
    }
    boolean first = true;
    // SQL的开始字符串
    applyOpen(context);
    int i = 0;
    // 遍历参数
    for (Object o : iterable) {
      DynamicContext oldContext = context;
      if (first || separator == null) {
        context = new PrefixedContext(context, "");
      } else {
        context = new PrefixedContext(context, separator);
      }
      int uniqueNumber = context.getUniqueNumber();
      // Issue #709
      if (o instanceof Map.Entry) {
        // 如果是Map对象则用key value的形式
        @SuppressWarnings("unchecked")
        Map.Entry<Object, Object> mapEntry = (Map.Entry<Object, Object>) o;
        applyIndex(context, mapEntry.getKey(), uniqueNumber);
        applyItem(context, mapEntry.getValue(), uniqueNumber);
      } else {
        // 以数量i作为key
        applyIndex(context, i, uniqueNumber);
        applyItem(context, o, uniqueNumber);
      }
      // FilteredDynamicContext动态生成SQL
      contents.apply(new FilteredDynamicContext(configuration, context, index, item, uniqueNumber));
      if (first) {
        first = !((PrefixedContext) context).isPrefixApplied();
      }
      context = oldContext;
      i++;
    }
    // SQL的结束字符串
    applyClose(context);
    context.getBindings().remove(item);
    context.getBindings().remove(index);
    return true;
  }

在每个遍历的时候applyIndex()applyItem()方法就会将参数和参数的占位符,以及参数SQL前后缀调用上面说的bind()方法存在bindings里。

  private void applyIndex(DynamicContext context, Object o, int i) {
    if (index != null) {
      context.bind(index, o);
      context.bind(itemizeItem(index, i), o);
    }
  }
  private void applyItem(DynamicContext context, Object o, int i) {
    if (item != null) {
      context.bind(item, o);
      context.bind(itemizeItem(item, i), o);
    }
  }

接着用FilteredDynamicContext处理占位符,这是ForEachSqlNode的一个内部类,继承了DynamicContext类,主要重写了appendSql()方法。

  private static class FilteredDynamicContext extends DynamicContext {
    ...

    @Override
    public void appendSql(String sql) {
      GenericTokenParser parser = new GenericTokenParser("#{", "}", content -> {
        String newContent = content.replaceFirst("^\\s*" + item + "(?![^.,:\\s])", itemizeItem(item, index));
        if (itemIndex != null && newContent.equals(content)) {
          newContent = content.replaceFirst("^\\s*" + itemIndex + "(?![^.,:\\s])", itemizeItem(itemIndex, index));
        }
        return "#{" + newContent + "}";
      });

      delegate.appendSql(parser.parse(sql));
    }

appendSql()用正则来查找替换#{}占位符里的内容,但这里也不是真正的绑定参数,只是替换刚才存在bindings里面的占位符号,例如__frch_item_1, __frch_item_2(在Dump文件中看到的)。

问题分析

由此可以看出问题是,Mybatisforeach拼接SQL的性能较差,尤其是通过正则之类的操作匹配占位符时需要较多的时间。同时又持有查询参数和占位符在ContextMap中无法被GC释放,所以一旦并发量上去就很容易内存占用过多导致OOM

场景复现

这个问题在本地很容易复现,我们先创建数据库表

CREATE TABLE user
(
    id int(11) PRIMARY KEY NOT NULL,
    name varchar(50)
);

创建一个SpringBoot+Mybatis的工程项目。并且模拟线上的JVM配置,在IDEA设置这个工程的VM Option参数-Xmx512m -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError

写出对应模拟线上的foreach语句

<select id="countUser" resultType="long">
    select
    IFNULL(sum(1),0)
    from user where
    <if test="ids != null and ids.size() > 0">
        id in
        <foreach collection="ids" item="item" index="index" open="("
                 close=")" separator=",">
            #{item}
        </foreach>
    </if>
</select>

再写单元测试

@Test
public void count() {
    AtomicInteger count = new AtomicInteger(0);
    for (int threadId = 0; threadId < 50; threadId++) {
        // 起50个线程并发调用countUser()方法
        int finalThreadId = threadId;
        new Thread(() -> {
            long userCount = userMapper.countUser(createIds(10000 + finalThreadId));
            log.info("thread:{}, userCount:{}", finalThreadId, userCount);
            count.getAndAdd(1);
        }).start();
    }

    // 等待50个查询线程跑完
    while (count.get() < 50) {
    }
    log.info("end!!!!");
}

private List<Long> createIds(int size) {
    List<Long> ids = new ArrayList<>();
    for (int i = 0; i < size; i++) {
        ids.add((long) i);
    }
    return ids;
}

接着运行单元测试。由于在JVM配置加了-XX:+PrintGCDetails参数,所以在控制台会显示GC日志,不一会就会看见很多的Full GC,然后程序就会出现OOM报错。

Snipaste_2020-01-16_23-20-45

处理

由于问题是Mybatis通过foreach拼接长SQL字符串性能太差导致的,所以解决思路有两种

  1. 通过拆分in的查询条件来减少foreach每次拼接SQL的长度
@Test
public void count2() {
    AtomicInteger count = new AtomicInteger(0);
    for (int threadId = 0; threadId < 50; threadId++) {
        // 起50个线程并发调用countUser()方法
        int finalThreadId = threadId;
        new Thread(() -> {
            List<Long> totalIds = createIds(100000 + finalThreadId);
            long totalUserCount = 0;
            //使用guava对list进行分割,按每1000个一组分割
            List<List<Long>> parts = Lists.partition(totalIds, 1000);
            for (List<Long> ids : parts) {
                totalUserCount += userMapper.countUser(ids);
            }
            log.info("thread:{}, userCount:{}", finalThreadId, totalUserCount);
            count.getAndAdd(1);
        }).start();
    }

    // 等待50个查询线程跑完
    while (count.get() < 50) {
    }
    log.info("end!!!!");
}

这样每次拼接查询SQL的时候只用循环1000次,很快就可以把资源释放掉,就不会引起OOM,但是这种方法还是会生成很多不必要的数据占用内存,频繁触发GC,浪费资源。

  1. 不使用Mybatisforeach来拼接in条件的SQL

既然Mybatisforeach性能不好,那我们通过Java层面自己拼接in条件,特别是针对这种查询条件也比较单一的,更适合自己拼接。

@Test
public void count3() {
    AtomicInteger count = new AtomicInteger(0);
    for (int threadId = 0; threadId < 50; threadId++) {
        // 起50个线程并发调用countUser()方法
        int finalThreadId = threadId;
        new Thread(() -> {
            List<Long> ids = createIds(100000 + finalThreadId);
            StringBuilder sb = new StringBuilder();
            for (long i : ids) {
                sb.append(i).append(",");
            }
            // 查询条件使用String字符串
            long userCount = userMapper.countUserString(sb.toString());
            log.info("thread:{}, userCount:{}", finalThreadId, userCount);
            count.getAndAdd(1);
        }).start();
    }

    // 等待50个查询线程跑完
    while (count.get() < 50) {
    }
    log.info("end!!!!");
}
<select id="countUserString" resultType="long">
    select
    IFNULL(sum(1),0)
    from user where
    <if test="null != ids and ids !=''">
        id in (#{ids})
    </if>
</select>

这样就能大大减少使用foreach而生成的对象,同时减少拼接SQL的时间,避免OOM发生的同时优化性能。

后记

这两次遇到OOM问题解决起来还算比较轻松的,除了后续分析得当以外,也离不开预先的环境配置。在服务JVM参数中增加-XX:+HeapDumpOnOutOfMemoryError-XX:+PrintGCDetails参数,可以在发生OOM的时候输出dump文件,并且能有GC日志查看GC的具体情况,这些都是对于OOM问题非常有帮助的。


原文地址:惨遭DruidDataSource和Mybatis暗算,导致OOM

查看原文

赞 12 收藏 9 评论 4

openmartin 赞了文章 · 2019-08-02

真实项目中 ThreadLocal 的妙用

一、什么是 ThreadLocal

ThreadLocal 提供了线程的局部变量,每个线程都可以通过 set() 和 get() 来对这个局部变量进行操作,但不会和其他线程的局部变量冲突,实现了线程间的据隔离。

简单讲:一个获取用户的请求线程 A,如果向 ThreadLocal 填充变量 AValue(只能被线程 A 操作),该变量对其他获取用户的请求线程 B、C...是隔离的.

最简单的使用方式:

类似一次 HTTP 请求线程中,利用 ThreadLocal 存储 Cookie 对象,进行状态管理。set Cookie:

private ThreadLocal httpThreadLocal = new ThreadLocal();

httpThreadLocal.set(“Cookie: sid=13420771402233”)

上面存储格式是 String ,实际场景存储的是具体的对象。在这次 HTTP 请求过程中,任何时候都可以获取 Cookie 。获取方式很简单 get Cookie:

String cookieValue = (String) httpThreadLocal.get();

56330542aaf5c815e1a027671c21435d.png

Thread 与 ThreadLocal 对象引用关系图

二、你熟悉的场景

2.1 数据库连接池

比如一次请求线程进来,业务 Dao 需要更新 user 表和 user-detail 表。如果是 new 出两个数据库 Connection ,分别不同的 Connection 操作 user 表和 user-detail 表,就无法保证事务。那么数据库连接池是如何保证的?

答案是:利用 ThreadLocal 存储唯一 Connection 对象。每次请求线程,pool.getConnection 获取连接的时候都会这样操作:

  • 会从 ThreadLocal 获取 Connection 对象。如果有,则保证了后面多个数据库操作共用同一个 Connection ,从而保证了事务。
  • 如果没有,往 ThreadLocal 新增Connection 对象,并返回到线程
错误的做法
public class XXXService {

    private Connection conn;
}

因为 conn 是线程不安全的。这样会导致多个请求公用一个连接。请求量很大的情况下,延迟各种。你懂。

因此,使用 ThreadLocal 保证每个请求线程的 Connection 是唯一的。即每个线程有自己的连接。

继续讲到 Spring 框架,在事务开始时,会给当前线程一个Jdbc Connection,在整个事务过程,都是使用该线程绑定的connection来执行数据库操作,实现了事务的隔离性。Spring框架里面就是用的ThreadLocal来实现这种隔离

2.2 HTTP Cookie

比如你访问百度、我访问百度,会有不同 Cookie 。而且你不能访问我的 Cookie,我也不能。顾名思义,使用 ThreadLocal 保证每个 HTTP 请求线程的 Cookie 是唯一的。

Cookie 这样才能做 Session 等状态管理。

三、实战场景

总结一下就是:ThreadLocal 可以让同一个线程中上下文之间数据共享

在上面章节 二、你熟悉的场景 其实介绍了很多现有场景。那么我这边具体的实战场景是什么?

简单的例子:

适用满足这两个条件的场景:1.每个线程独有的一些信息,2.这些信息又会在多个方法或类中用到。

  1. 一个请求线程,里面有两个异步小线程,各有一个方法。分别处理 A 或 B 业务
  2. 一种方法是传递不可变的入参
  3. 另一种就是 ThreadLocal,放在 ThreadLocal 的入参,会被各个方法共享。而且多个请求线程互不影响
复杂的例子:

一次发货操作:会根据入参,进行组件化、流程编排话。那么入参会被各个地方用到,而且有些流程组件是异步的(类似 new thread 操作的)。这时候可以定一个 XXContext 上下文:

public class XXContext {
    
    private static ThreadLocal<Map<Class<?>, Object>> context = new InheritableThreadLocal<>();
    
    /**
     * 把参数设置到上下文的Map中
     */
    public static void put(Object obj) {
        Map<Class<?>, Object> map = context.get();
        if (map == null) {
            map = new HashMap<>();
            context.set(map);
        }
        if (obj instanceof Enum) {
            map.put(obj.getClass().getSuperclass(), obj);
        } else {
            map.put(obj.getClass(), obj);
        }
    }
    
    /**
     * 从上下文中,根据类名取出参数
     */
    @SuppressWarnings("unchecked")
    public static <T> T get(Class<T> c) {
        Map<Class<?>, Object> map = context.get();
        if (map == null) {
            return null;
        }
        return (T) map.get(c);
    }
    
    /**
     * 清空ThreadLocal的数据
     */
    public static void clean() {
        context.remove();
    }
}

代码解析:

  • 都是 static 操作,类似 DateUtil 玩法
  • 记得每次请求线程后清理。可以 AOP 去清理,加个注解就行。因为同一个请求线程可能被业务方公用。

(完)

file

查看原文

赞 11 收藏 9 评论 0

openmartin 发布了文章 · 2019-04-23

Apache Shiro 配置 LDAP 验证

通常在根据LDAP进行身份验证时一般进行以下三步:

  1. 利用一个LDAP用户的用户名和密码绑定到LDAP服务器。
  2. 在LDAP中检索一个用户的条目,然后将提供的密码和检索到的LDAP记录中进行验证。
  3. 根据LDAP提供的记录,再去本系统中查找授权信息。

Shiro 提供了DefaultLdapRealm,只做了第二步,根据用户的条目和密码来验证。并不能满足我们的需求,所以肯定是要定制化LdapRealm。

这里使用Spring Ldap 来简化Ldap操作

public class LdapRealm extends AuthorizingRealm {

    private static final Logger logger = LoggerFactory.getLogger(LdapRealm.class);

    private LdapTemplate ldapTemplate;

    @Autowired
    private UserService userService;

    @Autowired
    private RoleService roleService;

    @Autowired
    private MenuService menuService;

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        String username = (String) token.getPrincipal();
        String password = new String((char[]) token.getCredentials());

        try {
            LdapQuery ldapQuery = LdapQueryBuilder.query().base("DC=example,DC=com").searchScope(SearchScope.SUBTREE)
                    .filter("(sAMAccountName={0})", username);

            boolean authResult = ldapTemplate.authenticate(ldapQuery.base(), ldapQuery.filter().encode(), password);

            if (!authResult) {
                logger.debug("ldap authentication for {} failed", username);
                return null;
            }

            User ldapUser = (User) ldapTemplate.searchForObject(ldapQuery, new LdapUserAttrMapper());

            User user = userService.selectUserById(ldapUser.getUserId());
            if (user == null) {
                // 用户名不存在抛出异常
                throw new UnknownAccountException();
            }
            if (user.getRemoveFlag()) {
                // 用户被管理员锁定抛出异常
                throw new LockedAccountException();
            }

            SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(user, token.getCredentials(),
                    "LdapRealm");
            return authenticationInfo;
        } catch (Exception e) {
            logger.error("ldap authentication failed", e.toString());
            return null;
        }

    }


    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        Long userId = ShiroUtils.getUserId();
        SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
        // 角色加入AuthorizationInfo认证对象
        info.setRoles(roleService.selectRoleKeys(userId));
        // 权限加入AuthorizationInfo认证对象
        info.setStringPermissions(menuService.selectPermsByUserId(userId));
        return info;
    }

    public LdapTemplate getLdapTemplate() {
        return ldapTemplate;
    }

    public void setLdapTemplate(LdapTemplate ldapTemplate) {
        this.ldapTemplate = ldapTemplate;
    }
}

关键的代码如下,验证用户和获取LDAP用户信息

LdapQuery ldapQuery = LdapQueryBuilder.query().base("DC=example,DC=com").searchScope(SearchScope.SUBTREE)
                    .filter("(sAMAccountName={0})", username);
boolean authResult = ldapTemplate.authenticate(ldapQuery.base(), ldapQuery.filter().encode(), password);
User ldapUser = (User) ldapTemplate.searchForObject(ldapQuery, new LdapUserAttrMapper());

Spring 的 ldap 配置如下:

<bean id="ldapContextSource" class="org.springframework.ldap.core.support.LdapContextSource">
    <property name="url" value="ldap://192.168.100.1:3268"/>
    <property name="userDn" value="CN=Reader"/>
    <property name="password" value="secret"/>
</bean>

<bean id="ldapTemplate" class="org.springframework.ldap.core.LdapTemplate">
    <property name="contextSource" ref="ldapContextSource"/>
</bean>

<bean id="ldapRealm" class="com.example.shiro.LdapRealm">
    <property name="ldapTemplate" ref="ldapTemplate"/>
</bean>
查看原文

赞 1 收藏 1 评论 0

openmartin 赞了文章 · 2019-02-20

springboot学习(三)——使用HttpMessageConverter进行http序列化和反序列化

以下内容,如有问题,烦请指出,谢谢!


对象的序列化/反序列化大家应该都比较熟悉:序列化就是将object转化为可以传输的二进制,反序列化就是将二进制转化为程序内部的对象。序列化/反序列化主要体现在程序I/O这个过程中,包括网络I/O和磁盘I/O。

那么什么是http序列化和反序列化呢?

在使用springmvc时,我们经常会这样写:

@RestController
@RequestMapping("/users")
public class UserController {
    @Autowired
    private UserService userService;

    @GetMapping("/{id}")
    public User getUserById(@PathVariable long id) {
        return userService.getUserById(id);
    }

    @PostMapping
    public User createUser(@RequestBody User user) {
        System.err.println("create an user: " + user);
        return user;
    }
}

@RestController中有@ResponseBody,可以帮我们把User序列化到resp.body中。@RequestBody可以帮我们把req.body的内容转化为User对象。如果是开发Web应用,一般这两个注解对应的就是Json序列化和反序列化的操作。这里实际上已经体现了Http序列化/反序列化这个过程,只不过和普通的对象序列化有些不一样,Http序列化/反序列化的层次更高,属于一种Object2Object之间的转换。

有过Netty使用经验的对这个应该比较了解,Netty中的Decoder和Encoder就有两种基本层次,层次低的一种是Byte <---> Message,二进制与程序内部消息对象之间的转换,就是常见的序列化/反序列化;另外一种是 Message <---> Message,程序内部对象之间的转换,比较高层次的序列化/反序列化。

Http协议的处理过程,TCP字节流 <---> HttpRequest/HttpResponse <---> 内部对象,就涉及这两种序列化。在springmvc中第一步已经由Servlet容器(tomcat等等)帮我们处理了,第二步则主要由框架帮我们处理。上面所说的Http序列化/反序列化就是指的这第二个步骤,它是controller层框架的核心功能之一,有了这个功能,就能大大减少代码量,让controller的逻辑更简洁清晰,就像上面示意的代码那样,方法中只有一行代码。

spirngmvc进行第二步操作,也就是Http序列化和反序列化的核心是HttpMessageConverter。用过老版本springmvc的可能有些印象,那时候需要在xml配置文件中注入MappingJackson2HttpMessageConverter这个类型的bean,告诉springmvc我们需要进行Json格式的转换,它就是HttpMessageConverter的一种实现。

springmvc消息转换过程

在Web开发中我们经常使用Json相关框架来进行第二步操作,这是因为Web应用中主要开发语言是js,对Json支持非常好。但是Json也有很大的缺点,大多数Json框架对循环引用支持不够好,并且Json报文体积通常比较大,相比一些二进制序列化更耗费流量。很多移动应用也使用Http进行通信,因为这是在手机app中,Json格式报文并没有什么特别的优势。这种情况下我们可能会需要一些性能更好,体积更小的序列化框架,比如Protobuf等等。

当前的SpringMVC 4.3版本已经集成了Protobuf的Converter,org.springframework.http.converter.protobuf.ProtobufHttpMessageConverter,使用这个类可以进行Protobuf中的Message类和http报文之间的转换。使用方式很简单,先依赖Protobuf相关的jar,代码中直接@Bean就行,像下面这样,springboot会自动注入并添加这种Converter。

    @Bean
    public ProtobufHttpMessageConverter protobufHttpMessageConverter() {
        return new ProtobufHttpMessageConverter();
    }

这里就不演示protobuf相关的内容了。

另外有很重要的一点需要说明一下,springmvc可以同时配置多个Converter,根据一定的规则(主要是Content-Type、Accept、controller方法的consumes/produces、Converter.mediaType以及Converter的排列顺序这四个属性)来选择到底是使用哪一个,这使得springmvc能够一个接口支持多种报文格式。这个规则的具体内容,下一篇再详细说明。


下面重点说下如何自定义一个HttpMessageConverter,就用Java原生序列化为例,叫作JavaSerializationConverter,基本仿照ProtobufHttpMessageConverter来写。

首先继承AbstractHttpMessageConverter,泛型类这里有几种方式可以选择:

  • 最广的可以选择Object,不过Object并不都是可以序列化的,但是可以在覆盖的supports方法中进一步控制,因此选择Object是可以的
  • 最符合的是Serializable,既完美满足泛型定义,本身也是个Java序列化/反序列化的充要条件
  • 自定义的基类Bean,有些技术规范要求自己代码中的所有bean都继承自同一个自定义的基类BaseBean,这样可以在Serializable的基础上再进一步控制,满足自己的业务要求

这里选择Serializable作为泛型基类。

其次是选择一个MediaType,使得springmvc能够根据Accept和Content-Type唯一确定是要使用JavaSerializationConverter,所以这个MediaType不能是通用的text/plain、application/json、*/*这种,得特殊一点,这里就用application/x-java-serialization;charset=UTF-8。因为Java序列化是二进制数据,charset不是必须的,但是MediaType的构造方法中需要指定一个charset,这里就用UTF-8。

最后,二进制在电脑上不是可以直接拷贝的内容,为了方便测试,使用Base64再处理一遍,这样就显示成正常文本了,便于测试。

整个代码如下:

package pr.study.springboot.configure.mvc.converter;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.nio.charset.Charset;
import java.util.Base64;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.AbstractHttpMessageConverter;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.util.StreamUtils;

public class JavaSerializationConverter extends AbstractHttpMessageConverter<Serializable> {
    private Logger LOGGER = LoggerFactory.getLogger(JavaSerializationConverter.class);

    public JavaSerializationConverter() {
        // 构造方法中指明consumes(req)和produces(resp)的类型,指明这个类型才会使用这个converter
        super(new MediaType("application", "x-java-serialization", Charset.forName("UTF-8")));
    }

    @Override
    protected boolean supports(Class<?> clazz) {
        // 使用Serializable,这里可以直接返回true
        // 使用object,这里还要加上Serializable接口实现类判断
        // 根据自己的业务需求加上其他判断
        return true;
    }

    @Override
    protected Serializable readInternal(Class<? extends Serializable> clazz, HttpInputMessage inputMessage)
            throws IOException, HttpMessageNotReadableException {
        byte[] bytes = StreamUtils.copyToByteArray(inputMessage.getBody());
        // base64使得二进制数据可视化,便于测试
        ByteArrayInputStream bytesInput = new ByteArrayInputStream(Base64.getDecoder().decode(bytes));
        ObjectInputStream objectInput = new ObjectInputStream(bytesInput);
        try {
            return (Serializable) objectInput.readObject();
        } catch (ClassNotFoundException e) {
            LOGGER.error("exception when java deserialize, the input is:{}", new String(bytes, "UTF-8"), e);
            return null;
        }
    }

    @Override
    protected void writeInternal(Serializable t, HttpOutputMessage outputMessage)
            throws IOException, HttpMessageNotWritableException {
        ByteArrayOutputStream bytesOutput = new ByteArrayOutputStream();
        ObjectOutputStream objectOutput = new ObjectOutputStream(bytesOutput);
        objectOutput.writeObject(t);
        // base64使得二进制数据可视化,便于测试
        outputMessage.getBody().write(Base64.getEncoder().encode(bytesOutput.toByteArray()));
    }

}

添加一个converter的方式有三种,代码以及说明如下:

    // 添加converter的第一种方式,代码很简单,也是推荐的方式
    // 这样做springboot会把我们自定义的converter放在顺序上的最高优先级(List的头部)
    // 即有多个converter都满足Accpet/ContentType/MediaType的规则时,优先使用我们这个
    @Bean
    public JavaSerializationConverter javaSerializationConverter() {
        return new JavaSerializationConverter();
    }

    // 添加converter的第二种方式
    // 通常在只有一个自定义WebMvcConfigurerAdapter时,会把这个方法里面添加的converter(s)依次放在最高优先级(List的头部)
    // 虽然第一种方式的代码先执行,但是bean的添加比这种方式晚,所以方式二的优先级 大于 方式一
    @Override
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
        // add方法可以指定顺序,有多个自定义的WebMvcConfigurerAdapter时,可以改变相互之间的顺序
        // 但是都在springmvc内置的converter前面
        converters.add(new JavaSerializationConverter());
    }

    // 添加converter的第三种方式
    // 同一个WebMvcConfigurerAdapter中的configureMessageConverters方法先于extendMessageConverters方法执行
    // 可以理解为是三种方式中最后执行的一种,不过这里可以通过add指定顺序来调整优先级,也可以使用remove/clear来删除converter,功能强大
    // 使用converters.add(xxx)会放在最低优先级(List的尾部)
    // 使用converters.add(0,xxx)会放在最高优先级(List的头部)
    @Override
    public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        converters.add(new JavaSerializationConverter());
    }

使用下面的数据演示:

// java序列化
rO0ABXNyAB1wci5zdHVkeS5zcHJpbmdib290LmJlYW4uVXNlcrt1879rvWjlAgAESgACaWRMAApjcmVhdGVUaW1ldAAQTGphdmEvdXRpbC9EYXRlO0wABWVtYWlsdAASTGphdmEvbGFuZy9TdHJpbmc7TAAEbmFtZXEAfgACeHIAIXByLnN0dWR5LnNwcmluZ2Jvb3QuYmVhbi5CYXNlQmVhbklx6Fsr8RKpAgAAeHAAAAAAAAAAe3NyAA5qYXZhLnV0aWwuRGF0ZWhqgQFLWXQZAwAAeHB3CAAAAWCs8ufxeHQAEGhlbGxvd29ybGRAZy5jb210AApoZWxsb3dvcmxk

// json
{"id":123,"name":"helloworld","email":"helloworld@g.com","createTime":"2017-12-31 22:21:28"}

// 对应的user.toString()
User[id=123, name=helloworld, email=helloworld@g.com, createTime=Sun Dec 31 22:21:28 CST 2017]

演示结果如下,包含了一个接口多种报文格式支持的演示:

1、请求是 GET + Accept: application/x-java-serialization,返回的是 Content-Type: application/x-java-serialization;charset=UTF-8 的Java序列化格式的报文

2、请求是 GET + Accept: application/json,返回的是 Content-Type: application/json;charset=UTF-8 的json格式报文

3、请求是 POST + Accept: application/x-java-serialization + Content-Type: application/x-java-serialization,返回的是 Content-Type: application/x-java-serialization;charset=UTF-8的Java序列化格式的报文

4、请求是 POST + Accept: application/json + Content-Type: application/x-java-serialization,返回的是 Content-Type: application/json;charset=UTF-8 的json格式报文

5、请求是 POST + Accept: application/json + Content-Type: application/json,返回的是 Content-Type: application/json;charset=UTF-8 的json格式报文

6、请求是 POST + Accept: application/x-java-serialization + Content-Type: application/json,返回的是 Content-Type: application/x-java-serialization;charset=UTF-8的Java序列化格式的报文


下面再说些其他的有关Http序列化/反序列化的内容.
1、jackson配置
使用Jackson时,一般我们都会配置下ObjectMapper,常见的两个是时间序列化格式,以及是否序列化null值。使用springboot时,因为Jackson是内置加载的,那么如何配置我们想要的的Jackson属性呢?最贱的的方式,那就是自己注入一个ObjectMapper实例,这样spring内所有通过依赖注入使用ObjectMapper的地方,都会优先使用我们自己注入的那个,JacksonConverter也不例外。

/**
 * jackson的核心是ObjectMapper,在这里配置ObjectMapper来控制springboot使用的jackson的某些功能
 */
@Configuration
public class MyObjectMpper {

    @Bean
    public ObjectMapper getObjectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.setSerializationInclusion(Include.NON_NULL); // 不序列化null的属性
        mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); // 默认的时间序列化格式
        return mapper;
    }
}

2、控制Json中某些属性的序列化方式
官方文档中说了个Custom JSON Serializers and Deserializers,我也没想到怎么用这个,后来网上发现了个比较好的例子,说的是rgb颜色的序列化。web页面需要的是css格式的rgb颜色,服务的提供的可能是三个独立的byte型数字,这时候就需要改变颜色属性的json序列化/反序列化方式。具体可以看看这里

3、FastJson配置
可能某些时候需要使用FastJson,这时候该如何配置呢?基本上和springmvc xml配置差不多,注入一个FastJsonHttpMessageConverter就行了。最简单的就是上面的配置JavaSerializationConverter的方式一,方式二和方式三也都行。
不过会有奇怪的问题出现(使用@JSONField(serialize=false, deserialize=false)注解createTime,用以区分FastJson和Jackson):
假如你把FastJson配置为优先级最高的,并且同时配置上JavaSerializationConverter,你会发现JavaSerializationConverter不管用了,请求是 GET + Accept: application/x-java-serialization,返回是 Content-Type: application/x-java-serialization;charset=UTF-8;,但是实际内容是json格式的,如下。

假如你把FastJson配置为优先级最低的,别的不管,你以为得到会是Jackson序列化后的结果。但实际上,你用浏览器直接敲得到的是FastJson的,用上面的 GET 的 fiddler结果是jackson的;

详细原因在下一篇讲解converter匹配规则时说。

这里说下原因中重要且值得吐槽的一点,那就是FastJsonHttpMessageConverter默认注册的MediaType的 */*,然后就有了上面的 请求是 GET + Accept: application/x-java-serialization,返回是 Content-Type: application/x-java-serialization;charset=UTF-8;,但是实际内容是json格式的,这种挂羊头卖狗肉的行为,明着违反HTTP协议的规范。

这个代码设计真是差,json框架就该只管json,这样霸道,什么格式都要管,为哪般!?


相关代码:
https://gitee.com/page12/stud...
https://github.com/page12/stu...

查看原文

赞 7 收藏 6 评论 0

openmartin 发布了文章 · 2018-09-06

Office Online Server 安装和集成

office 文件的在线预览和编辑一直是一个难题,但是我们在很多管理系统中都需要用到这个功能,至少是预览功能,通常的解决方案是把所有的文件转成 PDF,而 PDF 可以很方便在 Web 上查看。

2010 年微软推出了 Office Web Apps,可在线编辑和查看 office 文件,2016 年改名为 Office Online Server。

想体验一下可以试试 Office 免费在线版(不需购买 Office 365 就可以使用) https://www.office.com/?auth=1

功能和安装在计算机上的 Office 软件功能还是弱一些,如果只是日常的一些文字的输入排版,没用到高级功能的话,Web 版的 Office 完全够用。

Office Online Server 一般是在公司内部部署,可以在内部管理系统中集成在线编辑和查看 Office 文件的功能。

下载安装文件

官方的下载地址需要 license,我们只能从其他渠道下载安装包,msdn itellyou

Office Online Server 语言包

部署 Office Online Server

一般是需要两台服务器,一台域控制器,一台部署 Office Online Server

官方部署步骤 部署 Office Online Server

安装好系统后,千万别更新,千万别更新,千万别更新。
安装好系统后,千万别更新,千万别更新,千万别更新。
安装好系统后,千万别更新,千万别更新,千万别更新。

创建 Office Online Server 场

New-OfficeWebAppsFarm -InternalUrl "http://servername" --ExternalUrl "http://192.168.100.1" -AllowHttp -EditingEnabled

测试

安装好之后,有一个测试页面可以测试一下,
http://[office.domain]/op/generate.aspx
可以输入共享文件的路径测试一下

所有支持的文件格式以及对应的编辑和查看的URL
http://[office.domain]/hosting/discovery

WOPI Host 集成

安装好之后需要在内部管理系统中集成 Office 文件在线编辑和查看的功能,需要在自己开发的系统中支持WOPI协议,可以参考github上的wopihost

遇到的问题,Word编辑页面可以打开,查看页面打不开,去掉 CheckFileInfo 返回的 SHA256 就可以。

参考资料

  1. https://bbs.seafile.com/t/top...
  2. https://wopi.readthedocs.io/
  3. https://github.com/ethendev/w...
  4. https://blog.csdn.net/steveyg...
查看原文

赞 1 收藏 1 评论 0

openmartin 赞了文章 · 2018-08-16

基于 spring-session 解决分布式 session 共享问题

摘要:本文主要研究 基于 spring-seesion 解决分布式 session 的共享问题。首先讲述 session 共享问题的产生背景以及常见的解决方案;然后讲解本文主要研究的 spring-session 的概念和功能;接着演示了 spring-session 的两种管理 sessionid 的实现方案,属于实战内容,需重点掌握;再接着对后台保存数据到 redis 上的数据结构进行了分析;然后对 spring-session 的核心源代码进行了解读,方便理解 spring-session 框架的实现原理;最后列举了在使用 spring-session 的实践过程中可能遇到的问题或坑,重点去理解一下。

直奔主题

HttpSession 是通过 Servlet 容器创建和管理的,像 Tomcat/Jetty 都是保存在内存中的。而如果我们把 web 服务器搭建成分布式的集群,然后利用 LVS 或 Nginx 做负载均衡,那么来自同一用户的 Http 请求将有可能被分发到两个不同的 web 站点中去。那么问题就来了,如何保证不同的 web 站点能够共享同一份 session 数据呢?

最简单的想法将 session 管理从容器中独立出来。而实现方案有很多种,下面简单介绍下:

  • 第一种是使用容器扩展来实现,大家比较容易接受的是通过容器插件来实现,比如基于 Tomcat 的 tomcat-redis-session-manager ,基于 Jetty 的 jetty-session-redis 等等。好处是对项目来说是透明的,无需改动代码。不过前者目前还不支持 Tomcat 8 ,或者说不太完善。但是由于过于依赖容器,一旦容器升级或者更换意味着又得从新来过。并且代码不在项目中,对开发者来说维护也是个问题。
  • 第二种是自己写一套会话管理的工具类,包括 Session 管理和 Cookie 管理,在需要使用会话的时候都从自己的工具类中获取,而工具类后端存储可以放到 Redis 中。很显然这个方案灵活性最大,但开发需要一些额外的时间。并且系统中存在两套 Session 方案,很容易弄错而导致取不到数据。
  • 第三种是使用框架的会话管理工具,也就是如下介绍的 spring-session ,可以理解是替换了 Servlet 那一套会话管理,接管创建和管理 Session 数据的工作。既不依赖容器,又不需要改动代码,并且是用了 spring-data-redis 那一套连接池,可以说是最完美的解决方案。

解决方案之 spring-session

介绍

Spring Session 是 Spring 的项目之一,GitHub地址:https://github.com/spring-pro...

Spring Session 提供了一套创建和管理 Servlet HttpSession 的完美方案。

功能

spring Session 提供了 API 和实现,用于管理用户的 Session 信息。除此之外,它还提供了如下特性:

  • 将 session 所保存的状态卸载到特定的外部 session 存储汇总,如 Redis 中,他们能够以独立于应用服务器的方式提供高质量的集群。
  • 控制 sessionid 如何在客户端和服务器之间进行交换,这样的话就能很容易地编写 Restful API ,因为它可以从 HTTP 头信息中获取 sessionid ,而不必再依赖于 cookie。
  • 在非 Web 请求的处理代码中,能够访问 session 数据,比如在 JMS 消息的处理代码中。
  • 支持每个浏览器上使用多个 session,从而能够很容易地构建更加丰富的终端用户体验。
  • 当用户使用 WebSocket 发送请求的时候,能够保持 HttpSession 处于活跃状态。

方案一由 cookie 管理 sessionid

在 maven 中添加如下依赖

<!-- spring-session-data-redis 是一个空的包,仅仅只有一个 META-INF 文件夹。它的作用就在于引入如下四个          
包 spring-data-redis,jedis,spring-session,commons-pool2
-->
  <dependency>
      <groupId>org.springframework.session</groupId>
      <artifactId>spring-session-data-redis</artifactId>
      <version>1.0.1.RELEASE</version>
  </dependency>
  <dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-redis</artifactId>
    <version>1.6.1.RELEASE</version>
  </dependency>
  <dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.5.2</version>
  </dependency> 
  <dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session</artifactId>
    <version>1.1.0.RELEASE</version>
  </dependency>
  <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
    <version>2.2</version>
  </dependency>

在 spring 配置文件中添加如下配置

<!-- redis 的 bean 配置如下 -->
<bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig"/>

<bean id="jedisConnectionFactory" class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory">
    <property name="hostName" value="127.0.0.1" />
    <property name="port" value="6379" />
    <property name="password" value="" />
    <property name="timeout" value="3600" />
    <property name="poolConfig" ref="jedisPoolConfig" />
    <property name="usePool" value="true" />
    <property name="database" value="0"/> <!-- 默认存放在0号库中 -->
</bean>

<bean id="redisTemplate" class="org.springframework.data.redis.core.StringRedisTemplate">
    <property name="connectionFactory" ref="jedisConnectionFactory" />
</bean>

<!-- 将 session 放入 redis, spring-session 会使用此 bean -->
<bean id="redisHttpSessionConfiguration"                     class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration">                                                                    
    <property name="maxInactiveIntervalInSeconds" value="1800" />
</bean>

​这里前面几个 bean 都是操作 redis 时候使用的,最后一个 bean 才是 spring-session 需要用到的,其中的 id 可以不写或者保持不变,这也是一个约定优先配置的体现。这个 bean 中又会自动产生多个 bean ,用于相关操作,极大的简化了我们的配置项。其中有个比较重要的是 springSessionRepositoryFilter ,它将在下面的代理 filter 中被调用到。maxInactiveIntervalInSeconds 表示超时时间,默认是 1800 秒。上述配置可以采用 xml 来定义,官方文档中有采用注解来声明一个配置类。

在 web.xml 中配置过滤器

  接下来在 web.xml 中添加一个 session 代理 filter ,通过这个 filter 来包装 Servlet 的 getSession() 。需要注意的是这个 filter 需要放在所有 filter 链最前面,从而保证完全替换掉 tomcat 的 session。这个是约定。

<!-- delegatingFilterProxy -->
<filter>
    <filter-name>springSessionRepositoryFilter</filter-name>
    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
    <filter-name>springSessionRepositoryFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

验证

第一步:编写 Controller 代码

@RequestMapping(value = "user", method = RequestMethod.POST)
public void setUser(HttpSession session) {
    User user = new User();
    user.setName("lyf");
    user.setPassword("123");
    session.setAttribute("user", user);
}
@RequestMapping(value = "user", method = RequestMethod.GET)
public String getUser(HttpSession session) {
    User user = (User) session.getAttribute("user");
    String name = user.getName();
    return "用户名称:" + name;
}

第二步:浏览器中访问 Controller

  • post请求:localhost:8080/training/user

响应头部如下:Response Headers:

Set-Cookie:SESSION=a2c10601-3204-454e-b545-85e84f587045; Path=/training/; HttpOnly
...

会发现浏览器 Cookie 中的 jsessionid 已经替换为 session**

此时使用 redis-cli 到 redis 库中查询如下:

springsession:0>keys *
1) spring:session:sessions:a2c10601-3204-454e-b545-85e84f587045
2) spring:session:expirations:1502595600000
  • get请求:localhost:8080/training/user

请求头部如下:Request Headers:

Cookie:SESSION=a2c10601-3204-454e-b545-85e84f587045;

服务器通过 Cookie 中的 session 识别码从 redis 库中找到了需要的 session 对象并返回,浏览器显示如下:

用户名称:lyf
  • 总结

    通过如上 spring-session 配置即可将其集成到项目中,之后使用的所有有关 session 的操作,都会由 spring-session 来接管创建和信息存取。官方默认 spring-session 中的 session 信息都保存在 redis 数据库中。

    此实现方式弊端:如果浏览器禁用掉了 cookie 或者是非 web 请求时根本没有 cookie 的时候,那么如上通过cookie 管理 sessionid 的实现方式将不能够实现 session 共享。

方案二由 httpheader 管理 sessionid

在 maven 中添加如下依赖

同3.1

在 spring 配置文件中添加如下配置

<!-- redis 的 bean 配置如下 -->
<bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig"/>

<!-- 替代默认使用 cookie ,这里使用的是 httpheader -->
<bean id="httpSessonStrategy"          class="org.springframework.session.web.http.HeaderHttpSessionStrategy"/>

<bean id="jedisConnectionFactory" class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory">
      <property name="hostName" value="127.0.0.1" />
      <property name="port" value="6379" />
      <property name="password" value="" />
      <property name="timeout" value="3600" />
      <property name="poolConfig" ref="jedisPoolConfig" />
      <property name="usePool" value="true" />
</bean>

<bean id="redisTemplate" class="org.springframework.data.redis.core.StringRedisTemplate">
       <property name="connectionFactory" ref="jedisConnectionFactory" />
</bean>

<!-- 将 session 放入 redis -->
<bean id="redisHttpSessionConfiguration" class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration">
       <property name="maxInactiveIntervalInSeconds" value="1800" />
       <property name="httpSessionStrategy" ref="httpSessonStrategy"/>
</bean>

在 web.xml 中配置过滤器

同3.3

验证

第一步:编写 Controller代码

@RequestMapping(value = "user", method = RequestMethod.POST)
public void setUser(HttpSession session) {
    User user = new User();
    user.setName("lyf");
    user.setPassword("123");
    session.setAttribute("user", user);
}
@RequestMapping(value = "user", method = RequestMethod.GET)
public String getUser(HttpSession session) {
    User user = (User) session.getAttribute("user");
    String name = user.getName();
    return "用户名称:" + name;
}

第二步:浏览器中访问 Controller

  • post 请求:localhost:8080/training/user

响应头部如下:Response Headers:

x-auth-token:256064c7-b583-460f-bbd2-1f6dab3fd418
...

区别 Cookie 的地方在于,这种方式在响应头信息中添加了唯一标识字段 x-auth-token

此时使用 redis-cli 到 redis 库中查询如下:

springsession:0>keys *
1) spring:session:expirations:1502597280000
2) spring:session:sessions:256064c7-b583-460f-bbd2-1f6dab3fd418
  • get 请求:localhost:8080/training/user

    请求头部如下:Response Headers:

    x-auth-token:00ee4b6a-0aeb-42b1-a2bd-eae6f370c677

会发现此时在响应头信息中又重新创建了一个 x-auth-token ,因为 spring-seesion 的底层实现是在请求的时候服务端如果没有拿到这个唯一标识,就会重新创建一个新的 x-auth-token,
并保存到 redis 库中。

此时使用 redis-cli 到 redis 库中查询如下:

springsession:0>keys *
1) spring:session:sessions:00ee4b6a-0aeb-42b1-a2bd-eae6f370c677
2) spring:session:expirations:1502597280000
3) spring:session:sessions:256064c7-b583-460f-bbd2-1f6dab3fd418
4) spring:session:expirations:1502597460000
  • 总结

因此要想获取到 session 中的用户信息,需要将服务端返回的 x-auth-token 唯一标识符附加到 Headers上,然后服务器根据这个唯一标识符才能找到对应的用户信息
在此过程的 get 请求的 Headers 中添加如下键值对:

  x-auth-token:256064c7-b583-460f-bbd2-1f6dab3fd418

服务器通过 Headers 中的 x-auth-token 从 redis 库中找到了需要的 session 对象并返回,浏览器显示如下:

用户名称:lyf

因此:

Spring-session 可以控制客户端和服务器端之间如何进行 sessionid 的交换,这样更加易于编写 Restful API,因为它可以从 HTTP 头信息中获取 sessionid ,而不必再依赖于 cookie 。

spring-session redis 数据结构

创建 spring session

RedisSession 在创建时设置 3 个变量 creationTime ,maxInactiveInterval ,lastAccessedTime 。maxInactiveInterval 默认值为 1800 ,表示 1800s 之内该 session 没有被再次使用,则表明该 session 已过期。每次 session 被访问都会更新 lastAccessedTime 的值, session 的过期计算公式:当前时间-lastAccessedTime > maxInactiveInterval.

/**
* Creates a new instance ensuring to mark all of the new attributes to be
* persisted in the next save operation.
**/
RedisSession() {
    this(new MapSession());
    this.delta.put(CREATION_TIME_ATTR, getCreationTime());
    this.delta.put(MAX_INACTIVE_ATTR, getMaxInactiveIntervalInSeconds());
    this.delta.put(LAST_ACCESSED_ATTR, getLastAccessedTime());
    this.isNew = true;
    this.flushImmediateIfNecessary();
}
public MapSession() {
    this(UUID.randomUUID().toString());
}

flushImmediateIfNecessary 判断 session 是否需要立即写入后端存储。

获取 session

spring session在 redis 里面保存的数据包括:

  • SET 类型的spring:session:expireations:[min]

    min 表示从 1970 年 1 月 1 日 0 点 0 分经过的分钟数, SET 集合的 member 为 expires:[sessionId] ,表示 members 会在 min 分钟后过期。

  • String 类型的spring:session:sessions:expires:[sessionId]

    该数据的 TTL 表示 sessionId 过期的剩余时间,即 maxInactiveInterval。

  • Hash 类型的spring:session:sessions:[sessionId]

    session 保存的数据,记录了 creationTime,maxInactiveInterval,lastAccessedTime,attribute。前两个数据是用于 session 过期管理的辅助数据结构。

获取 session 流程:

​ 应用通过 getSession(boolean create) 方法来获取 session 数据,参数 create 表示 session 不存在时是否创建新的 session 。 getSession 方法首先从请求的 “.CURRENT_SESSION” 属性来获取 currentSession ,没有 currentSession ,则从 request 取出 sessionId ,然后读取 spring:session:sessions:[sessionId] 的值,同时根据 lastAccessedTime 和 MaxInactiveIntervalInSeconds 来判断这个 session 是否过期。如果 request 中没有 sessionId ,说明该用户是第一次访问,会根据不同的实现,如 RedisSession ,MongoExpiringSession ,GemFireSession 等来创建一个新的 session 。

​ 另外, 从 request 取 sessionId 依赖具体的 HttpSessionStrategy 的实现,spring session 给了两个默认的实现 CookieHttpSessionStrategy 和 HeaderHttpSessionStrategy ,即从 cookie 和 header 中取出 sessionId 。

具体的代码实现在第 4 章已经演示了。

session 有效期与删除

spring session 的有效期指的是访问有效期,每一次访问都会更新 lastAccessedTime 的值,过期时间为lastAccessedTime + maxInactiveInterval ,也即在有效期内每访问一次,有效期就向后延长 maxInactiveInterval。

对于过期数据,一般有三种删除策略:

1)定时删除,即在设置键的过期时间的同时,创建一个定时器, 当键的过期时间到来时,立即删除。

2)惰性删除,即在访问键的时候,判断键是否过期,过期则删除,否则返回该键值。

3)定期删除,即每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定。

​ redis 删除过期数据采用的是懒性删除+定期删除组合策略,也就是数据过期了并不会及时被删除。为了实现 session 过期的及时性,spring session 采用了定时删除的策略,但它并不是如上描述在设置键的同时设置定时器,而是采用固定频率(1分钟)轮询删除过期值,这里的删除是惰性删除

​ 轮询操作并没有去扫描所有的 spring:session:sessions:[sessionId] 的过期时间,而是在当前分钟数检查前一分钟应该过期的数据,即 spring:session:expirations:[min] 的 members ,然后 delete 掉 spring:session:expirations:[min] ,惰性删除 spring:session:sessions:expires:[sessionId] 。

​ 还有一点是,查看三个数据结构的TTL时间,spring:session:sessions:[sessionId] 和 spring:session:expirations:[min] 比真正的有效期大 5 分钟,目的是确保当 expire key 数据过期后,监听事件还能获取到 session 保存的原始数据。

@Scheduled(cron = "${spring.session.cleanup.cron.expression:0 * * * * *}")
public void cleanupExpiredSessions() {
    this.expirationPolicy.cleanExpiredSessions();
}
public void cleanExpiredSessions() {
    long now = System.currentTimeMillis();
    long prevMin = roundDownMinute(now);
    // preMin 时间到,将 spring:session:expirations:[min], 
    // set 集合中 members 包括了这一分钟之内需要过期的所有
    // expire key 删掉, member 元素为 expires:[sessionId]
    String expirationKey = getExpirationKey(prevMin);
    Set<Object> sessionsToExpire = this.redis.boundSetOps(expirationKey).members();
    this.redis.delete(expirationKey);
    for (Object session : sessionsToExpire) {
        // sessionKey 为 spring:session:sessions:expires:[sessionId]
        String sessionKey = getSessionKey((String) session);
        // 利用 redis 的惰性删除策略
        touch(sessionKey);
    }
}

​ spring session 在 redis 中保存了三个 key ,为什么? sessions key 记录 session 本身的数据,expires key标记 session 的准确过期时间,expiration key 保证 session 能够被及时删除,spring 监听事件能够被及时处理。

上面的代码展示了 session expires key 如何被删除,那 session 每次都是怎样更新过期时间的呢? 每一次 http 请求,在经过所有的 filter 处理过后,spring session 都会通过 onExpirationUpdated() 方法来更新 session 的过期时间, 具体的操作看下面源码的注释。

public void onExpirationUpdated(Long originalExpirationTimeInMilli,
            ExpiringSession session) {
    String keyToExpire = "expires:" + session.getId();
    long toExpire = roundUpToNextMinute(expiresInMillis(session));
    if (originalExpirationTimeInMilli != null) {
        long originalRoundedUp = roundUpToNextMinute(originalExpirationTimeInMilli);
        // 更新 expirations:[min] ,两个分钟数之内都有这个 session ,将前一个 set 中的成员删除
        if (toExpire != originalRoundedUp) {
            String expireKey = getExpirationKey(originalRoundedUp);
            this.redis.boundSetOps(expireKey).remove(keyToExpire);
        }
    }
    long sessionExpireInSeconds = session.getMaxInactiveIntervalInSeconds();
    String sessionKey = getSessionKey(keyToExpire);
    if (sessionExpireInSeconds < 0) {
        this.redis.boundValueOps(sessionKey).append("");
        this.redis.boundValueOps(sessionKey).persist();
        this.redis.boundHashOps(getSessionKey(session.getId())).persist();
        return;
    }
    String expireKey = getExpirationKey(toExpire);
    BoundSetOperations<Object, Object> expireOperations = this.redis
            .boundSetOps(expireKey);
    expireOperations.add(keyToExpire);
    long fiveMinutesAfterExpires = sessionExpireInSeconds
            + TimeUnit.MINUTES.toSeconds(5);
    // expirations:[min] key 的过期时间加 5 分钟
    expireOperations.expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);
    if (sessionExpireInSeconds == 0) {
        this.redis.delete(sessionKey);
    }
    else {
        // expires:[sessionId] 值为“”,过期时间为 MaxInactiveIntervalInSeconds
        this.redis.boundValueOps(sessionKey).append("");
        this.redis.boundValueOps(sessionKey).expire(sessionExpireInSeconds,
                TimeUnit.SECONDS);
    }
    // sessions:[sessionId] 的过期时间加 5 分钟
    this.redis.boundHashOps(getSessionKey(session.getId()))
            .expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);
}

源码解读

源码架构分析

使用 spring-session 需要解决两个核心问题:

问题一:如何创建集群环境下高可用的 session,要求能够可靠并高效地存储数据

解决:在高可用可扩展的集群中存储数据已经通过各种数据存储方案得到了解决,如 Redis、GemFire 以及 Apache Geode 等等

问题二:如何保证不管请求是 HTTP、WebSocket 等其他协议,服务端都能够获取到 sessionid 来找到对应的资源

解决:Spring Session 认为将请求与特定的 session 实例关联起来的问题是与协议相关的,因为在请求/响应周期中,客户端和服务器之间需要协商同意一种传递 sessionid 的方式。例如,如果请求是通过 HTTP 传递进来的,那么 session 可以通过 HTTP cookie 或 HTTP Header 信息与请求进行关联。如果使用 HTTPS 的话,那么可以借助SSL sessionid 实现请求与 session 的关联。如果使用 JMS 的话,那么 JMS 的 Header 信息能够用来存储请求和响应之间的 sessionid 。

HTTP 支持

Spring Session 对 HTTP 的支持是通过标准的 servlet filter 来实现的,这个 filter 必须要配置为拦截所有的 web 应用请求,并且它应该是 filter 链中的第一个 filter 。Spring Session filter 会确保随后调用javax.servlet.http.HttpServletRequestgetSession()方法时,都会返回 Spring Session 的HttpSession实例,而不是应用服务器默认的 HttpSession。

首先,我们了解一下标准 servlet 扩展点的一些背景知识:

在2001年,Servlet 2.3规范引入了ServletRequestWrapper。官方API中解释,ServletRequestWrapper“提供了ServletRequest接口的便利实现,开发人员如果希望将请求适配到 Servlet 的话,可以编写它的子类。这个类实现了包装(Wrapper)或者说是装饰(Decorator)模式。对方法的调用默认会通过包装的请求对象来执行”。如下的代码样例抽取自 Tomcat,展现了 ServletRequestWrapper 是如何实现的。

public class ServletRequestWrapper implements ServletRequest {

    private ServletRequest request;

    /**
     * 创建 ServletRequest 适配器,它包装了给定的请求对象。
     */
    public ServletRequestWrapper(ServletRequest request)  {
        if (request == null) {
            throw new IllegalArgumentException("Request cannot be null");   
        }
        this.request = request;
    }

    public ServletRequest getRequest() {
        return this.request;
    }
    
    public Object getAttribute(String name) {
        return this.request.getAttribute(name);
    } 
}

Servlet 2.3 规范还定义了HttpServletRequestWrapper,它是ServletRequestWrapper的子类,能够快速提供HttpServletRequest的自定义实现,如下的代码是从 Tomcat 抽取出来的,展现了HttpServletRequesWrapper类是如何运行的。

public class HttpServletRequestWrapper extends ServletRequestWrapper 
    implements HttpServletRequest {

    public HttpServletRequestWrapper(HttpServletRequest request) {
        super(request);
    }
    
    private HttpServletRequest _getHttpServletRequest() {
        return (HttpServletRequest) super.getRequest();
    }
  
    public HttpSession getSession(boolean create) {
     return this._getHttpServletRequest().getSession(create);
    }
   
    public HttpSession getSession() {
      return this._getHttpServletRequest().getSession();
    } 
}

所以,借助这些包装类就能编写代码来扩展HttpServletRequest,重载返回HttpSession的方法,让它返回由外部存储所提供的实现。如下的代码是从 Spring Session 项目中提取出来的。

/*
 * 注意,Spring Session 项目定义了扩展自
 * 标准 HttpServletRequestWrapper 的类,用来重载
 * HttpServletRequestWrapper 中与 session 相关的方法。
 */
private final class SessionRepositoryRequestWrapper
   extends HttpServletRequestWrapper {

   private HttpSessionWrapper currentSession;
   private Boolean requestedSessionIdValid;
   private boolean requestedSessionInvalidated;
   private final HttpServletResponse response;
   private final ServletContext servletContext;

   /*
   * 注意,这个构造器非常简单,它接收稍后会用到的参数,
   * 并且委托给它所扩展的 HttpServletRequestWrapper
   */
   private SessionRepositoryRequestWrapper(
      HttpServletRequest request,
      HttpServletResponse response,
      ServletContext servletContext) {
     super(request);
     this.response = response;
     this.servletContext = servletContext;
   }

   /*
   * 在这里,Spring Session 项目不再将调用委托给
   * 应用服务器,而是实现自己的逻辑,
   * 返回由外部数据存储作为支撑的 HttpSession 实例。
   *
   * @Param create 参数表示 session 不存在时是否创建新的 session
   */
   @Override
   public HttpSession getSession(boolean create) {
     // 检查是否存在 session ,如果存在,则直接返回
     if(currentSession != null) {
       return currentSession;
     }
     // 检查当前的请求中是否存在 sessionid
     String requestedSessionId = getRequestedSessionId();
     if(requestedSessionId != null) {
       // 如果存在 sessionid ,将会根据这个 sessionid,从它的 SessionRepository 中加载 session
       S session = sessionRepository.getSession(requestedSessionId);
       if(session != null) {
         // 封装 session 并返回
         this.requestedSessionIdValid = true;
         currentSession = new HttpSessionWrapper(session, getServletContext());
         currentSession.setNew(false);
         return currentSession;
       }
     }
     if(!create) {
       return null;
     }
     // session repository 中没有 session ,并且在当前请求中也没有与之关联的 sessoinid,
     // 那么就创建一个新的 session ,并将其持久化到 session repository 中
     S session = sessionRepository.createSession();
     currentSession = new HttpSessionWrapper(session, getServletContext());
     return currentSession;
   }

   @Override
   public HttpSession getSession() {
     return getSession(true);
   }
}

Spring Session 定义了SessionRepositoryFilter,它实现了 Servlet Filter接口。如下是抽取了这个 filter的关键部分

/*
 * SessionRepositoryFilter 只是一个标准的 ServletFilter,
 * 它的实现扩展了一个 helper 基类。
 */
public class SessionRepositoryFilter < S extends ExpiringSession >
    extends OncePerRequestFilter {

    /*
     * 这个方法是魔力真正发挥作用的地方。这个方法相当于重写了doFilter,
     * 创建了我们上文所述的封装请求对象和
     * 一个封装的响应对象,然后调用其余的 filter 链。
     * 这里,关键在于当这个 filter 后面的应用代码执行时,
     * 如果要获得 session 的话,得到的将会是 Spring Session 的
     * HttpServletSession 实例,它是由后端的外部数据存储作为支撑的。
     */
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {

        request.setAttribute(SESSION_REPOSITORY_ATTR, sessionRepository);

        SessionRepositoryRequestWrapper wrappedRequest =
          new SessionRepositoryRequestWrapper(request,response,servletContext);

        SessionRepositoryResponseWrapper wrappedResponse =
          new SessionRepositoryResponseWrapper(wrappedRequest, response);

        HttpServletRequest strategyRequest =
             httpSessionStrategy.wrapRequest(wrappedRequest, wrappedResponse);

        HttpServletResponse strategyResponse =
             httpSessionStrategy.wrapResponse(wrappedRequest, wrappedResponse);

        try {
            filterChain.doFilter(strategyRequest, strategyResponse);
        } finally {
            wrappedRequest.commitSession();
        }
    }
}

总结:通过对 spring-session 核心源码的分析得到的关键信息是,Spring Session 对 HTTP 的支持所依靠的是一个简单老式的ServletFilter,借助 servlet 规范中标准的特性来实现 Spring Session 的功能。因此,我们能够让已有的 war 文件使用 Spring Session 的功能,而无需修改已有的代码。

注意事项

  • 如上实现方式都是基于 xml 方式来配置的,官方也有通过注解方式来配置的
  • spring-session 要求 Redis 版本在2.8及以上
  • Spring Session 的核心项目并不依赖于Spring框架,所以,我们甚至能够将其应用于不使用 Spring 框架的项目中,只是需要引入 spring 常用的包,包括 spring-beans, spring-core, spring-tx 等,版本需在 3.2.9 及以上。但是当我们项目使用了 spring 的时候,版本需在 3.2.9 及以上。
  • 默认情况下,session 存储在 redis 的 key 是“spring:session::”,但如果有多个系统同时使用一个 redis,则会冲突,此时应该配置 redisNamespace 值,配置后,其 key 为 spring:session:devlops:keyName

    • 配置 redisNamesapce 的方式,在之前配置文件的 bean 中添加一个属性即可

      <!-- 将session放入redis -->
          <bean id="redisHttpSessionConfiguration" class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration">
              <property name="maxInactiveIntervalInSeconds" value="1800" />
              <property name="redisNamespace" value="${redisNamespace}"/>
          </bean>

      注意:spring-session 的版本在 1.1.0 及以上才支持命名空间

  • 如果想在 session 中保存一个对象,必须实现了 Serializable接口,这样 Spring-session 才能对保存的对象进行序列化,从而存储在 redis 里
  • session 的域不同会生成新的 session 的。所以在项目中做了负载均衡的话,域就是一样的,所以可以实现session 共享
  • 如果选用 redis 云服务,使用过程中会出现异常,异常原因是:很多 Redis 云服务提供商考虑到安全因素,会禁用掉 Redis 的 config 命令,因此需要我们手动在云服务后台管理系统手动配置,或者找云服务售后帮忙配置。然后我们在配置文件 RedisHttpSessionConfiguration 的 bean 中添加如下配置,解决使用 redis 云服务异常问题

    <!-- 让Spring Session不再执行config命令 -->
    <util:constant static-field="org.springframework.session.data.redis.config.ConfigureRedisAction.NO_OP">
    </util:constant>

注意:判断 config 命令是否被禁用,可以在 redis 的命令行去使用 config 命令,如果报没有找到该命令,说明 config 命令被禁用了。

参考

查看原文

赞 11 收藏 20 评论 2

认证与成就

  • 获得 13 次点赞
  • 获得 11 枚徽章 获得 0 枚金徽章, 获得 2 枚银徽章, 获得 9 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2015-06-24
个人主页被 805 人浏览