TCP可以使用两次握手建立连接吗?

人们经常说TCP建立连接需要三次握手,断开连接需要四次挥手。有次我抓包发现,断开连接只抓到了三个包。

上图中可以看到,20号是一方发送的TCP包,FIN 表示“我这边没有数据要传输了”,然后经过20、21、22三个包,双方断开连接。回顾一下教科书上的断开连接过程:

于是可以看到,上图中抓的包实际上是将ACK和FIN组合成一个包发送了,所以三个包就可以断开连接。维基百科的资料表示三个包断开连接也是可以的。

也可以通过测三次握手关闭连接。主机A发出FIN,主机B回复FIN & ACK,然后主机A回复ACK。

TCP要求,每一个发出去的包必须收到确认(ACK)。但实际上,并不会对每一个包发回一个单独的ACK,因为ACK和ACK标志位和数据段在不同的位置,所以数据和ACK是可以一同发的。Stack Overflow有一个很好看的ASCII图,我也贴一下。

TCP要求包收到ACK确认:

但实际上:

但实际ACK的情况还要复杂……我就不跑题了。

于是我就有了一个疑问,就是能否将第三次ACK的传递省略,等下一次有数据传送的时候再带上ACK传过去,不可以吗?

首先回顾一下TCP握手的原理,建立连接的目的是:1.表达一方企图建立连接的意图(SYN)2.表达知道对方的意图(ACK)。其实就是A发送SYN表示自己想要建立连接,B发送ACK表示知道了。然后B发送SYN表示想建立连接,A发送ACK表示自己知道了。向我们上面断开连接的三个包一样,B可以同时发送SYN+ACK,这样就是我们现在看到的三个包。

那么如果不发送第三个包,等下一次传输数据的时候带上ACK呢?

网上的解释基本都是这样的:

“已失效的连接请求报文段”的产生在这样一种情况下:client发出的第一个连接请求报文段并没有丢失,而是在某个网络结点长时间的滞留了,以致延误到连接释放以后的某个时间才到达server。本来这是一个早已失效的报文段。但server收到此失效的连接请求报文段后,就误认为是client再次发出的一个新的连接请求。于是就向client发出确认报文段,同意建立连接。假设不采用“三次握手”,那么只要server发出确认,新的连接就建立了。由于现在client并没有发出建立连接的请求,因此不会理睬server的确认,也不会向server发送数据。但server却以为新的运输连接已经建立,并一直等待client发来数据。这样,server的很多资源就白白浪费掉了。采用“三次握手”的办法可以防止上述现象发生。例如刚才那种情况,client不会向server的确认发出确认。server由于收不到确认,就知道client并没有要求建立连接。”

但并没有彻底解决我的疑问。比如A和B想建立连接,上述的解释只是意图保护B的资源,如果我是A,那么我这边是的实现是否可以做成不发送ACK而是带数据一同发送过去?

这里我们可以考虑一下如果有第三次包,但是第三次包ACK丢失的情况:

A发完ACK,单方面认为TCP为 Established状态,而B显然认为TCP为Active状态:

  1. 假定此时双方都没有数据发送,B会周期性超时重传,直到收到A的确认,收到之后B的TCP 连接也为 Established状态,双向可以发包。
  2. 假定此时A有数据发送,B收到A的 Data + ACK,自然会切换为established 状态,并接受A的 Data。
  3. 假定B有数据发送,数据发送不了,会一直周期性超时重传SYN + ACK,直到收到A的确认才可以发送数据。

(摘自知乎

所以从这里看来,如果发起连接放有意不发送ACK,而是等下一次带上数据发送,也是能够成功建立连接的。我自己认为这里面没有逻辑问题,在网上搜索了一些资料,想得到确认。

果然,有人在wireshark论坛贴了一个问题,说自己抓到的包在TCP三次握手中,第三次ACK带着数据。有人回复说:如果在收到对方的第二次包SYN+ACK之后很快要发送数据,那么第三次包ACK可以带着数据一起发回去。这在Windows和Linux中都是比较流行的一种实现。但是数据的大小在不同实现中有区别。

而在TCP fast open中,是明确说最后的ACK是可以带有数据的:

更让我惊讶的是,在原先的RFC 793描述的TCP连接建立过程中,甚至第一个包SYN也是可以带有数据的!这篇博客中有描述。但如果这样做的话,直到连接建立完成,第一次包带的数据都不能释放。所以我觉得现在的实现都没有这样做的原因是,包是节省了一些,但维护连接建立的成本更大了,更容易被SYN洪水攻击。

所以综上,TCP能不能通过两个包建立连接?不能。但是第三次ACK可以带有数据。但是ACK是必须发的,必须让对方知道自己的连接建立意图。如果收到对方第二个包SYN+ACK马上有数据要发送,那么就可以发送第三次ACK+数据;如果没有数据要发送,那么要给对方回复一个ACK,完成连接的建立。

 

有关爬虫框架的设计的一些备忘

我们公司一直在使用自己设计的爬虫框架,这么做的初衷是让框架保持简单,新手可以很快写一个爬虫工作。但是也遇到过许许多多的问题。这里记一下,如果要重新写一个爬虫框架的话,这些问题必须好好处理,可以少走很多弯路。

1.任务队列

爬虫肯定是要用到任务队列的。将每个页面的抓取分成任务,可以让任务之间互不影响,方便监控,也方便调度。任务队列是个很大的话题,我们用的是beanstalkd,它轻量,速度快,但是也有一些缺点。我对任务队列没有什么研究,这里就不比较各个方案了。但是爬虫需要的任务队列要求如下:

  1. 必须能够快速切换。一个任务就是一个网络请求+解析结果。这样划分比较合理,可以看到对某个页面的抓取状态。这就要求任务队列的吞吐量很高,要频繁的放、取任务。我们每天的抓取任务在千万级别。
  2. 支持分组(tube)。这样就可以对不同的爬虫任务循环执行。单队列可能造成对抓去网站DoS攻击,很不友好。
  3. 支持优先级。
  4. 支持重试,延迟重试。但不是必须,这个可以在爬虫队列实现。

2.数据的去重

如果没有考虑到数据去重,那么每一次执行爬虫任务都会带来新数据,这肯定是不合理的,所以爬虫每次运行都要考虑到用某种方式判定是否是一条已经存在的数据。

比较通用的方法是通过hash来判定重复。我们就是用的这种方案:对每一条数据挑一些字段(全文hash很危险,经常造成数据重复。比如有一个字段变化了,但其实应该作为一条数据处理)来计算hash值,存入数据库。在数据库中插入的时候根据hash值是否存在。

这种方案存在几个问题:

  1. hash碰撞。如果手动实现处理hash碰撞的情况,又是一个复杂的过程。目前我们是没有考虑这个的,因为我们的每一个目标网站数据量不是很多,对每一个目标网站使用一个collection存放,就大胆地假设了数据不会存在hash碰撞的情况。但是这可能是一个隐患。可以看一下这个项目,很有意思,可以对两个完全不同的pdf修改让它们的sha1结果一模一样但是不改变显示内容。
  2. 对数据库的压力。爬虫集群如果规模很大,那么意味着每一次爬虫任务都会去查询数据库,我们的Mongo集群经历过多次压力过大的问题。

其实我觉得,比较合理的方式是建立一种“爬虫任务–页面–数据”之间的关系。根据这种关系来去重,比如如果在抓取页面的时候就能判断过这个页面已经被处理过。那么就可以认为页面上的数据已经存在,可以节省很多操作。

这就需要下面下载与处理的分离。

3.下载与处理分离

这里想说的是,将下载和处理页面解耦。这样做的好处是你可以部署一个网络比较好或者有特殊要求的(比如国外ip,国内ip)集群来专门下载网页,然后用一些配置高的机器用来处理网页,这可能会节省一些vps租金。因为众所周知,爬虫比较耗费的资源是网络请求和ip。这样做的缺点是会增加框架的复杂程度。

此外,还有一个好处是上面提到的,下载好的页面可以做简单的判断,如果是已经处理过的页面并且没有发生更新,那么就直接结束这个任务。如果是个新的页面,就要交给后面的某个处理程序继续处理,像一个管道一样。

而且,还可以对下载步骤(或处理步骤)做一些Hook,如果我想保存下载所有的下载过的页面,就可以在下载完成的时候,将页面同时交给一个线程来上传到S3 bucket,同时交给一个线程做抽取任务。

这种解耦对监控来说也应该方便很多。

4.监控

需要监控的地方:

  1. 任务队列中的状态
  2. 每个任务的执行情况,比如异常等
  3. 数据的增长
  4. 追踪每个任务执行带来的数据

最好监控到每一次任务执行的参数,比如从任务队列中获得的任务信息,执行时间等。以及任务失败时候的异常,这样发现爬虫挂掉就很方便。另外需要特别注意的是,任务没有异常抛出,但实际上一次任务没有带来数据。就好比程序实际是错的,但是实际跑一次并不会挂掉。这种情况很难避免,即使你处理异常的时候非常小心,也有可能捕获了不想捕获的异常,唯有从监控上下功夫。最好是将任务执行和数据存储关联起来。在存数据的时候,记录这条数据是哪一个任务带来的。如果一次任务没有带来数据,也应该有警告。当然如果目标就是抓取一个列表页面,提取url,没有数据是正常的,这种情况应该可以被过滤掉。总之重点是,一个任务应该和进来的数据有关联,可以查看一个任务带来多少数据,亦可以查看某条数据是由哪个任务带来的。

第1条和第3条可以使用Grafana等组合Metric来实现。

目前来看,ElasticSearch用来做日志分析挺不错的。其他没有使用过。

暂时就想到这个,最近经常会有一个动手写个框架的冲动。以上只是个人的经验和想法,并不一定是合适的方案,欢迎讨论。

 

Vim:快速复制和替换

用Vim写代码经常遇到的一个操作是:复制一个单词(或其他text object),然后到下一个(多个)地方用复制的单词替换某个单词。我以前在这个简单的操作上浪费了很多时间……因为yyank之后,用d+p来粘贴,会粘贴出来d删除的内容……好吧,今天学到两种更快速的方法,记一下,以及原理。

第1种:选中文本并替换

操作步骤如下:

  1. 使用yiwyank(复制的意思)要复制的文本
  2. 移动到要替换的文本
  3. viwp使用yank的文本替换选中的文本
  4. 移动到下一个需要替换的文本
  5. viw"0p使用步骤1yank的文本再次替换

首先要了解的是Vim中的寄存器概念。Vim有很多寄存器,顾名思义,其实和CPU的寄存器差不多意思,你可以将内容临时存放在寄存器里面。在命令行模式下输入:reg可以看到寄存器的名字以及目前保存的内容。例如:使用"ayy将会把当前行yank(复制)到a寄存器。"ap可以粘贴出a寄存器的内容。

"寄存器是默认使用的寄存器。所以说,无论是用y命令复制,还是用d删除,还是我们使用viw”0p命令覆盖选中内容,内容都会保存到”寄存器。p粘贴默认也是粘贴的”寄存器的内容。1-9寄存器保存了删除的行,使用dd删除行就会保存到1寄存器,然后再删除一行,1寄存器就会被更新,之前1寄存器的内容就会移动到2,以此类推。0表示最近yank或删除的,也就是说,无论是y命令还是d命令都会将“最近”的文本保存到0寄存器。这就是第5步要使用"0p的原因,因为"寄存器的内容在第3部已经被覆盖了,所以我们要使用“最近yank的内容”就要指定0寄存器。关于所有寄存器的种类和解释,可以参考这篇文章,解释的很好。也可以看Vim的文档: :help reg

另外一个细节是使用了yiw来yank一个单词。这是用了Vim的文本对象,这样光标无论在单词首,还是在单词里面,都可以快速复制整个单词,而不用选中再复制。文本对象是Vim很强大的一个地方,这篇博客对文本对象介绍的很好

第2种:使用.重复操作

步骤如下:

  1. 使用yiwyank要复制的单词
  2. 移动到要替换的位置
  3. ciw CTRL-R 0 ESC 进入编辑模式,使用CTRL-R快捷键在插入模式中粘贴出寄存器0的内容并退回到Normal模式
  4. 移动到下一个要替换的位置
  5. 使用.重复步骤3的操作

这个用到了CTRL-R,这个组合键在Normal模式下是Redo的意思(重做,和Undo是对立的,说白点就是和CTRL-Z对立),但是在插入模式(其实在命令模式同样有效)下就变成了强大的“粘贴寄存器内容到当前光标处”的意思。这里有个不错的、简短的解释

另外使用了.进行重复操作。这个点表示“重复上次在Normal模式下的操作”,也就是步骤3的全部。

最后,如果对整行进行替换的话,可以对方法1进行改良,使用V来选中整行。

替换整行的步骤如下:

  1. yyyank要复制的一行
  2. 移动到要替换的行
  3. 使用Vp选中当前行并进行替换
  4. 移动到下一个要替换的行
  5. 使用V"0p替换一行。
 

pytest插件开发笔记

上一篇博客解决了写爬虫测试时候的一个痛点:复制粘贴太多的重复代码。可还有一个比较烦人的地方,即使test的代码可以自动生成,但还是需要人工去找到底什么地方出现了外部请求,如果 检查外部请求->下载对应的资源并生成mock的代码这部分能够自动完成就好了。今天下手搞了下。

我们的CI是通过pytest的conftest强制必须mock掉HTTP请求的。所以我想这个也可以通过pytest的插件机制来解决。于是就写了一个pytest插件来搞。

Github地址:https://github.com/laixintao/pytest-mock-helper

Pypi: https://pypi.org/project/pytest-mock-helper/

写这个插件花了大约两个小时,原理非常简单。在pytest启动的时候将requests库的requests.adapters.Adapter.send方法替换掉,加入我们要做的逻辑。这么做的灵感来自于另外一个pytest插件:pytest-blockage。这个插件的作用是让pytest强制block掉HTTP请求。和我们在conftest中做的事情一样,但是插件更合理一些,因为我们在不同的项目中就要复制conftest中的代码。不过要注意的是这个插件很久不更新了,不支持Python3。所以如果有类似的需求(在CI中强制Mock HTTP请求)建议使用我的插件。

能如此快速写出一个插件要得益于pytest超赞的文档啊。writing_plugins这一页写的很详细。有一些需要注意的地方,在这篇博客中我记一下:

插件载入的顺序:

  1. 内置的_pytest文件夹中的插件
  2. 外部安装的插件。通过搜索setuptools提供的entry_points查找pytest11,这个很有意思的,不知道为什么会是pytest11,看起来像是开发者随手写的。如果我们自己写插件,就要在setup.py中提供一个pytest11的entry_points
  3. 载入conftest.py

快速参考一些插件:

  1. 文档提供了一个非常简单的Yaml文件测试的插件代码,源代码一看就能懂
  2. 默认插件的源代码
  3. 这里有一个搭建在Heroku上面的pytest兼容性列表,列出了第三方写的插件。这个我猜是监控的pypi,因为我自己写的插件昨天上传到了pypi,今天就在这个列表看见了。不过它判断是否兼容应该是判断的setup.py中的classifiers。我啥也没写就被无情地判断成了不兼容Pyhton27也不兼容Python3。
  4. 一个工具cookiecutter-pytest-plugin,用来生成一个插件的模板。(我不是很推荐这种东西,刚开始用个最小的能跑起来的东西就可以了)

另外发现往pypi.python.org上传东西已经过时了,Api会返回Upload failed (410) Gone。现在正处于迁移的过程,新的包应该向pypi.org提交。而且应该用twine上传。

新的pypi酷炫多了,Python社区在迭代方面真是勇敢啊。都是好事儿。

 

Vim补全代码块神器Ultisnips

爬虫项目中的testcase有很多重复的代码,一般情况都是:mock掉发向服务器的请求 -> 初始化数据库 -> 模拟抓取过程 -> 检查副作用,比如对比存进来的数据等。这些代码不被调用,只是保证之前的代码正常工作,所以我觉得这种“复制、粘贴”编程是可以接受的。而且这种复制粘贴让每一个testcase更直观,抽象度不高。当CI炸了的时候可以很快定位到问题。

以前的做法是复制之前的一个testcase然后修改。这样很不爽。

后来写了一个代码生成器,每次运行命令都自动生成一段代码,然后去修改它。这样的好处是可以在代码生成器做一些事情,比如下载要处理的网页,自动生成运行目标测试的命令等。缺点是每次运行这个生成器都需要一些参数,而且没有和编辑器结合。

所以我觉得解决这个问题最好的办法还是编辑器原生的功能,Vim(一般的代码编辑器都会有这个功能)有Snippet代码片段的功能。自动插入一段代码,然后你可以在placeholder之间跳转修改,非常方便。

SirVer/ultisnips 效果

我根据参考[1]的推荐选择了Ultisnips。安装过程如下:

这是Github上官方的安装。我没有安装vim-snippets,这个插件是一些常用的片段,由网友贡献的。大体看了一下Python的,大都比较实用。不过我觉得用这些玩意儿很容易把Python写的跟Java似的,所以没弄这些。我只要自定义的想补全的片段就够了,其余的可以手写,毕竟写了这么长时间Python了没用这个也没感觉不适。

第三部分是对快捷键的设置。这三个let分别对应:触发展开片段的键、跳到下一个占位符的键,跳到上一个占位符的键。由于tab和YCM补全插件冲突,以及我认为Linux所有的编辑器和命令行都默认F是向前,B是向后的默契,所以我的设置这样的:

读取Ultisnips的路径是~/.vim/UltiSnips。但是也可以自定义。

写UltiSnips很简单,可以参考vim-snippets中的一个:

可以看到,大致的语法是:

这样在编辑器中打出<name>的时候,可选的snippets就会展开,然后用<tab>选择到该名字上(<shift> + <tab>可以向上选择),按你定义的“触发”快捷键就可以将<body>替换到编辑器中。

其中,placeholder在<body>中可以这样规定: ${2:var}。数字用来标志placeholder的id,用于跳转。另外很好用的一点多光标编辑:如果在一个 $2上面编辑,那么会改动所有的$2。

更高级的功能可以仔细阅读:help

对了,在编辑器外也要经常用一些片段,比如mongo查询等,推荐一个剪切板管理工具:Clipmenu,可以将常用的Snippet放到里面,随时取出来粘贴。

参考:

  1. http://mednoter.com/UltiSnips.html