开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

对mysql乐观锁、悲观锁、共享锁、排它锁、行锁、表锁概念的

发表于 2018-07-27

记得在上大学那会开始,在大学的课堂上,常常会听到老师讲什么共享锁,排它锁各种锁的词汇,以前仅仅听过一次就没有管了,并没有进行深入的研究

最近,在各种群里,又看见了什么乐观锁、悲观锁什么鬼的感觉很高级的词汇,于是乎今天对这几个概念进行学习,揭开它神秘的面纱,缕缕思路记录下我对这几个概念的想法

实验环境:

mysql5.6

存储引擎:innoDB

我们在操作数据库的时候,可能会由于并发问题而引起的数据的不一致性(数据冲突)

乐观锁

乐观锁不是数据库自带的,需要我们自己去实现。乐观锁是指操作数据库时(更新操作),想法很乐观,认为这次的操作不会导致冲突,在操作数据时,并不进行任何其他的特殊处理(也就是不加锁),而在进行更新后,再去判断是否有冲突了。

通常实现是这样的:在表中的数据进行操作时(更新),先给数据表加一个版本(version)字段,每操作一次,将那条记录的版本号加1。也就是先查询出那条记录,获取出version字段,如果要对那条记录进行操作(更新),则先判断此刻version的值是否与刚刚查询出来时的version的值相等,如果相等,则说明这段期间,没有其他程序对其进行操作,则可以执行更新,将version字段的值加1;如果更新时发现此刻的version值与刚刚获取出来的version的值不相等,则说明这段期间已经有其他程序对其进行操作了,则不进行更新操作。

举例:

下单操作包括3步骤:

1.查询出商品信息

select (status,status,version) from t_goods where id=#{id}

2.根据商品信息生成订单

3.修改商品status为2

update t_goods

set status=2,version=version+1

where id=#{id} and version=#{version};

除了自己手动实现乐观锁之外,现在网上许多框架已经封装好了乐观锁的实现,如hibernate,需要时,可能自行搜索”hiberate 乐观锁”试试看。

悲观锁

与乐观锁相对应的就是悲观锁了。悲观锁就是在操作数据时,认为此操作会出现数据冲突,所以在进行每次操作时都要通过获取锁才能进行对相同数据的操作,这点跟java中的synchronized很相似,所以悲观锁需要耗费较多的时间。另外与乐观锁相对应的,悲观锁是由数据库自己实现了的,要用的时候,我们直接调用数据库的相关语句就可以了。

说到这里,由悲观锁涉及到的另外两个锁概念就出来了,它们就是共享锁与排它锁。共享锁和排它锁是悲观锁的不同的实现,它俩都属于悲观锁的范畴。

共享锁

共享锁指的就是对于多个不同的事务,对同一个资源共享同一个锁。相当于对于同一把门,它拥有多个钥匙一样。就像这样,你家有一个大门,大门的钥匙有好几把,你有一把,你女朋友有一把,你们都可能通过这把钥匙进入你们家,进去啪啪啪啥的,一下理解了哈,没错,这个就是所谓的共享锁。 刚刚说了,对于悲观锁,一般数据库已经实现了,共享锁也属于悲观锁的一种,那么共享锁在mysql中是通过什么命令来调用呢。通过查询资料,了解到通过在执行语句后面加上lock in share mode就代表对某些资源加上共享锁了。
比如,我这里通过mysql打开两个查询编辑器,在其中开启一个事务,并不执行commit语句 city表DDL如下:

1
2
3
4
5
6
复制代码CREATE TABLE `city` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`state` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=18 DEFAULT CHARSET=utf8;


begin;
SELECT * from city where id = “1” lock in share mode;

然后在另一个查询窗口中,对id为1的数据进行更新

update city set name=”666” where id =”1”;
此时,操作界面进入了卡顿状态,过几秒后,也提示错误信息 [SQL]update city set name=”666” where id =”1”;
[Err] 1205 - Lock wait timeout exceeded; try restarting transaction

那么证明,对于id=1的记录加锁成功了,在上一条记录还没有commit之前,这条id=1的记录被锁住了,只有在上一个事务释放掉锁后才能进行操作,或用共享锁才能对此数据进行操作。 再实验一下:

update city set name=”666”
where id =”1” lock in share mode;
[Err] 1064 - You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ‘lock in share mode’ at line 1

加上共享锁后,也提示错误信息了,通过查询资料才知道,对于update,insert,delete语句会自动加排它锁的原因

于是,我又试了试SELECT * from city where id = “1” lock in share mode;

这下成功了。

排它锁

排它锁与共享锁相对应,就是指对于多个不同的事务,对同一个资源只能有一把锁。 与共享锁类型,在需要执行的语句后面加上for update就可以了
行锁
–

行锁,由字面意思理解,就是给某一行加上锁,也就是一条记录加上锁。

比如之前演示的共享锁语句

SELECT * from city where id = “1” lock in share mode;

由于对于city表中,id字段为主键,就也相当于索引。执行加锁时,会将id这个索引为1的记录加上锁,那么这个锁就是行锁。

表锁

表锁,和行锁相对应,给这个表加上锁。

MyISAM引擎里有的,暂时研究了

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

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

发表于 2018-07-27

引言

为什么写这篇文章?

首先,缓存由于其高并发和高性能的特性,已经在项目中被广泛使用。在读取缓存方面,大家没啥疑问,都是按照下图的流程来进行业务操作。
image
但是在更新缓存方面,对于更新完数据库,是更新缓存呢,还是删除缓存。又或者是先删除缓存,再更新数据库,其实大家存在很大的争议。目前没有一篇全面的博客,对这几种方案进行解析。于是博主战战兢兢,顶着被大家喷的风险,写了这篇文章。

文章结构

本文由以下三个部分组成
1、讲解缓存更新策略
2、对每种策略进行缺点分析
3、针对缺点给出改进方案

正文

先做一个说明,从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案。这种方案下,我们可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存。因此,接下来讨论的思路不依赖于给缓存设置过期时间这个方案。
在这里,我们讨论三种更新策略:

  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
复制代码`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》。其中就指出

  • 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
  • 命中:应用程序从cache中取数据,取到后返回。
  • 更新:先把数据存到数据库中,成功后,再让缓存失效。

另外,知名社交网站facebook也在论文《Scaling Memcache at Facebook》中提出,他们用的也是先更新数据库,再删缓存的策略。
这种情况不存在并发问题么?
不是的。假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生
(1)缓存刚好失效
(2)请求A查询数据库,得一个旧值
(3)请求B将新值写入数据库
(4)请求B删除缓存
(5)请求A将查到的旧值写入缓存
ok,如果发生上述情况,确实是会发生脏数据。
然而,发生这种情况的概率又有多少呢?
发生上述情况有一个先天性条件,就是步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。可是,大家想想,数据库的读操作的速度远快于写操作的(不然做读写分离干嘛,做读写分离的意义就是因为读操作比较快,耗资源少),因此步骤(3)耗时比步骤(2)更短,这一情形很难出现。
假设,有人非要抬杠,有强迫症,一定要解决怎么办?
如何解决上述并发问题?
首先,给缓存设有效时间是一种方案。其次,采用策略(2)里给出的异步延时删除策略,保证读请求完成以后,再进行删除操作。
还有其他造成不一致的原因么?
有的,这也是缓存更新策略(2)和缓存更新策略(3)都存在的一个问题,如果删缓存失败了怎么办,那不是会有不一致的情况出现么。比如一个写数据请求,然后写入数据库了,删缓存失败了,这会就出现不一致的情况了。这也是缓存更新策略(2)里留下的最后一个疑问。
如何解决?
提供一个保障的重试机制即可,这里给出两套方案。
方案一:
如下图所示
image
流程如下所示
(1)更新数据库数据;
(2)缓存因为种种问题删除失败
(3)将需要删除的key发送至消息队列
(4)自己消费消息,获得需要删除的key
(5)继续重试删除操作,直到成功
然而,该方案有一个缺点,对业务线代码造成大量的侵入。于是有了方案二,在方案二中,启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。
方案二:
image
流程如下图所示:
(1)更新数据库数据
(2)数据库会将操作信息写入binlog日志当中
(3)订阅程序提取出所需要的数据以及key
(4)另起一段非业务代码,获得该信息
(5)尝试删除缓存操作,发现删除失败
(6)将这些信息发送至消息队列
(7)重新从消息队列中获得该数据,重试操作。

备注说明:上述的
订阅binlog程序在mysql中有现成的中间件叫canal,可以完成订阅binlog日志的功能。至于oracle中,博主目前不知道有没有现成中间件可以使用。另外,重试机制,博主是采用的是消息队列的方式。如果对一致性要求不是很高,直接在程序中另起一个线程,每隔一段时间去重试即可,这些大家可以灵活自由发挥,只是提供一个思路。

总结

本文其实是对目前互联网中已有的一致性方案,进行了一个总结。对于先删缓存,再更新数据库的更新策略,还有方案提出维护一个内存队列的方式,博主看了一下,觉得实现异常复杂,没有必要,因此没有必要在文中给出。最后,希望大家有所收获。

参考文献

1、主从DB与cache一致性
2、缓存更新的套路

作者:孤独烟 出处:http://rjzheng.cnblogs.com/ 本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。如果觉得还有帮助的话,可以点一下右下角的【推荐】。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

一文读懂一台计算机是如何把数据传送给另外一台计算机的 正文

发表于 2018-07-27

正文

互联网相隔n公里路的两台计算机,是如何进行数据的传送的呢?在成千上万台的计算机中,一台计算机是如何正确着找到另外一个计算机,并把数据传给它的呢?

学过计算机网络的同学可能知道,在这互联网中,计算机与计算机之间的数据传送,主要是基于各种“协议”串联起来的。不过今天要讲的,并不会详细去讲各种协议,而是通过各种简化之后,让你大概知道数据之间传送的原理。

模型

互联网中数据的传送,其实分为好几层来处理数据的,每一层有它自己明确的功能。例如就像流水线生产一样,一部分人负责这部分的工作,处理完之后就把剩余的工作扔给另外一部分人来处理……

对于互联网数据传送的分层模型,有分成七层的,有分成5层的,还有分成4层的。例如分成七层模型的如下(从上到下):

  • 应用层
  • 表示层
  • 会话层
  • 传输层
  • 网络层
  • 数据链路层
  • 物理层

七层中,越往下越靠近计算机底层,越往上越靠近用户。

不过,我们今天要讲的,是以分成五层的模型来讲。其分层如下图:

相当于把应用层、表示层、会话层看成是一层的。接下来我们从下往上来一步一步讲,看看如何从一台计算机准确着传给另一台计算机的。

一. 物理层

一台计算机与另一台计算机要进行通信,第一件要做的事是什么?当然是要把这台计算机与另外的其他计算机连起来啊,例如可以通过光纤啊,电缆啊,双绞线啊等物体把他们联起来。然后才能进行通信,也就是说,,物理层负责把两台计算机连起来,然后在计算机之间传送0,1这样的电信号。

二. 数据链路层

前面说了,物理层它只是单纯着负责在计算机之间传输0,1这样的电信号。假如这些0,1组合的传送毫无规则,计算机是解读不了的。因此,我们需要制定一套规则来进行0,1的传送。例如多少个电信号为一组啊,每一组信号应该如何标识才能让计算机读懂啊等。

数据链路层工作在物理层之上,负责给这些0,1制定传送的规则,然后另一方再按照相应的规则来进行解读。

1. 以太网协议

以太网协议规定,一组电信号构成一个数据包,把这个数据包称之为“桢”。每一个桢由标头(Head)和数据(Data)两部分组成。如下:

这个桢的最大长度是1518个字节,最小长度为64字节。假如需要传送的数据很大的话,就分成多个桢来进行传送。

对于表头和数据这两个部分,他们存放的都是一些什么数据呢?我猜你眯着眼睛都能想到他们应该放什么数据。 毫无疑问,我们至少得知道这个桢是谁发送,发送给谁的等这些信息吧?所以标头部分主要是一些说明数据,例如发送者,接收者等信息。而数据部分则是这个数据包具体的,想给接受的内容。

大家想一个问题,一个桢的长度是64~1518个字节,也就是说桢的长度不是固定的,那你觉得标头部分的字节长度是固定的吗?它当然是固定的啊,假如不是固定的,每个桢都是单独发的,那计算机怎么知道标头是几个字节,数据是几个字节。所以标头部分的字节是固定的,并且固定为18个字节。

2. MAC地址

把一台计算的的数据通过物理层和链路层发送给另一台计算机,究竟是谁发给谁的,计算机与计算机之间如何区分,,你总得给他们一个唯一的标识吧?

这就是MAC地址,连入网络的每一个计算机都会有网卡接口,每一个网卡都会一个地址,这个地址就叫做MAC地址。计算机之间的数据传送,就是通过MAC地址来唯一寻找、传送的。MAC地址在网卡生产是就被唯一标识了。

广播与ARP协议

如图,假如计算机A知道了计算机B的MAC地址,然后计算机A想要给计算机B传送数据,虽然计算机A知道了计算机B的MAC地址,可是它要怎么给它传送数据呢?计算机A不仅连着计算机B,而且计算机A也还连着其他的计算机。 虽然计算机A知道计算机B的MAC地址,可是计算机A是无法知道计算机B是分布在哪边路线上的。实际上,计算机A是通过广播的方式把数据发送给计算机B。在同一个子网中,计算机A要向计算机B发送一个数据包,这个数据包包含接收者的MAC地址。这个时候同一个子网中的计算机C,D也会收到这个数据包的,然后收到这个数据包的计算机,会把数据包的MAC地址取出来,与自身的MAC地址对比,如果两者相同,则接受这个数据包,否则就丢弃这个数据包。这种发送方式我们称之为广播,就像我们平时在广场上通过广播的形式呼叫某个人一样。

那么问题来了,计算机A是如何知道计算机B的MAC地址的呢?这个时候就得由ARP协议这个家伙来解决了,不过ARP协议会涉及到IP地址,不过我们下面才会扯到IP地址。因此我们先放着,就当作是有这么一个ARP协议,通过它我们可以知道子网中其他计算机的MAC地址。

三. 网络层

上面我们有说到子网这个关键词,实际上我们所处的网络,是由无数个子网络构成的。广播的时候,也只有同一个子网里面的计算机能够收到。 假如没有子网这种划分的话,计算机A发一个数据包给计算机B,其他所有计算机也都能收到这个数据包,然后进行对比再舍弃。世界上有那么多它计算机,每一台计算机都能收到其他所有计算机的数据包,那就不得了了。那还不得奔溃。 因此产生了子网这么一个东西。

那么问题来了,我们如何区分哪些MAC地址是属于同一个子网的呢?假如是同一个子网,那我们就用广播的形式把数据传送给对方,如果不是同一个子网的,我们就会把数据发给网关,让网关进行转发。

为了解决这个问题我们引入了一套新的地址协议,这个地址协议能够帮助我们区分MAC地址是否处于同一个子网中。这也是网络层负责解决的问题。

1. IP协议

这个协议就是IP协议,它所定义的地址,我们称之为IP地址。IP协议有两种版本,一种是IPv4,另一种是IPv6。不过我们目前大多数用的还是IPv4,我们现在也只讨论IPv4这个版本的协议。

这个IP地址由32为的二进制数组成,我们一般把它分成4段的十进制表示,地址范围为0.0.0.0~255.255.255.255

每一台想要联网的计算机都会有一个IP地址。这个IP地址被分为两部分,前面一部分代表网络部分,后面一部分代表主机部分。并且网络部分和主机部分的二进制位数是不固定的。

假如两台计算机的网络部分是一模一样的,我们就说这两台计算机是处于同一个子网中。例如192.168.43.1和192.168.43.2,假如这两个IP地址的网络部分为24为,主机部分为8位。那么他们的网络部分都为192.168.43,所以他们处于同一个子网中。

可是问题来了,你怎么知道网络部分是占几位。也就是说,单单从两台计算机的IP地址,我们是无法判断他们的是否处于同一个子网中的。

这就引申出了另一个关键词————子码掩码。子码掩码和IP地址一样也是32位二进制数,不过它的网络部分规定全部为1,主机部分规定全部为0.也就是说,假如上面那两个IP地址的网络部分为24为,主机部分为8为的话,那他们的子码掩码都为11111111.11111111.11111111.00000000,即255.255.255.0。

那有了子字码掩码,如何来判端IP地址是否处于同一个子网中呢。显然,知道了子码掩码,相当于我们知道了网络部分是几位,主机部分是几位。我们只需要把IP地址与它的子码掩码做与(and)运算,然后把各自的结果进行比较就行了,如果比较的结果相同,则代表是同一个子网,否则不是同一个子网。

例如,192.168.43.1和192.168.43.2的子码掩码都为255.255.255.0,把IP与子码掩码相与,可以得到他们都为192.168.43.0,进而他们处于同一个子网中。

ARP协议

有了上面IP协议的知识,我们回来讲一下ARP协议。
有了两台计算机的IP地址,我们就可以判断出它们是否处于同一个子网之中。 假如他们处于同一个子网之中,计算机A要给计算机B发送数据时。我们可以通过ARP协议来得到计算机B的MAC地址。ARP协议也是通过广播的形式给同一个子网中的每台电脑发送一个数据包(当然,这个数据包会包含接收方的IP地址)。对方收到这个数据包之后,会取出IP地址与自身的对比,如果相同,则把自己的MAC地址回复给对方,否则就丢弃这个数据包。这样,计算机A就能知道计算机B的MAC地址了。

可能有人会问,知道了MAC地址之后,发送数据是通过广播的形式发送,询问对方的MAC地址也是通过广播的形式来发送,那其他计算机怎么知道你是要传送数据还是要询问MAC地址呢?其实在询问MAC地址的数据包中,在对方的MAC地址这一栏中,填的是一个特殊的MAC地址,其他计算机看到这个特殊的MAC地址之后,就能知道广播想干嘛了。

假如两台计算机的IP不是处于同一个子网之中,这个时候,我们就会把数据包发送给网关,然后让网关让我们进行转发传送

DNS服务器

这里再说一个问题,我们是如何知道对方计算机的IP地址的呢?这个问题可能有人会觉得很白痴,心想,当然是计算机的操作者来进行输入了。这没错,当我们想要访问某个网站的时候,我们可以输入IP来进行访问,但是我相信绝大多数人是输入一个网址域名的,例如访问百度是输入www.baidu.com这个域名。其实当我们输入这个域名时,会有一个叫做**DNS服务器**的家伙来帮我们解析这个域名,然后返回这个域名对应的IP给我们的。

四. 传输层

虽然我们已经把数据成功从计算机A传送到计算机B了,可是,计算机B里面有各种各样的应用程序,计算机该如何知道这些数据是给谁的呢?

这个时候,端口(Port)这个家伙就上场了,也就是说,我们在从计算机A传数据给计算表B的时候,还得指定一个端口,以供特定的应用程序来接受处理。
也就是说,传输层的功能就是建立端口到端口的通信。相比网络层的功能是建立主机到主机的通信。

也就是说,有了IP和端口,我们就可以进行通信了。这个时候可能有人会说,我输入IP地址的时候并没有指定一个端口啊。其实呢,对于有些传输协议,已经有设定了一些默认端口了。例如http的传输默认端口是80,这些端口信息也会包含在数据包里的。

应用层

终于说到应用层了,应用层这一层最接近我们用户了。

虽然我们收到了传输层传来的数据,可是这些传过来的数据五花八门,有html格式的,有mp4格式的,各种各样。你确定你能看的懂?

因此我们需要指定这些数据的格式规则,收到后才好解读渲染。而应用层的功能,就是用来规定应用程序的数据格式的。

五层模型至此讲到这里。对于有些层讲的比较简洁,就随便概况了一下。如果你想详细去了解,可以去买计算机网络相应的资料。希望我的讲解能让你对计算机之间数据的传输有个大概的了解。

:

​推荐阅读:https://juejin.cn/post/6844903648552550413(一文看懂https是如何保证数据传输的安全性的)

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

面试必问之JVM原理

发表于 2018-07-26

1:什么是JVM

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java虚拟机包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收堆和一个存储方法域。 JVM屏蔽了与具体操作系统平台相关的信息,使Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。


2:JRE/JDK/JVM是什么关系

JRE(JavaRuntimeEnvironment,Java运行环境),也就是Java平台。所有的Java 程序都要在JRE下才能运行。普通用户只需要运行已开发好的java程序,安装JRE即可。

JDK(Java Development Kit)是程序开发者用来来编译、调试java程序用的开发工具包。JDK的工具也是Java程序,也需要JRE才能运行。为了保持JDK的独立性和完整性,在JDK的安装过程中,JRE也是 安装的一部分。所以,在JDK的安装目录下有一个名为jre的目录,用于存放JRE文件。

JVM(JavaVirtualMachine,Java虚拟机)是JRE的一部分。它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。JVM有自己完善的硬件架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。Java语言最重要的特点就是跨平台运行。使用JVM就是为了支持与操作系统无关,实现跨平台。


3:JVM原理

JVM是java的核心和基础,在java编译器和os平台之间的虚拟处理器。它是一种利用软件方法实现的抽象的计算机基于下层的操作系统和硬件平台,可以在上面执行java的字节码程序。

java编译器只要面向JVM,生成JVM能理解的代码或字节码文件。Java源文件经编译成字节码程序,通过JVM将每一条指令翻译成不同平台机器码,通过特定平台运行。


4:JVM的体系结构

类装载器(ClassLoader)(用来装载.class文件)

执行引擎(执行字节码,或者执行本地方法)

运行时数据区(方法区、堆、java栈、PC寄存器、本地方法栈)


5:JVM运行时数据区

第一块:PC寄存器

PC寄存器是用于存储每个线程下一步将执行的JVM指令,如该方法为native的,则PC寄存器中不存储任何信息。

第二块:JVM栈

JVM栈是线程私有的,每个线程创建的同时都会创建JVM栈,JVM栈中存放的为当前线程中局部基本类型的变量(java中定义的八种基本类型:boolean、char、byte、short、int、long、float、double)、部分的返回结果以及Stack Frame,非基本类型的对象在JVM栈上仅存放一个指向堆上的地址。

第三块:堆(Heap)

它是JVM用来存储对象实例以及数组值的区域,可以认为Java中所有通过new创建的对象的内存都在此分配,Heap中的对象的内存需要等待GC进行回收。

(1) 堆是JVM中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了new对象的开销是比较大的

(2) Sun Hotspot JVM为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间TLAB(Thread Local Allocation Buffer),其大小由JVM根据运行的情况计算而得,在TLAB上分配对象时不需要加锁,因此JVM在给线程的对象分配内存时会尽量的在TLAB上分配,在这种情况下JVM中分配对象内存的性能和C基本是一样高效的,但如果对象过大的话则仍然是直接使用堆空间分配

(3) TLAB仅作用于新生代的Eden Space,因此在编写Java程序时,通常多个小的对象比大的对象分配起来更加高效。

(4) 所有新创建的Object 都将会存储在新生代Yong Generation中。如果Young Generation的数据在一次或多次GC后存活下来,那么将被转移到OldGeneration。新的Object总是创建在Eden Space。

第四块:方法区域(Method Area)

(1)在Sun JDK中这块区域对应的为PermanetGeneration,又称为持久代。

(2)方法区域存放了所加载的类的信息(名称、修饰符等)、类中的静态变量、类中定义为final类型的常量、类中的Field信息、类中的方法信息,当开发人员在程序中通过Class对象中的getName、isInterface等方法来获取信息时,这些数据都来源于方法区域,同时方法区域也是全局共享的,在一定的条件下它也会被GC,当方法区域需要使用的内存超过其允许的大小时,会抛出OutOfMemory的错误信息。

第五块:运行时常量池(Runtime Constant Pool)

存放的为类中的固定的常量信息、方法和Field的引用信息等,其空间从方法区域中分配。

第六块:本地方法堆栈(Native Method Stacks)

JVM采用本地方法堆栈来支持native方法的执行,此区域用于存储每个native方法调用的状态。


6:对象“已死”的判定算法

由于程序计数器、Java虚拟机栈、本地方法栈都是线程独享,其占用的内存也是随线程生而生、随线程结束而回收。而Java堆和方法区则不同,线程共享,是GC的所关注的部分。

在堆中几乎存在着所有对象,GC之前需要考虑哪些对象还活着不能回收,哪些对象已经死去可以回收。

有两种算法可以判定对象是否存活:

1.)引用计数算法:给对象中添加一个引用计数器,每当一个地方应用了对象,计数器加1;当引用失效,计数器减1;当计数器为0表示该对象已死、可回收。但是它很难解决两个对象之间相互循环引用的情况。

2.)可达性分析算法:通过一系列称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连(即对象到GC Roots不可达),则证明此对象已死、可回收。Java中可以作为GC Roots的对象包括:虚拟机栈中引用的对象、本地方法栈中Native方法引用的对象、方法区静态属性引用的对象、方法区常量引用的对象。

在主流的商用程序语言(如我们的Java)的主流实现中,都是通过可达性分析算法来判定对象是否存活的。


7:JVM垃圾回收

GC (Garbage Collection)的基本原理:将内存中不再被使用的对象进行回收,GC中用于回收的方法称为收集器,由于GC需要消耗一些资源和时间,Java在对对象的生命周期特征进行分析后,按照新生代、旧生代的方式来对对象进行收集,以尽可能的缩短GC对应用造成的暂停

(1)对新生代的对象的收集称为minor GC;

(2)对旧生代的对象的收集称为Full GC;

(3)程序中主动调用System.gc()强制执行的GC为Full GC。

不同的对象引用类型, GC会采用不同的方法进行回收,JVM对象的引用分为了四种类型:

(1)强引用:默认情况下,对象采用的均为强引用(这个对象的实例没有其他对象引用,GC时才会被回收)

(2)软引用:软引用是Java中提供的一种比较适合于缓存场景的应用(只有在内存不够用的情况下才会被GC)

(3)弱引用:在GC时一定会被GC回收

(4)虚引用:由于虚引用只是用来得知对象是否被GC


8:垃圾收集算法

1、标记-清除算法

最基础的算法,分标记和清除两个阶段:首先标记处所需要回收的对象,在标记完成后统一回收所有被标记的对象。

它有两点不足:一个效率问题,标记和清除过程都效率不高;一个是空间问题,标记清除之后会产生大量不连续的内存碎片(类似于我们电脑的磁盘碎片),空间碎片太多导致需要分配大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。


2、复制算法

为了解决效率问题,出现了“复制”算法,他将可用内存按容量划分为大小相等的两块,每次只需要使用其中一块。当一块内存用完了,将还存活的对象复制到另一块上面,然后再把刚刚用完的内存空间一次清理掉。这样就解决了内存碎片问题,但是代价就是可以用内容就缩小为原来的一半。


3、标记-整理算法

复制算法在对象存活率较高时就会进行频繁的复制操作,效率将降低。因此又有了标记-整理算法,标记过程同标记-清除算法,但是在后续步骤不是直接对对象进行清理,而是让所有存活的对象都向一侧移动,然后直接清理掉端边界以外的内存。


4、分代收集算法

当前商业虚拟机的GC都是采用分代收集算法,这种算法并没有什么新的思想,而是根据对象存活周期的不同将堆分为:新生代和老年代,方法区称为永久代(在新的版本中已经将永久代废弃,引入了元空间的概念,永久代使用的是JVM内存而元空间直接使用物理内存)。

这样就可以根据各个年代的特点采用不同的收集算法。

新生代中的对象“朝生夕死”,每次GC时都会有大量对象死去,少量存活,使用复制算法。新生代又分为Eden区和Survivor区(Survivor from、Survivor to),大小比例默认为8:1:1。

老年代中的对象因为对象存活率高、没有额外空间进行分配担保,就使用标记-清除或标记-整理算法。

新产生的对象优先进去Eden区,当Eden区满了之后再使用Survivor from,当Survivor from 也满了之后就进行Minor GC(新生代GC),将Eden和Survivor from中存活的对象copy进入Survivor to,然后清空Eden和Survivor from,这个时候原来的Survivor from成了新的Survivor to,原来的Survivor to成了新的Survivor from。复制的时候,如果Survivor to 无法容纳全部存活的对象,则根据老年代的分配担保(类似于银行的贷款担保)将对象copy进去老年代,如果老年代也无法容纳,则进行Full
GC(老年代GC)。


大对象直接进入老年代:JVM中有个参数配置-XX:PretenureSizeThreshold,令大于这个设置值的对象直接进入老年代,目的是为了避免在Eden和Survivor区之间发生大量的内存复制。

长期存活的对象进入老年代:JVM给每个对象定义一个对象年龄计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳,将被移入Survivor并且年龄设定为1。没熬过一次Minor GC,年龄就加1,当他的年龄到一定程度(默认为15岁,可以通过XX:MaxTenuringThreshold来设定),就会移入老年代。但是JVM并不是永远要求年龄必须达到最大年龄才会晋升老年代,如果Survivor 空间中相同年龄(如年龄为x)所有对象大小的总和大于Survivor的一半,年龄大于等于x的所有对象直接进入老年代,无需等到最大年龄要求。


9:垃圾收集器

垃圾收集算法是方法论,垃圾收集器是具体实现。JVM规范对于垃圾收集器的应该如何实现没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器差别较大,这里只看HotSpot虚拟机。

JDK7/8后,HotSpot虚拟机所有收集器及组合(连线)如下:


1.Serial收集器

Serial收集器是最基本、历史最久的收集器,曾是新生代手机的唯一选择。他是单线程的,只会使用一个CPU或一条收集线程去完成垃圾收集工作,并且它在收集的时候,必须暂停其他所有的工作线程,直到它结束,即“Stop the World”。停掉所有的用户线程,对很多应用来说难以接受。比如你在做一件事情,被别人强制停掉,你心里奔腾而过的“羊驼”还数的过来吗?

尽管如此,它仍然是虚拟机运行在client模式下的默认新生代收集器:简单而高效(与其他收集器的单个线程相比,因为没有线程切换的开销等)。

工作示意图:


2.ParNew收集器

ParNew收集器是Serial收集器的多线程版本,除了使用了多线程之外,其他的行为(收集算法、stop the world、对象分配规则、回收策略等)同Serial收集器一样。

是许多运行在Server模式下的JVM中首选的新生代收集器,其中一个很重还要的原因就是除了Serial之外,只有他能和老年代的CMS收集器配合工作。

工作示意图:


3.Parallel Scavenge收集器

新生代收集器,并行的多线程收集器。它的目标是达到一个可控的吞吐量(就是CPU运行用户代码的时间与CPU总消耗时间的比值,即 吞吐量=行用户代码的时间/[行用户代码的时间+垃圾收集时间]),这样可以高效率的利用CPU时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。

4.Serial Old收集器

Serial 收集器的老年代版本,单线程,“标记整理”算法,主要是给Client模式下的虚拟机使用。

另外还可以在Server模式下:

JDK 1.5之前的版本中雨Parallel Scavenge 收集器搭配使用

可以作为CMS的后背方案,在CMS发生Concurrent Mode Failure是使用

工作示意图:


5.Parallel Old收集器

Parallel Scavenge的老年代版本,多线程,“标记整理”算法,JDK 1.6才出现。在此之前Parallel Scavenge只能同Serial Old搭配使用,由于Serial Old的性能较差导致Parallel Scavenge的优势发挥不出来,尴了个尬~~

Parallel Old收集器的出现,使“吞吐量优先”收集器终于有了名副其实的组合。在吞吐量和CPU敏感的场合,都可以使用Parallel Scavenge/Parallel Old组合。组合的工作示意图如下:


6.CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,停顿时间短,用户体验就好。

基于“标记清除”算法,并发收集、低停顿,运作过程复杂,分4步:

1)初始标记:仅仅标记GC Roots能直接关联到的对象,速度快,但是需要“Stop The World”

2)并发标记:就是进行追踪引用链的过程,可以和用户线程并发执行。

3)重新标记:修正并发标记阶段因用户线程继续运行而导致标记发生变化的那部分对象的标记记录,比初始标记时间长但远比并发标记时间短,需要“Stop The World”

4)并发清除:清除标记为可以回收对象,可以和用户线程并发执行

由于整个过程耗时最长的并发标记和并发清除都可以和用户线程一起工作,所以总体上来看,CMS收集器的内存回收过程和用户线程是并发执行的。

工作示意图:

CSM收集器有3个缺点:

1)对CPU资源非常敏感

并发收集虽然不会暂停用户线程,但因为占用一部分CPU资源,还是会导致应用程序变慢,总吞吐量降低。

CMS的默认收集线程数量是=(CPU数量+3)/4;当CPU数量多于4个,收集线程占用的CPU资源多于25%,对用户程序影响可能较大;不足4个时,影响更大,可能无法接受。

2)无法处理浮动垃圾(在并发清除时,用户线程新产生的垃圾叫浮动垃圾),可能出现”Concurrent Mode Failure”失败。

并发清除时需要预留一定的内存空间,不能像其他收集器在老年代几乎填满再进行收集;如果CMS预留内存空间无法满足程序需要,就会出现一次”Concurrent Mode Failure”失败;这时JVM启用后备预案:临时启用Serail Old收集器,而导致另一次Full GC的产生;

3)产生大量内存碎片:CMS基于”标记-清除”算法,清除后不进行压缩操作产生大量不连续的内存碎片,这样会导致分配大内存对象时,无法找到足够的连续内存,从而需要提前触发另一次Full GC动作。


7.G1收集器

G1(Garbage-First)是JDK7-u4才正式推出商用的收集器。G1是面向服务端应用的垃圾收集器。它的使命是未来可以替换掉CMS收集器。

G1收集器特性:

并行与并发:能充分利用多CPU、多核环境的硬件优势,缩短停顿时间;能和用户线程并发执行。

分代收集:G1可以不需要其他GC收集器的配合就能独立管理整个堆,采用不同的方式处理新生对象和已经存活一段时间的对象。

空间整合:整体上看采用标记整理算法,局部看采用复制算法(两个Region之间),不会有内存碎片,不会因为大对象找不到足够的连续空间而提前触发GC,这点优于CMS收集器。

可预测的停顿:除了追求低停顿还能建立可以预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不超N毫秒,这点优于CMS收集器。

为什么能做到可预测的停顿?

是因为可以有计划的避免在整个Java堆中进行全区域的垃圾收集。

G1收集器将内存分大小相等的独立区域(Region),新生代和老年代概念保留,但是已经不再物理隔离。

G1跟踪各个Region获得其收集价值大小,在后台维护一个优先列表;

每次根据允许的收集时间,优先回收价值最大的Region(名称Garbage-First的由来);

这就保证了在有限的时间内可以获取尽可能高的收集效率。

对象被其他Region的对象引用了怎么办?

判断对象存活时,是否需要扫描整个Java堆才能保证准确?在其他的分代收集器,也存在这样的问题(而G1更突出):新生代回收的时候不得不扫描老年代?无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描:每个Region都有一个对应的Remembered Set;每次Reference类型数据写操作时,都会产生一个Write Barrier 暂时中断操作;然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的 Region(其他收集器:检查老年代对象是否引用了新生代对象);如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered
Set中;
进行垃圾收集时,在GC根节点的枚举范围加入 Remembered Set ,就可以保证不进行全局扫描,也不会有遗漏。


不计算维护Remembered Set的操作,回收过程可以分为4个步骤(与CMS较为相似):

1)初始标记:仅仅标记GC Roots能直接关联到的对象,并修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时能在正确可用的Region中创建新对象,需要“Stop The World”

2)并发标记:从GC Roots开始进行可达性分析,找出存活对象,耗时长,可与用户线程并发执行

3)最终标记:修正并发标记阶段因用户线程继续运行而导致标记发生变化的那部分对象的标记记录。并发标记时虚拟机将对象变化记录在线程Remember Set Logs里面,最终标记阶段将Remember Set Logs整合到Remember Set中,比初始标记时间长但远比并发标记时间短,需要“Stop The World”

4)筛选回收:首先对各个Region的回收价值和成本进行排序,然后根据用户期望的GC停顿时间来定制回收计划,最后按计划回收一些价值高的Region中垃圾对象。回收时采用复制算法,从一个或多个Region复制存活对象到堆上的另一个空的Region,并且在此过程中压缩和释放内存;可以并发进行,降低停顿时间,并增加吞吐量。

工作示意图:


10:基本结构

从Java平台的逻辑结构上来看,我们可以从下图来了解JVM:

从上图能清晰看到Java平台包含的各个逻辑模块,也能了解到JDK与JRE的区别。


文章有点长,大家觉得作者总结的还可以,大家可以点击下方二维码进行关注。《Java烂猪皮》公众号聊的不仅仅是Java技术知识,还有面试等干货,后期还有大量架构干货。大家一起关注吧!关注烂猪皮,你会了解的更多…………..

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Redis的数据类型——探究竟 String类型 Hash类

发表于 2018-07-26

接上篇 为什么要用Redis,今天来聊聊具体的Redis数据类型与命令。本篇是深入理解Redis的一个重要基础,请坐稳,前方 长文预警。

本系列内容基于:redis-3.2.12

文中不会介绍所有命令,主要是工作中经常遇到的。

平时我们看的大部分资料,都是简单粗暴的告诉我们这个命令干嘛,那个命令需要几个参数。这种方式只会知其然不知其所以然,本文从命令的时间复杂度到用途,再到对应类型在Redis低层采用何种结构保存数据,希望让大家认识的更深刻,使用时心里更有底。

  1. 这里在阅读中请注意:虽然很多命令的时间复杂度都是O(n),但要注意其n所代表的具体含义。
  2. 文中会用到 OBJECT ENCODING xxx 来检查Redis的内部编码,它其实是读取的 redisObject 结构体中 encoding 所代表的值。redisObject 对不同类型的数据提供了统一的表现形式。

String类型

应该讲这是Redis中使用的最广泛的数据类型。该类型中的一些命令使用场景非常广泛。比如:

  • 缓存,这是使用非常多的地方;
  • 计数器/限速器技术;
  • 共享Session服务器也是基于该数据类型

注:表格中仅仅说明了String中的12个命令,使用场景也仅列举了部分。

我们时常被人说教 MSET/MGET 这类命令少用,因为他们的时间复杂度是O(n),但其实这里注意,n表示的是本次设置或读取的key个数,所以如果你批量读取的key并不是很多,每个key的内容也不是很大,那么使用批量操作命令反而能够节省网络请求、传输的时间。

内部结构

String类型的数据最终是如何在Redis中保存的呢?如果要细究的话,得先从 SDS 这个结构说起,不过今天先按下不表这源码部分的细节,只谈其内部保存的数据结构。最终我们设置的字符串都会以三种形式中的一种被存储下来。

  • Int,8个字节的长整型,最大值是:0x7fffffffffffffffL
  • Embstr,小于等于44个字节的字符串
  • Raw

结合代码来看看Redis对这三种数据结构是如何决策的。当我们在客户端使用命令 SET test hello,redis 时,客户端会把命令保存到一个buf中,然后按照收到的命令先后顺序依次执行。这其中有一个函数是:processMultibulkBuffer() ,它内部调用了 createStringObject() 函数:

1
2
3
4
5
6
7
8
复制代码#define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44
robj *createStringObject(const char *ptr, size_t len) {
// 检查保存的字符串长度,选择对应类型
if (len <= OBJ_ENCODING_EMBSTR_SIZE_LIMIT)
return createEmbeddedStringObject(ptr,len);
else
return createRawStringObject(ptr,len);
}

不懂C语言不要紧,这里就是检查我们输入的字符串 hello,redis 长度是否超过了 44 ,如果超过了用类型 raw ,没有则选用 embstr 。实验看看:

1
2
3
4
5
6
7
8
复制代码127.0.0.1:6379> SET test 12345678901234567890123456789012345678901234 // len=44
OK
127.0.0.1:6379> OBJECT encoding test
"embstr"
127.0.0.1:6379> SET test 123456789012345678901234567890123456789012345 // len=45
OK
127.0.0.1:6379> OBJECT encoding test
"raw"

可以看到,一旦超过44,底层类型就变成了:raw 。等等,上面我们不是还提到有一个 int 类型吗?从函数里边完全看不到它的踪迹啊?不急,当我们输入的这条命令真的要开始执行时,也就是调用函数 setCommand() 时,会触发一个 tryObjectEncoding() 函数,这个函数的作用是试图对输入的字符串进行压缩,继续看看代码:

1
2
3
4
5
6
7
8
9
复制代码robj *tryObjectEncoding(robj *o) {
... ...
len = sdslen(s);
// 长度小于等于20,并且能够转成长整形
if(len <= 20 && string2l(s,len,&value)) {
o->encoding = OBJ_ENCODING_INT;
}
... ...
}

这个函数被我大幅缩水了,但是简单我们能够看到它判断长度是否小于等于20,并且尝试转化成整型,看看例子。

9223372036854775807 是8位字节可表示的最大整数,它的16进制形式是:0x7fffffffffffffffL

1
2
3
4
5
6
7
8
复制代码127.0.0.1:6379> SET test 9223372036854775807
OK
127.0.0.1:6379> OBJECT encoding test
"int"
127.0.0.1:6379> SET test 9223372036854775808 // 比上面大1
OK
127.0.0.1:6379> OBJECT encoding test
"embstr"

至此,关于String的类型选择流程完毕了。这对我们的参考价值是,我们在使用String类型保存数据时,要考虑到底层对应不同的类型,不同的类型在Redis内部会执行不同的流程,其所对应的执行效率、内存消耗都是不同的。

Hash类型

我们经常用它来保存一个结构化的数据,比如与一个用户相关的缓存信息。如果使用普通的String类型,需要对字符串进行序列化与反序列化,无疑增加额外开销,并且每次读取都只能全部读取出来。

  • 缓存结构化的数据,如:文章信息,可灵活修改其某一个字段,如阅读量。

Hash类型保存的结构话数据,非常像MySQL中的一条记录,我们可以方便修改某一个字段,但是它更具灵活性,每个记录能够含有不同的字段。

内部结构

在内部Hash类型数据可能存在两种类型的数据结构:

  • ZipList,更加节省空间,限制:key与field长度不超过64,key中field的个数不超过512个
  • HashTable

对于Hash,Redis 首先默认给它设置使用 ZipList 数据结构,后续根据条件进行判断是否需要改变。

1
2
3
4
5
6
7
8
9
10
11
12
复制代码
void hsetCommand(client *c) {
int update;
robj *o;

if ((o = hashTypeLookupWriteOrCreate(c,c->argv[1])) == NULL) return;
hashTypeTryConversion(o,c->argv,2,3);// 根据长度决策
... ...
update = hashTypeSet(o,c->argv[2],c->argv[3]);// 根据元素个数决策
addReply(c, update ? shared.czero : shared.cone);
... ...
}

hashTypeLookupWriteOrCreate() 内部会调用 createHashObject() 创建Hash对象。

1
2
3
4
5
6
复制代码robj *createHashObject(void) {
unsigned char *zl = ziplistNew();
robj *o = createObject(OBJ_HASH, zl);
o->encoding = OBJ_ENCODING_ZIPLIST;// 设置编码 ziplist
return o;
}

hashTypeTryConversion() 函数内部根据是否超过 hash_max_ziplist_value 限制的长度(64),来决定低层的数据结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码void hashTypeTryConversion(robj *o, robj **argv, int start, int end) {
int i;

if (o->encoding != OBJ_ENCODING_ZIPLIST) return;

for (i = start; i <= end; i++) {
// 检查 field 与 value 长度是否超长
if (sdsEncodedObject(argv[i]) &&
sdslen(argv[i]->ptr) > server.hash_max_ziplist_value)
{
hashTypeConvert(o, OBJ_ENCODING_HT);
break;
}
}
}

然后在函数 hashTypeSet() 中检查field个数是否超过了 hash_max_ziplist_entries 的限制(512个)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码int hashTypeSet(robj *o, robj *field, robj *value) {
int update = 0;

if (o->encoding == OBJ_ENCODING_ZIPLIST) {
... ...
// 检查field个数是否超过512
if (hashTypeLength(o) > server.hash_max_ziplist_entries)
hashTypeConvert(o, OBJ_ENCODING_HT);
} else if (o->encoding == OBJ_ENCODING_HT) {
... ...
}
... ...
return update;
}

来验证一下上面的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码127.0.0.1:6379> HSET test name qweqweqwkejkksdjfslfldsjfkldjslkfqweqweqwkejkksdjfslfldsjfkldjsl
(integer) 1
127.0.0.1:6379> HSTRLEN test name
(integer) 64
127.0.0.1:6379> OBJECT encoding test
"ziplist"
127.0.0.1:6379> HSET test name qweqweqwkejkksdjfslfldsjfkldjslkfqweqweqwkejkksdjfslfldsjfkldjslq
(integer) 0
127.0.0.1:6379> HSTRLEN test name
(integer) 65
127.0.0.1:6379> OBJECT encoding test
"hashtable"

关于key设置超过64,以及field个数超过512的限制情况,大家可自行测试。

List类型

List类型的用途也是非常广泛,主要概括下常用场景:

  • 消息队列:LPUSH + BRPOP(阻塞特征)
  • 缓存:用户记录各种记录,最大特点是可支持分页
  • 栈:LPUSH + LPOP
  • 队列:LPUSH + RPOP
  • 有限队列:LPUSH + LTRIM,可以维持队列中数据的数量

内部结构

List 的数据类型在低层实现有以下几种:

  • QuickList:它是以ZipList为节点的LinkedList
  • ZipList(省内存),在3.2.12版本中发现有地方使用
  • LinkedList,在3.2.12版本中发现有地方使用

网络上有些文章说 LinkedList 在 Redis 4.0 之后的版本没有再被使用,实际上我发现 Redis 3.2.12 版本中也没有再使用该结构(不直接做为数据存储结构),包括 ZipList 在 3.2.12 版本中都没有再被直接用来存储数据了。

我们做个实验来验证下,我们设置一个List中有 1000 个元素,每个元素value长度都超过 64 个字符。

1
2
3
4
5
6
复制代码127.0.0.1:6379> LLEN test
(integer) 1000
127.0.0.1:6379> OBJECT encoding test
"quicklist"
127.0.0.1:6379> LINDEX test 0
"qweqweqwkejkksdjfslfldsjfkldjslkfqweqweqwkejkksdjfslfldsjfkldjslq" // 65个字符

无论我们是改变列表元素的个数以及元素值的长度,其结构都是 QuickList。还不信的话,我们来看看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
复制代码void pushGenericCommand(client *c, int where) {
int j, waiting = 0, pushed = 0;
robj *lobj = lookupKeyWrite(c->db,c->argv[1]);
... ...
for (j = 2; j < c->argc; j++) {
c->argv[j] = tryObjectEncoding(c->argv[j]);
if (!lobj) {
// 创建 quick list
lobj = createQuicklistObject();
quicklistSetOptions(lobj->ptr, server.list_max_ziplist_size,
server.list_compress_depth);
dbAdd(c->db,c->argv[1],lobj);
}
listTypePush(lobj,c->argv[j],where);
pushed++;
}
... ...
}

初始话时,调用 createQuicklistObject() 设置其低层数据结构是:quick list 。后续流程中没有地方再对该结构进行转化。

Set类型

Set 类型的重要特性之一是可以去重、无序。它集合的性质在社交上可以有广泛的使用。

  • 共同关注
  • 共同喜好
  • 数据去重

内部结构

Set低层实现采用了两种数据结构:

  • IntSet,集合成员都是整数(不能超过最大整数)并且集合成员个数少于512时使用。
  • HashTable

该命令的代码如下,其中重要的两个关于决定类型的调用是:setTypeCreate() 和 setTypeAdd()。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码void saddCommand(client *c) {
robj *set;
... ...
if (set == NULL) {
// 初始化
set = setTypeCreate(c->argv[2]);
} else {
... ...
}

for (j = 2; j < c->argc; j++) {
// 内部会检查元素个数是否扩充到需要改变低层结构
if (setTypeAdd(set,c->argv[j])) added++;
}
... ...
}

来看下 Set 结构对象的初始创建代码:

1
2
3
4
5
复制代码robj *setTypeCreate(robj *value) {
if (isObjectRepresentableAsLongLong(value,NULL) == C_OK)
return createIntsetObject(); // 使用IntSet
return createSetObject(); // 使用HashTable
}

isObjectRepresentableAsLongLong() 内部判断其整数范围,如果是整数且没有超过最大整数就会使用 IntSet 来保存。否则使用 HashTable 。接着会检查元素的个数。

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
复制代码int setTypeAdd(robj *subject, robj *value) {
long long llval;
if (subject->encoding == OBJ_ENCODING_HT) {
... ...
} else if (subject->encoding == OBJ_ENCODING_INTSET) {
if (isObjectRepresentableAsLongLong(value,&llval) == C_OK) {
uint8_t success = 0;
subject->ptr = intsetAdd(subject->ptr,llval,&success);
if (success) {
/* Convert to regular set when the intset contains
* too many entries. */
if (intsetLen(subject->ptr) > server.set_max_intset_entries)
setTypeConvert(subject,OBJ_ENCODING_HT);
return 1;
}
} else {
/* Failed to get integer from object, convert to regular set. */
setTypeConvert(subject,OBJ_ENCODING_HT);
... ...
return 1;
}
}
... ...
return 0;
}

看看例子,这里以最大整数临界值为例:

1
2
3
4
5
6
7
8
复制代码127.0.0.1:6379> SADD test 9223372036854775807
(integer) 1
127.0.0.1:6379> OBJECT encoding test
"intset"
127.0.0.1:6379> SADD test 9223372036854775808
(integer) 1
127.0.0.1:6379> OBJECT encoding test
"hashtable"

关于集合个数的测试,请自行完成观察。

SortSet类型

现在的应用,都有一些排行榜之类的功能,比如投资网站显示投资金额排行,购物网站显示消费排行等。SortSet非常适合做这件事。常用来解决以下问题:

  • 各类排行榜
  • 设置执行任务权重,后台脚本根据其排序顺序执行相关操作
  • 范围查找,查找某个值在集合的哪个范围

内部结构

虽然有序集合也是集合,但是低层的数据结构却与Set不一样,它也有两种数据结构,分别是:

  • ZipList,当有序集合的元素个少于等于128或 member 的长度小于等于64的时候使用该结构
  • SkipList

这个转变成过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码void zaddGenericCommand(client *c, int flags) {
if (zobj == NULL) {
if (xx) goto reply_to_client; /* No key + XX option: nothing to do. */
if (server.zset_max_ziplist_entries == 0 ||
server.zset_max_ziplist_value < sdslen(c->argv[scoreidx+1]->ptr))
{
zobj = createZsetObject();// skip list
} else {
zobj = createZsetZiplistObject();// zip list
}
dbAdd(c->db,key,zobj);
} else {
... ...
}
... ...
if (zobj->encoding == OBJ_ENCODING_ZIPLIST) {
if (zzlLength(zobj->ptr) > server.zset_max_ziplist_entries)
zsetConvert(zobj,OBJ_ENCODING_SKIPLIST);// 根据个数转化编码
if (sdslen(ele->ptr) > server.zset_max_ziplist_value)
zsetConvert(zobj,OBJ_ENCODING_SKIPLIST);// 根据长度转化编码
}
}

这里以member长度超过64举例:

1
2
3
4
5
6
7
8
复制代码127.0.0.1:6379> ZADD test 77 qwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiopqwer // member长度是 64
(integer) 1
127.0.0.1:6379> OBJECT encoding test
"ziplist"
127.0.0.1:6379> ZADD test 77 qwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiopqwerq // member长度是65
(integer) 1
127.0.0.1:6379> OBJECT encoding test
"skiplist"

当我们member 超过64位长度时,低层的数据结构由 ZipList 转变成了 SkipList。剩下的元素个数的测试,动动手试试看。

全局常用命令

对于全局命令,不管对应的key是什么类型的数据,都是可以进行操作的。其中需要注意 KEYS 这个命令,不能用于线上,因为Redis单线程机制,如果内存中数据太多,会操作严重的阻塞,导致整个Redis服务都无法响应。

总结

  • Redis每种类型的命令时间复杂度不同,有的跟对应元素的个数有关系;有的跟请求个数有关系;
  • 合理安排元素相关个数以及长度,争取Redis底层采用最简单的数据结构;
  • 关注时间复杂度,了解自己的Redis内部元素情况,避免阻塞;
  • 越简单的数据,越能获得更好的性能;
  • Redis每种数据类型低层都对应多种数据结构,修改与扩展对上层无感知。

第一篇讲了为什么要用Redis,本文又讲了绝大部分命令吧,以及Redis源码中对它们的一些实现,后续开始关注具体实践中的一些操作。希望对大家有帮助,期待任何形式的批评与鼓励。

公众号:dayuTalk

image

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

【积德篇】 如何少写PHP "烂"代码 前言 Control

发表于 2018-07-18

写给初生牛犊不怕虎的童鞋们,大佬可随意摘看
本章基于PHP Laravel

前言

经常会有人问

  • 目录如何设计比较好?
  • 代码如何分布好?
  • 怎么写一个可维护的项目?

“烂”项目我也没少写,以下是参考互联网各大佬的文章总结及个人开发经验而来.

Controller

clipboard.png

Controller顾名思义是控制器,在入门PHP的时候,就知道Controller代表MVC中的C层,MVC本身的概念就代码分离,教你如何如何将业务分开,但面临着业务的不断发展,代码的复杂度也随之提高,功能与功能之间的链接错综复杂,最后你的MVC就变成了下图,所以仅仅依托MVC的设计思想已经无法支撑不断发展的业务。

现在我们将Controller的任务和能力重新定义,控制器仅仅控制Http Reqeust的请求,这样就符合了SOLID 单一功能原则.

clipboard.png

直接将业务代码写在Controller中,会使得代码及其臃肿,不易于维护和扩展

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
php复制代码<?php
namespace App\Http\Controller;

class UserController extends Controller{

public function register(Request $request){
$user = new User();
$user->username = $request->input('username');
$user->password = $request->input('password');
$result = $user->save();

return $result;
}

}

这时就应该思考如何分离业务代码,我们引入Service的概念

Service

Service本身译为服务

  • 将外部方法,公共方法注入到Service
  • 将Service注入到控制器

clipboard.png
像上图这样

UserController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
php复制代码<?php
namespace App\Http\Controller;

class UserController extends Controller{

public $request;

protected $userService;

public function __construct(Request $request, UserService $userService)
{
$this->request = $request;

$this->userService = $userService;
}

public function register()
{
//... validation
return $this->userService->register ($this->request->all());
}

}

UserService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
php复制代码<?php
namespace App\Service;

class UserService{

public function register($data)
{
$username = $data['username'];
$password = $data['password'];

$password = encrypt ($password);

$user = new User();
$user->username = $username;
$user->password = $password;
$result = $user->save();

return $result;
}

}

到现在为止,我们至少将业务与请求彻底分开了。但还是不如人意,如果把所有的业务及CURD全部写在Service中,那只不过是将Controller的臃肿转移到了Service,那Service就没有什么存在意义了。
所以我们需要继续分割Service,将对数据库的R操作独立出来,因为CUD的操作基本是一贯不变的,而R操作根据业务的复杂度则变的多姿多彩。所以独立R操作。这个时候我们引用Repository的概念。

Repository

我们使用Repository辅助Model,将相关的查询逻辑封装到不同的repository中,方便逻辑代码的维护

  • 符合SOLID的单一原则
  • 符合SOLID的依赖反转

clipboard.png

UserController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
php复制代码<?php
namespace App\Http\Controller;

class UserController extends Controller{

public $request;

protected $userService;

public function __construct(Request $request, UserService $userService)
{
$this->request = $request;

$this->userService = $userService;
}

public function getUserInfo()
{
//... validation
return $this->userService->getUserInfo ($this->request->all());
}

}

UserService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
php复制代码<?php
namespace App\Service;

class UserService{
public $userRepository;

public function __construct(UserRepository $userRepository){
$this->userRepository = $userRepository;
}
public function getUserInfo()
{
return $this->userRepository->getUserInfo($data);
}

}

UserRepository

1
2
3
4
5
6
7
8
9
10
11
12
13
14
php复制代码<?php
namespace App\Repository;

class UserRepository{

public function getUserInfo($data)
{
$userId = $data['user_id'];
$result = User::where('id',$userId)->first();

return $result;
}

}

解决了R的问题,有人就问了,难道因为CUD比较统一简单就可以放在一起了吗?答案是NO,我们引用一个新的名词Action。

Action

这是看了@Charlie_Jade的文章才学到的

独立每个操作文件,例如CreateUser,DeleteUser,UpdateUser

  • 符合SOLID的单一原则

clipboard.png

UserController

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
php复制代码<?php
namespace App\Http\Controller;

class UserController extends Controller{

public $request;

protected $userService;

public function __construct(Request $request, UserService $userService)
{
$this->request = $request;

$this->userService = $userService;
}

public function register(){
//... validation
return $this->userService->register($this->request->all());
}

public function getUserInfo()
{
return $this->userService->getUserInfo ($this->request->all());
}

}

UserService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
php复制代码<?php
namespace App\Service;

class UserService{

public function getUserInfo(UserRepository $userRepository)
{
return $this->userRepository->getUserInfo($data);
}

public function register(){
$result = (new CreateUser())->execute($this->request->all());

return $result;
}

}

UserRepository

1
2
3
4
5
6
7
8
9
10
11
12
13
14
php复制代码<?php
namespace App\Repository;

class UserRepository{

public function getUserInfo($data)
{
$userId = $data['user_id'];
$result = User::where('id',$userId)->first();

return $result;
}

}

CreateUser

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
php复制代码<?php

namespace App\Action;

use App\Model\Member;

class CreateUser extends CreateUserWallet
{
public function execute(array $data)
{
$models = new Member();
$models->tel = $data['tel'];
$models->password = $data['password'];
$result = $models->save ();

return $result;
}
}

以上代码逻辑见下图
clipboard.png
除模版(V)等HTML,JS等,还需要一些其他的规则,或者说是方式去实现一些代码的解耦合,以下不再提供代码案例。

Common

译为公共的,常用的,再部分开发中,你可能需要一些公共的方法(并非公共的类,例如邮件发送等,用他并不合适),比如查询用户余额,查询用户是否注册或者是否在线,生成订单号等。使用Common更要简单。他更像一个公共函数库的样子

clipboard.png

Event

不关心执行结果时可以选使用,不过Event的Listen也是提供了队列。

Exception

不要将你的所有错误提示都使用Return返回,很多时候你的返回未必是你的返回

致谢

感谢各位同学看完这篇文章,如果你有新的想法欢迎在评论区讨论.

参考文章

Laravel 的中大型專案架構:oomusou.io/laravel/arc…

Laravel 程序架构设计思路使用动作类 : segmentfault.com/a/119000001…

如何使用 Service 模式? : oomusou.io/laravel/ser…

面向对象设计的SOLID原则 : www.cnblogs.com/shanyou/arc…

交流

生命不息,编码不止。

微信搜索 【一文秒懂】 传播技术正能量,持续学习新知识。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

拆轮子:网关GOKU-API-Gateway

发表于 2018-07-15

前言

最近想学习一下网关相关的知识,搜了一下,看到有个悟空API网关的项目。文档图文并茂,又是企业级别的,决定就是它了,项目地址:GOKU-API-Gateway

问题

看在源码之前,得先定一下目标,盲目地看代码容易迷失。在看了官方的文档和跟着文档搭起来试用了一下之后,定下了下面这些目标。

  • GOKU-API-Gateway监控信息如何收集?如何存储?
  • 如何做到高效的转发?
  • QPS限制,在分布式的情况下是怎么做的,尤其是秒级的限制?
  • 如何做到方便添加新的过滤功能?
  • 有没有什么可以学习的?
  • 有没有可以改进的地方?
  • 思考网关应该提供一些什么功能?
  • 思考网关所面临着的挑战有哪些?

GOKU关键的结构体

看代码之前,有必要理解一下GOKU-API-Gateway中数据的抽象是怎样的。这个打开管理后台,把用起需要设置的东西都设置一遍,这一块基本也就可以了。对应的结构体在这里:server/conf。

关键的

API: 定义了一个接口转发,里面主要包含了,请求的URL,转发的URL,方法,流量策略等等信息

策略: 定义了流量限制的策略,主要有:鉴权方式,IP的黑白名单,流量控制等等信息

一次请求处理的大体流程

入口

在工程的最外层有两个文件:goku-ce.go,goku-ce-admin.go。点进去瞄一眼,大体就知道goku-ce-admin.go是后台管理的接口,goku-ce.go是真正的网关服务。

goku-ce.go

看到有ListenAndServe估计就是web框架那一套东西,可以全局搜一下ServeHTTP。其中middleware.Mapping是每一个API的处理函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码func main() {
server := goku.New()

// 注册路由的处理函数 server.RegisterRouter(server.ServiceConfig,middleware.Mapping,middleware.GetVisitCount)
fmt.Println("Listen",server.ServiceConfig.Port)

// 启动服务
err := goku.ListenAndServe(":" + server.ServiceConfig.Port,server)
if err != nil {
log.Println(err)
}
log.Println("Server on " + server.ServiceConfig.Port + " stopped")
os.Exit(0)
}

ServeHTTP

看到代码中的trees就想到了gin这个框架,点进去发现路由树这一块基本上和gin框架的差不多,但是节点中的内容有点不一样。不再是一个接口对应一组处理函数,而是只有一个。多了个Context的指针,Context对象里面主要是保存了API的中的转发地址,限流策略,统计信息等等,context对象是理解整个网关的处理最重要的对象,没有之一。相当于接口信息的本地缓存,当找到路由的处理函数时,就找到了接口信息的本地缓存,减少了一次缓存查询,这个思路非常棒!!!

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
复制代码
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// 省略N多代码

// 看到这个trees就想到了之前看的gin框架,
if root := r.trees[req.Method]; root != nil {

// context是个关键点,
handle, ps, context,tsr := root.getValue(path);
if handle != nil {
handle(w, req, ps,context)
return
} else{
// 省略N多代码
}

// 省略N多代码
}

//
type node struct {
path string
wildChild bool
nType nodeType
maxParams uint8
indices string
children []*node

// 只有一个处理函数
handle Handle
priority uint32

// API的中的转发地址,限流策略,统计信息都这context里面
context *Context
}

middleware.Mapping

在goku-ce.go中就说了这个是接口的处理函数,整个流程很清晰,各种过滤是怎么做的顺着点进去就可以看到了。其实可以发现,整个代码对应处理高并发中的一些小细节做不是很好,具体的在有什么可以改进的地方会重点描述。

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
复制代码func Mapping(res http.ResponseWriter, req *http.Request,param goku.Params,context *goku.Context) {
// 更新实时访问次数
go context.VisitCount.CurrentCount.UpdateDayCount()

// 验证IP是否合法
f,s := IPLimit(context,res,req)
if !f {
res.WriteHeader(403)
res.Write([]byte(s))

// 统计信息的收集
go context.VisitCount.FailureCount.UpdateDayCount()
go context.VisitCount.TotalCount.UpdateDayCount()
return
}

// 权限验证
f,s = Auth(context,res,req)
if !f {
res.WriteHeader(403)
res.Write([]byte(s))
go context.VisitCount.FailureCount.UpdateDayCount()
go context.VisitCount.TotalCount.UpdateDayCount()
return
}

// 速率限制
f,s = RateLimit(context)
if !f {
res.WriteHeader(403)
res.Write([]byte(s))
go context.VisitCount.FailureCount.UpdateDayCount()
go context.VisitCount.TotalCount.UpdateDayCount()
return
}

//接口转发
statusCode,body,headers := CreateRequest(context,req,res,param)
for key,values := range headers {
for _,value := range values {
res.Header().Set(key,value)
}
}
res.WriteHeader(statusCode)
res.Write(body)
if statusCode != 200 {
go context.VisitCount.FailureCount.UpdateDayCount()
go context.VisitCount.TotalCount.UpdateDayCount()
} else {
go context.VisitCount.SuccessCount.UpdateDayCount()
go context.VisitCount.TotalCount.UpdateDayCount()
}
return
}

问题的答案

GOKU-API-Gateway监控信息如何收集?如何存储?

监控信息请求过程中进行手机,直接存储在接口对应的Context里面。问题来了,当网关部署多个节点时,怎么将各个节点的监控信息收集起来?带着问题,去找代码,发现没有这一块的代码。估计这个开源的版本的阉割版吧,只能单节点部署。

QPS限制,在分布式的情况下是怎么做的,尤其是秒级的限制?

代码当中木有考虑到这一块

如何做到方便添加新的过滤功能?

有新的过滤功能需要,在middleware.Mapping函数里面添加。我觉得这里可以借鉴gin框架那一套,一个URI对应多个处理函数,每个处理函数就是一个过滤功能。这样的话,甚至可以实现热拔插功能,只要每个进程提供对应的接口修改,URI的处理函数列表。

有没有什么可以学习的?

接口信息放在路由树中

这个在上面已经说了,就不再做说明,很棒的思路。

有没有可以改进的地方?

在超高并发的场合,对代码要求会很高,没有必要的开销能省就省,考虑到一般用上了网关这东西,并发量肯定比较高的了,所以才有了下面的那些改进点。

时间如果不需要绝对的精确,没有必要每次都调用time.now()获取

代码里面有很多关于时间判断,其实都不要求绝对的精准,可以直接从缓存里面获取时间。因为每次调用time.now()都会进行系统调用,开销虽然很小。缓存也很简单,弄个定时器每秒更新一次就好。代码中的可以改进的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码func (l *LimitRate) UpdateDayCount() {
// TODO 改进
l.lock.Lock()
now := time.Now()


// 这里损失1以内秒的统计不会造成太大的影响,当前时间也应该从缓存里面拿,避免系统调用
if now.Day() != l.begin.Day(){
l.begin = now
l.count = 0
}
l.count++
l.lock.Unlock()
}

能缓存的就缓存起来,不需要每次都计算

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码func (l *LimitRate) UpdateDayCount() {
// TODO 改进
l.lock.Lock()
now := time.Now()

// 应为begin的时间是不变的日期应该在初始化的时候就计算好,这样就不用每次都调用l.begin.Day()
if now.Day() != l.begin.Day(){
l.begin = now
l.count = 0
}
l.count++
l.lock.Unlock()
}

高并发场景尽量不要打LOG,而且LOG也要有缓冲区的,缓冲区满了再打印

这里的尽量不要打log,并不是说不要不打log。 因为把log打印到磁盘是涉及到IO的,对性能是有所影响的。如果可以忍受一定的丢失,log应该设置一定的缓冲区,等缓冲区满了才打印到磁盘。

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
复制代码func (l *LimitRate) DayLimit() bool {
result := true
l.lock.Lock()
now := time.Now()

// 清除,重新计数
if now.Day() != l.begin.Day(){
l.begin = now
l.count = 0
}

if l.rate != 0 {
t := now.Hour()
bh := l.begin.Hour()

// TODO 改进 求加括号,用意很不明确
if bh <= t && t < l.end || (bh > l.end && (t < bh && t < l.end)){

// TODO 改进 万一有错超过了rate那就GG了,应用用>=
if l.count == l.rate {
result = false
} else {
l.count++
}
}
}

// TODO 改进 这种高并发场景不要打印
fmt.Println("Day count:")
fmt.Println(l.count)

l.lock.Unlock()
return result
}

开启goruntime是有成本的,简单的操作不应该开新的goruntime

goruntimes的声誉非常非常之好,既轻量,又廉价,开成千上万不成问题,但是这并不意味着没有开销。goruntime也是要有结构体来保存,也是要参与调度,也是要排队的等等。在代码当中,统计信息的收集都是开启一个goruntime,里面仅仅是加个锁,将计数器++,这个完全是没有必要的。这里可以通过channle的方式,弄常驻的goruntime专门来处理统计信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码func Mapping(res http.ResponseWriter, req *http.Request,param goku.Params,context *goku.Context) {
// 更新实时访问次数
go context.VisitCount.CurrentCount.UpdateDayCount()

// 验证IP是否合法
f,s := IPLimit(context,res,req)
if !f {
res.WriteHeader(403)
res.Write([]byte(s))
go context.VisitCount.FailureCount.UpdateDayCount()
go context.VisitCount.TotalCount.UpdateDayCount()
return
}
}

思考网关应该提供一些什么功能?

这个需要再看看其它的网关代码,才能总结出来。

思考网关所面临着的挑战有哪些?

网关作为所有API的入口,几乎可以说必然会有高并发的挑战。由于是所有API的入口,也必然要求高可用。

总结

总的来说,目前开源的部分估计仅仅是单机的代码,并没有我想要的东西。需要看其它开源的网关代码,继续学习。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

爬虫实战(三):微博用户信息分析

发表于 2018-07-15

前叙

系列文章:

爬虫实战(一):爬取微博用户信息

爬虫实战(二):Selenium 模拟登录并爬取信息

爬虫实战(三):微博用户信息分析

该系列文章介绍了什么?

1.爬虫分析和处理方法

2.Python中的数据库操作方法

3.Selenium浏览器自动化以及无头浏览器使用方法

4.对数据进行词云分析的方法

5.对数据进行可视化的方法

6.LDA隐含狄利克雷分布模型的建模和使用方法

前言

前面 这篇文章 介绍了爬取微博用户资料和动态并将数据保存在数据库的过程。下面来说一下对数据库数据与分析的过程。

数据库分析

首先先来回顾一下前一篇文章中谈到的数据库字段。下图是 weibo 数据库中的三张表:

oXauR.png

其中每个字段对应的含义请见源码。

再来看一下数据库中每张表里面的内容,首先是用户资料表中的内容:

oXLLy.png

可以看到和微博资料页的资料是对应的。

再来看看用户动态数据表:

oXEGi.png

里面包含了用户动态内容和动态发布时间,所以我们首先想到可以对微博动态内容进行处理。

可视化分析

了解了数据库结构以后,我们可以对微博动态和动态发布时间进行可视化处理,在这里,笔者做了以下几点分析:

  • 微博动态发布时间处理:画出微博发布时间统计图
  • 微博动态简单可视化:词云
  • 微博动态词频处理:词频统计以及生成词频图

以上三种分析程序均位于 Data_analysis.py 中, 其 main 函数代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
复制代码#可以指定需要分析的用户的uid(必须先存在conf.yaml里面,并且运行了一次爬虫程序)
def main(uid):
time_lists,str=get_time_str(uid)#将数据库中的微博动态转化为字符串
plot_create_time(time_lists)#画出微博发布时间统计图
with open('data/stop_words.txt') as f:
stop_words = f.read().split('\n')
str=format_content(str)#去掉表情和一些不必要的符号
word_list=word_segmentation(str,stop_words)#分词并去除停用词
create_wordcloud(word_list) #画出词云
counter = word_frequency(word_list, 10)# 返回前 top_N 个值,如果不指定则返回所有值
print(counter)
plot_chart(counter)#会生成词频图保存在weibo_wordfrq.html中

那么这三种分析具体如何实现呢?接下来笔者将对其进行一一阐述。

微博发布时间统计

要对微博动态内容进行可视化分析,第一步是要将微博动态内容整合到一起,下面这段代码是将用户微博中的所有动态拼接到一起,也就是 main 函数里面的第一行代码所调用的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
复制代码# time_lists,str=get_time_str(uid)# main 函数第一行代码
# 将数据库中的微博动态转化为字符串
def get_time_str(uid):
_, engine = Connect('../conf.yaml') # 连接数据库
conn = engine.connect()
metadata = MetaData(engine)
wb_data = Table('wb_data',metadata, autoload=True)
s = select([wb_data]).where(wb_data.c.uid == uid) #从wb_data表中选择uid对应的数据
res = conn.execute(s)
conn.close()
str = ''
time_lists = []
for row in res:
str += row[2] + '\n'#拼接动态字段
time_lists.append(row[3]) #将时间转化为列表对象,方便后续处理
return time_lists, str

返回的 time_lists 为动态发布时间列表, str 为所有动态拼接到一起的最终字符串.在这里因为是对时间进行统计,所以我们只需要关注 time_lists 这个列表.先来看看这个列表里面存放的具体内容:

1
复制代码[....,'02月02日 29', '01月21日 30', '01月20日 31', '01月10日 32', '01月02日 33', '01月01日 34', '2017-11-30', '2017-11-29', '2017-11-04', '2017-10-29', '2017-10-27',...]

这是我特意取的一段,刚好两种格式的时间都包含在内。这个列表里面包含的时间是从最近的月份和日期到最初的年份-月份-日期.例如上面的 '2017-11-30' 前面的日期格式均是 XX月XX日XX ,而 '01月01日 34' 后面的同理均是 20XX-XX-XX ,排序方式是由近到远 。在这里,我考虑分析每天发布的微博动态数和时间的变化趋势,并且将时间排列为离现在越远离原点越近。

于是便有了下面这段代码:

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
复制代码# plot_create_time(time_lists)# main 函数第二行代码
#画出微博发布时间的统计图
def plot_create_time(time_lists):
recent_time = re.compile(r'\d{2}月\d{2}日',re.S)
long_time = re.compile(r'(\d{4}-\d{2}-\d{2})',re.S)
tmp_lists = []#保存**月**日格式的数据
tmp_nums = []#统计**月**日发帖数量
long_lists = []#保存20**-**-**格式的数据
long_nums = []#统计20**-**-**发帖数量
for t in time_lists:
res = re.findall(recent_time, t)
if(res):#res[0]为**月**日格式的数据
if(not tmp_lists or res[0]!= tmp_lists[-1]):#列表为空或者不与前一个日期重复
tmp_lists.append(res[0])
tmp_nums.append(1)
else:#与前一个日期重复,计数加一
tmp_nums[-1]+=1
else:#res[0]20**-**-**格式的数据
res = re.findall(long_time,t)

if(not long_lists or res[0]!=long_lists[-1]):
long_lists.append(res[0])
long_nums.append(1)
else:
long_nums[-1]+=1
#将时间按照从远到进的顺序排列
tmp_lists.reverse()
tmp_nums.reverse()
long_lists.reverse()
long_nums.reverse()
time_list = long_lists + tmp_lists
time_nums = long_nums + tmp_nums

#调用 pyecharts 包渲染出统计图
chart = Bar('用户微博动态发布时间')
chart.add('动态数', time_list, time_nums, is_more_utils=True,datazoom_range=[10,40],is_datazoom_show=True)
chart.render("weibo_dynamic.html")

于是微博发布时间统计图便画出来了,是一个动态的图,效果如下:

weibo_dynamic.gif

词云分析

要对动态内容进行分析,首先要进行分词处理,并且,我们知道常用词语中有很多语气词等,会影响关键词语的权重,所以在进行分析之前需要去掉这些词语,这种词语在信息检索叫做 停用词 。此外,动态中含有的换行符,html代码等不必要的符号也需要去掉。

所以 main 函数中在得到词云之前有下面四行代码:

1
2
3
4
复制代码with open('data/stop_words.txt') as f:#从保存有停用词的文件中读取停用词
stop_words = f.read().split('\n')
str=format_content(str)# 去掉表情和一些不必要的符号
word_list=word_segmentation(str,stop_words)#分词并去除停用词

其中调用了 word_segmentation 函数,该函数的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
复制代码# 分词并去除停用词
def word_segmentation(content, stop_words):
# 使用 jieba 分词对文本进行分词处理
jieba.enable_parallel()
seg_list = jieba.cut(content)
seg_list = list(seg_list)

# 去除停用词
user_dict = [' ', '哒']
filter_space = lambda w: w not in stop_words and w not in user_dict
word_list = list(filter(filter_space, seg_list))

return word_list

在这里,我使用 jieba 进行分词,再对分词结果进行过滤,去除停用词,并返回该分词结果列表。然后使用 word_list 中的数据来画出词云,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
复制代码#create_wordcloud(word_list) # main 函数第7行代码
#画出词云
def create_wordcloud(content,image='weibo.jpg',max_words=5000,max_font_size=50):

cut_text = " ".join(content)
cloud = WordCloud(
# 设置字体,不指定就会出现乱码
font_path="HYQiHei-25J.ttf",
# 允许最大词汇
max_words=max_words,
# 设置背景色
# background_color='white',
# 最大号字体
max_font_size=max_font_size
)
word_cloud = cloud.generate(cut_text)
word_cloud.to_file(image)

最后得到结果 weibo.jpg ,打开看一看:

weibo.jpg

这就是词云分析的结果,读者可以根据自己需要,比如如果觉得原文和转发出现频率高价值不高可以加入停用词中进行过滤。

词频处理

对微博动态中的词语出现的频率进行统计处理,首先需要词语列表,因为在词云分析中我们已经得到了过滤掉停用词的词语列表 word_list ,所以在这里可以直接拿来使用:

1
2
3
4
5
6
7
8
9
10
11
复制代码#counter = word_frequency(word_list, 10)# main 函数倒数第三行代码
# 词频统计
# 返回前 top_N 个值,如果不指定则返回所有值
# from collections import Counter
def word_frequency(word_list, *top_N):
if top_N:
counter = Counter(word_list).most_common(top_N[0])
else:
counter = Counter(word_list).most_common()

return counter

得到的 counter 内容为词语以及出现的次数组成的列表,例如:

1
复制代码[('感觉', 11), ('代码', 10), ('说', 9),('晚上', 9), ('终于', 7), ('麻蛋', 6), ('写', 6), ('数据', 5), ('学校', 5), ('朋友', 4)]

然后根据得到的词频画图并渲染:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
复制代码# plot_chart(counter)# main 函数最后一行,会生成词频图保存在weibo_wordfrq.html中
#画出词频图,默认为柱状图
def plot_chart(counter, chart_type='Bar'):
items = [item[0] for item in counter]
values = [item[1] for item in counter]

if chart_type == 'Bar':
chart = Bar('微博动态词频统计')
chart.add('词频', items, values, is_more_utils=True)
else:
chart = Pie('微博动态词频统计')
chart.add('词频', items, values, is_label_show=True, is_more_utils=True)

chart.render('weibo_wordfrq.html')

最后得到排名前10位的词语频率统计图:

weibowordfrq.gif

LDA 分析

我们忙活了很久,对用户动态进行了多种分析,但是用户的偏好和个性感觉还是没有把握得很清楚,那么这时候就需要使用一种方法从海量信息中抽取关键部分,最好是能提取出主题,受之前这篇论文[1] 的启发,笔者得知 LDA 便是一种能够满足上述条件的很好的分析方法。

隐含狄利克雷分布(英语:Latent Dirichlet allocation,简称LDA)是一种主题模型,它可以将文档集中每篇文档的主题按照概率分布的形式给出。同时它是一种无监督学习算法,在训练时不需要手工标注的训练集,需要的仅仅是文档集以及指定主题的数量k即可。此外LDA的另一个优点则是,对于每一个主题均可找出一些词语来描述它。

以上说明摘录自维基百科.

本文着眼点在于实践,所以只介绍在 python 中使用 LDA 进行分析的方法,并不阐述 LDA 的原理。更何况,在计算机科学和数据科学的学术讲座中,讲者在介绍到LDA时,都往往会把原理这部分直接跳过去。

该部分代码位于 LDA_Analysis.py ,首先可以看看 main 函数的内容:

1
2
3
4
5
复制代码def main(uid):
wordlists, uid = getwords(uid)#获取分词列表
lda, tf, tf_feature_names, tf_vectorizer = word2vec(wordlists)#将单词转化为向量
Save_Topic_Words(lda, tf_feature_names, uid)#保存主题到数据库
pyLDAvisUI(lda, tf, tf_vectorizer)#根据主题结果进行渲染

第一步便是像之前那样进行分词并过滤停用词,调用了 getwords 函数,代码如下:

1
2
3
4
5
6
7
8
复制代码#获取微博动态数据
def getwords(uid):
_,str = get_time_str(uid) # 将数据库中的微博动态转化为字符串,可以指定uid(conf.yaml里面的)
with open('data/stop_words.txt') as f:
stop_words = f.read().split('\n')
str = format_content(str)
word_list = word_segmentation(str, stop_words) # 分词并去除停用词
return word_list,uid

该函数内容和之前介绍的 Data_analysis.py 中的效果一样,故在此不累述。

然后使用 sklearn 机器学习包中自带的 LDA 处理方法进行处理,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
复制代码#lda, tf, tf_feature_names, tf_vectorizer = word2vec(wordlists)# main 函数第二行代码
#使用LDA进行微博动态主题建模与分析
def word2vec(word_list,n_features=1000,topics = 5):
tf_vectorizer = CountVectorizer(strip_accents='unicode',
max_features=n_features,
#stop_words='english',已经进行过停用词处理故不用重复处理
max_df=0.5,
min_df=10)
tf = tf_vectorizer.fit_transform(word_list)

lda = LatentDirichletAllocation(n_components=topics,#主题数
learning_method='batch',
#样本量不大只是用来学习的话用"batch"比较好,这样可以少很多参数要调
)
#用变分贝叶斯方法训练模型
lda.fit(tf)

#依次输出每个主题的关键词表
tf_feature_names = tf_vectorizer.get_feature_names()

return lda,tf,tf_feature_names,tf_vectorizer #返回模型以及主题的关键词表

最后我们将主题以可视化结果展现出来:

1
2
3
4
5
6
复制代码#pyLDAvisUI(lda, tf, tf_vectorizer) # main 函数中最后一行
#将主题以可视化结果展现出来
def pyLDAvisUI(lda,tf,tf_vectorizer):

page = pyLDAvis.sklearn.prepare(lda, tf, tf_vectorizer)
pyLDAvis.save_html(page, 'lda.html') #将主题可视化数据保存为html文件

得到 lda.html ,为渲染以后的结果:

topic.gif

至此,用户信息分析介绍结束。

源码地址:github.com/starFalll/S…

参考:[1]陆飞.面向社会工程学的SNS分析和挖掘[D].上海:上海交通大学,2013.

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

为什么Goroutine能有上百万个,Java线程却只能有上

发表于 2018-07-13


作者|Russell Cohen
译者|张卫滨
本文通过 Java 和 Golang 在底层原理上的差异,分析了 Java 为什么只能创建数千个线程,而 Golang 可以有数百万的 Goroutines,并在上下文切换、栈大小方面对两者的实现原理进行了剖析。
很多有经验的工程师在使用基于 JVM 的语言时,都会看到这样的错误:

1
2
3
4
5
6
复制代码`[error] (run-main-0) java.lang.OutOfMemoryError: unable to create native thread:` 
[error] java.lang.OutOfMemoryError: unable to create native thread:
[error]     at java.base/java.lang.Thread.start0(Native Method)
[error]     at java.base/java.lang.Thread.start(Thread.java:813)
...
[error]     at java.base/java.lang.Thread.run(Thread.java:844)

呃,这是由线程所造成的OutOfMemory。在我的笔记本电脑上运行 Linux 操作系统时,仅仅创建 11500 个线程之后,就会出现这个错误。

如果你在 Go 语言上做相同的事情,启动永远处于休眠状态的 Goroutines,那么你会看到非常不同的结果。在我的笔记本电脑上,在我觉得实在乏味无聊之前,我能够创建七千万个 Goroutines。那么,为什么 Goroutines 的数量能够远远超过线程呢?要揭示问题的答案,我们需要一直向下沿着操作系统进行一次往返旅行。这不仅仅是一个学术问题,它对你如何设计软件有现实的影响。在生产环境中,我曾经多次遇到 JVM 线程的限制,有些是因为糟糕的代码泄露线程,有的则是因为工程师没有意识到 JVM
的线程限制。

那到底什么是线程?
术语“线程”可以用来描述很多不同的事情。在本文中,我会使用它来代指一个逻辑线程。也就是:按照线性顺序的一系列操作;一个执行的逻辑路径。CPU 的每个核心只能真正并发同时执行一个逻辑线程 [1]。这就带来一个固有的问题:如果线程的数量多于内核的数量,那么有的线程必须要暂停以便于其他的线程来运行工作,当再次轮到自己的执行的时候,会将任务恢复。为了支持暂停和恢复,线程至少需要如下两件事情:

  1. 某种类型的指令指针。也就是,当我暂停的时候,我正在执行哪行代码?
  2. 一个栈。也就是,我当前的状态是什么?栈中包含了本地变量以及指向变量所分配的堆的指针。同一个进程中的所有线程共享相同的堆 [2]。

于以上两点,系统在将线程调度到 CPU 上时就有了足够的信息,能够暂停某个线程、允许其他的线程运行,随后再次恢复原来的线程。这种操作通常对线程来说是完全透明的。从线程的角度来说,它是连续运行的。线程能够感知到重新调度的唯一方式是测量连续操作之间的计时 [3]。

回到我们最原始的问题:我们为什么能有这么多的 Goroutines 呢?

JVM 使用操作系统线程
尽管并非规范所要求,但是据我所知所有的现代、通用 JVM 都将线程委托给了平台的操作系统线程来处理。在接下来的内容中,我将会使用“用户空间线程(user space thread)”来代指由语言进行调度的线程,而不是内核 /OS 所调度的线程。操作系统实现的线程有两个属性,这两个属性极大地限制了它们可以存在的数量;任何将语言线程和操作系统线程进行 1:1 映射的解决方案都无法支持大规模的并发。

在 JVM 中,固定大小的栈
使用操作系统线程将会导致每个线程都有固定的、较大的内存成本
采用操作系统线程的另一个主要问题是每个 OS 线程都有大小固定的栈。尽管这个大小是可以配置的,但是在 64 位的环境中,JVM 会为每个线程分配 1M 的栈。你可以将默认的栈空间设置地更小一些,但是你需要权衡内存的使用,因为这会增加栈溢出的风险。代码中的递归越多,就越有可能出现栈溢出。如果你保持默认值的话,那么 1000 个线程就将使用 1GB 的 RAM。虽然现在 RAM 便宜了很多,但是几乎没有人会为了运行上百万个线程而准备 TB 级别的 RAM。

Go 的行为有何不同:动态大小的栈
Golang 采取了一种很聪明的技巧,防止系统因为运行大量的(大多数是未使用的)栈而耗尽内存:Go 的栈是动态分配大小的,随着存储数据的数量而增长和收缩。这并不是一件简单的事情,它的设计经历了多轮的迭代 [4]。我并不打算讲解内部的细节(关于这方面的知识,有很多的博客文章和其他材料进行了详细的阐述),但结论就是每个新建的 Goroutine 只有大约 4KB 的栈。每个栈只有 4KB,那么在一个 1GB 的 RAM 上,我们就可以有 250 万个 Goroutine 了,相对于 Java
中每个线程的 1MB,这是巨大的提升。

在 JVM 中:上下文切换的延迟
从上下文切换的角度来说,使用操作系统线程只能有数万个线程
因为 JVM 使用了操作系统线程,所以依赖操作系统内核来调度它们。操作系统有一个所有正在运行的进程和线程的列表,并试图为它们分配“公平”的 CPU 运行时间 [5]。当内核从一个线程切换至另一个线程时,有很多的工作要做。新运行的线程和进程必须要将其他线程也在同一个 CPU 上运行的事实抽象出去。我不会在这里讨论细节问题,但是如果你对此感兴趣的话,可以阅读更多的材料。这里比较重要的就是,切换上下文要消耗 1 到 100 微秒。这看上去时间并不多,相对现实的情况是每次切换 10 微秒,如果你想要每秒钟内至少调度每个线程一次的话,那么每个核心上只能运行大约
10 万个线程。这实际上还没有给线程时间来执行有用的工作。

Go 的行为有何不同:在一个操作系统线程上运行多个 Goroutines
Golang 实现了自己的调度器,允许众多的 Goroutines 运行在相同的 OS 线程上。就算 Go 会运行与内核相同的上下文切换,但是它能够避免切换至 ring-0 以运行内核,然后再切换回来,这样就会节省大量的时间。但是,这只是纸面上的分析。为了支持上百万的 Goroutines,Go 需要完成更复杂的事情。

即便 JVM 将线程放到用户空间,它也无法支持上百万的线程。假设在按照这样新设计系统中,新线程之间的切换只需要 100 纳秒。即便你所做的只是上下文切换,如果你想要每秒钟调度每个线程十次的话,你也只能运行大约 100 万个线程。更重要的是,为了完成这一点,我们需要最大限度地利用 CPU。要支持真正的大并发需要另外一项优化:当你知道线程能够做有用的工作时,才去调度它。如果你运行大量线程的话,其实只有少量的线程会执行有用的工作。Go 通过集成通道(channel)和调度器(scheduler)来实现这一点。如果某个
Goroutine 在一个空的通道上等待,那么调度器会看到这一点并且不会运行该 Goroutine。Go 更近一步,将大多数空闲的线程都放到它的操作系统线程上。通过这种方式,活跃的 Goroutine(预期数量会少得多)会在同一个线程上调度执行,而数以百万计的大多数休眠的 Goroutine 会单独处理。这样有助于降低延迟。

除非 Java 增加语言特性,允许调度器进行观察,否则的话,是不可能支持智能调度的。但是,你可以在“用户空间”中构建运行时调度器,它能够感知线程何时能够执行工作。这构成了像 Akka 这种类型的框架的基础,它能够支持上百万的 Actor[6].

结论   

操作系统线程模型与轻量级、用户空间的线程模型之间的转换在不断发生,未来可能还会继续 [7]。对于高度并发的用户场景来说,这是唯一的选择。然而,它具有相当的复杂性。如果 Go 选择采用 OS 线程而不是采用自己的调度器和递增的栈模式的话,那么他们能够在运行时中减少数千行的代码。对于很多用户场景来说,这确实是更好的模型。复杂性可以被语言和库的编写者抽象出去,这样软件工程师就能编写大量并发的程序了。

补充材料

  1. 超线程会将核心的效果加倍。指令流(instruction pipelining)也能增加 CPU 的并行效果。但是就当前来说,它还是 O(numCores)。
    1. 可能在有些特殊场景中,这种说法是不正确的,我想肯定有人会提醒我这一点。
    2. 这实际上是一种攻击。JavaScript 可以检测键盘中断所导致的在计时上的细微差别。恶意的站点用它来监听计时,而不是监听击键。参见:https://mlq.me/download/keystroke\_js.pdf。
    3. Golang 首先采用了一个分段的栈模型,在这个模型中,栈实际上会扩展至单独的内存区域,这个过程中使用非常聪明的记录功能进行跟踪。随后的实现在特定的场景下提升了性能,使用连续的栈来取代对栈的拆分,这很像对 hashtable 重新调整大小,分配一个新的更大的栈,并通过一些非常有技巧的指针操作,所有的内容都能够仔细地复制到新的、更大的栈中。
    4. 线程可以通过调用nice(参见man nice)来标记优先级,从而能够更好地控制它们调度的频率。
    5. Actor 通过支持大规模并发,为 Scala/Java 实现了与 Goroutines 相同目的的特性。与 Goroutines 类似,Actor 调度器能够看到哪个 Actor 的收件箱中有消息,从而只运行那些能够执行真正有用工作的 Actor。我们所能拥有的 Actor 的数量甚至还能超过 Goroutines,因为 Actor 并不需要栈。但是,这也意味着,如果 Actor 无法快速处理消息的话,调度器将会阻塞(因为 Actor 没有自己的栈,所以它无法在 Actor
      处理消息的过程中暂停)。阻塞的调度器意味着消息不能进行处理,系统很快会出现问题。这就是一种权衡。
    6. 在 Apache 中,每个请求都是由一个 OS 线程来处理的,这限制了 Apache 只能有效处理数千个并发连接。Nginx 选择了另外一种模型,一个 OS 线程能够应对上百个甚至上千的并发连接,从而允许更高程度的并发。Erlang 使用了一个类似的模型,它允许数百万 Actor 并发执行。Gevent 为 Python 带来了 greenlet(用户空间线程),它能够实现比以往更高程度的并发性 (Python 线程是 OS 线程)。

原文链接:

https://rcoh.me/posts/why-you-can-have-a-million-go-routines-but-only-1000-java-threads


课程推荐

很多人都听过持续交付能提高效率,但要说实施得多好、多彻底,估计很多人会面面相觑,我将结合我的个人多年实践经验与你分享如何设计、实施以及落地。

限时优惠 45 元,最后两天!

极客时间 戳此订阅 小程序

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Go代码重构:23倍的性能爆增

发表于 2018-07-12

sunsky303‘s随笔 工作、生活、兴趣、理想和思考
Go代码重构:23倍的性能爆增


几周前,我读了一篇名为“ Good Code vs Go Code中的错误代码 ”的文章,作者指导我们逐步完成实际业务用例的重构。

本文的重点是将“坏代码”转变为“良好代码”:更具惯用性,更易读,利用go语言的细节。但它也坚持将性能作为项目的一个重要方面。这引发了我的好奇心:让我们深入挖掘!


该程序基本上读取一个输入文件,并解析每一行以填充内存中的对象。

作者不仅[在Github上](https://github.com/teivah/golang-good-code-bad-code)发布 [了源代码](https://github.com/teivah/golang-good-code-bad-code),他还写了一个惯用的基准。这是一个非常好的主意,就像邀请调整代码并使用命令重现测量:
1
复制代码$ go test -bench =。
1
复制代码
每次执行μs(越小越好)

因此,在我的机器上,“好代码”的速度提高了16%。我们能获得更多吗?

根据我的经验,代码质量和性能之间存在有趣的关联。当您成功地重构代码以使其更清晰且更加分离时,您通常最终会使其更快,因为它不会使之前执行的无关指令变得混乱,并且还因为一些可能的优化变得明显且易于实现。

另一方面,如果你进一步追求性能,你将不得不放弃简单并诉诸于黑客。你确实会刮掉几毫秒,但代码质量会受到影响,因为它会变得更难以阅读和推理,更脆弱,更不灵活。

攀登mount Simplicity,然后降序
这是一个权衡:你愿意走多远?

为了正确确定您的绩效工作的优先顺序,最有价值的策略是确定您的瓶颈并专注于它们。要实现这一点,请使用分析工具!Pprof和Trace是你的朋友:

1
复制代码$ go test -bench =。-cpuprofile cpu.prof $ go tool pprof -svg cpu.prof> cpu.svg

一个相当大的CPU使用率图(点击SVG)

1
复制代码$ go test -bench =。-trace trace.out $ go工具跟踪trace.out

彩虹追踪:许多小任务(点击打开,仅限Chrome)
跟踪证明使用了所有CPU内核(底线0,1等),这在一开始看起来是件好事。但它显示了数千个小的彩色计算切片,以及一些空闲插槽,其中一些核心处于空闲状态。我们放大:

一个3毫秒的窗口(点击打开,仅限Chrome)
每个核心实际上花费大量时间闲置,并在微任务之间保持切换。看起来任务的粒度不是最优的,导致许多上下文切换以及由于同步而导致的争用。

让我们检查一下竞争检测器是否同步是正确的(如果没有,那么我们的问题比性能更大):

1
复制代码$ go test -race PASS

是!!看起来是正确的,没有遇到数据争用情况。测试函数和基准函数是不同的(参见文档),但在这里他们调用相同的函数ParseAdexpMessage,我们可以使用 -race。

“好”版本中的并发策略包括在其自己的goroutine中处理每行输入,以利用多个核心。这是一种合法的直觉,因为goroutines的声誉是轻量级和廉价的。我们多少得益于并发性?让我们与单个顺序goroutine中的相同代码进行比较(只需删除行解析函数调用之前的go关键字)

每次执行μs(越小越好)
哎呀,没有任何并行性,它实际上更快。这意味着启动goroutine的(非零)开销超过了同时使用多个核心所节省的时间。

自然的下一步,因为我们现在顺序而不是同时处理行,是为了避免使用结果通道的(非零)开销:让我们用裸片替换它。

每次执行μs(越小越好)

我们现在从“好”版本获得了大约40%的加速,只是简化了代码,删除了并发(差异)。

使用单个goroutine,在任何给定时间只有1个CPU内核正在工作。
现在让我们看一下Pprof图中的热函数调用:

发现瓶颈
我们当前版本的基准(顺序,带切片)花费86%的时间实际解析消息,这很好。我们很快注意到,总时间的43%用于将正则表达式与(* Regexp).FindAll匹配 。

虽然regexp是从原始文本中提取数据的一种方便灵活的方法,但它们存在缺陷,包括内存和运行时的成本。它们很强大,但对于许多用例来说可能有点过分。

在我们的程序中,模式

1
复制代码patternSubfield =“ - 。[^  - ] *”

主要用于识别以短划线“ - ” 开头的“ 命令 ”,并且一行可能有多个命令。通过一些调整,可以使用bytes.Split完成。让我们调整代码(commit,commit)以使用Split替换regexp:

每次执行μs(越小越好)
哇,这是40%的额外增益!

CPU图现在看起来像这样:

![](https://gitee.com/songjianzaina/juejin_p8/raw/master/img/e17290bbc652b4e7897023280679d7401e769831c40457ffd85af0e3db80ebab)

没有更多正则表达式的巨大成本。从5个不同的功能中分配内存花费了相当多的时间(40%)。有趣的是,总时间的21%现在由字节占.Trim 。

这个函数引起了我的兴趣:我们可以做得更好吗?
bytes.Trim期望一个“ cutset string”作为参数(对于要在左侧和右侧删除的字符),但我们仅使用单个空格字节作为cutset。这是一个例子,您可以通过引入一些复杂性来获得性能:实现您自己的自定义“trim”函数来代替标准库函数。在
自定义的“微调” 的交易,只有一个割集字节。


每次执行μs(越小越好)

是的,另外20%被削减了。当前版本的速度是原始“坏”速度的4倍,而机器只使用1个CPU内核。相当实质!


之前我们放弃了在线处理级别的并发性,但是通过并发更新仍然存在改进的空间,并且具有更粗略的粒度。例如,在每个文件在其自己的goroutine中处理时,处理6,000个文件(6,000条消息)在我的工作站上更快:

每条消息μs(越小越好,紫色并发)
66%的胜利(即3倍的加速),这是好的但“不是那么多”,因为它利用了我所有的12个CPU内核!这可能意味着使用新的优化代码,处理整个文件仍然是一个“小任务”,goroutine和同步的开销不可忽略不计。

有趣的是,将消息数量从6,000增加到120,000对顺序版本的性能没有影响,并且降低了“每个消息的1个goroutine”版本的性能。这是因为启动大量的goroutine是可能的,有时是 有用的,但它确实给go运行时调度程序带来了一些压力。

我们可以通过仅创建少数工作人员来减少执行时间(不是12倍因素,但仍然是这样),例如12个长期运行的goroutine,每个goroutine处理一部分消息:

每条消息μs(越小越好,紫色并发)
与顺序版本相比,大批消息的调优并发性删除了79%的执行时间。请注意,只有在确实要处理大量文件时,此策略才有意义。

所有CPU核心的最佳利用包括几个goroutine,每个goroutine处理相当数量的数据,在完成之前没有任何通信和同步。

选择与可用CPU核心数相等的多个进程(goroutine)是一种常见的启发式方法,但并不总是最佳:您的里程可能会根据任务的性质而有所不同。例如,如果您的任务从文件系统读取或发出网络请求,那么性能比CPU核心具有更多的goroutine是完全合理的。

每条消息μs(越小越好,紫色并发)
我们已经达到了这样的程度,即通过本地化增强很难提高解析代码的效率。现在,执行时间由小对象(例如Message结构)的分配和垃圾收集主导,这是有道理的,因为已知内存管理操作相对较慢。进一步优化分配策略……留给狡猾的读者练习。


使用完全不同的算法也可以带来很大的加速。

在这里,我从这次演讲中汲取灵感

Go - Rob Pike中的词汇扫描
构建自定义Lexer(源)和自定义Parser(
源 )。这是一个概念证明(我没有实现所有的角落情况),它不像原始算法那么直观,并且错误处理可能很难正确实现。但是,它比以前的优化版本节俭快30%。

每条消息μs(越小越好,紫色并发)
是的,与初始代码相比,这是一个23倍的加速因子。


这就是今天,我希望你喜欢这个旅程。以下是一些免责声明和外卖:

  • 可以使用不同的技术在多个抽象级别上提高性能,并且增益是乘法的。

  • 首先调整高级抽象:数据结构,算法,适当的解耦。稍后调整低级抽象:I / O,批处理,并发,stdlib使用,内存管理。

  • Big-O分析是基础,但通常 不是使给定程序运行得更快的相关工具。

  • 基准测试很难。使用分析和基准来发现瓶颈并深入了解您的代码。请记住,基准测试结果并不是最终用户在生产中遇到的“真实”延迟,并且将这些数字与盐分相提并论。

  • 幸运的是,工具(Bench, Pprof ,Trace, Race detector ,Cover)使性能探索变得平易近人且令人兴奋。

  • 编写好的相关测试并非易事。但它们非常珍贵,可以帮助“保持正常”,即重构,同时保留原始的正确性和语义。

  • 花一点时间问问自己“足够快”的速度有多快。不要浪费时间过度优化一次性脚本。考虑到优化伴随着成本:工程时间,复杂性,错误,技术债务。

  • 在模糊代码之前要三思而后行。

  • Ω(n²)及以上的算法通常很昂贵。

  • O(n)或O(n log n)或更低的复杂度通常很好。

  • 的隐性因素是不容忽视的!例如,文章中的 所有改进都是通过降低这些因素来实现的,而不是通过改变算法的复杂性类来实现的。

  • I / O通常是一个瓶颈:网络请求,数据库查询,文件系统。

  • 正则表达式往往是比实际需要更昂贵的解决方案。

  • 内存分配比计算更昂贵。

  • 堆栈中的对象比堆中的对象便宜。

  • 切片可用作替代昂贵的重新分配的替代方案。

  • 字符串对于只读使用(包括重新设置)是有效的,但对于任何其他操作,[]字节更有效。

  • 内存局部性很重要(CPU缓存友好性)。

  • 并发和并行是很有用的,但要正确起来很棘手。

  • 当挖掘更深层次和更低层次时,有一个“玻璃地板”,你真的不想在其中突破。如果你渴望asm指令,内在函数,SIMD …也许你应该考虑去做原型,然后切换到低级语言来充分利用硬件和每纳秒!

    谋胆并重
    posted on 2018-07-11 18:50 sunsky303 阅读(640) 评论(3) 编辑 收藏

评论

#1楼 2018-07-11 19:01 博客园团队

您好,文中的图片无法显示,麻烦上传一下图片 支持(0)反对(0)http://pic.cnblogs.com/face/35695/20140318223943.png

#2楼[楼主] 2018-07-11 20:06 sunsky303

@ 博客园团队
传了 支持(0)反对(0)http://pic.cnblogs.com/face/420532/20170329141435.png

#3楼40183882018/7/11 22:24:40 2018-07-11 22:24 牛腩

支持支持 支持(0)反对(0)http://pic.cnblogs.com/face/u41249.jpg 刷新评论刷新页面返回顶部 注册用户登录后才能发表评论,请 登录 或 注册,访问网站首页。
【推荐】超50万VC++源码: 大型组态工控、电力仿真CAD与GIS源码库!
【推荐】如何快速搭建人工智能应用?
【大赛】2018首届“顶天立地”AI开发者大赛
腾讯云0710 最新IT新闻:
· 小米前高管巴拉借IPO大赚一笔 持股价值2.09亿美元
· App Store十岁了,这里是和App有关的十个小秘密
· ARM和RISC-V公然开撕,GNOME之父指责ARM
· Facebook为学者提供1PB数据:供其研究虚假信息
· 请放过年轻人、放过我爸妈:知识付费,毒手正下沉
» 更多新闻… 最新知识库文章:
· 危害程序员职业生涯的三大观念
· 断点单步跟踪是一种低效的调试方法
· 测试 | 让每一粒尘埃有的放矢
· 从Excel到微服务
· 如何提升你的能力?给年轻程序员的几条建议
» 更多知识库文章…
Powered by:
博客园
Copyright © sunsky303

导航

  • 博客园
  • 首页
  • 新随笔
  • 联系
  • 订阅订阅
  • 管理

统计

  • 随笔 - 148
  • 文章 - 1
  • 评论 - 30
  • 引用 - 0

公告

搜索

常用链接

  • 我的随笔
  • 我的评论
  • 我的参与
  • 最新评论
  • 我的标签

最新随笔

  • 1. Go代码重构:23倍的性能爆增
  • 2. 分布式系统的一致性协议之 2PC 和 3PC
  • 3. 驳2B文 “我为什么放弃Go语言”
  • 4. 一切皆Socket
  • 5. swoole深入学习 2. tcp Server和tcp Client
  • 6. linux进程内存布局
  • 7. Dnsmasq加速本地DNS请求
  • 8. dnsmasq详解&手册
  • 9. 北京今天的天气真棒
  • 10. DNS详解: A记录,子域名,CNAME别名,PTR,MX,TXT,SRV,TTL

我的标签

  • 2017(2)
  • 2017年 golang、python、php、c++、c、java、Nodejs 性能 对比(1)
  • c(1)
  • c++(1)
  • Design Patterns(1)
  • golang(1)
  • java(1)
  • Nodejs(1)
  • php(1)
  • python(1)
  • 更多

随笔档案(149)

  • 2018年7月 (6)
  • 2018年6月 (24)
  • 2018年5月 (21)
  • 2018年4月 (12)
  • 2018年3月 (13)
  • 2018年2月 (1)
  • 2018年1月 (14)
  • 2017年12月 (14)
  • 2017年11月 (2)
  • 2017年10月 (7)
  • 2017年9月 (4)
  • 2017年8月 (5)
  • 2017年6月 (3)
  • 2017年5月 (2)
  • 2017年3月 (8)
  • 2017年2月 (3)
  • 2013年12月 (1)
  • 2013年9月 (2)
  • 2013年8月 (1)
  • 2013年1月 (2)
  • 2011年6月 (1)
  • 2011年3月 (3)

文章档案(1)

  • 2017年3月 (1)

积分与排名

  • 积分 - 27900
  • 排名 - 15458

最新评论

阅读排行榜

评论排行榜

推荐排行榜

–written by sunsky303

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…888889890…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%