阿里云CentOS 7上安装配置Docker

Docker 是一个开源工具,它可以让创建和管理 Linux 容器变得简单。容器就像是轻量级的虚拟机,并且可以以毫秒级的速度来启动或停止。Docker 帮助系统管理员和程序员在容器中开发应用程序,并且可以扩展到成千上万的节点。

1

这是一只鲸鱼,它托着许多集装箱。我们可以把宿主机可当做这只鲸鱼,把相互隔离的容器可看成集装箱,每个集装箱中都包含自己的应用程序。

Docker与传统虚拟区别

传统虚拟化技术的体系架构:

2

Docker技术的体系架构:

3

容器和 VM(虚拟机)的主要区别是:

  • 容器提供了基于进程的隔离,而虚拟机提供了资源的完全隔离。
  • 虚拟机可能需要一分钟来启动,而容器只需要一秒钟或更短。
  • 容器使用宿主操作系统的内核,而虚拟机使用独立的内核。

Doker 平台的基本构成

4

Docker 平台基本上由三部分组成:

  • 客户端:用户使用 Docker 提供的工具(CLI 以及 API 等)来构建,上传镜像并发布命令来创建和启动容器
  • Docker 主机:从 Docker registry 上下载镜像并启动容器
  • Docker registry:Docker 镜像仓库,用于保存镜像,并提供镜像上传和下载
  • 后面的文章会具体分析。

Docker 容器的状态机

5

一个容器在某个时刻可能处于以下几种状态之一:

  • created:已经被创建 (使用 docker ps -a 命令可以列出)但是还没有被启动 (使用 docker ps 命令还无法列出)
  • running:运行中
  • paused:容器的进程被暂停了
  • restarting:容器的进程正在重启过程中
  • exited:上图中的 stopped 状态,表示容器之前运行过但是现在处于停止状态(要区别于 created 状态,它是指一个新创出的尚未运行过的容器)。可以通过 start 命令使其重新进入 running 状态
  • destroyed:容器被删除了,再也不存在了

Docker 的安装

RedHat/CentOS必须要6.6版本以上,或者7.x才能安装docker,建议在RedHat/CentOS 7上使用docker,因为RedHat/CentOS 7的内核升级到了kernel 3.10,对lxc容器支持更好。

查看Linux内核版本(内核版本必须是3.10或者以上):

cat /proc/version

uname -a

lsb_release -a

##无法执行命令安装
yum install -y redhat-lsb

更新YUM源:

yum update

安装:

yum  install docker -y

检查版本:

docker -v

安装完成后,使用下面的命令来启动 docker 服务,并将其设置为开机启动:

service docker start
chkconfig docker on

下载官方的 CentOS 镜像:

docker pull centos

检查CentOS 镜像是否被获取:

docker images

下载完成后,你应该会看到:

[root@iZ2ze74fkxrls31tr2ia2fZ ~]# docker images centos
REPOSITORY       TAG        IMAGE ID     CREATED         SIZE
docker.io/centos latest    3fa822599e10    3weeks ago   203.5 MB

如果看到以上输出,说明你可以使用“docker.io/centos”这个镜像了,或将其称为仓库(Repository),该镜像有一个名为“latest”的标签(Tag),此外还有一个名为“3fa822599e10 ”的镜像 ID(可能您所看到的镜像 ID 与此处的不一致,那是正常现象,因为这个数字是随机生成的)。此外,我们可以看到该镜像只有 203.5 MB,非常小巧,而不像虚拟机的镜像文件那样庞大。

启动容器:

docker run -i -t -v /root/software/:/mnt/software/ 3fa822599e10 /bin/bash

docker run -ti ubuntu:14.04 /bin/bash

命令参数说明:
docker run <相关参数> <镜像 ID> <初始命令>

  • -i:表示以“交互模式”运行容器
  • -t:表示容器启动后会进入其命令行
  • -v:表示需要将本地哪个目录挂载到容器中,格式:-v <宿主机目录>:<容器目录>

Docker命令

我们可以把Docker 的命令大概地分类如下:

镜像操作:
    build     Build an image from a Dockerfile
    commit    Create a new image from a container's changes
    images    List images
    load      Load an image from a tar archive or STDIN
    pull      Pull an image or a repository from a registry
    push      Push an image or a repository to a registry
    rmi       Remove one or more images
    search    Search the Docker Hub for images
    tag       Tag an image into a repository
    save      Save one or more images to a tar archive 
    history   显示某镜像的历史
    inspect   获取镜像的详细信息

    容器及其中应用的生命周期操作:
    create    创建一个容器
    kill      Kill one or more running containers
    inspect   Return low-level information on a container, image or task
    pause     Pause all processes within one or more containers
    ps        List containers
    rm        删除一个或者多个容器
    rename    Rename a container
    restart   Restart a container
    run       创建并启动一个容器
    start     启动一个处于停止状态的容器
    stats     显示容器实时的资源消耗信息
    stop      停止一个处于运行状态的容器
    top       Display the running processes of a container
    unpause   Unpause all processes within one or more containers
    update    Update configuration of one or more containers
    wait      Block until a container stops, then print its exit code
    attach    Attach to a running container
    exec      Run a command in a running container
    port      List port mappings or a specific mapping for the container
    logs      获取容器的日志

    容器文件系统操作:
    cp        Copy files/folders between a container and the local filesystem
    diff      Inspect changes on a container's filesystem
    export    Export a container's filesystem as a tar archive
    import    Import the contents from a tarball to create a filesystem image

    Docker registry 操作:
    login     Log in to a Docker registry.
    logout    Log out from a Docker registry.

    Volume 操作
    volume    Manage Docker volumes

    网络操作
    network   Manage Docker networks

    Swarm 相关操作
    swarm     Manage Docker Swarm
    service   Manage Docker services
    node      Manage Docker Swarm nodes

    系统操作:
    version   Show the Docker version information
    events    持续返回docker 事件
    info      显示Docker 主机系统范围内的信息
# 查看运行中的容器
docker ps

# 查看所有容器
docker ps -a

# 退出容器
按Ctrl+D 即可退出当前容器【但退出后会停止容器】

# 退出不停止容器:
组合键:Ctrl+P+Q

# 启动容器
docker start 容器名或ID

# 进入容器
docker attach 容器名或ID

# 停止容器
docker stop 容器名或ID

# 暂停容器
docker pause 容器名或ID

#继续容器
docker unpause 容器名或ID

# 删除容器
docker rm 容器名或ID

# 删除全部容器--慎用
docker stop $(docker ps -q) & docker rm $(docker ps -aq)

#保存容器,生成镜像
docker commit 容器ID 镜像名称

#从 host 拷贝文件到 container 里面
docker cp /home/soft centos:/webapp

docker run与start的区别

docker run 只在第一次运行时使用,将镜像放到容器中,以后再次启动这个容器时,只需要使用命令docker start 即可。

docker run相当于执行了两步操作:将镜像放入容器中(docker create),然后将容器启动,使之变成运行时容器(docker start)。

6

而docker start的作用是,重新启动已存在的镜像。也就是说,如果使用这个命令,我们必须事先知道这个容器的ID,或者这个容器的名字,我们可以使用docker ps找到这个容器的信息。

7

因为容器的ID是随机码,而容器的名字又是看似无意义的命名,我们可以使用命令:

docker rename jovial_cori  centos

给这个容器命名。这样以后,我们再次启动或停止容器时,就可以直接使用这个名字:

docker [stop] [start]  new_name

而要显示出所有容器,包括没有启动的,可以使用命令:

docker ps -a

Docker配置

更改存储目录:

#复制docker存储目录
rsync -aXS /var/lib/docker/. /home/docker

#更改 docker 存储文件目录
ln -s  /home/docker  /var/lib/docker

获取IP:

docker inspect <container id>

要获取所有容器名称及其IP地址只需一个命令:

docker inspect -f '{{.Name}} - {{.NetworkSettings.IPAddress }}' $(docker ps -aq)

docker inspect --format='{{.Name}} - {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' $(docker ps -aq)

Docker 镜像加速器

注册个帐号

https://dev.aliyun.com/search.html

阿里云会自动为用户分配一个镜像加速器的地址,登录后进入”管理中心”–>”加速器”,里面有分配给你的镜像加速器的地址以及各个环境的使用说明。

镜像加速器地址:https://xxxxx.mirror.aliyuncs.com

如何配置镜像加速器

针对Docker客户端版本大于1.10.0的用户
您可以通过修改daemon配置文件/etc/docker/daemon.json来使用加速器:

{
    "registry-mirrors": ["<your accelerate address>"]
}

重启Docker Daemon:

sudo systemctl daemon-reload
sudo systemctl restart docker

后续:
1,查看镜像
docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
1fef6edc3f23 docker.io/centos "bin/bash" About a minute ago Exited (0) 8 seconds ago affectionate_khorana
66f6e4ff1098 docker.io/centos "bin/bash" About a minute ago Exited (0) About a minute ago elastic_khorana
a44b68ea8265 75835a67d134 "/bin/bash" 7 minutes ago Exited (0) 2 minutes ago angry_meitner
d0c8cbacb8ce 75835a67d134 "/bin/bash" 25 minutes ago Exited (0) 13 minutes ago mystifying_perlman
2.更新镜像
docker commit #第一列容器ID centos:update
3.保存镜像
docker save centos:update > centos.tar
4.加载镜像
docker load < centos.tar



常见命令:

1,后台启动docker并且端口映射

docker run -itd -p 3390<物理机端口>:3390<容器端口> cd3c0fd5029a /bin/bash

2,进入docker

docker attach <容器ID>

3,退出docker,不关闭container

ctrl+p ctrl+q

4.更新镜像

docker commit <容器ID> centos:update<自己定义的名字:标签>

5.保存镜像

docker save centos:update > centos.tar

6.加载镜像

docker load < centos.tar

PSSH 批量管理服务器

pssh命令是一个python编写可以在多台服务器上执行命令的工具,同时支持拷贝文件,是同类工具中很出色的,类似pdsh,个人认为相对pdsh更为简便,使用必须在各个服务器上配置好密钥认证访问。

1. 安装

安装可以使用yum或者apt-get安装,还可以使用源码安装, 由于我使用apt-get安装不好用,所以这里我只说下源码安装

wget http://parallel-ssh.googlecode.com/files/pssh-2.3.1.tar.gz 
tar xf pssh-2.3.1.tar.gz 
cd pssh-2.3.1/ 
python setup.py install

2. pssh选项说明

复制代码
--version:查看版本 
--help:查看帮助,即此信息 
-h:主机文件列表,内容格式”[user@]host[:port]” 
-H:主机字符串,内容格式”[user@]host[:port]” 
-:登录使用的用户名 
-p:并发的线程数【可选】 
-o:输出的文件目录【可选】 
-e:错误输入文件【可选】 
-t:TIMEOUT 超时时间设置,0无限制【可选】 
-O:SSH的选项 
-v:详细模式 
-A:手动输入密码模式 
-x:额外的命令行参数使用空白符号,引号,反斜线处理 
-X:额外的命令行参数,单个参数模式,同-x 
-i:每个服务器内部处理信息输出 
-P:打印出服务器返回信息
复制代码

 

3. 实例

(1) 查看版本

#pssh --version
#2.3.1

(2) 查看帮助

复制代码
#pssh --help
Usage: pssh [OPTIONS] command [...]

Options:
  --version             show program's version number and exit
  --help                show this help message and exit
  -h HOST_FILE, --hosts=HOST_FILE
                        hosts file (each line "[user@]host[:port]")
  -H HOST_STRING, --host=HOST_STRING
                        additional host entries ("[user@]host[:port]")
  -l USER, --user=USER  username (OPTIONAL)
  -p PAR, --par=PAR     max number of parallel threads (OPTIONAL)
  -o OUTDIR, --outdir=OUTDIR
                        output directory for stdout files (OPTIONAL)
  -e ERRDIR, --errdir=ERRDIR
                        output directory for stderr files (OPTIONAL)
  -t TIMEOUT, --timeout=TIMEOUT
                        timeout (secs) (0 = no timeout) per host (OPTIONAL)
  -O OPTION, --option=OPTION
                        SSH option (OPTIONAL)
  -v, --verbose         turn on warning and diagnostic messages (OPTIONAL)
  -A, --askpass         Ask for a password (OPTIONAL)
  -x ARGS, --extra-args=ARGS
                        Extra command-line arguments, with processing for
                        spaces, quotes, and backslashes
  -X ARG, --extra-arg=ARG
                        Extra command-line argument
  -i, --inline          inline aggregated output and error for each server
  --inline-stdout       inline standard output for each server
  -I, --send-input      read from standard input and send as input to ssh
  -P, --print           print output as we get it

Example: pssh -h hosts.txt -l irb2 -o /tmp/foo uptime
复制代码

(3) 使用主机文件列表执行pwd命令

复制代码
#pssh -h ip.txt -A -i pwd
Warning: do not enter your password if anyone else has superuser
privileges or access to your account.
Password: 
[1] 19:58:51 [SUCCESS] root@192.168.200.152
/root
[2] 19:58:51 [SUCCESS] root@192.168.200.154
/root
[3] 19:58:51 [SUCCESS] root@192.168.200.153
/root
[4] 19:58:52 [SUCCESS] root@192.168.200.155
/root
复制代码

说明: -h 后面的ip是要操作的机器ip列表,格式如下: root@192.168.200.152   -A 表示手动输入密码模式  -i表示要执行的命令

(4) 使用主机文件列表执行date命令

 

复制代码
#pssh -h ip.txt -A -i date
Warning: do not enter your password if anyone else has superuser
privileges or access to your account.
Password: 
[1] 20:13:36 [SUCCESS] root@192.168.200.152
2016年 07月 11日 星期一 20:10:24 CST
[2] 20:13:36 [SUCCESS] root@192.168.200.154
2016年 07月 11日 星期一 20:10:11 CST
[3] 20:13:36 [SUCCESS] root@192.168.200.153
2016年 07月 11日 星期一 20:10:56 CST
[4] 20:13:36 [SUCCESS] root@192.168.200.155
2016年 07月 11日 星期一 20:10:10 CST
复制代码

 

(5) 指定用户名

复制代码
#可以通过-l命令指定用户名
$pssh -h ip.txt -A -i -l root date
Warning: do not enter your password if anyone else has superuser
privileges or access to your account.
Password: 
[1] 20:13:36 [SUCCESS] 192.168.200.152
2016年 07月 11日 星期一 20:10:24 CST
[2] 20:13:36 [SUCCESS] 192.168.200.154
2016年 07月 11日 星期一 20:10:11 CST
[3] 20:13:36 [SUCCESS] 192.168.200.153
2016年 07月 11日 星期一 20:10:56 CST
[4] 20:13:36 [SUCCESS] 192.168.200.155
2016年 07月 11日 星期一 20:10:10 CST
复制代码

 

(6) 批量初始化服务器key

#让远程服务自动在/root/.ssh生成秘钥,方便部署证书信任
pssh -h host1.txt -l root -A "ssh-keygen -t rsa -f /root/.ssh/id_rsa -P \"\""

 

(7)批量修改机器密码

pssh -h host.txt -l root -A 'echo root:xxxxxxxx | chpasswd'

 

 

 

4. 介绍软件包内其他命令

     pscp   传输文件到多个hosts,他的特性和scp差不多
# 通过pscp对多个机器传文件,把test.sh传送到多个机器上
$ pscp.pssh -h host.txt -l root -A test.sh  

使用pscp对多个机器传文件,然后再通过pssh执行脚本,方便快捷

 

    pslurp   从多台远程机器拷贝文件
    pnuke    kill远程机器的进程

通过上面我们可以看到直接可以远程执行命令,对于机器的批量操作很方便。 大家使用的时候可以根据自己的实际需求编写相应的脚本

这里就简单写这几个例子。

 

如何优雅的设计和使用缓存?

背景

在之前的文章中你应该知道的缓存进化史介绍了爱奇艺的缓存架构和缓存的进化历史。俗话说得好,工欲善其事,必先利其器,有了好的工具肯定得知道如何用好这些工具,本篇将介绍如何利用好缓存。

1.确认是否需要缓存

在使用缓存之前,需要确认你的项目是否真的需要缓存。使用缓存会引入的一定的技术复杂度,后文也将会一一介绍这些复杂度。一般来说从两个方面来个是否需要使用缓存:

  1. CPU占用:如果你有某些应用需要消耗大量的cpu去计算,比如正则表达式,如果你使用正则表达式比较频繁,而其又占用了很多CPU的话,那你就应该使用缓存将正则表达式的结果给缓存下来。
  2. 数据库IO占用:如果你发现你的数据库连接池比较空闲,那么不应该用缓存。但是如果数据库连接池比较繁忙,甚至经常报出连接不够的报警,那么是时候应该考虑缓存了。笔者曾经有个服务,被很多其他服务调用,其他时间都还好,但是在每天早上10点的时候总是会报出数据库连接池连接不够的报警,经过排查,发现有几个服务选择了在10点做定时任务,大量的请求打过来,DB连接池不够,从而报出连接池不够的报警。这个时候有几个选择,我们可以通过扩容机器来解决,也可以通过增加数据库连接池来解决,但是没有必要增加这些成本,因为只有在10点的时候才会出现这个问题。后来引入了缓存,不仅解决了这个问题,而且还增加了读的性能。

如果并没有上述两个问题,那么你不必为了增加缓存而缓存。

2.选择合适的缓存

缓存又分进程内缓存和分布式缓存两种。很多人包括笔者在开始选缓存框架的时候都感到了困惑:网上的缓存太多了,大家都吹嘘自己很牛逼,我该怎么选择呢?

2.1 选择合适的进程缓存

首先看看几个比较常用的缓存的比较,具体原理可以参考你应该知道的缓存进化史:

比较项 ConcurrentHashMap LRUMap Ehcache Guava Cache Caffeine
读写性能 很好,分段锁 一般,全局加锁 好,需要做淘汰操作 很好
淘汰算法 LRU,一般 支持多种淘汰算法,LRU,LFU,FIFO LRU,一般 W-TinyLFU, 很好
功能丰富程度 功能比较简单 功能比较单一 功能很丰富 功能很丰富,支持刷新和虚引用等 功能和Guava Cache类似
工具大小 jdk自带类,很小 基于LinkedHashMap,较小 很大,最新版本1.4MB 是Guava工具类中的一个小部分,较小 一般,最新版本644KB
是否持久化
是否支持集群
  • 对于ConcurrentHashMap来说,比较适合缓存比较固定不变的元素,且缓存的数量较小的。虽然从上面表格中比起来有点逊色,但是其由于是jdk自带的类,在各种框架中依然有大量的使用,比如我们可以用来缓存我们反射的Method,Field等等;也可以缓存一些链接,防止其重复建立。在Caffeine中也是使用的ConcurrentHashMap来存储元素。
  • 对于LRUMap来说,如果不想引入第三方包,又想使用淘汰算法淘汰数据,可以使用这个。
  • 对于Ehcache来说,由于其jar包很大,较重量级。对于需要持久化和集群的一些功能的,可以选择Ehcache。笔者没怎么使用过这个缓存,如果要选择的话,可以选择分布式缓存来替代Ehcache。
  • 对于Guava Cache来说,Guava这个jar包在很多Java应用程序中都有大量的引入,所以很多时候其实是直接用就好了,并且其本身是轻量级的而且功能较为丰富,在不了解Caffeine的情况下可以选择Guava Cache。
  • 对于Caffeine来说,笔者是非常推荐的,其在命中率,读写性能上都比Guava Cache好很多,并且其API和Guava cache基本一致,甚至会多一点。在真实环境中使用Caffeine,取得过不错的效果。

总结一下:如果不需要淘汰算法则选择ConcurrentHashMap,如果需要淘汰算法和一些丰富的API,这里推荐选择Caffeine。

2.2 选择合适的分布式缓存

这里选取三个比较出名的分布式缓存来作为比较,MemCache(没有实战使用过),Redis(在美团又叫Squirrel),Tair(在美团又叫Cellar)。不同的分布式缓存功能特性和实现原理方面有很大的差异,因此他们所适应的场景也有所不同。

比较项 MemCache Squirrel/Redis Cellar/Tair
数据结构 只支持简单的Key-Value结构 String,Hash, List, Set, Sorted Set String,HashMap, List,Set
持久化 不支持 支持 支持
容量大小 数据纯内存,数据存储不宜过多 数据全内存,资源成本考量不宜超过100GB 可以配置全内存或内存+磁盘引擎,数据容量可无限扩充
读写性能 很高 很高(RT0.5ms左右) String类型比较高(RT1ms左右),复杂类型比较慢(RT5ms左右)
  • MemCache:这一块接触得比较少,不做过多的推荐。其吞吐量较大,但是支持的数据结构较少,并且不支持持久化。
  • Redis:支持丰富的数据结构,读写性能很高,但是数据全内存,必须要考虑资源成本,支持持久化。
  • Tair: 支持丰富的数据结构,读写性能较高,部分类型比较慢,理论上容量可以无限扩充。

总结:如果服务对延迟比较敏感,Map/Set数据也比较多的话,比较适合Redis。如果服务需要放入缓存量的数据很大,对延迟又不是特别敏感的话,那就可以选择Tair。在美团的很多应用中对Tair都有应用,在笔者的项目中使用其存放我们生成的支付token,支付码,用来替代数据库存储。大部分的情况下两者都可以选择,互为替代。

3.多级缓存

很多人一想到缓存马上脑子里面就会出现下面的图:


Redis用来存储热点数据,Redis中没有的数据则直接去数据库访问。

在之前介绍本地缓存的时候,很多人都问我,我已经有Redis了,我干嘛还需要了解Guava,Caffeine这些进程缓存呢。我基本统一回复下面两个答案:

  1. Redis如果挂了或者使用老版本的Redis,其会进行全量同步,此时Redis是不可用的,这个时候我们只能访问数据库,很容易造成雪崩。
  2. 访问Redis会有一定的网络I/O以及序列化反序列化,虽然性能很高但是其终究没有本地方法快,可以将最热的数据存放在本地,以便进一步加快访问速度。这个思路并不是我们做互联网架构独有的,在计算机系统中使用L1,L2,L3多级缓存,用来减少对内存的直接访问,从而加快访问速度。

所以如果仅仅是使用Redis,能满足我们大部分需求,但是当需要追求更高的性能以及更高的可用性的时候,那就不得不了解多级缓存。

3.1使用进程缓存

对于进程内缓存,其本来受限于内存的大小的限制,以及进程缓存更新后其他缓存无法得知,所以一般来说进程缓存适用于:

  1. 数据量不是很大,数据更新频率较低,之前我们有个查询商家名字的服务,在发送短信的时候需要调用,由于商家名字变更频率较低,并且就算是变更了没有及时变更缓存,短信里面带有老的商家名字客户也能接受。利用Caffeine作为本地缓存,size设置为1万,过期时间设置为1个小时,基本能在高峰期解决问题。
  2. 如果数据量更新频繁,也想使用进程缓存的话,那么可以将其过期时间设置为较短,或者设置其较短的自动刷新的时间。这些对于Caffeine或者Guava Cache来说都是现成的API。

3.2使用多级缓存

俗话说得好,世界上没有什么是一个缓存解决不了的事,如果有,那就两个。

一般来说我们选择一个进程缓存和一个分布式缓存来搭配做多级缓存,一般来说引入两个也足够了,如果使用三个,四个的话,技术维护成本会很高,反而有可能会得不偿失,如下图所示:


利用Caffeine做一级缓存,Redis作为二级缓存。

  1. 首先去Caffeine中查询数据,如果有直接返回。如果没有则进行第2步。
  2. 再去Redis中查询,如果查询到了返回数据并在Caffeine中填充此数据。如果没有查到则进行第3步。
  3. 最后去Mysql中查询,如果查询到了返回数据并在Redis,Caffeine中依次填充此数据。

对于Caffeine的缓存,如果有数据更新,只能删除更新数据的那台机器上的缓存,其他机器只能通过超时来过期缓存,超时设定可以有两种策略:

  • 设置成写入后多少时间后过期
  • 设置成写入后多少时间刷新

对于Redis的缓存更新,其他机器立马可见,但是也必须要设置超时时间,其时间比Caffeine的过期长。

为了解决进程内缓存的问题,设计进一步优化:


通过Redis的pub/sub,可以通知其他进程缓存对此缓存进行删除。如果Redis挂了或者订阅机制不靠谱,依靠超时设定,依然可以做兜底处理。

4.缓存更新

一般来说缓存的更新有两种情况:

  • 先删除缓存,再更新数据库。
  • 先更新数据库,再删除缓存。 这两种情况在业界,大家对其都有自己的看法。具体怎么使用还得看各自的取舍。当然肯定会有人问为什么要删除缓存呢?而不是更新缓存呢?你可以想想当有多个并发的请求更新数据,你并不能保证更新数据库的顺序和更新缓存的顺序一致,那就会出现数据库中和缓存中数据不一致的情况。所以一般来说考虑删除缓存。

4.1先删除缓存,再更新数据库

对于一个更新操作简单来说,就是先去各级缓存进行删除,然后更新数据库。这个操作有一个比较大的问题,在对缓存删除完之后,有一个读请求,这个时候由于缓存被删除所以直接会读库,读操作的数据是老的并且会被加载进入缓存当中,后续读请求全部访问的老数据。


对缓存的操作不论成功失败都不能阻塞我们对数据库的操作,那么很多时候删除缓存可以用异步的操作,但是先删除缓存不能很好的适用于这个场景。

先删除缓存也有一个好处是,如果对数据库操作失败了,那么由于先删除的缓存,最多只是造成Cache Miss。

4.2先更新数据库,再删除缓存(推荐)

如果我们使用更新数据库,再删除缓存就能避免上面的问题。但是同样的引入了新的问题,试想一下有一个数据此时是没有缓存的,所以查询请求会直接落库,更新操作在查询请求之后,但是更新操作删除数据库操作在查询完之后回填缓存之前,就会导致我们缓存中和数据库出现缓存不一致。

为什么我们这种情况有问题,很多公司包括Facebook还会选择呢?因为要触发这个条件比较苛刻。

  1. 首先需要数据不在缓存中。
  2. 其次查询操作需要在更新操作先到达数据库。
  3. 最后查询操作的回填比更新操作的删除后触发,这个条件基本很难出现,因为更新操作的本来在查询操作之后,一般来说更新操作比查询操作稍慢。但是更新操作的删除却在查询操作之后,所以这个情况比较少出现。

对比上面4.1的问题来说这种问题的概率很低,况且我们有超时机制保底所以基本能满足我们的需求。如果真的需要追求完美,可以使用二阶段提交,但是其成本和收益一般来说不成正比。

当然还有个问题是如果我们删除失败了,缓存的数据就会和数据库的数据不一致,那么我们就只能靠过期超时来进行兜底。对此我们可以进行优化,如果删除失败的话 我们不能影响主流程那么我们可以将其放入队列后续进行异步删除。

5.缓存挖坑三剑客

大家一听到缓存有哪些注意事项,肯定首先想到的是缓存穿透,缓存击穿,缓存雪崩这三个挖坑的小能手,这里简单介绍一下他们具体是什么以及应对的方法。

5.1缓存穿透

缓存穿透是指查询的数据在数据库是没有的,那么在缓存中自然也没有,所以,在缓存中查不到就会去数据库取查询,这样的请求一多,那么我们的数据库的压力自然会增大。

为了避免这个问题,可以采取下面两个手段:

  1. 约定:对于返回为NULL的依然缓存,对于抛出异常的返回不进行缓存,注意不要把抛异常的也给缓存了。采用这种手段的会增加我们缓存的维护成本,需要在插入缓存的时候删除这个空缓存,当然我们可以通过设置较短的超时时间来解决这个问题。

2. 制定一些规则过滤一些不可能存在的数据,小数据用BitMap,大数据可以用布隆过滤器,比如你的订单ID 明显是在一个范围1-1000,如果不是1-1000之内的数据那其实可以直接给过滤掉。


5.2缓存击穿

对于某些key设置了过期时间,但是其是热点数据,如果某个key失效,可能大量的请求打过来,缓存未命中,然后去数据库访问,此时数据库访问量会急剧增加。

为了避免这个问题,我们可以采取下面的两个手段:

  1. 加分布式锁:加载数据的时候可以利用分布式锁锁住这个数据的Key,在Redis中直接使用setNX操作即可,对于获取到这个锁的线程,查询数据库更新缓存,其他线程采取重试策略,这样数据库不会同时受到很多线程访问同一条数据。
  2. 异步加载:由于缓存击穿是热点数据才会出现的问题,可以对这部分热点数据采取到期自动刷新的策略,而不是到期自动淘汰。淘汰其实也是为了数据的时效性,所以采用自动刷新也可以。

5.3缓存雪崩

缓存雪崩是指缓存不可用或者大量缓存由于超时时间相同在同一时间段失效,大量请求直接访问数据库,数据库压力过大导致系统雪崩。

为了避免这个问题,我们采取下面的手段:

  1. 增加缓存系统可用性,通过监控关注缓存的健康程度,根据业务量适当的扩容缓存。
  2. 采用多级缓存,不同级别缓存设置的超时时间不同,及时某个级别缓存都过期,也有其他级别缓存兜底。
  3. 缓存的过期时间可以取个随机值,比如以前是设置10分钟的超时时间,那每个Key都可以随机8-13分钟过期,尽量让不同Key的过期时间不同。

6.缓存污染

缓存污染一般出现在我们使用本地缓存中,可以想象,在本地缓存中如果你获得了缓存,但是你接下来修改了这个数据,但是这个数据并没有更新在数据库,这样就造成了缓存污染:


上面的代码就造成了缓存污染,通过id获取Customer,但是需求需要修改Customer的名字,所以开发人员直接在取出来的对象中直接修改,这个Customer对象就会被污染,其他线程取出这个数据就是错误的数据。要想避免这个问题需要开发人员从编码上注意,并且代码必须经过严格的review,以及全方位的回归测试,才能从一定程度上解决这个问题。

7.序列化

序列化是很多人都不注意的一个问题,很多人忽略了序列化的问题,上线之后马上报出一下奇怪的错误异常,造成了不必要的损失,最后一排查都是序列化的问题。列举几个序列化常见的问题:

  1. key-value对象过于复杂导致序列化不支持:笔者之前出过一个问题,在美团的Tair内部默认是使用protostuff进行序列化,而美团使用的通讯框架是thfift,thrift的TO是自动生成的,这个TO里面很多复杂的数据结构,但是将其存放到了Tair中。查询的时候反序列化也没有报错,单测也通过,但是到qa测试的时候发现这一块功能有问题,发现有个字段是boolean类型默认是false,把它改成true之后,序列化到tair中再反序列化还是false。定位到是protostuff对于复杂结构的对象(比如数组,List
    等等)支持不是很好,会造成一定的问题。后来对这个TO进行了转换,用普通的Java对象就能进行正确的序列化反序列化。
  2. 添加了字段或者删除了字段,导致上线之后老的缓存获取的时候反序列化报错,或者出现一些数据移位。
  3. 不同的JVM的序列化不同,如果你的缓存有不同的服务都在共同使用(不提倡),那么需要注意不同JVM可能会对Class内部的Field排序不同,而影响序列化。比如下面的代码,在Jdk7和Jdk8中对象A的排列顺序不同,最终会导致反序列化结果出现问题:
//jdk 7
class A{
    int a;
    int b;
}
//jdk 8
class A{
    int b;
    int a;
}
复制代码

序列化的问题必须得到重视,解决的办法有如下几点:

  1. 测试:对于序列化需要进行全面的测试,如果有不同的服务并且他们的JVM不同那么你也需要做这一块的测试,在上面的问题中笔者的单测通过的原因是用的默认数据false,所以根本没有测试true的情况,还好QA给力,将其给测试出来了。
  2. 对于不同的序列化框架都有自己不同的原理,对于添加字段之后如果当前序列化框架不能兼容老的,那么可以换个序列化框架。 对于protostuff来说他是按照Field的顺序来进行反序列化的,对于添加字段我们需要放到末尾,也就是不能插在中间,否则会出现错误。对于删除字段来说,用@Deprecated注解进行标注弃用,如果贸然删除,除非是最后一个字段,否则肯定会出现序列化异常。
  3. 可以使用双写来避免,对于每个缓存的key值可以加上版本号,每次上线版本号都加1,比如现在线上的缓存用的是Key_1,即将要上线的是Key_2,上线之后对缓存的添加是会写新老两个不同的版本(Key_1,Key_2)的Key-Value,读取数据还是读取老版本Key_1的数据,假设之前的缓存的过期时间是半个小时,那么上线半个小时之后,之前的老缓存存量的数据都会被淘汰,此时线上老缓存和新缓存他们的数据基本是一样的,切换读操作到新缓存,然后停止双写。采用这种方法基本能平滑过渡新老Model交替,但是不好的点就是需要短暂的维护两套新老Model,下次上线的时候需要删除掉老Model,增加了维护成本。

8. GC调优

对于大量使用本地缓存的应用,由于涉及到缓存淘汰,那么GC问题必定是常事。如果出现GC较多,STW时间较长,那么必定会影响服务可用性。这一块给出下面几点建议:

  1. 经常查看GC监控,如何发现不正常,需要想办法对其进行优化。
  2. 对于CMS垃圾收集器,如果发现remark过长,如果是大量本地缓存应用的话这个过长应该很正常,因为在并发阶段很容易有很多新对象进入缓存,从而remark阶段扫描很耗时,remark又会暂停。可以开启-XX:CMSScavengeBeforeRemark,在remark阶段前进行一次YGC,从而减少remark阶段扫描gc root的开销。
  3. 可以使用G1垃圾收集器,通过-XX:MaxGCPauseMillis设置最大停顿时间,提高服务可用性。

9. 缓存的监控

很多人对于缓存的监控也比较忽略,基本上线之后如果不报错然后就默认他就生效了。但是存在这个问题,很多人由于经验不足,有可能设置了不恰当的过期时间,或者不恰当的缓存大小导致缓存命中率不高,让缓存就成为了代码中的一个装饰品。所以对于缓存各种指标的监控,也比较重要,通过其不同的指标数据,我们可以对缓存的参数进行优化,从而让缓存达到最优化:


上面的代码中用来记录get操作的,通过Cat记录了获取缓存成功,缓存不存在,缓存过期,缓存失败(获取缓存时如果抛出异常,则叫失败),通过这些指标,我们就能统计出命中率,我们调整过期时间和大小的时候就可以参考这些指标进行优化。

10. 一款好的框架

一个好的剑客没有一把好剑怎么行呢?如果要使用好缓存,一个好的框架也必不可少。在最开始使用的时候大家使用缓存都用一些util,把缓存的逻辑写在业务逻辑中:


上面的代码把缓存的逻辑耦合在业务逻辑当中,如果我们要增加成多级缓存那就需要修改我们的业务逻辑,不符合开闭原则,所以引入一个好的框架是不错的选择。

推荐大家使用JetCache这款开源框架,其实现了Java缓存规范JSR107并且支持自动刷新等高级功能。笔者参考JetCache结合Spring Cache, 监控框架Cat以及美团的熔断限流框架Rhino实现了一套自有的缓存框架,让操作缓存,打点监控,熔断降级,业务人员无需关心。上面的代码可以优化成:


对于一些监控数据也能轻松从大盘上看到:


最后

想要真正的使用好一个缓存,必须要掌握很多的知识,并不是看几个Redis原理分析,就能把Redis缓存用得炉火纯青。对于不同场景,缓存有各自不同的用法,同样的不同的缓存也有自己的调优策略,进程内缓存你需要关注的是他的淘汰算法和GC调优,以及要避免缓存污染等。分布式缓存你需要关注的是他的高可用,如果其不可用了如何进行降级,以及一些序列化的问题。一个好的框架也是必不可少的,对其如果使用得当再加上上面介绍的经验,相信能让你很好的驾驭住这头野马——缓存。

作者:公众号_咖啡拿铁
链接:https://juejin.im/post/5b849878e51d4538c77a974a
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

[转]反爬虫破解系列-汽车之家利用css样式替换文字破解方法

网站:

汽车之家:http://club.autohome.com.cn/ 以论坛为例

反爬虫措施:

在论坛发布的贴子正文中随机抽取某几个字使用span标签代替,标签内容位空,但css样式显示为所代替的文。这样不会

影响正常用户的阅读,只是在用鼠标选择的时候是选不到被替换的文字的,对爬虫则会造成采集内容不全的影响。

原理分析:

先看一下span标签的样式

截图是火狐浏览器的firebug的html面板。我们可以看到正文中每个span标签的样式都是一个文字,我们只需要找到每个

span标签的class属性于文字的对应关系即可还原正文内容,于是我找了一下css样式是在哪里定义的。找到了这样一个文

件,在firebug的css面板中可以看到所有的css文件,然后我尝试打开了一下这个url,发现结果还是帖子页面而非css文件

通过抓包也没有抓到类似这样的css文件,一番尝试无果后,我明白了,这个css文件应该是利用js生成的,于是我开始寻

找生成这样的文件的js代码,基本上就是拿各种关键词到各个js文件或者源代码中搜索,例如 ::before、content、hs_kw

等。然后发现js代码存在于网页源代码中,可以利用搜索 HS_ZY来定位到这段js代码,下面就是对js代码的分析破解了,

复制这段js代码到 http://jsbeautifier.org/ 一个js格式化工具网站。格式化之后,发现js代码是被混淆过的,这就比较蛋疼

了,幸好之前也接触过此类混淆,大概明白混淆的原理,无非就是将变量名随机替换,将完整代码拆分后用变量相加之类的,

于是在一番很麻烦的将各种变量手工替换回去之后,大概明白了js代码的主逻辑。

复制代码
(function(hZ_) {
    
    functionEW_() { = DV_()[decodeURIComponent]('%E3%80%81%E3%80%82%E4%B8%80%E4%B8%8A%E4%B8%8B%E4%B8%8D%E4%BA%86%E4%BA%94%E5%92%8C%E5%9C%B0%E5%A4%9A%E5%A4%A7%E5%A5%BD%E5%B0%8F%E5%BE%88%E5%BE%97%E6%98%AF%E7%9A%84%E7%9D%80%E8%BF%9C%E9%95%BF%E9%AB%98%EF%BC%81%EF%BC%8C%EF%BC%9F'Ÿ yc_()); 
    = la_((yc_() 23; 3; 19; 17; 9; 1; 8; 12; 18; 13; 2; 4; 16; 5; 6; 21; 15; 11; 22; 14; 24; 0; 10; 7; 20), lf_(;)); 
    = la_((10 _7, 6 _0; 2 _33, 14 _18; 8 _45, 8 _36; 0 _71, 16 _54; 13 _76, 3 _72; 0 _107, 16 _90; 15 _110, 1 _108; 4 _139, 12 _126; 9 _152, 7 _144; 10 _169, 6 _162; 4 _193, 12 _180; 11 _204, 5 _198; 3 _230, 13 _216; 1 _250, 15 _234; 13 _256, 3 _252; 6 _281, 10 _270; 9 _296, 7 _288; 13 _310, 3 _306; 6 _335, 10 _324; 7 _352, 9 _342; 6 _371, 10 _360; 5 _390, 11 _378; 5 _408, 11 _396; 7 _424, 9 _414; 6 _443, 10 _432lf_(;)), yc_(;));
            Uj_();
            return;;
        }
    function mS_() {
        for (Gx_ = 0; Gx_ < nf_.length; Gx_++) {
            var su_ = Pn_(nf_[Gx_], ',');
            var KN_ = '';
            for (Bk_ = 0; Bk_ < su_.length; Bk_++) {
                KN_ += ui_(su_[Bk_]) + '';
            }
            Kx_(Gx_, KN_);
        }
    }
    function NH_(Gx_) {
        return '.hs_kw' + Gx_ + '_maindC';
    }
    function Ln_() {
        return '::before { content:'
    }
})(document);
复制代码

很简单的逻辑,预先定义好哪几个字要被替换,上面代码中的那个很多%的字符串就是被替换的文字串,然后定义好每个文

字的序号,最后按照文字的序号对文字串进行重新排序并生成css样式,注意,最一开始的span标签的class属性中是有个序

号的,这个序号就是用来定位应该对应哪个文字。

接下来要做的就是无非就是从js代码中找到这个文字串,找到文字串的顺序,然后进行重排,然后根据span标签序号对原文

进行反向替换,从而得到完整的内容。

破解步骤:

简单整理一下:

1、从js代码中找到被替换的文字串和顺序

2、重排文字串

3、对原文中span标签根据class序号进行替换

其实2、3都比较简单,重点是第一步,找到被替换的文字串和顺序,由于源代码中js代码是被混淆过的,无法直接看出哪个

是文字串,所以首先应该对js代码进行反混淆,这个反混淆也不是说非得完整的还原所有的js代码,其实只要能反混淆到能

让我们看出文字串和顺序是什么就行了。

说一下反混淆的思路,其实很简单。就是执行起来比较麻烦而已,混淆是利用将一个简单的变量定义成复杂的js代码的方法

实现的,但这种混淆方式其实是有限的(这个有限指的是混淆用的工具在生成混淆代码时肯定是人为预先定义好了几种模式

,人为定义的肯定是有限的,只要你把所有的模式找出来,就可以还原了)。举个例子

function iq_() {
        'return iq_';
        return '3';
    }

这段代码其实你可以简单的认为就是变量iq()等于’3’,使用正则匹配这样的代码模式,然后提取关键字:函数名和最后一个

return的值,然后将提取到的信息保存起来用于对js代码进行全文替换。

复制代码
function cz_() {
        function _c() {
            return 'cz_';
        };
        if (_c() == 'cz__') {
            return _c();
        } else {
            return '84';
        }
    }
复制代码

这段代码复杂了一些,增加了判断,不过也简单,利用正则匹配这样的模式,然后提取关键字:函数名、第一个return的值,

判断中==后面的值,最后一个return的值,然后自己进行判断来确定cz_()的值应该是多少,保存起来进行全文替换。

以此类推,每种模式都可以使用正则来提取关键字并进行全文替换来反混淆,最后我们会得到一个大概被还原的js代码,其

中的文字串和顺序都清晰可见,再使用正则匹配出来就可以了。需要注意的一点是有时候被替换的不是单个文字,而是一些

词语,这是找到的顺序是”3,1;23,5″这样的,不过这些小伎俩应该不算什么,很好解决。

PS1:

发现一种新的模式,以前没注意,span的class属性hs_kw后面还有一串字符,估计是用来标示类别的,一般的网页

上只有一种class,出现多种的时候对应的源码中就会存在多段js代码,每段js代码对应一种class,关键是找到js代码对应的

class类型,然后分类型替换就行了。

结语:

这个建议大家自己动手做一下,还是比较有意思的,完整的破解代码见我的github

https://github.com/duanyifei/antispider/blob/master/autohome.py

[转]微博应对日访问量百亿级的缓存架构设计

作者介绍

陈波,新浪微博技术专家,《深入分布式缓存》作者。

注:本文转自中生代技术订阅号(ID:freshmanTechnology),经平台授权转载。

微博日活跃用户1.6亿+,每日访问量达百亿级,面对庞大用户群的海量访问,良好的架构且不断改进的缓存体系具有非常重要的支撑作用。本文将由新浪微博技术专家陈波老师,跟大家详细讲解那些庞大的数据都是如何呈现的。

本文大纲

1、微博在运行过程中的数据挑战

2、Feed平台系统架构

3、Cache架构及演进

4、总结与展望

数据挑战

Feed平台系统架构

Feed平台系统架构总共分为五层,最上面是端层,比如web端、客户端、大家用的IOS或安卓的一些客户端,还有一些开放平台、第三方接入的一些接口;下一层是平台接入层,不同的池子,主要是为了把好的资源集中调配给重要的核心接口,这样遇到突发流量的时候,就有更好的弹性来服务,提高服务稳定性。再下面是平台服务层,主要是Feed算法、关系等等。接下来是中间层,通过各种中间介质提供一些服务。最下面一层就是存储层。

1
Feed Timeline

大家日常刷微博的时候,比如在主站或客户端点一下刷新,最新获得了十到十五条微博,这是怎么构建出来的呢?

刷新之后,首先会获得用户的关注关系。比如他有一千个关注,会把这一千个ID拿到,再根据这一千个UID,拿到每个用户发表的一些微博。同时会获取这个用户的Inbox,就是他收到的特殊的一些消息,比如分组的一些微博、群的微博、下面的关注关系、关注人的微博列表。

拿到这一系列微博列表之后进行集合、排序,拿到所需要的那些ID,再对这些ID去取每一条微博ID对应的微博内容。如果这些微博是转发过来的,它还有一个原微博,会进一步取原微博内容。通过原微博取用户信息,进一步根据用户的过滤词对这些微博进行过滤,过滤掉用户不想看到的微博。

根据以上步骤留下的微博,会再进一步来看,用户对这些微博有没有收藏、点赞,做一些flag设置,还会对这些微博各种计数,转发、评论、赞数进行组装,最后才把这十几条微博返回给用户的各种端。

这样看来,用户一次请求得到的十几条记录,后端服务器大概要对几百甚至几千条数据进行实时组装,再返回给用户,整个过程对Cache体系强度依赖,所以Cache架构设计优劣会直接影响到微博体系表现的好坏。

2
Feed Cache架构

接下来我们看一下Cache架构,它主要分为六层。首先第一层是Inbox,主要是分组的一些微博,然后直接对群主的一些微博。Inbox比较少,主要是推的方式。

然后对于第二层的Outbox,每个用户都会发常规的微博,都会在它的Outbox里面去。根据存的ID数量,实际上分成多个Cache,普通的大概是200多条,如果长的大概是2000条。

第三层是一些关系,它的关注、粉丝、用户。

第四层是内容,每一条微博一些内容存在这里。

第五层就是一些存在性判断,比如某条微博我有没有赞过。之前有一些明星就说我没有点赞这条微博怎么显示我点赞了,引发了一些新闻。而这种就是记录,实际上她有在某个时候点赞过但可能忘记了。

最下面还有比较大的一层——计数,每条微博的评论、转发等计数,还有用户的关注数、粉丝数这些数据。

Cache架构及演进
1
简单KV数据类型

接下来我们着重讲一下微博的Cache架构演进过程。最开始微博上线时,我们是把它作为一个简单的KV数据类型来存储。我们主要采取哈希分片存储在MC池子里,上线几个月之后发现一些问题:有一些节点机器宕机或是其它原因,大量的请求会穿透Cache层达到DB上去,导致整个请求变慢,甚至DB僵死。

于是我们很快进行了改造,增加了一个HA层,这样即便Main层出现某些节点宕机情况或者挂掉之后,这些请求会进一步穿透到HA层,不会穿透到DB层。这样可以保证在任何情况下,整个系统命中率不会降低,系统服务稳定性有了比较大的提升。

对于这种做法,现在业界用得比较多,然后很多人说我直接用哈希,但这里面也有一些坑。比如我有一个节点,节点3宕机了,Main把它给摘掉,节点3的一些QA分给其他几个节点,这个业务量还不是很大,穿透DB,DB还可以抗住。但如果这个节点3恢复了,它又加进来之后,节点3的访问就会回来,稍后节点3因为网络原因或者机器本身的原因,它又宕机了,一些节点3的请求又会分给其他节点。这个时候就会出现问题,之前分散给其他节点写回来的数据已经没有人更新了,如果它没有被剔除掉就会出现混插数据。

实际上微博是一个广场型的业务,比如突发事件,某明星找个女朋友,瞬间流量就30%了。突发事件后,大量的请求会出现在某一些节点,会导致这些节点非常热,即便是MC也没办法满足这么大的请求量。这时MC就会变成瓶颈,导致整个系统变慢。

基于这个原因,我们引入了L1层,还是一个Main关系池,每一个L1大概是Main层的N分之一,六分之一、八分之一、十分之一这样一个内存量,根据请求量我会增加4到8个L1,这样所有请求来了之后首先会访问L1。L1命中的话就会直接访问,如果没有命中再来访问Main-HA层,这样在一些突发流量的时候,可以由L1来抗住大部分热的请求。对微博本身来说,新的数据就会越热,只要增加很少一部分内存就会抗住更大的量。

简单总结一下,通过简单KV数据类型的存储,我们实际上以MC为主的,层内HASH节点不漂移,Miss穿透到下一层去读取。通过多组L1读取性能提升,能够抗住峰值、突发流量,而且成本会大大降低。对读写策略,采取多写,读的话采用逐层穿透,如果Miss的话就进行回写。对存在里面的数据,我们最初采用Json/xml,2012年之后就直接采用Protocol Buffer格式,对一些比较大的用QuickL进行压缩。

2
集合类数据

刚才讲到简单的QA数据,那对于复杂的集合类数据怎么来处理?

比如我关注了2000人,新增一个人,就涉及到部分修改。有一种方式是把2000个ID全部拿下来进行修改,但这种对带宽、机器压力会很大。还有一些分页获取,我存了2000个,只需要取其中的第几页,比如第二页,也就是第十到第二十个,能不能不要全量把所有数据取回去。还有一些资源的联动计算,会计算到我关注的某些人里面ABC也关注了用户D。这种涉及到部分数据的修改、获取,包括计算,对MC来说实际上是不太擅长的。

各种关注关系都存在Redis里面取,通过Hash分布、储存,一组多存的方式来进行读写分离。现在Redis的内存大概有30个T,每天都有2-3万亿的请求。

在使用Redis的过程中,实际上还是遇到其他一些问题。比如从关注关系,我关注了2000个UID,有一种方式是全量存储,但微博有大量的用户,有些用户登陆得比较少,有些用户特别活跃,这样全部放在内存里成本开销是比较大的。所以我们就把Redis使用改成Cache,比如只存活跃的用户,如果你最近一段时间没有活跃,会把你从Redis里踢掉,再次有访问的时候再把你加进来。

这时存在一个问题,因为Redis工作机制是单线程模式,如果它加某一个UV,关注2000个用户,可能扩展到两万个UID,两万个UID塞回去基本上Redis就卡住了,没办法提供其他服务。所以我们扩展一种新的数据结构,两万个UID直接开了端,写的时候直接依次把它写到Redis里面去,读写的整个效率就会非常高。它的实现是一个long型的开放数组,通过Double Hash进行寻址。

我们对Redis进行了一些其他的扩展,大家可能也在网上看到过我们之前的一些分享,把数据放到公共变量里面,整个升级过程,我们测试1G的话加载要10分钟,10G大概要十几分钟以上,现在是毫秒级升级。

对于AOF,我们采用滚动的AOF,每个AOF是带一个ID的,达到一定的量再滚动到下一个AOF里去。对RDB落地的时候,我们会记录构建这个RDB时,AOF文件以及它所在的位置,通过新的RDB、AOF扩展模式,实现全增量复制。

3
其他数据类型-计数

接下来还有一些其他的数据类型,比如一个计数,实际上计数在每个互联网公司都可能会遇到,对一些中小型的业务来说,实际上MC和Redis足够用的,但在微博里计数出现了一些特点:单条Key有多条计数,比如一条微博,有转发数、评论数,还有点赞;一个用户有粉丝数、关注数等各种各样的数字。因为是计数,它的Value size是比较小的,根据它的各种业务场景,大概就是2-8个字节,一般4个字节为多,然后每日新增的微博大概十亿条记录,总记录就更可观了,然后一次请求,可能几百条计数要返回去。

4
计数器-Counter Service

最初是可以采取Memcached,但它有个问题,如果计数超过它内容容量时,会导致一些计数的剔除,宕机或重启后计数就没有了。另外可能有很多计数它为零,那这个时候怎么存,要不要存,存的话就占很多内存。微博每天上十亿的计数,光存0都要占大量的内存,如果不存又会导致穿透到DB里去,对服务的可溶性会存在影响。

2010年之后我们又采用Redis访问,随着数据量越来越大之后,发现Redis内存有效负荷还是比较低的,它一条KV大概需要至少65个字节,但实际上我们一个计数需要8个字节,然后Value大概4个字节,所以有效只有12个字节,还有四十多个字节都是被浪费掉的。这还只是单个KV,如果在一条Key有多个计数的情况下,它就浪费得更多了。比如说四个计数,一个Key 8个字节,四个计数每个计数是4个字节,16个字节大概需要26个字节就行了,但是用Redis存大概需要200多个字节。

后来我们通过自己研发的Counter Service,内存降至Redis的五分之一到十五分之一以下,而且进行冷热分离,热数据存在内存里,冷数据如果重新变热,就把它放到LRU里去。落地RDB、AOF,实现全增量复制,通过这种方式,热数据单机可以存百亿级,冷数据可以存千亿级。

整个存储架构大概是上图这样,上面是内存,下面是SSD,在内存里是预先把它分成N个Table,每个Table根据ID的指针序列,划出一定范围。任何一个ID过来先找到它所在的Table,如果有直接对它增增减减,有新的计数过来,发现内存不够的时候,就会把一个小的Table Dump到SSD里去,留着新的位置放在最上面供新的ID来使用。

有些人疑问说,如果在某个范围内,我的ID本来设的计数是4个字节,但是微博特别热,超过了4个字节,变成很大的一个计数怎么处理?对于超过限制的,我们把它放在Aux dict进行存放,对于落在SSD里的Table,我们有专门的IndAux进行访问,通过RDB方式进行复制。

5
其他数据类型-存在性判断

除了计数,微博还有一些业务,一些存在性判断。比如一条微博展现的,有没有点赞、阅读、推荐,如果这个用户已经读过这个微博了,就不要再显示给他。这种有一个很大的特点,它检查是否存在,每条记录非常小,比如Value1个bit就可以了,但总数据量巨大。比如微博每天新发表微博1亿左右,读的可能有上百亿、上千亿这种总的数据需要判断。怎么来存储是个很大的问题,而且这里面很多存在性就是0。还是前面说的,0要不要存?如果存了,每天就存上千亿的记录;如果不存,那大量的请求最终会穿透Cache层到DB层,任何DB都没办法抗住那么大的流量。

我们也进行了一些选型,首先直接考虑能不能用Redis。单条KV 65个字节,一个KV可以8个字节的话,Value只有1个bit,这样算下来每日新增内存有效率是非常低的。第二种我们新开发的Counter Service,单条KV Value1个bit,我就存1个byt,总共9个byt就可以了。这样每日新增内存900G,存的话可能就只能存最新若干天的,存个三天差不多快3个T了,压力也挺大,但比Redis已经好很多。

我们最终方案是自己开发Phantom,先采用把共享内存分段分配,最终使用的内存只用120G就可以。算法很简单,对每个Key可以进行N次哈希,如果哈希的某一个位它是1,那么进行3次哈希,三个数字把它设为1。把X2也进行三次哈希,后面来判断X1是否存在的时候,从进行三次哈希来看,如果都为1就认为它是存在的,如果某一个哈希X3,它的位算出来是0,那就百分百肯定是不存在的。

它的实现架构比较简单,把共享内存预先拆分到不同Table里,在里面进行开方式计算,然后读写,落地的话采用AOF+RDB的方式进行处理。整个过程因为放在共享内存里面,进程要升级重启数据也不会丢失。对外访问的时候,建Redis协议,它直接扩展新的协议就可以访问我们这个服务了。

6
小结

小结一下,到目前为止,我们关注了Cache集群内的高可用、扩展性、组件高性能,还有一个特别重要就是存储成本,还有一些我们没有关注到的,比如运维性如何,微博现在已经有几千差不多上万台服务器等。

7
进一步优化

8
服务化

采取的方案首先就是对整个Cache进行服务化管理,对配置进行服务化管理,避免频繁重启,另外如果配置发生变更,直接用一个脚本修改一下。

服务化还引入Cluster Manager,实现对外部的管理,通过一个界面来进行管理,可以进行服务校验。服务治理方面,可以做到扩容、缩容,SLA也可以得到很好的保障。另外,对于开发来说,现在就可以屏蔽Cache资源。

总结与展望

最后简单总结一下,对于微博Cache架构来说,我们从它的数据架构、性能、储存成本、服务化等不同方面进行了优化增强。欢迎对此有研究或有疑问的同行们留言,跟我们一起探讨。

 

 

[转]阿里巴巴开源项目TAC——新的服务端开发模式尝试

尝试

TAC是一个基于java的微服务容器,提供从业务代码编写、编译、发布、jar动态加载、运行等一系列常用开发流程的支持,是天猫App在服务端开发模式下的新尝试。TAC和客户端框架Tangram结合,极大提高开发效率;TAC目前在天猫App、手机淘宝特价版广泛使用;

疼痛之初——产生背景

天猫App(以下简称猫客)首页从2015年的坑位运营走向2016年的全面个性化,当时猫客首页的个性化业务多大50多处。以首页为例,这个过程中除了接入导购链路的二方服务之外,接入了大量的三方服务;同时我们发现:

  • 首页应用越来越复杂、庞大,应用代码修改、编译部署耗费时间太长,大部分时间都是苦力劳动;
  • 线上定位问题周期太长,从问题发现到定位再到处理,需要经过客户端、服务端、下游各种服务提供方,链路太长影响业务快速支撑;
  • 大量服务的接入导致发布线上部署频繁,一个简单服务的接入,几行代码的修改导致首页应用重新开发编译部署,影响整个首页稳定性;

当时微服务的概念已经很火,同时阿里内部也开始大力推广docker(现在大部分应用都跑在docker上)。我们想过将庞大的首页应用拆分成微服务,但是和基础服务不同,前台业务变化快,修改频繁,拆分之后开发同学依然要面临各种服务接入等体力劳动,同时需要维护拆分之后的多个应用,反而增加了劳动成本,因此拆分成微服务的方式治标不治本;


解决之道——TAC

在此背景下TAC孕育而生,它提供低成本开发与发布流程、低成本搭建与维护开发环境、高稳定性保障;TAC通过热部署的方式使得研发同学够从苦力劳动中解放出来,回归到业务开发中去,同时一个基础服务接入之后,能够提供给多个业务使用;在此模式下,业务能够进行更细粒度的拆分,且故障隔离业务A的改动不会影响业务B;

在TAC的帮助下,频繁修改的新业务可快速上线,不会出现因为修改一个字段、几行代码就需要重新发布整个应用的情况;同时与tangram结合,实现页面卡片、坑位的快速调整;


经过近三年的沉淀,我们今日放出了开源版本,将集团版本的TAC剥离与阿里相关的中间件、网络、协议、部署环境等,保留其核心功能;开源版本提供了编译、热加载、运行的基础能力;

TAC开源版本

系统结构


  • 开源版本分两部分,tac容器和tac控制台,存储和通信都依赖redis。
  • 为了简便,容器与外部服务直接的交互只通过http进行;
  • 同时为了提升开发体验,提供了与gitlab集成的能力,用户可直接gitlab提交代码并在控制台操作发布;快速验证;

核心类加载器


上图是tac的类加载器结构,每个线上的微服务实例都通过一个新的classloader加载,同时为了方便用户扩展新的数据源,在AppClassLoader上扩展了一个classloader以加载第三方数据源(当然也可以直接在代码中扩展,见tac-infrastructure);

Quick Start —— 如何使用

  • 安装 redis
  • 运行 container
java -jar tac-container.jar
  • 运行 console 控制台
java -jar tac-console.jar --admin
  • 成功后可打开控制台
http://localhost:7001/#/tacMs/list
  • 代码开发
    • 添加 SDK 依赖
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>tac-sdk</artifactId>
    <version>${project.version}</version>
</dependency>
  • 编写代码
public class HelloWorldTac implements TacHandler<Object> {

    /**
     * 引入日志服务
     */
    private TacLogger tacLogger = TacServiceFactory.getLogger();

    /**
     * 编写一个实现TacHandler接口的类
     *
     * @param context
     * @return
     * @throws Exception
     */

    @Override
    public TacResult<Object> execute(Context context) throws Exception {

        // 执行逻辑
        tacLogger.info("Hello World");

        Map<String, Object> data = new HashMap<>();
        data.put("name", "hellotac");
        data.put("platform", "iPhone");
        data.put("clientVersion", "7.0.2");
        data.put("userName", "tac-userName");
        return TacResult.newResult(data);
    }
}
  • 本地编译、打包
  • 发布及测试

  • 正式发布
  • 线上验证
curl  http://localhost:8001/api/tac/execute/helloworld -s|json
{
  "success": true,
  "msgCode": null,
  "msgInfo": null,
  "data": {
    "helloworld": {
      "data": {
        "name": "hellotac",
        "clientVersion": "7.0.2",
        "userName": "tac-userName",
        "platform": "iPhone"
      },
      "success": true,
      "msCode": "helloworld"
    }
  },
  "hasMore": null,
  "ip": "127.0.0.1"
}

[转]Lumen和Laravel错误处理机制修改

在使用Laravel或者Lumen时会碰到这种情况,如果php的代码中产生了Notice或者Warning,会导致Lumen跳到错误页,日志中会打印一个很长很长的stack trace,如下图:

$app->get('/test', function () use ($app) {
	$arr = [];
	print($arr['name']);
});
#请求/test 产生的日志
[2017-07-12 11:55:54] lumen.ERROR: ErrorException: Undefined index: name in /home/vagrant/Code/featurestream/routes/web.php:16
Stack trace:
#0 /home/vagrant/Code/featurestream/routes/web.php(16): Laravel\Lumen\Application->Laravel\Lumen\Concerns\{closure}(8, 'Undefined index...', '/home/vagrant/C...', 16, Array)
#1 [internal function]: Closure->{closure}()
#2 /home/vagrant/Code/featurestream/vendor/illuminate/container/BoundMethod.php(29): call_user_func_array(Object(Closure), Array)
....

上面的日志只截取了一小部分,实际运行时最简单的api请求Lumen的stack trace会有30层左右,Laravel的会有60层..
另外一个重要的问题是,这种处理机制会让一些小错误把整个请求搞挂,代码中到处加if(isset($arr['xx'])) 或者设置默认值。
对于这个问题,Laravel和Lumen设计的初衷是好的:所有的PHP错误都应该被处理,包括Notice和Warning。就是太严格了,有时候我们并不需要代码有这么严格的检查,出现Notice或者Warning时,只需要打印个模块日志或者有PHP日志就行。
先看下Lumen中的错误是如何处理的(Laravel中也差不多,不再单独讲)。

错误处理流程

错误Handler

先看Lumen的入口:bootstrap/app.php,有一段错误处理相关的代码:

$app->singleton(
    Illuminate\Contracts\Debug\ExceptionHandler::class,
    App\Exceptions\Handler::class
);

singleton是往app(Lumen的服务容器)里注入实例的方法,这里实例化了一个ExceptionHandler类,实例为Handler。后面的代码如果有从$app里取ExceptionHandler的实例的话,会返回Handler这个类的实例。
Handler类在App\Exceptions目录下,代码如下。比较简单,只包含两个方法,再去看父类(Laravel\Lumen\Exceptions\Handler)的方法逻辑会发现,report方法负责打印日志(也就是上文那个长长的trace),render方法会根据错误类型的不同构建错误页面

public function report(Exception $e)
{
    parent::report($e);
}

public function render($request, Exception $e)
{
    return parent::render($request, $e);
}

错误的处理逻辑找到了,如何触发进入这个逻辑的呢?

错误触发

正常情况下,PHP产生Notice或者Warning是不会抛出Exception的,会产生Exception肯定是框架内部做了更改。
bootstrap/app.php中没找到设置错误处理的地方,接着往下看Lumen框架的容器类Application(Lumen的核心类和入口),目录:vendor/laravel/lumen-framework/src/Application.php
可以看到在其构造函数中调用了一个registerErrorHandling方法,方法代码:

/**
 * Set the error handling for the application.
 *
 * @return void
 */
protected function registerErrorHandling()
{
    error_reporting(-1);

    set_error_handler(function ($level, $message, $file = '', $line = 0) {
        if (error_reporting() & $level) {
            throw new ErrorException($message, 0, $level, $file, $line);
        }
    });

    set_exception_handler(function ($e) {
        $this->handleUncaughtException($e);
    });

    register_shutdown_function(function () {
        $this->handleShutdown();
    });
}

set_error_handler是PHP设置错误处理的方法。registerErrorHandling下用error_reporting(-1)把PHP的报错开关都打开,这样所有级别的错误都会触发error_handler。在error_handler中抛出了一个ErrorException异常。
至此,我们知道ErrorException这个异常是怎么产生的了,知道异常会交由谁来处理了(上一节中的Handler类)。不过还有个疑问,ErrorException和Handler是怎么关联起来的?

ErrorException和Handler

set_error_handler设置的方法中会抛出异常,那肯定存在针对异常的try catch块。
从请求路口public/index.php往下看,
–>bootstrap/app.php –>Application.php -> run –>Application.php -> dispatch
在dispatch方法中发现了try catch的逻辑,在catch到Exception后调用了Handler的report和render方法。

public function dispatch($request = null)
{
   list($method, $pathInfo) = $this->parseIncomingRequest($request);
   try {
			...
   } catch (Exception $e) {
       return $this->prepareResponse($this->sendExceptionToHandler($e));
   } catch (Throwable $e) {
       return $this->prepareResponse($this->sendExceptionToHandler($e));
   }
}
protected function sendExceptionToHandler($e)
{
   $handler = $this->resolveExceptionHandler();

   if ($e instanceof Error) {
       $e = new FatalThrowableError($e);
   }

   $handler->report($e);

   return $handler->render($this->make('request'), $e);
}

解决

知道了错误从触发到结束的整个流程,再来看怎么解决.

方法1.屏蔽错误

简单粗暴的方法,既然框架用来error_reporting(-1)打开了所有错误开关,那我们再用error_reporting把我们不关心的错误给屏蔽了。把下面代码加在bootstrap/app.php中,注意得加在Applition实例化之后

error_reporting(E_ALL ^ E_NOTICE ^ E_WARNING);

这种方法的弊端显而易见,我们只是不想让E_NOTICE搞挂请求,但是这类错误还是得关注和修复的。。

方法2.修改Handler

既然是Handler负责错误处理,那我们修改Handler(App\Exceptions\Handler)的逻辑就行。

public function report(Exception $e)
{
    //parent::report($e);
    Log::warning($e->getMessage(), ['file' => $e->getFile(), 'line' => $e->getLine()]);
}

public function render($request, Exception $e)
{
    //return parent::render($request, $e);
    header('Content-type: application/json');
    echo json_encode(['errmsg' => $e->getMessage()]);
    return ;
}

如上代码,将错误处理逻辑进行更改,只打印简单的日志,并且也不跳到错误页了。
不过,这种方法还是有问题:

  1. 代码产生的NOTICE和WARNING还是会中断执行流程(因为会抛出异常)。比如一个请求中执行A->B->C三个方法,如果B中产生了一个NOTICE,整个请求还是会被中断,C不会被执行。
  2. 如果一个请求原本不是返回json,是返回一个view,则上面的render方法就不适用了。

方法3.在业务逻辑顶层中catch异常

原理类似方法2,只不过把异常的处理从Handler中移到了业务逻辑里(比如Controller中)。
这个方法的问题和方法2一样,NOTICE还是会中断请求…

方法4.修改error_handler,不抛出异常

罪魁祸首就在于那个set_error_handler注册的处理方法遇到PHP错误就会抛出异常,那我们修改他就行。
set_error_handler所在的registerErrorHandling方法在框架的源代码中,最好不要直接修改,我们可以在它注册完之后再重新注册一个覆盖它。可以加在bootstrap/app.php中,Application实例化之后。代码如下:

set_error_handler(function ($level, $message, $file = '', $line = 0) {
    if ($level == E_NOTICE || $level == E_WARNING) {
        Log::warning("PHP NOTICE or WARNING; MSG:[$message]", ['file' => $file, 'line' => $line]);
        return;
    }
    if (error_reporting() & $level) {
        throw new ErrorException($message, 0, $level, $file, $line);
    }
});

注意,代码中因为使用了Log这个Facades,因此必须放在$app->withFacades()之后
至此,NOTICE和WARNING不会产生烦人的日志,也不会搞挂请求,并且也保留了有效的提示信息。:)

[转]饿了么异地多活技术实现(一)总体介绍

饿了么技术团队花了1年多的时间,实现了业务的整体异地多活,能够灵活的在多个异地机房之间调度用户,实现了自由扩容和多机房容灾的目标。本文介绍这个项目的整体结构,还简要介绍实现多活的5大核心基础组件,为读者建立基本的概念模型,后续会有系列文章陆续介绍每个组件的实现细节。读者能够从中了解到做异地多活的大方向,为实现自己的异地多活,或者是容灾备份提供参考。

背景:为什么要做异地多活?

饿了么要做多活,是受业务发展的驱动,经过几年的高速发展,我们的业务已经扩大到单个数据中心撑不住了,主要机房已经不能再加机器,业务却不断的要求加扩容,所以我们需要一个方案能够把服务器部署到多个机房。另外一个更重要的原因是,整个机房级别的故障时有发生,每次都会带来严重的后果,我们需要在发生故障时,能够把一个机房的业务全部迁移到别的机房,保证服务可用。

归纳起来,我们要达到两个目标:

  1. 服务可以扩展到多个机房
  2. 能够应对整个机房级别的故障

解决这两个问题的常见办法是做异地多活,把服务分散到多个机房,自然扩展和高可用的问题就迎刃而解了。

饿了么有自己的特殊情况,对于其他大部分公司来说,其实要不要做多活,主要就是看下图这样一个曲线。

对于一个业务快速增长的企业,每次故障带来的损失也相应是加速增长的,而技术的投入总体上是线性的,初期故障损失小于技术投入,在某个时间点,故障的损失会超过技术投入,这时就要用一些高可用方案,来避免故障,多活就是其中最重要的一种。

异地多活面临的主要挑战是网络延迟,以北京到上海 1468 公里,即使是光速传输,一个来回也需要接近10ms,我们在实际测试的过程中,发现上海到北京的网络延迟,一般是 30 ms。这 30 ms可以和运算系统中其他的延迟时间做个比较:

L1 cache reference ……………………. 0.5 ns

Branch mispredict ………………………. 5 ns

L2 cache reference ……………………… 7 ns

Mutex lock/unlock ……………………… 25 ns

Main memory reference …………………. 100 ns

Compress 1K bytes with Zippy …………. 3,000 ns = 3 µs

Send 2K bytes over 1 Gbps network ……. 20,000 ns = 20 µs

SSD random read …………………… 150,000 ns = 150 µs

Read 1 MB sequentially from memory ….. 250,000 ns = 250 µs

Round trip within same datacenter …… 500,000 ns = 0.5 ms

Read 1 MB sequentially from SSD* ….. 1,000,000 ns = 1 ms

上海之间两个机房的网络延时…………….. 1,000,000 ns = 1 ms

Disk seek ……………………… 10,000,000 ns = 10 ms

Read 1 MB sequentially from disk …. 20,000,000 ns = 20 ms

北京到上海的网络延时……………….. 30,000,000 ns = 30 ms

Send packet CA->Netherlands->CA …. 150,000,000 ns = 150 ms

北京上海两地的网络延迟时间,大致是内网网络访问速度的 60 倍(30ms/0.5ms),如果不做任何改造,一方直接访问另外一方的服务,那么我们的APP的反应会比原来慢 60 倍,其实考虑上多次往返,可能会慢600倍。

如果机房都在上海,那么网络延迟只有内网速度的2倍,可以当成一个机房使用。所有有些公司的多活方案,会选择同城机房,把同城的几个机房当成一个机房部署,可以在不影响服务架构的情况下扩展出多个机房,不失为一个快速见效的方法。我们在做多活的初期也讨论过同城方案,比如在北京周边建设一个新机房,迁移部分服务到新机房,两个机房专线连接,服务间做跨机房调用。虽然这个方案比较容易,也解决了机房的扩展问题,但是对高可用却没有好处,相反还带来了更高的风险。

与同城多活的方案不同,异地多活的方案会限制机房间的相互调用,需要定义清晰的服务边界,减少相互依赖,让每个机房都成为独立的单元,不依赖于其他机房。经过几番考量,我们最终选择了异地多活的方案,对这两个方案的比较和思考可以见下表,异地多活虽然更困难一点,但是能同时达到我们的两个核心目标,更为可行。

设计:异地多活的实现思路和方法

我们的异地多活方案的,有几条基本原则,整个多活方案都是这些原则的自然推导。但在介绍一下这些原则之前,先要说明一下饿了么的服务流程,才能让大家更好的理解这些原则的来由

下面这张简图是我们的主流程:

业务过程中包含3个最重要的角色,分别是用户、商家和骑手,一个订单包含3个步骤:

  1. 用户打开我们的APP,系统会推荐出用户位置附近的各种美食,推荐顺序中结合了用户习惯,推荐排序,商户的推广等。用户找到中意的食物 ,下单并支付,订单会流转到商家。
  2. 商家接单并开始制作食物,制作完成后,系统调度骑手赶到店面,取走食物
  3. 骑手按照配送地址,把食物送到客户手中。

整个下单到配送完成,有严格的时间要求,必须在短短的几十分钟内完成,我们的服务和地理位置强相关,并且实时性要求高,服务的地域性和实时性是我们的核心特性,多活设计最重要的是满足这两个特性。

进过反复讨论,我们的多活架构通过遵循以下几条基本原则,来满足这两个核心特性:

  1. 业务内聚:单个订单的旅单过程,要在一个机房中完成,不允许跨机房调用。这个原则是为了保证实时性,旅单过程中不依赖另外一个机房的服务,才能保证没有延迟。我们称每个机房为一个 ezone,一个 ezone 包含了饿了么需要的各种服务。一笔业务能够内聚在一个 ezone 中,那么一个定单涉及的用户,商家,骑手,都会在相同的机房,这样订单在各个角色之间流转速度最快,不会因为各种异常情况导致延时。恰好我们的业务是地域化的,通过合理的地域划分,也能够实现业务内聚。
  2. 可用性优先:当发生故障切换机房时,优先保证系统可用,首先让用户可以下单吃饭,容忍有限时间段内的数据不一致,在事后修复。每个 ezone 都会有全量的业务数据,当一个 ezone 失效后,其他的 ezone 可以接管用户。用户在一个ezone的下单数据,会实时的复制到其他ezone。
  3. 保证数据正确:在确保可用的情况下,需要对数据做保护以避免错误,在切换和故障时,如果发现某些订单的状态在两个机房不一致,会锁定该笔订单,阻止对它进行更改,保证数据的正确。
  4. 业务可感:因为基础设施还没有强大到可以抹去跨机房的差异,需要让业务感知多活逻辑,业务代码要做一些改造,包括:需要业务代码能够识别出业务数据的归属,只处理本 ezone 的数据,过滤掉无关的数据。完善业务状态机,能够在数据出现不一致的时候,通过状态机发现和纠正。

这几条基本原则,贯穿了饿了么多活的整个设计。

基于这几条原则,我们从服务划分,流量路由,业务改造等方面设计了多活方案,下面简要介绍一主要逻辑。

服务划分(Sharding):

为了实现业务内聚,我们首先要选择一个划分方法(Sharding Key),对服务进行分区,让用户,商户,骑手能够正确的内聚到同一个 ezone 中。分区方案是整个多活的基础,它决定了之后的所有逻辑。

根据饿了么的业务特点,我们自然的选择地理位置作为划分业务的单元,把地理位置上接近的用户,商户,骑手划分到同一个ezone,这样一个订单的履单流程就会在一个机房完成,能够保证最小的延时,在某个机房出现问题的时候,也可以按照地理位置把用户,商户,骑手打包迁移到别的机房即可。

所以我们最终选择的方案如下图,自定义地理划分围栏,用围栏把全国分为多个 shard,围栏的边界尽量按照行政省界,必要的时候做一些调整,避免围栏穿过市区。一个ezone可以包含多个 shard,某个 ezone 的 shard ,可以随时切换到另外一个 ezone ,灵活的调度资源和failover。

这样的划分方案,基本解决了垮城市下单的问题,线上没有观察到有跨 ezone 下单的情况。围栏的划分是灵活的,可以随着以后业务的拓展进行修改,因为每个机房都是全量数据,所以调整围栏不会导致问题。

对这种划分方法,有一些常见的疑问,比如:

1.如果两个城市是接壤的,会出现商家和用户处于不同 ezone 的情况,岂不是破坏了内聚性原则?

这种情况确实会出现,为了尽量避免,我们在划分shard的时候没有简单的用城市名称,而是用了复杂的地理围栏实现,地理围栏主体按照省界划分,再加上局部微调,我们最大限度的避免了跨ezone下单的情况。但如果真的出现了,用户下单也不受影响,最多只是状态有1s左右的延迟。

2.用户是会动的,如果用户从北京到了上海,那么划分规则应该怎么应对?

用户在北京下单,数据落在北京shard,到上海下单,数据则落在上海的 shard,借助于底层的数据同步工具,用户无论在什么地方,都能看到自己的数据,但是有1s左右的延时,对于大部分的业务场景,这个延迟是可以承受的。当然也有些业务场景不能接受这 1s 的延时,我们也提供了另外的方案来应对,参考下文介绍Globa Zone的章节。

3.为什么不简单点,按照用户的ID来切分?

阿里是按照用户ID的取模来划分单元的,比较简洁。我们如果也用ID做切分,同一地方的用户,商户,骑手可能被划分到不同 ezone,就会出现比较多的跨机房调用,这样就更可能出现延迟,难以保证实时性。所以,我们本地配送的业务模式,决定了需要用地理位置来划分服务。

流量路由:

基于地理位置划分规则,我们开发了统一的流量路由层(API Router),这一层负责对客户端过来的 API 调用进行路由,把流量导向到正确的 ezone。API Router 部署在多个公有云机房中,用户就近接入到公有云的API Router,还可以提升接入质量。

前端 APP 做了改造,为每个请求都带上了分流标签,API Router 会检查流量上自带的分流标签,把分流标签转换为对应的 Shard ID,再查询 Shard ID 对应的 eZone,最终决定把流量路由到哪个 ezone。

最基础的分流标签是地理位置,有了地理位置,AR 就能计算出正确的 shard 归属。但业务是很复杂的,并不是所有的调用都能直接关联到某个地理位置上,我们使用了一种分层的路由方案,核心的路由逻辑是地理位置,但是也支持其他的一些 High Level Sharding Key,这些 Sharding Key 由 APIRouter 转换为核心的 Sharding Key,具体如下图。这样既减少了业务的改造工作量,也可以扩展出更多的分区方法。

除了入口处的路由,我们还开发了 SOA Proxy,用于路由SOA调用的,和API Router基于相同的路由规则。APIRouter 和 SOAProxy 构成了流量的路由通道,让我们可以灵活的控制各种调用在多活环境下的走向。

数据复制:

为了实现可用优先原则,所有机房都会有全量数据,这样用户可以随时切换到其他机房,全量数据就需要对数据进行实时复制,我们开发了相应的中间件,对 mysql,zookeeper ,消息队列和 redis 的数据进行复制。

Mysql 数据复制工具 DRC:

Mysql 的数据量最大,每个机房产生的数据,都通过 DRC 复制到其他 ezone,每个ezone的主键取值空间是ezoneid + 固定步长,所以产生的 id 各不相同,数据复制到一起后不会发生主键冲突。按照分区规则,正常情况下,每个 ezone 只会写入自己的数据,但万一出现异常,2个 ezone 同时更新了同一笔数据,就会产生冲突。DRC 支持基于时间戳的冲突解决方案,当一笔数据在两个机房同时被修改时,最后修改的数据会被保留,老的数据会被覆盖。

ZooKeeper 复制:

有些全局的配置信息,需要在所有机房都完全一致,我们开发了 zookeeper 复制工具,用于在多个机房中同步 ZK 信息。

消息队列和Redis复制:

MQ,Redis 的复制与 ZK 复制类似,也开发了 相应的复制工具。

强一致保证:

对于个别一致性要求很高的应用,我们提供了一种强一致的方案(Global Zone),Globa Zone是一种跨机房的读写分离机制,所有的写操作被定向到一个 Master 机房进行,以保证一致性,读操作可以在每个机房的 Slave库执行,也可以 bind 到 Master 机房进行,这一切都基于我们的数据库访问层(DAL)完成,业务基本无感知。

切换过程和各种异常保护:

避免数据错误非常重要,在网络断开,或者是切换过程中,特别容易产生错误数据。比如由于复制延时,订单状态不一致,用户有可能会重复支付。为了避免我们采取了一些保护措施,避免在切换时发生错误。

  1. 在网络中断时,如果不是必要,不做切换,因为任意单个机房能够提供完整服务。
  2. 如果需要切换,对锁定切换过程中的订单,直到切换完成,数据复制正常,才开放锁定。这个过程也通过 DAL 来实现
  3. 对于标记为其他机房的写入数据,DAL 会进行保护,拒绝写入。
  4. DRC 会检查并报告错误的写入操作,方便检查隐藏问题。

通过以上4条的保护,我们保证了数据的正确性,频繁的切换也不会出现异常的业务数据。

多个机房的Cache刷新:

数据的变更信息,通过 DRC 广播到多个机房,实现缓存的刷新,保证各个机房的缓存一致性。

整体结构

以上介绍了各个考虑的方面,现在可以综合起来看,饿了么多活的整体结构如下图:

业务改造:

业务可感知是一条基本原则,通过中间件提供的服务,多活逻辑会暴露给业务方,例如:当前服务所属的 ezone,路由策略,数据的归属 shard 等,基于这些信息,业务可以执行很多的逻辑。包括:

  1. 后台任务可以过滤掉非本 ezone 的数据。
  2. 可以在发生切换时,执行特定的逻辑,触发特定动作。
  3. 业务需要准备一些数据修复逻辑,在万一发生不一致时,手工或者自动纠正数据。

实现:多活的基础中间件

下面简要介绍一下支持以上功能的中间件,我们归纳为多活 5 大基础组件,之后会有系列文章,介绍每个基础组件的具体实现。

APIRouter : 路由分发服务

API Router是一个HTTP反向代理和负载均衡器,部署在公有云中作为HTTP API流量的入口,它能识别出流量的归属 shard ,并根据 shard 将流量转发到对应的 ezone。API Router 支持多种路由键,可以是地理位置,也可以是商户ID,订单ID等等,最终由 API Router 映射为统一的 Sharding ID。

Global Zone Service:全局状态协调器

GZS 维护着整个多活的路由表,其他所有的服务都从 GZS 订阅路由信息。切换机房的操作也在 GZS 控制台中完成。路由表包括:地理围栏信息,shard 到 ezone 的归属信息,商铺ID/订单ID 等路由逻辑层到 shard id 的映射关系等。GZS 通过在 SDK 端建立 Cache,来保证shard 逻辑能够最快速度执行,基本不需要和 GZS 交互,同时也有实时推送机制,确保在数据变更后能够快速通知到其他的服务。

SOA Proxy:内部网关

SOA Proxy 实现了对 SOA 调用的路由,执行和 API Router 相似的逻辑,但只用在机房之间进行通信的场景。业务使用 SOA Proxy 需要对代码做一些修改,把路由信息加入到调用的上下文中。

Data Replication Center:数据复制

DRC 负责 Mysql 数据的实时双向复制,保证跨机房延时在 1s 以内。提供了基于时间的冲突解决方案,确保各个机房的数据一致。DRC 除了复制数据,还对外提供了数据变更的通知,让业务能够感知到其他机房的数据变化,做相应的处理,例如清除Cache等。除了DRC,我们还有 ZK复制工具,RMQ 复制工具,Redis复制工具,基本每个数据层次,都有对应的复制方案。

Data Access Layer:数据访问

数据访问层支撑了 Globa Zone 的逻辑,还提供了最后一道保护,拒绝路由错误的数据写入,是多活最底层的支撑。

未来:下一步多活的计划

目前饿了么的服务已经部署到2个异地机房,下一步我们会扩展到3-4个机房,并且在公有云上建立一个新的ezone,充分利用公有云的强大的扩展能力,未来我们将能够快速的在全世界各地搭建数据中心,也能够快速的利用各种公有云基础设施,实现全球规模的高可用和扩展性。

文献引用:

Latency numbers every programmer should know

Linked In Databus

Alibaba Canal Project

作者介绍:

李双涛,饿了么框架工具部高级架构师,参与了整个饿了么多活的设计和实施过程。职业生涯中,有两次参与这种大规模的整站高可用改造,一次在 Cisco Webex,一次在饿了么,积累的宝贵的经验。

[转]跨域资源共享 CORS 详解

原文链接:http://www.ruanyifeng.com/blog/2016/04/cors.html

一、简介

CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。

整个CORS通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS通信与同源的AJAX通信没有差别,代码完全一样。浏览器一旦发现AJAX请求跨源,就会自动添加一些附加的头信息,有时还会多出一次附加的请求,但用户不会有感觉。

因此,实现CORS通信的关键是服务器。只要服务器实现了CORS接口,就可以跨源通信。

二、两种请求

浏览器将CORS请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。

只要同时满足以下两大条件,就属于简单请求。

(1) 请求方法是以下三种方法之一:

  • HEAD
  • GET
  • POST

(2)HTTP的头信息不超出以下几种字段:

  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID
  • Content-Type:只限于三个值application/x-www-form-urlencodedmultipart/form-datatext/plain

凡是不同时满足上面两个条件,就属于非简单请求。

浏览器对这两种请求的处理,是不一样的。

三、简单请求

3.1 基本流程

对于简单请求,浏览器直接发出CORS请求。具体来说,就是在头信息之中,增加一个Origin字段。

下面是一个例子,浏览器发现这次跨源AJAX请求是简单请求,就自动在头信息之中,添加一个Origin字段。


GET /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

上面的头信息中,Origin字段用来说明,本次请求来自哪个源(协议 + 域名 + 端口)。服务器根据这个值,决定是否同意这次请求。

如果Origin指定的源,不在许可范围内,服务器会返回一个正常的HTTP回应。浏览器发现,这个回应的头信息没有包含Access-Control-Allow-Origin字段(详见下文),就知道出错了,从而抛出一个错误,被XMLHttpRequestonerror回调函数捕获。注意,这种错误无法通过状态码识别,因为HTTP回应的状态码有可能是200。

如果Origin指定的域名在许可范围内,服务器返回的响应,会多出几个头信息字段。


Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8

上面的头信息之中,有三个与CORS请求相关的字段,都以Access-Control-开头。

(1)Access-Control-Allow-Origin

该字段是必须的。它的值要么是请求时Origin字段的值,要么是一个*,表示接受任意域名的请求。

(2)Access-Control-Allow-Credentials

该字段可选。它的值是一个布尔值,表示是否允许发送Cookie。默认情况下,Cookie不包括在CORS请求之中。设为true,即表示服务器明确许可,Cookie可以包含在请求中,一起发给服务器。这个值也只能设为true,如果服务器不要浏览器发送Cookie,删除该字段即可。

(3)Access-Control-Expose-Headers

该字段可选。CORS请求时,XMLHttpRequest对象的getResponseHeader()方法只能拿到6个基本字段:Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma。如果想拿到其他字段,就必须在Access-Control-Expose-Headers里面指定。上面的例子指定,getResponseHeader('FooBar')可以返回FooBar字段的值。

3.2 withCredentials 属性

上面说到,CORS请求默认不发送Cookie和HTTP认证信息。如果要把Cookie发到服务器,一方面要服务器同意,指定Access-Control-Allow-Credentials字段。


Access-Control-Allow-Credentials: true

另一方面,开发者必须在AJAX请求中打开withCredentials属性。


var xhr = new XMLHttpRequest();
xhr.withCredentials = true;

否则,即使服务器同意发送Cookie,浏览器也不会发送。或者,服务器要求设置Cookie,浏览器也不会处理。

但是,如果省略withCredentials设置,有的浏览器还是会一起发送Cookie。这时,可以显式关闭withCredentials


xhr.withCredentials = false;

需要注意的是,如果要发送Cookie,Access-Control-Allow-Origin就不能设为星号,必须指定明确的、与请求网页一致的域名。同时,Cookie依然遵循同源政策,只有用服务器域名设置的Cookie才会上传,其他域名的Cookie并不会上传,且(跨源)原网页代码中的document.cookie也无法读取服务器域名下的Cookie。

四、非简单请求

4.1 预检请求

非简单请求是那种对服务器有特殊要求的请求,比如请求方法是PUTDELETE,或者Content-Type字段的类型是application/json

非简单请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为”预检”请求(preflight)。

浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。

下面是一段浏览器的JavaScript脚本。


var url = 'http://api.alice.com/cors';
var xhr = new XMLHttpRequest();
xhr.open('PUT', url, true);
xhr.setRequestHeader('X-Custom-Header', 'value');
xhr.send();

上面代码中,HTTP请求的方法是PUT,并且发送一个自定义头信息X-Custom-Header

浏览器发现,这是一个非简单请求,就自动发出一个”预检”请求,要求服务器确认可以这样请求。下面是这个”预检”请求的HTTP头信息。


OPTIONS /cors HTTP/1.1
Origin: http://api.bob.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

“预检”请求用的请求方法是OPTIONS,表示这个请求是用来询问的。头信息里面,关键字段是Origin,表示请求来自哪个源。

除了Origin字段,”预检”请求的头信息包括两个特殊字段。

(1)Access-Control-Request-Method

该字段是必须的,用来列出浏览器的CORS请求会用到哪些HTTP方法,上例是PUT

(2)Access-Control-Request-Headers

该字段是一个逗号分隔的字符串,指定浏览器CORS请求会额外发送的头信息字段,上例是X-Custom-Header

4.2 预检请求的回应

服务器收到”预检”请求以后,检查了OriginAccess-Control-Request-MethodAccess-Control-Request-Headers字段以后,确认允许跨源请求,就可以做出回应。


HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

上面的HTTP回应中,关键的是Access-Control-Allow-Origin字段,表示http://api.bob.com可以请求数据。该字段也可以设为星号,表示同意任意跨源请求。


Access-Control-Allow-Origin: *

如果浏览器否定了”预检”请求,会返回一个正常的HTTP回应,但是没有任何CORS相关的头信息字段。这时,浏览器就会认定,服务器不同意预检请求,因此触发一个错误,被XMLHttpRequest对象的onerror回调函数捕获。控制台会打印出如下的报错信息。


XMLHttpRequest cannot load http://api.alice.com.
Origin http://api.bob.com is not allowed by Access-Control-Allow-Origin.

服务器回应的其他CORS相关字段如下。


Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 1728000

(1)Access-Control-Allow-Methods

该字段必需,它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法。注意,返回的是所有支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次”预检”请求。

(2)Access-Control-Allow-Headers

如果浏览器请求包括Access-Control-Request-Headers字段,则Access-Control-Allow-Headers字段是必需的。它也是一个逗号分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在”预检”中请求的字段。

(3)Access-Control-Allow-Credentials

该字段与简单请求时的含义相同。

(4)Access-Control-Max-Age

该字段可选,用来指定本次预检请求的有效期,单位为秒。上面结果中,有效期是20天(1728000秒),即允许缓存该条回应1728000秒(即20天),在此期间,不用发出另一条预检请求。

4.3 浏览器的正常请求和回应

一旦服务器通过了”预检”请求,以后每次浏览器正常的CORS请求,就都跟简单请求一样,会有一个Origin头信息字段。服务器的回应,也都会有一个Access-Control-Allow-Origin头信息字段。

下面是”预检”请求之后,浏览器的正常CORS请求。


PUT /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
X-Custom-Header: value
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

上面头信息的Origin字段是浏览器自动添加的。

下面是服务器正常的回应。


Access-Control-Allow-Origin: http://api.bob.com
Content-Type: text/html; charset=utf-8

上面头信息中,Access-Control-Allow-Origin字段是每次回应都必定包含的。

五、与JSONP的比较

CORS与JSONP的使用目的相同,但是比JSONP更强大。

JSONP只支持GET请求,CORS支持所有类型的HTTP请求。JSONP的优势在于支持老式浏览器,以及可以向不支持CORS的网站请求数据

[转]聊聊高并发长连接架构:百万在线的美拍直播弹幕系统如何实现

https://juejin.im/entry/5a0be18a6fb9a0450671280b

导读:直播弹幕是直播系统的核心功能之一。如何迅速作出一个有很好扩展性的弹幕系统?如何应对业务迅速发展?相信很多工程师/架构师都有自己的想法。本文作者是美拍的架构师,经历了直播弹幕从无到有,从小到大的过程。本文是作者对构建弹幕系统的经验总结。

 王静波,毕业于西安交通大学,曾任职于网易和新浪微博,微博工作期间负责开放平台业务和技术体系建设。2015 年 9 月加入美图,就职于架构平台部,目前负责部分核心业务和基础设施的研发工作,包括弹幕服务、Feed 服务、任务调度和质量监控体系等。十余年的后端研发经历,拥有丰富的后端研发经验,对于构建高可用、高并发的系统有较多实践经验。欢迎通过 wjb@meitu.com 跟他交流。

直播弹幕指直播间的用户,礼物,评论,点赞等消息,是直播间交互的重要手段。美拍直播弹幕系统从 2015 年 11 月到现在,经过了三个阶段的演进,目前能支撑百万用户同时在线。比较好地诠释了根据项目的发展阶段,进行平衡演进的过程。这三个阶段分别是快速上线,高可用保障体系建设,长连接演进。

一、快速上线

消息模型

美拍直播弹幕系统在设计初期的核心要求是:快速上线,并能支撑百万用户同时在线。基于这两点,我们策略是前中期 HTTP 轮询方案,中后期替换为长连接方案。因此在业务团队进行 HTTP 方案研发的同时,基础研发团队也紧锣密鼓地开发长连接系统。

直播间消息,相对于 IM 的场景,有其几个特点

  • 消息要求及时,过时的消息对于用户来说不重要;
  • 松散的群聊,用户随时进群,随时退群;
  • 用户进群后,离线期间(接听电话)的消息不需要重发;

对于用户来说,在直播间有三个典型的操作:

  • 进入直播间,拉取正在观看直播的用户列表
  • 接收直播间持续接收弹幕消息
  • 自己发消息

我们把礼物,评论,用户的数据都当做消息来看待。经过考虑选择了 Redis 的 sortedset 存储消息,消息模型如下:

  • 用户发消息,通过 Zadd,其中 score 消息的相对时间;
  • 接收直播间的消息,通过 ZrangeByScore 操作,两秒一次轮询;
  • 进入直播间,获取用户的列表,通过 Zrange 操作来完成;

 

因此总的流程是

  • 写消息流程是:  前端机 -> Kafka -> 处理机 -> Redis
  • 读消息流程是:  前端 -> Redis

不过这里有一个隐藏的并发问题:用户可能丢消息。

如上图所示,某个用户从第6号评论开始拉取,同时有两个用户在发表评论,分别是10,11号评论。如果11号评论先写入,用户刚好把6,7,8,9,11号拉走,用户下次再拉取消息,就从12号开始拉取,结果是:用户没有看到10号消息。

为了解决这个问题,我们加上了两个机制:

  • 在前端机,同一个直播间的同一种消息类型,写入 Kafka 的同一个 partition
  • 在处理机,同一个直播间的同一种消息类型,通过 synchronized 保证写入 Redis 的串行。

消息模型及并发问题解决后,开发就比较顺畅,系统很快就上线,达到预先预定目标。

上线后暴露问题的解决

上线后,随着量的逐渐增加,系统陆续暴露出三个比较严重的问题,我们一一进行解决

问题一:消息串行写入 Redis,如果某个直播间消息量很大,那么消息会堆积在 Kafka 中,消息延迟较大。

解决办法:

  • 消息写入流程:前端机-> Kafka -> 处理机 -> Redis
  • 前端机:如果延迟小,则只写入一个 Kafka 的partion;如果延迟大,则这个直播的这种消息类型写入 Kafka 的多个partion。
  • 处理机:如果延迟小,加锁串行写入 Redis;如果延迟大,则取消锁。因此有四种组合,四个档位,分别是
    • 一个partion, 加锁串行写入 Redis, 最大并发度:1
    • 多个partition,加锁串行写入 Redis, 最大并发度:Kafka partion的个数
    • 一个partion, 不加锁并行写入 Redis, 最大并发度: 处理机的线程池个数
    • 多个partion, 不加锁并行写入 Redis,最大并发度: Kafka partition个数处理机线程池的个数
  • 延迟程度判断:前端机写入消息时,打上消息的统一时间戳,处理机拿到后,延迟时间 = 现在时间 – 时间戳;
  • 档位选择:自动选择档位,粒度:某个直播间的某个消息类型

问题二:用户轮询最新消息,需要进行 Redis 的 ZrangByScore 操作,redis slave 的性能瓶颈较大

解决办法:

  • 本地缓存,前端机每隔1秒左右取拉取一次直播间的消息,用户到前端机轮询数据时,从本地缓存读取数据;
  • 消息的返回条数根据直播间的大小自动调整,小直播间返回允许时间跨度大一些的消息,大直播间则对时间跨度以及消息条数做更严格的限制。

 

解释:这里本地缓存与平常使用的本地缓存问题,有一个最大区别:成本问题。

如果所有直播间的消息都进行缓存,假设同时有1000个直播间,每个直播间5种消息类型,本地缓存每隔1秒拉取一次数据,40台前端机,那么对 Redis 的访问QPS是   1000 * 5 * 40 = 20万。成本太高,因此我们只有大直播间才自动开启本地缓存,小直播间不开启。

问题三:弹幕数据也支持回放,直播结束后,这些数据存放于 Redis 中,在回放时,会与直播的数据竞争 Redis 的 cpu 资源。

解决办法:

  • 直播结束后,数据备份到 mysql;
  • 增加一组回放的 Redis;
  • 前端机增加回放的 local cache;

 

解释:回放时,读取数据顺序是: local cache -> Redis -> mysql。localcache 与回放 Redis 都可以只存某个直播某种消息类型的部分数据,有效控制容量;local cache与回放 Redis 使用SortedSet数据结构,这样整个系统的数据结构都保持一致。

二、高可用保障

同城双机房部署

分为主机房和从机房,写入都在主机房,读取则由两个机房分担。从而有效保证单机房故障时,能快速恢复。

丰富的降级手段

全链路的业务监控

高可用保障建设完成后,迎来了 TFBOYS 在美拍的四场直播,这四场直播峰值同时在线人数达到近百万,共 2860万人次观看,2980万评论,26.23亿次点赞,直播期间,系统稳定运行,成功抗住压力。

使用长连接替换短连接轮询方案

长连接整体架构图如下

详细说明:

  • 客户端在使用长连接前,会调用路由服务,获取连接层IP,路由层特性:a. 可以按照百分比灰度;b. 可以对 uid,deviceId,版本进行黑白名单设置。黑名单:不允许使用长连接;白名单:即使长连接关闭或者不在灰度范围内,也允许使用长连接。这两个特性保证了我们长短连接切换的顺利进行;
  • 客户端的特性:a. 同时支持长连接和短连接,可根据路由服务的配置来决定;b. 自动降级,如果长连接同时三次连接不上,自动降级为短连接;c. 自动上报长连接性能数据;
  • 连接层只负责与客户端保持长连接,没有任何推送的业务逻辑。从而大大减少重启的次数,从而保持用户连接的稳定;
  • 推送层存储用户与直播间的订阅关系,负责具体推送。整个连接层与推送层与直播间业务无关,不需要感知到业务的变化;
  • 长连接业务模块用于用户进入直播间的验证工作;
  • 服务端之间的通讯使用基础研发团队研发的tardis框架来进行服务的调用,该框架基于 gRPC,使用 etcd 做服务发现;

长连接消息模型

我们采用了订阅推送模型,下图为基本的介绍

举例说明:用户1订阅了A直播,A直播有新的消息

  • 推送层查询订阅关系后,知道有用户1订阅了A直播,同时知道用户1在连接层1这个节点上,那么就会告知连接层有新的消息
  • 连接层1收到告知消息后,会等待一小段时间(毫秒级),再拉取一次用户1的消息,然后推送给用户1.

如果是大直播间(订阅用户多),那么推送层与连接层的告知/拉取模型,就会自动降级为广播模型。如下图所示

我们经历客户端三个版本的迭代,实现了两端(Android 与 iOS)长连接对短连接的替换,因为有灰度和黑白名单的支持,替换非常平稳,用户无感知。

总结与展望

回顾了系统的发展过程,达到了原定的前中期使用轮询,中后期使用长连接的预定目标,实践了原定的平衡演进的原则。从发展来看,未来计划要做的事情有

  • 针对机房在北京,南方某些地区会存在连接时间长的情况。我们如何让长连接更靠近用户。
  • 消息模型的进一步演进。