消息队列摘要

1,为什么要使用消息队列

解耦、弹性伸缩、冗余存储、流量削峰、异步通信、数据同步

(1) 解耦

将消息写入消息队列,需要消息的系统自己从消息队列中订阅,原系统不需要做任何修改。

(2) 异步

将消息写入消息队列,非必要的业务逻辑以异步的方式运行,加快响应速度

(3) 削峰

并发请求到消息队列,业务系统按照实际处理能力从消息队列拉去数据。

2,使用了消息队列会有什么缺点

系统可用性降低: 消息队列如果宕机,系统直接崩溃,因此,系统可用性降低

系统复杂性增加: 要多考虑的问题,比如一致性问题、如何保证消息不被重复消费,如何保证保证消息可靠传输。因此,需要考虑的东西更多,系统复杂性增大。

3,消息队列如何选型

1506330751030_7532_1506330753496.png

中小型软件公司:建议选RabbitMQ
大型软件公司:根据具体使用在rocketMq和kafka之间二选一

4,如何保证消息队列是高可用的

参考具体的消息队列高可用方案

5,如何保证消息不被重复消费

即保证消息队列的幂等性。

正常情况下,消费者在消费消息时候,消费完毕后,会发送一个确认信息给消息队列,消息队列就知道该消息被消费了,就会将该消息从消息队列中删除。只是不同的消息队列发送的确认信息形式不同,例如RabbitMQ是发送一个ACK确认消息,RocketMQ是返回一个CONSUME_SUCCESS成功标志,kafka实际上有个offset的概念,简单说一下(如果还不懂,出门找一个kafka入门到精通教程),就是每一个消息都有一个offset,kafka消费过消息后,需要提交offset,让消息队列知道自己已经消费过了。那造成重复消费的原因?,就是因为网络传输等等故障,确认信息没有传送到消息队列,导致消息队列不知道自己已经消费过该消息了,再次将该消息分发给其他的消费者。

解决:

  • (1) 拿到这个消息做数据库的insert操作,分配这个消息一个唯一主键,如果出现重复消费,则会导致主键冲突。
  • (2) redis的set的操作,无论set几次结果都是一样的,set操作本来就算幂等操作。
  • (3) 消费记录,以redis为例,给消息分配一个全局id,只要消费过该消息,将<id,message>以K-V形式写入redis。那消费者开始消费前,先去redis中查询有没消费记录即可。

6,保证消费的可靠性传输

  • (1)生产者丢数据
  • (2)消息队列丢数据
  • (3)消费者丢数据

如何保证消息的顺序性

通过某种算法,将需要保持先后顺序的消息放到同一个消息队列中(kafka中就是partition,rabbitMq中就是queue)。然后只用一个消费者去消费该队列。

参考:

【原创】分布式之消息队列复习精讲

消息中间件选型分析:从Kafka与RabbitMQ的对比看全局

常用消息队列介绍与对比

消息队列及常见消息队列介绍

docker容器无法访问宿主机-No route to host

方法一:关闭防火墙

centos关闭防火墙的操作为

1
systemctl stop firewalld

方法二: 在防火墙上开发指定端口

1
2
firewall-cmd --zone=public --add-port=2181/tcp --permanent
firewall-cmd --reload

参考:

docker容器无法访问宿主机-No route to host

浅谈Docker Bridge网络模式

Docker 网络之理解 bridge 驱动

Docker 网络之进阶篇

AspNetCore利用Skywalking监控性能

SkyWalking开源项目由吴晟于2015年创建,同年10月在GitHub上作为个人项目开源。

SkyWalking项目的核心目标,是针对微服务、Cloud Native、容器化架构,提供应用性能监控(APM)和分布式调用链追踪能力。

2017年11月,SkyWalking社区正式决定,寻求加入Apache基金会,希望能使项目成为更为开放、全球化和强大的APM开源产品,并加强来自社区的合作和交流。最终实现构建一款功能强大、简单易用的开源APM产品。

2017年12月8日,Apache软件基金会孵化器项目管理委员会 ASF IPMC宣布“SkyWalking全票通过,进入Apache孵化器”。

** 软件环境**

1
2
3
4
5
CentOS7
Docker 18.03.1-ce
ElasticSearch5.X
AspDotNetCore2.x
skywalking 5.0.0-beta

Docker安装ElasticSearch5.X

新建docker-compose.yml文件:

1
touch docker-compose.yml

编辑,填入以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
version: '2.2'
services:
elasticsearch5.6:
image: docker.elastic.co/elasticsearch/elasticsearch:5.6.10
container_name: elasticsearch5.6
environment:
- cluster.name=CollectorDBCluster
- xpack.security.enabled=false
- network.host= 0.0.0.0
- thread_pool.bulk.queue_size=1000
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- esdata1:/usr/share/elasticsearch/data
ports:
- 9200:9200
- 9300:9300
networks:
- esnet
volumes:
esdata1:
driver: local
networks:
esnet:

注:需要使用xpack.security.enabled=false,关闭xpack身份验证。

启动:

1
2
docker-compose up -d
docker ps # 查看容器

部署skywalking

官网:https://github.com/apache/incubator-skywalking/releases

参考:https://github.com/apache/incubator-skywalking/blob/master/docs/en/Deploy-backend-in-standalone-mode.md

下载5.0.0-beta

解压:

1
tar -xzvf apache-skywalking-apm-incubating-5.0.0-beta.tar.gz

** 修改配置**

(1) 修改/bin目录下webappService.sh

–collector.ribbon.listOfServers=192.168.0.110:10800

修改localhost为本地IP地址:

localhost=>192.168.0.110

(2) 修改config目录下application.yml

修改localhost为本地IP地址:

localhost=>192.168.0.110

进入解压后的目录,运行:

1
./bin/startup.sh

结果:

1
2
SkyWalking Collector started successfully!
SkyWalking Web Application started successfully!

查看主机监控的端口:

1
netstat -ntlp

结果:

1
2
3
4
5
6
7
8
tcp6       0      0 192.168.0.110:12800     :::*                    LISTEN      4147/java
tcp6 0 0 192.168.0.110:10800 :::* LISTEN 4147/java
tcp6 0 0 :::8080 :::* LISTEN 4153/java
tcp6 0 0 :::9200 :::* LISTEN 3682/docker-proxy
tcp6 0 0 :::8083 :::* LISTEN 3971/docker-proxy
tcp6 0 0 :::8084 :::* LISTEN 3868/docker-proxy
tcp6 0 0 :::9300 :::* LISTEN 3672/docker-proxy
tcp6 0 0 192.168.0.110:11800 :::* LISTEN 4147/java

其中8080,10800,11800,12800为skywalking程序使用的端口,
8083,8084为AspDotNetCore使用端口
9200,9300为EalsticSearch使用端口

运行dotnetcore应用

1.获取AspDotNetCore项目

1
2
git clone https://github.com/syxdevcode/SkyWalking.Sample.git ## 克隆项目
git pull origin master ## 更新代码(可选)

2.使用docker-compose命令启动

1
docker-compose up -d --build

注:运行可能遇到问题:

1
Unable to load the service index for source https://api.nuget.org/v3/index.json

可能因为镜像服务问题:

解决:

1
vim /etc/docker/daemon.json

修改前:

1
2
3
{
"registry-mirrors": ["https://lhao27k5.mirror.aliyuncs.com"]
}

修改后:

1
2
3
{
"registry-mirrors": ["https://registry.docker-cn.com"]
}

重启docker服务:

1
2
sudo systemctl daemon-reload
sudo systemctl restart docker

之后继续执行docker-compose命令,同时,不要忘记将elasticsearch容器启动。

查看容器IP:

1
2
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' skywalkingsample_skywalkingfrontend_1
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' skywalkingsample_skywalkingbackend_1

3.firewalld添加端口

在docker容器内部无法访问宿主机-No route to host问题,需要在宿主机防火墙添加端口,或者关闭防火墙(不推荐)

(1) 关闭/启动防火墙:

1
2
systemctl start firewalld
systemctl stop firewalld

(2) 添加端口

1
2
3
firewall-cmd --zone=public --add-port=8080/tcp --permanent
firewall-cmd --zone=public --add-port=11800/tcp --permanent
firewall-cmd --reload

4.测试AspDotNetCore程序接口

(1) Backend项目Put接口

1
2
3
4
curl --header "Content-Type: application/json" \
--request PUT \
--data '{"Id":"1","Name":"Test1"}' \
http://192.168.0.110:8084/api/apps

(2) Backend项目Get接口

1
2
curl http://localhost:8084/api/apps
[{"id":1,"name":"Test1"}]

5.查看skywalking

访问:http://localhost:8080/#/monitor/dashboard

QQ截图20180629105145.png

参考:

https://github.com/apache/incubator-skywalking/blob/master/docs/en/Deploy-backend-in-standalone-mode.md

利用Skywalking-netcore监控你的应用性能

Apache SkyWalking 为.NET Core带来开箱即用的分布式追踪和应用性能监控

Docker安装Elasticsearch

用Dockeredit安装Elasticsearch

Elasticsearch也可用作Docker镜像。 镜像使用centos:7作为基本图像。

所有发布的Docker镜像和标签列表可以在www.docker.elastic.co找到。 源代码可以在GitHub上找到。

X-Pack是一个Elastic Stack的扩展,可将安全性,警报,监控,报告和图形功能捆绑到一个易于安装的软件包中。 虽然X-Pack组件旨在无缝协同工作,您也可以轻松地启用或禁用要使用的功能。
在Elasticsearch 5.0.0之前,您必须安装单独的Shield,Watcher和Marvel插件,以获得X-Pack中捆绑在一起的功能。 使用X-Pack,您不再需要担心每个插件是否具有正确的版本,而只需安装您正在运行的Elasticsearch版本的X-Pack就可以了。

拉去镜像

镜像仓库:https://www.docker.elastic.co

1
2
docker pull docker.elastic.co/elasticsearch/elasticsearch:6.3.0
docker pull docker.elastic.co/elasticsearch/elasticsearch-oss:6.3.0

运行Elasticsearch

开发模式

使用以下命令可以快速启动Elasticsearch以进行开发或测试:

1
docker run -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch:6.3.0

生产模式

Linux

该vm.max_map_count设置应该在/etc/sysctl.conf中永久设置:

1
2
$ vim /etc/sysctl.conf
vm.max_map_count = 262144

使命令生效:sysctl -p

要在临时应用该设置: sysctl -w vm.max_map_count=262144

参考:https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html#docker-cli-run-prod-mode

docker-compose启动

启动命令:

1
docker-compose up -d

节点elasticsearch侦听localhost:9200,而elasticsearch2 谈判elasticsearch在桥接网络。

这个例子还使用了 名为卷的Docker,它将被调用esdata1,esdata2如果尚不存在,将会创建它。

docker-compose.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
version: '2.2'
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:6.3.0
container_name: elasticsearch
environment:
- cluster.name=CollectorDBCluster
- network.host= 0.0.0.0
- thread_pool.bulk.queue_size=1000
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- esdata1:/usr/share/elasticsearch/data
ports:
- 9200:9200
networks:
- esnet
elasticsearch2:
image: docker.elastic.co/elasticsearch/elasticsearch:6.3.0
container_name: elasticsearch2
environment:
- cluster.name=CollectorDBCluster
- network.host= 0.0.0.0
- thread_pool.bulk.queue_size=1000
- bootstrap.memory_lock=true
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
- "discovery.zen.ping.unicast.hosts=elasticsearch"
ulimits:
memlock:
soft: -1
hard: -1
volumes:
- esdata2:/usr/share/elasticsearch/data
networks:
- esnet

volumes:
esdata1:
driver: local
esdata2:
driver: local

networks:
esnet:

要停止群集,请键入docker-compose down。数据量将持续存在,因此可以使用docker-compose up再次使用相同的数据启动群集。要销毁群集和数据卷,只需键入 docker-compose down -v。

检查群集状态

查询容器IP:

1
2
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' elasticsearch
172.24.0.2

检查群集状态:

1
2
curl http://127.0.0.1:9200/_cat/health # 或者使用Docker IP
1529905639 05:47:19 docker-cluster green 2 2 0 0 0 0 0 0 - 100.0%

使用docker logs 查看日志。

用Docker 配置Elasticsearch

elasticsearch从/usr/share/elasticsearch/config/其下的文件加载它的配置。配置Elasticsearch和设置JVM选项中记录了这些配置文件。

该镜像提供了几种配置Elasticsearch设置的方法,传统方法是提供定制文件,也就是说 elasticsearch.yml。也可以使用环境变量来设置选项:

通过Docker环境变量呈现参数

要定义群集名称,docker run您可以通过 -e “cluster.name=mynewclustername”。双引号是必需的。

绑定配置

创建您的自定义配置文件并将其安装在图像的相应文件上。例如结合安装一custom_elasticsearch.yml与docker run可与参数来完成:

1
-v full_path_to / custom_elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml

容器以用户elasticsearch使用uid:gid的方式运行Elasticsearch1000:1000。绑定安装的主机目录和文件(如custom_elasticsearch.yml上面) 需要由该用户访问。对于数据和日志文件,例如/usr/share/elasticsearch/data写入访问也是必需的。另请参阅下面的注1。

自定义镜像

1
2
FROM docker.elastic.co/elasticsearch/elasticsearch:6.3.0
COPY --chown=elasticsearch:elasticsearch elasticsearch.yml /usr/share/elasticsearch/config/

然后构建镜像

1
2
docker build --tag=elasticsearch-custom .
docker run -ti -v /usr/share/elasticsearch/data elasticsearch-custom

一些插件需要额外的安全权限。您必须通过tty在运行Docker映像时附加一个并在提示中接受yes 来明确接受它们,或者单独检查安全权限,并且如果您对将这些–batch标志添加到plugin install命令时感到满意,那么您必须明确接受它们。查看插件管理文档了解更多详情。

使用Elasticsearch Docker镜像配置SSL/TLS

https://www.elastic.co/guide/en/elasticsearch/reference/current/configuring-tls-docker.html

生产环境注意事项

  • 1.默认情况下,Elasticsearch以elasticsearch使用uid:gid的用户身份在容器内运行1000:1000。
1
2
3
mkdir esdatadir
chmod g+rwx esdatadir
chgrp 1000 esdatadir
  • 2.确保nofile 和nproc可用于Elasticsearch容器的ulimits是非常重要的
1
--ulimit nofile = 6553665536
  • 3.交换性能和节点稳定性需要禁用。
1
-e“bootstrap.memory_lock = true”--ulimit memlock = -1:-1
  • 4.该镜像开放 TCP端口9200和9300.对于群集,建议随机化发布的端口–publish-all,除非您为每个主机固定一个容器。

  • 5.使用ES_JAVA_OPTS环境变量来设置堆大小。例如,使用16GB的使用-e ES_JAVA_OPTS=”-Xms16g -Xmx16g”与docker run。

  • 6./usr/share/elasticsearch/data如生产示例中所示, 始终使用绑定的卷,原因如下:

(1) 如果容器被杀死,您的elasticsearch节点的数据不会丢失
(2) Elasticsearch对I/O敏感,而Docker存储驱动程序对于快速I / O并不理想
(3) 它允许使用高级 Docker卷插件

参考:

https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html

Docker环境中Elasticsearch的安装

Elasticsearch重要配置

ElasticSearch大数据分布式弹性搜索引擎使用

ElasticSearch入门 第二篇:集群配置

elasticsearch.yml配置文件

https://github.com/13428282016/elasticsearch-CN/wiki/es-setup–elasticsearch

https://www.elastic.co/guide/en/elasticsearch/reference/current/system-config.html

https://www.elastic.co/guide/en/elasticsearch/reference/current/important-settings.html

ASP.NET Core基于Consul服务治理实现

直接运行ASP.NET Core程序

参照:Docker & Fabio & Consul群集 & ASP.NET Core 2.0实践

目录结构:

1
2
3
web
|--ConsulTest
|--ConsulTest1

复制一份新目录

1
cp -R ConsulTest ConsulTest1

修改docker-compose.override.yml配置文件:

1
2
3
4
5
6
7
8
version: '3.4'

services:
consultest:
environment:
- ASPNETCORE_ENVIRONMENT=Development
ports:
- "8080:8080"

修改bundleconfig.json:

注:service.ip需要使用以下命令查询:

1
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' consultest_consultest_1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"Service": {
"IP": "172.22.0.2",
"Name": "ConsulDemo1",
"Port": "8080"
},
"Consul": {
"IP": "172.17.0.5",
"Port": "8500"
},
"Logging": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Warning"
}
}
}

修改Program.cs

1
2
3
4
5
public static IWebHost BuildWebHost(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseStartup<Startup>()
.UseUrls("http://*:8080") // 添加端口映射
.Build();

运行以下命令,启动服务:

1
docker-compose up -d --build

使用配置文件重新启动consul客户端

准备配置文件,取名services_config.json(命名规则,需要使用.json后缀)

注:配置中的web service服务需要提前启动,以获取IP,port信息。

目录结构:

1
2
3
4
5
document
|--consul
|--client1.josn
|--config
|--services_config.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
{
"services":[
{
"id": "EDC_DNC_MSAD_CLIENT_SERVICE_01",
"name" : "Client-Service",
"tags": [
"urlprefix-/ClientService80"
],
"address": "172.21.0.2",
"port": 80,
"checks": [
{
"name": "clientservice80_check",
"http": "http://172.21.0.2:80/api/health",
"interval": "10s",
"timeout": "5s"
}
]
},
{
"id": "EDC_DNC_MSAD_CLIENT_SERVICE_02",
"name" : "Client-Service",
"tags": [
"urlprefix-/ClientService8080"
],
"address": "172.22.0.2",
"port": 8080,
"checks": [
{
"name": "clientservice8080_check",
"http": "http://172.22.0.2:8080/api/health",
"interval": "10s",
"timeout": "5s"
}
]
}
]
}

在document文件夹下运行以下命令:

1
docker run --name=client1 -it -d -p 8500:8500 -v $PWD/consul:/consul consul agent -config-dir=/consul/config -config-file=/consul/client1.json

-config-dir - 要加载的配置文件的目录。 Consul将加载后缀为“.json”的所有文件。加载顺序是按字母顺序排列的,并且与上面的配置文件选项一样使用相同的合并例程。可以多次指定此选项以加载多个目录。不加载config目录的子目录。

结果:

QQ截图20180620162327.png

通过API进行服务发现

1
curl http://localhost:8500/v1/catalog/service/Client-Service | python -m json.tool

结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
[root@localhost document]# curl http://localhost:8500/v1/catalog/service/Client-Service | python -m json.tool
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 100 945 100 945 0 0 51053 0 --:--:-- --:--:-- --:--:-- 52500
[
{
"Address": "172.17.0.5",
"CreateIndex": 24219,
"Datacenter": "consul-test",
"ID": "8bc069e0-821b-b714-751d-c6fe07dfa7ea",
"ModifyIndex": 24442,
"Node": "client1",
"NodeMeta": {
"consul-network-segment": ""
},
"ServiceAddress": "172.21.0.2",
"ServiceEnableTagOverride": false,
"ServiceID": "EDC_DNC_MSAD_CLIENT_SERVICE_01",
"ServiceMeta": {},
"ServiceName": "Client-Service",
"ServicePort": 80,
"ServiceTags": [
"urlprefix-/ClientService80"
],
"TaggedAddresses": {
"lan": "172.17.0.5",
"wan": "172.17.0.5"
}
},
{
"Address": "172.17.0.5",
"CreateIndex": 24220,
"Datacenter": "consul-test",
"ID": "8bc069e0-821b-b714-751d-c6fe07dfa7ea",
"ModifyIndex": 24443,
"Node": "client1",
"NodeMeta": {
"consul-network-segment": ""
},
"ServiceAddress": "172.22.0.2",
"ServiceEnableTagOverride": false,
"ServiceID": "EDC_DNC_MSAD_CLIENT_SERVICE_02",
"ServiceMeta": {},
"ServiceName": "Client-Service",
"ServicePort": 8080,
"ServiceTags": [
"urlprefix-/ClientService8080"
],
"TaggedAddresses": {
"lan": "172.17.0.5",
"wan": "172.17.0.5"
}
}

可以看到返回了两个服务实例的信息,当然,这里建议服务名还是不要有空格为好。此外,在服务发现的过程中,会加以一定的负载均衡策略,从这两个服务实例中选择一个返回给服务消费端,比如:随机、轮询、加权轮询、基于性能的最小连接数等等。

Consul集群之Key/Value存储

Consul除了可以实现服务注册和服务发现之外,还提供了强大的KV(Key/Value)存储。我们可以使用Consul的分层KV存储干任何事情,比如:动态配置,特征标记,协调,leader选举等。KV存储的API是基于http的。

查看所有KV

通过curl http://localhost:8500/v1/kv/?recurse命令

或者通过Web UI 查看。

新增KV

1
curl -X PUT -d 'consulvalue' http://172.17.0.5:8500/v1/kv/web/value1

key:value1, value:consulvalue

查看key/value:

1
2
[root@localhost ConsulTest]# curl http://172.17.0.5:8500/v1/kv/web/value1
[{"LockIndex":0,"Key":"web/value1","Flags":0,"Value":"Y29uc3VsdmFsdWU=","CreateIndex":24586,"ModifyIndex":24592}]

由于Consul的Value是经过Base64编码的(主要是为了允许非UTF-8的字符),所以这里看到的是编码后的结果。我们可以通过解码得到最终的Value值。

验证KV是否同步

1
2
3
4
5
6
7
8
9
10
11
[root@localhost document]# docker exec -it server1 sh
/ # consul kv get web/value1
consulvalue
/ # exit
[root@localhost document]# docker exec -it server2 sh
/ # consul kv get web/value1
consulvalue
/ # exit
[root@localhost document]# docker exec -it server3 sh
/ # consul kv get web/value1
consulvalue

编辑KV和删除KV

编辑:(同添加KV)

1
curl -X PUT -d 'consulvalue' http://172.17.0.5:8500/v1/kv/web/value1

删除:

1
curl -X DELETE http://172.17.0.5:8500/v1/kv/web/value1

Consul服务告警之Watch机制

添加NoticeService服务

源码参考:https://github.com/syxdevcode/ConsulTest.git

首先在ConsulTest目录运行以下命令更新源代码:

1
2
git checkout .  ##撤销更改
git pull origin master

在NoticeService目录运行以下命令,启动服务:

1
docker-compose up -d --build

查看生成的容器的IP:

1
2
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' noticeservice_noticeservice_1
172.23.0.2

修改配置文件

添加watch.json配置文件

目录结构:

1
2
3
4
5
6
document
|--consul
|--client1.josn
|--config
|--services_config.json
|--watch.json

在config文件夹下,添加watch.json文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"watches": [
{
"type": "checks",
"handler_type": "http",
"state": "critical",
"http_handler_config": {
"path": "http://172.23.0.2:8081/notice",
"method": "POST",
"timeout": "10s",
"header": { "Authorization": [ "token" ] }
}
}
]
}

细节请参考:

https://www.consul.io/docs/agent/watches.html

https://www.consul.io/api/health.html

state支持: any, passing, warning, or critical。

修改services_config.json文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
{
"services": [
{
"id": "EDC_DNC_MSAD_CLIENT_SERVICE_01",
"name": "Client-Service",
"tags": [
"urlprefix-/ClientService80"
],
"address": "172.21.0.2",
"port": 80,
"checks": [
{
"name": "clientservice80_check",
"http": "http://172.21.0.2:80/api/health",
"interval": "10s",
"timeout": "5s"
}
]
},
{
"id": "EDC_DNC_MSAD_CLIENT_SERVICE_02",
"name": "Client-Service",
"tags": [
"urlprefix-/ClientService8080"
],
"address": "172.22.0.2",
"port": 8080,
"checks": [
{
"name": "clientservice8080_check",
"http": "http://172.22.0.2:8080/api/health",
"interval": "10s",
"timeout": "5s"
}
]
},
{
"id": "EDC_DNC_MSAD_NOTICE_SERVICE",
"name": "Client-Service",
"tags": [
"urlprefix-/NoticeService8081"
],
"address": "172.23.0.2",
"port": 8081,
"checks": [
{
"name": "noticeservice_check",
"http": "http://172.23.0.2:8081/api/health",
"interval": "10s",
"timeout": "5s"
}
]
}
]
}

重启client1,并且查看NoticeService是否注册成功:

1
docker restart client1

QQ截图20180621134758.png

测试服务预警

(1) 手动关闭三个dotnet core服务中的其中一个,如:

1
docker stop consultest1_consultest_1

(2) 收到邮件。

1
2
3
4
5
6
7
8
9
健康检查故障:
--------------------------------------
Node:client1
Service ID:EDC_DNC_MSAD_CLIENT_SERVICE_02
Service Name:Client-Service
Check ID:service:EDC_DNC_MSAD_CLIENT_SERVICE_02
Check Name:clientservice8080_check
Check Status:critical
Check Output:Get http://172.22.0.2:8080/api/health: net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)

** 注意:确保你的虚拟机可以访问外网,不然是发布出来Email的**

参考:

.NET Core微服务之基于Consul实现服务治理(续)

https://www.consul.io/docs/agent/watches.html

https://www.consul.io/api/health.html

https://github.com/PlayFab/consuldotnet

1,Docker运行consul环境

1、Consul基础介绍

https://www.consul.io/

Consul 是 HashiCorp 公司推出的开源工具,用于实现分布式系统的服务发现与配置。与其他分布式服务注册与发现的方案,比如 Airbnb 的 SmartStack 等相比,Consul 的方案更“一站式”,一致性协议采用 Raft 算法,用来保证服务的高可用. 使用 GOSSIP 协议管理成员和广播消息, 并且支持 ACL 访问控制,内置了服务注册与发现框架、分布一致性协议实现、健康检查,并允许 HTTP 和 DNS 协议调用 API 存储键值对、Key/Value 存储、多数据中心方案,不再需要依赖其他工具(比如 ZooKeeper 等)。使用起来也较为简单。
Consul 用 Golang 实现,因此具有天然可移植性(支持 Linux、windows 和 Mac OS X);安装包仅包含一个可执行文件,方便部署,与 Docker 等轻量级容器可无缝配合。

要想利用Consul提供的服务实现服务的注册与发现,我们需要建立Consul Cluster。在Consul方案中,每个提供服务的节点上都要部署和运行Consul的agent,所有运行Consul Agent节点的集合构成Consul Cluster。Consul Agent有两种运行模式:Server和Client。这里的Server和Client只是Consul集群层面的区分,与搭建在Cluster之上的应用服务无关。以Server模式运行的Consul Agent节点用于维护Consul集群的状态,官方建议每个Consul Cluster至少有3个或以上的运行在Server Mode的Agent,Client节点不限。

  Consul支持多数据中心,每个数据中心的Consul Cluster都会在运行于Server模式下的Agent节点中选出一个Leader节点,这个选举过程通过Consul实现的raft协议保证,多个Server节点上的Consul数据信息是强一致的。处于Client Mode的Consul Agent节点比较简单,无状态,仅仅负责将请求转发给Server Agent节点。

** Consul 功能:**

  • 服务发现(Service Discovery):客户端通过 Consul 提供服务,其他客户端可以通过 Consul 利用 dns 或者 http 发现依赖服务

  • 健康检查(Health Checking): Consul 提供任务的健康检查,可以用来操作或者监控集群的健康,也可以在服务发现时去除失效的服务

  • 键值对存储(Key/Value Store): 存储层级键值对

  • 多数据中心(Multi Datacenter): Consul 支持开箱即用的多数据中心

** Consul的优势**

  • 使用 Raft 算法来保证一致性, 比复杂的 Paxos 算法更直接. 相比较而言, zookeeper 采用的是 Paxos, 而 etcd 使用的则是 Raft.

  • 支持多数据中心,内外网的服务采用不同的端口进行监听。 多数据中心集群可以避免单数据中心的单点故障,而其部署则需要考虑网络延迟, 分片等情况等. zookeeper 和 etcd 均不提供多数据中心功能的支持.

  • 支持健康检查. etcd 不提供此功能.

  • 支持 http 和 dns 协议接口. zookeeper 的集成较为复杂, etcd 只支持 http 协议,官方提供web管理界面, etcd 无此功能.

** Consul 的使用场景**

  • docker 实例的注册与配置共享

  • coreos 实例的注册与配置共享

  • vitess 集群

  • SaaS 应用的配置共享

  • 与 confd 服务集成,动态生成 nginx 和 haproxy 配置文件

** Consul 的模式**

  • client: 客户端, 无状态, 将 HTTP 和 DNS 接口请求转发给局域网内的服务端集群。一个 Client 是一个转发所有 RPC 到 Server 的代理。这个 Client 是相对无状态的;Client 唯一执行的后台活动是加入 LAN gossip 池,这有一个最低的资源开销并且仅消耗少量的网络带宽。

  • server: 服务端, 保存配置信息, 高可用集群, 在局域网内与本地客户端通讯, 通过广域网与其他数据中心通讯. 每个数据中心的 server 数量推荐为 3 个或是 5 个。参与 Raft 选举,维护集群状态,响应 RPC 查询,与其他数据中心交互 WAN gossip 和转发查询给 leader 或者远程数据中心。

群集架构图如下:

n4mdw.jpg

435188-20161228110501664-588566717.png

Consul和其他类似软件的对比:

QQ截图20180613135320.png

2、Consul群集搭建

Consul 集群搭建时一般提供两种模式:

** 手动模式**: 启动第一个节点后,此时此节点处于 bootstrap 模式,其节点手动执行加入,使用: -join命令
** 自动模式**: 启动第一个节点后,在其他节点配置好尝试加入的目标节点,然后等待其自动加入(不需要人为命令加入),配置:retry_join

以自动模式为例:

目录结构:

1
2
3
4
5
6
7
document
|--consul
|--server1.json
|--server2.json
|--server3.json
|--client1.json
|--data

server1.json配置文件:

1
2
3
4
5
6
7
8
9
10
{
"data_dir": "/data",
"datacenter": "consul-test",
"log_level": "INFO",
"node_name": "server1",
"server": true,
"bind_addr": "172.17.0.2",
"bootstrap": true,
"retry_join": ["172.17.0.2", "172.17.0.3", "172.17.0.4"]
}

server2.json配置文件:

1
2
3
4
5
6
7
8
9
{
"data_dir": "/data",
"datacenter": "consul-test",
"log_level": "INFO",
"node_name": "server2",
"server": true,
"bind_addr": "172.17.0.3",
"retry_join": ["172.17.0.2", "172.17.0.3", "172.17.0.4"]
}

server3.json配置文件:

1
2
3
4
5
6
7
8
9
{
"data_dir": "/data",
"datacenter": "consul-test",
"log_level": "INFO",
"node_name": "server3",
"server": true,
"bind_addr": "172.17.0.4",
"retry_join": ["172.17.0.2", "172.17.0.3", "172.17.0.4"]
}

client1.json配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"data_dir": "/data",
"datacenter": "consul-test",
"log_level": "INFO",
"node_name": "client1",
"server": false,
"ui": true, // 是否开启 UI 访问
"http_config": {
"response_headers": {
"Access-Control-Allow-Origin": "*"
}
},
"addresses": {
"http": "0.0.0.0"
},
"start_join": ["172.17.0.2", "172.17.0.3", "172.17.0.4"]
}

运行docker容器:

1
2
3
4
5
6
7
docker run --name=server1 -it -d -v $PWD/consul:/consul consul agent -config-file=/consul/server1.json

docker run --name=server2 -it -d -v $PWD/consul:/consul consul agent -config-file=/consul/server2.json

docker run --name=server3 -it -d -v $PWD/consul:/consul consul agent -config-file=/consul/server3.json

docker run --name=client1 -it -d -v $PWD/consul:/consul consul agent -config-file=/consul/client1.json

查看容器运行日志(选择其中一个即可),可以看出选举server1作为Leader:

1
docker logs server1

结果:

1
2018/06/14 08:12:06 [INFO] consul: New leader elected: server1

可以查看容器的ip:

1
2
docker inspect -f '{{.NetworkSettings.IPAddress}}' server1
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' server1

查看下集群的状态

注:server1为Leader;

1
docker exec -t server1 consul members

结果:

1
2
3
4
5
6
[root@localhost document]# docker exec -t server1 consul members
Node Address Status Type Build Protocol DC Segment
server1 172.17.0.2:8301 alive server 1.1.0 2 consul-test <all>
server2 172.17.0.3:8301 alive server 1.1.0 2 consul-test <all>
server3 172.17.0.4:8301 alive server 1.1.0 2 consul-test <all>
client1 172.17.0.5:8301 alive client 1.1.0 2 consul-test <default>

访问:http://172.17.0.5:8500,打开 Consul UI 界面,就可以看到我们配置的 Consul Client:

QQ截图20180615092803.png

可以使用以下命令查询其它信息:

1
2
3
curl -L http://172.17.0.5:8500/v1/catalog/datacenters
curl -L http://172.17.0.5:8500/v1/agent/members | python -m json.tool
curl http://172.17.0.5:8500/v1/agent/self | python -m json.tool

3、节点异常处理

LEADER 挂了

leader挂了,consul会重新选取出新的leader,只要超过一半的SERVER还活着,集群是可以正常工作的。

1
docker stop server1

QQ截图20180615095815.png

运行以下命令查看现在的Leader:

1
curl http://172.17.0.5:8500/v1/status/leader

结果:

1
2
[root@localhost document]# curl http://172.17.0.5:8500/v1/status/leader
"172.17.0.3:8300"

注:172.17.0.3为server2 IP。

2,Docker 运行 Fabio 环境

Fabio简介

Fabio 是 ebay 团队用 golang 开发的一个快速、现代、zero-conf 负载均衡 HTTP(S) 路由器,用于部署 Consul 管理的微服务。

因为 consul 支持服务注册与健康检查所以 fabio 能够零配置提供负载。

根据项目的介绍fabio 能提供每秒15000次请求。

服务发现的特点

服务与服务之间的调用需要在配置文件中填写好主机和端口,不易于维护且分布式环境中不易于部署与扩容。

那么此时就需要考虑服务启动的时候自己把主机和端口以及一些其他信息注册到注册中心,这样其他服务可以从中找到它。

甚至更为简单的注册完毕后通过 DNS 的方式来『寻址』。比如 Zookeepr 可以很好的完成这个工作,但是其中还有一个弊端就是服务的健康检查服务注册到注册中心之后如何保证这个服务一定可用?此时就需要自己来写逻辑当服务不可用的时候自动从注册中心下线掉。 然后Consul 可以很轻易的解决这个问题。

工作原理

Consul 提供了一套健康检测机制简单的说针对 http 类型的服务(consul 也支持 其他类型例如tcp)在注册的时候可以顺便注册下健康检测的信息,提供一个健康检测的地址(url)以及一个频率超时时间这样的话 consul 会定期的来请求当状态码是200的时候设置次服务是健康的状态否则是故障状态。

既然注册到consul的服务能够自己维护健康状态此时 fabio 的工作就很简单了! 就是直接从consul 注册表里面取出健康的服务根据服务注册时候的 tags 配置自动创建自己的路由表,然后当一个 http 请求过来的时候自动去做负载均衡

简单流程图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
======    服务注册     =========       =========
A服务 <------> consul集群 ----> 健康的 A/不健康的 A 集群
====== 健康检查 ========= =========
^
| 加入/移出路由表
|
========
fabio 集群
========
^
|
| A服务 如果找到则成功路由否则返回错误
V
http 请求

Docker 运行 Fabio

Fabio Docker 镜像地址:https://hub.docker.com/r/magiconair/fabio/

目录结构

1
2
fabio
|--fabio.properties

首先,在/fabio/目录下创建一个fabio.properties文件(示例配置),然后vim fabio.properties增加下面配置:

1
2
registry.consul.register.addr = 172.17.0.6:9998
registry.consul.addr = 172.17.0.5:8500

在fabio文件夹下运行命令:

1
docker run -d -p 9999:9999 -p 9998:9998 --name=fabio -v $PWD/fabio.properties:/etc/fabio/fabio.properties magiconair/fabio

注:默认是bridge网络模式,也就是我们常用的桥接模式,Docker 会分配给容器一个独立的 IP 地址(端口也是独立的),并且容器和主机之间可以相互访问。或者添加参数:–net=host:host网络模式,容器的网络接口和主机一样,也就是共享一个 IP 地址。

QQ截图20180615172806.png

3,Docker 运行 ASP.NET Core 2.0 服务

3.1 准备asp.net Core应用程序

QQ截图20180620090937.png

3.2 添加HealthController用于Consul健康检查

1
2
3
4
5
6
7
[Produces("application/json")]
[Route("api/Health")]
public class HealthController : Controller
{
[HttpGet]
public IActionResult Get() => Ok("ok");
}

3.3 调用Consul API注册服务

组件地址:https://github.com/PlayFab/consuldotnet

(1) 首先安装 Consul包:

1
install-package Conusl

(2) 添加扩展方法,用于调用Consul API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public static class AppBuilderExtensions
{
public static IApplicationBuilder RegisterWithConsul(this IApplicationBuilder app, IApplicationLifetime lifetime, ServiceEntity serviceEntity)
{
var consulClient = new ConsulClient(x => x.Address = new Uri($"http://{serviceEntity.ConsulIP}:{serviceEntity.ConsulPort}"));//请求注册的 Consul 地址
var httpCheck = new AgentServiceCheck()
{
DeregisterCriticalServiceAfter = TimeSpan.FromSeconds(5),//服务启动多久后注册
Interval = TimeSpan.FromSeconds(10),//健康检查时间间隔,或者称为心跳间隔

// 即本程序运行后的IP地址。需要使用命令:
// docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' consultest_consultest_1
HTTP = $"http://{serviceEntity.IP}:{serviceEntity.Port}/api/health",//健康检查地址
Timeout = TimeSpan.FromSeconds(5)
};

// Register service with consul
var registration = new AgentServiceRegistration()
{
Checks = new[] { httpCheck },
ID = Guid.NewGuid().ToString(),
Name = serviceEntity.ServiceName,
Address = serviceEntity.IP,
Port = serviceEntity.Port,
Tags = new[] { $"urlprefix-/{serviceEntity.ServiceName}" }//添加 urlprefix-/servicename 格式的 tag 标签,以便 Fabio 识别
};

consulClient.Agent.ServiceRegister(registration).Wait();//服务启动时注册,内部实现其实就是使用 Consul API 进行注册(HttpClient发起)
lifetime.ApplicationStopping.Register(() =>
{
consulClient.Agent.ServiceDeregister(registration.ID).Wait();//服务停止时取消注册
});
return app;
}
}

(3)Starup类的Configure方法中,调用此扩展方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env, IApplicationLifetime lifetime)
{
if (env.IsDevelopment())
{
app.UseBrowserLink();
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
}

app.UseStaticFiles();

app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});

// register this service
ServiceEntity serviceEntity = new ServiceEntity
{
IP = Configuration["Service:IP"],// NetworkHelper.LocalIPAddress,
Port = Convert.ToInt32(Configuration["Service:Port"]),
ServiceName = Configuration["Service:Name"],
ConsulIP = Configuration["Consul:IP"],
ConsulPort = Convert.ToInt32(Configuration["Consul:Port"])
};
app.RegisterWithConsul(lifetime, serviceEntity);
}

ServiceEntity类定义:

1
2
3
4
5
6
7
8
public class ServiceEntity
{
public string IP { get; set; }
public int Port { get; set; }
public string ServiceName { get; set; }
public string ConsulIP { get; set; }
public int ConsulPort { get; set; }
}

appSettings.json配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"Service": {
"IP": "172.21.0.2",
"Name": "ConsulDemo",
"Port": "80"
},
"Consul": {
"IP": "172.17.0.5",
"Port": "8500"
},
"Logging": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Warning"
}
}
}

3.4 运行ASP.NET Core程序

在Linux服务器下,建立程序文件夹,使用git clone命令下载代码,如:

1
2
3
4
mkdir web
cd web
git clone https://github.com/syxdevcode/ConsulTest.git
git pull origin master ### 更新代码

使用docker-compose 运行程序:

1
2
cd ConsulTest ## 进入目录
docker-compose up -d --build

结果:

1
2
3
Successfully built dfaf37c4246f
Successfully tagged consultest:latest
Recreating consultest_consultest_1 ... done

查看docker 容器:

1
docker ps -a

结果:

QQ截图20180620092619.png

其它命令:

查看容器IP:

1
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' consultest_consultest_1

3.5 验证

QQ截图20180620093002.png

QQ截图20180620093047.png

参考:

https://github.com/PlayFab/consuldotnet

https://www.consul.io/docs/agent/options.html

.NET Core微服务之基于Consul实现服务治理

Mac OS、Ubuntu 安装及使用 Consul

Docker & Consul & Fabio & ASP.NET Core 2.0 微服务跨平台实践

Docker 三剑客之 Docker Compose

Consul的安装方法

Consul 基础

Consul 原理和使用简介

Consul 集群搭建

Consul集群部署

服务发现 - consul 的介绍、部署和使用

Consul + fabio 实现自动服务发现、负载均衡

分布式锁实现

基于Consul的分布式锁主要利用Key/Value存储API中的acquire和release操作来实现。acquire和release操作是类似Check-And-Set的操作:

acquire操作只有当锁不存在持有者时才会返回true,并且set设置的Value值,同时执行操作的session会持有对该Key的锁,否则就返回false

release操作则是使用指定的session来释放某个Key的锁,如果指定的session无效,那么会返回false,否则就会set设置Value值,并返回true

具体实现中主要使用了这几个Key/Value的API:

create session:https://www.consul.io/api/session.html#session_create

delete session:https://www.consul.io/api/session.html#delete-session

KV acquire/release:https://www.consul.io/api/kv.html#create-update-key

基本流程

consul-lock.png

以上内容引用自:http://blog.didispace.com/spring-cloud-consul-lock-and-semphore/

代码参考:

https://github.com/PlayFab/consuldotnet

参考:

https://github.com/PlayFab/consuldotnet

https://www.consul.io/docs/guides/leader-election.html

https://www.consul.io/api/kv.html

基于Consul的分布式锁实现

缓存击穿

缓存一般是Key,value方式存在,当某一个Key不存在时会查询数据库,假如这个Key,一直不存在,则会频繁的请求数据库,对数据库造成访问压力。

1、使用互斥锁

在根据key获得的value值为空时,先锁上,再从数据库加载,加载完毕,释放锁。若其他线程发现获取锁失败,则睡眠50ms后重试。

当通过某一个key去查询数据的时候,如果对应在数据库中的数据都不存在(不管是数据不存在,还是系统故障),我们将此key对应的value设置为一个默认的值,比如“NULL”,并设置一个缓存的失效时间,它的过期时间会很短,最长不超过五分钟。这时在缓存失效之前,所有通过此key的访问都被缓存挡住了。后面如果此key对应的数据在DB中存在时,缓存失效之后,通过此key再去访问数据,就能拿到新的value了。

至于锁的类型,单机环境用并发包的Lock类型就行,集群环境则使用分布式锁( redis的setnx)

** 优点**

思路简单
保证一致性

** 缺点**

代码复杂度增大
存在死锁的风险

** SETNX语法**

SETNX key value

将 key 的值设为 value ,当且仅当 key 不存在。

若给定的 key 已经存在,则 SETNX 不做任何动作。

SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。

可用版本:>= 1.0.0

时间复杂度: O(1)

返回值: 设置成功,返回 1。设置失败,返回 0 。

1
2
3
4
5
6
7
8
172.19.0.2:6380> EXISTS job
(integer) 0
172.19.0.2:6380> setnx job "programmer"
(integer) 1
172.19.0.2:6380> setnx job "code-farmer"
(integer) 0
172.19.0.2:6380> get job
"programmer"

.net 代码

参考:http://www.cnblogs.com/yaoshangjin/p/7456378.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/// <summary>
/// 分布式锁
/// </summary>
/// <param name="resource">The thing we are locking on</param>
/// <param name="expirationTime">The time after which the lock will automatically be expired by Redis</param>
/// <param name="action">Action to be performed with locking</param>
/// <returns>True if lock was acquired and action was performed; otherwise false</returns>
public bool PerformActionWithLock(string resource, TimeSpan expirationTime, Action action)
{
RedisValue lockToken = Guid.NewGuid().ToString(); //Environment.MachineName;
RedisKey locKey = resource;
var db = GetDatabase();
if (db.LockTake(locKey, lockToken, expirationTime))
{
try
{
action();
}
finally
{
db.LockRelease(locKey, lockToken);
}
return true;
}
else
{
return false;
}
}

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[Test]
public void TestLock()
{
RedisKey _lockKey = "lock_key";
var _lockDuration = TimeSpan.FromSeconds(30);
var mainTask = new Task(() => _redisMSConnectionWrapper.PerformActionWithLock(_lockKey, _lockDuration, () =>
{
Console.WriteLine("1.执行锁内任务-开始");
Thread.Sleep(10000);
Console.WriteLine("1.执行锁内任务-结束");
}));
mainTask.Start();
Thread.Sleep(1000);
var subOk = true;
while (subOk)
{
_redisMSConnectionWrapper.PerformActionWithLock(_lockKey, _lockDuration, () =>
{
Console.WriteLine("2.执行锁内任务-开始");
Console.WriteLine("2.执行锁内任务-结束");
subOk = false;
});
Thread.Sleep(1000);
}
Console.WriteLine("结束");
}

2、异步构建缓存

或者叫”提前”使用互斥锁

在value内部设置1个超时值(timeout1), timeout1比实际的memcache timeout(timeout2)小。当从cache读取到timeout1发现它已经过期时候,马上延长timeout1并重新设置到cache。然后再从数据库加载数据并设置到cache中。

** 优点**

性价最佳,用户无需等待

** 缺点**

无法保证缓存一致性

3、布隆过滤器

布隆过滤器的巨大用处就是,能够迅速判断一个元素是否在一个集合中。因此他有如下三个使用场景:

1.网页爬虫对URL的去重,避免爬取相同的URL地址
2.反垃圾邮件,从数十亿个垃圾邮件列表中判断某邮箱是否垃圾邮箱(同理,垃圾短信)
3.缓存击穿,将已存在的缓存放到布隆过滤器中,当黑客访问不存在的缓存时迅速返回避免缓存及DB挂掉。

** 布隆过滤器的原理**

其内部维护一个全为0的bit数组,需要说明的是,布隆过滤器有一个误判率的概念,误判率越低,则数组越长,所占空间越大。误判率越高则数组越小,所占的空间越小。

** 优点**

思路简单
保证一致性
性能强

** 缺点**

代码复杂度增大
需要另外维护一个集合来存放缓存的Key
布隆过滤器不支持删值操作

缓存雪崩(缓存失效)

在高并发的环境下,缓存集中在一段时间内大量失效(例如设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效),此时有多个进程就会去同时去查询DB,然后再去同时设置缓存。DB访问量会瞬间增大,造成了缓存雪崩。

解决方法:

1, 在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待,参考上文:1、使用互斥锁。
2, 可以通过缓存reload机制,预先去更新缓存,再即将发生大并发访问前手动触发加载缓存。
3, 不同的key,设置不同的过期时间,让缓存失效的时间点尽量均匀,例如在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
4, 做二级缓存,或者双缓存策略。A1为原始缓存,A2为拷贝缓存,A1失效时,可以访问A2,A1缓存失效时间设置为短期,A2设置为长期。

热点key问题

(1) 这个key是一个热点key(例如一个重要的新闻,一个热门的八卦新闻等等),所以这种key访问量可能非常大。

(2) 缓存的构建是需要一定时间的。(可能是一个复杂计算,例如复杂的sql、多次IO、多个依赖(各种接口)等等)

于是就会出现一个致命问题:在缓存失效的瞬间,有大量线程来构建缓存,或者由于设计问题,造成缓存击穿,造成后端负载加大,甚至可能会让系统崩溃。

解决方法:

1,使用互斥锁(mutex key):这种解决方案思路比较简单,就是只让一个线程构建缓存,其他线程等待构建缓存的线程执行完,重新从缓存获取数据就可以了,参考上文:1、使用互斥锁。

2,”提前”使用互斥锁(mutex key):在value内部设置1个超时值(timeout1), timeout1比实际的memcache timeout(timeout2)小。当从cache读取到timeout1发现它已经过期时候,马上延长timeout1并重新设置到cache。然后再从数据库加载数据并设置到cache中。参考上文:2、异步构建缓存

3,”永远不过期”:

这里的“永远不过期”包含两层意思:

(1) 从redis上看,确实没有设置过期时间,这就保证了,不会出现热点key过期问题,也就是“物理”不过期。

(2) 从功能上看,如果不过期,那不就成静态的了吗?所以我们把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期

参考:

分布式之缓存击穿

缓存雪崩、缓存穿透、热点Key解决方案和分析

缓存穿透,缓存击穿,缓存雪崩解决方案分析

缓存击穿、失效以及热点key问题

缓存在分布式系统中的应用

一、缓存概述

缓存是分布式系统中的重要组件,主要解决高并发,大数据场景下,热点数据访问的性能问题。提供高性能的数据快速访问。

1.1缓存的原理

(1)将数据写入/读取速度更快的存储(设备)

(2)将数据缓存到离应用最近的位置;

(3)将数据缓存到离用户最近的位置。

1.2缓存分类

(1)CDN缓存;

(2)反向代理缓存;

(3)分布式Cache;

(4)本地应用缓存;

1.3缓存媒介

常用中间件:Redis,Memcache,Ngnix,Varnish,Squid,Ehcache等;

缓存的内容:文件,数据,对象;

缓存的介质:CPU,内存(本地,分布式),磁盘(本地,分布式)

1.4缓存设计

缓存设计需要解决以下几个问题:

(1)缓存什么?

哪些数据需要缓存:1.热点数据;2.静态资源;

(2)缓存的位置?

CDN,反向代理,分布式缓存服务器,本机(内存,硬盘)

(3)如何缓存的问题?

过期策略

1.固定时间:比如指定缓存的时间是30分钟;
2.相对时间:比如最近10分钟内没有访问的数据;

同步机制

实时写入;(推)
异步刷新;(推拉)

二、CDN缓存

CDN主要解决将数据缓存到离用户最近的位置,一般缓存静态资源文件(页面,脚本,图片,视频,文件等)。国内网络异常复杂,跨运营商的网络访问会很慢。为了解决跨运营商或各地用户访问问题,可以在重要的城市,部署CDN应用。使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。

2.1 CND原理

CDN的基本原理是广泛采用各种缓存服务器,将这些缓存服务器分布到用户访问相对集中的地区或网络中,在用户访问网站时,利用全局负载技术将用户的访问指向距离最近的工作正常的缓存服务器上,由缓存服务器直接响应用户请求。

(1)未部署CDN应用前

网络请求路径:

请求:本机网络(局域网)——》运营商网络——》应用服务器机房

响应:应用服务器机房——》运营商网络——》本机网络(局域网)

在不考虑复杂网络的情况下,从请求到响应需要经过3个节点,6个步骤完成一次用户访问操作。

(2)部署CDN应用后

网络路径:

请求:本机网络(局域网)——》运营商网络

响应:运营商网络——》本机网络(局域网)

在不考虑复杂网络的情况下,从请求到响应需要经过2个节点,2个步骤完成一次用户访问操作。

与不部署CDN服务相比,减少了1个节点,4个步骤的访问。极大的提高的系统的响应速度。

2.2 CDN优缺点

(1)优点(摘自百度百科)

1、本地Cache加速:提升访问速度,尤其含有大量图片和静态页面站点;

2、镜像服务:消除了不同运营商之间互联的瓶颈造成的影响,实现了跨运营商的网络加速,保证不同网络中的用户都能得到良好的访问质量;

3、远程加速:远程访问用户根据DNS负载均衡技术智能自动选择Cache服务器,选择最快的Cache服务器,加快远程访问的速度;

4、带宽优化:自动生成服务器的远程Mirror(镜像)cache服务器,远程用户访问时从cache服务器上读取数据,减少远程访问的带宽、分担网络流量、减轻原站点WEB服务器负载等功能。

5、集群抗攻击:广泛分布的CDN节点加上节点之间的智能冗余机制,可以有效地预防黑客入侵以及降低各种D.D.o.S攻击对网站的影响,同时保证较好的服务质量。

(2)缺点

1.动态资源缓存,需要注意实时性;

解决:主要缓存静态资源,动态资源建立多级缓存或准实时同步;

2.如何保证数据的一致性和实时性需要权衡考虑;

解决:

1.设置缓存失效时间(1个小时,最终一致性);
2.数据版本号;

三、反向代理缓存

反向代理是指在网站服务器机房部署代理服务器,实现负载均衡,数据缓存,安全控制等功能。

3.1缓存原理

反向代理位于应用服务器机房,处理所有对WEB服务器的请求。如果用户请求的页面在代理服务器上有缓冲的话,代理服务器直接将缓冲内容发送给用户。如果没有缓冲则先向WEB服务器发出请求,取回数据,本地缓存后再发送给用户。通过降低向WEB服务器的请求数,从而降低了WEB服务器的负载。

反向代理一般缓存静态资源,动态资源转发到应用服务器处理。常用的缓存应用服务器有Varnish,Ngnix,Squid。

3.2 代理缓存比较

常用的代理缓存有Varnish,Squid,Ngnix,简单比较如下:

(1)varnish和squid是专业的cache服务,nginx需要第三方模块支持;

(2)Varnish采用内存型缓存,避免了频繁在内存、磁盘中交换文件,性能比Squid高;

(3)Varnish由于是内存cache,所以对小文件如css,js,小图片啥的支持很棒,后端的持久化缓存可以采用的是Squid或ATS;

(4)Squid功能全而大,适合于各种静态的文件缓存,一般会在前端挂一个HAProxy或nginx做负载均衡跑多个实例;

(5)Nginx采用第三方模块ncache做的缓冲,性能基本达到varnish,一般作为反向代理使用,可以实现简单的缓存。

四、分布式缓存

CDN,反向代理缓存,主要解决静态文件,或用户请求资源的缓存,数据源一般为静态文件或动态生成的文件(有缓存头标识)。

分布式缓存,主要指缓存用户经常访问数据的缓存,数据源为数据库。一般起到热点数据访问和减轻数据库压力的作用。

目前分布式缓存设计,在大型网站架构中是必备的架构要素。常用的中间件有Memcache,Redis。

Memcache

(1)使用物理内存作为缓存区,可独立运行在服务器上。每个进程最大2G,如果想缓存更多的数据,可以开辟更多的memcache进程(不同端口)或者使用分布式memcache进行缓存,将数据缓存到不同的物理机或者虚拟机上。

(2)使用key-value的方式来存储数据,这是一种单索引的结构化数据组织形式,可使数据项查询时间复杂度为O(1)。

(3)协议简单:基于文本行的协议,直接通过telnet在memcached服务器上可进行存取数据操作,简单,方便多种缓存参考此协议;

(4)基于libevent高性能通信:Libevent是一套利用C开发的程序库,它将BSD系统的kqueue,Linux系统的epoll等事件处理功能封装成一个接口,与传统的select相比,提高了性能。

(5)内置的内存管理方式:所有数据都保存在内存中,存取数据比硬盘快,当内存满后,通过LRU算法自动删除不使用的缓存,但没有考虑数据的容灾问题,重启服务,所有数据会丢失。

(6)分布式:各个memcached服务器之间互不通信,各自独立存取数据,不共享任何信息。服务器并不具有分布式功能,分布式部署取决于memcache客户端。

(7)缓存策略:Memcached的缓存策略是LRU(最近最少使用)到期失效策略。在memcached内存储数据项时,可以指定它在缓存的失效时间,默认为永久。当memcached服务器用完分配的内时,失效的数据被首先替换,然后也是最近未使用的数据。在LRU中,memcached使用的是一种Lazy Expiration策略,自己不会监控存入的key/vlue对是否过期,而是在获取key值时查看记录的时间戳,检查key/value对空间是否过期,这样可减轻服务器的负载。

Redis

Redis 是一个开源(BSD许可)的,基于内存的,多数据结构存储系统。可以用作数据库、缓存和消息中间件。 支持多种类型的数据结构,如 字符串(strings), 散列(hashes), 列表(lists), 集合(sets), 有序集合(sorted sets) 与范围查询, bitmaps, hyperloglogs 和 地理空间(geospatial) 索引半径查询。

内置了 复制(replication),LUA脚本(Lua scripting), LRU驱动事件(LRU eviction),事务(transactions) 和不同级别的 磁盘持久化(persistence), 并通过 Redis哨兵(Sentinel)和自动分区(Cluster)提供高可用性(high availability)。

Memcache与Redis的比较

(1)数据结构:Memcache只支持key value存储方式,Redis支持更多的数据类型,比如Key value,hash,list,set,zset;

(2)多线程:Memcache支持多线程,redis支持单线程;CPU利用方面Memcache优于redis;

(3)持久化:Memcache不支持持久化,Redis支持持久化;

(4)内存利用率:memcache高,redis低(采用压缩的情况下比memcache高);

(5)过期策略:memcache过期后,不删除缓存,会导致下次取数据数据的问题,Redis有专门线程,清除缓存数据;

缓存穿透

缓存穿透是说收到了一个请求,但是该请求缓存里没有,只能去数据库里查询,然后放进缓存。这里面有两个风险,一个是同时有好多请求访问同一个数据,然后业务系统把这些请求全发到了数据库;第二个是有人恶意构造一个逻辑上不存在的数据,然后大量发送这个请求,这样每次请求都会被发送到数据库,可能导致数据挂掉。

怎么应对这种情况呢?对于恶意访问,一个思路是事先做校验,对恶意数据直接过滤掉,不要发到数据库层;第二个思路是缓存空结果,就是对查询不存在的数据仍然记录一条该数据不存在在缓存里,这样能有效的减少查询数据库的次数。

缓存击穿

缓存一般是Key,value方式存在,当某一个Key不存在时会查询数据库,假如这个Key,一直不存在,则会频繁的请求数据库,对数据库造成访问压力。

解决方法:

(1)对结果为空的数据也进行缓存,当此key有数据后,清理缓存;

(2)一定不存在的key,采用布隆过滤器,建立一个大的Bitmap中,查询时通过该bitmap过滤;

一个思路是全局锁,就是所有访问某个数据的请求都共享一个锁,获得锁的那个才有资格去访问数据库,其他线程必须等待。但是现在的业务都是分布式的,本地锁没法控制其他服务器也等待,所以要用到全局锁,比如用redis的setnx实现全局锁。

另一个思路是对即将过期的数据主动刷新,做法可以有很多,比如起一个线程轮询数据,比如把所有数据划分为不同的缓存区间,定期分区间刷新数据等等。

四、缓存雪崩

雪崩是指当大量缓存失效时,导致大量的请求访问数据库,导致数据库服务器,无法抗住请求或挂掉的情况。

解决方法:

(1)合理规划缓存的失效时间;

(2)合理评估数据库的负载压力;

(3)对数据库进行过载保护或应用层限流;

(4)多级缓存设计,缓存高可用;

参考:

大型网站架构系列:缓存在分布式系统中的应用(一)

大型网站架构系列:缓存在分布式系统中的应用(二)

大型网站架构系列:缓存在分布式系统中的应用(三)

高并发请求的缓存设计策略

本文转载深入学习Redis(1):Redis内存模型

这篇文章主要介绍Redis的内存模型(以3.0为例),包括Redis占用内存的情况及如何查询、不同的对象类型在内存中的编码方式、内存分配器(jemalloc)、简单动态字符串(SDS)、RedisObject等;然后在此基础上介绍几个Redis内存模型的应用。

一、Redis内存统计

在客户端通过redis-cli连接服务器后(后面如无特殊说明,客户端一律使用redis-cli),通过info命令可以查看内存使用情况:

1
info memory

结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
172.19.0.2:6380> info memory
# Memory
used_memory:848344
used_memory_human:828.46K
used_memory_rss:7520256
used_memory_rss_human:7.17M
used_memory_peak:848344
used_memory_peak_human:828.46K
used_memory_peak_perc:100.01%
used_memory_overhead:836086
used_memory_startup:786456
used_memory_dataset:12258
used_memory_dataset_perc:19.81%
total_system_memory:1910714368
total_system_memory_human:1.78G

其中,info命令可以显示redis服务器的许多信息,包括服务器基本信息、CPU、内存、持久化、客户端连接信息等等;memory是参数,表示只显示内存相关的信息。

返回结果中比较重要的几个说明如下:

(1)** used_memory**:Redis分配器分配的内存总量(单位是字节),包括使用的虚拟内存(即swap);Redis分配器后面会介绍。used_memory_human只是显示更友好。

(2)** used_memory_rss**:Redis进程占据操作系统的内存(单位是字节),与top及ps命令看到的值是一致的;除了分配器分配的内存之外,used_memory_rss还包括进程运行本身需要的内存、内存碎片等,但是不包括虚拟内存。

因此,used_memory和used_memory_rss,前者是从Redis角度得到的量,后者是从操作系统角度得到的量。二者之所以有所不同,一方面是因为内存碎片和Redis进程运行需要占用内存,使得前者可能比后者小,另一方面虚拟内存的存在,使得前者可能比后者大。

由于在实际应用中,Redis的数据量会比较大,此时进程运行占用的内存与Redis数据量和内存碎片相比,都会小得多;因此used_memory_rss和used_memory的比例,便成了衡量Redis内存碎片率的参数;这个参数就是mem_fragmentation_ratio。

(3)** mem_fragmentation_ratio**:内存碎片比率,该值是used_memory_rss / used_memory的比值。

mem_fragmentation_ratio一般大于1,且该值越大,内存碎片比例越大。mem_fragmentation_ratio<1,说明Redis使用了虚拟内存,由于虚拟内存的媒介是磁盘,比内存速度要慢很多,当这种情况出现时,应该及时排查,如果内存不足应该及时处理,如增加Redis节点、增加Redis服务器的内存、优化应用等。

一般来说,mem_fragmentation_ratio在1.03左右是比较健康的状态(对于jemalloc来说);

(4)** mem_allocator**:Redis使用的内存分配器,在编译时指定;可以是 libc 、jemalloc或者tcmalloc,默认是jemalloc;截图中使用的便是默认的jemalloc。

二、Redis内存划分

Redis的内存占用主要可以划分为以下几个部分:

1、数据

作为数据库,数据是最主要的部分;这部分占用的内存会统计在used_memory中。

Redis使用键值对存储数据,其中的值(对象)包括5种类型,即字符串、哈希、列表、集合、有序集合。这5种类型是Redis对外提供的,实际上,在Redis内部,每种类型可能有2种或更多的内部编码实现;此外,Redis在存储对象时,并不是直接将数据扔进内存,而是会对对象进行各种包装:如redisObject、SDS等;这篇文章后面将重点介绍Redis中数据存储的细节。

2、进程本身运行需要的内存

Redis主进程本身运行肯定需要占用内存,如代码、常量池等等;这部分内存大约几兆,在大多数生产环境中与Redis数据占用的内存相比可以忽略。这部分内存不是由jemalloc分配,因此不会统计在used_memory中。

补充说明:除了主进程外,Redis创建的子进程运行也会占用内存,如Redis执行AOF、RDB重写时创建的子进程。当然,这部分内存不属于Redis进程,也不会统计在used_memory和used_memory_rss中。

3、缓冲内存

缓冲内存包括客户端缓冲区、复制积压缓冲区、AOF缓冲区等;其中,客户端缓冲存储客户端连接的输入输出缓冲;复制积压缓冲用于部分复制功能;AOF缓冲区用于在进行AOF重写时,保存最近的写入命令。在了解相应功能之前,不需要知道这些缓冲的细节;这部分内存由jemalloc分配,因此会统计在used_memory中。

4、内存碎片

内存碎片是Redis在分配、回收物理内存过程中产生的。例如,如果对数据的更改频繁,而且数据之间的大小相差很大,可能导致redis释放的空间在物理内存中并没有释放,但redis又无法有效利用,这就形成了内存碎片。内存碎片不会统计在used_memory中。

内存碎片的产生与对数据进行的操作、数据的特点等都有关;此外,与使用的内存分配器也有关系:如果内存分配器设计合理,可以尽可能的减少内存碎片的产生。后面将要说到的jemalloc便在控制内存碎片方面做的很好。

如果Redis服务器中的内存碎片已经很大,可以通过安全重启的方式减小内存碎片:因为重启之后,Redis重新从备份文件中读取数据,在内存中进行重排,为每个数据重新选择合适的内存单元,减小内存碎片。

三、Redis数据存储的细节

1、概述

关于Redis数据存储的细节,涉及到内存分配器(如jemalloc)、简单动态字符串(SDS)、5种对象类型及内部编码、redisObject。在讲述具体内容之前,先说明一下这几个概念之间的关系。

下图是执行set hello world时,所涉及到的数据模型。

1174710-20180327001055927-1896197804.png

图片来源:https://searchdatabase.techtarget.com.cn/7-20218/

(1)dictEntry:Redis是Key-Value数据库,因此对每个键值对都会有一个dictEntry,里面存储了指向Key和Value的指针;next指向下一个dictEntry,与本Key-Value无关。

(2)Key:图中右上角可见,Key(”hello”)并不是直接以字符串存储,而是存储在SDS结构中。

(3)redisObject:Value(“world”)既不是直接以字符串存储,也不是像Key一样直接存储在SDS中,而是存储在redisObject中。实际上,不论Value是5种类型的哪一种,都是通过redisObject来存储的;而redisObject中的type字段指明了Value对象的类型,ptr字段则指向对象所在的地址。不过可以看出,字符串对象虽然经过了redisObject的包装,但仍然需要通过SDS存储。

实际上,redisObject除了type和ptr字段以外,还有其他字段图中没有给出,如用于指定对象内部编码的字段;后面会详细介绍。

(4)jemalloc:无论是DictEntry对象,还是redisObject、SDS对象,都需要内存分配器(如jemalloc)分配内存进行存储。以DictEntry对象为例,有3个指针组成,在64位机器下占24个字节,jemalloc会为它分配32字节大小的内存单元。

下面来分别介绍jemalloc、redisObject、SDS、对象类型及内部编码。

2、jemalloc

Redis在编译时便会指定内存分配器;内存分配器可以是 libc 、jemalloc或者tcmalloc,默认是jemalloc。

jemalloc作为Redis的默认内存分配器,在减小内存碎片方面做的相对比较好。jemalloc在64位系统中,将内存空间划分为小、大、巨大三个范围;每个范围内又划分了许多小的内存块单位;当Redis存储数据时,会选择大小最合适的内存块进行存储。

jemalloc划分的内存单元如下图所示:

1174710-20180327001126509-2023165562.png

图片来源:http://blog.csdn.net/zhengpeitao/article/details/76573053

例如,如果需要存储大小为130字节的对象,jemalloc会将其放入160字节的内存单元中。

3、redisObject

前面说到,Redis对象有5种类型;无论是哪种类型,Redis都不会直接存储,而是通过redisObject对象进行存储。

redisObject对象非常重要,Redis对象的类型、内部编码、内存回收、共享对象等功能,都需要redisObject支持,下面将通过redisObject的结构来说明它是如何起作用的。

redisObject的定义如下(不同版本的Redis可能稍稍有所不同):

1
2
3
4
5
6
7
typedef struct redisObject {
  unsigned type:4;
  unsigned encoding:4;
  unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */
  int refcount;
  void *ptr;
} robj;

redisObject的每个字段的含义和作用如下:

(1)type

type字段表示对象的类型,占4个比特;目前包括REDIS_STRING(字符串)、REDIS_LIST (列表)、REDIS_HASH(哈希)、REDIS_SET(集合)、REDIS_ZSET(有序集合)。

当我们执行type命令时,便是通过读取RedisObject的type字段获得对象的类型;如下所示:

1
2
3
4
5
6
7
8
172.19.0.2:6380> set mystring helloredis
OK
172.19.0.2:6380> type mystring
string
172.19.0.2:6380> sadd myset member1 member2 member3
(integer) 3
172.19.0.2:6380> type myset
set

(2)encoding

encoding表示对象的内部编码,占4个比特。

对于Redis支持的每种类型,都有至少两种内部编码,例如对于字符串,有int、embstr、raw三种编码。通过encoding属性,Redis可以根据不同的使用场景来为对象设置不同的编码,大大提高了Redis的灵活性和效率。以列表对象为例,有压缩列表和双端链表两种编码方式;如果列表中的元素较少,Redis倾向于使用压缩列表进行存储,因为压缩列表占用内存更少,而且比双端链表可以更快载入;当列表对象元素较多时,压缩列表就会转化为更适合存储大量元素的双端链表。

通过object encoding命令,可以查看对象采用的编码方式,如所示:

1
2
3
4
5
6
7
8
172.19.0.2:6380> set key1 33
OK
172.19.0.2:6380> object encoding key1
"int"
172.19.0.2:6380> set key2 helloworld
OK
172.19.0.2:6380> object encoding key2
"embstr"

(3)lru

lru记录的是对象最后一次被命令程序访问的时间,占据的比特数不同的版本有所不同(如4.0版本占24比特,2.6版本占22比特)。

通过对比lru时间与当前时间,可以计算某个对象的空转时间;object idletime命令可以显示该空转时间(单位是秒)。object idletime命令的一个特殊之处在于它不改变对象的lru值。

1
2
3
4
5
6
7
8
9
172.19.0.2:6380> object idletime mystring
(integer) 540
172.19.0.2:6380> object idletime mystring
(integer) 541
172.19.0.2:6380> object idletime mystring
(integer) 547
172.19.0.2:6380> get mystring
"helloredis"
172.19.0.2:6380> object idletime mystring

lru值除了通过object idletime命令打印之外,还与Redis的内存回收有关系:如果Redis打开了maxmemory选项,且内存回收算法选择的是volatile-lru或allkeys—lru,那么当Redis内存占用超过maxmemory指定的值时,Redis会优先选择空转时间最长的对象进行释放。

(4)refcount

** refcount与共享对象**

refcount记录的是该对象被引用的次数,类型为整型。refcount的作用,主要在于对象的引用计数和内存回收。当创建新对象时,refcount初始化为1;当有新程序使用该对象时,refcount加1;当对象不再被一个新程序使用时,refcount减1;当refcount变为0时,对象占用的内存会被释放。

Redis中被多次使用的对象(refcount>1),称为共享对象。Redis为了节省内存,当有一些对象重复出现时,新的程序不会创建新的对象,而是仍然使用原来的对象。这个被重复使用的对象,就是共享对象。目前共享对象仅支持整数值的字符串对象。

** 共享对象的具体实现 **

Redis的共享对象目前只支持整数值的字符串对象。之所以如此,实际上是对内存和CPU(时间)的平衡:共享对象虽然会降低内存消耗,但是判断两个对象是否相等却需要消耗额外的时间。对于整数值,判断操作复杂度为O(1);对于普通字符串,判断复杂度为O(n);而对于哈希、列表、集合和有序集合,判断的复杂度为O(n^2)。

虽然共享对象只能是整数值的字符串对象,但是5种类型都可能使用共享对象(如哈希、列表等的元素可以使用)。

就目前的实现来说,Redis服务器在初始化时,会创建10000个字符串对象,值分别是09999的整数值;当Redis需要使用值为09999的字符串对象时,可以直接使用这些共享对象。10000这个数字可以通过调整参数REDIS_SHARED_INTEGERS(4.0中是OBJ_SHARED_INTEGERS)的值进行改变。

(5)ptr

ptr指针指向具体的数据,如前面的例子中,set hello world,ptr指向包含字符串world的SDS。

(6)总结

综上所述,redisObject的结构与对象类型、编码、内存回收、共享对象都有关系;一个redisObject对象的大小为16字节:

4bit+4bit+24bit+4Byte+8Byte=16Byte。

4、SDS

Redis没有直接使用C字符串(即以空字符’\0’结尾的字符数组)作为默认的字符串表示,而是使用了SDS。SDS是简单动态字符串(Simple Dynamic String)的缩写。

(1)SDS结构

sds的结构如下:

1
2
3
4
5
struct sdshdr {
int len;
int free;
char buf[];
};

其中,buf表示字节数组,用来存储字符串;len表示buf已使用的长度,free表示buf未使用的长度。下面是两个例子。

通过SDS的结构可以看出,buf数组的长度=free+len+1(其中1表示字符串结尾的空字符);所以,一个SDS结构占据的空间为:free所占长度+len所占长度+ buf数组的长度=4+4+free+len+1=free+len+9。

(2)SDS与C字符串的应用

Redis在存储对象时,一律使用SDS代替C字符串。例如set hello world命令,hello和world都是以SDS的形式存储的。而sadd myset member1 member2 member3命令,不论是键(”myset”),还是集合中的元素(”member1”、 ”member2”和”member3”),都是以SDS的形式存储。除了存储对象,SDS还用于存储各种缓冲区。

只有在字符串不会改变的情况下,如打印日志时,才会使用C字符串。

四、Redis的对象类型与内部编码

前面已经说过,Redis支持5种对象类型,而每种结构都有至少两种编码;这样做的好处在于:一方面接口与实现分离,当需要增加或改变内部编码时,用户使用不受影响,另一方面可以根据不同的应用场景切换内部编码,提高效率。

关于Redis内部编码的转换,都符合以下规律:编码转换在Redis写入数据时完成,且转换过程不可逆,只能从小内存编码向大内存编码转换。

1、字符串

(1)概况

字符串是最基础的类型,因为所有的键都是字符串类型,且字符串之外的其他几种复杂类型的元素也是字符串。

字符串长度不能超过512MB。

(2)内部编码

字符串类型的内部编码有3种,它们的应用场景如下:

** int**:8个字节的长整型。字符串值是整型时,这个值使用long整型表示。

** embstr**:<=39字节的字符串。embstr与raw都使用redisObject和sds保存数据,区别在于,embstr的使用只分配一次内存空间(因此redisObject和sds是连续的),而raw需要分配两次内存空间(分别为redisObject和sds分配空间)。因此与raw相比,embstr的好处在于创建时少分配一次空间,删除时少释放一次空间,以及对象的所有数据连在一起,寻找方便。而embstr的坏处也很明显,如果字符串的长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间,因此redis中的embstr实现为只读。

** raw**:大于39个字节的字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
172.19.0.2:6380> object encoding key2
"int"
172.19.0.2:6380> set key2 helloworld
OK
172.19.0.2:6380> object encoding key2
"embstr"
172.19.0.2:6380> set key3 helloworldhelloworldhelloworldhelloworldhelloworld
OK
172.19.0.2:6380> strlen key3
(integer) 50
172.19.0.2:6380> object encoding key3
"raw"
172.19.0.2:6380>

embstr和raw进行区分的长度,是39;是因为redisObject的长度是16字节,sds的长度是9+字符串长度;因此当字符串长度是39时,embstr的长度正好是16+9+39=64,jemalloc正好可以分配64字节的内存单元。

(3)编码转换

当int数据不再是整数,或大小超过了long的范围时,自动转化为raw。

而对于embstr,由于其实现是只读的,因此在对embstr对象进行修改时,都会先转化为raw再进行修改,因此,只要是修改embstr对象,修改后的对象一定是raw的,无论是否达到了39个字节。

1
2
3
4
5
6
7
8
9
10
172.19.0.2:6380> set key1 hello
OK
172.19.0.2:6380> object encoding key1
"embstr"
172.19.0.2:6380> append key1 ,world
(integer) 11
172.19.0.2:6380> get key1
"hello,world"
172.19.0.2:6380> object encoding key1
"raw"

2、列表

(1)列表概况

列表(list)用来存储多个有序的字符串,每个字符串称为元素;一个列表可以存储2^32-1个元素。Redis中的列表支持两端插入和弹出,并可以获得指定位置(或范围)的元素,可以充当数组、队列、栈等。

(2)列表内部编码

列表的内部编码可以是压缩列表(ziplist)或双端链表(linkedlist)。

双端链表:由一个list结构和多个listNode结构组成;典型结构如下图所示:

1174710-20180327001435577-242733744.png

图片来源:《Redis设计与实现》

通过图中可以看出,双端链表同时保存了表头指针和表尾指针,并且每个节点都有指向前和指向后的指针;链表中保存了列表的长度;dup、free和match为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。而链表中每个节点指向的是type为字符串的redisObject。

压缩列表:压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块(而不是像双端链表一样每个节点是指针)组成的顺序型数据结构;具体结构相对比较复杂,略。与双端链表相比,压缩列表可以节省内存空间,但是进行修改或增删操作时,复杂度较高;因此当节点数量较少时,可以使用压缩列表;但是节点数量多时,还是使用双端链表划算。

压缩列表不仅用于实现列表,也用于实现哈希、有序列表;使用非常广泛。

(3)列表编码转换

只有同时满足下面两个条件时,才会使用压缩列表:列表中元素数量小于512个;列表中所有字符串对象都不足64字节。如果有一个条件不满足,则使用双端列表;且编码只可能由压缩列表转化为双端链表,反方向则不可能。

其中,单个字符串不能超过64字节,是为了便于统一分配每个节点的长度;这里的64字节是指字符串的长度,不包括SDS结构,因为压缩列表使用连续、定长内存块存储字符串,不需要SDS结构指明长度。后面提到压缩列表,也会强调长度不超过64字节,原理与这里类似。

3、哈希

(1)概况

哈希(作为一种数据结构),不仅是redis对外提供的5种对象类型的一种(与字符串、列表、集合、有序结合并列),也是Redis作为Key-Value数据库所使用的数据结构。为了说明的方便,在本文后面当使用“内层的哈希”时,代表的是redis对外提供的5种对象类型的一种;使用“外层的哈希”代指Redis作为Key-Value数据库所使用的数据结构。

#####(2)内部编码

内层的哈希使用的内部编码可以是压缩列表(ziplist)和哈希表(hashtable)两种;Redis的外层的哈希则只使用了hashtable。

压缩列表前面已介绍。与哈希表相比,压缩列表用于元素个数少、元素长度小的场景;其优势在于集中存储,节省空间;同时,虽然对于元素的操作复杂度也由O(n)变为了O(1),但由于哈希中元素数量较少,因此操作的时间并没有明显劣势。

hashtable:一个hashtable由1个dict结构、2个dictht结构、1个dictEntry指针数组(称为bucket)和多个dictEntry结构组成。

正常情况下(即hashtable没有进行rehash时)各部分关系如下图所示:

1174710-20180327001627028-325473621.png

图片改编自:《Redis设计与实现》

下面从底层向上依次介绍各个部分:

** dictEntry **

dictEntry结构用于保存键值对,结构定义如下:

1
2
3
4
5
6
7
8
9
typedef struct dictEntry{
void *key;
union{
void *val;
uint64_tu64;
int64_ts64;
}v;
struct dictEntry *next;
}dictEntry;

其中,各个属性的功能如下:

key:键值对中的键;
val:键值对中的值,使用union(即共用体)实现,存储的内容既可能是一个指向值的指针,也可能是64位整型,或无符号64位整型;
next:指向下一个dictEntry,用于解决哈希冲突问题
在64位系统中,一个dictEntry对象占24字节(key/val/next各占8字节)。

** bucket**

bucket是一个数组,数组的每个元素都是指向dictEntry结构的指针。redis中bucket数组的大小计算规则如下:大于dictEntry的、最小的2^n;例如,如果有1000个dictEntry,那么bucket大小为1024;如果有1500个dictEntry,则bucket大小为2048。

** dictht**

dictht结构如下:

1
2
3
4
5
6
typedef struct dictht{
dictEntry **table;
unsigned long size;
unsigned long sizemask;
unsigned long used;
}dictht;

其中,各个属性的功能说明如下:

table属性是一个指针,指向bucket;
size属性记录了哈希表的大小,即bucket的大小;
used记录了已使用的dictEntry的数量;
sizemask属性的值总是为size-1,这个属性和哈希值一起决定一个键在table中存储的位置。

** dict**

一般来说,通过使用dictht和dictEntry结构,便可以实现普通哈希表的功能;但是Redis的实现中,在dictht结构的上层,还有一个dict结构。下面说明dict结构的定义及作用。

dict结构如下:

1
2
3
4
5
6
pedef struct dict{
dictType *type;
void *privdata;
dictht ht[2];
int trehashidx;
} dict;

其中,type属性和privdata属性是为了适应不同类型的键值对,用于创建多态字典。

ht属性和trehashidx属性则用于rehash,即当哈希表需要扩展或收缩时使用。ht是一个包含两个项的数组,每项都指向一个dictht结构,这也是Redis的哈希会有1个dict、2个dictht结构的原因。通常情况下,所有的数据都是存在放dict的ht[0]中,ht[1]只在rehash的时候使用。dict进行rehash操作的时候,将ht[0]中的所有数据rehash到ht[1]中。然后将ht[1]赋值给ht[0],并清空ht[1]。

因此,Redis中的哈希之所以在dictht和dictEntry结构之外还有一个dict结构,一方面是为了适应不同类型的键值对,另一方面是为了rehash。

(3)编码转换

如前所述,Redis中内层的哈希既可能使用哈希表,也可能使用压缩列表。

只有同时满足下面两个条件时,才会使用压缩列表:哈希中元素数量小于512个;哈希中所有键值对的键和值字符串长度都小于64字节。如果有一个条件不满足,则使用哈希表;且编码只可能由压缩列表转化为哈希表,反方向则不可能。

下图展示了Redis内层的哈希编码转换的特点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
172.19.0.2:6380> hset myhash k1 v1
(integer) 1
172.19.0.2:6380> hset myhash k2 v2
(integer) 1
172.19.0.2:6380> object encoding myhash
"ziplist"
172.19.0.2:6380> hset myhash k4 vvsdfsddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd
(integer) 1
172.19.0.2:6380> object encoding myhash
"hashtable"
172.19.0.2:6380> hdel myhash k4
(integer) 1
172.19.0.2:6380> object encoding myhash
"hashtable"
172.19.0.2:6380>

4、集合

(1)概况

集合(set)与列表类似,都是用来保存多个字符串,但集合与列表有两点不同:集合中的元素是无序的,因此不能通过索引来操作元素;集合中的元素不能有重复。

一个集合中最多可以存储2^32-1个元素;除了支持常规的增删改查,Redis还支持多个集合取交集、并集、差集。

(2)内部编码

集合的内部编码可以是整数集合(intset)或哈希表(hashtable)。

哈希表前面已经讲过,这里略过不提;需要注意的是,集合在使用哈希表时,值全部被置为null。

整数集合的结构定义如下:

1
2
3
4
5
typedef struct intset{
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;

其中,encoding代表contents中存储内容的类型,虽然contents(存储集合中的元素)是int8_t类型,但实际上其存储的值是int16_t、int32_t或int64_t,具体的类型便是由encoding决定的;length表示元素个数。

整数集合适用于集合所有元素都是整数且集合元素数量较小的时候,与哈希表相比,整数集合的优势在于集中存储,节省空间;同时,虽然对于元素的操作复杂度也由O(n)变为了O(1),但由于集合数量较少,因此操作的时间并没有明显劣势。

(3)编码转换

只有同时满足下面两个条件时,集合才会使用整数集合:集合中元素数量小于512个;集合中所有元素都是整数值。如果有一个条件不满足,则使用哈希表;且编码只可能由整数集合转化为哈希表,反方向则不可能。

5、有序集合

(1)概况

有序集合与集合一样,元素都不能重复;但与集合不同的是,有序集合中的元素是有顺序的。与列表使用索引下标作为排序依据不同,有序集合为每个元素设置一个分数(score)作为排序依据。

(2)内部编码

有序集合的内部编码可以是压缩列表(ziplist)或跳跃表(skiplist)。ziplist在列表和哈希中都有使用,前面已经讲过,这里略过不提。

跳跃表是一种有序数据结构,通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。除了跳跃表,实现有序数据结构的另一种典型实现是平衡树;大多数情况下,跳跃表的效率可以和平衡树媲美,且跳跃表实现比平衡树简单很多,因此redis中选用跳跃表代替平衡树。跳跃表支持平均O(logN)、最坏O(N)的复杂点进行节点查找,并支持顺序操作。Redis的跳跃表实现由zskiplist和zskiplistNode两个结构组成:前者用于保存跳跃表信息(如头结点、尾节点、长度等),后者用于表示跳跃表节点。具体结构相对比较复杂,略。

(3)编码转换

只有同时满足下面两个条件时,才会使用压缩列表:有序集合中元素数量小于128个;有序集合中所有成员长度都不足64字节。如果有一个条件不满足,则使用跳跃表;且编码只可能由压缩列表转化为跳跃表,反方向则不可能。

五、应用举例

1、估算Redis内存使用量

要估算redis中的数据占据的内存大小,需要对redis的内存模型有比较全面的了解,包括前面介绍的hashtable、sds、redisobject、各种对象类型的编码方式等。

下面以最简单的字符串类型来进行说明。

假设有90000个键值对,每个key的长度是7个字节,每个value的长度也是7个字节(且key和value都不是整数);下面来估算这90000个键值对所占用的空间。在估算占据空间之前,首先可以判定字符串类型使用的编码方式:embstr。

90000个键值对占据的内存空间主要可以分为两部分:一部分是90000个dictEntry占据的空间;一部分是键值对所需要的bucket空间。

每个dictEntry占据的空间包括:

  1. 一个dictEntry,24字节,jemalloc会分配32字节的内存块

  2. 一个key,7字节,所以SDS(key)需要7+9=16个字节,jemalloc会分配16字节的内存块

  3. 一个redisObject,16字节,jemalloc会分配16字节的内存块

  4. 一个value,7字节,所以SDS(value)需要7+9=16个字节,jemalloc会分配16字节的内存块

  5. 综上,一个dictEntry需要32+16+16+16=80个字节。

bucket空间:bucket数组的大小为大于90000的最小的2^n,是131072;每个bucket元素为8字节(因为64位系统中指针大小为8字节)。

因此,可以估算出这90000个键值对占据的内存大小为:9000080 + 1310728 = 8248576。

2、优化内存占用

了解redis的内存模型,对优化redis内存占用有很大帮助。下面介绍几种优化场景。

(1)利用jemalloc特性进行优化

上一小节所讲述的90000个键值便是一个例子。由于jemalloc分配内存时数值是不连续的,因此key/value字符串变化一个字节,可能会引起占用内存很大的变动;在设计时可以利用这一点。

例如,如果key的长度如果是8个字节,则SDS为17字节,jemalloc分配32字节;此时将key长度缩减为7个字节,则SDS为16字节,jemalloc分配16字节;则每个key所占用的空间都可以缩小一半。

(2)使用整型/长整型

如果是整型/长整型,Redis会使用int类型(8字节)存储来代替字符串,可以节省更多空间。因此在可以使用长整型/整型代替字符串的场景下,尽量使用长整型/整型。

(3)共享对象

利用共享对象,可以减少对象的创建(同时减少了redisObject的创建),节省内存空间。目前redis中的共享对象只包括10000个整数(0-9999);可以通过调整REDIS_SHARED_INTEGERS参数提高共享对象的个数;例如将REDIS_SHARED_INTEGERS调整到20000,则0-19999之间的对象都可以共享。

考虑这样一种场景:论坛网站在redis中存储了每个帖子的浏览数,而这些浏览数绝大多数分布在0-20000之间,这时候通过适当增大REDIS_SHARED_INTEGERS参数,便可以利用共享对象节省内存空间。

(4)避免过度设计

然而需要注意的是,不论是哪种优化场景,都要考虑内存空间与设计复杂度的权衡;而设计复杂度会影响到代码的复杂度、可维护性。

如果数据量较小,那么为了节省内存而使得代码的开发、维护变得更加困难并不划算;还是以前面讲到的90000个键值对为例,实际上节省的内存空间只有几MB。但是如果数据量有几千万甚至上亿,考虑内存的优化就比较必要了。

3、关注内存碎片率

内存碎片率是一个重要的参数,对redis 内存的优化有重要意义。

如果内存碎片率过高(jemalloc在1.03左右比较正常),说明内存碎片多,内存浪费严重;这时便可以考虑重启redis服务,在内存中对数据进行重排,减少内存碎片。

如果内存碎片率小于1,说明redis内存不足,部分数据使用了虚拟内存(即swap);由于虚拟内存的存取速度比物理内存差很多(2-3个数量级),此时redis的访问速度可能会变得很慢。因此必须设法增大物理内存(可以增加服务器节点数量,或提高单机内存),或减少redis中的数据。

要减少redis中的数据,除了选用合适的数据类型、利用共享对象等,还有一点是要设置合理的数据回收策略(maxmemory-policy),当内存达到一定量后,根据不同的优先级对内存进行回收。

分布式之数据库和缓存双写一致性方案

本文参考【原创】分布式之数据库和缓存双写一致性方案解析,做的进一步整理笔记。

三种更新策略:

1.先更新数据库,再更新缓存

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

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

(1)先更新数据库,再更新缓存

这套方案,大家是普遍反对的。为什么呢?有如下两点原因。

** 原因一(线程安全角度)**

同时有请求A和请求B进行更新操作,那么会出现

(1)线程A更新了数据库

(2)线程B更新了数据库

(3)线程B更新了缓存

(4)线程A更新了缓存

这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑。

** 原因二(业务场景角度)**

有如下两点:

(1)如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。

(2)如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。

接下来讨论的就是争议最大的,先删缓存,再更新数据库。还是先更新数据库,再删缓存的问题。

(2)先删缓存,再更新数据库

该方案会导致不一致的原因是。同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:

(1)请求A进行写操作,删除缓存

(2)请求B查询发现缓存不存在

(3)请求B去数据库查询得到旧值

(4)请求B将旧值写入缓存

(5)请求A将新值写入数据库

上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。
那么,如何解决呢?采用延时双删策略

伪代码如下

1
2
3
4
5
6
7
public void write(string key,object data)
{
redis.delKey(key);
db.updateData(data);
Thread.sleep(1000);
redis.delKey(key);
}

转化为中文描述就是

(1)先淘汰缓存

(2)再写数据库(这两步和原来一样)

(3)休眠1秒,再次淘汰缓存

这么做,可以将1秒内所造成的缓存脏数据,再次删除。

** 那么,这个1秒怎么确定的,具体该休眠多久呢?**

针对上面的情形,读者应该自行评估自己的项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

** 如果你用了mysql的读写分离架构怎么办?**

ok,在这种情况下,造成数据不一致的原因如下,还是两个请求,一个请求A进行更新操作,另一个请求B进行查询操作。

(1)请求A进行写操作,删除缓存

(2)请求A将数据写入数据库了

(3)请求B查询缓存发现,缓存没有值

(4)请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值

(5)请求B将旧值写入缓存

(6)数据库完成主从同步,从库变为新值

上述情形,就是数据不一致的原因。还是使用双删延时策略。只是,睡眠时间修改为在主从同步的延时时间基础上,加几百ms。

** 采用这种同步淘汰策略,吞吐量降低怎么办?**

ok,那就将第二次删除作为异步的。自己起一个线程,异步删除。这样,写的请求就不用沉睡一段时间后了,再返回。这么做,加大吞吐量。

** 第二次删除,如果删除失败怎么办?**

这是个非常好的问题,因为第二次删除失败,就会出现如下情形。还是有两个请求,一个请求A进行更新操作,另一个请求B进行查询操作,为了方便,假设是单库:

(1)请求A进行写操作,删除缓存

(2)请求B查询发现缓存不存在

(3)请求B去数据库查询得到旧值

(4)请求B将旧值写入缓存

(5)请求A将新值写入数据库

(6)请求A试图去删除请求B写入对缓存值,结果失败了。

ok,这也就是说。如果第二次删除缓存失败,会再次出现缓存和数据库不一致的问题。

如何解决呢?
具体解决方案,且看对第(3)种更新策略的解析。

(3)先更新数据库,再删缓存

** Cache Aside Pattern **

这是最常用最常用的pattern了。其具体逻辑如下:

** 失效**:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。

** 命中**:应用程序从cache中取数据,取到后返回。

** 更新**:先把数据存到数据库中,成功后,再让缓存失效。

注意,我们的更新是先更新数据库,成功后,让缓存失效。

** 这种情况不存在并发问题么?**

不是的。假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生

(1)缓存刚好失效

(2)请求A查询数据库,得一个旧值

(3)请求B将新值写入数据库

(4)请求B删除缓存

(5)请求A将查到的旧值写入缓存

ok,如果发生上述情况,确实是会发生脏数据。

** 然而,发生这种情况的概率又有多少呢?**

发生上述情况有一个先天性条件,就是步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。可是,大家想想,数据库的读操作的速度远快于写操作的(不然做读写分离干嘛,做读写分离的意义就是因为读操作比较快,耗资源少),因此步骤(3)耗时比步骤(2)更短,这一情形很难出现。

** 如何解决上述并发问题?**

首先,给缓存设有效时间是一种方案。其次,采用策略(2)里给出的异步延时删除策略,保证读请求完成以后,再进行删除操作。

** 还有其他造成不一致的原因么?**

有的,这也是缓存更新策略(2)和缓存更新策略(3)都存在的一个问题,如果删缓存失败了怎么办,那不是会有不一致的情况出现么。比如一个写数据请求,然后写入数据库了,删缓存失败了,这会就出现不一致的情况了。这也是缓存更新策略(2)里留下的最后一个疑问。

** 如何解决?**

提供一个保障的重试机制即可,这里给出两套方案。

** 方案一:**

流程如下所示

o_update1.png

(1)更新数据库数据;

(2)缓存因为种种问题删除失败

(3)将需要删除的key发送至消息队列

(4)自己消费消息,获得需要删除的key

(5)继续重试删除操作,直到成功

** 方案二:**

流程如下所示:

o_update2.png

(1)更新数据库数据

(2)数据库会将操作信息写入binlog日志当中

(3)订阅程序提取出所需要的数据以及key

(4)另起一段非业务代码,获得该信息

(5)尝试删除缓存操作,发现删除失败

(6)将这些信息发送至消息队列

(7)重新从消息队列中获得该数据,重试操作。

备注说明:上述的订阅binlog程序在mysql中有现成的中间件叫canal,可以完成订阅binlog日志的功能。另外,重试机制,可以采用的是消息队列的方式。如果对一致性要求不是很高,直接在程序中另起一个线程,每隔一段时间去重试即可。

参考:

【原创】分布式之数据库和缓存双写一致性方案解析

缓存更新的套路

主从DB与cache一致性

Redis详解

本文参考【原创】分布式之redis复习精讲,做的进一步整理笔记。

1、为什么使用redis

Redis 的优势:

性能极高 – Redis 能读的速度是 110000 次/s,写的速度是 81000 次/s。

丰富的数据类型 – Redis 支持二进制案例的 Strings, Lists, Hashes, Sets 及 Ordered Sets 数据类型操作。

原子 – Redis 的所有操作都是原子性的,意思就是要么成功执行要么失败完全不执行。单个操作是原子性的。多个操作也支持事务,即原子性,通过 MULTI 和 EXEC 指令包起来。

丰富的特性 – Redis 还支持 publish/subscribe, 通知, key 过期等等特性。

性能

我们在碰到需要执行耗时特别久,且结果不频繁变动的SQL,就特别适合将运行结果放入缓存。这样,后面的请求就去缓存中读取,使得请求能够迅速响应。

并发

在大并发的情况下,所有的请求直接访问数据库,数据库会出现连接异常。这个时候,就需要使用redis做一个缓冲操作,让请求先访问到redis,而不是直接访问数据库。

2、使用redis有什么缺点

分析:大家用redis这么久,这个问题是必须要了解的,基本上使用redis都会碰到一些问题,常见的也就几个。

回答:主要是四个问题

(一)缓存和数据库双写一致性问题

(二)缓存雪崩问题

(三)缓存击穿问题

(四)缓存的并发竞争问题

3、单线程的redis为什么这么快

分析:这个问题其实是对redis内部机制的一个考察。

回答:主要是以下三点

(一)纯内存操作

(二)单线程操作,避免了频繁的上下文切换

(三)采用了非阻塞I/O多路复用机制

4、redis的数据类型,以及每种数据类型的使用场景

** 一共五种 **

String

最常规的set/get操作,value可以是String也可以是数字。一般做一些复杂的计数功能的缓存。

hash

这里value存放的是结构化的对象,比较方便的就是操作其中的某个字段。在做单点登录的时候,就是用这种数据结构存储用户信息,以cookieId作为key,设置30分钟为缓存过期时间,能很好的模拟出类似session的效果。

list

使用List的数据结构,可以做简单的消息队列的功能。另外还有一个就是,可以利用lrange命令,做基于redis的分页功能,性能极佳,用户体验好。

set

因为set堆放的是一堆不重复值的集合。所以可以做全局去重的功能。

sorted set

sorted set多了一个权重参数score,集合中的元素能够按score进行排列。可以做排行榜应用,取TOP N操作

5、redis的过期策略以及内存淘汰机制

分析:这个问题其实相当重要,到底redis有没用到家,这个问题就可以看出来。比如你redis只能存5G数据,可是你写了10G,那会删5G的数据。怎么删的,这个问题思考过么?还有,你的数据已经设置了过期时间,但是时间到了,内存占用率还是比较高,有思考过原因么?

回答:
redis采用的是定期删除+惰性删除策略。

为什么不用定时删除策略

定时删除,用一个定时器来负责监视key,过期则自动删除。虽然内存及时释放,但是十分消耗CPU资源。在大并发请求下,CPU要将时间应用在处理请求,而不是删除key,因此没有采用这一策略.

定期删除+惰性删除是如何工作的呢

定期删除,redis默认每个100ms检查,是否有过期的key,有过期key则删除。需要说明的是,redis不是每个100ms将所有的key检查一次,而是随机抽取进行检查(如果每隔100ms,全部key进行检查,redis岂不是卡死)。因此,如果只采用定期删除策略,会导致很多key到时间没有删除。
于是,惰性删除派上用场。也就是说在你获取某个key的时候,redis会检查一下,这个key如果设置了过期时间那么是否过期了?如果过期了此时就会删除。

采用定期删除+惰性删除就没其他问题了么

不是的,如果定期删除没删除key。然后你也没即时去请求key,也就是说惰性删除也没生效。这样,redis的内存会越来越高。那么就应该采用内存淘汰机制。
在redis.conf中有一行配置

1
# maxmemory-policy volatile-lru

该配置就是配内存淘汰策略的

1)noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。** 不推荐**

2)allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key。** 推荐使用。**

3)allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个key。** 不推荐。**

4)volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的key。这种情况一般是把redis既当缓存,又做持久化存储的时候才用。** 不推荐 **

5)volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个key。** 不推荐**

6)volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的key优先移除。** 不推荐**

ps:如果没有设置 expire 的key, 不满足先决条件(prerequisites); 那么 volatile-lru, volatile-random 和 volatile-ttl 策略的行为, 和 noeviction(不删除) 基本上一致。

6、redis和数据库双写一致性问题

分析:一致性问题是分布式常见问题,还可以再分为最终一致性和强一致性。数据库和缓存双写,就必然会存在不一致的问题。答这个问题,先明白一个前提。就是如果对数据有强一致性要求,不能放缓存。我们所做的一切,只能保证最终一致性。另外,我们所做的方案其实从根本上来说,只能说降低不一致发生的概率,无法完全避免。因此,有强一致性要求的数据,不能放缓存。

回答: 参考 《分布式之数据库和缓存双写一致性方案解析》首先,采取正确更新策略,先更新数据库,再删缓存。其次,因为可能存在删除缓存失败的问题,提供一个补偿措施即可,例如利用消息队列。

7、如何应对缓存穿透和缓存雪崩问题

分析:一般中小型传统软件企业,很难碰到这个问题。如果有大并发的项目,流量有几百万左右。这两个问题一定要深刻考虑。

回答:如下所示

缓存穿透

即黑客故意去请求缓存中不存在的数据,导致所有的请求都怼到数据库上,从而数据库连接异常。

** 解决方案:**

(一)利用互斥锁,缓存失效的时候,先去获得锁,得到锁了,再去请求数据库。没得到锁,则休眠一段时间重试

(二)采用异步更新策略,无论key是否取到值,都直接返回。value值中维护一个缓存失效时间,缓存如果过期,异步起一个线程去读数据库,更新缓存。需要做缓存预热(项目启动前,先加载缓存)操作。

(三)提供一个能迅速判断请求是否有效的拦截机制,比如,利用布隆过滤器,内部维护一系列合法有效的key。迅速判断出,请求所携带的Key是否合法有效。如果不合法,则直接返回。

缓存雪崩

即缓存同一时间大面积的失效,这个时候又来了一波请求,结果请求都怼到数据库上,从而导致数据库连接异常。

解决方案:

(一)给缓存的失效时间,加上一个随机值,避免集体失效。

(二)使用互斥锁,但是该方案吞吐量明显下降了。

(三)双缓存。我们有两个缓存,缓存A和缓存B。缓存A的失效时间为20分钟,缓存B不设失效时间。自己做缓存预热操作。然后细分以下几个小点

I 从缓存A读数据库,有则直接返回

II A没有数据,直接从B读数据,直接返回,并且异步启动一个更新线程。

III 更新线程同时更新缓存A和缓存B。

8、如何解决redis的并发竞争问题

分析:同时有多个子系统去set一个key。s不推荐使用redis的事务机制。因为生产环境,基本都是redis集群环境,做了数据分片操作。你一个事务中有涉及到多个key操作的时候,这多个key不一定都存储在同一个redis-server上。因此,redis的事务机制,十分鸡肋。

回答:如下所示

(1)如果对这个key操作,不要求顺序

这种情况下,准备一个分布式锁,大家去抢锁,抢到锁就做set操作即可,比较简单。

(2)如果对这个key操作,要求顺序

假设有一个key1,系统A需要将key1设置为valueA,系统B需要将key1设置为valueB,系统C需要将key1设置为valueC.

期望按照key1的value值按照 valueA–>valueB–>valueC的顺序变化。这种时候我们在数据写入数据库的时候,需要保存一个时间戳。假设时间戳如下

1
2
3
系统A key 1 {valueA  3:00}
系统B key 1 {valueB 3:05}
系统C key 1 {valueC 3:10}

那么,假设这会系统B先抢到锁,将key1设置为{valueB 3:05}。接下来系统A抢到锁,发现自己的valueA的时间戳早于缓存中的时间戳,那就不做set操作了。以此类推。

其他方法,比如利用队列,将set方法变成串行访问也可以。总之,灵活变通。

参考:

【原创】分布式之redis复习精讲

redis持久化

Redis虽然是一种内存型数据库,一旦服务器进程退出,数据库的数据就会丢失,为了解决这个问题Redis提供了两种持久化的方案,将内存中的数据保存到磁盘中,避免数据的丢失。

AOF与RDB对比

** AOF **更安全,可将数据及时同步到文件中,但需要较多的磁盘IO,AOF文件尺寸较大,文件内容恢复相对较慢, 也更完整。

** RDB **持久化,安全性较差,它是正常时期数据备份及 master-slave数据同步的最佳手段,文件尺寸较小,恢复数度较快。

RDB持久化

redis提供了RDB持久化的功能,这个功能可以将redis在内存中的的状态保存到硬盘中,它可以手动执行,也可以再redis.conf中配置,定期执行。

RDB持久化产生的RDB文件是一个经过压缩的二进制文件,这个文件被保存在硬盘中,redis可以通过这个文件还原数据库当时的状态。

RDB的创建与载入

RDB文件可以通过两个命令来生成:

SAVE:阻塞redis的服务器进程,直到RDB文件被创建完毕。
BGSAVE:派生(fork)一个子进程来创建新的RDB文件,记录接收到BGSAVE当时的数据库状态,父进程继续处理接收到的命令,子进程完成文件的创建之后,会发送信号给父进程,而与此同时,父进程处理命令的同时,通过轮询来接收子进程的信号。
而RDB文件的载入一般情况是自动的,redis服务器启动的时候,redis服务器再启动的时候如果检测到RDB文件的存在,那么redis会自动载入这个文件。

如果服务器开启了AOF持久化,那么服务器会优先使用AOF文件来还原数据库状态。

RDB是通过保存键值对来记录数据库状态的,采用copy on write的模式,每次都是全量的备份。

自动保存间隔

BGSAVE可以在不阻塞主进程的情况下完成数据的备份。可以通过redis.conf中设置多个自动保存条件,只要有一个条件被满足,服务器就会执行BGSAVE命令。

相关配置

1
2
3
4
5
6
7
# 以下配置表示的条件:
# 服务器在900秒之内被修改了1次
save 900 1
# 服务器在300秒之内被修改了10次
save 300 10
# 服务器在60秒之内被修改了10000次
save 60 10000

AOF持久化

AOF持久化(Append-Only-File),与RDB持久化不同,AOF持久化是通过保存Redis服务器锁执行的写状态来记录数据库的

具体来说,RDB持久化相当于备份数据库状态,而AOF持久化是备份数据库接收到的命令,所有被写入AOF的命令都是以redis的协议格式来保存的。

在AOF持久化的文件中,数据库会记录下所有变更数据库状态的命令,除了指定数据库的select命令,其他的命令都是来自client的,这些命令会以追加(append)的形式保存到文件中。

** appendfsync** 影响服务器多久完成一次命令的记录

always:将缓存区的内容总是即时写到AOF文件中。

everysec:将缓存区的内容每隔一秒写入AOF文件中。

no :写入AOF文件中的操作由操作系统决定,一般而言为了提高效率,操作系统会等待缓存区被填满,才会开始同步数据到磁盘

redis默认everysec

redis在载入AOF文件的时候,会创建一个虚拟的client,把AOF中每一条命令都执行一遍,最终还原回数据库的状态,它的载入也是自动的。在RDB和AOF备份文件都有的情况下,redis会优先载入AOF备份文件

AOF文件可能会随着服务器运行的时间越来越大,可以利用AOF重写的功能,来控制AOF文件的大小。AOF重写功能会首先读取数据库中现有的键值对状态,然后根据类型使用一条命令来替代前的键值对多条命令。

AOF重写功能有大量写入操作,所以redis才用子进程来处理AOF重写。这里带来一个新的问题,由于处理重新的是子进程,这样意味着如果主线程的数据在此时被修改,备份的数据和主库的数据将会有不一致的情况发生。因此redis还设置了一个AOF重写缓冲区,这个缓冲区在子进程被创建开始之后开始使用,这个期间,所有的命令会被存两份,一份在AOF缓存空间,一份在AOF重写缓冲区,当AOF重写完成之后,子进程发送信号给主进程,通知主进程将AOF重写缓冲区的内容添加到AOF文件中。

相关配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#AOF 和 RDB 持久化方式可以同时启动并且无冲突。
#如果AOF开启,启动redis时会加载aof文件,这些文件能够提供更好的保证。
appendonly yes

# 只增文件的文件名称。(默认是appendonly.aof)
# appendfilename appendonly.aof
#redis支持三种不同的写入方式:
#
# no:不调用,之等待操作系统来清空缓冲区当操作系统要输出数据时。很快。
# always: 每次更新数据都写入仅增日志文件。慢,但是最安全。
# everysec: 每秒调用一次。折中。
appendfsync everysec

# 设置为yes表示rewrite期间对新写操作不fsync,暂时存在内存中,等rewrite完成后再写入.官方文档建议如果你有特殊的情况可以配置为'yes'。但是配置为'no'是最为安全的选择。
no-appendfsync-on-rewrite no

# 自动重写只增文件。
# redis可以自动盲从的调用‘BGREWRITEAOF’来重写日志文件,如果日志文件增长了指定的百分比。
# 当前AOF文件大小是上次日志重写得到AOF文件大小的二倍时,自动启动新的日志重写过程。
auto-aof-rewrite-percentage 100
# 当前AOF文件启动新的日志重写过程的最小值,避免刚刚启动Reids时由于文件尺寸较小导致频繁的重写。
auto-aof-rewrite-min-size 64mb

目录结构

1
2
3
4
5
test1
|--data
|--docker-compose.yml
|--Dockerfile
|--redis.conf

新建redis.conf

具体配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# 使用守护进程模式
daemonize no
# 非保护模式,可以外网访问
protected-mode no
timeout 300
databases 16
rdbcompression yes
# 学习开发,使用最大日志级别,能够看到最多的日志信息
loglevel debug
port 6380
##授权密码,各个配置保持一致
##暂且禁用指令重命名
##rename-command
##开启AOF,禁用snapshot
slave-read-only yes
maxclients 10000
maxmemory 1000mb

#AOF 和 RDB 持久化方式可以同时启动并且无冲突。
#如果AOF开启,启动redis时会加载aof文件,这些文件能够提供更好的保证。
appendonly yes

# 只增文件的文件名称。(默认是appendonly.aof)
# appendfilename appendonly.aof
#redis支持三种不同的写入方式:

# no:不调用,之等待操作系统来清空缓冲区当操作系统要输出数据时。很快。
# always: 每次更新数据都写入仅增日志文件。慢,但是最安全。
# everysec: 每秒调用一次。折中。
appendfsync everysec

# 设置为yes表示rewrite期间对新写操作不fsync,暂时存在内存中,等rewrite完成后再写入.官方文档建议如果你有特殊的情况可以配置为'yes'。但是配置为'no'是最为安全的选择。
no-appendfsync-on-rewrite no

# 自动重写只增文件。
# redis可以自动盲从的调用‘BGREWRITEAOF’来重写日志文件,如果日志文件增长了指定的百分比。
# 当前AOF文件大小是上次日志重写得到AOF文件大小的二倍时,自动启动新的日志重写过程。
auto-aof-rewrite-percentage 100
# 当前AOF文件启动新的日志重写过程的最小值,避免刚刚启动Reids时由于文件尺寸较小导致频繁的重写。
auto-aof-rewrite-min-size 64mb

新建Dockerfile

1
2
3
4
5
6
7
8
9
10
FROM redis:4-alpine

#设置时区
RUN apk add --update tzdata
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone


COPY redis.conf /conf/redis.conf
RUN chown redis:redis /conf/*

** 生成镜像**

1
docker build -t redis:v2 .

新建docker-compose.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
version: '3'

services:
redis-master:
container_name: redis_server
volumes:
- $PWD/data:/data
image: redis:v2
command: ["redis-server", "/conf/redis.conf"]
ports:
- "6380:6380"
expose:
- "6380"

** 执行**

1
docker-compose up -d

验证

在data目录查看是否有.aof文件生成

1
2
3
[root@localhost redistest1]# cd data
[root@localhost data]# ls
appendonly.aof

获取容器IP

1
2
docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' redis_server
172.19.0.2

连接Redis

1
docker exec -it redis_server redis-cli -h 172.19.0.2 -p 6380

参考:

Redis 持久化

深入学习Redis(2):持久化

Docker修改时区

修改Dockerfile

** CentOS7**

1
2
3
RUN apk add --update tzdata
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
1
2
3
4
5
6
#update system timezone
#RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime

#update application timezone
RUN echo "Asia/Shanghai" >> /etc/timezone