Apr 27
这周换换口味,记录一下去年踩的一个大坑。 



大概是去年8月份,那会儿我们还在用着64GB的“小内存”机器。

由于升级一次版本需要较长的时间(1~2小时),因此我们每天只发一次车,由值班的同学负责,发布所有已merge的commit。 

当天负责值班的我正开着车,突然收到 Bytedance-System 的夺命连环call,打开Lark一看:

引用

[规则]:机器资源报警
[报警上下文]:
 host: 10.x.x.x
内存使用率: 0.944
[报警方式]:电话&Lark


打开ganglia一看,更令人害怕:
Apr 20

TCP#2: 西厢记和西厢计划 不指定

felix021 @ 2020-4-20 17:39 [IT » 网络] 评论(0) , 引用(0) , 阅读(2076) | Via 本站原创
TCP#2: 西厢记和西厢计划

引用
自那日听琴之后,多日不见莺莺,张生害了相思病,趁红娘探病之机会,托她捎信给莺莺,莺莺回信约张生月下相会。夜晚,小姐莺莺在后花园弹琴,张生听到琴声,攀上墙头一看,是莺莺在弹琴。急欲与小姐相见,便翻墙而入,莺莺见他翻墙而入,反怪他行为下流,发誓不再见他,致使张生病情愈发严重。

《西厢记》



上篇《TCP:学得越多越不懂》发出来以后,有朋友很委婉地说:“如果能结合现实生产场景会有意义一点。”

经过深刻的反思,我决定虚心接受建议,写一点理论结合实践的内容。


# == 回忆杀 ==

曾经在猫扑和天涯冲浪的网虫应该都还记得,谷歌当时还是Goooooogle,是可以直接访问的。

但是如果想搜索一些奇怪的词汇(比如███),一点击"手气不错",浏览器马上就会显示无法访问,并且这个现象会持续几分钟。

点击在新窗口中浏览此图片

于是很多小伙伴就换到一个号称自己更懂中文的搜索引擎了。

(该爬虫当年有个广告拍得不错:https://v.qq.com/x/page/r0137s2op5j.html

作为一个曾被新自由主义(Neoliberalism)洗脑的年轻人,我在寻找“自由”的路上发现了墙的存在,也知道了这是方校长的杰作。

但是墙到底是个什么样的存在呢?



# == 防火墙 ==

我们的防火墙,其名源自《The Great Firewall of China: How to Build and Control an Alternative Version of the Internet》这本书。

虽然名字叫防火墙(Firewall,简称FW),但严格来说,(在早期)它其实是一个入侵检测系统(Instrusion Detection System,简称IDS)。

和FW不同的是,IDS是监听设备,不需要部署在链路中间,只要能把流量旁路引出供它分析即可。

点击在新窗口中浏览此图片

通过旁路分析,IDS可以在不影响现有流量的情况下部署(只要路由器/交换机上有镜像端口即可),在IDS出现异常时(例如在流量高峰IDS设备性能不足时 )也不会导致网络中断。

曾经有人发现,在流量特别大的时候,墙的检测功能有时会失效,因此推测其是旁路引流进行分析的(符合IDS的特征)。

既然是旁路的,就无法直接Drop数据包,为了达到阻断通信的目的,需要利用协议的特性来实现。


# == RST大法 ==

看了上篇《TCP:学得越多越不懂》的同学,对报文的控制位里的 RST 可能还有点印象,在遇到异常情况时,可用于通知对方重置连接(细节详见RFC 793):

引用
If the receiving TCP is in a non-synchronized state (i.e. SYN-SENT, SYN-RECEIVED), it returns to LISTEN on receiving an acceptable reset. If the TCP is in one of the synchronized states (ESTABLISHED, FIN-WAIT1, FIN-WAIT2, CLOSE-WAIT, CLOSING, LAST-ACK, TIME-WAIT), it aborts the connection and informs its user

https://tools.ietf.org/html/rfc793


有些同学可能像我一样懒得读英文原文,所以翻译一下:

* 如果连接状态处于“非连接完成”状态(例如SYN-SEND, SYN-RECEIVED),当收到reset时会将状态返回LISTEN;

* 如果TCP状态是 ESTABLISHED, FIN-WAIT-1, ..., LAST-ACK, TIME-WAIT 其中之一时,放弃连接并通知用户。

忘了上述状态含义的话,可以再回顾下这张状态流转图:

点击在新窗口中浏览此图片
(tcp连接状态图,截取自rfc 793)

这就是上篇里提到的“我们敬爱的防火墙很爱用它”的原因了:

当检测到“入侵行为”时(例如HTTP报文中出现了███)发送RST,按照RFC 793规范的TCP协议栈实现,收到RST后就应当放弃本次连接。

于是你就在浏览器上看到连接被重置(reset)了。



# == 反RST大法 ==

那么,如果我忽略RST包,不就可以不被墙欺骗吗?

实际上,用 iptables 来实现这一点很简单:

$ iptables -A INPUT -p tcp --tcp-flags RST RST -j DROP


很不幸,方校长的团队对此的解决方法也非常简单,只要向双方都发送RST包就可以了。

当然如果在服务器一端也忽略RST,就可以成功绕过墙的忽悠——据说剑桥大学有人实验验证过,确实可行。

可惜的是,用户通常没法控制服务器端忽略RST,因此这个方法的实用价值不高。

但是这个思路为西厢计划做好了铺垫。



# == 西厢计划 ==

我看到这个项目的名字的时候 ,真佩服作者的脑洞。

了解这个计划的原理之后,就更佩服作者的脑洞了。

前面说到,墙是在检测到某个关键词的时候才会发送RST包。

为了检测关键词,它需要工作在应用层(HTTP协议)。

而为了工作在应用层,它需要维护TCP连接的状态。

由于那时的设备性能比较弱(所以会出现高峰期检测失效的情况),为了提高吞吐量,方校长团队的方案是:实现一个简化的TCP栈。

RFC 793规范中定义了很多有效性检测,例如检测序列号是否有效来过滤old duplicates等,以保证通信的可靠性。

但这不是墙的需求,因此可以去掉很多规则,从而提高分析性能。

那么,如果我可以欺骗墙,这个连接已经被关闭,那么后续该连接的包就会被墙认为是网络中滞留的无效包,绕过关键词检测。

具体该怎么办呢?



# == 第一阶段 ==

上篇提到了一个细节:

引用
虽然ISN=4000,但是发送方发送的第一个包,SEQ是4001开始的,TCP协议规定 SYN 需要占一个序号(虽然SYN并不是实际传输的数据),所以前面示意图中ACK的seq是 x+1 。同样,FIN也会占用一个序号,这样可以保证FIN报文的重传和确认不会有歧义。

TCP:学得越多越不懂
https://mp.weixin.qq.com/s/xyPUEFUr_v9sSKKqlBkI7w



我们知道,在三次握手的最后一步,A本应发送一个ACK(seq=y+1)。

但如果这时候 A 发送了一个 FIN 呢?

B收到以后,由于此时连接尚未建立,会直接忽略这个包。

点击在新窗口中浏览此图片

而墙实现的TCP栈比较简陋,它认为A已经关闭了链接,因此A后续发送的包就不会再触发关键词检测。

但是注意,TCP是双向的,虽然A主动关闭连接,但是B仍然可能有数据要发送(划重点:面试题“为什么TCP断开连接需要4次”的答案),因此还需要欺骗墙说在B这侧也终止链接了。

这又该怎么办呢?



# == 第二阶段  ==

显然我们不能让服务器直接发一个FIN,否则这个连接就真完了。

幸运的是,RFC 793给了一个“梯子”:

引用
If the connection is in any non-synchronized state (LISTEN, SYN-SENT, SYN-RECEIVED), and the incoming segment acknowledges something not yet sent (the segment carries an unacceptable ACK), or ...(省略)..., a reset is sent.

Reset Generation, RFC 793 [Page 35]


翻译:如果连接处于“非连接完成”状态,收到一个无效的ACK,应当发出一个reset。

如果A在三次握手的最后一步,没有按规范要求发送ACK(seq=y+1),而是发送ACK(seq=y),那么B在收到以后就会按照协议的要求回复一个RST:

点击在新窗口中浏览此图片

这时我们可以在 A 上用“反RST大法”,忽略服务端返回的RST,这个连接就不受影响。

但是墙的TCP栈认为客户端会按照协议终止连接,于是就不再有必要检测服务端后续的报文了。


# == 大结局 ==

从此张生和崔莺莺过上了幸福的生活。

方校长的团队当然不会放任这种事情的发生,西厢计划没过多久就失效了。

随着技术的进步、性能的提升,现在墙似乎已经集成到了链路中、可以直接DROP数据包,不再需要RST大法了。

不过为了业务需要,企业可以向电信主管部门申请VPN用于正常的生产经营。

例如字节跳动,为了建设21世纪数字丝绸之路,通过技术出海,在40多个国家和地区排在应用商店总榜前列,包括韩国、印尼、马来西亚、俄罗斯、土耳其等“一带一路”沿线的主要国家。

如果你也想过上幸福的生活,不妨投个简历,一起为一带一路做贡献吧。

关于字节跳动面试的详情,可参考我之前写的《程序员面试指北:面试官视角》

https://mp.weixin.qq.com/s/Byvu-w7kyby-L7FBCE24Uw

# ~ 投递链接 ~

网盟广告(穿山甲)-后端开发(上海)
https://job.toutiao.com/s/sBAvKe

网盟广告(穿山甲)-后端开发(北京)
https://job.toutiao.com/s/sBMyxk

网盟广告(穿山甲)-广告策略研发(上海)
https://job.toutiao.com/s/sBDMAK

其他地区、其他职能线
https://job.toutiao.com/s/sB9Jqk


# 参考文章

[1] “西厢计划”原理小解
https://blog.youxu.info/2010/03/14/west-chamber/

[2] 从Linux协议栈代码和RFC看西厢计划原理
https://blog.csdn.net/dog250/article/details/7246895

[3] RFC 793 - TRANSMISSION CONTROL PROTOCOL
https://tools.ietf.org/html/rfc793
Apr 6

TCP:学得越多越不懂 不指定

felix021 @ 2020-4-6 13:25 [IT » 网络] 评论(0) , 引用(0) , 阅读(2240) | Via 本站原创
周末小课堂又开张了,这次我们来聊一聊TCP协议。


== 握手 ==

多少有点令人意外的是,大多数程序员对TCP协议的印象仅限于在创建连接时的三次握手。

严格地说,“三次握手”其实是一个不太准确的翻译,英文原文是 "3-way handshake",意思是握手有三个步骤。

不过既然教科书都这么翻译,我就只能先忍了。

“三次握手”的步骤相信各位都非常熟悉了:

引用
A: 喂,听得到吗 (SYN)
B: 阔以,你呢 (SYN-ACK)
A: 我也阔以,开始唠吧 (ACK)


(咦,这不是远程面试的开场白吗)

那么问题来了:为什么不是2次握手或者4次握手呢?


== 3次 ==

针对“为什么不是4次”,知乎的段子手是这么回答的:

引用
A: 喂,听得到吗 (SYN)
B: 阔以,你呢 (SYN-ACK)
A: 我也阔以,你呢 (SYN-ACK)
B: ...我不想和傻*说话 (FIN)


由此可见知乎质量的下降。


实际上,上面省略了真正重要的信息,在握手过程中传输的,不是“你能不能听得到”,而是:


引用
A: 喂,我的数据从x开始编号 (SYN)
B: 知道了,我的从y开始编号 (SYN-ACK)
A: 行,咱俩开始唠吧 (ACK)


协商一个序号的过程需要一个来回(告知 + 确认),理论上需要2个来回(4次),互相确认了双方的初始序号(ISN,Initial Sequence Number),才能真正开始通信。

由于第二个来回的“告知”可以和前一次的“确认”合并在同一个报文里(具体怎么结合后面讲),因此最终只需要3次握手,就可以建立起一个tcp链接。

这也解释了为什么不能只有2次握手:因为只能协商一个序号。

不过话说回来,知乎段子手的回复也不是全在抖机灵:毕竟,发起方怎么才能确认接收方已经知道发起方知道接收方知道了呢?即使发起方再问一遍,接收方又怎么知道发起方知道了接收方知道了呢?

很遗憾,结论是:无论多少个来回都不能保证双方达成一致。

由于实践中丢包率通常不高,因此最合理的做法就是3次握手(2个来回),少了不够,多了白搭;同时配上相应的容错机制。

例如 SYN+ACK 包丢失,那么发起方在等待超时后重传SYN包即可。

引用
想想看,如果最后一个ACK丢了会怎样?


然后问题又来了:为什么需要协商初始序号,才能开始通信呢?

== 可靠 ==

我们都知道,tcp是一个“可靠”(Reliable)的协议。

这里“可靠”指的不是保证送达,毕竟网络链路中存在太多不可靠因素。

在 IETF 的 RFC 793(TCP协议)中,Reliability的具体定义是:TCP协议必须能够应对网络通信系统中损坏、丢失、重复或者乱序发送的数据。

引用
Reliability:

The TCP must recover from data that is damaged, lost, duplicated, or delivered out of order by the internet communication system.

https://tools.ietf.org/html/rfc793


为了保证这一点,tcp需要给每一个 [字节] 编号:双方通过三次握手,互相确定了对方的初始序号,后续 [每个包的序号 - 初始序号] 就能标识该包在字节流中所处的位置,这样就可以通过重传来保证数据的连续性。

举个例子:

* 发送方(ISN=4000)
    * 发出 4001、4002、4003、4004
    * (假设每个包只有1字节的数据)
* 接收方
    * 收到 4001、4002、4004
    * 4003因为某种原因没有抵达
    * 这时上层应用只能读到4001、4002中的信息

由于接收方没有收到4003,因此给发送方的ACK中,序号最大值是4003(表示收到了4003之前的数据)。

过了一段时间(Linux下默认是1s),发送方发现4003一直没被ACK,就会重传这个包。

当接收方最终收到 4003 以后,上层应用才可以读到4003和4004,从而保证其收到的消息都是可靠的。(以及,接收方需要给发送方ACK,序号是4005)

注意:虽然ISN=4000,但是发送方发送的第一个包,SEQ是4001开始的,TCP协议规定 SYN 需要占一个序号(虽然SYN并不是实际传输的数据),所以前面示意图中ACK的seq是 x+1 。同样,FIN也会占用一个序号,这样可以保证FIN报文的重传和确认不会有歧义。

但是,为什么序号不能从 0 开始呢?

== 可靠² ==

真实世界的复杂性总是让人头秃。

我们知道,操作系统使用五元组(协议=tcp,源IP,源端口,目的IP,目的端口)来标识一个连接,当一个包抵达时,会根据这个包的信息,将它分发到对应的连接去处理。

一般情况下,服务器的端口号通常是固定的(如http 80),而操作系统会为客户端随机分配一个最近没有被使用的端口号,因此包总能被分发到正确的连接里。

但在某些特殊的场景下(例如快速、连续地开启和关闭连接),客户端使用的端口号也可能和上一次一样(或者用了其他刚断开的连接的端口号)。

而TCP协议并不对此作出限制:

引用
The protocol places no restriction on a particular connection being used over and over again. ... New instances of a connection will be referred to as incarnations of the connection.


那么:

* 如果前一个连接的包,因为某种原因滞留在网络中,这会儿才送达,客户端可能无法区分(其sequence number在本连接中可能是有效的)。

* 恶意第三方伪造报文的难度很小。注意,在这个场景里,第三方并 [不需要] 处于通信双方的链路之间,只要他发出的报文可以抵达通信的一方即可。

因此我们需要精心挑选一个ISN,使得上述case发生的可能性尽可能低。

注意:不是在tcp协议的层面上100%避免,因为这会导致协议变得更复杂,实现上增加额外的开销,而在绝大多数情况下是不必要的。如果需要“100%可靠”,需要在应用层协议上增加额外的校验机制;或者使用类似IPSec这样的网络层协议来保证对包的有效识别。

那么,ISN应该如何挑选呢?

== ISN生成器 ==

说起来其实很简单:

TCP协议的要求是,实现一个大约每 4 微秒加 1 的 32bit 计数器(时钟),在每次创建一个新连接时,使用这个计数器的值作为ISN。

假设传输速度是 2 Mb/s,连接使用的sequence number大约需要 4.55 小时才会溢出并绕回(wrap-around)到ISN。即使提高到 100 Mb/s,也需要大约 5.4 分钟。

而一个包在网络中滞留的时间通常是有限的,这个时间我们称之为MSL(Maximum Segment Lifetime),工程实践中一般认为不会超过2分钟。

所以我们一般不用担心本次连接的早期segment(tcp协议称之为 old duplicates)导致的混淆。

注:在家用千兆以太网已经逐渐普及、服务器间开始使用万兆以太网卡的今天,wrap-around的时间已经降低到32.8s(千兆)、3.28s(万兆),这个假定已经不太站得住脚了,因此 rfc1185 针对这种高带宽环境提出了一种扩展方案,通过在报文中加上时间戳,从而可以识别出这些 old duplicates。

主要风险在于前面提到的场景:前一个连接可能传输了较多数据,因此其序列号可能大于当前连接的ISN;如果该连接的报文因为某种原因滞留、现在又突然冒出来,当前连接将无法分辨。

因此,TCP协议要求在断开连接时,TIME-WAIT 状态需要保留 2 MSL 的时间才能转成 CLOSED(如下图底部所示)。

点击在新窗口中浏览此图片

(tcp连接状态图,截取自rfc 793)

那么问题又来了:为什么只有 TIME-WAIT 需要等待 2MSL,而LAST-ACK不需要呢?

== 报文 ==

针对TCP协议可以提的问题太多了,写得有点累,所以这里不打算继续自问自答了。

但写了这么多,还没有看一下TCP报文是什么结构的,实在不应该,这里还是祭出 rfc 793 里的 ascii art(并顺便佩服rfc大佬的画图功力)

点击在新窗口中浏览此图片

简单介绍下:

* 一行是4个字节(32 bits),header一般共5行(options和padding是可选的)
* 第一行包含了源端口和目的端口
    * 每个端口16bits,所以端口最大是65535
    * 源IP和目的IP在IP报文头里
* 第二行是本次报文的Sequence Number
* 第三行是ACK序列号
* 第四行包含了较多信息:
    * 数据偏移量:4字节的倍数,最小是0101(5),表示数据从第20个字节开始(大部分情况)
    * 控制位(CTL):一共6个,其中的ACK、SYN、FIN就不介绍了
    * RST是Reset,遇到异常情况时通知对方重置连接(我们敬爱的防火墙很爱用它)
    * URG表示这个报文很重要,应该优先传送、接收方应该及时给上层应用。URG的数据不影响seq,实际很少被用到,感兴趣的话可以参考下RFC 854(Telnet协议)
    * PSH表示这个报文不应该被缓存、应当立即被发送出去。在交互式应用中比较常用,如ssh,用户每按下一个键都应该及时发出去。注意和Nagle算法可能会有一些冲突。
    * 窗口大小:表示这个包的发送方当前可以接受的数据量(字节数),从这个包里的ack序号开始算起。**用于控制滑动窗口大小的关键字段就是它了。**

举个例子,三次握手的第二步,SYN和ACK合并的报文就是这么生成的:

* Sequence Number填入从ISN生成器中获取的值
* Acknowledgement Number填入 [发送方的序号 + 1]
* 将控制位中的ACK位、SYN位都置1

写不动了,真是没完没了(相信看到这里的同学已经不多了),但是TCP协议中还有很多有意思的设计本文完全没有涉及,文末我给出一些推荐阅读的链接,供感兴趣的同学参考。

== 总结 ==

* TCP“三次握手”翻译不准确
* 握手的目的是双方协商初始序列号ISN
* 序列号是用于保证通信的可靠性
* 不使用 0 作为ISN可以避免一些坑
* TCP报文里包含了端口号、2个序列号、一些控制位、滑动窗口大小
* 我在字节跳动网盟广告业务线(穿山甲),由于业务持续高速发展,长期缺人。关于字节跳动面试的详情,可参考我之前写的
    * 《程序员面试指北:面试官视角》
    * https://mp.weixin.qq.com/s/Byvu-w7kyby-L7FBCE24Uw

~ 投递链接 ~

后端开发(上海) https://job.toutiao.com/s/sBAvKe

后端开发(北京) https://job.toutiao.com/s/sBMyxk

广告策略研发(上海) https://job.toutiao.com/s/sBDMAK

其他地区、职能线 https://job.toutiao.com/s/sB9Jqk


== 推荐阅读 ==

[1] RFC 793:TRANSMISSION CONTROL PROTOCOL

https://tools.ietf.org/html/rfc793


[2] Coolshell - TCP 的那些事儿 (上 & 下)

https://coolshell.cn/articles/11564.html

https://coolshell.cn/articles/11609.html


[3] 知乎 - TCP 为什么是三次握手,而不是两次或四?

https://www.zhihu.com/question/24853633
Mar 29
原发于我的公众号:felix021

在乔纳森·斯威夫特的著名讽刺小说《格列夫游记》中,小人国内部分裂成 Big-endian 和 Little-endian 两派,区别在于一派要求从鸡蛋的大头把鸡蛋打破,另一派要求从鸡蛋的小头把鸡蛋打破。

然后忘了这个故事,咱们开始吧。

== 坑 ==
Charles 同学这周又踩了个坑,数据插入 MySQL 时报错:

引用
1366 Incorrect string value: '\xF0\x90\x8D\x83...' for column 'content' at row 1


按惯例搜一下,据说是因为 mysql 用的 utf8 不支持 emoji,需要修改配置文件,将字符集改成 utf8mb4:

引用
[mysqld]
character-set-server = utf8mb4
stackoverflow.com/questions/10957238


但是 Charles 已经给 MySQL 加上了这个配置,仍然报错。

实际上,MySQL 还有另外一个配置,用于指定客户端和服务器之间连接时使用的字符集:

引用
[mysqld]
character-set-client-handshake = utf8mb4


当然,也可以在 MySQL Client 中指定,具体需要参考 client 的文档,或者简单粗暴地在连接成功以后执行(但不推荐):

引用
SET NAMES utf8mb4;


== utf8 和 utf8mb4 ==

那么,什么是 utf8mb4 ?和 utf8 有啥区别呢?

根据 MySQL 的 manual:

引用
The utfmb4 character set has these characteristics:
- Supports BMP and supplementary characters.
- Requires a maximum of four bytes per multibyte character.
https://dev.mysql.com/doc/refman/8.0/en/charset-unicode-utf8mb4.html

(文档中 utf8mb4 打错了,我是原样复制的)

翻译过来就是,utf8mb4 支持 BMP ( Unicode Basic Multilingual Plane )和补充字符,每个字符最多 4 字节(这里 “mb4” 大概就是 multi byte 4 的简写了)。

冷知识:Unicode 编码一共有 17 个 "Plane"( 0~16 ),其中 Plane 0 就是 BMP,包含绝大多数常用字符,比如希腊、希伯来、阿拉伯、CJK ( Chinese-Japanese-Korean )字符等。Plane 1~16 被称为 "supplementary planes",包含不常用的其他字符,例如 emoji 和某些特殊的 CJK 字符。所以目前 Unicode 字符的最大编码为 0x10FFFF 。

至于 utf8,MySQL 文档里也有说明:

引用
utf8 is an alias for the utf8mb3 character set.

Note
The utf8mb3 character set is deprecated and will be removed in a future MySQL release. Please use utf8mb4 instead
https://dev.mysql.com/doc/refman/8.0/en/charset-unicode-utf8.html

简单说就是挂羊头卖狗肉了,看到的是 utf8,实际用的是 utf8mb3

utf8mb3 的文档就不贴了(懒),和 ut8mb4 的区别就在于最多只支持 3 个字节,因此不支持 Unicode 的补充字符集。

也就是说,MySQL 里的 utf8,实际上是一个阉割版的 utf8 。

MySQL 从 5.5.3 才开始支持完整版的 utf8 ( utf8mb4 ),并且后续计划移除 utf8mb3,utf8 未来在 mysql 中也会变成 utf8mb4 的别名,所以以后默认都使用 utf8mb4 就对了。

话说回来,MySQL 为什么会有这种奇怪的设定呢?

其实最初是从性能上考虑的,这个精简版的 utf8 在运行的时候可以更快一点点。

要知道 MySQL 已经是一个 24 岁的老项目了,在 1995 年诞生时,Intel 才只推出了 Pentium Pro,对比现在的 CPU,性能可以说是非常差了。

冷知识:差到什么程度呢?举个例子,早期的 Windows Beta 版,桌面右下角时间是可以显示秒数的,但由于当时硬件的性能问题,微软在发布 Windows 95 之前就移除了该功能,直到 Windows 7 ( 2009 年)才允许通过修改注册表开启。

== 真正的 utf8 ==

那么真正的 utf8 长什么样呢?

在查文档之前,不妨先动手创建一个文档看一下

$ echo '0Aa 你好' > utf-8.txt

$ file utf-8.txt
utf-8.txt: UTF-8 Unicode text

$ xxd utf-8.txt #用 16 进制的方式查看
0000: 3041 61e4 bda0 e5a5 bd0a      0Aa.......

可以看到,开头"0Aa" 对应 3 个字节 0x30 、0x41 、0x61 (十进制 48 、65 、97,大写 A < 小写 a 就是这么来的)。

最后一个字符 0x0a 是 echo 默认输出的换行。

冷知识:可以加上 -n 参数让 echo 不输出换行符。换行符在不同 OS 下不同,在 Linux/Unix 下是 "\n" ( 0x0a ),在 Windows 下是 "\r\n"( 0x0d 0x0a ),在早期 Mac 下是 "\r" ( 0x0d ),从 Mac OS 10.0 ( 2001 )开始也和 Unix 一样用 "\n" 了。在 C/Python 等语言下,fopen/open 默认使用“文本模式”打开文件,读取时会统一转换成 "\n",写入时将"\n"转换为按 os 的默认值。还有一些其他场景可能需要注意,例如 http 协议中 header 使用 "\r\n" 换行。

中间的 "e4bda0e5a5bd" 这 6 个字节对应的就是 “你好” 了,每个字符 3 个字节。

那到底如何确定一个 utf8 字符是几个字节呢?

这里贴一个 wikipedia 的表格 Number

引用
字节数 | 比特数 | Unicode 区间 开始~结束 | 字节 1~4
1  7  U+0000  U+00007F  0xxxxxxx     
2  11  U+0080  U+0007FF  110xxxxx 10xxxxxx   
3  16  U+0800  U+00FFFF  1110xxxx 10xxxxxx 10xxxxxx
4  21  U+10000  U+10FFFF  11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

简单解释一下:

1. 第一个字节中,开头 1 的数量表明了这个 utf8 字符包含几个字节
2. 0 开头,表示只需要 1 个字节,剩余 7 bit 可以表示 unicode 中的 0~127,正好和 ascii 编码兼容。
3. 110 开头,表示需要 2 个字节,包含 11 bit,可以表示大部分非 CJK 字符(希腊、阿拉伯等字符)
4. 1110 开头,表示需要 3 个字节,包含 16 bit,正好可以表示所有 BMP 的字符,比如 “你好”都在 这个 Plane 里面,所以一共需要 6 个字节。
5. 11110 开头,表示需要 4 个字节,包含 21 bit,最多可以包含 32 个 Plane,超过了当前 17 个 Plane 的
6. 后续字符都是 10 开头,不会和首字符混淆,因此在解析的时候很容易识别,就算遇到了错误的编码字符(例如按字节截断到字符中间),也可以简单跳过,定位下一个字符。

前面展示了 ASCII 字符和中文,咱们顺便再看看 emoji 的 utf-8 编码长什么样:

$ echo -n  > 1.txt
$ xxd 1.txt
0000: f09f 9880    ....


根据上面的表格,我们可以算出,这个 GRIN FACE (露齿笑)的 Unicode 码点是 0x1F600,在 Unicode 的 Plain 1 中,因此 utf-8 编码需要 4 个字节。

== 字符集和编码规范 ==

上文提到了 Unicode 和 utf-8 这两个名词,但是很多同学其实没搞明白他俩的区别是啥。

一般我们提到 Unicode 时指的是字符集( Character Set ),其中包含了一系列字符,并为每一个字符指定了码点( Code Point,即该字符的编号)。

Unicode 标准里包含了很多编码规范,utf-8 是其中一种,指定了一个 Unicode 字符的码点应该如何存储,例如 ASCII 用一个字节,超过一个字节的根据需要的 bit 数量,分别存储到多个字节中。

除了 utf-8 之外,Unicode 还有多种不同的编码规范,例如

1. ucs-2,就是简单地一一对应 BMP 的编码,每个字符使用 2 个字节,因此不支持补充字符。
2. ucs-4,用 4 个字节来存储 unicode 编码,因此支持 Unicode 中的所有 plain 。Unicode 后续的修订也会保证添加的码点都在 31bit 范围内。
3. utf-16,BMP 内的字符等于 ucs-2,其他 plane 用 4 个字节表示; windows 和 ecma 规范( javascript )使用 utf-16 作为其内部编码。
4. utf-32,ucs-4 的子集,一般可以认为就是 ucs-4 。

然鹅 utf-8 几乎统治了互联网,超过 93%的网页是用 UTF-8 编码的,以至于 IETF 要求所有网络协议都需要标明内容的编码,并且必须支持 UTF-8 。

至于原因么,还记得开头的那个故事吗? utf-8 避免了上述编码中的字节序( big endian 、little endian )的问题。

当然这只是一个原因,我认为更重要的是,utf-8 保持了对 ascii 的兼容,路径依赖的强大惯性,会导致上述 4 种编码在实际推广中带来很高的迁移成本(按理应该在这里讲讲马屁股宽度的故事,不过跑题太远了)。

utf-8 在保持后向兼容的前提下,能支持所有 Unicode 字符,相比 ucs4 还能节省大量存储空间、数据传输量,因此统治了互联网,也就在情理之中了。

除了 Unicode 之外,还有很多其他字符集,例如最经典的 ASCII,由于字符少,其编码规范也相当简单。

在中国,比较常见的字符集还有 GB2312 ( 1980 年)、GBK ( 1993 年)、GB18030 ( 2000 年),这些标准都规定了对应的编码规范,所以这些名字既可以表示字符集,也可以表示编码规范。

其中 GB2312 只包含 7445 个字符,其中汉字 6763 个(只包含常用汉字,很多生僻字都不支持),编码规范也兼容 ASCII 。GBK ( GB13000 )兼容 GB2312,添加了更多字符,GB18030 是进一步的补充。

冷知识:我们可以使用 iconv 命令行工具来修改文件的字符编码

$ iconv -f gb18030 -t utf-8 < gb18030.txt
0Aa 你好

也可以在 vim 中这干
:set fileencoding=gb18030


此外,使用 windows 的同学可能还见到过一个奇怪的代号 "cp936"(在上述 iconv 命令、vim 中都可以使用),这是微软对 GB2312 的实现,在 Win95 以后实际上也支持了大部分 GBK 字符。

== 总结 ==

1.  Unicode 是一个字符集,包含 17 个 Plane,每个 Plane 65536 个码点,Plane0 是 BMP,其他 Plane 是补充字符
2. UTF-8 是一种编码规范,用 1~4 个字节编码 Unicode 字符,兼容 ASCII,中文 3 字节,补充字符如 emoji 需要 4 字节)
3. MySQL 中的 utf8 是阉割版的、只支持 BMP 的编码,以后记得都使用 utf8mb4 ;除了 server 编码,记得也要修改连接的编码(客户端编码)。
4. 除了 utf-8 之外,还有好几个没什么卵用的字符集 /编码。
5. 我在网盟广告业务线(穿山甲),由于业务持续高速发展,长期缺人、不限 HC 。关于字节跳动面试的详情,可参考我之前写的《程序员面试指北:面试官视角》: https://mp.weixin.qq.com/s/Byvu-w7kyby-L7FBCE24Uw

~ 投递链接 ~

后端开发(上海) https://job.toutiao.com/s/sBAvKe

后端开发(北京) https://job.toutiao.com/s/sBMyxk

广告策略研发(上海) https://job.toutiao.com/s/sBDMAK

其他地区、职能线 https://job.toutiao.com/s/sB9Jq

----

再补充一个冷知识。

可能有些 PHP 程序员还踩过一个坑 :

用windows的 notepad 编辑utf-8编码的php文件并保存以后,代码执行不正常了。

这是因为 notepad 给文件开头打了个标记 (你可以用 xxd 或者 ultraedit 打开试试看)。

这个标记被称为 UTF-8 BOM,具体值为 0xEF 0xBB 0xBF,用来告诉文件的读取方,这个文件是使用 UTF-8 编码的。

比如说,你的csv是使用utf-8编码的,如果没有这个BOM,使用微软的Excel打开就会出现乱码。

至于PHP的问题,由于它在code tag(<?php)之前,PHP的解释器会把它当成 html 文件的一部分,可能就会导致 session 相关函数启动之前,就输出了http body。

最后,再学习一下移除 BOM 的方法吧:

vim:不要炸弹
引用
:set nobomb

sed:inplace
引用
$ sed -i '1s/^\xEF\xBB\xBF//' bom.txt
Mar 23

关于 RSA 的一些趣事 不指定

felix021 @ 2020-3-23 20:54 [IT » 其他] 评论(0) , 引用(0) , 阅读(1860) | Via 本站原创
文章有点长(一共 2300 字), 但最后一个故事最有意思, 看不完的话可以直接拉到底

== 1 ==

从面试题说起好了。

在考察到网络这一块的时候,可能会问问 http 协议,聊安全相关问题时,就顺便聊聊 https 。

大多数候选人知道非对称加密,了解客户端会用 RSA 公钥进行加密。

那么,服务器在返回响应报文之前,会用什么来进行加密呢?

有些候选人回答:“用服务器私钥进行加密”。

内心呵呵一笑

接着问,那服务器返回的信息岂不是可以被中间人拦截并解密吗?

候选人一般就放弃挣扎,只能强颜欢笑了。

有进一步了解过 https 的同学,能够说出在 SSL/TLS 握手以后,会生成一个对称加密密钥。

那么,既然有非对称加密,为什么还需要使用对称加密呢?

有些候选人就回答不上来了,只能强颜欢笑+1 。

实际上这是因为非对称加密的性能通常比对称加密算法差几个数量级。

以 RSA 为例,在加解密的时候,需要对大整数(典型值是 2048bit,256 字节)做大量乘法、取模等运算;相比之下如 AES 这样的对称加密算法会简单很多,一些 XOR 、移位,以及在 4x4 的矩阵上做些变换,还可以通过查表来加速。

此外,由于 AES 的广泛应用,主流 CPU ( Intel, AMD, ARM )都有相应的扩展指令集,可以将性能提升一个数量级,实际每秒能处理的数据在数百 MB 这个量级上。

有些硬盘号称有全盘加密功能,实际上就是硬盘的主控芯片在写入前通过 AES 进行加密,在电脑启动时 BIOS 会要求输入密码。这样即使电脑丢了,或者硬盘被人拆下挂到其他机器上也不用担心数据泄露。

关于 RSA 算法的实现细节,推荐阮一峰写的《 RSA 算法原理》

https://www.ruanyifeng.com/blog/2013/06/rsa_algorithm_part_one.html

== 2 ==

另一个有趣的事情是 2017 年,当时在钱厂,对接某银行系统的时候,在通信协议的加密这块,对方给了一个 jar 包(不给源码),以及不知什么编码的公钥、私钥文件,既不是 PEM 也不是 X509,是个奇怪的二进制文件。

然鹅,钱厂用的是 PHP,这就有点尴尬了。

幸好这个 jar 包没有经过混淆,用安卓开发小伙伴提供的反编译工具,得到了源码,并经过一番努力重写成了 PHP 的源码。

然后发现那个公私钥是 java object 序列化后得到的字节码。

更有趣的还在后面。

为了方便测试,我们按银行给的 API 写了一套 mock 系统,这样就可以在不依赖银行在内部完成全流程自测,大幅提高了开发效率。

在部署 mock 系统的时候,没想太多,就用银行提供的这对公私钥,然后竟然调通了

也就是说,银行给的公钥和私钥文件竟然是一对,把他们的私钥直接给我们了……

我猜,应该是银行的安全审计部门在项目需求中要求用非对称加密,但是又没有对最终代码进行审查吧

顺便一提,正式上线时,对方给的 API url 是 https 的,但是 url 中的域名是 IP,钱厂在代码中只能把 CURLOPT_SSL_VERIFYHOST 设为 false 。

过了一段时间,他们决定用个正式的域名,才给安上了 https 证书。

又过了一段时间,他们的证书过期了。

并且在故障期间不允许我们忽略证书进行访问。

== 3 ==

故事 2 里提到,把那段 java 代码“经过一番努力”重写成了 PHP,其实中间还是遇到了个不大不小的麻烦。

Java 代码里用了一个叫 bouncycastle 的库来进行 RSA 的加解密,而我用 PHP 的 openssl_private_encrypt 加密的文本,并不能被他们提供的 java 代码正常解密。

经过多次尝试,我发现了一个现象:对同一个消息,java 代码加密生成的密文,每次都一样,而 PHP 生成的密文,则总是在变化。

作为一个信息安全专业的毕业生,我竟然不知道这是为什么,真是愧对国家愧对党,只好默默点开桌面上的小飞机,在一些不存在的网站上摸索。

根据这个现象,我在 stackoverflow 找到了 "data encrypted with openssl_public_encrypt is different every time?" 这个问题,答案中给了个线索:

The PKCS#1 encryption algorithm uses some random seed to make the cipher-text different every time. This protects the cipher-text against several attacks, like frequency analysis, ciphertext matching.

FROM stackoverflow.com/questions/36627518

经过进一步的搜索,终于找到了如何在 PHP 中解决这个问题。

具体解决办法后面说,这里先介绍一些背景知识。

RSA 加密的基本流程是:将一个和密钥长度相同的输入(明文),通过一系列运算(加密),得到一个和密钥长度相同的输出(密文)。

以 1024 bit 的 RSA 密钥为例,每次输入 128 字节,输出 128 字节。

对于超过 128 字节的情况,就需要将原始数据切成 128 字节的块,分别加密后再拼起来;解密时,按 128 字节拆开解密。

但是不足 128 字节的情况,比如像密码这种短数据,或者长数据也并不总是 128 的倍数,会留下小尾巴,这就有点尴尬。

因此我们还需要用某种方法,将不足 128 字节的数据拼( padding )到 128 字节,再进行加密;解密得到的数据,需要把 padding 的数据去掉,才能得原始数据。

真是让人头秃。

继续。

对于普通文本,一个简单的做法是用 ASCII 0 进行填充。

但这会带来 2 个问题:

如果原文中包含了 ASCII 0,就无法有效识别

对于相同的输入,总是能得到相同的密文。

问题 2 可能招致某些类型的攻击,例如前面引用中提到的 "frequency analysis",以一个简化的场景为例,假设每个单词是单独加密的,在英文中单词 a 出现的次数最多,通过统计密文出现的频率,可以破译对应的明文。

一个改进的方案是,使用一些随机数进行填充,这样可以保证相同明文每次加密得到不同的密文。

基于这个思路,RFC 2313 制定了 RSA 的加密标准 PKCS #1: RSA Encryption Version 1.5,通过在 128 字节中的前 11 个字节里加入一些随机数,保证每次加密得到的密文不同。

回到最初的问题,通过查看 PHP 的 openssl_public_encrypt 文档,可以发现它有一个 $padding 参数,默认值是 OPENSSL_PKCS1_PADDING 。

而银行给的 Java 代码是 Cipher.getInstance("RSA", new BouncyCastleProvider()); 按照官方文档的说明,这里的 RSA 等于 "RSA/NONE/NoPadding"。

最后,通过在 PHP 代码中给数据手动填充前导 ASCII 0,并指定 OPENSSL_NO_PADDING,终于和 Java 代码兼容了。

问题圆满解决。

等等……

银行用的是 NoPadding ?

== THE END ==

其实关于 RSA 还有一些其他有趣的事情,这次就先写到这里,下次(如果我还记得的话),可以聊聊 RSA 和币圈的一点小八卦。

按照前几篇的套路,文末还是要贴一下招聘广告:

我在网盟广告业务线(穿山甲),由于业务持续高速发展,长期缺人、不限 HC 。关于字节跳动面试的详情,可参考我之前写的《程序员面试指北:面试官视角》

https://mp.weixin.qq.com/s/Byvu-w7kyby-L7FBCE24Uw

~ 投递链接 ~

后端开发(上海) https://job.toutiao.com/s/sBAvKe

后端开发(北京) https://job.toutiao.com/s/sBMyxk

广告策略研发(上海) https://job.toutiao.com/s/sBDMAK

其他地区、职能线 https://job.toutiao.com/s/sB9Jqk

Mar 22

PYTHON RSA加解密 不指定

felix021 @ 2020-3-22 00:31 [IT » 其他] 评论(0) , 引用(0) , 阅读(2290) | Via 本站原创
和某厂通信需要按其要求用rsa加密某段数据,该厂给了个Example.java,和一个X509 Certificate。

之前是把java编译好,然后在python里用system来调用它,有点丑。

最近需要复用这段代码,希望代码干净点,所以在Python里重新实现一遍。

# 1. 将 x509cer 转成  PEM 格式
import java.io.*;
import java.util.Base64;
import java.security.PublicKey;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;

public class Test2
{
    public static PublicKey getPublicKeyByX509Cer(String cerFilePath) throws Exception
    {
        InputStream x509Is = new FileInputStream(cerFilePath);
        CertificateFactory certificatefactory = CertificateFactory.getInstance("X.509");
        X509Certificate cert = (X509Certificate)certificatefactory.generateCertificate(x509Is);
        x509Is.close();
        return cert.getPublicKey();
    }

    public static void main(String[] args) throws Exception {
        PublicKey pubKey = getPublicKeyByX509Cer("public.cer");
        System.out.println(Base64.getEncoder().encodeToString(pubKey.getEncoded()));
    }
}


输出PEM编码的公钥,类似:MIGfMA0GCSqGSIb3DQ...(中间省略)...rAvxiOfQIDAQAB


# 2. 在Python中加密

#!/usr/bin/python
#coding:utf-8
import base64
import binascii
import Crypto
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5

PUBLIC_KEY = "MIGfMA0GCSqGSIb3DQ...(中间省略)...rAvxiOfQIDAQAB"

#返回base64编码后的密文
def RSAEncrypt(message, pubkey):
    binary = base64.b64decode(pubkey)
    key = RSA.importKey(binary)
    #PKCS#1建议的padding需要占用11个字节
    length = (key.size() + 1) / 8 - 11

    cipher = PKCS1_v1_5.new(key)
    res = []
    for i in range(0, len(message), length):
        res.append(cipher.encrypt(message[i:i+length]))
    result = ''.join(res)
    return binascii.hexlify(result)

print RSAEncrypt("123456", PUBLIC_KEY)


由于该厂在Java代码中使用 "RSA/NONE/PKCS1Padding",和PKCS1_v1_5的默认padding一致,因此不需要特殊处理。

注:
如果希望使用 NoPadding,看起来Python的Crypto没有直接提供支持,可能需要用PKCS1_OAEP并自己填充ASCII 0(但不确定,好像OAEP也包含了某种padding,encrypt的文档里说"长度需要 - 2 - 2倍hashcode长度"),几年前用PHP的时候踩过坑,详见
  https://www.felix021.com/blog/read.php?2169
  RSA: Java bouncy castle 与 PHP openssl_public_encrypt 兼容的那点事儿

# 3. 解密

反向处理就好,先base64 decode,分段解密再拼起来

PRIVATE_KEY = "MIICXQIBAAKBgQ....."

# 输入是base64编码的密文
def RSADecrypt(message, prikey):
    binary = base64.b64decode(prikey)
    key = RSA.importKey(binary)
    length = (key.size() + 1) / 8
    cipher = PKCS1_v1_5.new(key)
    message = binascii.unhexlify(message)
    res = []
    for i in range(0, len(message), length):
        res.append(cipher.decrypt(message[i:i+length], 'sentinel: random message'))
    result = ''.join(res)
    return result


注:decrypt方法的文档里说,"sentinel" 应当是一个无意义的字符串,并且尽量应当和典型的明文长度相似(这里偷懒了)。因为PKCS1_v1_5没有完善的完整性校验,某个构造的输入可能可以被正确解码,虽然看起来是一串没有意义的随机文字。加上sentinel以后,当解密出错的时候,会返回指定的 sentinel 继续后续处理流程,从而可以躲避选择密文攻击的检测。

关于这个选择密文攻击的细节可参考这篇文章:SSL.TLS协议安全系列:SSL的Padding Oracle攻击


# 4. 其他

可以用这个命令来生成RSA的公私钥对
引用
$ openssl genrsa -out test.pem 1024
$ openssl rsa -in test.pem -pubout -out test_public.pem


RSA.importKey(key) 方法的 key 也可以直接使用 PEM 文件的内容,也可以用 base64 decode 以后的 binary data。
Sep 21
最近线上服务压力很大,api的p99有点扛不住。

广告业务对延时的要求普遍比较严格,有些adx设置的超时时间低至100ms,因此亟需找出性能热点。

根据对目前系统情况的估计(和metrics埋点数据),大致估计问题出在广告的正排环节。

使用 pprof  也证明了这一块确实是热点:

引用
$ go tool pprof http://$IP:$PORT/debug/pprof/profile
...
(pprof) top 10
Showing nodes accounting for 25.50s, 32.63% of 78.14s total
Dropped 1533 nodes (cum <= 0.39s)
Showing top 10 nodes out of 284
      flat  flat%  sum%        cum  cum%
    4.56s  5.84%  5.84%      4.87s  6.23%  syscall.Syscall
    4.03s  5.16% 10.99%      4.03s  5.16%  runtime.aeshashbody
    3.50s  4.48% 15.47%      6.01s  7.69%  git.xxx.org/xxx/target.NewFilter
    2.78s  3.56% 19.03%      3.73s  4.77%  runtime.mapaccess2_fast64
    2.63s  3.37% 22.40%      4.52s  5.78%  runtime.mapiternext
    2.08s  2.66% 25.06%      2.16s  2.76%  runtime.heapBitsForObject
    1.65s  2.11% 27.17%      1.93s  2.47%  runtime.mapaccess1_fast64
    1.57s  2.01% 29.18%      2.96s  3.79%  runtime.mapaccess2
    1.43s  1.83% 31.01%      2.06s  2.64%  runtime.runqgrab
    1.27s  1.63% 32.63%      1.27s  1.63%  runtime.epollwait
(pprof) png
Generating report in profile001.png (使用生成的线框图查看耗时)


其中第三行 NewFilter 就是正排过滤函数。因为一些历史原因,系统里不是所有定向条件都使用了倒排,正排实现起来毕竟简单、容易理解,而一旦开了这个口子,就会有越来越多正排加进来,推测是这个原因导致了性能的逐渐衰退。

经过讨论,D同学花了一周多的时间,逐个梳理重写。在Libra(字节跳动内部的ABTest平台,参考谷歌分层实验框架方案)上开实验,平均耗时 -9%,从统计数据上来说,实验组比对照组有了显著的改进,但从最终结果上,整体的p95、p99超时都出现了进一步恶化。

这说明真正的问题不在于正排的计算,优化的思路出现了偏差。

考虑到晚高峰期间的cpu占用率也只是刚超过50%,也就是说有可能性能问题在于锁,但pprof的 block 和 mutex 都是空的,没有线索。

猜测问题有可能在日志,代码里确实用得不少。日志用的是 github.com/ngaut/logging 库,每一次调用都会用到两个全局mutex。但通过调整log level 为error级别,大幅减少了日志量,并没有看到性能的改善。

经过搜索,发现 uber 基于 pprof 开发了一个神器 go-torch,可以生成火焰图。安装好 go-torch 及依赖 FlameGraph 以后执行

引用
$ go-torch  -u http://$IP:$PORT -f cpu.svg
INFO[14:52:23] Run pprof command: go tool pprof -raw -seconds 30 http://$IP:$PORT/debug/pprof/profile
INFO[14:52:54] Writing svg to cpu.svg


用 Chrome 打开 cpu.svg,人肉排查:

点击在新窗口中浏览此图片

可以看到,在NewFilter旁边竟然还有一个耗时接近的 runtime.growslice ,结合实际代码(略作简化),可以推测是 slice 的初始化长度不足。

matchAds := make([]*ad, 0, 4096)
adsBitMap.GetList(func(seq int) {
    if NewFilter(ctx, ad) {
        matchAds = append(matchAds, adlist[seq])
    }
})

// 顺便提一下,bitmap是一个uint64数组,GetList(f) 是将每一个等于1的bit索引传给 f
// GetList方法里面用了cpu的BSF指令来提高性能。



实际上最终定向后得到的广告往往在数万甚至数十万的级别,而 go 的 slice 扩容在超过1024个元素以后是1.25倍,可想而知会出现大量的内存分配和拷贝,导致性能随着广告数量的增加逐渐恶化。最近的广告数量也确实有了大幅的上升 —— 逻辑上形成了闭环。

经过优化,使用更大的初始化长度,并且使用 sync.Pool 来进一步减少内存分配,最终上线后p95和p99都下降了超过50%,效果显著。

参考:
golang 使用pprof和go-torch做性能分析 https://www.cnblogs.com/li-peng/p/9391543.html
Aug 10

记 python 超时的一个坑 不指定

felix021 @ 2019-8-10 13:34 [IT » 其他] 评论(1) , 引用(0) , 阅读(5394) | Via 本站原创
# 背景

有一个 python 脚本调用 A 服务的 x 接口获取若干 GB 的数据(大量对象),读取和解析大约需要 5 分钟。

由于 x 接口的改造,需要改成调用 B 服务的 y 接口。

A、B 服务都是基于字节跳动的 KITE 框架开发的(今日头条Go建千亿级微服务的实践),通信协议是 thrift 0.9.2 。

# 现象

改成调用 B 服务,在测试过程中发现,每次大约到 3 分钟以后就会出现报错 TTransportException(TSocket read 0 bytes);之后会重试,但第一次报错后,之后每次大约1分钟内就会再次报同样的错误,重试 3 次后放弃、进程退出。

# 排查

1. 由于测试的时间是晚高峰,初步判断可能是晚高峰服务端压力太大导致。次日平峰期测试仍然复现。

2. 搜索,发现有人遇到类似问题,是从 thrift 0.9 升级到 1.0,服务端没有进行 utf8 编码导致客户端的解析问题,他通过修改服务端代码解决。然而服务端显然不存在问题,因为其他的服务调用该接口表现稳定。此外我遇到的问题并没有升级thrift版本。

3. 还是从报错信息入手,在代码里搜索 "TSocket read 0 bytes",来自于 python2.7/site-packages/thrift/transport/TSocket.py

  def read(self, sz):
    try:
      buff = self.handle.recv(sz)
    except socket.error, e:
      if (e.args[0] == errno.ECONNRESET and
          (sys.platform == 'darwin' or sys.platform.startswith('freebsd'))):
        self.close()
        buff = ''
      elif e.args[0] == errno.EINTR:
        buff = self.handle.recv(sz)
        if len(buff) > 0:
          return buff
      else:
        raise
    if len(buff) == 0:
      raise TTransportException(type=TTransportException.END_OF_FILE, message='TSocket read 0 bytes')
    return buff


4. 通过插入调试代码,发现并没有抛出异常,说明确实只读到了 0 字节,因此可以大致判断问题发生在 server 端。

5. 查看 B 服务的 log,发现确实有“客户端超时” 的报错信息。通过查看 KITE 框架的文档,发现默认的超时时间是 3 秒,A服务在配置文件里指定了 20s 的超时时间,而 B 服务没有指定。

6. 通过修改 B 服务的超时时间,调用成功。但为什么 python 作为一个客户端,会出现长达 3s 的停顿导致超时呢,尤其是在局域网甚至本机环境,不可能是网络原因。

7. 联想到曾经踩过的一个坑(详见:https://www.felix021.com/blog/read.php?2142),猜测是python的gc导致。虽然python是引用计数,但为了避免循环引用导致的内存泄漏,还是有一个 stw 的 gc 扫描。通过关闭这个扫描,就解决了这个超过 3s 的停顿

import gc
gc.disable()


# 吐槽

python真是慢。同样一个api,golang只要17s就完成了调用、反序列化,而python需要长达5分钟。

# 吐槽 #2

大概过了半个月,python把内存用爆了,最后只好用 go 重写了。
分页: 4/99 第一页 上页 1 2 3 4 5 6 7 8 9 10 下页 最后页 [ 显示模式: 摘要 | 列表 ]