Mac 安装Hadoop

随着运维工作的进一步深入,后期可能涉及到对源码的修改,需要多次重新编译。因此,这一关是绕不过的。

如有疑问,请联系作者,微信 17180081757

版本声明

  • 源码:Apache Hadoop 2.7.3
  • 系统:macOS 10.14.5
  • 依赖:
    • oracle jdk 1.8.0_231
    • Apache Maven 3.6.0
    • libprotoc 2.5.0

编译

核心命令

如果使用命令行:package -Pdist,native -DskipTests -Dtar
如果使用IDEA 开发工具,右上角,选择“Run/Debug Configuration”,如下图,,点击“+”新增一个,maven的run configuration,Name随便起,主要是Command Line:package -Pdist,native -DskipTests -Dtar

编译Hadoop源码时间比较长

Hadoop源码量巨大、依赖众多,编译时间比较长。

下载jar包和编译protoc是两个大头。编译protoc用了1小时左右,下载jar包+编译Hadoop用了2个多小时。除去这些时间,也需要1小时左右才能编译成功。

还好上半年为了看Yarn的状态机编译过一回,虽然是不完全编译,但也下载了大部分依赖的jar包,并编译安装了protoc(强烈建议编译安装,忘记当时有什么坑来着)。这次只需要继续踩上次剩下的坑了。

不过,鉴于第一次编译时,大部分人都会重复多次才能编译成功,单次编译的时间也没什么意义了。喝杯茶,慢慢来吧。

JDK版本

[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:2.5.1:compile (default-compile) on project hadoop-annotations: Compilation failure: Compilation failure:[ERROR] /Volumes/Extended/Users/msh/IdealProjects/Git-Study/hadoop/hadoop-common-project/hadoop-annotations/src/main/java/org/apache/hadoop/classification/tools/ExcludePrivateAnnotationsJDiffDoclet.java:[20,22] 错误: 程序包com.sun.javadoc不存在

不明白为啥这个包会不存在,可能是JDK版本问题。google一番,参考解决Mac OS 下编译Hadoop Annotations 程序包com.sun.javadoc找不到问题解决。

验证

在所有的pom.xml里面找设置1.7 jdk的地方:

 12345678910111213141516 find . -name pom.xml > tmp/tmp.txt
while read filedocnt=0grep ‘1.7’ $file -C2 | while read line; doif [ -n “$line” ]; thenif [ $cnt -eq 0 ]; thenecho “+++file: $file”ficnt=$((cnt+1))echo $linefidonecnt=0done < tmp/tmp.txt

输出:

 12345678910111213141516171819 +++file: ./hadoop-common-project/hadoop-annotations/pom.xml</profile><profile><id>jdk1.7</id><activation>–<activation><jdk>1.7</jdk></activation><dependencies>—-<groupId>jdk.tools</groupId><artifactId>jdk.tools</artifactId><version>1.7</version><scope>system</scope><systemPath>${java.home}/../lib/tools.jar</systemPath>+++file: ./hadoop-project/pom.xml…(略)

确实./hadoop-common-project/hadoop-annotations/pom.xml中限制了jdk版本。

解决

Mac上的默认JDK是oracle jdk1.8.0_102的,翻了下jdk源码也有这个包。说明不是因为该包实际不存在。

可以尝试修改pom里限制的jdk版本;不过,为了防止使用了deprecated方法等麻烦,这里直接切jdk 1.7,不改pom。

openssl引起编译报错

 12 [ERROR] Failed to execute goal org.apache.maven.plugins:maven-antrun-plugin:1.7:run (make) on project hadoop-pipes: An Ant BuildException has occured: exec returned: 1[ERROR] around Ant part …<exec dir=”/Volumes/Extended/Users/msh/IdealProjects/Git-Study/hadoop/hadoop-tools/hadoop-pipes/target/native” executable=”cmake” failonerror=”true”>… @ 5:153 in /Volumes/Extended/Users/msh/IdealProjects/Git-Study/hadoop/hadoop-tools/hadoop-pipes/target/antrun/build-main.xml

猜测是ant版本问题,重装了jdk1.7适配的ant。

Ant BuildException也是够迷惑的。而且之前猴子电脑配置的jdk1.8,切到1.7之后ant就不能用了(brew安装的ant用1.8jdk编译的,1.7无法解析class文件),重装了适配1.7的ant版本后,ant可以正常使用了,却还是报这个错。。。

结果还是报这个错,打开build-main.xml看,发现是一个cmake命令的配置,copy到终端执行:

 1 cmake /Volumes/Extended/Users/msh/IdealProjects/Git-Study/hadoop/hadoop-tools/hadoop-pipes/src/ -DJVM_ARCH_DATA_MODEL=64

输出:

 123456789101112131415 …(略)CommandLineTools/usr/bin/c++ — works– Detecting CXX compiler ABI info– Detecting CXX compiler ABI info – done– Detecting CXX compile features– Detecting CXX compile features – doneCMake Error at /usr/local/Cellar/cmake/3.6.2/share/cmake/Modules/FindPackageHandleStandardArgs.cmake:148 (message):Could NOT find OpenSSL, try to set the path to OpenSSL root folder in thesystem variable OPENSSL_ROOT_DIR (missing: OPENSSL_INCLUDE_DIR)Call Stack (most recent call first):/usr/local/Cellar/cmake/3.6.2/share/cmake/Modules/FindPackageHandleStandardArgs.cmake:388 (_FPHSA_FAILURE_MESSAGE)/usr/local/Cellar/cmake/3.6.2/share/cmake/Modules/FindOpenSSL.cmake:380 (find_package_handle_standard_args)CMakeLists.txt:20 (find_package)
…(略)

OPENSSL_ROOT_DIROPENSSL_INCLUDE_DIR没有设置。echo一下确实没有设置。

解决

Mac自带OpenSSL,然而猴子并不知道哪里算是root,哪里算是include;另外,据说mac计划移除默认的openssl。干脆自己重新安装:

 1 brew install openssl

然后配置环境变量:

 12export OPENSSL_ROOT_DIR=/usr/local/Cellar/openssl/1.0.2nexport OPENSSL_INCLUDE_DIR=$OPENSSL_ROOT_DIR/include
如下是我的配置,请参考:
cat ~/.bash_profile
export PROTOC=”/usr/local/bin/protoc”
export M2_HOME=/Users/lynchgao/apache-maven-3.6.3
export PATH=$PATH:$M2_HOME/bin
JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk1.8.0_231.jdk/Contents/Home
PATH=$JAVA_HOME/bin:$PATH:.
CLASSPATH=$JAVA_HOME/lib/tools.jar:$JAVA_HOME/lib/dt.jar:.
export JAVA_HOME
export PATH
export CLASSPATH
export OPENSSL_ROOT_DIR=/usr/local/Cellar/openssl1.0.2k
export PATH=OPENSSL_ROOT_DIR/bin:$PATH
export OPENSSL_INCLUDE_DIR=$OPENSSL_ROOT_DIR/include
export OPENSSL_LIB_DIR=$OPENSSL_ROOT_DIR/lib

继续编译,发现编译hadoop-pipes时候,报错“around Ant part …<exec dir=”/Users/lynchgao/IdeaProjects/hadoop/hadoop-tools/hadoop-pipes/target/native” executable=”make”

按照上面的方法,进入指定目录执行make

cd /Users/lynchgao/IdeaProjects/hadoop/hadoop-tools/hadoop-pipes/target/native

执行make

报错“/usr/local/opt/openssl@1.1/include/openssl/ossl_typ.h:102:16: note: forward
declaration of ‘hmac_ctx_st’”

网上搜了一下,说是版本冲突,查了一下hadoop 2.7.3 用的是openssl1.0.2k,

从git上拉取代码https://github.com/openssl/openssl/tree/OpenSSL_1_0_2k

编译mac 64位版本版本需要注意,darwin64-x86_64-cc 是编译64位

sudo ./Configure darwin64-x86_64-cc –prefix=/usr/local/Cellar/openssl1.0.2k

make

make install

然后替换brew 默认安装的路径,替换brew默认路径,是因为hadoop-pipeline c代码 include时候需要找openssl

mv /usr/local/opt/openssl@1.1/ /usr/local/opt/openssl@1.1_bak/

cp -rf /usr/local/Cellar/openssl1.0.2k /usr/local/opt/openssl@1.1

如果从命令行执行 openssl version还是高版本,请联系我

maven仓库不稳定

 1 [ERROR] Failed to execute goal on project hadoop-aws: Could not resolve dependencies for project org.apache.hadoop:hadoop-aws:jar:2.6.0: Could not transfer artifact com.amazonaws:aws-java-sdk:jar:1.7.4 from/to central (https://repo.maven.apache.org/maven2): GET request of: com/amazonaws/aws-java-sdk/1.7.4/aws-java-sdk-1.7.4.jar from central failed: SSL peer shut down incorrectly -> [Help 1]

出现类似“Could not resolve dependencies”、“SSL peer shut down incorrectly”等语句,一般是maven不稳定,换个稳定的maven源,或者重新编译多试几次。

更换旧的Openssl办法

不过,我们还有最后一步,那就是当我们使用openssl时,使用的是我们用homebrew新下载的openssl。为了达到这个目的,我们有两种方法。

将homebrew下载的openssl软链接/usr/bin/openssl目录下。这里,我们先将它保存一份老的,然后再软链接新下载的。


$ mv /usr/bin/openssl /usr/bin/openssl_old
mv: rename /usr/bin/openssl to /usr/bin/openssl_old: Operation not permitted
$ ln -s /usr/local/Cellar/openssl/1.0.2p/bin/openssl /usr/bin/openssl
ln: /usr/bin/openssl: Operation not permitted

Operation not permitted提示没有权限操作,对/usr/bin目录下的东西,我已经遇到过几次这个问题了,于是继续google,在stackoverflow上找到了Operation Not Permitted when on root El capitan (rootless disabled)

重启系统,当启动的时候我们同时按下cmd+r进入Recovery模式,之后选择实用工具 => 终端,在终端输入如下命令,接口文件系统的锁定,并且重启电脑(cmd+r后,会进入另外一个选择系统启动的界面,在这个界面里面不要马上重新启动,先找到终端,在終端中输入csrutil disable):

$ csrutil disable
$ reboot

最后,我们执行前面两个命令,查看版本。

$ sudo mv /usr/bin/openssl /usr/bin/openssl_old
$ sudo ln -s /usr/local/Cellar/openssl/1.0.2p/bin/openssl /usr/bin/openssl
$ openssl version
OpenSSL 1.0.2p  14 Aug 2018

➜ which openssl
/usr/local/opt/openssl/bin/openssl

这样,我们的openssl升级成功了。不过,为了安全起见,我还是重新启动电脑,然后重新开启了csrutil。

csrutil enable
reboot

其他

历史遗留坑

上次编译有个小坑,是Hadoop源码里的历史遗留问题。

编译过程中会在$JAVA_HOME/Classes下找一个并不存在的jar包classes.jar,实际上需要的是$JAVA_HOME/lib/tools.jar,加个软链就好(注意mac加软链时与linux的区别)。

因此上次编译猴子已经修复了这个问题,这里就不复现了。具体可以看这篇mac下编译Hadoop

没有SKIPTESTS

没有skipTests的话,至少会在测试过程中以下错误:

 1234567891011 [ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:2.16:test (default-test) on project hadoop-auth: There are test failures.[ERROR][ERROR] Please refer to /Volumes/Extended/Users/msh/IdealProjects/Git-Study/hadoop/hadoop-common-project/hadoop-auth/target/surefire-reports for the individual test results.
Results :
Tests in error:TestKerberosAuthenticator.testAuthenticationHttpClientPost:157 » ClientProtocolTestKerberosAuthenticator.testAuthenticationHttpClientPost:157 » ClientProtocol
Tests run: 92, Failures: 0, Errors: 2, Skipped: 0

可以暂时忽略这些相关错误,skipTests跳过测试,能追踪源码了解主要过程即可。

启动伪分布式“集群”

编译成功后,在hadoop-dist模块的target目录下,生成了各种发行版。选择hadoop-2.6.0.tar.gz,找个地方解压。

配置SSH 无密码链接

如果没有安装SSH,执行下面命令安装

# Install ssh
$ apt install ssh
# Check 22 port
$ netstat –nat

回到用户目录
即 /home/ubuntu (ubuntu 是当前用户的主目录)

$ cd ~

执行 ssh-keygen 命令, 一直回车。

$ ssh-keygen -t rsa

在当前用户目录下有个隐藏目录 .ssh 目录 ,进入该目录

$ cd .ssh

里面有 id_rsa.pub 文件, 将其赋值到 authorized_keys 文件

$ cp id_rsa.pub authorized_keys

然后再测试 SSH登录

当你尝试连接本机的时候就可以直接链接不需要登录。
如果你想直接链接其他VM, 只需要将其他机器上的 id_rsa.pub 添加到authorized_keys, 这样就可以直接ssh 链接过去而不需要输入密码。 这个在后面启动hadoop 时候就很有用,启动服务就不用输入密码。

配置IP

$ sudo vim /etc/hosts
# 通过此命令配置IP映射

第一个VM的 mster

10.0.1.6 slave1
10.0.1.10 slave2
10.0.1.12 master

第二个VM的 slave1

10.0.1.6 slave1
10.0.1.10 slave2
10.0.1.12 master

第三个VM的 slave2

10.0.1.6 slave1
10.0.1.10 slave2
10.0.1.12 master

配置Hadoop

首先解压 hadoop 文件

$ tar -xvf hadoop-2.7.3.tar.gz
咱们这个案例是把hadoop编译好的包拷出来使用
cd /Users/lynchgao/IdeaProjects/hadoop/hadoop-dist/target

解压完成之后进入 配置文件所在目录即 hadoop-2.7.3 目录下 etc/hadoop 内

cd /data/install/apache/hadoop-2.7.3/etc/hadoop/

接下来要配置以下几个文件:
core-site.xml、hdfs-site.xml、yarn-site.xml、mapred-site.xml、slaves、hadoop-env.sh、yarn-env.sh

hadoop-env.sh和yarn-env.sh 配置 jdk 环境

# The java implementation to use.
#export JAVA_HOME=${JAVA_HOME}
export JAVA_HOME=/home/ubuntu/developer/jdk1.8.0_121
core-site.xml
<!-- Put site-specific property overrides in this file. -->
<configuration>
    <property>
        <name>fs.default.name</name>
        <value>hdfs://master:9000</value>
    </property>
    <property>
        <name>hadoop.tmp.dir</name>
        <value>file:/home/ubuntu/developer/hadoop-2.7.3/tmp</value>
    </property>
    <property>
        <name>io.file.buffer.size</name>
        <value>131702</value>
    </property>
</configuration>
hdfs-site.xml

<configuration>
    <property>
        <name>dfs.namenode.name.dir</name>
        <value>file:/home/ubuntu/developer/hadoop-2.7.3/hdfs/name</value>
    </property>
    <property>
        <name>dfs.datanode.data.dir</name>
        <value>file:/home/ubuntu/developer/hadoop-2.7.3/hdfs/data</value>
    </property>
    <property>
        <name>dfs.replication</name>
        <value>2</value>
    </property>
    <property>
        <name>dfs.namenode.secondary.http-address</name>
        <value>master:9001</value>
    </property>
    <property>
        <name>dfs.webhdfs.enabled</name>
        <value>true</value>
    </property>
    <property>
        <name>dfs.namenode.datanode.registration.ip-hostname-check</name>
        <value>false</value>
    </property>
    <property>
        <name>dfs.permissions</name>
        <value>false</value>
    </property> 
</configuration>
yarn-site.xml
<configuration>

  <property>
    <name>yarn.nodemanager.aux-services</name>
    <value>mapreduce_shuffle</value>
  </property>
  <property>
    <name>yarn.nodemanager.auxservices.mapreduce.shuffle.class</name>
    <value>org.apache.hadoop.mapred.ShuffleHandler</value>
  </property>
  <property>
    <name>yarn.resourcemanager.address</name>
    <value>master:8032</value>
  </property>
  <property>
    <name>yarn.resourcemanager.scheduler.address</name>
    <value>master:8030</value>
  </property>
  <property>
    <name>yarn.resourcemanager.resource-tracker.address</name>
    <value>master:8031</value>
  </property>
  <property>
    <name>yarn.resourcemanager.admin.address</name>
    <value>master:8033</value>
  </property>
  <property>
    <name>yarn.resourcemanager.webapp.address</name>
    <value>master:8088</value>
  </property>
</configuration>
mapred-site.xml

默认没有这个文件 但是提供了个模板 mapred-site.xml.template
通过这个模板复制一个

$ cp etc/hadoop/mapred-site.xml.template etc/hadoop/mapred-site.xml

<configuration>
    <property>
        <name>mapreduce.framework.name</name>
        <value>yarn</value>
    </property>
    <property>
        <name>mapreduce.jobhistory.address</name>
        <value>master:10020</value>
    </property>
    <property>
        <name>mapreduce.jobhistory.webapp.address</name>
        <value>master:19888</value>
    </property>
</configuration>

slaves
slave1
slave2
将配置好的hadoop 文件夹复制给其他节点(slave1 和slave2)
scp -r /home/ubuntu/developer/hadoop-2.7.3 ubuntu@slave1:/home/ubuntu/developer/hadoop-2.7.3 
scp -r /home/ubuntu/developer/hadoop-2.7.3 ubuntu@slave2:/home/ubuntu/developer/hadoop-2.7.3 
运行启动Hadoop

1- 初始化hadoop(清空hdfs数据):

rm -rf /home/ubuntu/developer/hadoop-2.7.3/hdfs/*
rm -rf /home/ubuntu/developer/hadoop-2.7.3/tmp/*
/home/ubuntu/developer/hadoop-2.7.3/bin/hdfs namenode -format

2- 启动hdfs,yarn

/home/ubuntu/developer/hadoop-2.7.3/sbin/start-dfs.sh
/home/ubuntu/developer/hadoop-2.7.3/sbin/start-yarn.sh

3- 停止hdfs,yarn

/home/ubuntu/developer/hadoop-2.7.3/sbin/stop-dfs.sh
/home/ubuntu/developer/hadoop-2.7.3/sbin/stop-yarn.sh

4- 检查是否成功
在 master 终端敲 jps 命令

master-jps.png

在 slave 终端敲 jps 命令

slave-jps.png

或者在master 节点看 report

$ bin/hdfs dfsadmin -report

到此, hadoop 可以正常启动。

一些常用命令
#列出HDFS下的文件
hdfs dfs -ls 
#列出HDFS下某个文档中的文件
hdfs dfs -ls in 
#上传文件到指定目录并且重新命名,只有所有的DataNode都接收完数据才算成功
hdfs dfs -put test1.txt test2.txt 
#从HDFS获取文件并且重新命名为getin,
同put一样可操作文件也可操作目录
hdfs dfs -get in getin 
#删除指定文件从HDFS上
hdfs dfs -rmr out 
#查看HDFS上in目录的内容
hdfs dfs -cat in/* 
#查看HDFS的基本统计信息
hdfs dfsadmin -report 
#退出安全模式
hdfs dfsadmin -safemode leave 
#进入安全模式
hdfs dfsadmin -safemode enter 

运行WordCount官方例子

  1. 在 /home/ubuntu 下建立一个文件夹input, 并放几个txt文件在内
  2. 切换到 hadoop-2.7.3目录内
  3. 给hadoop创建一个 wc_input文件夹
$  bin/hdfs dfs -mkdir /wc_input
  1. 将 /home/ubuntu/input 内的文件传到hadoop /wc_input 内
$ bin/hdfs dfs –put /home/ubuntu/input*   /wc_input
$ bin/hadoop jar share/hadoop/mapreduce/hadoop-mapreduce-examples-2.7.3.jar wordcount /wc_input /wc_oput
  1. 查看结果
$ bin/hdfs dfs -ls /wc_output
$ bin/hdfs dfs -ls /wc_output/part-r-00000
  1. 在浏览器上查看
    http://your-floating-ip:50070/dfshealth.html
    但是在此之前可能需要开通端口,为了简便我在OpenStack上将所有端口开通。

tcp-ports.png

http://your-floating-ip:8088/cluster/scheduler

Flink 零基础实战教程:如何计算实时热门商品

From:http://wuchong.me/blog/2018/11/07/use-flink-calculate-hot-items/

上一篇入门教程中,我们已经能够快速构建一个基础的 Flink 程序了。本文会一步步地带领你实现一个更复杂的 Flink 应用程序:实时热门商品。在开始本文前我们建议你先实践一遍上篇文章,因为本文会沿用上文的my-flink-project项目框架。

通过本文你将学到:

  1. 如何基于 EventTime 处理,如何指定 Watermark
  2. 如何使用 Flink 灵活的 Window API
  3. 何时需要用到 State,以及如何使用
  4. 如何使用 ProcessFunction 实现 TopN 功能

实战案例介绍

本案例将实现一个“实时热门商品”的需求,我们可以将“实时热门商品”翻译成程序员更好理解的需求:每隔5分钟输出最近一小时内点击量最多的前 N 个商品。将这个需求进行分解我们大概要做这么几件事情:

  • 抽取出业务时间戳,告诉 Flink 框架基于业务时间做窗口
  • 过滤出点击行为数据
  • 按一小时的窗口大小,每5分钟统计一次,做滑动窗口聚合(Sliding Window)
  • 按每个窗口聚合,输出每个窗口中点击量前N名的商品

数据准备

这里我们准备了一份淘宝用户行为数据集(来自阿里云天池公开数据集,特别感谢)。本数据集包含了淘宝上某一天随机一百万用户的所有行为(包括点击、购买、加购、收藏)。数据集的组织形式和MovieLens-20M类似,即数据集的每一行表示一条用户行为,由用户ID、商品ID、商品类目ID、行为类型和时间戳组成,并以逗号分隔。关于数据集中每一列的详细描述如下:

列名称说明
用户ID整数类型,加密后的用户ID
商品ID整数类型,加密后的商品ID
商品类目ID整数类型,加密后的商品所属类目ID
行为类型字符串,枚举类型,包括(‘pv’, ‘buy’, ‘cart’, ‘fav’)
时间戳行为发生的时间戳,单位秒

你可以通过下面的命令下载数据集到项目的 resources 目录下:

$ cd my-flink-project/src/main/resources
$ curl https://raw.githubusercontent.com/wuchong/my-flink-project/master/src/main/resources/UserBehavior.csv > UserBehavior.csv

这里是否使用 curl 命令下载数据并不重要,你也可以使用 wget 命令或者直接访问链接下载数据。关键是,将数据文件保存到项目的 resources 目录下,方便应用程序访问。

编写程序

在 src/main/java/myflink 下创建 HotItems.java 文件:

package myflink;

public class HotItems {

public static void main(String[] args) throws Exception {

}
}

与上文一样,我们会一步步往里面填充代码。第一步仍然是创建一个 StreamExecutionEnvironment,我们把它添加到 main 函数中。

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 为了打印到控制台的结果不乱序,我们配置全局的并发为1,这里改变并发对结果正确性没有影响
env.setParallelism(1);

创建模拟数据源

在数据准备章节,我们已经将测试的数据集下载到本地了。由于是一个csv文件,我们将使用 CsvInputFormat 创建模拟数据源。

注:虽然一个流式应用应该是一个一直运行着的程序,需要消费一个无限数据源。但是在本案例教程中,为了省去构建真实数据源的繁琐,我们使用了文件来模拟真实数据源,这并不影响下文要介绍的知识点。这也是一种本地验证 Flink 应用程序正确性的常用方式。

我们先创建一个 UserBehavior 的 POJO 类(所有成员变量声明成public便是POJO类),强类型化后能方便后续的处理。

/** 用户行为数据结构 **/
public static class UserBehavior {
public long userId; // 用户ID
public long itemId; // 商品ID
public int categoryId; // 商品类目ID
public String behavior; // 用户行为, 包括(“pv”, “buy”, “cart”, “fav”)
public long timestamp; // 行为发生的时间戳,单位秒
}

接下来我们就可以创建一个 PojoCsvInputFormat 了, 这是一个读取 csv 文件并将每一行转成指定 POJO
类型(在我们案例中是 UserBehavior)的输入器。

// UserBehavior.csv 的本地文件路径
URL fileUrl = HotItems2.class.getClassLoader().getResource(“UserBehavior.csv”);
Path filePath = Path.fromLocalFile(new File(fileUrl.toURI()));
// 抽取 UserBehavior 的 TypeInformation,是一个 PojoTypeInfo
PojoTypeInfo<UserBehavior> pojoType = (PojoTypeInfo<UserBehavior>) TypeExtractor.createTypeInfo(UserBehavior.class);
// 由于 Java 反射抽取出的字段顺序是不确定的,需要显式指定下文件中字段的顺序
String[] fieldOrder = new String[]{“userId”, “itemId”, “categoryId”, “behavior”, “timestamp”};
// 创建 PojoCsvInputFormat
PojoCsvInputFormat<UserBehavior> csvInput = new PojoCsvInputFormat<>(filePath, pojoType, fieldOrder);

下一步我们用 PojoCsvInputFormat 创建输入源。

DataStream<UserBehavior> dataSource = env.createInput(csvInput, pojoType);

这就创建了一个 UserBehavior 类型的 DataStream

EventTime 与 Watermark

当我们说“统计过去一小时内点击量”,这里的“一小时”是指什么呢? 在 Flink 中它可以是指 ProcessingTime ,也可以是 EventTime,由用户决定。

  • ProcessingTime:事件被处理的时间。也就是由机器的系统时间来决定。
  • EventTime:事件发生的时间。一般就是数据本身携带的时间。

在本案例中,我们需要统计业务时间上的每小时的点击量,所以要基于 EventTime 来处理。那么如果让 Flink 按照我们想要的业务时间来处理呢?这里主要有两件事情要做。

第一件是告诉 Flink 我们现在按照 EventTime 模式进行处理,Flink 默认使用 ProcessingTime 处理,所以我们要显式设置下。

env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);

第二件事情是指定如何获得业务时间,以及生成 Watermark。Watermark 是用来追踪业务事件的概念,可以理解成 EventTime 世界中的时钟,用来指示当前处理到什么时刻的数据了。由于我们的数据源的数据已经经过整理,没有乱序,即事件的时间戳是单调递增的,所以可以将每条数据的业务时间就当做 Watermark。这里我们用 AscendingTimestampExtractor 来实现时间戳的抽取和 Watermark 的生成。

注:真实业务场景一般都是存在乱序的,所以一般使用 BoundedOutOfOrdernessTimestampExtractor

DataStream<UserBehavior> timedData = dataSource
.assignTimestampsAndWatermarks(new AscendingTimestampExtractor<UserBehavior>() {
@Override
public long extractAscendingTimestamp(UserBehavior userBehavior) {
// 原始数据单位秒,将其转成毫秒
return userBehavior.timestamp * 1000;
}
});

这样我们就得到了一个带有时间标记的数据流了,后面就能做一些窗口的操作。

过滤出点击事件

在开始窗口操作之前,先回顾下需求“每隔5分钟输出过去一小时内点击量最多的前 N 个商品”。由于原始数据中存在点击、加购、购买、收藏各种行为的数据,但是我们只需要统计点击量,所以先使用 FilterFunction 将点击行为数据过滤出来。

DataStream<UserBehavior> pvData = timedData
.filter(new FilterFunction<UserBehavior>() {
@Override
public boolean filter(UserBehavior userBehavior) throws Exception {
// 过滤出只有点击的数据
return userBehavior.behavior.equals(“pv”);
}
});

窗口统计点击量

由于要每隔5分钟统计一次最近一小时每个商品的点击量,所以窗口大小是一小时,每隔5分钟滑动一次。即分别要统计 [09:00, 10:00), [09:05, 10:05), [09:10, 10:10)… 等窗口的商品点击量。是一个常见的滑动窗口需求(Sliding Window)。

DataStream<ItemViewCount> windowedData = pvData
.keyBy(“itemId”)
.timeWindow(Time.minutes(60), Time.minutes(5))
.aggregate(new CountAgg(), new WindowResultFunction());

我们使用.keyBy("itemId")对商品进行分组,使用.timeWindow(Time size, Time slide)对每个商品做滑动窗口(1小时窗口,5分钟滑动一次)。然后我们使用 .aggregate(AggregateFunction af, WindowFunction wf) 做增量的聚合操作,它能使用AggregateFunction提前聚合掉数据,减少 state 的存储压力。较之.apply(WindowFunction wf)会将窗口中的数据都存储下来,最后一起计算要高效地多。aggregate()方法的第一个参数用于

这里的CountAgg实现了AggregateFunction接口,功能是统计窗口中的条数,即遇到一条数据就加一。

/** COUNT 统计的聚合函数实现,每出现一条记录加一 */
public static class CountAgg implements AggregateFunction<UserBehavior, Long, Long> {

@Override
public Long createAccumulator() {
return 0L;
}

@Override
public Long add(UserBehavior userBehavior, Long acc) {
return acc + 1;
}

@Override
public Long getResult(Long acc) {
return acc;
}

@Override
public Long merge(Long acc1, Long acc2) {
return acc1 + acc2;
}
}

.aggregate(AggregateFunction af, WindowFunction wf) 的第二个参数WindowFunction将每个 key每个窗口聚合后的结果带上其他信息进行输出。我们这里实现的WindowResultFunction将主键商品ID,窗口,点击量封装成了ItemViewCount进行输出。

/** 用于输出窗口的结果 */
public static class WindowResultFunction implements WindowFunction<Long, ItemViewCount, Tuple, TimeWindow> {

@Override
public void apply(
Tuple key, // 窗口的主键,即 itemId
TimeWindow window, // 窗口
Iterable<Long> aggregateResult, // 聚合函数的结果,即 count 值
Collector<ItemViewCount> collector // 输出类型为 ItemViewCount
) throws Exception {
Long itemId = ((Tuple1<Long>) key).f0;
Long count = aggregateResult.iterator().next();
collector.collect(ItemViewCount.of(itemId, window.getEnd(), count));
}
}

/** 商品点击量(窗口操作的输出类型) */
public static class ItemViewCount {
public long itemId; // 商品ID
public long windowEnd; // 窗口结束时间戳
public long viewCount; // 商品的点击量

public static ItemViewCount of(long itemId, long windowEnd, long viewCount) {
ItemViewCount result = new ItemViewCount();
result.itemId = itemId;
result.windowEnd = windowEnd;
result.viewCount = viewCount;
return result;
}
}

现在我们得到了每个商品在每个窗口的点击量的数据流。

TopN 计算最热门商品

为了统计每个窗口下最热门的商品,我们需要再次按窗口进行分组,这里根据ItemViewCount中的windowEnd进行keyBy()操作。然后使用 ProcessFunction 实现一个自定义的 TopN 函数 TopNHotItems 来计算点击量排名前3名的商品,并将排名结果格式化成字符串,便于后续输出。

DataStream<String> topItems = windowedData
.keyBy(“windowEnd”)
.process(new TopNHotItems(3)); // 求点击量前3名的商品

ProcessFunction 是 Flink 提供的一个 low-level API,用于实现更高级的功能。它主要提供了定时器 timer 的功能(支持EventTime或ProcessingTime)。本案例中我们将利用 timer 来判断何时收齐了某个 window 下所有商品的点击量数据。由于 Watermark 的进度是全局的,

在 processElement 方法中,每当收到一条数据(ItemViewCount),我们就注册一个 windowEnd+1 的定时器(Flink 框架会自动忽略同一时间的重复注册)。windowEnd+1 的定时器被触发时,意味着收到了windowEnd+1的 Watermark,即收齐了该windowEnd下的所有商品窗口统计值。我们在 onTimer() 中处理将收集的所有商品及点击量进行排序,选出 TopN,并将排名信息格式化成字符串后进行输出。

这里我们还使用了 ListState<ItemViewCount> 来存储收到的每条 ItemViewCount 消息,保证在发生故障时,状态数据的不丢失和一致性。ListState 是 Flink 提供的类似 Java List 接口的 State API,它集成了框架的 checkpoint 机制,自动做到了 exactly-once 的语义保证。

/** 求某个窗口中前 N 名的热门点击商品,key 为窗口时间戳,输出为 TopN 的结果字符串 */
public static class TopNHotItems extends KeyedProcessFunction<Tuple, ItemViewCount, String> {

private final int topSize;

public TopNHotItems(int topSize) {
this.topSize = topSize;
}

// 用于存储商品与点击数的状态,待收齐同一个窗口的数据后,再触发 TopN 计算
private ListState<ItemViewCount> itemState;

@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
// 状态的注册
ListStateDescriptor<ItemViewCount> itemsStateDesc = new ListStateDescriptor<>(
“itemState-state”,
ItemViewCount.class);
itemState = getRuntimeContext().getListState(itemsStateDesc);
}

@Override
public void processElement(
ItemViewCount input,
Context context,
Collector<String> collector) throws Exception {

// 每条数据都保存到状态中
itemState.add(input);
// 注册 windowEnd+1 的 EventTime Timer, 当触发时,说明收齐了属于windowEnd窗口的所有商品数据
context.timerService().registerEventTimeTimer(input.windowEnd + 1);
}

@Override
public void onTimer(
long timestamp, OnTimerContext ctx, Collector<String> out) throws Exception {
// 获取收到的所有商品点击量
List<ItemViewCount> allItems = new ArrayList<>();
for (ItemViewCount item : itemState.get()) {
allItems.add(item);
}
// 提前清除状态中的数据,释放空间
itemState.clear();
// 按照点击量从大到小排序
allItems.sort(new Comparator<ItemViewCount>() {
@Override
public int compare(ItemViewCount o1, ItemViewCount o2) {
return (int) (o2.viewCount – o1.viewCount);
}
});
// 将排名信息格式化成 String, 便于打印
StringBuilder result = new StringBuilder();
result.append(“====================================\n”);
result.append(“时间: “).append(new Timestamp(timestamp-1)).append(“\n”);
for (int i=0;i<topSize;i++) {
ItemViewCount currentItem = allItems.get(i);
// No1: 商品ID=12224 浏览量=2413
result.append(“No”).append(i).append(“:”)
.append(” 商品ID=”).append(currentItem.itemId)
.append(” 浏览量=”).append(currentItem.viewCount)
.append(“\n”);
}
result.append(“====================================\n\n”);

out.collect(result.toString());
}
}

打印输出

最后一步我们将结果打印输出到控制台,并调用env.execute执行任务。

topItems.print();
env.execute(“Hot Items Job”);

运行程序

直接运行 main 函数,就能看到不断输出的每个时间点的热门商品ID。

总结

本文的完整代码可以通过 GitHub 访问到。本文通过实现一个“实时热门商品”的案例,学习和实践了 Flink 的多个核心概念和 API 用法。包括 EventTime、Watermark 的使用,State 的使用,Window API 的使用,以及 TopN 的实现。希望本文能加深大家对 Flink 的理解,帮助大家解决实战上遇到的问题。

如何从小白成长为 Apache Committer?

from:http://wuchong.me/blog/2019/02/12/how-to-become-apache-committer/

过去三年,我一直在为 Apache Flink 开源项目贡献,也在两年前成为了 Flink Committer。我在 Flink 社区成长的过程中受到过社区大神的很多指导,如今也有很多人在向我咨询如何能参与到开源社区中,如何能成为 Committer。这也是本文写作的初衷,希望能帮助更多人参与到开源社区中。

本文将以 Apache Flink 为例,介绍如何参与社区贡献,如何成为 Apache Committer。

我们先来了解下一个小白在 Apache 社区中的成长路线是什么样的。

Apache 社区的成长路线

Apache 软件基金会(Apache Software Foundation,ASF)在开源软件界大名鼎鼎。ASF 能保证旗下 200 多个项目的社区活动运转良好,得益于其独特的组织架构和良好的制度。

用户 (User): 通过使用社区的项目构建自己的业务架构的开发者都是Apache的用户。

贡献者 (Contributor): 帮助解答用户的问题,贡献代码或文档,在邮件列表中参与讨论设计和方案的都是 Contributor。

提交者 (Committer): 贡献多了以后,就有可能经过 PMC 的提议和投票,邀请你成为 Committer。成为 Committer 也就意味着正式加入 Apache了,不但拥有相应项目的写入权限还有 apache.org 的专属邮箱。成为 Committer 的一个福利是可以免费使用 JetBrains 家的全套付费产品,包括全宇宙最好用的 IntelliJ IDEA (这是笔者当初成为 Committer 的最大动力之一)。

PMC: Committer 再往上走就是 PMC,这个必须由现有 PMC 成员提名。PMC 主要负责保证开源项目的社区活动都能运转良好,包括 Roadmap 的制定,版本的发布,Committer 的提拔。

ASF Member 相当于是基金会的“股东”,有董事会选举的投票权,也可以参与董事会竞选。ASF Member 也有权利决定是否接受一个新项目,主要关注 Apache 基金会本身的发展。ASF Member 通常要从 Contributor, Committer 等这些角色起步,逐步通过行动证明自己后,才可能被接受成为ASF Member。

Apache 社区的成员分类,权限由低到高,像极了我们在公司的晋升路线,一步步往上走。

如何成为 Committer

成为 Apache Committer 并没有一个确切的标准,但是 Committer 的候选人一般都是长期活跃的贡献者。成为 Committer 并没有要求必须有巨大的架构改进贡献,或者多少行的代码贡献。贡献文档、参与邮件列表的讨论、帮助回答问题都是很重要的增加贡献,提升影响力的方式。

所以如何成为 Committer 的问题归根结底还是如何参与贡献,以及如何开始贡献的问题。

成为 Committer 的关键在于持之以恒。不同项目,项目所处的不同阶段,成为 Committer 的难度都不太一样,笔者之前也持续贡献了近一年才有幸成为了 Committer。但是只要能坚持,保持活跃,持续贡献,为项目做的贡献被大家认可后,成为 Committer 也只是时间问题了。

如何参与贡献

参与贡献 Apache 项目有许多途径,包括提Bug,提需求,参与讨论,贡献代码和文档等等。

  1. 订阅开发者邮件列表:dev@flink.apache.org。关注社区动向,参与设计和方案的讨论,大胆地提出你的想法!
  2. 订阅用户邮件列表:user@flink.apache.orguser-zh@flink.apache.org。帮助解答用户问题。
  3. 提Bug和提需求:Flink 使用 JIRA 来管理issue。打开 Flink JIRA 并登录,点击菜单栏中的红色 “Create“ 按钮,创建一个issue。
  4. 贡献代码:可以在 Flink JIRA 中寻找自己感兴趣的 issue,并提交一个 Pull Request(下文会介绍提交一个 PR 的全过程)。如果是新手,建议从 “starter” 标记的 issue 入手。笔者在 Flink 项目的第一个 issue 就是修复了打印日志中的错别字,非常适合于熟悉贡献流程,而且当天就 merge 了,成就感满满。当熟悉了流程之后,建议专注贡献某个模块(如 SQL, DataStream, Runtime),有利于积累影响力。
  5. 贡献文档:文档是一个项目很重要的部分,可以在 JIRA 中寻找并解决文档类的 issue。熟悉中英文的同学可以参与贡献中文翻译,可以搜索 “chinese-translation” 的 issue
  6. 代码审查:Flink 每天都会在 GitHub 上收到很多 Pull Request 。帮助 review 代码也是对社区很重要的贡献。
  7. 还有很多参与贡献的方式,比如帮助测试RC版本,写Flink相关的博客等等。

如何提交第一个 Pull Request

1. 订阅 dev 邮件列表

  1. 用自己的邮箱给 dev-subscribe@flink.apache.org 发送任意邮件。
  2. 收到官方确认邮件。
  3. 回复该邮件,内容随意,表示确认即可。
  4. 确认后,会收到一封欢迎邮件,表示订阅成功。

2. 在 dev 邮件列表中申请 JIRA Contributor 权限

Apache 项目都使用 JIRA 来管理 issue,认领 issue 需要 Contributor 权限。申请权限之前,先在 JIRA 注册一个账号。然后发邮件到 dev 邮件列表(dev@flink.apache.org)申请 Contributor 权限,邮件内容要带上 JIRA 账号 id。例如:

Hi,

I want to contribute to Apache Flink.
Would you please give me the contributor permission?
My JIRA ID is xxxx.

社区的PMC大佬们看到后会第一时间给你权限的。

3. 在 JIRA 中挑选 issue

申请到权限后,就可以在 JIRA 中认领 issue了,推荐从简单的开始做起。例如中文翻译的issue。认领的方式非常简单,在 issue 页面右侧 Assignee 处点击 “Assign to me”,如下图所示。

Tip: 如果感兴趣的 issue 已经被别人认领,但是 Assignee 迟迟没有开始开发。那么可以在 issue 下面友好地询问下是否有时间开发,是否介意重新认领该 issue。

4. 本地开发代码

认领了 issue 后建议尽快开始开发,本地的开发环境建议使用 IntelliJ IDEA。在开发过程中有几个注意点:

  • 分支开发。 从最新的 master 分支切出一个开发分支用于 issue 开发。
  • 单 PR 单改动。 不要在 PR 中混入不相关的改动,不做无关的代码优化,不做无关的代码格式化。如果真有必要,可以另开 JIRA 解决。
  • 保证新代码能被单元测试覆盖到。如果原本的测试用例,无法覆盖到,则需要自己编写对应的单元测试。

5. 创建 pull request

在提交之前,先更新 master 分支,并通过 git rebase -i master 命令,将自己的提交置顶(也可以通过 IDEA > VCS > Git > Rebase 可视化界面来做 rebase)。同时保证自己的提交信息中只有一个 commit,commit message 遵循规范格式。Commit 格式是 “[FLINK-XXX] [YYY] ZZZ”,其中 XXX 是 JIRA ID,YYY 是 component 名字,ZZZ 是 JIRA title。例如 [FLINK-5385] [core] Add a helper method to create Row object

要创建一个 pull request,需要将这个开发分支推到自己 fork 的 Flink 仓库中。并在 fork 仓库页面(https://github.com/<your-user-name>/flink)点击 “Compare & pull request” 或者 “New pull request” 按钮,开始创建一个 PR。确保 base 是 apache/flink master,head 是刚刚的开发分支。另外在编辑框中按提示提供尽可能丰富的PR描述,然后点击 “Create pull request”。

6. 解决 code review 反馈的问题和建议

提交 PR 后会收到修改建议,只需要为这些修改 追加commit 就行,commit message 随意。注意不要 rebase/squash commits。追加 commit 能方便地看出距离上次的改动,而 rebase/squash 会导致 reviewer 不得不从头到尾重新看一遍 diff。

7. Committer merge PR

当 PR 获得 Committer 的 +1 认可后,就可以等待被 merge 到主干分支了。merge 的工作会由 Committer 来完成,Committer 会将你的分支再次 rebase 到最新的master 之上,并将多个 commits 合并成一个,完善 commit 信息,做最后的测试检查,最后会 merge 到 master 。

此时在 Flink 仓库的 commit 历史中就能看到自己的提交信息了。恭喜你成为了 code contributor!

总结

在我看来,成为 Apache Committer 的小窍门有几点:

  1. 把项目看成自己的事情,自发地,有激情地去做贡献
  2. 保持活跃,持续贡献,耐心和平常心都很重要
  3. 专注一个模块,吃透该模块的源码和原理,成为某个模块的专家
  4. 提升个人的代码品位和质量,让他人信任你的代码
  5. 勇敢地在邮件列表中参与讨论

希望通过本文能让大家了解到,成为 Contributor 并没有想象中那么难,成为 Committer 也不是不可能,只要怀有开源的热情,找到自己感兴趣的项目,在开源贡献中成长,持之以恒,付出总会有回报的。

成为 Apache Committer 不仅仅是一种光环和荣誉,更多的是一种责任,代表着社区的信任,期盼着你能为社区做更多的贡献。所以成为 Committer 远不是终点,而是一个更高起点,毕竟 Committer 之上还有 PMC 呢 ;-)。

关注微信公众号 爱解决,更多大牛在线帮你解决问题

十分钟带你了解 Apache Flink 核心技术

Apache Flink 介绍

Apache Flink 是近年来越来越流行的一款开源大数据计算引擎,它同时支持了批处理和流处理,也能用来做一些基于事件的应用。使用官网的一句话来介绍 Flink 就是 “Stateful Computations Over Streams”

首先 Flink 是一个纯流式的计算引擎,它的基本数据模型是数据流。流可以是无边界的无限流,即一般意义上的流处理。也可以是有边界的有限流,这样就是批处理。因此 Flink 用一套架构同时支持了流处理和批处理。其次,Flink 的一个优势是支持有状态的计算。如果处理一个事件(或一条数据)的结果只跟事件本身的内容有关,称为无状态处理;反之结果还和之前处理过的事件有关,称为有状态处理。稍微复杂一点的数据处理,比如说基本的聚合,数据流之间的关联都是有状态处理。

Apache Flink 之所以能越来越受欢迎,我们认为离不开它最重要的四个基石:Checkpoint、State、Time、Window。

首先是Checkpoint机制,这是 Flink 最重要的一个特性。Flink 基于 Chandy-Lamport 算法实现了分布式一致性的快照,从而提供了 exactly-once 的语义。在 Flink 之前的流计算系统(如 Strom,Samza)都没有很好地解决 exactly-once 的问题。提供了一致性的语义之后,Flink 为了让用户在编程时能够更轻松、更容易地去管理状态,引入了托管状态(managed state)并提供了 API 接口,让用户使用起来感觉就像在用 Java 的集合类一样。除此之外,Flink 还实现了 watermark 的机制,解决了基于事件时间处理时的数据乱序和数据迟到的问题。最后,流计算中的计算一般都会基于窗口来计算,所以 Flink 提供了一套开箱即用的窗口操作,包括滚动窗口、滑动窗口、会话窗口,还支持非常灵活的自定义窗口以满足特殊业务的需求。

在 Flink 1.0.0 时期,加入了 State API,即 ValueState、ReducingState、ListState 等等。State API 可以认为是 Flink 里程碑式的创新,它能够让用户像使用 Java 集合一样地使用 Flink State,却能够自动享受到状态的一致性保证,不会因为故障而丢失状态。包括后来 Apache Beam 的 State API 也从中借鉴了很多。

在 Flink 1.1.0 时期,支持了 Session Window 以及迟到数据容忍的功能。

在 Flink 1.2.0 时期,提供了 ProcessFunction,这是一个 Lower-level 的API,用于实现更高级更复杂的功能。它除了能够注册各种类型的 State 外,还支持注册定时器(支持 EventTime 和 ProcessingTime),常用于开发一些基于事件、基于时间的应用程序。

在 Flink 1.3.0 时期,提供了 Side Output 功能。算子的输出一般只有一种输出类型,但是有些时候可能需要输出另外的类型,比如除了输出主流外,还希望把一些异常数据、迟到数据以侧边流的形式进行输出,并分别交给下游不同节点进行处理。简而言之,Side Output 支持了多路输出的功能。

在 Flink 1.5.0 时期,加入了BroadcastState。BroadcastState是对 State API 的一个扩展。它用来存储上游被广播过来的数据,这个 operator 的每个并发上存的BroadcastState里面的数据都是一模一样的,因为它是从上游广播而来的。基于这种State可以比较好地去解决 CEP 中的动态规则的功能,以及 SQL 中不等值Join的场景。

在 Flink 1.6.0 时期,提供了State TTL功能、DataStream Interval Join功能。State TTL实现了在申请某个State时候可以在指定一个生命周期参数(TTL),指定该state过了多久之后需要被系统自动清除。在这个版本之前,如果用户想要实现这种状态清理操作需要使用ProcessFunction注册一个Timer,然后利用Timer的回调手动把这个State清除。从该版本开始,Flink框架可以基于TTL原生地解决这件事情。另外 DataStream Interval Join 功能也叫做 区间Join。例如左流的每一条数据去Join右流前后5分钟之内的数据,这种就是5分钟的区间Join。

在 Flink 1.0.0 时期,Table API (结构化数据处理API)和 CEP(复杂事件处理API)这两个框架被首次加入到仓库中。Table API 是一种结构化的高级 API,支持 Java 语言和 Scala 语言,类似于 Spark 的 DataFrame API。但是当时社区对于 SQL 的需求很大,而 SQL 和 Table API 非常相近,他们都是一种处理结构化数据的语言,实现上可以共用很多内容。所以在 Flink 1.1.0里面,社区基于Apache Calcite对整个 Table 模块做了重构,使得同时支持了 Table API 和 SQL 并共用了大部分代码。

在 Flink 1.2.0 时期,社区在Table API和SQL上支持丰富的内置窗口操作,包括Tumbling Window、Sliding Window、Session Window。

在 Flink 1.3.0 时期,社区首次提出了Dynamic Table这个概念,借助Dynamic Table,流和批之间可以相互进行转换。流可以是一张表,表也可以是一张流,这是流批统一的基础之一。其中Retraction机制是实现Dynamic Table的基础,基于Retraction才能够正确地实现多级Aggregate、多级Join,才能够保证流式 SQL 的语义与结果的正确性。另外,在该版本中还支持了 CEP 算子的可伸缩容(即改变并发)。

在 Flink 1.5.0 时期,在 Table API 和 SQL 上支持了Join操作,包括无限流的 Join 和带窗口的 Join。还添加了 SQL CLI 支持。SQL CLI 提供了一个类似Shell命令的对话框,可以交互式执行查询。

Checkpoint机制在Flink很早期的时候就已经支持,是Flink一个很核心的功能,Flink 社区也一直努力提升 Checkpoint 和 Recovery 的效率。

在 Flink 1.0.0 时期,提供了 RocksDB 状态后端的支持,在这个版本之前所有的状态数据只能存在进程的内存里面,JVM 内存是固定大小的,随着数据越来越多总会发生 FullGC 和 OOM 的问题,所以在生产环境中很难应用起来。如果想要存更多数据、更大的State就要用到 RocksDB。RocksDB是一款基于文件的嵌入式数据库,它会把数据存到磁盘,同时又提供高效的读写性能。所以使用RocksDB不会发生OOM这种事情。

在 Flink 1.1.0 时期,支持了 RocksDB Snapshot 的异步化。在之前的版本,RocksDB 的 Snapshot 过程是同步的,它会阻塞主数据流的处理,很影响吞吐量。在支持异步化之后,吞吐量得到了极大的提升。

在 Flink 1.2.0 时期,通过引入KeyGroup的机制,支持了 KeyedState 和 OperatorState 的可扩缩容。也就是支持了对带状态的流计算任务改变并发的功能。

在 Flink 1.3.0 时期,支持了 Incremental Checkpoint (增量检查点)机制。Incemental Checkpoint 的支持标志着 Flink 流计算任务正式达到了生产就绪状态。增量检查点是每次只将本次 checkpoint 期间新增的状态快照并持久化存储起来。一般流计算任务,GB 级别的状态,甚至 TB 级别的状态是非常常见的,如果每次都把全量的状态都刷到分布式存储中,这个效率和网络代价是很大的。如果每次只刷新增的数据,效率就会高很多。在这个版本里面还引入了细粒度的recovery的功能,细粒度的recovery在做恢复的时候,只需要恢复失败节点的联通子图,不用对整个 Job 进行恢复,这样便能够提高恢复效率。

在 Flink 1.5.0 时期,引入了本地状态恢复的机制。因为基于checkpoint机制,会把State持久化地存储到某个分布式存储,比如HDFS,当发生 failover 的时候需要重新把数据从远程HDFS再下载下来,如果这个状态特别大那么下载耗时就会较长,failover 恢复所花的时间也会拉长。本地状态恢复机制会提前将状态文件在本地也备份一份,当Job发生failover之后,恢复时可以在本地直接恢复,不需从远程HDFS重新下载状态文件,从而提升了恢复的效率。

在 Flink 1.2.0 时期,提供了Async I/O功能。Async I/O 是阿里巴巴贡献给社区的一个呼声非常高的特性,主要目的是为了解决与外部系统交互时网络延迟成为了系统瓶颈的问题。例如,为了关联某些字段需要查询外部 HBase 表,同步的方式是每次查询的操作都是阻塞的,数据流会被频繁的I/O请求卡住。当使用异步I/O之后就可以同时地发起N个异步查询的请求,不会阻塞主数据流,这样便提升了整个job的吞吐量,提升CPU利用率。

在 Flink 1.3.0 时期,引入了HistoryServer的模块。HistoryServer主要功能是当job结束以后,会把job的状态以及信息都进行归档,方便后续开发人员做一些深入排查。

在 Flink 1.4.0 时期,提供了端到端的 exactly-once 的语义保证。Exactly-once 是指每条输入的数据只会作用在最终结果上有且只有一次,即使发生软件或硬件的故障,不会有丢数据或者重复计算发生。而在该版本之前,exactly-once 保证的范围只是 Flink 应用本身,并不包括输出给外部系统的部分。在 failover 时,这就有可能写了重复的数据到外部系统,所以一般会使用幂等的外部系统来解决这个问题。在 Flink 1.4 的版本中,Flink 基于两阶段提交协议,实现了端到端的 exactly-once 语义保证。内置支持了 Kafka 的端到端保证,并提供了 TwoPhaseCommitSinkFunction 供用于实现自定义外部存储的端到端 exactly-once 保证。

在 Flink 1.5.0 时期,Flink 发布了新的部署模型和处理模型(FLIP6)。新部署模型的开发工作已经持续了很久,该模型的实现对Flink核心代码改动特别大,可以说是自 Flink 项目创建以来,Runtime 改动最大的一次。简而言之,新的模型可以在YARN, MESOS调度系统上更好地动态分配资源、动态释放资源,并实现更高的资源利用率,还有提供更好的作业之间的隔离。

除了 FLIP6 的改进,在该版本中,还对网站栈做了重构。重构的原因是在老版本中,上下游多个 task 之间的通信会共享同一个 TCP connection,导致某一个 task 发生反压时,所有共享该连接的 task 都会被阻塞,反压的粒度是 TCP connection 级别的。为了改进反压机制,Flink应用了在解决网络拥塞时一种经典的流控方法——基于Credit的流量控制。使得流控的粒度精细到具体某个 task 级别,有效缓解了反压对吞吐量的影响。

总结

Flink 同时支持了流处理和批处理,目前流计算的模型已经相对比较成熟和领先,也经历了各个公司大规模生产的验证。社区在接下来将继续加强流计算方面的性能和功能,包括对 Flink SQL 扩展更丰富的功能和引入更多的优化。另一方面也将加大力量提升批处理、机器学习等生态上的能力。

关注微信公众号 爱解决,更多技术大牛帮你解决问题!

Java开发中的23种设计模式详解

【放弃了原文访问者模式的Demo,自己写了一个新使用场景的Demo,加上了自己的理解】

      【源码地址:https://github.com/leon66666/DesignPattern

      一、设计模式的分类

      总体来说设计模式分为三大类:

      创建型模式,共五种:工厂方法模式、抽象工厂模式、单例模式、建造者模式、原型模式。

      结构型模式,共七种:适配器模式、装饰器模式、代理模式、外观模式、桥接模式、组合模式、享元模式。

      行为型模式,共十一种:策略模式、模板方法模式、观察者模式、迭代子模式、责任链模式、命令模式、备忘录模式、状态模式、访问者模式、中介者模式、解释器模式。

      其实还有两类:并发型模式和线程池模式。用一个图片来整体描述一下:

      二、设计模式的六大原则

      1、开闭原则(Open Close Principle)

      开闭原则就是说对扩展开放,对修改关闭。在程序需要进行拓展的时候,不能去修改原有的代码,实现一个热插拔的效果。所以一句话概括就是:为了使程序的扩展性好,易于维护和升级。想要达到这样的效果,我们需要使用接口和抽象类,后面的具体设计中我们会提到这点。

      2、里氏代换原则(Liskov Substitution Principle)

      里氏代换原则(Liskov Substitution Principle LSP)面向对象设计的基本原则之一。 里氏代换原则中说,任何基类可以出现的地方,子类一定可以出现。 LSP是继承复用的基石,只有当衍生类可以替换掉基类,软件单位的功能不受到影响时,基类才能真正被复用,而衍生类也能够在基类的基础上增加新的行为。里氏代换原则是对“开-闭”原则的补充。实现“开-闭”原则的关键步骤就是抽象化。而基类与子类的继承关系就是抽象化的具体实现,所以里氏代换原则是对实现抽象化的具体步骤的规范。—— From Baidu 百科

      3、依赖倒转原则(Dependence Inversion Principle)

      这个是开闭原则的基础,具体内容:真对接口编程,依赖于抽象而不依赖于具体。

      4、接口隔离原则(Interface Segregation Principle)

      这个原则的意思是:使用多个隔离的接口,比使用单个接口要好。还是一个降低类之间的耦合度的意思,从这儿我们看出,其实设计模式就是一个软件的设计思想,从大型软件架构出发,为了升级和维护方便。所以上文中多次出现:降低依赖,降低耦合。

      5、迪米特法则(最少知道原则)(Demeter Principle)

      为什么叫最少知道原则,就是说:一个实体应当尽量少的与其他实体之间发生相互作用,使得系统功能模块相对独立。

      6、合成复用原则(Composite Reuse Principle)

      原则是尽量使用合成/聚合的方式,而不是使用继承。

      三、Java的23中设计模式

      从这一块开始,我们详细介绍Java中23种设计模式的概念,应用场景等情况,并结合他们的特点及设计模式的原则进行分析。

      1、工厂方法模式(Factory Method)

      工厂方法模式分为三种:

      11、普通工厂模式,就是建立一个工厂类,对实现了同一接口的一些类进行实例的创建。首先看下关系图:

      举例如下:(我们举一个发送邮件和短信的例子)

      首先,创建二者的共同接口:

      [java] view plaincopy

  1.       public interface Sender {  
  2.       public void Send();  
  3.       }  

      其次,创建实现类:

      [java] view plaincopy

  1.       public class MailSender implements Sender {  
  2.       @Override  
  3.       public void Send() {  
  4.       System.out.println(“this is mailsender!”);  
  5.       }  
  6.       }  

      [java] view plaincopy

  1.       public class SmsSender implements Sender {  
  2.       @Override  
  3.       public void Send() {  
  4.       System.out.println(“this is sms sender!”);  
  5.       }  
  6.       }  

      最后,建工厂类:

      [java] view plaincopy

  1.       public class SendFactory {  
  2.       public Sender produce(String type) {  
  3.       if (“mail”.equals(type)) {  
  4.       return new MailSender();  
  5.       } else if (“sms”.equals(type)) {  
  6.       return new SmsSender();  
  7.       } else {  
  8.       System.out.println(“请输入正确的类型!”);  
  9.       return null;  
  10.       }  
  11.       }  
  12.       }  

      我们来测试下:

  1.       public class FactoryTest {  
  2.       public static void main(String[] args) {  
  3.       SendFactory factory = new SendFactory();  
  4.       Sender sender = factory.produce(“sms”);  
  5.       sender.Send();  
  6.       }  
  7.       }  

      输出:this is sms sender!

      22、多个工厂方法模式,是对普通工厂方法模式的改进,在普通工厂方法模式中,如果传递的字符串出错,则不能正确创建对象,而多个工厂方法模式是提供多个工厂方法,分别创建对象。关系图:

      将上面的代码做下修改,改动下SendFactory类就行,如下:

      [java] view plaincopypublic class SendFactory {  

      public Sender produceMail(){  

  1.       return new MailSender();  
  2.       }  
  3.       public Sender produceSms(){  
  4.       return new SmsSender();  
  5.       }  
  6.       }  

      测试类如下:

      [java] view plaincopy

  1.       public class FactoryTest {  
  2.       public static void main(String[] args) {  
  3.       SendFactory factory = new SendFactory();  
  4.       Sender sender = factory.produceMail();  
  5.       sender.Send();  
  6.       }  
  7.       }  

      输出:this is mailsender!

      33、静态工厂方法模式,将上面的多个工厂方法模式里的方法置为静态的,不需要创建实例,直接调用即可。

      [java] view plaincopy

  1.       public class SendFactory {  
  2.       public static Sender produceMail(){  
  3.       return new MailSender();  
  4.       }  
  5.       public static Sender produceSms(){  
  6.       return new SmsSender();  
  7.       }  
  8.       }  

      [java] view plaincopy

  1.       public class FactoryTest {  
  2.       public static void main(String[] args) {      
  3.       Sender sender = SendFactory.produceMail();  
  4.       sender.Send();  
  5.       }  
  6.       }  

      输出:this is mailsender!

      总体来说,工厂模式适合:凡是出现了大量的产品需要创建,并且具有共同的接口时,可以通过工厂方法模式进行创建。在以上的三种模式中,第一种如果传入的字符串有误,不能正确创建对象,第三种相对于第二种,不需要实例化工厂类,所以,大多数情况下,我们会选用第三种——静态工厂方法模式。

      2、抽象工厂模式(Abstract Factory)

      工厂方法模式有一个问题就是,类的创建依赖工厂类,也就是说,如果想要拓展程序,必须对工厂类进行修改,这违背了闭包原则,所以,从设计角度考虑,有一定的问题,如何解决?就用到抽象工厂模式,创建多个工厂类,这样一旦需要增加新的功能,直接增加新的工厂类就可以了,不需要修改之前的代码。因为抽象工厂不太好理解,我们先看看图,然后就和代码,就比较容易理解。

      请看例子:

      [java] view plaincopy

  1.       public interface Sender {  
  2.       public void Send();  
  3.       }  

      两个实现类:

      [java] view plaincopy

  1.       public class MailSender implements Sender {  
  2.       @Override  
  3.       public void Send() {  
  4.       System.out.println(“this is mailsender!”);  
  5.       }  
  6.       }  

      [java] view plaincopy

  1.       public class SmsSender implements Sender {  
  2.       @Override  
  3.       public void Send() {  
  4.       System.out.println(“this is sms sender!”);  
  5.       }  
  6.       }  

      两个工厂类:

      [java] view plaincopy

  1.       public class SendMailFactory implements Provider {  
  2.       @Override  
  3.       public Sender produce(){  
  4.       return new MailSender();  
  5.       }  
  6.       }  

      [java] view plaincopy

  1.       public class SendSmsFactory implements Provider{  
  2.       @Override  
  3.       public Sender produce() {  
  4.       return new SmsSender();  
  5.       }  
  6.       }  

      在提供一个接口:

      [java] view plaincopy

  1.       public interface Provider {  
  2.       public Sender produce();  
  3.       }  

      测试类:

      [java] view plaincopy

  1.       public class Test {  
  2.       public static void main(String[] args) {  
  3.       Provider provider = new SendMailFactory();  
  4.       Sender sender = provider.produce();  
  5.       sender.Send();  
  6.       }  
  7.       }  

      其实这个模式的好处就是,如果你现在想增加一个功能:发及时信息,则只需做一个实现类,实现Sender接口,同时做一个工厂类,实现Provider接口,就OK了,无需去改动现成的代码。这样做,拓展性较好!

      3、单例模式(Singleton)

      单例对象(Singleton)是一种常用的设计模式。在Java应用中,单例对象能保证在一个JVM中,该对象只有一个实例存在。这样的模式有几个好处:

      1、某些类创建比较频繁,对于一些大型的对象,这是一笔很大的系统开销。

      2、省去了new操作符,降低了系统内存的使用频率,减轻GC压力。

      3、有些类如交易所的核心交易引擎,控制着交易流程,如果该类可以创建多个的话,系统完全乱了。(比如一个军队出现了多个司令员同时指挥,肯定会乱成一团),所以只有使用单例模式,才能保证核心交易服务器独立控制整个流程。

      首先我们写一个简单的单例类:

      [java] view plaincopy

  1.       public class Singleton {  
  2.       /* 持有私有静态实例,防止被引用,此处赋值为null,目的是实现延迟加载 */  
  3.       private static Singleton instance = null;  
  4.       /* 私有构造方法,防止被实例化 */  
  5.       private Singleton() {  
  6.       }  
  7.       /* 静态工程方法,创建实例 */  
  8.       public static Singleton getInstance() {  
  9.       if (instance == null) {  
  10.       instance = new Singleton();  
  11.       }  
  12.       return instance;  
  13.       }  
  14.       /* 如果该对象被用于序列化,可以保证对象在序列化前后保持一致 */  
  15.       public Object readResolve() {  
  16.       return instance;  
  17.       }  
  18.       }  

      这个类可以满足基本要求,但是,像这样毫无线程安全保护的类,如果我们把它放入多线程的环境下,肯定就会出现问题了,如何解决?我们首先会想到对getInstance方法加synchronized关键字,如下:

      [java] view plaincopy

  1.       public static synchronized Singleton getInstance() {  
  2.       if (instance == null) {  
  3.       instance = new Singleton();  
  4.       }  
  5.       return instance;  
  6.       }  

      但是,synchronized关键字锁住的是这个对象,这样的用法,在性能上会有所下降,因为每次调用getInstance(),都要对对象上锁,事实上,只有在第一次创建对象的时候需要加锁,之后就不需要了,所以,这个地方需要改进。我们改成下面这个:

      [java] view plaincopy

  1.       public static Singleton getInstance() {  
  2.       if (instance == null) {  
  3.       synchronized (instance) {  
  4.       if (instance == null) {  
  5.       instance = new Singleton();  
  6.       }  
  7.       }  
  8.       }  
  9.       return instance;  
  10.       }  

      似乎解决了之前提到的问题,将synchronized关键字加在了内部,也就是说当调用的时候是不需要加锁的,只有在instance为null,并创建对象的时候才需要加锁,性能有一定的提升。但是,这样的情况,还是有可能有问题的,看下面的情况:在Java指令中创建对象和赋值操作是分开进行的,也就是说instance = new Singleton();语句是分两步执行的。但是JVM并不保证这两个操作的先后顺序,也就是说有可能JVM会为新的Singleton实例分配空间,然后直接赋值给instance成员,然后再去初始化这个Singleton实例。这样就可能出错了,我们以A、B两个线程为例:

      a>A、B线程同时进入了第一个if判断

      b>A首先进入synchronized块,由于instance为null,所以它执行instance = new Singleton();

      c>由于JVM内部的优化机制,JVM先画出了一些分配给Singleton实例的空白内存,并赋值给instance成员(注意此时JVM没有开始初始化这个实例),然后A离开了synchronized块。

      d>B进入synchronized块,由于instance此时不是null,因此它马上离开了synchronized块并将结果返回给调用该方法的程序。

      e>此时B线程打算使用Singleton实例,却发现它没有被初始化,于是错误发生了。

      所以程序还是有可能发生错误,其实程序在运行过程是很复杂的,从这点我们就可以看出,尤其是在写多线程环境下的程序更有难度,有挑战性。我们对该程序做进一步优化:

      [java] view plaincopy

  1.       private static class SingletonFactory{           
  2.       private static Singleton instance = new Singleton();           
  3.       }           
  4.       public static Singleton getInstance(){           
  5.       return SingletonFactory.instance;           
  6.       }   

      实际情况是,单例模式使用内部类来维护单例的实现,JVM内部的机制能够保证当一个类被加载的时候,这个类的加载过程是线程互斥的。这样当我们第一次调用getInstance的时候,JVM能够帮我们保证instance只被创建一次,并且会保证把赋值给instance的内存初始化完毕,这样我们就不用担心上面的问题。同时该方法也只会在第一次调用的时候使用互斥机制,这样就解决了低性能问题。这样我们暂时总结一个完美的单例模式:

      [java] view plaincopy

  1.       public class Singleton {  
  2.       /* 私有构造方法,防止被实例化 */  
  3.       private Singleton() {  
  4.       }  
  5.       /* 此处使用一个内部类来维护单例 */  
  6.       private static class SingletonFactory {  
  7.       private static Singleton instance = new Singleton();  
  8.       }  
  9.       /* 获取实例 */  
  10.       public static Singleton getInstance() {  
  11.       return SingletonFactory.instance;  
  12.       }  
  13.       /* 如果该对象被用于序列化,可以保证对象在序列化前后保持一致 */  
  14.       public Object readResolve() {  
  15.       return getInstance();  
  16.       }  
  17.       }  

      其实说它完美,也不一定,如果在构造函数中抛出异常,实例将永远得不到创建,也会出错。所以说,十分完美的东西是没有的,我们只能根据实际情况,选择最适合自己应用场景的实现方法。也有人这样实现:因为我们只需要在创建类的时候进行同步,所以只要将创建和getInstance()分开,单独为创建加synchronized关键字,也是可以的:

      [java] view plaincopy

  1.       public class SingletonTest {  
  2.       private static SingletonTest instance = null;  
  3.       private SingletonTest() {  
  4.       }  
  5.       private static synchronized void syncInit() {  
  6.       if (instance == null) {  
  7.       instance = new SingletonTest();  
  8.       }  
  9.       }  
  10.       public static SingletonTest getInstance() {  
  11.       if (instance == null) {  
  12.       syncInit();  
  13.       }  
  14.       return instance;  
  15.       }  
  16.       }  

      考虑性能的话,整个程序只需创建一次实例,所以性能也不会有什么影响。

      补充:采用”影子实例”的办法为单例对象的属性同步更新

      [java] view plaincopy

  1.       public class SingletonTest {  
  2.       private static SingletonTest instance = null;  
  3.       private Vector properties = null;  
  4.       public Vector getProperties() {  
  5.       return properties;  
  6.       }  
  7.       private SingletonTest() {  
  8.       }  
  9.       private static synchronized void syncInit() {  
  10.       if (instance == null) {  
  11.       instance = new SingletonTest();  
  12.       }  
  13.       }  
  14.       public static SingletonTest getInstance() {  
  15.       if (instance == null) {  
  16.       syncInit();  
  17.       }  
  18.       return instance;  
  19.       }  
  20.       public void updateProperties() {  
  21.       SingletonTest shadow = new SingletonTest();  
  22.       properties = shadow.getProperties();  
  23.       }  
  24.       }  

      通过单例模式的学习告诉我们:

      1、单例模式理解起来简单,但是具体实现起来还是有一定的难度。

      2、synchronized关键字锁定的是对象,在用的时候,一定要在恰当的地方使用(注意需要使用锁的对象和过程,可能有的时候并不是整个对象及整个过程都需要锁)。

      到这儿,单例模式基本已经讲完了,结尾处,笔者突然想到另一个问题,就是采用类的静态方法,实现单例模式的效果,也是可行的,此处二者有什么不同?

      首先,静态类不能实现接口。(从类的角度说是可以的,但是那样就破坏了静态了。因为接口中不允许有static修饰的方法,所以即使实现了也是非静态的)

      其次,单例可以被延迟初始化,静态类一般在第一次加载是初始化。之所以延迟加载,是因为有些类比较庞大,所以延迟加载有助于提升性能。

      再次,单例类可以被继承,他的方法可以被覆写。但是静态类内部方法都是static,无法被覆写。

      最后一点,单例类比较灵活,毕竟从实现上只是一个普通的Java类,只要满足单例的基本需求,你可以在里面随心所欲的实现一些其它功能,但是静态类不行。从上面这些概括中,基本可以看出二者的区别,但是,从另一方面讲,我们上面最后实现的那个单例模式,内部就是用一个静态类来实现的,所以,二者有很大的关联,只是我们考虑问题的层面不同罢了。两种思想的结合,才能造就出完美的解决方案,就像HashMap采用数组+链表来实现一样,其实生活中很多事情都是这样,单用不同的方法来处理问题,总是有优点也有缺点,最完美的方法是,结合各个方法的优点,才能最好的解决问题!

      4、建造者模式(Builder)

      工厂类模式提供的是创建单个类的模式,而建造者模式则是将各种产品集中起来进行管理,用来创建复合对象,所谓复合对象就是指某个类具有不同的属性,其实建造者模式就是前面抽象工厂模式和最后的Test结合起来得到的。我们看一下代码:

      还和前面一样,一个Sender接口,两个实现类MailSender和SmsSender。最后,建造者类如下:

      [java] view plaincopy

  1.       public class Builder {  
  2.       private List<Sender> list = new ArrayList<Sender>();  
  3.       public void produceMailSender(int count){  
  4.       for(int i=0; i<count; i++){  
  5.       list.add(new MailSender());  
  6.       }  
  7.       }  
  8.       public void produceSmsSender(int count){  
  9.       for(int i=0; i<count; i++){  
  10.       list.add(new SmsSender());  
  11.       }  
  12.       }  
  13.       }  

      测试类:

      [java] view plaincopy

  1.       public class Test {  
  2.       public static void main(String[] args) {  
  3.       Builder builder = new Builder();  
  4.       builder.produceMailSender(10);  
  5.       }  
  6.       }  

      从这点看出,建造者模式将很多功能集成到一个类里,这个类可以创造出比较复杂的东西。所以与工程模式的区别就是:工厂模式关注的是创建单个产品,而建造者模式则关注创建符合对象,多个部分。因此,是选择工厂模式还是建造者模式,依实际情况而定。

      5、原型模式(Prototype)

      原型模式虽然是创建型的模式,但是与工程模式没有关系,从名字即可看出,该模式的思想就是将一个对象作为原型,对其进行复制、克隆,产生一个和原对象类似的新对象。本小结会通过对象的复制,进行讲解。在Java中,复制对象是通过clone()实现的,先创建一个原型类:

      [java] view plaincopy

  1.       public class Prototype implements Cloneable {  
  2.       public Object clone() throws CloneNotSupportedException {  
  3.       Prototype proto = (Prototype) super.clone();  
  4.       return proto;  
  5.       }  
  6.       }  

      很简单,一个原型类,只需要实现Cloneable接口,覆写clone方法,此处clone方法可以改成任意的名称,因为Cloneable接口是个空接口,你可以任意定义实现类的方法名,如cloneA或者cloneB,因为此处的重点是super.clone()这句话,super.clone()调用的是Object的clone()方法,而在Object类中,clone()是native的,具体怎么实现,我会在另一篇文章中,关于解读Java中本地方法的调用,此处不再深究。在这儿,我将结合对象的浅复制和深复制来说一下,首先需要了解对象深、浅复制的概念:

      浅复制:将一个对象复制后,基本数据类型的变量都会重新创建,而引用类型,指向的还是原对象所指向的。

      深复制:将一个对象复制后,不论是基本数据类型还有引用类型,都是重新创建的。简单来说,就是深复制进行了完全彻底的复制,而浅复制不彻底。

      此处,写一个深浅复制的例子:

      [java] view plaincopy

  1.       public class Prototype implements Cloneable, Serializable {  
  2.       private static final long serialVersionUID = 1L;  
  3.       private String string;  
  4.       private SerializableObject obj;  
  5.       /* 浅复制 */  
  6.       public Object clone() throws CloneNotSupportedException {  
  7.       Prototype proto = (Prototype) super.clone();  
  8.       return proto;  
  9.       }  
  10.       /* 深复制 */  
  11.       public Object deepClone() throws IOException, ClassNotFoundException {  
  12.       /* 写入当前对象的二进制流 */  
  13.       ByteArrayOutputStream bos = new ByteArrayOutputStream();  
  14.       ObjectOutputStream oos = new ObjectOutputStream(bos);  
  15.       oos.writeObject(this);  
  16.       /* 读出二进制流产生的新对象 */  
  17.       ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());  
  18.       ObjectInputStream ois = new ObjectInputStream(bis);  
  19.       return ois.readObject();  
  20.       }  
  21.       public String getString() {  
  22.       return string;  
  23.       }  
  24.       public void setString(String string) {  
  25.       this.string = string;  
  26.       }  
  27.       public SerializableObject getObj() {  
  28.       return obj;  
  29.       }  
  30.       public void setObj(SerializableObject obj) {  
  31.       this.obj = obj;  
  32.       }  
  33.       }  
  34.       class SerializableObject implements Serializable {  
  35.       private static final long serialVersionUID = 1L;  
  36.       }  

      要实现深复制,需要采用流的形式读入当前对象的二进制输入,再写出二进制数据对应的对象。

      我们接着讨论设计模式,上篇文章我讲完了5种创建型模式,这章开始,我将讲下7种结构型模式:适配器模式、装饰模式、代理模式、外观模式、桥接模式、组合模式、享元模式。其中对象的适配器模式是各种模式的起源,我们看下面的图:

      适配器模式将某个类的接口转换成客户端期望的另一个接口表示,目的是消除由于接口不匹配所造成的类的兼容性问题。主要分为三类:类的适配器模式、对象的适配器模式、接口的适配器模式。首先,我们来看看类的适配器模式,先看类图:

      核心思想就是:有一个Source类,拥有一个方法,待适配,目标接口时Targetable,通过Adapter类,将Source的功能扩展到Targetable里,看代码:

      [java] view plaincopy

  1.       public class Source {  
  2.       public void method1() {  
  3.       System.out.println(“this is original method!”);  
  4.       }  
  5.       }  

      [java] view plaincopy

  1.       public interface Targetable {  
  2.       /* 与原类中的方法相同 */  
  3.       public void method1();  
  4.       /* 新类的方法 */  
  5.       public void method2();  
  6.       }  

      [java] view plaincopy

  1.       public class Adapter extends Source implements Targetable {  
  2.       @Override  
  3.       public void method2() {  
  4.       System.out.println(“this is the targetable method!”);  
  5.       }  
  6.       }  

      Adapter类继承Source类,实现Targetable接口,下面是测试类:

      [java] view plaincopy

  1.       public class AdapterTest {  
  2.       public static void main(String[] args) {  
  3.       Targetable target = new Adapter();  
  4.       target.method1();  
  5.       target.method2();  
  6.       }  
  7.       }  

      输出:

      this is original method!
      this is the targetable method!

      这样Targetable接口的实现类就具有了Source类的功能。

      对象的适配器模式

      基本思路和类的适配器模式相同,只是将Adapter类作修改,这次不继承Source类,而是持有Source类的实例,以达到解决兼容性的问题。看图:

      只需要修改Adapter类的源码即可:

      [java] view plaincopy

  1.       public class Wrapper implements Targetable {  
  2.       private Source source;  
  3.       public Wrapper(Source source){  
  4.       super();  
  5.       this.source = source;  
  6.       }  
  7.       @Override  
  8.       public void method2() {  
  9.       System.out.println(“this is the targetable method!”);  
  10.       }  
  11.       @Override  
  12.       public void method1() {  
  13.       source.method1();  
  14.       }  
  15.       }  

      测试类:

      [java] view plaincopy

  1.       public class AdapterTest {  
  2.       public static void main(String[] args) {  
  3.       Source source = new Source();  
  4.       Targetable target = new Wrapper(source);  
  5.       target.method1();  
  6.       target.method2();  
  7.       }  
  8.       }  

      输出与第一种一样,只是适配的方法不同而已。

      第三种适配器模式是接口的适配器模式,接口的适配器是这样的:有时我们写的一个接口中有多个抽象方法,当我们写该接口的实现类时,必须实现该接口的所有方法,这明显有时比较浪费,因为并不是所有的方法都是我们需要的,有时只需要某一些,此处为了解决这个问题,我们引入了接口的适配器模式,借助于一个抽象类,该抽象类实现了该接口,实现了所有的方法,而我们不和原始的接口打交道,只和该抽象类取得联系,所以我们写一个类,继承该抽象类,重写我们需要的方法就行。看一下类图:

      这个很好理解,在实际开发中,我们也常会遇到这种接口中定义了太多的方法,以致于有时我们在一些实现类中并不是都需要。看代码:

      [java] view plaincopy

  1.       public interface Sourceable {  
  2.       public void method1();  
  3.       public void method2();  
  4.       }  

      抽象类Wrapper2:

      [java] view plaincopy

  1.       public abstract class Wrapper2 implements Sourceable{  
  2.       public void method1(){}  
  3.       public void method2(){}  
  4.       }  

      [java] view plaincopy

  1.       public class SourceSub1 extends Wrapper2 {  
  2.       public void method1(){  
  3.       System.out.println(“the sourceable interface’s first Sub1!”);  
  4.       }  
  5.       }  

      [java] view plaincopy

  1.       public class SourceSub2 extends Wrapper2 {  
  2.       public void method2(){  
  3.       System.out.println(“the sourceable interface’s second Sub2!”);  
  4.       }  
  5.       }  

      [java] view plaincopy

  1.       public class WrapperTest {  
  2.       public static void main(String[] args) {  
  3.       Sourceable source1 = new SourceSub1();  
  4.       Sourceable source2 = new SourceSub2();  
  5.       source1.method1();  
  6.       source1.method2();  
  7.       source2.method1();  
  8.       source2.method2();  
  9.       }  
  10.       }  

      测试输出:

      the sourceable interface’s first Sub1!
      the sourceable interface’s second Sub2!

      达到了我们的效果!

      讲了这么多,总结一下三种适配器模式的应用场景:

      类的适配器模式:当希望将一个类转换成满足另一个新接口的类时,可以使用类的适配器模式,创建一个新类,继承原有的类,实现新的接口即可。

      对象的适配器模式:当希望将一个对象转换成满足另一个新接口的对象时,可以创建一个Wrapper类,持有原类的一个实例,在Wrapper类的方法中,调用实例的方法就行。

      接口的适配器模式:当不希望实现一个接口中所有的方法时,可以创建一个抽象类Wrapper,实现所有方法,我们写别的类的时候,继承抽象类即可。

      7、装饰模式(Decorator)

      顾名思义,装饰模式就是给一个对象增加一些新的功能,而且是动态的,要求装饰对象和被装饰对象实现同一个接口,装饰对象持有被装饰对象的实例,关系图如下:

      Source类是被装饰类,Decorator类是一个装饰类,可以为Source类动态的添加一些功能,代码如下:

      [java] view plaincopy

  1.       public interface Sourceable {  
  2.       public void method();  
  3.       }  

      [java] view plaincopy

  1.       public class Source implements Sourceable {  
  2.       @Override  
  3.       public void method() {  
  4.       System.out.println(“the original method!”);  
  5.       }  
  6.       }  

      [java] view plaincopy

  1.       public class Decorator implements Sourceable {  
  2.       private Sourceable source;  
  3.       public Decorator(Sourceable source){  
  4.       super();  
  5.       this.source = source;  
  6.       }  
  7.       @Override  
  8.       public void method() {  
  9.       System.out.println(“before decorator!”);  
  10.       source.method();  
  11.       System.out.println(“after decorator!”);  
  12.       }  
  13.       }  

      测试类:

      [java] view plaincopy

  1.       public class DecoratorTest {  
  2.       public static void main(String[] args) {  
  3.       Sourceable source = new Source();  
  4.       Sourceable obj = new Decorator(source);  
  5.       obj.method();  
  6.       }  
  7.       }  

      输出:

      before decorator!
      the original method!
      after decorator!

      装饰器模式的应用场景:

      1、需要扩展一个类的功能。

      2、动态的为一个对象增加功能,而且还能动态撤销。(继承不能做到这一点,继承的功能是静态的,不能动态增删。)

      缺点:产生过多相似的对象,不易排错!

      8、代理模式(Proxy)

      其实每个模式名称就表明了该模式的作用,代理模式就是多一个代理类出来,替原对象进行一些操作,比如我们在租房子的时候回去找中介,为什么呢?因为你对该地区房屋的信息掌握的不够全面,希望找一个更熟悉的人去帮你做,此处的代理就是这个意思。再如我们有的时候打官司,我们需要请律师,因为律师在法律方面有专长,可以替我们进行操作,表达我们的想法。先来看看关系图:

      根据上文的阐述,代理模式就比较容易的理解了,我们看下代码:

      [java] view plaincopy

  1.       public interface Sourceable {  
  2.       public void method();  
  3.       }  

      [java] view plaincopy

  1.       public class Source implements Sourceable {  
  2.       @Override  
  3.       public void method() {  
  4.       System.out.println(“the original method!”);  
  5.       }  
  6.       }  

      [java] view plaincopy

  1.       public class Proxy implements Sourceable {  
  2.       private Source source;  
  3.       public Proxy(){  
  4.       super();  
  5.       this.source = new Source();  
  6.       }  
  7.       @Override  
  8.       public void method() {  
  9.       before();  
  10.       source.method();  
  11.       atfer();  
  12.       }  
  13.       private void atfer() {  
  14.       System.out.println(“after proxy!”);  
  15.       }  
  16.       private void before() {  
  17.       System.out.println(“before proxy!”);  
  18.       }  
  19.       }  

      测试类:

      [java] view plaincopy

  1.       public class ProxyTest {  
  2.       public static void main(String[] args) {  
  3.       Sourceable source = new Proxy();  
  4.       source.method();  
  5.       }  
  6.       }  

      输出:

      before proxy!
      the original method!
      after proxy!

      代理模式的应用场景:

      如果已有的方法在使用的时候需要对原有的方法进行改进,此时有两种办法:

      1、修改原有的方法来适应。这样违反了“对扩展开放,对修改关闭”的原则。

      2、就是采用一个代理类调用原有的方法,且对产生的结果进行控制。这种方法就是代理模式。

      使用代理模式,可以将功能划分的更加清晰,有助于后期维护!

      9、外观模式(Facade)

      外观模式是为了解决类与类之家的依赖关系的,像spring一样,可以将类和类之间的关系配置到配置文件中,而外观模式就是将他们的关系放在一个Facade类中,降低了类类之间的耦合度,该模式中没有涉及到接口,看下类图:(我们以一个计算机的启动过程为例)

      我们先看下实现类:

      [java] view plaincopy

  1.       public class CPU {  
  2.       public void startup(){  
  3.       System.out.println(“cpu startup!”);  
  4.       }  
  5.       public void shutdown(){  
  6.       System.out.println(“cpu shutdown!”);  
  7.       }  
  8.       }  

      [java] view plaincopy

  1.       public class Memory {  
  2.       public void startup(){  
  3.       System.out.println(“memory startup!”);  
  4.       }  
  5.       public void shutdown(){  
  6.       System.out.println(“memory shutdown!”);  
  7.       }  
  8.       }  

      [java] view plaincopy

  1.       public class Disk {  
  2.       public void startup(){  
  3.       System.out.println(“disk startup!”);  
  4.       }  
  5.       public void shutdown(){  
  6.       System.out.println(“disk shutdown!”);  
  7.       }  
  8.       }  

      [java] view plaincopy

  1.       public class Computer {  
  2.       private CPU cpu;  
  3.       private Memory memory;  
  4.       private Disk disk;  
  5.       public Computer(){  
  6.       cpu = new CPU();  
  7.       memory = new Memory();  
  8.       disk = new Disk();  
  9.       }  
  10.       public void startup(){  
  11.       System.out.println(“start the computer!”);  
  12.       cpu.startup();  
  13.       memory.startup();  
  14.       disk.startup();  
  15.       System.out.println(“start computer finished!”);  
  16.       }  
  17.       public void shutdown(){  
  18.       System.out.println(“begin to close the computer!”);  
  19.       cpu.shutdown();  
  20.       memory.shutdown();  
  21.       disk.shutdown();  
  22.       System.out.println(“computer closed!”);  
  23.       }  
  24.       }  

      User类如下:

      [java] view plaincopy

  1.       public class User {  
  2.       public static void main(String[] args) {  
  3.       Computer computer = new Computer();  
  4.       computer.startup();  
  5.       computer.shutdown();  
  6.       }  
  7.       }  

      输出:

      start the computer!
      cpu startup!
      memory startup!
      disk startup!
      start computer finished!
      begin to close the computer!
      cpu shutdown!
      memory shutdown!
      disk shutdown!
      computer closed!

      如果我们没有Computer类,那么,CPU、Memory、Disk他们之间将会相互持有实例,产生关系,这样会造成严重的依赖,修改一个类,可能会带来其他类的修改,这不是我们想要看到的,有了Computer类,他们之间的关系被放在了Computer类里,这样就起到了解耦的作用,这,就是外观模式!

      10、桥接模式(Bridge)

      桥接模式就是把事物和其具体实现分开,使他们可以各自独立的变化。桥接的用意是:将抽象化与实现化解耦,使得二者可以独立变化,像我们常用的JDBC桥DriverManager一样,JDBC进行连接数据库的时候,在各个数据库之间进行切换,基本不需要动太多的代码,甚至丝毫不用动,原因就是JDBC提供统一接口,每个数据库提供各自的实现,用一个叫做数据库驱动的程序来桥接就行了。我们来看看关系图:

      实现代码:

      先定义接口:

      [java] view plaincopy

  1.       public interface Sourceable {  
  2.       public void method();  
  3.       }  

      分别定义两个实现类:

      [java] view plaincopy

  1.       public class SourceSub1 implements Sourceable {  
  2.       @Override  
  3.       public void method() {  
  4.       System.out.println(“this is the first sub!”);  
  5.       }  
  6.       }  

      [java] view plaincopy

  1.       public class SourceSub2 implements Sourceable {  
  2.       @Override  
  3.       public void method() {  
  4.       System.out.println(“this is the second sub!”);  
  5.       }  
  6.       }  

      定义一个桥,持有Sourceable的一个实例:

      [java] view plaincopy

  1.       public abstract class Bridge {  
  2.       private Sourceable source;  
  3.       public void method(){  
  4.       source.method();  
  5.       }  
  6.       public Sourceable getSource() {  
  7.       return source;  
  8.       }  
  9.       public void setSource(Sourceable source) {  
  10.       this.source = source;  
  11.       }  
  12.       }  

      [java] view plaincopy

  1.       public class MyBridge extends Bridge {  
  2.       public void method(){  
  3.       getSource().method();  
  4.       }  
  5.       }  

      测试类:

      [java] view plaincopy

  1.       public class BridgeTest {  
  2.       public static void main(String[] args) {  
  3.       Bridge bridge = new MyBridge();  
  4.       /*调用第一个对象*/  
  5.       Sourceable source1 = new SourceSub1();  
  6.       bridge.setSource(source1);  
  7.       bridge.method();  
  8.       /*调用第二个对象*/  
  9.       Sourceable source2 = new SourceSub2();  
  10.       bridge.setSource(source2);  
  11.       bridge.method();  
  12.       }  
  13.       }  

      output:

      this is the first sub!
      this is the second sub!

      这样,就通过对Bridge类的调用,实现了对接口Sourceable的实现类SourceSub1和SourceSub2的调用。接下来我再画个图,大家就应该明白了,因为这个图是我们JDBC连接的原理,有数据库学习基础的,一结合就都懂了。

      11、组合模式(Composite)

      组合模式有时又叫部分-整体模式在处理类似树形结构的问题时比较方便,看看关系图:

      直接来看代码:

      [java] view plaincopy

  1.       public class TreeNode {  
  2.       private String name;  
  3.       private TreeNode parent;  
  4.       private Vector<TreeNode> children = new Vector<TreeNode>();  
  5.       public TreeNode(String name){  
  6.       this.name = name;  
  7.       }  
  8.       public String getName() {  
  9.       return name;  
  10.       }  
  11.       public void setName(String name) {  
  12.       this.name = name;  
  13.       }  
  14.       public TreeNode getParent() {  
  15.       return parent;  
  16.       }  
  17.       public void setParent(TreeNode parent) {  
  18.       this.parent = parent;  
  19.       }  
  20.       //添加孩子节点  
  21.       public void add(TreeNode node){  
  22.       children.add(node);  
  23.       }  
  24.       //删除孩子节点  
  25.       public void remove(TreeNode node){  
  26.       children.remove(node);  
  27.       }  
  28.       //取得孩子节点  
  29.       public Enumeration<TreeNode> getChildren(){  
  30.       return children.elements();  
  31.       }  
  32.       }  

      [java] view plaincopy

  1.       public class Tree {  
  2.       TreeNode root = null;  
  3.       public Tree(String name) {  
  4.       root = new TreeNode(name);  
  5.       }  
  6.       public static void main(String[] args) {  
  7.       Tree tree = new Tree(“A”);  
  8.       TreeNode nodeB = new TreeNode(“B”);  
  9.       TreeNode nodeC = new TreeNode(“C”);  
  10.       nodeB.add(nodeC);  
  11.       tree.root.add(nodeB);  
  12.       System.out.println(“build the tree finished!”);  
  13.       }  
  14.       }  

      使用场景:将多个对象组合在一起进行操作,常用于表示树形结构中,例如二叉树,数等。

      12、享元模式(Flyweight)

      享元模式的主要目的是实现对象的共享,即共享池,当系统中对象多的时候可以减少内存的开销,通常与工厂模式一起使用。

      FlyWeightFactory负责创建和管理享元单元,当一个客户端请求时,工厂需要检查当前对象池中是否有符合条件的对象,如果有,就返回已经存在的对象,如果没有,则创建一个新对象,FlyWeight是超类。一提到共享池,我们很容易联想到Java里面的JDBC连接池,想想每个连接的特点,我们不难总结出:适用于作共享的一些个对象,他们有一些共有的属性,就拿数据库连接池来说,url、driverClassName、username、password及dbname,这些属性对于每个连接来说都是一样的,所以就适合用享元模式来处理,建一个工厂类,将上述类似属性作为内部数据,其它的作为外部数据,在方法调用时,当做参数传进来,这样就节省了空间,减少了实例的数量。

      看个例子:

      看下数据库连接池的代码:

      [java] view plaincopy

  1.       public class ConnectionPool {  
  2.       private Vector<Connection> pool;  
  3.       /*公有属性*/  
  4.       private String url = “jdbc:mysql://localhost:3306/test”;  
  5.       private String username = “root”;  
  6.       private String password = “root”;  
  7.       private String driverClassName = “com.mysql.jdbc.Driver”;  
  8.       private int poolSize = 100;  
  9.       private static ConnectionPool instance = null;  
  10.       Connection conn = null;  
  11.       /*构造方法,做一些初始化工作*/  
  12.       private ConnectionPool() {  
  13.       pool = new Vector<Connection>(poolSize);  
  14.       for (int i = 0; i < poolSize; i++) {  
  15.       try {  
  16.       Class.forName(driverClassName);  
  17.       conn = DriverManager.getConnection(url, username, password);  
  18.       pool.add(conn);  
  19.       } catch (ClassNotFoundException e) {  
  20.       e.printStackTrace();  
  21.       } catch (SQLException e) {  
  22.       e.printStackTrace();  
  23.       }  
  24.       }  
  25.       }  
  26.       /* 返回连接到连接池 */  
  27.       public synchronized void release() {  
  28.       pool.add(conn);  
  29.       }  
  30.       /* 返回连接池中的一个数据库连接 */  
  31.       public synchronized Connection getConnection() {  
  32.       if (pool.size() > 0) {  
  33.       Connection conn = pool.get(0);  
  34.       pool.remove(conn);  
  35.       return conn;  
  36.       } else {  
  37.       return null;  
  38.       }  
  39.       }  
  40.       }  

      通过连接池的管理,实现了数据库连接的共享,不需要每一次都重新创建连接,节省了数据库重新创建的开销,提升了系统的性能!本章讲解了7种结构型模式,因为篇幅的问题,剩下的11种行为型模式,

      本章是关于设计模式的最后一讲,会讲到第三种设计模式——行为型模式,共11种:策略模式、模板方法模式、观察者模式、迭代子模式、责任链模式、命令模式、备忘录模式、状态模式、访问者模式、中介者模式、解释器模式。这段时间一直在写关于设计模式的东西,终于写到一半了,写博文是个很费时间的东西,因为我得为读者负责,不论是图还是代码还是表述,都希望能尽量写清楚,以便读者理解,我想不论是我还是读者,都希望看到高质量的博文出来,从我本人出发,我会一直坚持下去,不断更新,源源动力来自于读者朋友们的不断支持,我会尽自己的努力,写好每一篇文章!希望大家能不断给出意见和建议,共同打造完美的博文!

      先来张图,看看这11中模式的关系:

      第一类:通过父类与子类的关系进行实现。第二类:两个类之间。第三类:类的状态。第四类:通过中间类

      13、策略模式(strategy)

      策略模式定义了一系列算法,并将每个算法封装起来,使他们可以相互替换,且算法的变化不会影响到使用算法的客户。需要设计一个接口,为一系列实现类提供统一的方法,多个实现类实现该接口,设计一个抽象类(可有可无,属于辅助类),提供辅助函数,关系图如下:

      图中ICalculator提供同意的方法,
      AbstractCalculator是辅助类,提供辅助方法,接下来,依次实现下每个类:

      首先统一接口:

      [java] view plaincopy

  1.       public interface ICalculator {  
  2.       public int calculate(String exp);  
  3.       }  

      辅助类:

      [java] view plaincopy

  1.       public abstract class AbstractCalculator {  
  2.       public int[] split(String exp,String opt){  
  3.       String array[] = exp.split(opt);  
  4.       int arrayInt[] = new int[2];  
  5.       arrayInt[0] = Integer.parseInt(array[0]);  
  6.       arrayInt[1] = Integer.parseInt(array[1]);  
  7.       return arrayInt;  
  8.       }  
  9.       }  

      三个实现类:

      [java] view plaincopy

  1.       public class Plus extends AbstractCalculator implements ICalculator {  
  2.       @Override  
  3.       public int calculate(String exp) {  
  4.       int arrayInt[] = split(exp,”\\+”);  
  5.       return arrayInt[0]+arrayInt[1];  
  6.       }  
  7.       }  

      [java] view plaincopy

  1.       public class Minus extends AbstractCalculator implements ICalculator {  
  2.       @Override  
  3.       public int calculate(String exp) {  
  4.       int arrayInt[] = split(exp,”-“);  
  5.       return arrayInt[0]-arrayInt[1];  
  6.       }  
  7.       }  

      [java] view plaincopy

  1.       public class Multiply extends AbstractCalculator implements ICalculator {  
  2.       @Override  
  3.       public int calculate(String exp) {  
  4.       int arrayInt[] = split(exp,”\\*”);  
  5.       return arrayInt[0]*arrayInt[1];  
  6.       }  
  7.       }  

      简单的测试类:

      [java] view plaincopy

  1.       public class StrategyTest {  
  2.       public static void main(String[] args) {  
  3.       String exp = “2+8”;  
  4.       ICalculator cal = new Plus();  
  5.       int result = cal.calculate(exp);  
  6.       System.out.println(result);  
  7.       }  
  8.       }  

      输出:10

      策略模式的决定权在用户,系统本身提供不同算法的实现,新增或者删除算法,对各种算法做封装。因此,策略模式多用在算法决策系统中,外部用户只需要决定用哪个算法即可。

      14、模板方法模式(Template Method)

      解释一下模板方法模式,就是指:一个抽象类中,有一个主方法,再定义1…n个方法,可以是抽象的,也可以是实际的方法,定义一个类,继承该抽象类,重写抽象方法,通过调用抽象类,实现对子类的调用,先看个关系图:

      就是在AbstractCalculator类中定义一个主方法calculate,calculate()调用spilt()等,Plus和Minus分别继承AbstractCalculator类,通过对AbstractCalculator的调用实现对子类的调用,看下面的例子:

      [java] view plaincopy

  1.       public abstract class AbstractCalculator {  
  2.       /*主方法,实现对本类其它方法的调用*/  
  3.       public final int calculate(String exp,String opt){  
  4.       int array[] = split(exp,opt);  
  5.       return calculate(array[0],array[1]);  
  6.       }  
  7.       /*被子类重写的方法*/  
  8.       abstract public int calculate(int num1,int num2);  
  9.       public int[] split(String exp,String opt){  
  10.       String array[] = exp.split(opt);  
  11.       int arrayInt[] = new int[2];  
  12.       arrayInt[0] = Integer.parseInt(array[0]);  
  13.       arrayInt[1] = Integer.parseInt(array[1]);  
  14.       return arrayInt;  
  15.       }  
  16.       }  

      [java] view plaincopy

  1.       public class Plus extends AbstractCalculator {  
  2.       @Override  
  3.       public int calculate(int num1,int num2) {  
  4.       return num1 + num2;  
  5.       }  
  6.       }  

      测试类:

      [java] view plaincopy

  1.       public class StrategyTest {  
  2.       public static void main(String[] args) {  
  3.       String exp = “8+8”;  
  4.       AbstractCalculator cal = new Plus();  
  5.       int result = cal.calculate(exp, “\\+”);  
  6.       System.out.println(result);  
  7.       }  
  8.       }  

      我跟踪下这个小程序的执行过程:首先将exp和”\\+”做参数,调用AbstractCalculator类里的calculate(String,String)方法,在calculate(String,String)里调用同类的split(),之后再调用calculate(int ,int)方法,从这个方法进入到子类中,执行完return num1 + num2后,将值返回到AbstractCalculator类,赋给result,打印出来。正好验证了我们开头的思路。

      15、观察者模式(Observer)

      包括这个模式在内的接下来的四个模式,都是类和类之间的关系,不涉及到继承,学的时候应该 记得归纳,记得本文最开始的那个图。观察者模式很好理解,类似于邮件订阅和RSS订阅,当我们浏览一些博客或wiki时,经常会看到RSS图标,就这的意思是,当你订阅了该文章,如果后续有更新,会及时通知你。其实,简单来讲就一句话:当一个对象变化时,其它依赖该对象的对象都会收到通知,并且随着变化!对象之间是一种一对多的关系。先来看看关系图:

      我解释下这些类的作用:MySubject类就是我们的主对象,Observer1和Observer2是依赖于MySubject的对象,当MySubject变化时,Observer1和Observer2必然变化。AbstractSubject类中定义着需要监控的对象列表,可以对其进行修改:增加或删除被监控对象,且当MySubject变化时,负责通知在列表内存在的对象。我们看实现代码:

      一个Observer接口:

      [java] view plaincopy

  1.       public interface Observer {  
  2.       public void update();  
  3.       }  

      两个实现类:

      [java] view plaincopy

  1.       public class Observer1 implements Observer {  
  2.       @Override  
  3.       public void update() {  
  4.       System.out.println(“observer1 has received!”);  
  5.       }  
  6.       }  

      [java] view plaincopy

  1.       public class Observer2 implements Observer {  
  2.       @Override  
  3.       public void update() {  
  4.       System.out.println(“observer2 has received!”);  
  5.       }  
  6.       }  

      Subject接口及实现类:

      [java] view plaincopy

  1.       public interface Subject {  
  2.       /*增加观察者*/  
  3.       public void add(Observer observer);  
  4.       /*删除观察者*/  
  5.       public void del(Observer observer);  
  6.       /*通知所有的观察者*/  
  7.       public void notifyObservers();  
  8.       /*自身的操作*/  
  9.       public void operation();  
  10.       }  

      [java] view plaincopy

  1.       public abstract class AbstractSubject implements Subject {  
  2.       private Vector<Observer> vector = new Vector<Observer>();  
  3.       @Override  
  4.       public void add(Observer observer) {  
  5.       vector.add(observer);  
  6.       }  
  7.       @Override  
  8.       public void del(Observer observer) {  
  9.       vector.remove(observer);  
  10.       }  
  11.       @Override  
  12.       public void notifyObservers() {  
  13.       Enumeration<Observer> enumo = vector.elements();  
  14.       while(enumo.hasMoreElements()){  
  15.       enumo.nextElement().update();  
  16.       }  
  17.       }  
  18.       }  

      [java] view plaincopy

  1.       public class MySubject extends AbstractSubject {  
  2.       @Override  
  3.       public void operation() {  
  4.       System.out.println(“update self!”);  
  5.       notifyObservers();  
  6.       }  
  7.       }  

      测试类:

      [java] view plaincopy

  1.       public class ObserverTest {  
  2.       public static void main(String[] args) {  
  3.       Subject sub = new MySubject();  
  4.       sub.add(new Observer1());  
  5.       sub.add(new Observer2());  
  6.       sub.operation();  
  7.       }  
  8.       }  

      输出:

      update self!
      observer1 has received!
      observer2 has received!

      这些东西,其实不难,只是有些抽象,不太容易整体理解,建议读者:根据关系图,新建项目,自己写代码(或者参考我的代码),按照总体思路走一遍,这样才能体会它的思想,理解起来容易! 

      16、迭代子模式(Iterator)

      顾名思义,迭代器模式就是顺序访问聚集中的对象,一般来说,集合中非常常见,如果对集合类比较熟悉的话,理解本模式会十分轻松。这句话包含两层意思:一是需要遍历的对象,即聚集对象,二是迭代器对象,用于对聚集对象进行遍历访问。我们看下关系图:

      这个思路和我们常用的一模一样,MyCollection中定义了集合的一些操作,MyIterator中定义了一系列迭代操作,且持有Collection实例,我们来看看实现代码:

      两个接口:

      [java] view plaincopy

  1.       public interface Collection {  
  2.       public Iterator iterator();  
  3.       /*取得集合元素*/  
  4.       public Object get(int i);  
  5.       /*取得集合大小*/  
  6.       public int size();  
  7.       }  

      [java] view plaincopy

  1.       public interface Iterator {  
  2.       //前移  
  3.       public Object previous();  
  4.       //后移  
  5.       public Object next();  
  6.       public boolean hasNext();  
  7.       //取得第一个元素  
  8.       public Object first();  
  9.       }  

      两个实现:

      [java] view plaincopy

  1.       public class MyCollection implements Collection {  
  2.       public String string[] = {“A”,”B”,”C”,”D”,”E”};  
  3.       @Override  
  4.       public Iterator iterator() {  
  5.       return new MyIterator(this);  
  6.       }  
  7.       @Override  
  8.       public Object get(int i) {  
  9.       return string[i];  
  10.       }  
  11.       @Override  
  12.       public int size() {  
  13.       return string.length;  
  14.       }  
  15.       }  

      [java] view plaincopy

  1.       public class MyIterator implements Iterator {  
  2.       private Collection collection;  
  3.       private int pos = -1;  
  4.       public MyIterator(Collection collection){  
  5.       this.collection = collection;  
  6.       }  
  7.       @Override  
  8.       public Object previous() {  
  9.       if(pos > 0){  
  10.       pos–;  
  11.       }  
  12.       return collection.get(pos);  
  13.       }  
  14.       @Override  
  15.       public Object next() {  
  16.       if(pos<collection.size()-1){  
  17.       pos++;  
  18.       }  
  19.       return collection.get(pos);  
  20.       }  
  21.       @Override  
  22.       public boolean hasNext() {  
  23.       if(pos<collection.size()-1){  
  24.       return true;  
  25.       }else{  
  26.       return false;  
  27.       }  
  28.       }  
  29.       @Override  
  30.       public Object first() {  
  31.       pos = 0;  
  32.       return collection.get(pos);  
  33.       }  
  34.       }  

      测试类:

      [java] view plaincopy

  1.       public class Test {  
  2.       public static void main(String[] args) {  
  3.       Collection collection = new MyCollection();  
  4.       Iterator it = collection.iterator();  
  5.       while(it.hasNext()){  
  6.       System.out.println(it.next());  
  7.       }  
  8.       }  
  9.       }  

      输出:A B C D E

      此处我们貌似模拟了一个集合类的过程,感觉是不是很爽?其实JDK中各个类也都是这些基本的东西,加一些设计模式,再加一些优化放到一起的,只要我们把这些东西学会了,掌握好了,我们也可以写出自己的集合类,甚至框架!

      17、责任链模式(Chain of Responsibility)
      接下来我们将要谈谈责任链模式,有多个对象,每个对象持有对下一个对象的引用,这样就会形成一条链,请求在这条链上传递,直到某一对象决定处理该请求。但是发出者并不清楚到底最终那个对象会处理该请求,所以,责任链模式可以实现,在隐瞒客户端的情况下,对系统进行动态的调整。先看看关系图:

      Abstracthandler类提供了get和set方法,方便MyHandle类设置和修改引用对象,MyHandle类是核心,实例化后生成一系列相互持有的对象,构成一条链。

      [java] view plaincopy

  1.       public interface Handler {  
  2.       public void operator();  
  3.       }  

      [java] view plaincopy

  1.       public abstract class AbstractHandler {  
  2.       private Handler handler;  
  3.       public Handler getHandler() {  
  4.       return handler;  
  5.       }  
  6.       public void setHandler(Handler handler) {  
  7.       this.handler = handler;  
  8.       }  
  9.       }  

      [java] view plaincopy

  1.       public class MyHandler extends AbstractHandler implements Handler {  
  2.       private String name;  
  3.       public MyHandler(String name) {  
  4.       this.name = name;  
  5.       }  
  6.       @Override  
  7.       public void operator() {  
  8.       System.out.println(name+”deal!”);  
  9.       if(getHandler()!=null){  
  10.       getHandler().operator();  
  11.       }  
  12.       }  
  13.       }  

      [java] view plaincopy

  1.       public class Test {  
  2.       public static void main(String[] args) {  
  3.       MyHandler h1 = new MyHandler(“h1”);  
  4.       MyHandler h2 = new MyHandler(“h2”);  
  5.       MyHandler h3 = new MyHandler(“h3”);  
  6.       h1.setHandler(h2);  
  7.       h2.setHandler(h3);  
  8.       h1.operator();  
  9.       }  
  10.       }  

      输出:

      h1deal!
      h2deal!
      h3deal!

      此处强调一点就是,链接上的请求可以是一条链,可以是一个树,还可以是一个环,模式本身不约束这个,需要我们自己去实现,同时,在一个时刻,命令只允许由一个对象传给另一个对象,而不允许传给多个对象。

      18、命令模式(Command)

      命令模式很好理解,举个例子,司令员下令让士兵去干件事情,从整个事情的角度来考虑,司令员的作用是,发出口令,口令经过传递,传到了士兵耳朵里,士兵去执行。这个过程好在,三者相互解耦,任何一方都不用去依赖其他人,只需要做好自己的事儿就行,司令员要的是结果,不会去关注到底士兵是怎么实现的。我们看看关系图:

      Invoker是调用者(司令员),Receiver是被调用者(士兵),MyCommand是命令,实现了Command接口,持有接收对象,看实现代码:

      [java] view plaincopy

  1.       public interface Command {  
  2.       public void exe();  
  3.       }  

      [java] view plaincopy

  1.       public class MyCommand implements Command {  
  2.       private Receiver receiver;  
  3.       public MyCommand(Receiver receiver) {  
  4.       this.receiver = receiver;  
  5.       }  
  6.       @Override  
  7.       public void exe() {  
  8.       receiver.action();  
  9.       }  
  10.       }  

      [java] view plaincopy

  1.       public class Receiver {  
  2.       public void action(){  
  3.       System.out.println(“command received!”);  
  4.       }  
  5.       }  

      [java] view plaincopy

  1.       public class Invoker {  
  2.       private Command command;  
  3.       public Invoker(Command command) {  
  4.       this.command = command;  
  5.       }  
  6.       public void action(){  
  7.       command.exe();  
  8.       }  
  9.       }  

      [java] view plaincopy

  1.       public class Test {  
  2.       public static void main(String[] args) {  
  3.       Receiver receiver = new Receiver();  
  4.       Command cmd = new MyCommand(receiver);  
  5.       Invoker invoker = new Invoker(cmd);  
  6.       invoker.action();  
  7.       }  
  8.       }  

      输出:command received!

      这个很哈理解,命令模式的目的就是达到命令的发出者和执行者之间解耦,实现请求和执行分开,熟悉Struts的同学应该知道,Struts其实就是一种将请求和呈现分离的技术,其中必然涉及命令模式的思想!

      其实每个设计模式都是很重要的一种思想,看上去很熟,其实是因为我们在学到的东西中都有涉及,尽管有时我们并不知道,其实在Java本身的设计之中处处都有体现,像AWT、JDBC、集合类、IO管道或者是Web框架,里面设计模式无处不在。因为我们篇幅有限,很难讲每一个设计模式都讲的很详细,不过我会尽我所能,尽量在有限的空间和篇幅内,把意思写清楚了,更好让大家明白。本章不出意外的话,应该是设计模式最后一讲了,首先还是上一下上篇开头的那个图:

      本章讲讲第三类和第四类。

      19、备忘录模式(Memento)

      主要目的是保存一个对象的某个状态,以便在适当的时候恢复对象,个人觉得叫备份模式更形象些,通俗的讲下:假设有原始类A,A中有各种属性,A可以决定需要备份的属性,备忘录类B是用来存储A的一些内部状态,类C呢,就是一个用来存储备忘录的,且只能存储,不能修改等操作。做个图来分析一下:

      Original类是原始类,里面有需要保存的属性value及创建一个备忘录类,用来保存value值。Memento类是备忘录类,Storage类是存储备忘录的类,持有Memento类的实例,该模式很好理解。直接看源码:

      [java] view plaincopy

  1.       public class Original {  
  2.       private String value;  
  3.       public String getValue() {  
  4.       return value;  
  5.       }  
  6.       public void setValue(String value) {  
  7.       this.value = value;  
  8.       }  
  9.       public Original(String value) {  
  10.       this.value = value;  
  11.       }  
  12.       public Memento createMemento(){  
  13.       return new Memento(value);  
  14.       }  
  15.       public void restoreMemento(Memento memento){  
  16.       this.value = memento.getValue();  
  17.       }  
  18.       }  

      [java] view plaincopy

  1.       public class Memento {  
  2.       private String value;  
  3.       public Memento(String value) {  
  4.       this.value = value;  
  5.       }  
  6.       public String getValue() {  
  7.       return value;  
  8.       }  
  9.       public void setValue(String value) {  
  10.       this.value = value;  
  11.       }  
  12.       }  

      [java] view plaincopy

  1.       public class Storage {  
  2.       private Memento memento;  
  3.       public Storage(Memento memento) {  
  4.       this.memento = memento;  
  5.       }  
  6.       public Memento getMemento() {  
  7.       return memento;  
  8.       }  
  9.       public void setMemento(Memento memento) {  
  10.       this.memento = memento;  
  11.       }  
  12.       }  

      测试类:

      [java] view plaincopy

  1.       public class Test {  
  2.       public static void main(String[] args) {  
  3.       // 创建原始类  
  4.       Original origi = new Original(“egg”);  
  5.       // 创建备忘录  
  6.       Storage storage = new Storage(origi.createMemento());  
  7.       // 修改原始类的状态  
  8.       System.out.println(“初始化状态为:” + origi.getValue());  
  9.       origi.setValue(“niu”);  
  10.       System.out.println(“修改后的状态为:” + origi.getValue());  
  11.       // 回复原始类的状态  
  12.       origi.restoreMemento(storage.getMemento());  
  13.       System.out.println(“恢复后的状态为:” + origi.getValue());  
  14.       }  
  15.       }  

      输出:

      初始化状态为:egg
      修改后的状态为:niu
      恢复后的状态为:egg

      简单描述下:新建原始类时,value被初始化为egg,后经过修改,将value的值置为niu,最后倒数第二行进行恢复状态,结果成功恢复了。其实我觉得这个模式叫“备份-恢复”模式最形象。

      20、状态模式(State)

      核心思想就是:当对象的状态改变时,同时改变其行为,很好理解!就拿QQ来说,有几种状态,在线、隐身、忙碌等,每个状态对应不同的操作,而且你的好友也能看到你的状态,所以,状态模式就两点:1、可以通过改变状态来获得不同的行为。2、你的好友能同时看到你的变化。看图:

      State类是个状态类,Context类可以实现切换,我们来看看代码:

      [java] view plaincopy

  1.       package com.xtfggef.dp.state;  
  2.       /** 
  3.       * 状态类的核心类 
  4.       * 2012-12-1 
  5.       * @author erqing 
  6.       * 
  7.       */  
  8.       public class State {  
  9.       private String value;  
  10.       public String getValue() {  
  11.       return value;  
  12.       }  
  13.       public void setValue(String value) {  
  14.       this.value = value;  
  15.       }  
  16.       public void method1(){  
  17.       System.out.println(“execute the first opt!”);  
  18.       }  
  19.       public void method2(){  
  20.       System.out.println(“execute the second opt!”);  
  21.       }  
  22.       }  

      [java] view plaincopy

  1.       package com.xtfggef.dp.state;  
  2.       /** 
  3.       * 状态模式的切换类   2012-12-1 
  4.       * @author erqing 
  5.       *  
  6.       */  
  7.       public class Context {  
  8.       private State state;  
  9.       public Context(State state) {  
  10.       this.state = state;  
  11.       }  
  12.       public State getState() {  
  13.       return state;  
  14.       }  
  15.       public void setState(State state) {  
  16.       this.state = state;  
  17.       }  
  18.       public void method() {  
  19.       if (state.getValue().equals(“state1”)) {  
  20.       state.method1();  
  21.       } else if (state.getValue().equals(“state2”)) {  
  22.       state.method2();  
  23.       }  
  24.       }  
  25.       }  

      测试类:

      [java] view plaincopy

  1.       public class Test {  
  2.       public static void main(String[] args) {  
  3.       State state = new State();  
  4.       Context context = new Context(state);  
  5.       //设置第一种状态  
  6.       state.setValue(“state1”);  
  7.       context.method();  
  8.       //设置第二种状态  
  9.       state.setValue(“state2”);  
  10.       context.method();  
  11.       }  
  12.       }  

      输出:

      execute the first opt!
      execute the second opt!

      根据这个特性,状态模式在日常开发中用的挺多的,尤其是做网站的时候,我们有时希望根据对象的某一属性,区别开他们的一些功能,比如说简单的权限控制等。
21、访问者模式(Visitor)

      访问者模式把数据结构和作用于结构上的操作解耦合,使得操作集合可相对自由地演化。访问者模式适用于数据结构相对稳定算法又易变化的系统。因为访问者模式使得算法操作增加变得容易。若系统数据结构对象易于变化,经常有新的数据对象增加进来,则不适合使用访问者模式。访问者模式的优点是增加操作很容易,因为增加操作意味着增加新的访问者。访问者模式将有关行为集中到一个访问者对象中,其改变不影响系统数据结构。其缺点就是增加新的数据结构很困难。—— From 百科

      简单来说,访问者模式就是一种分离对象数据结构与行为的方法,通过这种分离,可达到为一个被访问者动态添加新的操作而无需做其它的修改的效果。

      来看看我自己写的demo。场景:银行柜台提供的服务和来办业务的人。把银行的服务和业务的办理解耦了。缺点:如果银行要修改底层业务接口,所有继承接口的类都需要作出修改。不过java8的新特性接口默认方法可以解决这个问题,或者java8之前可以通过接口的适配器模式来解决这个问题

      publicclass VisitorDemo {
      // 银行柜台服务,以后银行要新增业务,只需要新增一个类实现这个接口就可以了。 interface Service { publicvoid accept(Visitor visitor); }
      // 来办业务的人,里面可以加上权限控制等等 staticclass Visitor { publicvoid process(Service service) { // 基本业务 System.out.println(“基本业务”); } publicvoid process(Saving service) { // 存款 System.out.println(“存款”); } publicvoid process(Draw service) { // 提款 System.out.println(“提款”); } publicvoid process(Fund service) { System.out.println(“基金”); // 基金 } } staticclass Saving implements Service { publicvoid accept(Visitor visitor) { visitor.process(this); } } staticclass Draw implements Service { publicvoid accept(Visitor visitor) { visitor.process(this); } } staticclass Fund implements Service { publicvoid accept(Visitor visitor) { visitor.process(this); } } publicstaticvoid main(String[] args) { Service saving = new Saving(); Service fund = new Fund(); Service draw = new Draw(); Visitor visitor = new Visitor(); Visitor guweiwei = new Visitor(); fund.accept(guweiwei); saving.accept(visitor); fund.accept(visitor); draw.accept(visitor); } }

      测试:

      publicstaticvoid main(String[] args) { Service saving = new Saving(); Service fund = new Fund(); Service draw = new Draw(); Visitor visitor = new Visitor(); Visitor guweiwei =new Visitor(); fund.accept(guweiwei); saving.accept(visitor); fund.accept(visitor); draw.accept(visitor); }

      输出:

      基金
      存款
      基金
      提款

      该模式适用场景:如果我们想为一个现有的类增加新功能,不得不考虑几个事情:1、新功能会不会与现有功能出现兼容性问题?2、以后会不会再需要添加?3、如果类不允许修改代码怎么办?面对这些问题,最好的解决方法就是使用访问者模式,访问者模式适用于数据结构相对稳定的系统,把数据结构和算法解耦,
22、中介者模式(Mediator)

      中介者模式也是用来降低类类之间的耦合的,因为如果类类之间有依赖关系的话,不利于功能的拓展和维护,因为只要修改一个对象,其它关联的对象都得进行修改。如果使用中介者模式,只需关心和Mediator类的关系,具体类类之间的关系及调度交给Mediator就行,这有点像spring容器的作用。先看看图:

      User类统一接口,User1和User2分别是不同的对象,二者之间有关联,如果不采用中介者模式,则需要二者相互持有引用,这样二者的耦合度很高,为了解耦,引入了Mediator类,提供统一接口,MyMediator为其实现类,里面持有User1和User2的实例,用来实现对User1和User2的控制。这样User1和User2两个对象相互独立,他们只需要保持好和Mediator之间的关系就行,剩下的全由MyMediator类来维护!基本实现:

      [java] view plaincopy

  1.       public interface Mediator {  
  2.       public void createMediator();  
  3.       public void workAll();  
  4.       }  

      [java] view plaincopy

  1.       public class MyMediator implements Mediator {  
  2.       private User user1;  
  3.       private User user2;  
  4.       public User getUser1() {  
  5.       return user1;  
  6.       }  
  7.       public User getUser2() {  
  8.       return user2;  
  9.       }  
  10.       @Override  
  11.       public void createMediator() {  
  12.       user1 = new User1(this);  
  13.       user2 = new User2(this);  
  14.       }  
  15.       @Override  
  16.       public void workAll() {  
  17.       user1.work();  
  18.       user2.work();  
  19.       }  
  20.       }  

      [java] view plaincopy

  1.       public abstract class User {  
  2.       private Mediator mediator;  
  3.       public Mediator getMediator(){  
  4.       return mediator;  
  5.       }  
  6.       public User(Mediator mediator) {  
  7.       this.mediator = mediator;  
  8.       }  
  9.       public abstract void work();  
  10.       }  

      [java] view plaincopy

  1.       public class User1 extends User {  
  2.       public User1(Mediator mediator){  
  3.       super(mediator);  
  4.       }  
  5.       @Override  
  6.       public void work() {  
  7.       System.out.println(“user1 exe!”);  
  8.       }  
  9.       }  

      [java] view plaincopy

  1.       public class User2 extends User {  
  2.       public User2(Mediator mediator){  
  3.       super(mediator);  
  4.       }  
  5.       @Override  
  6.       public void work() {  
  7.       System.out.println(“user2 exe!”);  
  8.       }  
  9.       }  

      测试类:

      [java] view plaincopy

  1.       public class Test {  
  2.       public static void main(String[] args) {  
  3.       Mediator mediator = new MyMediator();  
  4.       mediator.createMediator();  
  5.       mediator.workAll();  
  6.       }  
  7.       }  

      输出:

      user1 exe!
      user2 exe!
23、解释器模式(Interpreter)
      解释器模式是我们暂时的最后一讲,一般主要应用在OOP开发中的编译器的开发中,所以适用面比较窄。

      Context类是一个上下文环境类,Plus和Minus分别是用来计算的实现,代码如下:

      [java] view plaincopy

  1.       public interface Expression {  
  2.       public int interpret(Context context);  
  3.       }  

      [java] view plaincopy

  1.       public class Plus implements Expression {  
  2.       @Override  
  3.       public int interpret(Context context) {  
  4.       return context.getNum1()+context.getNum2();  
  5.       }  
  6.       }  

      [java] view plaincopy

  1.       public class Minus implements Expression {  
  2.       @Override  
  3.       public int interpret(Context context) {  
  4.       return context.getNum1()-context.getNum2();  
  5.       }  
  6.       }  

      [java] view plaincopy

  1.       public class Context {  
  2.       private int num1;  
  3.       private int num2;  
  4.       public Context(int num1, int num2) {  
  5.       this.num1 = num1;  
  6.       this.num2 = num2;  
  7.       }  
  8.       public int getNum1() {  
  9.       return num1;  
  10.       }  
  11.       public void setNum1(int num1) {  
  12.       this.num1 = num1;  
  13.       }  
  14.       public int getNum2() {  
  15.       return num2;  
  16.       }  
  17.       public void setNum2(int num2) {  
  18.       this.num2 = num2;  
  19.       }  
  20.       }  

      [java] view plaincopy

  1.       public class Test {  
  2.       public static void main(String[] args) {  
  3.       // 计算9+2-8的值  
  4.       int result = new Minus().interpret((new Context(new Plus()  
  5.       .interpret(new Context(9, 2)), 8)));  
  6.       System.out.println(result);  
  7.       }  
  8.       }  

      最后输出正确的结果:3。  

      基本就这样,解释器模式用来做各种各样的解释器,如正则表达式等的解释器等等!
      设计模式基本就这么大概讲完了,总体感觉有点简略,的确,这么点儿篇幅,不足以对整个23种设计模式做全面的阐述,此处读者可将它作为一个理论基础去学习,通过这四篇博文,先基本有个概念,虽然我讲的有些简单,但基本都能说明问题及他们的特点,如果对哪一个感兴趣,可以继续深入研究!同时我也会不断更新,尽量补全遗

FLINK 官方文档解读-数据流编程模型

上来一个图,就是抽象模型,越靠近底部的越底层

  • 最底层的抽象是指提供了基本的状态流,被嵌入到DataStreamAPI通过处理方法访问,它允许用户随意的一个或者多个流处理事件,并且用一致的容错机制,并且,用户可以注册时间和处理的回调方法,允许程序处理更为复杂的计算
  • Not done,To be continue

Flink 官方文档解读-导读

该文章,写给想看开源项目,不知道怎么看,或者不会看的朋友

访问Flink官方地址:https://flink.apache.org/ ,可以通过Apache官方 www.apache.org 点击Project或者直接搜索“Flink”

我的一个阅读习惯,从Documentation开始,然后找到最新的一个Release版本,当前是1.7

访问页面,我们会看到上面的页面,其中,一些知识点,都被加上了超链接,后续我们单独介绍,先来看看这一页说了什么内容。

一上来,先说了一些文档的编辑时间。说了一下Flink是一个分布式流处理和批处理的开源平台,Flink核心是一个流式处理引擎,他提供了数据分发,通信,在分布式计算数据流上进行数据容错,在留引擎上构建了批处理,包含本地迭代支持,内存管理,编程优化。

第一步,介绍一些概念,数据流编程模型分布式运行环境,它会帮助你理解其他章节,包括安装和编程,所以强烈建议你读一下。

初识

编程指南,你需要阅读我们的手册,包括基本API概念和去学习一下,DataStreaming APIDataSet API,如何去写一个流式程序。

部署

在你准备发布一个产品时,请先阅读产品清单

其他资源

一些讲座可以关注flink官方网站,或者看 YouTube

培训教材

博客data Artisans官方博客

美团外卖iOS App冷启动治理(转载)

一、背景

冷启动时长是App性能的重要指标,作为用户体验的第一道“门”,直接决定着用户对App的第一印象。美团外卖iOS客户端从2013年11月开始,历经几十个版本的迭代开发,产品形态不断完善,业务功能日趋复杂;同时外卖App也已经由原来的独立业务App演进成为一个平台App,陆续接入了闪购、跑腿等其他新业务。因此,更多更复杂的工作需要在App冷启动的时候被完成,这给App的冷启动性能带来了挑战。对此,我们团队基于业务形态的变化和外卖App的特点,对冷启动进行了持续且有针对性的优化工作,目的就是为了呈现更加流畅的用户体验。

二、冷启动定义

一般而言,大家把iOS冷启动的过程定义为:从用户点击App图标开始到appDelegate didFinishLaunching方法执行完成为止。这个过程主要分为两个阶段:

  • T1:main()函数之前,即操作系统加载App可执行文件到内存,然后执行一系列的加载&链接等工作,最后执行至App的main()函数。
  • T2:main()函数之后,即从main()开始,到appDelegate的didFinishLaunchingWithOptions方法执行完毕。

然而,当didFinishLaunchingWithOptions执行完成时,用户还没有看到App的主界面,也不能开始使用App。例如在外卖App中,App还需要做一些初始化工作,然后经历定位、首页请求、首页渲染等过程后,用户才能真正看到数据内容并开始使用,我们认为这个时候冷启动才算完成。我们把这个过程定义为T3。

综上,外卖App把冷启动过程定义为:从用户点击App图标开始到用户能看到App主界面内容为止这个过程,即T1+T2+T3。在App冷启动过程当中,这三个阶段中的每个阶段都存在很多可以被优化的点。

三、问题现状

性能存量问题

美团外卖iOS客户端经过几十个版本的迭代开发后,在冷启动过程中已经积累了若干性能问题,解决这些性能瓶颈是冷启动优化工作的首要目标,这些问题主要包括:

注:启动项的定义,在App启动过程中需要被完成的某项工作,我们称之为一个启动项。例如某个SDK的初始化、某个功能的预加载等。

性能增量问题

一般情况下,在App早期阶段,冷启动不会有明显的性能问题。冷启动性能问题也不是在某个版本突然出现的,而是随着版本迭代,App功能越来越复杂,启动任务越来越多,冷启动时间也一点点延长。最后当我们注意到,并想要优化它的时候,这个问题已经变得很棘手了。外卖App的性能问题增量主要来自启动项的增加,随着版本迭代,启动项任务简单粗暴地堆积在启动流程中。如果每个版本冷启动时间增加0.1s,那么几个版本下来,冷启动时长就会明显增加很多。

四、治理思路

冷启动性能问题的治理目标主要有三个:

  1. 解决存量问题:优化当前性能瓶颈点,优化启动流程,缩短冷启动时间。
  2. 管控增量问题:冷启动流程规范化,通过代码范式和文档指导后续冷启动过程代码的维护,控制时间增量。
  3. 完善监控:完善冷启动性能指标监控,收集更详细的数据,及时发现性能问题。

五、规范启动流程

截止至2017年底,美团外卖用户数已达2.5亿,而美团外卖App也已完成了从支撑单一业务的App到支持多业务的平台型App的演进(美团外卖iOS多端复用的推动、支撑与思考),公司的一些新兴业务也陆续集成到外卖App当中。下面是外卖App的架构图,外卖的架构主要分为三层,底层是基础组件层,中层是外卖平台层,平台层向下管理基础组件,向上为业务组件提供统一的适配接口,上层是基础组件层,包括外卖业务拆分的子业务组件(外卖App和美团App中的外卖频道可以复用子业务组件)和接入的其他非外卖业务。

App的平台化为业务方提供了高效、标准的统一平台,但与此同时,平台化和业务的快速迭代也给冷启动带来了问题:

  1. 现有的启动项堆积严重,拖慢启动速度。
  2. 新的启动项缺乏添加范式,杂乱无章,修改风险大,难以阅读和维护。

面对这个问题,我们首先梳理了目前启动流程中所有的启动项,然后针对App平台化设计了新的启动项管理方式:分阶段启动和启动项自注册

分阶段启动

早期由于业务比较简单,所有启动项都是不加以区分,简单地堆积到didFinishLaunchingWithOptions方法中,但随着业务的增加,越来越多的启动项代码堆积在一起,性能较差,代码臃肿而混乱。

通过对SDK的梳理和分析,我们发现启动项也需要根据所完成的任务被分类,有些启动项是需要刚启动就执行的操作,如Crash监控、统计上报等,否则会导致信息收集的缺失;有些启动项需要在较早的时间节点完成,例如一些提供用户信息的SDK、定位功能的初始化、网络初始化等;有些启动项则可以被延迟执行,如一些自定义配置,一些业务服务的调用、支付SDK、地图SDK等。我们所做的分阶段启动,首先就是把启动流程合理地划分为若干个启动阶段,然后依据每个启动项所做的事情的优先级把它们分配到相应的启动阶段,优先级高的放在靠前的阶段,优先级低的放在靠后的阶段。

下面是我们对美团外卖App启动阶段进行的重新定义,对所有启动项进行的梳理和重新分类,把它们对应到合理的启动阶段。这样做一方面可以推迟执行那些不必过早执行的启动项,缩短启动时间;另一方面,把启动项进行归类,方便后续的阅读和维护。然后把这些规则落地为启动项的维护文档,指导后续启动项的新增和维护。

通过上面的工作,我们梳理出了十几个可以推迟执行的启动项,占所有启动项的30%左右,有效地优化了启动项所占的这部分冷启动时间。

启动项自注册

确定了启动项分阶段启动的方案后,我们面对的问题就是如何执行这些启动项。比较容易想到的方案是:在启动时创建一个启动管理器,然后读取所有启动项,然后当时间节点到来时由启动器触发启动项执行。这种方式存在两个问题:

  1. 所有启动项都要预先写到一个文件中(在.m文件import,或用.plist文件组织),这种中心化的写法会导致臃肿的代码,难以阅读维护。
  2. 启动项代码无法复用:启动项无法收敛到子业务库内部,在外卖App和美团App中要重复实现,和外卖App平台化的方向不符。

而我们希望的方式是,启动项维护方式可插拔,启动项之间、业务模块之间不耦合,且一次实现可在两端复用。下图是我们采用的启动项管理方式,我们称之为启动项的自注册:一个启动项定义在子业务模块内部,被封装成一个方法,并且自声明启动阶段(例如一个启动项A,在独立App中可以声明为在willFinishLaunch阶段被执行,在美团App中则声明在resignActive阶段被执行)。这种方式下,启动项即实现了两端复用,不相关的启动项互相隔离,添加/删除启动项都更加方便。

那么如何给一个启动项声明启动阶段?又如何在正确的时机触发启动项的执行呢?在代码上,一个启动项最终都会对应到一个函数的执行,所以在运行时只要能获取到函数的指针,就可以触发启动项。美团平台开发的组件启动治理基建Kylin正是这样做的:Kylin的核心思想就是在编译时把数据(如函数指针)写入到可执行文件的__DATA段中,运行时再从__DATA段取出数据进行相应的操作(调用函数)。

为什么要用借用__DATA段呢?原因就是为了能够覆盖所有的启动阶段,例如main()之前的阶段。

Kylin实现原理简述:Clang 提供了很多的编译器函数,它们可以完成不同的功能。其中一种就是 section() 函数,section()函数提供了二进制段的读写能力,它可以将一些编译期就可以确定的常量写入数据段。 在具体的实现中,主要分为编译期和运行时两个部分。在编译期,编译器会将标记了 attribute((section())) 的数据写到指定的数据段中,例如写一个{key(key代表不同的启动阶段), *pointer}对到数据段。到运行时,在合适的时间节点,在根据key读取出函数指针,完成函数的调用。

上述方式,可以封装成一个宏,来达到代码的简化,以调用宏 KLN_STRINGS_EXPORT(“Key”, “Value”)为例,最终会被展开为:

__attribute__((used, section("__DATA" "," "__kylin__"))) static const KLN_DATA __kylin__0 = (KLN_DATA){(KLN_DATA_HEADER){"Key", KLN_STRING, KLN_IS_ARRAY}, "Value"};

使用示例,编译器把启动项函数注册到启动阶段A:

KLN_FUNCTIONS_EXPORT(STAGE_KEY_A)() { // 在a.m文件中,通过注册宏,把启动项A声明为在STAGE_KEY_A阶段执行
    // 启动项代码A
}
KLN_FUNCTIONS_EXPORT(STAGE_KEY_A)() { // 在b.m文件中,把启动项B声明为在STAGE_KEY_A阶段执行
    // 启动项代码B
}

在启动流程中,在启动阶段STAGE_KEY_A触发所有注册到STAGE_KEY_A时间节点的启动项,通过对这种方式,几乎没有任何额外的辅助代码,我们用一种很简洁的方式完成了启动项的自注册。

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    // 其他逻辑
    [[KLNKylin sharedInstance] executeArrayForKey:STAGE_KEY_A];  // 在此触发所有注册到STAGE_KEY_A时间节点的启动项
    // 其他逻辑
    return YES;
}

完成对现有的启动项的梳理和优化后,我们也输出了后续启动项的添加&维护规范,规范后续启动项的分类原则,优先级和启动阶段。目的是管控性能问题增量,保证优化成果。

六、优化main()之前

在调用main()函数之前,基本所有的工作都是由操作系统完成的,开发者能够插手的地方不多,所以如果想要优化这段时间,就必须先了解一下,操作系统在main()之前做了什么。main()之前操作系统所做的工作就是把可执行文件(Mach-O格式)加载到内存空间,然后加载动态链接库dyld,再执行一系列动态链接操作和初始化操作的过程(加载、绑定、及初始化方法)。这方面的资料网上比较多,但重复性较高,此处附上一篇WWDC的Topic:Optimizing App Startup Time 。

加载过程—从exec()到main()

真正的加载过程从exec()函数开始,exec()是一个系统调用。操作系统首先为进程分配一段内存空间,然后执行如下操作:

  1. 把App对应的可执行文件加载到内存。
  2. 把Dyld加载到内存。
  3. Dyld进行动态链接。

下面我们简要分析一下Dyld在各阶段所做的事情:

阶段工作
加载动态库Dyld从主执行文件的header获取到需要加载的所依赖动态库列表,然后它需要找到每个 dylib,而应用所依赖的 dylib 文件可能会再依赖其他 dylib,所以所需要加载的是动态库列表一个递归依赖的集合
Rebase和Bind– Rebase在Image内部调整指针的指向。在过去,会把动态库加载到指定地址,所有指针和数据对于代码都是对的,而现在地址空间布局是随机化,所以需要在原来的地址根据随机的偏移量做一下修正
– Bind是把指针正确地指向Image外部的内容。这些指向外部的指针被符号(symbol)名称绑定,dyld需要去符号表里查找,找到symbol对应的实现
Objc setup– 注册Objc类 (class registration)
– 把category的定义插入方法列表 (category registration)
– 保证每一个selector唯一 (selector uniquing)
Initializers– Objc的+load()函数
– C++的构造函数属性函数
– 非基本类型的C++静态全局变量的创建(通常是类或结构体)

最后 dyld 会调用 main() 函数,main() 会调用 UIApplicationMain(),before main()的过程也就此完成。

了解完main()之前的加载过程后,我们可以分析出一些影响T1时间的因素:

  1. 动态库加载越多,启动越慢。
  2. ObjC类,方法越多,启动越慢。
  3. ObjC的+load越多,启动越慢。
  4. C的constructor函数越多,启动越慢。
  5. C++静态对象越多,启动越慢。

针对以上几点,我们做了如下一些优化工作:

代码瘦身

随着业务的迭代,不断有新的代码加入,同时也会废弃掉无用的代码和资源文件,但是工程中经常有无用的代码和文件被遗弃在角落里,没有及时被清理掉。这些无用的部分一方面增大了App的包体积,另一方便也拖慢了App的冷启动速度,所以及时清理掉这些无用的代码和资源十分有必要。

通过对Mach-O文件的了解,可以知道__TEXT:__objcmethname:中包含了代码中的所有方法,而\_DATA__objc_selrefs中则包含了所有被使用的方法的引用,通过取两个集合的差集就可以得到所有未被使用的代码。核心方法如下,具体可以参考:objc_cover:

def referenced_selectors(path):
    re_sel = re.compile("__TEXT:__objc_methname:(.+)") //获取所有方法
    refs = set()
    lines = os.popen("/usr/bin/otool -v -s __DATA __objc_selrefs %s" % path).readlines() # ios & mac //真正被使用的方法
    for line in lines:
        results = re_sel.findall(line)
        if results:
            refs.add(results[0])
    return refs
}

通过这种方法,我们排查了十几个无用类和250+无用的方法。

+load优化

目前iOS App中或多或少的都会写一些+load方法,用于在App启动执行一些操作,+load方法在Initializers阶段被执行,但过多+load方法则会拖慢启动速度,对于大中型的App更是如此。通过对App中+load的方法分析,发现很多代码虽然需要在App启动时较早的时机进行初始化,但并不需要在+load这样非常靠前的位置,完全是可以延迟到App冷启动后的某个时间节点,例如一些路由操作。其实+load也可以被当做一种启动项来处理,所以在替换+load方法的具体实现上,我们仍然采用了上面的Kylin方式。

使用示例:

// 用WMAPP_BUSINESS_INIT_AFTER_HOMELOADING声明替换+load声明即可,不需其他改动
WMAPP_BUSINESS_INIT_AFTER_HOMELOADING() { 
    // 原+load方法中的代码
}
// 在某个合适的时机触发注册到该阶段的所有方法,如冷启动结束后
[[KLNKylin sharedInstance] executeArrayForKey:@kWMAPP_BUSINESS_INITIALIZATION_AFTER_HOMELOADING_KEY] 
}

七、优化耗时操作

在main()之后主要工作是各种启动项的执行(上面已经叙述),主界面的构建,例如TabBarVC,HomeVC等等。资源的加载,如图片I/O、图片解码、archive文档等。这些操作中可能会隐含着一些耗时操作,靠单纯阅读非常难以发现,如何发现这些耗时点呢?找到合适的工具就会事半功倍。

Time Profiler

Time Profiler是Xcode自带的时间性能分析工具,它按照固定的时间间隔来跟踪每一个线程的堆栈信息,通过统计比较时间间隔之间的堆栈状态,来推算某个方法执行了多久,并获得一个近似值。Time Profiler的使用方法网上有很多使用教程,这里我们也不过多介绍,附上一篇使用文档:Instruments Tutorial with Swift: Getting Started

火焰图

除了Time Profiler,火焰图也是一个分析CPU耗时的利器,相比于Time Profiler,火焰图更加清晰。火焰图分析的产物是一张调用栈耗时图片,之所以称为火焰图,是因为整个图形看起来就像一团跳动的火焰,火焰尖部是调用栈的栈顶,底部是栈底,纵向表示调用栈的深度,横向表示消耗的时间。一个格子的宽度越大,越说明其可能是瓶颈。分析火焰图主要就是看那些比较宽大的火苗,特别留意那些类似“平顶山”的火苗。下面是美团平台开发的性能分析工具-Caesium的分析效果图:

通过对火焰图的分析,我们发现了冷启动过程中存在着不少问题,并成功优化了0.3S+的时间。优化内容总结如下:

优化点举例
发现隐晦的耗时操作发现在冷启动过程中archive了一张图片,非常耗时
推迟&减少I/O操作减少动画图片组的数量,替换大图资源等。因为相比于内存操作,硬盘I/O是非常耗时的操作
推迟执行的一些任务如一些资源的I/O,一些布局逻辑,对象的创建时机等

八、优化串行操作

在冷启动过程中,有很多操作是串行执行的,若干个任务串行执行,时间必然比较长。如果能变串行为并行,那么冷启动时间就能够大大缩短。

闪屏页的使用

现在许多App在启动时并不直接进入首页,而是会向用户展示一个持续一小段时间的闪屏页,如果使用恰当,这个闪屏页就能帮我们节省一些启动时间。因为当一个App比较复杂的时候,启动时首次构建App的UI就是一个比较耗时的过程,假定这个时间是0.2秒,如果我们是先构建首页UI,然后再在Window上加上这个闪屏页,那么冷启动时,App就会实实在在地卡住0.2秒,但是如果我们是先把闪屏页作为App的RootViewController,那么这个构建过程就会很快。因为闪屏页只有一个简单的ImageView,而这个ImageView则会向用户展示一小段时间,这时我们就可以利用这一段时间来构建首页UI了,一举两得。

缓存定位&首页预请求

美团外卖App冷启动过程中一个重要的串行流程就是:首页定位–>首页请求–>首页渲染过程,这三个操作占了整个首页加载时间的77%左右,所以想要缩短冷启动时间,就一定要从这三点出发进行优化。

之前串行操作流程如下:

优化后的设计,在发起定位的同时,使用客户端缓存定位,进行首页数据的预请求,使定位和请求并行进行。然后当用户真实定位成功后,判断真实定位是否命中缓存定位,如果命中,则刚才的预请求数据有效,这样可以节省大概40%的时间首页加载时间,效果非常明显;如果未命中,则弃用预请求数据,重新请求。

九、数据监控

Time Profiler和Caesium火焰图都只能在线下分析App在单台设备中的耗时操作,局限性比较大,无法在线上监控App在用户设备上的表现。外卖App使用公司内部自研的Metrics性能监控系统,长期监控App的性能指标,帮助我们掌握App在线上各种环境下的真实表现,并为技术优化项目提供可靠的数据支持。Metrics监控的核心指标之一,就是冷启动时间。

冷启动开始&结束时间节点

  1. 结束时间点:结束时间比较好确定,我们可以将首页某些视图元素的展示作为首页加载完成的标志。
  2. 开始时间点:一般情况下,我们都是在main()之后才开始接管App,但以main()函数作为冷启动起始点显然不合适,因为这样无法统计到T1时间段。那么,起始时间如何确定呢?目前业界常见的有两种方法,一是以可执行文件中任意一个类的+load方法的执行时间作为起始点;二是分析dylib的依赖关系,找到叶子节点的dylib,然后以其中某个类的+load方法的执行时间作为起始点。根据Dyld对dylib的加载顺序,后者的时机更早。但是这两种方法获取的起始点都只在Initializers阶段,而Initializers之前的时长都没有被计入。Metrics则另辟蹊径,以App的进程创建时间(即exec函数执行时间)作为冷启动的起始时间。因为系统允许我们通过sysctl函数获得进程的有关信息,其中就包括进程创建的时间戳。
#import <sys/sysctl.h>
#import <mach/mach.h>

+ (BOOL)processInfoForPID:(int)pid procInfo:(struct kinfo_proc*)procInfo
{
    int cmd[4] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, pid};
    size_t size = sizeof(*procInfo);
    return sysctl(cmd, sizeof(cmd)/sizeof(*cmd), procInfo, &size, NULL, 0) == 0;
}

+ (NSTimeInterval)processStartTime
{
    struct kinfo_proc kProcInfo;
    if ([self processInfoForPID:[[NSProcessInfo processInfo] processIdentifier] procInfo:&kProcInfo]) {
        return kProcInfo.kp_proc.p_un.__p_starttime.tv_sec * 1000.0 + kProcInfo.kp_proc.p_un.__p_starttime.tv_usec / 1000.0;
    } else {
        NSAssert(NO, @"无法取得进程的信息");
        return 0;
    }
}

进程创建的时机非常早。经过实验,在一个新建的空白App中,进程创建时间比叶子节点dylib中的+load方法执行时间早12ms,比main函数的执行时间早13ms(实验设备:iPhone 7 Plus (iOS 12.0)、Xcode 10.0、Release 模式)。外卖App线上的数据则更加明显,同样的机型(iPhone 7 Plus)和系统版本(iOS 12.0),进程创建时间比叶子节点dylib中的+load方法执行时间早688ms。而在全部机型和系统版本中,这一数据则是878ms。

冷启动过程时间节点

我们也在App冷启动过程中的所有关键节点打上一连串测速点,Metrics会记录下测速点的名称,及其距离进程创建时间的时长。我们没有采用自动打点的方式,是因为外卖App的冷启动过程十分复杂,而自动打点无法做到如此细致,并不实用。另外,Metrics记录的是时间轴上以进程创建时间为原点的一组顺序的时间点,而不是一组时间段,是因为顺序的时间点可以计算任意两个时间点之间的距离,即可以将时间点处理成时间段。但是,一组时间段可能无法还原为顺序的时间点,因为时间段之间可能并不是首尾相接的,特别是对于异步执行或者多线程的情况。

在测速完毕后,Metrics会统一将所有测速点上报到后台。下图是美团外卖App 6.10版本的部分过程节点监控数据截图:

Metrics还会由后台对数据做聚合计算,得到冷启动总时长和各个测速点时长的50分位数、90分位数和95分位数的统计数据,这样我们就能从宏观上对冷启动时长分布情况有所了解。下图中横轴为时长,纵轴为上报的样本数。

十、总结

对于快速迭代的App,随着业务复杂度的增加,冷启动时长会不可避免的增加。冷启动流程也是一个比较复杂的过程,当遇到冷启动性能瓶颈时,我们可以根据App自身的特点,配合工具的使用,从多方面、多角度进行优化。同时,优化冷启动存量问题只是冷启动治理的第一步,因为冷启动性能问题并不是一日造成的,也不能简单的通过一次优化工作就能解决,我们需要通过合理的设计、规范的约束,来有效地管控性能问题的增量,并通过持续的线上监控来及时发现并修正性能问题,这样才能够长期保证良好的App冷启动体验。

作者简介

郭赛,美团点评资深工程师。2015年加入美团,目前作为外卖iOS团队主力开发,负责移动端业务开发,业务类基础设施的建设与维护。

徐宏,美团点评资深工程师。2016年加入美团,目前作为外卖iOS团队主力开发,负责移动端APM性能监控,高可用基础设施支撑相关推进工作。

来自:https://tech.meituan.com/waimai_ios_optimizing_startup.html

数据库智能运维探索与实践(转载)

从自动化到智能化运维过渡时,美团DBA团队进行了哪些思考、探索与实践?本文根据赵应钢在“第九届中国数据库技术大会”上的演讲内容整理而成,部分内容有更新。

背景

近些年,传统的数据库运维方式已经越来越难于满足业务方对数据库的稳定性、可用性、灵活性的要求。随着数据库规模急速扩大,各种NewSQL系统上线使用,运维逐渐跟不上业务发展,各种矛盾暴露的更加明显。在业务的驱动下,美团点评DBA团队经历了从“人肉”运维到工具化、产品化、自助化、自动化的转型之旅,也开始了智能运维在数据库领域的思考和实践。

本文将介绍美团点评整个数据库平台的演进历史,以及我们当前的情况和面临的一些挑战,最后分享一下我们从自动化到智能化运维过渡时,所进行的思考、探索与实践。

数据库平台的演变

我们数据库平台的演进大概经历了五个大的阶段:

第一个是脚本化阶段,这个阶段,我们人少,集群少,服务流量也比较小,脚本化的模式足以支撑整个服务。

第二个是工具化阶段,我们把一些脚本包装成工具,围绕CMDB管理资产和服务,并完善了监控系统。这时,我们的工具箱也逐渐丰富起来,包括DDL变更工具、SQL Review工具、慢查询采集分析工具和备份闪回工具等等。

第三个是产品化阶段,工具化阶段可能还是单个的工具,但是在完成一些复杂操作时,就需要把这些工具组装起来形成一个产品。当然,并不是说这个产品一定要做成Web系统的形式,而是工具组装起来形成一套流程之后,就可以保证所有DBA的操作行为,对流程的理解以及对线上的影响都是一致的。我们会在易用性和安全性层面不断进行打磨。而工具产品化的主要受益者是DBA,其定位是提升运维服务的效率,减少事故的发生,并方便进行快速统一的迭代。

第四个是打造私有云平台阶段,随着美团点评业务的高速发展,仅靠十几、二十个DBA越来越难以满足业务发展的需要。所以我们就把某些日常操作开放授权,让开发人员自助去做,将DBA从繁琐的操作中解放出来。当时整个平台每天执行300多次改表操作;自助查询超过1万次;自助申请账号、授权并调整监控;自助定义敏感数据并授权给业务方管理员自助审批和管理;自定义业务的高峰和低峰时间段等等;自助下载、查询日志等等。

第五个是自动化阶段,对这个阶段的理解,其实是“仁者见仁,智者见智”。大多数人理解的自动化,只是通过Web平台来执行某些操作,但我们认为这只是半自动化,所谓的自动化应该是完全不需要人参与。目前,我们很多操作都还处于半自动化阶段,下一个阶段我们需要从半自动过渡到全自动。以MySQL系统为例,从运维角度看包括主从的高可用、服务过载的自我保护、容量自动诊断与评估以及集群的自动扩缩容等等。

现状和面临的挑战

下图是我们平台的现状,以关系数据库RDS平台为例,其中集成了很多管理的功能,例如主从的高可用、MGW的管理、DNS的变更、备份系统、升级流程、流量分配和切换系统、账号管理、数据归档、服务与资产的流转系统等等。

而且我们按照逻辑对平台设计进行了划分,例如以用户维度划分的RDS自助平台,DBA管理平台和测试环境管理平台;以功能维度划分的运维、运营和监控;以存储类型为维度划分的关系型数据库MySQL、分布式KV缓存、分布式KV存储,以及正在建设中的NewSQL数据库平台等等。未来,我们希望打造成“MySQL+NoSQL+NewSQL,存储+缓存的一站式服务平台”。

挑战一:RootCause定位难

即便我们打造了一个很强大的平台,但还是发现有很多问题难以搞定。第一个就是故障定位,如果是简单的故障,我们有类似天网、雷达这样的系统去发现和定位。但是如果故障发生在数据库内部,那就需要专业的数据库知识,去定位和查明到底是什么原因导致了故障。

通常来讲,故障的轨迹是一个链,但也可能是一个“多米诺骨牌”的连环。可能因为一些原因导致SQL执行变慢,引起连接数的增长,进而导致业务超时,而业务超时又会引发业务不断重试,结果会产生更多的问题。当我们收到一个报警时,可能已经过了30秒甚至更长时间,DBA再去查看时,已经错过了最佳的事故处理时机。所以,我们要在故障发生之后,制定一些应对策略,例如快速切换主库、自动屏蔽下线问题从库等等。除此之外,还有一个比较难的问题,就是如何避免相似的故障再次出现。

挑战二:人力和发展困境

第二个挑战是人力和发展的困境,当服务流量成倍增长时,其成本并不是以相同的速度对应增长的。当业务逻辑越来越复杂时,每增加一块钱的营收,其后面对应的数据库QPS可能是2倍甚至5倍,业务逻辑越复杂,服务支撑的难度越大。另外,传统的关系型数据库在容量、延时、响应时间以及数据量等方面很容易达到瓶颈,这就需要我们不断拆分集群,同时开发诉求也多种多样,当我们尝试使用平台化的思想去解决问题时,还要充分思考如何满足研发人员多样化的需求。

人力困境这一问题,从DBA的角度来说,时间被严重的碎片化,自身的成长就会遇到瓶颈,比如经常会做一些枯燥的重复操作;另外,业务咨询量暴增,尽管我们已经在尝试平台化的方法,但是还是跟不上业务发展的速度。还有一个就是专业的DBA越来越匮乏,越来越贵,关键是根本招聘不到人手。

在这种背景下,我们必须去思考:如何突破困局?如何朝着智能化转型?传统运维苦在哪里?智能化运维又能解决哪些问题?

首先从故障产生的原因来说,传统运维是故障触发,而智能运维是隐患驱动。换句话来说,智能运维不用报警,通过看报表就能知道可能要出事了,能够把故障消灭在“萌芽”阶段;第二,传统运维是被动接受,而智能运维是主动出击。但主动出击不一定是通过DBA去做,可能是系统或者机器人操作;第三,传统运维是由DBA发起和解决的,而智能运维是系统发起、RD自助;第四,传统运维属于“人肉救火”,而智能运维属于“智能决策执行”;最后一点,传统运维需要DBA亲临事故现场,而智能运维DBA只需要“隐身幕后”。

从自动化到智能化

那么,如何从半自动化过渡到自动化,进而发展到智能化运维呢?在这个过程中,我们会面临哪些痛点呢?

我们的目标是为整个公司的业务系统提供高效、稳定、快速的存储服务,这也是DBA存在的价值。业务并不关心后面是MySQL还是NoSQL,只关心数据是否没丢,服务是否可用,出了问题之后多长时间能够恢复等等。所以我们尽可能做到把这些东西对开发人员透明化,提供稳定高效快速的服务。而站在公司的角度,就是在有限的资源下,提升效率,降低成本,尽可能长远地解决问题。

上图是传统运维和智能运维的特点分析,左边属于传统运维,右边属于智能运维。传统运维在采集这一块做的不够,所以它没有太多的数据可供参考,其分析和预警能力是比较弱的。而智能运维刚好是反过来,重采集,很多功夫都在平时做了,包括分析、预警和执行,智能分析并推送关键报表。

而我们的目标,是让智能运维中的“报警+分析+执行”的比重占据的越来越少。

决策执行如何去做呢?我们都知道,预警重要但不紧急,但报警是紧急且重要的,如果你不能够及时去处理的话,事态可能会扩大,甚至会给公司带来直接的经济损失。

预警通常代表我们已经定位了一个问题,它的决策思路是非常清晰的,可以使用基于规则或AI的方式去解决,相对难度更小一些。而报警依赖于现场的链路分析,变量多、路径长,所以决策难,间接导致任何决策的风险可能都变大。所以说我们的策略就是全面的采集数据,然后增多预警,率先实现预警发现和处理的智能化。就像我们既有步枪,也有手枪和刺刀,能远距离解决敌人的,就尽量不要短兵相接、肉搏上阵。

数据采集,从数据库角度来说,我们产生的数据分成四块,Global Status、Variable,Processlist、InnoDB Status,Slow、Error、General Log和Binlog;从应用侧来说,包含端到端成功率、响应时间95线、99线、错误日志和吞吐量;从系统层面,支持秒级采样、操作系统各项指标;从变更侧来看,包含集群拓扑调整、在线DDL、DML变更、DB平台操作日志和应用端发布记录等等。

数据分析,首先是围绕集群分析,接着是实例、库,最后是表,其中每个对象都可以在多项指标上同比和环比,具体对比项可参考上图。

通过上面的步骤,我们基本可以获得数据库的画像,并且帮助我们从整体上做资源规划和服务治理。例如,有些集群实例数特别多且有继续增加的趋势,那么服务器需要scale up;读增加迅猛,读写比变大,那么应考虑存储KV化;利用率和分布情况会影响到服务器采购和预算制定;哪几类报警最多,就专项治理,各个击破。

从局部来说,我们根据分析到的一些数据,可以做一个集群的健康体检,例如数据库的某些指标是否超标、如何做调整等等。

数据库预警,通过分析去发现隐患,把报警转化为预警。上图是我们实际情况下的报警统计分析结果,其中主从延迟占比最大。假设load.1minPerCPU比较高,我们怎么去解决?那么,可能需要采购CPU单核性能更高的机器,而不是采用更多的核心。再比如说磁盘空间,当我们发现3T的磁盘空间普遍不够时,我们下次可以采购6T或更大空间的磁盘。

针对空间预警问题,什么时候需要拆分集群?MySQL数据库里,拆分或迁移数据库,花费的时间可能会很久。所以需要评估当前集群,按目前的增长速度还能支撑多长时间,进而反推何时要开始拆分、扩容等操作。

针对慢查询的预警问题,我们会统计红黑榜,上图是统计数据,也有利用率和出轨率的数据。假设这是一个金融事业群的数据库,假设有业务需要访问且是直连,那么这时就会产生几个问题:第一个,有没有数据所有者的授权;第二个,如果不通过服务化方式或者接口,发生故障时,它可能会导致整个金融的数据库挂,如何进行降级?所以,我们会去统计出轨率跟慢查询,如果某数据库正被以一种非法的方式访问,那么我们就会扫描出来,再去进行服务治理。

从运维的层面来说,我们做了故障快速转移,包括自动生成配置文件,自动判断是否启用监控,切换后自动重写配置,以及从库可自动恢复上线等等。

报警自动处理,目前来说大部分的处理工作还是基于规则,在大背景下拟定规则,触发之后,按照满足的前提条件触发动作,随着库的规则定义的逐渐完善和丰富,可以逐步解决很多简单的问题,这部分就不再需要人的参与。

展望

未来我们还会做一个故障诊断平台,类似于“扁鹊”,实现日志的采集、入库和分析,同时提供接口,供全链路的故障定位和分析、服务化治理。

展望智能运维,应该是在自动化和智能化上交叠演进,在ABC(AI、Big Data、Cloud Computing)三个方向上深入融合。在数据库领域,NoSQL和SQL界限正变得模糊,软硬结合、存储计算分离架构也被越来越多的应用,智能运维正当其时,我们也面临更多新的挑战。我们的目标是,希望通过DB平台的不断建设加固,平台能自己发现问题,自动定位问题,并智能的解决问题。

作者简介

应钢,美团点评研究员,数据库专家。曾就职于百度、新浪、去哪儿网等,10年数据库自动化运维开发、数据库性能优化、大规模数据库集群技术保障和架构优化经验。精通主流的SQL与NoSQL系统,现专注于公司业务在NewSQL领域的创新和落地。

来自:https://tech.meituan.com/Intelligent_Operation_Practice_in_meituan.html

LruCache在美团DSP系统中的应用演进(转载)

背景

DSP系统是互联网广告需求方平台,用于承接媒体流量,投放广告。业务特点是并发度高,平均响应低(百毫秒)。

为了能够有效提高DSP系统的性能,美团平台引入了一种带有清退机制的缓存结构LruCache(Least Recently Used Cache),在目前的DSP系统中,使用LruCache + 键值存储数据库的机制将远端数据变为本地缓存数据,不仅能够降低平均获取信息的耗时,而且通过一定的清退机制,也可以维持服务内存占用在安全区间。

本文将会结合实际应用场景,阐述引入LruCache的原因,并会在高QPS下的挑战与解决方案等方面做详细深入的介绍,希望能对DSP感兴趣的同学有所启发。

LruCache简介

LruCache采用的缓存算法为LRU(Least Recently Used),即最近最少使用算法。这一算法的核心思想是当缓存数据达到预设上限后,会优先淘汰近期最少使用的缓存对象。

LruCache内部维护一个双向链表和一个映射表。链表按照使用顺序存储缓存数据,越早使用的数据越靠近链表尾部,越晚使用的数据越靠近链表头部;映射表通过Key-Value结构,提供高效的查找操作,通过键值可以判断某一数据是否缓存,如果缓存直接获取缓存数据所属的链表节点,进一步获取缓存数据。LruCache结构图如下所示,上半部分是双向链表,下半部分是映射表(不一定有序)。双向链表中value_1所处位置为链表头部,value_N所处位置为链表尾部。

LruCache 初始结构

LruCache读操作,通过键值在映射表中查找缓存数据是否存在。如果数据存在,则将缓存数据所处节点从链表中当前位置取出,移动到链表头部;如果不存在,则返回查找失败,等待新数据写入。下图为通过LruCache查找key_2后LruCache结构的变化。

LruCache 查找

LruCache没有达到预设上限情况下的写操作,直接将缓存数据加入到链表头部,同时将缓存数据键值与缓存数据所处的双链表节点作为键值对插入到映射表中。下图是LruCache预设上限大于N时,将数据M写入后的数据结构。

LruCache 未达预设上限,添加数据

LruCache达到预设上限情况下的写操作,首先将链表尾部的缓存数据在映射表中的键值对删除,并删除链表尾部数据,再将新的数据正常写入到缓存中。下图是LruCache预设上限为N时,将数据M写入后的数据结构。

LruCache 达预设上限,添加数据

线程安全的LruCache在读写操作中,全部使用锁做临界区保护,确保缓存使用是线程安全的。

LruCache在美团DSP系统的应用场景

在美团DSP系统中广泛应用键值存储数据库,例如使用Redis存储广告信息,服务可以通过广告ID获取广告信息。每次请求都从远端的键值存储数据库中获取广告信息,请求耗时非常长。随着业务发展,QPS呈现巨大的增长趋势,在这种高并发的应用场景下,将广告信息从远端键值存储数据库中迁移到本地以减少查询耗时是常见解决方案。另外服务本身的内存占用要稳定在一个安全的区间内。面对持续增长的广告信息,引入LruCache + 键值存储数据库的机制来达到提高系统性能,维持内存占用安全、稳定的目标。

LruCache + Redis机制的应用演进

在实际应用中,LruCache + Redis机制实践分别经历了引入LruCache、LruCache增加时效清退机制、HashLruCache满足高QPS应用场景以及零拷贝机制四个阶段。各阶段的测试机器是16核16G机器。

演进一:引入LruCache提高美团DSP系统性能

在较低QPS环境下,直接请求Redis获取广告信息,可以满足场景需求。但是随着单机QPS的增加,直接请求Redis获取广告信息,耗时也会增加,无法满足业务场景的需求。

引入LruCache,将远端存放于Redis的信息本地化存储。LruCache可以预设缓存上限,这个上限可以根据服务所在机器内存与服务本身内存占用来确定,确保增加LruCache后,服务本身内存占用在安全范围内;同时可以根据查询操作统计缓存数据在实际使用中的命中率。

下图是增加LruCache结构前后,且增加LruCache后命中率高于95%的情况下,针对持续增长的QPS得出的数据获取平均耗时(ms)对比图:

引入LruCache前后平均耗时

根据平均耗时图显示可以得出结论:

  1. QPS高于250后,直接请求Redis获取数据的平均耗时达到10ms以上,完全无法满足使用的需求。
  2. 增加LruCache结构后,耗时下降一个量级。从平均耗时角度看,QPS不高于500的情况下,耗时低于2ms。

下图是增加LruCache结构前后,且增加LruCache后命中率高于95%的情况下,针对持续增长的QPS得出的数据获取Top999耗时(ms)对比图:

引入LruCache前后tp999耗时

根据Top999耗时图可以得出以下结论:

  1. 增加LruCache结构后,Top999耗时比平均耗时增长一个数量级。
  2. 即使是较低的QPS下,使用LruCache结构的Top999耗时也是比较高的。

引入LruCache结构,在实际使用中,在一定的QPS范围内,确实可以有效减少数据获取的耗时。但是QPS超出一定范围后,平均耗时和Top999耗时都很高。所以LruCache在更高的QPS下性能还需要进一步优化。

演进二:LruCache增加时效清退机制

在业务场景中,Redis中的广告数据有可能做修改。服务本身作为数据的使用方,无法感知到数据源的变化。当缓存的命中率较高或者部分数据在较长时间内多次命中,可能出现数据失效的情况。即数据源发生了变化,但服务无法及时更新数据。针对这一业务场景,增加了时效清退机制。

时效清退机制的组成部分有三点:设置缓存数据过期时间,缓存数据单元增加时间戳以及查询中的时效性判断。缓存数据单元将数据进入LruCache的时间戳与数据一起缓存下来。缓存过期时间表示缓存单元缓存的时间上限。查询中的时效性判断表示查询时的时间戳与缓存时间戳的差值超过缓存过期时间,则强制将此数据清空,重新请求Redis获取数据做缓存。

在查询中做时效性判断可以最低程度的减少时效判断对服务的中断。当LruCache预设上限较低时,定期做全量数据清理对于服务本身影响较小。但如果LruCache的预设上限非常高,则一次全量数据清理耗时可能达到秒级甚至分钟级,将严重阻断服务本身的运行。所以将时效性判断加入到查询中,只对单一的缓存单元做时效性判断,在服务性能和数据有效性之间做了折中,满足业务需求。

演进三:高QPS下HashLruCache的应用

LruCache引入美团DSP系统后,在一段时间内较好地支持了业务的发展。随着业务的迭代,单机QPS持续上升。在更高QPS下,LruCache的查询耗时有了明显的提高,逐渐无法适应低平响的业务场景。在这种情况下,引入了HashLruCache机制以解决这个问题。

LruCache在高QPS下的耗时增加原因分析:

线程安全的LruCache中有锁的存在。每次读写操作之前都有加锁操作,完成读写操作之后还有解锁操作。在低QPS下,锁竞争的耗时基本可以忽略;但是在高QPS下,大量的时间消耗在了等待锁的操作上,导致耗时增长。

HashLruCache适应高QPS场景:

针对大量的同步等待操作导致耗时增加的情况,解决方案就是尽量减小临界区。引入Hash机制,对全量数据做分片处理,在原有LruCache的基础上形成HashLruCache,以降低查询耗时。

HashLruCache引入某种哈希算法,将缓存数据分散到N个LruCache上。最简单的哈希算法即使用取模算法,将广告信息按照其ID取模,分散到N个LruCache上。查询时也按照相同的哈希算法,先获取数据可能存在的分片,然后再去对应的分片上查询数据。这样可以增加LruCache的读写操作的并行度,减小同步等待的耗时。

下图是使用16分片的HashLruCache结构前后,且命中率高于95%的情况下,针对持续增长的QPS得出的数据获取平均耗时(ms)对比图:

引入HashLruCache前后平均耗时

根据平均耗时图可以得出以下结论:

  1. 使用HashLruCache后,平均耗时减少将近一半,效果比较明显。
  2. 对比不使用HashLruCache的平均耗时可以发现,使用HashLruCache的平均耗时对QPS的增长不敏感,没有明显增长。

下图是使用16分片的HashLruCache结构前后,且命中率高于95%的情况下,针对持续增长的QPS得出的数据获取Top999耗时(ms)对比图:

引入HashLruCache前后tp999耗时

根据Top999耗时图可以得出以下结论:

  1. 使用HashLruCache后,Top999耗时减少为未使用时的三分之一左右,效果非常明显。
  2. 使用HashLruCache的Top999耗时随QPS增长明显比不使用的情况慢,相对来说对QPS的增长敏感度更低。

引入HashLruCache结构后,在实际使用中,平均耗时和Top999耗时都有非常明显的下降,效果非常显著。

HashLruCache分片数量确定:

根据以上分析,进一步提高HashLruCache性能的一个方法是确定最合理的分片数量,增加足够的并行度,减少同步等待消耗。所以分片数量可以与CPU数量一致。由于超线程技术的使用,可以将分片数量进一步提高,增加并行性。

下图是使用HashLruCache机制后,命中率高于95%,不同分片数量在不同QPS下得出的数据获取平均耗时(ms)对比图:

HashLruCache分片数量耗时

平均耗时图显示,在较高的QPS下,平均耗时并没有随着分片数量的增加而有明显的减少,基本维持稳定的状态。

下图是使用HashLruCache机制后,命中率高于95%,不同分片数量在不同QPS下得出的数据获取Top999耗时(ms)对比图:

HashLruCache分片tp99耗时

Top999耗时图显示,QPS为750时,分片数量从8增长到16再增长到24时,Top999耗时有一定的下降,并不显著;QPS为1000时,分片数量从8增长到16有明显下降,但是从16增长到24时,基本维持了稳定状态。明显与实际使用的机器CPU数量有较强的相关性。

HashLruCache机制在实际使用中,可以根据机器性能并结合实际场景的QPS来调节分片数量,以达到最好的性能。

演进四:零拷贝机制

线程安全的LruCache内部维护一套数据。对外提供数据时,将对应的数据完整拷贝一份提供给调用方使用。如果存放结构简单的数据,拷贝操作的代价非常小,这一机制不会成为性能瓶颈。但是美团DSP系统的应用场景中,LruCache中存放的数据结构非常复杂,单次的拷贝操作代价很大,导致这一机制变成了性能瓶颈。

理想的情况是LruCache对外仅仅提供数据地址,即数据指针。使用方在业务需要使用的地方通过数据指针获取数据。这样可以将复杂的数据拷贝操作变为简单的地址拷贝,大量减少拷贝操作的性能消耗,即数据的零拷贝机制。直接的零拷贝机制存在安全隐患,即由于LruCache中的时效清退机制,可能会出现某一数据已经过期被删除,但是使用方仍然通过持有失效的数据指针来获取该数据。

进一步分析可以确定,以上问题的核心是存放于LruCache的数据生命周期对于使用方不透明。解决这一问题的方案是为LruCache中存放的数据添加原子变量的引用计数。使用原子变量不仅确保了引用计数的线程安全,使得各个线程读取的引用计数一致,同时保证了并发状态最小的同步性能开销。不论是LruCache中还是使用方,每次获取数据指针时,即将引用计数加1;同理,不再持有数据指针时,引用计数减1。当引用计数为0时,说明数据没有被任何使用方使用,且数据已经过期从LruCache中被删除。这时删除数据的操作是安全的。

下图是使零拷贝机制后,命中率高于95%,不同QPS下得出的数据获取平均耗时(ms)对比图:

HashLruCache分片数量耗时

平均耗时图显示,使用零拷贝机制后,平均耗时下降幅度超过60%,效果非常显著。

下图是使零拷贝机制后,命中率高于95%,不同QPS下得出的数据获取Top999耗时(ms)对比图:

HashLruCache分片数量耗时

根据Top999耗时图可以得出以下结论:

  1. 使用零拷贝后,Top999耗时降幅将近50%,效果非常明显。
  2. 在高QPS下,使用零拷贝机制的Top999耗时随QPS增长明显比不使用的情况慢,相对来说对QPS的增长敏感度更低。

引入零拷贝机制后,通过拷贝指针替换拷贝数据,大量降低了获取复杂业务数据的耗时,同时将临界区减小到最小。线程安全的原子变量自增与自减操作,目前在多个基础库中都有实现,例如C++11就提供了内置的整型原子变量,实现线程安全的自增与自减操作。

在HashLruCache中引入零拷贝机制,可以进一步有效降低平均耗时和Top999耗时,且在高QPS下对于稳定Top999耗时有非常好的效果。

总结

下图是一系列优化措施前后,命中率高于95%,不同QPS下得出的数据获取平均耗时(ms)对比图:

HashLruCache分片数量耗时

平均耗时图显示,优化后的平均耗时仅为优化前的20%以内,性能提升非常明显。优化后平均耗时对于QPS的增长敏感度更低,更好的支持了高QPS的业务场景。

下图是一系列优化措施前后,命中率高于95%,不同QPS下得出的数据获取Top999耗时(ms)对比图:

HashLruCache分片数量耗时

Top999耗时图显示,优化后的Top999耗时仅为优化前的20%以内,对于长尾请求的耗时有非常明显的降低。

LruCache是一个非常常见的数据结构。在美团DSP的高QPS业务场景下,发挥了重要的作用。为了符合业务需要,在原本的清退机制外,补充了时效性强制清退机制。随着业务的发展,针对更高QPS的业务场景,使用HashLruCache机制,降低缓存的查询耗时。针对不同的具体场景,在不同的QPS下,不断尝试更合理的分片数量,不断提高HashLruCache的查询性能。通过引用计数的方案,在HashLruCache中引入零拷贝机制,进一步大幅降低平均耗时和Top999耗时,更好的服务于业务场景的发展。

作者简介

王粲,2018年11月加入美团,任职美团高级工程师,负责美团DSP系统后端基础架构的研发工作。

崔涛,2015年6月加入美团,任职资深广告技术专家,期间一手指导并从0到1搭建美团DSP投放平台,具备丰富的大规模计算引擎的开发和性能优化经验。

霜霜,2015年6月加入美团,任职美团高级工程师,美团DSP系统后端基础架构与机器学习架构负责人,全面负责DSP业务广告召回和排序服务的架构设计与优化。文章来自:https://tech.meituan.com/lrucache_practice_dsp.html