Repository: xiaolincoder/CS-Base Branch: main Commit: 381b846137c4 Files: 125 Total size: 1.1 MB Directory structure: gitextract_7wig8p9z/ ├── .autocorrectrc ├── .github/ │ └── workflows/ │ └── autocorrect.yml ├── README.md ├── cs_learn/ │ ├── README.md │ ├── cs_learn.md │ ├── feel_cs.md │ └── look_book.md ├── mysql/ │ ├── README.md │ ├── base/ │ │ ├── how_select.md │ │ └── row_format.md │ ├── buffer_pool/ │ │ ├── README.md │ │ └── buffer_pool.md │ ├── index/ │ │ ├── 2000w.md │ │ ├── count.md │ │ ├── index_interview.md │ │ ├── index_issue.md │ │ ├── index_lose.md │ │ ├── page.md │ │ └── why_index_chose_bpuls_tree.md │ ├── lock/ │ │ ├── deadlock.md │ │ ├── how_to_lock.md │ │ ├── lock_phantom.md │ │ ├── mysql_lock.md │ │ ├── show_lock.md │ │ └── update_index.md │ ├── log/ │ │ ├── README.md │ │ └── how_update.md │ └── transaction/ │ ├── mvcc.md │ └── phantom.md ├── network/ │ ├── 1_base/ │ │ ├── how_os_deal_network_package.md │ │ ├── tcp_ip_model.md │ │ └── what_happen_url.md │ ├── 2_http/ │ │ ├── http2.md │ │ ├── http3.md │ │ ├── http_interview.md │ │ ├── http_optimize.md │ │ ├── http_rpc.md │ │ ├── http_websocket.md │ │ ├── https_ecdhe.md │ │ ├── https_optimize.md │ │ └── https_rsa.md │ ├── 3_tcp/ │ │ ├── challenge_ack.md │ │ ├── isn_deff.md │ │ ├── out_of_order_fin.md │ │ ├── port.md │ │ ├── quic.md │ │ ├── syn_drop.md │ │ ├── tcp_down_and_crash.md │ │ ├── tcp_drop.md │ │ ├── tcp_feature.md │ │ ├── tcp_http_keepalive.md │ │ ├── tcp_interview.md │ │ ├── tcp_no_accpet.md │ │ ├── tcp_no_listen.md │ │ ├── tcp_optimize.md │ │ ├── tcp_problem.md │ │ ├── tcp_queue.md │ │ ├── tcp_stream.md │ │ ├── tcp_tcpdump.md │ │ ├── tcp_three_fin.md │ │ ├── tcp_tls.md │ │ ├── tcp_tw_reuse_close.md │ │ ├── tcp_unplug_the_network_cable.md │ │ └── time_wait_recv_syn.md │ ├── 4_ip/ │ │ ├── ip_base.md │ │ ├── ping.md │ │ └── ping_lo.md │ ├── 5_learn/ │ │ ├── draw.md │ │ └── learn_network.md │ └── README.md ├── os/ │ ├── 10_learn/ │ │ ├── draw.md │ │ └── learn_os.md │ ├── 1_hardware/ │ │ ├── cpu_mesi.md │ │ ├── float.md │ │ ├── how_cpu_deal_task.md │ │ ├── how_cpu_run.md │ │ ├── how_to_make_cpu_run_faster.md │ │ ├── soft_interrupt.md │ │ └── storage.md │ ├── 2_os_structure/ │ │ └── linux_vs_windows.md │ ├── 3_memory/ │ │ ├── alloc_mem.md │ │ ├── cache_lru.md │ │ ├── malloc.md │ │ ├── mem_reclaim.md │ │ └── vmem.md │ ├── 4_process/ │ │ ├── create_thread_max.md │ │ ├── deadlock.md │ │ ├── multithread_sync.md │ │ ├── pessim_and_optimi_lock.md │ │ ├── process_base.md │ │ ├── process_commu.md │ │ └── thread_crash.md │ ├── 5_schedule/ │ │ └── schedule.md │ ├── 6_file_system/ │ │ ├── file_system.md │ │ └── pagecache.md │ ├── 7_device/ │ │ └── device.md │ ├── 8_network_system/ │ │ ├── hash.md │ │ ├── reactor.md │ │ ├── selete_poll_epoll.md │ │ └── zero_copy.md │ ├── 9_linux_cmd/ │ │ ├── linux_network.md │ │ └── pv_uv.md │ └── README.md ├── reader_nb/ │ ├── 1_reader.md │ ├── 2_reader.md │ ├── 3_reader.md │ ├── 4_reader.md │ ├── 5_reader.md │ ├── 6_reader.md │ ├── 7_reader.md │ ├── 8_reader.md │ └── README.md └── redis/ ├── README.md ├── architecture/ │ └── mysql_redis_consistency.md ├── base/ │ └── redis_interview.md ├── cluster/ │ ├── cache_problem.md │ ├── cluster.md │ ├── master_slave_replication.md │ └── sentinel.md ├── data_struct/ │ ├── command.md │ └── data_struct.md ├── module/ │ └── strategy.md └── storage/ ├── aof.md ├── bigkey_aof_rdb.md └── rdb.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .autocorrectrc ================================================ # yaml-language-server: $schema=https://huacnlee.github.io/autocorrect/schema.json rules: # Default rules: https://github.com/huacnlee/autocorrect/raw/main/autocorrect/.autocorrectrc.default spellcheck: 2 spellcheck: words: # Please do not add a general English word (eg. apple, python) here. # Users can add their special words to their .autocorrectrc file by their need. - ActiveMQ - AirPods - Aliyun - API - App Store - AppKit - AppStore = App Store - AWS - CacheStorage - CDN - CentOS - CloudFront - CORS - CPU - DNS - Elasticsearch - ESLint - Facebook - GeForce - GitHub - Google - GPU - H5 - Hadoop - HBase - HDFS - HKEX - HTML - HTTP - HTTPS - I10n - I18n - iMovie - IndexedDB - Intel - iOS - iPad - iPadOS - iPhone - iTunes - JavaScript - jQuery - JSON - JWT - Linux - LocalStorage - macOS - Markdown - Microsoft - MongoDB - Mozilla - MVC - MySQL - Nasdaq - Netflix - NodeJS = Node.js - NoSQL - NVDIA - NYSE - OAuth - Objective-C - OLAP - OSS - P2P - PaaS - RabbitMQ - Redis - RESTful - RSS - RubyGem - RubyGems - SaaS - Sass - SDK - Shopify - SQL - SQLite - SQLServer - SSL - Tesla - TikTok - tvOS - TypeScript - Ubuntu - UML - URI - URL - VIM - watchOS - WebAssembly - WebKit - Webpack - Wi-Fi - Windows - WWDC - Xcode - XML - YAML - YML - YouTube ================================================ FILE: .github/workflows/autocorrect.yml ================================================ name: Autocorrect on: [push, pull_request] jobs: autocorrect: name: Check text autocorrect runs-on: ubuntu-latest steps: - name: Check out source uses: actions/checkout@v3 with: fetch-depth: 1 - name: Exec autocorrect uses: huacnlee/autocorrect-action@main ================================================ FILE: README.md ================================================ # 小林 x 图解计算机基础 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/网站封面.png) 👉 **点击**:[图解计算机基础在线阅读](https://xiaolincoding.com/) 本站所有文章都是我[公众号:小林 coding](https://mp.weixin.qq.com/s/FYH1I8CRsuXDSybSGY_AFA)的原创文章,内容包含图解计算机网络、操作系统、计算机组成、数据库,共 1000 张图 + 50 万字,破除晦涩难懂的计算机基础知识,让天下没有难懂的八股文(口嗨一下,大家不要当真哈哈)!🚀 曾经我也苦恼于那些晦涩难弄的计算机基础知识,但在我啃了一本又一本的书,看了一个又一个的视频后,终于对这些“家伙”有了认识。我想着,这世界上肯定有一些朋友也跟我有一样的苦恼,为此下决心,用图解 + 通熟易懂的讲解来帮助大家理解,利用工作之余,坚持输出图解文章两年之久,这才有了今天的网站! ## :open_book:《图解网络》 - **介绍**:point_down:: - [图解网络介绍](https://xiaolincoding.com/network/) - **网络基础篇** :point_down: - [TCP/IP 网络模型有哪几层?](https://xiaolincoding.com/network/1_base/tcp_ip_model.html) - [键入网址到网页显示,期间发生了什么?](https://xiaolincoding.com/network/1_base/what_happen_url.html) - [Linux 系统是如何收发网络包的?](https://xiaolincoding.com/network/1_base/how_os_deal_network_package.html) - **HTTP 篇** :point_down: - [HTTP 常见面试题](https://xiaolincoding.com/network/2_http/http_interview.html) - [HTTP/1.1 如何优化?](https://xiaolincoding.com/network/2_http/http_optimize.html) - [HTTPS RSA 握手解析](https://xiaolincoding.com/network/2_http/https_rsa.html) - [HTTPS ECDHE 握手解析](https://xiaolincoding.com/network/2_http/https_ecdhe.html) - [HTTPS 如何优化?](https://xiaolincoding.com/network/2_http/https_optimize.html) - [HTTP/2 牛逼在哪?](https://xiaolincoding.com/network/2_http/http2.html) - [HTTP/3 强势来袭](https://xiaolincoding.com/network/2_http/http3.html) - [既然有 HTTP 协议,为什么还要有 RPC?](https://xiaolincoding.com/network/2_http/http_rpc.html) - [既然有 HTTP 协议,为什么还要有 WebSocket?](https://xiaolincoding.com/network/2_http/http_websocket.html) - **TCP 篇** :point_down: - [TCP 三次握手与四次挥手面试题](https://xiaolincoding.com/network/3_tcp/tcp_interview.html) - [TCP 重传、滑动窗口、流量控制、拥塞控制](https://xiaolincoding.com/network/3_tcp/tcp_feature.html) - [TCP 实战抓包分析](https://xiaolincoding.com/network/3_tcp/tcp_tcpdump.html) - [TCP 半连接队列和全连接队列](https://xiaolincoding.com/network/3_tcp/tcp_queue.html) - [如何优化 TCP?](https://xiaolincoding.com/network/3_tcp/tcp_optimize.html) - [如何理解是 TCP 面向字节流协议?](https://xiaolincoding.com/network/3_tcp/tcp_stream.html) - [为什么 TCP 每次建立连接时,初始化序列号都要不一样呢?](https://xiaolincoding.com/network/3_tcp/isn_deff.html) - [SYN 报文什么时候情况下会被丢弃?](https://xiaolincoding.com/network/3_tcp/syn_drop.html) - [四次挥手中收到乱序的 FIN 包会如何处理?](https://xiaolincoding.com/network/3_tcp/out_of_order_fin.html) - [在 TIME_WAIT 状态的 TCP 连接,收到 SYN 后会发生什么?](https://xiaolincoding.com/network/3_tcp/time_wait_recv_syn.html) - [TCP 连接,一端断电和进程崩溃有什么区别?](https://xiaolincoding.com/network/3_tcp/tcp_down_and_crash.html) - [拔掉网线后,原本的 TCP 连接还存在吗?](https://xiaolincoding.com/network/3_tcp/tcp_unplug_the_network_cable.html) - [tcp_tw_reuse 为什么默认是关闭的?](https://xiaolincoding.com/network/3_tcp/tcp_tw_reuse_close.html) - [HTTPS 中 TLS 和 TCP 能同时握手吗?](https://xiaolincoding.com/network/3_tcp/tcp_tls.html) - [TCP Keepalive 和 HTTP Keep-Alive 是一个东西吗?](https://xiaolincoding.com/network/3_tcp/tcp_http_keepalive.html) - [TCP 协议有什么缺陷?](https://xiaolincoding.com/network/3_tcp/tcp_problem.html) - [如何基于 UDP 协议实现可靠传输?](https://xiaolincoding.com/network/3_tcp/quic.html) - [TCP 和 UDP 可以使用同一个端口吗?](https://xiaolincoding.com/network/3_tcp/port.html) - [服务端没有 listen,客户端发起连接建立,会发生什么?](https://xiaolincoding.com/network/3_tcp/tcp_no_listen.html) - [没有 accept,可以建立 TCP 连接吗?](https://xiaolincoding.com/network/3_tcp/tcp_no_accpet.html) - [用了 TCP 协议,数据一定不会丢吗?](https://xiaolincoding.com/network/3_tcp/tcp_drop.html) - [TCP 四次挥手,可以变成三次吗?](https://xiaolincoding.com/network/3_tcp/tcp_three_fin.html) - [TCP 序列号和确认号是如何变化的?](https://xiaolincoding.com/network/3_tcp/tcp_seq_ack.html) - **IP 篇** :point_down: - [IP 基础知识全家桶](https://xiaolincoding.com/network/4_ip/ip_base.html) - [ping 的工作原理](https://xiaolincoding.com/network/4_ip/ping.html) - [断网了,还能 ping 通 127.0.0.1 吗?](https://xiaolincoding.com/network/4_ip/ping_lo.html) - **学习心得** :point_down: - [计算机网络怎么学?](https://xiaolincoding.com/network/5_learn/learn_network.html) - [画图经验分享](https://xiaolincoding.com/network/5_learn/draw.html) ## :open_book:《图解系统》 - **介绍**:point_down:: - [图解系统介绍](https://xiaolincoding.com/os/) - **硬件结构** :point_down: - [CPU 是如何执行程序的?](https://xiaolincoding.com/os/1_hardware/how_cpu_run.html) - [磁盘比内存慢几万倍?](https://xiaolincoding.com/os/1_hardware/storage.html) - [如何写出让 CPU 跑得更快的代码?](https://xiaolincoding.com/os/1_hardware/how_to_make_cpu_run_faster.html) - [CPU 缓存一致性](https://xiaolincoding.com/os/1_hardware/cpu_mesi.html) - [CPU 是如何执行任务的?](https://xiaolincoding.com/os/1_hardware/how_cpu_deal_task.html) - [什么是软中断?](https://xiaolincoding.com/os/1_hardware/soft_interrupt.html) - [为什么 0.1 + 0.2 不等于 0.3?](https://xiaolincoding.com/os/1_hardware/float.html) - **操作系统结构** :point_down: - [Linux 内核 vs Windows 内核](https://xiaolincoding.com/os/2_os_structure/linux_vs_windows.html) - **内存管理** :point_down: - [为什么要有虚拟内存?](https://xiaolincoding.com/os/3_memory/vmem.html) - [malloc 是如何分配内存的?](https://xiaolincoding.com/os/3_memory/malloc.html) - [内存满了,会发生什么?](https://xiaolincoding.com/os/3_memory/mem_reclaim.html) - [在 4GB 物理内存的机器上,申请 8G 内存会怎么样?](https://xiaolincoding.com/os/3_memory/alloc_mem.html) - [如何避免预读失效和缓存污染的问题?](https://xiaolincoding.com/os/3_memory/cache_lru.html) - [深入理解 Linux 虚拟内存管理](https://xiaolincoding.com/os/3_memory/linux_mem.html) - [深入理解 Linux 物理内存管理](https://xiaolincoding.com/os/3_memory/linux_mem2.html) - **进程管理** :point_down: - [进程、线程基础知识](https://xiaolincoding.com/os/4_process/process_base.html) - [进程间有哪些通信方式?](https://xiaolincoding.com/os/4_process/process_commu.html) - [多线程冲突了怎么办?](https://xiaolincoding.com/os/4_process/multithread_sync.html) - [怎么避免死锁?](https://xiaolincoding.com/os/4_process/deadlock.html) - [什么是悲观锁、乐观锁?](https://xiaolincoding.com/os/4_process/pessim_and_optimi_lock.html) - [一个进程最多可以创建多少个线程?](https://xiaolincoding.com/os/4_process/create_thread_max.html) - [线程崩溃了,进程也会崩溃吗?](https://xiaolincoding.com/os/4_process/thread_crash.html) - **调度算法** :point_down: - [进程调度/页面置换/磁盘调度算法](https://xiaolincoding.com/os/5_schedule/schedule.html) - **文件系统** :point_down: - [文件系统全家桶](https://xiaolincoding.com/os/6_file_system/file_system.html) - [进程写文件时,进程发生了崩溃,已写入的数据会丢失吗?](https://xiaolincoding.com/os/6_file_system/pagecache.html) - **设备管理** :point_down: - [键盘敲入 A 字母时,操作系统期间发生了什么?](https://xiaolincoding.com/os/7_device/device.html) - **网络系统** :point_down: - [什么是零拷贝?](https://xiaolincoding.com/os/8_network_system/zero_copy.html) - [I/O 多路复用:select/poll/epoll](https://xiaolincoding.com/os/8_network_system/selete_poll_epoll.html) - [高性能网络模式:Reactor 和 Proactor](https://xiaolincoding.com/os/8_network_system/reactor.html) - [什么是一致性哈希?](https://xiaolincoding.com/os/8_network_system/hash.html) - **学习心得** :point_down: - [如何查看网络的性能指标?](https://xiaolincoding.com/os/9_linux_chtml/linux_network.html) - [画图经验分享](https://xiaolincoding.com/os/9_linux_chtml/pv_uv.html) - **学习心得** :point_down: - [计算机网络怎么学?](https://xiaolincoding.com/os/10_learn/learn_os.html) - [画图经验分享](https://xiaolincoding.com/os/10_learn/draw.html) ## :open_book:《图解 MySQL》 - **介绍**:point_down:: - [图解 MySQL 介绍](https://xiaolincoding.com/mysql/) - **基础篇**:point_down:: - [执行一条 select 语句,期间发生了什么?](https://xiaolincoding.com/mysql/base/how_select.html) - [MySQL 一行记录是怎么存储的?](https://xiaolincoding.com/mysql/base/row_format.html) - **索引篇** :point_down: - [索引常见面试题](https://xiaolincoding.com/mysql/index/index_interview.html) - [从数据页的角度看 B+ 树](https://xiaolincoding.com/mysql/index/page.html) - [为什么 MySQL 采用 B+ 树作为索引?](https://xiaolincoding.com/mysql/index/why_index_chose_bpuls_tree.html) - [MySQL 单表不要超过 2000W 行,靠谱吗?](https://xiaolincoding.com/mysql/index/2000w.html) - [索引失效有哪些?](https://xiaolincoding.com/mysql/index/index_lose.html) - [MySQL 使用 like“%x“,索引一定会失效吗?](https://xiaolincoding.com/mysql/index/index_issue.html) - [count(\*) 和 count(1) 有什么区别?哪个性能最好?](https://xiaolincoding.com/mysql/index/count.html) - **事务篇** :point_down: - [事务隔离级别是怎么实现的?](https://xiaolincoding.com/mysql/transaction/mvcc.html) - [MySQL 可重复读隔离级别,完全解决幻读了吗?](https://xiaolincoding.com/mysql/transaction/phantom.html) - **锁篇** :point_down: - [MySQL 有哪些锁?](https://xiaolincoding.com/mysql/lock/mysql_lock.html) - [MySQL 是怎么加锁的?](https://xiaolincoding.com/mysql/lock/how_to_lock.html) - [update 没加索引会锁全表](https://xiaolincoding.com/mysql/lock/update_index.html) - [MySQL 死锁了,怎么办?](https://xiaolincoding.com/mysql/lock/deadlock.html) - [字节面试:加了什么锁,导致死锁的?](https://xiaolincoding.com/mysql/lock/show_lock.html) - **日志篇** :point_down: - [MySQL 日志:undo log、redo log、binlog 有什么用?](https://xiaolincoding.com/mysql/log/how_update.html) - **内存篇** :point_down: - [揭开 Buffer Pool 的面纱](https://xiaolincoding.com/mysql/buffer_pool/buffer_pool.html) ## :open_book: 《图解 Redis》 - **面试篇** :point_down: - [Redis 常见面试题](https://xiaolincoding.com/redis/base/redis_interview.html) - **数据类型篇** :point_down: - [Redis 数据类型和应用场景](https://xiaolincoding.com/redis/data_struct/command.html) - [图解 Redis 数据结构](https://xiaolincoding.com/redis/data_struct/data_struct.html) - **持久化篇** :point_down: - [AOF 持久化是怎么实现的?](https://xiaolincoding.com/redis/storage/aof.html) - [RDB 快照是怎么实现的?](https://xiaolincoding.com/redis/storage/rdb.html) - **功能篇**:point_down: - [Redis 过期删除策略和内存淘汰策略有什么区别?](https://xiaolincoding.com/redis/module/strategy.html) - **高可用篇** :point_down: - [主从复制是怎么实现的?](https://xiaolincoding.com/redis/cluster/master_slave_replication.html) - [为什么要有哨兵?](https://xiaolincoding.com/redis/cluster/sentinel.html) - **缓存篇** :point_down: - [什么是缓存雪崩、击穿、穿透?](https://xiaolincoding.com/redis/cluster/cache_problem.html) - [数据库和缓存如何保证一致性?](https://xiaolincoding.com/redis/architecture/mysql_redis_consistency.html) ## :muscle: 学习心得 - [计算机基础学习路线](https://xiaolincoding.com/cs_learn/) :计算机基础学习书籍 + 视频推荐,全面且清晰。 - [互联网校招心得](https://xiaolincoding.com/reader_nb/) :小林神仙读者们的校招和学习心得,值得学习。 ## :books: 图解系列 PDF 下载 - [图解网络 + 图解系统 PDF 下载](https://mp.weixin.qq.com/s/02036z-FMOCLpZ_otwMwBg) ## 勘误及提问 如果有疑问或者发现错误,可以在相应的 Issues 进行提问或勘误,也可以在[图解计算机基础网站](https://xiaolincoding.com/)对应的文章底部留言。 如果喜欢或者有所启发,欢迎 Star,对作者也是一种鼓励。 ## 公众号 最新的图解文章都在公众号首发,强烈推荐关注!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/其他/公众号介绍.png) ================================================ FILE: cs_learn/README.md ================================================ # 介绍 本系列是小林的个人的学习心得,希望对大家有启发:muscle:。 - [学习计算机基础有什么推荐的书?](/cs_learn/cs_learn.md) - [看书的一点小建议](/cs_learn/look_book.md) - [如何将计算机网络、操作系统、数据结构与算法、计算组成融会贯通?](/cs_learn/feel_cs.md) ================================================ FILE: cs_learn/cs_learn.md ================================================ # 学习计算机基础有什么推荐的书? 大家好,我是小林。 之前有读者问我,学习计算机基础有什么推荐的书? 这一个我就很有心得了,因为我大学的专业并不是计算机专业的,是电气自动化专业的,所以学校的课程并没有操作系统、计算机网络、计算机组成、数据库这类课程,但是还是会有编程课。 所以,计算机基础这些课程都是我自学过来的,期间看过了很多的书,也在中国慕课和 B 站刷过很多视频,踩过不少的坑。 **其实在自学过程中,最容易踩坑的地方就是不看自己当前水平,盲目跟风买那些豆瓣高分的大而全的计算机黑皮系列的书,然后学几天,就放弃了。** 这些大而全的计算机黑皮书当然很经典,但是它们并不适合新人入门学习,因为这类书籍的内容都充满大量的专业术语,我们人在看到陌生又难以理解的词汇时,就会感觉很吃力,脑子看着就会很累,就会驱使你做简单和快乐的事情,比如睡觉、刷短视频、玩游戏。。。 不要问我,为什么知道的那么清楚,因为我就是这么踩坑过来的。 所以,我认为**学习一门学科的时候,要从最基础的书开始学起,接着搭配视频快速入门,然后再渐渐步入到这些大而全的计算机黑皮书**。 关键的问题来了,计算机基础有哪些入门的基础书和视频呢? 接下来,我就跟大家分享下,我看过的书和视频,都是**从入门再到进阶的路线。** ![图片](https://img-blog.csdnimg.cn/img_convert/8e4af8d45ca65fb478fedc8488a86921.png) ## 一、数据结构与算法 很多人同学在大一的时候,就学会了一门编程语言,大概率都是 C 语言,有了一定的编程能力,就可以开始学习数据结构与算法。 数据结构与算法这方面的话是非常非常非常重要的,想要冲大厂的同学们如果这方面不过关可能连笔试都过不去,更别谈面试的手撕算法了。 - **算法:** 动态规划、回溯算法、查找算法、搜索算法、贪心算法、分治算法、位运算、双指针、排序、模拟、数学、…… - **数据结构:** 数组、栈、队列、字符串、链表、树、图、堆、哈希表、…… ### 数据结构学习 首先推荐《**大话数据结构**》这本入门级别的书,因为书里的内容都是大白话,而且还图文并茂,读起来还是很顺畅的。 然后视频推荐《**浙江大学的数据结构**》课程,在 B 站就能搜索到,课程是老师带大家用 C 语言来实现各种常见的数据结构。 ![图片](https://img-blog.csdnimg.cn/img_convert/d92a6af0a7c93a6074d6cc23a78eec4d.png) 现在大多数高级语言都会有容器,就是把一些常见的数据结构封装成了容器,然后使用起来就比较方便,但是不利于我们理解底层的数据结构是怎么变换和操作的,所以这门课还是很有意义的。 如果想要实战的话,可以去 Leetcode 官方出品的免费教程 Leetbook,网站很细心的按照各个知识点循序渐进地罗列了出来。讲解知识 + 实战演练,学习起来会比看书效率高。 ![图片](https://img-blog.csdnimg.cn/img_convert/1cb852d316c0351440ff63009c1dac7a.png) ### 算法学习 之前有位校招去字节的读者分享过他的算法学习心得,我觉得写的不错,这里我就直接贴出来。 在我看来,笔试能力在校招中要占据 60% 的重要程度。 首先笔试不过,你根本没有面试的展示机会。其次面试中也会反复让你手写代码,以字节为例,每一轮面试都是 1-2 道编程题,有时候不怎么聊简历;百度每一轮面试有一道编程题。现在公司的面试模式就是这样,如果代码没搞出来,大概率会被淘汰。 并且最窒息的是这些代码题都不简单,一般都是 leetcode 中等到 hard 难度。 刷题主要可从以下三个渠道。 第一个,剑指 offer: *https://www.nowcoder.com/ta/coding-interviews* ![图片](https://img-blog.csdnimg.cn/img_convert/495530b807b915eadcadbb9a059209e6.png) 第二个,leetcode: *https://leetcode-cn.com/problemset/algorithms/* ![图片](https://img-blog.csdnimg.cn/img_convert/fc367892c4ecd7d69921882b20b1e7db.png) 第三个,近期面试中常考题: *https://www.nowcoder.com/activity/oj* ![图片](https://img-blog.csdnimg.cn/img_convert/0de0efc5b97af270726392f21e4fe1f7.png) 《剑指 offer》作为大经典,是一定要刷两遍以上的,很多题都是面试时的原题,里面包含了很多笔试常用的思想方法,反复看,反复研。 我一开始每天 10 题,过两遍以后,要求自己每天快速过剑指一遍。 leetcode 由于题目十分之多,刷完是不可能的。我的意见是 leetcode 作为你弱项的专项练习。 leetcode 右侧有标签分类,如下图: ![图片](https://img-blog.csdnimg.cn/img_convert/211771c0c74b22307f82d88f5aca20aa.png) 另外在巩固完弱项的情况下,建议将 leetcode 前 300 题刷熟练,据我个人面试经历,国内大厂面试出的代码题 80% 都是这个范围内的。 另外,我在刷题的时候看了 labuladong 总结的算法小抄,对算法的理解很有帮助。 最后根据我的面试经历,根据个人感觉,公司常考题有: - 链表类(链表反转类题目) - 二叉树类(二叉树的遍历类型题,最大公祖先类题目) - 字符串操作题目 - dfs/bfs - 动态规划(这个考的基本都是 leetcode 上的或者是背包问题,对于动态规划问题其实有很多种类,比较见到的就是一维动态和二维动态),另外还有区间调度类型的题目(贪心算法,也属于动态规划的一种特殊情况。 其实也没有什么技巧,多刷题,多理解就好了。 ## 二、计算机组成原理 计算组成原理确实是分为两个方向,一个是硬件电路的,一个是软件程序的。 我自己本身是干开发的,所以我这次分享的机组资料是跟软化程序有关的,也就是不会涉及到硬件电路的东西,即使你不会数字电路、微机原理也是可以直接学习的。 ### 入门学习 先极力推荐 b 站的《**计算机科学速成课**》,这个课程是国外录制的,内容真的是好,视频的动画很精美,讲课的时候不会很死板,反正就是不看后悔、相见很晚系列。 ![图片](https://img-blog.csdnimg.cn/img_convert/4ea2fcc42cbfbb17f1ae882c058a9597.png) **对于入门计算机组成,可以先看前 10 个视频**,看完这 10 个视频也就不到 2 个小时,看完前 10 个视频对计算机的工作方式就有一个基本的了解了。 看完前 10 个视频就可以开始看书了。 讲真,不太建议小白一上来就看那些厚的不行的计算机组成原理的黑皮书,这些书是经典的没错,也正是由于它们是经典的,所以这些书的知识体系很全、很多、很厚。 但是这样很容易让初学者迷失在里头,可能刚兴致勃勃看几十页就放弃了,于是这些厚的不行的书就成为了你们的**垫书神器**,**知识没学多少,颈椎病倒是治好了。** 对于初学者,我推荐两本书《**计算机是怎么样跑起来**》和《**程序是怎么跑起来的**》,这两本很薄而且图文并茂,作者都是用大白话的方式来阐述知识,这点对初学者非常友好。 这两本不用 1 个月就能看完,因为在看这两本书的时候,你会看的很顺畅,相比学习的心态,你更多的是会带着「好奇心」的心态去读。 其中 **《程序是怎么跑起来的》是一个「微缩版本」的计算机组成原理**,你可以只选择看这一本,从这本书的名字也可以知道,它是从计算机是怎么运行程序的视角来讲的,然后把涉及到的计算机硬件和它们之间是如何协作的一点一点的给大家带出来,让大家能瞬间明白这些计算机硬件的作用。 这本仅仅是入门级别,主要的作用是让初学者明白计算机组成原理这门课是学什么的,以及梳理主要的知识体系,有了这本书的概念后,再去深入计算机组成的时候,就不会雨里雾里的。 另外,《**编码:隐匿在计算机软硬件背后的语言**》这本书也很不错,是本**科普类的书**,非常适合非科班的同学,主要讲是计算机工作的原理(二进制编码、加减法运算、计算机部件、浮点数定点数、处理器等),也就是跟计组息息相关的知识,它的内容很有趣味性,并不像教科书那样晦涩难懂,丝毫不会让你感到生硬,读起来很畅快。 ### 深入学习 想要深入学习计算机组成原理的同学,我首先推荐《**计算机组成与设计:硬件 / 软件接口**》这本书, 这本书确实很厚,差不多 500 多页,但是**书从来没有人规定一定要从头读到尾,一页页的读的。** 重要的不是看完一本书,而是从书上学到多少,解决了什么问题。 大家可以挑这几个章节看,跟开发者关系比较大的章节: - **第一章:计算机抽象以及相关技术**,这个章节主要是介绍了计算机组成的思想,可以简单快读看,不用重点读; - **第二章:指令**,大体上讲的是计算机是如果识别和运行指令的,以及代码到指令的过程; - **第三章:计算机的算数运算**,介绍的是计算机是如何进行加减乘除法的,以及浮点数的运算; - **第五章:层次化存储**,讲的是计算机的存储层次结构,而且重点讲的是 CPU Cahe。 看书觉得很累,也可以结合视频一起看,这里推荐**哈工大的《计算机组成原理》视频**,在 b 站就可以直接看,大家自己去搜索就可以。 **看书和看视频可以相互结合的,比如你看视频看了计算机指令的内容,然后你可以不用继续往下看,可以回到一本书上,看书上对应这个章节的内容,这是个很好的学习方法,视频和书籍相辅相成。** 你要是觉得哈工大的计组课程太难,你可以看**王道考研的计算机组成原理的视频课程**,同样 b 站就可以看。 这个视频虽然是针对考研的,但是也是可以作为学习计组的资料,讲的内容不会太深,适合你快速建立计算机组成原理体系,和梳理计组知识的脉络。 另外,在推荐一本《**深入理解计算系统**》这本书,人称 CSAPP。 可能大家以为这本书是讲操作系统的,我最开始也以为是这样。后面当我开始啃这本书的时候,发现我大错特错,它远不止我想的那样。 这本书是从程序员的角度学习计算机系统是如何工作的,通过描述程序是如何映射到计算机系统上,程序是如何执行的,以及程序效率低下的原因,这样的方式可以让大家能更好的知道「程序与计算机系统」的关系。 CSAPP 涵盖的内容非常多,有**计算机组成 + 操作系统 + 汇编 + C 语言 + Linux 系统编程**,涉猎的领域比较多,是一本综合性的书,更是一本程序员修炼内功的指引书。 CSAPP 主要包括以下内容: - 信息表示(如何使用二进制表示整型、浮点数等); - C 和汇编语言的学习(通过汇编语言更深入地理解 C 语言是什么); - 计算机体系结构(存储层次结构、局部性原理、处理器体系结构); - 编译链接(C 语言如何从文本变成可执行文件、静态链接、动态链接); - 操作系统的使用(异常控制流、虚拟内存、多个系统调用介绍); - 网络及并发编程(并发的基本概念、网络相关的系统调用的介绍)。 你会发现有部分内容和《**计算机组成与设计:硬件 / 软件接口**》这本书重合了,**重合的部分就是重中之重的计算机组成原理知识了**,而且内容都是差不多的,你可以看完一本书的内容,然后跳到另外一本看相同章节的内容,多本书的结合可以让我们更加容易理解。 这两本书有个区别: - 《计算机组成与设计:硬件 / 软件接口》讲的指令格式是 RISC 的; - 《深入理解计算系统》讲的指令格式是 x86 的; 其他重合的计组知识都大同小异。 CSAPP 的视频课程是国外老师录制的,但是在 b 站已经有好人帮我们做了中文字幕,看了这视频,相当于在国外上了一门计算机课的感觉。 B 站地址:*https://www.bilibili.com/video/BV1iW411d7hd* ![图片](https://img-blog.csdnimg.cn/img_convert/911ab1c94e54c316c6d7a80a31707178.png) 如果你是在校生,有了一定 C 语言基础后,非常建议你就开始看这本书,有精力也可以做做 CSAPP 的 lab。**越早开始看,你的收益就越大,因为当计算机体系搭建起来后,你后面再深入每一个课程的时候,你会发现学起来会比较轻松些。** 对于已经工作了,但是计算机系统没有一个清晰认识的读者,也可以从这本书开始一点一点学起来,这本书是很厚,但是并不一定要把书完完看完,每个章节的知识点还是比较独立的,有关硬件的章节我们可以选择跳过。 ## 三、操作系统 操作系统真的可以说是 `Super Man`,它为了我们做了非常厉害的事情,以至于我们根本察觉不到,只有通过学习它,我们才能深刻体会到它的精妙之处,甚至会被计算机科学家设计思想所震撼,有些思想实际上也是可以应用于我们工作开发中。 操作系统比较重要的四大模块,分别是[内存管理](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247485033&idx=1&sn=bf9ba7aca126ad186922c57a96928593&scene=21#wechat_redirect)、[进程管理](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247485175&idx=1&sn=eda03758d4e810afd897ade44c19a508&scene=21#wechat_redirect)、[文件系统管理](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247485446&idx=1&sn=2c525f008622b98bc08a66f2b4dcfee8&scene=21#wechat_redirect)、[输入输出设备管理](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247485498&idx=1&sn=6948f309461ea83c691892949c8272dd&scene=21#wechat_redirect)。这是我学习操作系统的顺序,也是我推荐给大家的学习顺序,因为内存管理不仅是最重要、最难的模块,也是和其他模块关联性最大的模块,先把它搞定,后续的模块学起来我认为会相对轻松一些。 学习的过程中,你可能会遇到很多「虚拟」的概念,比如虚拟内存、虚拟文件系统,实际上它们的本质上都是一样的,都是**向下屏蔽差异,向上提供统一的东西**,以方便我们程序员使用。 还有,你也遇到各种各样的[调度算法](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247485564&idx=1&sn=b1673a5da4fab943a8a0d27ca1f1fb5c&scene=21#wechat_redirect),在这里你可以看到数据结构与算法的魅力,重要的是我们要理解为什么要提出那么多调度算法,你当然可以说是为了更快更有效率,但是因什么问题而因此引入新算法的这个过程,更是我们重点学习的地方。 你也会开始明白进程与线程最大的区别在于上下文切换过程中,**线程不用切换虚拟内存**,因为同一个进程内的线程都是共享虚拟内存空间的,线程就单这一点不用切换,就相比进程上下文切换的性能开销减少了很多。由于虚拟内存与物理内存的映射关系需要查询页表,页表的查询是很慢的过程,因此会把常用的地址映射关系缓存在 TLB 里的,这样便可以提高页表的查询速度,如果发生了进程切换,那 TLB 缓存的地址映射关系就会失效,缓存失效就意味着命中率降低,于是虚拟地址转为物理地址这一过程就会很慢。 你也开始不会傻傻的认为 read 或 write 之后数据就直接写到硬盘了,更不会觉得多次操作 read 或 write 方法性能会很低,因为你发现操作系统会有个「**磁盘高速缓冲区**」,它已经帮我们做了缓存的工作,它会预读数据、缓存最近访问的数据,以及使用 I/O 调度算法来合并和排队磁盘调度 I/O,这些都是为了减少操作系统对磁盘的访问频率。 …… 还有太多太多了,我在这里就不赘述了,剩下的就交给你们在学习操作系统的途中去探索和发现了。 还有一点需要注意,学操作系统的时候,不要误以为它是在说 Linux 操作系统,这也是我初学的时候犯的一个错误,操作系统是集合大多数操作系统实现的思想,跟实际具体实现的 Linux 操作系统多少都会有点差别,如果要想 Linux 操作系统的具体实现方式,可以选择看 Linux 内核相关的资料,但是在这之前你先掌握了操作系统的基本知识,这样学起来才能事半功倍。 ### 入门系列 对于没学过操作系统的小白,我建议学的时候,不要直接闷头看书。 相信我,你不用几分钟就会打退堂鼓,然后就把厚厚的书拿去垫显示器了,从此再无后续,毕竟直接看书太特喵的枯燥了,当然不如用来垫显示器玩游戏来着香。 B 站关于操作系统课程资源很多,我在里面也看了不同老师讲的课程,觉得比较好的入门级课程是《**操作系统 - 清华大学**》,该课程由清华大学老师向勇和陈渝授课,虽然我们上不了清华大学,但是至少我们可以在网上选择听清华大学的课嘛。课程授课的顺序,就如我前面推荐的学习顺序:「内存管理 -> 进程管理 -> 文件系统管理 -> 输入输出设备管理」。 ![图片](https://img-blog.csdnimg.cn/img_convert/f73da593f654cea3c11f2be2f58bb490.png) 《操作系统 - 清华大学》 该清华大学的视频教学搭配的书应该是《**现代操作系统**》,你可以视频和书籍两者结合一起学,比如看完视频的内存管理,然后就看书上对应的章节,这样相比直接啃书相对会比较好。 清华大学的操作系统视频课讲的比较精炼,涉及到的内容没有那么细,《**操作系统 - 哈工大**》李治军老师授课的视频课程相对就会比较细节,老师会用 Linux 内核代码的角度带你进一步理解操作系统,也会用生活小例子帮助你理解。 ![图片](https://img-blog.csdnimg.cn/img_convert/423db5b8997f04b352bc84980ea82aaa.png) 《操作系统 - 哈工大》 ### 深入系列 《现代操作系统》这本书我感觉缺少比较多细节,说的还是比较笼统,而且书也好无聊。 推荐一个说的更细的操作系统书 —— 《**操作系统导论**》,这本书不仅告诉你 What,还会告诉你 How,书的内容都是循序渐进,层层递进的,阅读起来还是觉得挺有意思的,这本书的内存管理和并发这两个部分说的很棒。 去年国内也出了一本不错的操作系统书《**现代操作系统 - 原理与实现**》,这本书不怎么厚,把操作系统重要的知识都讲了一遍,是我看过的操作系统书里配图比较多的书了,学起来不会太费解。 当然,少不了这本被称为神书的《**深入理解计算机系统**》,豆瓣评分高达 `9.8` 分,这本书严格来说不算操作系统书,它是以程序员视角理解计算机系统,不只是涉及到操作系统,还涉及到了计算机组成、C 语言、汇编语言等知识,是一本综合性比较强的书。 ## 四、计算机网络 计算机网络相比操作系统好学非常多,因为计算机网络不抽象,你要想知道网络中的细节,你都可以通过抓包来分析,而且不管是手机、个人电脑和服务器,它们所使用的计算网络协议是一致的。 也就是说,计算机网络不会因为设备的不同而不同,大家都遵循这一套「规则」来相互通信,这套规则就是 TCP/IP 网络模型。 ![图片](https://img-blog.csdnimg.cn/img_convert/c644838e318f6cfb144e875c999625e7.png) OSI 参考模型与 TCP/IP 的关系 TCP/IP 网络参考模型共有 `4` 层,其中需要我们熟练掌握的是应用层、传输层和网络层,至于网络接口层(数据链路层和物理层)我们只需要做简单的了解就可以了。 对于应用层,当然重点要熟悉最常见的 [HTTP 和 HTTPS](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247483971&idx=1&sn=8f2d5dae3d95efc446061b352c8e9961&scene=21#wechat_redirect),传输层 TCP 和 UDP 都要熟悉,网络层要熟悉 [IPv4](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247483971&idx=1&sn=8f2d5dae3d95efc446061b352c8e9961&scene=21#wechat_redirect),IPv6 可以做简单点了解。 我觉得学习一个东西,就从我们常见的事情开始着手。 比如,ping 命令可以说在我们判断网络环境的时候,最常使用的了,你可以先把你电脑 ping 你舍友或同事的电脑的过程中发生的事情都搞明白,这样就基本知道一个数据包是怎么转发的了,于是你就知道了网络层、数据链路层和物理层之间是如何工作,如何相互配合的了。 搞明白了 ping 过程,就明白了两个计算机是怎么通信的了,然后你学起 HTTP 请求过程的时候,会很快就能掌握了,因为网络层以下的工作方式,你在学习 ping 的时候就已经明白了,这时就只需要认真掌握传输层中的 TCP 和应用层中的 HTTP 协议,就能搞明白[访问网页的整个过程](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247483989&idx=1&sn=7e2ed852770743d3955ef9d5561fcef3&scene=21#wechat_redirect)了,这也是面试常见的题目了,毕竟它能考察你网络知识的全面性。 重中之重的知识就是 TCP 了,TCP 不管是[建立连接、断开连接](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247484005&idx=1&sn=cb07ee1c891a7bdd0af3859543190202&scene=21#wechat_redirect)的过程,还是数据传输的过程,都不能放过,针对数据可靠传输的特性,又可以拆解为[超时重新、流量控制、滑动窗口、拥塞控制](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247484017&idx=1&sn=dc54d43bfd5dc088e48adcfa2e2bc13f&scene=21#wechat_redirect)等等知识点,学完这些只能算对 TCP 有个「**感性**」的认识,另外我们还得知道 Linux 提供的 [TCP 内核的参数](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247484774&idx=1&sn=fa9e67e60c5f9d2e9d2aa6ea8ab2a441&scene=21#wechat_redirect)的作用,这样才能从容地应对工作中遇到的问题。 接下来,推荐我看过并觉得不错的计算机网络相关的书籍和视频。 ### 入门系列 此系列针对没有任何计算机基础的朋友,如果已经对计算机轻车熟路的大佬,也不要忽略,不妨看看我推荐的正确吗。 如果你要入门 HTTP,首先最好书籍就是《**图解 HTTP**》了,作者真的做到完完全全的「图解」,小林的图解功夫还是从这里偷学到不少,书籍不厚,相信优秀的你,几天就可以看完了。 如果要入门 TCP/IP 网络模型,我推荐的是《**图解 TCP/IP**》,这本书也是以大量的图文来介绍了 TCP/IP 网络模式的每一层,但是这个书籍的顺序不是从「应用层 —> 物理层」,而是从「物理层 -> 应用层」顺序开始讲的,这一点我觉得不太好,这样一上来就把最枯燥的部分讲了,很容易就被劝退了,所以我建议先跳过前面几个章节,先看网络层和传输层的章节,然后再回头看前面的这几个章节。 另外,你想了解网络是怎么传输,那我推荐《**网络是怎样连接的**》,这本书相对比较全面的把访问一个网页的发生的过程讲解了一遍,其中关于电信等运营商是怎么传输的,这部分你可以跳过,当然你感兴趣也可以看,只是我觉得没必要看。 如果你觉得书籍过于枯燥,你可以结合 B 站《**计算机网络微课堂**》视频一起学习,这个视频是湖南科技大学老师制作的,PPT 的动图是我见过做的最用心的了,一看就懂的佳作。 ![图片](https://img-blog.csdnimg.cn/img_convert/a90178a6726ca49fe8a028c85e7389b2.png) 《计算机网络微课堂》 ### 深入学习 看完入门系列,相信你对计算机网络已经有个大体的认识了,接下来我们也不能放慢脚步,快马加鞭,借此机会继续深入学习,因为隐藏在背后的细节还是很多的。 对于 TCP/IP 网络模型深入学习的话,推荐《**计算机网络 - 自顶向下方法**》,这本书是从我们最熟悉 HTTP 开始说起,一层一层的说到最后物理层的,有种挖地洞的感觉,这样的内容编排顺序相对是比较合理的。 但如果要深入 TCP,前面的这些书还远远不够,赋有计算机网络圣经的之说的《**TCP/IP 详解 卷一:协议**》这本书,是进一步深入学习的好资料,这本书的作者用各种实验的方式来细说各种协议,但不得不说,这本书真的很枯燥,当时我也啃的很难受,但是它质量是真的很高,这本书我只看了 TCP 部分,其他部分你可以选择性看,但是你一定要过几遍这本书的 TCP 部分,涵盖的内容非常全且细。 要说我看过最好的 TCP 资料,那必定是《**The TCP/IP GUIDE**》这本书了,目前只有英文版本的,而且有个专门的网址可以白嫖看这本书的内容,图片都是彩色,看起来很舒服很鲜明,小林之前写的 TCP 文章不少案例和图片都是参考这里的,这本书精华部分就是把 TCP 滑动窗口和流量控制说的超级明白,很可惜拥塞控制部分说的不多。 ![图片](https://img-blog.csdnimg.cn/img_convert/1689ef2b417e6247546d553f63655f28.png) 《The TCP/IP GUIDE》 > 白嫖站点:http://www.tcpipguide.com/free/t_TCPSlidingWindowAcknowledgmentSystemForDataTranspo-6.htm 当然,计算机网络最牛逼的资料,那必定 **RFC 文档**,它可以称为计算机网络世界的「法规」,也是最新、最权威和最正确的地方了,困惑大家的 TCP 为什么三次握手和四次挥手,其实在 RFC 文档几句话就说明白了。 > TCP 协议的 RFC 文档:https://datatracker.ietf.org/doc/rfc1644/ ### 实战系列 在学习书籍资料的时候,不管是 TCP、UDP、ICMP、DNS、HTTP、HTTPS 等协议,最好都可以亲手尝试抓数据报,接着可以用 [Wireshark 工具](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247484469&idx=1&sn=55ec7f4addf10ddf25e8c8806da83921&scene=21#wechat_redirect)看每一个数据报文的信息,这样你会觉得计算机网络没有想象中那么抽象了,因为它们被你「抓」出来了,并毫无保留地显现在你面前了,于是你就可以肆无忌惮地「扒开」它们,看清它们每一个头信息。 那在这里,我也给你推荐 2 本关于 Wireshark 网络分析的书:**《Wireshark 网络分析就这么简单》与《Wireshark 网络分析的艺术》**。 这两本书都是同一个作者,书中的案例都是源于作者工作中的实际的案例,作者的文笔相当有趣,看起来堪比小说一样爽,相信你不用一个星期 2 本都能看完了。 ## 五、MySQL MySQL 入门的话是了解 SQL 语法,进阶的话是深入底层实现原理。 千万不要一上来就看《高性能 MySQL》,我曾经先读《高性能 MySQL》然后一路暴雷…… 因为这本不是入门的书籍! 我先介绍下 MySQL 的重点知识,也是面试常面的知识点: - **基本语法**:select/delete/insert/update、limit、join 等 - **索引**:B+树,聚族索引,二级索引,组合索引,最左匹配原则,索引失效、慢查询 - **事务**:事务四大特性 ACID,事务隔离级别,MVCC - **锁**:全局锁、表级锁、行级锁、快照读、当前读、乐观锁、悲观锁、死锁 - **日志**:重做日志 (redo log)、回滚日志 (undo log)、二进制日志 (binlog) - **架构**:读写分离、主从架构、分库分表、数据库和缓存双写一致性 MySQL 入门推荐《**SQL 必知必会**》,这一本很薄的书,主要是讲数据库增删查改的 SQL 语法。 学完 SQL 语法,我们不能止步,要深入去了解 MySQL 底层知识。 这里建议先看《**MySQL 是怎么运行的**》,这本书含有很多图,是小白学习 MySQL 底层知识的最佳书籍,看了下作者简介,他是裸辞一年在家里写出来的这本书,看的出来非常用心的了。 MySQL 用的最多的就是 InnoDB 引擎了,所以进一步学习 InnoDB 是很有必要的,这里推荐《**MySQL 技术内幕**》,这本书可以结合《MySQL 是怎么样运行的》一起看。 好了,看完上面的,你对 MySQL 已经有相当多的认识了,MySQL 还有一本高性能的书《高性能 MySQL》,非常的经典,这本书比较厚,大家可以当作字典,索引章节大家可以去看看,看完后你对索引的认识又会刷新一遍。 ## 六、Redis 要入门 Redis,就要先知道这东西怎么用,说白了,最开始就是先学习操作 Reids 的相关命令,就像我们入门 MySQL 的时候,都是先学习 SQL 语言。 入门 Redis 命令这一块我当时没有去专门买书看,而是直接看视频,因为我觉得命令的使用实操性还是比较强的,跟着老师敲命令学习会比较快一些。 这里我推荐下**尚硅谷 Redis 视频课**,在 B 站就可以看,讲的还是挺清晰的,也把 Redis 很多重点知识也讲了,比如 Redis 基本数据结构、持久化技术、主从复制、哨兵、集群等等,一套连招下来,就基本入门了。 ![图片](https://img-blog.csdnimg.cn/img_convert/ccf05f1c3190218a55eb4ba11a4fce89.png) Redis 官网也有一整套的命令详解,遇到需要或者不会的地方可以查一下:http://doc.redisfans.com ![图片](https://img-blog.csdnimg.cn/img_convert/2a4f283ca83d9433c18cfbfccc81d27c.png) 视频是帮助我们快速入门,但是并不能止于视频,因为一些细节的知识点视频上并没有提及,这时候我们就要回归书本。 这里推荐学习 Redis 的圣经级别的书——《**Redis 设计与实现**》,因为它太经典了! 这本书不是教你如何使用 Redis,而是跟你讲解 Redis 是怎么实现,怎么设计的,也就说源码级别的学习,但是书上并没有大段贴代码,作者是用伪代码的方式来讲解,所以读起来不会太难的。 书本上主要围绕这几大知识点:**数据结构、AOF 和 RDB 持久化技术、网络输入输出系统、主从复制、哨兵模式、集群模式。** 到这里你已经是入门 Redis 了,不仅会了 Redis 基本命令,还懂 Redis 的实现,剩下的就是学习如何在实战中运用 Redis。 这里推荐《**Redis 实战**》这本书,该书通过实际的例子,展示了使用 Redis 构建多种不同的应用程序的方法。 处于进阶阶段的 Redis 学习者可以通过阅读该书来学习如何使用 Redis 去构建实际的应用,然后举一反三,把书中介绍的程序和方法应用到自己遇到的问题上。 ## 七、看书心得 **没有人规定看书一定要一页一页的全部看完,我们要知道看书的目的是什么?** 无非不就是收获知识,和解决问题嘛。 所以最好的看书方式是带着问题去翻阅,比如: - 带着程序是如何在计算机里跑起来的问题,去学计算机组成原理; - 带着输入一条 url 到网页显示,期间发生了什么的问题,去学习计算机网络; - 带着进程、内存、磁盘是如何被操作系统管理点,去学习操作系统; - 带着如何实现一个高并发网络模型,去学习网络编程; - …… 我之前也写过一篇我的看书心得,帮助到了很多同学,建议没看过的同学,去看看; [看书的一点小建议](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247493177&idx=1&sn=77e32cec53e8a1aee9fa9fd3b31b19c2&scene=21#wechat_redirect) ## 八、融汇贯通 看到这,很多小伙伴会说了,学了这么多计算机基础,怎么将这些知识点融会贯通呢? 我之前写过一篇文章,用一个案例把计算机原理 + 操作系统 + 数据结构 + 计算机网络融会贯通,带大家感受下感受计算基础之美:[如何将计算机组成、操作系统、计算机网络、数据结构与算法融会贯通?](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247495586&idx=1&sn=14450e0efaef7a9eeeccb15faa2d007c&scene=21#wechat_redirect) ------ 这次分享就到这啦。 ================================================ FILE: cs_learn/feel_cs.md ================================================ # 如何将计算机网络、操作系统、数据结构与算法、计算组成融会贯通? 大家好,我是小林。 有位关注我一年的读者找我,他去年关注我公众后,开始自学 CS,主要是计算机基础这一块。 ![图片](https://img-blog.csdnimg.cn/img_convert/d3692e2197fb0d020ca4f13a4d3a1b42.png) 他从那时起,就日复一日的学习,并在 Github 有做笔记的习惯,你看他的提交记录,每天都有,一天都没拉下,就这样坚持了一年。 这个一年没有间断过的坚持,我是真的被震撼到,虽然我也经常肝文章,但是我也做不到每天都是学习的状态,总会想偷懒几天,毕竟学习真的是反人性的哈哈。 这位读者去年的时候,也只是会用 python 输出 hello world 初学者,而如今能开始啃 Redis 源码了,并且还记录了学习 Redis 数据结构的源码笔记。 ![图片](https://img-blog.csdnimg.cn/img_convert/459e34ac553f5c7d43b022c3bf132afc.png) 我也跟他讨论了我学计算基础的感受,他也有相同的感受,看来是同道中人。 ![图片](https://img-blog.csdnimg.cn/img_convert/28b130de1b3131bbb80255bdb8a525aa.png) 之前有很多读者问我学计算机基础有啥用?不懂算法、计算机网络、操作系统这些东西,也可以完成工作上的 CRUD 业务开发,那为什么要花时间去学? 是的,不懂这些,确实不会影响 CRUD 业务开发,对于这类业务开发的工作,难点是在于对业务的理解,但是门槛并不高,找个刚毕业人,让他花几个月时间熟悉业务和代码,他一样可以上手开发了,也就是说,单纯的 CRUD 业开发工作很快就会被体力更好的新人取代的。 另外,在面对一些性能问题,如果没有计算机基础,我们是无从下手的,这时候程序员之间的分水岭就出来了。 **今天,我不讲虚的东西。** **我以如何设计一个「\*高性能的单机管理主机的心跳服务\*」的方式,让大家感受计算基础之美,这里会涉及到数据结构与算法 + 操作系统 + 计算机组成 + 计算机网络这些知识。** ![图片](https://img-blog.csdnimg.cn/img_convert/1605e8908f606b4856cb2ab18783ea61.png) 大家耐心看下去,你会发现原来计算机基础知识的用处,相信我,你会感触很深刻。 ------ ## 案例需求 后台通常是由多台服务器对外提供服务的,也就是所谓的集群。 ![图片](https://img-blog.csdnimg.cn/img_convert/950f3c4267ec15a866d4d2eeae4c913d.png) 如果集群中的某一台主机宕机了,我们必须要感知到这台主机宕机了,这样才做容灾处理,比如该宕机的主机的业务迁移到另外一台主机等等。 那如何感知呢?那就需要**心跳服务**了。 ![图片](https://img-blog.csdnimg.cn/img_convert/9f08aedac70921f6979ef9fd18c3efc3.png) 要求每台主机都要向一台主机上报心跳包,这样我们就能在这台主机上看到每台主机的在线情况了。 心跳服务主要做两件事情: - **发现宕机的主机**; - **发现上线的主机**。 看上去感觉很简单,但是**当集群达到十万,甚至百万台的时候,要实现一个可以能管理这样规模的集群的心跳服务进程,没点底层知识是无法做到的。** 接下来,将从三个维度来设计这个心跳服务: - 宕机判断算法的设计 - 高并发架构的设计 - 传输层协议的选择 ## 宕机判断算法的设计 这个心跳服务最关键是判断宕机的算法。 如果采用暴力遍历所有主机的方式来找到超时的主机,在面对只有几百台主机的场景是没问题,但是这个算法会随着主机越多,算法复杂度也会上升,程序的性能也就会急剧下降。 所以,我们应该设计一个可以应对超大集群规模的宕机判断算法。 我们先来思考下,心跳包应该有什么数据结构来管理? 心跳包里的内容是有主机上报的时间信息的,也就是有时间关系的,那么可以**用「双向链表」构成先入先出的队列**,这样就保存了心跳包的时序关系。 ![图片](https://img-blog.csdnimg.cn/img_convert/6b91f788ed74f2ec673e1e9951439e39.png) **由于采用的数据结构是双向链表,所以队尾插入和队头删除操作的时间复杂度是 O(1)。** 如果有新的心跳包,则将其插入到双向链表的尾部,那么最老的心跳包就是在双向链表的头部,这样在寻找宕机的主机时,只要看双向链表头部最老的心跳包,距现在是否超过 5 秒,如果超过 5 秒 则认为该主机宕机,然后将其从双向链表中删除。 细心的同学肯定发现了个问题,就是如果一个主机的心跳包已经在队列中,那么下次该主机的心跳包要怎么处理呢? 为了维持队列里的心跳包是主机最新上报的,所以要先找到该主机旧的心跳包,然后将其删除,再把新的心跳包插入到双向链表的队尾。 问题来了,在队列找到该主机旧的心跳包,**由于数据结构是双向链表,所以这个查询过程的时间复杂度时 O(N)**,也就是说随着队列里的元素越多,会越影响程序的性能,这一点我们必须优化。 **查询效率最好的数据结构就是「哈希表」了,时间复杂度只有 O(1)**,因此我们可以加入这个数据结构来优化。 哈希表的 Key 是主机的 IP 地址,Value 包含**主机在双向链表里的节点**,这样我们就可以通过哈希表轻松找到该主机在双向链表中的位置。 ![图片](https://img-blog.csdnimg.cn/img_convert/4eb462ff8fc7ca8333d58097cbafd1a6.png) 这样,每当收到心跳包时,先判断其在不在哈希表里。 - 如果不存在哈希表里,说明是新主机上线,**先将其插入到双向链表的尾部,然后将该主机的 IP 作为 Key,主机在双向链表的节点作为 Value 插入到哈希表**。 - 如果存在哈希表里,说明主机已经上线过,**先通过查询哈希表,找到该主机在双向链表里旧的心跳包的节点,然后就可以通过该节点将其从双向链表中删除,最后将新的心跳包插入到双向链表的队尾,同时更新哈希表**。 可以看到,上面这些操作全都是 O(1),不管集群规模多大,时间复杂度都不会增加,但是代价就是内存占用会越多,这个就是以**空间换时间**的方式。 有个细节的问题,不知道大家发现了没有,就是为什么队列的数据结构采用双向链表,而不是单向链表? 因为双向链表比单向链表多了个 pre 的指针,可以通过其找到上一个节点,那么在删除中间节点的时候,就可以直接删除,而**如果是单向链表在删除中间的时候,我们得先通过遍历找到需被删除节点的上一个节点,才能完成删除操作,这里中间多了个遍历操作**。 既然引入哈希表,那我们在判断出有主机宕机了(检查双向链表队头的主机是否超时),**除了要将其从双向链表中删除,也要从哈希表中删除**。要将主机从哈希表删除,首先我们要知道主机的 IP,因为这是哈希表的 Key。 双向链表存储的内容必须包含主机的 IP 信息,那**为了更快查询到主机的 IP,双向链表存储的内容可以是一个键值对(Key-Value),其 Key 就是主机的 IP,Value 就是主机的信息。** ![图片](https://img-blog.csdnimg.cn/img_convert/afb535960d1b9fb6bd19611111b2c139.png) 这样,在发现双向链表中头部的节点超时了,由于节点的内容是键值对,于是就能快速地从该节点获取主机的 IP,知道了主机的 IP 信息,就能把哈希表中该主机信息删除。 至此,就设计出了一个高性能的宕机判断算法,主要用了数据结构:哈希表 + 双向链表,通过这个组合,查询 + 删除 + 插入操作的时间复杂度都是 O(1),以空间换时间的思想,这就是**数据结构与算法之美**! 熟悉算法的同学应该感受出来了,上面这个算法就是**类 LRU 算法**,用于**淘汰最近最久使用的元素**的场景,该算法应用范围很广的,操作系统、Redis、MySQL 都有使用该算法。 在很多大厂面试的时候,经常会考察 LRU 算法,甚至会要求手写出来,后面我在写一篇 LRU 算法实现的文章。 ## 高并发架构的设计 设计完高效的宕机判断算法后,我们来设计个能充分利用服务器资源的架构,以应对高并发的场景。 首先第一个问题,选用单线程还是多线程模式? 选用单线程的话,意味着程序只能利用一个 CPU 的算力,如果 CPU 是一颗 1GHZ 主频的 CPU,意味着一秒钟只有 10 亿个时钟周期可以工作,如果要让心跳服务程序每秒接收到 100 万心跳包,那么就要求它必须在 1000 个时时钟周期内处理完一个心跳包。 这是无法做到的,因为一个汇编指令的执行需要多个时钟周期,更何况高级语言的一条语句是由多个汇编指令构成的,而且这个 1000 个时钟周期还要包含内核从网卡上读取报文,以及协议栈的报文分析。 因此,采用单线程模式会出现算力不足的情况,意味着在百万级的心跳场景下,容易出现内核缓冲区的数据无法被即使取出而导致溢出的现象,然后就会出现大量的丢包。 所以,我们要选择多进程或者多线程的模式,来充分利用多核的 CPU 资源。多进程的优势是进程间互不干扰,但是内存不共享,进程间通信比较麻烦,**因此采用多线程模式开发会更好一些,多线程间可以共享数据。** 多线程体现在「分发线程是多线程和工作线程是多线程」,决定了多线程开发模式后,我们还需要解决五个问题。 #### 第一个多路复用 我们应该使用多路复用技术来服务多个客户端,而且是要**使用 epoll**。 因为 select 和 poll 的缺陷在于,当客户端越多,也就是 Socket 集合越大,Socket 集合的遍历和拷贝会带来很大的开销; 而 epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了。 多路复用更详细的介绍,可以看之前这篇文章:[这次答应我,一举拿下 I/O 多路复用!](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247489558&idx=1&sn=7a96604032d28b8843ca89cb8c129154&scene=21#wechat_redirect) #### 第二个负载均衡 在收到心跳包后,我们应该要将心跳包均匀分发到不同的工作线程上处理。 分发的规则可以用哈希函数,这样在接收到心跳包后,解析出主机的 IP 地址,然后通过哈希函数分发给工作线程处理。 ![图片](https://img-blog.csdnimg.cn/img_convert/da0477685f5310bbf44b3a65aa755504.png) 于是**每个工作线程只会处理特定主机的心跳包,多个工作线程间互不干扰,不用在多个工作线程间加锁,从而实现了无锁编程**。 #### 第三个多线程同步 分发线程和工作线程之间可以加个消息队列,形成「生产者 - 消费者」模型。 分发线程负责将接收到的心跳包加入到队列里,工作线程负责从队列取出心跳包做进一步的处理。 除此之外,还需要做如下两点。 第一点,工作线程一般是多于分发线程,**给每一个工作线程都创建独立的缓冲队列**。 第二点,缓冲队列是会被分发线程和工作线程同时操作,所以在操作该队列要加锁,为了避免线程获取锁失而主动放弃 CPU,可以**选择自旋锁,因为自旋锁在获取锁失败后,CPU 还在执行该线程,只不过 CPU 在空转,效率比互斥锁高**。 ![图片](https://img-blog.csdnimg.cn/img_convert/b63f3afb0bf1db3811f78a7cae8b16ca.png) 更多关于锁的讲解可以看这篇:「[互斥锁、自旋锁、读写锁、悲观锁、乐观锁的应用场景](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247485583&idx=1&sn=412546e55f9f5cf394bdda633fcc2b1c&scene=21#wechat_redirect)」 #### 第四个线程绑定 CPU 现代 CPU 都是多核心的,线程可能在不同 CPU 核心来回切换执行,这对 CPU Cache 不是有利的,虽然 L3 Cache 是多核心之间共享的,但是 L1 和 L2 Cache 都是每个核心独有的。 **如果一个线程在不同核心来回切换,各个核心的缓存命中率就会受到影响**,相反如果线程都在同一个核心上执行,那么其数据的 L1 和 L2 Cache 的缓存命中率可以得到有效提高,缓存命中率高就意味着 CPU 可以减少访问 内存的频率。 当有多个同时执行「计算密集型」的线程,为了防止因为切换到不同的核心,而导致缓存命中率下降的问题,我们可以把**线程绑定在某一个 CPU 核心上**,这样性能可以得到非常可观的提升。 在 Linux 上提供了 `sched_setaffinity` 方法,来实现将线程绑定到某个 CPU 核心这一功能。 ![图片](https://img-blog.csdnimg.cn/img_convert/e43def3d28d7bf38c58447782d07b560.png) 更多关于 CPU Cache 的介绍,可以看这篇:「[如何写出让 CPU 跑得更快的代码?](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247486022&idx=1&sn=8bb5a066d81f77523a06cd09251055da&scene=21#wechat_redirect)」 #### 第五个内存分配器 Linux 默认的内存分配器是 PtMalloc2,它有一个缺点在申请小内存和多线程的情况下,申请内存的效率并不高。 后来,Google 开发的 TCMalloc 内存分配器就解决这个问题,**它在多线程下分配小内存的速度要快很多**,所以对于心跳服务应当改用 TCMalloc 申请内存。 下图是 TCMalloc 作者给出的性能测试数据,可以看到线程数越多,二者的速度差距越大,**显然 TCMalloc 更具有优势。** ![图片](https://img-blog.csdnimg.cn/img_convert/ecbde763ec855f0d08ce0785afb040f8.png) 我暂时就想到这么多了,**这里每一个点都跟「计算机组成和操作系统」知识密切相关**。 ## 传输层协议的选择 心跳包的传输层协议应该是选 TCP 和 UDP 呢? 对于传输层协议的选择,我们要看**心跳包的长度大小**。 如果长度小于 MTU,那么可以选择 UDP 协议,因为 UDP 协议没那么复杂,而且心跳包也不是一定要完全可靠传输,如果中途发生丢包,下一次心跳包能收到就行。 如果长度大于 MTU,就要选择 TCP 了,因为 UDP 在传送大于 1500 字节的报文,IP 协议就会把报文拆包后再发到网络中,并在接收方组装回原来的报文,然而,IP 协议并不擅长做这件事,拆包组包的效率很低。 所以,**TCP 协议就选择自己做拆包组包的事情,当心跳包的长度大于 MSS 时就会在 TCP 层拆包,且保证 TCP 层拆包的报文长度不会 MTU**。 ![图片](https://img-blog.csdnimg.cn/img_convert/7e88ec21809ce5601527ca81e9944384.png)MTU 与 MSS 选择了 TCP 协议后,我们还要解决一些事情,因为 TCP 协议是复杂的。 首先,要让服务器能支持更多的 TCP 连接,TCP 连接是通过**四元组**唯一确认的,也就是**「源 IP、目的 IP、源端口、目的端口」**。 那么当服务器 IP 地址(目的 IP)和监听端口(目标端口)固定时,变化的只有源 IP(2^32)和源端口(2^16),因此理论上服务器最大能连接 `2^(32+16)` 个客户端。 这只是理论值,实际上服务器的资源肯定达不到那么多连接。Linux 系统一切皆文件,所以 TCP 连接也是文件,那么服务器要增大下面这两个地方的最大文件句柄数: - 通过 `ulimit` 命令增大单进程允许最大文件句柄数; - 通过 `/proc/sys/fs/file-nr` 增大系统允许最大文件句柄数。 另外,TCP 协议的默认内核参数并不适应高并发的场景,所以我们还得在下面这四个方向通过调整内核参数来优化 TCP 协议: - 三次握手过程需要优化; - 四次挥手过程需要优化: - TCP 缓冲区要根据网络带宽时延积设置; - 需要优化; 前三个的优化的思路,我在之前的文章写过,详见:「[面试官:换人!他连 TCP 这几个参数都不懂](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247484774&idx=1&sn=fa9e67e60c5f9d2e9d2aa6ea8ab2a441&scene=21#wechat_redirect)」 这里简单说一下优化拥塞控制算法的思路。 传统的拥塞控制分为四个部分:慢启动、拥塞避免、快速重传、快速恢复,如下图: ![图片](https://img-blog.csdnimg.cn/img_convert/930207409b70829914a5c4ee39a5f807.png)TCP 拥塞控制 当 TCP 连接建立成功后,拥塞控制算法就会发生作用,首先进入慢启动阶段。决定连接此时网速的是初始拥塞窗口,默认值是 `10 MSS`。 在带宽时延积较大的网络中,应当调高初始拥塞窗口,比如 `20 MSS` 或 `30 MSS`,Linux 上可以通过 `route ip change` 命令修改它。 **传统的拥塞控制算法是基于丢包作为判断拥塞的依据**。不过实际上,网络刚出现拥塞时并不会丢包,而真的出现丢包时,拥塞已经非常严重了,比如像理由器里都有缓冲队列应对突发流量: ![图片](https://img-blog.csdnimg.cn/img_convert/2f9a1866b956d767bbe8a97e4ca9b48f.png) 上图中三种情况: - 当缓冲队列为空时,传输速度最快; - 当缓冲队列开始有报文挤压,那么网速就开始变慢了,也就是网络延时变高了; - 当缓冲队列溢出时,就出现了丢包现象。 传统的拥塞控制算法就是在第三步这个时间点进入拥塞避免阶段,显然已经很晚了。 其实进行拥塞控制的最佳时间点,是缓冲队列刚出现积压的时刻,也就是第二步。 Google 推出的 **BBR 算法是以测量带宽、时延来确定拥塞的拥塞控制算法**,能提高网络环境的质量,减少网络延迟和降低丢包率。 Linux 4.9 版本之后都支持 BBR 算法,开启 BBR 算法的方式: ```plain net.ipv4.tcp_congestion_control=bbr ``` 这里的每一个知识都涉及到了计算机网络,这就是**计算机网络之美**! ## 总结 掌握好数据结构与算法,才能设计出高效的宕机判断算法,本文我们采用哈希表 + 双向链表实现了类 LRU 算法。 掌握好计算组成 + 操作系统,才能设计出高性能的架构,本文我们采用多线程模式来充分利用 CPU 资源,还需要考虑 IO 多路服用的选择,锁的选择,消息队列的引入,内存分配器的选择等等。 掌握好计算机网络,才能选择契合场景的传输协议,如果心跳包长度大于 MTU,那么选择 TCP 更有利,但是 TCP 是个复杂的协议,在高并发的场景下,需要对 TCP 的每一个阶段需要优化。如果如果心跳包长度小于 MTU,且不要求可靠传输时,UDP 协议是更好的选择。 怎么样? 是不是感动到了计算机基础之美。 ================================================ FILE: cs_learn/look_book.md ================================================ # 看书的一点小建议 大家好,我是小林。 昨天看到小北写了篇「[看书的一点小建议](https://mp.weixin.qq.com/s?__biz=Mzg4NjUxMzg5MA==&mid=2247490764&idx=1&sn=7f1b25efd659ee6ca66b4845fbdba9cb&scene=21#wechat_redirect)」,写的很不错,今天我也根据自己经历,分享下看计算机基础类书的心得。 每隔一段时间,都有些读者跑来请教我学习的心得。 ![图片](https://img-blog.csdnimg.cn/img_convert/f3e678d6d722310ee4b5ea31b80abcd1.png) ![图片](https://img-blog.csdnimg.cn/img_convert/6a94b1c389461b1ba7ba16de2a45aaf6.png) 他们的困惑可以归类这几点: - 书看不懂,容易放弃,怎么办? - 看书的效率很低,怎么办? - 做了很多笔记,依然过会就忘记,怎么办? 这些困惑我曾经也经历过,中途也踩过很多坑,浪费了很多的时间,好在及时反思,调整了看书的方法,后面学习的效率立竿见影。 ------ ## 不要直接选择困难模式 大家应该知道计算机书里有个黑皮系列的书,黑皮系列的书有一个共同的特点就是**厚重**! 我相信不少小伙伴在想要学习计算机基础类知识的时候,就买了这类黑皮书,书到货后,我们满怀信心,举着厚重的黑皮书,下决心要把这些黑皮书一页一页地攻读下来,结果不过几天就被劝退了,然后就只有前几页是有翻阅的痕迹,剩下的几百页都完全是新的,最终这些厚厚的黑皮书就成了**垫显示器的神器**。 ![图片](https://img-blog.csdnimg.cn/img_convert/0293f9b6a101818b26b28e3464e9f567.png) 黑皮系列的书确实都是经典书,豆瓣评分都很高,知识点很全面,是好书无疑。但是这类书并不适合新手入门,你想想我们学习中文的时候,你是拿着新华字典学的吗?很显然不是。 黑皮书就好像游戏里「困难模式」,新人一上来就玩这个模式,根本就体会不到游戏的乐趣了,卸载了游戏那还是小事,如果留下心里阴影,造成不可逆的伤害,这就非常不好了。 说白了,这些厚的不行的计算机书不适合入门,我们应该先从「简单模式」慢慢过渡,**要屠龙,得先从新手村起步**。 就拿我亲身经历举例。 当初在学习计算机网络的时候,看见大家都说《计算机网络 - 自顶向下》和《TCP/IP 详解》这两本书好,我立马买了学习,这本也是黑皮系列大厚书,奈何小林当时太菜,根本就砍不动这本书,砍两下,刀钝了,就想睡觉。 后面又找了一波书,发现《图解 TCP/IP》、 《图解 HTTP》、《网络是怎么连接的》这几本书都不厚,而且搭配了很多图,我又立马买回来学习。 这几本书读起来不会太困难,不出一个月,我就把这三本书看完了,立马对计算机网络有了个整体且清晰的认识,终于知道了网络七层模型是什么,也知道了两台电脑是如何通过网络进行相互通信的,也知道 HTTP、DNS、TCP、UDP、IP、ICMP、DHCP、ARP 这些常见的协议是用来干嘛的了,成功突破了新手村。 虽然突破了新手村,但是学的知识还不够深入。 所以,我后面回来看《计算机网络 - 自顶向下》和《TCP/IP 详解》这两本厚厚的书,不过这次就不会那么吃力了。 后面回看这两本书时,我也没有选择从头看到尾,因为有些内容和在新手村看的书的内容重叠了,而且由于在新手村里知道了哪几个协议是常见的,于是就选择了这几个协议的章节进行深入学习,比如: - 我想进一步学习 TCP 协议的特性,于是就跳到《TCP/IP 详解》书里讲 TCP 协议的几个章节,我就从中学到了 TCP 流量控制、超时重传、拥塞控制等等。 - 我想进一步学习 IP 协议,于是就跳到《计算机网络 - 自顶向下》书里讲 IP 协议的章节,我就从中学到了 IP 协议更多的细节,IP 包头的各个字段用途、寻址、路由转发的原理等等。 看了黑皮书,我也深刻感受到黑皮系列的书确实经典,知识体系很全面,也很细节。 但是这种大且全的书并不意味着适合入门,新手很容易就在各种细节中迷失,而且书上有些不常用的协议我们是可以选择不看的,如果不知道重点很容易就把时间浪费在这些地方,得不偿失。 我是在新手村学习里抓到学习计算机网络的方向,也就是把「**键入网址,到网页显示,期间发生了什么?**」这个问题所涉及到的协议都要掌握,比如 HTTP、DNS、TCP、UDP、IP、ARP、MAC 等等,然后再查黑皮书对应的章节来深入学习对应的协议。 不仅仅是计算机网络,我在学习操作系统、计算机组成原理、网络编程等等也是用这套方法,都是先看新手村的书,得知了哪些是重点知识后,再跳到黑皮书里对应该知识的章节进行深入学习。 当初在学网络编程的时候,看见网上的人都说 UNP(Unix 网络编程)、APUE(Unix 高级环境编程)这两本书是网络编程圣经的书,那么好学的小林,那肯定毫无犹豫买了。 书到货后,我瞬间就懵逼了,这两本书是我买过最厚的书,这尼玛怎么学? 跟着书本的节奏,学了一段的时间,是懂了些 Linux 网络和系统 API 的用法,摸索来摸索去都是各个 API 的细节,**始终不知道高并发网络框架是如何实现的**。 ![图片](https://img-blog.csdnimg.cn/img_convert/3ec31f475de32791bb6bfaf32d86cf90.png) 后面我又重新找了一波关于网络编程的书,找到了这两本:《TCP/IP 网络编程》和《Linux 高性能服务器编程》。 - 《TCP/IP 网络编程》绝对是新手村级别的书,书里的内容不会有过多的术语,作者都用大白话来表达,配图也很清晰,也有介绍我想知道的网络框架,虽然是比较基础的多进程服务端模型、多线程服务端模型、异步 IO 模型。而且最后一章实现了简单的 HTTP 服务端,让我知道了从代码角度是怎么解析 HTTP 报文的,以及状态机是如何实现和运转的。 - 《Linux 高性能服务器编程》这本书主要是网络框架为主,前几章关于网络基础知识对于掌握了计算机网络知识的同学可以直接跳过的,你看,很多知识是想通的,当我们知道掌握了这块知识后,在学习新一本书的时候,就可以跳过重叠的内容。在这本书我学到了,Reactor、Proactor、信号、定时器、多进程编程、多线程编程、进程池和线程池等。 这两本书让我大概知道了如果一个服务端要服务多个客户端时,不是就简单写个 socket 编程就完事,而是还要结合 IO 多路复用 + 多线程的思想,也就是 Reactor 的设计理念,知道了这些事情后,后面我在看很多开源框架的网络模型时候,发现大多数基于 Reactor 的思想来实现的。 有了网络编程总体的视角后,在需要深入理解 socket api 中各种属性设置(超时、非阻塞 IO、阻塞 IO 等)和异常处理就要回归 APUE 这本书。 到这里我才知道 UNP 和 APUE 为什么会被称为网络编程圣经级别的书,原因是书里各种细节和异常都写的很全,也很细致,可以应对工作中很多问题。 但是事实证明,它并不是个入门级的书,所以 UNP 和 APUE 的用途比较像字典,在需要的时候去查阅就好。 学习算机组成也一样,我先看《程序是怎么样跑起来的》这本书,知道了程序跑起来的大概过程以及涉及到的知识点,然后带着这个问题,从《计算机组成与设计》这本黑皮书找到每一部分的细节,通过进一步学习,知道了程序编译过程,知道了 Intel x86 的指令结构,知道了计算机是如何存储并计算浮点数的,知道了 CPU 执行程序的工作流程,知道了计算机存储结构金字塔模型等等。 所以,大家在学习的时候,应该避免直接学大而全的书,我们要先从入门级别的书看起,抓住了主线重点知识后,再通过查阅这类大而全的书来进行深入学习。 ------ ## 不要只局限学一本书 我在学习的时候,有个习惯,喜欢找同类型的书一起学,就不会说学操作系统的时候,就只看一本理论书,而是结合 Linux 系统编程和内核分析的书一起看,**一层层的深入一个知识点**。 比如,我在学习操作系统的时候,在《现代操作系统》学了「进程与线程」的内容,而这本书介绍的内容比较概念性的,知识点也比较笼统,不够具体。 然后我就会去学《Unix 高级环境编程》第 7 章「进程环境」、第 8 章「进程控制」、第 11 章「线程」、第 12 章「线程控制」、第 15 章「进程间通信」,这一系列章节看完后,就知道了 Linux 是如果通过创建进程和线程,不只局限于理论了,还学会了应用。 当然这还不够,我还会去学《深入 Linux 内核架构》第 2 章关于进程和线程的 Linux 源码分析,发现 Linux 中进程和线程实际上都是用一个结构体 `task_struct` 来表示的。让我很惊叹的是,Linux 操作系统对于进程和线程的创建,都是调用 `do_fork` 函数实现的。 ![图片](https://img-blog.csdnimg.cn/img_convert/0e8f4dd1c1ad0759ddff6df29e8ff4e5.png) 只不过传递的参数不同,通过参数的不同来控制是复制父进程的资源(内存、文件描述、信号量等),还是引用父进程的资源,这样会更加深刻知道进程和线程的区别。 再比如,我在学习计算机网络的时候,在《图解 TCP/IP》学到了第六章关于 TCP 超时重传、流量控制、拥塞控制等内容,这本书讲的比较浅。 为了更深入理解 TCP,我就会去看《TCP/IP 详解》第 17 到 24 章,这几章都是详细介绍了 TCP,在这里会学到更全面的 TCP,比如 同时打开或关闭、negle 算法、往返时间 RTT 的计算、还有拥塞控制、快速重传、快速恢复、慢启动这些过程中的拥塞窗口是怎么变化的等等。 但是我在学《TCP/IP 详解》遇到了点困难,因为书里的案例有些地方看的不清晰,也不容易懂,特别是那些 TCP 抓包图,看到瞎眼。 后面我找到了本神书:《TCP/IP Guide》,很可惜只有英文的,我只看了这本书讲滑动窗口和流量控制的章节,因为这本书的精华就是这两个,其他的一般般,这两个章节的配图特别多,也很清晰。 ![图片](https://img-blog.csdnimg.cn/img_convert/a3359404cfec81917734560ec285f215.png) 我就在这知道了发送窗口和接收窗口的工作过程,也知道了滑动窗口对流量的影响,也知道了操作系统内存紧张的时候,也会影响滑动窗口,以及糊涂窗口综合症等等。 所以在学习一个知识的时候,大家不一定要把一本书从头看到尾后,才去学另外一本书。 最好的方式是在一本书看完某个章节的知识点后,马上去学另外一本相对比较深入的书的对应章节的内容,这样一层一层的深入下去,你对这个知识点的掌握就会很深刻了。 ------ ## 不要只看不动手 计算机类的知识都比较庞大,单纯只看很容易就忘记的了,当然即使做了笔记也会忘记。 就像小林写了很多文章,每篇文章的内容我也不一定都记得住,但是当我回看文章后,知识点很快会被唤醒起来。 所以记笔记的好处在于后面复习的时候,可以很快就能回想起来。 记笔记的方式有很多种,手写在笔记本、在书上标注、在 world 文档记录等等,但这些我觉得都不是好的方式。 我觉得比较好的方式是**思维导图**,把思维导图当作一课自己的知识树,每深入学一个知识点的时候,就开一个分支去记录,记录的内容最好是用自己的话来描述,而不是复制书上的内容,这样只是单纯的 copy,最好经过自己大脑的思考,用自己的话做个小总结,这样的知识点不容易忘。 还有很多知识其实可以结合**生活中的场景**来记忆,这样想忘记都难,比如阻塞 IO、非阻塞 IO、同步 IO 和异步 IO,我之前文章用去饭堂打菜的场景来介绍它们之间的区别。 ![图片](https://img-blog.csdnimg.cn/img_convert/a8db34dc8f3ed25b0910579b605450a7.png) 再比如介绍各种进程调度算法,我之前文章用银行业务办理的场景来介绍。 ![图片](https://img-blog.csdnimg.cn/img_convert/6e230e3982fe3f8a4a6cf19e1ca1c100.png) ------ ## 总结 最后做个总结,回答开头的问题。 > 书看不懂,容易放弃,怎么办? 不要一开始选择困难模式,也就是不要一开始选择大而全的书,这类书一般不适合入门学习。 我们先要找新手村级别的书来入门,新手村的书一般很快就看完的,看完后我们大概就知道这类书籍的重点知识,然后再通过查阅这些大而全的书的目录来学习对应章节的内容。 > 看书的效率很低,怎么办? 其实书并不一定要全部从头看完的,而且也不要固执到一直只看一本书。 最好在学习某个知识点的时候,通过看多本书来一层层的学习这个知识点,这样你学起来的知识点会比较全面,也更加深入。 按这种方式学,你会发现很多书都被你不经意间看了 7788 的。 > 做了很多笔记,依然过会就忘记,怎么办? 做笔记建议使用思维导图,把思维导图当作一课自己的知识树,每深入学一个知识点的时候,就开一个分支去记录。 在记录笔记的时候,尽量少 copy 书上的内容,最好还是经过自己思考后用自己的话输出的笔记,而且可以搭配生活场景来加深记忆点。 ================================================ FILE: mysql/README.md ================================================ # 图解 MySQL 介绍 《图解 MySQL》目前还在连载更新中,大家不要催啦:joy: ,更新完会第一时间整理 PDF 的。 目前已经更新好的文章: - **基础篇**:point_down: - [执行一条 SQL 查询语句,期间发生了什么?](/mysql/base/how_select.md) - [MySQL 一行记录是怎么存储的?](/mysql/base/row_format.md) - **索引篇** :point_down: - [索引常见面试题](/mysql/index/index_interview.md) - [从数据页的角度看 B+ 树](/mysql/index/page.md) - [为什么 MySQL 采用 B+ 树作为索引?](/mysql/index/why_index_chose_bpuls_tree.md) - [MySQL 单表不要超过 2000W 行,靠谱吗?](/mysql/index/2000w.md) - [索引失效有哪些?](/mysql/index/index_lose.md) - [MySQL 使用 like“%x“,索引一定会失效吗?](/mysql/index/index_issue.md) - [count(\*) 和 count(1) 有什么区别?哪个性能最好?](/mysql/index/count.md) - **事务篇** :point_down: - [事务隔离级别是怎么实现的?](/mysql/transaction/mvcc.md) - [MySQL 可重复读隔离级别,完全解决幻读了吗?](/mysql/transaction/phantom.md) - **锁篇** :point_down: - [MySQL 有哪些锁?](/mysql/lock/mysql_lock.md) - [MySQL 是怎么加锁的?](/mysql/lock/how_to_lock.md) - [update 没加索引会锁全表?](/mysql/lock/update_index.md) - [MySQL 记录锁 + 间隙锁可以防止删除操作而导致的幻读吗?](/mysql/lock/lock_phantom.md) - [MySQL 死锁了,怎么办?](/mysql/lock/deadlock.md) - [字节面试:加了什么锁,导致死锁的?](/mysql/lock/show_lock.md) - **日志篇** :point_down: - [undo log、redo log、binlog 有什么用?](/mysql/log/how_update.md) - **内存篇** :point_down: - [揭开 Buffer_Pool 的面纱](/mysql/buffer_pool/buffer_pool.md) ---- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: mysql/base/how_select.md ================================================ # 执行一条 select 语句,期间发生了什么? 大家好,我是小林。 学习 SQL 的时候,大家肯定第一个先学到的就是 select 查询语句了,比如下面这句查询语句: ```sql // 在 product 表中,查询 id = 1 的记录 select * from product where id = 1; ``` 但是有没有想过,**MySQL 执行一条 select 查询语句,在 MySQL 中期间发生了什么?** 带着这个问题,我们可以很好的了解 MySQL 内部的架构,所以这次小林就带大家拆解一下 MySQL 内部的结构,看看内部里的每一个“零件”具体是负责做什么的。 ## MySQL 执行流程是怎样的? 先来一个上帝视角图,下面就是 MySQL 执行一条 SQL 查询语句的流程,也从图中可以看到 MySQL 内部架构里的各个功能模块。 ![查询语句执行流程](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/sql执行过程/mysql查询流程.png) 可以看到,MySQL 的架构共分为两层:**Server 层和存储引擎层**, - **Server 层负责建立连接、分析和执行 SQL**。MySQL 大多数的核心功能模块都在这实现,主要包括连接器,查询缓存、解析器、预处理器、优化器、执行器等。另外,所有的内置函数(如日期、时间、数学和加密函数等)和所有跨存储引擎的功能(如存储过程、触发器、视图等。)都在 Server 层实现。 - **存储引擎层负责数据的存储和提取**。支持 InnoDB、MyISAM、Memory 等多个存储引擎,不同的存储引擎共用一个 Server 层。现在最常用的存储引擎是 InnoDB,从 MySQL 5.5 版本开始,InnoDB 成为了 MySQL 的默认存储引擎。我们常说的索引数据结构,就是由存储引擎层实现的,不同的存储引擎支持的索引类型也不相同,比如 InnoDB 支持索引类型是 B+树,且是默认使用,也就是说在数据表中创建的主键索引和二级索引默认使用的是 B+ 树索引。 好了,现在我们对 Server 层和存储引擎层有了一个简单认识,接下来,就详细说一条 SQL 查询语句的执行流程,依次看看每一个功能模块的作用。 ## 第一步:连接器 如果你在 Linux 操作系统里要使用 MySQL,那你第一步肯定是要先连接 MySQL 服务,然后才能执行 SQL 语句,普遍我们都是使用下面这条命令进行连接: ```shell # -h 指定 MySQL 服务的 IP 地址,如果是连接本地的 MySQL服务,可以不用这个参数; # -u 指定用户名,管理员角色名为 root; # -p 指定密码,如果命令行中不填写密码(为了密码安全,建议不要在命令行写密码),就需要在交互对话里面输入密码 mysql -h$ip -u$user -p ``` 连接的过程需要先经过 TCP 三次握手,因为 MySQL 是基于 TCP 协议进行传输的,如果 MySQL 服务并没有启动,则会收到如下的报错: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/sql执行过程/mysql连接错误.png) 如果 MySQL 服务正常运行,完成 TCP 连接的建立后,连接器就要开始验证你的用户名和密码,如果用户名或密码不对,就收到一个"Access denied for user"的错误,然后客户端程序结束执行。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/sql执行过程/密码错误.png) 如果用户密码都没有问题,连接器就会获取该用户的权限,然后保存起来,后续该用户在此连接里的任何操作,都会基于连接开始时读到的权限进行权限逻辑的判断。 所以,如果一个用户已经建立了连接,即使管理员中途修改了该用户的权限,也不会影响已经存在连接的权限。修改完成后,只有再新建的连接才会使用新的权限设置。 > 如何查看 MySQL 服务被多少个客户端连接了? 如果你想知道当前 MySQL 服务被多少个客户端连接了,你可以执行 `show processlist` 命令进行查看。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/sql执行过程/查看连接.png) 比如上图的显示结果,共有两个用户名为 root 的用户连接了 MySQL 服务,其中 id 为 6 的用户的 Command 列的状态为 `Sleep` ,这意味着该用户连接完 MySQL 服务就没有再执行过任何命令,也就是说这是一个空闲的连接,并且空闲的时长是 736 秒(Time 列)。 > 空闲连接会一直占用着吗? 当然不是了,MySQL 定义了空闲连接的最大空闲时长,由 `wait_timeout` 参数控制的,默认值是 8 小时(28800 秒),如果空闲连接超过了这个时间,连接器就会自动将它断开。 ```sql mysql> show variables like 'wait_timeout'; +---------------+-------+ | Variable_name | Value | +---------------+-------+ | wait_timeout | 28800 | +---------------+-------+ 1 row in set (0.00 sec) ``` 当然,我们自己也可以手动断开空闲的连接,使用的是 kill connection + id 的命令。 ```sql mysql> kill connection +6; Query OK, 0 rows affected (0.00 sec) ``` 一个处于空闲状态的连接被服务端主动断开后,这个客户端并不会马上知道,等到客户端在发起下一个请求的时候,才会收到这样的报错“ERROR 2013 (HY000): Lost connection to MySQL server during query”。 > MySQL 的连接数有限制吗? MySQL 服务支持的最大连接数由 max_connections 参数控制,比如我的 MySQL 服务默认是 151 个,超过这个值,系统就会拒绝接下来的连接请求,并报错提示“Too many connections”。 ```sql mysql> show variables like 'max_connections'; +-----------------+-------+ | Variable_name | Value | +-----------------+-------+ | max_connections | 151 | +-----------------+-------+ 1 row in set (0.00 sec) ``` MySQL 的连接也跟 HTTP 一样,有短连接和长连接的概念,它们的区别如下: ```c // 短连接 连接 mysql 服务(TCP 三次握手) 执行sql 断开 mysql 服务(TCP 四次挥手) // 长连接 连接 mysql 服务(TCP 三次握手) 执行sql 执行sql 执行sql .... 断开 mysql 服务(TCP 四次挥手) ``` 可以看到,使用长连接的好处就是可以减少建立连接和断开连接的过程,所以一般是推荐使用长连接。 但是,使用长连接后可能会占用内存增多,因为 MySQL 在执行查询过程中临时使用内存管理连接对象,这些连接对象资源只有在连接断开时才会释放。如果长连接累计很多,将导致 MySQL 服务占用内存太大,有可能会被系统强制杀掉,这样会发生 MySQL 服务异常重启的现象。 > 怎么解决长连接占用内存的问题? 有两种解决方式。 第一种,**定期断开长连接**。既然断开连接后就会释放连接占用的内存资源,那么我们可以定期断开长连接。 第二种,**客户端主动重置连接**。MySQL 5.7 版本实现了 `mysql_reset_connection()` 函数的接口,注意这是接口函数不是命令,那么当客户端执行了一个很大的操作后,在代码里调用 mysql_reset_connection 函数来重置连接,达到释放内存的效果。这个过程不需要重连和重新做权限验证,但是会将连接恢复到刚刚创建完时的状态。 至此,连接器的工作做完了,简单总结一下: - 与客户端进行 TCP 三次握手建立连接; - 校验客户端的用户名和密码,如果用户名或密码不对,则会报错; - 如果用户名和密码都对了,会读取该用户的权限,然后后面的权限逻辑判断都基于此时读取到的权限; ## 第二步:查询缓存 连接器的工作完成后,客户端就可以向 MySQL 服务发送 SQL 语句了,MySQL 服务收到 SQL 语句后,就会解析出 SQL 语句的第一个字段,看看是什么类型的语句。 如果 SQL 是查询语句(select 语句),MySQL 就会先去查询缓存(Query Cache)里查找缓存数据,看看之前有没有执行过这一条命令,这个查询缓存是以 key-value 形式保存在内存中的,key 为 SQL 查询语句,value 为 SQL 语句查询的结果。 如果查询的语句命中查询缓存,那么就会直接返回 value 给客户端。如果查询的语句没有命中查询缓存中,那么就要往下继续执行,等执行完后,查询的结果就会被存入查询缓存中。 这么看,查询缓存还挺有用,但是其实**查询缓存挺鸡肋**的。 对于更新比较频繁的表,查询缓存的命中率很低的,因为只要一个表有更新操作,那么这个表的查询缓存就会被清空。如果刚缓存了一个查询结果很大的数据,还没被使用的时候,刚好这个表有更新操作,查询缓冲就被清空了,相当于缓存了个寂寞。 所以,MySQL 8.0 版本直接将查询缓存删掉了,也就是说 MySQL 8.0 开始,执行一条 SQL 查询语句,不会再走到查询缓存这个阶段了。 对于 MySQL 8.0 之前的版本,如果想关闭查询缓存,我们可以通过将参数 query_cache_type 设置成 DEMAND。 ::: tip 这里说的查询缓存是 server 层的,也就是 MySQL 8.0 版本移除的是 server 层的查询缓存,并不是 Innodb 存储引擎中的 buffer pool。 :::: ## 第三步:解析 SQL 在正式执行 SQL 查询语句之前,MySQL 会先对 SQL 语句做解析,这个工作交由「解析器」来完成。 ### 解析器 解析器会做如下两件事情。 第一件事情,**词法分析**。MySQL 会根据你输入的字符串识别出关键字出来,构建出 SQL 语法树,这样方便后面模块获取 SQL 类型、表名、字段名、where 条件等等。 第二件事情,**语法分析**。根据词法分析的结果,语法解析器会根据语法规则,判断你输入的这个 SQL 语句是否满足 MySQL 语法。 如果我们输入的 SQL 语句语法不对,就会在解析器这个阶段报错。比如,我下面这条查询语句,把 from 写成了 form,这时 MySQL 解析器就会给报错。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/sql执行过程/语法错误.png) 但是注意,表不存在或者字段不存在,并不是在解析器里做的,《MySQL 45 讲》说是在解析器做的,但是经过我和朋友看 MySQL 源码(5.7 和 8.0)得出结论是解析器只负责构建语法树和检查语法,但是不会去查表或者字段存不存在。 那到底谁来做检测表和字段是否存在的工作呢?别急,接下来就是了。 ## 第四步:执行 SQL 经过解析器后,接着就要进入执行 SQL 查询语句的流程了,每条`SELECT` 查询语句流程主要可以分为下面这三个阶段: - prepare 阶段,也就是预处理阶段; - optimize 阶段,也就是优化阶段; - execute 阶段,也就是执行阶段; ### 预处理器 我们先来说说预处理阶段做了什么事情。 - 检查 SQL 查询语句中的表或者字段是否存在; - 将 `select *` 中的 `*` 符号,扩展为表上的所有列; 我下面这条查询语句,test 这张表是不存在的,这时 MySQL 就会在执行 SQL 查询语句的 prepare 阶段中报错。 ```sql mysql> select * from test; ERROR 1146 (42S02): Table 'mysql.test' doesn't exist ``` 这里贴个 MySQL 8.0 源码来证明表或字段是否存在的判断,不是在解析器里做的,而是在 prepare 阶段。(*PS:下图是公众号「一树一溪」老哥帮我分析的,这位老哥专门写 MySQL 源码文章,感兴趣的朋友,可以微信搜索关注*) ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/sql执行过程/表不存在.jpeg) 上面的中间部分是 MySQL 报错表不存在时的函数调用栈,可以看到表不存在的错误是在 get_table_share() 函数里报错的,而这个函数是在 prepare 阶段调用的。 不过,对于 MySQL 5.7 判断表或字段是否存在的工作,是在词法分析&语法分析之后,prepare 阶段之前做的。结论都一样,不是在解析器里做的。代码我就不放了,正因为 MySQL 5.7 代码结构不好,所以 MySQL 8.0 代码结构变化很大,后来判断表或字段是否存在的工作就被放入到 prepare 阶段做了。 ### 优化器 经过预处理阶段后,还需要为 SQL 查询语句先制定一个执行计划,这个工作交由「优化器」来完成的。 **优化器主要负责将 SQL 查询语句的执行方案确定下来**,比如在表里面有多个索引的时候,优化器会基于查询成本的考虑,来决定选择使用哪个索引。 当然,我们本次的查询语句(select * from product where id = 1)很简单,就是选择使用主键索引。 要想知道优化器选择了哪个索引,我们可以在查询语句最前面加个 `explain` 命令,这样就会输出这条 SQL 语句的执行计划,然后执行计划中的 key 就表示执行过程中使用了哪个索引,比如下图的 key 为 `PRIMARY` 就是使用了主键索引。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/sql执行过程/执行计划.png) 如果查询语句的执行计划里的 key 为 null 说明没有使用索引,那就会全表扫描(type = ALL),这种查询扫描的方式是效率最低档次的,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/sql执行过程/全表扫描.png) 这张 product 表只有一个索引就是主键,现在我在表中将 name 设置为普通索引(二级索引)。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/sql执行过程/产品表.png) 这时 product 表就有主键索引(id)和普通索引(name)。假设执行了这条查询语句: ```sql select id from product where id > 1 and name like 'i%'; ``` 这条查询语句的结果既可以使用主键索引,也可以使用普通索引,但是执行的效率会不同。这时,就需要优化器来决定使用哪个索引了。 很显然这条查询语句是**覆盖索引**,直接在二级索引就能查找到结果(因为二级索引的 B+ 树的叶子节点的数据存储的是主键值),就没必要在主键索引查找了,因为查询主键索引的 B+ 树的成本会比查询二级索引的 B+ 的成本大,优化器基于查询成本的考虑,会选择查询代价小的普通索引。 在下图中执行计划,我们可以看到,执行过程中使用了普通索引(name),Exta 为 Using index,这就是表明使用了覆盖索引优化。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/sql执行过程/选择索引.png) ### 执行器 经历完优化器后,就确定了执行方案,接下来 MySQL 就真正开始执行语句了,这个工作是由「执行器」完成的。在执行的过程中,执行器就会和存储引擎交互了,交互是以数据行为单位的。 接下来,用三种方式执行过程,跟大家说一下执行器和存储引擎的交互过程(PS:为了写好这一部分,特地去看 MySQL 源码,也是第一次看哈哈)。 - 主键索引查询 - 全表扫描 - 索引下推 #### 主键索引查询 以本文开头查询语句为例,看看执行器是怎么工作的。 ```sql select * from product where id = 1; ``` 这条查询语句的查询条件用到了主键索引,而且是等值查询,同时主键 id 是唯一,不会有 id 相同的记录,所以优化器决定选用访问类型为 const 进行查询,也就是使用主键索引查询一条记录,那么执行器与存储引擎的执行流程是这样的: - 执行器第一次查询,会调用 read_first_record 函数指针指向的函数,因为优化器选择的访问类型为 const,这个函数指针被指向为 InnoDB 引擎索引查询的接口,把条件 `id = 1` 交给存储引擎,**让存储引擎定位符合条件的第一条记录**。 - 存储引擎通过主键索引的 B+ 树结构定位到 id = 1 的第一条记录,如果记录是不存在的,就会向执行器上报记录找不到的错误,然后查询结束。如果记录是存在的,就会将记录返回给执行器; - 执行器从存储引擎读到记录后,接着判断记录是否符合查询条件,如果符合则发送给客户端,如果不符合则跳过该记录。 - 执行器查询的过程是一个 while 循环,所以还会再查一次,但是这次因为不是第一次查询了,所以会调用 read_record 函数指针指向的函数,因为优化器选择的访问类型为 const,这个函数指针被指向为一个永远返回 - 1 的函数,所以当调用该函数的时候,执行器就退出循环,也就是结束查询了。 至此,这个语句就执行完成了。 #### 全表扫描 举个全表扫描的例子: ```plain select * from product where name = 'iphone'; ``` 这条查询语句的查询条件没有用到索引,所以优化器决定选用访问类型为 ALL 进行查询,也就是全表扫描的方式查询,那么这时执行器与存储引擎的执行流程是这样的: - 执行器第一次查询,会调用 read_first_record 函数指针指向的函数,因为优化器选择的访问类型为 all,这个函数指针被指向为 InnoDB 引擎全扫描的接口,**让存储引擎读取表中的第一条记录**; - 执行器会判断读到的这条记录的 name 是不是 iphone,如果不是则跳过;如果是则将记录发给客户端(是的没错,Server 层每从存储引擎读到一条记录就会发送给客户端,之所以客户端显示的时候是直接显示所有记录的,是因为客户端是等查询语句查询完成后,才会显示出所有的记录)。 - 执行器查询的过程是一个 while 循环,所以还会再查一次,会调用 read_record 函数指针指向的函数,因为优化器选择的访问类型为 all,read_record 函数指针指向的还是 InnoDB 引擎全扫描的接口,所以接着向存储引擎层要求继续读刚才那条记录的下一条记录,存储引擎把下一条记录取出后就将其返回给执行器(Server 层),执行器继续判断条件,不符合查询条件即跳过该记录,否则发送到客户端; - 一直重复上述过程,直到存储引擎把表中的所有记录读完,然后向执行器(Server 层)返回了读取完毕的信息; - 执行器收到存储引擎报告的查询完毕的信息,退出循环,停止查询。 至此,这个语句就执行完成了。 #### 索引下推 在这部分非常适合讲索引下推(MySQL 5.6 推出的查询优化策略),这样大家能清楚的知道,「下推」这个动作,下推到了哪里。 索引下推能够减少**二级索引**在查询时的回表操作,提高查询的效率,因为它将 Server 层部分负责的事情,交给存储引擎层去处理了。 举一个具体的例子,方便大家理解,这里一张用户表如下,我对 age 和 reward 字段建立了联合索引(age,reward): ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/sql执行过程/路飞表.png) 现在有下面这条查询语句: ```sql select * from t_user where age > 20 and reward = 100000; ``` 联合索引当遇到范围查询 (>、<) 就会停止匹配,也就是 **age 字段能用到联合索引,但是 reward 字段则无法利用到索引**。具体原因这里可以看这篇:[索引常见面试题](https://xiaolincoding.com/mysql/index/index_interview.html#%E6%8C%89%E5%AD%97%E6%AE%B5%E4%B8%AA%E6%95%B0%E5%88%86%E7%B1%BB) 那么,不使用索引下推(MySQL 5.6 之前的版本)时,执行器与存储引擎的执行流程是这样的: - Server 层首先调用存储引擎的接口定位到满足查询条件的第一条二级索引记录,也就是定位到 age > 20 的第一条记录; - 存储引擎根据二级索引的 B+ 树快速定位到这条记录后,获取主键值,然后**进行回表操作**,将完整的记录返回给 Server 层; - Server 层在判断该记录的 reward 是否等于 100000,如果成立则将其发送给客户端;否则跳过该记录; - 接着,继续向存储引擎索要下一条记录,存储引擎在二级索引定位到记录后,获取主键值,然后回表操作,将完整的记录返回给 Server 层; - 如此往复,直到存储引擎把表中的所有记录读完。 可以看到,没有索引下推的时候,每查询到一条二级索引记录,都要进行回表操作,然后将记录返回给 Server,接着 Server 再判断该记录的 reward 是否等于 100000。 而使用索引下推后,判断记录的 reward 是否等于 100000 的工作交给了存储引擎层,过程如下: - Server 层首先调用存储引擎的接口定位到满足查询条件的第一条二级索引记录,也就是定位到 age > 20 的第一条记录; - 存储引擎定位到二级索引后,**先不执行回表**操作,而是先判断一下该索引中包含的列(reward 列)的条件(reward 是否等于 100000)是否成立。如果**条件不成立**,则直接**跳过该二级索引**。如果**成立**,则**执行回表**操作,将完成记录返回给 Server 层。 - Server 层在判断其他的查询条件(本次查询没有其他条件)是否成立,如果成立则将其发送给客户端;否则跳过该记录,然后向存储引擎索要下一条记录。 - 如此往复,直到存储引擎把表中的所有记录读完。 可以看到,使用了索引下推后,虽然 reward 列无法使用到联合索引,但是因为它包含在联合索引(age,reward)里,所以直接在存储引擎过滤出满足 reward = 100000 的记录后,才去执行回表操作获取整个记录。相比于没有使用索引下推,节省了很多回表操作。 当你发现执行计划里的 Extra 部分显示了“Using index condition”,说明使用了索引下推。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/sql执行过程/索引下推执行计划.png) --- ## 总结 执行一条 SQL 查询语句,期间发生了什么? - 连接器:建立连接,管理连接、校验用户身份; - 查询缓存:查询语句如果命中查询缓存则直接返回,否则继续往下执行。MySQL 8.0 已删除该模块; - 解析 SQL,通过解析器对 SQL 查询语句进行词法分析、语法分析,然后构建语法树,方便后续模块读取表名、字段、语句类型; - 执行 SQL:执行 SQL 共有三个阶段: - 预处理阶段:检查表或字段是否存在;将 `select *` 中的 `*` 符号扩展为表上的所有列。 - 优化阶段:基于查询成本的考虑,选择查询成本最小的执行计划; - 执行阶段:根据执行计划执行 SQL 查询语句,从存储引擎读取记录,返回给客户端; 怎么样?现在再看这张图,是不是很清晰了。 ![查询语句执行流程](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/sql执行过程/mysql查询流程.png) 完! ---- 参考资料: - 《MySQL 45 讲》 - 《MySQL 是怎样运行的:从根儿上理解 MySQL》 - http://www.iskm.org/mysql56/sql__executor_8cc_source.html - https://tangocc.github.io/2018/10/11/mysql-sourcecode/ --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: mysql/base/row_format.md ================================================ # MySQL 一行记录是怎么存储的? 大家好,我是小林。 之前有位读者在面字节的时候,被问到这么个问题: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/row_format/读者问题.jpeg) 如果你知道 MySQL 一行记录的存储结构,那么这个问题对你没什么难度。 如果你不知道也没关系,这次我跟大家聊聊 **MySQL 一行记录是怎么存储的?** 知道了这个之后,除了能应解锁前面这道面试题,你还会解锁这些面试题: - MySQL 的 NULL 值会占用空间吗? - MySQL 怎么知道 varchar(n) 实际占用数据的大小? - varchar(n) 中 n 最大取值为多少? - 行溢出后,MySQL 是怎么处理的? 这些问题看似毫不相干,其实都是在围绕「MySQL 一行记录的存储结构」这一个知识点,所以攻破了这个知识点后,这些问题就引刃而解了。 好了,话不多说,发车! ## MySQL 的数据存放在哪个文件? 大家都知道 MySQL 的数据都是保存在磁盘的,那具体是保存在哪个文件呢? MySQL 存储的行为是由存储引擎实现的,MySQL 支持多种存储引擎,不同的存储引擎保存的文件自然也不同。 InnoDB 是我们常用的存储引擎,也是 MySQL 默认的存储引擎。所以,本文主要以 InnoDB 存储引擎展开讨论。 先来看看 MySQL 数据库的文件存放在哪个目录? ``` sql mysql> SHOW VARIABLES LIKE 'datadir'; +---------------+-----------------+ | Variable_name | Value | +---------------+-----------------+ | datadir | /var/lib/mysql/ | +---------------+-----------------+ 1 row in set (0.00 sec) ``` 我们每创建一个 database(数据库)都会在 /var/lib/mysql/ 目录里面创建一个以 database 为名的目录,然后保存表结构和表数据的文件都会存放在这个目录里。 比如,我这里有一个名为 my_test 的 database,该 database 里有一张名为 t_order 数据库表。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/row_format/database.png) 然后,我们进入 /var/lib/mysql/my_test 目录,看看里面有什么文件? ```shell [root@xiaolin ~]#ls /var/lib/mysql/my_test db.opt t_order.frm t_order.ibd ``` 可以看到,共有三个文件,这三个文件分别代表着: - db.opt,用来存储当前数据库的默认字符集和字符校验规则。 - t_order.frm,t_order 的**表结构**会保存在这个文件。在 MySQL 中建立一张表都会生成一个.frm 文件,该文件是用来保存每个表的元数据信息的,主要包含表结构定义。 - t_order.ibd,t_order 的**表数据**会保存在这个文件。表数据既可以存在共享表空间文件(文件名:ibdata1)里,也可以存放在独占表空间文件(文件名:表名字.ibd)。这个行为是由参数 innodb_file_per_table 控制的,若设置了参数 innodb_file_per_table 为 1,则会将存储的数据、索引等信息单独存储在一个独占表空间,从 MySQL 5.6.6 版本开始,它的默认值就是 1 了,因此从这个版本之后,MySQL 中每一张表的数据都存放在一个独立的 .ibd 文件。 好了,现在我们知道了一张数据库表的数据是保存在「表名字.ibd」的文件里的,这个文件也称为独占表空间文件。 ### 表空间文件的结构是怎么样的? **表空间由段(segment)、区(extent)、页(page)、行(row)组成**,InnoDB 存储引擎的逻辑存储结构大致如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/row_format/表空间结构.drawio.png) 下面我们从下往上一个个看看。 #### 1、行(row) 数据库表中的记录都是按行(row)进行存放的,每行记录根据不同的行格式,有不同的存储结构。 后面我们详细介绍 InnoDB 存储引擎的行格式,也是本文重点介绍的内容。 #### 2、页(page) 记录是按照行来存储的,但是数据库的读取并不以「行」为单位,否则一次读取(也就是一次 I/O 操作)只能处理一行数据,效率会非常低。 因此,**InnoDB 的数据是按「页」为单位来读写的**,也就是说,当需要读一条记录的时候,并不是将这个行记录从磁盘读出来,而是以页为单位,将其整体读入内存。 **默认每个页的大小为 16KB**,也就是最多能保证 16KB 的连续存储空间。 页是 InnoDB 存储引擎磁盘管理的最小单元,意味着数据库每次读写都是以 16KB 为单位的,一次最少从磁盘中读取 16K 的内容到内存中,一次最少把内存中的 16K 内容刷新到磁盘中。 页的类型有很多,常见的有数据页、undo 日志页、溢出页等等。数据表中的行记录是用「数据页」来管理的,数据页的结构这里我就不讲细说了,之前文章有说过,感兴趣的可以去看这篇文章:[换一个角度看 B+ 树](https://xiaolincoding.com/mysql/index/page.html) 总之知道表中的记录存储在「数据页」里面就行。 #### 3、区(extent) 我们知道 InnoDB 存储引擎是用 B+ 树来组织数据的。 B+ 树中每一层都是通过双向链表连接起来的,如果是以页为单位来分配存储空间,那么链表中相邻的两个页之间的物理位置并不是连续的,可能离得非常远,那么磁盘查询时就会有大量的随机 I/O,随机 I/O 是非常慢的。 解决这个问题也很简单,就是让链表中相邻的页的物理位置也相邻,这样就可以使用顺序 I/O 了,那么在范围查询(扫描叶子节点)的时候性能就会很高。 那具体怎么解决呢? **在表中数据量大的时候,为某个索引分配空间的时候就不再按照页为单位分配了,而是按照区(extent)为单位分配。每个区的大小为 1MB,对于 16KB 的页来说,连续的 64 个页会被划为一个区,这样就使得链表中相邻的页的物理位置也相邻,就能使用顺序 I/O 了**。 #### 4、段(segment) 表空间是由各个段(segment)组成的,段是由多个区(extent)组成的。段一般分为数据段、索引段和回滚段等。 - 索引段:存放 B + 树的非叶子节点的区的集合; - 数据段:存放 B + 树的叶子节点的区的集合; - 回滚段:存放的是回滚数据的区的集合,之前讲[事务隔离](https://xiaolincoding.com/mysql/transaction/mvcc.html)的时候就介绍到了 MVCC 利用了回滚段实现了多版本查询数据。 好了,终于说完表空间的结构了。接下来,就具体讲一下 InnoDB 的行格式了。 之所以要绕一大圈才讲行记录的格式,主要是想让大家知道行记录是存储在哪个文件,以及行记录在这个表空间文件中的哪个区域,有一个从上往下切入的视角,这样理解起来不会觉得很抽象。 ## InnoDB 行格式有哪些? 行格式(row_format),就是一条记录的存储结构。 InnoDB 提供了 4 种行格式,分别是 Redundant、Compact、Dynamic 和 Compressed 行格式。 - Redundant 是很古老的行格式了,MySQL 5.0 版本之前用的行格式,现在基本没人用了。 - 由于 Redundant 不是一种紧凑的行格式,所以 MySQL 5.0 之后引入了 Compact 行记录存储方式,Compact 是一种紧凑的行格式,设计的初衷就是为了让一个数据页中可以存放更多的行记录,从 MySQL 5.1 版本之后,行格式默认设置成 Compact。 - Dynamic 和 Compressed 两个都是紧凑的行格式,它们的行格式都和 Compact 差不多,因为都是基于 Compact 改进一点东西。从 MySQL5.7 版本之后,默认使用 Dynamic 行格式。 Redundant 行格式我这里就不讲了,因为现在基本没人用了,这次重点介绍 Compact 行格式,因为 Dynamic 和 Compressed 这两个行格式跟 Compact 非常像。 所以,弄懂了 Compact 行格式,之后你们在去了解其他行格式,很快也能看懂。 ## COMPACT 行格式长什么样? 先跟 Compact 行格式混个脸熟,它长这样: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/row_format/COMPACT.drawio.png) 可以看到,一条完整的记录分为「记录的额外信息」和「记录的真实数据」两个部分。 接下里,分别详细说下。 ### 记录的额外信息 记录的额外信息包含 3 个部分:变长字段长度列表、NULL 值列表、记录头信息。 #### 1. 变长字段长度列表 varchar(n) 和 char(n) 的区别是什么,相信大家都非常清楚,char 是定长的,varchar 是变长的,变长字段实际存储的数据的长度(大小)不固定的。 所以,在存储数据的时候,也要把数据占用的大小存起来,存到「变长字段长度列表」里面,读取数据的时候才能根据这个「变长字段长度列表」去读取对应长度的数据。其他 TEXT、BLOB 等变长字段也是这么实现的。 为了展示「变长字段长度列表」具体是怎么保存「变长字段的真实数据占用的字节数」,我们先创建这样一张表,字符集是 ascii(所以每一个字符占用的 1 字节),行格式是 Compact,t_user 表中 name 和 phone 字段都是变长字段: ```sql CREATE TABLE `t_user` ( `id` int(11) NOT NULL, `name` VARCHAR(20) DEFAULT NULL, `phone` VARCHAR(20) DEFAULT NULL, `age` int(11) DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB DEFAULT CHARACTER SET = ascii ROW_FORMAT = COMPACT; ``` 现在 t_user 表里有这三条记录: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/row_format/t_test.png) 接下来,我们看看看看这三条记录的行格式中的「变长字段长度列表」是怎样存储的。 先来看第一条记录: - name 列的值为 a,真实数据占用的字节数是 1 字节,十六进制 0x01; - phone 列的值为 123,真实数据占用的字节数是 3 字节,十六进制 0x03; - age 列和 id 列不是变长字段,所以这里不用管。 这些变长字段的真实数据占用的字节数会按照列的顺序**逆序存放**(等下会说为什么要这么设计),所以「变长字段长度列表」里的内容是「03 01」,而不是「01 03」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/row_format/变长字段长度列表1.png) 同样的道理,我们也可以得出**第二条记录**的行格式中,「变长字段长度列表」里的内容是「04 02」,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/row_format/变长字段长度列表2.png) **第三条记录**中 phone 列的值是 NULL,**NULL 是不会存放在行格式中记录的真实数据部分里的**,所以「变长字段长度列表」里不需要保存值为 NULL 的变长字段的长度。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/row_format/变长字段长度列表3.png) > 为什么「变长字段长度列表」的信息要按照逆序存放? 这个设计是有想法的,主要是因为「记录头信息」中指向下一个记录的指针,指向的是下一条记录的「记录头信息」和「真实数据」之间的位置,这样的好处是向左读就是记录头信息,向右读就是真实数据,比较方便。 「变长字段长度列表」中的信息之所以要逆序存放,是因为这样可以**使得位置靠前的记录的真实数据和数据对应的字段长度信息可以同时在一个 CPU Cache Line 中,这样就可以提高 CPU Cache 的命中率**。 同样的道理,NULL 值列表的信息也需要逆序存放。 如果你不知道什么是 CPU Cache,可以看[这篇文章](https://xiaolincoding.com/os/1_hardware/how_to_make_cpu_run_faster.html),这属于计算机组成的知识。 > 每个数据库表的行格式都有「变长字段字节数列表」吗? 其实变长字段字节数列表不是必须的。 **当数据表没有变长字段的时候,比如全部都是 int 类型的字段,这时候表里的行格式就不会有「变长字段长度列表」了**,因为没必要,不如去掉以节省空间。 所以「变长字段长度列表」只出现在数据表有变长字段的时候。 #### 2. NULL 值列表 表中的某些列可能会存储 NULL 值,如果把这些 NULL 值都放到记录的真实数据中会比较浪费空间,所以 Compact 行格式把这些值为 NULL 的列存储到 NULL 值列表中。 如果存在允许 NULL 值的列,则每个列对应一个二进制位(bit),二进制位按照列的顺序逆序排列。 - 二进制位的值为`1`时,代表该列的值为 NULL。 - 二进制位的值为`0`时,代表该列的值不为 NULL。 另外,NULL 值列表必须用整数个字节的位表示(1 字节 8 位),如果使用的二进制位个数不足整数个字节,则在字节的高位补 `0`。 还是以 t_user 表的这三条记录作为例子: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/row_format/t_test.png) 接下来,我们看看看看这三条记录的行格式中的 NULL 值列表是怎样存储的。 先来看**第一条记录**,第一条记录所有列都有值,不存在 NULL 值,所以用二进制来表示是酱紫的: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/row_format/null值列表1.png) 但是 InnoDB 是用整数字节的二进制位来表示 NULL 值列表的,现在不足 8 位,所以要在高位补 0,最终用二进制来表示是酱紫的: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/row_format/null值列表2.png) 所以,对于第一条数据,NULL 值列表用十六进制表示是 0x00。 接下来看**第二条记录**,第二条记录 age 列是 NULL 值,所以,对于第二条数据,NULL 值列表用十六进制表示是 0x04。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/row_format/null值列表3.png) 最后**第三条记录**,第三条记录 phone 列 和 age 列是 NULL 值,所以,对于第三条数据,NULL 值列表用十六进制表示是 0x06。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/row_format/null值列表4.png) 我们把三条记录的 NULL 值列表都填充完毕后,它们的行格式是这样的: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/row_format/null值列表5.png) > 每个数据库表的行格式都有「NULL 值列表」吗? NULL 值列表也不是必须的。 **当数据表的字段都定义成 NOT NULL 的时候,这时候表里的行格式就不会有 NULL 值列表了**。 所以在设计数据库表的时候,通常都是建议将字段设置为 NOT NULL,这样可以至少节省 1 字节的空间(NULL 值列表至少占用 1 字节空间)。 > 「NULL 值列表」是固定 1 字节空间吗?如果这样的话,一条记录有 9 个字段值都是 NULL,这时候怎么表示? 「NULL 值列表」的空间不是固定 1 字节的。 当一条记录有 9 个字段值都是 NULL,那么就会创建 2 字节空间的「NULL 值列表」,以此类推。 #### 3. 记录头信息 记录头信息中包含的内容很多,我就不一一列举了,这里说几个比较重要的: - delete_mask:标识此条数据是否被删除。从这里可以知道,我们执行 detele 删除记录的时候,并不会真正的删除记录,只是将这个记录的 delete_mask 标记为 1。 - next_record:下一条记录的位置。从这里可以知道,记录与记录之间是通过链表组织的。在前面我也提到了,指向的是下一条记录的「记录头信息」和「真实数据」之间的位置,这样的好处是向左读就是记录头信息,向右读就是真实数据,比较方便。 - record_type:表示当前记录的类型,0 表示普通记录,1 表示 B+树非叶子节点记录,2 表示最小记录,3 表示最大记录 ### 记录的真实数据 记录真实数据部分除了我们定义的字段,还有三个隐藏字段,分别为:row_id、trx_id、roll_pointer,我们来看下这三个字段是什么。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/row_format/记录的真实数据.png) - row_id 如果我们建表的时候指定了主键或者唯一约束列,那么就没有 row_id 隐藏字段了。如果既没有指定主键,又没有唯一约束,那么 InnoDB 就会为记录添加 row_id 隐藏字段。row_id 不是必需的,占用 6 个字节。 - trx_id 事务 id,表示这个数据是由哪个事务生成的。trx_id 是必需的,占用 6 个字节。 - roll_pointer 这条记录上一个版本的指针。roll_pointer 是必需的,占用 7 个字节。 如果你熟悉 MVCC 机制,你应该就清楚 trx_id 和 roll_pointer 的作用了,如果你还不知道 MVCC 机制,可以看完[这篇文章](https://xiaolincoding.com/mysql/transaction/mvcc.html),一定要掌握,面试也很经常问 MVCC 是怎么实现的。 ## varchar(n) 中 n 最大取值为多少? 我们要清楚一点,**MySQL 规定除了 TEXT、BLOBs 这种大对象类型之外,其他所有的列(不包括隐藏列和记录头信息)占用的字节长度加起来不能超过 65535 个字节**。 也就是说,一行记录除了 TEXT、BLOBs 类型的列,限制最大为 65535 字节,注意是一行的总长度,不是一列。 知道了这个前提之后,我们再来看看这个问题:「varchar(n) 中 n 最大取值为多少?」 varchar(n) 字段类型的 n 代表的是最多存储的字符数量,并不是字节大小哦。 要算 varchar(n) 最大能允许存储的字节数,还要看数据库表的字符集,因为字符集代表着,1 个字符要占用多少字节,比如 ascii 字符集,1 个字符占用 1 字节,那么 varchar(100) 意味着最大能允许存储 100 字节的数据。 ### 单字段的情况 前面我们知道了,一行记录最大只能存储 65535 字节的数据。 那假设数据库表只有一个 varchar(n) 类型的列且字符集是 ascii,在这种情况下,varchar(n) 中 n 最大取值是 65535 吗? 不着急说结论,我们先来做个实验验证一下。 我们定义一个 varchar(65535) 类型的字段,字符集为 ascii 的数据库表。 ```sql CREATE TABLE test ( `name` VARCHAR(65535) NULL ) ENGINE = InnoDB DEFAULT CHARACTER SET = ascii ROW_FORMAT = COMPACT; ``` 看能不能成功创建一张表: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/row_format/error.png) 可以看到,创建失败了。 从报错信息就可以知道**一行数据的最大字节数是 65535(不包含 TEXT、BLOBs 这种大对象类型),其中包含了 storage overhead**。 问题来了,这个 storage overhead 是什么呢?其实就是「变长字段长度列表」和「NULL 值列表」,也就是说**一行数据的最大字节数 65535,其实是包含「变长字段长度列表」和「NULL 值列表」所占用的字节数的**。所以,我们在算 varchar(n) 中 n 最大值时,需要减去 storage overhead 占用的字节数。 这是因为我们存储字段类型为 varchar(n) 的数据时,其实分成了三个部分来存储: - 真实数据 - 真实数据占用的字节数 - NULL 标识,如果不允许为 NULL,这部分不需要 > 本次案例中,「NULL 值列表」所占用的字节数是多少? 前面我创建表的时候,字段是允许为 NULL 的,所以**会用 1 字节来表示「NULL 值列表」**。 > 本次案例中,「变长字段长度列表」所占用的字节数是多少? 「变长字段长度列表」所占用的字节数 = 所有「变长字段长度」占用的字节数之和。 所以,我们要先知道每个变长字段的「变长字段长度」需要用多少字节表示?具体情况分为: - 条件一:如果变长字段允许存储的最大字节数小于等于 255 字节,就会用 1 字节表示「变长字段长度」; - 条件二:如果变长字段允许存储的最大字节数大于 255 字节,就会用 2 字节表示「变长字段长度」; 我们这里字段类型是 varchar(65535) ,字符集是 ascii,所以代表着变长字段允许存储的最大字节数是 65535,符合条件二,所以会用 2 字节来表示「变长字段长度」。 **因为我们这个案例是只有 1 个变长字段,所以「变长字段长度列表」= 1 个「变长字段长度」占用的字节数,也就是 2 字节**。 因为我们在算 varchar(n) 中 n 最大值时,需要减去「变长字段长度列表」和「NULL 值列表」所占用的字节数的。所以,**在数据库表只有一个 varchar(n) 字段且字符集是 ascii 的情况下,varchar(n) 中 n 最大值 = 65535 - 2 - 1 = 65532**。 我们先来测试看看 varchar(65533) 是否可行? ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/row_format/65533.png) 可以看到,还是不行,接下来看看 varchar(65532) 是否可行? ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/row_format/65532.png) 可以看到,创建成功了。说明我们的推论是正确的,在算 varchar(n) 中 n 最大值时,需要减去「变长字段长度列表」和「NULL 值列表」所占用的字节数的。 当然,我上面这个例子是针对字符集为 ascii 情况,如果采用的是 UTF-8,varchar(n) 最多能存储的数据计算方式就不一样了: - 在 UTF-8 字符集下,一个字符串最多需要三个字节,varchar(n) 的 n 最大取值就是 65532/3 = 21844。 上面所说的只是针对于一个字段的计算方式。 ### 多字段的情况 **如果有多个字段的话,要保证所有字段的长度 + 变长字段字节数列表所占用的字节数 + NULL 值列表所占用的字节数 <= 65535**。 这里举个多字段的情况的例子(感谢@Emoji 同学提供的例子) ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/row_format/多字段的情况.png) ## 行溢出后,MySQL 是怎么处理的? MySQL 中磁盘和内存交互的基本单位是页,一个页的大小一般是 `16KB`,也就是 `16384字节`,而一个 varchar(n) 类型的列最多可以存储 `65532字节`,一些大对象如 TEXT、BLOB 可能存储更多的数据,这时一个页可能就存不了一条记录。这个时候就会**发生行溢出,多的数据就会存到另外的「溢出页」中**。 如果一个数据页存不了一条记录,InnoDB 存储引擎会自动将溢出的数据存放到「溢出页」中。在一般情况下,InnoDB 的数据都是存放在「数据页」中。但是当发生行溢出时,溢出的数据会存放到「溢出页」中。 当发生行溢出时,在记录的真实数据处只会保存该列的一部分数据,而把剩余的数据放在「溢出页」中,然后真实数据处用 20 字节存储指向溢出页的地址,从而可以找到剩余数据所在的页。大致如下图所示。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/row_format/行溢出.png) 上面这个是 Compact 行格式在发生行溢出后的处理。 Compressed 和 Dynamic 这两个行格式和 Compact 非常类似,主要的区别在于处理行溢出数据时有些区别。 这两种格式采用完全的行溢出方式,记录的真实数据处不会存储该列的一部分数据,只存储 20 个字节的指针来指向溢出页。而实际的数据都存储在溢出页中,看起来就像下面这样: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/row_format/行溢出2.png) ## 总结 > MySQL 的 NULL 值是怎么存放的? MySQL 的 Compact 行格式中会用「NULL 值列表」来标记值为 NULL 的列,NULL 值并不会存储在行格式中的真实数据部分。 NULL 值列表会占用 1 字节空间,当表中所有字段都定义成 NOT NULL,行格式中就不会有 NULL 值列表,这样可节省 1 字节的空间。 > MySQL 怎么知道 varchar(n) 实际占用数据的大小? MySQL 的 Compact 行格式中会用「变长字段长度列表」存储变长字段实际占用的数据大小。 > varchar(n) 中 n 最大取值为多少? 一行记录最大能存储 65535 字节的数据,但是这个是包含「变长字段字节数列表所占用的字节数」和「NULL 值列表所占用的字节数」。所以,我们在算 varchar(n) 中 n 最大值时,需要减去这两个列表所占用的字节数。 如果一张表只有一个 varchar(n) 字段,且允许为 NULL,字符集为 ascii。varchar(n) 中 n 最大取值为 65532。 计算公式:65535 - 变长字段字节数列表所占用的字节数 - NULL 值列表所占用的字节数 = 65535 - 2 - 1 = 65532。 如果有多个字段的话,要保证所有字段的长度 + 变长字段字节数列表所占用的字节数 + NULL 值列表所占用的字节数 <= 65535。 > 行溢出后,MySQL 是怎么处理的? 如果一个数据页存不了一条记录,InnoDB 存储引擎会自动将溢出的数据存放到「溢出页」中。 Compact 行格式针对行溢出的处理是这样的:当发生行溢出时,在记录的真实数据处只会保存该列的一部分数据,而把剩余的数据放在「溢出页」中,然后真实数据处用 20 字节存储指向溢出页的地址,从而可以找到剩余数据所在的页。 Compressed 和 Dynamic 这两种格式采用完全的行溢出方式,记录的真实数据处不会存储该列的一部分数据,只存储 20 个字节的指针来指向溢出页。而实际的数据都存储在溢出页中。 参考资料: - 《MySQL 是怎样运行的》 - 《MySQL 技术内幕 InnoDB 存储引擎》 --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: mysql/buffer_pool/README.md ================================================ buffer poll、Change Buffer、Adaptive Hash Index、Change Buffer、Doublewrite Buffer 正在赶稿的路上。。。。。 ================================================ FILE: mysql/buffer_pool/buffer_pool.md ================================================ # 揭开 Buffer Pool 的面纱 大家好,我是小林。 今天就聊 MySQL 的 Buffer Pool,发车! ![](https://img-blog.csdnimg.cn/e5a23e5c53ef471b947b5007866229fe.png#pic_center) ## 为什么要有 Buffer Pool? 虽然说 MySQL 的数据是存储在磁盘里的,但是也不能每次都从磁盘里面读取数据,这样性能是极差的。 要想提升查询性能,加个缓存就行了嘛。所以,当数据从磁盘中取出后,缓存内存中,下次查询同样的数据的时候,直接从内存中读取。 为此,Innodb 存储引擎设计了一个**缓冲池(*Buffer Pool*)**,来提高数据库的读写性能。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/缓冲池.drawio.png) 有了缓冲池后: - 当读取数据时,如果数据存在于 Buffer Pool 中,客户端就会直接读取 Buffer Pool 中的数据,否则再去磁盘中读取。 - 当修改数据时,首先是修改 Buffer Pool 中数据所在的页,然后将其页设置为脏页,最后由后台线程将脏页写入到磁盘。 ### Buffer Pool 有多大? Buffer Pool 是在 MySQL 启动的时候,向操作系统申请的一片连续的内存空间,默认配置下 Buffer Pool 只有 `128MB` 。 可以通过调整 `innodb_buffer_pool_size` 参数来设置 Buffer Pool 的大小,一般建议设置成可用物理内存的 60%~80%。 ### Buffer Pool 缓存什么? InnoDB 会把存储的数据划分为若干个「页」,以页作为磁盘和内存交互的基本单位,一个页的默认大小为 16KB。因此,Buffer Pool 同样需要按「页」来划分。 在 MySQL 启动的时候,**InnoDB 会为 Buffer Pool 申请一片连续的内存空间,然后按照默认的`16KB`的大小划分出一个个的页,Buffer Pool 中的页就叫做缓存页**。此时这些缓存页都是空闲的,之后随着程序的运行,才会有磁盘上的页被缓存到 Buffer Pool 中。 所以,MySQL 刚启动的时候,你会观察到使用的虚拟内存空间很大,而使用到的物理内存空间却很小,这是因为只有这些虚拟内存被访问后,操作系统才会触发缺页中断,接着将虚拟地址和物理地址建立映射关系。 Buffer Pool 除了缓存「索引页」和「数据页」,还包括了 undo 页,插入缓存、自适应哈希索引、锁信息等等。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/bufferpool内容.drawio.png) 为了更好的管理这些在 Buffer Pool 中的缓存页,InnoDB 为每一个缓存页都创建了一个**控制块**,控制块信息包括「缓存页的表空间、页号、缓存页地址、链表节点」等等。 控制块也是占有内存空间的,它是放在 Buffer Pool 的最前面,接着才是缓存页,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/缓存页.drawio.png) 上图中控制块和缓存页之间灰色部分称为碎片空间。 > 为什么会有碎片空间呢? 你想想啊,每一个控制块都对应一个缓存页,那在分配足够多的控制块和缓存页后,可能剩余的那点儿空间不够一对控制块和缓存页的大小,自然就用不到喽,这个用不到的那点儿内存空间就被称为碎片了。 当然,如果你把 Buffer Pool 的大小设置的刚刚好的话,也可能不会产生碎片。 > 查询一条记录,就只需要缓冲一条记录吗? 不是的。 当我们查询一条记录时,InnoDB 是会把整个页的数据加载到 Buffer Pool 中,因为,通过索引只能定位到磁盘中的页,而不能定位到页中的一条记录。将页加载到 Buffer Pool 后,再通过页里的页目录去定位到某条具体的记录。 关于页结构长什么样和索引怎么查询数据的问题可以在这篇找到答案:[换一个角度看 B+ 树](https://mp.weixin.qq.com/s/A5gNVXMNE-iIlY3oofXtLw) ## 如何管理 Buffer Pool? ### 如何管理空闲页? Buffer Pool 是一片连续的内存空间,当 MySQL 运行一段时间后,这片连续的内存空间中的缓存页既有空闲的,也有被使用的。 那当我们从磁盘读取数据的时候,总不能通过遍历这一片连续的内存空间来找到空闲的缓存页吧,这样效率太低了。 所以,为了能够快速找到空闲的缓存页,可以使用链表结构,将空闲缓存页的「控制块」作为链表的节点,这个链表称为 **Free 链表**(空闲链表)。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/freelist.drawio.png) Free 链表上除了有控制块,还有一个头节点,该头节点包含链表的头节点地址,尾节点地址,以及当前链表中节点的数量等信息。 Free 链表节点是一个一个的控制块,而每个控制块包含着对应缓存页的地址,所以相当于 Free 链表节点都对应一个空闲的缓存页。 有了 Free 链表后,每当需要从磁盘中加载一个页到 Buffer Pool 中时,就从 Free 链表中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填上,然后把该缓存页对应的控制块从 Free 链表中移除。 ### 如何管理脏页? 设计 Buffer Pool 除了能提高读性能,还能提高写性能,也就是更新数据的时候,不需要每次都要写入磁盘,而是将 Buffer Pool 对应的缓存页标记为**脏页**,然后再由后台线程将脏页写入到磁盘。 那为了能快速知道哪些缓存页是脏的,于是就设计出 **Flush 链表**,它跟 Free 链表类似的,链表的节点也是控制块,区别在于 Flush 链表的元素都是脏页。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/Flush.drawio.png) 有了 Flush 链表后,后台线程就可以遍历 Flush 链表,将脏页写入到磁盘。 ### 如何提高缓存命中率? Buffer Pool 的大小是有限的,对于一些频繁访问的数据我们希望可以一直留在 Buffer Pool 中,而一些很少访问的数据希望可以在某些时机可以淘汰掉,从而保证 Buffer Pool 不会因为满了而导致无法再缓存新的数据,同时还能保证常用数据留在 Buffer Pool 中。 要实现这个,最容易想到的就是 LRU(Least recently used)算法。 该算法的思路是,链表头部的节点是最近使用的,而链表末尾的节点是最久没被使用的。那么,当空间不够了,就淘汰最久没被使用的节点,从而腾出空间。 简单的 LRU 算法的实现思路是这样的: - 当访问的页在 Buffer Pool 里,就直接把该页对应的 LRU 链表节点移动到链表的头部。 - 当访问的页不在 Buffer Pool 里,除了要把页放入到 LRU 链表的头部,还要淘汰 LRU 链表末尾的节点。 比如下图,假设 LRU 链表长度为 5,LRU 链表从左到右有 1,2,3,4,5 的页。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/lru.png) 如果访问了 3 号的页,因为 3 号页在 Buffer Pool 里,所以把 3 号页移动到头部即可。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/lru2.png) 而如果接下来,访问了 8 号页,因为 8 号页不在 Buffer Pool 里,所以需要先淘汰末尾的 5 号页,然后再将 8 号页加入到头部。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/lru3.png) 到这里我们可以知道,Buffer Pool 里有三种页和链表来管理数据。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/bufferpoll_page.png) 图中: - Free Page(空闲页),表示此页未被使用,位于 Free 链表; - Clean Page(干净页),表示此页已被使用,但是页面未发生修改,位于 LRU 链表。 - Dirty Page(脏页),表示此页「已被使用」且「已经被修改」,其数据和磁盘上的数据已经不一致。当脏页上的数据写入磁盘后,内存数据和磁盘数据一致,那么该页就变成了干净页。脏页同时存在于 LRU 链表和 Flush 链表。 简单的 LRU 算法并没有被 MySQL 使用,因为简单的 LRU 算法无法避免下面这两个问题: - 预读失效; - Buffer Pool 污染; > 什么是预读失效? 先来说说 MySQL 的预读机制。程序是有空间局部性的,靠近当前被访问数据的数据,在未来很大概率会被访问到。 所以,MySQL 在加载数据页时,会提前把它相邻的数据页一并加载进来,目的是为了减少磁盘 IO。 但是可能这些**被提前加载进来的数据页,并没有被访问**,相当于这个预读是白做了,这个就是**预读失效**。 如果使用简单的 LRU 算法,就会把预读页放到 LRU 链表头部,而当 Buffer Pool 空间不够的时候,还需要把末尾的页淘汰掉。 如果这些预读页如果一直不会被访问到,就会出现一个很奇怪的问题,不会被访问的预读页却占用了 LRU 链表前排的位置,而末尾淘汰的页,可能是频繁访问的页,这样就大大降低了缓存命中率。 > 怎么解决预读失效而导致缓存命中率降低的问题? 我们不能因为害怕预读失效,而将预读机制去掉,大部分情况下,局部性原理还是成立的。 要避免预读失效带来影响,最好就是**让预读的页停留在 Buffer Pool 里的时间要尽可能的短,让真正被访问的页才移动到 LRU 链表的头部,从而保证真正被读取的热数据留在 Buffer Pool 里的时间尽可能长**。 那到底怎么才能避免呢? MySQL 是这样做的,它改进了 LRU 算法,将 LRU 划分了 2 个区域:**old 区域 和 young 区域**。 young 区域在 LRU 链表的前半部分,old 区域则是在后半部分,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/young%2Bold.png) old 区域占整个 LRU 链表长度的比例可以通过 `innodb_old_blocks_pct` 参数来设置,默认是 37,代表整个 LRU 链表中 young 区域与 old 区域比例是 63:37。 **划分这两个区域后,预读的页就只需要加入到 old 区域的头部,当页被真正访问的时候,才将页插入 young 区域的头部**。如果预读的页一直没有被访问,就会从 old 区域移除,这样就不会影响 young 区域中的热点数据。 接下来,给大家举个例子。 假设有一个长度为 10 的 LRU 链表,其中 young 区域占比 70 %,old 区域占比 30 %。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/lrutwo.drawio.png) 现在有个编号为 20 的页被预读了,这个页只会被插入到 old 区域头部,而 old 区域末尾的页(10 号)会被淘汰掉。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/lrutwo2.png) 如果 20 号页一直不会被访问,它也没有占用到 young 区域的位置,而且还会比 young 区域的数据更早被淘汰出去。 如果 20 号页被预读后,立刻被访问了,那么就会将它插入到 young 区域的头部,young 区域末尾的页(7 号),会被挤到 old 区域,作为 old 区域的头部,这个过程并不会有页被淘汰。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/lrutwo3.png) 虽然通过划分 old 区域 和 young 区域避免了预读失效带来的影响,但是还有个问题无法解决,那就是 Buffer Pool 污染的问题。 > 什么是 Buffer Pool 污染? 当某一个 SQL 语句**扫描了大量的数据**时,在 Buffer Pool 空间比较有限的情况下,可能会将 **Buffer Pool 里的所有页都替换出去,导致大量热数据被淘汰了**,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 IO,MySQL 性能就会急剧下降,这个过程被称为 **Buffer Pool 污染**。 注意,Buffer Pool 污染并不只是查询语句查询出了大量的数据才出现的问题,即使查询出来的结果集很小,也会造成 Buffer Pool 污染。 比如,在一个数据量非常大的表,执行了这条语句: ```sql select * from t_user where name like "%xiaolin%"; ``` 可能这个查询出来的结果就几条记录,但是由于这条语句会发生索引失效,所以这个查询过程是全表扫描的,接着会发生如下的过程: - 从磁盘读到的页加入到 LRU 链表的 old 区域头部; - 当从页里读取行记录时,也就是页被访问的时候,就要将该页放到 young 区域头部; - 接下来拿行记录的 name 字段和字符串 xiaolin 进行模糊匹配,如果符合条件,就加入到结果集里; - 如此往复,直到扫描完表中的所有记录。 经过这一番折腾,原本 young 区域的热点数据都会被替换掉。 举个例子,假设需要批量扫描:21,22,23,24,25 这五个页,这些页都会被逐一访问(读取页里的记录)。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/lruthree.drawio.png) 在批量访问这些数据的时候,会被逐一插入到 young 区域头部。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/lruthree1.png) 可以看到,原本在 young 区域的热点数据 6 和 7 号页都被淘汰了,这就是 Buffer Pool 污染的问题。 > 怎么解决出现 Buffer Pool 污染而导致缓存命中率下降的问题? 像前面这种全表扫描的查询,很多缓冲页其实只会被访问一次,但是它却只因为被访问了一次而进入到 young 区域,从而导致热点数据被替换了。 LRU 链表中 young 区域就是热点数据,只要我们提高进入到 young 区域的门槛,就能有效地保证 young 区域里的热点数据不会被替换掉。 MySQL 是这样做的,进入到 young 区域条件增加了一个**停留在 old 区域的时间判断**。 具体是这样做的,在对某个处在 old 区域的缓存页进行第一次访问时,就在它对应的控制块中记录下来这个访问时间: - 如果后续的访问时间与第一次访问的时间**在某个时间间隔内**,那么**该缓存页就不会被从 old 区域移动到 young 区域的头部**; - 如果后续的访问时间与第一次访问的时间**不在某个时间间隔内**,那么**该缓存页移动到 young 区域的头部**; 这个间隔时间是由 `innodb_old_blocks_time` 控制的,默认是 1000 ms。 也就说,**只有同时满足「被访问」与「在 old 区域停留时间超过 1 秒」两个条件,才会被插入到 young 区域头部**,这样就解决了 Buffer Pool 污染的问题。 另外,MySQL 针对 young 区域其实做了一个优化,为了防止 young 区域节点频繁移动到头部。young 区域前面 1/4 被访问不会移动到链表头部,只有后面的 3/4 被访问了才会。 ### 脏页什么时候会被刷入磁盘? 引入了 Buffer Pool 后,当修改数据时,首先是修改 Buffer Pool 中数据所在的页,然后将其页设置为脏页,但是磁盘中还是原数据。 因此,脏页需要被刷入磁盘,保证缓存和磁盘数据一致,但是若每次修改数据都刷入磁盘,则性能会很差,因此一般都会在一定时机进行批量刷盘。 可能大家担心,如果在脏页还没有来得及刷入到磁盘时,MySQL 宕机了,不就丢失数据了吗? 这个不用担心,InnoDB 的更新操作采用的是 Write Ahead Log 策略,即先写日志,再写入磁盘,通过 redo log 日志让 MySQL 拥有了崩溃恢复能力。 下面几种情况会触发脏页的刷新: - 当 redo log 日志满了的情况下,会主动触发脏页刷新到磁盘; - Buffer Pool 空间不足时,需要将一部分数据页淘汰掉,如果淘汰的是脏页,需要先将脏页同步到磁盘; - MySQL 认为空闲时,后台线程回定期将适量的脏页刷入到磁盘; - MySQL 正常关闭之前,会把所有的脏页刷入到磁盘; 在我们开启了慢 SQL 监控后,如果你发现**「偶尔」会出现一些用时稍长的 SQL**,这可能是因为脏页在刷新到磁盘时可能会给数据库带来性能开销,导致数据库操作抖动。 如果间断出现这种现象,就需要调大 Buffer Pool 空间或 redo log 日志的大小。 ## 总结 Innodb 存储引擎设计了一个**缓冲池(*Buffer Pool*)**,来提高数据库的读写性能。 Buffer Pool 以页为单位缓冲数据,可以通过 `innodb_buffer_pool_size` 参数调整缓冲池的大小,默认是 128 M。 Innodb 通过三种链表来管理缓页: - Free List(空闲页链表),管理空闲页; - Flush List(脏页链表),管理脏页; - LRU List,管理脏页 + 干净页,将最近且经常查询的数据缓存在其中,而不常查询的数据就淘汰出去。; InnoDB 对 LRU 做了一些优化,我们熟悉的 LRU 算法通常是将最近查询的数据放到 LRU 链表的头部,而 InnoDB 做 2 点优化: - 将 LRU 链表 分为**young 和 old 两个区域**,加入缓冲池的页,优先插入 old 区域;页被访问时,才进入 young 区域,目的是为了解决预读失效的问题。 - 当**「页被访问」且「old 区域停留时间超过 `innodb_old_blocks_time` 阈值(默认为 1 秒)」**时,才会将页插入到 young 区域,否则还是插入到 old 区域,目的是为了解决批量数据访问,大量热数据淘汰的问题。 可以通过调整 `innodb_old_blocks_pct` 参数,设置 young 区域和 old 区域比例。 在开启了慢 SQL 监控后,如果你发现「偶尔」会出现一些用时稍长的 SQL,这可因为脏页在刷新到磁盘时导致数据库性能抖动。如果在很短的时间出现这种现象,就需要调大 Buffer Pool 空间或 redo log 日志的大小。 ---- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: mysql/index/2000w.md ================================================ # MySQL 单表不要超过 2000W 行,靠谱吗? 作为在后端圈开车的多年老司机,是不是经常听到过: - “MySQL 单表最好不要超过 2000W” - “单表超过 2000W 就要考虑数据迁移了” - “你这个表数据都马上要到 2000W 了,难怪查询速度慢” 这些名言民语就和“群里只讨论技术,不开车,开车速度不要超过 120 码,否则自动踢群”,只听过,没试过,哈哈。 下面我们就把车速踩到底,干到 180 码试试……. > 原文链接:https://my.oschina.net/u/4090830/blog/5559454 ## **实验** 实验一把看看…… 建一张表 ```sql CREATE TABLE person( id int NOT NULL AUTO_INCREMENT PRIMARY KEY comment '主键', person_id tinyint not null comment '用户 id', person_name VARCHAR(200) comment '用户名称', gmt_create datetime comment '创建时间', gmt_modified datetime comment '修改时间' ) comment '人员信息表'; ``` 插入一条数据 ```sql insert into person values(1, 1,'user_1', NOW(), now()); ``` 利用 MySQL 伪列 rownum 设置伪列起始点为 1 ```sql select (@i:=@i+1) as rownum, person_name from person, (select @i:=100) as init; set @i=1; ``` 运行下面的 sql,连续执行 20 次,就是 2 的 20 次方约等于 100w 的数据;执行 23 次就是 2 的 23 次方约等于 800w , 如此下去即可实现千万测试数据的插入。 如果不想翻倍翻倍的增加数据,而是想少量,少量的增加,有个技巧,就是在 SQL 的后面增加 where 条件,如 id > 某一个值去控制增加的数据量即可。 ```sql insert into person(id, person_id, person_name, gmt_create, gmt_modified) select @i:=@i+1, left(rand()*10,10) as person_id, concat('user_',@i%2048), date_add(gmt_create,interval + @i*cast(rand()*100 as signed) SECOND), date_add(date_add(gmt_modified,interval +@i*cast(rand()*100 as signed) SECOND), interval + cast(rand()*1000000 as signed) SECOND) from person; ``` 此处需要注意的是,也许你在执行到近 800w 或者 1000w 数据的时候,会报错:The total number of locks exceeds the lock table size。 这是由于你的临时表内存设置的不够大,只需要扩大一下设置参数即可。 ```sql SET GLOBAL tmp_table_size =512*1024*1024; (512M) SET global innodb_buffer_pool_size= 1*1024*1024*1024 (1G); ``` 先来看一组测试数据,这组数据是在 MySQL 8.0 的版本,并且是在我本机上,由于本机还跑着 idea , 浏览器等各种工具,所以并不是机器配置就是用于数据库配置,所以测试数据只限于参考。 ![图片](https://img-blog.csdnimg.cn/img_convert/db5d7c556b3e8e31b3b90d4ace54fe7e.png) ![图片](https://img-blog.csdnimg.cn/img_convert/d556bf1db3d3ebd0e781ebda33a916d6.png) 看到这组数据似乎好像真的和标题对应,当数据达到 2000W 以后,查询时长急剧上升,难道这就是铁律吗? 那下面我们就来看看这个建议值 2000W 是怎么来的? ## **单表数量限制** 首先我们先想想数据库单表行数最大多大? ```sql CREATE TABLE person( id int(10) NOT NULL AUTO_INCREMENT PRIMARY KEY comment '主键', person_id tinyint not null comment '用户 id', person_name VARCHAR(200) comment '用户名称', gmt_create datetime comment '创建时间', gmt_modified datetime comment '修改时间' ) comment '人员信息表'; ``` 看看上面的建表 sql。id 是主键,本身就是唯一的,也就是说主键的大小可以限制表的上限: - 如果主键声明 `int` 类型,也就是 32 位,那么支持 2^32-1 ~~21 亿; - 如果主键声明 `bigint` 类型,那就是 2^62-1(36893488147419103232),难以想象这个的多大了,一般还没有到这个限制之前,可能数据库已经爆满了!! 有人统计过,如果建表的时候,自增字段选择无符号的 bigint , 那么自增长最大值是 18446744073709551615,按照一秒新增一条记录的速度,大约什么时候能用完? ![图片](https://img-blog.csdnimg.cn/img_convert/87031fea63547be0f8ea692781d5b068.png) ## **表空间** 下面我们再来看看索引的结构,我们下面讲内容都是基于 Innodb 引擎的,大家都知道 Innodb 的索引内部用的是 B+ 树。 ![图片](https://img-blog.csdnimg.cn/img_convert/cb6f4fddc960cde55575a1b80a563b9a.png) 这张表数据,在硬盘上存储也是类似如此的,它实际是放在一个叫 person.ibd(innodb data)的文件中,也叫做表空间;虽然数据表中,他们看起来是一条连着一条,但是实际上在文件中它被分成很多小份的数据页,而且每一份都是 16K。 大概就像下面这样,当然这只是我们抽象出来的,在表空间中还有段、区、组等很多概念,但是我们需要跳出来看。 ![图片](https://img-blog.csdnimg.cn/img_convert/ee29f7137057b226658627ff61a6a514.png) ## **页的数据结构** 实际页的内部结构像是下面这样的: ![图片](https://img-blog.csdnimg.cn/img_convert/c34b589e12e5bc0855c9bdeab0c63a88.png) 从图中可以看出,一个 InnoDB 数据页的存储空间大致被划分成了 7 个部分,有的部分占用的字节数是确定的,有的部分占用的字节数是不确定的。 在页的 7 个组成部分中,我们自己存储的记录会按照我们指定的行格式存储到 `User Records` 部分。 但是在一开始生成页的时候,其实并没有 User Records 这个部分,每当我们插入一条记录,都会从 Free Space 部分,也就是尚未使用的存储空间中申请一个记录大小的空间划分到 User Records 部分。 当 Free Space 部分的空间全部被 User Records 部分替代掉之后,也就意味着这个页使用完了,如果还有新的记录插入的话,就需要去申请新的页了。 这个过程的图示如下: ![图片](https://img-blog.csdnimg.cn/img_convert/ea5cc8c67b7656d3f2a11e42293a0244.png) 刚刚上面说到了数据的新增的过程。 那下面就来说说,数据的查找过程,假如我们需要查找一条记录,我们可以把表空间中的每一页都加载到内存中,然后对记录挨个判断是不是我们想要的。 在数据量小的时候,没啥问题,内存也可以撑。但是现实就是这么残酷,不会给你这个局面。 为了解决这问题,MySQL 中就有了索引的概念,大家都知道索引能够加快数据的查询,那到底是怎么个回事呢?下面我就来看看。 ## **索引的数据结构** 在 MySQL 中索引的数据结构和刚刚描述的页几乎是一模一样的,而且大小也是 16K,。 但是在索引页中记录的是页 (数据页,索引页) 的最小主键 id 和页号,以及在索引页中增加了层级的信息,从 0 开始往上算,所以页与页之间就有了上下层级的概念。 ![图片](https://img-blog.csdnimg.cn/img_convert/6374409c6c404d446855dc6a694b6d26.png) 看到这个图之后,是不是有点似曾相似的感觉,是不是像一棵二叉树啊,对,没错!它就是一棵树。 只不过我们在这里只是简单画了三个节点,2 层结构的而已,如果数据多了,可能就会扩展到 3 层的树,这个就是我们常说的 B+ 树,最下面那一层的 page level =0, 也就是叶子节点,其余都是非叶子节点。 ![图片](https://img-blog.csdnimg.cn/img_convert/baf6644df710e1639e8e956b1f67d502.png) 看上图中,我们是单拿一个节点来看,首先它是一个非叶子节点(索引页),在它的内容区中有 id 和 页号地址两部分: - id:对应页中记录的最小记录 id 值; - 页号:地址是指向对应页的指针; 而数据页与此几乎大同小异,区别在于数据页记录的是真实的行数据而不是页地址,而且 id 的也是顺序的。 ## **单表建议值** 下面我们就以 3 层,2 分叉(实际中是 M 分叉)的图例来说明一下查找一个行数据的过程。 ![图片](https://img-blog.csdnimg.cn/img_convert/585429e5078566bda9b2fa18f85215af.png) 比如说我们需要查找一个 id=6 的行数据: - 因为在非叶子节点中存放的是页号和该页最小的 id,所以我们从顶层开始对比,首先看页号 10 中的目录,有 [id=1, 页号 = 20],[id=5, 页号 = 30], 说明左侧节点最小 id 为 1,右侧节点最小 id 是 5。6>5, 那按照二分法查找的规则,肯定就往右侧节点继续查找; - 找到页号 30 的节点后,发现这个节点还有子节点(非叶子节点),那就继续比对,同理,6>5 && 6<7, 所以找到了页号 60; - 找到页号 60 之后,发现此节点为叶子节点(数据节点),于是将此页数据加载至内存进行一一对比,结果找到了 id=6 的数据行。 从上述的过程中发现,我们为了查找 id=6 的数据,总共查询了三个页,如果三个页都在磁盘中(未提前加载至内存),那么最多需要经历三次的磁盘 IO。 需要注意的是,图中的页号只是个示例,实际情况下并不是连续的,在磁盘中存储也不一定是顺序的。 至此,我们大概已经了解了表的数据是怎么个结构了,也大概知道查询数据是个怎么的过程了,这样我们也就能大概估算这样的结构能存放多少数据了。 从上面的图解我们知道 B+ 数的叶子节点才是存在数据的,而非叶子节点是用来存放索引数据的。 所以,同样一个 16K 的页,非叶子节点里的每条数据都指向新的页,而新的页有两种可能 - 如果是叶子节点,那么里面就是一行行的数据 - 如果是非叶子节点的话,那么就会继续指向新的页 假设 - 非叶子节点内指向其他页的数量为 x - 叶子节点内能容纳的数据行数为 y - B+ 数的层数为 z 如下图中所示,**Total =x^(z-1) \*y 也就是说总数会等于 x 的 z-1 次方 与 Y 的乘积**。 ![图片](https://img-blog.csdnimg.cn/img_convert/e741373dcb282fce80d1522d33c6b53b.png) > X =? 在文章的开头已经介绍了页的结构,索引也也不例外,都会有 File Header (38 byte)、Page Header (56 Byte)、Infimum + Supermum(26 byte)、File Trailer(8byte), 再加上页目录,大概 1k 左右。 我们就当做它就是 1K, 那整个页的大小是 16K, 剩下 15k 用于存数据,在索引页中主要记录的是主键与页号,主键我们假设是 Bigint (8 byte), 而页号也是固定的(4Byte), 那么索引页中的一条数据也就是 12byte。 所以 x=15*1024/12≈1280 行。 > Y=? 叶子节点和非叶子节点的结构是一样的,同理,能放数据的空间也是 15k。 但是叶子节点中存放的是真正的行数据,这个影响的因素就会多很多,比如,字段的类型,字段的数量。每行数据占用空间越大,页中所放的行数量就会越少。 这边我们暂时按一条行数据 1k 来算,那一页就能存下 15 条,Y = 15*1024/1000 ≈15。 算到这边了,是不是心里已经有谱了啊。 根据上述的公式,Total =x^(z-1) *y,已知 x=1280,y=15: - 假设 B+ 树是两层,那就是 z = 2,Total = (1280 ^1)*15 = 19200 - 假设 B+ 树是三层,那就是 z = 3,Total = (1280 ^2) *15 = 24576000(约 2.45kw) 哎呀,妈呀!这不是正好就是文章开头说的最大行数建议值 2000W 嘛!对的,一般 B+ 数的层级最多也就是 3 层。 你试想一下,如果是 4 层,除了查询的时候磁盘 IO 次数会增加,而且这个 Total 值会是多少,大概应该是 3 百多亿吧,也不太合理,所以,3 层应该是比较合理的一个值。 > 到这里难道就完了? 不。 我们刚刚在说 Y 的值时候假设的是 1K,那比如我实际当行的数据占用空间不是 1K , 而是 5K, 那么单个数据页最多只能放下 3 条数据。 同样,还是按照 z = 3 的值来计算,那 Total = (1280 ^2) *3 = 4915200(近 500w) 所以,在保持相同的层级(相似查询性能)的情况下,在行数据大小不同的情况下,其实这个最大建议值也是不同的,而且影响查询性能的还有很多其他因素,比如,数据库版本,服务器配置,sql 的编写等等。 MySQL 为了提高性能,会将表的索引装载到内存中,在 InnoDB buffer size 足够的情况下,其能完成全加载进内存,查询不会有问题。 但是,当单表数据库到达某个量级的上限时,导致内存无法存储其索引,使得之后的 SQL 查询会产生磁盘 IO,从而导致性能下降,所以增加硬件配置(比如把内存当磁盘使),可能会带来立竿见影的性能提升哈。 ## **总结** - MySQL 的表数据是以页的形式存放的,页在磁盘中不一定是连续的。 - 页的空间是 16K, 并不是所有的空间都是用来存放数据的,会有一些固定的信息,如,页头,页尾,页码,校验码等等。 - 在 B+ 树中,叶子节点和非叶子节点的数据结构是一样的,区别在于,叶子节点存放的是实际的行数据,而非叶子节点存放的是主键和页号。 - 索引结构不会影响单表最大行数,2000W 也只是推荐值,超过了这个值可能会导致 B + 树层级更高,影响查询性能。 --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: mysql/index/count.md ================================================ # count(*) 和 count(1) 有什么区别?哪个性能最好? 大家好,我是小林。 当我们对一张数据表中的记录进行统计的时候,习惯都会使用 count 函数来统计,但是 count 函数传入的参数有很多种,比如 count(1)、count(`*`)、count(字段) 等。 到底哪种效率是最好的呢?是不是 count(`*`) 效率最差? 我曾经以为 count(`*`) 是效率最差的,因为认知上 `selete * from t` 会读取所有表中的字段,所以凡事带有 `*` 字符的就觉得会读取表中所有的字段,当时网上有很多博客也这么说。 但是,当我深入 count 函数的原理后,被啪啪啪的打脸了! 不多说,发车! ![图片](https://img-blog.csdnimg.cn/img_convert/d9b9817e92f805e9a16faf31a2c10d9a.png) ## 哪种 count 性能最好? 我先直接说结论: ![图片](https://img-blog.csdnimg.cn/img_convert/af711033aa3423330d3a4bc6baeb9532.png) 要弄明白这个,我们得要深入 count 的原理,以下内容基于常用的 innodb 存储引擎来说明。 ### count() 是什么? count() 是一个聚合函数,函数的参数不仅可以是字段名,也可以是其他任意表达式,该函数作用是**统计符合查询条件的记录中,函数指定的参数不为 NULL 的记录有多少个**。 假设 count() 函数的参数是字段名,如下: ```sql select count(name) from t_order; ``` 这条语句是统计「t_order 表中,name 字段不为 NULL 的记录」有多少个。也就是说,如果某一条记录中的 name 字段的值为 NULL,则就不会被统计进去。 再来假设 count() 函数的参数是数字 1 这个表达式,如下: ```sql select count(1) from t_order; ``` 这条语句是统计「t_order 表中,1 这个表达式不为 NULL 的记录」有多少个。 1 这个表达式就是单纯数字,它永远都不是 NULL,所以上面这条语句,其实是在统计 t_order 表中有多少个记录。 ### count(主键字段) 执行过程是怎样的? 在通过 count 函数统计有多少个记录时,MySQL 的 server 层会维护一个名叫 count 的变量。 server 层会循环向 InnoDB 读取一条记录,如果 count 函数指定的参数不为 NULL,那么就会将变量 count 加 1,直到符合查询的全部记录被读完,就退出循环。最后将 count 变量的值发送给客户端。 InnoDB 是通过 B+ 树来保存记录的,根据索引的类型又分为聚簇索引和二级索引,它们区别在于,聚簇索引的叶子节点存放的是实际数据,而二级索引的叶子节点存放的是主键值,而不是实际数据。 用下面这条语句作为例子: ```sql //id 为主键值 select count(id) from t_order; ``` 如果表里只有主键索引,没有二级索引时,那么,InnoDB 循环遍历聚簇索引,将读取到的记录返回给 server 层,然后读取记录中的 id 值,就会 id 值判断是否为 NULL,如果不为 NULL,就将 count 变量加 1。 ![图片](https://img-blog.csdnimg.cn/img_convert/9bb4f32ac843467684a2664d4db61ae3.png) 但是,如果表里有二级索引时,InnoDB 循环遍历的对象就不是聚簇索引,而是二级索引。 ![图片](https://img-blog.csdnimg.cn/img_convert/aac550602ef1022e0b45020dbe0f716a.png) 这是因为相同数量的二级索引记录可以比聚簇索引记录占用更少的存储空间,所以二级索引树比聚簇索引树小,这样遍历二级索引的 I/O 成本比遍历聚簇索引的 I/O 成本小,因此「优化器」优先选择的是二级索引。 ### count(1) 执行过程是怎样的? 用下面这条语句作为例子: ```plain select count(1) from t_order; ``` 如果表里只有主键索引,没有二级索引时。 ![图片](https://img-blog.csdnimg.cn/img_convert/e630fdc5897b5c5dbc332e8838afa1fc.png) 那么,InnoDB 循环遍历聚簇索引(主键索引),将读取到的记录返回给 server 层,**但是不会读取记录中的任何字段的值**,因为 count 函数的参数是 1,不是字段,所以不需要读取记录中的字段值。参数 1 很明显并不是 NULL,因此 server 层每从 InnoDB 读取到一条记录,就将 count 变量加 1。 可以看到,count(1) 相比 count(主键字段) 少一个步骤,就是不需要读取记录中的字段值,所以通常会说 count(1) 执行效率会比 count(主键字段) 高一点。 但是,如果表里有二级索引时,InnoDB 循环遍历的对象就二级索引了。 ![图片](https://img-blog.csdnimg.cn/img_convert/01e83441a7721f0864deb1ac14ad8ea6.png) ### count(*) 执行过程是怎样的? 看到 `*` 这个字符的时候,是不是大家觉得是读取记录中的所有字段值? 对于 `select *` 这条语句来说是这个意思,但是在 count(*) 中并不是这个意思。 **count(`*`) 其实等于 count(`0`)**,也就是说,当你使用 count(`*`) 时,MySQL 会将 `*` 参数转化为参数 0 来处理。 ![图片](https://img-blog.csdnimg.cn/img_convert/27b229f049b27898f3a86c7da7e26114.png) 所以,**count(\*) 执行过程跟 count(1) 执行过程基本一样的**,性能没有什么差异。 在 MySQL 5.7 的官方手册中有这么一句话: *InnoDB handles SELECT COUNT(`*`) and SELECT COUNT(`1`) operations in the same way. There is no performance difference.* *翻译:InnoDB 以相同的方式处理 SELECT COUNT(`*`)和 SELECT COUNT(`1`)操作,没有性能差异。* 而且 MySQL 会对 count(*) 和 count(1) 有个优化,如果有多个二级索引的时候,优化器会使用 key_len 最小的二级索引进行扫描。 只有当没有二级索引的时候,才会采用主键索引来进行统计。 ### count(字段) 执行过程是怎样的? count(字段) 的执行效率相比前面的 count(1)、count(*)、count(主键字段) 执行效率是最差的。 用下面这条语句作为例子: ```sql // name不是索引,普通字段 select count(name) from t_order; ``` 对于这个查询来说,会采用全表扫描的方式来计数,所以它的执行效率是比较差的。 ![图片](https://img-blog.csdnimg.cn/img_convert/f24dfeb85e2cfce0e4dc3a17b893b3f5.png) ### 小结 count(1)、 count(*)、count(主键字段) 在执行的时候,如果表里存在二级索引,优化器就会选择二级索引进行扫描。 所以,如果要执行 count(1)、count(*)、count(主键字段) 时,尽量在数据表上建立二级索引,这样优化器会自动采用 key_len 最小的二级索引进行扫描,相比于扫描主键索引效率会高一些。 再来,就是不要使用 count(字段) 来统计记录个数,因为它的效率是最差的,会采用全表扫描的方式来统计。如果你非要统计表中该字段不为 NULL 的记录个数,建议给这个字段建立一个二级索引。 ## 为什么要通过遍历的方式来计数? 你可能会好奇,为什么 count 函数需要通过遍历的方式来统计记录个数? 我前面将的案例都是基于 Innodb 存储引擎来说明的,但是在 MyISAM 存储引擎里,执行 count 函数的方式是不一样的,通常在没有任何查询条件下的 count(*),MyISAM 的查询速度要明显快于 InnoDB。 使用 MyISAM 引擎时,执行 count 函数只需要 O(1 ) 复杂度,这是因为每张 MyISAM 的数据表都有一个 meta 信息有存储了 row_count 值,由表级锁保证一致性,所以直接读取 row_count 值就是 count 函数的执行结果。 而 InnoDB 存储引擎是支持事务的,同一个时刻的多个查询,由于多版本并发控制(MVCC)的原因,InnoDB 表“应该返回多少行”也是不确定的,所以无法像 MyISAM 一样,只维护一个 row_count 变量。 举个例子,假设表 t_order 有 100 条记录,现在有两个会话并行以下语句: ![图片](https://img-blog.csdnimg.cn/img_convert/04d714293f5c687810562e984b67d2e7.png) 在会话 A 和会话 B 的最后一个时刻,同时查表 t_order 的记录总个数,可以发现,显示的结果是不一样的。所以,在使用 InnoDB 存储引擎时,就需要扫描表来统计具体的记录。 而当带上 where 条件语句之后,MyISAM 跟 InnoDB 就没有区别了,它们都需要扫描表来进行记录个数的统计。 ## 如何优化 count(*)? 如果对一张大表经常用 count(*) 来做统计,其实是很不好的。 比如下面我这个案例,表 t_order 共有 1200+ 万条记录,我也创建了二级索引,但是执行一次 `select count(*) from t_order` 要花费差不多 5 秒! ![图片](https://img-blog.csdnimg.cn/img_convert/74a4359b58dc6ed41a241e425f43764d.png) 面对大表的记录统计,我们有没有什么其他更好的办法呢? ### 第一种,近似值 如果你的业务对于统计个数不需要很精确,比如搜索引擎在搜索关键词的时候,给出的搜索结果条数是一个大概值。 ![图片](https://img-blog.csdnimg.cn/img_convert/cd18879de0c0b37660f53a5f1af3d172.png) 这时,我们就可以使用 show table status 或者 explain 命令来表进行估算。 执行 explain 命令效率是很高的,因为它并不会真正的去查询,下图中的 rows 字段值就是 explain 命令对表 t_order 记录的估算值。 ![图片](https://img-blog.csdnimg.cn/img_convert/7590623443e8f225e5652109e6d9e3d2.png) ### 第二种,额外表保存计数值 如果是想精确的获取表的记录总数,我们可以将这个计数值保存到单独的一张计数表中。 当我们在数据表插入一条记录的同时,将计数表中的计数字段 + 1。也就是说,在新增和删除操作时,我们需要额外维护这个计数表。 ---- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: mysql/index/index_interview.md ================================================ # 索引常见面试题 大家好,我是小林。 面试中,MySQL 索引相关的问题基本都是一系列问题,都是先从索引的基本原理,再到索引的使用场景,比如: - 索引底层使用了什么数据结构和算法? - 为什么 MySQL InnoDB 选择 B+tree 作为索引的数据结构? - 什么时候适用索引? - 什么时候不需要创建索引? - 什么情况下索引会失效? - 有什么优化索引的方法? - …… 今天就带大家,夯实 MySQL 索引的知识点。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/索引/索引提纲.png) ## 什么是索引? 当你想查阅书中某个知识的内容,你会选择一页一页的找呢?还是在书的目录去找呢? 傻瓜都知道时间是宝贵的,当然是选择在书的目录去找,找到后再翻到对应的页。书中的**目录**,就是充当**索引**的角色,方便我们快速查找书中的内容,所以索引是以空间换时间的设计思想。 那换到数据库中,索引的定义就是帮助存储引擎快速获取数据的一种数据结构,形象的说就是**索引是数据的目录**。 所谓的存储引擎,说白了就是如何存储数据、如何为存储的数据建立索引和如何更新、查询数据等技术的实现方法。MySQL 存储引擎有 MyISAM、InnoDB、Memory,其中 InnoDB 是在 MySQL 5.5 之后成为默认的存储引擎。 下图是 MySQL 的结构图,索引和数据就是位于存储引擎中: ![](https://myblog-tuchuang.oss-cn-shanghai.aliyuncs.com/1623727651911_20170928110355446.png) ## 索引的分类 你知道索引有哪些吗?大家肯定都能霹雳啪啦地说出聚簇索引、主键索引、二级索引、普通索引、唯一索引、hash 索引、B+树索引等等。 然后再问你,你能将这些索引分一下类吗?可能大家就有点模糊了。其实,要对这些索引进行分类,要清楚这些索引的使用和实现方式,然后再针对有相同特点的索引归为一类。 我们可以按照四个角度来分类索引。 - 按「数据结构」分类:**B+tree 索引、Hash 索引、Full-text 索引**。 - 按「物理存储」分类:**聚簇索引(主键索引)、二级索引(辅助索引)**。 - 按「字段特性」分类:**主键索引、唯一索引、普通索引、前缀索引**。 - 按「字段个数」分类:**单列索引、联合索引**。 接下来,按照这些角度来说说各类索引的特点。 ### 按数据结构分类 从数据结构的角度来看,MySQL 常见索引有 B+Tree 索引、HASH 索引、Full-Text 索引。 每一种存储引擎支持的索引类型不一定相同,我在表中总结了 MySQL 常见的存储引擎 InnoDB、MyISAM 和 Memory 分别支持的索引类型。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/索引/索引分类.drawio.png) InnoDB 是在 MySQL 5.5 之后成为默认的 MySQL 存储引擎,B+Tree 索引类型也是 MySQL 存储引擎采用最多的索引类型。 在创建表时,InnoDB 存储引擎会根据不同的场景选择不同的列作为索引: - 如果有主键,默认会使用主键作为聚簇索引的索引键(key); - 如果没有主键,就选择第一个不包含 NULL 值的唯一列作为聚簇索引的索引键(key); - 在上面两个都没有的情况下,InnoDB 将自动生成一个隐式自增 id 列作为聚簇索引的索引键(key); 其它索引都属于辅助索引(Secondary Index),也被称为二级索引或非聚簇索引。**创建的主键索引和二级索引默认使用的是 B+Tree 索引**。 为了让大家理解 B+Tree 索引的存储和查询的过程,接下来我通过一个简单例子,说明一下 B+Tree 索引在存储数据中的具体实现。 先创建一张商品表,id 为主键,如下: ```sql CREATE TABLE `product` ( `id` int(11) NOT NULL, `product_no` varchar(20) DEFAULT NULL, `name` varchar(255) DEFAULT NULL, `price` decimal(10, 2) DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE ) CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic; ``` 商品表里,有这些行数据: ![](https://img-blog.csdnimg.cn/824c43b801c64e81acb0a9b042d50311.png) 这些行数据,存储在 B+Tree 索引时是长什么样子的? B+Tree 是一种多叉树,叶子节点才存放数据,非叶子节点只存放索引,而且每个节点里的数据是**按主键顺序存放**的。每一层父节点的索引值都会出现在下层子节点的索引值中,因此在叶子节点中,包括了所有的索引值信息,并且每一个叶子节点都指向下一个叶子节点,形成一个链表。 主键索引的 B+Tree 如图所示: ![主键索引 B+Tree](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/索引/btree.drawio.png) #### 通过主键查询商品数据的过程 比如,我们执行了下面这条查询语句,这条语句使用了主键索引查询 id 号为 5 的商品。查询过程是这样的,B+Tree 会自顶向下逐层进行查找: - 将 5 与根节点的索引数据 (1,10,20) 比较,5 在 1 和 10 之间,所以根据 B+Tree 的搜索逻辑,找到第二层的索引数据 (1,4,7); - 在第二层的索引数据 (1,4,7) 中进行查找,因为 5 在 4 和 7 之间,所以找到第三层的索引数据(4,5,6); - 在叶子节点的索引数据(4,5,6)中进行查找,然后我们找到了索引值为 5 的行数据。 数据库的索引和数据都是存储在硬盘的,我们可以把读取一个节点当作一次磁盘 I/O 操作。那么上面的整个查询过程一共经历了 3 个节点,也就是进行了 3 次 I/O 操作。 B+Tree 存储千万级的数据只需要 3-4 层高度就可以满足,这意味着从千万级的表查询目标数据最多需要 3-4 次磁盘 I/O,所以**B+Tree 相比于 B 树和二叉树来说,最大的优势在于查询效率很高,因为即使在数据量很大的情况,查询一个数据的磁盘 I/O 依然维持在 3-4 次。** #### 通过二级索引查询商品数据的过程 主键索引的 B+Tree 和二级索引的 B+Tree 区别如下: - 主键索引的 B+Tree 的叶子节点存放的是实际数据,所有完整的用户记录都存放在主键索引的 B+Tree 的叶子节点里; - 二级索引的 B+Tree 的叶子节点存放的是主键值,而不是实际数据。 我这里将前面的商品表中的 product_no(商品编码)字段设置为二级索引,那么二级索引的 B+Tree 如下图,其中非叶子的 key 值是 product_no(图中橙色部分),叶子节点存储的数据是主键值(图中绿色部分)。 ![二级索引 B+Tree](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/索引/二级索引btree.drawio.png) 如果我用 product_no 二级索引查询商品,如下查询语句: ```sql select * from product where product_no = '0002'; ``` 会先检查二级索引中的 B+Tree 的索引值(商品编码,product_no),找到对应的叶子节点,然后获取主键值,然后再通过主键索引中的 B+Tree 查询到对应的叶子节点,然后获取整行数据。**这个过程叫「回表」,也就是说要查两个 B+Tree 才能查到数据**。如下图: ![回表](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/索引/回表.drawio.png) 不过,当查询的数据是能在二级索引的 B+Tree 的叶子节点里查询到,这时就不用再去主键索引中查寻了,比如下面这条查询语句: ```sql select id from product where product_no = '0002'; ``` **这种在二级索引的 B+Tree 就能查询到结果的过程就叫作「覆盖索引」,也就是只需要查一个 B+Tree 就能找到数据**。 #### 为什么 MySQL InnoDB 选择 B+tree 作为索引的数据结构? 前面已经讲了 B+Tree 的索引原理,现在就来回答一下 B+Tree 相比于 B 树、二叉树或 Hash 索引结构的优势在哪儿? 之前我也专门写过一篇文章,想详细了解的可以看这篇:「[女朋友问我:为什么 MySQL 喜欢 B+ 树?我笑着画了 20 张图](https://mp.weixin.qq.com/s/w1ZFOug8-Sa7ThtMnlaUtQ)」,这里就简单做个比对。 ***1、B+Tree vs B Tree*** B+Tree 只在叶子节点存储数据,而 B 树 的非叶子节点也要存储数据,所以 B+Tree 的单个节点的数据量更小,在相同的磁盘 I/O 次数下,就能查询更多的节点。 另外,B+Tree 叶子节点采用的是双链表连接,适合 MySQL 中常见的基于范围的顺序查找,而 B 树无法做到这一点。 ***2、B+Tree vs 二叉树*** 对于有 N 个叶子节点的 B+Tree,其搜索复杂度为`O(logdN)`,其中 d 表示节点允许的最大子节点个数为 d 个。 在实际的应用当中,d 值是大于 100 的,这样就保证了,即使数据达到千万级别时,B+Tree 的高度依然维持在 3~4 层左右,也就是说一次数据查询操作只需要做 3~4 次的磁盘 I/O 操作就能查询到目标数据。 而二叉树的每个父节点的儿子节点个数只能是 2 个,意味着其搜索复杂度为 `O(logN)`,这已经比 B+Tree 高出不少,因此二叉树检索到目标数据所经历的磁盘 I/O 次数要更多。 ***3, B+Tree vs Hash*** Hash 在做等值查询的时候效率贼快,搜索复杂度为 O(1)。但也有其局限性: - **数据顺序性**:哈希表无法提供数据的顺序访问,更适合做等值的查询。很多查询不仅需要找到特定的键值,还需要根据键值排序来返回结果,或者执行范围查询。B+Tree 可以很好地支持,Hash 表则无法做到。 - **空间效率**:可能导致空间利用效率不高,特别是在处理大量数据时。数据量变大时冲突也会增加。 - **需要重新构建**:哈希索引通常只存储在内存中,当数据库重启或发生崩溃时,需要重新构建。 因此,B+Tree 索引要比 Hash 表索引有着更广泛的适用场景。 ### 按物理存储分类 从物理存储的角度来看,索引分为聚簇索引(主键索引)、二级索引(辅助索引)。 这两个区别在前面也提到了: - 主键索引的 B+Tree 的叶子节点存放的是实际数据,所有完整的用户记录都存放在主键索引的 B+Tree 的叶子节点里; - 二级索引的 B+Tree 的叶子节点存放的是主键值,而不是实际数据。 所以,在查询时使用了二级索引,如果查询的数据能在二级索引里查询的到,那么就不需要回表,这个过程就是覆盖索引。如果查询的数据不在二级索引里,就会先检索二级索引,找到对应的叶子节点,获取到主键值后,然后再检索主键索引,就能查询到数据了,这个过程就是回表。 ### 按字段特性分类 从字段特性的角度来看,索引分为主键索引、唯一索引、普通索引、前缀索引。 #### 主键索引 主键索引就是建立在主键字段上的索引,通常在创建表的时候一起创建,一张表最多只有一个主键索引,索引列的值不允许有空值。 在创建表时,创建主键索引的方式如下: ```sql CREATE TABLE table_name ( .... PRIMARY KEY (index_column_1) USING BTREE ); ``` #### 唯一索引 唯一索引建立在 UNIQUE 字段上的索引,一张表可以有多个唯一索引,索引列的值必须唯一,但是允许有空值。 在创建表时,创建唯一索引的方式如下: ```sql CREATE TABLE table_name ( .... UNIQUE KEY(index_column_1,index_column_2,...) ); ``` 建表后,如果要创建唯一索引,可以使用这面这条命令: ```sql CREATE UNIQUE INDEX index_name ON table_name(index_column_1,index_column_2,...); ``` #### 普通索引 普通索引就是建立在普通字段上的索引,既不要求字段为主键,也不要求字段为 UNIQUE。 在创建表时,创建普通索引的方式如下: ```sql CREATE TABLE table_name ( .... INDEX(index_column_1,index_column_2,...) ); ``` 建表后,如果要创建普通索引,可以使用这面这条命令: ```sql CREATE INDEX index_name ON table_name(index_column_1,index_column_2,...); ``` #### 前缀索引 前缀索引是指对字符类型字段的前几个字符建立的索引,而不是在整个字段上建立的索引,前缀索引可以建立在字段类型为 char、varchar、binary、varbinary 的列上。 使用前缀索引的目的是为了减少索引占用的存储空间,提升查询效率。 在创建表时,创建前缀索引的方式如下: ```sql CREATE TABLE table_name( column_list, INDEX(column_name(length)) ); ``` 建表后,如果要创建前缀索引,可以使用这面这条命令: ```sql CREATE INDEX index_name ON table_name(column_name(length)); ``` ### 按字段个数分类 从字段个数的角度来看,索引分为单列索引、联合索引(复合索引)。 - 建立在单列上的索引称为单列索引,比如主键索引; - 建立在多列上的索引称为联合索引; #### 联合索引 通过将多个字段组合成一个索引,该索引就被称为联合索引。 比如,将商品表中的 product_no 和 name 字段组合成联合索引`(product_no, name)`,创建联合索引的方式如下: ```sql CREATE INDEX index_product_no_name ON product(product_no, name); ``` 联合索引`(product_no, name)` 的 B+Tree 示意图如下: ![联合索引](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/索引/联合索引.drawio.png) 可以看到,联合索引的非叶子节点用两个字段的值作为 B+Tree 的 key 值。当在联合索引查询数据时,先按 product_no 字段比较,在 product_no 相同的情况下再按 name 字段比较。 也就是说,联合索引查询的 B+Tree 是先按 product_no 进行排序,然后再 product_no 相同的情况再按 name 字段排序。 因此,使用联合索引时,存在**最左匹配原则**,也就是按照最左优先的方式进行索引的匹配。在使用联合索引进行查询的时候,如果不遵循「最左匹配原则」,联合索引会失效,这样就无法利用到索引快速查询的特性了。 比如,如果创建了一个 `(a, b, c)` 联合索引,如果查询条件是以下这几种,就可以匹配上联合索引: - where a=1. - where a=1 and b=2 and c=3. - where a=1 and b=2. - where a=1 and c=3. 需要注意的是,因为有查询优化器,所以 a 字段在 where 子句的顺序并不重要。 但是,如果查询条件是以下这几种,因为不符合最左匹配原则,所以就无法匹配上联合索引,联合索引就会失效: - where b=2. - where c=3. - where b=2 and c=3. 上面这些查询条件之所以会失效,是因为`(a, b, c)` 联合索引,是先按 a 排序,在 a 相同的情况再按 b 排序,在 b 相同的情况再按 c 排序。所以,**b 和 c 是全局无序,局部相对有序的**,这样在没有遵循最左匹配原则的情况下,是无法利用到索引的。 我这里举联合索引(a,b)的例子,该联合索引的 B+ Tree 如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/索引/联合索引案例.drawio.png) 可以看到,a 是全局有序的(1, 2, 2, 3, 4, 5, 6, 7 ,8),而 b 是全局是无序的(12,7,8,2,3,8,10,5,2)。因此,直接执行`where b = 2`这种查询条件没有办法利用联合索引的,**利用索引的前提是索引里的 key 是有序的**。 只有在 a 相同的情况才,b 才是有序的,比如 a 等于 2 的时候,b 的值为(7,8),这时就是有序的,这个有序状态是局部的,因此,执行`where a = 2 and b = 7`是 a 和 b 字段能用到联合索引的,也就是联合索引生效了。 ##### 联合索引范围查询 联合索引有一些特殊情况,**并不是查询过程使用了联合索引查询,就代表联合索引中的所有字段都用到了联合索引进行索引查询**,也就是可能存在部分字段用到联合索引的 B+Tree,部分字段没有用到联合索引的 B+Tree 的情况。 这种特殊情况就发生在范围查询。联合索引的最左匹配原则会一直向右匹配直到遇到「范围查询」就会停止匹配。**也就是范围查询的字段可以用到联合索引,但是在范围查询字段的后面的字段无法用到联合索引**。 范围查询有很多种,那到底是哪些范围查询会导致联合索引的最左匹配原则会停止匹配呢? 接下来,举例几个范围查例子。 > Q1: `select * from t_table where a > 1 and b = 2`,联合索引(a, b)哪一个字段用到了联合索引的 B+Tree? 由于联合索引(二级索引)是先按照 a 字段的值排序的,所以符合 a > 1 条件的二级索引记录肯定是相邻,于是在进行索引扫描的时候,可以定位到符合 a > 1 条件的第一条记录,然后沿着记录所在的链表向后扫描,直到某条记录不符合 a > 1 条件位置。所以 a 字段可以在联合索引的 B+Tree 中进行索引查询。 **但是在符合 a > 1 条件的二级索引记录的范围里,b 字段的值是无序的**。比如前面图的联合索引的 B+ Tree 里,下面这三条记录的 a 字段的值都符合 a > 1 查询条件,而 b 字段的值是无序的: - a 字段值为 5 的记录,该记录的 b 字段值为 8; - a 字段值为 6 的记录,该记录的 b 字段值为 10; - a 字段值为 7 的记录,该记录的 b 字段值为 5; 因此,我们不能根据查询条件 b = 2 来进一步减少需要扫描的记录数量(b 字段无法利用联合索引进行索引查询的意思)。 所以在执行 Q1 这条查询语句的时候,对应的扫描区间是 (2, + ∞),形成该扫描区间的边界条件是 a > 1,与 b = 2 无关。 因此,**Q1 这条查询语句只有 a 字段用到了联合索引进行索引查询,而 b 字段并没有使用到联合索引**。 我们也可以在执行计划中的 key_len 知道这一点,在使用联合索引进行查询的时候,通过 key_len 我们可以知道优化器具体使用了多少个字段的搜索条件来形成扫描区间的边界条件。 举例个例子,a 和 b 都是 int 类型且不为 NULL 的字段,那么 Q1 这条查询语句执行计划如下,可以看到 key_len 为 4 字节(如果字段允许为 NULL,就在字段类型占用的字节数上加 1,也就是 5 字节),说明只有 a 字段用到了联合索引进行索引查询,而且可以看到,即使 b 字段没用到联合索引,key 为 idx_a_b,说明 Q1 查询语句使用了 idx_a_b 联合索引。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/索引/q1.png) 通过 Q1 查询语句我们可以知道,a 字段使用了 > 进行范围查询,联合索引的最左匹配原则在遇到 a 字段的范围查询( >)后就停止匹配了,因此 b 字段并没有使用到联合索引。 > Q2: `select * from t_table where a >= 1 and b = 2`,联合索引(a, b)哪一个字段用到了联合索引的 B+Tree? Q2 和 Q1 的查询语句很像,唯一的区别就是 a 字段的查询条件「大于等于」。 由于联合索引(二级索引)是先按照 a 字段的值排序的,所以符合 >= 1 条件的二级索引记录肯定是相邻,于是在进行索引扫描的时候,可以定位到符合 >= 1 条件的第一条记录,然后沿着记录所在的链表向后扫描,直到某条记录不符合 a>= 1 条件位置。所以 a 字段可以在联合索引的 B+Tree 中进行索引查询。 虽然在符合 a>= 1 条件的二级索引记录的范围里,b 字段的值是「无序」的,**但是对于符合 a = 1 的二级索引记录的范围里,b 字段的值是「有序」的**(因为对于联合索引,是先按照 a 字段的值排序,然后在 a 字段的值相同的情况下,再按照 b 字段的值进行排序)。 于是,在确定需要扫描的二级索引的范围时,当二级索引记录的 a 字段值为 1 时,可以通过 b = 2 条件减少需要扫描的二级索引记录范围(b 字段可以利用联合索引进行索引查询的意思)。也就是说,从符合 a = 1 and b = 2 条件的第一条记录开始扫描,而不需要从第一个 a 字段值为 1 的记录开始扫描。 所以,**Q2 这条查询语句 a 和 b 字段都用到了联合索引进行索引查询**。 我们也可以在执行计划中的 key_len 知道这一点。执行计划如下,可以看到 key_len 为 8 字节,说明优化器使用了 2 个字段的查询条件来形成扫描区间的边界条件,也就是 a 和 b 字段都用到了联合索引进行索引查询。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/索引/q2.png) 通过 Q2 查询语句我们可以知道,虽然 a 字段使用了 >= 进行范围查询,但是联合索引的最左匹配原则并没有在遇到 a 字段的范围查询( >=)后就停止匹配了,b 字段还是可以用到了联合索引的。 > Q3: `SELECT * FROM t_table WHERE a BETWEEN 2 AND 8 AND b = 2`,联合索引(a, b)哪一个字段用到了联合索引的 B+Tree? Q3 查询条件中 `a BETWEEN 2 AND 8` 的意思是查询 a 字段的值在 2 和 8 之间的记录。不同的数据库对 BETWEEN ... AND 处理方式是有差异的。在 MySQL 中,BETWEEN 包含了 value1 和 value2 边界值,类似于 \>= and =<。而有的数据库则不包含 value1 和 value2 边界值(类似于 > and <)。 这里我们只讨论 MySQL。由于 MySQL 的 BETWEEN 包含 value1 和 value2 边界值,所以类似于 Q2 查询语句,因此 **Q3 这条查询语句 a 和 b 字段都用到了联合索引进行索引查询**。 我们也可以在执行计划中的 key_len 知道这一点。执行计划如下,可以看到 key_len 为 8 字节,说明优化器使用了 2 个字段的查询条件来形成扫描区间的边界条件,也就是 a 和 b 字段都用到了联合索引进行索引查询。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/索引/q3.png) 通过 Q3 查询语句我们可以知道,虽然 a 字段使用了 BETWEEN 进行范围查询,但是联合索引的最左匹配原则并没有在遇到 a 字段的范围查询(BETWEEN)后就停止匹配了,b 字段还是可以用到了联合索引的。 > Q4: `SELECT * FROM t_user WHERE name like 'j%' and age = 22`,联合索引(name, age)哪一个字段用到了联合索引的 B+Tree? 由于联合索引(二级索引)是先按照 name 字段的值排序的,所以前缀为‘j’的 name 字段的二级索引记录都是相邻的,于是在进行索引扫描的时候,可以定位到符合前缀为‘j’的 name 字段的第一条记录,然后沿着记录所在的链表向后扫描,直到某条记录的 name 前缀不为‘j’为止。 所以 a 字段可以在联合索引的 B+Tree 中进行索引查询,形成的扫描区间是['j','k')。注意,j 是闭区间。如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/索引/q4-1.drawio.png) 虽然在符合前缀为‘j’的 name 字段的二级索引记录的范围里,age 字段的值是「无序」的,**但是对于符合 name = j 的二级索引记录的范围里,age 字段的值是「有序」的**(因为对于联合索引,是先按照 name 字段的值排序,然后在 name 字段的值相同的情况下,再按照 age 字段的值进行排序)。 于是,在确定需要扫描的二级索引的范围时,当二级索引记录的 name 字段值为‘j’时,可以通过 age = 22 条件减少需要扫描的二级索引记录范围(age 字段可以利用联合索引进行索引查询的意思)。也就是说,从符合 `name = 'j' and age = 22` 条件的第一条记录时开始扫描,而不需要从第一个 name 为 j 的记录开始扫描。如下图的右边: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/索引/q4-2.drawio.png) 所以,**Q4 这条查询语句 a 和 b 字段都用到了联合索引进行索引查询**。 我们也可以在执行计划中的 key_len 知道这一点。本次例子中: - name 字段的类型是 varchar(30) 且不为 NULL,数据库表使用了 utf8mb4 字符集,一个字符集为 utf8mb4 的字符是 4 个字节,因此 name 字段的实际数据最多占用的存储空间长度是 120 字节(30 x 4),然后因为 name 是变长类型的字段,需要再加 2,也就是 name 的 key_len 为 122。 - age 字段的类型是 int 且不为 NULL,key_len 为 4。 Q4 查询语句的执行计划如下,可以看到 key_len 为 126 字节,name 的 key_len 为 122,age 的 key_len 为 4,说明优化器使用了 2 个字段的查询条件来形成扫描区间的边界条件,也就是 name 和 age 字段都用到了联合索引进行索引查询。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/索引/q4-执行计划.png) 通过 Q4 查询语句我们可以知道,虽然 name 字段使用了 like 前缀匹配进行范围查询,但是联合索引的最左匹配原则并没有在遇到 name 字段的范围查询(like 'j%')后就停止匹配了,age 字段还是可以用到了联合索引的。 综上所示,**联合索引的最左匹配原则,在遇到范围查询(如 \>、<)的时候,就会停止匹配,也就是范围查询的字段可以用到联合索引,但是在范围查询字段的后面的字段无法用到联合索引。注意,对于 >=、<=、BETWEEN、like 前缀匹配的范围查询,并不会停止匹配,前面我也用了四个例子说明了**。 ##### 索引下推 现在我们知道,对于联合索引(a, b),在执行 `select * from table where a > 1 and b = 2` 语句的时候,只有 a 字段能用到索引,那在联合索引的 B+Tree 找到第一个满足条件的主键值(ID 为 2)后,还需要判断其他条件是否满足(看 b 是否等于 2),那是在联合索引里判断?还是回主键索引去判断呢? - 在 MySQL 5.6 之前,只能从 ID2(主键值)开始一个个回表,到「主键索引」上找出数据行,再对比 b 字段值。 - 而 MySQL 5.6 引入的**索引下推优化**(index condition pushdown), **可以在联合索引遍历过程中,对联合索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数**。 当你的查询语句的执行计划里,出现了 Extra 为 `Using index condition`,那么说明使用了索引下推的优化。 ##### 索引区分度 另外,建立联合索引时的字段顺序,对索引效率也有很大影响。越靠前的字段被用于索引过滤的概率越高,实际开发工作中**建立联合索引时,要把区分度大的字段排在前面,这样区分度大的字段越有可能被更多的 SQL 使用到**。 区分度就是某个字段 column 不同值的个数「除以」表的总行数,计算公式如下: ![区分度计算公式](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/索引/区分度.png) 比如,性别的区分度就很小,不适合建立索引或不适合排在联合索引列的靠前的位置,而 UUID 这类字段就比较适合做索引或排在联合索引列的靠前的位置。 因为如果索引的区分度很小,假设字段的值分布均匀,那么无论搜索哪个值都可能得到一半的数据。在这些情况下,还不如不要索引,因为 MySQL 还有一个查询优化器,查询优化器发现某个值出现在表的数据行中的百分比(惯用的百分比界线是"30%")很高的时候,它一般会忽略索引,进行全表扫描。 ##### 联合索引进行排序 这里出一个题目,针对针对下面这条 SQL,你怎么通过索引来提高查询效率呢? ```sql select * from order where status = 1 order by create_time asc ``` 有的同学会认为,单独给 status 建立一个索引就可以了。 但是更好的方式给 status 和 create_time 列建立一个联合索引,因为这样可以避免 MySQL 数据库发生文件排序。 因为在查询时,如果只用到 status 的索引,但是这条语句还要对 create_time 排序,这时就要用文件排序 filesort,也就是在 SQL 执行计划中,Extra 列会出现 Using filesort。 所以,要利用索引的有序性,在 status 和 create_time 列建立联合索引,这样根据 status 筛选后的数据就是按照 create_time 排好序的,避免在文件排序,提高了查询效率。 ## 什么时候需要 / 不需要创建索引? 索引最大的好处是提高查询速度,但是索引也是有缺点的,比如: - 需要占用物理空间,数量越大,占用空间越大; - 创建索引和维护索引要耗费时间,这种时间随着数据量的增加而增大; - 会降低表的增删改的效率,因为每次增删改索引,B+ 树为了维护索引有序性,都需要进行动态维护。 所以,索引不是万能钥匙,它也是根据场景来使用的。 #### 什么时候适用索引? - 字段有唯一性限制的,比如商品编码; - 经常用于 `WHERE` 查询条件的字段,这样能够提高整个表的查询速度,如果查询条件不是一个字段,可以建立联合索引。 - 经常用于 `GROUP BY` 和 `ORDER BY` 的字段,这样在查询的时候就不需要再去做一次排序了,因为我们都已经知道了建立索引之后在 B+Tree 中的记录都是排序好的。 #### 什么时候不需要创建索引? - `WHERE` 条件,`GROUP BY`,`ORDER BY` 里用不到的字段,索引的价值是快速定位,如果起不到定位的字段通常是不需要创建索引的,因为索引是会占用物理空间的。 - 字段中存在大量重复数据,不需要创建索引,比如性别字段,只有男女,如果数据库表中,男女的记录分布均匀,那么无论搜索哪个值都可能得到一半的数据。在这些情况下,还不如不要索引,因为 MySQL 还有一个查询优化器,查询优化器发现某个值出现在表的数据行中的百分比很高的时候,它一般会忽略索引,进行全表扫描。 - 表数据太少的时候,不需要创建索引; - 经常更新的字段不用创建索引,比如不要对电商项目的用户余额建立索引,因为索引字段频繁修改,由于要维护 B+Tree 的有序性,那么就需要频繁的重建索引,这个过程是会影响数据库性能的。 ## 有什么优化索引的方法? 这里说一下几种常见优化索引的方法: - 前缀索引优化; - 覆盖索引优化; - 主键索引最好是自增的; - 防止索引失效; ### 前缀索引优化 前缀索引顾名思义就是使用某个字段中字符串的前几个字符建立索引,那我们为什么需要使用前缀来建立索引呢? 使用前缀索引是为了减小索引字段大小,可以增加一个索引页中存储的索引值,有效提高索引的查询速度。在一些大字符串的字段作为索引时,使用前缀索引可以帮助我们减小索引项的大小。 不过,前缀索引有一定的局限性,例如: - order by 就无法使用前缀索引; - 无法把前缀索引用作覆盖索引; ### 覆盖索引优化 覆盖索引是指 SQL 中 query 的所有字段,在索引 B+Tree 的叶子节点上都能找得到的那些索引,从二级索引中查询得到记录,而不需要通过聚簇索引查询获得,可以避免回表的操作。 假设我们只需要查询商品的名称、价格,有什么方式可以避免回表呢? 我们可以建立一个联合索引,即「商品 ID、名称、价格」作为一个联合索引。如果索引中存在这些数据,查询将不会再次检索主键索引,从而避免回表。 所以,使用覆盖索引的好处就是,不需要查询出包含整行记录的所有信息,也就减少了大量的 I/O 操作。 ### 主键索引最好是自增的 我们在建表的时候,都会默认将主键索引设置为自增的,具体为什么要这样做呢?又什么好处? InnoDB 创建主键索引默认为聚簇索引,数据被存放在了 B+Tree 的叶子节点上。也就是说,同一个叶子节点内的各个数据是按主键顺序存放的,因此,每当有一条新的数据插入时,数据库会根据主键将其插入到对应的叶子节点中。 **如果我们使用自增主键**,那么每次插入的新数据就会按顺序添加到当前索引节点的位置,不需要移动已有的数据,当页面写满,就会自动开辟一个新页面。因为每次**插入一条新记录,都是追加操作,不需要重新移动数据**,因此这种插入数据的方法效率非常高。 **如果我们使用非自增主键**,由于每次插入主键的索引值都是随机的,因此每次插入新的数据时,就可能会插入到现有数据页中间的某个位置,这将不得不移动其它数据来满足新数据的插入,甚至需要从一个页面复制数据到另外一个页面,我们通常将这种情况称为**页分裂**。**页分裂还有可能会造成大量的内存碎片,导致索引结构不紧凑,从而影响查询效率**。 举个例子,假设某个数据页中的数据是 1、3、5、9,且数据页满了,现在准备插入一个数据 7,则需要把数据页分割为两个数据页: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/索引/页分裂.png) 出现页分裂时,需要将一个页的记录移动到另外一个页,性能会受到影响,同时页空间的利用率下降,造成存储空间的浪费。 而如果记录是顺序插入的,例如插入数据 11,则只需开辟新的数据页,也就不会发生页分裂: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/索引/开辟新页.png) 因此,在使用 InnoDB 存储引擎时,如果没有特别的业务需求,建议使用自增字段作为主键。 另外,主键字段的长度不要太大,因为**主键字段长度越小,意味着二级索引的叶子节点越小(二级索引的叶子节点存放的数据是主键值),这样二级索引占用的空间也就越小**。 ### 索引最好设置为 NOT NULL 为了更好的利用索引,索引列要设置为 NOT NULL 约束。有两个原因: - 第一原因:索引列存在 NULL 就会导致优化器在做索引选择的时候更加复杂,更加难以优化,因为可为 NULL 的列会使索引、索引统计和值比较都更复杂,比如进行索引统计时,count 会省略值为 NULL 的行。 - 第二个原因:NULL 值是一个没意义的值,但是它会占用物理空间,所以会带来的存储空间的问题,会导致更多的存储空间占用,因为 InnoDB 默认行存储格式`COMPACT`,会用 1 字节空间存储 NULL 值列表,如下图的黄色部分: ![](https://tva1.sinaimg.cn/large/008eGmZEgy1gp6pbo6xd7j30v602u3yq.jpg) ### 防止索引失效 用上了索引并不意味着查询的时候会使用到索引,所以我们心里要清楚有哪些情况会导致索引失效,从而避免写出索引失效的查询语句,否则这样的查询效率是很低的。 我之前写过索引失效的文章,想详细了解的可以去看这篇文章:[谁还没碰过索引失效呢?](https://mp.weixin.qq.com/s/lEx6iRRP3MbwJ82Xwp675w) 这里简单说一下,发生索引失效的情况: - 当我们使用左或者左右模糊匹配的时候,也就是 `like %xx` 或者 `like %xx%`这两种方式都会造成索引失效; - 当我们在查询条件中对索引列做了计算、函数、类型转换操作,这些情况下都会造成索引失效; - 联合索引要能正确使用需要遵循最左匹配原则,也就是按照最左优先的方式进行索引的匹配,否则就会导致索引失效。 - 在 WHERE 子句中,如果在 OR 前的条件列是索引列,而在 OR 后的条件列不是索引列,那么索引会失效。 我上面说的是常见的索引失效场景,实际过程中,可能会出现其他的索引失效场景,这时我们就需要查看执行计划,通过执行计划显示的数据判断查询语句是否使用了索引。 如下图,就是一个没有使用索引,并且是一个全表扫描的查询语句。 ![](https://img-blog.csdnimg.cn/img_convert/798ab1331d1d6dff026e262e788f1a28.png) 对于执行计划,参数有: - possible_keys 字段表示可能用到的索引; - key 字段表示实际用的索引,如果这一项为 NULL,说明没有使用索引; - key_len 表示索引的长度; - rows 表示扫描的数据行数。 - type 表示数据扫描类型,我们需要重点看这个。 type 字段就是描述了找到所需数据时使用的扫描方式是什么,常见扫描类型的**执行效率从低到高的顺序为**: - All(全表扫描); - index(全索引扫描); - range(索引范围扫描); - ref(非唯一索引扫描); - eq_ref(唯一索引扫描); - const(结果只有一条的主键或唯一索引扫描)。 在这些情况里,all 是最坏的情况,因为采用了全表扫描的方式。index 和 all 差不多,只不过 index 对索引表进行全扫描,这样做的好处是不再需要对数据进行排序,但是开销依然很大。所以,要尽量避免全表扫描和全索引扫描。 range 表示采用了索引范围扫描,一般在 where 子句中使用 < 、>、in、between 等关键词,只检索给定范围的行,属于范围查找。**从这一级别开始,索引的作用会越来越明显,因此我们需要尽量让 SQL 查询可以使用到 range 这一级别及以上的 type 访问方式**。 ref 类型表示采用了非唯一索引,或者是唯一索引的非唯一性前缀,返回数据返回可能是多条。因为虽然使用了索引,但该索引列的值并不唯一,有重复。这样即使使用索引快速查找到了第一条数据,仍然不能停止,要进行目标值附近的小范围扫描。但它的好处是它并不需要扫全表,因为索引是有序的,即便有重复值,也是在一个非常小的范围内扫描。 eq_ref 类型是使用主键或唯一索引时产生的访问方式,通常使用在多表联查中。比如,对两张表进行联查,关联条件是两张表的 user_id 相等,且 user_id 是唯一索引,那么使用 EXPLAIN 进行执行计划查看的时候,type 就会显示 eq_ref。 const 类型表示使用了主键或者唯一索引与常量值进行比较,比如 select name from product where id=1。 需要说明的是 const 类型和 eq_ref 都使用了主键或唯一索引,不过这两个类型有所区别,**const 是与常量进行比较,查询效率会更快,而 eq_ref 通常用于多表联查中**。 > 除了关注 type,我们也要关注 extra 显示的结果。 这里说几个重要的参考指标: - Using filesort:当查询语句中包含 order by 操作,而且无法利用索引完成排序操作的时候,这时不得不选择相应的排序算法进行,甚至可能会通过文件排序,效率是很低的,所以要避免这种问题的出现。 - Using temporary:使了用临时表保存中间结果,MySQL 在对查询结果排序时使用临时表,常见于排序 order by 和分组查询 group by。效率低,要避免这种问题的出现。 - Using index:所需数据只需在索引即可全部获得,不须要再到表中取数据,也就是使用了覆盖索引,避免了回表操作,效率不错。 ## 总结 这次主要介绍了索引的原理、分类和使用。我把重点总结在了下面这个表格 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/索引/索引总结.drawio.png) 完! --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: mysql/index/index_issue.md ================================================ # MySQL 使用 like“%x“,索引一定会失效吗? 大家好,我是小林。 昨天发了一篇关于索引失效的文章:[谁还没碰过索引失效呢](http://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247503394&idx=1&sn=6e5b7b2c9bd9002a4b2dfa69273069b3&chksm=f98d8a88cefa039e726f1196ba14210ddbe49b5fcbb6da620778a7497fa25404433ef0b76268&scene=21#wechat_redirect) 我在文末留了一个有点意思的思考题: ![图片](https://img-blog.csdnimg.cn/img_convert/c3e14ca7c5581a84820f7a9d647d4d14.png) 这个思考题其实是出自于,我之前这篇文章「[一条 SQL 语句引发的思考](http://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247495686&idx=2&sn=dfa18870d8cd2f430f893d402b9f4e54&chksm=f98db4accefa3dba680c1b343700ef87d184c45d4d7739bb0263cece3c1b21d0ca5f875736f6&scene=21#wechat_redirect)」中留言区一位读者朋友出的问题。 很多读者都在留言区说了自己的想法,也有不少读者私聊我答案到底是什么? 所以,我今晚就跟大家聊聊这个思考题。 ### 题目一 题目一很简单,相信大家都能分析出答案,我昨天分享的索引失效文章里也提及过。 题目 1 的数据库表如下,id 是主键索引,name 是二级索引,其他字段都是非索引字段。 ![图片](https://img-blog.csdnimg.cn/img_convert/f46694a7f2c91443b616eadf8526c09a.png) 这四条模糊匹配的查询语句,第一条和第二条都会走索引扫描,而且都是选择扫描二级索引(index_name),我贴个第二条查询语句的执行计划结果图: ![图片](https://img-blog.csdnimg.cn/img_convert/febffda129751df080f734c1fc7980f1.png) 而第三和第四条会发生索引失效,执行计划的结果 type= ALL,代表了全表扫描。 ![图片](https://img-blog.csdnimg.cn/img_convert/52952f616b03318e196b6e1207b888ad.png) ### 题目二 题目 2 的数据库表特别之处在于,只有两个字段,一个是主键索引 id,另外一个是二级索引 name。 ![图片](https://img-blog.csdnimg.cn/img_convert/a80a15eb8cd65eec777908282e04be2a.png) 针对题目 2 的数据表,第一条和第二条模糊查询语句也是一样可以走索引扫描,第二条查询语句的执行计划如下,Extra 里的 Using index 说明用上了覆盖索引: ![图片](https://img-blog.csdnimg.cn/img_convert/d250a6ba3068ef41da9039974dad206a.png) 我们来看一下第三条查询语句的执行计划(第四条也是一样的结果): ![图片](https://img-blog.csdnimg.cn/img_convert/948ac3e63c36a93101860e7da11ddc42.png) 从执行计划的结果中,可以看到 key=index_name,也就是说用上了二级索引,而且从 Extra 里的 Using index 说明用上了覆盖索引。 这是为什么呢? 首先,这张表的字段没有「非索引」字段,所以 `select *` 相当于 `select id,name`,然后**这个查询的数据都在二级索引的 B+ 树,因为二级索引的 B+ 树的叶子节点包含「索引值 + 主键值」,所以查二级索引的 B+ 树就能查到全部结果了,这个就是覆盖索引。** 但是执行计划里的 type 是 `index`,这代表着是通过全扫描二级索引的 B+ 树的方式查询到数据的,也就是遍历了整颗索引树。 而第一和第二条查询语句的执行计划中 type 是 `range`,表示对索引列进行范围查询,也就是利用了索引树的有序性的特点,通过查询比较的方式,快速定位到了数据行。 所以,type=range 的查询效率会比 type=index 的高一些。 > 为什么选择全扫描二级索引树,而不扫描聚簇索引树呢? 因为二级索引树的记录东西很少,就只有「索引列 + 主键值」,而聚簇索引记录的东西会更多,比如聚簇索引中的叶子节点则记录了主键值、事务 id、用于事务和 MVCC 的回滚指针以及所有的剩余列。 再加上,这个 select * 不用执行回表操作。 所以,MySQL 优化器认为直接遍历二级索引树要比遍历聚簇索引树的成本要小的多,因此 MySQL 选择了「全扫描二级索引树」的方式查询数据。 > 为什么这个数据表加了非索引字段,执行同样的查询语句后,怎么变成走的是全表扫描呢? 加了其他字段后,`select * from t_user where name like "%xx";` 要查询的数据就不能只在二级索引树里找了,得需要回表操作才能完成查询的工作,再加上是左模糊匹配,无法利用索引树的有序性来快速定位数据,所以得在二级索引树逐一遍历,获取主键值后,再到聚簇索引树检索到对应的数据行,这样实在太累了。 所以,优化器认为上面这样的查询过程的成本实在太高了,所以直接选择全表扫描的方式来查询数据。 ------ 从这个思考题我们知道了,使用左模糊匹配(like "%xx")并不一定会走全表扫描,关键还是看数据表中的字段。 如果数据库表中的字段只有主键 + 二级索引,那么即使使用了左模糊匹配,也不会走全表扫描(type=all),而是走全扫描二级索引树 (type=index)。 再说一个相似,我们都知道联合索引要遵循最左匹配才能走索引,但是如果数据库表中的字段都是索引的话,即使查询过程中,没有遵循最左匹配原则,也是走全扫描二级索引树 (type=index),比如下图: ![图片](https://img-blog.csdnimg.cn/img_convert/35d04bff09bb638727245c7f9aa95b5c.png) 就说到这了,下次见啦 ---- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: mysql/index/index_lose.md ================================================ # 索引失效有哪些? 大家好,我是小林。 在工作中,如果我们想提高一条语句查询速度,通常都会想对字段建立索引。 但是索引并不是万能的。建立了索引,并不意味着任何查询语句都能走索引扫描。 稍不注意,可能你写的查询语句是会导致索引失效,从而走了全表扫描,虽然查询的结果没问题,但是查询的性能大大降低。 今天就来跟大家盘一盘,常见的 6 种会发生索引失效的场景。 **不仅会用实验案例给大家说明,也会清楚每个索引失效的原因**。 发车! ![图片](https://img-blog.csdnimg.cn/img_convert/a9e6a9708a6dbbcc65906d1338d2ae70.png) ## 索引存储结构长什么样? 我们先来看看索引存储结构长什么样?因为只有知道索引的存储结构,才能更好的理解索引失效的问题。 索引的存储结构跟 MySQL 使用哪种存储引擎有关,因为存储引擎就是负责将数据持久化在磁盘中,而不同的存储引擎采用的索引数据结构也会不相同。 MySQL 默认的存储引擎是 InnoDB,它采用 B+Tree 作为索引的数据结构,至于为什么选择 B+ 树作为索引的数据结构,详细的分析可以看我这篇文章:[为什么 MySQL 喜欢 B+ 树?](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247502168&idx=1&sn=ff63afcea1e8835fca3fe7a97e6922b4&scene=21#wechat_redirect) 在创建表时,InnoDB 存储引擎默认会创建一个主键索引,也就是聚簇索引,其它索引都属于二级索引。 MySQL 的 MyISAM 存储引擎支持多种索引数据结构,比如 B+ 树索引、R 树索引、Full-Text 索引。MyISAM 存储引擎在创建表时,创建的主键索引默认使用的是 B+ 树索引。 虽然,InnoDB 和 MyISAM 都支持 B+ 树索引,但是它们数据的存储结构实现方式不同。不同之处在于: - InnoDB 存储引擎:B+ 树索引的叶子节点保存数据本身; - MyISAM 存储引擎:B+ 树索引的叶子节点保存数据的物理地址; 接下来,我举个例子,给大家展示下这两种存储引擎的索引存储结构的区别。 这里有一张 t_user 表,其中 id 字段为主键索引,其他都是普通字段。 ![图片](https://img-blog.csdnimg.cn/img_convert/00730eac527c21a4034f0f9ba0218aba.png) 如果使用的是 MyISAM 存储引擎,B+ 树索引的叶子节点保存数据的物理地址,即用户数据的指针,如下图: ![图片](https://img-blog.csdnimg.cn/img_convert/f287701eba9bf6f32a2d09b013bb451b.png) 如果使用的是 InnoDB 存储引擎,B+ 树索引的叶子节点保存数据本身,如下图所示: ![图片](https://img-blog.csdnimg.cn/img_convert/ef726d7287b854ea4862f7442d8012ec.png) InnoDB 存储引擎根据索引类型不同,分为聚簇索引(上图就是聚簇索引)和二级索引。它们区别在于,聚簇索引的叶子节点存放的是实际数据,所有完整的用户数据都存放在聚簇索引的叶子节点,而二级索引的叶子节点存放的是主键值,而不是实际数据。 如果将 name 字段设置为普通索引,那么这个二级索引长下图这样,叶子节点仅存放主键值。 ![图片](https://img-blog.csdnimg.cn/img_convert/7dd91be9fe584d94a4d71572ae7214d0.png) 知道了 InnoDB 存储引擎的聚簇索引和二级索引的存储结构后,接下来举几个查询语句,说下查询过程是怎么选择用哪个索引类型的。 在我们使用「主键索引」字段作为条件查询的时候,如果要查询的数据都在「聚簇索引」的叶子节点里,那么就会在「聚簇索引」中的 B+ 树检索到对应的叶子节点,然后直接读取要查询的数据。如下面这条语句: ```plain // id 字段为主键索引 select * from t_user where id=1; ``` 在我们使用「二级索引」字段作为条件查询的时候,如果要查询的数据都在「聚簇索引」的叶子节点里,那么需要检索两颗 B+树: - 先在「二级索引」的 B+ 树找到对应的叶子节点,获取主键值; - 然后用上一步获取的主键值,在「聚簇索引」中的 B+ 树检索到对应的叶子节点,然后获取要查询的数据。 上面这个过程叫做**回表**,如下面这条语句: ```plain // name 字段为二级索引 select * from t_user where name="林某"; ``` 在我们使用「二级索引」字段作为条件查询的时候,如果要查询的数据在「二级索引」的叶子节点,那么只需要在「二级索引」的 B+ 树找到对应的叶子节点,然后读取要查询的数据,这个过程叫做**覆盖索引**。如下面这条语句: ```plain // name 字段为二级索引 select id from t_user where name="林某"; ``` 上面这些查询语句的条件都用到了索引列,所以在查询过程都用上了索引。 但是并不意味着,查询条件用上了索引列,就查询过程就一定都用上索引,接下来我们再一起看看哪些情况会导致索引失效,而发生全表扫描。 首先说明下,下面的实验案例,我使用的 MySQL 版本为 `8.0.26`。 ## 对索引使用左或者左右模糊匹配 当我们使用左或者左右模糊匹配的时候,也就是 `like %xx` 或者 `like %xx%` 这两种方式都会造成索引失效。 比如下面的 like 语句,查询 name 后缀为「林」的用户,执行计划中的 type=ALL 就代表了全表扫描,而没有走索引。 ```plain // name 字段为二级索引 select * from t_user where name like '%林'; ``` ![图片](https://img-blog.csdnimg.cn/img_convert/3175fdd8a7d00aa7edeb18bc90e63333.png) 如果是查询 name 前缀为林的用户,那么就会走索引扫描,执行计划中的 type=range 表示走索引扫描,key=index_name 看到实际走了 index_name 索引: ```plain // name 字段为二级索引 select * from t_user where name like '林%'; ``` ![图片](https://img-blog.csdnimg.cn/img_convert/c5c1506ac9fe3fdb98085e7e09c48394.png) > 为什么 like 关键字左或者左右模糊匹配无法走索引呢? **因为索引 B+ 树是按照「索引值」有序排列存储的,只能根据前缀进行比较。** 举个例子,下面这张二级索引图,是以 name 字段有序排列存储的。 ![图片](https://img-blog.csdnimg.cn/img_convert/b161c1d88f978e42077f8c14e10972a7.png) 假设我们要查询 name 字段前缀为「林」的数据,也就是 `name like '林%'`,扫描索引的过程: - 首节点查询比较:林这个字的拼音大小比首节点的第一个索引值中的陈字大,但是比首节点的第二个索引值中的周字小,所以选择去节点 2 继续查询; - 节点 2 查询比较:节点 2 的第一个索引值中的陈字的拼音大小比林字小,所以继续看下一个索引值,发现节点 2 有与林字前缀匹配的索引值,于是就往叶子节点查询,即叶子节点 4; - 节点 4 查询比较:节点 4 的第一个索引值的前缀符合林字,于是就读取该行数据,接着继续往右匹配,直到匹配不到前缀为林的索引值。 如果使用 `name like '%林'` 方式来查询,因为查询的结果可能是「陈林、张林、周林」等之类的,所以不知道从哪个索引值开始比较,于是就只能通过全表扫描的方式来查询。 想要更详细了解 InnoDB 的 B+ 树查询过程,可以看我写的这篇:[B+ 树里的节点里存放的是什么呢?查询数据的过程又是怎样的?](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247502059&idx=1&sn=ccbee22bda8c3d6a98237be769a7c89c&scene=21#wechat_redirect) ## 对索引使用函数 有时候我们会用一些 MySQL 自带的函数来得到我们想要的结果,这时候要注意了,如果查询条件中对索引字段使用函数,就会导致索引失效。 比如下面这条语句查询条件中对 name 字段使用了 LENGTH 函数,执行计划中的 type=ALL,代表了全表扫描: ```plain // name 为二级索引 select * from t_user where length(name)=6; ``` ![图片](https://img-blog.csdnimg.cn/img_convert/2525f7b7190eeb70323579e8d665eb94.png) > 为什么对索引使用函数,就无法走索引了呢? 因为索引保存的是索引字段的原始值,而不是经过函数计算后的值,自然就没办法走索引了。 不过,从 MySQL 8.0 开始,索引特性增加了函数索引,即可以针对函数计算后的值建立一个索引,也就是说该索引的值是函数计算后的值,所以就可以通过扫描索引来查询数据。 举个例子,我通过下面这条语句,对 length(name) 的计算结果建立一个名为 idx_name_length 的索引。 ```plain alter table t_user add key idx_name_length ((length(name))); ``` 然后我再用下面这条查询语句,这时候就会走索引了。 ![图片](https://img-blog.csdnimg.cn/img_convert/56fc9a5a9b35d933f810e5213b3c8acb.png) ## 对索引进行表达式计算 在查询条件中对索引进行表达式计算,也是无法走索引的。 比如,下面这条查询语句,执行计划中 type = ALL,说明是通过全表扫描的方式查询数据的: ```plain explain select * from t_user where id + 1 = 10; ``` ![图片](https://img-blog.csdnimg.cn/img_convert/798ab1331d1d6dff026e262e788f1a28.png) 但是,如果把查询语句的条件改成 where id = 10 - 1,这样就不是在索引字段进行表达式计算了,于是就可以走索引查询了。 ![图片](https://img-blog.csdnimg.cn/img_convert/96c3132ade7b161a5c2a3c051b337402.png) > 为什么对索引进行表达式计算,就无法走索引了呢? 原因跟对索引使用函数差不多。 因为索引保存的是索引字段的原始值,而不是 id + 1 表达式计算后的值,所以无法走索引,只能通过把索引字段的取值都取出来,然后依次进行表达式的计算来进行条件判断,因此采用的就是全表扫描的方式。 有的同学可能会说,这种对索引进行简单的表达式计算,在代码特殊处理下,应该是可以做到索引扫描的,比方将 id + 1 = 10 变成 id = 10 - 1。 是的,是能够实现,但是 MySQL 还是偷了这个懒,没有实现。 我的想法是,可能也是因为,表达式计算的情况多种多样,每种都要考虑的话,代码可能会很臃肿,所以干脆将这种索引失效的场景告诉程序员,让程序员自己保证在查询条件中不要对索引进行表达式计算。 ## 对索引隐式类型转换 如果索引字段是字符串类型,但是在条件查询中,输入的参数是整型的话,你会在执行计划的结果发现这条语句会走全表扫描。 我在原本的 t_user 表增加了 phone 字段,是二级索引且类型是 varchar。 ![图片](https://img-blog.csdnimg.cn/img_convert/e7aa91ce5e699ff0c6c357f6b1e70597.png) 然后我在条件查询中,用整型作为输入参数,此时执行计划中 type = ALL,所以是通过全表扫描来查询数据的。 ```plain select * from t_user where phone = 1300000001; ``` ![图片](https://img-blog.csdnimg.cn/img_convert/7be4dcbe00f15c8c583f20ffd67b772a.png) 但是如果索引字段是整型类型,查询条件中的输入参数即使字符串,是不会导致索引失效,还是可以走索引扫描。 我们再看第二个例子,id 是整型,但是下面这条语句还是走了索引扫描的。 ```plain explain select * from t_user where id = '1'; ``` ![图片](https://img-blog.csdnimg.cn/img_convert/028df5ce4a42a6cf33524b2e1102c15d.png) > 为什么第一个例子会导致索引失效,而第二例子不会呢? 要明白这个原因,首先我们要知道 MySQL 的数据类型转换规则是什么?就是看 MySQL 是会将字符串转成数字处理,还是将数字转换成字符串处理。 我在看《MySQL45 讲》的时候看到一个简单的测试方式,就是通过 select“10” > 9 的结果来知道 MySQL 的数据类型转换规则是什么: - 如果规则是 MySQL 会将自动「字符串」转换成「数字」,就相当于 select 10 > 9,这个就是数字比较,所以结果应该是 1; - 如果规则是 MySQL 会将自动「数字」转换成「字符串」,就相当于 select "10" > "9",这个是字符串比较,字符串比较大小是逐位从高位到低位逐个比较(按 ascii 码) ,那么"10"字符串相当于“1”和“0”字符的组合,所以先是拿“1”字符和“9”字符比较,因为“1”字符比“9”字符小,所以结果应该是 0。 在 MySQL 中,执行的结果如下图: ![图片](https://img-blog.csdnimg.cn/img_convert/25316fd06cadff4434f02391ef0b5c55.png) 上面的结果为 1,说明 **MySQL 在遇到字符串和数字比较的时候,会自动把字符串转为数字,然后再进行比较**。 前面的例子一中的查询语句,我也跟大家说了是会走全表扫描: ```plain //例子一的查询语句 select * from t_user where phone = 1300000001; ``` 这是因为 phone 字段为字符串,所以 MySQL 要会自动把字符串转为数字,所以这条语句相当于: ```plain select * from t_user where CAST(phone AS signed int) = 1300000001; ``` 可以看到,**CAST 函数是作用在了 phone 字段,而 phone 字段是索引,也就是对索引使用了函数!而前面我们也说了,对索引使用函数是会导致索引失效的**。 例子二中的查询语句,我跟大家说了是会走索引扫描: ```plain //例子二的查询语句 select * from t_user where id = "1"; ``` 这时因为字符串部分是输入参数,也就需要将字符串转为数字,所以这条语句相当于: ```plain select * from t_user where id = CAST("1" AS signed int); ``` 可以看到,索引字段并没有用任何函数,CAST 函数是用在了输入参数,因此是可以走索引扫描的。 ## 联合索引非最左匹配 对主键字段建立的索引叫做聚簇索引,对普通字段建立的索引叫做二级索引。 那么**多个普通字段组合在一起创建的索引就叫做联合索引**,也叫组合索引。 创建联合索引时,我们需要注意创建时的顺序问题,因为联合索引 (a, b, c) 和 (c, b, a) 在使用的时候会存在差别。 联合索引要能正确使用需要遵循**最左匹配原则**,也就是按照最左优先的方式进行索引的匹配。 比如,如果创建了一个 `(a, b, c)` 联合索引,如果查询条件是以下这几种,就可以匹配上联合索引: - where a=1. - where a=1 and b=2 and c=3. - where a=1 and b=2. 需要注意的是,因为有查询优化器,所以 a 字段在 where 子句的顺序并不重要。 但是,如果查询条件是以下这几种,因为不符合最左匹配原则,所以就无法匹配上联合索引,联合索引就会失效: - where b=2. - where c=3. - where b=2 and c=3. 有一个比较特殊的查询条件:where a = 1 and c = 3,符合最左匹配吗? 这种其实严格意义上来说是属于索引截断,不同版本处理方式也不一样。 MySQL 5.5 的话,前面 a 会走索引,在联合索引找到主键值后,开始回表,到主键索引读取数据行,然后再比对 c 字段的值。 从 MySQL 5.6 之后,有一个**索引下推功能**,可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。 大概原理是:截断的字段会被下推到存储引擎层进行条件判断(因为 c 字段的值是在 `(a, b, c)` 联合索引里的),然后过滤出符合条件的数据后再返回给 Server 层。由于在引擎层就过滤掉大量的数据,无需再回表读取数据来进行判断,减少回表次数,从而提升了性能。 比如下面这条 where a = 1 and c = 0 语句,我们可以从执行计划中的 Extra=Using index condition 使用了索引下推功能。 ![图片](https://img-blog.csdnimg.cn/img_convert/28f252422b6c5e49cc81c448d08547dc.png) > 为什么联合索引不遵循最左匹配原则就会失效? 原因是,在联合索引的情况下,数据是按照索引第一列排序,第一列数据相同时才会按照第二列排序。 也就是说,如果我们想使用联合索引中尽可能多的列,查询条件中的各个列必须是联合索引中从最左边开始连续的列。如果我们仅仅按照第二列搜索,肯定无法走索引。 ## WHERE 子句中的 OR 在 WHERE 子句中,如果在 OR 前的条件列是索引列,而在 OR 后的条件列不是索引列,那么索引会失效。 举个例子,比如下面的查询语句,id 是主键,age 是普通列,从执行计划的结果看,是走了全表扫描。 ```plain select * from t_user where id = 1 or age = 18; ``` ![图片](https://img-blog.csdnimg.cn/img_convert/550dc58c222cc8d2740985e8d6497821.png) 这是因为 OR 的含义就是两个只要满足一个即可,因此只有一个条件列是索引列是没有意义的,只要有条件列不是索引列,就会进行全表扫描。 要解决办法很简单,将 age 字段设置为索引即可。 ![图片](https://img-blog.csdnimg.cn/img_convert/7f72c4ef72b7b979c844798d7be06916.png) 可以看到 type=index merge,index merge 的意思就是对 id 和 age 分别进行了扫描,然后将这两个结果集进行了合并,这样做的好处就是避免了全表扫描。 ## 总结 今天给大家介绍了 6 种会发生索引失效的情况: - 当我们使用左或者左右模糊匹配的时候,也就是 `like %xx` 或者 `like %xx%`这两种方式都会造成索引失效; - 当我们在查询条件中对索引列使用函数,就会导致索引失效。 - 当我们在查询条件中对索引列进行表达式计算,也是无法走索引的。 - MySQL 在遇到字符串和数字比较的时候,会自动把字符串转为数字,然后再进行比较。如果字符串是索引列,而条件语句中的输入参数是数字的话,那么索引列会发生隐式类型转换,由于隐式类型转换是通过 CAST 函数实现的,等同于对索引列使用了函数,所以就会导致索引失效。 - 联合索引要能正确使用需要遵循最左匹配原则,也就是按照最左优先的方式进行索引的匹配,否则就会导致索引失效。 - 在 WHERE 子句中,如果在 OR 前的条件列是索引列,而在 OR 后的条件列不是索引列,那么索引会失效。 ---- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: mysql/index/page.md ================================================ # 从数据页的角度看 B+ 树 大家好,我是小林。 大家背八股文的时候,都知道 MySQL 里 InnoDB 存储引擎是采用 B+ 树来组织数据的。 这点没错,但是大家知道 B+ 树里的节点里存放的是什么呢?查询数据的过程又是怎样的? 这次,我们**从数据页的角度看 B+ 树**,看看每个节点长啥样。 ![图片](https://img-blog.csdnimg.cn/img_convert/f7696506a3c1c94621fcbad10341f1a8.png) ## InnoDB 是如何存储数据的? MySQL 支持多种存储引擎,不同的存储引擎,存储数据的方式也是不同的,我们最常使用的是 InnoDB 存储引擎,所以就跟大家图解下 InnoDB 是如何存储数据的。 记录是按照行来存储的,但是数据库的读取并不以「行」为单位,否则一次读取(也就是一次 I/O 操作)只能处理一行数据,效率会非常低。 因此,**InnoDB 的数据是按「数据页」为单位来读写的**,也就是说,当需要读一条记录的时候,并不是将这个记录本身从磁盘读出来,而是以页为单位,将其整体读入内存。 数据库的 I/O 操作的最小单位是页,**InnoDB 数据页的默认大小是 16KB**,意味着数据库每次读写都是以 16KB 为单位的,一次最少从磁盘中读取 16K 的内容到内存中,一次最少把内存中的 16K 内容刷新到磁盘中。 数据页包括七个部分,结构如下图: ![图片](https://img-blog.csdnimg.cn/img_convert/243b1466779a9e107ae3ef0155604a17.png) 这 7 个部分的作用如下图: ![图片](https://img-blog.csdnimg.cn/img_convert/fabd6dadd61a0aa342d7107213955a72.png) 在 File Header 中有两个指针,分别指向上一个数据页和下一个数据页,连接起来的页相当于一个双向的链表,如下图所示: ![图片](https://img-blog.csdnimg.cn/img_convert/557d17e05ce90f18591c2305871af665.png) 采用链表的结构是让数据页之间不需要是物理上的连续的,而是逻辑上的连续。 数据页的主要作用是存储记录,也就是数据库的数据,所以重点说一下数据页中的 User Records 是怎么组织数据的。 **数据页中的记录按照「主键」顺序组成单向链表**,单向链表的特点就是插入、删除非常方便,但是检索效率不高,最差的情况下需要遍历链表上的所有节点才能完成检索。 因此,数据页中有一个**页目录**,起到记录的索引作用,就像我们书那样,针对书中内容的每个章节设立了一个目录,想看某个章节的时候,可以查看目录,快速找到对应的章节的页数,而数据页中的页目录就是为了能快速找到记录。 那 InnoDB 是如何给记录创建页目录的呢?页目录与记录的关系如下图: ![图片](https://img-blog.csdnimg.cn/img_convert/261011d237bec993821aa198b97ae8ce.png) 页目录创建的过程如下: 1. 将所有的记录划分成几个组,这些记录包括最小记录和最大记录,但不包括标记为“已删除”的记录; 2. 每个记录组的最后一条记录就是组内最大的那条记录,并且最后一条记录的头信息中会存储该组一共有多少条记录,作为 n_owned 字段(上图中粉红色字段) 3. 页目录用来存储每组最后一条记录的地址偏移量,这些地址偏移量会按照先后顺序存储起来,每组的地址偏移量也被称之为槽(slot),**每个槽相当于指针指向了不同组的最后一个记录**。 从图可以看到,**页目录就是由多个槽组成的,槽相当于分组记录的索引**。然后,因为记录是按照「主键值」从小到大排序的,所以**我们通过槽查找记录时,可以使用二分法快速定位要查询的记录在哪个槽(哪个记录分组),定位到槽后,再遍历槽内的所有记录,找到对应的记录**,无需从最小记录开始遍历整个页中的记录链表。 以上面那张图举个例子,5 个槽的编号分别为 0,1,2,3,4,我想查找主键为 11 的用户记录: - 先二分得出槽中间位是 (0+4)/2=2,2 号槽里最大的记录为 8。因为 11 > 8,所以需要从 2 号槽后继续搜索记录; - 再使用二分搜索出 2 号和 4 号槽的中间位是 (2+4)/2= 3,3 号槽里最大的记录为 12。因为 11 < 12,所以主键为 11 的记录在 3 号槽里; - 这里有个问题,**「槽对应的值都是这个组的主键最大的记录,如何找到组里最小的记录」**?比如槽 3 对应最大主键是 12 的记录,那如何找到最小记录 9。解决办法是:通过槽 3 找到 槽 2 对应的记录,也就是主键为 8 的记录。主键为 8 的记录的下一条记录就是槽 3 当中主键最小的 9 记录,然后开始向下搜索 2 次,定位到主键为 11 的记录,取出该条记录的信息即为我们想要查找的内容。 看到第三步的时候,可能有的同学会疑问,如果某个槽内的记录很多,然后因为记录都是单向链表串起来的,那这样在槽内查找某个记录的时间复杂度不就是 O(n) 了吗? 这点不用担心,InnoDB 对每个分组中的记录条数都是有规定的,槽内的记录就只有几条: - 第一个分组中的记录只能有 1 条记录; - 最后一个分组中的记录条数范围只能在 1-8 条之间; - 剩下的分组中记录条数范围只能在 4-8 条之间。 ## B+ 树是如何进行查询的? 上面我们都是在说一个数据页中的记录检索,因为一个数据页中的记录是有限的,且主键值是有序的,所以通过对所有记录进行分组,然后将组号(槽号)存储到页目录,使其起到索引作用,通过二分查找的方法快速检索到记录在哪个分组,来降低检索的时间复杂度。 但是,当我们需要存储大量的记录时,就需要多个数据页,这时我们就需要考虑如何建立合适的索引,才能方便定位记录所在的页。 为了解决这个问题,**InnoDB 采用了 B+ 树作为索引**。磁盘的 I/O 操作次数对索引的使用效率至关重要,因此在构造索引的时候,我们更倾向于采用“矮胖”的 B+ 树数据结构,这样所需要进行的磁盘 I/O 次数更少,而且 B+ 树 更适合进行关键字的范围查询。 InnoDB 里的 B+ 树中的**每个节点都是一个数据页**,结构示意图如下: ![图片](https://img-blog.csdnimg.cn/img_convert/7c635d682bd3cdc421bb9eea33a5a413.png) 通过上图,我们看出 B+ 树的特点: - 只有叶子节点(最底层的节点)才存放了数据,非叶子节点(其他上层节)仅用来存放目录项作为索引。 - 非叶子节点分为不同层次,通过分层来降低每一层的搜索量; - 所有节点按照索引键大小排序,构成一个双向链表,便于范围查询; 我们再看看 B+ 树如何实现快速查找主键为 6 的记录,以上图为例子: - 从根节点开始,通过二分法快速定位到符合页内范围包含查询值的页,因为查询的主键值为 6,在[1, 7) 范围之间,所以到页 30 中查找更详细的目录项; - 在非叶子节点(页 30)中,继续通过二分法快速定位到符合页内范围包含查询值的页,主键值大于 5,所以就到叶子节点(页 16)查找记录; - 接着,在叶子节点(页 16)中,通过槽查找记录时,使用二分法快速定位要查询的记录在哪个槽(哪个记录分组),定位到槽后,再遍历槽内的所有记录,找到主键为 6 的记录。 可以看到,在定位记录所在哪一个页时,也是通过二分法快速定位到包含该记录的页。定位到该页后,又会在该页内进行二分法快速定位记录所在的分组(槽号),最后在分组内进行遍历查找。 ## 聚簇索引和二级索引 另外,索引又可以分成聚簇索引和非聚簇索引(二级索引),它们区别就在于叶子节点存放的是什么数据: - 聚簇索引的叶子节点存放的是实际数据,所有完整的用户记录都存放在聚簇索引的叶子节点; - 二级索引的叶子节点存放的是主键值,而不是实际数据。 因为表的数据都是存放在聚簇索引的叶子节点里,所以 InnoDB 存储引擎一定会为表创建一个聚簇索引,且由于数据在物理上只会保存一份,所以聚簇索引只能有一个。 InnoDB 在创建聚簇索引时,会根据不同的场景选择不同的列作为索引: - 如果有主键,默认会使用主键作为聚簇索引的索引键; - 如果没有主键,就选择第一个不包含 NULL 值的唯一列作为聚簇索引的索引键; - 在上面两个都没有的情况下,InnoDB 将自动生成一个隐式自增 id 列作为聚簇索引的索引键; 一张表只能有一个聚簇索引,那为了实现非主键字段的快速搜索,就引出了二级索引(非聚簇索引/辅助索引),它也是利用了 B+ 树的数据结构,但是二级索引的叶子节点存放的是主键值,不是实际数据。 二级索引的 B+ 树如下图,数据部分为主键值: ![图片](https://img-blog.csdnimg.cn/img_convert/3104c8c3adf36e8931862fe8a0520f5d.png) 因此,**如果某个查询语句使用了二级索引,但是查询的数据不是主键值,这时在二级索引找到主键值后,需要去聚簇索引中获得数据行,这个过程就叫作「回表」,也就是说要查两个 B+ 树才能查到数据。不过,当查询的数据是主键值时,因为只在二级索引就能查询到,不用再去聚簇索引查,这个过程就叫作「索引覆盖」,也就是只需要查一个 B+ 树就能找到数据。** ## 总结 InnoDB 的数据是按「数据页」为单位来读写的,默认数据页大小为 16 KB。每个数据页之间通过双向链表的形式组织起来,物理上不连续,但是逻辑上连续。 数据页内包含用户记录,每个记录之间用单向链表的方式组织起来,为了加快在数据页内高效查询记录,设计了一个页目录,页目录存储各个槽(分组),且主键值是有序的,于是可以通过二分查找法的方式进行检索从而提高效率。 为了高效查询记录所在的数据页,InnoDB 采用 b+ 树作为索引,每个节点都是一个数据页。 如果叶子节点存储的是实际数据的就是聚簇索引,一个表只能有一个聚簇索引;如果叶子节点存储的不是实际数据,而是主键值则就是二级索引,一个表中可以有多个二级索引。 在使用二级索引进行查找数据时,如果查询的数据能在二级索引找到,那么就是「索引覆盖」操作,如果查询的数据不在二级索引里,就需要先在二级索引找到主键值,需要去聚簇索引中获得数据行,这个过程就叫作「回表」。 关于索引的内容还有很多,比如索引失效、索引优化等等,这些内容我下次在讲啦! ---- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: mysql/index/why_index_chose_bpuls_tree.md ================================================ # 为什么 MySQL 采用 B+ 树作为索引? 大家好,我是小林。 「为什么 MySQL 采用 B+ 树作为索引?」这句话,是不是在面试时经常出现。 要解释这个问题,其实不单单要从数据结构的角度出发,还要考虑磁盘 I/O 操作次数,因为 MySQL 的数据是存储在磁盘中的嘛。 这次,就跟大家一层一层的分析这个问题,图中包含大量的动图来帮助大家理解,相信看完你就拿捏这道题目了! ![图片](https://img-blog.csdnimg.cn/img_convert/3dcb127877c7e77839404275279b136b.png) ## 怎样的索引的数据结构是好的? MySQL 的数据是持久化的,意味着数据(索引 + 记录)是保存到磁盘上的,因为这样即使设备断电了,数据也不会丢失。 磁盘是一个慢的离谱的存储设备,有多离谱呢? 人家内存的访问速度是纳秒级别的,而磁盘访问的速度是毫秒级别的,也就是说读取同样大小的数据,磁盘中读取的速度比从内存中读取的速度要慢上万倍,甚至几十万倍。 磁盘读写的最小单位是**扇区**,扇区的大小只有 `512B` 大小,操作系统一次会读写多个扇区,所以**操作系统的最小读写单位是块(Block)。Linux 中的块大小为 `4KB`**,也就是一次磁盘 I/O 操作会直接读写 8 个扇区。 由于数据库的索引是保存到磁盘上的,因此当我们通过索引查找某行数据的时候,就需要先从磁盘读取索引到内存,再通过索引从磁盘中找到某行数据,然后读入到内存,也就是说查询过程中会发生多次磁盘 I/O,而磁盘 I/O 次数越多,所消耗的时间也就越大。 所以,我们希望索引的数据结构能在尽可能少的磁盘的 I/O 操作中完成查询工作,因为磁盘 I/O 操作越少,所消耗的时间也就越小。 另外,MySQL 是支持范围查找的,所以索引的数据结构不仅要能高效地查询某一个记录,而且也要能高效地执行范围查找。 所以,要设计一个适合 MySQL 索引的数据结构,至少满足以下要求: - 能在尽可能少的磁盘的 I/O 操作中完成查询工作; - 要能高效地查询某一个记录,也要能高效地执行范围查找; 分析完要求后,我们针对每一个数据结构分析一下。 ## 什么是二分查找? 索引数据最好能按顺序排列,这样可以使用「二分查找法」高效定位数据。 假设我们现在用数组来存储索引,比如下面有一个排序的数组,如果要从中找出数字 3,最简单办法就是从头依次遍历查询,这种方法的时间复杂度是 O(n),查询效率并不高。因为该数组是有序的,所以我们可以采用二分查找法,比如下面这张采用二分法的查询过程图: ![图片](https://img-blog.csdnimg.cn/img_convert/f01bb5e7e940231c4f39e7f1cfb449f3.png) 可以看到,二分查找法每次都把查询的范围减半,这样时间复杂度就降到了 O(logn),但是每次查找都需要不断计算中间位置。 ## 什么是二分查找树? 用数组来实现线性排序的数据虽然简单好用,但是插入新元素的时候性能太低。 因为插入一个元素,需要将这个元素之后的所有元素后移一位,如果这个操作发生在磁盘中呢?这必然是灾难性的。因为磁盘的速度比内存慢几十万倍,所以我们不能用一种线性结构将磁盘排序。 其次,有序的数组在使用二分查找的时候,每次查找都要不断计算中间的位置。 那我们能不能设计一个非线形且天然适合二分查找的数据结构呢? 有的,请看下图这个神奇的操作,找到所有二分查找中用到的所有中间节点,把他们用指针连起来,并将最中间的节点作为根节点。 ![请添加图片描述](https://img-blog.csdnimg.cn/49cb4df3fb7d4accbc75f6970ff1d0ea.gif) 怎么样?是不是变成了二叉树,不过它不是普通的二叉树,它是一个**二叉查找树**。 **二叉查找树的特点是一个节点的左子树的所有节点都小于这个节点,右子树的所有节点都大于这个节点**,这样我们在查询数据时,不需要计算中间节点的位置了,只需将查找的数据与节点的数据进行比较。 假设,我们查找索引值为 key 的节点: 1. 如果 key 大于根节点,则在右子树中进行查找; 2. 如果 key 小于根节点,则在左子树中进行查找; 3. 如果 key 等于根节点,也就是找到了这个节点,返回根节点即可。 二叉查找树查找某个节点的动图演示如下,比如要查找节点 3: ![图片](https://img-blog.csdnimg.cn/img_convert/f3089bdfe1a795a8142d4442bdb1e9a7.gif) 另外,二叉查找树解决了插入新节点的问题,因为二叉查找树是一个跳跃结构,不必连续排列。这样在插入的时候,新节点可以放在任何位置,不会像线性结构那样插入一个元素,所有元素都需要向后排列。 下面是二叉查找树插入某个节点的动图演示: ![请添加图片描述](https://img-blog.csdnimg.cn/be13777664584209826f90d5d4128b7f.gif) 因此,二叉查找树解决了连续结构插入新元素开销很大的问题,同时又保持着天然的二分结构。 那是不是二叉查找树就可以作为索引的数据结构了呢? 不行不行,二叉查找树存在一个极端情况,会导致它变成一个瘸子! **当每次插入的元素都是二叉查找树中最大的元素,二叉查找树就会退化成了一条链表,查找数据的时间复杂度变成了 O(n)**,如下动图演示: ![请添加图片描述](https://img-blog.csdnimg.cn/320c5c2653e74f9e9ebfcd8ca9681905.gif) 由于树是存储在磁盘中的,访问每个节点,都对应一次磁盘 I/O 操作(*假设一个节点的大小「小于」操作系统的最小读写单位块的大小*),也就是说**树的高度就等于每次查询数据时磁盘 IO 操作的次数**,所以树的高度越高,就会影响查询性能。 二叉查找树由于存在退化成链表的可能性,会使得查询操作的时间复杂度从 O(logn) 升为 O(n)。 而且会随着插入的元素越多,树的高度也变高,意味着需要磁盘 IO 操作的次数就越多,这样导致查询性能严重下降,再加上不能范围查询,所以不适合作为数据库的索引结构。 ## 什么是自平衡二叉树? 为了解决二叉查找树会在极端情况下退化成链表的问题,后面就有人提出**平衡二叉查找树(AVL 树)**。 主要是在二叉查找树的基础上增加了一些条件约束:**每个节点的左子树和右子树的高度差不能超过 1**。也就是说节点的左子树和右子树仍然为平衡二叉树,这样查询操作的时间复杂度就会一直维持在 O(logn) 。 下图是每次插入的元素都是平衡二叉查找树中最大的元素,可以看到,它会维持自平衡: ![图片](https://img-blog.csdnimg.cn/img_convert/8d5da86f2e41a0b641dbf45b23f7b7dd.gif) 除了平衡二叉查找树,还有很多自平衡的二叉树,比如红黑树,它也是通过一些约束条件来达到自平衡,不过红黑树的约束条件比较复杂,不是本篇的重点重点,大家可以看《数据结构》相关的书籍来了解红黑树的约束条件。 下面是红黑树插入节点的过程,这左旋右旋的操作,就是为了自平衡。 ![图片](https://img-blog.csdnimg.cn/img_convert/b2628d1248e41207a08871f7bfac3522.gif) **不管平衡二叉查找树还是红黑树,都会随着插入的元素增多,而导致树的高度变高,这就意味着磁盘 I/O 操作次数多,会影响整体数据查询的效率**。 比如,下面这个平衡二叉查找树的高度为 5,那么在访问最底部的节点时,就需要磁盘 5 次 I/O 操作。 ![图片](https://img-blog.csdnimg.cn/img_convert/2d26d30c953cd47c6ab637ad0eba2f99.png) 根本原因是因为它们都是二叉树,也就是每个节点只能保存 2 个子节点,如果我们把二叉树改成 M 叉树(M>2)呢? 比如,当 M=3 时,在同样的节点个数情况下,三叉树比二叉树的树高要矮。 ![图片](https://img-blog.csdnimg.cn/img_convert/00fb73de7014a87958f1597345e9ef2f.png) 因此,**当树的节点越多的时候,并且树的分叉数 M 越大的时候,M 叉树的高度会远小于二叉树的高度**。 ## 什么是 B 树 自平衡二叉树虽然能保持查询操作的时间复杂度在 O(logn),但是因为它本质上是一个二叉树,每个节点只能有 2 个子节点,那么当节点个数越多的时候,树的高度也会相应变高,这样就会增加磁盘的 I/O 次数,从而影响数据查询的效率。 为了解决降低树的高度的问题,后面就出来了 B 树,它不再限制一个节点就只能有 2 个子节点,而是允许 M 个子节点 (M>2),从而降低树的高度。 B 树的每一个节点最多可以包括 M 个子节点,M 称为 B 树的阶,所以 B 树就是一个多叉树。 假设 M = 3,那么就是一棵 3 阶的 B 树,特点就是每个节点最多有 2 个(M-1 个)数据和最多有 3 个(M 个)子节点,超过这些要求的话,就会分裂节点,比如下面的的动图: ![图片](https://img-blog.csdnimg.cn/img_convert/9a96956de3be0614f7ec2344741b4dcc.gif) 我们来看看一棵 3 阶的 B 树的查询过程是怎样的? ![图片](https://img-blog.csdnimg.cn/img_convert/341cedca6863fcd0624febd835165ae2.gif) 假设我们在上图一棵 3 阶的 B 树中要查找的索引值是 9 的记录那么步骤可以分为以下几步: 1. 与根节点的索引 (4,8)进行比较,9 大于 8,那么往右边的子节点走; 2. 然后该子节点的索引为(10,12),因为 9 小于 10,所以会往该节点的左边子节点走; 3. 走到索引为 9 的节点,然后我们找到了索引值 9 的节点。 可以看到,一棵 3 阶的 B 树在查询叶子节点中的数据时,由于树的高度是 3,所以在查询过程中会发生 3 次磁盘 I/O 操作。 而如果同样的节点数量在平衡二叉树的场景下,树的高度就会很高,意味着磁盘 I/O 操作会更多。所以,B 树在数据查询中比平衡二叉树效率要高。 但是 B 树的每个节点都包含数据(索引 + 记录),而用户的记录数据的大小很有可能远远超过了索引数据,这就需要花费更多的磁盘 I/O 操作次数来读到「有用的索引数据」。 而且,在我们查询位于底层的某个节点(比如 A 记录)过程中,「非 A 记录节点」里的记录数据会从磁盘加载到内存,但是这些记录数据是没用的,我们只是想读取这些节点的索引数据来做比较查询,而「非 A 记录节点」里的记录数据对我们是没用的,这样不仅增多磁盘 I/O 操作次数,也占用内存资源。 另外,如果使用 B 树来做范围查询的话,需要使用中序遍历,这会涉及多个节点的磁盘 I/O 问题,从而导致整体速度下降。 ## 什么是 B+ 树? B+ 树就是对 B 树做了一个升级,MySQL 中索引的数据结构就是采用了 B+ 树,B+ 树结构如下图: ![图片](https://img-blog.csdnimg.cn/img_convert/b6678c667053a356f46fc5691d2f5878.png) B+ 树与 B 树差异的点,主要是以下这几点: - 叶子节点(最底部的节点)才会存放实际数据(索引 + 记录),非叶子节点只会存放索引; - 所有索引都会在叶子节点出现,叶子节点之间构成一个有序链表; - 非叶子节点的索引也会同时存在在子节点中,并且是在子节点中所有索引的最大(或最小)。 - 非叶子节点中有多少个子节点,就有多少个索引; 下面通过三个方面,比较下 B+ 和 B 树的性能区别。 ### 1、单点查询 B 树进行单个索引查询时,最快可以在 O(1) 的时间代价内就查到,而从平均时间代价来看,会比 B+ 树稍快一些。 但是 B 树的查询波动会比较大,因为每个节点既存索引又存记录,所以有时候访问到了非叶子节点就可以找到索引,而有时需要访问到叶子节点才能找到索引。 **B+ 树的非叶子节点不存放实际的记录数据,仅存放索引,因此数据量相同的情况下,相比既存索引又存记录的 B 树,B+树的非叶子节点可以存放更多的索引,因此 B+ 树可以比 B 树更「矮胖」,查询底层节点的磁盘 I/O 次数会更少**。 ### 2、插入和删除效率 B+ 树有大量的冗余节点,这样使得删除一个节点的时候,可以直接从叶子节点中删除,甚至可以不动非叶子节点,这样删除非常快, 比如下面这个动图是删除 B+ 树 0004 节点的过程,因为非叶子节点有 0004 的冗余节点,所以在删除的时候,树形结构变化很小: ![请添加图片描述](https://img-blog.csdnimg.cn/25508b0cd9c44ef6937fdd737020a7f1.gif) > 注意,:B+ 树对于非叶子节点的子节点和索引的个数,定义方式可能会有不同,有的是说非叶子节点的子节点的个数为 M 阶,而索引的个数为 M-1(这个是维基百科里的定义),因此我本文关于 B+ 树的动图都是基于这个。但是我在前面介绍 B+ 树与 B+ 树的差异时,说的是「非叶子节点中有多少个子节点,就有多少个索引」,主要是 MySQL 用到的 B+ 树就是这个特性。 下面这个动图是删除 B 树 0008 节点的过程,可能会导致树的复杂变化: ![请添加图片描述](https://img-blog.csdnimg.cn/2be62679487640bbaac663fa96c7f35f.gif) 甚至,B+ 树在删除根节点的时候,由于存在冗余的节点,所以不会发生复杂的树的变形,比如下面这个动图是删除 B+ 树根节点的过程: ![请添加图片描述](https://img-blog.csdnimg.cn/23730b5af987480fabff0f1d142a2b6c.gif) B 树则不同,B 树没有冗余节点,删除节点的时候非常复杂,比如删除根节点中的数据,可能涉及复杂的树的变形,比如下面这个动图是删除 B 树根节点的过程: ![图片](https://img-blog.csdnimg.cn/img_convert/7552002f9b8195ab650d431bfe66cce2.gif) B+ 树的插入也是一样,有冗余节点,插入可能存在节点的分裂(如果节点饱和),但是最多只涉及树的一条路径。而且 B+ 树会自动平衡,不需要像更多复杂的算法,类似红黑树的旋转操作等。 因此,**B+ 树的插入和删除效率更高**。 ### 3、范围查询 B 树和 B+ 树等值查询原理基本一致,先从根节点查找,然后对比目标数据的范围,最后递归的进入子节点查找。 因为 **B+ 树所有叶子节点间还有一个链表进行连接,这种设计对范围查找非常有帮助**,比如说我们想知道 12 月 1 日和 12 月 12 日之间的订单,这个时候可以先查找到 12 月 1 日所在的叶子节点,然后利用链表向右遍历,直到找到 12 月 12 日的节点,这样就不需要从根节点查询了,进一步节省查询需要的时间。 而 B 树没有将所有叶子节点用链表串联起来的结构,因此只能通过树的遍历来完成范围查询,这会涉及多个节点的磁盘 I/O 操作,范围查询效率不如 B+ 树。 因此,存在大量范围检索的场景,适合使用 B+树,比如数据库。而对于大量的单个索引查询的场景,可以考虑 B 树,比如 nosql 的 MongoDB。 ### MySQL 中的 B+ 树 MySQL 的存储方式根据存储引擎的不同而不同,我们最常用的就是 Innodb 存储引擎,它就是采用了 B+ 树作为了索引的数据结构。 下图就是 Innodb 里的 B+ 树: ![图片](https://img-blog.csdnimg.cn/img_convert/dd076212a7637b9032c97a615c39dcd7.png) 但是 Innodb 使用的 B+ 树有一些特别的点,比如: - B+ 树的叶子节点之间是用「双向链表」进行连接,这样的好处是既能向右遍历,也能向左遍历。 - B+ 树点节点内容是数据页,数据页里存放了用户的记录以及各种信息,每个数据页默认大小是 16 KB。 Innodb 根据索引类型不同,分为聚簇和二级索引。他们区别在于,聚簇索引的叶子节点存放的是实际数据,所有完整的用户记录都存放在聚簇索引的叶子节点,而二级索引的叶子节点存放的是主键值,而不是实际数据。 因为表的数据都是存放在聚簇索引的叶子节点里,所以 InnoDB 存储引擎一定会为表创建一个聚簇索引,且由于数据在物理上只会保存一份,所以聚簇索引只能有一个,而二级索引可以创建多个。 更多关于 Innodb 的 B+ 树,可以看我之前写的这篇:[从数据页的角度看 B+ 树](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247502059&idx=1&sn=ccbee22bda8c3d6a98237be769a7c89c&scene=21#wechat_redirect)。 ## 总结 MySQL 是会将数据持久化在硬盘,而存储功能是由 MySQL 存储引擎实现的,所以讨论 MySQL 使用哪种数据结构作为索引,实际上是在讨论存储引擎使用哪种数据结构作为索引,InnoDB 是 MySQL 默认的存储引擎,它就是采用了 B+ 树作为索引的数据结构。 要设计一个 MySQL 的索引数据结构,不仅仅考虑数据结构增删改的时间复杂度,更重要的是要考虑磁盘 I/0 的操作次数。因为索引和记录都是存放在硬盘,硬盘是一个非常慢的存储设备,我们在查询数据的时候,最好能在尽可能少的磁盘 I/0 的操作次数内完成。 二分查找树虽然是一个天然的二分结构,能很好的利用二分查找快速定位数据,但是它存在一种极端的情况,每当插入的元素都是树内最大的元素,就会导致二分查找树退化成一个链表,此时查询复杂度就会从 O(logn) 降低为 O(n)。 为了解决二分查找树退化成链表的问题,就出现了自平衡二叉树,保证了查询操作的时间复杂度就会一直维持在 O(logn) 。但是它本质上还是一个二叉树,每个节点只能有 2 个子节点,随着元素的增多,树的高度会越来越高。 而树的高度决定于磁盘 I/O 操作的次数,因为树是存储在磁盘中的,访问每个节点,都对应一次磁盘 I/O 操作,也就是说树的高度就等于每次查询数据时磁盘 IO 操作的次数,所以树的高度越高,就会影响查询性能。 B 树和 B+ 都是通过多叉树的方式,会将树的高度变矮,所以这两个数据结构非常适合检索存于磁盘中的数据。 但是 MySQL 默认的存储引擎 InnoDB 采用的是 B+ 作为索引的数据结构,原因有: - B+ 树的非叶子节点不存放实际的记录数据,仅存放索引,因此数据量相同的情况下,相比既存索引又存记录的 B 树,B+树的非叶子节点可以存放更多的索引,因此 B+ 树可以比 B 树更「矮胖」,查询底层节点的磁盘 I/O 次数会更少。 - B+ 树有大量的冗余节点(所有非叶子节点都是冗余索引),这些冗余索引让 B+ 树在插入、删除的效率都更高,比如删除根节点的时候,不会像 B 树那样会发生复杂的树的变化; - B+ 树叶子节点之间用链表连接了起来,有利于范围查询,而 B 树要实现范围查询,因此只能通过树的遍历来完成范围查询,这会涉及多个节点的磁盘 I/O 操作,范围查询效率不如 B+ 树。 完! ---- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: mysql/lock/deadlock.md ================================================ # MySQL 死锁了,怎么办? 大家好,我是小林。 说个很早之前自己遇到过数据库死锁问题。 有个业务主要逻辑就是新增订单、修改订单、查询订单等操作。然后因为订单是不能重复的,所以当时在新增订单的时候做了幂等性校验,做法就是在新增订单记录之前,先通过 `select ... for update` 语句查询订单是否存在,如果不存在才插入订单记录。 而正是因为这样的操作,当业务量很大的时候,就可能会出现死锁。 接下来跟大家聊下**为什么会发生死锁,以及怎么避免死锁**。 ## 死锁的发生 本次案例使用存储引擎 Innodb,隔离级别为可重复读(RR)。 接下来,我用实战的方式来带大家看看死锁是怎么发生的。 我建了一张订单表,其中 id 字段为主键索引,order_no 字段普通索引,也就是非唯一索引: ```sql CREATE TABLE `t_order` ( `id` int NOT NULL AUTO_INCREMENT, `order_no` int DEFAULT NULL, `create_date` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `index_order` (`order_no`) USING BTREE ) ENGINE=InnoDB ; ``` 然后,先 `t_order` 表里现在已经有了 6 条记录: ![图片](https://img-blog.csdnimg.cn/img_convert/54fc00f9f87a60ab7b5ba92d824a892d.png) 假设这时有两事务,一个事务要插入订单 1007,另外一个事务要插入订单 1008,因为需要对订单做幂等性校验,所以两个事务先要查询该订单是否存在,不存在才插入记录,过程如下: ![](https://img-blog.csdnimg.cn/img_convert/90c1e01d0345de639e3426cea0390e80.png) 可以看到,两个事务都陷入了等待状态(前提没有打开死锁检测),也就是发生了死锁,因为都在相互等待对方释放锁。 这里在查询记录是否存在的时候,使用了 `select ... for update` 语句,目的为了防止事务执行的过程中,有其他事务插入了记录,而出现幻读的问题。 如果没有使用 `select ... for update` 语句,而使用了单纯的 select 语句,如果是两个订单号一样的请求同时进来,就会出现两个重复的订单,有可能出现幻读,如下图: ![](https://img-blog.csdnimg.cn/img_convert/8ae18f10f1a89aac5e93f0e9794e469e.png) ## 为什么会产生死锁? 可重复读隔离级别下,是存在幻读的问题。 **Innodb 引擎为了解决「可重复读」隔离级别下的幻读问题,就引出了 next-key 锁**,它是记录锁和间隙锁的组合。 - Record Lock,记录锁,锁的是记录本身; - Gap Lock,间隙锁,锁的就是两个值之间的空隙,以防止其他事务在这个空隙间插入新的数据,从而避免幻读现象。 普通的 select 语句是不会对记录加锁的,因为它是通过 MVCC 的机制实现的快照读,如果要在查询时对记录加行锁,可以使用下面这两个方式: ```sql begin; //对读取的记录加共享锁 select ... lock in share mode; commit; //锁释放 begin; //对读取的记录加排他锁 select ... for update; commit; //锁释放 ``` 行锁的释放时机是在事务提交(commit)后,锁就会被释放,并不是一条语句执行完就释放行锁。 比如,下面事务 A 查询语句会锁住 `(2, +∞]` 范围的记录,然后期间如果有其他事务在这个锁住的范围插入数据就会被阻塞。 ![图片](https://img-blog.csdnimg.cn/img_convert/8d1dfbab758fe7e4c58563fca9ccb6d4.png) next-key 锁的加锁规则其实挺复杂的,在一些场景下会退化成记录锁或间隙锁,我之前也写一篇加锁规则,详细可以看这篇:[MySQL 是怎么加锁的?](https://xiaolincoding.com/mysql/lock/how_to_lock.html) 需要注意的是,如果 update 语句的 where 条件没有用到索引列,那么就会全表扫描,在一行行扫描的过程中,不仅给行记录加上了行锁,还给行记录两边的空隙也加上了间隙锁,相当于锁住整个表,然后直到事务结束才会释放锁。 所以在线上千万不要执行没有带索引条件的 update 语句,不然会造成业务停滞,我有个读者就因为干了这个事情,然后被老板教育了一波,详细可以看这篇:[update 没加索引会锁全表?](https://xiaolincoding.com/mysql/lock/update_index.html) 回到前面死锁的例子。 ![](https://img-blog.csdnimg.cn/img_convert/90c1e01d0345de639e3426cea0390e80.png) 事务 A 在执行下面这条语句的时候: ```sql select id from t_order where order_no = 1007 for update; ``` 我们可以通过 `select * from performance_schema.data_locks\G;` 这条语句,查看事务执行 SQL 过程中加了什么锁。 ![](https://img-blog.csdnimg.cn/1cf8614eba3b45b9874dc6204b4d0cd1.png) 从上图可以看到,共加了两个锁,分别是: - 表锁:X 类型的意向锁; - 行锁:X 类型的 next-key 锁; 这里我们重点关注行锁,图中 LOCK_TYPE 中的 RECORD 表示行级锁,而不是记录锁的意思,通过 LOCK_MODE 可以确认是 next-key 锁,还是间隙锁,还是记录锁: - 如果 LOCK_MODE 为 `X`,说明是 X 型的 next-key 锁; - 如果 LOCK_MODE 为 `X, REC_NOT_GAP`,说明是 X 型的记录锁; - 如果 LOCK_MODE 为 `X, GAP`,说明是 X 型的间隙锁; **因此,此时事务 A 在二级索引(INDEX_NAME : index_order)上加的是 X 型的 next-key 锁,锁范围是`(1006, +∞]`**。 next-key 锁的范围 (1006, +∞],是怎么确定的? 根据我的经验,如果 LOCK_MODE 是 next-key 锁或者间隙锁,那么 LOCK_DATA 就表示锁的范围最右值,此次的事务 A 的 LOCK_DATA 是 supremum pseudo-record,表示的是 +∞。然后锁范围的最左值是 t_order 表中最后一个记录的 index_order 的值,也就是 1006。因此,next-key 锁的范围 (1006, +∞]。 ::: tip 有的读者问,[MySQL 是怎么加锁的?](https://xiaolincoding.com/mysql/lock/how_to_lock.html)这篇文章讲非唯一索引等值查询时,说「当查询的记录不存在时,加 next-key lock,然后会退化为间隙锁」。为什么上面事务 A 的 next-key lock 并没有退化为间隙锁? 如果表中最后一个记录的 order_no 为 1005,那么等值查询 order_no = 1006(不存在),就是 next key lock,如上面事务 A 的情况。 如果表中最后一个记录的 order_no 为 1010,那么等值查询 order_no = 1006(不存在),就是间隙锁,比如下图: ![](https://img-blog.csdnimg.cn/fb6709207ac445ddbc175e3cdf993ff2.png) ::: 当事务 B 往事务 A next-key 锁的范围 (1006, +∞] 里插入 id = 1008 的记录就会被锁住: ```sql Insert into t_order (order_no, create_date) values (1008, now()); ``` 因为当我们执行以下插入语句时,会在插入间隙上获取插入意向锁,**而插入意向锁与间隙锁是冲突的,所以当其它事务持有该间隙的间隙锁时,需要等待其它事务释放间隙锁之后,才能获取到插入意向锁。而间隙锁与间隙锁之间是兼容的,所以所以两个事务中 `select ... for update` 语句并不会相互影响**。 案例中的事务 A 和事务 B 在执行完后 `select ... for update` 语句后都持有范围为`(1006,+∞]`的 next-key 锁,而接下来的插入操作为了获取到插入意向锁,都在等待对方事务的间隙锁释放,于是就造成了循环等待,导致死锁。 > 为什么间隙锁与间隙锁之间是兼容的? 在 MySQL 官网上还有一段非常关键的描述: *Gap locks in InnoDB are “purely inhibitive”, which means that their only purpose is to prevent other transactions from Inserting to the gap. Gap locks can co-exist. A gap lock taken by one transaction does not prevent another transaction from taking a gap lock on the same gap. There is no difference between shared and exclusive gap locks. They do not conflict with each other, and they perform the same function.* **间隙锁的意义只在于阻止区间被插入**,因此是可以共存的。**一个事务获取的间隙锁不会阻止另一个事务获取同一个间隙范围的间隙锁**,共享和排他的间隙锁是没有区别的,他们相互不冲突,且功能相同,即两个事务可以同时持有包含共同间隙的间隙锁。 这里的共同间隙包括两种场景: - 其一是两个间隙锁的间隙区间完全一样; - 其二是一个间隙锁包含的间隙区间是另一个间隙锁包含间隙区间的子集。 但是有一点要注意,**next-key lock 是包含间隙锁 + 记录锁的,如果一个事务获取了 X 型的 next-key lock,那么另外一个事务在获取相同范围的 X 型的 next-key lock 时,是会被阻塞的**。 比如,一个事务持有了范围为 (1, 10] 的 X 型的 next-key lock,那么另外一个事务在获取相同范围的 X 型的 next-key lock 时,就会被阻塞。 虽然相同范围的间隙锁是多个事务相互兼容的,但对于记录锁,我们是要考虑 X 型与 S 型关系。X 型的记录锁与 X 型的记录锁是冲突的,比如一个事务执行了 select ... where id = 1 for update,后一个事务在执行这条语句的时候,就会被阻塞的。 但是还要注意!对于这种范围为 (1006, +∞] 的 next-key lock,两个事务是可以同时持有的,不会冲突。因为 +∞ 并不是一个真实的记录,自然就不需要考虑 X 型与 S 型关系。 > 插入意向锁是什么? 注意!插入意向锁名字虽然有意向锁,但是它并不是意向锁,它是一种特殊的间隙锁。 在 MySQL 的官方文档中有以下重要描述: *An Insert intention lock is a type of gap lock set by Insert operations prior to row Insertion. This lock signals the intent to Insert in such a way that multiple transactions Inserting into the same index gap need not wait for each other if they are not Inserting at the same position within the gap. Suppose that there are index records with values of 4 and 7. Separate transactions that attempt to Insert values of 5 and 6, respectively, each lock the gap between 4 and 7 with Insert intention locks prior to obtaining the exclusive lock on the Inserted row, but do not block each other because the rows are nonconflicting.* 这段话表明尽管**插入意向锁是一种特殊的间隙锁,但不同于间隙锁的是,该锁只用于并发插入操作**。 如果说间隙锁锁住的是一个区间,那么「插入意向锁」锁住的就是一个点。因而从这个角度来说,插入意向锁确实是一种特殊的间隙锁。 插入意向锁与间隙锁的另一个非常重要的差别是:尽管「插入意向锁」也属于间隙锁,但两个事务却不能在同一时间内,一个拥有间隙锁,另一个拥有该间隙区间内的插入意向锁(当然,插入意向锁如果不在间隙锁区间内则是可以的)。 另外,我补充一点,插入意向锁的生成时机: - 每插入一条新记录,都需要看一下待插入记录的下一条记录上是否已经被加了间隙锁,如果已加间隙锁,此时会生成一个插入意向锁,然后锁的状态设置为等待状态(*PS:MySQL 加锁时,是先生成锁结构,然后设置锁的状态,如果锁状态是等待状态,并不是意味着事务成功获取到了锁,只有当锁状态为正常状态时,才代表事务成功获取到了锁*),现象就是 Insert 语句会被阻塞。 ## Insert 语句是怎么加行级锁的? Insert 语句在正常执行时是不会生成锁结构的,它是靠聚簇索引记录自带的 trx_id 隐藏列来作为**隐式锁**来保护记录的。 > 什么是隐式锁? 当事务需要加锁的时,如果这个锁不可能发生冲突,InnoDB 会跳过加锁环节,这种机制称为隐式锁。隐式锁是 InnoDB 实现的一种延迟加锁机制,其特点是只有在可能发生冲突时才加锁,从而减少了锁的数量,提高了系统整体性能。 隐式锁就是在 Insert 过程中不加锁,只有在特殊情况下,才会将隐式锁转换为显式锁,这里我们列举两个场景。 - 如果记录之间加有间隙锁,为了避免幻读,此时是不能插入记录的; - 如果 Insert 的记录和已有记录存在唯一键冲突,此时也不能插入记录; ### 1、记录之间加有间隙锁 每插入一条新记录,都需要看一下待插入记录的下一条记录上是否已经被加了间隙锁,如果已加间隙锁,此时会生成一个插入意向锁,然后锁的状态设置为等待状态(*PS:MySQL 加锁时,是先生成锁结构,然后设置锁的状态,如果锁状态是等待状态,并不是意味着事务成功获取到了锁,只有当锁状态为正常状态时,才代表事务成功获取到了锁*),现象就是 Insert 语句会被阻塞。 举个例子,现在 t_order 表中,只有这些数据,**order_no 是二级索引**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/5条数据.png) 现在,事务 A 执行了下面这条语句。 ```sql # 事务 A mysql> begin; Query OK, 0 rows affected (0.01 sec) mysql> select * from t_order where order_no = 1006 for update; Empty set (0.01 sec) ``` 接着,我们执行 `select * from performance_schema.data_locks\G;` 语句,确定事务 A 加了什么类型的锁,这里只关注在记录上加锁的类型。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/事务A间隙锁.png) 本次的例子加的是 next-key 锁(记录锁 + 间隙锁),锁范围是`(1005, +∞]`。 然后,有个事务 B 在这个间隙锁中,插入了一个记录,那么此时该事务 B 就会被阻塞: ```sql # 事务 B 插入一条记录 mysql> begin; Query OK, 0 rows affected (0.01 sec) mysql> insert into t_order(order_no, create_date) values(1010,now()); ### 阻塞状态。。。。 ``` 接着,我们执行 `select * from performance_schema.data_locks\G;` 语句,确定事务 B 加了什么类型的锁,这里只关注在记录上加锁的类型。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/事务b插入意向锁.png) 可以看到,事务 B 的状态为等待状态(LOCK_STATUS: WAITING),因为向事务 A 生成的 next-key 锁(记录锁 + 间隙锁)范围`(1005, +∞]` 中插入了一条记录,所以事务 B 的插入操作生成了一个插入意向锁(`LOCK_MODE: X,INSERT_INTENTION`),锁的状态是等待状态,意味着事务 B 并没有成功获取到插入意向锁,因此事务 B 发生阻塞。 ### 2、遇到唯一键冲突 如果在插入新记录时,插入了一个与「已有的记录的主键或者唯一二级索引列值相同」的记录(不过可以有多条记录的唯一二级索引列的值同时为 NULL,这里不考虑这种情况),此时插入就会失败,然后对于这条记录加上了 **S 型的锁**。 至于是行级锁的类型是记录锁,还是 next-key 锁,跟是「主键冲突」还是「唯一二级索引冲突」有关系。 如果主键索引重复: - 当隔离级别为**读已提交**时,插入新记录的事务会给已存在的主键值重复的聚簇索引记录**添加 S 型记录锁**。 - 当隔离级别是**可重复读**(默认隔离级别),插入新记录的事务会给已存在的主键值重复的聚簇索引记录**添加 S 型记录锁**。 如果唯一二级索引列重复: - **不论是哪个隔离级别**,插入新记录的事务都会给已存在的二级索引列值重复的二级索引记录**添加 S 型 next-key 锁**。对的,没错,即使是读已提交隔离级别也是加 next-key 锁,这是读已提交隔离级别中为数不多的给记录添加间隙锁的场景。至于为什么要加 next-key 锁,我也没找到合理的解释。 #### 主键索引冲突 下面举个「主键冲突」的例子,MySQL 8.0 版本,事务隔离级别为可重复读(默认隔离级别)。 t_order 表中的 id 字段为主键索引,并且已经存在 id 值为 5 的记录,此时有个事务,插入了一条 id 为 5 的记录,就会报主键索引冲突的错误。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/主键冲突.png) 但是除了报错之外,还做一个很重要的事情,就是对 id 为 5 的这条记录加上了 **S 型的记录锁**。 可以执行 `select * from performance_schema.data_locks\G;` 语句,确定事务加了什么锁。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/主键冲突锁.png) 可以看到,主键索引为 5(LOCK_DATA)的这条记录中加了锁类型为 S 型的记录锁。注意,这里 LOCK_TYPE 中的 RECORD 表示行级锁,而不是记录锁的意思。如果是 S 型记录锁的话,LOCK_MODE 会显示 `S, REC_NOT_GAP`。 所以,在隔离级别是「可重复读」的情况下,如果在插入数据的时候,发生了主键索引冲突,插入新记录的事务会给已存在的主键值重复的聚簇索引记录**添加 S 型记录锁**。 #### 唯一二级索引冲突 下面举个「唯一二级索引冲突」的例子,MySQL 8.0 版本,事务隔离级别为可重复读(默认隔离级别)。 t_order 表中的 order_no 字段为唯一二级索引,并且已经存在 order_no 值为 1001 的记录,此时事务 A,插入了 order_no 为 1001 的记录,就出现了报错。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/插入失败.png) 但是除了报错之外,还做一个很重要的事情,就是对 order_no 值为 1001 这条记录加上了 **S 型的 next-key 锁**。 我们可以执行 `select * from performance_schema.data_locks\G;` 语句,确定事务加了什么类型的锁,这里只关注在记录上加锁的类型。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/s类型锁.png) 可以看到,**index_order 二级索引加了 S 型的 next-key 锁,范围是 (-∞, 1001]**。注意,这里 LOCK_TYPE 中的 RECORD 表示行级锁,而不是记录锁的意思。如果是记录锁的话,LOCK_MODE 会显示 `S, REC_NOT_GAP`。 此时,事务 B 执行了 select * from t_order where order_no = 1001 for update; 就会阻塞,因为这条语句想加 X 型的锁,是与 S 型的锁是冲突的,所以就会被阻塞。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/唯一索引冲突.drawio.png) 我们也可以从 performance_schema.data_locks 这个表中看到,事务 B 的状态(LOCK_STATUS)是等待状态,加锁的类型 X 型的记录锁(LOCK_MODE: X,REC_NOT_GAP)。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/事务b等待状态.png) 上面的案例是针对唯一二级索引重复而插入失败的场景。 > 接下来,分析两个事务执行过程中,执行了相同的 insert 语句的场景。 现在 t_order 表中,只有这些数据,**order_no 为唯一二级索引**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/5条数据.png) 在隔离级别可重复读的情况下,开启两个事务,前后执行相同的 Insert 语句,此时**事务 B 的 Insert 语句会发生阻塞**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/唯一索引加锁.drawio.png) 两个事务的加锁过程: - 事务 A 先插入 order_no 为 1006 的记录,可以插入成功,此时对应的唯一二级索引记录被「隐式锁」保护,此时还没有实际的锁结构(执行完这里的时候,你可以看查 performance_schema.data_locks 信息,可以看到这条记录是没有加任何锁的); - 接着,事务 B 也插入 order_no 为 1006 的记录,由于事务 A 已经插入 order_no 值为 1006 的记录,所以事务 B 在插入二级索引记录时会遇到重复的唯一二级索引列值,此时事务 B 想获取一个 S 型 next-key 锁,但是事务 A 并未提交,**事务 A 插入的 order_no 值为 1006 的记录上的「隐式锁」会变「显示锁」且锁类型为 X 型的记录锁,所以事务 B 向获取 S 型 next-key 锁时会遇到锁冲突,事务 B 进入阻塞状态**。 我们可以执行 `select * from performance_schema.data_locks\G;` 语句,确定事务加了什么类型的锁,这里只关注在记录上加锁的类型。 先看事务 A 对 order_no 为 1006 的记录加了什么锁? 从下图可以看到,**事务 A 对 order_no 为 1006 记录加上了类型为 X 型的记录锁**(*注意,这个是在执行事务 B 之后才产生的锁,没执行事务 B 之前,该记录还是隐式锁*)。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/事务a显示锁.png) 然后看事务 B 想对 order_no 为 1006 的记录加什么锁? 从下图可以看到,**事务 B 想对 order_no 为 1006 的记录加 S 型的 next-key 锁,但是由于事务 A 在该记录上持有了 X 型的记录锁,这两个锁是冲突的,所以导致事务 B 处于等待状态**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/事务b等待.png) 从这个实验可以得知,并发多个事务的时候,第一个事务插入的记录,并不会加锁,而是会用隐式锁保护唯一二级索引的记录。 但是当第一个事务还未提交的时候,有其他事务插入了与第一个事务相同的记录,第二个事务就会**被阻塞**,**因为此时第一事务插入的记录中的隐式锁会变为显示锁且类型是 X 型的记录锁,而第二个事务是想对该记录加上 S 型的 next-key 锁,X 型与 S 型的锁是冲突的**,所以导致第二个事务会等待,直到第一个事务提交后,释放了锁。 如果 order_no 不是唯一二级索引,那么两个事务,前后执行相同的 Insert 语句,是不会发生阻塞的,就如前面的这个例子。 ![](https://img-blog.csdnimg.cn/img_convert/8ae18f10f1a89aac5e93f0e9794e469e.png) ## 如何避免死锁? 死锁的四个必要条件:**互斥、占有且等待、不可强占用、循环等待**。只要系统发生死锁,这些条件必然成立,但是只要破坏任意一个条件就死锁就不会成立。 在数据库层面,有两种策略通过「打破循环等待条件」来解除死锁状态: - **设置事务等待锁的超时时间**。当一个事务的等待时间超过该值后,就对这个事务进行回滚,于是锁就释放了,另一个事务就可以继续执行了。在 InnoDB 中,参数 `innodb_lock_wait_timeout` 是用来设置超时时间的,默认值时 50 秒。 当发生超时后,就出现下面这个提示: ![图片](https://img-blog.csdnimg.cn/img_convert/c296c1889f0101d335699311b4ef20a8.png) - **开启主动死锁检测**。主动死锁检测在发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 `innodb_deadlock_detect` 设置为 on,表示开启这个逻辑,默认就开启。 当检测到死锁后,就会出现下面这个提示: ![图片](https://img-blog.csdnimg.cn/img_convert/f380ef357d065498d8d54ad07f145e09.png) 上面这个两种策略是「当有死锁发生时」的避免方式。 我们可以回归业务的角度来预防死锁,对订单做幂等性校验的目的是为了保证不会出现重复的订单,那我们可以直接将 order_no 字段设置为唯一索引列,利用它的唯一性来保证订单表不会出现重复的订单,不过有一点不好的地方就是在我们插入一个已经存在的订单记录时就会抛出异常。 ------ 最后说个段子: 面试官:解释下什么是死锁? 应聘者:你录用我,我就告诉你 面试官:你告诉我,我就录用你 应聘者:你录用我,我就告诉你 面试官:卧槽滚! **……** --- 参考资料: - 《MySQL 是怎样运行的?》 - http://mysql.taobao.org/monthly/2020/09/06/ ---- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: mysql/lock/how_to_lock.md ================================================ # MySQL 是怎么加锁的? 大家好,我是小林。 是不是很多人都对 MySQL 加行级锁的规则搞的迷迷糊糊,对记录一会加的是 next-key 锁,一会加是间隙锁,一会又是记录锁。 坦白说,确实还挺复杂的,但是好在我找点了点规律,也知道如何用命令分析加了什么类型的行级锁。 之前我写过一篇关于「MySQL 是怎么加行级锁的?」的文章,随着我写 MySQL 锁相关的文章越来越多时,后来发现当时的文章写的不够详细。 为了让大家很清楚的知道 MySQL 是怎么加行级锁的,以及如何用命令分析加了什么行级锁,再加上为了解释清楚为什么 MySQL 要这么加行级锁,所以**我重写了这篇文章**。 这一重写,就多增加了 1W 多字 + 30 张图,所以完全算是新的文章了。 文章内容比较长,大家可以耐心看下去,看完之后你会有一种突然被顿悟的感觉,因为我自己写完这篇文章后,自己也被自己顿悟了。 ## 什么 SQL 语句会加行级锁? InnoDB 引擎是支持行级锁的,而 MyISAM 引擎并不支持行级锁,所以后面的内容都是基于 InnoDB 引擎 的。 所以,在说 MySQL 是怎么加行级锁的时候,其实是在说 InnoDB 引擎是怎么加行级锁的。 普通的 select 语句是不会对记录加锁的,因为它属于快照读,是通过 MVCC(多版本并发控制)实现的。 如果要在查询时对记录加行级锁,可以使用下面这两个方式,这两种查询会加锁的语句称为**锁定读**。 ```sql //对读取的记录加共享锁(S型锁) select ... lock in share mode; //对读取的记录加独占锁(X型锁) select ... for update; ``` 上面这两条语句必须在一个事务中,**因为当事务提交了,锁就会被释放**,所以在使用这两条语句的时候,要加上 begin 或者 start transaction 开启事务的语句。 **除了上面这两条锁定读语句会加行级锁之外,update 和 delete 操作都会加行级锁,且锁的类型都是独占锁 (X 型锁)**。 ```sql //对操作的记录加独占锁(X型锁) update table .... where id = 1; //对操作的记录加独占锁(X型锁) delete from table where id = 1; ``` 共享锁(S 锁)满足读读共享,读写互斥。独占锁(X 锁)满足写写互斥、读写互斥。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/%E9%94%81/x%E9%94%81%E5%92%8Cs%E9%94%81.png) ## 行级锁有哪些种类? 不同隔离级别下,行级锁的种类是不同的。 在读已提交隔离级别下,行级锁的种类只有记录锁,也就是仅仅把一条记录锁上。 在可重复读隔离级别下,行级锁的种类除了有记录锁,还有间隙锁(目的是为了避免幻读),所以行级锁的种类主要有三类: - Record Lock,记录锁,也就是仅仅把一条记录锁上; - Gap Lock,间隙锁,锁定一个范围,但是不包含记录本身; - Next-Key Lock:Record Lock + Gap Lock 的组合,锁定一个范围,并且锁定记录本身。 接下来,分别介绍这三种行级锁。 ### Record Lock Record Lock 称为记录锁,锁住的是一条记录。而且记录锁是有 S 锁和 X 锁之分的: - 当一个事务对一条记录加了 S 型记录锁后,其他事务也可以继续对该记录加 S 型记录锁(S 型与 S 锁兼容),但是不可以对该记录加 X 型记录锁(S 型与 X 锁不兼容); - 当一个事务对一条记录加了 X 型记录锁后,其他事务既不可以对该记录加 S 型记录锁(S 型与 X 锁不兼容),也不可以对该记录加 X 型记录锁(X 型与 X 锁不兼容)。 举个例子,当一个事务执行了下面这条语句: ```sql mysql > begin; mysql > select * from t_test where id = 1 for update; ``` 事务会对表中主键 id = 1 的这条记录加上 X 型的记录锁,如果这时候其他事务对这条记录进行删除或者更新操作,那么这些操作都会被阻塞。注意,其他事务插入一条 id = 1 的新记录并不会被阻塞,而是会报主键冲突的错误,这是因为主键有唯一性的约束。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/%E9%94%81/%E8%AE%B0%E5%BD%95%E9%94%81.drawio.png) 当事务执行 commit 后,事务过程中生成的锁都会被释放。 ### Gap Lock Gap Lock 称为间隙锁,只存在于可重复读隔离级别,目的是为了解决可重复读隔离级别下幻读的现象。 假设,表中有一个范围 id 为(3,5)间隙锁,那么其他事务就无法插入 id = 4 这条记录了,这样就有效的防止幻读现象的发生。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/%E9%94%81/gap%E9%94%81.drawio.png) 间隙锁虽然存在 X 型间隙锁和 S 型间隙锁,但是并没有什么区别,**间隙锁之间是兼容的,即两个事务可以同时持有包含共同间隙范围的间隙锁,并不存在互斥关系,因为间隙锁的目的是防止插入幻影记录而提出的**。 ### Next-Key Lock Next-Key Lock 称为临键锁,是 Record Lock + Gap Lock 的组合,锁定一个范围,并且锁定记录本身。 假设,表中有一个范围 id 为(3,5] 的 next-key lock,那么其他事务即不能插入 id = 4 记录,也不能修改和删除 id = 5 这条记录。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/%E9%94%81/%E4%B8%B4%E9%94%AE%E9%94%81.drawio.png) 所以,next-key lock 即能保护该记录,又能阻止其他事务将新记录插入到被保护记录前面的间隙中。 **next-key lock 是包含间隙锁 + 记录锁的,如果一个事务获取了 X 型的 next-key lock,那么另外一个事务在获取相同范围的 X 型的 next-key lock 时,是会被阻塞的**。 比如,一个事务持有了范围为 (1, 10] 的 X 型的 next-key lock,那么另外一个事务在获取相同范围的 X 型的 next-key lock 时,就会被阻塞。 虽然相同范围的间隙锁是多个事务相互兼容的,但对于记录锁,我们是要考虑 X 型与 S 型关系,X 型的记录锁与 X 型的记录锁是冲突的。 ## MySQL 是怎么加行级锁的? 行级锁加锁规则比较复杂,不同的场景,加锁的形式是不同的。 **加锁的对象是索引,加锁的基本单位是 next-key lock**,它是由记录锁和间隙锁组合而成的,**next-key lock 是前开后闭区间,而间隙锁是前开后开区间**。 但是,next-key lock 在一些场景下会退化成记录锁或间隙锁。 那到底是什么场景呢?总结一句,**在能使用记录锁或者间隙锁就能避免幻读现象的场景下,next-key lock 就会退化成退化成记录锁或间隙锁**。 这次会以下面这个表结构来进行实验说明: ```sql CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL, `age` int NOT NULL, PRIMARY KEY (`id`), KEY `index_age` (`age`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ``` 其中,id 是主键索引(唯一索引),age 是普通索引(非唯一索引),name 是普通的列。 表中的有这些行记录: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/user.png) 这次实验环境的 **MySQL 版本是 8.0.26,隔离级别是「可重复读」**。 不同版本的加锁规则可能是不同的,但是大体上是相同的。 ### 唯一索引等值查询 当我们用唯一索引进行等值查询的时候,查询的记录存不存在,加锁的规则也会不同: - 当查询的记录是「存在」的,在索引树上定位到这一条记录后,将该记录的索引中的 next-key lock 会**退化成「记录锁」**。 - 当查询的记录是「不存在」的,在索引树找到第一条大于该查询记录的记录后,将该记录的索引中的 next-key lock 会**退化成「间隙锁」**。 接下里用两个案例来说明。 #### 1、记录存在的情况 假设事务 A 执行了这条等值查询语句,查询的记录是「存在」于表中的。 ```sql mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> select * from user where id = 1 for update; +----+--------+-----+ | id | name | age | +----+--------+-----+ | 1 | 路飞 | 19 | +----+--------+-----+ 1 row in set (0.02 sec) ``` 那么,事务 A 会为 id 为 1 的这条记录就会加上 **X 型的记录锁**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/唯一索引记录锁.drawio.png) 接下来,如果有其他事务,对 id 为 1 的记录进行更新或者删除操作的话,这些操作都会被阻塞,因为更新或者删除操作也会对记录加 X 型的记录锁,而 X 锁和 X 锁之间是互斥关系。 比如,下面这个例子: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/唯一索引等值查询(存在).drawio.png) 因为事务 A 对 id = 1 的记录加了 **X 型的记录锁**,所以事务 B 在修改 id=1 的记录时会被阻塞,事务 C 在删除 id=1 的记录时也会被阻塞。 > 有什么命令可以分析加了什么锁? 我们可以通过 `select * from performance_schema.data_locks\G;` 这条语句,查看事务执行 SQL 过程中加了什么锁。 我们以前面的事务 A 作为例子,分析下下它加了什么锁。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/事务a加锁分析.png) 从上图可以看到,共加了两个锁,分别是: - 表锁:X 类型的意向锁; - 行锁:X 类型的记录锁; 这里我们重点关注行级锁,图中 LOCK_TYPE 中的 RECORD 表示行级锁,而不是记录锁的意思。 通过 LOCK_MODE 可以确认是 next-key 锁,还是间隙锁,还是记录锁: - 如果 LOCK_MODE 为 `X`,说明是 next-key 锁; - 如果 LOCK_MODE 为 `X, REC_NOT_GAP`,说明是记录锁; - 如果 LOCK_MODE 为 `X, GAP`,说明是间隙锁; 因此,**此时事务 A 在 id = 1 记录的主键索引上加的是记录锁,锁住的范围是 id 为 1 的这条记录**。这样其他事务就无法对 id 为 1 的这条记录进行更新和删除操作了。 从这里我们也可以得知,**加锁的对象是针对索引**,因为这里查询语句扫描的 B+ 树是聚簇索引树,即主键索引树,所以是对主键索引加锁。将对应记录的主键索引加 记录锁后,就意味着其他事务无法对该记录进行更新和删除操作了。 > 为什么唯一索引等值查询并且查询记录存在的场景下,该记录的索引中的 next-key lock 会退化成记录锁? 原因就是在唯一索引等值查询并且查询记录存在的场景下,仅靠记录锁也能避免幻读的问题。 幻读的定义就是,当一个事务前后两次查询的结果集,不相同时,就认为发生幻读。所以,要避免幻读就是避免结果集某一条记录被其他事务删除,或者有其他事务插入了一条新记录,这样前后两次查询的结果集就不会出现不相同的情况。 - 由于主键具有唯一性,所以**其他事务插入 id = 1 的时候,会因为主键冲突,导致无法插入 id = 1 的新记录**。这样事务 A 在多次查询 id = 1 的记录的时候,不会出现前后两次查询的结果集不同,也就避免了幻读的问题。 - 由于对 id = 1 加了记录锁,**其他事务无法删除该记录**,这样事务 A 在多次查询 id = 1 的记录的时候,不会出现前后两次查询的结果集不同,也就避免了幻读的问题。 #### 2、记录不存在的情况 假设事务 A 执行了这条等值查询语句,查询的记录是「不存在」于表中的。 ```sql mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> select * from user where id = 2 for update; Empty set (0.03 sec) ``` 接下来,通过 `select * from performance_schema.data_locks\G;` 这条语句,查看事务执行 SQL 过程中加了什么锁。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/事务a分析1.png) 从上图可以看到,共加了两个锁,分别是: - 表锁:X 类型的意向锁; - 行锁:X 类型的间隙锁; 因此,**此时事务 A 在 id = 5 记录的主键索引上加的是间隙锁,锁住的范围是 (1, 5)。** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/唯一索引间隙锁.drawio.png) 接下来,如果有其他事务插入 id 值为 2、3、4 这一些记录的话,这些插入语句都会发生阻塞。 注意,如果其他事务插入的 id = 1 或者 id = 5 的记录话,并不会发生阻塞,而是报主键冲突的错误,因为表中已经存在 id = 1 和 id = 5 的记录了。 比如,下面这个例子: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/唯一索引等值查询(不存在).drawio.png) 因为事务 A 在 id = 5 记录的主键索引上加了范围为 (1, 5) 的 X 型间隙锁,所以事务 B 在插入一条 id 为 3 的记录时会被阻塞住,即无法插入 id = 3 的记录。 > 间隙锁的范围`(1, 5)` ,是怎么确定的? 根据我的经验,如果 LOCK_MODE 是 next-key 锁或者间隙锁,那么 LOCK_DATA 就表示锁的范围「右边界」,此次的事务 A 的 LOCK_DATA 是 5。 然后锁范围的「左边界」是表中 id 为 5 的上一条记录的 id 值,即 1。 因此,间隙锁的范围`(1, 5)`。 > 为什么唯一索引等值查询并且查询记录「不存在」的场景下,在索引树找到第一条大于该查询记录的记录后,要将该记录的索引中的 next-key lock 会退化成「间隙锁」? 原因就是在唯一索引等值查询并且查询记录不存在的场景下,仅靠间隙锁就能避免幻读的问题。 - 为什么 id = 5 记录上的主键索引的锁不可以是 next-key lock?如果是 next-key lock,就意味着其他事务无法删除 id = 5 这条记录,但是这次的案例是查询 id = 2 的记录,只要保证前后两次查询 id = 2 的结果集相同,就能避免幻读的问题了,所以即使 id =5 被删除,也不会有什么影响,那就没必须加 next-key lock,因此只需要在 id = 5 加间隙锁,避免其他事务插入 id = 2 的新记录就行了。 - 为什么不可以针对不存在的记录加记录锁?锁是加在索引上的,而这个场景下查询的记录是不存在的,自然就没办法锁住这条不存在的记录。 ### 唯一索引范围查询 范围查询和等值查询的加锁规则是不同的。 当唯一索引进行范围查询时,**会对每一个扫描到的索引加 next-key 锁,然后如果遇到下面这些情况,会退化成记录锁或者间隙锁**: - 情况一:针对「大于等于」的范围查询,因为存在等值查询的条件,那么如果等值查询的记录是存在于表中,那么该记录的索引中的 next-key 锁会**退化成记录锁**。 - 情况二:针对「小于或者小于等于」的范围查询,要看条件值的记录是否存在于表中: - 当条件值的记录不在表中,那么不管是「小于」还是「小于等于」条件的范围查询,**扫描到终止范围查询的记录时,该记录的索引的 next-key 锁会退化成间隙锁**,其他扫描到的记录,都是在这些记录的索引上加 next-key 锁。 - 当条件值的记录在表中,如果是「小于」条件的范围查询,**扫描到终止范围查询的记录时,该记录的索引的 next-key 锁会退化成间隙锁**,其他扫描到的记录,都是在这些记录的索引上加 next-key 锁;如果「小于等于」条件的范围查询,扫描到终止范围查询的记录时,该记录的索引 next-key 锁不会退化成间隙锁。其他扫描到的记录,都是在这些记录的索引上加 next-key 锁。 接下来,通过几个实验,才验证我上面说的结论。 #### 1、针对「大于或者大于等于」的范围查询 > 实验一:针对「大于」的范围查询的情况。 假设事务 A 执行了这条范围查询语句: ```sql mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> select * from user where id > 15 for update; +----+-----------+-----+ | id | name | age | +----+-----------+-----+ | 20 | 香克斯 | 39 | +----+-----------+-----+ 1 row in set (0.01 sec) ``` 事务 A 加锁变化过程如下: 1. 最开始要找的第一行是 id = 20,由于查询该记录不是一个等值查询(不是大于等于条件查询),所以对该主键索引加的是范围为 (15, 20] 的 next-key 锁; 2. 由于是范围查找,就会继续往后找存在的记录,虽然我们看见表中最后一条记录是 id = 20 的记录,但是实际在 Innodb 存储引擎中,会用一个特殊的记录来标识最后一条记录,该特殊的记录的名字叫 supremum pseudo-record,所以扫描第二行的时候,也就扫描到了这个特殊记录的时候,会对该主键索引加的是范围为 (20, +∞] 的 next-key 锁。 3. 停止扫描。 可以得知,事务 A 在主键索引上加了两个 X 型 的 next-key 锁: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/唯一索引范围查询大于15.drawio.png) - 在 id = 20 这条记录的主键索引上,加了范围为 (15, 20] 的 next-key 锁,意味着其他事务即无法更新或者删除 id = 20 的记录,同时无法插入 id 值为 16、17、18、19 的这一些新记录。 - 在特殊记录(supremum pseudo-record)的主键索引上,加了范围为 (20, +∞] 的 next-key 锁,意味着其他事务无法插入 id 值大于 20 的这一些新记录。 我们也可以通过 `select * from performance_schema.data_locks\G;` 这条语句来看看事务 A 加了什么锁。 输出结果如下,我这里只截取了行级锁的内容。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/范围查询大于.png) 从上图中的分析中,也可以得到**事务 A 在主键索引上加了两个 X 型 的 next-key 锁:** - 在 id = 20 这条记录的主键索引上,加了范围为 (15, 20] 的 next-key 锁,意味着其他事务即无法更新或者删除 id = 20 的记录,同时无法插入 id 值为 16、17、18、19 的这一些新记录。 - 在特殊记录(supremum pseudo-record)的主键索引上,加了范围为 (20, +∞] 的 next-key 锁,意味着其他事务无法插入 id 值大于 20 的这一些新记录。 > 实验二:针对「大于等于」的范围查询的情况。 假设事务 A 执行了这条范围查询语句: ```sql mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> select * from user where id >= 15 for update; +----+-----------+-----+ | id | name | age | +----+-----------+-----+ | 15 | 乌索普 | 20 | | 20 | 香克斯 | 39 | +----+-----------+-----+ 2 rows in set (0.00 sec) ``` 事务 A 加锁变化过程如下: 1. 最开始要找的第一行是 id = 15,由于查询该记录是一个等值查询(等于 15),所以该主键索引的 next-key 锁会**退化成记录锁**,也就是仅锁住 id = 15 这一行记录。 2. 由于是范围查找,就会继续往后找存在的记录,扫描到的第二行是 id = 20,于是对该主键索引加的是范围为 (15, 20] 的 next-key 锁; 3. 接着扫描到第三行的时候,扫描到了特殊记录(supremum pseudo-record),于是对该主键索引加的是范围为 (20, +∞] 的 next-key 锁。 4. 停止扫描。 可以得知,事务 A 在主键索引上加了三个 X 型 的锁,分别是: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/唯一索引范围查询大于等于15.drawio.png) - 在 id = 15 这条记录的主键索引上,加了记录锁,范围是 id = 15 这一行记录;意味着其他事务无法更新或者删除 id = 15 的这一条记录; - 在 id = 20 这条记录的主键索引上,加了 next-key 锁,范围是 (15, 20] 。意味着其他事务即无法更新或者删除 id = 20 的记录,同时无法插入 id 值为 16、17、18、19 的这一些新记录。 - 在特殊记录(supremum pseudo-record)的主键索引上,加了 next-key 锁,范围是 (20, +∞] 。意味着其他事务无法插入 id 值大于 20 的这一些新记录。 我们也可以通过 `select * from performance_schema.data_locks\G;` 这条语句来看看事务 A 加了什么锁。 输出结果如下,我这里只截取了行级锁的内容。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/范围查询大于等于.png) 通过前面这个实验,我们证明了: - 针对「大于等于」条件的唯一索引范围查询的情况下,如果条件值的记录存在于表中,那么由于查询该条件值的记录是包含一个等值查询的操作,所以该记录的索引中的 next-key 锁会**退化成记录锁**。 #### 2、针对「小于或者小于等于」的范围查询 > 实验一:针对「小于」的范围查询时,查询条件值的记录「不存在」表中的情况。 假设事务 A 执行了这条范围查询语句,注意查询条件值的记录(id 为 6)并不存在于表中。 ```sql mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> select * from user where id < 6 for update; +----+--------+-----+ | id | name | age | +----+--------+-----+ | 1 | 路飞 | 19 | | 5 | 索隆 | 21 | +----+--------+-----+ 3 rows in set (0.00 sec) ``` 事务 A 加锁变化过程如下: 1. 最开始要找的第一行是 id = 1,于是对该主键索引加的是范围为 (-∞, 1] 的 next-key 锁; 2. 由于是范围查找,就会继续往后找存在的记录,扫描到的第二行是 id = 5,所以对该主键索引加的是范围为 (1, 5] 的 next-key 锁; 3. 由于扫描到的第二行记录(id = 5),满足 id < 6 条件,而且也没有达到终止扫描的条件,接着会继续扫描。 4. 扫描到的第三行是 id = 10,该记录不满足 id < 6 条件的记录,所以 id = 10 这一行记录的锁会**退化成间隙锁**,于是对该主键索引加的是范围为 (5, 10) 的间隙锁。 5. 由于扫描到的第三行记录(id = 10),不满足 id < 6 条件,达到了终止扫描的条件,于是停止扫描。 从上面的分析中,可以得知事务 A 在主键索引上加了三个 X 型的锁: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/唯一索引范围查询小于等于6.drawio.png) - 在 id = 1 这条记录的主键索引上,加了范围为 (-∞, 1] 的 next-key 锁,意味着其他事务即无法更新或者删除 id = 1 的这一条记录,同时也无法插入 id 小于 1 的这一些新记录。 - 在 id = 5 这条记录的主键索引上,加了范围为 (1, 5] 的 next-key 锁,意味着其他事务即无法更新或者删除 id = 5 的这一条记录,同时也无法插入 id 值为 2、3、4 的这一些新记录。 - 在 id = 10 这条记录的主键索引上,加了范围为 (5, 10) 的间隙锁,意味着其他事务无法插入 id 值为 6、7、8、9 的这一些新记录。 我们也可以通过 `select * from performance_schema.data_locks\G;` 这条语句来看看事务 A 加了什么锁。 输出结果如下,我这里只截取了行级锁的内容。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/唯一索引范围查询小于6.png) 从上图中的分析中,也可以得知事务 A 在主键索引加的三个锁,就是我们前面分析出那三个锁。 虽然这次范围查询的条件是「小于」,但是查询条件值的记录不存在于表中(id 为 6 的记录不在表中),所以如果事务 A 的范围查询的条件改成 <= 6 的话,加的锁还是和范围查询条件为 < 6 是一样的。大家自己也验证下这个结论。 因此,**针对「小于或者小于等于」的唯一索引范围查询,如果条件值的记录不在表中,那么不管是「小于」还是「小于等于」的范围查询,扫描到终止范围查询的记录时,该记录中索引的 next-key 锁会退化成间隙锁,其他扫描的记录,则是在这些记录的索引上加 next-key 锁**。 > 实验二:针对「小于等于」的范围查询时,查询条件值的记录「存在」表中的情况。 假设事务 A 执行了这条范围查询语句,注意查询条件值的记录(id 为 5)存在于表中。 ```sql mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> select * from user where id <= 5 for update; +----+--------+-----+ | id | name | age | +----+--------+-----+ | 1 | 路飞 | 19 | | 5 | 索隆 | 21 | +----+--------+-----+ 2 rows in set (0.00 sec) ``` 事务 A 加锁变化过程如下: 1. 最开始要找的第一行是 id = 1,于是对该记录加的是范围为 (-∞, 1] 的 next-key 锁; 2. 由于是范围查找,就会继续往后找存在的记录,扫描到的第二行是 id = 5,于是对该记录加的是范围为 (1, 5] 的 next-key 锁。 3. 由于主键索引具有唯一性,不会存在两个 id = 5 的记录,所以不会再继续扫描,于是停止扫描。 从上面的分析中,可以得到**事务 A 在主键索引上加了 2 个 X 型的锁**: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/唯一索引范围查询小于等于5.drawio.png) - 在 id = 1 这条记录的主键索引上,加了范围为 (-∞, 1] 的 next-key 锁。意味着其他事务即无法更新或者删除 id = 1 的这一条记录,同时也无法插入 id 小于 1 的这一些新记录。 - 在 id = 5 这条记录的主键索引上,加了范围为 (1, 5] 的 next-key 锁。意味着其他事务即无法更新或者删除 id = 5 的这一条记录,同时也无法插入 id 值为 2、3、4 的这一些新记录。 我们也可以通过 `select * from performance_schema.data_locks\G;` 这条语句来看看事务 A 加了什么锁。 输出结果如下,我这里只截取了行级锁的内容。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/范围查询小于等于.png) 从上图中的分析中,可以得到事务 A 在主键索引上加了两个 X 型 next-key 锁,分别是: - 在 id = 1 这条记录的主键索引上,加了范围为 (-∞, 1] 的 next-key 锁; - 在 id = 5 这条记录的主键索引上,加了范围为 (1, 5 ] 的 next-key 锁。 > 实验三:再来看针对「小于」的范围查询时,查询条件值的记录「存在」表中的情况。 如果事务 A 的查询语句是小于的范围查询,且查询条件值的记录(id 为 5)存在于表中。 ```sql select * from user where id < 5 for update; ``` 事务 A 加锁变化过程如下: 1. 最开始要找的第一行是 id = 1,于是对该记录加的是范围为 (-∞, 1] 的 next-key 锁; 2. 由于是范围查找,就会继续往后找存在的记录,扫描到的第二行是 id = 5,该记录是第一条不满足 id < 5 条件的记录,于是**该记录的锁会退化为间隙锁,锁范围是 (1,5)**。 3. 由于找到了第一条不满足 id < 5 条件的记录,于是停止扫描。 可以得知,此时**事务 A 在主键索引上加了两种 X 型锁:** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/唯一索引范围查询小于5.drawio.png) - 在 id = 1 这条记录的主键索引上,加了范围为 (-∞, 1] 的 next-key 锁,意味着其他事务即无法更新或者删除 id = 1 的这一条记录,同时也无法插入 id 小于 1 的这一些新记录。 - 在 id = 5 这条记录的主键索引上,加了范围为 (1,5) 的间隙锁,意味着其他事务无法插入 id 值为 2、3、4 的这一些新记录。 我们也可以通过 `select * from performance_schema.data_locks\G;` 这条语句来看看事务 A 加了什么锁。 输出结果如下,我这里只截取了行级锁的内容。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/唯一索引范围查询小于.png) 从上图中的分析中,可以得到事务 A 在主键索引上加了 **X 型的范围为 (-∞, 1] 的 next-key 锁,和 X 型的范围为 (1, 5) 的间隙锁**。 因此,通过前面这三个实验,可以得知。 在针对「小于或者小于等于」的唯一索引(主键索引)范围查询时,存在这两种情况会将索引的 next-key 锁会退化成间隙锁的: - 当条件值的记录「不在」表中时,那么不管是「小于」还是「小于等于」条件的范围查询,扫描到终止范围查询的记录时,该记录的主键索引中的 next-key 锁会**退化成间隙锁**,其他扫描到的记录,都是在这些记录的主键索引上加 next-key 锁。 - 当条件值的记录「在」表中时: - 如果是「小于」条件的范围查询,扫描到终止范围查询的记录时,该记录的主键索引中的 next-key 锁会**退化成间隙锁**,其他扫描到的记录,都是在这些记录的主键索引上,加 next-key 锁。 - 如果是「小于等于」条件的范围查询,扫描到终止范围查询的记录时,该记录的主键索引中的 next-key 锁「不会」退化成间隙锁,其他扫描到的记录,都是在这些记录的主键索引上加 next-key 锁。 ### 非唯一索引等值查询 当我们用非唯一索引进行等值查询的时候,**因为存在两个索引,一个是主键索引,一个是非唯一索引(二级索引),所以在加锁时,同时会对这两个索引都加锁,但是对主键索引加锁的时候,只有满足查询条件的记录才会对它们的主键索引加锁**。 针对非唯一索引等值查询时,查询的记录存不存在,加锁的规则也会不同: - 当查询的记录「存在」时,由于不是唯一索引,所以肯定存在索引值相同的记录,于是**非唯一索引等值查询的过程是一个扫描的过程,直到扫描到第一个不符合条件的二级索引记录就停止扫描,然后在扫描的过程中,对扫描到的二级索引记录加的是 next-key 锁,而对于第一个不符合条件的二级索引记录,该二级索引的 next-key 锁会退化成间隙锁。同时,在符合查询条件的记录的主键索引上加记录锁**。 - 当查询的记录「不存在」时,**扫描到第一条不符合条件的二级索引记录,该二级索引的 next-key 锁会退化成间隙锁。因为不存在满足查询条件的记录,所以不会对主键索引加锁**。 接下里用两个实验来说明。 #### 1、记录不存在的情况 > 实验一:针对非唯一索引等值查询时,查询的值不存在的情况。 先来说说非唯一索引等值查询时,查询的记录不存在的情况,因为这个比较简单。 假设事务 A 对非唯一索引(age)进行了等值查询,且表中不存在 age = 25 的记录。 ```sql mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> select * from user where age = 25 for update; Empty set (0.00 sec) ``` 事务 A 加锁变化过程如下: - 定位到第一条不符合查询条件的二级索引记录,即扫描到 age = 39,于是**该二级索引的 next-key 锁会退化成间隙锁,范围是 (22, 39)**。 - 停止查询 事务 A 在 age = 39 记录的二级索引上,加了 X 型的间隙锁,范围是 (22, 39)。意味着其他事务无法插入 age 值为 23、24、25、26、……、38 这些新记录。不过对于插入 age = 22 和 age = 39 记录的语句,在一些情况是可以成功插入的,而一些情况则无法成功插入,具体哪些情况,会在后面说。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/非唯一索引等值查询age=25.drawio.png) 我们也可以通过 `select * from performance_schema.data_locks\G;` 这条语句来看看事务 A 加了什么锁。 输出结果如下,我这里只截取了行级锁的内容。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/非唯一索引等值查询age=25.png) 从上图的分析,可以看到,事务 A 在 age = 39 记录的二级索引上(INDEX_NAME: index_age),加了范围为 (22, 39) 的 X 型间隙锁。 此时,如果有其他事务插入了 age 值为 23、24、25、26、……、38 这些新记录,那么这些插入语句都会发生阻塞。不过对于插入 age = 39 记录的语句,在一些情况是可以成功插入的,而一些情况则无法成功插入,具体哪些情况,接下来我们就说! > 当有一个事务持有二级索引的间隙锁 (22, 39) 时,什么情况下,可以让其他事务的插入 age = 22 或者 age = 39 记录的语句成功?又是什么情况下,插入 age = 22 或者 age = 39 记录时的语句会被阻塞? 我们先要清楚,什么情况下插入语句会发生阻塞。 **插入语句在插入一条记录之前,需要先定位到该记录在 B+树 的位置,如果插入的位置的下一条记录的索引上有间隙锁,才会发生阻塞**。 在分析二级索引的间隙锁是否可以成功插入记录时,我们要先要知道二级索引树是如何存放记录的? 二级索引树是按照二级索引值(age 列)按顺序存放的,在相同的二级索引值情况下,再按主键 id 的顺序存放。知道了这个前提,我们才能知道执行插入语句的时候,插入的位置的下一条记录是谁。 基于前面的实验,事务 A 是在 age = 39 记录的二级索引上,加了 X 型的间隙锁,范围是 (22, 39)。 插入 age = 22 记录的成功和失败的情况分别如下: - 当其他事务插入一条 age = 22,id = 3 的记录的时候,在二级索引树上定位到插入的位置,而**该位置的下一条是 id = 10、age = 22 的记录,该记录的二级索引上没有间隙锁,所以这条插入语句可以执行成功**。 - 当其他事务插入一条 age = 22,id = 12 的记录的时候,在二级索引树上定位到插入的位置,而**该位置的下一条是 id = 20、age = 39 的记录,正好该记录的二级索引上有间隙锁,所以这条插入语句会被阻塞,无法插入成功**。 插入 age = 39 记录的成功和失败的情况分别如下: - 当其他事务插入一条 age = 39,id = 3 的记录的时候,在二级索引树上定位到插入的位置,而**该位置的下一条是 id = 20、age = 39 的记录,正好该记录的二级索引上有间隙锁,所以这条插入语句会被阻塞,无法插入成功**。 - 当其他事务插入一条 age = 39,id = 21 的记录的时候,在二级索引树上定位到插入的位置,而**该位置的下一条记录不存在,也就没有间隙锁了,所以这条插入语句可以插入成功**。 所以,**当有一个事务持有二级索引的间隙锁 (22, 39) 时,插入 age = 22 或者 age = 39 记录的语句是否可以执行成功,关键还要考虑插入记录的主键值,因为「二级索引值(age 列)+ 主键值(id 列)」才可以确定插入的位置,确定了插入位置后,就要看插入的位置的下一条记录是否有间隙锁,如果有间隙锁,就会发生阻塞,如果没有间隙锁,则可以插入成功**。 知道了这个结论之后,我们再回过头看,非唯一索引等值查询时,查询的记录不存在时,执行`select * from performance_schema.data_locks\G;` 输出的结果。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/非唯一索引等值查询age=25_1.png) 在前面分析输出结果的时候,我说的结论是:「*事务 A 在 age = 39 记录的二级索引上(INDEX_NAME: index_age),加了范围为 (22, 39) 的 X 型间隙锁*」。这个结论其实还不够准确,因为只考虑了 LOCK_DATA 第一个数值(39),没有考虑 LOCK_DATA 第二个数值(20)。 那 `LOCK_DATA:39,20` 是什么意思? - LOCK_DATA 第一个数值,也就是 39,它代表的是 age 值。从前面我们也知道了,LOCK_DATA 第一个数值是 next-key 锁和间隙锁**锁住的范围的右边界值**。 - LOCK_DATA 第二个数值,也就是 20,它代表的是 id 值。 之所以 LOCK_DATA 要多显示一个数值(ID 值),是因为针对「当某个事务持有非唯一索引的 (22, 39) 间隙锁的时候,其他事务是否可以插入 age = 39 新记录」的问题,还需要考虑插入记录的 id 值。而 **LOCK_DATA 的第二个数值,就是说明在插入 age = 39 新记录时,哪些范围的 id 值是不可以插入的**。 因此, `LOCK_DATA:39,20` + `LOCK_MODE : X, GAP` 的意思是,事务 A 在 age = 39 记录的二级索引上(INDEX_NAME: index_age),加了 age 值范围为 (22, 39) 的 X 型间隙锁,**同时针对其他事务插入 age 值为 39 的新记录时,不允许插入的新记录的 id 值小于 20 **。如果插入的新记录的 id 值大于 20,则可以插入成功。 但是我们无法从`select * from performance_schema.data_locks\G;` 输出的结果分析出「在插入 age =22 新记录时,哪些范围的 id 值是可以插入成功的」,这时候就**得自己画出二级索引的 B+ 树的结构,然后确定插入位置后,看下该位置的下一条记录是否存在间隙锁,如果存在间隙锁,则无法插入成功,如果不存在间隙锁,则可以插入成功**。 #### 2、记录存在的情况 > 实验二:针对非唯一索引等值查询时,查询的值存在的情况。 假设事务 A 对非唯一索引(age)进行了等值查询,且表中存在 age = 22 的记录。 ```sql mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> select * from user where age = 22 for update; +----+--------+-----+ | id | name | age | +----+--------+-----+ | 10 | 山治 | 22 | +----+--------+-----+ 1 row in set (0.00 sec) ``` 事务 A 加锁变化过程如下: - 由于不是唯一索引,所以肯定存在值相同的记录,于是非唯一索引等值查询的过程是一个扫描的过程,最开始要找的第一行是 age = 22,于是对该二级索引记录加上范围为 (21, 22] 的 next-key 锁。同时,因为 age = 22 符合查询条件,于是对 age = 22 的记录的主键索引加上记录锁,即对 id = 10 这一行加记录锁。 - 接着继续扫描,扫描到的第二行是 age = 39,该记录是第一个不符合条件的二级索引记录,所以该二级索引的 next-key 锁会**退化成间隙锁**,范围是 (22, 39)。 - 停止查询。 可以看到,事务 A 对主键索引和二级索引都加了 X 型的锁: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/非唯一索引等值查询存在.drawio.png) - 主键索引: - 在 id = 10 这条记录的主键索引上,加了记录锁,意味着其他事务无法更新或者删除 id = 10 的这一行记录。 - 二级索引(非唯一索引): - 在 age = 22 这条记录的二级索引上,加了范围为 (21, 22] 的 next-key 锁,意味着其他事务无法更新或者删除 age = 22 的这一些新记录,不过对于插入 age = 20 和 age = 21 新记录的语句,在一些情况是可以成功插入的,而一些情况则无法成功插入,具体哪些情况,会在后面说。 - 在 age = 39 这条记录的二级索引上,加了范围 (22, 39) 的间隙锁。意味着其他事务无法插入 age 值为 23、24、…… 、38 的这一些新记录。不过对于插入 age = 22 和 age = 39 记录的语句,在一些情况是可以成功插入的,而一些情况则无法成功插入,具体哪些情况,会在后面说。 我们也可以通过 `select * from performance_schema.data_locks\G;` 这条语句来看看事务 A 加了什么锁。 输出结果如下,我这里只截取了行级锁的内容。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/非唯一索引等值查询(存在).png) 从上图的分析,可以看到,事务 A 对二级索引(INDEX_NAME: index_age)加了两个 X 型锁,分别是: - 在 age = 22 这条记录的二级索引上,加了范围为 (21, 22] 的 next-key 锁,意味着其他事务无法更新或者删除 age = 22 的这一些新记录,针对是否可以插入 age = 21 和 age = 22 的新记录,分析如下: - 是否可以插入 age = 21 的新记录,还要看插入的新记录的 id 值,**如果插入 age = 21 新记录的 id 值小于 5,那么就可以插入成功**,因为此时插入的位置的下一条记录是 id = 5,age = 21 的记录,该记录的二级索引上没有间隙锁。**如果插入 age = 21 新记录的 id 值大于 5,那么就无法插入成功**,因为此时插入的位置的下一条记录是 id = 20,age = 39 的记录,该记录的二级索引上有间隙锁。 - 是否可以插入 age = 22 的新记录,还要看插入的新记录的 id 值,从 `LOCK_DATA : 22, 10` 可以得知,其他事务插入 age 值为 22 的新记录时,**如果插入的新记录的 id 值小于 10,那么插入语句会发生阻塞;如果插入的新记录的 id 大于 10,还要看该新记录插入的位置的下一条记录是否有间隙锁,如果没有间隙锁则可以插入成功,如果有间隙锁,则无法插入成功**。 - 在 age = 39 这条记录的二级索引上,加了范围 (22, 39) 的间隙锁。意味着其他事务无法插入 age 值为 23、24、…… 、38 的这一些新记录,针对是否可以插入 age = 22 和 age = 39 的新记录,分析如下: - 是否可以插入 age = 22 的新记录,还要看插入的新记录的 id 值,**如果插入 age = 22 新记录的 id 值小于 10,那么插入语句会被阻塞,无法插入**,因为此时插入的位置的下一条记录是 id = 10,age = 22 的记录,该记录的二级索引上有间隙锁(age = 22 这条记录的二级索引上有 next-key 锁)。**如果插入 age = 21 新记录的 id 值大于 10,也无法插入**,因为此时插入的位置的下一条记录是 id = 20,age = 39 的记录,该记录的二级索引上有间隙锁。 - 是否可以插入 age = 39 的新记录,还要看插入的新记录的 id 值,从 `LOCK_DATA : 39, 20` 可以得知,其他事务插入 age 值为 39 的新记录时,**如果插入的新记录的 id 值小于 20,那么插入语句会发生阻塞,如果插入的新记录的 id 大于 20,则可以插入成功**。 同时,事务 A 还对主键索引(INDEX_NAME: PRIMARY)加了 X 型的记录锁: - 在 id = 10 这条记录的主键索引上,加了记录锁,意味着其他事务无法更新或者删除 id = 10 的这一行记录。 > 为什么这个实验案例中,需要在二级索引索引上加范围 (22, 39) 的间隙锁? 要找到这个问题的答案,我们要明白 MySQL 在可重复读的隔离级别场景下,为什么要引入间隙锁?其实**是为了避免幻读现象的发生**。 如果这个实验案例中: ```sql select * from user where age = 22 for update; ``` 如果事务 A 不在二级索引索引上加范围 (22, 39) 的间隙锁,只在二级索引索引上加范围为 (21, 22] 的 next-key 锁的话,那么就会有幻读的问题。 前面我也说过,在非唯一索引上加了范围为 (21, 22] 的 next-key 锁,是无法完全锁住 age = 22 新记录的插入,因为对于是否可以插入 age = 22 的新记录,还要看插入的新记录的 id 值,从 `LOCK_DATA : 22, 10` 可以得知,其他事务插入 age 值为 22 的新记录时,如果插入的新记录的 id 值小于 10,那么插入语句会发生阻塞,**如果插入的新记录的 id 值大于 10,则可以插入成功**。 也就是说,只在二级索引索引(非唯一索引)上加范围为 (21, 22] 的 next-key 锁,其他事务是有可能插入 age 值为 22 的新记录的(比如插入一个 age = 22,id = 12 的新记录),那么如果事务 A 再一次查询 age = 22 的记录的时候,前后两次查询 age = 22 的结果集就不一样了,这时就发生了幻读的现象。 **那么当在 age = 39 这条记录的二级索引索引上加了范围为 (22, 39) 的间隙锁后,其他事务是无法插入一个 age = 22,id = 12 的新记录,因为当其他事务插入一条 age = 22,id = 12 的新记录的时候,在二级索引树上定位到插入的位置,而该位置的下一条是 id = 20、age = 39 的记录,正好该记录的二级索引上有间隙锁,所以这条插入语句会被阻塞,无法插入成功,这样就避免幻读现象的发生**。 所以,为了避免幻读现象的发生,就需要在二级索引索引上加范围 (22, 39) 的间隙锁。 ### 非唯一索引范围查询 非唯一索引和主键索引的范围查询的加锁也有所不同,不同之处在于**非唯一索引范围查询,索引的 next-key lock 不会有退化为间隙锁和记录锁的情况**,也就是非唯一索引进行范围查询时,对二级索引记录加锁都是加 next-key 锁。 就带大家简单分析一下,事务 A 的这条范围查询语句: ```sql mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> select * from user where age >= 22 for update; +----+-----------+-----+ | id | name | age | +----+-----------+-----+ | 10 | 山治 | 22 | | 20 | 香克斯 | 39 | +----+-----------+-----+ 2 rows in set (0.01 sec) ``` 事务 A 的加锁变化: - 最开始要找的第一行是 age = 22,虽然范围查询语句包含等值查询,但是这里不是唯一索引范围查询,所以是不会发生退化锁的现象,因此对该二级索引记录加 next-key 锁,范围是 (21, 22]。同时,对 age = 22 这条记录的主键索引加记录锁,即对 id = 10 这一行记录的主键索引加记录锁。 - 由于是范围查询,接着继续扫描已经存在的二级索引记录。扫面的第二行是 age = 39 的二级索引记录,于是对该二级索引记录加 next-key 锁,范围是 (22, 39],同时,对 age = 39 这条记录的主键索引加记录锁,即对 id = 20 这一行记录的主键索引加记录锁。 - 虽然我们看见表中最后一条二级索引记录是 age = 39 的记录,但是实际在 Innodb 存储引擎中,会用一个特殊的记录来标识最后一条记录,该特殊的记录的名字叫 supremum pseudo-record,所以扫描第二行的时候,也就扫描到了这个特殊记录的时候,会对该二级索引记录加的是范围为 (39, +∞] 的 next-key 锁。 - 停止查询 可以看到,事务 A 对主键索引和二级索引都加了 X 型的锁: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/非唯一索引范围查询age大于等于22.drawio.png) - 主键索引(id 列): - 在 id = 10 这条记录的主键索引上,加了记录锁,意味着其他事务无法更新或者删除 id = 10 的这一行记录。 - 在 id = 20 这条记录的主键索引上,加了记录锁,意味着其他事务无法更新或者删除 id = 20 的这一行记录。 - 二级索引(age 列): - 在 age = 22 这条记录的二级索引上,加了范围为 (21, 22] 的 next-key 锁,意味着其他事务无法更新或者删除 age = 22 的这一些新记录,不过对于是否可以插入 age = 21 和 age = 22 的新记录,还需要看新记录的 id 值,有些情况是可以成功插入的,而一些情况则无法插入,具体哪些情况,我们前面也讲了。 - 在 age = 39 这条记录的二级索引上,加了范围为 (22, 39] 的 next-key 锁,意味着其他事务无法更新或者删除 age = 39 的这一些记录,也无法插入 age 值为 23、24、25、...、38 的这一些新记录。不过对于是否可以插入 age = 22 和 age = 39 的新记录,还需要看新记录的 id 值,有些情况是可以成功插入的,而一些情况则无法插入,具体哪些情况,我们前面也讲了。 - 在特殊的记录(supremum pseudo-record)的二级索引上,加了范围为 (39, +∞] 的 next-key 锁,意味着其他事务无法插入 age 值大于 39 的这些新记录。 > 在 age >= 22 的范围查询中,明明查询 age = 22 的记录存在并且属于等值查询,为什么不会像唯一索引那样,将 age = 22 记录的二级索引上的 next-key 锁退化为记录锁? 因为 age 字段是非唯一索引,不具有唯一性,所以如果只加记录锁(记录锁无法防止插入,只能防止删除或者修改),就会导致其他事务插入一条 age = 22 的记录,这样前后两次查询的结果集就不相同了,出现了幻读现象。 ### 没有加索引的查询 前面的案例,我们的查询语句都有使用索引查询,也就是查询记录的时候,是通过索引扫描的方式查询的,然后对扫描出来的记录进行加锁。 **如果锁定读查询语句,没有使用索引列作为查询条件,或者查询语句没有走索引查询,导致扫描是全表扫描。那么,每一条记录的索引上都会加 next-key 锁,这样就相当于锁住的全表,这时如果其他事务对该表进行增、删、改操作的时候,都会被阻塞**。 不只是锁定读查询语句不加索引才会导致这种情况,update 和 delete 语句如果查询条件不加索引,那么由于扫描的方式是全表扫描,于是就会对每一条记录的索引上都会加 next-key 锁,这样就相当于锁住的全表。 因此,**在线上在执行 update、delete、select ... for update 等具有加锁性质的语句,一定要检查语句是否走了索引,如果是全表扫描的话,会对每一个索引加 next-key 锁,相当于把整个表锁住了**,这是挺严重的问题。 ## 总结 这次我以 **MySQL 8.0.26** 版本,在可重复读隔离级别之下,做了几个实验,让大家了解了唯一索引和非唯一索引的行级锁的加锁规则。 我这里总结下,MySQL 行级锁的加锁规则。 唯一索引等值查询: - 当查询的记录是「存在」的,在索引树上定位到这一条记录后,将该记录的索引中的 next-key lock 会**退化成「记录锁」**。 - 当查询的记录是「不存在」的,在索引树找到第一条大于该查询记录的记录后,将该记录的索引中的 next-key lock 会**退化成「间隙锁」**。 非唯一索引等值查询: - 当查询的记录「存在」时,由于不是唯一索引,所以肯定存在索引值相同的记录,于是非唯一索引等值查询的过程是一个扫描的过程,直到扫描到第一个不符合条件的二级索引记录就停止扫描,然后**在扫描的过程中,对扫描到的二级索引记录加的是 next-key 锁,而对于第一个不符合条件的二级索引记录,该二级索引的 next-key 锁会退化成间隙锁。同时,在符合查询条件的记录的主键索引上加记录锁**。 - 当查询的记录「不存在」时,**扫描到第一条不符合条件的二级索引记录,该二级索引的 next-key 锁会退化成间隙锁。因为不存在满足查询条件的记录,所以不会对主键索引加锁**。 非唯一索引和主键索引的范围查询的加锁规则不同之处在于: - 唯一索引在满足一些条件的时候,索引的 next-key lock 退化为间隙锁或者记录锁。 - 非唯一索引范围查询,索引的 next-key lock 不会退化为间隙锁和记录锁。 其实理解 MySQL 为什么要这样加锁,主要要以避免幻读角度去分析,这样就很容易理解这些加锁的规则了。 还有一件很重要的事情,在线上在执行 update、delete、select ... for update 等具有加锁性质的语句,一定要检查语句是否走了索引,**如果是全表扫描的话,会对每一个索引加 next-key 锁,相当于把整个表锁住了**,这是挺严重的问题。 最后附上「@一只小铭」同学总结的流程图。 唯一索引加锁的流程图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/唯一索引加锁流程.jpeg) 非唯一索引加锁的流程图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/行级锁/非唯一索引加锁流程.jpeg) 就说到这啦,我们下次见啦! ---- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: mysql/lock/lock_phantom.md ================================================ # MySQL 记录锁 + 间隙锁可以防止删除操作而导致的幻读吗? 大家好,我是小林。 昨天有位读者在美团二面的时候,被问到关于幻读的问题: ![](https://img-blog.csdnimg.cn/4c48fe8a02374754b1cf92591ae8d3b4.png) 面试官反问的大概意思是,**MySQL 记录锁 + 间隙锁可以防止删除操作而导致的幻读吗?** 答案是可以的。 接下来,通过几个小实验来证明这个结论吧,顺便再帮大家复习一下记录锁 + 间隙锁。 ## 什么是幻读? 首先来看看 MySQL 文档是怎么定义幻读(Phantom Read)的: ***The so-called phantom problem occurs within a transaction when the same query produces different sets of rows at different times. For example, if a SELECT is executed twice, but returns a row the second time that was not returned the first time, the row is a “phantom” row.*** 翻译:当同一个查询在不同的时间产生不同的结果集时,事务中就会出现所谓的幻象问题。例如,如果 SELECT 执行了两次,但第二次返回了第一次没有返回的行,则该行是“幻像”行。 举个例子,假设一个事务在 T1 时刻和 T2 时刻分别执行了下面查询语句,途中没有执行其他任何语句: ```sql SELECT * FROM t_test WHERE id > 100; ``` 只要 T1 和 T2 时刻执行产生的结果集是不相同的,那就发生了幻读的问题,比如: - T1 时间执行的结果是有 5 条行记录,而 T2 时间执行的结果是有 6 条行记录,那就发生了幻读的问题。 - T1 时间执行的结果是有 5 条行记录,而 T2 时间执行的结果是有 4 条行记录,也是发生了幻读的问题。 > MySQL 是怎么解决幻读的? MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它很大程度上避免幻读现象(并不是完全解决了,详见这篇[文章](https://xiaolincoding.com/mysql/transaction/phantom.html)),解决的方案有两种: - 针对**快照读**(普通 select 语句),是**通过 MVCC 方式解决了幻读**,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。 - 针对**当前读**(select ... for update 等语句),是**通过 next-key lock(记录锁 + 间隙锁)方式解决了幻读**,因为当执行 select ... for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。 ## 实验验证 接下来,来验证「MySQL 记录锁 + 间隙锁**可以防止**删除操作而导致的幻读问题」的结论。 实验环境:MySQL 8.0 版本,可重复读隔离级。 现在有一张用户表(t_user),表里**只有一个主键索引**,表里有以下行数据: ![在这里插入图片描述](https://img-blog.csdnimg.cn/75c5c503d7df4ad091bfc35708dce6c4.png) 现在有一个 A 事务执行了一条查询语句,查询到年龄大于 20 岁的用户共有 6 条行记录。 ![](https://img-blog.csdnimg.cn/68dd89fc95aa42cf9b0c4251d4e9226c.png) 然后,B 事务执行了一条删除 id = 2 的语句: ![](https://img-blog.csdnimg.cn/2332fad58bc548ec917ba7ea44d09d30.png) 此时,B 事务的删除语句就陷入了**等待状态**,说明是无法进行删除的。 因此,MySQL 记录锁 + 间隙锁**可以防止**删除操作而导致的幻读问题。 ### 加锁分析 问题来了,A 事务在执行 select ... for update 语句时,具体加了什么锁呢? 我们可以通过 `select * from performance_schema.data_locks\G;` 这条语句,查看事务执行 SQL 过程中加了什么锁。 输出的内容很多,共有 11 行信息,我删减了一些不重要的信息: ![请添加图片描述](https://img-blog.csdnimg.cn/90e68bf52b2c4e8a9127cfcbb0f0a322.png) 从上面输出的信息可以看到,共加了两种不同粒度的锁,分别是: - 表锁(`LOCK_TYPE: TABLE`):X 类型的意向锁; - 行锁(`LOCK_TYPE: RECORD`):X 类型的 next-key 锁; 这里我们重点关注「行锁」,图中 `LOCK_TYPE` 中的 `RECORD` 表示行级锁,而不是记录锁的意思: - 如果 LOCK_MODE 为 `X`,说明是 next-key 锁; - 如果 LOCK_MODE 为 `X, REC_NOT_GAP`,说明是记录锁; - 如果 LOCK_MODE 为 `X, GAP`,说明是间隙锁; 然后通过 `LOCK_DATA` 信息,可以确认 next-key 锁的范围,具体怎么确定呢? - 根据我的经验,如果 LOCK_MODE 是 next-key 锁或者间隙锁,那么 **LOCK_DATA 就表示锁的范围最右值**,而锁范围的最左值为 LOCK_DATA 的上一条记录的值。 因此,此时事务 A 在主键索引(`INDEX_NAME : PRIMARY`)上加了 10 个 next-key 锁,如下: - X 型的 next-key 锁,范围:(-∞, 1] - X 型的 next-key 锁,范围:(1, 2] - X 型的 next-key 锁,范围:(2, 3] - X 型的 next-key 锁,范围:(3, 4] - X 型的 next-key 锁,范围:(4, 5] - X 型的 next-key 锁,范围:(5, 6] - X 型的 next-key 锁,范围:(6, 7] - X 型的 next-key 锁,范围:(7, 8] - X 型的 next-key 锁,范围:(8, 9] - X 型的 next-key 锁,范围:(9, +∞] **这相当于把整个表给锁住了,其他事务在对该表进行增、删、改操作的时候都会被阻塞**。 只有在事务 A 提交了事务,事务 A 执行过程中产生的锁才会被释放。 > 为什么只是查询年龄 20 岁以上行记录,而把整个表给锁住了呢? 这是因为事务 A 的这条查询语句是**全表扫描,锁是在遍历索引的时候加上的,并不是针对输出的结果加锁**。 ![](https://img-blog.csdnimg.cn/e0b2a18daa864306a84ec51c0866d170.png) 因此,**在线上在执行 update、delete、select ... for update 等具有加锁性质的语句,一定要检查语句是否走了索引,如果是全表扫描的话,会对每一个索引加 next-key 锁,相当于把整个表锁住了**,这是挺严重的问题。 > 如果对 age 建立索引,事务 A 这条查询会加什么锁呢? 接下来,我**对 age 字段建立索引**,然后再执行这条查询语句: ![](https://img-blog.csdnimg.cn/68dd89fc95aa42cf9b0c4251d4e9226c.png) 接下来,继续通过 `select * from performance_schema.data_locks\G;` 这条语句,查看事务执行 SQL 过程中加了什么锁。 具体的信息,我就不打印了,我直接说结论吧。 **因为表中有两个索引,分别是主键索引和 age 索引,所以会分别对这两个索引加锁。** 主键索引会加如下的锁: - X 型的记录锁,锁住 id = 2 的记录; - X 型的记录锁,锁住 id = 3 的记录; - X 型的记录锁,锁住 id = 5 的记录; - X 型的记录锁,锁住 id = 6 的记录; - X 型的记录锁,锁住 id = 7 的记录; - X 型的记录锁,锁住 id = 8 的记录; 分析 age 索引加锁的范围时,要先对 age 字段进行排序。 ![请添加图片描述](https://img-blog.csdnimg.cn/b93b31af4eec416e9f00c2adc1f7d0c1.png) age 索引加的锁: - X 型的 next-key lock,锁住 age 范围 (19, 21] 的记录; - X 型的 next-key lock,锁住 age 范围 (21, 21] 的记录; - X 型的 next-key lock,锁住 age 范围 (21, 23] 的记录; - X 型的 next-key lock,锁住 age 范围 (23, 23] 的记录; - X 型的 next-key lock,锁住 age 范围 (23, 39] 的记录; - X 型的 next-key lock,锁住 age 范围 (39, 43] 的记录; - X 型的 next-key lock,锁住 age 范围 (43, +∞] 的记录; 化简一下,**age 索引 next-key 锁的范围是 (19, +∞]。** 可以看到,对 age 字段建立了索引后,查询语句是索引查询,并不会全表扫描,因此**不会把整张表给锁住**。 ![](https://img-blog.csdnimg.cn/2920c60d5a9b42f2a65933fa14761c20.png) 总结一下,在对 age 字段建立索引后,事务 A 在执行下面这条查询语句后,主键索引和 age 索引会加下图中的锁。 ![请添加图片描述](https://img-blog.csdnimg.cn/5b9a2d7a2cd240fea47b938364f0b76a.png) 事务 A 加上锁后,事务 B、C、D、E 在执行以下语句都会被阻塞。 ![请添加图片描述](https://img-blog.csdnimg.cn/46c9b44142f14217b39bd973868e732e.png) ## 总结 在 MySQL 的可重复读隔离级别下,针对当前读的语句会对**索引**加记录锁 + 间隙锁,这样可以避免其他事务执行增、删、改时导致幻读的问题。 有一点要注意的是,在执行 update、delete、select ... for update 等具有加锁性质的语句,一定要检查语句是否走了索引,如果是全表扫描的话,会对每一个索引加 next-key 锁,相当于把整个表锁住了,这是挺严重的问题。 完! --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: mysql/lock/mysql_lock.md ================================================ # MySQL 有哪些锁? 大家好,我是小林。 这次,来说说 **MySQL 的锁**,主要是 Q&A 的形式,看起来会比较轻松。 不多 BB 了,**发车!** 在 MySQL 里,根据加锁的范围,可以分为**全局锁、表级锁和行锁**三类。 ![](https://img-blog.csdnimg.cn/1e37f6994ef44714aba03b8046b1ace2.png) ## 全局锁 > 全局锁是怎么用的? 要使用全局锁,则要执行这条命令: ```sql flush tables with read lock ``` 执行后,**整个数据库就处于只读状态了**,这时其他线程执行以下操作,都会被阻塞: - 对数据的增删改操作,比如 insert、delete、update 等语句; - 对表结构的更改操作,比如 alter table、drop table 等语句。 如果要释放全局锁,则要执行这条命令: ```sql unlock tables ``` 当然,当会话断开了,全局锁会被自动释放。 > 全局锁应用场景是什么? 全局锁主要应用于做**全库逻辑备份**,这样在备份数据库期间,不会因为数据或表结构的更新,而出现备份文件的数据与预期的不一样。 举个例子大家就知道了。 在全库逻辑备份期间,假设不加全局锁的场景,看看会出现什么意外的情况。 如果在全库逻辑备份期间,有用户购买了一件商品,一般购买商品的业务逻辑是会涉及到多张数据库表的更新,比如在用户表更新该用户的余额,然后在商品表更新被购买的商品的库存。 那么,有可能出现这样的顺序: 1. 先备份了用户表的数据; 2. 然后有用户发起了购买商品的操作; 3. 接着再备份商品表的数据。 也就是在备份用户表和商品表之间,有用户购买了商品。 这种情况下,备份的结果是用户表中该用户的余额并没有扣除,反而商品表中该商品的库存被减少了,如果后面用这个备份文件恢复数据库数据的话,用户钱没少,而库存少了,等于用户白嫖了一件商品。 所以,在全库逻辑备份期间,加上全局锁,就不会出现上面这种情况了。 > 加全局锁又会带来什么缺点呢? 加上全局锁,意味着整个数据库都是只读状态。 那么如果数据库里有很多数据,备份就会花费很多的时间,关键是备份期间,业务只能读数据,而不能更新数据,这样会造成业务停滞。 > 既然备份数据库数据的时候,使用全局锁会影响业务,那有什么其他方式可以避免? 有的,如果数据库的引擎支持的事务支持**可重复读的隔离级别**,那么在备份数据库之前先开启事务,会先创建 Read View,然后整个事务执行期间都在用这个 Read View,而且由于 MVCC 的支持,备份期间业务依然可以对数据进行更新操作。 因为在可重复读的隔离级别下,即使其他事务更新了表的数据,也不会影响备份数据库时的 Read View,这就是事务四大特性中的隔离性,这样备份期间备份的数据一直是在开启事务时的数据。 备份数据库的工具是 mysqldump,在使用 mysqldump 时加上 `–single-transaction` 参数的时候,就会在备份数据库之前先开启事务。这种方法只适用于支持「可重复读隔离级别的事务」的存储引擎。 InnoDB 存储引擎默认的事务隔离级别正是可重复读,因此可以采用这种方式来备份数据库。 但是,对于 MyISAM 这种不支持事务的引擎,在备份数据库时就要使用全局锁的方法。 ## 表级锁 > MySQL 表级锁有哪些?具体怎么用的。 MySQL 里面表级别的锁有这几种: - 表锁; - 元数据锁(MDL); - 意向锁; - AUTO-INC 锁; ### 表锁 先来说说**表锁**。 如果我们想对学生表(t_student)加表锁,可以使用下面的命令: ```sql //表级别的共享锁,也就是读锁; lock tables t_student read; //表级别的独占锁,也就是写锁; lock tables t_student write; ``` 需要注意的是,表锁除了会限制别的线程的读写外,也会限制本线程接下来的读写操作。 也就是说如果本线程对学生表加了「共享表锁」,那么本线程接下来如果要对学生表执行写操作的语句,是会被阻塞的,当然其他线程对学生表进行写操作时也会被阻塞,直到锁被释放。 要释放表锁,可以使用下面这条命令,会释放当前会话的所有表锁: ```sql unlock tables ``` 另外,当会话退出后,也会释放所有表锁。 不过尽量避免在使用 InnoDB 引擎的表使用表锁,因为表锁的颗粒度太大,会影响并发性能,**InnoDB 牛逼的地方在于实现了颗粒度更细的行级锁**。 ### 元数据锁 再来说说**元数据锁**(MDL)。 我们不需要显式的使用 MDL,因为当我们对数据库表进行操作时,会自动给这个表加上 MDL: - 对一张表进行 CRUD 操作时,加的是 **MDL 读锁**; - 对一张表做结构变更操作的时候,加的是 **MDL 写锁**; MDL 是为了保证当用户对表执行 CRUD 操作时,防止其他线程对这个表结构做了变更。 当有线程在执行 select 语句(加 MDL 读锁)的期间,如果有其他线程要更改该表的结构(申请 MDL 写锁),那么将会被阻塞,直到执行完 select 语句(释放 MDL 读锁)。 反之,当有线程对表结构进行变更(加 MDL 写锁)的期间,如果有其他线程执行了 CRUD 操作(申请 MDL 读锁),那么就会被阻塞,直到表结构变更完成(释放 MDL 写锁)。 > MDL 不需要显示调用,那它是在什么时候释放的? MDL 是在事务提交后才会释放,这意味着**事务执行期间,MDL 是一直持有的**。 那如果数据库有一个长事务(所谓的长事务,就是开启了事务,但是一直还没提交),那在对表结构做变更操作的时候,可能会发生意想不到的事情,比如下面这个顺序的场景: 1. 首先,线程 A 先启用了事务(但是一直不提交),然后执行一条 select 语句,此时就先对该表加上 MDL 读锁; 2. 然后,线程 B 也执行了同样的 select 语句,此时并不会阻塞,因为「读读」并不冲突; 3. 接着,线程 C 修改了表字段,此时由于线程 A 的事务并没有提交,也就是 MDL 读锁还在占用着,这时线程 C 就无法申请到 MDL 写锁,就会被阻塞, 那么在线程 C 阻塞后,后续有其他线程对该表的 select 语句,就都会被阻塞。如果此时有大量该对表的 select 语句的请求到来,就会有大量的线程被阻塞住,这时数据库的线程很快就会爆满了。 > 为什么因为线程 C 申请不到 MDL 写锁,会导致后续线程申请读锁的查询操作也会被阻塞? 这是因为申请 MDL 锁的操作会形成一个队列,队列中**写锁获取优先级高于读锁**,一旦出现 MDL 写锁等待,会阻塞后续该表的所有 CRUD 操作。 所以为了能安全的对表结构进行变更,在对表结构变更前,先要看看数据库中的长事务,是否有事务已经对表加上了 MDL 读锁,如果可以,考虑 kill 掉这个长事务,然后再做表结构的变更。 ### 意向锁 接着,说说**意向锁**。 - 在使用 InnoDB 引擎的表里对某些记录加上「共享锁」之前,需要先在表级别加上一个「意向共享锁」; - 在使用 InnoDB 引擎的表里对某些记录加上「独占锁」之前,需要先在表级别加上一个「意向独占锁」; 也就是,当执行插入、更新、删除操作,需要先对表加上「意向独占锁」,然后对该记录加独占锁。 而普通的 select 是不会加行级锁的,普通的 select 语句是利用 MVCC 实现一致性读,是无锁的。 不过,select 也是可以对记录加共享锁和独占锁的,具体方式如下: ```sql //先在表上加上意向共享锁,然后对读取的记录加共享锁 select ... lock in share mode; //先在表上加上意向独占锁,然后对读取的记录加独占锁 select ... for update; ``` **意向共享锁和意向独占锁是表级锁,不会和行级的共享锁和独占锁发生冲突,而且意向锁之间也不会发生冲突,只会和共享表锁(*lock tables ... read*)和独占表锁(*lock tables ... write*)发生冲突。** 表锁和行锁是满足读读共享、读写互斥、写写互斥的。 如果没有「意向锁」,那么加「独占表锁」时,就需要遍历表里所有记录,查看是否有记录存在独占锁,这样效率会很慢。 那么有了「意向锁」,由于在对记录加独占锁前,先会加上表级别的意向独占锁,那么在加「独占表锁」时,直接查该表是否有意向独占锁,如果有就意味着表里已经有记录被加了独占锁,这样就不用去遍历表里的记录。 所以,**意向锁的目的是为了快速判断表里是否有记录被加锁**。 ### AUTO-INC 锁 表里的主键通常都会设置成自增的,这是通过对主键字段声明 `AUTO_INCREMENT` 属性实现的。 之后可以在插入数据时,可以不指定主键的值,数据库会自动给主键赋值递增的值,这主要是通过 **AUTO-INC 锁**实现的。 AUTO-INC 锁是特殊的表锁机制,锁**不是在一个事务提交后才释放,而是在执行完插入语句后就会立即释放**。 **在插入数据时,MySQL 会加一个表级别的 AUTO-INC 锁**,然后会为被 `AUTO_INCREMENT` 修饰的字段赋递增的值,等插入语句执行完成后,才会把 AUTO-INC 锁释放掉。 那么,一个事务在持有 AUTO-INC 锁的过程中,其他的事务如果要向该表插入语句都会被阻塞,从而保证插入数据时,被 `AUTO_INCREMENT` 修饰的字段的值是连续递增的。 但是,AUTO-INC 锁在对大量数据进行插入的时候,会影响插入性能,因为另一个事务中的插入会被阻塞。 因此,在 MySQL 5.1.22 版本开始,InnoDB 存储引擎提供了一种**轻量级的锁**来实现自增。 一样也是在插入数据的时候,会为被 `AUTO_INCREMENT` 修饰的字段加上轻量级锁,**然后给该字段赋值一个自增的值,就把这个轻量级锁释放了,而不需要等待整个插入语句执行完后才释放锁**。 InnoDB 存储引擎提供了个 innodb_autoinc_lock_mode 的系统变量,是用来控制选择用 AUTO-INC 锁,还是轻量级的锁。 - 当 innodb_autoinc_lock_mode = 0,就采用 AUTO-INC 锁,语句执行结束后才释放锁; - 当 innodb_autoinc_lock_mode = 2,就采用轻量级锁,申请自增主键后就释放锁,并不需要等语句执行后才释放。 - 当 innodb_autoinc_lock_mode = 1: - 普通 insert 语句,自增锁在申请之后就马上释放; - 类似 insert …… select 这样的批量插入数据的语句,自增锁还是要等语句结束后才被释放; 当 innodb_autoinc_lock_mode = 2 是性能最高的方式,但是当搭配 binlog 的日志格式是 statement 一起使用的时候,在「主从复制的场景」中会发生**数据不一致的问题**。 举个例子,考虑下面场景: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/innodb_autoinc_lock_mode=2.png) session A 往表 t 中插入了 4 行数据,然后创建了一个相同结构的表 t2,然后**两个 session 同时执行向表 t2 中插入数据**。 如果 innodb_autoinc_lock_mode = 2,意味着「申请自增主键后就释放锁,不必等插入语句执行完」。那么就可能出现这样的情况: - session B 先插入了两个记录,(1,1,1)、(2,2,2); - 然后,session A 来申请自增 id 得到 id=3,插入了(3,5,5); - 之后,session B 继续执行,插入两条记录 (4,3,3)、 (5,4,4)。 可以看到,**session B 的 insert 语句,生成的 id 不连续**。 当「主库」发生了这种情况,binlog 面对 t2 表的更新只会记录这两个 session 的 insert 语句,如果 binlog_format=statement,记录的语句就是原始语句。记录的顺序要么先记 session A 的 insert 语句,要么先记 session B 的 insert 语句。 但不论是哪一种,这个 binlog 拿去「从库」执行,这时从库是按「顺序」执行语句的,只有当执行完一条 SQL 语句后,才会执行下一条 SQL。因此,在**从库上「不会」发生像主库那样两个 session「同时」执行向表 t2 中插入数据的场景。所以,在备库上执行了 session B 的 insert 语句,生成的结果里面,id 都是连续的。这时,主从库就发生了数据不一致**。 要解决这问题,binlog 日志格式要设置为 row,这样在 binlog 里面记录的是主库分配的自增值,到备库执行的时候,主库的自增值是什么,从库的自增值就是什么。 所以,**当 innodb_autoinc_lock_mode = 2 时,并且 binlog_format = row,既能提升并发性,又不会出现数据一致性问题**。 ## 行级锁 InnoDB 引擎是支持行级锁的,而 MyISAM 引擎并不支持行级锁。 前面也提到,普通的 select 语句是不会对记录加锁的,因为它属于快照读。如果要在查询时对记录加行锁,可以使用下面这两个方式,这种查询会加锁的语句称为**锁定读**。 ```sql //对读取的记录加共享锁 select ... lock in share mode; //对读取的记录加独占锁 select ... for update; ``` 上面这两条语句必须在一个事务中,**因为当事务提交了,锁就会被释放**,所以在使用这两条语句的时候,要加上 begin、start transaction 或者 set autocommit = 0。 共享锁(S 锁)满足读读共享,读写互斥。独占锁(X 锁)满足写写互斥、读写互斥。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/x锁和s锁.png) 行级锁的类型主要有三类: - Record Lock,记录锁,也就是仅仅把一条记录锁上; - Gap Lock,间隙锁,锁定一个范围,但是不包含记录本身; - Next-Key Lock:Record Lock + Gap Lock 的组合,锁定一个范围,并且锁定记录本身。 ### Record Lock Record Lock 称为记录锁,锁住的是一条记录。而且记录锁是有 S 锁和 X 锁之分的: - 当一个事务对一条记录加了 S 型记录锁后,其他事务也可以继续对该记录加 S 型记录锁(S 型与 S 锁兼容),但是不可以对该记录加 X 型记录锁(S 型与 X 锁不兼容); - 当一个事务对一条记录加了 X 型记录锁后,其他事务既不可以对该记录加 S 型记录锁(S 型与 X 锁不兼容),也不可以对该记录加 X 型记录锁(X 型与 X 锁不兼容)。 举个例子,当一个事务执行了下面这条语句: ```sql mysql > begin; mysql > select * from t_test where id = 1 for update; ``` 就是对 t_test 表中主键 id 为 1 的这条记录加上 X 型的记录锁,这样其他事务就无法对这条记录进行修改了。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/记录锁.drawio.png) 当事务执行 commit 后,事务过程中生成的锁都会被释放。 ### Gap Lock Gap Lock 称为间隙锁,只存在于可重复读隔离级别,目的是为了解决可重复读隔离级别下幻读的现象。 假设,表中有一个范围 id 为(3,5)间隙锁,那么其他事务就无法插入 id = 4 这条记录了,这样就有效的防止幻读现象的发生。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/gap锁.drawio.png) 间隙锁虽然存在 X 型间隙锁和 S 型间隙锁,但是并没有什么区别,**间隙锁之间是兼容的,即两个事务可以同时持有包含共同间隙范围的间隙锁,并不存在互斥关系,因为间隙锁的目的是防止插入幻影记录而提出的**。 ### Next-Key Lock Next-Key Lock 称为临键锁,是 Record Lock + Gap Lock 的组合,锁定一个范围,并且锁定记录本身。 假设,表中有一个范围 id 为(3,5] 的 next-key lock,那么其他事务即不能插入 id = 4 记录,也不能修改 id = 5 这条记录。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/临键锁.drawio.png) 所以,next-key lock 即能保护该记录,又能阻止其他事务将新纪录插入到被保护记录前面的间隙中。 **next-key lock 是包含间隙锁 + 记录锁的,如果一个事务获取了 X 型的 next-key lock,那么另外一个事务在获取相同范围的 X 型的 next-key lock 时,是会被阻塞的**。 比如,一个事务持有了范围为 (1, 10] 的 X 型的 next-key lock,那么另外一个事务在获取相同范围的 X 型的 next-key lock 时,就会被阻塞。 虽然相同范围的间隙锁是多个事务相互兼容的,但对于记录锁,我们是要考虑 X 型与 S 型关系,X 型的记录锁与 X 型的记录锁是冲突的。 ### 插入意向锁 一个事务在插入一条记录的时候,需要判断插入位置是否已被其他事务加了间隙锁(next-key lock 也包含间隙锁)。 如果有的话,插入操作就会发生**阻塞**,直到拥有间隙锁的那个事务提交为止(释放间隙锁的时刻),在此期间会生成一个**插入意向锁**,表明有事务想在某个区间插入新记录,但是现在处于等待状态。 举个例子,假设事务 A 已经对表加了一个范围 id 为(3,5)间隙锁。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/gap锁.drawio.png) 当事务 A 还没提交的时候,事务 B 向该表插入一条 id = 4 的新记录,这时会判断到插入的位置已经被事务 A 加了间隙锁,于是事物 B 会生成一个插入意向锁,然后将锁的状态设置为等待状态(*PS:MySQL 加锁时,是先生成锁结构,然后设置锁的状态,如果锁状态是等待状态,并不是意味着事务成功获取到了锁,只有当锁状态为正常状态时,才代表事务成功获取到了锁*),此时事务 B 就会发生阻塞,直到事务 A 提交了事务。 插入意向锁名字虽然有意向锁,但是它并**不是意向锁,它是一种特殊的间隙锁,属于行级别锁**。 如果说间隙锁锁住的是一个区间,那么「插入意向锁」锁住的就是一个点。因而从这个角度来说,插入意向锁确实是一种特殊的间隙锁。 插入意向锁与间隙锁的另一个非常重要的差别是:尽管「插入意向锁」也属于间隙锁,但两个事务却不能在同一时间内,一个拥有间隙锁,另一个拥有该间隙区间内的插入意向锁(当然,插入意向锁如果不在间隙锁区间内则是可以的)。 --- 参考资料: - 《MySQL 技术内幕:innodb》 - 《MySQL 实战 45 讲》 - 《从根儿上理解 MySQL》 ---- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: mysql/lock/show_lock.md ================================================ # 字节面试:加了什么锁,导致死锁的? 大家好,我是小林。 之前收到读者面试字节时,被问到一个关于 MySQL 的问题。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/字节mysql面试题.png) ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/提问.png) 如果对 MySQL 加锁机制比较熟悉的同学,应该一眼就能看出**会发生死锁**,但是具体加了什么锁而导致死锁,是需要我们具体分析的。 接下来,就跟聊聊上面两个事务执行 SQL 语句的过程中,加了什么锁,从而导致死锁的。 ## 准备工作 先创建一张 t_student 表,假设除了 id 字段,其他字段都是普通字段。 ```sql CREATE TABLE `t_student` ( `id` int NOT NULL, `no` varchar(255) DEFAULT NULL, `name` varchar(255) DEFAULT NULL, `age` int DEFAULT NULL, `score` int DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` 然后,插入相关的数据后,t_student 表中的记录如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/t_student.png) ## 开始实验 在实验开始前,先说明下实验环境: - MySQL 版本:8.0.26 - 隔离级别:可重复读(RR) 启动两个事务,按照题目的 SQL 执行顺序,过程如下表格: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/ab事务死锁.drawio.png) 可以看到,事务 A 和 事务 B 都在执行 insert 语句后,都陷入了等待状态(前提没有打开死锁检测),也就是发生了死锁,因为都在相互等待对方释放锁。 ## 为什么会发生死锁? 我们可以通过 `select * from performance_schema.data_locks\G;` 这条语句,查看事务执行 SQL 过程中加了什么锁。 接下来,针对每一条 SQL 语句分析具体加了什么锁。 ### Time 1 阶段加锁分析 Time 1 阶段,事务 A 执行以下语句: ```sql # 事务 A mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> update t_student set score = 100 where id = 25; Query OK, 0 rows affected (0.01 sec) Rows matched: 0 Changed: 0 Warnings: 0 ``` 然后执行 `select * from performance_schema.data_locks\G;` 这条语句,查看事务 A 此时加了什么锁。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/事务a的锁.png) 从上图可以看到,共加了两个锁,分别是: - 表锁:X 类型的意向锁; - 行锁:X 类型的间隙锁; 这里我们重点关注行锁,图中 LOCK_TYPE 中的 RECORD 表示行级锁,而不是记录锁的意思,通过 LOCK_MODE 可以确认是 next-key 锁,还是间隙锁,还是记录锁: - 如果 LOCK_MODE 为 `X`,说明是 next-key 锁; - 如果 LOCK_MODE 为 `X, REC_NOT_GAP`,说明是记录锁; - 如果 LOCK_MODE 为 `X, GAP`,说明是间隙锁; **因此,此时事务 A 在主键索引(INDEX_NAME : PRIMARY)上加的是间隙锁,锁范围是`(20, 30)`**。 > 间隙锁的范围`(20, 30)` ,是怎么确定的? 根据我的经验,如果 LOCK_MODE 是 next-key 锁或者间隙锁,那么 LOCK_DATA 就表示锁的范围最右值,此次的事务 A 的 LOCK_DATA 是 30。 然后锁范围的最左值是 t_student 表中 id 为 30 的上一条记录的 id 值,即 20。 ![在这里插入图片描述](https://img-blog.csdnimg.cn/403f9c1012e84a4c83bfb2fc3990f177.png) 因此,间隙锁的范围`(20, 30)`。 ### Time 2 阶段加锁分析 Time 2 阶段,事务 B 执行以下语句: ```sql # 事务 B mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> update t_student set score = 100 where id = 26; Query OK, 0 rows affected (0.01 sec) Rows matched: 0 Changed: 0 Warnings: 0 ``` 然后执行 `select * from performance_schema.data_locks\G;` 这条语句,查看事务 B 此时加了什么锁。 ![在这里插入图片描述](https://img-blog.csdnimg.cn/44277cfefbd6446db861bfb81a1e4a59.png) 从上图可以看到,行锁是 X 类型的间隙锁,间隙锁的范围是`(20, 30)`。 > 事务 A 和 事务 B 的间隙锁范围都是一样的,为什么不会冲突? 两个事务的间隙锁之间是相互兼容的,不会产生冲突。 在 MySQL 官网上还有一段非常关键的描述: *Gap locks in InnoDB are “purely inhibitive”, which means that their only purpose is to prevent other transactions from Inserting to the gap. Gap locks can co-exist. A gap lock taken by one transaction does not prevent another transaction from taking a gap lock on the same gap. There is no difference between shared and exclusive gap locks. They do not conflict with each other, and they perform the same function.* **间隙锁的意义只在于阻止区间被插入**,因此是可以共存的。**一个事务获取的间隙锁不会阻止另一个事务获取同一个间隙范围的间隙锁**,共享(S 型)和排他(X 型)的间隙锁是没有区别的,他们相互不冲突,且功能相同。 ### Time 3 阶段加锁分析 Time 3,事务 A 插入了一条记录: ```sql # Time 3 阶段,事务 A 插入了一条记录 mysql> insert into t_student(id, no, name, age,score) value (25, 'S0025', 'sony', 28, 90); /// 阻塞等待...... ``` 此时,事务 A 就陷入了等待状态。 然后执行 `select * from performance_schema.data_locks\G;` 这条语句,查看事务 A 在获取什么锁而导致被阻塞。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/事务a等待中.png) 可以看到,事务 A 的状态为等待状态(LOCK_STATUS: WAITING),因为向事务 B 生成的间隙锁(范围 `(20, 30)`)中插入了一条记录,所以事务 A 的插入操作生成了一个插入意向锁(`LOCK_MODE:INSERT_INTENTION`)。 > 插入意向锁是什么? 注意!插入意向锁名字里虽然有意向锁这三个字,但是它并不是意向锁,它属于行级锁,是一种特殊的间隙锁。 在 MySQL 的官方文档中有以下重要描述: *An Insert intention lock is a type of gap lock set by Insert operations prior to row Insertion. This lock signals the intent to Insert in such a way that multiple transactions Inserting into the same index gap need not wait for each other if they are not Inserting at the same position within the gap. Suppose that there are index records with values of 4 and 7. Separate transactions that attempt to Insert values of 5 and 6, respectively, each lock the gap between 4 and 7 with Insert intention locks prior to obtaining the exclusive lock on the Inserted row, but do not block each other because the rows are nonconflicting.* 这段话表明尽管**插入意向锁是一种特殊的间隙锁,但不同于间隙锁的是,该锁只用于并发插入操作**。 如果说间隙锁锁住的是一个区间,那么「插入意向锁」锁住的就是一个点。因而从这个角度来说,插入意向锁确实是一种特殊的间隙锁。 插入意向锁与间隙锁的另一个非常重要的差别是:**尽管「插入意向锁」也属于间隙锁,但两个事务却不能在同一时间内,一个拥有间隙锁,另一个拥有该间隙区间内的插入意向锁(当然,插入意向锁如果不在间隙锁区间内则是可以的)。所以,插入意向锁和间隙锁之间是冲突的**。 另外,我补充一点,插入意向锁的生成时机: - 每插入一条新记录,都需要看一下待插入记录的下一条记录上是否已经被加了间隙锁,如果已加间隙锁,此时会生成一个插入意向锁,然后锁的状态设置为等待状态(*PS:MySQL 加锁时,是先生成锁结构,然后设置锁的状态,如果锁状态是等待状态,并不是意味着事务成功获取到了锁,只有当锁状态为正常状态时,才代表事务成功获取到了锁*),现象就是 Insert 语句会被阻塞。 ### Time 4 阶段加锁分析 Time 4,事务 B 插入了一条记录: ```sql # Time 4 阶段,事务 B 插入了一条记录 mysql> insert into t_student(id, no, name, age,score) value (26, 'S0026', 'ace', 28, 90); /// 阻塞等待...... ``` 此时,事务 B 就陷入了等待状态。 然后执行 `select * from performance_schema.data_locks\G;` 这条语句,查看事务 B 在获取什么锁而导致被阻塞。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/事务b等待中.png) 可以看到,事务 B 在生成插入意向锁时而导致被阻塞,这是因为事务 B 向事务 A 生成的范围为 (20, 30) 的间隙锁插入了一条记录,而插入意向锁和间隙锁是冲突的,所以事务 B 在获取插入意向锁时就陷入了等待状态。 > 最后回答,为什么会发生死锁? 本次案例中,事务 A 和事务 B 在执行完后 update 语句后都持有范围为`(20, 30)`的间隙锁,而接下来的插入操作为了获取到插入意向锁,都在等待对方事务的间隙锁释放,于是就造成了循环等待,满足了死锁的四个条件:**互斥、占有且等待、不可强占用、循环等待**,因此发生了死锁。 ## 总结 两个事务即使生成的间隙锁的范围是一样的,也不会发生冲突,因为间隙锁目的是为了防止其他事务插入数据,因此间隙锁与间隙锁之间是相互兼容的。 在执行插入语句时,如果插入的记录在其他事务持有间隙锁范围内,插入语句就会被阻塞,因为插入语句在碰到间隙锁时,会生成一个插入意向锁,然后插入意向锁和间隙锁之间是互斥的关系。 如果两个事务分别向对方持有的间隙锁范围内插入一条记录,而插入操作为了获取到插入意向锁,都在等待对方事务的间隙锁释放,于是就造成了循环等待,满足了死锁的四个条件:**互斥、占有且等待、不可强占用、循环等待**,因此发生了死锁。 ## 读者问答 ![在这里插入图片描述](https://img-blog.csdnimg.cn/f4d4d7fdb9074b098b1077acff698aea.png) --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: mysql/lock/update_index.md ================================================ # update 没加索引会锁全表? 大家好,我是小林。 昨晚在群划水的时候,看到有位读者说了这么一件事。 ![在这里插入图片描述](https://img-blog.csdnimg.cn/202e1521bc02411698eb6162cf121114.png) 大概就是,在线上执行一条 update 语句修改数据库数据的时候,where 条件没有带上索引,导致业务直接崩了,被老板教训了一波 这次我们就来看看: - 为什么会发生这种的事故? - 又该如何避免这种事故的发生? 说个前提,接下来说的案例都是基于 InnoDB 存储引擎,且事务的隔离级别是可重复读。 ## 为什么会发生这种的事故? InnoDB 存储引擎的默认事务隔离级别是「可重复读」,但是在这个隔离级别下,在多个事务并发的时候,会出现幻读的问题,所谓的幻读是指在同一事务下,连续执行两次同样的查询语句,第二次的查询语句可能会返回之前不存在的行。 因此 InnoDB 存储引擎自己实现了行锁,通过 next-key 锁(记录锁和间隙锁的组合)来锁住记录本身和记录之间的“间隙”,防止其他事务在这个记录之间插入新的记录,从而避免了幻读现象。 当我们执行 update 语句时,实际上是会对记录加独占锁(X 锁)的,如果其他事务对持有独占锁的记录进行修改时是会被阻塞的。另外,这个锁并不是执行完 update 语句就会释放的,而是会等事务结束时才会释放。 在 InnoDB 事务中,对记录加锁带基本单位是 next-key 锁,但是会因为一些条件会退化成间隙锁,或者记录锁。加锁的位置准确的说,锁是加在索引上的而非行上。 比如,在 update 语句的 where 条件使用了唯一索引,那么 next-key 锁会退化成记录锁,也就是只会给一行记录加锁。 这里举个例子,这里有一张数据库表,其中 id 为主键索引。 ![](https://img-blog.csdnimg.cn/img_convert/3c3af16e7a948833ccb6409e8b51daf8.png) 假设有两个事务的执行顺序如下: ![在这里插入图片描述](https://img-blog.csdnimg.cn/d2326f98cbb34fc09ca4013703251501.png) 可以看到,事务 A 的 update 语句中 where 是等值查询,并且 id 是唯一索引,所以只会对 id = 1 这条记录加锁,因此,事务 B 的更新操作并不会阻塞。 但是,**在 update 语句的 where 条件没有使用索引,就会全表扫描,于是就会对所有记录加上 next-key 锁(记录锁 + 间隙锁),相当于把整个表锁住了**。 假设有两个事务的执行顺序如下: ![](https://img-blog.csdnimg.cn/img_convert/1aa886fe95e7bc791c296e2d342fa435.png) 可以看到,这次事务 B 的 update 语句被阻塞了。 这是因为事务 A 的 update 语句中 where 条件没有索引列,触发了全表扫描,在扫描过程中会对索引加锁,所以全表扫描的场景下,所有记录都会被加锁,也就是这条 update 语句产生了 4 个记录锁和 5 个间隙锁,相当于锁住了全表。 ![](https://img-blog.csdnimg.cn/img_convert/63e055617720853f5b64c99576227c09.png) 因此,当在数据量非常大的数据库表执行 update 语句时,如果没有使用索引,就会给全表的加上 next-key 锁,那么锁就会持续很长一段时间,直到事务结束,而这期间除了 `select ... from`语句,其他语句都会被锁住不能执行,业务会因此停滞,接下来等着你的,就是老板的挨骂。 那 update 语句的 where 带上索引就能避免全表记录加锁了吗? 并不是。 **关键还得看这条语句在执行过程种,优化器最终选择的是索引扫描,还是全表扫描,如果走了全表扫描,就会对全表的记录加锁了**。 :::tip 网上很多资料说,update 没加锁索引会加表锁,这是不对的。 Innodb 源码里面在扫描记录的时候,都是针对索引项这个单位去加锁的,update 不带索引就是全表扫扫描,也就是表里的索引项都加锁,相当于锁了整张表,所以大家误以为加了表锁。 ::: ## 如何避免这种事故的发生? 我们可以将 MySQL 里的 `sql_safe_updates` 参数设置为 1,开启安全更新模式。 > 官方的解释: > If set to 1, MySQL aborts UPDATE or DELETE statements that do not use a key in the WHERE clause or a LIMIT clause. (Specifically, UPDATE statements must have a WHERE clause that uses a key or a LIMIT clause, or both. DELETE statements must have both.) This makes it possible to catch UPDATE or DELETE statements where keys are not used properly and that would probably change or delete a large number of rows. The default value is 0. 大致的意思是,当 sql_safe_updates 设置为 1 时。 update 语句必须满足如下条件之一才能执行成功: - 使用 where,并且 where 条件中必须有索引列; - 使用 limit; - 同时使用 where 和 limit,此时 where 条件中可以没有索引列; delete 语句必须满足以下条件能执行成功: - 同时使用 where 和 limit,此时 where 条件中可以没有索引列; 如果 where 条件带上了索引列,但是优化器最终扫描选择的是全表,而不是索引的话,我们可以使用 `force index([index_name])` 可以告诉优化器使用哪个索引,以此避免有几率锁全表带来的隐患。 ## 总结 不要小看一条 update 语句,在生产机上使用不当可能会导致业务停滞,甚至崩溃。 当我们要执行 update 语句的时候,确保 where 条件中带上了索引列,并且在测试机确认该语句是否走的是索引扫描,防止因为扫描全表,而对表中的所有记录加上锁。 我们可以打开 MySQL sql_safe_updates 参数,这样可以预防 update 操作时 where 条件没有带上索引列。 如果发现即使在 where 条件中带上了索引列,优化器走的还是全表扫描,这时我们就要使用 `force index([index_name])` 可以告诉优化器使用哪个索引。 这次就说到这啦,下次要小心点,别再被老板挨骂啦。 ---- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: mysql/log/README.md ================================================ redo log、binlog、undolog 正在赶稿的路上。。。。。 ================================================ FILE: mysql/log/how_update.md ================================================ # MySQL 日志:undo log、redo log、binlog 有什么用? 大家好,我是小林。 从这篇「[执行一条 SQL 查询语句,期间发生了什么?](https://xiaolincoding.com/mysql/base/how_select.html)」中,我们知道了一条查询语句经历的过程,这属于「读」一条记录的过程,如下图: ![查询语句执行流程](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/sql%E6%89%A7%E8%A1%8C%E8%BF%87%E7%A8%8B/mysql%E6%9F%A5%E8%AF%A2%E6%B5%81%E7%A8%8B.png) 那么,**执行一条 update 语句,期间发生了什么?**,比如这一条 update 语句: ```sql UPDATE t_user SET name = 'xiaolin' WHERE id = 1; ``` 查询语句的那一套流程,更新语句也是同样会走一遍: - 客户端先通过连接器建立连接,连接器自会判断用户身份; - 因为这是一条 update 语句,所以不需要经过查询缓存,但是表上有更新语句,是会把整个表的查询缓存清空的,所以说查询缓存很鸡肋,在 MySQL 8.0 就被移除这个功能了; - 解析器会通过词法分析识别出关键字 update,表名等等,构建出语法树,接着还会做语法分析,判断输入的语句是否符合 MySQL 语法; - 预处理器会判断表和字段是否存在; - 优化器确定执行计划,因为 where 条件中的 id 是主键索引,所以决定要使用 id 这个索引; - 执行器负责具体执行,找到这一行,然后更新。 不过,更新语句的流程会涉及到 undo log(回滚日志)、redo log(重做日志) 、binlog(归档日志)这三种日志: - **undo log(回滚日志)**:是 Innodb 存储引擎层生成的日志,实现了事务中的**原子性**,主要**用于事务回滚和 MVCC**。 - **redo log(重做日志)**:是 Innodb 存储引擎层生成的日志,实现了事务中的**持久性**,主要**用于掉电等故障恢复**; - **binlog(归档日志)**:是 Server 层生成的日志,主要**用于数据备份和主从复制**; 所以这次就带着这个问题,看看这三种日志是怎么工作的。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/提纲.png) ## 为什么需要 undo log? 我们在执行执行一条“增删改”语句的时候,虽然没有输入 begin 开启事务和 commit 提交事务,但是 MySQL 会**隐式开启事务**来执行“增删改”语句的,执行完就自动提交事务的,这样就保证了执行完“增删改”语句后,我们可以及时在数据库表看到“增删改”的结果了。 执行一条语句是否自动提交事务,是由 `autocommit` 参数决定的,默认是开启。所以,执行一条 update 语句也是会使用事务的。 那么,考虑一个问题。一个事务在执行过程中,在还没有提交事务之前,如果 MySQL 发生了崩溃,要怎么回滚到事务之前的数据呢? 如果我们每次在事务执行过程中,都记录下回滚时需要的信息到一个日志里,那么在事务执行中途发生了 MySQL 崩溃后,就不用担心无法回滚到事务之前的数据,我们可以通过这个日志回滚到事务之前的数据。 实现这一机制就是 **undo log(回滚日志),它保证了事务的 [ACID 特性](https://xiaolincoding.com/mysql/transaction/mvcc.html#%E4%BA%8B%E5%8A%A1%E6%9C%89%E5%93%AA%E4%BA%9B%E7%89%B9%E6%80%A7)中的原子性(Atomicity)**。 undo log 是一种用于撤销回退的日志。在事务没提交之前,MySQL 会先记录更新前的数据到 undo log 日志文件里面,当事务回滚时,可以利用 undo log 来进行回滚。如下图: ![回滚事务](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/回滚事务.png?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 每当 InnoDB 引擎对一条记录进行操作(修改、删除、新增)时,要把回滚时需要的信息都记录到 undo log 里,比如: - 在**插入**一条记录时,要把这条记录的主键值记下来,这样之后回滚时只需要把这个主键值对应的记录**删掉**就好了; - 在**删除**一条记录时,要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录**插入**到表中就好了; - 在**更新**一条记录时,要把被更新的列的旧值记下来,这样之后回滚时再把这些列**更新为旧值**就好了。 在发生回滚时,就读取 undo log 里的数据,然后做原先相反操作。比如当 delete 一条记录时,undo log 中会把记录中的内容都记下来,然后执行回滚操作的时候,就读取 undo log 里的数据,然后进行 insert 操作。 不同的操作,需要记录的内容也是不同的,所以不同类型的操作(修改、删除、新增)产生的 undo log 的格式也是不同的,具体的每一个操作的 undo log 的格式我就不详细介绍了,感兴趣的可以自己去查查。 一条记录的每一次更新操作产生的 undo log 格式都有一个 roll_pointer 指针和一个 trx_id 事务 id: - 通过 trx_id 可以知道该记录是被哪个事务修改的; - 通过 roll_pointer 指针可以将这些 undo log 串成一个链表,这个链表就被称为版本链; 版本链如下图: ![版本链](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/版本链.png?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 另外,**undo log 还有一个作用,通过 ReadView + undo log 实现 MVCC(多版本并发控制)**。 对于「读提交」和「可重复读」隔离级别的事务来说,它们的快照读(普通 select 语句)是通过 Read View + undo log 来实现的,它们的区别在于创建 Read View 的时机不同: - 「读提交」隔离级别是在每个 select 都会生成一个新的 Read View,也意味着,事务期间的多次读取同一条数据,前后两次读的数据可能会出现不一致,因为可能这期间另外一个事务修改了该记录,并提交了事务。 - 「可重复读」隔离级别是启动事务时生成一个 Read View,然后整个事务期间都在用这个 Read View,这样就保证了在事务期间读到的数据都是事务启动前的记录。 这两个隔离级别实现是通过「事务的 Read View 里的字段」和「记录中的两个隐藏列(trx_id 和 roll_pointer)」的比对,如果不满足可见行,就会顺着 undo log 版本链里找到满足其可见性的记录,从而控制并发事务访问同一个记录时的行为,这就叫 MVCC(多版本并发控制)。具体的实现可以看我这篇文章:[事务隔离级别是怎么实现的?](https://xiaolincoding.com/mysql/transaction/mvcc.html#%E4%BA%8B%E5%8A%A1%E7%9A%84%E9%9A%94%E7%A6%BB%E7%BA%A7%E5%88%AB%E6%9C%89%E5%93%AA%E4%BA%9B) 因此,undo log 两大作用: - **实现事务回滚,保障事务的原子性**。事务处理过程中,如果出现了错误或者用户执 行了 ROLLBACK 语句,MySQL 可以利用 undo log 中的历史数据将数据恢复到事务开始之前的状态。 - **实现 MVCC(多版本并发控制)关键因素之一**。MVCC 是通过 ReadView + undo log 实现的。undo log 为每条记录保存多份历史数据,MySQL 在执行快照读(普通 select 语句)的时候,会根据事务的 Read View 里的信息,顺着 undo log 的版本链找到满足其可见性的记录。 ## 为什么需要 Buffer Pool? MySQL 的数据都是存在磁盘中的,那么我们要更新一条记录的时候,得先要从磁盘读取该记录,然后在内存中修改这条记录。那修改完这条记录是选择直接写回到磁盘,还是选择缓存起来呢? 当然是缓存起来好,这样下次有查询语句命中了这条记录,直接读取缓存中的记录,就不需要从磁盘获取数据了。 为此,Innodb 存储引擎设计了一个**缓冲池(Buffer Pool)**,来提高数据库的读写性能。 ![Buffer Poo](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/%E7%BC%93%E5%86%B2%E6%B1%A0.drawio.png?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 有了 Buffer Pool 后: - 当读取数据时,如果数据存在于 Buffer Pool 中,客户端就会直接读取 Buffer Pool 中的数据,否则再去磁盘中读取。 - 当修改数据时,如果数据存在于 Buffer Pool 中,那直接修改 Buffer Pool 中数据所在的页,然后将其页设置为脏页(该页的内存数据和磁盘上的数据已经不一致),为了减少磁盘 I/O,不会立即将脏页写入磁盘,后续由后台线程选择一个合适的时机将脏页写入到磁盘。 ### Buffer Pool 缓存什么? InnoDB 会把存储的数据划分为若干个「页」,以页作为磁盘和内存交互的基本单位,一个页的默认大小为 16KB。因此,Buffer Pool 同样需要按「页」来划分。 在 MySQL 启动的时候,**InnoDB 会为 Buffer Pool 申请一片连续的内存空间,然后按照默认的`16KB`的大小划分出一个个的页,Buffer Pool 中的页就叫做缓存页**。此时这些缓存页都是空闲的,之后随着程序的运行,才会有磁盘上的页被缓存到 Buffer Pool 中。 所以,MySQL 刚启动的时候,你会观察到使用的虚拟内存空间很大,而使用到的物理内存空间却很小,这是因为只有这些虚拟内存被访问后,操作系统才会触发缺页中断,申请物理内存,接着将虚拟地址和物理地址建立映射关系。 Buffer Pool 除了缓存「索引页」和「数据页」,还包括了 Undo 页,插入缓存、自适应哈希索引、锁信息等等。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/bufferpool%E5%86%85%E5%AE%B9.drawio.png?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) > Undo 页是记录什么? 开启事务后,InnoDB 层更新记录前,首先要记录相应的 undo log,如果是更新操作,需要把被更新的列的旧值记下来,也就是要生成一条 undo log,undo log 会写入 Buffer Pool 中的 Undo 页面。 > 查询一条记录,就只需要缓冲一条记录吗? 不是的。 当我们查询一条记录时,InnoDB 是会把整个页的数据加载到 Buffer Pool 中,将页加载到 Buffer Pool 后,再通过页里的「页目录」去定位到某条具体的记录。 关于页结构长什么样和索引怎么查询数据的问题可以在这篇找到答案:[换一个角度看 B+ 树](https://mp.weixin.qq.com/s/A5gNVXMNE-iIlY3oofXtLw) ## 为什么需要 redo log? Buffer Pool 是提高了读写效率没错,但是问题来了,Buffer Pool 是基于内存的,而内存总是不可靠,万一断电重启,还没来得及落盘的脏页数据就会丢失。 为了防止断电导致数据丢失的问题,当有一条记录需要更新的时候,InnoDB 引擎就会先更新内存(同时标记为脏页),然后将本次对这个页的修改以 redo log 的形式记录下来,**这个时候更新就算完成了**。 后续,InnoDB 引擎会在适当的时候,由后台线程将缓存在 Buffer Pool 的脏页刷新到磁盘里,这就是 **WAL(Write-Ahead Logging)技术**。 **WAL 技术指的是,MySQL 的写操作并不是立刻写到磁盘上,而是先写日志,然后在合适的时间再写到磁盘上**。 过程如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/wal.png?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) > 什么是 redo log? redo log 是物理日志,记录了某个数据页做了什么修改,比如**对 XXX 表空间中的 YYY 数据页 ZZZ 偏移量的地方做了 AAA 更新**,每当执行一个事务就会产生这样的一条或者多条物理日志。 在事务提交时,只要先将 redo log 持久化到磁盘即可,可以不需要等到将缓存在 Buffer Pool 里的脏页数据持久化到磁盘。 当系统崩溃时,虽然脏页数据没有持久化,但是 redo log 已经持久化,接着 MySQL 重启后,可以根据 redo log 的内容,将所有数据恢复到最新的状态。 > 被修改 Undo 页面,需要记录对应 redo log 吗? 需要的。 开启事务后,InnoDB 层更新记录前,首先要记录相应的 undo log,如果是更新操作,需要把被更新的列的旧值记下来,也就是要生成一条 undo log,undo log 会写入 Buffer Pool 中的 Undo 页面。 不过,**在内存修改该 Undo 页面后,需要记录对应的 redo log**。 > redo log 和 undo log 区别在哪? 这两种日志是属于 InnoDB 存储引擎的日志,它们的区别在于: - redo log 记录了此次事务「**完成后**」的数据状态,记录的是更新**之后**的值; - undo log 记录了此次事务「**开始前**」的数据状态,记录的是更新**之前**的值; 事务提交之前发生了崩溃,重启后会通过 undo log 回滚事务,事务提交之后发生了崩溃,重启后会通过 redo log 恢复事务,如下图: ![事务恢复](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/事务恢复.png?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 所以有了 redo log,再通过 WAL 技术,InnoDB 就可以保证即使数据库发生异常重启,之前已提交的记录都不会丢失,这个能力称为 **crash-safe**(崩溃恢复)。可以看出来, **redo log 保证了事务四大特性中的持久性**。 > redo log 要写到磁盘,数据也要写磁盘,为什么要多此一举? 写入 redo log 的方式使用了追加操作,所以磁盘操作是**顺序写**,而写入数据需要先找到写入位置,然后才写到磁盘,所以磁盘操作是**随机写**。 磁盘的「顺序写」比「随机写」高效的多,因此 redo log 写入磁盘的开销更小。 针对「顺序写」为什么比「随机写」更快这个问题,可以比喻为你有一个本子,按照顺序一页一页写肯定比写一个字都要找到对应页写快得多。 可以说这是 WAL 技术的另外一个优点:**MySQL 的写操作从磁盘的「随机写」变成了「顺序写」**,提升语句的执行性能。这是因为 MySQL 的写操作并不是立刻更新到磁盘上,而是先记录在日志上,然后在合适的时间再更新到磁盘上。 至此,针对为什么需要 redo log 这个问题我们有两个答案: - **实现事务的持久性,让 MySQL 有 crash-safe 的能力**,能够保证 MySQL 在任何时间段突然崩溃,重启后之前已提交的记录都不会丢失; - **将写操作从「随机写」变成了「顺序写」**,提升 MySQL 写入磁盘的性能。 > 产生的 redo log 是直接写入磁盘的吗? 不是的。 实际上,执行一个事务的过程中,产生的 redo log 也不是直接写入磁盘的,因为这样会产生大量的 I/O 操作,而且磁盘的运行速度远慢于内存。 所以,redo log 也有自己的缓存—— **redo log buffer**,每当产生一条 redo log 时,会先写入到 redo log buffer,后续在持久化到磁盘如下图: ![事务恢复](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/redologbuf.webp) redo log buffer 默认大小 16 MB,可以通过 `innodb_log_Buffer_size` 参数动态的调整大小,增大它的大小可以让 MySQL 处理「大事务」是不必写入磁盘,进而提升写 IO 性能。 ### redo log 什么时候刷盘? 缓存在 redo log buffer 里的 redo log 还是在内存中,它什么时候刷新到磁盘? 主要有下面几个时机: - MySQL 正常关闭时; - 当 redo log buffer 中记录的写入量大于 redo log buffer 内存空间的一半时,会触发落盘; - InnoDB 的后台线程每隔 1 秒,将 redo log buffer 持久化到磁盘。 - 每次事务提交时都将缓存在 redo log buffer 里的 redo log 直接持久化到磁盘(这个策略可由 innodb_flush_log_at_trx_commit 参数控制,下面会说)。 > innodb_flush_log_at_trx_commit 参数控制的是什么? 单独执行一个更新语句的时候,InnoDB 引擎会自己启动一个事务,在执行更新语句的过程中,生成的 redo log 先写入到 redo log buffer 中,然后等事务提交的时候,再将缓存在 redo log buffer 中的 redo log 按组的方式「顺序写」到磁盘。 上面这种 redo log 刷盘时机是在事务提交的时候,这个默认的行为。 除此之外,InnoDB 还提供了另外两种策略,由参数 `innodb_flush_log_at_trx_commit` 参数控制,可取的值有:0、1、2,默认值为 1,这三个值分别代表的策略如下: - 当设置该**参数为 0 时**,表示每次事务提交时,还是**将 redo log 留在 redo log buffer 中** ,该模式下在事务提交时不会主动触发写入磁盘的操作。 - 当设置该**参数为 1 时**,表示每次事务提交时,都**将缓存在 redo log buffer 里的 redo log 直接持久化到磁盘**,这样可以保证 MySQL 异常重启之后数据不会丢失。 - 当设置该**参数为 2 时**,表示每次事务提交时,都只是缓存在 redo log buffer 里的 redo log **写到 redo log 文件,注意写入到「redo log 文件」并不意味着写入到了磁盘**,因为操作系统的文件系统中有个 Page Cache(如果你想了解 Page Cache,可以看[这篇](https://xiaolincoding.com/os/6_file_system/pagecache.html) ),Page Cache 是专门用来缓存文件数据的,所以写入「redo log 文件」意味着写入到了操作系统的文件缓存。 我画了一个图,方便大家理解: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/innodb_flush_log_at_trx_commit.drawio.png?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) > innodb_flush_log_at_trx_commit 为 0 和 2 的时候,什么时候才将 redo log 写入磁盘? InnoDB 的后台线程每隔 1 秒: - 针对参数 0:会把缓存在 redo log buffer 中的 redo log,通过调用 `write()` 写到操作系统的 Page Cache,然后调用 `fsync()` 持久化到磁盘。**所以参数为 0 的策略,MySQL 进程的崩溃会导致上一秒钟所有事务数据的丢失**; - 针对参数 2:调用 fsync,将缓存在操作系统中 Page Cache 里的 redo log 持久化到磁盘。**所以参数为 2 的策略,较取值为 0 情况下更安全,因为 MySQL 进程的崩溃并不会丢失数据,只有在操作系统崩溃或者系统断电的情况下,上一秒钟所有事务数据才可能丢失**。 加入了后台现线程后,innodb_flush_log_at_trx_commit 的刷盘时机如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/innodb_flush_log_at_trx_commit2.drawio.png?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) > 这三个参数的应用场景是什么? 这三个参数的数据安全性和写入性能的比较如下: - 数据安全性:参数 1 > 参数 2 > 参数 0 - 写入性能:参数 0 > 参数 2> 参数 1 所以,数据安全性和写入性能是熊掌不可得兼的,**要不追求数据安全性,牺牲性能;要不追求性能,牺牲数据安全性**。 - 在一些对数据安全性要求比较高的场景中,显然 `innodb_flush_log_at_trx_commit` 参数需要设置为 1。 - 在一些可以容忍数据库崩溃时丢失 1s 数据的场景中,我们可以将该值设置为 0,这样可以明显地减少日志同步到磁盘的 I/O 操作。 - 安全性和性能折中的方案就是参数 2,虽然参数 2 没有参数 0 的性能高,但是数据安全性方面比参数 0 强,因为参数 2 只要操作系统不宕机,即使数据库崩溃了,也不会丢失数据,同时性能方面比参数 1 高。 ### redo log 文件写满了怎么办? 默认情况下,InnoDB 存储引擎有 1 个重做日志文件组 ( redo log Group),「重做日志文件组」由有 2 个 redo log 文件组成,这两个 redo 日志的文件名叫:`ib_logfile0` 和 `ib_logfile1` 。 ![重做日志文件组](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/重做日志文件组.drawio.png) 在重做日志组中,每个 redo log File 的大小是固定且一致的,假设每个 redo log File 设置的上限是 1 GB,那么总共就可以记录 2GB 的操作。 重做日志文件组是以**循环写**的方式工作的,从头开始写,写到末尾就又回到开头,相当于一个环形。 所以 InnoDB 存储引擎会先写 ib_logfile0 文件,当 ib_logfile0 文件被写满的时候,会切换至 ib_logfile1 文件,当 ib_logfile1 文件也被写满时,会切换回 ib_logfile0 文件。 ![重做日志文件组写入过程](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/重做日志文件组写入过程.drawio.png) 我们知道 redo log 是为了防止 Buffer Pool 中的脏页丢失而设计的,那么如果随着系统运行,Buffer Pool 的脏页刷新到了磁盘中,那么 redo log 对应的记录也就没用了,这时候我们擦除这些旧记录,以腾出空间记录新的更新操作。 redo log 是循环写的方式,相当于一个环形,InnoDB 用 write pos 表示 redo log 当前记录写到的位置,用 check point 表示当前要擦除的位置,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/checkpoint.png) 图中的: - write pos 和 check point 的移动都是顺时针方向; - write pos ~ check point 之间的部分(图中的红色部分),用来记录新的更新操作; - check point ~ write pos 之间的部分(图中蓝色部分):待落盘的脏数据页记录; 如果 write pos 追上了 check point,就意味着 **redo log 文件满了,这时 MySQL 不能再执行新的更新操作,也就是说 MySQL 会被阻塞**(*因此所以针对并发量大的系统,适当设置 redo log 的文件大小非常重要*),此时**会停下来将 Buffer Pool 中的脏页刷新到磁盘中,然后标记 redo log 哪些记录可以被擦除,接着对旧的 redo log 记录进行擦除,等擦除完旧记录腾出了空间,check point 就会往后移动(图中顺时针)**,然后 MySQL 恢复正常运行,继续执行新的更新操作。 所以,一次 check point 的过程就是脏页刷新到磁盘中变成干净页,然后标记 redo log 哪些记录可以被覆盖的过程。 ## 为什么需要 binlog? 前面介绍的 undo log 和 redo log 这两个日志都是 Innodb 存储引擎生成的。 MySQL 在完成一条更新操作后,Server 层还会生成一条 binlog,等之后事务提交的时候,会将该事务执行过程中产生的所有 binlog 统一写 入 binlog 文件。 binlog 文件是记录了所有数据库表结构变更和表数据修改的日志,不会记录查询类的操作,比如 SELECT 和 SHOW 操作。 > 为什么有了 binlog,还要有 redo log? 这个问题跟 MySQL 的时间线有关系。 最开始 MySQL 里并没有 InnoDB 引擎,MySQL 自带的引擎是 MyISAM,但是 MyISAM 没有 crash-safe 的能力,binlog 日志只能用于归档。 而 InnoDB 是另一个公司以插件形式引入 MySQL 的,既然只依靠 binlog 是没有 crash-safe 能力的,所以 InnoDB 使用 redo log 来实现 crash-safe 能力。 ### redo log 和 binlog 有什么区别? 这两个日志有四个区别。 *1、适用对象不同:* - binlog 是 MySQL 的 Server 层实现的日志,所有存储引擎都可以使用; - redo log 是 Innodb 存储引擎实现的日志; *2、文件格式不同:* - binlog 有 3 种格式类型,分别是 STATEMENT、ROW、MIXED,区别如下: > 在 MySQL 5.7.7 之前,`binlog_format`的默认值是`STATEMENT`,而在 5.7.7 及之后的版本,默认值为`ROW`。 - STATEMENT:每一条修改数据的 SQL 都会被记录到 binlog 中(相当于记录了逻辑操作,所以针对这种格式,binlog 可以称为逻辑日志),主从复制中 slave 端再根据 SQL 语句重现。但 STATEMENT 有动态函数的问题,比如你用了 uuid 或者 now 这些函数,你在主库上执行的结果并不是你在从库执行的结果,这种随时在变的函数会导致复制的数据不一致; - ROW:记录行数据最终被修改成什么样了(这种格式的日志,就不能称为逻辑日志了),不会出现 STATEMENT 下动态函数的问题。但 ROW 的缺点是每行数据的变化结果都会被记录,比如执行批量 update 语句,更新多少行数据就会产生多少条记录,使 binlog 文件过大,而在 STATEMENT 格式下只会记录一个 update 语句而已; - MIXED:包含了 STATEMENT 和 ROW 模式,它会根据不同的情况自动使用 ROW 模式和 STATEMENT 模式; - redo log 是物理日志,记录的是在某个数据页做了什么修改,比如对 XXX 表空间中的 YYY 数据页 ZZZ 偏移量的地方做了 AAA 更新; *3、写入方式不同:* - binlog 是追加写,写满一个文件,就创建一个新的文件继续写,不会覆盖以前的日志,保存的是全量的日志。 - redo log 是循环写,日志空间大小是固定,全部写满就从头开始,保存未被刷入磁盘的脏页日志。 *4、用途不同:* - binlog 用于备份恢复、主从复制; - redo log 用于掉电等故障恢复。 > 如果不小心整个数据库的数据被删除了,能使用 redo log 文件恢复数据吗? 不可以使用 redo log 文件恢复,只能使用 binlog 文件恢复。 因为 redo log 文件是循环写,是会边写边擦除日志的,只记录未被刷入磁盘的数据的物理日志,已经刷入磁盘的数据都会从 redo log 文件里擦除。 binlog 文件保存的是全量的日志,也就是保存了所有数据变更的情况,理论上只要记录在 binlog 上的数据,都可以恢复,所以如果不小心整个数据库的数据被删除了,得用 binlog 文件恢复数据。 ### 主从复制是怎么实现? MySQL 的主从复制依赖于 binlog,也就是记录 MySQL 上的所有变化并以二进制形式保存在磁盘上。复制的过程就是将 binlog 中的数据从主库传输到从库上。 这个过程一般是**异步**的,也就是主库上执行事务操作的线程不会等待复制 binlog 的线程同步完成。 ![MySQL 主从复制过程](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/主从复制过程.drawio.png?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) MySQL 集群的主从复制过程梳理成 3 个阶段: - **写入 Binlog**:主库写 binlog 日志,提交事务,并更新本地存储数据。 - **同步 Binlog**:把 binlog 复制到所有从库上,每个从库把 binlog 写到暂存日志中。 - **回放 Binlog**:回放 binlog,并更新存储引擎中的数据。 具体详细过程如下: - MySQL 主库在收到客户端提交事务的请求之后,会先写入 binlog,再提交事务,更新存储引擎中的数据,事务提交完成后,返回给客户端“操作成功”的响应。 - 从库会创建一个专门的 I/O 线程,连接主库的 log dump 线程,来接收主库的 binlog 日志,再把 binlog 信息写入 relay log 的中继日志里,再返回给主库“复制成功”的响应。 - 从库会创建一个用于回放 binlog 的线程,去读 relay log 中继日志,然后回放 binlog 更新存储引擎中的数据,最终实现主从的数据一致性。 在完成主从复制之后,你就可以在写数据时只写主库,在读数据时只读从库,这样即使写请求会锁表或者锁记录,也不会影响读请求的执行。 ![MySQL 主从架构](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/主从架构.drawio.png?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) > 从库是不是越多越好? 不是的。 因为从库数量增加,从库连接上来的 I/O 线程也比较多,**主库也要创建同样多的 log dump 线程来处理复制的请求,对主库资源消耗比较高,同时还受限于主库的网络带宽**。 所以在实际使用中,一个主库一般跟 2~3 个从库(1 套数据库,1 主 2 从 1 备主),这就是一主多从的 MySQL 集群结构。 > MySQL 主从复制还有哪些模型? 主要有三种: - **同步复制**:MySQL 主库提交事务的线程要等待所有从库的复制成功响应,才返回客户端结果。这种方式在实际项目中,基本上没法用,原因有两个:一是性能很差,因为要复制到所有节点才返回响应;二是可用性也很差,主库和所有从库任何一个数据库出问题,都会影响业务。 - **异步复制**(默认模型):MySQL 主库提交事务的线程并不会等待 binlog 同步到各从库,就返回客户端结果。这种模式一旦主库宕机,数据就会发生丢失。 - **半同步复制**:MySQL 5.7 版本之后增加的一种复制方式,介于两者之间,事务线程不用等待所有的从库复制成功响应,只要一部分复制成功响应回来就行,比如一主二从的集群,只要数据成功复制到任意一个从库上,主库的事务线程就可以返回给客户端。这种**半同步复制的方式,兼顾了异步复制和同步复制的优点,即使出现主库宕机,至少还有一个从库有最新的数据,不存在数据丢失的风险**。 ### binlog 什么时候刷盘? 事务执行过程中,先把日志写到 binlog cache(Server 层的 cache),事务提交的时候,再把 binlog cache 写到 binlog 文件中。 MySQL 给 binlog cache 分配了一片内存,每个线程一个,参数 binlog_cache_size 用于控制单个线程内 binlog cache 所占内存的大小。如果超过了这个参数规定的大小,就要暂存到磁盘。 > 什么时候 binlog cache 会写到 binlog 文件? 在事务提交的时候,执行器把 binlog cache 里的完整事务写入到 binlog 文件中,并清空 binlog cache。如下图: ![binlog cach](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/binlogcache.drawio.png) 虽然每个线程有自己 binlog cache,但是最终都写到同一个 binlog 文件: - 图中的 write,指的就是指把日志写入到 binlog 文件,但是并没有把数据持久化到磁盘,因为数据还缓存在文件系统的 page cache 里,write 的写入速度还是比较快的,因为不涉及磁盘 I/O。 - 图中的 fsync,才是将数据持久化到磁盘的操作,这里就会涉及磁盘 I/O,所以频繁的 fsync 会导致磁盘的 I/O 升高。 MySQL 提供一个 sync_binlog 参数来控制数据库的 binlog 刷到磁盘上的频率: - sync_binlog = 0 的时候,表示每次提交事务都只 write,不 fsync,后续交由操作系统决定何时将数据持久化到磁盘; - sync_binlog = 1 的时候,表示每次提交事务都会 write,然后马上执行 fsync; - sync_binlog = N(N>1) 的时候,表示每次提交事务都 write,但累积 N 个事务后才 fsync。 在 MySQL 中系统默认的设置是 sync_binlog = 1,是最安全但是性能损耗最大的设置。因为当设置为 1 的时候,即使主机发生异常重启,最多丢失一个事务的 binlog,而已经持久化到磁盘的数据就不会有影响,不过就是对写入性能影响太大。 而当 sync_binlog 设置为 0 的时候,也就是不做任何强制性的磁盘刷新指令,这时候的性能是最好的,但是风险也是最大的。因为一旦主机发生异常重启,还没持久化到磁盘的数据就会丢失。 如果能容忍少量事务的 binlog 日志存在丢失风险的情况下,同时为了提高写入的性能,一般可以将 sync_binlog 设置为 100~1000 中的某个数值。 > 三个日志讲完了,至此我们可以先小结下,update 语句的执行过程。 当优化器分析出成本最小的执行计划后,执行器就按照执行计划开始进行更新操作。 具体更新一条记录 `UPDATE t_user SET name = 'xiaolin' WHERE id = 1;` 的流程如下: 1. 执行器负责具体执行,会调用存储引擎的接口,通过主键索引树搜索获取 id = 1 这一行记录: - 如果 id=1 这一行所在的数据页本来就在 buffer pool 中,就直接返回给执行器更新; - 如果记录不在 buffer pool,将数据页从磁盘读入到 buffer pool,返回记录给执行器。 2. 执行器得到聚簇索引记录后,会看一下更新前的记录和更新后的记录是否一样: - 如果一样的话就不进行后续更新流程; - 如果不一样的话就把更新前的记录和更新后的记录都当作参数传给 InnoDB 层,让 InnoDB 真正的执行更新记录的操作; 3. 开启事务,InnoDB 层更新记录前,首先要记录相应的 undo log,因为这是更新操作,需要把被更新的列的旧值记下来,也就是要生成一条 undo log,undo log 会写入 Buffer Pool 中的 Undo 页面,不过在内存修改该 Undo 页面后,需要记录对应的 redo log。 4. InnoDB 层开始更新记录,会先更新内存(同时标记为脏页),然后将记录写到 redo log 里面,这个时候更新就算完成了。为了减少磁盘 I/O,不会立即将脏页写入磁盘,后续由后台线程选择一个合适的时机将脏页写入到磁盘。这就是 **WAL 技术**,MySQL 的写操作并不是立刻写到磁盘上,而是先写 redo 日志,然后在合适的时间再将修改的行数据写到磁盘上。 5. 至此,一条记录更新完了。 6. 在一条更新语句执行完成后,然后开始记录该语句对应的 binlog,此时记录的 binlog 会被保存到 binlog cache,并没有刷新到硬盘上的 binlog 文件,在事务提交时才会统一将该事务运行过程中的所有 binlog 刷新到硬盘。 7. 事务提交,剩下的就是「两阶段提交」的事情了,接下来就讲这个。 ## 为什么需要两阶段提交? 事务提交后,redo log 和 binlog 都要持久化到磁盘,但是这两个是独立的逻辑,可能出现半成功的状态,这样就造成两份日志之间的逻辑不一致。 举个例子,假设 id = 1 这行数据的字段 name 的值原本是 'jay',然后执行 `UPDATE t_user SET name = 'xiaolin' WHERE id = 1;` 如果在持久化 redo log 和 binlog 两个日志的过程中,出现了半成功状态,那么就有两种情况: - **如果在将 redo log 刷入到磁盘之后,MySQL 突然宕机了,而 binlog 还没有来得及写入**。MySQL 重启后,通过 redo log 能将 Buffer Pool 中 id = 1 这行数据的 name 字段恢复到新值 xiaolin,但是 binlog 里面没有记录这条更新语句,在主从架构中,binlog 会被复制到从库,由于 binlog 丢失了这条更新语句,从库的这一行 name 字段是旧值 jay,与主库的值不一致性; - **如果在将 binlog 刷入到磁盘之后,MySQL 突然宕机了,而 redo log 还没有来得及写入**。由于 redo log 还没写,崩溃恢复以后这个事务无效,所以 id = 1 这行数据的 name 字段还是旧值 jay,而 binlog 里面记录了这条更新语句,在主从架构中,binlog 会被复制到从库,从库执行了这条更新语句,那么这一行 name 字段是新值 xiaolin,与主库的值不一致性; 可以看到,在持久化 redo log 和 binlog 这两份日志的时候,如果出现半成功的状态,就会造成主从环境的数据不一致性。这是因为 redo log 影响主库的数据,binlog 影响从库的数据,所以 redo log 和 binlog 必须保持一致才能保证主从数据一致。 **MySQL 为了避免出现两份日志之间的逻辑不一致的问题,使用了「两阶段提交」来解决**,两阶段提交其实是分布式事务一致性协议,它可以保证多个逻辑操作要不全部成功,要不全部失败,不会出现半成功的状态。 **两阶段提交把单个事务的提交拆分成了 2 个阶段,分别是「准备(Prepare)阶段」和「提交(Commit)阶段」**,每个阶段都由协调者(Coordinator)和参与者(Participant)共同完成。注意,不要把提交(Commit)阶段和 commit 语句混淆了,commit 语句执行的时候,会包含提交(Commit)阶段。 举个拳击比赛的例子,两位拳击手(参与者)开始比赛之前,裁判(协调者)会在中间确认两位拳击手的状态,类似于问你准备好了吗? - **准备阶段**:裁判(协调者)会依次询问两位拳击手(参与者)是否准备好了,然后拳击手听到后做出应答,如果觉得自己准备好了,就会跟裁判说准备好了;如果没有自己还没有准备好(比如拳套还没有带好),就会跟裁判说还没准备好。 - **提交阶段**:如果两位拳击手(参与者)都回答准备好了,裁判(协调者)宣布比赛正式开始,两位拳击手就可以直接开打;如果任何一位拳击手(参与者)回答没有准备好,裁判(协调者)会宣布比赛暂停,对应事务中的回滚操作。 ### 两阶段提交的过程是怎样的? 在 MySQL 的 InnoDB 存储引擎中,开启 binlog 的情况下,MySQL 会同时维护 binlog 日志与 InnoDB 的 redo log,为了保证这两个日志的一致性,MySQL 使用了**内部 XA 事务**(是的,也有外部 XA 事务,跟本文不太相关,我就不介绍了),内部 XA 事务由 binlog 作为协调者,存储引擎是参与者。 当客户端执行 commit 语句或者在自动提交的情况下,MySQL 内部开启一个 XA 事务,**分两阶段来完成 XA 事务的提交**,如下图: ![两阶段提交](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/两阶段提交.drawio.png?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 从图中可看出,事务的提交过程有两个阶段,就是**将 redo log 的写入拆成了两个步骤:prepare 和 commit,中间再穿插写入 binlog**,具体如下: - **prepare 阶段**:将 XID(内部 XA 事务的 ID)写入到 redo log,同时将 redo log 对应的事务状态设置为 prepare,然后将 redo log 刷新到硬盘; - **commit 阶段**:把 XID 写入到 binlog,然后将 binlog 刷新到磁盘,接着调用引擎的提交事务接口,将 redo log 状态设置为 commit(将事务设置为 commit 状态后,刷入到磁盘 redo log 文件,所以 commit 状态也是会刷盘的); ### 异常重启会出现什么现象? 我们来看看在两阶段提交的不同时刻,MySQL 异常重启会出现什么现象?下图中有时刻 A 和时刻 B 都有可能发生崩溃: ![时刻 A 与时刻 B](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/两阶段提交崩溃点.drawio.png?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 不管是时刻 A(已经 redo log,还没写入 binlog),还是时刻 B(已经写入 redo log 和 binlog,还没写入 commit 标识)崩溃,**此时的 redo log 都处于 prepare 状态**。 在 MySQL 重启后会按顺序扫描 redo log 文件,碰到处于 prepare 状态的 redo log,就拿着 redo log 中的 XID 去 binlog 查看是否存在此 XID: - **如果 binlog 中没有当前内部 XA 事务的 XID,说明 redolog 完成刷盘,但是 binlog 还没有刷盘,则回滚事务**。对应时刻 A 崩溃恢复的情况。 - **如果 binlog 中有当前内部 XA 事务的 XID,说明 redolog 和 binlog 都已经完成了刷盘,则提交事务**。对应时刻 B 崩溃恢复的情况。 可以看到,**对于处于 prepare 阶段的 redo log,即可以提交事务,也可以回滚事务,这取决于是否能在 binlog 中查找到与 redo log 相同的 XID**,如果有就提交事务,如果没有就回滚事务。这样就可以保证 redo log 和 binlog 这两份日志的一致性了。 所以说,**两阶段提交是以 binlog 写成功为事务提交成功的标识**,因为 binlog 写成功了,就意味着能在 binlog 中查找到与 redo log 相同的 XID。 > 处于 prepare 阶段的 redo log 加上完整 binlog,重启就提交事务,MySQL 为什么要这么设计? binlog 已经写入了,之后就会被从库(或者用这个 binlog 恢复出来的库)使用。 所以,在主库上也要提交这个事务。采用这个策略,主库和备库的数据就保证了一致性。 > 事务没提交的时候,redo log 会被持久化到磁盘吗? 会的。 事务执行中间过程的 redo log 也是直接写在 redo log buffer 中的,这些缓存在 redo log buffer 里的 redo log 也会被「后台线程」每隔一秒一起持久化到磁盘。 也就是说,事务没提交的时候,redo log 也是可能被持久化到磁盘的。 有的同学可能会问,如果 mysql 崩溃了,还没提交事务的 redo log 已经被持久化磁盘了,mysql 重启后,数据不就不一致了? 放心,这种情况 mysql 重启会进行回滚操作,因为事务没提交的时候,binlog 是还没持久化到磁盘的。 所以,redo log 可以在事务没提交之前持久化到磁盘,但是 binlog 必须在事务提交之后,才可以持久化到磁盘。 ## 两阶段提交有什么问题? 两阶段提交虽然保证了两个日志文件的数据一致性,但是性能很差,主要有两个方面的影响: - **磁盘 I/O 次数高**:对于“双 1”配置,每个事务提交都会进行两次 fsync(刷盘),一次是 redo log 刷盘,另一次是 binlog 刷盘。 - **锁竞争激烈**:两阶段提交虽然能够保证「单事务」两个日志的内容一致,但在「多事务」的情况下,却不能保证两者的提交顺序一致,因此,在两阶段提交的流程基础上,还需要加一个锁来保证提交的原子性,从而保证多事务的情况下,两个日志的提交顺序一致。 > 为什么两阶段提交的磁盘 I/O 次数会很高? binlog 和 redo log 在内存中都对应的缓存空间,binlog 会缓存在 binlog cache,redo log 会缓存在 redo log buffer,它们持久化到磁盘的时机分别由下面这两个参数控制。一般我们为了避免日志丢失的风险,会将这两个参数设置为 1: - 当 sync_binlog = 1 的时候,表示每次提交事务都会将 binlog cache 里的 binlog 直接持久到磁盘; - 当 innodb_flush_log_at_trx_commit = 1 时,表示每次事务提交时,都将缓存在 redo log buffer 里的 redo log 直接持久化到磁盘; 可以看到,如果 sync_binlog 和 当 innodb_flush_log_at_trx_commit 都设置为 1,那么在每个事务提交过程中,都会至少调用 2 次刷盘操作,一次是 redo log 刷盘,一次是 binlog 落盘,所以这会成为性能瓶颈。 > 为什么锁竞争激烈? 在早期的 MySQL 版本中,通过使用 prepare_commit_mutex 锁来保证事务提交的顺序,在一个事务获取到锁时才能进入 prepare 阶段,一直到 commit 阶段结束才能释放锁,下个事务才可以继续进行 prepare 操作。 通过加锁虽然完美地解决了顺序一致性的问题,但在并发量较大的时候,就会导致对锁的争用,性能不佳。 ### 组提交 **MySQL 引入了 binlog 组提交(group commit)机制,当有多个事务提交的时候,会将多个 binlog 刷盘操作合并成一个,从而减少磁盘 I/O 的次数**,如果说 10 个事务依次排队刷盘的时间成本是 10,那么将这 10 个事务一次性一起刷盘的时间成本则近似于 1。 引入了组提交机制后,prepare 阶段不变,只针对 commit 阶段,将 commit 阶段拆分为三个过程: - **flush 阶段**:多个事务按进入的顺序将 binlog 从 cache 写入文件(不刷盘); - **sync 阶段**:对 binlog 文件做 fsync 操作(多个事务的 binlog 合并一次刷盘); - **commit 阶段**:各个事务按顺序做 InnoDB commit 操作; 上面的**每个阶段都有一个队列**,每个阶段有锁进行保护,因此保证了事务写入的顺序,第一个进入队列的事务会成为 leader,leader 领导所在队列的所有事务,全权负责整队的操作,完成后通知队内其他事务操作结束。 ![每个阶段都有一个队列](http://keithlan.github.io/image/mysql_innodb_arch/commit_4.png) 对每个阶段引入了队列后,锁就只针对每个队列进行保护,不再锁住提交事务的整个过程,可以看的出来,**锁粒度减小了,这样就使得多个阶段可以并发执行,从而提升效率**。 > 有 binlog 组提交,那有 redo log 组提交吗? 这个要看 MySQL 版本,MySQL 5.6 没有 redo log 组提交,MySQL 5.7 有 redo log 组提交。 在 MySQL 5.6 的组提交逻辑中,每个事务各自执行 prepare 阶段,也就是各自将 redo log 刷盘,这样就没办法对 redo log 进行组提交。 所以在 MySQL 5.7 版本中,做了个改进,在 prepare 阶段不再让事务各自执行 redo log 刷盘操作,而是推迟到组提交的 flush 阶段,也就是说 prepare 阶段融合在了 flush 阶段。 这个优化是将 redo log 的刷盘延迟到了 flush 阶段之中,sync 阶段之前。通过延迟写 redo log 的方式,为 redolog 做了一次组写入,这样 binlog 和 redo log 都进行了优化。 接下来介绍每个阶段的过程,注意下面的过程针对的是“双 1”配置(sync_binlog 和 innodb_flush_log_at_trx_commit 都配置为 1)。 > flush 阶段 第一个事务会成为 flush 阶段的 Leader,此时后面到来的事务都是 Follower: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/组提交1.png) 接着,获取队列中的事务组,由绿色事务组的 Leader 对 rodo log 做一次 write + fsync,即一次将同组事务的 redolog 刷盘: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/组提交2.png) 完成了 prepare 阶段后,将绿色这一组事务执行过程中产生的 binlog 写入 binlog 文件(调用 write,不会调用 fsync,所以不会刷盘,binlog 缓存在操作系统的文件系统中)。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/write_binlog.png) 从上面这个过程,可以知道 flush 阶段队列的作用是**用于支撑 redo log 的组提交**。 如果在这一步完成后数据库崩溃,由于 binlog 中没有该组事务的记录,所以 MySQL 会在重启后回滚该组事务。 > sync 阶段 绿色这一组事务的 binlog 写入到 binlog 文件后,并不会马上执行刷盘的操作,而是**会等待一段时间**,这个等待的时长由 `Binlog_group_commit_sync_delay` 参数控制,**目的是为了组合更多事务的 binlog,然后再一起刷盘**,如下过程: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/组提交4.png) 不过,在等待的过程中,如果事务的数量提前达到了 `Binlog_group_commit_sync_no_delay_count` 参数设置的值,就不用继续等待了,就马上将 binlog 刷盘,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/组提交5.png) 从上面的过程,可以知道 sync 阶段队列的作用是**用于支持 binlog 的组提交**。 如果想提升 binlog 组提交的效果,可以通过设置下面这两个参数来实现: - `binlog_group_commit_sync_delay= N`,表示在等待 N 微妙后,直接调用 fsync,将处于文件系统中 page cache 中的 binlog 刷盘,也就是将「binlog 文件」持久化到磁盘。 - `binlog_group_commit_sync_no_delay_count = N`,表示如果队列中的事务数达到 N 个,就忽视 binlog_group_commit_sync_delay 的设置,直接调用 fsync,将处于文件系统中 page cache 中的 binlog 刷盘。 如果在这一步完成后数据库崩溃,由于 binlog 中已经有了事务记录,MySQL 会在重启后通过 redo log 刷盘的数据继续进行事务的提交。 > commit 阶段 最后进入 commit 阶段,调用引擎的提交事务接口,将 redo log 状态设置为 commit。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/how_update/组提交6.png) commit 阶段队列的作用是承接 sync 阶段的事务,完成最后的引擎提交,使得 sync 可以尽早的处理下一组事务,最大化组提交的效率。 ## MySQL 磁盘 I/O 很高,有什么优化的方法? 现在我们知道事务在提交的时候,需要将 binlog 和 redo log 持久化到磁盘,那么如果出现 MySQL 磁盘 I/O 很高的现象,我们可以通过控制以下参数,来“延迟”binlog 和 redo log 刷盘的时机,从而降低磁盘 I/O 的频率: - 设置组提交的两个参数:binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count 参数,延迟 binlog 刷盘的时机,从而减少 binlog 的刷盘次数。这个方法是基于“额外的故意等待”来实现的,因此可能会增加语句的响应时间,但即使 MySQL 进程中途挂了,也没有丢失数据的风险,因为 binlog 早被写入到 page cache 了,只要系统没有宕机,缓存在 page cache 里的 binlog 就会被持久化到磁盘。 - 将 sync_binlog 设置为大于 1 的值(比较常见是 100~1000),表示每次提交事务都 write,但累积 N 个事务后才 fsync,相当于延迟了 binlog 刷盘的时机。但是这样做的风险是,主机掉电时会丢 N 个事务的 binlog 日志。 - 将 innodb_flush_log_at_trx_commit 设置为 2。表示每次事务提交时,都只是缓存在 redo log buffer 里的 redo log 写到 redo log 文件,注意写入到「redo log 文件」并不意味着写入到了磁盘,因为操作系统的文件系统中有个 Page Cache,专门用来缓存文件数据的,所以写入「redo log 文件」意味着写入到了操作系统的文件缓存,然后交由操作系统控制持久化到磁盘的时机。但是这样做的风险是,主机掉电的时候会丢数据。 ## 总结 具体更新一条记录 `UPDATE t_user SET name = 'xiaolin' WHERE id = 1;` 的流程如下: 1. 执行器负责具体执行,会调用存储引擎的接口,通过主键索引树搜索获取 id = 1 这一行记录: - 如果 id=1 这一行所在的数据页本来就在 buffer pool 中,就直接返回给执行器更新; - 如果记录不在 buffer pool,将数据页从磁盘读入到 buffer pool,返回记录给执行器。 2. 执行器得到聚簇索引记录后,会看一下更新前的记录和更新后的记录是否一样: - 如果一样的话就不进行后续更新流程; - 如果不一样的话就把更新前的记录和更新后的记录都当作参数传给 InnoDB 层,让 InnoDB 真正的执行更新记录的操作; 3. 开启事务,InnoDB 层更新记录前,首先要记录相应的 undo log,因为这是更新操作,需要把被更新的列的旧值记下来,也就是要生成一条 undo log,undo log 会写入 Buffer Pool 中的 Undo 页面,不过在内存修改该 Undo 页面后,需要记录对应的 redo log。 4. InnoDB 层开始更新记录,先生成对应redo log,并存入redo log buffer里面,当事务提交时,将redo log写入redo log file,并更新buffer pool中的数据页,将其放入flush 链表并标记脏页和记录redo log对应的lsn到该页的oldest_modification,这个时候更新就算完成了。为了减少磁盘 I/O,不会立即将脏页写入磁盘,后续由后台线程选择一个合适的时机将脏页写入到磁盘。这就是 **WAL 技术**,MySQL 的写操作并不是立刻写到磁盘上,而是先写 redo 日志,然后在合适的时间再将修改的行数据写到磁盘上。 5. 至此,一条记录更新完了。 6. 在一条更新语句执行完成后,然后开始记录该语句对应的 binlog,此时记录的 binlog 会被保存到 binlog cache,并没有刷新到硬盘上的 binlog 文件,在事务提交时才会统一将该事务运行过程中的所有 binlog 刷新到硬盘。 7. 事务提交(为了方便说明,这里不说组提交的过程,只说两阶段提交): - **prepare 阶段**:将 redo log 对应的事务状态设置为 prepare,然后将 redo log 刷新到硬盘; - **commit 阶段**:将 binlog 刷新到磁盘,接着调用引擎的提交事务接口,将 redo log 状态设置为 commit(将事务设置为 commit 状态后,刷入到磁盘 redo log 文件); 8. 至此,一条更新语句执行完成。 --- 参考资料: - 《MySQL 45 讲》 - 《MySQL 是怎样运行的?》 - https://developer.aliyun.com/article/617776 - http://mysql.taobao.org/monthly/2021/10/01/ - https://www.cnblogs.com/Neeo/articles/13883976.html - https://www.cnblogs.com/mengxinJ/p/14211427.html --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: mysql/transaction/mvcc.md ================================================ # 事务隔离级别是怎么实现的? 这是我的钱包,共有 100 万元。 ![图片](https://img-blog.csdnimg.cn/img_convert/bc8fe6de0e9e466671cd7d335c116de3.png) 今天我心情好,我决定给你的转账 100 万,最后的结果肯定是我的余额变为 0 元,你的余额多了 100 万元,是不是想到就很开心? 转账这一动作在程序里会涉及到一系列的操作,假设我向你转账 100 万的过程是有下面这几个步骤组成的: ![图片](https://img-blog.csdnimg.cn/img_convert/40b48e0a7c9a57ae6b78b62c3fad4048.png) 可以看到这个转账的过程涉及到了两次修改数据库的操作。 假设在执行第三步骤之后,服务器忽然掉电了,就会发生一个蛋疼的事情,我的账户扣了 100 万,但是钱并没有到你的账户上,也就是说**这 100 万消失了!** 要解决这个问题,就要保证转账业务里的所有数据库的操作是不可分割的,要么全部执行成功,要么全部失败,不允许出现中间状态的数据。 数据库中的「**事务(*Transaction*)**」就能达到这样的效果。 我们在转账操作前先开启事务,等所有数据库操作执行完成后,才提交事务,对于已经提交的事务来说,该事务对数据库所做的修改将永久生效,如果中途发生发生中断或错误,那么该事务期间对数据库所做的修改将会被回滚到没执行该事务之前的状态。 没错,今天就来图解 MySQL 事务啦,开车! ![](https://img-blog.csdnimg.cn/eb15d4b6a9d543c1be4f7090479d969c.png) ------ ## 事务有哪些特性? 事务是由 MySQL 的引擎来实现的,我们常见的 InnoDB 引擎它是支持事务的。 不过并不是所有的引擎都能支持事务,比如 MySQL 原生的 MyISAM 引擎就不支持事务,也正是这样,所以大多数 MySQL 的引擎都是用 InnoDB。 事务看起来感觉简单,但是要实现事务必须要遵守 4 个特性,分别如下: - **原子性(Atomicity)**:一个事务中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节,而且事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样,就好比买一件商品,购买成功时,则给商家付了钱,商品到手;购买失败时,则商品在商家手中,消费者的钱也没花出去。 - **一致性(Consistency)**:是指事务操作前和操作后,数据满足完整性约束,数据库保持一致性状态。比如,用户 A 和用户 B 在银行分别有 800 元和 600 元,总共 1400 元,用户 A 给用户 B 转账 200 元,分为两个步骤,从 A 的账户扣除 200 元和对 B 的账户增加 200 元。一致性就是要求上述步骤操作后,最后的结果是用户 A 还有 600 元,用户 B 有 800 元,总共 1400 元,而不会出现用户 A 扣除了 200 元,但用户 B 未增加的情况(该情况,用户 A 和 B 均为 600 元,总共 1200 元)。 - **隔离性(Isolation)**:数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致,因为多个事务同时使用相同的数据时,不会相互干扰,每个事务都有一个完整的数据空间,对其他并发事务是隔离的。也就是说,消费者购买商品这个事务,是不影响其他消费者购买的。 - **持久性(Durability)**:事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失。 InnoDB 引擎通过什么技术来保证事务的这四个特性的呢? - 持久性是通过 redo log(重做日志)来保证的; - 原子性是通过 undo log(回滚日志)来保证的; - 隔离性是通过 MVCC(多版本并发控制)或锁机制来保证的; - 一致性则是通过持久性 + 原子性 + 隔离性来保证; 这次将**重点介绍事务的隔离性**,这也是面试时最常问的知识的点。 为什么事务要有隔离性,我们就要知道并发事务时会引发什么问题。 ## 并行事务会引发什么问题? MySQL 服务端是允许多个客户端连接的,这意味着 MySQL 会出现同时处理多个事务的情况。 那么**在同时处理多个事务的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题**。 接下来,通过举例子给大家说明,这些问题是如何发生的。 ### 脏读 **如果一个事务「读到」了另一个「未提交事务修改过的数据」,就意味着发生了「脏读」现象。** 举个栗子。 假设有 A 和 B 这两个事务同时在处理,事务 A 先开始从数据库中读取小林的余额数据,然后再执行更新操作,如果此时事务 A 还没有提交事务,而此时正好事务 B 也从数据库中读取小林的余额数据,那么事务 B 读取到的余额数据是刚才事务 A 更新后的数据,即使没有提交事务。 ![图片](https://img-blog.csdnimg.cn/img_convert/10b513008ea35ee880c592a88adcb12f.png) 因为事务 A 是还没提交事务的,也就是它随时可能发生回滚操作,**如果在上面这种情况事务 A 发生了回滚,那么事务 B 刚才得到的数据就是过期的数据,这种现象就被称为脏读。** ### 不可重复读 **在一个事务内多次读取同一个数据,如果出现前后两次读到的数据不一样的情况,就意味着发生了「不可重复读」现象。** 举个栗子。 假设有 A 和 B 这两个事务同时在处理,事务 A 先开始从数据库中读取小林的余额数据,然后继续执行代码逻辑处理,**在这过程中如果事务 B 更新了这条数据,并提交了事务,那么当事务 A 再次读取该数据时,就会发现前后两次读到的数据是不一致的,这种现象就被称为不可重复读。** ![图片](https://img-blog.csdnimg.cn/img_convert/f5b4f8f0c0adcf044b34c1f300a95abf.png) ### 幻读 **在一个事务内多次查询某个符合查询条件的「记录数量」,如果出现前后两次查询到的记录数量不一样的情况,就意味着发生了「幻读」现象。** 举个栗子。 假设有 A 和 B 这两个事务同时在处理,事务 A 先开始从数据库查询账户余额大于 100 万的记录,发现共有 5 条,然后事务 B 也按相同的搜索条件也是查询出了 5 条记录。 ![图片](https://img-blog.csdnimg.cn/img_convert/d19a1019dc35dfe8cfe7fbff8cd97e31.png) 接下来,事务 A 插入了一条余额超过 100 万的账号,并提交了事务,此时数据库超过 100 万余额的账号个数就变为 6。 然后事务 B 再次查询账户余额大于 100 万的记录,此时查询到的记录数量有 6 条,**发现和前一次读到的记录数量不一样了,就感觉发生了幻觉一样,这种现象就被称为幻读。** ## 事务的隔离级别有哪些? 前面我们提到,当多个事务并发执行时可能会遇到「脏读、不可重复读、幻读」的现象,这些现象会对事务的一致性产生不同程度的影响。 - 脏读:读到其他事务未提交的数据; - 不可重复读:前后读取的数据不一致; - 幻读:前后读取的记录数量不一致。 这三个现象的严重性排序如下: ![图片](https://img-blog.csdnimg.cn/img_convert/d37bfa1678eb71ae7e33dc8f211d1ec1.png) SQL 标准提出了四种隔离级别来规避这些现象,隔离级别越高,性能效率就越低,这四个隔离级别如下: - **读未提交(*read uncommitted*)**,指一个事务还没提交时,它做的变更就能被其他事务看到; - **读提交(*read committed*)**,指一个事务提交之后,它做的变更才能被其他事务看到; - **可重复读(*repeatable read*)**,指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,**MySQL InnoDB 引擎的默认隔离级别**; - **串行化(*serializable* )**;会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行; 按隔离水平高低排序如下: ![图片](https://img-blog.csdnimg.cn/img_convert/cce766a69dea725cd8f19b90db2d0430.png) 针对不同的隔离级别,并发事务时可能发生的现象也会不同。 ![图片](https://img-blog.csdnimg.cn/img_convert/4e98ea2e60923b969790898565b4d643.png) 也就是说: - 在「读未提交」隔离级别下,可能发生脏读、不可重复读和幻读现象; - 在「读提交」隔离级别下,可能发生不可重复读和幻读现象,但是不可能发生脏读现象; - 在「可重复读」隔离级别下,可能发生幻读现象,但是不可能发生脏读和不可重复读现象; - 在「串行化」隔离级别下,脏读、不可重复读和幻读现象都不可能会发生。 所以,要解决脏读现象,就要升级到「读提交」以上的隔离级别;要解决不可重复读现象,就要升级到「可重复读」的隔离级别,要解决幻读现象不建议将隔离级别升级到「串行化」。 不同的数据库厂商对 SQL 标准中规定的 4 种隔离级别的支持不一样,有的数据库只实现了其中几种隔离级别,**我们讨论的 MySQL 虽然支持 4 种隔离级别,但是与 SQL 标准中规定的各级隔离级别允许发生的现象却有些出入**。 MySQL 在「可重复读」隔离级别下,可以很大程度上避免幻读现象的发生(注意是很大程度避免,并不是彻底避免),所以 MySQL 并不会使用「串行化」隔离级别来避免幻读现象的发生,因为使用「串行化」隔离级别会影响性能。 **MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它很大程度上避免幻读现象(并不是完全解决了,详见这篇[文章](https://xiaolincoding.com/mysql/transaction/phantom.html))**,解决的方案有两种: - 针对**快照读**(普通 select 语句),是**通过 MVCC 方式解决了幻读**,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。 - 针对**当前读**(select ... for update 等语句),是**通过 next-key lock(记录锁 + 间隙锁)方式解决了幻读**,因为当执行 select ... for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。 接下来,举个具体的例子来说明这四种隔离级别,有一张账户余额表,里面有一条账户余额为 100 万的记录。然后有两个并发的事务,事务 A 只负责查询余额,事务 B 则会将我的余额改成 200 万,下面是按照时间顺序执行两个事务的行为: ![图片](https://img-blog.csdnimg.cn/img_convert/d5de450e901ed926d0b5278c8b65b9fe.png) 在不同隔离级别下,事务 A 执行过程中查询到的余额可能会不同: - 在「读未提交」隔离级别下,事务 B 修改余额后,虽然没有提交事务,但是此时的余额已经可以被事务 A 看见了,于是事务 A 中余额 V1 查询的值是 200 万,余额 V2、V3 自然也是 200 万了; - 在「读提交」隔离级别下,事务 B 修改余额后,因为没有提交事务,所以事务 A 中余额 V1 的值还是 100 万,等事务 B 提交完后,最新的余额数据才能被事务 A 看见,因此额 V2、V3 都是 200 万; - 在「可重复读」隔离级别下,事务 A 只能看见启动事务时的数据,所以余额 V1、余额 V2 的值都是 100 万,当事务 A 提交事务后,就能看见最新的余额数据了,所以余额 V3 的值是 200 万; - 在「串行化」隔离级别下,事务 B 在执行将余额 100 万修改为 200 万时,由于此前事务 A 执行了读操作,这样就发生了读写冲突,于是就会被锁住,直到事务 A 提交后,事务 B 才可以继续执行,所以从 A 的角度看,余额 V1、V2 的值是 100 万,余额 V3 的值是 200 万。 这四种隔离级别具体是如何实现的呢? - 对于「读未提交」隔离级别的事务来说,因为可以读到未提交事务修改的数据,所以直接读取最新的数据就好了; - 对于「串行化」隔离级别的事务来说,通过加读写锁的方式来避免并行访问; - 对于「读提交」和「可重复读」隔离级别的事务来说,它们是通过 **Read View **来实现的,它们的区别在于创建 Read View 的时机不同,大家可以把 Read View 理解成一个数据快照,就像相机拍照那样,定格某一时刻的风景。**「读提交」隔离级别是在「每个语句执行前」都会重新生成一个 Read View,而「可重复读」隔离级别是「启动事务时」生成一个 Read View,然后整个事务期间都在用这个 Read View**。 注意,执行「开始事务」命令,并不意味着启动了事务。在 MySQL 有两种开启事务的命令,分别是: - 第一种:begin/start transaction 命令; - 第二种:start transaction with consistent snapshot 命令; 这两种开启事务的命令,事务的启动时机是不同的: - 执行了 begin/start transaction 命令后,并不代表事务启动了。只有在执行这个命令后,执行了增删查改操作的 SQL 语句,才是事务真正启动的时机; - 执行了 start transaction with consistent snapshot 命令,就会马上启动事务。 接下来详细说下,Read View 在 MVCC 里如何工作的? ## Read View 在 MVCC 里如何工作的? 我们需要了解两个知识: - Read View 中四个字段作用; - 聚簇索引记录中两个跟事务有关的隐藏列; 那 Read View 到底是个什么东西? ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/事务隔离/readview结构.drawio.png) Read View 有四个重要的字段: - m_ids:指的是在创建 Read View 时,当前数据库中「活跃事务」的**事务 id 列表**,注意是一个列表,**“活跃事务”指的就是,启动了但还没提交的事务**。 - min_trx_id:指的是在创建 Read View 时,当前数据库中「活跃事务」中事务 **id 最小的事务**,也就是 m_ids 的最小值。 - max_trx_id:这个并不是 m_ids 的最大值,而是**创建 Read View 时当前数据库中应该给下一个事务的 id 值**,也就是全局事务中最大的事务 id 值 + 1; - creator_trx_id:指的是**创建该 Read View 的事务的事务 id**。 知道了 Read View 的字段,我们还需要了解聚簇索引记录中的两个隐藏列。 假设在账户余额表插入一条小林余额为 100 万的记录,然后我把这两个隐藏列也画出来,该记录的整个示意图如下: ![图片](https://img-blog.csdnimg.cn/img_convert/f595d13450878acd04affa82731f76c5.png) 对于使用 InnoDB 存储引擎的数据库表,它的聚簇索引记录中都包含下面两个隐藏列: - trx_id,当一个事务对某条聚簇索引记录进行改动时,就会**把该事务的事务 id 记录在 trx_id 隐藏列里**; - roll_pointer,每次对某条聚簇索引记录进行改动时,都会把旧版本的记录写入到 undo 日志中,然后**这个隐藏列是个指针,指向每一个旧版本记录**,于是就可以通过它找到修改前的记录。 在创建 Read View 后,我们可以将记录中的 trx_id 划分这三种情况: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/事务隔离/ReadView.drawio.png) 一个事务去访问记录的时候,除了自己的更新记录总是可见之外,还有这几种情况: - 如果记录的 trx_id 值小于 Read View 中的 `min_trx_id` 值,表示这个版本的记录是在创建 Read View **前**已经提交的事务生成的,所以该版本的记录对当前事务**可见**。 - 如果记录的 trx_id 值大于等于 Read View 中的 `max_trx_id` 值,表示这个版本的记录是在创建 Read View **后**才启动的事务生成的,所以该版本的记录对当前事务**不可见**。 - 如果记录的 trx_id 值在 Read View 的 `min_trx_id` 和 `max_trx_id` 之间,需要判断 trx_id 是否在 m_ids 列表中: - 如果记录的 trx_id **在** `m_ids` 列表中,表示生成该版本记录的活跃事务依然活跃着(还没提交事务),所以该版本的记录对当前事务**不可见**。 - 如果记录的 trx_id **不在** `m_ids`列表中,表示生成该版本记录的活跃事务已经被提交,所以该版本的记录对当前事务**可见**。 **这种通过「版本链」来控制并发事务访问同一个记录时的行为就叫 MVCC(多版本并发控制)。** ## 可重复读是如何工作的? **可重复读隔离级别是启动事务时生成一个 Read View,然后整个事务期间都在用这个 Read View**。 假设事务 A(事务 id 为 51)启动后,紧接着事务 B(事务 id 为 52)也启动了,那这两个事务创建的 Read View 如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/事务隔离/事务ab的视图-new.png) 事务 A 和 事务 B 的 Read View 具体内容如下: - 在事务 A 的 Read View 中,它的事务 id 是 51,由于它是第一个启动的事务,所以此时活跃事务的事务 id 列表就只有 51,活跃事务的事务 id 列表中最小的事务 id 是事务 A 本身,下一个事务 id 则是 52。 - 在事务 B 的 Read View 中,它的事务 id 是 52,由于事务 A 是活跃的,所以此时活跃事务的事务 id 列表是 51 和 52,**活跃的事务 id 中最小的事务 id 是事务 A**,下一个事务 id 应该是 53。 接着,在可重复读隔离级别下,事务 A 和事务 B 按顺序执行了以下操作: - 事务 B 读取小林的账户余额记录,读到余额是 100 万; - 事务 A 将小林的账户余额记录修改成 200 万,并没有提交事务; - 事务 B 读取小林的账户余额记录,读到余额还是 100 万; - 事务 A 提交事务; - 事务 B 读取小林的账户余额记录,读到余额依然还是 100 万; 接下来,跟大家具体分析下。 事务 B 第一次读小林的账户余额记录,在找到记录后,它会先看这条记录的 trx_id,此时**发现 trx_id 为 50,比事务 B 的 Read View 中的 min_trx_id 值(51)还小,这意味着修改这条记录的事务早就在事务 B 启动前提交过了,所以该版本的记录对事务 B 可见的**,也就是事务 B 可以获取到这条记录。 接着,事务 A 通过 update 语句将这条记录修改了(还未提交事务),将小林的余额改成 200 万,这时 MySQL 会记录相应的 undo log,并以链表的方式串联起来,形成**版本链**,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/事务隔离/事务ab的视图2.png) 你可以在上图的「记录的字段」看到,由于事务 A 修改了该记录,以前的记录就变成旧版本记录了,于是最新记录和旧版本记录通过链表的方式串起来,而且最新记录的 trx_id 是事务 A 的事务 id(trx_id = 51)。 然后事务 B 第二次去读取该记录,**发现这条记录的 trx_id 值为 51,在事务 B 的 Read View 的 min_trx_id 和 max_trx_id 之间,则需要判断 trx_id 值是否在 m_ids 范围内,判断的结果是在的,那么说明这条记录是被还未提交的事务修改的,这时事务 B 并不会读取这个版本的记录。而是沿着 undo log 链条往下找旧版本的记录,直到找到 trx_id 可见的第一条记录(trx_id「小于」事务 B 的 Read View 中的 min_trx_id 值,或者 trx_id 在事务 B 的 Read View 的 min_trx_id 和 max_trx_id 之间,但是不在 m_ids 范围内)**,所以事务 B 能读取到的是 trx_id 为 50 的记录,也就是小林余额是 100 万的这条记录。 最后,当事物 A 提交事务后,**由于隔离级别时「可重复读」,所以事务 B 再次读取记录时,还是基于启动事务时创建的 Read View 来判断当前版本的记录是否可见。所以,即使事物 A 将小林余额修改为 200 万并提交了事务,事务 B 第三次读取记录时,读到的记录都是小林余额是 100 万的这条记录**。 就是通过这样的方式实现了,「可重复读」隔离级别下在事务期间读到的记录都是事务启动前的记录。 ## 读提交是如何工作的? **读提交隔离级别是在每次读取数据时,都会生成一个新的 Read View**。 也意味着,事务期间的多次读取同一条数据,前后两次读的数据可能会出现不一致,因为可能这期间另外一个事务修改了该记录,并提交了事务。 那读提交隔离级别是怎么工作呢?我们还是以前面的例子来聊聊。 假设事务 A(事务 id 为 51)启动后,紧接着事务 B(事务 id 为 52)也启动了,接着按顺序执行了以下操作: - 事务 B 读取数据(创建 Read View),小林的账户余额为 100 万; - 事务 A 修改数据(还没提交事务),将小林的账户余额从 100 万修改成了 200 万; - 事务 B 读取数据(创建 Read View),小林的账户余额为 100 万; - 事务 A 提交事务; - 事务 B 读取数据(创建 Read View),小林的账户余额为 200 万; 那具体怎么做到的呢?我们重点看事务 B 每次读取数据时创建的 Read View。前两次 事务 B 读取数据时创建的 Read View 如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/事务隔离/读提交事务.png) 我们来分析下为什么事务 B 第二次读数据时,读不到事务 A(还未提交事务)修改的数据? 事务 B 在找到小林这条记录时,会看这条记录的 trx_id 是 51,在事务 B 的 Read View 的 min_trx_id 和 max_trx_id 之间,接下来需要判断 trx_id 值是否在 m_ids 范围内,判断的结果是在的,那么说明**这条记录是被还未提交的事务修改的,这时事务 B 并不会读取这个版本的记录**。而是,沿着 undo log 链条往下找旧版本的记录,直到找到 trx_id 可见的第一条记录(trx_id「小于」事务 B 的 Read View 中的 min_trx_id 值,或者 trx_id 在事务 B 的 Read View 的 min_trx_id 和 max_trx_id 之间,但是不在 m_ids 范围内),所以事务 B 能读取到的是 trx_id 为 50 的记录,也就是小林余额是 100 万的这条记录。 我们来分析下为什么事务 A 提交后,事务 B 就可以读到事务 A 修改的数据? 在事务 A 提交后,**由于隔离级别是「读提交」,所以事务 B 在每次读数据的时候,会重新创建 Read View**,此时事务 B 第三次读取数据时创建的 Read View 如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/事务隔离/读提交事务2.drawio.png) 事务 B 在找到小林这条记录时,**会发现这条记录的 trx_id 是 51,比事务 B 的 Read View 中的 min_trx_id 值(52)还小,这意味着修改这条记录的事务早就在创建 Read View 前提交过了,所以该版本的记录对事务 B 是可见的**。 正是因为在读提交隔离级别下,事务每次读数据时都重新创建 Read View,那么在事务期间的多次读取同一条数据,前后两次读的数据可能会出现不一致,因为可能这期间另外一个事务修改了该记录,并提交了事务。 ## 总结 事务是在 MySQL 引擎层实现的,我们常见的 InnoDB 引擎是支持事务的,事务的四大特性是原子性、一致性、隔离性、持久性,我们这次主要讲的是隔离性。 当多个事务并发执行的时候,会引发脏读、不可重复读、幻读这些问题,那为了避免这些问题,SQL 提出了四种隔离级别,分别是读未提交、读已提交、可重复读、串行化,从左往右隔离级别顺序递增,隔离级别越高,意味着性能越差,InnoDB 引擎的默认隔离级别是可重复读。 要解决脏读现象,就要将隔离级别升级到读已提交以上的隔离级别,要解决不可重复读现象,就要将隔离级别升级到可重复读以上的隔离级别。 而对于幻读现象,不建议将隔离级别升级为串行化,因为这会导致数据库并发时性能很差。MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它很大程度上避免幻读现象(并不是完全解决了,详见这篇[文章](https://xiaolincoding.com/mysql/transaction/phantom.html)),解决的方案有两种: - 针对**快照读**(普通 select 语句),是**通过 MVCC 方式解决了幻读**,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。 - 针对**当前读**(select ... for update 等语句),是**通过 next-key lock(记录锁 + 间隙锁)方式解决了幻读**,因为当执行 select ... for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。 对于「读提交」和「可重复读」隔离级别的事务来说,它们是通过 Read View 来实现的,它们的区别在于创建 Read View 的时机不同: - 「读提交」隔离级别是在每个 select 都会生成一个新的 Read View,也意味着,事务期间的多次读取同一条数据,前后两次读的数据可能会出现不一致,因为可能这期间另外一个事务修改了该记录,并提交了事务。 - 「可重复读」隔离级别是启动事务时生成一个 Read View,然后整个事务期间都在用这个 Read View,这样就保证了在事务期间读到的数据都是事务启动前的记录。 这两个隔离级别实现是通过「事务的 Read View 里的字段」和「记录中的两个隐藏列」的比对,来控制并发事务访问同一个记录时的行为,这就叫 MVCC(多版本并发控制)。 在可重复读隔离级别中,普通的 select 语句就是基于 MVCC 实现的快照读,也就是不会加锁的。而 select .. for update 语句就不是快照读了,而是当前读了,也就是每次读都是拿到最新版本的数据,但是它会对读到的记录加上 next-key lock 锁。 ---- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: mysql/transaction/phantom.md ================================================ # MySQL 可重复读隔离级别,完全解决幻读了吗? 大家好,我是小林。 我在[上一篇文章](https://xiaolincoding.com/mysql/transaction/mvcc.html)提到,MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它很大程度上避免幻读现象(并不是完全解决了),解决的方案有两种: - 针对**快照读**(普通 select 语句),是**通过 MVCC 方式解决了幻读**,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。 - 针对**当前读**(select ... for update 等语句),是**通过 next-key lock(记录锁 + 间隙锁)方式解决了幻读**,因为当执行 select ... for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。 这两个解决方案是很大程度上解决了幻读现象,但是还是有个别的情况造成的幻读现象是无法解决的。 这次,就跟大家好好聊这个问题。 ## 什么是幻读? 首先来看看 MySQL 文档是怎么定义幻读(Phantom Read)的: ***The so-called phantom problem occurs within a transaction when the same query produces different sets of rows at different times. For example, if a SELECT is executed twice, but returns a row the second time that was not returned the first time, the row is a “phantom” row.*** 翻译:当同一个查询在不同的时间产生不同的结果集时,事务中就会出现所谓的幻象问题。例如,如果 SELECT 执行了两次,但第二次返回了第一次没有返回的行,则该行是“幻像”行。 举个例子,假设一个事务在 T1 时刻和 T2 时刻分别执行了下面查询语句,途中没有执行其他任何语句: ```sql SELECT * FROM t_test WHERE id > 100; ``` 只要 T1 和 T2 时刻执行产生的结果集是不相同的,那就发生了幻读的问题,比如: - T1 时间执行的结果是有 5 条行记录,而 T2 时间执行的结果是有 6 条行记录,那就发生了幻读的问题。 - T1 时间执行的结果是有 5 条行记录,而 T2 时间执行的结果是有 4 条行记录,也是发生了幻读的问题。 ## 快照读是如何避免幻读的? 可重复读隔离级是由 MVCC(多版本并发控制)实现的,实现的方式是启动事务后,在执行第一个查询语句后,会创建一个 Read View,**后续的查询语句利用这个 Read View,通过这个 Read View 就可以在 undo log 版本链找到事务开始时的数据,所以事务过程中每次查询的数据都是一样的**,即使中途有其他事务插入了新纪录,是查询不出来这条数据的,所以就很好了避免幻读问题。 做个实验,数据库表 t_stu 如下,其中 id 为主键。 ![](https://img-blog.csdnimg.cn/7f9df142b3594daeaaca495abb7133f5.png) 然后在可重复读隔离级别下,有两个事务的执行顺序如下: ![在这里插入图片描述](https://img-blog.csdnimg.cn/e576e047dccc47d5a59636ea342750b8.png?) 从这个实验结果可以看到,即使事务 B 中途插入了一条记录,事务 A 前后两次查询的结果集都是一样的,并没有出现所谓的幻读现象。 ## 当前读是如何避免幻读的? MySQL 里除了普通查询是快照读,其他都是**当前读**,比如 update、insert、delete,这些语句执行前都会查询最新版本的数据,然后再做进一步的操作。 这很好理解,假设你要 update 一个记录,另一个事务已经 delete 这条记录并且提交事务了,这样不是会产生冲突吗,所以 update 的时候肯定要知道最新的数据。 另外,`select ... for update` 这种查询语句是当前读,每次执行的时候都是读取最新的数据。 接下来,我们假设`select ... for update`当前读是不会加锁的(实际上是会加锁的),在做一遍实验。 ![](https://img-blog.csdnimg.cn/1f872ff92b644b5f81cee2dd9188b199.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5bCP5p6XY29kaW5n,size_20,color_FFFFFF,t_70,g_se,x_16) 这时候,事务 B 插入的记录,就会被事务 A 的第二条查询语句查询到(因为是当前读),这样就会出现前后两次查询的结果集合不一样,这就出现了幻读。 所以,**Innodb 引擎为了解决「可重复读」隔离级别使用「当前读」而造成的幻读问题,就引出了间隙锁**。 假设,表中有一个范围 id 为(3,5)间隙锁,那么其他事务就无法插入 id = 4 这条记录了,这样就有效的防止幻读现象的发生。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/gap锁.drawio.png) 举个具体例子,场景如下: ![](https://img-blog.csdnimg.cn/3af285a8e70f4d4198318057eb955520.png?) 事务 A 执行了这面这条锁定读语句后,就在对表中的记录加上 id 范围为 (2, +∞] 的 next-key lock(next-key lock 是间隙锁 + 记录锁的组合)。 然后,事务 B 在执行插入语句的时候,判断到插入的位置被事务 A 加了 next-key lock,于是事物 B 会生成一个插入意向锁,同时进入等待状态,直到事务 A 提交了事务。这就避免了由于事务 B 插入新记录而导致事务 A 发生幻读的现象。 ## 幻读被完全解决了吗? **可重复读隔离级别下虽然很大程度上避免了幻读,但是还是没有能完全解决幻读**。 我举例一个可重复读隔离级别发生幻读现象的场景。 ### 第一个发生幻读现象的场景 还是以这张表作为例子: ![](https://img-blog.csdnimg.cn/7f9df142b3594daeaaca495abb7133f5.png) 事务 A 执行查询 id = 5 的记录,此时表中是没有该记录的,所以查询不出来。 ```sql # 事务 A mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> select * from t_stu where id = 5; Empty set (0.01 sec) ``` 然后事务 B 插入一条 id = 5 的记录,并且提交了事务。 ```sql # 事务 B mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> insert into t_stu values(5, '小美', 18); Query OK, 1 row affected (0.00 sec) mysql> commit; Query OK, 0 rows affected (0.00 sec) ``` 此时,**事务 A 更新 id = 5 这条记录,对没错,事务 A 看不到 id = 5 这条记录,但是他去更新了这条记录,这场景确实很违和,然后再次查询 id = 5 的记录,事务 A 就能看到事务 B 插入的纪录了,幻读就是发生在这种违和的场景**。 ```sql # 事务 A mysql> update t_stu set name = '小林 coding' where id = 5; Query OK, 1 row affected (0.01 sec) Rows matched: 1 Changed: 1 Warnings: 0 mysql> select * from t_stu where id = 5; +----+--------------+------+ | id | name | age | +----+--------------+------+ | 5 | 小林coding | 18 | +----+--------------+------+ 1 row in set (0.00 sec) ``` 整个发生幻读的时序图如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/mysql/锁/幻读发生.drawio.png) 在可重复读隔离级别下,事务 A 第一次执行普通的 select 语句时生成了一个 ReadView,之后事务 B 向表中新插入了一条 id = 5 的记录并提交。接着,事务 A 对 id = 5 这条记录进行了更新操作,在这个时刻,这条新记录的 trx_id 隐藏列的值就变成了事务 A 的事务 id,之后事务 A 再使用普通 select 语句去查询这条记录时就可以看到这条记录了,于是就发生了幻读。 因为这种特殊现象的存在,所以我们认为 **MySQL Innodb 中的 MVCC 并不能完全避免幻读现象**。 ### 第二个发生幻读现象的场景 除了上面这一种场景会发生幻读现象之外,还有下面这个场景也会发生幻读现象。 - T1 时刻:事务 A 先执行「快照读语句」:select * from t_test where id > 100 得到了 3 条记录。 - T2 时刻:事务 B 往`t_test`表中插入一个 id= 200 的记录并提交; - T3 时刻:事务 A 再执行「当前读语句」select * from t_test where id > 100 for update 就会得到 4 条记录,此时也发生了幻读现象。 **要避免这类特殊场景下发生幻读的现象的话,就是尽量在开启事务之后,马上执行 select ... for update 这类当前读的语句**,因为它会对记录加 next-key lock,从而避免其他事务插入一条新记录。 ## 总结 MySQL InnoDB 引擎的可重复读隔离级别(默认隔离级),根据不同的查询方式,分别提出了避免幻读的方案: - 针对**快照读**(普通 select 语句),是通过 MVCC 方式解决了幻读。 - 针对**当前读**(select ... for update 等语句),是通过 next-key lock(记录锁 + 间隙锁)方式解决了幻读。 我举例了两个发生幻读场景的例子。 第一个例子:对于快照读,MVCC 并不能完全避免幻读现象。因为当事务 A 更新了一条事务 B 插入的记录,那么事务 A 前后两次查询的记录条目就不一样了,所以就发生幻读。 第二个例子:对于当前读,如果事务开启后,并没有执行当前读,而是先快照读,然后这期间如果其他事务插入了一条记录,那么事务后续使用当前读进行查询的时候,就会发现两次查询的记录条目就不一样了,所以就发生幻读。 所以,**MySQL 可重复读隔离级别并没有彻底解决幻读,只是很大程度上避免了幻读现象的发生。** 要避免这类特殊场景下发生幻读的现象的话,就是尽量在开启事务之后,马上执行 select ... for update 这类当前读的语句,因为它会对记录加 next-key lock,从而避免其他事务插入一条新记录。 ---- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/1_base/how_os_deal_network_package.md ================================================ # 2.3 Linux 系统是如何收发网络包的? 这次,就围绕一个问题来说。 **Linux 系统是如何收发网络包的?** ## 网络模型 为了使得多种设备能通过网络相互通信,和为了解决各种不同设备在网络互联中的兼容性问题,国际标准化组织制定了开放式系统互联通信参考模型(*Open System Interconnection Reference Model*),也就是 OSI 网络模型,该模型主要有 7 层,分别是应用层、表示层、会话层、传输层、网络层、数据链路层以及物理层。 每一层负责的职能都不同,如下: - 应用层,负责给应用程序提供统一的接口; - 表示层,负责把数据转换成兼容另一个系统能识别的格式; - 会话层,负责建立、管理和终止表示层实体之间的通信会话; - 传输层,负责端到端的数据传输; - 网络层,负责数据的路由、转发、分片; - 数据链路层,负责数据的封帧和差错检测,以及 MAC 寻址; - 物理层,负责在物理网络中传输数据帧; 由于 OSI 模型实在太复杂,提出的也只是概念理论上的分层,并没有提供具体的实现方案。 事实上,我们比较常见,也比较实用的是四层模型,即 TCP/IP 网络模型,Linux 系统正是按照这套网络模型来实现网络协议栈的。 TCP/IP 网络模型共有 4 层,分别是应用层、传输层、网络层和网络接口层,每一层负责的职能如下: - 应用层,负责向用户提供一组应用程序,比如 HTTP、DNS、FTP 等; - 传输层,负责端到端的通信,比如 TCP、UDP 等; - 网络层,负责网络包的封装、分片、路由、转发,比如 IP、ICMP 等; - 网络接口层,负责网络包在物理网络中的传输,比如网络包的封帧、MAC 寻址、差错检测,以及通过网卡传输网络帧等; TCP/IP 网络模型相比 OSI 网络模型简化了不少,也更加易记,它们之间的关系如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/OSI与TCP.png) 不过,我们常说的七层和四层负载均衡,是用 OSI 网络模型来描述的,七层对应的是应用层,四层对应的是传输层。 --- ## Linux 网络协议栈 我们可以把自己的身体比作应用层中的数据,打底衣服比作传输层中的 TCP 头,外套比作网络层中 IP 头,帽子和鞋子分别比作网络接口层的帧头和帧尾。 在冬天这个季节,当我们要从家里出去玩的时候,自然要先穿个打底衣服,再套上保暖外套,最后穿上帽子和鞋子才出门,这个过程就好像我们把 TCP 协议通信的网络包发出去的时候,会把应用层的数据按照网络协议栈层层封装和处理。 你从下面这张图可以看到,应用层数据在每一层的封装格式。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/封装.png) 其中: - 传输层,给应用数据前面增加了 TCP 头; - 网络层,给 TCP 数据包前面增加了 IP 头; - 网络接口层,给 IP 数据包前后分别增加了帧头和帧尾; 这些新增的头部和尾部,都有各自的作用,也都是按照特定的协议格式填充,这每一层都增加了各自的协议头,那自然网络包的大小就增大了,但物理链路并不能传输任意大小的数据包,所以在以太网中,规定了最大传输单元(MTU)是 `1500` 字节,也就是规定了单次传输的最大 IP 包大小。 当网络包超过 MTU 的大小,就会在网络层分片,以确保分片后的 IP 包不会超过 MTU 大小,如果 MTU 越小,需要的分包就越多,那么网络吞吐能力就越差,相反的,如果 MTU 越大,需要的分包就越少,那么网络吞吐能力就越好。 知道了 TCP/IP 网络模型,以及网络包的封装原理后,那么 Linux 网络协议栈的样子,你想必猜到了大概,它其实就类似于 TCP/IP 的四层结构: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/协议栈.png) 从上图的的网络协议栈,你可以看到: - 应用程序需要通过系统调用,来跟 Socket 层进行数据交互; - Socket 层的下面就是传输层、网络层和网络接口层; - 最下面的一层,则是网卡驱动程序和硬件网卡设备; ## Linux 接收网络包的流程 网卡是计算机里的一个硬件,专门负责接收和发送网络包,当网卡接收到一个网络包后,会通过 DMA 技术,将网络包写入到指定的内存地址,也就是写入到 Ring Buffer,这个是一个环形缓冲区,接着就会告诉操作系统这个网络包已经到达。 > 那应该怎么告诉操作系统这个网络包已经到达了呢? 最简单的一种方式就是触发中断,也就是每当网卡收到一个网络包,就触发一个中断告诉操作系统。 但是,这存在一个问题,在高性能网络场景下,网络包的数量会非常多,那么就会触发非常多的中断,要知道当 CPU 收到了中断,就会停下手里的事情,而去处理这些网络包,处理完毕后,才会回去继续其他事情,那么频繁地触发中断,则会导致 CPU 一直没完没了的处理中断,而导致其他任务可能无法继续前进,从而影响系统的整体效率。 所以为了解决频繁中断带来的性能开销,Linux 内核在 2.6 版本中引入了 **NAPI 机制**,它是混合「中断和轮询」的方式来接收网络包,它的核心概念就是**不采用中断的方式读取数据**,而是首先采用中断唤醒数据接收的服务程序,然后 `poll` 的方法来轮询数据。 因此,当有网络包到达时,会通过 DMA 技术,将网络包写入到指定的内存地址,接着网卡向 CPU 发起硬件中断,当 CPU 收到硬件中断请求后,根据中断表,调用已经注册的中断处理函数。 硬件中断处理函数会做如下的事情: - 需要先「暂时屏蔽中断」,表示已经知道内存中有数据了,告诉网卡下次再收到数据包直接写内存就可以了,不要再通知 CPU 了,这样可以提高效率,避免 CPU 不停的被中断。 - 接着,发起「软中断」,然后恢复刚才屏蔽的中断。 至此,硬件中断处理函数的工作就已经完成。 硬件中断处理函数做的事情很少,主要耗时的工作都交给软中断处理函数了。 > 软中断的处理 内核中的 ksoftirqd 线程专门负责软中断的处理,当 ksoftirqd 内核线程收到软中断后,就会来轮询处理数据。 ksoftirqd 线程会从 Ring Buffer 中获取一个数据帧,用 sk_buff 表示,从而可以作为一个网络包交给网络协议栈进行逐层处理。 > 网络协议栈 首先,会先进入到网络接口层,在这一层会检查报文的合法性,如果不合法则丢弃,合法则会找出该网络包的上层协议的类型,比如是 IPv4,还是 IPv6,接着再去掉帧头和帧尾,然后交给网络层。 到了网络层,则取出 IP 包,判断网络包下一步的走向,比如是交给上层处理还是转发出去。当确认这个网络包要发送给本机后,就会从 IP 头里看看上一层协议的类型是 TCP 还是 UDP,接着去掉 IP 头,然后交给传输层。 传输层取出 TCP 头或 UDP 头,根据四元组「源 IP、源端口、目的 IP、目的端口」作为标识,找出对应的 Socket,并把数据放到 Socket 的接收缓冲区。 最后,应用层程序调用 Socket 接口,将内核的 Socket 接收缓冲区的数据「拷贝」到应用层的缓冲区,然后唤醒用户进程。 至此,一个网络包的接收过程就已经结束了,你也可以从下图左边部分看到网络包接收的流程,右边部分刚好反过来,它是网络包发送的流程。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/收发流程.png) ## Linux 发送网络包的流程 如上图的右半部分,发送网络包的流程正好和接收流程相反。 首先,应用程序会调用 Socket 发送数据包的接口,由于这个是系统调用,所以会从用户态陷入到内核态中的 Socket 层,内核会申请一个内核态的 sk_buff 内存,**将用户待发送的数据拷贝到 sk_buff 内存,并将其加入到发送缓冲区**。 接下来,网络协议栈从 Socket 发送缓冲区中取出 sk_buff,并按照 TCP/IP 协议栈从上到下逐层处理。 如果使用的是 TCP 传输协议发送数据,那么**先拷贝一个新的 sk_buff 副本** ,这是因为 sk_buff 后续在调用网络层,最后到达网卡发送完成的时候,这个 sk_buff 会被释放掉。而 TCP 协议是支持丢失重传的,在收到对方的 ACK 之前,这个 sk_buff 不能被删除。所以内核的做法就是每次调用网卡发送的时候,实际上传递出去的是 sk_buff 的一个拷贝,等收到 ACK 再真正删除。 接着,对 sk_buff 填充 TCP 头。这里提一下,sk_buff 可以表示各个层的数据包,在应用层数据包叫 data,在 TCP 层我们称为 segment,在 IP 层我们叫 packet,在数据链路层称为 frame。 你可能会好奇,为什么全部数据包只用一个结构体来描述呢?协议栈采用的是分层结构,上层向下层传递数据时需要增加包头,下层向上层数据时又需要去掉包头,如果每一层都用一个结构体,那在层之间传递数据的时候,就要发生多次拷贝,这将大大降低 CPU 效率。 于是,为了在层级之间传递数据时,不发生拷贝,只用 sk_buff 一个结构体来描述所有的网络包,那它是如何做到的呢?是通过调整 sk_buff 中 `data` 的指针,比如: - 当接收报文时,从网卡驱动开始,通过协议栈层层往上传送数据报,通过增加 skb->data 的值,来逐步剥离协议首部。 - 当要发送报文时,创建 sk_buff 结构体,数据缓存区的头部预留足够的空间,用来填充各层首部,在经过各下层协议时,通过减少 skb->data 的值来增加协议首部。 你可以从下面这张图看到,当发送报文时,data 指针的移动过程。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E5%A4%9A%E8%B7%AF%E5%A4%8D%E7%94%A8/sk_buff.jpg) 至此,传输层的工作也就都完成了。 然后交给网络层,在网络层里会做这些工作:选取路由(确认下一跳的 IP)、填充 IP 头、netfilter 过滤、对超过 MTU 大小的数据包进行分片。处理完这些工作后会交给网络接口层处理。 网络接口层会通过 ARP 协议获得下一跳的 MAC 地址,然后对 sk_buff 填充帧头和帧尾,接着将 sk_buff 放到网卡的发送队列中。 这一些工作准备好后,会触发「软中断」告诉网卡驱动程序,这里有新的网络包需要发送,驱动程序会从发送队列中读取 sk_buff,将这个 sk_buff 挂到 RingBuffer 中,接着将 sk_buff 数据映射到网卡可访问的内存 DMA 区域,最后触发真实的发送。 当数据发送完成以后,其实工作并没有结束,因为内存还没有清理。当发送完成的时候,网卡设备会触发一个硬中断来释放内存,主要是释放 sk_buff 内存和清理 RingBuffer 内存。 最后,当收到这个 TCP 报文的 ACK 应答时,传输层就会释放原始的 sk_buff。 > 发送网络数据的时候,涉及几次内存拷贝操作? 第一次,调用发送数据的系统调用的时候,内核会申请一个内核态的 sk_buff 内存,将用户待发送的数据拷贝到 sk_buff 内存,并将其加入到发送缓冲区。 第二次,在使用 TCP 传输协议的情况下,从传输层进入网络层的时候,每一个 sk_buff 都会被克隆一个新的副本出来。副本 sk_buff 会被送往网络层,等它发送完的时候就会释放掉,然后原始的 sk_buff 还保留在传输层,目的是为了实现 TCP 的可靠传输,等收到这个数据包的 ACK 时,才会释放原始的 sk_buff。 第三次,当 IP 层发现 sk_buff 大于 MTU 时才需要进行。会再申请额外的 sk_buff,并将原来的 sk_buff 拷贝为多个小的 sk_buff。 ## 总结 电脑与电脑之间通常都是通过话网卡、交换机、路由器等网络设备连接到一起,那由于网络设备的异构性,国际标准化组织定义了一个七层的 OSI 网络模型,但是这个模型由于比较复杂,实际应用中并没有采用,而是采用了更为简化的 TCP/IP 模型,Linux 网络协议栈就是按照了该模型来实现的。 TCP/IP 模型主要分为应用层、传输层、网络层、网络接口层四层,每一层负责的职责都不同,这也是 Linux 网络协议栈主要构成部分。 当应用程序通过 Socket 接口发送数据包,数据包会被网络协议栈从上到下进行逐层处理后,才会被送到网卡队列中,随后由网卡将网络包发送出去。 而在接收网络包时,同样也要先经过网络协议栈从下到上的逐层处理,最后才会被送到应用程序。 ---- 参考资料: - Linux 网络包发送过程:https://mp.weixin.qq.com/s/wThfD9th9e_-YGHJJ3HXNQ - Linux 网络数据接收流程(TCP)- NAPI:https://wenfh2020.com/2021/12/29/kernel-tcp-receive/ - Linux 网络 - 数据包接收过程:https://blog.csdn.net/frank_jb/article/details/115841622 --- 哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,别忘记关注我哦! ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/1_base/tcp_ip_model.md ================================================ # 2.1 TCP/IP 网络模型有哪几层? 问大家,为什么要有 TCP/IP 网络模型? 对于同一台设备上的进程间通信,有很多种方式,比如有管道、消息队列、共享内存、信号等方式,而对于不同设备上的进程间通信,就需要网络通信,而设备是多样性的,所以要兼容多种多样的设备,就协商出了一套**通用的网络协议**。 这个网络协议是分层的,每一层都有各自的作用和职责,接下来就根据「TCP/IP 网络模型」分别对每一层进行介绍。 ## 应用层 最上层的,也是我们能直接接触到的就是**应用层**(*Application Layer*),我们电脑或手机使用的应用软件都是在应用层实现。那么,当两个不同设备的应用需要通信的时候,应用就把应用数据传给下一层,也就是传输层。 所以,应用层只需要专注于为用户提供应用功能,比如 HTTP、FTP、Telnet、DNS、SMTP 等。 应用层是不用去关心数据是如何传输的,就类似于,我们寄快递的时候,只需要把包裹交给快递员,由他负责运输快递,我们不需要关心快递是如何被运输的。 而且应用层是工作在操作系统中的用户态,传输层及以下则工作在内核态。 ## 传输层 应用层的数据包会传给传输层,**传输层**(*Transport Layer*)是为应用层提供网络支持的。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/应用层.png) 在传输层会有两个传输协议,分别是 TCP 和 UDP。 TCP 的全称叫传输控制协议(*Transmission Control Protocol*),大部分应用使用的正是 TCP 传输层协议,比如 HTTP 应用层协议。TCP 相比 UDP 多了很多特性,比如流量控制、超时重传、拥塞控制等,这些都是为了保证数据包能可靠地传输给对方。 UDP 相对来说就很简单,简单到只负责发送数据包,不保证数据包是否能抵达对方,但它实时性相对更好,传输效率也高。当然,UDP 也可以实现可靠传输,把 TCP 的特性在应用层上实现就可以,不过要实现一个商用的可靠 UDP 传输协议,也不是一件简单的事情。 应用需要传输的数据可能会非常大,如果直接传输就不好控制,因此当传输层的数据包大小超过 MSS(TCP 最大报文段长度) ,就要将数据包分块,这样即使中途有一个分块丢失或损坏了,只需要重新发送这一个分块,而不用重新发送整个数据包。在 TCP 协议中,我们把每个分块称为一个 **TCP 段**(*TCP Segment*)。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/TCP段.png) 当设备作为接收方时,传输层则要负责把数据包传给应用,但是一台设备上可能会有很多应用在接收或者传输数据,因此需要用一个编号将应用区分开来,这个编号就是**端口**。 比如 80 端口通常是 Web 服务器用的,22 端口通常是远程登录服务器用的。而对于浏览器(客户端)中的每个标签栏都是一个独立的进程,操作系统会为这些进程分配临时的端口号。 由于传输层的报文中会携带端口号,因此接收方可以识别出该报文是发送给哪个应用。 ## 网络层 传输层可能大家刚接触的时候,会认为它负责将数据从一个设备传输到另一个设备,事实上它并不负责。 实际场景中的网络环节是错综复杂的,中间有各种各样的线路和分叉路口,如果一个设备的数据要传输给另一个设备,就需要在各种各样的路径和节点进行选择,而传输层的设计理念是简单、高效、专注,如果传输层还负责这一块功能就有点违背设计原则了。 也就是说,我们不希望传输层协议处理太多的事情,只需要服务好应用即可,让其作为应用间数据传输的媒介,帮助实现应用到应用的通信,而实际的传输功能就交给下一层,也就是**网络层**(*Internet Layer*)。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/网络层.png) 网络层最常使用的是 IP 协议(*Internet Protocol*),IP 协议会将传输层的报文作为数据部分,再加上 IP 报头组装成 IP 报文,如果 IP 报文大小超过 MTU(以太网中一般为 1500 字节)就会**再次进行分片**,得到一个即将发送到网络的 IP 报文。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/键入网址过程/12.jpg) 网络层负责将数据从一个设备传输到另一个设备,世界上那么多设备,又该如何找到对方呢?因此,网络层需要有区分设备的编号。 我们一般用 IP 地址给设备进行编号,对于 IPv4 协议,IP 地址共 32 位,分成了四段(比如,192.168.100.1),每段是 8 位。只有一个单纯的 IP 地址虽然做到了区分设备,但是寻址起来就特别麻烦,全世界那么多台设备,难道一个一个去匹配?这显然不科学。 因此,需要将 IP 地址分成两种意义: - 一个是**网络号**,负责标识该 IP 地址是属于哪个「子网」的; - 一个是**主机号**,负责标识同一「子网」下的不同主机; 怎么分的呢?这需要配合**子网掩码**才能算出 IP 地址 的网络号和主机号。 举个例子,比如 10.100.122.0/24,后面的`/24`表示就是 `255.255.255.0` 子网掩码,255.255.255.0 二进制是「11111111-11111111-11111111-00000000」,大家数数一共多少个 1?不用数了,是 24 个 1,为了简化子网掩码的表示,用/24 代替 255.255.255.0。 知道了子网掩码,该怎么计算出网络地址和主机地址呢? 将 10.100.122.2 和 255.255.255.0 进行**按位与运算**,就可以得到网络号,如下图: ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/16.jpg) 将 255.255.255.0 取反后与 IP 地址进行进行**按位与运算**,就可以得到主机号。 大家可以去搜索下子网掩码计算器,自己改变下「掩码位」的数值,就能体会到子网掩码的作用了。 ![子网掩码计算器](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4/网络/子网掩码计算器.png) 那么在寻址的过程中,先匹配到相同的网络号(表示要找到同一个子网),才会去找对应的主机。 除了寻址能力,IP 协议还有另一个重要的能力就是**路由**。实际场景中,两台设备并不是用一条网线连接起来的,而是通过很多网关、路由器、交换机等众多网络设备连接起来的,那么就会形成很多条网络的路径,因此当数据包到达一个网络节点,就需要通过路由算法决定下一步走哪条路径。 路由器寻址工作中,就是要找到目标地址的子网,找到后进而把数据包转发给对应的网络内。 ![IP 地址的网络号](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/17.jpg) 所以,**IP 协议的寻址作用是告诉我们去往下一个目的地该朝哪个方向走,路由则是根据「下一个目的地」选择路径。寻址更像在导航,路由更像在操作方向盘**。 ## 网络接口层 生成了 IP 头部之后,接下来要交给**网络接口层**(*Link Layer*)在 IP 头部的前面加上 MAC 头部,并封装成数据帧(Data frame)发送到网络上。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/网络接口层.png) IP 头部中的接收方 IP 地址表示网络包的目的地,通过这个地址我们就可以判断要将包发到哪里,但在以太网的世界中,这个思路是行不通的。 什么是以太网呢?电脑上的以太网接口,Wi-Fi 接口,以太网交换机、路由器上的千兆,万兆以太网口,还有网线,它们都是以太网的组成部分。以太网就是一种在「局域网」内,把附近的设备连接起来,使它们之间可以进行通讯的技术。 以太网在判断网络包目的地时和 IP 的方式不同,因此必须采用相匹配的方式才能在以太网中将包发往目的地,而 MAC 头部就是干这个用的,所以,在以太网进行通讯要用到 MAC 地址。 MAC 头部是以太网使用的头部,它包含了接收方和发送方的 MAC 地址等信息,我们可以通过 ARP 协议获取对方的 MAC 地址。 所以说,网络接口层主要为网络层提供「链路级别」传输的服务,负责在以太网、WiFi 这样的底层网络上发送原始数据包,工作在网卡这个层次,使用 MAC 地址来标识网络上的设备。 ## 总结 综上所述,TCP/IP 网络通常是由上到下分成 4 层,分别是**应用层,传输层,网络层和网络接口层**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/tcpip参考模型.drawio.png) 再给大家贴一下每一层的封装格式: ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%B5%AE%E7%82%B9/%E5%B0%81%E8%A3%85.png) 网络接口层的传输单位是帧(frame),IP 层的传输单位是包(packet),TCP 层的传输单位是段(segment),HTTP 的传输单位则是消息或报文(message)。但这些名词并没有什么本质的区分,可以统称为数据包。 --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/1_base/what_happen_url.md ================================================ # 2.2 键入网址到网页显示,期间发生了什么? 想必不少小伙伴面试过程中,会遇到「**当键入网址后,到网页显示,其间发生了什么**」的面试题。 还别说,这问题真挺常问的,前几天坐在我旁边的主管电话面试应聘者的时候,也问了这个问题。 接下来以下图较简单的网络拓扑模型作为例子,探究探究其间发生了什么? ![简单的网络模型](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/2.jpg) ## 孤单小弟 —— HTTP > 浏览器做的第一步工作是解析 URL 首先浏览器做的第一步工作就是要对 `URL` 进行解析,从而生成发送给 `Web` 服务器的请求信息。 让我们看看一条长长的 URL 里的各个元素的代表什么,见下图: ![URL 解析](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/3.jpg) 所以图中的长长的 URL 实际上是请求服务器里的文件资源。 > 要是上图中的蓝色部分 URL 元素都省略了,那应该是请求哪个文件呢? 当没有路径名时,就代表访问根目录下事先设置的**默认文件**,也就是 `/index.html` 或者 `/default.html` 这些文件,这样就不会发生混乱了。 > 生产 HTTP 请求信息 对 `URL` 进行解析之后,浏览器确定了 Web 服务器和文件名,接下来就是根据这些信息来生成 HTTP 请求消息了。 ![HTTP 的消息格式](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/4.jpg) > 一个孤单 HTTP 数据包表示:“我这么一个小小的数据包,没亲没友,直接发到浩瀚的网络,谁会知道我呢?谁能载我一程呢?谁能保护我呢?我的目的地在哪呢?”充满各种疑问的它,没有停滞不前,依然踏上了征途! --- ## 真实地址查询 —— DNS 通过浏览器解析 URL 并生成 HTTP 消息后,需要委托操作系统将消息发送给 `Web` 服务器。 但在发送之前,还有一项工作需要完成,那就是**查询服务器域名对应的 IP 地址**,因为委托操作系统发送消息时,必须提供通信对象的 IP 地址。 比如我们打电话的时候,必须要知道对方的电话号码,但由于电话号码难以记忆,所以通常我们会将对方电话号 + 姓名保存在通讯录里。 所以,有一种服务器就专门保存了 `Web` 服务器域名与 `IP` 的对应关系,它就是 `DNS` 服务器。 > 域名的层级关系 DNS 中的域名都是用**句点**来分隔的,比如 `www.server.com`,这里的句点代表了不同层次之间的**界限**。 在域名中,**越靠右**的位置表示其层级**越高**。 毕竟域名是外国人发明,所以思维和中国人相反,比如说一个城市地点的时候,外国喜欢从小到大的方式顺序说起(如 XX 街道 XX 区 XX 市 XX 省),而中国则喜欢从大到小的顺序(如 XX 省 XX 市 XX 区 XX 街道)。 实际上域名最后还有一个点,比如 `www.server.com.`,这个最后的一个点代表根域名。 也就是,`.` 根域是在最顶层,它的下一层就是 `.com` 顶级域,再下面是 `server.com`。 所以域名的层级关系类似一个树状结构: - 根 DNS 服务器(.) - 顶级域 DNS 服务器(.com) - 权威 DNS 服务器(server.com) ![DNS 树状结构](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/5.jpg) 根域的 DNS 服务器信息保存在互联网中所有的 DNS 服务器中。 这样一来,任何 DNS 服务器就都可以找到并访问根域 DNS 服务器了。 因此,客户端只要能够找到任意一台 DNS 服务器,就可以通过它找到根域 DNS 服务器,然后再一路顺藤摸瓜找到位于下层的某台目标 DNS 服务器。 > 域名解析的工作流程 1. 客户端首先会发出一个 DNS 请求,问 www.server.com 的 IP 是啥,并发给本地 DNS 服务器(也就是客户端的 TCP/IP 设置中填写的 DNS 服务器地址)。 2. 本地域名服务器收到客户端的请求后,如果缓存里的表格能找到 www.server.com,则它直接返回 IP 地址。如果没有,本地 DNS 会去问它的根域名服务器:“老大,能告诉我 www.server.com 的 IP 地址吗?”根域名服务器是最高层次的,它不直接用于域名解析,但能指明一条道路。 3. 根 DNS 收到来自本地 DNS 的请求后,发现后置是 .com,说:“www.server.com 这个域名归 .com 区域管理”,我把 .com 顶级域名服务器的地址给你,你去问问它吧。” 4. 本地 DNS 收到顶级域名服务器的地址后,发起请求问“老二,你能告诉我 www.server.com 的 IP 地址吗?” 5. 顶级域名服务器说:“我给你负责 www.server.com 区域的权威 DNS 服务器的地址,你去问它应该能问到”。 6. 本地 DNS 于是转向问权威 DNS 服务器:“老三,www.server.com 对应的 IP 是啥呀?”server.com 的权威 DNS 服务器,它是域名解析结果的原出处。为啥叫权威呢?就是我的域名我做主。 7. 权威 DNS 服务器查询后将对应的 IP 地址 X.X.X.X 告诉本地 DNS。 8. 本地 DNS 再将 IP 地址返回客户端,客户端和目标建立连接。 至此,我们完成了 DNS 的解析过程。现在总结一下,整个过程我画成了一个图。 ![域名解析的工作流程](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/6.jpg) DNS 域名解析的过程蛮有意思的,整个过程就和我们日常生活中找人问路的过程类似,**只指路不带路**。 > 那是不是每次解析域名都要经过那么多的步骤呢? 当然不是了,还有缓存这个东西的嘛。 浏览器会先看自身有没有对这个域名的缓存,如果有,就直接返回,如果没有,就去问操作系统,操作系统也会去看自己的缓存,如果有,就直接返回,如果没有,再去 hosts 文件看,也没有,才会去问「本地 DNS 服务器」。 > 数据包表示:“DNS 老大哥厉害呀,找到了目的地了!我还是很迷茫呀,我要发出去,接下来我需要谁的帮助呢?” ---- ## 指南好帮手 —— 协议栈 通过 DNS 获取到 IP 后,就可以把 HTTP 的传输工作交给操作系统中的**协议栈**。 协议栈的内部分为几个部分,分别承担不同的工作。上下关系是有一定的规则的,上面的部分会向下面的部分委托工作,下面的部分收到委托的工作并执行。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/7.jpg) 应用程序(浏览器)通过调用 Socket 库,来委托协议栈工作。协议栈的上半部分有两块,分别是负责收发数据的 TCP 和 UDP 协议,这两个传输协议会接受应用层的委托执行收发数据的操作。 协议栈的下面一半是用 IP 协议控制网络包收发操作,在互联网上传数据时,数据会被切分成一块块的网络包,而将网络包发送给对方的操作就是由 IP 负责的。 此外 IP 中还包括 `ICMP` 协议和 `ARP` 协议。 - `ICMP` 用于告知网络包传送过程中产生的错误以及各种控制信息。 - `ARP` 用于根据 IP 地址查询相应的以太网 MAC 地址。 IP 下面的网卡驱动程序负责控制网卡硬件,而最下面的网卡则负责完成实际的收发操作,也就是对网线中的信号执行发送和接收操作。 > 数据包看了这份指南表示:“原来我需要那么多大佬的协助啊,那我先去找找 TCP 大佬!” ---- ## 可靠传输 —— TCP HTTP 是基于 TCP 协议传输的,所以在这我们先了解下 TCP 协议。 > TCP 包头格式 我们先看看 TCP 报文头部的格式: ![TCP 包头格式](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/8.jpg) 首先,**源端口号**和**目标端口**号是不可少的,如果没有这两个端口号,数据就不知道应该发给哪个应用。 接下来有包的**序**号,这个是为了解决包乱序的问题。 还有应该有的是**确认号**,目的是确认发出去对方是否有收到。如果没有收到就应该重新发送,直到送达,这个是为了解决不丢包的问题。 接下来还有一些**状态位**。例如 `SYN` 是发起一个连接,`ACK` 是回复,`RST` 是重新连接,`FIN` 是结束连接等。TCP 是面向连接的,因而双方要维护连接的状态,这些带状态位的包的发送,会引起双方的状态变更。 还有一个重要的就是**窗口大小**。TCP 要做**流量控制**,通信双方各声明一个窗口(缓存大小),标识自己当前能够的处理能力,别发送的太快,撑死我,也别发的太慢,饿死我。 除了做流量控制以外,TCP 还会做**拥塞控制**,对于真正的通路堵车不堵车,它无能为力,唯一能做的就是控制自己,也即控制发送的速度。不能改变世界,就改变自己嘛。 > TCP 传输数据之前,要先三次握手建立连接 在 HTTP 传输数据之前,首先需要 TCP 建立连接,TCP 连接的建立,通常称为**三次握手**。 这个所谓的「连接」,只是双方计算机里维护一个状态机,在连接建立的过程中,双方的状态变化时序图就像这样。 ![TCP 三次握手](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4/网络/TCP三次握手.drawio.png) - 一开始,客户端和服务端都处于 `CLOSED` 状态。先是服务端主动监听某个端口,处于 `LISTEN` 状态。 - 然后客户端主动发起连接 `SYN`,之后处于 `SYN-SENT` 状态。 - 服务端收到发起的连接,返回 `SYN`,并且 `ACK` 客户端的 `SYN`,之后处于 `SYN-RCVD` 状态。 - 客户端收到服务端发送的 `SYN` 和 `ACK` 之后,发送对 `SYN` 确认的 `ACK`,之后处于 `ESTABLISHED` 状态,因为它一发一收成功了。 - 服务端收到 `ACK` 的 `ACK` 之后,处于 `ESTABLISHED` 状态,因为它也一发一收了。 所以三次握手目的是**保证双方都有发送和接收的能力**。 > 如何查看 TCP 的连接状态? TCP 的连接状态查看,在 Linux 可以通过 `netstat -napt` 命令查看。 ![TCP 连接状态查看](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/10.jpg) > TCP 分割数据 如果 HTTP 请求消息比较长,超过了 `MSS` 的长度,这时 TCP 就需要把 HTTP 的数据拆解成一块块的数据发送,而不是一次性发送所有数据。 ![MTU 与 MSS](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/11.jpg) - `MTU`:一个网络包的最大长度,以太网中一般为 `1500` 字节。 - `MSS`:除去 IP 和 TCP 头部之后,一个网络包所能容纳的 TCP 数据的最大长度。 数据会被以 `MSS` 的长度为单位进行拆分,拆分出来的每一块数据都会被放进单独的网络包中。也就是在每个被拆分的数据加上 TCP 头信息,然后交给 IP 模块来发送数据。 ![数据包分割](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/12.jpg) > TCP 报文生成 TCP 协议里面会有两个端口,一个是浏览器监听的端口(通常是随机生成的),一个是 Web 服务器监听的端口(HTTP 默认端口号是 `80`,HTTPS 默认端口号是 `443`)。 在双方建立了连接后,TCP 报文中的数据部分就是存放 HTTP 头部 + 数据,组装好 TCP 报文之后,就需交给下面的网络层处理。 至此,网络包的报文如下图。 ![TCP 层报文](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/13.jpg) > 此时,遇上了 TCP 的 数据包激动表示:“太好了,碰到了可靠传输的 TCP 传输,它给我加上 TCP 头部,我不再孤单了,安全感十足啊!有大佬可以保护我的可靠送达!但我应该往哪走呢?” --- ## 远程定位 —— IP TCP 模块在执行连接、收发、断开等各阶段操作时,都需要委托 IP 模块将数据封装成**网络包**发送给通信对象。 > IP 包头格式 我们先看看 IP 报文头部的格式: ![IP 包头格式](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/14.jpg) 在 IP 协议里面需要有**源地址 IP** 和 **目标地址 IP**: - 源地址 IP,即是客户端输出的 IP 地址; - 目标地址,即通过 DNS 域名解析得到的 Web 服务器 IP。 因为 HTTP 是经过 TCP 传输的,所以在 IP 包头的**协议号**,要填写为 `06`(十六进制),表示协议为 TCP。 > 假设客户端有多个网卡,就会有多个 IP 地址,那 IP 头部的源地址应该选择哪个 IP 呢? 当存在多个网卡时,在填写源地址 IP 时,就需要判断到底应该填写哪个地址。这个判断相当于在多块网卡中判断应该使用哪个一块网卡来发送包。 这个时候就需要根据**路由表**规则,来判断哪一个网卡作为源地址 IP。 在 Linux 操作系统,我们可以使用 `route -n` 命令查看当前系统的路由表。 ![路由表](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/15.jpg) 举个例子,根据上面的路由表,我们假设 Web 服务器的目标地址是 `192.168.10.200`。 ![路由规则判断](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/16.jpg) 1. 首先先和第一条目的子网掩码(`Genmask`)进行 **与运算**,得到结果为 `192.168.10.0`,但是第一个条目的 `Destination` 是 `192.168.3.0`,两者不一致所以匹配失败。 2. 再与第二条目的子网掩码进行 **与运算**,得到的结果为 `192.168.10.0`,与第二条目的 `Destination 192.168.10.0` 匹配成功,所以将使用 `eth1` 网卡的 IP 地址作为 IP 包头的源地址。 那么假设 Web 服务器的目标地址是 `10.100.20.100`,那么依然依照上面的路由表规则判断,判断后的结果是和第三条目匹配。 第三条目比较特殊,它目标地址和子网掩码都是 `0.0.0.0`,这表示**默认网关**,如果其他所有条目都无法匹配,就会自动匹配这一行。并且后续就把包发给路由器,`Gateway` 即是路由器的 IP 地址。 > IP 报文生成 至此,网络包的报文如下图。 ![IP 层报文](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/17.jpg) > 此时,加上了 IP 头部的数据包表示:“有 IP 大佬给我指路了,感谢 IP 层给我加上了 IP 包头,让我有了远程定位的能力,不会害怕在浩瀚的互联网迷茫了!可是目的地好远啊,我下一站应该去哪呢?” --- ## 两点传输 —— MAC 生成了 IP 头部之后,接下来网络包还需要在 IP 头部的前面加上 **MAC 头部**。 > MAC 包头格式 MAC 头部是以太网使用的头部,它包含了接收方和发送方的 MAC 地址等信息。 ![MAC 包头格式](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/18.jpg) 在 MAC 包头里需要**发送方 MAC 地址**和**接收方目标 MAC 地址**,用于**两点之间的传输**。 一般在 TCP/IP 通信里,MAC 包头的**协议类型**只使用: - `0800` :IP 协议 - `0806` :ARP 协议 > MAC 发送方和接收方如何确认? **发送方**的 MAC 地址获取就比较简单了,MAC 地址是在网卡生产时写入到 ROM 里的,只要将这个值读取出来写入到 MAC 头部就可以了。 **接收方**的 MAC 地址就有点复杂了,只要告诉以太网对方的 MAC 的地址,以太网就会帮我们把包发送过去,那么很显然这里应该填写对方的 MAC 地址。 所以先得搞清楚应该把包发给谁,这个只要查一下**路由表**就知道了。在路由表中找到相匹配的条目,然后把包发给 `Gateway` 列中的 IP 地址就可以了。 > 既然知道要发给谁,那如何获取对方的 MAC 地址呢? 不知道对方 MAC 地址?不知道就喊呗。 此时就需要 `ARP` 协议帮我们找到路由器的 MAC 地址。 ![ARP 广播](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/19.jpg) ARP 协议会在以太网中以**广播**的形式,对以太网所有的设备喊出:“这个 IP 地址是谁的?请把你的 MAC 地址告诉我”。 然后就会有人回答:“这个 IP 地址是我的,我的 MAC 地址是 XXXX”。 如果对方和自己处于同一个子网中,那么通过上面的操作就可以得到对方的 MAC 地址。然后,我们将这个 MAC 地址写入 MAC 头部,MAC 头部就完成了。 > 好像每次都要广播获取,这不是很麻烦吗? 放心,在后续操作系统会把本次查询结果放到一块叫做 **ARP 缓存**的内存空间留着以后用,不过缓存的时间就几分钟。 也就是说,在发包时: - 先查询 ARP 缓存,如果其中已经保存了对方的 MAC 地址,就不需要发送 ARP 查询,直接使用 ARP 缓存中的地址。 - 而当 ARP 缓存中不存在对方 MAC 地址时,则发送 ARP 广播查询。 > 查看 ARP 缓存内容 在 Linux 系统中,我们可以使用 `arp -a` 命令来查看 ARP 缓存的内容。 ![ARP 缓存内容](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/20.jpg) > MAC 报文生成 至此,网络包的报文如下图。 ![MAC 层报文](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/21.jpg) > 此时,加上了 MAC 头部的数据包万分感谢,说道:“感谢 MAC 大佬,我知道我下一步要去哪了!我现在有很多头部兄弟,相信我可以到达最终的目的地!”。 > 带着众多头部兄弟的数据包,终于准备要出门了。 --- ## 出口 —— 网卡 网络包只是存放在内存中的一串二进制数字信息,没有办法直接发送给对方。因此,我们需要将**数字信息转换为电信号**,才能在网线上传输,也就是说,这才是真正的数据发送过程。 负责执行这一操作的是**网卡**,要控制网卡还需要靠**网卡驱动程序**。 网卡驱动获取网络包之后,会将其**复制**到网卡内的缓存区中,接着会在其**开头加上报头和起始帧分界符,在末尾加上用于检测错误的帧校验序列**。 ![数据包](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4/网络/数据包.drawio.png) - 起始帧分界符是一个用来表示包起始位置的标记 - 末尾的 `FCS`(帧校验序列)用来检查包传输过程是否有损坏 最后网卡会将包转为电信号,通过网线发送出去。 > 唉,真是不容易,发一个包,真是历经千辛万苦。致此,一个带有许多头部的数据终于踏上寻找目的地的征途了! --- ## 送别者 —— 交换机 下面来看一下包是如何通过交换机的。交换机的设计是将网络包**原样**转发到目的地。交换机工作在 MAC 层,也称为**二层网络设备**。 > 交换机的包接收操作 首先,电信号到达网线接口,交换机里的模块进行接收,接下来交换机里的模块将电信号转换为数字信号。 然后通过包末尾的 `FCS` 校验错误,如果没问题则放到缓冲区。这部分操作基本和计算机的网卡相同,但交换机的工作方式和网卡不同。 计算机的网卡本身具有 MAC 地址,并通过核对收到的包的接收方 MAC 地址判断是不是发给自己的,如果不是发给自己的则丢弃;相对地,交换机的端口不核对接收方 MAC 地址,而是直接接收所有的包并存放到缓冲区中。因此,和网卡不同,**交换机的端口不具有 MAC 地址**。 将包存入缓冲区后,接下来需要查询一下这个包的接收方 MAC 地址是否已经在 MAC 地址表中有记录了。 交换机的 MAC 地址表主要包含两个信息: - 一个是设备的 MAC 地址, - 另一个是该设备连接在交换机的哪个端口上。 ![交换机的 MAC 地址表](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/23.jpg) 举个例子,如果收到的包的接收方 MAC 地址为 `00-02-B3-1C-9C-F9`,则与图中表中的第 3 行匹配,根据端口列的信息,可知这个地址位于 `3` 号端口上,然后就可以通过交换电路将包发送到相应的端口了。 所以,**交换机根据 MAC 地址表查找 MAC 地址,然后将信号发送到相应的端口**。 > 当 MAC 地址表找不到指定的 MAC 地址会怎么样? 地址表中找不到指定的 MAC 地址。这可能是因为具有该地址的设备还没有向交换机发送过包,或者这个设备一段时间没有工作导致地址被从地址表中删除了。 这种情况下,交换机无法判断应该把包转发到哪个端口,只能将包转发到除了源端口之外的所有端口上,无论该设备连接在哪个端口上都能收到这个包。 这样做不会产生什么问题,因为以太网的设计本来就是将包发送到整个网络的,然后**只有相应的接收者才接收包,而其他设备则会忽略这个包**。 有人会说:“这样做会发送多余的包,会不会造成网络拥塞呢?” 其实完全不用过于担心,因为发送了包之后目标设备会作出响应,只要返回了响应包,交换机就可以将它的地址写入 MAC 地址表,下次也就不需要把包发到所有端口了。 局域网中每秒可以传输上千个包,多出一两个包并无大碍。 此外,如果接收方 MAC 地址是一个**广播地址**,那么交换机会将包发送到除源端口之外的所有端口。 以下两个属于广播地址: - MAC 地址中的 `FF:FF:FF:FF:FF:FF` - IP 地址中的 `255.255.255.255` > 数据包通过交换机转发抵达了路由器,准备要离开土生土长的子网了。此时,数据包和交换机离别时说道:“感谢交换机兄弟,帮我转发到出境的大门,我要出远门啦!” --- ## 出境大门 —— 路由器 > 路由器与交换机的区别 网络包经过交换机之后,现在到达了**路由器**,并在此被转发到下一个路由器或目标设备。 这一步转发的工作原理和交换机类似,也是通过查表判断包转发的目标。 不过在具体的操作过程上,路由器和交换机是有区别的。 - 因为**路由器**是基于 IP 设计的,俗称**三层**网络设备,路由器的各个端口都具有 MAC 地址和 IP 地址; - 而**交换机**是基于以太网设计的,俗称**二层**网络设备,交换机的端口不具有 MAC 地址。 > 路由器基本原理 路由器的端口具有 MAC 地址,因此它就能够成为以太网的发送方和接收方;同时还具有 IP 地址,从这个意义上来说,它和计算机的网卡是一样的。 当转发包时,首先路由器端口会接收发给自己的以太网包,然后**路由表**查询转发目标,再由相应的端口作为发送方将以太网包发送出去。 > 路由器的包接收操作 首先,电信号到达网线接口部分,路由器中的模块会将电信号转成数字信号,然后通过包末尾的 `FCS` 进行错误校验。 如果没问题则检查 MAC 头部中的**接收方 MAC 地址**,看看是不是发给自己的包,如果是就放到接收缓冲区中,否则就丢弃这个包。 总的来说,路由器的端口都具有 MAC 地址,只接收与自身地址匹配的包,遇到不匹配的包则直接丢弃。 > 查询路由表确定输出端口 完成包接收操作之后,路由器就会**去掉**包开头的 MAC 头部。 **MAC 头部的作用就是将包送达路由器**,其中的接收方 MAC 地址就是路由器端口的 MAC 地址。因此,当包到达路由器之后,MAC 头部的任务就完成了,于是 MAC 头部就会**被丢弃**。 接下来,路由器会根据 MAC 头部后方的 `IP` 头部中的内容进行包的转发操作。 转发操作分为几个阶段,首先是查询**路由表**判断转发目标。 ![路由器转发](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/24.jpg) 具体的工作流程根据上图,举个例子。 假设地址为 `10.10.1.101` 的计算机要向地址为 `192.168.1.100` 的服务器发送一个包,这个包先到达图中的路由器。 判断转发目标的第一步,就是根据包的接收方 IP 地址查询路由表中的目标地址栏,以找到相匹配的记录。 路由匹配和前面讲的一样,每个条目的子网掩码和 `192.168.1.100` IP 做 **& 与运算**后,得到的结果与对应条目的目标地址进行匹配,如果匹配就会作为候选转发目标,如果不匹配就继续与下个条目进行路由匹配。 如第二条目的子网掩码 `255.255.255.0` 与 `192.168.1.100` IP 做 **& 与运算**后,得到结果是 `192.168.1.0` ,这与第二条目的目标地址 `192.168.1.0` 匹配,该第二条目记录就会被作为转发目标。 实在找不到匹配路由时,就会选择**默认路由**,路由表中子网掩码为 `0.0.0.0` 的记录表示「默认路由」。 > 路由器的发送操作 接下来就会进入包的**发送操作**。 首先,我们需要根据**路由表的网关列**判断对方的地址。 - 如果网关是一个 IP 地址,则这个 IP 地址就是我们要转发到的目标地址,**还未抵达终点**,还需继续需要路由器转发。 - 如果网关为空,则 IP 头部中的接收方 IP 地址就是要转发到的目标地址,也是就终于找到 IP 包头里的目标地址了,说明**已抵达终点**。 知道对方的 IP 地址之后,接下来需要通过 `ARP` 协议根据 IP 地址查询 MAC 地址,并将查询的结果作为接收方 MAC 地址。 路由器也有 ARP 缓存,因此首先会在 ARP 缓存中查询,如果找不到则发送 ARP 查询请求。 接下来是发送方 MAC 地址字段,这里填写输出端口的 MAC 地址。还有一个以太类型字段,填写 `0800` (十六进制)表示 IP 协议。 网络包完成后,接下来会将其转换成电信号并通过端口发送出去。这一步的工作过程和计算机也是相同的。 发送出去的网络包会通过**交换机**到达下一个路由器。由于接收方 MAC 地址就是下一个路由器的地址,所以交换机会根据这一地址将包传输到下一个路由器。 接下来,下一个路由器会将包转发给再下一个路由器,经过层层转发之后,网络包就到达了最终的目的地。 不知你发现了没有,在网络包传输的过程中,**源 IP 和目标 IP 始终是不会变的,一直变化的是 MAC 地址(NAT除外)**,因为需要 MAC 地址在以太网内进行**两个设备**之间的包传输。 > 数据包通过多个路由器道友的帮助,在网络世界途经了很多路程,最终抵达了目的地的城门!城门值守的路由器,发现了这个小兄弟数据包原来是找城内的人,于是它就将数据包送进了城内,再经由城内的交换机帮助下,最终转发到了目的地了。数据包感慨万千的说道:“多谢这一路上,各路大侠的相助!” --- ## 互相扒皮 —— 服务器 与 客户端 数据包抵达了服务器,服务器肯定高兴呀,正所谓有朋自远方来,不亦乐乎? 服务器高兴的不得了,于是开始扒数据包的皮!就好像你收到快递,能不兴奋吗? ![网络分层模型](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/%E9%94%AE%E5%85%A5%E7%BD%91%E5%9D%80%E8%BF%87%E7%A8%8B/25.jpg) 数据包抵达服务器后,服务器会先扒开数据包的 MAC 头部,查看是否和服务器自己的 MAC 地址符合,符合就将包收起来。 接着继续扒开数据包的 IP 头,发现 IP 地址符合,根据 IP 头中协议项,知道自己上层是 TCP 协议。 于是,扒开 TCP 的头,里面有序列号,需要看一看这个序列包是不是我想要的,如果是就放入缓存中然后返回一个 ACK,如果不是就丢弃。TCP 头部里面还有端口号,HTTP 的服务器正在监听这个端口号。 于是,服务器自然就知道是 HTTP 进程想要这个包,于是就将包发给 HTTP 进程。 服务器的 HTTP 进程看到,原来这个请求是要访问一个页面,于是就把这个网页封装在 HTTP 响应报文里。 HTTP 响应报文也需要穿上 TCP、IP、MAC 头部,不过这次是源地址是服务器 IP 地址,目的地址是客户端 IP 地址。 穿好头部衣服后,从网卡出去,交由交换机转发到出城的路由器,路由器就把响应数据包发到了下一个路由器,就这样跳啊跳。 最后跳到了客户端的城门把守的路由器,路由器扒开 IP 头部发现是要找城内的人,于是又把包发给了城内的交换机,再由交换机转发到客户端。 客户端收到了服务器的响应数据包后,同样也非常的高兴,客户能拆快递了! 于是,客户端开始扒皮,把收到的数据包的皮扒剩 HTTP 响应报文后,交给浏览器去渲染页面,一份特别的数据包快递,就这样显示出来了! 最后,客户端要离开了,向服务器发起了 TCP 四次挥手,至此双方的连接就断开了。 ---- ## 一个数据包臭不要脸的感受 > 下面内容的「我」,代表「臭美的数据包角色」。注:(括号的内容)代表我的吐槽,三连呸! 我一开始我虽然孤单、不知所措,但没有停滞不前。我依然满怀信心和勇气开始了征途。(**你当然有勇气,你是应用层数据,后面有底层兄弟当靠山,我呸!**) 我很庆幸遇到了各路神通广大的大佬,有可靠传输的 TCP、有远程定位功能的 IP、有指明下一站位置的 MAC 等(**你当然会遇到,因为都被计算机安排好的,我呸!**)。 这些大佬都给我前面加上了头部,使得我能在交换机和路由器的转发下,抵达到了目的地!(**哎,你也不容易,不吐槽了,放过你!**) 这一路上的经历,让我认识到了网络世界中各路大侠协作的重要性,是他们维护了网络世界的秩序,感谢他们!(**我呸,你应该感谢众多计算机科学家!**) ---- 参考资料 [1] 户根勤。网络是怎么连接的。人民邮电出版社。 [2] 刘超。趣谈网络协议。极客时间。 ---- ## 读者问答 > 读者问:“笔记本的是自带交换机的吗?交换机现在我还不知道是什么” 笔记本不是交换机,交换机通常是 2 个网口以上。 现在家里的路由器其实有了交换机的功能了。交换机可以简单理解成一个设备,三台电脑网线接到这个设备,这三台电脑就可以互相通信了,交换机嘛,交换数据这么理解就可以。 > 读者问:“如果知道你电脑的 Mac 地址,我可以直接给你发消息吗?” Mac 地址只能是两个设备之间传递时使用的,如果你要从大老远给我发消息,是离不开 IP 的。 > 读者问:“请问公网服务器的 Mac 地址是在什么时机通过什么方式获取到的?我看 ARP 获取 Mac 地址只能获取到内网机器的 Mac 地址吧?” 在发送数据包时,如果目标主机不是本地局域网,填入的 Mac 地址是路由器,也就是把数据包转发给路由器,路由器一直转发下一个路由器,直到转发到目标主机的路由器,发现 IP 地址是自己局域网内的主机,就会 ARP 请求获取目标主机的 Mac 地址,从而转发到这个服务器主机。 转发的过程中,源 IP 地址和目标 IP 地址是不会变的(前提:没有使用 NAT 网络的),源 MAC 地址和目标 MAC 地址是会变化的。 --- 哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,别忘记关注我哦! ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/2_http/http2.md ================================================ # 3.6 HTTP/2 牛逼在哪? 不多 BB 了,直接发车! **一起来看看 HTTP/2 牛逼在哪?** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http2/http2提纲.png) --- ## HTTP/1.1 协议的性能问题 我们得先要了解下 HTTP/1.1 协议存在的性能问题,因为 HTTP/2 协议就是把这些性能问题逐个攻破了。 现在的站点相比以前变化太多了,比如: - *消息的大小变大了*,从几 KB 大小的消息,到几 MB 大小的消息; - *页面资源变多了*,从每个页面不到 10 个的资源,到每页超 100 多个资源; - *内容形式变多样了*,从单纯到文本内容,到图片、视频、音频等内容; - *实时性要求变高了*,对页面的实时性要求的应用越来越多; 这些变化带来的最大性能问题就是 **HTTP/1.1 的高延迟**,延迟高必然影响的就是用户体验。主要原因如下几个: - *延迟难以下降*,虽然现在网络的「带宽」相比以前变多了,但是延迟降到一定幅度后,就很难再下降了,说白了就是到达了延迟的下限; - *并发连接有限*,谷歌浏览器最大并发连接数是 6 个,而且每一个连接都要经过 TCP 和 TLS 握手耗时,以及 TCP 慢启动过程给流量带来的影响; - *队头阻塞问题*,同一连接只能在完成一个 HTTP 事务(请求和响应)后,才能处理下一个事务; - *HTTP 头部巨大且重复*,由于 HTTP 协议是无状态的,每一个请求都得携带 HTTP 头部,特别是对于有携带 Cookie 的头部,而 Cookie 的大小通常很大; - *不支持服务器推送消息*,因此当客户端需要获取通知时,只能通过定时器不断地拉取消息,这无疑浪费大量了带宽和服务器资源。 为了解决 HTTP/1.1 性能问题,具体的优化手段你可以看这篇文章「[HTTP/1.1 如何优化?](https://xiaolincoding.com/network/2_http/http_optimize.html)」,这里我举例几个常见的优化手段: - 将多张小图合并成一张大图供浏览器 JavaScript 来切割使用,这样可以将多个请求合并成一个请求,但是带来了新的问题,当某张小图片更新了,那么需要重新请求大图片,浪费了大量的网络带宽; - 将图片的二进制数据通过 Base64 编码后,把编码数据嵌入到 HTML 或 CSS 文件中,以此来减少网络请求次数; - 将多个体积较小的 JavaScript 文件使用 Webpack 等工具打包成一个体积更大的 JavaScript 文件,以一个请求替代了很多个请求,但是带来的问题,当某个 js 文件变化了,需要重新请求同一个包里的所有 js 文件; - 将同一个页面的资源分散到不同域名,提升并发连接上限,因为浏览器通常对同一域名的 HTTP 连接最大只能是 6 个; 尽管对 HTTP/1.1 协议的优化手段如此之多,但是效果还是不尽人意,因为这些手段都是对 HTTP/1.1 协议的“外部”做优化,**而一些关键的地方是没办法优化的,比如请求 - 响应模型、头部巨大且重复、并发连接耗时、服务器不能主动推送等,要改变这些必须重新设计 HTTP 协议,于是 HTTP/2 就出来了!** --- ## 兼容 HTTP/1.1 HTTP/2 出来的目的是为了改善 HTTP 的性能。协议升级有一个很重要的地方,就是要**兼容**老版本的协议,否则新协议推广起来就相当困难,所幸 HTTP/2 做到了兼容 HTTP/1.1。 那么,HTTP/2 是怎么做的呢? 第一点,HTTP/2 没有在 URI 里引入新的协议名,仍然用「http://」表示明文协议,用「https://」表示加密协议,于是只需要浏览器和服务器在背后自动升级协议,这样可以让用户意识不到协议的升级,很好的实现了协议的平滑升级。 第二点,只在应用层做了改变,还是基于 TCP 协议传输,应用层方面为了保持功能上的兼容,HTTP/2 把 HTTP 分解成了「语义」和「语法」两个部分,「语义」层不做改动,与 HTTP/1.1 完全一致,比如请求方法、状态码、头字段等规则保留不变。 但是,HTTP/2 在「语法」层面做了很多改造,基本改变了 HTTP 报文的传输格式。 ## 头部压缩 HTTP 协议的报文是由「Header + Body」构成的,对于 Body 部分,HTTP/1.1 协议可以使用头字段「Content-Encoding」指定 Body 的压缩方式,比如用 gzip 压缩,这样可以节约带宽,但报文中的另外一部分 Header,是没有针对它的优化手段。 HTTP/1.1 报文中 Header 部分存在的问题: - 含很多固定的字段,比如 Cookie、User Agent、Accept 等,这些字段加起来也高达几百字节甚至上千字节,所以有必要**压缩**; - 大量的请求和响应的报文里有很多字段值都是重复的,这样会使得大量带宽被这些冗余的数据占用了,所以有必须要**避免重复性**; - 字段是 ASCII 编码的,虽然易于人类观察,但效率低,所以有必要改成**二进制编码**; HTTP/2 对 Header 部分做了大改造,把以上的问题都解决了。 HTTP/2 没使用常见的 gzip 压缩方式来压缩头部,而是开发了 **HPACK** 算法,HPACK 算法主要包含三个组成部分: - 静态字典; - 动态字典; - Huffman 编码(压缩算法); 客户端和服务器两端都会建立和维护「**字典**」,用长度较小的索引号表示重复的字符串,再用 Huffman 编码压缩数据,**可达到 50%~90% 的高压缩率**。 ### 静态表编码 HTTP/2 为高频出现在头部的字符串和字段建立了一张**静态表**,它是写入到 HTTP/2 框架里的,不会变化的,静态表里共有 `61` 组,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http2/静态表.png) 表中的 `Index` 表示索引(Key),`Header Value` 表示索引对应的 Value,`Header Name` 表示字段的名字,比如 Index 为 2 代表 GET,Index 为 8 代表状态码 200。 你可能注意到,表中有的 Index 没有对应的 Header Value,这是因为这些 Value 并不是固定的而是变化的,这些 Value 都会经过 Huffman 编码后,才会发送出去。 这么说有点抽象,我们来看个具体的例子,下面这个 `server` 头部字段,在 HTTP/1.1 的形式如下: ```plain server: nghttpx\r\n ``` 算上冒号空格和末尾的`\r\n`,共占用了 17 字节,**而使用了静态表和 Huffman 编码,可以将它压缩成 8 字节,压缩率大概 47%**。 我抓了个 HTTP/2 协议的网络包,你可以从下图看到,高亮部分就是 `server` 头部字段,只用了 8 个字节来表示 `server` 头部数据。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http2/静态编码.png) 根据 RFC7541 规范,如果头部字段属于静态表范围,并且 Value 是变化,那么它的 HTTP/2 头部前 2 位固定为 `01`,所以整个头部格式如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http2/静态头部.png) HTTP/2 头部由于基于**二进制编码**,就不需要冒号空格和末尾的\r\n 作为分隔符,于是改用表示字符串长度(Value Length)来分割 Index 和 Value。 接下来,根据这个头部格式来分析上面抓包的 `server` 头部的二进制数据。 首先,从静态表中能查到 `server` 头部字段的 Index 为 54,二进制为 110110,再加上固定 01,头部格式第 1 个字节就是 `01110110`,这正是上面抓包标注的红色部分的二进制数据。 然后,第二个字节的首个比特位表示 Value 是否经过 Huffman 编码,剩余的 7 位表示 Value 的长度,比如这次例子的第二个字节为 `10000110`,首位比特位为 1 就代表 Value 字符串是经过 Huffman 编码的,经过 Huffman 编码的 Value 长度为 6。 最后,字符串 `nghttpx` 经过 Huffman 编码后压缩成了 6 个字节,Huffman 编码的原理是将高频出现的信息用「较短」的编码表示,从而缩减字符串长度。 于是,在统计大量的 HTTP 头部后,HTTP/2 根据出现频率将 ASCII 码编码为了 Huffman 编码表,可以在 RFC7541 文档找到这张**静态 Huffman 表**,我就不把表的全部内容列出来了,我只列出字符串 `nghttpx` 中每个字符对应的 Huffman 编码,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http2/nghttpx.png) 通过查表后,字符串 `nghttpx` 的 Huffman 编码在下图看到,共 6 个字节,每一个字符的 Huffman 编码,我用相同的颜色将他们对应起来了,最后的 7 位是补位的。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http2/nghttpx2.png) 最终,`server` 头部的二进制数据对应的静态头部格式如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http2/静态头部2.png) ### 动态表编码 静态表只包含了 61 种高频出现在头部的字符串,不在静态表范围内的头部字符串就要自行构建**动态表**,它的 Index 从 `62` 起步,会在编码解码的时候随时更新。 比如,第一次发送时头部中的「`User-Agent` 」字段数据有上百个字节,经过 Huffman 编码发送出去后,客户端和服务器双方都会更新自己的动态表,添加一个新的 Index 号 62。**那么在下一次发送的时候,就不用重复发这个字段的数据了,只用发 1 个字节的 Index 号就好了,因为双方都可以根据自己的动态表获取到字段的数据**。 所以,使得动态表生效有一个前提:**必须同一个连接上,重复传输完全相同的 HTTP 头部**。如果消息字段在 1 个连接上只发送了 1 次,或者重复传输时,字段总是略有变化,动态表就无法被充分利用了。 因此,随着在同一 HTTP/2 连接上发送的报文越来越多,客户端和服务器双方的「字典」积累的越来越多,理论上最终每个头部字段都会变成 1 个字节的 Index,这样便避免了大量的冗余数据的传输,大大节约了带宽。 理想很美好,现实很骨感。动态表越大,占用的内存也就越大,如果占用了太多内存,是会影响服务器性能的,因此 Web 服务器都会提供类似 `http2_max_requests` 的配置,用于限制一个连接上能够传输的请求数量,避免动态表无限增大,请求数量到达上限后,就会关闭 HTTP/2 连接来释放内存。 综上,HTTP/2 头部的编码通过「静态表、动态表、Huffman 编码」共同完成的。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http2/头部编码.png) --- ## 二进制帧 HTTP/2 厉害的地方在于将 HTTP/1 的文本格式改成二进制格式传输数据,极大提高了 HTTP 传输效率,而且二进制数据使用位运算能高效解析。 你可以从下图看到,HTTP/1.1 的响应和 HTTP/2 的区别: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http2/二进制帧.png) HTTP/2 把响应报文划分成了两类**帧(*Frame*)**,图中的 HEADERS(首部)和 DATA(消息负载)是帧的类型,也就是说一条 HTTP 响应,划分成了两类帧来传输,并且采用二进制来编码。 比如状态码 200,在 HTTP/1.1 是用 '2''0''0' 三个字符来表示(二进制:00110010 00110000 00110000),共用了 3 个字节,如下图 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/%E7%BD%91%E7%BB%9C/http2/http1.png) 在 HTTP/2 对于状态码 200 的二进制编码是 10001000,只用了 1 字节就能表示,相比于 HTTP/1.1 节省了 2 个字节,如下图: ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/%E7%BD%91%E7%BB%9C/http2/h2c.png) Header: :status: 200 OK 的编码内容为:1000 1000,那么表达的含义是什么呢? ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/http/index.png) 1. 最前面的 1 标识该 Header 是静态表中已经存在的 KV。 2. 我们再回顾一下之前的静态表内容,“:status: 200 OK”其静态表编码是 8,即 1000。 因此,整体加起来就是 1000 1000。 HTTP/2 **二进制帧**的结构如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http2/帧格式.png) 帧头(Frame Header)很小,只有 9 个字节,帧开头的前 3 个字节表示帧数据(Frame Playload)的**长度**。 帧长度后面的一个字节是表示**帧的类型**,HTTP/2 总共定义了 10 种类型的帧,一般分为**数据帧**和**控制帧**两类,如下表格: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http2/帧类型.png) 帧类型后面的一个字节是**标志位**,可以保存 8 个标志位,用于携带简单的控制信息,比如: - **END_HEADERS** 表示头数据结束标志,相当于 HTTP/1 里头后的空行(“\r\n”); - **END_Stream** 表示单方向数据发送结束,后续不会再有数据帧。 - **PRIORITY** 表示流的优先级; 帧头的最后 4 个字节是**流标识符**(Stream ID),但最高位被保留不用,只有 31 位可以使用,因此流标识符的最大值是 2^31,大约是 21 亿,它的作用是用来标识该 Frame 属于哪个 Stream,接收方可以根据这个信息从乱序的帧里找到相同 Stream ID 的帧,从而有序组装信息。 最后面就是**帧数据**了,它存放的是通过 **HPACK 算法**压缩过的 HTTP 头部和包体。 --- ## 并发传输 知道了 HTTP/2 的帧结构后,我们再来看看它是如何实现**并发传输**的。 我们都知道 HTTP/1.1 的实现是基于请求 - 响应模型的。同一个连接中,HTTP 完成一个事务(请求与响应),才能处理下一个事务,也就是说在发出请求等待响应的过程中,是没办法做其他事情的,如果响应迟迟不来,那么后续的请求是无法发送的,也造成了**队头阻塞**的问题。 而 HTTP/2 就很牛逼了,通过 Stream 这个设计,**多个 Stream 复用一条 TCP 连接,达到并发的效果**,解决了 HTTP/1.1 队头阻塞的问题,提高了 HTTP 传输的吞吐量。 为了理解 HTTP/2 的并发是怎样实现的,我们先来理解 HTTP/2 中的 Stream、Message、Frame 这 3 个概念。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http2/stream.png) 你可以从上图中看到: - 1 个 TCP 连接包含一个或者多个 Stream,Stream 是 HTTP/2 并发的关键技术; - 1 个 Stream 里包含 2个 Message,Message 对应 HTTP/1 中的请求或响应,由 HTTP 头部和包体构成; - Message 里包含一条或者多个 Frame,Frame 是 HTTP/2 最小单位,以二进制压缩格式存放 HTTP/1 中的内容(头部和包体); 因此,我们可以得出个结论:多个 Stream 跑在一条 TCP 连接,同一个 HTTP 请求与响应是跑在同一个 Stream 中,HTTP 消息可以由多个 Frame 构成,一个 Frame 可以由多个 TCP 报文构成。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http2/stream2.png) 在 HTTP/2 连接上,**不同 Stream 的帧是可以乱序发送的(因此可以并发不同的 Stream)**,因为每个帧的头部会携带 Stream ID 信息,所以接收端可以通过 Stream ID 有序组装成 HTTP 消息,而**同一 Stream 内部的帧必须是严格有序的**。 比如下图,服务端**并行交错地**发送了两个响应:Stream 1 和 Stream 3,这两个 Stream 都是跑在一个 TCP 连接上,客户端收到后,会根据相同的 Stream ID 有序组装成 HTTP 消息。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/http/http2多路复用.jpeg) 客户端和服务器**双方都可以建立 Stream**,因为服务端可以主动推送资源给客户端,客户端建立的 Stream 必须是奇数号,而服务器建立的 Stream 必须是偶数号。 比如下图,Stream 1 是客户端向服务端请求的资源,属于客户端建立的 Stream,所以该 Stream 的 ID 是奇数(数字 1);Stream 2 和 4 都是服务端主动向客户端推送的资源,属于服务端建立的 Stream,所以这两个 Stream 的 ID 是偶数(数字 2 和 4)。 ![](https://img-blog.csdnimg.cn/83445581dafe409d8cfd2c573b2781ac.png) 同一个连接中的 Stream ID 是不能复用的,只能顺序递增,所以当 Stream ID 耗尽时,需要发一个控制帧 `GOAWAY`,用来关闭 TCP 连接。 在 Nginx 中,可以通过 `http2_max_concurrent_Streams` 配置来设置 Stream 的上限,默认是 128 个。 HTTP/2 通过 Stream 实现的并发,比 HTTP/1.1 通过 TCP 连接实现并发要牛逼的多,**因为当 HTTP/2 实现 100 个并发 Stream 时,只需要建立一次 TCP 连接,而 HTTP/1.1 需要建立 100 个 TCP 连接,每个 TCP 连接都要经过 TCP 握手、慢启动以及 TLS 握手过程,这些都是很耗时的。** HTTP/2 还可以对每个 Stream 设置不同**优先级**,帧头中的「标志位」可以设置优先级,比如客户端访问 HTML/CSS 和图片资源时,希望服务器先传递 HTML/CSS,再传图片,那么就可以通过设置 Stream 的优先级来实现,以此提高用户体验。 ## 服务器主动推送资源 HTTP/1.1 不支持服务器主动推送资源给客户端,都是由客户端向服务器发起请求后,才能获取到服务器响应的资源。 比如,客户端通过 HTTP/1.1 请求从服务器那获取到了 HTML 文件,而 HTML 可能还需要依赖 CSS 来渲染页面,这时客户端还要再发起获取 CSS 文件的请求,需要两次消息往返,如下图左边部分: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http2/push.png) 如上图右边部分,在 HTTP/2 中,客户端在访问 HTML 时,服务器可以直接主动推送 CSS 文件,减少了消息传递的次数。 在 Nginx 中,如果你希望客户端访问 /test.html 时,服务器直接推送 /test.css,那么可以这么配置: ```nginx location /test.html { http2_push /test.css; } ``` 那 HTTP/2 的推送是怎么实现的? 客户端发起的请求,必须使用的是奇数号 Stream,服务器主动的推送,使用的是偶数号 Stream。服务器在推送资源时,会通过 `PUSH_PROMISE` 帧传输 HTTP 头部,并通过帧中的 `Promised Stream ID` 字段告知客户端,接下来会在哪个偶数号 Stream 中发送包体。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http2/push2.png) 如上图,在 Stream 1 中通知客户端 CSS 资源即将到来,然后在 Stream 2 中发送 CSS 资源,注意 Stream 1 和 2 是可以**并发**的。 --- ## 总结 HTTP/2 协议其实还有很多内容,比如流控制、流状态、依赖关系等等。 这次主要介绍了关于 HTTP/2 是如何提升性能的几个方向,它相比 HTTP/1 大大提高了传输效率、吞吐能力。 第一点,对于常见的 HTTP 头部通过**静态表和 Huffman 编码**的方式,将体积压缩了近一半,而且针对后续的请求头部,还可以建立**动态表**,将体积压缩近 90%,大大提高了编码效率,同时节约了带宽资源。 不过,动态表并非可以无限增大,因为动态表是会占用内存的,动态表越大,内存也越大,容易影响服务器总体的并发能力,因此服务器需要限制 HTTP/2 连接时长或者请求次数。 第二点,**HTTP/2 实现了 Stream 并发**,多个 Stream 只需复用 1 个 TCP 连接,节约了 TCP 和 TLS 握手时间,以及减少了 TCP 慢启动阶段对流量的影响。不同的 Stream ID 可以并发,即使乱序发送帧也没问题,比如发送 A 请求帧 1 -> B 请求帧 1 -> A 请求帧 2 -> B 请求帧 2,但是同一个 Stream 里的帧必须严格有序。 另外,可以根据资源的渲染顺序来设置 Stream 的**优先级**,从而提高用户体验。 第三点,**服务器支持主动推送资源**,大大提升了消息的传输性能,服务器推送资源时,会先发送 PUSH_PROMISE 帧,告诉客户端接下来在哪个 Stream 发送资源,然后用偶数号 Stream 发送资源给客户端。 HTTP/2 通过 Stream 的并发能力,解决了 HTTP/1 队头阻塞的问题,看似很完美了,但是 HTTP/2 还是存在“队头阻塞”的问题,只不过问题不是在 HTTP 这一层面,而是在 TCP 这一层。 **HTTP/2 是基于 TCP 协议来传输数据的,TCP 是字节流协议,TCP 层必须保证收到的字节数据是完整且连续的,这样内核才会将缓冲区里的数据返回给 HTTP 应用,那么当「前 1 个字节数据」没有到达时,后收到的字节数据只能存放在内核缓冲区里,只有等到这 1 个字节数据到达时,HTTP/2 应用层才能从内核中拿到数据,这就是 HTTP/2 队头阻塞问题。** 有没有什么解决方案呢?既然是 TCP 协议自身的问题,那干脆放弃 TCP 协议,转而使用 UDP 协议作为传输层协议,这个大胆的决定,HTTP/3 协议做了! ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/27-HTTP3.png) --- 参考资料: 1. https://developers.google.com/web/fundamentals/performance/http2 2. https://http2.akamai.com/demo 3. https://tools.ietf.org/html/rfc7541 --- 哈喽,我是小林,就爱图解计算机基础,如果文章对你有帮助,别忘记关注哦! ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/2_http/http3.md ================================================ # 3.7 HTTP/3 强势来袭 HTTP/3 现在(2022 年 5 月)还没正式推出,不过自 2017 年起,HTTP/3 已经更新到 34 个草案了,基本的特性已经确定下来了,对于包格式可能后续会有变化。 所以,这次 HTTP/3 介绍不会涉及到包格式,只说它的特性。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http3/HTTP3提纲.png) ## 美中不足的 HTTP/2 HTTP/2 通过头部压缩、二进制编码、多路复用、服务器推送等新特性大幅度提升了 HTTP/1.1 的性能,而美中不足的是 HTTP/2 协议是基于 TCP 实现的,于是存在的缺陷有三个。 - 队头阻塞; - TCP 与 TLS 的握手时延迟; - 网络迁移需要重新连接; ### 队头阻塞 HTTP/2 多个请求是跑在一个 TCP 连接中的,那么当 TCP 丢包时,整个 TCP 都要等待重传,那么就会阻塞该 TCP 连接中的所有请求。 比如下图中,Stream 2 有一个 TCP 报文丢失了,那么即使收到了 Stream 3 和 Stream 4 的 TCP 报文,应用层也是无法读取的,相当于阻塞了 Stream 3 和 Stream 4 请求。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/quic/http2阻塞.jpeg) 因为 TCP 是字节流协议,TCP 层必须保证收到的字节数据是完整且有序的,如果序列号较低的 TCP 段在网络传输中丢失了,即使序列号较高的 TCP 段已经被接收了,应用层也无法从内核中读取到这部分数据,从 HTTP 视角看,就是请求被阻塞了。 举个例子,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http3/tcp队头阻塞.gif) 图中发送方发送了很多个 Packet,每个 Packet 都有自己的序号,你可以认为是 TCP 的序列号,其中 Packet 3 在网络中丢失了,即使 Packet 4-6 被接收方收到后,由于内核中的 TCP 数据不是连续的,于是接收方的应用层就无法从内核中读取到,只有等到 Packet 3 重传后,接收方的应用层才可以从内核中读取到数据,这就是 HTTP/2 的队头阻塞问题,是在 TCP 层面发生的。 ### TCP 与 TLS 的握手时延迟 发起 HTTP 请求时,需要经过 TCP 三次握手和 TLS 四次握手(TLS 1.2)的过程,因此共需要 3 个 RTT 的时延才能发出请求数据。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http3/TCP%2BTLS.gif) 另外,TCP 由于具有「拥塞控制」的特性,所以刚建立连接的 TCP 会有个「慢启动」的过程,它会对 TCP 连接产生“减速”效果。 ### 网络迁移需要重新连接 一个 TCP 连接是由四元组(源 IP 地址,源端口,目标 IP 地址,目标端口)确定的,这意味着如果 IP 地址或者端口变动了,就会导致需要 TCP 与 TLS 重新握手,这不利于移动设备切换网络的场景,比如 4G 网络环境切换成 WiFi。 这些问题都是 TCP 协议固有的问题,无论应用层的 HTTP/2 在怎么设计都无法逃脱。要解决这个问题,就必须把**传输层协议替换成 UDP**,这个大胆的决定,HTTP/3 做了! ![HTTP/1 ~ HTTP/3](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/27-HTTP3.png) ## QUIC 协议的特点 我们深知,UDP 是一个简单、不可靠的传输协议,而且是 UDP 包之间是无序的,也没有依赖关系。 而且,UDP 是不需要连接的,也就不需要握手和挥手的过程,所以天然的就比 TCP 快。 当然,HTTP/3 不仅仅只是简单将传输协议替换成了 UDP,还基于 UDP 协议在「应用层」实现了 **QUIC 协议**,它具有类似 TCP 的连接管理、拥塞窗口、流量控制的网络特性,相当于将不可靠传输的 UDP 协议变成“可靠”的了,所以不用担心数据包丢失的问题。 QUIC 协议的优点有很多,这里举例几个,比如: - 无队头阻塞; - 更快的连接建立; - 连接迁移; ### 无队头阻塞 QUIC 协议也有类似 HTTP/2 Stream 与多路复用的概念,也是可以在同一条连接上并发传输多个 Stream,Stream 可以认为就是一条 HTTP 请求。 由于 QUIC 使用的传输协议是 UDP,UDP 不关心数据包的顺序,如果数据包丢失,UDP 也不关心。 不过 QUIC 协议会保证数据包的可靠性,每个数据包都有一个序号唯一标识。当某个流中的一个数据包丢失了,即使该流的其他数据包到达了,数据也无法被 HTTP/3 读取,直到 QUIC 重传丢失的报文,数据才会交给 HTTP/3。 而其他流的数据报文只要被完整接收,HTTP/3 就可以读取到数据。这与 HTTP/2 不同,HTTP/2 只要某个流中的数据包丢失了,其他流也会因此受影响。 所以,QUIC 连接上的多个 Stream 之间并没有依赖,都是独立的,某个流发生丢包了,只会影响该流,其他流不受影响。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/quic/quic无阻塞.jpeg) ### 更快的连接建立 对于 HTTP/1 和 HTTP/2 协议,TCP 和 TLS 是分层的,分别属于内核实现的传输层、OpenSSL 库实现的表示层,因此它们难以合并在一起,需要分批次来握手,先 TCP 握手,再 TLS 握手。 HTTP/3 在传输数据前虽然需要 QUIC 协议握手,这个握手过程只需要 1 RTT,握手的目的是为确认双方的「连接 ID」,连接迁移就是基于连接 ID 实现的。 但是 HTTP/3 的 QUIC 协议并不是与 TLS 分层,而是 **QUIC 内部包含了 TLS,它在自己的帧会携带 TLS 里的“记录”,再加上 QUIC 使用的是 TLS 1.3,因此仅需 1 个 RTT 就可以「同时」完成建立连接与密钥协商,甚至在第二次连接的时候,应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果**。 如下图右边部分,HTTP/3 当会话恢复时,有效负载数据与第一个数据包一起发送,可以做到 0-RTT: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http3/0-rtt.gif) ### 连接迁移 在前面我们提到,基于 TCP 传输协议的 HTTP 协议,由于是通过四元组(源 IP、源端口、目的 IP、目的端口)确定一条 TCP 连接。 ![TCP 四元组](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzEwLmpwZw?x-oss-process=image/format,png) 那么当移动设备的网络从 4G 切换到 WiFi 时,意味着 IP 地址变化了,那么就必须要断开连接,然后重新建立连接,而建立连接的过程包含 TCP 三次握手和 TLS 四次握手的时延,以及 TCP 慢启动的减速过程,给用户的感觉就是网络突然卡顿了一下,因此连接的迁移成本是很高的。 而 QUIC 协议没有用四元组的方式来“绑定”连接,而是通过**连接 ID** 来标记通信的两个端点,客户端和服务器可以各自选择一组 ID 来标记自己,因此即使移动设备的网络变化后,导致 IP 地址变化了,只要仍保有上下文信息(比如连接 ID、TLS 密钥等),就可以“无缝”地复用原连接,消除重连的成本,没有丝毫卡顿感,达到了**连接迁移**的功能。 ## HTTP/3 协议 了解完 QUIC 协议的特点后,我们再来看看 HTTP/3 协议在 HTTP 这一层做了什么变化。 HTTP/3 同 HTTP/2 一样采用二进制帧的结构,不同的地方在于 HTTP/2 的二进制帧里需要定义 Stream,而 HTTP/3 自身不需要再定义 Stream,直接使用 QUIC 里的 Stream,于是 HTTP/3 的帧的结构也变简单了。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http3/http3frame.png) 从上图可以看到,HTTP/3 帧头只有两个字段:类型和长度。 根据帧类型的不同,大体上分为数据帧和控制帧两大类,Headers 帧(HTTP 头部)和 DATA 帧(HTTP 包体)属于数据帧。 HTTP/3 在头部压缩算法这一方面也做了升级,升级成了 **QPACK**。与 HTTP/2 中的 HPACK 编码方式相似,HTTP/3 中的 QPACK 也采用了静态表、动态表及 Huffman 编码。 对于静态表的变化,HTTP/2 中的 HPACK 的静态表只有 61 项,而 HTTP/3 中的 QPACK 的静态表扩大到 91 项。 HTTP/2 和 HTTP/3 的 Huffman 编码并没有多大不同,但是动态表编解码方式不同。 所谓的动态表,在首次请求 - 响应后,双方会将未包含在静态表中的 Header 项更新各自的动态表,接着后续传输时仅用 1 个数字表示,然后对方可以根据这 1 个数字从动态表查到对应的数据,就不必每次都传输长长的数据,大大提升了编码效率。 可以看到,**动态表是具有时序性的,如果首次出现的请求发生了丢包,后续的收到请求,对方就无法解码出 HPACK 头部,因为对方还没建立好动态表,因此后续的请求解码会阻塞到首次请求中丢失的数据包重传过来**。 HTTP/3 的 QPACK 解决了这一问题,那它是如何解决的呢? QUIC 会有两个特殊的单向流,所谓的单向流只有一端可以发送消息,双向则指两端都可以发送消息,传输 HTTP 消息时用的是双向流,这两个单向流的用法: - 一个叫 QPACK Encoder Stream,用于将一个字典(Key-Value)传递给对方,比如面对不属于静态表的 HTTP 请求头部,客户端可以通过这个 Stream 发送字典; - 一个叫 QPACK Decoder Stream,用于响应对方,告诉它刚发的字典已经更新到自己的本地动态表了,后续就可以使用这个字典来编码了。 这两个特殊的单向流是用来**同步双方的动态表**,编码方收到解码方更新确认的通知后,才使用动态表编码 HTTP 头部。 ## 总结 HTTP/2 虽然具有多个流并发传输的能力,但是传输层是 TCP 协议,于是存在以下缺陷: - **队头阻塞**,HTTP/2 多个请求跑在一个 TCP 连接中,如果序列号较低的 TCP 段在网络传输中丢失了,即使序列号较高的 TCP 段已经被接收了,应用层也无法从内核中读取到这部分数据,从 HTTP 视角看,就是多个请求被阻塞了; - **TCP 和 TLS 握手时延**,TCP 三次握手和 TLS 四次握手,共有 3-RTT 的时延; - **连接迁移需要重新连接**,移动设备从 4G 网络环境切换到 WiFi 时,由于 TCP 是基于四元组来确认一条 TCP 连接的,那么网络环境变化后,就会导致 IP 地址或端口变化,于是 TCP 只能断开连接,然后再重新建立连接,切换网络环境的成本高; HTTP/3 就将传输层从 TCP 替换成了 UDP,并在 UDP 协议上开发了 QUIC 协议,来保证数据的可靠传输。 QUIC 协议的特点: - **无队头阻塞**,QUIC 连接上的多个 Stream 之间并没有依赖,都是独立的,也不会有底层协议限制,某个流发生丢包了,只会影响该流,其他流不受影响; - **建立连接速度快**,因为 QUIC 内部包含 TLS 1.3,因此仅需 1 个 RTT 就可以「同时」完成建立连接与 TLS 密钥协商,甚至在第二次连接的时候,应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果。 - **连接迁移**,QUIC 协议没有用四元组的方式来“绑定”连接,而是通过「连接 ID」来标记通信的两个端点,客户端和服务器可以各自选择一组 ID 来标记自己,因此即使移动设备的网络变化后,导致 IP 地址变化了,只要仍保有上下文信息(比如连接 ID、TLS 密钥等),就可以“无缝”地复用原连接,消除重连的成本; 另外 HTTP/3 的 QPACK 通过两个特殊的单向流来同步双方的动态表,解决了 HTTP/2 的 HPACK 队头阻塞问题。 **期待,HTTP/3 正式推出的那一天!** --- 参考资料: 1. https://medium.com/faun/http-2-spdy-and-http-3-quic-bae7d9a3d484 2. https://developers.google.com/web/fundamentals/performance/http2?hl=zh-cn 3. https://blog.cloudflare.com/http3-the-past-present-and-future/ 4. https://tools.ietf.org/html/draft-ietf-quic-http-34 5. https://tools.ietf.org/html/draft-ietf-quic-transport-34#section-17 6. https://ably.com/topic/http3?amp%3Butm_campaign=evergreen&%3Butm_source=reddit&utm_medium=referral 7. https://www.nginx.org.cn/article/detail/422 8. https://www.bilibili.com/read/cv793000/ 9. https://www.chinaz.com/2020/1009/1192436.shtml --- 哈喽,我是小林,就爱图解计算机基础,如果文章对你有帮助,别忘记关注哦! ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/2_http/http_interview.md ================================================ # 3.1 HTTP 常见面试题 在面试过程中,HTTP 被提问的概率还是比较高的。 小林我搜集了 6 大类 HTTP 面试常问的题目,同时这 6 大类题跟 **HTTP 的发展和演变**关联性是比较大的,通过**问答 + 图解**的形式**由浅入深**的方式帮助大家进一步的学习和理解 HTTP。 1. HTTP 基本概念 2. Get 与 Post 3. HTTP 特性 4. HTTP 缓存技术 5. HTTPS 与 HTTP 6. HTTP/1.1、HTTP/2、HTTP/3 演变 ![提纲](https://img-blog.csdnimg.cn/6b9bfd38d2684b3f9843ebabf8771212.png) ## HTTP 基本概念 ### HTTP 是什么? HTTP 是超文本传输协议,也就是**H**yper**T**ext **T**ransfer **P**rotocol。 > 能否详细解释「超文本传输协议」? HTTP 的名字「超文本协议传输」,它可以拆成三个部分: - 超文本 - 传输 - 协议 ![三个部分](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/3-HTTP三部分.png) *1. 「协议」* 在生活中,我们也能随处可见「协议」,例如: - 刚毕业时会签一个「三方协议」; - 找房子时会签一个「租房协议」; ![三方协议和租房协议](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/4-租房和三方协议.png) 生活中的协议,本质上与计算机中的协议是相同的,协议的特点: - 「**协**」字,代表的意思是必须有**两个以上的参与者**。例如三方协议里的参与者有三个:你、公司、学校三个;租房协议里的参与者有两个:你和房东。 - 「**议**」字,代表的意思是对参与者的一种**行为约定和规范**。例如三方协议里规定试用期期限、毁约金等;租房协议里规定租期期限、每月租金金额、违约如何处理等。 针对 HTTP **协议**,我们可以这么理解。 HTTP 是一个用在计算机世界里的**协议**。它使用计算机能够理解的语言确立了一种计算机之间交流通信的规范(**两个以上的参与者**),以及相关的各种控制和错误处理方式(**行为约定和规范**)。 *2. 「传输」* 所谓的「传输」,很好理解,就是把一堆东西从 A 点搬到 B 点,或者从 B 点 搬到 A 点。 别轻视了这个简单的动作,它至少包含两项重要的信息。 HTTP 协议是一个**双向协议**。 我们在上网冲浪时,浏览器是请求方 A,百度网站就是应答方 B。双方约定用 HTTP 协议来通信,于是浏览器把请求数据发送给网站,网站再把一些数据返回给浏览器,最后由浏览器渲染在屏幕,就可以看到图片、视频了。 ![请求 - 应答](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/5-请求应答.png) 数据虽然是在 A 和 B 之间传输,但允许中间有**中转或接力**。 就好像第一排的同学想传递纸条给最后一排的同学,那么传递的过程中就需要经过好多个同学(中间人),这样的传输方式就从「A < --- > B」,变成了「A <-> N <-> M <-> B」。 而在 HTTP 里,需要中间人遵从 HTTP 协议,只要不打扰基本的数据传输,就可以添加任意额外的东西。 针对**传输**,我们可以进一步理解了 HTTP。 HTTP 是一个在计算机世界里专门用来在**两点之间传输数据**的约定和规范。 *3. 「超文本」* HTTP 传输的内容是「超文本」。 我们先来理解「文本」,在互联网早期的时候只是简单的字符文字,但现在「文本」的涵义已经可以扩展为图片、视频、压缩包等,在 HTTP 眼里这些都算作「文本」。 再来理解「超文本」,它就是**超越了普通文本的文本**,它是文字、图片、视频等的混合体,最关键有超链接,能从一个超文本跳转到另外一个超文本。 HTML 就是最常见的超文本了,它本身只是纯文字文件,但内部用很多标签定义了图片、视频等的链接,再经过浏览器的解释,呈现给我们的就是一个文字、有画面的网页了。 OK,经过了对 HTTP 里这三个名词的详细解释,就可以给出比「超文本传输协议」这七个字更准确更有技术含量的答案: **HTTP 是一个在计算机世界里专门在「两点」之间「传输」文字、图片、音频、视频等「超文本」数据的「约定和规范」。** > 那「HTTP 是用于从互联网服务器传输超文本到本地浏览器的协议」,这种说法正确吗? 这种说法是**不正确**的。因为也可以是「服务器< -- >服务器」,所以采用**两点之间**的描述会更准确。 ### HTTP 常见的状态码有哪些? ![ 五大类 HTTP 状态码 ](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/6-五大类HTTP状态码.png) `1xx` 类状态码属于**提示信息**,是协议处理中的一种中间状态,实际用到的比较少。 - 「**101 Switching Protocols**」协议切换,服务器已经理解了客户端的请求,并将通过 Upgrade 消息头通知客户端采用不同的协议来完成这个请求。比如切换到一个实时且同步的协议(如 WebSocket)以传送利用此类特性的资源。 `2xx` 类状态码表示服务器**成功**处理了客户端的请求,也是我们最愿意看到的状态。 - 「**200 OK**」是最常见的成功状态码,表示一切正常。如果是非 `HEAD` 请求,服务器返回的响应头都会有 body 数据。 - 「**204 No Content**」也是常见的成功状态码,与 200 OK 基本相同,但响应头没有 body 数据。 - 「**206 Partial Content**」是应用于 HTTP 分块下载或断点续传,表示响应返回的 body 数据并不是资源的全部,而是其中的一部分,也是服务器处理成功的状态。 `3xx` 类状态码表示客户端请求的资源发生了变动,需要客户端用新的 URL 重新发送请求获取资源,也就是**重定向**。 - 「**301 Moved Permanently**」表示永久重定向,说明请求的资源已经不存在了,需改用新的 URL 再次访问。 - 「**302 Found**」表示临时重定向,说明请求的资源还在,但暂时需要用另一个 URL 来访问。 301 和 302 都会在响应头里使用字段 `Location`,指明后续要跳转的 URL,浏览器会自动重定向新的 URL。 - 「**304 Not Modified**」不具有跳转的含义,表示资源未修改,重定向已存在的缓冲文件,也称缓存重定向,也就是告诉客户端可以继续使用缓存资源,用于缓存控制。 `4xx` 类状态码表示客户端发送的**报文有误**,服务器无法处理,也就是错误码的含义。 - 「**400 Bad Request**」表示客户端请求的报文有错误,但只是个笼统的错误。 - 「**403 Forbidden**」表示服务器禁止访问资源,并不是客户端的请求出错。 - 「**404 Not Found**」表示请求的资源在服务器上不存在或未找到,所以无法提供给客户端。 `5xx` 类状态码表示客户端请求报文正确,但是**服务器处理时内部发生了错误**,属于服务器端的错误码。 - 「**500 Internal Server Error**」与 400 类似,是个笼统通用的错误码,服务器发生了什么错误,我们并不知道。 - 「**501 Not Implemented**」表示客户端请求的功能还不支持,类似“即将开业,敬请期待”的意思。 - 「**502 Bad Gateway**」通常是服务器作为网关或代理时返回的错误码,表示服务器自身工作正常,访问后端服务器发生了错误。 - 「**503 Service Unavailable**」表示服务器当前很忙,暂时无法响应客户端,类似“网络服务正忙,请稍后重试”的意思。 ### HTTP 常见字段有哪些? *Host* 字段 客户端发送请求时,用来指定服务器的域名。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/7-HOST字段.png) ```plain Host: www.A.com ``` 有了 `Host` 字段,就可以将请求发往「同一台」服务器上的不同网站。 *Content-Length 字段* 服务器在返回数据时,会有 `Content-Length` 字段,表明本次回应的数据长度。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/8-content-length字段.png) ```plain Content-Length: 1000 ``` 如上面则是告诉浏览器,本次服务器回应的数据长度是 1000 个字节,后面的字节就属于下一个回应了。 大家应该都知道 HTTP 是基于 TCP 传输协议进行通信的,而使用了 TCP 传输协议,就会存在一个“粘包”的问题,**HTTP 协议通过设置回车符、换行符作为 HTTP header 的边界,通过 Content-Length 字段作为 HTTP body 的边界,这两个方式都是为了解决“粘包”的问题**。具体什么是 TCP 粘包,可以看这篇文章:[如何理解是 TCP 面向字节流协议?](https://xiaolincoding.com/network/3_tcp/tcp_stream.html) *Connection 字段* `Connection` 字段最常用于客户端要求服务器使用「HTTP 长连接」机制,以便其他请求复用。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/9-connection字段.png) HTTP 长连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。 ![HTTP 长连接](https://img-blog.csdnimg.cn/img_convert/d2b20d1cc03936332adb2a68512eb167.png) HTTP/1.1 版本的默认连接都是长连接,但为了兼容老版本的 HTTP,需要指定 `Connection` 首部字段的值为 `Keep-Alive`。 ```plain Connection: Keep-Alive ``` 开启了 HTTP Keep-Alive 机制后,连接就不会中断,而是保持连接。当客户端发送另一个请求时,它会使用同一个连接,一直持续到客户端或服务器端提出断开连接。 PS:大家不要把 HTTP Keep-Alive 和 TCP Keepalive 搞混了,这两个虽然长的像,但是不是一个东西,具体可以看我这篇文章:[TCP Keepalive 和 HTTP Keep-Alive 是一个东西吗?](https://xiaolincoding.com/network/3_tcp/tcp_http_keepalive.html) *Content-Type 字段* `Content-Type` 字段用于服务器回应时,告诉客户端,本次数据是什么格式。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/10-content-type字段.png) ```plain Content-Type: text/html; Charset=utf-8 ``` 上面的类型表明,发送的是网页,而且编码是 UTF-8。 客户端请求的时候,可以使用 `Accept` 字段声明自己可以接受哪些数据格式。 ```plain Accept: */* ``` 上面代码中,客户端声明自己可以接受任何格式的数据。 *Content-Encoding 字段* `Content-Encoding` 字段说明数据的压缩方法。表示服务器返回的数据使用了什么压缩格式 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/11-content-encoding字段.png) ```plain Content-Encoding: gzip ``` 上面表示服务器返回的数据采用了 gzip 方式压缩,告知客户端需要用此方式解压。 客户端在请求时,用 `Accept-Encoding` 字段说明自己可以接受哪些压缩方法。 ```plain Accept-Encoding: gzip, deflate ``` --- ## GET 与 POST ### GET 和 POST 有什么区别? 根据 RFC 规范,**GET 的语义是从服务器获取指定的资源**,这个资源可以是静态的文本、页面、图片视频等。GET 请求的参数位置一般是写在 URL 中,URL 规定只能支持 ASCII,所以 GET 请求的参数只允许 ASCII 字符,而且浏览器会对 URL 的长度有限制(HTTP 协议本身对 URL 长度并没有做任何规定)。 比如,你打开我的文章,浏览器就会发送 GET 请求给服务器,服务器就会返回文章的所有文字及资源。 ![GET 请求](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/12-Get请求.png) 根据 RFC 规范,**POST 的语义是根据请求负荷(报文 body)对指定的资源做出处理**,具体的处理方式视资源类型而不同。POST 请求携带数据的位置一般是写在报文 body 中,body 中的数据可以是任意格式的数据,只要客户端与服务端协商好即可,而且浏览器不会对 body 大小做限制。 比如,你在我文章底部,敲入了留言后点击「提交」(**暗示你们留言**),浏览器就会执行一次 POST 请求,把你的留言文字放进了报文 body 里,然后拼接好 POST 请求头,通过 TCP 协议发送给服务器。 ![POST 请求](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/13-Post请求.png) ### GET 和 POST 方法都是安全和幂等的吗? 先说明下安全和幂等的概念: - 在 HTTP 协议里,所谓的「安全」是指请求方法不会「破坏」服务器上的资源。 - 所谓的「幂等」,意思是多次执行相同的操作,结果都是「相同」的。 如果从 RFC 规范定义的语义来看: - **GET 方法就是安全且幂等的**,因为它是「只读」操作,无论操作多少次,服务器上的数据都是安全的,且每次的结果都是相同的。所以,**可以对 GET 请求的数据做缓存,这个缓存可以做到浏览器本身上(彻底避免浏览器发请求),也可以做到代理上(如 nginx),而且在浏览器中 GET 请求可以保存为书签**。 - **POST** 因为是「新增或提交数据」的操作,会修改服务器上的资源,所以是**不安全**的,且多次提交数据就会创建多个资源,所以**不是幂等**的。所以,**浏览器一般不会缓存 POST 请求,也不能把 POST 请求保存为书签**。 做个简要的小结。 GET 的语义是请求获取指定的资源。GET 方法是安全、幂等、可被缓存的。 POST 的语义是根据请求负荷(报文主体)对指定的资源做出处理,具体的处理方式视资源类型而不同。POST 不安全,不幂等,(大部分实现)不可缓存。 注意,上面是从 RFC 规范定义的语义来分析的。 但是实际过程中,开发者不一定会按照 RFC 规范定义的语义来实现 GET 和 POST 方法。比如: - 可以用 GET 方法实现新增或删除数据的请求,这样实现的 GET 方法自然就不是安全和幂等。 - 可以用 POST 方法实现查询数据的请求,这样实现的 POST 方法自然就是安全和幂等。 曾经有个笑话,有人写了个博客,删除博客用的是 GET 请求,他觉得没人访问就连鉴权都没做。然后 Google 服务器爬虫爬了一遍,他所有博文就没了。。。 如果「安全」放入概念是指信息是否会被泄漏的话,虽然 POST 用 body 传输数据,而 GET 用 URL 传输,这样数据会在浏览器地址栏容易看到,但是并不能说 GET 不如 POST 安全的。 因为 HTTP 传输的内容都是明文的,虽然在浏览器地址栏看不到 POST 提交的 body 数据,但是只要抓个包就都能看到了。 所以,要避免传输过程中数据被窃取,就要使用 HTTPS 协议,这样所有 HTTP 的数据都会被加密传输。 > GET 请求可以带 body 吗? RFC 规范并没有规定 GET 请求不能带 body 的。理论上,任何请求都可以带 body 的。只是因为 RFC 规范定义的 GET 请求是获取资源,所以根据这个语义不需要用到 body。 另外,URL 中的查询参数也不是 GET 所独有的,POST 请求的 URL 中也可以有参数的。 ## HTTP 缓存技术 ### HTTP 缓存有哪些实现方式? 对于一些具有重复性的 HTTP 请求,比如每次请求得到的数据都一样的,我们可以把这对「请求 - 响应」的数据都**缓存在本地**,那么下次就直接读取本地的数据,不必在通过网络获取服务器的响应了,这样的话 HTTP/1.1 的性能肯定肉眼可见的提升。 所以,避免发送 HTTP 请求的方法就是通过**缓存技术**,HTTP 设计者早在之前就考虑到了这点,因此 HTTP 协议的头部有不少是针对缓存的字段。 HTTP 缓存有两种实现方式,分别是**强制缓存和协商缓存**。 ### 什么是强制缓存? 强缓存指的是只要浏览器判断缓存没有过期,则直接使用浏览器的本地缓存,决定是否使用缓存的主动性在于浏览器这边。 如下图中,返回的是 200 状态码,但在 size 项中标识的是 from disk cache,就是使用了强制缓存。 ![](https://img-blog.csdnimg.cn/1cb6bc37597e4af8adfef412bfc57a42.png) 强缓存是利用下面这两个 HTTP 响应头部(Response Header)字段实现的,它们都用来表示资源在客户端缓存的有效期: - `Cache-Control`,是一个相对时间; - `Expires`,是一个绝对时间; 如果 HTTP 响应头部同时有 Cache-Control 和 Expires 字段的话,**Cache-Control 的优先级高于 Expires** 。 Cache-control 选项更多一些,设置更加精细,所以建议使用 Cache-Control 来实现强缓存。具体的实现流程如下: - 当浏览器第一次请求访问服务器资源时,服务器会在返回这个资源的同时,在 Response 头部加上 Cache-Control,Cache-Control 中设置了过期时间大小; - 浏览器再次请求访问服务器中的该资源时,会先**通过请求资源的时间与 Cache-Control 中设置的过期时间大小,来计算出该资源是否过期**,如果没有,则使用该缓存,否则重新请求服务器; - 服务器再次收到请求后,会再次更新 Response 头部的 Cache-Control。 ### 什么是协商缓存? 当我们在浏览器使用开发者工具的时候,你可能会看到过某些请求的响应码是 `304`,这个是告诉浏览器可以使用本地缓存的资源,通常这种通过服务端告知客户端是否可以使用缓存的方式被称为协商缓存。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/%E7%BD%91%E7%BB%9C/http1.1%E4%BC%98%E5%8C%96/%E7%BC%93%E5%AD%98etag.png) 上图就是一个协商缓存的过程,所以**协商缓存就是与服务端协商之后,通过协商结果来判断是否使用本地缓存**。 协商缓存可以基于两种头部来实现。 第一种:请求头部中的 `If-Modified-Since` 字段与响应头部中的 `Last-Modified` 字段实现,这两个字段的意思是: - 响应头部中的 `Last-Modified`:标示这个响应资源的最后修改时间; - 请求头部中的 `If-Modified-Since`:当资源过期了,发现响应头中具有 Last-Modified 声明,则再次发起请求的时候带上 Last-Modified 的时间,服务器收到请求后发现有 If-Modified-Since 则与被请求资源的最后修改时间进行对比(Last-Modified),如果最后修改时间较新(大),说明资源又被改过,则返回最新资源,HTTP 200 OK;如果最后修改时间较旧(小),说明资源无新修改,响应 HTTP 304 走缓存。 第二种:请求头部中的 `If-None-Match` 字段与响应头部中的 `ETag` 字段,这两个字段的意思是: - 响应头部中 `Etag`:唯一标识响应资源; - 请求头部中的 `If-None-Match`:当资源过期时,浏览器发现响应头里有 Etag,则再次向服务器发起请求时,会将请求头 If-None-Match 值设置为 Etag 的值。服务器收到请求后进行比对,如果资源没有变化返回 304,如果资源变化了返回 200。 第一种实现方式是基于时间实现的,第二种实现方式是基于一个唯一标识实现的,相对来说后者可以更加准确地判断文件内容是否被修改,避免由于时间篡改导致的不可靠问题。 如果在第一次请求资源的时候,服务端返回的 HTTP 响应头部同时有 Etag 和 Last-Modified 字段,那么客户端再下一次请求的时候,如果带上了 ETag 和 Last-Modified 字段信息给服务端,**这时 Etag 的优先级更高**,也就是服务端先会判断 Etag 是否变化了,如果 Etag 有变化就不用在判断 Last-Modified 了,如果 Etag 没有变化,然后再看 Last-Modified。 **为什么 ETag 的优先级更高?** 这是因为 ETag 主要能解决 Last-Modified 几个比较难以解决的问题: 1. 在没有修改文件内容情况下文件的最后修改时间可能也会改变,这会导致客户端认为这文件被改动了,从而重新请求; 2. 可能有些文件是在秒级以内修改的,`If-Modified-Since` 能检查到的粒度是秒级的,使用 Etag 就能够保证这种需求下客户端在 1 秒内能刷新多次; 3. 有些服务器不能精确获取文件的最后修改时间。 注意,**协商缓存这两个字段都需要配合强制缓存中 Cache-Control 字段来使用,只有在未能命中强制缓存的时候,才能发起带有协商缓存字段的请求**。 下图是强制缓存和协商缓存的工作流程: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/http/http缓存.png) 当使用 ETag 字段实现的协商缓存的过程: - 当浏览器第一次请求访问服务器资源时,服务器会在返回这个资源的同时,在 Response 头部加上 ETag 唯一标识,这个唯一标识的值是根据当前请求的资源生成的; - 当浏览器再次请求访问服务器中的该资源时,首先会先检查强制缓存是否过期: - 如果没有过期,则直接使用本地缓存; - 如果缓存过期了,会在 Request 头部加上 If-None-Match 字段,该字段的值就是 ETag 唯一标识; - 服务器再次收到请求后,**会根据请求中的 If-None-Match 值与当前请求的资源生成的唯一标识进行比较**: - **如果值相等,则返回 304 Not Modified,不会返回资源**; - 如果不相等,则返回 200 状态码和返回资源,并在 Response 头部加上新的 ETag 唯一标识; - 如果浏览器收到 304 的请求响应状态码,则会从本地缓存中加载资源,否则更新资源。 ## HTTP 特性 到目前为止,HTTP 常见到版本有 HTTP/1.1,HTTP/2.0,HTTP/3.0,不同版本的 HTTP 特性是不一样的。 这里先用 HTTP/1.1 版本给大家介绍,其他版本的后续也会介绍。 ### HTTP/1.1 的优点有哪些? HTTP 最突出的优点是「简单、灵活和易于扩展、应用广泛和跨平台」。 *1. 简单* HTTP 基本的报文格式就是 `header + body`,头部信息也是 `key-value` 简单文本的形式,**易于理解**,降低了学习和使用的门槛。 *2. 灵活和易于扩展* HTTP 协议里的各类请求方法、URI/URL、状态码、头字段等每个组成要求都没有被固定死,都允许开发人员**自定义和扩充**。 同时 HTTP 由于是工作在应用层( `OSI` 第七层),则它**下层可以随意变化**,比如: - HTTPS 就是在 HTTP 与 TCP 层之间增加了 SSL/TLS 安全传输层; - HTTP/1.1 和 HTTP/2.0 传输协议使用的是 TCP 协议,而到了 HTTP/3.0 传输协议改用了 UDP 协议。 *3. 应用广泛和跨平台* 互联网发展至今,HTTP 的应用范围非常的广泛,从台式机的浏览器到手机上的各种 APP,从看新闻、刷贴吧到购物、理财、吃鸡,HTTP 的应用遍地开花,同时天然具有**跨平台**的优越性。 ### HTTP/1.1 的缺点有哪些? HTTP 协议里有优缺点一体的**双刃剑**,分别是「无状态、明文传输」,同时还有一大缺点「不安全」。 *1. 无状态双刃剑* 无状态的**好处**,因为服务器不会去记忆 HTTP 的状态,所以不需要额外的资源来记录状态信息,这能减轻服务器的负担,能够把更多的 CPU 和内存用来对外提供服务。 无状态的**坏处**,既然服务器没有记忆能力,它在完成有关联性的操作时会非常麻烦。 例如登录->添加购物车->下单->结算->支付,这系列操作都要知道用户的身份才行。但服务器不知道这些请求是有关联的,每次都要问一遍身份信息。 这样每操作一次,都要验证信息,这样的购物体验还能愉快吗?别问,问就是**酸爽**! 对于无状态的问题,解法方案有很多种,其中比较简单的方式用 **Cookie** 技术。 `Cookie` 通过在请求和响应报文中写入 Cookie 信息来控制客户端的状态。 相当于,**在客户端第一次请求后,服务器会下发一个装有客户信息的「小贴纸」,后续客户端请求服务器的时候,带上「小贴纸」,服务器就能认得了了**, ![Cookie 技术](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/14-cookie技术.png) *2. 明文传输双刃剑* 明文意味着在传输过程中的信息,是可方便阅读的,比如 Wireshark 抓包都可以直接肉眼查看,为我们调试工作带了极大的便利性。 但是这正是这样,HTTP 的所有信息都暴露在了光天化日下,相当于**信息裸奔**。在传输的漫长的过程中,信息的内容都毫无隐私可言,很容易就能被窃取,如果里面有你的账号密码信息,那**你号没了**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/15-你号没了.png) *3. 不安全* HTTP 比较严重的缺点就是不安全: - 通信使用明文(不加密),内容可能会被窃听。比如,**账号信息容易泄漏,那你号没了。** - 不验证通信方的身份,因此有可能遭遇伪装。比如,**访问假的淘宝、拼多多,那你钱没了。** - 无法证明报文的完整性,所以有可能已遭篡改。比如,**网页上植入垃圾广告,视觉污染,眼没了。** HTTP 的安全问题,可以用 HTTPS 的方式解决,也就是通过引入 SSL/TLS 层,使得在安全上达到了极致。 ### HTTP/1.1 的性能如何? HTTP 协议是基于 **TCP/IP**,并且使用了「**请求 - 应答**」的通信模式,所以性能的关键就在这**两点**里。 *1. 长连接* 早期 HTTP/1.0 性能上的一个很大的问题,那就是每发起一个请求,都要新建一次 TCP 连接(三次握手),而且是串行请求,做了无谓的 TCP 连接建立和断开,增加了通信开销。 为了解决上述 TCP 连接问题,HTTP/1.1 提出了**长连接**的通信方式,也叫持久连接。这种方式的好处在于减少了 TCP 连接的重复建立和断开所造成的额外开销,减轻了服务器端的负载。 持久连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。 ![短连接与长连接](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/16-短连接与长连接.png) 当然,如果某个 HTTP 长连接超过一定时间没有任何数据交互,服务端就会主动断开这个连接。 *2. 管道网络传输* HTTP/1.1 采用了长连接的方式,这使得管道(pipeline)网络传输成为了可能。 即可在同一个 TCP 连接里面,客户端可以发起多个请求,只要第一个请求发出去了,不必等其回来,就可以发第二个请求出去,可以**减少整体的响应时间。** 举例来说,客户端需要请求两个资源。以前的做法是,在同一个 TCP 连接里面,先发送 A 请求,然后等待服务器做出回应,收到后再发出 B 请求。那么,管道机制则是允许浏览器同时发出 A 请求和 B 请求,如下图: ![管道网络传输](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/17-管道网络传输.png) 但是**服务器必须按照接收请求的顺序发送对这些管道化请求的响应**。 如果服务端在处理 A 请求时耗时比较长,那么后续的请求的处理都会被阻塞住,这称为「队头堵塞」。 所以,**HTTP/1.1 管道解决了请求的队头阻塞,但是没有解决响应的队头阻塞**。 ::: tip **注意!!!** 实际上 HTTP/1.1 管道化技术不是默认开启,而且浏览器基本都没有支持,所以**后面所有文章讨论 HTTP/1.1 都是建立在没有使用管道化的前提**。大家知道有这个功能,但是没有被使用就行了。 ::: *3. 队头阻塞* 「请求 - 应答」的模式加剧了 HTTP 的性能问题。 因为当顺序发送的请求序列中的一个请求因为某种原因被阻塞时,在后面排队的所有请求也一同被阻塞了,会招致客户端一直请求不到数据,这也就是「**队头阻塞**」,好比上班的路上塞车。 ![队头阻塞](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/18-队头阻塞.png) 总之 HTTP/1.1 的性能一般般,后续的 HTTP/2 和 HTTP/3 就是在优化 HTTP 的性能。 ## HTTP 与 HTTPS ### HTTP 与 HTTPS 有哪些区别? - HTTP 是超文本传输协议,信息是明文传输,存在安全风险的问题。HTTPS 则解决 HTTP 不安全的缺陷,在 TCP 和 HTTP 网络层之间加入了 SSL/TLS 安全协议,使得报文能够加密传输。 - HTTP 连接建立相对简单,TCP 三次握手之后便可进行 HTTP 的报文传输。而 HTTPS 在 TCP 三次握手之后,还需进行 SSL/TLS 的握手过程,才可进入加密报文传输。 - 两者的默认端口不一样,HTTP 默认端口号是 80,HTTPS 默认端口号是 443。 - HTTPS 协议需要向 CA(证书权威机构)申请数字证书,来保证服务器的身份是可信的。 ### HTTPS 解决了 HTTP 的哪些问题? HTTP 由于是明文传输,所以安全上存在以下三个风险: - **窃听风险**,比如通信链路上可以获取通信内容,用户号容易没。 - **篡改风险**,比如强制植入垃圾广告,视觉污染,用户眼容易瞎。 - **冒充风险**,比如冒充淘宝网站,用户钱容易没。 ![HTTP 与 HTTPS 网络层](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/19-HTTPS与HTTP.png) HTTP**S** 在 HTTP 与 TCP 层之间加入了 `SSL/TLS` 协议,可以很好的解决了上述的风险: - **信息加密**:交互信息无法被窃取,但你的号会因为「自身忘记」账号而没。 - **校验机制**:无法篡改通信内容,篡改了就不能正常显示,但百度「竞价排名」依然可以搜索垃圾广告。 - **身份证书**:证明淘宝是真的淘宝网,但你的钱还是会因为「剁手」而没。 可见,只要自身不做「恶」,SSL/TLS 协议是能保证通信是安全的。 > HTTPS 是如何解决上面的三个风险的? - **混合加密**的方式实现信息的**机密性**,解决了窃听的风险。 - **摘要算法**的方式来实现**完整性**,它能够为数据生成独一无二的「指纹」,指纹用于校验数据的完整性,解决了篡改的风险。 - 将服务器公钥放入到**数字证书**中,解决了冒充的风险。 *1. 混合加密* 通过**混合加密**的方式可以保证信息的**机密性**,解决了窃听的风险。 ![混合加密](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/20-混合加密.png) HTTPS 采用的是**对称加密**和**非对称加密**结合的「混合加密」方式: - 在通信建立前采用**非对称加密**的方式交换「会话秘钥」,后续就不再使用非对称加密。 - 在通信过程中全部使用**对称加密**的「会话秘钥」的方式加密明文数据。 采用「混合加密」的方式的原因: - **对称加密**只使用一个密钥,运算速度快,密钥必须保密,无法做到安全的密钥交换。 - **非对称加密**使用两个密钥:公钥和私钥,公钥可以任意分发而私钥保密,解决了密钥交换问题但速度慢。 *2. 摘要算法 + 数字签名* 为了保证传输的内容不被篡改,我们需要对内容计算出一个「指纹」,然后同内容一起传输给对方。 对方收到后,先是对内容也计算出一个「指纹」,然后跟发送方发送的「指纹」做一个比较,如果「指纹」相同,说明内容没有被篡改,否则就可以判断出内容被篡改了。 那么,在计算机里会**用摘要算法(哈希函数)来计算出内容的哈希值**,也就是内容的「指纹」,这个**哈希值是唯一的,且无法通过哈希值推导出内容**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/摘要算法.png) 通过哈希算法可以确保内容不会被篡改,**但是并不能保证「内容 + 哈希值」不会被中间人替换,因为这里缺少对客户端收到的消息是否来源于服务端的证明**。 举个例子,你想向老师请假,一般来说是要求由家长写一份请假理由并签名,老师才能允许你请假。 但是你有模仿你爸爸字迹的能力,你用你爸爸的字迹写了一份请假理由然后签上你爸爸的名字,老师一看到这个请假条,查看字迹和签名,就误以为是你爸爸写的,就会允许你请假。 那作为老师,要如何避免这种情况发生呢?现实生活中的,可以通过电话或视频来确认是否是由父母发出的请假,但是计算机里可没有这种操作。 那为了避免这种情况,计算机里会用**非对称加密算法**来解决,共有两个密钥: - 一个是公钥,这个是可以公开给所有人的; - 一个是私钥,这个必须由本人管理,不可泄露。 这两个密钥可以**双向加解密**的,比如可以用公钥加密内容,然后用私钥解密,也可以用私钥加密内容,公钥解密内容。 流程的不同,意味着目的也不相同: - **公钥加密,私钥解密**。这个目的是为了**保证内容传输的安全**,因为被公钥加密的内容,其他人是无法解密的,只有持有私钥的人,才能解密出实际的内容; - **私钥加密,公钥解密**。这个目的是为了**保证消息不会被冒充**,因为私钥是不可泄露的,如果公钥能正常解密出私钥加密的内容,就能证明这个消息是来源于持有私钥身份的人发送的。 一般我们不会用非对称加密来加密实际的传输内容,因为非对称加密的计算比较耗费性能的。 所以非对称加密的用途主要在于**通过「私钥加密,公钥解密」的方式,来确认消息的身份**,我们常说的**数字签名算法**,就是用的是这种方式,不过私钥加密内容不是内容本身,而是**对内容的哈希值加密**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/数字签名.png) 私钥是由服务端保管,然后服务端会向客户端颁发对应的公钥。如果客户端收到的信息,能被公钥解密,就说明该消息是由服务器发送的。 引入了数字签名算法后,你就无法模仿你爸爸的字迹来请假了,你爸爸手上持有着私钥,你老师持有着公钥。 这样只有用你爸爸手上的私钥才对请假条进行「签名」,老师通过公钥看能不能解出这个「签名」,如果能解出并且确认内容的完整性,就能证明是由你爸爸发起的请假条,这样老师才允许你请假,否则老师就不认。 *3. 数字证书* 前面我们知道: - 可以通过哈希算法来保证消息的完整性; - 可以通过数字签名来保证消息的来源可靠性(能确认消息是由持有私钥的一方发送的); 但是这还远远不够,**还缺少身份验证的环节**,万一公钥是被伪造的呢? 还是拿请假的例子,虽然你爸爸持有私钥,老师通过是否能用公钥解密来确认这个请假条是不是来源你父亲的。 但是我们还可以自己伪造出一对公私钥啊! 你找了个夜晚,偷偷把老师桌面上和你爸爸配对的公钥,换成了你的公钥,那么下次你在请假的时候,你继续模仿你爸爸的字迹写了个请假条,然后用你的私钥做个了「数字签名」。 但是老师并不知道自己的公钥被你替换过了,所以他还是按照往常一样用公钥解密,由于这个公钥和你的私钥是配对的,老师当然能用这个被替换的公钥解密出来,并且确认了内容的完整性,于是老师就会以为是你父亲写的请假条,又允许你请假了。 好家伙,为了一个请假,真的是斗智斗勇。 后面你的老师和父亲发现了你伪造公私钥的事情后,决定重新商量一个对策来应对你这个臭家伙。 正所谓魔高一尺,道高一丈。 既然伪造公私钥那么随意,所以你爸把他的公钥注册到**警察局**,警察局用他们自己的私钥对你父亲的公钥做了个数字签名,然后把你爸爸的「个人信息 + 公钥 + 数字签名」打包成一个**数字证书,也就是说这个数字证书包含你爸爸的公钥。** 这样,你爸爸如果因为家里确实有事要向老师帮你请假的时候,不仅会用自己的私钥对内容进行签名,还会把数字证书给到老师。 老师拿到了数字证书后,**首先会去警察局验证这个数字证书是否合法**,因为数字证书里有警察局的数字签名,警察局要验证证书合法性的时候,用自己的公钥解密,如果能解密成功,就说明这个数字证书是在警察局注册过的,就认为该数字证书是合法的,然后就会把数字证书里头的公钥(你爸爸的)给到老师。 **由于通过警察局验证了数字证书是合法的,那么就能证明这个公钥就是你父亲的**,于是老师就可以安心的用这个公钥解密出清教条,如果能解密出,就证明是你爸爸写的请假条。 正是通过了一个权威的机构来证明你爸爸的身份,所以你的伪造公私钥这个小伎俩就没用了。 在计算机里,这个权威的机构就是 CA(数字证书认证机构),将服务器公钥放在数字证书(由数字证书认证机构颁发)中,只要证书是可信的,公钥就是可信的。 数字证书的工作流程,我也画了一张图,方便大家理解: ![数子证书工作流程](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/22-数字证书工作流程.png) 通过数字证书的方式保证服务器公钥的身份,解决冒充的风险。 ### HTTPS 是如何建立连接的?其间交互了什么? SSL/TLS 协议基本流程: - 客户端向服务器索要并验证服务器的公钥。 - 双方协商生产「会话秘钥」。 - 双方采用「会话秘钥」进行加密通信。 前两步也就是 SSL/TLS 的建立过程,也就是 TLS 握手阶段。 TLS 的「握手阶段」涉及**四次**通信,使用不同的密钥交换算法,TLS 握手流程也会不一样的,现在常用的密钥交换算法有两种:[RSA 算法](https://xiaolincoding.com/network/2_http/https_rsa.html) 和 [ECDHE 算法](https://xiaolincoding.com/network/2_http/https_ecdhe.html)。 基于 RSA 算法的 TLS 握手过程比较容易理解,所以这里先用这个给大家展示 TLS 握手过程,如下图: ![HTTPS 连接建立过程](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/23-HTTPS工作流程.png) TLS 协议建立的详细流程: *1. ClientHello* 首先,由客户端向服务器发起加密通信请求,也就是 `ClientHello` 请求。 在这一步,客户端主要向服务器发送以下信息: (1)客户端支持的 TLS 协议版本,如 TLS 1.2 版本。 (2)客户端生产的随机数(`Client Random`),后面用于生成「会话秘钥」条件之一。 (3)客户端支持的密码套件列表,如 RSA 加密算法。 *2. SeverHello* 服务器收到客户端请求后,向客户端发出响应,也就是 `ServerHello`。服务器回应的内容有如下内容: (1)确认 TLS 协议版本,如果浏览器不支持,则关闭加密通信。 (2)服务器生产的随机数(`Server Random`),也是后面用于生产「会话秘钥」条件之一。 (3)确认的密码套件列表,如 RSA 加密算法。 (4)服务器的数字证书。 *3.客户端回应* 客户端收到服务器的回应之后,首先通过浏览器或者操作系统中的 CA 公钥,确认服务器的数字证书的真实性。 如果证书没有问题,客户端会**从数字证书中取出服务器的公钥**,然后使用它加密报文,向服务器发送如下信息: (1)一个随机数(`pre-master key`)。该随机数会被服务器公钥加密。 (2)加密通信算法改变通知,表示随后的信息都将用「会话秘钥」加密通信。 (3)客户端握手结束通知,表示客户端的握手阶段已经结束。这一项同时把之前所有内容的发生的数据做个摘要,用来供服务端校验。 上面第一项的随机数是整个握手阶段的第三个随机数,会发给服务端,所以这个随机数客户端和服务端都是一样的。 **服务器和客户端有了这三个随机数(Client Random、Server Random、pre-master key),接着就用双方协商的加密算法,各自生成本次通信的「会话秘钥」**。 *4. 服务器的最后回应* 服务器收到客户端的第三个随机数(`pre-master key`)之后,通过协商的加密算法,计算出本次通信的「会话秘钥」。 然后,向客户端发送最后的信息: (1)加密通信算法改变通知,表示随后的信息都将用「会话秘钥」加密通信。 (2)服务器握手结束通知,表示服务器的握手阶段已经结束。这一项同时把之前所有内容的发生的数据做个摘要,用来供客户端校验。 至此,整个 TLS 的握手阶段全部结束。接下来,客户端与服务器进入加密通信,就完全是使用普通的 HTTP 协议,只不过用「会话秘钥」加密内容。 ::: tip 如果想深入学习基于 RSA 算法的 HTTPS 握手过程,可以看这篇,我通过抓包的方式,逐步分析每一个过程:[HTTPS RSA 握手解析](https://xiaolincoding.com/network/2_http/https_rsa.html) 不过,基于 RSA 算法的 HTTPS 存在「前向安全」的问题:如果服务端的私钥泄漏了,过去被第三方截获的所有 TLS 通讯密文都会被破解。 为了解决这个问题,后面就出现了 ECDHE 密钥协商算法,我们现在大多数网站使用的正是 ECDHE 密钥协商算法,关于 ECDHE 握手的过程可以看这篇文章:[HTTPS ECDHE 握手解析](https://xiaolincoding.com/network/2_http/https_ecdhe.html#%E7%A6%BB%E6%95%A3%E5%AF%B9%E6%95%B0) ::: > 客户端校验数字证书的流程是怎样的? 接下来,详细说一下实际中数字证书签发和验证流程。 如下图图所示,为数字证书签发和验证流程: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/%E7%BD%91%E7%BB%9C/https/%E8%AF%81%E4%B9%A6%E7%9A%84%E6%A0%A1%E9%AA%8C.png) CA 签发证书的过程,如上图左边部分: - 首先 CA 会把持有者的公钥、用途、颁发者、有效时间等信息打成一个包,然后对这些信息进行 Hash 计算,得到一个 Hash 值; - 然后 CA 会使用自己的私钥将该 Hash 值加密,生成 Certificate Signature,也就是 CA 对证书做了签名; - 最后将 Certificate Signature 添加在文件证书上,形成数字证书; 客户端校验服务端的数字证书的过程,如上图右边部分: - 首先客户端会使用同样的 Hash 算法获取该证书的 Hash 值 H1; - 通常浏览器和操作系统中集成了 CA 的公钥信息,浏览器收到证书后可以使用 CA 的公钥解密 Certificate Signature 内容,得到一个 Hash 值 H2; - 最后比较 H1 和 H2,如果值相同,则为可信赖的证书,否则则认为证书不可信。 但事实上,证书的验证过程中**还存在一个证书信任链的问题**,因为我们向 CA 申请的证书一般不是根证书签发的,而是由中间证书签发的,比如百度的证书,从下图你可以看到,证书的层级有三级: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/%E7%BD%91%E7%BB%9C/https/baidu%E8%AF%81%E4%B9%A6.png) 对于这种三级层级关系的证书的验证过程如下: - 客户端收到 baidu.com 的证书后,发现这个证书的签发者不是根证书,就无法根据本地已有的根证书中的公钥去验证 baidu.com 证书是否可信。于是,客户端根据 baidu.com 证书中的签发者,找到该证书的颁发机构是“GlobalSign Organization Validation CA - SHA256 - G2”,然后向 CA 请求该中间证书。 - 请求到证书后发现“GlobalSign Organization Validation CA - SHA256 - G2”证书是由“GlobalSign Root CA”签发的,由于“GlobalSign Root CA”没有再上级签发机构,说明它是根证书,也就是自签证书。应用软件会检查此证书有否已预载于根证书清单上,如果有,则可以利用根证书中的公钥去验证“GlobalSign Organization Validation CA - SHA256 - G2”证书,如果发现验证通过,就认为该中间证书是可信的。 - “GlobalSign Organization Validation CA - SHA256 - G2”证书被信任后,可以使用“GlobalSign Organization Validation CA - SHA256 - G2”证书中的公钥去验证 baidu.com 证书的可信性,如果验证通过,就可以信任 baidu.com 证书。 在这四个步骤中,最开始客户端只信任根证书 GlobalSign Root CA 证书的,然后“GlobalSign Root CA”证书信任“GlobalSign Organization Validation CA - SHA256 - G2”证书,而“GlobalSign Organization Validation CA - SHA256 - G2”证书又信任 baidu.com 证书,于是客户端也信任 baidu.com 证书。 总括来说,由于用户信任 GlobalSign,所以由 GlobalSign 所担保的 baidu.com 可以被信任,另外由于用户信任操作系统或浏览器的软件商,所以由软件商预载了根证书的 GlobalSign 都可被信任。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/%E7%BD%91%E7%BB%9C/https/%E7%94%A8%E6%88%B7%E4%BF%A1%E4%BB%BB.png) 操作系统里一般都会内置一些根证书,比如我的 MAC 电脑里内置的根证书有这么多: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/%E7%BD%91%E7%BB%9C/https/%E7%B3%BB%E7%BB%9F%E6%A0%B9%E8%AF%81%E4%B9%A6.png) 这样的一层层地验证就构成了一条信任链路,整个证书信任链验证流程如下图所示: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/%E7%BD%91%E7%BB%9C/https/%E8%AF%81%E4%B9%A6%E9%93%BE.png) 最后一个问题,为什么需要证书链这么麻烦的流程?Root CA 为什么不直接颁发证书,而是要搞那么多中间层级呢? **这是为了确保根证书的绝对安全性,将根证书隔离地越严格越好,不然根证书如果失守了,那么整个信任链都会有问题。** ### HTTPS 的应用数据是如何保证完整性的? TLS 在实现上分为**握手协议**和**记录协议**两层: - TLS 握手协议就是我们前面说的 TLS 四次握手的过程,负责协商加密算法和生成对称密钥,后续用此密钥来保护应用程序数据(即 HTTP 数据); - TLS 记录协议负责保护应用程序数据并验证其完整性和来源,所以对 HTTP 数据加密是使用记录协议; TLS 记录协议主要负责消息(HTTP 数据)的压缩,加密及数据的认证,过程如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/记录协议.png) 具体过程如下: - 首先,消息被分割成多个较短的片段,然后分别对每个片段进行压缩。 - 接下来,经过压缩的片段会被**加上消息认证码(MAC 值,这个是通过哈希算法生成的),这是为了保证完整性,并进行数据的认证**。通过附加消息认证码的 MAC 值,可以识别出篡改。与此同时,为了防止重放攻击,在计算消息认证码时,还加上了片段的编码。 - 再接下来,经过压缩的片段再加上消息认证码会一起通过对称密码进行加密。 - 最后,上述经过加密的数据再加上由数据类型、版本号、压缩后的长度组成的报头就是最终的报文数据。 记录协议完成后,最终的报文数据将传递到传输控制协议 (TCP) 层进行传输。 如果你想详细了解记录协议是如何分片、压缩、计算 MAC 值、分组加密,可以看这篇:[理解 SSL/TLS 系列 (四) 记录协议](https://blog.csdn.net/zhanyiwp/article/details/105627799) ### HTTPS 一定安全可靠吗? 之前有读者在字节面试的时候,被问到:**HTTPS 一定安全可靠吗?** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/http/提问.jpeg) 这个问题的场景是这样的:客户端通过浏览器向服务端发起 HTTPS 请求时,被「假基站」转发到了一个「中间人服务器」,于是客户端是和「中间人服务器」完成了 TLS 握手,然后这个「中间人服务器」再与真正的服务端完成 TLS 握手。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/http/https中间人.drawio.png) 具体过程如下: - 客户端向服务端发起 HTTPS 建立连接请求时,然后被「假基站」转发到了一个「中间人服务器」,接着中间人向服务端发起 HTTPS 建立连接请求,此时客户端与中间人进行 TLS 握手,中间人与服务端进行 TLS 握手; - 在客户端与中间人进行 TLS 握手过程中,中间人会发送自己的公钥证书给客户端,**客户端验证证书的真伪**,然后从证书拿到公钥,并生成一个随机数,用公钥加密随机数发送给中间人,中间人使用私钥解密,得到随机数,此时双方都有随机数,然后通过算法生成对称加密密钥(A),后续客户端与中间人通信就用这个对称加密密钥来加密数据了。 - 在中间人与服务端进行 TLS 握手过程中,服务端会发送从 CA 机构签发的公钥证书给中间人,从证书拿到公钥,并生成一个随机数,用公钥加密随机数发送给服务端,服务端使用私钥解密,得到随机数,此时双方都有随机数,然后通过算法生成对称加密密钥(B),后续中间人与服务端通信就用这个对称加密密钥来加密数据了。 - 后续的通信过程中,中间人用对称加密密钥(A)解密客户端的 HTTPS 请求的数据,然后用对称加密密钥(B)加密 HTTPS 请求后,转发给服务端,接着服务端发送 HTTPS 响应数据给中间人,中间人用对称加密密钥(B)解密 HTTPS 响应数据,然后再用对称加密密钥(A)加密后,转发给客户端。 从客户端的角度看,其实并不知道网络中存在中间人服务器这个角色。那么中间人就可以解开浏览器发起的 HTTPS 请求里的数据,也可以解开服务端响应给浏览器的 HTTPS 响应数据。相当于,中间人能够“偷看”浏览器与服务端之间的 HTTPS 请求和响应的数据。 但是要发生这种场景是有前提的,前提是用户点击接受了中间人服务器的证书。 中间人服务器与客户端在 TLS 握手过程中,实际上发送了自己伪造的证书给浏览器,而这个伪造的证书是能被浏览器(客户端)识别出是非法的,于是就会提醒用户该证书存在问题。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/http/证书安全提示.png) 如果用户执意点击「继续浏览此网站」,相当于用户接受了中间人伪造的证书,那么后续整个 HTTPS 通信都能被中间人监听了。 所以,这其实并不能说 HTTPS 不够安全,毕竟浏览器都已经提示证书有问题了,如果用户坚决要访问,那不能怪 HTTPS,得怪自己手贱。 另外,如果你的电脑中毒了,被恶意导入了中间人的根证书,那么在验证中间人的证书的时候,由于你操作系统信任了中间人的根证书,那么等同于中间人的证书是合法的,这种情况下,浏览器是不会弹出证书存在问题的风险提醒的。 这其实也不关 HTTPS 的事情,是你电脑中毒了才导致 HTTPS 数据被中间人劫持的。 所以,**HTTPS 协议本身到目前为止还是没有任何漏洞的,即使你成功进行中间人攻击,本质上是利用了客户端的漏洞(用户点击继续访问或者被恶意导入伪造的根证书),并不是 HTTPS 不够安全**。 > 为什么抓包工具能截取 HTTPS 数据? 很多抓包工具 之所以可以明文看到 HTTPS 数据,工作原理与中间人一致的。 对于 HTTPS 连接来说,中间人要满足以下两点,才能实现真正的明文代理: 1. 中间人,作为客户端与真实服务端建立连接这一步不会有问题,因为服务端不会校验客户端的身份; 2. 中间人,作为服务端与真实客户端建立连接,这里会有客户端信任服务端的问题,也就是服务端必须有对应域名的私钥; 中间人要拿到私钥只能通过如下方式: 1. 去网站服务端拿到私钥; 2. 去 CA 处拿域名签发私钥; 3. 自己签发受浏览器信任的证书; 不用解释,抓包工具只能使用第三种方式取得中间人的身份。 因此使用抓包工具进行 HTTPS 抓包的时候,抓包工具会生成根证书,导入到客户端系统的 受信任的根证书列表 中,这里的根证书实际上起认证中心(CA)的作用。 随后抓包工具使用该根证书签发域名的证书,因为根证书受信任,域名的证书同样会被浏览器信任。也就是抓包工具给自己创建了一个认证中心 CA,客户端拿着中间人(抓包工具)签发的证书去中间人(抓包工具)自己的 CA 做认证,这个证书当然被认为是有效的。 > 如何避免被中间人抓取数据? 我们要保证自己电脑的安全,不要被病毒乘虚而入,而且也不要点击任何证书非法的网站,这样 HTTPS 数据就不会被中间人截取到了。 当然,我们还可以通过 **HTTPS 双向认证**来避免这种问题。 一般我们的 HTTPS 是单向认证,客户端只会验证了服务端的身份,但是服务端并不会验证客户端的身份。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/http/双向认证.png) 如果用了双向认证方式,不仅客户端会验证服务端的身份,而且服务端也会验证客户端的身份。服务端一旦验证到请求自己的客户端为不可信任的,服务端就拒绝继续通信,客户端如果发现服务端为不可信任的,那么也中止通信。 ## HTTP/1.1、HTTP/2、HTTP/3 演变 ### HTTP/1.1 相比 HTTP/1.0 提高了什么性能? HTTP/1.1 相比 HTTP/1.0 性能上的改进: - 使用长连接的方式改善了 HTTP/1.0 短连接造成的性能开销。 - 支持管道(pipeline)网络传输,只要第一个请求发出去了,不必等其回来,就可以发第二个请求出去,可以减少整体的响应时间。 但 HTTP/1.1 还是有性能瓶颈: - 请求 / 响应头部(Header)未经压缩就发送,首部信息越多延迟越大。只能压缩 `Body` 的部分; - 发送冗长的首部。每次互相发送相同的首部造成的浪费较多; - 服务器是按请求的顺序响应的,如果服务器响应慢,会招致客户端一直请求不到数据,也就是队头阻塞; - 没有请求优先级控制; - 请求只能从客户端开始,服务器只能被动响应。 ### HTTP/2 做了什么优化? HTTP/2 协议是基于 HTTPS 的,所以 HTTP/2 的安全性也是有保障的。 ![HTT/1 ~ HTTP/2](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/25-HTTP2.png) 那 HTTP/2 相比 HTTP/1.1 性能上的改进: - 头部压缩 - 二进制格式 - 并发传输 - 服务器主动推送资源 *1. 头部压缩* HTTP/2 会**压缩头**(Header)如果你同时发出多个请求,他们的头是一样的或是相似的,那么,协议会帮你**消除重复的部分**。 这就是所谓的 `HPACK` 算法:在客户端和服务器同时维护一张头信息表,所有字段都会存入这个表,生成一个索引号,以后就不发送同样字段了,只发送索引号,这样就**提高速度**了。 *2. 二进制格式* HTTP/2 不再像 HTTP/1.1 里的纯文本形式的报文,而是全面采用了**二进制格式**,头信息和数据体都是二进制,并且统称为帧(frame):**头信息帧(Headers Frame)和数据帧(Data Frame)**。 ![HTTP/1 与 HTTP/2 ](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/%E7%BD%91%E7%BB%9C/http2/%E4%BA%8C%E8%BF%9B%E5%88%B6%E5%B8%A7.png) 这样虽然对人不友好,但是对计算机非常友好,因为计算机只懂二进制,那么收到报文后,无需再将明文的报文转成二进制,而是直接解析二进制报文,这**增加了数据传输的效率**。 比如状态码 200,在 HTTP/1.1 是用 '2''0''0' 三个字符来表示(二进制:00110010 00110000 00110000),共用了 3 个字节,如下图 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/%E7%BD%91%E7%BB%9C/http2/http1.png) 在 HTTP/2 对于状态码 200 的二进制编码是 10001000,只用了 1 字节就能表示,相比于 HTTP/1.1 节省了 2 个字节,如下图: ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/%E7%BD%91%E7%BB%9C/http2/h2c.png) Header: :status: 200 OK 的编码内容为:1000 1000,那么表达的含义是什么呢? ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/http/index.png) 1. 最前面的 1 表示该 Header 是静态表中已经存在的 KV。(至于什么是静态表,可以看这篇:[HTTP/2 牛逼在哪?](https://xiaolincoding.com/network/2_http/http2.html)) 2. 在静态表理,“:status: 200 ok”静态表编码是 8,二进制即是 1000。 因此,整体加起来就是 1000 1000。 *3. 并发传输* 我们都知道 HTTP/1.1 的实现是基于请求 - 响应模型的。同一个连接中,HTTP 完成一个事务(请求与响应),才能处理下一个事务,也就是说在发出请求等待响应的过程中,是没办法做其他事情的,如果响应迟迟不来,那么后续的请求是无法发送的,也造成了**队头阻塞**的问题。 而 HTTP/2 就很牛逼了,引出了 Stream 概念,多个 Stream 复用在一条 TCP 连接。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http2/stream.png) 从上图可以看到,1 个 TCP 连接包含多个 Stream,Stream 里可以包含 1 个或多个 Message,Message 对应 HTTP/1 中的请求或响应,由 HTTP 头部和包体构成。Message 里包含一条或者多个 Frame,Frame 是 HTTP/2 最小单位,以二进制压缩格式存放 HTTP/1 中的内容(头部和包体)。 **针对不同的 HTTP 请求用独一无二的 Stream ID 来区分,接收端可以通过 Stream ID 有序组装成 HTTP 消息,不同 Stream 的帧是可以乱序发送的,因此可以并发不同的 Stream,也就是 HTTP/2 可以并行交错地发送请求和响应**。 比如下图,服务端**并行交错地**发送了两个响应:Stream 1 和 Stream 3,这两个 Stream 都是跑在一个 TCP 连接上,客户端收到后,会根据相同的 Stream ID 有序组装成 HTTP 消息。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/http/http2多路复用.jpeg) *4、服务器推送* HTTP/2 还在一定程度上改善了传统的「请求 - 应答」工作模式,服务端不再是被动地响应,可以**主动**向客户端发送消息。 客户端和服务器**双方都可以建立 Stream**,Stream ID 也是有区别的,客户端建立的 Stream 必须是奇数号,而服务器建立的 Stream 必须是偶数号。 比如下图,Stream 1 是客户端向服务端请求的资源,属于客户端建立的 Stream,所以该 Stream 的 ID 是奇数(数字 1);Stream 2 和 4 都是服务端主动向客户端推送的资源,属于服务端建立的 Stream,所以这两个 Stream 的 ID 是偶数(数字 2 和 4)。 ![](https://img-blog.csdnimg.cn/83445581dafe409d8cfd2c573b2781ac.png) 再比如,客户端通过 HTTP/1.1 请求从服务器那获取到了 HTML 文件,而 HTML 可能还需要依赖 CSS 来渲染页面,这时客户端还要再发起获取 CSS 文件的请求,需要两次消息往返,如下图左边部分: ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/%E7%BD%91%E7%BB%9C/http2/push.png) 如上图右边部分,在 HTTP/2 中,客户端在访问 HTML 时,服务器可以直接主动推送 CSS 文件,减少了消息传递的次数。 > HTTP/2 有什么缺陷? HTTP/2 通过 Stream 的并发能力,解决了 HTTP/1 队头阻塞的问题,看似很完美了,但是 HTTP/2 还是存在“队头阻塞”的问题,只不过问题不是在 HTTP 这一层面,而是在 TCP 这一层。 **HTTP/2 是基于 TCP 协议来传输数据的,TCP 是字节流协议,TCP 层必须保证收到的字节数据是完整且连续的,这样内核才会将缓冲区里的数据返回给 HTTP 应用,那么当「前 1 个字节数据」没有到达时,后收到的字节数据只能存放在内核缓冲区里,只有等到这 1 个字节数据到达时,HTTP/2 应用层才能从内核中拿到数据,这就是 HTTP/2 队头阻塞问题。** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/quic/http2阻塞.jpeg) 举个例子,如下图: ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/%E7%BD%91%E7%BB%9C/http3/tcp%E9%98%9F%E5%A4%B4%E9%98%BB%E5%A1%9E.gif) 图中发送方发送了很多个 packet,每个 packet 都有自己的序号,你可以认为是 TCP 的序列号,其中 packet 3 在网络中丢失了,即使 packet 4-6 被接收方收到后,由于内核中的 TCP 数据不是连续的,于是接收方的应用层就无法从内核中读取到,只有等到 packet 3 重传后,接收方的应用层才可以从内核中读取到数据,这就是 HTTP/2 的队头阻塞问题,是在 TCP 层面发生的。 所以,一旦发生了丢包现象,就会触发 TCP 的重传机制,这样在一个 TCP 连接中的**所有的 HTTP 请求都必须等待这个丢了的包被重传回来**。 ::: tip 如果想更进一步了解 HTTP/2 协议,可以看我这篇文章:[HTTP/2 牛逼在哪?](https://xiaolincoding.com/network/2_http/http2.html) ::: ### HTTP/3 做了哪些优化? 前面我们知道了 HTTP/1.1 和 HTTP/2 都有队头阻塞的问题: - HTTP/1.1 中的管道(pipeline)虽然解决了请求的队头阻塞,但是**没有解决响应的队头阻塞**,因为服务端需要按顺序响应收到的请求,如果服务端处理某个请求消耗的时间比较长,那么只能等响应完这个请求后,才能处理下一个请求,这属于 HTTP 层队头阻塞。 - HTTP/2 虽然通过多个请求复用一个 TCP 连接解决了 HTTP 的队头阻塞,但是**一旦发生丢包,就会阻塞住所有的 HTTP 请求**,这属于 TCP 层队头阻塞。 HTTP/2 队头阻塞的问题是因为 TCP,所以 **HTTP/3 把 HTTP 下层的 TCP 协议改成了 UDP!** ![HTTP/1 ~ HTTP/3](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/27-HTTP3.png) UDP 发送是不管顺序,也不管丢包的,所以不会出现像 HTTP/2 队头阻塞的问题。大家都知道 UDP 是不可靠传输的,但基于 UDP 的 **QUIC 协议** 可以实现类似 TCP 的可靠性传输。 QUIC 有以下 3 个特点。 - 无队头阻塞 - 更快的连接建立 - 连接迁移 *1、无队头阻塞* QUIC 协议也有类似 HTTP/2 Stream 与多路复用的概念,也是可以在同一条连接上并发传输多个 Stream,Stream 可以认为就是一条 HTTP 请求。 QUIC 有自己的一套机制可以保证传输的可靠性的。**当某个流发生丢包时,只会阻塞这个流,其他流不会受到影响,因此不存在队头阻塞问题**。这与 HTTP/2 不同,HTTP/2 只要某个流中的数据包丢失了,其他流也会因此受影响。 所以,QUIC 连接上的多个 Stream 之间并没有依赖,都是独立的,某个流发生丢包了,只会影响该流,其他流不受影响。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/quic/quic无阻塞.jpeg) *2、更快的连接建立* 对于 HTTP/1 和 HTTP/2 协议,TCP 和 TLS 是分层的,分别属于内核实现的传输层、openssl 库实现的表示层,因此它们难以合并在一起,需要分批次来握手,先 TCP 握手,再 TLS 握手。 HTTP/3 在传输数据前虽然需要 QUIC 协议握手,但这个握手过程只需要 1 RTT,握手的目的是为确认双方的「连接 ID」,连接迁移就是基于连接 ID 实现的。 但是 HTTP/3 的 QUIC 协议并不是与 TLS 分层,而是 QUIC 内部包含了 TLS,它在自己的帧会携带 TLS 里的“记录”,再加上 QUIC 使用的是 TLS/1.3,因此仅需 1 个 RTT 就可以「同时」完成建立连接与密钥协商,如下图: ![TCP HTTPS(TLS/1.3)和 QUIC HTTPS ](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/28-HTTP3交互次数.png) 甚至,在第二次连接的时候,应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果。 如下图右边部分,HTTP/3 当会话恢复时,有效负载数据与第一个数据包一起发送,可以做到 0-RTT(下图的右下角): ![](https://img-blog.csdnimg.cn/4cad213f5125432693e0e2a512c2d1a1.png) *3、连接迁移* 基于 TCP 传输协议的 HTTP 协议,由于是通过四元组(源 IP、源端口、目的 IP、目的端口)确定一条 TCP 连接。 ![TCP 四元组](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzEwLmpwZw?x-oss-process=image/format,png) 那么**当移动设备的网络从 4G 切换到 WIFI 时,意味着 IP 地址变化了,那么就必须要断开连接,然后重新建立连接**。而建立连接的过程包含 TCP 三次握手和 TLS 四次握手的时延,以及 TCP 慢启动的减速过程,给用户的感觉就是网络突然卡顿了一下,因此连接的迁移成本是很高的。 而 QUIC 协议没有用四元组的方式来“绑定”连接,而是通过**连接 ID** 来标记通信的两个端点,客户端和服务器可以各自选择一组 ID 来标记自己,因此即使移动设备的网络变化后,导致 IP 地址变化了,只要仍保有上下文信息(比如连接 ID、TLS 密钥等),就可以“无缝”地复用原连接,消除重连的成本,没有丝毫卡顿感,达到了**连接迁移**的功能。 所以,QUIC 是一个在 UDP 之上的**伪** TCP + TLS + HTTP/2 的多路复用的协议。 QUIC 是新协议,对于很多网络设备,根本不知道什么是 QUIC,只会当做 UDP,这样会出现新的问题,因为有的网络设备是会丢掉 UDP 包的,而 QUIC 是基于 UDP 实现的,那么如果网络设备无法识别这个是 QUIC 包,那么就会当作 UDP 包,然后被丢弃。 HTTP/3 现在普及的进度非常的缓慢,不知道未来 UDP 是否能够逆袭 TCP。 ::: tip 如果想更进一步了解 HTTP/3 和 QUIC 协议,可以看我这两篇文章: - [HTTP/3 强势来袭](https://xiaolincoding.com/network/2_http/http3.html) - [如何基于 UDP 协议实现可靠传输?](https://xiaolincoding.com/network/3_tcp/quic.html) ::: ---- 参考资料: [1] 上野 宣。图解 HTTP.人民邮电出版社。 [2] 罗剑锋。透视 HTTP 协议。极客时间。 [3] 陈皓.HTTP 的前世今。酷壳 CoolShell.https://coolshell.cn/articles/19840.html [4] 阮一峰.HTTP 协议入门。阮一峰的网络日志.http://www.ruanyifeng.com/blog/2016/08/http.html ---- ## 读者问答 > 读者问:“https 和 http 相比,就是传输的内容多了对称加密,可以这么理解吗?” 1. 建立连接时候:https 比 http 多了 TLS 的握手过程; 2. 传输内容的时候:https 会把数据进行加密,通常是对称加密数据; > 读者问:“我看文中 TLS 和 SSL 没有做区分,这两个需要区分吗?” 这俩实际上是一个东西。 SSL 是洋文“*Secure Sockets Layer*”的缩写,中文叫做「安全套接层」。它是在上世纪 90 年代中期,由网景公司设计的。 到了 1999 年,SSL 因为应用广泛,已经成为互联网上的事实标准。IETF 就在那年把 SSL 标准化。标准化之后的名称改为 TLS(是“*Transport Layer Security*”的缩写),中文叫做「传输层安全协议」。 很多相关的文章都把这两者并列称呼(SSL/TLS),因为这两者可以视作同一个东西的不同阶段。 > 读者问:“为啥 SSL 的握手是 4 次?” SSL/TLS 1.2 需要 4 握手,需要 2 个 RTT 的时延,我文中的图是把每个交互分开画了,实际上把他们合在一起发送,就是 4 次握手: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/29-TLS1.2-四次握手.png) 另外,SSL/TLS 1.3 优化了过程,只需要 1 个 RTT 往返时延,也就是只需要 3 次握手: ![T](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/30-TLS1.3.png) ---- 本文的 `30` 张图片,都是从一条线两条线画出来,灰常的费劲,深切感受到画图也是个**体力活**啊! 爱偷懒的我其实不爱画图,但为了让大家能更好的理解,在跟自己无数次斗争后,踏上了耗时耗体力的画图的不归路,希望对你们有帮助! **小林是专为大家图解的工具人,Goodbye,我们下次见!** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/2_http/http_optimize.md ================================================ # 3.2 HTTP/1.1 如何优化? 问你一句:「**你知道 HTTP/1.1 该如何优化吗?**」 我们可以从下面这三种优化思路来优化 HTTP/1.1 协议: - *尽量避免发送 HTTP 请求*; - *在需要发送 HTTP 请求时,考虑如何减少请求次数*; - *减少服务器的 HTTP 响应的数据大小*; 下面,就针对这三种思路具体看看有哪些优化方法。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http1.1优化/优化http1.1提纲.png) --- ## 如何避免发送 HTTP 请求? 这个思路你看到是不是觉得很奇怪,不发送 HTTP 请求,那客户端还怎么和服务器交互数据?小林你这不是耍流氓嘛? 冷静冷静,你说的没错,客户端当然要向服务器发送请求的。 但是,对于一些具有重复性的 HTTP 请求,比如每次请求得到的数据都一样的,我们可以把这对「请求 - 响应」的数据都**缓存在本地**,那么下次就直接读取本地的数据,不必在通过网络获取服务器的响应了,这样的话 HTTP/1.1 的性能肯定肉眼可见的提升。 所以,避免发送 HTTP 请求的方法就是通过**缓存技术**,HTTP 设计者早在之前就考虑到了这点,因此 HTTP 协议的头部有不少是针对缓存的字段。 那缓存是如何做到的呢? 客户端会把第一次请求以及响应的数据保存在本地磁盘上,其中将请求的 URL 作为 key,而响应作为 value,两者形成映射关系。 这样当后续发起相同的请求时,就可以先在本地磁盘上通过 key 查到对应的 value,也就是响应,如果找到了,就直接从本地读取该响应。毋庸置疑,读取本地磁盘的速度肯定比网络请求快得多,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http1.1优化/缓存访问.png) 聪明的你可能想到了,万一缓存的响应不是最新的,而客户端并不知情,那么该怎么办呢? 放心,这个问题 HTTP 设计者早已考虑到。 所以,服务器在发送 HTTP 响应时,会估算一个过期的时间,并把这个信息放到响应头部中,这样客户端在查看响应头部的信息时,一旦发现缓存的响应是过期的,则就会重新发送网络请求。 如果客户端从第一次请求得到的响应头部中发现该响应过期了,客户端重新发送请求,假设服务器上的资源并没有变更,还是老样子,那么你觉得还要在服务器的响应带上这个资源吗? 很显然不带的话,可以提高 HTTP 协议的性能,那具体如何做到呢? 只需要客户端在重新发送请求时,在请求的 `Etag` 头部带上第一次请求的响应头部中的摘要,这个摘要是唯一标识响应的资源,当服务器收到请求后,会将本地资源的摘要与请求中的摘要做个比较。 如果不同,那么说明客户端的缓存已经没有价值,服务器在响应中带上最新的资源。 如果相同,说明客户端的缓存还是可以继续使用的,那么服务器**仅返回不含有包体的 `304 Not Modified` 响应**,告诉客户端仍然有效,这样就可以减少响应资源在网络中传输的延时,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http1.1优化/缓存etag.png) 缓存真的是性能优化的一把万能钥匙,小到 CPU Cache、Page Cache、Redis Cache,大到 HTTP 协议的缓存。 --- ## 如何减少 HTTP 请求次数? 减少 HTTP 请求次数自然也就提升了 HTTP 性能,可以从这 3 个方面入手: - *减少重定向请求次数*; - *合并请求*; - *延迟发送请求*; ### 减少重定向请求次数 我们先来看看什么是**重定向请求**? 服务器上的一个资源可能由于迁移、维护等原因从 url1 移至 url2 后,而客户端不知情,它还是继续请求 url1,这时服务器不能粗暴地返回错误,而是通过 `302` 响应码和 `Location` 头部,告诉客户端该资源已经迁移至 url2 了,于是客户端需要再发送 url2 请求以获得服务器的资源。 那么,如果重定向请求越多,那么客户端就要多次发起 HTTP 请求,每一次的 HTTP 请求都得经过网络,这无疑会越降低网络性能。 另外,服务端这一方往往不只有一台服务器,比如源服务器上一级是代理服务器,然后代理服务器才与客户端通信,这时客户端重定向就会导致客户端与代理服务器之间需要 2 次消息传递,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http1.1优化/客户端重定向.png) 如果**重定向的工作交由代理服务器完成,就能减少 HTTP 请求次数了**,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http1.1优化/代理服务器重定向.png) 而且当代理服务器知晓了重定向规则后,可以进一步减少消息传递次数,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http1.1优化/代理服务器重定向2.png) 除了 `302` 重定向响应码,还有其他一些重定向的响应码,你可以从下图看到: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http1.1优化/重定向响应码.png) 其中,`301` 和 `308` 响应码是告诉客户端可以将重定向响应缓存到本地磁盘,之后客户端就自动用 url2 替代 url1 访问服务器的资源。 ### 合并请求 如果把多个访问小文件的请求合并成一个大的请求,虽然传输的总资源还是一样,但是减少请求,也就意味着**减少了重复发送的 HTTP 头部**。 另外由于 HTTP/1.1 是请求响应模型,如果第一个发送的请求,未收到对应的响应,那么后续的请求就不会发送(PS:HTTP/1.1 管道模式是默认不使用的,所以讨论 HTTP/1.1 的队头阻塞问题,是不考虑管道模式的),于是为了防止单个请求的阻塞,所以**一般浏览器会同时发起 5-6 个请求,每一个请求都是不同的 TCP 连接**,那么如果合并了请求,也就会**减少 TCP 连接的数量,因而省去了 TCP 握手和慢启动过程耗费的时间**。 接下来,具体看看合并请求的几种方式。 有的网页会含有很多小图片、小图标,有多少个小图片,客户端就要发起多少次请求。那么对于这些小图片,我们可以考虑使用 `CSS Image Sprites` 技术把它们合成一个大图片,这样浏览器就可以用一次请求获得一个大图片,然后再根据 CSS 数据把大图片切割成多张小图片。 ![图来源于:墨染枫林的 CSDN](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/http1.1优化/css精灵.png) 这种方式就是**通过将多个小图片合并成一个大图片来减少 HTTP 请求的次数,以减少 HTTP 请求的次数,从而减少网络的开销**。 除了将小图片合并成大图片的方式,还有服务端使用 `webpack` 等打包工具将 js、css 等资源合并打包成大文件,也是能达到类似的效果。 另外,还可以将图片的二进制数据用 `base64` 编码后,以 URL 的形式嵌入到 HTML 文件,跟随 HTML 文件一并发送。 ```plain 既然有 HTTP 协议,为什么还要有 RPC? 其实,`TCP` 是**70 年**代出来的协议,而 `HTTP` 是 **90 年代**才开始流行的。而直接使用裸 TCP 会有问题,可想而知,这中间这么多年有多少自定义的协议,而这里面就有**80 年代**出来的 `RPC`。 所以我们该问的不是**既然有 HTTP 协议为什么要有 RPC**,而是**为什么有 RPC 还要有 HTTP 协议**。 > 那既然有 RPC 了,为什么还要有 HTTP 呢? 现在电脑上装的各种**联网**软件,比如 xx 管家,xx 卫士,它们都作为**客户端(Client)需要跟服务端(Server)建立连接收发消息**,此时都会用到应用层协议,在这种 Client/Server (C/S) 架构下,它们可以使用自家造的 RPC 协议,因为它只管连自己公司的服务器就 ok 了。 但有个软件不同,**浏览器(Browser)**,不管是 Chrome 还是 IE,它们不仅要能访问自家公司的**服务器(Server)**,还需要访问其他公司的网站服务器,因此它们需要有个统一的标准,不然大家没法交流。于是,HTTP 就是那个时代用于统一 **Browser/Server (B/S)** 的协议。 也就是说在多年以前,**HTTP 主要用于 B/S 架构,而 RPC 更多用于 C/S 架构。但现在其实已经没分那么清了,B/S 和 C/S 在慢慢融合。**很多软件同时支持多端,比如某度云盘,既要支持**网页版**,还要支持**手机端和 PC 端**,如果通信协议都用 HTTP 的话,那服务器只用同一套就够了。而 RPC 就开始退居幕后,一般用于公司内部集群里,各个微服务之间的通讯。 那这么说的话,**都用 HTTP 得了,还用什么 RPC?** 仿佛又回到了文章开头的样子,那这就要从它们之间的区别开始说起。 ## HTTP 和 RPC 有什么区别 我们来看看 RPC 和 HTTP 区别比较明显的几个点。 ### 服务发现 首先要向某个服务器发起请求,你得先建立连接,而建立连接的前提是,你得知道 **IP 地址和端口**。这个找到服务对应的 IP 端口的过程,其实就是**服务发现**。 在 **HTTP** 中,你知道服务的域名,就可以通过 **DNS 服务**去解析得到它背后的 IP 地址,默认 80 端口。 而 **RPC** 的话,就有些区别,一般会有专门的**中间服务**去保存服务名和 IP 信息,比如 **Consul 或者 Etcd,甚至是 Redis**。想要访问某个服务,就去这些中间服务去获得 IP 和端口信息。由于 DNS 也是服务发现的一种,所以也有基于 DNS 去做服务发现的组件,比如**CoreDNS**。 可以看出服务发现这一块,两者是有些区别,但不太能分高低。 ### 底层连接形式 以主流的 **HTTP/1.1** 协议为例,其默认在建立底层 TCP 连接之后会一直保持这个连接(**Keep Alive**),之后的请求和响应都会复用这条连接。 而 **RPC** 协议,也跟 HTTP 类似,也是通过建立 TCP 长链接进行数据交互,但不同的地方在于,RPC 协议一般还会再建个**连接池**,在请求量大的时候,建立多条连接放在池内,要发数据的时候就从池里取一条连接出来,**用完放回去,下次再复用**,可以说非常环保。 ![connection_pool](https://img-blog.csdnimg.cn/img_convert/ec5c8e28d3ea308c6db2ac991a12ea80.png) **由于连接池有利于提升网络请求性能,所以不少编程语言的网络库里都会给 HTTP 加个连接池**,比如 **Go** 就是这么干的。 可以看出这一块两者也没太大区别,所以也不是关键。 ### 传输的内容 基于 TCP 传输的消息,说到底,无非都是**消息头 Header 和消息体 Body。** **Header** 是用于标记一些特殊信息,其中最重要的是**消息体长度**。 **Body** 则是放我们真正需要传输的内容,而这些内容只能是二进制 01 串,毕竟计算机只认识这玩意。所以 TCP 传字符串和数字都问题不大,因为字符串可以转成编码再变成 01 串,而数字本身也能直接转为二进制。但结构体呢,我们得想个办法将它也转为二进制 01 串,这样的方案现在也有很多现成的,比如 **Json,Protobuf。** 这个将结构体转为二进制数组的过程就叫**序列化**,反过来将二进制数组复原成结构体的过程叫**反序列化**。 ![序列化和反序列化](https://img-blog.csdnimg.cn/img_convert/dba2bc3af0938d2c087f85acc191fd3f.png) 对于主流的 HTTP/1.1,虽然它现在叫**超文本**协议,支持音频视频,但 HTTP 设计初是用于做网页**文本**展示的,所以它传的内容以字符串为主。Header 和 Body 都是如此。在 Body 这块,它使用 **Json** 来**序列化**结构体数据。 我们可以随便截个图直观看下。 ![HTTP 报文](https://img-blog.csdnimg.cn/img_convert/324cbe84c303a3b975e50329f5cdbf8b.png) 可以看到这里面的内容非常多的**冗余**,显得**非常啰嗦**。最明显的,像 `Header` 里的那些信息,其实如果我们约定好头部的第几位是 Content-Type,就**不需要每次都真的把"Content-Type"这个字段都传过来**,类似的情况其实在 `body` 的 Json 结构里也特别明显。 而 RPC,因为它定制化程度更高,可以采用体积更小的 Protobuf 或其他序列化协议去保存结构体数据,同时也不需要像 HTTP 那样考虑各种浏览器行为,比如 302 重定向跳转啥的。**因此性能也会更好一些,这也是在公司内部微服务中抛弃 HTTP,选择使用 RPC 的最主要原因。** ![HTTP 原理](https://img-blog.csdnimg.cn/img_convert/f4cef7331cabcfe56d9d6434f7ef907f.png) ![RPC 原理](https://img-blog.csdnimg.cn/img_convert/12244fb0b19b2e61755fcab799198f68.png) 当然上面说的 HTTP,其实**特指的是现在主流使用的 HTTP/1.1**,`HTTP/2` 在前者的基础上做了很多改进,所以**性能可能比很多 RPC 协议还要好**,甚至连 `gRPC` 底层都直接用的 `HTTP/2`。 > 那么问题又来了,为什么既然有了 HTTP/2,还要有 RPC 协议? 这个是由于 HTTP/2 是 2015 年出来的。那时候很多公司内部的 RPC 协议都已经跑了好些年了,基于历史原因,一般也没必要去换了。 ## 总结 - 纯裸 TCP 是能收发数据,但它是个**无边界**的数据流,上层需要定义**消息格式**用于定义**消息边界**。于是就有了各种协议,HTTP 和各类 RPC 协议就是在 TCP 之上定义的应用层协议。 - **RPC 本质上不算是协议,而是一种调用方式**,而像 gRPC 和 Thrift 这样的具体实现,才是协议,它们是实现了 RPC 调用的协议。目的是希望程序员能像调用本地方法那样去调用远端的服务方法。同时 RPC 有很多种实现方式,**不一定非得基于 TCP 协议**。 - 从发展历史来说,**HTTP 主要用于 B/S 架构,而 RPC 更多用于 C/S 架构。但现在其实已经没分那么清了,B/S 和 C/S 在慢慢融合**。很多软件同时支持多端,所以对外一般用 HTTP 协议,而内部集群的微服务之间则采用 RPC 协议进行通讯。 - RPC 其实比 HTTP 出现的要早,且比目前主流的 HTTP/1.1 **性能**要更好,所以大部分公司内部都还在使用 RPC。 - **HTTP/2.0** 在 **HTTP/1.1** 的基础上做了优化,性能可能比很多 RPC 协议都要好,但由于是这几年才出来的,所以也不太可能取代掉 RPC。 --- ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」*** ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/2_http/http_websocket.md ================================================ # 3.9 既然有 HTTP 协议,为什么还要有 WebSocket? > 来源:公众号@小白 debug > > 原文地址:[既然有 HTTP 协议,为什么还要有 WebSocket?](https://mp.weixin.qq.com/s/jJNdXMNmXcE8wSE0gbtTAQ) 平时我们打开网页,比如购物网站某宝。都是点一下「列表商品」,跳转一下网页就到了「商品详情」。 从 HTTP 协议的角度来看,就是点一下网页上的某个按钮,**前端发一次 HTTP 请求,网站返回一次 HTTP 响应**。这种由客户端主动请求,服务器响应的方式也满足大部分网页的功能场景。 但有没有发现,这种情况下,服务器从来就「不会主动」给客户端发一次消息。就像你喜欢的女生从来不会主动找你一样。 但如果现在,你在刷网页的时候「右下角」突然弹出一个小广告,提示你【一个人在家偷偷才能玩哦】。 **求知,好学,勤奋**,这些刻在你 DNA 里的东西都动起来了。 你点开后发现。 长相平平无奇的古某提示你"道士 9 条狗,全服横着走"。 影帝某辉老师跟你说"系兄弟就来砍我"。 ![图片](https://img-blog.csdnimg.cn/img_convert/b8cca4b1291f25235bc8df3dddbb6da3.png) 来都来了,你就选了个角色进到了游戏界面里。 ![图片](https://img-blog.csdnimg.cn/img_convert/95e5b4cee384b182d0e604378c3ca00a.jpeg) 这时候,上来就是一个小怪,从远处走来,然后疯狂拿木棒子抽你。 **你全程没点任何一次鼠标**。服务器就自动将怪物的移动数据和攻击数据源源不断发给你了。 这…….太暖心了。 感动之余,问题就来了: 像这种**看起来服务器主动发消息给客户端的场景**,是怎么做到的? 在真正回答这个问题之前,我们先来聊下一些相关的知识背景。 ## 使用 HTTP 不断轮询 其实问题的痛点在于,**怎么样才能在用户不做任何操作的情况下,网页能收到消息并发生变更。** 最常见的解决方案是,**网页的前端代码里不断定时发 HTTP 请求到服务器,服务器收到请求后给客户端响应消息。** 这其实时一种「**伪**」服务器推的形式。 它其实并不是服务器主动发消息到客户端,而是客户端自己不断偷偷请求服务器,只是用户无感知而已。 用这种方式的场景也有很多,最常见的就是**扫码登录**。 比如,某信公众号平台,登录页面二维码出现之后,**前端**网页根本不知道用户扫没扫,于是不断去向**后端**服务器询问,看有没有人扫过这个码。而且是以大概 1 到 2 秒的间隔去不断发出请求,这样可以保证用户在扫码后能在 1 到 2 秒内得到及时的反馈,不至于**等太久**。 ## 使用 HTTP 定时轮询 但这样,会有两个比较明显的问题: - 当你打开 F12 页面时,你会发现满屏的 HTTP 请求。虽然很小,但这其实也消耗带宽,同时也会增加下游服务器的负担。 - 最坏情况下,用户在扫码后,需要等个 1~2 秒,正好才触发下一次 HTTP 请求,然后才跳转页面,用户会感到**明显的卡顿**。 使用起来的体验就是,二维码出现后,手机扫一扫,然后在手机上点个确认,这时候**卡顿等个 1~2 秒**,页面才跳转。 ![图片](https://img-blog.csdnimg.cn/img_convert/5e0e0e25e8aca80812c9a2892032111c.png) 那么问题又来了,**有没有更好的解决方案?** 有,而且实现起来成本还非常低。 ## 长轮询 我们知道,HTTP 请求发出后,一般会给服务器留一定的时间做响应,比如 3 秒,规定时间内没返回,就认为是超时。 如果我们的 HTTP 请求**将超时设置的很大**,比如 30 秒,**在这 30 秒内只要服务器收到了扫码请求,就立马返回给客户端网页。如果超时,那就立马发起下一次请求。** 这样就减少了 HTTP 请求的个数,并且由于大部分情况下,用户都会在某个 30 秒的区间内做扫码操作,所以响应也是及时的。 ![图片](https://img-blog.csdnimg.cn/img_convert/1058a96ba35215c0f30accc3ff5bb824.png) 比如,某度云网盘就是这么干的。所以你会发现一扫码,手机上点个确认,电脑端网页就**秒跳转**,体验很好。 ![图片](https://img-blog.csdnimg.cn/img_convert/a3a8c95b97d2bac26cfab123a4da68b2.png) 像这种发起一个请求,在较长时间内等待服务器响应的机制,就是所谓的**长轮询机制**。我们常用的消息队列 RocketMQ 中,消费者去取数据时,也用到了这种方式。 ![图片](https://img-blog.csdnimg.cn/img_convert/6173c1d25abc914ef17ee9e534ed6a5f.png) 像这种,在用户不感知的情况下,服务器将数据推送给浏览器的技术,就是所谓的**服务器推送**技术,它还有个毫不沾边的英文名,**comet** 技术,大家听过就好。 上面提到的两种解决方案(不断轮询和长轮询),本质上,其实还是客户端主动去取数据。 对于像扫码登录这样的**简单场景**还能用用。但如果是网页游戏呢,游戏一般会有大量的数据需要从服务器主动推送到客户端。 这就得说下 **WebSocket** 了。 ## WebSocket 是什么 我们知道 TCP 连接的两端,**同一时间里**,**双方**都可以**主动**向对方发送数据。这就是所谓的**全双工**。 而现在使用最广泛的 `HTTP/1.1`,也是基于 TCP 协议的,**同一时间里**,客户端和服务器**只能有一方主动**发数据,这就是所谓的**半双工**。 也就是说,好好的全双工 TCP,被 HTTP/1.1 用成了半双工。 为什么? 这是由于 HTTP 协议设计之初,考虑的是看看网页文本的场景,能做到**客户端发起请求再由服务器响应**,就够了,根本就没考虑网页游戏这种,客户端和服务器之间都要互相主动发大量数据的场景。 所以,为了更好的支持这样的场景,我们需要另外一个**基于 TCP 的新协议**。 于是新的应用层协议 **WebSocket** 就被设计出来了。 大家别被这个名字给带偏了。虽然名字带了个 socket,但其实 **socket 和 WebSocket 之间,就跟雷峰和雷峰塔一样,二者接近毫无关系**。 ![图片](https://img-blog.csdnimg.cn/img_convert/3bbe4c5db972513f912d30ba8cbddd65.png) ### 怎么建立 WebSocket 连接 我们平时刷网页,一般都是在浏览器上刷的,一会刷刷图文,这时候用的是 **HTTP 协议**,一会打开网页游戏,这时候就得切换成我们新介绍的 **WebSocket 协议**。 为了兼容这些使用场景。浏览器在 **TCP 三次握手**建立连接之后,都**统一使用 HTTP 协议**先进行一次通信。 - 如果此时是**普通的 HTTP 请求**,那后续双方就还是老样子继续用普通 HTTP 协议进行交互,这点没啥疑问。 - 如果这时候是**想建立 WebSocket 连接**,就会在 HTTP 请求里带上一些**特殊的 header 头**,如下: ```http Connection: Upgrade Upgrade: WebSocket Sec-WebSocket-Key: T2a6wZlAwhgQNqruZ2YUyg==\r\n ``` 这些 header 头的意思是,浏览器想**升级协议(Connection: Upgrade)**,并且**想升级成 WebSocket 协议(Upgrade: WebSocket)**。同时带上一段**随机生成的 Base64 码(Sec-WebSocket-Key)**,发给服务器。 如果服务器正好支持升级成 WebSocket 协议。就会走 WebSocket 握手流程,同时根据客户端生成的 Base64 码,用某个**公开的**算法变成另一段字符串,放在 HTTP 响应的 `Sec-WebSocket-Accept` 头里,同时带上 `101 状态码`,发回给浏览器。HTTP 的响应如下: ```http HTTP/1.1 101 Switching Protocols\r\n Sec-WebSocket-Accept: iBJKv/ALIW2DobfoA4dmr3JHBCY=\r\n Upgrade: WebSocket\r\n Connection: Upgrade\r\n ``` HTTP 状态码=200(正常响应)的情况,大家见得多了。101 确实不常见,它其实是指**协议切换**。 ![图片](https://img-blog.csdnimg.cn/img_convert/dea71991b336c876cae2e1ebdf03b62d.png) 之后,浏览器也用同样的**公开算法**将 `Base64 码` 转成另一段字符串,如果这段字符串跟服务器传回来的**字符串一致**,那验证通过。 ![图片](https://img-blog.csdnimg.cn/img_convert/117eebe06cc6b35ded3216a95706f080.png) 就这样经历了一来一回两次 HTTP 握手,WebSocket 就建立完成了,后续双方就可以使用 WebSocket 的数据格式进行通信了。 ![图片](https://img-blog.csdnimg.cn/img_convert/f4edd3018914fe6eb38fad6aa3fd2d65.png) ### WebSocket 抓包 我们可以用 WireShark 抓个包,实际看下数据包的情况。 ![图片](https://img-blog.csdnimg.cn/img_convert/f756ca625523f0f9d40a402465179bbe.png) 上面这张图,注意画了红框的第 `2445` 行报文,是 WebSocket 的**第一次握手**,意思是发起了一次带有 `特殊 Header` 的 HTTP 请求。 ![图片](https://img-blog.csdnimg.cn/img_convert/82d65f08dad05e6b537ea06b94224a5f.png) 上面这个图里画了红框的 `4714` 行报文,就是服务器在得到第一次握手后,响应的**第二次握手**,可以看到这也是个 HTTP 类型的报文,返回的状态码是 101。同时可以看到返回的报文 Header 中也带有各种 `WebSocket` 相关的信息,比如 `Sec-WebSocket-Accept`。 ![图片](https://img-blog.csdnimg.cn/img_convert/382c7699530ea7e7b22f60bb68af21bd.png) 上面这张图就是全貌了,从截图上的注释可以看出,WebSocket 和 HTTP 一样都是基于 TCP 的协议。**经历了三次 TCP 握手之后,利用 HTTP 协议升级为 WebSocket 协议**。 你在网上可能会看到一种说法:“WebSocket 是基于 HTTP 的新协议”,**其实这并不对**,因为 WebSocket 只有在建立连接时才用到了 HTTP,**升级完成之后就跟 HTTP 没有任何关系了**。 这就好像你喜欢的女生通过你要到了你大学室友的微信,然后他们自己就聊起来了。你能说这个女生是通过你去跟你室友沟通的吗?不能。你跟 HTTP 一样,都只是个**工具人**。 ![图片](https://img-blog.csdnimg.cn/img_convert/2e9d4b1652bdfa1e3ae4bb24f70a1b5a.png) 这就有点"**借壳生蛋**"的那意思。 ## HTTP 和 WebSocket 的关系 ### WebSocket 的消息格式 上面提到在完成协议升级之后,两端就会用 WebSocket 的数据格式进行通信。 数据包在 WebSocket 中被叫做**帧**,我们来看下它的数据格式长什么样子。 ![图片](https://img-blog.csdnimg.cn/img_convert/3a63a86e5d7e72a37b9828fc6e65c21f.png) 这里面字段很多,但我们只需要关注下面这几个。 **opcode 字段**:这个是用来标志这是个**什么类型**的数据帧。比如。 - 等于 1,是指 text 类型(`string`)的数据包 - 等于 2,是二进制数据类型(`[]byte`)的数据包 - 等于 8,是关闭连接的信号 **payload 字段**:存放的是我们**真正想要传输的数据的长度**,单位是**字节**。比如你要发送的数据是 `字符串"111"`,那它的长度就是 `3`。 ![图片](https://img-blog.csdnimg.cn/img_convert/437a076935f82be1d36960c9a4785fbd.png) 另外,可以看到,我们存放**payload 长度的字段有好几个**,我们既可以用最前面的 `7 bit`, 也可以用后面的 `7+16 bit` 或 `7+64 bit`。 那么问题就来了。 我们知道,在数据层面,大家都是 01 二进制流。我怎么知道**什么情况下应该读 7 bit,什么情况下应该读 7+16 bit 呢?** WebSocket 会用最开始的 7 bit 做标志位。不管接下来的数据有多大,都**先读最先的 7 个 bit**,根据它的取值决定还要不要再读个 16 bit 或 64 bit。 - 如果 `最开始的 7 bit` 的值是 0~125,那么它就表示了 **payload 全部长度**,只读最开始的 `7 个 bit` 就完事了。 ![图片](https://img-blog.csdnimg.cn/img_convert/690f5a4deda2de50f3a35eddf0be4d75.png) - 如果是 `126(0x7E)`。那它表示 payload 的长度范围在 `126~65535` 之间,接下来还需要**再读 16 bit**。这 16 bit 会包含 payload 的真实长度。 ![图片](https://img-blog.csdnimg.cn/img_convert/c815c9dabc02fceb42a98c762705af33.png) - 如果是 `127(0x7F)`。那它表示 payload 的长度范围 `>=65536`,接下来还需要**再读 64 bit**。这 64 bit 会包含 payload 的长度。这能放 2 的 64 次方 byte 的数据,换算一下好多个 TB,肯定够用了。 ![图片](https://img-blog.csdnimg.cn/img_convert/192b22b4fe46e8dfb7b17549306d5998.png) **payload data 字段**:这里存放的就是真正要传输的数据,在知道了上面的 payload 长度后,就可以根据这个值去截取对应的数据。 大家有没有发现一个小细节,WebSocket 的数据格式也是**数据头(内含 payload 长度) + payload data** 的形式。 ![图片](https://img-blog.csdnimg.cn/img_convert/d449242f1bf41c6f95a5314ec8311d0d.jpeg) 这是因为 TCP 协议本身就是全双工,但直接使用**纯裸 TCP** 去传输数据,会有**粘包**的"问题"。为了解决这个问题,上层协议一般会用**消息头 + 消息体**的格式去重新包装要发的数据。 而**消息头**里一般含有**消息体的长度**,通过这个长度可以去截取真正的消息体。 HTTP 协议和大部分 RPC 协议,以及我们今天介绍的 WebSocket 协议,都是这样设计的。 ![图片](https://img-blog.csdnimg.cn/img_convert/b91fedb1856897c231b8fb5932b7b2d2.png) ### WebSocket 的使用场景 WebSocket 完美继承了 TCP 协议的**全双工**能力,并且还贴心的提供了解决粘包的方案。 它适用于**需要服务器和客户端(浏览器)频繁交互**的大部分场景,比如网页/小程序游戏,网页聊天室,以及一些类似飞书这样的网页协同办公软件。 回到文章开头的问题,在使用 WebSocket 协议的网页游戏里,怪物移动以及攻击玩家的行为是**服务器逻辑**产生的,对玩家产生的伤害等数据,都需要由**服务器主动发送给客户端**,客户端获得数据后展示对应的效果。 ![图片](https://img-blog.csdnimg.cn/img_convert/31410d2e885aab55c2c588aad754bb5c.png) ## 总结 - TCP 协议本身是**全双工**的,但我们最常用的 HTTP/1.1,虽然是基于 TCP 的协议,但它是**半双工**的,对于大部分需要服务器主动推送数据到客户端的场景,都不太友好,因此我们需要使用支持全双工的 WebSocket 协议。 - 在 HTTP/1.1 里,只要客户端不问,服务端就不答。基于这样的特点,对于登录页面这样的简单场景,可以使用**定时轮询或者长轮询**的方式实现**服务器推送**(comet) 的效果。 - 对于客户端和服务端之间需要频繁交互的复杂场景,比如网页游戏,都可以考虑使用 WebSocket 协议。 - WebSocket 和 socket 几乎没有任何关系,只是叫法相似。 - 正因为各个浏览器都支持 HTTP 协议,所以 WebSocket 会先利用 HTTP 协议加上一些特殊的 Header 头进行握手升级操作,升级成功后就跟 HTTP 没有任何关系了,之后就用 WebSocket 的数据格式进行收发数据。 ------ ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」关注我*** ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/2_http/https_ecdhe.md ================================================ # 3.4 HTTPS ECDHE 握手解析 HTTPS 常用的密钥交换算法有两种,分别是 RSA 和 ECDHE 算法。 其中,RSA 是比较传统的密钥交换算法,它不具备前向安全的性质,因此现在很少服务器使用的。而 ECDHE 算法具有前向安全,所以被广泛使用。 我在上一篇已经介绍了 [RSA 握手的过程](https://mp.weixin.qq.com/s/U9SRLE7jZTB6lUZ6c8gTKg),今天这一篇就「从理论再到实战抓包」介绍 **ECDHE 算法**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/ecdhe提纲.png) --- ## 离散对数 ECDHE 密钥协商算法是 DH 算法演进过来的,所以我们先从 DH 算法说起。 DH 算法是非对称加密算法,因此它可以用于密钥交换,该算法的核心数学思想是**离散对数**。 是不是听到这个数学概念就怂了?不怕,这次不会说离散对数推导的过程,只简单提一下它的数学公式。 离散对数是「离散 + 对数」的两个数学概念的组合,所以我们先来复习一遍对数。 要说起对数,必然要说指数,因为它们是互为反函数,指数就是幂运算,对数是指数的逆运算。 举个栗子,如果以 2 作为底数,那么指数和对数运算公式,如下图所示: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/指数与对数.png) 那么对于底数为 2 的时候,32 的对数是 5,64 的对数是 6,计算过程如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/求对数.png) 对数运算的取值是可以连续的,而离散对数的取值是不能连续的,因此也以「离散」得名, 离散对数是在对数运算的基础上加了「模运算」,也就说取余数,对应编程语言的操作符是「%」,也可以用 mod 表示。离散对数的概念如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/离散对数.png) 上图的,底数 a 和模数 p 是离散对数的公共参数,也就说是公开的,b 是真数,i 是对数。知道了对数,就可以用上面的公式计算出真数。但反过来,知道真数却很难推算出对数。 **特别是当模数 p 是一个很大的质数,即使知道底数 a 和真数 b,在现有的计算机的计算水平是几乎无法算出离散对数的,这就是 DH 算法的数学基础。** --- ## DH 算法 认识了离散对数,我们来看看 DH 算法是如何密钥交换的。 现假设小红和小明约定使用 DH 算法来交换密钥,那么基于离散对数,小红和小明需要先确定模数和底数作为算法的参数,这两个参数是公开的,用 P 和 G 来代称。 然后小红和小明各自生成一个随机整数作为**私钥**,双方的私钥要各自严格保管,不能泄漏,小红的私钥用 a 代称,小明的私钥用 b 代称。 现在小红和小明双方都有了 P 和 G 以及各自的私钥,于是就可以计算出**公钥**: - 小红的公钥记作 A,A = G ^ a ( mod P ); - 小明的公钥记作 B,B = G ^ b ( mod P ); A 和 B 也是公开的,因为根据离散对数的原理,从真数(A 和 B)反向计算对数 a 和 b 是非常困难的,至少在现有计算机的计算能力是无法破解的,如果量子计算机出来了,那就有可能被破解,当然如果量子计算机真的出来了,那么密钥协商算法就要做大的升级了。 双方交换各自 DH 公钥后,小红手上共有 5 个数:P、G、a、A、B,小明手上也同样共有 5 个数:P、G、b、B、A。 然后小红执行运算:B ^ a ( mod P ),其结果为 K,因为离散对数的幂运算有交换律,所以小明执行运算:A ^ b ( mod P ),得到的结果也是 K。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/dh算法.png) 这个 K 就是小红和小明之间用的**对称加密密钥**,可以作为会话密钥使用。 可以看到,整个密钥协商过程中,小红和小明公开了 4 个信息:P、G、A、B,其中 P、G 是算法的参数,A 和 B 是公钥,而 a、b 是双方各自保管的私钥,黑客无法获取这 2 个私钥,因此黑客只能从公开的 P、G、A、B 入手,计算出离散对数(私钥)。 前面也多次强调,根据离散对数的原理,如果 P 是一个大数,在现有的计算机的计算能力是很难破解出 私钥 a、b 的,破解不出私钥,也就无法计算出会话密钥,因此 DH 密钥交换是安全的。 --- ## DHE 算法 根据私钥生成的方式,DH 算法分为两种实现: - static DH 算法,这个是已经被废弃了; - DHE 算法,现在常用的; static DH 算法里有一方的私钥是静态的,也就说每次密钥协商的时候有一方的私钥都是一样的,一般是服务器方固定,即 a 不变,客户端的私钥则是随机生成的。 于是,DH 交换密钥时就只有客户端的公钥是变化,而服务端公钥是不变的,那么随着时间延长,黑客就会截获海量的密钥协商过程的数据,因为密钥协商的过程有些数据是公开的,黑客就可以依据这些数据暴力破解出服务器的私钥,然后就可以计算出会话密钥了,于是之前截获的加密数据会被破解,所以 **static DH 算法不具备前向安全性**。 既然固定一方的私钥有被破解的风险,那么干脆就让双方的私钥在每次密钥交换通信时,都是随机生成的、临时的,这个方式也就是 DHE 算法,E 全称是 ephemeral(临时性的)。 所以,即使有个牛逼的黑客破解了某一次通信过程的私钥,其他通信过程的私钥仍然是安全的,因为**每个通信过程的私钥都是没有任何关系的,都是独立的,这样就保证了「前向安全」**。 ---- ## ECDHE 算法 DHE 算法由于计算性能不佳,因为需要做大量的乘法,为了提升 DHE 算法的性能,所以就出现了现在广泛用于密钥交换算法 —— **ECDHE 算法**。 ECDHE 算法是在 DHE 算法的基础上利用了 ECC 椭圆曲线特性,可以用更少的计算量计算出公钥,以及最终的会话密钥。 小红和小明使用 ECDHE 密钥交换算法的过程: - 双方事先确定好使用哪种椭圆曲线,和曲线上的基点 G,这两个参数都是公开的; - 双方各自随机生成一个随机数作为**私钥 d**,并与基点 G 相乘得到**公钥 Q**(Q = dG),此时小红的公私钥为 Q1 和 d1,小明的公私钥为 Q2 和 d2; - 双方交换各自的公钥,最后小红计算点(x1,y1) = d1Q2,小明计算点(x2,y2) = d2Q1,由于椭圆曲线上是可以满足乘法交换和结合律,所以 d1Q2 = d1d2G = d2d1G = d2Q1,因此**双方的 x 坐标是一样的,所以它是共享密钥,也就是会话密钥**。 这个过程中,双方的私钥都是随机、临时生成的,都是不公开的,即使根据公开的信息(椭圆曲线、公钥、基点 G)也是很难计算出椭圆曲线上的离散对数(私钥)。 --- ## ECDHE 握手过程 知道了 ECDHE 算法基本原理后,我们就结合实际的情况来看看。 我用 Wireshark 工具抓了用 ECDHE 密钥协商算法的 TSL 握手过程,可以看到是四次握手: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/ech_tls握手.png) 细心的小伙伴应该发现了,**使用了 ECDHE,在 TLS 第四次握手前,客户端就已经发送了加密的 HTTP 数据**,而对于 RSA 握手过程,必须要完成 TLS 四次握手,才能传输应用数据。 所以,**ECDHE 相比 RSA 握手过程省去了一个消息往返的时间**,这个有点「抢跑」的意思,它被称为是「*TLS False Start*」,跟「*TCP Fast Open*」有点像,都是在还没连接完全建立前,就发送了应用数据,这样便提高了传输的效率。 接下来,分析每一个 ECDHE 握手过程。 ### TLS 第一次握手 客户端首先会发一个「**Client Hello**」消息,消息里面有客户端使用的 TLS 版本号、支持的密码套件列表,以及生成的**随机数(*Client Random*)**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/ech_clinethello.png) ### TLS 第二次握手 服务端收到客户端的「打招呼」,同样也要回礼,会返回「**Server Hello**」消息,消息面有服务器确认的 TLS 版本号,也给出了一个**随机数(*Server Random*)**,然后从客户端的密码套件列表选择了一个合适的密码套件。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/ech_serverhello.png) 不过,这次选择的密码套件就和 RSA 不一样了,我们来分析一下这次的密码套件的意思。 「 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384」 - 密钥协商算法使用 ECDHE; - 签名算法使用 RSA; - 握手后的通信使用 AES 对称算法,密钥长度 256 位,分组模式是 GCM; - 摘要算法使用 SHA384; 接着,服务端为了证明自己的身份,发送「**Certificate**」消息,会把证书也发给客户端。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/ech_certificate.png) 这一步就和 RSA 握手过程有很大到区别了,因为服务端选择了 ECDHE 密钥协商算法,所以会在发送完证书后,发送「**Server Key Exchange**」消息。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/ech_serverkey.png) 这个过程服务器做了三件事: - 选择了**名为 x25519 的椭圆曲线**,选好了椭圆曲线相当于椭圆曲线基点 G 也定好了,这些都会公开给客户端; - 生成随机数作为服务端椭圆曲线的私钥,保留到本地; - 根据基点 G 和私钥计算出**服务端的椭圆曲线公钥**,这个会公开给客户端。 为了保证这个椭圆曲线的公钥不被第三方篡改,服务端会用 RSA 签名算法给服务端的椭圆曲线公钥做个签名。 随后,就是「**Server Hello Done**」消息,服务端跟客户端表明:“这些就是我提供的信息,打招呼完毕”。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/ech_serverhellodone.png) 至此,TLS 两次握手就已经完成了,目前客户端和服务端通过明文共享了这几个信息:**Client Random、Server Random、使用的椭圆曲线、椭圆曲线基点 G、服务端椭圆曲线的公钥**,这几个信息很重要,是后续生成会话密钥的材料。 ### TLS 第三次握手 客户端收到了服务端的证书后,自然要校验证书是否合法,如果证书合法,那么服务端到身份就是没问题的。校验证书的过程会走证书链逐级验证,确认证书的真实性,再用证书的公钥验证签名,这样就能确认服务端的身份了,确认无误后,就可以继续往下走。 客户端会生成一个随机数作为客户端椭圆曲线的私钥,然后再根据服务端前面给的信息,生成**客户端的椭圆曲线公钥**,然后用「**Client Key Exchange**」消息发给服务端。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/ech_clientkeyexchange.png) 至此,双方都有对方的椭圆曲线公钥、自己的椭圆曲线私钥、椭圆曲线基点 G。于是,双方都就计算出点(x,y),其中 x 坐标值双方都是一样的,前面说 ECDHE 算法时候,说 x 是会话密钥,**但实际应用中,x 还不是最终的会话密钥**。 还记得 TLS 握手阶段,客户端和服务端都会生成了一个随机数传递给对方吗? **最终的会话密钥,就是用「客户端随机数 + 服务端随机数 + x(ECDHE 算法算出的共享密钥) 」三个材料生成的**。 之所以这么麻烦,是因为 TLS 设计者不信任客户端或服务器「伪随机数」的可靠性,为了保证真正的完全随机,把三个不可靠的随机数混合起来,那么「随机」的程度就非常高了,足够让黑客计算不出最终的会话密钥,安全性更高。 算好会话密钥后,客户端会发一个「**Change Cipher Spec**」消息,告诉服务端后续改用对称算法加密通信。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/ech_schangecipherspec.png) 接着,客户端会发「**Encrypted Handshake Message**」消息,把之前发送的数据做一个摘要,再用对称密钥加密一下,让服务端做个验证,验证下本次生成的对称密钥是否可以正常使用。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/ech_encryptedhandshakemessage.png) ### TLS 第四次握手 最后,服务端也会有一个同样的操作,发「**Change Cipher Spec**」和「**Encrypted Handshake Message**」消息,如果双方都验证加密和解密没问题,那么握手正式完成。于是,就可以正常收发加密的 HTTP 请求和响应了。 ---- ## 总结 RSA 和 ECDHE 握手过程的区别: - RSA 密钥协商算法「不支持」前向保密,ECDHE 密钥协商算法「支持」前向保密; - 使用了 RSA 密钥协商算法,TLS 完成四次握手后,才能进行应用数据传输,而对于 ECDHE 算法,客户端可以不用等服务端的最后一次 TLS 握手,就可以提前发出加密的 HTTP 数据,节省了一个消息的往返时间(这个是 RFC 文档规定的,具体原因文档没有说明,所以这点我也不太明白); - 使用 ECDHE,在 TLS 第 2 次握手中,会出现服务器端发出的「Server Key Exchange」消息,而 RSA 握手过程没有该消息; --- 参考资料: 1. https://zh.wikipedia.org/wiki/橢圓曲線迪菲-赫爾曼金鑰交換 2. https://zh.wikipedia.org/wiki/椭圆曲线 3. https://zh.wikipedia.org/wiki/迪菲-赫爾曼密鑰交換 4. https://time.geekbang.org/column/article/148188 5. https://zhuanlan.zhihu.com/p/106967180 --- 哈喽,我是小林,就爱图解计算机基础,如果文章对你有帮助,别忘记关注哦! ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/2_http/https_optimize.md ================================================ # 3.5 HTTPS 如何优化? 由裸数据传输的 HTTP 协议转成加密数据传输的 HTTPS 协议,给应用数据套了个「保护伞」,提高安全性的同时也带来了性能消耗。 因为 HTTPS 相比 HTTP 协议多一个 TLS 协议握手过程,**目的是为了通过非对称加密握手协商或者交换出对称加密密钥**,这个过程最长可以花费掉 2 RTT,接着后续传输的应用数据都得使用对称加密密钥来加密/解密。 为了数据的安全性,我们不得不使用 HTTPS 协议,至今大部分网址都已从 HTTP 迁移至 HTTPS 协议,因此针对 HTTPS 的优化是非常重要的。 这次,就从多个角度来优化 HTTPS。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https优化/优化https提纲.png) --- ## 分析性能损耗 既然要对 HTTPS 优化,那得清楚哪些步骤会产生性能消耗,再对症下药。 产生性能消耗的两个环节: - 第一个环节,TLS 协议握手过程; - 第二个环节,握手后的对称加密报文传输。 对于第二环节,现在主流的对称加密算法 AES、ChaCha20 性能都是不错的,而且一些 CPU 厂商还针对它们做了硬件级别的优化,因此这个环节的性能消耗可以说非常地小。 而第一个环节,TLS 协议握手过程不仅增加了网络延时(最长可以花费掉 2 RTT),而且握手过程中的一些步骤也会产生性能损耗,比如: - 对于 ECDHE 密钥协商算法,握手过程中会客户端和服务端都需要临时生成椭圆曲线公私钥; - 客户端验证证书时,会访问 CA 获取 CRL 或者 OCSP,目的是验证服务器的证书是否有被吊销; - 双方计算 Pre-Master,也就是对称加密密钥; 为了大家更清楚这些步骤在 TLS 协议握手的哪一个阶段,我画出了这幅图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https优化/tls性能损耗.png) --- ## 硬件优化 玩游戏时,如果我们怎么都战胜不了对方,那么有一个最有效、最快的方式来变强,那就是「充钱」,如果还是不行,那说明你充的钱还不够多。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https优化/充钱.jpg) 对于计算机里也是一样,软件都是跑在物理硬件上,硬件越牛逼,软件跑的也越快,所以如果要优化 HTTPS 优化,最直接的方式就是花钱买性能参数更牛逼的硬件。 但是花钱也要花对方向,**HTTPS 协议是计算密集型,而不是 I/O 密集型**,所以不能把钱花在网卡、硬盘等地方,应该花在 CPU 上。 一个好的 CPU,可以提高计算性能,因为 HTTPS 连接过程中就有大量需要计算密钥的过程,所以这样可以加速 TLS 握手过程。 另外,如果可以,应该选择可以**支持 AES-NI 特性的 CPU**,因为这种款式的 CPU 能在指令级别优化了 AES 算法,这样便加速了数据的加解密传输过程。 如果你的服务器是 Linux 系统,那么你可以使用下面这行命令查看 CPU 是否支持 AES-NI 指令集: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https优化/aesni_intel.png) 如果我们的 CPU 支持 AES-NI 特性,那么对于对称加密的算法应该选择 AES 算法。否则可以选择 ChaCha20 对称加密算法,因为 ChaCha20 算法的运算指令相比 AES 算法会对 CPU 更友好一点。 --- ## 软件优化 如果公司预算充足对于新的服务器是可以考虑购买更好的 CPU,但是对于已经在使用的服务器,硬件优化的方式可能就不太适合了,于是就要从软件的方向来优化了。 软件的优化方向可以分成两种,一个是**软件升级**,一个是**协议优化**。 先说第一个软件升级,软件升级就是将正在使用的软件升级到最新版本,因为最新版本不仅提供了最新的特性,也优化了以前软件的问题或性能。比如: - 将 Linux 内核从 2.x 升级到 4.x; - 将 OpenSSL 从 1.0.1 升级到 1.1.1; - ... 看似简单的软件升级,对于有成百上千服务器的公司来说,软件升级也跟硬件升级同样是一个棘手的问题,因为要实行软件升级,会花费时间和人力,同时也存在一定的风险,也可能会影响正常的线上服务。 既然如此,我们把目光放到协议优化,也就是在现有的环节下,通过较小的改动,来进行优化。 --- ## 协议优化 协议的优化就是对「密钥交换过程」进行优化。 ### 密钥交换算法优化 TLS 1.2 版本如果使用的是 RSA 密钥交换算法,那么需要 4 次握手,也就是要花费 2 RTT,才可以进行应用数据的传输,而且 RSA 密钥交换算法不具备前向安全性。 总之使用 **RSA 密钥交换算法的 TLS 握手过程,不仅慢,而且安全性也不高**。 因此如果可以,尽量**选用 ECDHE 密钥交换**算法替换 RSA 算法,因为该算法由于支持「False Start」,它是“抢跑”的意思,客户端可以在 TLS 协议的第 3 次握手后,第 4 次握手前,发送加密的应用数据,以此将 **TLS 握手的消息往返由 2 RTT 减少到 1 RTT,而且安全性也高,具备前向安全性**。 ECDHE 算法是基于椭圆曲线实现的,不同的椭圆曲线性能也不同,应该尽量**选择 x25519 曲线**,该曲线是目前最快的椭圆曲线。 比如在 Nginx 上,可以使用 ssl_ecdh_curve 指令配置想使用的椭圆曲线,把优先使用的放在前面: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https优化/ssl_ecdh_curve.png) 对于对称加密算法方面,如果对安全性不是特别高的要求,可以**选用 AES_128_GCM**,它比 AES_256_GCM 快一些,因为密钥的长度短一些。 比如在 Nginx 上,可以使用 ssl_ciphers 指令配置想使用的非对称加密算法和对称加密算法,也就是密钥套件,而且把性能最快最安全的算法放在最前面: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https优化/ssl_ciphers.png) ### TLS 升级 当然,如果可以,直接把 TLS 1.2 升级成 TLS 1.3,TLS 1.3 大幅度简化了握手的步骤,**完成 TLS 握手只要 1 RTT**,而且安全性更高。 在 TLS 1.2 的握手中,一般是需要 4 次握手,先要通过 Client Hello(第 1 次握手)和 Server Hello(第 2 次握手)消息协商出后续使用的加密算法,再互相交换公钥(第 3 和 第 4 次握手),然后计算出最终的会话密钥,下图的左边部分就是 TLS 1.2 的握手过程: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https优化/tls1.2and1.3.png) 上图的右边部分就是 TLS 1.3 的握手过程,可以发现 **TLS 1.3 把 Hello 和公钥交换这两个消息合并成了一个消息,于是这样就减少到只需 1 RTT 就能完成 TLS 握手**。 怎么合并的呢?具体的做法是,客户端在 Client Hello 消息里带上了支持的椭圆曲线,以及这些椭圆曲线对应的公钥。 服务端收到后,选定一个椭圆曲线等参数,然后返回消息时,带上服务端这边的公钥。经过这 1 个 RTT,双方手上已经有生成会话密钥的材料了,于是客户端计算出会话密钥,就可以进行应用数据的加密传输了。 而且,TLS1.3 对密码套件进行“减肥”了, **对于密钥交换算法,废除了不支持前向安全性的 RSA 和 DH 算法,只支持 ECDHE 算法**。 对于对称加密和签名算法,只支持目前最安全的几个密码套件,比如 openssl 中仅支持下面 5 种密码套件: - TLS_AES_256_GCM_SHA384 - TLS_CHACHA20_POLY1305_SHA256 - TLS_AES_128_GCM_SHA256 - TLS_AES_128_CCM_8_SHA256 - TLS_AES_128_CCM_SHA256 之所以 TLS1.3 仅支持这么少的密码套件,是因为 TLS1.2 由于支持各种古老且不安全的密码套件,中间人可以利用降级攻击,伪造客户端的 Client Hello 消息,替换客户端支持的密码套件为一些不安全的密码套件,使得服务器被迫使用这个密码套件进行 HTTPS 连接,从而破解密文。 --- ## 证书优化 为了验证的服务器的身份,服务器会在 TLS 握手过程中,把自己的证书发给客户端,以此证明自己身份是可信的。 对于证书的优化,可以有两个方向: - 一个是**证书传输**, - 一个是**证书验证**; ### 证书传输优化 要让证书更便于传输,那必然是减少证书的大小,这样可以节约带宽,也能减少客户端的运算量。所以,**对于服务器的证书应该选择椭圆曲线(ECDSA)证书,而不是 RSA 证书,因为在相同安全强度下,ECC 密钥长度比 RSA 短的多**。 ### 证书验证优化 客户端在验证证书时,是个复杂的过程,会走证书链逐级验证,验证的过程不仅需要「用 CA 公钥解密证书」以及「用签名算法验证证书的完整性」,而且为了知道证书是否被 CA 吊销,客户端有时还会再去访问 CA,下载 CRL 或者 OCSP 数据,以此确认证书的有效性。 这个访问过程是 HTTP 访问,因此又会产生一系列网络通信的开销,如 DNS 查询、建立连接、收发数据等。 #### CRL CRL 称为证书吊销列表(*Certificate Revocation List*),这个列表是由 CA 定期更新,列表内容都是被撤销信任的证书序号,如果服务器的证书在此列表,就认为证书已经失效,不在的话,则认为证书是有效的。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https优化/crl.png) 但是 CRL 存在两个问题: - 第一个问题,由于 CRL 列表是由 CA 维护的,定期更新,如果一个证书刚被吊销后,客户端在更新 CRL 之前还是会信任这个证书,**实时性较差**; - 第二个问题,**随着吊销证书的增多,列表会越来越大,下载的速度就会越慢**,下载完客户端还得遍历这么大的列表,那么就会导致客户端在校验证书这一环节的延时很大,进而拖慢了 HTTPS 连接。 #### OCSP 因此,现在基本都是使用 OCSP,名为在线证书状态协议(*Online Certificate Status Protocol*)来查询证书的有效性,它的工作方式是**向 CA 发送查询请求,让 CA 返回证书的有效状态**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https优化/ocsp.png) 不必像 CRL 方式客户端需要下载大大的列表,还要从列表查询,同时因为可以实时查询每一张证书的有效性,解决了 CRL 的实时性问题。 OCSP 需要向 CA 查询,因此也是要发生网络请求,而且还得看 CA 服务器的“脸色”,如果网络状态不好,或者 CA 服务器繁忙,也会导致客户端在校验证书这一环节的延时变大。 #### OCSP Stapling 于是为了解决这一个网络开销,就出现了 OCSP Stapling,其原理是:服务器向 CA 周期性地查询证书状态,获得一个带有时间戳和签名的响应结果并缓存它。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https优化/opscp-stapling.png) 当有客户端发起连接请求时,服务器会把这个「响应结果」在 TLS 握手过程中发给客户端。由于有签名的存在,服务器无法篡改,因此客户端就能得知证书是否已被吊销了,这样客户端就不需要再去查询。 --- ## 会话复用 TLS 握手的目的就是为了协商出会话密钥,也就是对称加密密钥,那我们如果我们把首次 TLS 握手协商的对称加密密钥缓存起来,待下次需要建立 HTTPS 连接时,直接「复用」这个密钥,不就减少 TLS 握手的性能损耗了吗? 这种方式就是**会话复用**(*TLS session resumption*),会话复用分两种: - 第一种叫 Session ID; - 第二种叫 Session Ticket; ### Session ID Session ID 的工作原理是,**客户端和服务器首次 TLS 握手连接后,双方会在内存缓存会话密钥,并用唯一的 Session ID 来标识**,Session ID 和会话密钥相当于 key-value 的关系。 当客户端再次连接时,hello 消息里会带上 Session ID,服务器收到后就会从内存找,如果找到就直接用该会话密钥恢复会话状态,跳过其余的过程,只用一个消息往返就可以建立安全通信。当然为了安全性,内存中的会话密钥会定期失效。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https优化/sessionid.png) 但是它有两个缺点: - 服务器必须保持每一个客户端的会话密钥,随着客户端的增多,**服务器的内存压力也会越大**。 - 现在网站服务一般是由多台服务器通过负载均衡提供服务的,**客户端再次连接不一定会命中上次访问过的服务器**,于是还要走完整的 TLS 握手过程; ### Session Ticket 为了解决 Session ID 的问题,就出现了 Session Ticket,**服务器不再缓存每个客户端的会话密钥,而是把缓存的工作交给了客户端**,类似于 HTTP 的 Cookie。 客户端与服务器首次建立连接时,服务器会加密「会话密钥」作为 Ticket 发给客户端,交给客户端缓存该 Ticket。 客户端再次连接服务器时,客户端会发送 Ticket,服务器解密后就可以获取上一次的会话密钥,然后验证有效期,如果没问题,就可以恢复会话了,开始加密通信。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https优化/ticket.png) 对于集群服务器的话,**要确保每台服务器加密「会话密钥」的密钥是一致的**,这样客户端携带 Ticket 访问任意一台服务器时,都能恢复会话。 Session ID 和 Session Ticket **都不具备前向安全性**,因为一旦加密「会话密钥」的密钥被破解或者服务器泄漏「会话密钥」,前面劫持的通信密文都会被破解。 同时应对**重放攻击**也很困难,这里简单介绍下重放攻击工作的原理。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https优化/重放攻击.png) 假设 Alice 想向 Bob 证明自己的身份。Bob 要求 Alice 的密码作为身份证明,爱丽丝应尽全力提供(可能是在经过如哈希函数的转换之后)。与此同时,Eve 窃听了对话并保留了密码(或哈希)。 交换结束后,Eve(冒充 Alice)连接到 Bob。当被要求提供身份证明时,Eve 发送从 Bob 接受的最后一个会话中读取的 Alice 的密码(或哈希),从而授予 Eve 访问权限。 重放攻击的危险之处在于,如果中间人截获了某个客户端的 Session ID 或 Session Ticket 以及 POST 报文,而一般 POST 请求会改变数据库的数据,中间人就可以利用此截获的报文,不断向服务器发送该报文,这样就会导致数据库的数据被中间人改变了,而客户是不知情的。 避免重放攻击的方式就是需要**对会话密钥设定一个合理的过期时间**。 ### Pre-shared Key 前面的 Session ID 和 Session Ticket 方式都需要在 1 RTT 才能恢复会话。 而 TLS1.3 更为牛逼,对于重连 TLS1.3 只需要 **0 RTT**,原理和 Ticket 类似,只不过在重连时,客户端会把 Ticket 和 HTTP 请求一同发送给服务端,这种方式叫 **Pre-shared Key**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https优化/0-RTT.png) 同样的,Pre-shared Key 也有重放攻击的危险。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https优化/0-rtt-attack.png) 如上图,假设中间人通过某种方式,截获了客户端使用会话重用技术的 POST 请求,通常 POST 请求是会改变数据库的数据,然后中间人就可以把截获的这个报文发送给服务器,服务器收到后,也认为是合法的,于是就恢复会话,致使数据库的数据又被更改,但是此时用户是不知情的。 所以,应对重放攻击可以给会话密钥设定一个合理的过期时间,以及只针对安全的 HTTP 请求如 GET/HEAD 使用会话重用。 --- ## 总结 对于硬件优化的方向,因为 HTTPS 是属于计算密集型,应该选择计算力更强的 CPU,而且最好选择**支持 AES-NI 特性的 CPU**,这个特性可以在硬件级别优化 AES 对称加密算法,加快应用数据的加解密。 对于软件优化的方向,如果可以,把软件升级成较新的版本,比如将 Linux 内核 2.X 升级成 4.X,将 openssl 1.0.1 升级到 1.1.1,因为新版本的软件不仅会提供新的特性,而且还会修复老版本的问题。 对于协议优化的方向: - 密钥交换算法应该选择 **ECDHE 算法**,而不用 RSA 算法,因为 ECDHE 算法具备前向安全性,而且客户端可以在第三次握手之后,就发送加密应用数据,节省了 1 RTT。 - 将 TLS1.2 升级 **TLS1.3**,因为 TLS1.3 的握手过程只需要 1 RTT,而且安全性更强。 对于证书优化的方向: - 服务器应该选用 **ECDSA 证书**,而非 RSA 证书,因为在相同安全级别下,ECC 的密钥长度比 RSA 短很多,这样可以提高证书传输的效率; - 服务器应该开启 **OCSP Stapling** 功能,由服务器预先获得 OCSP 的响应,并把响应结果缓存起来,这样 TLS 握手的时候就不用再访问 CA 服务器,减少了网络通信的开销,提高了证书验证的效率; 对于重连 HTTPS 时,我们可以使用一些技术让客户端和服务端使用上一次 HTTPS 连接使用的会话密钥,直接恢复会话,而不用再重新走完整的 TLS 握手过程。 常见的**会话重用**技术有 Session ID 和 Session Ticket,用了会话重用技术,当再次重连 HTTPS 时,只需要 1 RTT 就可以恢复会话。对于 TLS1.3 使用 Pre-shared Key 会话重用技术,只需要 0 RTT 就可以恢复会话。 这些会话重用技术虽然好用,但是存在一定的安全风险,它们不仅不具备前向安全,而且有重放攻击的风险,所以应当对会话密钥设定一个合理的过期时间。 --- 参考资料: 1. http://www.doc88.com/p-8621583210895.html 2. https://zhuanlan.zhihu.com/p/33685085 3. https://en.wikipedia.org/wiki/Replay_attack 4. https://en.wikipedia.org/wiki/Downgrade_attack 5. https://www.cnblogs.com/racent-Z/p/14011056.html 6. http://www.guoyanbin.com/a-detailed-look-at-rfc-8446-a-k-a-tls-1-3/ 7. https://www.thesslstore.com/blog/crl-explained-what-is-a-certificate-revocation-list/ --- 哈喽,我是小林,就爱图解计算机基础,如果文章对你有帮助,别忘记关注哦! ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/2_http/https_rsa.md ================================================ # 3.3 HTTPS RSA 握手解析 我前面讲,简单给大家介绍了的 HTTPS 握手过程,但是还不够细! 只讲了比较基础的部分,所以这次我们再来深入一下 HTTPS,用**实战抓包**的方式,带大家再来窥探一次 HTTPS。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/https提纲.png) 对于还不知道对称加密和非对称加密的同学,你先复习我以前的这篇文章[「硬核!30 张图解 HTTP 常见的面试题」,](https://mp.weixin.qq.com/s/bUy220-ect00N4gnO0697A)本篇文章默认大家已经具备了这些知识。 --- ## TLS 握手过程 HTTP 由于是明文传输,所谓的明文,就是说客户端与服务端通信的信息都是肉眼可见的,随意使用一个抓包工具都可以截获通信的内容。 所以安全上存在以下三个风险: - *窃听风险*,比如通信链路上可以获取通信内容,用户号容易没。 - *篡改风险*,比如强制植入垃圾广告,视觉污染,用户眼容易瞎。 - *冒充风险*,比如冒充淘宝网站,用户钱容易没。 HTTP**S** 在 HTTP 与 TCP 层之间加入了 TLS 协议,来解决上述的风险。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/HTTP/19-HTTPS与HTTP.png) TLS 协议是如何解决 HTTP 的风险的呢? - *信息加密*:HTTP 交互信息是被加密的,第三方就无法被窃取; - *校验机制*:校验信息传输过程中是否有被第三方篡改过,如果被篡改过,则会有警告提示; - *身份证书*:证明淘宝是真的淘宝网; 可见,有了 TLS 协议,能保证 HTTP 通信是安全的了,那么在进行 HTTP 通信前,需要先进行 TLS 握手。TLS 的握手过程,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/tls握手.png) 上图简要概述了 TLS 的握手过程,其中每一个「框」都是一个记录(*record*),记录是 TLS 收发数据的基本单位,类似于 TCP 里的 segment。多个记录可以组合成一个 TCP 包发送,所以**通常经过「四个消息」就可以完成 TLS 握手,也就是需要 2 个 RTT 的时延**,然后就可以在安全的通信环境里发送 HTTP 报文,实现 HTTPS 协议。 所以可以发现,HTTPS 是应用层协议,需要先完成 TCP 连接建立,然后走 TLS 握手过程后,才能建立通信安全的连接。 事实上,不同的密钥交换算法,TLS 的握手过程可能会有一些区别。 这里先简单介绍下密钥交换算法,因为考虑到性能的问题,所以双方在加密应用信息时使用的是对称加密密钥,而对称加密密钥是不能被泄漏的,为了保证对称加密密钥的安全性,所以使用非对称加密的方式来保护对称加密密钥的协商,这个工作就是密钥交换算法负责的。 接下来,我们就以最简单的 `RSA` 密钥交换算法,来看看它的 TLS 握手过程。 --- ## RSA 握手过程 传统的 TLS 握手基本都是使用 RSA 算法来实现密钥交换的,在将 TLS 证书部署服务端时,证书文件其实就是服务端的公钥,会在 TLS 握手阶段传递给客户端,而服务端的私钥则一直留在服务端,一定要确保私钥不能被窃取。 在 RSA 密钥协商算法中,客户端会生成随机密钥,并使用服务端的公钥加密后再传给服务端。根据非对称加密算法,公钥加密的消息仅能通过私钥解密,这样服务端解密后,双方就得到了相同的密钥,再用它加密应用消息。 我用 Wireshark 工具抓了用 RSA 密钥交换的 TLS 握手过程,你可以从下面看到,一共经历了四次握手: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/tls四次握手.png) 对应 Wireshark 的抓包,我也画了一幅图,你可以从下图很清晰地看到该过程: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/https_rsa.png) 那么,接下来针对每一个 TLS 握手做进一步的介绍。 ### TLS 第一次握手 客户端首先会发一个「**Client Hello**」消息,字面意思我们也能理解到,这是跟服务器「打招呼」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/clienthello.png) 消息里面有客户端使用的 TLS 版本号、支持的密码套件列表,以及生成的**随机数(*Client Random*)**,这个随机数会被服务端保留,它是生成对称加密密钥的材料之一。 ### TLS 第二次握手 当服务端收到客户端的「Client Hello」消息后,会确认 TLS 版本号是否支持,和从密码套件列表中选择一个密码套件,以及生成**随机数(*Server Random*)**。 接着,返回「**Server Hello**」消息,消息里面有服务器确认的 TLS 版本号,也给出了随机数(Server Random),然后从客户端的密码套件列表选择了一个合适的密码套件。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/serverhello.png) 可以看到,服务端选择的密码套件是“Cipher Suite: TLS_RSA_WITH_AES_128_GCM_SHA256”。 这个密码套件看起来真让人头晕,好一大串,但是其实它是有固定格式和规范的。基本的形式是「**密钥交换算法 + 签名算法 + 对称加密算法 + 摘要算法**」,一般 WITH 单词前面有两个单词,第一个单词是约定密钥交换的算法,第二个单词是约定证书的验证算法。比如刚才的密码套件的意思就是: - 由于 WITH 单词前只有一个 RSA,则说明握手时密钥交换算法和签名算法都是使用 RSA; - 握手后的通信使用 AES 对称算法,密钥长度 128 位,分组模式是 GCM; - 摘要算法 SHA256 用于消息认证和产生随机数; 就前面这两个客户端和服务端相互「打招呼」的过程,客户端和服务端就已确认了 TLS 版本和使用的密码套件,而且你可能发现客户端和服务端都会各自生成一个随机数,并且还会把随机数传递给对方。 那这个随机数有啥用呢?其实这两个随机数是后续作为生成「会话密钥」的条件,所谓的会话密钥就是数据传输时,所使用的对称加密密钥。 然后,服务端为了证明自己的身份,会发送「**Server Certificate**」给客户端,这个消息里含有数字证书。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/certificate.png) 随后,服务端发了「**Server Hello Done**」消息,目的是告诉客户端,我已经把该给你的东西都给你了,本次打招呼完毕。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/serverhellodone.png) ### 客户端验证证书 在这里刹个车,客户端拿到了服务端的数字证书后,要怎么校验该数字证书是真实有效的呢? #### 数字证书和 CA 机构 在说校验数字证书是否可信的过程前,我们先来看看数字证书是什么,一个数字证书通常包含了: - 公钥; - 持有者信息; - 证书认证机构(CA)的信息; - CA 对这份文件的数字签名及使用的算法; - 证书有效期; - 还有一些其他额外信息; 那数字证书的作用,是用来认证公钥持有者的身份,以防止第三方进行冒充。说简单些,证书就是用来告诉客户端,该服务端是否是合法的,因为只有证书合法,才代表服务端身份是可信的。 我们用证书来认证公钥持有者的身份(服务端的身份),那证书又是怎么来的?又该怎么认证证书呢? 为了让服务端的公钥被大家信任,服务端的证书都是由 CA(*Certificate Authority*,证书认证机构)签名的,CA 就是网络世界里的公安局、公证中心,具有极高的可信度,所以由它来给各个公钥签名,信任的一方签发的证书,那必然证书也是被信任的。 之所以要签名,是因为签名的作用可以避免中间人在获取证书时对证书内容的篡改。 #### 数字证书签发和验证流程 如下图所示,为数字证书签发和验证流程: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/证书的校验.png) CA 签发证书的过程,如上图左边部分: - 首先 CA 会把持有者的公钥、用途、颁发者、有效时间等信息打成一个包,然后对这些信息进行 Hash 计算,得到一个 Hash 值; - 然后 CA 会使用自己的私钥将该 Hash 值加密,生成 Certificate Signature,也就是 CA 对证书做了签名; - 最后将 Certificate Signature 添加在文件证书上,形成数字证书; 客户端校验服务端的数字证书的过程,如上图右边部分: - 首先客户端会使用同样的 Hash 算法获取该证书的 Hash 值 H1; - 通常浏览器和操作系统中集成了 CA 的公钥信息,浏览器收到证书后可以使用 CA 的公钥解密 Certificate Signature 内容,得到一个 Hash 值 H2; - 最后比较 H1 和 H2,如果值相同,则为可信赖的证书,否则则认为证书不可信。 #### 证书链 但事实上,证书的验证过程中还存在一个证书信任链的问题,因为我们向 CA 申请的证书一般不是根证书签发的,而是由中间证书签发的,比如百度的证书,从下图你可以看到,证书的层级有三级: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/baidu证书.png) 对于这种三级层级关系的证书的验证过程如下: - 客户端收到 baidu.com 的证书后,发现这个证书的签发者不是根证书,就无法根据本地已有的根证书中的公钥去验证 baidu.com 证书是否可信。于是,客户端根据 baidu.com 证书中的签发者,找到该证书的颁发机构是“GlobalSign Organization Validation CA - SHA256 - G2”,然后向 CA 请求该中间证书。 - 请求到证书后发现“GlobalSign Organization Validation CA - SHA256 - G2”证书是由“GlobalSign Root CA”签发的,由于“GlobalSign Root CA”没有再上级签发机构,说明它是根证书,也就是自签证书。应用软件会检查此证书是否已预载于根证书清单上,如果有,则可以利用根证书中的公钥去验证“GlobalSign Organization Validation CA - SHA256 - G2”证书,如果发现验证通过,就认为该中间证书是可信的。 - “GlobalSign Organization Validation CA - SHA256 - G2”证书被信任后,可以使用“GlobalSign Organization Validation CA - SHA256 - G2”证书中的公钥去验证 baidu.com 证书的可信性,如果验证通过,就可以信任 baidu.com 证书。 在这四个步骤中,最开始客户端只信任根证书 GlobalSign Root CA 证书的,然后“GlobalSign Root CA”证书信任“GlobalSign Organization Validation CA - SHA256 - G2”证书,而“GlobalSign Organization Validation CA - SHA256 - G2”证书又信任 baidu.com 证书,于是客户端也信任 baidu.com 证书。 总括来说,由于用户信任 GlobalSign,所以由 GlobalSign 所担保的 baidu.com 可以被信任,另外由于用户信任操作系统或浏览器的软件商,所以由软件商预载了根证书的 GlobalSign 都可被信任。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/用户信任.png) 操作系统里一般都会内置一些根证书,比如我的 MAC 电脑里内置的根证书有这么多: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/系统根证书.png) 这样的一层层地验证就构成了一条信任链路,整个证书信任链验证流程如下图所示: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/证书链.png) 最后一个问题,为什么需要证书链这么麻烦的流程?Root CA 为什么不直接颁发证书,而是要搞那么多中间层级呢? 这是为了确保根证书的绝对安全性,将根证书隔离地越严格越好,不然根证书如果失守了,那么整个信任链都会有问题。 ### TLS 第三次握手 客户端验证完证书后,认为可信则继续往下走。 接着,客户端就会生成一个新的**随机数 (*pre-master*)**,用服务器的 RSA 公钥加密该随机数,通过「**Client Key Exchange**」消息传给服务端。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/clietnkeyexchange.png) 服务端收到后,用 RSA 私钥解密,得到客户端发来的随机数 (pre-master)。 至此,**客户端和服务端双方都共享了三个随机数,分别是 Client Random、Server Random、pre-master**。 于是,双方根据已经得到的三个随机数,生成**会话密钥(Master Secret)**,它是对称密钥,用于对后续的 HTTP 请求/响应的数据加解密。 生成完「会话密钥」后,然后客户端发一个「**Change Cipher Spec**」,告诉服务端开始使用加密方式发送消息。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/cipherspecmessage.png) 然后,客户端再发一个「**Encrypted Handshake Message(Finishd)**」消息,把之前所有发送的数据做个**摘要**,再用会话密钥(master secret)加密一下,让服务器做个验证,验证加密通信「是否可用」和「之前握手信息是否有被中途篡改过」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/https/encryptd.png) 可以发现,「Change Cipher Spec」之前传输的 TLS 握手数据都是明文,之后都是对称密钥加密的密文。 ### TLS 第四次握手 服务器也是同样的操作,发「**Change Cipher Spec**」和「**Encrypted Handshake Message**」消息,如果双方都验证加密和解密没问题,那么握手正式完成。 最后,就用「会话密钥」加解密 HTTP 请求和响应了。 --- ## RSA 算法的缺陷 **使用 RSA 密钥协商算法的最大问题是不支持前向保密**。 因为客户端传递随机数(用于生成对称加密密钥的条件之一)给服务端时使用的是公钥加密的,服务端收到后,会用私钥解密得到随机数。所以一旦服务端的私钥泄漏了,过去被第三方截获的所有 TLS 通讯密文都会被破解。 为了解决这个问题,后面就出现了 ECDHE 密钥协商算法,我们现在大多数网站使用的正是 ECDHE 密钥协商算法,关于 ECDHE 握手的过程,将在下一篇揭晓。 --- 哈喽,我是小林,就爱图解计算机基础,如果文章对你有帮助,别忘记关注哦! ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/3_tcp/challenge_ack.md ================================================ # 4.9 已建立连接的 TCP,收到 SYN 会发生什么? 大家好,我是小林。 昨晚有位读者问了我这么个问题: ![](https://img-blog.csdnimg.cn/ea1c6e0165f04232ab02046132e63d0f.jpg) 大概意思是,一个已经建立的 TCP 连接,客户端中途宕机了,而服务端此时也没有数据要发送,一直处于 Established 状态,客户端恢复后,向服务端建立连接,此时服务端会怎么处理? 看过我的图解网络的读者都知道,TCP 连接是由「四元组」唯一确认的。 然后这个场景中,客户端的 IP、服务端 IP、目的端口并没有变化,所以这个问题关键要看客户端发送的 SYN 报文中的源端口是否和上一次连接的源端口相同。 **1. 客户端的 SYN 报文里的端口号与历史连接不相同** 如果客户端恢复后发送的 SYN 报文中的源端口号跟上一次连接的源端口号不一样,此时服务端会认为是新的连接要建立,于是就会通过三次握手来建立新的连接。 那旧连接里处于 Established 状态的服务端最后会怎么样呢? 如果服务端发送了数据包给客户端,由于客户端的连接已经被关闭了,此时客户的内核就会回 RST 报文,服务端收到后就会释放连接。 如果服务端一直没有发送数据包给客户端,在超过一段时间后,TCP 保活机制就会启动,检测到客户端没有存活后,接着服务端就会释放掉该连接。 **2. 客户端的 SYN 报文里的端口号与历史连接相同** 如果客户端恢复后,发送的 SYN 报文中的源端口号跟上一次连接的源端口号一样,也就是处于 Established 状态的服务端收到了这个 SYN 报文。 大家觉得服务端此时会做什么处理呢? - 丢掉 SYN 报文? - 回复 RST 报文? - 回复 ACK 报文? 刚开始我看到这个问题的时候,也是没有思路的,因为之前没关注过,然后这个问题不能靠猜,所以我就看了 RFC 规范和看了 Linux 内核源码,最终知道了答案。 我不卖关子,先直接说答案。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/est_syn.png) **处于 Established 状态的服务端,如果收到了客户端的 SYN 报文(注意此时的 SYN 报文其实是乱序的,因为 SYN 报文的初始化序列号其实是一个随机数),会回复一个携带了正确序列号和确认号的 ACK 报文,这个 ACK 被称之为 Challenge ACK。** **接着,客户端收到这个 Challenge ACK,发现确认号(ack num)并不是自己期望收到的,于是就会回 RST 报文,服务端收到后,就会释放掉该连接。** ## RFC 文档解释 RFC 793 文档里的第 34 页里,有说到这个例子。 ![](https://img-blog.csdnimg.cn/873ad18443c040708c415bab6592ae41.png) 原文的解释我也贴出来给大家看看。 - When the SYN arrives at line 3, TCP B, being in a synchronized state, and the incoming segment outside the window, responds with an acknowledgment indicating what sequence it next expects to hear (ACK 100). - TCP A sees that this segment does not acknowledge anything it sent and, being unsynchronized, sends a reset (RST) because it has detected a half-open connection. - TCP B aborts at line 5. - TCP A willcontinue to try to Established the connection; 我就不瞎翻译了,意思和我在前面用中文说的解释差不多。 ## 源码分析 处于 Established 状态的服务端如果收到了客户端的 SYN 报文时,内核会调用这些函数: ```csharp tcp_v4_rcv -> tcp_v4_do_rcv -> tcp_rcv_Establisheded -> tcp_validate_incoming -> tcp_send_ack ``` 我们只关注 tcp_validate_incoming 函数是怎么处理 SYN 报文的,精简后的代码如下: ![](https://img-blog.csdnimg.cn/780bc02c8fa940c0a320a5916b216c21.png) 从上面的代码实现可以看到,处于 Established 状态的服务端,在收到报文后,首先会判断序列号是否在窗口内,如果不在,则看看 RST 标记有没有被设置,如果有就会丢掉。然后如果没有 RST 标志,就会判断是否有 SYN 标记,如果有 SYN 标记就会跳转到 syn_challenge 标签,然后执行 tcp_send_challenge_ack 函数。 tcp_send_challenge_ack 函数里就会调用 tcp_send_ack 函数来回复一个携带了正确序列号和确认号的 ACK 报文。 ## 如何关闭一个 TCP 连接? 这里问题大家这么一个问题,如何关闭一个 TCP 连接? 可能大家第一反应是「杀掉进程」不就行了吗? 是的,这个是最粗暴的方式,杀掉客户端进程和服务端进程影响的范围会有所不同: - 在客户端杀掉进程的话,就会发送 FIN 报文,来断开这个客户端进程与服务端建立的所有 TCP 连接,这种方式影响范围只有这个客户端进程所建立的连接,而其他客户端或进程不会受影响。 - 而在服务端杀掉进程影响就大了,此时所有的 TCP 连接都会被关闭,服务端无法继续提供访问服务。 所以,关闭进程的方式并不可取,最好的方式要精细到关闭某一条 TCP 连接。 有的小伙伴可能会说,伪造一个四元组相同的 RST 报文不就行了? 这个思路很好,但是不要忘了还有个序列号的问题,你伪造的 RST 报文的序列号一定能被对方接受吗? 如果 RST 报文的序列号不是对方期望收到的序列号,这个 RST 报文会被对方丢弃的,就达不到关闭的连接的效果。 举个例子,下面这个场景,客户端发送了一个长度为 100 的 TCP 数据报文,服务端收到后响应了 ACK 报文,表示收到了这个 TCP 数据报文。**服务端响应的这个 ACK 报文中的确认号(ack = x + 100)就是表明服务端下一次期望收到的序列号是 x + 100**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/rst合法.png) 所以,**要伪造一个能关闭 TCP 连接的 RST 报文,必须同时满足「四元组相同」和「序列号是对方期望的」这两个条件。** 直接伪造符合预期的序列号是比较困难,因为如果一个正在传输数据的 TCP 连接,序列号都是时刻都在变化,因此很难刚好伪造一个正确序列号的 RST 报文。 ### killcx 的工具 办法还是有的,**我们可以伪造一个四元组相同的 SYN 报文,来拿到“合法”的序列号!** 正如我们最开始学到的,如果处于 Established 状态的服务端,收到四元组相同的 SYN 报文后,**会回复一个 Challenge ACK,这个 ACK 报文里的「确认号」,正好是服务端下一次想要接收的序列号,说白了,就是可以通过这一步拿到服务端下一次预期接收的序列号。** **然后用这个确认号作为 RST 报文的序列号,发送给服务端,此时服务端会认为这个 RST 报文里的序列号是合法的,于是就会释放连接!** 在 Linux 上有个叫 killcx 的工具,就是基于上面这样的方式实现的,它会主动发送 SYN 包获取 SEQ/ACK 号,然后利用 SEQ/ACK 号伪造两个 RST 报文分别发给客户端和服务端,这样双方的 TCP 连接都会被释放,这种方式活跃和非活跃的 TCP 连接都可以杀掉。 killcx 的工具使用方式也很简单,如果在服务端执行 killcx 工具,只需指明客户端的 IP 和端口号,如果在客户端执行 killcx 工具,则就指明服务端的 IP 和端口号。 ```csharp ./killcx :<端口号> ``` killcx 工具的工作原理,如下图,下图是在客户端执行 killcx 工具。 ![](https://img-blog.csdnimg.cn/95592346a9a747819cd27741a660213c.png) 它伪造客户端发送 SYN 报文,服务端收到后就会回复一个携带了正确「序列号和确认号」的 ACK 报文(Challenge ACK),然后就可以利用这个 ACK 报文里面的信息,伪造两个 RST 报文: - 用 Challenge ACK 里的确认号伪造 RST 报文发送给服务端,服务端收到 RST 报文后就会释放连接。 - 用 Challenge ACK 里的序列号伪造 RST 报文发送给客户端,客户端收到 RST 也会释放连接。 正是通过这样的方式,成功将一个 TCP 连接关闭了! 这里给大家贴一个使用 killcx 工具关闭连接的抓包图,大家多看看序列号和确认号的变化。 ![](https://img-blog.csdnimg.cn/71cbefee5ab741018386b6a37f492614.png?) 所以,以后抓包中,如果莫名奇妙出现一个 SYN 包,有可能对方接下来想要对你发起的 RST 攻击,直接将你的 TCP 连接断开! 怎么样,很巧妙吧! ### tcpkill 的工具 除了 killcx 工具能关闭 TCP 连接,还有 tcpkill 工具也可以做到。 这两个工具都是通过伪造 RST 报文来关闭指定的 TCP 连接,但是它们拿到正确的序列号的实现方式是不同的。 - tcpkill 工具是在双方进行 TCP 通信时,拿到对方下一次期望收到的序列号,然后将序列号填充到伪造的 RST 报文,并将其发送给对方,达到关闭 TCP 连接的效果。 - killcx 工具是主动发送一个 SYN 报文,对方收到后会回复一个携带了正确序列号和确认号的 ACK 报文,这个 ACK 被称之为 Challenge ACK,这时就可以拿到对方下一次期望收到的序列号,然后将序列号填充到伪造的 RST 报文,并将其发送给对方,达到关闭 TCP 连接的效果。 可以看到,这两个工具在获取对方下一次期望收到的序列号的方式是不同的。 tcpkill 工具属于被动获取,就是在双方进行 TCP 通信的时候,才能获取到正确的序列号,很显然**这种方式无法关闭非活跃的 TCP 连接**,只能用于关闭活跃的 TCP 连接。因为如果这条 TCP 连接一直没有任何数据传输,则就永远获取不到正确的序列号。 killcx 工具则是属于主动获取,它是主动发送一个 SYN 报文,通过对方回复的 Challenge ACK 来获取正确的序列号,所以这种方式**无论 TCP 连接是否活跃,都可以关闭**。 接下来,我就用这 tcpkill 工具来做个实验。 在这里,我用 nc 工具来模拟一个 TCP 服务端,监听 8888 端口。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/tcpkill/tcpkill1.png) 接着,在客户端机子上,用 nc 工具模拟一个 TCP 客户端,连接我们刚才启动的服务端,并且指定了客户端的端口为 11111。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/tcpkill/tcpkill2.png) 这时候,服务端就可以看到这条 TCP 连接了。 ![图片](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/tcpkill/tcpkill3.png) 注意,我这台服务端的公网 IP 地址是 121.43.173.240,私网 IP 地址是 172.19.11.21,在服务端通过 netstat 命令查看 TCP 连接的时候,则会将服务端的地址显示成私网 IP 地址。至此,我们前期工作就做好了。 接下来,我们在服务端执行 tcpkill 工具,来关闭这条 TCP 连接,看看会发生什么? 在这里,我指定了要关闭的客户端 IP 为 114.132.166.90 和端口为 11111 的 TCP 连接。 ![图片](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/tcpkill/tcpkill4.png) 可以看到,tcpkill 工具阻塞中,没有任何输出,而且此时的 TCP 连接还是存在的,并没有被干掉。 ![图片](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/tcpkill/tcpkill5.png) 为什么 TCP 连接没用被干掉? 因为在执行 tcpkill 工具后,这条 TCP 连接并没有传输任何数据,而 tcpkill 工具是需要拦截双方的 TCP 通信,才能获取到正确的序列号,从而才能伪装出正确的序列号的 RST 报文。 所以,从这里也说明了,**tcpkill 工具不适合关闭非活跃的 TCP 连接**。 接下来,我们尝试在客户端发送一个数据。 ![图片](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/tcpkill/tcpkill8.png) 可以看到,在发送了「hi」数据后,客户端就断开了,并且错误提示连接被对方关闭了。 此时,服务端已经查看不到刚才那条 TCP 连接了。 ![图片](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/tcpkill/tcpkill7.png) 然后,我们在服务端看看 tcpkill 工具输出的信息。 ![图片](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/tcpkill/tcpkill8.png) 可以看到, **tcpkill 工具给服务端和客户端都发送了伪造的 RST 报文,从而达到关闭一条 TCP 连接的效果**。 到这里我们知道了,运行 tcpkill 工具后,只有目标连接有新 TCP 包发送/接收的时候,才能关闭一条 TCP 连接。因此,**tcpkill 只适合关闭活跃的 TCP 连接,不适合用来关闭非活跃的 TCP 连接**。 上面的实验过程,我也抓了数据包,流程如下: ![图片](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/tcpkill/tcpkill9.png) 最后一个 RST 报文就是 tcpkill 工具伪造的 RST 报文。 ## 总结 要伪造一个能关闭 TCP 连接的 RST 报文,必须同时满足「四元组相同」和「序列号是对方期望的」这两个条件。 今天给大家介绍了两种关闭 TCP 连接的工具:tcpkill 和 killcx 工具。 这两种工具都是通过伪造 RST 报文来关闭 TCP 连接的,但是它们获取「对方下一次期望收到的序列号的方式是不同的,也正因此,造就了这两个工具的应用场景有区别。 - tcpkill 工具只能用来关闭活跃的 TCP 连接,无法关闭非活跃的 TCP 连接,因为 tcpkill 工具是等双方进行 TCP 通信后,才去获取正确的序列号,如果这条 TCP 连接一直没有任何数据传输,则就永远获取不到正确的序列号。 - killcx 工具可以用来关闭活跃和非活跃的 TCP 连接,因为 killcx 工具是主动发送 SYN 报文,这时对方就会回复 Challenge ACK,然后 killcx 工具就能从这个 ACK 获取到正确的序列号。 完! --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/3_tcp/isn_deff.md ================================================ # 4.7 为什么 TCP 每次建立连接时,初始化序列号都要不一样呢? 大家好,我是小林。 **为什么 TCP 每次建立连接时,初始化序列号都要不一样呢?** 接下来,我一步一步给大家讲明白,我觉得应该有不少人会有类似的问题,所以今天再肝一篇! > 为什么 TCP 每次建立连接时,初始化序列号都要不一样呢? 主要原因是为了防止历史报文被下一个相同四元组的连接接收。 > TCP 四次挥手中的 TIME_WAIT 状态不是会持续 2 MSL 时长,历史报文不是早就在网络中消失了吗? 是的,如果能正常四次挥手,由于 TIME_WAIT 状态会持续 2 MSL 时长,历史报文会在下一个连接之前就会自然消失。 但是来了,我们并不能保证每次连接都能通过四次挥手来正常关闭连接。 假设每次建立连接,客户端和服务端的初始化序列号都是从 0 开始: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/tcp/isn相同.png) 过程如下: - 客户端和服务端建立一个 TCP 连接,在客户端发送数据包被网络阻塞了,然后超时重传了这个数据包,而此时服务端设备断电重启了,之前与客户端建立的连接就消失了,于是在收到客户端的数据包的时候就会发送 RST 报文。 - 紧接着,客户端又与服务端建立了与上一个连接相同四元组的连接; - 在新连接建立完成后,上一个连接中被网络阻塞的数据包正好抵达了服务端,刚好该数据包的序列号正好是在服务端的接收窗口内,所以该数据包会被服务端正常接收,就会造成数据错乱。 可以看到,如果每次建立连接,客户端和服务端的初始化序列号都是一样的话,很容易出现历史报文被下一个相同四元组的连接接收的问题。 > 客户端和服务端的初始化序列号不一样不是也会发生这样的事情吗? 是的,即使客户端和服务端的初始化序列号不一样,也会存在收到历史报文的可能。 但是我们要清楚一点,历史报文能否被对方接收,还要看该历史报文的序列号是否正好在对方接收窗口内,如果不在就会丢弃,如果在才会接收。 如果每次建立连接客户端和服务端的初始化序列号都「不一样」,就有大概率因为历史报文的序列号「不在」对方接收窗口,从而很大程度上避免了历史报文,比如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/tcp/isn不相同.png) 相反,如果每次建立连接客户端和服务端的初始化序列号都「一样」,就有大概率遇到历史报文的序列号刚「好在」对方的接收窗口内,从而导致历史报文被新连接成功接收。 所以,每次初始化序列号不一样能够很大程度上避免历史报文被下一个相同四元组的连接接收,注意是很大程度上,并不是完全避免了。 > 那客户端和服务端的初始化序列号都是随机的,那还是有可能随机成一样的呀? RFC793 提到初始化序列号 ISN 随机生成算法:`ISN = M + F(localhost, localport, remotehost, remoteport)`。 - M 是一个计时器,这个计时器每隔 4 微秒加 1。 - F 是一个 Hash 算法,根据源 IP、目的 IP、源端口、目的端口生成一个随机数值,要保证 Hash 算法不能被外部轻易推算得出。 可以看到,随机数是会基于时钟计时器递增的,基本不可能会随机成一样的初始化序列号。 > 懂了,客户端和服务端初始化序列号都是随机生成的话,就能避免连接接收历史报文了。 是的,但是也不是完全避免了。 为了能更好的理解这个原因,我们先来了解序列号(SEQ)和初始序列号(ISN)。 - **序列号**,是 TCP 一个头部字段,标识了 TCP 发送端到 TCP 接收端的数据流的一个字节,因为 TCP 是面向字节流的可靠协议,为了保证消息的顺序性和可靠性,TCP 为每个传输方向上的每个字节都赋予了一个编号,以便于传输成功后确认、丢失后重传以及在接收端保证不会乱序。**序列号是一个 32 位的无符号数,因此在到达 4G 之后再循环回到 0**。 - **初始序列号**,在 TCP 建立连接的时候,客户端和服务端都会各自生成一个初始序列号,它是基于时钟生成的一个随机数,来保证每个连接都拥有不同的初始序列号。**初始化序列号可被视为一个 32 位的计数器,该计数器的数值每 4 微秒加 1,循环一次需要 4.55 小时**。 给大家抓了一个包,下图中的 Seq 就是序列号,其中红色框住的分别是客户端和服务端各自生成的初始序列号。 ![](https://img-blog.csdnimg.cn/img_convert/ed84bb4aa742a33f50d8035da2867ca2.png) 通过前面我们知道,**序列号和初始化序列号并不是无限递增的,会发生回绕为初始值的情况,这意味着无法根据序列号来判断新老数据**。 不要以为序列号的上限值是 4 GB,就以为很大,很难发生回绕。在一个速度足够快的网络中传输大量数据时,序列号的回绕时间就会变短。如果序列号回绕的时间极短,我们就会再次面临之前延迟的报文抵达后序列号依然有效的问题。 为了解决这个问题,就需要有 TCP 时间戳。`tcp_timestamps` 参数是默认开启的,开启了 `tcp_timestamps` 参数,TCP 头部就会使用时间戳选项,它有两个好处,**一个是便于精确计算 RTT,另一个是能防止序列号回绕(PAWS)**。 试看下面的示例,假设 TCP 的发送窗口是 1 GB,并且使用了时间戳选项,发送方会为每个 TCP 报文分配时间戳数值,我们假设每个报文时间加 1,然后使用这个连接传输一个 6 GB 大小的数据流。 ![图片](https://img-blog.csdnimg.cn/img_convert/1d497c38621ebc44ee3d8763fd03da67.png) 32 位的序列号在时刻 D 和 E 之间回绕。假设在时刻 B 有一个报文丢失并被重传,又假设这个报文段在网络上绕了远路并在时刻 F 重新出现。如果 TCP 无法识别这个绕回的报文,那么数据完整性就会遭到破坏。 使用时间戳选项能够有效的防止上述问题,如果丢失的报文会在时刻 F 重新出现,由于它的时间戳为 2,小于最近的有效时间戳(5 或 6),因此防回绕序列号算法(PAWS)会将其丢弃。 防回绕序列号算法要求连接双方维护最近一次收到的数据包的时间戳(Recent TSval),每收到一个新数据包都会读取数据包中的时间戳值跟 Recent TSval 值做比较,**如果发现收到的数据包中时间戳不是递增的,则表示该数据包是过期的,就会直接丢弃这个数据包**。 > 懂了,客户端和服务端的初始化序列号都是随机生成,能很大程度上避免历史报文被下一个相同四元组的连接接收,然后又引入时间戳的机制,从而完全避免了历史报文被接收的问题。 嗯嗯,没错。 > 如果时间戳也回绕了怎么办? 时间戳的大小是 32 bit,所以理论上也是有回绕的可能性的。 时间戳回绕的速度只与对端主机时钟频率有关。 Linux 以本地时钟计数(jiffies)作为时间戳的值,不同的增长时间会有不同的问题: - 如果时钟计数加 1 需要 1 ms,则需要约 24.8 天才能回绕一半,只要报文的生存时间小于这个值的话判断新旧数据就不会出错。 - 如果时钟计数提高到 1 us 加 1,则回绕需要约 71.58 分钟才能回绕,这时问题也不大,因为网络中旧报文几乎不可能生存超过 70 分钟,只是如果 70 分钟没有报文收发则会有一个包越过 PAWS(这种情况会比较多见,相比之下 24 天没有数据传输的 TCP 连接少之又少),但除非这个包碰巧是序列号回绕的旧数据包而被放入接收队列(太巧了吧),否则也不会有问题; - 如果时钟计数提高到 0.1 us 加 1 回绕需要 7 分钟多一点,这时就可能会有问题了,连接如果 7 分钟没有数据收发就会有一个报文越过 PAWS,对于 TCP 连接而言这么短的时间内没有数据交互太常见了吧!这样的话会频繁有包越过 PAWS 检查,从而使得旧包混入数据中的概率大大增加; Linux 在 PAWS 检查做了一个特殊处理,如果一个 TCP 连接连续 24 天不收发数据则在接收第一个包时基于时间戳的 PAWS 会失效,也就是可以 PAWS 函数会放过这个特殊的情况,认为是合法的,可以接收该数据包。 ```c // tcp_paws_check 函数如果返回 true 则 PAWS 通过: static inline bool tcp_paws_check(const struct tcp_options_received *rx_opt, int paws_win) { ...... // 从上次收到包到现在经历的时间多于 24 天,返回 true if (unlikely(get_seconds() >= rx_opt->ts_recent_stamp + TCP_PAWS_24DAYS)) return true; ..... return false; } ``` 要解决时间戳回绕的问题,可以考虑以下解决方案: 1)增加时间戳的大小,由 32 bit 扩大到 64 bit 这样虽然可以在能够预见的未来解决时间戳回绕的问题,但会导致新旧协议兼容性问题,像现在的 IPv4 与 IPv6 一样 2)将一个与时钟频率无关的值作为时间戳,时钟频率可以增加但时间戳的增速不变 随着时钟频率的提高,TCP 在相同时间内能够收发的包也会越来越多。如果时间戳的增速不变,则会有越来越多的报文使用相同的时间戳。这种趋势到达一定程度则时间戳就会失去意义,除非在可预见的未来这种情况不会发生。 3)暂时没想到 --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/3_tcp/out_of_order_fin.md ================================================ # 4.10 四次挥手中收到乱序的 FIN 包会如何处理? 大家好,我是小林。 收到个读者的问题,他在面试鹅厂的时候,被搞懵了,因为面试官问了他这么一个网络问题: ![](https://img-blog.csdnimg.cn/39f790ee7a45473587c8fe3e08e01ba4.jpg?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5bCP5p6XY29kaW5n,size_17,color_FFFFFF,t_70,g_se,x_16) 不得不说,鹅厂真的很喜欢问网络问题,而且爱问异常情况下的网络问题,之前也有篇另外一个读者面试鹅厂的网络问题:「[被鹅厂面怕了!](https://blog.csdn.net/qq_34827674/article/details/117922761)」。 不过这道鹅厂的网络题可能是提问的读者表述有问题,**因为如果 FIN 报文比数据包先抵达客户端,此时 FIN 报文其实是一个乱序的报文,此时客户端的 TCP 连接并不会从 FIN_WAIT_2 状态转换到 TIME_WAIT 状态**。 ![](https://img-blog.csdnimg.cn/ccabc2f21b014c6c9118cd29ae11c18c.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5bCP5p6XY29kaW5n,size_20,color_FFFFFF,t_70,g_se,x_16) 因此,我们要关注到点是看「**在 FIN_WAIT_2 状态下,是如何处理收到的乱序到 FIN 报文,然后 TCP 连接又是什么时候才进入到 TIME_WAIT 状态?**」。 我这里先直接说结论: **在 FIN_WAIT_2 状态时,如果收到乱序的 FIN 报文,那么就被会加入到「乱序队列」,并不会进入到 TIME_WAIT 状态。** **等再次收到前面被网络延迟的数据包时,会判断乱序队列有没有数据,然后会检测乱序队列中是否有可用的数据,如果能在乱序队列中找到与当前报文的序列号保持的顺序的报文,就会看该报文是否有 FIN 标志,如果发现有 FIN 标志,这时才会进入 TIME_WAIT 状态。** 我也画了一张图,大家可以结合着图来理解。 ![](https://img-blog.csdnimg.cn/4effcf2a9e7e4adeb892da98ee21694b.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5bCP5p6XY29kaW5n,size_20,color_FFFFFF,t_70,g_se,x_16) ## TCP 源码分析 接下来,我带大家看看源码,听到要源码分析,可能有的同学就怂了。 其实要分析我们今天这个问题,只要懂 if else 就行了,我也会用中文来表述代码的逻辑,所以单纯看我的文字也是可以的。 这次我们重点分析的是,在 FIN_WAIT_2 状态下,收到 FIN 报文是如何处理的。 在 Linux 内核里,当 IP 层处理完消息后,会通过回调 tcp_v4_rcv 函数将消息转给 TCP 层,所以这个函数就是 TCP 层收到消息的入口。 ![](https://img-blog.csdnimg.cn/ad39a3204f914df89aa6c6138cfc31aa.jpg?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5bCP5p6XY29kaW5n,size_20,color_FFFFFF,t_70,g_se,x_16) 处于 FIN_WAIT_2 状态下的客户端,在收到服务端的报文后,最终会调用 tcp_v4_do_rcv 函数。 ![](https://img-blog.csdnimg.cn/c5ca5b3fea0e4ad6baa2ab370358f03e.jpg?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5bCP5p6XY29kaW5n,size_20,color_FFFFFF,t_70,g_se,x_16) 接下来,tcp_v4_do_rcv 方法会调用 tcp_rcv_state_process,在这里会根据 TCP 状态做对应的处理,这里我们只关注 FIN_WAIT_2 状态。 ![](https://img-blog.csdnimg.cn/f76b7e2167544fec859700f55138e95f.jpg?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5bCP5p6XY29kaW5n,size_20,color_FFFFFF,t_70,g_se,x_16) 在上面这个代码里,可以看到如果 shutdown 关闭了读方向,那么在收到对方发来的数据包,则会回复 RST 报文。 而我们这次的题目里,shutdown 只关闭了写方向,所以会继续往下调用 tcp_data_queue 函数(因为 case TCP_FIN_WAIT2 代码块里并没有 break 语句,所以会走到该函数)。 ![](https://img-blog.csdnimg.cn/4ff161a34408447fa38b120b014b29f4.jpg?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5bCP5p6XY29kaW5n,size_20,color_FFFFFF,t_70,g_se,x_16) 在上面的 tcp_data_queue 函数里,如果收到的报文的序列号是我们预期的,也就是有序的话: - 会判断该报文有没有 FIN 标志,如果有的话就会调用 tcp_fin 函数,这个函数负责将 FIN_WAIT_2 状态转换为 TIME_WAIT。 - 接着还会看乱序队列有没有数据,如果有的话会调用 tcp_ofo_queue 函数,这个函数负责检查乱序队列中是否有数据包可用,即能不能在乱序队列找到与当前数据包保持序列号连续的数据包。 而当收到的报文的序列号不是我们预期的,也就是乱序的话,则调用 tcp_data_queue_ofo 函数,将报文加入到乱序队列,这个队列的数据结构是红黑树。 我们的题目里,客户端收到的 FIN 报文实际上是一个乱序的报文,因此此时并不会调用 tcp_fin 函数进行状态转换,而是将报文通过 tcp_data_queue_ofo 函数加入到乱序队列。 然后当客户端收到被网络延迟的数据包后,此时因为该数据包的序列号是期望的,然后又因为上一次收到的乱序 FIN 报文被加入到了乱序队列,表明乱序队列是有数据的,于是就会调用 tcp_ofo_queue 函数。 我们来看看 tcp_ofo_queue 函数。 ![](https://img-blog.csdnimg.cn/dd51b407245d45549eeae64d24634133.jpg?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5bCP5p6XY29kaW5n,size_20,color_FFFFFF,t_70,g_se,x_16) 在上面的 tcp_ofo_queue 函数里,在乱序队列中找到能与当前报文的序列号保持的顺序的报文后,会看该报文是否有 FIN 标志,如果有的话,就会调用 tcp_fin() 函数。 最后,我们来看看 tcp_fin 函数的处理。 ![](https://img-blog.csdnimg.cn/67b33007fcd04d2fa98e79d19823fc95.jpg?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5bCP5p6XY29kaW5n,size_20,color_FFFFFF,t_70,g_se,x_16) 可以看到,如果当前的 TCP 状态为 TCP_FIN_WAIT2,就会发送第四次挥手 ack,然后调用 tcp_time_wait 函数,这个函数里会将 TCP 状态变更为 TIME_WAIT,并启动 TIME_WAIT 的定时器。 ## 怎么看 TCP 源码? 之前有不少同学问我,我是怎么看 TCP 源码的? 其实我看 TCP 源码,并不是直接打开 Linux 源码直接看,因为 Linux 源码实在太庞大了,如果我不知道 TCP 入口函数在哪,那简直就是大海捞针。 所以,在看 TCP 源码,我们可以去网上搜索下别人的源码分析,网上已经有很多前辈帮我们分析了 TCP 源码了,而且各个函数的调用链路,他们都有写出来了。 比如,你想了解 TCP 三次握手/四次挥手的源码实现,你就可以以「TCP 三次握手/四次挥手的源码分析」这样关键字来搜索,大部分文章的注释写的还是很清晰,我最开始就按这种方式来学习 TCP 源码的。 网上的文章一般只会将重点的部分,很多代码细节没有贴出来,如果你想完整的看到函数的所有代码,那就得看内核代码了。 这里推荐个看 Linux 内核代码的在线网站: https://elixir.bootlin.com/linux/latest/source ![](https://img-blog.csdnimg.cn/c56e69f998e747208abb82897edc2629.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA5bCP5p6XY29kaW5n,size_20,color_FFFFFF,t_70,g_se,x_16) 我觉得还是挺好用的,左侧各个版本的代码都有,右上角也可以搜索函数。 所以,我看 TCP 源码的经验就是,先在网上找找前辈写的 TCP 源码分析,然后知道整个函数的调用链路后,如果想具体了解某个函数的具体实现,可以在我说的那个看 Linux 内核代码的在线网站上搜索该函数,就可以看到完整的函数的实现。如果中途遇到看不懂的代码,也可以将这个代码复制到百度或者谷歌搜索,一般也能找到别人分析的过程。 学会了看 TCP 源码其实有助于我们分析一些异常问题,就比如今天这道网络题目,在网上其实是搜索不出答案的,而且我们也很难用实验的方式来模拟。 所以要想知道答案,只能去看源码。 --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/3_tcp/port.md ================================================ # 4.18 TCP 和 UDP 可以使用同一个端口吗? 大家好,我是小林。 之前有读者在字节面试的时候,被问到:**TCP 和 UDP 可以同时监听相同的端口吗?** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/port/提问.png) 关于端口的知识点,还是挺多可以讲的,比如还可以牵扯到这几个问题: - 多个 TCP 服务进程可以同时绑定同一个端口吗? - 重启 TCP 服务进程时,为什么会出现“Address in use”的报错信息?又该怎么避免? - 客户端的端口可以重复使用吗? - 客户端 TCP 连接 TIME_WAIT 状态过多,会导致端口资源耗尽而无法建立新的连接吗? 所以,这次就跟大家盘一盘这些问题。 ## TCP 和 UDP 可以同时绑定相同的端口吗? 其实我感觉这个问题「TCP 和 UDP 可以同时监听相同的端口吗?」表述有问题,这个问题应该表述成「**TCP 和 UDP 可以同时绑定相同的端口吗?**」 因为「监听」这个动作是在 TCP 服务端网络编程中才具有的,而 UDP 服务端网络编程中是没有「监听」这个动作的。 TCP 和 UDP 服务端网络相似的一个地方,就是会调用 bind 绑定端口。 给大家贴一下 TCP 和 UDP 网络编程的区别就知道了。 TCP 网络编程如下,服务端执行 listen() 系统调用就是监听端口的动作。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/port/tcp网络编程.png) UDP 网络编程如下,服务端是没有监听这个动作的,只有执行 bind() 系统调用来绑定端口的动作。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/port/udp网络编程.png) > TCP 和 UDP 可以同时绑定相同的端口吗? 答案:**可以的**。 在数据链路层中,通过 MAC 地址来寻找局域网中的主机。在网际层中,通过 IP 地址来寻找网络中互连的主机或路由器。在传输层中,需要通过端口进行寻址,来识别同一计算机中同时通信的不同应用程序。 所以,传输层的「端口号」的作用,是为了区分同一个主机上不同应用程序的数据包。 传输层有两个传输协议分别是 TCP 和 UDP,在内核中是两个完全独立的软件模块。 当主机收到数据包后,可以在 IP 包头的「协议号」字段知道该数据包是 TCP/UDP,所以可以根据这个信息确定送给哪个模块(TCP/UDP)处理,送给 TCP/UDP 模块的报文根据「端口号」确定送给哪个应用程序处理。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/port/tcp和udp模块.jpeg) 因此,TCP/UDP 各自的端口号也相互独立,如 TCP 有一个 80 号端口,UDP 也可以有一个 80 号端口,二者并不冲突。 > 验证结果 我简单写了 TCP 和 UDP 服务端的程序,它们都绑定同一个端口号 8888。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/port/tcp和udp服务端程序.png) 运行这两个程序后,通过 netstat 命令可以看到,TCP 和 UDP 是可以同时绑定同一个端口号的。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/port/1.png) ## 多个 TCP 服务进程可以绑定同一个端口吗? 还是以前面的 TCP 服务端程序作为例子,启动两个同时绑定同一个端口的 TCP 服务进程。 运行第一个 TCP 服务进程之后,netstat 命令可以查看,8888 端口已经被一个 TCP 服务进程绑定并监听了,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/port/2.png) 接着,运行第二个 TCP 服务进程的时候,就报错了“Address already in use”,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/port/3.png) 我上面的测试案例是两个 TCP 服务进程同时绑定地址和端口是:0.0.0.0 地址和 8888 端口,所以才出现的错误。 如果两个 TCP 服务进程绑定的 IP 地址不同,而端口相同的话,也是可以绑定成功的,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/port/4.png) 所以,默认情况下,针对「多个 TCP 服务进程可以绑定同一个端口吗?」这个问题的答案是:**如果两个 TCP 服务进程同时绑定的 IP 地址和端口都相同,那么执行 bind() 时候就会出错,错误是“Address already in use”**。 注意,如果 TCP 服务进程 A 绑定的地址是 0.0.0.0 和端口 8888,而如果 TCP 服务进程 B 绑定的地址是 192.168.1.100 地址(或者其他地址)和端口 8888,那么执行 bind() 时候也会出错。 这是因为 0.0.0.0 地址比较特殊,代表任意地址,意味着绑定了 0.0.0.0 地址,相当于把主机上的所有 IP 地址都绑定了。 ::: tip 如果想多个进程绑定相同的 IP 地址和端口,也是有办法的,就是对 socket 设置 SO_REUSEPORT 属性(内核 3.9 版本提供的新特性),本文不对 SO_REUSEPORT 做具体介绍,感兴趣的同学自行去学习。 ::: > 重启 TCP 服务进程时,为什么会有“Address in use”的报错信息? TCP 服务进程需要绑定一个 IP 地址和一个端口,然后就监听在这个地址和端口上,等待客户端连接的到来。 然后在实践中,我们可能会经常碰到一个问题,当 TCP 服务进程重启之后,总是碰到“Address in use”的报错信息,TCP 服务进程不能很快地重启,而是要过一会才能重启成功。 这是为什么呢? 当我们重启 TCP 服务进程的时候,意味着通过服务器端发起了关闭连接操作,于是就会经过四次挥手,而对于主动关闭方,会在 TIME_WAIT 这个状态里停留一段时间,这个时间大约为 2MSL。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/port/四次挥手.png) **当 TCP 服务进程重启时,服务端会出现 TIME_WAIT 状态的连接,TIME_WAIT 状态的连接使用的 IP+PORT 仍然被认为是一个有效的 IP+PORT 组合,相同机器上不能够在该 IP+PORT 组合上进行绑定,那么执行 bind() 函数的时候,就会返回了 Address already in use 的错误**。 而等 TIME_WAIT 状态的连接结束后,重启 TCP 服务进程就能成功。 > 重启 TCP 服务进程时,如何避免“Address in use”的报错信息? 我们可以在调用 bind 前,对 socket 设置 SO_REUSEADDR 属性,可以解决这个问题。 ```c int on = 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)); ``` 因为 SO_REUSEADDR 作用是:**如果当前启动进程绑定的 IP+PORT 与处于 TIME_WAIT 状态的连接占用的 IP+PORT 存在冲突,但是新启动的进程使用了 SO_REUSEADDR 选项,那么该进程就可以绑定成功**。 举个例子,服务端有个监听 0.0.0.0 地址和 8888 端口的 TCP 服务进程。‍ ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/port/5.png) 有个客户端(IP 地址:192.168.1.100)已经和服务端(IP 地址:172.19.11.200)建立了 TCP 连接,那么在 TCP 服务进程重启时,服务端会与客户端经历四次挥手,服务端的 TCP 连接会短暂处于 TIME_WAIT 状态: ```bash 客户端地址:端口 服务端地址:端口 TCP 连接状态 192.168.1.100:37272 172.19.11.200:8888 TIME_WAI ``` 如果 TCP 服务进程没有对 socket 设置 SO_REUSEADDR 属性,那么在重启时,由于存在一个和绑定 IP+PORT 一样的 TIME_WAIT 状态的连接,那么在执行 bind() 函数的时候,就会返回了 Address already in use 的错误。 如果 TCP 服务进程对 socket 设置 SO_REUSEADDR 属性了,那么在重启时,即使存在一个和绑定 IP+PORT 一样的 TIME_WAIT 状态的连接,依然可以正常绑定成功,因此可以正常重启成功。 因此,在所有 TCP 服务器程序中,调用 bind 之前最好对 socket 设置 SO_REUSEADDR 属性,这不会产生危害,相反,它会帮助我们在很快时间内重启服务端程序。‍ **前面我提到过这个问题**:如果 TCP 服务进程 A 绑定的地址是 0.0.0.0 和端口 8888,而如果 TCP 服务进程 B 绑定的地址是 192.168.1.100 地址(或者其他地址)和端口 8888,那么执行 bind() 时候也会出错。 这个问题也可以由 SO_REUSEADDR 解决,因为它的**另外一个作用**:绑定的 IP 地址 + 端口时,只要 IP 地址不是正好 (exactly) 相同,那么允许绑定。 比如,0.0.0.0:8888 和 192.168.1.100:8888,虽然逻辑意义上前者包含了后者,但是 0.0.0.0 泛指所有本地 IP,而 192.168.1.100 特指某一 IP,两者并不是完全相同,所以在对 socket 设置 SO_REUSEADDR 属性后,那么执行 bind() 时候就会绑定成功。 ## 客户端的端口可以重复使用吗? 客户端在执行 connect 函数的时候,会在内核里随机选择一个端口,然后向服务端发起 SYN 报文,然后与服务端进行三次握手。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/port/tcp编程.png) 所以,客户端的端口选择的发生在 connect 函数,内核在选择端口的时候,会从 `net.ipv4.ip_local_port_range` 这个内核参数指定的范围来选取一个端口作为客户端端口。 该参数的默认值是 32768 61000,意味着端口总可用的数量是 61000 - 32768 = 28232 个。 当客户端与服务端完成 TCP 连接建立后,我们可以通过 netstat 命令查看 TCP 连接。 ```bash $ netstat -napt 协议 源ip地址:端口 目的ip地址:端口 状态 tcp 192.168.110.182.64992 117.147.199.51.443 ESTABLISHED ``` > 那问题来了,上面客户端已经用了 64992 端口,那么还可以继续使用该端口发起连接吗? 这个问题,很多同学都会说不可以继续使用该端口了,如果按这个理解的话,默认情况下客户端可以选择的端口是 28232 个,那么意味着客户端只能最多建立 28232 个 TCP 连接,如果真是这样的话,那么这个客户端并发连接也太少了吧,所以这是错误理解。 正确的理解是,**TCP 连接是由四元组(源 IP 地址,源端口,目的 IP 地址,目的端口)唯一确认的,那么只要四元组中其中一个元素发生了变化,那么就表示不同的 TCP 连接的。所以如果客户端已使用端口 64992 与服务端 A 建立了连接,那么客户端要与服务端 B 建立连接,还是可以使用端口 64992 的,因为内核是通过四元组信息来定位一个 TCP 连接的,并不会因为客户端的端口号相同,而导致连接冲突的问题。** 比如下面这张图,有 2 个 TCP 连接,左边是客户端,右边是服务端,客户端使用了相同的端口 50004 与两个服务端建立了 TCP 连接。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/port/6.jpeg) 仔细看,上面这两条 TCP 连接的四元组信息中的「目的 IP 地址」是不同的,一个是 180.101.49.12,另外一个是 180.101.49.11。 > 多个客户端可以 bind 同一个端口吗? bind 函数虽然常用于服务端网络编程中,但是它也是用于客户端的。 前面我们知道,客户端是在调用 connect 函数的时候,由内核随机选取一个端口作为连接的端口。 而如果我们想自己指定连接的端口,就可以用 bind 函数来实现:客户端先通过 bind 函数绑定一个端口,然后调用 connect 函数就会跳过端口选择的过程了,转而使用 bind 时确定的端口。 针对这个问题:多个客户端可以 bind 同一个端口吗? 要看多个客户端绑定的 IP + PORT 是否都相同,如果都是相同的,那么在执行 bind() 时候就会出错,错误是“Address already in use”。 如果一个绑定在 192.168.1.100:6666,一个绑定在 192.168.1.200:6666,因为 IP 不相同,所以执行 bind() 的时候,能正常绑定。 所以,如果多个客户端同时绑定的 IP 地址和端口都是相同的,那么执行 bind() 时候就会出错,错误是“Address already in use”。 一般而言,客户端不建议使用 bind 函数,应该交由 connect 函数来选择端口会比较好,因为客户端的端口通常都没什么意义。 > 客户端 TCP 连接 TIME_WAIT 状态过多,会导致端口资源耗尽而无法建立新的连接吗? 针对这个问题要看,客户端是否都是与同一个服务器(目标地址和目标端口一样)建立连接。 如果客户端都是与同一个服务器(目标地址和目标端口一样)建立连接,那么如果客户端 TIME_WAIT 状态的连接过多,当端口资源被耗尽,就无法与这个服务器再建立连接了。 但是,**因为只要客户端连接的服务器不同,端口资源可以重复使用的**。 所以,如果客户端都是与不同的服务器建立连接,即使客户端端口资源只有几万个,客户端发起百万级连接也是没问题的(当然这个过程还会受限于其他资源,比如文件描述符、内存、CPU 等)。 > 如何解决客户端 TCP 连接 TIME_WAIT 过多,导致无法与同一个服务器建立连接的问题? 前面我们提到,如果客户端都是与同一个服务器(目标地址和目标端口一样)建立连接,那么如果客户端 TIME_WAIT 状态的连接过多,当端口资源被耗尽,就无法与这个服务器再建立连接了。 针对这个问题,也是有解决办法的,那就是打开 `net.ipv4.tcp_tw_reuse` 这个内核参数。 **因为开启了这个内核参数后,客户端调用 connect 函数时,如果选择到的端口,已经被相同四元组的连接占用的时候,就会判断该连接是否处于 TIME_WAIT 状态,如果该连接处于 TIME_WAIT 状态并且 TIME_WAIT 状态持续的时间超过了 1 秒,那么就会重用这个连接,然后就可以正常使用该端口了。** 举个例子,假设客户端已经与服务器建立了一个 TCP 连接,并且这个状态处于 TIME_WAIT 状态: ```bash 客户端地址:端口 服务端地址:端口 TCP 连接状态 192.168.1.100:2222 172.19.11.21:8888 TIME_WAIT ``` 然后客户端又与该服务器(172.19.11.21:8888)发起了连接,**在调用 connect 函数时,内核刚好选择了 2222 端口,接着发现已经被相同四元组的连接占用了:** - 如果**没有开启** net.ipv4.tcp_tw_reuse 内核参数,那么内核就会选择下一个端口,然后继续判断,直到找到一个没有被相同四元组的连接使用的端口,如果端口资源耗尽还是没找到,那么 connect 函数就会返回错误。 - 如果**开启**了 net.ipv4.tcp_tw_reuse 内核参数,就会判断该四元组的连接状态是否处于 TIME_WAIT 状态,**如果连接处于 TIME_WAIT 状态并且该状态持续的时间超过了 1 秒,那么就会重用该连接**,于是就可以使用 2222 端口了,这时 connect 就会返回成功。 再次提醒一次,开启了 net.ipv4.tcp_tw_reuse 内核参数,是客户端(连接发起方)在调用 connect() 函数时才起作用,所以在服务端开启这个参数是没有效果的。 > 客户端端口选择的流程总结 至此,我们已经把客户端在执行 connect 函数时,内核选择端口的情况大致说了一遍,为了让大家更明白客户端端口的选择过程,我画了一流程图。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/port/端口选择.jpg) ## **总结** > TCP 和 UDP 可以同时绑定相同的端口吗? 可以的。 TCP 和 UDP 传输协议,在内核中是由两个完全独立的软件模块实现的。 当主机收到数据包后,可以在 IP 包头的「协议号」字段知道该数据包是 TCP/UDP,所以可以根据这个信息确定送给哪个模块(TCP/UDP)处理,送给 TCP/UDP 模块的报文根据「端口号」确定送给哪个应用程序处理。 因此,TCP/UDP 各自的端口号也相互独立,互不影响。 > 多个 TCP 服务进程可以同时绑定同一个端口吗? 如果两个 TCP 服务进程同时绑定的 IP 地址和端口都相同,那么执行 bind() 时候就会出错,错误是“Address already in use”。 如果两个 TCP 服务进程绑定的端口都相同,而 IP 地址不同,那么执行 bind() 不会出错。 > 如何解决服务端重启时,报错“Address already in use”的问题? 当我们重启 TCP 服务进程的时候,意味着通过服务器端发起了关闭连接操作,于是就会经过四次挥手,而对于主动关闭方,会在 TIME_WAIT 这个状态里停留一段时间,这个时间大约为 2MSL。 当 TCP 服务进程重启时,服务端会出现 TIME_WAIT 状态的连接,TIME_WAIT 状态的连接使用的 IP+PORT 仍然被认为是一个有效的 IP+PORT 组合,相同机器上不能够在该 IP+PORT 组合上进行绑定,那么执行 bind() 函数的时候,就会返回了 Address already in use 的错误。 要解决这个问题,我们可以对 socket 设置 SO_REUSEADDR 属性。 这样即使存在一个和绑定 IP+PORT 一样的 TIME_WAIT 状态的连接,依然可以正常绑定成功,因此可以正常重启成功。 > 客户端的端口可以重复使用吗? 在客户端执行 connect 函数的时候,只要客户端连接的服务器不是同一个,内核允许端口重复使用。 TCP 连接是由四元组(源 IP 地址,源端口,目的 IP 地址,目的端口)唯一确认的,那么只要四元组中其中一个元素发生了变化,那么就表示不同的 TCP 连接的。 所以,如果客户端已使用端口 64992 与服务端 A 建立了连接,那么客户端要与服务端 B 建立连接,还是可以使用端口 64992 的,因为内核是通过四元组信息来定位一个 TCP 连接的,并不会因为客户端的端口号相同,而导致连接冲突的问题。 > 客户端 TCP 连接 TIME_WAIT 状态过多,会导致端口资源耗尽而无法建立新的连接吗? 要看客户端是否都是与同一个服务器(目标地址和目标端口一样)建立连接。 如果客户端都是与同一个服务器(目标地址和目标端口一样)建立连接,那么如果客户端 TIME_WAIT 状态的连接过多,当端口资源被耗尽,就无法与这个服务器再建立连接了。即使在这种状态下,还是可以与其他服务器建立连接的,只要客户端连接的服务器不是同一个,那么端口是重复使用的。 > 如何解决客户端 TCP 连接 TIME_WAIT 过多,导致无法与同一个服务器建立连接的问题? 打开 net.ipv4.tcp_tw_reuse 这个内核参数。 因为开启了这个内核参数后,客户端调用 connect 函数时,如果选择到的端口,已经被相同四元组的连接占用的时候,就会判断该连接是否处于 TIME_WAIT 状态。 如果该连接处于 TIME_WAIT 状态并且 TIME_WAIT 状态持续的时间超过了 1 秒,那么就会重用这个连接,然后就可以正常使用该端口了。 --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/3_tcp/quic.md ================================================ # 4.17 如何基于 UDP 协议实现可靠传输? 大家好,我是小林。 我记得之前在群里看到,有位读者字节一面的时候被问到:「**如何基于 UDP 协议实现可靠传输?**」 很多同学第一反应就会说把 TCP 可靠传输的特性(序列号、确认应答、超时重传、流量控制、拥塞控制)在应用层实现一遍。 实现的思路确实这样没错,但是有没有想过,**既然 TCP 天然支持可靠传输,为什么还需要基于 UDP 实现可靠传输呢?这不是重复造轮子吗?** 所以,我们要先弄清楚 TCP 协议有哪些痛点?而这些痛点是否可以在基于 UDP 协议实现的可靠传输协议中得到改进? 在之前这篇文章:[TCP 就没什么缺陷吗?](https://mp.weixin.qq.com/s/9kHoRk6QIYOFUR_PCmHY6g),我已经说了 TCP 协议四个方面的缺陷: - 升级 TCP 的工作很困难; - TCP 建立连接的延迟; - TCP 存在队头阻塞问题; - 网络迁移需要重新建立 TCP 连接; 现在市面上已经有基于 UDP 协议实现的可靠传输协议的成熟方案了,那就是 QUIC 协议,已经应用在了 HTTP/3。 这次,**聊聊 QUIC 是如何实现可靠传输的?又是如何解决上面 TCP 协议四个方面的缺陷**? ![](https://img-blog.csdnimg.cn/605d1026df934f20a5ee12f3c55aa6a7.png) ## QUIC 是如何实现可靠传输的? 要基于 UDP 实现的可靠传输协议,那么就要在应用层下功夫,也就是要设计好协议的头部字段。 拿 HTTP/3 举例子,在 UDP 报文头部与 HTTP 消息之间,共有 3 层头部: ![](https://static001.geekbang.org/resource/image/ab/7c/ab3283383013b707d1420b6b4cb8517c.png) 整体看的视角是这样的: ![](https://docs.citrix.com/en-us/citrix-adc/media/http3-over-quic-protocol-works.png) 接下来,分别对每一个 Header 做个介绍。 ### Packet Header Packet Header 首次建立连接时和日常传输数据时使用的 Header 是不同的。如下图(*注意我没有把 Header 所有字段都画出来,只是画出了重要的字段*): ![Packet Header](https://img-blog.csdnimg.cn/bcf3ccb6a15c4cdebe1cd0527fdd9a5e.png) Packet Header 细分这两种: - Long Packet Header 用于首次建立连接。 - Short Packet Header 用于日常传输数据。 QUIC 也是需要三次握手来建立连接的,主要目的是为了协商连接 ID。协商出连接 ID 后,后续传输时,双方只需要固定住连接 ID,从而实现连接迁移功能。所以,你可以看到日常传输数据的 Short Packet Header 不需要在传输 Source Connection ID 字段了,只需要传输 Destination Connection ID。 Short Packet Header 中的 `Packet Number` 是每个报文独一无二的编号,它是**严格递增**的,也就是说就算 Packet N 丢失了,重传的 Packet N 的 Packet Number 已经不是 N,而是一个比 N 大的值。 ![](https://img-blog.csdnimg.cn/635813465fbb449882da2e2bee39f24e.png) > 为什么要这么设计呢? 我们先来看看 TCP 的问题,TCP 在重传报文时的序列号和原始报文的序列号是一样的,也正是由于这个特性,引入了 TCP 重传的歧义问题。 ![TCP 重传的歧义问题](https://img-blog.csdnimg.cn/7e4e778413c1452bb6d58ec3d5452316.png) 比如上图,当 TCP 发生超时重传后,客户端发起重传,然后接收到了服务端确认 ACK。由于客户端原始报文和重传报文序列号都是一样的,那么服务端针对这两个报文回复的都是相同的 ACK。 这样的话,客户端就无法判断出是「原始报文的响应」还是「重传报文的响应」,这样在计算 RTT(往返时间)时应该选择从发送原始报文开始计算,还是重传原始报文开始计算呢? - 如果算成原始报文的响应,但实际上是重传报文的响应(上图左),会导致采样 RTT 变大; - 如果算成重传报文的响应,但实际上是原始报文的响应(上图右),又很容易导致采样 RTT 过小; RTO(超时时间)是基于 RTT 来计算的,那么如果 RTT 计算不精准,那么 RTO(超时时间)也会不精确,这样可能导致重传的概率事件增大。 QUIC 报文中的 Pakcet Number 是严格递增的,即使是重传报文,它的 Pakcet Number 也是递增的,这样就能更加精确计算出报文的 RTT。 ![](https://img-blog.csdnimg.cn/ca91985c9a94487a8a29db1249109717.png) 如果 ACK 的 Packet Number 是 N+M,就根据重传报文计算采样 RTT。如果 ACK 的 Pakcet Number 是 N,就根据原始报文的时间计算采样 RTT,没有歧义性的问题。 另外,还有一个好处,**QUIC 使用的 Packet Number 单调递增的设计,可以让数据包不再像 TCP 那样必须有序确认,QUIC 支持乱序确认,当数据包 Packet N 丢失后,只要有新的已接收数据包确认,当前窗口就会继续向右滑动**(后面讲流量控制的时候,会举例子)。 待发送端获知数据包 Packet N 丢失后,会将需要重传的数据包放到待发送队列,重新编号比如数据包 Packet N+M 后重新发送给接收端,对重传数据包的处理跟发送新的数据包类似,这样就不会因为丢包重传将当前窗口阻塞在原地,从而解决了队头阻塞问题。 所以,Packet Number 单调递增的两个好处: - 可以更加精确计算 RTT,没有 TCP 重传的歧义性问题; - 可以支持乱序确认,因为丢包重传将当前窗口阻塞在原地,而 TCP 必须是顺序确认的,丢包时会导致窗口不滑动; ### QUIC Frame Header 一个 Packet 报文中可以存放多个 QUIC Frame。 ![](https://img-blog.csdnimg.cn/6a94d41ef3d14cb6b7846e73da6c3104.png) 每一个 Frame 都有明确的类型,针对类型的不同,功能也不同,自然格式也不同。 我这里只举例 Stream 类型的 Frame 格式,Stream 可以认为就是一条 HTTP 请求,它长这样: ![](https://img-blog.csdnimg.cn/536298d2c54a43b699026bffe0f85010.png) - Stream ID 作用:多个并发传输的 HTTP 消息,通过不同的 Stream ID 加以区别,类似于 HTTP2 的 Stream ID; - Offset 作用:类似于 TCP 协议中的 Seq 序号,**保证数据的顺序性和可靠性**; - Length 作用:指明了 Frame 数据的长度。 在前面介绍 Packet Header 时,说到 Packet Number 是严格递增,即使重传报文的 Packet Number 也是递增的,既然重传数据包的 Packet N+M 与丢失数据包的 Packet N 编号并不一致,我们怎么确定这两个数据包的内容一样呢? 所以引入 Frame Header 这一层,**通过 Stream ID + Offset 字段信息实现数据的有序性**,通过比较两个数据包的 Stream ID 与 Stream Offset,如果都是一致,就说明这两个数据包的内容一致。 举个例子,下图中,数据包 Packet N 丢失了,后面重传该数据包的编号为 Packet N+2,**丢失的数据包和重传的数据包 Stream ID 与 Offset 都一致,说明这两个数据包的内容一致**。这些数据包传输到接收端后,接收端能根据 Stream ID 与 Offset 字段信息将 Stream x 和 Stream x+y 按照顺序组织起来,然后交给应用程序处理。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/quic/Packet丢失.jpeg) 总的来说,**QUIC 通过单向递增的 Packet Number,配合 Stream ID 与 Offset 字段信息,可以支持乱序确认而不影响数据包的正确组装**,摆脱了 TCP 必须按顺序确认应答 ACK 的限制,解决了 TCP 因某个数据包重传而阻塞后续所有待发送数据包的问题。 ## QUIC 是如何解决 TCP 队头阻塞问题的? ### 什么是 TCP 队头阻塞问题? TCP 队头阻塞的问题要从两个角度看,一个是**发送窗口的队头阻塞**,另外一个是**接收窗口的队头阻塞**。 *1、发送窗口的队头阻塞。* TCP 发送出去的数据,都是需要按序确认的,只有在数据都被按顺序确认完后,发送窗口才会往前滑动。 举个例子,比如下图的发送方把发送窗口内的数据全部都发出去了,可用窗口的大小就为 0 了,表明可用窗口耗尽,在没收到 ACK 确认之前是无法继续发送数据了。 ![可用窗口耗尽](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/17.jpg?) 接着,当发送方收到对第 `32~36` 字节的 ACK 确认应答后,则**滑动窗口往右边移动 5 个字节,因为有 5 个字节的数据被应答确认**,接下来第 `52~56` 字节又变成了可用窗口,那么后续也就可以发送 `52~56` 这 5 个字节的数据了。 ![32 ~ 36 字节已确认](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/18.jpg) **但是如果某个数据报文丢失或者其对应的 ACK 报文在网络中丢失,会导致发送方无法移动发送窗口,这时就无法再发送新的数据**,只能超时重传这个数据报文,直到收到这个重传报文的 ACK,发送窗口才会移动,继续后面的发送行为。 举个例子,比如下图,客户端是发送方,服务器是接收方。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/quic/ack丢失.jpeg) 客户端发送了第 5~9 字节的数据,但是第 5 字节的 ACK 确认报文在网络中丢失了,那么即使客户端收到第 6~9 字节的 ACK 确认报文,发送窗口也不会往前移动。 **此时的第 5 字节相当于“队头”,因为没有收到“队头”的 ACK 确认报文,导致发送窗口无法往前移动,此时发送方就无法继续发送后面的数据,相当于按下了发送行为的暂停键,这就是发送窗口的队头阻塞问题**。 *2、接收窗口的队头阻塞。* 接收方收到的数据范围必须在接收窗口范围内,如果收到超过接收窗口范围的数据,就会丢弃该数据,比如下图接收窗口的范围是 32 ~ 51 字节,如果收到第 52 字节以上数据都会被丢弃。 ![接收窗口](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/20.jpg) 接收窗口什么时候才能滑动?当接收窗口收到有序数据时,接收窗口才能往前滑动,然后那些已经接收并且被确认的「有序」数据就可以被应用层读取。 但是,**当接收窗口收到的数据不是有序的,比如收到第 33~40 字节的数据,由于第 32 字节数据没有收到,接收窗口无法向前滑动,那么即使先收到第 33~40 字节的数据,这些数据也无法被应用层读取的**。只有当发送方重传了第 32 字节数据并且被接收方收到后,接收窗口才会往前滑动,然后应用层才能从内核读取第 32~40 字节的数据。 好了,至此发送窗口和接收窗口的队头阻塞问题都说完了,这两个问题的原因都是因为 TCP 必须按序处理数据,也就是 TCP 层为了保证数据的有序性,只有在处理完有序的数据后,滑动窗口才能往前滑动,否则就停留。 - 停留「发送窗口」会使得发送方无法继续发送数据。 - 停留「接收窗口」会使得应用层无法读取新的数据。 其实也不能怪 TCP 协议,它本来设计目的就是为了保证数据的有序性。 ### HTTP/2 的队头阻塞 HTTP/2 通过抽象出 Stream 的概念,实现了 HTTP 并发传输,一个 Stream 就代表 HTTP/1.1 里的请求和响应。 ![HTTP/2](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/%E7%BD%91%E7%BB%9C/http2/stream2.png) 在 HTTP/2 连接上,不同 Stream 的帧是可以乱序发送的(因此可以并发不同的 Stream),因为每个帧的头部会携带 Stream ID 信息,所以接收端可以通过 Stream ID 有序组装成 HTTP 消息,而同一 Stream 内部的帧必须是严格有序的。 **但是 HTTP/2 多个 Stream 请求都是在一条 TCP 连接上传输,这意味着多个 Stream 共用同一个 TCP 滑动窗口,那么当发生数据丢失,滑动窗口是无法往前移动的,此时就会阻塞住所有的 HTTP 请求,这属于 TCP 层队头阻塞**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/quic/http2阻塞.jpeg) ### 没有队头阻塞的 QUIC QUIC 也借鉴 HTTP/2 里的 Stream 的概念,在一条 QUIC 连接上可以并发发送多个 HTTP 请求 (Stream)。 但是 **QUIC 给每一个 Stream 都分配了一个独立的滑动窗口,这样使得一个连接上的多个 Stream 之间没有依赖关系,都是相互独立的,各自控制的滑动窗口**。 假如 Stream2 丢了一个 UDP 包,也只会影响 Stream2 的处理,不会影响其他 Stream,与 HTTP/2 不同,HTTP/2 只要某个流中的数据包丢失了,其他流也会因此受影响。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/quic/quic无阻塞.jpeg) ## QUIC 是如何做流量控制的? TCP 流量控制是通过让「接收方」告诉「发送方」,它(接收方)的接收窗口有多大,从而让「发送方」根据「接收方」的实际接收能力控制发送的数据量。 QUIC 实现流量控制的方式: - 通过 window_update 帧告诉对端自己可以接收的字节数,这样发送方就不会发送超过这个数量的数据。 - 通过 BlockFrame 告诉对端由于流量控制被阻塞了,无法发送数据。 在前面说到,TCP 的接收窗口在收到有序的数据后,接收窗口才能往前滑动,否则停止滑动;TCP 的发送窗口在收到对已发送数据的顺序确认 ACK 后,发送窗口才能往前滑动,否则停止滑动。 QUIC 是基于 UDP 传输的,而 UDP 没有流量控制,因此 QUIC 实现了自己的流量控制机制,QUIC 的滑动窗口滑动的条件跟 TCP 有一点差别,但是同一个 Stream 的数据也是要保证顺序的,不然无法实现可靠传输,因此同一个 Stream 的数据包丢失了,也会造成窗口无法滑动。 **QUIC 的 每个 Stream 都有各自的滑动窗口,不同 Stream 互相独立,队头的 Stream A 被阻塞后,不妨碍 StreamB、C 的读取**。而对于 HTTP/2 而言,所有的 Stream 都跑在一条 TCP 连接上,而这些 Stream 共享一个滑动窗口,因此同一个 Connection 内,Stream A 被阻塞后,StreamB、C 必须等待。 QUIC 实现了两种级别的流量控制,分别为 Stream 和 Connection 两种级别: - **Stream 级别的流量控制**:Stream 可以认为就是一条 HTTP 请求,每个 Stream 都有独立的滑动窗口,所以每个 Stream 都可以做流量控制,防止单个 Stream 消耗连接(Connection)的全部接收缓冲。 - **Connection 流量控制**:限制连接中所有 Stream 相加起来的总字节数,防止发送方超过连接的缓冲容量。 ### Stream 级别的流量控制 最开始,接收方的接收窗口初始状态如下(网上的讲 QUIC 流量控制的资料太少了,下面的例子我是参考 google 文档的:[Flow control in QUIC](https://docs.google.com/document/d/1F2YfdDXKpy20WVKJueEf4abn_LVZHhMUMS5gX6Pgjl4/mobilebasic)): ![](https://img-blog.csdnimg.cn/f1070a6eccd24559904815297b07f789.png) 接着,接收方收到了发送方发送过来的数据,有的数据被上层读取了,有的数据丢包了,此时的接收窗口状况如下: ![](https://img-blog.csdnimg.cn/77e9a7cf70da4a1b981f61e78db2ad56.png) 可以看到,**接收窗口的左边界取决于接收到的最大偏移字节数**,此时的`接收窗口 = 最大窗口数 - 接收到的最大偏移数`。 这里就可以看出 QUIC 的流量控制和 TCP 有点区别了: - TCP 的接收窗口只有在前面所有的 Segment 都接收的情况下才会移动左边界,当在前面还有字节未接收但收到后面字节的情况下,窗口也不会移动。 - QUIC 的接收窗口的左边界滑动条件取决于接收到的最大偏移字节数。 *PS:但是你要问我这么设计有什么好处?我也暂时没想到,因为资料太少了,至今没找到一个合理的说明,如果你知道,欢迎告诉我啊!* 那接收窗口右边界触发的滑动条件是什么呢?看下图: ![接收窗口触发的滑动](https://img-blog.csdnimg.cn/bbde0c66088f439b919a6d18b389aadb.png) 当图中的绿色部分数据超过最大接收窗口的一半后,最大接收窗口向右移动,接收窗口的右边界也向右扩展,同时给对端发送「窗口更新帧」,当发送方收到接收方的窗口更新帧后,发送窗口的右边界也会往右扩展,以此达到窗口滑动的效果。 绿色部分的数据是已收到的顺序的数据,**如果中途丢失了数据包,导致绿色部分的数据没有超过最大接收窗口的一半,那接收窗口就无法滑动了**,这个只影响同一个 Stream,其他 Stream 是不会影响的,因为每个 Stream 都有各自的滑动窗口。 在前面我们说过 QUIC 支持乱序确认,具体是怎么做到的呢? 接下来,举个例子(下面的例子来源于:[QUIC——快速 UDP 网络连接协议](https://juejin.cn/post/7066993430102016037)): 如图所示,当前发送方的缓冲区大小为 8,发送方 QUIC 按序(offset 顺序)发送 29-36 的数据包: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/quic/乱序确认1.png) 31、32、34 数据包先到达,基于 offset 被优先乱序确认,但 30 数据包没有确认,所以当前已提交的字节偏移量不变,发送方的缓存区不变。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/quic/乱序确认2.png) 30 到达并确认,发送方的缓存区收缩到阈值,接收方发送 MAX_STREAM_DATA Frame(协商缓存大小的特定帧)给发送方,请求增长最大绝对字节偏移量。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/quic/乱序确认3.png) 协商完毕后最大绝对字节偏移量右移,发送方的缓存区变大,同时发送方发现数据包 33 超时 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/quic/乱序确认4.png) 发送方将超时数据包重新编号为 42 继续发送 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/quic/乱序确认5.png) 以上就是最基本的数据包发送 - 接收过程,控制数据发送的唯一限制就是最大绝对字节偏移量,该值是接收方基于当前已经提交的偏移量(连续已确认并向上层应用提交的数据包 offset)和发送方协商得出。 ### Connection 流量控制 而对于 Connection 级别的流量窗口,其接收窗口大小就是各个 Stream 接收窗口大小之和。 ![Connection 流量控制](https://img-blog.csdnimg.cn/839501cffa7146cbb8d992264594e61d.png) 上图所示的例子,所有 Streams 的最大窗口数为 120,其中: - Stream 1 的最大接收偏移为 100,可用窗口 = 120 - 100 = 20 - Stream 2 的最大接收偏移为 90,可用窗口 = 120 - 90 = 30 - Stream 3 的最大接收偏移为 110,可用窗口 = 120 - 110 = 10 那么整个 Connection 的可用窗口 = 20 + 30 + 10 = 60 ```text 可用窗口 = Stream 1 可用窗口 + Stream 2 可用窗口 + Stream 3 可用窗口 ``` ## QUIC 对拥塞控制改进 QUIC 协议当前默认使用了 TCP 的 Cubic 拥塞控制算法(我们熟知的慢开始、拥塞避免、快重传、快恢复策略),同时也支持 CubicBytes、Reno、RenoBytes、BBR、PCC 等拥塞控制算法,相当于将 TCP 的拥塞控制算法照搬过来了。 QUIC 是如何改进 TCP 的拥塞控制算法的呢? QUIC 是处于应用层的,应用程序层面就能实现不同的拥塞控制算法,不需要操作系统,不需要内核支持。这是一个飞跃,因为传统的 TCP 拥塞控制,必须要端到端的网络协议栈支持,才能实现控制效果。而内核和操作系统的部署成本非常高,升级周期很长,所以 TCP 拥塞控制算法迭代速度是很慢的。而 **QUIC 可以随浏览器更新,QUIC 的拥塞控制算法就可以有较快的迭代速度**。 TCP 更改拥塞控制算法是对系统中所有应用都生效,无法根据不同应用设定不同的拥塞控制策略。但是因为 QUIC 处于应用层,所以就**可以针对不同的应用设置不同的拥塞控制算法**,这样灵活性就很高了。 ## QUIC 更快的连接建立 对于 HTTP/1 和 HTTP/2 协议,TCP 和 TLS 是分层的,分别属于内核实现的传输层、openssl 库实现的表示层,因此它们难以合并在一起,需要分批次来握手,先 TCP 握手(1RTT),再 TLS 握手(2RTT),所以需要 3RTT 的延迟才能传输数据,就算 Session 会话复用,也需要至少 2 个 RTT。 HTTP/3 在传输数据前虽然需要 QUIC 协议握手,这个握手过程只需要 1 RTT,握手的目的是为确认双方的「连接 ID」,连接迁移就是基于连接 ID 实现的。 但是 HTTP/3 的 QUIC 协议并不是与 TLS 分层,而是**QUIC 内部包含了 TLS,它在自己的帧会携带 TLS 里的“记录”,再加上 QUIC 使用的是 TLS1.3,因此仅需 1 个 RTT 就可以「同时」完成建立连接与密钥协商,甚至在第二次连接的时候,应用数据包可以和 QUIC 握手信息(连接信息 + TLS 信息)一起发送,达到 0-RTT 的效果**。 如下图右边部分,HTTP/3 当会话恢复时,有效负载数据与第一个数据包一起发送,可以做到 0-RTT(下图的右下角): ![](https://img-blog.csdnimg.cn/4cad213f5125432693e0e2a512c2d1a1.png) ## QUIC 是如何迁移连接的? 基于 TCP 传输协议的 HTTP 协议,由于是通过四元组(源 IP、源端口、目的 IP、目的端口)确定一条 TCP 连接。 ![TCP 四元组](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzEwLmpwZw?x-oss-process=image/format,png) 那么**当移动设备的网络从 4G 切换到 WIFI 时,意味着 IP 地址变化了,那么就必须要断开连接,然后重新建立 TCP 连接**。 而建立连接的过程包含 TCP 三次握手和 TLS 四次握手的时延,以及 TCP 慢启动的减速过程,给用户的感觉就是网络突然卡顿了一下,因此连接的迁移成本是很高的。 QUIC 协议没有用四元组的方式来“绑定”连接,而是通过**连接 ID**来标记通信的两个端点,客户端和服务器可以各自选择一组 ID 来标记自己,因此即使移动设备的网络变化后,导致 IP 地址变化了,只要仍保有上下文信息(比如连接 ID、TLS 密钥等),就可以“无缝”地复用原连接,消除重连的成本,没有丝毫卡顿感,达到了**连接迁移**的功能。 --- 参考资料: - https://www.taohui.tech/2021/02/04/%E7%BD%91%E7%BB%9C%E5%8D%8F%E8%AE%AE/%E6%B7%B1%E5%85%A5%E5%89%96%E6%9E%90HTTP3%E5%8D%8F%E8%AE%AE/ - https://zhuanlan.zhihu.com/p/32553477 --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/3_tcp/syn_drop.md ================================================ # 4.8 SYN 报文什么时候情况下会被丢弃? 大家好,我是小林。 之前有个读者在秋招面试的时候,被问了这么一个问题:SYN 报文什么时候情况下会被丢弃? ![](https://img-blog.csdnimg.cn/img_convert/d4df0c85e08f66f6a2aa2038af73adcc.png) 好家伙,现在面试都问那么细节了吗? 不过话说回来,这个问题跟工作上也是有关系的,因为我就在工作中碰到这么奇怪的时候,客户端向服务端发起了连接,但是连接并没有建立起来,通过抓包分析发现,服务端是收到 SYN 报文了,但是并没有回复 SYN+ACK(TCP 第二次握手),说明 SYN 报文被服务端忽略了,然后客户端就一直在超时重传 SYN 报文,直到达到最大的重传次数。 接下来,我就给出我遇到过 SYN 报文被丢弃的两种场景: - 开启 tcp_tw_recycle 参数,并且在 NAT 环境下,造成 SYN 报文被丢弃 - TCP 两个队列满了(半连接队列和全连接队列),造成 SYN 报文被丢弃 ## 坑爹的 tcp_tw_recycle TCP 四次挥手过程中,主动断开连接方会有一个 TIME_WAIT 的状态,这个状态会持续 2 MSL 后才会转变为 CLOSED 状态。 ![](https://img-blog.csdnimg.cn/img_convert/bee0c8e8d84047e7434803fb340f9e5d.png) 在 Linux 操作系统下,TIME_WAIT 状态的持续时间是 60 秒,这意味着这 60 秒内,客户端一直会占用着这个端口。要知道,端口资源也是有限的,一般可以开启的端口为 32768~61000,也可以通过如下参数设置指定范围: ```plain net.ipv4.ip_local_port_range ``` **如果客户端(发起连接方)的 TIME_WAIT 状态过多**,占满了所有端口资源,那么就无法对「目的 IP+ 目的 PORT」都一样的服务器发起连接了,但是被使用的端口,还是可以继续对另外一个服务器发起连接的。具体可以看我这篇文章:[客户端的端口可以重复使用吗?](https://xiaolincoding.com/network/3_tcp/port.html#%E5%AE%A2%E6%88%B7%E7%AB%AF%E7%9A%84%E7%AB%AF%E5%8F%A3%E5%8F%AF%E4%BB%A5%E9%87%8D%E5%A4%8D%E4%BD%BF%E7%94%A8%E5%90%97) 因此,客户端(发起连接方)都是和「目的 IP+ 目的 PORT」都一样的服务器建立连接的话,当客户端的 TIME_WAIT 状态连接过多的话,就会受端口资源限制,如果占满了所有端口资源,那么就无法再跟「目的 IP+ 目的 PORT」都一样的服务器建立连接了。 不过,即使是在这种场景下,只要连接的是不同的服务器,端口是可以重复使用的,所以客户端还是可以向其他服务器发起连接的,这是因为内核在定位一个连接的时候,是通过四元组(源 IP、源端口、目的 IP、目的端口)信息来定位的,并不会因为客户端的端口一样,而导致连接冲突。 但是 TIME_WAIT 状态也不是摆设作用,它的作用有两个: - 防止具有相同四元组的旧数据包被收到,也就是防止历史连接中的数据,被后面的连接接受,否则就会导致后面的连接收到一个无效的数据, - 保证「被动关闭连接」的一方能被正确的关闭,即保证最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭; 不过,Linux 操作系统提供了两个可以系统参数来快速回收处于 TIME_WAIT 状态的连接,这两个参数都是默认关闭的: - net.ipv4.tcp_tw_reuse,如果开启该选项的话,客户端(连接发起方)在调用 connect() 函数时,**如果内核选择到的端口,已经被相同四元组的连接占用的时候,就会判断该连接是否处于 TIME_WAIT 状态,如果该连接处于 TIME_WAIT 状态并且 TIME_WAIT 状态持续的时间超过了 1 秒,那么就会重用这个连接,然后就可以正常使用该端口了。**所以该选项只适用于连接发起方。 - net.ipv4.tcp_tw_recycle,如果开启该选项的话,允许处于 TIME_WAIT 状态的连接被快速回收; 要使得这两个选项生效,有一个前提条件,就是要打开 TCP 时间戳,即 net.ipv4.tcp_timestamps=1(默认即为 1))。 **tcp_tw_recycle 在使用了 NAT 的网络下是不安全的!** 对于服务器来说,如果同时开启了 recycle 和 timestamps 选项,则会开启一种称之为「per-host 的 PAWS 机制」。 > 首先给大家说说什么是 PAWS 机制? tcp_timestamps 选项开启之后,PAWS 机制会自动开启,它的作用是防止 TCP 包中的序列号发生绕回。 正常来说每个 TCP 包都会有自己唯一的 SEQ,出现 TCP 数据包重传的时候会复用 SEQ 号,这样接收方能通过 SEQ 号来判断数据包的唯一性,也能在重复收到某个数据包的时候判断数据是不是重传的。**但是 TCP 这个 SEQ 号是有限的,一共 32 bit,SEQ 开始是递增,溢出之后从 0 开始再次依次递增**。 所以当 SEQ 号出现溢出后单纯通过 SEQ 号无法标识数据包的唯一性,某个数据包延迟或因重发而延迟时可能导致连接传递的数据被破坏,比如: ![](https://img-blog.csdnimg.cn/img_convert/f5fbe947240026cc2f076267cb698496.png) 上图 A 数据包出现了重传,并在 SEQ 号耗尽再次从 A 递增时,第一次发的 A 数据包延迟到达了 Server,这种情况下如果没有别的机制来保证,Server 会认为延迟到达的 A 数据包是正确的而接收,反而是将正常的第三次发的 SEQ 为 A 的数据包丢弃,造成数据传输错误。 PAWS 就是为了避免这个问题而产生的,在开启 tcp_timestamps 选项情况下,一台机器发的所有 TCP 包都会带上发送时的时间戳,PAWS 要求连接双方维护最近一次收到的数据包的时间戳(Recent TSval),每收到一个新数据包都会读取数据包中的时间戳值跟 Recent TSval 值做比较,**如果发现收到的数据包中时间戳不是递增的,则表示该数据包是过期的,就会直接丢弃这个数据包**。 对于上面图中的例子有了 PAWS 机制就能做到在收到 Delay 到达的 A 号数据包时,识别出它是个过期的数据包而将其丢掉。 > 那什么是 per-host 的 PAWS 机制呢? 前面我提到,开启了 recycle 和 timestamps 选项,就会开启一种叫 per-host 的 PAWS 机制。**per-host 是对「对端 IP 做 PAWS 检查」**,而非对「IP + 端口」四元组做 PAWS 检查。 但是如果客户端网络环境是用了 NAT 网关,那么客户端环境的每一台机器通过 NAT 网关后,都会是相同的 IP 地址,在服务端看来,就好像只是在跟一个客户端打交道一样,无法区分出来。 Per-host PAWS 机制利用 TCP option 里的 timestamp 字段的增长来判断串扰数据,而 timestamp 是根据客户端各自的 CPU tick 得出的值。 当客户端 A 通过 NAT 网关和服务器建立 TCP 连接,然后服务器主动关闭并且快速回收 TIME-WAIT 状态的连接后,**客户端 B 也通过 NAT 网关和服务器建立 TCP 连接,注意客户端 A 和 客户端 B 因为经过相同的 NAT 网关,所以是用相同的 IP 地址与服务端建立 TCP 连接,如果客户端 B 的 timestamp 比 客户端 A 的 timestamp 小,那么由于服务端的 per-host 的 PAWS 机制的作用,服务端就会丢弃客户端主机 B 发来的 SYN 包**。 因此,tcp_tw_recycle 在使用了 NAT 的网络下是存在问题的,如果它是对 TCP 四元组做 PAWS 检查,而不是对「相同的 IP 做 PAWS 检查」,那么就不会存在这个问题了。 网上很多博客都说开启 tcp_tw_recycle 参数来优化 TCP,我信你个鬼,糟老头坏的很! tcp_tw_recycle 在 Linux 4.12 版本后,直接取消了这一参数。 ## accpet 队列满了 在 TCP 三次握手的时候,Linux 内核会维护两个队列,分别是: - 半连接队列,也称 SYN 队列; - 全连接队列,也称 accepet 队列; 服务端收到客户端发起的 SYN 请求后,**内核会把该连接存储到半连接队列**,并向客户端响应 SYN+ACK,接着客户端会返回 ACK,服务端收到第三次握手的 ACK 后,**内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来。** ![](https://img-blog.csdnimg.cn/img_convert/c9959166180b0e239bb48234ff7c2f5b.png) ### 半连接队列满了 当服务器造成 syn 攻击,就有可能导致 **TCP 半连接队列满了,这时后面来的 syn 包都会被丢弃**。 但是,**如果开启了 syncookies 功能,即使半连接队列满了,也不会丢弃 syn 包**。 syncookies 是这么做的:服务器根据当前状态计算出一个值,放在己方发出的 SYN+ACK 报文中发出,当客户端返回 ACK 报文时,取出该值验证,如果合法,就认为连接建立成功,如下图所示。 ![](https://img-blog.csdnimg.cn/img_convert/58e01036d1febd0103dd0ec4d5acff05.png) syncookies 参数主要有以下三个值: - 0 值,表示关闭该功能; - 1 值,表示仅当 SYN 半连接队列放不下时,再启用它; - 2 值,表示无条件开启功能; 那么在应对 SYN 攻击时,只需要设置为 1 即可: ![](https://img-blog.csdnimg.cn/img_convert/e795b4ff5be76c85814ee190b4921f25.png) 这里给出几种防御 SYN 攻击的方法: - 增大半连接队列; - 开启 tcp_syncookies 功能 - 减少 SYN+ACK 重传次数 *方式一:增大半连接队列* **要想增大半连接队列,我们得知不能只单纯增大 tcp_max_syn_backlog 的值,还需一同增大 somaxconn 和 backlog,也就是增大全连接队列**。否则,只单纯增大 tcp_max_syn_backlog 是无效的。 增大 tcp_max_syn_backlog 和 somaxconn 的方法是修改 Linux 内核参数: ![](https://img-blog.csdnimg.cn/img_convert/29f1fd2894162e15cbac938a2373b543.png) 增大 backlog 的方式,每个 Web 服务都不同,比如 Nginx 增大 backlog 的方法如下: ![](https://img-blog.csdnimg.cn/img_convert/a6b11fbd1fcb742cdcc87447fc23b73f.png) 最后,改变了如上这些参数后,要重启 Nginx 服务,因为半连接队列和全连接队列都是在 listen() 初始化的。 *方式二:开启 tcp_syncookies 功能* 开启 tcp_syncookies 功能的方式也很简单,修改 Linux 内核参数: ![](https://img-blog.csdnimg.cn/img_convert/54b7411607978cb9ff36d88cf47eb5c4.png) *方式三:减少 SYN+ACK 重传次数* 当服务端受到 SYN 攻击时,就会有大量处于 SYN_RECV 状态的 TCP 连接,处于这个状态的 TCP 会重传 SYN+ACK,当重传超过次数达到上限后,就会断开连接。 那么针对 SYN 攻击的场景,我们可以减少 SYN+ACK 的重传次数,以加快处于 SYN_RECV 状态的 TCP 连接断开。 ![](https://img-blog.csdnimg.cn/img_convert/19443a03430368b72c201113150471c5.png) ### 全连接队列满了 **在服务端并发处理大量请求时,如果 TCP accpet 队列过小,或者应用程序调用 accept() 不及时,就会造成 accpet 队列满了,这时后续的连接就会被丢弃,这样就会出现服务端请求数量上不去的现象。** ![](https://img-blog.csdnimg.cn/img_convert/d1538f8d3b50da26039bc6b171a13ad1.png) 我们可以通过 ss 命令来看 accpet 队列大小,在「LISTEN 状态」时,`Recv-Q/Send-Q` 表示的含义如下: ![](https://img-blog.csdnimg.cn/img_convert/d7e8fcbb4afa583687b76064b7f1afac.png) - Recv-Q:当前 accpet 队列的大小,也就是当前已完成三次握手并等待服务端 `accept()` 的 TCP 连接个数; - Send-Q:当前 accpet 最大队列长度,上面的输出结果说明监听 8088 端口的 TCP 服务进程,accpet 队列的最大长度为 128; 如果 Recv-Q 的大小超过 Send-Q,就说明发生了 accpet 队列满的情况。 要解决这个问题,我们可以: - 调大 accpet 队列的最大长度,调大的方式是通过**调大 backlog 以及 somaxconn 参数。** - 检查系统或者代码为什么调用 accept() 不及时; 关于 SYN 队列和 accpet 队列,我之前写过一篇很详细的文章:[TCP 半连接队列和全连接队列满了会发生什么?又该如何应对?](https://mp.weixin.qq.com/s/2qN0ulyBtO2I67NB_RnJbg) --- 好了,今天就分享到这里啦。 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/3_tcp/tcp_down_and_crash.md ================================================ # 4.12 TCP 连接,一端断电和进程崩溃有什么区别? 有位读者找我说,他在面试腾讯的时候,遇到了这么个问题: ![在这里插入图片描述](https://img-blog.csdnimg.cn/2021061513401120.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODI3Njc0,size_16,color_FFFFFF,t_70) 这个属于 **TCP 异常断开连接**的场景,这部分内容在我的「图解网络」还没有详细介绍过,这次就乘着这次机会补一补。 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20210615134020994.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODI3Njc0,size_16,color_FFFFFF,t_70) 这个问题有几个关键词: - 没有开启 keepalive; - 一直没有数据交互; - 进程崩溃; - 主机崩溃; 我们先来认识认识什么是 TCP keepalive 呢? 这东西其实就是 **TCP 的保活机制**,它的工作原理我之前的文章写过,这里就直接贴下以前的内容。 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20210615134028909.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODI3Njc0,size_16,color_FFFFFF,t_70) 如果两端的 TCP 连接一直没有数据交互,达到了触发 TCP 保活机制的条件,那么内核里的 TCP 协议栈就会发送探测报文。 - 如果对端程序是正常工作的。当 TCP 保活的探测报文发送给对端,对端会正常响应,这样 **TCP 保活时间会被重置**,等待下一个 TCP 保活时间的到来。 - 如果对端主机崩溃,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,**TCP 会报告该 TCP 连接已经死亡**。 所以,TCP 保活机制可以在双方没有数据交互的情况,通过探测报文,来确定对方的 TCP 连接是否存活。 ![在这里插入图片描述](https://img-blog.csdnimg.cn/20210615134036676.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODI3Njc0,size_16,color_FFFFFF,t_70) 注意,应用程序若想使用 TCP 保活机制需要通过 socket 接口设置 `SO_KEEPALIVE` 选项才能够生效,如果没有设置,那么就无法使用 TCP 保活机制。 ## 主机崩溃 知道了 TCP keepalive 作用,我们再回过头看题目中的「主机崩溃」这种情况。 > 在没有开启 TCP keepalive,且双方一直没有数据交互的情况下,如果客户端的「主机崩溃」了,会发生什么。 客户端主机崩溃了,服务端是**无法感知到的**,在加上服务端没有开启 TCP keepalive,又没有数据交互的情况下,**服务端的 TCP 连接将会一直处于 ESTABLISHED 连接状态**,直到服务端重启进程。 所以,我们可以得知一个点,在没有使用 TCP 保活机制且双方不传输数据的情况下,一方的 TCP 连接处在 ESTABLISHED 状态,并不代表另一方的连接还一定正常。 ## 进程崩溃 > 那题目中的「进程崩溃」的情况呢? TCP 的连接信息是由内核维护的,所以当服务端的进程崩溃后,内核需要回收该进程的所有 TCP 连接资源,于是内核会发送第一次挥手 FIN 报文,后续的挥手过程也都是在内核完成,并不需要进程的参与,所以即使服务端的进程退出了,还是能与客户端完成 TCP 四次挥手的过程。 我自己做了实验,使用 kill -9 来模拟进程崩溃的情况,发现**在 kill 掉进程后,服务端会发送 FIN 报文,与客户端进行四次挥手**。 所以,即使没有开启 TCP keepalive,且双方也没有数据交互的情况下,如果其中一方的进程发生了崩溃,这个过程操作系统是可以感知的到的,于是就会发送 FIN 报文给对方,然后与对方进行 TCP 四次挥手。 ![在这里插入图片描述](https://img-blog.csdnimg.cn/2021061513405211.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODI3Njc0,size_16,color_FFFFFF,t_70) --- ## 有数据传输的场景 以上就是对这个面试题的回答,接下来我们看看在「**有数据传输**」的场景下的一些异常情况: - 第一种,客户端主机宕机,又迅速重启,会发生什么? - 第二种,客户端主机宕机,一直没有重启,会发生什么? ### 客户端主机宕机,又迅速重启 在客户端主机宕机后,服务端向客户端发送的报文会得不到任何的响应,在一定时长后,服务端就会触发**超时重传**机制,重传未得到响应的报文。 服务端重传报文的过程中,客户端主机重启完成后,客户端的内核就会接收重传的报文,然后根据报文的信息传递给对应的进程: - 如果客户端主机上**没有**进程绑定该 TCP 报文的目标端口号,那么客户端内核就会**回复 RST 报文,重置该 TCP 连接**; - 如果客户端主机上**有**进程绑定该 TCP 报文的目标端口号,由于客户端主机重启后,之前的 TCP 连接的数据结构已经丢失了,客户端内核里协议栈会发现找不到该 TCP 连接的 socket 结构体,于是就会**回复 RST 报文,重置该 TCP 连接**。 所以,**只要有一方重启完成后,收到之前 TCP 连接的报文,都会回复 RST 报文,以断开连接**。 ### 客户端主机宕机,一直没有重启 这种情况,服务端超时重传报文的次数达到一定阈值后,内核就会判定出该 TCP 有问题,然后通过 Socket 接口告诉应用程序该 TCP 连接出问题了,于是服务端的 TCP 连接就会断开。 ![](https://img-blog.csdnimg.cn/20210615134110763.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODI3Njc0,size_16,color_FFFFFF,t_70) > 那 TCP 的数据报文具体重传几次呢? 在 Linux 系统中,提供一个叫 tcp_retries2 配置项,默认值是 15,如下图: ![在这里插入图片描述](https://img-blog.csdnimg.cn/20210615134059647.png) 这个内核参数是控制,在 TCP 连接建立的情况下,超时重传的最大次数。 不过 tcp_retries2 设置了 15 次,并不代表 TCP 超时重传了 15 次才会通知应用程序终止该 TCP 连接,**内核会根据 tcp_retries2 设置的值,计算出一个 timeout**(*如果 tcp_retries2 =15,那么计算得到的 timeout = 924600 ms*),**如果重传间隔超过这个 timeout,则认为超过了阈值,就会停止重传,然后就会断开 TCP 连接**。 在发生超时重传的过程中,每一轮的超时时间(RTO)都是**倍数增长**的,比如如果第一轮 RTO 是 200 毫秒,那么第二轮 RTO 是 400 毫秒,第三轮 RTO 是 800 毫秒,以此类推。 而 RTO 是基于 RTT(一个包的往返时间)来计算的,如果 RTT 较大,那么计算出来的 RTO 就越大,那么经过几轮重传后,很快就达到了上面的 timeout 值了。 举个例子,如果 tcp_retries2 =15,那么计算得到的 timeout = 924600 ms,如果重传总间隔时长达到了 timeout 就会停止重传,然后就会断开 TCP 连接: - 如果 RTT 比较小,那么 RTO 初始值就约等于下限 200ms,也就是第一轮的超时时间是 200 毫秒,由于 timeout 总时长是 924600 ms,表现出来的现象刚好就是重传了 15 次,超过了 timeout 值,从而断开 TCP 连接 - 如果 RTT 比较大,假设 RTO 初始值计算得到的是 1000 ms,也就是第一轮的超时时间是 1 秒,那么根本不需要重传 15 次,重传总间隔就会超过 924600 ms。 最小 RTO 和最大 RTO 是在 Linux 内核中定义好了: ```c #define TCP_RTO_MAX ((unsigned)(120*HZ)) #define TCP_RTO_MIN ((unsigned)(HZ/5)) ``` Linux 2.6+ 使用 1000 毫秒的 HZ,因此`TCP_RTO_MIN`约为 200 毫秒,`TCP_RTO_MAX`约为 120 秒。 如果`tcp_retries`设置为`15`,且 RTT 比较小,那么 RTO 初始值就约等于下限 200ms,这意味着**它需要 924.6 秒**才能将断开的 TCP 连接通知给上层(即应用程序),每一轮的 RTO 增长关系如下表格: ![在这里插入图片描述](https://img-blog.csdnimg.cn/2021061513410645.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODI3Njc0,size_16,color_FFFFFF,t_70) --- ## 总结 如果「**客户端进程崩溃**」,客户端的进程在发生崩溃的时候,内核会发送 FIN 报文,与服务端进行四次挥手。 但是,「**客户端主机宕机**」,那么是不会发生四次挥手的,具体后续会发生什么?还要看服务端会不会发送数据? - 如果服务端会发送数据,由于客户端已经不存在,收不到数据报文的响应报文,服务端的数据报文会超时重传,当重传总间隔时长达到一定阈值(内核会根据 tcp_retries2 设置的值计算出一个阈值)后,会断开 TCP 连接; - 如果服务端一直不会发送数据,再看服务端有没有开启 TCP keepalive 机制? - 如果有开启,服务端在一段时间没有进行数据交互时,会触发 TCP keepalive 机制,探测对方是否存在,如果探测到对方已经消亡,则会断开自身的 TCP 连接; - 如果没有开启,服务端的 TCP 连接会一直存在,并且一直保持在 ESTABLISHED 状态。 最后说句,TCP 牛逼,啥异常都考虑到了。 **小林是专为大家图解的工具人,Goodbye,我们下次见!** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/3_tcp/tcp_drop.md ================================================ # 4.21 用了 TCP 协议,数据一定不会丢吗? > 来源:公众号@小白 debug > >原文地址:[用了 TCP 协议,数据一定不会丢吗?](https://mp.weixin.qq.com/s/XNJoaVnYT1SxHsdNWeAaUw) 大家后,我是小林。 问大家一句:TCP 是一个可靠的传输协议,那它一定能保证数据不丢失吗? 这次,就跟大家探讨这个问题。 ## 数据包的发送流程 首先,我们两个手机的绿皮聊天软件客户端,要通信,中间会通过它们家服务器。大概长这样。 ![聊天软件三端通信](https://img-blog.csdnimg.cn/img_convert/1d0a1d60ca4f720423911cf8f25c4ac3.png) 但为了**简化模型**,我们把中间的服务器给省略掉,假设这是个端到端的通信。且为了保证消息的可靠性,我们盲猜它们之间用的是**TCP 协议**进行通信。 ![聊天软件两端通信](https://img-blog.csdnimg.cn/img_convert/7e8bae365b8d27560aac1cd28f501156.png) 为了发送数据包,两端首先会通过**三次握手**,建立 TCP 连接。 一个数据包,从聊天框里发出,消息会从**聊天软件**所在的**用户空间**拷贝到**内核空间**的**发送缓冲区(send buffer)**,数据包就这样顺着**传输层、网络层,进入到数据链路层,在这里数据包会经过流控(qdisc),再通过 RingBuffer 发到物理层的网卡**。数据就这样顺着**网卡**发到了**纷繁复杂**的网络世界里。这里头数据会经过 n 多个**路由器和交换机**之间的跳转,最后到达**目的机器的网卡**处。 此时目的机器的网卡会通知**DMA**将数据包信息放到`RingBuffer`中,再触发一个**硬中断**给`CPU`,`CPU`触发**软中断**让`ksoftirqd`去`RingBuffer`收包,于是一个数据包就这样顺着**物理层,数据链路层,网络层,传输层**,最后从内核空间拷贝到用户空间里的**聊天软件**里。 ![网络发包收包全景图](https://img-blog.csdnimg.cn/img_convert/28e4d6b004530fbf75fe346d181baa81.png) > 画了那么大一张图,只水了 200 字做解释,我多少是有些心痛的。 到这里,抛开一些细节,大家大概知道了一个数据包从**发送到接收**的宏观过程。 可以看到,这上面全是密密麻麻的**名词**。 整条链路下来,有不少地方可能会发生丢包。 但为了不让大家**保持蹲姿太久**影响身体健康,我这边只重点讲下几个**常见容易发生丢包的场景**。 ## 建立连接时丢包 TCP 协议会通过**三次握手**建立连接。大概长下面这样。 ![TCP 三次握手](https://img-blog.csdnimg.cn/img_convert/923f5005edb536c0d07b096bbf2ca282.png) 在服务端,第一次握手之后,会先建立个**半连接**,然后再发出第二次握手。这时候需要有个地方可以**暂存**这些半连接。这个地方就叫**半连接队列**。 如果之后第三次握手来了,半连接就会升级为全连接,然后暂存到另外一个叫**全连接队列**的地方,坐等程序执行`accept()`方法将其取走使用。 ![半连接队列和全连接队列](https://img-blog.csdnimg.cn/img_convert/02a78bb83fe167324f26e8c910d7a7a2.png) 是队列就有长度,有长度就有可能会满,如果它们**满了**,那新来的包就会被**丢弃**。 可以通过下面的方式查看是否存在这种丢包行为。 ```shell # 全连接队列溢出次数 # netstat -s | grep overflowed 4343 times the listen queue of a socket overflowed # 半连接队列溢出次数 # netstat -s | grep -i "SYNs to LISTEN sockets dropped" 109 times the listen queue of a socket overflowed ``` 从现象来看就是连接建立失败。 ![图片](https://img-blog.csdnimg.cn/img_convert/591d630098b4fc5316a5005f1e94b844.png) ## 流量控制丢包 应用层能发网络数据包的软件有那么多,如果所有数据不加控制一股脑冲入到网卡,网卡会吃不消,那怎么办?让数据按一定的规则排个队依次处理,也就是所谓的**qdisc**(**Q**ueueing **Disc**iplines,排队规则),这也是我们常说的**流量控制**机制。 排队,得先有个队列,而队列有个**长度**。 我们可以通过下面的`ifconfig`命令查看到,里面涉及到的`txqueuelen`后面的数字`1000`,其实就是流控队列的长度。 当发送数据过快,流控队列长度`txqueuelen`又不够大时,就容易出现**丢包**现象。 ![qdisc 丢包](https://img-blog.csdnimg.cn/img_convert/6f2821018be08a2f27561155e8085de4.png) 可以通过下面的`ifconfig`命令,查看 TX 下的 dropped 字段,当它大于 0 时,则**有可能**是发生了流控丢包。 ```shell # ifconfig eth0 eth0: flags=4163 mtu 1500 inet 172.21.66.69 netmask 255.255.240.0 broadcast 172.21.79.255 inet6 fe80::216:3eff:fe25:269f prefixlen 64 scopeid 0x20 ether 00:16:3e:25:26:9f txqueuelen 1000 (Ethernet) RX packets 6962682 bytes 1119047079 (1.0 GiB) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 9688919 bytes 2072511384 (1.9 GiB) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 ``` 当遇到这种情况时,我们可以尝试修改下流控队列的长度。比如像下面这样将 eth0 网卡的流控队列长度从 1000 提升为 1500. ```shell # ifconfig eth0 txqueuelen 1500 ``` ## 网卡丢包 网卡和它的驱动导致丢包的场景也比较常见,原因很多,比如**网线质量差,接触不良**。除此之外,我们来聊几个常见的场景。 ### RingBuffer 过小导致丢包 上面提到,在接收数据时,会将数据暂存到`RingBuffer`接收缓冲区中,然后等着内核触发软中断慢慢收走。如果这个**缓冲区过小**,而这时候发送的数据又过快,就有可能发生溢出,此时也会产生**丢包**。 ![RingBuffer 满了导致丢包](https://img-blog.csdnimg.cn/img_convert/8f3ed2d6c4e2e154849f1e661528fe89.png) 我们可以通过下面的命令去查看是否发生过这样的事情。 ```shell # ifconfig eth0: RX errors 0 dropped 0 overruns 0 frame 0 ``` 查看上面的`overruns`指标,它记录了由于`RingBuffer`长度不足导致的溢出次数。 当然,用`ethtool`命令也能查看。 ```shell # ethtool -S eth0|grep rx_queue_0_drops ``` 但这里需要注意的是,因为一个网卡里是可以有**多个 RingBuffer**的,所以上面的`rx_queue_0_drops`里的 0 代表的是**第 0 个 RingBuffer**的丢包数,对于多队列的网卡,这个 0 还可以改成其他数字。但我的家庭条件不允许我看其他队列的丢包数,所以上面的命令对我来说是够用了。。。 当发现有这类型丢包的时候,可以通过下面的命令查看当前网卡的配置。 ```shell #ethtool -g eth0 Ring parameters for eth0: Pre-set maximums: RX: 4096 RX Mini: 0 RX Jumbo: 0 TX: 4096 Current hardware settings: RX: 1024 RX Mini: 0 RX Jumbo: 0 TX: 1024 ``` 上面的输出内容,含义是**RingBuffer 最大支持 4096 的长度,但现在实际只用了 1024。** 想要修改这个长度可以执行`ethtool -G eth1 rx 4096 tx 4096`将发送和接收 RingBuffer 的长度都改为 4096。 **RingBuffer**增大之后,可以减少因为容量小而导致的丢包情况。 ### 网卡性能不足 网卡作为硬件,**传输速度是有上限的**。当网络传输速度过大,达到网卡上限时,就会发生丢包。这种情况一般常见于压测场景。 我们可以通过`ethtool`加网卡名,获得当前网卡支持的最大速度。 ```shell # ethtool eth0 Settings for eth0: Speed: 10000Mb/s ``` 可以看到,我这边用的网卡能支持的最大传输速度**speed=1000Mb/s**。 也就是俗称的千兆网卡,但注意这里的单位是**Mb**,这里的**b 是指 bit,而不是 Byte。1Byte=8bit**。所以 10000Mb/s 还要除以 8,也就是理论上网卡最大传输速度是`1000/8 = 125MB/s`。 我们可以通过`sar命令`从网络接口层面来分析数据包的收发情况。 ```shell # sar -n DEV 1 Linux 3.10.0-1127.19.1.el7.x86_64 2022年07月27日 _x86_64_ (1 CPU) 08时35分39秒 IFACE rxpck/s txpck/s rxkB/s txkB/s rxcmp/s txcmp/s rxmcst/s 08时35分40秒 eth0 6.06 4.04 0.35 121682.33 0.00 0.00 0.00 ``` 其中 **txkB/s 是指当前每秒发送的字节(byte)总数,rxkB/s 是指每秒接收的字节(byte)总数**。 当两者加起来的值约等于`12~13w字节`的时候,也就对应大概`125MB/s`的传输速度。此时达到网卡性能极限,就会开始丢包。 遇到这个问题,优先看下你的服务是不是真有这么大的**真实流量**,如果是的话可以考虑下拆分服务,或者就忍痛充钱升级下配置吧。 ## 接收缓冲区丢包 我们一般使用`TCP socket`进行网络编程的时候,内核都会分配一个**发送缓冲区**和一个**接收缓冲区**。 当我们想要发一个数据包,会在代码里执行`send(msg)`,这时候数据包并不是一把梭直接就走网卡飞出去的。而是将数据拷贝到内核**发送缓冲区**就完事**返回**了,至于**什么时候发数据,发多少数据**,这个后续由内核自己做决定。 ![tcp_sendmsg 逻辑](https://img-blog.csdnimg.cn/img_convert/9cd22437777205662048c73cc5855add.png) 而**接收缓冲区**作用也类似,从外部网络收到的数据包就暂存在这个地方,然后坐等用户空间的应用程序将数据包取走。 这两个缓冲区是有大小限制的,可以通过下面的命令去查看。 ```shell # 查看接收缓冲区 # sysctl net.ipv4.tcp_rmem net.ipv4.tcp_rmem = 4096 87380 6291456 # 查看发送缓冲区 # sysctl net.ipv4.tcp_wmem net.ipv4.tcp_wmem = 4096 16384 4194304 ``` 不管是接收缓冲区还是发送缓冲区,都能看到三个数值,分别对应缓冲区的**最小值,默认值和最大值(min、default、max)。缓冲区会在 min 和 max 之间动态调整。** **那么问题来了,如果缓冲区设置过小会怎么样?** 对于**发送缓冲区**,执行 send 的时候,如果是**阻塞**调用,那就会等,等到缓冲区有空位可以发数据。 ![send 阻塞](https://img-blog.csdnimg.cn/img_convert/7312e536393463dcf0d57aeb07f28ed5.gif) 如果是**非阻塞**调用,就会**立刻返回**一个 `EAGAIN` 错误信息,意思是 `Try again`。让应用程序下次再重试。这种情况下一般不会发生丢包。 ![send 非阻塞](https://img-blog.csdnimg.cn/img_convert/f378a299ca60c490ee5437e1143916c8.gif) 当接受缓冲区满了,事情就不一样了,它的 TCP 接收窗口会变为 0,也就是所谓的**零窗口**,并且会通过数据包里的`win=0`,告诉发送端,"球球了,顶不住了,别发了"。一般这种情况下,发送端就该停止发消息了,但如果这时候确实还有数据发来,就会发生**丢包**。 ![recv_buffer 丢包](https://img-blog.csdnimg.cn/img_convert/2df66c2e1d9f1245813e8d1de7482e0c.png) 我们可以通过下面的命令里的`TCPRcvQDrop`查看到有没有发生过这种丢包现象。 ```shell cat /proc/net/netstat TcpExt: SyncookiesSent TCPRcvQDrop SyncookiesFailed TcpExt: 0 157 60116 ``` 但是说个伤心的事情,我们一般也看不到这个`TCPRcvQDrop`,因为这个是`5.9版本`里引入的打点,而我们的服务器用的一般是`2.x~3.x`左右版本。你可以通过下面的命令查看下你用的是什么版本的 linux 内核。 ```shell # cat /proc/version Linux version 3.10.0-1127.19.1.el7.x86_64 ``` ## 两端之间的网络丢包 前面提到的是两端机器内部的网络丢包,除此之外,两端之间那么长的一条链路都属于外部网络,这中间有各种路由器和交换机还有光缆啥的,丢包也是很经常发生的。 这些丢包行为发生在中间链路的某些个机器上,我们当然是没权限去登录这些机器。但我们可以通过一些命令观察整个链路的连通情况。 ### **ping 命令查看丢包** 比如我们知道目的地的域名是 `baidu.com`。想知道你的机器到 baidu 服务器之间,有没有产生丢包行为。可以使用 ping 命令。 ![ping 查看丢包](https://img-blog.csdnimg.cn/img_convert/56bdca9995c0c2a343b2b73b67933b78.png) 倒数第二行里有个`100% packet loss`,意思是丢包率 100%。 但这样其实你只能知道**你的机器和目的机器之间有没有丢包。** **那如果你想知道你和目的机器之间的这条链路,哪个节点丢包了,有没有办法呢?** 有。 ### **mtr 命令** mtr 命令可以查看到你的机器和目的机器之间的每个节点的丢包情况。 像下面这样执行命令。 ![mtr_icmp](https://img-blog.csdnimg.cn/img_convert/4a2d8dbfb648bcced864fb653af9f036.png) 其中 -r 是指 report,以报告的形式打印结果。 可以看到`Host`那一列,出现的都是链路中间每一跳的机器,`Loss`的那一列就是指这一跳对应的丢包率。 需要注意的是,中间有一些是 host 是`???`,那个是因为**mtr 默认用的是 ICMP 包**,有些节点限制了**ICMP 包**,导致不能正常展示。 我们可以在 mtr 命令里加个`-u`,也就是使用**udp 包**,就能看到部分???对应的 IP。 ![mtr-udp](https://img-blog.csdnimg.cn/img_convert/0650adc524ab7d82028dc83cfc9961e1.png) 把**ICMP 包和 UDP 包的结果**拼在一起看,就是**比较完整**的链路图了。 还有个小细节,`Loss`那一列,我们在 icmp 的场景下,关注**最后一行**,如果是 0%,那不管前面 loss 是 100% 还是 80% 都无所谓,那些都是**节点限制**导致的**虚报**。 但如果**最后一行是 20%,再往前几行都是 20% 左右**,那说明丢包就是从最接近的那一行开始产生的,长时间是这样,那很可能这一跳出了点问题。如果是公司内网的话,你可以带着这条线索去找对应的网络同事。如果是外网的话,那耐心点等等吧,别人家的开发会比你更着急。 ![图片](https://img-blog.csdnimg.cn/img_convert/7142a4e285024dc6aadea4255984c485.png) ## 发生丢包了怎么办 说了这么多。只是想告诉大家,**丢包是很常见的,几乎不可避免的一件事情**。 但问题来了,发生丢包了怎么办? 这个好办,用**TCP 协议**去做传输。 ![TCP 是什么](https://img-blog.csdnimg.cn/img_convert/b2225e071fec7cfb240aa295ed4037bf.png) 建立了 TCP 连接的两端,发送端在发出数据后会等待接收端回复`ack包`,`ack包`的目的是为了告诉对方自己确实收到了数据,但如果中间链路发生了丢包,那发送端会迟迟收不到确认 ack,于是就会进行**重传**。以此来保证每个数据包都确确实实到达了接收端。 假设现在网断了,我们还用聊天软件发消息,聊天软件会使用 TCP 不断尝试重传数据,**如果重传期间网络恢复了**,那数据就能正常发过去。但如果多次重试直到超时都还是失败,这时候你将收获一个**红色感叹号**。 ![图片](https://img-blog.csdnimg.cn/img_convert/c1460d52efe7c5e4d80c2f7160d5b126.png) 这时候问题又来了。 假设**某绿皮聊天软件用的就是 TCP 协议。** 在聊天的时候,发生丢包了,丢包了会**重试**,重试失败了还会出现**红色感叹号。** 于是乎,问题就变成了,**用了 TCP 协议,就一定不会丢包吗?** ## 用了 TCP 协议就一定不会丢包吗 我们知道 TCP 位于**传输层**,在它的上面还有各种**应用层协议**,比如常见的 HTTP 或者各类 RPC 协议。 ![四层网络协议](https://img-blog.csdnimg.cn/img_convert/c6794dd51c8780f12e4022fc964ebb0a.png) TCP 保证的可靠性,是**传输层的可靠性**。也就是说,**TCP 只保证数据从 A 机器的传输层可靠地发到 B 机器的传输层。** 至于数据到了接收端的传输层之后,能不能保证到应用层,TCP 并不管。 假设现在,我们输入一条消息,从聊天框发出,走到**传输层 TCP 协议的发送缓冲区**,不管中间有没有丢包,最后通过重传都保证发到了对方的**传输层 TCP 接收缓冲区**,此时接收端回复了一个`ack`,发送端收到这个`ack`后就会将自己**发送缓冲区**里的消息给扔掉。到这里 TCP 的任务就结束了。 TCP 任务是结束了,但聊天软件的任务没结束。 **聊天软件还需要将数据从 TCP 的接收缓冲区里读出来,如果在读出来这一刻,手机由于内存不足或其他各种原因,导致软件崩溃闪退了。** 发送端以为自己发的消息已经发给对方了,但接收端却并没有收到这条消息。 于是乎,**消息就丢了。** ![使用 TCP 协议却发生丢包](https://img-blog.csdnimg.cn/img_convert/9286ab84bcaa74576bc11c8e9322fee9.png) **虽然概率很小,但它就是发生了**。 合情合理,逻辑自洽。 ## 这类丢包问题怎么解决? 故事到这里也到尾声了,感动之余,我们来**聊点掏心窝子的话**。 **其实前面说的都对,没有一句是假话**。 但某绿皮聊天软件这么成熟,怎么可能没考虑过这一点呢。 大家应该还记得我们文章开头提到过,**为了简单**,就将服务器那一方给省略了,从三端通信变成了两端通信,所以才有了这个丢包问题。 **现在我们重新将服务器加回来。** ![聊天软件三端通信](https://img-blog.csdnimg.cn/img_convert/d53659df39d64db4780d2816bd8314d1.png) 大家有没有发现,有时候我们在手机里聊了一大堆内容,然后登录电脑版,它能将最近的聊天记录都同步到电脑版上。也就是说服务器**可能**记录了我们最近发过什么数据,假设**每条消息都有个 id**,服务器和聊天软件每次都拿**最新消息的 id**进行对比,就能知道两端消息是否一致,就像**对账**一样。 对于**发送方**,只要定时跟服务端的内容对账一下,就知道哪条消息没发送成功,直接重发就好了。 如果**接收方**的聊天软件崩溃了,重启后跟服务器稍微通信一下就知道少了哪条数据,同步上来就是了,所以也不存在上面提到的丢包情况。 可以看出,**TCP 只保证传输层的消息可靠性,并不保证应用层的消息可靠性。如果我们还想保证应用层的消息可靠性,就需要应用层自己去实现逻辑做保证。** 那么问题叒来了,**两端通信的时候也能对账,为什么还要引入第三端服务器?** 主要有三个原因。 - 第一,如果是两端通信,你聊天软件里有`1000个`好友,你就得建立`1000个`连接。但如果引入服务端,你只需要跟服务器建立`1个`连接就够了,**聊天软件消耗的资源越少,手机就越省电**。 - 第二,就是**安全问题**,如果还是两端通信,随便一个人找你对账一下,你就把聊天记录给同步过去了,这并不合适吧。如果对方别有用心,信息就泄露了。引入第三方服务端就可以很方便的做各种**鉴权**校验。 - 第三,是**软件版本问题**。软件装到用户手机之后,软件更不更新就是由用户说了算了。如果还是两端通信,且两端的**软件版本跨度太大**,很容易产生各种兼容性问题,但引入第三端服务器,就可以强制部分过低版本升级,否则不能使用软件。但对于大部分兼容性问题,给服务端加兼容逻辑就好了,不需要强制用户更新软件。 所以看到这里大家应该明白了,我把服务端去掉,并不单纯是**为了简单**。 ## 总结 - 数据从发送端到接收端,链路很长,任何一个地方都可能发生丢包,几乎可以说丢包不可避免。 - 平时没事也不用关注丢包,大部分时候 TCP 的重传机制保证了消息可靠性。 - 当你发现服务异常的时候,比如接口延时很高,总是失败的时候,可以用 ping 或者 mtr 命令看下是不是中间链路发生了丢包。 - TCP 只保证传输层的消息可靠性,并不保证应用层的消息可靠性。如果我们还想保证应用层的消息可靠性,就需要应用层自己去实现逻辑做保证。 ---- ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」*** ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/3_tcp/tcp_feature.md ================================================ # 4.2 TCP 重传、滑动窗口、流量控制、拥塞控制 TCP **巨复杂**,它为了保证可靠性,用了巨多的机制来保证,真是个「伟大」的协议,写着写着发现这水太深了。。。 本文的全部图片都是小林绘画的,非常的辛苦且累,不废话了,直接进入正文,Go! 相信大家都知道 TCP 是一个可靠传输的协议,那它是如何保证可靠的呢? 为了实现可靠性传输,需要考虑很多事情,例如数据的破坏、丢包、重复以及分片顺序混乱等问题。如不能解决这些问题,也就无从谈起可靠传输。 那么,TCP 是通过序列号、确认应答、重发控制、连接管理以及窗口控制等机制实现可靠性传输的。 今天,将重点介绍 TCP 的**重传机制、滑动窗口、流量控制、拥塞控制。** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/3.jpg?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) --- ## 重传机制 TCP 实现可靠传输的方式之一,是通过序列号与确认应答。 在 TCP 中,当发送端的数据到达接收主机时,接收端主机会返回一个确认应答消息,表示已收到消息。 ![正常的数据传输](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/4.jpg?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 但在错综复杂的网络,并不一定能如上图那么顺利能正常的数据传输,万一数据在传输过程中丢失了呢? 所以 TCP 针对数据包丢失的情况,会用**重传机制**解决。 接下来说说常见的重传机制: - 超时重传 - 快速重传 - SACK - D-SACK ### 超时重传 重传机制的其中一个方式,就是在发送数据时,设定一个定时器,当超过指定的时间后,没有收到对方的 `ACK` 确认应答报文,就会重发该数据,也就是我们常说的**超时重传**。 TCP 会在以下两种情况发生超时重传: - 数据包丢失 - 确认应答丢失 ![超时重传的两种情况](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/5.jpg?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) > 超时时间应该设置为多少呢? 我们先来了解一下什么是 `RTT`(Round-Trip Time 往返时延),从下图我们就可以知道: ![RTT](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/6.jpg?) `RTT` 指的是**数据发送时刻到接收到确认的时刻的差值**,也就是包的往返时间。 超时重传时间是以 `RTO` (Retransmission Timeout 超时重传时间)表示。 假设在重传的情况下,超时时间 `RTO` 「较长或较短」时,会发生什么事情呢? ![超时时间较长与较短](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/7.jpg?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 上图中有两种超时时间不同的情况: - 当超时时间 **RTO 较大**时,重发就慢,丢了老半天才重发,没有效率,性能差; - 当超时时间 **RTO 较小**时,会导致可能并没有丢就重发,于是重发的就快,会增加网络拥塞,导致更多的超时,更多的超时导致更多的重发。 精确的测量超时时间 `RTO` 的值是非常重要的,这可让我们的重传机制更高效。 根据上述的两种情况,我们可以得知,**超时重传时间 RTO 的值应该略大于报文往返 RTT 的值**。 ![RTO 应略大于 RTT](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/8.jpg?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 至此,可能大家觉得超时重传时间 `RTO` 的值计算,也不是很复杂嘛。 好像就是在发送端发包时记下 `t0` ,然后接收端再把这个 `ack` 回来时再记一个 `t1`,于是 `RTT = t1 – t0`。没那么简单,**这只是一个采样,不能代表普遍情况**。 实际上「报文往返 RTT 的值」是经常变化的,因为我们的网络也是时常变化的。也就因为「报文往返 RTT 的值」是经常波动变化的,所以「超时重传时间 RTO 的值」应该是一个**动态变化的值**。 我们来看看 Linux 是如何计算 `RTO` 的呢? 估计往返时间,通常需要采样以下两个: - 需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个平滑 RTT 的值,而且这个值还是要不断变化的,因为网络状况不断地变化。 - 除了采样 RTT,还要采样 RTT 的波动范围,这样就避免如果 RTT 有一个大的波动的话,很难被发现的情况。 RFC6289 建议使用以下的公式计算 RTO: ![RFC6289 建议的 RTO 计算 ](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/9.jpg?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 其中 `SRTT` 是计算平滑的 RTT,`DevRTR` 是计算平滑的 RTT 与 最新 RTT 的差距。 在 Linux 下,**α = 0.125,β = 0.25, μ = 1,∂ = 4**。别问怎么来的,问就是大量实验中调出来的。 如果超时重发的数据,再次超时的时候,又需要重传的时候,TCP 的策略是**超时间隔加倍。** 也就是**每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送。** 超时触发重传存在的问题是,超时周期可能相对较长。那是不是可以有更快的方式呢? 于是就可以用「快速重传」机制来解决超时重发的时间等待。 ### 快速重传 TCP 还有另外一种**快速重传(Fast Retransmit)机制**,它**不以时间为驱动,而是以数据驱动重传**。 快速重传机制,是如何工作的呢?其实很简单,一图胜千言。 ![快速重传机制](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/10.jpg?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 在上图,发送方发出了 1,2,3,4,5 份数据: - 第一份 Seq1 先送到了,于是就 Ack 回 2; - 结果 Seq2 因为某些原因没收到,Seq3 到达了,于是还是 Ack 回 2; - 后面的 Seq4 和 Seq5 都到了,但还是 Ack 回 2,因为 Seq2 还是没有收到; - **发送端收到了三个 Ack = 2 的确认,知道了 Seq2 还没有收到,就会在定时器过期之前,重传丢失的 Seq2。** - 最后,收到了 Seq2,此时因为 Seq3,Seq4,Seq5 都收到了,于是 Ack 回 6。 所以,快速重传的工作方式是当收到三个相同的 ACK 报文时,会在定时器过期之前,重传丢失的报文段。 快速重传机制只解决了一个问题,就是超时时间的问题,但是它依然面临着另外一个问题。就是**重传的时候,是重传一个,还是重传所有的问题。** 举个例子,假设发送方发了 6 个数据,编号的顺序是 Seq1 ~ Seq6,但是 Seq2、Seq3 都丢失了,那么接收方在收到 Seq4、Seq5、Seq6 时,都是回复 ACK2 给发送方,但是发送方并不清楚这连续的 ACK2 是接收方收到哪个报文而回复的,那是选择重传 Seq2 一个报文,还是重传 Seq2 之后已发送的所有报文呢(Seq2、Seq3、Seq4、Seq5、Seq6)呢? - 如果只选择重传 Seq2 一个报文,那么重传的效率很低。因为对于丢失的 Seq3 报文,还得在后续收到三个重复的 ACK3 才能触发重传。 - 如果选择重传 Seq2 之后已发送的所有报文,虽然能同时重传已丢失的 Seq2 和 Seq3 报文,但是 Seq4、Seq5、Seq6 的报文是已经被接收过了,对于重传 Seq4 ~Seq6 折部分数据相当于做了一次无用功,浪费资源。 可以看到,不管是重传一个报文,还是重传已发送的报文,都存在问题。 为了解决不知道该重传哪些 TCP 报文,于是就有 `SACK` 方法。 ### SACK 方法 还有一种实现重传机制的方式叫:`SACK`( Selective Acknowledgment), **选择性确认**。 这种方式需要在 TCP 头部「选项」字段里加一个 `SACK` 的东西,它**可以将已收到的数据的信息发送给「发送方」**,这样发送方就可以知道哪些数据收到了,哪些数据没收到,知道了这些信息,就可以**只重传丢失的数据**。 如下图,发送方收到了三次同样的 ACK 确认报文,于是就会触发快速重发机制,通过 `SACK` 信息发现只有 `200~299` 这段数据丢失,则重发时,就只选择了这个 TCP 段进行重复。 ![选择性确认](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/11.jpg?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 如果要支持 `SACK`,必须双方都要支持。在 Linux 下,可以通过 `net.ipv4.tcp_sack` 参数打开这个功能(Linux 2.4 后默认打开)。 ### Duplicate SACK Duplicate SACK 又称 `D-SACK`,其主要**使用了 SACK 来告诉「发送方」有哪些数据被重复接收了。** 下面举例两个栗子,来说明 `D-SACK` 的作用。 *栗子一号:ACK 丢包* ![ACK 丢包](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/12.jpg?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) - 「接收方」发给「发送方」的两个 ACK 确认应答都丢失了,所以发送方超时后,重传第一个数据包(3000 ~ 3499) - **于是「接收方」发现数据是重复收到的,于是回了一个 SACK = 3000~3500**,告诉「发送方」3000~3500 的数据早已被接收了,因为 ACK 都到了 4000 了,已经意味着 4000 之前的所有数据都已收到,所以这个 SACK 就代表着 `D-SACK`。 - 这样「发送方」就知道了,数据没有丢,是「接收方」的 ACK 确认报文丢了。 *栗子二号:网络延时* ![网络延时](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/13.jpg?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) - 数据包(1000~1499)被网络延迟了,导致「发送方」没有收到 Ack 1500 的确认报文。 - 而后面报文到达的三个相同的 ACK 确认报文,就触发了快速重传机制,但是在重传后,被延迟的数据包(1000~1499)又到了「接收方」; - **所以「接收方」回了一个 SACK=1000~1500,因为 ACK 已经到了 3000,所以这个 SACK 是 D-SACK,表示收到了重复的包。** - 这样发送方就知道快速重传触发的原因不是发出去的包丢了,也不是因为回应的 ACK 包丢了,而是因为网络延迟了。 可见,`D-SACK` 有这么几个好处: 1. 可以让「发送方」知道,是发出去的包丢了,还是接收方回应的 ACK 包丢了; 2. 可以知道是不是「发送方」的数据包被网络延迟了; 3. 可以知道网络中是不是把「发送方」的数据包给复制了; 在 Linux 下可以通过 `net.ipv4.tcp_dsack` 参数开启/关闭这个功能(Linux 2.4 后默认打开)。 --- ## 滑动窗口 > 引入窗口概念的原因 我们都知道 TCP 是每发送一个数据,都要进行一次确认应答。当上一个数据包收到了应答了,再发送下一个。 这个模式就有点像我和你面对面聊天,你一句我一句。但这种方式的缺点是效率比较低的。 如果你说完一句话,我在处理其他事情,没有及时回复你,那你不是要干等着我做完其他事情后,我回复你,你才能说下一句话,很显然这不现实。 ![按数据包进行确认应答](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/14.jpg?) 所以,这样的传输方式有一个缺点:数据包的**往返时间越长,通信的效率就越低**。 为解决这个问题,TCP 引入了**窗口**这个概念。即使在往返时间较长的情况下,它也不会降低网络通信的效率。 那么有了窗口,就可以指定窗口大小,窗口大小就是指**无需等待确认应答,而可以继续发送数据的最大值**。 窗口的实现实际上是操作系统开辟的一个缓存空间,发送方主机在等到确认应答返回之前,必须在缓冲区中保留已发送的数据。如果按期收到确认应答,此时数据就可以从缓存区清除。 假设窗口大小为 `3` 个 TCP 段,那么发送方就可以「连续发送」 `3` 个 TCP 段,并且中途若有 ACK 丢失,可以通过「下一个确认应答进行确认」。如下图: ![用滑动窗口方式并行处理](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/15.jpg?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 图中的 ACK 600 确认应答报文丢失,也没关系,因为可以通过下一个确认应答进行确认,只要发送方收到了 ACK 700 确认应答,就意味着 700 之前的所有数据「接收方」都收到了。这个模式就叫**累计确认**或者**累计应答**。 > 窗口大小由哪一方决定? TCP 头里有一个字段叫 `Window`,也就是窗口大小。 **这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。** 所以,通常窗口的大小是由接收方的窗口大小来决定的。 发送方发送的数据大小不能超过接收方的窗口大小,否则接收方就无法正常接收到数据。 > 发送方的滑动窗口 我们先来看看发送方的窗口,下图就是发送方缓存的数据,根据处理的情况分成四个部分,其中深蓝色方框是发送窗口,紫色方框是可用窗口: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/16.jpg?) - #1 是已发送并收到 ACK 确认的数据:1~31 字节 - #2 是已发送但未收到 ACK 确认的数据:32~45 字节 - #3 是未发送但总大小在接收方处理范围内(接收方还有空间):46~51 字节 - #4 是未发送但总大小超过接收方处理范围(接收方没有空间):52 字节以后 在下图,当发送方把数据「全部」都一下发送出去后,可用窗口的大小就为 0 了,表明可用窗口耗尽,在没收到 ACK 确认之前是无法继续发送数据了。 ![可用窗口耗尽](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/17.jpg?) 在下图,当收到之前发送的数据 `32~36` 字节的 ACK 确认应答后,如果发送窗口的大小没有变化,则**滑动窗口往右边移动 5 个字节,因为有 5 个字节的数据被应答确认**,接下来 `52~56` 字节又变成了可用窗口,那么后续也就可以发送 `52~56` 这 5 个字节的数据了。 ![32 ~ 36 字节已确认](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/18.jpg) > 程序是如何表示发送方的四个部分的呢? TCP 滑动窗口方案使用三个指针来跟踪在四个传输类别中的每一个类别中的字节。其中两个指针是绝对指针(指特定的序列号),一个是相对指针(需要做偏移)。 ![SND.WND, SND.UN, SND.NXT](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/19.jpg?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) - `SND.WND`:表示发送窗口的大小(大小是由接收方指定的); - `SND.UNA`(*Send Unacknoleged*):是一个绝对指针,它指向的是已发送但未收到确认的第一个字节的序列号,也就是 #2 的第一个字节。 - `SND.NXT`:也是一个绝对指针,它指向未发送但可发送范围的第一个字节的序列号,也就是 #3 的第一个字节。 - 指向 #4 的第一个字节是个相对指针,它需要 `SND.UNA` 指针加上 `SND.WND` 大小的偏移量,就可以指向 #4 的第一个字节了。 那么可用窗口大小的计算就可以是: **可用窗口大小 = SND.WND -(SND.NXT - SND.UNA)** > 接收方的滑动窗口 接下来我们看看接收方的窗口,接收窗口相对简单一些,根据处理的情况划分成三个部分: - #1 + #2 是已成功接收并确认的数据(等待应用进程读取); - #3 是未收到数据但可以接收的数据; - #4 未收到数据并不可以接收的数据; ![接收窗口](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/20.jpg) 其中三个接收部分,使用两个指针进行划分: - `RCV.WND`:表示接收窗口的大小,它会通告给发送方。 - `RCV.NXT`:是一个指针,它指向期望从发送方发送来的下一个数据字节的序列号,也就是 #3 的第一个字节。 - 指向 #4 的第一个字节是个相对指针,它需要 `RCV.NXT` 指针加上 `RCV.WND` 大小的偏移量,就可以指向 #4 的第一个字节了。 > 接收窗口和发送窗口的大小是相等的吗? 并不是完全相等,接收窗口的大小是**约等于**发送窗口的大小的。 因为滑动窗口并不是一成不变的。比如,当接收方的应用进程读取数据的速度非常快的话,这样的话接收窗口可以很快的就空缺出来。那么新的接收窗口大小,是通过 TCP 报文中的 Windows 字段来告诉发送方。那么这个传输过程是存在时延的,所以接收窗口和发送窗口是约等于的关系。 --- ## 流量控制 发送方不能无脑的发数据给接收方,要考虑接收方处理能力。 如果一直无脑的发数据给对方,但对方处理不过来,那么就会导致触发重发机制,从而导致网络流量的无端的浪费。 为了解决这种现象发生,**TCP 提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量,这就是所谓的流量控制。** 下面举个栗子,为了简单起见,假设以下场景: - 客户端是接收方,服务端是发送方 - 假设接收窗口和发送窗口相同,都为 `200` - 假设两个设备在整个传输过程中都保持相同的窗口大小,不受外界影响 ![流量控制](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/21.png?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 根据上图的流量控制,说明下每个过程: 1. 客户端向服务端发送请求数据报文。这里要说明下,本次例子是把服务端作为发送方,所以没有画出服务端的接收窗口。 2. 服务端收到请求报文后,发送确认报文和 80 字节的数据,于是可用窗口 `Usable` 减少为 120 字节,同时 `SND.NXT` 指针也向右偏移 80 字节后,指向 321,**这意味着下次发送数据的时候,序列号是 321。** 3. 客户端收到 80 字节数据后,于是接收窗口往右移动 80 字节,`RCV.NXT` 也就指向 321,**这意味着客户端期望的下一个报文的序列号是 321**,接着发送确认报文给服务端。 4. 服务端再次发送了 120 字节数据,于是可用窗口耗尽为 0,服务端无法再继续发送数据。 5. 客户端收到 120 字节的数据后,于是接收窗口往右移动 120 字节,`RCV.NXT` 也就指向 441,接着发送确认报文给服务端。 6. 服务端收到对 80 字节数据的确认报文后,`SND.UNA` 指针往右偏移后指向 321,于是可用窗口 `Usable` 增大到 80。 7. 服务端收到对 120 字节数据的确认报文后,`SND.UNA` 指针往右偏移后指向 441,于是可用窗口 `Usable` 增大到 200。 8. 服务端可以继续发送了,于是发送了 160 字节的数据后,`SND.NXT` 指向 601,于是可用窗口 `Usable` 减少到 40。 9. 客户端收到 160 字节后,接收窗口往右移动了 160 字节,`RCV.NXT` 也就是指向了 601,接着发送确认报文给服务端。 10. 服务端收到对 160 字节数据的确认报文后,发送窗口往右移动了 160 字节,于是 `SND.UNA` 指针偏移了 160 后指向 601,可用窗口 `Usable` 也就增大至了 200。 ### 操作系统缓冲区与滑动窗口的关系 前面的流量控制例子,我们假定了发送窗口和接收窗口是不变的,但是实际上,发送窗口和接收窗口中所存放的字节数,都是放在操作系统内存缓冲区中的,而操作系统的缓冲区,会**被操作系统调整**。 当应用进程没办法及时读取缓冲区的内容时,也会对我们的缓冲区造成影响。 > 那操作系统的缓冲区,是如何影响发送窗口和接收窗口的呢? *我们先来看看第一个例子。* 当应用程序没有及时读取缓存时,发送窗口和接收窗口的变化。 考虑以下场景: - 客户端作为发送方,服务端作为接收方,发送窗口和接收窗口初始大小为 `360`; - 服务端非常的繁忙,当收到客户端的数据时,应用层不能及时读取数据。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/22.jpg?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 根据上图的流量控制,说明下每个过程: 1. 客户端发送 140 字节数据后,可用窗口变为 220(360 - 140)。 2. 服务端收到 140 字节数据,**但是服务端非常繁忙,应用进程只读取了 40 个字节,还有 100 字节占用着缓冲区,于是接收窗口收缩到了 260(360 - 100)**,最后发送确认信息时,将窗口大小通告给客户端。 3. 客户端收到确认和窗口通告报文后,发送窗口减少为 260。 4. 客户端发送 180 字节数据,此时可用窗口减少到 80。 5. 服务端收到 180 字节数据,**但是应用程序没有读取任何数据,这 180 字节直接就留在了缓冲区,于是接收窗口收缩到了 80(260 - 180)**,并在发送确认信息时,通过窗口大小给客户端。 6. 客户端收到确认和窗口通告报文后,发送窗口减少为 80。 7. 客户端发送 80 字节数据后,可用窗口耗尽。 8. 服务端收到 80 字节数据,**但是应用程序依然没有读取任何数据,这 80 字节留在了缓冲区,于是接收窗口收缩到了 0**,并在发送确认信息时,通过窗口大小给客户端。 9. 客户端收到确认和窗口通告报文后,发送窗口减少为 0。 可见最后窗口都收缩为 0 了,也就是发生了窗口关闭。当发送方可用窗口变为 0 时,发送方实际上会定时发送窗口探测报文,以便知道接收方的窗口是否发生了改变,这个内容后面会说,这里先简单提一下。 *我们先来看看第二个例子。* 当服务端系统资源非常紧张的时候,操作系统可能会直接减少了接收缓冲区大小,这时应用程序又无法及时读取缓存数据,那么这时候就有严重的事情发生了,会出现数据包丢失的现象。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/23.jpg?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 说明下每个过程: 1. 客户端发送 140 字节的数据,于是可用窗口减少到了 220。 2. **服务端因为现在非常的繁忙,操作系统于是就把接收缓存减少了 120 字节,当收到 140 字节数据后,又因为应用程序没有读取任何数据,所以 140 字节留在了缓冲区中,于是接收窗口大小从 360 收缩成了 100**,最后发送确认信息时,通告窗口大小给对方。 3. 此时客户端因为还没有收到服务端的通告窗口报文,所以不知道此时接收窗口收缩成了 100,客户端只会看自己的可用窗口还有 220,所以客户端就发送了 180 字节数据,于是可用窗口减少到 40。 4. 服务端收到了 180 字节数据时,**发现数据大小超过了接收窗口的大小,于是就把数据包丢失了。** 5. 客户端收到第 2 步时,服务端发送的确认报文和通告窗口报文,尝试减少发送窗口到 100,把窗口的右端向左收缩了 80,此时可用窗口的大小就会出现诡异的负值。 所以,如果发生了先减少缓存,再收缩窗口,就会出现丢包的现象。 **为了防止这种情况发生,TCP 规定是不允许同时减少缓存又收缩窗口的,而是采用先收缩窗口,过段时间再减少缓存,这样就可以避免了丢包情况。** ### 窗口关闭 在前面我们都看到了,TCP 通过让接收方指明希望从发送方接收的数据大小(窗口大小)来进行流量控制。 **如果窗口大小为 0 时,就会阻止发送方给接收方传递数据,直到窗口变为非 0 为止,这就是窗口关闭。** > 窗口关闭潜在的危险 接收方向发送方通告窗口大小时,是通过 `ACK` 报文来通告的。 那么,当发生窗口关闭时,接收方处理完数据后,会向发送方通告一个窗口非 0 的 ACK 报文,如果这个通告窗口的 ACK 报文在网络中丢失了,那麻烦就大了。 ![窗口关闭潜在的危险](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/24.jpg?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 这会导致发送方一直等待接收方的非 0 窗口通知,接收方也一直等待发送方的数据,如不采取措施,这种相互等待的过程,会造成了死锁的现象。 > TCP 是如何解决窗口关闭时,潜在的死锁现象呢? 为了解决这个问题,TCP 为每个连接设有一个持续定时器,**只要 TCP 连接一方收到对方的零窗口通知,就启动持续计时器。** 如果持续计时器超时,就会发送**窗口探测 ( Window probe ) 报文**,而对方在确认这个探测报文时,给出自己现在的接收窗口大小。 ![窗口探测](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/25.jpg?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) - 如果接收窗口仍然为 0,那么收到这个报文的一方就会重新启动持续计时器; - 如果接收窗口不是 0,那么死锁的局面就可以被打破了。 窗口探测的次数一般为 3 次,每次大约 30-60 秒(不同的实现可能会不一样)。如果 3 次过后接收窗口还是 0 的话,有的 TCP 实现就会发 `RST` 报文来中断连接。 ### 糊涂窗口综合症 如果接收方太忙了,来不及取走接收窗口里的数据,那么就会导致发送方的发送窗口越来越小。 到最后,**如果接收方腾出几个字节并告诉发送方现在有几个字节的窗口,而发送方会义无反顾地发送这几个字节,这就是糊涂窗口综合症**。 要知道,我们的 `TCP + IP` 头有 `40` 个字节,为了传输那几个字节的数据,要搭上这么大的开销,这太不经济了。 就好像一个可以承载 50 人的大巴车,每次来了一两个人,就直接发车。除非家里有矿的大巴司机,才敢这样玩,不然迟早破产。要解决这个问题也不难,大巴司机等乘客数量超过了 25 个,才认定可以发车。 现举个糊涂窗口综合症的栗子,考虑以下场景: 接收方的窗口大小是 360 字节,但接收方由于某些原因陷入困境,假设接收方的应用层读取的能力如下: - 接收方每接收 3 个字节,应用程序就只能从缓冲区中读取 1 个字节的数据; - 在下一个发送方的 TCP 段到达之前,应用程序还从缓冲区中读取了 40 个额外的字节; ![糊涂窗口综合症](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/26.png?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 每个过程的窗口大小的变化,在图中都描述的很清楚了,可以发现窗口不断减少了,并且发送的数据都是比较小的了。 所以,糊涂窗口综合症的现象是可以发生在发送方和接收方: - 接收方可以通告一个小的窗口 - 而发送方可以发送小数据 于是,要解决糊涂窗口综合症,就要同时解决上面两个问题就可以了: - 让接收方不通告小窗口给发送方 - 让发送方避免发送小数据 > 怎么让接收方不通告小窗口呢? 接收方通常的策略如下: 当「窗口大小」小于 min( MSS,缓存空间/2 ) ,也就是小于 MSS 与 1/2 缓存大小中的最小值时,就会向发送方通告窗口为 `0`,也就阻止了发送方再发数据过来。 等到接收方处理了一些数据后,窗口大小 >= MSS,或者接收方缓存空间有一半可以使用,就可以把窗口打开让发送方发送数据过来。 > 怎么让发送方避免发送小数据呢? 发送方通常的策略如下: 使用 Nagle 算法,该算法的思路是延时处理,只有满足下面两个条件中的任意一个条件,才可以发送数据: - 条件一:要等到可用窗口大小 >= `MSS` 并且 数据大小 >= `MSS`; - 条件二:收到之前发送数据的 `ack` 回包; 只要上面两个条件都不满足,发送方一直在囤积数据,直到满足上面的发送条件。 Nagle 伪代码如下: ```c if 有数据要发送 { if 可用窗口大小 >= MSS and 可发送的数据 >= MSS { 立刻发送MSS大小的数据 } else { if 有未确认的数据 { 将数据放入缓存等待接收ACK } else { 立刻发送数据 } } } ``` 注意,如果接收方不能满足「不通告小窗口给发送方」,那么即使开了 Nagle 算法,也无法避免糊涂窗口综合症,因为如果对端 ACK 回复很快的话(达到 Nagle 算法的条件二),Nagle 算法就不会拼接太多的数据包,这种情况下依然会有小数据包的传输,网络总体的利用率依然很低。 所以,**接收方得满足「不通告小窗口给发送方」+ 发送方开启 Nagle 算法,才能避免糊涂窗口综合症**。 另外,Nagle 算法默认是打开的,如果对于一些需要小数据包交互的场景的程序,比如,telnet 或 ssh 这样的交互性比较强的程序,则需要关闭 Nagle 算法。 可以在 Socket 设置 `TCP_NODELAY` 选项来关闭这个算法(关闭 Nagle 算法没有全局参数,需要根据每个应用自己的特点来关闭) ```c setsockopt(sock_fd, IPPROTO_TCP, TCP_NODELAY, (char *)&value, sizeof(int)); ``` --- ## 拥塞控制 > 为什么要有拥塞控制呀,不是有流量控制了吗? 前面的流量控制是避免「发送方」的数据填满「接收方」的缓存,但是并不知道网络的中发生了什么。 一般来说,计算机网络都处在一个共享的环境。因此也有可能会因为其他主机之间的通信使得网络拥堵。 **在网络出现拥堵时,如果继续发送大量数据包,可能会导致数据包时延、丢失等,这时 TCP 就会重传数据,但是一重传就会导致网络的负担更重,于是会导致更大的延迟以及更多的丢包,这个情况就会进入恶性循环被不断地放大……** 所以,TCP 不能忽略网络上发生的事,它被设计成一个无私的协议,当网络发生拥塞时,TCP 会自我牺牲,降低发送的数据量。 于是,就有了**拥塞控制**,控制的目的就是**避免「发送方」的数据填满整个网络。** 为了在「发送方」调节所要发送数据的量,定义了一个叫做「**拥塞窗口**」的概念。 > 什么是拥塞窗口?和发送窗口有什么关系呢? **拥塞窗口 cwnd**是发送方维护的一个的状态变量,它会根据**网络的拥塞程度动态变化的**。 我们在前面提到过发送窗口 `swnd` 和接收窗口 `rwnd` 是约等于的关系,那么由于加入了拥塞窗口的概念后,此时发送窗口的值是 swnd = min(cwnd, rwnd),也就是拥塞窗口和接收窗口中的最小值。 拥塞窗口 `cwnd` 变化的规则: - 只要网络中没有出现拥塞,`cwnd` 就会增大; - 但网络中出现了拥塞,`cwnd` 就减少; > 那么怎么知道当前网络是否出现了拥塞呢? 其实只要「发送方」没有在规定时间内接收到 ACK 应答报文,也就是**发生了超时重传,就会认为网络出现了拥塞。** > 拥塞控制有哪些控制算法? 拥塞控制主要是四个算法: - 慢启动 - 拥塞避免 - 拥塞发生 - 快速恢复 ### 慢启动 TCP 在刚建立连接完成后,首先是有个慢启动的过程,这个慢启动的意思就是一点一点的提高发送数据包的数量,如果一上来就发大量的数据,这不是给网络添堵吗? 慢启动的算法记住一个规则就行:**当发送方每收到一个 ACK,拥塞窗口 cwnd 的大小就会加 1。** 这里假定拥塞窗口 `cwnd` 和发送窗口 `swnd` 相等,下面举个栗子: - 连接建立完成后,一开始初始化 `cwnd = 1`,表示可以传一个 `MSS` 大小的数据。 - 当收到一个 ACK 确认应答后,cwnd 增加 1,于是一次能够发送 2 个 - 当收到 2 个的 ACK 确认应答后,cwnd 增加 2,于是就可以比之前多发 2 个,所以这一次能够发送 4 个 - 当这 4 个的 ACK 确认到来的时候,每个确认 cwnd 增加 1,4 个确认 cwnd 增加 4,于是就可以比之前多发 4 个,所以这一次能够发送 8 个。 慢启动算法的变化过程如下图: ![慢启动算法](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/27.jpg?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 可以看出慢启动算法,发包的个数是**指数性的增长**。 > 那慢启动涨到什么时候是个头呢? 有一个叫慢启动门限 `ssthresh` (slow start threshold)状态变量。 - 当 `cwnd` < `ssthresh` 时,使用慢启动算法。 - 当 `cwnd` >= `ssthresh` 时,就会使用「拥塞避免算法」。 ### 拥塞避免算法 前面说道,当拥塞窗口 `cwnd` 「超过」慢启动门限 `ssthresh` 就会进入拥塞避免算法。 一般来说 `ssthresh` 的大小是 `65535` 字节。 那么进入拥塞避免算法后,它的规则是:**每当收到一个 ACK 时,cwnd 增加 1/cwnd。** 接上前面的慢启动的栗子,现假定 `ssthresh` 为 `8`: - 当 8 个 ACK 应答确认到来时,每个确认增加 1/8,8 个 ACK 确认 cwnd 一共增加 1,于是这一次能够发送 9 个 `MSS` 大小的数据,变成了**线性增长。** 拥塞避免算法的变化过程如下图: ![拥塞避免](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/28.jpg?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 所以,我们可以发现,拥塞避免算法就是将原本慢启动算法的指数增长变成了线性增长,还是增长阶段,但是增长速度缓慢了一些。 就这么一直增长着后,网络就会慢慢进入了拥塞的状况了,于是就会出现丢包现象,这时就需要对丢失的数据包进行重传。 当触发了重传机制,也就进入了「拥塞发生算法」。 ### 拥塞发生 当网络出现拥塞,也就是会发生数据包重传,重传机制主要有两种: - 超时重传 - 快速重传 这两种使用的拥塞发生算法是不同的,接下来分别来说说。 > 发生超时重传的拥塞发生算法 当发生了「超时重传」,则就会使用拥塞发生算法。 这个时候,ssthresh 和 cwnd 的值会发生变化: - `ssthresh` 设为 `cwnd/2`, - `cwnd` 重置为 `1` (是恢复为 cwnd 初始化值,我这里假定 cwnd 初始化值 1) > 怎么查看系统的 cwnd 初始化值? Linux 针对每一个 TCP 连接的 cwnd 初始化值是 10,也就是 10 个 MSS,我们可以用 ss -nli 命令查看每一个 TCP 连接的 cwnd 初始化值,如下图 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/cwnd.png) 拥塞发生算法的变化如下图: ![拥塞发送 —— 超时重传](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/29.jpg?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 接着,就重新开始慢启动,慢启动是会突然减少数据流的。这真是一旦「超时重传」,马上回到解放前。但是这种方式太激进了,反应也很强烈,会造成网络卡顿。 就好像本来在秋名山高速漂移着,突然来个紧急刹车,轮胎受得了吗。。。 > 发生快速重传的拥塞发生算法 还有更好的方式,前面我们讲过「快速重传算法」。当接收方发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速地重传,不必等待超时再重传。 TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,则 `ssthresh` 和 `cwnd` 变化如下: - `cwnd = cwnd/2` ,也就是设置为原来的一半; - `ssthresh = cwnd`; - 进入快速恢复算法 ### 快速恢复 快速重传和快速恢复算法一般同时使用,快速恢复算法是认为,你还能收到 3 个重复 ACK 说明网络也不那么糟糕,所以没有必要像 `RTO` 超时那么强烈。 正如前面所说,进入快速恢复之前,`cwnd` 和 `ssthresh` 已被更新了: - `cwnd = cwnd/2` ,也就是设置为原来的一半; - `ssthresh = cwnd`; 然后,进入快速恢复算法如下: - 拥塞窗口 `cwnd = ssthresh + 3` (3 的意思是确认有 3 个数据包被收到了); - 重传丢失的数据包; - 如果再收到重复的 ACK,那么 cwnd 增加 1; - 如果收到新数据的 ACK 后,把 cwnd 设置为第一步中的 ssthresh 的值,原因是该 ACK 确认了新的数据,说明从 duplicated ACK 时的数据都已收到,该恢复过程已经结束,可以回到恢复之前的状态了,也即再次进入拥塞避免状态; 快速恢复算法的变化过程如下图: ![快速重传和快速恢复](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/网络/拥塞发生-快速重传.drawio.png?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) 也就是没有像「超时重传」一夜回到解放前,而是还在比较高的值,后续呈线性增长。 ::: tip 很多人问题,快速恢复算法过程中,为什么收到新的数据后,cwnd 设置回了 ssthresh? 我在评论区看到@[muum641651](https://github.com/muum641651)回答的不错,这里贴出来给大家。 我的理解是: 1. 在快速恢复的过程中,首先 ssthresh = cwnd/2,然后 cwnd = ssthresh + 3,表示网络可能出现了阻塞,所以需要减小 cwnd 以避免,加 3 代表快速重传时已经确认接收到了 3 个重复的数据包; 2. 随后继续重传丢失的数据包,如果再收到重复的 ACK,那么 cwnd 增加 1。加 1 代表每个收到的重复的 ACK 包,都已经离开了网络。这个过程的目的是尽快将丢失的数据包发给目标。 3. 如果收到新数据的 ACK 后,把 cwnd 设置为第一步中的 ssthresh 的值,恢复过程结束。 **首先,快速恢复是拥塞发生后慢启动的优化,其首要目的仍然是降低 cwnd 来减缓拥塞,所以必然会出现 cwnd 从大到小的改变。** **其次,过程 2(cwnd 逐渐加 1)的存在是为了尽快将丢失的数据包发给目标,从而解决拥塞的根本问题(三次相同的 ACK 导致的快速重传),所以这一过程中 cwnd 反而是逐渐增大的。** ::: --- 参考资料: [1] 趣谈网络协议专栏。刘超。极客时间 [2] Web 协议详解与抓包实战专栏。陶辉。极客时间 [3] TCP/IP 详解 卷 1:协议。范建华 译。机械工业出版社 [4] 图解 TCP/IP.竹下隆史.人民邮电出版社 [5] The TCP/IP Guide.Charles M. Kozierok. [6] TCP 那些事(上).陈皓。酷壳博客。 https://coolshell.cn/articles/11564.html [7] TCP 那些事(下).陈皓。酷壳博客.https://coolshell.cn/articles/11609.html --- ## 读者问答 > 读者问:“整个看完收获很大,下面是我的一些疑问(稍后 > 会去确认): > 1.拥塞避免这一段,蓝色字体:每当收到一个 > ACK 时,cwnd 增加 1/cwnd。是否应该是 > 1/ssthresh?否则不符合线性增长。 > 2.快速重传的拥塞发生算法,步骤一和步骤 2 是 > 否写反了?否则快速恢复算法中最后一步【如果 > 收到新数据的 ACK 后,设置 cwnd 为 > ssthresh,接看就进入了拥塞避免算法】没什么 > 意义。 > 3.对 ssthresh 的变化介绍的比较含糊。” 1. 是 1/cwnd,你可以在 RFC2581 第 3 页找到答案 2. 没有写反,同样你可以在 RFC2581 第 5 页找到答案 3. ssthresh 就是慢启动门限,我觉得 ssthresh 我已经说的很清楚了,当然你可以找其他资料补充你的疑惑 --- 是吧?TCP 巨复杂吧?看完很累吧? 但这还只是 TCP 冰山一脚,它的更深处就由你们自己去探索啦。 **小林是专为大家图解的工具人,Goodbye,我们下次见!** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png?image_process=watermark,text_5YWs5LyX5Y-377ya5bCP5p6XY29kaW5n,type_ZnpsdHpoaw,x_10,y_10,g_se,size_20,color_0000CD,t_70,fill_0) ================================================ FILE: network/3_tcp/tcp_http_keepalive.md ================================================ # 4.15 TCP Keepalive 和 HTTP Keep-Alive 是一个东西吗? 大家好,我是小林。 之前有读者问了我这么个问题: ![在这里插入图片描述](https://img-blog.csdnimg.cn/20210715090027883.jpg?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODI3Njc0,size_16,color_FFFFFF,t_70) 大致问题是,**TCP 的 Keepalive 和 HTTP 的 Keep-Alive 是一个东西吗?** 这是个好问题,应该有不少人都会搞混,因为这两个东西看上去太像了,很容易误以为是同一个东西。 事实上,**这两个完全是两样不同东西**,实现的层面也不同: - HTTP 的 Keep-Alive,是由**应用层(用户态)** 实现的,称为 HTTP 长连接; - TCP 的 Keepalive,是由 **TCP 层(内核态)** 实现的,称为 TCP 保活机制; 接下来,分别说说它们。 ## HTTP 的 Keep-Alive HTTP 协议采用的是「请求 - 应答」的模式,也就是客户端发起了请求,服务端才会返回响应,一来一回这样子。 ![请求 - 应答](https://img-blog.csdnimg.cn/img_convert/6c062074058f40ae65ed722e2d082a90.png) 由于 HTTP 是基于 TCP 传输协议实现的,客户端与服务端要进行 HTTP 通信前,需要先建立 TCP 连接,然后客户端发送 HTTP 请求,服务端收到后就返回响应,至此「请求 - 应答」的模式就完成了,随后就会释放 TCP 连接。 ![一个 HTTP 请求](https://img-blog.csdnimg.cn/img_convert/9acbaebbbe07cc870858a350052d9c87.png) 如果每次请求都要经历这样的过程:建立 TCP -> 请求资源 -> 响应资源 -> 释放连接,那么此方式就是 **HTTP 短连接**,如下图: ![HTTP 短连接](https://img-blog.csdnimg.cn/img_convert/d6f6757c02e3afbf113d1048c937f8ee.png) 这样实在太累人了,一次连接只能请求一次资源。 能不能在第一个 HTTP 请求完后,先不断开 TCP 连接,让后续的 HTTP 请求继续使用此连接? 当然可以,HTTP 的 Keep-Alive 就是实现了这个功能,可以使用同一个 TCP 连接来发送和接收多个 HTTP 请求/应答,避免了连接建立和释放的开销,这个方法称为 **HTTP 长连接**。 ![HTTP 长连接](https://img-blog.csdnimg.cn/img_convert/d2b20d1cc03936332adb2a68512eb167.png) HTTP 长连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。 怎么才能使用 HTTP 的 Keep-Alive 功能? 在 HTTP 1.0 中默认是关闭的,如果浏览器要开启 Keep-Alive,它必须在请求的包头中添加: ```plain Connection: Keep-Alive ``` 然后当服务器收到请求,作出回应的时候,它也添加一个头在响应中: ```plain Connection: Keep-Alive ``` 这样做,连接就不会中断,而是保持连接。当客户端发送另一个请求时,它会使用同一个连接。这一直继续到客户端或服务器端提出断开连接。 **从 HTTP 1.1 开始,就默认是开启了 Keep-Alive**,如果要关闭 Keep-Alive,需要在 HTTP 请求的包头里添加: ```plain Connection:close ``` 现在大多数浏览器都默认是使用 HTTP/1.1,所以 Keep-Alive 都是默认打开的。一旦客户端和服务端达成协议,那么长连接就建立好了。 HTTP 长连接不仅仅减少了 TCP 连接资源的开销,而且这给 **HTTP 流水线**技术提供了可实现的基础。 所谓的 HTTP 流水线,是**客户端可以先一次性发送多个请求,而在发送过程中不需先等待服务器的回应**,可以减少整体的响应时间。 举例来说,客户端需要请求两个资源。以前的做法是,在同一个 TCP 连接里面,先发送 A 请求,然后等待服务器做出回应,收到后再发出 B 请求。HTTP 流水线机制则允许客户端同时发出 A 请求和 B 请求。 ![右边为 HTTP 流水线机制](https://img-blog.csdnimg.cn/img_convert/b3fa409edd8aa1dea830af2a69fc8a31.png) 但是**服务器还是按照顺序响应**,先回应 A 请求,完成后再回应 B 请求。 而且要等服务器响应完客户端第一批发送的请求后,客户端才能发出下一批的请求,也就说如果服务器响应的过程发生了阻塞,那么客户端就无法发出下一批的请求,此时就造成了「队头阻塞」的问题。 可能有的同学会问,如果使用了 HTTP 长连接,如果客户端完成一个 HTTP 请求后,就不再发起新的请求,此时这个 TCP 连接一直占用着不是挺浪费资源的吗? 对没错,所以为了避免资源浪费的情况,web 服务软件一般都会提供 `keepalive_timeout` 参数,用来指定 HTTP 长连接的超时时间。 比如设置了 HTTP 长连接的超时时间是 60 秒,web 服务软件就会**启动一个定时器**,如果客户端在完后一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,**定时器的时间一到,就会触发回调函数来释放该连接。** ![HTTP 长连接超时](https://img-blog.csdnimg.cn/img_convert/7e995ecb2e42941342f97256707496c9.png) ## TCP 的 Keepalive TCP 的 Keepalive 这东西其实就是 **TCP 的保活机制**,它的工作原理我之前的文章写过,这里就直接贴下以前的内容。 如果两端的 TCP 连接一直没有数据交互,达到了触发 TCP 保活机制的条件,那么内核里的 TCP 协议栈就会发送探测报文。 - 如果对端程序是正常工作的。当 TCP 保活的探测报文发送给对端,对端会正常响应,这样 **TCP 保活时间会被重置**,等待下一个 TCP 保活时间的到来。 - 如果对端主机宕机(*注意不是进程崩溃,进程崩溃后操作系统在回收进程资源的时候,会发送 FIN 报文,而主机宕机则是无法感知的,所以需要 TCP 保活机制来探测对方是不是发生了主机宕机*),或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,**TCP 会报告该 TCP 连接已经死亡**。 所以,TCP 保活机制可以在双方没有数据交互的情况,通过探测报文,来确定对方的 TCP 连接是否存活,这个工作是在内核完成的。 ![TCP 保活机制](https://img-blog.csdnimg.cn/img_convert/87e138ae9f2438c8f4e2c9c46ec40b95.png) 注意,应用程序若想使用 TCP 保活机制需要通过 socket 接口设置 `SO_KEEPALIVE` 选项才能够生效,如果没有设置,那么就无法使用 TCP 保活机制。 ## 总结 HTTP 的 Keep-Alive 也叫 HTTP 长连接,该功能是由「应用程序」实现的,可以使得用同一个 TCP 连接来发送和接收多个 HTTP 请求/应答,减少了 HTTP 短连接带来的多次 TCP 连接建立和释放的开销。 TCP 的 Keepalive 也叫 TCP 保活机制,该功能是由「内核」实现的,当客户端和服务端长达一定时间没有进行数据交互时,内核为了确保该连接是否还有效,就会发送探测报文,来检测对方是否还在线,然后来决定是否要关闭该连接。 --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/3_tcp/tcp_interview.md ================================================ # 4.1 TCP 三次握手与四次挥手面试题 大家好,我是小林。 **任 TCP 虐我千百遍,我仍待 TCP 如初恋。** 巨巨巨巨长的提纲,发车!发车! ![](https://img-blog.csdnimg.cn/1310bf5ed78e4c8186481c47719e0793.png) > PS:本次文章不涉及 TCP 流量控制、拥塞控制、可靠性传输等方面知识,这些知识在这篇:[你还在为 TCP 重传、滑动窗口、流量控制、拥塞控制发愁吗?看完图解就不愁了](https://mp.weixin.qq.com/s/Tc09ovdNacOtnMOMeRc_uA) --- ## TCP 基本认识 ### TCP 头格式有哪些? 我们先来看看 TCP 头的格式,标注颜色的表示与本文关联比较大的字段,其他字段不做详细阐述。 ![TCP 头格式](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzYuanBn?x-oss-process=image/format,png) **序列号**:在建立连接时由计算机生成的随机数作为其初始值,通过 SYN 包传给接收端主机,每发送一次数据,就「累加」一次该「数据字节数」的大小。**用来解决网络包乱序问题。** **确认应答号**:指下一次「期望」收到的数据的序列号,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收。**用来解决丢包的问题。** **控制位:** - *ACK*:该位为 `1` 时,「确认应答」的字段变为有效,TCP 规定除了最初建立连接时的 `SYN` 包之外该位必须设置为 `1` 。 - *RST*:该位为 `1` 时,表示 TCP 连接中出现异常必须强制断开连接。 - *SYN*:该位为 `1` 时,表示希望建立连接,并在其「序列号」的字段进行序列号初始值的设定。 - *FIN*:该位为 `1` 时,表示今后不会再有数据发送,希望断开连接。当通信结束希望断开连接时,通信双方的主机之间就可以相互交换 `FIN` 位为 1 的 TCP 段。 ### 为什么需要 TCP 协议?TCP 工作在哪一层? `IP` 层是「不可靠」的,它不保证网络包的交付、不保证网络包的按序交付、也不保证网络包中的数据的完整性。 ![OSI 参考模型与 TCP/IP 的关系](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzcuanBn?x-oss-process=image/format,png) 如果需要保障网络数据包的可靠性,那么就需要由上层(传输层)的 `TCP` 协议来负责。 因为 TCP 是一个工作在**传输层**的**可靠**数据传输的服务,它能确保接收端接收的网络包是**无损坏、无间隔、非冗余和按序的。** ### 什么是 TCP? TCP 是**面向连接的、可靠的、基于字节流**的传输层通信协议。 ![](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzguanBn?x-oss-process=image/format,png) - **面向连接**:一定是「一对一」才能连接,不能像 UDP 协议可以一个主机同时向多个主机发送消息,也就是一对多是无法做到的; - **可靠的**:无论网络链路中出现了怎样的链路变化,TCP 都可以保证一个报文一定能够到达接收端; - **字节流**:用户消息通过 TCP 协议传输时,消息可能会被操作系统「分组」成多个的 TCP 报文,如果接收方的程序如果不知道「消息的边界」,是无法读出一个有效的用户消息的。并且 TCP 报文是「有序的」,当「前一个」TCP 报文没有收到的时候,即使它先收到了后面的 TCP 报文,那么也不能扔给应用层去处理,同时对「重复」的 TCP 报文会自动丢弃。 ### 什么是 TCP 连接? 我们来看看 RFC 793 是如何定义「连接」的: *Connections: The reliability and flow control mechanisms described above require that TCPs initialize and maintain certain status information for each data stream. The combination of this information, including sockets, sequence numbers, and window sizes, is called a connection.* 简单来说就是,**用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括 Socket、序列号和窗口大小称为连接。** ![](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzkuanBn?x-oss-process=image/format,png) 所以我们可以知道,建立一个 TCP 连接是需要客户端与服务端达成上述三个信息的共识。 - **Socket**:由 IP 地址和端口号组成 - **序列号**:用来解决乱序问题等 - **窗口大小**:用来做流量控制 ### 如何唯一确定一个 TCP 连接呢? TCP 四元组可以唯一的确定一个连接,四元组包括如下: - 源地址 - 源端口 - 目的地址 - 目的端口 ![TCP 四元组](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzEwLmpwZw?x-oss-process=image/format,png) 源地址和目的地址的字段(32 位)是在 IP 头部中,作用是通过 IP 协议发送报文给对方主机。 源端口和目的端口的字段(16 位)是在 TCP 头部中,作用是告诉 TCP 协议应该把报文发给哪个进程。 > 有一个 IP 的服务端监听了一个端口,它的 TCP 的最大连接数是多少? 服务端通常固定在某个本地端口上监听,等待客户端的连接请求。 因此,客户端 IP 和端口是可变的,其理论值计算公式如下: ![](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzExLmpwZw?x-oss-process=image/format,png) 对 IPv4,客户端的 IP 数最多为 `2` 的 `32` 次方,客户端的端口数最多为 `2` 的 `16` 次方,也就是服务端单机最大 TCP 连接数,约为 `2` 的 `48` 次方。 当然,服务端最大并发 TCP 连接数远不能达到理论上限,会受以下因素影响: - **文件描述符限制**,每个 TCP 连接都是一个文件,如果文件描述符被占满了,会发生 Too many open files。Linux 对可打开的文件描述符的数量分别作了三个方面的限制: - **系统级**:当前系统可打开的最大数量,通过 `cat /proc/sys/fs/file-max` 查看; - **用户级**:指定用户可打开的最大数量,通过 `cat /etc/security/limits.conf` 查看; - **进程级**:单个进程可打开的最大数量,通过 `cat /proc/sys/fs/nr_open` 查看; - **内存限制**,每个 TCP 连接都要占用一定内存,操作系统的内存是有限的,如果内存资源被占满后,会发生 OOM。 ### UDP 和 TCP 有什么区别呢?分别的应用场景是? UDP 不提供复杂的控制机制,利用 IP 提供面向「无连接」的通信服务。 UDP 协议真的非常简单,头部只有 `8` 个字节(64 位),UDP 的头部格式如下: ![UDP 头部格式](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzEyLmpwZw?x-oss-process=image/format,png) - 目标和源端口:主要是告诉 UDP 协议应该把报文发给哪个进程。 - 包长度:该字段保存了 UDP 首部的长度跟数据的长度之和。 - 校验和:校验和是为了提供可靠的 UDP 首部和数据而设计,防止收到在网络传输中受损的 UDP 包。 **TCP 和 UDP 区别:** *1. 连接* - TCP 是面向连接的传输层协议,传输数据前先要建立连接。 - UDP 是不需要连接,即刻传输数据。 *2. 服务对象* - TCP 是一对一的两点服务,即一条连接只有两个端点。 - UDP 支持一对一、一对多、多对多的交互通信 *3. 可靠性* - TCP 是可靠交付数据的,数据可以无差错、不丢失、不重复、按序到达。 - UDP 是尽最大努力交付,不保证可靠交付数据。但是我们可以基于 UDP 传输协议实现一个可靠的传输协议,比如 QUIC 协议,具体可以参见这篇文章:[如何基于 UDP 协议实现可靠传输?](https://xiaolincoding.com/network/3_tcp/quic.html) *4. 拥塞控制、流量控制* - TCP 有拥塞控制和流量控制机制,保证数据传输的安全性。 - UDP 则没有,即使网络非常拥堵了,也不会影响 UDP 的发送速率。 *5. 首部开销* - TCP 首部长度较长,会有一定的开销,首部在没有使用「选项」字段时是 `20` 个字节,如果使用了「选项」字段则会变长的。 - UDP 首部只有 8 个字节,并且是固定不变的,开销较小。 *6. 传输方式* - TCP 是流式传输,没有边界,但保证顺序和可靠。 - UDP 是一个包一个包的发送,是有边界的,但可能会丢包和乱序。 *7. 分片不同* - TCP 的数据大小如果大于 MSS 大小,则会在传输层进行分片,目标主机收到后,也同样在传输层组装 TCP 数据包,如果中途丢失了一个分片,只需要传输丢失的这个分片。 - UDP 的数据大小如果大于 MTU 大小,则会在 IP 层进行分片,目标主机收到后,在 IP 层组装完数据,接着再传给传输层。 **TCP 和 UDP 应用场景:** 由于 TCP 是面向连接,能保证数据的可靠性交付,因此经常用于: - `FTP` 文件传输; - HTTP / HTTPS; 由于 UDP 面向无连接,它可以随时发送数据,再加上 UDP 本身的处理既简单又高效,因此经常用于: - 包总量较少的通信,如 `DNS` 、`SNMP` 等; - 视频、音频等多媒体通信; - 广播通信; > 为什么 UDP 头部没有「首部长度」字段,而 TCP 头部有「首部长度」字段呢? 原因是 TCP 有**可变长**的「选项」字段,而 UDP 头部长度则是**不会变化**的,无需多一个字段去记录 UDP 的首部长度。 > 为什么 UDP 头部有「包长度」字段,而 TCP 头部则没有「包长度」字段呢? 先说说 TCP 是如何计算负载数据长度: ![](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzEzLmpwZw?x-oss-process=image/format,png) 其中 IP 总长度 和 IP 首部长度,在 IP 首部格式是已知的。TCP 首部长度,则是在 TCP 首部格式已知的,所以就可以求得 TCP 数据的长度。 大家这时就奇怪了问:“UDP 也是基于 IP 层的呀,那 UDP 的数据长度也可以通过这个公式计算呀?为何还要有「包长度」呢?” 这么一问,确实感觉 UDP 的「包长度」是冗余的。 我查阅了很多资料,我觉得有两个比较靠谱的说法: - 第一种说法:因为为了网络设备硬件设计和处理方便,首部长度需要是 `4` 字节的整数倍。如果去掉 UDP 的「包长度」字段,那 UDP 首部长度就不是 `4` 字节的整数倍了,所以我觉得这可能是为了补全 UDP 首部长度是 `4` 字节的整数倍,才补充了「包长度」字段。 - 第二种说法:如今的 UDP 协议是基于 IP 协议发展的,而当年可能并非如此,依赖的可能是别的不提供自身报文长度或首部长度的网络层协议,因此 UDP 报文首部需要有长度字段以供计算。 ### TCP 和 UDP 可以使用同一个端口吗? 答案:**可以的**。 在数据链路层中,通过 MAC 地址来寻找局域网中的主机。在网际层中,通过 IP 地址来寻找网络中互连的主机或路由器。在传输层中,需要通过端口进行寻址,来识别同一计算机中同时通信的不同应用程序。 所以,传输层的「端口号」的作用,是为了区分同一个主机上不同应用程序的数据包。 传输层有两个传输协议分别是 TCP 和 UDP,在内核中是两个完全独立的软件模块。 当主机收到数据包后,可以在 IP 包头的「协议号」字段知道该数据包是 TCP/UDP,所以可以根据这个信息确定送给哪个模块(TCP/UDP)处理,送给 TCP/UDP 模块的报文根据「端口号」确定送给哪个应用程序处理。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/port/tcp%E5%92%8Cudp%E6%A8%A1%E5%9D%97.jpeg) 因此,TCP/UDP 各自的端口号也相互独立,如 TCP 有一个 80 号端口,UDP 也可以有一个 80 号端口,二者并不冲突。 关于端口的知识点,还是挺多可以讲的,比如还可以牵扯到这几个问题: - 多个 TCP 服务进程可以同时绑定同一个端口吗? - 重启 TCP 服务进程时,为什么会出现“Address in use”的报错信息?又该怎么避免? - 客户端的端口可以重复使用吗? - 客户端 TCP 连接 TIME_WAIT 状态过多,会导致端口资源耗尽而无法建立新的连接吗? 上面这些问题,可以看这篇文章:[TCP 和 UDP 可以使用同一个端口吗?](https://xiaolincoding.com/network/3_tcp/port.html) ## TCP 连接建立 ### TCP 三次握手过程是怎样的? TCP 是面向连接的协议,所以使用 TCP 前必须先建立连接,而**建立连接是通过三次握手来进行的**。三次握手的过程如下图: ![TCP 三次握手](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4/%E7%BD%91%E7%BB%9C/TCP%E4%B8%89%E6%AC%A1%E6%8F%A1%E6%89%8B.drawio.png) - 一开始,客户端和服务端都处于 `CLOSE` 状态。先是服务端主动监听某个端口,处于 `LISTEN` 状态 ![第一个报文 —— SYN 报文](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzE1LmpwZw?x-oss-process=image/format,png) - 客户端会随机初始化序号(`client_isn`),将此序号置于 TCP 首部的「序号」字段中,同时把 `SYN` 标志位置为 `1`,表示 `SYN` 报文。接着把第一个 SYN 报文发送给服务端,表示向服务端发起连接,该报文不包含应用层数据,之后客户端处于 `SYN-SENT` 状态。 ![第二个报文 —— SYN + ACK 报文](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzE2LmpwZw?x-oss-process=image/format,png) - 服务端收到客户端的 `SYN` 报文后,首先服务端也随机初始化自己的序号(`server_isn`),将此序号填入 TCP 首部的「序号」字段中,其次把 TCP 首部的「确认应答号」字段填入 `client_isn + 1`, 接着把 `SYN` 和 `ACK` 标志位置为 `1`。最后把该报文发给客户端,该报文也不包含应用层数据,之后服务端处于 `SYN-RCVD` 状态。 ![第三个报文 —— ACK 报文](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzE3LmpwZw?x-oss-process=image/format,png) - 客户端收到服务端报文后,还要向服务端回应最后一个应答报文,首先该应答报文 TCP 首部 `ACK` 标志位置为 `1` ,其次「确认应答号」字段填入 `server_isn + 1` ,最后把报文发送给服务端,这次报文可以携带客户到服务端的数据,之后客户端处于 `ESTABLISHED` 状态。 - 服务端收到客户端的应答报文后,也进入 `ESTABLISHED` 状态。 从上面的过程可以发现**第三次握手是可以携带数据的,前两次握手是不可以携带数据的**,这也是面试常问的题。 一旦完成三次握手,双方都处于 `ESTABLISHED` 状态,此时连接就已建立完成,客户端和服务端就可以相互发送数据了。 ### 如何在 Linux 系统中查看 TCP 状态? TCP 的连接状态查看,在 Linux 可以通过 `netstat -napt` 命令查看。 ![TCP 连接状态查看](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzE4LmpwZw?x-oss-process=image/format,png) ### 为什么是三次握手?不是两次、四次? 相信大家比较常回答的是:“因为三次握手才能保证双方具有接收和发送的能力。” 这回答是没问题,但这回答是片面的,并没有说出主要的原因。 在前面我们知道了什么是 **TCP 连接**: - 用于保证可靠性和流量控制维护的某些状态信息,这些信息的组合,包括 **Socket、序列号和窗口大小**称为连接。 所以,重要的是**为什么三次握手才可以初始化 Socket、序列号和窗口大小并建立 TCP 连接。** 接下来,以三个方面分析三次握手的原因: - 三次握手才可以阻止重复历史连接的初始化(主要原因) - 三次握手才可以同步双方的初始序列号 - 三次握手才可以避免资源浪费 *原因一:避免历史连接* 我们来看看 RFC 793 指出的 TCP 连接使用三次握手的**首要原因**: *The principle reason for the three-way handshake is to prevent old duplicate connection initiations from causing confusion.* 简单来说,三次握手的**首要原因是为了防止旧的重复连接初始化造成混乱。** 我们考虑一个场景,客户端先发送了 SYN(seq = 90)报文,然后客户端宕机了,而且这个 SYN 报文还被网络阻塞了,服务端并没有收到,接着客户端重启后,又重新向服务端建立连接,发送了 SYN(seq = 100)报文(*注意!不是重传 SYN,重传的 SYN 的序列号是一样的*)。 看看三次握手是如何阻止历史连接的: ![三次握手避免历史连接](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzE5LmpwZw?x-oss-process=image/format,png) 客户端连续发送多次 SYN(都是同一个四元组)建立连接的报文,在**网络拥堵**情况下: - 一个「旧 SYN 报文」比「最新的 SYN」报文早到达了服务端,那么此时服务端就会回一个 `SYN + ACK` 报文给客户端,此报文中的确认号是 91(90+1)。 - 客户端收到后,发现自己期望收到的确认号应该是 100 + 1,而不是 90 + 1,于是就会回 RST 报文。 - 服务端收到 RST 报文后,就会释放连接。 - 后续最新的 SYN 抵达了服务端后,客户端与服务端就可以正常的完成三次握手了。 上述中的「旧 SYN 报文」称为历史连接,TCP 使用三次握手建立连接的**最主要原因就是防止「历史连接」初始化了连接**。 ::: tip 有很多人问,如果服务端在收到 RST 报文之前,先收到了「新 SYN 报文」,也就是服务端收到客户端报文的顺序是:「旧 SYN 报文」->「新 SYN 报文」,此时会发生什么? 当服务端第一次收到 SYN 报文,也就是收到「旧 SYN 报文」时,就会回复 `SYN + ACK` 报文给客户端,此报文中的确认号是 91(90+1)。 然后这时再收到「新 SYN 报文」时,就会回 [Challenge Ack](https://xiaolincoding.com/network/3_tcp/challenge_ack.html) 报文给客户端,**这个 ack 报文并不是确认收到「新 SYN 报文」的,而是上一次的 ack 确认号**,也就是 91(90+1)。所以客户端收到此 ACK 报文时,发现自己期望收到的确认号应该是 101,而不是 91,于是就会回 RST 报文。 ::: **如果是两次握手连接,就无法阻止历史连接**,那为什么 TCP 两次握手为什么无法阻止历史连接呢? 我先直接说结论,主要是因为**在两次握手的情况下,服务端没有中间状态给客户端来阻止历史连接,导致服务端可能建立一个历史连接,造成资源浪费**。 你想想,在两次握手的情况下,服务端在收到 SYN 报文后,就进入 ESTABLISHED 状态,意味着这时可以给对方发送数据,但是客户端此时还没有进入 ESTABLISHED 状态,假设这次是历史连接,客户端判断到此次连接为历史连接,那么就会回 RST 报文来断开连接,而服务端在第一次握手的时候就进入 ESTABLISHED 状态,所以它可以发送数据的,但是它并不知道这个是历史连接,它只有在收到 RST 报文后,才会断开连接。 ![两次握手无法阻止历史连接](https://img-blog.csdnimg.cn/img_convert/fe898053d2e93abac950b1637645943f.png) 可以看到,如果采用两次握手建立 TCP 连接的场景下,服务端在向客户端发送数据前,并没有阻止掉历史连接,导致服务端建立了一个历史连接,又白白发送了数据,妥妥地浪费了服务端的资源。 因此,**要解决这种现象,最好就是在服务端发送数据前,也就是建立连接之前,要阻止掉历史连接,这样就不会造成资源浪费,而要实现这个功能,就需要三次握手**。 所以,**TCP 使用三次握手建立连接的最主要原因是防止「历史连接」初始化了连接。** ::: tip 有人问:客户端发送三次握手(ack 报文)后就可以发送数据了,而被动方此时还是 syn_received 状态,如果 ack 丢了,那客户端发的数据是不是也白白浪费了? 不是的,即使服务端还是在 syn_received 状态,收到了客户端发送的数据,还是可以建立连接的,并且还可以正常收到这个数据包。这是因为数据报文中是有 ack 标识位,也有确认号,这个确认号就是确认收到了第二次握手。如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/tcp/相同ack.png) 所以,服务端收到这个数据报文,是可以正常建立连接的,然后就可以正常接收这个数据包了。 ::: *原因二:同步双方初始序列号* TCP 协议的通信双方,都必须维护一个「序列号」,序列号是可靠传输的一个关键因素,它的作用: - 接收方可以去除重复的数据; - 接收方可以根据数据包的序列号按序接收; - 可以标识发送出去的数据包中,哪些是已经被对方收到的(通过 ACK 报文中的序列号知道); 可见,序列号在 TCP 连接中占据着非常重要的作用,所以当客户端发送携带「初始序列号」的 `SYN` 报文的时候,需要服务端回一个 `ACK` 应答报文,表示客户端的 SYN 报文已被服务端成功接收,那当服务端发送「初始序列号」给客户端的时候,依然也要得到客户端的应答回应,**这样一来一回,才能确保双方的初始序列号能被可靠的同步。** ![四次握手与三次握手](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzIwLmpwZw?x-oss-process=image/format,png) 四次握手其实也能够可靠的同步双方的初始化序号,但由于**第二步和第三步可以优化成一步**,所以就成了「三次握手」。 而两次握手只保证了一方的初始序列号能被对方成功接收,没办法保证双方的初始序列号都能被确认接收。 *原因三:避免资源浪费* 如果只有「两次握手」,当客户端发出的 `SYN` 报文在网络中阻塞,客户端没有接收到 `ACK` 报文,就会重新发送 `SYN`报文。**由于没有第三次握手,服务端不清楚客户端是否收到了自己回复的 `ACK` 报文,所以服务端每收到一个 `SYN` 就只能先主动建立一个连接**,这会造成什么情况呢? 如果客户端发送的 `SYN` 报文在网络中阻塞了,重复发送多次 `SYN` 报文,那么服务端在收到请求后就会**建立多个冗余的无效连接,造成不必要的资源浪费。** ![两次握手会造成资源浪费](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzIyLmpwZw?x-oss-process=image/format,png) 即两次握手会造成消息滞留情况下,服务端重复接受无用的连接请求 `SYN` 报文,而造成重复分配资源。 ::: tip 很多人问,两次握手不是也可以根据上下文信息丢弃`SYN`历史报文吗? 我这里两次握手是假设「由于没有第三次握手,服务端不清楚客户端是否收到了自己发送的建立连接的 `ACK` 确认报文,所以每收到一个 `SYN` 就只能先主动建立一个连接」这个场景。 当然你要实现成类似三次握手那样,根据上下文丢弃 syn 历史报文也是可以的,两次握手没有具体的实现,怎么假设都行。 ::: *小结* TCP 建立连接时,通过三次握手**能防止历史连接的建立,能减少双方不必要的资源开销,能帮助双方同步初始化序列号**。序列号能够保证数据包不重复、不丢弃和按序传输。 不使用「两次握手」和「四次握手」的原因: - 「两次握手」:无法防止历史连接的建立,会造成双方资源的浪费,也无法可靠的同步双方序列号; - 「四次握手」:三次握手就已经理论上最少可靠连接建立,所以不需要使用更多的通信次数。 ### 为什么每次建立 TCP 连接时,初始化的序列号都要求不一样呢? 主要原因有两个方面: - 为了防止历史报文被下一个相同四元组的连接接收(主要方面); - 为了安全性,防止黑客伪造的相同序列号的 TCP 报文被对方接收; 接下来,详细说说第一点。 假设每次建立连接,客户端和服务端的初始化序列号都是从 0 开始: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/tcp/isn相同.png) 过程如下: - 客户端和服务端建立一个 TCP 连接,在客户端发送数据包被网络阻塞了,然后超时重传了这个数据包,而此时服务端设备断电重启了,之前与客户端建立的连接就消失了,于是在收到客户端的数据包的时候就会发送 RST 报文。 - 紧接着,客户端又与服务端建立了与上一个连接相同四元组的连接; - 在新连接建立完成后,上一个连接中被网络阻塞的数据包正好抵达了服务端,刚好该数据包的序列号正好是在服务端的接收窗口内,所以该数据包会被服务端正常接收,就会造成数据错乱。 可以看到,**如果每次建立连接,客户端和服务端的初始化序列号都是一样的话,很容易出现历史报文被下一个相同四元组的连接接收的问题**。 如果每次建立连接客户端和服务端的初始化序列号都「不一样」,就有大概率因为历史报文的序列号「不在」对方接收窗口,从而很大程度上避免了历史报文,比如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/tcp/isn不相同.png) 相反,如果每次建立连接客户端和服务端的初始化序列号都「一样」,就有大概率遇到历史报文的序列号刚「好在」对方的接收窗口内,从而导致历史报文被新连接成功接收。 所以,每次初始化序列号不一样很大程度上能够避免历史报文被下一个相同四元组的连接接收,注意是很大程度上,并不是完全避免了(因为序列号会有回绕的问题,所以需要用时间戳的机制来判断历史报文,详细看篇:[TCP 是如何避免历史报文的?](https://xiaolincoding.com/network/3_tcp/isn_deff.html))。 ### 初始序列号 ISN 是如何随机产生的? 起始 `ISN` 是基于时钟的,每 4 微秒 + 1,转一圈要 4.55 个小时。 RFC793 提到初始化序列号 ISN 随机生成算法:ISN = M + F(localhost, localport, remotehost, remoteport)。 - `M` 是一个计时器,这个计时器每隔 4 微秒加 1。 - `F` 是一个 Hash 算法,根据源 IP、目的 IP、源端口、目的端口生成一个随机数值。要保证 Hash 算法不能被外部轻易推算得出,用 MD5 算法是一个比较好的选择。 可以看到,随机数是会基于时钟计时器递增的,基本不可能会随机成一样的初始化序列号。 ### 既然 IP 层会分片,为什么 TCP 层还需要 MSS 呢? 我们先来认识下 MTU 和 MSS ![MTU 与 MSS](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzIzLmpwZw?x-oss-process=image/format,png) - `MTU`:一个网络包的最大长度,以太网中一般为 `1500` 字节; - `MSS`:除去 IP 和 TCP 头部之后,一个网络包所能容纳的 TCP 数据的最大长度; 如果在 TCP 的整个报文(头部 + 数据)交给 IP 层进行分片,会有什么异常呢? 当 IP 层有一个超过 `MTU` 大小的数据(TCP 头部 + TCP 数据)要发送,那么 IP 层就要进行分片,把数据分片成若干片,保证每一个分片都小于 MTU。把一份 IP 数据报进行分片以后,由目标主机的 IP 层来进行重新组装后,再交给上一层 TCP 传输层。 这看起来井然有序,但这存在隐患的,**那么当如果一个 IP 分片丢失,整个 IP 报文的所有分片都得重传**。 因为 IP 层本身没有超时重传机制,它由传输层的 TCP 来负责超时和重传。 当某一个 IP 分片丢失后,接收方的 IP 层就无法组装成一个完整的 TCP 报文(头部 + 数据),也就无法将数据报文送到 TCP 层,所以接收方不会响应 ACK 给发送方,因为发送方迟迟收不到 ACK 确认报文,所以会触发超时重传,就会重发「整个 TCP 报文(头部 + 数据)」。 因此,可以得知由 IP 层进行分片传输,是非常没有效率的。 所以,为了达到最佳的传输效能 TCP 协议在**建立连接的时候通常要协商双方的 MSS 值**,当 TCP 层发现数据超过 MSS 时,则就先会进行分片,当然由它形成的 IP 包的长度也就不会大于 MTU,自然也就不用 IP 分片了。 ![握手阶段协商 MSS](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzI0LmpwZw?x-oss-process=image/format,png) 经过 TCP 层分片后,如果一个 TCP 分片丢失后,**进行重发时也是以 MSS 为单位**,而不用重传所有的分片,大大增加了重传的效率。 ### 第一次握手丢失了,会发生什么? 当客户端想和服务端建立 TCP 连接的时候,首先第一个发的就是 SYN 报文,然后进入到 `SYN_SENT` 状态。 在这之后,如果客户端迟迟收不到服务端的 SYN-ACK 报文(第二次握手),就会触发「超时重传」机制,重传 SYN 报文,而且**重传的 SYN 报文的序列号都是一样的**。 不同版本的操作系统可能超时时间不同,有的 1 秒的,也有 3 秒的,这个超时时间是写死在内核里的,如果想要更改则需要重新编译内核,比较麻烦。 当客户端在 1 秒后没收到服务端的 SYN-ACK 报文后,客户端就会重发 SYN 报文,那到底重发几次呢? 在 Linux 里,客户端的 SYN 报文最大重传次数由 `tcp_syn_retries`内核参数控制,这个参数是可以自定义的,默认值一般是 5。 ```shell # cat /proc/sys/net/ipv4/tcp_syn_retries 5 ``` 通常,第一次超时重传是在 1 秒后,第二次超时重传是在 2 秒,第三次超时重传是在 4 秒后,第四次超时重传是在 8 秒后,第五次是在超时重传 16 秒后。没错,**每次超时的时间是上一次的 2 倍**。 当第五次超时重传后,会继续等待 32 秒,如果服务端仍然没有回应 ACK,客户端就不再发送 SYN 包,然后断开 TCP 连接。 所以,总耗时是 1+2+4+8+16+32=63 秒,大约 1 分钟左右。 举个例子,假设 tcp_syn_retries 参数值为 3,那么当客户端的 SYN 报文一直在网络中丢失时,会发生下图的过程: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/tcp/第1次握手丢失.png) 具体过程: - 当客户端超时重传 3 次 SYN 报文后,由于 tcp_syn_retries 为 3,已达到最大重传次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到服务端的第二次握手(SYN-ACK 报文),那么客户端就会断开连接。 ### 第二次握手丢失了,会发生什么? 当服务端收到客户端的第一次握手后,就会回 SYN-ACK 报文给客户端,这个就是第二次握手,此时服务端会进入 `SYN_RCVD` 状态。 第二次握手的 `SYN-ACK` 报文其实有两个目的: - 第二次握手里的 ACK,是对第一次握手的确认报文; - 第二次握手里的 SYN,是服务端发起建立 TCP 连接的报文; 所以,如果第二次握手丢了,就会发生比较有意思的事情,具体会怎么样呢? 因为第二次握手报文里是包含对客户端的第一次握手的 ACK 确认报文,所以,如果客户端迟迟没有收到第二次握手,那么客户端就觉得可能自己的 SYN 报文(第一次握手)丢失了,于是**客户端就会触发超时重传机制,重传 SYN 报文**。 然后,因为第二次握手中包含服务端的 SYN 报文,所以当客户端收到后,需要给服务端发送 ACK 确认报文(第三次握手),服务端才会认为该 SYN 报文被客户端收到了。 那么,如果第二次握手丢失了,服务端就收不到第三次握手,于是**服务端这边会触发超时重传机制,重传 SYN-ACK 报文**。 在 Linux 下,SYN-ACK 报文的最大重传次数由 `tcp_synack_retries`内核参数决定,默认值是 5。 ```shell # cat /proc/sys/net/ipv4/tcp_synack_retries 5 ``` 因此,当第二次握手丢失了,客户端和服务端都会重传: - 客户端会重传 SYN 报文,也就是第一次握手,最大重传次数由 `tcp_syn_retries`内核参数决定; - 服务端会重传 SYN-ACK 报文,也就是第二次握手,最大重传次数由 `tcp_synack_retries` 内核参数决定。 举个例子,假设 tcp_syn_retries 参数值为 1,tcp_synack_retries 参数值为 2,那么当第二次握手一直丢失时,发生的过程如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/tcp/第2次握手丢失.png) 具体过程: - 当客户端超时重传 1 次 SYN 报文后,由于 tcp_syn_retries 为 1,已达到最大重传次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到服务端的第二次握手(SYN-ACK 报文),那么客户端就会断开连接。 - 当服务端超时重传 2 次 SYN-ACK 报文后,由于 tcp_synack_retries 为 2,已达到最大重传次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到客户端的第三次握手(ACK 报文),那么服务端就会断开连接。 ### 第三次握手丢失了,会发生什么? 客户端收到服务端的 SYN-ACK 报文后,就会给服务端回一个 ACK 报文,也就是第三次握手,此时客户端状态进入到 `ESTABLISH` 状态。 因为这个第三次握手的 ACK 是对第二次握手的 SYN 的确认报文,所以当第三次握手丢失了,如果服务端那一方迟迟收不到这个确认报文,就会触发超时重传机制,重传 SYN-ACK 报文,直到收到第三次握手,或者达到最大重传次数。 注意,**ACK 报文是不会有重传的,当 ACK 丢失了,就由对方重传对应的报文**。 举个例子,假设 tcp_synack_retries 参数值为 2,那么当第三次握手一直丢失时,发生的过程如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/tcp/第三次握手丢失.drawio.png) 具体过程: - 当服务端超时重传 2 次 SYN-ACK 报文后,由于 tcp_synack_retries 为 2,已达到最大重传次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到客户端的第三次握手(ACK 报文),那么服务端就会断开连接。 ### 什么是 SYN 攻击?如何避免 SYN 攻击? 我们都知道 TCP 连接建立是需要三次握手,假设攻击者短时间伪造不同 IP 地址的 `SYN` 报文,服务端每接收到一个 `SYN` 报文,就进入`SYN_RCVD` 状态,但服务端发送出去的 `ACK + SYN` 报文,无法得到未知 IP 主机的 `ACK` 应答,久而久之就会**占满服务端的半连接队列**,使得服务端不能为正常用户服务。 ![SYN 攻击](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzI1LmpwZw?x-oss-process=image/format,png) 先跟大家说一下,什么是 TCP 半连接和全连接队列。 在 TCP 三次握手的时候,Linux 内核会维护两个队列,分别是: - 半连接队列,也称 SYN 队列; - 全连接队列,也称 accept 队列; 我们先来看下 Linux 内核的 `SYN` 队列(半连接队列)与 `Accpet` 队列(全连接队列)是如何工作的? ![正常流程](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzI2LmpwZw?x-oss-process=image/format,png) 正常流程: - 当服务端接收到客户端的 SYN 报文时,会创建一个半连接的对象,然后将其加入到内核的「SYN 队列」; - 接着发送 SYN + ACK 给客户端,等待客户端回应 ACK 报文; - 服务端接收到 ACK 报文后,从「SYN 队列」取出一个半连接对象,然后创建一个新的连接对象放入到「Accept 队列」; - 应用通过调用 `accpet()` socket 接口,从「Accept 队列」取出连接对象。 不管是半连接队列还是全连接队列,都有最大长度限制,超过限制时,默认情况都会丢弃报文。 SYN 攻击方式最直接的表现就会把 TCP 半连接队列打满,这样**当 TCP 半连接队列满了,后续再在收到 SYN 报文就会丢弃**,导致客户端无法和服务端建立连接。 避免 SYN 攻击方式,可以有以下四种方法: - 调大 netdev_max_backlog; - 增大 TCP 半连接队列; - 开启 tcp_syncookies; - 减少 SYN+ACK 重传次数 > 方式一:调大 netdev_max_backlog 当网卡接收数据包的速度大于内核处理的速度时,会有一个队列保存这些数据包。控制该队列的最大值如下参数,默认值是 1000,我们要适当调大该参数的值,比如设置为 10000: ```bash net.core.netdev_max_backlog = 10000 ``` > 方式二:增大 TCP 半连接队列 增大 TCP 半连接队列,要同时增大下面这三个参数: - 增大 net.ipv4.tcp_max_syn_backlog - 增大 listen() 函数中的 backlog - 增大 net.core.somaxconn 具体为什么是三个参数决定 TCP 半连接队列的大小,可以看这篇:可以看这篇:[TCP 半连接队列和全连接队列满了会发生什么?又该如何应对?](https://xiaolincoding.com/network/3_tcp/tcp_queue.html) > 方式三:开启 net.ipv4.tcp_syncookies 开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接,相当于绕过了 SYN 半连接来建立连接。 ![tcp_syncookies 应对 SYN 攻击](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzI5LmpwZw?x-oss-process=image/format,png) 具体过程: - 当「SYN 队列」满之后,后续服务端收到 SYN 包,不会丢弃,而是根据算法,计算出一个 `cookie` 值; - 将 cookie 值放到第二次握手报文的「序列号」里,然后服务端回第二次握手给客户端; - 服务端接收到客户端的应答报文时,服务端会检查这个 ACK 包的合法性。如果合法,将该连接对象放入到「Accept 队列」。 - 最后应用程序通过调用 `accpet()` 接口,从「Accept 队列」取出的连接。 可以看到,当开启了 tcp_syncookies 了,即使受到 SYN 攻击而导致 SYN 队列满时,也能保证正常的连接成功建立。 net.ipv4.tcp_syncookies 参数主要有以下三个值: - 0 值,表示关闭该功能; - 1 值,表示仅当 SYN 半连接队列放不下时,再启用它; - 2 值,表示无条件开启功能; 那么在应对 SYN 攻击时,只需要设置为 1 即可。 ```bash $ echo 1 > /proc/sys/net/ipv4/tcp_syncookies ``` > 方式四:减少 SYN+ACK 重传次数 当服务端受到 SYN 攻击时,就会有大量处于 SYN_REVC 状态的 TCP 连接,处于这个状态的 TCP 会重传 SYN+ACK,当重传超过次数达到上限后,就会断开连接。 那么针对 SYN 攻击的场景,我们可以减少 SYN-ACK 的重传次数,以加快处于 SYN_REVC 状态的 TCP 连接断开。 SYN-ACK 报文的最大重传次数由 `tcp_synack_retries`内核参数决定(默认值是 5 次),比如将 tcp_synack_retries 减少到 2 次: ```shell $ echo 2 > /proc/sys/net/ipv4/tcp_synack_retries ``` ## TCP 连接断开 ### TCP 四次挥手过程是怎样的? 天下没有不散的宴席,对于 TCP 连接也是这样,TCP 断开连接是通过**四次挥手**方式。 双方都可以主动断开连接,断开连接后主机中的「资源」将被释放,四次挥手的过程如下图: ![客户端主动关闭连接 —— TCP 四次挥手](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzMwLmpwZw?x-oss-process=image/format,png) - 客户端打算关闭连接,此时会发送一个 TCP 首部 `FIN` 标志位被置为 `1` 的报文,也即 `FIN` 报文,之后客户端进入 `FIN_WAIT_1` 状态。 - 服务端收到该报文后,就向客户端发送 `ACK` 应答报文,接着服务端进入 `CLOSE_WAIT` 状态。 - 客户端收到服务端的 `ACK` 应答报文后,之后进入 `FIN_WAIT_2` 状态。 - 等待服务端处理完数据后,也向客户端发送 `FIN` 报文,之后服务端进入 `LAST_ACK` 状态。 - 客户端收到服务端的 `FIN` 报文后,回一个 `ACK` 应答报文,之后进入 `TIME_WAIT` 状态 - 服务端收到了 `ACK` 应答报文后,就进入了 `CLOSE` 状态,至此服务端已经完成连接的关闭。 - 客户端在经过 `2MSL` 一段时间后,自动进入 `CLOSE` 状态,至此客户端也完成连接的关闭。 你可以看到,每个方向都需要**一个 FIN 和一个 ACK**,因此通常被称为**四次挥手**。 这里一点需要注意是:**主动关闭连接的,才有 TIME_WAIT 状态。** ### 为什么挥手需要四次? 再来回顾下四次挥手双方发 `FIN` 包的过程,就能理解为什么需要四次了。 - 关闭连接时,客户端向服务端发送 `FIN` 时,仅仅表示客户端不再发送数据了但是还能接收数据。 - 服务端收到客户端的 `FIN` 报文时,先回一个 `ACK` 应答报文,而服务端可能还有数据需要处理和发送,等服务端不再发送数据时,才发送 `FIN` 报文给客户端来表示同意现在关闭连接。 从上面过程可知,服务端通常需要等待完成数据的发送和处理,所以服务端的 `ACK` 和 `FIN` 一般都会分开发送,因此是需要四次挥手。 但是**在特定情况下,四次挥手是可以变成三次挥手的**,具体情况可以看这篇:[TCP 四次挥手,可以变成三次吗?](https://xiaolincoding.com/network/3_tcp/tcp_three_fin.html) ### 第一次挥手丢失了,会发生什么? 当客户端(主动关闭方)调用 close 函数后,就会向服务端发送 FIN 报文,试图与服务端断开连接,此时客户端的连接进入到 `FIN_WAIT_1` 状态。 正常情况下,如果能及时收到服务端(被动关闭方)的 ACK,则会很快变为 `FIN_WAIT2`状态。 如果第一次挥手丢失了,那么客户端迟迟收不到被动方的 ACK 的话,也就会触发超时重传机制,重传 FIN 报文,重发次数由 `tcp_orphan_retries` 参数控制。 当客户端重传 FIN 报文的次数超过 `tcp_orphan_retries` 后,就不再发送 FIN 报文,则会在等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到第二次挥手,那么直接进入到 `close` 状态。 举个例子,假设 tcp_orphan_retries 参数值为 3,当第一次挥手一直丢失时,发生的过程如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/tcp/第一次挥手丢失.png) 具体过程: - 当客户端超时重传 3 次 FIN 报文后,由于 tcp_orphan_retries 为 3,已达到最大重传次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到服务端的第二次挥手(ACK 报文),那么客户端就会断开连接。 ### 第二次挥手丢失了,会发生什么? 当服务端收到客户端的第一次挥手后,就会先回一个 ACK 确认报文,此时服务端的连接进入到 `CLOSE_WAIT` 状态。 在前面我们也提了,ACK 报文是不会重传的,所以如果服务端的第二次挥手丢失了,客户端就会触发超时重传机制,重传 FIN 报文,直到收到服务端的第二次挥手,或者达到最大的重传次数。 举个例子,假设 tcp_orphan_retries 参数值为 2,当第二次挥手一直丢失时,发生的过程如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/tcp/第二次挥手丢失.png) 具体过程: - 当客户端超时重传 2 次 FIN 报文后,由于 tcp_orphan_retries 为 2,已达到最大重传次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到服务端的第二次挥手(ACK 报文),那么客户端就会断开连接。 这里提一下,当客户端收到第二次挥手,也就是收到服务端发送的 ACK 报文后,客户端就会处于 `FIN_WAIT2` 状态,在这个状态需要等服务端发送第三次挥手,也就是服务端的 FIN 报文。 对于 close 函数关闭的连接,由于无法再发送和接收数据,所以`FIN_WAIT2` 状态不可以持续太久,而 `tcp_fin_timeout` 控制了这个状态下连接的持续时长,默认值是 60 秒。 这意味着对于调用 close 关闭的连接,如果在 60 秒后还没有收到 FIN 报文,客户端(主动关闭方)的连接就会直接关闭,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/tcp/fin_wait_2.drawio.png) 但是注意,如果主动关闭方使用 shutdown 函数关闭连接,指定了只关闭发送方向,而接收方向并没有关闭,那么意味着主动关闭方还是可以接收数据的。 此时,如果主动关闭方一直没收到第三次挥手,那么主动关闭方的连接将会一直处于 `FIN_WAIT2` 状态(`tcp_fin_timeout` 无法控制 shutdown 关闭的连接)。如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/tcp/fin_wait_2死等.drawio.png) ### 第三次挥手丢失了,会发生什么? 当服务端(被动关闭方)收到客户端(主动关闭方)的 FIN 报文后,内核会自动回复 ACK,同时连接处于 `CLOSE_WAIT` 状态,顾名思义,它表示等待应用进程调用 close 函数关闭连接。 此时,内核是没有权利替代进程关闭连接,必须由进程主动调用 close 函数来触发服务端发送 FIN 报文。 服务端处于 CLOSE_WAIT 状态时,调用了 close 函数,内核就会发出 FIN 报文,同时连接进入 LAST_ACK 状态,等待客户端返回 ACK 来确认连接关闭。 如果迟迟收不到这个 ACK,服务端就会重发 FIN 报文,重发次数仍然由 `tcp_orphan_retries` 参数控制,这与客户端重发 FIN 报文的重传次数控制方式是一样的。 举个例子,假设 `tcp_orphan_retries` = 3,当第三次挥手一直丢失时,发生的过程如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/tcp/第三次挥手丢失.drawio.png) 具体过程: - 当服务端重传第三次挥手报文的次数达到了 3 次后,由于 tcp_orphan_retries 为 3,达到了重传最大次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到客户端的第四次挥手(ACK 报文),那么服务端就会断开连接。 - 客户端因为是通过 close 函数关闭连接的,处于 FIN_WAIT_2 状态是有时长限制的,如果 tcp_fin_timeout 时间内还是没能收到服务端的第三次挥手(FIN 报文),那么客户端就会断开连接。 ### 第四次挥手丢失了,会发生什么? 当客户端收到服务端的第三次挥手的 FIN 报文后,就会回 ACK 报文,也就是第四次挥手,此时客户端连接进入 `TIME_WAIT` 状态。 在 Linux 系统,TIME_WAIT 状态会持续 2MSL 后才会进入关闭状态。 然后,服务端(被动关闭方)没有收到 ACK 报文前,还是处于 LAST_ACK 状态。 如果第四次挥手的 ACK 报文没有到达服务端,服务端就会重发 FIN 报文,重发次数仍然由前面介绍过的 `tcp_orphan_retries` 参数控制。 举个例子,假设 tcp_orphan_retries 为 2,当第四次挥手一直丢失时,发生的过程如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/tcp/第四次挥手丢失drawio.drawio.png) 具体过程: - 当服务端重传第三次挥手报文达到 2 时,由于 tcp_orphan_retries 为 2,达到了最大重传次数,于是再等待一段时间(时间为上一次超时时间的 2 倍),如果还是没能收到客户端的第四次挥手(ACK 报文),那么服务端就会断开连接。 - 客户端在收到第三次挥手后,就会进入 TIME_WAIT 状态,开启时长为 2MSL 的定时器,如果途中再次收到第三次挥手(FIN 报文)后,就会重置定时器,当等待 2MSL 时长后,客户端就会断开连接。 ### 为什么 TIME_WAIT 等待的时间是 2MSL? `MSL` 是 Maximum Segment Lifetime,**报文最大生存时间**,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为 TCP 报文基于是 IP 协议的,而 IP 头中有一个 `TTL` 字段,是 IP 数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减 1,当此值为 0 则数据报将被丢弃,同时发送 ICMP 报文通知源主机。 MSL 与 TTL 的区别:MSL 的单位是时间,而 TTL 是经过路由跳数。所以 **MSL 应该要大于等于 TTL 消耗为 0 的时间**,以确保报文已被自然消亡。 **TTL 的值一般是 64,Linux 将 MSL 设置为 30 秒,意味着 Linux 认为数据报文经过 64 个路由器的时间不会超过 30 秒,如果超过了,就认为报文已经消失在网络中了**。 TIME_WAIT 等待 2 倍的 MSL,比较合理的解释是:网络中可能存在来自发送方的数据包,当这些发送方的数据包被接收方处理后又会向对方发送响应,所以**一来一回需要等待 2 倍的时间**。 比如,如果被动关闭方没有收到断开连接的最后的 ACK 报文,就会触发超时重发 `FIN` 报文,另一方接收到 FIN 后,会重发 ACK 给被动关闭方,一来一去正好 2 个 MSL。 可以看到 **2MSL 时长** 这其实是相当于**至少允许报文丢失一次**。比如,若 ACK 在一个 MSL 内丢失,这样被动方重发的 FIN 会在第 2 个 MSL 内到达,TIME_WAIT 状态的连接可以应对。 为什么不是 4 或者 8 MSL 的时长呢?你可以想象一个丢包率达到百分之一的糟糕网络,连续两次丢包的概率只有万分之一,这个概率实在是太小了,忽略它比解决它更具性价比。 `2MSL` 的时间是从**客户端接收到 FIN 后发送 ACK 开始计时的**。如果在 TIME-WAIT 时间内,因为客户端的 ACK 没有传输到服务端,客户端又接收到了服务端重发的 FIN 报文,那么 **2MSL 时间将重新计时**。 在 Linux 系统里 `2MSL` 默认是 `60` 秒,那么一个 `MSL` 也就是 `30` 秒。**Linux 系统停留在 TIME_WAIT 的时间为固定的 60 秒**。 其定义在 Linux 内核代码里的名称为 TCP_TIMEWAIT_LEN: ```c #define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME-WAIT state, about 60 seconds */ ``` 如果要修改 TIME_WAIT 的时间长度,只能修改 Linux 内核代码里 TCP_TIMEWAIT_LEN 的值,并重新编译 Linux 内核。 ### 为什么需要 TIME_WAIT 状态? 主动发起关闭连接的一方,才会有 `TIME-WAIT` 状态。 需要 TIME-WAIT 状态,主要是两个原因: - 防止历史连接中的数据,被后面相同四元组的连接错误的接收; - 保证「被动关闭连接」的一方,能被正确的关闭; *原因一:防止历史连接中的数据,被后面相同四元组的连接错误的接收* 为了能更好的理解这个原因,我们先来了解序列号(SEQ)和初始序列号(ISN)。 - **序列号**,是 TCP 一个头部字段,标识了 TCP 发送端到 TCP 接收端的数据流的一个字节,因为 TCP 是面向字节流的可靠协议,为了保证消息的顺序性和可靠性,TCP 为每个传输方向上的每个字节都赋予了一个编号,以便于传输成功后确认、丢失后重传以及在接收端保证不会乱序。**序列号是一个 32 位的无符号数,因此在到达 4G 之后再循环回到 0**。 - **初始序列号**,在 TCP 建立连接的时候,客户端和服务端都会各自生成一个初始序列号,它是基于时钟生成的一个随机数,来保证每个连接都拥有不同的初始序列号。**初始化序列号可被视为一个 32 位的计数器,该计数器的数值每 4 微秒加 1,循环一次需要 4.55 小时**。 给大家抓了一个包,下图中的 Seq 就是序列号,其中红色框住的分别是客户端和服务端各自生成的初始序列号。 ![TCP 抓包图](https://img-blog.csdnimg.cn/img_convert/c9ea9b844e87bcd4acd3e320403ecab3.png) 通过前面我们知道,**序列号和初始化序列号并不是无限递增的,会发生回绕为初始值的情况,这意味着无法根据序列号来判断新老数据**。 假设 TIME-WAIT 没有等待时间或时间过短,被延迟的数据包抵达后会发生什么呢? ![TIME-WAIT 时间过短,收到旧连接的数据报文](https://img-blog.csdnimg.cn/img_convert/6385cc99500b01ba2ef288c27523c1e7.png) 如上图: - 服务端在关闭连接之前发送的 `SEQ = 301` 报文,被网络延迟了。 - 接着,服务端以相同的四元组重新打开了新连接,前面被延迟的 `SEQ = 301` 这时抵达了客户端,而且该数据报文的序列号刚好在客户端接收窗口内,因此客户端会正常接收这个数据报文,但是这个数据报文是上一个连接残留下来的,这样就产生数据错乱等严重的问题。 为了防止历史连接中的数据,被后面相同四元组的连接错误的接收,因此 TCP 设计了 TIME_WAIT 状态,状态会持续 `2MSL` 时长,这个时间**足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。** *原因二:保证「被动关闭连接」的一方,能被正确的关闭* 在 RFC 793 指出 TIME-WAIT 另一个重要的作用是: *TIME-WAIT - represents waiting for enough time to pass to be sure the remote TCP received the acknowledgment of its connection termination request.* 也就是说,TIME-WAIT 作用是**等待足够的时间以确保最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭。** 如果客户端(主动关闭方)最后一次 ACK 报文(第四次挥手)在网络中丢失了,那么按照 TCP 可靠性原则,服务端(被动关闭方)会重发 FIN 报文。 假设客户端没有 TIME_WAIT 状态,而是在发完最后一次回 ACK 报文就直接进入 CLOSE 状态,如果该 ACK 报文丢失了,服务端则重传的 FIN 报文,而这时客户端已经进入到关闭状态了,在收到服务端重传的 FIN 报文后,就会回 RST 报文。 ![TIME-WAIT 时间过短,没有确保连接正常关闭](https://img-blog.csdnimg.cn/img_convert/3a81c23ce57c27cf63fc2b77e34de0ab.png) 服务端收到这个 RST 并将其解释为一个错误(Connection reset by peer),这对于一个可靠的协议来说不是一个优雅的终止方式。 为了防止这种情况出现,客户端必须等待足够长的时间,确保服务端能够收到 ACK,如果服务端没有收到 ACK,那么就会触发 TCP 重传机制,服务端会重新发送一个 FIN,这样一去一来刚好两个 MSL 的时间。 ![TIME-WAIT 时间正常,确保了连接正常关闭](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4/网络/TIME-WAIT连接正常关闭.drawio.png) 客户端在收到服务端重传的 FIN 报文时,TIME_WAIT 状态的等待时间,会重置回 2MSL。 ### TIME_WAIT 过多有什么危害? 过多的 TIME-WAIT 状态主要的危害有两种: - 第一是占用系统资源,比如文件描述符、内存资源、CPU 资源、线程资源等; - 第二是占用端口资源,端口资源也是有限的,一般可以开启的端口为 `32768~61000`,也可以通过 `net.ipv4.ip_local_port_range`参数指定范围。 客户端和服务端 TIME_WAIT 过多,造成的影响是不同的。 **如果客户端(主动发起关闭连接方)的 TIME_WAIT 状态过多**,占满了所有端口资源,那么就无法对「目的 IP+ 目的 PORT」都一样的服务端发起连接了,但是被使用的端口,还是可以继续对另外一个服务端发起连接的。具体可以看我这篇文章:[客户端的端口可以重复使用吗?](https://xiaolincoding.com/network/3_tcp/port.html#%E5%AE%A2%E6%88%B7%E7%AB%AF%E7%9A%84%E7%AB%AF%E5%8F%A3%E5%8F%AF%E4%BB%A5%E9%87%8D%E5%A4%8D%E4%BD%BF%E7%94%A8%E5%90%97) 因此,客户端(发起连接方)都是和「目的 IP+ 目的 PORT」都一样的服务端建立连接的话,当客户端的 TIME_WAIT 状态连接过多的话,就会受端口资源限制,如果占满了所有端口资源,那么就无法再跟「目的 IP+ 目的 PORT」都一样的服务端建立连接了。 不过,即使是在这种场景下,只要连接的是不同的服务端,端口是可以重复使用的,所以客户端还是可以向其他服务端发起连接的,这是因为内核在定位一个连接的时候,是通过四元组(源 IP、源端口、目的 IP、目的端口)信息来定位的,并不会因为客户端的端口一样,而导致连接冲突。 **如果服务端(主动发起关闭连接方)的 TIME_WAIT 状态过多**,并不会导致端口资源受限,因为服务端只监听一个端口,而且由于一个四元组唯一确定一个 TCP 连接,因此理论上服务端可以建立很多连接,但是 TCP 连接过多,会占用系统资源,比如文件描述符、内存资源、CPU 资源、线程资源等。 ### 如何优化 TIME_WAIT? 这里给出优化 TIME-WAIT 的几个方式,都是有利有弊: - 打开 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_timestamps 选项; - net.ipv4.tcp_max_tw_buckets - 程序中使用 SO_LINGER,应用强制使用 RST 关闭。 *方式一:net.ipv4.tcp_tw_reuse 和 tcp_timestamps* 如下的 Linux 内核参数开启后,则可以**复用处于 TIME_WAIT 的 socket 为新的连接所用**。 有一点需要注意的是,**tcp_tw_reuse 功能只能用客户端(连接发起方),因为开启了该功能,在调用 connect() 函数时,内核会随机找一个 time_wait 状态超过 1 秒的连接给新的连接复用。** ```shell net.ipv4.tcp_tw_reuse = 1 ``` 使用这个选项,还有一个前提,需要打开对 TCP 时间戳的支持,即 ```plain net.ipv4.tcp_timestamps=1(默认即为 1) ``` 这个时间戳的字段是在 TCP 头部的「选项」里,它由一共 8 个字节表示时间戳,其中第一个 4 字节字段用来保存发送该数据包的时间,第二个 4 字节字段用来保存最近一次接收对方发送到达数据的时间。 由于引入了时间戳,我们在前面提到的 `2MSL` 问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃。 *方式二:net.ipv4.tcp_max_tw_buckets* 这个值默认为 18000,**当系统中处于 TIME_WAIT 的连接一旦超过这个值时,系统就会将后面的 TIME_WAIT 连接状态重置**,这个方法比较暴力。 *方式三:程序中使用 SO_LINGER* 我们可以通过设置 socket 选项,来设置调用 close 关闭连接行为。 ```c struct linger so_linger; so_linger.l_onoff = 1; so_linger.l_linger = 0; setsockopt(s, SOL_SOCKET, SO_LINGER, &so_linger,sizeof(so_linger)); ``` 如果`l_onoff`为非 0,且`l_linger`值为 0,那么调用`close`后,会立该发送一个`RST`标志给对端,该 TCP 连接将跳过四次挥手,也就跳过了`TIME_WAIT`状态,直接关闭。 但这为跨越`TIME_WAIT`状态提供了一个可能,不过是一个非常危险的行为,不值得提倡。 前面介绍的方法都是试图越过 `TIME_WAIT`状态的,这样其实不太好。虽然 TIME_WAIT 状态持续的时间是有一点长,显得很不友好,但是它被设计来就是用来避免发生乱七八糟的事情。 《UNIX 网络编程》一书中却说道:**TIME_WAIT 是我们的朋友,它是有助于我们的,不要试图避免这个状态,而是应该弄清楚它**。 **如果服务端要避免过多的 TIME_WAIT 状态的连接,就永远不要主动断开连接,让客户端去断开,由分布在各处的客户端去承受 TIME_WAIT**。 ### 服务器出现大量 TIME_WAIT 状态的原因有哪些? 首先要知道 TIME_WAIT 状态是主动关闭连接方才会出现的状态,所以如果服务器出现大量的 TIME_WAIT 状态的 TCP 连接,就是说明服务器主动断开了很多 TCP 连接。 问题来了,**什么场景下服务端会主动断开连接呢?** - 第一个场景:HTTP 没有使用长连接 - 第二个场景:HTTP 长连接超时 - 第三个场景:HTTP 长连接的请求数量达到上限 接下来,分别介绍下。 *第一个场景:HTTP 没有使用长连接* 我们先来看看 HTTP 长连接(Keep-Alive)机制是怎么开启的。 在 HTTP/1.0 中默认是关闭的,如果浏览器要开启 Keep-Alive,它必须在请求的 header 中添加: ```text Connection: Keep-Alive ``` 然后当服务器收到请求,作出回应的时候,它也被添加到响应中 header 里: ```text Connection: Keep-Alive ``` 这样做,TCP 连接就不会中断,而是保持连接。当客户端发送另一个请求时,它会使用同一个 TCP 连接。这一直继续到客户端或服务器端提出断开连接。 **从 HTTP/1.1 开始,就默认是开启了 Keep-Alive**,现在大多数浏览器都默认是使用 HTTP/1.1,所以 Keep-Alive 都是默认打开的。一旦客户端和服务端达成协议,那么长连接就建立好了。 如果要关闭 HTTP Keep-Alive,需要在 HTTP 请求或者响应的 header 里添加 `Connection:close` 信息,也就是说,**只要客户端和服务端任意一方的 HTTP header 中有 `Connection:close` 信息,那么就无法使用 HTTP 长连接的机制**。 关闭 HTTP 长连接机制后,每次请求都要经历这样的过程:建立 TCP -> 请求资源 -> 响应资源 -> 释放连接,那么此方式就是 **HTTP 短连接**,如下图: ![HTTP 短连接](https://img-blog.csdnimg.cn/img_convert/d6f6757c02e3afbf113d1048c937f8ee.png) 在前面我们知道,只要任意一方的 HTTP header 中有 `Connection:close` 信息,就无法使用 HTTP 长连接机制,这样在完成一次 HTTP 请求/处理后,就会关闭连接。 问题来了,**这时候是客户端还是服务端主动关闭连接呢?** 在 RFC 文档中,并没有明确由谁来关闭连接,**请求和响应的双方都可以主动关闭 TCP 连接。** 不过,**根据大多数 Web 服务的实现,不管哪一方禁用了 HTTP Keep-Alive,都是由服务端主动关闭连接**,那么此时服务端上就会出现 TIME_WAIT 状态的连接。 > 客户端禁用了 HTTP Keep-Alive,服务端开启 HTTP Keep-Alive,谁是主动关闭方? 当客户端禁用了 HTTP Keep-Alive,这时候 HTTP 请求的 header 就会有 `Connection:close` 信息,这时服务端在发完 HTTP 响应后,就会主动关闭连接。 为什么要这么设计呢?HTTP 是请求 - 响应模型,发起方一直是客户端,HTTP Keep-Alive 的初衷是**为客户端后续的请求重用连接**,如果我们**在某次 HTTP 请求 - 响应模型中,请求的 header 定义了 `connection:close` 信息,那不再重用这个连接的时机就只有在服务端了**,所以我们在 HTTP 请求 - 响应这个周期的「末端」关闭连接是合理的。 > 客户端开启了 HTTP Keep-Alive,服务端禁用了 HTTP Keep-Alive,谁是主动关闭方? 当客户端开启了 HTTP Keep-Alive,而服务端禁用了 HTTP Keep-Alive,这时服务端在发完 HTTP 响应后,服务端也会主动关闭连接。 为什么要这么设计呢?在服务端主动关闭连接的情况下,只要调用一次 close() 就可以释放连接,剩下的工作由内核 TCP 栈直接进行了处理,整个过程只有一次 syscall;如果是要求 客户端关闭,则服务端在写完最后一个 response 之后需要把这个 socket 放入 readable 队列,调用 select / epoll 去等待事件;然后调用一次 read() 才能知道连接已经被关闭,这其中是两次 syscall,多一次用户态程序被激活执行,而且 socket 保持时间也会更长。 因此,**当服务端出现大量的 TIME_WAIT 状态连接的时候,可以排查下是否客户端和服务端都开启了 HTTP Keep-Alive**,因为任意一方没有开启 HTTP Keep-Alive,都会导致服务端在处理完一个 HTTP 请求后,就主动关闭连接,此时服务端上就会出现大量的 TIME_WAIT 状态的连接。 针对这个场景下,解决的方式也很简单,让客户端和服务端都开启 HTTP Keep-Alive 机制。 *第二个场景:HTTP 长连接超时* HTTP 长连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。 HTTP 长连接可以在同一个 TCP 连接上接收和发送多个 HTTP 请求/应答,避免了连接建立和释放的开销。 ![](https://img-blog.csdnimg.cn/img_convert/d2b20d1cc03936332adb2a68512eb167.png) 可能有的同学会问,如果使用了 HTTP 长连接,如果客户端完成一个 HTTP 请求后,就不再发起新的请求,此时这个 TCP 连接一直占用着不是挺浪费资源的吗? 对没错,所以为了避免资源浪费的情况,web 服务软件一般都会提供一个参数,用来指定 HTTP 长连接的超时时间,比如 nginx 提供的 keepalive_timeout 参数。 假设设置了 HTTP 长连接的超时时间是 60 秒,nginx 就会启动一个「定时器」,**如果客户端在完后一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,定时器的时间一到,nginx 就会触发回调函数来关闭该连接,那么此时服务端上就会出现 TIME_WAIT 状态的连接**。 ![HTTP 长连接超时](https://img-blog.csdnimg.cn/img_convert/7e995ecb2e42941342f97256707496c9.png) 当服务端出现大量 TIME_WAIT 状态的连接时,如果现象是有大量的客户端建立完 TCP 连接后,很长一段时间没有发送数据,那么大概率就是因为 HTTP 长连接超时,导致服务端主动关闭连接,产生大量处于 TIME_WAIT 状态的连接。 可以往网络问题的方向排查,比如是否是因为网络问题,导致客户端发送的数据一直没有被服务端接收到,以至于 HTTP 长连接超时。 *第三个场景:HTTP 长连接的请求数量达到上限* Web 服务端通常会有个参数,来定义一条 HTTP 长连接上最大能处理的请求数量,当超过最大限制时,就会主动关闭连接。 比如 nginx 的 keepalive_requests 这个参数,这个参数是指一个 HTTP 长连接建立之后,nginx 就会为这个连接设置一个计数器,记录这个 HTTP 长连接上已经接收并处理的客户端请求的数量。**如果达到这个参数设置的最大值时,则 nginx 会主动关闭这个长连接**,那么此时服务端上就会出现 TIME_WAIT 状态的连接。 keepalive_requests 参数的默认值是 100,意味着每个 HTTP 长连接最多只能跑 100 次请求,这个参数往往被大多数人忽略,因为当 QPS (每秒请求数) 不是很高时,默认值 100 凑合够用。 但是,**对于一些 QPS 比较高的场景,比如超过 10000 QPS,甚至达到 30000 , 50000 甚至更高,如果 keepalive_requests 参数值是 100,这时候就 nginx 就会很频繁地关闭连接,那么此时服务端上就会出大量的 TIME_WAIT 状态**。 针对这个场景下,解决的方式也很简单,调大 nginx 的 keepalive_requests 参数就行。 ### 服务器出现大量 CLOSE_WAIT 状态的原因有哪些? CLOSE_WAIT 状态是「被动关闭方」才会有的状态,而且如果「被动关闭方」没有调用 close 函数关闭连接,那么就无法发出 FIN 报文,从而无法使得 CLOSE_WAIT 状态的连接转变为 LAST_ACK 状态。 所以,**当服务端出现大量 CLOSE_WAIT 状态的连接的时候,说明服务端的程序没有调用 close 函数关闭连接**。 那什么情况会导致服务端的程序没有调用 close 函数关闭连接?这时候通常需要排查代码。 我们先来分析一个普通的 TCP 服务端的流程: 1. 创建服务端 socket,bind 绑定端口、listen 监听端口 2. 将服务端 socket 注册到 epoll 3. epoll_wait 等待连接到来,连接到来时,调用 accpet 获取已连接的 socket 4. 将已连接的 socket 注册到 epoll 5. epoll_wait 等待事件发生 6. 对方连接关闭时,我方调用 close 可能导致服务端没有调用 close 函数的原因,如下。 **第一个原因**:第 2 步没有做,没有将服务端 socket 注册到 epoll,这样有新连接到来时,服务端没办法感知这个事件,也就无法获取到已连接的 socket,那服务端自然就没机会对 socket 调用 close 函数了。 不过这种原因发生的概率比较小,这种属于明显的代码逻辑 bug,在前期 read view 阶段就能发现的了。 **第二个原因**:第 3 步没有做,有新连接到来时没有调用 accpet 获取该连接的 socket,导致当有大量的客户端主动断开了连接,而服务端没机会对这些 socket 调用 close 函数,从而导致服务端出现大量 CLOSE_WAIT 状态的连接。 发生这种情况可能是因为服务端在执行 accpet 函数之前,代码卡在某一个逻辑或者提前抛出了异常。 **第三个原因**:第 4 步没有做,通过 accpet 获取已连接的 socket 后,没有将其注册到 epoll,导致后续收到 FIN 报文的时候,服务端没办法感知这个事件,那服务端就没机会调用 close 函数了。 发生这种情况可能是因为服务端在将已连接的 socket 注册到 epoll 之前,代码卡在某一个逻辑或者提前抛出了异常。之前看到过别人解决 close_wait 问题的实践文章,感兴趣的可以看看:[一次 Netty 代码不健壮导致的大量 CLOSE_WAIT 连接原因分析](https://mp.weixin.qq.com/s?__biz=MzU3Njk0MTc3Ng==&mid=2247486020&idx=1&sn=f7cf41aec28e2e10a46228a64b1c0a5c&scene=21#wechat_redirect) **第四个原因**:第 6 步没有做,当发现客户端关闭连接后,服务端没有执行 close 函数,可能是因为代码漏处理,或者是在执行 close 函数之前,代码卡在某一个逻辑,比如发生死锁等等。 可以发现,**当服务端出现大量 CLOSE_WAIT 状态的连接的时候,通常都是代码的问题,这时候我们需要针对具体的代码一步一步的进行排查和定位,主要分析的方向就是服务端为什么没有调用 close**。 ### 如果已经建立了连接,但是客户端突然出现故障了怎么办? 客户端出现故障指的是客户端的主机发生了宕机,或者断电的场景。发生这种情况的时候,如果服务端一直不会发送数据给客户端,那么服务端是永远无法感知到客户端宕机这个事件的,也就是服务端的 TCP 连接将一直处于 `ESTABLISH` 状态,占用着系统资源。 为了避免这种情况,TCP 搞了个**保活机制**。这个机制的原理是这样的: 定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。 在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值: ```shell net.ipv4.tcp_keepalive_time=7200 net.ipv4.tcp_keepalive_intvl=75 net.ipv4.tcp_keepalive_probes=9 ``` - tcp_keepalive_time=7200:表示保活时间是 7200 秒(2 小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制 - tcp_keepalive_intvl=75:表示每次检测间隔 75 秒; - tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。 也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接。 ![](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzMzLmpwZw?x-oss-process=image/format,png) 注意,应用程序若想使用 TCP 保活机制需要通过 socket 接口设置 `SO_KEEPALIVE` 选项才能够生效,如果没有设置,那么就无法使用 TCP 保活机制。 如果开启了 TCP 保活,需要考虑以下几种情况: - 第一种,对端程序是正常工作的。当 TCP 保活的探测报文发送给对端,对端会正常响应,这样 **TCP 保活时间会被重置**,等待下一个 TCP 保活时间的到来。 - 第二种,对端主机宕机并重启。当 TCP 保活的探测报文发送给对端后,对端是可以响应的,但由于没有该连接的有效信息,**会产生一个 RST 报文**,这样很快就会发现 TCP 连接已经被重置。 - 第三种,是对端主机宕机(*注意不是进程崩溃,进程崩溃后操作系统在回收进程资源的时候,会发送 FIN 报文,而主机宕机则是无法感知的,所以需要 TCP 保活机制来探测对方是不是发生了主机宕机*),或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,**TCP 会报告该 TCP 连接已经死亡**。 TCP 保活的这个机制检测的时间是有点长,我们可以自己在应用层实现一个心跳机制。 比如,web 服务软件一般都会提供 `keepalive_timeout` 参数,用来指定 HTTP 长连接的超时时间。如果设置了 HTTP 长连接的超时时间是 60 秒,web 服务软件就会**启动一个定时器**,如果客户端在完成一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,**定时器的时间一到,就会触发回调函数来释放该连接。** ![web 服务的 心跳机制](https://img-blog.csdnimg.cn/img_convert/2d872f947dedd24800a1867dc4f8b9ce.png) ### 如果已经建立了连接,但是服务端的进程崩溃会发生什么? TCP 的连接信息是由内核维护的,所以当服务端的进程崩溃后,内核需要回收该进程的所有 TCP 连接资源,于是内核会发送第一次挥手 FIN 报文,后续的挥手过程也都是在内核完成,并不需要进程的参与,所以即使服务端的进程退出了,还是能与客户端完成 TCP 四次挥手的过程。 我自己做了个实验,使用 kill -9 来模拟进程崩溃的情况,发现**在 kill 掉进程后,服务端会发送 FIN 报文,与客户端进行四次挥手**。 ::: tip 关于进程崩溃和主机宕机的区别,可以参考这篇:[TCP 连接,一端断电和进程崩溃有什么区别?](https://xiaolincoding.com/network/3_tcp/tcp_down_and_crash.html) 还有一个类似的问题:「拔掉网线后,原本的 TCP 连接还存在吗?」,具体可以看这篇:[拔掉网线后,原本的 TCP 连接还存在吗?](https://xiaolincoding.com/network/3_tcp/tcp_unplug_the_network_cable.html) ::: --- ## Socket 编程 ### 针对 TCP 应该如何 Socket 编程? ![基于 TCP 协议的客户端和服务端工作](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzM0LmpwZw?x-oss-process=image/format,png) - 服务端和客户端初始化 `socket`,得到文件描述符; - 服务端调用 `bind`,将 socket 绑定在指定的 IP 地址和端口; - 服务端调用 `listen`,进行监听; - 服务端调用 `accept`,等待客户端连接; - 客户端调用 `connect`,向服务端的地址和端口发起连接请求; - 服务端 `accept` 返回用于传输的 `socket` 的文件描述符; - 客户端调用 `write` 写入数据;服务端调用 `read` 读取数据; - 客户端断开连接时,会调用 `close`,那么服务端 `read` 读取数据的时候,就会读取到了 `EOF`,待处理完数据后,服务端调用 `close`,表示连接关闭。 这里需要注意的是,服务端调用 `accept` 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。 所以,监听的 socket 和真正用来传送数据的 socket,是「两个」socket,一个叫作**监听 socket**,一个叫作**已完成连接 socket**。 成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。 ### listen 时候参数 backlog 的意义? Linux 内核中会维护两个队列: - 半连接队列(SYN 队列):接收到一个 SYN 建立连接请求,处于 SYN_RCVD 状态; - 全连接队列(Accpet 队列):已完成 TCP 三次握手过程,处于 ESTABLISHED 状态; ![ SYN 队列 与 Accpet 队列 ](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzM1LmpwZw?x-oss-process=image/format,png) ```c int listen (int socketfd, int backlog) ``` - 参数一 socketfd 为 socketfd 文件描述符 - 参数二 backlog,这参数在历史版本有一定的变化 在早期 Linux 内核 backlog 是 SYN 队列大小,也就是未完成的队列大小。 在 Linux 内核 2.2 之后,backlog 变成 accept 队列,也就是已完成连接建立的队列长度,**所以现在通常认为 backlog 是 accept 队列。** **但是上限值是内核参数 somaxconn 的大小,也就说 accpet 队列长度 = min(backlog, somaxconn)。** 想详细了解 TCP 半连接队列和全连接队列,可以看这篇:[TCP 半连接队列和全连接队列满了会发生什么?又该如何应对?](https://xiaolincoding.com/network/3_tcp/tcp_queue.html) ### accept 发生在三次握手的哪一步? 我们先看看客户端连接服务端时,发送了什么? ![socket 三次握手](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4/网络/socket三次握手.drawio.png) - 客户端的协议栈向服务端发送了 SYN 包,并告诉服务端当前发送序列号 client_isn,客户端进入 SYN_SENT 状态; - 服务端的协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为 client_isn+1,表示对 SYN 包 client_isn 的确认,同时服务端也发送一个 SYN 包,告诉客户端当前我的发送序列号为 server_isn,服务端进入 SYN_RCVD 状态; - 客户端协议栈收到 ACK 之后,使得应用程序从 `connect` 调用返回,表示客户端到服务端的单向连接建立成功,客户端的状态为 ESTABLISHED,同时客户端协议栈也会对服务端的 SYN 包进行应答,应答数据为 server_isn+1; - ACK 应答包到达服务端后,服务端的 TCP 连接进入 ESTABLISHED 状态,同时服务端协议栈使得 `accept` 阻塞调用返回,这个时候服务端到客户端的单向连接也建立成功。至此,客户端与服务端两个方向的连接都建立成功。 从上面的描述过程,我们可以得知**客户端 connect 成功返回是在第二次握手,服务端 accept 成功返回是在三次握手成功之后。** ### 客户端调用 close 了,连接是断开的流程是什么? 我们看看客户端主动调用了 `close`,会发生什么? ![客户端调用 close 过程](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzM3LmpwZw?x-oss-process=image/format,png) - 客户端调用 `close`,表明客户端没有数据需要发送了,则此时会向服务端发送 FIN 报文,进入 FIN_WAIT_1 状态; - 服务端接收到了 FIN 报文,TCP 协议栈会为 FIN 包插入一个文件结束符 `EOF` 到接收缓冲区中,应用程序可以通过 `read` 调用来感知这个 FIN 包。这个 `EOF` 会被**放在已排队等候的其他已接收的数据之后**,这就意味着服务端需要处理这种异常情况,因为 EOF 表示在该连接上再无额外数据到达。此时,服务端进入 CLOSE_WAIT 状态; - 接着,当处理完数据后,自然就会读到 `EOF`,于是也调用 `close` 关闭它的套接字,这会使得服务端发出一个 FIN 包,之后处于 LAST_ACK 状态; - 客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进入 TIME_WAIT 状态; - 服务端收到 ACK 确认包后,就进入了最后的 CLOSE 状态; - 客户端经过 `2MSL` 时间之后,也进入 CLOSE 状态; ### 没有 accept,能建立 TCP 连接吗? 答案:**可以的**。 accept 系统调用并不参与 TCP 三次握手过程,它只是负责从 TCP 全连接队列取出一个已经建立连接的 socket,用户层通过 accept 系统调用拿到了已经建立连接的 socket,就可以对该 socket 进行读写操作了。 ![半连接队列与全连接队列](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/3.jpg) 更想了解这个问题,可以参考这篇文章:[没有 accept,能建立 TCP 连接吗?](https://xiaolincoding.com/network/3_tcp/tcp_no_accpet.html) ### 没有 listen,能建立 TCP 连接吗? 答案:**可以的**。 客户端是可以自己连自己的形成连接(**TCP 自连接**),也可以两个客户端同时向对方发出请求建立连接(**TCP 同时打开**),这两个情况都有个共同点,就是**没有服务端参与,也就是没有 listen,就能 TCP 建立连接。** 更想了解这个问题,可以参考这篇文章:[服务端没有 listen,客户端发起连接建立,会发生什么?](https://xiaolincoding.com/network/3_tcp/tcp_no_listen.html) --- ## 唠叨 希望这篇能破除大家对 TCP 的大多数疑惑,有任何问题都可以在留言区和我交流。 **小林是专为大家图解的工具人,Goodbye,我们下次见!** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/3_tcp/tcp_no_accpet.md ================================================ # 4.20 没有 accept,能建立 TCP 连接吗? > 来源:公众号@小白 debug > 原文地址:[阿里二面:没有 accept,能建立 TCP 连接吗?](https://mp.weixin.qq.com/s/oPX_JoZUaLn6sW54yppfvA) 大家好,我是小林。 这次,我们来讨论一下,**没有 accept,能建立 TCP 连接吗?** 下面这个动图,是我们平时客户端和服务端建立连接时的代码流程。 ![握手建立连接流程](https://img-blog.csdnimg.cn/img_convert/e0d405a55626eb8e4a52553a54680618.gif) 对应的是下面一段简化过的服务端伪代码。 ```c int main() { /*Step 1: 创建服务器端监听 socket 描述符 listen_fd*/ listen_fd = socket(AF_INET, SOCK_STREAM, 0); /*Step 2: bind 绑定服务器端的 IP 和端口,所有客户端都向这个 IP 和端口发送和请求数据*/ bind(listen_fd, xxx); /*Step 3: 服务端开启监听*/ listen(listen_fd, 128); /*Step 4: 服务器等待客户端的链接,返回值 cfd 为客户端的 socket 描述符*/ cfd = accept(listen_fd, xxx); /*Step 5: 读取客户端发来的数据*/ n = read(cfd, buf, sizeof(buf)); } ``` 估计大家也是老熟悉这段伪代码了。 需要注意的是,在执行 `listen()` 方法之后还会执行一个 `accept()` 方法。 **一般情况**下,如果启动服务器,会发现最后程序会**阻塞在** `accept()` 里。 此时服务端就算 ok 了,就等客户端了。 那么,再看下简化过的客户端伪代码。 ```c int main() { /*Step 1: 创建客户端端 socket 描述符 cfd*/ cfd = socket(AF_INET, SOCK_STREAM, 0); /*Step 2: connect 方法,对服务器端的 IP 和端口号发起连接*/ ret = connect(cfd, xxxx); /*Step 3: 向服务器端写数据*/ write(cfd, buf, strlen(buf)); } ``` 客户端比较简单,创建好 `socket` 之后,直接就发起 `connect` 方法。 此时回到服务端,会发现**之前一直阻塞的 accept 方法,返回结果了**。 这就算两端成功建立好了一条连接。之后就可以愉快的进行读写操作了。 那么,我们今天的问题是,**如果没有这个 accept 方法,TCP 连接还能建立起来吗?** 其实只要在执行 `accept()` 之前执行一个 `sleep(20)`,然后立刻执行客户端相关的方法,同时抓个包,就能得出结论。 ![不执行 accept 时抓包结果](https://img-blog.csdnimg.cn/img_convert/2cfc1d028f3e37f10c2f81375ddb998a.png) 从抓包结果看来,**就算不执行 `accept()` 方法,三次握手照常进行,并顺利建立连接。** 更骚气的是,**在服务端执行 `accept()` 前,如果客户端发送消息给服务端,服务端是能够正常回复 ack 确认包的。** 并且,`sleep(20)` 结束后,服务端正常执行 `accept()`,客户端前面发送的消息,还是能正常收到的。 通过这个现象,我们可以多想想为什么。顺便好好了解下三次握手的细节。 ## 三次握手的细节分析 我们先看面试八股文的老股,三次握手。 ![TCP 三次握手](https://img-blog.csdnimg.cn/img_convert/8d55a06f2efa946921ff61a008c76b00.png) 服务端代码,对 socket 执行 bind 方法可以绑定监听端口,然后执行 `listen` 方法后,就会进入监听(`LISTEN`)状态。内核会为每一个处于 `LISTEN` 状态的 `socket` 分配两个队列,分别叫**半连接队列和全连接队列**。 ![每个 listen Socket 都有一个全连接和半连接队列](https://img-blog.csdnimg.cn/img_convert/d7e2d60b28b0f9b460aafbf1bd6e7892.png) ### 半连接队列、全连接队列是什么 ![半连接队列和全连接队列](https://img-blog.csdnimg.cn/img_convert/36242c85809865fcd2da48594de15ebb.png) - **半连接队列(`SYN` 队列)**,服务端收到**第一次握手**后,会将 `sock` 加入到这个队列中,队列内的 `sock` 都处于 `SYN_RECV` 状态。 - **全连接队列(`ACCEPT` 队列)**,在服务端收到**第三次握手**后,会将半连接队列的 `sock` 取出,放到全连接队列中。队列里的 `sock` 都处于 `ESTABLISHED` 状态。这里面的连接,就**等着服务端执行 `accept()` 后被取出了。** 看到这里,文章开头的问题就有了答案,建立连接的过程中根本不需要 `accept()` 参与, **执行 `accept()` 只是为了从全连接队列里取出一条连接。** 我们把话题再重新回到这两个队列上。 虽然都叫**队列**,但其实**全连接队列(icsk_accept_queue)是个链表**,而**半连接队列(syn_table)是个哈希表**。 ![半连接全连接队列的内部结构](https://img-blog.csdnimg.cn/img_convert/6f964fb09d6971dab1762a45dfa30b3b.png) ### 为什么半连接队列要设计成哈希表 先对比下**全连接里队列**,他本质是个链表,因为也是线性结构,说它是个队列也没毛病。它里面放的都是已经建立完成的连接,这些连接正等待被取走。而服务端取走连接的过程中,并不关心具体是哪个连接,只要是个连接就行,所以直接从队列头取就行了。这个过程算法复杂度为 `O(1)`。 而**半连接队列**却不太一样,因为队列里的都是不完整的连接,嗷嗷等待着第三次握手的到来。那么现在有一个第三次握手来了,则需要从队列里把相应 IP 端口的连接取出,**如果半连接队列还是个链表,那我们就需要依次遍历,才能拿到我们想要的那个连接,算法复杂度就是 `O(n)`。** 而如果将半连接队列设计成哈希表,那么查找半连接的算法复杂度就回到 `O(1)` 了。 因此出于效率考虑,全连接队列被设计成链表,而半连接队列被设计为哈希表。 ### 怎么观察两个队列的大小 #### 查看全连接队列 ```shell # ss -lnt State Recv-Q Send-Q Local Address:Port Peer Address:Port LISTEN 0 128 127.0.0.1:46269 *:* ``` 通过 `ss -lnt` 命令,可以看到全连接队列的大小,其中 `Send-Q` 是指全连接队列的最大值,可以看到我这上面的最大值是 `128`;`Recv-Q` 是指当前的全连接队列的使用值,我这边用了 `0` 个,也就是全连接队列里为空,连接都被取出来了。 当上面 `Send-Q` 和 `Recv-Q` 数值很接近的时候,那么全连接队列可能已经满了。可以通过下面的命令查看是否发生过队列**溢出**。 ```shell # netstat -s | grep overflowed 4343 times the listen queue of a socket overflowed ``` 上面说明发生过 `4343` 次全连接队列溢出的情况。这个查看到的是**历史发生过的次数**。 如果配合使用 `watch -d` 命令,可以自动每 `2s` 间隔执行相同命令,还能高亮显示变化的数字部分,如果溢出的数字不断变多,说明**正在发生**溢出的行为。 ```shell # watch -d 'netstat -s | grep overflowed' Every 2.0s: netstat -s | grep overflowed Fri Sep 17 09:00:45 2021 4343 times the listen queue of a socket overflowed ``` #### 查看半连接队列 半连接队列没有命令可以直接查看到,但因为半连接队列里,放的都是 `SYN_RECV` 状态的连接,那可以通过统计处于这个状态的连接的数量,间接获得半连接队列的长度。 ```shell # netstat -nt | grep -i '127.0.0.1:8080' | grep -i 'SYN_RECV' | wc -l 0 ``` 注意半连接队列和全连接队列都是挂在某个 `Listen socket` 上的,我这里用的是 `127.0.0.1:8080`,大家可以替换成自己想要查看的 **IP 端口**。 可以看到我的机器上的半连接队列长度为 `0`,这个很正常,**正经连接谁会没事老待在半连接队列里。** 当队列里的半连接不断增多,最终也是会发生溢出,可以通过下面的命令查看。 ```shell # netstat -s | grep -i "SYNs to LISTEN sockets dropped" 26395 SYNs to LISTEN sockets dropped ``` 可以看到,我的机器上一共发生了 `26395` 次半连接队列溢出。同样建议配合 `watch -d` 命令使用。 ```shell # watch -d 'netstat -s | grep -i "SYNs to LISTEN sockets dropped"' Every 2.0s: netstat -s | grep -i "SYNs to LISTEN sockets dropped" Fri Sep 17 08:36:38 2021 26395 SYNs to LISTEN sockets dropped ``` ### 全连接队列满了会怎么样? 如果队列满了,服务端还收到客户端的第三次握手 ACK,默认当然会丢弃这个 ACK。 但除了丢弃之外,还有一些附带行为,这会受 `tcp_abort_on_overflow` 参数的影响。 ```shell # cat /proc/sys/net/ipv4/tcp_abort_on_overflow 0 ``` - `tcp_abort_on_overflow` 设置为 `0`,全连接队列满了之后,会丢弃这个第三次握手 ACK 包,并且开启定时器,重传第二次握手的 SYN+ACK,如果重传超过一定限制次数,还会把对应的**半连接队列里的连接**给删掉。 ![tcp_abort_on_overflow 为 0](https://img-blog.csdnimg.cn/img_convert/874f2fb7108020fd4dcfa021f377ec66.png) - `tcp_abort_on_overflow`设置为 `1`,全连接队列满了之后,就直接发 RST 给客户端,效果上看就是连接断了。 这个现象是不是很熟悉,服务端**端口未监听**时,客户端尝试去连接,服务端也会回一个 RST。这两个情况长一样,所以客户端这时候收到 RST 之后,其实无法区分到底是**端口未监听**,还是**全连接队列满了**。 ![tcp_abort_on_overflow 为 1](https://img-blog.csdnimg.cn/img_convert/6a01c5df74748870a69921da89825d9c.png) ### 半连接队列要是满了会怎么样 **一般是丢弃**,但这个行为可以通过 `tcp_syncookies` 参数去控制。但比起这个,更重要的是先了解下半连接队列为什么会被打满。 首先我们需要明白,一般情况下,半连接的"生存"时间其实很短,只有在第一次和第三次握手间,如果半连接都满了,说明服务端疯狂收到第一次握手请求,如果是线上游戏应用,能有这么多请求进来,那说明你可能要富了。但现实往往比较骨感,你可能遇到了 **SYN Flood 攻击**。 所谓 **SYN Flood 攻击**,可以简单理解为,攻击方模拟客户端疯狂发第一次握手请求过来,在服务端憨憨地回复第二次握手过去之后,客户端死活不发第三次握手过来,这样做,可以把服务端半连接队列打满,从而导致正常连接不能正常进来。 ![syn 攻击](https://img-blog.csdnimg.cn/img_convert/d894de5374a12bd5d75d86d4a718d186.png) 那这种情况怎么处理?有没有一种方法可以**绕过半连接队列**? 有,上面提到的 `tcp_syncookies` 派上用场了。 ```shell # cat /proc/sys/net/ipv4/tcp_syncookies 1 ``` 当它被设置为 `1` 的时候,客户端发来**第一次握手** SYN 时,服务端**不会将其放入半连接队列中**,而是直接生成一个 `cookies`,这个 `cookies` 会跟着**第二次握手**,发回客户端。客户端在发**第三次握手**的时候带上这个 `cookies`,服务端验证到它就是当初发出去的那个,就会建立连接并放入到全连接队列中。可以看出整个过程不再需要半连接队列的参与。 ![tcp_syncookies=1](https://img-blog.csdnimg.cn/img_convert/d696b8b345526533bde8fa990e205c32.png) #### 会有一个 cookies 队列吗 生成是 `cookies`,保存在哪呢?**是不是会有一个队列保存这些 cookies?** 我们可以反过来想一下,如果有 `cookies` 队列,那它会跟半连接队列一样,到头来,还是会被 **SYN Flood 攻击**打满。 实际上 `cookies` 并不会有一个专门的队列保存,它是通过**通信双方的 IP 地址端口、时间戳、MSS**等信息进行**实时计算**的,保存在 **TCP 报头**的 `seq` 里。 ![tcp 报头_seq 的位置](https://img-blog.csdnimg.cn/img_convert/6d280b0946a73ea6185653cbcfcc489f.png) 当服务端收到客户端发来的第三次握手包时,会通过 seq 还原出**通信双方的 IP 地址端口、时间戳、MSS**,验证通过则建立连接。 #### cookies 方案为什么不直接取代半连接队列? 目前看下来 `syn cookies` 方案省下了半连接队列所需要的队列内存,还能解决 **SYN Flood 攻击**,那为什么不直接取代半连接队列? 凡事皆有利弊,`cookies` 方案虽然能防 **SYN Flood 攻击**,但是也有一些问题。因为服务端并不会保存连接信息,所以如果传输过程中数据包丢了,也不会重发第二次握手的信息。 另外,编码解码 `cookies`,都是比较**耗 CPU** 的,利用这一点,如果此时攻击者构造大量的**第三次握手包(ACK 包)**,同时带上各种瞎编的 `cookies` 信息,服务端收到 `ACK 包`后**以为是正经 cookies**,憨憨地跑去解码(**耗 CPU**),最后发现不是正经数据包后才丢弃。 这种通过构造大量 `ACK 包`去消耗服务端资源的攻击,叫 **ACK 攻击**,受到攻击的服务器可能会因为 **CPU 资源耗尽**导致没能响应正经请求。 ![ack 攻击](https://img-blog.csdnimg.cn/img_convert/15a0a5f7fe15ee2bc5e07492eda5a8ea.gif) ### 没有 listen,为什么还能建立连接 那既然没有 `accept` 方法能建立连接,那是不是没有 `listen` 方法,也能建立连接?是的,之前写的一篇文章提到过客户端是可以自己连自己的形成连接(**TCP 自连接**),也可以两个客户端同时向对方发出请求建立连接(**TCP 同时打开**),这两个情况都有个共同点,就是**没有服务端参与,也就是没有 listen,就能建立连接。** 当时文章最后也留了个疑问,**没有 listen,为什么还能建立连接?** 我们知道执行 `listen` 方法时,会创建半连接队列和全连接队列。 三次握手的过程中会在这两个队列中暂存连接信息。 所以形成连接,前提是你得**有个地方存放着**,方便握手的时候能根据 IP 端口等信息找到 socket 信息。 **那么客户端会有半连接队列吗?** **显然没有**,因为客户端没有执行 `listen`,因为半连接队列和全连接队列都是在执行 `listen` 方法时,内核自动创建的。 但内核还有个**全局 `hash` 表**,可以用于存放 `sock` 连接的信息。这个全局 `hash` 表其实还细分为 `ehash,bhash和listen_hash` 等,但因为过于细节,大家理解成有一个**全局 hash** 就够了。 在 TCP 自连接的情况中,客户端在 `connect` 方法时,最后会将自己的连接信息放入到这个**全局 hash 表**中,然后将信息发出,消息在经过回环地址重新回到 TCP 传输层的时候,就会根据 IP 端口信息,再一次从这个**全局 hash** 中取出信息。于是握手包一来一回,最后成功建立连接。 TCP 同时打开的情况也类似,只不过从一个客户端变成了两个客户端而已。 ## 总结 - **每一个** `socket` 执行 `listen` 时,内核都会自动创建一个半连接队列和全连接队列。 - 第三次握手前,TCP 连接会放在半连接队列中,直到第三次握手到来,才会被放到全连接队列中。 - `accept` 方法只是为了从全连接队列中拿出一条连接,本身跟三次握手几乎**毫无关系**。 - 出于效率考虑,虽然都叫队列,但半连接队列其实被设计成了**哈希表**,而全连接队列本质是链表。 - 全连接队列满了,再来第三次握手也会丢弃,此时如果 `tcp_abort_on_overflow=1`,还会直接发 `RST` 给客户端。 - 半连接队列满了,可能是因为受到了 `SYN Flood` 攻击,可以设置 `tcp_syncookies`,绕开半连接队列。 - 客户端没有半连接队列和全连接队列,但有一个**全局 hash**,可以通过它实现自连接或 TCP 同时打开。 --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/3_tcp/tcp_no_listen.md ================================================ # 4.19 服务端没有 listen,客户端发起连接建立,会发生什么? 大家好,我是小林。 早上看到一个读者说面字节三面的时候,问了这个问题: ![图片](https://img-blog.csdnimg.cn/img_convert/5f5b9c96c86580e3f14978d5c10c7721.jpeg) 这位读者的角度是以为服务端没有调用 listen,客户端会 ping 不通服务器,很明显,搞错了。 ping 使用的协议是 ICMP,属于网络层的事情,而面试官问的是传输层的问题。 针对这个问题,服务端如果只 bind 了 IP 地址和端口,而没有调用 listen 的话,然后客户端对服务端发起了 TCP 连接建立,此时那么会发生什么呢? ## 做个实验 这个问题,自己做个实验就知道了。 我用下面这个程序作为例子,绑定了 IP 地址 + 端口,而没有调用 listen。 ```c /*******服务器程序 TCPServer.c ************/ #include #include #include #include #include #include #include #include int main(int argc, char *argv[]) { int sockfd, ret; struct sockaddr_in server_addr; /* 服务器端创建 tcp socket 描述符 */ sockfd = socket(AF_INET, SOCK_STREAM, 0); if(sockfd < 0) { fprintf(stderr, "Socket error:%s\n\a", strerror(errno)); exit(1); } /* 服务器端填充 sockaddr 结构 */ bzero(&server_addr, sizeof(struct sockaddr_in)); server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = htonl(INADDR_ANY); server_addr.sin_port = htons(8888); /* 绑定 ip + 端口 */ ret = bind(sockfd, (struct sockaddr *)(&server_addr), sizeof(struct sockaddr)); if(ret < 0) { fprintf(stderr, "Bind error:%s\n\a", strerror(errno)); exit(1); } //没有调用 listen sleep(1000); close(sockfd); return 0; } ``` 然后,我用浏览器访问这个地址:http://121.43.173.240:8888/ ![图片](https://img-blog.csdnimg.cn/img_convert/5bdb5443db5b97ff724ab94e014af6a5.png) 报错连接服务器失败。 同时,我也用抓包工具,抓了这个过程。 ![图片](https://img-blog.csdnimg.cn/img_convert/a77921ffafbbff86d07983ca0db3e6e0.png) 可以看到,客户端对服务端发起 SYN 报文后,服务端回了 RST 报文。 所以,这个问题就有了答案,**服务端如果只 bind 了 IP 地址和端口,而没有调用 listen 的话,然后客户端对服务端发起了连接建立,服务端会回 RST 报文。** ## 源码分析 接下来,带大家源码分析一下。 Linux 内核处理收到 TCP 报文的入口函数是 tcp_v4_rcv,在收到 TCP 报文后,会调用 __inet_lookup_skb 函数找到 TCP 报文所属 socket。 ```plain int tcp_v4_rcv(struct sk_buff *skb) { ... sk = __inet_lookup_skb(&tcp_hashinfo, skb, th->source, th->dest); if (!sk) goto no_tcp_socket; ... } ``` __inet_lookup_skb 函数首先查找连接建立状态的 socket(__inet_lookup_established),在没有命中的情况下,才会查找监听套接口(__inet_lookup_listener)。 ![图片](https://img-blog.csdnimg.cn/img_convert/88416aa95d255495e07fb3a002b2167b.png) 查找监听套接口(__inet_lookup_listener)这个函数的实现是,根据目的地址和目的端口算出一个哈希值,然后在哈希表找到对应监听该端口的 socket。 本次的案例中,服务端是没有调用 listen 函数的,所以自然也是找不到监听该端口的 socket。 所以,__inet_lookup_skb 函数最终找不到对应的 socket,于是跳转到 no_tcp_socket。 ![图片](https://img-blog.csdnimg.cn/img_convert/54ee363e149ee3dfba30efb1a542ef5c.png) 在这个错误处理中,只要收到的报文(skb)的「校验和」没问题的话,内核就会调用 tcp_v4_send_reset 发送 RST 中止这个连接。 至此,整个源码流程就解析完。 其实很多网络的问题,大家都可以自己做实验来找到答案的。 ![图片](https://img-blog.csdnimg.cn/img_convert/8d04584bf7fa40f02229d611a569f370.jpeg) ## 没有 listen,能建立 TCP 连接吗? 标题的问题在前面已经解答,**现在我们看另外一个相似的问题**。 之前看群消息,看到有读者面试腾讯的时候,被问到这么一个问题。 > 不使用 listen,可以建立 TCP 连接吗? 答案,**是可以的,客户端是可以自己连自己的形成连接(TCP 自连接),也可以两个客户端同时向对方发出请求建立连接(TCP 同时打开),这两个情况都有个共同点,就是没有服务端参与,也就是没有 listen,就能建立连接**。 > 那没有 listen,为什么还能建立连接? 我们知道执行 listen 方法时,会创建半连接队列和全连接队列。 三次握手的过程中会在这两个队列中暂存连接信息。 所以形成连接,前提是你得有个地方存放着,方便握手的时候能根据 IP + 端口等信息找到对应的 socket。 > 那么客户端会有半连接队列吗? 显然没有,因为客户端没有执行 listen,因为半连接队列和全连接队列都是在执行 listen 方法时,内核自动创建的。 但内核还有个全局 hash 表,可以用于存放 sock 连接的信息。 这个全局 hash 表其实还细分为 ehash,bhash 和 listen_hash 等,但因为过于细节,大家理解成有一个全局 hash 就够了, **在 TCP 自连接的情况中,客户端在 connect 方法时,最后会将自己的连接信息放入到这个全局 hash 表中,然后将信息发出,消息在经过回环地址重新回到 TCP 传输层的时候,就会根据 IP + 端口信息,再一次从这个全局 hash 中取出信息。于是握手包一来一回,最后成功建立连接**。 TCP 同时打开的情况也类似,只不过从一个客户端变成了两个客户端而已。 > 做个实验 客户端自连接的代码,TCP socket 可以 connect 它本身 bind 的地址和端口: ```c #include #include #include #include #include #include #include #include #define LOCAL_IP_ADDR (0x7F000001) // IP 127.0.0.1 #define LOCAL_TCP_PORT (34567) // 端口 int main(void) { struct sockaddr_in local, peer; int ret; char buf[128]; int sock = socket(AF_INET, SOCK_STREAM, 0); memset(&local, 0, sizeof(local)); memset(&peer, 0, sizeof(peer)); local.sin_family = AF_INET; local.sin_port = htons(LOCAL_TCP_PORT); local.sin_addr.s_addr = htonl(LOCAL_IP_ADDR); peer = local; int flag = 1; ret = setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag)); if (ret == -1) { printf("Fail to setsocket SO_REUSEADDR: %s\n", strerror(errno)); exit(1); } ret = bind(sock, (const struct sockaddr *)&local, sizeof(local)); if (ret) { printf("Fail to bind: %s\n", strerror(errno)); exit(1); } ret = connect(sock, (const struct sockaddr *)&peer, sizeof(peer)); if (ret) { printf("Fail to connect myself: %s\n", strerror(errno)); exit(1); } printf("Connect to myself successfully\n"); //发送数据 strcpy(buf, "Hello, myself~"); send(sock, buf, strlen(buf), 0); memset(buf, 0, sizeof(buf)); //接收数据 recv(sock, buf, sizeof(buf), 0); printf("Recv the msg: %s\n", buf); sleep(1000); close(sock); return 0; } ``` 编译运行: ![](https://img-blog.csdnimg.cn/9db974179b9e4a279f7edb0649752c27.png) 通过 netstat 命令命令客户端自连接的 TCP 连接: ![在这里插入图片描述](https://img-blog.csdnimg.cn/e2b116e843c14e468eadf9d30e1b877c.png) 从截图中,可以看到 TCP socket 成功的“连接”了自己,并发送和接收了数据包,netstat 的输出更证明了 TCP 的两端地址和端口是完全相同的。 --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/3_tcp/tcp_optimize.md ================================================ # 4.5 如何优化 TCP? TCP 性能的提升不仅考察 TCP 的理论知识,还考察了对于操作系统提供的内核参数的理解与应用。 TCP 协议是由操作系统实现,所以操作系统提供了不少调节 TCP 的参数。 ![Linux TCP 参数](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/2.jpg) 如何正确有效的使用这些参数,来提高 TCP 性能是一个不那么简单事情。我们需要针对 TCP 每个阶段的问题来对症下药,而不是病急乱投医。 接下来,将以三个角度来阐述提升 TCP 的策略,分别是: - TCP 三次握手的性能提升; - TCP 四次挥手的性能提升; - TCP 数据传输的性能提升; ![本节提纲](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/3.jpg) --- ## TCP 三次握手的性能提升 TCP 是面向连接的、可靠的、双向传输的传输层通信协议,所以在传输数据之前需要经过三次握手才能建立连接。 ![三次握手与数据传输](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/4.jpg) 那么,三次握手的过程在一个 HTTP 请求的平均时间占比 10% 以上,在网络状态不佳、高并发或者遭遇 SYN 攻击等场景中,如果不能有效正确的调节三次握手中的参数,就会对性能产生很多的影响。 如何正确有效的使用这些参数,来提高 TCP 三次握手的性能,这就需要理解「三次握手的状态变迁」,这样当出现问题时,先用 `netstat` 命令查看是哪个握手阶段出现了问题,再来对症下药,而不是病急乱投医。 ![TCP 三次握手的状态变迁](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/5.jpg) 客户端和服务端都可以针对三次握手优化性能。主动发起连接的客户端优化相对简单些,而服务端需要监听端口,属于被动连接方,其间保持许多的中间状态,优化方法相对复杂一些。 所以,客户端(主动发起连接方)和服务端(被动连接方)优化的方式是不同的,接下来分别针对客户端和服务端优化。 ### 客户端优化 三次握手建立连接的首要目的是「同步序列号」。 只有同步了序列号才有可靠传输,TCP 许多特性都依赖于序列号实现,比如流量控制、丢包重传等,这也是三次握手中的报文称为 SYN 的原因,SYN 的全称就叫 *Synchronize Sequence Numbers*(同步序列号)。 ![TCP 头部](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/6.jpg) > SYN_SENT 状态的优化 客户端作为主动发起连接方,首先它将发送 SYN 包,于是客户端的连接就会处于 `SYN_SENT` 状态。 客户端在等待服务端回复的 ACK 报文,正常情况下,服务器会在几毫秒内返回 SYN+ACK,但如果客户端长时间没有收到 SYN+ACK 报文,则会重发 SYN 包,**重发的次数由 tcp_syn_retries 参数控制**,默认是 5 次: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/7.jpg) 通常,第一次超时重传是在 1 秒后,第二次超时重传是在 2 秒,第三次超时重传是在 4 秒后,第四次超时重传是在 8 秒后,第五次是在超时重传 16 秒后。没错,**每次超时的时间是上一次的 2 倍**。 当第五次超时重传后,会继续等待 32 秒,如果服务端仍然没有回应 ACK,客户端就会终止三次握手。 所以,总耗时是 1+2+4+8+16+32=63 秒,大约 1 分钟左右。 ![SYN 超时重传](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/8.jpg) 你可以根据网络的稳定性和目标服务器的繁忙程度修改 SYN 的重传次数,调整客户端的三次握手时间上限。比如内网中通讯时,就可以适当调低重试次数,尽快把错误暴露给应用程序。 ### 服务端优化 当服务端收到 SYN 包后,服务端会立马回复 SYN+ACK 包,表明确认收到了客户端的序列号,同时也把自己的序列号发给对方。 此时,服务端出现了新连接,状态是 `SYN_RCV`。在这个状态下,Linux 内核就会建立一个「半连接队列」来维护「未完成」的握手信息,当半连接队列溢出后,服务端就无法再建立新的连接。 ![半连接队列与全连接队列](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/9.jpg) SYN 攻击,攻击的是就是这个半连接队列。 > 如何查看由于 SYN 半连接队列已满,而被丢弃连接的情况? 我们可以通过该 `netstat -s` 命令给出的统计结果中,可以得到由于半连接队列已满,引发的失败次数: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/10.jpg) 上面输出的数值是**累计值**,表示共有多少个 TCP 连接因为半连接队列溢出而被丢弃。**隔几秒执行几次,如果有上升的趋势,说明当前存在半连接队列溢出的现象**。 > 如何调整 SYN 半连接队列大小? 要想增大半连接队列,**不能只单纯增大 tcp_max_syn_backlog 的值,还需一同增大 somaxconn 和 backlog,也就是增大 accept 队列。否则,只单纯增大 tcp_max_syn_backlog 是无效的。** 增大 tcp_max_syn_backlog 和 somaxconn 的方法是修改 Linux 内核参数: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/11.jpg) 增大 backlog 的方式,每个 Web 服务都不同,比如 Nginx 增大 backlog 的方法如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/12.jpg) 最后,改变了如上这些参数后,要重启 Nginx 服务,因为 SYN 半连接队列和 accept 队列都是在 `listen()` 初始化的。 > 如果 SYN 半连接队列已满,只能丢弃连接吗? 并不是这样,**开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接**。 syncookies 的工作原理:服务器根据当前状态计算出一个值,放在己方发出的 SYN+ACK 报文中发出,当客户端返回 ACK 报文时,取出该值验证,如果合法,就认为连接建立成功,如下图所示。 ![开启 syncookies 功能](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/13.jpg) syncookies 参数主要有以下三个值: - 0 值,表示关闭该功能; - 1 值,表示仅当 SYN 半连接队列放不下时,再启用它; - 2 值,表示无条件开启功能; 那么在应对 SYN 攻击时,只需要设置为 1 即可: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/14.jpg) > SYN_RCV 状态的优化 当客户端接收到服务器发来的 SYN+ACK 报文后,就会回复 ACK 给服务器,同时客户端连接状态从 SYN_SENT 转换为 ESTABLISHED,表示连接建立成功。 服务器端连接成功建立的时间还要再往后,等到服务端收到客户端的 ACK 后,服务端的连接状态才变为 ESTABLISHED。 如果服务器没有收到 ACK,就会重发 SYN+ACK 报文,同时一直处于 SYN_RCV 状态。 当网络繁忙、不稳定时,报文丢失就会变严重,此时应该调大重发次数。反之则可以调小重发次数。**修改重发次数的方法是,调整 tcp_synack_retries 参数**: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/15.jpg) tcp_synack_retries 的默认重试次数是 5 次,与客户端重传 SYN 类似,它的重传会经历 1、2、4、8、16 秒,最后一次重传后会继续等待 32 秒,如果服务端仍然没有收到 ACK,才会关闭连接,故共需要等待 63 秒。 服务器收到 ACK 后连接建立成功,此时,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来。 如果进程不能及时地调用 accept 函数,就会造成 accept 队列(也称全连接队列)溢出,最终导致建立好的 TCP 连接被丢弃。 ![ accept 队列溢出](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/16.jpg) > accept 队列已满,只能丢弃连接吗? 丢弃连接只是 Linux 的默认行为,我们还可以选择向客户端发送 RST 复位报文,告诉客户端连接已经建立失败。打开这一功能需要将 tcp_abort_on_overflow 参数设置为 1。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/17.jpg) tcp_abort_on_overflow 共有两个值分别是 0 和 1,其分别表示: - 0:如果 accept 队列满了,那么 server 扔掉 client 发过来的 ack; - 1:如果 accept 队列满了,server 发送一个 `RST` 包给 client,表示废掉这个握手过程和这个连接; 如果要想知道客户端连接不上服务端,是不是服务端 TCP 全连接队列满的原因,那么可以把 tcp_abort_on_overflow 设置为 1,这时如果在客户端异常中可以看到很多 `connection reset by peer` 的错误,那么就可以证明是由于服务端 TCP 全连接队列溢出的问题。 通常情况下,应当把 tcp_abort_on_overflow 设置为 0,因为这样更有利于应对突发流量。 举个例子,当 accept 队列满导致服务器丢掉了 ACK,与此同时,客户端的连接状态却是 ESTABLISHED,客户端进程就在建立好的连接上发送请求。只要服务器没有为请求回复 ACK,客户端的请求就会被多次「重发」。**如果服务器上的进程只是短暂的繁忙造成 accept 队列满,那么当 accept 队列有空位时,再次接收到的请求报文由于含有 ACK,仍然会触发服务器端成功建立连接。** ![tcp_abort_on_overflow 为 0 可以应对突发流量](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/18.jpg) 所以,tcp_abort_on_overflow 设为 0 可以提高连接建立的成功率,只有你非常肯定 TCP 全连接队列会长期溢出时,才能设置为 1 以尽快通知客户端。 > 如何调整 accept 队列的长度呢? accept 队列的长度取决于 somaxconn 和 backlog 之间的最小值,也就是 min(somaxconn, backlog),其中: - somaxconn 是 Linux 内核的参数,默认值是 128,可以通过 `net.core.somaxconn` 来设置其值; - backlog 是 `listen(int sockfd, int backlog)` 函数中的 backlog 大小; Tomcat、Nginx、Apache 常见的 Web 服务的 backlog 默认值都是 511。 > 如何查看服务端进程 accept 队列的长度? 可以通过 `ss -ltn` 命令查看: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/19.jpg) - Recv-Q:当前 accept 队列的大小,也就是当前已完成三次握手并等待服务端 `accept()` 的 TCP 连接; - Send-Q:accept 队列最大长度,上面的输出结果说明监听 8088 端口的 TCP 服务,accept 队列的最大长度为 128; > 如何查看由于 accept 连接队列已满,而被丢弃的连接? 当超过了 accept 连接队列,服务端则会丢掉后续进来的 TCP 连接,丢掉的 TCP 连接的个数会被统计起来,我们可以使用 netstat -s 命令来查看: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/20.jpg) 上面看到的 41150 times,表示 accept 队列溢出的次数,注意这个是累计值。可以隔几秒钟执行下,如果这个数字一直在增加的话,说明 accept 连接队列偶尔满了。 如果持续不断地有连接因为 accept 队列溢出被丢弃,就应该调大 backlog 以及 somaxconn 参数。 ### 如何绕过三次握手? 以上我们只是在对三次握手的过程进行优化,接下来我们看看如何绕过三次握手发送数据。 三次握手建立连接造成的后果就是,HTTP 请求必须在一个 RTT(从客户端到服务器一个往返的时间)后才能发送。 ![常规 HTTP 请求](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/21.jpg) 在 Linux 3.7 内核版本之后,提供了 TCP Fast Open 功能,这个功能可以减少 TCP 连接建立的时延。 > 接下来说说,TCP Fast Open 功能的工作方式。 ![开启 TCP Fast Open 功能](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/22.jpg) 在客户端首次建立连接时的过程: 1. 客户端发送 SYN 报文,该报文包含 Fast Open 选项,且该选项的 Cookie 为空,这表明客户端请求 Fast Open Cookie; 2. 支持 TCP Fast Open 的服务器生成 Cookie,并将其置于 SYN-ACK 数据包中的 Fast Open 选项以发回客户端; 3. 客户端收到 SYN-ACK 后,本地缓存 Fast Open 选项中的 Cookie。 所以,第一次发起 HTTP GET 请求的时候,还是需要正常的三次握手流程。 之后,如果客户端再次向服务器建立连接时的过程: 1. 客户端发送 SYN 报文,该报文包含「数据」(对于非 TFO 的普通 TCP 握手过程,SYN 报文中不包含「数据」)以及此前记录的 Cookie; 2. 支持 TCP Fast Open 的服务器会对收到 Cookie 进行校验:如果 Cookie 有效,服务器将在 SYN-ACK 报文中对 SYN 和「数据」进行确认,服务器随后将「数据」递送至相应的应用程序;如果 Cookie 无效,服务器将丢弃 SYN 报文中包含的「数据」,且其随后发出的 SYN-ACK 报文将只确认 SYN 的对应序列号; 3. 如果服务器接受了 SYN 报文中的「数据」,服务器可在握手完成之前发送「数据」,**这就减少了握手带来的 1 个 RTT 的时间消耗**; 4. 客户端将发送 ACK 确认服务器发回的 SYN 以及「数据」,但如果客户端在初始的 SYN 报文中发送的「数据」没有被确认,则客户端将重新发送「数据」; 5. 此后的 TCP 连接的数据传输过程和非 TFO 的正常情况一致。 所以,之后发起 HTTP GET 请求的时候,可以绕过三次握手,这就减少了握手带来的 1 个 RTT 的时间消耗。 开启了 TFO 功能,cookie 的值是存放到 TCP option 字段里的: ![TCP option 字段 - TFO](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%82%E6%95%B0/TCP%20option%E5%AD%97%E6%AE%B5%20-%20TFO.png) 注:客户端在请求并存储了 Fast Open Cookie 之后,可以不断重复 TCP Fast Open 直至服务器认为 Cookie 无效(通常为过期)。 > Linux 下怎么打开 TCP Fast Open 功能呢? 在 Linux 系统中,可以通过**设置 tcp_fastopn 内核参数,来打开 Fast Open 功能**: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/23.jpg) tcp_fastopn 各个值的意义: - 0 关闭 - 1 作为客户端使用 Fast Open 功能 - 2 作为服务端使用 Fast Open 功能 - 3 无论作为客户端还是服务器,都可以使用 Fast Open 功能 **TCP Fast Open 功能需要客户端和服务端同时支持,才有效果。** ### 小结 本小结主要介绍了关于优化 TCP 三次握手的几个 TCP 参数。 ![三次握手优化策略](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/24.jpg) > 客户端的优化 当客户端发起 SYN 包时,可以通过 `tcp_syn_retries` 控制其重传的次数。 > 服务端的优化 当服务端 SYN 半连接队列溢出后,会导致后续连接被丢弃,可以通过 `netstat -s` 观察半连接队列溢出的情况,如果 SYN 半连接队列溢出情况比较严重,可以通过 `tcp_max_syn_backlog、somaxconn、backlog` 参数来调整 SYN 半连接队列的大小。 服务端回复 SYN+ACK 的重传次数由 `tcp_synack_retries` 参数控制。如果遭受 SYN 攻击,应把 `tcp_syncookies` 参数设置为 1,表示仅在 SYN 队列满后开启 syncookie 功能,可以保证正常的连接成功建立。 服务端收到客户端返回的 ACK,会把连接移入 accpet 队列,等待进行调用 accpet() 函数取出连接。 可以通过 `ss -lnt` 查看服务端进程的 accept 队列长度,如果 accept 队列溢出,系统默认丢弃 ACK,如果可以把 `tcp_abort_on_overflow` 设置为 1,表示用 RST 通知客户端连接建立失败。 如果 accpet 队列溢出严重,可以通过 listen 函数的 `backlog` 参数和 `somaxconn` 系统参数提高队列大小,accept 队列长度取决于 min(backlog, somaxconn)。 > 绕过三次握手 TCP Fast Open 功能可以绕过三次握手,使得 HTTP 请求减少了 1 个 RTT 的时间,Linux 下可以通过 `tcp_fastopen` 开启该功能,同时必须保证服务端和客户端同时支持。 --- ## TCP 四次挥手的性能提升 接下来,我们一起看看针对 TCP 四次挥手关闭连接时,如何优化性能。 在开始之前,我们得先了解四次挥手状态变迁的过程。 客户端和服务端双方都可以主动断开连接,**通常先关闭连接的一方称为主动方,后关闭连接的一方称为被动方。** ![客户端主动关闭](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/25.jpg) 可以看到,**四次挥手过程只涉及了两种报文,分别是 FIN 和 ACK**: - FIN 就是结束连接的意思,谁发出 FIN 报文,就表示它将不会再发送任何数据,关闭这一方向上的传输通道; - ACK 就是确认的意思,用来通知对方:你方的发送通道已经关闭; 四次挥手的过程: - 当主动方关闭连接时,会发送 FIN 报文,此时发送方的 TCP 连接将从 ESTABLISHED 变成 FIN_WAIT1。 - 当被动方收到 FIN 报文后,内核会自动回复 ACK 报文,连接状态将从 ESTABLISHED 变成 CLOSE_WAIT,表示被动方在等待进程调用 close 函数关闭连接。 - 当主动方收到这个 ACK 后,连接状态由 FIN_WAIT1 变为 FIN_WAIT2,也就是表示**主动方的发送通道就关闭了**。 - 当被动方进入 CLOSE_WAIT 时,被动方还会继续处理数据,等到进程的 read 函数返回 0 后,应用程序就会调用 close 函数,进而触发内核发送 FIN 报文,此时被动方的连接状态变为 LAST_ACK。 - 当主动方收到这个 FIN 报文后,内核会回复 ACK 报文给被动方,同时主动方的连接状态由 FIN_WAIT2 变为 TIME_WAIT,**在 Linux 系统下大约等待 1 分钟后,TIME_WAIT 状态的连接才会彻底关闭**。 - 当被动方收到最后的 ACK 报文后,**被动方的连接就会关闭**。 你可以看到,每个方向都需要**一个 FIN 和一个 ACK**,因此通常被称为**四次挥手**。 这里一点需要注意是:**主动关闭连接的,才有 TIME_WAIT 状态。** 主动关闭方和被动关闭方优化的思路也不同,接下来分别说说如何优化他们。 ### 主动方的优化 关闭连接的方式通常有两种,分别是 RST 报文关闭和 FIN 报文关闭。 如果进程收到 RST 报文,就直接关闭连接了,不需要走四次挥手流程,是一个暴力关闭连接的方式。 安全关闭连接的方式必须通过四次挥手,它由进程调用 `close` 和 `shutdown` 函数发起 FIN 报文(shutdown 参数须传入 SHUT_WR 或者 SHUT_RDWR 才会发送 FIN)。 > 调用 close 函数和 shutdown 函数有什么区别? 调用了 close 函数意味着完全断开连接,**完全断开不仅指无法传输数据,而且也不能发送数据。此时,调用了 close 函数的一方的连接叫做「孤儿连接」,如果你用 netstat -p 命令,会发现连接对应的进程名为空。** 使用 close 函数关闭连接是不优雅的。于是,就出现了一种优雅关闭连接的 `shutdown` 函数,**它可以控制只关闭一个方向的连接**: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/26.jpg) 第二个参数决定断开连接的方式,主要有以下三种方式: - SHUT_RD(0):**关闭连接的「读」这个方向**,如果接收缓冲区有已接收的数据,则将会被丢弃,并且后续再收到新的数据,会对数据进行 ACK,然后悄悄地丢弃。也就是说,对端还是会接收到 ACK,在这种情况下根本不知道数据已经被丢弃了。 - SHUT_WR(1):**关闭连接的「写」这个方向**,这就是常被称为「半关闭」的连接。如果发送缓冲区还有未发送的数据,将被立即发送出去,并发送一个 FIN 报文给对端。 - SHUT_RDWR(2):相当于 SHUT_RD 和 SHUT_WR 操作各一次,**关闭套接字的读和写两个方向**。 close 和 shutdown 函数都可以关闭连接,但这两种方式关闭的连接,不只功能上有差异,控制它们的 Linux 参数也不相同。 > FIN_WAIT1 状态的优化 主动方发送 FIN 报文后,连接就处于 FIN_WAIT1 状态,正常情况下,如果能及时收到被动方的 ACK,则会很快变为 FIN_WAIT2 状态。 但是当迟迟收不到对方返回的 ACK 时,连接就会一直处于 FIN_WAIT1 状态。此时,**内核会定时重发 FIN 报文,其中重发次数由 tcp_orphan_retries 参数控制**(注意,orphan 虽然是孤儿的意思,该参数却不只对孤儿连接有效,事实上,它对所有 FIN_WAIT1 状态下的连接都有效),默认值是 0。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/27.jpg) 你可能会好奇,这 0 表示几次?**实际上当为 0 时,特指 8 次**,从下面的内核源码可知: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/28.jpg) 如果 FIN_WAIT1 状态连接很多,我们就需要考虑降低 tcp_orphan_retries 的值,当重传次数超过 tcp_orphan_retries 时,连接就会直接关闭掉。 对于普遍正常情况时,调低 tcp_orphan_retries 就已经可以了。如果遇到恶意攻击,FIN 报文根本无法发送出去,这由 TCP 两个特性导致的: - 首先,TCP 必须保证报文是有序发送的,FIN 报文也不例外,当发送缓冲区还有数据没有发送时,FIN 报文也不能提前发送。 - 其次,TCP 有流量控制功能,当接收方接收窗口为 0 时,发送方就不能再发送数据。所以,当攻击者下载大文件时,就可以通过接收窗口设为 0,这就会使得 FIN 报文都无法发送出去,那么连接会一直处于 FIN_WAIT1 状态。 解决这种问题的方法,是**调整 tcp_max_orphans 参数,它定义了「孤儿连接」的最大数量**: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/29.jpg) 当进程调用了 `close` 函数关闭连接,此时连接就会是「孤儿连接」,因为它无法再发送和接收数据。Linux 系统为了防止孤儿连接过多,导致系统资源长时间被占用,就提供了 `tcp_max_orphans` 参数。如果孤儿连接数量大于它,新增的孤儿连接将不再走四次挥手,而是直接发送 RST 复位报文强制关闭。 > FIN_WAIT2 状态的优化 当主动方收到 ACK 报文后,会处于 FIN_WAIT2 状态,就表示主动方的发送通道已经关闭,接下来将等待对方发送 FIN 报文,关闭对方的发送通道。 这时,**如果连接是用 shutdown 函数关闭的,连接可以一直处于 FIN_WAIT2 状态,因为它可能还可以发送或接收数据。但对于 close 函数关闭的孤儿连接,由于无法再发送和接收数据,所以这个状态不可以持续太久,而 tcp_fin_timeout 控制了这个状态下连接的持续时长**,默认值是 60 秒: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/30.jpg) 它意味着对于孤儿连接(调用 close 关闭的连接),如果在 60 秒后还没有收到 FIN 报文,连接就会直接关闭。 这个 60 秒不是随便决定的,它与 TIME_WAIT 状态持续的时间是相同的,后面我们再来说说为什么是 60 秒。 > TIME_WAIT 状态的优化 TIME_WAIT 是主动方四次挥手的最后一个状态,也是最常遇见的状态。 当收到被动方发来的 FIN 报文后,主动方会立刻回复 ACK,表示确认对方的发送通道已经关闭,接着就处于 TIME_WAIT 状态。在 Linux 系统,TIME_WAIT 状态会持续 60 秒后才会进入关闭状态。 TIME_WAIT 状态的连接,在主动方看来确实快已经关闭了。然后,被动方没有收到 ACK 报文前,还是处于 LAST_ACK 状态。如果这个 ACK 报文没有到达被动方,被动方就会重发 FIN 报文。重发次数仍然由前面介绍过的 tcp_orphan_retries 参数控制。 TIME-WAIT 的状态尤其重要,主要是两个原因: - 防止历史连接中的数据,被后面相同四元组的连接错误的接收; - 保证「被动关闭连接」的一方,能被正确的关闭; *原因一:防止历史连接中的数据,被后面相同四元组的连接错误的接收* TIME-WAIT 的一个作用是**防止收到历史数据,从而导致数据错乱的问题。** 假设 TIME-WAIT 没有等待时间或时间过短,被延迟的数据包抵达后会发生什么呢? ![TIME-WAIT 时间过短,收到旧连接的数据报文](https://img-blog.csdnimg.cn/img_convert/6385cc99500b01ba2ef288c27523c1e7.png) - 如上图: - 服务端在关闭连接之前发送的 `SEQ = 301` 报文,被网络延迟了。 - 接着,服务端以相同的四元组重新打开了新连接,前面被延迟的 `SEQ = 301` 这时抵达了客户端,而且该数据报文的序列号刚好在客户端接收窗口内,因此客户端会正常接收这个数据报文,但是这个数据报文是上一个连接残留下来的,这样就产生数据错乱等严重的问题。 为了防止历史连接中的数据,被后面相同四元组的连接错误的接收,因此 TCP 设计了 TIME_WAIT 状态,状态会持续 `2MSL` 时长,这个时间**足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。** *原因二:保证「被动关闭连接」的一方,能被正确的关闭* 在 RFC 793 指出 TIME-WAIT 另一个重要的作用是: *TIME-WAIT - represents waiting for enough time to pass to be sure the remote TCP received the acknowledgment of its connection termination request.* 也就是说,TIME-WAIT 作用是**等待足够的时间以确保最后的 ACK 能让被动关闭方接收,从而帮助其正常关闭。** 如果客户端(主动关闭方)最后一次 ACK 报文(第四次挥手)在网络中丢失了,那么按照 TCP 可靠性原则,服务端(被动关闭方)会重发 FIN 报文。 假设客户端没有 TIME_WAIT 状态,而是在发完最后一次回 ACK 报文就直接进入 CLOSED 状态,如果该 ACK 报文丢失了,服务端则重传的 FIN 报文,而这时客户端已经进入到关闭状态了,在收到服务端重传的 FIN 报文后,就会回 RST 报文。 ![TIME-WAIT 时间过短,没有确保连接正常关闭](https://img-blog.csdnimg.cn/img_convert/3a81c23ce57c27cf63fc2b77e34de0ab.png) 服务端收到这个 RST 并将其解释为一个错误(Connection reset by peer),这对于一个可靠的协议来说不是一个优雅的终止方式。 为了防止这种情况出现,客户端必须等待足够长的时间,确保服务端能够收到 ACK,如果服务端没有收到 ACK,那么就会触发 TCP 重传机制,服务端会重新发送一个 FIN,这样一去一来刚好两个 MSL 的时间。 ![TIME-WAIT 时间正常,确保了连接正常关闭](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4/网络/TIME-WAIT连接正常关闭.drawio.png) 客户端在收到服务端重传的 FIN 报文时,TIME_WAIT 状态的等待时间,会重置回 2MSL。 我们再回过头来看看,为什么 TIME_WAIT 状态要保持 60 秒呢? 这与孤儿连接 FIN_WAIT2 状态默认保留 60 秒的原理是一样的,**因为这两个状态都需要保持 2MSL 时长。MSL 全称是 Maximum Segment Lifetime,它定义了一个报文在网络中的最长生存时间**(报文每经过一次路由器的转发,IP 头部的 TTL 字段就会减 1,减到 0 时报文就被丢弃,这就限制了报文的最长存活时间)。 为什么是 2 MSL 的时长呢?这其实是相当于**至少允许报文丢失一次**。比如,若 ACK 在一个 MSL 内丢失,这样被动方重发的 FIN 会在第 2 个 MSL 内到达,TIME_WAIT 状态的连接可以应对。 为什么不是 4 或者 8 MSL 的时长呢?你可以想象一个丢包率达到百分之一的糟糕网络,连续两次丢包的概率只有万分之一,这个概率实在是太小了,忽略它比解决它更具性价比。 **因此,TIME_WAIT 和 FIN_WAIT2 状态的最大时长都是 2 MSL,由于在 Linux 系统中,MSL 的值固定为 30 秒,所以它们都是 60 秒。** > TIME_WAIT 状态优化方式一 **Linux 提供了 tcp_max_tw_buckets 参数,当 TIME_WAIT 的连接数量超过该参数时,新关闭的连接就不再经历 TIME_WAIT 而直接关闭:** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/33.jpg) 当服务器的并发连接增多时,相应地,同时处于 TIME_WAIT 状态的连接数量也会变多,此时就应当调大 `tcp_max_tw_buckets` 参数,减少不同连接间数据错乱的概率。tcp_max_tw_buckets 也不是越大越好,毕竟系统资源是有限的。 > TIME_WAIT 状态优化方式二 **有一种方式可以在建立新连接时,复用处于 TIME_WAIT 状态的连接,那就是打开 tcp_tw_reuse 参数。但是需要注意,该参数是只用于客户端(建立连接的发起方),因为是在调用 connect() 时起作用的,而对于服务端(被动连接方)是没有用的。** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/34.jpg) 网上很多博客都说在服务端开启 tcp_tw_reuse 参数来优化 TCP,我信你个鬼,糟老头坏的很!**tcp_tw_reuse 只作用在 connect 函数,也就是客户端,跟服务端一毛关系的没有**。 tcp_tw_reuse 从协议角度理解是安全可控的,可以复用处于 TIME_WAIT 的端口为新的连接所用。 什么是协议角度理解的安全可控呢?主要有两点: - 只适用于连接发起方,也就是 C/S 模型中的客户端; - 对应的 TIME_WAIT 状态的连接创建时间超过 1 秒才可以被复用。 使用这个选项,还有一个前提,需要打开对 TCP 时间戳的支持(对方也要打开): ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/35.jpg) 由于引入了时间戳,它能带来了些好处: - 我们在前面提到的 2MSL(TIME_WAIT 状态的持续时间)问题就不复存在了,因为重复的数据包会因为时间戳过期被自然丢弃; - 同时,它还可以防止序列号绕回,也是因为重复的数据包会由于时间戳过期被自然丢弃; 时间戳是在 TCP 的选项字段里定义的,开启了时间戳功能,在 TCP 报文传输的时候会带上发送报文的时间戳。 ![TCP option 字段 - 时间戳](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%82%E6%95%B0/TCP%20option%E5%AD%97%E6%AE%B5-%E6%97%B6%E9%97%B4%E6%88%B3.png) 另外,老版本的 Linux 还提供了 `tcp_tw_recycle` 参数,但是当开启了它,允许处于 TIME_WAIT 状态的连接被快速回收,但是有个**大坑**。 开启了 recycle 和 timestamps 选项,就会开启一种叫 per-host 的 PAWS(判断 TCP 报文中时间戳是否是历史报文)机制,**per-host 是对「对端 IP 做 PAWS 检查」**,而非对「IP + 端口」四元组做 PAWS 检查。 如果客户端网络环境是用了 NAT 网关,那么客户端环境的每一台机器通过 NAT 网关后,都会是相同的 IP 地址,在服务端看来,就好像只是在跟一个客户端打交道一样,无法区分出来。 Per-host PAWS 机制利用 TCP option 里的 timestamp 字段的增长来判断串扰数据,而 timestamp 是根据客户端各自的 CPU tick 得出的值。 当客户端 A 通过 NAT 网关和服务器建立 TCP 连接,然后服务器主动关闭并且快速回收 TIME-WAIT 状态的连接后,**客户端 B 也通过 NAT 网关和服务器建立 TCP 连接,注意客户端 A 和 客户端 B 因为经过相同的 NAT 网关,所以是用相同的 IP 地址与服务端建立 TCP 连接,如果客户端 B 的 timestamp 比 客户端 A 的 timestamp 小,那么由于服务端的 per-host 的 PAWS 机制的作用,服务端就会丢弃客户端主机 B 发来的 SYN 包**。 因此,tcp_tw_recycle 在使用了 NAT 的网络下是存在问题的,如果它是对 TCP 四元组做 PAWS 检查,而不是对「相同的 IP 做 PAWS 检查」,那么就不会存在这个问题了。 网上很多博客都说开启 tcp_tw_recycle 参数来优化 TCP,我信你个鬼,糟老头坏的很! 所以,不建议设置为 1,在 Linux 4.12 版本后,Linux 内核直接取消了这一参数,建议关闭它: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/36.jpg) > TIME_WAIT 状态优化方式三 我们可以在程序中设置 socket 选项,来设置调用 close 关闭连接行为。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/37.jpg) 如果 `l_onoff` 为非 0,且 `l_linger` 值为 0,**那么调用 close 后,会立该发送一个 RST 标志给对端,该 TCP 连接将跳过四次挥手,也就跳过了 TIME_WAIT 状态,直接关闭。** 这种方式只推荐在客户端使用,服务端千万不要使用。因为服务端一调用 close,就发送 RST 报文的话,客户端就总是看到 TCP 连接错误“connnection reset by peer”。 ### 被动方的优化 当被动方收到 FIN 报文时,内核会自动回复 ACK,同时连接处于 CLOSE_WAIT 状态,顾名思义,它表示等待应用进程调用 close 函数关闭连接。 内核没有权利替代进程去关闭连接,因为如果主动方是通过 shutdown 关闭连接,那么它就是想在半关闭连接上接收数据或发送数据。因此,Linux 并没有限制 CLOSE_WAIT 状态的持续时间。 当然,大多数应用程序并不使用 shutdown 函数关闭连接。所以,**当你用 netstat 命令发现大量 CLOSE_WAIT 状态。就需要排查你的应用程序,因为可能因为应用程序出现了 Bug,read 函数返回 0 时,没有调用 close 函数。** 处于 CLOSE_WAIT 状态时,调用了 close 函数,内核就会发出 FIN 报文关闭发送通道,同时连接进入 LAST_ACK 状态,等待主动方返回 ACK 来确认连接关闭。 如果迟迟收不到这个 ACK,内核就会重发 FIN 报文,重发次数仍然由 tcp_orphan_retries 参数控制,这与主动方重发 FIN 报文的优化策略一致。 还有一点我们需要注意的,**如果被动方迅速调用 close 函数,那么被动方的 ACK 和 FIN 有可能在一个报文中发送,这样看起来,四次挥手会变成三次挥手,这只是一种特殊情况,不用在意。** > 如果连接双方同时关闭连接,会怎么样? 由于 TCP 是双全工的协议,所以是会出现两方同时关闭连接的现象,也就是同时发送了 FIN 报文。 此时,上面介绍的优化策略仍然适用。两方发送 FIN 报文时,都认为自己是主动方,所以都进入了 FIN_WAIT1 状态,FIN 报文的重发次数仍由 tcp_orphan_retries 参数控制。 ![同时关闭](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/38.jpg) 接下来,**双方在等待 ACK 报文的过程中,都等来了 FIN 报文。这是一种新情况,所以连接会进入一种叫做 CLOSING 的新状态,它替代了 FIN_WAIT2 状态**。接着,双方内核回复 ACK 确认对方发送通道的关闭后,进入 TIME_WAIT 状态,等待 2MSL 的时间后,连接自动关闭。 ### 小结 针对 TCP 四次挥手的优化,我们需要根据主动方和被动方四次挥手状态变化来调整系统 TCP 内核参数。 ![四次挥手的优化策略](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/39.jpg) > 主动方的优化 主动发起 FIN 报文断开连接的一方,如果迟迟没收到对方的 ACK 回复,则会重传 FIN 报文,重传的次数由 `tcp_orphan_retries` 参数决定。 当主动方收到 ACK 报文后,连接就进入 FIN_WAIT2 状态,根据关闭的方式不同,优化的方式也不同: - 如果这是 close 函数关闭的连接,那么它就是孤儿连接。如果 `tcp_fin_timeout` 秒内没有收到对方的 FIN 报文,连接就直接关闭。同时,为了应对孤儿连接占用太多的资源,`tcp_max_orphans` 定义了最大孤儿连接的数量,超过时连接就会直接释放。 - 反之是 shutdown 函数关闭的连接,则不受此参数限制; 当主动方接收到 FIN 报文,并返回 ACK 后,主动方的连接进入 TIME_WAIT 状态。这一状态会持续 1 分钟,为了防止 TIME_WAIT 状态占用太多的资源,`tcp_max_tw_buckets` 定义了最大数量,超过时连接也会直接释放。 当 TIME_WAIT 状态过多时,还可以通过设置 `tcp_tw_reuse` 和 `tcp_timestamps` 为 1,将 TIME_WAIT 状态的端口复用于作为客户端的新连接,注意该参数只适用于客户端。 > 被动方的优化 被动关闭的连接方应对非常简单,它在回复 ACK 后就进入了 CLOSE_WAIT 状态,等待进程调用 close 函数关闭连接。因此,出现大量 CLOSE_WAIT 状态的连接时,应当从应用程序中找问题。 当被动方发送 FIN 报文后,连接就进入 LAST_ACK 状态,在未等到 ACK 时,会在 `tcp_orphan_retries` 参数的控制下重发 FIN 报文。 --- ## TCP 传输数据的性能提升 在前面介绍的是三次握手和四次挥手的优化策略,接下来主要介绍的是 TCP 传输数据时的优化策略。 TCP 连接是由内核维护的,内核会为每个连接建立内存缓冲区: - 如果连接的内存配置过小,就无法充分使用网络带宽,TCP 传输效率就会降低; - 如果连接的内存配置过大,很容易把服务器资源耗尽,这样就会导致新连接无法建立; 因此,我们必须理解 Linux 下 TCP 内存的用途,才能正确地配置内存大小。 ### 滑动窗口是如何影响传输速度的? TCP 会保证每一个报文都能够抵达对方,它的机制是这样:报文发出去后,必须接收到对方返回的确认报文 ACK,如果迟迟未收到,就会超时重发该报文,直到收到对方的 ACK 为止。 **所以,TCP 报文发出去后,并不会立马从内存中删除,因为重传时还需要用到它。** 由于 TCP 是内核维护的,所以报文存放在内核缓冲区。如果连接非常多,我们可以通过 free 命令观察到 `buff/cache` 内存是会增大。 如果 TCP 是每发送一个数据,都要进行一次确认应答。当上一个数据包收到了应答了,再发送下一个。这个模式就有点像我和你面对面聊天,你一句我一句,但这种方式的缺点是效率比较低的。 ![按数据包进行确认应答](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/40.jpg) 所以,这样的传输方式有一个缺点:数据包的**往返时间越长,通信的效率就越低**。 **要解决这一问题不难,并行批量发送报文,再批量确认报文即可。** ![并行处理](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/41.jpg) 然而,这引出了另一个问题,发送方可以随心所欲的发送报文吗?**当然这不现实,我们还得考虑接收方的处理能力。** 当接收方硬件不如发送方,或者系统繁忙、资源紧张时,是无法瞬间处理这么多报文的。于是,这些报文只能被丢掉,使得网络效率非常低。 **为了解决这种现象发生,TCP 提供一种机制可以让「发送方」根据「接收方」的实际接收能力控制发送的数据量,这就是滑动窗口的由来。** 接收方根据它的缓冲区,可以计算出后续能够接收多少字节的报文,这个数字叫做接收窗口。当内核接收到报文时,必须用缓冲区存放它们,这样剩余缓冲区空间变小,接收窗口也就变小了;当进程调用 read 函数后,数据被读入了用户空间,内核缓冲区就被清空,这意味着主机可以接收更多的报文,接收窗口就会变大。 因此,接收窗口并不是恒定不变的,接收方会把当前可接收的大小放在 TCP 报文头部中的**窗口字段**,这样就可以起到窗口大小通知的作用。 发送方的窗口等价于接收方的窗口吗?如果不考虑拥塞控制,发送方的窗口大小「约等于」接收方的窗口大小,因为窗口通知报文在网络传输是存在时延的,所以是约等于的关系。 ![TCP 头部](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/42.jpg) 从上图中可以看到,窗口字段只有 2 个字节,因此它最多能表达 65535 字节大小的窗口,也就是 64KB 大小。 这个窗口大小最大值,在当今高速网络下,很明显是不够用的。所以后续有了扩充窗口的方法:**在 TCP 选项字段定义了窗口扩大因子,用于扩大 TCP 通告窗口,其值大小是 2^14,这样就使 TCP 的窗口大小从 16 位扩大为 30 位(2^16 * 2^ 14 = 2^30),所以此时窗口的最大值可以达到 1GB。** ![TCP option 选项 - 窗口扩展](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%82%E6%95%B0/TCP%20option%E5%AD%97%E6%AE%B5-%E7%AA%97%E5%8F%A3.png) Linux 中打开这一功能,需要把 tcp_window_scaling 配置设为 1(默认打开): ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/43.jpg) 要使用窗口扩大选项,通讯双方必须在各自的 SYN 报文中发送这个选项: - 主动建立连接的一方在 SYN 报文中发送这个选项; - 而被动建立连接的一方只有在收到带窗口扩大选项的 SYN 报文之后才能发送这个选项。 这样看来,只要进程能及时地调用 read 函数读取数据,并且接收缓冲区配置得足够大,那么接收窗口就可以无限地放大,发送方也就无限地提升发送速度。 **这是不可能的,因为网络的传输能力是有限的,当发送方依据发送窗口,发送超过网络处理能力的报文时,路由器会直接丢弃这些报文。因此,缓冲区的内存并不是越大越好。** ### 如何确定最大传输速度? 在前面我们知道了 TCP 的传输速度,受制于发送窗口与接收窗口,以及网络设备传输能力。其中,窗口大小由内核缓冲区大小决定。如果缓冲区与网络传输能力匹配,那么缓冲区的利用率就达到了最大化。 问题来了,如何计算网络的传输能力呢? 相信大家都知道网络是有「带宽」限制的,带宽描述的是网络传输能力,它与内核缓冲区的计量单位不同: - 带宽是单位时间内的流量,表达是「速度」,比如常见的带宽 100 MB/s; - 缓冲区单位是字节,当网络速度乘以时间才能得到字节数; 这里需要说一个概念,就是带宽时延积,它决定网络中飞行报文的大小,它的计算方式: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/44.jpg) 比如最大带宽是 100 MB/s,网络时延(RTT)是 10ms 时,意味着客户端到服务端的网络一共可以存放 100MB/s * 0.01s = 1MB 的字节。 这个 1MB 是带宽和时延的乘积,所以它就叫「带宽时延积」(缩写为 BDP,Bandwidth Delay Product)。同时,这 1MB 也表示「飞行中」的 TCP 报文大小,它们就在网络线路、路由器等网络设备上。如果飞行报文超过了 1 MB,就会导致网络过载,容易丢包。 **由于发送缓冲区大小决定了发送窗口的上限,而发送窗口又决定了「已发送未确认」的飞行报文的上限。因此,发送缓冲区不能超过「带宽时延积」。** 发送缓冲区与带宽时延积的关系: - 如果发送缓冲区「超过」带宽时延积,超出的部分就没办法有效的网络传输,同时导致网络过载,容易丢包; - 如果发送缓冲区「小于」带宽时延积,就不能很好的发挥出网络的传输效率。 所以,发送缓冲区的大小最好是往带宽时延积靠近。 ### 怎样调整缓冲区大小? 在 Linux 中发送缓冲区和接收缓冲都是可以用参数调节的。设置完后,Linux 会根据你设置的缓冲区进行**动态调节**。 > 调节发送缓冲区范围 先来看看发送缓冲区,它的范围通过 tcp_wmem 参数配置; ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/45.jpg) 上面三个数字单位都是字节,它们分别表示: - 第一个数值是动态范围的最小值,4096 byte = 4K; - 第二个数值是初始默认值,16384 byte ≈ 16K; - 第三个数值是动态范围的最大值,4194304 byte = 4096K(4M); **发送缓冲区是自行调节的**,当发送方发送的数据被确认后,并且没有新的数据要发送,就会把发送缓冲区的内存释放掉。 > 调节接收缓冲区范围 而接收缓冲区的调整就比较复杂一些,先来看看设置接收缓冲区范围的 tcp_rmem 参数: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/46.jpg) 上面三个数字单位都是字节,它们分别表示: - 第一个数值是动态范围的最小值,表示即使在内存压力下也可以保证的最小接收缓冲区大小,4096 byte = 4K; - 第二个数值是初始默认值,87380 byte ≈ 86K; - 第三个数值是动态范围的最大值,6291456 byte = 6144K(6M); **接收缓冲区可以根据系统空闲内存的大小来调节接收窗口:** - 如果系统的空闲内存很多,就可以自动把缓冲区增大一些,这样传给对方的接收窗口也会变大,因而提升发送方发送的传输数据数量; - 反之,如果系统的内存很紧张,就会减少缓冲区,这虽然会降低传输效率,可以保证更多的并发连接正常工作; 发送缓冲区的调节功能是自动开启的,**而接收缓冲区则需要配置 tcp_moderate_rcvbuf 为 1 来开启调节功能**: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/47.jpg) > 调节 TCP 内存范围 接收缓冲区调节时,怎么知道当前内存是否紧张或充分呢?这是通过 tcp_mem 配置完成的: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/48.jpg) 上面三个数字单位不是字节,而是「页面大小」,1 页表示 4KB,它们分别表示: - 当 TCP 内存小于第 1 个值时,不需要进行自动调节; - 在第 1 和第 2 个值之间时,内核开始调节接收缓冲区的大小; - 大于第 3 个值时,内核不再为 TCP 分配新内存,此时新连接是无法建立的; 一般情况下这些值是在系统启动时根据系统内存数量计算得到的。根据当前 tcp_mem 最大内存页面数是 177120,当内存为 (177120 * 4) / 1024K ≈ 692M 时,系统将无法为新的 TCP 连接分配内存,即 TCP 连接将被拒绝。 > 根据实际场景调节的策略 在高并发服务器中,为了兼顾网速与大量的并发连接,**我们应当保证缓冲区的动态调整的最大值达到带宽时延积,而最小值保持默认的 4K 不变即可。而对于内存紧张的服务而言,调低默认值是提高并发的有效手段。** 同时,如果这是网络 IO 型服务器,那么,**调大 tcp_mem 的上限可以让 TCP 连接使用更多的系统内存,这有利于提升并发能力**。需要注意的是,tcp_wmem 和 tcp_rmem 的单位是字节,而 tcp_mem 的单位是页面大小。而且,**千万不要在 socket 上直接设置 SO_SNDBUF 或者 SO_RCVBUF,这样会关闭缓冲区的动态调整功能。** ### 小结 本节针对 TCP 优化数据传输的方式,做了一些介绍。 ![数据传输的优化策略](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-参数/49.jpg) TCP 可靠性是通过 ACK 确认报文实现的,又依赖滑动窗口提升了发送速度也兼顾了接收方的处理能力。 可是,默认的滑动窗口最大值只有 64 KB,不满足当今的高速网络的要求,要想提升发送速度必须提升滑动窗口的上限,在 Linux 下是通过设置 `tcp_window_scaling` 为 1 做到的,此时最大值可高达 1GB。 滑动窗口定义了网络中飞行报文的最大字节数,当它超过带宽时延积时,网络过载,就会发生丢包。而当它小于带宽时延积时,就无法充分利用网络带宽。因此,滑动窗口的设置,必须参考带宽时延积。 内核缓冲区决定了滑动窗口的上限,缓冲区可分为:发送缓冲区 tcp_wmem 和接收缓冲区 tcp_rmem。 Linux 会对缓冲区动态调节,我们应该把缓冲区的上限设置为带宽时延积。发送缓冲区的调节功能是自动打开的,而接收缓冲区需要把 tcp_moderate_rcvbuf 设置为 1 来开启。其中,调节的依据是 TCP 内存范围 tcp_mem。 但需要注意的是,如果程序中的 socket 设置 SO_SNDBUF 和 SO_RCVBUF,则会关闭缓冲区的动态整功能,所以不建议在程序设置它俩,而是交给内核自动调整比较好。 有效配置这些参数后,既能够最大程度地保持并发性,也能让资源充裕时连接传输速度达到最大值。 --- 参考资料: [1] 系统性能调优必知必会。陶辉。极客时间。 [2] 网络编程实战专栏。盛延敏。极客时间。 [3] http://www.blogjava.net/yongboy/archive/2013/04/11/397677.html [4] http://blog.itpub.net/31559359/viewspace-2284113/ [5] https://blog.51cto.com/professor/1909022 [6] https://vincent.bernat.ch/en/blog/2014-tcp-time-wait-state-linux --- ## 读者问答 > 读者问:“小林,请教个问题,somaxconn 和 backlog 是不是都是指的是 accept 队列?然后 somaxconn 是内核参数,backlog 是通过系统调用间隔地修改 somaxconn,比如 Linux 中 listen() 函数?” 两者取最小值才是 accpet 队列。 > 读者问:“小林,还有个问题要请教下,“如果 accept 队列满了,那么 server 扔掉 client 发过来的 ack”,也就是说该 TCP 连接还是位于半连接队列中,没有丢弃吗?” 1. 当 accept 队列满了,后续新进来的 syn 包都会被丢失 2. 我文章的突发流量例子是,那个连接进来的时候 accept 队列还没满,但是在第三次握手的时候,accept 队列突然满了,就会导致 ack 被丢弃,就一直处于半连接队列。 ---- **小林是专为大家图解的工具人,Goodbye,我们下次见!** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/3_tcp/tcp_problem.md ================================================ # 4.16 TCP 协议有什么缺陷? 大家好,我是小林。 写的多了后,忽然思考一个问题,TCP 通过序列号、确认应答、超时重传、流量控制、拥塞控制等方式实现了可靠传输,看起来它很完美,事实真的是这样吗?TCP 就没什么缺陷吗? 所以,今天就跟大家聊聊,TCP 协议有哪些缺陷?主要有四个方面: - 升级 TCP 的工作很困难; - TCP 建立连接的延迟; - TCP 存在队头阻塞问题; - 网络迁移需要重新建立 TCP 连接; 接下来,针对这四个方面详细说一下。 ## 升级 TCP 的工作很困难 TCP 协议是诞生在 1973 年,至今 TCP 协议依然还在实现更多的新特性。 但是 TCP 协议是在内核中实现的,应用程序只能使用不能修改,如果要想升级 TCP 协议,那么只能升级内核。 而升级内核这个工作是很麻烦的事情,麻烦的事情不是说升级内核这个操作很麻烦,而是由于内核升级涉及到底层软件和运行库的更新,我们的服务程序就需要回归测试是否兼容新的内核版本,所以服务器的内核升级也比较保守和缓慢。 很多 TCP 协议的新特性,都是需要客户端和服务端同时支持才能生效的,比如 TCP Fast Open 这个特性,虽然在 2013 年就被提出了,但是 Windows 很多系统版本依然不支持它,这是因为 PC 端的系统升级滞后很严重,W indows Xp 现在还有大量用户在使用,尽管它已经存在快 20 年。 所以,即使 TCP 有比较好的特性更新,也很难快速推广,用户往往要几年或者十年才能体验到。 ## TCP 建立连接的延迟 基于 TCP 实现的应用协议,都是需要先建立三次握手才能进行数据传输,比如 HTTP 1.0/1.1、HTTP/2、HTTPS。 现在大多数网站都是使用 HTTPS 的,这意味着在 TCP 三次握手之后,还需要经过 TLS 四次握手后,才能进行 HTTP 数据的传输,这在一定程序上增加了数据传输的延迟。 TCP 三次握手和 TLS 握手延迟,如图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/%E7%BD%91%E7%BB%9C/http3/TCP%2BTLS.gif) TCP 三次握手的延迟被 TCP Fast Open(快速打开)这个特性解决了,这个特性可以在「第二次建立连接」时减少 TCP 连接建立的时延。 ![常规 HTTP 请求 与 Fast Open HTTP 请求](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-Wireshark/45.jpg) 过程如下: - 在第一次建立连接的时候,服务端在第二次握手产生一个 `Cookie` (已加密)并通过 SYN、ACK 包一起发给客户端,于是客户端就会缓存这个 `Cookie`,所以第一次发起 HTTP Get 请求的时候,还是需要 2 个 RTT 的时延; - 在下次请求的时候,客户端在 SYN 包带上 `Cookie` 发给服务端,就提前可以跳过三次握手的过程,因为 `Cookie` 中维护了一些信息,服务端可以从 `Cookie` 获取 TCP 相关的信息,这时发起的 HTTP GET 请求就只需要 1 个 RTT 的时延; TCP Fast Open 这个特性是不错,但是它需要服务端和客户端的操作系统同时支持才能体验到,而 TCP Fast Open 是在 2013 年提出的,所以市面上依然有很多老式的操作系统不支持,而升级操作系统是很麻烦的事情,因此 TCP Fast Open 很难被普及开来。 还有一点,针对 HTTPS 来说,TLS 是在应用层实现的握手,而 TCP 是在内核实现的握手,这两个握手过程是无法结合在一起的,总是得先完成 TCP 握手,才能进行 TLS 握手。 也正是 TCP 是在内核实现的,所以 TLS 是无法对 TCP 头部加密的,这意味着 TCP 的序列号都是明文传输,所以就存安全的问题。 一个典型的例子就是攻击者伪造一个的 RST 报文强制关闭一条 TCP 连接,而攻击成功的关键则是 TCP 字段里的序列号位于接收方的滑动窗口内,该报文就是合法的。 为此 TCP 也不得不进行三次握手来同步各自的序列号,而且初始化序列号时是采用随机的方式(不完全随机,而是随着时间流逝而线性增长,到了 2^32 尽头再回滚)来提升攻击者猜测序列号的难度,以增加安全性。 但是这种方式只能避免攻击者预测出合法的 RST 报文,而无法避免攻击者截获客户端的报文,然后中途伪造出合法 RST 报文的攻击的方式。 ![](https://gw.alipayobjects.com/mdn/rms_1c90e8/afts/img/A*po6LQIBU7zIAAAAAAAAAAAAAARQnAQ) 大胆想一下,如果 TCP 的序列号也能被加密,或许真的不需要三次握手了,客户端和服务端的初始序列号都从 0 开始,也就不用做同步序列号的工作了,但是要实现这个要改造整个协议栈,太过于麻烦,即使实现出来了,很多老的网络设备未必能兼容。 ## TCP 存在队头阻塞问题 TCP 是字节流协议,**TCP 层必须保证收到的字节数据是完整且有序的**,如果序列号较低的 TCP 段在网络传输中丢失了,即使序列号较高的 TCP 段已经被接收了,应用层也无法从内核中读取到这部分数据。如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/%E7%BD%91%E7%BB%9C/http3/tcp%E9%98%9F%E5%A4%B4%E9%98%BB%E5%A1%9E.gif) 图中发送方发送了很多个 packet,每个 packet 都有自己的序号,你可以认为是 TCP 的序列号,其中 `packet #3` 在网络中丢失了,即使 `packet #4-6` 被接收方收到后,由于内核中的 TCP 数据不是连续的,于是接收方的应用层就无法从内核中读取到,只有等到 `packet #3` 重传后,接收方的应用层才可以从内核中读取到数据。 这就是 TCP 队头阻塞问题,但这也不能怪 TCP,因为只有这样做才能保证数据的有序性。 HTTP/2 多个请求是跑在一个 TCP 连接中的,那么当 TCP 丢包时,整个 TCP 都要等待重传,那么就会阻塞该 TCP 连接中的所有请求,所以 HTTP/2 队头阻塞问题就是因为 TCP 协议导致的。 ![](https://pic2.zhimg.com/80/v2-2dd2a9fb8693489b9a0b24771c8a40a1_1440w.jpg) ## 网络迁移需要重新建立 TCP 连接 基于 TCP 传输协议的 HTTP 协议,由于是通过四元组(源 IP、源端口、目的 IP、目的端口)确定一条 TCP 连接。 ![TCP 四元组](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL3hpYW9saW5jb2Rlci9JbWFnZUhvc3QyLyVFOCVBRSVBMSVFNyVBRSU5NyVFNiU5QyVCQSVFNyVCRCU5MSVFNyVCQiU5Qy9UQ1AtJUU0JUI4JTg5JUU2JUFDJUExJUU2JThGJUExJUU2JTg5JThCJUU1JTkyJThDJUU1JTlCJTlCJUU2JUFDJUExJUU2JThDJUE1JUU2JTg5JThCLzEwLmpwZw?x-oss-process=image/format,png) 那么**当移动设备的网络从 4G 切换到 WIFI 时,意味着 IP 地址变化了,那么就必须要断开连接,然后重新建立 TCP 连接**。 而建立连接的过程包含 TCP 三次握手和 TLS 四次握手的时延,以及 TCP 慢启动的减速过程,给用户的感觉就是网络突然卡顿了一下,因此连接的迁移成本是很高的。 ## 结尾 我记得之前在群里看到,有位读者字节一面的时候被问到:「**如何基于 UDP 协议实现可靠传输?**」 很多同学第一反应就会说把 TCP 可靠传输的特性(序列号、确认应答、超时重传、流量控制、拥塞控制)在应用层实现一遍。 实现的思路确实这样没错,但是有没有想过,**既然 TCP 天然支持可靠传输,为什么还需要基于 UDP 实现可靠传输呢?这不是重复造轮子吗?** 所以,我们要先弄清楚 TCP 协议有哪些痛点?而这些痛点是否可以在基于 UDP 协议实现的可靠传输协议中得到改进? 现在市面上已经有基于 UDP 协议实现的可靠传输协议的成熟方案了,那就是 QUIC 协议,**QUIC 协议把我本文说的 TCP 的缺点都给解决了**,而且已经应用在了 HTTP/3。 ![](https://miro.medium.com/max/1400/1*uk5OZPL7gtUwqRLwaoGyFw.png) --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/3_tcp/tcp_queue.md ================================================ # 4.4 TCP 半连接队列和全连接队列 网上许多博客针对增大 TCP 半连接队列和全连接队列的方式如下: - 增大 TCP 半连接队列的方式是增大 /proc/sys/net/ipv4/tcp_max_syn_backlog; - 增大 TCP 全连接队列的方式是增大 listen() 函数中的 backlog; 这里先跟大家说下,**上面的方式都是不准确的。** > “你怎么知道不准确?” 很简单呀,因为我做了实验和看了 TCP 协议栈的内核源码,发现要增大这两个队列长度,不是简简单单增大某一个参数就可以的。 接下来,就会以**实战 + 源码分析,带大家解密 TCP 半连接队列和全连接队列。** > “源码分析,那不是劝退吗?我们搞 Java 的看不懂呀” 放心,本文的源码分析不会涉及很深的知识,因为都被我删减了,你只需要会条件判断语句 if、左移右移操作符、加减法等基本语法,就可以看懂。 另外,不仅有源码分析,还会介绍 Linux 排查半连接队列和全连接队列的命令。 > “哦?似乎很有看头,那我姑且看一下吧!” 行,没有被劝退的小伙伴,值得鼓励,下面这图是本文的提纲: ![本文提纲](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/2.jpg) --- ## 什么是 TCP 半连接队列和全连接队列? 在 TCP 三次握手的时候,Linux 内核会维护两个队列,分别是: - 半连接队列,也称 SYN 队列; - 全连接队列,也称 accept 队列; 服务端收到客户端发起的 SYN 请求后,**内核会把该连接存储到半连接队列**,并向客户端响应 SYN+ACK,接着客户端会返回 ACK,服务端收到第三次握手的 ACK 后,**内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来。** ![半连接队列与全连接队列](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/3.jpg) 不管是半连接队列还是全连接队列,都有最大长度限制,超过限制时,内核会直接丢弃,或返回 RST 包。 --- ## 实战 - TCP 全连接队列溢出 > 如何知道应用程序的 TCP 全连接队列大小? 在服务端可以使用 `ss` 命令,来查看 TCP 全连接队列的情况: 但需要注意的是 `ss` 命令获取的 `Recv-Q/Send-Q` 在「LISTEN 状态」和「非 LISTEN 状态」所表达的含义是不同的。从下面的内核代码可以看出区别: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/4.jpg) 在「LISTEN 状态」时,`Recv-Q/Send-Q` 表示的含义如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/5.jpg) - Recv-Q:当前全连接队列的大小,也就是当前已完成三次握手并等待服务端 `accept()` 的 TCP 连接; - Send-Q:当前全连接最大队列长度,上面的输出结果说明监听 8088 端口的 TCP 服务,最大全连接长度为 128; 在「非 LISTEN 状态」时,`Recv-Q/Send-Q` 表示的含义如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/6.jpg) - Recv-Q:已收到但未被应用进程读取的字节数; - Send-Q:已发送但未收到确认的字节数; > 如何模拟 TCP 全连接队列溢出的场景? ![测试环境](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/7.jpg) 实验环境: - 客户端和服务端都是 CentOs 6.5,Linux 内核版本 2.6.32 - 服务端 IP 192.168.3.200,客户端 IP 192.168.3.100 - 服务端是 Nginx 服务,端口为 8088 这里先介绍下 `wrk` 工具,它是一款简单的 HTTP 压测工具,它能够在单机多核 CPU 的条件下,使用系统自带的高性能 I/O 机制,通过多线程和事件模式,对目标机器产生大量的负载。 本次模拟实验就使用 `wrk` 工具来压力测试服务端,发起大量的请求,一起看看服务端 TCP 全连接队列满了会发生什么?有什么观察指标? 客户端执行 `wrk` 命令对服务端发起压力测试,并发 3 万个连接: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/8.jpg) 在服务端可以使用 `ss` 命令,来查看当前 TCP 全连接队列的情况: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/9.jpg) 其间共执行了两次 ss 命令,从上面的输出结果,可以发现当前 TCP 全连接队列上升到了 129 大小,超过了最大 TCP 全连接队列。 **当超过了 TCP 最大全连接队列,服务端则会丢掉后续进来的 TCP 连接**,丢掉的 TCP 连接的个数会被统计起来,我们可以使用 netstat -s 命令来查看: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/10.jpg) 上面看到的 41150 times,表示全连接队列溢出的次数,注意这个是累计值。可以隔几秒钟执行下,如果这个数字一直在增加的话肯定全连接队列偶尔满了。 从上面的模拟结果,可以得知,**当服务端并发处理大量请求时,如果 TCP 全连接队列过小,就容易溢出。发生 TCP 全连接队溢出的时候,后续的请求就会被丢弃,这样就会出现服务端请求数量上不去的现象。** ![全连接队列溢出](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/11.jpg) > Linux 有个参数可以指定当 TCP 全连接队列满了会使用什么策略来回应客户端。 实际上,丢弃连接只是 Linux 的默认行为,我们还可以选择向客户端发送 RST 复位报文,告诉客户端连接已经建立失败。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/12.jpg) tcp_abort_on_overflow 共有两个值分别是 0 和 1,其分别表示: - 0:如果全连接队列满了,那么 server 扔掉 client 发过来的 ack; - 1:如果全连接队列满了,server 发送一个 `reset` 包给 client,表示废掉这个握手过程和这个连接; 如果要想知道客户端连接不上服务端,是不是服务端 TCP 全连接队列满的原因,那么可以把 tcp_abort_on_overflow 设置为 1,这时如果在客户端异常中可以看到很多 `connection reset by peer` 的错误,那么就可以证明是由于服务端 TCP 全连接队列溢出的问题。 通常情况下,应当把 tcp_abort_on_overflow 设置为 0,因为这样更有利于应对突发流量。 举个例子,当 TCP 全连接队列满导致服务器丢掉了 ACK,与此同时,客户端的连接状态却是 ESTABLISHED,进程就在建立好的连接上发送请求。只要服务器没有为请求回复 ACK,请求就会被多次**重发**。如果服务器上的进程只是**短暂的繁忙造成 accept 队列满,那么当 TCP 全连接队列有空位时,再次接收到的请求报文由于含有 ACK,仍然会触发服务器端成功建立连接。** 所以,tcp_abort_on_overflow 设为 0 可以提高连接建立的成功率,只有你非常肯定 TCP 全连接队列会长期溢出时,才能设置为 1 以尽快通知客户端。 > 如何增大 TCP 全连接队列呢? 是的,当发现 TCP 全连接队列发生溢出的时候,我们就需要增大该队列的大小,以便可以应对客户端大量的请求。 **TCP 全连接队列的最大值取决于 somaxconn 和 backlog 之间的最小值,也就是 min(somaxconn, backlog)**。从下面的 Linux 内核代码可以得知: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/13.jpg) - `somaxconn` 是 Linux 内核的参数,默认值是 128,可以通过 `/proc/sys/net/core/somaxconn` 来设置其值; - `backlog` 是 `listen(int sockfd, int backlog)` 函数中的 backlog 大小,Nginx 默认值是 511,可以通过修改配置文件设置其长度; 前面模拟测试中,我的测试环境: - somaxconn 是默认值 128; - Nginx 的 backlog 是默认值 511 所以测试环境的 TCP 全连接队列最大值为 min(128, 511),也就是 `128`,可以执行 `ss` 命令查看: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/14.jpg) 现在我们重新压测,把 TCP 全连接队列**搞大**,把 `somaxconn` 设置成 5000: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/15.jpg) 接着把 Nginx 的 backlog 也同样设置成 5000: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/16.jpg) 最后要重启 Nginx 服务,因为只有重新调用 `listen()` 函数 TCP 全连接队列才会重新初始化。 重启完后 Nginx 服务后,服务端执行 ss 命令,查看 TCP 全连接队列大小: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/17.jpg) 从执行结果,可以发现 TCP 全连接最大值为 5000。 > 增大 TCP 全连接队列后,继续压测 客户端同样以 3 万个连接并发发送请求给服务端: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/18.jpg) 服务端执行 `ss` 命令,查看 TCP 全连接队列使用情况: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/19.jpg) 从上面的执行结果,可以发现全连接队列使用增长的很快,但是一直都没有超过最大值,所以就不会溢出,那么 `netstat -s` 就不会有 TCP 全连接队列溢出个数的显示: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/20.jpg) 说明 TCP 全连接队列最大值从 128 增大到 5000 后,服务端抗住了 3 万连接并发请求,也没有发生全连接队列溢出的现象了。 **如果持续不断地有连接因为 TCP 全连接队列溢出被丢弃,就应该调大 backlog 以及 somaxconn 参数。** --- ## 实战 - TCP 半连接队列溢出 > 如何查看 TCP 半连接队列长度? 很遗憾,TCP 半连接队列长度的长度,没有像全连接队列那样可以用 ss 命令查看。 但是我们可以抓住 TCP 半连接的特点,就是服务端处于 `SYN_RECV` 状态的 TCP 连接,就是 TCP 半连接队列。 于是,我们可以使用如下命令计算当前 TCP 半连接队列长度: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/21.jpg) > 如何模拟 TCP 半连接队列溢出场景? 模拟 TCP 半连接溢出场景不难,实际上就是对服务端一直发送 TCP SYN 包,但是不回第三次握手 ACK,这样就会使得服务端有大量的处于 `SYN_RECV` 状态的 TCP 连接。 这其实也就是所谓的 SYN 洪泛、SYN 攻击、DDos 攻击。 ![测试环境](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/22.jpg) 实验环境: - 客户端和服务端都是 CentOs 6.5,Linux 内核版本 2.6.32 - 服务端 IP 192.168.3.200,客户端 IP 192.168.3.100 - 服务端是 Nginx 服务,端口为 8088 注意:本次模拟实验是没有开启 tcp_syncookies,关于 tcp_syncookies 的作用,后续会说明。 本次实验使用 `hping3` 工具模拟 SYN 攻击: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/23.jpg) 当服务端受到 SYN 攻击后,连接服务端 ssh 就会断开了,无法再连上。只能在服务端主机上执行查看当前 TCP 半连接队列大小: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/24.jpg) 同时,还可以通过 netstat -s 观察半连接队列溢出的情况: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/25.jpg) 上面输出的数值是**累计值**,表示共有多少个 TCP 连接因为半连接队列溢出而被丢弃。**隔几秒执行几次,如果有上升的趋势,说明当前存在半连接队列溢出的现象**。 > 大部分人都说 tcp_max_syn_backlog 是指定半连接队列的大小,是真的吗? 很遗憾,半连接队列的大小并不单单只跟 `tcp_max_syn_backlog` 有关系。 上面模拟 SYN 攻击场景时,服务端的 tcp_max_syn_backlog 的默认值如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/26.jpg) 但是在测试的时候发现,服务端最多只有 256 个半连接队列,而不是 512,所以**半连接队列的最大长度不一定由 tcp_max_syn_backlog 值决定的**。 > 接下来,走进 Linux 内核的源码,来分析 TCP 半连接队列的最大值是如何决定的。 TCP 第一次握手(收到 SYN 包)的 Linux 内核代码如下,其中缩减了大量的代码,只需要重点关注 TCP 半连接队列溢出的处理逻辑: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/27.jpg) 从源码中,我可以得出共有三个条件因队列长度的关系而被丢弃的: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/28.jpg) 1. **如果半连接队列满了,并且没有开启 tcp_syncookies,则会丢弃;** 2. **若全连接队列满了,且没有重传 SYN+ACK 包的连接请求多于 1 个,则会丢弃;** 3. **如果没有开启 tcp_syncookies,并且 max_syn_backlog 减去 当前半连接队列长度小于 (max_syn_backlog >> 2),则会丢弃;** 关于 tcp_syncookies 的设置,后面在详细说明,可以先给大家说一下,开启 tcp_syncookies 是缓解 SYN 攻击其中一个手段。 接下来,我们继续跟一下检测半连接队列是否满的函数 inet_csk_reqsk_queue_is_full 和 检测全连接队列是否满的函数 sk_acceptq_is_full: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/29.jpg) 从上面源码,可以得知: - **全**连接队列的最大值是 `sk_max_ack_backlog` 变量,sk_max_ack_backlog 实际上是在 listen() 源码里指定的,也就是 **min(somaxconn, backlog)**; - **半**连接队列的最大值是 `max_qlen_log` 变量,max_qlen_log 是在哪指定的呢?现在暂时还不知道,我们继续跟进; 我们继续跟进代码,看一下是哪里初始化了半连接队列的最大值 max_qlen_log: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/30.jpg) 从上面的代码中,我们可以算出 max_qlen_log 是 8,于是代入到 检测半连接队列是否满的函数 reqsk_queue_is_full: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/31.jpg) 也就是 `qlen >> 8` 什么时候为 1 就代表半连接队列满了。这计算这不难,很明显是当 qlen 为 256 时,`256 >> 8 = 1`。 至此,总算知道为什么上面模拟测试 SYN 攻击的时候,服务端处于 `SYN_RECV` 连接最大只有 256 个。 可见,**半连接队列最大值不是单单由 max_syn_backlog 决定,还跟 somaxconn 和 backlog 有关系。** 在 Linux 2.6.32 内核版本,它们之间的关系,总体可以概况为: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/32.jpg) - 当 max_syn_backlog > min(somaxconn, backlog) 时,半连接队列最大值 max_qlen_log = min(somaxconn, backlog) * 2; - 当 max_syn_backlog < min(somaxconn, backlog) 时,半连接队列最大值 max_qlen_log = max_syn_backlog * 2; > 半连接队列最大值 max_qlen_log 就表示服务端处于 SYN_RECV 状态的最大个数吗? 依然很遗憾,并不是。 max_qlen_log 是**理论**半连接队列最大值,并不一定代表服务端处于 SYN_RECV 状态的最大个数。 在前面我们在分析 TCP 第一次握手(收到 SYN 包)时会被丢弃的三种条件: 1. 如果半连接队列满了,并且没有开启 tcp_syncookies,则会丢弃; 2. 若全连接队列满了,且没有重传 SYN+ACK 包的连接请求多于 1 个,则会丢弃; 3. **如果没有开启 tcp_syncookies,并且 max_syn_backlog 减去 当前半连接队列长度小于 (max_syn_backlog >> 2),则会丢弃;** 假设条件 1 当前半连接队列的长度「没有超过」理论的半连接队列最大值 max_qlen_log,那么如果条件 3 成立,则依然会丢弃 SYN 包,也就会使得服务端处于 SYN_RECV 状态的最大个数不会是理论值 max_qlen_log。 似乎很难理解,我们继续接着做实验,实验见真知。 服务端环境如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/33.jpg) 配置完后,服务端要重启 Nginx,因为全连接队列最大值和半连接队列最大值是在 listen() 函数初始化。 根据前面的源码分析,我们可以计算出半连接队列 max_qlen_log 的最大值为 256: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/34.jpg) 客户端执行 hping3 发起 SYN 攻击: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/35.jpg) 服务端执行如下命令,查看处于 SYN_RECV 状态的最大个数: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/36.jpg) 可以发现,服务端处于 SYN_RECV 状态的最大个数并不是 max_qlen_log 变量的值。 这就是前面所说的原因:**如果当前半连接队列的长度「没有超过」理论半连接队列最大值 max_qlen_log,那么如果条件 3 成立,则依然会丢弃 SYN 包,也就会使得服务端处于 SYN_RECV 状态的最大个数不会是理论值 max_qlen_log。** 我们来分析一波条件 3 : ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/37.jpg) 从上面的分析,可以得知如果触发「当前半连接队列长度 > 192」条件,TCP 第一次握手的 SYN 包是会被丢弃的。 在前面我们测试的结果,服务端处于 SYN_RECV 状态的最大个数是 193,正好是触发了条件 3,所以处于 SYN_RECV 状态的个数还没到「理论半连接队列最大值 256」,就已经把 SYN 包丢弃了。 所以,服务端处于 SYN_RECV 状态的最大个数分为如下两种情况: - 如果「当前半连接队列」**没超过**「理论半连接队列最大值」,但是**超过** max_syn_backlog - (max_syn_backlog >> 2),那么处于 SYN_RECV 状态的最大个数就是 max_syn_backlog - (max_syn_backlog >> 2); - 如果「当前半连接队列」**超过**「理论半连接队列最大值」,那么处于 SYN_RECV 状态的最大个数就是「理论半连接队列最大值」; > 每个 Linux 内核版本「理论」半连接最大值计算方式会不同。 在上面我们是针对 Linux 2.6.32 版本分析的「理论」半连接最大值的算法,可能每个版本有些不同。 比如在 Linux 5.0.0 的时候,「理论」半连接最大值就是全连接队列最大值,但依然还是有队列溢出的三个条件: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/38.jpg) > 如果 SYN 半连接队列已满,只能丢弃连接吗? 并不是这样,**开启 syncookies 功能就可以在不使用 SYN 半连接队列的情况下成功建立连接**,在前面我们源码分析也可以看到这点,当开启了 syncookies 功能就不会丢弃连接。 syncookies 是这么做的:服务器根据当前状态计算出一个值,放在己方发出的 SYN+ACK 报文中发出,当客户端返回 ACK 报文时,取出该值验证,如果合法,就认为连接建立成功,如下图所示。 ![开启 syncookies 功能](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/39.jpg) syncookies 参数主要有以下三个值: - 0 值,表示关闭该功能; - 1 值,表示仅当 SYN 半连接队列放不下时,再启用它; - 2 值,表示无条件开启功能; 那么在应对 SYN 攻击时,只需要设置为 1 即可: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/40.jpg) > 如何防御 SYN 攻击? 这里给出几种防御 SYN 攻击的方法: - 增大半连接队列; - 开启 tcp_syncookies 功能 - 减少 SYN+ACK 重传次数 *方式一:增大半连接队列* 在前面源码和实验中,得知**要想增大半连接队列,我们得知不能只单纯增大 tcp_max_syn_backlog 的值,还需一同增大 somaxconn 和 backlog,也就是增大全连接队列**。否则,只单纯增大 tcp_max_syn_backlog 是无效的。 增大 tcp_max_syn_backlog 和 somaxconn 的方法是修改 Linux 内核参数: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/41.jpg) 增大 backlog 的方式,每个 Web 服务都不同,比如 Nginx 增大 backlog 的方法如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/42.jpg) 最后,改变了如上这些参数后,要重启 Nginx 服务,因为半连接队列和全连接队列都是在 listen() 初始化的。 *方式二:开启 tcp_syncookies 功能* 开启 tcp_syncookies 功能的方式也很简单,修改 Linux 内核参数: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/43.jpg) *方式三:减少 SYN+ACK 重传次数* 当服务端受到 SYN 攻击时,就会有大量处于 SYN_RECV 状态的 TCP 连接,处于这个状态的 TCP 会重传 SYN+ACK,当重传超过次数达到上限后,就会断开连接。 那么针对 SYN 攻击的场景,我们可以减少 SYN+ACK 的重传次数,以加快处于 SYN_RECV 状态的 TCP 连接断开。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/44.jpg) --- 参考资料: [1] 系统性能调优必知必会。陶辉。极客时间。 [2] https://www.cnblogs.com/zengkefu/p/5606696.html [3] https://blog.cloudflare.com/syn-packet-handling-in-the-wild/ --- ## 读者问答 > 读者问:“咦 我比较好奇博主都是从哪里学到这些知识的呀?书籍?视频?还是多种参考资料” 你可以看我的参考文献呀,知识点我主要是在极客专栏学的,实战模拟实验和源码解析是自己瞎折腾出来的。 > 读者问:“syncookies 启用后就不需要半链接了?那请求的数据会存在哪里?” syncookies = 1 时,半连接队列满后,后续的请求就不会存放到半连接队列了,而是在第二次握手的时候,服务端会计算一个 cookie 值,放入到 SYN +ACK 包中的序列号发给客户端,客户端收到后并回 ack,服务端就会校验连接是否合法,合法就直接把连接放入到全连接队列。 ---- ## 最后 本文是以 Linux 2.6.32 版本的内核用实验 + 源码的方式,给大家说明了 TCP 半连接队列和全连接队列,我们可以看到 TCP 半连接队列「并不是」如网上说的那样 tcp_max_syn_backlog 表示半连接队列。 TCP 半连接队列的大小对于不同的 Linux 内核版本会有不同的计算方式,所以并不要求大家要死记住本文计算 TCP 半连接队列的大小。 重要的是要学会自我源码分析,这样不管碰到什么版本的 Linux 内核,都不再怕了。 网上搜索出来的信息,并不一定针对你的系统,通过自我分析一波,你会更了解你当前使用的 Linux 内核版本! **小林是专为大家图解的工具人,Goodbye,我们下次见!** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/3_tcp/tcp_stream.md ================================================ # 4.6 如何理解是 TCP 面向字节流协议? 有个读者问我,这么个问题: > TCP 是面向字节流的协议,UDP 是面向报文的协议?这里的「面向字节流」和「面向报文」该如何理解。 ------ ## 如何理解字节流? 之所以会说 TCP 是面向字节流的协议,UDP 是面向报文的协议,是因为操作系统对 TCP 和 UDP 协议的**发送方的机制不同**,也就是问题原因在发送方。 > 先来说说为什么 UDP 是面向报文的协议? 当用户消息通过 UDP 协议传输时,**操作系统不会对消息进行拆分**,在组装好 UDP 头部后就交给网络层来处理,所以发出去的 UDP 报文中的数据部分就是完整的用户消息,也就是**每个 UDP 报文就是一个用户消息的边界**,这样接收方在接收到 UDP 报文后,读一个 UDP 报文就能读取到完整的用户消息。 你可能会问,如果收到了两个 UDP 报文,操作系统是怎么区分开的? 操作系统在收到 UDP 报文后,会将其插入到队列里,**队列里的每一个元素就是一个 UDP 报文**,这样当用户调用 recvfrom() 系统调用读数据的时候,就会从队列里取出一个数据,然后从内核里拷贝给用户缓冲区。 ![图片](https://img-blog.csdnimg.cn/img_convert/a9116c5b375d356048df033dcb53582e.png) > 再来说说为什么 TCP 是面向字节流的协议? 当用户消息通过 TCP 协议传输时,**消息可能会被操作系统分组成多个的 TCP 报文**,也就是一个完整的用户消息被拆分成多个 TCP 报文进行传输。 这时,接收方的程序如果不知道发送方发送的消息的长度,也就是不知道消息的边界时,是无法读出一个有效的用户消息的,因为用户消息被拆分成多个 TCP 报文后,并不能像 UDP 那样,一个 UDP 报文就能代表一个完整的用户消息。 举个实际的例子来说明。 发送方准备发送「Hi.」和「I am Xiaolin」这两个消息。 在发送端,当我们调用 send 函数完成数据“发送”以后,数据并没有被真正从网络上发送出去,只是从应用程序拷贝到了操作系统内核协议栈中。 至于什么时候真正被发送,**取决于发送窗口、拥塞窗口以及当前发送缓冲区的大小等条件**。也就是说,我们不能认为每次 send 调用发送的数据,都会作为一个整体完整地消息被发送出去。 如果我们考虑实际网络传输过程中的各种影响,假设发送端陆续调用 send 函数先后发送「Hi.」和「I am Xiaolin」报文,那么实际的发送很有可能是这几种情况。 第一种情况,这两个消息被分到同一个 TCP 报文,像这样: ![图片](https://img-blog.csdnimg.cn/img_convert/02dce678f870c8c70482b6e37dbb5574.png) 第二种情况,「I am Xiaolin」的部分随「Hi」在一个 TCP 报文中发送出去,像这样: ![图片](https://img-blog.csdnimg.cn/img_convert/f58b70cde860188b8f95a433e2f5293b.png) 第三种情况,「Hi.」的一部分随 TCP 报文被发送出去,另一部分和「I am Xiaolin」一起随另一个 TCP 报文发送出去,像这样。 ![图片](https://img-blog.csdnimg.cn/img_convert/68080e783d7acc842fa254e4f9ec5630.png) 类似的情况还能举例很多种,这里主要是想说明,我们不知道「Hi.」和「I am Xiaolin」这两个用户消息是如何进行 TCP 分组传输的。 因此,**我们不能认为一个用户消息对应一个 TCP 报文,正因为这样,所以 TCP 是面向字节流的协议**。 当两个消息的某个部分内容被分到同一个 TCP 报文时,就是我们常说的 TCP 粘包问题,这时接收方不知道消息的边界的话,是无法读出有效的消息。 要解决这个问题,要交给**应用程序**。 ## 如何解决粘包? 粘包的问题出现是因为不知道一个用户消息的边界在哪,如果知道了边界在哪,接收方就可以通过边界来划分出有效的用户消息。 一般有三种方式分包的方式: - 固定长度的消息; - 特殊字符作为边界; - 自定义消息结构。 #### 固定长度的消息 这种是最简单方法,即每个用户消息都是固定长度的,比如规定一个消息的长度是 64 个字节,当接收方接满 64 个字节,就认为这个内容是一个完整且有效的消息。 但是这种方式灵活性不高,实际中很少用。 ### 特殊字符作为边界 我们可以在两个用户消息之间插入一个特殊的字符串,这样接收方在接收数据时,读到了这个特殊字符,就把认为已经读完一个完整的消息。 HTTP 是一个非常好的例子。 ![图片](https://img-blog.csdnimg.cn/img_convert/a49a6bb8cd38ae1738d9c00aec68b444.png) HTTP 通过设置回车符、换行符作为 HTTP 报文协议的边界。 有一点要注意,这个作为边界点的特殊字符,如果刚好消息内容里有这个特殊字符,我们要对这个字符转义,避免被接收方当作消息的边界点而解析到无效的数据。 ### 自定义消息结构 我们可以自定义一个消息结构,由包头和数据组成,其中包头包是固定大小的,而且包头里有一个字段来说明紧随其后的数据有多大。 比如这个消息结构体,首先 4 个字节大小的变量来表示数据长度,真正的数据则在后面。 ```c struct { u_int32_t message_length; char message_data[]; } message; ``` 当接收方接收到包头的大小(比如 4 个字节)后,就解析包头的内容,于是就可以知道数据的长度,然后接下来就继续读取数据,直到读满数据的长度,就可以组装成一个完整的用户消息来处理了。 ================================================ FILE: network/3_tcp/tcp_tcpdump.md ================================================ # 4.3 TCP 实战抓包分析 为了让大家更容易「看得见」TCP,我搭建不少测试环境,并且数据包抓很多次,花费了不少时间,才抓到比较容易分析的数据包。 接下来丢包、乱序、超时重传、快速重传、选择性确认、流量控制等等 TCP 的特性,都能「一览无余」。 没错,我把 TCP 的"衣服扒光"了,就为了给大家看的清楚,嘻嘻。 ![提纲](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/2.jpg) --- ## 显形“不可见”的网络包 网络世界中的数据包交互我们肉眼是看不见的,它们就好像隐形了一样,我们对着课本学习计算机网络的时候就会觉得非常的抽象,加大了学习的难度。 还别说,我自己在大学的时候,也是如此。 直到工作后,认识了两大分析网络的利器:**tcpdump 和 Wireshark**,这两大利器把我们“看不见”的数据包,呈现在我们眼前,一目了然。 唉,当初大学学习计网的时候,要是能知道这两个工具,就不会学的一脸懵逼。 > tcpdump 和 Wireshark 有什么区别? tcpdump 和 Wireshark 就是最常用的网络抓包和分析工具,更是分析网络性能必不可少的利器。 - tcpdump 仅支持命令行格式使用,常用在 Linux 服务器中抓取和分析网络包。 - Wireshark 除了可以抓包外,还提供了可视化分析网络包的图形页面。 所以,这两者实际上是搭配使用的,先用 tcpdump 命令在 Linux 服务器上抓包,接着把抓包的文件拖出到 Windows 电脑后,用 Wireshark 可视化分析。 当然,如果你是在 Windows 上抓包,只需要用 Wireshark 工具就可以。 > tcpdump 在 Linux 下如何抓包? tcpdump 提供了大量的选项以及各式各样的过滤表达式,来帮助你抓取指定的数据包,不过不要担心,只需要掌握一些常用选项和过滤表达式,就可以满足大部分场景的需要了。 假设我们要抓取下面的 ping 的数据包: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/3.jpg) 要抓取上面的 ping 命令数据包,首先我们要知道 ping 的数据包是 `icmp` 协议,接着在使用 tcpdump 抓包的时候,就可以指定只抓 icmp 协议的数据包: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/4.jpg) 那么当 tcpdump 抓取到 icmp 数据包后,输出格式如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/5.jpg) ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/6.jpg) 从 tcpdump 抓取的 icmp 数据包,我们很清楚的看到 `icmp echo` 的交互过程了,首先发送方发起了 `ICMP echo request` 请求报文,接收方收到后回了一个 `ICMP echo reply` 响应报文,之后 `seq` 是递增的。 我在这里也帮你整理了一些最常见的用法,并且绘制成了表格,你可以参考使用。 首先,先来看看常用的选项类,在上面的 ping 例子中,我们用过 `-i` 选项指定网口,用过 `-nn` 选项不对 IP 地址和端口名称解析。其他常用的选项,如下表格: ![tcpdump 常用选项类](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/7.jpg) 接下来,我们再来看看常用的过滤表用法,在上面的 ping 例子中,我们用过的是 `icmp and host 183.232.231.174`,表示抓取 icmp 协议的数据包,以及源地址或目标地址为 183.232.231.174 的包。其他常用的过滤选项,我也整理成了下面这个表格。 ![tcpdump 常用过滤表达式类](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/8.jpg) 说了这么多,你应该也发现了,tcpdump 虽然功能强大,但是输出的格式并不直观。 所以,在工作中 tcpdump 只是用来抓取数据包,不用来分析数据包,而是把 tcpdump 抓取的数据包保存成 pcap 后缀的文件,接着用 Wireshark 工具进行数据包分析。 > Wireshark 工具如何分析数据包? Wireshark 除了可以抓包外,还提供了可视化分析网络包的图形页面,同时,还内置了一系列的汇总分析工具。 比如,拿上面的 ping 例子来说,我们可以使用下面的命令,把抓取的数据包保存到 ping.pcap 文件 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/9.jpg) 接着把 ping.pcap 文件拖到电脑,再用 Wireshark 打开它。打开后,你就可以看到下面这个界面: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/10.jpg) 是吧?在 Wireshark 的页面里,可以更加直观的分析数据包,不仅展示各个网络包的头部信息,还会用不同的颜色来区分不同的协议,由于这次抓包只有 ICMP 协议,所以只有紫色的条目。 接着,在网络包列表中选择某一个网络包后,在其下面的网络包详情中,**可以更清楚的看到,这个网络包在协议栈各层的详细信息**。比如,以编号 1 的网络包为例子: ![ping 网络包](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/11.jpg) - 可以在数据链路层,看到 MAC 包头信息,如源 MAC 地址和目标 MAC 地址等字段; - 可以在 IP 层,看到 IP 包头信息,如源 IP 地址和目标 IP 地址、TTL、IP 包长度、协议等 IP 协议各个字段的数值和含义; - 可以在 ICMP 层,看到 ICMP 包头信息,比如 Type、Code 等 ICMP 协议各个字段的数值和含义; Wireshark 用了分层的方式,展示了各个层的包头信息,把“不可见”的数据包,清清楚楚的展示了给我们,还有理由学不好计算机网络吗?是不是**相见恨晚**? 从 ping 的例子中,我们可以看到网络分层就像有序的分工,每一层都有自己的责任范围和信息,上层协议完成工作后就交给下一层,最终形成一个完整的网络包。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/12.jpg) --- ## 解密 TCP 三次握手和四次挥手 既然学会了 tcpdump 和 Wireshark 两大网络分析利器,那我们快马加鞭,接下来用它俩抓取和分析 HTTP 协议网络包,并理解 TCP 三次握手和四次挥手的工作原理。 本次例子,我们将要访问的 http://192.168.3.200 服务端。在终端一用 tcpdump 命令抓取数据包: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/13.jpg) 接着,在终端二执行下面的 curl 命令: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/14.jpg) 最后,回到终端一,按下 Ctrl+C 停止 tcpdump,并把得到的 http.pcap 取出到电脑。 使用 Wireshark 打开 http.pcap 后,你就可以在 Wireshark 中,看到如下的界面: ![HTTP 网络包](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/15.jpg) 我们都知道 HTTP 是基于 TCP 协议进行传输的,那么: - 最开始的 3 个包就是 TCP 三次握手建立连接的包 - 中间是 HTTP 请求和响应的包 - 而最后的 3 个包则是 TCP 断开连接的挥手包 Wireshark 可以用时序图的方式显示数据包交互的过程,从菜单栏中,点击 统计 (Statistics) -> 流量图 (Flow Graph),然后,在弹出的界面中的「流量类型」选择「TCP Flows」,你可以更清晰的看到,整个过程中 TCP 流的执行过程: ![TCP 流量图](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/16.jpg) > 你可能会好奇,为什么三次握手连接过程的 Seq 是 0? 实际上是因为 Wireshark 工具帮我们做了优化,它默认显示的是序列号 seq 是相对值,而不是真实值。 如果你想看到实际的序列号的值,可以右键菜单,然后找到「协议首选项」,接着找到「Relative Seq」后,把它给取消,操作如下: ![取消序列号相对值显示](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/17.jpg) 取消后,Seq 显示的就是真实值了: ![TCP 流量图](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/18.jpg) 可见,客户端和服务端的序列号实际上是不同的,序列号是一个随机值。 这其实跟我们书上看到的 TCP 三次握手和四次挥手很类似,作为对比,你通常看到的 TCP 三次握手和四次挥手的流程,基本是这样的: ![TCP 三次握手和四次挥手的流程](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/19.jpg) > 为什么抓到的 TCP 挥手是三次,而不是书上说的四次? 当被动关闭方(上图的服务端)在 TCP 挥手过程中,「**没有数据要发送」并且「开启了 TCP 延迟确认机制」,那么第二和第三次挥手就会合并传输,这样就出现了三次挥手。** 而通常情况下,服务器端收到客户端的 `FIN` 后,很可能还没发送完数据,所以就会先回复客户端一个 `ACK` 包,稍等一会儿,完成所有数据包的发送后,才会发送 `FIN` 包,这也就是四次挥手了。 --- ## TCP 三次握手异常情况实战分析 TCP 三次握手的过程相信大家都背的滚瓜烂熟,那么你有没有想过这三个异常情况: - **TCP 第一次握手的 SYN 丢包了,会发生了什么?** - **TCP 第二次握手的 SYN、ACK 丢包了,会发生什么?** - **TCP 第三次握手的 ACK 包丢了,会发生什么?** 有的小伙伴可能说:“很简单呀,包丢了就会重传嘛。” 那我在继续问你: - 那会重传几次? - 超时重传的时间 RTO 会如何变化? - 在 Linux 下如何设置重传次数? - …… 是不是哑口无言,无法回答? 不知道没关系,接下里我用三个实验案例,带大家一起探究探究这三种异常。 ### 实验场景 本次实验用了两台虚拟机,一台作为服务端,一台作为客户端,它们的关系如下: ![实验环境](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/21.jpg) - 客户端和服务端都是 CentOs 6.5 Linux,Linux 内核版本 2.6.32 - 服务端 192.168.12.36,apache web 服务 - 客户端 192.168.12.37 ### 实验一:TCP 第一次握手 SYN 丢包 为了模拟 TCP 第一次握手 SYN 丢包的情况,我是在拔掉服务器的网线后,立刻在客户端执行 curl 命令: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/22.jpg) 其间 tcpdump 抓包的命令如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/23.jpg) 过了一会,curl 返回了超时连接的错误: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/24.jpg) 从 `date` 返回的时间,可以发现在超时接近 1 分钟的时间后,curl 返回了错误。 接着,把 tcp_sys_timeout.pcap 文件用 Wireshark 打开分析,显示如下图: ![SYN 超时重传五次](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/25.jpg) 从上图可以发现,客户端发起了 SYN 包后,一直没有收到服务端的 ACK,所以一直超时重传了 5 次,并且每次 RTO 超时时间是不同的: - 第一次是在 1 秒超时重传 - 第二次是在 3 秒超时重传 - 第三次是在 7 秒超时重传 - 第四次是在 15 秒超时重传 - 第五次是在 31 秒超时重传 可以发现,每次超时时间 RTO 是**指数(翻倍)上涨的**,当超过最大重传次数后,客户端不再发送 SYN 包。 在 Linux 中,第一次握手的 `SYN` 超时重传次数,是如下内核参数指定的: ```bash $ cat /proc/sys/net/ipv4/tcp_syn_retries 5 ``` `tcp_syn_retries` 默认值为 5,也就是 SYN 最大重传次数是 5 次。 接下来,我们继续做实验,把 `tcp_syn_retries` 设置为 2 次: ```bash $ echo 2 > /proc/sys/net/ipv4/tcp_syn_retries ``` 重传抓包后,用 Wireshark 打开分析,显示如下图: ![SYN 超时重传两次](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/26.jpg) > 实验一的实验小结 通过实验一的实验结果,我们可以得知,当客户端发起的 TCP 第一次握手 SYN 包,在超时时间内没收到服务端的 ACK,就会在超时重传 SYN 数据包,每次超时重传的 RTO 是翻倍上涨的,直到 SYN 包的重传次数到达 `tcp_syn_retries` 值后,客户端不再发送 SYN 包。 ![SYN 超时重传](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/27.jpg) ### 实验二:TCP 第二次握手 SYN、ACK 丢包 为了模拟客户端收不到服务端第二次握手 SYN、ACK 包,我的做法是在客户端加上防火墙限制,直接粗暴的把来自服务端的数据都丢弃,防火墙的配置如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/28.jpg) 接着,在客户端执行 curl 命令: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/29.jpg) 从 `date` 返回的时间前后,可以算出大概 1 分钟后,curl 报错退出了。 客户端在这其间抓取的数据包,用 Wireshark 打开分析,显示的时序图如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/30.jpg) 从图中可以发现: - 客户端发起 SYN 后,由于防火墙屏蔽了服务端的所有数据包,所以 curl 是无法收到服务端的 SYN、ACK 包,当发生超时后,就会重传 SYN 包 - 服务端收到客户的 SYN 包后,就会回 SYN、ACK 包,但是客户端一直没有回 ACK,服务端在超时后,重传了 SYN、ACK 包,**接着一会,客户端超时重传的 SYN 包又抵达了服务端,服务端收到后,然后回了 SYN、ACK 包,但是 SYN、ACK 包的重传定时器并没有重置,还持续在重传,因为第二次握手在没收到第三次握手的 ACK 确认报文时,就会重传到最大次数。** - 最后,客户端 SYN 超时重传次数达到了 5 次(tcp_syn_retries 默认值 5 次),就不再继续发送 SYN 包了。 所以,我们可以发现,**当第二次握手的 SYN、ACK 丢包时,客户端会超时重发 SYN 包,服务端也会超时重传 SYN、ACK 包。** > 咦?客户端设置了防火墙,屏蔽了服务端的网络包,为什么 tcpdump 还能抓到服务端的网络包? 添加 iptables 限制后,tcpdump 是否能抓到包,这要看添加的 iptables 限制条件: - 如果添加的是 `INPUT` 规则,则可以抓得到包 - 如果添加的是 `OUTPUT` 规则,则抓不到包 网络包进入主机后的顺序如下: - 进来的顺序 Wire -> NIC -> **tcpdump -> netfilter/iptables** - 出去的顺序 **iptables -> tcpdump** -> NIC -> Wire > tcp_syn_retries 是限制 SYN 重传次数,那第二次握手 SYN、ACK 限制最大重传次数是多少? TCP 第二次握手 SYN、ACK 包的最大重传次数是通过 `tcp_synack_retries` 内核参数限制的,其默认值如下: ```bash $ cat /proc/sys/net/ipv4/tcp_synack_retries 5 ``` 是的,TCP 第二次握手 SYN、ACK 包的最大重传次数默认值是 `5` 次。 为了验证 SYN、ACK 包最大重传次数是 5 次,我们继续做下实验,我们先把客户端的 `tcp_syn_retries` 设置为 1,表示客户端 SYN 最大超时次数是 1 次,目的是为了防止多次重传 SYN,把服务端 SYN、ACK 超时定时器重置。 接着,还是如上面的步骤: 1. 客户端配置防火墙屏蔽服务端的数据包 2. 客户端 tcpdump 抓取 curl 执行时的数据包 把抓取的数据包,用 Wireshark 打开分析,显示的时序图如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/31.jpg) 从上图,我们可以分析出: - 客户端的 SYN 只超时重传了 1 次,因为 `tcp_syn_retries` 值为 1 - 服务端应答了客户端超时重传的 SYN 包后,由于一直收不到客户端的 ACK 包,所以服务端一直在超时重传 SYN、ACK 包,每次的 RTO 也是指数上涨的,一共超时重传了 5 次,因为 `tcp_synack_retries` 值为 5 接着,我把 **tcp_synack_retries 设置为 2**,`tcp_syn_retries` 依然设置为 1: ```bash $ echo 2 > /proc/sys/net/ipv4/tcp_synack_retries $ echo 1 > /proc/sys/net/ipv4/tcp_syn_retries ``` 依然保持一样的实验步骤进行操作,接着把抓取的数据包,用 Wireshark 打开分析,显示的时序图如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/32.jpg) 可见: - 客户端的 SYN 包只超时重传了 1 次,符合 tcp_syn_retries 设置的值; - 服务端的 SYN、ACK 超时重传了 2 次,符合 tcp_synack_retries 设置的值 > 实验二的实验小结 通过实验二的实验结果,我们可以得知,当 TCP 第二次握手 SYN、ACK 包丢了后,客户端 SYN 包会发生超时重传,服务端 SYN、ACK 也会发生超时重传。 客户端 SYN 包超时重传的最大次数,是由 tcp_syn_retries 决定的,默认值是 5 次;服务端 SYN、ACK 包时重传的最大次数,是由 tcp_synack_retries 决定的,默认值是 5 次。 ### 实验三:TCP 第三次握手 ACK 丢包 为了模拟 TCP 第三次握手 ACK 包丢,我的实验方法是**在服务端配置防火墙,屏蔽客户端 TCP 报文中标志位是 ACK 的包**,也就是当服务端收到客户端的 TCP ACK 的报文时就会丢弃。 iptables 配置命令如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/33.jpg) 接着,在客户端执行如下 tcpdump 命令: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/34.jpg) 然后,客户端向服务端发起 telnet,因为 telnet 命令是会发起 TCP 连接,所以用此命令做测试: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/35.jpg) 此时,由于服务端收不到第三次握手的 ACK 包,所以一直处于 `SYN_RECV` 状态: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/36.jpg) 而客户端是已完成 TCP 连接建立,处于 `ESTABLISHED` 状态: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/37.jpg) 过了 1 分钟后,观察发现服务端的 TCP 连接不见了: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/38.jpg) 过了 30 分钟,客户端依然还是处于 `ESTABLISHED` 状态: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/39.jpg) 接着,在刚才客户端建立的 telnet 会话,输入 123456 字符,进行发送: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/40.jpg) 持续「好长」一段时间,客户端的 telnet 才断开连接: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/41.jpg) 以上就是本次的实现三的现象,这里存在两个疑点: - 为什么服务端原本处于 `SYN_RECV` 状态的连接,过 1 分钟后就消失了? - 为什么客户端 telnet 输入 123456 字符后,过了好长一段时间,telnet 才断开连接? 不着急,我们把刚抓的数据包,用 Wireshark 打开分析,显示的时序图如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/42.jpg) 上图的流程: - 客户端发送 SYN 包给服务端,服务端收到后,回了个 SYN、ACK 包给客户端,此时服务端的 TCP 连接处于 `SYN_RECV` 状态; - 客户端收到服务端的 SYN、ACK 包后,给服务端回了个 ACK 包,此时客户端的 TCP 连接处于 `ESTABLISHED` 状态; - 由于服务端配置了防火墙,屏蔽了客户端的 ACK 包,所以服务端一直处于 `SYN_RECV` 状态,没有进入 `ESTABLISHED` 状态,tcpdump 之所以能抓到客户端的 ACK 包,是因为数据包进入系统的顺序是先进入 tcpudmp,后经过 iptables; - 接着,服务端超时重传了 SYN、ACK 包,重传了 5 次后,也就是**超过 tcp_synack_retries 的值(默认值是 5),然后就没有继续重传了,此时服务端的 TCP 连接主动中止了,所以刚才处于 SYN_RECV 状态的 TCP 连接断开了**,而客户端依然处于`ESTABLISHED` 状态; - 虽然服务端 TCP 断开了,但过了一段时间,发现客户端依然处于`ESTABLISHED` 状态,于是就在客户端的 telnet 会话输入了 123456 字符; - 由于服务端的防火墙配置了屏蔽所有携带 ACK 标志位的 TCP 报文,客户端发送的数据报文,服务端并不会接收,而是丢弃(如果服务端没有设置防火墙,由于服务端已经断开连接,此时收到客户的发来的数据报文后,会回 RST 报文)。客户端由于一直收不到数据报文的确认报文,所以触发超时重传,在超时重传过程中,每一次重传,RTO 的值是指数增长的,所以持续了好长一段时间,客户端的 telnet 才报错退出了,此时共重传了 15 次,然后客户端的也断开了连接。 通过这一波分析,刚才的两个疑点已经解除了: - 服务端在重传 SYN、ACK 包时,超过了最大重传次数 `tcp_synack_retries`,于是服务端的 TCP 连接主动断开了。 - 客户端向服务端发送数据报文时,如果迟迟没有收到数据包的确认报文,也会触发超时重传,一共重传了 15 次数据报文,最后 telnet 就断开了连接。 > TCP 第一次握手的 SYN 包超时重传最大次数是由 tcp_syn_retries 指定,TCP 第二次握手的 SYN、ACK 包超时重传最大次数是由 tcp_synack_retries 指定,那 TCP 建立连接后的数据包最大超时重传次数是由什么参数指定呢? TCP 建立连接后的数据包传输,最大超时重传次数是由 `tcp_retries2` 指定,默认值是 15 次,如下: ```plain $ cat /proc/sys/net/ipv4/tcp_retries2 15 ``` 如果 15 次重传都做完了,TCP 就会告诉应用层说:“搞不定了,包怎么都传不过去!” > 那如果客户端不发送数据,什么时候才会断开处于 ESTABLISHED 状态的连接? 这里就需要提到 TCP 的 **保活机制**。这个机制的原理是这样的: 定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个「探测报文」,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。 在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值: ```plain net.ipv4.tcp_keepalive_time=7200 net.ipv4.tcp_keepalive_intvl=75 net.ipv4.tcp_keepalive_probes=9 ``` - tcp_keepalive_time=7200:表示保活时间是 7200 秒(2 小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制 - tcp_keepalive_intvl=75:表示每次检测间隔 75 秒; - tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。 也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/43.jpg) 这个时间是有点长的,所以如果我抓包足够久,或许能抓到探测报文。 > 实验三的实验小结 在建立 TCP 连接时,如果第三次握手的 ACK,服务端无法收到,则服务端就会短暂处于 `SYN_RECV` 状态,而客户端会处于 `ESTABLISHED` 状态。 由于服务端一直收不到 TCP 第三次握手的 ACK,则会一直重传 SYN、ACK 包,直到重传次数超过 `tcp_synack_retries` 值(默认值 5 次)后,服务端就会断开 TCP 连接。 而客户端则会有两种情况: - 如果客户端没发送数据包,一直处于 `ESTABLISHED` 状态,然后经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接,于是客户端连接就会断开连接。 - 如果客户端发送了数据包,一直没有收到服务端对该数据包的确认报文,则会一直重传该数据包,直到重传次数超过 `tcp_retries2` 值(默认值 15 次)后,客户端就会断开 TCP 连接。 --- ## TCP 快速建立连接 客户端在向服务端发起 HTTP GET 请求时,一个完整的交互过程,需要 2.5 个 RTT 的时延。 由于第三次握手是可以携带数据的,这时如果在第三次握手发起 HTTP GET 请求,需要 2 个 RTT 的时延。 但是在下一次(不是同个 TCP 连接的下一次)发起 HTTP GET 请求时,经历的 RTT 也是一样,如下图: ![常规 HTTP 请求](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/44.jpg) 在 Linux 3.7 内核版本中,提供了 TCP Fast Open 功能,这个功能可以减少 TCP 连接建立的时延。 ![常规 HTTP 请求 与 Fast Open HTTP 请求](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/45.jpg) - 在第一次建立连接的时候,服务端在第二次握手产生一个 `Cookie` (已加密)并通过 SYN、ACK 包一起发给客户端,于是客户端就会缓存这个 `Cookie`,所以第一次发起 HTTP Get 请求的时候,还是需要 2 个 RTT 的时延; - 在下次请求的时候,客户端在 SYN 包带上 `Cookie` 发给服务端,就提前可以跳过三次握手的过程,因为 `Cookie` 中维护了一些信息,服务端可以从 `Cookie` 获取 TCP 相关的信息,这时发起的 HTTP GET 请求就只需要 1 个 RTT 的时延; 注:客户端在请求并存储了 Fast Open Cookie 之后,可以不断重复 TCP Fast Open 直至服务器认为 Cookie 无效(通常为过期) > 在 Linux 上如何打开 Fast Open 功能? 可以通过设置 `net.ipv4.tcp_fastopn` 内核参数,来打开 Fast Open 功能。 net.ipv4.tcp_fastopn 各个值的意义: - 0 关闭 - 1 作为客户端使用 Fast Open 功能 - 2 作为服务端使用 Fast Open 功能 - 3 无论作为客户端还是服务器,都可以使用 Fast Open 功能 > TCP Fast Open 抓包分析 在下图,数据包 7 号,客户端发起了第二次 TCP 连接时,SYN 包会携带 Cooike,并且长度为 5 的数据。 服务端收到后,校验 Cooike 合法,于是就回了 SYN、ACK 包,并且确认应答收到了客户端的数据包,ACK = 5 + 1 = 6 ![TCP Fast Open 抓包分析](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/46.jpg) --- ## TCP 重复确认和快速重传 当接收方收到乱序数据包时,会发送重复的 ACK,以便告知发送方要重发该数据包,**当发送方收到 3 个重复 ACK 时,就会触发快速重传,立刻重发丢失数据包。** ![快速重传机制](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/47.jpg) TCP 重复确认和快速重传的一个案例,用 Wireshark 分析,显示如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/48.jpg) - 数据包 1 期望的下一个数据包 Seq 是 1,但是数据包 2 发送的 Seq 却是 10945,说明收到的是乱序数据包,于是回了数据包 3,还是同样的 Seq = 1,Ack = 1,这表明是重复的 ACK; - 数据包 4 和 6 依然是乱序的数据包,于是依然回了重复的 ACK; - 当对方收到三次重复的 ACK 后,于是就快速重传了 Seq = 1、Len = 1368 的数据包 8; - 当收到重传的数据包后,发现 Seq = 1 是期望的数据包,于是就发送了个确认收到快速重传的 ACK 注意:快速重传和重复 ACK 标记信息是 Wireshark 的功能,非数据包本身的信息。 以上案例在 TCP 三次握手时协商开启了**选择性确认 SACK**,因此一旦数据包丢失并收到重复 ACK,即使在丢失数据包之后还成功接收了其他数据包,也只需要重传丢失的数据包。如果不启用 SACK,就必须重传丢失包之后的每个数据包。 如果要支持 `SACK`,必须双方都要支持。在 Linux 下,可以通过 `net.ipv4.tcp_sack` 参数打开这个功能(Linux 2.4 后默认打开)。 --- ## TCP 流量控制 TCP 为了防止发送方无脑的发送数据,导致接收方缓冲区被填满,所以就有了滑动窗口的机制,它可利用接收方的接收窗口来控制发送方要发送的数据量,也就是流量控制。 接收窗口是由接收方指定的值,存储在 TCP 头部中,它可以告诉发送方自己的 TCP 缓冲空间区大小,这个缓冲区是给应用程序读取数据的空间: - 如果应用程序读取了缓冲区的数据,那么缓冲空间区就会把被读取的数据移除 - 如果应用程序没有读取数据,则数据会一直滞留在缓冲区。 接收窗口的大小,是在 TCP 三次握手中协商好的,后续数据传输时,接收方发送确认应答 ACK 报文时,会携带当前的接收窗口的大小,以此来告知发送方。 假设接收方接收到数据后,应用层能很快的从缓冲区里读取数据,那么窗口大小会一直保持不变,过程如下: ![理想状态下的窗口变化](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/49.jpg) 但是现实中服务器会出现繁忙的情况,当应用程序读取速度慢,那么缓存空间会慢慢被占满,于是为了保证发送方发送的数据不会超过缓冲区大小,服务器则会调整窗口大小的值,接着通过 ACK 报文通知给对方,告知现在的接收窗口大小,从而控制发送方发送的数据大小。 ![服务端繁忙状态下的窗口变化](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/50.jpg) ### 零窗口通知与窗口探测 假设接收方处理数据的速度跟不上接收数据的速度,缓存就会被占满,从而导致接收窗口为 0,当发送方接收到零窗口通知时,就会停止发送数据。 如下图,可以看到接收方的窗口大小在不断的收缩至 0: ![窗口大小在收缩](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/51.jpg) 接着,发送方会**定时发送窗口大小探测报文**,以便及时知道接收方窗口大小的变化。 以下图 Wireshark 分析图作为例子说明: ![零窗口 与 窗口探测](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/52.jpg) - 发送方发送了数据包 1 给接收方,接收方收到后,由于缓冲区被占满,回了个零窗口通知; - 发送方收到零窗口通知后,就不再发送数据了,直到过了 `3.4` 秒后,发送了一个 TCP Keep-Alive 报文,也就是窗口大小探测报文; - 当接收方收到窗口探测报文后,就立马回一个窗口通知,但是窗口大小还是 0; - 发送方发现窗口还是 0,于是继续等待了 `6.8`(翻倍)秒后,又发送了窗口探测报文,接收方依然还是回了窗口为 0 的通知; - 发送方发现窗口还是 0,于是继续等待了 `13.5`(翻倍)秒后,又发送了窗口探测报文,接收方依然还是回了窗口为 0 的通知; 可以发现,这些窗口探测报文以 3.4s、6.5s、13.5s 的间隔出现,说明超时时间会**翻倍**递增。 这连接暂停了 25s,想象一下你在打王者的时候,25s 的延迟你还能上王者吗? ### 发送窗口的分析 > 在 Wireshark 看到的 Windows size 也就是 " win = ",这个值表示发送窗口吗? 这不是发送窗口,而是在向对方声明自己的接收窗口。 你可能会好奇,抓包文件里有「Window size scaling factor」,它其实是算出实际窗口大小的乘法因子,「Window size value」实际上并不是真实的窗口大小,真实窗口大小的计算公式如下: 「Window size value」 * 「Window size scaling factor」 = 「Caculated window size 」 对应的下图案例,也就是 32 * 2048 = 65536。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/53.jpg) 实际上是 Caculated window size 的值是 Wireshark 工具帮我们算好的,Window size scaling factor 和 Window size value 的值是在 TCP 头部中,其中 Window size scaling factor 是在三次握手过程中确定的,如果你抓包的数据没有 TCP 三次握手,那可能就无法算出真实的窗口大小的值,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/54.jpg) > 如何在包里看出发送窗口的大小? 很遗憾,没有简单的办法,发送窗口虽然是由接收窗口决定,但是它又可以被网络因素影响,也就是拥塞窗口,实际上发送窗口是值是 min(拥塞窗口,接收窗口)。 > 发送窗口和 MSS 有什么关系? 发送窗口决定了一口气能发多少字节,而 MSS 决定了这些字节要分多少包才能发完。 举个例子,如果发送窗口为 16000 字节的情况下,如果 MSS 是 1000 字节,那就需要发送 1600/1000 = 16 个包。 > 发送方在一个窗口发出 n 个包,是不是需要 n 个 ACK 确认报文? 不一定,因为 TCP 有累计确认机制,所以当收到多个数据包时,只需要应答最后一个数据包的 ACK 报文就可以了。 --- ## TCP 延迟确认与 Nagle 算法 当我们 TCP 报文的承载的数据非常小的时候,例如几个字节,那么整个网络的效率是很低的,因为每个 TCP 报文中都会有 20 个字节的 TCP 头部,也会有 20 个字节的 IP 头部,而数据只有几个字节,所以在整个报文中有效数据占有的比重就会非常低。 这就好像快递员开着大货车送一个小包裹一样浪费。 那么就出现了常见的两种策略,来减少小报文的传输,分别是: - Nagle 算法 - 延迟确认 > Nagle 算法是如何避免大量 TCP 小数据报文的传输? Nagle 算法做了一些策略来避免过多的小数据报文发送,这可提高传输效率。 Nagle 伪代码如下: ```c if 有数据要发送 { if 可用窗口大小 >= MSS and 可发送的数据 >= MSS { 立刻发送MSS大小的数据 } else { if 有未确认的数据 { 将数据放入缓存等待接收ACK } else { 立刻发送数据 } } } ``` 使用 Nagle 算法,该算法的思路是延时处理,只有满足下面两个条件中的任意一个条件,才能可以发送数据: - 条件一:要等到窗口大小 >= `MSS` 并且 数据大小 >= `MSS`; - 条件二:收到之前发送数据的 `ack` 回包; 只要上面两个条件都不满足,发送方一直在囤积数据,直到满足上面的发送条件。 ![禁用 Nagle 算法 与 启用 Nagle 算法](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/55.jpg) 上图右侧启用了 Nagle 算法,它的发送数据的过程: - 一开始由于没有已发送未确认的报文,所以就立刻发了 H 字符; - 接着,在还没收到对 H 字符的确认报文时,发送方就一直在囤积数据,直到收到了确认报文后,此时没有已发送未确认的报文,于是就把囤积后的 ELL 字符一起发给了接收方; - 待收到对 ELL 字符的确认报文后,于是把最后一个 O 字符发送了出去 可以看出,**Nagle 算法一定会有一个小报文,也就是在最开始的时候。** 另外,Nagle 算法默认是打开的,如果对于一些需要小数据包交互的场景的程序,比如,telnet 或 ssh 这样的交互性比较强的程序,则需要关闭 Nagle 算法。 可以在 Socket 设置 `TCP_NODELAY` 选项来关闭这个算法(关闭 Nagle 算法没有全局参数,需要根据每个应用自己的特点来关闭)。 ![关闭 Nagle 算法](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/56.jpg) > 那延迟确认又是什么? 事实上当没有携带数据的 ACK,它的网络效率也是很低的,因为它也有 40 个字节的 IP 头 和 TCP 头,但却没有携带数据报文。 为了解决 ACK 传输效率低问题,所以就衍生出了 **TCP 延迟确认**。 TCP 延迟确认的策略: - 当有响应数据要发送时,ACK 会随着响应数据一起立刻发送给对方 - 当没有响应数据要发送时,ACK 将会延迟一段时间,以等待是否有响应数据可以一起发送 - 如果在延迟等待发送 ACK 期间,对方的第二个数据报文又到达了,这时就会立刻发送 ACK ![TCP 延迟确认](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/57.jpg) 延迟等待的时间是在 Linux 内核中定义的,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/58.jpg) 关键就需要 `HZ` 这个数值大小,HZ 是跟系统的时钟频率有关,每个操作系统都不一样,在我的 Linux 系统中 HZ 大小是 `1000`,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/59.jpg) 知道了 HZ 的大小,那么就可以算出: * 最大延迟确认时间是 `200` ms (1000/5) * 最短延迟确认时间是 `40` ms (1000/25) TCP 延迟确认可以在 Socket 设置 `TCP_QUICKACK` 选项来关闭这个算法。 ![关闭 TCP 延迟确认](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/60.jpg) > 延迟确认 和 Nagle 算法混合使用时,会产生新的问题 当 TCP 延迟确认 和 Nagle 算法混合使用时,会导致时耗增长,如下图: ![TCP 延迟确认 和 Nagle 算法混合使用](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/计算机网络/TCP-Wireshark/61.jpg) 发送方使用了 Nagle 算法,接收方使用了 TCP 延迟确认会发生如下的过程: - 发送方先发出一个小报文,接收方收到后,由于延迟确认机制,自己又没有要发送的数据,只能干等着发送方的下一个报文到达; - 而发送方由于 Nagle 算法机制,在未收到第一个报文的确认前,是不会发送后续的数据; - 所以接收方只能等待最大时间 200 ms 后,才回 ACK 报文,发送方收到第一个报文的确认报文后,也才可以发送后续的数据。 很明显,这两个同时使用会造成额外的时延,这就会使得网络"很慢"的感觉。 要解决这个问题,只有两个办法: - 要不发送方关闭 Nagle 算法 - 要不接收方关闭 TCP 延迟确认 --- 参考资料: [1] Wireshark 网络分析的艺术。林沛满。人民邮电出版社。 [2] Wireshark 网络分析就这么简单。林沛满。人民邮电出版社。 [3] Wireshark 数据包分析实战.Chris Sanders .人民邮电出版社。读者问答 --- ## 读者问答 > 读者问:“两个问题,请教一下作者: > tcp_retries1 参数,是什么场景下生效? > tcp_retries2 是不是只受限于规定的次数,还是受限于次数和时间限制的最小值?” tcp_retries1 和 tcp_retries2 都是在 TCP 三次握手之后的场景。 - 当重传次数超过 tcp_retries1 就会指示 IP 层进行 MTU 探测、刷新路由等过程,并不会断开 TCP 连接,当重传次数超过 tcp_retries2 才会断开 TCP 流。 - tcp_retries1 和 tcp_retries2 两个重传次数都是受一个 timeout 值限制的,timeout 的值是根据它俩的值计算出来的,当重传时间超过 timeout,就不会继续重传了,即使次数还没到达。 > 读者问:“tcp_orphan_retries 也是控制 tcp 连接的关闭。这个跟 tcp_retries1 tcp_retries2 有什么区别吗?” 主动方发送 FIN 报文后,连接就处于 FIN_WAIT1 状态下,该状态通常应在数十毫秒内转为 FIN_WAIT2。如果迟迟收不到对方返回的 ACK 时,此时,内核会定时重发 FIN 报文,其中重发次数由 tcp_orphan_retries 参数控制。 > 读者问:“请问,为什么连续两个报文的 seq 会是一样的呢,比如三次握手之后的那个报文?还是说,序号相同的是同一个报文,只是拆开显示了?” 1. 三次握手中的前两次,是 seq+1; 2. 三次握手中的最后一个 ack,实际上是可以携带数据的,由于我文章的例子是没有发送数据的,你可以看到第三次握手的 len=0,在数据传输阶段「下一个 seq=seq+len」,所以第三次握手的 seq 和下一个数据报的 seq 是一样的,因为 len 为 0; --- ## 最后 文章中 Wireshark 分析的截图,可能有些会看的不清楚,为了方便大家用 Wireshark 分析,**我已把文中所有抓包的源文件,已分享到公众号了,大家在后台回复「抓包」,就可以获取了。** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-Wireshark/62.png) **小林是专为大家图解的工具人,Goodbye,我们下次见!** ================================================ FILE: network/3_tcp/tcp_three_fin.md ================================================ # 4.22 TCP 四次挥手,可以变成三次吗? 大家好,我是小林。 有位读者面美团时,被问到:**TCP 四次挥手中,能不能把第二次的 ACK 报文,放到第三次 FIN 报文一起发送?** ![](https://img-blog.csdnimg.cn/6e02477ccea24facbf7eada108158bc2.png) 虽然我们在学习 TCP 挥手时,学到的是需要四次来完成 TCP 挥手,但是**在一些情况下,TCP 四次挥手是可以变成 TCP 三次挥手的**。 ![在这里插入图片描述](https://img-blog.csdnimg.cn/52f35dcbe24a4ca7abb23f292837c707.png) 而且在用 wireshark 工具抓包的时候,我们也会常看到 TCP 挥手过程是三次,而不是四次,如下图: ![在这里插入图片描述](https://img-blog.csdnimg.cn/361207c2e5c34bec8708b79990ba7e99.png) 先来回答为什么 RFC 文档里定义 TCP 挥手过程是要四次? 再来回答什么情况下,什么情况会出现三次挥手? ## TCP 四次挥手 TCP 四次挥手的过程如下: ![在这里插入图片描述](https://img-blog.csdnimg.cn/18635e15653a4affbdab2c9bf72d599e.png) 具体过程: - 客户端主动调用关闭连接的函数,于是就会发送 FIN 报文,这个 FIN 报文代表客户端不会再发送数据了,进入 FIN_WAIT_1 状态; - 服务端收到了 FIN 报文,然后马上回复一个 ACK 确认报文,此时服务端进入 CLOSE_WAIT 状态。在收到 FIN 报文的时候,TCP 协议栈会为 FIN 包插入一个文件结束符 EOF 到接收缓冲区中,服务端应用程序可以通过 read 调用来感知这个 FIN 包,这个 EOF 会被**放在已排队等候的其他已接收的数据之后**,所以必须要得继续 read 接收缓冲区已接收的数据; - 接着,当服务端在 read 数据的时候,最后自然就会读到 EOF,接着 **read() 就会返回 0,这时服务端应用程序如果有数据要发送的话,就发完数据后才调用关闭连接的函数,如果服务端应用程序没有数据要发送的话,可以直接调用关闭连接的函数**,这时服务端就会发一个 FIN 包,这个 FIN 报文代表服务端不会再发送数据了,之后处于 LAST_ACK 状态; - 客户端接收到服务端的 FIN 包,并发送 ACK 确认包给服务端,此时客户端将进入 TIME_WAIT 状态; - 服务端收到 ACK 确认包后,就进入了最后的 CLOSE 状态; - 客户端经过 2MSL 时间之后,也进入 CLOSE 状态; 你可以看到,每个方向都需要**一个 FIN 和一个 ACK**,因此通常被称为**四次挥手**。 ### 为什么 TCP 挥手需要四次呢? 服务器收到客户端的 FIN 报文时,内核会马上回一个 ACK 应答报文,**但是服务端应用程序可能还有数据要发送,所以并不能马上发送 FIN 报文,而是将发送 FIN 报文的控制权交给服务端应用程序**: - 如果服务端应用程序有数据要发送的话,就发完数据后,才调用关闭连接的函数; - 如果服务端应用程序没有数据要发送的话,可以直接调用关闭连接的函数, 从上面过程可知,**是否要发送第三次挥手的控制权不在内核,而是在被动关闭方(上图的服务端)的应用程序,因为应用程序可能还有数据要发送,由应用程序决定什么时候调用关闭连接的函数,当调用了关闭连接的函数,内核就会发送 FIN 报文了,** 所以服务端的 ACK 和 FIN 一般都会分开发送。 > FIN 报文一定得调用关闭连接的函数,才会发送吗? 不一定。 如果进程退出了,不管是不是正常退出,还是异常退出(如进程崩溃),内核都会发送 FIN 报文,与对方完成四次挥手。 ### 粗暴关闭 vs 优雅关闭 前面介绍 TCP 四次挥手的时候,并没有详细介绍关闭连接的函数,其实关闭的连接的函数有两种函数: - close 函数,同时 socket 关闭发送方向和读取方向,也就是 socket 不再有发送和接收数据的能力。如果有多进程/多线程共享同一个 socket,如果有一个进程调用了 close 关闭只是让 socket 引用计数 -1,并不会导致 socket 不可用,同时也不会发出 FIN 报文,其他进程还是可以正常读写该 socket,直到引用计数变为 0,才会发出 FIN 报文。 - shutdown 函数,可以指定 socket 只关闭发送方向而不关闭读取方向,也就是 socket 不再有发送数据的能力,但是还是具有接收数据的能力。如果有多进程/多线程共享同一个 socket,shutdown 则不管引用计数,直接使得该 socket 不可用,然后发出 FIN 报文,如果有别的进程企图使用该 socket,将会受到影响。 如果客户端是用 close 函数来关闭连接,那么在 TCP 四次挥手过程中,如果收到了服务端发送的数据,由于客户端已经不再具有发送和接收数据的能力,所以客户端的内核会回 RST 报文给服务端,然后内核会释放连接,这时就不会经历完成的 TCP 四次挥手,所以我们常说,调用 close 是粗暴的关闭。 ![在这里插入图片描述](https://img-blog.csdnimg.cn/3b5f1897d2d74028aaf4d552fbce1a74.png) 当服务端收到 RST 后,内核就会释放连接,当服务端应用程序再次发起读操作或者写操作时,就能感知到连接已经被释放了: - 如果是读操作,则会返回 RST 的报错,也就是我们常见的 Connection reset by peer。 - 如果是写操作,那么程序会产生 SIGPIPE 信号,应用层代码可以捕获并处理信号,如果不处理,则默认情况下进程会终止,异常退出。 相对的,shutdown 函数因为可以指定只关闭发送方向而不关闭读取方向,所以即使在 TCP 四次挥手过程中,如果收到了服务端发送的数据,客户端也是可以正常读取到该数据的,然后就会经历完整的 TCP 四次挥手,所以我们常说,调用 shutdown 是优雅的关闭。 ![优雅关闭.drawio.png](https://img-blog.csdnimg.cn/71f5646ec58849e5921adc08bb6789d4.png) 但是注意,shutdown 函数也可以指定「只关闭读取方向,而不关闭发送方向」,但是这时候内核是不会发送 FIN 报文的,因为发送 FIN 报文是意味着我方将不再发送任何数据,而 shutdown 如果指定「不关闭发送方向」,就意味着 socket 还有发送数据的能力,所以内核就不会发送 FIN。 ## 什么情况会出现三次挥手? 当被动关闭方(上图的服务端)在 TCP 挥手过程中,「**没有数据要发送」并且「开启了 TCP 延迟确认机制」,那么第二和第三次挥手就会合并传输,这样就出现了三次挥手。** ![在这里插入图片描述](https://img-blog.csdnimg.cn/d7b349efa4f94453943b433b704a4ca8.png) 然后因为 TCP 延迟确认机制是默认开启的,所以导致我们抓包时,看见三次挥手的次数比四次挥手还多。 > 什么是 TCP 延迟确认机制? 当发送没有携带数据的 ACK,它的网络效率也是很低的,因为它也有 40 个字节的 IP 头 和 TCP 头,但却没有携带数据报文。 为了解决 ACK 传输效率低问题,所以就衍生出了 **TCP 延迟确认**。 TCP 延迟确认的策略: - 当有响应数据要发送时,ACK 会随着响应数据一起立刻发送给对方 - 当没有响应数据要发送时,ACK 将会延迟一段时间,以等待是否有响应数据可以一起发送 - 如果在延迟等待发送 ACK 期间,对方的第二个数据报文又到达了,这时就会立刻发送 ACK ![](https://img-blog.csdnimg.cn/33f3d2d54a924b0a80f565038327e0e4.png) 延迟等待的时间是在 Linux 内核中定义的,如下图: ![](https://img-blog.csdnimg.cn/ae241915337a4d2c9cb2f7ab91e6661d.png) 关键就需要 HZ 这个数值大小,HZ 是跟系统的时钟频率有关,每个操作系统都不一样,在我的 Linux 系统中 HZ 大小是 1000,如下图: ![](https://img-blog.csdnimg.cn/7a67bd4dc2894335b974e38674ba90b4.png) 知道了 HZ 的大小,那么就可以算出: - 最大延迟确认时间是 200 ms(1000/5) - 最短延迟确认时间是 40 ms(1000/25) > 怎么关闭 TCP 延迟确认机制? 如果要关闭 TCP 延迟确认机制,可以在 Socket 设置里启用 TCP_QUICKACK。 ```cpp // 1 表示开启 TCP_QUICKACK,即关闭 TCP 延迟确认机制 int value = 1; setsockopt(socketfd, IPPROTO_TCP, TCP_QUICKACK, (char*)& value, sizeof(int)); ``` ### 实验验证 #### 实验一 接下来,来给大家做个实验,验证这个结论: > 当被动关闭方(上图的服务端)在 TCP 挥手过程中,「**没有数据要发送」并且「开启了 TCP 延迟确认机制」,那么第二和第三次挥手就会合并传输,这样就出现了三次挥手。** 服务端的代码如下,做的事情很简单,就读取数据,然后当 read 返回 0 的时候,就马上调用 close 关闭连接。因为 TCP 延迟确认机制是默认开启的,所以不需要特殊设置。 ```cpp #include #include #include #include #include #include #include #include #include #define MAXLINE 1024 int main(int argc, char *argv[]) { // 1. 创建一个监听 socket int listenfd = socket(AF_INET, SOCK_STREAM, 0); if(listenfd < 0) { fprintf(stderr, "socket error : %s\n", strerror(errno)); return -1; } // 2. 初始化服务器地址和端口 struct sockaddr_in server_addr; bzero(&server_addr, sizeof(struct sockaddr_in)); server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = htonl(INADDR_ANY); server_addr.sin_port = htons(8888); // 3. 绑定地址 + 端口 if(bind(listenfd, (struct sockaddr *)(&server_addr), sizeof(struct sockaddr)) < 0) { fprintf(stderr,"bind error:%s\n", strerror(errno)); return -1; } printf("begin listen....\n"); // 4. 开始监听 if(listen(listenfd, 128)) { fprintf(stderr, "listen error:%s\n\a", strerror(errno)); exit(1); } // 5. 获取已连接的 socket struct sockaddr_in client_addr; socklen_t client_addrlen = sizeof(client_addr); int clientfd = accept(listenfd, (struct sockaddr *)&client_addr, &client_addrlen); if(clientfd < 0) { fprintf(stderr, "accept error:%s\n\a", strerror(errno)); exit(1); } printf("accept success\n"); char message[MAXLINE] = {0}; while(1) { //6. 读取客户端发送的数据 int n = read(clientfd, message, MAXLINE); if(n < 0) { // 读取错误 fprintf(stderr, "read error:%s\n\a", strerror(errno)); break; } else if(n == 0) { // 返回 0,代表读到 FIN 报文 fprintf(stderr, "client closed \n"); close(clientfd); // 没有数据要发送,立马关闭连接 break; } message[n] = 0; printf("received %d bytes: %s\n", n, message); } close(listenfd); return 0; } ``` 客户端代码如下,做的事情也很简单,与服务端连接成功后,就发送数据给服务端,然后睡眠一秒后,就调用 close 关闭连接,所以客户端是主动关闭方: ```cpp #include #include #include #include #include #include #include #include int main(int argc, char *argv[]) { // 1. 创建一个监听 socket int connectfd = socket(AF_INET, SOCK_STREAM, 0); if(connectfd < 0) { fprintf(stderr, "socket error : %s\n", strerror(errno)); return -1; } // 2. 初始化服务器地址和端口 struct sockaddr_in server_addr; bzero(&server_addr, sizeof(struct sockaddr_in)); server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); server_addr.sin_port = htons(8888); // 3. 连接服务器 if(connect(connectfd, (struct sockaddr *)(&server_addr), sizeof(server_addr)) < 0) { fprintf(stderr,"connect error:%s\n", strerror(errno)); return -1; } printf("connect success\n"); char sendline[64] = "hello, i am xiaolin"; //4. 发送数据 int ret = send(connectfd, sendline, strlen(sendline), 0); if(ret != strlen(sendline)) { fprintf(stderr,"send data error:%s\n", strerror(errno)); return -1; } printf("already send %d bytes\n", ret); sleep(1); //5. 关闭连接 close(connectfd); return 0; } ``` 编译服务端和客户端的代码: ![在这里插入图片描述](https://img-blog.csdnimg.cn/291c6bdf93fa4e04b1606eef57d76836.png) 先启用服务端: ![在这里插入图片描述](https://img-blog.csdnimg.cn/a975aa542caf41b2a1f303563df697b7.png) 然后用 tcpdump 工具开始抓包,命令如下: ```bash tcpdump -i lo tcp and port 8888 -s0 -w /home/tcp_close.pcap ``` 然后启用客户端,可以看到,与服务端连接成功后,发完数据就退出了。 ![在这里插入图片描述](https://img-blog.csdnimg.cn/8ea9f527a68a4c0184edc8842aaf55d6.png) 此时,服务端的输出: ![在这里插入图片描述](https://img-blog.csdnimg.cn/ff5f9ae91a3a4576b59e6e9c4716464d.png) 接下来,我们来看看抓包的结果。 ![在这里插入图片描述](https://img-blog.csdnimg.cn/b542a2777aca4419b47205484b52cc03.png) 可以看到,TCP 挥手次数是 3 次。 所以,下面这个结论是没问题的。 > 结论:当被动关闭方(上图的服务端)在 TCP 挥手过程中,「**没有数据要发送」并且「开启了 TCP 延迟确认机制(默认会开启)」,那么第二和第三次挥手就会合并传输,这样就出现了三次挥手。** #### 实验二 我们再做一次实验,来看看**关闭 TCP 延迟确认机制,会出现四次挥手吗?** 客户端代码保持不变,服务端代码需要增加一点东西。 在上面服务端代码中,增加了打开了 TCP_QUICKACK(快速应答)机制的代码,如下: ![在这里插入图片描述](https://img-blog.csdnimg.cn/fbbe19e6b1cc4a21b024588950b88eee.png) 编译好服务端代码后,就开始运行服务端和客户端的代码,同时用 tcpdump 进行抓包。 抓包的结果如下,可以看到是四次挥手。 ![在这里插入图片描述](https://img-blog.csdnimg.cn/b6327b1057d64f54997c0eb322b28a55.png) 所以,当被动关闭方(上图的服务端)在 TCP 挥手过程中,「**没有数据要发送」,同时「关闭了 TCP 延迟确认机制」,那么就会是四次挥手。** > 设置 TCP_QUICKACK 的代码,为什么要放在 read 返回 0 之后? 我也是多次实验才发现,在 bind 之前设置 TCP_QUICKACK 是不生效的,只有在 read 返回 0 的时候,设置 TCP_QUICKACK 才会出现四次挥手。 网上查了下资料说,设置 TCP_QUICKACK 并不是永久的,所以每次读取数据的时候,如果想要立刻回 ACK,那就得在每次读取数据之后,重新设置 TCP_QUICKACK。 而我这里的实验,目的是为了当收到客户端的 FIN 报文(第一次挥手)后,立马回 ACK 报文。所以就在 read 返回 0 的时候,设置 TCP_QUICKACK。当然,实际应用中,没人会在这个位置设置 TCP_QUICKACK,因为操作系统都通过 TCP 延迟确认机制帮我们把四次挥手优化成了三次挥手了。 ## 总结 当被动关闭方在 TCP 挥手过程中,如果「没有数据要发送」,同时「没有开启 TCP_QUICKACK(默认情况就是没有开启,没有开启 TCP_QUICKACK,等于就是在使用 TCP 延迟确认机制)」,那么第二和第三次挥手就会合并传输,这样就出现了三次挥手。 **所以,出现三次挥手现象,是因为 TCP 延迟确认机制导致的。** ---- ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」*** ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/3_tcp/tcp_tls.md ================================================ # 4.14 HTTPS 中 TLS 和 TCP 能同时握手吗? 大家好,我是小林。 有位读者在面试的时候,碰到这么个问题: ![图片](https://img-blog.csdnimg.cn/img_convert/4d07f1ab714bb4b3efefbf5655b2f81e.png) 面试官跟他说 **HTTPS 中的 TLS 握手过程可以同时进行三次握手**,然后读者之前看我的文章是说「*先进行 TCP 三次握手,再进行 TLS 四次握手*」,他跟面试官说了这个,面试官说他不对,他就感到很困惑。 我们先不管面试官说的那句「*HTTPS 中的 TLS 握手过程可以同时进行三次握手*」对不对。 但是面试官说「*HTTPS 建立连接的过程,先进行 TCP 三次握手,再进行 TLS 四次握手*」是错的,**这很明显面试官的水平有问题,这种公司不去也罢!** 如果是我面试遇到这样的面试官,我直接当场给他抓 HTTPS 建立过程的网络包,然后给他看,啪啪啪啪啪的打他脸。 比如,下面这个 TLSv1.2 的 基于 RSA 算法的四次握手过程: ![图片](https://img-blog.csdnimg.cn/img_convert/4e4f0d13effbeaf963992148b022ef3f.png) 难道不是先三次握手,再进行 TLS 四次握手吗?面试官你脸疼吗? 不过 TLS 握手过程的次数还得看版本。 TLSv1.2 握手过程基本都是需要四次,也就是需要经过 2-RTT 才能完成握手,然后才能发送请求,而 TLSv1.3 只需要 1-RTT 就能完成 TLS 握手,如下图。 ![图片](https://img-blog.csdnimg.cn/img_convert/0877fe78380bf34ad3b28768e59fb53a.png) **一般情况下,不管 TLS 握手次数如何,都得先经过 TCP 三次握手后才能进行**,因为 HTTPS 都是基于 TCP 传输协议实现的,得先建立完可靠的 TCP 连接才能做 TLS 握手的事情。 > 那面试官说的这句「HTTPS 中的 TLS 握手过程可以同时进行三次握手」对不对呢? 这个场景是可能发生的,但是需要在特定的条件下才可能发生,**如果没有说任何前提条件,说这句话就是在耍流氓。** 那到底什么条件下,这个场景才能发生呢?需要下面这两个条件同时满足才可以: - **客户端和服务端都开启了 TCP Fast Open 功能,且 TLS 版本是 1.3;** - **客户端和服务端已经完成过一次通信。** 那具体怎么做到的呢?我们先了解些 TCP Fast Open 功能和 TLSv1.3 的特性。 ## TCP Fast Open > 我们先来了解下什么是 TCP Fast Open? 常规的情况下,如果要使用 TCP 传输协议进行通信,则客户端和服务端通信之前,先要经过 TCP 三次握手后,建立完可靠的 TCP 连接后,客户端才能将数据发送给服务端。 其中,TCP 的第一次和第二次握手是不能够携带数据的,而 TCP 的第三次握手是可以携带数据的,因为这时候客户端的 TCP 连接状态已经是 ESTABLISHED,表明客户端这一方已经完成了 TCP 连接建立。 ![图片](https://img-blog.csdnimg.cn/img_convert/35bc3541c237686aa36e0a88f80592d4.png) 就算客户端携带数据的第三次握手在网络中丢失了,客户端在一定时间内没有收到服务端对该数据的应答报文,就会触发超时重传机制,然后客户端重传该携带数据的第三次握手的报文,直到重传次数达到系统的阈值,客户端就会销毁该 TCP 连接。 说完常规的 TCP 连接后,我们再来看看 TCP Fast Open。 TCP Fast Open 是为了绕过 TCP 三次握手发送数据,在 Linux 3.7 内核版本之后,提供了 TCP Fast Open 功能,这个功能可以减少 TCP 连接建立的时延。 要使用 TCP Fast Open 功能,客户端和服务端都要同时支持才会生效。 不过,开启了 TCP Fast Open 功能,**想要绕过 TCP 三次握手发送数据,得建立第二次以后的通信过程。** 在客户端首次建立连接时的过程,如下图: ![图片](https://img-blog.csdnimg.cn/img_convert/7cb0bd3cde30493fec9562cbdb549f83.png) 具体介绍: - 客户端发送 SYN 报文,该报文包含 Fast Open 选项,且该选项的 Cookie 为空,这表明客户端请求 Fast Open Cookie; - 支持 TCP Fast Open 的服务器生成 Cookie,并将其置于 SYN-ACK 报文中的 Fast Open 选项以发回客户端; - 客户端收到 SYN-ACK 后,本地缓存 Fast Open 选项中的 Cookie。 所以,第一次客户端和服务端通信的时候,还是需要正常的三次握手流程。随后,客户端就有了 Cookie 这个东西,它可以用来向服务器 TCP 证明先前与客户端 IP 地址的三向握手已成功完成。 对于客户端与服务端的后续通信,客户端可以在第一次握手的时候携带应用数据,从而达到绕过三次握手发送数据的效果,整个过程如下图: ![图片](https://img-blog.csdnimg.cn/img_convert/fc452688b9351e0cabf60212dde3f21e.png) 我详细介绍下这个过程: - 客户端发送 SYN 报文,该报文可以携带「应用数据」以及此前记录的 Cookie; - 支持 TCP Fast Open 的服务器会对收到 Cookie 进行校验:如果 Cookie 有效,服务器将在 SYN-ACK 报文中对 SYN 和「数据」进行确认,服务器随后将「应用数据」递送给对应的应用程序;如果 Cookie 无效,服务器将丢弃 SYN 报文中包含的「应用数据」,且其随后发出的 SYN-ACK 报文将只确认 SYN 的对应序列号; - **如果服务器接受了 SYN 报文中的「应用数据」,服务器可在握手完成之前发送「响应数据」,这就减少了握手带来的 1 个 RTT 的时间消耗**; - 客户端将发送 ACK 确认服务器发回的 SYN 以及「应用数据」,但如果客户端在初始的 SYN 报文中发送的「应用数据」没有被确认,则客户端将重新发送「应用数据」; - 此后的 TCP 连接的数据传输过程和非 TCP Fast Open 的正常情况一致。 所以,如果客户端和服务端同时支持 TCP Fast Open 功能,那么在完成首次通信过程后,后续客户端与服务端 的通信则可以绕过三次握手发送数据,这就减少了握手带来的 1 个 RTT 的时间消耗。 ## TLSv1.3 > 说完 TCP Fast Open,再来看看 TLSv1.3。 在最开始的时候,我也提到 TLSv1.3 握手过程只需 1-RTT 的时间,它到整个握手过程,如下图: ![图片](https://img-blog.csdnimg.cn/img_convert/1fd5ba4000f82613fdd70cab6da4b9cb.png) TCP 连接的第三次握手是可以携带数据的,如果客户端在第三次握手发送了 TLSv1.3 第一次握手数据,是不是就表示「*HTTPS 中的 TLS 握手过程可以同时进行三次握手*」?。 不是的,因为服务端只有在收到客户端的 TCP 的第三次握手后,才能和客户端进行后续 TLSv1.3 握手。 TLSv1.3 还有个更厉害到地方在于**会话恢复**机制,在**重连 TLvS1.3 只需要 0-RTT**,用“pre_shared_key”和“early_data”扩展,在 TCP 连接后立即就建立安全连接发送加密消息,过程如下图: ![图片](https://img-blog.csdnimg.cn/img_convert/59539201f006d7dc0a06333617e5ea85.png) ## TCP Fast Open + TLSv1.3 在前面我们知道,客户端和服务端同时支持 TCP Fast Open 功能的情况下,**在第二次以后到通信过程中,客户端可以绕过三次握手直接发送数据,而且服务端也不需要等收到第三次握手后才发送数据。** 如果 HTTPS 的 TLS 版本是 1.3,那么 TLS 过程只需要 1-RTT。 **因此如果「TCP Fast Open + TLSv1.3」情况下,在第二次以后的通信过程中,TLS 和 TCP 的握手过程是可以同时进行的。** **如果基于 TCP Fast Open 场景下的 TLSv1.3 0-RTT 会话恢复过程,不仅 TLS 和 TCP 的握手过程是可以同时进行的,而且 HTTP 请求也可以在这期间内一同完成。** ## 总结 最后做个总结。 「HTTPS 是先进行 TCP 三次握手,再进行 TLSv1.2 四次握手」,这句话一点问题都没有,怀疑这句话是错的人,才有问题。 「HTTPS 中的 TLS 握手过程可以同时进行三次握手」,这个场景是可能存在到,但是在没有说任何前提条件,而说这句话就等于耍流氓。需要下面这两个条件同时满足才可以: - **客户端和服务端都开启了 TCP Fast Open 功能,且 TLS 版本是 1.3;** - **客户端和服务端已经完成过一次通信;** 怎么样,那位“面试官”学废了吗? --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/3_tcp/tcp_tw_reuse_close.md ================================================ # 4.14 tcp_tw_reuse 为什么默认是关闭的? 大家好,我是小林。 上周有个读者在面试微信的时候,**被问到既然打开 net.ipv4.tcp_tw_reuse 参数可以快速复用处于 TIME_WAIT 状态的 TCP 连接,那为什么 Linux 默认是关闭状态呢?** ![图片](https://img-blog.csdnimg.cn/img_convert/23f1aea82a0b7c37f1031524600626f1.png) ![图片](https://img-blog.csdnimg.cn/img_convert/076e60b984028bf3ad762eb2bd7ed0f3.png) 好家伙,真的问好细节! 当时看到读者这个问题的时候,我也是一脸懵逼的,经过我的一番思考后,终于知道怎么回答这题了。 其实这题在变相问「**如果 TIME_WAIT 状态持续时间过短或者没有,会有什么问题?**」 因为开启 tcp_tw_reuse 参数可以快速复用处于 TIME_WAIT 状态的 TCP 连接时,相当于缩短了 TIME_WAIT 状态的持续时间。 可能有的同学会问说,使用 tcp_tw_reuse 快速复用处于 TIME_WAIT 状态的 TCP 连接时,是需要保证 net.ipv4.tcp_timestamps 参数是开启的(默认是开启的),而 tcp_timestamps 参数可以避免旧连接的延迟报文,这不是解决了没有 TIME_WAIT 状态时的问题了吗? 是解决部分问题,但是不能完全解决,接下来,我跟大家聊聊这个问题。 ![图片](https://img-blog.csdnimg.cn/img_convert/d17df1a39a750c33948062ecfc9a8d32.png) ## 什么是 TIME_WAIT 状态? TCP 四次挥手过程,如下图: ![图片](https://img-blog.csdnimg.cn/img_convert/e973a17cb5b1092085ca1bbcd7083559.png)图片 - 客户端打算关闭连接,此时会发送一个 TCP 首部 `FIN` 标志位被置为 `1`的报文,也即 `FIN` 报文,之后客户端进入 `FIN_WAIT_1` 状态。 - 服务端收到该报文后,就向客户端发送 `ACK` 应答报文,接着服务端进入 `CLOSED_WAIT` 状态。 - 客户端收到服务端的 `ACK` 应答报文后,之后进入 `FIN_WAIT_2` 状态。 - 等待服务端处理完数据后,也向客户端发送 `FIN` 报文,之后服务端进入 `LAST_ACK` 状态。 - 客户端收到服务端的 `FIN` 报文后,回一个 `ACK` 应答报文,之后进入 `TIME_WAIT` 状态 - 服务器收到了 `ACK` 应答报文后,就进入了 `CLOSE` 状态,至此服务端已经完成连接的关闭。 - 客户端在经过 `2MSL` 一段时间后,自动进入 `CLOSE` 状态,至此客户端也完成连接的关闭。 你可以看到,两个方向都需要**一个 FIN 和一个 ACK**,因此通常被称为**四次挥手**。 这里一点需要注意是:**主动关闭连接的,才有 TIME_WAIT 状态。** 可以看到,TIME_WAIT 是「主动关闭方」断开连接时的最后一个状态,该状态会持续 ***2MSL(Maximum Segment Lifetime)\*** 时长,之后进入 CLOSED 状态。 MSL 指的是 TCP 协议中任何报文在网络上最大的生存时间,任何超过这个时间的数据都将被丢弃。虽然 RFC 793 规定 MSL 为 2 分钟,但是在实际实现的时候会有所不同,比如 Linux 默认为 30 秒,那么 2MSL 就是 60 秒。 MSL 是由网络层的 IP 包中的 TTL 来保证的,TTL 是 IP 头部的一个字段,用于设置一个数据报可经过的路由器的数量上限。报文每经过一次路由器的转发,IP 头部的 TTL 字段就会减 1,减到 0 时报文就被丢弃。 MSL 与 TTL 的区别:MSL 的单位是时间,而 TTL 是经过路由跳数。所以 **MSL 应该要大于等于 TTL 消耗为 0 的时间**,以确保报文已被自然消亡。 **TTL 的值一般是 64,Linux 将 MSL 设置为 30 秒,意味着 Linux 认为数据报文经过 64 个路由器的时间不会超过 30 秒,如果超过了,就认为报文已经消失在网络中了**。 ## 为什么要设计 TIME_WAIT 状态? 设计 TIME_WAIT 状态,主要有两个原因: - 防止历史连接中的数据,被后面相同四元组的连接错误的接收; - 保证「被动关闭连接」的一方,能被正确的关闭; #### 原因一:防止历史连接中的数据,被后面相同四元组的连接错误的接收 为了能更好的理解这个原因,我们先来了解序列号(SEQ)和初始序列号(ISN)。 - **序列号**,是 TCP 一个头部字段,标识了 TCP 发送端到 TCP 接收端的数据流的一个字节,因为 TCP 是面向字节流的可靠协议,为了保证消息的顺序性和可靠性,TCP 为每个传输方向上的每个字节都赋予了一个编号,以便于传输成功后确认、丢失后重传以及在接收端保证不会乱序。**序列号是一个 32 位的无符号数,因此在到达 4G 之后再循环回到 0**。 - **初始序列号**,在 TCP 建立连接的时候,客户端和服务端都会各自生成一个初始序列号,它是基于时钟生成的一个随机数,来保证每个连接都拥有不同的初始序列号。**初始化序列号可被视为一个 32 位的计数器,该计数器的数值每 4 微秒加 1,循环一次需要 4.55 小时**。 给大家抓了一个包,下图中的 Seq 就是序列号,其中红色框住的分别是客户端和服务端各自生成的初始序列号。 ![图片](https://img-blog.csdnimg.cn/img_convert/b70ee2f17636deeb3930010b6dcdabb7.png) 通过前面我们知道,**序列号和初始化序列号并不是无限递增的,会发生回绕为初始值的情况,这意味着无法根据序列号来判断新老数据**。 假设 TIME-WAIT 没有等待时间或时间过短,被延迟的数据包抵达后会发生什么呢? ![图片](https://img-blog.csdnimg.cn/img_convert/f1ba45cdb7d772ccd12dc604dee26c91.png) - 服务端在关闭连接之前发送的 `SEQ = 301` 报文,被网络延迟了。 - 接着,服务端以相同的四元组重新打开了新连接,前面被延迟的 `SEQ = 301` 这时抵达了客户端,而且该数据报文的序列号刚好在客户端接收窗口内,因此客户端会正常接收这个数据报文,但是这个数据报文是上一个连接残留下来的,这样就产生数据错乱等严重的问题。 为了防止历史连接中的数据,被后面相同四元组的连接错误的接收,因此 TCP 设计了 TIME_WAIT 状态,状态会持续 `2MSL` 时长,这个时间**足以让两个方向上的数据包都被丢弃,使得原来连接的数据包在网络中都自然消失,再出现的数据包一定都是新建立连接所产生的。** #### 原因二:保证「被动关闭连接」的一方,能被正确的关闭 如果客户端(主动关闭方)最后一次 ACK 报文(第四次挥手)在网络中丢失了,那么按照 TCP 可靠性原则,服务端(被动关闭方)会重发 FIN 报文。 假设客户端没有 TIME_WAIT 状态,而是在发完最后一次回 ACK 报文就直接进入 CLOSED 状态,如果该 ACK 报文丢失了,服务端则重传的 FIN 报文,而这时客户端已经进入到关闭状态了,在收到服务端重传的 FIN 报文后,就会回 RST 报文。 ![图片](https://img-blog.csdnimg.cn/img_convert/8016c9f9b875649a5ab8bdd245c34729.png) 服务端收到这个 RST 并将其解释为一个错误(Connection reset by peer),这对于一个可靠的协议来说不是一个优雅的终止方式。 为了防止这种情况出现,客户端必须等待足够长的时间,确保服务端能够收到 ACK,如果服务端没有收到 ACK,那么就会触发 TCP 重传机制,服务端会重新发送一个 FIN,这样一去一来刚好两个 MSL 的时间。 ![TIME-WAIT 时间正常,确保了连接正常关闭](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4/网络/TIME-WAIT连接正常关闭.drawio.png) 客户端在收到服务端重传的 FIN 报文时,TIME_WAIT 状态的等待时间,会重置回 2MSL。 ## tcp_tw_reuse 是什么? 在 Linux 操作系统下,TIME_WAIT 状态的持续时间是 60 秒,这意味着这 60 秒内,客户端一直会占用着这个端口。要知道,端口资源也是有限的,一般可以开启的端口为 32768~61000,也可以通过如下参数设置指定范围: ```plain net.ipv4.ip_local_port_range ``` **如果客户端(主动关闭连接方)的 TIME_WAIT 状态过多**,占满了所有端口资源,那么就无法对「目的 IP+ 目的 PORT」都一样的服务器发起连接了,但是被使用的端口,还是可以继续对另外一个服务器发起连接的。具体可以看我这篇文章:[客户端的端口可以重复使用吗?](https://xiaolincoding.com/network/3_tcp/port.html#%E5%AE%A2%E6%88%B7%E7%AB%AF%E7%9A%84%E7%AB%AF%E5%8F%A3%E5%8F%AF%E4%BB%A5%E9%87%8D%E5%A4%8D%E4%BD%BF%E7%94%A8%E5%90%97) 因此,客户端(主动关闭连接方)都是和「目的 IP+ 目的 PORT」都一样的服务器建立连接的话,当客户端的 TIME_WAIT 状态连接过多的话,就会受端口资源限制,如果占满了所有端口资源,那么就无法再跟「目的 IP+ 目的 PORT」都一样的服务器建立连接了。 不过,即使是在这种场景下,只要连接的是不同的服务器,端口是可以重复使用的,所以客户端还是可以向其他服务器发起连接的,这是因为内核在定位一个连接的时候,是通过四元组(源 IP、源端口、目的 IP、目的端口)信息来定位的,并不会因为客户端的端口一样,而导致连接冲突。 好在,Linux 操作系统提供了两个可以系统参数来快速回收处于 TIME_WAIT 状态的连接,这两个参数都是默认关闭的: - net.ipv4.tcp_tw_reuse,如果开启该选项的话,客户端(连接发起方)在调用 connect() 函数时,**如果内核选择到的端口,已经被相同四元组的连接占用的时候,就会判断该连接是否处于 TIME_WAIT 状态,如果该连接处于 TIME_WAIT 状态并且 TIME_WAIT 状态持续的时间超过了 1 秒,那么就会重用这个连接,然后就可以正常使用该端口了**。所以该选项只适用于连接发起方。 - net.ipv4.tcp_tw_recycle,如果开启该选项的话,允许处于 TIME_WAIT 状态的连接被快速回收,该参数在 **NAT 的网络下是不安全的**!详细见这篇文章介绍:[SYN 报文什么时候情况下会被丢弃?](https://xiaolincoding.com/network/3_tcp/syn_drop.html) 要使得上面这两个参数生效,有一个前提条件,就是要打开 TCP 时间戳,即 net.ipv4.tcp_timestamps=1(默认即为 1)。 开启了 tcp_timestamps 参数,TCP 头部就会使用时间戳选项,它有两个好处,**一个是便于精确计算 RTT,另一个是能防止序列号回绕(PAWS)**,我们先来介绍这个功能。 序列号是一个 32 位的无符号整型,上限值是 4GB,超过 4GB 后就需要将序列号回绕进行重用。这在以前网速慢的年代不会造成什么问题,但在一个速度足够快的网络中传输大量数据时,序列号的回绕时间就会变短。如果序列号回绕的时间极短,我们就会再次面临之前延迟的报文抵达后序列号依然有效的问题。 为了解决这个问题,就需要有 TCP 时间戳。 试看下面的示例,假设 TCP 的发送窗口是 1 GB,并且使用了时间戳选项,发送方会为每个 TCP 报文分配时间戳数值,我们假设每个报文时间加 1,然后使用这个连接传输一个 6GB 大小的数据流。 ![图片](https://img-blog.csdnimg.cn/img_convert/bf004909d9e44c3bc740737ced6731a0.png) 32 位的序列号在时刻 D 和 E 之间回绕。假设在时刻 B 有一个报文丢失并被重传,又假设这个报文段在网络上绕了远路并在时刻 F 重新出现。如果 TCP 无法识别这个绕回的报文,那么数据完整性就会遭到破坏。 使用时间戳选项能够有效的防止上述问题,如果丢失的报文会在时刻 F 重新出现,由于它的时间戳为 2,小于最近的有效时间戳(5 或 6),因此防回绕序列号算法(PAWS)会将其丢弃。 防回绕序列号算法要求连接双方维护最近一次收到的数据包的时间戳(Recent TSval),每收到一个新数据包都会读取数据包中的时间戳值跟 Recent TSval 值做比较,**如果发现收到的数据包中时间戳不是递增的,则表示该数据包是过期的,就会直接丢弃这个数据包**。 ## 为什么 tcp_tw_reuse 默认是关闭的? 通过前面这么多铺垫,终于可以说这个问题了。 开启 tcp_tw_reuse 会有什么风险呢?我觉得会有 2 个问题。 ### 第一个问题 我们知道开启 tcp_tw_reuse 的同时,也需要开启 tcp_timestamps,意味着可以用时间戳的方式有效的判断回绕序列号的历史报文。 但是,在看我看了防回绕序列号函数的源码后,发现对于 **RST 报文的时间戳即使过期了,只要 RST 报文的序列号在对方的接收窗口内,也是能被接受的**。 下面 tcp_validate_incoming 函数就是验证接收到的 TCP 报文是否合格的函数,其中第一步就会进行 PAWS 检查,由 tcp_paws_discard 函数负责。 ```c static bool tcp_validate_incoming(struct sock *sk, struct sk_buff *skb, const struct tcphdr *th, int syn_inerr) { struct tcp_sock *tp = tcp_sk(sk); /* RFC1323: H1. Apply PAWS check first. */ if (tcp_fast_parse_options(sock_net(sk), skb, th, tp) && tp->rx_opt.saw_tstamp && tcp_paws_discard(sk, skb)) { if (!th->rst) { .... goto discard; } /* Reset is accepted even if it did not pass PAWS. */ } ``` 当 tcp_paws_discard 返回 true,就代表报文是一个历史报文,于是就要丢弃这个报文。但是在丢掉这个报文的时候,会先判断是不是 RST 报文,如果不是 RST 报文,才会将报文丢掉。也就是说,即使 RST 报文是一个历史报文,并不会被丢弃。 假设有这样的场景,如下图: ![](https://img-blog.csdnimg.cn/img_convert/0df2003d41ec0ef23844975a85cfb722.png) 过程如下: - 客户端向一个还没有被服务端监听的端口发起了 HTTP 请求,接着服务端就会回 RST 报文给对方,很可惜的是 **RST 报文被网络阻塞了**。 - 由于客户端迟迟没有收到 TCP 第二次握手,于是重发了 SYN 包,与此同时服务端已经开启了服务,监听了对应的端口。于是接下来,客户端和服务端就进行了 TCP 三次握手、数据传输(HTTP 应答 - 响应)、四次挥手。 - 因为**客户端开启了 tcp_tw_reuse,于是快速复用 TIME_WAIT 状态的端口,又与服务端建立了一个与刚才相同的四元组的连接**。 - 接着,**前面被网络延迟 RST 报文这时抵达了客户端,而且 RST 报文的序列号在客户端的接收窗口内,由于防回绕序列号算法不会防止过期的 RST,所以 RST 报文会被客户端接受了,于是客户端的连接就断开了**。 上面这个场景就是开启 tcp_tw_reuse 风险,**因为快速复用 TIME_WAIT 状态的端口,导致新连接可能被回绕序列号的 RST 报文断开了,而如果不跳过 TIME_WAIT 状态,而是停留 2MSL 时长,那么这个 RST 报文就不会出现下一个新的连接**。 可能大家会有这样的疑问,为什么 PAWS 检查要放过过期的 RST 报文。我翻了 RFC 1323,里面有一句提到: *It is recommended that RST segments NOT carry timestamps, and that RST segments be acceptable regardless of their timestamp. Old duplicate RST segments should be exceedingly unlikely, and their cleanup function should take precedence over timestamps.* 大概的意思:*建议 RST 段不携带时间戳,并且无论其时间戳如何,RST 段都是可接受的。老的重复的 RST 段应该是极不可能的,并且它们的清除功能应优先于时间戳。* RFC 1323 提到说收历史的 RST 报文是极不可能,之所以有这样的想法是因为 TIME_WAIT 状态持续的 2MSL 时间,足以让连接中的报文在网络中自然消失,所以认为按正常操作来说是不会发生的,因此认为清除连接优先于时间戳。 而我前面提到的案例,是因为开启了 tcp_tw_reuse 状态,跳过了 TIME_WAIT 状态,才发生的事情。 有同学会说,都经过一个 HTTP 请求了,延迟的 RST 报文竟然还会存活? 一个 HTTP 请求其实很快的,比如我下面这个抓包,只需要 0.2 秒就完成了,远小于 MSL,所以延迟的 RST 报文存活是有可能的。 ![图片](https://img-blog.csdnimg.cn/img_convert/2ac40ca1757888b7154a2baa8bbb9885.png) ### 第二个问题 开启 tcp_tw_reuse 来快速复用 TIME_WAIT 状态的连接,如果第四次挥手的 ACK 报文丢失了,服务端会触发超时重传,重传第三次挥手报文,处于 syn_sent 状态的客户端收到服务端重传第三次挥手报文,则会回 RST 给服务端。如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/tcp/tcp_tw_reuse第二个问题.drawio.png) 这时候有同学就问了,如果 TIME_WAIT 状态被快速复用后,刚好第四次挥手的 ACK 报文丢失了,那客户端复用 TIME_WAIT 状态后发送的 SYN 报文被处于 last_ack 状态的服务端收到了会发生什么呢? 处于 last_ack 状态的服务端收到了 SYN 报文后,会回复确认号与服务端上一次发送 ACK 报文一样的 ACK 报文,这个 ACK 报文称为 [Challenge ACK](https://xiaolincoding.com/network/3_tcp/challenge_ack.html),并不是确认收到 SYN 报文。 处于 syn_sent 状态的客户端收到服务端的 [Challenge ACK](https://xiaolincoding.com/network/3_tcp/challenge_ack.html) 后,发现不是自己期望收到的确认号,于是就会回复 RST 报文,服务端收到后,就会断开连接。 ## 总结 tcp_tw_reuse 的作用是让客户端快速复用处于 TIME_WAIT 状态的端口,相当于跳过了 TIME_WAIT 状态,这可能会出现这样的两个问题: - 历史 RST 报文可能会终止后面相同四元组的连接,因为 PAWS 检查到即使 RST 是过期的,也不会丢弃。 - 如果第四次挥手的 ACK 报文丢失了,有可能被动关闭连接的一方不能被正常的关闭; 虽然 TIME_WAIT 状态持续的时间是有一点长,显得很不友好,但是它被设计来就是用来避免发生乱七八糟的事情。 《UNIX 网络编程》一书中却说道:**TIME_WAIT 是我们的朋友,它是有助于我们的,不要试图避免这个状态,而是应该弄清楚它**。 --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/3_tcp/tcp_unplug_the_network_cable.md ================================================ # 4.13 拔掉网线后,原本的 TCP 连接还存在吗? 大家好,我是小林。 今天,聊一个有趣的问题:**拔掉网线几秒,再插回去,原本的 TCP 连接还存在吗?** 可能有的同学会说,网线都被拔掉了,那说明物理层被断开了,那在上层的传输层理应也会断开,所以原本的 TCP 连接就不会存在的了。就好像,我们拨打有线电话的时候,如果某一方的电话线被拔了,那么本次通话就彻底断了。 真的是这样吗? 上面这个逻辑就有问题。问题在于,错误的认为拔掉网线这个动作会影响传输层,事实上并不会影响。 实际上,TCP 连接在 Linux 内核中是一个名为 `struct socket` 的结构体,该结构体的内容包含 TCP 连接的状态等信息。当拔掉网线的时候,操作系统并不会变更该结构体的任何内容,所以 TCP 连接的状态也不会发生改变。 我在我的电脑上做了个小实验,我用 ssh 终端连接了我的云服务器,然后我通过断开 wifi 的方式来模拟拔掉网线的场景,此时查看 TCP 连接的状态没有发生变化,还是处于 ESTABLISHED 状态。 ![图片](https://img-blog.csdnimg.cn/img_convert/fff358407ee92aeea1e17386191a5d18.png) 通过上面这个实验结果,我们知道了,拔掉网线这个动作并不会影响 TCP 连接的状态。 接下来,要看拔掉网线后,双方做了什么动作。 所以,针对这个问题,要分场景来讨论: - 拔掉网线后,有数据传输; - 拔掉网线后,没有数据传输; ## 拔掉网线后,有数据传输 在客户端拔掉网线后,服务端向客户端发送的数据报文会得不到任何的响应,在等待一定时长后,服务端就会触发**超时重传**机制,重传未得到响应的数据报文。 **如果在服务端重传报文的过程中,客户端刚好把网线插回去了**,由于拔掉网线并不会改变客户端的 TCP 连接状态,并且还是处于 ESTABLISHED 状态,所以这时客户端是可以正常接收服务端发来的数据报文的,然后客户端就会回 ACK 响应报文。 此时,客户端和服务端的 TCP 连接依然存在的,就感觉什么事情都没有发生。 但是,**如果在服务端重传报文的过程中,客户端一直没有将网线插回去**,服务端超时重传报文的次数达到一定阈值后,内核就会判定出该 TCP 有问题,然后通过 Socket 接口告诉应用程序该 TCP 连接出问题了,于是服务端的 TCP 连接就会断开。 而等客户端插回网线后,如果客户端向服务端发送了数据,由于服务端已经没有与客户端相同四元祖的 TCP 连接了,因此服务端内核就会回复 RST 报文,客户端收到后就会释放该 TCP 连接。 此时,客户端和服务端的 TCP 连接都已经断开了。 > 那 TCP 的数据报文具体重传几次呢? 在 Linux 系统中,提供了一个叫 tcp_retries2 配置项,默认值是 15,如下图: ![图片](https://img-blog.csdnimg.cn/img_convert/f92c00c7e9cd01e89326e943232e5f04.png) 这个内核参数是控制,在 TCP 连接建立的情况下,超时重传的最大次数。 不过 tcp_retries2 设置了 15 次,并不代表 TCP 超时重传了 15 次才会通知应用程序终止该 TCP 连接,**内核会根据 tcp_retries2 设置的值,计算出一个 timeout**(*如果 tcp_retries2 =15,那么计算得到的 timeout = 924600 ms*),**如果重传间隔超过这个 timeout,则认为超过了阈值,就会停止重传,然后就会断开 TCP 连接**。 在发生超时重传的过程中,每一轮的超时时间(RTO)都是**倍数增长**的,比如如果第一轮 RTO 是 200 毫秒,那么第二轮 RTO 是 400 毫秒,第三轮 RTO 是 800 毫秒,以此类推。 而 RTO 是基于 RTT(一个包的往返时间)来计算的,如果 RTT 较大,那么计算出来的 RTO 就越大,那么经过几轮重传后,很快就达到了上面的 timeout 值了。 举个例子,如果 tcp_retries2 =15,那么计算得到的 timeout = 924600 ms,如果重传总间隔时长达到了 timeout 就会停止重传,然后就会断开 TCP 连接: - 如果 RTT 比较小,那么 RTO 初始值就约等于下限 200ms,也就是第一轮的超时时间是 200 毫秒,由于 timeout 总时长是 924600 ms,表现出来的现象刚好就是重传了 15 次,超过了 timeout 值,从而断开 TCP 连接 - 如果 RTT 比较大,假设 RTO 初始值计算得到的是 1000 ms,也就是第一轮的超时时间是 1 秒,那么根本不需要重传 15 次,重传总间隔就会超过 924600 ms。 最小 RTO 和最大 RTO 是在 Linux 内核中定义好了: ```c #define TCP_RTO_MAX ((unsigned)(120*HZ)) #define TCP_RTO_MIN ((unsigned)(HZ/5)) ``` Linux 2.6+ 使用 1000 毫秒的 HZ,因此`TCP_RTO_MIN`约为 200 毫秒,`TCP_RTO_MAX`约为 120 秒。 如果`tcp_retries`设置为`15`,且 RTT 比较小,那么 RTO 初始值就约等于下限 200ms,这意味着**它需要 924.6 秒**才能将断开的 TCP 连接通知给上层(即应用程序),每一轮的 RTO 增长关系如下表格: ![](https://img-blog.csdnimg.cn/img_convert/10fa6882db83aee68f246c04fcb7d760.png) ## 拔掉网线后,没有数据传输 针对拔掉网线后,没有数据传输的场景,还得看是否开启了 TCP keepalive 机制(TCP 保活机制)。 如果**没有开启** TCP keepalive 机制,在客户端拔掉网线后,并且双方都没有进行数据传输,那么客户端和服务端的 TCP 连接将会一直保持存在。 而如果**开启**了 TCP keepalive 机制,在客户端拔掉网线后,即使双方都没有进行数据传输,在持续一段时间后,TCP 就会发送探测报文: - 如果**对端是正常工作**的。当 TCP 保活的探测报文发送给对端,对端会正常响应,这样 **TCP 保活时间会被重置**,等待下一个 TCP 保活时间的到来。 - 如果**对端主机宕机**(*注意不是进程崩溃,进程崩溃后操作系统在回收进程资源的时候,会发送 FIN 报文,而主机宕机则是无法感知的,所以需要 TCP 保活机制来探测对方是不是发生了主机宕机*),或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,**TCP 会报告该 TCP 连接已经死亡**。 所以,TCP 保活机制可以在双方没有数据交互的情况,通过探测报文,来确定对方的 TCP 连接是否存活。 > TCP keepalive 机制具体是怎么样的? 这个机制的原理是这样的: 定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。 在 Linux 内核可以有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值: ```plain net.ipv4.tcp_keepalive_time=7200 net.ipv4.tcp_keepalive_intvl=75 net.ipv4.tcp_keepalive_probes=9 ``` - tcp_keepalive_time=7200:表示保活时间是 7200 秒(2 小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制 - tcp_keepalive_intvl=75:表示每次检测间隔 75 秒; - tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。 也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接。 ![](https://img-blog.csdnimg.cn/img_convert/46906e588260607680db43a68fe00278.png) 注意,应用程序若想使用 TCP 保活机制需要通过 socket 接口设置 `SO_KEEPALIVE` 选项才能够生效,如果没有设置,那么就无法使用 TCP 保活机制。 > TCP keepalive 机制探测的时间也太长了吧? 对的,是有点长。 TCP keepalive 是 **TCP 层(内核态)** 实现的,它是给所有基于 TCP 传输协议的程序一个兜底的方案。 实际上,我们应用层可以自己实现一套探测机制,可以在较短的时间内,探测到对方是否存活。 比如,web 服务软件一般都会提供 `keepalive_timeout` 参数,用来指定 HTTP 长连接的超时时间。如果设置了 HTTP 长连接的超时时间是 60 秒,web 服务软件就会**启动一个定时器**,如果客户端在完后一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,**定时器的时间一到,就会触发回调函数来释放该连接。** ![图片](https://img-blog.csdnimg.cn/img_convert/c881f163091a4c6427d68b7144c3a980.png) ## 总结 客户端拔掉网线后,并不会直接影响 TCP 连接状态。所以,拔掉网线后,TCP 连接是否还会存在,关键要看拔掉网线之后,有没有进行数据传输。 有数据传输的情况: - 在客户端拔掉网线后,如果服务端发送了数据报文,那么在服务端重传次数没有达到最大值之前,客户端就插回了网线,那么双方原本的 TCP 连接还是能正常存在,就好像什么事情都没有发生。 - 在客户端拔掉网线后,如果服务端发送了数据报文,在客户端插回网线之前,服务端重传次数达到了最大值时,服务端就会断开 TCP 连接。等到客户端插回网线后,向服务端发送了数据,因为服务端已经断开了与客户端相同四元组的 TCP 连接,所以就会回 RST 报文,客户端收到后就会断开 TCP 连接。至此,双方的 TCP 连接都断开了。 没有数据传输的情况: - 如果双方都没有开启 TCP keepalive 机制,那么在客户端拔掉网线后,如果客户端一直不插回网线,那么客户端和服务端的 TCP 连接状态将会一直保持存在。 - 如果双方都开启了 TCP keepalive 机制,那么在客户端拔掉网线后,如果客户端一直不插回网线,TCP keepalive 机制会探测到对方的 TCP 连接没有存活,于是就会断开 TCP 连接。而如果在 TCP 探测期间,客户端插回了网线,那么双方原本的 TCP 连接还是能正常存在。 除了客户端拔掉网线的场景,还有客户端「[主机宕机和进程崩溃](https://xiaolincoding.com/network/3_tcp/tcp_down_and_crash.html)」的两种场景。 第一个场景,客户端宕机这件事跟拔掉网线是一样无法被服务端的感知的,所以如果在没有数据传输,并且没有开启 TCP keepalive 机制时,,**服务端的 TCP 连接将会一直处于 ESTABLISHED 连接状态**,直到服务端重启进程。 所以,我们可以得知一个点。在没有使用 TCP 保活机制,且双方不传输数据的情况下,一方的 TCP 连接处在 ESTABLISHED 状态时,并不代表另一方的 TCP 连接还一定是正常的。 第二个场景,客户端的进程崩溃后,客户端的内核就会向服务端发送 FIN 报文,**与服务端进行四次挥手**。 所以,即使没有开启 TCP keepalive,且双方也没有数据交互的情况下,如果其中一方的进程发生了崩溃,这个过程操作系统是可以感知的到的,于是就会发送 FIN 报文给对方,然后与对方进行 TCP 四次挥手。 完! --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/3_tcp/time_wait_recv_syn.md ================================================ # 4.11 在 TIME_WAIT 状态的 TCP 连接,收到 SYN 后会发生什么? 大家好,我是小林。 周末跟朋友讨论了一些 TCP 的问题,在查阅《Linux 服务器高性能编程》这本书的时候,发现书上写了这么一句话: ![图片](https://img-blog.csdnimg.cn/img_convert/65739ee668999bda02aa9236aad6437f.png) 书上说,处于 TIME_WAIT 状态的连接,在收到相同四元组的 SYN 后,会回 RST 报文,对方收到后就会断开连接。 书中作者只是提了这么一句话,没有给予源码或者抓包图的证据。 起初,我看到也觉得这个逻辑也挺符合常理的,但是当我自己去啃了 TCP 源码后,发现并不是这样的。 所以,今天就来讨论下这个问题,「**在 TCP 正常挥手过程中,处于 TIME_WAIT 状态的连接,收到相同四元组的 SYN 后会发生什么?**」 问题现象如下图,左边是服务端,右边是客户端: ![图片](https://img-blog.csdnimg.cn/img_convert/74b53919396dcda634cfd5b5795cbf16.png) ## 先说结论 在跟大家分析 TCP 源码前,我先跟大家直接说下结论。 针对这个问题,**关键是要看 SYN 的「序列号和时间戳」是否合法**,因为处于 TIME_WAIT 状态的连接收到 SYN 后,会判断 SYN 的「序列号和时间戳」是否合法,然后根据判断结果的不同做不同的处理。 先跟大家说明下,什么是「合法」的 SYN? - **合法 SYN**:客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要**大**,**并且** SYN 的「时间戳」比服务端「最后收到的报文的时间戳」要**大**。 - **非法 SYN**:客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要**小**,**或者** SYN 的「时间戳」比服务端「最后收到的报文的时间戳」要**小**。 上面 SYN 合法判断是基于双方都开启了 TCP 时间戳机制的场景,如果双方都没有开启 TCP 时间戳机制,则 SYN 合法判断如下: - **合法 SYN**:客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要**大**。 - **非法 SYN**:客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要**小**。 ### 收到合法 SYN 如果处于 TIME_WAIT 状态的连接收到「合法的 SYN」后,**就会重用此四元组连接,跳过 2MSL 而转变为 SYN_RECV 状态,接着就能进行建立连接过程**。 用下图作为例子,双方都启用了 TCP 时间戳机制,TSval 是发送报文时的时间戳: ![图片](https://img-blog.csdnimg.cn/img_convert/39d0d04adf72fe3d37623acff9ae2507.png) 上图中,在收到第三次挥手的 FIN 报文时,会记录该报文的 TSval(21),用 ts_recent 变量保存。然后会计算下一次期望收到的序列号,本次例子下一次期望收到的序列号就是 301,用 rcv_nxt 变量保存。 处于 TIME_WAIT 状态的连接收到 SYN 后,**因为 SYN 的 seq(400)大于 rcv_nxt(301),并且 SYN 的 TSval(30)大于 ts_recent(21),所以是一个「合法的 SYN」,于是就会重用此四元组连接,跳过 2MSL 而转变为 SYN_RECV 状态,接着就能进行建立连接过程。** ### 收到非法的 SYN 如果处于 TIME_WAIT 状态的连接收到「非法的 SYN」后,就会**再回复一个第四次挥手的 ACK 报文,客户端收到后,发现并不是自己期望收到确认号(ack num),就回 RST 报文给服务端**。 用下图作为例子,双方都启用了 TCP 时间戳机制,TSval 是发送报文时的时间戳: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/network/tcp/tw收到不合法.png) 上图中,在收到第三次挥手的 FIN 报文时,会记录该报文的 TSval(21),用 ts_recent 变量保存。然后会计算下一次期望收到的序列号,本次例子下一次期望收到的序列号就是 301,用 rcv_nxt 变量保存。 处于 TIME_WAIT 状态的连接收到 SYN 后,**因为 SYN 的 seq(200)小于 rcv_nxt(301),所以是一个「非法的 SYN」,就会再回复一个与第四次挥手一样的 ACK 报文,客户端收到后,发现并不是自己期望收到确认号,就回 RST 报文给服务端**。 > PS:这里先埋一个疑问,处于 TIME_WAIT 状态的连接,收到 RST 会断开连接吗? ## 源码分析 下面源码分析是基于 Linux 4.2 版本的内核代码。 Linux 内核在收到 TCP 报文后,会执行 `tcp_v4_rcv` 函数,在该函数和 TIME_WAIT 状态相关的主要代码如下: ```c int tcp_v4_rcv(struct sk_buff *skb) { struct sock *sk; ... //收到报文后,会调用此函数,查找对应的 sock sk = __inet_lookup_skb(&tcp_hashinfo, skb, __tcp_hdrlen(th), th->source, th->dest, sdif, &refcounted); if (!sk) goto no_tcp_socket; process: //如果连接的状态为 time_wait,会跳转到 do_time_wait if (sk->sk_state == TCP_TIME_WAIT) goto do_time_wait; ... do_time_wait: ... //由 tcp_timewait_state_process 函数处理在 time_wait 状态收到的报文 switch (tcp_timewait_state_process(inet_twsk(sk), skb, th)) { // 如果是 TCP_TW_SYN,那么允许此 SYN 重建连接 // 即允许 TIM_WAIT 状态跃迁到 SYN_RECV case TCP_TW_SYN: { struct sock *sk2 = inet_lookup_listener(....); if (sk2) { .... goto process; } } // 如果是 TCP_TW_ACK,那么,返回记忆中的 ACK case TCP_TW_ACK: tcp_v4_timewait_ack(sk, skb); break; // 如果是 TCP_TW_RST 直接发送 RESET 包 case TCP_TW_RST: tcp_v4_send_reset(sk, skb); inet_twsk_deschedule_put(inet_twsk(sk)); goto discard_it; // 如果是 TCP_TW_SUCCESS 则直接丢弃此包,不做任何响应 case TCP_TW_SUCCESS:; } goto discard_it; } ``` 该代码的过程: 1. 接收到报文后,会调用 `__inet_lookup_skb()` 函数查找对应的 sock 结构; 2. 如果连接的状态是 `TIME_WAIT`,会跳转到 do_time_wait 处理; 3. 由 `tcp_timewait_state_process()` 函数来处理收到的报文,处理后根据返回值来做相应的处理。 先跟大家说下,如果收到的 SYN 是合法的,`tcp_timewait_state_process()` 函数就会返回 `TCP_TW_SYN`,然后重用此连接。如果收到的 SYN 是非法的,`tcp_timewait_state_process()` 函数就会返回 `TCP_TW_ACK`,然后会回上次发过的 ACK。 接下来,看 `tcp_timewait_state_process()` 函数是如何判断 SYN 包的。 ```c enum tcp_tw_status tcp_timewait_state_process(struct inet_timewait_sock *tw, struct sk_buff *skb, const struct tcphdr *th) { ... //paws_reject 为 false,表示没有发生时间戳回绕 //paws_reject 为 true,表示发生了时间戳回绕 bool paws_reject = false; tmp_opt.saw_tstamp = 0; //TCP 头中有选项且旧连接开启了时间戳选项 if (th->doff > (sizeof(*th) >> 2) && tcptw->tw_ts_recent_stamp) { //解析选项 tcp_parse_options(twsk_net(tw), skb, &tmp_opt, 0, NULL); if (tmp_opt.saw_tstamp) { ... //检查收到的报文的时间戳是否发生了时间戳回绕 paws_reject = tcp_paws_reject(&tmp_opt, th->rst); } } .... //是 SYN 包、没有 RST、没有 ACK、时间戳没有回绕,并且序列号也没有回绕, if (th->syn && !th->rst && !th->ack && !paws_reject && (after(TCP_SKB_CB(skb)->seq, tcptw->tw_rcv_nxt) || (tmp_opt.saw_tstamp && //新连接开启了时间戳 (s32)(tcptw->tw_ts_recent - tmp_opt.rcv_tsval) < 0))) { //时间戳没有回绕 // 初始化序列号 u32 isn = tcptw->tw_snd_nxt + 65535 + 2; if (isn == 0) isn++; TCP_SKB_CB(skb)->tcp_tw_isn = isn; return TCP_TW_SYN; //允许重用 TIME_WAIT 四元组重新建立连接 } if (!th->rst) { // 如果时间戳回绕,或者报文里包含 ack,则将 TIMEWAIT 状态的持续时间重新延长 if (paws_reject || th->ack) inet_twsk_schedule(tw, &tcp_death_row, TCP_TIMEWAIT_LEN, TCP_TIMEWAIT_LEN); // 返回 TCP_TW_ACK, 发送上一次的 ACK return TCP_TW_ACK; } inet_twsk_put(tw); return TCP_TW_SUCCESS; } ``` 如果双方启用了 TCP 时间戳机制,就会通过 `tcp_paws_reject()` 函数来判断时间戳是否发生了回绕,也就是「当前收到的报文的时间戳」是否大于「上一次收到的报文的时间戳」: - 如果大于,就说明没有发生时间戳绕回,函数返回 false。 - 如果小于,就说明发生了时间戳回绕,函数返回 true。 从源码可以看到,当收到 SYN 包后,如果该 SYN 包的时间戳没有发生回绕,也就是时间戳是递增的,并且 SYN 包的序列号也没有发生回绕,也就是 SYN 的序列号「大于」下一次期望收到的序列号。就会初始化一个序列号,然后返回 TCP_TW_SYN,接着就重用该连接,也就跳过 2MSL 而转变为 SYN_RECV 状态,接着就能进行建立连接过程。 如果双方都没有启用 TCP 时间戳机制,就只需要判断 SYN 包的序列号有没有发生回绕,如果 SYN 的序列号大于下一次期望收到的序列号,就可以跳过 2MSL,重用该连接。 如果 SYN 包是非法的,就会返回 TCP_TW_ACK,接着就会发送与上一次一样的 ACK 给对方。 ## 在 TIME_WAIT 状态,收到 RST 会断开连接吗? 在前面我留了一个疑问,处于 TIME_WAIT 状态的连接,收到 RST 会断开连接吗? 会不会断开,关键看 `net.ipv4.tcp_rfc1337` 这个内核参数(默认情况是为 0): - 如果这个参数设置为 0,收到 RST 报文会提前结束 TIME_WAIT 状态,释放连接。 - 如果这个参数设置为 1,就会丢掉 RST 报文。 源码处理如下: ```c enum tcp_tw_status tcp_timewait_state_process(struct inet_timewait_sock *tw, struct sk_buff *skb, const struct tcphdr *th) { .... //rst 报文的时间戳没有发生回绕 if (!paws_reject && (TCP_SKB_CB(skb)->seq == tcptw->tw_rcv_nxt && (TCP_SKB_CB(skb)->seq == TCP_SKB_CB(skb)->end_seq || th->rst))) { //处理 rst 报文 if (th->rst) { //不开启这个选项,当收到 RST 时会立即回收 tw,但这样做是有风险的 if (twsk_net(tw)->ipv4.sysctl_tcp_rfc1337 == 0) { kill: //删除 tw 定时器,并释放 tw inet_twsk_deschedule_put(tw); return TCP_TW_SUCCESS; } } else { //将 TIMEWAIT 状态的持续时间重新延长 inet_twsk_reschedule(tw, TCP_TIMEWAIT_LEN); } ... return TCP_TW_SUCCESS; } } ``` TIME_WAIT 状态收到 RST 报文而释放连接,这样等于跳过 2MSL 时间,这么做还是有风险。 sysctl_tcp_rfc1337 这个参数是在 rfc 1337 文档提出来的,目的是避免因为 TIME_WAIT 状态收到 RST 报文而跳过 2MSL 的时间,文档里也给出跳过 2MSL 时间会有什么潜在问题。 TIME_WAIT 状态之所以要持续 2MSL 时间,主要有两个目的: - 防止历史连接中的数据,被后面相同四元组的连接错误的接收; - 保证「被动关闭连接」的一方,能被正确的关闭; 详细的为什么要设计 TIME_WAIT 状态,我在这篇有详细说明:[如果 TIME_WAIT 状态持续时间过短或者没有,会有什么问题?](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247502380&idx=1&sn=7b82818a5fb6f1127d17f0ded550c4bd&scene=21#wechat_redirect) 虽然 TIME_WAIT 状态持续的时间是有一点长,显得很不友好,但是它被设计来就是用来避免发生乱七八糟的事情。 《UNIX 网络编程》一书中却说道:**TIME_WAIT 是我们的朋友,它是有助于我们的,不要试图避免这个状态,而是应该弄清楚它**。 所以,我个人觉得将 `net.ipv4.tcp_rfc1337` 设置为 1 会比较安全。 ## 总结 在 TCP 正常挥手过程中,处于 TIME_WAIT 状态的连接,收到相同四元组的 SYN 后会发生什么? 如果双方开启了时间戳机制: - 如果客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要**大**,**并且**SYN 的「时间戳」比服务端「最后收到的报文的时间戳」要**大**。那么就会重用该四元组连接,跳过 2MSL 而转变为 SYN_RECV 状态,接着就能进行建立连接过程。 - 如果客户端的 SYN 的「序列号」比服务端「期望下一个收到的序列号」要**小**,**或者**SYN 的「时间戳」比服务端「最后收到的报文的时间戳」要**小**。那么就会**再回复一个第四次挥手的 ACK 报文,客户端收到后,发现并不是自己期望收到确认号,就回 RST 报文给服务端**。 在 TIME_WAIT 状态,收到 RST 会断开连接吗? - 如果 `net.ipv4.tcp_rfc1337` 参数为 0,则提前结束 TIME_WAIT 状态,释放连接。 - 如果 `net.ipv4.tcp_rfc1337` 参数为 1,则会丢掉该 RST 报文。 完! --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/4_ip/ip_base.md ================================================ # 5.1 IP 基础知识全家桶 前段时间,有读者希望我写一篇关于 IP 分类地址、子网划分等的文章,他反馈常常混淆,摸不着头脑。 那么,说来就来!而且要盘就盘全一点,顺便挑战下小林的图解功力,所以就来个 **IP 基础知识全家桶**。 吃完这个 IP 基础知识全家桶,包你撑着肚子喊出:“**真香!**” 不多说,直接上菜,共分为**三道菜**: - 首先是前菜「IP 基本认识」 - 其次是主菜「IP 地址的基础知识」 - 最后是点心「IP 协议相关技术」 ![IP 基础知识全家桶](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/1.jpg) > 为啥要比喻成菜?因为小林是**菜狗**(押韵不?) --- ## 前菜 —— IP 基本认识 IP 在 TCP/IP 参考模型中处于第三层,也就是**网络层**。 网络层的主要作用是:**实现主机与主机之间的通信,也叫点对点(end to end)通信。** ![IP 的作用](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/2.jpg) > 网络层与数据链路层有什么关系呢? 有的小伙伴分不清 IP(网络层)和 MAC(数据链路层)之间的区别和关系。 其实很容易区分,在上面我们知道 IP 的作用是主机之间通信用的,而 **MAC 的作用则是实现「直连」的两个设备之间通信,而 IP 则负责在「没有直连」的两个网络之间进行通信传输。** 举个生活的栗子,小林要去一个很远的地方旅行,制定了一个行程表,其间需先后乘坐飞机、地铁、公交车才能抵达目的地,为此小林需要买飞机票,地铁票等。 飞机票和地铁票都是去往特定的地点的,每张票只能够在某一限定区间内移动,此处的「区间内」就如同通信网络中数据链路。 在区间内移动相当于数据链路层,充当区间内两个节点传输的功能,区间内的出发点好比源 MAC 地址,目标地点好比目的 MAC 地址。 整个旅游行程表就相当于网络层,充当远程定位的功能,行程的开始好比源 IP,行程的终点好比目的 IP 地址。 ![IP 的作用与 MAC 的作用](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/3.jpg) 如果小林只有行程表而没有车票,就无法搭乘交通工具到达目的地。相反,如果除了车票而没有行程表,恐怕也很难到达目的地。因为小林不知道该坐什么车,也不知道该在哪里换乘。 因此,只有两者兼备,既有某个区间的车票又有整个旅行的行程表,才能保证到达目的地。与此类似,**计算机网络中也需要「数据链路层」和「网络层」这个分层才能实现向最终目标地址的通信。** 还有重要一点,旅行途中我们虽然不断变化了交通工具,但是旅行行程的起始地址和目的地址始终都没变。其实,在网络中数据包传输中也是如此,**源 IP 地址和目标 IP 地址在传输过程中是不会变化的,只有源 MAC 地址和目标 MAC 一直在变化。** --- ## 主菜 —— IP 地址的基础知识 在 TCP/IP 网络通信时,为了保证能正常通信,每个设备都需要配置正确的 IP 地址,否则无法实现正常的通信。 IP 地址(IPv4 地址)由 `32` 位正整数来表示,IP 地址在计算机是以二进制的方式处理的。 而人类为了方便记忆采用了**点分十进制**的标记方式,也就是将 32 位 IP 地址以每 8 位为组,共分为 `4` 组,每组以「`.`」隔开,再将每组转换成十进制。 ![点分十进制](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/4.jpg) 那么,IP 地址最大值也就是 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/5.jpg) 也就说,最大允许 43 亿台计算机连接到网络。 实际上,IP 地址并不是根据主机台数来配置的,而是以网卡。像服务器、路由器等设备都是有 2 个以上的网卡,也就是它们会有 2 个以上的 IP 地址。 ![每块网卡可以分配一个以上的 IP 地址](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/6.jpg) 因此,让 43 亿台计算机全部连网其实是不可能的,更何况 IP 地址是由「网络标识」和「主机标识」这两个部分组成的,所以实际能够连接到网络的计算机个数更是少了很多。 > 可能有的小伙伴提出了疑问,现在不仅电脑配了 IP,手机、IPad 等电子设备都配了 IP 呀,照理来说肯定会超过 43 亿啦,那是怎么能够支持这么多 IP 的呢? 因为会根据一种可以更换 IP 地址的技术 `NAT`,使得可连接计算机数超过 43 亿台。 `NAT` 技术后续会进一步讨论和说明。 ### IP 地址的分类 互联网诞生之初,IP 地址显得很充裕,于是计算机科学家们设计了**分类地址**。 IP 地址分类成了 5 种类型,分别是 A 类、B 类、C 类、D 类、E 类。 ![IP 地址分类](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/7.jpg) 上图中黄色部分为分类号,用以区分 IP 地址类别。 > 什么是 A、B、C 类地址? 其中对于 A、B、C 类主要分为两个部分,分别是**网络号和主机号**。这很好理解,好比小林是 A 小区 1 栋 101 号,你是 B 小区 1 栋 101 号。 我们可以用下面这个表格,就能很清楚的知道 A、B、C 分类对应的地址范围、最大主机个数。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/8.jpg) > A、B、C 分类地址最大主机个数是如何计算的呢? 最大主机个数,就是要看主机号的位数,如 C 类地址的主机号占 8 位,那么 C 类地址的最大主机个数: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/9.jpg) 为什么要减 2 呢? 因为在 IP 地址中,有两个 IP 是特殊的,分别是主机号全为 1 和 全为 0 地址。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/10.jpg) - 主机号全为 1 指定某个网络下的所有主机,用于广播 - 主机号全为 0 指定某个网络 因此,在分配过程中,应该去掉这两种情况。 > 广播地址用于什么? 广播地址用于在**同一个链路中相互连接的主机之间发送数据包**。 学校班级中就有广播的例子,在准备上课的时候,通常班长会喊:“上课,全体起立!”,班里的同学听到这句话是不是全部都站起来了?这个句话就有广播的含义。 当主机号全为 1 时,就表示该网络的广播地址。例如把 `172.20.0.0/16` 用二进制表示如下: 10101100.00010100.00000000.00000000 将这个地址的**主机部分全部改为 1**,则形成广播地址: 10101100.00010100.`11111111.11111111` 再将这个地址用十进制表示,则为 `172.20.255.255`。 广播地址可以分为本地广播和直接广播两种。 - **在本网络内广播的叫做本地广播**。例如网络地址为 192.168.0.0/24 的情况下,广播地址是 192.168.0.255。因为这个广播地址的 IP 包会被路由器屏蔽,所以不会到达 192.168.0.0/24 以外的其他链路上。 - **在不同网络之间的广播叫做直接广播**。例如网络地址为 192.168.0.0/24 的主机向 192.168.1.255/24 的目标地址发送 IP 包。收到这个包的路由器,将数据转发给 192.168.1.0/24,从而使得所有 192.168.1.1~192.168.1.254 的主机都能收到这个包(由于直接广播有一定的安全问题,多数情况下会在路由器上设置为不转发。) 。 ![本地广播与直接广播](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/11.jpg) > 什么是 D、E 类地址? 而 D 类和 E 类地址是没有主机号的,所以不可用于主机 IP,D 类常被用于**多播**,E 类是预留的分类,暂时未使用。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/12.jpg) > 多播地址用于什么? 多播用于**将包发送给特定组内的所有主机。** 还是举班级的栗子,老师说:“最后一排的同学,上来做这道数学题。”,老师指定的是最后一排的同学,也就是多播的含义了。 由于广播无法穿透路由,若想给其他网段发送同样的包,就可以使用可以穿透路由的多播。 ![单播、广播、多播通信](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/13.jpg) 多播使用的 D 类地址,其前四位是 `1110` 就表示是多播地址,而剩下的 28 位是多播的组编号。 从 224.0.0.0 ~ 239.255.255.255 都是多播的可用范围,其划分为以下三类: - 224.0.0.0 ~ 224.0.0.255 为预留的组播地址,只能在局域网中,路由器是不会进行转发的。 - 224.0.1.0 ~ 238.255.255.255 为用户可用的组播地址,可以用于 Internet 上。 - 239.0.0.0 ~ 239.255.255.255 为本地管理组播地址,可供内部网在内部使用,仅在特定的本地范围内有效。 > IP 分类的优点 不管是路由器还是主机解析到一个 IP 地址时候,我们判断其 IP 地址的首位是否为 0,为 0 则为 A 类地址,那么就能很快的找出网络地址和主机地址。 其余分类判断方式参考如下图: ![IP 分类判断](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/14.jpg) 所以,这种分类地址的优点就是**简单明了、选路(基于网络地址)简单**。 > IP 分类的缺点 *缺点一* **同一网络下没有地址层次**,比如一个公司里用了 B 类地址,但是可能需要根据生产环境、测试环境、开发环境来划分地址层次,而这种 IP 分类是没有地址层次划分的功能,所以这就**缺少地址的灵活性**。 *缺点二* A、B、C 类有个尴尬处境,就是**不能很好的与现实网络匹配**。 - C 类地址能包含的最大主机数量实在太少了,只有 254 个,估计一个网吧都不够用。 - 而 B 类地址能包含的最大主机数量又太多了,6 万多台机器放在一个网络下面,一般的企业基本达不到这个规模,闲着的地址就是浪费。 这两个缺点,都可以在 `CIDR` 无分类地址解决。 ### 无分类地址 CIDR 正因为 IP 分类存在许多缺点,所以后面提出了无分类地址的方案,即 `CIDR`。 这种方式不再有分类地址的概念,32 比特的 IP 地址被划分为两部分,前面是**网络号**,后面是**主机号**。 > 怎么划分网络号和主机号的呢? 表示形式 `a.b.c.d/x`,其中 `/x` 表示前 x 位属于**网络号**,x 的范围是 `0 ~ 32`,这就使得 IP 地址更加具有灵活性。 比如 10.100.122.2/24,这种地址表示形式就是 CIDR,/24 表示前 24 位是网络号,剩余的 8 位是主机号。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/15.jpg) 还有另一种划分网络号与主机号形式,那就是**子网掩码**,掩码的意思就是掩盖掉主机号,剩余的就是网络号。 **将子网掩码和 IP 地址按位计算 AND,就可得到网络号。** ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/16.jpg) > 为什么要分离网络号和主机号? 因为两台计算机要通讯,首先要判断是否处于同一个广播域内,即网络地址是否相同。如果网络地址相同,表明接受方在本网络上,那么可以把数据包直接发送到目标主机。 路由器寻址工作中,也就是通过这样的方式来找到对应的网络号的,进而把数据包转发给对应的网络内。 ![IP 地址的网络号](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/17.jpg) > 怎么进行子网划分? 在上面我们知道可以通过子网掩码划分出网络号和主机号,那实际上子网掩码还有一个作用,那就是**划分子网**。 **子网划分实际上是将主机地址分为两个部分:子网网络地址和子网主机地址**。形式如下: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/18.jpg) - 未做子网划分的 ip 地址:网络地址+主机地址 - 做子网划分后的 ip 地址:网络地址+(子网网络地址+子网主机地址) 假设对 C 类地址进行子网划分,网络地址 192.168.1.0,使用子网掩码 255.255.255.192 对其进行子网划分。 C 类地址中前 24 位是网络号,最后 8 位是主机号,根据子网掩码可知**从 8 位主机号中借用 2 位作为子网号**。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/19.jpg) 由于子网网络地址被划分成 2 位,那么子网地址就有 4 个,分别是 00、01、10、11,具体划分如下图: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/20.jpg) 划分后的 4 个子网如下表格: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/21.jpg) ### 公有 IP 地址与私有 IP 地址 在 A、B、C 分类地址,实际上有分公有 IP 地址和私有 IP 地址。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/22.jpg) 平时我们办公室、家里、学校用的 IP 地址,一般都是私有 IP 地址。因为这些地址允许组织内部的 IT 人员自己管理、自己分配,而且可以重复。因此,你学校的某个私有 IP 地址和我学校的可以是一样的。 就像每个小区都有自己的楼编号和门牌号,你小区家可以叫 1 栋 101 号,我小区家也可以叫 1 栋 101,没有任何问题。但一旦出了小区,就需要带上中山路 666 号(公网 IP 地址),是国家统一分配的,不能两个小区都叫中山路 666。 所以,公有 IP 地址是有个组织统一分配的,假设你要开一个博客网站,那么你就需要去申请购买一个公有 IP,这样全世界的人才能访问。并且公有 IP 地址基本上要在整个互联网范围内保持唯一。 ![公有 IP 地址与私有 IP 地址](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/23.jpg) > 公有 IP 地址由谁管理呢? 私有 IP 地址通常是内部的 IT 人员管理,公有 IP 地址是由 `ICANN` 组织管理,中文叫「互联网名称与数字地址分配机构」。 IANA 是 ICANN 的其中一个机构,它负责分配互联网 IP 地址,是按洲的方式层层分配。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/24.jpg) - ARIN 北美地区 - LACNIC 拉丁美洲和一些加勒比群岛 - RIPE NCC 欧洲、中东和中亚 - AfriNIC 非洲地区 - APNIC 亚太地区 其中,在中国是由 CNNIC 的机构进行管理,它是中国国内唯一指定的全局 IP 地址管理的组织。 ### IP 地址与路由控制 IP 地址的**网络地址**这一部分是用于进行路由控制。 路由控制表中记录着网络地址与下一步应该发送至路由器的地址。在主机和路由器上都会有各自的路由器控制表。 在发送 IP 包时,首先要确定 IP 包首部中的目标地址,再从路由控制表中找到与该地址具有**相同网络地址**的记录,根据该记录将 IP 包转发给相应的下一个路由器。如果路由控制表中存在多条相同网络地址的记录,就选择相同位数最多的网络地址,也就是最长匹配。 下面以下图的网络链路作为例子说明: ![IP 地址与路由控制](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/25.jpg) 1. 主机 A 要发送一个 IP 包,其源地址是 `10.1.1.30` 和目标地址是 `10.1.2.10`,由于没有在主机 A 的路由表找到与目标地址 `10.1.2.10` 的网络地址,于是包被转发到默认路由(路由器 `1` ) 2. 路由器 `1` 收到 IP 包后,也在路由器 `1` 的路由表匹配与目标地址相同的网络地址记录,发现匹配到了,于是就把 IP 数据包转发到了 `10.1.0.2` 这台路由器 `2` 3. 路由器 `2` 收到后,同样对比自身的路由表,发现匹配到了,于是把 IP 包从路由器 `2` 的 `10.1.2.1` 这个接口出去,最终经过交换机把 IP 数据包转发到了目标主机 > 环回地址是不会流向网络 环回地址是在同一台计算机上的程序之间进行网络通信时所使用的一个默认地址。 计算机使用一个特殊的 IP 地址 **127.0.0.1 作为环回地址**。与该地址具有相同意义的是一个叫做 `localhost` 的主机名。使用这个 IP 或主机名时,数据包不会流向网络。 ### IP 分片与重组 每种数据链路的最大传输单元 `MTU` 都是不相同的,如 FDDI 数据链路 MTU 4352、以太网的 MTU 是 1500 字节等。 每种数据链路的 MTU 之所以不同,是因为每个不同类型的数据链路的使用目的不同。使用目的不同,可承载的 MTU 也就不同。 其中,我们最常见数据链路是以太网,它的 MTU 是 `1500` 字节。 那么当 IP 数据包大小大于 MTU 时,IP 数据包就会被分片。 经过分片之后的 IP 数据报在被重组的时候,只能由目标主机进行,路由器是不会进行重组的。 假设发送方发送一个 4000 字节的大数据报,若要传输在以太网链路,则需要把数据报分片成 3 个小数据报进行传输,再交由接收方重组成大数据报。 ![分片与重组](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/26.jpg) 在分片传输中,一旦某个分片丢失,则会造成整个 IP 数据报作废,所以 TCP 引入了 `MSS` 也就是在 TCP 层进行分片不由 IP 层分片,那么对于 UDP 我们尽量不要发送一个大于 `MTU` 的数据报文。 ### IPv6 基本认识 IPv4 的地址是 32 位的,大约可以提供 42 亿个地址,但是早在 2011 年 IPv4 地址就已经被分配完了。 但是 IPv6 的地址是 `128` 位的,这可分配的地址数量是大的惊人,说个段子 **IPv6 可以保证地球上的每粒沙子都能被分配到一个 IP 地址。** 但 IPv6 除了有更多的地址之外,还有更好的安全性和扩展性,说简单点就是 IPv6 相比于 IPv4 能带来更好的网络体验。 但是因为 IPv4 和 IPv6 不能相互兼容,所以不但要我们电脑、手机之类的设备支持,还需要网络运营商对现有的设备进行升级,所以这可能是 IPv6 普及率比较慢的一个原因。 > IPv6 的亮点 IPv6 不仅仅只是可分配的地址变多了,它还有非常多的亮点。 - IPv6 可自动配置,即使没有 DHCP 服务器也可以实现自动分配 IP 地址,真是**便捷到即插即用**啊。 - IPv6 包头包首部长度采用固定的值 `40` 字节,去掉了包头校验和,简化了首部结构,减轻了路由器负荷,大大**提高了传输的性能**。 - IPv6 有应对伪造 IP 地址的网络安全功能以及防止线路窃听的功能,大大**提升了安全性**。 - **...** (由你发现更多的亮点) > IPv6 地址的标识方法 IPv4 地址长度共 32 位,是以每 8 位作为一组,并用点分十进制的表示方式。 IPv6 地址长度是 128 位,是以每 16 位作为一组,每组用冒号「:」隔开。 ![IPv6 地址表示方法](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/27.jpg) 如果出现连续的 0 时还可以将这些 0 省略,并用两个冒号「::」隔开。但是,一个 IP 地址中只允许出现一次两个连续的冒号。 ![Pv6 地址缺省表示方](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/28.jpg) > IPv6 地址的结构 IPv6 类似 IPv4,也是通过 IP 地址的前几位标识 IP 地址的种类。 IPv6 的地址主要有以下类型地址: - 单播地址,用于一对一的通信 - 组播地址,用于一对多的通信 - 任播地址,用于通信最近的节点,最近的节点是由路由协议决定 - 没有广播地址 ![IPv6 地址结构](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/29.jpg) > IPv6 单播地址类型 对于一对一通信的 IPv6 地址,主要划分了三类单播地址,每类地址的有效范围都不同。 - 在同一链路单播通信,不经过路由器,可以使用**链路本地单播地址**,IPv4 没有此类型 - 在内网里单播通信,可以使用**唯一本地地址**,相当于 IPv4 的私有 IP - 在互联网通信,可以使用**全局单播地址**,相当于 IPv4 的公有 IP ![ IPv6 中的单播通信](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/30.jpg) ### IPv4 首部与 IPv6 首部 IPv4 首部与 IPv6 首部的差异如下图: ![IPv4 首部与 IPv6 首部的差异](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/31.jpg) IPv6 相比 IPv4 的首部改进: - **取消了首部校验和字段。** 因为在数据链路层和传输层都会校验,因此 IPv6 直接取消了 IP 的校验。 - **取消了分片/重新组装相关字段。** 分片与重组是耗时的过程,IPv6 不允许在中间路由器进行分片与重组,这种操作只能在源与目标主机,这将大大提高了路由器转发的速度。 - **取消选项字段。** 选项字段不再是标准 IP 首部的一部分了,但它并没有消失,而是可能出现在 IPv6 首部中的「下一个首部」指出的位置上。删除该选项字段使的 IPv6 的首部成为固定长度的 `40` 字节。 --- ## 点心 —— IP 协议相关技术 跟 IP 协议相关的技术也不少,接下来说说与 IP 协议相关的重要且常见的技术。 - DNS 域名解析 - ARP 与 RARP 协议 - DHCP 动态获取 IP 地址 - NAT 网络地址转换 - ICMP 互联网控制报文协议 - IGMP 因特网组管理协 ### DNS 我们在上网的时候,通常使用的方式是域名,而不是 IP 地址,因为域名方便人类记忆。 那么实现这一技术的就是 **DNS 域名解析**,DNS 可以将域名网址自动转换为具体的 IP 地址。 > 域名的层级关系 DNS 中的域名都是用**句点**来分隔的,比如 `www.server.com`,这里的句点代表了不同层次之间的**界限**。 在域名中,**越靠右**的位置表示其层级**越高**。 毕竟域名是外国人发明,所以思维和中国人相反,比如说一个城市地点的时候,外国喜欢从小到大的方式顺序说起(如 XX 街道 XX 区 XX 市 XX 省),而中国则喜欢从大到小的顺序(如 XX 省 XX 市 XX 区 XX 街道)。 根域是在最顶层,它的下一层就是 com 顶级域,再下面是 server.com。 所以域名的层级关系类似一个树状结构: - 根 DNS 服务器 - 顶级域 DNS 服务器(com) - 权威 DNS 服务器(server.com) ![DNS 树状结构](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/32.jpg) 根域的 DNS 服务器信息保存在互联网中所有的 DNS 服务器中。这样一来,任何 DNS 服务器就都可以找到并访问根域 DNS 服务器了。 因此,客户端只要能够找到任意一台 DNS 服务器,就可以通过它找到根域 DNS 服务器,然后再一路顺藤摸瓜找到位于下层的某台目标 DNS 服务器。 > 域名解析的工作流程 浏览器首先看一下自己的缓存里有没有,如果没有就向操作系统的缓存要,还没有就检查本机域名解析文件 `hosts`,如果还是没有,就会 DNS 服务器进行查询,查询的过程如下: 1. 客户端首先会发出一个 DNS 请求,问 www.server.com 的 IP 是啥,并发给本地 DNS 服务器(也就是客户端的 TCP/IP 设置中填写的 DNS 服务器地址)。 2. 本地域名服务器收到客户端的请求后,如果缓存里的表格能找到 www.server.com,则它直接返回 IP 地址。如果没有,本地 DNS 会去问它的根域名服务器:“老大,能告诉我 www.server.com 的 IP 地址吗?”根域名服务器是最高层次的,它不直接用于域名解析,但能指明一条道路。 3. 根 DNS 收到来自本地 DNS 的请求后,发现后置是 .com,说:“www.server.com 这个域名归 .com 区域管理”,我给你 .com 顶级域名服务器地址给你,你去问问它吧。” 4. 本地 DNS 收到顶级域名服务器的地址后,发起请求问“老二,你能告诉我 www.server.com 的 IP 地址吗?” 5. 顶级域名服务器说:“我给你负责 www.server.com 区域的权威 DNS 服务器的地址,你去问它应该能问到”。 6. 本地 DNS 于是转向问权威 DNS 服务器:“老三,www.server.com 对应的 IP 是啥呀?”server.com 的权威 DNS 服务器,它是域名解析结果的原出处。为啥叫权威呢?就是我的域名我做主。 7. 权威 DNS 服务器查询后将对应的 IP 地址 X.X.X.X 告诉本地 DNS。 8. 本地 DNS 再将 IP 地址返回客户端,客户端和目标建立连接。 至此,我们完成了 DNS 的解析过程。现在总结一下,整个过程我画成了一个图。 ![域名解析的工作流程](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/33.jpg) DNS 域名解析的过程蛮有意思的,整个过程就和我们日常生活中找人问路的过程类似,**只指路不带路**。 ### ARP 在传输一个 IP 数据报的时候,确定了源 IP 地址和目标 IP 地址后,就会通过主机「路由表」确定 IP 数据包下一跳。然而,网络层的下一层是数据链路层,所以我们还要知道「下一跳」的 MAC 地址。 由于主机的路由表中可以找到下一跳的 IP 地址,所以可以通过 **ARP 协议**,求得下一跳的 MAC 地址。 > 那么 ARP 又是如何知道对方 MAC 地址的呢? 简单地说,ARP 是借助 **ARP 请求与 ARP 响应**两种类型的包确定 MAC 地址的。 ![ARP 广播](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/34.jpg) - 主机会通过**广播发送 ARP 请求**,这个包中包含了想要知道的 MAC 地址的主机 IP 地址。 - 当同个链路中的所有设备收到 ARP 请求时,会去拆开 ARP 请求包里的内容,如果 ARP 请求包中的目标 IP 地址与自己的 IP 地址一致,那么这个设备就将自己的 MAC 地址塞入 **ARP 响应包**返回给主机。 操作系统通常会把第一次通过 ARP 获取的 MAC 地址缓存起来,以便下次直接从缓存中找到对应 IP 地址的 MAC 地址。 不过,MAC 地址的缓存是有一定期限的,超过这个期限,缓存的内容将被清除。 > RARP 协议你知道是什么吗? ARP 协议是已知 IP 地址求 MAC 地址,那 RARP 协议正好相反,它是**已知 MAC 地址求 IP 地址**。例如将打印机服务器等小型嵌入式设备接入到网络时就经常会用得到。 通常这需要架设一台 `RARP` 服务器,在这个服务器上注册设备的 MAC 地址及其 IP 地址。然后再将这个设备接入到网络,接着: - 该设备会发送一条「我的 MAC 地址是 XXXX,请告诉我,我的 IP 地址应该是什么」的请求信息。 - RARP 服务器接到这个消息后返回「MAC 地址为 XXXX 的设备,IP 地址为 XXXX」的信息给这个设备。 最后,设备就根据从 RARP 服务器所收到的应答信息设置自己的 IP 地址。 ![RARP](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/35.jpg) ### DHCP DHCP 在生活中我们是很常见的了,我们的电脑通常都是通过 DHCP 动态获取 IP 地址,大大省去了配 IP 信息繁琐的过程。 接下来,我们来看看我们的电脑是如何通过 4 个步骤的过程,获取到 IP 的。 ![DHCP 工作流程](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/36.jpg) 先说明一点,DHCP 客户端进程监听的是 68 端口号,DHCP 服务端进程监听的是 67 端口号。 这 4 个步骤: - 客户端首先发起 **DHCP 发现报文(DHCP DISCOVER)** 的 IP 数据报,由于客户端没有 IP 地址,也不知道 DHCP 服务器的地址,所以使用的是 UDP **广播**通信,其使用的广播目的地址是 255.255.255.255(端口 67)并且使用 0.0.0.0(端口 68)作为源 IP 地址。DHCP 客户端将该 IP 数据报传递给链路层,链路层然后将帧广播到所有的网络中设备。 - DHCP 服务器收到 DHCP 发现报文时,用 **DHCP 提供报文(DHCP OFFER)** 向客户端做出响应。该报文仍然使用 IP 广播地址 255.255.255.255,该报文信息携带服务器提供可租约的 IP 地址、子网掩码、默认网关、DNS 服务器以及 **IP 地址租用期**。 - 客户端收到一个或多个服务器的 DHCP 提供报文后,从中选择一个服务器,并向选中的服务器发送 **DHCP 请求报文(DHCP REQUEST**进行响应,回显配置的参数。 - 最后,服务端用 **DHCP ACK 报文**对 DHCP 请求报文进行响应,应答所要求的参数。 一旦客户端收到 DHCP ACK 后,交互便完成了,并且客户端能够在租用期内使用 DHCP 服务器分配的 IP 地址。 如果租约的 DHCP IP 地址快过期了,客户端会向服务器发送 DHCP 请求报文: - 服务器如果同意继续租用,则用 DHCP ACK 报文进行应答,客户端就会延长租期。 - 服务器如果不同意继续租用,则用 DHCP NACK 报文,客户端就要停止使用租约的 IP 地址。 可以发现,DHCP 交互中,**全程都是使用 UDP 广播通信**。 > 咦,用的是广播,那如果 DHCP 服务器和客户端不是在同一个局域网内,路由器又不会转发广播包,那不是每个网络都要配一个 DHCP 服务器? 所以,为了解决这一问题,就出现了 **DHCP 中继代理**。有了 DHCP 中继代理以后,**对不同网段的 IP 地址分配也可以由一个 DHCP 服务器统一进行管理。** ![ DHCP 中继代理](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/37.jpg) - DHCP 客户端会向 DHCP 中继代理发送 DHCP 请求包,而 DHCP 中继代理在收到这个广播包以后,再以**单播**的形式发给 DHCP 服务器。 - 服务器端收到该包以后再向 DHCP 中继代理返回应答,并由 DHCP 中继代理将此包广播给 DHCP 客户端。 因此,DHCP 服务器即使不在同一个链路上也可以实现统一分配和管理 IP 地址。 ### NAT IPv4 的地址是非常紧缺的,在前面我们也提到可以通过无分类地址来减缓 IPv4 地址耗尽的速度,但是互联网的用户增速是非常惊人的,所以 IPv4 地址依然有被耗尽的危险。 于是,提出了一种**网络地址转换 NAT** 的方法,再次缓解了 IPv4 地址耗尽的问题。 简单的来说 NAT 就是同个公司、家庭、教室内的主机对外部通信时,把私有 IP 地址转换成公有 IP 地址。 ![NAT](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/38.jpg) > 那不是 N 个私有 IP 地址,你就要 N 个公有 IP 地址?这怎么就缓解了 IPv4 地址耗尽的问题?这不瞎扯吗? 确实是,普通的 NAT 转换没什么意义。 由于绝大多数的网络应用都是使用传输层协议 TCP 或 UDP 来传输数据的。 因此,可以把 IP 地址 + 端口号一起进行转换。 这样,就用一个全球 IP 地址就可以了,这种转换技术就叫**网络地址与端口转换 NAPT。** 很抽象?来,看下面的图解就能瞬间明白了。 ![NAPT](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/39.jpg) 图中有两个客户端 192.168.1.10 和 192.168.1.11 同时与服务器 183.232.231.172 进行通信,并且这两个客户端的本地端口都是 1025。 此时,**两个私有 IP 地址都转换 IP 地址为公有地址 120.229.175.121,但是以不同的端口号作为区分。** 于是,生成一个 NAPT 路由器的转换表,就可以正确地转换地址跟端口的组合,令客户端 A、B 能同时与服务器之间进行通信。 这种转换表在 NAT 路由器上自动生成。例如,在 TCP 的情况下,建立 TCP 连接首次握手时的 SYN 包一经发出,就会生成这个表。而后又随着收到关闭连接时发出 FIN 包的确认应答从表中被删除。 > NAT 那么牛逼,难道就没缺点了吗? 当然有缺陷,肯定没有十全十美的方案。 由于 NAT/NAPT 都依赖于自己的转换表,因此会有以下的问题: - 外部无法主动与 NAT 内部服务器建立连接,因为 NAPT 转换表没有转换记录。 - 转换表的生成与转换操作都会产生性能开销。 - 通信过程中,如果 NAT 路由器重启了,所有的 TCP 连接都将被重置。 > 如何解决 NAT 潜在的问题呢? 解决的方法主要有两种方法。 *第一种就是改用 IPv6* IPv6 可用范围非常大,以至于每台设备都可以配置一个公有 IP 地址,就不搞那么多花里胡哨的地址转换了,但是 IPv6 普及速度还需要一些时间。 *第二种 NAT 穿透技术* NAT 穿透技术拥有这样的功能,它能够让网络应用程序主动发现自己位于 NAT 设备之后,并且会主动获得 NAT 设备的公有 IP,并为自己建立端口映射条目,注意这些都是 NAT 设备后的应用程序自动完成的。 也就是说,在 NAT 穿透技术中,NAT 设备后的应用程序处于主动地位,它已经明确地知道 NAT 设备要修改它外发的数据包,于是它主动配合 NAT 设备的操作,主动地建立好映射,这样就不像以前由 NAT 设备来建立映射了。 说人话,就是客户端主动从 NAT 设备获取公有 IP 地址,然后自己建立端口映射条目,然后用这个条目对外通信,就不需要 NAT 设备来进行转换了。 ### ICMP ICMP 全称是 **Internet Control Message Protocol**,也就是**互联网控制报文协议**。 里面有个关键词 —— **控制**,如何控制的呢? 网络包在复杂的网络传输环境里,常常会遇到各种问题。 当遇到问题的时候,总不能死个不明不白,没头没脑的作风不是计算机网络的风格。所以需要传出消息,报告遇到了什么问题,这样才可以调整传输策略,以此来控制整个局面。 > ICMP 功能都有啥? `ICMP` 主要的功能包括:**确认 IP 包是否成功送达目标地址、报告发送过程中 IP 包被废弃的原因和改善网络设置等。** 在 `IP` 通信中如果某个 `IP` 包因为某种原因未能达到目标地址,那么这个具体的原因将**由 ICMP 负责通知**。 ![ICMP 目标不可达消息](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/40.jpg) 如上图例子,主机 `A` 向主机 `B` 发送了数据包,由于某种原因,途中的路由器 `2` 未能发现主机 `B` 的存在,这时,路由器 `2` 就会向主机 `A` 发送一个 `ICMP` 目标不可达数据包,说明发往主机 `B` 的包未能成功。 ICMP 的这种通知消息会使用 `IP` 进行发送。 因此,从路由器 `2` 返回的 ICMP 包会按照往常的路由控制先经过路由器 `1` 再转发给主机 `A` 。收到该 ICMP 包的主机 `A` 则分解 ICMP 的首部和数据域以后得知具体发生问题的原因。 > ICMP 类型 ICMP 大致可以分为两大类: - 一类是用于诊断的查询消息,也就是「**查询报文类型**」 - 另一类是通知出错原因的错误消息,也就是「**差错报文类型**」 ![常见的 ICMP 类型](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/41.jpg) ### IGMP ICMP 跟 IGMP 是一点关系都没有的,就好像周杰与周杰伦的区别,大家不要混淆了。 在前面我们知道了组播地址,也就是 D 类地址,既然是组播,那就说明是只有一组的主机能收到数据包,不在一组的主机不能收到数组包,怎么管理是否是在一组呢?那么,就需要 `IGMP` 协议了。 ![组播模型](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/42.jpg) **IGMP 是因特网组管理协议,工作在主机(组播成员)和最后一跳路由之间**,如上图中的蓝色部分。 - IGMP 报文向路由器申请加入和退出组播组,默认情况下路由器是不会转发组播包到连接中的主机,除非主机通过 IGMP 加入到组播组,主机申请加入到组播组时,路由器就会记录 IGMP 路由器表,路由器后续就会转发组播包到对应的主机了。 - IGMP 报文采用 IP 封装,IP 头部的协议号为 2,而且 TTL 字段值通常为 1,因为 IGMP 是工作在主机与连接的路由器之间。 > IGMP 工作机制 IGMP 分为了三个版本分别是,IGMPv1、IGMPv2、IGMPv3。 接下来,以 `IGMPv2` 作为例子,说说**常规查询与响应和离开组播组**这两个工作机制。 *常规查询与响应工作机制* ![ IGMP 常规查询与响应工作机制](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/43.jpg) 1. 路由器会周期性发送目的地址为 `224.0.0.1`(表示同一网段内所有主机和路由器) **IGMP 常规查询报文**。 2. 主机 1 和 主机 3 收到这个查询,随后会启动「报告延迟计时器」,计时器的时间是随机的,通常是 0~10 秒,计时器超时后主机就会发送 **IGMP 成员关系报告报文**(源 IP 地址为自己主机的 IP 地址,目的 IP 地址为组播地址)。如果在定时器超时之前,收到同一个组内的其他主机发送的成员关系报告报文,则自己不再发送,这样可以减少网络中多余的 IGMP 报文数量。 3. 路由器收到主机的成员关系报文后,就会在 IGMP 路由表中加入该组播组,后续网络中一旦该组播地址的数据到达路由器,它会把数据包转发出去。 *离开组播组工作机制* 离开组播组的情况一,网段中仍有该组播组: ![ IGMPv2 离开组播组工作机制 情况 1](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/44.jpg) 1. 主机 1 要离开组 224.1.1.1,发送 IGMPv2 离组报文,报文的目的地址是 224.0.0.2(表示发向网段内的所有路由器) 2. 路由器 收到该报文后,以 1 秒为间隔连续发送 IGMP 特定组查询报文(共计发送 2 个),以便确认该网络是否还有 224.1.1.1 组的其他成员。 3. 主机 3 仍然是组 224.1.1.1 的成员,因此它立即响应这个特定组查询。路由器知道该网络中仍然存在该组播组的成员,于是继续向该网络转发 224.1.1.1 的组播数据包。 离开组播组的情况二,网段中没有该组播组: ![ IGMPv2 离开组播组工作机制 情况 2](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/45.jpg) 1. 主机 1 要离开组播组 224.1.1.1,发送 IGMP 离组报文。 2. 路由器收到该报文后,以 1 秒为间隔连续发送 IGMP 特定组查询报文(共计发送 2 个)。此时在该网段内,组 224.1.1.1 已经没有其他成员了,因此没有主机响应这个查询。 3. 一定时间后,路由器认为该网段中已经没有 224.1.1.1 组播组成员了,将不会再向这个网段转发该组播地址的数据包。 --- 参考资料: [1] 计算机网络 - 自顶向下方法。陈鸣 译。机械工业出版社 [2] TCP/IP 详解 卷 1:协议。范建华 译。机械工业出版社 [3] 图解 TCP/IP.竹下隆史.人民邮电出版社 --- ## 读者问答 > 读者问题:“组播不太懂。。。假设一台机器加入组播地址,需要把 IP 改成组播地址吗?如果离开某个组播地址,需要 dhcp 重新请求个 IP 吗?” 组播地址不是用于机器 ip 地址的,因为组播地址没有网络号和主机号,所以跟 dhcp 没关系。组播地址一般是用于 udp 协议,机器发送 UDP 组播数据时,目标地址填的是组播地址,那么在组播组内的机器都能收到数据包。 是否加入组播组和离开组播组,是由 socket 一个接口实现的,主机 ip 是不用改变的。 --- 哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,别忘记关注我哦! ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/4_ip/ping.md ================================================ # 5.2 ping 的工作原理 在日常生活或工作中,我们在判断与对方**网络是否畅通**,使用的最多的莫过于 `ping` 命令了。 “**那你知道 `ping` 是如何工作的吗?**” —— 来自小林的灵魂拷问 可能有的小伙伴奇怪的问:“我虽然不明白它的工作,但 ping 我也用的贼 6 啊!” 你用的是 6,但你在面试官面前,你就 6 不起来了,毕竟他们也爱问。 所以,我们要抱有「**知其然,知其所以然**」的态度,这样就能避免面试过程中,出门右拐的情况了。 ![来自面试官的灵魂拷问](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/ping/2.jpg) 不知道的小伙伴也没关系,今天我们就来搞定它,搞懂它。消除本次的问号,**让问号少一点**。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/ping/3.jpg) ---- ## IP 协议的助手 —— ICMP 协议 ping 是基于 `ICMP` 协议工作的,所以要明白 ping 的工作,首先我们先来熟悉 **ICMP 协议**。 > ICMP 是什么? ICMP 全称是 **Internet Control Message Protocol**,也就是**互联网控制报文协议**。 里面有个关键词 —— **控制**,如何控制的呢? 网络包在复杂的网络传输环境里,常常会遇到各种问题。当遇到问题的时候,总不能死的不明不白,没头没脑的作风不是计算机网络的风格。所以需要传出消息,报告遇到了什么问题,这样才可以调整传输策略,以此来控制整个局面。 > ICMP 功能都有啥? `ICMP` 主要的功能包括:**确认 IP 包是否成功送达目标地址、报告发送过程中 IP 包被废弃的原因和改善网络设置等。** 在 `IP` 通信中如果某个 `IP` 包因为某种原因未能达到目标地址,那么这个具体的原因将**由 ICMP 负责通知**。 ![ICMP 目标不可达消息](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/ping/4.jpg) 如上图例子,主机 `A` 向主机 `B` 发送了数据包,由于某种原因,途中的路由器 `2` 未能发现主机 `B` 的存在,这时,路由器 `2` 就会向主机 `A` 发送一个 `ICMP` 目标不可达数据包,说明发往主机 `B` 的包未能成功。 ICMP 的这种通知消息会使用 `IP` 进行发送。 因此,从路由器 `2` 返回的 ICMP 包会按照往常的路由控制先经过路由器 `1` 再转发给主机 `A` 。收到该 ICMP 包的主机 `A` 则分解 ICMP 的首部和数据域以后得知具体发生问题的原因。 > ICMP 包头格式 ICMP 报文是封装在 IP 包里面,它工作在网络层,是 IP 协议的助手。 ![ICMP 报文](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/ping/5.jpg) ICMP 包头的**类型**字段,大致可以分为两大类: - 一类是用于诊断的查询消息,也就是「**查询报文类型**」 - 另一类是通知出错原因的错误消息,也就是「**差错报文类型**」 ![常见的 ICMP 类型](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/ping/6.jpg) ## 查询报文类型 > 回送消息 —— 类型 `0` 和 `8` **回送消息**用于进行通信的主机或路由器之间,判断所发送的数据包是否已经成功到达对端的一种消息,`ping` 命令就是利用这个消息实现的。 ![ICMP 回送消息](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/ping/7.jpg) 可以向对端主机发送**回送请求**的消息(`ICMP Echo Request Message`,类型 `8`),也可以接收对端主机发回来的**回送应答**消息(`ICMP Echo Reply Message`,类型 `0`)。 ![ICMP 回送请求和回送应答报文](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/ping/8.jpg) 相比原生的 ICMP,这里多了两个字段: - **标识符**:用以区分是哪个应用程序发 ICMP 包,比如用进程 `PID` 作为标识符; - **序号**:序列号从 `0` 开始,每发送一次新的回送请求就会加 `1`,可以用来确认网络包是否有丢失。 在**选项数据**中,`ping` 还会存放发送请求的时间值,来计算往返时间,说明路程的长短。 ## 差错报文类型 接下来,说明几个常用的 ICMP 差错报文的例子: - 目标不可达消息 —— 类型 为 `3` - 原点抑制消息 —— 类型 `4` - 重定向消息 —— 类型 `5` - 超时消息 —— 类型 `11` > 目标不可达消息(Destination Unreachable Message) —— 类型为 `3` IP 路由器无法将 IP 数据包发送给目标地址时,会给发送端主机返回一个**目标不可达**的 ICMP 消息,并在这个消息中显示不可达的具体原因,原因记录在 ICMP 包头的**代码**字段。 由此,根据 ICMP 不可达的具体消息,发送端主机也就可以了解此次发送**不可达的具体原因**。 举例 6 种常见的目标不可达类型的**代码**: ![目标不可达类型的常见代码号](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/ping/9.jpg) - 网络不可达代码为 `0` - 主机不可达代码为 `1` - 协议不可达代码为 `2` - 端口不可达代码为 `3` - 需要进行分片但设置了不分片位代码为 `4` 为了给大家说清楚上面的目标不可达的原因,**小林牺牲自己给大家送 5 次外卖。** 为什么要送外卖?别问,问就是为 `35` 岁的老林做准备 ... ![外卖员 —— 小林](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/ping/10.jpg) #### a. 网络不可达代码为 0 *外卖版本:* 小林第一次送外卖时,小区里只有 A 和 B 区两栋楼,但送餐地址写的是 C 区楼,小林表示头上很多问号,压根就没这个地方。 *正常版本:* IP 地址是分为网络号和主机号的,所以当路由器中的路由器表匹配不到接收方 IP 的网络号,就通过 ICMP 协议以**网络不可达**(`Network Unreachable`)的原因告知主机。 自从不再有网络分类以后,网络不可达也渐渐不再使用了。 #### b. 主机不可达代码为 1 *外卖版本:* 小林第二次送外卖时,这次小区有 5 层楼高的 C 区楼了,找到地方了,但送餐地址写的是 C 区楼 601 号房,说明找不到这个房间。 *正常版本:* 当路由表中没有该主机的信息,或者该主机没有连接到网络,那么会通过 ICMP 协议以**主机不可达**(`Host Unreachable`)的原因告知主机。 #### c. 协议不可达代码为 2 *外卖版本:* 小林第三次送外卖时,这次小区有 C 区楼,也有 601 号房,找到地方了,也找到房间了,但是一开门人家是外国人说的是英语,我说的是中文!语言不通,外卖送达失败~ *正常版本:* 当主机使用 TCP 协议访问对端主机时,能找到对端的主机了,可是对端主机的防火墙已经禁止 TCP 协议访问,那么会通过 ICMP 协议以**协议不可达**的原因告知主机。 #### d. 端口不可达代码为 3 *外卖版本:* 小林第四次送外卖时,这次小区有 C 区楼,也有 601 号房,找到地方了,也找到房间了,房间里的人也是说中文的人了,但是人家说他要的不是外卖,而是快递。。。 *正常版本:* 当主机访问对端主机 8080 端口时,这次能找到对端主机了,防火墙也没有限制,可是发现对端主机没有进程监听 8080 端口,那么会通过 ICMP 协议以**端口不可达**的原因告知主机。 #### e. 需要进行分片但设置了不分片位代码为 4 *外卖版本:* 小林第五次送外卖时,这次是个吃播博主点了 100 份外卖,但是吃播博主要求一次性要把全部外卖送达,小林的一台电动车装不下呀,这样就没办法送达了。 *正常版本:* 发送端主机发送 IP 数据报时,将 IP 首部的**分片禁止标志位**设置为`1`。根据这个标志位,途中的路由器遇到超过 MTU 大小的数据包时,不会进行分片,而是直接抛弃。 随后,通过一个 ICMP 的不可达消息类型,**代码为 4** 的报文,告知发送端主机。 > 原点抑制消息(ICMP Source Quench Message) —— 类型 `4` 在使用低速广域线路的情况下,连接 WAN 的路由器可能会遇到网络拥堵的问题。 `ICMP` 原点抑制消息的目的就是**为了缓和这种拥堵情况**。 当路由器向低速线路发送数据时,其发送队列的缓存变为零而无法发送出去时,可以向 IP 包的源地址发送一个 ICMP **原点抑制消息**。 收到这个消息的主机借此了解在整个线路的某一处发生了拥堵的情况,从而增大 IP 包的传输间隔,减少网络拥堵的情况。 然而,由于这种 ICMP 可能会引起不公平的网络通信,一般不被使用。 > 重定向消息(ICMP Redirect Message) —— 类型 `5` 如果路由器发现发送端主机使用了「不是最优」的路径发送数据,那么它会返回一个 ICMP **重定向消息**给这个主机。 在这个消息中包含了**最合适的路由信息和源数据**。这主要发生在路由器持有更好的路由信息的情况下。路由器会通过这样的 ICMP 消息告知发送端,让它下次发给另外一个路由器。 好比,小林本可以过条马路就能到的地方,但小林不知道,所以绕了一圈才到,后面小林知道后,下次小林就不会那么**傻**再绕一圈了。 > 超时消息(ICMP Time Exceeded Message) —— 类型 `11` IP 包中有一个字段叫做 `TTL` (`Time To Live`,生存周期),它的**值随着每经过一次路由器就会减 1,直到减到 0 时该 IP 包会被丢弃。** 此时,路由器将会发送一个 ICMP **超时消息**给发送端主机,并通知该包已被丢弃。 设置 IP 包生存周期的主要目的,是为了在路由控制遇到问题发生循环状况时,避免 IP 包无休止地在网络上被转发。 ![ICMP 时间超过消息](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/ping/11.jpg) 此外,有时可以用 TTL 控制包的到达范围,例如设置一个**较小的 TTL 值**。 ---- ## ping —— 查询报文类型的使用 接下来,我们重点来看 `ping` 的**发送和接收过程**。 同个子网下的主机 A 和 主机 B,主机 A 执行`ping` 主机 B 后,我们来看看其间发送了什么? ![主机 A ping 主机 B](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/ping/12.jpg) ping 命令执行的时候,源主机首先会构建一个 **ICMP 回送请求消息**数据包。 ICMP 数据包内包含多个字段,最重要的是两个: - 第一个是**类型**,对于回送请求消息而言该字段为 `8`; - 另外一个是**序号**,主要用于区分连续 ping 的时候发出的多个数据包。 每发出一个请求数据包,序号会自动加 `1`。为了能够计算往返时间 `RTT`,它会在报文的数据部分插入发送时间。 ![主机 A 的 ICMP 回送请求报文](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/ping/13.jpg) 然后,由 ICMP 协议将这个数据包连同地址 192.168.1.2 一起交给 IP 层。IP 层将以 192.168.1.2 作为**目的地址**,本机 IP 地址作为**源地址**,**协议**字段设置为 `1` 表示是 `ICMP` 协议,再加上一些其他控制信息,构建一个 `IP` 数据包。 ![主机 A 的 IP 层数据包](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/ping/14.jpg) 接下来,需要加入 `MAC` 头。如果在本地 ARP 映射表中查找出 IP 地址 192.168.1.2 所对应的 MAC 地址,则可以直接使用;如果没有,则需要发送 `ARP` 协议查询 MAC 地址,获得 MAC 地址后,由数据链路层构建一个数据帧,目的地址是 IP 层传过来的 MAC 地址,源地址则是本机的 MAC 地址;还要附加上一些控制信息,依据以太网的介质访问规则,将它们传送出去。 ![主机 A 的 MAC 层数据包](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/ping/15.jpg) 主机 `B` 收到这个数据帧后,先检查它的目的 MAC 地址,并和本机的 MAC 地址对比,如符合,则接收,否则就丢弃。 接收后检查该数据帧,将 IP 数据包从帧中提取出来,交给本机的 IP 层。同样,IP 层检查后,将有用的信息提取后交给 ICMP 协议。 主机 `B` 会构建一个 **ICMP 回送响应消息**数据包,回送响应数据包的**类型**字段为 `0`,**序号**为接收到的请求数据包中的序号,然后再发送出去给主机 A。 ![主机 B 的 ICMP 回送响应报文](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/ping/16.jpg) 在规定的时候间内,源主机如果没有接到 ICMP 的应答包,则说明目标主机不可达;如果接收到了 ICMP 回送响应消息,则说明目标主机可达。 此时,源主机会检查,用当前时刻减去该数据包最初从源主机上发出的时刻,就是 ICMP 数据包的时间延迟。 针对上面发送的事情,总结成了如下图: ![主机 A ping 主机 B 期间发送的事情](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/ping/17.png) 当然这只是最简单的,同一个局域网里面的情况。如果跨网段的话,还会涉及网关的转发、路由器的转发等等。 但是对于 ICMP 的头来讲,是没什么影响的。会影响的是根据目标 IP 地址,选择路由的下一跳,还有每经过一个路由器到达一个新的局域网,需要换 MAC 头里面的 MAC 地址。 说了这么多,可以看出 ping 这个程序是**使用了 ICMP 里面的 ECHO REQUEST(类型为 8)和 ECHO REPLY(类型为 0)**。 ## traceroute —— 差错报文类型的使用 有一款充分利用 ICMP **差错报文类型**的应用叫做 `traceroute`(在 UNIX、MacOS 中是这个命令,而在 Windows 中对等的命令叫做 tracert)。 *1. traceroute 作用一* traceroute 的第一个作用就是**故意设置特殊的 TTL,来追踪去往目的地时沿途经过的路由器。** traceroute 的参数指向某个**目的 IP 地址**: ```bash traceroute 192.168.1.100 ``` > 这个作用是如何工作的呢? 它的原理就是利用 IP 包的**生存期限** 从 `1` 开始按照顺序递增的同时发送 **UDP 包**,强制接收 **ICMP 超时消息**的一种方法。 比如,将 TTL 设置 为 `1`,则遇到第一个路由器,就牺牲了,接着返回 ICMP 差错报文网络包,类型是**时间超时**。 接下来将 TTL 设置为 `2`,第一个路由器过了,遇到第二个路由器也牺牲了,也同时返回了 ICMP 差错报文数据包,如此往复,直到到达目的主机。 这样的过程,traceroute 就可以拿到了所有的路由器 IP。 当然有的路由器根本就不会返回这个 ICMP,所以对于有的公网地址,是看不到中间经过的路由的。 > 发送方如何知道发出的 UDP 包是否到达了目的主机呢? traceroute 在发送 `UDP` 包时,会填入一个**不可能的端口号**值作为 UDP 目标端口号(大于 `3000` )。当目的主机,收到 UDP 包后,会返回 ICMP 差错报文消息,但这个差错报文消息的类型是「**端口不可达**」。 所以,**当差错报文类型是端口不可达时,说明发送方发出的 UDP 包到达了目的主机。** *2. traceroute 作用二* traceroute 还有一个作用是**故意设置不分片,从而确定路径的 MTU**。 > 这么做是为了什么? 这样做的目的是为了**路径 MTU 发现**。 因为有的时候我们并不知道路由器的 `MTU` 大小,以太网的数据链路上的 `MTU` 通常是 `1500` 字节,但是非以太网的 `MTU` 值就不一样了,所以我们要知道 `MTU` 的大小,从而控制发送的包大小。 ![MTU 路径发现(UDP 的情况下)](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/ping/18.jpg) 它的工作原理如下: 首先在发送端主机发送 `IP` 数据报时,将 `IP` 包首部的**分片禁止标志位设置为 1**。根据这个标志位,途中的路由器不会对大数据包进行分片,而是将包丢弃。 随后,通过一个 ICMP 的不可达消息将**数据链路上 MTU 的值**一起给发送主机,不可达消息的类型为「**需要进行分片但设置了不分片位**」。 发送主机端每次收到 ICMP 差错报文时就**减少**包的大小,以此来定位一个合适的 `MTU` 值,以便能到达目标主机。 ---- 参考资料: [1] 竹下隆史。图解 TCP/IP.人民邮电出版社。 [2] 刘超。趣谈网络协议。极客时间。 --- ## 读者问答 > 读者问:“有个问题就是 A 的 icmp 到了 B 后,B 为啥会自动给 A 一个回执 0?这是操作系统的底层设计吗?” 你说的“回执 0”是指 ICMP 类型为 0 吗?如果是的话,那么 B 收到 A 的回送请求(类型为 8)ICMP 报文,B 主机操作系统协议栈发现是个回送请求 ICMP 报文,那么协议栈就会组装一个回送应答(类型为 0)的 IMCP 回应给 A。 ---- 哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,别忘记关注我哦! ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/4_ip/ping_lo.md ================================================ # 5.3 断网了,还能 ping 通 127.0.0.1 吗? > 来源:公众号@小白 debug > > 原文地址:[断网了,还能 ping 通 127.0.0.1 吗?](https://mp.weixin.qq.com/s/qqfnyw4wKFjJqnV1eoRDhw) 你**女神爱不爱你**,你问她,她可能不会告诉你。 但**网通不通**,你 `ping` 一下就知道了。 可能看到标题,你就知道答案了,但是你了解背后的原因吗? 那如果把 `127.0.0.1` 换成 `0.0.0.0` 或 `localhost` 会怎么样呢?你知道这几个`IP`有什么区别吗? 以前面试的时候就遇到过这个问题,大家看个动图了解下面试官和我当时的场景,求当时我的心里阴影面积。 ![图片](https://img-blog.csdnimg.cn/img_convert/c3a97f36607c0e7ef6805bfba482a060.gif) 话不多说,我们直接开车。 拔掉网线,断网。 ![图片](https://img-blog.csdnimg.cn/img_convert/5137bc7bce08dc60cfa2f8152b738dfd.jpeg) 然后在控制台输入`ping 127.0.0.1`。 ```shell $ ping 127.0.0.1 PING 127.0.0.1 (127.0.0.1): 56 data bytes 64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.080 ms 64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.093 ms 64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.074 ms 64 bytes from 127.0.0.1: icmp_seq=3 ttl=64 time=0.079 ms 64 bytes from 127.0.0.1: icmp_seq=4 ttl=64 time=0.079 ms ^C --- 127.0.0.1 ping statistics --- 5 packets transmitted, 5 packets received, 0.0% packet loss round-trip min/avg/max/stddev = 0.074/0.081/0.093/0.006 ms ``` 说明,拔了网线,`ping 127.0.0.1` 是**能 ping 通的**。 其实这篇文章看到这里,标题前半个问题已经被回答了。但是我们可以再想深一点。 为什么断网了还能 `ping` 通 `127.0.0.1` 呢? **这能说明你不用交网费就能上网吗?** **不能。** 首先我们需要进入基础科普环节。 不懂的同学看了就懂了,懂的看了就当查漏补缺吧。 ## 什么是 127.0.0.1 首先,这是个 `IPV4` 地址。 `IPV4` 地址有 `32` 位,一个字节有 `8` 位,共 `4` 个字节。 其中**127 开头的都属于回环地址**,也是 `IPV4` 的特殊地址,没什么道理,就是人为规定的。 而`127.0.0.1`是**众多**回环地址中的一个。之所以不是 `127.0.0.2` ,而是 `127.0.0.1`,是因为源码里就是这么定义的,也没什么道理。 ```c /* Address to loopback in software to local host. */ #define INADDR_LOOPBACK 0x7f000001 /* 127.0.0.1 */ ``` ![图片](https://img-blog.csdnimg.cn/img_convert/fa904fbcf66cc7abf510a8dc16f867fa.png) `IPv4` 的地址是 `32` 位的,2 的 32 次方,大概是`40+亿`。地球光人口就 76 亿了,40 亿 IP 这点量,**塞牙缝都不够**,实际上**IP 也确实用完**了。 所以就有了`IPV6`, `IPv6` 的地址是 `128` 位的,大概是 2 的 128 次方≈**10 的 38 次方**。据说地球的沙子数量大概是 **10 的 23 次方**,所以 IPV6 的 IP 可以认为用不完。 IPV4 以 8 位一组,每组之间用 **.** 号隔开。 IPV6 就以 16 位为一组,每组之间用 **:** 号隔开。如果全是 0,那么可以省略不写。 ![图片](https://img-blog.csdnimg.cn/img_convert/e841adeeecf9451e1aca296d5c7a7f30.png) 在 IPV4 下的回环地址是 `127.0.0.1`,在`IPV6`下,表达为 `::1` 。中间把**连续的 0**给省略了,之所以不是**7 个 冒号**,而是**2 个冒号:** ,是因为一个 IPV6 地址中**只允许出现⼀次两个连续的冒号**。 > 多说一句:在 IPV4 下用的是 **ping 127.0.0.1** 命令。在 IPV6 下用的是 **ping6 ::1** 命令。 ## 什么是 ping ping 是应用层命令,可以理解为它跟游戏或者聊天软件属于同一层。只不过聊天软件可以收发消息,还能点个赞什么的,有很多复杂的功能。而 ping 作为一个小软件,它的功能比较简单,就是**尝试**发送一个小小的消息到目标机器上,判断目的机器是否**可达**,其实也就是判断目标机器网络是否能连通。 ping 应用的底层,用的是网络层的**ICMP 协议**。 IP 和 ICMP 和 Ping 所在分层 虽然 ICMP 协议和 IP 协议**都属于网络层协议**,但其实**ICMP 也是利用了 IP 协议进行消息的传输**。 ![图片](https://img-blog.csdnimg.cn/img_convert/8e0aba146432baeb407ab445292c8019.png) 所以,大家在这里完全可以简单的理解为 ping 某个 IP 就是往某个 IP 地址发个消息。 ## TCP 发数据和 ping 的区别 一般情况下,我们会使用 TCP 进行网络数据传输,那么我们可以看下它和 ping 的区别。 *PS:下图中有一处画错了,右边是 tcp 数据,而不是 ping 数据,我偷懒就不重画了*。 ![图片](https://img-blog.csdnimg.cn/img_convert/eb0963a11439dff361dbe0e7a8876abd.png) ping 和其他应用层软件都属于**应用层**。 那么我们横向对比一下,比方说聊天软件,如果用的是 TCP 的方式去发送消息。 为了发送消息,那就得先知道往哪发。linux 里万物皆文件,那你要发消息的目的地,也是个文件,这里就引出了 socket 的概念。 要使用 `socket` , 那么首先需要创建它。 在 TCP 传输中创建的方式是 `socket(AF_INET, SOCK_STREAM, 0);`,其中 `AF_INET` 表示将使用 IPV4 里 **host:port** 的方式去解析待会你输入的网络地址。`SOCK_STREAM` 是指使用面向字节流的 TCP 协议,**工作在传输层**。 创建好了 `socket` 之后,就可以愉快的把要传输的数据写到这个文件里。调用 socket 的`sendto`接口的过程中进程会从**用户态进入到内核态**,最后会调用到 `sock_sendmsg` 方法。 然后进入传输层,带上`TCP`头。网络层带上`IP`头,数据链路层带上 `MAC`头等一系列操作后。进入网卡的**发送队列 ring buffer** ,顺着网卡就发出去了。 回到 `ping` ,整个过程也基本跟 `TCP` 发数据类似,差异的地方主要在于,创建 `socket` 的时候用的是 `socket(AF_INET,SOCK_RAW,IPPROTO_ICMP)`,`SOCK_RAW` 是原始套接字,**工作在网络层**,所以构建`ICMP`(网络层协议)的数据,是再合适不过了。ping 在进入内核态后最后也是调用的 `sock_sendmsg` 方法,进入到网络层后加上**ICMP 和 IP 头**后,数据链路层加上**MAC 头**,也是顺着网卡发出。因此 本质上 ping 跟 普通应用发消息 在程序流程上没太大差别。 这也解释了**为什么当你发现怀疑网络有问题的时候,别人第一时间是问你能 ping 通吗?** 因为可以简单理解为 ping 就是自己组了个数据包,让系统按着其他软件发送数据的路径往外发一遍,能通的话说明其他软件发的数据也能通。 ## 为什么断网了还能 ping 通 127.0.0.1 前面提到,有网的情况下,ping 最后是**通过网卡**将数据发送出去的。 那么断网的情况下,网卡已经不工作了,ping 回环地址却一切正常,我们可以看下这种情况下的工作原理。 ![图片](https://img-blog.csdnimg.cn/img_convert/c1019a8be584b27c4fc8b8abda9d3cf1.png) 从应用层到传输层再到网络层。这段路径跟 ping 外网的时候是几乎是一样的。到了网络层,系统会根据目的 IP,在路由表中获取对应的**路由信息**,而这其中就包含选择**哪个网卡**把消息发出。 当发现**目标 IP 是外网 IP**时,会从"真网卡"发出。 当发现**目标 IP 是回环地址**时,就会选择**本地网卡**。 本地网卡,其实就是个 **"** **假网卡** **"**,它不像"真网卡"那样有个`ring buffer`什么的,"假网卡"会把数据推到一个叫 `input_pkt_queue` 的 **链表** 中。这个链表,其实是所有网卡共享的,上面挂着发给本机的各种消息。消息被发送到这个链表后,会再触发一个**软中断**。 专门处理软中断的工具人 **"** **ksoftirqd** **"**(这是个**内核线程**),它在收到软中断后就会立马去链表里把消息取出,然后顺着数据链路层、网络层等层层往上传递最后给到应用程序。 ![图片](https://img-blog.csdnimg.cn/img_convert/a207c14a5416f44e9dbf0fe0a41179e4.png) ping 回环地址和**通过 TCP 等各种协议发送数据到回环地址**都是走这条路径。整条路径从发到收,都没有经过"真网卡"。**之所以 127.0.0.1 叫本地回环地址,可以理解为,消息发出到这个地址上的话,就不会出网络,在本机打个转就又回来了。** 所以断网,依然能 `ping` 通 `127.0.0.1`。 ## ping 回环地址和 ping 本机地址有什么区别 我们在 mac 里执行 `ifconfig` 。 ```shell $ ifconfig lo0: flags=8049 mtu 16384 inet 127.0.0.1 netmask 0xff000000 ... en0: flags=8863 mtu 1500 inet 192.168.31.6 netmask 0xffffff00 broadcast 192.168.31.255 ... ``` 能看到 **lo0**,表示本地回环接口,对应的地址,就是我们前面提到的 **127.0.0.1** ,也就是**回环地址**。 和 **eth0**,表示本机第一块网卡,对应的 IP 地址是**192.168.31.6**,管它叫**本机 IP**。 之前一直认为 ping 本机 IP 的话会通过"真网卡"出去,然后遇到第一个路由器,再发回来到本机。 为了验证这个说法,可以进行抓包,但结果跟上面的说法并不相同。 ping 127.0.0.1: ![图片](https://img-blog.csdnimg.cn/img_convert/bc2765b1d6d3e37a5663f98085198926.png) ping 本机地址: ![图片](https://img-blog.csdnimg.cn/img_convert/50cd584f9f82aee8d3d9bfaf7d910cb8.png) 可以看到 ping 本机 IP 跟 ping 回环地址一样,相关的网络数据,都是走的 **lo0**,本地回环接口,也就是前面提到的**"假网卡"**。 只要走了本地回环接口,那数据都不会发送到网络中,在本机网络协议栈中兜一圈,就发回来了。因此 **ping 回环地址和 ping 本机地址没有区别**。 ## 127.0.0.1 和 localhost 以及 0.0.0.0 有区别吗 回到文章开头动图里的提问,算是面试八股文里的老常客了。 以前第一次用 `nginx` 的时候,发现用这几个 `IP`,都能正常访问到 `nginx` 的欢迎网页。一度认为这几个 `IP` 都是一样的。 访问 127.0.0.1:80 ![图片](https://img-blog.csdnimg.cn/img_convert/12e13316a18009ce8b2983846819e270.png) 访问 localhost:80 ![图片](https://img-blog.csdnimg.cn/img_convert/2c35f573e91e94733d009384a4657859.png) 访问 0.0.0.0:80 ![图片](https://img-blog.csdnimg.cn/img_convert/ba534fdc5f21b3ab26d0b8c890bb02c3.png) 访问本机的 IP 地址 ![图片](https://img-blog.csdnimg.cn/img_convert/9b31572ced19805fab02a23b22819b92.png) 但本质上还是有些区别的。 首先 `localhost` 就不叫 `IP`,它是一个域名,就跟 `"baidu.com"`,是一个形式的东西,只不过默认会把它解析为 `127.0.0.1` ,当然这可以在 `/etc/hosts` 文件下进行修改。 所以默认情况下,使用 `localhost` 跟使用 `127.0.0.1` 确实是没区别的。 其次就是 `0.0.0.0`,执行 ping 0.0.0.0,是会失败的,因为它在`IPV4`中表示的是无效的**目标地址**。 ```shell $ ping 0.0.0.0 PING 0.0.0.0 (0.0.0.0): 56 data bytes ping: sendto: No route to host ping: sendto: No route to host ``` 但它还是很有用处的,回想下,我们启动服务器的时候,一般会 `listen` 一个 IP 和端口,等待客户端的连接。 如果此时 `listen` 的是本机的 `0.0.0.0` , 那么它表示本机上的**所有 IPV4 地址**。 ```c /* Address to accept any incoming messages. */ #define INADDR_ANY ((unsigned long int) 0x00000000) /* 0.0.0.0 */ ``` 举个例子。刚刚提到的 `127.0.0.1` 和 `192.168.31.6` ,都是本机的 IPV4 地址,如果监听 `0.0.0.0` ,那么用上面两个地址,都能访问到这个服务器。 当然,客户端 `connect` 时,不能使用 `0.0.0.0` 。必须指明要连接哪个服务器 IP。 ## 总结 - `127.0.0.1` 是**回环地址**。`localhost`是**域名**,但默认等于 `127.0.0.1`。 - `ping` 回环地址和 `ping` 本机地址,是一样的,走的是**lo0 "假网卡"**,都会经过网络层和数据链路层等逻辑,最后在快要出网卡前**狠狠拐了个弯**,将数据插入到一个**链表**后就**软中断**通知 **ksoftirqd** 来进行**收数据**的逻辑,**压根就不出网络**。所以断网了也能 `ping` 通回环地址。 - 如果服务器 `listen` 的是 `0.0.0.0`,那么此时用`127.0.0.1`和本机地址**都可以**访问到服务。 --- 哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,别忘记关注我哦! ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/5_learn/draw.md ================================================ # 6.2 画图经验分享 小林写这么多篇图解文章,你们猜我收到的最多的读者问题是什么?没错,就是问我是使用什么**画图**工具,看来对这一点大家都相当好奇,那干脆不如写一篇介绍下我是怎么画图的。 如果我的文章缺少了自己画的图片,相当于失去了灵魂,技术文章本身就很枯燥,如果文章中没有几张图片,读者被劝退的概率飙飙升,剩下没被劝退的估计看着看着就睡着了。所以,精美的图片可以说是必不可少的一部分,不仅在阅读时能带来视觉的冲击,而且图片相比文字能涵盖更多的信息,不然怎会有一图胜千言的说法呢? 这时,可能有的读者会说自己不写文章呀,是不是没有必要了解画图了?我觉得这是不对,画图在我们工作中其实也是有帮助的,比如如果你想跟领导汇报一个业务流程的问题,把业务流程画出来,肯定用图的方式比用文字的方式交流起来会更有效率,更轻松些;如果你参与了一个比较复杂的项目开发,你也可以把代码的流程图给画出来,不仅能帮助自己加深理解,也能帮助后面参与的同事能更快的接手这个项目;甚至如果你要晋升级别了,演讲 PTT 里的配图也是必不可少的。 不过很多人都是纠结用什么画图工具,其实小林觉得再烂的画图工具,只要你思路清晰,确定自己要表达出什么信息,也是能把图画好的,所以不必纠结哪款画图工具,挑一款自己画起来舒服的就行了。 > “小林,你说的我都懂,我就是喜欢你的画图风格嘛,你就说说你用啥画的?” 咳咳,没问题,直接坦白讲,我用的是一个在线的画图网址,地址是: - *https://draw.io* 用它的原因是使用方便和简单,当然最重要的是它完全免费,没有什么限制,甚至还能直接把图片保存到 GoogleDrive、OneDrive 和 Github,我就是保存到 Github,然后用 Github 作为我的图床。 既然要认识它,那就先来看看它长什么样子,它主要分为三个区域,从左往右的顺序是「图形选择区域、绘图区域、属性设置区域」。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/正面图.png) 其中,最左边的「图形选择区域」可以选择的图案有很多种,常见的流程图、时序图、表格图都有,甚至还可以在最左下角的「更多图形」找到其他种类的图形,比如网络设备图标等。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/图形选择.png) 再来,最右边「属性设置区域」可以设置文字的大小,图片颜色、线条形状等,而我最常用颜色板块是下面这三种,都是比较浅色的,这样看起来舒服些。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/浅色风格2.png) ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/深浅色风格.png) 我最近常用的一个图形是圆角方块图,它的位置如下图,但是它默认的颜色过于深色,如果要在方框图中描述文字,则可能看不清楚,这时我会在最右侧的「属性设置区域」把方块颜色设置成浅色系列的。另外,还有一点需要注意的是,默认的字体大小比较小,我一般会调成 `16px` 大小。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/常用的方块.png) 如果你不喜欢上图的带有「划痕」的圆角方块图形,可以选择下图中这个最简洁的圆角方框图形。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/圆角方块图形.png) 这个简洁的圆角方框图形,再搭配颜色,能组合成很多结构图,比如我用过它组成过 CPU Cache 的结构图。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/操作系统/存储结构/CPU-Cache.png) 那直角方框图形,我主要是用来组成「表格」,原因自带的表格不好看,也不方便调。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/表格.png) 如果觉得直直的线条太死板,你可以把图片属性中的「*Comic*」勾上,于是就会变成歪歪扭扭的效果啦,有点像手绘风格,挺多人喜欢这种风格。 比如,我用过这种风格画过 TCP 三次握手流程的图。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E4%B8%89%E6%AC%A1%E6%8F%A1%E6%89%8B%E5%92%8C%E5%9B%9B%E6%AC%A1%E6%8C%A5%E6%89%8B/14.jpg) 方块图形再加上菱形,就可以组合成简单程序流程图了,比如我画过存储器缓存流程图。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/操作系统/存储结构/缓存体系.png) 所以,不要小看这些基本图形,只要构思清晰,再基本的图形,也是能构成层次分明并且好看的图。 基本的图形介绍完后,相信你画一些简单程序流程图等图形是没问题的了,接下来就是各种**图形 + 线条**的组合的了。 通过一些基本的图形组合,你还可以画出时序图,时序图可以用来描述多个对象之间的交互流程,比如我画过多个线程获取互斥锁的时序图。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/操作系统/锁/互斥锁工作流程.png) 再来,为了更好表达零拷贝技术的过程,那么用图的方式会更清晰。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E9%9B%B6%E6%8B%B7%E8%B4%9D/senfile-%E9%9B%B6%E6%8B%B7%E8%B4%9D.png) 前面也提到,图形不只是简单图形,还有其他自带的设备类图形,比如我用网络设备图画过单播、广播、多播通信的区别图。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/13.jpg) 你要说,我画过最复杂的图,那就是写 TCP 流量控制的时候,把整个交互过程 + 文字描述 + 滑动窗口状况都画出来了,现在回想起来还是觉得累人。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/22.jpg) 还有好多好多,我就比一一列举,这半年下来,小林至少画了 `500+` 张图了,每一张图其实还是挺费时间的,相信画过图的朋友后,都能体会到这种感觉了。但没办法,谁叫小林是图解工具人呢,画图可以更好的诠释文章内容,但最重要的是,把你们吸引过来了,这是件让我非常高兴的事情,也是让我感觉画图这个事情值得认真做。 另外,细心的读者也发现了,小林贴代码的时候,使用的是图片的形式,原因是代码通常都是比较长,在手机看文章用图片的呈现的方式会更舒服清晰。 在这里也推荐下这个代码截图网址: - *https://carbon.now.sh/* 网站页面如下图,代码显示的效果是不是很美观? ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/carbon.png) 文字的分享有局限性,关键还是要你自己动手摸索摸索,形成自己一套画图的方法论,练习的时候可以先从模仿画起,后面再结合工作或文章的需求画出自己心中的那个图。 ================================================ FILE: network/5_learn/learn_network.md ================================================ # 6.1 计算机网络怎么学? 计算机网络相比操作系统好学非常多,因为计算机网络不抽象,你要想知道网络中的细节,你都可以通过抓包来分析,而且不管是手机、个人电脑和服务器,它们所使用的计算网络协议是一致的。 也就是说,计算机网络不会因为设备的不同而不同,大家都遵循这一套「规则」来相互通信,这套规则就是 TCP/IP 网络模型。 ![OSI 参考模型与 TCP/IP 的关系](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E4%B8%89%E6%AC%A1%E6%8F%A1%E6%89%8B%E5%92%8C%E5%9B%9B%E6%AC%A1%E6%8C%A5%E6%89%8B/7.jpg) TCP/IP 网络参考模型共有 `4` 层,其中需要我们熟练掌握的是应用层、传输层和网络层,至于网络接口层(数据链路层和物理层)我们只需要做简单的了解就可以了。 对于应用层,当然重点要熟悉最常见的 [HTTP 和 HTTPS](https://mp.weixin.qq.com/s/bUy220-ect00N4gnO0697A),传输层 TCP 和 UDP 都要熟悉,网络层要熟悉 [IPv4](https://mp.weixin.qq.com/s/bUy220-ect00N4gnO0697A),IPv6 可以做简单点了解。 我觉得学习一个东西,就从我们常见的事情开始着手。 比如,ping 命令可以说在我们判断网络环境的时候,最常使用的了,你可以先把你电脑 ping 你舍友或同事的电脑的过程中发生的事情都搞明白,这样就基本知道一个数据包是怎么转发的了,于是你就知道了网络层、数据链路层和物理层之间是如何工作,如何相互配合的了。 搞明白了 ping 过程,我相信你学起 HTTP 请求过程的时候,会很快就能掌握了,因为网络层以下的工作方式,你在学习 ping 的时候就已经明白了,这时就只需要认真掌握传输层中的 TCP 和应用层中的 HTTP 协议,就能搞明白[访问网页的整个过程](https://mp.weixin.qq.com/s/iSZp41SRmh5b2bXIvzemIw)了,这也是面试常见的题目了,毕竟它能考察你网络知识的全面性。 重中之重的知识就是 TCP 了,TCP 不管是[建立连接、断开连接](https://mp.weixin.qq.com/s/tH8RFmjrveOmgLvk9hmrkw)的过程,还是数据传输的过程,都不能放过,针对数据可靠传输的特性,又可以拆解为[超时重传、流量控制、滑动窗口、拥塞控制](https://mp.weixin.qq.com/s/Tc09ovdNacOtnMOMeRc_uA)等等知识点,学完这些只能算对 TCP 有个「**感性**」的认识,另外我们还得知道 Linux 提供的 [TCP 内核的参数](https://mp.weixin.qq.com/s/fjnChU3MKNc_x-Wk7evLhg)的作用,这样才能从容地应对工作中遇到的问题。 接下来,推荐我看过并觉得不错的计算机网络相关的书籍和视频。 ## 入门系列 此系列针对没有任何计算机基础的朋友,如果已经对计算机轻车熟路的大佬,也不要忽略,不妨看看我推荐的正确吗。 如果你要入门 HTTP,首先最好书籍就是《**图解 HTTP**》了,作者真的做到完完全全的「图解」,小林的图解功夫还是从这里偷学到不少,书籍不厚,相信优秀的你,几天就可以看完了。 ![《图解 HTTP》](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/其他/图解HTTP.jpg) 如果要入门 TCP/IP 网络模型,我推荐的是《**图解 TCP/IP**》,这本书也是以大量的图文来介绍了 TCP/IP 网络模式的每一层,但是这个书籍的顺序不是从「应用层 —> 物理层」,而是从「物理层 -> 应用层」顺序开始讲的,这一点我觉得不太好,这样一上来就把最枯燥的部分讲了,很容易就被劝退了,所以我建议先跳过前面几个章节,先看网络层和传输层的章节,然后再回头看前面的这几个章节。 ![《图解 TCP/IP》](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%9B%BE%E8%A7%A3TCPIP.png) 另外,你想了解网络是怎么传输,那我推荐《**网络是怎样连接的**》,这本书相对比较全面的把访问一个网页的发生的过程讲解了一遍,其中关于电信等运营商是怎么传输的,这部分你可以跳过,当然你感兴趣也可以看,只是我觉得没必要看。 ![《网络是怎样连接的》](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E7%BD%91%E7%BB%9C%E6%98%AF%E6%80%8E%E4%B9%88%E8%BF%9E%E6%8E%A5%E7%9A%84.png) 如果你觉得书籍过于枯燥,你可以结合 B 站《**计算机网络微课堂**》视频一起学习,这个视频是湖南科技大学老师制作的,PPT 的动图是我见过做的最用心的了,一看就懂的佳作。 ![《计算机网络微课堂》](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/其他/计算机网络微课堂.png) > B 站视频地址:https://www.bilibili.com/video/BV1c4411d7jb?p=1 ## 深入学习系列 看完入门系列,相信你对计算机网络已经有个大体的认识了,接下来我们也不能放慢脚步,快马加鞭,借此机会继续深入学习,因为隐藏在背后的细节还是很多的。 对于 TCP/IP 网络模型深入学习的话,推荐《**计算机网络 - 自顶向下方法**》,这本书是从我们最熟悉 HTTP 开始说起,一层一层的说到最后物理层的,有种挖地洞的感觉,这样的内容编排顺序相对是比较合理的。 ![《计算机网络 - 自顶向下方法》](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/其他/计算机网络自定向下.png) 但如果要深入 TCP,前面的这些书还远远不够,赋有计算机网络圣经的之说的《**TCP/IP 详解 卷一:协议**》这本书,是进一步深入学习的好资料,这本书的作者用各种实验的方式来细说各种协议,但不得不说,这本书真的很枯燥,当时我也啃的很难受,但是它质量是真的很高,这本书我只看了 TCP 部分,其他部分你可以选择性看,但是你一定要过几遍这本书的 TCP 部分,涵盖的内容非常全且细。 ![《TCP/IP 详解 卷一:协议》](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/TCPIP%E5%8D%8F%E8%AE%AE%E8%AF%A6%E8%A7%A3.png) 要说我看过最好的 TCP 资料,那必定是《**The TCP/IP GUIDE**》这本书了,目前只有英文版本的,而且有个专门的网址可以白嫖看这本书的内容,图片都是彩色,看起来很舒服很鲜明,小林之前写的 TCP 文章不少案例和图片都是参考这里的,这本书精华部分就是把 TCP 滑动窗口和流量控制说的超级明白,很可惜拥塞控制部分说的不多。 ![《The TCP/IP GUIDE》](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/TCPIP%20GUIDE.png) > 白嫖站点:http://www.tcpipguide.com/free/t_TCPSlidingWindowAcknowledgmentSystemForDataTranspo-6.htm 当然,计算机网络最牛逼的资料,那必定 **RFC 文档**,它可以称为计算机网络世界的「法规」,也是最新、最权威和最正确的地方了,困惑大家的 TCP 为什么三次握手和四次挥手,其实在 RFC 文档几句话就说明白了。 > TCP 协议的 RFC 文档:https://datatracker.ietf.org/doc/rfc1644/ ## 实战系列 在学习书籍资料的时候,不管是 TCP、UDP、ICMP、DNS、HTTP、HTTPS 等协议,最好都可以亲手尝试抓数据报,接着可以用 [Wireshark 工具](https://mp.weixin.qq.com/s/bHZ2_hgNQTKFZpWMCfUH9A)看每一个数据报文的信息,这样你会觉得计算机网络没有想象中那么抽象了,因为它们被你「抓」出来了,并毫无保留地显现在你面前了,于是你就可以肆无忌惮地「扒开」它们,看清它们每一个头信息。 那在这里,我也给你推荐 2 本关于 Wireshark 网络分析的书,这两本书都是同一个作者,书中的案例都是源于作者工作中的实际的案例,作者的文笔相当有趣,看起来堪比小说一样爽,相信你不用一个星期 2 本都能看完了。 ![《Wireshark 网络分析就这么简单》与《Wireshark 网络分析的艺术》](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/其他/wireshark书.png) ## 最后 文中推荐的书,小林都已经把电子书整理好给大家了,只需要在小林的公众号后台回复「**我要学习**」,即可获取百度网盘下载链接。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: network/README.md ================================================ # 图解网络介绍 大家好,我是小林,是《图解网络》的作者,本站的内容都是整理于我[公众号](https://mp.weixin.qq.com/s/FYH1I8CRsuXDSybSGY_AFA)里的图解文章。 还没关注的朋友,可以微信搜索「**小林 coding**」,关注我的公众号,**后续最新版本的 PDF 会在我的公众号第一时间发布**,而且会有更多其他系列的图解文章,比如操作系统、计算机组成、数据库、算法等等。 简单介绍下《图解网络》,整个内容共有 **`20W` 字 + `500` 张图**,每一篇都自己手绘了很多图,目的也很简单,击破大家对于「八股文」的恐惧。 ## 适合什么群体? 《图解网络》写的网络知识主要是**面向程序员**的,因为小林本身也是个程序员,所以涉及到的知识主要是关于程序员日常工作或者面试的网络知识。 非常适合有一点网络基础,但是又不怎么扎实,或者知识点串不起来的同学,说白**这本图解网络就是为了拯救半桶水的同学而出来的**。 因为小林写的图解网络就四个字,**通俗易懂**! 相信你在看这本图解网络的时候,你心里的感受会是: - 「卧槽,原来是这样,大学老师教知识原来是这么理解」 - 「卧槽,我的网络知识串起来了」 - 「卧槽,我感觉面试稳了」 - 「卧槽,相见恨晚」 当然,也适合面试突击网络知识时拿来看。图解网络里的内容基本是面试常见的协议,比如 HTTP、HTTPS、TCP、UDP、IP 等等,也有很多面试常问的问题,比如: - TCP 为什么三次握手?四次挥手? - TCP 为什么要有 TIME_WAIT 状态? - TCP 为什么是可靠传输协议,而 UDP 不是? - 键入网址到网页显示,期间发生了什么? - HTTPS 握手过程是怎样的? - ……. 不敢说 100 % 涵盖了面试的网络问题,但是至少 90% 是有的,而且内容的深度应对大厂也是绰绰有余,有非常多的读者跑来感激小林的图解网络,帮助他们拿到了国内很多一线大厂的 offer。 ## 要怎么阅读? 很诚恳的告诉你,《图解网络》不是教科书,而是我写的图解网络文章的整合,所以肯定是没有教科书那么细致和全面,当然也就不会有很多废话,都是直击重点,不绕弯,而且有的知识点书上看不到。 阅读的顺序可以不用从头读到尾,你可以根据你想要了解的知识点,通过本站的搜索功能,去看哪个章节的内容就好,可以随意阅读任何章节。 本站的左侧边拦就是《图解网络》的目录结构(别看篇章不多,每一章都是很长很长的文章哦 :laughing:): - **网络基础篇** :point_down: - [TCP/IP 网络模型有哪几层?](/network/1_base/tcp_ip_model.md) - [键入网址到网页显示,期间发生了什么?](/network/1_base/what_happen_url.md) - [Linux 系统是如何收发网络包的?](/network/1_base/how_os_deal_network_package.md) - **HTTP 篇** :point_down: - [HTTP 常见面试题](/network/2_http/http_interview.md) - [HTTP/1.1 如何优化?](/network/2_http/http_optimize.md) - [HTTPS RSA 握手解析](/network/2_http/https_rsa.md) - [HTTPS ECDHE 握手解析](/network/2_http/https_ecdhe.md) - [HTTPS 如何优化?](/network/2_http/https_optimize.md) - [HTTP/2 牛逼在哪?](/network/2_http/http2.md) - [HTTP/3 强势来袭](/network/2_http/http3.md) - [既然有 HTTP 协议,为什么还要有 RPC?](/network/2_http/http_rpc.md) - [既然有 HTTP 协议,为什么还要有 websocket?](/network/2_http/http_websocket.md) - **TCP 篇** :point_down: - [TCP 三次握手与四次挥手面试题](/network/3_tcp/tcp_interview.md) - [TCP 重传、滑动窗口、流量控制、拥塞控制](/network/3_tcp/tcp_feature.md) - [TCP 实战抓包分析](/network/3_tcp/tcp_tcpdump.md) - [TCP 半连接队列和全连接队列](/network/3_tcp/tcp_queue.md) - [如何优化 TCP?](/network/3_tcp/tcp_optimize.md) - [如何理解是 TCP 面向字节流协议?](/network/3_tcp/tcp_stream.md) - [为什么 TCP 每次建立连接时,初始化序列号都要不一样呢?](/network/3_tcp/isn_deff.md) - [SYN 报文什么时候情况下会被丢弃?](/network/3_tcp/syn_drop.md) - [四次挥手中收到乱序的 FIN 包会如何处理?](/network/3_tcp/out_of_order_fin.md) - [在 TIME_WAIT 状态的 TCP 连接,收到 SYN 后会发生什么?](/network/3_tcp/time_wait_recv_syn.md) - [TCP 连接,一端断电和进程崩溃有什么区别?](/network/3_tcp/tcp_down_and_crash.md) - [拔掉网线后,原本的 TCP 连接还存在吗?](/network/3_tcp/tcp_unplug_the_network_cable.md) - [tcp_tw_reuse 为什么默认是关闭的?](/network/3_tcp/tcp_tw_reuse_close.md) - [HTTPS 中 TLS 和 TCP 能同时握手吗?](/network/3_tcp/tcp_tls.md) - [TCP Keepalive 和 HTTP Keep-Alive 是一个东西吗?](/network/3_tcp/tcp_http_keepalive.md) - [TCP 有什么缺陷?](/network/3_tcp/tcp_problem.md) - [如何基于 UDP 协议实现可靠传输?](/network/3_tcp/quic.md) - [TCP 和 UDP 可以使用同一个端口吗?](/network/3_tcp/port.md) - [服务端没有 listen,客户端发起连接建立,会发生什么?](/network/3_tcp/tcp_no_listen.md) - [没有 accept,可以建立 TCP 连接吗?](/network/3_tcp/tcp_no_accpet.md) - [用了 TCP 协议,数据一定不会丢吗?](/network/3_tcp/tcp_drop.md) - [TCP 四次挥手,可以变成三次吗?](/network/3_tcp/tcp_three_fin.md) - **IP 篇** :point_down: - [IP 基础知识全家桶](/network/4_ip/ip_base.md) - [ping 的工作原理](/network/4_ip/ping.md) - [断网了,还能 ping 通 127.0.0.1 吗?](/network/4_ip/ping_lo.md) - **学习心得** :point_down: - [计算机网络怎么学?](/network/5_learn/learn_network.md) - [画图经验分享](/network/5_learn/draw.md) ## 质量如何? 图解网络的质量小林说的不算,读者说的算! 图解网络的第一个版本自去年发布以来,每隔一段时间,就会有不少的读者跑来感激小林。 他们说看了我的图解网络,轻松应对大厂的网络面试题,而且每次面试时问到网络问题,他们一点都不慌,甚至暗暗窃喜。 ![在这里插入图片描述](https://img-blog.csdnimg.cn/160f55b965cf4c42ba160e327178a783.png) ## 有错误怎么办? 小林是个手残党,时常写出错别字。 如果你在学习的过程中,**如果你发现有任何错误或者疑惑的地方,欢迎你通过邮箱或者底部留言给小林**,勘误邮箱:xiaolincoding@163.com 小林抽时间会逐个修正,然后发布新版本的图解网络 PDF,一起迭代出更好的图解网络! 新的图解文章都在公众号首发,别忘记关注了哦!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/其他/公众号介绍.png) ================================================ FILE: os/10_learn/draw.md ================================================ # 11.2 画图经验分享 小林写这么多篇图解文章,你们猜我收到的最多的读者问题是什么?没错,就是问我是使用什么**画图**工具,看来对这一点大家都相当好奇,那干脆不如写一篇介绍下我是怎么画图的。 如果我的文章缺少了自己画的图片,相当于失去了灵魂,技术文章本身就很枯燥,如果文章中没有几张图片,读者被劝退的概率飙飙升,剩下没被劝退的估计看着看着就睡着了。所以,精美的图片可以说是必不可少的一部分,不仅在阅读时能带来视觉的冲击,而且图片相比文字能涵盖更多的信息,不然怎会有一图胜千言的说法呢? 这时,可能有的读者会说自己不写文章呀,是不是没有必要了解画图了?我觉得这是不对,画图在我们工作中其实也是有帮助的,比如如果你想跟领导汇报一个业务流程的问题,把业务流程画出来,肯定用图的方式比用文字的方式交流起来会更有效率,更轻松些;如果你参与了一个比较复杂的项目开发,你也可以把代码的流程图给画出来,不仅能帮助自己加深理解,也能帮助后面参与的同事能更快的接手这个项目;甚至如果你要晋升级别了,演讲 PTT 里的配图也是必不可少的。 不过很多人都是纠结用什么画图工具,其实小林觉得再烂的画图工具,只要你思路清晰,确定自己要表达出什么信息,也是能把图画好的,所以不必纠结哪款画图工具,挑一款自己画起来舒服的就行了。 > “小林,你说的我都懂,我就是喜欢你的画图风格嘛,你就说说你用啥画的?” 咳咳,没问题,直接坦白讲,我用的是一个在线的画图网址,地址是: - *https://draw.io* 用它的原因是使用方便和简单,当然最重要的是它完全免费,没有什么限制,甚至还能直接把图片保存到 GoogleDrive、OneDrive 和 Github,我就是保存到 Github,然后用 Github 作为我的图床。 既然要认识它,那就先来看看它长什么样子,它主要分为三个区域,从左往右的顺序是「图形选择区域、绘图区域、属性设置区域」。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/正面图.png) 其中,最左边的「图形选择区域」可以选择的图案有很多种,常见的流程图、时序图、表格图都有,甚至还可以在最左下角的「更多图形」找到其他种类的图形,比如网络设备图标等。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/图形选择.png) 再来,最右边「属性设置区域」可以设置文字的大小,图片颜色、线条形状等,而我最常用颜色板块是下面这三种,都是比较浅色的,这样看起来舒服些。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/浅色风格.png) ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/浅色风格2.png) ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/深浅色风格.png) 我最近常用的一个图形是圆角方块图,它的位置如下图,但是它默认的颜色过于深色,如果要在方框图中描述文字,则可能看不清楚,这时我会在最右侧的「属性设置区域」把方块颜色设置成浅色系列的。另外,还有一点需要注意的是,默认的字体大小比较小,我一般会调成 `16px` 大小。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/常用的方块.png) 如果你不喜欢上图的带有「划痕」的圆角方块图形,可以选择下图中这个最简洁的圆角方框图形。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/圆角方块图形.png) 这个简洁的圆角方框图形,再搭配颜色,能组合成很多结构图,比如我用过它组成过 CPU Cache 的结构图。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/操作系统/存储结构/CPU-Cache.png) 那直角方框图形,我主要是用来组成「表格」,原因自带的表格不好看,也不方便调。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/表格.png) 如果觉得直直的线条太死板,你可以把图片属性中的「*Comic*」勾上,于是就会变成歪歪扭扭的效果啦,有点像手绘风格,挺多人喜欢这种风格。 比如,我用过这种风格画过 TCP 三次握手流程的图。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E4%B8%89%E6%AC%A1%E6%8F%A1%E6%89%8B%E5%92%8C%E5%9B%9B%E6%AC%A1%E6%8C%A5%E6%89%8B/14.jpg) 方块图形再加上菱形,就可以组合成简单程序流程图了,比如我画过存储器缓存流程图。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/操作系统/存储结构/缓存体系.png) 所以,不要小看这些基本图形,只要构思清晰,再基本的图形,也是能构成层次分明并且好看的图。 基本的图形介绍完后,相信你画一些简单程序流程图等图形是没问题的了,接下来就是各种**图形 + 线条**的组合的了。 通过一些基本的图形组合,你还可以画出时序图,时序图可以用来描述多个对象之间的交互流程,比如我画过多个线程获取互斥锁的时序图。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/操作系统/锁/互斥锁工作流程.png) 再来,为了更好表达零拷贝技术的过程,那么用图的方式会更清晰。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E9%9B%B6%E6%8B%B7%E8%B4%9D/senfile-%E9%9B%B6%E6%8B%B7%E8%B4%9D.png) 前面也提到,图形不只是简单图形,还有其他自带的设备类图形,比如我用网络设备图画过单播、广播、多播通信的区别图。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/IP/13.jpg) 你要说,我画过最复杂的图,那就是写 TCP 流量控制的时候,把整个交互过程 + 文字描述 + 滑动窗口状况都画出来了,现在回想起来还是觉得累人。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8F%AF%E9%9D%A0%E7%89%B9%E6%80%A7/22.jpg) 还有好多好多,我就比一一列举,这半年下来,小林至少画了 `500+` 张图了,每一张图其实还是挺费时间的,相信画过图的朋友后,都能体会到这种感觉了。但没办法,谁叫小林是图解工具人呢,画图可以更好的诠释文章内容,但最重要的是,把你们吸引过来了,这是件让我非常高兴的事情,也是让我感觉画图这个事情值得认真做。 另外,细心的读者也发现了,小林贴代码的时候,使用的是图片的形式,原因是代码通常都是比较长,在手机看文章用图片的呈现的方式会更舒服清晰。 在这里也推荐下这个代码截图网址: - *https://carbon.now.sh/* 网站页面如下图,代码显示的效果是不是很美观? ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/carbon.png) 文字的分享有局限性,关键还是要你自己动手摸索摸索,形成自己一套画图的方法论,练习的时候可以先从模仿画起,后面再结合工作或文章的需求画出自己心中的那个图。 ================================================ FILE: os/10_learn/learn_os.md ================================================ # 11.1 操作系统怎么学? 操作系统真的可以说是 `Super Man`,它为了我们做了非常厉害的事情,以至于我们根本察觉不到,只有通过学习它,我们才能深刻体会到它的精妙之处,甚至会被计算机科学家设计思想所震撼,有些思想实际上也是可以应用于我们工作开发中。 操作系统比较重要的四大模块,分别是[内存管理](https://mp.weixin.qq.com/s/HJB_ATQFNqG82YBCRr97CA)、[进程管理](https://mp.weixin.qq.com/s/YXl6WZVzRKCfxzerJWyfrg)、[文件系统管理](https://mp.weixin.qq.com/s/qJdoXTv_XS_4ts9YuzMNIw)、[输入输出设备管理](https://mp.weixin.qq.com/s/04BkLtnPBmmx6CtdQPXiRA)。这是我学习操作系统的顺序,也是我推荐给大家的学习顺序,因为内存管理不仅是最重要、最难的模块,也是和其他模块关联性最大的模块,先把它搞定,后续的模块学起来我认为会相对轻松一些。 学习的过程中,你可能会遇到很多「虚拟」的概念,比如虚拟内存、虚拟文件系统,实际上它们的本质上都是一样的,都是**向下屏蔽差异,向上提供统一的东西**,以方便我们程序员使用。 还有,你也遇到各种各样的[调度算法](https://mp.weixin.qq.com/s/JWj6_BF9Xc84kQcyx6Nf_g),在这里你可以看到数据结构与算法的魅力,重要的是我们要理解为什么要提出那么多调度算法,你当然可以说是为了更快更有效率,但是因什么问题而因此引入新算法的这个过程,更是我们重点学习的地方。 你也会开始明白进程与线程最大的区别在于上下文切换过程中,**线程不用切换虚拟内存**,因为同一个进程内的线程都是共享虚拟内存空间的,线程就单这一点不用切换,就相比进程上下文切换的性能开销减少了很多。由于虚拟内存与物理内存的映射关系需要查询页表,页表的查询是很慢的过程,因此会把常用的地址映射关系缓存在 TLB 里的,这样便可以提高页表的查询速度,如果发生了进程切换,那 TLB 缓存的地址映射关系就会失效,缓存失效就意味着命中率降低,于是虚拟地址转为物理地址这一过程就会很慢。 你也开始不会傻傻的认为 read 或 write 之后数据就直接写到硬盘了,更不会觉得多次操作 read 或 write 方法性能会很低,因为你发现操作系统会有个「**磁盘高速缓冲区**」,它已经帮我们做了缓存的工作,它会预读数据、缓存最近访问的数据,以及使用 I/O 调度算法来合并和排队磁盘调度 I/O,这些都是为了减少操作系统对磁盘的访问频率。 …… 还有太多太多了,我在这里就不赘述了,剩下的就交给你们在学习操作系统的途中去探索和发现了。 还有一点需要注意,学操作系统的时候,不要误以为它是在说 Linux 操作系统,这也是我初学的时候犯的一个错误,操作系统是集合大多数操作系统实现的思想,跟实际具体实现的 Linux 操作系统多少都会有点差别,如果要想 Linux 操作系统的具体实现方式,可以选择看 Linux 内核相关的资料,但是在这之前你先掌握了操作系统的基本知识,这样学起来才能事半功倍。 ## 入门系列 对于没学过操作系统的小白,我建议学的时候,不要直接闷头看书。相信我,你不用几分钟就会打退堂鼓,然后就把厚厚的书拿去垫显示器了,从此再无后续,毕竟直接看书太特喵的枯燥了,当然不如用来垫显示器玩游戏来着香。 B 站关于操作系统课程资源很多,我在里面也看了不同老师讲的课程,觉得比较好的入门级课程是《**操作系统 - 清华大学**》,该课程由清华大学老师向勇和陈渝授课,虽然我们上不了清华大学,但是至少我们可以在网上选择听清华大学的课嘛。课程授课的顺序,就如我前面推荐的学习顺序:「内存管理 -> 进程管理 -> 文件系统管理 -> 输入输出设备管理」。 ![《操作系统 - 清华大学》](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F-%E6%B8%85%E5%8D%8E%E5%A4%A7%E5%AD%A6.png) > B 站清华大学操作系统视频地址:https://www.bilibili.com/video/BV1js411b7vg?from=search&seid=2361361014547524697 该清华大学的视频教学搭配的书应该是《**现代操作系统**》,你可以视频和书籍两者结合一起学,比如看完视频的内存管理,然后就看书上对应的章节,这样相比直接啃书相对会比较好。 ![《现代操作系统》](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E7%8E%B0%E4%BB%A3%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F.png) 清华大学的操作系统视频课讲的比较精炼,涉及到的内容没有那么细,《**操作系统 - 哈工大**》李治军老师授课的视频课程相对就会比较细节,老师会用 Linux 内核代码的角度带你进一步理解操作系统,也会用生活小例子帮助你理解。 ![《操作系统 - 哈工大》](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F-%E5%93%88%E5%B7%A5%E5%A4%A7.png) > B 站哈工大操作系统视频地址:https://www.bilibili.com/video/BV1d4411v7u7?from=search&seid=2361361014547524697 ## 深入学习系列 《现代操作系统》这本书我感觉缺少比较多细节,说的还是比较笼统,而且书也好无聊。 推荐一个说的更细的操作系统书 —— 《**操作系统导论**》,这本书不仅告诉你 What,还会告诉你 How,书的内容都是循序渐进,层层递进的,阅读起来还是觉得挺有意思的,这本书的内存管理和并发这两个部分说的很棒,这本书的中文版本我也没找到资源,不过微信读书可以免费看这本书。 ![《操作系统导论》](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F%E5%AF%BC%E8%AE%BA.png) 当然,少不了这本被称为神书的《**深入理解计算机系统**》,豆瓣评分高达 `9.8` 分,这本书严格来说不算操作系统书,它是以程序员视角理解计算机系统,不只是涉及到操作系统,还涉及到了计算机组成、C 语言、汇编语言等知识,是一本综合性比较强的书。 ![《深入理解计算机系统》](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E6%B7%B1%E5%85%A5%E7%90%86%E8%A7%A3%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%B3%BB%E7%BB%9F.jpg) 它告诉我们计算机是如何设计和工作的,操作系统有哪些重点,它们的作用又是什么,这本书的目标其实便是要讲清楚原理,但并不会把某个话题挖掘地过于深入,过于细节。看看这本书后,我们就可以对计算机系统各组件的工作方式有了理性的认识。在一定程度上,其实它是在锻炼一种思维方式 —— 计算思维。 ---- ## 最后 文中推荐的书,小林都已经把电子书整理好给大家了,只需要在小林的公众号后台回复「**我要学习**」,即可获取百度网盘下载链接。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: os/1_hardware/cpu_mesi.md ================================================ # 2.4 CPU 缓存一致性 直接上,不多 BB 了。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/CPU缓存一致性/缓存一致性提纲.png) --- ## CPU Cache 的数据写入 随着时间的推移,CPU 和内存的访问性能相差越来越大,于是就在 CPU 内部嵌入了 CPU Cache(高速缓存),CPU Cache 离 CPU 核心相当近,因此它的访问速度是很快的,于是它充当了 CPU 与内存之间的缓存角色。 CPU Cache 通常分为三级缓存:L1 Cache、L2 Cache、L3 Cache,级别越低的离 CPU 核心越近,访问速度也快,但是存储容量相对就会越小。其中,在多核心的 CPU 里,每个核心都有各自的 L1/L2 Cache,而 L3 Cache 是所有核心共享使用的。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/存储结构/CPU-Cache.png) 我们先简单了解下 CPU Cache 的结构,CPU Cache 是由很多个 Cache Line 组成的,CPU Line 是 CPU 从内存读取数据的基本单位,而 CPU Line 是由各种标志(Tag)+ 数据块(Data Block)组成,你可以在下图清晰的看到: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/CPU缓存一致性/Cache的数据结构.png) 我们当然期望 CPU 读取数据的时候,都是尽可能地从 CPU Cache 中读取,而不是每一次都要从内存中获取数据。所以,身为程序员,我们要尽可能写出缓存命中率高的代码,这样就有效提高程序的性能,具体的做法,你可以参考我上一篇文章[「如何写出让 CPU 跑得更快的代码?」](https://mp.weixin.qq.com/s/-uhAhBD2zGl_h19E4fNJzQ) 事实上,数据不光是只有读操作,还有写操作,那么如果数据写入 Cache 之后,内存与 Cache 相对应的数据将会不同,这种情况下 Cache 和内存数据都不一致了,于是我们肯定是要把 Cache 中的数据同步到内存里的。 问题来了,那在什么时机才把 Cache 中的数据写回到内存呢?为了应对这个问题,下面介绍两种针对写入数据的方法: - 写直达(*Write Through*) - 写回(*Write Back*) ### 写直达 保持内存与 Cache 一致性最简单的方式是,**把数据同时写入内存和 Cache 中**,这种方法称为**写直达(*Write Through*)**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/CPU缓存一致性/写直达.png) 在这个方法里,写入前会先判断数据是否已经在 CPU Cache 里面了: - 如果数据已经在 Cache 里面,先将数据更新到 Cache 里面,再写入到内存里面; - 如果数据没有在 Cache 里面,就直接把数据更新到内存里面。 写直达法很直观,也很简单,但是问题明显,无论数据在不在 Cache 里面,每次写操作都会写回到内存,这样写操作将会花费大量的时间,无疑性能会受到很大的影响。 ### 写回 既然写直达由于每次写操作都会把数据写回到内存,而导致影响性能,于是为了要减少数据写回内存的频率,就出现了**写回(*Write Back*)的方法**。 在写回机制中,**当发生写操作时,新的数据仅仅被写入 Cache Block 里,只有当修改过的 Cache Block「被替换」时才需要写到内存中**,减少了数据写回内存的频率,这样便可以提高系统的性能。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/CPU缓存一致性/写回1.png) 那具体如何做到的呢?下面来详细说一下: - 如果当发生写操作时,数据已经在 CPU Cache 里的话,则把数据更新到 CPU Cache 里,同时标记 CPU Cache 里的这个 Cache Block 为脏(Dirty)的,这个脏的标记代表这个时候,我们 CPU Cache 里面的这个 Cache Block 的数据和内存是不一致的,这种情况是不用把数据写到内存里的; - 如果当发生写操作时,数据所对应的 Cache Block 里存放的是「别的内存地址的数据」的话,就要检查这个 Cache Block 里的数据有没有被标记为脏的: - 如果是脏的话,我们就要把这个 Cache Block 里的数据写回到内存,然后再把当前要写入的数据,先从内存读入到 Cache Block 里(注意,这一步不是没用的,具体为什么要这一步,可以看这个「[回答](https://stackoverflow.com/questions/26672661/for-write-back-cache-policy-why-data-should-first-be-read-from-memory-before-w)」),然后再把当前要写入的数据写入到 Cache Block,最后也把它标记为脏的; - 如果不是脏的话,把当前要写入的数据先从内存读入到 Cache Block 里,接着将数据写入到这个 Cache Block 里,然后再把这个 Cache Block 标记为脏的就好了。 可以发现写回这个方法,在把数据写入到 Cache 的时候,只有在缓存不命中,同时数据对应的 Cache 中的 Cache Block 为脏标记的情况下,才会将数据写到内存中,而在缓存命中的情况下,则在写入后 Cache 后,只需把该数据对应的 Cache Block 标记为脏即可,而不用写到内存里。 这样的好处是,如果我们大量的操作都能够命中缓存,那么大部分时间里 CPU 都不需要读写内存,自然性能相比写直达会高很多。 为什么缓存没命中时,还要定位 Cache Block?这是因为此时是要判断数据即将写入到 cache block 里的位置,是否被「其他数据」占用了此位置,如果这个「其他数据」是脏数据,那么就要帮忙把它写回到内存。 CPU 缓存与内存使用「写回」机制的流程图如下,左半部分就是读操作的流程,右半部分就是写操作的流程,也就是我们上面讲的内容。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/%E5%85%AB%E8%82%A1%E6%96%87/writeback.png) ## 缓存一致性问题 现在 CPU 都是多核的,由于 L1/L2 Cache 是多个核心各自独有的,那么会带来多核心的**缓存一致性(*Cache Coherence*)** 的问题,如果不能保证缓存一致性的问题,就可能造成结果错误。 那缓存一致性的问题具体是怎么发生的呢?我们以一个含有两个核心的 CPU 作为例子看一看。 假设 A 号核心和 B 号核心同时运行两个线程,都操作共同的变量 i(初始值为 0)。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/CPU缓存一致性/缓存一致性问题例子.png) 这时如果 A 号核心执行了 `i++` 语句的时候,为了考虑性能,使用了我们前面所说的写回策略,先把值为 `1` 的执行结果写入到 L1/L2 Cache 中,然后把 L1/L2 Cache 中对应的 Block 标记为脏的,这个时候数据其实没有被同步到内存中的,因为写回策略,只有在 A 号核心中的这个 Cache Block 要被替换的时候,数据才会写入到内存里。 如果这时旁边的 B 号核心尝试从内存读取 i 变量的值,则读到的将会是错误的值,因为刚才 A 号核心更新 i 值还没写入到内存中,内存中的值还依然是 0。**这个就是所谓的缓存一致性问题,A 号核心和 B 号核心的缓存,在这个时候是不一致,从而会导致执行结果的错误。** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/CPU缓存一致性/缓存一致性问题例子2.png) 那么,要解决这一问题,就需要一种机制,来同步两个不同核心里面的缓存数据。要实现的这个机制的话,要保证做到下面这 2 点: - 第一点,某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache,这个称为**写传播(*Write Propagation*)**; - 第二点,某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,这个称为**事务的串行化(*Transaction Serialization*)**。 第一点写传播很容易就理解,当某个核心在 Cache 更新了数据,就需要同步到其他核心的 Cache 里。而对于第二点事务的串行化,我们举个例子来理解它。 假设我们有一个含有 4 个核心的 CPU,这 4 个核心都操作共同的变量 i(初始值为 0)。A 号核心先把 i 值变为 100,而此时同一时间,B 号核心先把 i 值变为 200,这里两个修改,都会「传播」到 C 和 D 号核心。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/CPU缓存一致性/事件顺序问题.png) 那么问题就来了,C 号核心先收到了 A 号核心更新数据的事件,再收到 B 号核心更新数据的事件,因此 C 号核心看到的变量 i 是先变成 100,后变成 200。 而如果 D 号核心收到的事件是反过来的,则 D 号核心看到的是变量 i 先变成 200,再变成 100,虽然是做到了写传播,但是各个 Cache 里面的数据还是不一致的。 所以,我们要保证 C 号核心和 D 号核心都能看到**相同顺序的数据变化**,比如变量 i 都是先变成 100,再变成 200,这样的过程就是事务的串行化。 要实现事务串行化,要做到 2 点: - CPU 核心对于 Cache 中数据的操作,需要同步给其他 CPU 核心; - 要引入「锁」的概念,如果两个 CPU 核心里有相同数据的 Cache,那么对于这个 Cache 数据的更新,只有拿到了「锁」,才能进行对应的数据更新。 那接下来我们看看,写传播和事务串行化具体是用什么技术实现的。 --- ## 总线嗅探 写传播的原则就是当某个 CPU 核心更新了 Cache 中的数据,要把该事件广播通知到其他核心。最常见实现的方式是**总线嗅探(*Bus Snooping*)**。 我还是以前面的 i 变量例子来说明总线嗅探的工作机制,当 A 号 CPU 核心修改了 L1 Cache 中 i 变量的值,通过总线把这个事件广播通知给其他所有的核心,然后每个 CPU 核心都会监听总线上的广播事件,并检查是否有相同的数据在自己的 L1 Cache 里面,如果 B 号 CPU 核心的 L1 Cache 中有该数据,那么也需要把该数据更新到自己的 L1 Cache。 可以发现,总线嗅探方法很简单,CPU 需要每时每刻监听总线上的一切活动,但是不管别的核心的 Cache 是否缓存相同的数据,都需要发出一个广播事件,这无疑会加重总线的负载。 另外,总线嗅探只是保证了某个 CPU 核心的 Cache 更新数据这个事件能被其他 CPU 核心知道,但是并不能保证事务串行化。 于是,有一个协议基于总线嗅探机制实现了事务串行化,也用状态机机制降低了总线带宽压力,这个协议就是 MESI 协议,这个协议就做到了 CPU 缓存一致性。 --- ## MESI 协议 MESI 协议其实是 4 个状态单词的开头字母缩写,分别是: - *Modified*,已修改 - *Exclusive*,独占 - *Shared*,共享 - *Invalidated*,已失效 这四个状态来标记 Cache Line 四个不同的状态。 「已修改」状态就是我们前面提到的脏标记,代表该 Cache Block 上的数据已经被更新过,但是还没有写到内存里。而「已失效」状态,表示的是这个 Cache Block 里的数据已经失效了,不可以读取该状态的数据。 「独占」和「共享」状态都代表 Cache Block 里的数据是干净的,也就是说,这个时候 Cache Block 里的数据和内存里面的数据是一致性的。 「独占」和「共享」的差别在于,独占状态的时候,数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 没有该数据。这个时候,如果要向独占的 Cache 写数据,就可以直接自由地写入,而不需要通知其他 CPU 核心,因为只有你这有这个数据,就不存在缓存一致性的问题了,于是就可以随便操作该数据。 另外,在「独占」状态下的数据,如果有其他核心从内存读取了相同的数据到各自的 Cache,那么这个时候,独占状态下的数据就会变成共享状态。 那么,「共享」状态代表着相同的数据在多个 CPU 核心的 Cache 里都有,所以当我们要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后再更新当前 Cache 里面的数据。 我们举个具体的例子来看看这四个状态的转换: 1. 当 A 号 CPU 核心从内存读取变量 i 的值,数据被缓存在 A 号 CPU 核心自己的 Cache 里面,此时其他 CPU 核心的 Cache 没有缓存该数据,于是标记 Cache Line 状态为「独占」,此时其 Cache 中的数据与内存是一致的; 2. 然后 B 号 CPU 核心也从内存读取了变量 i 的值,此时会发送消息给其他 CPU 核心,由于 A 号 CPU 核心已经缓存了该数据,所以会把数据返回给 B 号 CPU 核心。在这个时候,A 和 B 核心缓存了相同的数据,Cache Line 的状态就会变成「共享」,并且其 Cache 中的数据与内存也是一致的; 3. 当 A 号 CPU 核心要修改 Cache 中 i 变量的值,发现数据对应的 Cache Line 的状态是共享状态,则要向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后 A 号 CPU 核心才更新 Cache 里面的数据,同时标记 Cache Line 为「已修改」状态,此时 Cache 中的数据就与内存不一致了。 4. 如果 A 号 CPU 核心「继续」修改 Cache 中 i 变量的值,由于此时的 Cache Line 是「已修改」状态,因此不需要给其他 CPU 核心发送消息,直接更新数据即可。 5. 如果 A 号 CPU 核心的 Cache 里的 i 变量对应的 Cache Line 要被「替换」,发现 Cache Line 状态是「已修改」状态,就会在替换前先把数据同步到内存。 所以,可以发现当 Cache Line 状态是「已修改」或者「独占」状态时,修改更新其数据不需要发送广播给其他 CPU 核心,这在一定程度上减少了总线带宽压力。 事实上,整个 MESI 的状态可以用一个有限状态机来表示它的状态流转。还有一点,对于不同状态触发的事件操作,可能是来自本地 CPU 核心发出的广播事件,也可以是来自其他 CPU 核心通过总线发出的广播事件。下图即是 MESI 协议的状态图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/CPU缓存一致性/MESI协议.png) MESI 协议的四种状态之间的流转过程,我汇总成了下面的表格,你可以更详细的看到每个状态转换的原因: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/CPU%E7%BC%93%E5%AD%98%E4%B8%80%E8%87%B4%E6%80%A7/%20MESI%E7%8A%B6%E6%80%81%E8%BD%AC%E6%8D%A2%E8%A1%A8%E6%A0%BC.png) --- ## 总结 CPU 在读写数据的时候,都是在 CPU Cache 读写数据的,原因是 Cache 离 CPU 很近,读写性能相比内存高出很多。对于 Cache 里没有缓存 CPU 所需要读取的数据的这种情况,CPU 则会从内存读取数据,并将数据缓存到 Cache 里面,最后 CPU 再从 Cache 读取数据。 而对于数据的写入,CPU 都会先写入到 Cache 里面,然后再在找个合适的时机写入到内存,那就有「写直达」和「写回」这两种策略来保证 Cache 与内存的数据一致性: - 写直达,只要有数据写入,都会直接把数据写入到内存里面,这种方式简单直观,但是性能就会受限于内存的访问速度; - 写回,对于已经缓存在 Cache 的数据的写入,只需要更新其数据就可以,不用写入到内存,只有在需要把缓存里面的脏数据交换出去的时候,才把数据同步到内存里,这种方式在缓存命中率高的情况,性能会更好; 当今 CPU 都是多核的,每个核心都有各自独立的 L1/L2 Cache,只有 L3 Cache 是多个核心之间共享的。所以,我们要确保多核缓存是一致性的,否则会出现错误的结果。 要想实现缓存一致性,关键是要满足 2 点: - 第一点是写传播,也就是当某个 CPU 核心发生写入操作时,需要把该事件广播通知给其他核心; - 第二点是事务的串行化,这个很重要,只有保证了这个,才能保障我们的数据是真正一致的,我们的程序在各个不同的核心上运行的结果也是一致的; 基于总线嗅探机制的 MESI 协议,就满足上面了这两点,因此它是保障缓存一致性的协议。 MESI 协议,是已修改、独占、共享、已失效这四个状态的英文缩写的组合。整个 MESI 状态的变更,则是根据来自本地 CPU 核心的请求,或者来自其他 CPU 核心通过总线传输过来的请求,从而构成一个流动的状态机。另外,对于在「已修改」或者「独占」状态的 Cache Line,修改更新其数据不需要发送广播给其他 CPU 核心。 --- ## 关注作者 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/其他/公众号介绍.png) ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF*** ================================================ FILE: os/1_hardware/float.md ================================================ # 2.7 为什么 0.1 + 0.2 不等于 0.3? 我们来思考几个问题: - 为什么负数要用补码表示? - 十进制小数怎么转成二进制? - 计算机是怎么存小数的? - 0.1 + 0.2 == 0.3 吗? - ... 别看这些问题都看似简单,但是其实还是有点东西的这些问题。 --- ## 为什么负数要用补码表示? 十进制转换二进制的方法相信大家都熟能生巧了,如果你说你还不知道,我觉得你还是太谦虚,可能你只是忘记了,即使你真的忘记了,不怕,贴心的小林在和你一起回忆一下。 十进制数转二进制采用的是**除 2 取余法**,比如数字 8 转二进制的过程如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/十进制转二进制.png) 接着,我们看看「整数类型」的数字在计算机的存储方式,这其实很简单,也很直观,就是将十进制的数字转换成二进制即可。 我们以 `int` 类型的数字作为例子,int 类型是 `32` 位的,其中**最高位是作为「符号标志位」**,正数的符号位是 `0`,负数的符号位是 `1`,**剩余的 31 位则表示二进制数据**。 那么,对于 int 类型的数字 1 的二进制数表示如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/int1.png) 而负数就比较特殊了点,负数在计算机中是以「补码」表示的,**所谓的补码就是把正数的二进制全部取反再加 1**,比如 -1 的二进制是把数字 1 的二进制取反后再加 1,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/反码.png) 不知道你有没有想过,为什么计算机要用补码的方式来表示负数?在回答这个问题前,我们假设不用补码的方式来表示负数,而只是把最高位的符号标志位变为 1 表示负数,如下图过程: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/非反码.png) 如果采用这种方式来表示负数的二进制的话,试想一下 `-2 + 1` 的运算过程,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/非反码运算.png) 按道理,`-2 + 1 = -1`,但是上面的运算过程中得到结果却是 `-3`,所可以发现,这种负数的表示方式是不能用常规的加法来计算了,就需要特殊处理,要先判断数字是否为负数,如果是负数就要把加法操作变成减法操作才可以得到正确对结果。 到这里,我们就可以回答前面提到的「负数为什么要用补码方式来表示」的问题了。 如果负数不是使用补码的方式表示,则在做基本对加减法运算的时候,**还需要多一步操作来判断是否为负数,如果为负数,还得把加法反转成减法,或者把减法反转成加法**,这就非常不好了,毕竟加减法运算在计算机里是很常使用的,所以为了性能考虑,应该要尽量简化这个运算过程。 **而用了补码的表示方式,对于负数的加减法操作,实际上是和正数加减法操作一样的**。你可以看到下图,用补码表示的负数在运算 `-2 + 1` 过程的时候,其结果是正确的: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/补码运算过程.png) --- ## 十进制小数与二进制的转换 好了,整数十进制转二进制我们知道了,接下来看看小数是怎么转二进制的,小数部分的转换不同于整数部分,它采用的是**乘 2 取整法**,将十进制中的小数部分乘以 2 作为二进制的一位,然后继续取小数部分乘以 2 作为下一位,直到不存在小数为止。 话不多说,我们就以 `8.625` 转二进制作为例子,直接上图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/十进制小数转二进制.png) 最后把「整数部分 + 小数部分」结合在一起后,其结果就是 `1000.101`。 但是,并不是所有小数都可以用二进制表示,前面提到的 0.625 小数是一个特例,刚好通过乘 2 取整法的方式完整的转换成二进制。 如果我们用相同的方式,来把 `0.1` 转换成二进制,过程如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/无限小数.png) 可以发现,`0.1` 的二进制表示是无限循环的。 **由于计算机的资源是有限的,所以是没办法用二进制精确的表示 0.1,只能用「近似值」来表示,就是在有限的精度情况下,最大化接近 0.1 的二进制数,于是就会造成精度缺失的情况**。 对于二进制小数转十进制时,需要注意一点,小数点后面的指数幂是**负数**。 比如,二进制 `0.1` 转成十进制就是 `2^(-1)`,也就是十进制 `0.5`,二进制 `0.01` 转成十进制就是 `2^-2`,也就是十进制 `0.25`,以此类推。 举个例子,二进制 `1010.101` 转十进制的过程,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/小数转二进制2.png) --- ## 计算机是怎么存小数的? `1000.101` 这种二进制小数是「定点数」形式,代表着小数点是定死的,不能移动,如果你移动了它的小数点,这个数就变了,就不再是它原来的值了。 然而,计算机并不是这样存储的小数的,计算机存储小数的采用的是**浮点数**,名字里的「浮点」表示小数点是可以浮动的。 比如 `1000.101` 这个二进制数,可以表示成 `1.000101 x 2^3`,类似于数学上的科学记数法。 既然提到了科学计数法,我再帮大家复习一下。 比如有个很大的十进制数 1230000,我们可以也可以表示成 `1.23 x 10^6`,这种方式就称为科学记数法。 该方法在小数点左边只有一个数字,而且把这种整数部分没有前导 0 的数字称为**规格化**,比如 `1.0 x 10^(-9)` 是规格化的科学记数法,而 `0.1 x 10^(-9)` 和 `10.0 x 10^(-9)` 就不是了。 因此,如果二进制要用到科学记数法,同时要规范化,那么不仅要保证基数为 2,还要保证小数点左侧只有 1 位,而且必须为 1。 所以通常将 `1000.101` 这种二进制数,规格化表示成 `1.000101 x 2^3`,其中,最为关键的是 000101 和 3 这两个东西,它就可以包含了这个二进制小数的所有信息: - `000101` 称为**尾数**,即小数点后面的数字; - `3` 称为**指数**,指定了小数点在数据中的位置; 现在绝大多数计算机使用的浮点数,一般采用的是 IEEE 制定的国际标准,这种标准形式如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/IEEE标准.png) 这三个重要部分的意义如下: - *符号位*:表示数字是正数还是负数,为 0 表示正数,为 1 表示负数; - *指数位*:指定了小数点在数据中的位置,指数可以是负数,也可以是正数,**指数位的长度越长则数值的表达范围就越大**; - *尾数位*:小数点右侧的数字,也就是小数部分,比如二进制 1.0011 x 2^(-2),尾数部分就是 0011,而且**尾数的长度决定了这个数的精度**,因此如果要表示精度更高的小数,则就要提高尾数位的长度; 用 `32` 位来表示的浮点数,则称为**单精度浮点数**,也就是我们编程语言中的 `float` 变量,而用 `64` 位来表示的浮点数,称为**双精度浮点数**,也就是 `double` 变量,它们的结构如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/float.png) 可以看到: - double 的尾数部分是 52 位,float 的尾数部分是 23 位,由于同时都带有一个固定隐含位(这个后面会说),所以 double 有 53 个二进制有效位,float 有 24 个二进制有效位,所以所以它们的精度在十进制中分别是 `log10(2^53)` 约等于 `15.95` 和 `log10(2^24)` 约等于 `7.22` 位,因此 double 的有效数字是 `15~16` 位,float 的有效数字是 `7~8` 位,这些有效位是包含整数部分和小数部分; - double 的指数部分是 11 位,而 float 的指数位是 8 位,意味着 double 相比 float 能表示更大的数值范围; 那二进制小数,是如何转换成二进制浮点数的呢? 我们就以 `10.625` 作为例子,看看这个数字在 float 里是如何存储的。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/float存储.png) 首先,我们计算出 10.625 的二进制小数为 1010.101。 然后**把小数点,移动到第一个有效数字后面**,即将 1010.101 右移 `3` 位成 `1.010101`,右移 3 位就代表 +3,左移 3 位就是 -3。 **float 中的「指数位」就跟这里移动的位数有关系,把移动的位数再加上「偏移量」,float 的话偏移量是 127,相加后就是指数位的值了**,即指数位这 8 位存的是 `10000010`(十进制 130),因此你可以认为「指数位」相当于指明了小数点在数据中的位置。 `1.010101` 这个数的**小数点右侧的数字就是 float 里的「尾数位」**,由于尾数位是 23 位,则后面要补充 0,所以最终尾数位存储的数字是 `01010100000000000000000`。 在算指数的时候,你可能会有疑问为什么要加上偏移量呢? 前面也提到,指数可能是正数,也可能是负数,即指数是有符号的整数,而有符号整数的计算是比无符号整数麻烦的,所以为了减少不必要的麻烦,在实际存储指数的时候,需要把指数转换成**无符号整数**。 float 的指数部分是 8 位,IEEE 标准规定单精度浮点的指数取值范围是 `-126 ~ +127`,于是为了把指数转换成无符号整数,就要加个**偏移量**,比如 float 的指数偏移量是 `127`,这样指数就不会出现负数了。 比如,指数如果是 8,则实际存储的指数是 8 + 127(偏移量)= 135,即把 135 转换为二进制之后再存储,而当我们需要计算实际的十进制数的时候,再把指数减去「偏移量」即可。 细心的朋友肯定发现,移动后的小数点左侧的有效位(即 1)消失了,它并没有存储到 float 里。 这是因为 IEEE 标准规定,二进制浮点数的小数点左侧只能有 1 位,并且还只能是 1,**既然这一位永远都是 1,那就可以不用存起来了**。 于是就让 23 位尾数只存储小数部分,然后在计算时会**自动把这个 1 加上,这样就可以节约 1 位的空间,尾数就能多存一位小数,相应的精度就更高了一点**。 那么,对于我们在从 float 的二进制浮点数转换成十进制时,要考虑到这个隐含的 1,转换公式如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/float公式.png) 举个例子,我们把下图这个 float 的数据转换成十进制,过程如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/float转二进制例子.png) --- ## 0.1 + 0.2 == 0.3 ? 前面提到过,并不是所有小数都可以用「完整」的二进制来表示的,比如十进制 0.1 在转换成二进制小数的时候,是一串无限循环的二进制数,计算机是无法表达无限循环的二进制数的,毕竟计算机的资源是有限。 因此,计算机只能用「近似值」来表示该二进制,那么意味着计算机存放的小数可能不是一个真实值。 现在基本都是用 IEEE 754 规范的「单精度浮点类型」或「双精度浮点类型」来存储小数的,根据精度的不同,近似值也会不同。 那计算机是存储 0.1 是一个怎么样的二进制浮点数呢? 偷个懒,我就不自己手动算了,可以使用 binaryconvert 这个工具,将十进制 0.1 小数转换成 float 浮点数: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/0.1工具.png) 可以看到,8 位指数部分是 `01111011`,23 位的尾数部分是 `10011001100110011001101`,可以看到尾数部分是 `0011` 是一直循环的,只不过尾数是有长度限制的,所以只会显示一部分,所以是一个近似值,精度十分有限。 接下来,我们看看 0.2 的 float 浮点数: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/0.2工具.png) 可以看到,8 位指数部分是 `01111100`,稍微和 0.1 的指数不同,23 位的尾数部分是 `10011001100110011001101` 和 0.1 的尾数部分是相同的,也是一个近似值。 0.1 的二进制浮点数转换成十进制的结果是 `0.100000001490116119384765625`: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/0.1浮点数转二进制小数.png) 0.2 的二进制浮点数转换成十进制的结果是 `0.20000000298023223876953125`: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/0.2浮点数转换.png) 这两个结果相加就是 `0.300000004470348358154296875`: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/0.1%2B0.2.png) 所以,你会看到**在计算机中 0.1 + 0.2 并不等于完整的 0.3**。 这主要是**因为有的小数无法可以用「完整」的二进制来表示,所以计算机里只能采用近似数的方式来保存,那两个近似数相加,得到的必然也是一个近似数**。 我们在 JavaScript 里执行 0.1 + 0.2,你会得到下面这个结果: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/js0.1%2B0.2.png) 结果和我们前面推到的类似,因为 JavaScript 对于数字都是使用 IEEE 754 标准下的双精度浮点类型来存储的。 而我们二进制只能精准表达 2 除尽的数字 1/2, 1/4, 1/8,但是对于 0.1(1/10) 和 0.2(1/5),在二进制中都无法精准表示时,需要根据精度舍入。 我们人类熟悉的十进制运算系统,可以精准表达 2 和 5 除尽的数字,例如 1/2, 1/4, 1/5(0.2), 1/8, 1/10(0.1)。 当然,十进制也有无法除尽的地方,例如 1/3, 1/7,也需要根据精度舍入。 --- ## 总结 最后,再来回答开头的问题。 > 为什么负数要用补码表示? 负数之所以用补码的方式来表示,主要是为了统一和正数的加减法操作一样,毕竟数字的加减法是很常用的一个操作,就不要搞特殊化,尽量以统一的方式来运算。 > 十进制小数怎么转成二进制? 十进制整数转二进制使用的是「除 2 取余法」,十进制小数使用的是「乘 2 取整法」。 > 计算机是怎么存小数的? 计算机是以浮点数的形式存储小数的,大多数计算机都是 IEEE 754 标准定义的浮点数格式,包含三个部分: - 符号位:表示数字是正数还是负数,为 0 表示正数,为 1 表示负数; - 指数位:指定了小数点在数据中的位置,指数可以是负数,也可以是正数,指数位的长度越长则数值的表达范围就越大; - 尾数位:小数点右侧的数字,也就是小数部分,比如二进制 1.0011 x 2^(-2),尾数部分就是 0011,而且尾数的长度决定了这个数的精度,因此如果要表示精度更高的小数,则就要提高尾数位的长度; 用 32 位来表示的浮点数,则称为单精度浮点数,也就是我们编程语言中的 float 变量,而用 64 位来表示的浮点数,称为双精度浮点数,也就是 double 变量。 > 0.1 + 0.2 == 0.3 吗? 不是的,0.1 和 0.2 这两个数字用二进制表达会是一个一直循环的二进制数,比如 0.1 的二进制表示为 0.0 0011 0011 0011…… (0011 无限循环),对于计算机而言,0.1 无法精确表达,这是浮点数计算造成精度损失的根源。 因此,IEEE 754 标准定义的浮点数只能根据精度舍入,然后用「近似值」来表示该二进制,那么意味着计算机存放的小数可能不是一个真实值。 0.1 + 0.2 并不等于完整的 0.3,这主要是因为这两个小数无法用「完整」的二进制来表示,只能根据精度舍入,所以计算机里只能采用近似数的方式来保存,那两个近似数相加,得到的必然也是一个近似数。 ================================================ FILE: os/1_hardware/how_cpu_deal_task.md ================================================ # 2.5 CPU 是如何执行任务的? 你清楚下面这几个问题吗? - 有了内存,为什么还需要 CPU Cache? - CPU 是怎么读写数据的? - 如何让 CPU 能读取数据更快一些? - CPU 伪共享是如何发生的?又该如何避免? - CPU 是如何调度任务的?如果你的任务对响应要求很高,你希望它总是能被先调度,这该怎么办? - ... 这篇,我们就来回答这些问题。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/提纲.png) --- ## CPU 如何读写数据的? 先来认识 CPU 的架构,只有理解了 CPU 的 架构,才能更好地理解 CPU 是如何读写数据的,对于现代 CPU 的架构图如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/CPU架构.png) 可以看到,一个 CPU 里通常会有多个 CPU 核心,比如上图中的 1 号和 2 号 CPU 核心,并且每个 CPU 核心都有自己的 L1 Cache 和 L2 Cache,而 L1 Cache 通常分为 dCache(数据缓存)和 iCache(指令缓存),L3 Cache 则是多个核心共享的,这就是 CPU 典型的缓存层次。 上面提到的都是 CPU 内部的 Cache,放眼外部的话,还会有内存和硬盘,这些存储设备共同构成了金字塔存储层次。如下图所示: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/存储结构/存储器的层次关系图.png) 从上图也可以看到,从上往下,存储设备的容量会越大,而访问速度会越慢。至于每个存储设备的访问延时,你可以看下图的表格: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/存储结构/存储器成本的对比.png) 你可以看到,CPU 访问 L1 Cache 速度比访问内存快 100 倍,这就是为什么 CPU 里会有 L1~L3 Cache 的原因,目的就是把 Cache 作为 CPU 与内存之间的缓存层,以减少对内存的访问频率。 CPU 从内存中读取数据到 Cache 的时候,并不是一个字节一个字节读取,而是一块一块的方式来读取数据的,这一块一块的数据被称为 CPU Cache Line(缓存块),所以 **CPU Cache Line 是 CPU 从内存读取数据到 Cache 的单位**。 至于 CPU Cache Line 大小,在 Linux 系统可以用下面的方式查看到,你可以看我服务器的 L1 Cache Line 大小是 64 字节,也就意味着 **L1 Cache 一次载入数据的大小是 64 字节**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU缓存/查看CPULine大小.png) 那么对数组的加载,CPU 就会加载数组里面连续的多个数据到 Cache 里,因此我们应该按照物理内存地址分布的顺序去访问元素,这样访问数组元素的时候,Cache 命中率就会很高,于是就能减少从内存读取数据的频率,从而可提高程序的性能。 但是,在我们不使用数组,而是使用单独的变量的时候,则会有 Cache 伪共享的问题,Cache 伪共享问题上是一个性能杀手,我们应该要规避它。 接下来,就来看看 Cache 伪共享是什么?又如何避免这个问题? 现在假设有一个双核心的 CPU,这两个 CPU 核心并行运行着两个不同的线程,它们同时从内存中读取两个不同的数据,分别是类型为 `long` 的变量 A 和 B,这个两个数据的地址在物理内存上是**连续**的,如果 Cahce Line 的大小是 64 字节,并且变量 A 在 Cahce Line 的开头位置,那么这两个数据是位于**同一个 Cache Line 中**,又因为 CPU Cache Line 是 CPU 从内存读取数据到 Cache 的单位,所以这两个数据会被同时读入到了两个 CPU 核心中各自 Cache 中。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/同一个缓存行.png) 我们来思考一个问题,如果这两个不同核心的线程分别修改不同的数据,比如 1 号 CPU 核心的线程只修改了 变量 A,或 2 号 CPU 核心的线程的线程只修改了变量 B,会发生什么呢? ### 分析伪共享的问题 现在我们结合保证多核缓存一致的 MESI 协议,来说明这一整个的过程,如果你还不知道 MESI 协议,你可以看我这篇文章「[10 张图打开 CPU 缓存一致性的大门](https://mp.weixin.qq.com/s/PDUqwAIaUxNkbjvRfovaCg)」。 ①. 最开始变量 A 和 B 都还不在 Cache 里面,假设 1 号核心绑定了线程 A,2 号核心绑定了线程 B,线程 A 只会读写变量 A,线程 B 只会读写变量 B。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/分析伪共享1.png) ②. 1 号核心读取变量 A,由于 CPU 从内存读取数据到 Cache 的单位是 Cache Line,也正好变量 A 和 变量 B 的数据归属于同一个 Cache Line,所以 A 和 B 的数据都会被加载到 Cache,并将此 Cache Line 标记为「独占」状态。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/分析伪共享2.png) ③. 接着,2 号核心开始从内存里读取变量 B,同样的也是读取 Cache Line 大小的数据到 Cache 中,此 Cache Line 中的数据也包含了变量 A 和 变量 B,此时 1 号和 2 号核心的 Cache Line 状态变为「共享」状态。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/分析伪共享3.png) ④. 1 号核心需要修改变量 A,发现此 Cache Line 的状态是「共享」状态,所以先需要通过总线发送消息给 2 号核心,通知 2 号核心把 Cache 中对应的 Cache Line 标记为「已失效」状态,然后 1 号核心对应的 Cache Line 状态变成「已修改」状态,并且修改变量 A。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/分析伪共享4.png) ⑤. 之后,2 号核心需要修改变量 B,此时 2 号核心的 Cache 中对应的 Cache Line 是已失效状态,另外由于 1 号核心的 Cache 也有此相同的数据,且状态为「已修改」状态,所以要先把 1 号核心的 Cache 对应的 Cache Line 写回到内存,然后 2 号核心再从内存读取 Cache Line 大小的数据到 Cache 中,最后把变量 B 修改到 2 号核心的 Cache 中,并将状态标记为「已修改」状态。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/分析伪共享5.png) 所以,可以发现如果 1 号和 2 号 CPU 核心这样持续交替的分别修改变量 A 和 B,就会重复 ④ 和 ⑤ 这两个步骤,Cache 并没有起到缓存的效果,虽然变量 A 和 B 之间其实并没有任何的关系,但是因为同时归属于一个 Cache Line,这个 Cache Line 中的任意数据被修改后,都会相互影响,从而出现 ④ 和 ⑤ 这两个步骤。 因此,这种因为多个线程同时读写同一个 Cache Line 的不同变量时,而导致 CPU Cache 失效的现象称为**伪共享(*False Sharing*)**。 ### 避免伪共享的方法 因此,对于多个线程共享的热点数据,即经常会修改的数据,应该避免这些数据刚好在同一个 Cache Line 中,否则就会出现为伪共享的问题。 接下来,看看在实际项目中是用什么方式来避免伪共享的问题的。 在 Linux 内核中存在 `__cacheline_aligned_in_smp` 宏定义,是用于解决伪共享的问题。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/__cacheline_aligned.png) 从上面的宏定义,我们可以看到: - 如果在多核(MP)系统里,该宏定义是 `__cacheline_aligned`,也就是 Cache Line 的大小; - 而如果在单核系统里,该宏定义是空的; 因此,针对在同一个 Cache Line 中的共享的数据,如果在多核之间竞争比较严重,为了防止伪共享现象的发生,可以采用上面的宏定义使得变量在 Cache Line 里是对齐的。 举个例子,有下面这个结构体: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/struct_test.png) 结构体里的两个成员变量 a 和 b 在物理内存地址上是连续的,于是它们可能会位于同一个 Cache Line 中,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/struct_ab.png) 所以,为了防止前面提到的 Cache 伪共享问题,我们可以使用上面介绍的宏定义,将 b 的地址设置为 Cache Line 对齐地址,如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/struct_test1.png) 这样 a 和 b 变量就不会在同一个 Cache Line 中了,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/struct_ab1.png) 所以,避免 Cache 伪共享实际上是用空间换时间的思想,浪费一部分 Cache 空间,从而换来性能的提升。 我们再来看一个应用层面的规避方案,有一个 Java 并发框架 Disruptor 使用「字节填充 + 继承」的方式,来避免伪共享的问题。 Disruptor 中有一个 RingBuffer 类会经常被多个线程使用,代码如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/Disruptor.png) 你可能会觉得 RingBufferPad 类里 7 个 long 类型的名字很奇怪,但事实上,它们虽然看起来毫无作用,但却对性能的提升起到了至关重要的作用。 我们都知道,CPU Cache 从内存读取数据的单位是 CPU Cache Line,一般 64 位 CPU 的 CPU Cache Line 的大小是 64 个字节,一个 long 类型的数据是 8 个字节,所以 CPU 一下会加载 8 个 long 类型的数据。 根据 JVM 对象继承关系中父类成员和子类成员,内存地址是连续排列布局的,因此 RingBufferPad 中的 7 个 long 类型数据作为 Cache Line **前置填充**,而 RingBuffer 中的 7 个 long 类型数据则作为 Cache Line **后置填充**,这 14 个 long 变量没有任何实际用途,更不会对它们进行读写操作。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/填充字节.png) 另外,RingBufferFelds 里面定义的这些变量都是 `final` 修饰的,意味着第一次加载之后不会再修改,又**由于「前后」各填充了 7 个不会被读写的 long 类型变量,所以无论怎么加载 Cache Line,这整个 Cache Line 里都没有会发生更新操作的数据,于是只要数据被频繁地读取访问,就自然没有数据被换出 Cache 的可能,也因此不会产生伪共享的问题**。 --- ## CPU 如何选择线程的? 了解完 CPU 读取数据的过程后,我们再来看看 CPU 是根据什么来选择当前要执行的线程。 在 Linux 内核中,进程和线程都是用 `task_struct` 结构体表示的,区别在于线程的 task_struct 结构体里部分资源是共享了进程已创建的资源,比如内存地址空间、代码段、文件描述符等,所以 Linux 中的线程也被称为轻量级进程,因为线程的 task_struct 相比进程的 task_struct 承载的 资源比较少,因此以「轻」得名。 一般来说,没有创建线程的进程,是只有单个执行流,它被称为是主线程。如果想让进程处理更多的事情,可以创建多个线程分别去处理,但不管怎么样,它们对应到内核里都是 `task_struct`。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/任务.png) 所以,Linux 内核里的调度器,调度的对象就是 `task_struct`,接下来我们就把这个数据结构统称为**任务**。 在 Linux 系统中,根据任务的优先级以及响应要求,主要分为两种,其中优先级的数值越小,优先级越高: - 实时任务,对系统的响应时间要求很高,也就是要尽可能快的执行实时任务,优先级在 `0~99` 范围内的就算实时任务; - 普通任务,响应时间没有很高的要求,优先级在 `100~139` 范围内都是普通任务级别; ### 调度类 由于任务有优先级之分,Linux 系统为了保障高优先级的任务能够尽可能早的被执行,于是分为了这几种调度类,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/调度类.png) Deadline 和 Realtime 这两个调度类,都是应用于实时任务的,这两个调度类的调度策略合起来共有这三种,它们的作用如下: - *SCHED_DEADLINE*:是按照 deadline 进行调度的,距离当前时间点最近的 deadline 的任务会被优先调度; - *SCHED_FIFO*:对于相同优先级的任务,按先来先服务的原则,但是优先级更高的任务,可以抢占低优先级的任务,也就是优先级高的可以「插队」; - *SCHED_RR*:对于相同优先级的任务,轮流着运行,每个任务都有一定的时间片,当用完时间片的任务会被放到队列尾部,以保证相同优先级任务的公平性,但是高优先级的任务依然可以抢占低优先级的任务; 而 Fair 调度类是应用于普通任务,都是由 CFS 调度器管理的,分为两种调度策略: - *SCHED_NORMAL*:普通任务使用的调度策略; - *SCHED_BATCH*:后台任务的调度策略,不和终端进行交互,因此在不影响其他需要交互的任务,可以适当降低它的优先级。 ### 完全公平调度 我们平日里遇到的基本都是普通任务,对于普通任务来说,公平性最重要,在 Linux 里面,实现了一个基于 CFS 的调度算法,也就是**完全公平调度(*Completely Fair Scheduling*)**。 这个算法的理念是想让分配给每个任务的 CPU 时间是一样,于是它为每个任务安排一个虚拟运行时间 vruntime,如果一个任务在运行,其运行的越久,该任务的 vruntime 自然就会越大,而没有被运行的任务,vruntime 是不会变化的。 那么,**在 CFS 算法调度的时候,会优先选择 vruntime 少的任务**,以保证每个任务的公平性。 这就好比,让你把一桶的奶茶平均分到 10 杯奶茶杯里,你看着哪杯奶茶少,就多倒一些;哪个多了,就先不倒,这样经过多轮操作,虽然不能保证每杯奶茶完全一样多,但至少是公平的。 当然,上面提到的例子没有考虑到优先级的问题,虽然是普通任务,但是普通任务之间还是有优先级区分的,所以在计算虚拟运行时间 vruntime 还要考虑普通任务的**权重值**,注意权重值并不是优先级的值,内核中会有一个 nice 级别与权重值的转换表,nice 级别越低的权重值就越大,至于 nice 值是什么,我们后面会提到。 于是就有了以下这个公式: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/vruntime.png) 你可以不用管 NICE_0_LOAD 是什么,你就认为它是一个常量,那么在「同样的实际运行时间」里,高权重任务的 vruntime 比低权重任务的 vruntime **少**,你可能会奇怪为什么是少的?你还记得 CFS 调度吗,它是会优先选择 vruntime 少的任务进行调度,所以高权重的任务就会被优先调度了,于是高权重的获得的实际运行时间自然就多了。 ### CPU 运行队列 一个系统通常都会运行着很多任务,多任务的数量基本都是远超 CPU 核心数量,因此这时候就需要**排队**。 事实上,每个 CPU 都有自己的**运行队列(*Run Queue, rq*)**,用于描述在此 CPU 上所运行的所有进程,其队列包含三个运行队列,Deadline 运行队列 dl_rq、实时任务运行队列 rt_rq 和 CFS 运行队列 cfs_rq,其中 cfs_rq 是用红黑树来描述的,按 vruntime 大小来排序的,最左侧的叶子节点,就是下次会被调度的任务。 PS:下图中的 csf_rq 应该是 `cfs_rq`,由于找不到原图了,我偷个懒,我就不重新画了,嘻嘻。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/CPU队列.png) 这几种调度类是有优先级的,优先级如下:Deadline > Realtime > Fair,这意味着 Linux 选择下一个任务执行的时候,会按照此优先级顺序进行选择,也就是说先从 `dl_rq` 里选择任务,然后从 `rt_rq` 里选择任务,最后从 `cfs_rq` 里选择任务。因此,**实时任务总是会比普通任务优先被执行**。 ### 调整优先级 如果我们启动任务的时候,没有特意去指定优先级的话,默认情况下都是普通任务,普通任务的调度类是 Fair,由 CFS 调度器来进行管理。CFS 调度器的目的是实现任务运行的公平性,也就是保障每个任务的运行的时间是差不多的。 如果你想让某个普通任务有更多的执行时间,可以调整任务的 `nice` 值,从而让优先级高一些的任务执行更多时间。nice 的值能设置的范围是 `-20~19`,值越低,表明优先级越高,因此 -20 是最高优先级,19 则是最低优先级,默认优先级是 0。 是不是觉得 nice 值的范围很诡异?事实上,nice 值并不是表示优先级,而是表示优先级的修正数值,它与优先级(priority)的关系是这样的:priority(new) = priority(old) + nice。内核中,priority 的范围是 0~139,值越低,优先级越高,其中前面的 0~99 范围是提供给实时任务使用的,而 nice 值是映射到 100~139,这个范围是提供给普通任务用的,因此 nice 值调整的是普通任务的优先级。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/优先级.png) 在前面我们提到了,权重值与 nice 值是有关系的,nice 值越低,权重值就越大,计算出来的 vruntime 就会越少,由于 CFS 算法调度的时候,就会优先选择 vruntime 少的任务进行执行,所以 nice 值越低,任务的优先级就越高。 我们可以在启动任务的时候,可以指定 nice 的值,比如将 mysqld 以 -3 优先级: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/nice.png) 如果想修改已经运行中的任务的优先级,则可以使用 `renice` 来调整 nice 值: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/renice.png) nice 调整的是普通任务的优先级,所以不管怎么缩小 nice 值,任务永远都是普通任务,如果某些任务要求实时性比较高,那么你可以考虑改变任务的优先级以及调度策略,使得它变成实时任务,比如: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU伪共享/chrt.png) --- ## 总结 理解 CPU 是如何读写数据的前提,是要理解 CPU 的架构,CPU 内部的多个 Cache + 外部的内存和磁盘都就构成了金字塔的存储器结构,在这个金字塔中,越往下,存储器的容量就越大,但访问速度就会小。 CPU 读写数据的时候,并不是按一个一个字节为单位来进行读写,而是以 CPU Cache Line 大小为单位,CPU Cache Line 大小一般是 64 个字节,也就意味着 CPU 读写数据的时候,每一次都是以 64 字节大小为一块进行操作。 因此,如果我们操作的数据是数组,那么访问数组元素的时候,按内存分布的地址顺序进行访问,这样能充分利用到 Cache,程序的性能得到提升。但如果操作的数据不是数组,而是普通的变量,并在多核 CPU 的情况下,我们还需要避免 Cache Line 伪共享的问题。 所谓的 Cache Line 伪共享问题就是,多个线程同时读写同一个 Cache Line 的不同变量时,而导致 CPU Cache 失效的现象。那么对于多个线程共享的热点数据,即经常会修改的数据,应该避免这些数据刚好在同一个 Cache Line 中,避免的方式一般有 Cache Line 大小字节对齐,以及字节填充等方法。 系统中需要运行的多线程数一般都会大于 CPU 核心,这样就会导致线程排队等待 CPU,这可能会产生一定的延时,如果我们的任务对延时容忍度很低,则可以通过一些人为手段干预 Linux 的默认调度策略和优先级。 --- ## 关注作者 ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF*** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/其他/公众号介绍.png) ================================================ FILE: os/1_hardware/how_cpu_run.md ================================================ # 2.1 CPU 是如何执行程序的? 代码写了那么多,你知道 `a = 1 + 2` 这条代码是怎么被 CPU 执行的吗? 软件用了那么多,你知道软件的 32 位和 64 位之间的区别吗?再来 32 位的操作系统可以运行在 64 位的电脑上吗?64 位的操作系统可以运行在 32 位的电脑上吗?如果不行,原因是什么? CPU 看了那么多,我们都知道 CPU 通常分为 32 位和 64 位,你知道 64 位相比 32 位 CPU 的优势在哪吗?64 位 CPU 的计算性能一定比 32 位 CPU 高很多吗? 不知道也不用慌张,接下来就循序渐进的、一层一层的攻破这些问题。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/程序执行/程序执行提纲.png) --- ## 图灵机的工作方式 要想知道程序执行的原理,我们可以先从「图灵机」说起,图灵的基本思想是用机器来模拟人们用纸笔进行数学运算的过程,而且还定义了计算机由哪些部分组成,程序又是如何执行的。 图灵机长什么样子呢?你从下图可以看到图灵机的实际样子: ![图来源自:http://www.kristergustafsson.me/turing-machine/](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/程序执行/Turing%2Bmachine%2B1.jpeg) 图灵机的基本组成如下: - 有一条「纸带」,纸带由一个个连续的格子组成,每个格子可以写入字符,纸带就好比内存,而纸带上的格子的字符就好比内存中的数据或程序; - 有一个「读写头」,读写头可以读取纸带上任意格子的字符,也可以把字符写入到纸带的格子; - 读写头上有一些部件,比如存储单元、控制单元以及运算单元: 1、存储单元用于存放数据; 2、控制单元用于识别字符是数据还是指令,以及控制程序的流程等; 3、运算单元用于执行运算指令; 知道了图灵机的组成后,我们以简单数学运算的 `1 + 2` 作为例子,来看看它是怎么执行这行代码的。 - 首先,用读写头把「1、2、+」这 3 个字符分别写入到纸带上的 3 个格子,然后读写头先停在 1 字符对应的格子上; ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/程序执行/图灵机-第一步.png) - 接着,读写头读入 1 到存储设备中,这个存储设备称为图灵机的状态; ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/程序执行/图灵机-第二步.png) - 然后读写头向右移动一个格,用同样的方式把 2 读入到图灵机的状态,于是现在图灵机的状态中存储着两个连续的数字,1 和 2; ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/程序执行/图灵机-第三步.png) - 读写头再往右移动一个格,就会碰到 + 号,读写头读到 + 号后,将 + 号传输给「控制单元」,控制单元发现是一个 + 号而不是数字,所以没有存入到状态中,因为 `+` 号是运算符指令,作用是加和目前的状态,于是通知「运算单元」工作。运算单元收到要加和状态中的值的通知后,就会把状态中的 1 和 2 读入并计算,再将计算的结果 3 存放到状态中; ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/程序执行/图灵机-第四步.png) - 最后,运算单元将结果返回给控制单元,控制单元将结果传输给读写头,读写头向右移动,把结果 3 写入到纸带的格子中; ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/程序执行/图灵机-第五步.png) 通过上面的图灵机计算 `1 + 2` 的过程,可以发现图灵机主要功能就是读取纸带格子中的内容,然后交给控制单元识别字符是数字还是运算符指令,如果是数字则存入到图灵机状态中,如果是运算符,则通知运算符单元读取状态中的数值进行计算,计算结果最终返回给读写头,读写头把结果写入到纸带的格子中。 事实上,图灵机这个看起来很简单的工作方式,和我们今天的计算机是基本一样的。接下来,我们一同再看看当今计算机的组成以及工作方式。 --- ## 冯诺依曼模型 在 1945 年冯诺依曼和其他计算机科学家们提出了计算机具体实现的报告,其遵循了图灵机的设计,而且还提出用电子元件构造计算机,并约定了用二进制进行计算和存储。 最重要的是定义计算机基本结构为 5 个部分,分别是**运算器、控制器、存储器、输入设备、输出设备**,这 5 个部分也被称为**冯诺依曼模型**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4/操作系统/Von_Neumann_architecture.svg) 运算器、控制器是在中央处理器里的,存储器就我们常见的内存,输入输出设备则是计算机外接的设备,比如键盘就是输入设备,显示器就是输出设备。 存储单元和输入输出设备要与中央处理器打交道的话,离不开总线。所以,它们之间的关系如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/程序执行/冯诺依曼模型.png) 接下来,分别介绍内存、中央处理器、总线、输入输出设备。 ### 内存 我们的程序和数据都是存储在内存,存储的区域是线性的。 在计算机数据存储中,存储数据的基本单位是**字节(*byte*)**,1 字节等于 8 位(8 bit)。每一个字节都对应一个内存地址。 内存的地址是从 0 开始编号的,然后自增排列,最后一个地址为内存总字节数 - 1,这种结构好似我们程序里的数组,所以内存的读写任何一个数据的速度都是一样的。 ### 中央处理器 中央处理器也就是我们常说的 CPU,32 位和 64 位 CPU 最主要区别在于一次能计算多少字节数据: - 32 位 CPU 一次可以计算 4 个字节; - 64 位 CPU 一次可以计算 8 个字节; 这里的 32 位和 64 位,通常称为 CPU 的位宽。 之所以 CPU 要这样设计,是为了能计算更大的数值,如果是 8 位的 CPU,那么一次只能计算 1 个字节 `0~255` 范围内的数值,这样就无法一次完成计算 `10000 * 500` ,于是为了能一次计算大数的运算,CPU 需要支持多个 byte 一起计算,所以 CPU 位宽越大,可以计算的数值就越大,比如说 32 位 CPU 能计算的最大整数是 `4294967295`。 CPU 内部还有一些组件,常见的有**寄存器、控制单元和逻辑运算单元**等。其中,控制单元负责控制 CPU 工作,逻辑运算单元负责计算,而寄存器可以分为多种类,每种寄存器的功能又不尽相同。 CPU 中的寄存器主要作用是存储计算时的数据,你可能好奇为什么有了内存还需要寄存器?原因很简单,因为内存离 CPU 太远了,而寄存器就在 CPU 里,还紧挨着控制单元和逻辑运算单元,自然计算时速度会很快。 常见的寄存器种类: - *通用寄存器*,用来存放需要进行运算的数据,比如需要进行加和运算的两个数据。 - *程序计数器*,用来存储 CPU 要执行下一条指令「所在的内存地址」,注意不是存储了下一条要执行的指令,此时指令还在内存中,程序计数器只是存储了下一条指令「的地址」。 - *指令寄存器*,用来存放当前正在执行的指令,也就是指令本身,指令被执行完成之前,指令都存储在这里。 ### 总线 总线是用于 CPU 和内存以及其他设备之间的通信,总线可分为 3 种: - *地址总线*,用于指定 CPU 将要操作的内存地址; - *数据总线*,用于读写内存的数据; - *控制总线*,用于发送和接收信号,比如中断、设备复位等信号,CPU 收到信号后自然进行响应,这时也需要控制总线; 当 CPU 要读写内存数据的时候,一般需要通过下面这三个总线: - 首先要通过「地址总线」来指定内存的地址; - 然后通过「控制总线」控制是读或写命令; - 最后通过「数据总线」来传输数据; ### 输入、输出设备 输入设备向计算机输入数据,计算机经过计算后,把数据输出给输出设备。期间,如果输入设备是键盘,按下按键时是需要和 CPU 进行交互的,这时就需要用到控制总线了。 --- ## 线路位宽与 CPU 位宽 数据是如何通过线路传输的呢?其实是通过操作电压,低电压表示 0,高压电压则表示 1。 如果构造了高低高这样的信号,其实就是 101 二进制数据,十进制则表示 5,如果只有一条线路,就意味着每次只能传递 1 bit 的数据,即 0 或 1,那么传输 101 这个数据,就需要 3 次才能传输完成,这样的效率非常低。 这样一位一位传输的方式,称为串行,下一个 bit 必须等待上一个 bit 传输完成才能进行传输。当然,想一次多传一些数据,增加线路即可,这时数据就可以并行传输。 为了避免低效率的串行传输的方式,线路的位宽最好一次就能访问到所有的内存地址。 CPU 要想操作的内存地址就需要地址总线: - 如果地址总线只有 1 条,那每次只能表示「0 或 1」这两种地址,所以 CPU 能操作的内存地址最大数量为 2(2^1)个(注意,不要理解成同时能操作 2 个内存地址); - 如果地址总线有 2 条,那么能表示 00、01、10、11 这四种地址,所以 CPU 能操作的内存地址最大数量为 4(2^2)个。 那么,想要 CPU 操作 4G 大的内存,那么就需要 32 条地址总线,因为 `2 ^ 32 = 4G`。 知道了线路位宽的意义后,我们再来看看 CPU 位宽。 CPU 的位宽最好不要小于线路位宽,比如 32 位 CPU 控制 40 位宽的地址总线和数据总线的话,工作起来就会非常复杂且麻烦,所以 32 位的 CPU 最好和 32 位宽的线路搭配,因为 32 位 CPU 一次最多只能操作 32 位宽的地址总线和数据总线。 如果用 32 位 CPU 去加和两个 64 位大小的数字,就需要把这 2 个 64 位的数字分成 2 个低位 32 位数字和 2 个高位 32 位数字来计算,先加个两个低位的 32 位数字,算出进位,然后加和两个高位的 32 位数字,最后再加上进位,就能算出结果了,可以发现 32 位 CPU 并不能一次性计算出加和两个 64 位数字的结果。 对于 64 位 CPU 就可以一次性算出加和两个 64 位数字的结果,因为 64 位 CPU 可以一次读入 64 位的数字,并且 64 位 CPU 内部的逻辑运算单元也支持 64 位数字的计算。 但是并不代表 64 位 CPU 性能比 32 位 CPU 高很多,很少应用需要算超过 32 位的数字,所以**如果计算的数额不超过 32 位数字的情况下,32 位和 64 位 CPU 之间没什么区别的,只有当计算超过 32 位数字的情况下,64 位的优势才能体现出来**。 另外,32 位 CPU 最大只能操作 4GB 内存,就算你装了 8 GB 内存条,也没用。而 64 位 CPU 寻址范围则很大,理论最大的寻址空间为 `2^64`。 --- ## 程序执行的基本过程 在前面,我们知道了程序在图灵机的执行过程,接下来我们来看看程序在冯诺依曼模型上是怎么执行的。 程序实际上是一条一条指令,所以程序的运行过程就是把每一条指令一步一步的执行起来,负责执行指令的就是 CPU 了。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/程序执行/CPU执行程序.png) 那 CPU 执行程序的过程如下: - 第一步,CPU 读取「程序计数器」的值,这个值是指令的内存地址,然后 CPU 的「控制单元」操作「地址总线」指定需要访问的内存地址,接着通知内存设备准备数据,数据准备好后通过「数据总线」将指令数据传给 CPU,CPU 收到内存传来的数据后,将这个指令数据存入到「指令寄存器」。 - 第二步,「程序计数器」的值自增,表示指向下一条指令。这个自增的大小,由 CPU 的位宽决定,比如 32 位的 CPU,指令是 4 个字节,需要 4 个内存地址存放,因此「程序计数器」的值会自增 4; - 第三步,CPU 分析「指令寄存器」中的指令,确定指令的类型和参数,如果是计算类型的指令,就把指令交给「逻辑运算单元」运算;如果是存储类型的指令,则交由「控制单元」执行; 简单总结一下就是,一个程序执行的时候,CPU 会根据程序计数器里的内存地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。 CPU 从程序计数器读取指令、到执行、再到下一条指令,这个过程会不断循环,直到程序执行结束,这个不断循环的过程被称为 **CPU 的指令周期**。 --- ## a = 1 + 2 执行具体过程 知道了基本的程序执行过程后,接下来用 `a = 1 + 2` 的作为例子,进一步分析该程序在冯诺伊曼模型的执行过程。 CPU 是不认识 `a = 1 + 2` 这个字符串,这些字符串只是方便我们程序员认识,要想这段程序能跑起来,还需要把整个程序翻译成**汇编语言**的程序,这个过程称为编译成汇编代码。 针对汇编代码,我们还需要用汇编器翻译成机器码,这些机器码由 0 和 1 组成的机器语言,这一条条机器码,就是一条条的**计算机指令**,这个才是 CPU 能够真正认识的东西。 下面来看看 `a = 1 + 2` 在 32 位 CPU 的执行过程。 程序编译过程中,编译器通过分析代码,发现 1 和 2 是数据,于是程序运行时,内存会有个专门的区域来存放这些数据,这个区域就是「数据段」。如下图,数据 1 和 2 的区域位置: - 数据 1 被存放到 0x200 位置; - 数据 2 被存放到 0x204 位置; 注意,数据和指令是分开区域存放的,存放指令区域的地方称为「正文段」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/程序执行/数据段与正文段.png) 编译器会把 `a = 1 + 2` 翻译成 4 条指令,存放到正文段中。如图,这 4 条指令被存放到了 0x100 ~ 0x10c 的区域中: - 0x100 的内容是 `load` 指令将 0x200 地址中的数据 1 装入到寄存器 `R0`; - 0x104 的内容是 `load` 指令将 0x204 地址中的数据 2 装入到寄存器 `R1`; - 0x108 的内容是 `add` 指令将寄存器 `R0` 和 `R1` 的数据相加,并把结果存放到寄存器 `R2`; - 0x10c 的内容是 `store` 指令将寄存器 `R2` 中的数据存回数据段中的 0x208 地址中,这个地址也就是变量 `a` 内存中的地址; 编译完成后,具体执行程序的时候,程序计数器会被设置为 0x100 地址,然后依次执行这 4 条指令。 上面的例子中,由于是在 32 位 CPU 执行的,因此一条指令是占 32 位大小,所以你会发现每条指令间隔 4 个字节。 而数据的大小是根据你在程序中指定的变量类型,比如 `int` 类型的数据则占 4 个字节,`char` 类型的数据则占 1 个字节。 ### 指令 上面的例子中,图中指令的内容我写的是简易的汇编代码,目的是为了方便理解指令的具体内容,事实上指令的内容是一串二进制数字的机器码,每条指令都有对应的机器码,CPU 通过解析机器码来知道指令的内容。 不同的 CPU 有不同的指令集,也就是对应着不同的汇编语言和不同的机器码,接下来选用最简单的 MIPS 指集,来看看机器码是如何生成的,这样也能明白二进制的机器码的具体含义。 MIPS 的指令是一个 32 位的整数,高 6 位代表着操作码,表示这条指令是一条什么样的指令,剩下的 26 位不同指令类型所表示的内容也就不相同,主要有三种类型 R、I 和 J。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/程序执行/MIPS指令集.png) 一起具体看看这三种类型的含义: - *R 指令*,用在算术和逻辑操作,里面有读取和写入数据的寄存器地址。如果是逻辑位移操作,后面还有位移操作的「位移量」,而最后的「功能码」则是再前面的操作码不够的时候,扩展操作码来表示对应的具体指令的; - *I 指令*,用在数据传输、条件分支等。这个类型的指令,就没有了位移量和功能码,也没有了第三个寄存器,而是把这三部分直接合并成了一个地址值或一个常数; - *J 指令*,用在跳转,高 6 位之外的 26 位都是一个跳转后的地址; 接下来,我们把前面例子的这条指令:「`add` 指令将寄存器 `R0` 和 `R1` 的数据相加,并把结果放入到 `R2`」,翻译成机器码。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/程序执行/add的MIPS指令.png) 加和运算 add 指令是属于 R 指令类型: - add 对应的 MIPS 指令里操作码是 `000000`,以及最末尾的功能码是 `100000`,这些数值都是固定的,查一下 MIPS 指令集的手册就能知道的; - rs 代表第一个寄存器 R0 的编号,即 `00000`; - rt 代表第二个寄存器 R1 的编号,即 `00001`; - rd 代表目标的临时寄存器 R2 的编号,即 `00010`; - 因为不是位移操作,所以位移量是 `00000` 把上面这些数字拼在一起就是一条 32 位的 MIPS 加法指令了,那么用 16 进制表示的机器码则是 `0x00011020`。 编译器在编译程序的时候,会构造指令,这个过程叫做指令的编码。CPU 执行程序的时候,就会解析指令,这个过程叫作指令的解码。 现代大多数 CPU 都使用来流水线的方式来执行指令,所谓的流水线就是把一个任务拆分成多个小任务,于是一条指令通常分为 4 个阶段,称为 4 级流水线,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/程序执行/CPU指令周期.png) 四个阶段的具体含义: 1. CPU 通过程序计数器读取对应内存地址的指令,这个部分称为 **Fetch(取得指令)**; 2. CPU 对指令进行解码,这个部分称为 **Decode(指令译码)**; 3. CPU 执行指令,这个部分称为 **Execution(执行指令)**; 4. CPU 将计算结果存回寄存器或者将寄存器的值存入内存,这个部分称为 **Store(数据回写)**; 上面这 4 个阶段,我们称为**指令周期(*Instrution Cycle*)**,CPU 的工作就是一个周期接着一个周期,周而复始。 事实上,不同的阶段其实是由计算机中的不同组件完成的: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/程序执行/指令周期工作组件.png) - 取指令的阶段,我们的指令是存放在**存储器**里的,实际上,通过程序计数器和指令寄存器取出指令的过程,是由**控制器**操作的; - 指令的译码过程,也是由**控制器**进行的; - 指令执行的过程,无论是进行算术操作、逻辑操作,还是进行数据传输、条件分支操作,都是由**算术逻辑单元**操作的,也就是由**运算器**处理的。但是如果是一个简单的无条件地址跳转,则是直接在**控制器**里面完成的,不需要用到运算器。 ### 指令的类型 指令从功能角度划分,可以分为 5 大类: - *数据传输类型的指令*,比如 `store/load` 是寄存器与内存间数据传输的指令,`mov` 是将一个内存地址的数据移动到另一个内存地址的指令; - *运算类型的指令*,比如加减乘除、位运算、比较大小等等,它们最多只能处理两个寄存器中的数据; - *跳转类型的指令*,通过修改程序计数器的值来达到跳转执行指令的过程,比如编程中常见的 `if-else`、`switch-case`、函数调用等。 - *信号类型的指令*,比如发生中断的指令 `trap`; - *闲置类型的指令*,比如指令 `nop`,执行后 CPU 会空转一个周期; ### 指令的执行速度 CPU 的硬件参数都会有 `GHz` 这个参数,比如一个 1 GHz 的 CPU,指的是时钟频率是 1 G,代表着 1 秒会产生 1G 次数的脉冲信号,每一次脉冲信号高低电平的转换就是一个周期,称为时钟周期。 对于 CPU 来说,在一个时钟周期内,CPU 仅能完成一个最基本的动作,时钟频率越高,时钟周期就越短,工作速度也就越快。 一个时钟周期一定能执行完一条指令吗?答案是不一定的,大多数指令不能在一个时钟周期完成,通常需要若干个时钟周期。不同的指令需要的时钟周期是不同的,加法和乘法都对应着一条 CPU 指令,但是乘法需要的时钟周期就要比加法多。 > 如何让程序跑的更快? 程序执行的时候,耗费的 CPU 时间少就说明程序是快的,对于程序的 CPU 执行时间,我们可以拆解成 **CPU 时钟周期数(*CPU Cycles*)和时钟周期时间(*Clock Cycle Time*)的乘积**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/程序执行/程序的CPU执行时间公式1.png) 时钟周期时间就是我们前面提及的 CPU 主频,主频越高说明 CPU 的工作速度就越快,比如我手头上的电脑的 CPU 是 2.4 GHz 四核 Intel Core i5,这里的 2.4 GHz 就是电脑的主频,时钟周期时间就是 1/2.4G。 要想 CPU 跑的更快,自然缩短时钟周期时间,也就是提升 CPU 主频,但是今非彼日,摩尔定律早已失效,当今的 CPU 主频已经很难再做到翻倍的效果了。 另外,换一个更好的 CPU,这个也是我们软件工程师控制不了的事情,我们应该把目光放到另外一个乘法因子 —— CPU 时钟周期数,如果能减少程序所需的 CPU 时钟周期数量,一样也是能提升程序的性能的。 对于 CPU 时钟周期数我们可以进一步拆解成:「**指令数 x 每条指令的平均时钟周期数(*Cycles Per Instruction*,简称 `CPI`)**」,于是程序的 CPU 执行时间的公式可变成如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/程序执行/程序的CPU执行时间公式2.png) 因此,要想程序跑的更快,优化这三者即可: - *指令数*,表示执行程序所需要多少条指令,以及哪些指令。这个层面是基本靠编译器来优化,毕竟同样的代码,在不同的编译器,编译出来的计算机指令会有各种不同的表示方式。 - *每条指令的平均时钟周期数 CPI*,表示一条指令需要多少个时钟周期数,现代大多数 CPU 通过流水线技术(Pipeline),让一条指令需要的 CPU 时钟周期数尽可能的少; - *时钟周期时间*,表示计算机主频,取决于计算机硬件。有的 CPU 支持超频技术,打开了超频意味着把 CPU 内部的时钟给调快了,于是 CPU 工作速度就变快了,但是也是有代价的,CPU 跑的越快,散热的压力就会越大,CPU 会很容易崩溃。 很多厂商为了跑分而跑分,基本都是在这三个方面入手的哦,特别是超频这一块。 --- ## 总结 最后我们再来回答开头的问题。 > 64 位相比 32 位 CPU 的优势在哪吗?64 位 CPU 的计算性能一定比 32 位 CPU 高很多吗? 64 位相比 32 位 CPU 的优势主要体现在两个方面: - 64 位 CPU 可以一次计算超过 32 位的数字,而 32 位 CPU 如果要计算超过 32 位的数字,要分多步骤进行计算,效率就没那么高,但是大部分应用程序很少会计算那么大的数字,所以**只有运算大数字的时候,64 位 CPU 的优势才能体现出来,否则和 32 位 CPU 的计算性能相差不大**。 - 64 位 CPU 可以**寻址更大的内存空间**,32 位 CPU 最大的寻址地址是 4G,即使你加了 8G 大小的内存,也还是只能寻址到 4G,而 64 位 CPU 最大寻址地址是 `2^64`,远超于 32 位 CPU 最大寻址地址的 `2^32`。 > 你知道软件的 32 位和 64 位之间的区别吗?再来 32 位的操作系统可以运行在 64 位的电脑上吗?64 位的操作系统可以运行在 32 位的电脑上吗?如果不行,原因是什么? 64 位和 32 位软件,实际上代表指令是 64 位还是 32 位的: - 如果 32 位指令在 64 位机器上执行,需要一套兼容机制,就可以做到兼容运行了。但是**如果 64 位指令在 32 位机器上执行,就比较困难了,因为 32 位的寄存器存不下 64 位的指令**; - 操作系统其实也是一种程序,我们也会看到操作系统会分成 32 位操作系统、64 位操作系统,其代表意义就是操作系统中程序的指令是多少位,比如 64 位操作系统,指令也就是 64 位,因此不能装在 32 位机器上。 总之,硬件的 64 位和 32 位指的是 CPU 的位宽,软件的 64 位和 32 位指的是指令的位宽。 ---- ## 关注作者 **哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: os/1_hardware/how_to_make_cpu_run_faster.md ================================================ # 2.3 如何写出让 CPU 跑得更快的代码? 代码都是由 CPU 跑起来的,我们代码写的好与坏就决定了 CPU 的执行效率,特别是在编写计算密集型的程序,更要注重 CPU 的执行效率,否则将会大大影响系统性能。 CPU 内部嵌入了 CPU Cache(高速缓存),它的存储容量很小,但是离 CPU 核心很近,所以缓存的读写速度是极快的,那么如果 CPU 运算时,直接从 CPU Cache 读取数据,而不是从内存的话,运算速度就会很快。 但是,大多数人不知道 CPU Cache 的运行机制,以至于不知道如何才能够写出能够配合 CPU Cache 工作机制的代码,一旦你掌握了它,你写代码的时候,就有新的优化思路了。 那么,接下来我们就来看看,CPU Cache 到底是什么样的,是如何工作的呢,又该如何写出让 CPU 执行更快的代码呢? ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/CPU%E7%BC%93%E5%AD%98/CPUCache%E6%8F%90%E7%BA%B2.png) --- ## CPU Cache 有多快? 你可能会好奇为什么有了内存,还需要 CPU Cache?根据摩尔定律,CPU 的访问速度每 18 个月就会翻倍,相当于每年增长 60% 左右,内存的速度当然也会不断增长,但是增长的速度远小于 CPU,平均每年只增长 7% 左右。于是,CPU 与内存的访问性能的差距不断拉大。 到现在,一次内存访问所需时间是 `200~300` 多个时钟周期,这意味着 CPU 和内存的访问速度已经相差 `200~300` 多倍了。 为了弥补 CPU 与内存两者之间的性能差异,就在 CPU 内部引入了 CPU Cache,也称高速缓存。 CPU Cache 通常分为大小不等的三级缓存,分别是 **L1 Cache、L2 Cache 和 L3 Cache**。 由于 CPU Cache 所使用的材料是 SRAM,价格比内存使用的 DRAM 高出很多,在当今每生产 1 MB 大小的 CPU Cache 需要 7 美金的成本,而内存只需要 0.015 美金的成本,成本方面相差了 466 倍,所以 CPU Cache 不像内存那样动辄以 GB 计算,它的大小是以 KB 或 MB 来计算的。 在 Linux 系统中,我们可以使用下图的方式来查看各级 CPU Cache 的大小,比如我这手上这台服务器,离 CPU 核心最近的 L1 Cache 是 32KB,其次是 L2 Cache 是 256KB,最大的 L3 Cache 则是 3MB。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU缓存/查看CPU高速缓存大小.png) 其中,**L1 Cache 通常会分为「数据缓存」和「指令缓存」**,这意味着数据和指令在 L1 Cache 这一层是分开缓存的,上图中的 `index0` 也就是数据缓存,而 `index1` 则是指令缓存,它两的大小通常是一样的。 另外,你也会注意到,L3 Cache 比 L1 Cache 和 L2 Cache 大很多,这是因为 **L1 Cache 和 L2 Cache 都是每个 CPU 核心独有的,而 L3 Cache 是多个 CPU 核心共享的。** 程序执行时,会先将内存中的数据加载到共享的 L3 Cache 中,再加载到每个核心独有的 L2 Cache,最后进入到最快的 L1 Cache,之后才会被 CPU 读取。它们之间的层级关系,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/存储结构/CPU-Cache.png) 越靠近 CPU 核心的缓存其访问速度越快,CPU 访问 L1 Cache 只需要 `2~4` 个时钟周期,访问 L2 Cache 大约 `10~20` 个时钟周期,访问 L3 Cache 大约 `20~60` 个时钟周期,而访问内存速度大概在 `200~300` 个 时钟周期之间。如下表格: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU缓存/访问速度表格.png) **所以,CPU 从 L1 Cache 读取数据的速度,相比从内存读取的速度,会快 `100` 多倍。** --- ## CPU Cache 的数据结构和读取过程是什么样的? 我们先简单了解下 CPU Cache 的结构,CPU Cache 是由很多个 Cache Line 组成的,Cache Line 是 CPU 从内存读取数据的基本单位,而 Cache Line 是由各种标志(Tag)+ 数据块(Data Block)组成,你可以在下图清晰的看到: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/CPU%E7%BC%93%E5%AD%98%E4%B8%80%E8%87%B4%E6%80%A7/Cache%E7%9A%84%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84.png) CPU Cache 的数据是从内存中读取过来的,它是以一小块一小块读取数据的,而不是按照单个数组元素来读取数据的,在 CPU Cache 中的,这样一小块一小块的数据,称为 **Cache Line(缓存块)**。 你可以在你的 Linux 系统,用下面这种方式来查看 CPU 的 Cache Line,你可以看我服务器的 L1 Cache Line 大小是 64 字节,也就意味着 **L1 Cache 一次载入数据的大小是 64 字节**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU缓存/查看CPULine大小.png) 比如,有一个 `int array[100]` 的数组,当载入 `array[0]` 时,由于这个数组元素的大小在内存只占 4 字节,不足 64 字节,CPU 就会**顺序加载**数组元素到 `array[15]`,意味着 `array[0]~array[15]` 数组元素都会被缓存在 CPU Cache 中了,因此当下次访问这些数组元素时,会直接从 CPU Cache 读取,而不用再从内存中读取,大大提高了 CPU 读取数据的性能。 事实上,CPU 读取数据的时候,无论数据是否存放到 Cache 中,CPU 都是先访问 Cache,只有当 Cache 中找不到数据时,才会去访问内存,并把内存中的数据读入到 Cache 中,CPU 再从 CPU Cache 读取数据。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU缓存/缓存逻辑.png) 这样的访问机制,跟我们使用「内存作为硬盘的缓存」的逻辑是一样的,如果内存有缓存的数据,则直接返回,否则要访问龟速一般的硬盘。 那 CPU 怎么知道要访问的内存数据,是否在 Cache 里?如果在的话,如何找到 Cache 对应的数据呢?我们从最简单、基础的**直接映射 Cache(*Direct Mapped Cache*)** 说起,来看看整个 CPU Cache 的数据结构和访问逻辑。 前面,我们提到 CPU 访问内存数据时,是一小块一小块数据读取的,具体这一小块数据的大小,取决于 `coherency_line_size` 的值,一般 64 字节。在内存中,这一块的数据我们称为**内存块(*Block*)**,读取的时候我们要拿到数据所在内存块的地址。 对于直接映射 Cache 采用的策略,就是把内存块的地址始终「映射」在一个 CPU Cache Line(缓存块)的地址,至于映射关系实现方式,则是使用「取模运算」,取模运算的结果就是内存块地址对应的 CPU Cache Line(缓存块)的地址。 举个例子,内存共被划分为 32 个内存块,CPU Cache 共有 8 个 CPU Cache Line,假设 CPU 想要访问第 15 号内存块,如果 15 号内存块中的数据已经缓存在 CPU Cache Line 中的话,则是一定映射在 7 号 CPU Cache Line 中,因为 `15 % 8` 的值是 7。 机智的你肯定发现了,使用取模方式映射的话,就会出现多个内存块对应同一个 CPU Cache Line,比如上面的例子,除了 15 号内存块是映射在 7 号 CPU Cache Line 中,还有 7 号、23 号、31 号内存块都是映射到 7 号 CPU Cache Line 中。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/CPU%E7%BC%93%E5%AD%98/%E6%B1%82%E6%A8%A1%E6%98%A0%E5%B0%84%E7%AD%96%E7%95%A5.png) 因此,为了区别不同的内存块,在对应的 CPU Cache Line 中我们还会存储一个**组标记(Tag)**。这个组标记会记录当前 CPU Cache Line 中存储的数据对应的内存块,我们可以用这个组标记来区分不同的内存块。 除了组标记信息外,CPU Cache Line 还有两个信息: - 一个是,从内存加载过来的实际存放**数据(*Data*)**。 - 另一个是,**有效位(*Valid bit*)**,它是用来标记对应的 CPU Cache Line 中的数据是否是有效的,如果有效位是 0,无论 CPU Cache Line 中是否有数据,CPU 都会直接访问内存,重新加载数据。 CPU 在从 CPU Cache 读取数据的时候,并不是读取 CPU Cache Line 中的整个数据块,而是读取 CPU 所需要的一个数据片段,这样的数据统称为一个**字(*Word*)**。那怎么在对应的 CPU Cache Line 中数据块中找到所需的字呢?答案是,需要一个**偏移量(Offset)**。 因此,一个内存的访问地址,包括**组标记、CPU Cache Line 索引、偏移量**这三种信息,于是 CPU 就能通过这些信息,在 CPU Cache 中找到缓存的数据。而对于 CPU Cache 里的数据结构,则是由**索引 + 有效位 + 组标记 + 数据块**组成。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU缓存/直接Cache映射.png) 如果内存中的数据已经在 CPU Cahe 中了,那 CPU 访问一个内存地址的时候,会经历这 4 个步骤: 1. 根据内存地址中索引信息,计算在 CPU Cahe 中的索引,也就是找出对应的 CPU Cache Line 的地址; 2. 找到对应 CPU Cache Line 后,判断 CPU Cache Line 中的有效位,确认 CPU Cache Line 中数据是否是有效的,如果是无效的,CPU 就会直接访问内存,并重新加载数据,如果数据有效,则往下执行; 3. 对比内存地址中组标记和 CPU Cache Line 中的组标记,确认 CPU Cache Line 中的数据是我们要访问的内存数据,如果不是的话,CPU 就会直接访问内存,并重新加载数据,如果是的话,则往下执行; 4. 根据内存地址中偏移量信息,从 CPU Cache Line 的数据块中,读取对应的字。 到这里,相信你对直接映射 Cache 有了一定认识,但其实除了直接映射 Cache 之外,还有其他通过内存地址找到 CPU Cache 中的数据的策略,比如全相连 Cache(*Fully Associative Cache*)、组相连 Cache(*Set Associative Cache*)等,这几种策策略的数据结构都比较相似,我们理解了直接映射 Cache 的工作方式,其他的策略如果你有兴趣去看,相信很快就能理解的了。 --- ## 如何写出让 CPU 跑得更快的代码? 我们知道 CPU 访问内存的速度,比访问 CPU Cache 的速度慢了 100 多倍,所以如果 CPU 所要操作的数据在 CPU Cache 中的话,这样将会带来很大的性能提升。访问的数据在 CPU Cache 中的话,意味着**缓存命中**,缓存命中率越高的话,代码的性能就会越好,CPU 也就跑的越快。 于是,「如何写出让 CPU 跑得更快的代码?」这个问题,可以改成「如何写出 CPU 缓存命中率高的代码?」。 在前面我也提到,L1 Cache 通常分为「数据缓存」和「指令缓存」,这是因为 CPU 会分别处理数据和指令,比如 `1+1=2` 这个运算,`+` 就是指令,会被放在「指令缓存」中,而输入数字 `1` 则会被放在「数据缓存」里。 因此,**我们要分开来看「数据缓存」和「指令缓存」的缓存命中率**。 ### 如何提升数据缓存的命中率? 假设要遍历二维数组,有以下两种形式,虽然代码执行结果是一样,但你觉得哪种形式效率最高呢?为什么高呢? ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU缓存/遍历数组.png) 经过测试,形式一 `array[i][j]` 执行时间比形式二 `array[j][i]` 快好几倍。 之所以有这么大的差距,是因为二维数组 `array` 所占用的内存是连续的,比如长度 `N` 的值是 `2` 的话,那么内存中的数组元素的布局顺序是这样的: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU缓存/数组内存布局顺序.png) 形式一用 `array[i][j]` 访问数组元素的顺序,正是和内存中数组元素存放的顺序一致。当 CPU 访问 `array[0][0]` 时,由于该数据不在 Cache 中,于是会「顺序」把跟随其后的 3 个元素从内存中加载到 CPU Cache,这样当 CPU 访问后面的 3 个数组元素时,就能在 CPU Cache 中成功地找到数据,这意味着缓存命中率很高,缓存命中的数据不需要访问内存,这便大大提高了代码的性能。 而如果用形式二的 `array[j][i]` 来访问,则访问的顺序就是: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU缓存/形式二访问顺序.png) 你可以看到,访问的方式跳跃式的,而不是顺序的,那么如果 N 的数值很大,那么操作 `array[j][i]` 时,是没办法把 `array[j+1][i]` 也读入到 CPU Cache 中的,既然 `array[j+1][i]` 没有读取到 CPU Cache,那么就需要从内存读取该数据元素了。很明显,这种不连续性、跳跃式访问数据元素的方式,可能不能充分利用到了 CPU Cache 的特性,从而代码的性能不高。 那访问 `array[0][0]` 元素时,CPU 具体会一次从内存中加载多少元素到 CPU Cache 呢?这个问题,在前面我们也提到过,这跟 CPU Cache Line 有关,它表示 **CPU Cache 一次性能加载数据的大小**,可以在 Linux 里通过 `coherency_line_size` 配置查看 它的大小,通常是 64 个字节。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU缓存/查看CPULine大小.png) 也就是说,当 CPU 访问内存数据时,如果数据不在 CPU Cache 中,则会一次性会连续加载 64 字节大小的数据到 CPU Cache,那么当访问 `array[0][0]` 时,由于该元素不足 64 字节,于是就会往后**顺序**读取 `array[0][0]~array[0][15]` 到 CPU Cache 中。顺序访问的 `array[i][j]` 因为利用了这一特点,所以就会比跳跃式访问的 `array[j][i]` 要快。 **因此,遇到这种遍历数组的情况时,按照内存布局顺序访问,将可以有效的利用 CPU Cache 带来的好处,这样我们代码的性能就会得到很大的提升,** ### 如何提升指令缓存的命中率? 提升数据的缓存命中率的方式,是按照内存布局顺序访问,那针对指令的缓存该如何提升呢? 我们以一个例子来看看,有一个元素为 0 到 100 之间随机数字组成的一维数组: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU缓存/随机数数组.png) 接下来,对这个数组做两个操作: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/CPU%E7%BC%93%E5%AD%98/%E6%8E%92%E5%BA%8Fand%E6%95%B0%E7%BB%84%E9%81%8D%E5%8E%86.png) - 第一个操作,循环遍历数组,把小于 50 的数组元素置为 0; - 第二个操作,将数组排序; 那么问题来了,你觉得先遍历再排序速度快,还是先排序再遍历速度快呢? 在回答这个问题之前,我们先了解 CPU 的**分支预测器**。对于 if 条件语句,意味着此时至少可以选择跳转到两段不同的指令执行,也就是 if 还是 else 中的指令。那么,**如果分支预测可以预测到接下来要执行 if 里的指令,还是 else 指令的话,就可以「提前」把这些指令放在指令缓存中,这样 CPU 可以直接从 Cache 读取到指令,于是执行速度就会很快**。 当数组中的元素是随机的,分支预测就无法有效工作,而当数组元素都是是顺序的,分支预测器会动态地根据历史命中数据对未来进行预测,这样命中率就会很高。 因此,先排序再遍历速度会更快,这是因为排序之后,数字是从小到大的,那么前几次循环命中 `if < 50` 的次数会比较多,于是分支预测就会缓存 `if` 里的 `array[i] = 0` 指令到 Cache 中,后续 CPU 执行该指令就只需要从 Cache 读取就好了。 如果你肯定代码中的 `if` 中的表达式判断为 `true` 的概率比较高,我们可以使用显示分支预测工具,比如在 C/C++ 语言中编译器提供了 `likely` 和 `unlikely` 这两种宏,如果 `if` 条件为 `ture` 的概率大,则可以用 `likely` 宏把 `if` 里的表达式包裹起来,反之用 `unlikely` 宏。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/CPU%E7%BC%93%E5%AD%98/likely.png) 实际上,CPU 自身的动态分支预测已经是比较准的了,所以只有当非常确信 CPU 预测的不准,且能够知道实际的概率情况时,才建议使用这两种宏。 ### 如何提升多核 CPU 的缓存命中率? 在单核 CPU,虽然只能执行一个线程,但是操作系统给每个线程分配了一个时间片,时间片用完了,就调度下一个线程,于是各个线程就按时间片交替地占用 CPU,从宏观上看起来各个线程同时在执行。 而现代 CPU 都是多核心的,线程可能在不同 CPU 核心来回切换执行,这对 CPU Cache 不是有利的,虽然 L3 Cache 是多核心之间共享的,但是 L1 和 L2 Cache 都是每个核心独有的,**如果一个线程在不同核心来回切换,各个核心的缓存命中率就会受到影响**,相反如果线程都在同一个核心上执行,那么其数据的 L1 和 L2 Cache 的缓存命中率可以得到有效提高,缓存命中率高就意味着 CPU 可以减少访问 内存的频率。 当有多个同时执行「计算密集型」的线程,为了防止因为切换到不同的核心,而导致缓存命中率下降的问题,我们可以把**线程绑定在某一个 CPU 核心上**,这样性能可以得到非常可观的提升。 在 Linux 上提供了 `sched_setaffinity` 方法,来实现将线程绑定到某个 CPU 核心这一功能。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/CPU缓存/sched_setaffinity.png) --- ## 总结 由于随着计算机技术的发展,CPU 与 内存的访问速度相差越来越多,如今差距已经高达好几百倍了,所以 CPU 内部嵌入了 CPU Cache 组件,作为内存与 CPU 之间的缓存层,CPU Cache 由于离 CPU 核心很近,所以访问速度也是非常快的,但由于所需材料成本比较高,它不像内存动辄几个 GB 大小,而是仅有几十 KB 到 MB 大小。 当 CPU 访问数据的时候,先是访问 CPU Cache,如果缓存命中的话,则直接返回数据,就不用每次都从内存读取数据了。因此,缓存命中率越高,代码的性能越好。 但需要注意的是,当 CPU 访问数据时,如果 CPU Cache 没有缓存该数据,则会从内存读取数据,但是并不是只读一个数据,而是一次性读取一块一块的数据存放到 CPU Cache 中,之后才会被 CPU 读取。 内存地址映射到 CPU Cache 地址里的策略有很多种,其中比较简单是直接映射 Cache,它巧妙的把内存地址拆分成「索引 + 组标记 + 偏移量」的方式,使得我们可以将很大的内存地址,映射到很小的 CPU Cache 地址里。 要想写出让 CPU 跑得更快的代码,就需要写出缓存命中率高的代码,CPU L1 Cache 分为数据缓存和指令缓存,因而需要分别提高它们的缓存命中率: - 对于数据缓存,我们在遍历数据的时候,应该按照内存布局的顺序操作,这是因为 CPU Cache 是根据 CPU Cache Line 批量操作数据的,所以顺序地操作连续内存数据时,性能能得到有效的提升; - 对于指令缓存,有规律的条件分支语句能够让 CPU 的分支预测器发挥作用,进一步提高执行的效率; 另外,对于多核 CPU 系统,线程可能在不同 CPU 核心来回切换,这样各个核心的缓存命中率就会受到影响,于是要想提高线程的缓存命中率,可以考虑把线程绑定 CPU 到某一个 CPU 核心。 ---- ## 关注作者 分享个喜事,小林平日里忙着输出文章,今天收到一份特别的快递,是 CSDN 寄来的奖状。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%BE%AE%E4%BF%A1%E5%9B%BE%E7%89%87_20201017154037.jpg) 骄傲的说,你们关注的是 CSDN 首届技术原创第一名的博主,以后简历又可以吹牛逼了 没有啦,其实主要还是**谢谢你们不离不弃的支持**。 sao *哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF* ================================================ FILE: os/1_hardware/soft_interrupt.md ================================================ # 2.6 什么是软中断? 今日的技术主题:**什么是软中断?**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/软中断/软中断提纲.png) --- ## 中断是什么? 先来看看什么是中断?在计算机中,中断是系统用来响应硬件设备请求的一种机制,操作系统收到硬件的中断请求,会打断正在执行的进程,然后调用内核中的中断处理程序来响应请求。 这样的解释可能过于学术了,容易云里雾里,我就举个生活中取外卖的例子。 小林中午搬完砖,肚子饿了,点了份白切鸡外卖,这次我带闪了,没有被某团大数据杀熟。虽然平台上会显示配送进度,但是我也不能一直傻傻地盯着呀,时间很宝贵,当然得去干别的事情,等外卖到了配送员会通过「电话」通知我,电话响了,我就会停下手中地事情,去拿外卖。 这里的打电话,其实就是对应计算机里的中断,没接到电话的时候,我可以做其他的事情,只有接到了电话,也就是发生中断,我才会停下当前的事情,去进行另一个事情,也就是拿外卖。 从这个例子,我们可以知道,中断是一种异步的事件处理机制,可以提高系统的并发处理能力。 操作系统收到了中断请求,会打断其他进程的运行,所以**中断请求的响应程序,也就是中断处理程序,要尽可能快的执行完,这样可以减少对正常进程运行调度地影响。** 而且,中断处理程序在响应中断时,可能还会「临时关闭中断」,这意味着,如果当前中断处理程序没有执行完之前,系统中其他的中断请求都无法被响应,也就说中断有可能会丢失,所以中断处理程序要短且快。 还是回到外卖的例子,小林到了晚上又点起了外卖,这次为了犒劳自己,共点了两份外卖,一份小龙虾和一份奶茶,并且是由不同地配送员来配送,那么问题来了,当第一份外卖送到时,配送员给我打了长长的电话,说了一些杂七杂八的事情,比如给个好评等等,但如果这时另一位配送员也想给我打电话。 很明显,这时第二位配送员因为我在通话中(相当于关闭了中断响应),自然就无法打通我的电话,他可能尝试了几次后就走掉了(相当于丢失了一次中断)。 --- ## 什么是软中断? 前面我们也提到了,中断请求的处理程序应该要短且快,这样才能减少对正常进程运行调度地影响,而且中断处理程序可能会暂时关闭中断,这时如果中断处理程序执行时间过长,可能在还未执行完中断处理程序前,会丢失当前其他设备的中断请求。 那 Linux 系统**为了解决中断处理程序执行过长和中断丢失的问题,将中断过程分成了两个阶段,分别是「上半部和下半部分」**。 - **上半部用来快速处理中断**,一般会暂时关闭中断请求,主要负责处理跟硬件紧密相关或者时间敏感的事情。 - **下半部用来延迟处理上半部未完成的工作**,一般以「内核线程」的方式运行。 前面的外卖例子,由于第一个配送员长时间跟我通话,则导致第二位配送员无法拨通我的电话,其实当我接到第一位配送员的电话,可以告诉配送员说我现在下楼,剩下的事情,等我们见面再说(上半部),然后就可以挂断电话,到楼下后,在拿外卖,以及跟配送员说其他的事情(下半部)。 这样,第一位配送员就不会占用我手机太多时间,当第二位配送员正好过来时,会有很大几率拨通我的电话。 再举一个计算机中的例子,常见的网卡接收网络包的例子。 网卡收到网络包后,通过 DMA 方式将接收到的数据写入内存,接着会通过**硬件中断**通知内核有新的数据到了,于是内核就会调用对应的中断处理程序来处理该事件,这个事件的处理也是会分成上半部和下半部。 上部分要做的事情很少,会先禁止网卡中断,避免频繁硬中断,而降低内核的工作效率。接着,内核会触发一个**软中断**,把一些处理比较耗时且复杂的事情,交给「软中断处理程序」去做,也就是中断的下半部,其主要是需要从内存中找到网络数据,再按照网络协议栈,对网络数据进行逐层解析和处理,最后把数据送给应用程序。 所以,中断处理程序的上部分和下半部可以理解为: - **上半部直接处理硬件请求,也就是硬中断**,主要是负责耗时短的工作,特点是快速执行; - **下半部是由内核触发,也就说软中断**,主要是负责上半部未完成的工作,通常都是耗时比较长的事情,特点是延迟执行; 还有一个区别,硬中断(上半部)是会打断 CPU 正在执行的任务,然后立即执行中断处理程序,而软中断(下半部)是以内核线程的方式执行,并且每一个 CPU 都对应一个软中断内核线程,名字通常为「ksoftirqd/CPU 编号」,比如 0 号 CPU 对应的软中断内核线程的名字是 `ksoftirqd/0` 不过,软中断不只是包括硬件设备中断处理程序的下半部,一些内核自定义事件也属于软中断,比如内核调度等、RCU 锁(内核里常用的一种锁)等。 --- ### 系统里有哪些软中断? 在 Linux 系统里,我们可以通过查看 `/proc/softirqs` 的 内容来知晓「软中断」的运行情况,以及 `/proc/interrupts` 的 内容来知晓「硬中断」的运行情况。 接下来,就来简单的解析下 `/proc/softirqs` 文件的内容,在我服务器上查看到的文件内容如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/软中断/softirqs.png) 你可以看到,每一个 CPU 都有自己对应的不同类型软中断的**累计运行次数**,有 3 点需要注意下。 第一点,要注意第一列的内容,它是代表着软中断的类型,在我的系统里,软中断包括了 10 个类型,分别对应不同的工作类型,比如 `NET_RX` 表示网络接收中断,`NET_TX` 表示网络发送中断、`TIMER` 表示定时中断、`RCU` 表示 RCU 锁中断、`SCHED` 表示内核调度中断。 第二点,要注意同一种类型的软中断在不同 CPU 的分布情况,正常情况下,同一种中断在不同 CPU 上的累计次数相差不多,比如我的系统里,`NET_RX` 在 CPU0、CPU1、CPU2、CPU3 上的中断次数基本是同一个数量级,相差不多。 第三点,这些数值是系统运行以来的累计中断次数,数值的大小没什么参考意义,但是系统的**中断次数的变化速率**才是我们要关注的,我们可以使用 `watch -d cat /proc/softirqs` 命令查看中断次数的变化速率。 前面提到过,软中断是以内核线程的方式执行的,我们可以用 `ps` 命令可以查看到,下面这个就是在我的服务器上查到软中断内核线程的结果: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/软中断/ksoftirqd.png) 可以发现,内核线程的名字外面都有有中括号,这说明 ps 无法获取它们的命令行参数,所以一般来说,名字在中括号里的都可以认为是内核线程。 而且,你可以看到有 4 个 `ksoftirqd` 内核线程,这是因为我这台服务器的 CPU 是 4 核心的,每个 CPU 核心都对应着一个内核线程。 --- ## 如何定位软中断 CPU 使用率过高的问题? 要想知道当前的系统的软中断情况,我们可以使用 `top` 命令查看,下面是一台服务器上的 top 的数据: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/软中断/top_si.png) 上图中的黄色部分 `si`,就是 CPU 在软中断上的使用率,而且可以发现,每个 CPU 使用率都不高,两个 CPU 的使用率虽然只有 3% 和 4% 左右,但是都是用在软中断上了。 另外,也可以看到 CPU 使用率最高的进程也是软中断 `ksoftirqd`,因此可以认为此时系统的开销主要来源于软中断。 如果要知道是哪种软中断类型导致的,我们可以使用 `watch -d cat /proc/softirqs` 命令查看每个软中断类型的中断次数的变化速率。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/软中断/watch.png) 一般对于网络 I/O 比较高的 Web 服务器,`NET_RX` 网络接收中断的变化速率相比其他中断类型快很多。 如果发现 `NET_RX` 网络接收中断次数的变化速率过快,接下来就可以使用 `sar -n DEV` 查看网卡的网络包接收速率情况,然后分析是哪个网卡有大量的网络包进来。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/操作系统/软中断/sar_dev.png) 接着,在通过 `tcpdump` 抓包,分析这些包的来源,如果是非法的地址,可以考虑加防火墙,如果是正常流量,则要考虑硬件升级等。 --- ## 总结 为了避免由于中断处理程序执行时间过长,而影响正常进程的调度,Linux 将中断处理程序分为上半部和下半部: - 上半部,对应硬中断,由硬件触发中断,用来快速处理中断; - 下半部,对应软中断,由内核触发中断,用来异步处理上半部未完成的工作; Linux 中的软中断包括网络收发、定时、调度、RCU 锁等各种类型,可以通过查看 /proc/softirqs 来观察软中断的累计中断次数情况,如果要实时查看中断次数的变化率,可以使用 watch -d cat /proc/softirqs 命令。 每一个 CPU 都有各自的软中断内核线程,我们还可以用 ps 命令来查看内核线程,一般名字在中括号里面到,都认为是内核线程。 如果在 top 命令发现,CPU 在软中断上的使用率比较高,而且 CPU 使用率最高的进程也是软中断 ksoftirqd 的时候,这种一般可以认为系统的开销被软中断占据了。 这时我们就可以分析是哪种软中断类型导致的,一般来说都是因为网络接收软中断导致的,如果是的话,可以用 sar 命令查看是哪个网卡的有大量的网络包接收,再用 tcpdump 抓网络包,做进一步分析该网络包的源头是不是非法地址,如果是就需要考虑防火墙增加规则,如果不是,则考虑硬件升级等。 --- ## 关注作者 ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF*** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/其他/公众号介绍.png) ================================================ FILE: os/1_hardware/storage.md ================================================ # 2.2 磁盘比内存慢几万倍? 大家如果想自己组装电脑的话,肯定需要购买一个 CPU,但是存储器方面的设备,分类比较多,那我们肯定不能只买一种存储器,比如你除了要买内存,还要买硬盘,而针对硬盘我们还可以选择是固态硬盘还是机械硬盘。 相信大家都知道内存和硬盘都属于计算机的存储设备,断电后内存的数据是会丢失的,而硬盘则不会,因为硬盘是持久化存储设备,同时也是一个 I/O 设备。 但其实 CPU 内部也有存储数据的组件,这个应该比较少人注意到,比如**寄存器、CPU L1/L2/L3 Cache** 也都是属于存储设备,只不过它们能存储的数据非常小,但是它们因为靠近 CPU 核心,所以访问速度都非常快,快过硬盘好几个数量级别。 问题来了,**那机械硬盘、固态硬盘、内存这三个存储器,到底和 CPU L1 Cache 相比速度差多少倍呢?** 在回答这个问题之前,我们先来看看「**存储器的层次结构**」,好让我们对存储器设备有一个整体的认识。 ![](https://raw.githubusercontent.com/xiaolincoder/ImageHost3/main/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E5%AD%98%E5%82%A8%E5%99%A8/%E5%AD%98%E5%82%A8%E5%99%A8%E6%8F%90%E7%BA%B2.png) --- ## 存储器的层次结构 我们想象中一个场景,大学期末准备考试了,你前去图书馆临时抱佛脚。那么,在看书的时候,我们的大脑会思考问题,也会记忆知识点,另外我们通常也会把常用的书放在自己的桌子上,当我们要找一本不常用的书,则会去图书馆的书架找。 就是这么一个小小的场景,已经把计算机的存储结构基本都涵盖了。 我们可以把 CPU 比喻成我们的大脑,大脑正在思考的东西,就好比 CPU 中的**寄存器**,处理速度是最快的,但是能存储的数据也是最少的,毕竟我们也不能一下同时思考太多的事情,除非你练过。 我们大脑中的记忆,就好比 **CPU Cache**,中文称为 CPU 高速缓存,处理速度相比寄存器慢了一点,但是能存储的数据也稍微多了一些。 CPU Cache 通常会分为 **L1、L2、L3 三层**,其中 L1 Cache 通常分成「数据缓存」和「指令缓存」,L1 是距离 CPU 最近的,因此它比 L2、L3 的读写速度都快、存储空间都小。我们大脑中短期记忆,就好比 L1 Cache,而长期记忆就好比 L2/L3 Cache。 寄存器和 CPU Cache 都是在 CPU 内部,跟 CPU 挨着很近,因此它们的读写速度都相当的快,但是能存储的数据很少,毕竟 CPU 就这么丁点大。 知道 CPU 内部的存储器的层次分布,我们放眼看看 CPU 外部的存储器。 当我们大脑记忆中没有资料的时候,可以从书桌或书架上拿书来阅读,那我们桌子上的书,就好比**内存**,我们虽然可以一伸手就可以拿到,但读写速度肯定远慢于寄存器,那图书馆书架上的书,就好比**硬盘**,能存储的数据非常大,但是读写速度相比内存差好几个数量级,更别说跟寄存器的差距了。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/存储结构/学习与存储层次关系.png) 我们从图书馆书架取书,把书放到桌子上,再阅读书,我们大脑就会记忆知识点,然后再经过大脑思考,这一系列过程相当于,数据从硬盘加载到内存,再从内存加载到 CPU 的寄存器和 Cache 中,然后再通过 CPU 进行处理和计算。 **对于存储器,它的速度越快、能耗会越高、而且材料的成本也是越贵的,以至于速度快的存储器的容量都比较小。** CPU 里的寄存器和 Cache,是整个计算机存储器中价格最贵的,虽然存储空间很小,但是读写速度是极快的,而相对比较便宜的内存和硬盘,速度肯定比不上 CPU 内部的存储器,但是能弥补存储空间的不足。 存储器通常可以分为这么几个级别: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/存储结构/存储区分级.png) - 寄存器; - CPU Cache; 1. L1-Cache; 2. L2-Cache; 3. L3-Cahce; - 内存; - SSD/HDD 硬盘 ### 寄存器 最靠近 CPU 的控制单元和逻辑计算单元的存储器,就是寄存器了,它使用的材料速度也是最快的,因此价格也是最贵的,那么数量不能很多。 存储器的数量通常在几十到几百之间,每个寄存器可以用来存储一定的字节(byte)的数据。比如: - 32 位 CPU 中大多数寄存器可以存储 `4` 个字节; - 64 位 CPU 中大多数寄存器可以存储 `8` 个字节。 寄存器的访问速度非常快,一般要求在半个 CPU 时钟周期内完成读写,CPU 时钟周期跟 CPU 主频息息相关,比如 2 GHz 主频的 CPU,那么它的时钟周期就是 1/2G,也就是 0.5ns(纳秒)。 CPU 处理一条指令的时候,除了读写寄存器,还需要解码指令、控制指令执行和计算。如果寄存器的速度太慢,则会拉长指令的处理周期,从而给用户的感觉,就是电脑「很慢」。 ### CPU Cache CPU Cache 用的是一种叫 **SRAM(*Static Random-Access* Memory,静态随机存储器)** 的芯片。 SRAM 之所以叫「静态」存储器,是因为只要有电,数据就可以保持存在,而一旦断电,数据就会丢失了。 在 SRAM 里面,一个 bit 的数据,通常需要 6 个晶体管,所以 SRAM 的存储密度不高,同样的物理空间下,能存储的数据是有限的,不过也因为 SRAM 的电路简单,所以访问速度非常快。 CPU 的高速缓存,通常可以分为 L1、L2、L3 这样的三层高速缓存,也称为一级缓存、二级缓存、三级缓存。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/存储结构/CPU-Cache.png) #### L1 高速缓存 L1 高速缓存的访问速度几乎和寄存器一样快,通常只需要 `2~4` 个时钟周期,而大小在几十 KB 到几百 KB 不等。 每个 CPU 核心都有一块属于自己的 L1 高速缓存,指令和数据在 L1 是分开存放的,所以 L1 高速缓存通常分成**指令缓存**和**数据缓存**。 在 Linux 系统,我们可以通过这条命令,查看 CPU 里的 L1 Cache「数据」缓存的容量大小: ```bash $ cat /sys/devices/system/cpu/cpu0/cache/index0/size 32K ``` 而查看 L1 Cache「指令」缓存的容量大小,则是: ```bash $ cat /sys/devices/system/cpu/cpu0/cache/index1/size 32K ``` #### L2 高速缓存 L2 高速缓存同样每个 CPU 核心都有,但是 L2 高速缓存位置比 L1 高速缓存距离 CPU 核心 更远,它大小比 L1 高速缓存更大,CPU 型号不同大小也就不同,通常大小在几百 KB 到几 MB 不等,访问速度则更慢,速度在 `10~20` 个时钟周期。 在 Linux 系统,我们可以通过这条命令,查看 CPU 里的 L2 Cache 的容量大小: ```bash $ cat /sys/devices/system/cpu/cpu0/cache/index2/size 256K ``` #### L3 高速缓存 L3 高速缓存通常是多个 CPU 核心共用的,位置比 L2 高速缓存距离 CPU 核心 更远,大小也会更大些,通常大小在几 MB 到几十 MB 不等,具体值根据 CPU 型号而定。 访问速度相对也比较慢一些,访问速度在 `20~60`个时钟周期。 在 Linux 系统,我们可以通过这条命令,查看 CPU 里的 L3 Cache 的容量大小: ```bash $ cat /sys/devices/system/cpu/cpu0/cache/index3/size 3072K ``` ### 内存 内存用的芯片和 CPU Cache 有所不同,它使用的是一种叫作 **DRAM (*Dynamic Random Access Memory*,动态随机存取存储器)** 的芯片。 相比 SRAM,DRAM 的密度更高,功耗更低,有更大的容量,而且造价比 SRAM 芯片便宜很多。 DRAM 存储一个 bit 数据,只需要一个晶体管和一个电容就能存储,但是因为数据会被存储在电容里,电容会不断漏电,所以需要「定时刷新」电容,才能保证数据不会被丢失,这就是 DRAM 之所以被称为「动态」存储器的原因,只有不断刷新,数据才能被存储起来。 DRAM 的数据访问电路和刷新电路都比 SRAM 更复杂,所以访问的速度会更慢,内存速度大概在 `200~300` 个 时钟周期之间。 ### SSD/HDD 硬盘 SSD(*Solid-state disk*)就是我们常说的固体硬盘,结构和内存类似,但是它相比内存的优点是断电后数据还是存在的,而内存、寄存器、高速缓存断电后数据都会丢失。内存的读写速度比 SSD 大概快 `10~1000` 倍。 当然,还有一款传统的硬盘,也就是机械硬盘(*Hard Disk Drive, HDD*),它是通过物理读写的方式来访问数据的,因此它访问速度是非常慢的,它的速度比内存慢 `10W` 倍左右。 由于 SSD 的价格快接近机械硬盘了,因此机械硬盘已经逐渐被 SSD 替代了。 --- ## 存储器的层次关系 现代的一台计算机,都用上了 CPU Cahce、内存、到 SSD 或 HDD 硬盘这些存储器设备了。 其中,存储空间越大的存储器设备,其访问速度越慢,所需成本也相对越少。 CPU 并不会直接和每一种存储器设备直接打交道,而是每一种存储器设备只和它相邻的存储器设备打交道。 比如,CPU Cache 的数据是从内存加载过来的,写回数据的时候也只写回到内存,CPU Cache 不会直接把数据写到硬盘,也不会直接从硬盘加载数据,而是先加载到内存,再从内存加载到 CPU Cache 中。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/存储结构/存储器的层次关系图.png) 所以,**每个存储器只和相邻的一层存储器设备打交道,并且存储设备为了追求更快的速度,所需的材料成本必然也是更高,也正因为成本太高,所以 CPU 内部的寄存器、L1\L2\L3 Cache 只好用较小的容量,相反内存、硬盘则可用更大的容量,这就我们今天所说的存储器层次结构**。 另外,当 CPU 需要访问内存中某个数据的时候,如果寄存器有这个数据,CPU 就直接从寄存器取数据即可,如果寄存器没有这个数据,CPU 就会查询 L1 高速缓存,如果 L1 没有,则查询 L2 高速缓存,L2 还是没有的话就查询 L3 高速缓存,L3 依然没有的话,才去内存中取数据。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/存储结构/缓存体系1.png) 所以,存储层次结构也形成了**缓存**的体系。 ---- ## 存储器之间的实际价格和性能差距 前面我们知道了,速度越快的存储器,造价成本往往也越高,那我们就以实际的数据来看看,不同层级的存储器之间的性能和价格差异。 下面这张表格是不同层级的存储器之间的成本对比图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/存储结构/存储器成本的对比.png) 你可以看到 L1 Cache 的访问延时是 1 纳秒,而内存已经是 100 纳秒了,相比 L1 Cache 速度慢了 `100` 倍。另外,机械硬盘的访问延时更是高达 10 毫秒,相比 L1 Cache 速度慢了 `10000000` 倍,差了好几个数量级别。 在价格上,每生成 MB 大小的 L1 Cache 相比内存贵了 `466` 倍,相比机械硬盘那更是贵了 `175000` 倍。 我在某东逛了下各个存储器设备的零售价,8G 内存 + 1T 机械硬盘 + 256G 固态硬盘的总价格,都不及一块 Intle i5-10400 的 CPU 的价格,这款 CPU 的高速缓存的总大小也就十多 MB。 --- ## 总结 各种存储器之间的关系,可以用我们在图书馆学习这个场景来理解。 CPU 可以比喻成我们的大脑,我们当前正在思考和处理的知识的过程,就好比 CPU 中的**寄存器**处理数据的过程,速度极快,但是容量很小。而 CPU 中的 **L1-L3 Cache** 好比我们大脑中的短期记忆和长期记忆,需要小小花费点时间来调取数据并处理。 我们面前的桌子就相当于**内存**,能放下更多的书(数据),但是找起来和看起来就要花费一些时间,相比 CPU Cache 慢不少。而图书馆的书架相当于**硬盘**,能放下比内存更多的数据,但找起来就更费时间了,可以说是最慢的存储器设备了。 从 寄存器、CPU Cache,到内存、硬盘,这样一层层下来的存储器,访问速度越来越慢,存储容量越来越大,价格也越来越便宜,而且每个存储器只和相邻的一层存储器设备打交道,于是这样就形成了存储器的层次结构。 再来回答,开头的问题:那机械硬盘、固态硬盘、内存这三个存储器,到底和 `CPU L1 Cache` 相比速度差多少倍呢? CPU L1 Cache 随机访问延时是 1 纳秒,内存则是 100 纳秒,所以 **CPU L1 Cache 比内存快 `100` 倍左右**。 SSD 随机访问延时是 150 微秒,所以 **CPU L1 Cache 比 SSD 快 `150000` 倍左右**。 最慢的机械硬盘随机访问延时已经高达 10 毫秒,我们来看看机械硬盘到底有多「龟速」: - **SSD 比机械硬盘快 70 倍左右;** - **内存比机械硬盘快 100000 倍左右;** - **CPU L1 Cache 比机械硬盘快 10000000 倍左右;** 我们把上述的时间比例差异放大后,就能非常直观感受到它们的性能差异了。如果 CPU 访问 L1 Cache 的缓存时间是 1 秒,那访问内存则需要大约 2 分钟,随机访问 SSD 里的数据则需要 1.7 天,访问机械硬盘那更久,长达近 4 个月。 可以发现,不同的存储器之间性能差距很大,构造存储器分级很有意义,分级的目的是要构造**缓存**体系。 --- ## 关注作者 新的**技术交流群**已经慢慢人多起来了,群里的大牛真的多,大家交流都很踊跃,也有很多热心分享和回答问题的小伙伴,是你交朋友好地方,更是你上班划水的好入口。 准备入冬了,一起来抱团取暖吧,加群方式很简单,只需要加我的微信二维码,备注「**加群**」即可。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) **哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF** ================================================ FILE: os/2_os_structure/linux_vs_windows.md ================================================ # 3.1 Linux 内核 vs Windows 内核 Windows 和 Linux 可以说是我们比较常见的两款操作系统的。 Windows 基本占领了电脑时代的市场,商业上取得了很大成就,但是它并不开源,所以要想接触源码得加入 Windows 的开发团队中。 对于服务器使用的操作系统基本上都是 Linux,而且内核源码也是开源的,任何人都可以下载,并增加自己的改动或功能,Linux 最大的魅力在于,全世界有非常多的技术大佬为它贡献代码。 这两个操作系统各有千秋,不分伯仲。 操作系统核心的东西就是内核,这次我们就来看看,**Linux 内核和 Windows 内核有什么区别?** --- ## 内核 什么是内核呢? 计算机是由各种外部硬件设备组成的,比如内存、cpu、硬盘等,如果每个应用都要和这些硬件设备对接通信协议,那这样太累了,所以这个中间人就由内核来负责,**让内核作为应用连接硬件设备的桥梁**,应用程序只需关心与内核交互,不用关心硬件的细节。 ![内核](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/操作系统/内核/Kernel_Layout.png) 内核有哪些能力呢? 现代操作系统,内核一般会提供 4 个基本能力: - 管理进程、线程,决定哪个进程、线程使用 CPU,也就是进程调度的能力; - 管理内存,决定内存的分配和回收,也就是内存管理的能力; - 管理硬件设备,为进程与硬件设备之间提供通信能力,也就是硬件通信能力; - 提供系统调用,如果应用程序要运行更高权限运行的服务,那么就需要有系统调用,它是用户程序与操作系统之间的接口。 内核是怎么工作的? 内核具有很高的权限,可以控制 cpu、内存、硬盘等硬件,而应用程序具有的权限很小,因此大多数操作系统,把内存分成了两个区域: - 内核空间,这个内存空间只有内核程序可以访问; - 用户空间,这个内存空间专门给应用程序使用; 用户空间的代码只能访问一个局部的内存空间,而内核空间的代码可以访问所有内存空间。因此,当程序使用用户空间时,我们常说该程序在**用户态**执行,而当程序使内核空间时,程序则在**内核态**执行。 应用程序如果需要进入内核空间,就需要通过系统调用,下面来看看系统调用的过程: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/操作系统/内核/systemcall.png) 内核程序执行在内核态,用户程序执行在用户态。当应用程序使用系统调用时,会产生一个中断。发生中断后,CPU 会中断当前在执行的用户程序,转而跳转到中断处理程序,也就是开始执行内核程序。内核处理完后,主动触发中断,把 CPU 执行权限交回给用户程序,回到用户态继续工作。 --- ## Linux 的设计 Linux 的开山始祖是来自一位名叫 Linus Torvalds 的芬兰小伙子,他在 1991 年用 C 语言写出了第一版的 Linux 操作系统,那年他 22 岁。 完成第一版 Linux 后,Linux Torvalds 就在网络上发布了 Linux 内核的源代码,每个人都可以免费下载和使用。 Linux 内核设计的理念主要有这几个点: - *MultiTask*,多任务 - *SMP*,对称多处理 - *ELF*,可执行文件链接格式 - *Monolithic Kernel*,宏内核 #### MultiTask MultiTask 的意思是**多任务**,代表着 Linux 是一个多任务的操作系统。 多任务意味着可以有多个任务同时执行,这里的「同时」可以是并发或并行: - 对于单核 CPU 时,可以让每个任务执行一小段时间,时间到就切换另外一个任务,从宏观角度看,一段时间内执行了多个任务,这被称为并发。 - 对于多核 CPU 时,多个任务可以同时被不同核心的 CPU 同时执行,这被称为并行。 ### SMP SMP 的意思是**对称多处理**,代表着每个 CPU 的地位是相等的,对资源的使用权限也是相同的,多个 CPU 共享同一个内存,每个 CPU 都可以访问完整的内存和硬件资源。 这个特点决定了 Linux 操作系统不会有某个 CPU 单独服务应用程序或内核程序,而是每个程序都可以被分配到任意一个 CPU 上被执行。 ### ELF ELF 的意思是**可执行文件链接格式**,它是 Linux 操作系统中可执行文件的存储格式,你可以从下图看到它的结构: ![ELF 文件格式](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/操作系统/内核/Elf.png) ELF 把文件分成了一个个分段,每一个段都有自己的作用,具体每个段的作用这里我就不详细说明了,感兴趣的同学可以去看《程序员的自我修养——链接、装载和库》这本书。 另外,ELF 文件有两种索引,Program header table 中记录了「运行时」所需的段,而 Section header table 记录了二进制文件中各个「段的首地址」。 那 ELF 文件怎么生成的呢? 我们编写的代码,首先通过「编译器」编译成汇编代码,接着通过「汇编器」变成目标代码,也就是目标文件,最后通过「链接器」把多个目标文件以及调用的各种函数库链接起来,形成一个可执行文件,也就是 ELF 文件。 那 ELF 文件是怎么被执行的呢? 执行 ELF 文件的时候,会通过「装载器」把 ELF 文件装载到内存里,CPU 读取内存中的指令和数据,于是程序就被执行起来了。 ### Monolithic Kernel Monolithic Kernel 的意思是**宏内核**,Linux 内核架构就是宏内核,意味着 Linux 的内核是一个完整的可执行程序,且拥有最高的权限。 宏内核的特征是系统内核的所有模块,比如进程调度、内存管理、文件系统、设备驱动等,都运行在内核态。 不过,Linux 也实现了动态加载内核模块的功能,例如大部分设备驱动是以可加载模块的形式存在的,与内核其他模块解耦,让驱动开发和驱动加载更为方便、灵活。 ![分别为宏内核、微内核、混合内核的操作系统结构](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/操作系统/内核/OS-structure2.png) 与宏内核相反的是**微内核**,微内核架构的内核只保留最基本的能力,比如进程调度、虚拟机内存、中断等,把一些应用放到了用户空间,比如驱动程序、文件系统等。这样服务与服务之间是隔离的,单个服务出现故障或者完全攻击,也不会导致整个操作系统挂掉,提高了操作系统的稳定性和可靠性。 微内核内核功能少,可移植性高,相比宏内核有一点不好的地方在于,由于驱动程序不在内核中,而且驱动程序一般会频繁调用底层能力的,于是驱动和硬件设备交互就需要频繁切换到内核态,这样会带来性能损耗。华为的鸿蒙操作系统的内核架构就是微内核。 还有一种内核叫**混合类型内核**,它的架构有点像微内核,内核里面会有一个最小版本的内核,然后其他模块会在这个基础上搭建,然后实现的时候会跟宏内核类似,也就是把整个内核做成一个完整的程序,大部分服务都在内核中,这就像是宏内核的方式包裹着一个微内核。 --- ## Windows 设计 当今 Windows 7、Windows 10 使用的内核叫 Windows NT,NT 全称叫 New Technology。 下图是 Windows NT 的结构图片: ![Windows NT 的结构](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/操作系统/内核/windowNT.png) Windows 和 Linux 一样,同样支持 MutiTask 和 SMP,但不同的是,**Window 的内核设计是混合型内核**,在上图你可以看到内核中有一个 *MicroKernel* 模块,这个就是最小版本的内核,而整个内核实现是一个完整的程序,含有非常多模块。 Windows 的可执行文件的格式与 Linux 也不同,所以这两个系统的可执行文件是不可以在对方上运行的。 Windows 的可执行文件格式叫 PE,称为**可移植执行文件**,扩展名通常是`.exe`、`.dll`、`.sys`等。 PE 的结构你可以从下图中看到,它与 ELF 结构有一点相似。 ![PE 文件结构](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/操作系统/内核/pe.png) --- ## 总结 对于内核的架构一般有这三种类型: - 宏内核,包含多个模块,整个内核像一个完整的程序; - 微内核,有一个最小版本的内核,一些模块和服务则由用户态管理; - 混合内核,是宏内核和微内核的结合体,内核中抽象出了微内核的概念,也就是内核中会有一个小型的内核,其他模块就在这个基础上搭建,整个内核是个完整的程序; Linux 的内核设计是采用了宏内核,Window 的内核设计则是采用了混合内核。 这两个操作系统的可执行文件格式也不一样,Linux 可执行文件格式叫作 ELF,Windows 可执行文件格式叫作 PE。 --- ## 关注作者 ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF*** ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/其他/公众号介绍.png) ================================================ FILE: os/3_memory/alloc_mem.md ================================================ # 4.4 在 4GB 物理内存的机器上,申请 8G 内存会怎么样? 大家好,我是小林。 看到读者在群里讨论这些面试题: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/读者提问.png) 其中,第一个问题「**在 4GB 物理内存的机器上,申请 8G 内存会怎么样?**」存在比较大的争议,有人说会申请失败,有的人说可以申请成功。 这个问题在没有前置条件下,就说出答案就是耍流氓。这个问题要考虑三个前置条件: - 操作系统是 32 位的,还是 64 位的? - 申请完 8G 内存后会不会被使用? - 操作系统有没有使用 Swap 机制? 所以,我们要分场景讨论。 ## 操作系统虚拟内存大小 应用程序通过 malloc 函数申请内存的时候,实际上申请的是虚拟内存,此时并不会分配物理内存。 当应用程序读写了这块虚拟内存,CPU 就会去访问这个虚拟内存,这时会发现这个虚拟内存没有映射到物理内存,CPU 就会产生**缺页中断**,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler(缺页中断函数)处理。 缺页中断处理函数会看是否有空闲的物理内存: - 如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系。 - 如果没有空闲的物理内存,那么内核就会开始进行[回收内存](https://xiaolincoding.com/os/3_memory/mem_reclaim.html)的工作,如果回收内存工作结束后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了触发 OOM(Out of Memory)机制。 32 位操作系统和 64 位操作系统的虚拟地址空间大小是不同的,在 Linux 操作系统中,虚拟地址空间的内部又被分为**内核空间和用户空间**两部分,如下所示: ![](https://img-blog.csdnimg.cn/3a6cb4e3f27241d3b09b4766bb0b1124.png) 通过这里可以看出: - `32` 位系统的内核空间占用 `1G`,位于最高处,剩下的 `3G` 是用户空间; - `64` 位系统的内核空间和用户空间都是 `128T`,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。 ### 32 位系统的场景 > 现在可以回答这个问题了:在 32 位操作系统、4GB 物理内存的机器上,申请 8GB 内存,会怎么样? 因为 32 位操作系统,进程最多只能申请 3 GB 大小的虚拟内存空间,所以进程申请 8GB 内存的话,在申请虚拟内存阶段就会失败(我手上没有 32 位操作系统测试,我估计失败的错误是 cannot allocate memory,也就是无法申请内存失败)。 ### 64 位系统的场景 > 在 64 位操作系统、4GB 物理内存的机器上,申请 8G 内存,会怎么样? 64 位操作系统,进程可以使用 128 TB 大小的虚拟内存空间,所以进程申请 8GB 内存是没问题的,因为进程申请内存是申请虚拟内存,只要不读写这个虚拟内存,操作系统就不会分配物理内存。 我们可以简单做个测试,我的服务器是 64 位操作系统,但是物理内存只有 2 GB: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/2gb.png) 现在,我在机器上,连续申请 4 次 1 GB 内存,也就是一共申请了 4 GB 内存,注意下面代码只是单纯分配了虚拟内存,并没有使用该虚拟内存: ```c #include #include #include #include #define MEM_SIZE 1024 * 1024 * 1024 int main() { char* addr[4]; int i = 0; for(i = 0; i < 4; ++i) { addr[i] = (char*) malloc(MEM_SIZE); if(!addr[i]) { printf("执行 malloc 失败,错误:%s\n",strerror(errno)); return -1; } printf("主线程调用 malloc 后,申请 1gb 大小得内存,此内存起始地址:0X%p\n", addr[i]); } //输入任意字符后,才结束 getchar(); return 0; } ``` 然后运行这个代码,可以看到,我的物理内存虽然只有 2GB,但是程序正常分配了 4GB 大小的虚拟内存: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/虚拟内存4g.png) 我们可以通过下面这条命令查看进程(test)的虚拟内存大小: ```shell # ps aux | grep test USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND root 7797 0.0 0.0 4198540 352 pts/1 S+ 16:58 0:00 ./test ``` 其中,VSZ 就代表进程使用的虚拟内存大小,RSS 代表进程使用的物理内存大小。可以看到,VSZ 大小为 4198540,也就是 4GB 的虚拟内存。 > 之前有读者跟我反馈,说他自己也做了这个实验,然后发现 64 位操作系统,在申请 4GB 虚拟内存的时候失败了,这是为什么呢? 失败的错误: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/033读者-1.png) 我当时帮他排查了下,发现跟 Linux 中的 [overcommit_memory](http://linuxperf.com/?p=102) 参数有关,可以使用 `cat /proc/sys/vm/overcommit_memory` 来查看这个参数,这个参数接受三个值: - 如果值为 0(默认值),代表:Heuristic overcommit handling,它允许 overcommit,但过于明目张胆的 overcommit 会被拒绝,比如 malloc 一次性申请的内存大小就超过了系统总内存。Heuristic 的意思是“试探式的”,内核利用某种算法猜测你的内存申请是否合理,大概可以理解为单次申请不能超过 free memory + free swap + pagecache 的大小 + SLAB 中可回收的部分,超过了就会拒绝 overcommit。 - 如果值为 1,代表:Always overcommit. 允许 overcommit,对内存申请来者不拒。 - 如果值为 2,代表:Don’t overcommit. 禁止 overcommit。 当时那位读者的 overcommit_memory 参数是默认值 0,所以申请失败的原因可能是内核认为我们申请的内存太大了,它认为不合理,所以 malloc() 返回了 Cannot allocate memory 错误,这里申请 4GB 虚拟内存失败的同学可以将这个 overcommit_memory 设置为 1,就可以 overcommit 了。 ```shell echo 1 > /proc/sys/vm/overcommit_memory ``` 设置完为 1 后,读者的机子就可以正常申请 4GB 虚拟内存了。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/033读者-2.png) **不过我的环境 overcommit_memory 是 0,在 64 系统、2 G 物理内存场景下,也是可以成功申请 4 G 内存的,我怀疑可能是不同版本的内核在 overcommit_memory 为 0 时,检测内存申请是否合理的算法可能是不同的。** **总之,如果你申请大内存的时候,不想被内核检测内存申请是否合理的算法干扰的话,将 overcommit_memory 设置为 1 就行。** > 那么将这个 overcommit_memory 设置为 1 之后,64 位的主机就可以申请接近 128T 虚拟内存了吗? 不一定,还得看你服务器的物理内存大小。 读者的服务器物理内存是 2 GB,实验后发现,进程还没有申请到 128T 虚拟内存的时候就被杀死了。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/033读者-3.png) 注意,这次是 killed,而不是 Cannot Allocate Memory,说明并不是内存申请有问题,而是触发 OOM 了。 但是为什么会触发 OOM 呢? 那得看你的主机的「物理内存」够不够大了,即使 malloc 申请的是虚拟内存,只要不去访问就不会映射到物理内存,但是申请虚拟内存的过程中,还是使用到了物理内存(比如内核保存虚拟内存的数据结构,也是占用物理内存的),如果你的主机是只有 2GB 的物理内存的话,大概率会触发 OOM。 可以使用 top 命令,点击两下 m,通过进度条观察物理内存使用情况。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/033读者-4.png) 可以看到申请虚拟内存的过程中**物理内存使用量一直在增长**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/033读者-5.png) ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/033读者-6.png) ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/033读者-7.png) 直到直接内存回收之后,也无法回收出一块空间供这个进程使用,这个时候就会触发 OOM,给所有能杀死的进程打分,分数越高的进程越容易被杀死。 在这里当然是这个进程得分最高,那么操作系统就会将这个进程杀死,所以最后会出现 killed,而不是 Cannot allocate memory。 > 那么 2GB 的物理内存的 64 位操作系统,就不能申请 128T 的虚拟内存了吗? 其实可以,上面的情况是还没开启 swap 的情况。 使用 swapfile 的方式开启了 1GB 的 swap 空间之后再做实验: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/033读者-8.png) ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/033读者-9.png) 发现出现了 Cannot allocate memory,但是其实到这里已经成功了, 打开计算器计算一下,发现已经申请了 127.998T 虚拟内存了。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/033读者-10.png) 实际上我们是不可能申请完整个 128T 的用户空间的,因为程序运行本身也需要申请虚拟空间 申请 127T 虚拟内存试试: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/033读者-11.png) 发现进程没有被杀死,也没有 Cannot allocate memory,也正好是 127T 虚拟内存空间。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/033读者-12.png) 在 top 中我们可以看到这个申请了 127T 虚拟内存的进程。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/033读者-13.png) ## Swap 机制的作用 前面讨论在 32 位/64 位操作系统环境下,申请的虚拟内存超过物理内存后会怎么样? - 在 32 位操作系统,因为进程最大只能申请 3 GB 大小的虚拟内存,所以直接申请 8G 内存,会申请失败。 - 在 64 位操作系统,因为进程最大只能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G 内存也是没问题,因为申请的内存是虚拟内存。 程序申请的虚拟内存,如果没有被使用,它是不会占用物理空间的。当访问这块虚拟内存后,操作系统才会进行物理内存分配。 如果申请物理内存大小超过了空闲物理内存大小,就要看操作系统有没有开启 Swap 机制: - 如果没有开启 Swap 机制,程序就会直接 OOM; - 如果有开启 Swap 机制,程序可以正常运行。 > 什么是 Swap 机制? 当系统的物理内存不够用的时候,就需要将物理内存中的一部分空间释放出来,以供当前运行的程序使用。那些被释放的空间可能来自一些很长时间没有什么操作的程序,这些被释放的空间会被临时保存到磁盘,等到那些程序要运行时,再从磁盘中恢复保存的数据到内存中。 另外,当内存使用存在压力的时候,会开始触发内存回收行为,会把这些不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。 这种,将内存数据换出磁盘,又从磁盘中恢复数据到内存的过程,就是 Swap 机制负责的。 Swap 就是把一块磁盘空间或者本地文件,当成内存来使用,它包含换出和换入两个过程: - **换出(Swap Out)** ,是把进程暂时不用的内存数据存储到磁盘中,并释放这些数据占用的内存; - **换入(Swap In)**,是在进程再次访问这些内存的时候,把它们从磁盘读到内存中来; Swap 换入换出的过程如下图: ![](https://img-blog.csdnimg.cn/388a29f45fe947e5a49240e4eff13538.png) 使用 Swap 机制优点是,应用程序实际可以使用的内存空间将远远超过系统的物理内存。由于硬盘空间的价格远比内存要低,因此这种方式无疑是经济实惠的。当然,频繁地读写硬盘,会显著降低操作系统的运行速率,这也是 Swap 的弊端。 Linux 中的 Swap 机制会在内存不足和内存闲置的场景下触发: - **内存不足**:当系统需要的内存超过了可用的物理内存时,内核会将内存中不常使用的内存页交换到磁盘上为当前进程让出内存,保证正在执行的进程的可用性,这个内存回收的过程是强制的直接内存回收(Direct Page Reclaim)。直接内存回收是同步的过程,会阻塞当前申请内存的进程。 - **内存闲置**:应用程序在启动阶段使用的大量内存在启动后往往都不会使用,通过后台运行的守护进程(kSwapd),我们可以将这部分只使用一次的内存交换到磁盘上为其他内存的申请预留空间。kSwapd 是 Linux 负责页面置换(Page replacement)的守护进程,它也是负责交换闲置内存的主要进程,它会在[空闲内存低于一定水位](https://xiaolincoding.com/os/3_memory/mem_reclaim.html#%E5%B0%BD%E6%97%A9%E8%A7%A6%E5%8F%91-kSwapd-%E5%86%85%E6%A0%B8%E7%BA%BF%E7%A8%8B%E5%BC%82%E6%AD%A5%E5%9B%9E%E6%94%B6%E5%86%85%E5%AD%98)时,回收内存页中的空闲内存保证系统中的其他进程可以尽快获得申请的内存。kSwapd 是后台进程,所以回收内存的过程是异步的,不会阻塞当前申请内存的进程。 Linux 提供了两种不同的方法启用 Swap,分别是 Swap 分区(Swap Partition)和 Swap 文件(Swapfile),开启方法可以看[这个资料](https://support.huaweicloud.com/trouble-ecs/ecs_trouble_0322.html): - Swap 分区是硬盘上的独立区域,该区域只会用于交换分区,其他的文件不能存储在该区域上,我们可以使用 `swapon -s` 命令查看当前系统上的交换分区; - Swap 文件是文件系统中的特殊文件,它与文件系统中的其他文件也没有太多的区别; > Swap 换入换出的是什么类型的内存? 内核缓存的文件数据,因为都有对应的磁盘文件,所以在回收文件数据的时候,直接写回到对应的文件就可以了。 但是像进程的堆、栈数据等,它们是没有实际载体,这部分内存被称为匿名页。而且这部分内存很可能还要再次被访问,所以不能直接释放内存,于是就需要有一个能保存匿名页的磁盘载体,这个载体就是 Swap 分区。 匿名页回收的方式是通过 Linux 的 Swap 机制,Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。 接下来,通过两个实验,看看申请的物理内存超过物理内存会怎样? - 实验一:没有开启 Swap 机制 - 实验二:有开启 Swap 机制 ### 实验一:没有开启 Swap 机制 我的服务器是 64 位操作系统,但是物理内存只有 2 GB,而且没有 Swap 分区: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/2gb.png) 我们改一下前面的代码,使得在申请完 4GB 虚拟内存后,通过 memset 函数访问这个虚拟内存,看看在没有 Swap 分区的情况下,会发生什么? ```c #include #include #include #include #define MEM_SIZE 1024 * 1024 * 1024 int main() { char* addr[4]; int i = 0; for(i = 0; i < 4; ++i) { addr[i] = (char*) malloc(MEM_SIZE); if(!addr[i]) { printf("执行 malloc 失败,错误:%s\n",strerror(errno)); return -1; } printf("主线程调用 malloc 后,申请 1gb 大小得内存,此内存起始地址:0X%p\n", addr[i]); } for(i = 0; i < 4; ++i) { printf("开始访问第 %d 块虚拟内存 (每一块虚拟内存为 1 GB)\n", i + 1); memset(addr[i], 0, MEM_SIZE); } //输入任意字符后,才结束 getchar(); return 0; } ``` 运行结果: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/发生oom.png) 可以看到,在访问第 2 块虚拟内存(每一块虚拟内存是 1 GB)的时候,因为超过了机器的物理内存(2GB),进程(test)被操作系统杀掉了。 通过查看 message 系统日志,可以发现该进程是被操作系统 OOM killer 机制杀掉了,日志里报错了 Out of memory,也就是发生 OOM(内存溢出错误)。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/oom日志.png) > 什么是 OOM? 内存溢出 (Out Of Memory,简称 OOM) 是指应用系统中存在无法回收的内存或使用的内存过多,最终使得程序运行要用到的内存大于能提供的最大内存。此时程序就运行不了,系统会提示内存溢出。 ### 实验二:有开启 Swap 机制 我用我的 mac book pro 笔记本做测试,我的笔记本是 64 位操作系统,物理内存是 8 GB,目前 Swap 分区大小为 1 GB(注意这个大小不是固定不变的,Swap 分区总大小是会动态变化的,当没有使用 Swap 分区时,Swap 分区总大小是 0;当使用了 Swap 分区,Swap 分区总大小会增加至 1 GB;当 Swap 分区已使用的大小超过 1 GB 时;Swap 分区总大小就会增加到至 2 GB;当 Swap 分区已使用的大小超过 2 GB 时;Swap 分区总大小就增加至 3GB,如此往复。这个估计是 macos 自己实现的,Linux 的分区则是固定大小的,Swap 分区不会根据使用情况而自动增长)。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/swap分区大小.png) 为了方便观察磁盘 I/O 情况,我们改进一下前面的代码,分配完 32 GB 虚拟内存后(笔记本物理内存是 8 GB),通过一个 while 循环频繁访问虚拟内存,代码如下: ```c #include #include #include #define MEM_SIZE 32 * 1024 * 1024 * 1024 int main() { char* addr = (char*) malloc((long)MEM_SIZE); printf("主线程调用 malloc 后,目前共申请了 32gb 的虚拟内存\n"); //循环频繁访问虚拟内存 while(1) { printf("开始访问 32gb 大小的虚拟内存...\n"); memset(addr, 0, (long)MEM_SIZE); } return 0; } ``` 运行结果如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/代码3运行结果.png) 可以看到,在有 Swap 分区的情况下,即使笔记本物理内存是 8 GB,申请并使用 32 GB 内存是没问题,程序正常运行了,并没有发生 OOM。 从下图可以看到,进程的内存显示 32 GB(这个不要理解为占用的物理内存,理解为已被访问的虚拟内存大小,也就是在物理内存呆过的内存大小),系统已使用的 Swap 分区达到 2.3 GB。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/test进程内存情况.png) 此时我的笔记本电脑的磁盘开始出现“沙沙”的声音,通过查看磁盘的 I/O 情况,可以看到磁盘 I/O 达到了一个峰值,非常高: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/磁盘io.png) > 有了 Swap 分区,是不是意味着进程可以使用的内存是无上限的? 当然不是,我把上面的代码改成了申请 64GB 内存后,当进程申请完 64GB 虚拟内存后,使用到 56 GB(这个不要理解为占用的物理内存,理解为已被访问的虚拟内存大小,也就是在物理内存呆过的内存大小)的时候,进程就被系统 kill 掉了,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/被kill掉.png) 当系统多次尝试回收内存,还是无法满足所需使用的内存大小,进程就会被系统 kill 掉了,意味着发生了 OOM(*PS:我没有在 macos 系统找到像 linux 系统里的 /var/log/message 系统日志文件,所以无法通过查看日志确认是否发生了 OOM*)。 ## 总结 至此,验证完成了。简单总结下: - 在 32 位操作系统,因为进程理论上最大能申请 3 GB 大小的虚拟内存,所以直接申请 8G 内存,会申请失败。 - 在 64 位 位操作系统,因为进程理论上最大能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G 内存也是没问题,因为申请的内存是虚拟内存。如果这块虚拟内存被访问了,要看系统有没有 Swap 分区: - 如果没有 Swap 分区,因为物理空间不够,进程会被操作系统杀掉,原因是 OOM(内存溢出); - 如果有 Swap 分区,即使物理内存只有 4GB,程序也能正常使用 8GB 的内存,进程可以正常运行; --- ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF*** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: os/3_memory/cache_lru.md ================================================ # 4.5 如何避免预读失效和缓存污染的问题? 大家好,我是小林。 上周群里看到有位小伙伴面试时,被问到这两个问题: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/操作系统/缓存/提问.png) 咋一看,以为是在问操作系统的问题,其实这两个题目都是在问**如何改进 LRU 算法**。 因为传统的 LRU 算法存在这两个问题: - **「预读失效」导致缓存命中率下降(对应第一个题目)** - **「缓存污染」导致缓存命中率下降(对应第二个题目)** Redis 的缓存淘汰算法则是通过**实现 LFU 算法**来避免「缓存污染」而导致缓存命中率下降的问题(Redis 没有预读机制)。 MySQL 和 Linux 操作系统是通过**改进 LRU 算法**来避免「预读失效和缓存污染」而导致缓存命中率下降的问题。 这次,就重点讲讲 **MySQL 和 Linux 操作系统是如何改进 LRU 算法的?** 好了,开始发车,坐稳了! ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/操作系统/缓存/缓存污染提纲.png) ## Linux 和 MySQL 的缓存 ### Linux 操作系统的缓存 在应用程序读取文件的数据的时候,Linux 操作系统是会对读取的文件数据进行缓存的,会缓存在文件系统中的 **Page Cache**(如下图中的页缓存)。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E8%99%9A%E6%8B%9F%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F.png) Page Cache 属于内存空间里的数据,由于内存访问比磁盘访问快很多,在下一次访问相同的数据就不需要通过磁盘 I/O 了,命中缓存就直接返回数据即可。 因此,Page Cache 起到了加速访问数据的作用。 ### MySQL 的缓存 MySQL 的数据是存储在磁盘里的,为了提升数据库的读写性能,Innodb 存储引擎设计了一个**缓冲池**(Buffer Pool),Buffer Pool 属于内存空间里的数据。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/%E7%BC%93%E5%86%B2%E6%B1%A0.drawio.png) 有了缓冲池后: - 当读取数据时,如果数据存在于 Buffer Pool 中,客户端就会直接读取 Buffer Pool 中的数据,否则再去磁盘中读取。 - 当修改数据时,首先是修改 Buffer Pool 中数据所在的页,然后将其页设置为脏页,最后由后台线程将脏页写入到磁盘。 ## 传统 LRU 是如何管理内存数据的? Linux 的 Page Cache 和 MySQL 的 Buffer Pool 的大小是有限的,并不能无限的缓存数据,对于一些频繁访问的数据我们希望可以一直留在内存中,而一些很少访问的数据希望可以在某些时机可以淘汰掉,从而保证内存不会因为满了而导致无法再缓存新的数据,同时还能保证常用数据留在内存中。 要实现这个,最容易想到的就是 LRU(Least recently used)算法。 LRU 算法一般是用「链表」作为数据结构来实现的,链表头部的数据是最近使用的,而链表末尾的数据是最久没被使用的。那么,当空间不够了,就淘汰最久没被使用的节点,也就是链表末尾的数据,从而腾出内存空间。 因为 Linux 的 Page Cache 和 MySQL 的 Buffer Pool 缓存的**基本数据单位都是页(Page)单位**,所以**后续以「页」名称代替「数据」**。 传统的 LRU 算法的实现思路是这样的: - 当访问的页在内存里,就直接把该页对应的 LRU 链表节点移动到链表的头部。 - 当访问的页不在内存里,除了要把该页放入到 LRU 链表的头部,还要淘汰 LRU 链表末尾的页。 比如下图,假设 LRU 链表长度为 5,LRU 链表从左到右有编号为 1,2,3,4,5 的页。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/lru.png) 如果访问了 3 号页,因为 3 号页已经在内存了,所以把 3 号页移动到链表头部即可,表示最近被访问了。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/lru2.png) 而如果接下来,访问了 8 号页,因为 8 号页不在内存里,且 LRU 链表长度为 5,所以必须要淘汰数据,以腾出内存空间来缓存 8 号页,于是就会淘汰末尾的 5 号页,然后再将 8 号页加入到头部。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/lru3.png) 传统的 LRU 算法并没有被 Linux 和 MySQL 使用,因为传统的 LRU 算法无法避免下面这两个问题: - 预读失效导致缓存命中率下降; - 缓存污染导致缓存命中率下降; ## 预读失效,怎么办? ### 什么是预读机制? Linux 操作系统为基于 Page Cache 的读缓存机制提供**预读机制**,一个例子是: - 应用程序只想读取磁盘上文件 A 的 offset 为 0-3KB 范围内的数据,由于磁盘的基本读写单位为 block(4KB),于是操作系统至少会读 0-4KB 的内容,这恰好可以在一个 page 中装下。 - 但是操作系统出于空间局部性原理(靠近当前被访问数据的数据,在未来很大概率会被访问到),会选择将磁盘块 offset [4KB,8KB)、[8KB,12KB) 以及 [12KB,16KB) 都加载到内存,于是额外在内存中申请了 3 个 page; 下图代表了操作系统的预读机制: ![](https://img-blog.csdnimg.cn/img_convert/ae8252378169c8c14b8b9907983f7d8b.png) 上图中,应用程序利用 read 系统调动读取 4KB 数据,实际上内核使用预读机制(ReadaHead)机制完成了 16KB 数据的读取,也就是通过一次磁盘顺序读将多个 Page 数据装入 Page Cache。 这样下次读取 4KB 数据后面的数据的时候,就不用从磁盘读取了,直接在 Page Cache 即可命中数据。因此,预读机制带来的好处就是**减少了 磁盘 I/O 次数,提高系统磁盘 I/O 吞吐量**。 MySQL Innodb 存储引擎的 Buffer Pool 也有类似的预读机制,MySQL 从磁盘加载页时,会提前把它相邻的页一并加载进来,目的是为了减少磁盘 IO。 ### 预读失效会带来什么问题? 如果**这些被提前加载进来的页,并没有被访问**,相当于这个预读工作是白做了,这个就是**预读失效**。 如果使用传统的 LRU 算法,就会把「预读页」放到 LRU 链表头部,而当内存空间不够的时候,还需要把末尾的页淘汰掉。 如果这些「预读页」如果一直不会被访问到,就会出现一个很奇怪的问题,**不会被访问的预读页却占用了 LRU 链表前排的位置,而末尾淘汰的页,可能是热点数据,这样就大大降低了缓存命中率** 。 ### 如何避免预读失效造成的影响? 我们不能因为害怕预读失效,而将预读机制去掉,大部分情况下,空间局部性原理还是成立的。 要避免预读失效带来影响,最好就是**让预读页停留在内存里的时间要尽可能的短,让真正被访问的页才移动到 LRU 链表的头部,从而保证真正被读取的热数据留在内存里的时间尽可能长**。 那到底怎么才能避免呢? Linux 操作系统和 MySQL Innodb 通过改进传统 LRU 链表来避免预读失效带来的影响,具体的改进分别如下: - Linux 操作系统实现两个了 LRU 链表:**活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list)**; - MySQL 的 Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域:**young 区域 和 old 区域**。 这两个改进方式,设计思想都是类似的,**都是将数据分为了冷数据和热数据,然后分别进行 LRU 算法**。不再像传统的 LRU 算法那样,所有数据都只用一个 LRU 算法管理。 接下来,具体聊聊 Linux 和 MySQL 是如何避免预读失效带来的影响? > Linux 是如何避免预读失效带来的影响? Linux 操作系统实现两个了 LRU 链表:**活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list)**。 - **active list** 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页; - **inactive list** 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页; 有了这两个 LRU 链表后,**预读页就只需要加入到 inactive list 区域的头部,当页被真正访问的时候,才将页插入 active list 的头部**。如果预读的页一直没有被访问,就会从 inactive list 移除,这样就不会影响 active list 中的热点数据。 接下来,给大家举个例子。 假设 active list 和 inactive list 的长度为 5,目前内存中已经有如下 10 个页: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/操作系统/缓存/active_inactive_list.drawio.png) 现在有个编号为 20 的页被预读了,这个页只会被插入到 inactive list 的头部,而 inactive list 末尾的页(10 号)会被淘汰掉。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/操作系统/缓存/active_inactive_list1.drawio.png) **即使编号为 20 的预读页一直不会被访问,它也没有占用到 active list 的位置**,而且还会比 active list 中的页更早被淘汰出去。 如果 20 号页被预读后,立刻被访问了,那么就会将它插入到 active list 的头部,active list 末尾的页(5 号),会被**降级**到 inactive list,作为 inactive list 的头部,这个过程并不会有数据被淘汰。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/操作系统/缓存/active_inactive_list2.drawio.png) > MySQL 是如何避免预读失效带来的影响? MySQL 的 Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域,**young 区域 和 old 区域**。 young 区域在 LRU 链表的前半部分,old 区域则是在后半部分,这两个区域都有各自的头和尾节点,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/young%2Bold.png) young 区域与 old 区域在 LRU 链表中的占比关系并不是一比一的关系,而是是 7 比 3(默认比例)的关系。 **划分这两个区域后,预读的页就只需要加入到 old 区域的头部,当页被真正访问的时候,才将页插入 young 区域的头部**。如果预读的页一直没有被访问,就会从 old 区域移除,这样就不会影响 young 区域中的热点数据。 接下来,给大家举个例子。 假设有一个长度为 10 的 LRU 链表,其中 young 区域占比 70 %,old 区域占比 30 %。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/lrutwo.drawio.png) 现在有个编号为 20 的页被预读了,这个页只会被插入到 old 区域头部,而 old 区域末尾的页(10 号)会被淘汰掉。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/lrutwo2.png) 如果 20 号页一直不会被访问,它也没有占用到 young 区域的位置,而且还会比 young 区域的数据更早被淘汰出去。 如果 20 号页被预读后,立刻被访问了,那么就会将它插入到 young 区域的头部,young 区域末尾的页(7 号),会被挤到 old 区域,作为 old 区域的头部,这个过程并不会有页被淘汰。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/lrutwo3.png) ## 缓存污染,怎么办? ### 什么是缓存污染? 虽然 Linux(实现两个 LRU 链表)和 MySQL(划分两个区域)通过改进传统的 LRU 数据结构,避免了预读失效带来的影响。 但是如果还是使用「只要数据被访问一次,就将数据加入到活跃 LRU 链表头部(或者 young 区域)」这种方式的话,那么**还存在缓存污染的问题**。 当我们在批量读取数据的时候,由于数据被访问了一次,这些大量数据都会被加入到「活跃 LRU 链表」里,然后之前缓存在活跃 LRU 链表(或者 young 区域)里的热点数据全部都被淘汰了,**如果这些大量的数据在很长一段时间都不会被访问的话,那么整个活跃 LRU 链表(或者 young 区域)就被污染了**。 ### 缓存污染会带来什么问题? 缓存污染带来的影响就是很致命的,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 I/O,系统性能就会急剧下降。 我以 MySQL 举例子,Linux 发生缓存污染的现象也是类似。 当某一个 SQL 语句**扫描了大量的数据**时,在 Buffer Pool 空间比较有限的情况下,可能会将 **Buffer Pool 里的所有页都替换出去,导致大量热数据被淘汰了**,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 I/O,MySQL 性能就会急剧下降。 注意,缓存污染并不只是查询语句查询出了大量的数据才出现的问题,即使查询出来的结果集很小,也会造成缓存污染。 比如,在一个数据量非常大的表,执行了这条语句: ```sql select * from t_user where name like "%xiaolin%"; ``` 可能这个查询出来的结果就几条记录,但是由于这条语句会发生索引失效,所以这个查询过程是全表扫描的,接着会发生如下的过程: - 从磁盘读到的页加入到 LRU 链表的 old 区域头部; - 当从页里读取行记录时,也就是**页被访问的时候,就要将该页放到 young 区域头部**; - 接下来拿行记录的 name 字段和字符串 xiaolin 进行模糊匹配,如果符合条件,就加入到结果集里; - 如此往复,直到扫描完表中的所有记录。 经过这一番折腾,由于这条 SQL 语句访问的页非常多,每访问一个页,都会将其加入 young 区域头部,那么**原本 young 区域的热点数据都会被替换掉,导致缓存命中率下降**。那些在批量扫描时,而被加入到 young 区域的页,如果在很长一段时间都不会再被访问的话,那么就污染了 young 区域。 举个例子,假设需要批量扫描:21,22,23,24,25 这五个页,这些页都会被逐一访问(读取页里的记录)。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/lruthree.drawio.png) 在批量访问这些页的时候,会被逐一插入到 young 区域头部。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/mysql/innodb/lruthree1.png) 可以看到,原本在 young 区域的 6 和 7 号页都被淘汰了,而批量扫描的页基本占满了 young 区域,如果这些页在很长一段时间都不会被访问,那么就对 young 区域造成了污染。 如果 6 和 7 号页是热点数据,那么在被淘汰后,后续有 SQL 再次读取 6 和 7 号页时,由于缓存未命中,就要从磁盘中读取了,降低了 MySQL 的性能,这就是缓存污染带来的影响。 ### 怎么避免缓存污染造成的影响? 前面的 LRU 算法只要数据被访问一次,就将数据加入活跃 LRU 链表(或者 young 区域),**这种 LRU 算法进入活跃 LRU 链表的门槛太低了**!正是因为门槛太低,才导致在发生缓存污染的时候,很容就将原本在活跃 LRU 链表里的热点数据淘汰了。 所以,**只要我们提高进入到活跃 LRU 链表(或者 young 区域)的门槛,就能有效地保证活跃 LRU 链表(或者 young 区域)里的热点数据不会被轻易替换掉**。 Linux 操作系统和 MySQL Innodb 存储引擎分别是这样提高门槛的: - **Linux 操作系统**:在内存页被访问**第二次**的时候,才将页从 inactive list 升级到 active list 里。 - **MySQL Innodb**:在内存页被访问**第二次**的时候,并不会马上将该页从 old 区域升级到 young 区域,因为还要进行**停留在 old 区域的时间判断**: - 如果第二次的访问时间与第一次访问的时间**在 1 秒内**(默认值),那么该页就**不会**被从 old 区域升级到 young 区域; - 如果第二次的访问时间与第一次访问的时间**超过 1 秒**,那么该页就**会**从 old 区域升级到 young 区域; 提高了进入活跃 LRU 链表(或者 young 区域)的门槛后,就很好了避免缓存污染带来的影响。 在批量读取数据时候,**如果这些大量数据只会被访问一次,那么它们就不会进入到活跃 LRU 链表(或者 young 区域)**,也就不会把热点数据淘汰,只会待在非活跃 LRU 链表(或者 old 区域)中,后续很快也会被淘汰。 ## 总结 传统的 LRU 算法法无法避免下面这两个问题: - 预读失效导致缓存命中率下降; - 缓存污染导致缓存命中率下降; 为了避免「预读失效」造成的影响,Linux 和 MySQL 对传统的 LRU 链表做了改进: - Linux 操作系统实现两个了 LRU 链表:**活跃 LRU 链表(active list)和非活跃 LRU 链表(inactive list)**。 - MySQL Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域:**young 区域 和 old 区域**。 但是如果还是使用「只要数据被访问一次,就将数据加入到活跃 LRU 链表头部(或者 young 区域)」这种方式的话,那么**还存在缓存污染的问题**。 为了避免「缓存污染」造成的影响,Linux 操作系统和 MySQL Innodb 存储引擎分别提高了升级为热点数据的门槛: - Linux 操作系统:在内存页被访问**第二次**的时候,才将页从 inactive list 升级到 active list 里。 - MySQL Innodb:在内存页被访问**第二次**的时候,并不会马上将该页从 old 区域升级到 young 区域,因为还要进行**停留在 old 区域的时间判断**: - 如果第二次的访问时间与第一次访问的时间**在 1 秒内**(默认值),那么该页就**不会**被从 old 区域升级到 young 区域; - 如果第二次的访问时间与第一次访问的时间**超过 1 秒**,那么该页就**会**从 old 区域升级到 young 区域; 通过提高了进入 active list(或者 young 区域)的门槛后,就很好了避免缓存污染带来的影响。 完! ------ ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF*** ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: os/3_memory/malloc.md ================================================ # 4.2 malloc 是如何分配内存的? 大家好,我是小林。 这次我们就以 malloc 动态内存分配为切入点,我在文中也做了小实验: - malloc 是如何分配内存的? - malloc 分配的是物理内存吗? - malloc(1) 会分配多大的内存? - free 释放内存,会归还给操作系统吗? - free() 函数只传入一个内存地址,为什么能知道要释放多大的内存? 发车! ## Linux 进程的内存分布长什么样? 在 Linux 操作系统中,虚拟地址空间的内部又被分为**内核空间和用户空间**两部分,不同位数的系统,地址空间的范围也不同。比如最常见的 32 位和 64 位系统,如下所示: ![图片](https://img-blog.csdnimg.cn/img_convert/1db038e1d2e5325b05e2bb80475d962a.png) 通过这里可以看出: - `32` 位系统的内核空间占用 `1G`,位于最高处,剩下的 `3G` 是用户空间; - `64` 位系统的内核空间和用户空间都是 `128T`,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。 再来说说,内核空间与用户空间的区别: - 进程在用户态时,只能访问用户空间内存; - 只有进入内核态后,才可以访问内核空间的内存; 虽然每个进程都各自有独立的虚拟内存,但是**每个虚拟内存中的内核地址,其实关联的都是相同的物理内存**。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。 ![图片](https://img-blog.csdnimg.cn/img_convert/c88bda5db60029f3ea57e4306e7da936.png) 接下来,进一步了解虚拟空间的划分情况,用户空间和内核空间划分的方式是不同的,内核空间的分布情况就不多说了。 我们看看用户空间分布的情况,以 32 位系统为例,我画了一张图来表示它们的关系: 通过这张图你可以看到,用户空间内存从**低到高**分别是 6 种不同的内存段: ![图片](https://img-blog.csdnimg.cn/img_convert/7b5b6b3728acde8df019350df3cb85c1.png) - 程序文件段,包括二进制可执行代码; - 已初始化数据段,包括静态常量; - 未初始化数据段,包括未初始化的静态变量; - 堆段,包括动态分配的内存,从低地址开始向上增长; - 文件映射段,包括动态库、共享内存等,从低地址开始向上增长(跟硬件和内核版本有关); - 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 `8 MB`。当然系统也提供了参数,以便我们自定义大小; 在这 6 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 `malloc()` 或者 `mmap()` ,就可以分别在堆和文件映射段动态分配内存。 ## malloc 是如何分配内存的? 实际上,malloc() 并不是系统调用,而是 C 库里的函数,用于动态分配内存。 malloc 申请内存的时候,会有两种方式向操作系统申请堆内存。 - 方式一:通过 brk() 系统调用从堆分配内存 - 方式二:通过 mmap() 系统调用在文件映射区域分配内存; 方式一实现的方式很简单,就是通过 brk() 函数将「堆顶」指针向高地址移动,获得新的内存空间。如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/brk申请.png) 方式二通过 mmap() 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区“偷”了一块内存。如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/mmap申请.png) > 什么场景下 malloc() 会通过 brk() 分配内存?又是什么场景下通过 mmap() 分配内存? malloc() 源码里默认定义了一个阈值: - 如果用户分配的内存小于 128 KB,则通过 brk() 申请内存; - 如果用户分配的内存大于 128 KB,则通过 mmap() 申请内存; 注意,不同的 glibc 版本定义的阈值也是不同的。 ## malloc() 分配的是物理内存吗? 不是的,**malloc() 分配的是虚拟内存**。 如果分配后的虚拟内存没有被访问的话,虚拟内存是不会映射到物理内存的,这样就不会占用物理内存了。 只有在访问已分配的虚拟地址空间的时候,操作系统通过查找页表,发现虚拟内存对应的页没有在物理内存中,就会触发缺页中断,然后操作系统会建立虚拟内存和物理内存之间的映射关系。 ## malloc(1) 会分配多大的虚拟内存? malloc() 在分配内存的时候,并不是老老实实按用户预期申请的字节数来分配内存空间大小,而是**会预分配更大的空间作为内存池**。 具体会预分配多大的空间,跟 malloc 使用的内存管理器有关系,我们就以 malloc 默认的内存管理器(Ptmalloc2)来分析。 接下里,我们做个实验,用下面这个代码,通过 malloc 申请 1 字节的内存时,看看操作系统实际分配了多大的内存空间。 ```c #include #include int main() { printf("使用 cat /proc/%d/maps查看内存分配\n",getpid()); //申请 1 字节的内存 void *addr = malloc(1); printf("此 1 字节的内存起始地址:%x\n", addr); printf("使用 cat /proc/%d/maps查看内存分配\n",getpid()); //将程序阻塞,当输入任意字符时才往下执行 getchar(); //释放内存 free(addr); printf("释放了 1 字节的内存,但 heap 堆并不会释放\n"); getchar(); return 0; } ``` 执行代码(**先提前说明,我使用的 glibc 库的版本是 2.17**): ![图片](https://img-blog.csdnimg.cn/img_convert/080ee187c8c92db45092b6688774e8da.png) 我们可以通过 /proc//maps 文件查看进程的内存分布情况。我在 maps 文件通过此 1 字节的内存起始地址过滤出了内存地址的范围。 ```shell [root@xiaolin ~]# cat /proc/3191/maps | grep d730 00d73000-00d94000 rw-p 00000000 00:00 0 [heap] ``` 这个例子分配的内存小于 128 KB,所以是通过 brk() 系统调用向堆空间申请的内存,因此可以看到最右边有 [heap] 的标识。 可以看到,堆空间的内存地址范围是 00d73000-00d94000,这个范围大小是 132KB,也就说明了 **malloc(1) 实际上预分配 132K 字节的内存**。 可能有的同学注意到了,程序里打印的内存起始地址是 `d73010`,而 maps 文件显示堆内存空间的起始地址是 `d73000`,为什么会多出来 `0x10` (16 字节)呢?这个问题,我们先放着,后面会说。 ## free 释放内存,会归还给操作系统吗? 我们在上面的进程往下执行,看看通过 free() 函数释放内存后,堆内存还在吗? ![图片](https://img-blog.csdnimg.cn/img_convert/1a9337f8f6b83fbc186f257511b5ce67.png) 从下图可以看到,通过 free 释放内存后,堆内存还是存在的,并没有归还给操作系统。 ![图片](https://img-blog.csdnimg.cn/img_convert/2b8f63892830553ec04c5f05f336ae8b.png) 这是因为与其把这 1 字节释放给操作系统,不如先缓存着放进 malloc 的内存池里,当进程再次申请 1 字节的内存时就可以直接复用,这样速度快了很多。 当然,当进程退出后,操作系统就会回收进程的所有资源。 上面说的 free 内存后堆内存还存在,是针对 malloc 通过 brk() 方式申请的内存的情况。 如果 malloc 通过 mmap 方式申请的内存,free 释放内存后就会归归还给操作系统。 我们做个实验验证下,通过 malloc 申请 128 KB 字节的内存,来使得 malloc 通过 mmap 方式来分配内存。 ```c #include #include int main() { //申请 1 字节的内存 void *addr = malloc(128*1024); printf("此 128KB 字节的内存起始地址:%x\n", addr); printf("使用 cat /proc/%d/maps查看内存分配\n",getpid()); //将程序阻塞,当输入任意字符时才往下执行 getchar(); //释放内存 free(addr); printf("释放了 128KB 字节的内存,内存也归还给了操作系统\n"); getchar(); return 0; } ``` 执行代码: ![图片](https://img-blog.csdnimg.cn/img_convert/500fdc021d956f60963f308760f511d0.png) 查看进程的内存的分布情况,可以发现最右边没有 [head] 标志,说明是通过 mmap 以匿名映射的方式从文件映射区分配的匿名内存。 ![图片](https://img-blog.csdnimg.cn/img_convert/501f458b8d35abe5e378a0f14c667797.png) 然后我们释放掉这个内存看看: ![图片](https://img-blog.csdnimg.cn/img_convert/fcdbe91cc03b6a2f6e93dd1971d1b438.png) 再次查看该 128 KB 内存的起始地址,可以发现已经不存在了,说明归还给了操作系统。 ![图片](https://img-blog.csdnimg.cn/img_convert/3f63c56b131d92806b5aabca29d33a38.png) 对于「malloc 申请的内存,free 释放内存会归还给操作系统吗?」这个问题,我们可以做个总结了: - malloc 通过 **brk()** 方式申请的内存,free 释放内存的时候,**并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用**; - malloc 通过 **mmap()** 方式申请的内存,free 释放内存的时候,**会把内存归还给操作系统,内存得到真正的释放**。 ## 为什么不全部使用 mmap 来分配内存? 因为向操作系统申请内存,是要通过系统调用的,执行系统调用是要进入内核态的,然后在回到用户态,运行态的切换会耗费不少时间。 所以,申请内存的操作应该避免频繁的系统调用,如果都用 mmap 来分配内存,等于每次都要执行系统调用。 另外,因为 mmap 分配的内存每次释放的时候,都会归还给操作系统,于是每次 mmap 分配的虚拟地址都是缺页状态的,然后在第一次访问该虚拟地址的时候,就会触发缺页中断。 也就是说,**频繁通过 mmap 分配的内存话,不仅每次都会发生运行态的切换,还会发生缺页中断(在第一次访问虚拟地址后),这样会导致 CPU 消耗较大**。 为了改进这两个问题,malloc 通过 brk() 系统调用在堆空间申请内存的时候,由于堆空间是连续的,所以直接预分配更大的内存来作为内存池,当内存释放的时候,就缓存在内存池中。 **等下次在申请内存的时候,就直接从内存池取出对应的内存块就行了,而且可能这个内存块的虚拟地址与物理地址的映射关系还存在,这样不仅减少了系统调用的次数,也减少了缺页中断的次数,这将大大降低 CPU 的消耗**。 ## 既然 brk 那么牛逼,为什么不全部使用 brk 来分配? 前面我们提到通过 brk 从堆空间分配的内存,并不会归还给操作系统,那么我们那考虑这样一个场景。 如果我们连续申请了 10k,20k,30k 这三片内存,如果 10k 和 20k 这两片释放了,变为了空闲内存空间,如果下次申请的内存小于 30k,那么就可以重用这个空闲内存空间。 ![图片](https://img-blog.csdnimg.cn/img_convert/75edee0cb75450e7987a8a482b975bda.png) 但是如果下次申请的内存大于 30k,没有可用的空闲内存空间,必须向 OS 申请,实际使用内存继续增大。 因此,随着系统频繁地 malloc 和 free,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致“内存泄露”。而这种“泄露”现象使用 valgrind 是无法检测出来的。 所以,malloc 实现中,充分考虑了 brk 和 mmap 行为上的差异及优缺点,默认分配大块内存 (128KB) 才使用 mmap 分配内存空间。 ## free() 函数只传入一个内存地址,为什么能知道要释放多大的内存? 还记得,我前面提到,malloc 返回给用户态的内存起始地址比进程的堆空间起始地址多了 16 字节吗? 这个多出来的 16 字节就是保存了该内存块的描述信息,比如有该内存块的大小。 ![图片](https://img-blog.csdnimg.cn/img_convert/cb6e3ce4532ff0a6bfd60fe3e52a806e.png) 这样当执行 free() 函数时,free 会对传入进来的内存地址向左偏移 16 字节,然后从这个 16 字节的分析出当前的内存块的大小,自然就知道要释放多大的内存了。 --- ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF*** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/其他/公众号介绍.png) ================================================ FILE: os/3_memory/mem_reclaim.md ================================================ # 4.3 内存满了,会发生什么? 大家好,我是小林。 前几天有位读者留言说,面腾讯时,被问了两个内存管理的问题: ![](https://img-blog.csdnimg.cn/cbe38428e4e644dd81ab5e85545cacf7.png) ![](https://img-blog.csdnimg.cn/90a7216d65b4454ba185db2a2d6c2b8a.png) 先来说说第一个问题:虚拟内存有什么作用? - 第一,虚拟内存可以使得进程对运行内存超过物理内存大小,因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。 - 第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题。 - 第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。 然后今天主要是聊聊第二个问题,「**系统内存紧张时,会发生什么?**」 发车! ![](https://img-blog.csdnimg.cn/e069da38c4b54ee98a585a176e2c342f.png) ## 内存分配的过程是怎样的? 应用程序通过 malloc 函数申请内存的时候,实际上申请的是虚拟内存,此时并不会分配物理内存。 当应用程序读写了这块虚拟内存,CPU 就会去访问这个虚拟内存,这时会发现这个虚拟内存没有映射到物理内存,CPU 就会产生**缺页中断**,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler(缺页中断函数)处理。 缺页中断处理函数会看是否有空闲的物理内存,如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系。 如果没有空闲的物理内存,那么内核就会开始进行**回收内存**的工作,回收的方式主要是两种:直接内存回收和后台内存回收。 - **后台内存回收**(kswapd):在物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,这个回收内存的过程**异步**的,不会阻塞进程的执行。 - **直接内存回收**(direct reclaim):如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是**同步**的,会阻塞进程的执行。 如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了 ——**触发 OOM(Out of Memory)机制**。 OOM Killer 机制会根据算法选择一个占用物理内存较高的进程,然后将其杀死,以便释放内存资源,如果物理内存依然不足,OOM Killer 会继续杀死占用物理内存较高的进程,直到释放足够的内存位置。 申请物理内存的过程如下图: ![](https://img-blog.csdnimg.cn/2f61b0822b3c4a359f99770231981b07.png) ## 哪些内存可以被回收? 系统内存紧张的时候,就会进行回收内存的工作,那具体哪些内存是可以被回收的呢? 主要有两类内存可以被回收,而且它们的回收方式也不同。 - **文件页**(File-backed Page):内核缓存的磁盘数据(Buffer)和内核缓存的文件数据(Cache)都叫作文件页。大部分文件页,都可以直接释放内存,以后有需要时,再从磁盘重新读取就可以了。而那些被应用程序修改过,并且暂时还没写入磁盘的数据(也就是脏页),就得先写入磁盘,然后才能进行内存释放。所以,**回收干净页的方式是直接释放内存,回收脏页的方式是先写回磁盘后再释放内存**。 - **匿名页**(Anonymous Page):这部分内存没有实际载体,不像文件缓存有硬盘文件这样一个载体,比如堆、栈数据等。这部分内存很可能还要再次被访问,所以不能直接释放内存,它们**回收的方式是通过 Linux 的 Swap 机制**,Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。 文件页和匿名页的回收都是基于 LRU 算法,也就是优先回收不常访问的内存。LRU 回收算法,实际上维护着 active 和 inactive 两个双向链表,其中: - **active_list** 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页; - **inactive_list** 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页; 越接近链表尾部,就表示内存页越不常访问。这样,在回收内存时,系统就可以根据活跃程度,优先回收不活跃的内存。 活跃和非活跃的内存页,按照类型的不同,又分别分为文件页和匿名页。可以从 /proc/meminfo 中,查询它们的大小,比如: ```shell # grep表示只保留包含active的指标(忽略大小写) # sort表示按照字母顺序排序 [root@xiaolin ~]# cat /proc/meminfo | grep -i active | sort Active: 901456 kB Active(anon): 227252 kB Active(file): 674204 kB Inactive: 226232 kB Inactive(anon): 41948 kB Inactive(file): 184284 kB ``` ## 回收内存带来的性能影响 在前面我们知道了回收内存有两种方式。 - 一种是后台内存回收,也就是唤醒 kswapd 内核线程,这种方式是异步回收的,不会阻塞进程。 - 一种是直接内存回收,这种方式是同步回收的,会阻塞进程,这样就会造成很长时间的延迟,以及系统的 CPU 利用率会升高,最终引起系统负荷飙高。 可被回收的内存类型有文件页和匿名页: - 文件页的回收:对于干净页是直接释放内存,这个操作不会影响性能,而对于脏页会先写回到磁盘再释放内存,这个操作会发生磁盘 I/O 的,这个操作是会影响系统性能的。 - 匿名页的回收:如果开启了 Swap 机制,那么 Swap 机制会将不常访问的匿名页换出到磁盘中,下次访问时,再从磁盘换入到内存中,这个操作是会影响系统性能的。 可以看到,回收内存的操作基本都会发生磁盘 I/O 的,如果回收内存的操作很频繁,意味着磁盘 I/O 次数会很多,这个过程势必会影响系统的性能,整个系统给人的感觉就是很卡。 下面针对回收内存导致的性能影响,说说常见的解决方式。 ### 调整文件页和匿名页的回收倾向 从文件页和匿名页的回收操作来看,文件页的回收操作对系统的影响相比匿名页的回收操作会少一点,因为文件页对于干净页回收是不会发生磁盘 I/O 的,而匿名页的 Swap 换入换出这两个操作都会发生磁盘 I/O。 Linux 提供了一个 `/proc/sys/vm/swappiness` 选项,用来调整文件页和匿名页的回收倾向。 swappiness 的范围是 0-100,数值越大,越积极使用 Swap,也就是更倾向于回收匿名页;数值越小,越消极使用 Swap,也就是更倾向于回收文件页。 ```shell [root@xiaolin ~]# cat /proc/sys/vm/swappiness 0 ``` 一般建议 swappiness 设置为 0(默认值是 60),这样在回收内存的时候,会更倾向于文件页的回收,但是并不代表不会回收匿名页。 ### 尽早触发 kswapd 内核线程异步回收内存 > 如何查看系统的直接内存回收和后台内存回收的指标? 我们可以使用 `sar -B 1` 命令来观察: ![](https://img-blog.csdnimg.cn/8acb6b28d0fc4858bd57be147d087def.png) 图中红色框住的就是后台内存回收和直接内存回收的指标,它们分别表示: - pgscank/s : kswapd(后台回收线程) 每秒扫描的 page 个数。 - pgscand/s: 应用程序在内存申请过程中每秒直接扫描的 page 个数。 - pgsteal/s: 扫描的 page 中每秒被回收的个数(pgscank+pgscand)。 如果系统时不时发生抖动,并且在抖动的时间段里如果通过 sar -B 观察到 pgscand 数值很大,那大概率是因为「直接内存回收」导致的。 针对这个问题,解决的办法就是,可以通过尽早的触发「后台内存回收」来避免应用程序进行直接内存回收。 > 什么条件下才能触发 kswapd 内核线程回收内存呢? 内核定义了三个内存阈值(watermark,也称为水位),用来衡量当前剩余内存(pages_free)是否充裕或者紧张,分别是: - 页最小阈值(pages_min); - 页低阈值(pages_low); - 页高阈值(pages_high); 这三个内存阈值会划分为四种内存使用情况,如下图: ![](https://img-blog.csdnimg.cn/166bc9f5b7c545d89f1e36ab8dd772cf.png) kswapd 会定期扫描内存的使用情况,根据剩余内存(pages_free)的情况来进行内存回收的工作。 - 图中绿色部分:如果剩余内存(pages_free)大于 页高阈值(pages_high),说明剩余内存是充足的; - 图中蓝色部分:如果剩余内存(pages_free)在页高阈值(pages_high)和页低阈值(pages_low)之间,说明内存有一定压力,但还可以满足应用程序申请内存的请求; - 图中橙色部分:如果剩余内存(pages_free)在页低阈值(pages_low)和页最小阈值(pages_min)之间,说明内存压力比较大,剩余内存不多了。**这时 kswapd0 会执行内存回收,直到剩余内存大于高阈值(pages_high)为止**。虽然会触发内存回收,但是不会阻塞应用程序,因为两者关系是异步的。 - 图中红色部分:如果剩余内存(pages_free)小于页最小阈值(pages_min),说明用户可用内存都耗尽了,此时就会**触发直接内存回收**,这时应用程序就会被阻塞,因为两者关系是同步的。 可以看到,当剩余内存页(pages_free)小于页低阈值(pages_low),就会触发 kswapd 进行后台回收,然后 kswapd 会一直回收到剩余内存页(pages_free)大于页高阈值(pages_high)。 也就是说 kswapd 的活动空间只有 pages_low 与 pages_min 之间的这段区域,如果剩余内存低于了 pages_min 会触发直接内存回收,高于了 pages_high 又不会唤醒 kswapd。 页低阈值(pages_low)可以通过内核选项 `/proc/sys/vm/min_free_kbytes` (该参数代表系统所保留空闲内存的最低限)来间接设置。 min_free_kbytes 虽然设置的是页最小阈值(pages_min),但是页高阈值(pages_high)和页低阈值(pages_low)都是根据页最小阈值(pages_min)计算生成的,它们之间的计算关系如下: ```plain pages_min = min_free_kbytes pages_low = pages_min*5/4 pages_high = pages_min*3/2 ``` 如果系统时不时发生抖动,并且通过 sar -B 观察到 pgscand 数值很大,那大概率是因为直接内存回收导致的,这时可以增大 min_free_kbytes 这个配置选项来及早地触发后台回收,然后继续观察 pgscand 是否会降为 0。 增大了 min_free_kbytes 配置后,这会使得系统预留过多的空闲内存,从而在一定程度上降低了应用程序可使用的内存量,这在一定程度上浪费了内存。极端情况下设置 min_free_kbytes 接近实际物理内存大小时,留给应用程序的内存就会太少而可能会频繁地导致 OOM 的发生。 所以在调整 min_free_kbytes 之前,需要先思考一下,应用程序更加关注什么,如果关注延迟那就适当地增大 min_free_kbytes,如果关注内存的使用量那就适当地调小 min_free_kbytes。 ### NUMA 架构下的内存回收策略 > 什么是 NUMA 架构? 再说 NUMA 架构前,先给大家说说 SMP 架构,这两个架构都是针对 CPU 的。 SMP 指的是一种**多个 CPU 处理器共享资源的电脑硬件架构**,也就是说每个 CPU 地位平等,它们共享相同的物理资源,包括总线、内存、IO、操作系统等。每个 CPU 访问内存所用时间都是相同的,因此,这种系统也被称为一致存储访问结构(UMA,Uniform Memory Access)。 随着 CPU 处理器核数的增多,多个 CPU 都通过一个总线访问内存,这样总线的带宽压力会越来越大,同时每个 CPU 可用带宽会减少,这也就是 SMP 架构的问题。 ![SMP 与 NUMA 架构](https://img-blog.csdnimg.cn/img_convert/feec409868070d8cd79aecad2895b531.png) 为了解决 SMP 架构的问题,就研制出了 NUMA 结构,即非一致存储访问结构(Non-uniform memory access,NUMA)。 NUMA 架构将每个 CPU 进行了分组,每一组 CPU 用 Node 来表示,一个 Node 可能包含多个 CPU。 **每个 Node 有自己独立的资源,包括内存、IO 等**,每个 Node 之间可以通过互联模块总线(QPI)进行通信,所以,也就意味着每个 Node 上的 CPU 都可以访问到整个系统中的所有内存。但是,访问远端 Node 的内存比访问本地内存要耗时很多。 > NUMA 架构跟回收内存有什么关系? 在 NUMA 架构下,当某个 Node 内存不足时,系统可以从其他 Node 寻找空闲内存,也可以从本地内存中回收内存。 具体选哪种模式,可以通过 /proc/sys/vm/zone_reclaim_mode 来控制。它支持以下几个选项: - 0(默认值):在回收本地内存之前,在其他 Node 寻找空闲内存; - 1:只回收本地内存; - 2:只回收本地内存,在本地回收内存时,可以将文件页中的脏页写回硬盘,以回收内存。 - 4:只回收本地内存,在本地回收内存时,可以用 swap 方式回收内存。 在使用 NUMA 架构的服务器,如果系统出现还有一半内存的时候,却发现系统频繁触发「直接内存回收」,导致了影响了系统性能,那么大概率是因为 zone_reclaim_mode 没有设置为 0,导致当本地内存不足的时候,只选择回收本地内存的方式,而不去使用其他 Node 的空闲内存。 虽然说访问远端 Node 的内存比访问本地内存要耗时很多,但是相比内存回收的危害而言,访问远端 Node 的内存带来的性能影响还是比较小的。因此,zone_reclaim_mode 一般建议设置为 0。 ## 如何保护一个进程不被 OOM 杀掉呢? 在系统空闲内存不足的情况,进程申请了一个很大的内存,如果直接内存回收都无法回收出足够大的空闲内存,那么就会触发 OOM 机制,内核就会根据算法选择一个进程杀掉。 Linux 到底是根据什么标准来选择被杀的进程呢?这就要提到一个在 Linux 内核里有一个 `oom_badness()` 函数,它会把系统中可以被杀掉的进程扫描一遍,并对每个进程打分,得分最高的进程就会被首先杀掉。 进程得分的结果受下面这两个方面影响: - 第一,进程已经使用的物理内存页面数。 - 第二,每个进程的 OOM 校准值 oom_score_adj。它是可以通过 `/proc/[pid]/oom_score_adj` 来配置的。我们可以在设置 -1000 到 1000 之间的任意一个数值,调整进程被 OOM Kill 的几率。 函数 oom_badness() 里的最终计算方法是这样的: ```c // points 代表打分的结果 // process_pages 代表进程已经使用的物理内存页面数 // oom_score_adj 代表 OOM 校准值 // totalpages 代表系统总的可用页面数 points = process_pages + oom_score_adj*totalpages/1000 ``` **用「系统总的可用页面数」乘以「OOM 校准值 oom_score_adj」再除以 1000,最后再加上进程已经使用的物理页面数,计算出来的值越大,那么这个进程被 OOM Kill 的几率也就越大**。 每个进程的 oom_score_adj 默认值都为 0,所以最终得分跟进程自身消耗的内存有关,消耗的内存越大越容易被杀掉。我们可以通过调整 oom_score_adj 的数值,来改成进程的得分结果: - 如果你不想某个进程被首先杀掉,那你可以调整该进程的 oom_score_adj,从而改变这个进程的得分结果,降低该进程被 OOM 杀死的概率。 - 如果你想某个进程无论如何都不能被杀掉,那你可以将 oom_score_adj 配置为 -1000。 我们最好将一些很重要的系统服务的 oom_score_adj 配置为 -1000,比如 sshd,因为这些系统服务一旦被杀掉,我们就很难再登陆进系统了。 但是,不建议将我们自己的业务程序的 oom_score_adj 设置为 -1000,因为业务程序一旦发生了内存泄漏,而它又不能被杀掉,这就会导致随着它的内存开销变大,OOM killer 不停地被唤醒,从而把其他进程一个个给杀掉。 参考资料: - https://time.geekbang.org/column/article/277358 - https://time.geekbang.org/column/article/75797 - https://www.jianshu.com/p/e40e8813842f ## 总结 内核在给应用程序分配物理内存的时候,如果空闲物理内存不够,那么就会进行内存回收的工作,主要有两种方式: - 后台内存回收:在物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,这个回收内存的过程异步的,不会阻塞进程的执行。 - 直接内存回收:如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行。 可被回收的内存类型有文件页和匿名页: - 文件页的回收:对于干净页是直接释放内存,这个操作不会影响性能,而对于脏页会先写回到磁盘再释放内存,这个操作会发生磁盘 I/O 的,这个操作是会影响系统性能的。 - 匿名页的回收:如果开启了 Swap 机制,那么 Swap 机制会将不常访问的匿名页换出到磁盘中,下次访问时,再从磁盘换入到内存中,这个操作是会影响系统性能的。 文件页和匿名页的回收都是基于 LRU 算法,也就是优先回收不常访问的内存。回收内存的操作基本都会发生磁盘 I/O 的,如果回收内存的操作很频繁,意味着磁盘 I/O 次数会很多,这个过程势必会影响系统的性能。 针对回收内存导致的性能影响,常见的解决方式。 - 设置 /proc/sys/vm/swappiness,调整文件页和匿名页的回收倾向,尽量倾向于回收文件页; - 设置 /proc/sys/vm/min_free_kbytes,调整 kswapd 内核线程异步回收内存的时机; - 设置 /proc/sys/vm/zone_reclaim_mode,调整 NUMA 架构下内存回收策略,建议设置为 0,这样在回收本地内存之前,会在其他 Node 寻找空闲内存,从而避免在系统还有很多空闲内存的情况下,因本地 Node 的本地内存不足,发生频繁直接内存回收导致性能下降的问题; 在经历完直接内存回收后,空闲的物理内存大小依然不够,那么就会触发 OOM 机制,OOM killer 就会根据每个进程的内存占用情况和 oom_score_adj 的值进行打分,得分最高的进程就会被首先杀掉。 我们可以通过调整进程的 /proc/[pid]/oom_score_adj 值,来降低被 OOM killer 杀掉的概率。 完! --- 新的图解文章都在公众号首发,别忘记关注了哦!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: os/3_memory/vmem.md ================================================ # 4.1 为什么要有虚拟内存? 本篇跟大家说说**内存管理**,内存管理还是比较重要的一个环节,理解了它,至少对整个操作系统的工作会有一个初步的轮廓,这也难怪面试的时候常问内存管理。 干就完事,本文的提纲: ![](https://img-blog.csdnimg.cn/970ec527d1c1417eab0d3246e77405f9.png) ## 虚拟内存 如果你是电子相关专业的,肯定在大学里捣鼓过单片机。 单片机是没有操作系统的,所以每次写完代码,都需要借助工具把程序烧录进去,这样程序才能跑起来。 另外,**单片机的 CPU 是直接操作内存的「物理地址」**。 ![](https://img-blog.csdnimg.cn/019f1f0d2d30469cbda2b8fe2cf5e622.png) 在这种情况下,要想在内存中同时运行两个程序是不可能的。如果第一个程序在 2000 的位置写入一个新的值,将会擦掉第二个程序存放在相同位置上的所有内容,所以同时运行两个程序是根本行不通的,这两个程序会立刻崩溃。 > 操作系统是如何解决这个问题呢? 这里关键的问题是这两个程序都引用了绝对物理地址,而这正是我们最需要避免的。 我们可以把进程所使用的地址「隔离」开来,即让操作系统为每个进程分配独立的一套「**虚拟地址**」,人人都有,大家自己玩自己的地址就行,互不干涉。但是有个前提每个进程都不能访问物理地址,至于虚拟地址最终怎么落到物理内存里,对进程来说是透明的,操作系统已经把这些都安排的明明白白了。 ![进程的中间层](https://img-blog.csdnimg.cn/img_convert/298fb68e3da94d767b02f2ed81ebf2c4.png) **操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。** 如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。 于是,这里就引出了两种地址的概念: - 我们程序所使用的内存地址叫做**虚拟内存地址**(*Virtual Memory Address*) - 实际存在硬件里面的空间地址叫**物理内存地址**(*Physical Memory Address*)。 操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存,如下图所示: ![](https://img-blog.csdnimg.cn/72ab76ba697e470b8ceb14d5fc5688d9.png) > 操作系统是如何管理虚拟地址与物理地址之间的关系? 主要有两种方式,分别是**内存分段和内存分页**,分段是比较早提出的,我们先来看看内存分段。 ## 内存分段 程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。**不同的段是有不同的属性的,所以就用分段(*Segmentation*)的形式把这些段分离出来。** > 分段机制下,虚拟地址和物理地址是如何映射的? 分段机制下的虚拟地址由两部分组成,**段选择因子**和**段内偏移量**。 ![](https://img-blog.csdnimg.cn/a9ed979e2ed8414f9828767592aadc21.png) 段选择因子和段内偏移量: - **段选择因子**就保存在段寄存器里面。段选择因子里面最重要的是**段号**,用作段表的索引。**段表**里面保存的是这个**段的基地址、段的界限和特权等级**等。 - 虚拟地址中的**段内偏移量**应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。 在上面,知道了虚拟地址是通过**段表**与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址,如下图: ![](https://img-blog.csdnimg.cn/c5e2ab63e6ee4c8db575f3c7c9c85962.png) 如果要访问段 3 中偏移量 500 的虚拟地址,我们可以计算出物理地址为,段 3 基地址 7000 + 偏移量 500 = 7500。 分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处: - 第一个就是**内存碎片**的问题。 - 第二个就是**内存交换的效率低**的问题。 接下来,说说为什么会有这两个问题。 > 我们先来看看,分段为什么会产生内存碎片的问题? 我们来看看这样一个例子。假设有 1G 的物理内存,用户执行了多个程序,其中: - 游戏占用了 512MB 内存 - 浏览器占用了 128MB 内存 - 音乐占用了 256 MB 内存。 这个时候,如果我们关闭了浏览器,则空闲内存还有 1024 - 512 - 256 = 256MB。 如果这个 256MB 不是连续的,被分成了两段 128 MB 内存,这就会导致没有空间再打开一个 200MB 的程序。 ![](https://img-blog.csdnimg.cn/6142bc3c917e4a6298bdb62936e0d332.png) > 内存分段会出现内存碎片吗? 内存碎片主要分为,内部内存碎片和外部内存碎片。 内存分段管理可以做到段根据实际需求分配内存,所以有多少需求就分配多大的段,所以**不会出现内部内存碎片**。 但是由于每个段的长度不固定,所以多个段未必能恰好使用所有的内存空间,会产生了多个不连续的小物理内存,导致新的程序无法被装载,所以**会出现外部内存碎片**的问题。 解决「外部内存碎片」的问题就是**内存交换**。 可以把音乐程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,我们不能装载回原来的位置,而是紧紧跟着那已经被占用了的 512MB 内存后面。这样就能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进来。 这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。 > 再来看看,分段为什么会导致内存交换效率低的问题? 对于多进程的系统来说,用分段的方式,外部内存碎片是很容易产生的,产生了外部内存碎片,那不得不重新 `Swap` 内存区域,这个过程会产生性能瓶颈。 因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。 所以,**如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。** 为了解决内存分段的「外部内存碎片和内存交换效率低」的问题,就出现了内存分页。 ## 内存分页 分段的好处就是能产生连续的内存空间,但是会出现「外部内存碎片和内存交换的空间太大」的问题。 要解决这些问题,那么就要想出能少出现一些内存碎片的办法。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决问题了。这个办法,也就是**内存分页**(*Paging*)。 **分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小**。这样一个连续并且尺寸固定的内存空间,我们叫**页**(*Page*)。在 Linux 下,每一页的大小为 `4KB`。 虚拟地址与物理地址之间通过**页表**来映射,如下图: ![](https://img-blog.csdnimg.cn/08a8e315fedc4a858060db5cb4a654af.png) 页表是存储在内存里的,**内存管理单元** (*MMU*)就做将虚拟内存地址转换成物理地址的工作。 而当进程访问的虚拟地址在页表中查不到时,系统会产生一个**缺页异常**,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。 > 分页是怎么解决分段的「外部内存碎片和内存交换效率低」的问题? 内存分页由于内存空间都是预先划分好的,也就不会像内存分段一样,在段与段之间会产生间隙非常小的内存,这正是分段会产生外部内存碎片的原因。而**采用了分页,页与页之间是紧密排列的,所以不会有外部碎片。** 但是,因为内存分页机制分配内存的最小单位是一页,即使程序不足一页大小,我们最少只能分配一个页,所以页内会出现内存浪费,所以针对**内存分页机制会有内部内存碎片**的现象。 如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为**换出**(*Swap Out*)。一旦需要的时候,再加载进来,称为**换入**(*Swap In*)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,**内存交换的效率就相对比较高。** ![](https://img-blog.csdnimg.cn/388a29f45fe947e5a49240e4eff13538.png) 更进一步地,分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是**只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。** > 分页机制下,虚拟地址和物理地址是如何映射的? 在分页机制下,虚拟地址分为两部分,**页号**和**页内偏移**。页号作为页表的索引,**页表**包含物理页每页所在**物理内存的基地址**,这个基地址与页内偏移的组合就形成了物理内存地址,见下图。 ![](https://img-blog.csdnimg.cn/7884f4d8db4949f7a5bb4bbd0f452609.png) 总结一下,对于一个内存地址转换,其实就是这样三个步骤: - 把虚拟内存地址,切分成页号和偏移量; - 根据页号,从页表里面,查询对应的物理页号; - 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址。 下面举个例子,虚拟内存中的页通过页表映射为了物理内存中的页,如下图: ![](https://img-blog.csdnimg.cn/8f187878c809414ca2486b0b71e8880e.png) 这看起来似乎没什么毛病,但是放到实际中操作系统,这种简单的分页是肯定是会有问题的。 > 简单的分页有什么缺陷吗? 有空间上的缺陷。 因为操作系统是可以同时运行非常多的进程的,那这不就意味着页表会非常的庞大。 在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万(2^20)个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 `4MB` 的内存来存储页表。 这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。 那么,`100` 个进程的话,就需要 `400MB` 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。 ### 多级页表 要解决上面的问题,就需要采用一种叫作**多级页表**(*Multi-Level Page Table*)的解决方案。 在前面我们知道了,对于单页表的实现方式,在 32 位和页大小 `4KB` 的环境下,一个进程的页表需要装下 100 多万个「页表项」,并且每个页表项是占用 4 字节大小的,于是相当于每个页表需占用 4MB 大小的空间。 我们把这个 100 多万个「页表项」的单级页表再分页,将页表(一级页表)分为 `1024` 个页表(二级页表),每个表(二级页表)中包含 `1024` 个「页表项」,形成**二级分页**。如下图所示: ![](https://img-blog.csdnimg.cn/19296e249b2240c29f9c52be70f611d5.png) > 你可能会问,分了二级表,映射 4GB 地址空间就需要 4KB(一级页表)+ 4MB(二级页表)的内存,这样占用空间不是更大了吗? 当然如果 4GB 的虚拟地址全部都映射到了物理内存上的话,二级分页占用空间确实是更大了,但是,我们往往不会为一个进程分配那么多内存。 其实我们应该换个角度来看问题,还记得计算机组成原理里面无处不在的**局部性原理**么? 每个进程都有 4GB 的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到 4GB,因为会存在部分对应的页表项都是空的,根本没有分配,对于已分配的页表项,如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存。 如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间,但**如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表**。做个简单的计算,假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= `0.804MB`,这对比单级页表的 `4MB` 是不是一个巨大的节约? 那么为什么不分级的页表就做不到这样节约内存呢? 我们从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以**页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项**(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。 我们把二级分页再推广到多级页表,就会发现页表占用的内存空间更少了,这一切都要归功于对局部性原理的充分应用。 对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是: - 全局页目录项 PGD(*Page Global Directory*); - 上层页目录项 PUD(*Page Upper Directory*); - 中间页目录项 PMD(*Page Middle Directory*); - 页表项 PTE(*Page Table Entry*); ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/内存管理/四级分页.png) ### TLB 多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。 程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。 ![](https://img-blog.csdnimg.cn/edce58534d9342ff89f5261b1929c754.png) 我们就可以利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(*Translation Lookaside Buffer*) ,通常称为页表缓存、转址旁路缓存、快表等。 ![](https://img-blog.csdnimg.cn/a3cdf27646b24614a64cfc5d7ccffa35.png) 在 CPU 芯片里面,封装了内存管理单元(*Memory Management Unit*)芯片,它用来完成地址转换和 TLB 的访问与交互。 有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。 TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。 ## 段页式内存管理 内存分段和内存分页并不是对立的,它们是可以组合起来在同一个系统中使用的,那么组合起来后,通常称为**段页式内存管理**。 ![](https://img-blog.csdnimg.cn/f19ebd6f70f84083b0d87cc5e9dea8e3.png) 段页式内存管理实现的方式: - 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制; - 接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页; 这样,地址结构就由**段号、段内页号和页内位移**三部分组成。 用于段页式地址变换的数据结构是每一个程序一张段表,每个段又建立一张页表,段表中的地址是页表的起始地址,而页表中的地址则为某页的物理页号,如图所示: ![](https://img-blog.csdnimg.cn/8904fb89ae0c49c4b0f2f7b5a0a7b099.png) 段页式地址变换中要得到物理地址须经过三次内存访问: - 第一次访问段表,得到页表起始地址; - 第二次访问页表,得到物理页号; - 第三次将物理页号与页内位移组合,得到物理地址。 可用软、硬件相结合的方法实现段页式地址变换,这样虽然增加了硬件成本和系统开销,但提高了内存的利用率。 ## Linux 内存管理 那么,Linux 操作系统采用了哪种方式来管理内存呢? > 在回答这个问题前,我们得先看看 Intel 处理器的发展历史。 早期 Intel 的处理器从 80286 开始使用的是段式内存管理。但是很快发现,光有段式内存管理而没有页式内存管理是不够的,这会使它的 X86 系列会失去市场的竞争力。因此,在不久以后的 80386 中就实现了页式内存管理。也就是说,80386 除了完成并完善从 80286 开始的段式内存管理的同时还实现了页式内存管理。 但是这个 80386 的页式内存管理设计时,没有绕开段式内存管理,而是建立在段式内存管理的基础上,这就意味着,**页式内存管理的作用是在由段式内存管理所映射而成的地址上再加上一层地址映射。** 由于此时由段式内存管理映射而成的地址不再是“物理地址”了,Intel 就称之为“线性地址”(也称虚拟地址)。于是,段式内存管理先将逻辑地址映射成线性地址,然后再由页式内存管理将线性地址映射成物理地址。 ![](https://img-blog.csdnimg.cn/bc0aaaf379fc4bc8882efd94b9052b64.png) 这里说明下逻辑地址和线性地址: - 程序所使用的地址,通常是没被段式内存管理映射的地址,称为逻辑地址; - 通过段式内存管理映射的地址,称为线性地址,也叫虚拟地址; 逻辑地址是「段式内存管理」转换前的地址,线性地址则是「页式内存管理」转换前的地址。 > 了解完 Intel 处理器的发展历史后,我们再来说说 Linux 采用了什么方式管理内存? **Linux 内存主要采用的是页式内存管理,但同时也不可避免地涉及了段机制**。 这主要是上面 Intel 处理器发展历史导致的,因为 Intel X86 CPU 一律对程序中使用的地址先进行段式映射,然后才能进行页式映射。既然 CPU 的硬件结构是这样,Linux 内核也只好服从 Intel 的选择。 但是事实上,Linux 内核所采取的办法是使段式映射的过程实际上不起什么作用。也就是说,“上有政策,下有对策”,若惹不起就躲着走。 **Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护。** > 我们再来瞧一瞧,Linux 的虚拟地址空间是如何分布的? 在 Linux 操作系统中,虚拟地址空间的内部又被分为**内核空间和用户空间**两部分,不同位数的系统,地址空间的范围也不同。比如最常见的 32 位和 64 位系统,如下所示: ![](https://img-blog.csdnimg.cn/3a6cb4e3f27241d3b09b4766bb0b1124.png) 通过这里可以看出: - `32` 位系统的内核空间占用 `1G`,位于最高处,剩下的 `3G` 是用户空间; - `64` 位系统的内核空间和用户空间都是 `128T`,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。 再来说说,内核空间与用户空间的区别: - 进程在用户态时,只能访问用户空间内存; - 只有进入内核态后,才可以访问内核空间的内存; 虽然每个进程都各自有独立的虚拟内存,但是**每个虚拟内存中的内核地址,其实关联的都是相同的物理内存**。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。 ![](https://img-blog.csdnimg.cn/48403193b7354e618bf336892886bcff.png) 接下来,进一步了解虚拟空间的划分情况,用户空间和内核空间划分的方式是不同的,内核空间的分布情况就不多说了。 我们看看用户空间分布的情况,以 32 位系统为例,我画了一张图来表示它们的关系: ![虚拟内存空间划分](https://img-blog.csdnimg.cn/img_convert/b4f882b9447760ce5321de109276ec23.png) 通过这张图你可以看到,用户空间内存,从**低到高**分别是 6 种不同的内存段: - 程序文件段(.text),包括二进制可执行代码; - 已初始化数据段(.data),包括静态常量; - 未初始化数据段(.bss),包括未初始化的静态变量; - 堆段,包括动态分配的内存,从低地址开始向上增长; - 文件映射段,包括动态库、共享内存等,从低地址开始向上增长([跟硬件和内核版本有关](http://lishiwen4.github.io/linux/linux-process-memory-location)); - 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 `8 MB`。当然系统也提供了参数,以便我们自定义大小; 在这 7 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 `malloc()` 或者 `mmap()` ,就可以分别在堆和文件映射段动态分配内存。 ## 总结 为了在多进程环境下,使得进程之间的内存地址不受影响,相互隔离,于是操作系统就为每个进程独立分配一套**虚拟地址空间**,每个程序只关心自己的虚拟地址就可以,实际上大家的虚拟地址都是一样的,但分布到物理地址内存是不一样的。作为程序,也不用关心物理地址的事情。 每个进程都有自己的虚拟空间,而物理内存只有一个,所以当启用了大量的进程,物理内存必然会很紧张,于是操作系统会通过**内存交换**技术,把不常使用的内存暂时存放到硬盘(换出),在需要的时候再装载回物理内存(换入)。 那既然有了虚拟地址空间,那必然要把虚拟地址「映射」到物理地址,这个事情通常由操作系统来维护。 那么对于虚拟地址与物理地址的映射关系,可以有**分段**和**分页**的方式,同时两者结合都是可以的。 内存分段是根据程序的逻辑角度,分成了栈段、堆段、数据段、代码段等,这样可以分离出不同属性的段,同时是一块连续的空间。但是每个段的大小都不是统一的,这就会导致外部内存碎片和内存交换效率低的问题。 于是,就出现了内存分页,把虚拟空间和物理空间分成大小固定的页,如在 Linux 系统中,每一页的大小为 `4KB`。由于分了页后,就不会产生细小的内存碎片,解决了内存分段的外部内存碎片问题。同时在内存交换的时候,写入硬盘也就一个页或几个页,这就大大提高了内存交换的效率。 再来,为了解决简单分页产生的页表过大的问题,就有了**多级页表**,它解决了空间上的问题,但这就会导致 CPU 在寻址的过程中,需要有很多层表参与,加大了时间上的开销。于是根据程序的**局部性原理**,在 CPU 芯片中加入了 **TLB**,负责缓存最近常被访问的页表项,大大提高了地址的转换速度。 **Linux 系统主要采用了分页管理,但是由于 Intel 处理器的发展史,Linux 系统无法避免分段管理**。于是 Linux 就把所有段的基地址设为 `0`,也就意味着所有程序的地址空间都是线性地址空间(虚拟地址),相当于屏蔽了 CPU 逻辑地址的概念,所以段只被用于访问控制和内存保护。 另外,Linux 系统中虚拟空间分布可分为**用户态**和**内核态**两部分,其中用户态的分布:代码段、全局变量、BSS、函数栈、堆内存、映射区。 > 最后,说下虚拟内存有什么作用? - 第一,虚拟内存可以使得进程的运行内存超过物理内存大小,因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。 - 第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题。 - 第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。 --- ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF*** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/其他/公众号介绍.png) ================================================ FILE: os/4_process/create_thread_max.md ================================================ # 5.6 一个进程最多可以创建多少个线程? 大家好,我是小林。 昨天有位读者问了我这么个问题: ![](https://img-blog.csdnimg.cn/20210715092002563.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODI3Njc0,size_16,color_FFFFFF,t_70) ![](https://img-blog.csdnimg.cn/20210715092015507.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODI3Njc0,size_16,color_FFFFFF,t_70) 大致意思就是,他看了一个面经,说虚拟内存是 2G 大小,然后他看了我的图解系统 PDF 里说虚拟内存是 4G,然后他就懵逼了。 其实他看这个面经很有问题,没有说明是什么操作系统,以及是多少位操作系统。 因为不同的操作系统和不同位数的操作系统,虚拟内存可能是不一样多。 Windows 系统我不了解,我就说说 Linux 系统。 在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址 空间的范围也不同。比如最常⻅的 32 位和 64 位系统,如下所示: ![](https://img-blog.csdnimg.cn/20210715092026648.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODI3Njc0,size_16,color_FFFFFF,t_70) 通过这里可以看出: - 32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间; - 64 位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中 间部分是未定义的。 --- 接着,来看看读者那个面经题目:**一个进程最多可以创建多少个线程?** 这个问题跟两个东西有关系: - **进程的虚拟内存空间上限**,因为创建一个线程,操作系统需要为其分配一个栈空间,如果线程数量越多,所需的栈空间就要越大,那么虚拟内存就会占用的越多。 - **系统参数限制**,虽然 Linux 并没有内核参数来控制单个进程创建的最大线程个数,但是有系统级别的参数来控制整个系统的最大线程个数。 我们先看看,在进程里创建一个线程需要消耗多少虚拟内存大小? 我们可以执行 ulimit -a 这条命令,查看进程创建线程时默认分配的栈空间大小,比如我这台服务器默认分配给线程的栈空间大小为 8M。 ![](https://img-blog.csdnimg.cn/20210715092041211.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODI3Njc0,size_16,color_FFFFFF,t_70) 在前面我们知道,在 32 位 Linux 系统里,一个进程的虚拟空间是 4G,内核分走了 1G,**留给用户用的只有 3G**。 那么假设创建一个线程需要占用 10M 虚拟内存,总共有 3G 虚拟内存可以使用。于是我们可以算出,最多可以创建差不多 300 个(3G/10M)左右的线程。 如果你想自己做个实验,你可以找台 32 位的 Linux 系统运行下面这个代码: ![](https://img-blog.csdnimg.cn/20210715092052531.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODI3Njc0,size_16,color_FFFFFF,t_70) 由于我手上没有 32 位的系统,我这里贴一个网上别人做的测试结果: ![](https://img-blog.csdnimg.cn/202107150921005.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODI3Njc0,size_16,color_FFFFFF,t_70) 如果想使得进程创建上千个线程,那么我们可以调整创建线程时分配的栈空间大小,比如调整为 512k: ```plain $ ulimit -s 512 ``` ---- 说完 32 位系统的情况,我们来看看 64 位系统里,一个进程能创建多少线程呢? 我的测试服务器的配置: - 64 位系统; - 2G 物理内存; - 单核 CPU。 64 位系统意味着用户空间的虚拟内存最大值是 128T,这个数值是很大的,如果按创建一个线程需占用 10M 栈空间的情况来算,那么理论上可以创建 128T/10M 个线程,也就是 1000 多万个线程,有点魔幻! 所以按 64 位系统的虚拟内存大小,理论上可以创建无数个线程。 事实上,肯定创建不了那么多线程,除了虚拟内存的限制,还有系统的限制。 比如下面这三个内核参数的大小,都会影响创建线程的上限: - ***/proc/sys/kernel/threads-max***,表示系统支持的最大线程数,默认值是 `14553`; - ***/proc/sys/kernel/pid_max***,表示系统全局的 PID 号数值的限制,每一个进程或线程都有 ID,ID 的值超过这个数,进程或线程就会创建失败,默认值是 `32768`; - ***/proc/sys/vm/max_map_count***,表示限制一个进程可以拥有的 VMA(虚拟内存区域) 的数量,具体什么意思我也没搞清楚,反正如果它的值很小,也会导致创建线程失败,默认值是 `65530`。 那接下针对我的测试服务器的配置,看下一个进程最多能创建多少个线程呢? 我在这台服务器跑了前面的程序,其结果如下: ![](https://img-blog.csdnimg.cn/20210715092109740.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODI3Njc0,size_16,color_FFFFFF,t_70) 可以看到,创建了 14374 个线程后,就无法在创建了,而且报错是因为资源的限制。 前面我提到的 `threads-max` 内核参数,它是限制系统里最大线程数,默认值是 14553。 我们可以运行那个测试线程数的程序后,看下当前系统的线程数是多少,可以通过 `top -H` 查看。 ![](https://img-blog.csdnimg.cn/20210715092125376.png) 左上角的 Threads 的数量显示是 14553,与 `threads-max` 内核参数的值相同,所以我们可以认为是因为这个参数导致无法继续创建线程。 那么,我们可以把 threads-max 参数设置成 `99999`: ```plain echo 99999 > /proc/sys/kernel/threads-max ``` 设置完 threads-max 参数后,我们重新跑测试线程数的程序,运行后结果如下图: ![](https://img-blog.csdnimg.cn/20210715092138115.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODI3Njc0,size_16,color_FFFFFF,t_70) 可以看到,当进程创建了 32326 个线程后,就无法继续创建里,且报错是无法继续申请内存。 此时的上限个数很接近 `pid_max` 内核参数的默认值(32768),那么我们可以尝试将这个参数设置为 99999: ```plain echo 99999 > /proc/sys/kernel/pid_max ``` 设置完 pid_max 参数后,继续跑测试线程数的程序,运行后结果创建线程的个数还是一样卡在了 32768 了。 当时我也挺疑惑的,明明 pid_max 已经调整大后,为什么线程个数还是上不去呢? 后面经过查阅资料发现,`max_map_count` 这个内核参数也是需要调大的,但是它的数值与最大线程数之间有什么关系,我也不太明白,只是知道它的值是会限制创建线程个数的上限。 然后,我把 max_map_count 内核参数也设置成后 99999: ```plain echo 99999 > /proc/sys/kernel/max_map_count ``` 继续跑测试线程数的程序,结果如下图: ![](https://img-blog.csdnimg.cn/20210715092151214.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODI3Njc0,size_16,color_FFFFFF,t_70) 当创建差不多 5 万个线程后,我的服务器就卡住不动了,CPU 都已经被占满了,毕竟这个是单核 CPU,所以现在是 CPU 的瓶颈了。 我只有这台服务器,如果你们有性能更强的服务器来测试的话,有兴趣的小伙伴可以去测试下。 接下来,我们换个思路测试下,把创建线程时分配的栈空间调大,比如调大为 1000M,在大就会创建线程失败。 ```plain ulimit -s 1024000 ``` 设置完后,跑测试线程的程序,其结果如下: ![](https://img-blog.csdnimg.cn/20210715092207662.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODI3Njc0,size_16,color_FFFFFF,t_70) 总共创建了 26390 个线程,然后就无法继续创建了,而且该进程的虚拟内存空间已经高达 25T,要知道这台服务器的物理内存才 2G。 为什么物理内存只有 2G,进程的虚拟内存却可以使用 25T 呢? 因为虚拟内存并不是全部都映射到物理内存的,程序是有局部性的特性,也就是某一个时间只会执行部分代码,所以只需要映射这部分程序就好。 你可以从上面那个 top 的截图看到,虽然进程虚拟空间很大,但是物理内存(RES)只有使用了 400 多 M。 好了,简单总结下: - 32 位系统,用户态的虚拟空间只有 3G,如果创建线程时分配的栈空间是 10M,那么一个进程最多只能创建 300 个左右的线程。 - 64 位系统,用户态的虚拟空间大到有 128T,理论上不会受虚拟内存大小的限制,而会受系统的参数或性能限制。 ================================================ FILE: os/4_process/deadlock.md ================================================ # 5.4 怎么避免死锁? 面试过程中,死锁也是高频的考点,因为如果线上环境真的发生了死锁,那真的出大事了。 这次,我们就来系统地聊聊死锁的问题。 - 死锁的概念; - 模拟死锁问题的产生; - 利用工具排查死锁问题; - 避免死锁问题的发生; --- ## 死锁的概念 在多线程编程中,我们为了防止多线程竞争共享资源而导致数据错乱,都会在操作共享资源之前加上互斥锁,只有成功获得到锁的线程,才能操作共享资源,获取不到锁的线程就只能等待,直到锁被释放。 那么,当两个线程为了保护两个不同的共享资源而使用了两个互斥锁,那么这两个互斥锁应用不当的时候,可能会造成**两个线程都在等待对方释放锁**,在没有外力的作用下,这些线程会一直相互等待,就没办法继续运行,这种情况就是发生了**死锁**。 举个例子,小林拿了小美房间的钥匙,而小林在自己的房间里,小美拿了小林房间的钥匙,而小美也在自己的房间里。如果小林要从自己的房间里出去,必须拿到小美手中的钥匙,但是小美要出去,又必须拿到小林手中的钥匙,这就形成了死锁。 死锁只有**同时满足**以下四个条件才会发生: - 互斥条件; - 持有并等待条件; - 不可剥夺条件; - 环路等待条件; ### 互斥条件 互斥条件是指**多个线程不能同时使用同一个资源**。 比如下图,如果线程 A 已经持有的资源,不能再同时被线程 B 持有,如果线程 B 请求获取线程 A 已经占用的资源,那线程 B 只能等待,直到线程 A 释放了资源。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/操作系统/死锁/互斥条件.png) ### 持有并等待条件 持有并等待条件是指,当线程 A 已经持有了资源 1,又想申请资源 2,而资源 2 已经被线程 C 持有了,所以线程 A 就会处于等待状态,但是**线程 A 在等待资源 2 的同时并不会释放自己已经持有的资源 1**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/操作系统/死锁/持有并等待条件.png) ### 不可剥夺条件 不可剥夺条件是指,当线程已经持有了资源,**在自己使用完之前不能被其他线程获取**,线程 B 如果也想使用此资源,则只能在线程 A 使用完并释放后才能获取。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/操作系统/死锁/不可剥夺条件.png) ### 环路等待条件 环路等待条件指的是,在死锁发生的时候,**两个线程获取资源的顺序构成了环形链**。 比如,线程 A 已经持有资源 2,而想请求资源 1,线程 B 已经获取了资源 1,而想请求资源 2,这就形成资源请求等待的环形图。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/操作系统/死锁/环路等待条件.png) --- ## 模拟死锁问题的产生 Talk is cheap. Show me the code. 下面,我们用代码来模拟死锁问题的产生。 首先,我们先创建 2 个线程,分别为线程 A 和 线程 B,然后有两个互斥锁,分别是 mutex_A 和 mutex_B,代码如下: ```c pthread_mutex_t mutex_A = PTHREAD_MUTEX_INITIALIZER; pthread_mutex_t mutex_B = PTHREAD_MUTEX_INITIALIZER; int main() { pthread_t tidA, tidB; //创建两个线程 pthread_create(&tidA, NULL, threadA_proc, NULL); pthread_create(&tidB, NULL, threadB_proc, NULL); pthread_join(tidA, NULL); pthread_join(tidB, NULL); printf("exit\n"); return 0; } ``` 接下来,我们看下线程 A 函数做了什么。 ```c //线程函数 A void *threadA_proc(void *data) { printf("thread A waiting get ResourceA \n"); pthread_mutex_lock(&mutex_A); printf("thread A got ResourceA \n"); sleep(1); printf("thread A waiting get ResourceB \n"); pthread_mutex_lock(&mutex_B); printf("thread A got ResourceB \n"); pthread_mutex_unlock(&mutex_B); pthread_mutex_unlock(&mutex_A); return (void *)0; } ``` 可以看到,线程 A 函数的过程: - 先获取互斥锁 A,然后睡眠 1 秒; - 再获取互斥锁 B,然后释放互斥锁 B; - 最后释放互斥锁 A; ```c //线程函数 B void *threadB_proc(void *data) { printf("thread B waiting get ResourceB \n"); pthread_mutex_lock(&mutex_B); printf("thread B got ResourceB \n"); sleep(1); printf("thread B waiting get ResourceA \n"); pthread_mutex_lock(&mutex_A); printf("thread B got ResourceA \n"); pthread_mutex_unlock(&mutex_A); pthread_mutex_unlock(&mutex_B); return (void *)0; } ``` 可以看到,线程 B 函数的过程: - 先获取互斥锁 B,然后睡眠 1 秒; - 再获取互斥锁 A,然后释放互斥锁 A; - 最后释放互斥锁 B; 然后,我们运行这个程序,运行结果如下: ```shell thread B waiting get ResourceB thread B got ResourceB thread A waiting get ResourceA thread A got ResourceA thread B waiting get ResourceA thread A waiting get ResourceB // 阻塞中。。。 ``` 可以看到线程 B 在等待互斥锁 A 的释放,线程 A 在等待互斥锁 B 的释放,双方都在等待对方资源的释放,很明显,产生了死锁问题。 --- ## 利用工具排查死锁问题 如果你想排查你的 Java 程序是否死锁,则可以使用 `jstack` 工具,它是 jdk 自带的线程堆栈分析工具。 由于小林的死锁代码例子是 C 写的,在 Linux 下,我们可以使用 `pstack` + `gdb` 工具来定位死锁问题。 pstack 命令可以显示每个线程的栈跟踪信息(函数调用过程),它的使用方式也很简单,只需要 `pstack ` 就可以了。 那么,在定位死锁问题时,我们可以多次执行 pstack 命令查看线程的函数调用过程,多次对比结果,确认哪几个线程一直没有变化,且是因为在等待锁,那么大概率是由于死锁问题导致的。 我用 pstack 输出了我前面模拟死锁问题的进程的所有线程的情况,我多次执行命令后,其结果都一样,如下: ```shell $ pstack 87746 Thread 3 (Thread 0x7f60a610a700 (LWP 87747)): #0 0x0000003720e0da1d in __lll_lock_wait () from /lib64/libpthread.so.0 #1 0x0000003720e093ca in _L_lock_829 () from /lib64/libpthread.so.0 #2 0x0000003720e09298 in pthread_mutex_lock () from /lib64/libpthread.so.0 #3 0x0000000000400725 in threadA_proc () #4 0x0000003720e07893 in start_thread () from /lib64/libpthread.so.0 #5 0x00000037206f4bfd in clone () from /lib64/libc.so.6 Thread 2 (Thread 0x7f60a5709700 (LWP 87748)): #0 0x0000003720e0da1d in __lll_lock_wait () from /lib64/libpthread.so.0 #1 0x0000003720e093ca in _L_lock_829 () from /lib64/libpthread.so.0 #2 0x0000003720e09298 in pthread_mutex_lock () from /lib64/libpthread.so.0 #3 0x0000000000400792 in threadB_proc () #4 0x0000003720e07893 in start_thread () from /lib64/libpthread.so.0 #5 0x00000037206f4bfd in clone () from /lib64/libc.so.6 Thread 1 (Thread 0x7f60a610c700 (LWP 87746)): #0 0x0000003720e080e5 in pthread_join () from /lib64/libpthread.so.0 #1 0x0000000000400806 in main () .... $ pstack 87746 Thread 3 (Thread 0x7f60a610a700 (LWP 87747)): #0 0x0000003720e0da1d in __lll_lock_wait () from /lib64/libpthread.so.0 #1 0x0000003720e093ca in _L_lock_829 () from /lib64/libpthread.so.0 #2 0x0000003720e09298 in pthread_mutex_lock () from /lib64/libpthread.so.0 #3 0x0000000000400725 in threadA_proc () #4 0x0000003720e07893 in start_thread () from /lib64/libpthread.so.0 #5 0x00000037206f4bfd in clone () from /lib64/libc.so.6 Thread 2 (Thread 0x7f60a5709700 (LWP 87748)): #0 0x0000003720e0da1d in __lll_lock_wait () from /lib64/libpthread.so.0 #1 0x0000003720e093ca in _L_lock_829 () from /lib64/libpthread.so.0 #2 0x0000003720e09298 in pthread_mutex_lock () from /lib64/libpthread.so.0 #3 0x0000000000400792 in threadB_proc () #4 0x0000003720e07893 in start_thread () from /lib64/libpthread.so.0 #5 0x00000037206f4bfd in clone () from /lib64/libc.so.6 Thread 1 (Thread 0x7f60a610c700 (LWP 87746)): #0 0x0000003720e080e5 in pthread_join () from /lib64/libpthread.so.0 #1 0x0000000000400806 in main () ``` 可以看到,Thread 2 和 Thread 3 一直阻塞获取锁(*pthread_mutex_lock*)的过程,而且 pstack 多次输出信息都没有变化,那么可能大概率发生了死锁。 但是,还不能够确认这两个线程是在互相等待对方的锁的释放,因为我们看不到它们是等在哪个锁对象,于是我们可以使用 gdb 工具进一步确认。 整个 gdb 调试过程,如下: ```shell // gdb 命令 $ gdb -p 87746 // 打印所有的线程信息 (gdb) info thread 3 Thread 0x7f60a610a700 (LWP 87747) 0x0000003720e0da1d in __lll_lock_wait () from /lib64/libpthread.so.0 2 Thread 0x7f60a5709700 (LWP 87748) 0x0000003720e0da1d in __lll_lock_wait () from /lib64/libpthread.so.0 * 1 Thread 0x7f60a610c700 (LWP 87746) 0x0000003720e080e5 in pthread_join () from /lib64/libpthread.so.0 //最左边的 * 表示 gdb 锁定的线程,切换到第二个线程去查看 // 切换到第2个线程 (gdb) thread 2 [Switching to thread 2 (Thread 0x7f60a5709700 (LWP 87748))]#0 0x0000003720e0da1d in __lll_lock_wait () from /lib64/libpthread.so.0 // bt 可以打印函数堆栈,却无法看到函数参数,跟 pstack 命令一样 (gdb) bt #0 0x0000003720e0da1d in __lll_lock_wait () from /lib64/libpthread.so.0 #1 0x0000003720e093ca in _L_lock_829 () from /lib64/libpthread.so.0 #2 0x0000003720e09298 in pthread_mutex_lock () from /lib64/libpthread.so.0 #3 0x0000000000400792 in threadB_proc (data=0x0) at dead_lock.c:25 #4 0x0000003720e07893 in start_thread () from /lib64/libpthread.so.0 #5 0x00000037206f4bfd in clone () from /lib64/libc.so.6 // 打印第三帧信息,每次函数调用都会有压栈的过程,而 frame 则记录栈中的帧信息 (gdb) frame 3 #3 0x0000000000400792 in threadB_proc (data=0x0) at dead_lock.c:25 27 printf("thread B waiting get ResourceA \n"); 28 pthread_mutex_lock(&mutex_A); // 打印mutex_A的值 , __owner表示gdb中标示线程的值,即LWP (gdb) p mutex_A $1 = {__data = {__lock = 2, __count = 0, __owner = 87747, __nusers = 1, __kind = 0, __spins = 0, __list = {__prev = 0x0, __next = 0x0}}, __size = "\002\000\000\000\000\000\000\000\303V\001\000\001", '\000' , __align = 2} // 打印mutex_B的值 , __owner表示gdb中标示线程的值,即LWP (gdb) p mutex_B $2 = {__data = {__lock = 2, __count = 0, __owner = 87748, __nusers = 1, __kind = 0, __spins = 0, __list = {__prev = 0x0, __next = 0x0}}, __size = "\002\000\000\000\000\000\000\000\304V\001\000\001", '\000' , __align = 2} ``` 我来解释下,上面的调试过程: 1. 通过 `info thread` 打印了所有的线程信息,可以看到有 3 个线程,一个是主线程(LWP 87746),另外两个都是我们自己创建的线程(LWP 87747 和 87748); 2. 通过 `thread 2`,将切换到第 2 个线程(LWP 87748); 3. 通过 `bt`,打印线程的调用栈信息,可以看到有 threadB_proc 函数,说明这个是线程 B 函数,也就说 LWP 87748 是线程 B; 4. 通过 `frame 3`,打印调用栈中的第三个帧的信息,可以看到线程 B 函数,在获取互斥锁 A 的时候阻塞了; 5. 通过 `p mutex_A`,打印互斥锁 A 对象信息,可以看到它被 LWP 为 87747(线程 A)的线程持有着; 6. 通过 `p mutex_B`,打印互斥锁 B 对象信息,可以看到他被 LWP 为 87748(线程 B)的线程持有着; 因为线程 B 在等待线程 A 所持有的 mutex_A, 而同时线程 A 又在等待线程 B 所拥有的 mutex_B, 所以可以断定该程序发生了死锁。 --- ## 避免死锁问题的发生 前面我们提到,产生死锁的四个必要条件是:互斥条件、持有并等待条件、不可剥夺条件、环路等待条件。 那么避免死锁问题就只需要破环其中一个条件就可以,最常见的并且可行的就是**使用资源有序分配法,来破环环路等待条件**。 那什么是资源有序分配法呢? 线程 A 和 线程 B 获取资源的顺序要一样,当线程 A 是先尝试获取资源 A,然后尝试获取资源 B 的时候,线程 B 同样也是先尝试获取资源 A,然后尝试获取资源 B。也就是说,线程 A 和 线程 B 总是以相同的顺序申请自己想要的资源。 我们使用资源有序分配法的方式来修改前面发生死锁的代码,我们可以不改动线程 A 的代码。 我们先要清楚线程 A 获取资源的顺序,它是先获取互斥锁 A,然后获取互斥锁 B。 所以我们只需将线程 B 改成以相同顺序的获取资源,就可以打破死锁了。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost4@main/操作系统/死锁/资源有序分配.png) 线程 B 函数改进后的代码如下: ```c //线程 B 函数,同线程 A 一样,先获取互斥锁 A,然后获取互斥锁 B void *threadB_proc(void *data) { printf("thread B waiting get ResourceA \n"); pthread_mutex_lock(&mutex_A); printf("thread B got ResourceA \n"); sleep(1); printf("thread B waiting get ResourceB \n"); pthread_mutex_lock(&mutex_B); printf("thread B got ResourceB \n"); pthread_mutex_unlock(&mutex_B); pthread_mutex_unlock(&mutex_A); return (void *)0; } ``` 执行结果如下,可以看,没有发生死锁。 ```shell thread B waiting get ResourceA thread B got ResourceA thread A waiting get ResourceA thread B waiting get ResourceB thread B got ResourceB thread A got ResourceA thread A waiting get ResourceB thread A got ResourceB exit ``` --- ## 总结 简单来说,死锁问题的产生是由两个或者以上线程并行执行的时候,争夺资源而互相等待造成的。 死锁只有同时满足互斥、持有并等待、不可剥夺、环路等待这四个条件的时候才会发生。 所以要避免死锁问题,就是要破坏其中一个条件即可,最常用的方法就是使用资源有序分配法来破坏环路等待条件。 --- ## 关注作者 ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF*** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/其他/公众号介绍.png) ================================================ FILE: os/4_process/multithread_sync.md ================================================ # 5.3 多线程冲突了怎么办? > 先来看看虚构的小故事 已经晚上 11 点了,程序员小明的双手还在键盘上飞舞着,眼神依然注视着的电脑屏幕。 没办法这段时间公司业绩增长中,需求自然也多了起来,加班自然也少不了。 天气变化莫测,这时窗外下起了蓬勃大雨,同时闪电轰鸣。 但这一丝都没有影响到小明,始料未及,突然一道巨大的雷一闪而过,办公楼就这么停电了,随后整栋楼都在回荡着的小明那一声撕心裂肺的「卧槽」。 此时,求小明的心里面积有多大? 等小明心里平复后,突然肚子非常的痛,想上厕所,小明心想肯定是晚上吃的某堡王有问题。 整栋楼都停了电,小明两眼一抹黑,啥都看不见,只能靠摸墙的方法,一步一步的来到了厕所门口。 到了厕所(**共享资源**),由于实在太急,小明直接冲入了厕所里,用手摸索着刚好第一个门没锁门,便夺门而入。 这就荒唐了,这个门里面正好小红在上着厕所,正好这个厕所门是坏了的,没办法锁门。 黑暗中,小红虽然看不见,但靠着声音,发现自己面前的这扇门有动静,觉得不对劲,于是铆足了力气,用她穿着高跟鞋脚,用力地一脚踢了过去。 小明很幸运,被踢中了「命根子」,撕心裂肺地喊出了一个字「痛」! 故事说完了,扯了那么多,实际上是为了说明,**对于共享资源,如果没有上锁,在多线程的环境里,那么就可能会发生翻车现场。** 接下来,用 `30+` 张图,带大家走进操作系统中避免多线程资源竞争的**互斥、同步**的方法。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/2-%E6%8F%90%E7%BA%B2.jpg) --- ## 竞争与协作 在单核 CPU 系统里,为了实现多个程序同时运行的假象,操作系统通常以时间片调度的方式,让每个进程执行每次执行一个时间片,时间片用完了,就切换下一个进程运行,由于这个时间片的时间很短,于是就造成了「并发」的现象。 ![并发](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/3-%E5%B9%B6%E5%8F%91.jpg) 另外,操作系统也为每个进程创建巨大、私有的虚拟内存的假象,这种地址空间的抽象让每个程序好像拥有自己的内存,而实际上操作系统在背后秘密地让多个地址空间「复用」物理内存或者磁盘。 ![虚拟内存管理 - 换入换出](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/4-%E5%86%85%E5%AD%98%E4%BA%A4%E6%8D%A2.jpg) 如果一个程序只有一个执行流程,也代表它是单线程的。当然一个程序可以有多个执行流程,也就是所谓的多线程程序,线程是调度的基本单位,进程则是资源分配的基本单位。 所以,线程之间是可以共享进程的资源,比如代码段、堆空间、数据段、打开的文件等资源,但每个线程都有自己独立的栈空间。 ![多线程](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/5-%E5%A4%9A%E7%BA%BF%E7%A8%8B.jpg) 那么问题就来了,多个线程如果竞争共享资源,如果不采取有效的措施,则会造成共享数据的混乱。 我们做个小实验,创建两个线程,它们分别对共享变量 `i` 自增 `1` 执行 `10000` 次,如下代码(虽然说是 C++ 代码,但是没学过 C++ 的同学也是能看懂的): ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/互斥与同步/6-%E5%A4%9A%E7%BA%BF%E7%A8%8B%E7%AB%9E%E4%BA%89C%2B%2B%E4%BB%A3%E7%A0%81%E4%BE%8B%E5%AD%90.jpeg) 按理来说,`i` 变量最后的值应该是 `20000`,但很不幸,并不是如此。我们对上面的程序执行一下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/7-%E8%BF%90%E8%A1%8C%E7%BB%93%E6%9E%9C.jpg) 运行了两次,发现出现了 `i` 值的结果是 `15173`,也会出现 `20000` 的 i 值结果。 每次运行不但会产生错误,而且得到不同的结果。在计算机里是不能容忍的,虽然是小概率出现的错误,但是小概率事件它一定是会发生的,「墨菲定律」大家都懂吧。 > 为什么会发生这种情况? 为了理解为什么会发生这种情况,我们必须了解编译器为更新计数器 `i` 变量生成的代码序列,也就是要了解汇编指令的执行顺序。 在这个例子中,我们只是想给 `i` 加上数字 1,那么它对应的汇编指令执行过程是这样的: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/8-%E6%B1%87%E7%BC%96%E8%AF%AD%E5%8F%A5%E8%B5%8B%E5%80%BC%E8%BF%87%E7%A8%8B.jpg) 可以发现,只是单纯给 `i` 加上数字 1,在 CPU 运行的时候,实际上要执行 `3` 条指令。 设想我们的线程 1 进入这个代码区域,它将 i 的值(假设此时是 50)从内存加载到它的寄存器中,然后它向寄存器加 1,此时在寄存器中的 i 值是 51。 现在,一件不幸的事情发生了:**时钟中断发生**。因此,操作系统将当前正在运行的线程的状态保存到线程的线程控制块 TCB。 现在更糟的事情发生了,线程 2 被调度运行,并进入同一段代码。它也执行了第一条指令,从内存获取 i 值并将其放入到寄存器中,此时内存中 i 的值仍为 50,因此线程 2 寄存器中的 i 值也是 50。假设线程 2 执行接下来的两条指令,将寄存器中的 i 值 + 1,然后将寄存器中的 i 值保存到内存中,于是此时全局变量 i 值是 51。 最后,又发生一次上下文切换,线程 1 恢复执行。还记得它已经执行了两条汇编指令,现在准备执行最后一条指令。回忆一下,线程 1 寄存器中的 i 值是 51,因此,执行最后一条指令后,将值保存到内存,全局变量 i 的值再次被设置为 51。 简单来说,增加 i(值为 50)的代码被运行两次,按理来说,最后的 i 值应该是 52,但是由于**不可控的调度**,导致最后 i 值却是 51。 针对上面线程 1 和线程 2 的执行过程,我画了一张流程图,会更明确一些: ![蓝色表示线程 1,红色表示线程 2](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/9-%E6%B1%87%E7%BC%96%E8%AF%AD%E5%8F%A5-%E8%B5%8B%E5%80%BC%E8%BF%87%E7%A8%8B-%E7%AB%9E%E4%BA%89.jpg) ### 互斥的概念 上面展示的情况称为**竞争条件(*race condition*)**,当多线程相互竞争操作共享变量时,由于运气不好,即在执行过程中发生了上下文切换,我们得到了错误的结果,事实上,每次运行都可能得到不同的结果,因此输出的结果存在**不确定性(*indeterminate*)**。 由于多线程执行操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为**临界区(*critical section*),它是访问共享资源的代码片段,一定不能给多线程同时执行。** 我们希望这段代码是**互斥(*mutualexclusion*)的,也就说保证一个线程在临界区执行时,其他线程应该被阻止进入临界区**,说白了,就是这段代码执行过程中,最多只能出现一个线程。 ![互斥](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/10-%E4%B8%B4%E7%95%8C%E5%8C%BA.jpg) 另外,说一下互斥也并不是只针对多线程。在多进程竞争共享资源的时候,也同样是可以使用互斥的方式来避免资源竞争造成的资源混乱。 ### 同步的概念 互斥解决了并发进程/线程对临界区的使用问题。这种基于临界区控制的交互作用是比较简单的,只要一个进程/线程进入了临界区,其他试图想进入临界区的进程/线程都会被阻塞着,直到第一个进程/线程离开了临界区。 我们都知道在多线程里,每个线程并不一定是顺序执行的,它们基本是以各自独立的、不可预知的速度向前推进,但有时候我们又希望多个线程能密切合作,以实现一个共同的任务。 例子,线程 1 是负责读入数据的,而线程 2 是负责处理数据的,这两个线程是相互合作、相互依赖的。线程 2 在没有收到线程 1 的唤醒通知时,就会一直阻塞等待,当线程 1 读完数据需要把数据传给线程 2 时,线程 1 会唤醒线程 2,并把数据交给线程 2 处理。 **所谓同步,就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步**。 举个生活的同步例子,你肚子饿了想要吃饭,你叫妈妈早点做菜,妈妈听到后就开始做菜,但是在妈妈没有做完饭之前,你必须阻塞等待,等妈妈做完饭后,自然会通知你,接着你吃饭的事情就可以进行了。 ![吃饭与做菜的同步关系](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/11-%E5%90%83%E9%A5%AD%E5%90%8C%E6%AD%A5.jpg) 注意,同步与互斥是两种不同的概念: - 同步就好比:「操作 A 应在操作 B 之前执行」,「操作 C 必须在操作 A 和操作 B 都完成之后才能执行」等; - 互斥就好比:「操作 A 和操作 B 不能在同一时刻执行」; --- ## 互斥与同步的实现和使用 在进程/线程并发执行的过程中,进程/线程之间存在协作的关系,例如有互斥、同步的关系。 为了实现进程/线程间正确的协作,操作系统必须提供实现进程协作的措施和方法,主要的方法有两种: - *锁*:加锁、解锁操作; - *信号量*:P、V 操作; 这两个都可以方便地实现进程/线程互斥,而信号量比锁的功能更强一些,它还可以方便地实现进程/线程同步。 ### 锁 使用加锁操作和解锁操作可以解决并发线程/进程的互斥问题。 任何想进入临界区的线程,必须先执行加锁操作。若加锁操作顺利通过,则线程可进入临界区;在完成对临界资源的访问后再执行解锁操作,以释放该临界资源。 ![加锁 - 解锁](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/12-%E4%BA%92%E6%96%A5%E9%94%81.jpg) 根据锁的实现不同,可以分为「忙等待锁」和「无忙等待锁」。 > 我们先来看看「忙等待锁」的实现 在说明「忙等待锁」的实现之前,先介绍现代 CPU 体系结构提供的特殊**原子操作指令 —— 测试和置位(*Test-and-Set*)指令**。 如果用 C 代码表示 Test-and-Set 指令,形式如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/13-TestAndSet.jpg) 测试并设置指令做了下述事情: - 把 `old_ptr` 更新为 `new` 的新值 - 返回 `old_ptr` 的旧值; 当然,**关键是这些代码是原子执行**。因为既可以测试旧值,又可以设置新值,所以我们把这条指令叫作「测试并设置」。 那什么是原子操作呢?**原子操作就是要么全部执行,要么都不执行,不能出现执行到一半的中间状态** 我们可以运用 Test-and-Set 指令来实现「忙等待锁」,代码如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/14-%E8%87%AA%E6%97%8B%E9%94%81.jpg) 我们来确保理解为什么这个锁能工作: - 第一个场景是,首先假设一个线程在运行,调用 `lock()`,没有其他线程持有锁,所以 `flag` 是 0。当调用 `TestAndSet(flag, 1)` 方法,返回 0,线程会跳出 while 循环,获取锁。同时也会原子的设置 flag 为 1,标志锁已经被持有。当线程离开临界区,调用 `unlock()` 将 `flag` 清理为 0。 - 第二种场景是,当某一个线程已经持有锁(即 `flag` 为 1)。本线程调用 `lock()`,然后调用 `TestAndSet(flag, 1)`,这一次返回 1。只要另一个线程一直持有锁,`TestAndSet()` 会重复返回 1,本线程会一直**忙等**。当 `flag` 终于被改为 0,本线程会调用 `TestAndSet()`,返回 0 并且原子地设置为 1,从而获得锁,进入临界区。 很明显,当获取不到锁时,线程就会一直 while 循环,不做任何事情,所以就被称为「忙等待锁」,也被称为**自旋锁(*spin lock*)**。 这是最简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。在单处理器上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。 > 再来看看「无等待锁」的实现 无等待锁顾明思议就是获取不到锁的时候,不用自旋。 既然不想自旋,那当没获取到锁的时候,就把当前线程放入到锁的等待队列,然后执行调度程序,把 CPU 让给其他线程执行。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/15-%E6%97%A0%E7%AD%89%E5%BE%85%E9%94%81.jpg) 本次只是提出了两种简单锁的实现方式。当然,在具体操作系统实现中,会更复杂,但也离不开本例子两个基本元素。 如果你想要对锁的更进一步理解,推荐大家可以看《操作系统导论》第 28 章锁的内容,这本书在「微信读书」就可以免费看。 ### 信号量 信号量是操作系统提供的一种协调共享资源访问的方法。 通常**信号量表示资源的数量**,对应的变量是一个整型(`sem`)变量。 另外,还有**两个原子操作的系统调用函数来控制信号量的**,分别是: - *P 操作*:将 `sem` 减 `1`,相减后,如果 `sem < 0`,则进程/线程进入阻塞等待,否则继续,表明 P 操作可能会阻塞; - *V 操作*:将 `sem` 加 `1`,相加后,如果 `sem <= 0`,唤醒一个等待中的进程/线程,表明 V 操作不会阻塞; P 操作是用在进入临界区之前,V 操作是用在离开临界区之后,这两个操作是必须成对出现的。 举个类比,2 个资源的信号量,相当于 2 条火车轨道,PV 操作如下图过程: ![信号量与火车轨道](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/16-%E7%81%AB%E8%BD%A6PV%E6%93%8D%E4%BD%9C.jpg) > 操作系统是如何实现 PV 操作的呢? 信号量数据结构与 PV 操作的算法描述如下图: ![PV 操作的算法描述](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/17-%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9FPV%E7%AE%97%E6%B3%95%E6%8F%8F%E8%BF%B0.jpg) PV 操作的函数是由操作系统管理和实现的,所以操作系统已经使得执行 PV 函数时是具有原子性的。 > PV 操作如何使用的呢? 信号量不仅可以实现临界区的互斥访问控制,还可以线程间的事件同步。 我们先来说说如何使用**信号量实现临界区的互斥访问**。 为每类共享资源设置一个信号量 `s`,其初值为 `1`,表示该临界资源未被占用。 只要把进入临界区的操作置于 `P(s)` 和 `V(s)` 之间,即可实现进程/线程互斥: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/18-%E4%BA%92%E6%96%A5%E4%BF%A1%E5%8F%B7%E9%87%8F.jpg) 此时,任何想进入临界区的线程,必先在互斥信号量上执行 P 操作,在完成对临界资源的访问后再执行 V 操作。由于互斥信号量的初始值为 1,故在第一个线程执行 P 操作后 s 值变为 0,表示临界资源为空闲,可分配给该线程,使之进入临界区。 若此时又有第二个线程想进入临界区,也应先执行 P 操作,结果使 s 变为负值,这就意味着临界资源已被占用,因此,第二个线程被阻塞。 并且,直到第一个线程执行 V 操作,释放临界资源而恢复 s 值为 0 后,才唤醒第二个线程,使之进入临界区,待它完成临界资源的访问后,又执行 V 操作,使 s 恢复到初始值 1。 对于两个并发线程,互斥信号量的值仅取 1、0 和 -1 三个值,分别表示: - 如果互斥信号量为 1,表示没有线程进入临界区; - 如果互斥信号量为 0,表示有一个线程进入临界区; - 如果互斥信号量为 -1,表示一个线程进入临界区,另一个线程等待进入。 通过互斥信号量的方式,就能保证临界区任何时刻只有一个线程在执行,就达到了互斥的效果。 再来,我们说说如何使用**信号量实现事件同步**。 同步的方式是设置一个信号量,其初值为 `0`。 我们把前面的「吃饭 - 做饭」同步的例子,用代码的方式实现一下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/19-%E4%BA%92%E6%96%A5%E4%BF%A1%E5%8F%B7%E9%87%8F%E5%90%8C%E6%AD%A5%E5%AE%9E%E7%8E%B0-%E5%90%83%E9%A5%AD%E4%BE%8B%E5%AD%90.jpg) 妈妈一开始询问儿子要不要做饭时,执行的是 `P(s1)` ,相当于询问儿子需不需要吃饭,由于 `s1` 初始值为 0,此时 `s1` 变成 -1,表明儿子不需要吃饭,所以妈妈线程就进入等待状态。 当儿子肚子饿时,执行了 `V(s1)`,使得 `s1` 信号量从 -1 变成 0,表明此时儿子需要吃饭了,于是就唤醒了阻塞中的妈妈线程,妈妈线程就开始做饭。 接着,儿子线程执行了 `P(s2)`,相当于询问妈妈饭做完了吗,由于 `s2` 初始值是 0,则此时 `s2` 变成 -1,说明妈妈还没做完饭,儿子线程就等待状态。 最后,妈妈终于做完饭了,于是执行 `V(s2)`,`s2` 信号量从 -1 变回了 0,于是就唤醒等待中的儿子线程,唤醒后,儿子线程就可以进行吃饭了。 ### 生产者 - 消费者问题 ![生产者 - 消费者模型](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/20-%E7%94%9F%E4%BA%A7%E8%80%85%E6%B6%88%E8%B4%B9%E8%80%85.jpg) 生产者 - 消费者问题描述: - **生产者**在生成数据后,放在一个缓冲区中; - **消费者**从缓冲区取出数据处理; - 任何时刻,**只能有一个**生产者或消费者可以访问缓冲区; 我们对问题分析可以得出: - 任何时刻只能有一个线程操作缓冲区,说明操作缓冲区是临界代码,**需要互斥**; - 缓冲区空时,消费者必须等待生产者生成数据;缓冲区满时,生产者必须等待消费者取出数据。说明生产者和消费者**需要同步**。 那么我们需要三个信号量,分别是: - 互斥信号量 `mutex`:用于互斥访问缓冲区,初始化值为 1; - 资源信号量 `fullBuffers`:用于消费者询问缓冲区是否有数据,有数据则读取数据,初始化值为 0(表明缓冲区一开始为空); - 资源信号量 `emptyBuffers`:用于生产者询问缓冲区是否有空位,有空位则生成数据,初始化值为 n(缓冲区大小); 具体的实现代码: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/21-%E7%94%9F%E4%BA%A7%E8%80%85%E6%B6%88%E8%B4%B9%E8%80%85%E4%BB%A3%E7%A0%81%E7%A4%BA%E4%BE%8B.jpg) 如果消费者线程一开始执行 `P(fullBuffers)`,由于信号量 `fullBuffers` 初始值为 0,则此时 `fullBuffers` 的值从 0 变为 -1,说明缓冲区里没有数据,消费者只能等待。 接着,轮到生产者执行 `P(emptyBuffers)`,表示减少 1 个空槽,如果当前没有其他生产者线程在临界区执行代码,那么该生产者线程就可以把数据放到缓冲区,放完后,执行 `V(fullBuffers)` ,信号量 `fullBuffers` 从 -1 变成 0,表明有「消费者」线程正在阻塞等待数据,于是阻塞等待的消费者线程会被唤醒。 消费者线程被唤醒后,如果此时没有其他消费者线程在读数据,那么就可以直接进入临界区,从缓冲区读取数据。最后,离开临界区后,把空槽的个数 + 1。 --- ## 经典同步问题 ### 哲学家就餐问题 当初我在校招的时候,面试官也问过「哲学家就餐」这道题目,我当时听的一脸懵逼,无论面试官怎么讲述这个问题,我也始终没听懂,就莫名其妙的说这个问题会「死锁」。 当然,我这回答槽透了,所以当场 game over,残酷又悲惨故事,就不多说了,反正当时菜就是菜。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/22-%E4%B8%8D%E9%9A%BE%E8%BF%87-%E8%A1%A8%E6%83%85.jpg) 时至今日,看我来图解这道题。 ![哲学家就餐的问题](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/23-%E5%93%B2%E5%AD%A6%E5%AE%B6%E8%BF%9B%E9%A4%90%E6%A8%A1%E5%9E%8B.jpg) 先来看看哲学家就餐的问题描述: - `5` 个老大哥哲学家,闲着没事做,围绕着一张圆桌吃面; - 巧就巧在,这个桌子只有 `5` 支叉子,每两个哲学家之间放一支叉子; - 哲学家围在一起先思考,思考中途饿了就会想进餐; - **奇葩的是,这些哲学家要两支叉子才愿意吃面,也就是需要拿到左右两边的叉子才进餐**; - **吃完后,会把两支叉子放回原处,继续思考**; 那么问题来了,如何保证哲 学家们的动作有序进行,而不会出现有人永远拿不到叉子呢? > 方案一 我们用信号量的方式,也就是 PV 操作来尝试解决它,代码如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/24-%E5%93%B2%E5%AD%A6%E5%AE%B6%E8%BF%9B%E9%A4%90-%E6%96%B9%E6%A1%88%E4%B8%80%E7%A4%BA%E4%BE%8B.jpg) 上面的程序,好似很自然。拿起叉子用 P 操作,代表有叉子就直接用,没有叉子时就等待其他哲学家放回叉子。 ![方案一的问题](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/25-%E5%93%B2%E5%AD%A6%E5%AE%B6%E8%BF%9B%E9%A4%90-%E6%96%B9%E6%A1%88%E4%B8%80%E9%97%AE%E9%A2%98.jpg) 不过,这种解法存在一个极端的问题:**假设五位哲学家同时拿起左边的叉子,桌面上就没有叉子了, 这样就没有人能够拿到他们右边的叉子,也就说每一位哲学家都会在 `P(fork[(i + 1) % N ])` 这条语句阻塞了,很明显这发生了死锁的现象**。 > 方案二 既然「方案一」会发生同时竞争左边叉子导致死锁的现象,那么我们就在拿叉子前,加个互斥信号量,代码如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/26-%E5%93%B2%E5%AD%A6%E5%AE%B6%E8%BF%9B%E9%A4%90-%E6%96%B9%E6%A1%88%E4%BA%8C%E7%A4%BA%E4%BE%8B.jpg) 上面程序中的互斥信号量的作用就在于,**只要有一个哲学家进入了「临界区」,也就是准备要拿叉子时,其他哲学家都不能动,只有这位哲学家用完叉子了,才能轮到下一个哲学家进餐。** ![方案二的问题](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/27-%E5%93%B2%E5%AD%A6%E5%AE%B6%E8%BF%9B%E9%A4%90-%E6%96%B9%E6%A1%88%E4%BA%8C%E9%97%AE%E9%A2%98.jpg) 方案二虽然能让哲学家们按顺序吃饭,但是每次进餐只能有一位哲学家,而桌面上是有 5 把叉子,按道理是能可以有两个哲学家同时进餐的,所以从效率角度上,这不是最好的解决方案。 > 方案三 那既然方案二使用互斥信号量,会导致只能允许一个哲学家就餐,那么我们就不用它。 另外,方案一的问题在于,会出现所有哲学家同时拿左边刀叉的可能性,那我们就避免哲学家可以同时拿左边的刀叉,采用分支结构,根据哲学家的编号的不同,而采取不同的动作。 **即让偶数编号的哲学家「先拿左边的叉子后拿右边的叉子」,奇数编号的哲学家「先拿右边的叉子后拿左边的叉子」。** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/28-%E5%93%B2%E5%AD%A6%E5%AE%B6%E8%BF%9B%E9%A4%90-%E6%96%B9%E6%A1%88%E4%B8%89%E7%A4%BA%E4%BE%8B.jpg) 上面的程序,在 P 操作时,根据哲学家的编号不同,拿起左右两边叉子的顺序不同。另外,V 操作是不需要分支的,因为 V 操作是不会阻塞的。 ![方案三可解决问题](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/29-%E5%93%B2%E5%AD%A6%E5%AE%B6%E8%BF%9B%E9%A4%90-%E6%96%B9%E6%A1%88%E4%B8%89-%E5%9B%BE%E8%A7%A3.jpg) 方案三即不会出现死锁,也可以两人同时进餐。 > 方案四 在这里再提出另外一种可行的解决方案,我们**用一个数组 state 来记录每一位哲学家的三个状态,分别是在进餐状态、思考状态、饥饿状态(正在试图拿叉子)。** 那么,**一个哲学家只有在两个邻居都没有进餐时,才可以进入进餐状态。** 第 `i` 个哲学家的左邻右舍,则由宏 `LEFT` 和 `RIGHT` 定义: - *LEFT* : ( i + 5 - 1 ) % 5 - *RIGHT* : ( i + 1 ) % 5 比如 i 为 2,则 `LEFT` 为 1,`RIGHT` 为 3。 具体代码实现如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/30-%E5%93%B2%E5%AD%A6%E5%AE%B6%E8%BF%9B%E9%A4%90-%E6%96%B9%E6%A1%88%E5%9B%9B%E7%A4%BA%E4%BE%8B.jpg) 上面的程序使用了一个信号量数组,每个信号量对应一位哲学家,这样在所需的叉子被占用时,想进餐的哲学家就被阻塞。 注意,每个进程/线程将 `smart_person` 函数作为主代码运行,而其他 `take_forks`、`put_forks` 和 `test` 只是普通的函数,而非单独的进程/线程。 ![方案四也可解决问题](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/31-%E5%93%B2%E5%AD%A6%E5%AE%B6%E8%BF%9B%E9%A4%90-%E6%96%B9%E6%A1%88%E5%9B%9B-%E5%9B%BE%E8%A7%A3.jpg) 方案四同样不会出现死锁,也可以两人同时进餐。 ### 读者 - 写者问题 前面的「哲学家进餐问题」对于互斥访问有限的竞争问题(如 I/O 设备)一类的建模过程十分有用。 另外,还有个著名的问题是「读者 - 写者」,它为数据库访问建立了一个模型。 读者只会读取数据,不会修改数据,而写者即可以读也可以修改数据。 读者 - 写者的问题描述: - 「读 - 读」允许:同一时刻,允许多个读者同时读 - 「读 - 写」互斥:没有写者时读者才能读,没有读者时写者才能写 - 「写 - 写」互斥:没有其他写者时,写者才能写 接下来,提出几个解决方案来分析分析。 > 方案一 使用信号量的方式来尝试解决: - 信号量 `wMutex`:控制写操作的互斥信号量,初始值为 1; - 读者计数 `rCount`:正在进行读操作的读者个数,初始化为 0; - 信号量 `rCountMutex`:控制对 rCount 读者计数器的互斥修改,初始值为 1; 接下来看看代码的实现: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/32-%E8%AF%BB%E8%80%85%E5%86%99%E8%80%85-%E6%96%B9%E6%A1%88%E4%B8%80%E7%A4%BA%E4%BE%8B.jpg) 上面的这种实现,是读者优先的策略,因为只要有读者正在读的状态,后来的读者都可以直接进入,如果读者持续不断进入,则写者会处于饥饿状态。 > 方案二 那既然有读者优先策略,自然也有写者优先策略: - 只要有写者准备要写入,写者应尽快执行写操作,后来的读者就必须阻塞; - 如果有写者持续不断写入,则读者就处于饥饿; 在方案一的基础上新增如下变量: - 信号量 `rMutex`:控制读者进入的互斥信号量,初始值为 1; - 信号量 `wDataMutex`:控制写者写操作的互斥信号量,初始值为 1; - 写者计数 `wCount`:记录写者数量,初始值为 0; - 信号量 `wCountMutex`:控制 wCount 互斥修改,初始值为 1; 具体实现如下代码: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/33-%E8%AF%BB%E8%80%85%E5%86%99%E8%80%85-%E6%96%B9%E6%A1%88%E4%BA%8C%E7%A4%BA%E4%BE%8B.jpg) 注意,这里 `rMutex` 的作用,开始有多个读者读数据,它们全部进入读者队列,此时来了一个写者,执行了 `P(rMutex)` 之后,后续的读者由于阻塞在 `rMutex` 上,都不能再进入读者队列,而写者到来,则可以全部进入写者队列,因此保证了写者优先。 同时,第一个写者执行了 `P(rMutex)` 之后,也不能马上开始写,必须等到所有进入读者队列的读者都执行完读操作,通过 `V(wDataMutex)` 唤醒写者的写操作。 > 方案三 既然读者优先策略和写者优先策略都会造成饥饿的现象,那么我们就来实现一下公平策略。 公平策略: - 优先级相同; - 写者、读者互斥访问; - 只能一个写者访问临界区; - 可以有多个读者同时访问临界资源; 具体代码实现: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E4%BA%92%E6%96%A5%E4%B8%8E%E5%90%8C%E6%AD%A5/34-%E8%AF%BB%E8%80%85%E5%86%99%E8%80%85-%E6%96%B9%E6%A1%88%E4%B8%89%E7%A4%BA%E4%BE%8B.jpg) 看完代码不知你是否有这样的疑问,为什么加了一个信号量 `flag`,就实现了公平竞争? 对比方案一的读者优先策略,可以发现,读者优先中只要后续有读者到达,读者就可以进入读者队列,而写者必须等待,直到没有读者到达。 没有读者到达会导致读者队列为空,即 `rCount==0`,此时写者才可以进入临界区执行写操作。 而这里 `flag` 的作用就是阻止读者的这种特殊权限(特殊权限是只要读者到达,就可以进入读者队列)。 比如:开始来了一些读者读数据,它们全部进入读者队列,此时来了一个写者,执行 `P(flag)` 操作,使得后续到来的读者都阻塞在 `flag` 上,不能进入读者队列,这会使得读者队列逐渐为空,即 `rCount` 减为 0。 这个写者也不能立马开始写(因为此时读者队列不为空),会阻塞在信号量 `wDataMutex` 上,读者队列中的读者全部读取结束后,最后一个读者进程执行 `V(wDataMutex)`,唤醒刚才的写者,写者则继续开始进行写操作。 --- ## 关注作者 **小林是专为大家图解的工具人,Goodbye,我们下次见!** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: os/4_process/pessim_and_optimi_lock.md ================================================ # 5.5 什么是悲观锁、乐观锁? 生活中用到的锁,用途都比较简单粗暴,上锁基本是为了防止外人进来、电动车被偷等等。 但生活中也不是没有 BUG 的,比如加锁的电动车在「广西 - 窃·格瓦拉」面前,锁就是形同虚设,只要他愿意,他就可以轻轻松松地把你电动车给「顺走」,不然打工怎么会是他这辈子不可能的事情呢?牛逼之人,必有牛逼之处。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/其他/窃格瓦拉.jpg) 那在编程世界里,「锁」更是五花八门,多种多样,每种锁的加锁开销以及应用场景也可能会不同。 如何用好锁,也是程序员的基本素养之一了。 高并发的场景下,如果选对了合适的锁,则会大大提高系统的性能,否则性能会降低。 所以,知道各种锁的开销,以及应用场景是很有必要的。 接下来,就谈一谈常见的这几种锁: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/锁/锁之提供.png) --- 多线程访问共享资源的时候,避免不了资源竞争而导致数据错乱的问题,所以我们通常为了解决这一问题,都会在访问共享资源之前加锁。 最常用的就是互斥锁,当然还有很多种不同的锁,比如自旋锁、读写锁、乐观锁等,不同种类的锁自然适用于不同的场景。 如果选择了错误的锁,那么在一些高并发的场景下,可能会降低系统的性能,这样用户体验就会非常差了。 所以,为了选择合适的锁,我们不仅需要清楚知道加锁的成本开销有多大,还需要分析业务场景中访问的共享资源的方式,再来还要考虑并发访问共享资源时的冲突概率。 对症下药,才能减少锁对高并发性能的影响。 那接下来,针对不同的应用场景,谈一谈「**互斥锁、自旋锁、读写锁、乐观锁、悲观锁**」的选择和使用。 ## 互斥锁与自旋锁 最底层的两种就是会「互斥锁和自旋锁」,有很多高级的锁都是基于它们实现的,你可以认为它们是各种锁的地基,所以我们必须清楚它俩之间的区别和应用。 加锁的目的就是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题。 当已经有一个线程加锁后,其他线程加锁则就会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的: - **互斥锁**加锁失败后,线程会**释放 CPU** ,给其他线程; - **自旋锁**加锁失败后,线程会**忙等待**,直到它拿到锁; 互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,**既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞**。 **对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的**。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/锁/互斥锁工作流程.png) 所以,互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。 那这个开销成本是什么呢?会有**两次线程上下文切换的成本**: - 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行; - 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。 线程的上下文切换的是什么?当两个线程是属于同一个进程,**因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。** 上下文切换的耗时有大佬统计过,大概在几十纳秒到几微秒之间,如果你锁住的代码执行时间比较短,那可能上下文切换的时间都比你锁住的代码执行时间还要长。 所以,**如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁。** 自旋锁是通过 CPU 提供的 `CAS` 函数(*Compare And Swap*),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。 一般加锁的过程,包含两个步骤: - 第一步,查看锁的状态,如果锁是空闲的,则执行第二步; - 第二步,将锁设置为当前线程持有; CAS 函数就把这两个步骤合并成一条硬件级指令,形成**原子指令**,这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。 比如,设锁为变量 lock,整数 0 表示锁是空闲状态,整数 pid 表示线程 ID,那么 CAS(lock, 0, pid) 就表示自旋锁的加锁操作,CAS(lock, pid, 0) 则表示解锁操作。 使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。这里的「忙等待」可以用 `while` 循环等待实现,不过最好是使用 CPU 提供的 `PAUSE` 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。 自旋锁是最简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。**需要注意,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。** 自旋锁开销少,在多核系统下一般不会主动产生线程切换,适合异步、协程等在用户态切换请求的编程方式,但如果被锁住的代码执行时间过长,自旋的线程会长时间占用 CPU 资源,所以自旋的时间和被锁住的代码执行的时间是成「正比」的关系,我们需要清楚的知道这一点。 自旋锁与互斥锁使用层面比较相似,但实现层面上完全不同:**当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对**。 它俩是锁的最基本处理方式,更高级的锁都会选择其中一个来实现,比如读写锁既可以选择互斥锁实现,也可以基于自旋锁实现。 --- ## 读写锁 读写锁从字面意思我们也可以知道,它由「读锁」和「写锁」两部分构成,如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。 所以,**读写锁适用于能明确区分读操作和写操作的场景**。 读写锁的工作原理是: - 当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。 - 但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。 所以说,写锁是独占锁,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁,而读锁是共享锁,因为读锁可以被多个线程同时持有。 知道了读写锁的工作原理后,我们可以发现,**读写锁在读多写少的场景,能发挥出优势**。 另外,根据实现的不同,读写锁可以分为「读优先锁」和「写优先锁」。 读优先锁期望的是,读锁能被更多的线程持有,以便提高读线程的并发性,它的工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 仍然可以成功获取读锁,最后直到读线程 A 和 C 释放读锁后,写线程 B 才可以成功获取写锁。如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/锁/读优先锁工作流程.png) 而「写优先锁」是优先服务写线程,其工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 获取读锁时会失败,于是读线程 C 将被阻塞在获取读锁的操作,这样只要读线程 A 释放读锁后,写线程 B 就可以成功获取写锁。如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/操作系统/锁/写优先锁工作流程.png) 读优先锁对于读线程并发性更好,但也不是没有问题。我们试想一下,如果一直有读线程获取读锁,那么写线程将永远获取不到写锁,这就造成了写线程「饥饿」的现象。 写优先锁可以保证写线程不会饿死,但是如果一直有写线程获取写锁,读线程也会被「饿死」。 既然不管优先读锁还是写锁,对方可能会出现饿死问题,那么我们就不偏袒任何一方,搞个「公平读写锁」。 **公平读写锁比较简单的一种方式是:用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现「饥饿」的现象。** 互斥锁和自旋锁都是最基本的锁,读写锁可以根据场景来选择这两种锁其中的一个进行实现。 --- ## 乐观锁与悲观锁 前面提到的互斥锁、自旋锁、读写锁,都是属于悲观锁。 悲观锁做事比较悲观,它认为**多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁**。 那相反的,如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁。 乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:**先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作**。 放弃后如何重试,这跟业务场景息息相关,虽然重试的成本很高,但是冲突的概率足够低的话,还是可以接受的。 可见,乐观锁的心态是,不管三七二十一,先改了资源再说。另外,你会发现**乐观锁全程并没有加锁,所以它也叫无锁编程**。 这里举一个场景例子:在线文档。 我们都知道在线文档可以同时多人编辑的,如果使用了悲观锁,那么只要有一个用户正在编辑文档,此时其他用户就无法打开相同的文档了,这用户体验当然不好了。 那实现多人同时编辑,实际上是用了乐观锁,它允许多个用户打开同一个文档进行编辑,编辑完提交之后才验证修改的内容是否有冲突。 怎么样才算发生冲突?这里举个例子,比如用户 A 先在浏览器编辑文档,之后用户 B 在浏览器也打开了相同的文档进行编辑,但是用户 B 比用户 A 提交早,这一过程用户 A 是不知道的,当 A 提交修改完的内容时,那么 A 和 B 之间并行修改的地方就会发生冲突。 服务端要怎么验证是否冲突了呢?通常方案如下: - 由于发生冲突的概率比较低,所以先让用户编辑文档,但是浏览器在下载文档时会记录下服务端返回的文档版本号; - 当用户提交修改时,发给服务端的请求会带上原始文档版本号,服务器收到后将它与当前版本号进行比较,如果版本号不一致则提交失败,如果版本号一致则修改成功,然后服务端版本号更新到最新的版本号。 实际上,我们常见的 SVN 和 Git 也是用了乐观锁的思想,先让用户编辑代码,然后提交的时候,通过版本号来判断是否产生了冲突,发生了冲突的地方,需要我们自己修改后,再重新提交。 乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以**只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。** --- ## 总结 开发过程中,最常见的就是互斥锁的了,互斥锁加锁失败时,会用「线程切换」来应对,当加锁失败的线程再次加锁成功后的这一过程,会有两次线程上下文切换的成本,性能损耗比较大。 如果我们明确知道被锁住的代码的执行时间很短,那我们应该选择开销比较小的自旋锁,因为自旋锁加锁失败时,并不会主动产生线程切换,而是一直忙等待,直到获取到锁,那么如果被锁住的代码执行时间很短,那这个忙等待的时间相对应也很短。 如果能区分读操作和写操作的场景,那读写锁就更合适了,它允许多个读线程可以同时持有读锁,提高了读的并发性。根据偏袒读方还是写方,可以分为读优先锁和写优先锁,读优先锁并发性很强,但是写线程会被饿死,而写优先锁会优先服务写线程,读线程也可能会被饿死,那为了避免饥饿的问题,于是就有了公平读写锁,它是用队列把请求锁的线程排队,并保证先入先出的原则来对线程加锁,这样便保证了某种线程不会被饿死,通用性也更好点。 互斥锁和自旋锁都是最基本的锁,读写锁可以根据场景来选择这两种锁其中的一个进行实现。 另外,互斥锁、自旋锁、读写锁都属于悲观锁,悲观锁认为并发访问共享资源时,冲突概率可能非常高,所以在访问共享资源前,都需要先加锁。 相反的,如果并发访问共享资源时,冲突概率非常低的话,就可以使用乐观锁,它的工作方式是,在访问共享资源时,不用先加锁,修改完共享资源后,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。 但是,一旦冲突概率上升,就不适合使用乐观锁了,因为它解决冲突的重试成本非常高。 不管使用的哪种锁,我们的加锁的代码范围应该尽可能的小,也就是加锁的粒度要小,这样执行速度会比较快。再来,使用上了合适的锁,就会快上加快了。 ## 读者问答 > CAS 不是乐观锁吗,为什么基于 CAS 实现的自旋锁是悲观锁? 乐观锁是先修改同步资源,再验证有没有发生冲突。 悲观锁是修改共享数据前,都要先加锁,防止竞争。 CAS 是乐观锁没错,但是 CAS 和自旋锁不同之处,自旋锁基于 CAS 加了 while 或者睡眠 CPU 的操作而产生自旋的效果,加锁失败会忙等待直到拿到锁,自旋锁是要需要事先拿到锁才能修改数据的,所以算悲观锁。 --- ## 关注作者 这周末忙里偷闲了下,看了三部电影,简单说一下感受。 首先看了「利刃出鞘」,这部电影是悬疑类型,也是豆瓣高分电影,电影虽然没有什么大场面,但是单纯靠缜密的剧情铺设,全程无尿点,结尾也各种翻转,如果喜欢悬疑类电影朋友,不妨抽个时间看看。 再来,看了「花木兰」,这电影我特喵无法可说,烂片中的战斗鸡,演员都是中国人却全在说英文(导演是美国迪士尼的),这种感觉就很奇怪很别扭,好比你看西游记、水浒传英文版那样的别扭。别扭也就算了,关键剧情平淡无奇,各种无厘头的地方,反正看完之后,我非常后悔把我生命中非常珍贵的 2 个小时献给了它,如果能重来,我选择用这 2 小时睡觉。 最后,当然看了「信条」,诺兰用巨资拍摄出来的电影,花钱买飞机来撞,画面非常震撼,可以说非常有诚意了。诺兰钟爱时间的概念,这次则以时间倒流方式来呈现,非常的烧脑,反正我看完后脑袋懵懵的,我就是要这种感觉,嘻嘻。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) **大家好,我是小林,一个专为大家图解的工具人,如果觉得文章对你有帮助,欢迎分享给你的朋友,我们下次见!** ================================================ FILE: os/4_process/process_base.md ================================================ # 5.1 进程、线程基础知识 > 先来看看一则小故事 我们写好的一行行代码,为了让其工作起来,我们还得把它送进城(**进程**)里,那既然进了城里,那肯定不能胡作非为了。 城里人有城里人的规矩,城中有个专门管辖你们的城管(**操作系统**),人家让你休息就休息,让你工作就工作,毕竟摊位不多,每个人都要占这个摊位来工作,城里要工作的人多着去了。 所以城管为了公平起见,它使用一种策略(**调度**)方式,给每个人一个固定的工作时间(**时间片**),时间到了就会通知你去休息而换另外一个人上场工作。 另外,在休息时候你也不能偷懒,要记住工作到哪了,不然下次到你工作了,你忘记工作到哪了,那还怎么继续? 有的人,可能还进入了县城(**线程**)工作,这里相对轻松一些,在休息的时候,要记住的东西相对较少,而且还能共享城里的资源。 > “哎哟,难道本文内容是进程和线程?” 可以,聪明的你猜出来了,也不枉费我瞎编乱造的故事了。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/2-开车-表情包.jpg) 进程和线程对于写代码的我们,真的天天见、日日见了,但见的多不代表你就熟悉它们,比如简单问你一句,你知道它们的工作原理和区别吗? 不知道没关系,今天就要跟大家讨论**操作系统的进程和线程**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/3-提纲.jpg) ::: tip 先强调一下,我们本篇讲的主要都是操作系统理论知识,偏大学计算机专业课上的那种,并不是讲解 Linux 或 Windows 操作系统的实现方式,所以大家要区别一下。 想让了解 Linux 或 Windows 操作系统的具体实现,得去看这些操作系统的实现原理或者源码书籍。 ::: --- ## 进程 我们编写的代码只是一个存储在硬盘的静态文件,通过编译后就会生成二进制可执行文件,当我们运行这个可执行文件后,它会被装载到内存中,接着 CPU 会执行程序中的每一条指令,那么这个**运行中的程序,就被称为「进程」(Process)**。 现在我们考虑有一个会读取硬盘文件数据的程序被执行了,那么当运行到读取文件的指令时,就会去从硬盘读取数据,但是硬盘的读写速度是非常慢的,那么在这个时候,如果 CPU 傻傻的等硬盘返回数据的话,那 CPU 的利用率是非常低的。 做个类比,你去煮开水时,你会傻傻的等水壶烧开吗?很明显,小孩也不会傻等。我们可以在水壶烧开之前去做其他事情。当水壶烧开了,我们自然就会听到“嘀嘀嘀”的声音,于是再把烧开的水倒入到水杯里就好了。 所以,当进程要从硬盘读取数据时,CPU 不需要阻塞等待数据的返回,而是去执行另外的进程。当硬盘数据返回时,CPU 会收到个**中断**,于是 CPU 再继续运行这个进程。 ![进程 1 与进程 2 切换](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/4-进程交替运行.jpg) 这种**多个程序、交替执行**的思想,就有 CPU 管理多个进程的初步想法。 对于一个支持多进程的系统,CPU 会从一个进程快速切换至另一个进程,其间每个进程各运行几十或几百个毫秒。 虽然单核的 CPU 在某一个瞬间,只能运行一个进程。但在 1 秒钟期间,它可能会运行多个进程,这样就产生**并行的错觉**,实际上这是**并发**。 > 并发和并行有什么区别? 一图胜千言。 ![并发与并行](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/5-并发与并行.jpg) > 进程与程序的关系的类比 到了晚饭时间,一对小情侣肚子都咕咕叫了,于是男生见机行事,就想给女生做晚饭,所以他就在网上找了辣子鸡的菜谱,接着买了一些鸡肉、辣椒、香料等材料,然后边看边学边做这道菜。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/6-做菜对应进程关系.jpg) 突然,女生说她想喝可乐,那么男生只好把做菜的事情暂停一下,并在手机菜谱标记做到哪一个步骤,把状态信息记录了下来。 然后男生听从女生的指令,跑去下楼买了一瓶冰可乐后,又回到厨房继续做菜。 **这体现了,CPU 可以从一个进程(做菜)切换到另外一个进程(买可乐),在切换前必须要记录当前进程中运行的状态信息,以备下次切换回来的时候可以恢复执行。** 所以,可以发现进程有着「**运行 - 暂停 - 运行**」的活动规律。 ### 进程的状态 在上面,我们知道了进程有着「运行 - 暂停 - 运行」的活动规律。一般说来,一个进程并不是自始至终连续不停地运行的,它与并发执行中的其他进程的执行是相互制约的。 它有时处于运行状态,有时又由于某种原因而暂停运行处于等待状态,当使它暂停的原因消失后,它又进入准备运行状态。 所以,**在一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。** ![进程的三种基本状态](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/7-进程三个基本状态.jpg) 上图中各个状态的意义: - 运行状态(*Running*):该时刻进程占用 CPU; - 就绪状态(*Ready*):可运行,由于其他进程处于运行状态而暂时停止运行; - 阻塞状态(*Blocked*):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它 CPU 控制权,它也无法运行; 当然,进程还有另外两个基本状态: - 创建状态(*new*):进程正在被创建时的状态; - 结束状态(*Exit*):进程正在从系统中消失时的状态; 于是,一个完整的进程状态的变迁如下图: ![进程五种状态的变迁](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/8-进程五个状态.jpg) 再来详细说明一下进程的状态变迁: - *NULL -> 创建状态*:一个新进程被创建时的第一个状态; - *创建状态 -> 就绪状态*:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程是很快的; - *就绪态 -> 运行状态*:处于就绪状态的进程被操作系统的进程调度器选中后,就分配给 CPU 正式运行该进程; - *运行状态 -> 结束状态*:当进程已经运行完成或出错时,会被操作系统作结束状态处理; - *运行状态 -> 就绪状态*:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,操作系统会把该进程变为就绪态,接着从就绪态选中另外一个进程运行; - *运行状态 -> 阻塞状态*:当进程请求某个事件且必须等待时,例如请求 I/O 事件; - *阻塞状态 -> 就绪状态*:当进程要等待的事件完成时,它从阻塞状态变到就绪状态; 如果有大量处于阻塞状态的进程,进程可能会占用着物理内存空间,显然不是我们所希望的,毕竟物理内存空间是有限的,被阻塞状态的进程占用着物理内存就是一种浪费物理内存的行为。 所以,在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等需要再次运行的时候,再从硬盘换入到物理内存。 ![虚拟内存管理 - 换入换出](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/9-换入换出.jpg) 那么,就需要一个新的状态,来**描述进程没有占用实际的物理内存空间的情况,这个状态就是挂起状态**。这跟阻塞状态是不一样,阻塞状态是等待某个事件的返回。 另外,挂起状态可以分为两种: - 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现; - 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行; 这两种挂起状态加上前面的五种状态,就变成了七种状态变迁(留给我的颜色不多了),见如下图: ![七种状态变迁](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/10-进程七中状态.jpg) 导致进程挂起的原因不只是因为进程所使用的内存空间不在物理内存,还包括如下情况: - 通过 sleep 让进程间歇性挂起,其工作原理是设置一个定时器,到期后唤醒进程。 - 用户希望挂起一个程序的执行,比如在 Linux 中用 `Ctrl+Z` 挂起进程; ### 进程的控制结构 在操作系统中,是用**进程控制块**(*process control block,PCB*)数据结构来描述进程的。 那 PCB 是什么呢?打开知乎搜索你就会发现这个东西并不是那么简单。 ![知乎搜 PCB 的提示](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/11-PCB嫖娼.jpg) 打住打住,我们是个正经的人,怎么会去看那些问题呢?是吧,回来回来。 **PCB 是进程存在的唯一标识**,这意味着一个进程的存在,必然会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失。 > PCB 具体包含什么信息呢? **进程描述信息:** - 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符; - 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务; **进程控制和管理信息:** - 进程当前状态,如 new、ready、running、waiting 或 blocked 等; - 进程优先级:进程抢占 CPU 时的优先级; **资源分配清单:** - 有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息。 **CPU 相关信息:** - CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执行时,能从断点处继续执行。 可见,PCB 包含信息还是比较多的。 > 每个 PCB 是如何组织的呢? 通常是通过**链表**的方式进行组织,把具有**相同状态的进程链在一起,组成各种队列**。比如: - 将所有处于就绪状态的进程链在一起,称为**就绪队列**; - 把所有因等待某事件而处于等待状态的进程链在一起就组成各种**阻塞队列**; - 另外,对于运行队列在单核 CPU 系统中则只有一个运行指针了,因为单核 CPU 在某个时间,只能运行一个程序。 那么,就绪队列和阻塞队列链表的组织形式如下图: ![就绪队列和阻塞队列](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/12-PCB状态链表组织.jpg) 除了链接的组织方式,还有索引方式,它的工作原理:将同一状态的进程组织在一个索引表中,索引表项指向相应的 PCB,不同状态对应不同的索引表。 一般会选择链表,因为可能面临进程创建,销毁等调度导致进程状态发生变化,所以链表能够更加灵活的插入和删除。 ### 进程的控制 我们熟知了进程的状态变迁和进程的数据结构 PCB 后,再来看看进程的**创建、终止、阻塞、唤醒**的过程,这些过程也就是进程的控制。 **01 创建进程** 操作系统允许一个进程创建另一个进程,而且允许子进程继承父进程所拥有的资源。 创建进程的过程如下: - 申请一个空白的 PCB,并向 PCB 中填写一些控制和管理进程的信息,比如进程的唯一标识等; - 为该进程分配运行时所必需的资源,比如内存资源; - 将 PCB 插入到就绪队列,等待被调度运行; **02 终止进程** 进程可以有 3 种终止方式:正常结束、异常结束以及外界干预(信号 `kill` 掉)。 当子进程被终止时,其在父进程处继承的资源应当还给父进程。而当父进程被终止时,该父进程的子进程就变为孤儿进程,会被 1 号进程收养,并由 1 号进程对它们完成状态收集工作。 终止进程的过程如下: - 查找需要终止的进程的 PCB; - 如果处于执行状态,则立即终止该进程的执行,然后将 CPU 资源分配给其他进程; - 如果其还有子进程,则应将该进程的子进程交给 1 号进程接管; - 将该进程所拥有的全部资源都归还给操作系统; - 将其从 PCB 所在队列中删除; **03 阻塞进程** 当进程需要等待某一事件完成时,它可以调用阻塞语句把自己阻塞等待。而一旦被阻塞等待,它只能由另一个进程唤醒。 阻塞进程的过程如下: - 找到将要被阻塞进程标识号对应的 PCB; - 如果该进程为运行状态,则保护其现场,将其状态转为阻塞状态,停止运行; - 将该 PCB 插入到阻塞队列中去; **04 唤醒进程** 进程由「运行」转变为「阻塞」状态是由于进程必须等待某一事件的完成,所以处于阻塞状态的进程是绝对不可能叫醒自己的。 如果某进程正在等待 I/O 事件,需由别的进程发消息给它,则只有当该进程所期待的事件出现时,才由发现者进程用唤醒语句叫醒它。 唤醒进程的过程如下: - 在该事件的阻塞队列中找到相应进程的 PCB; - 将其从阻塞队列中移出,并置其状态为就绪状态; - 把该 PCB 插入到就绪队列中,等待调度程序调度; 进程的阻塞和唤醒是一对功能相反的语句,如果某个进程调用了阻塞语句,则必有一个与之对应的唤醒语句。 ### 进程的上下文切换 各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程可以在 CPU 执行,那么这个**一个进程切换到另一个进程运行,称为进程的上下文切换**。 > 在详细说进程上下文切换前,我们先来看看 CPU 上下文切换 大多数操作系统都是多任务,通常支持大于 CPU 数量的任务同时运行。实际上,这些任务并不是同时运行的,只是因为系统在很短的时间内,让各个任务分别在 CPU 运行,于是就造成同时运行的错觉。 任务是交给 CPU 运行的,那么在每个任务运行前,CPU 需要知道任务从哪里加载,又从哪里开始运行。 所以,操作系统需要事先帮 CPU 设置好 **CPU 寄存器和程序计数器**。 CPU 寄存器是 CPU 内部一个容量小,但是速度极快的内存(缓存)。我举个例子,寄存器像是你的口袋,内存像你的书包,硬盘则是你家里的柜子,如果你的东西存放到口袋,那肯定是比你从书包或家里柜子取出来要快的多。 再来,程序计数器则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。 所以说,CPU 寄存器和程序计数是 CPU 在运行任何任务前,所必须依赖的环境,这些环境就叫做 **CPU 上下文**。 既然知道了什么是 CPU 上下文,那理解 CPU 上下文切换就不难了。 CPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。 系统内核会存储保持下来的上下文信息,当此任务再次被分配给 CPU 运行时,CPU 会重新加载这些上下文,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。 上面说到所谓的「任务」,主要包含进程、线程和中断。所以,可以根据任务的不同,把 CPU 上下文切换分成:**进程上下文切换、线程上下文切换和中断上下文切换**。 > 进程的上下文切换到底是切换什么呢? 进程是由内核管理和调度的,所以进程的切换只能发生在内核态。 所以,**进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。** 通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行,如下图所示: ![进程上下文切换](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/13-进程上下文切换.jpg) 大家需要注意,进程的上下文开销是很关键的,我们希望它的开销越小越好,这样可以使得进程可以把更多时间花费在执行程序上,而不是耗费在上下文切换。 > 发生进程上下文切换有哪些场景? - 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,进程就从运行状态变为就绪状态,系统从就绪队列选择另外一个进程运行; - 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行; - 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度; - 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行; - 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序; 以上,就是发生进程上下文切换的常见场景了。 ---- ## 线程 在早期的操作系统中都是以进程作为独立运行的基本单位,直到后面,计算机科学家们又提出了更小的能独立运行的基本单位,也就是**线程。** ### 为什么使用线程? 我们举个例子,假设你要编写一个视频播放器软件,那么该软件功能的核心模块有三个: - 从视频文件当中读取数据; - 对读取的数据进行解压缩; - 把解压缩后的视频数据播放出来; 对于单进程的实现方式,我想大家都会是以下这个方式: ![单进程实现方式](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/14-单线程mp4代码实例.jpg) 对于单进程的这种方式,存在以下问题: - 播放出来的画面和声音会不连贯,因为当 CPU 能力不够强的时候,`Read` 的时候可能进程就等在这了,这样就会导致等半天才进行数据解压和播放; - 各个函数之间不是并发执行,影响资源的使用效率; 那改进成多进程的方式: ![多进程实现方式](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/15-多进程mp4-代码实例.jpg) 对于多进程的这种方式,依然会存在问题: - 进程之间如何通信,共享数据? - 维护进程的系统开销较大,如创建进程时,分配资源、建立 PCB;终止进程时,回收资源、撤销 PCB;进程切换时,保存当前进程的状态信息; 那到底如何解决呢?需要有一种新的实体,满足以下特性: - 实体之间可以并发运行; - 实体之间共享相同的地址空间; 这个新的实体,就是**线程 ( *Thread* )**,线程之间可以并发运行且共享相同的地址空间。 ### 什么是线程? **线程是进程当中的一条执行流程。** 同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程各自都有一套独立的寄存器和栈,这样可以确保线程的控制流是相对独立的。 ![多线程](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/16-多线程内存结构.jpg) > 线程的优缺点? 线程的优点: - 一个进程中可以同时存在多个线程; - 各个线程之间可以并发执行; - 各个线程之间可以共享地址空间和文件等资源; 线程的缺点: - 当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃(这里是针对 C/C++ 语言,Java 语言中的线程奔溃不会造成进程崩溃,具体分析原因可以看这篇:[线程崩溃了,进程也会崩溃吗?](https://xiaolincoding.com/os/4_process/thread_crash.html))。 举个例子,对于游戏的用户设计,则不应该使用多线程的方式,否则一个用户挂了,会影响其他同个进程的线程。 ### 线程与进程的比较 线程与进程的比较如下: - 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位; - 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈; - 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系; - 线程能减少并发执行的时间和空间开销; 对于,线程相比进程能减少开销,体现在: - 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们; - 线程的终止时间比进程快,因为线程释放的资源相比进程少很多; - 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的; - 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了; 所以,不管是时间效率,还是空间效率线程比进程都要高。 ### 线程的上下文切换 在前面我们知道了,线程与进程最大的区别在于:**线程是调度的基本单位,而进程则是资源拥有的基本单位**。 所以,所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。 对于线程和进程,我们可以这么理解: - 当进程只有一个线程时,可以认为进程就等于线程; - 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时是不需要修改的; 另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。 > 线程上下文切换的是什么? 这还得看线程是不是属于同一个进程: - 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样; - **当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据**; 所以,线程的上下文切换相比进程,开销要小很多。 ### 线程的实现 主要有三种线程的实现方式: - **用户线程(*User Thread*)**:在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理; - **内核线程(*Kernel Thread*)**:在内核中实现的线程,是由内核管理的线程; - **轻量级进程(*LightWeight Process*)**:在内核中来支持用户线程; 那么,这还需要考虑一个问题,用户线程和内核线程的对应关系。 首先,第一种关系是**多对一**的关系,也就是多个用户线程对应同一个内核线程: ![多对一](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/17-内核线程与用户线程-一对多关系.jpg) 第二种是**一对一**的关系,也就是一个用户线程对应一个内核线程: ![一对一](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/18-内核线程与用户线程-一对一关系.jpg) 第三种是**多对多**的关系,也就是多个用户线程对应到多个内核线程: ![多对多](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/19-内核线程与用户线程-多对多关系.jpg) > 用户线程如何理解?存在什么优势和缺陷? 用户线程是基于用户态的线程管理库来实现的,那么**线程控制块(*Thread Control Block, TCB*)** 也是在库里面来实现的,对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB。 所以,**用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等。** 用户级线程的模型,也就类似前面提到的**多对一**的关系,即多个用户线程对应同一个内核线程,如下图所示: ![用户级线程模型](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/20-线程PCB-一对多关系.jpg) 用户线程的**优点**: - 每个进程都需要有它私有的线程控制块(TCB)列表,用来跟踪记录它各个线程状态信息(PC、栈指针、寄存器),TCB 由用户级线程库函数来维护,可用于不支持线程技术的操作系统; - 用户线程的切换也是由线程库函数来完成的,无需用户态与内核态的切换,所以速度特别快; 用户线程的**缺点**: - 由于操作系统不参与线程的调度,如果一个线程发起了系统调用而阻塞,那进程所包含的用户线程都不能执行了。 - 当一个线程开始运行后,除非它主动地交出 CPU 的使用权,否则它所在的进程当中的其他线程无法运行,因为用户态的线程没法打断当前运行中的线程,它没有这个特权,只有操作系统才有,但是用户线程不是由操作系统管理的。 - 由于时间片分配给进程,故与其他进程比,在多线程执行时,每个线程得到的时间片较少,执行会比较慢; 以上,就是用户线程的优缺点了。 > 那内核线程如何理解?存在什么优势和缺陷? **内核线程是由操作系统管理的,线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责。** 内核线程的模型,也就类似前面提到的**一对一**的关系,即一个用户线程对应一个内核线程,如下图所示: ![内核线程模型](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/21-线程PCB-一对一关系.jpg) 内核线程的**优点**: - 在一个进程当中,如果某个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行; - 分配给线程,多线程的进程获得更多的 CPU 运行时间; 内核线程的**缺点**: - 在支持内核线程的操作系统中,由内核来维护进程和线程的上下文信息,如 PCB 和 TCB; - 线程的创建、终止和切换都是通过系统调用的方式来进行,因此对于系统来说,系统开销比较大; 以上,就是内核线程的优缺点了。 > 最后的轻量级进程如何理解? **轻量级进程(*Light-weight process,LWP*)是内核支持的用户线程,一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持,而且 LWP 是由内核管理并像普通进程一样被调度**。 在大多数系统中,**LWP 与普通进程的区别也就在于它只有一个最小的执行上下文和调度程序所需的统计信息**。一般来说,一个进程代表程序的一个实例,而 LWP 代表程序的执行线程,因为一个执行线程不像进程那样需要那么多状态信息,所以 LWP 也不带有这样的信息。 在 LWP 之上也是可以使用用户线程的,那么 LWP 与用户线程的对应关系就有三种: - `1 : 1`,即一个 LWP 对应 一个用户线程; - `N : 1`,即一个 LWP 对应多个用户线程; - `M : N`,即多个 LWP 对应多个用户线程; 接下来针对上面这三种对应关系说明它们优缺点。先看下图的 LWP 模型: ![LWP 模型](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/22-LWP.jpg) **1 : 1 模式** 一个线程对应到一个 LWP 再对应到一个内核线程,如上图的进程 4,属于此模型。 - 优点:实现并行,当一个 LWP 阻塞,不会影响其他 LWP; - 缺点:每一个用户线程,就产生一个内核线程,创建线程的开销较大。 **N : 1 模式** 多个用户线程对应一个 LWP 再对应一个内核线程,如上图的进程 2,线程管理是在用户空间完成的,此模式中用户的线程对操作系统不可见。 - 优点:用户线程要开几个都没问题,且上下文切换发生在用户空间,切换的效率较高; - 缺点:一个用户线程如果阻塞了,则整个进程都将会阻塞,另外在多核 CPU 中,是没办法充分利用 CPU 的。 **M : N 模式** 根据前面的两个模型混搭一起,就形成 `M:N` 模型,该模型提供了两级控制,首先多个用户线程对应到多个 LWP,LWP 再一一对应到内核线程,如上图的进程 3。 - 优点:综合了前两种优点,大部分的线程上下文发生在用户空间,且多个线程又可以充分利用多核 CPU 的资源。 **组合模式** 如上图的进程 5,此进程结合 `1:1` 模型和 `M:N` 模型。开发人员可以针对不同的应用特点调节内核线程的数目来达到物理并行性和逻辑并行性的最佳方案。 --- ## 调度 进程都希望自己能够占用 CPU 进行工作,那么这涉及到前面说过的进程上下文切换。 一旦操作系统把进程切换到运行状态,也就意味着该进程占用着 CPU 在执行,但是当操作系统把进程切换到其他状态时,那就不能在 CPU 中执行了,于是操作系统会选择下一个要运行的进程。 选择一个进程运行这一功能是在操作系统中完成的,通常称为**调度程序**(*scheduler*)。 那到底什么时候调度进程,或以什么原则来调度进程呢? ::: tip 我知道很多人会问,线程不是操作系统的调度单位吗?为什么这里参与调度的是进程? 先提前说明,这里的进程指只有主线程的进程,所以调度主线程就等于调度了整个进程。 那为什么干脆不直接取名线程调度?主要是操作系统相关书籍,都是用进程调度这个名字,所以我也沿用了这个名字。 ::: ### 调度时机 在进程的生命周期中,当进程从一个运行状态到另外一状态变化的时候,其实会触发一次调度。 比如,以下状态的变化都会触发操作系统的调度: - *从就绪态 -> 运行态*:当进程被创建时,会进入到就绪队列,操作系统会从就绪队列选择一个进程运行; - *从运行态 -> 阻塞态*:当进程发生 I/O 事件而阻塞时,操作系统必须选择另外一个进程运行; - *从运行态 -> 结束态*:当进程退出结束后,操作系统得从就绪队列选择另外一个进程运行; 因为,这些状态变化的时候,操作系统需要考虑是否要让新的进程给 CPU 运行,或者是否让当前进程从 CPU 上退出来而换另一个进程运行。 另外,如果硬件时钟提供某个频率的周期性中断,那么可以根据如何处理时钟中断 ,把调度算法分为两类: - **非抢占式调度算法**挑选一个进程,然后让该进程运行直到被阻塞,或者直到该进程退出,才会调用另外一个进程,也就是说不会理时钟中断这个事情。 - **抢占式调度算法**挑选一个进程,然后让该进程只运行某段时间,如果在该时段结束时,该进程仍然在运行时,则会把它挂起,接着调度程序从就绪队列挑选另外一个进程。这种抢占式调度处理,需要在时间间隔的末端发生**时钟中断**,以便把 CPU 控制返回给调度程序进行调度,也就是常说的**时间片机制**。 ### 调度原则 *原则一*:如果运行的程序,发生了 I/O 事件的请求,那 CPU 使用率必然会很低,因为此时进程在阻塞等待硬盘的数据返回。这样的过程,势必会造成 CPU 突然的空闲。所以,**为了提高 CPU 利用率,在这种发送 I/O 事件致使 CPU 空闲的情况下,调度程序需要从就绪队列中选择一个进程来运行。** *原则二*:有的程序执行某个任务花费的时间会比较长,如果这个程序一直占用着 CPU,会造成系统吞吐量(CPU 在单位时间内完成的进程数量)的降低。所以,**要提高系统的吞吐率,调度程序要权衡长任务和短任务进程的运行完成数量。** *原则三*:从进程开始到结束的过程中,实际上是包含两个时间,分别是进程运行时间和进程等待时间,这两个时间总和就称为周转时间。进程的周转时间越小越好,**如果进程的等待时间很长而运行时间很短,那周转时间就很长,这不是我们所期望的,调度程序应该避免这种情况发生。** *原则四*:处于就绪队列的进程,也不能等太久,当然希望这个等待的时间越短越好,这样可以使得进程更快的在 CPU 中执行。所以,**就绪队列中进程的等待时间也是调度程序所需要考虑的原则。** *原则五*:对于鼠标、键盘这种交互式比较强的应用,我们当然希望它的响应时间越快越好,否则就会影响用户体验了。所以,**对于交互式比较强的应用,响应时间也是调度程序需要考虑的原则。** ![五种调度原则](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/23-五种调度规则.jpg) 针对上面的五种调度原则,总结成如下: - **CPU 利用率**:调度程序应确保 CPU 是始终匆忙的状态,这可提高 CPU 的利用率; - **系统吞吐量**:吞吐量表示的是单位时间内 CPU 完成进程的数量,长作业的进程会占用较长的 CPU 资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量; - **周转时间**:周转时间是进程运行 + 阻塞时间 + 等待时间的总和,一个进程的周转时间越小越好; - **等待时间**:这个等待时间不是阻塞状态的时间,而是进程处于就绪队列的时间,等待的时间越长,用户越不满意; - **响应时间**:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准。 说白了,这么多调度原则,目的就是要使得进程要「快」。 ### 调度算法 不同的调度算法适用的场景也是不同的。 接下来,说说在**单核 CPU 系统**中常见的调度算法。 > 01 先来先服务调度算法 最简单的一个调度算法,就是非抢占式的**先来先服务(*First Come First Serve, FCFS*)算法**了。 ![FCFS 调度算法](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/24-先来先服务.jpg) 顾名思义,先来后到,**每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。** 这似乎很公平,但是当一个长作业先运行了,那么后面的短作业等待的时间就会很长,不利于短作业。 FCFS 对长作业有利,适用于 CPU 繁忙型作业的系统,而不适用于 I/O 繁忙型作业的系统。 > 02 最短作业优先调度算法 **最短作业优先(*Shortest Job First, SJF*)调度算法**同样也是顾名思义,它会**优先选择运行时间最短的进程来运行**,这有助于提高系统的吞吐量。 ![SJF 调度算法](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/25-最短作业优先算法.jpg) 这显然对长作业不利,很容易造成一种极端现象。 比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。 > 03 高响应比优先调度算法 前面的「先来先服务调度算法」和「最短作业优先调度算法」都没有很好的权衡短作业和长作业。 那么,**高响应比优先 (*Highest Response Ratio Next, HRRN*)调度算法**主要是权衡了短作业和长作业。 **每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行**,「响应比优先级」的计算公式: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/26-响应比公式.jpg) 从上面的公式,可以发现: - 如果两个进程的「等待时间」相同时,「要求的服务时间」越短,「响应比」就越高,这样短作业的进程容易被选中运行; - 如果两个进程「要求的服务时间」相同时,「等待时间」越长,「响应比」就越高,这就兼顾到了长作业进程,因为进程的响应比可以随时间等待的增加而提高,当其等待时间足够长时,其响应比便可以升到很高,从而获得运行的机会; ::: tip 很多人问怎么才能知道一个进程要求服务的时间?这不是不可预知的吗? 对的,这是不可预估的。所以,高响应比优先调度算法是「理想型」的调度算法,现实中是实现不了的。 ::: > 04 时间片轮转调度算法 最古老、最简单、最公平且使用最广的算法就是**时间片轮转(*Round Robin, RR*)调度算法**。 ![RR 调度算法](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/27-时间片轮询.jpg) **每个进程被分配一个时间段,称为时间片(*Quantum*),即允许该进程在该时间段中运行。** - 如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配给另外一个进程; - 如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换; 另外,时间片的长度就是一个很关键的点: - 如果时间片设得太短会导致过多的进程上下文切换,降低了 CPU 效率; - 如果时间片设得太长又可能引起短作业进程的响应时间变长,不利于短作业。 一般来说,时间片设为 `20ms~50ms` 通常是一个比较合理的折中值。 > 05 最高优先级调度算法 前面的「时间片轮转算法」做了个假设,即让所有的进程同等重要,也不偏袒谁,大家的运行时间都一样。 但是,对于多用户计算机系统就有不同的看法了,它们希望调度是有优先级的,即希望调度程序能**从就绪队列中选择最高优先级的进程进行运行,这称为最高优先级(*Highest Priority First,HPF*)调度算法**。 进程的优先级可以分为,静态优先级和动态优先级: - 静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化; - 动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是**随着时间的推移增加等待进程的优先级**。 该算法也有两种处理优先级高的方法,非抢占式和抢占式: - 非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。 - 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。 但是依然有缺点,可能会导致低优先级的进程永远不会运行。 > 06 多级反馈队列调度算法 **多级反馈队列(*Multilevel Feedback Queue*)调度算法**是「时间片轮转算法」和「最高优先级算法」的综合和发展。 顾名思义: - 「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。 - 「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列; ![多级反馈队列](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/28-多级队列.jpg) 来看看,它是如何工作的: - 设置了多个队列,赋予每个队列不同的优先级,每个**队列优先级从高到低**,同时**优先级越高时间片越短**; - 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成; - 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行; 可以发现,对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也变更长了,所以该算法很好的**兼顾了长短作业,同时有较好的响应时间。** > 看的迷迷糊糊?那我拿去银行办业务的例子,把上面的调度算法串起来,你还不懂,你锤我! **办理业务的客户相当于进程,银行窗口工作人员相当于 CPU。** 现在,假设这个银行只有一个窗口(单核 CPU),那么工作人员一次只能处理一个业务。 ![银行办业务](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/29-银行1V1.jpg) 那么最简单的处理方式,就是先来的先处理,后面来的就乖乖排队,这就是**先来先服务(*FCFS*)调度算法**。但是万一先来的这位老哥是来贷款的,这一谈就好几个小时,一直占用着窗口,这样后面的人只能干等,或许后面的人只是想简单的取个钱,几分钟就能搞定,却因为前面老哥办长业务而要等几个小时,你说气不气人? ![先来先服务](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/30-银行-先来先服务.jpg) 有客户抱怨了,那我们就要改进,我们干脆优先给那些几分钟就能搞定的人办理业务,这就是**短作业优先(*SJF*)调度算法**。听起来不错,但是依然还是有个极端情况,万一办理短业务的人非常的多,这会导致长业务的人一直得不到服务,万一这个长业务是个大客户,那不就捡了芝麻丢了西瓜 ![最短作业优先](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/31-银行-最短作业优先.jpg) 那就公平起见,现在窗口工作人员规定,每个人我只处理 10 分钟。如果 10 分钟之内处理完,就马上换下一个人。如果没处理完,依然换下一个人,但是客户自己得记住办理到哪个步骤了。这个也就是**时间片轮转(*RR*)调度算法**。但是如果时间片设置过短,那么就会造成大量的上下文切换,增大了系统开销。如果时间片过长,相当于退化成 FCFS 算法了。 ![时间片轮转](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/32-银行-时间论片.jpg) 既然公平也可能存在问题,那银行就对客户分等级,分为普通客户、VIP 客户、SVIP 客户。只要高优先级的客户一来,就第一时间处理这个客户,这就是**最高优先级(*HPF*)调度算法**。但依然也会有极端的问题,万一当天来的全是高级客户,那普通客户不是没有被服务的机会,不把普通客户当人是吗?那我们把优先级改成动态的,如果客户办理业务时间增加,则降低其优先级,如果客户等待时间增加,则升高其优先级。 ![最高优先级(静态)](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/33-银行-最高优先级.jpg) 那有没有兼顾到公平和效率的方式呢?这里介绍一种算法,考虑的还算充分的,**多级反馈队列(*MFQ*)调度算法**,它是时间片轮转算法和优先级算法的综合和发展。它的工作方式: ![多级反馈队列](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/进程和线程/34-银行-多级反馈.jpg) - 银行设置了多个排队(就绪)队列,每个队列都有不同的优先级,**各个队列优先级从高到低**,同时每个队列执行时间片的长度也不同,**优先级越高的时间片越短**。 - 新客户(进程)来了,先进入第一级队列的末尾,按先来先服务原则排队等待被叫号(运行)。如果时间片用完客户的业务还没办理完成,则让客户进入到下一级队列的末尾,以此类推,直至客户业务办理完成。 - 当第一级队列没人排队时,就会叫号二级队列的客户。如果客户办理业务过程中,有新的客户加入到较高优先级的队列,那么此时办理中的客户需要停止办理,回到原队列的末尾等待再次叫号,因为要把窗口让给刚进入较高优先级队列的客户。 可以发现,对于要办理短业务的客户来说,可以很快的轮到并解决。对于要办理长业务的客户,一下子解决不了,就可以放到下一个队列,虽然等待的时间稍微变长了,但是轮到自己的办理时间也变长了,也可以接受,不会造成极端的现象,可以说是综合上面几种算法的优点。 ---- ## 关注作者 ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF*** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/其他/公众号介绍.png) ================================================ FILE: os/4_process/process_commu.md ================================================ # 5.2 进程间有哪些通信方式? 直接开讲! ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%BF%9B%E7%A8%8B%E9%97%B4%E9%80%9A%E4%BF%A1/3-%E6%8F%90%E7%BA%B2.jpg) --- 每个进程的用户地址空间都是独立的,一般而言是不能互相访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%BF%9B%E7%A8%8B%E9%97%B4%E9%80%9A%E4%BF%A1/4-%E8%BF%9B%E7%A8%8B%E7%A9%BA%E9%97%B4.jpg) Linux 内核提供了不少进程间通信的机制,我们来一起瞧瞧有哪些? ## 管道 如果你学过 Linux 命令,那你肯定很熟悉「`|`」这个竖线。 ```bash $ ps auxf | grep mysql ``` 上面命令行里的「`|`」竖线就是一个**管道**,它的功能是将前一个命令(`ps auxf`)的输出,作为后一个命令(`grep mysql`)的输入,从这功能描述,可以看出**管道传输数据是单向的**,如果想相互通信,我们需要创建两个管道才行。 同时,我们得知上面这种管道是没有名字,所以「`|`」表示的管道称为**匿名管道**,用完了就销毁。 管道还有另外一个类型是**命名管道**,也被叫做 `FIFO`,因为数据是先进先出的传输方式。 在使用命名管道前,先需要通过 `mkfifo` 命令来创建,并且指定管道名字: ```bash $ mkfifo myPipe ``` myPipe 就是这个管道的名称,基于 Linux 一切皆文件的理念,所以管道也是以文件的方式存在,我们可以用 ls 看一下,这个文件的类型是 p,也就是 pipe(管道)的意思: ```bash $ ls -l prw-r--r--. 1 root root 0 Jul 17 02:45 myPipe ``` 接下来,我们往 myPipe 这个管道写入数据: ```bash $ echo "hello" > myPipe // 将数据写进管道 // 停住了 ... ``` 你操作了后,你会发现命令执行后就停在这了,这是因为管道里的内容没有被读取,只有当管道里的数据被读完后,命令才可以正常退出。 于是,我们执行另外一个命令来读取这个管道里的数据: ```bash $ cat < myPipe // 读取管道里的数据 hello ``` 可以看到,管道里的内容被读取出来了,并打印在了终端上,另外一方面,echo 那个命令也正常退出了。 我们可以看出,**管道这种通信方式效率低,不适合进程间频繁地交换数据**。当然,它的好处,自然就是简单,同时也我们很容易得知管道里的数据已经被另一个进程读取了。 > 那管道如何创建呢,背后原理是什么? 匿名管道的创建,需要通过下面这个系统调用: ```c int pipe(int fd[2]) ``` 这里表示创建一个匿名管道,并返回了两个描述符,一个是管道的读取端描述符 `fd[0]`,另一个是管道的写入端描述符 `fd[1]`。注意,这个匿名管道是特殊的文件,只存在于内存,不存于文件系统中。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%BF%9B%E7%A8%8B%E9%97%B4%E9%80%9A%E4%BF%A1/5-%E7%AE%A1%E9%81%93-pipe.jpg) 其实,**所谓的管道,就是内核里面的一串缓存**。从管道的一端写入的数据,实际上是缓存在内核中的,另一端读取,也就是从内核中读取这段数据。另外,管道传输的数据是无格式的流且大小受限。 看到这,你可能会有疑问了,这两个描述符都是在一个进程里面,并没有起到进程间通信的作用,怎么样才能使得管道是跨过两个进程的呢? 我们可以使用 `fork` 创建子进程,**创建的子进程会复制父进程的文件描述符**,这样就做到了两个进程各有两个「 `fd[0]` 与 `fd[1]`」,两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信了。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%BF%9B%E7%A8%8B%E9%97%B4%E9%80%9A%E4%BF%A1/6-%E7%AE%A1%E9%81%93-pipe-fork.jpg) 管道只能一端写入,另一端读出,所以上面这种模式容易造成混乱,因为父进程和子进程都可以同时写入,也都可以读出。那么,为了避免这种情况,通常的做法是: - 父进程关闭读取的 fd[0],只保留写入的 fd[1]; - 子进程关闭写入的 fd[1],只保留读取的 fd[0]; ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%BF%9B%E7%A8%8B%E9%97%B4%E9%80%9A%E4%BF%A1/7-%E7%AE%A1%E9%81%93-pipe-fork-%E5%8D%95%E5%90%91%E9%80%9A%E4%BF%A1.jpg) 所以说如果需要双向通信,则应该创建两个管道。 到这里,我们仅仅解析了使用管道进行父进程与子进程之间的通信,但是在我们 shell 里面并不是这样的。 在 shell 里面执行 `A | B`命令的时候,A 进程和 B 进程都是 shell 创建出来的子进程,A 和 B 之间不存在父子关系,它俩的父进程都是 shell。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%BF%9B%E7%A8%8B%E9%97%B4%E9%80%9A%E4%BF%A1/8-%E7%AE%A1%E9%81%93-pipe-shell.jpg) 所以说,在 shell 里通过「`|`」匿名管道将多个命令连接在一起,实际上也就是创建了多个子进程,那么在我们编写 shell 脚本时,能使用一个管道搞定的事情,就不要多用一个管道,这样可以减少创建子进程的系统开销。 我们可以得知,**对于匿名管道,它的通信范围是存在父子关系的进程**。因为管道没有实体,也就是没有管道文件,只能通过 fork 来复制父进程 fd 文件描述符,来达到通信的目的。 另外,**对于命名管道,它可以在不相关的进程间也能相互通信**。因为命令管道,提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。 不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循**先进先出**原则,不支持 lseek 之类的文件定位操作。 --- ## 消息队列 前面说到管道的通信方式是效率低的,因此管道不适合进程间频繁地交换数据。 对于这个问题,**消息队列**的通信模式就可以解决。比如,A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了。同理,B 进程要给 A 进程发送消息也是如此。 再来,**消息队列是保存在内核中的消息链表**,在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。 消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在,而前面提到的匿名管道的生命周期,是随进程的创建而建立,随进程的结束而销毁。 消息这种模型,两个进程之间的通信就像平时发邮件一样,你来一封,我回一封,可以频繁沟通了。 但邮件的通信方式存在不足的地方有两点,**一是通信不及时,二是附件也有大小限制**,这同样也是消息队列通信不足的点。 **消息队列不适合比较大数据的传输**,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。在 Linux 内核中,会有两个宏定义 `MSGMAX` 和 `MSGMNB`,它们以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度。 **消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销**,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。 --- ## 共享内存 消息队列的读取和写入的过程,都会有发生用户态与内核态之间的消息拷贝过程。那**共享内存**的方式,就很好的解决了这一问题。 现代操作系统,对于内存管理,采用的是虚拟内存技术,也就是每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中。所以,即使进程 A 和 进程 B 的虚拟地址是一样的,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响。 **共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中**。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%BF%9B%E7%A8%8B%E9%97%B4%E9%80%9A%E4%BF%A1/9-%E5%85%B1%E4%BA%AB%E5%86%85%E5%AD%98.jpg) --- ## 信号量 用了共享内存通信方式,带来新的问题,那就是如果多个进程同时修改同一个共享内存,很有可能就冲突了。例如两个进程都同时写一个地址,那先写的那个进程会发现内容被别人覆盖了。 为了防止多进程竞争共享资源,而造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只能被一个进程访问。正好,**信号量**就实现了这一保护机制。 **信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据**。 信号量表示资源的数量,控制信号量的方式有两种原子操作: - 一个是 **P 操作**,这个操作会把信号量减去 1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。 - 另一个是 **V 操作**,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程; P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作是必须成对出现的。 接下来,举个例子,如果要使得两个进程互斥访问共享内存,我们可以初始化信号量为 `1`。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%BF%9B%E7%A8%8B%E9%97%B4%E9%80%9A%E4%BF%A1/10-%E4%BF%A1%E5%8F%B7%E9%87%8F-%E4%BA%92%E6%96%A5.jpg) 具体的过程如下: - 进程 A 在访问共享内存前,先执行了 P 操作,由于信号量的初始值为 1,故在进程 A 执行 P 操作后信号量变为 0,表示共享资源可用,于是进程 A 就可以访问共享内存。 - 若此时,进程 B 也想访问共享内存,执行了 P 操作,结果信号量变为了 -1,这就意味着临界资源已被占用,因此进程 B 被阻塞。 - 直到进程 A 访问完共享内存,才会执行 V 操作,使得信号量恢复为 0,接着就会唤醒阻塞中的线程 B,使得进程 B 可以访问共享内存,最后完成共享内存的访问后,执行 V 操作,使信号量恢复到初始值 1。 可以发现,信号初始化为 `1`,就代表着是**互斥信号量**,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。 另外,在多进程里,每个进程并不一定是顺序执行的,它们基本是以各自独立的、不可预知的速度向前推进,但有时候我们又希望多个进程能密切合作,以实现一个共同的任务。 例如,进程 A 是负责生产数据,而进程 B 是负责读取数据,这两个进程是相互合作、相互依赖的,进程 A 必须先生产了数据,进程 B 才能读取到数据,所以执行是有前后顺序的。 那么这时候,就可以用信号量来实现多进程同步的方式,我们可以初始化信号量为 `0`。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%BF%9B%E7%A8%8B%E9%97%B4%E9%80%9A%E4%BF%A1/11-%E4%BF%A1%E5%8F%B7%E9%87%8F-%E5%90%8C%E6%AD%A5.jpg) 具体过程: - 如果进程 B 比进程 A 先执行了,那么执行到 P 操作时,由于信号量初始值为 0,故信号量会变为 -1,表示进程 A 还没生产数据,于是进程 B 就阻塞等待; - 接着,当进程 A 生产完数据后,执行了 V 操作,就会使得信号量变为 0,于是就会唤醒阻塞在 P 操作的进程 B; - 最后,进程 B 被唤醒后,意味着进程 A 已经生产了数据,于是进程 B 就可以正常读取数据了。 可以发现,信号初始化为 `0`,就代表着是**同步信号量**,它可以保证进程 A 应在进程 B 之前执行。 --- ## 信号 上面说的进程间通信,都是常规状态下的工作模式。**对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。** 信号跟信号量虽然名字相似度 66.66%,但两者用途完全不一样,就好像 Java 和 JavaScript 的区别。 在 Linux 操作系统中,为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。我们可以通过 `kill -l` 命令,查看所有的信号: ```shell $ kill -l 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP 6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1 11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP 21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ 26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR 31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3 38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8 43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13 48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12 53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7 58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2 63) SIGRTMAX-1 64) SIGRTMAX ``` 运行在 shell 终端的进程,我们可以通过键盘输入某些组合键的时候,给进程发送信号。例如 - Ctrl+C 产生 `SIGINT` 信号,表示终止该进程; - Ctrl+Z 产生 `SIGTSTP` 信号,表示停止该进程,但还未结束; 如果进程在后台运行,可以通过 `kill` 命令的方式给进程发送信号,但前提需要知道运行中的进程 PID 号,例如: - kill -9 1050,表示给 PID 为 1050 的进程发送 `SIGKILL` 信号,用来立即结束该进程; 所以,信号事件的来源主要有硬件来源(如键盘 Cltr+C)和软件来源(如 kill 命令)。 信号是进程间通信机制中**唯一的异步通信机制**,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。 **1.执行默认操作**。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。 **2.捕捉信号**。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。 **3.忽略信号**。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 `SIGKILL` 和 `SIGSTOP`,它们用于在任何时候中断或结束某一进程。 --- ## Socket 前面提到的管道、消息队列、共享内存、信号量和信号都是在同一台主机上进行进程间通信,那要想**跨网络与不同主机上的进程之间通信,就需要 Socket 通信了。** 实际上,Socket 通信不仅可以跨网络与不同主机的进程间通信,还可以在同主机上进程间通信。 我们来看看创建 socket 的系统调用: ```c int socket(int domain, int type, int protocol) ``` 三个参数分别代表: - domain 参数用来指定协议族,比如 AF_INET 用于 IPV4、AF_INET6 用于 IPV6、AF_LOCAL/AF_UNIX 用于本机; - type 参数用来指定通信特性,比如 SOCK_STREAM 表示的是字节流,对应 TCP、SOCK_DGRAM 表示的是数据报,对应 UDP、SOCK_RAW 表示的是原始套接字; - protocol 参数原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成,protocol 目前一般写成 0 即可; 根据创建 socket 类型的不同,通信的方式也就不同: - 实现 TCP 字节流通信:socket 类型是 AF_INET 和 SOCK_STREAM; - 实现 UDP 数据报通信:socket 类型是 AF_INET 和 SOCK_DGRAM; - 实现本地进程间通信: 「本地字节流 socket」类型是 AF_LOCAL 和 SOCK_STREAM,「本地数据报 socket」类型是 AF_LOCAL 和 SOCK_DGRAM。另外,AF_UNIX 和 AF_LOCAL 是等价的,所以 AF_UNIX 也属于本地 socket; 接下来,简单说一下这三种通信的编程模式。 > 针对 TCP 协议通信的 socket 编程模型 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%BF%9B%E7%A8%8B%E9%97%B4%E9%80%9A%E4%BF%A1/12-TCP%E7%BC%96%E7%A8%8B%E6%A8%A1%E5%9E%8B.jpg) - 服务端和客户端初始化 `socket`,得到文件描述符; - 服务端调用 `bind`,将绑定在 IP 地址和端口; - 服务端调用 `listen`,进行监听; - 服务端调用 `accept`,等待客户端连接; - 客户端调用 `connect`,向服务器端的地址和端口发起连接请求; - 服务端 `accept` 返回用于传输的 `socket` 的文件描述符; - 客户端调用 `write` 写入数据;服务端调用 `read` 读取数据; - 客户端断开连接时,会调用 `close`,那么服务端 `read` 读取数据的时候,就会读取到了 `EOF`,待处理完数据后,服务端调用 `close`,表示连接关闭。 这里需要注意的是,服务端调用 `accept` 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。 所以,监听的 socket 和真正用来传送数据的 socket,是「**两个**」socket,一个叫作**监听 socket**,一个叫作**已完成连接 socket**。 成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。 > 针对 UDP 协议通信的 socket 编程模型 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%BF%9B%E7%A8%8B%E9%97%B4%E9%80%9A%E4%BF%A1/13-UDP%E7%BC%96%E7%A8%8B%E6%A8%A1%E5%9E%8B.jpg) UDP 是没有连接的,所以不需要三次握手,也就不需要像 TCP 调用 listen 和 connect,但是 UDP 的交互仍然需要 IP 地址和端口号,因此也需要 bind。 对于 UDP 来说,不需要要维护连接,那么也就没有所谓的发送方和接收方,甚至都不存在客户端和服务端的概念,只要有一个 socket 多台机器就可以任意通信,因此每一个 UDP 的 socket 都需要 bind。 另外,每次通信时,调用 sendto 和 recvfrom,都要传入目标主机的 IP 地址和端口。 > 针对本地进程间通信的 socket 编程模型 本地 socket 被用于在**同一台主机上进程间通信**的场景: - 本地 socket 的编程接口和 IPv4、IPv6 套接字编程接口是一致的,可以支持「字节流」和「数据报」两种协议; - 本地 socket 的实现效率大大高于 IPv4 和 IPv6 的字节流、数据报 socket 实现; 对于本地字节流 socket,其 socket 类型是 AF_LOCAL 和 SOCK_STREAM。 对于本地数据报 socket,其 socket 类型是 AF_LOCAL 和 SOCK_DGRAM。 本地字节流 socket 和 本地数据报 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是**绑定一个本地文件**,这也就是它们之间的最大区别。 --- ## 总结 由于每个进程的用户空间都是独立的,不能相互访问,这时就需要借助内核空间来实现进程间通信,原因很简单,每个进程都是共享一个内核空间。 Linux 内核提供了不少进程间通信的方式,其中最简单的方式就是管道,管道分为「匿名管道」和「命名管道」。 **匿名管道**顾名思义,它没有名字标识,匿名管道是特殊文件只存在于内存,没有存在于文件系统中,shell 命令中的「`|`」竖线就是匿名管道,通信的数据是**无格式的流并且大小受限**,通信的方式是**单向**的,数据只能在一个方向上流动,如果要双向通信,需要创建两个管道,再来**匿名管道是只能用于存在父子关系的进程间通信**,匿名管道的生命周期随着进程创建而建立,随着进程终止而消失。 **命名管道**突破了匿名管道只能在亲缘关系进程间的通信限制,因为使用命名管道的前提,需要在文件系统创建一个类型为 p 的设备文件,那么毫无关系的进程就可以通过这个设备文件进行通信。另外,不管是匿名管道还是命名管道,进程写入的数据都是**缓存在内核**中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循**先进先出**原则,不支持 lseek 之类的文件定位操作。 **消息队列**克服了管道通信的数据是无格式的字节流的问题,消息队列实际上是保存在内核的「消息链表」,消息队列的消息体是可以用户自定义的数据类型,发送数据时,会被分成一个一个独立的消息体,当然接收数据时,也要与发送方发送的消息体的数据类型保持一致,这样才能保证读取的数据是正确的。消息队列通信的速度不是最及时的,毕竟**每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程。** **共享内存**可以解决消息队列通信中用户态与内核态之间数据拷贝过程带来的开销,**它直接分配一个共享空间,每个进程都可以直接访问**,就像访问进程自己的空间一样快捷方便,不需要陷入内核态或者系统调用,大大提高了通信的速度,享有**最快**的进程间通信方式之名。但是便捷高效的共享内存通信,**带来新的问题,多进程竞争同个共享资源会造成数据的错乱。** 那么,就需要**信号量**来保护共享资源,以确保任何时刻只能有一个进程访问共享资源,这种方式就是互斥访问。**信号量不仅可以实现访问的互斥性,还可以实现进程间的同步**,信号量其实是一个计数器,表示的是资源个数,其值可以通过两个原子操作来控制,分别是 **P 操作和 V 操作**。 与信号量名字很相似的叫**信号**,它俩名字虽然相似,但功能一点儿都不一样。信号是**异步通信机制**,信号可以在应用进程和内核之间直接交互,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件,信号事件的来源主要有硬件来源(如键盘 Cltr+C)和软件来源(如 kill 命令),一旦有信号发生,**进程有三种方式响应信号 1. 执行默认操作、2. 捕捉信号、3. 忽略信号**。有两个信号是应用进程无法捕捉和忽略的,即 `SIGKILL` 和 `SIGSTOP`,这是为了方便我们能在任何时候结束或停止某个进程。 前面说到的通信机制,都是工作于同一台主机,如果**要与不同主机的进程间通信,那么就需要 Socket 通信了**。Socket 实际上不仅用于不同的主机进程间通信,还可以用于本地主机进程间通信,可根据创建 Socket 的类型不同,分为三种常见的通信方式,一个是基于 TCP 协议的通信方式,一个是基于 UDP 协议的通信方式,一个是本地进程间通信方式。 以上,就是进程间通信的主要机制了。你可能会问了,那线程通信间的方式呢? 同个进程下的线程之间都是共享进程的资源,只要是共享变量都可以做到线程间通信,比如全局变量,所以对于线程间关注的不是通信方式,而是关注多线程竞争共享资源的问题,信号量也同样可以在线程间实现互斥与同步: - 互斥的方式,可保证任意时刻只有一个线程访问共享资源; - 同步的方式,可保证线程 A 应在线程 B 之前执行; ## 关注作者 ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF*** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/其他/公众号介绍.png) ---- ================================================ FILE: os/4_process/thread_crash.md ================================================ # 5.7 线程崩溃了,进程也会崩溃吗? > 来源:公众号@码海 > > 原文地址:[美团一面:线程崩溃了,进程也会崩溃吗?](https://mp.weixin.qq.com/s/easnVQ75Rq-C07W4YWeclQ) 大家好,我是小林。 之前分享这篇文章的时候:[进程和线程基础知识全家桶,30 张图一套带走](https://xiaolincoding.com/os/4_process/process_base.html),提到说线程的一个缺点: ![](https://img-blog.csdnimg.cn/899ce21f16244826a7e2fb899484b348.png) 很多同学就好奇,**为什么 C/C++ 语言里,线程崩溃后,进程也会崩溃,而 Java 语言里却不会呢?** 刚好看到朋友([公众号:码海](https://mp.weixin.qq.com/s/JnlTdUk8Jvao8L6FAtKqhQ))写了一篇:「**美团面试题:为什么线程崩溃崩溃不会导致 JVM 崩溃?**」 我觉得写的很好,所以分享给大家一起拜读拜读,本文分以下几节来探讨: 1. 线程崩溃,进程一定会崩溃吗 2. 进程是如何崩溃的 - 信号机制简介 3. 为什么在 JVM 中线程崩溃不会导致 JVM 进程崩溃 4. openJDK 源码解析 ## 线程崩溃,进程一定会崩溃吗 一般来说如果线程是因为非法访问内存引起的崩溃,那么进程肯定会崩溃,为什么系统要让进程崩溃呢,这主要是因为在进程中,**各个线程的地址空间是共享的**,既然是共享,那么某个线程对地址的非法访问就会导致内存的不确定性,进而可能会影响到其他线程,这种操作是危险的,操作系统会认为这很可能导致一系列严重的后果,于是干脆让整个进程崩溃 ![](https://img-blog.csdnimg.cn/17be94f342ea4e49a227b195845880fd.png) 线程共享代码段,数据段,地址空间,文件非法访问内存有以下几种情况,我们以 C 语言举例来看看。 1.、针对只读内存写入数据 ```c #include #include int main() { char *s = "hello world"; // 向只读内存写入数据,崩溃 s[1] = 'H'; } ``` 2、访问了进程没有权限访问的地址空间(比如内核空间) ```c #include #include int main() { int *p = (int *)0xC0000fff; // 针对进程的内核空间写入数据,崩溃 *p = 10; } ``` 在 32 位虚拟地址空间中,p 指向的是内核空间,显然不具有写入权限,所以上述赋值操作会导致崩溃 3、访问了不存在的内存,比如: ```c #include #include int main() { int *a = NULL; *a = 1; } ``` 以上错误都是访问内存时的错误,所以统一会报 Segment Fault 错误(即段错误),这些都会导致进程崩溃 ## 进程是如何崩溃的 - 信号机制简介 那么线程崩溃后,进程是如何崩溃的呢,这背后的机制到底是怎样的,答案是**信号**。 大家想想要干掉一个正在运行的进程是不是经常用 kill -9 pid 这样的命令,这里的 kill 其实就是给指定 pid 发送终止信号的意思,其中的 9 就是信号。 其实信号有很多类型的,在 Linux 中可以通过 `kill -l`查看所有可用的信号: ![](https://img-blog.csdnimg.cn/eba4dce5e59442b8b2b24d9e171bab0d.png) 当然了发 kill 信号必须具有一定的权限,否则任意进程都可以通过发信号来终止其他进程,那显然是不合理的,实际上 kill 执行的是系统调用,将控制权转移给了内核(操作系统),由内核来给指定的进程发送信号 那么发个信号进程怎么就崩溃了呢,这背后的原理到底是怎样的? 其背后的机制如下 1. CPU 执行正常的进程指令 2. 调用 kill 系统调用向进程发送信号 3. 进程收到操作系统发的信号,CPU 暂停当前程序运行,并将控制权转交给操作系统 4. 调用 kill 系统调用向进程发送信号(假设为 11,即 SIGSEGV,一般非法访问内存报的都是这个错误) 5. **操作系统根据情况执行相应的信号处理程序(函数),一般执行完信号处理程序逻辑后会让进程退出** 注意上面的第五步,如果进程没有注册自己的信号处理函数,那么操作系统会执行默认的信号处理程序(一般最后会让进程退出),但如果注册了,则会执行自己的信号处理函数,这样的话就给了进程一个垂死挣扎的机会,它收到 kill 信号后,可以调用 exit() 来退出,**但也可以使用 sigsetjmp,siglongjmp 这两个函数来恢复进程的执行** ```c // 自定义信号处理函数示例 #include #include #include // 自定义信号处理函数,处理自定义逻辑后再调用 exit 退出 void sigHandler(int sig) { printf("Signal %d catched!\n", sig); exit(sig); } int main(void) { signal(SIGSEGV, sigHandler); int *p = (int *)0xC0000fff; *p = 10; // 针对不属于进程的内核空间写入数据,崩溃 } // 以上结果输出:Signal 11 catched! ``` **如代码所示**:注册信号处理函数后,当收到 SIGSEGV 信号后,先执行相关的逻辑再退出 另外当进程接收信号之后也可以不定义自己的信号处理函数,而是选择忽略信号,如下 ```c #include #include #include int main(void) { // 忽略信号 signal(SIGSEGV, SIG_IGN); // 产生一个 SIGSEGV 信号 raise(SIGSEGV); printf("正常结束"); } ``` 也就是说虽然给进程发送了 kill 信号,但如果进程自己定义了信号处理函数或者无视信号就有机会逃出生天,当然了 kill -9 命令例外,不管进程是否定义了信号处理函数,都会马上被干掉。 说到这大家是否想起了一道经典面试题:**如何让正在运行的 Java 工程的优雅停机?** 通过上面的介绍大家不难发现,其实是 JVM 自己定义了信号处理函数,这样当发送 kill pid 命令(默认会传 15 也就是 SIGTERM)后,JVM 就可以在信号处理函数中执行一些资源清理之后再调用 exit 退出。 这种场景显然不能用 kill -9,不然一下把进程干掉了资源就来不及清除了。 ## 为什么线程崩溃不会导致 JVM 进程崩溃 现在我们再来看看开头这个问题,相信你多少会心中有数,想想看在 Java 中有哪些是常见的由于非法访问内存而产生的 Exception 或 error 呢,常见的是大家熟悉的 StackoverflowError 或者 NPE(NullPointerException),NPE 我们都了解,属于是访问了不存在的内存。 但为什么栈溢出(Stackoverflow)也属于非法访问内存呢,这得简单聊一下进程的虚拟空间,也就是前面提到的共享地址空间。 现代操作系统为了保护进程之间不受影响,所以使用了虚拟地址空间来隔离进程,进程的寻址都是针对虚拟地址,每个进程的虚拟空间都是一样的,而线程会共用进程的地址空间。 以 32 位虚拟空间,进程的虚拟空间分布如下: ![](https://img-blog.csdnimg.cn/8de250fcb055400c94f95c99712a1158.png) 那么 stackoverflow 是怎么发生的呢? 进程每调用一个函数,都会分配一个栈帧,然后在栈帧里会分配函数里定义的各种局部变量。 假设现在调用了一个无限递归的函数,那就会持续分配栈帧,但 stack 的大小是有限的(Linux 中默认为 8 M,可以通过 ulimit -a 查看),如果无限递归很快栈就会分配完了,此时再调用函数试图分配超出栈的大小内存,就会发生段错误,也就是 stackoverflowError。 ![](https://img-blog.csdnimg.cn/c54aff1660e34d8a8a83d534c3390954.png) 好了,现在我们知道了 StackoverflowError 怎么产生的。 那问题来了,既然 StackoverflowError 或者 NPE 都属于非法访问内存,JVM 为什么不会崩溃呢? 有了上一节的铺垫,相信你不难回答,其实就是**因为 JVM 自定义了自己的信号处理函数,拦截了 SIGSEGV 信号,针对这两者不让它们崩溃**。 怎么证明这个推测呢,我们来看下 JVM 的源码来一探究竟 ## openJDK 源码解析 HotSpot 虚拟机目前使用范围最广的 Java 虚拟机,据 R 大所述,Oracle JDK 与 OpenJDK 里的 JVM 都是 HotSpot VM,从源码层面说,两者基本上是同一个东西。 OpenJDK 是开源的,所以我们主要研究下 Java 8 的 OpenJDK 即可,地址如下:[https://github.com/AdoptOpenJDK/openjdk-jdk8u](https://github.com/AdoptOpenJDK/openjdk-jdk8u),有兴趣的可以下载来看看。 我们只要研究 Linux 下的 JVM,为了便于说明,也方便大家查阅,我把其中关于信号处理的关键流程整理了下(忽略其中的次要代码)。 ![](https://img-blog.csdnimg.cn/474ddf8657a0438da1822e0f6fa59af7.png) 可以看到,在启动 JVM 的时候,也设置了信号处理函数,收到 SIGSEGV,SIGPIPE 等信号后最终会调用 JVM_handle_linux_signal 这个自定义信号处理函数,再来看下这个函数的主要逻辑。 ```java JVM_handle_linux_signal(int sig, siginfo_t* info, void* ucVoid, int abort_if_unrecognized) { // Must do this before SignalHandlerMark, if crash protection installed we will longjmp away // 这段代码里会调用 siglongjmp,主要做线程恢复之用 os::ThreadCrashProtection::check_crash_protection(sig, t); if (info != NULL && uc != NULL && thread != NULL) { pc = (address) os::Linux::ucontext_get_pc(uc); // Handle ALL stack overflow variations here if (sig == SIGSEGV) { // Si_addr may not be valid due to a bug in the linux-ppc64 kernel (see // comment below). Use get_stack_bang_address instead of si_addr. address addr = ((NativeInstruction*)pc)->get_stack_bang_address(uc); // 判断是否栈溢出了 if (addr < thread->stack_base() && addr >= thread->stack_base() - thread->stack_size()) { if (thread->thread_state() == _thread_in_Java) { // 针对栈溢出 JVM 的内部处理 stub = SharedRuntime::continuation_for_implicit_exception(thread, pc, SharedRuntime::STACK_OVERFLOW); } } } } if (sig == SIGSEGV && !MacroAssembler::needs_explicit_null_check((intptr_t)info->si_addr)) { // 此处会做空指针检查 stub = SharedRuntime::continuation_for_implicit_exception(thread, pc, SharedRuntime::IMPLICIT_NULL); } // 如果是栈溢出或者空指针最终会返回 true,不会走最后的 report_and_die,所以 JVM 不会退出 if (stub != NULL) { // save all thread context in case we need to restore it if (thread != NULL) thread->set_saved_exception_pc(pc); uc->uc_mcontext.gregs[REG_PC] = (greg_t)stub; // 返回 true 代表 JVM 进程不会退出 return true; } VMError err(t, sig, pc, info, ucVoid); // 生成 hs_err_pid_xxx.log 文件并退出 err.report_and_die(); ShouldNotReachHere(); return true; // Mute compiler } ``` 从以上代码我们可以知道以下信息: 1. 发生 stackoverflow 还有空指针错误,确实都发送了 SIGSEGV,只是虚拟机不选择退出,而是自己内部作了额外的处理,其实是恢复了线程的执行,并抛出 StackoverflowError 和 NPE,这就是为什么 JVM 不会崩溃且我们能捕获这两个错误/异常的原因 2. 如果针对 SIGSEGV 等信号,在以上的函数中 JVM 没有做额外的处理,那么最终会走到 report_and_die 这个方法,这个方法主要做的事情是生成 hs_err_pid_xxx.log crash 文件(记录了一些堆栈信息或错误),然后退出 至此我相信大家明白了为什么发生了 StackoverflowError 和 NPE 这两个非法访问内存的错误,JVM 却没有崩溃。 **原因其实就是虚拟机内部定义了信号处理函数,而在信号处理函数中对这两者做了额外的处理以让 JVM 不崩溃,另一方面也可以看出如果 JVM 不对信号做额外的处理,最后会自己退出并产生 crash 文件 hs_err_pid_xxx.log(可以通过 -XX:ErrorFile=/var/*log*/hs_err.log 这样的方式指定),这个文件记录了虚拟机崩溃的重要原因**。 所以也可以说,虚拟机是否崩溃只要看它是否会产生此崩溃日志文件 ## 总结 正常情况下,操作系统为了保证系统安全,所以针对非法内存访问会发送一个 SIGSEGV 信号,而操作系统一般会调用默认的信号处理函数(一般会让相关的进程崩溃)。 但如果进程觉得"罪不致死",那么它也可以选择自定义一个信号处理函数,这样的话它就可以做一些自定义的逻辑,比如记录 crash 信息等有意义的事。 回过头来看为什么虚拟机会针对 StackoverflowError 和 NullPointerException 做额外处理让线程恢复呢,针对 stackoverflow 其实它采用了一种栈回溯的方法保证线程可以一直执行下去,而捕获空指针错误主要是这个错误实在太普遍了。 为了这一个很常见的错误而让 JVM 崩溃那线上的 JVM 要宕机多少次,所以出于工程健壮性的考虑,与其直接让 JVM 崩溃倒不如让线程起死回生,并且将这两个错误/异常抛给用户来处理。 --- ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF*** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: os/5_schedule/schedule.md ================================================ # 6.1 进程调度/页面置换/磁盘调度算法 最近,我偷偷潜伏在各大技术群,因为秋招在即,看到不少小伙伴分享的大厂面经。 然后发现,操作系统的知识点考察还是比较多的,大厂就是大厂就爱问基础知识。其中,关于操作系统的「调度算法」考察也算比较频繁。 所以,我这边总结了操作系统的三大调度机制,分别是「**进程调度/页面置换/磁盘调度算法**」,供大家复习,希望大家在秋招能斩获自己心意的 offer。 ![本文提纲](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/操作系统/调度算法/调度算法提纲.png) --- ## 进程调度算法 进程调度算法也称 CPU 调度算法,毕竟进程是由 CPU 调度的。 当 CPU 空闲时,操作系统就选择内存中的某个「就绪状态」的进程,并给其分配 CPU。 什么时候会发生 CPU 调度呢?通常有以下情况: 1. 当进程从运行状态转到等待状态; 2. 当进程从运行状态转到就绪状态; 3. 当进程从等待状态转到就绪状态; 4. 当进程从运行状态转到终止状态; 其中发生在 1 和 4 两种情况下的调度称为「非抢占式调度」,2 和 3 两种情况下发生的调度称为「抢占式调度」。 非抢占式的意思就是,当进程正在运行时,它就会一直运行,直到该进程完成或发生某个事件而被阻塞时,才会把 CPU 让给其他进程。 而抢占式调度,顾名思义就是进程正在运行的时,可以被打断,使其把 CPU 让给其他进程。那抢占的原则一般有三种,分别是时间片原则、优先权原则、短作业优先原则。 你可能会好奇为什么第 3 种情况也会发生 CPU 调度呢?假设有一个进程是处于等待状态的,但是它的优先级比较高,如果该进程等待的事件发生了,它就会转到就绪状态,一旦它转到就绪状态,如果我们的调度算法是以优先级来进行调度的,那么它就会立马抢占正在运行的进程,所以这个时候就会发生 CPU 调度。 那第 2 种状态通常是时间片到的情况,因为时间片到了就会发生中断,于是就会抢占正在运行的进程,从而占用 CPU。 调度算法影响的是等待时间(进程在就绪队列中等待调度的时间总和),而不能影响进程真在使用 CPU 的时间和 I/O 时间。 接下来,说说常见的调度算法: - 先来先服务调度算法 - 最短作业优先调度算法 - 高响应比优先调度算法 - 时间片轮转调度算法 - 最高优先级调度算法 - 多级反馈队列调度算法 ### 先来先服务调度算法 最简单的一个调度算法,就是非抢占式的**先来先服务(*First Come First Severd, FCFS*)算法**了。 ![FCFS 调度算法](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/操作系统/进程和线程/24-先来先服务.jpg) 顾名思义,先来后到,**每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。** 这似乎很公平,但是当一个长作业先运行了,那么后面的短作业等待的时间就会很长,不利于短作业。 FCFS 对长作业有利,适用于 CPU 繁忙型作业的系统,而不适用于 I/O 繁忙型作业的系统。 ### 最短作业优先调度算法 **最短作业优先(*Shortest Job First, SJF*)调度算法**同样也是顾名思义,它会**优先选择运行时间最短的进程来运行**,这有助于提高系统的吞吐量。 ![SJF 调度算法](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/操作系统/进程和线程/25-最短作业优先算法.jpg) 这显然对长作业不利,很容易造成一种极端现象。 比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。 ### 高响应比优先调度算法 前面的「先来先服务调度算法」和「最短作业优先调度算法」都没有很好的权衡短作业和长作业。 那么,**高响应比优先 (*Highest Response Ratio Next, HRRN*)调度算法**主要是权衡了短作业和长作业。 **每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行**,「响应比优先级」的计算公式: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/操作系统/进程和线程/26-响应比公式.jpg) 从上面的公式,可以发现: - 如果两个进程的「等待时间」相同时,「要求的服务时间」越短,「响应比」就越高,这样短作业的进程容易被选中运行; - 如果两个进程「要求的服务时间」相同时,「等待时间」越长,「响应比」就越高,这就兼顾到了长作业进程,因为进程的响应比可以随时间等待的增加而提高,当其等待时间足够长时,其响应比便可以升到很高,从而获得运行的机会; ### 时间片轮转调度算法 最古老、最简单、最公平且使用最广的算法就是**时间片轮转(*Round Robin, RR*)调度算法**。 。 ![RR 调度算法](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/操作系统/进程和线程/27-时间片轮询.jpg) **每个进程被分配一个时间段,称为时间片(*Quantum*),即允许该进程在该时间段中运行。** - 如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配另外一个进程; - 如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换; 另外,时间片的长度就是一个很关键的点: - 如果时间片设得太短会导致过多的进程上下文切换,降低了 CPU 效率; - 如果时间片设得太长又可能引起短作业进程的响应时间变长,不利于短作业。 通常时间片设为 `20ms~50ms` 通常是一个比较合理的折中值。 ### 最高优先级调度算法 前面的「时间片轮转算法」做了个假设,即让所有的进程同等重要,也不偏袒谁,大家的运行时间都一样。 但是,对于多用户计算机系统就有不同的看法了,它们希望调度是有优先级的,即希望调度程序能**从就绪队列中选择最高优先级的进程进行运行,这称为最高优先级(*Highest Priority First,HPF*)调度算法**。 进程的优先级可以分为,静态优先级或动态优先级: - 静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化; - 动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是**随着时间的推移增加等待进程的优先级**。 该算法也有两种处理优先级高的方法,非抢占式和抢占式: - 非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。 - 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。 但是依然有缺点,可能会导致低优先级的进程永远不会运行。 ### 多级反馈队列调度算法 **多级反馈队列(*Multilevel Feedback Queue*)调度算法**是「时间片轮转算法」和「最高优先级算法」的综合和发展。 顾名思义: - 「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。 - 「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列; ![多级反馈队列](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/操作系统/进程和线程/28-多级队列.jpg) 来看看,它是如何工作的: - 设置了多个队列,赋予每个队列不同的优先级,每个**队列优先级从高到低**,同时**优先级越高时间片越短**; - 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成; - 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行; 可以发现,对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也会更长了,所以该算法很好的**兼顾了长短作业,同时有较好的响应时间。** --- ## 内存页面置换算法 在了解内存页面置换算法前,我们得先谈一下**缺页异常(缺页中断)**。 当 CPU 访问的页面不在物理内存时,便会产生一个缺页中断,请求操作系统将所缺页调入到物理内存。那它与一般中断的主要区别在于: - 缺页中断在指令执行「期间」产生和处理中断信号,而一般中断在一条指令执行「完成」后检查和处理中断信号。 - 缺页中断返回到该指令的开始重新执行「该指令」,而一般中断返回回到该指令的「下一个指令」执行。 我们来看一下缺页中断的处理流程,如下图: ![缺页中断的处理流程](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/操作系统/调度算法/缺页异常流程.png) 1. 在 CPU 里访问一条 Load M 指令,然后 CPU 会去找 M 所对应的页表项。 2. 如果该页表项的状态位是「有效的」,那 CPU 就可以直接去访问物理内存了,如果状态位是「无效的」,则 CPU 则会发送缺页中断请求。 3. 操作系统收到了缺页中断,则会执行缺页中断处理函数,先会查找该页面在磁盘中的页面的位置。 4. 找到磁盘中对应的页面后,需要把该页面换入到物理内存中,但是在换入前,需要在物理内存中找空闲页,如果找到空闲页,就把页面换入到物理内存中。 5. 页面从磁盘换入到物理内存完成后,则把页表项中的状态位修改为「有效的」。 6. 最后,CPU 重新执行导致缺页异常的指令。 上面所说的过程,第 4 步是能在物理内存找到空闲页的情况,那如果找不到呢? 找不到空闲页的话,就说明此时内存已满了,这时候,就需要「页面置换算法」选择一个物理页,如果该物理页有被修改过(脏页),则把它换出到磁盘,然后把该被置换出去的页表项的状态改成「无效的」,最后把正在访问的页面装入到这个物理页中。 这里提一下,页表项通常有如下图的字段: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/操作系统/调度算法/页表项字段.png) 那其中: - *状态位*:用于表示该页是否有效,也就是说是否在物理内存中,供程序访问时参考。 - *访问字段*:用于记录该页在一段时间被访问的次数,供页面置换算法选择出页面时参考。 - *修改位*:表示该页在调入内存后是否有被修改过,由于内存中的每一页都在磁盘上保留一份副本,因此,如果没有修改,在置换该页时就不需要将该页写回到磁盘上,以减少系统的开销;如果已经被修改,则将该页重写到磁盘上,以保证磁盘中所保留的始终是最新的副本。 - *硬盘地址*:用于指出该页在硬盘上的地址,通常是物理块号,供调入该页时使用。 这里我整理了虚拟内存的管理整个流程,你可以从下面这张图看到: ![虚拟内存的流程](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/操作系统/调度算法/虚拟内存管理流程.png) 所以,页面置换算法的功能是,**当出现缺页异常,需调入新页面而内存已满时,选择被置换的物理页面**,也就是说选择一个物理页面换出到磁盘,然后把需要访问的页面换入到物理页。 那其算法目标则是,尽可能减少页面的换入换出的次数,常见的页面置换算法有如下几种: - 最佳页面置换算法(*OPT*) - 先进先出置换算法(*FIFO*) - 最近最久未使用的置换算法(*LRU*) - 时钟页面置换算法(*Lock*) - 最不常用置换算法(*LFU*) ### 最佳页面置换算法 最佳页面置换算法基本思路是,**置换在「未来」最长时间不访问的页面**。 所以,该算法实现需要计算内存中每个逻辑页面的「下一次」访问时间,然后比较,选择未来最长时间不访问的页面。 我们举个例子,假设一开始有 3 个空闲的物理页,然后有请求的页面序列,那它的置换过程如下图: ![最佳页面置换算法](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/操作系统/调度算法/最优置换算法.png) 在这个请求的页面序列中,缺页共发生了 `7` 次(空闲页换入 3 次 + 最优页面置换 4 次),页面置换共发生了 `4` 次。 这很理想,但是实际系统中无法实现,因为程序访问页面时是动态的,我们是无法预知每个页面在「下一次」访问前的等待时间。 所以,最佳页面置换算法作用是为了衡量你的算法的效率,你的算法效率越接近该算法的效率,那么说明你的算法是高效的。 ### 先进先出置换算法 既然我们无法预知页面在下一次访问前所需的等待时间,那我们可以**选择在内存驻留时间很长的页面进行中置换**,这个就是「先进先出置换」算法的思想。 还是以前面的请求的页面序列作为例子,假设使用先进先出置换算法,则过程如下图: ![先进先出置换算法](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/操作系统/调度算法/FIFO置换算法.png) 在这个请求的页面序列中,缺页共发生了 `10` 次,页面置换共发生了 `7` 次,跟最佳页面置换算法比较起来,性能明显差了很多。 ### 最近最久未使用的置换算法 最近最久未使用(*LRU*)的置换算法的基本思路是,发生缺页时,**选择最长时间没有被访问的页面进行置换**,也就是说,该算法假设已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。 这种算法近似最优置换算法,最优置换算法是通过「未来」的使用情况来推测要淘汰的页面,而 LRU 则是通过「历史」的使用情况来推测要淘汰的页面。 还是以前面的请求的页面序列作为例子,假设使用最近最久未使用的置换算法,则过程如下图: ![最近最久未使用的置换算法](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/操作系统/调度算法/LRU置换算法.png) 在这个请求的页面序列中,缺页共发生了 `9` 次,页面置换共发生了 `6` 次,跟先进先出置换算法比较起来,性能提高了一些。 虽然 LRU 在理论上是可以实现的,但代价很高。为了完全实现 LRU,需要在内存中维护一个所有页面的链表,最近最多使用的页面在表头,最近最少使用的页面在表尾。 困难的是,在每次访问内存时都必须要更新「整个链表」。在链表中找到一个页面,删除它,然后把它移动到表头是一个非常费时的操作。 所以,LRU 虽然看上去不错,但是由于开销比较大,实际应用中比较少使用。 ### 时钟页面置换算法 那有没有一种即能优化置换的次数,也能方便实现的算法呢? 时钟页面置换算法就可以两者兼得,它跟 LRU 近似,又是对 FIFO 的一种改进。 该算法的思路是,把所有的页面都保存在一个类似钟面的「环形链表」中,一个表针指向最老的页面。 当发生缺页中断时,算法首先检查表针指向的页面: - 如果它的访问位位是 0 就淘汰该页面,并把新的页面插入这个位置,然后把表针前移一个位置; - 如果访问位是 1 就清除访问位,并把表针前移一个位置,重复这个过程直到找到了一个访问位为 0 的页面为止; 我画了一副时钟页面置换算法的工作流程图,你可以在下方看到: ![时钟页面置换算法](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/操作系统/调度算法/时钟置换算法.png) 了解了这个算法的工作方式,就明白为什么它被称为时钟(*Clock*)算法了。 ### 最不常用算法 最不常用(*LFU*)算法,这名字听起来很调皮,但是它的意思不是指这个算法不常用,而是**当发生缺页中断时,选择「访问次数」最少的那个页面,并将其淘汰**。 它的实现方式是,对每个页面设置一个「访问计数器」,每当一个页面被访问时,该页面的访问计数器就累加 1。在发生缺页中断时,淘汰计数器值最小的那个页面。 看起来很简单,每个页面加一个计数器就可以实现了,但是在操作系统中实现的时候,我们需要考虑效率和硬件成本的。 要增加一个计数器来实现,这个硬件成本是比较高的,另外如果要对这个计数器查找哪个页面访问次数最小,查找链表本身,如果链表长度很大,是非常耗时的,效率不高。 但还有个问题,LFU 算法只考虑了频率问题,没考虑时间的问题,比如有些页面在过去时间里访问的频率很高,但是现在已经没有访问了,而当前频繁访问的页面由于没有这些页面访问的次数高,在发生缺页中断时,就会可能会误伤当前刚开始频繁访问,但访问次数还不高的页面。 那这个问题的解决的办法还是有的,可以定期减少访问的次数,比如当发生时间中断时,把过去时间访问的页面的访问次数除以 2,也就说,随着时间的流失,以前的高访问次数的页面会慢慢减少,相当于加大了被置换的概率。 --- ## 磁盘调度算法 我们来看看磁盘的结构,如下图: ![磁盘的结构](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/操作系统/调度算法/磁盘结构.jpg) 常见的机械磁盘是上图左边的样子,中间圆的部分是磁盘的盘片,一般会有多个盘片,每个盘面都有自己的磁头。右边的图就是一个盘片的结构,盘片中的每一层分为多个磁道,每个磁道分多个扇区,每个扇区是 `512` 字节。那么,多个具有相同编号的磁道形成一个圆柱,称之为磁盘的柱面,如上图里中间的样子。 磁盘调度算法的目的很简单,就是为了提高磁盘的访问性能,一般是通过优化磁盘的访问请求顺序来做到的。 寻道的时间是磁盘访问最耗时的部分,如果请求顺序优化的得当,必然可以节省一些不必要的寻道时间,从而提高磁盘的访问性能。 假设有下面一个请求序列,每个数字代表磁道的位置: 98,183,37,122,14,124,65,67 初始磁头当前的位置是在第 `53` 磁道。 接下来,分别对以上的序列,作为每个调度算法的例子,那常见的磁盘调度算法有: - 先来先服务算法 - 最短寻道时间优先算法 - 扫描算法算法 - 循环扫描算法 - LOOK 与 C-LOOK 算法 ### 先来先服务 先来先服务(*First-Come,First-Served,FCFS*),顾名思义,先到来的请求,先被服务。 那按照这个序列的话: 98,183,37,122,14,124,65,67 那么,磁盘的写入顺序是从左到右,如下图: ![先来先服务](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%B0%83%E5%BA%A6%E7%AE%97%E6%B3%95/%E7%A3%81%E7%9B%98%E8%B0%83%E5%BA%A6-%E5%85%88%E6%9D%A5%E5%85%88%E6%9C%8D%E5%8A%A1.png) 先来先服务算法总共移动了 `640` 个磁道的距离,这么一看这种算法,比较简单粗暴,但是如果大量进程竞争使用磁盘,请求访问的磁道可能会很分散,那先来先服务算法在性能上就会显得很差,因为寻道时间过长。 ### 最短寻道时间优先 最短寻道时间优先(*Shortest Seek First,SSF*)算法的工作方式是,优先选择从当前磁头位置所需寻道时间最短的请求,还是以这个序列为例子: 98,183,37,122,14,124,65,67 那么,那么根据距离磁头(53 位置)最近的请求的算法,具体的请求则会是下列从左到右的顺序: 65,67,37,14,98,122,124,183 ![最短寻道时间优先](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%B0%83%E5%BA%A6%E7%AE%97%E6%B3%95/%E7%A3%81%E7%9B%98%E8%B0%83%E5%BA%A6-%E6%9C%80%E7%9F%AD%E5%AF%BB%E9%81%93%E6%97%B6%E9%97%B4%E4%BC%98%E5%85%88.png) 磁头移动的总距离是 `236` 磁道,相比先来先服务性能提高了不少。 但这个算法可能存在某些请求的**饥饿**,因为本次例子我们是静态的序列,看不出问题,假设是一个动态的请求,如果后续来的请求都是小于 183 磁道的,那么 183 磁道可能永远不会被响应,于是就产生了饥饿现象,这里**产生饥饿的原因是磁头在一小块区域来回移动**。 ## 扫描算法 最短寻道时间优先算法会产生饥饿的原因在于:磁头有可能再一个小区域内来回得移动。 为了防止这个问题,可以规定:**磁头在一个方向上移动,访问所有未完成的请求,直到磁头到达该方向上的最后的磁道,才调换方向,这就是扫描(*Scan*)算法**。 这种算法也叫做电梯算法,比如电梯保持按一个方向移动,直到在那个方向上没有请求为止,然后改变方向。 还是以这个序列为例子,磁头的初始位置是 53: 98,183,37,122,14,124,65,67 那么,假设扫描调度算先朝磁道号减少的方向移动,具体请求则会是下列从左到右的顺序: 37,14,`0`,65,67,98,122,124,183 ![扫描算法](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%B0%83%E5%BA%A6%E7%AE%97%E6%B3%95/%E7%A3%81%E7%9B%98%E8%B0%83%E5%BA%A6-%E6%89%AB%E6%8F%8F%E7%AE%97%E6%B3%95.png) 磁头先响应左边的请求,直到到达最左端(0 磁道)后,才开始反向移动,响应右边的请求。 扫描调度算法性能较好,不会产生饥饿现象,但是存在这样的问题,中间部分的磁道会比较占便宜,中间部分相比其他部分响应的频率会比较多,也就是说每个磁道的响应频率存在差异。 ### 循环扫描算法 扫描算法使得每个磁道响应的频率存在差异,那么要优化这个问题的话,可以总是按相同的方向进行扫描,使得每个磁道的响应频率基本一致。 循环扫描(*Circular Scan, CSCAN* )规定:只有磁头朝某个特定方向移动时,才处理磁道访问请求,而返回时直接快速移动至最靠边缘的磁道,也就是复位磁头,这个过程是很快的,并且**返回中途不处理任何请求**,该算法的特点,就是**磁道只响应一个方向上的请求**。 还是以这个序列为例子,磁头的初始位置是 53: 98,183,37,122,14,124,65,67 那么,假设循环扫描调度算先朝磁道增加的方向移动,具体请求会是下列从左到右的顺序: 65,67,98,122,124,183,`199`,`0`,14,37 ![循环扫描算法](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%B0%83%E5%BA%A6%E7%AE%97%E6%B3%95/%E7%A3%81%E7%9B%98%E8%B0%83%E5%BA%A6-C-SCAN%E7%AE%97%E6%B3%95.png) 磁头先响应了右边的请求,直到碰到了最右端的磁道 199,就立即回到磁盘的开始处(磁道 0),但这个返回的途中是不响应任何请求的,直到到达最开始的磁道后,才继续顺序响应右边的请求。 循环扫描算法相比于扫描算法,对于各个位置磁道响应频率相对比较平均。 ### LOOK 与 C-LOOK 算法 我们前面说到的扫描算法和循环扫描算法,都是磁头移动到磁盘「最始端或最末端」才开始调换方向。 那这其实是可以优化的,优化的思路就是**磁头在移动到「最远的请求」位置,然后立即反向移动。** 那针对 SCAN 算法的优化则叫 LOOK 算法,它的工作方式,磁头在每个方向上仅仅移动到最远的请求位置,然后立即反向移动,而不需要移动到磁盘的最始端或最末端,**反向移动的途中会响应请求**。 ![LOOK 算法](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/操作系统/调度算法/磁盘调度-LOOK算法.png) 而针对 C-SCAN 算法的优化则叫 C-LOOK,它的工作方式,磁头在每个方向上仅仅移动到最远的请求位置,然后立即反向移动,而不需要移动到磁盘的最始端或最末端,**反向移动的途中不会响应请求**。 ![C-LOOK 算法](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/操作系统/调度算法/磁盘调度-C-LOOK算法.png) --- ## 关注作者 ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF*** ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/其他/公众号介绍.png) ================================================ FILE: os/6_file_system/file_system.md ================================================ # 7.1 文件系统全家桶 不多 BB,直接上「硬菜」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/文件系统/文件系统-提纲.png) --- ## 文件系统的基本组成 文件系统是操作系统中负责管理持久数据的子系统,说简单点,就是负责把用户的文件存到磁盘硬件中,因为即使计算机断电了,磁盘里的数据并不会丢失,所以可以持久化的保存文件。 文件系统的基本数据单位是文件,它的目的是对磁盘上的文件进行组织管理,那组织的方式不同,就会形成不同的文件系统。 Linux 最经典的一句话是:「**一切皆文件**」,不仅普通的文件和目录,就连块设备、管道、socket 等,也都是统一交给文件系统管理的。 Linux 文件系统会为每个文件分配两个数据结构:**索引节点(*index node*)和目录项(*directory entry*)**,它们主要用来记录文件的元信息和目录层次结构。 - 索引节点,也就是 *inode*,用来记录文件的元信息,比如 inode 编号、文件大小、访问权限、创建时间、修改时间、**数据在磁盘的位置**等等。索引节点是文件的**唯一**标识,它们之间一一对应,也同样都会被存储在硬盘中,所以**索引节点同样占用磁盘空间**。 - 目录项,也就是 *dentry*,用来记录文件的名字、**索引节点指针**以及与其他目录项的层级关联关系。多个目录项关联起来,就会形成目录结构,但它与索引节点不同的是,**目录项是由内核维护的一个数据结构,不存放于磁盘,而是缓存在内存**。 由于索引节点唯一标识一个文件,而目录项记录着文件的名字,所以目录项和索引节点的关系是多对一,也就是说,一个文件可以有多个别名。比如,硬链接的实现就是多个目录项中的索引节点指向同一个文件。 注意,目录也是文件,也是用索引节点唯一标识,和普通文件不同的是,普通文件在磁盘里面保存的是文件数据,而目录文件在磁盘里面保存子目录或文件。 > 目录项和目录是一个东西吗? 虽然名字很相近,但是它们不是一个东西,目录是个文件,持久化存储在磁盘,而目录项是内核一个数据结构,缓存在内存。 如果查询目录频繁从磁盘读,效率会很低,所以内核会把已经读过的目录用目录项这个数据结构缓存在内存,下次再次读到相同的目录时,只需从内存读就可以,大大提高了文件系统的效率。 注意,目录项这个数据结构不只是表示目录,也是可以表示文件的。 > 那文件数据是如何存储在磁盘的呢? 磁盘读写的最小单位是**扇区**,扇区的大小只有 `512B` 大小,很明显,如果每次读写都以这么小为单位,那这读写的效率会非常低。 所以,文件系统把多个扇区组成了一个**逻辑块**,每次读写的最小单位就是逻辑块(数据块),Linux 中的逻辑块大小为 `4KB`,也就是一次性读写 8 个扇区,这将大大提高了磁盘的读写的效率。 以上就是索引节点、目录项以及文件数据的关系,下面这个图就很好的展示了它们之间的关系: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/文件系统/目录项和索引关系图.png) 索引节点是存储在硬盘上的数据,那么为了加速文件的访问,通常会把索引节点加载到内存中。 另外,磁盘进行格式化的时候,会被分成三个存储区域,分别是超级块、索引节点区和数据块区。 - *超级块*,用来存储文件系统的详细信息,比如块个数、块大小、空闲块等等。 - *索引节点区*,用来存储索引节点; - *数据块区*,用来存储文件或目录数据; 我们不可能把超级块和索引节点区全部加载到内存,这样内存肯定撑不住,所以只有当需要使用的时候,才将其加载进内存,它们加载进内存的时机是不同的: - 超级块:当文件系统挂载时进入内存; - 索引节点区:当文件被访问时进入内存; --- ## 虚拟文件系统 文件系统的种类众多,而操作系统希望**对用户提供一个统一的接口**,于是在用户层与文件系统层引入了中间层,这个中间层就称为**虚拟文件系统(*Virtual File System,VFS*)。** VFS 定义了一组所有文件系统都支持的数据结构和标准接口,这样程序员不需要了解文件系统的工作原理,只需要了解 VFS 提供的统一接口即可。 在 Linux 文件系统中,用户空间、系统调用、虚拟文件系统、缓存、文件系统以及存储之间的关系如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E8%99%9A%E6%8B%9F%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F.png) Linux 支持的文件系统也不少,根据存储位置的不同,可以把文件系统分为三类: - *磁盘的文件系统*,它是直接把数据存储在磁盘中,比如 Ext 2/3/4、XFS 等都是这类文件系统。 - *内存的文件系统*,这类文件系统的数据不是存储在硬盘的,而是占用内存空间,我们经常用到的 `/proc` 和 `/sys` 文件系统都属于这一类,读写这类文件,实际上是读写内核中相关的数据。 - *网络的文件系统*,用来访问其他计算机主机数据的文件系统,比如 NFS、SMB 等等。 文件系统首先要先挂载到某个目录才可以正常使用,比如 Linux 系统在启动时,会把文件系统挂载到根目录。 --- ## 文件的使用 我们从用户角度来看文件的话,就是我们要怎么使用文件?首先,我们得通过系统调用来打开一个文件。 ![write 的过程](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E5%86%99%E5%88%B0%E7%A3%81%E7%9B%98%E8%BF%87%E7%A8%8B.png) ```c fd = open(name, flag); # 打开文件 ... write(fd,...); # 写数据 ... close(fd); # 关闭文件 ``` 上面简单的代码是读取一个文件的过程: - 首先用 `open` 系统调用打开文件,`open` 的参数中包含文件的路径名和文件名。 - 使用 `write` 写数据,其中 `write` 使用 `open` 所返回的**文件描述符**,并不使用文件名作为参数。 - 使用完文件后,要用 `close` 系统调用关闭文件,避免资源的泄露。 我们打开了一个文件后,操作系统会跟踪进程打开的所有文件,所谓的跟踪呢,就是操作系统为每个进程维护一个打开文件表,文件表里的每一项代表「**文件描述符**」,所以说文件描述符是打开文件的标识。 ![打开文件表](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E6%89%93%E5%BC%80%E8%A1%A8.png) 操作系统在打开文件表中维护着打开文件的状态和信息: - 文件指针:系统跟踪上次读写位置作为当前文件位置指针,这种指针对打开文件的某个进程来说是唯一的; - 文件打开计数器:文件关闭时,操作系统必须重用其打开文件表条目,否则表内空间不够用。因为多个进程可能打开同一个文件,所以系统在删除打开文件条目之前,必须等待最后一个进程关闭文件,该计数器跟踪打开和关闭的数量,当该计数为 0 时,系统关闭文件,删除该条目; - 文件磁盘位置:绝大多数文件操作都要求系统修改文件数据,该信息保存在内存中,以免每个操作都从磁盘中读取; - 访问权限:每个进程打开文件都需要有一个访问模式(创建、只读、读写、添加等),该信息保存在进程的打开文件表中,以便操作系统能允许或拒绝之后的 I/O 请求; 在用户视角里,文件就是一个持久化的数据结构,但操作系统并不会关心你想存在磁盘上的任何的数据结构,操作系统的视角是如何把文件数据和磁盘块对应起来。 所以,用户和操作系统对文件的读写操作是有差异的,用户习惯以字节的方式读写文件,而操作系统则是以数据块来读写文件,那屏蔽掉这种差异的工作就是文件系统了。 我们来分别看一下,读文件和写文件的过程: - 当用户进程从文件读取 1 个字节大小的数据时,文件系统则需要获取字节所在的数据块,再返回数据块对应的用户进程所需的数据部分。 - 当用户进程把 1 个字节大小的数据写进文件时,文件系统则找到需要写入数据的数据块的位置,然后修改数据块中对应的部分,最后再把数据块写回磁盘。 所以说,**文件系统的基本操作单位是数据块**。 --- ## 文件的存储 文件的数据是要存储在硬盘上面的,数据在磁盘上的存放方式,就像程序在内存中存放的方式那样,有以下两种: - 连续空间存放方式 - 非连续空间存放方式 其中,非连续空间存放方式又可以分为「链表方式」和「索引方式」。 不同的存储方式,有各自的特点,重点是要分析它们的存储效率和读写性能,接下来分别对每种存储方式说一下。 ### 连续空间存放方式 连续空间存放方式顾名思义,**文件存放在磁盘「连续的」物理空间中**。这种模式下,文件的数据都是紧密相连,**读写效率很高**,因为一次磁盘寻道就可以读出整个文件。 使用连续存放的方式有一个前提,必须先知道一个文件的大小,这样文件系统才会根据文件的大小在磁盘上找到一块连续的空间分配给文件。 所以,**文件头里需要指定「起始块的位置」和「长度」**,有了这两个信息就可以很好的表示文件存放方式是一块连续的磁盘空间。 注意,此处说的文件头,就类似于 Linux 的 inode。 ![连续空间存放方式](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E8%BF%9E%E7%BB%AD%E7%A9%BA%E9%97%B4%E5%AD%98%E6%94%BE%E6%96%B9%E5%BC%8F.png) 连续空间存放的方式虽然读写效率高,**但是有「磁盘空间碎片」和「文件长度不易扩展」的缺陷。** 如下图,如果文件 B 被删除,磁盘上就留下一块空缺,这时,如果新来的文件小于其中的一个空缺,我们就可以将其放在相应空缺里。但如果该文件的大小大于所有的空缺,但却小于空缺大小之和,则虽然磁盘上有足够的空缺,但该文件还是不能存放。当然了,我们可以通过将现有文件进行挪动来腾出空间以容纳新的文件,但是这个在磁盘挪动文件是非常耗时,所以这种方式不太现实。 ![磁盘碎片](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E8%BF%9E%E7%BB%AD%E7%A9%BA%E9%97%B4%E5%AD%98%E6%94%BE%E6%96%B9%E5%BC%8F-%E7%A3%81%E7%9B%98%E7%A2%8E%E7%89%87.png) 另外一个缺陷是文件长度扩展不方便,例如上图中的文件 A 要想扩大一下,需要更多的磁盘空间,唯一的办法就只能是挪动的方式,前面也说了,这种方式效率是非常低的。 那么有没有更好的方式来解决上面的问题呢?答案当然有,既然连续空间存放的方式不太行,那么我们就改变存放的方式,使用非连续空间存放方式来解决这些缺陷。 ### 非连续空间存放方式 非连续空间存放方式分为「链表方式」和「索引方式」。 > 我们先来看看链表的方式。 链表的方式存放是**离散的,不用连续的**,于是就可以**消除磁盘碎片**,可大大提高磁盘空间的利用率,同时**文件的长度可以动态扩展**。根据实现的方式的不同,链表可分为「**隐式链表**」和「**显式链接**」两种形式。 文件要以「**隐式链表**」的方式存放的话,**实现的方式是文件头要包含「第一块」和「最后一块」的位置,并且每个数据块里面留出一个指针空间,用来存放下一个数据块的位置**,这样一个数据块连着一个数据块,从链头开始就可以顺着指针找到所有的数据块,所以存放的方式可以是不连续的。 ![隐式链表](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E9%9D%9E%E8%BF%9E%E7%BB%AD%E7%A9%BA%E9%97%B4%E5%AD%98%E6%94%BE%E6%96%B9%E5%BC%8F-%E9%93%BE%E8%A1%A8%E6%96%B9%E5%BC%8F.png) 隐式链表的存放方式的**缺点在于无法直接访问数据块,只能通过指针顺序访问文件,以及数据块指针消耗了一定的存储空间**。隐式链接分配的**稳定性较差**,系统在运行过程中由于软件或者硬件错误**导致链表中的指针丢失或损坏,会导致文件数据的丢失。** 如果取出每个磁盘块的指针,把它放在内存的一个表中,就可以解决上述隐式链表的两个不足。那么,这种实现方式是「**显式链接**」,它指**把用于链接文件各数据块的指针,显式地存放在内存的一张链接表中**,该表在整个磁盘仅设置一张,**每个表项中存放链接指针,指向下一个数据块号**。 对于显式链接的工作方式,我们举个例子,文件 A 依次使用了磁盘块 4、7、2、10 和 12,文件 B 依次使用了磁盘块 6、3、11 和 14。利用下图中的表,可以从第 4 块开始,顺着链走到最后,找到文件 A 的全部磁盘块。同样,从第 6 块开始,顺着链走到最后,也能够找出文件 B 的全部磁盘块。最后,这两个链都以一个不属于有效磁盘编号的特殊标记(如 -1)结束。内存中的这样一个表格称为**文件分配表(*File Allocation Table,FAT*)**。 ![显式链接](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E5%88%86%E9%85%8D%E8%A1%A8.png) 由于查找记录的过程是在内存中进行的,因而不仅显著地**提高了检索速度**,而且**大大减少了访问磁盘的次数**。但也正是整个表都存放在内存中的关系,它的主要的缺点是**不适用于大磁盘**。 比如,对于 200GB 的磁盘和 1KB 大小的块,这张表需要有 2 亿项,每一项对应于这 2 亿个磁盘块中的一个块,每项如果需要 4 个字节,那这张表要占用 800MB 内存,很显然 FAT 方案对于大磁盘而言不太合适。 > 接下来,我们来看看索引的方式。 链表的方式解决了连续分配的磁盘碎片和文件动态扩展的问题,但是不能有效支持直接访问(FAT 除外),索引的方式可以解决这个问题。 索引的实现是为每个文件创建一个「**索引数据块**」,里面存放的是**指向文件数据块的指针列表**,说白了就像书的目录一样,要找哪个章节的内容,看目录查就可以。 另外,**文件头需要包含指向「索引数据块」的指针**,这样就可以通过文件头知道索引数据块的位置,再通过索引数据块里的索引信息找到对应的数据块。 创建文件时,索引块的所有指针都设为空。当首次写入第 i 块时,先从空闲空间中取得一个块,再将其地址写到索引块的第 i 个条目。 ![索引的方式](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E9%9D%9E%E8%BF%9E%E7%BB%AD%E7%A9%BA%E9%97%B4%E5%AD%98%E6%94%BE%E6%96%B9%E5%BC%8F-%E7%B4%A2%E5%BC%95%E6%96%B9%E5%BC%8F.png) 索引的方式优点在于: - 文件的创建、增大、缩小很方便; - 不会有碎片的问题; - 支持顺序读写和随机读写; 由于索引数据也是存放在磁盘块的,如果文件很小,明明只需一块就可以存放的下,但还是需要额外分配一块来存放索引数据,所以缺陷之一就是存储索引带来的开销。 如果文件很大,大到一个索引数据块放不下索引信息,这时又要如何处理大文件的存放呢?我们可以通过组合的方式,来处理大文件的存。 先来看看链表 + 索引的组合,这种组合称为「**链式索引块**」,它的实现方式是**在索引数据块留出一个存放下一个索引数据块的指针**,于是当一个索引数据块的索引信息用完了,就可以通过指针的方式,找到下一个索引数据块的信息。那这种方式也会出现前面提到的链表方式的问题,万一某个指针损坏了,后面的数据也就会无法读取了。 ![链式索引块](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E9%93%BE%E5%BC%8F%E7%B4%A2%E5%BC%95%E5%9D%97.png) 还有另外一种组合方式是索引 + 索引的方式,这种组合称为「**多级索引块**」,实现方式是**通过一个索引块来存放多个索引数据块**,一层套一层索引,像极了俄罗斯套娃是吧。 ![多级索引块](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E5%A4%9A%E7%BA%A7%E7%B4%A2%E5%BC%95%E5%9D%97.png) ### Unix 文件的实现方式 我们先把前面提到的文件实现方式,做个比较: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/文件系统/文件存储方式比较.png) 那早期 Unix 文件系统是组合了前面的文件存放方式的优点,如下图: ![早期 Unix 文件系统](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/Unix%20%E5%A4%9A%E7%BA%A7%E7%B4%A2%E5%BC%95.png) 它是根据文件的大小,存放的方式会有所变化: - 如果存放文件所需的数据块小于 10 块,则采用直接查找的方式; - 如果存放文件所需的数据块超过 10 块,则采用一级间接索引方式; - 如果前面两种方式都不够存放大文件,则采用二级间接索引方式; - 如果二级间接索引也不够存放大文件,这采用三级间接索引方式; 那么,文件头(*Inode*)就需要包含 13 个指针: - 10 个指向数据块的指针; - 第 11 个指向索引块的指针; - 第 12 个指向二级索引块的指针; - 第 13 个指向三级索引块的指针; 所以,这种方式能很灵活地支持小文件和大文件的存放: - 对于小文件使用直接查找的方式可减少索引数据块的开销; - 对于大文件则以多级索引的方式来支持,所以大文件在访问数据块时需要大量查询; 这个方案就用在了 Linux Ext 2/3 文件系统里,虽然解决大文件的存储,但是对于大文件的访问,需要大量的查询,效率比较低。 为了解决这个问题,Ext 4 做了一定的改变,具体怎么解决的,本文就不展开了。 --- ## 空闲空间管理 前面说到的文件的存储是针对已经被占用的数据块组织和管理,接下来的问题是,如果我要保存一个数据块,我应该放在硬盘上的哪个位置呢?难道需要将所有的块扫描一遍,找个空的地方随便放吗? 那这种方式效率就太低了,所以针对磁盘的空闲空间也是要引入管理的机制,接下来介绍几种常见的方法: - 空闲表法 - 空闲链表法 - 位图法 ### 空闲表法 空闲表法就是为所有空闲空间建立一张表,表内容包括空闲区的第一个块号和该空闲区的块个数,注意,这个方式是连续分配的。如下图: ![空闲表法](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E7%A9%BA%E9%97%B2%E8%A1%A8%E6%B3%95.png) 当请求分配磁盘空间时,系统依次扫描空闲表里的内容,直到找到一个合适的空闲区域为止。当用户撤销一个文件时,系统回收文件空间。这时,也需顺序扫描空闲表,寻找一个空闲表条目并将释放空间的第一个物理块号及它占用的块数填到这个条目中。 这种方法仅当有少量的空闲区时才有较好的效果。因为,如果存储空间中有着大量的小的空闲区,则空闲表变得很大,这样查询效率会很低。另外,这种分配技术适用于建立连续文件。 ### 空闲链表法 我们也可以使用「链表」的方式来管理空闲空间,每一个空闲块里有一个指针指向下一个空闲块,这样也能很方便的找到空闲块并管理起来。如下图: ![空闲链表法](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E7%A9%BA%E9%97%B2%E5%9D%97%E9%93%BE%E8%A1%A8.png) 当创建文件需要一块或几块时,就从链头上依次取下一块或几块。反之,当回收空间时,把这些空闲块依次接到链头上。 这种技术只要在主存中保存一个指针,令它指向第一个空闲块。其特点是简单,但不能随机访问,工作效率低,因为每当在链上增加或移动空闲块时需要做很多 I/O 操作,同时数据块的指针消耗了一定的存储空间。 空闲表法和空闲链表法都不适合用于大型文件系统,因为这会使空闲表或空闲链表太大。 ### 位图法 位图是利用二进制的一位来表示磁盘中一个盘块的使用情况,磁盘上所有的盘块都有一个二进制位与之对应。 当值为 0 时,表示对应的盘块空闲,值为 1 时,表示对应的盘块已分配。它形式如下: ```plain 1111110011111110001110110111111100111 ... ``` 在 Linux 文件系统就采用了位图的方式来管理空闲空间,不仅用于数据空闲块的管理,还用于 inode 空闲块的管理,因为 inode 也是存储在磁盘的,自然也要有对其管理。 --- ## 文件系统的结构 前面提到 Linux 是用位图的方式管理空闲空间,用户在创建一个新文件时,Linux 内核会通过 inode 的位图找到空闲可用的 inode,并进行分配。要存储数据时,会通过块的位图找到空闲的块,并分配,但仔细计算一下还是有问题的。 数据块的位图是放在磁盘块里的,假设是放在一个块里,一个块 4K,每位表示一个数据块,共可以表示 `4 * 1024 * 8 = 2^15` 个空闲块,由于 1 个数据块是 4K 大小,那么最大可以表示的空间为 `2^15 * 4 * 1024 = 2^27` 个 byte,也就是 128M。 也就是说按照上面的结构,如果采用「一个块的位图 + 一系列的块」,外加「一个块的 inode 的位图 + 一系列的 inode 的结构」能表示的最大空间也就 128M,这太少了,现在很多文件都比这个大。 在 Linux 文件系统,把这个结构称为一个**块组**,那么有 N 多的块组,就能够表示 N 大的文件。 下图给出了 Linux Ext2 整个文件系统的结构和块组的内容,文件系统都由大量块组组成,在硬盘上相继排布: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/文件系统/块组.png) 最前面的第一个块是引导块,在系统启动时用于启用引导,接着后面就是一个一个连续的块组了,块组的内容如下: - *超级块*,包含的是文件系统的重要信息,比如 inode 总个数、块总个数、每个块组的 inode 个数、每个块组的块个数等等。 - *块组描述符*,包含文件系统中各个块组的状态,比如块组中空闲块和 inode 的数目等,每个块组都包含了文件系统中「所有块组的组描述符信息」。 - *数据位图和 inode 位图*,用于表示对应的数据块或 inode 是空闲的,还是被使用中。 - *inode 列表*,包含了块组中所有的 inode,inode 用于保存文件系统中与各个文件和目录相关的所有元数据。 - *数据块*,包含文件的有用数据。 你可以会发现每个块组里有很多重复的信息,比如**超级块和块组描述符表,这两个都是全局信息,而且非常的重要**,这么做是有两个原因: - 如果系统崩溃破坏了超级块或块组描述符,有关文件系统结构和内容的所有信息都会丢失。如果有冗余的副本,该信息是可能恢复的。 - 通过使文件和管理数据尽可能接近,减少了磁头寻道和旋转,这可以提高文件系统的性能。 不过,Ext2 的后续版本采用了稀疏技术。该做法是,超级块和块组描述符表不再存储到文件系统的每个块组中,而是只写入到块组 0、块组 1 和其他 ID 可以表示为 3、5、7 的幂的块组中。 --- ## 目录的存储 在前面,我们知道了一个普通文件是如何存储的,但还有一个特殊的文件,经常用到的目录,它是如何保存的呢? 基于 Linux 一切皆文件的设计思想,目录其实也是个文件,你甚至可以通过 `vim` 打开它,它也有 inode,inode 里面也是指向一些块。 和普通文件不同的是,**普通文件的块里面保存的是文件数据,而目录文件的块里面保存的是目录里面一项一项的文件信息。** 在目录文件的块中,最简单的保存格式就是**列表**,就是一项一项地将目录下的文件信息(如文件名、文件 inode、文件类型等)列在表里。 列表中每一项就代表该目录下的文件的文件名和对应的 inode,通过这个 inode,就可以找到真正的文件。 ![目录格式哈希表](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E7%9B%AE%E5%BD%95%E5%93%88%E5%B8%8C%E8%A1%A8.png) 通常,第一项是「`.`」,表示当前目录,第二项是「`..`」,表示上一级目录,接下来就是一项一项的文件名和 inode。 如果一个目录有超级多的文件,我们要想在这个目录下找文件,按照列表一项一项的找,效率就不高了。 于是,保存目录的格式改成**哈希表**,对文件名进行哈希计算,把哈希值保存起来,如果我们要查找一个目录下面的文件名,可以通过名称取哈希。如果哈希能够匹配上,就说明这个文件的信息在相应的块里面。 Linux 系统的 ext 文件系统就是采用了哈希表,来保存目录的内容,这种方法的优点是查找非常迅速,插入和删除也较简单,不过需要一些预备措施来避免哈希冲突。 目录查询是通过在磁盘上反复搜索完成,需要不断地进行 I/O 操作,开销较大。所以,为了减少 I/O 操作,把当前使用的文件目录缓存在内存,以后要使用该文件时只要在内存中操作,从而降低了磁盘操作次数,提高了文件系统的访问速度。 --- ## 软链接和硬链接 有时候我们希望给某个文件取个别名,那么在 Linux 中可以通过**硬链接(*Hard Link*)** 和**软链接(*Symbolic Link*)** 的方式来实现,它们都是比较特殊的文件,但是实现方式也是不相同的。 硬链接是**多个目录项中的「索引节点」指向一个文件**,也就是指向同一个 inode,但是 inode 是不可能跨越文件系统的,每个文件系统都有各自的 inode 数据结构和列表,所以**硬链接是不可用于跨文件系统的**。由于多个目录项都是指向一个 inode,那么**只有删除文件的所有硬链接以及源文件时,系统才会彻底删除该文件。** ![硬链接](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E7%A1%AC%E9%93%BE%E6%8E%A5-2.png) 软链接相当于重新创建一个文件,这个文件有**独立的 inode**,但是这个**文件的内容是另外一个文件的路径**,所以访问软链接的时候,实际上相当于访问到了另外一个文件,所以**软链接是可以跨文件系统的**,甚至**目标文件被删除了,链接文件还是在的,只不过指向的文件找不到了而已。** ![软链接](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E8%BD%AF%E9%93%BE%E6%8E%A5.png) --- ## 文件 I/O 文件的读写方式各有千秋,对于文件的 I/O 分类也非常多,常见的有 - 缓冲与非缓冲 I/O - 直接与非直接 I/O - 阻塞与非阻塞 I/O VS 同步与异步 I/O 接下来,分别对这些分类讨论讨论。 ### 缓冲与非缓冲 I/O 文件操作的标准库是可以实现数据的缓存,那么**根据「是否利用标准库缓冲」,可以把文件 I/O 分为缓冲 I/O 和非缓冲 I/O**: - 缓冲 I/O,利用的是标准库的缓存实现文件的加速访问,而标准库再通过系统调用访问文件。 - 非缓冲 I/O,直接通过系统调用访问文件,不经过标准库缓存。 这里所说的「缓冲」特指标准库内部实现的缓冲。 比方说,很多程序遇到换行时才真正输出,而换行前的内容,其实就是被标准库暂时缓存了起来,这样做的目的是,减少系统调用的次数,毕竟系统调用是有 CPU 上下文切换的开销的。 ### 直接与非直接 I/O 我们都知道磁盘 I/O 是非常慢的,所以 Linux 内核为了减少磁盘 I/O 次数,在系统调用后,会把用户数据拷贝到内核中缓存起来,这个内核缓存空间也就是「页缓存」,只有当缓存满足某些条件的时候,才发起磁盘 I/O 的请求。 那么,**根据是「否利用操作系统的缓存」,可以把文件 I/O 分为直接 I/O 与非直接 I/O**: - 直接 I/O,不会发生内核缓存和用户程序之间数据复制,而是直接经过文件系统访问磁盘。 - 非直接 I/O,读操作时,数据从内核缓存中拷贝给用户程序,写操作时,数据从用户程序拷贝给内核缓存,再由内核决定什么时候写入数据到磁盘。 如果你在使用文件操作类的系统调用函数时,指定了 `O_DIRECT` 标志,则表示使用直接 I/O。如果没有设置过,默认使用的是非直接 I/O。 > 如果用了非直接 I/O 进行写数据操作,内核什么情况下才会把缓存数据写入到磁盘? 以下几种场景会触发内核缓存的数据写入磁盘: - 在调用 `write` 的最后,当发现内核缓存的数据太多的时候,内核会把数据写到磁盘上; - 用户主动调用 `sync`,内核缓存会刷到磁盘上; - 当内存十分紧张,无法再分配页面时,也会把内核缓存的数据刷到磁盘上; - 内核缓存的数据的缓存时间超过某个时间时,也会把数据刷到磁盘上; ### 阻塞与非阻塞 I/O VS 同步与异步 I/O 为什么把阻塞 / 非阻塞与同步与异步放一起说的呢?因为它们确实非常相似,也非常容易混淆,不过它们之间的关系还是有点微妙的。 先来看看**阻塞 I/O**,当用户程序执行 `read` ,线程会被阻塞,一直等到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程完成,`read` 才会返回。 注意,**阻塞等待的是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程**。过程如下图: ![阻塞 I/O](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E9%98%BB%E5%A1%9E%20I_O.png) 知道了阻塞 I/O,来看看**非阻塞 I/O**,非阻塞的 read 请求在数据未准备好的情况下立即返回,可以继续往下执行,此时应用程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区,`read` 调用才可以获取到结果。过程如下图: ![非阻塞 I/O](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E9%9D%9E%E9%98%BB%E5%A1%9E%20I_O%20.png) 注意,**这里最后一次 read 调用,获取数据的过程,是一个同步的过程,是需要等待的过程。这里的同步指的是内核态的数据拷贝到用户程序的缓存区这个过程。** 举个例子,访问管道或 socket 时,如果设置了 `O_NONBLOCK` 标志,那么就表示使用的是非阻塞 I/O 的方式访问,而不做任何设置的话,默认是阻塞 I/O。 应用程序每次轮询内核的 I/O 是否准备好,感觉有点傻乎乎,因为轮询的过程中,应用程序啥也做不了,只是在循环。 为了解决这种傻乎乎轮询方式,于是 **I/O 多路复用**技术就出来了,如 select、poll,它是通过 I/O 事件分发,当内核数据准备好时,再以事件通知应用程序进行操作。 这个做法大大改善了 CPU 的利用率,因为当调用了 I/O 多路复用接口,如果没有事件发生,那么当前线程就会发生阻塞,这时 CPU 会切换其他线程执行任务,等内核发现有事件到来的时候,会唤醒阻塞在 I/O 多路复用接口的线程,然后用户可以进行后续的事件处理。 整个流程要比阻塞 IO 要复杂,似乎也更浪费性能。但 **I/O 多路复用接口最大的优势在于,用户可以在一个线程内同时处理多个 socket 的 IO 请求**(参见:[I/O 多路复用:select/poll/epoll](https://xiaolincoding.com/os/8_network_system/selete_poll_epoll.html))。用户可以注册多个 socket,然后不断地调用 I/O 多路复用接口读取被激活的 socket,即可达到在同一个线程内同时处理多个 IO 请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。 下图是使用 select I/O 多路复用过程。注意,`read` 获取数据的过程(数据从内核态拷贝到用户态的过程),也是一个**同步的过程**,需要等待: ![I/O 多路复用](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E5%9F%BA%E4%BA%8E%E9%9D%9E%E9%98%BB%E5%A1%9E%20I_O%20%E7%9A%84%E5%A4%9A%E8%B7%AF%E5%A4%8D%E7%94%A8.png) 实际上,无论是阻塞 I/O、非阻塞 I/O,还是基于非阻塞 I/O 的多路复用**都是同步调用。因为它们在 read 调用时,内核将数据从内核空间拷贝到应用程序空间,过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不高,read 调用就会在这个同步过程中等待比较长的时间。** 而真正的**异步 I/O** 是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待。 当我们发起 `aio_read` 之后,就立即返回,内核自动将数据从内核空间拷贝到应用程序空间,这个拷贝过程同样是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作。过程如下图: ![异步 I/O](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E5%BC%82%E6%AD%A5%20I_O.png) 下面这张图,总结了以上几种 I/O 模型: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost/操作系统/文件系统/同步VS异步IO.png) 在前面我们知道了,I/O 是分为两个过程的: 1. 数据准备的过程 2. 数据从内核空间拷贝到用户进程缓冲区的过程 阻塞 I/O 会阻塞在「过程 1」和「过程 2」,而非阻塞 I/O 和基于非阻塞 I/O 的多路复用只会阻塞在「过程 2」,所以这三个都可以认为是同步 I/O。 异步 I/O 则不同,「过程 1」和「过程 2」都不会阻塞。 > 用故事去理解这几种 I/O 模型 举个你去饭堂吃饭的例子,你好比用户程序,饭堂好比操作系统。 阻塞 I/O 好比,你去饭堂吃饭,但是饭堂的菜还没做好,然后你就一直在那里等啊等,等了好长一段时间终于等到饭堂阿姨把菜端了出来(数据准备的过程),但是你还得继续等阿姨把菜(内核空间)打到你的饭盒里(用户空间),经历完这两个过程,你才可以离开。 非阻塞 I/O 好比,你去了饭堂,问阿姨菜做好了没有,阿姨告诉你没,你就离开了,过几十分钟,你又来饭堂问阿姨,阿姨说做好了,于是阿姨帮你把菜打到你的饭盒里,这个过程你是得等待的。 基于非阻塞的 I/O 多路复用好比,你去饭堂吃饭,发现有一排窗口,饭堂阿姨告诉你这些窗口都还没做好菜,等做好了再通知你,于是等啊等(`select` 调用中),过了一会阿姨通知你菜做好了,但是不知道哪个窗口的菜做好了,你自己看吧。于是你只能一个一个窗口去确认,后面发现 5 号窗口菜做好了,于是你让 5 号窗口的阿姨帮你打菜到饭盒里,这个打菜的过程你是要等待的,虽然时间不长。打完菜后,你自然就可以离开了。 异步 I/O 好比,你让饭堂阿姨将菜做好并把菜打到饭盒里后,把饭盒送到你面前,整个过程你都不需要任何等待。 --- ## 关注作者 ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF*** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/其他/公众号介绍.png) ================================================ FILE: os/6_file_system/pagecache.md ================================================ # 7.2 进程写文件时,进程发生了崩溃,已写入的数据会丢失吗? 大家好,我是小林。 前几天,有位读者问了我这么个问题: ![](https://img-blog.csdnimg.cn/img_convert/23eb7000c28fb8135e0947620a75d946.png) 大概就是,进程写文件(使用缓冲 IO)过程中,写一半的时候,进程发生了崩溃,已写入的数据会丢失吗? 答案,是不会的。 ![](https://img-blog.csdnimg.cn/img_convert/1541c881598f554920355f0a3c5780fd.png) 因为进程在执行 write(使用缓冲 IO)系统调用的时候,实际上是将文件数据写到了内核的 page cache,它是文件系统中用于缓存文件数据的缓冲,所以即使进程崩溃了,文件数据还是保留在内核的 page cache,我们读数据的时候,也是从内核的 page cache 读取,因此还是依然读的进程崩溃前写入的数据。 内核会找个合适的时机,将 page cache 中的数据持久化到磁盘。但是如果 page cache 里的文件数据,在持久化到磁盘化到磁盘之前,系统发生了崩溃,那这部分数据就会丢失了。 当然,我们也可以在程序里调用 fsync 函数,在写文文件的时候,立刻将文件数据持久化到磁盘,这样就可以解决系统崩溃导致的文件数据丢失的问题。 我在网上看到一篇介绍 page cache 很好的文章,分享给大家一起学习。 > 作者:spongecaptain > > 原文地址:[Linux 的 Page Cache](https://spongecaptain.cool/SimpleClearFileIO/1.%20page%20cache.html) ## Page Cache ### Page Cache 是什么? 为了理解 Page Cache,我们不妨先看一下 Linux 的文件 I/O 系统,如下图所示: ![](https://img-blog.csdnimg.cn/img_convert/72568a29816fa9b505f15edac68adee2.png) 上图中,红色部分为 Page Cache。可见 Page Cache 的本质是由 Linux 内核管理的内存区域。我们通过 mmap 以及 buffered I/O 将文件读取到内存空间实际上都是读取到 Page Cache 中。 ### 如何查看系统的 Page Cache? 通过读取 `/proc/meminfo` 文件,能够实时获取系统内存情况: ```shell $ cat /proc/meminfo ... Buffers: 1224 kB Cached: 111472 kB SwapCached: 36364 kB Active: 6224232 kB Inactive: 979432 kB Active(anon): 6173036 kB Inactive(anon): 927932 kB Active(file): 51196 kB Inactive(file): 51500 kB ... Shmem: 10000 kB ... SReclaimable: 43532 kB ... ``` 根据上面的数据,你可以简单得出这样的公式(等式两边之和都是 112696 KB): ```plain Buffers + Cached + SwapCached = Active(file) + Inactive(file) + Shmem + SwapCached ``` 两边等式都是 Page Cache,即: ```plain Page Cache = Buffers + Cached + SwapCached ``` 通过阅读下面的小节,就能够理解为什么 SwapCached 与 Buffers 也是 Page Cache 的一部分。 ### page 与 Page Cache page 是内存管理分配的基本单位,Page Cache 由多个 page 构成。page 在操作系统中通常为 4KB 大小(32bits/64bits),而 Page Cache 的大小则为 4KB 的整数倍。 **另一方面,并不是所有 page 都被组织为 Page Cache**。 Linux 系统上供用户可访问的内存分为两个类型,即: - File-backed pages:文件备份页也就是 Page Cache 中的 page,对应于磁盘上的若干数据块;对于这些页最大的问题是脏页回盘; - Anonymous pages:匿名页不对应磁盘上的任何磁盘数据块,它们是进程的运行时内存空间(例如方法栈、局部变量表等属性); **为什么 Linux 不把 Page Cache 称为 block cache,这不是更好吗?** 这是因为从磁盘中加载到内存的数据不仅仅放在 Page Cache 中,还放在 buffer cache 中。 例如通过 Direct I/O 技术的磁盘文件就不会进入 Page Cache 中。当然,这个问题也有 Linux 历史设计的原因,毕竟这只是一个称呼,含义随着 Linux 系统的演进也逐渐不同。 下面比较一下 File-backed pages 与 Anonymous pages 在 Swap 机制下的性能。 内存是一种珍惜资源,当内存不够用时,内存管理单元(Memory Mangament Unit)需要提供调度算法来回收相关内存空间。内存空间回收的方式通常就是 swap,即交换到持久化存储设备上。 File-backed pages(Page Cache)的内存回收代价较低。Page Cache 通常对应于一个文件上的若干顺序块,因此可以通过顺序 I/O 的方式落盘。另一方面,如果 Page Cache 上没有进行写操作(所谓的没有脏页),甚至不会将 Page Cache 回盘,因为数据的内容完全可以通过再次读取磁盘文件得到。 Page Cache 的主要难点在于脏页回盘,这个内容会在后面进行详细说明。 Anonymous pages 的内存回收代价较高。这是因为 Anonymous pages 通常随机地写入持久化交换设备。另一方面,无论是否有写操作,为了确保数据不丢失,Anonymous pages 在 swap 时必须持久化到磁盘。 ### Swap 与缺页中断 Swap 机制指的是当物理内存不够用,内存管理单元(Memory Mangament Unit,MMU)需要提供调度算法来回收相关内存空间,然后将清理出来的内存空间给当前内存申请方。 Swap 机制存在的本质原因是 Linux 系统提供了虚拟内存管理机制,每一个进程认为其独占内存空间,因此所有进程的内存空间之和远远大于物理内存。所有进程的内存空间之和超过物理内存的部分就需要交换到磁盘上。 操作系统以 page 为单位管理内存,当进程发现需要访问的数据不在内存时,操作系统可能会将数据以页的方式加载到内存中。上述过程被称为**缺页中断**,当操作系统发生缺页中断时,就会通过系统调用将 page 再次读到内存中。 但主内存的空间是有限的,当主内存中不包含可以使用的空间时,操作系统会从选择合适的物理内存页驱逐回磁盘,为新的内存页让出位置,**选择待驱逐页的过程在操作系统中叫做页面替换(Page Replacement)**,替换操作又会触发 swap 机制。 如果物理内存足够大,那么可能不需要 Swap 机制,但是 Swap 在这种情况下还是有一定优势:对于有发生内存泄漏几率的应用程序(进程),Swap 交换分区更是重要,这可以确保内存泄露不至于导致物理内存不够用,最终导致系统崩溃。但内存泄露会引起频繁的 swap,此时非常影响操作系统的性能。 Linux 通过一个 swappiness 参数来控制 Swap 机制:这个参数值可为 0-100,控制系统 swap 的优先级: - 高数值:较高频率的 swap,进程不活跃时主动将其转换出物理内存。 - 低数值:较低频率的 swap,这可以确保交互式不因为内存空间频繁地交换到磁盘而提高响应延迟。 **最后,为什么 SwapCached 也是 Page Cache 的一部分?** 这是因为当匿名页(Inactive(anon) 以及 Active(anon))先被交换(swap out)到磁盘上后,然后再加载回(swap in)内存中,由于读入到内存后原来的 Swap File 还在,所以 SwapCached 也可以认为是 File-backed page,即属于 Page Cache。这个过程如下图所示。 ![图片](https://img-blog.csdnimg.cn/img_convert/cbba24cac4668625c4e32d8cf641cf9c.png) ### Page Cache 与 buffer cache 执行 free 命令,注意到会有两列名为 buffers 和 cached,也有一行名为“-/+ buffers/cache”。 ```plain ~ free -m total used free shared buffers cached Mem: 128956 96440 32515 0 5368 39900 -/+ buffers/cache: 51172 77784 Swap: 16002 0 16001 ``` 其中,cached 列表示当前的页缓存(Page Cache)占用量,buffers 列表示当前的块缓存(buffer cache)占用量。 用一句话来解释:**Page Cache 用于缓存文件的页数据,buffer cache 用于缓存块设备(如磁盘)的块数据。** - 页是逻辑上的概念,因此 Page Cache 是与文件系统同级的; - 块是物理上的概念,因此 buffer cache 是与块设备驱动程序同级的。 Page Cache 与 buffer cache 的**共同目的都是加速数据 I/O**: - 写数据时首先写到缓存,将写入的页标记为 dirty,然后向外部存储 flush,也就是缓存写机制中的 write-back(另一种是 write-through,Linux 默认情况下不采用); - 读数据时首先读取缓存,如果未命中,再去外部存储读取,并且将读取来的数据也加入缓存。操作系统总是积极地将所有空闲内存都用作 Page Cache 和 buffer cache,当内存不够用时也会用 LRU 等算法淘汰缓存页。 在 Linux 2.4 版本的内核之前,Page Cache 与 buffer cache 是完全分离的。但是,块设备大多是磁盘,磁盘上的数据又大多通过文件系统来组织,这种设计导致很多数据被缓存了两次,浪费内存。 **所以在 2.4 版本内核之后,两块缓存近似融合在了一起:如果一个文件的页加载到了 Page Cache,那么同时 buffer cache 只需要维护块指向页的指针就可以了**。只有那些没有文件表示的块,或者绕过了文件系统直接操作(如 dd 命令)的块,才会真正放到 buffer cache 里。 因此,**我们现在提起 Page Cache,基本上都同时指 Page Cache 和 buffer cache 两者,本文之后也不再区分,直接统称为 Page Cache**。 下图近似地示出 32-bit Linux 系统中可能的一种 Page Cache 结构,其中 block size 大小为 1KB,page size 大小为 4KB。 ![图片](https://img-blog.csdnimg.cn/img_convert/c81ffa0b7d11506ffad3c33001385444.png) Page Cache 中的每个文件都是一棵基数树(radix tree,本质上是多叉搜索树),树的每个节点都是一个页。根据文件内的偏移量就可以快速定位到所在的页,如下图所示。关于基数树的原理可以参见英文维基,这里就不细说了。 ![图片](https://img-blog.csdnimg.cn/img_convert/cfda154558181c4af27a34c1d4a97552.png) ### Page Cache 与预读 操作系统为基于 Page Cache 的读缓存机制提供**预读机制**(PAGE_READAHEAD),一个例子是: - 用户线程仅仅请求读取磁盘上文件 A 的 offset 为 0-3KB 范围内的数据,由于磁盘的基本读写单位为 block(4KB),于是操作系统至少会读 0-4KB 的内容,这恰好可以在一个 page 中装下。 - 但是操作系统出于局部性原理会选择将磁盘块 offset [4KB,8KB)、[8KB,12KB) 以及 [12KB,16KB) 都加载到内存,于是额外在内存中申请了 3 个 page; 下图代表了操作系统的预读机制: ![](https://img-blog.csdnimg.cn/img_convert/ae8252378169c8c14b8b9907983f7d8b.png) 上图中,应用程序利用 read 系统调动读取 4KB 数据,实际上内核使用 readahead 机制完成了 16KB 数据的读取。 ## Page Cache 与文件持久化的一致性&可靠性 现代 Linux 的 Page Cache 正如其名,是对磁盘上 page(页)的内存缓存,同时可以用于读/写操作。 任何系统引入缓存,就会引发一致性问题:内存中的数据与磁盘中的数据不一致,例如常见后端架构中的 Redis 缓存与 MySQL 数据库就存在一致性问题。 Linux 提供多种机制来保证数据一致性,但无论是单机上的内存与磁盘一致性,还是分布式组件中节点 1 与节点 2、节点 3 的数据一致性问题,理解的关键是 trade-off:吞吐量与数据一致性保证是一对矛盾。 首先,需要我们理解一下文件的数据。**文件 = 数据 + 元数据**。元数据用来描述文件的各种属性,也必须存储在磁盘上。因此,我们说保证文件一致性其实包含了两个方面:数据一致 + 元数据一致。 > 文件的元数据包括:文件大小、创建时间、访问时间、属主属组等信息。 我们考虑如下一致性问题:如果发生写操作并且对应的数据在 Page Cache 中,那么写操作就会直接作用于 Page Cache 中,此时如果数据还没刷新到磁盘,那么内存中的数据就领先于磁盘,此时对应 page 就被称为 Dirty page。 当前 Linux 下以两种方式实现文件一致性: 1. **Write Through(写穿)**:向用户层提供特定接口,应用程序可主动调用接口来保证文件一致性; 2. **Write back(写回)**:系统中存在定期任务(表现形式为内核线程),周期性地同步文件系统中文件脏数据块,这是默认的 Linux 一致性方案; 上述两种方式最终都依赖于系统调用,主要分为如下三种系统调用: | 方法 | 含义 | | :---------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | fsync(intfd) | fsync(fd):将 fd 代表的文件的脏数据和脏元数据全部刷新至磁盘中。 | | fdatasync(int fd) | fdatasync(fd):将 fd 代表的文件的脏数据刷新至磁盘,同时对必要的元数据刷新至磁盘中,这里所说的必要的概念是指:对接下来访问文件有关键作用的信息,如文件大小,而文件修改时间等不属于必要信息 | | sync() | sync():则是对系统中所有的脏的文件数据元数据刷新至磁盘中 | 上述三种系统调用可以分别由用户进程与内核进程发起。下面我们研究一下内核线程的相关特性。 1. 创建的针对回写任务的内核线程数由系统中持久存储设备决定,为每个存储设备创建单独的刷新线程; 2. 关于多线程的架构问题,Linux 内核采取了 Lighthttp 的做法,即系统中存在一个管理线程和多个刷新线程(每个持久存储设备对应一个刷新线程)。管理线程监控设备上的脏页面情况,若设备一段时间内没有产生脏页面,就销毁设备上的刷新线程;若监测到设备上有脏页面需要回写且尚未为该设备创建刷新线程,那么创建刷新线程处理脏页面回写。而刷新线程的任务较为单调,只负责将设备中的脏页面回写至持久存储设备中。 3. 刷新线程刷新设备上脏页面大致设计如下: - 每个设备保存脏文件链表,保存的是该设备上存储的脏文件的 inode 节点。所谓的回写文件脏页面即回写该 inode 链表上的某些文件的脏页面; - 系统中存在多个回写时机,第一是应用程序主动调用回写接口(fsync,fdatasync 以及 sync 等),第二管理线程周期性地唤醒设备上的回写线程进行回写,第三是某些应用程序/内核任务发现内存不足时要回收部分缓存页面而事先进行脏页面回写,设计一个统一的框架来管理这些回写任务非常有必要。 Write Through 与 Write back 在持久化的可靠性上有所不同: - Write Through 以牺牲系统 I/O 吞吐量作为代价,向上层应用确保一旦写入,数据就已经落盘,不会丢失; - Write back 在系统发生宕机的情况下无法确保数据已经落盘,因此存在数据丢失的问题。不过,在程序挂了,例如被 kill -9,Page Cache 中的数据操作系统还是会确保落盘; ## Page Cache 的优劣势 ### Page Cache 的优势 **1.加快数据访问** 如果数据能够在内存中进行缓存,那么下一次访问就不需要通过磁盘 I/O 了,直接命中内存缓存即可。 由于内存访问比磁盘访问快很多,因此加快数据访问是 Page Cache 的一大优势。 **2.减少 I/O 次数,提高系统磁盘 I/O 吞吐量** 得益于 Page Cache 的缓存以及预读能力,而程序又往往符合局部性原理,因此通过一次 I/O 将多个 page 装入 Page Cache 能够减少磁盘 I/O 次数,进而提高系统磁盘 I/O 吞吐量。 ### Page Cache 的劣势 page cache 也有其劣势,最直接的缺点是需要占用额外物理内存空间,物理内存在比较紧俏的时候可能会导致频繁的 swap 操作,最终导致系统的磁盘 I/O 负载的上升。 Page Cache 的另一个缺陷是对应用层并没有提供很好的管理 API,几乎是透明管理。应用层即使想优化 Page Cache 的使用策略也很难进行。因此一些应用选择在用户空间实现自己的 page 管理,而不使用 page cache,例如 MySQL InnoDB 存储引擎以 16KB 的页进行管理。 Page Cache 最后一个缺陷是在某些应用场景下比 Direct I/O 多一次磁盘读 I/O 以及磁盘写 I/O。 Direct I/O 即直接 I/O。其名字中的”直接”二字用于区分使用 page cache 机制的缓存 I/O。 - 缓存文件 I/O:用户空间要读写一个文件并**不直接**与磁盘交互,而是中间夹了一层缓存,即 page cache; - 直接文件 I/O:用户空间读取的文件**直接**与磁盘交互,没有中间 page cache 层; “直接”在这里还有另一层语义:其他所有技术中,数据至少需要在内核空间存储一份,但是在 Direct I/O 技术中,数据直接存储在用户空间中,绕过了内核。 Direct I/O 模式如下图所示: ![directIO](https://img-blog.csdnimg.cn/img_convert/503d7d5d3f330d64fcade48b312f767d.png) 此时用户空间直接通过 DMA 的方式与磁盘以及网卡进行数据拷贝。 **Direct I/O 的读写非常有特点**: - Write 操作:由于其不使用 page cache,所以其进行写文件,如果返回成功,数据就真的落盘了(不考虑磁盘自带的缓存); - Read 操作:由于其不使用 page cache,每次读操作是真的从磁盘中读取,不会从文件系统的缓存中读取。 --- 参考资料 - [Linux 内核技术实战课](https://time.geekbang.org/column/intro/337) - [Reconsidering swapping](https://lwn.net/Articles/690079/) - [访问局部性](https://zh.wikipedia.org/wiki/访问局部性) - [DMA 与零拷贝技术](https://spongecaptain.cool/SimpleClearFileIO/2.%20DMA%20%E4%B8%8E%E9%9B%B6%E6%8B%B7%E8%B4%9D%E6%8A%80%E6%9C%AF.html) --- ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF*** ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: os/7_device/device.md ================================================ # 8.1 键盘敲入 A 字母时,操作系统期间发生了什么? 键盘可以说是我们最常使用的输入硬件设备了,但身为程序员的你,你知道「**键盘敲入 A 字母时,操作系统期间发生了什么吗**」? 那要想知道这个发生的过程,我们得先了解了解「操作系统是如何管理多种多样的的输入输出设备」的,等了解完这个后,我们再来看看这个问题,你就会发现问题已经被迎刃而解了。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%BE%93%E5%85%A5%E8%BE%93%E5%87%BA/%E8%AE%BE%E5%A4%87%E7%AE%A1%E7%90%86.png) --- ## 设备控制器 我们的电脑设备可以接非常多的输入输出设备,比如键盘、鼠标、显示器、网卡、硬盘、打印机、音响等等,每个设备的用法和功能都不同,那操作系统是如何把这些输入输出设备统一管理的呢? 为了屏蔽设备之间的差异,每个设备都有一个叫**设备控制器(*Device Control*)** 的组件,比如硬盘有硬盘控制器、显示器有视频控制器等。 ![计算机 I/O 系统结构](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%BE%93%E5%85%A5%E8%BE%93%E5%87%BA/I_O%E7%B3%BB%E7%BB%9F%E7%BB%93%E6%9E%84.png) 因为这些控制器都很清楚的知道对应设备的用法和功能,所以 CPU 是通过设备控制器来和设备打交道的。 设备控制器里有芯片,它可执行自己的逻辑,也有自己的寄存器,用来与 CPU 进行通信,比如: - 通过写入这些寄存器,操作系统可以命令设备发送数据、接收数据、开启或关闭,或者执行某些其他操作。 - 通过读取这些寄存器,操作系统可以了解设备的状态,是否准备好接收一个新的命令等。 实际上,控制器是有三类寄存器,它们分别是**状态寄存器(*Status Register*)**、 **命令寄存器(*Command Register*)**以及**数据寄存器(*Data Register*)**,如下图: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%BE%93%E5%85%A5%E8%BE%93%E5%87%BA/%E8%AE%BE%E5%A4%87%E6%8E%A7%E5%88%B6%E5%99%A8.png) 这三个寄存器的作用: - *数据寄存器*,CPU 向 I/O 设备写入需要传输的数据,比如要打印的内容是「Hello」,CPU 就要先发送一个 H 字符给到对应的 I/O 设备。 - *命令寄存器*,CPU 发送一个命令,告诉 I/O 设备,要进行输入/输出操作,于是就会交给 I/O 设备去工作,任务完成后,会把状态寄存器里面的状态标记为完成。 - *状态寄存器*,目的是告诉 CPU,现在已经在工作或工作已经完成,如果已经在工作状态,CPU 再发送数据或者命令过来,都是没有用的,直到前面的工作已经完成,状态寄存标记成已完成,CPU 才能发送下一个字符和命令。 CPU 通过读写设备控制器中的寄存器控制设备,这可比 CPU 直接控制输入输出设备,要方便和标准很多。 另外,输入输出设备可分为两大类:**块设备(*Block Device*)**和**字符设备(*Character Device*)**。 - *块设备*,把数据存储在固定大小的块中,每个块有自己的地址,硬盘、USB 是常见的块设备。 - *字符设备*,以字符为单位发送或接收一个字符流,字符设备是不可寻址的,也没有任何寻道操作,鼠标是常见的字符设备。 块设备通常传输的数据量会非常大,于是控制器设立了一个可读写的**数据缓冲区**。 - CPU 写入数据到控制器的缓冲区时,当缓冲区的数据囤够了一部分,才会发给设备。 - CPU 从控制器的缓冲区读取数据时,也需要缓冲区囤够了一部分,才拷贝到内存。 这样做是为了,减少对设备的频繁操作。 那 CPU 是如何与设备的控制寄存器和数据缓冲区进行通信的?存在两个方法: - *端口 I/O*,每个控制寄存器被分配一个 I/O 端口,可以通过特殊的汇编指令操作这些寄存器,比如 `in/out` 类似的指令。 - *内存映射 I/O*,将所有控制寄存器映射到内存空间中,这样就可以像读写内存一样读写数据缓冲区。 --- ## I/O 控制方式 在前面我知道,每种设备都有一个设备控制器,控制器相当于一个小 CPU,它可以自己处理一些事情,但有个问题是,当 CPU 给设备发送了一个指令,让设备控制器去读设备的数据,它读完的时候,要怎么通知 CPU 呢? 控制器的寄存器一般会有状态标记位,用来标识输入或输出操作是否完成。于是,我们想到第一种**轮询等待**的方法,让 CPU 一直查寄存器的状态,直到状态标记为完成,很明显,这种方式非常的傻瓜,它会占用 CPU 的全部时间。 那我们就想到第二种方法 —— **中断**,通知操作系统数据已经准备好了。我们一般会有一个硬件的**中断控制器**,当设备完成任务后触发中断到中断控制器,中断控制器就通知 CPU,一个中断产生了,CPU 需要停下当前手里的事情来处理中断。 另外,中断有两种,一种**软中断**,例如代码调用 `INT` 指令触发,一种是**硬件中断**,就是硬件通过中断控制器触发的。 但中断的方式对于频繁读写数据的磁盘,并不友好,这样 CPU 容易经常被打断,会占用 CPU 大量的时间。对于这一类设备的问题的解决方法是使用 **DMA(*Direct Memory Access*)** 功能,它可以使得设备在 CPU 不参与的情况下,能够自行完成把设备 I/O 数据放入到内存。那要实现 DMA 功能要有「DMA 控制器」硬件的支持。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%BE%93%E5%85%A5%E8%BE%93%E5%87%BA/DMA%E5%B7%A5%E4%BD%9C%E5%8E%9F%E7%90%86.png) DMA 的工作方式如下: - CPU 需对 DMA 控制器下发指令,告诉它想读取多少数据,读完的数据放在内存的某个地方就可以了; - 接下来,DMA 控制器会向磁盘控制器发出指令,通知它从磁盘读数据到其内部的缓冲区中,接着磁盘控制器将缓冲区的数据传输到内存; - 当磁盘控制器把数据传输到内存的操作完成后,磁盘控制器在总线上发出一个确认成功的信号到 DMA 控制器; - DMA 控制器收到信号后,DMA 控制器发中断通知 CPU 指令完成,CPU 就可以直接用内存里面现成的数据了; 可以看到,CPU 当要读取磁盘数据的时候,只需给 DMA 控制器发送指令,然后返回去做其他事情,当磁盘数据拷贝到内存后,DMA 控制机器通过中断的方式,告诉 CPU 数据已经准备好了,可以从内存读数据了。仅仅在传送开始和结束时需要 CPU 干预。 ---- ## 设备驱动程序 虽然设备控制器屏蔽了设备的众多细节,但每种设备的控制器的寄存器、缓冲区等使用模式都是不同的,所以为了屏蔽「设备控制器」的差异,引入了**设备驱动程序**。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%BE%93%E5%85%A5%E8%BE%93%E5%87%BA/%E9%A9%B1%E5%8A%A8%E7%A8%8B%E5%BA%8F.png) 设备控制器不属于操作系统范畴,它是属于硬件,而设备驱动程序属于操作系统的一部分,操作系统的内核代码可以像本地调用代码一样使用设备驱动程序的接口,而设备驱动程序是面向设备控制器的代码,它发出操控设备控制器的指令后,才可以操作设备控制器。 不同的设备控制器虽然功能不同,但是**设备驱动程序会提供统一的接口给操作系统**,这样不同的设备驱动程序,就可以以相同的方式接入操作系统。如下图: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%BE%93%E5%85%A5%E8%BE%93%E5%87%BA/%E9%A9%B1%E5%8A%A8%E7%A8%8B%E5%BA%8F%E6%8E%A5%E5%8F%A3%E4%B8%80%E8%87%B4%E6%80%A7.png) 前面提到了不少关于中断的事情,设备完成了事情,则会发送中断来通知操作系统。那操作系统就需要有一个地方来处理这个中断,这个地方也就是在设备驱动程序里,它会及时响应控制器发来的中断请求,并根据这个中断的类型调用响应的**中断处理程序**进行处理。 通常,设备驱动程序初始化的时候,要先注册一个该设备的中断处理函数。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%BE%93%E5%85%A5%E8%BE%93%E5%87%BA/%E4%B8%AD%E6%96%AD%E5%B7%A5%E4%BD%9C%E8%BF%87%E7%A8%8B.png) 我们来看看,中断处理程序的处理流程: 1. 在 I/O 时,设备控制器如果已经准备好数据,则会通过中断控制器向 CPU 发送中断请求; 2. 保护被中断进程的 CPU 上下文; 3. 转入相应的设备中断处理函数; 4. 进行中断处理; 5. 恢复被中断进程的上下文; ---- ## 通用块层 对于块设备,为了减少不同块设备的差异带来的影响,Linux 通过一个统一的**通用块层**,来管理不同的块设备。 通用块层是处于文件系统和磁盘驱动中间的一个块设备抽象层,它主要有两个功能: - 第一个功能,向上为文件系统和应用程序,提供访问块设备的标准接口,向下把各种不同的磁盘设备抽象为统一的块设备,并在内核层面,提供一个框架来管理这些设备的驱动程序; - 第二功能,通用层还会给文件系统和应用程序发来的 I/O 请求排队,接着会对队列重新排序、请求合并等方式,也就是 I/O 调度,主要目的是为了提高磁盘读写的效率。 Linux 内存支持 5 种 I/O 调度算法,分别是: - 没有调度算法 - 先入先出调度算法 - 完全公平调度算法 - 优先级调度 - 最终期限调度算法 第一种,没有调度算法,是的,你没听错,它不对文件系统和应用程序的 I/O 做任何处理,这种算法常用在虚拟机 I/O 中,此时磁盘 I/O 调度算法交由物理机系统负责。 第二种,先入先出调度算法,这是最简单的 I/O 调度算法,先进入 I/O 调度队列的 I/O 请求先发生。 第三种,完全公平调度算法,大部分系统都把这个算法作为默认的 I/O 调度器,它为每个进程维护了一个 I/O 调度队列,并按照时间片来均匀分布每个进程的 I/O 请求。 第四种,优先级调度算法,顾名思义,优先级高的 I/O 请求先发生,它适用于运行大量进程的系统,像是桌面环境、多媒体应用等。 第五种,最终期限调度算法,分别为读、写请求创建了不同的 I/O 队列,这样可以提高机械磁盘的吞吐量,并确保达到最终期限的请求被优先处理,适用于在 I/O 压力比较大的场景,比如数据库等。 --- ## 存储系统 I/O 软件分层 前面说到了不少东西,设备、设备控制器、驱动程序、通用块层,现在再结合文件系统原理,我们来看看 Linux 存储系统的 I/O 软件分层。 可以把 Linux 存储系统的 I/O 由上到下可以分为三个层次,分别是文件系统层、通用块层、设备层。他们整个的层次关系如下图: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%BE%93%E5%85%A5%E8%BE%93%E5%87%BA/I_O%E8%BD%AF%E4%BB%B6%E5%88%86%E5%B1%82.png) 这三个层次的作用是: - 文件系统层,包括虚拟文件系统和其他文件系统的具体实现,它向上为应用程序统一提供了标准的文件访问接口,向下会通过通用块层来存储和管理磁盘数据。 - 通用块层,包括块设备的 I/O 队列和 I/O 调度器,它会对文件系统的 I/O 请求进行排队,再通过 I/O 调度器,选择一个 I/O 发给下一层的设备层。 - 设备层,包括硬件设备、设备控制器和驱动程序,负责最终物理设备的 I/O 操作。 有了文件系统接口之后,不但可以通过文件系统的命令行操作设备,也可以通过应用程序,调用 `read`、`write` 函数,就像读写文件一样操作设备,所以说设备在 Linux 下,也只是一个特殊的文件。 但是,除了读写操作,还需要有检查特定于设备的功能和属性。于是,需要 `ioctl` 接口,它表示输入输出控制接口,是用于配置和修改特定设备属性的通用接口。 另外,存储系统的 I/O 是整个系统最慢的一个环节,所以 Linux 提供了不少缓存机制来提高 I/O 的效率。 - 为了提高文件访问的效率,会使用**页缓存、索引节点缓存、目录项缓存**等多种缓存机制,目的是为了减少对块设备的直接调用。 - 为了提高块设备的访问效率,会使用**缓冲区**,来缓存块设备的数据。 --- ## 键盘敲入字母时,期间发生了什么? 看完前面的内容,相信你对输入输出设备的管理有了一定的认识,那接下来就从操作系统的角度回答开头的问题「键盘敲入字母时,操作系统期间发生了什么?」 我们先来看看 CPU 的硬件架构图: ![CPU 的硬件架构图](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E8%BE%93%E5%85%A5%E8%BE%93%E5%87%BA/CPU%20%E7%A1%AC%E4%BB%B6%E6%80%BB%E7%BA%BF%E5%9B%BE.png) CPU 里面的内存接口,直接和系统总线通信,然后系统总线再接入一个 I/O 桥接器,这个 I/O 桥接器,另一边接入了内存总线,使得 CPU 和内存通信。再另一边,又接入了一个 I/O 总线,用来连接 I/O 设备,比如键盘、显示器等。 那当用户输入了键盘字符,**键盘控制器**就会产生扫描码数据,并将其缓冲在键盘控制器的寄存器中,紧接着键盘控制器通过总线给 CPU 发送**中断请求**。 CPU 收到中断请求后,操作系统会**保存被中断进程的 CPU 上下文**,然后调用键盘的**中断处理程序**。 键盘的中断处理程序是在**键盘驱动程序**初始化时注册的,那键盘**中断处理函数**的功能就是从键盘控制器的寄存器的缓冲区读取扫描码,再根据扫描码找到用户在键盘输入的字符,如果输入的字符是显示字符,那就会把扫描码翻译成对应显示字符的 ASCII 码,比如用户在键盘输入的是字母 A,是显示字符,于是就会把扫描码翻译成 A 字符的 ASCII 码。 得到了显示字符的 ASCII 码后,就会把 ASCII 码放到「读缓冲区队列」,接下来就是要把显示字符显示屏幕了,显示设备的驱动程序会定时从「读缓冲区队列」读取数据放到「写缓冲区队列」,最后把「写缓冲区队列」的数据一个一个写入到显示设备的控制器的寄存器中的数据缓冲区,最后将这些数据显示在屏幕里。 显示出结果后,**恢复被中断进程的上下文**。 --- ## 关注作者 ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF*** ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/其他/公众号介绍.png) ================================================ FILE: os/8_network_system/hash.md ================================================ # 9.4 什么是一致性哈希? 大家好,我是小林。 在逛牛客网的面经的时候,发现有位同学在面微信的时候,被问到这个问题: ![](https://img-blog.csdnimg.cn/img_convert/2ad888cd9ca79d8d68fbd7ff29a6e088.png) 第一个问题就是:**一致性哈希是什么,使用场景,解决了什么问题?** 这个问题还挺有意思的,所以今天就来聊聊这个。 发车! ![](https://img-blog.csdnimg.cn/img_convert/7de125e1b754aa50132e1fa385ad5c0a.png) ## 如何分配请求? 大多数网站背后肯定不是只有一台服务器提供服务,因为单机的并发量和数据量都是有限的,所以都会用多台服务器构成集群来对外提供服务。 但是问题来了,现在有那么多个节点(后面统称服务器为节点,因为少一个字),要如何分配客户端的请求呢? ![](https://img-blog.csdnimg.cn/img_convert/b752a4f8dcaab8ed4d941ebcc6f606c5.png) 其实这个问题就是「负载均衡问题」。解决负载均衡问题的算法很多,不同的负载均衡算法,对应的就是不同的分配策略,适应的业务场景也不同。 最简单的方式,引入一个中间的负载均衡层,让它将外界的请求「轮流」的转发给内部的集群。比如集群有 3 个节点,外界请求有 3 个,那么每个节点都会处理 1 个请求,达到了分配请求的目的。 ![](https://img-blog.csdnimg.cn/img_convert/d3279ad754257977f98e702cb156e9cf.png) 考虑到每个节点的硬件配置有所区别,我们可以引入权重值,将硬件配置更好的节点的权重值设高,然后根据各个节点的权重值,按照一定比重分配在不同的节点上,让硬件配置更好的节点承担更多的请求,这种算法叫做加权轮询。 加权轮询算法使用场景是建立在每个节点存储的数据都是相同的前提。所以,每次读数据的请求,访问任意一个节点都能得到结果。 但是,加权轮询算法是无法应对「分布式系统」的,因为分布式系统中,每个节点存储的数据是不同的。 当我们想提高系统的容量,就会将数据水平切分到不同的节点来存储,也就是将数据分布到了不同的节点。比如**一个分布式 KV(key-value)缓存系统,某个 key 应该到哪个或者哪些节点上获得,应该是确定的**,不是说任意访问一个节点都可以得到缓存结果的。 因此,我们要想一个能应对分布式系统的负载均衡算法。 ## 使用哈希算法有什么问题? 有的同学可能很快就想到了:**哈希算法**。因为对同一个关键字进行哈希计算,每次计算都是相同的值,这样就可以将某个 key 确定到一个节点了,可以满足分布式系统的负载均衡需求。 哈希算法最简单的做法就是进行取模运算,比如分布式系统中有 3 个节点,基于 `hash(key) % 3` 公式对数据进行了映射。 如果客户端要获取指定 key 的数据,通过下面的公式可以定位节点: ```plain hash(key) % 3 ``` 如果经过上面这个公式计算后得到的值是 0,就说明该 key 需要去第一个节点获取。 但是有一个很致命的问题,**如果节点数量发生了变化,也就是在对系统做扩容或者缩容时,必须迁移改变了映射关系的数据**,否则会出现查询不到数据的问题。 举个例子,假设我们有一个由 A、B、C 三个节点组成分布式 KV 缓存系统,基于计算公式 `hash(key) % 3` 将数据进行了映射,每个节点存储了不同的数据: ![](https://img-blog.csdnimg.cn/img_convert/025ddcaabece1f4b5823dfb1fb7340ef.png) 现在有 3 个查询 key 的请求,分别查询 key-01,key-02,key-03 的数据,这三个 key 分别经过 hash() 函数计算后的值为 hash( key-01) = 6、hash( key-02) = 7、hash(key-03) = 8,然后再对这些值进行取模运算。 通过这样的哈希算法,每个 key 都可以定位到对应的节点。 ![](https://img-blog.csdnimg.cn/img_convert/ed14c96417e08b4f916e0cd23d12b7bd.png) 当 3 个节点不能满足业务需求了,这时我们增加了一个节点,节点的数量从 3 变化为 4,意味取模哈希函数中基数的变化,这样会导致**大部分映射关系改变**,如下图: ![](https://img-blog.csdnimg.cn/img_convert/392c54cfb9ec47f5191008aa1d27d6b5.png) 比如,之前的 hash(key-01) % `3` = 0,就变成了 hash(key-01) % `4` = 2,查询 key-01 数据时,寻址到了节点 C,而 key-01 的数据是存储在节点 A 上的,不是在节点 C,所以会查询不到数据。 同样的道理,如果我们对分布式系统进行缩容,比如移除一个节点,也会因为取模哈希函数中基数的变化,可能出现查询不到数据的问题。 要解决这个问题的办法,就需要我们进行**迁移数据**,比如节点的数量从 3 变化为 4 时,要基于新的计算公式 hash(key) % 4,重新对数据和节点做映射。 假设总数据条数为 M,哈希算法在面对节点数量变化时,**最坏情况下所有数据都需要迁移,所以它的数据迁移规模是 O(M)**,这样数据的迁移成本太高了。 所以,我们应该要重新想一个新的算法,来避免分布式系统在扩容或者缩容时,发生过多的数据迁移。 ## 使用一致性哈希算法有什么问题? 一致性哈希算法就很好地解决了分布式系统在扩容或者缩容时,发生过多的数据迁移的问题。 一致哈希算法也用了取模运算,但与哈希算法不同的是,哈希算法是对节点的数量进行取模运算,而**一致哈希算法是对 2^32 进行取模运算,是一个固定的值**。 我们可以把一致哈希算法是对 2^32 进行取模运算的结果值组织成一个圆环,就像钟表一样,钟表的圆可以理解成由 60 个点组成的圆,而此处我们把这个圆想象成由 2^32 个点组成的圆,这个圆环被称为**哈希环**,如下图: ![](https://img-blog.csdnimg.cn/img_convert/0ea3960fef48d4cbaeb4bec4345301e7.png) 一致性哈希要进行两步哈希: - 第一步:对存储节点进行哈希计算,也就是对存储节点做哈希映射,比如根据节点的 IP 地址进行哈希; - 第二步:当对数据进行存储或访问时,对数据进行哈希映射; 所以,**一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上**。 问题来了,对「数据」进行哈希映射得到一个结果要怎么找到存储该数据的节点呢? 答案是,映射的结果值往**顺时针的方向的找到第一个节点**,就是存储该数据的节点。 举个例子,有 3 个节点经过哈希计算,映射到了如下图的位置: ![](https://img-blog.csdnimg.cn/img_convert/83d7f363643353c92d252e34f1d4f687.png) 接着,对要查询的 key-01 进行哈希计算,确定此 key-01 映射在哈希环的位置,然后从这个位置往顺时针的方向找到第一个节点,就是存储该 key-01 数据的节点。 比如,下图中的 key-01 映射的位置,往顺时针的方向找到第一个节点就是节点 A。 ![](https://img-blog.csdnimg.cn/img_convert/30c2c70721c12f9c140358fbdc5f2282.png) 所以,当需要对指定 key 的值进行读写的时候,要通过下面 2 步进行寻址: - 首先,对 key 进行哈希计算,确定此 key 在环上的位置; - 然后,从这个位置沿着顺时针方向走,遇到的第一节点就是存储 key 的节点。 知道了一致哈希寻址的方式,我们来看看,如果增加一个节点或者减少一个节点会发生大量的数据迁移吗? 假设节点数量从 3 增加到了 4,新的节点 D 经过哈希计算后映射到了下图中的位置: ![](https://img-blog.csdnimg.cn/img_convert/f8909edef2f3949f8945bb99380baab3.png) 你可以看到,key-01、key-03 都不受影响,只有 key-02 需要被迁移节点 D。 假设节点数量从 3 减少到了 2,比如将节点 A 移除: ![](https://img-blog.csdnimg.cn/img_convert/31485046f1303b57d8aaeaab103ea7ab.png) 你可以看到,key-02 和 key-03 不会受到影响,只有 key-01 需要被迁移节点 B。 因此,**在一致哈希算法中,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响**。 上面这些图中 3 个节点映射在哈希环还是比较分散的,所以看起来请求都会「均衡」到每个节点。 但是**一致性哈希算法并不保证节点能够在哈希环上分布均匀**,这样就会带来一个问题,会有大量的请求集中在一个节点上。 比如,下图中 3 个节点的映射位置都在哈希环的右半边: ![](https://img-blog.csdnimg.cn/img_convert/d528bae6fcec2357ba2eb8f324ad9fd5.png) 这时候有一半以上的数据的寻址都会找节点 A,也就是访问请求主要集中的节点 A 上,这肯定不行的呀,说好的负载均衡呢,这种情况一点都不均衡。 另外,在这种节点分布不均匀的情况下,进行容灾与扩容时,哈希环上的相邻节点容易受到过大影响,容易发生雪崩式的连锁反应。 比如,上图中如果节点 A 被移除了,当节点 A 宕机后,根据一致性哈希算法的规则,其上数据应该全部迁移到相邻的节点 B 上,这样,节点 B 的数据量、访问量都会迅速增加很多倍,一旦新增的压力超过了节点 B 的处理能力上限,就会导致节点 B 崩溃,进而形成雪崩式的连锁反应。 所以,**一致性哈希算法虽然减少了数据迁移量,但是存在节点分布不均匀的问题**。 ## 如何通过虚拟节点提高均衡度? 要想解决节点能在哈希环上分配不均匀的问题,就是要有大量的节点,节点数越多,哈希环上的节点分布的就越均匀。 但问题是,实际中我们没有那么多节点。所以这个时候我们就加入**虚拟节点**,也就是对一个真实节点做多个副本。 具体做法是,**不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点,所以这里有「两层」映射关系。** 比如对每个节点分别设置 3 个虚拟节点: - 对节点 A 加上编号来作为虚拟节点:A-01、A-02、A-03 - 对节点 B 加上编号来作为虚拟节点:B-01、B-02、B-03 - 对节点 C 加上编号来作为虚拟节点:C-01、C-02、C-03 引入虚拟节点后,原本哈希环上只有 3 个节点的情况,就会变成有 9 个虚拟节点映射到哈希环上,哈希环上的节点数量多了 3 倍。 ![](https://img-blog.csdnimg.cn/img_convert/dbb57b8d6071d011d05eeadd93269e13.png) 你可以看到,**节点数量多了后,节点在哈希环上的分布就相对均匀了**。这时候,如果有访问请求寻址到「A-01」这个虚拟节点,接着再通过「A-01」虚拟节点找到真实节点 A,这样请求就能访问到真实节点 A 了。 上面为了方便你理解,每个真实节点仅包含 3 个虚拟节点,这样能起到的均衡效果其实很有限。而在实际的工程中,虚拟节点的数量会大很多,比如 Nginx 的一致性哈希算法,每个权重为 1 的真实节点就含有 160 个虚拟节点。 另外,虚拟节点除了会提高节点的均衡度,还会提高系统的稳定性。**当节点变化时,会有不同的节点共同分担系统的变化,因此稳定性更高**。 比如,当某个节点被移除时,对应该节点的多个虚拟节点均会移除,而这些虚拟节点按顺时针方向的下一个虚拟节点,可能会对应不同的真实节点,即这些不同的真实节点共同分担了节点变化导致的压力。 而且,有了虚拟节点后,还可以为硬件配置更好的节点增加权重,比如对权重更高的节点增加更多的虚拟机节点即可。 因此,**带虚拟节点的一致性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景**。 ## 总结 不同的负载均衡算法适用的业务场景也不同的。 轮询这类的策略只能适用与每个节点的数据都是相同的场景,访问任意节点都能请求到数据。但是不适用分布式系统,因为分布式系统意味着数据水平切分到了不同的节点上,访问数据的时候,一定要寻址存储该数据的节点。 哈希算法虽然能建立数据和节点的映射关系,但是每次在节点数量发生变化的时候,最坏情况下所有数据都需要迁移,这样太麻烦了,所以不适用节点数量变化的场景。 为了减少迁移的数据量,就出现了一致性哈希算法。 一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响。 但是一致性哈希算法不能够均匀的分布节点,会出现大量请求都集中在一个节点的情况,在这种情况下进行容灾与扩容时,容易出现雪崩的连锁反应。 为了解决一致性哈希算法不能够均匀的分布节点的问题,就需要引入虚拟节点,对一个真实节点做多个副本。不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点,所以这里有「两层」映射关系。 引入虚拟节点后,会提高节点的均衡度,还会提高系统的稳定性。所以,带虚拟节点的一致性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景。 完! ## 关注作者 **_哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF_** ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/其他/公众号介绍.png) ================================================ FILE: os/8_network_system/reactor.md ================================================ # 9.3 高性能网络模式:Reactor 和 Proactor 小林,来了。 这次就来**图解 Reactor 和 Proactor** 这两个高性能网络模式。 别小看这两个东西,特别是 Reactor 模式,市面上常见的开源软件很多都采用了这个方案,比如 Redis、Nginx、Netty 等等,所以学好这个模式设计的思想,不仅有助于我们理解很多开源软件,而且也能在面试时吹逼。 发车! ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/操作系统/Reactor/reactor提纲.jpeg) --- ## 演进 如果要让服务器服务多个客户端,那么最直接的方式就是为每一条连接创建线程。 其实创建进程也是可以的,原理是一样的,进程和线程的区别在于线程比较轻量级些,线程的创建和线程间切换的成本要小些,为了描述简述,后面都以线程为例。 处理完业务逻辑后,随着连接关闭后线程也同样要销毁了,但是这样不停地创建和销毁线程,不仅会带来性能开销,也会造成浪费资源,而且如果要连接几万条连接,创建几万个线程去应对也是不现实的。 要这么解决这个问题呢?我们可以使用「资源复用」的方式。 也就是不用再为每个连接创建线程,而是创建一个「线程池」,将连接分配给线程,然后一个线程可以处理多个连接的业务。 不过,这样又引来一个新的问题,线程怎样才能高效地处理多个连接的业务? 当一个连接对应一个线程时,线程一般采用「read -> 业务处理 -> send」的处理流程,如果当前连接没有数据可读,那么线程会阻塞在 `read` 操作上(socket 默认情况是阻塞 I/O),不过这种阻塞方式并不影响其他线程。 但是引入了线程池,那么一个线程要处理多个连接的业务,线程在处理某个连接的 `read` 操作时,如果遇到没有数据可读,就会发生阻塞,那么线程就没办法继续处理其他连接的业务。 要解决这一个问题,最简单的方式就是将 socket 改成非阻塞,然后线程不断地轮询调用 `read` 操作来判断是否有数据,这种方式虽然该能够解决阻塞的问题,但是解决的方式比较粗暴,因为轮询是要消耗 CPU 的,而且随着一个线程处理的连接越多,轮询的效率就会越低。 上面的问题在于,线程并不知道当前连接是否有数据可读,从而需要每次通过 `read` 去试探。 那有没有办法在只有当连接上有数据的时候,线程才去发起读请求呢?答案是有的,实现这一技术的就是 I/O 多路复用。 I/O 多路复用技术会用一个系统调用函数来监听我们所有关心的连接,也就说可以在一个监控线程里面监控很多的连接。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/操作系统/多路复用/多路复用.png) 我们熟悉的 select/poll/epoll 就是内核提供给用户态的多路复用系统调用,线程可以通过一个系统调用函数从内核中获取多个事件。 > PS:如果想知道 select/poll/epoll 的区别,可以看看小林之前写的这篇文章:[这次答应我,一举拿下 I/O 多路复用!](https://mp.weixin.qq.com/s/Qpa0qXxuIM8jrBqDaXmVNA) select/poll/epoll 是如何获取网络事件的呢? 在获取事件时,先把我们要关心的连接传给内核,再由内核检测: - 如果没有事件发生,线程只需阻塞在这个系统调用,而无需像前面的线程池方案那样轮询调用 read 操作来判断是否有数据。 - 如果有事件发生,内核会返回产生了事件的连接,线程就会从阻塞状态返回,然后在用户态中再处理这些连接对应的业务即可。 当下开源软件能做到网络高性能的原因就是 I/O 多路复用吗? 是的,基本是基于 I/O 多路复用,用过 I/O 多路复用接口写网络程序的同学,肯定知道是面向过程的方式写代码的,这样的开发的效率不高。 于是,大佬们基于面向对象的思想,对 I/O 多路复用作了一层封装,让使用者不用考虑底层网络 API 的细节,只需要关注应用代码的编写。 大佬们还为这种模式取了个让人第一时间难以理解的名字:**Reactor 模式**。 Reactor 翻译过来的意思是「反应堆」,可能大家会联想到物理学里的核反应堆,实际上并不是的这个意思。 这里的反应指的是「**对事件反应**」,也就是**来了一个事件,Reactor 就有相对应的反应/响应**。 事实上,Reactor 模式也叫 `Dispatcher` 模式,我觉得这个名字更贴合该模式的含义,即 **I/O 多路复用监听事件,收到事件后,根据事件类型分配(Dispatch)给某个进程 / 线程**。 Reactor 模式主要由 Reactor 和处理资源池这两个核心部分组成,它俩负责的事情如下: - Reactor 负责监听和分发事件,事件类型包含连接事件、读写事件; - 处理资源池负责处理事件,如 read -> 业务逻辑 -> send; Reactor 模式是灵活多变的,可以应对不同的业务场景,灵活在于: - Reactor 的数量可以只有一个,也可以有多个; - 处理资源池可以是单个进程 / 线程,也可以是多个进程 /线程; 将上面的两个因素排列组设一下,理论上就可以有 4 种方案选择: - 单 Reactor 单进程 / 线程; - 单 Reactor 多进程 / 线程; - 多 Reactor 单进程 / 线程; - 多 Reactor 多进程 / 线程; 其中,「多 Reactor 单进程 / 线程」实现方案相比「单 Reactor 单进程 / 线程」方案,不仅复杂而且也没有性能优势,因此实际中并没有应用。 剩下的 3 个方案都是比较经典的,且都有应用在实际的项目中: - 单 Reactor 单进程 / 线程; - 单 Reactor 多线程 / 进程; - 多 Reactor 多进程 / 线程; 方案具体使用进程还是线程,要看使用的编程语言以及平台有关: - Java 语言一般使用线程,比如 Netty; - C 语言使用进程和线程都可以,例如 Nginx 使用的是进程,Memcache 使用的是线程。 接下来,分别介绍这三个经典的 Reactor 方案。 --- ## Reactor ### 单 Reactor 单进程 / 线程 一般来说,C 语言实现的是「**单 Reactor *单进程***」的方案,因为 C 语言编写完的程序,运行后就是一个独立的进程,不需要在进程中再创建线程。 而 Java 语言实现的是「**单 Reactor *单线程***」的方案,因为 Java 程序是跑在 Java 虚拟机这个进程上面的,虚拟机中有很多线程,我们写的 Java 程序只是其中的一个线程而已。 我们来看看「**单 Reactor 单进程**」的方案示意图: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/操作系统/Reactor/单Reactor单进程.png) 可以看到进程里有 **Reactor、Acceptor、Handler** 这三个对象: - Reactor 对象的作用是监听和分发事件; - Acceptor 对象的作用是获取连接; - Handler 对象的作用是处理业务; 对象里的 select、accept、read、send 是系统调用函数,dispatch 和「业务处理」是需要完成的操作,其中 dispatch 是分发事件操作。 接下来,介绍下「单 Reactor 单进程」这个方案: - Reactor 对象通过 select(IO 多路复用接口)监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型; - 如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件; - 如果不是连接建立事件,则交由当前连接对应的 Handler 对象来进行响应; - Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。 单 Reactor 单进程的方案因为全部工作都在同一个进程内完成,所以实现起来比较简单,不需要考虑进程间通信,也不用担心多进程竞争。 但是,这种方案存在 2 个缺点: - 第一个缺点,因为只有一个进程,**无法充分利用 多核 CPU 的性能**; - 第二个缺点,Handler 对象在业务处理时,整个进程是无法处理其他连接的事件的,**如果业务处理耗时比较长,那么就造成响应的延迟**; 所以,单 Reactor 单进程的方案**不适用计算密集型的场景,只适用于业务处理非常快速的场景**。 Redis 是由 C 语言实现的,它采用的正是「单 Reactor 单进程」的方案,因为 Redis 业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的方案。 ### 单 Reactor 多线程 / 多进程 如果要克服「单 Reactor 单线程 / 进程」方案的缺点,那么就需要引入多线程 / 多进程,这样就产生了**单 Reactor 多线程 / 多进程**的方案。 闻其名不如看其图,先来看看「单 Reactor 多线程」方案的示意图如下: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/操作系统/Reactor/单Reactor多线程.png) 详细说一下这个方案: - Reactor 对象通过 select(IO 多路复用接口)监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型; - 如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件; - 如果不是连接建立事件,则交由当前连接对应的 Handler 对象来进行响应; 上面的三个步骤和单 Reactor 单线程方案是一样的,接下来的步骤就开始不一样了: - Handler 对象不再负责业务处理,只负责数据的接收和发送,Handler 对象通过 read 读取到数据后,会将数据发给子线程里的 Processor 对象进行业务处理; - 子线程里的 Processor 对象就进行业务处理,处理完后,将结果发给主线程中的 Handler 对象,接着由 Handler 通过 send 方法将响应结果发送给 client; 单 Reator 多线程的方案优势在于**能够充分利用多核 CPU 的性能**,那既然引入多线程,那么自然就带来了多线程竞争资源的问题。 例如,子线程完成业务处理后,要把结果传递给主线程的 Reactor 进行发送,这里涉及共享数据的竞争。 要避免多线程由于竞争共享资源而导致数据错乱的问题,就需要在操作共享资源前加上互斥锁,以保证任意时间里只有一个线程在操作共享资源,待该线程操作完释放互斥锁后,其他线程才有机会操作共享数据。 聊完单 Reactor 多线程的方案,接着来看看单 Reactor 多进程的方案。 事实上,单 Reactor 多进程相比单 Reactor 多线程实现起来很麻烦,主要因为要考虑子进程 <-> 父进程的双向通信,并且父进程还得知道子进程要将数据发送给哪个客户端。 而多线程间可以共享数据,虽然要额外考虑并发问题,但是这远比进程间通信的复杂度低得多,因此实际应用中也看不到单 Reactor 多进程的模式。 另外,「单 Reactor」的模式还有个问题,**因为一个 Reactor 对象承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方**。 ### 多 Reactor 多进程 / 线程 要解决「单 Reactor」的问题,就是将「单 Reactor」实现成「多 Reactor」,这样就产生了第 **多 Reactor 多进程 / 线程**的方案。 老规矩,闻其名不如看其图。多 Reactor 多进程 / 线程方案的示意图如下(以线程为例): ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/操作系统/Reactor/主从Reactor多线程.png) 方案详细说明如下: - 主线程中的 MainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 对象中的 accept 获取连接,将新的连接分配给某个子线程; - 子线程中的 SubReactor 对象将 MainReactor 对象分配的连接加入 select 继续进行监听,并创建一个 Handler 用于处理连接的响应事件。 - 如果有新的事件发生时,SubReactor 对象会调用当前连接对应的 Handler 对象来进行响应。 - Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。 多 Reactor 多线程的方案虽然看起来复杂的,但是实际实现时比单 Reactor 多线程的方案要简单的多,原因如下: - 主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理。 - 主线程和子线程的交互很简单,主线程只需要把新连接传给子线程,子线程无须返回数据,直接就可以在子线程将处理结果发送给客户端。 大名鼎鼎的两个开源软件 Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案。 采用了「多 Reactor 多进程」方案的开源软件是 Nginx,不过方案与标准的多 Reactor 多进程有些差异。 具体差异表现在主进程中仅仅用来初始化 socket,并没有创建 mainReactor 来 accept 连接,而是由子进程的 Reactor 来 accept 连接,通过锁来控制一次只有一个子进程进行 accept(防止出现惊群现象),子进程 accept 新连接后就放到自己的 Reactor 进行处理,不会再分配给其他子进程。 --- ## Proactor 前面提到的 Reactor 是非阻塞同步网络模式,而 **Proactor 是异步网络模式**。 这里先给大家复习下阻塞、非阻塞、同步、异步 I/O 的概念。 先来看看**阻塞 I/O**,当用户程序执行 `read` ,线程会被阻塞,一直等到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程完成,`read` 才会返回。 注意,**阻塞等待的是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程**。过程如下图: ![阻塞 I/O](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E9%98%BB%E5%A1%9E%20I_O.png) 知道了阻塞 I/O,来看看**非阻塞 I/O**,非阻塞的 read 请求在数据未准备好的情况下立即返回,可以继续往下执行,此时应用程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区,`read` 调用才可以获取到结果。过程如下图: ![非阻塞 I/O](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E9%9D%9E%E9%98%BB%E5%A1%9E%20I_O%20.png) 注意,**这里最后一次 read 调用,获取数据的过程,是一个同步的过程,是需要等待的过程。这里的同步指的是内核态的数据拷贝到用户程序的缓存区这个过程。** 举个例子,如果 socket 设置了 `O_NONBLOCK` 标志,那么就表示使用的是非阻塞 I/O 的方式访问,而不做任何设置的话,默认是阻塞 I/O。 因此,无论 read 和 send 是阻塞 I/O,还是非阻塞 I/O 都是同步调用。因为在 read 调用时,内核将数据从内核空间拷贝到用户空间的过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不高,read 调用就会在这个同步过程中等待比较长的时间。 而真正的**异步 I/O** 是「内核数据准备好」和「数据从内核态拷贝到用户态」这**两个过程都不用等待**。 当我们发起 `aio_read` (异步 I/O)之后,就立即返回,内核自动将数据从内核空间拷贝到用户空间,这个拷贝过程同样是异步的,内核自动完成的,和前面的同步操作不一样,**应用程序并不需要主动发起拷贝动作**。过程如下图: ![异步 I/O](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E5%BC%82%E6%AD%A5%20I_O.png) 举个你去饭堂吃饭的例子,你好比应用程序,饭堂好比操作系统。 阻塞 I/O 好比,你去饭堂吃饭,但是饭堂的菜还没做好,然后你就一直在那里等啊等,等了好长一段时间终于等到饭堂阿姨把菜端了出来(数据准备的过程),但是你还得继续等阿姨把菜(内核空间)打到你的饭盒里(用户空间),经历完这两个过程,你才可以离开。 非阻塞 I/O 好比,你去了饭堂,问阿姨菜做好了没有,阿姨告诉你没,你就离开了,过几十分钟,你又来饭堂问阿姨,阿姨说做好了,于是阿姨帮你把菜打到你的饭盒里,这个过程你是得等待的。 异步 I/O 好比,你让饭堂阿姨将菜做好并把菜打到饭盒里后,把饭盒送到你面前,整个过程你都不需要任何等待。 很明显,异步 I/O 比同步 I/O 性能更好,因为异步 I/O 在「内核数据准备好」和「数据从内核空间拷贝到用户空间」这两个过程都不用等待。 Proactor 正是采用了异步 I/O 技术,所以被称为异步网络模型。 现在我们再来理解 Reactor 和 Proactor 的区别,就比较清晰了。 - **Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件**。在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将 socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据。 - **Proactor 是异步网络模式,感知的是已完成的读写事件**。在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据。 因此,**Reactor 可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」**,而 **Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应用进程」**。这里的「事件」就是有新连接、有数据可读、有数据可写的这些 I/O 事件,这里的「处理」包含从驱动读取到内核以及从内核读取到用户空间。 举个实际生活中的例子,Reactor 模式就是快递员在楼下,给你打电话告诉你快递到你家小区了,你需要自己下楼来拿快递。而在 Proactor 模式下,快递员直接将快递送到你家门口,然后通知你。 无论是 Reactor,还是 Proactor,都是一种基于「事件分发」的网络编程模式,区别在于 **Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式则是基于「已完成」的 I/O 事件**。 接下来,一起看看 Proactor 模式的示意图: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/操作系统/Reactor/Proactor.png) 介绍一下 Proactor 模式的工作流程: - Proactor Initiator 负责创建 Proactor 和 Handler 对象,并将 Proactor 和 Handler 都通过 Asynchronous Operation Processor 注册到内核; - Asynchronous Operation Processor 负责处理注册请求,并处理 I/O 操作; - Asynchronous Operation Processor 完成 I/O 操作后通知 Proactor; - Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理; - Handler 完成业务处理; 可惜的是,在 Linux 下的异步 I/O 是不完善的, `aio` 系列函数是由 POSIX 定义的异步操作接口,不是真正的操作系统级别支持的,而是在用户空间模拟出来的异步,并且仅仅支持基于本地文件的 aio 异步操作,网络编程中的 socket 是不支持的,这也使得基于 Linux 的高性能网络程序都是使用 Reactor 方案。 而 Windows 里实现了一套完整的支持 socket 的异步编程接口,这套接口就是 `IOCP`,是由操作系统级别实现的异步 I/O,真正意义上异步 I/O,因此在 Windows 里实现高性能网络程序可以使用效率更高的 Proactor 方案。 ---- ## 总结 常见的 Reactor 实现方案有三种。 第一种方案单 Reactor 单进程 / 线程,不用考虑进程间通信以及数据同步的问题,因此实现起来比较简单,这种方案的缺陷在于无法充分利用多核 CPU,而且处理业务逻辑的时间不能太长,否则会延迟响应,所以不适用于计算机密集型的场景,适用于业务处理快速的场景,比如 Redis 采用的是单 Reactor 单进程的方案。 第二种方案单 Reactor 多线程,通过多线程的方式解决了方案一的缺陷,但它离高并发还差一点距离,差在只有一个 Reactor 对象来承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方。 第三种方案多 Reactor 多进程 / 线程,通过多个 Reactor 来解决了方案二的缺陷,主 Reactor 只负责监听事件,响应事件的工作交给了从 Reactor,Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案,Nginx 则采用了类似于「多 Reactor 多进程」的方案。 Reactor 可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」,而 Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应用进程」。 因此,真正的大杀器还是 Proactor,它是采用异步 I/O 实现的异步网络模型,感知的是已完成的读写事件,而不需要像 Reactor 感知到事件后,还需要调用 read 来从内核中获取数据。 不过,无论是 Reactor,还是 Proactor,都是一种基于「事件分发」的网络编程模式,区别在于 Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式则是基于「已完成」的 I/O 事件。 --- ## 关注作者 ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF*** ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/其他/公众号介绍.png) ================================================ FILE: os/8_network_system/selete_poll_epoll.md ================================================ # 9.2 I/O 多路复用:select/poll/epoll 我们以最简单 socket 网络模型,一步一步的过渡到 I/O 多路复用。 但我不会具体细节说到每个系统调用的参数,这方面书上肯定比我说的详细。 好了,**发车!** ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/操作系统/多路复用/多路复用提纲.png) --- ## 最基本的 Socket 模型 要想客户端和服务器能在网络中通信,那必须得使用 Socket 编程,它是进程间通信里比较特别的方式,特别之处在于它是可以跨主机间通信。 Socket 的中文名叫作插口,乍一看还挺迷惑的。事实上,双方要进行网络通信前,各自得创建一个 Socket,这相当于客户端和服务器都开了一个“口子”,双方读取和发送数据的时候,都通过这个“口子”。这样一看,是不是觉得很像弄了一根网线,一头插在客户端,一头插在服务端,然后进行通信。 创建 Socket 的时候,可以指定网络层使用的是 IPv4 还是 IPv6,传输层使用的是 TCP 还是 UDP。 UDP 的 Socket 编程相对简单些,这里我们只介绍基于 TCP 的 Socket 编程。 服务器的程序要先跑起来,然后等待客户端的连接和数据,我们先来看看服务端的 Socket 编程过程是怎样的。 服务端首先调用 `socket()` 函数,创建网络协议为 IPv4,以及传输协议为 TCP 的 Socket,接着调用 `bind()` 函数,给这个 Socket 绑定一个 **IP 地址和端口**,绑定这两个的目的是什么? - 绑定端口的目的:当内核收到 TCP 报文,通过 TCP 头里面的端口号,来找到我们的应用程序,然后把数据传递给我们。 - 绑定 IP 地址的目的:一台机器是可以有多个网卡的,每个网卡都有对应的 IP 地址,当绑定一个网卡时,内核在收到该网卡上的包,才会发给我们; 绑定完 IP 地址和端口后,就可以调用 `listen()` 函数进行监听,此时对应 TCP 状态图中的 `listen`,如果我们要判断服务器中一个网络程序有没有启动,可以通过 `netstat` 命令查看对应的端口号是否有被监听。 服务端进入了监听状态后,通过调用 `accept()` 函数,来从内核获取客户端的连接,如果没有客户端连接,则会阻塞等待客户端连接的到来。 那客户端是怎么发起连接的呢?客户端在创建好 Socket 后,调用 `connect()` 函数发起连接,该函数的参数要指明服务端的 IP 地址和端口号,然后万众期待的 TCP 三次握手就开始了。 在 TCP 连接的过程中,服务器的内核实际上为每个 Socket 维护了两个队列: - 一个是还没完全建立连接的队列,称为 **TCP 半连接队列**,这个队列都是没有完成三次握手的连接,此时服务端处于 `syn_rcvd` 的状态; - 一个是一件建立连接的队列,称为 **TCP 全连接队列**,这个队列都是完成了三次握手的连接,此时服务端处于 `established` 状态; 当 TCP 全连接队列不为空后,服务端的 `accept()` 函数,就会从内核中的 TCP 全连接队列里拿出一个已经完成连接的 Socket 返回应用程序,后续数据传输都用这个 Socket。 注意,监听的 Socket 和真正用来传数据的 Socket 是两个: - 一个叫作**监听 Socket**; - 一个叫作**已连接 Socket**; 连接建立后,客户端和服务端就开始相互传输数据了,双方都可以通过 `read()` 和 `write()` 函数来读写数据。 至此,TCP 协议的 Socket 程序的调用过程就结束了,整个过程如下图: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/操作系统/多路复用/tcp_socket.png) 看到这,不知道你有没有觉得读写 Socket 的方式,好像读写文件一样。 是的,基于 Linux 一切皆文件的理念,在内核中 Socket 也是以「文件」的形式存在的,也是有对应的文件描述符。 > PS : 下面会说到内核里的数据结构,不感兴趣的可以跳过这一部分,不会对后续的内容有影响。 文件描述符的作用是什么?每一个进程都有一个数据结构 `task_struct`,该结构体里有一个指向「文件描述符数组」的成员指针。该数组里列出这个进程打开的所有文件的文件描述符。数组的下标是文件描述符,是一个整数,而数组的内容是一个指针,指向内核中所有打开的文件的列表,也就是说内核可以通过文件描述符找到对应打开的文件。 然后每个文件都有一个 inode,Socket 文件的 inode 指向了内核中的 Socket 结构,在这个结构体里有两个队列,分别是**发送队列**和**接收队列**,这个两个队列里面保存的是一个个 `struct sk_buff`,用链表的组织形式串起来。 sk_buff 可以表示各个层的数据包,在应用层数据包叫 data,在 TCP 层我们称为 segment,在 IP 层我们叫 packet,在数据链路层称为 frame。 你可能会好奇,为什么全部数据包只用一个结构体来描述呢?协议栈采用的是分层结构,上层向下层传递数据时需要增加包头,下层向上层数据时又需要去掉包头,如果每一层都用一个结构体,那在层之间传递数据的时候,就要发生多次拷贝,这将大大降低 CPU 效率。 于是,为了在层级之间传递数据时,不发生拷贝,只用 sk_buff 一个结构体来描述所有的网络包,那它是如何做到的呢?是通过调整 sk_buff 中 `data` 的指针,比如: - 当接收报文时,从网卡驱动开始,通过协议栈层层往上传送数据报,通过增加 skb->data 的值,来逐步剥离协议首部。 - 当要发送报文时,创建 sk_buff 结构体,数据缓存区的头部预留足够的空间,用来填充各层首部,在经过各下层协议时,通过减少 skb->data 的值来增加协议首部。 你可以从下面这张图看到,当发送报文时,data 指针的移动过程。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/操作系统/多路复用/sk_buff.jpg) --- ## 如何服务更多的用户? 前面提到的 TCP Socket 调用流程是最简单、最基本的,它基本只能一对一通信,因为使用的是同步阻塞的方式,当服务端在还没处理完一个客户端的网络 I/O 时,或者 读写操作发生阻塞时,其他客户端是无法与服务端连接的。 可如果我们服务器只能服务一个客户,那这样就太浪费资源了,于是我们要改进这个网络 I/O 模型,以支持更多的客户端。 在改进网络 I/O 模型前,我先来提一个问题,你知道服务器单机理论最大能连接多少个客户端? 相信你知道 TCP 连接是由四元组唯一确认的,这个四元组就是:**本机 IP, 本机端口,对端 IP, 对端端口**。 服务器作为服务方,通常会在本地固定监听一个端口,等待客户端的连接。因此服务器的本地 IP 和端口是固定的,于是对于服务端 TCP 连接的四元组只有对端 IP 和端口是会变化的,所以**最大 TCP 连接数 = 客户端 IP 数 × 客户端端口数**。 对于 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是**服务端单机最大 TCP 连接数约为 2 的 48 次方**。 这个理论值相当“丰满”,但是服务器肯定承载不了那么大的连接数,主要会受两个方面的限制: - **文件描述符**,Socket 实际上是一个文件,也就会对应一个文件描述符。在 Linux 下,单个进程打开的文件描述符数是有限制的,没有经过修改的值一般都是 1024,不过我们可以通过 ulimit 增大文件描述符的数目; - **系统内存**,每个 TCP 连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的; 那如果服务器的内存只有 2 GB,网卡是千兆的,能支持并发 1 万请求吗? 并发 1 万请求,也就是经典的 C10K 问题,C 是 Client 单词首字母缩写,C10K 就是单机同时处理 1 万个请求的问题。 从硬件资源角度看,对于 2GB 内存千兆网卡的服务器,如果每个请求处理占用不到 200KB 的内存和 100Kbit 的网络带宽就可以满足并发 1 万个请求。 不过,要想真正实现 C10K 的服务器,要考虑的地方在于服务器的网络 I/O 模型,效率低的模型,会加重系统开销,从而会离 C10K 的目标越来越远。 --- ## 多进程模型 基于最原始的阻塞网络 I/O,如果服务器要支持多个客户端,其中比较传统的方式,就是使用**多进程模型**,也就是为每个客户端分配一个进程来处理请求。 服务器的主进程负责监听客户的连接,一旦与客户端连接完成,`accept()` 函数就会返回一个「已连接 Socket」,这时就通过 `fork()` 函数创建一个子进程,实际上就把父进程所有相关的东西都**复制**一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。 这两个进程刚复制完的时候,几乎一模一样。不过,会根据**返回值**来区分是父进程还是子进程,如果返回值是 0,则是子进程;如果返回值是其他的整数,就是父进程。 正因为子进程会**复制父进程的文件描述符**,于是就可以直接使用「已连接 Socket」和客户端通信了, 可以发现,子进程不需要关心「监听 Socket」,只需要关心「已连接 Socket」;父进程则相反,将客户服务交给子进程来处理,因此父进程不需要关心「已连接 Socket」,只需要关心「监听 Socket」。 下面这张图描述了从连接请求到连接建立,父进程创建生子进程为客户服务。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/操作系统/多路复用/多进程.png) 另外,当「子进程」退出时,实际上内核里还会保留该进程的一些信息,也是会占用内存的,如果不做好“回收”工作,就会变成**僵尸进程**,随着僵尸进程越多,会慢慢耗尽我们的系统资源。 因此,父进程要“善后”好自己的孩子,怎么善后呢?那么有两种方式可以在子进程退出后回收资源,分别是调用 `wait()` 和 `waitpid()` 函数。 这种用多个进程来应付多个客户端的方式,在应对 100 个客户端还是可行的,但是当客户端数量高达一万时,肯定扛不住的,因为每产生一个进程,必会占据一定的系统资源,而且进程间上下文切换的“包袱”是很重的,性能会大打折扣。 进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。 --- ## 多线程模型 既然进程间上下文切换的“包袱”很重,那我们就搞个比较轻量级的模型来应对多用户的请求 —— **多线程模型**。 线程是运行在进程中的一个“逻辑流”,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多。 当服务器与客户端 TCP 完成连接后,通过 `pthread_create()` 函数创建线程,然后将「已连接 Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。 如果每来一个连接就创建一个线程,线程运行完后,还得操作系统还得销毁线程,虽说线程切换的上下文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的。 那么,我们可以使用**线程池**的方式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若干个线程,这样当由新连接建立时,将这个已连接的 Socket 放入到一个队列里,然后线程池里的线程负责从队列中取出已连接 Socket 进程处理。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/操作系统/多路复用/线程池.png) 需要注意的是,这个队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个队列前要加锁。 上面基于进程或者线程模型的,其实还是有问题的。新到来一个 TCP 连接,就需要分配一个进程或者线程,那么如果要达到 C10K,意味着要一台机器维护 1 万个连接,相当于要维护 1 万个进程/线程,操作系统就算死扛也是扛不住的。 --- ## I/O 多路复用 既然为每个请求分配一个进程/线程的方式不合适,那有没有可能只使用一个进程来维护多个 Socket 呢?答案是有的,那就是 **I/O 多路复用**技术。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/操作系统/多路复用/多路复用.png) 一个进程虽然任意时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程,这就是多路复用,这种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用。 我们熟悉的 select/poll/epoll 是内核提供给用户态的多路复用系统调用,**进程可以通过一个系统调用函数从内核中获取多个事件**。 select/poll/epoll 是如何获取网络事件的呢?在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。 select/poll/epoll 这是三个多路复用接口,都能实现 C10K 吗?接下来,我们分别说说它们。 --- ## select/poll select 实现多路复用的方式是,将已连接的 Socket 都放到一个**文件描述符集合**,然后调用 `select()` 函数将文件描述符集合**拷贝**到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过**遍历**文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写,接着再把整个文件描述符集合**拷贝**回用户态里,然后用户态还需要再通过**遍历**的方法找到可读或可写的 Socket,然后再对其处理。 所以,对于 select 这种方式,需要进行 **2 次「遍历」文件描述符集合**,一次是在内核态里,一次是在用户态里,而且还会发生 **2 次「拷贝」文件描述符集合**,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。 select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制,默认最大值为 `1024`,只能监听 0~1023 的文件描述符。 poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。 但是 poll 和 select 并没有太大的本质区别,**都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合**,这种方式随着并发数上来,性能的损耗会呈指数级增长。 --- ## epoll epoll 通过两个方面,很好解决了 select/poll 的问题。 _第一点_,epoll 在内核里使用**红黑树来跟踪进程所有待检测的文件描述符**,把需要监控的 socket 通过 `epoll_ctl()` 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删查一般时间复杂度是 `O(logn)`,通过对这棵红黑树进行操作,这样就不需要像 select/poll 每次操作时都传入整个 socket 集合,只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。 _第二点_,epoll 使用事件驱动的机制,内核里**维护了一个链表来记录就绪事件**,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 `epoll_wait()` 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。 从下图你可以看到 epoll 相关的接口作用: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/操作系统/多路复用/epoll.png) epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,**epoll 被称为解决 C10K 问题的利器**。 插个题外话,网上文章不少说,`epoll_wait` 返回时,对于就绪的事件,epoll 使用的是共享内存的方式,即用户态和内核态都指向了就绪链表,所以就避免了内存拷贝消耗。 这是错的!看过 epoll 内核源码的都知道,**压根就没有使用共享内存这个玩意**。你可以从下面这份代码看到, `epoll_wait` 实现的内核代码中调用了 `__put_user` 函数,这个函数就是将数据从内核拷贝到用户空间。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/操作系统/多路复用/__put_user.png) 好了,这个题外话就说到这了,我们继续! epoll 支持两种事件触发模式,分别是**边缘触发(_edge-triggered,ET_)**和**水平触发(_level-triggered,LT_)**。 这两个术语还挺抽象的,其实它们的区别还是很好理解的。 - 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,**服务器端只会从 epoll_wait 中苏醒一次**,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完; - 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,**服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束**,目的是告诉我们有数据需要读取; 举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。 这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。 如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。 如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会**循环**从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,**边缘触发模式一般和非阻塞 I/O 搭配使用**,程序会一直执行 I/O 操作,直到系统调用(如 `read` 和 `write`)返回错误,错误类型为 `EAGAIN` 或 `EWOULDBLOCK`。 一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。 select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。 另外,使用 I/O 多路复用时,最好搭配非阻塞 I/O 一起使用,Linux 手册关于 select 的内容中有如下说明: > Under Linux, select() may report a socket file descriptor as "ready for reading", while nevertheless a subsequent read blocks. This could for example happen when data has arrived but upon examination has wrong checksum and is discarded. There may be other circumstances in which a file descriptor is spuriously reported as ready. Thus it may be safer to use O_NONBLOCK on sockets that should not block. 我谷歌翻译的结果: > 在 Linux 下,select() 可能会将一个 socket 文件描述符报告为 "准备读取",而后续的读取块却没有。例如,当数据已经到达,但经检查后发现有错误的校验和而被丢弃时,就会发生这种情况。也有可能在其他情况下,文件描述符被错误地报告为就绪。因此,在不应该阻塞的 socket 上使用 O_NONBLOCK 可能更安全。 简单点理解,就是**多路复用 API 返回的事件并不一定可读写的**,如果使用阻塞 I/O,那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 I/O,以便应对极少数的特殊情况。 --- ## 总结 最基础的 TCP 的 Socket 编程,它是阻塞 I/O 模型,基本上只能一对一通信,那为了服务更多的客户端,我们需要改进网络 I/O 模型。 比较传统的方式是使用多进程/线程模型,每来一个客户端连接,就分配一个进程/线程,然后后续的读写都在对应的进程/线程,这种方式处理 100 个客户端没问题,但是当客户端增大到 10000 个时,10000 个进程/线程的调度、上下文切换以及它们占用的内存,都会成为瓶颈。 为了解决上面这个问题,就出现了 I/O 的多路复用,可以只在一个进程里处理多个文件的 I/O,Linux 下有三种提供 I/O 多路复用的 API,分别是:select、poll、epoll。 select 和 poll 并没有本质区别,它们内部都是使用「线性结构」来存储进程关注的 Socket 集合。 在使用的时候,首先需要把关注的 Socket 集合通过 select/poll 系统调用从用户态拷贝到内核态,然后由内核检测事件,当有网络事件产生时,内核需要遍历进程关注 Socket 集合,找到对应的 Socket,并设置其状态为可读/可写,然后把整个 Socket 集合从内核态拷贝到用户态,用户态还要继续遍历整个 Socket 集合找到可读/可写的 Socket,然后对其处理。 很明显发现,select 和 poll 的缺陷在于,当客户端越多,也就是 Socket 集合越大,Socket 集合的遍历和拷贝会带来很大的开销,因此也很难应对 C10K。 epoll 是解决 C10K 问题的利器,通过两个方面解决了 select/poll 的问题。 - epoll 在内核里使用「红黑树」来关注进程所有待检测的 Socket,红黑树是个高效的数据结构,增删查一般时间复杂度是 O(logn),通过对这棵黑红树的管理,不需要像 select/poll 在每次操作时都传入整个 Socket 集合,减少了内核和用户空间大量的数据拷贝和内存分配。 - epoll 使用事件驱动的机制,内核里维护了一个「链表」来记录就绪事件,只将有事件发生的 Socket 集合传递给应用程序,不需要像 select/poll 那样轮询扫描整个集合(包含有和无事件的 Socket),大大提高了检测的效率。 而且,epoll 支持边缘触发和水平触发的方式,而 select/poll 只支持水平触发,一般而言,边缘触发的方式会比水平触发的效率高。 --- ## 关注作者 **_哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF_** ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/其他/公众号介绍.png) ================================================ FILE: os/8_network_system/zero_copy.md ================================================ # 9.1 什么是零拷贝? 磁盘可以说是计算机系统最慢的硬件之一,读写速度相差内存 10 倍以上,所以针对优化磁盘的技术非常的多,比如零拷贝、直接 I/O、异步 I/O 等等,这些优化的目的就是为了提高系统的吞吐量,另外操作系统内核中的磁盘高速缓存区,可以有效的减少磁盘的访问次数。 这次,我们就以「文件传输」作为切入点,来分析 I/O 工作方式,以及如何优化传输文件的性能。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E9%9B%B6%E6%8B%B7%E8%B4%9D/%E9%9B%B6%E6%8B%B7%E8%B4%9D%E6%8F%90%E7%BA%B2.png) --- ## 为什么要有 DMA 技术? 在没有 DMA 技术前,I/O 的过程是这样的: - CPU 发出对应的指令给磁盘控制器,然后返回; - 磁盘控制器收到指令后,于是就开始准备数据,会把数据放入到磁盘控制器的内部缓冲区中,然后产生一个**中断**; - CPU 收到中断信号后,停下手头的工作,接着把磁盘控制器的缓冲区的数据一次一个字节地读进自己的寄存器,然后再把寄存器里的数据写入到内存,而在数据传输的期间 CPU 是无法执行其他任务的。 为了方便你理解,我画了一副图: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E9%9B%B6%E6%8B%B7%E8%B4%9D/I_O%20%E4%B8%AD%E6%96%AD.png) 可以看到,整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。 简单的搬运几个字符数据那没问题,但是如果我们用千兆网卡或者硬盘传输大量数据的时候,都用 CPU 来搬运的话,肯定忙不过来。 计算机科学家们发现了事情的严重性后,于是就发明了 DMA 技术,也就是**直接内存访问(*Direct Memory Access*)** 技术。 什么是 DMA 技术?简单理解就是,**在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务**。 那使用 DMA 控制器进行数据传输的过程究竟是什么样的呢?下面我们来具体看看。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E9%9B%B6%E6%8B%B7%E8%B4%9D/DRM%20I_O%20%E8%BF%87%E7%A8%8B.png) 具体过程: - 用户进程调用 read 方法,向操作系统发出 I/O 请求,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态; - 操作系统收到请求后,进一步将 I/O 请求发送 DMA,然后让 CPU 执行其他任务; - DMA 进一步将 I/O 请求发送给磁盘; - 磁盘收到 DMA 的 I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知自己缓冲区已满; - **DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以执行其他任务**; - 当 DMA 读取了足够多的数据,就会发送中断信号给 CPU; - CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回; 可以看到,整个数据传输的过程,CPU 不再参与数据搬运的工作,而是全程由 DMA 完成,但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。 早期 DMA 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器。 --- ## 传统的文件传输有多糟糕? 如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。 传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。 代码通常如下,一般会需要两个系统调用: ```c read(file, tmp_buf, len); write(socket, tmp_buf, len); ``` 代码很简单,虽然就两行代码,但是这里面发生了不少的事情。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E9%9B%B6%E6%8B%B7%E8%B4%9D/%E4%BC%A0%E7%BB%9F%E6%96%87%E4%BB%B6%E4%BC%A0%E8%BE%93.png) 首先,期间共**发生了 4 次用户态与内核态的上下文切换**,因为发生了两次系统调用,一次是 `read()` ,一次是 `write()`,每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。 上下文切换的成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。 其次,还**发生了 4 次数据拷贝**,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的,下面说一下这个过程: - *第一次拷贝*,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。 - *第二次拷贝*,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。 - *第三次拷贝*,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。 - *第四次拷贝*,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。 我们回过头看这个文件传输的过程,我们只是搬运一份数据,结果却搬运了 4 次,过多的数据拷贝无疑会消耗 CPU 资源,大大降低了系统性能。 这种简单又传统的文件传输方式,存在冗余的上下文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。 所以,**要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数**。 --- ## 如何优化文件传输的性能? > 先来看看,如何减少「用户态与内核态的上下文切换」的次数呢? 读取磁盘数据的时候,之所以要发生上下文切换,这是因为用户空间没有权限操作磁盘或网卡,内核的权限最高,这些操作设备的过程都需要交由操作系统内核来完成,所以一般要通过内核去完成某些任务的时候,就需要使用操作系统提供的系统调用函数。 而一次系统调用必然会发生 2 次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由进程代码执行。 所以,**要想减少上下文切换的次数,就要减少系统调用的次数**。 > 再来看看,如何减少「数据拷贝」的次数? 在前面我们知道了,传统的文件传输方式会历经 4 次数据拷贝,而且这里面,「从内核的读缓冲区拷贝到用户的缓冲区里,再从用户的缓冲区里拷贝到 socket 的缓冲区里」,这个过程是没有必要的。 因为文件传输的应用场景中,在用户空间我们并不会对数据「再加工」,所以数据实际上可以不用搬运到用户空间,因此**用户的缓冲区是没有必要存在的**。 ---- ## 如何实现零拷贝? 零拷贝技术实现的方式通常有 2 种: - mmap + write - sendfile 下面就谈一谈,它们是如何减少「上下文切换」和「数据拷贝」的次数。 ### mmap + write 在前面我们知道,`read()` 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 `mmap()` 替换 `read()` 系统调用函数。 ```c buf = mmap(file, len); write(sockfd, buf, len); ``` `mmap()` 系统调用函数会直接把内核缓冲区里的数据「**映射**」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E9%9B%B6%E6%8B%B7%E8%B4%9D/mmap%20+%20write%20%E9%9B%B6%E6%8B%B7%E8%B4%9D.png) 具体过程如下: - 应用进程调用了 `mmap()` 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区; - 应用进程再调用 `write()`,操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据; - 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。 我们可以得知,通过使用 `mmap()` 来代替 `read()`,可以减少一次数据拷贝的过程。 但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。 ### sendfile 在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 `sendfile()`,函数形式如下: ```c #include ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count); ``` 它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。 首先,它可以替代前面的 `read()` 和 `write()` 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。 其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E9%9B%B6%E6%8B%B7%E8%B4%9D/senfile-3%E6%AC%A1%E6%8B%B7%E8%B4%9D.png) 但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(*The Scatter-Gather Direct Memory Access*)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。 你可以在你的 Linux 系统通过下面这个命令,查看网卡是否支持 scatter-gather 特性: ```bash $ ethtool -k eth0 | grep scatter-gather scatter-gather: on ``` 于是,从 Linux 内核 `2.4` 版本开始起,对于支持 SG-DMA 技术的网卡, `sendfile()` 系统调用的过程发生了点变化,具体过程如下: - 第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里; - 第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝; 所以,这个过程之中,只进行了 2 次数据拷贝,如下图: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E9%9B%B6%E6%8B%B7%E8%B4%9D/senfile-%E9%9B%B6%E6%8B%B7%E8%B4%9D.png) 这就是所谓的**零拷贝(*Zero-copy*)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。** 零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,**只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。** 所以,总体来看,**零拷贝技术可以把文件传输的性能提高至少一倍以上**。 ### 使用零拷贝技术的项目 事实上,Kafka 这个开源项目,就利用了「零拷贝」技术,从而大幅提升了 I/O 的吞吐率,这也是 Kafka 在处理海量数据为什么这么快的原因之一。 如果你追溯 Kafka 文件传输的代码,你会发现,最终它调用了 Java NIO 库里的 `transferTo` 方法: ```java @Override public long transferFrom(FileChannel fileChannel, long position, long count) throws IOException { return fileChannel.transferTo(position, count, socketChannel); } ``` 如果 Linux 系统支持 `sendfile()` 系统调用,那么 `transferTo()` 实际上最后就会使用到 `sendfile()` 系统调用函数。 曾经有大佬专门写过程序测试过,在同样的硬件条件下,传统文件传输和零拷拷贝文件传输的性能差异,你可以看到下面这张测试数据图,使用了零拷贝能够缩短 `65%` 的时间,大幅度提升了机器传输数据的吞吐量。 ![数据来源于:https://developer.ibm.com/articles/j-zerocopy/](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E9%9B%B6%E6%8B%B7%E8%B4%9D/%E9%9B%B6%E6%8B%B7%E8%B4%9D%E6%B5%8B%E8%AF%95%E6%95%B0%E6%8D%AE.png) 另外,Nginx 也支持零拷贝技术,一般默认是开启零拷贝技术,这样有利于提高文件传输的效率,是否开启零拷贝技术的配置如下: ```plain http { ... sendfile on ... } ``` sendfile 配置的具体意思: - 设置为 on 表示,使用零拷贝技术来传输文件:sendfile,这样只需要 2 次上下文切换,和 2 次数据拷贝。 - 设置为 off 表示,使用传统的文件传输技术:read + write,这时就需要 4 次上下文切换,和 4 次数据拷贝。 当然,要使用 sendfile,Linux 内核版本必须要 2.1 以上的版本。 --- ## PageCache 有什么作用? 回顾前面说道文件传输过程,其中第一步都是先需要先把磁盘文件数据拷贝「内核缓冲区」里,这个「内核缓冲区」实际上是**磁盘高速缓存(*PageCache*)**。 由于零拷贝使用了 PageCache 技术,可以使得零拷贝进一步提升了性能,我们接下来看看 PageCache 是如何做到这一点的。 读写磁盘相比读写内存的速度慢太多了,所以我们应该想办法把「读写磁盘」替换成「读写内存」。于是,我们会通过 DMA 把磁盘里的数据搬运到内存里,这样就可以用读内存替换读磁盘。 但是,内存空间远比磁盘要小,内存注定只能拷贝磁盘里的一小部分数据。 那问题来了,选择哪些磁盘数据拷贝到内存呢? 我们都知道程序运行的时候,具有「局部性」,所以通常,刚被访问的数据在短时间内再次被访问的概率很高,于是我们可以用 **PageCache 来缓存最近被访问的数据**,当空间不足时淘汰最久未被访问的缓存。 所以,读磁盘数据的时候,优先在 PageCache 找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后缓存到 PageCache 中。 还有一点,读取磁盘数据的时候,需要找到数据所在的位置,但是对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,再开始「顺序」读取数据,但是旋转磁头这个物理动作是非常耗时的,为了降低它的影响,**PageCache 使用了「预读功能」**。 比如,假设 read 方法每次只会读 `32 KB` 的字节,虽然 read 刚开始只会读 0 ~ 32 KB 的字节,但内核会把其后面的 32~64 KB 也读取到 PageCache,这样后面读取 32~64 KB 的成本就很低,如果在 32~64 KB 淘汰出 PageCache 前,进程读取到它了,收益就非常大。 所以,PageCache 的优点主要是两个: - 缓存最近被访问的数据; - 预读功能; 这两个做法,将大大提高读写磁盘的性能。 **但是,在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费 DMA 多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能** 这是因为如果你有很多 GB 级别文件需要传输,每当用户访问这些大文件的时候,内核就会把它们载入 PageCache 中,于是 PageCache 空间很快被这些大文件占满。 另外,由于文件太大,可能某些部分的文件数据被再次访问的概率比较低,这样就会带来 2 个问题: - PageCache 由于长时间被大文件占据,其他「热点」的小文件可能就无法充分使用到 PageCache,于是这样磁盘读写的性能就会下降了; - PageCache 中的大文件数据,由于没有享受到缓存带来的好处,但却耗费 DMA 多拷贝到 PageCache 一次; 所以,针对大文件的传输,不应该使用 PageCache,也就是说不应该使用零拷贝技术,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,这样在高并发的环境下,会带来严重的性能问题。 --- ## 大文件传输用什么方式实现? 那针对大文件的传输,我们应该使用什么方式呢? 我们先来看看最初的例子,当调用 read 方法读取文件时,进程实际上会阻塞在 read 方法调用,因为要等待磁盘数据的返回,如下图: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E9%9B%B6%E6%8B%B7%E8%B4%9D/%E9%98%BB%E5%A1%9E%20IO%20%E7%9A%84%E8%BF%87%E7%A8%8B.png) 具体过程: - 当调用 read 方法时,会阻塞着,此时内核会向磁盘发起 I/O 请求,磁盘收到请求后,便会寻址,当磁盘数据准备好后,就会向内核发起 I/O 中断,告知内核磁盘数据已经准备好; - 内核收到 I/O 中断后,就将数据从磁盘控制器缓冲区拷贝到 PageCache 里; - 最后,内核再把 PageCache 中的数据拷贝到用户缓冲区,于是 read 调用就正常返回了。 对于阻塞的问题,可以用异步 I/O 来解决,它工作方式如下图: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost2/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F/%E9%9B%B6%E6%8B%B7%E8%B4%9D/%E5%BC%82%E6%AD%A5%20IO%20%E7%9A%84%E8%BF%87%E7%A8%8B.png) 它把读操作分为两部分: - 前半部分,内核向磁盘发起读请求,但是可以**不等待数据就位就可以返回**,于是进程此时可以处理其他任务; - 后半部分,当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的**通知**,再去处理数据; 而且,我们可以发现,异步 I/O 并没有涉及到 PageCache,所以使用异步 I/O 就意味着要绕开 PageCache。 绕开 PageCache 的 I/O 叫直接 I/O,使用 PageCache 的 I/O 则叫缓存 I/O。通常,对于磁盘,异步 I/O 只支持直接 I/O。 前面也提到,大文件的传输不应该使用 PageCache,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache。 于是,**在高并发的场景下,针对大文件的传输的方式,应该使用「异步 I/O + 直接 I/O」来替代零拷贝技术**。 直接 I/O 应用场景常见的两种: - 应用程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损耗。在 MySQL 数据库中,可以通过参数设置开启直接 I/O,默认是不开启; - 传输大文件的时候,由于大文件难以命中 PageCache 缓存,而且会占满 PageCache 导致「热点」文件无法充分利用缓存,从而增大了性能开销,因此,这时应该使用直接 I/O。 另外,由于直接 I/O 绕过了 PageCache,就无法享受内核的这两点的优化: - 内核的 I/O 调度算法会缓存尽可能多的 I/O 请求在 PageCache 中,最后「**合并**」成一个更大的 I/O 请求再发给磁盘,这样做是为了减少磁盘的寻址操作; - 内核也会「**预读**」后续的 I/O 请求放在 PageCache 中,一样是为了减少对磁盘的操作; 于是,传输大文件的时候,使用「异步 I/O + 直接 I/O」了,就可以无阻塞地读取文件了。 所以,传输文件的时候,我们要根据文件的大小来使用不同的方式: - 传输大文件的时候,使用「异步 I/O + 直接 I/O」; - 传输小文件的时候,则使用「零拷贝技术」; 在 nginx 中,我们可以用如下配置,来根据文件的大小来使用不同的方式: ```plain location /video/ { sendfile on; aio on; directio 1024m; } ``` 当文件大小大于 `directio` 值后,使用「异步 I/O + 直接 I/O」,否则使用「零拷贝技术」。 --- ## 总结 早期 I/O 操作,内存与磁盘的数据传输的工作都是由 CPU 完成的,而此时 CPU 不能执行其他任务,会特别浪费 CPU 资源。 于是,为了解决这一问题,DMA 技术就出现了,每个 I/O 设备都有自己的 DMA 控制器,通过这个 DMA 控制器,CPU 只需要告诉 DMA 控制器,我们要传输什么数据,从哪里来,到哪里去,就可以放心离开了。后续的实际数据传输工作,都会由 DMA 控制器来完成,CPU 不需要参与数据传输的工作。 传统 IO 的工作方式,从硬盘读取数据,然后再通过网卡向外发送,我们需要进行 4 上下文切换,和 4 次数据拷贝,其中 2 次数据拷贝发生在内存里的缓冲区和对应的硬件设备之间,这个是由 DMA 完成,另外 2 次则发生在内核态和用户态之间,这个数据搬移工作是由 CPU 完成的。 为了提高文件传输的性能,于是就出现了零拷贝技术,它通过一次系统调用(`sendfile` 方法)合并了磁盘读取与网络发送两个操作,降低了上下文切换次数。另外,拷贝数据都是发生在内核中的,天然就降低了数据拷贝的次数。 Kafka 和 Nginx 都有实现零拷贝技术,这将大大提高文件传输的性能。 零拷贝技术是基于 PageCache 的,PageCache 会缓存最近访问的数据,提升了访问缓存数据的性能,同时,为了解决机械硬盘寻址慢的问题,它还协助 I/O 调度算法实现了 IO 合并与预读,这也是顺序读比随机读性能好的原因。这些优势,进一步提升了零拷贝的性能。 需要注意的是,零拷贝技术是不允许进程对文件内容作进一步的加工的,比如压缩数据再发送。 另外,当传输大文件时,不能使用零拷贝,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,并且大文件的缓存命中率不高,这时就需要使用「异步 IO + 直接 IO」的方式。 在 Nginx 里,可以通过配置,设定一个文件大小阈值,针对大文件使用异步 IO 和直接 IO,而对小文件使用零拷贝。 ================================================ FILE: os/9_linux_cmd/linux_network.md ================================================ # 10.1 如何查看网络的性能指标? Linux 网络协议栈是根据 TCP/IP 模型来实现的,TCP/IP 模型由应用层、传输层、网络层和网络接口层,共四层组成,每一层都有各自的职责。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/操作系统/浮点/封装.png) 应用程序要发送数据包时,通常是通过 socket 接口,于是就会发生系统调用,把应用层的数据拷贝到内核里的 socket 层,接着由网络协议栈从上到下逐层处理后,最后才会送到网卡发送出去。 而对于接收网络包时,同样也要经过网络协议逐层处理,不过处理的方向与发送数据时是相反的,也就是从下到上的逐层处理,最后才送到应用程序。 网络的速度往往跟用户体验是挂钩的,那我们又该用什么指标来衡量 Linux 的网络性能呢?以及如何分析网络问题呢? 这次,我们就来说这些。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/操作系统/网络/网络提纲.png) --- ## 性能指标有哪些? 通常是以 4 个指标来衡量网络的性能,分别是带宽、延时、吞吐率、PPS(Packet Per Second),它们表示的意义如下: - *带宽*,表示链路的最大传输速率,单位是 b/s(比特 / 秒),带宽越大,其传输能力就越强。 - *延时*,表示请求数据包发送后,收到对端响应,所需要的时间延迟。不同的场景有着不同的含义,比如可以表示建立 TCP 连接所需的时间延迟,或一个数据包往返所需的时间延迟。 - *吞吐率*,表示单位时间内成功传输的数据量,单位是 b/s(比特 / 秒)或者 B/s(字节 / 秒),吞吐受带宽限制,带宽越大,吞吐率的上限才可能越高。 - *PPS*,全称是 Packet Per Second(包 / 秒),表示以网络包为单位的传输速率,一般用来评估系统对于网络的转发能力。 当然,除了以上这四种基本的指标,还有一些其他常用的性能指标,比如: - *网络的可用性*,表示网络能否正常通信; - *并发连接数*,表示 TCP 连接数量; - *丢包率*,表示所丢失数据包数量占所发送数据组的比率; - *重传率*,表示重传网络包的比例; 你可能会问了,如何观测这些性能指标呢?不急,继续往下看。 --- ## 网络配置如何看? 要想知道网络的配置和状态,我们可以使用 `ifconfig` 或者 `ip` 命令来查看。 这两个命令功能都差不多,不过它们属于不同的软件包,`ifconfig` 属于 `net-tools` 软件包,`ip` 属于 `iproute2` 软件包,我的印象中 `net-tools` 软件包没有人继续维护了,而 `iproute2` 软件包是有开发者依然在维护,所以更推荐你使用 `ip` 工具。 学以致用,那就来使用这两个命令,来查看网口 `eth0` 的配置等信息: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/操作系统/网络/showeth0.png) 虽然这两个命令输出的格式不尽相同,但是输出的内容基本相同,比如都包含了 IP 地址、子网掩码、MAC 地址、网关地址、MTU 大小、网口的状态以及网路包收发的统计信息,下面就来说说这些信息,它们都与网络性能有一定的关系。 第一,网口的连接状态标志。其实也就是表示对应的网口是否连接到交换机或路由器等设备,如果 `ifconfig` 输出中看到有 `RUNNING`,或者 `ip` 输出中有 `LOWER_UP`,则说明物理网路是连通的,如果看不到,则表示网口没有接网线。 第二,MTU 大小。默认值是 `1500` 字节,其作用主要是限制网络包的大小,如果 IP 层有一个数据报要传,而且数据帧的长度比链路层的 MTU 还大,那么 IP 层就需要进行分片,即把数据报分成若干片,这样每一片就都小于 MTU。事实上,每个网络的链路层 MTU 可能会不一样,所以你可能需要调大或者调小 MTU 的数值。 第三,网口的 IP 地址、子网掩码、MAC 地址、网关地址。这些信息必须要配置正确,网络功能才能正常工作。 第四,网路包收发的统计信息。通常有网络收发的字节数、包数、错误数以及丢包情况的信息,如果 `TX`(发送)和 `RX`(接收)部分中 errors、dropped、overruns、carrier 以及 collisions 等指标不为 0 时,则说明网络发送或者接收出问题了,这些出错统计信息的指标意义如下: - *errors* 表示发生错误的数据包数,比如校验错误、帧同步错误等; - *dropped* 表示丢弃的数据包数,即数据包已经收到了 Ring Buffer(这个缓冲区是在内核内存中,更具体一点是在网卡驱动程序里),但因为系统内存不足等原因而发生的丢包; - *overruns* 表示超限数据包数,即网络接收/发送速度过快,导致 Ring Buffer 中的数据包来不及处理,而导致的丢包,因为过多的数据包挤压在 Ring Buffer,这样 Ring Buffer 很容易就溢出了; - *carrier* 表示发生 carrirer 错误的数据包数,比如双工模式不匹配、物理电缆出现问题等; - *collisions* 表示冲突、碰撞数据包数; `ifconfig` 和 `ip` 命令只显示的是网口的配置以及收发数据包的统计信息,而看不到协议栈里的信息,那接下来就来看看如何查看协议栈里的信息。 --- ## socket 信息如何查看? 我们可以使用 `netstat` 或者 `ss`,这两个命令查看 socket、网络协议栈、网口以及路由表的信息。 虽然 `netstat` 与 `ss` 命令查看的信息都差不多,但是如果在生产环境中要查看这类信息的时候,尽量不要使用 `netstat` 命令,因为它的性能不好,在系统比较繁忙的情况下,如果频繁使用 `netstat` 命令则会对性能的开销雪上加霜,所以更推荐你使用性能更好的 `ss` 命令。 从下面这张图,你可以看到这两个命令的输出内容: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/操作系统/网络/showsocket.png) 可以发现,输出的内容都差不多,比如都包含了 socket 的状态(*State*)、接收队列(*Recv-Q*)、发送队列(*Send-Q*)、本地地址(*Local Address*)、远端地址(*Foreign Address*)、进程 PID 和进程名称(*PID/Program name*)等。 接收队列(*Recv-Q*)和发送队列(*Send-Q*)比较特殊,在不同的 socket 状态。它们表示的含义是不同的。 当 socket 状态处于 `Established`时: - *Recv-Q* 表示 socket 缓冲区中还没有被应用程序读取的字节数; - *Send-Q* 表示 socket 缓冲区中还没有被远端主机确认的字节数; 而当 socket 状态处于 `Listen` 时: - *Recv-Q* 表示全连接队列的长度; - *Send-Q* 表示全连接队列的最大长度; 在 TCP 三次握手过程中,当服务器收到客户端的 SYN 包后,内核会把该连接存储到半连接队列,然后再向客户端发送 SYN+ACK 包,接着客户端会返回 ACK,服务端收到第三次握手的 ACK 后,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其增加到全连接队列,等待进程调用 `accept()` 函数时把连接取出来。 ![半连接队列与全连接队列](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost/%E8%AE%A1%E7%AE%97%E6%9C%BA%E7%BD%91%E7%BB%9C/TCP-%E5%8D%8A%E8%BF%9E%E6%8E%A5%E5%92%8C%E5%85%A8%E8%BF%9E%E6%8E%A5/3.jpg) 也就说,全连接队列指的是服务器与客户端完了 TCP 三次握手后,还没有被 `accept()` 系统调用取走连接的队列。 那对于协议栈的统计信息,依然还是使用 `netstat` 或 `ss`,它们查看统计信息的命令如下: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/操作系统/网络/showinfo.png) `ss` 命令输出的统计信息相比 `netsat` 比较少,`ss` 只显示已经连接(*estab*)、关闭(*closed*)、孤儿(*orphaned*)socket 等简要统计。 而 `netstat` 则有更详细的网络协议栈信息,比如上面显示了 TCP 协议的主动连接(*active connections openings*)、被动连接(*passive connection openings*)、失败重试(*failed connection attempts*)、发送(*segments send out*)和接收(*segments received*)的分段数量等各种信息。 --- ## 网络吞吐率和 PPS 如何查看? 可以使用 `sar` 命令当前网络的吞吐率和 PPS,用法是给 `sar` 增加 `-n` 参数就可以查看网络的统计信息,比如 - sar -n DEV,显示网口的统计数据; - sar -n EDEV,显示关于网络错误的统计数据; - sar -n TCP,显示 TCP 的统计数据 比如,我通过 `sar` 命令获取了网口的统计信息: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/操作系统/网络/sar.png) 它们的含义: - `rxpck/s` 和 `txpck/s` 分别是接收和发送的 PPS,单位为包 / 秒。 - `rxkB/s` 和 `txkB/s` 分别是接收和发送的吞吐率,单位是 KB/ 秒。 - `rxcmp/s` 和 `txcmp/s` 分别是接收和发送的压缩数据包数,单位是包 / 秒。 对于带宽,我们可以使用 `ethtool` 命令来查询,它的单位通常是 `Gb/s` 或者 `Mb/s`,不过注意这里小写字母 `b` ,表示比特而不是字节。我们通常提到的千兆网卡、万兆网卡等,单位也都是比特(*bit*)。如下你可以看到,eth0 网卡就是一个千兆网卡: ```bash $ ethtool eth0 | grep Speed Speed: 1000Mb/s ``` --- ## 连通性和延时如何查看? 要测试本机与远程主机的连通性和延时,通常是使用 `ping` 命令,它是基于 ICMP 协议的,工作在网络层。 比如,如果要测试本机到 `192.168.12.20` IP 地址的连通性和延时: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/操作系统/网络/ping.png) 显示的内容主要包含 `icmp_seq`(ICMP 序列号)、`TTL`(生存时间,或者跳数)以及 `time` (往返延时),而且最后会汇总本次测试的情况,如果网络没有丢包,`packet loss` 的百分比就是 0。 不过,需要注意的是,`ping` 不通服务器并不代表 HTTP 请求也不通,因为有的服务器的防火墙是会禁用 ICMP 协议的。 --- ## 关注作者 ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF*** ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/其他/公众号介绍.png) ================================================ FILE: os/9_linux_cmd/pv_uv.md ================================================ # 10.2 如何从日志分析 PV、UV? 很多时候,我们观察程序是否如期运行,或者是否有错误,最直接的方式就是看运行**日志**,当然要想从日志快速查到我们想要的信息,前提是程序打印的日志要精炼、精准。 但日志涵盖的信息远不止于此,比如对于 nginx 的 access.log 日志,我们可以根据日志信息**分析用户行为**。 什么用户行为呢?比如分析出哪个页面访问次数(*PV*)最多,访问人数(*UV*)最多,以及哪天访问量最多,哪个请求访问最多等等。 这次,将用一个大概几万条记录的 nginx 日志文件作为案例,一起来看看如何分析出「用户信息」。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/网络/log/提纲日志.png) --- ## 别急着开始 当我们要分析日志的时候,先用 `ls -lh` 命令查看日志文件的大小,如果日志文件大小非常大,最好不要在线上环境做。 比如我下面这个日志就 6.5M,不算大,在线上环境分析问题不大。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/网络/log/ls.png) 如果日志文件数据量太大,你直接一个 `cat` 命令一执行,是会影响线上环境,加重服务器的负载,严重的话,可能导致服务器无响应。 当发现日志很大的时候,我们可以使用 `scp` 命令将文件传输到闲置的服务器再分析,scp 命令使用方式如下图: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/网络/log/scp.png) --- ## 慎用 cat 大家都知道 `cat` 命令是用来查看文件内容的,但是日志文件数据量有多少,它就读多少,很显然不适用大文件。 对于大文件,我们应该养成好习惯,用 `less` 命令去读文件里的内容,因为 less 并不会加载整个文件,而是按需加载,先是输出一小页的内容,当你要往下看的时候,才会继续加载。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/网络/log/less.png) 可以发现,nginx 的 access.log 日志每一行是一次用户访问的记录,从左到右分别包含如下信息: - 客户端的 IP 地址; - 访问时间; - HTTP 请求的方法、路径、协议版本、协议版本、返回的状态码; - User Agent,一般是客户端使用的操作系统以及版本、浏览器及版本等; 不过,有时候我们想看日志最新部分的内容,可以使用 `tail` 命令,比如当你想查看倒数 5 行的内容,你可以使用这样的命令: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/网络/log/tail-n.png) 如果你想实时看日志打印的内容,你可以使用 `tail -f` 命令,这样你看日志的时候,就会是阻塞状态,有新日志输出的时候,就会实时显示出来。 --- ## PV 分析 PV 的全称叫 *Page View*,用户访问一个页面就是一次 PV,比如大多数博客平台,点击一次页面,阅读量就加 1,所以说 PV 的数量并不代表真实的用户数量,只是个点击量。 对于 nginx 的 `acess.log` 日志文件来说,分析 PV 还是比较容易的,既然日志里的内容是访问记录,那有多少条日志记录就有多少 PV。 我们直接使用 `wc -l` 命令,就可以查看整体的 PV 了,如下图一共有 49903 条 PV。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/网络/log/wc.png) --- ## PV 分组 nginx 的 `acess.log` 日志文件有访问时间的信息,因此我们可以根据访问时间进行分组,比如按天分组,查看每天的总 PV,这样可以得到更加直观的数据。 要按时间分组,首先我们先「访问时间」过滤出来,这里可以使用 awk 命令来处理,awk 是一个处理文本的利器。 awk 命令默认是以「空格」为分隔符,由于访问时间在日志里的第 4 列,因此可以使用 `awk '{print $4}' access.log` 命令把访问时间的信息过滤出来,结果如下: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/网络/log/awk日期.png) 上面的信息还包含了时分秒,如果只想显示年月日的信息,可以使用 `awk` 的 `substr` 函数,从第 2 个字符开始,截取 11 个字符。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/网络/log/awk日期2.png) 接着,我们可以使用 `sort` 对日期进行排序,然后使用 `uniq -c` 进行统计,于是按天分组的 PV 就出来了。 可以看到,每天的 PV 量大概在 2000-2800: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/网络/log/awkpv.png) 注意,**使用 `uniq -c` 命令前,先要进行 `sort` 排序**,因为 uniq 去重的原理是比较相邻的行,然后除去第二行和该行的后续副本,因此在使用 uniq 命令之前,请使用 sort 命令使所有重复行相邻。 --- ## UV 分析 UV 的全称是 *Uniq Visitor*,它代表访问人数,比如公众号的阅读量就是以 UV 统计的,不管单个用户点击了多少次,最终只算 1 次阅读量。 access.log 日志里虽然没有用户的身份信息,但是我们可以用「客户端 IP 地址」来**近似统计** UV。 ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/网络/log/uv.png) 该命令的输出结果是 2589,也就说明 UV 的量为 2589。上图中,从左到右的命令意思如下: - `awk '{print $1}' access.log`,取日志的第 1 列内容,客户端的 IP 地址正是第 1 列; - `sort`,对信息排序; - `uniq`,去除重复的记录; - `wc -l`,查看记录条数; --- ## UV 分组 假设我们按天来分组分析每天的 UV 数量,这种情况就稍微比较复杂,需要比较多的命令来实现。 既然要按天统计 UV,那就得把「日期 + IP 地址」过滤出来,并去重,命令如下: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/网络/log/uv分组.png) 具体分析如下: - 第一次 `ack` 是将第 4 列的日期和第 1 列的客户端 IP 地址过滤出来,并用空格拼接起来; - 然后 `sort` 对第一次 ack 输出的内容进行排序; - 接着用 `uniq` 去除重复的记录,也就说日期 +IP 相同的行就只保留一个; 上面只是把 UV 的数据列了出来,但是并没有统计出次数。 如果需要对当天的 UV 统计,在上面的命令再拼接 `awk '{uv[$1]++;next}END{for (ip in uv) print ip, uv[ip]}'` 命令就可以了,结果如下图: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/网络/log/awknext.png) awk 本身是「逐行」进行处理的,当执行完一行后,我们可以用 `next` 关键字来告诉 awk 跳转到下一行,把下一行作为输入。 对每一行输入,awk 会根据第 1 列的字符串(也就是日期)进行累加,这样相同日期的 ip 地址,就会累加起来,作为当天的 uv 数量。 之后的 `END` 关键字代表一个触发器,就是当前面的输入全部完成后,才会执行 END {} 中的语句,END 的语句是通过 foreach 遍历 uv 中所有的 key,打印出按天分组的 uv 数量。 --- ## 终端分析 nginx 的 access.log 日志最末尾关于 User Agent 的信息,主要是客户端访问服务器使用的工具,可能是手机、浏览器等。 因此,我们可以利用这一信息来分析有哪些终端访问了服务器。 User Agent 的信息在日志里的第 12 列,因此我们先使用 `awk` 过滤出第 12 列的内容后,进行 `sort` 排序,再用 `uniq -c` 去重并统计,最后再使用 `sort -rn`(*r 表示逆向排序,n 表示按数值排序*)对统计的结果排序,结果如下图: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/网络/log/terminal.png) --- ## 分析 TOP3 的请求 access.log 日志中,第 7 列是客户端请求的路径,先使用 `awk` 过滤出第 7 列的内容后,进行 `sort` 排序,再用 `uniq -c` 去重并统计,然后再使用 `sort -rn` 对统计的结果排序,最后使用 `head -n 3` 分析 TOP3 的请求,结果如下图: ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost4@main/网络/log/TOP3.png) --- ## 关注作者 ***哈喽,我是小林,就爱图解计算机基础,如果觉得文章对你有帮助,欢迎微信搜索「小林 coding」,关注后,回复「网络」再送你图解网络 PDF*** ![](https://cdn.jsdelivr.net/gh/xiaolincoder/ImageHost3@main/其他/公众号介绍.png) ================================================ FILE: os/README.md ================================================ # 图解系统介绍 大家好,我是小林,是《图解系统》的作者,本站的内容都是整理于我[公众号](https://mp.weixin.qq.com/s/FYH1I8CRsuXDSybSGY_AFA)里的图解文章。 还没关注的朋友,可以微信搜索「**小林 coding**」,关注我的公众号,**后续最新版本的 PDF 会在我的公众号第一时间发布**,而且会有更多其他系列的图解文章,比如操作系统、计算机组成、数据库、算法等等。 简单介绍下这个《图解系统》,整个内容共有 **`16W` 字 + `400` 张图**,文字都是小林一个字一个字敲出来的,图片都是小林一个点一条线画出来的,非常的不容易。 ![在这里插入图片描述](https://img-blog.csdnimg.cn/38c89e02026a4c1e8b98ed0a9ee6cb44.png) 图解系统不仅仅涉及了操作系统的内容,还涉及一些计算机组成和 Linux 命令的内容, 当然还是操作系统的内容占比较高,基本把操作系统**进程管理、内存管理、文件系统、设备管理、网络系统**这五大结构图解了,其中进程管理和网络系统这两个章节篇幅比较多,进程管理不仅包含了进程与线程的基本知识,还包含了进程间通信,多线程同步、死锁、悲观锁和乐观锁。网络系统包含 I/O 多路复用、零拷贝、Reactor 等等。 计算机组成主要涉及是 CPU 方面的知识,我们不关注 CPU 是怎么设计与实现的,**只关注跟我们开发者有关系的 CPU 知识**,比如 CPU 执行程序的原理,CPU 缓存,CPU 伪共享等等,这些看似跟我们开发者无关,实际上关系挺大的,只有了解 CPU 缓存才能写出更快的代码,只要了解 CPU 伪共享才能避免写出无效缓存的代码。 至于 Linux 命令的章节暂时内容没有很多,主要就写了如何用 Linux 命令「查看网络指标」和「从日志分析 PV、UV」,之所以没有写太多是觉得命令类的文章没办法体现出小林的图解功力,再加上这类命令一般网上资源也很多,工作中遇到需要使用某个命令时,去搜索了解并自己体验了一番后,才会比较深刻,单纯只看文章很容易就忘记这些命令了。 ## 小白适合看吗? 《图解系统》不是单纯的面经,而是相对比较系统化的内容,当然小林所写的内容是操作系统的重点知识,也是面试常问的知识点。 我觉得相比背零零散散的面经,更建议你学好整个操作系统的知识体系,后面你在看面经的时候,你会发觉这些只不过是这颗知识树中的一个小分支,而且延展性会更好。 操作系统是很容易让小白畏惧一门课,因为不管哪本操作系统书都是厚厚的,就会觉得操作系统东西太多,而且也不容易看懂,每个字我们能得懂,但是连成一句话就看懵了。 其实小林当时在入门操作系统的时候,也是跟大家感受一样的,谁不是从小白度过过来的呢? 之前我花了很多时间看书和看视频,学好操作系统后,我就在想能不能写一份帮助大家快速入门操作系统系统文章呢,于是就开始踏上了图解之路,**用精美的图片打破大家对操作系统的畏惧感**。 事实证明,图解系列是正确的,在公众号连续写了很多篇图解系统的文章后,收到了非常多读者的支持与认可,有反馈以前大学没学会的,然后看了我的文章突然就醒悟了,也有反馈面试前突击了我的文章,然后拿到了心意的 offer。 所以,这份图解系统适合小白学习,也可以当作面试突击用的手册。 不过,再怎么吹我的《图解系统》,如果大家想要系统化全面的学习操作系统,自然还是离不开书的,《图解系统》的末尾会有我学习操作系统的心得,会推荐我看过并且认为不错的书和视频,大家可以留意一下。 ## 要怎么阅读? 很诚恳的告诉你,《图解系统》不是教科书。而是我在公众号里写的图解系统文章的整合,所以肯定是没有教科书那么细致和全面,当然也就不会有很多废话,都是直击重点,不绕弯,而且有的知识点书上看不到。 阅读的顺序可以不用从头读到尾,你可以根据你想要了解的知识点,通过本站的搜索功能,去看哪个章节的文章就好,可以随意阅读任何章节。 本站的左侧菜单就是《图解系统》的目录结构(别看篇章不多,每一章都是很长很长的文章哦 :laughing:): - **硬件结构** :point_down: - [CPU 是如何执行程序的?](/os/1_hardware/how_cpu_run.md) - [磁盘比内存慢几万倍?](/os/1_hardware/storage.md) - [如何写出让 CPU 跑得更快的代码?](/os/1_hardware/how_to_make_cpu_run_faster.md) - [CPU 缓存一致性](/os/1_hardware/cpu_mesi.md) - [CPU 是如何执行任务的?](/os/1_hardware/how_cpu_deal_task.md) - [什么是软中断?](/os/1_hardware/soft_interrupt.md) - [为什么 0.1 + 0.2 不等于 0.3?](/os/1_hardware/float.md) - **操作系统结构** :point_down: - [Linux 内核 vs Windows 内核](/os/2_os_structure/linux_vs_windows.md) - **内存管理** :point_down: - [为什么要有虚拟内存?](/os/3_memory/vmem.md) - [malloc 是如何分配内存的?](/os/3_memory/malloc.md) - [内存满了,会发生什么?](/os/3_memory/mem_reclaim.md) - [在 4GB 物理内存的机器上,申请 8G 内存会怎么样?](/os/3_memory/alloc_mem.md) - [如何避免预读失效和缓存污染的问题?](/os/3_memory/cache_lru.md) - [深入理解 Linux 虚拟内存管理](/os/3_memory/linux_mem.md) - **进程管理** :point_down: - [进程、线程基础知识](/os/4_process/process_base.md) - [进程间有哪些通信方式?](/os/4_process/process_commu.md) - [多线程冲突了怎么办?](/os/4_process/multithread_sync.md) - [怎么避免死锁?](/os/4_process/deadlock.md) - [什么是悲观锁、乐观锁?](/os/4_process/pessim_and_optimi_lock.md) - [一个进程最多可以创建多少个线程?](/os/4_process/create_thread_max.md) - [线程崩溃了,进程也会崩溃吗?](/os/4_process/thread_crash.md) - **调度算法** :point_down: - [进程调度/页面置换/磁盘调度算法](/os/5_schedule/schedule.md) - **文件系统** :point_down: - [文件系统全家桶](/os/6_file_system/file_system.md) - [进程写文件时,进程发生了崩溃,已写入的数据会丢失吗?](/os/6_file_system/pagecache.md) - **设备管理** :point_down: - [键盘敲入 A 字母时,操作系统期间发生了什么?](/os/7_device/device.md) - **网络系统** :point_down: - [什么是零拷贝?](/os/8_network_system/zero_copy.md) - [I/O 多路复用:select/poll/epoll](/os/8_network_system/selete_poll_epoll.md) - [高性能网络模式:Reactor 和 Proactor](/os/8_network_system/reactor.md) - [什么是一致性哈希?](/os/8_network_system/hash.md) - **Linux 命令** :point_down: - [如何查看网络的性能指标?](/os/9_linux_cmd/linux_network.md) - [如何从日志分析 PV、UV?](/os/9_linux_cmd/pv_uv.md) - **学习心得** :point_down: - [操作系统怎么学?](/os/10_learn/learn_os.md) - [画图经验分享](/os/10_learn/draw.md) ## 有错误怎么办? 小林是个手残党,时常写出错别字。 如果你在学习的过程中,**如果你发现有任何错误或者疑惑的地方,欢迎你通过邮箱或者底部留言给小林**,勘误邮箱:xiaolincoding@163.com 小林抽时间会逐个修正,然后发布新版本的图解系统 PDF,一起迭代出更好的图解系统! 新的图解文章都在公众号首发,别忘记关注了哦!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/其他/公众号介绍.png) ================================================ FILE: reader_nb/1_reader.md ================================================ # 非科班本科拿下年薪 35w+ 的 offer 大家好,我是小林哥。 经常会有读者跟小林发私信,说我的图解系统和图解网络在面试中帮到他们,而且不少都是拿到了一线的大厂。 于是,我就在想,能不能邀请这些优秀的读者来分享他面试心得和学习经验。 因为小林读者里肯定还有很多是在校生,我相信这些过来人的经验会帮助到你们,也让大家能提前知道如何准备,少走一些弯路,磨平校招的信息差,不至于到大四才知道校招这些事情。 说干就干,然后就有了这个「***读者牛逼系列\***」,真心希望这里的每一位牛逼读者的经历,能够影响到你,给你带来启发和价值。 今天来跟大家分享的是一位我影响很深刻的读者,这位读者称呼为**小李**,他是小林的老读者了,从去年就一直关注我,时常问我一些学习方向和选择的事情。 小李是双非本科 + 非科班,通过自己的学习,校招时共投递了上百家公司,中途相当坎坷,被打击过,也失落过,但是他并没有因此一蹶不振,而是继续坚持自学下去,他相信努力是有用,最终他**从最开始月薪 5k offer 成功换成年总包 35w+ 的 offer**。 这简直绝了,妥妥就是一个普通人逆袭的例子,还竟然是小林的读者,实在是我的荣幸。 他的经历写的很长,我认认真真看完了,感触很深,看的出是个很认真的小伙子,大家可以耐心把这篇文章看完,相信你会收获很多。 ![在这里插入图片描述](https://img-blog.csdnimg.cn/355861dfbf6e4a2db280722cbb99b2b5.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5bCP5p6XY29kaW5n,size_20,color_FFFFFF,t_70,g_se,x_16#pic_center) *PS:以下内容来自小李读者的表述。* ------ ## 校招路程 伴随着拿到一份满意的 offer,令人折磨的校招终于告一阶段了…… 我的校招经历应该是非常坎坷的一段经历,但是应该能够给其他还在迷茫的小伙伴一点帮助,所以在小林哥的鼓励下我决定将我的校招经历和我的学习路程以及经验还有一点这段时间的感悟分享给大家,如果能对大家起到一点微小的帮助是最好的事情。 先说一下我自己的校招经历吧。 我自己是一名双非非科班的学生,高考的时候报「软件工程」这个专业但是滑档了,分到了一个自己不喜欢的专业,又没有提前做职业规划,导致到了大学之后浑浑噩噩了三年。 一直混到了大三下学期,因为疫情的原因在家里上网课,在六月份左右的一天,我突然觉得我不可能这样一直混到毕业吧?那我以后能干什么呢? 认真思考了几天,**觉得不能这样子下去了**,但是又不喜欢本专业,因为我一直对计算机比较感兴趣,所以最后想着干脆去当程序员吧。 说干就干,我从七月份开始买了一本《大话数据结构》和一本谢希仁的《计算机网络》还有一本《C++ primer》就开始看起来了,当时也没有做任何规划,也是一时兴起,所以就三天打鱼两天晒网的开始学起来了(*真的很建议大家在不论做什么事情之间做一个详细的规划,这非常非常重要!*)。 然后一晃眼就到了九月份,开学我到了学校还要先补考两门课程(*混子的悲哀……大学千万不要挂科!*),然后补考完就到了九月中旬了,我匆匆制作了份简历,然后就开始投简历,基本上都是石沉大海,**只有一份面试机会**,而这个面试也是让我真正改变的契机。 我还记得这个面试是来自「深信服」的,甚至到现在我都记得当时那个面试官的长相,整场面试三十分钟,我说的最多的三个词是「不了解」、「不清楚」、「不太懂」……基本上是一问三不知。 甚至最后面试官想捞我一手,问我们专业还学什么课程,我说模电、数电,面试官说:额……好吧……你有什么想问我的吗? 我都结结巴巴说不出话来。面试的过程中我真的超级超级羞愧,刚开始面了五分钟我就想结束面试了,真的是什么都不会,真的说不出来的丢人,我同学说我当时整个人的脸都涨红了。 当天晚上我躺在床上羞愧又难过,都睡不着觉,我不断地问自己:「你这么菜哪来的脸投简历?」 下定决定开始**停止投简历**,好好充实自己。 正赶上当当做活动,我一口气买了一千多元的书,从 C++、数据结构与算法、计算机网络、操作系统还有 Linux 相关的全部买了个齐全。 从此开始了每天疯狂学习的道路,每天基本上**睁眼学到闭眼**,梦里都是知识,我觉得我自己在计算机这方面还是有一点悟性的吧,再加上一点韧性,所以我学起来很快。 我尤其喜欢 C++ 相关的知识,当时觉得学累了就看看 C++ 相关的书籍充一充电。 就这样充实的生活学了一个月左右,正好十月中旬学校有线下校招,我就去试了试水,面试比较简单,很快就下了 offer,我还记得好像是 base 长沙月薪 5k+,其他的待遇就忘记了。 我当时的心态就是觉得啊?不是吧?我就值这个价吗? 还难过了一段时间,然后想都没想就直接拒了,觉得还是自己太菜了,然后**继续投入到学习当中**了。 到了 11 月初学校又有线下校招,我又过去试了一下。最后拿到了 base 上海月薪 10k+,我还记得岗位是叫「MMI 助理开发工程师」,我当时都不知道这个是干什么的,待遇觉得还挺好的,因为我们专业以前的毕业生都是能拿到 6—8k 都算是不错的了。但是最后犹豫了一下,还是拒绝了,**觉得这应该不是我的极限**。 一直保持学习到 12 月初,突然某手机大厂联系我面试,说实话都我不知道什么时候投的简历了,应该是秋招补招把我捞上来了。 当时我整体学的差不多了,蛮久没面试了,所以我就试了试,几轮面试下来我基本上侃侃而谈,不会的东西很少,所以很快 offer 就下来了,base 北京月薪 15k+,犹豫了蛮久的,最后考虑到秋招末期了而且有大厂的名头,最后就接了 offer 了。 当时心里想着拿一个保底,然后还想着再好好学一学,春招再冲一冲。 结果放假回家两个月基本没怎么学习,因为过年家里事挺多的,也没有一个好的环境,自己的内心也比较浮躁。 到了三月初又补考了一门课程,然后发现自己根本学不进去了,最后就决定去公司提前实习了。 到了公司以后,感觉大厂确实是大厂,各方面真的很不错,但是呆了一段时间之后发现部门的业务和主要技术和我期待的有偏差。 所以一边实习一边学习,最后在四月初离职回到学习继续开始春招,**最后成功拿到了一份 base 北京总包 35+ 的 offer**。 我自己挺满意的,而且因为我学习的时间太短了,前前后后可能有效学习就大半年左右,最后能拿到这样的 offer 我也感到非常庆幸,最重要的是技术方向也是我比较喜欢的方向。 这里提一下违约的事,我之前签的那个大厂**没有索取违约金**,然后违约流程走了大概半个月左右,最后拿到了新的三方。 据我了解好像一般比较大的企业都不会索取违约金,一些小企业可能要先交违约金再解约三方。 这里并不是鼓励大家去违约,只是说在有更好的选择或者明显感觉到现在的岗位不适合自己的情况下,不要惧怕去违约,有些同学可能害怕违约了企业会拉黑自己,实际上这种可能性很低,而且并没有实质的影响。 hr 经历的违约事件数不胜数,并不会对你做特殊处理。就业是一个双向选择的过程,为了自己以后的职业发展,还需要做更慎重的考虑。 因为匆匆学习,更多的只是学习了理论知识,而且算法与项目经验还有实习经历都阻隔了我进大厂的可能,基本上我笔试都做的很艰难,大部分大厂笔试都挂掉了,就算勉强进了面试,也倒在了手撕算法的环节上。 导致我现在拿到 offer 后还是在不断的刷题,因为算法基础太差了,但是它又是那么重要。 我的经历更像是一个普通人的经历吧,不像其他大佬大厂 offer 拿到手软,我前前后后的面试公司可能就十来家,最后成功拿到手的 offer 也就四个。 功利一点来看,从 5k+ 到 10k+ 再到 15k+ 最后到 20k+,慢慢打怪成长一样。有时候后悔自己觉悟的太晚,准备的太慢,导致错过了更好的自己。 但是转念一想,**改变,就是好事**。 ## 学习规划 我自己一开始没有什么规划方向,走着走着就往 C++/QT 客户端那边走了,因为学了点 QT,但是后来又觉得后端可能发展方向好一点,所以在辞去实习之后重新投的简历也全部投的后端方向的。 所以我的学习规划基本上是 **C++ 服务端方向**的,希望可以给各位同学一些帮助。 #### 编程语言 我自己是有较扎实的 C 知识,所以学 C++ 并不是那么困难,而且我比较喜欢 C++ 这门语言,喜欢它的严谨和多样性,所以我看的 C++ 书籍可能比较多。 从《C++ Primer》到《Effective C++》系列再到《C++沉思录》这些前前后后可能看了十几本左右,越往后看越体会到 C++ 的复杂性,越看越感觉难…… 校招的话 C++ 一般不会问的很难,甚至如果面试官是搞 PHP 或者 Java 的可能都不问你语言相关。 所以我**不建议你直接啃《C++ Primer》,把它当作一本词典来用会更好**,新人上来一直看这么厚一本书很容易直接被劝退。 我推荐你从《C++ 新经典》 -->《Essential C++》 --> 《C++新经典 对象模型》这样的顺序学习,基本上校招应付面试是绰绰有余了。 如果想要在面试中在 C++ 相关做到侃侃而谈的话可以看看 Effective 系列的书籍,以及啃一啃《深入理解 C++ 对象模型》这本书,我过年的时候把它放在家里了,是我最后悔的事。因为到现在为止,我看这本书都蛮吃力的,需要慢慢的看,细细的看,而且很多地方都不理解。 如果有时间的话也可以把 python 当第二语言,用惯了 C++ 再学 python,是一种不一样的体验,真的挺有意思的。 **其实语言并不是那么重要,基础知识更重要,建议把更多的时间放在基础知识上,尤其是想要冲大厂的同学**。 #### 数据结构与算法 数据结构与算法这方面的话是非常非常非常重要的,想要冲大厂的同学们如果这方面不过关可能连笔试都过不去,更别谈面试的手撕算法了。 我自己的话因为准备比较匆忙,所以只是把匆匆《剑指 offer》刷了一遍半,然后 leetcode 刷了 100 道左右 easy 题,看了一遍《labuladong 算法小抄》(从小林哥这中的嘿嘿),面试和笔试一般太难的题我就宕机了。 我后面复盘了一下,我因为时间紧,所以刷题很匆忙,像赶任务一样,刷完了就完了,也没有做总结和回顾。 实际上一道题可以牵扯到一类题的做法,都是有框架和套路的,有很多大佬都总结过。 刷题的话还是需要不断地总结和回顾,才能达到有效刷题,像我都是无效刷题。 根据我做的笔试题来看的话,基本上我**建议你把《剑指 offer》刷两遍,然后 leetcode 热题 100 道刷一遍,最后跟着《labuladong 算法小抄》总结一遍框架,基本上这一块就妥了**。 但是尽量还是一直要保持刷题,因为以后工作了跳槽也对算法有要求,多多益善吧。 以上是建立在有数据结构的基础上,如果对数据结构不了解的话可以先看一下《大话数据结构》或者某客时间上有一门关于数据结构的课程。 算法这方面的话一定不要一直闷头刷题,要学会总结题型和方法。 做的多了你拿到一道题的时候脑海里就有了基本的框架,该用什么数据结构去做,该用什么方法去做,心里都有一个数了,是数量引起质变的一个过程。 我个人就比较惭愧了,这方面基础不是很扎实,导致现在也在一直补。 从我寥寥无几的大厂面试经验来看,在面试的时候面试官会引导你去做题,是有一个相当舒适的做题环境的,所以不用畏惧算法如猛虎,做的多了对付校招还是绰绰有余。 #### 操作系统 操作系统我自己是从《深入理解计算机系统》先看,发现有些地方难以理解,而且不少章节用纯汇编来演示,看不太懂,又去看了《现代操作系统》和《操作系统导论》,阅读顺序几乎是从难到易,一路踩雷…… 我建议初学者先看一下《操作系统导论》,这本书讲的比较浅而且易懂,然后再看《现代操作系统》,看的差不多了,最后看《深入理解计算机系统》。 《深入理解计算机系统》的话,可以跳着看,有几章节是用纯汇编演示的,懂汇编的同学可以看一看,不懂的同学粗略看一看也没关系。 到这基本校招问的知识你已经具备了,如果想要实践的话,github 上也有一些和操作系统相关的项目可以看一看。 #### 计算机网络 计算机网络我自己是先看了谢希仁的《计算机网络》,发现讲的太杂了,并不是很深入,又看了《TCP/IP 详解:卷 1》,又发现讲的太深入了,根本看不懂,最后又看了《计算机网络自顶向下方法》和《Http 图解》,面试的时候就够用了。 我建议你直接看《计算机网络自顶向下方法》就好了,然后直接看小林哥总结的计算机网络相关的知识就可以了,应付面试时绰绰有余了。 #### 设计模式 这个我自己是通过《大话设计模式》入门,然后看网上别人博客总结的一些知识,最后在某客时间上买了一门设计模式相关的课程学习了一下(*某客时间该打广告费了,笑~*)。 总体来说学的并不是很深入,因为设计模式这个东西学起来很简单,但是实际项目中应用起来太难了,而且根据我面试中的经验,一般蛮少有面试官会问,就算问也就问一问简单的单例模式、工厂模式这些,问的不是很深入。 所以我建议你通过《大话设计模式》入个门,然后对设计原则和常用的设计模式有个印象,最后可以在网上看看别人的经验。 校招的话很难在这一块问的比较深入,虽然它很重要,但是没有商业项目和工作经验的前提下,应届生很难真正熟悉这些,最多做到入个门,这样就已经足够了。 #### 数据库 数据库相关的话我是学习了 MySQL 和 Redis 两个比较常用的数据库。 MySQL 我是先读《高性能 MySQL》然后又学了某客时间上的《MySQL45 讲》最后最近再看《MySQL 是怎样运行的》,又是一路暴雷…… 所以我建议你先看《MySQL 是怎样运行的》,然后在某客时间上看《MySQL 实战 45 讲》,最后稍微看一看《高性能 MySQL》(*挑章节看,这本书讲的太深,理解起来比较困难*)。 最好在学完这些之后自己动手去部署一个 MySQL,然后挑一些小项目做一做。 我在面试的时候和面试官聊 MySQL 聊的比较深,他给我出了蛮多 MySQL 相关场景题,我答的不尽人意,面试官说我基础可以,但是实际上手比较差,建议我私下可以部署一个 MySQL 试一试,这些问题会有更好的理解。 所以我把这个建议也给你,希望可以理论实际双管齐下,不要像我一样做个偏科生(哭)…… Redis 的话我是先看了一遍《Redis 设计与实现》,然后在某客学了《Redis 核心技术与实战》,在看实战经验的过程中,感觉有一些吃力,于是又返回去看了一遍《Redis 设计与实现》。 Redis 算是我比较匆忙的学习了,因为在决定走后端这条道路之后补学的,学习的比较匆忙,所以我也只能给一点粗浅的建议,建议先看《Redis 设计与实现》,有了基础知识之后可以选择看一下其他 Redis 的书籍或视频加深基础,然后去某客时间学习一下《Redis 设计与实现》。 我面试的话,Redis 方面基本没有什么太大的问题,一般问的都可以说道一二。 #### Linux 系统 Linux 的话我建议你先看《鸟哥的 Linux 私房菜》,对 Linux 整体有了一些了解之后,可以再看一看《Linux 系统编程》。 因为走的是后端方向,所以看一下《Linux 高性能服务器编程》,强推这门书,都是干货,然后跟着书上做一个服务器相关的项目,会有助于加深理解。 如果想学 Linux 内核的话,先看《Linux 设计与实现》,这本书看起来难度尚可,后面有能力的话可以研究下《深入理解 Linux 内核》(我自己是放弃了……真的看的很困难)。 而且我面试的时候虽然简历上写了,但是面试的时候并没有被问到 Linux 内核相关的问题,所以这个一般应该是加分项,有能力的可以搞一下。 Linux 系统的话纯看书可能事半功倍,最好在 Linux 环境下做两个小项目,这样子对整体学习可能更有用处。 ## 关于简历 我一开始也不会写简历,杂七杂八写了一堆,重要的地方反而都没有写。 记得有一次,我以为计网、操作系统、数据结构与算法这些都是必须项、是默认就会的,所以就没有在简历上写,导致面试的时候面试官到最后说:「你这个简历我不知道该问些什么」。 他们是做 os 的,我当时还疑惑为什么不问我操作系统相关的,很久以后才感受到面试官是严谨的根据你简历上的内容对你进行提问,有时候可能他们部门主用的技术你简历上没有写,才会主动的问你一两句,其他的技术你不写就默认代表你不会。 所以关于简历,首先要**将自己掌握的技术写全**,注意精通、熟悉、掌握、了解这些的区别,有可能决定了面试官问你的深度。 更重要的一点是,**自己不会的东西或者只听说过的技术不要为了简历好看写在上面凑数**,可能你其他的点答的挺好的,但是问到了这个你写上去凑数的东西,你一问三不知,会降低评价分数,甚至直接决定是否录用。 因为实际面试的时间就算一场一个小时也是很短的,很难全面的去考察你简历中的所有技术,只能根据部门的需求着重考察一些。 而问了一个你简历上写的技术,你全然不知,这种情况下,完全可以怀疑你的简历上写的技术是否全部属实,但是面试时间又很短,没办法去一一验证,最后只能是你吞下这份苦果。 然后对于互联网而言,没实习和有实习是 0 和 1 的区别。 就拿我自己来说,虽然之前在手机大厂短暂实习了一个多月,写在简历上之后,明显能感觉到在春招的时候机会多了很多。 所以大家还是尽量早做准备,不仅是实习转正提前结束校招或者丰富简历、学到更多东西,更重要的是提前去适应这种互联网工作环境,看一看自己到底适不适合在这种环境下工作。 不然等真正正式入职了之后,不适应互联网环境,然后想要考公考编,失去了应届生身份,则悔之晚矣。 ## 面试技巧 在有扎实的基础前提下,在面试的时候要学会引导面试官的面试方向。 假如你对 http 协议研究的比较通透,在问到和网络协议相关的问题时,可以试着把问题导向 http 方面的知识。 比如你在回答完上一个问题时,发现这个问题和 http 中的某些知识点吻合,可以在说完之后顺嘴提一句这个和 http 中 xxx 有点像,然后面试官可能就顺着问你 http 中的知识了。 因为面试官其实在面试的时候,除了部门硬性要求掌握的技术,并没有一个具体的侧重点,所以在这种情况下,大概率就顺着你所说的问下去了。 就像学校后门明明那么多吃的,但是你中午不知道该吃什么,你的小伙伴顺嘴提一句去吃麻辣烫吧,你在没有明确意向的方向下可能就跟着他去吃了(除非你特别讨厌吃麻辣烫,笑)。 还有在面试的时候,不要怯场,就把这场面试当作知识交流一样,和面试官去探讨一些知识的细节。 每一场面试对于我们而言,也是一种学习经历,因为在学习的时候是系统的学习,并没有侧重于某个点,而在面试的时候,可能面试官会撂下大部分的知识,非要和你深究一个方面的知识,直到问到你不会为止。面试完之后,就可以做复盘,加深自己的知识深度,慢慢补全自己的知识框架。 还有,面试的时候问到你不会了直接就说不太了解就行了,不要硬撑,你会与不会在面试官眼里都是能一眼看穿的事。 在面试几场之后,你会感觉到自己慢慢的进入了一种状态,懂得怎么去面试了,然后保持面试复盘,补全自己的知识盲区,成功拿到满意的 offer 只是时间问题。 我面试的时候,极大多数的面试官都非常和蔼,而且知识面都很广,有些问题你不会他们都愿意给你解答并且提出一些学习的建议,所以越到后面我越来越享受面试的过程,享受那种知识交流的氛围(当然,遇到完全不会的问题还是戴上了痛苦面具)。 最后的一点是,在校招过程中,一场接一场的面试,你根本无法静下心来学习新的知识,只能在已有的知识框架下修修补补,深化一些知识点。 所以建议小伙伴们,还是**早做准备!早做准备!早做准备!** 上一张我在这个过程中不断补全自己知识点的图吧,只要知识框架搭起来之后,学习是件很轻松的事情。 ![请添加图片描述](https://img-blog.csdnimg.cn/f87d110c8a904e24bd388be549200e20.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5bCP5p6XY29kaW5n,size_20,color_FFFFFF,t_70,g_se,x_16) ## 学习心得 我觉得我自己算是学习速度比较快的人吧,学习效率也还可以,所以把我自己的学习方法分享给大家。 我在学习一门新技术的时候,会选择一本难度适宜的书,根据目录在自己脑海中把知识框架搭起来,然后看书慢慢去填补具体的知识,就像一颗多叉树一样,系统化的学习。 在看完一遍书之后,会有意的去看一些比较重要的知识,深化这些知识,同时我也关注了许多公众号,在框架搭起来并完善之后碎片化的学习。 最后再看一看网上别人对这门技术一些知识的理解,再对照自己的理解,补充优化。 最好的是做一些小的项目,去应用这些知识,毕竟实践出真知(然而我因为时间太短,这一步没有进行 555)。 同时面试的过程也是一个查漏补缺的过程。 思维导图是一个好东西,给大家推荐一下,有助于系统化的搭建知识体系,上一张我在春招的时候补全 Redis 知识的图吧。 ![请添加图片描述](https://img-blog.csdnimg.cn/2bea52b3248240319ec62205eaf02b0f.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5bCP5p6XY29kaW5n,size_20,color_FFFFFF,t_70,g_se,x_16) 因为学习 Redis 的时间比较短,而且并没有过实际上手,所以用知识导图把这些知识分门别类的存储,然后记忆到脑子里,在面试的过程中,学习并且不断补充,加深自己的知识深度。 其实看视频也是一种不错的学习方式,但是我个人更习惯看书构建知识框架,所以仁者见仁智者见智啦。 ## 一些感悟 给大家分享一些我在校招中的感悟吧,我眼中的【**校招 = 技术 + 心态 + 运气 + 勇气**】。 技术是毋庸置疑最重要的一点,没有技术的基础,一切都是空谈。 同时心态也非常重要,在经历长时间的笔试、面试的折磨时,还要一日一日的等待面试结果,那段时间每一个电话都以为是 offer call,每一个提示音都以为是邮箱信息……最后可能大部分都没有结果…… 从一开始的满怀信心到后来的迷茫踌躇再到最后的怀疑自我,这些我都经历过,在最崩溃的时候真的不想再找工作了,随缘吧。庆幸的是我坚持下来了,并且最后有一个好的结果。 运气是比较玄学的一个东西,从投简历的时间到面试的部门到各个公司不同的需求,都有可能决定你能否拿到一个满意的 offer。 从我个人而言,秋招和春招加起来可能投了有一百多家公司,最后面试的可能只有十几家左右,拿到了 4 个 offer。 ![图片](https://img-blog.csdnimg.cn/img_convert/f3cd252cbe90f2f6b4f2f27f83b12136.png) 这个是我辞去实习后回到学校春招投的公司,最后笔试的有三家,全挂,直接面试的有两个,最后拿了一个 offer。 因为我算法基础比较差,拿 offer 的那家没有笔试,直接面试,我在面试中发挥的比较好,而且算法题也都会,所以最后成功拿到了 offer。 最后同时也需要有做决定的勇气,我在提前实习之后,感觉到我可能不适合这份工作,但是公司又是大厂,而且适时又在春招中后期了,当时也犹豫了蛮久的,最后觉得还是决定不将就,回到学校重新找工作,最后对我而言也是一个比较满意的结果了,而且公司也没有要违约金,感谢公司~ 比较重要的一点还有就是持续保持学习,并且永远相信自己,不要放弃。 不止校招,而是进了互联网这一行,我们基本上只能保持学习,不然犹如逆水行舟,不行则退,大家应该也知道这一点。 放一张自己还放在学校的一部分书吧,其他的都在春节搬回家去了。 ![图片](https://img-blog.csdnimg.cn/img_convert/68eca7f2f295ba9256edaa386b8d961d.png) 也可以看到这部分书中算法书的比重就很高,所以算法真的非常非常非常重要,学好算法是进大厂的第一步,希望大家可以早点做准备,不要像我一样临时抱佛脚。 洋洋洒洒写了这么多,不止是希望我自己的经历可以对各位小伙伴起到一些帮助,也是对自己的深度复盘,在这个过程中发现了自己蛮多的不足,希望可以慢慢成为一个技术全面的人。 复盘之后就该进行新的学习啦,努力让自己享受这个学习的过程吧,毕竟进了咱们这一行,持续学习是非常重要的一件事。 希望大家都可以早作准备,日后拿到一份满意的 offer ~ 感谢你能耐心看到这儿,希望我的经历能对你起到一些帮助。 ================================================ FILE: reader_nb/2_reader.md ================================================ # 被字节捞了六七次,终于拿到 offer 了! 大家好,我是小林哥。 前段时间,有位读者激动地跑来跟我说,进字节了。 ![图片](https://img-blog.csdnimg.cn/img_convert/ba425d359c86114f94de27b85adaa97b.png) 经过和他一番的交流后,发现他的校招经历也是比较坎坷的,去年秋招不理想,面试屡屡受挫。 但是,经历完后秋招后,他就开始立马反思面试的过程,以第三人称视角去审视当时的自己,从中发现自己的不足,然后制定好计划逐个突破。 最终,在今年春招终于拿下字节的 offer。 别看我说的简单,其实他中途被字节捞了六七次,这非常考验面试者的心态。 所以,今天就由这位读者来分享他的校招心得。 希望这篇诚意之作,能对未来找工作的人或者正在找工作路上的人一些帮助和指引,也是希望能使你们少走一些弯路。 ------ ## 基本情况 先来说一下我的基本情况,我是西安某 211 高校电子科学与技术专业的研究生(非科班)。 本科学过计算机相关的一些课程,如计算机网络,操作系统。编程语言方面本科除了学过开设的 C 语言外,自学了 Python 和 C++。 研一抽空才开始对数据结构和算法进行了学习,所以我觉得自己一开始就是个小白,一点点成长起来的。 另外,因为顾及到女朋友,找工作比较考虑地域,可能有一些小伙伴与我有相同的经历。 我在秋招的时候签了深圳的 Shopee,但是当时为了和女朋友在一个地方,春招又开始找工作,最后经历了一个月的努力,杭州字节上岸。 有小伙伴可能会问我,为什么秋招没有直接找杭州的。 其实,我在秋招的时候也找了杭州的,但是当时杭州比较好的公司我都没过。。。 ![图片](https://img-blog.csdnimg.cn/img_convert/bdbaf062efd81fcd56c13865933ad21c.png) 这里我来说一下为什么我秋招如此的失利(菜是原罪/(ㄒo ㄒ)/~~)。 在秋招一开始,也陆陆续续投了一些公司,有大厂提前批,也有二线厂。 但是,当时怪自己复习不充分,**有时候因为代码问题没过,有时候因为基础答得不好导致与后续面试无缘**。 后来经过多次得复盘,在基础和代码题下了很多功夫,但是还是面试失利。 后来在复盘时,我发现我**与面试官交流比较少**,比如当面试官问我一个比较偏实际业务的问题时,我基本都是在那思考好久,中途也很少和面试官交流。 这就会显得很尴尬,就好像你和一个人在聊天,另一个人突然掉线一样。 即使你最后回答出来了,但是给面试官的体验不太好。毕竟如果有幸进了公司,就成了一起共事的同事,肯定会一起交流业务,技术问题。 如果面试官觉得你在面试中与他交流甚少,那么会给他一个暗示,就是你不想和他交流(也许这不是你的本意),最终 pass 你。 ## 你要找什么工作? 可能这一点上你会考虑很多,比如岗位,地域,平台,收入等。这些都是秋招前可能就要考虑的问题,特别是岗位,更是决定了备战复习的方向。 作为过来人,我会给一些自己的看法,但是最终靠你自己决定。 岗位是我最先想聊的。 一般大部分人都会选择找的岗位是算法、后端开发、测试开发岗位。而像前端、客户端开发(IOS/Android)也有一些人会去做。 个人感受每年这两种岗位投递的人数不如上述三个岗位多,尤其是今年的算法岗位和后端开发可以说是一片红海。 这些岗位在校招过程中的难度系数是:算法 > 开发 > 测试开发 > 前端 = 客户端开发。 其中算法主要分三个方向,难度如下:计算机视觉 > 推荐系统(机器学习、大数据)> 自然语言处理。 以上提到的难度不是指技术难度,而是获得心仪 offer 的难度。 大厂的 cv 岗神仙打架,算法今年诸神黄昏,更加上疫情影响,许多海归学子也加入其中,可以说今年是找工作最难的一年。 ![图片](https://img-blog.csdnimg.cn/img_convert/1d4b92c9d7226c69d17773fe92e6becf.png) 所以,在这里我的建议就是,心中要先有一个目标岗位,然后去针对目标岗位的一些技术栈进行准备。 如果要找开发岗位的,扎实的学习完计算机基础、数据库、算法和数据结构、设计模式以及 C++ 或 Java 等编程语言相关的知识,准备一两个项目,并且熟悉项目中所用技术的原理细节。 如果有小伙伴要找算法岗位的话,那么要把自己研究的领域吃透,并且在其他领域也要有所涉及一些。 ## 简历篇 作为一名在校生,在进入校园的那一刻起就要想着怎么充实自己的简历,因为大家不可能用半年的时间来填满自己的简历。 简历上的各项荣誉都是你在校期间的积累。简历里内容主要有:个人基本信息、联系方式、教育经历、成绩、掌握的技术栈、科研项目经历、比赛经历、获奖经历、发表论文、其它等。 一般简历上可手写意向岗位,因为不同公司对岗位的叫法差距蛮大,不能每一个都打印意向岗位。此外简历打印最好是彩印,让面试官看着舒服。 对于在校生,简历内容最好在两页以内(也即双面彩印时,一张纸可完成)。 纸质版简历一般在现场面试要携带,但在网上投递公司时,通常是按照公司要求填写个人简历,这个过程会十分繁琐,一个公司可能要填半小时,着实费心。 在这里推荐大家使用牛客简历助手插件,可以帮助你快速填写电子简历。 下面主要说简历里的技术体现。 面试官看重简历里的什么? 这个问题你可以换位一下,如果你是面试官,你觉得哪些内容能体现一个人的水平? emmm 据个人面试经验,简历上的闪光点排名及说明如下表: ![图片](https://img-blog.csdnimg.cn/img_convert/fe4483bc6e7f3c08ccf4bc3cdcff176c.png) ![图片](https://img-blog.csdnimg.cn/img_convert/bf38ba6330e127517751e13f0d3164aa.png) 希望上述表格对大家有导向作用,另外说一下简历上的一下注意点: *第一点*,每个闪光点的描述上要包含:这个项目或比赛是什么、你负责了什么、用了什么技术栈、最终排名如何。 项目、比赛、论文每个里面要有至少有一个自己非常熟悉。据个人经验,面试官通常会这样提问“你挑一个印象最深的项目/比赛讲一下吧”。 所以你们自己要提前预演怎么回答这个问题,一定要预演一遍。 *第二点*,简历上写的任何技术,你都要会。不会的千万千万不要写,被问到就死翘翘了。 拿捏好用词,比如「熟悉 C++」和「熟练掌握 C++」程度上是不一样的,只是刚入门的技术,我建议就不要写了。 简历上你写的任何技术栈,只要面试官熟悉,他基本上都会问你。 比如,你写你懂 Docker,恰巧面试官会,那他不出意外肯定会问一些相关问题。 ## 项目篇 各位找工作的小伙伴一定很在意找工作时面试官是不是特别看重项目经验,这里请小伙伴听我娓娓道来。 以我个人的面试经历,我其实简历上是没有什么和研发类岗位相关的一些项目的,我简历上的项目基本都是在校期间做的科研项目以及一些比赛(计算机视觉相关比赛)。 我在一开始面试的时候屡次失利,以为我是没有相关的项目经历。 但是后来通过听自己的面试录音(因为我们由于疫情是线上面试)进行复盘,发现自己在回答一些技术问题时回答的也不是很好(这里的不好指的不是回答有误,而是对于某一个问题,基本上都是两三句话就讲完了,个人感觉给面试官的感觉就是你懂得很浅)。 那么这里的项目经验到底重不重要呢? 其实对于国内的一些互联网大厂来讲,像阿里、字节,这些公司其实对于项目经验看的不是很重,**更看重的是你的基础**(对于大厂来讲一个基础扎实的应届生可塑性是很强的)和**代码能力**。 但是不看重,不代表不用准备项目,如果你像我一样是后期转岗,没有时间准备,那么就把自己简历上的项目都搞明白,给面试官讲清楚,面试官可能不懂你做的是什么,但是多年的工作经验,他们对于你做的东西,一听就能洞察其中的要点,可能还会深挖一下细节。 反之,如果你讲的不好(不是你不懂,而是表达交流能力的问题),面试官可能听的云里雾里,会大大减分的,毕竟以后你进了公司都是同事,交流肯定很多,他们更喜欢和交流起来舒服的人一起共事。 但是如果一开始就打算找研发类岗位的小伙伴,时间充裕的话,还是准备一下相关的项目,这里准备的时候一定要吃透,具体分为以下几个部分: - 项目整体的实现流程(最好自己画一个图出来,方便自己理解,以及面试过程中可以展示给面试官,清楚明了); - 你在做这个项目的时候遇到了哪些难点,是怎么解决的(重点,面试官肯定会问的); - 在这个项目中用到的一些知识点,技术栈一定要清楚细节,搞明白为什么这么用,有没有可以替代的,有的话为什么不用替代的,用了效果怎么样(凡事要多考虑一下,毕竟你不知道面试官会不会问,准备全面一点不会有坏处)。 以上几点最好自己用电子文章整理总结,方便复习。 最后在多说一句,其实面试官都知道对于应届生生来讲怎么会有和公司业务贴近的项目经验(除非你去实习过)。 因此,如果没有项目经验,那就准备好当下自己所拥有的以及基础,尽力表现出自己的亮点,如果有项目经验,就准备全面一点,基础也不要落下哦。 ## 技术篇 由于我在校招过程中找的大都是测试开发岗位,客户端和算法岗位也找过一些,因此主要说一下对于测试开发岗位考察的技术。 对于测试开发岗位,据我个人面试经验总结如下几个部分:数据结构,计算机基础(包括操作系统,计算机网络等),数据库,设计模式、测试用例设计,测试的方法,代码。 另外,对于算法,我这里也可以给小伙伴一些建议,因为本人在校期间是做计算机视觉算法的,算法岗位需要掌握的总结为 4 个部分:通用知识理论(不管什么岗位都要会的)、深度学习、机器学习、代码。 对于客户端每个公司具体考察什么我也不好说,据我个人面试经验,字节客户端岗位会招 0 经验,但是你的基础和代码要过关(二者不可缺一才能过关斩将)。 ### 数据结构 笔试必考内容。 对于数据结构,我是通过看《大话数据结构》这本书进行学习的,并自己通过代码去实现一些常用的数据结构,如二叉树,链表等。 笔试时很多编程题都与数据结构有关,比如堆栈队列二叉树等知识频繁出现在笔试中,还有各种排序的变异程序题。 而面试时,面试官也喜欢提问相关知识,比如他们很热衷提问快排和归并排序,还喜欢提问 Top k、海量数据排序等。 ### 编程语言 一般 Python 都要会,鉴于经历有限,C++ 或 Java 二选一。 如果你偏向找 Java 开发,机器学习和大数据的话选 Java 更好。 算法笔试时使用的语言一般就是上述三者之一。 不过这里讲的掌握一门编程语言,不是简单的用它写代码,需要更深层的掌握底层原理。 比如我在面试百度的时候,一个小时的面试,有 30 分钟再问 C++ 的知识,在面试字节等其他公司的时候,经常被问到 Python 的内存管理机制。 如果你想快速概览一门语言的知识点,推荐看菜鸟教程,总结的短小精悍,适合学习和查阅。 由于我主攻 C++,这里我说一下我主要在准备秋招时看了哪些资料吧。 首先对于 C++的基础学习,我看了《C++ Primer Plus》这本书,看完基本就入门了,看完之后看了《Effective C++》以及侯捷的《STL 源码剖析》(由于时间关系没有看完)。看完书之后就是对语言的熟悉了,后期刷题基本上都用 C++ 去刷。 其他常考的 C++ 知识,我可能没有总结资料,需要你们自己搜,比如: - char a[3], char a[], char*a 的区别; - 模板 template、const 和 define 定义常数用谁更好; - 宏定义和内联函数的区别; - 虚函数(常考); - 虚表(常考); - sizeof; - 多态; - C++内存管理; - C++11 特性; - C++编译的 4 个阶段; - 动态库与静态库; - C++结构体和类的内存对齐问题; - 智能指针; - ……. 关于 Python,我没有直接的资料(也是由于在校期间做的科研项目 Python 用的比较多,比较熟悉),印象中常考察的点有: - Python 多线程; - 全局解释锁; - Python 数据结构的底层,如索引、元素增删、字典、元组、集合等是怎么实现的; - 内存管理方式; - 深拷贝浅拷贝; - 三元表达式; - *args 和 **kwargs; - join 用法、split 用法; - 类、类中 self; - 运算符 and,or,not; - is 和“==”的区别; - 位运算和 C++的不同; - …… 以上我提到的最好看看,但不能作为唯一参考,真实考察的会比我上面提到的多。多搜搜面经和真题,查漏补缺,学会记录,好记性不如烂笔头。 ### 计算机基础与数据库 计算机基础包括计算机网络和操作系统。 这里首先推荐的就是小林的图解操作系统和图解计算机网络了,讲解通俗易懂,对于有一点基础的人来说很快就能构建起知识网络,对于 0 基础的人来说可以突击一下面试。 但是建议如果时间充足还是建议先系统学习一下,这里我推荐可以看《计算机网络(第七版)》,看完基本上整体入门,然后再去看小林的图解系列,把整个知识串起来形成知识网络。 数据库,我学习的是 MySQL,是在 b 站找的尚硅谷的视频一整套(包括基础和进阶)学习下来的。 包括操作系统,我也是先在 b 站系统的学习了(看的是王道考研的操作系统),才去看小林的图解系统对一些常考的问题以及怎么与实际问题结合进行了学习和查漏补缺。 另外,在公司大多都是在 linux 环境下进行开发工作,面试官多多少少会问一些 linux 指令,大家可以熟悉一些常用的指令。 这里我根据我个人的面试经历总结一下上述几个方面常考的一些题。 计算机网络: - TCP、UDP 的区别; - HTTP 与 HTTPS 的区别; - 三次握手与四次挥手; - HTTPS 加密方式与过程; - 输入一个网址中间发生了哪些过程(如果面试官问道这个问题,那么肯定会连带 DNS 解析过程及三次握手四次挥手一起问); - 什么是 Cookie,如果 cookie 禁用怎么办; - Cookie 与 Session 的区别; - TCP 的长连接怎么实现; - HTTP 1.x,2.x 的特性; - TCP 的可靠性传输怎么保证(会被问到重传机制,滑动窗口,流量控制); - Get 和 Post 请求方式的区别 - …… 大家看小林的图解网络就好了!!! 操作系统: - 内存管理; - 进程与线程(每次都考); - 进程通信的方式; - 死锁相关知识; - 页面调度算法等; 数据库: - 给几个表,写 SQL 语句; - SQL 语句如何优化; - SQL 索引; - 根据给定的表,让你说一下如何创建索引比较好。 Linux 命令: - chmod 指令; - 查找文件中符合条件的字符串指令 grep; - sed 指令; - 查看进程使用情况的指令 top(其实也可以说 htop)。这个指令不同面试官不一定考察什么。建议可以看看文件管理类指令和文档编辑指令。 ### 笔试和面试的代码题 由于这个十分重要,所以我放到这部分最后单独说明。 在我看来,笔试能力在秋招中要占据 60% 的重要程度。 首先笔试不过,你根本没有面试的展示机会。 其次面试中也会反复让你手写代码,以字节为例,每一轮面试都是 1-2 道编程题,有时候不怎么聊简历;百度每一轮面试有一道编程题。 现在公司的面试模式就是这样,如果代码没搞出来,大概率会被淘汰。 并且最窒息的是这些代码题都不简单,一般都是 leetcode 中等到 hard 难度。 刷题主要可从以下三个渠道。 第一个,剑指 offer: *https://www.nowcoder.com/ta/coding-interviews* ![图片](https://img-blog.csdnimg.cn/img_convert/bdf8b3f1b13f35a6edfc868bf610b92f.png) 第二个,leetcode: *https://leetcode-cn.com/problemset/algorithms/* ![图片](https://img-blog.csdnimg.cn/img_convert/2ac096668856f913f7f35d51e05aea82.png) 第三个,近期面试中常考题: *https://www.nowcoder.com/activity/oj* ![图片](https://img-blog.csdnimg.cn/img_convert/29825901e111cdd3de4f442ba3f0fa5c.png) 剑指 offer 作为大经典,是一定要刷两遍以上的,很多题都是面试时的原题,里面包含了很多笔试常用的思想方法,反复看,反复研。 我一开始每天 10 题,过两遍以后,要求自己每天快速过剑指一遍。 leetcode 由于题目十分之多,刷完是不可能的。我的意见是 leetcode 作为你弱项的专项练习。 leetcode 右侧有标签分类,如下图: ![图片](https://img-blog.csdnimg.cn/img_convert/f0bc1ed60a8c32ce7a230cd59a69ce52.png) 另外在巩固完弱项的情况下,建议将 leetcode 前 300 题刷熟练,据我个人面试经历,国内大厂面试出的代码题 80% 都是这个范围内的。 另外,我在刷题的时候看了 labuladong 总结的算法小抄,对算法的理解很有帮助。 最后根据我的面试经历,根据个人感觉,公司常考题有: - 链表类(链表反转类题目) - 二叉树类(二叉树的遍历类型题,最大公祖先类题目) - 字符串操作题目 - dfs/bfs - 动态规划(这个考的基本都是 leetcode 上的或者是背包问题,对于动态规划问题其实有很多种类,比较见到的就是一维动态和二维动态),另外还有区间调度类型的题目(贪心算法,也属于动态规划的一种特殊情况。 其实也没有什么技巧,多刷题,多理解就好了。 ## 心理篇 校招期间要时刻关注自己心理状态,这可能会起到最重要的作用。 焦虑、彷徨、孤独、自我怀疑,校招期间会时常发生,相当折磨一个人。 像我后期找字节,就被字节捞了六七次(这里提一下,想被捞的话,你的面试评价一定要好),真的很搞人心态,但是我坚持下来了,希望小伙伴在找工作的时候不管发生什么坚持下去。 希望你放下比较,不要因为别人先拿到了 offer 就开始焦虑,有人可以早早结束校招,那他也在后面付出了我们看不到的东西,也可能焦虑彷徨过。 你要做的是掌控自己的节奏,知识打磨的够硬,简历够充实,准备充分就不用担心没有心仪 offer。 另外面试完不要深究面试结果如何,整个校招面试会很多,如果每一场结束后都不能释怀,会很影响你接下来的状态。 面试结束后,会不会被录取不是我们焦虑就能决定的,你可以从面试中总结,但不要从面试中懊悔。 如果你有一起奋战的恋人、朋友,这个过程中互相鼓励和帮助,体验会好很多。 及早准备,如果你有幸在提前批就拿到心仪 offer,那么你后面会过的很称心。 早早准备总不会差,加油~ **再说你已经拿到本宝典,还!怕!啥!** ================================================ FILE: reader_nb/3_reader.md ================================================ # 三本逆袭!拿到京东美团等 offer 大家好,我是小林哥。 这次还是读者牛逼系列,最近发这个频繁主要是准备秋招了嘛。 想通过几篇读者的校招心得激励一下还在校的读者,给大家打打鸡血,让大家感觉自己又可以了。 我觉得这个挺重要的,因为当你发现别人都可以的时候,其实也是变相在督促自己,告诉自己你也是可以的,别低估努力后的自己。 这次读者他是一名三本的大四学生,通过去年 6 个月的努力,校招时拿到京东美团滴滴高途作业帮等 offer,最终选择了在作业帮担任后端研发。 这又是一个逆袭的例子,所以还是那句话,**以大多数人的努力程度之低,根本轮不到拼天赋**。 今天就由这位读者向大家分享一下,他的曲折的求职之路和这一路走下来收获到的经验和感悟。 ------ ## 转折点 #### 2020-2-29 我的想法开始发生变化 疫情前,我是一个不爱学习的井底之蛙,喜欢带着鄙夷的目光看这个世界。 直到疫情来袭,困在家中无所事事,游戏也打腻了。 当时记得很清楚,某 up 主介绍 Nignx 的时候,我第一反应,这是啥,这个怎么念? 随着这三分钟热度,想再学习一下 Java 基础吧。 Java 基础那时我已经看了很多遍视频了,觉得不能再这样下去了,以后找工作不能再让爸妈为我操心,突然觉得当年说的话简直就…… ![图片](https://img-blog.csdnimg.cn/img_convert/94d9f56e89e1ac542e70a076c9e3be78.png) 于是,决定要走出这个舒适圈。我就把网上好评的 Java 相关的书籍全买下来了。 ![图片](https://img-blog.csdnimg.cn/img_convert/47897936edcc649dfaddbb7e30a432f9.png) #### 2020-3-10 开干! 书一到,立马按排! **每天 7 点起床,吃完饭就开干,一直到晚上 23 点。** 就这样,因为这个疫情,我从一个不爱看书转变成了抱着书就不肯撒手的人。 其实,书上的知识真的可以充饥。 ## 第一份简历 #### 2020-4-2 投递了第一份简历 在某某人的催促下,我制作了我的第一份简历,没有照搬任何模板,没有花里胡哨,简单明了。 ![图片](https://img-blog.csdnimg.cn/img_convert/46458343b65ffe0801d6fa85b565d74c.png) 我的简历当时写的很烂,毕竟简历就是你的第二张脸。 于是,我投了阿里,结果不了了之。 ## 面试 #### 2020-6-20 第一次接到面试邮件 当时,上海一家公司发出面试邀请。一面过二面挂,说实话,二面的感觉,完全就是敷衍我,能明显看到那种鄙视链的存在。 那时候深深的刻在了我的心中,自闭了有一阵。 但是我不相信宿命论,于是重新刷书做题,开始研究源码。 #### 2020-8-23 从 0 到 1 之后投简历到处碰壁,于是转向一些小公司和外包公司。 这些公司面试,几乎不问技术问题,纯聊天。 就这样,都没有拿到 offer,不过经历上次面试,心态已经放平稳了。 后来终于拿到了一份 6.5k 的 offer,这是一个好的开始,我相信自己接下来会有更多 offer,于是继续准备继续面,我把目光开始转向中大厂。 最后在不断地坚持下,9 月中下旬 -10 月下,**收获了京东美团滴滴高途作业帮等 offer**。 最后询问了一些大佬,再比对薪资、部门、业务前景等等因素,最终选择了目前的公司。 ## When 至于何时准备,我建议越早越好,毕竟一个萝卜一个坑。 前天坐飞机,认识了一个华科的小姐姐,非常优秀,人家从大一就开始参加这方面的社团和比赛,积累了不少经验和奖项,于是在 2020 年上半年就拿到了微软等国内外大厂实习机会。 所以,省下打游戏的时间吧,来做这些有意义的事情,为自己铺好后面的路,就不会像我一样很累了。 ## How #### 怎么做? 三点:看书,刷题,参加比赛! 每个人不太一样,喜欢看视频学习,喜欢看电子书,或者背题。 而我就比较喜欢看纸质的书,书里一般讲的都是原理,当你明白原理,编程或者面试的时候就游刃有余了,只靠背题是不够的,背题只能提升你的下限,不能提升你的上限。 看完书也不要忘记动手实践,看看源码,或者自己实现一下,看看是不是这回事。顺便再刷一两道题,保持做题手感。当然能参加比赛就最好了,这是能把你和别人拉开距离的手段之一。 **看书:**林鸽有一篇文章写的很好——《[看书的一点小建议](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247493177&idx=1&sn=77e32cec53e8a1aee9fa9fd3b31b19c2&scene=21#wechat_redirect)》,我一开始看书也是看不懂,最后自己摸索出方法,和他说的差不多,当你能看懂并且看完一本书,幸福感爆棚。 并且,看书一定要思考,比如合适看完,看完有何感受,学到了什么,能否解决之前所学内容的困惑。 看完书,还是远远不够的,当你有一定能力了,可以翻阅一下官网的文档和源码,不要害怕看文档,其实通常这里都是权威且容易理解的。 看源码首先梳理流程,知道每步是干嘛的,都梳理明白之后,在去看细节如何实现的,最后再串起来。 推荐书籍:除了上面图中出现的书籍,顺便推荐一下 Go 方向的书籍,《Go 程序设计语言》、《Go 语言编程圣经》《GoWeb 编程》。 **刷题:**刷题就用 leetcode 或者 lintcode,我是先刷数组、链表和树。明白这些数据结构后,我再去刷经典的 100 题。 每一题都要琢磨透,一天做 1 道都没有问题,只要你能弄明白就 ok。 其次当你遇到一道题二三十分钟都没有思路,直接看答案吧……这就是一个新的知识点,再遇到类似的题,起码你有思路了。 当你已经掌握做题套路后,也不要断了,保持做题的感觉。 **参加比赛:**比赛就太多了,蓝桥杯 互联网 + ACM,越早参加这类的社团越好,还有机会培养你,当你有了一定的比赛经验,这也是丰富你简历的一个途径,有了这类奖项一定要在校招的时候好好利用起来。 平时关注一些技术博主,小林 coding、艾小仙等等。刷视频的时间,不如看看他们的文章,都是满满的干货! 再加个技术群和大家一起讨论讨论,或者给新手讲解,能给他讲明白,说明你已经掌握这个知识点了。 #### 怎么投? 三点:牛客网,Boss,官网招聘公众号。 我这三个是我用的最多的,我先是打开手机的应用商城,搜每个模块的前几的 APP 公司,例如:在线教育,社交,游戏等。 然后从上面的进行投递(后来我才知道内推是个什么,最好先让别人内推,毕竟你过了,他有钱拿可以帮你指导一下),我基本都是官网投递的(都是辛酸史),不要嫌改简历麻烦,一定要改的对胃口! #### 怎么面? 面试前一定要准备,自我介绍一定要多练。 我和我室友经常面试前来一次模拟,以防磕巴。 面试的时候,场景题要有自己的想法,每个团队,面对校招生大多是看可培养性,不要无情的工具人。 而且要从容(面试官不开摄像头除外),语言逻辑一定要顺畅,突出重点,带有引导性的去和面试官聊。 举个例子,当时某厂面试官问我 http 和 https 有什么区别,我提了一点 https 多了一次 SSL 握手过程,紧接着就问我 http 和 https 三次握手(多亏了小林的计网模块)。 ## Choose 怎么选?这个单拿出来讲。 从网上看到一个:钱多事少离家近,占两个就可以。 当你拿到 offer 之后,如何做抉择? 根据我询问各个大佬再加上个人见解,总结如下: **前景**,建议不要只看薪资,多看看企业目前发展状况,如果给你开高薪,而目前的发展前景已经不容乐观,那不就是 49 年入国军? **部门**,选择的部门打听一下是不是边缘部门,如果是边缘部门进去了,也很没意思。 **岗位**,如果你是学后端的,结果给了你一个测开或者客户端的 offer,那最后还是要好好考虑一下,自己打听一下这些岗位的前景如何,再做决断。根据我询问的诸多大佬们,他们是建议能后端就不要选以上那俩,这也是我最终放弃其他的原因之一。 **语言**,说实话这个其实无关紧要,不过还是得说一下,我是 Java 转 Go,如果是转小语种的话……建议好好想想以后,这语言的岗位公司需求多不多,别给自己挖坑。 **城市**,这个非常重要,毕竟你要到一个人生地不熟且无亲无故,一切都要自己从头去打拼;既然选择了远方,就只顾风雨兼程。 **镀金**,如果你和我一样,出身不好,那不如找个大厂镀金,干个几年再出去,也可乱杀。 **薪资**,虽然没人能和钱过不去,不过眼光还是要放远,不要只看近三年能赚多少,看的远一点,一劳永逸。 最后,如果事情少不加班就更好了~ 摸鱼的快乐,难以形容。 ## 感悟 在我做出最终的选择之后,也没再去投递了,说实话,累了。 既然选择了这条道路就不能后悔,既来之则安之。 没有哪家企业一开始就是大厂,一个地方正是因为不够好,所以才需要去建设,机遇就蕴含在其中。 之前在博客上看到一句话,这里贴出来给大家。 > 加入大家口中的好公司,好部门而沾沾自喜,显得幼稚而虚荣。但凡思考过如何实现自我价值的互联网人应该都明白一点,一个已经成功的产品带来的荣誉感并不足以填补自己内心的不自信,也不利于提升自己;有能力的人最终会选择打造自己的产品,哪里能给最大的自由,能最大限度实现自己的想法,哪里就是最好的地方。 > > > > 刚毕业的人喜欢谈论工资,为了一点月薪的差异破坏自己的心情,但到后面会发现,随着工资增长自己的力量并没有变大。最重要的,还是得升级角色,一个高阶角色,并不只代表着权力,更多的是责任和保护的能力。 > > https://www.gonglin91.com/2018/04/18/beijing/ 我不相信宿命论,每个人都可以凭借自己的努力去实现梦想,一开始的我不相信,当它发生在我身上时,我信了,当时发誓,作为感激,我要保持学习的习惯。 另外建议大家少看一些负能量的东西,在脉脉上,我发现很多人都抱怨自己当前工作不顺利不满意进行宣泄,这些东西都会潜移默化的影响你,保持一颗积极乐观的心,做好本职工作就好了。 我是学一个东西很慢的人,**既然我都可以,你为什么不可以!** ================================================ FILE: reader_nb/4_reader.md ================================================ # 拿下腾讯天美 offer 的春招经历 大家好,我是小林哥。 前段时间,有位读者跟我发消息说,他拿了这三个实习的 offer。 ![图片](https://img-blog.csdnimg.cn/img_convert/4d5d11196720fc263664e7ee91383d00.png) 不知道你们惊呆没,我是当场惊呆了,感觉这三个闭着眼选都没问题,这个问题就好像是选北大还是清华,就是这么朴实无华。 后面他选择了去天美,原因是他本身对游戏兴趣就很强烈,他学习编程的起因也是源于游戏,他对游戏的热爱是从小就有的了。 看到这,大家很想看这位读者的经历吧? 小林有先见之明,所以今天邀请了这位读者来分享他的校招经历和学习经验。 不多 BB 了,直接发车! ## 基本信息 Hello,这里是伞兵一号。 本人是一个小菜鸡,双非,软件工程专业。 本科一直在 ACM 实验室,但是面试结束之前都没有拿过牌(没打过现场赛)。 自己由兴趣做了两个项目,一个是大一时候做的 Unity3D 的一个 FPS 游戏,还有一个是 CV+AI 的一个项目。 本次春招靠运气拿到了几家不错的 offer。 字节跳动今日头条后端。 ![图片](https://img-blog.csdnimg.cn/img_convert/0c34b84bb61519b55c84005d56f1788c.png) 腾讯天美服务器后台开发。 ![图片](https://img-blog.csdnimg.cn/img_convert/07efce5886cb4f1f4815fe4bf417b46e.png) 阿里阿里云 C++研发。 ![图片](https://img-blog.csdnimg.cn/img_convert/62bf3127e81fe1d893b40ddfe7f21a1a.png) Momenta Deep Learning. ![图片](https://img-blog.csdnimg.cn/img_convert/7a204e44e1361f38deaa31d4940d2dfa.png) 大疆自动驾驶测试开发。 ![图片](https://img-blog.csdnimg.cn/img_convert/55a65c7fe7938b2f0d56effa3c6c5936.png) 当然,也不是一帆风顺,面挂了有: - 微软苏州 SDE(被调剂,相当于挂) - 网易雷火笔试 ## 春招经历 #### 三月 都说金三银四,但是因为我觉得自己太菜,三月份我不敢投任何一家,3/20 号微软截至投递,因为我对 WLB 的追求,微软一直是我的 DreamOffer。 所以我在 3/19 投出了我的第一份简历,可以看到,简历上实际上大部分是用来充数的,项目的话除去上面两个项目其余都是课程设计,奖项也因为本人太菜和本次 ACM 赛季的延迟,含金量不是很高,所以我当时投出去内心是十分坎坷的。 ![图片](https://img-blog.csdnimg.cn/img_convert/67553f669b807a67d4bbaf17f2669c1e.png) 幸运的是收到了微软的笔试通知,3/27 参加了笔试,拿到了满分,到这里三月份就结束了。 ![图片](https://img-blog.csdnimg.cn/img_convert/c4be8fc7593ce74cb144a4bfd912d718.png) #### 四月 *4/1*,下午投了网易雷火,晚上就笔试,我把自己作死了。 具体是怎么回事呢,大概是我看到一道 dp 可写,但是我没写,我觉得最后一题那个数电模拟题比较有意思,我就去写那个了,但是一直 TLE,最后 2.5 道题耻辱退场,成功把自己玩死了。 *4/8*,收到了微软的面试邀请,此时我对于除去 C++ 之外所谓的八股文完全没有看过,但是听说微软那边主要注重于算法。 我也没有专门去准备「八股文」,就这样,我开始了人生中的第一场面试,第一场面试体验非常好,一直聊项目。 最后写算法因为紧张还听错题目了,发现听错之后面试官非常 nice 的说,没事,就当作是你写了两个题。 *4/16*,微软终面,面试很顺利,后面面试官问我,如果以你的 FPS 项目为基础,设计一个多人对战服务器,怎么设计,并且面试官和我说:「如果这个问题你能答上来,我觉得今天面试结果还是非常不错的」。 我之前也没仔细看过这方面,就凭借着多年游戏经验答了,我答了传包协议和原因,具体设计,面对高负载采用主从模式容灾 + 分布式处理,采用类似于帧同步的方式。 面试官认同了我的方案,说我说的是一种非常经典的游戏服务器设计方案,面试结束之后我去问了在腾讯搞游戏的学长,他说我说的都在点子上。 我这时候以为大约有 5 成把握了,而且很奇怪,面完之后我就不想面其他家了,一直在等消息,那段时间很焦虑,学不进东西。 *4/26*,转折点来了,我那批面试的人,微软发了第一批(也是唯一一批)offer,没有我,这时候我开始慌了,开始去准备简历投其他公司。 *4/27-4/30*,投了 BAT、Momenta、大疆。 大疆 4/27 当晚直接面试,Momenta 也约当晚,但是以为那周考试,我推到 4/30 面试。 两家面试都很顺利,4/30 当天收到了 momenta 约我 5/6 二面的通知。 #### 五月 *5/1*,收到了腾讯通知 5/6 面试一面(如果不加班的话,为什么 5/1 通知呢,如果加班的话,为什么 5/6 面试呢)。 五一小长假大概过了一遍小林的「**图解网络**」和挑着看了看「**图解系统**」。 *5/6*,下午 Momenta 终面之后腾讯一面,也蛮顺利的,面完腾讯一个小时后通知晚上二面,二面是一位 old-fashion 的工程师,用 c 写了算法。字节也约了 5/8 一面。 *5/7*,腾讯约 5/8 三面,大疆发了 offer。 *5/8*,上午字节一面很顺利,中午约了 5/10 二面,阿里这时候也约了当天晚上一面,下午腾讯三面聊的很开心,面试官非常认可我项目的解决方案,之后开始聊游戏设计和游戏服务器技术的发展,聊的很投机,三面结束后几分钟,官网显示到了 HR 面试阶段。 过了一会约了我 HR 面,当天晚上阿里一面,问的很难,我大概凭借经验答上了 60% 左右,我以为我挂了,过了十分钟左右打过来直接二面,二面相对顺利。 *5/10*,下午字节二面结束之后直接三面,聊的都很不错,然后直接 HR 面,不得不说,效率是真的高啊。Momenta 发了 o ffer。 *5/12*,字节发 offer 了,阿里约我第二天三面,晚上腾讯给了口头 offer。 *5/13*,阿里三面,本来是钉钉面试,因为网络问题变成了电话面试,聊了项目,给面试官介绍了 ACM 比赛(面试官问我,说我本科在 ACM 实验室搞算法,为什么连 SVM 的实现也不了解,所以我介绍了 ACM 比赛。 *5/14*,坐上了去银川的火车,坐了十个小时,第一次去参加 ICPC 线下赛,运气好,拿了银牌(银川赛站相对其他赛站含金量要低)。 *5/17*,在回来的路上收到了阿里约 HR 面的电话,(因为火车进山了,打了三次才约成时间,麻烦人家了。 *5/19*,腾讯发了 offer,阿里 HR 面,面完之后当晚发了 offer。 至此,我的春招结束了。 总结一下吧,能拿到这些 offer,运气也占很大一部分。 面试中项目占了很大一部分时间,和面试官聊的也很开心,也得到了许多很棒的改进建议,充分的认识到了自己的不足。 其实这么多场面试下来,我觉得更多的不是像考官 - 考生这样的关系,**更多的像是两个「技术宅」在聊天**,都感觉到大家非常友善,互相交流技术,面试体验一级棒。 ## 学习经历 记得当时在知乎上看到这样一个问题:「**你为什么选择计算机专业?**」 我在这里选一部分我当时的回答。 一句话,源于**游戏**。 小时候家里在山村里,爸爸到城里去工作,家里一般只有我和我妈妈,我哥家有一台小霸王游戏机,我就几乎天天都想跑去他家顶蘑菇,玩雪人兄弟,魂斗罗。两个小孩玩的也蛮高兴的。 后来应该是我爸妈看到了电视上小霸王「学习机」的广告,以为真的能「学习」,就给我也买了一台。结果到手之后发现不太对劲,就和我说每周只能礼拜五玩一个小时。 六岁时候我搬到了城里,这台游戏机又继续陪伴了我大约三年的时间,那时都手柄很劣质,大概十块钱还是五块钱一个,经常十字键就搓断了,只剩一个摇杆,我就那张卫生纸垫在上面继续玩,经常把我大拇指的皮搓破。。。 每次在外面受了伤或者去医院看完病回来,疼的不行的时候我妈就和我说:“去吧,打开游戏机玩会”。 每次打开游戏我的疼痛就瞬间荡然无存。 慢慢的,我也学皮了,学会了偷偷打游戏,好几次我妈摸了摸电视屁股,然后我就遭了打。。 到后来小霸王挂了,家里买了台世嘉 MD2,当时的第一感觉只有震撼。 “这上面的画质太好了,游戏也比「黄卡」游戏机上的好玩”。 还有一个经常和朋友们交换的密码本。 不过「黑卡」游戏机的卡带太贵了,记得当时买的一款国内的游戏是 40 元,带纸盒和说明书。 这对于当时一周 5 块钱零花的我来说是要攒两个月左右的。 形成鲜明对比的就是之前的「黄卡」,几乎都是 x in 1,不过这也给我带来了好游戏值得花钱的感觉。 沙丘魔堡,幽游白书,光明力量,战斧,炸弹人,Super Off Road 等等,好游戏太多太多了。 另外有个小插曲就是我小学一年级的时候得了过敏性紫癜,住院了。 有两个东西让我印象深刻。 一个是我妈妈送给我的一本书《小熊开店记》,还有一个就是我爸爸的朋友送给我的电子积木玩具,这两样东西也是对我数学和电路的启蒙吧。 初中的时候,通过盗版 cod 接触到了主机游戏这一块儿,和好朋友边玩边赞叹这他妈就是艺术。 当时顶着 800x600 的分辨率和全低画质打完了 cod9,从此开始研究硬件,天天心心念念着我那份 1200 块极致性价比装机单。 100 多块钱买了根三星 2G 内存条发现电脑滴滴的响,商家又给我换了一条蓝魔,插上能用了,我的电脑也升级为了 4G 内存,装机的梦想也是高考完了才实现。 12 年接触到了 steam,也第一次买了正版游戏,省吃俭用一年花 240 买了盒装的 cod8。 大概是 13 年吧,有一天好哥们拿着本 ivon horton 写的《C 语言入门经典》神秘兮兮的和我说: “咱们玩了这么久的游戏,要不要写个游戏玩玩”。 就这样,初中时候接触到了 c 语言,算是知道了这么个东西(初中课上 vb 没好好学)。 从此立下了改变中国游戏局面的志向,进入了这一个大坑。 “我们总想着改变世界,其实不被这个世界所改变已是奢求” 中考之后我去了一个垃圾省重点,好哥们去了省内三大高中之一。 高中期间他继续阅读 C++ 入门,我看完那本 C 之后也就没写过代码。 高考我发挥失常,他发挥正常,他去了某 985 学 CS,我到了双非学 SE。(我们两个学校的名字也挺有意思的,我经常和他说我们是兄弟院校) 这里插一句,到现在实际上挺感慨的,性能不错的电脑也有了,也用奖学金买了 switch 和 PS,可是玩游戏的时间却越来越少了。 到了大学,一开始自己捣鼓了几个月 unity,做出来个 FPS 游戏,自己通过 youtube 和官方文档好不容易把重力系统捣鼓出来,又加了加技能,特殊弹药和 BOSS 战,虽然啥也不懂,但是慢慢捣鼓出来,很开心。 后面听说了 ACM 比赛,开始自学刘汝佳老师的《算法竞赛入门经典》(所谓的「入门经典」,我到现在还没刷完),之后进入了 ACM 实验室,进入了天坑。 算法的学习,如同吉老师说的,现在 ACM 算法竞赛和实际工作需求的偏差越来越大。 我个人 ACM 的学习过程是相对曲折的,因为我们是自己找题训练,在学完基础的算法过后,有一段时间进入了迷茫期,不知道学什么,怎么学,现在的话有譬如 acwing 这样的网站可以系统性的针对算法竞赛进行一个学习,我认为是很不错的。 就我本次春招找工作中遇到的算法题来说,总体难度不是很高,如果你有 ACM 经验的话当然更好,但是没有搞过 ACM 也没有关系,掌握基本的算法思想如**二分,分治,普通的动态规划,DFS/BFS**和基础的数据结构如**树,栈,队列,堆,链表**即可。 我个人的话是在 leetcode 上刷了 100 多道题目左右,难度分布大概 4Easy-4Medium-2Hard 这样子,如果没有 ACM 经验的话推荐刷 300 多道应该也差不多,重点在于掌握思想,而不是去死记硬背。 有 ACM 经验的我也建议大概刷一下 leetcode,相较于 ACM 比赛还是有一点差别的(当然,大佬除外)。 面试题中思维题居多,想锻炼思维的话也可以到 codeforces 上刷一些题目,推荐 DIV3 的所有题目和 DIV2 的前三道题,非常锻炼思维能力。 学习方面的话,专业课均分保持在 90+,因为考试相对简单。 我认为,**计算机基础是最重要的**,什么语言无所谓,语言只是一个工具,只要对计算机足够了解,学任何东西都会快速上手。 从那时开始买书,我在实验室的工位也慢慢垒起了一座小书山。 这是我大二时候的座位。 ![图片](https://img-blog.csdnimg.cn/img_convert/c279b882a62407a74c2e2d19d79ca39d.png) 阅读经典书籍,让我受益良多,特别是黑皮书系列,yyds! 我印象深刻的是《计算机组成原理:硬件/软件接口》书中说的计算机系统结构的八个伟大思想: ![图片](https://img-blog.csdnimg.cn/img_convert/8985bd6398fb2f8eb2e514801a049c2d.png) 正如 David Wheeler 所言: > All problems in computer science can be solved by another level of indirection > > > > “计算机科学中遇到的所有问题都可通过增加一层抽象来解决” 后面学习不论是计算机网络,还是操作系统,都对这几个思想印象深刻,感觉都是这几个思想的具体化实现,类似于 cache 或者 Redis 这样的技术,我认为实际上就是利用了加速大概率事件,和存储器层次的思想去进行设计和加速。 在选择专业方向分流的时候,我觉得,程序员多少应该学习底层(只是本人的拙见),我选择了有 x86 汇编,arm 汇编的方向,我认为这些属于程序员的内功。 尤其是 C++ 程序员,要对汇编和内存有具体了解,看到代码,我们要具有通过编译器视角,CPU 视角去看代码的能力,所谓的「知其然,知其所以然」。 如同侯捷老师所言:「学习需要明师。但是明师可遇不可求,所以退而求其次你需要好书,并尽早建立自修的基础。迷时师渡,悟了自渡,寻好书看好书,就是你的自渡法门。」 我在学习过程由衷的觉得机械工业出版社出版的多本经典书籍让我受益良多,比如龙书《编译原理》,上文提到的《计算机组成原理:硬件/软件接口》,还有《计算机网络自顶向下方法》《现代操作系统》《汇编语言基于 x86 处理器》等等,都是经典中的经典。 还有 effective 三部曲,《代码大全》《深度探索 C++对象模型》《STL 源码解析》等好书,无一不让人读起来大呼过瘾(当然我还没全读完,最近准备去补一下 CSAPP,英语版好贵啊!) 英语对于程序员来说也是重中之重,有很多时候要去外网求解,比如 stackoverflow 上和暴躁老哥激情对喷,到 github 上和大家同性交友等等。 不过我学习英语的道路比较畸形,小时候上了几年补习班背了几千词,之后再也没有背过英语。 后面英语的提升就是通过看视频和玩游戏和老外聊天,友(激)好(情)交(对)流(喷)来学习…… ## 一点建议 小林哥让我**给在校生一点学习建议**,但我也是在校生,谈不上能有什么资历给大家建议,只能说给大家分享一点经验吧。 如果您是刚入学的大一大二的学生,那么我建议您**要着重计算机基础知识的学习**,语言只是表达思想的工具,对于计算机的理解会帮助您去理解学习语言。 同时也**要****注重实践**,多去写代码,学会使用 Debugger 去调试,选择一个现代的 IDE,可以帮助您去更好的学习,如 JetBrains/VisualStudio 等,这些学生都可以去申请免费试用,可以去申请 GitHubStudentPack。 学习 Linux 也可以使用 WSL 去学习,相对安装 Linux 系统来说要简单许多,同时也不要忘记了学习代码风格,代码风格太差会影响你找 BUG 的能力和耗费的精力,也有可能造成一行代码报五个错的奇葩情况。 还要学会如何使用搜索引擎,查找文档可以去 Microsoft Document、IBM Knowledge Center、cppreference 等,找具体问题可以去 Github 和 StackOverflow 等,学会如何提问也是一种很重要的东西。 对语言不必要求什么都学,我认为到毕业时会使用一门 OOP 语言和一门脚本语言即可,语言实际上是一种触类旁通的东西,重点在于思维的培养。 要去大胆的做一些自己当前能力之外的项目,在学习完基本的软件工程学的基础上,或者说只要你了解了面向对象思维和基本的设计原则如「高内聚,低耦合」。 去做一些自己真正感兴趣的事情,自己去设计这个软件的架构和实现,所谓实践出真知,不要惧怕做有挑战性的项目,Just do it(干就完了)。 对于未定义行为 (undefined behavior) 不必耗费太多精力 (如`++i+++i+++i`这样的毒瘤题目。 如果您和我一样是大三大四的同学,正在找工作或者准备找工作,那么我推荐您大致看一下面试经验,复习「八股文」,实际上我建议您结合之前的学习去理解,很多东西是上文提到的八大思想的具体实现。 以我个人为例,我在学习「八股文」期间,很多东西是去理解,把他挂载到之前的一个知识点上,面试开始前几个小时去大致看一下具体细节,在理解的基础上,您可以和面试官款款而谈。 对于「八股文」,我们要尽量融会贯通,建立起完整的计算机体系知识结构,这样学习起来会事半功倍。 对于算法的训练,如同上文,刷一刷 leetcode,总结一下算法思想,再加上适当的练习即可。 对于自己写的项目,大致回想一下有哪些技术难点或者有趣的地方,能够具体从想法的出现,解决的方案,中途的问题,后续的测试,未来的发展方向做讲解即可。 ## 总结 说了这么多,总结一下。 实际上回顾我这一段时间的学习生活,其实都源自与对于计算机的兴趣,其实很多时候不像是在学习,更多像是在玩,在我的眼中,计算机是个很有意思的东西,不论是自己去组装台式机,还是去写代码,更多的是基于兴趣的学习和实践,我认为实践在这门学科中和学习知识是同等重要的。 我始终认为,计算机基础知识是一切的基础,具体语言,各种工具只是「外功」,但是计算机基础知识就是「内功」,只重视外功的招式,而不注意内功的修行,是不可能成为高手的。 最后以我很喜欢的侯捷老师的一句话做结尾: > “万丈高楼平地起,勿在浮沙筑高台” 实习只是开始,今后还望能继续进步,与君共勉。 本文只是个人观点,只能给大家提供我的学习历程和思考角度,并不代表这样就是对的,我也只是一个在慢慢摸索的菜鸡罢了,也受我本人狭隘眼界的局限性,还请大家见谅。 十分感谢小林哥给我这个机会能够和大家进行经验交流与分享。 在这里祝看到这篇文章的您,**万事胜意**。 ================================================ FILE: reader_nb/5_reader.md ================================================ # 机械工作 2 年,自学转行进腾讯了! 大家好,我是小林哥。 之前分享过很多读者校招拿到大厂 offer 的经历,他们都有一个共同的特点。 就是在大学期间对于**计算机基础准备**的很充分,比如数据结构与算法、操作系统、计算机网络、数据库、Linux 这些。 然后期间就有很多读者问我,对于**社招或者转行又应该怎么准备呢?** 前段时间,就有位**在机械行业工作了 2 年的读者,成功转行了程序员,而且还进了腾讯**: ![图片](https://img-blog.csdnimg.cn/img_convert/c765439bcb2b1c7b3ede4093e5e94511.png) 我知道大家肯定会很好奇他的经历,所以小林已经邀请这位读者来分享他的转行的经历。 当时我看完后挺震撼的。 震撼在于他之前的机械工作,竟然晚上 10 点才下班,每天工作 14 个小时,这工作强度不比互联网大厂少,而薪资却不及互联网一半。 这个工作强度,使得他在上班期间根本没时间自学编程,都是利用周末的时间从早学到晚。 而且**还是持续自学了 2 年**,我开始以为他是自学了半年后转行的,没想到一直坚持那么久,点个赞~ 而且这位读者说他自己挺幸运的,他最开始转行找工作的时候,目标并不是想去大厂,只是想去小厂,**结果事与愿违**,非常滴魔幻! 相信这位读者的转行经历,能鼓舞到那些也有此想法的朋友。 ![图片](https://img-blog.csdnimg.cn/img_convert/d29e1c7af81262f4616fdf7dbbbb2d2a.png) ------ ## 前言 大家好,很感谢林哥给我这次机会分享,介绍一下我自己。 我 19 年毕业于武汉理工大学,毕业后一直在做机械研发的工作,**自学了两年 Linux C++ 编程**。 如今在武汉找到了一份编程开发的工作,现在我将我的经历写出来,希望能给大家一些帮助。 ### 为什么要转行? 转行的主要原因是因为毕业设计要做一个串口播放器。 当时做了播放器和串口通信的实现分别花了一个星期,怎么将二者打通折磨了我很久。 这期间我借了很多书,看了很多博客,看了很多视频,完全没有相关内容。最后论文指导老师都劝我放弃了,不然毕业论文就写不完了。 没法子,只能先放弃。 虽然口口声声说放弃了,但我的内心还是不想放弃,有好几晚都失眠了。直到一个周四的晚上,我依旧失眠到两点。 不过这一晚不同,我翻来覆去睡不着,脑海中突然闪过一个很好的 idea,害怕这个 idea 飞走,我悄咪咪的爬下床,打开我的电脑,顺着思路敲下代码,越写越兴奋,心里不断传来声音说「对的,这是对的,哈哈哈~」。 当我点击编译,一个一个功能进行测试,和预期功能完全一样,那一瞬间成就感填满了我的内心。 这时一道阳光透过窗照在我脸上(武汉夏天太阳出来早),原来已经天亮了,这是我第一次对一件事这么着迷,就想着以后做这方面的工作。 下面这张图就是我当时做的串口播放器。 ![图片](https://img-blog.csdnimg.cn/img_convert/f0d4104b6b973c13fda9973f5ec58ccb.png) 可以通过右侧的按钮进行控制,也可以连接串口后,接收相应的串口信号,进行视频的播放、暂停、全屏之类的操作,同时还可以发送和显示接收到的串口数据。 虽然现在现在这个播放器看起来很简陋,但当时足足花了我一个月的时间,是我的第一个可以直接体会到的程序,我一直将它放在我的桌面上。 **当学到厌倦的时候,都会打开它,提醒自己走到这一步的初心。** ### 选择什么方向? 因为我是机械出身的,我最初的想法是转到与机械关联性大一点的岗位 -- 嵌入式。 心里想着悄悄的学习,然后司内转岗,惊艳所有人(很幼稚的想法)。说干就干,我先在网上找了学习路线,制定了学习计划。 需要学习 51 单片机,就在 B 站找到了郭天祥的 51 单片机教程,还淘宝买了个 51 单片机,照着教程一点一点的敲,有不懂的就看书。 很快就遇到了瓶颈,书上有个电路图我看不懂,出于「我不仅是想找一份工作,我还想把这份工做好」的想法,我又去网上找学习路线。 网上说嵌入式得学数电模电,学数电模电又得学电路分析、微机原理等等。 于是我做了一个错误示范,在 MOOC 上找了北理工的基础电路分析课,还淘宝买了对应教程。 一学学了两个月,堪堪对着视频学完,想着厚厚的数电模电,还有树莓派之类的要学,BOSS 直聘又查了一下单片机开发的工资,得出结论 -- **我不适合嵌入式**(学东西直接一口气学完,不要老想着完美主义)。 接着我又开始了寻找学习路线的路途,因为限定语言是 C++,找到四个路线嵌入式、音视频、服务器、游戏,考虑未来准备发展城市的互联网环境和房价,**最终决定做服务器开发**。 接着就是在知乎上查找服务器开发的路线,看了很多,根据自己的情况,制定学习计划,再按计划实施就可以了。 ### 学习方法 我一直坚信选择比努力重要,好的方法可以事半功倍。 我提供一下我自学期间的方法,或者说是基本原则,虽然不一定适合大家,但希望能给大家一个参考。 - 睡眠最重要,睡得好,才有精力同时应对工作和学习,每天晚上必须保证七个小时的睡眠,中午也要午休一个小时; - 学习少便是多。听懂一个知识点,比略略看了三四个点更重要; - 刚开始学的时候看视频,快速构建知识框架,后续看书补全知识框架; - 做好笔记,以输出为目的进行学习,推介用 typora 做学习笔记,将自己每天所学的内容记用 typora 记录下来; - 关注相关领域的博主,比如说@小林 coding,加入他们的技术交流群,你最后会发现,你即使潜水,也能学到很多; - 将所需要学的分为知识和技能,二者相辅相成,但知识偏理论,要记下来,技能偏实践,一定要亲自动手。 ## 一边工作一边自学 ### 背景 我的本专业是汽车类的,当时高考结束选择机械专业的原因就两个。 第一是认知有限,无人指导,只知道程序员秃头、教师要改作业、材料要做危险品实验等等,一众专业中唯独机械万金油,什么都会,什么都能干,而且越老越吃香,因为是农村的,一心求稳,害怕找不到工作,机械就成了首选专业。 其二就是这个专业是我们学校的王牌专业,本着宁为鸡头,不为凤尾的原则,就不考虑更好一点学校的普通专业。大四找工作时才知道,「什么都会」代表着「什么都不精」,「越老越吃香」代表着「年轻时不吃香」。 当时最大的愿望是能找到一份年薪十万的工作,能拿到年薪十万的基本上都是学院里的佼佼者,拿到十五万到二十万就是学院里的 top 同学,再往上我就没怎么听说了。 现实却是,大多是四五千月薪的工作,而且得经过层层选拔。印象最深刻的是一个知名汽车厂,面试了三轮,还要写小作文,最后一问工资四千五。 偶然间参加了一次互联网公司的宣讲会,听到 17 万只是他们公司的白菜价时,我整个人都惊呆了,我只能告诉自己「机械专业越老越吃香,他们三十五之后有淘汰危机,而我没有。」 现实往往是惨痛的,**我在前一家机械公司呆了一年半**。工作节奏是**周一到周四早 8 晚 10,周五早 8 晚 5,迟到要罚款,周六可以晚点去,但一定要去上白班**。 虽然中午可以休息两个小时,下午也有一个半小时吃饭,但每次下夜班都是十点了,回到寝室只能简单洗漱一下就睡了。 虽然每天的在工位上的时间只有「区区十小时」,但真正自己能支配的时间只有晚上十点半到第二天七点五十,每次周日的时候,只想睡觉,一觉醒来,发现明天又要上班了,感觉自己完全没休息好,马上又要上班了。 仅仅一年的时间,跟我一起进去的小伙伴体检都有了大大小小的问题,我也有了脂肪肝、甲状腺结节、糜烂性胃炎,有时候还会心悸、胸痛。 最开始的计划是一边上班、一边自学,转行成功了再辞职。 直到第去年六月份的时候,带我的同事去年绩效打得不好,公司要求末尾淘汰。 他是公司刚成立没多久就来了的,被公司优化了,本来没什么,拿个 N+1 走人也可以的,恶心人的却是,公司用了网上各种套路,诸如威胁、调岗、最低工资等等招式对付我的同事。 最后达成协议,工资降一半,继续留下来,我同事也同意了。 但不到一个月,又说上面政策变了,还是得走人,可以给 N,但是是降薪后的 N,我的同事彻底心凉了,不愿意在纠缠了。 这件事也让我彻底心凉了,即使董事长说「发动机不过是一坨铁」,我们研发人员也只是相视一笑,直到这件事发生,我们才明白----**这公司没什么人情味**,我们都可能是下一个「他」,谁知道明年会不会优化我们呢? 即使我们努力加班,年纪轻轻就一大堆毛病,也阻止不了被优化。但这已经是机械行业里的 top 企业了,集团还是世界五百强,从业人员都还是这种待遇。 若是出去,其它机械小厂是不是更糟糕,这更加坚定了我转行的决心。 后来了解到仅仅这个公司这样,广丰广本上汽这样的明星企业还是很好的,但进去的条件极其严苛。 小鹏、蔚来的互联网车企待遇也很好,但需求更大的是「软件人才」,不是机械。 **如果大家有志于汽车行业,也不要因为我的经历而寒心,我的第一家公司代表不了所有的机械行业,只能给大家一个参考。** **如果有志于发展中国的汽车行业,建议大家可以学计算机、通信、电气,然后进入车企当嵌入式工程师,结构岗我就不推介了,这个坑我已经踩了。** 言归正传,说说**学习节奏**。 为了保证充足的精力应对学习和工作,我基本上都是晚上十点下班,回到宿舍洗漱一番,和女朋友聊聊天,十一点就赶紧睡觉了,然后第二天早上 6 点起床看书,看到七点四十就去吃早餐上班(住在厂区,离办公地点很近)。 这样的节奏一般保持六天,周日就睡半天,打打游戏,看看电影,放松自己,下周继续上一周的节奏。 周一到周六除了早上固定的学习时间,我也会在中午或下午下班看半小时书,不过效率没有早上高,故而早上一般学习最重要的内容,其他时间看博客理解早上学的内容。 **在这一年半的时间里,我只学了 C++、操作系统、计算机网络、数据结构和算法,基本上都是在 MOOC、B 站上找视频自学的,看完视频再看书进行补充。** ### C++ 的学习 我在 B 站看黑马程序员的 C++ 课程入门的。我基本是先看完一个章节的内容,心中有了大概的理解,就在 VS 上复现出来。 我对自己的要求就是--**一定要复现出来**,因为有种错觉就是--我以为我会了,一写代码就卡壳了,争取每天学习都动手敲代码。 作为初学者,我学了三四个月,在此期间,**一直秉承着「少便是多」的原则,甚至会刻意放慢速度**,如果发现前面的忘了,就重新再学一遍,而且第二遍第三遍会快很多,而且还会发现之前忽略的细节。 得益于这阶段 C++ 学得很慢,对基础知识记住得比较多,后来面试看看面经,C++ 相关的问题基本能回答出来。 我学完后没看书,因为《C++ primer》太厚了,直至面试,我也只是略读了一次。 ### 操作系统的学习 操作系统我是在 MOOC 上看北京大学陈向群老师的现代操作系统学的。 ![图片](https://img-blog.csdnimg.cn/img_convert/57fc76e8139d3f5ccb000ce55834b42c.png) 不过现在 B 站也有视频,操作系统还有其他很优秀的课,比如华科的、哈工大的、清华大学的,我选择北京大学老师的课是因为我买了《现代操作系统》这本书,课程刚好对应上。 学习过程中,操作系统给我的感觉就是非常的抽象,这一阶段学得非常慢,进程线程我都是看书才看懂的,不过这一阶段要学的基本可以分为四个部分:**进程线程、内存、文件系统、I/O(面试问得很少,理解即可**),其他的部分可以后续遇到再学。 学习节奏基本保持着每个星期只学一章,多了不学,也不能少。 ### 计算机网络的学习 计网我是通过哈工大的计算机网络学的,配套书籍是《计算机网络自顶向下》,我只看了视频的**应用层、传输层、网络层、链路层、物理层**。 ![图片](https://img-blog.csdnimg.cn/img_convert/0022889beebeb466a842d064c8c6f2f1.png) 到传输层的时候我就感觉有些吃力了,还好当时我遇到了宝藏博主@小林 coding,小林的图解让我有一种醍醐灌顶的感觉,原来课上理解不清的概念都被小林图解得明明白白,衷心推介大家关注。 ### 数据结构的学习 我的数据结构是通过浙大陈越姥姥的课学的,B 站有视频,大家可以去搜。 ![图片](https://img-blog.csdnimg.cn/img_convert/cb1f3f626adfafd6b6e9e92094efb1f9.png) 这一课程需要达到的目标是,知道什么是**数组、链表、栈、队列、堆、树、哈希表、图**这些数据结构。 最好能照着视频实现自己的数据结构。只要能将这些实现出来,基本就可以进入下一阶段了。 ## 在家自学半年 在家这半年主要做了两件事: - 第一是将之前学的操作系统、计算机网络、数据结构和算法、C++ 进行总结,用 xmind 将每一个内容的知识点脉络整理出来,用 typora 将比较难得知识点记录下来。 - 第二就是进行实战训练,学习编程技能,比如 linux 的基础 shell 命令、vim、gdb、gcc、make、git 的使用,学完这些后,开始跟着 acwing 刷题,刷完算法基础课后,学习 linux 系统编程和 linux 网络编程。 ### 编程基础技能 ![图片](https://img-blog.csdnimg.cn/img_convert/a9da5ec5d05c1d5aa6475946d615181d.png) ### 刷题 我加入了很多博主的技术交流群,在里面一直潜水,但依旧学到了很多。 有一次就知道了 **acwing 的算法基础课**,B 站试看了一下,发现正是自己所缺的刷题套路。淘宝买了正版码,就开始了学习刷题套路之旅。 最开始的时候,我想着把课程看完,再回来做练习题。实行了一个星期,发现自己还是什么都不知道。 复盘了一下,还是自己太急躁来了,就给自己定下了**少就是多的原则**,一定要把题目刷出来才进入下一 阶段。 我开始以写出来为目的进行学习,如果今天写不出来,就看题解,去理解,理解后自己写两三遍,直到能随手写出来。 不出意料,我的进度慢得出奇,一个下午只理解了一个题,不过我还是坚定这个方法是可行的。 皇天不负有心人,一星期后我发现自己慢慢地摸索到了刷题的节奏。渐渐的能跟上视频进度了,学了两个月,终于把 acwing 的算法基础课学完,虽然花的时间比较长,但还是值得的。 在我遇到的面试中,但凡是要求手撕算法的,都能在算法基础课里找到一个类型的题。 ### linux 系统编程 & 网络编程 我将 linux 系统编程、网络编程看做是操作系统、计算机网络的技能体现,既然是技能,就一定要练。 这两门课我是看 B 站黑马程序员的视频学的,在 B 站输入关键词就能找到。这两门课一定要 学完操作系统和计算机网络之后再学习,不然很多东西都听不懂。 还有就是**一定要做笔记**,下面就是我做的 linux 系统编程、网络编程的笔记,可以在视频下方找到别人的笔记,进行增删改查,变成自己的笔记。 ![图片](https://img-blog.csdnimg.cn/img_convert/e269f181bb13b29c68ee21445011e713.png) ![图片](https://img-blog.csdnimg.cn/img_convert/07546b0156febac3f918887d716c4261.png) ## 开始面试 ### 八股文 如果说学习计网、OS、数据结构是让自己有理论基础,学习 linux 系统编程、网络编程是让自己有编码基础,那么**八股文就是让自己有面试基础**。 有些知识点你可能掌握了,但问的时候却回答不上来,终究还是不熟悉套路。 可以在 CSDN 输入「XXX 八股文、XXX 面试题」这些关键词,就能找到很多,收集到了就复制到 typora 中,达到一定量了,就按自己的知识地图分好类,面试前做看一看就可以了。 对了,**小林的《图解系统》和《图解计网》,大家一定要在面试前看明白,看明白了,系统和计网就 不怕面试官问了。** 关于八股文的收集,给大家推荐一个我关注的博主@拓跋阿秀,这位博主非常的优秀,本身拿了字节 offer,他在他的公众号上分享了他收集的八股文资料,我的大部分八股文都是来自于他分享的资料。 说一说我在面试中遇到印象比较深的面试题,希望能能给大家一个参考: #### C++ 1. 说说 const 与指针? 2. 定义常量有哪些方法?有什么区别? 3. 声明和定义的区别? 4. static 的作用? 5. 指针和引用的区别? 6. vector 和 list 的区别? 7. map 和 unordered_map 的区别? 8. map 和 multimap 的区别? 9. 类的三大特性是什么?说说自己的理解 10. 多态的实现原理(虚函数表指针方向) #### 操作系统:(看小林 coding《图解系统》就没问题了) 1. 进程与线程的区别? 2. 什么是内核空间? 3. 内存空间分配? 4. 内存分配算法 5. 内存页面为什么是 4k 大小最合适? 6. 什么是外碎片、内碎片? 7. 什么是硬连接 #### 计算机网络:(看小林 coding《图解网络》) 1. get 和 post 区别? 2. 三次握手? 3. 四次挥手? #### linux 系统编程 1. 进程通信方式? 2. 线程通信方式? 3. 线程同步方式? #### linux 网络编程 1. select、poll、epoll 区别? 2. 说说你理解的 IO 多路复用? 3. 说说你做的 web 服务器? #### 算法题:(要求手撕) 1. 两个有序 set 容器合并成一个有序序列(归并排序) 2. 求一个字符串里有几个子字符串(kmp 算法) 3. 数字转换成罗马数字(考虑所有情况即可) 4. 将一系列区间合并成几个闭区间(区间合并) #### 其它 1. git 的 commit 命令提交的是什么?idex 是什么? 2. 说说 gdb 的常用调试命令? 3. 说说你常用的 shell 命令? ### 简历 简历里一定要有项目,**我的项目就是 linux 网络编程课程后面的 web 服务器**。下面就是我写在简历上的项目: ![图片](https://img-blog.csdnimg.cn/img_convert/42c0e7834a8599e472c4bfa2d030ce8f.png) 虽然很简单,只能实现网页看文字、图片、音视频,但我也会写上去。 面了这么多家,**发现面试官在意的不是做了什么项目,而是在项目中得到了什么,更注重你的思想感悟**。 关于技能栈,我会将我所有知道的技能都分类写上去,但一定确定自己会,而且有自己的理解,不熟的我都不放,不给自己挖坑。 面试官一般也只问我简历上的内容。好好准备一下八股文,面试前多看看小林的《图解网络》和《图解系统》,基本就没问题了。 ![图片](https://img-blog.csdnimg.cn/img_convert/d1f9d265067960901eb35f93931d459c.png) ### 面试 > 这么找到面试机会呢? 我给大家说说我的做法: *第一步*,下载 boss 直聘,填写好自己的在线简历; *第二步*,刷 boss 直聘,这个软件有个机制就是,你看了对方的岗位,hr 会看到谁看了他的岗位,他有可能会看你的简历,如果 hr 感觉合适就会联系你。当时我晚上失眠,刷到两三点(不要学我),第二天醒来发现有一大堆 hr 发消息。而且上面会有很多大公司的员工兼职招聘,因为大厂的内退奖励很丰厚,有人靠下班刷 boss 赚了几万内推奖励。 *第三步*,多面试,提高自己的面试能力。我最开始面试的时候话都说不利索,到后面都可以侃侃而谈了。就是同样的话说多了,熟练度高了。 > 怎么进的腾讯的? 我的面试并不多,也就十几家。 发现小公司都不是很愿意给面试机会,**反而大一点的公司面试机会更多一些**,其中华为的 od 招聘最愿意给机会。 我最开始的目标是找个小公司,月薪六七千我就满足了,但在长沙还是很难实现。此时接二连三有一些大公司 boss 直聘上给我留言,我知道自己面不上,每次都是回答「对不起,我不太合适」。 后来收到多了,我也烦了,就直接答应了腾讯的 hr。想着简历也不会过,过了去体验一下大厂面试也好。 所以也没很重视,甚至面试前几分钟都还在睡午觉,还好有闹钟。 面试是在周六下午,一共两面,**面试问题并没有想象中的难,基本上就是我上面罗列的八股文**。 让我比较不适应的是手撕算法,我第一次面试手撕,还有点不习惯,还好当时学 AcWing 算法基础课时每一道题都写了好几遍,才不至于卡壳。 一面就是手撕归并排序,我写出来了。二面手撕 kmp,我没写出来,但说了 kmp 的算法思路。运气比较好,面试官让我通过了面试。 事后总结了一下,感觉能通过面试是因为: - 简历上的我都会用; - 基本问题都能回答上来; - 平时刷题都是实打实的手敲(没怎么刷题,主要就是写了 AcWing 算法基础课的练习题)。 大家平时一定要多刷题,去大厂手撕算法是必不可少的。 > 怎么回答自己半年的空窗期? 我当时就将自己两年间的经历和心路历程都跟面试官说了一下。 期间聊到了自己为什么要转行?为什么要选择互联网?对自己而言最重要的是什么? 面试官基本都比较满意我的回答,后面复盘,认为**面试官在意的应该是我是否是一个有规划的人**,想通过这个问题看我是不是脑子一热就转行的人。 ### 面试遇到的槽点 #### hr 压价 hr 压价是普遍存在的,如果可以以更低的成本招聘到人,何乐而不为呢? 刚开始遇到压价我很慌,后面 offer 拿了几个,也就有了应对措施,给大家分享一下: - 多面试,多拿 offer,只要手里有一个 offer,就可以和 hr 谈价; - 确定好自己的目标薪资,如果 offer 达到薪资目标就考虑五险一金、工作时间、平台,做好横向对比,选取最合适自己的 offer; - 放宽心,工作是双向选择的结果,你在选择公司,公司也在选择候选人,如果公司不愿意开出满意的薪资,可以换一家。 #### 外包 我自学之初就看了很多网上的文章,**基本都说不要去外包**。 我面试过程中也遇到了外包,是武汉的某某科技(国企),看了我的简历,boss 上找到了我,要了简历。后面给我打了个电话,说看了我的简历,认为我是可造之才,愿意给我个机会,做他们的外包,月薪六千,不包吃住,没有五险一金,一年后看表现决定是否让我转正,最重要的是不用面试。 当时听到这条件,我的确心动了,**但想到我学了两年,却连面试都都没有,我不甘心**,至少得让我面试一下,最后就拒绝了。 后来才知道,这是这个公司在忽悠人,人员流失非常严重。说出来是希望给大家一个参考,至少得面试一下。 最新型的外包就是 boss 上铺天盖地的 od 岗位,签的是外企德科,我也面了。 先机试,要求 150 分,三道算法题,好好做都能过。然后是两轮技术面,**面试过程中都要手撕算法题**。最后是人事面,都过了。 最后谈工资的时候,只拿到 11.9k,其中 2k 还是绩效工资,换而言之就是,你表现不好,就少 2k。 而且 od 是没有奖金和股票的,hr 们都说不分正式员工和 od,实际上是你干着和正式员工一样的活,他们有年终奖和股票,你没有。 不过大家没有路的话,也可是试试,毕竟是大厂。 #### 面试不问技术 我遇到最奇怪一个面试,莫过于第一场面试,全程没问我技术问题,只问我会不会喝酒,有什么兴趣爱好,会不会打麻将,我回答得都不好,但面试官还是让我过了。 因为刚开始转行面试,我很珍惜这一次的机会。 到后面的时候越想越不对劲,写代码还要会搓麻将?感觉不靠谱,就拒绝了二面。 后来才了解到,那个行业技术人员混的一般,混得好的都是很会「来事」的。 如果大家遇到这种奇怪的面试,希望大家多考虑一番,**不要因为转行而没有自信,感觉自己低人一等,对求职公司委曲求全**。 公司这么多,总会找到一个愿意接受我们的。 ## 后记 基本上就这些了,我的经历只能给大家一个参考,如果你已经工作了,并且想转行,希望能给你一些参考。 如果你是学生,我建议你去看@编程指北的学习路线,更加全面。 也可以关注@CodeSheep,羊哥的 B 视频在我转行初期给了我很大的指导。 若是想找 C++ 相关的技术群,我建议找@程序喵大人,喵哥的技术交流区是我见过 C++ 氛围最好的技术交流群。 也许分享的博主有些多了,不过我真的从这些博主那里学到了很多,所以推介给大家,如果大家不喜欢,也不要骂我,我没收他们钱。 还有一点就是,如果大家时间充足的话,我建议大家一定要学一学 Python 和 MySQL,我现在正在恶补。。。 谢谢大家,祝大家工作、生活、学习愉快! **人生没有白走的路,每一路都算数。** ================================================ FILE: reader_nb/6_reader.md ================================================ # 做了国外 CS 课程的 lab,拿到腾讯实习! 大家好,我是小林。 相信不少 CS 学生都有关于项目到底要怎么准备的问题,可能大家认为要做个非常强的项目才有机会面试。 在前几个星期,有位大三非科班的读者的项目经历写的是**国外 CS 课程的 lab**,也就是课程的实验,并不是什么高大上的项目,他依然拿到了腾讯的实习。 ![图片](https://img-blog.csdnimg.cn/img_convert/d594992b15d3db17ee6cdb2804ab3176.png) 他跟说,对于校招面试,项目其实并不要求做的很牛逼,但是要保证是你自己亲手做的,因为面试时,问项目主要是问你在项目中用什么技术解决了什么问题,然后达到了什么效果,你能回答出这些才是主要的。 然后面试过程中,计算机基础和算法才是大头,考的最多的还是这些。 他由于在学校里参加过 ACM 比赛,所以面试时的算法对他不是难度,他甚至都没刷过 leetcode,但是每次面试的算法他都是秒杀的。 他的弱点主要是在计算机基础知识,因为他不是非科班的,很多计算机专业课都没上过,或者有的没怎么认真学过。 他最开始因为没有准备计算机基础,面试屡屡挫败,后面他开始突击一两月这些八股文,我的图解网络和系统也对他起到来一定的帮助,最终成功拿到腾讯的实习。 我也邀请这位读者分享他的学习经验和做 lab 的经验。 开车! ![图片](https://img-blog.csdnimg.cn/img_convert/2ef0e8ae154ff655c9b14c53a3e71a4f.png) ## 我跨专业是如何学 CS 的 简单概括我的情况。 我是名非科班的 ACM 选手,在准备面试之前这一段时间都没有系统的学习过专业课的知识 我是一名大三的机械系的学生,大概在大二上学期过完的假期,我刷知乎偶然看到了同学校学长描述自己的竞赛经历,觉得很好奇,就入坑了算法竞赛也就是俗称的 ACM,兴趣使然一直打到了大三下学期。 在这段时间里,我和队友或者其他计算机院的学生聊天的时候,偶尔会听到一些关于专业课的知识或者名词,然后会去搜这些相应的知识稍微进行了解,但是完全没有系统的学习过 CS 的专业课知识,基本上就是看到了什么知识点感觉好玩就去搜一搜。可以说,在面试之前我对 CS 的体系和知识是完全陌生的。 到了大三下学期,偶然看到学校群里有学姐发的实习招聘信息,我才意识到:我都大三了,是时候着手准备一下找工作的事了。 通过学姐发的招聘信息,我被内推去参加了 tx 的实习面试。 这一次面试的内容都十分简单,什么是多态,多态怎么实现的,进程和线程的内容是什么,计算机网络的几层结构是怎么样的……如果你有准备面试的话,你会发现这些问题简直就是送分题! 但由于我从来没有学习过相关的知识,被虐的惨不忍睹(面试官还和我说会进行评估,然后五分钟之后就把我挂了)才发现自己这一块有如此大的欠缺,开始着手一点点的从头开始补 CS 的知识。 ## 如何学习专业课知识 **不要上来就啃所谓的经典书。** 先去找一些经典的网课看看。对于新手来说,网课的老师可能讲的更像人话一点。 我个人主要推荐:**CSAPP**。从计算机的组成比如浮点数的存储方式,存储金字塔结构到操作系统的进程线程,计算机网络的 socket 等都有介绍,一个性价比很高的课程能够让你了解整个计算机体系结构。 其次我个人认为面试中常考察的点就是 **OS、计网、数据库和一些语言知识**。语言大家自己去找对应的课程学习。 计网的话我是看小林的图解网络 + b 站的湖科大的老师做的视频,都附带了很多图和动画,简单易懂。参考的文字材料就是自顶向下 + TCP/IP 详解卷 1。 OS 没有找过网课看,觉得看完 CSAPP 之后挺好理解的了。用的文字材料主要是小林的图解系统 + 操作系统概论。 数据库网课看的是 15445,这是一个面向磁盘的数据库,后面发现大量的讲解的其实是 Mysql 的原理。然后文字材料参考的是 Mysql 技术内幕,Innodb 存储引擎和 Redis 设计与实现。 进阶的内容大家可以参照自己想要发展的方向学习啦,我认为学到这里的人对计算机体系结构有了了解之后,应该很容易找自己的方向。像我对分布式和数据库感兴趣,就选了 6.824 和 15445 作为扩展学习内容。 虽然我推荐了一些网课和书籍作为学习内容,但是我学习过程中绝对不止参考了这些东西。 对于一个知识点,我如果看不懂的话先考虑找公开课/博客,然后在书中找较为书面化的表达方式,最后**我还会在自己博客中写笔记,用自己的话表达也是一种学习方式**。 ## 关于 6.824 和 15445 6.824 和 15445 这两个一个是关于分布式系统的。 他会讲一些关于 mapreduce 和 raft 的分布式算法,以及 15445,它一个基于磁盘的数据库(后来我看 innodb 引擎的时候发现其实就是对着 mysql 讲的)**选择这两个课程学习的好处在于他们的 lab 真的很好**,其实 CSAPP 出名的地方同样如此。 差不多的内容,讲师的水平肯定不会有决定性差距,差距主要体现在这些公开课由很好的 lab,带你手把手对一个知识进行实现算法/数据结构,让你对知识的理解更加深刻。 同时,由于这些是比较常见的算法,一般面试官都会懂,因此就喜欢和你聊一聊相关的问题,是一个面试的时候很好的谈资。你可以聊一聊你项目里面是怎么实现的,遇到了什么问题,如何解决的。 铺垫了这么多,如何学习这种公开课? 6.824 和 15445 在 b 站上都是有相应的视频的,但据我了解免费放出来的貌似都是机翻的,可能有一些地方语义不是很通畅,这里**我推荐一个组织叫 simviso,人工翻译公开课的视频,会比机翻的看起来舒服一些**。 然后关于怎么做公开课的 lab,其实你要实现的东西老师上课从答题思路到实现细节都会讲的很清楚,认真听了公开课之后你就知道里面的东西怎么运作的了,只需要自己再理一理怎么实现之类的问题就可以了。 同时,官网上也会有很详细的教程,告诉你要实现什么东西,然后每个模块你要实现什么样的函数,拥有什么样的功能,是**保姆级教学**!不用担心不会写的问题。 知识点在课上会说的很清楚,lab 做的东西绝对不会超纲,不仅课上会教你,官网的文档也写得很清楚,可能对于英语不是很好的小伙伴会造成一些困惑,但是我感觉当程序员面对英语文档应该会是很常见的事情。 #### 6.824 lab 6.824 的官网地址: http://nil.csail.mit.edu/6.824/2018/schedule.html 6.824 的官网的 schedule,如下图: ![图片](https://img-blog.csdnimg.cn/img_convert/50567a129b0f94b7de9725dc6b6e4ef1.png) 下图是 lab1 的 mapreduce 介绍,这一部分是教你怎么用 git 配置环境的。 ![图片](https://img-blog.csdnimg.cn/img_convert/158b732d6e6b3ff6c5aea1285b67f09a.png) 下图是 lab1 的 part1,告诉你你需要在 common_map.go 里实 现 domap()函数等等……会写的很清楚让你去实现哪一部分的模块。 ![图片](https://img-blog.csdnimg.cn/img_convert/17ccd88cb4276063c2a85864441d7511.png) 而且,还提供了一些 test 来测试你实现的对不对,如下图: ![图片](https://img-blog.csdnimg.cn/img_convert/9d20734b607c90ae19a2ad2f46b7193d.png) #### 15445 lab 15445 的官网地址: https://15445.courses.cs.cmu.edu/fall2020/schedule.html 15445 的官网 schedule 如下图,可以找到里面的 project released 点进去。 ![图片](https://img-blog.csdnimg.cn/img_convert/2bfa6458fbde31d203366e3bcc33a119.png) 下图是 lab1 的 LRU 模块,告诉你要去实现 src/……/lru_replacer.h 的 victim 函数啊,pin 函数啊等等,写的十分清楚。 ![图片](https://img-blog.csdnimg.cn/img_convert/23da55e8b84a02ba7642bfe6ccc169e4.png) 所以,大家有时间的话,**一定要做 lab**。 我个人认为**国外的公开课的最大优势就是量身定制的 lab,真的能将你课上所学知识完美的再复现一遍,让你的理解更加深刻。** ## 参加竞赛对面试的帮助 这个问题可以转换为:**学算法有什么好处?** 其实面试中考察的算法,甚至是算法竞赛中考察的算法,都是很久以前计算机科学家们玩烂了的。在现实工程中有许多算法都是被淘汰了/用不上的。 那为什么还要学? 他们原本设计出来是为了解决某些特定问题的。比如最小生成树,原本就是为了解决计算机网络中的一些特定问题的,或者说二叉树,将二分这一个思想转换成了一个持久化的数据结构。 我个人的理解就是,**学习算法你可以学习原本解决这些计算机问题的思维,培养了计算机思维,在后续的专业课学习中就打了一个很好的基础。** 回到原问题,参加算法竞赛的好处在于奖项多了之后,简历更突出(帮助我一个双非的学生过了一些简历关),和具备扎实的编程功底,良好的计算机思维。 同时,参加了算法竞赛,基本上就是对面试的算法题进行了一个**降维打击**吧。 **我虽然没有刷过 leetcode,但面试的算法题基本都没什么压力写出来了***(中间也挂了不少面试,但不是挂在算法上,是当时八股背的不好)。 虽然面试不仅仅是由算法题组成的,但对于很多同学来说,专业知识都掌握好了,但是死在了算法上,这就有点气人。还是得多多刷题~现在太卷啦,尽量都做到最好吧。 ## 总结 在我面试的过程中,算法环节遇到的题目都是十分简单或者十分常见的问题,因此只要多刷刷题,提高自己的实现能力就 ok 了。 **最主要的还是基础知识的准备**。 首先对照着一些简单易懂的公开课/小林的图解系列等了解知识雏形,然后再自己从专业书里更详细的学习。 同时自己写博客,多刷刷面经,看看面试喜欢考什么,大概就 ok 了? 也许吧,大家都要加油哦! ================================================ FILE: reader_nb/7_reader.md ================================================ ## 大三就啃框架源码!轻松因对字节面试 大家好,我是小林。 上周我发了个[读者字节三面的面经](https://mp.weixin.qq.com/s?__biz=MzUxODAzNDg4NQ==&mid=2247496990&idx=2&sn=160eaa432d4bfe7115fa7baee19db3ed&scene=21#wechat_redirect),结果评论区很多人不相信这是校招的面经,觉得难度有点高。 ![图片](https://img-blog.csdnimg.cn/img_convert/8fe33baf189b2fb06919e79583abf93d.png) 首先这个确实是读者真实的校招面经,再来因为他之前实习过,他的实习项目里涉及到了不少中间件,所以面试官对于高并发问题考察比较多,也算是按简历来问的了。 正好他自己在学习的时候,**有看源码的习惯**,所以面试的问题,他都能应对,甚至能说到面试官眼前一亮,然后在几天拿到了字节 Java 后端的意向。 我也邀请了这位读者分享他的学习路线和心得,**他最后的心得讨论八股文的事情,说的很好,值得一读!** ------ ## 我的情况 我先说说我的基本情况。 双非一本,科班,大学 0 基础,随着学校课程,选择后端开发,大三寒假在某独角兽企业 Java 实习到提前批,字节已 oc。 我这次的分享更多的适合准备校招或者是大学已经决定选择后端方向的同学,社招大佬见笑~ 首先这里要说明一下哈,下面的路线可能对于很多人是「填鸭式教学」,就是基本会是面向面试准备的,关于面试工作实际开发以及程序员自我修养的这部分会放在最后心得部分,感兴趣的可以选择性观看。 我的观点始终是由点到线,由线再到面。 很多东西肯定是经典的书籍会更加权威和全面,但是说实话,不是每个人都能抱着本书看的进去,并且理解透彻,比如我一开始看书的时候就是很迷。 所以我的建议一直都是,**从弱智的,易懂的,有实际案例的博客或者是视频看起,了解大概,动动手撞撞坑之后,再去系统性的看书**,这样会比较好理解。 起码我是这样经历过来的,因为实在没有其他办法。 ## 学习路线 首先推荐几个后端学习的仓库吧: Github 上很出名的: - javaguide - cs-note - JavaFamily 当然我也很期待小林大佬能早日推出自己的仓库啦~ 首先要搞清楚,作为后端开发,我们需要掌握一下什么知识点,其实无外乎就是如下内容。 ### 编程语言 以 Java 为例子。 #### Java 语法基础 这个没啥好说的,科班的话有教材,非科班的话建议看一下入门教程,菜鸟教程之类的(别嫌弱智、简单)。 看完之后可以全面的看一下进阶书籍(这是面向科班&非科班),例如著名的:Java 核心卷等(说实话,一开始让我看,真心看不下去,可能是我菜……)。 #### 集合框架源码 必须是源码,现在这种烂八股应该不会有人不会源码吧?而且源码里面很多设计思想可以学到很多,例如 hash 的泊松分布推导等。 上面那部分基础看完应该都知道这是什么了吧……有能力有自信的可以根据面经题目自己去分析源码,小白同学建议网上找一些源码分析(这里一定要注意,我是很建议经常百度的,但是不要无脑百度,一个源码可以看多一些博客对比一下,然后自己实际去看一下分析一下是不是如此,切忌人云亦云),有一些能力之后自己去总结。 #### 并发编程 并发编程是现在后端语言很重要的一块基础(当然工作中可能比较少机会可以用到),但是并发编程扎不扎实,运用的熟不熟练可以很好的体现一个程序员的编程素养和基本功。 这里肯定有大佬会吐槽说面试造航母,进门拧螺丝哈,hhh 确实并发编程其实一般场景运用不多,但是在项目里,很多可以优化的细节,别人做不到你能做到的,往往就是在这些点,包括下文 JVM。 - 基础:上述的教程,自己打一下练练手。 - 进阶:书籍《并发编程的艺术》、《并发编程实战》。 #### JVM 虚拟机 这里可能很多人有些疑问了哈,实际开发中你写项目需要了解虚拟机吗?真正调优轮得到你吗? emmm,这块的话只能说见仁见智了,个人认为开发里的虚拟机小调试以及一些常见溢出的分析还是很有必要的。 退一万步说,学 Java 的,总不能连虚拟机都不知道吧。我个人认为啊,任何东西都是知其然、知其所以然、知其所以必然,了解清楚了,知道原理,你写代码的时候会有一种看透的感觉以及会有前所未有的安全感。 - 基础:虚拟机有很多原理分析的整套文章,我这里就不推荐了,都可以搜的到。 - 全面进阶:推荐周志明大佬的《深入 Java 虚拟机》,现在应该有第三版了。 #### Java 相关框架使用以及源码了解 从常见的 ssm 到 spring 全家桶,这一块真心不好学,资料很杂,入门的话我建议去看一下黑马程序员,尚硅谷相关教程,或者是 how2j 这个网站,上面也有详细的路线和简要入门教程(各位大佬别吐槽哈,不是宣传培训班,但是确实作为小白入门很香)。 上面是简单入门,无脑运用,实际进阶的话还是看看源码看看书哈,一定要找些项目来做做。 - 推荐书籍:《spring 源码分析》。 - 面试重点:这块多去看面经和源码吧,实际开发里需要关注的点还是有的,例如异步注解的循环依赖报错,我在实习的时候就遇到了,不懂原理真心不好解决。 ### 数据库 以 Mysql 为例子。 科班基础有教材,非科班基础推荐小林大佬的《图解 mysql》。 进阶的话,极客时间的《mysql45 讲》,书籍主要两本,分别是《高性能 mysql》和《mysql 技术内幕》,书籍建议有一定基础再看。 面试重点:小林&帅地的文章里都有很多,不再赘述。 ### 中间件 #### Redis 这个入门也是看一下教程会快一些,科班应该也没有专门的课程吧? 入门可以考虑尚硅谷之类的视频,进阶的话可以看看书和一些源码分析:《redis 的设计与实现》,老经典了吧。 不过 redis 个人认为难点在于运用,这点就不多说了,学完基础大家应该都会知道后续的学习路线了,可以期待一下小林大佬的《图解 redis》 #### mq、kafka 这个有余力可以学一下,面向面试的话两者都行,实际运用看业务场景,这里不再赘述。 入门还是推荐看视频,b 站很多,随便搜,没有什么特别推荐。 进阶的话,推荐书籍《rocketmq 的技术内幕》,不过说实话,我自己也在看 mq 源码,感觉这本书写的一般,不够细节,目前没发现什么特别全面的书籍。 ### 计算机基础 #### 算法与数据结构 这一块的重要性不再多说啦,当然也有很多人吐槽这个没用的,确实我也很烦应试刷题,可以没办法,确实是面试硬性要求,尤其是外企入门推荐左程云大神视频&帅地玩编程的相关文章。 #### 设计模式 科班有教材,非科班建议《大话设计模式》这类书籍入门 这一块偏抽象与实践,主要还是业务场景,需要自己多找找实际案例看看多理解。 #### 计算机网络 科班有教材,非科班直接无脑小林大佬的《图解网络》。 看完之后进阶可以考虑详细的看一下《计算机自顶向下》,源码分析等,例如:开发者内功修炼公众号。 面试重点,后面心得会提到。 #### 操作系统 科班有教材,非科班直接入手小林《图解系统》。 进阶可以考虑看一下《操作系统导论》,国外的课程 CSAPP,MIT 相关课程等等(动手会比较多)。 面试重点,后面心得会提到 #### 汇编、计组 这一块科班应该很熟悉,噩梦,非科班的话建议找一些视频入手,b 站很多大学的计算机相关课程,播放量高的都挺不错。 #### 分布式 rpc 这里不再赘述了,校招生估计大都是了解基本概念,相信学完上述内容,自己应该清楚该怎么学了~ #### 云原生云计算 docker、k8s 这些这里不赘述了哈,相信需要学这个的大佬自己知道如何学了~ ## 一些心得 说实在的,刚刚那个路线其实稍微学过点后端的估计都知道,而且现在开源仓库太多啦,我想可能对大家帮助更大的还是自己的学习感悟吧。 其实说到这里相信大家肯定很清楚了,太多资料,太多八股了…… 这里聊一下我对八股的一些看法吧(因为我看到上次小林大佬发的面经下面评论有同学提到八股的问题)。 首先得搞清楚八股是什么,**一个知识点,你能把使用以及原理说出来,我称之为八股,但是你能把底层关联以及业务使用,优化历程也能搞清楚,我称之为能力**。 固然,现在的 CS 基本已经形成了套路,一套一套的面试题,很多人无脑跟着背就行,甚至现在还有分公司,分部门,对应的面经和知识点都有人总结,可见八股影响之深。 但是退一步说,**八股真的没用吗?八股不能体现你的能力吗?八股对于你的工作真的没有提升吗?** 以我自己为例吧,这里不以偏概全哈,单纯就是分享一下自己的经历和看法。 ***例子一\*** 刚刚上文学习路线有说到虚拟机的学习,很多人吐槽是不是这玩意没必要学? 但其实呢,我在实习的时候就遇到了自己的一个模块线上 oom 了,排查了很久通过动态数据定位到是新生代与老年代比例的一个问题。 当然了,对于很多大佬来说,这不算什么。但是对于我来说,假设我连这一块的基本知识都没掌握的话,我即便百度到了解决办法(例如无脑把比例调大,把内存调大),还是没法从根本上解决问题,所以我还是挺庆幸的,不然那天就背锅了…… ***例子二\*** 再者说吧,还是八股的问题,面试的时候这可以作为你的一个优势,别人回答 CMS 就是简单的说下基本过程(我称之为八股),但是你回答可以把三色标记出现的问题以及 CMS 短暂的 STW 的问题引出 G1,并且还能举出例子,你在实习或者业务中使用的是什么收集器,为什么,怎么切换的(我称之为能力)。 ***例子三\*** 再举个例子吧,一个很八股的问题,三次握手,老八股了。如果是简单的说出过程甚至需要说出中间的标志位,我都认为这是基础(八股),但是如果你能说出为什么前两次握手不能带数据,怎么避免攻击的,实际企业应用是怎么做的(开发者内功修炼里有相关文章),我称之为能力,我到现在还记得面试的时候把相关过程分析以及这一块内核的源码说出来的时候面试官惊讶的表情,这就不是八股了,这是你的优势。 同样的,工作中也是一样,我实习的部门是做底层开发的,网络嗅探,内核参数监控是常事,所以我会认为,作为一名程序员,不管说是不是真正用到了,但是实际上经常接触的东西,这些东西,还是值得多去了解的。 例如之前面经提到的 QUIC,实际上很多厂内部已经有类似协议开发的应用了,所以个人认为还算是一个很常见的东西。 ***例子四\*** 多给一个例子吧,也是面试的小技巧,可以多说一点内容体现你的基本素养。 例如 Java 很熟悉的 volatile 关键字,假设说我是面试官,我的面试者只说到原子性有序性,JMM 内存模型这些,我会认为这是八股。 但是如果能说到汇编文件的 lock 前缀,内存屏障,MESI 设计,MESI 与 volatile 的关系,MESI 优化队列,总线锁与缓存锁,总线风暴,那么我认为这是能力。 至于这对工作有没有用,我个人认为,见仁见智吧(总线风暴就是一个点)。 ***例子四\*** 上次面经文章分享的是我的实习项目,具体就不方便透露了,但是还是说一个点吧。 实习项目里使用了 mq,我在开发的时候会注意去看其他企业的相关实施方案,会去整理源码,然后开发过程中会进行压测,无意间我发现,我的 mq 使用的比隔壁一位高级开发还要溜,当然我就是随眼一瞟觉得他写的不怎么样。 那么我在面试的过程中,面试官同样的一个 mq 怎么保证消费可靠这种问题,我与别人回答的差距就出来了。 这里面我就不强调 os 和网络,数据库这些基础知识的重要性啦,我个人不是很建议出了什么新技术就莽着去学习,因为万物离不开底层,吃透底层,再搞清楚业务,那么现在的很多中间件啊,分布式相关的知识啊,个人认为只是新瓶装旧酒。 举上述例子我不是想秀自己的知识储备,这些在各位大佬面前真的是不值一提,羞愧万分,只是想通过个人在校招准备过程中说明一点。 就是**八股 ≠无用,面试通过 ≠ 背八股**。 很多时候你以为你问题都回答上来了,面试却挂了,面试官是在刷你 KPI(当然确实也有可能是)。 但是我更多的认为要多反思自己,是不是说到位了,是单纯的在背,还是说自己的理解,结合业务场景来说,并且说出优化的点,说出这种方法存在的问题,我个人觉得这是会让面试官眼前一亮的点。 同时,也是我在“八股”过程中的一点感悟吧,其实校招只是人生的一个阶段,欲速则不达,要珍惜现在能静下心来沉淀知识的时间。 程序员的内功修养与素养真的很重要,所以在“八股”之余会多去看看一些知识的源码以及企业里面的应用,会去看看《代码整洁之道》、《程序员的基本素养》等,总结成自己的笔记,未来希望能开源。 关于有人问到如何学的问题? 其实很简单,还是那句话,知识点都是那么多,深度和延伸得靠你自己了,一个大方向就是,从语言的实现到操作系统(网络)的实现,按照这个方向去搜集资料,去看源码,相信会很有收获的。 肯定有人提到,学这么多,进去还不是拧螺丝? 这个经典问题,只能说见仁见智了,我始终认为,有拧螺丝的时候,当然也有造航母的机会,这得看你的选择与把握机会的能力。 当然这里也要大厂小厂的争论,这里就不说了。不管在哪里,肯定会有拧螺丝的时候,但这不妨碍你有一颗自我学习,自己提升的心,与君共勉,这可能是我年轻气盛的想法。 最后再次说明一下,我是小林大佬的忠诚读者,他的图解系列给了我很多灵感和帮助,应他邀请写分享,第一次写这些类型的内容,如果有任何语言斟酌的不到位的话,望各位海涵! 最后如果大家能够从中有一点点的收获或者是认同,借小林大佬的光,万分荣幸,希望大家也能找到自己满意的工作。 ================================================ FILE: reader_nb/8_reader.md ================================================ # 文科生自学转码,成功拿下众多互联网大厂 offer! 前几天收到一个读者的喜讯。 他是一名文科生,不过他通过自学,在今年拿到了非常多的大厂实习 offer,岗位是前端开发。 ![图片](https://img-blog.csdnimg.cn/img_convert/e8ccba9a38352f1fd0aa8e842e1cf233.png) 我觉得他很厉害,而且他转行经历值得有这方面想法的同学学习和参考,所以我就邀请他写了一个分享稿,希望对你们有帮助。 ## 正文 我是来自某双一流高校的文科研一(保送本校),在今年 11 月份收到了阿里、腾讯、百度、字节、快手、滴滴、完美世界、商汤等几家厂的前端实习 offer,应该是投简历的公司都给了 offer。 我的前端学习过程大概持续了大概有一年,也就是从大四上学期快要结束的时候到目前。 我的学习方式也比较笨,最开始就是抱着大厚书肯。 《CSS 权威指南》(上下)、《Javascript 高级程序设计》(第四版)、《You don’t know JavaScript》、《Javascript 忍者秘籍》(第二版)这些就是我的入门书籍,这四本中前两本我都是看了两边,都在 1000 页左右,后面两本则是草草翻了一下。 这个过程为我打下了比较扎实的 JS 功底,大概是用了 2 个月的时候,我大概就能摸清楚原型/原型链、Promise/异步、闭包、Event loop 等 JS 中的一些核心知识点了。我觉得一开始看视频会好一点,我自己学习的时候看书看不懂的地方也是去 B 站看相关知识点的讲解。 紧接着的寒假,我就在家搞毕设,用 react 做了一个场外交易平台(导师做的方向偏向于行为经济学),使用 node、区块链和数据库搞了一个全栈的项目。 整个项目其实就是按照 B 站上的 React 目前播放量最高的那个视频(我看的时候刚出来没几天)学完之后写了一个应用,之后找了一些关于登陆注册、鉴权和状态管理的一些知识做了一些应用,整个项目就完善很多了(寒假剩余时间摸鱼)。 大四下学期,开始补计网和算法的基础知识(前端这边操作系统问的少一些)。 计网方面在 B 站看了中科大的 mooc,讲的不错,看了自顶向下方法那本书,**但是这些都不如小林哥的笔记比较好!!!**不是我吹,我字节一面完全背的小林哥的笔记,面试官直接感叹:“我面了这么多人,从没有一个人像你一样说的这么细致的。”(得益于大学文科背书功底?) 算法方面是看了《算法(第四版)》,youtube 上看的普林斯顿的网课,跟着写了点代码,然后这个学期剩余时间几乎都在谈恋爱。 接下来,就开始第一次面试。当时是陪对象去投春招,被 HR 拉着投了一个知名 K12 公司,当场被拉去面试,莫名其妙就过了。我看了一下名单上好像就一个人投了前端,好像那个人就在我前面,进去没多久就出来了,我自己却面了将近 3 个小时,写了 4 张 A4 纸正反向面。 暑期就去北京实习了,亲身感受了一层楼一夜之间被开除的感觉。我在北京实习的时候,每天上班地铁上背小林的笔记,周末去公司刷 leetcode,刷的方法就是按照题型刷一下。 实习归来感觉自己太菜了,好多技术栈都没学过。回来之后补了 linux 的一些东西,看了 docker,跟着 webpack 官网撸了一边,看了 koa2、redux、react-redux 源码,看了《狼书》(一二册)、《前端开发核心知识进阶》看了半本,再次去学习 JS 的相关基础知识。 11 月份的时候看了看,牛客上的面经,感觉自己好像也可以进字节了,就去面试了基本上每个厂都给了 offer,最后选择了去杭州阿里。 我个人的感觉,知识的进步就会经历「知道自己不会」到「不知道自己会」的这样一个过程,每天学习一点点,每个月都去看一本书或者看一个小的项目源码,切记闭门造车这种行为吧,很多时候自己学了很久的东西,可能就是项目源码里面的一个很浅显的东西或者是书上都写着的,看视频很多时候是一个入门的方式,看书和源码是比较好的深入的方式。 这段时间,好多次自我怀疑转行是否正确,能不能在秋招找到一个合适工作,我直到拿到快手的 offer 之前一直都觉得自己非常菜,快手的 HR 告诉我,“部门对你评价真的特别高,这边 Leader 专门跟我说一定要你来。” 慢慢的时间会见证我们一天天的变强的。 ## 问答 > 为什么要转互联网? 为什么转行,因为原来的专业不好就业,如果读博的话,老板虽然也是业内大牛,但是我对这个方向不感兴趣,而且有一个室友是信息竞赛保送上来,他做的是前端开发,当时感觉他正在从事的事情很酷,然后受影响就去做前端了。 > 总共花了多长长时间学习? 总共学习的时间,除去整块玩的时间、准备毕设和修学分的时间,满打满算的话有 7 个月,老板不怎么 push,干什么也不管。 > 刚开始接触编程的时候会不会觉得很困难,你又是怎么克服的? 刚开始的时候的确很难,但是我的大佬室友带着我飞,手把手教我 hello world,然后直到能到自己能写一些 demo 之后,感觉到成就感之后就更有动力。我觉得学习编程的前期找一个能问问题的老师真的很重要。 > 你算法刷了多少题,你是怎么克服算法题的? 算法题一共刷了 300 道左右吧,暑假去实习的时候,我住在青旅里面,室友玩的很不错,我告诉他们,我每天晚上下班回来刷三道题,周末为了省钱周末去公司蹭饭刷一天算法(包三餐),不会写的背下来就好了,把主要思路背下来整理个笔记,可以跟别人交流一下整体思路。 > 面试时,面试过会介意你文科的身份吗?是不是等有相关的互联网实习,再去面一线大厂会更容易? 面试官不太会介意出身,我觉得只要技能点点满了就好了,这对文科生能否通过简历关很重要,但是面试官还是会问问为什么转行,想好这个问题就好了(我就是实话实说)。 我觉得有第一份实习很重要,没有第一份实习很难找到后面的实习,我觉得我暑假的实习对于我下面的找工作有很大帮助。 暑假实习的第一天跟导师沟通的时候,我直接告诉我的导师我实习期间想要得到哪些成长: - 第一点:我希望能参与到一个 Vue 实际项目开发中; - 第二点:我想经历一个从需求评审到正式上线项目的完整过程; 当时,刚好我们组特别缺人,我基本上就是当正式工在用,这些需求都被满足了,这对我后面的成长帮助很大。但是第一份实习却很考验运气,很感谢上家公司给我 offer。 ## 我的想法 以上就是这位文科生读者转行的心得分享了,接下里我说下我的感受。 我觉得这个读者很会抓住机会。 第一,他刚好有一个会前端的室友,抓住了一个被室友带飞的机会。这一点非常关键,因为单纯一个人学,没人交流会学的很乏味,而且很容易陷入困境。如果身边有一个可以随时交流的前端大佬,可以很快度过小白时期,有时候一个小小的问题,就能被一句话解决,而不是自己在网上折腾个几个小时。 第二,他很善于利用网上的免费资源,他看的视频和做的项目,大部分来自于 B 站上的视频。这就是互联网带给我们学习上的便利,但是再怎么便利,还是得自己去搜并且学起来才是真有用,而不是百度网盘下载了几十 G 的学习资源就等于会了。 第三,抓住了某 k12 公司的实习机会。他找的是前端开发,这个岗位相比后端开发没那么卷,而正好这家公司缺前端开发,于是就有了第一次在一家互联网方向的公司实习。有了这段实习经历后,对于他后面在面试一线大厂的时候,起到了很关键的作用,因为公司看到你有了一份实习经历,证明你自学的知识能实际投入到工作中,也就不会在意你是文科生这个身份,毕竟程序员是以技术能力说话的嘛。 今天分享就到这啦,我们下期见! ================================================ FILE: reader_nb/README.md ================================================ # 介绍 本系列的文章是小林读者们互联网春招、秋招的真实经历,基本都是拿下互联网一线大厂 offer 的经验分享。 因为他们都说我的图解网络和图解系统对他们帮助很大,所以我也把他们的经历整理到了网站。 如果你正好是大学生,不妨可以看看他们的校招和学习经验的分享,或许能帮到你呢!:muscle: - [非科班本科拿下年薪 35w+ 的 offer](/reader_nb/1_reader.md) - [被字节捞了六七次,终于拿到 offer 了!](/reader_nb/2_reader.md) - [三本逆袭!拿到京东美团等 offer](/reader_nb/3_reader.md) - [拿下腾讯天美 offer 的春招经历](/reader_nb/4_reader.md) - [机械工作 2 年,自学转行进腾讯了!](/reader_nb/5_reader.md) - [做了国外 CS 课程的 lab,拿到腾讯实习!](/reader_nb/6_reader.md) - [大三就啃框架源码!轻松因对字节面试](/reader_nb/7_reader.md) - [文科生自学转码,成功拿下众多互联网大厂 offer!](/reader_nb/8_reader.md) ::: tip 如果你也因为小林的图解文章而拿到不错的 offer,可以向小林投稿你的经历,小林会给予你一定的稿费,同时还会收录到本站里,想投稿的同学可以加我的微信:xiaolincoding,期待你的分享。 ::: ================================================ FILE: redis/README.md ================================================ # 图解 Redis 介绍 《图解 Redis》目前还在连载更新中,大家不要催啦:joy: ,更新完会第一时间整理 PDF 的。 目前已经更新好的文章: - **面试篇** :point_down: - [Redis 常见面试题](/redis/base/redis_interview.md) - **数据类型篇** :point_down: - [Redis 数据类型和应用场景](/redis/data_struct/command.md) - [图解 Redis 数据结构](/redis/data_struct/data_struct.md) - **持久化篇** :point_down: - [AOF 持久化是怎么实现的?](/redis/storage/aof.md) - [RDB 快照是怎么实现的?](/redis/storage/rdb.md) - [Redis 大 Key 对持久化有什么影响?](/redis/storage/bigkey_aof_rdb.md) - **功能篇**:point_down: - [Redis 过期删除策略和内存淘汰策略有什么区别?](/redis/module/strategy.md) - **高可用篇** :point_down: - [主从复制是怎么实现的?](/redis/cluster/master_slave_replication.md) - [为什么要有哨兵?](/redis/cluster/sentinel.html) - :joy: 正在赶稿的路上。。。。。 - **缓存篇** :point_down: - [什么是缓存雪崩、击穿、穿透?](/redis/cluster/cache_problem.md) - [数据库和缓存如何保证一致性?](/redis/architecture/mysql_redis_consistency.md) ---- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: redis/architecture/mysql_redis_consistency.md ================================================ # 数据库和缓存如何保证一致性? 一天,老板说「最近公司的用户越来越多了,但是服务器的访问速度越来越差的,阿旺帮我优化下,**做好了给你画个饼!**」。 ![图片](https://img-blog.csdnimg.cn/img_convert/14952b672cc7573bdc9edaefbf204b3a.png) 程序员阿旺听到老板口中的「画饼」后就非常期待,没有任何犹豫就接下了老板给的这个任务。 阿旺登陆到了服务器,经过一番排查后,确认服务器的**性能瓶颈是在数据库**。 这好办,给服务器加上 Redis,让其作为数据库的缓存。 这样,在客户端请求数据时,如果能在缓存中命中数据,那就查询缓存,不用在去查询数据库,从而减轻数据库的压力,提高服务器的性能。 ## 先更新数据库,还是先更新缓存? 阿旺有了这个想法后,就准备开始着手优化服务器,但是挡在在他前面的是这样的一个问题。 ![图片](https://img-blog.csdnimg.cn/img_convert/b3bc9c4851ed731a36c9fee0f64264fe.png) **由于引入了缓存,那么在数据更新时,不仅要更新数据库,而且要更新缓存,这两个更新操作存在前后的问题**: - 先更新数据库,再更新缓存; - 先更新缓存,再更新数据库; 阿旺没想到太多,他觉得最新的数据肯定要先更新数据库,这样才可以确保数据库里的数据是最新的,于是他就采用了「**先更新数据库,再更新缓存**」的方案。 阿旺经过几个夜晚的折腾,终于「优化好了服务器」,然后就直接上线了,自信心满满跑去跟老板汇报。 老板不懂技术,自然也没多虑,就让后续阿旺观察下服务器的情况,如果效果不错,就跟阿旺谈画饼的事情。 阿旺观察了好几天,发现数据库的压力大大减少了,访问速度也提高了不少,心想这事肯定成的了。 好景不长,突然老板收到一个客户的投诉,客户说他刚发起了**两次更新年龄的操作**,但是显示的年龄确还是第一次更新时的年龄,而第二次更新年龄并没有生效。 老板立马就找了阿旺,训斥着阿旺说:「*这么简单的更新操作,都有 bug?我脸往哪儿放?你的饼还要不要了?*」 听到自己准备到手的饼要没了的阿旺瞬间就慌了,立马登陆服务器排查问题,阿旺查询缓存和数据库的数据后发现了问题。 数据库的数据是客户第二次更新操作的数据,而缓存确还是第一次更新操作的数据,也就是**出现了数据库和缓存的数据不一致的问题**。 这个问题可大了,阿旺经过一轮的分析,造成缓存和数据库的数据不一致的现象,是因为**并发问题**! ### 先更新数据库,再更新缓存 举个例子,比如「请求 A」和「请求 B」两个请求,同时更新「同一条」数据,则可能出现这样的顺序: ![图片](https://img-blog.csdnimg.cn/img_convert/8febac10b14bed16cb96d1d944cd08da.png) A 请求先将数据库的数据更新为 1,然后在更新缓存前,请求 B 将数据库的数据更新为 2,紧接着也把缓存更新为 2,然后 A 请求更新缓存为 1。 此时,数据库中的数据是 2,而缓存中的数据却是 1,**出现了缓存和数据库中的数据不一致的现象**。 ### 先更新缓存,再更新数据库 那换成「**先更新缓存,再更新数据库**」这个方案,还会有问题吗? 依然还是存在并发的问题,分析思路也是一样。 假设「请求 A」和「请求 B」两个请求,同时更新「同一条」数据,则可能出现这样的顺序: ![图片](https://img-blog.csdnimg.cn/img_convert/454a8228a6549176ad7e0484fba3c92b.png) A 请求先将缓存的数据更新为 1,然后在更新数据库前,B 请求来了,将缓存的数据更新为 2,紧接着把数据库更新为 2,然后 A 请求将数据库的数据更新为 1。 此时,数据库中的数据是 1,而缓存中的数据却是 2,**出现了缓存和数据库中的数据不一致的现象**。 所以,**无论是「先更新数据库,再更新缓存」,还是「先更新缓存,再更新数据库」,这两个方案都存在并发问题,当两个请求并发更新同一条数据的时候,可能会出现缓存和数据库中的数据不一致的现象**。 ## 先更新数据库,还是先删除缓存? 阿旺定位出问题后,思考了一番后,决定在更新数据时,**不更新缓存,而是删除缓存中的数据。然后,到读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中。** 阿旺想的这个策略是有名字的,是叫 **Cache Aside 策略**,中文是叫旁路缓存策略。 该策略又可以细分为「读策略」和「写策略」。 ![图片](https://img-blog.csdnimg.cn/img_convert/6e3db3ba2f829ddc14237f5c7c00e7ce.png) **写策略的步骤:** - 更新数据库中的数据; - 删除缓存中的数据。 **读策略的步骤:** - 如果读取的数据命中了缓存,则直接返回数据; - 如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。 阿旺在想到「写策略」的时候,又陷入更深层次的思考,到底该选择哪种顺序呢? - 先删除缓存,再更新数据库; - 先更新数据库,再删除缓存。 阿旺这次经过上次教训,不再「想当然」的乱选方案,因为老板这次给的饼很大啊,必须把握住。 于是阿旺用并发的角度来分析,看看这两种方案哪个可以保证数据库与缓存的数据一致性。 ### 先删除缓存,再更新数据库 阿旺还是以用户表的场景来分析。 假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21。 ![图片](https://img-blog.csdnimg.cn/img_convert/cc208c2931b4e889d1a58cb655537767.png) 最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库的数据不一致。 可以看到,**先删除缓存,再更新数据库,在「读 + 写」并发的时候,还是会出现缓存和数据库的数据不一致的问题**。 ### 先更新数据库,再删除缓存 继续用「读 + 写」请求的并发的场景来分析。 假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中。 ![图片](https://img-blog.csdnimg.cn/img_convert/1cc7401143e79383ead96582ac11b615.png) 最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库数据不一致。 从上面的理论上分析,先更新数据库,再删除缓存也是会出现数据不一致性的问题,**但是在实际中,这个问题出现的概率并不高**。 **因为缓存的写入通常要远远快于数据库的写入**,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。 而一旦请求 A 早于请求 B 删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中而从数据库中重新读取数据,所以不会出现这种不一致的情况。 所以,**「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的**。 而且阿旺为了确保万无一失,还给缓存数据加上了「**过期时间**」,就算在这期间存在缓存数据不一致,有过期时间来兜底,这样也能达到最终一致。 阿旺思考到这一步后,觉得自己真的是个小天才,因为他竟然想到了个「天衣无缝」的方案,他二话不说就采用了这个方案,又经过几天的折腾,终于完成了。 他自信满满的向老板汇报,已经解决了上次客户的投诉的问题了。老板觉得阿旺这小伙子不错,这么快就解决问题了,然后让阿旺在观察几天。 事情哪有这么顺利呢?结果又没过多久,老板又收到客户的投诉了,**说自己明明更新了数据,但是数据要过一段时间才生效**,客户接受不了。 老板面无表情的找上阿旺,让阿旺尽快查出问题。 阿旺得知又有 Bug 就更慌了,立马就登录服务器去排查问题,查看日志后得知了原因。 「先更新数据库,再删除缓存」其实是两个操作,前面的所有分析都是建立在这两个操作都能同时执行成功,而这次客户投诉的问题就在于,**在删除缓存(第二个操作)的时候失败了,导致缓存中的数据是旧值**。 好在之前给缓存加上了过期时间,所以才会出现客户说的过一段时间才更新生效的现象,假设如果没有这个过期时间的兜底,那后续的请求读到的就会一直是缓存中的旧数据,这样问题就更大了。 所以新的问题来了,**如何保证「先更新数据库,再删除缓存」这两个操作能执行成功?** 阿旺分析出问题后,慌慌张张的向老板汇报了问题。 老板知道事情后,又给了阿旺几天来解决这个问题,画饼的事情这次没有再提了。 **阿旺会用什么方式来解决这个问题呢?** **老板画的饼事情,能否兑现给阿旺呢?** 预知后事,且听下回阿旺的故事。 ![图片](https://img-blog.csdnimg.cn/img_convert/e51903be6ada20f87bb1c8015ba30631.png) ## 小结 阿旺的事情就聊到这,我们继续说点其他。 「先更新数据库,再删除缓存」的方案虽然保证了数据库与缓存的数据一致性,但是每次更新数据的时候,缓存的数据都会被删除,这样会对缓存的命中率带来影响。 所以,**如果我们的业务对缓存命中率有很高的要求,我们可以采用「更新数据库 + 更新缓存」的方案,因为更新缓存并不会出现缓存未命中的情况**。 但是这个方案前面我们也分析过,在两个更新请求并发执行的时候,会出现数据不一致的问题,因为更新数据库和更新缓存这两个操作是独立的,而我们又没有对操作做任何并发控制,那么当两个线程并发更新它们的话,就会因为写入顺序的不同造成数据的不一致。 所以我们得增加一些手段来解决这个问题,这里提供两种做法: - 在更新缓存前先加个**分布式锁**,保证同一时间只运行一个请求更新缓存,就不会产生并发问题了,当然,在引入锁之后,会对写入性能产生影响。 - 在更新完缓存时,给缓存加上较短的**过期时间**,这样即使出现缓存不一致的情况,缓存的数据也会很快过期,对业务还是能接受的。 对了,针对「先删除缓存,再更新数据库」方案在「读 + 写」并发请求而造成缓存不一致的解决办法是「**延迟双删**」。 延迟双删实现的伪代码如下: ```plain #删除缓存 redis.delKey(X) #更新数据库 db.update(X) #睡眠 Thread.sleep(N) #再删除缓存 redis.delKey(X) ``` 加了个睡眠时间,主要是为了确保请求 A 在睡眠的时候,请求 B 能够在这这一段时间完成「从数据库读取数据,再把缺失的缓存写入缓存」的操作,然后请求 A 睡眠完,再删除缓存。 所以,请求 A 的睡眠时间就需要大于请求 B「从数据库读取数据 + 写入缓存」的时间。 但是具体睡眠多久其实是个**玄学**,很难评估出来,所以这个方案也只是**尽可能**保证一致性而已,极端情况下,依然也会出现缓存不一致的现象。 因此,还是比较建议用「先更新数据库,再删除缓存」的方案。 ------ ## 前情回顾 上回程序员阿旺为了提升数据访问的性能,引入 Redis 作为 MySQL 缓存层,但是这件事情并不是那么简单,因为还要考虑 Redis 和 MySQL 双写一致性的问题。 阿旺经过一番周折,最终选用了「**先更新数据库,再删缓存**」的策略,原因是这个策略即使在并发读写时,也能最大程度保证数据一致性。 聪明的阿旺还搞了个兜底的方案,就是给缓存加上了过期时间。 本以为就这样不会在出现数据一致性的问题,结果将功能上线后,老板还是收到用户的投诉「说自己明明更新了数据,但是数据要过一段时间才生效」,客户接受不了。 老板转告给了阿旺,阿旺得知又有 Bug 就更慌了,立马就登录服务器去排查问题,查看日志后得知了原因。 「先更新数据库,再删除缓存」其实是两个操作,这次客户投诉的问题就在于,**在删除缓存(第二个操作)的时候失败了,导致缓存中的数据是旧值,而数据库是最新值**。 好在之前给缓存加上了过期时间,所以才会出现客户说的过一段时间才更新生效的现象,假设如果没有这个过期时间的兜底,那后续的请求读到的就会一直是缓存中的旧数据,这样问题就更大了。 所以新的问题来了,**如何保证「先更新数据库,再删除缓存」这两个操作能执行成功?** 阿旺分析出问题后,慌慌张张的向老板汇报了问题。 老板知道事情后,又给了阿旺几天来解决这个问题,画饼的事情这次没有再提了。 - 阿旺会用什么方式来解决这个问题呢? - 老板画的饼事情,能否兑现给阿旺呢? ## 如何保证两个操作都能执行成功? 这次用户的投诉是因为在删除缓存(第二个操作)的时候失败了,导致缓存还是旧值,而数据库是最新值,造成数据库和缓存数据不一致的问题,会对敏感业务造成影响。 举个例子,来说明下。 应用要把数据 X 的值从 1 更新为 2,先成功更新了数据库,然后在 Redis 缓存中删除 X 的缓存,但是这个操作却失败了,这个时候数据库中 X 的新值为 2,Redis 中的 X 的缓存值为 1,出现了数据库和缓存数据不一致的问题。 ![图片](https://img-blog.csdnimg.cn/img_convert/2a2ea2854bbc3ae8ae86d7da45fa32ee.png) 那么,后续有访问数据 X 的请求,会先在 Redis 中查询,因为缓存并没有 诶删除,所以会缓存命中,但是读到的却是旧值 1。 其实不管是先操作数据库,还是先操作缓存,只要第二个操作失败都会出现数据一致的问题。 问题原因知道了,该怎么解决呢?有两种方法: - 重试机制。 - 订阅 MySQL binlog,再操作缓存。 先来说第一种。 ##### 重试机制 我们可以引入**消息队列**,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。 - 如果应用**删除缓存失败**,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是**重试机制**。当然,如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。 - 如果**删除缓存成功**,就要把数据从消息队列中移除,避免重复操作,否则就继续重试。 举个例子,来说明重试机制的过程。 ![图片](https://img-blog.csdnimg.cn/img_convert/a4440f0d572612e0832b903e4a62bd2b.png) ##### 订阅 MySQL binlog,再操作缓存 「**先更新数据库,再删缓存**」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。 于是我们就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。 Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。 下图是 Canal 的工作原理: ![图片](https://img-blog.csdnimg.cn/img_convert/2ee2280e9f59b6b4879ebdec6eb0cf52.png) 所以,**如果要想保证「先更新数据库,再删缓存」策略第二个操作能执行成功,我们可以使用「消息队列来重试缓存的删除」,或者「订阅 MySQL binlog 再操作缓存」,这两种方法有一个共同的特点,都是采用异步操作缓存。** ## 老板发饼啦 阿旺由于对消息队列比较熟悉,所以他决定采用「消息队列来重试缓存的删除」的方案,来解决这次的用户问题。 经过几天几夜的操作,服务器搞定啦,立马向老板汇报工作。 老板让阿旺再观察些时间,如果没问题,到中秋节就商量“饼”的事情。 时间过的很快,中秋佳节到了,这期间一直都没有用户反馈数据不一致的问题。 老板见这次阿旺表现很好,没有再出现任何差错,服务器的访问性能也上来了,**于是给阿旺发了这个超级大的月饼,你看这个饼又大又圆,就像你的代码又长又多。** ![图片](https://img-blog.csdnimg.cn/img_convert/9e20ea7a0a6477c717cdfdc6a6642b28.png) 阿旺看到这个月饼,哭笑不得,没想到这就是老板画的饼,是真的很大饼。。。。 *以上故事纯属虚拟,如有巧合,以你为准*。 --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost2/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: redis/base/redis_interview.md ================================================ # Redis 常见面试题 大家好,我是小林。 不知不觉《图解 Redis》系列文章写了很多了,考虑到一些同学面试突击 Redis,所以我整理了 3 万字 + 40 张图的 Redis 八股文,共收集了 40 多个面试题。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/八股文/3万字.jpeg) 发车! ![提纲](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/八股文/redis八股文提纲.png) ## 认识 Redis ### 什么是 Redis? 我们直接看 Redis 官方是怎么介绍自己的。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/八股文/redis简介.png) Redis 官方的介绍原版是英文的,我翻译成了中文后截图的,所以有些文字读起来会比较拗口,没关系,我会把里面比较重要的特性抽出来讲一下。 Redis 是一种基于内存的数据库,对数据的读写操作都是在内存中完成,因此**读写速度非常快**,常用于**缓存,消息队列、分布式锁等场景**。 Redis 提供了多种数据类型来支持不同的业务场景,比如 String(字符串)、Hash(哈希)、List (列表)、Set(集合)、Zset(有序集合)、Bitmaps(位图)、HyperLogLog(基数统计)、GEO(地理信息)、Stream(流),并且对数据类型的操作都是**原子性**的,因为执行命令由单线程负责的,不存在并发竞争的问题。 除此之外,Redis 还支持**事务、持久化、Lua 脚本、多种集群方案(主从复制模式、哨兵模式、切片集群模式)、发布/订阅模式,内存淘汰机制、过期删除机制**等等。 ### Redis 和 Memcached 有什么区别? 很多人都说用 Redis 作为缓存,但是 Memcached 也是基于内存的数据库,为什么不选择它作为缓存呢?要解答这个问题,我们就要弄清楚 Redis 和 Memcached 的区别。 Redis 与 Memcached **共同点**: 1. 都是基于内存的数据库,一般都用来当做缓存使用。 1. 都有过期策略。 1. 两者的性能都非常高。 Redis 与 Memcached **区别**: - Redis 支持的数据类型更丰富(String、Hash、List、Set、ZSet),而 Memcached 只支持最简单的 key-value 数据类型; - Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memcached 没有持久化功能,数据全部存在内存之中,Memcached 重启或者挂掉后,数据就没了; - Redis 原生支持集群模式,Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据; - Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持; ### 为什么用 Redis 作为 MySQL 的缓存? 主要是因为 **Redis 具备「高性能」和「高并发」两种特性**。 ***1、Redis 具备高性能*** 假如用户第一次访问 MySQL 中的某些数据。这个过程会比较慢,因为是从硬盘上读取的。将该用户访问的数据缓存在 Redis 中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了,操作 Redis 缓存就是直接操作内存,所以速度相当快。 ![](https://img-blog.csdnimg.cn/img_convert/37e4378d2edcb5e217b00e5f12973efd.png) 如果 MySQL 中的对应数据改变了之后,同步改变 Redis 缓存中相应的数据即可,不过这里会有 Redis 和 MySQL 双写一致性的问题,后面我们会提到。 ***2、Redis 具备高并发*** 单台设备的 Redis 的 QPS(Query Per Second,每秒钟处理完请求的次数)是 MySQL 的 10 倍,Redis 单机的 QPS 能轻松破 10w,而 MySQL 单机的 QPS 很难破 1w。 所以,直接访问 Redis 能够承受的请求是远远大于直接访问 MySQL 的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。 ## Redis 数据结构 ### Redis 数据类型以及使用场景分别是什么? Redis 提供了丰富的数据类型,常见的有五种数据类型:**String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/八股文/key.png) ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/八股文/五种数据类型.png) 随着 Redis 版本的更新,后面又支持了四种数据类型: **BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增)**。 Redis 五种数据类型的应用场景: - String 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等。 - List 类型的应用场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。 - Hash 类型:缓存对象、购物车等。 - Set 类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。 - Zset 类型:排序场景,比如排行榜、电话和姓名排序等。 Redis 后续版本又支持四种数据类型,它们的应用场景如下: - BitMap(2.2 版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等; - HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等; - GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车; - Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息 ID,支持以消费组形式消费数据。 ::: tip 想深入了解这 9 种数据类型,可以看这篇:[2 万字 + 20 张图 | 细说 Redis 常见数据类型和应用场景](https://xiaolincoding.com/redis/data_struct/command.html) ::: ### 五种常见的 Redis 数据类型是怎么实现? 我画了一张 Redis 数据类型和底层数据结构的对应关图,左边是 Redis 3.0 版本的,也就是《Redis 设计与实现》这本书讲解的版本,现在看还是有点过时了,右边是现在 Redis 7.0 版本的。 ![](https://img-blog.csdnimg.cn/img_convert/9fa26a74965efbf0f56b707a03bb9b7f.png) > String 类型内部实现 String 类型的底层的数据结构实现主要是 SDS(简单动态字符串)。 SDS 和我们认识的 C 字符串不太一样,之所以没有使用 C 语言的字符串表示,因为 SDS 相比于 C 的原生字符串: - **SDS 不仅可以保存文本数据,还可以保存二进制数据**。因为 SDS 使用 len 属性的值而不是空字符来判断字符串是否结束,并且 SDS 的所有 API 都会以处理二进制的方式来处理 SDS 存放在 buf[] 数组里的数据。所以 SDS 不光能存放文本数据,而且能保存图片、音频、视频、压缩文件这样的二进制数据。 - **SDS 获取字符串长度的时间复杂度是 O(1)**。因为 C 语言的字符串并不记录自身长度,所以获取长度的复杂度为 O(n);而 SDS 结构里用 len 属性记录了字符串长度,所以复杂度为 O(1)。 - **Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出**。因为 SDS 在拼接字符串之前会检查 SDS 空间是否满足要求,如果空间不够会自动扩容,所以不会导致缓冲区溢出的问题。 > List 类型内部实现 List 类型的底层数据结构是由**双向链表或压缩列表**实现的: - 如果列表的元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节(默认值,可由 list-max-ziplist-value 配置),Redis 会使用**压缩列表**作为 List 类型的底层数据结构; - 如果列表的元素不满足上面的条件,Redis 会使用**双向链表**作为 List 类型的底层数据结构; 但是**在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表**。 > Hash 类型内部实现 Hash 类型的底层数据结构是由**压缩列表或哈希表**实现的: - 如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用**压缩列表**作为 Hash 类型的底层数据结构; - 如果哈希类型元素不满足上面条件,Redis 会使用**哈希表**作为 Hash 类型的底层数据结构。 **在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了**。 > Set 类型内部实现 Set 类型的底层数据结构是由**哈希表或整数集合**实现的: - 如果集合中的元素都是整数且元素个数小于 512(默认值,set-maxintset-entries 配置)个,Redis 会使用**整数集合**作为 Set 类型的底层数据结构; - 如果集合中的元素不满足上面条件,则 Redis 使用**哈希表**作为 Set 类型的底层数据结构。 > ZSet 类型内部实现 Zset 类型的底层数据结构是由**压缩列表或跳表**实现的: - 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用**压缩列表**作为 Zset 类型的底层数据结构; - 如果有序集合的元素不满足上面的条件,Redis 会使用**跳表**作为 Zset 类型的底层数据结构; **在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。** ::: tip 想深入了解这 9 种数据结构,可以看这篇:[2 万字 + 40 张图 | 细说 Redis 数据结构](https://xiaolincoding.com/redis/data_struct/data_struct.html) ::: ## Redis 线程模型 ### Redis 是单线程吗? **Redis 单线程指的是「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的**,这也是我们常说 Redis 是单线程的原因。 但是,**Redis 程序并不是单线程的**,Redis 在启动的时候,是会**启动后台线程**(BIO)的: - **Redis 在 2.6 版本**,会启动 2 个后台线程,分别处理关闭文件、AOF 刷盘这两个任务; - **Redis 在 4.0 版本之后**,新增了一个新的后台线程,用来异步释放 Redis 内存,也就是 lazyfree 线程。例如执行 unlink key / flushdb async / flushall async 等命令,会把这些删除操作交给后台线程来执行,好处是不会导致 Redis 主线程卡顿。因此,当我们要删除一个大 key 的时候,不要使用 del 命令删除,因为 del 是在主线程处理的,这样会导致 Redis 主线程卡顿,因此我们应该使用 unlink 命令来异步删除大 key。 之所以 Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理,是因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求了。 后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的方法即可。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/八股文/后台线程.jpg) 关闭文件、AOF 刷盘、释放内存这三个任务都有各自的任务队列: - BIO_CLOSE_FILE,关闭文件任务队列:当队列有任务后,后台线程会调用 close(fd) ,将文件关闭; - BIO_AOF_FSYNC,AOF 刷盘任务队列:当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到队列中。当发现队列有任务后,后台线程会调用 fsync(fd),将 AOF 文件刷盘, - BIO_LAZY_FREE,lazy free 任务队列:当队列有任务后,后台线程会 free(obj) 释放对象 / free(dict) 删除数据库所有对象 / free(skiplist) 释放跳表对象; ### Redis 单线程模式是怎样的? Redis 6.0 版本之前的单线模式如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/八股文/redis单线程模型.drawio.png) 图中的蓝色部分是一个事件循环,是由主线程负责的,可以看到网络 I/O 和命令处理都是单线程。 Redis 初始化的时候,会做下面这几件事情: - 首先,调用 epoll_create() 创建一个 epoll 对象和调用 socket() 一个服务端 socket - 然后,调用 bind() 绑定端口和调用 listen() 监听该 socket; - 然后,将调用 epoll_ctl() 将 listen socket 加入到 epoll,同时注册「连接事件」处理函数。 初始化完后,主线程就进入到一个**事件循环函数**,主要会做以下事情: - 首先,先调用**处理发送队列函数**,看是发送队列里是否有任务,如果有发送任务,则通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会注册写事件处理函数,等待 epoll_wait 发现可写后再处理。 - 接着,调用 epoll_wait 函数等待事件的到来: - 如果是**连接事件**到来,则会调用**连接事件处理函数**,该函数会做这些事情:调用 accpet 获取已连接的 socket -> 调用 epoll_ctl 将已连接的 socket 加入到 epoll -> 注册「读事件」处理函数; - 如果是**读事件**到来,则会调用**读事件处理函数**,该函数会做这些事情:调用 read 获取客户端发送的数据 -> 解析命令 -> 处理命令 -> 将客户端对象添加到发送队列 -> 将执行结果写到发送缓存区等待发送; - 如果是**写事件**到来,则会调用**写事件处理函数**,该函数会做这些事情:通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会继续注册写事件处理函数,等待 epoll_wait 发现可写后再处理。 以上就是 Redis 单线模式的工作方式,如果你想看源码解析,可以参考这一篇:[为什么单线程的 Redis 如何做到每秒数万 QPS?](https://mp.weixin.qq.com/s/oeOfsgF-9IOoT5eQt5ieyw) ### Redis 采用单线程为什么还这么快? 官方使用基准测试的结果是,**单线程的 Redis 吞吐量可以达到 10W/每秒**,如下图所示: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/八股文/性能.png) 之所以 Redis 采用单线程(网络 I/O 和执行命令)那么快,有如下几个原因: - Redis 的大部分操作**都在内存中完成**,并且采用了高效的数据结构,因此 Redis 瓶颈可能是机器的内存或者网络带宽,而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了; - Redis 采用单线程模型可以**避免了多线程之间的竞争**,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。 - Redis 采用了 **I/O 多路复用机制**处理大量的客户端 Socket 请求,IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。 ### Redis 6.0 之前为什么使用单线程? 我们都知道单线程的程序是无法利用服务器的多核 CPU 的,那么早期 Redis 版本的主要工作(网络 I/O 和执行命令)为什么还要使用单线程呢?我们不妨先看一下 Redis 官方给出的[FAQ](https://link.juejin.cn/?target=https%3A%2F%2Fredis.io%2Ftopics%2Ffaq)。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/八股文/redis官方单线程回答.png) 核心意思是:**CPU 并不是制约 Redis 性能表现的瓶颈所在**,更多情况下是受到内存大小和网络 I/O 的限制,所以 Redis 核心网络模型使用单线程并没有什么问题,如果你想要使用服务的多核 CPU,可以在一台服务器上启动多个节点或者采用分片集群的方式。 除了上面的官方回答,选择单线程的原因也有下面的考虑。 使用了单线程后,可维护性高,多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,**增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗**。 ### Redis 6.0 之后为什么引入了多线程? 虽然 Redis 的主要工作(网络 I/O 和执行命令)一直是单线程模型,但是**在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求**,**这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上**。 所以为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理。**但是对于命令的执行,Redis 仍然使用单线程来处理,**所以大家**不要误解** Redis 有多线程同时执行命令。 Redis 官方表示,**Redis 6.0 版本引入的多线程 I/O 特性对性能提升至少是一倍以上**。 Redis 6.0 版本支持的 I/O 多线程特性,默认情况下 I/O 多线程只针对发送响应数据(write client socket),并不会以多线程的方式处理读请求(read client socket)。要想开启多线程处理客户端读请求,就需要把 Redis.conf 配置文件中的 io-threads-do-reads 配置项设为 yes。 ```c //读请求也使用 io 多线程 io-threads-do-reads yes ``` 同时,Redis.conf 配置文件中提供了 IO 多线程个数的配置项。 ```c // io-threads N,表示启用 N-1 个 I/O 多线程(主线程也算一个 I/O 线程) io-threads 4 ``` 关于线程数的设置,官方的建议是如果为 4 核的 CPU,建议线程数设置为 2 或 3,如果为 8 核 CPU 建议线程数设置为 6,线程数一定要小于机器核数,线程数并不是越大越好。 因此,Redis 6.0 版本之后,Redis 在启动的时候,默认情况下会创建 6 个线程: - Redis-server:Redis 的主线程,主要负责执行命令; - bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,分别异步处理关闭文件任务、AOF 刷盘任务、释放内存任务; - io_thd_1、io_thd_2、io_thd_3:三个 I/O 线程,io-threads 默认是 4,所以会启动 3(4-1)个 I/O 多线程,用来分担 Redis 网络 I/O 的压力。 ## Redis 持久化 ### Redis 如何实现数据不丢失? Redis 的读写操作都是在内存中,所以 Redis 性能才会高,但是当 Redis 重启后,内存中的数据就会丢失,那为了保证内存中的数据不会丢失,Redis 实现了数据持久化的机制,这个机制会把数据存储到磁盘,这样在 Redis 重启就能够从磁盘中恢复原有的数据。 Redis 共有三种数据持久化的方式: - **AOF 日志**:每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里; - **RDB 快照**:将某一时刻的内存数据,以二进制的方式写入磁盘; - **混合持久化方式**:Redis 4.0 新增的方式,集成了 AOF 和 RBD 的优点; ### AOF 日志是如何实现的? Redis 在执行完一条写操作命令后,就会把该命令以追加的方式写入到一个文件里,然后 Redis 重启时,会读取该文件记录的命令,然后逐一执行命令的方式来进行数据恢复。 ![](https://img-blog.csdnimg.cn/img_convert/6f0ab40396b7fc2c15e6f4487d3a0ad7.png) 我这里以「_set name xiaolin_」命令作为例子,Redis 执行了这条命令后,记录在 AOF 日志里的内容如下图: ![](https://img-blog.csdnimg.cn/img_convert/337021a153944fd0f964ca834e34d0f2.png) 我这里给大家解释下。 「*3」表示当前命令有三个部分,每部分都是以「$+数字」开头,后面紧跟着具体的命令、键或值。然后,这里的「数字」表示这部分中的命令、键或值一共有多少字节。例如,「$3 set」表示这部分有 3 个字节,也就是「set」命令这个字符串的长度。 > 为什么先执行命令,再把数据写入日志呢? Reids 是先执行写操作命令后,才将该命令记录到 AOF 日志里的,这么做其实有两个好处。 - **避免额外的检查开销**:因为如果先将写操作命令记录到 AOF 日志里,再执行该命令的话,如果当前的命令语法有问题,那么如果不进行命令语法检查,该错误的命令记录到 AOF 日志里后,Redis 在使用日志恢复数据时,就可能会出错。 - **不会阻塞当前写操作命令的执行**:因为当写操作命令执行成功后,才会将命令记录到 AOF 日志。 当然,这样做也会带来风险: - **数据可能会丢失:** 执行写操作命令和记录日志是两个过程,那当 Redis 在还没来得及将命令写入到硬盘时,服务器发生宕机了,这个数据就会有丢失的风险。 - **可能阻塞其他操作:** 由于写操作命令执行成功后才记录到 AOF 日志,所以不会阻塞当前命令的执行,但因为 AOF 日志也是在主线程中执行,所以当 Redis 把日志文件写入磁盘的时候,还是会阻塞后续的操作无法执行。 > AOF 写回策略有几种? 先来看看,Redis 写入 AOF 日志的过程,如下图: ![](https://img-blog.csdnimg.cn/img_convert/4eeef4dd1bedd2ffe0b84d4eaa0dbdea.png) 具体说说: 1. Redis 执行完写操作命令后,会将命令追加到 server.aof_buf 缓冲区; 1. 然后通过 write() 系统调用,将 aof_buf 缓冲区的数据写入到 AOF 文件,此时数据并没有写入到硬盘,而是拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘; 1. 具体内核缓冲区的数据什么时候写入到硬盘,由内核决定。 Redis 提供了 3 种写回硬盘的策略,控制的就是上面说的第三步的过程。 在 Redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可填: - **Always**,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘; - **Everysec**,这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘; - **No**,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。 我也把这 3 个写回策略的优缺点总结成了一张表格: ![](https://img-blog.csdnimg.cn/img_convert/98987d9417b2bab43087f45fc959d32a.png) > AOF 日志过大,会触发什么机制? AOF 日志是一个文件,随着执行的写操作命令越来越多,文件的大小会越来越大。 如果当 AOF 日志文件过大就会带来性能问题,比如重启 Redis 后,需要读 AOF 文件的内容以恢复数据,如果文件过大,整个恢复的过程就会很慢。 所以,Redis 为了避免 AOF 文件越写越大,提供了 **AOF 重写机制**,当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。 AOF 重写机制是在重写时,读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到「新的 AOF 文件」,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件。 举个例子,在没有使用重写机制前,假设前后执行了「_set name xiaolin_」和「_set name xiaolincoding_」这两个命令的话,就会将这两个命令记录到 AOF 文件。 ![](https://img-blog.csdnimg.cn/img_convert/723d6c580c05400b3841bc69566dd61b.png) 但是**在使用重写机制后,就会读取 name 最新的 value(键值对) ,然后用一条「set name xiaolincoding」命令记录到新的 AOF 文件**,之前的第一个命令就没有必要记录了,因为它属于「历史」命令,没有作用了。这样一来,一个键值对在重写日志中只用一条命令就行了。 重写工作完成后,就会将新的 AOF 文件覆盖现有的 AOF 文件,这就相当于压缩了 AOF 文件,使得 AOF 文件体积变小了。 > 重写 AOF 日志的过程是怎样的? Redis 的**重写 AOF 过程是由后台子进程 _bgrewriteaof_ 来完成的**,这么做可以达到两个好处: - 子进程进行 AOF 重写期间,主进程可以继续处理命令请求,从而避免阻塞主进程; - 子进程带有主进程的数据副本,这里使用子进程而不是线程,因为如果是使用线程,多线程之间会共享内存,那么在修改共享内存数据的时候,需要通过加锁来保证数据的安全,而这样就会降低性能。而使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程任意一方修改了该共享内存,就会发生「写时复制」,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全。 触发重写机制后,主进程就会创建重写 AOF 的子进程,此时父子进程共享物理内存,重写子进程只会对这个内存进行只读,重写 AOF 子进程会读取数据库里的所有数据,并逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志(新的 AOF 文件)。 **但是重写过程中,主进程依然可以正常处理命令**,那问题来了,重写 AOF 日志过程中,如果主进程修改了已经存在 key-value,那么会发生写时复制,此时这个 key-value 数据在子进程的内存数据就跟主进程的内存数据不一致了,这时要怎么办呢? 为了解决这种数据不一致问题,Redis 设置了一个 **AOF 重写缓冲区**,这个缓冲区在创建 bgrewriteaof 子进程之后开始使用。 在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会**同时将这个写命令写入到「AOF 缓冲区」和「AOF 重写缓冲区」**。 ![](https://img-blog.csdnimg.cn/202105270918298.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODI3Njc0,size_16,color_FFFFFF,t_70#pic_center) 也就是说,在 bgrewriteaof 子进程执行 AOF 重写期间,主进程需要执行以下三个工作: - 执行客户端发来的命令; - 将执行后的写命令追加到「AOF 缓冲区」; - 将执行后的写命令追加到「AOF 重写缓冲区」; 当子进程完成 AOF 重写工作(_扫描数据库中所有数据,逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志_)后,会向主进程发送一条信号,信号是进程间通讯的一种方式,且是异步的。 主进程收到该信号后,会调用一个信号处理函数,该函数主要做以下工作: - 将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,使得新旧两个 AOF 文件所保存的数据库状态一致; - 新的 AOF 的文件进行改名,覆盖现有的 AOF 文件。 信号函数执行完后,主进程就可以继续像往常一样处理命令了。 ::: tip AOF 日志的内容就暂时提这些,想更详细了解 AOF 日志的工作原理,可以详细看这篇:[AOF 持久化是怎么实现的](https://xiaolincoding.com/redis/storage/aof.html) ::: ### RDB 快照是如何实现的呢? 因为 AOF 日志记录的是操作命令,不是实际的数据,所以用 AOF 方法做故障恢复时,需要全量把日志都执行一遍,一旦 AOF 日志非常多,势必会造成 Redis 的恢复操作缓慢。 为了解决这个问题,Redis 增加了 RDB 快照。所谓的快照,就是记录某一个瞬间东西,比如当我们给风景拍照时,那一个瞬间的画面和信息就记录到了一张照片。 所以,RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据。 因此在 Redis 恢复数据时,RDB 恢复数据的效率会比 AOF 高些,因为直接将 RDB 文件读入内存就可以,不需要像 AOF 那样还需要额外执行操作命令的步骤才能恢复数据。 > RDB 做快照时会阻塞线程吗? Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave,他们的区别就在于是否在「主线程」里执行: - 执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,**会阻塞主线程**; - 执行了 bgsave 命令,会创建一个子进程来生成 RDB 文件,这样可以**避免主线程的阻塞**; Redis 还可以通过配置文件的选项来实现每隔一段时间自动执行一次 bgsave 命令,默认会提供以下配置: ```c save 900 1 save 300 10 save 60 10000 ``` 别看选项名叫 save,实际上执行的是 bgsave 命令,也就是会创建子进程来生成 RDB 快照文件。 只要满足上面条件的任意一个,就会执行 bgsave,它们的意思分别是: - 900 秒之内,对数据库进行了至少 1 次修改; - 300 秒之内,对数据库进行了至少 10 次修改; - 60 秒之内,对数据库进行了至少 10000 次修改。 这里提一点,Redis 的快照是**全量快照**,也就是说每次执行快照,都是把内存中的「所有数据」都记录到磁盘中。所以执行快照是一个比较重的操作,如果频率太频繁,可能会对 Redis 性能产生影响。如果频率太低,服务器故障时,丢失的数据会更多。 > RDB 在执行快照的时候,数据能修改吗? 可以的,执行 bgsave 过程中,Redis 依然**可以继续处理操作命令**的,也就是数据是能被修改的,关键的技术就在于**写时复制技术(Copy-On-Write, COW)。** 执行 bgsave 命令的时候,会通过 fork() 创建子进程,此时子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个,此时如果主线程执行读操作,则主线程和 bgsave 子进程互相不影响。 ![](https://img-blog.csdnimg.cn/img_convert/c34a9d1f58d602ff1fe8601f7270baa7.png) 如果主线程执行写操作,则被修改的数据会复制一份副本,然后 bgsave 子进程会把该副本数据写入 RDB 文件,在这个过程中,主线程仍然可以直接修改原来的数据。 ![](https://img-blog.csdnimg.cn/img_convert/ebd620db8a1af66fbeb8f4d4ef6adc68.png) ::: tip RDB 快照的内容就暂时提这些,想更详细了解 RDB 快照的工作原理,可以详细看这篇:[RDB 快照是怎么实现的?](https://xiaolincoding.com/redis/storage/rdb.html) ::: ### 为什么会有混合持久化? RDB 优点是数据恢复速度快,但是快照的频率不好把握。频率太低,丢失的数据就会比较多,频率太高,就会影响性能。 AOF 优点是丢失数据少,但是数据恢复不快。 为了集成了两者的优点,Redis 4.0 提出了**混合使用 AOF 日志和内存快照**,也叫混合持久化,既保证了 Redis 重启速度,又降低数据丢失风险。 混合持久化工作在 **AOF 日志重写过程**,当开启了混合持久化时,在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。 也就是说,使用了混合持久化,AOF 文件的**前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据**。 ![](https://img-blog.csdnimg.cn/img_convert/f67379b60d151262753fec3b817b8617.png) 这样的好处在于,重启 Redis 加载数据的时候,由于前半部分是 RDB 内容,这样**加载的时候速度会很快**。 加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的操作命令,可以使得**数据更少的丢失**。 **混合持久化优点:** - 混合持久化结合了 RDB 和 AOF 持久化的优点,开头为 RDB 的格式,使得 Redis 可以更快的启动,同时结合 AOF 的优点,有减低了大量数据丢失的风险。 **混合持久化缺点:** - AOF 文件中添加了 RDB 格式的内容,使得 AOF 文件的可读性变得很差; - 兼容性差,如果开启混合持久化,那么此混合持久化 AOF 文件,就不能用在 Redis 4.0 之前版本了。 ## Redis 集群 ### Redis 如何实现服务高可用? 要想设计一个高可用的 Redis 服务,一定要从 Redis 的多服务节点来考虑,比如 Redis 的主从复制、哨兵模式、切片集群。 > 主从复制 主从复制是 Redis 高可用服务的最基础的保证,实现方案就是将从前的一台 Redis 服务器,同步数据到多台从 Redis 服务器上,即一主多从的模式,且主从服务器之间采用的是「读写分离」的方式。 主服务器可以进行读写操作,当发生写操作时自动将写操作同步给从服务器,而从服务器一般是只读,并接受主服务器同步过来写操作命令,然后执行这条命令。 ![](https://img-blog.csdnimg.cn/img_convert/2b7231b6aabb9a9a2e2390ab3a280b2d.png) 也就是说,所有的数据修改只在主服务器上进行,然后将最新的数据同步给从服务器,这样就使得主从服务器的数据是一致的。 注意,主从服务器之间的命令复制是**异步**进行的。 具体来说,在主从服务器命令传播阶段,主服务器收到新的写命令后,会发送给从服务器。但是,主服务器并不会等到从服务器实际执行完命令后,再把结果返回给客户端,而是主服务器自己在本地执行完命令后,就会向客户端返回结果了。如果从服务器还没有执行主服务器同步过来的命令,主从服务器间的数据就不一致了。 所以,无法实现强一致性保证(主从数据时时刻刻保持一致),数据不一致是难以避免的。 ::: tip 想更详细了解 Redis 主从复制的工作原理,可以详细看这篇:[主从复制是怎么实现的?](https://xiaolincoding.com/redis/cluster/master_slave_replication.html) ::: > 哨兵模式 在使用 Redis 主从服务的时候,会有一个问题,就是当 Redis 的主从服务器出现故障宕机时,需要手动进行恢复。 为了解决这个问题,Redis 增加了哨兵模式(**Redis Sentinel**),因为哨兵模式做到了可以监控主从服务器,并且提供**主从节点故障转移的功能。** ![](https://img-blog.csdnimg.cn/26f88373d8454682b9e0c1d4fd1611b4.png) ::: tip 想更详细了解 Redis 哨兵的工作原理,可以详细看这篇:[哨兵是怎么实现的?](https://xiaolincoding.com/redis/cluster/sentinel.html) ::: > 切片集群模式 当 Redis 缓存数据量大到一台服务器无法缓存时,就需要使用 **Redis 切片集群**(Redis Cluster)方案,它将数据分布在不同的服务器上,以此来降低系统对单主节点的依赖,从而提高 Redis 服务的读写性能。 Redis Cluster 方案采用哈希槽(Hash Slot),来处理数据和节点之间的映射关系。在 Redis Cluster 方案中,**一个切片集群共有 16384 个哈希槽**,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中,具体执行过程分为两大步: - 根据键值对的 key,按照 [CRC16 算法](https://en.wikipedia.org/wiki/Cyclic_redundancy_check)计算一个 16 bit 的值。 - 再用 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。 接下来的问题就是,这些哈希槽怎么被映射到具体的 Redis 节点上的呢?有两种方案: - **平均分配:** 在使用 cluster create 命令创建 Redis 集群时,Redis 会自动把所有哈希槽平均分布到集群节点上。比如集群中有 9 个节点,则每个节点上槽的个数为 16384/9 个。 - **手动分配:** 可以使用 cluster meet 命令手动建立节点间的连接,组成集群,再使用 cluster addslots 命令,指定每个节点上的哈希槽个数。 为了方便你的理解,我通过一张图来解释数据、哈希槽,以及节点三者的映射分布关系。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/八股文/redis切片集群映射分布关系.jpg) 上图中的切片集群一共有 3 个节点,假设有 4 个哈希槽(Slot 0~Slot 3)时,我们就可以通过命令手动分配哈希槽,比如节点 1 保存哈希槽 0 和 1,节点 2 保存哈希槽 2 和 3。 ```c redis-cli -h 192.168.1.10 –p 6379 cluster addslots 0,1 redis-cli -h 192.168.1.11 –p 6379 cluster addslots 2,3 ``` 然后在集群运行的过程中,key1 和 key2 计算完 CRC16 值后,对哈希槽总个数 5 进行取模,再根据各自的模数结果,就可以被映射到对应的节点 1 和节点 3 上了。 需要注意的是,在手动分配哈希槽时,需要把 16384 个槽都分配完,否则 Redis 集群无法正常工作。 ### 集群脑裂导致数据丢失怎么办? > 什么是脑裂? 先来理解集群的脑裂现象,这就好比一个人有两个大脑,那么到底受谁控制呢? 那么在 Redis 中,集群脑裂产生数据丢失的现象是怎样的呢? 在 Redis 主从架构中,部署方式一般是「一主多从」,主节点提供写操作,从节点提供读操作。 如果主节点的网络突然发生了问题,它与所有的从节点都失联了,但是此时的主节点和客户端的网络是正常的,这个客户端并不知道 Redis 内部已经出现了问题,还在照样的向这个失联的主节点写数据(过程 A),此时这些数据被旧主节点缓存到了缓冲区里,因为主从节点之间的网络问题,这些数据都是无法同步给从节点的。 这时,哨兵也发现主节点失联了,它就认为主节点挂了(但实际上主节点正常运行,只是网络出问题了),于是哨兵就会在「从节点」中选举出一个 leeder 作为主节点,这时集群就有两个主节点了 —— **脑裂出现了**。 然后,网络突然好了,哨兵因为之前已经选举出一个新主节点了,它就会把旧主节点降级为从节点(A),然后从节点(A)会向新主节点请求数据同步,**因为第一次同步是全量同步的方式,此时的从节点(A)会清空掉自己本地的数据,然后再做全量同步。所以,之前客户端在过程 A 写入的数据就会丢失了,也就是集群产生脑裂数据丢失的问题**。 总结一句话就是:由于网络问题,集群节点之间失去联系。主从数据不同步;重新平衡选举,产生两个主服务。等网络恢复,旧主节点会降级为从节点,再与新主节点进行同步复制的时候,由于会从节点会清空自己的缓冲区,所以导致之前客户端写入的数据丢失了。 > 解决方案 当主节点发现从节点下线或者通信超时的总数量小于阈值时,那么禁止主节点进行写数据,直接把错误返回给客户端。 在 Redis 的配置文件中有两个参数我们可以设置: - min-slaves-to-write x,主节点必须要有至少 x 个从节点连接,如果小于这个数,主节点会禁止写数据。 - min-slaves-max-lag x,主从数据复制和同步的延迟不能超过 x 秒,如果超过,主节点会禁止写数据。 我们可以把 min-slaves-to-write 和 min-slaves-max-lag 这两个配置项搭配起来使用,分别给它们设置一定的阈值,假设为 N 和 T。 这两个配置项组合后的要求是,主库连接的从库中至少有 N 个从库,和主库进行数据复制时的 ACK 消息延迟不能超过 T 秒,否则,主库就不会再接收客户端的写请求了。 即使原主库是假故障,它在假故障期间也无法响应哨兵心跳,也不能和从库进行同步,自然也就无法和从库进行 ACK 确认了。这样一来,min-slaves-to-write 和 min-slaves-max-lag 的组合要求就无法得到满足,**原主库就会被限制接收客户端写请求,客户端也就不能在原主库中写入新数据了**。 **等到新主库上线时,就只有新主库能接收和处理客户端请求,此时,新写的数据会被直接写到新主库中。而原主库会被哨兵降为从库,即使它的数据被清空了,也不会有新数据丢失。** 再来举个例子。 假设我们将 min-slaves-to-write 设置为 1,把 min-slaves-max-lag 设置为 12s,把哨兵的 down-after-milliseconds 设置为 10s,主库因为某些原因卡住了 15s,导致哨兵判断主库客观下线,开始进行主从切换。 同时,因为原主库卡住了 15s,没有一个从库能和原主库在 12s 内进行数据复制,原主库也无法接收客户端请求了。 这样一来,主从切换完成后,也只有新主库能接收请求,不会发生脑裂,也就不会发生数据丢失的问题了。 ## Redis 过期删除与内存淘汰 ### Redis 使用的过期删除策略是什么? Redis 是可以对 key 设置过期时间的,因此需要有相应的机制将已过期的键值对删除,而做这个工作的就是过期键值删除策略。 每当我们对一个 key 设置了过期时间时,Redis 会把该 key 带上过期时间存储到一个**过期字典**(expires dict)中,也就是说「过期字典」保存了数据库中所有 key 的过期时间。 当我们查询一个 key 时,Redis 首先检查该 key 是否存在于过期字典中: - 如果不在,则正常读取键值; - 如果存在,则会获取该 key 的过期时间,然后与当前系统时间进行比对,如果比系统时间大,那就没有过期,否则判定该 key 已过期。 Redis 使用的过期删除策略是「**惰性删除 + 定期删除**」这两种策略配和使用。 > 什么是惰性删除策略? 惰性删除策略的做法是,**不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。** 惰性删除的流程图如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/%E8%BF%87%E6%9C%9F%E7%AD%96%E7%95%A5/%E6%83%B0%E6%80%A7%E5%88%A0%E9%99%A4.jpg) 惰性删除策略的**优点**: - 因为每次访问时,才会检查 key 是否过期,所以此策略只会使用很少的系统资源,因此,惰性删除策略对 CPU 时间最友好。 惰性删除策略的**缺点**: - 如果一个 key 已经过期,而这个 key 又仍然保留在数据库中,那么只要这个过期 key 一直没有被访问,它所占用的内存就不会释放,造成了一定的内存空间浪费。所以,惰性删除策略对内存不友好。 > 什么是定期删除策略? 定期删除策略的做法是,**每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期 key。** Redis 的定期删除的流程: 1. 从过期字典中随机抽取 20 个 key; 1. 检查这 20 个 key 是否过期,并删除已过期的 key; 1. 如果本轮检查的已过期 key 的数量,超过 5 个(20/4),也就是「已过期 key 的数量」占比「随机抽取 key 的数量」大于 25%,则继续重复步骤 1;如果已过期的 key 比例小于 25%,则停止继续删除过期 key,然后等待下一轮再检查。 可以看到,定期删除是一个循环的流程。那 Redis 为了保证定期删除不会出现循环过度,导致线程卡死现象,为此增加了定期删除循环流程的时间上限,默认不会超过 25ms。 定期删除的流程如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/%E8%BF%87%E6%9C%9F%E7%AD%96%E7%95%A5/%E5%AE%9A%E6%97%B6%E5%88%A0%E9%99%A4%E6%B5%81%E7%A8%8B.jpg) 定期删除策略的**优点**: - 通过限制删除操作执行的时长和频率,来减少删除操作对 CPU 的影响,同时也能删除一部分过期的数据减少了过期键对空间的无效占用。 定期删除策略的**缺点**: - 难以确定删除操作执行的时长和频率。如果执行的太频繁,就会对 CPU 不友好;如果执行的太少,那又和惰性删除一样了,过期 key 占用的内存不会及时得到释放。 可以看到,惰性删除策略和定期删除策略都有各自的优点,所以 **Redis 选择「惰性删除 + 定期删除」这两种策略配和使用**,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。 ::: tip Redis 的过期删除的内容就暂时提这些,想更详细了解的,可以详细看这篇:[Redis 过期删除策略和内存淘汰策略有什么区别?](https://xiaolincoding.com/redis/module/strategy.html) ::: ### Redis 持久化时,对过期键会如何处理的? Redis 持久化文件有两种格式:RDB(Redis Database)和 AOF(Append Only File),下面我们分别来看过期键在这两种格式中的呈现状态。 RDB 文件分为两个阶段,RDB 文件生成阶段和加载阶段。 - **RDB 文件生成阶段**:从内存状态持久化成 RDB(文件)的时候,会对 key 进行过期检查,**过期的键「不会」被保存到新的 RDB 文件中**,因此 Redis 中的过期键不会对生成新 RDB 文件产生任何影响。 - **RDB 加载阶段**:RDB 加载阶段时,要看服务器是主服务器还是从服务器,分别对应以下两种情况: - **如果 Redis 是「主服务器」运行模式的话,在载入 RDB 文件时,程序会对文件中保存的键进行检查,过期键「不会」被载入到数据库中**。所以过期键不会对载入 RDB 文件的主服务器造成影响; - **如果 Redis 是「从服务器」运行模式的话,在载入 RDB 文件时,不论键是否过期都会被载入到数据库中**。但由于主从服务器在进行数据同步时,从服务器的数据会被清空。所以一般来说,过期键对载入 RDB 文件的从服务器也不会造成影响。 AOF 文件分为两个阶段,AOF 文件写入阶段和 AOF 重写阶段。 - **AOF 文件写入阶段**:当 Redis 以 AOF 模式持久化时,**如果数据库某个过期键还没被删除,那么 AOF 文件会保留此过期键,当此过期键被删除后,Redis 会向 AOF 文件追加一条 DEL 命令来显式地删除该键值**。 - **AOF 重写阶段**:执行 AOF 重写时,会对 Redis 中的键值对进行检查,**已过期的键不会被保存到重写后的 AOF 文件中**,因此不会对 AOF 重写造成任何影响。 ### Redis 主从模式中,对过期键会如何处理? 当 Redis 运行在主从模式下时,**从库不会进行过期扫描,从库对过期的处理是被动的**。也就是即使从库中的 key 过期了,如果有客户端访问从库时,依然可以得到 key 对应的值,像未过期的键值对一样返回。 从库的过期键处理依靠主服务器控制,**主库在 key 到期时,会在 AOF 文件里增加一条 del 指令,同步到所有的从库**,从库通过执行这条 del 指令来删除过期的 key。 ### Redis 内存满了,会发生什么? 在 Redis 的运行内存达到了某个阀值,就会触发**内存淘汰机制**,这个阀值就是我们设置的最大运行内存,此值在 Redis 的配置文件中可以找到,配置项为 maxmemory。 ### Redis 内存淘汰策略有哪些? Redis 内存淘汰策略共有八种,这八种策略大体分为「不进行数据淘汰」和「进行数据淘汰」两类策略。 ***1、不进行数据淘汰的策略*** **noeviction**(Redis3.0 之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,而是不再提供服务,直接返回错误。 ***2、进行数据淘汰的策略*** 针对「进行数据淘汰」这一类策略,又可以细分为「在设置了过期时间的数据中进行淘汰」和「在所有数据范围内进行淘汰」这两类策略。 在设置了过期时间的数据中进行淘汰: - **volatile-random**:随机淘汰设置了过期时间的任意键值; - **volatile-ttl**:优先淘汰更早过期的键值。 - **volatile-lru**(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值; - **volatile-lfu**(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值; 在所有数据范围内进行淘汰: - **allkeys-random**:随机淘汰任意键值; - **allkeys-lru**:淘汰整个键值中最久未使用的键值; - **allkeys-lfu**(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。 ### LRU 算法和 LFU 算法有什么区别? > 什么是 LRU 算法? **LRU** 全称是 Least Recently Used 翻译为**最近最少使用**,会选择淘汰最近最少使用的数据。 传统 LRU 算法的实现是基于「链表」结构,链表中的元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可,因为链表尾部的元素就代表最久未被使用的元素。 Redis 并没有使用这样的方式实现 LRU 算法,因为传统的 LRU 算法存在两个问题: - 需要用链表管理所有的缓存数据,这会带来额外的空间开销; - 当有数据被访问时,需要在链表上把该数据移动到头端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。 > Redis 是如何实现 LRU 算法的? Redis 实现的是一种**近似 LRU 算法**,目的是为了更好的节约内存,它的**实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间**。 当 Redis 进行内存淘汰时,会使用**随机采样的方式来淘汰数据**,它是随机取 5 个值(此值可配置),然后**淘汰最久没有使用的那个**。 Redis 实现的 LRU 算法的优点: - 不用为所有的数据维护一个大链表,节省了空间占用; - 不用在每次数据访问时都移动链表项,提升了缓存的性能; 但是 LRU 算法有一个问题,**无法解决缓存污染问题**,比如应用一次读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在 Redis 缓存中很长一段时间,造成缓存污染。 因此,在 Redis 4.0 之后引入了 LFU 算法来解决这个问题。 > 什么是 LFU 算法? LFU 全称是 Least Frequently Used 翻译为**最近最不常用的**,LFU 算法是根据数据访问次数来淘汰数据的,它的核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。 所以,LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。这样就解决了偶尔被访问一次之后,数据留存在缓存中很长一段时间的问题,相比于 LRU 算法也更合理一些。 > Redis 是如何实现 LFU 算法的? LFU 算法相比于 LRU 算法的实现,多记录了「数据的访问频次」的信息。Redis 对象的结构如下: ```c typedef struct redisObject { ... // 24 bits,用于记录对象的访问信息 unsigned lru:24; ... } robj; ``` Redis 对象头中的 lru 字段,在 LRU 算法下和 LFU 算法下使用方式并不相同。 **在 LRU 算法中**,Redis 对象头的 24 bits 的 lru 字段是用来记录 key 的访问时间戳,因此在 LRU 模式下,Redis 可以根据对象头中的 lru 字段记录的值,来比较最后一次 key 的访问时间长,从而淘汰最久未被使用的 key。 **在 LFU 算法中**,Redis 对象头的 24 bits 的 lru 字段被分成两段来存储,高 16bit 存储 ldt(Last Decrement Time),用来记录 key 的访问时间戳;低 8bit 存储 logc(Logistic Counter),用来记录 key 的访问频次。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/%E8%BF%87%E6%9C%9F%E7%AD%96%E7%95%A5/lru%E5%AD%97%E6%AE%B5.png) ::: tip Redis 的内存淘汰的内容就暂时提这些,想更详细了解的,可以详细看这篇:[Redis 过期删除策略和内存淘汰策略有什么区别?](https://xiaolincoding.com/redis/module/strategy.html) ::: ## Redis 缓存设计 ### 如何避免缓存雪崩、缓存击穿、缓存穿透? > 如何避免缓存雪崩? 通常我们为了保证缓存中的数据与数据库中的数据一致性,会给 Redis 里的数据设置过期时间,当缓存数据过期后,用户访问的数据如果不在缓存里,业务系统需要重新生成缓存,因此就会访问数据库,并将数据更新到 Redis 里,这样后续请求都可以直接命中缓存。 ![](https://img-blog.csdnimg.cn/img_convert/e2b8d2eb5536aa71664772457792ec40.png) 那么,当**大量缓存数据在同一时间过期(失效)**时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是**缓存雪崩**的问题。 对于缓存雪崩问题,我们可以采用两种方案解决。 - **将缓存失效时间随机打散:** 我们可以在原有的失效时间基础上增加一个随机值(比如 1 到 10 分钟)这样每个缓存的过期时间都不重复了,也就降低了缓存集体失效的概率。 - **设置缓存不过期:** 我们可以通过后台服务来更新缓存数据,从而避免因为缓存失效造成的缓存雪崩,也可以在一定程度上避免缓存并发问题。 > 如何避免缓存击穿? 我们的业务通常会有几个数据会被频繁地访问,比如秒杀活动,这类被频繁访问的数据被称为热点数据。 如果缓存中的**某个热点数据过期**了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是**缓存击穿**的问题。 ![](https://img-blog.csdnimg.cn/img_convert/acb5f4e7ef24a524a53c39eb016f63d4.png) 可以发现缓存击穿跟缓存雪崩很相似,你可以认为缓存击穿是缓存雪崩的一个子集。 应对缓存击穿可以采取前面说到两种方案: - 互斥锁方案(Redis 中使用 setNX 方法设置一个状态位,表示这是一种锁定状态),保证同一时间只有一个业务线程请求缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。 - 不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间; > 如何避免缓存穿透? 当发生缓存雪崩或击穿时,数据库中还是保存了应用要访问的数据,一旦缓存恢复相对应的数据,就可以减轻数据库的压力,而缓存穿透就不一样了。 当用户访问的数据,**既不在缓存中,也不在数据库中**,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增,这就是**缓存穿透**的问题。 ![](https://img-blog.csdnimg.cn/img_convert/b7031182f770a7a5b3c82eaf749f53b0.png) 缓存穿透的发生一般有这两种情况: - 业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据; - 黑客恶意攻击,故意大量访问某些读取不存在数据的业务; 应对缓存穿透的方案,常见的方案有三种。 - **非法请求的限制**:当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在 API 入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。 - **设置空值或者默认值**:当我们线上业务发现缓存穿透的现象时,可以针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。 - **使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在**:我们可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在,即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。 ::: tip 推荐阅读:[什么是缓存雪崩、击穿、穿透?](https://xiaolincoding.com/redis/cluster/cache_problem.html) ::: ### 如何设计一个缓存策略,可以动态缓存热点数据呢? 由于数据存储受限,系统并不是将所有数据都需要存放到缓存中的,而**只是将其中一部分热点数据缓存起来**,所以我们要设计一个热点数据动态缓存的策略。 热点数据动态缓存的策略总体思路:**通过数据最新访问时间来做排名,并过滤掉不常访问的数据,只留下经常访问的数据**。 以电商平台场景中的例子,现在要求只缓存用户经常访问的 Top 1000 的商品。具体细节如下: - 先通过缓存系统做一个排序队列(比如存放 1000 个商品),系统会根据商品的访问时间,更新队列信息,越是最近访问的商品排名越靠前; - 同时系统会定期过滤掉队列中排名最后的 200 个商品,然后再从数据库中随机读取出 200 个商品加入队列中; - 这样当请求每次到达的时候,会先从队列中获取商品 ID,如果命中,就根据 ID 再从另一个缓存数据结构中读取实际的商品信息,并返回。 在 Redis 中可以用 zadd 方法和 zrange 方法来完成排序队列和获取 200 个商品的操作。 ### 说说常见的缓存更新策略? 常见的缓存更新策略共有 3 种: - Cache Aside(旁路缓存)策略; - Read/Write Through(读穿 / 写穿)策略; - Write Back(写回)策略; 实际开发中,Redis 和 MySQL 的更新策略用的是 Cache Aside,另外两种策略应用不了。 > Cache Aside(旁路缓存)策略 Cache Aside(旁路缓存)策略是最常用的,应用程序直接与「数据库、缓存」交互,并负责对缓存的维护,该策略又可以细分为「读策略」和「写策略」。 ![](https://img-blog.csdnimg.cn/img_convert/6e3db3ba2f829ddc14237f5c7c00e7ce.png) **写策略的步骤:** - 先更新数据库中的数据,再删除缓存中的数据。 **读策略的步骤:** - 如果读取的数据命中了缓存,则直接返回数据; - 如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。 注意,写策略的步骤的顺序不能倒过来,即**不能先删除缓存再更新数据库**,原因是在「读 + 写」并发的时候,会出现缓存和数据库的数据不一致性的问题。 举个例子,假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21。 ![](https://img-blog.csdnimg.cn/img_convert/cc208c2931b4e889d1a58cb655537767.png) 最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库的数据不一致。 **为什么「先更新数据库再删除缓存」不会有数据不一致的问题?** 继续用「读 + 写」请求的并发的场景来分析。 假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中。 ![](https://img-blog.csdnimg.cn/img_convert/1cc7401143e79383ead96582ac11b615.png) 最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库数据不一致。 从上面的理论上分析,先更新数据库,再删除缓存也是会出现数据不一致性的问题,**但是在实际中,这个问题出现的概率并不高**。 **因为缓存的写入通常要远远快于数据库的写入**,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。而一旦请求 A 早于请求 B 删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中而从数据库中重新读取数据,所以不会出现这种不一致的情况。 **Cache Aside 策略适合读多写少的场景,不适合写多的场景**,因为当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。如果业务对缓存命中率有严格的要求,那么可以考虑两种解决方案: - 一种做法是在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能会有一些影响; - 另一种做法同样也是在更新数据时更新缓存,只是给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快过期,对业务的影响也是可以接受。 > Read/Write Through(读穿 / 写穿)策略 Read/Write Through(读穿 / 写穿)策略原则是应用程序只和缓存交互,不再和数据库交互,而是由缓存和数据库交互,相当于更新数据库的操作由缓存自己代理了。 ***1、Read Through 策略*** 先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库查询数据,并将结果写入到缓存组件,最后缓存组件将数据返回给应用。 ***2、Write Through 策略*** 当有数据更新的时候,先查询要写入的数据在缓存中是否已经存在: - 如果缓存中数据已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,然后缓存组件告知应用程序更新完成。 - 如果缓存中数据不存在,直接更新数据库,然后返回; 下面是 Read Through/Write Through 策略的示意图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/八股文/WriteThrough.jpg) Read Through/Write Through 策略的特点是由缓存节点而非应用程序来和数据库打交道,在我们开发过程中相比 Cache Aside 策略要少见一些,原因是我们经常使用的分布式缓存组件,无论是 Memcached 还是 Redis 都不提供写入数据库和自动加载数据库中的数据的功能。而我们在使用本地缓存的时候可以考虑使用这种策略。 > Write Back(写回)策略 Write Back(写回)策略在更新数据的时候,只更新缓存,同时将缓存数据设置为脏的,然后立马返回,并不会更新数据库。对于数据库的更新,会通过批量异步更新的方式进行。 实际上,Write Back(写回)策略也不能应用到我们常用的数据库和缓存的场景中,因为 Redis 并没有异步更新数据库的功能。 Write Back 是计算机体系结构中的设计,比如 CPU 的缓存、操作系统中文件系统的缓存都采用了 Write Back(写回)策略。 **Write Back 策略特别适合写多的场景**,因为发生写操作的时候,只需要更新缓存,就立马返回了。比如,写文件的时候,实际上是写入到文件系统的缓存就返回了,并不会写磁盘。 **但是带来的问题是,数据不是强一致性的,而且会有数据丢失的风险**,因为缓存一般使用内存,而内存是非持久化的,所以一旦缓存机器掉电,就会造成原本缓存中的脏数据丢失。所以你会发现系统在掉电之后,之前写入的文件会有部分丢失,就是因为 Page Cache 还没有来得及刷盘造成的。 这里贴一张 CPU 缓存与内存使用 Write Back 策略的流程图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/八股文/writeback.png) 有没有觉得这个流程很熟悉?因为我在写 [CPU 缓存文章](https://xiaolincoding.com/os/1_hardware/cpu_mesi.html#%E5%86%99%E5%9B%9E)的时候提到过。 ### 如何保证缓存和数据库数据的一致性? ::: tip 推荐阅读:[数据库和缓存如何保证一致性?](https://xiaolincoding.com/redis/architecture/mysql_redis_consistency.html) ::: ## Redis 实战 ### Redis 如何实现延迟队列? 延迟队列是指把当前要做的事情,往后推迟一段时间再做。延迟队列的常见使用场景有以下几种: - 在淘宝、京东等购物平台上下单,超过一定时间未付款,订单会自动取消; - 打车的时候,在规定时间没有车主接单,平台会取消你的单并提醒你暂时没有车主接单; - 点外卖的时候,如果商家在 10 分钟还没接单,就会自动取消订单; 在 Redis 可以使用有序集合(ZSet)的方式来实现延迟消息队列的,ZSet 有一个 Score 属性可以用来存储延迟执行的时间。 使用 zadd score1 value1 命令就可以一直往内存中生产消息。再利用 zrangebysocre 查询符合条件的所有待处理的任务,通过循环执行队列任务即可。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/八股文/延迟队列.png) ### Redis 的大 key 如何处理? > 什么是 Redis 大 key? 大 key 并不是指 key 的值很大,而是 key 对应的 value 很大。 一般而言,下面这两种情况被称为大 key: - String 类型的值大于 10 KB; - Hash、List、Set、ZSet 类型的元素的个数超过 5000 个; > 大 key 会造成什么问题? 大 key 会带来以下四种影响: - **客户端超时阻塞**。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。 - **引发网络阻塞**。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。 - **阻塞工作线程**。如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。 - **内存分布不均**。集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有大 key 的 Redis 节点占用内存多,QPS 也会比较大。 > 如何找到大 key? ***1、redis-cli --bigkeys 查找大 key*** 可以通过 redis-cli --bigkeys 命令查找大 key: ```shell redis-cli -h 127.0.0.1 -p6379 -a "password" --bigkeys ``` 使用的时候注意事项: - 最好选择在从节点上执行该命令。因为主节点上执行时,会阻塞主节点; - 如果没有从节点,那么可以选择在 Redis 实例业务压力的低峰阶段进行扫描查询,以免影响到实例的正常运行;或者可以使用 -i 参数控制扫描间隔,避免长时间扫描降低 Redis 实例的性能。 该方式的不足之处: - 这个方法只能返回每种类型中最大的那个 bigkey,无法得到大小排在前 N 位的 bigkey; - 对于集合类型来说,这个方法只统计集合元素个数的多少,而不是实际占用的内存量。但是,一个集合中的元素个数多,并不一定占用的内存就多。因为,有可能每个元素占用的内存很小,这样的话,即使元素个数有很多,总内存开销也不大; ***2、使用 SCAN 命令查找大 key*** 使用 SCAN 命令对数据库扫描,然后用 TYPE 命令获取返回的每一个 key 的类型。 对于 String 类型,可以直接使用 STRLEN 命令获取字符串的长度,也就是占用的内存空间字节数。 对于集合类型来说,有两种方法可以获得它占用的内存大小: - 如果能够预先从业务层知道集合元素的平均大小,那么,可以使用下面的命令获取集合元素的个数,然后乘以集合元素的平均大小,这样就能获得集合占用的内存大小了。List 类型:`LLEN` 命令;Hash 类型:`HLEN` 命令;Set 类型:`SCARD` 命令;Sorted Set 类型:`ZCARD` 命令; - 如果不能提前知道写入集合的元素大小,可以使用 `MEMORY USAGE` 命令(需要 Redis 4.0 及以上版本),查询一个键值对占用的内存空间。 ***3、使用 RdbTools 工具查找大 key*** 使用 RdbTools 第三方开源工具,可以用来解析 Redis 快照(RDB)文件,找到其中的大 key。 比如,下面这条命令,将大于 10 kb 的  key  输出到一个表格文件。 ```shell rdb dump.rdb -c memory --bytes 10240 -f redis.csv ``` > 如何删除大 key? 删除操作的本质是要释放键值对占用的内存空间,不要小瞧内存的释放过程。 释放内存只是第一步,为了更加高效地管理内存空间,在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序。 所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis 主线程的阻塞,如果主线程发生了阻塞,其他所有请求可能都会超时,超时越来越多,会造成 Redis 连接耗尽,产生各种异常。 因此,删除大 key 这一个动作,我们要小心。具体要怎么做呢?这里给出两种方法: - 分批次删除 - 异步删除(Redis 4.0 版本以上) ***1、分批次删除*** 对于**删除大 Hash**,使用 `hscan` 命令,每次获取 100 个字段,再用 `hdel` 命令,每次删除 1 个字段。 Python 代码: ```python def del_large_hash(): r = redis.StrictRedis(host='redis-host1', port=6379) large_hash_key ="xxx" #要删除的大 hash 键名 cursor = '0' while cursor != 0: # 使用 hscan 命令,每次获取 100 个字段 cursor, data = r.hscan(large_hash_key, cursor=cursor, count=100) for item in data.items(): # 再用 hdel 命令,每次删除 1 个字段 r.hdel(large_hash_key, item[0]) ``` 对于**删除大 List**,通过 `ltrim` 命令,每次删除少量元素。 Python 代码: ```python def del_large_list(): r = redis.StrictRedis(host='redis-host1', port=6379) large_list_key = 'xxx' #要删除的大 list 的键名 while r.llen(large_list_key)>0: #每次只删除最右 100 个元素 r.ltrim(large_list_key, 0, -101) ``` 对于**删除大 Set**,使用 `sscan` 命令,每次扫描集合中 100 个元素,再用 `srem` 命令每次删除一个键。 Python 代码: ```python def del_large_set(): r = redis.StrictRedis(host='redis-host1', port=6379) large_set_key = 'xxx' # 要删除的大 set 的键名 cursor = '0' while cursor != 0: # 使用 sscan 命令,每次扫描集合中 100 个元素 cursor, data = r.sscan(large_set_key, cursor=cursor, count=100) for item in data: # 再用 srem 命令每次删除一个键 r.srem(large_size_key, item) ``` 对于**删除大 ZSet**,使用 `zremrangebyrank` 命令,每次删除 top 100 个元素。 Python 代码: ```python def del_large_sortedset(): r = redis.StrictRedis(host='large_sortedset_key', port=6379) large_sortedset_key='xxx' while r.zcard(large_sortedset_key)>0: # 使用 zremrangebyrank 命令,每次删除 top 100 个元素 r.zremrangebyrank(large_sortedset_key,0,99) ``` ***2、异步删除*** 从 Redis 4.0 版本开始,可以采用**异步删除**法,**用 unlink 命令代替 del 来删除**。 这样 Redis 会将这个 key 放入到一个异步线程中进行删除,这样不会阻塞主线程。 除了主动调用 unlink 命令实现异步删除之外,我们还可以通过配置参数,达到某些条件的时候自动进行异步删除。 主要有 4 种场景,默认都是关闭的: ```plain lazyfree-lazy-eviction no lazyfree-lazy-expire no lazyfree-lazy-server-del noslave-lazy-flush no ``` 它们代表的含义如下: - lazyfree-lazy-eviction:表示当 Redis 运行内存超过 maxmeory 时,是否开启 lazy free 机制删除; - lazyfree-lazy-expire:表示设置了过期时间的键值,当过期之后是否开启 lazy free 机制删除; - lazyfree-lazy-server-del:有些指令在处理已存在的键时,会带有一个隐式的 del 键的操作,比如 rename 命令,当目标键已存在,Redis 会先删除目标键,如果这些目标键是一个 big key,就会造成阻塞删除的问题,此配置表示在这种场景中是否开启 lazy free 机制删除; - slave-lazy-flush:针对 slave (从节点) 进行全量数据同步,slave 在加载 master 的 RDB 文件前,会运行 flushall 来清理自己的数据,它表示此时是否开启 lazy free 机制删除。 建议开启其中的 lazyfree-lazy-eviction、lazyfree-lazy-expire、lazyfree-lazy-server-del 等配置,这样就可以有效的提高主线程的执行效率。 ### Redis 管道有什么用? 管道技术(Pipeline)是客户端提供的一种批处理技术,用于一次处理多个 Redis 命令,从而提高整个交互的性能。 普通命令模式,如下图所示: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/八股文/普通命令模式.jpg) 管道模式,如下图所示: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/八股文/管道模式.jpg) 使用**管道技术可以解决多个命令执行时的网络等待**,它是把多个命令整合到一起发送给服务器端处理之后统一返回给客户端,这样就免去了每条命令执行后都要等待的情况,从而有效地提高了程序的执行效率。 但使用管道技术也要注意避免发送的命令过大,或管道内的数据太多而导致的网络阻塞。 要注意的是,管道技术本质上是客户端提供的功能,而非 Redis 服务器端的功能。 ### Redis 事务支持回滚吗? MySQL 在执行事务时,会提供回滚机制,当事务执行发生错误时,事务中的所有操作都会撤销,已经修改的数据也会被恢复到事务执行前的状态。 **Redis 中并没有提供回滚机制**,虽然 Redis 提供了 DISCARD 命令,但是这个命令只能用来主动放弃事务执行,把暂存的命令队列清空,起不到回滚的效果。 下面是 DISCARD 命令用法: ```c #读取 count 的值4 127.0.0.1:6379> GET count "1" #开启事务 127.0.0.1:6379> MULTI OK #发送事务的第一个操作,对count减1 127.0.0.1:6379> DECR count QUEUED #执行DISCARD命令,主动放弃事务 127.0.0.1:6379> DISCARD OK #再次读取a:stock的值,值没有被修改 127.0.0.1:6379> GET count "1" ``` 事务执行过程中,如果命令入队时没报错,而事务提交后,实际执行时报错了,正确的命令依然可以正常执行,所以这可以看出 **Redis 并不一定保证原子性**(原子性:事务中的命令要不全部成功,要不全部失败)。 比如下面这个例子: ```c #获取name原本的值 127.0.0.1:6379> GET name "xiaolin" #开启事务 127.0.0.1:6379> MULTI OK #设置新值 127.0.0.1:6379(TX)> SET name xialincoding QUEUED #注意,这条命令是错误的 # expire 过期时间正确来说是数字,并不是‘10s’字符串,但是还是入队成功了 127.0.0.1:6379(TX)> EXPIRE name 10s QUEUED #提交事务,执行报错 #可以看到 set 执行成功,而 expire 执行错误。 127.0.0.1:6379(TX)> EXEC 1) OK 2) (error) ERR value is not an integer or out of range #可以看到,name 还是被设置为新值了 127.0.0.1:6379> GET name "xialincoding" ``` > 为什么 Redis 不支持事务回滚? Redis [官方文档](https://redis.io/topics/transactions )的解释如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/八股文/redis官方解释回滚.png) 大概的意思是,作者不支持事务回滚的原因有以下两个: - 他认为 Redis 事务的执行时,错误通常都是编程错误造成的,这种错误通常只会出现在开发环境中,而很少会在实际的生产环境中出现,所以他认为没有必要为 Redis 开发事务回滚功能; - 不支持事务回滚是因为这种复杂的功能和 Redis 追求的简单高效的设计主旨不符合。 这里不支持事务回滚,指的是不支持事务运行时错误的事务回滚。 ### 如何用 Redis 实现分布式锁的? 分布式锁是用于分布式环境下并发控制的一种机制,用于控制某个资源在同一时刻只能被一个应用所使用。如下图所示: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/八股文/分布式锁.jpg) Redis 本身可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来保存分布式锁,而且 Redis 的读写性能高,可以应对高并发的锁操作场景。 Redis 的 SET 命令有个 NX 参数可以实现「key 不存在才插入」,所以可以用它来实现分布式锁: - 如果 key 不存在,则显示插入成功,可以用来表示加锁成功; - 如果 key 存在,则会显示插入失败,可以用来表示加锁失败。 基于 Redis 节点实现分布式锁时,对于加锁操作,我们需要满足三个条件。 - 加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,我们使用 SET 命令带上 NX 选项来实现加锁; - 锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在 SET 命令执行时加上 EX/PX 选项,设置其过期时间; - 锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用 SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端; 满足这三个条件的分布式命令如下: ```c SET lock_key unique_value NX PX 10000 ``` - lock_key 就是 key 键; - unique_value 是客户端生成的唯一的标识,区分来自不同客户端的锁操作; - NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作; - PX 10000 表示设置 lock_key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁。 而解锁的过程就是将 lock_key 键删除(del lock_key),但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。 可以看到,解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。 ```c // 释放锁时,先比较 unique_value 是否相等,避免锁的误释放 if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end ``` 这样一来,就通过使用 SET 命令和 Lua 脚本在 Redis 单节点上完成了分布式锁的加锁和解锁。 > 基于 Redis 实现分布式锁有什么优缺点? 基于 Redis 实现分布式锁的**优点**: 1. 性能高效(这是选择缓存实现分布式锁最核心的出发点)。 1. 实现方便。很多研发工程师选择使用 Redis 来实现分布式锁,很大成分上是因为 Redis 提供了 setnx 方法,实现分布式锁很方便。 1. 避免单点故障(因为 Redis 是跨集群部署的,自然就避免了单点故障)。 基于 Redis 实现分布式锁的**缺点**: - **超时时间不好设置**。如果锁的超时时间设置过长,会影响性能,如果设置的超时时间过短会保护不到共享资源。比如在有些场景中,一个线程 A 获取到了锁之后,由于业务代码执行时间可能比较长,导致超过了锁的超时时间,自动失效,注意 A 线程没执行完,后续线程 B 又意外的持有了锁,意味着可以操作共享资源,那么两个线程之间的共享资源就没办法进行保护了。 - **那么如何合理设置超时时间呢?** 我们可以基于续约的方式设置超时时间:先给锁设置一个超时时间,然后启动一个守护线程,让守护线程在一段时间后,重新设置这个锁的超时时间。实现方式就是:写一个守护线程,然后去判断锁的情况,当锁快失效的时候,再次进行续约加锁,当主线程执行完成后,销毁续约锁即可,不过这种方式实现起来相对复杂。 - **Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性**。如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。 > Redis 如何解决集群情况下分布式锁的可靠性? 为了保证集群环境下分布式锁的可靠性,Redis 官方已经设计了一个分布式锁算法 Redlock(红锁)。 它是基于**多个 Redis 节点**的分布式锁,即使有节点发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。官方推荐是至少部署 5 个 Redis 节点,而且都是主节点,它们之间没有任何关系,都是一个个孤立的节点。 Redlock 算法的基本思路,**是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败**。 这样一来,即使有某个 Redis 节点发生故障,因为锁的数据在其他节点上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失。 Redlock 算法加锁三个过程: - 第一步是,客户端获取当前时间(t1)。 - 第二步是,客户端按顺序依次向 N 个 Redis 节点执行加锁操作: - 加锁操作使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。 - 如果某个 Redis 节点发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给「加锁操作」设置一个超时时间(不是对「锁」设置超时时间,而是对「加锁操作」设置超时时间),加锁操作的超时时间需要远远地小于锁的过期时间,一般也就是设置为几十毫秒。 - 第三步是,一旦客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁,就再次获取当前时间(t2),然后计算计算整个加锁过程的总耗时(t2-t1)。如果 t2-t1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败。 可以看到,加锁成功要同时满足两个条件(*简述:如果有超过半数的 Redis 节点成功的获取到了锁,并且总耗时没有超过锁的有效时间,那么就是加锁成功*): - 条件一:客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁; - 条件二:客户端从大多数节点获取锁的总耗时(t2-t1)小于锁设置的过期时间。 加锁成功后,客户端需要重新计算这把锁的有效时间,计算的结果是「锁最初设置的过期时间」减去「客户端从大多数节点获取锁的总耗时(t2-t1)」。如果计算的结果已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。 加锁失败后,客户端向**所有 Redis 节点发起释放锁的操作**,释放锁的操作和在单节点上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。 ---- 参考资料: - 《Redis 设计与实现》 - 《Redis 实战》 - 《Redis 核心技术与实战》 - 《Redis 核心原理与实战》 --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: redis/cluster/cache_problem.md ================================================ # 什么是缓存雪崩、击穿、穿透? 用户的数据一般都是存储于数据库,数据库的数据是落在磁盘上的,磁盘的读写速度可以说是计算机里最慢的硬件了。 当用户的请求,都访问数据库的话,请求数量一上来,数据库很容易就崩溃的了,所以为了避免用户直接访问数据库,会用 Redis 作为缓存层。 因为 Redis 是内存数据库,我们可以将数据库的数据缓存在 Redis 里,相当于数据缓存在内存,内存的读写速度比硬盘快好几个数量级,这样大大提高了系统性能。 ![图片](https://img-blog.csdnimg.cn/img_convert/37e4378d2edcb5e217b00e5f12973efd.png) 引入了缓存层,就会有缓存异常的三个问题,分别是**缓存雪崩、缓存击穿、缓存穿透**。 这三个问题也是面试中很常考察的问题,我们不光要清楚地知道它们是怎么发生,还需要知道如何解决它们。 话不多说,**发车!** ![图片](https://img-blog.csdnimg.cn/img_convert/61781cd6d82e4a0cc5f7521333049f0d.png) ------ ## 缓存雪崩 通常我们为了保证缓存中的数据与数据库中的数据一致性,会给 Redis 里的数据设置过期时间,当缓存数据过期后,用户访问的数据如果不在缓存里,业务系统需要重新生成缓存,因此就会访问数据库,并将数据更新到 Redis 里,这样后续请求都可以直接命中缓存。 ![图片](https://img-blog.csdnimg.cn/img_convert/e2b8d2eb5536aa71664772457792ec40.png) 那么,当**大量缓存数据在同一时间过期(失效)或者 Redis 故障宕机**时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是**缓存雪崩**的问题。 ![图片](https://img-blog.csdnimg.cn/img_convert/717343a0da7a1b05edab1d1cdf8f28e5.png) 可以看到,发生缓存雪崩有两个原因: - 大量数据同时过期; - Redis 故障宕机; 不同的诱因,应对的策略也会不同。 ### 大量数据同时过期 针对大量数据同时过期而引发的缓存雪崩问题,常见的应对方法有下面这几种: - 均匀设置过期时间; - 互斥锁; - 双 key 策略; - 后台更新缓存; *1. 均匀设置过期时间* 如果要给缓存数据设置过期时间,应该避免将大量的数据设置成同一个过期时间。我们可以在对缓存数据设置过期时间时,**给这些数据的过期时间加上一个随机数**,这样就保证数据不会在同一时间过期。 *2. 互斥锁* 当业务线程在处理用户请求时,**如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存**(从数据库读取数据,再将数据更新到 Redis 里),当缓存构建完成后,再释放锁。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。 实现互斥锁的时候,最好设置**超时时间**,不然第一个请求拿到了锁,然后这个请求发生了某种意外而一直阻塞,一直不释放锁,这时其他请求也一直拿不到锁,整个系统就会出现无响应的现象。 *3. 双 key 策略* 我们对缓存数据可以使用两个 key,一个是**主 key,会设置过期时间**,一个是**备 key,不会设置过期**,它们只是 key 不一样,但是 value 值是一样的,相当于给缓存数据做了个副本。 当业务线程访问不到「主 key」的缓存数据时,就直接返回「备 key」的缓存数据,然后在更新缓存的时候,**同时更新「主 key」和「备 key」的数据。** 双 key 策略的好处是,当主 key 过期了,有大量请求获取缓存数据的时候,直接返回备 key 的数据,这样可以快速响应请求。而不用因为 key 失效而导致大量请求被锁阻塞住(采用了互斥锁,仅一个请求来构建缓存),后续再通知后台线程,重新构建主 key 的数据。 *4. 后台更新缓存* 业务线程不再负责更新缓存,缓存也不设置有效期,而是**让缓存“永久有效”,并将更新缓存的工作交由后台线程定时更新**。 事实上,缓存数据不设置有效期,并不是意味着数据一直能在内存里,因为**当系统内存紧张的时候,有些缓存数据会被“淘汰”**,而在缓存被“淘汰”到下一次后台定时更新缓存的这段时间内,业务线程读取缓存失败就返回空值,业务的视角就以为是数据丢失了。 解决上面的问题的方式有两种。 第一种方式,后台线程不仅负责定时更新缓存,而且也负责**频繁地检测缓存是否有效**,检测到缓存失效了,原因可能是系统紧张而被淘汰的,于是就要马上从数据库读取数据,并更新到缓存。 这种方式的检测时间间隔不能太长,太长也导致用户获取的数据是一个空值而不是真正的数据,所以检测的间隔最好是毫秒级的,但是总归是有个间隔时间,用户体验一般。 第二种方式,在业务线程发现缓存数据失效后(缓存数据被淘汰),**通过消息队列发送一条消息通知后台线程更新缓存**,后台线程收到消息后,在更新缓存前可以判断缓存是否存在,存在就不执行更新缓存操作;不存在就读取数据库数据,并将数据加载到缓存。这种方式相比第一种方式缓存的更新会更及时,用户体验也比较好。 在业务刚上线的时候,我们最好提前把数据缓存起来,而不是等待用户访问才来触发缓存构建,这就是所谓的**缓存预热**,后台更新缓存的机制刚好也适合干这个事情。 ### Redis 故障宕机 针对 Redis 故障宕机而引发的缓存雪崩问题,常见的应对方法有下面这几种: - 服务熔断或请求限流机制; - 构建 Redis 缓存高可靠集群; *1. 服务熔断或请求限流机制* 因为 Redis 故障宕机而导致缓存雪崩问题时,我们可以启动**服务熔断**机制,**暂停业务应用对缓存服务的访问,直接返回错误**,不用再继续访问数据库,从而降低对数据库的访问压力,保证数据库系统的正常运行,然后等到 Redis 恢复正常后,再允许业务应用访问缓存服务。 服务熔断机制是保护数据库的正常运行,但是暂停了业务应用访问缓存服系统,全部业务都无法正常工作 为了减少对业务的影响,我们可以启用**请求限流**机制,**只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务**,等到 Redis 恢复正常并把缓存预热完后,再解除请求限流的机制。 *2. 构建 Redis 缓存高可靠集群* 服务熔断或请求限流机制是缓存雪崩发生后的应对方案,我们最好通过**主从节点的方式构建 Redis 缓存高可靠集群**。 如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了由于 Redis 故障宕机而导致的缓存雪崩问题。 ------ ## 缓存击穿 我们的业务通常会有几个数据会被频繁地访问,比如秒杀活动,这类被频繁访问的数据被称为热点数据。 如果缓存中的**某个热点数据过期**了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是**缓存击穿**的问题。 ![图片](https://img-blog.csdnimg.cn/img_convert/acb5f4e7ef24a524a53c39eb016f63d4.png) 可以发现缓存击穿跟缓存雪崩很相似,你可以认为缓存击穿是缓存雪崩的一个子集。 应对缓存击穿可以采取前面说到两种方案: - 互斥锁方案,保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。 - 不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间; ------ ## 缓存穿透 当发生缓存雪崩或击穿时,数据库中还是保存了应用要访问的数据,一旦缓存恢复相对应的数据,就可以减轻数据库的压力,而缓存穿透就不一样了。 当用户访问的数据,**既不在缓存中,也不在数据库中**,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增,这就是**缓存穿透**的问题。 ![图片](https://img-blog.csdnimg.cn/img_convert/b7031182f770a7a5b3c82eaf749f53b0.png) 缓存穿透的发生一般有这两种情况: - 业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据; - 黑客恶意攻击,故意大量访问某些读取不存在数据的业务; 应对缓存穿透的方案,常见的方案有三种。 - 第一种方案,非法请求的限制; - 第二种方案,缓存空值或者默认值; - 第三种方案,使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在; 第一种方案,非法请求的限制 当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在 API 入口处我们要判断出请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。 第二种方案,缓存空值或者默认值 当我们线上业务发现缓存穿透的现象时,可以针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。 *第三种方案,使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在。* 我们可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在。 即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。 那问题来了,布隆过滤器是如何工作的呢?接下来,我介绍下。 布隆过滤器由「初始值都为 0 的位图数组」和「N 个哈希函数」两部分组成。当我们在写入数据库数据时,在布隆过滤器里做个标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中。 布隆过滤器会通过 3 个操作完成标记: - 第一步,使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值; - 第二步,将第一步得到的 N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置。 - 第三步,将每个哈希值在位图数组的对应位置的值设置为 1; 举个例子,假设有一个位图数组长度为 8,哈希函数 3 个的布隆过滤器。 ![图片](https://img-blog.csdnimg.cn/img_convert/86b0046c2622b2c4bda697f9bc0f5b28.png) 在数据库写入数据 x 后,把数据 x 标记在布隆过滤器时,数据 x 会被 3 个哈希函数分别计算出 3 个哈希值,然后在对这 3 个哈希值对 8 取模,假设取模的结果为 1、4、6,然后把位图数组的第 1、4、6 位置的值设置为 1。**当应用要查询数据 x 是否数据库时,通过布隆过滤器只要查到位图数组的第 1、4、6 位置的值是否全为 1,只要有一个为 0,就认为数据 x 不在数据库中**。 布隆过滤器由于是基于哈希函数实现查找的,高效查找的同时**存在哈希冲突的可能性**,比如数据 x 和数据 y 可能都落在第 1、4、6 位置,而事实上,可能数据库中并不存在数据 y,存在误判的情况。 所以,**查询布隆过滤器说数据存在,并不一定证明数据库中存在这个数据,但是查询到数据不存在,数据库中一定就不存在这个数据**。 ------ ## 总结 缓存异常会面临的三个问题:缓存雪崩、击穿和穿透。 其中,缓存雪崩和缓存击穿主要原因是数据不在缓存中,而导致大量请求访问了数据库,数据库压力骤增,容易引发一系列连锁反应,导致系统奔溃。不过,一旦数据被重新加载回缓存,应用又可以从缓存快速读取数据,不再继续访问数据库,数据库的压力也会瞬间降下来。因此,缓存雪崩和缓存击穿应对的方案比较类似。 而缓存穿透主要原因是数据既不在缓存也不在数据库中。因此,缓存穿透与缓存雪崩、击穿应对的方案不太一样。 我这里整理了表格,你可以从下面这张表格很好的知道缓存雪崩、击穿和穿透的区别以及应对方案。 ![图片](https://img-blog.csdnimg.cn/img_convert/061e2c04e0ebca3425dd75dd035b6b7b.png) ------ 参考资料: 1.《极客时间:Redis 核心技术与实战》 2. https://github.com/doocs/advanced-java/blob/main/docs/high-concurrency/redis-caching-avalanche-and-caching-penetration.md 3. https://medium.com/@mena.meseha/3-major-problems-and-solutions-in-the-cache-world-155ecae41d4f ---- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: redis/cluster/cluster.md ================================================ # 为什么要有集群? 小林正在赶稿中。。。。。。 ================================================ FILE: redis/cluster/master_slave_replication.md ================================================ # 主从复制是怎么实现的? 大家好,我是小林哥。 我在前两篇已经给大家图解了 AOF 和 RDB,这两个持久化技术保证了即使在服务器重启的情况下也不会丢失数据(或少量损失)。 不过,由于数据都是存储在一台服务器上,如果出事就完犊子了,比如: - 如果服务器发生了宕机,由于数据恢复是需要点时间,那么这个期间是无法服务新的请求的; - 如果这台服务器的硬盘出现了故障,可能数据就都丢失了。 要避免这种单点故障,最好的办法是将数据备份到其他服务器上,让这些服务器也可以对外提供服务,这样即使有一台服务器出现了故障,其他服务器依然可以继续提供服务。 ![图片](https://img-blog.csdnimg.cn/img_convert/22c7fe97ce5d3c382b08d83a4d8a5b96.png) 多台服务器要保存同一份数据,这里问题就来了。 这些服务器之间的数据如何保持一致性呢?数据的读写操作是否每台服务器都可以处理? Redis 提供了**主从复制模式**,来避免上述的问题。 这个模式可以保证多台服务器的数据一致性,且主从服务器之间采用的是「读写分离」的方式。 主服务器可以进行读写操作,当发生写操作时自动将写操作同步给从服务器,而从服务器一般是只读,并接受主服务器同步过来写操作命令,然后执行这条命令。 ![图片](https://img-blog.csdnimg.cn/img_convert/2b7231b6aabb9a9a2e2390ab3a280b2d.png) 也就是说,所有的数据修改只在主服务器上进行,然后将最新的数据同步给从服务器,这样就使得主从服务器的数据是一致的。 同步这两个字说的简单,但是这个同步过程并没有想象中那么简单,要考虑的事情不是一两个。 我们先来看看,主从服务器间的第一次同步是如何工作的? ## 第一次同步 多台服务器之间要通过什么方式来确定谁是主服务器,或者谁是从服务器呢? 我们可以使用 `replicaof`(Redis 5.0 之前使用 slaveof)命令形成主服务器和从服务器的关系。 比如,现在有服务器 A 和 服务器 B,我们在服务器 B 上执行下面这条命令: ```plain # 服务器 B 执行这条命令 replicaof <服务器 A 的 IP 地址> <服务器 A 的 Redis 端口号> ``` 接着,服务器 B 就会变成服务器 A 的「从服务器」,然后与主服务器进行第一次同步。 主从服务器间的第一次同步的过程可分为三个阶段: - 第一阶段是建立链接、协商同步; - 第二阶段是主服务器同步数据给从服务器; - 第三阶段是主服务器发送新写操作命令给从服务器。 为了让你更清楚了解这三个阶段,我画了一张图。 ![图片](https://img-blog.csdnimg.cn/img_convert/ea4f7e86baf2435af3999e5cd38b6a26.png) 接下来,我在具体介绍每一个阶段都做了什么。 *第一阶段:建立链接、协商同步* 执行了 replicaof 命令后,从服务器就会给主服务器发送 `psync` 命令,表示要进行数据同步。 psync 命令包含两个参数,分别是**主服务器的 runID** 和**复制进度 offset**。 - runID,每个 Redis 服务器在启动时都会自动生产一个随机的 ID 来唯一标识自己。当从服务器和主服务器第一次同步时,因为不知道主服务器的 run ID,所以将其设置为 "?"。 - offset,表示复制的进度,第一次同步时,其值为 -1。 主服务器收到 psync 命令后,会用 `FULLRESYNC` 作为响应命令返回给对方。 并且这个响应命令会带上两个参数:主服务器的 runID 和主服务器目前的复制进度 offset。从服务器收到响应后,会记录这两个值。 FULLRESYNC 响应命令的意图是采用**全量复制**的方式,也就是主服务器会把所有的数据都同步给从服务器。 所以,第一阶段的工作时为了全量复制做准备。 那具体怎么全量同步呀呢?我们可以往下看第二阶段。 *第二阶段:主服务器同步数据给从服务器* 接着,主服务器会执行 bgsave 命令来生成 RDB 文件,然后把文件发送给从服务器。 从服务器收到 RDB 文件后,会先清空当前的数据,然后载入 RDB 文件。 这里有一点要注意,主服务器生成 RDB 这个过程是不会阻塞主线程的,因为 bgsave 命令是产生了一个子进程来做生成 RDB 文件的工作,是异步工作的,这样 Redis 依然可以正常处理命令。 但是,这期间的写操作命令并没有记录到刚刚生成的 RDB 文件中,这时主从服务器间的数据就不一致了。 那么为了保证主从服务器的数据一致性,**主服务器在下面这三个时间间隙中将收到的写操作命令,写入到 replication buffer 缓冲区里**: - 主服务器生成 RDB 文件期间; - 主服务器发送 RDB 文件给从服务器期间; - 「从服务器」加载 RDB 文件期间; *第三阶段:主服务器发送新写操作命令给从服务器* 在主服务器生成的 RDB 文件发送完,从服务器收到 RDB 文件后,丢弃所有旧数据,将 RDB 数据载入到内存。完成 RDB 的载入后,会回复一个确认消息给主服务器。 接着,主服务器将 replication buffer 缓冲区里所记录的写操作命令发送给从服务器,从服务器执行来自主服务器 replication buffer 缓冲区里发来的命令,这时主从服务器的数据就一致了。 至此,主从服务器的第一次同步的工作就完成了。 ## 命令传播 主从服务器在完成第一次同步后,双方之间就会维护一个 TCP 连接。 ![图片](https://img-blog.csdnimg.cn/img_convert/03eacec67cc58ff8d5819d0872ddd41e.png) 后续主服务器可以通过这个连接继续将写操作命令传播给从服务器,然后从服务器执行该命令,使得与主服务器的数据库状态相同。 而且这个连接是长连接的,目的是避免频繁的 TCP 连接和断开带来的性能开销。 上面的这个过程被称为**基于长连接的命令传播**,通过这种方式来保证第一次同步后的主从服务器的数据一致性。 ## 分摊主服务器的压力 在前面的分析中,我们可以知道主从服务器在第一次数据同步的过程中,主服务器会做两件耗时的操作:生成 RDB 文件和传输 RDB 文件。 主服务器是可以有多个从服务器的,如果从服务器数量非常多,而且都与主服务器进行全量同步的话,就会带来两个问题: - 由于是通过 bgsave 命令来生成 RDB 文件的,那么主服务器就会忙于使用 fork() 创建子进程,如果主服务器的内存数据非常大,在执行 fork() 函数时是会阻塞主线程的,从而使得 Redis 无法正常处理请求; - 传输 RDB 文件会占用主服务器的网络带宽,会对主服务器响应命令请求产生影响。 这种情况就好像,刚创业的公司,由于人不多,所以员工都归老板一个人管,但是随着公司的发展,人员的扩充,老板慢慢就无法承担全部员工的管理工作了。 要解决这个问题,老板就需要设立经理职位,由经理管理多名普通员工,然后老板只需要管理经理就好。 Redis 也是一样的,从服务器可以有自己的从服务器,我们可以把拥有从服务器的从服务器当作经理角色,它不仅可以接收主服务器的同步数据,自己也可以同时作为主服务器的形式将数据同步给从服务器,组织形式如下图: ![图片](https://img-blog.csdnimg.cn/img_convert/4d850bfe8d712d3d67ff13e59b919452.png) 通过这种方式,**主服务器生成 RDB 和传输 RDB 的压力可以分摊到充当经理角色的从服务器**。 那具体怎么做到的呢? 其实很简单,我们在「从服务器」上执行下面这条命令,使其作为目标服务器的从服务器: ```plain replicaof <目标服务器的 IP> 6379 ``` 此时如果目标服务器本身也是「从服务器」,那么该目标服务器就会成为「经理」的角色,不仅可以接受主服务器同步的数据,也会把数据同步给自己旗下的从服务器,从而减轻主服务器的负担。 ## 增量复制 主从服务器在完成第一次同步后,就会基于长连接进行命令传播。 可是,网络总是不按套路出牌的嘛,说延迟就延迟,说断开就断开。 如果主从服务器间的网络连接断开了,那么就无法进行命令传播了,这时从服务器的数据就没办法和主服务器保持一致了,客户端就可能从「从服务器」读到旧的数据。 ![图片](https://img-blog.csdnimg.cn/img_convert/4845008abadaa871613873f5ffdcb542.png) 那么问题来了,如果此时断开的网络,又恢复正常了,要怎么继续保证主从服务器的数据一致性呢? 在 Redis 2.8 之前,如果主从服务器在命令同步时出现了网络断开又恢复的情况,从服务器就会和主服务器重新进行一次全量复制,很明显这样的开销太大了,必须要改进一波。 所以,从 Redis 2.8 开始,网络断开又恢复后,从主从服务器会采用**增量复制**的方式继续同步,也就是只会把网络断开期间主服务器接收到的写操作命令,同步给从服务器。 网络恢复后的增量复制过程如下图: ![图片](https://img-blog.csdnimg.cn/img_convert/e081b470870daeb763062bb873a4477e.png) 主要有三个步骤: - 从服务器在恢复网络后,会发送 psync 命令给主服务器,此时的 psync 命令里的 offset 参数不是 -1; - 主服务器收到该命令后,然后用 CONTINUE 响应命令告诉从服务器接下来采用增量复制的方式同步数据; - 然后主服务将主从服务器断线期间,所执行的写命令发送给从服务器,然后从服务器执行这些命令。 那么关键的问题来了,**主服务器怎么知道要将哪些增量数据发送给从服务器呢?** 答案藏在这两个东西里: - **repl_backlog_buffer**,是一个「**环形**」缓冲区,用于主从服务器断连后,从中找到差异的数据; - **replication offset**,标记上面那个缓冲区的同步进度,主从服务器都有各自的偏移量,主服务器使用 master_repl_offset 来记录自己「*写*」到的位置,从服务器使用 slave_repl_offset 来记录自己「*读*」到的位置。 那 repl_backlog_buffer 缓冲区是什么时候写入的呢? 在主服务器进行命令传播时,不仅会将写命令发送给从服务器,还会将写命令写入到 repl_backlog_buffer 缓冲区里,因此 这个缓冲区里会保存着最近传播的写命令。 网络断开后,当从服务器重新连上主服务器时,从服务器会通过 psync 命令将自己的复制偏移量 slave_repl_offset 发送给主服务器,主服务器根据自己的 master_repl_offset 和 slave_repl_offset 之间的差距,然后来决定对从服务器执行哪种同步操作: - 如果判断出从服务器要读取的数据还在 repl_backlog_buffer 缓冲区里,那么主服务器将采用**增量同步**的方式; - 相反,如果判断出从服务器要读取的数据已经不存在 repl_backlog_buffer 缓冲区里,那么主服务器将采用**全量同步**的方式。 当主服务器在 repl_backlog_buffer 中找到主从服务器差异(增量)的数据后,就会将增量的数据写入到 replication buffer 缓冲区,这个缓冲区我们前面也提到过,它是缓存将要传播给从服务器的命令。 ![图片](https://img-blog.csdnimg.cn/img_convert/2db4831516b9a8b79f833cf0593c1f12.png) repl_backlog_buffer 缓行缓冲区的默认大小是 1M,并且由于它是一个环形缓冲区,所以当缓冲区写满后,主服务器继续写入的话,就会覆盖之前的数据。因此,当主服务器的写入速度远超于从服务器的读取速度,缓冲区的数据一下就会被覆盖。 那么在网络恢复时,如果从服务器想读的数据已经被覆盖了,主服务器就会采用全量同步,这个方式比增量同步的性能损耗要大很多。 因此,**为了避免在网络恢复时,主服务器频繁地使用全量同步的方式,我们应该调整下 repl_backlog_buffer 缓冲区大小,尽可能的大一些**,减少出现从服务器要读取的数据被覆盖的概率,从而使得主服务器采用增量同步的方式。 那 repl_backlog_buffer 缓冲区具体要调整到多大呢? repl_backlog_buffer 最小的大小可以根据这面这个公式估算。 ![图片](https://img-blog.csdnimg.cn/img_convert/5e9e65a4a59b3688fa37cadbd87bb5ac.png) 我来解释下这个公式的意思: - second 为从服务器断线后重新连接上主服务器所需的平均 时间 (以秒计算)。 - write_size_per_second 则是主服务器平均每秒产生的写命令数据量大小。 举个例子,如果主服务器平均每秒产生 1 MB 的写命令,而从服务器断线之后平均要 5 秒才能重新连接主服务器。 那么 repl_backlog_buffer 大小就不能低于 5 MB,否则新写地命令就会覆盖旧数据了。 当然,为了应对一些突发的情况,可以将 repl_backlog_buffer 的大小设置为此基础上的 2 倍,也就是 10 MB。 关于 repl_backlog_buffer 大小修改的方法,只需要修改配置文件里下面这个参数项的值就可以。 ```shell repl-backlog-size 1mb ``` ## 总结 主从复制共有三种模式:**全量复制、基于长连接的命令传播、增量复制**。 主从服务器第一次同步的时候,就是采用全量复制,此时主服务器会有两个耗时的地方,分别是生成 RDB 文件和传输 RDB 文件。为了避免过多的从服务器和主服务器进行全量复制,可以把一部分从服务器升级为「经理角色」,让它也有自己的从服务器,通过这样可以分摊主服务器的压力。 第一次同步完成后,主从服务器都会维护着一个长连接,主服务器在接收到写操作命令后,就会通过这个连接将写命令传播给从服务器,来保证主从服务器的数据一致性。 如果遇到网络断开,增量复制就可以上场了,不过这个还跟 repl_backlog_size 这个大小有关系。 如果它配置的过小,主从服务器网络恢复时,可能发生「从服务器」想读的数据已经被覆盖了,那么这时就会导致主服务器采用全量复制的方式。所以为了避免这种情况的频繁发生,要调大这个参数的值,以降低主从服务器断开后全量同步的概率。 ## 面试题 ### Redis 主从节点是长连接还是短链接? 长连接 ### 怎么判断 Redis 某个节点是否正常工作? Redis 判断节点是否正常工作,基本都是通过互相的 ping-pong 心跳检测机制,如果有一半以上的节点去 ping 一个节点的时候没有 pong 回应,集群就会认为这个节点挂掉了,会断开与这个节点的连接。 Redis 主从节点发送的心跳间隔是不一样的,而且作用也有一点区别: - Redis 主节点默认每隔 10 秒对从节点发送 ping 命令,判断从节点的存活性和连接状态,可通过参数 repl-ping-slave-period 控制发送频率。 - Redis 从节点每隔 1 秒发送 replconf ack{offset} 命令,给主节点上报自身当前的复制偏移量,目的是为了: - 实时监测主从节点网络状态; - 上报自身复制偏移量,检查复制数据是否丢失,如果从节点数据丢失,再从主节点的复制缓冲区中拉取丢失数据。 ### 主从复制架构中,过期 key 如何处理? 主节点处理了一个 key 或者通过淘汰算法淘汰了一个 key,这个时间主节点模拟一条 del 命令发送给从节点,从节点收到该命令后,就进行删除 key 的操作。 ### Redis 是同步复制还是异步复制? Redis 主节点每次收到写命令之后,先写到内部的缓冲区,然后异步发送给从节点。 ### 主从复制中两个 Buffer(replication buffer、repl backlog buffer) 有什么区别? replication buffer、repl backlog buffer 区别如下: - 出现的阶段不一样: - repl backlog buffer 是在增量复制阶段出现,**一个主节点只分配一个 repl backlog buffer**; - replication buffer 是在全量复制阶段和增量复制阶段都会出现,**主节点会给每个新连接的从节点,分配一个 replication buffer**; - 这两个 Buffer 都有大小限制的,当缓冲区满了之后,发生的事情不一样: - 当 repl backlog buffer 满了,因为是环形结构,会直接**覆盖起始位置数据**; - 当 replication buffer 满了,会导致连接断开,删除缓存,从节点重新连接,**重新开始全量复制**。 ### 如何应对主从数据不一致? > 为什么会出现主从数据不一致? 主从数据不一致,就是指客户端从从节点中读取到的值和主节点中的最新值并不一致。 之所以会出现主从数据不一致的现象,是**因为主从节点间的命令复制是异步进行的**,所以无法实现强一致性保证(主从数据时时刻刻保持一致)。 具体来说,在主从节点命令传播阶段,主节点收到新的写命令后,会发送给从节点。但是,主节点并不会等到从节点实际执行完命令后,再把结果返回给客户端,而是主节点自己在本地执行完命令后,就会向客户端返回结果了。如果从节点还没有执行主节点同步过来的命令,主从节点间的数据就不一致了。 > 如何如何应对主从数据不一致? 第一种方法,尽量保证主从节点间的网络连接状况良好,避免主从节点在不同的机房。 第二种方法,可以开发一个外部程序来监控主从节点间的复制进度。具体做法: - Redis 的 INFO replication 命令可以查看主节点接收写命令的进度信息(master_repl_offset)和从节点复制写命令的进度信息(slave_repl_offset),所以,我们就可以开发一个监控程序,先用 INFO replication 命令查到主、从节点的进度,然后,我们用 master_repl_offset 减去 slave_repl_offset,这样就能得到从节点和主节点间的复制进度差值了。 - 如果某个从节点的进度差值大于我们预设的阈值,我们可以让客户端不再和这个从节点连接进行数据读取,这样就可以减少读到不一致数据的情况。不过,为了避免出现客户端和所有从节点都不能连接的情况,我们需要把复制进度差值的阈值设置得大一些。 ### 主从切换如何减少数据丢失? 主从切换过程中,产生数据丢失的情况有两种: - 异步复制同步丢失 - 集群产生脑裂数据丢失 我们不可能保证数据完全不丢失,只能做到使得尽量少的数据丢失。 #### 异步复制同步丢失 对于 Redis 主节点与从节点之间的数据复制,是异步复制的,当客户端发送写请求给主节点的时候,客户端会返回 ok,接着主节点将写请求异步同步给各个从节点,但是如果此时主节点还没来得及同步给从节点时发生了断电,那么主节点内存中的数据会丢失。 > 减少异步复制的数据丢失的方案 Redis 配置里有一个参数 min-slaves-max-lag,表示一旦所有的从节点数据复制和同步的延迟都超过了 min-slaves-max-lag 定义的值,那么主节点就会拒绝接收任何请求。 假设将 min-slaves-max-lag 配置为 10s 后,根据目前 master->slave 的复制速度,如果数据同步完成所需要时间超过 10s,就会认为 master 未来宕机后损失的数据会很多,master 就拒绝写入新请求。这样就能将 master 和 slave 数据差控制在 10s 内,即使 master 宕机也只是这未复制的 10s 数据。 那么对于客户端,当客户端发现 master 不可写后,我们可以采取降级措施,将数据暂时写入本地缓存和磁盘中,在一段时间(等 master 恢复正常)后重新写入 master 来保证数据不丢失,也可以将数据写入 kafka 消息队列,等 master 恢复正常,再隔一段时间去消费 kafka 中的数据,让将数据重新写入 master。 #### 集群产生脑裂数据丢失 先来理解集群的脑裂现象,这就好比一个人有两个大脑,那么到底受谁控制呢? 那么在 Redis 中,集群脑裂产生数据丢失的现象是怎样的呢? 在 Redis 主从架构中,部署方式一般是「一主多从」,主节点提供写操作,从节点提供读操作。 如果主节点的网络突然发生了问题,它与所有的从节点都失联了,但是此时的主节点和客户端的网络是正常的,这个客户端并不知道 Redis 内部已经出现了问题,还在照样的向这个失联的主节点写数据(过程 A),此时这些数据被主节点缓存到了缓冲区里,因为主从节点之间的网络问题,这些数据都是无法同步给从节点的。 这时,哨兵也发现主节点失联了,它就认为主节点挂了(但实际上主节点正常运行,只是网络出问题了),于是哨兵就会在从节点中选举出一个 leader 作为主节点,这时集群就有两个主节点了 —— **脑裂出现了**。 这时候网络突然好了,哨兵因为之前已经选举出一个新主节点了,它就会把旧主节点降级为从节点(A),然后从节点(A)会向新主节点请求数据同步,**因为第一次同步是全量同步的方式,此时的从节点(A)会清空掉自己本地的数据,然后再做全量同步。所以,之前客户端在过程 A 写入的数据就会丢失了,也就是集群产生脑裂数据丢失的问题**。 总结一句话就是:由于网络问题,集群节点之间失去联系。主从数据不同步;重新平衡选举,产生两个主服务。等网络恢复,旧主节点会降级为从节点,再与新主节点进行同步复制的时候,由于会从节点会清空自己的缓冲区,所以导致之前客户端写入的数据丢失了。 > 减少脑裂的数据丢的方案 当主节点发现「从节点下线的数量太多」,或者「网络延迟太大」的时候,那么主节点会禁止写操作,直接把错误返回给客户端。 在 Redis 的配置文件中有两个参数我们可以设置: - min-slaves-to-write x,主节点必须要有至少 x 个从节点连接,如果小于这个数,主节点会禁止写数据。 - min-slaves-max-lag x,主从数据复制和同步的延迟不能超过 x 秒,如果超过,主节点会禁止写数据。 我们可以把 min-slaves-to-write 和 min-slaves-max-lag 这两个配置项搭配起来使用,分别给它们设置一定的阈值,假设为 N 和 T。 这两个配置项组合后的要求是,**主节点连接的从节点中至少有 N 个从节点,「并且」主节点进行数据复制时的 ACK 消息延迟不能超过 T 秒**,否则,主节点就不会再接收客户端的写请求了。 即使原主节点是假故障,它在假故障期间也无法响应哨兵心跳,也不能和从节点进行同步,自然也就无法和从节点进行 ACK 确认了。这样一来,min-slaves-to-write 和 min-slaves-max-lag 的组合要求就无法得到满足,**原主节点就会被限制接收客户端写请求,客户端也就不能在原主节点中写入新数据了**。 **等到新主节点上线时,就只有新主节点能接收和处理客户端请求,此时,新写的数据会被直接写到新主节点中。而原主节点会被哨兵降为从节点,即使它的数据被清空了,也不会有新数据丢失。我再来给你举个例子。** 假设我们将 min-slaves-to-write 设置为 1,把 min-slaves-max-lag 设置为 12s,把哨兵的 down-after-milliseconds 设置为 10s,主节点因为某些原因卡住了 15s,导致哨兵判断主节点客观下线,开始进行主从切换。同时,因为原主节点卡住了 15s,没有一个从节点能和原主节点在 12s 内进行数据复制,原主节点也无法接收客户端请求了。这样一来,主从切换完成后,也只有新主节点能接收请求,不会发生脑裂,也就不会发生数据丢失的问题了。 ### 主从如何做到故障自动切换? 主节点挂了,从节点是无法自动升级为主节点的,这个过程需要人工处理,在此期间 Redis 无法对外提供写操作。 此时,Redis 哨兵机制就登场了,哨兵在发现主节点出现故障时,由哨兵自动完成故障发现和故障转移,并通知给应用方,从而实现高可用性。 ---- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: redis/cluster/sentinel.md ================================================ # 为什么要有哨兵? 大家好,我是小林。 这次聊聊,Redis 的哨兵机制。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/哨兵/哨兵提纲.png) ## 为什么要有哨兵机制? 在 Redis 的主从架构中,由于主从模式是读写分离的,如果主节点(master)挂了,那么将没有主节点来服务客户端的写操作请求,也没有主节点给从节点(slave)进行数据同步了。 ![主节点挂了](https://img-blog.csdnimg.cn/db568766644a4d10b8a91cdd2f8a4070.png) 这时如果要恢复服务的话,需要人工介入,选择一个「从节点」切换为「主节点」,然后让其他从节点指向新的主节点,同时还需要通知上游那些连接 Redis 主节点的客户端,将其配置中的主节点 IP 地址更新为「新主节点」的 IP 地址。 这样也不太“智能”了,要是有一个节点能监控「主节点」的状态,当发现主节点挂了,它自动将一个「从节点」切换为「主节点」的话,那么可以节省我们很多事情啊! Redis 在 2.8 版本以后提供的**哨兵(*Sentinel*)机制**,它的作用是实现**主从节点故障转移**。它会监测主节点是否存活,如果发现主节点挂了,它就会选举一个从节点切换为主节点,并且把新主节点的相关信息通知给从节点和客户端。 ## 哨兵机制是如何工作的? 哨兵其实是一个运行在特殊模式下的 Redis 进程,所以它也是一个节点。从“哨兵”这个名字也可以看得出来,它相当于是“观察者节点”,观察的对象是主从节点。 当然,它不仅仅是观察那么简单,在它观察到有异常的状况下,会做出一些“动作”,来修复异常状态。 哨兵节点主要负责三件事情:**监控、选主、通知**。 ![哨兵的职责](https://img-blog.csdnimg.cn/775865f6bd894dfba8d373ee54d79af1.png) 所以,我们重点要学习这三件事情: - 哨兵节点是如何监控节点的?又是如何判断主节点是否真的故障了? - 根据什么规则选择一个从节点切换为主节点? - 怎么把新主节点的相关信息通知给从节点和客户端呢? ## 如何判断主节点真的故障了? 哨兵会每隔 1 秒给所有主从节点发送 PING 命令,当主从节点收到 PING 命令后,会发送一个响应命令给哨兵,这样就可以判断它们是否在正常运行。 ![哨兵监控主从节点](https://img-blog.csdnimg.cn/26f88373d8454682b9e0c1d4fd1611b4.png) 如果主节点或者从节点没有在规定的时间内响应哨兵的 PING 命令,哨兵就会将它们标记为「**主观下线**」。这个「规定的时间」是配置项 `down-after-milliseconds` 参数设定的,单位是毫秒。 > 主观下线?难道还有客观下线? 是的没错,客观下线只适用于主节点。 之所以针对「主节点」设计「主观下线」和「客观下线」两个状态,是因为有可能「主节点」其实并没有故障,可能只是因为主节点的系统压力比较大或者网络发送了拥塞,导致主节点没有在规定时间内响应哨兵的 PING 命令。 所以,为了减少误判的情况,哨兵在部署的时候不会只部署一个节点,而是用多个节点部署成**哨兵集群**(*最少需要三台机器来部署哨兵集群*),**通过多个哨兵节点一起判断,就可以就可以避免单个哨兵因为自身网络状况不好,而误判主节点下线的情况**。同时,多个哨兵的网络同时不稳定的概率较小,由它们一起做决策,误判率也能降低。 具体是怎么判定主节点为「客观下线」的呢? 当一个哨兵判断主节点为「主观下线」后,就会向其他哨兵发起命令,其他哨兵收到这个命令后,就会根据自身和主节点的网络状况,做出赞成投票或者拒绝投票的响应。 ![](https://img-blog.csdnimg.cn/13e4361407ba46979e802eaa654dcf67.png) 当这个哨兵的赞同票数达到哨兵配置文件中的 quorum 配置项设定的值后,这时主节点就会被该哨兵标记为「客观下线」。 例如,现在有 3 个哨兵,quorum 配置的是 2,那么一个哨兵需要 2 张赞成票,就可以标记主节点为“客观下线”了。这 2 张赞成票包括哨兵自己的一张赞成票和另外两个哨兵的赞成票。 PS:quorum 的值一般设置为哨兵个数的二分之一加 1,例如 3 个哨兵就设置 2。 哨兵判断完主节点客观下线后,哨兵就要开始在多个「从节点」中,选出一个从节点来做新主节点。 ## 由哪个哨兵进行主从故障转移? 前面说过,为了更加“客观”的判断主节点故障了,一般不会只由单个哨兵的检测结果来判断,而是多个哨兵一起判断,这样可以减少误判概率,所以**哨兵是以哨兵集群的方式存在的**。 问题来了,由哨兵集群中的哪个节点进行主从故障转移呢? 所以这时候,还需要在哨兵集群中选出一个 leader,让 leader 来执行主从切换。 选举 leader 的过程其实是一个投票的过程,在投票开始前,肯定得有个「候选者」。 > 那谁来作为候选者呢? 哪个哨兵节点判断主节点为「客观下线」,这个哨兵节点就是候选者,所谓的候选者就是想当 Leader 的哨兵。 举个例子,假设有三个哨兵。当哨兵 B 先判断到主节点「主观下线后」,就会给其他实例发送 is-master-down-by-addr 命令。接着,其他哨兵会根据自己和主节点的网络连接情况,做出赞成投票或者拒绝投票的响应。 ![](https://img-blog.csdnimg.cn/d0bed80d28a543fd8dcd299d4b06cf04.png) 当哨兵 B 收到赞成票数达到哨兵配置文件中的 quorum 配置项设定的值后,就会将主节点标记为「客观下线」,此时的哨兵 B 就是一个 Leader 候选者。 > 候选者如何选举成为 Leader? 候选者会向其他哨兵发送命令,表明希望成为 Leader 来执行主从切换,并让所有其他哨兵对它进行投票。 每个哨兵只有一次投票机会,如果用完后就不能参与投票了,可以投给自己或投给别人,但是只有候选者才能把票投给自己。 那么在投票过程中,任何一个「候选者」,要满足两个条件: - 第一,拿到半数以上的赞成票; - 第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。 举个例子,假设哨兵节点有 3 个,quorum 设置为 2,那么任何一个想成为 Leader 的哨兵只要拿到 2 张赞成票,就可以选举成功了。如果没有满足条件,就需要重新进行选举。 这时候有的同学就会问了,如果某个时间点,刚好有两个哨兵节点判断到主节点为客观下线,那这时不就有两个候选者了?这时该如何决定谁是 Leader 呢? 每位候选者都会先给自己投一票,然后向其他哨兵发起投票请求。如果投票者先收到「候选者 A」的投票请求,就会先投票给它,如果投票者用完投票机会后,收到「候选者 B」的投票请求后,就会拒绝投票。这时,候选者 A 先满足了上面的那两个条件,所以「候选者 A」就会被选举为 Leader。 > 为什么哨兵节点至少要有 3 个? 如果哨兵集群中只有 2 个哨兵节点,此时如果一个哨兵想要成功成为 Leader,必须获得 2 票,而不是 1 票。 所以,如果哨兵集群中有个哨兵挂掉了,那么就只剩一个哨兵了,如果这个哨兵想要成为 Leader,这时票数就没办法达到 2 票,就无法成功成为 Leader,这时是无法进行主从节点切换的。 因此,通常我们至少会配置 3 个哨兵节点。这时,如果哨兵集群中有个哨兵挂掉了,那么还剩下两个哨兵,如果这个哨兵想要成为 Leader,这时还是有机会达到 2 票的,所以还是可以选举成功的,不会导致无法进行主从节点切换。 当然,你要问,如果 3 个哨兵节点,挂了 2 个怎么办?这个时候得人为介入了,或者增加多一点哨兵节点。 再说一个问题,Redis 1 主 4 从,5 个哨兵,quorum 设置为 3,如果 2 个哨兵故障,当主节点宕机时,哨兵能否判断主节点“客观下线”?主从能否自动切换? - **哨兵集群可以判定主节点“客观下线”**。哨兵集群还剩下 3 个哨兵,当一个哨兵判断主节点“主观下线”后,询问另外 2 个哨兵后,有可能能拿到 3 张赞同票,这时就达到了 quorum 的值,因此,哨兵集群可以判定主节点为“客观下线”。 - **哨兵集群可以完成主从切换**。当有个哨兵标记主节点为「客观下线」后,就会进行选举 Leader 的过程,因为此时哨兵集群还剩下 3 个哨兵,那么还是可以拿到半数以上(5/2+1=3)的票,而且也达到了 quorum 值,满足了选举 Leader 的两个条件,所以就能选举成功,因此哨兵集群可以完成主从切换。 如果 quorum 设置为 2,并且如果有 3 个哨兵故障的话。此时哨兵集群还是可以判定主节点为“客观下线”,但是哨兵不能完成主从切换了,大家可以自己推演下。 如果 quorum 设置为 3,并且如果有 3 个哨兵故障的话,哨兵集群即不能判定主节点为“客观下线”,也不能完成主从切换了。 可以看到,quorum 为 2 的时候,并且如果有 3 个哨兵故障的话,虽然可以判定主节点为“客观下线”,但是不能完成主从切换,这样感觉「判定主节点为客观下线」这件事情白做了一样,既然这样,还不如不要做,quorum 为 3 的时候,就可以避免这种无用功。 所以,**quorum 的值建议设置为哨兵个数的二分之一加 1**,例如 3 个哨兵就设置 2,5 个哨兵设置为 3,而且**哨兵节点的数量应该是奇数**。 ## 主从故障转移的过程是怎样的? 在哨兵集群中通过投票的方式,选举出了哨兵 leader 后,就可以进行主从故障转移的过程了,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/哨兵/主从故障转移.png) 主从故障转移操作包含以下四个步骤: - 第一步:在已下线主节点(旧主节点)属下的所有「从节点」里面,挑选出一个从节点,并将其转换为主节点。 - 第二步:让已下线主节点属下的所有「从节点」修改复制目标,修改为复制「新主节点」; - 第三步:将新主节点的 IP 地址和信息,通过「发布者/订阅者机制」通知给客户端; - 第四步:继续监视旧主节点,当这个旧主节点重新上线时,将它设置为新主节点的从节点; ### 步骤一:选出新主节点 故障转移操作第一步要做的就是在已下线主节点属下的所有「从节点」中,挑选出一个状态良好、数据完整的从节点,然后向这个「从节点」发送 SLAVEOF no one 命令,将这个「从节点」转换为「主节点」。 那么多「从节点」,到底选择哪个从节点作为新主节点的? 随机的方式好吗?随机的方式,实现起来很简单,但是如果选到一个网络状态不好的从节点作为新主节点,那么可能在将来不久又要做一次主从故障迁移。 所以,我们首先要把网络状态不好的从节点给过滤掉。首先把已经下线的从节点过滤掉,然后把以往网络连接状态不好的从节点也给过滤掉。 怎么判断从节点之前的网络连接状态不好呢? Redis 有个叫 down-after-milliseconds * 10 配置项,其 down-after-milliseconds 是主从节点断连的最大连接超时时间。如果在 down-after-milliseconds 毫秒内,主从节点都没有通过网络联系上,我们就可以认为主从节点断连了。如果发生断连的次数超过了 10 次,就说明这个从节点的网络状况不好,不适合作为新主节点。 至此,我们就把网络状态不好的从节点过滤掉了,接下来要对所有从节点进行三轮考察:**优先级、复制进度、ID 号**。在进行每一轮考察的时候,哪个从节点优先胜出,就选择其作为新主节点。 - 第一轮考察:哨兵首先会根据从节点的优先级来进行排序,优先级越小排名越靠前, - 第二轮考察:如果优先级相同,则查看复制的下标,哪个从「主节点」接收的复制数据多,哪个就靠前。 - 第三轮考察:如果优先级和下标都相同,就选择从节点 ID 较小的那个。 #### 第一轮考察:优先级最高的从节点胜出 Redis 有个叫 slave-priority 配置项,可以给从节点设置优先级。 每一台从节点的服务器配置不一定是相同的,我们可以根据服务器性能配置来设置从节点的优先级。 比如,如果「A 从节点」的物理内存是所有从节点中最大的,那么我们可以把「A 从节点」的优先级设置成最高。这样当哨兵进行第一轮考虑的时候,优先级最高的 A 从节点就会优先胜出,于是就会成为新主节点。 #### 第二轮考察:复制进度最靠前的从节点胜出 如果在第一轮考察中,发现优先级最高的从节点有两个,那么就会进行第二轮考察,比较两个从节点哪个复制进度。 什么是复制进度?主从架构中,主节点会将写操作同步给从节点,在这个过程中,主节点会用 master_repl_offset 记录当前的最新写操作在 repl_backlog_buffer 中的位置(如下图中的「主服务器已经写入的数据」的位置),而从节点会用 slave_repl_offset 这个值记录当前的复制进度(如下图中的「从服务器要读的位置」的位置)。 ![](https://img-blog.csdnimg.cn/img_convert/2db4831516b9a8b79f833cf0593c1f12.png) 如果某个从节点的 slave_repl_offset 最接近 master_repl_offset,说明它的复制进度是最靠前的,于是就可以将它选为新主节点。 #### 第三轮考察:ID 号小的从节点胜出 如果在第二轮考察中,发现有两个从节点优先级和复制进度都是一样的,那么就会进行第三轮考察,比较两个从节点的 ID 号,ID 号小的从节点胜出。 什么是 ID 号?每个从节点都有一个编号,这个编号就是 ID 号,是用来唯一标识从节点的。 到这里,选主的事情终于结束了。简单给大家总结下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/哨兵/选主过程.webp) 在选举出从节点后,哨兵 leader 向被选中的从节点发送 `SLAVEOF no one` 命令,让这个从节点解除从节点的身份,将其变为新主节点。 如下图,哨兵 leader 向被选中的从节点 server2 发送 `SLAVEOF no one` 命令,将该从节点升级为新主节点。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/哨兵/升级从节点为主节点.png) 在发送 `SLAVEOF no one` 命令之后,哨兵 leader 会以每秒一次的频率向被升级的从节点发送 `INFO` 命令(没进行故障转移之前,`INFO` 命令的频率是每十秒一次),并观察命令回复中的角色信息,当被升级节点的角色信息从原来的 slave 变为 master 时,哨兵 leader 就知道被选中的从节点已经顺利升级为主节点了。 如下图,选中的从节点 server2 升级成了新主节点: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/哨兵/升主节点成功.png) ### 步骤二:将从节点指向新主节点 当新主节点出现之后,哨兵 leader 下一步要做的就是,让已下线主节点属下的所有「从节点」指向「新主节点」,这一动作可以通过向「从节点」发送 `SLAVEOF` 命令来实现。 如下图,哨兵 leader 向所有从节点(server3 和 server4)发送 `SLAVEOF` ,让它们成为新主节点的从节点。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/哨兵/从节点指向新主节点.png) 所有从节点指向新主节点后的拓扑图如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/哨兵/从节点转换成功.png) ### 步骤三:通知客户端主节点已更换 经过前面一系列的操作后,哨兵集群终于完成主从切换的工作,那么新主节点的信息要如何通知给客户端呢? 这主要**通过 Redis 的发布者/订阅者机制来实现**的。每个哨兵节点提供发布者/订阅者机制,客户端可以从哨兵订阅消息。 哨兵提供的消息订阅频道有很多,不同频道包含了主从节点切换过程中的不同关键事件,几个常见的事件如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/哨兵/哨兵频道.webp) 客户端和哨兵建立连接后,客户端会订阅哨兵提供的频道。**主从切换完成后,哨兵就会向 `+switch-master` 频道发布新主节点的 IP 地址和端口的消息,这个时候客户端就可以收到这条信息,然后用这里面的新主节点的 IP 地址和端口进行通信了**。 通过发布者/订阅者机制机制,有了这些事件通知,客户端不仅可以在主从切换后得到新主节点的连接信息,还可以监控到主从节点切换过程中发生的各个重要事件。这样,客户端就可以知道主从切换进行到哪一步了,有助于了解切换进度。 ### 步骤四:将旧主节点变为从节点 故障转移操作最后要做的是,继续监视旧主节点,当旧主节点重新上线时,哨兵集群就会向它发送 `SLAVEOF` 命令,让它成为新主节点的从节点,如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/哨兵/旧主节点变为新主节点.png) 至此,整个主从节点的故障转移的工作结束。 ## 哨兵集群是如何组成的? 前面提到了 Redis 的发布者/订阅者机制,那就不得不提一下哨兵集群的组成方式,因为它也用到了这个技术。 在我第一次搭建哨兵集群的时候,当时觉得很诧异。因为在配置哨兵的信息时,竟然只需要填下面这几个参数,设置主节点名字、主节点的 IP 地址和端口号以及 quorum 值。 ```c sentinel monitor ``` 不需要填其他哨兵节点的信息,我就好奇它们是如何感知对方的,又是如何组成哨兵集群的? 后面才了解到,**哨兵节点之间是通过 Redis 的发布者/订阅者机制来相互发现的**。 在主从集群中,主节点上有一个名为`__sentinel__:hello`的频道,不同哨兵就是通过它来相互发现,实现互相通信的。 在下图中,哨兵 A 把自己的 IP 地址和端口的信息发布到`__sentinel__:hello` 频道上,哨兵 B 和 C 订阅了该频道。那么此时,哨兵 B 和 C 就可以从这个频道直接获取哨兵 A 的 IP 地址和端口号。然后,哨兵 B、C 可以和哨兵 A 建立网络连接。 ![](https://img-blog.csdnimg.cn/a6286053c6884cf58bf397d01674fe80.png) 通过这个方式,哨兵 B 和 C 也可以建立网络连接,这样一来,哨兵集群就形成了。 > 哨兵集群会对「从节点」的运行状态进行监控,那哨兵集群如何知道「从节点」的信息? 主节点知道所有「从节点」的信息,所以哨兵会每 10 秒一次的频率向主节点发送 INFO 命令来获取所有「从节点」的信息。 如下图所示,哨兵 B 给主节点发送 INFO 命令,主节点接受到这个命令后,就会把从节点列表返回给哨兵。接着,哨兵就可以根据从节点列表中的连接信息,和每个从节点建立连接,并在这个连接上持续地对从节点进行监控。哨兵 A 和 C 可以通过相同的方法和从节点建立连接。 ![](https://img-blog.csdnimg.cn/fdd5f695bb3643258662886f9fba0aab.png) 正是通过 Redis 的发布者/订阅者机制,哨兵之间可以相互感知,然后组成集群,同时,哨兵又通过 INFO 命令,在主节点里获得了所有从节点连接信息,于是就能和从节点建立连接,并进行监控了。 ## 总结 Redis 在 2.8 版本以后提供的**哨兵(*Sentinel*)机制**,它的作用是实现**主从节点故障转移**。它会监测主节点是否存活,如果发现主节点挂了,它就会选举一个从节点切换为主节点,并且把新主节点的相关信息通知给从节点和客户端。 哨兵一般是以集群的方式部署,至少需要 3 个哨兵节点,哨兵集群主要负责三件事情:**监控、选主、通知**。 哨兵节点通过 Redis 的发布者/订阅者机制,哨兵之间可以相互感知,相互连接,然后组成哨兵集群,同时哨兵又通过 INFO 命令,在主节点里获得了所有从节点连接信息,于是就能和从节点建立连接,并进行监控了。 *1、第一轮投票:判断主节点下线* 当哨兵集群中的某个哨兵判定主节点下线(主观下线)后,就会向其他哨兵发起命令,其他哨兵收到这个命令后,就会根据自身和主节点的网络状况,做出赞成投票或者拒绝投票的响应。 当这个哨兵的赞同票数达到哨兵配置文件中的 quorum 配置项设定的值后,这时主节点就会被该哨兵标记为「客观下线」。 *2、第二轮投票:选出哨兵 leader* 某个哨兵判定主节点客观下线后,该哨兵就会发起投票,告诉其他哨兵,它想成为 leader,想成为 leader 的哨兵节点,要满足两个条件: - 第一,拿到半数以上的赞成票; - 第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。 *3、由哨兵 leader 进行主从故障转移* 选举出了哨兵 leader 后,就可以进行主从故障转移的过程了。该操作包含以下四个步骤: - 第一步:在已下线主节点(旧主节点)属下的所有「从节点」里面,挑选出一个从节点,并将其转换为主节点,选择的规则: - 过滤掉已经离线的从节点; - 过滤掉历史网络连接状态不好的从节点; - 将剩下的从节点,进行三轮考察:优先级、复制进度、ID 号。在每一轮考察过程中,如果找到了一个胜出的从节点,就将其作为新主节点。 - 第二步:让已下线主节点属下的所有「从节点」修改复制目标,修改为复制「新主节点」; - 第三步:将新主节点的 IP 地址和信息,通过「发布者/订阅者机制」通知给客户端; - 第四步:继续监视旧主节点,当这个旧主节点重新上线时,将它设置为新主节点的从节点; 完! 参考资料: - 《Redis 核心技术与实战》 - 《Redis 设计与实现》 --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: redis/data_struct/command.md ================================================ # Redis 常见数据类型和应用场景 大家好,我是小林。 我们都知道 Redis 提供了丰富的数据类型,常见的有五种:**String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)**。 随着 Redis 版本的更新,后面又支持了四种数据类型: **BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增)**。 每种数据对象都有各自的应用场景,你能说出它们各自的应用场景吗? 面试过程中,这个问题也很常被问到,又比如会举例一个应用场景来问你,让你说使用哪种 Redis 数据类型来实现。 所以,这次我们就来学习 **Redis 数据类型的使用以及应用场景**。 > PS:你可以自己本机安装 Redis 或者通过 Redis 官网提供的[在线 Redis 环境](https://try.redis.io/) 来敲命令。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/redis命令提纲.png) ## String ### 介绍 String 是最基本的 key-value 结构,key 是唯一标识,value 是具体的值,value 其实不仅是字符串,也可以是数字(整数或浮点数),value 最多可以容纳的数据长度是 `512M`。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/string.png) ### 内部实现 String 类型的底层的数据结构实现主要是 int 和 SDS(简单动态字符串)。 SDS 和我们认识的 C 字符串不太一样,之所以没有使用 C 语言的字符串表示,因为 SDS 相比于 C 的原生字符串: - **SDS 不仅可以保存文本数据,还可以保存二进制数据**。因为 `SDS` 使用 `len` 属性的值而不是空字符来判断字符串是否结束,并且 SDS 的所有 API 都会以处理二进制的方式来处理 SDS 存放在 `buf[]` 数组里的数据。所以 SDS 不光能存放文本数据,而且能保存图片、音频、视频、压缩文件这样的二进制数据。 - **SDS 获取字符串长度的时间复杂度是 O(1)**。因为 C 语言的字符串并不记录自身长度,所以获取长度的复杂度为 O(n);而 SDS 结构里用 `len` 属性记录了字符串长度,所以复杂度为 `O(1)`。 - **Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出**。因为 SDS 在拼接字符串之前会检查 SDS 空间是否满足要求,如果空间不够会自动扩容,所以不会导致缓冲区溢出的问题。 字符串对象的内部编码(encoding)有 3 种:**int、raw 和 embstr**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/string结构.png) 如果一个字符串对象保存的是整数值,并且这个整数值可以用`long`类型来表示,那么字符串对象会将整数值保存在字符串对象结构的`ptr`属性里面(将`void*`转换成 long),并将字符串对象的编码设置为`int`。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/int.png) 如果字符串对象保存的是一个字符串,并且这个字符串的长度小于等于 32 字节(redis 2.+版本),那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串,并将对象的编码设置为`embstr`, `embstr`编码是专门用于保存短字符串的一种优化编码方式: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/embstr.png) 如果字符串对象保存的是一个字符串,并且这个字符串的长度大于 32 字节(redis 2.+版本),那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串,并将对象的编码设置为`raw`: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/raw.png) 注意,embstr 编码和 raw 编码的边界在 redis 不同版本中是不一样的: - redis 2.+ 是 32 字节 - redis 3.0-4.0 是 39 字节 - redis 5.0 是 44 字节 可以看到`embstr`和`raw`编码都会使用`SDS`来保存值,但不同之处在于`embstr`会通过一次内存分配函数来分配一块连续的内存空间来保存`redisObject`和`SDS`,而`raw`编码会通过调用两次内存分配函数来分别分配两块空间来保存`redisObject`和`SDS`。Redis 这样做会有很多好处: - `embstr`编码将创建字符串对象所需的内存分配次数从 `raw` 编码的两次降低为一次; - 释放 `embstr`编码的字符串对象同样只需要调用一次内存释放函数; - 因为`embstr`编码的字符串对象的所有数据都保存在一块连续的内存里面可以更好的利用 CPU 缓存提升性能。 但是 embstr 也有缺点的: - 如果字符串的长度增加需要重新分配内存时,整个 redisObject 和 sds 都需要重新分配空间,所以**embstr 编码的字符串对象实际上是只读的**,redis 没有为 embstr 编码的字符串对象编写任何相应的修改程序。当我们对 embstr 编码的字符串对象执行任何修改命令(例如 append)时,程序会先将对象的编码从 embstr 转换成 raw,然后再执行修改命令。 ### 常用指令 普通字符串的基本操作: ```shell # 设置 key-value 类型的值 > SET name lin OK # 根据 key 获得对应的 value > GET name "lin" # 判断某个 key 是否存在 > EXISTS name (integer) 1 # 返回 key 所储存的字符串值的长度 > STRLEN name (integer) 3 # 删除某个 key 对应的值 > DEL name (integer) 1 ``` 批量设置 : ```shell # 批量设置 key-value 类型的值 > MSET key1 value1 key2 value2 OK # 批量获取多个 key 对应的 value > MGET key1 key2 1) "value1" 2) "value2" ``` 计数器(字符串的内容为整数的时候可以使用): ```shell # 设置 key-value 类型的值 > SET number 0 OK # 将 key 中储存的数字值增一 > INCR number (integer) 1 # 将key中存储的数字值加 10 > INCRBY number 10 (integer) 11 # 将 key 中储存的数字值减一 > DECR number (integer) 10 # 将key中存储的数字值键 10 > DECRBY number 10 (integer) 0 ``` 过期(默认为永不过期): ```bash # 设置 key 在 60 秒后过期(该方法是针对已经存在的key设置过期时间) > EXPIRE name 60 (integer) 1 # 查看数据还有多久过期 > TTL name (integer) 51 #设置 key-value 类型的值,并设置该key的过期时间为 60 秒 > SET key value EX 60 OK > SETEX key 60 value OK ``` 不存在就插入: ```shell # 不存在就插入(not exists) >SETNX key value (integer) 1 ``` ### 应用场景 #### 缓存对象 使用 String 来缓存对象有两种方式: - 直接缓存整个对象的 JSON,命令例子: `SET user:1 '{"name":"xiaolin", "age":18}'`。 - 采用将 key 进行分离为 user:ID:属性,采用 MSET 存储,用 MGET 获取各属性值,命令例子: `MSET user:1:name xiaolin user:1:age 18 user:2:name xiaomei user:2:age 20`。 #### 常规计数 因为 Redis 处理命令是单线程,所以执行命令的过程是原子的。因此 String 数据类型适合计数场景,比如计算访问次数、点赞、转发、库存数量等等。 比如计算文章的阅读量: ```shell # 初始化文章的阅读量 > SET aritcle:readcount:1001 0 OK #阅读量+1 > INCR aritcle:readcount:1001 (integer) 1 #阅读量+1 > INCR aritcle:readcount:1001 (integer) 2 #阅读量+1 > INCR aritcle:readcount:1001 (integer) 3 # 获取对应文章的阅读量 > GET aritcle:readcount:1001 "3" ``` #### 分布式锁 SET 命令有个 NX 参数可以实现「key 不存在才插入」,可以用它来实现分布式锁: - 如果 key 不存在,则显示插入成功,可以用来表示加锁成功; - 如果 key 存在,则会显示插入失败,可以用来表示加锁失败。 一般而言,还会对分布式锁加上过期时间,分布式锁的命令如下: ```shell SET lock_key unique_value NX PX 10000 ``` - lock_key 就是 key 键; - unique_value 是客户端生成的唯一的标识; - NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作; - PX 10000 表示设置 lock_key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁。 而解锁的过程就是将 lock_key 键删除,但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。 可以看到,解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。 ```Lua // 释放锁时,先比较 unique_value 是否相等,避免锁的误释放 if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end ``` 这样一来,就通过使用 SET 命令和 Lua 脚本在 Redis 单节点上完成了分布式锁的加锁和解锁。 #### 共享 Session 信息 通常我们在开发后台管理系统时,会使用 Session 来保存用户的会话 (登录) 状态,这些 Session 信息会被保存在服务器端,但这只适用于单系统应用,如果是分布式系统此模式将不再适用。 例如用户一的 Session 信息被存储在服务器一,但第二次访问时用户一被分配到服务器二,这个时候服务器并没有用户一的 Session 信息,就会出现需要重复登录的问题,问题在于分布式系统每次会把请求随机分配到不同的服务器。 分布式系统单独存储 Session 流程图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/Session1.png) 因此,我们需要借助 Redis 对这些 Session 信息进行统一的存储和管理,这样无论请求发送到那台服务器,服务器都会去同一个 Redis 获取相关的 Session 信息,这样就解决了分布式系统下 Session 存储的问题。 分布式系统使用同一个 Redis 存储 Session 流程图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/Session2.png) ## List ### 介绍 List 列表是简单的字符串列表,**按照插入顺序排序**,可以从头部或尾部向 List 列表添加元素。 列表的最大长度为 `2^32 - 1`,也即每个列表支持超过 `40 亿`个元素。 ### 内部实现 List 类型的底层数据结构是由**双向链表或压缩列表**实现的: - 如果列表的元素个数小于 `512` 个(默认值,可由 `list-max-ziplist-entries` 配置),列表每个元素的值都小于 `64` 字节(默认值,可由 `list-max-ziplist-value` 配置),Redis 会使用**压缩列表**作为 List 类型的底层数据结构; - 如果列表的元素不满足上面的条件,Redis 会使用**双向链表**作为 List 类型的底层数据结构; 但是**在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表**。 ### 常用命令 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/list.png) ```shell # 将一个或多个值value插入到key列表的表头(最左边),最后的值在最前面 LPUSH key value [value ...] # 将一个或多个值value插入到key列表的表尾(最右边) RPUSH key value [value ...] # 移除并返回key列表的头元素 LPOP key # 移除并返回key列表的尾元素 RPOP key # 返回列表key中指定区间内的元素,区间以偏移量start和stop指定,从0开始 LRANGE key start stop # 从key列表表头弹出一个元素,没有就阻塞timeout秒,如果timeout=0则一直阻塞 BLPOP key [key ...] timeout # 从key列表表尾弹出一个元素,没有就阻塞timeout秒,如果timeout=0则一直阻塞 BRPOP key [key ...] timeout ``` ### 应用场景 #### 消息队列 消息队列在存取消息时,必须要满足三个需求,分别是**消息保序、处理重复的消息和保证消息可靠性**。 Redis 的 List 和 Stream 两种数据类型,就可以满足消息队列的这三个需求。我们先来了解下基于 List 的消息队列实现方法,后面在介绍 Stream 数据类型时候,在详细说说 Stream。 *1、如何满足消息保序需求?* List 本身就是按先进先出的顺序对数据进行存取的,所以,如果使用 List 作为消息队列保存消息的话,就已经能满足消息保序的需求了。 List 可以使用 LPUSH + RPOP(或者反过来,RPUSH+LPOP)命令实现消息队列。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/list消息队列.png) - 生产者使用 `LPUSH key value[value...]` 将消息插入到队列的头部,如果 key 不存在则会创建一个空的队列再插入消息。 - 消费者使用 `RPOP key` 依次读取队列的消息,先进先出。 不过,在消费者读取数据时,有一个潜在的性能风险点。 在生产者往 List 中写入数据时,List 并不会主动地通知消费者有新消息写入,如果消费者想要及时处理消息,就需要在程序中不停地调用 `RPOP` 命令(比如使用一个 while(1) 循环)。如果有新消息写入,RPOP 命令就会返回结果,否则,RPOP 命令返回空值,再继续循环。 所以,即使没有新消息写入 List,消费者也要不停地调用 RPOP 命令,这就会导致消费者程序的 CPU 一直消耗在执行 RPOP 命令上,带来不必要的性能损失。 为了解决这个问题,Redis 提供了 BRPOP 命令。**BRPOP 命令也称为阻塞式读取,客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据**。和消费者程序自己不停地调用 RPOP 命令相比,这种方式能节省 CPU 开销。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/消息队列.png) *2、如何处理重复的消息?* 消费者要实现重复消息的判断,需要 2 个方面的要求: - 每个消息都有一个全局的 ID。 - 消费者要记录已经处理过的消息的 ID。当收到一条消息后,消费者程序就可以对比收到的消息 ID 和记录的已处理过的消息 ID,来判断当前收到的消息有没有经过处理。如果已经处理过,那么,消费者程序就不再进行处理了。 但是 **List 并不会为每个消息生成 ID 号,所以我们需要自行为每个消息生成一个全局唯一 ID**,生成之后,我们在用 LPUSH 命令把消息插入 List 时,需要在消息中包含这个全局唯一 ID。 例如,我们执行以下命令,就把一条全局 ID 为 111000102、库存量为 99 的消息插入了消息队列: ```shell > LPUSH mq "111000102:stock:99" (integer) 1 ``` *3、如何保证消息可靠性?* 当消费者程序从 List 中读取一条消息后,List 就不会再留存这条消息了。所以,如果消费者程序在处理消息的过程出现了故障或宕机,就会导致消息没有处理完成,那么,消费者程序再次启动后,就没法再次从 List 中读取消息了。 为了留存消息,List 类型提供了 `BRPOPLPUSH` 命令,这个命令的**作用是让消费者程序从一个 List 中读取消息,同时,Redis 会把这个消息再插入到另一个 List(可以叫作备份 List)留存**。 这样一来,如果消费者程序读了消息但没能正常处理,等它重启后,就可以从备份 List 中重新读取消息并进行处理了。 好了,到这里可以知道基于 List 类型的消息队列,满足消息队列的三大需求(消息保序、处理重复的消息和保证消息可靠性)。 - 消息保序:使用 LPUSH + RPOP; - 阻塞读取:使用 BRPOP; - 重复消息处理:生产者自行实现全局唯一 ID; - 消息的可靠性:使用 BRPOPLPUSH > List 作为消息队列有什么缺陷? **List 不支持多个消费者消费同一条消息**,因为一旦消费者拉取一条消息后,这条消息就从 List 中删除了,无法被其它消费者再次消费。 要实现一条消息可以被多个消费者消费,那么就要将多个消费者组成一个消费组,使得多个消费者可以消费同一条消息,但是 **List 类型并不支持消费组的实现**。 这就要说起 Redis 从 5.0 版本开始提供的 Stream 数据类型了,Stream 同样能够满足消息队列的三大需求,而且它还支持「消费组」形式的消息读取。 ## Hash ### 介绍 Hash 是一个键值对(key - value)集合,其中 value 的形式如: `value=[{field1,value1},...{fieldN,valueN}]`。Hash 特别适合用于存储对象。 Hash 与 String 对象的区别如下图所示: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/hash.png) ### 内部实现 Hash 类型的底层数据结构是由**压缩列表或哈希表**实现的: - 如果哈希类型元素个数小于 `512` 个(默认值,可由 `hash-max-ziplist-entries` 配置),所有值小于 `64` 字节(默认值,可由 `hash-max-ziplist-value` 配置)的话,Redis 会使用**压缩列表**作为 Hash 类型的底层数据结构; - 如果哈希类型元素不满足上面条件,Redis 会使用**哈希表**作为 Hash 类型的 底层数据结构。 **在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了**。 ### 常用命令 ```shell # 存储一个哈希表key的键值 HSET key field value # 获取哈希表key对应的field键值 HGET key field # 在一个哈希表key中存储多个键值对 HMSET key field value [field value...] # 批量获取哈希表key中多个field键值 HMGET key field [field ...] # 删除哈希表key中的field键值 HDEL key field [field ...] # 返回哈希表key中field的数量 HLEN key # 返回哈希表key中所有的键值 HGETALL key # 为哈希表key中field键的值加上增量n HINCRBY key field n ``` ### 应用场景 #### 缓存对象 Hash 类型的(key,field,value)的结构与对象的(对象 id,属性,值)的结构相似,也可以用来存储对象。 我们以用户信息为例,它在关系型数据库中的结构是这样的: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/用户信息.png) 我们可以使用如下命令,将用户对象的信息存储到 Hash 类型: ```shell # 存储一个哈希表uid:1的键值 > HMSET uid:1 name Tom age 15 2 # 存储一个哈希表uid:2的键值 > HMSET uid:2 name Jerry age 13 2 # 获取哈希表用户id为1中所有的键值 > HGETALL uid:1 1) "name" 2) "Tom" 3) "age" 4) "15" ``` Redis Hash 存储其结构如下图: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/hash存储结构.png) 在介绍 String 类型的应用场景时有所介绍,String + Json 也是存储对象的一种方式,那么存储对象时,到底用 String + json 还是用 Hash 呢? 一般对象用 String + Json 存储,对象中某些频繁变化的属性可以考虑抽出来用 Hash 类型存储。 #### 购物车 以用户 id 为 key,商品 id 为 field,商品数量为 value,恰好构成了购物车的 3 个要素,如下图所示。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/购物车.png) 涉及的命令如下: - 添加商品:`HSET cart:{用户id} {商品id} 1` - 添加数量:`HINCRBY cart:{用户id} {商品id} 1` - 商品总数:`HLEN cart:{用户id}` - 删除商品:`HDEL cart:{用户id} {商品id}` - 获取购物车所有商品:`HGETALL cart:{用户id}` 当前仅仅是将商品 ID 存储到了 Redis 中,在回显商品具体信息的时候,还需要拿着商品 id 查询一次数据库,获取完整的商品的信息。 ## Set ### 介绍 Set 类型是一个无序并唯一的键值集合,它的存储顺序不会按照插入的先后顺序进行存储。 一个集合最多可以存储 `2^32-1` 个元素。概念和数学中个的集合基本类似,可以交集,并集,差集等等,所以 Set 类型除了支持集合内的增删改查,同时还支持多个集合取交集、并集、差集。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/set.png) Set 类型和 List 类型的区别如下: - List 可以存储重复元素,Set 只能存储非重复元素; - List 是按照元素的先后顺序存储元素的,而 Set 则是无序方式存储元素的。 ### 内部实现 Set 类型的底层数据结构是由**哈希表或整数集合**实现的: - 如果集合中的元素都是整数且元素个数小于 `512` (默认值,`set-maxintset-entries`配置)个,Redis 会使用**整数集合**作为 Set 类型的底层数据结构; - 如果集合中的元素不满足上面条件,则 Redis 使用**哈希表**作为 Set 类型的底层数据结构。 ### 常用命令 Set 常用操作: ```shell # 往集合key中存入元素,元素存在则忽略,若key不存在则新建 SADD key member [member ...] # 从集合key中删除元素 SREM key member [member ...] # 获取集合key中所有元素 SMEMBERS key # 获取集合key中的元素个数 SCARD key # 判断member元素是否存在于集合key中 SISMEMBER key member # 从集合key中随机选出count个元素,元素不从key中删除 SRANDMEMBER key [count] # 从集合key中随机选出count个元素,元素从key中删除 SPOP key [count] ``` Set 运算操作: ```shell # 交集运算 SINTER key [key ...] # 将交集结果存入新集合destination中 SINTERSTORE destination key [key ...] # 并集运算 SUNION key [key ...] # 将并集结果存入新集合destination中 SUNIONSTORE destination key [key ...] # 差集运算 SDIFF key [key ...] # 将差集结果存入新集合destination中 SDIFFSTORE destination key [key ...] ``` ### 应用场景 集合的主要几个特性,无序、不可重复、支持并交差等操作。 因此 Set 类型比较适合用来数据去重和保障数据的唯一性,还可以用来统计多个集合的交集、差集和并集等,当我们存储的数据是无序并且需要去重的情况下,比较适合使用集合类型进行存储。 但是要提醒你一下,这里有一个潜在的风险。**Set 的差集、并集和交集的计算复杂度较高,在数据量较大的情况下,如果直接执行这些计算,会导致 Redis 实例阻塞**。 在主从集群中,为了避免主库因为 Set 做聚合计算(交集、差集、并集)时导致主库被阻塞,我们可以选择一个从库完成聚合统计,或者把数据返回给客户端,由客户端来完成聚合统计。 #### 点赞 Set 类型可以保证一个用户只能点一个赞,这里举例子一个场景,key 是文章 id,value 是用户 id。 `uid:1` 、`uid:2`、`uid:3` 三个用户分别对 article:1 文章点赞了。 ```shell # uid:1 用户对文章 article:1 点赞 > SADD article:1 uid:1 (integer) 1 # uid:2 用户对文章 article:1 点赞 > SADD article:1 uid:2 (integer) 1 # uid:3 用户对文章 article:1 点赞 > SADD article:1 uid:3 (integer) 1 ``` `uid:1` 取消了对 article:1 文章点赞。 ```plain > SREM article:1 uid:1 (integer) 1 ``` 获取 article:1 文章所有点赞用户 : ```shell > SMEMBERS article:1 1) "uid:3" 2) "uid:2" ``` 获取 article:1 文章的点赞用户数量: ```shell > SCARD article:1 (integer) 2 ``` 判断用户 `uid:1` 是否对文章 article:1 点赞了: ```shell > SISMEMBER article:1 uid:1 (integer) 0 # 返回0说明没点赞,返回1则说明点赞了 ``` #### 共同关注 Set 类型支持交集运算,所以可以用来计算共同关注的好友、公众号等。 key 可以是用户 id,value 则是已关注的公众号的 id。 `uid:1` 用户关注公众号 id 为 5、6、7、8、9,`uid:2` 用户关注公众号 id 为 7、8、9、10、11。 ```shell # uid:1 用户关注公众号 id 为 5、6、7、8、9 > SADD uid:1 5 6 7 8 9 (integer) 5 # uid:2 用户关注公众号 id 为 7、8、9、10、11 > SADD uid:2 7 8 9 10 11 (integer) 5 ``` `uid:1` 和 `uid:2` 共同关注的公众号: ```shell # 获取共同关注 > SINTER uid:1 uid:2 1) "7" 2) "8" 3) "9" ``` 给 `uid:2` 推荐 `uid:1` 关注的公众号: ```shell > SDIFF uid:1 uid:2 1) "5" 2) "6" ``` 验证某个公众号是否同时被 `uid:1` 或 `uid:2` 关注: ```shell > SISMEMBER uid:1 5 (integer) 1 # 返回1,说明关注了 > SISMEMBER uid:2 5 (integer) 0 # 返回0,说明没关注 ``` #### 抽奖活动 存储某活动中中奖的用户名,Set 类型因为有去重功能,可以保证同一个用户不会中奖两次。 key 为抽奖活动名,value 为员工名称,把所有员工名称放入抽奖箱: ```shell >SADD lucky Tom Jerry John Sean Marry Lindy Sary Mark (integer) 5 ``` 如果允许重复中奖,可以使用 SRANDMEMBER 命令。 ```shell # 抽取 1 个一等奖: > SRANDMEMBER lucky 1 1) "Tom" # 抽取 2 个二等奖: > SRANDMEMBER lucky 2 1) "Mark" 2) "Jerry" # 抽取 3 个三等奖: > SRANDMEMBER lucky 3 1) "Sary" 2) "Tom" 3) "Jerry" ``` 如果不允许重复中奖,可以使用 SPOP 命令。 ```shell # 抽取一等奖1个 > SPOP lucky 1 1) "Sary" # 抽取二等奖2个 > SPOP lucky 2 1) "Jerry" 2) "Mark" # 抽取三等奖3个 > SPOP lucky 3 1) "John" 2) "Sean" 3) "Lindy" ``` ## Zset ### 介绍 Zset 类型(有序集合类型)相比于 Set 类型多了一个排序属性 score(分值),对于有序集合 ZSet 来说,每个存储元素相当于有两个值组成的,一个是有序结合的元素值,一个是排序值。 有序集合保留了集合不能有重复成员的特性(分值可以重复),但不同的是,有序集合中的元素可以排序。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/zset.png) ### 内部实现 Zset 类型的底层数据结构是由**压缩列表或跳表**实现的: - 如果有序集合的元素个数小于 `128` 个,并且每个元素的值小于 `64` 字节时,Redis 会使用**压缩列表**作为 Zset 类型的底层数据结构; - 如果有序集合的元素不满足上面的条件,Redis 会使用**跳表**作为 Zset 类型的底层数据结构; **在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。** ### 常用命令 Zset 常用操作: ```shell # 往有序集合key中加入带分值元素 ZADD key score member [[score member]...] # 往有序集合key中删除元素 ZREM key member [member...] # 返回有序集合key中元素member的分值 ZSCORE key member # 返回有序集合key中元素个数 ZCARD key # 为有序集合key中元素member的分值加上increment ZINCRBY key increment member # 正序获取有序集合key从start下标到stop下标的元素 ZRANGE key start stop [WITHSCORES] # 倒序获取有序集合key从start下标到stop下标的元素 ZREVRANGE key start stop [WITHSCORES] # 返回有序集合中指定分数区间内的成员,分数由低到高排序。 ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count] # 返回指定成员区间内的成员,按字典正序排列, 分数必须相同。 ZRANGEBYLEX key min max [LIMIT offset count] # 返回指定成员区间内的成员,按字典倒序排列, 分数必须相同 ZREVRANGEBYLEX key max min [LIMIT offset count] ``` Zset 运算操作(相比于 Set 类型,ZSet 类型没有支持差集运算): ```shell # 并集计算(相同元素分值相加),numberkeys一共多少个key,WEIGHTS每个key对应的分值乘积 ZUNIONSTORE destkey numberkeys key [key...] # 交集计算(相同元素分值相加),numberkeys一共多少个key,WEIGHTS每个key对应的分值乘积 ZINTERSTORE destkey numberkeys key [key...] ``` ### 应用场景 Zset 类型(Sorted Set,有序集合)可以根据元素的权重来排序,我们可以自己来决定每个元素的权重值。比如说,我们可以根据元素插入 Sorted Set 的时间确定权重值,先插入的元素权重小,后插入的元素权重大。 在面对需要展示最新列表、排行榜等场景时,如果数据更新频繁或者需要分页显示,可以优先考虑使用 Sorted Set。 #### 排行榜 有序集合比较典型的使用场景就是排行榜。例如学生成绩的排名榜、游戏积分排行榜、视频播放排名、电商系统中商品的销量排名等。 我们以博文点赞排名为例,小林发表了五篇博文,分别获得赞为 200、40、100、50、150。 ```shell # arcticle:1 文章获得了200个赞 > ZADD user:xiaolin:ranking 200 arcticle:1 (integer) 1 # arcticle:2 文章获得了40个赞 > ZADD user:xiaolin:ranking 40 arcticle:2 (integer) 1 # arcticle:3 文章获得了100个赞 > ZADD user:xiaolin:ranking 100 arcticle:3 (integer) 1 # arcticle:4 文章获得了50个赞 > ZADD user:xiaolin:ranking 50 arcticle:4 (integer) 1 # arcticle:5 文章获得了150个赞 > ZADD user:xiaolin:ranking 150 arcticle:5 (integer) 1 ``` 文章 arcticle:4 新增一个赞,可以使用 ZINCRBY 命令(为有序集合 key 中元素 member 的分值加上 increment): ```shell > ZINCRBY user:xiaolin:ranking 1 arcticle:4 "51" ``` 查看某篇文章的赞数,可以使用 ZSCORE 命令(返回有序集合 key 中元素个数): ```shell > ZSCORE user:xiaolin:ranking arcticle:4 "50" ``` 获取小林文章赞数最多的 3 篇文章,可以使用 ZREVRANGE 命令(倒序获取有序集合 key 从 start 下标到 stop 下标的元素): ```shell # WITHSCORES 表示把 score 也显示出来 > ZREVRANGE user:xiaolin:ranking 0 2 WITHSCORES 1) "arcticle:1" 2) "200" 3) "arcticle:5" 4) "150" 5) "arcticle:3" 6) "100" ``` 获取小林 100 赞到 200 赞的文章,可以使用 ZRANGEBYSCORE 命令(返回有序集合中指定分数区间内的成员,分数由低到高排序): ```shell > ZRANGEBYSCORE user:xiaolin:ranking 100 200 WITHSCORES 1) "arcticle:3" 2) "100" 3) "arcticle:5" 4) "150" 5) "arcticle:1" 6) "200" ``` #### 电话、姓名排序 使用有序集合的 `ZRANGEBYLEX` 或 `ZREVRANGEBYLEX` 可以帮助我们实现电话号码或姓名的排序,我们以 `ZRANGEBYLEX` (返回指定成员区间内的成员,按 key 正序排列,分数必须相同)为例。 **注意:不要在分数不一致的 SortSet 集合中去使用 ZRANGEBYLEX 和 ZREVRANGEBYLEX 指令,因为获取的结果会不准确。** *1、电话排序* 我们可以将电话号码存储到 SortSet 中,然后根据需要来获取号段: ```shell > ZADD phone 0 13100111100 0 13110114300 0 13132110901 (integer) 3 > ZADD phone 0 13200111100 0 13210414300 0 13252110901 (integer) 3 > ZADD phone 0 13300111100 0 13310414300 0 13352110901 (integer) 3 ``` 获取所有号码: ```shell > ZRANGEBYLEX phone - + 1) "13100111100" 2) "13110114300" 3) "13132110901" 4) "13200111100" 5) "13210414300" 6) "13252110901" 7) "13300111100" 8) "13310414300" 9) "13352110901" ``` 获取 132 号段的号码: ```shell > ZRANGEBYLEX phone [132 (133 1) "13200111100" 2) "13210414300" 3) "13252110901" ``` 获取 132、133 号段的号码: ```shell > ZRANGEBYLEX phone [132 (134 1) "13200111100" 2) "13210414300" 3) "13252110901" 4) "13300111100" 5) "13310414300" 6) "13352110901" ``` *2、姓名排序* ```shell > zadd names 0 Toumas 0 Jake 0 Bluetuo 0 Gaodeng 0 Aimini 0 Aidehua (integer) 6 ``` 获取所有人的名字: ```shell > ZRANGEBYLEX names - + 1) "Aidehua" 2) "Aimini" 3) "Bluetuo" 4) "Gaodeng" 5) "Jake" 6) "Toumas" ``` 获取名字中大写字母 A 开头的所有人: ```shell > ZRANGEBYLEX names [A (B 1) "Aidehua" 2) "Aimini" ``` 获取名字中大写字母 C 到 Z 的所有人: ```shell > ZRANGEBYLEX names [C [Z 1) "Gaodeng" 2) "Jake" 3) "Toumas" ``` ## BitMap ### 介绍 Bitmap,即位图,是一串连续的二进制数组(0 和 1),可以通过偏移量(offset)定位元素。BitMap 通过最小的单位 bit 来进行`0|1`的设置,表示某个元素的值或者状态,时间复杂度为 O(1)。 由于 bit 是计算机中最小的单位,使用它进行储存将非常节省空间,特别适合一些数据量大且使用**二值统计的场景**。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/bitmap.png) ### 内部实现 Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。 String 类型是会保存为二进制的字节数组,所以,Redis 就把字节数组的每个 bit 位利用起来,用来表示一个元素的二值状态,你可以把 Bitmap 看作是一个 bit 数组。 ### 常用命令 bitmap 基本操作: ```shell # 设置值,其中value只能是 0 和 1 SETBIT key offset value # 获取值 GETBIT key offset # 获取指定范围内值为 1 的个数 # start 和 end 以字节为单位 BITCOUNT key start end ``` bitmap 运算操作: ```shell # BitMap间的运算 # operations 位移操作符,枚举值 AND 与运算 & OR 或运算 | XOR 异或 ^ NOT 取反 ~ # result 计算的结果,会存储在该key中 # key1 … keyn 参与运算的key,可以有多个,空格分割,not运算只能一个key # 当 BITOP 处理不同长度的字符串时,较短的那个字符串所缺少的部分会被看作 0。返回值是保存到 destkey 的字符串的长度(以字节byte为单位),和输入 key 中最长的字符串长度相等。 BITOP [operations] [result] [key1] [keyn…] # 返回指定key中第一次出现指定value(0/1)的位置 BITPOS [key] [value] ``` ### 应用场景 Bitmap 类型非常适合二值状态统计的场景,这里的二值状态就是指集合元素的取值就只有 0 和 1 两种,在记录海量数据时,Bitmap 能够有效地节省内存空间。 #### 签到统计 在签到打卡的场景中,我们只用记录签到(1)或未签到(0),所以它就是非常典型的二值状态。 签到统计时,每个用户一天的签到用 1 个 bit 位就能表示,一个月(假设是 31 天)的签到情况用 31 个 bit 位就可以,而一年的签到也只需要用 365 个 bit 位,根本不用太复杂的集合类型。 假设我们要统计 ID 100 的用户在 2022 年 6 月份的签到情况,就可以按照下面的步骤进行操作。 第一步,执行下面的命令,记录该用户 6 月 3 号已签到。 ```shell SETBIT uid:sign:100:202206 2 1 ``` 第二步,检查该用户 6 月 3 日是否签到。 ```shell GETBIT uid:sign:100:202206 2 ``` 第三步,统计该用户在 6 月份的签到次数。 ```shell BITCOUNT uid:sign:100:202206 ``` 这样,我们就知道该用户在 6 月份的签到情况了。 > 如何统计这个月首次打卡时间呢? Redis 提供了 `BITPOS key bitValue [start] [end]`指令,返回数据表示 Bitmap 中第一个值为 `bitValue` 的 offset 位置。 在默认情况下,命令将检测整个位图,用户可以通过可选的 `start` 参数和 `end` 参数指定要检测的范围。所以我们可以通过执行这条命令来获取 userID = 100 在 2022 年 6 月份**首次打卡**日期: ```apache BITPOS uid:sign:100:202206 1 ``` 需要注意的是,因为 offset 从 0 开始的,所以我们需要将返回的 value + 1。 #### 判断用户登录态 Bitmap 提供了 `GETBIT、SETBIT` 操作,通过一个偏移值 offset 对 bit 数组的 offset 位置的 bit 位进行读写操作,需要注意的是 offset 从 0 开始。 只需要一个 key = login_status 表示存储用户登录状态集合数据,将用户 ID 作为 offset,在线就设置为 1,下线设置 0。通过 `GETBIT`判断对应的用户是否在线。50000 万 用户只需要 6 MB 的空间。 假如我们要判断 ID = 10086 的用户的登录情况: 第一步,执行以下指令,表示用户已登录。 ```shell SETBIT login_status 10086 1 ``` 第二步,检查该用户是否登录,返回值 1 表示已登录。 ```apache GETBIT login_status 10086 ``` 第三步,登出,将 offset 对应的 value 设置成 0。 ```shell SETBIT login_status 10086 0 ``` #### 连续签到用户总数 如何统计出这连续 7 天连续打卡用户总数呢? 我们把每天的日期作为 Bitmap 的 key,userId 作为 offset,若是打卡则将 offset 位置的 bit 设置成 1。 key 对应的集合的每个 bit 位的数据则是一个用户在该日期的打卡记录。 一共有 7 个这样的 Bitmap,如果我们能对这 7 个 Bitmap 的对应的 bit 位做 『与』运算。同样的 UserID offset 都是一样的,当一个 userID 在 7 个 Bitmap 对应对应的 offset 位置的 bit = 1 就说明该用户 7 天连续打卡。 结果保存到一个新 Bitmap 中,我们再通过 `BITCOUNT` 统计 bit = 1 的个数便得到了连续打卡 7 天的用户总数了。 Redis 提供了 `BITOP operation destkey key [key ...]`这个指令用于对一个或者多个 key 的 Bitmap 进行位元操作。 - `operation` 可以是 `and`、`OR`、`NOT`、`XOR`。当 BITOP 处理不同长度的字符串时,较短的那个字符串所缺少的部分会被看作 `0` 。空的 `key` 也被看作是包含 `0` 的字符串序列。 假设要统计 3 天连续打卡的用户数,则是将三个 bitmap 进行 AND 操作,并将结果保存到 destmap 中,接着对 destmap 执行 BITCOUNT 统计,如下命令: ```shell # 与操作 BITOP AND destmap bitmap:01 bitmap:02 bitmap:03 # 统计 bit 位 = 1 的个数 BITCOUNT destmap ``` 即使一天产生一个亿的数据,Bitmap 占用的内存也不大,大约占 12 MB 的内存(10^8/8/1024/1024),7 天的 Bitmap 的内存开销约为 84 MB。同时我们最好给 Bitmap 设置过期时间,让 Redis 删除过期的打卡数据,节省内存。 ## HyperLogLog ### 介绍 Redis HyperLogLog 是 Redis 2.8.9 版本新增的数据类型,是一种用于「统计基数」的数据集合类型,基数统计就是指统计一个集合中不重复的元素个数。但要注意,HyperLogLog 统计规则是基于概率完成的,不是非常准确,标准误算率是 0.81%。 所以,简单来说 HyperLogLog **提供不精确的去重计数**。 HyperLogLog 的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的内存空间总是固定的、并且是很小的。 在 Redis 里面,**每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 `2^64` 个不同元素的基数**,和元素越多就越耗费内存的 Set 和 Hash 类型相比,HyperLogLog 就非常节省空间。 这什么概念?举个例子给大家对比一下。 用 Java 语言来说,一般 long 类型占用 8 字节,而 1 字节有 8 位,即:1 byte = 8 bit,即 long 数据类型最大可以表示的数是:`2^63-1`。对应上面的`2^64`个数,假设此时有`2^63-1`这么多个数,从 `0 ~ 2^63-1`,按照`long`以及`1k = 1024 字节`的规则来计算内存总数,就是:`((2^63-1) * 8/1024)K`,这是很庞大的一个数,存储空间远远超过`12K`,而 `HyperLogLog` 却可以用 `12K` 就能统计完。 ### 内部实现 HyperLogLog 的实现涉及到很多数学问题,太费脑子了,我也没有搞懂,如果你想了解一下,课下可以看看这个:[HyperLogLog](https://en.wikipedia.org/wiki/HyperLogLog)。 ### 常见命令 HyperLogLog 命令很少,就三个。 ```shell # 添加指定元素到 HyperLogLog 中 PFADD key element [element ...] # 返回给定 HyperLogLog 的基数估算值。 PFCOUNT key [key ...] # 将多个 HyperLogLog 合并为一个 HyperLogLog PFMERGE destkey sourcekey [sourcekey ...] ``` ### 应用场景 #### 百万级网页 UA (User Agent) 计数 Redis HyperLogLog 优势在于只需要花费 12 KB 内存,就可以计算接近 2^64 个元素的基数,和元素越多就越耗费内存的 Set 和 Hash 类型相比,HyperLogLog 就非常节省空间。 所以,非常适合统计百万级以上的网页 UA 的场景。 在统计 UA 时,你可以用 PFADD 命令(用于向 HyperLogLog 中添加新元素)把访问页面的每个用户都添加到 HyperLogLog 中。 ```shell PFADD page1:ua user1 user2 user3 user4 user5 ``` 接下来,就可以用 PFCOUNT 命令直接获得 page1 的 UA 值了,这个命令的作用就是返回 HyperLogLog 的统计结果。 ```shell PFCOUNT page1:ua ``` 不过,有一点需要你注意一下,HyperLogLog 的统计规则是基于概率完成的,所以它给出的统计结果是有一定误差的,标准误算率是 0.81%。 这也就意味着,你使用 HyperLogLog 统计的 UA 是 100 万,但实际的 UA 可能是 101 万。虽然误差率不算大,但是,如果你需要精确统计结果的话,最好还是继续用 Set 或 Hash 类型。 ## GEO Redis GEO 是 Redis 3.2 版本新增的数据类型,主要用于存储地理位置信息,并对存储的信息进行操作。 在日常生活中,我们越来越依赖搜索“附近的餐馆”、在打车软件上叫车,这些都离不开基于位置信息服务(Location-Based Service,LBS)的应用。LBS 应用访问的数据是和人或物关联的一组经纬度信息,而且要能查询相邻的经纬度范围,GEO 就非常适合应用在 LBS 服务的场景中。 ### 内部实现 GEO 本身并没有设计新的底层数据结构,而是直接使用了 Sorted Set 集合类型。 GEO 类型使用 GeoHash 编码方法实现了经纬度到 Sorted Set 中元素权重分数的转换,这其中的两个关键机制就是「对二维地图做区间划分」和「对区间进行编码」。一组经纬度落在某个区间后,就用区间的编码值来表示,并把编码值作为 Sorted Set 元素的权重分数。 这样一来,我们就可以把经纬度保存到 Sorted Set 中,利用 Sorted Set 提供的“按权重进行有序范围查找”的特性,实现 LBS 服务中频繁使用的“搜索附近”的需求。 ### 常用命令 ```shell # 存储指定的地理空间位置,可以将一个或多个经度(longitude)、纬度(latitude)、位置名称(member)添加到指定的 key 中。 GEOADD key longitude latitude member [longitude latitude member ...] # 从给定的 key 里返回所有指定名称(member)的位置(经度和纬度),不存在的返回 nil。 GEOPOS key member [member ...] # 返回两个给定位置之间的距离。 GEODIST key member1 member2 [m|km|ft|mi] # 根据用户给定的经纬度坐标来获取指定范围内的地理位置集合。 GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key] ``` ### 应用场景 #### 滴滴叫车 这里以滴滴叫车的场景为例,介绍下具体如何使用 GEO 命令:GEOADD 和 GEORADIUS 这两个命令。 假设车辆 ID 是 33,经纬度位置是(116.034579,39.030452),我们可以用一个 GEO 集合保存所有车辆的经纬度,集合 key 是 cars:locations。 执行下面的这个命令,就可以把 ID 号为 33 的车辆的当前经纬度位置存入 GEO 集合中: ```shell GEOADD cars:locations 116.034579 39.030452 33 ``` 当用户想要寻找自己附近的网约车时,LBS 应用就可以使用 GEORADIUS 命令。 例如,LBS 应用执行下面的命令时,Redis 会根据输入的用户的经纬度信息(116.054579,39.030452),查找以这个经纬度为中心的 5 公里内的车辆信息,并返回给 LBS 应用。 ```shell GEORADIUS cars:locations 116.054579 39.030452 5 km ASC COUNT 10 ``` ## Stream ### 介绍 Redis Stream 是 Redis 5.0 版本新增加的数据类型,Redis 专门为消息队列设计的数据类型。 在 Redis 5.0 Stream 没出来之前,消息队列的实现方式都有着各自的缺陷,例如: - 发布订阅模式,不能持久化也就无法可靠的保存消息,并且对于离线重连的客户端不能读取历史消息的缺陷; - List 实现消息队列的方式不能重复消费,一个消息消费完就会被删除,而且生产者需要自行实现全局唯一 ID。 基于以上问题,Redis 5.0 便推出了 Stream 类型也是此版本最重要的功能,用于完美地实现消息队列,它支持消息的持久化、支持自动生成全局唯一 ID、支持 ack 确认消息的模式、支持消费组模式等,让消息队列更加的稳定和可靠。 ### 常见命令 Stream 消息队列操作命令: - XADD:插入消息,保证有序,可以自动生成全局唯一 ID; - XLEN:查询消息长度; - XREAD:用于读取消息,可以按 ID 读取数据; - XDEL:根据消息 ID 删除消息; - DEL:删除整个 Stream; - XRANGE:读取区间消息 - XREADGROUP:按消费组形式读取消息; - XPENDING 和 XACK: - XPENDING 命令可以用来查询每个消费组内所有消费者「已读取、但尚未确认」的消息; - XACK 命令用于向消息队列确认消息处理已完成; ### 应用场景 #### 消息队列 生产者通过 XADD 命令插入一条消息: ```shell # * 表示让 Redis 为插入的数据自动生成一个全局唯一的 ID # 往名称为 mymq 的消息队列中插入一条消息,消息的键是 name,值是 xiaolin > XADD mymq * name xiaolin "1654254953808-0" ``` 插入成功后会返回全局唯一的 ID:"1654254953808-0"。消息的全局唯一 ID 由两部分组成: - 第一部分“1654254953808”是数据插入时,以毫秒为单位计算的当前服务器时间; - 第二部分表示插入消息在当前毫秒内的消息序号,这是从 0 开始编号的。例如,“1654254953808-0”就表示在“1654254953808”毫秒内的第 1 条消息。 消费者通过 XREAD 命令从消息队列中读取消息时,可以指定一个消息 ID,并从这个消息 ID 的下一条消息开始进行读取(注意是输入消息 ID 的下一条信息开始读取,不是查询输入 ID 的消息)。 ```shell # 从 ID 号为 1654254953807-0 的消息开始,读取后续的所有消息(示例中一共 1 条)。 > XREAD STREAMS mymq 1654254953807-0 1) 1) "mymq" 2) 1) 1) "1654254953808-0" 2) 1) "name" 2) "xiaolin" ``` 如果**想要实现阻塞读(当没有数据时,阻塞住),可以调用 XRAED 时设定 BLOCK 配置项**,实现类似于 BRPOP 的阻塞读取操作。 比如,下面这命令,设置了 BLOCK 10000 的配置项,10000 的单位是毫秒,表明 XREAD 在读取最新消息时,如果没有消息到来,XREAD 将阻塞 10000 毫秒(即 10 秒),然后再返回。 ```shell # 命令最后的“$”符号表示读取最新的消息 > XREAD BLOCK 10000 STREAMS mymq $ (nil) (10.00s) ``` Stream 的基础方法,使用 xadd 存入消息和 xread 循环阻塞读取消息的方式可以实现简易版的消息队列,交互流程如下图所示: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/Stream简易.png) > 前面介绍的这些操作 List 也支持的,接下来看看 Stream 特有的功能。 Stream 可以以使用 **XGROUP 创建消费组**,创建消费组之后,Stream 可以使用 XREADGROUP 命令让消费组内的消费者读取消息。 创建两个消费组,这两个消费组消费的消息队列是 mymq,都指定从第一条消息开始读取: ```shell # 创建一个名为 group1 的消费组,0-0 表示从第一条消息开始读取。 > XGROUP CREATE mymq group1 0-0 OK # 创建一个名为 group2 的消费组,0-0 表示从第一条消息开始读取。 > XGROUP CREATE mymq group2 0-0 OK ``` 消费组 group1 内的消费者 consumer1 从 mymq 消息队列中读取所有消息的命令如下: ```shell # 命令最后的参数“>”,表示从第一条尚未被消费的消息开始读取。 > XREADGROUP GROUP group1 consumer1 STREAMS mymq > 1) 1) "mymq" 2) 1) 1) "1654254953808-0" 2) 1) "name" 2) "xiaolin" ``` **消息队列中的消息一旦被消费组里的一个消费者读取了,就不能再被该消费组内的其他消费者读取了,即同一个消费组里的消费者不能消费同一条消息**。 比如说,我们执行完刚才的 XREADGROUP 命令后,再执行一次同样的命令,此时读到的就是空值了: ```shell > XREADGROUP GROUP group1 consumer1 STREAMS mymq > (nil) ``` 但是,**不同消费组的消费者可以消费同一条消息(但是有前提条件,创建消息组的时候,不同消费组指定了相同位置开始读取消息)**。 比如说,刚才 group1 消费组里的 consumer1 消费者消费了一条 id 为 1654254953808-0 的消息,现在用 group2 消费组里的 consumer1 消费者消费消息: ```shell > XREADGROUP GROUP group2 consumer1 STREAMS mymq > 1) 1) "mymq" 2) 1) 1) "1654254953808-0" 2) 1) "name" 2) "xiaolin" ``` 因为我创建两组的消费组都是从第一条消息开始读取,所以可以看到第二组的消费者依然可以消费 id 为 1654254953808-0 的这一条消息。因此,不同的消费组的消费者可以消费同一条消息。 使用消费组的目的是让组内的多个消费者共同分担读取消息,所以,我们通常会让每个消费者读取部分消息,从而实现消息读取负载在多个消费者间是均衡分布的。 例如,我们执行下列命令,让 group2 中的 consumer1、2、3 各自读取一条消息。 ```shell # 让 group2 中的 consumer1 从 mymq 消息队列中消费一条消息 > XREADGROUP GROUP group2 consumer1 COUNT 1 STREAMS mymq > 1) 1) "mymq" 2) 1) 1) "1654254953808-0" 2) 1) "name" 2) "xiaolin" # 让 group2 中的 consumer2 从 mymq 消息队列中消费一条消息 > XREADGROUP GROUP group2 consumer2 COUNT 1 STREAMS mymq > 1) 1) "mymq" 2) 1) 1) "1654256265584-0" 2) 1) "name" 2) "xiaolincoding" # 让 group2 中的 consumer3 从 mymq 消息队列中消费一条消息 > XREADGROUP GROUP group2 consumer3 COUNT 1 STREAMS mymq > 1) 1) "mymq" 2) 1) 1) "1654256271337-0" 2) 1) "name" 2) "Tom" ``` > 基于 Stream 实现的消息队列,如何保证消费者在发生故障或宕机再次重启后,仍然可以读取未处理完的消息? Streams 会自动使用内部队列(也称为 PENDING List)留存消费组里每个消费者读取的消息,直到消费者使用 XACK 命令通知 Streams“消息已经处理完成”。 消费确认增加了消息的可靠性,一般在业务处理完成之后,需要执行 XACK 命令确认消息已经被消费完成,整个流程的执行如下图所示: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/消息确认.png) 如果消费者没有成功处理消息,它就不会给 Streams 发送 XACK 命令,消息仍然会留存。此时,**消费者可以在重启后,用 XPENDING 命令查看已读取、但尚未确认处理完成的消息**。 例如,我们来查看一下 group2 中各个消费者已读取、但尚未确认的消息个数,命令如下: ```shell 127.0.0.1:6379> XPENDING mymq group2 1) (integer) 3 2) "1654254953808-0" # 表示 group2 中所有消费者读取的消息最小 ID 3) "1654256271337-0" # 表示 group2 中所有消费者读取的消息最大 ID 4) 1) 1) "consumer1" 2) "1" 2) 1) "consumer2" 2) "1" 3) 1) "consumer3" 2) "1" ``` 如果想查看某个消费者具体读取了哪些数据,可以执行下面的命令: ```shell # 查看 group2 里 consumer2 已从 mymq 消息队列中读取了哪些消息 > XPENDING mymq group2 - + 10 consumer2 1) 1) "1654256265584-0" 2) "consumer2" 3) (integer) 410700 4) (integer) 1 ``` 可以看到,consumer2 已读取的消息的 ID 是 1654256265584-0。 **一旦消息 1654256265584-0 被 consumer2 处理了,consumer2 就可以使用 XACK 命令通知 Streams,然后这条消息就会被删除**。 ```shell > XACK mymq group2 1654256265584-0 (integer) 1 ``` 当我们再使用 XPENDING 命令查看时,就可以看到,consumer2 已经没有已读取、但尚未确认处理的消息了。 ```shell > XPENDING mymq group2 - + 10 consumer2 (empty array) ``` 好了,基于 Stream 实现的消息队列就说到这里了,小结一下: - 消息保序:XADD/XREAD - 阻塞读取:XREAD block - 重复消息处理:Stream 在使用 XADD 命令,会自动生成全局唯一 ID; - 消息可靠性:内部使用 PENDING List 自动保存消息,使用 XPENDING 命令查看消费组已经读取但是未被确认的消息,消费者使用 XACK 确认消息; - 支持消费组形式消费数据 > Redis 基于 Stream 消息队列与专业的消息队列有哪些差距? 一个专业的消息队列,必须要做到两大块: - 消息不丢。 - 消息可堆积。 *1、Redis Stream 消息会丢失吗?* 使用一个消息队列,其实就分为三大块:**生产者、队列中间件、消费者**,所以要保证消息就是保证三个环节都不能丢失数据。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/消息队列三个阶段.png) Redis Stream 消息队列能不能保证三个环节都不丢失数据? - Redis 生产者会不会丢消息?生产者会不会丢消息,取决于生产者对于异常情况的处理是否合理。从消息被生产出来,然后提交给 MQ 的过程中,只要能正常收到(MQ 中间件)的 ack 确认响应,就表示发送成功,所以只要处理好返回值和异常,如果返回异常则进行消息重发,那么这个阶段是不会出现消息丢失的。 - Redis 消费者会不会丢消息?不会,因为 Stream(MQ 中间件)会自动使用内部队列(也称为 PENDING List)留存消费组里每个消费者读取的消息,但是未被确认的消息。消费者可以在重启后,用 XPENDING 命令查看已读取、但尚未确认处理完成的消息。等到消费者执行完业务逻辑后,再发送消费确认 XACK 命令,也能保证消息的不丢失。 - Redis 消息中间件会不会丢消息?**会**,Redis 在以下 2 个场景下,都会导致数据丢失: - AOF 持久化配置为每秒写盘,但这个写盘过程是异步的,Redis 宕机时会存在数据丢失的可能 - 主从复制也是异步的,[主从切换时,也存在丢失数据的可能](https://xiaolincoding.com/redis/cluster/master_slave_replication.html#redis-%E4%B8%BB%E4%BB%8E%E5%88%87%E6%8D%A2%E5%A6%82%E4%BD%95%E5%87%8F%E5%B0%91%E6%95%B0%E6%8D%AE%E4%B8%A2%E5%A4%B1)。 可以看到,Redis 在队列中间件环节无法保证消息不丢。像 RabbitMQ 或 Kafka 这类专业的队列中间件,在使用时是部署一个集群,生产者在发布消息时,队列中间件通常会写「多个节点」,也就是有多个副本,这样一来,即便其中一个节点挂了,也能保证集群的数据不丢失。 *2、Redis Stream 消息可堆积吗?* Redis 的数据都存储在内存中,这就意味着一旦发生消息积压,则会导致 Redis 的内存持续增长,如果超过机器内存上限,就会面临被 OOM 的风险。 所以 Redis 的 Stream 提供了可以指定队列最大长度的功能,就是为了避免这种情况发生。 当指定队列最大长度时,队列长度超过上限后,旧消息会被删除,只保留固定长度的新消息。这么来看,Stream 在消息积压时,如果指定了最大长度,还是有可能丢失消息的。 但 Kafka、RabbitMQ 专业的消息队列它们的数据都是存储在磁盘上,当消息积压时,无非就是多占用一些磁盘空间。 因此,把 Redis 当作队列来使用时,会面临的 2 个问题: - Redis 本身可能会丢数据; - 面对消息挤压,内存资源会紧张; 所以,能不能将 Redis 作为消息队列来使用,关键看你的业务场景: - 如果你的业务场景足够简单,对于数据丢失不敏感,而且消息积压概率比较小的情况下,把 Redis 当作队列是完全可以的。 - 如果你的业务有海量消息,消息积压的概率比较大,并且不能接受数据丢失,那么还是用专业的消息队列中间件吧。 > 补充:Redis 发布/订阅机制为什么不可以作为消息队列? 发布订阅机制存在以下缺点,都是跟丢失数据有关: 1. 发布/订阅机制没有基于任何数据类型实现,所以不具备「数据持久化」的能力,也就是发布/订阅机制的相关操作,不会写入到 RDB 和 AOF 中,当 Redis 宕机重启,发布/订阅机制的数据也会全部丢失。 2. 发布订阅模式是“发后既忘”的工作模式,如果有订阅者离线重连之后不能消费之前的历史消息。 3. 当消费端有一定的消息积压时,也就是生产者发送的消息,消费者消费不过来时,如果超过 32M 或者是 60s 内持续保持在 8M 以上,消费端会被强行断开,这个参数是在配置文件中设置的,默认值是 `client-output-buffer-limit pubsub 32mb 8mb 60`。 所以,发布/订阅机制只适合即时通讯的场景,比如[构建哨兵集群](https://xiaolincoding.com/redis/cluster/sentinel.html#%E5%93%A8%E5%85%B5%E9%9B%86%E7%BE%A4%E6%98%AF%E5%A6%82%E4%BD%95%E7%BB%84%E6%88%90%E7%9A%84)的场景采用了发布/订阅机制。 ## 总结 Redis 常见的五种数据类型:**String(字符串),Hash(哈希),List(列表),Set(集合)及 Zset(sorted set:有序集合)**。 这五种数据类型都由多种数据结构实现的,主要是出于时间和空间的考虑,当数据量小的时候使用更简单的数据结构,有利于节省内存,提高性能。 这五种数据类型与底层数据结构对应关系图如下,左边是 Redis 3.0 版本的,也就是《Redis 设计与实现》这本书讲解的版本,现在看还是有点过时了,右边是现在 Github 最新的 Redis 代码的。 ![](https://img-blog.csdnimg.cn/img_convert/9fa26a74965efbf0f56b707a03bb9b7f.png) 可以看到,Redis 数据类型的底层数据结构随着版本的更新也有所不同,比如: - 在 Redis 3.0 版本中 List 对象的底层数据结构由「双向链表」或「压缩表列表」实现,但是在 3.2 版本之后,List 数据类型底层数据结构是由 quicklist 实现的; - 在最新的 Redis 代码中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。 Redis 五种数据类型的应用场景: - String 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等。 - List 类型的应用场景:消息队列(有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。 - Hash 类型:缓存对象、购物车等。 - Set 类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。 - Zset 类型:排序场景,比如排行榜、电话和姓名排序等。 Redis 后续版本又支持四种数据类型,它们的应用场景如下: - BitMap(2.2 版新增):二值状态统计的场景,比如签到、判断用户登录状态、连续签到用户总数等; - HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等; - GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车; - Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息 ID,支持以消费组形式消费数据。 针对 Redis 是否适合做消息队列,关键看你的业务场景: - 如果你的业务场景足够简单,对于数据丢失不敏感,而且消息积压概率比较小的情况下,把 Redis 当作队列是完全可以的。 - 如果你的业务有海量消息,消息积压的概率比较大,并且不能接受数据丢失,那么还是用专业的消息队列中间件吧。 --- 参考资料: - 《Redis 核心技术与实战》 - https://www.cnblogs.com/hunternet/p/12742390.html - https://www.cnblogs.com/qdhxhz/p/15669348.html - https://www.cnblogs.com/bbgs-xc/p/14376109.html - http://kaito-kidd.com/2021/04/19/can-redis-be-used-as-a-queue/ --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: redis/data_struct/data_struct.md ================================================ # Redis 数据结构 大家好,我是小林。 **Redis 为什么那么快?** 除了它是内存数据库,使得所有的操作都在内存上进行之外,还有一个重要因素,它实现的数据结构,使得我们对数据进行增删查改操作时,Redis 能高效的处理。 因此,这次我们就来好好聊一下 Redis 数据结构,这个在面试中太常问了。 注意,**Redis 数据结构并不是指 String(字符串)对象、List(列表)对象、Hash(哈希)对象、Set(集合)对象和 Zset(有序集合)对象,因为这些是 Redis 键值对中值的数据类型,也就是数据的保存形式,这些对象的底层实现的方式就用到了数据结构**。 我画了一张 Redis 数据类型(也叫 Redis 对象)和底层数据结构的对应关图,左边是 Redis 3.0 版本的,也就是《Redis 设计与实现》这本书讲解的版本,现在看还是有点过时了,右边是现在 Github 最新的 Redis 代码的(还未发布正式版本)。 ![](https://img-blog.csdnimg.cn/img_convert/9fa26a74965efbf0f56b707a03bb9b7f.png) 可以看到,Redis 数据类型的底层数据结构随着版本的更新也有所不同,比如: - 在 Redis 3.0 版本中 List 对象的底层数据结构由「双向链表」或「压缩表列表」实现,但是在 3.2 版本之后,List 数据类型底层数据结构是由 quicklist 实现的; - 在最新的 Redis 代码(还未发布正式版本)中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。 **这次,小林把新旧版本的数据结构说图解一遍,共有 9 种数据结构:SDS、双向链表、压缩列表、哈希表、跳表、整数集合、quicklist、listpack。** 不多 BB 了,直接发车! ![](https://img-blog.csdnimg.cn/img_convert/a9c3e7dc4ac79363d8eb8eb2290a58e6.png) ## 键值对数据库是怎么实现的? 在开始讲数据结构之前,先给介绍下 Redis 是怎样实现键值对(key-value)数据库的。 Redis 的键值对中的 key 就是字符串对象,而 **value 可以是字符串对象,也可以是集合数据类型的对象**,比如 List 对象、Hash 对象、Set 对象和 Zset 对象。 举个例子,我这里列出几种 Redis 新增键值对的命令: ```redis > SET name "xiaolincoding" OK > HSET person name "xiaolincoding" age 18 0 > RPUSH stu "xiaolin" "xiaomei" (integer) 4 ``` 这些命令代表着: - 第一条命令:name 是一个**字符串键**,因为键的**值是一个字符串对象**; - 第二条命令:person 是一个**哈希表键**,因为键的**值是一个包含两个键值对的哈希表对象**; - 第三条命令:stu 是一个**列表键**,因为键的**值是一个包含两个元素的列表对象**; 这些键值对是如何保存在 Redis 中的呢? Redis 是使用了一个「哈希表」保存所有键值对,哈希表的最大好处就是让我们可以用 O(1) 的时间复杂度来快速查找到键值对。哈希表其实就是一个数组,数组中的元素叫做哈希桶。 Redis 的哈希桶是怎么保存键值对数据的呢? 哈希桶存放的是指向键值对数据的指针(dictEntry*),这样通过指针就能找到键值对数据,然后因为键值对的值可以保存字符串对象和集合数据类型的对象,所以键值对的数据结构中并不是直接保存值本身,而是保存了 void * key 和 void * value 指针,分别指向了实际的键对象和值对象,这样一来,即使值是集合数据,也可以通过 void * value 指针找到。 我这里画了一张 Redis 保存键值对所涉及到的数据结构。 ![](https://img-blog.csdnimg.cn/img_convert/f302fce6c92c0682024f47bf7579b44c.png) 这些数据结构的内部细节,我先不展开讲,后面在讲哈希表数据结构的时候,在详细的说说,因为用到的数据结构是一样的。这里先大概说下图中涉及到的数据结构的名字和用途: - redisDb 结构,表示 Redis 数据库的结构,结构体里存放了指向了 dict 结构的指针; - dict 结构,结构体里存放了 2 个哈希表,正常情况下都是用「哈希表 1」,「哈希表 2」只有在 rehash 的时候才用,具体什么是 rehash,我在本文的哈希表数据结构会讲; - ditctht 结构,表示哈希表的结构,结构里存放了哈希表数组,数组中的每个元素都是指向一个哈希表节点结构(dictEntry)的指针; - dictEntry 结构,表示哈希表节点的结构,结构里存放了 **void * key 和 void * value 指针, *key 指向的是 String 对象,而 *value 则可以指向 String 对象,也可以指向集合类型的对象,比如 List 对象、Hash 对象、Set 对象和 Zset 对象**。 特别说明下,void * key 和 void * value 指针指向的是 **Redis 对象**,Redis 中的每个对象都由 redisObject 结构表示,如下图: ![](https://img-blog.csdnimg.cn/img_convert/58d3987af2af868dca965193fb27c464.png) 对象结构里包含的成员变量: - type,标识该对象是什么类型的对象(String 对象、List 对象、Hash 对象、Set 对象和 Zset 对象); - encoding,标识该对象使用了哪种底层的数据结构; - **ptr,指向底层数据结构的指针**。 我画了一张 Redis 键值对数据库的全景图,你就能清晰知道 Redis 对象和数据结构的关系了: ![](https://img-blog.csdnimg.cn/img_convert/3c386666e4e7638a07b230ba14b400fe.png) 接下来,就好好聊一下底层数据结构! ## SDS 字符串在 Redis 中是很常用的,键值对中的键是字符串类型,值有时也是字符串类型。 Redis 是用 C 语言实现的,但是它没有直接使用 C 语言的 char* 字符数组来实现字符串,而是自己封装了一个名为简单动态字符串(simple dynamic string,SDS)的数据结构来表示字符串,也就是 Redis 的 String 数据类型的底层数据结构是 SDS。 既然 Redis 设计了 SDS 结构来表示字符串,肯定是 C 语言的 char* 字符数组存在一些缺陷。 要了解这一点,得先来看看 char* 字符数组的结构。 ### C 语言字符串的缺陷 C 语言的字符串其实就是一个字符数组,即数组中每个元素是字符串中的一个字符。 比如,下图就是字符串“xiaolin”的 char* 字符数组的结构: ![](https://img-blog.csdnimg.cn/img_convert/376128646c75a893ad47914858fa2131.png) 没学过 C 语言的同学,可能会好奇为什么最后一个字符是“\0”? 在 C 语言里,对字符串操作时,char * 指针只是指向字符数组的起始位置,而**字符数组的结尾位置就用“\0”表示,意思是指字符串的结束**。 因此,C 语言标准库中的字符串操作函数就通过判断字符是不是“\0”来决定要不要停止操作,如果当前字符不是“\0” ,说明字符串还没结束,可以继续操作,如果当前字符是“\0”是则说明字符串结束了,就要停止操作。 举个例子,C 语言获取字符串长度的函数 `strlen`,就是通过字符数组中的每一个字符,并进行计数,等遇到字符为“\0”后,就会停止遍历,然后返回已经统计到的字符个数,即为字符串长度。下图显示了 strlen 函数的执行流程: ![](https://img-blog.csdnimg.cn/img_convert/bcf6bde3b647bdc343efcbc1a8f10579.png) 很明显,**C 语言获取字符串长度的时间复杂度是 O(N)(*这是一个可以改进的地方***) C 语言字符串用“\0”字符作为结尾标记有个缺陷。假设有个字符串中有个“\0”字符,这时在操作这个字符串时就会**提早结束**,比如“xiao\0lin”字符串,计算字符串长度的时候则会是 4,如下图: ![](https://img-blog.csdnimg.cn/img_convert/6286480eb1840a8930e18fd215d82565.png) 因此,除了字符串的末尾之外,**字符串里面不能含有“\0”字符**,否则最先被程序读入的“\0”字符将被误认为是字符串结尾,这个限制使得 C 语言的字符串只能保存文本数据,**不能保存像图片、音频、视频文化这样的二进制数据(*这也是一个可以改进的地方*)** 另外,C 语言标准库中字符串的操作函数是很不安全的,对程序员很不友好,稍微一不注意,就会导致缓冲区溢出。 举个例子,strcat 函数是可以将两个字符串拼接在一起。 ```c //将 src 字符串拼接到 dest 字符串后面 char *strcat(char *dest, const char* src); ``` **C 语言的字符串是不会记录自身的缓冲区大小的**,所以 strcat 函数假定程序员在执行这个函数时,已经为 dest 分配了足够多的内存,可以容纳 src 字符串中的所有内容,而**一旦这个假定不成立,就会发生缓冲区溢出将可能会造成程序运行终止,(*这是一个可以改进的地方***)。 而且,strcat 函数和 strlen 函数类似,时间复杂度也很高,也都需要先通过遍历字符串才能得到目标字符串的末尾。然后对于 strcat 函数来说,还要再遍历源字符串才能完成追加,**对字符串的操作效率不高**。 好了,通过以上的分析,我们可以得知 C 语言的字符串不足之处以及可以改进的地方: - 获取字符串长度的时间复杂度为 O(N); - 字符串的结尾是以“\0”字符标识,字符串里面不能包含有“\0”字符,因此不能保存二进制数据; - 字符串操作函数不高效且不安全,比如有缓冲区溢出的风险,有可能会造成程序运行终止; Redis 实现的 SDS 的结构就把上面这些问题解决了,接下来我们一起看看 Redis 是如何解决的。 ### SDS 结构设计 下图就是 Redis 5.0 的 SDS 的数据结构: ![](https://img-blog.csdnimg.cn/img_convert/516738c4058cdf9109e40a7812ef4239.png) 结构中的每个成员变量分别介绍下: - **len,记录了字符串长度**。这样获取字符串长度的时候,只需要返回这个成员变量值就行,时间复杂度只需要 O(1)。 - **alloc,分配给字符数组的空间长度**。这样在修改字符串的时候,可以通过 `alloc - len` 计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出的问题。 - **flags,用来表示不同类型的 SDS**。一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,后面在说明区别之处。 - **buf[],字符数组,用来保存实际数据**。不仅可以保存字符串,也可以保存二进制数据。 总的来说,Redis 的 SDS 结构在原本字符数组之上,增加了三个元数据:len、alloc、flags,用来解决 C 语言字符串的缺陷。 #### O(1)复杂度获取字符串长度 C 语言的字符串长度获取 strlen 函数,需要通过遍历的方式来统计字符串长度,时间复杂度是 O(N)。 而 Redis 的 SDS 结构因为加入了 len 成员变量,那么**获取字符串长度的时候,直接返回这个成员变量的值就行,所以复杂度只有 O(1)**。 #### 二进制安全 因为 SDS 不需要用“\0”字符来标识字符串结尾了,而是**有个专门的 len 成员变量来记录长度,所以可存储包含“\0”的数据**。但是 SDS 为了兼容部分 C 语言标准库的函数,SDS 字符串结尾还是会加上“\0”字符。 因此,SDS 的 API 都是以处理二进制的方式来处理 SDS 存放在 buf[] 里的数据,程序不会对其中的数据做任何限制,数据写入的时候时什么样的,它被读取时就是什么样的。 通过使用二进制安全的 SDS,而不是 C 字符串,使得 Redis 不仅可以保存文本数据,也可以保存任意格式的二进制数据。 #### 不会发生缓冲区溢出 C 语言的字符串标准库提供的字符串操作函数,大多数(比如 strcat 追加字符串函数)都是不安全的,因为这些函数把缓冲区大小是否满足操作需求的工作交由开发者来保证,程序内部并不会判断缓冲区大小是否足够用,当发生了缓冲区溢出就有可能造成程序异常结束。 所以,Redis 的 SDS 结构里引入了 alloc 和 len 成员变量,这样 SDS API 通过 `alloc - len` 计算,可以算出剩余可用的空间大小,这样在对字符串做修改操作的时候,就可以由程序内部判断缓冲区大小是否足够用。 而且,**当判断出缓冲区大小不够用时,Redis 会自动将扩大 SDS 的空间大小**,以满足修改所需的大小。 SDS 扩容的规则代码如下: ```c hisds hi_sdsMakeRoomFor(hisds s, size_t addlen) { ... ... // s 目前的剩余空间已足够,无需扩展,直接返回 if (avail >= addlen) return s; //获取目前 s 的长度 len = hi_sdslen(s); sh = (char *)s - hi_sdsHdrSize(oldtype); //扩展之后 s 至少需要的长度 newlen = (len + addlen); //根据新长度,为 s 分配新空间所需要的大小 if (newlen < HI_SDS_MAX_PREALLOC) //新长度 struct test1 { char a; int b; } test1; int main() { printf("%lu\n", sizeof(test1)); return 0; } ``` 大家猜猜这个结构体大小是多少?我先直接说答案,这个结构体大小计算出来是 8。 ![](https://img-blog.csdnimg.cn/img_convert/35820959e8cf4376391c427ed7f81495.png) 这是因为默认情况下,编译器是使用「字节对齐」的方式分配内存,虽然 char 类型只占一个字节,但是由于成员变量里有 int 类型,它占用了 4 个字节,所以在成员变量为 char 类型分配内存时,会分配 4 个字节,其中这多余的 3 个字节是为了字节对齐而分配的,相当于有 3 个字节被浪费掉了。 如果不想编译器使用字节对齐的方式进行分配内存,可以采用了 `__attribute__ ((packed))` 属性定义结构体,这样一来,结构体实际占用多少内存空间,编译器就分配多少空间。 比如,我用 `__attribute__ ((packed))` 属性定义下面的结构体,同样包含 char 和 int 两个类型的成员变量,代码如下所示: ```c #include struct __attribute__((packed)) test2 { char a; int b; } test2; int main() { printf("%lu\n", sizeof(test2)); return 0; } ``` 这时打印的结果是 5(1 个字节 char + 4 字节 int)。 ![](https://img-blog.csdnimg.cn/img_convert/47e6c8fbc17fd6c89bdfcb5eedaaacff.png) 可以看得出,这是按照实际占用字节数进行分配内存的,这样可以节省内存空间。 --- ## 链表 大家最熟悉的数据结构除了数组之外,我相信就是链表了。 Redis 的 List 对象的底层实现之一就是链表。C 语言本身没有链表这个数据结构的,所以 Redis 自己设计了一个链表数据结构。 ### 链表节点结构设计 先来看看「链表节点」结构的样子: ```c typedef struct listNode { //前置节点 struct listNode *prev; //后置节点 struct listNode *next; //节点的值 void *value; } listNode; ``` 有前置节点和后置节点,可以看的出,这个是一个双向链表。 ![](https://img-blog.csdnimg.cn/img_convert/4fecbf7f63c73ec284a4821e0bfe2843.png) ### 链表结构设计 不过,Redis 在 listNode 结构体基础上又封装了 list 这个数据结构,这样操作起来会更方便,链表结构如下: ```c typedef struct list { //链表头节点 listNode *head; //链表尾节点 listNode *tail; //节点值复制函数 void *(*dup)(void *ptr); //节点值释放函数 void (*free)(void *ptr); //节点值比较函数 int (*match)(void *ptr, void *key); //链表节点数量 unsigned long len; } list; ``` list 结构为链表提供了链表头指针 head、链表尾节点 tail、链表节点数量 len、以及可以自定义实现的 dup、free、match 函数。 举个例子,下面是由 list 结构和 3 个 listNode 结构组成的链表。 ![](https://img-blog.csdnimg.cn/img_convert/cadf797496816eb343a19c2451437f1e.png) ### 链表的优势与缺陷 Redis 的链表实现优点如下: - listNode 链表节点的结构里带有 prev 和 next 指针,**获取某个节点的前置节点或后置节点的时间复杂度只需 O(1),而且这两个指针都可以指向 NULL,所以链表是无环链表**; - list 结构因为提供了表头指针 head 和表尾节点 tail,所以**获取链表的表头节点和表尾节点的时间复杂度只需 O(1)**; - list 结构因为提供了链表节点数量 len,所以**获取链表中的节点数量的时间复杂度只需 O(1)**; - listNode 链表节使用 void* 指针保存节点值,并且可以通过 list 结构的 dup、free、match 函数指针为节点设置该节点类型特定的函数,因此**链表节点可以保存各种不同类型的值**; 链表的缺陷也是有的: - 链表每个节点之间的内存都是不连续的,意味着**无法很好利用 CPU 缓存**。能很好利用 CPU 缓存的数据结构就是数组,因为数组的内存是连续的,这样就可以充分利用 CPU 缓存来加速访问。 - 还有一点,保存一个链表节点的值都需要一个链表节点结构头的分配,**内存开销较大**。 因此,Redis 3.0 的 List 对象在数据量比较少的情况下,会采用「压缩列表」作为底层数据结构的实现,它的优势是节省内存空间,并且是内存紧凑型的数据结构。 不过,压缩列表存在性能问题(具体什么问题,下面会说),所以 Redis 在 3.2 版本设计了新的数据结构 quicklist,并将 List 对象的底层数据结构改由 quicklist 实现。 然后在 Redis 5.0 设计了新的数据结构 listpack,沿用了压缩列表紧凑型的内存布局,最终在最新的 Redis 版本,将 Hash 对象和 Zset 对象的底层数据结构实现之一的压缩列表,替换成由 listpack 实现。 --- ## 压缩列表 压缩列表的最大特点,就是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销。 但是,压缩列表的缺陷也是有的: - 不能保存过多的元素,否则查询效率就会降低; - 新增或修改某个元素时,压缩列表占用的内存空间需要重新分配,甚至可能引发连锁更新的问题。 因此,Redis 对象(List 对象、Hash 对象、Zset 对象)包含的元素数量较少,或者元素值不大的情况才会使用压缩列表作为底层数据结构。 接下来,就跟大家详细聊下压缩列表。 ### 压缩列表结构设计 压缩列表是 Redis 为了节约内存而开发的,它是**由连续内存块组成的顺序型数据结构**,有点类似于数组。 ![](https://img-blog.csdnimg.cn/img_convert/ab0b44f557f8b5bc7acb3a53d43ebfcb.png) 压缩列表在表头有三个字段: - ***zlbytes***,记录整个压缩列表占用的内存字节数; - ***zltail***,记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量; - ***zllen***,记录压缩列表包含的节点数量; - ***zlend***,标记压缩列表的结束点,固定值 0xFF(十进制 255)。 在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段(zllen)的长度直接定位,复杂度是 O(1)。而**查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了,因此压缩列表不适合保存过多的元素**。 另外,压缩列表节点(entry)的构成如下: ![](https://img-blog.csdnimg.cn/img_convert/a3b1f6235cf0587115b21312fe60289c.png) 压缩列表节点包含三部分内容: - ***prevlen***,记录了「前一个节点」的长度,目的是为了实现从后向前遍历; - ***encoding***,记录了当前节点实际数据的「类型和长度」,类型主要有两种:字符串和整数。 - ***data***,记录了当前节点的实际数据,类型和长度都由 `encoding` 决定; 当我们往压缩列表中插入数据时,压缩列表就会根据数据类型是字符串还是整数,以及数据的大小,会使用不同空间大小的 prevlen 和 encoding 这两个元素里保存的信息,**这种根据数据大小和类型进行不同的空间大小分配的设计思想,正是 Redis 为了节省内存而采用的**。 分别说下,prevlen 和 encoding 是如何根据数据的大小和类型来进行不同的空间大小分配。 压缩列表里的每个节点中的 prevlen 属性都记录了「前一个节点的长度」,而且 prevlen 属性的空间大小跟前一个节点长度值有关,比如: - 如果**前一个节点的长度小于 254 字节**,那么 prevlen 属性需要用 **1 字节的空间**来保存这个长度值; - 如果**前一个节点的长度大于等于 254 字节**,那么 prevlen 属性需要用 **5 字节的空间**来保存这个长度值; encoding 属性的空间大小跟数据是字符串还是整数,以及字符串的长度有关,如下图(下图中的 content 表示的是实际数据,即本文的 data 字段): ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/压缩列表编码.png) - 如果**当前节点的数据是整数**,则 encoding 会使用 **1 字节的空间**进行编码,也就是 encoding 长度为 1 字节。通过 encoding 确认了整数类型,就可以确认整数数据的实际大小了,比如如果 encoding 编码确认了数据是 int16 整数,那么 data 的长度就是 int16 的大小。 - 如果**当前节点的数据是字符串,根据字符串的长度大小**,encoding 会使用 **1 字节/2 字节/5 字节的空间**进行编码,encoding 编码的前两个 bit 表示数据的类型,后续的其他 bit 标识字符串数据的实际长度,即 data 的长度。 ### 连锁更新 压缩列表除了查找复杂度高的问题,还有一个问题。 **压缩列表新增某个元素或修改某个元素时,如果空间不够,压缩列表占用的内存空间就需要重新分配。而当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降**。 前面提到,压缩列表节点的 prevlen 属性会根据前一个节点的长度进行不同的空间大小分配: - 如果前一个**节点的长度小于 254 字节**,那么 prevlen 属性需要用 **1 字节的空间**来保存这个长度值; - 如果前一个**节点的长度大于等于 254 字节**,那么 prevlen 属性需要用 **5 字节的空间**来保存这个长度值; 现在假设一个压缩列表中有多个连续的、长度在 250~253 之间的节点,如下图: ![](https://img-blog.csdnimg.cn/img_convert/462c6a65531667f2bcf420953b0aded9.png) 因为这些节点长度值小于 254 字节,所以 prevlen 属性需要用 1 字节的空间来保存这个长度值。 这时,如果将一个长度大于等于 254 字节的新节点加入到压缩列表的表头节点,即新节点将成为 e1 的前置节点,如下图: ![](https://img-blog.csdnimg.cn/img_convert/d1a6deff4672580609c99a5b06bf3429.png) 因为 e1 节点的 prevlen 属性只有 1 个字节大小,无法保存新节点的长度,此时就需要对压缩列表的空间重分配操作,并将 e1 节点的 prevlen 属性从原来的 1 字节大小扩展为 5 字节大小。 多米诺牌的效应就此开始。 ![](https://img-blog.csdnimg.cn/img_convert/1f0e5ae7ab749078cadda5ba0ed98eac.png) e1 原本的长度在 250~253 之间,因为刚才的扩展空间,此时 e1 的长度就大于等于 254 了,因此原本 e2 保存 e1 的 prevlen 属性也必须从 1 字节扩展至 5 字节大小。 正如扩展 e1 引发了对 e2 扩展一样,扩展 e2 也会引发对 e3 的扩展,而扩展 e3 又会引发对 e4 的扩展…… 一直持续到结尾。 **这种在特殊情况下产生的连续多次空间扩展操作就叫做「连锁更新」**,就像多米诺牌的效应一样,第一张牌倒下了,推动了第二张牌倒下;第二张牌倒下,又推动了第三张牌倒下……, ### 压缩列表的缺陷 空间扩展操作也就是重新分配内存,因此**连锁更新一旦发生,就会导致压缩列表占用的内存空间要多次重新分配,这就会直接影响到压缩列表的访问性能**。 所以说,**虽然压缩列表紧凑型的内存布局能节省内存开销,但是如果保存的元素数量增加了,或是元素变大了,会导致内存重新分配,最糟糕的是会有「连锁更新」的问题**。 因此,**压缩列表只会用于保存的节点数量不多的场景**,只要节点数量足够小,即使发生连锁更新,也是能接受的。 虽说如此,Redis 针对压缩列表在设计上的不足,在后来的版本中,新增设计了两种数据结构:quicklist(Redis 3.2 引入)和 listpack(Redis 5.0 引入)。这两种数据结构的设计目标,就是尽可能地保持压缩列表节省内存的优势,同时解决压缩列表的「连锁更新」的问题。 ## 哈希表 哈希表是一种保存键值对(key-value)的数据结构。 哈希表中的每一个 key 都是独一无二的,程序可以根据 key 查找到与之关联的 value,或者通过 key 来更新 value,又或者根据 key 来删除整个 key-value 等等。 在讲压缩列表的时候,提到过 Redis 的 Hash 对象的底层实现之一是压缩列表(最新 Redis 代码已将压缩列表替换成 listpack)。Hash 对象的另外一个底层实现就是哈希表。 哈希表优点在于,它**能以 O(1) 的复杂度快速查询数据**。怎么做到的呢?将 key 通过 Hash 函数的计算,就能定位数据在表中的位置,因为哈希表实际上是数组,所以可以通过索引值快速查询到数据。 但是存在的风险也是有,在哈希表大小固定的情况下,随着数据不断增多,那么**哈希冲突**的可能性也会越高。 解决哈希冲突的方式,有很多种。 **Redis 采用了「链式哈希」来解决哈希冲突**,在不扩容哈希表的前提下,将具有相同哈希值的数据串起来,形成链表,以便这些数据在表中仍然可以被查询到。 接下来,详细说说哈希表。 ### 哈希表结构设计 Redis 的哈希表结构如下: ```c typedef struct dictht { //哈希表数组 dictEntry **table; //哈希表大小 unsigned long size; //哈希表大小掩码,用于计算索引值 unsigned long sizemask; //该哈希表已有的节点数量 unsigned long used; } dictht; ``` 可以看到,哈希表是一个数组(dictEntry **table),数组的每个元素是一个指向「哈希表节点(dictEntry)」的指针。 ![](https://img-blog.csdnimg.cn/img_convert/dc495ffeaa3c3d8cb2e12129b3423118.png) 哈希表节点的结构如下: ```c typedef struct dictEntry { //键值对中的键 void *key; //键值对中的值 union { void *val; uint64_t u64; int64_t s64; double d; } v; //指向下一个哈希表节点,形成链表 struct dictEntry *next; } dictEntry; ``` dictEntry 结构里不仅包含指向键和值的指针,还包含了指向下一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对链接起来,以此来解决哈希冲突的问题,这就是链式哈希。 另外,这里还跟你提一下,dictEntry 结构里键值对中的值是一个「联合体 v」定义的,因此,键值对中的值可以是一个指向实际值的指针,或者是一个无符号的 64 位整数或有符号的 64 位整数或 double 类的值。这么做的好处是可以节省内存空间,因为当「值」是整数或浮点数时,就可以将值的数据内嵌在 dictEntry 结构里,无需再用一个指针指向实际的值,从而节省了内存空间。 ### 哈希冲突 哈希表实际上是一个数组,数组里的每一个元素就是一个哈希桶。 当一个键值对的键经过 Hash 函数计算后得到哈希值,再将 (哈希值 % 哈希表大小) 取模计算,得到的结果值就是该 key-value 对应的数组元素位置,也就是第几个哈希桶。 > 什么是哈希冲突呢? 举个例子,有一个可以存放 8 个哈希桶的哈希表。key1 经过哈希函数计算后,再将「哈希值 % 8」进行取模计算,结果值为 1,那么就对应哈希桶 1,类似的,key9 和 key10 分别对应哈希桶 1 和桶 6。 ![](https://img-blog.csdnimg.cn/img_convert/753724a072e77d139c926ecf1f049b29.png) 此时,key1 和 key9 对应到了相同的哈希桶中,这就发生了哈希冲突。 因此,**当有两个以上数量的 kay 被分配到了哈希表中同一个哈希桶上时,此时称这些 key 发生了冲突。** ### 链式哈希 Redis 采用了「**链式哈希**」的方法来解决哈希冲突。 > 链式哈希是怎么实现的? 实现的方式就是每个哈希表节点都有一个 next 指针,用于指向下一个哈希表节点,因此多个哈希表节点可以用 next 指针构成一个单项链表,**被分配到同一个哈希桶上的多个节点可以用这个单项链表连接起来**,这样就解决了哈希冲突。 还是用前面的哈希冲突例子,key1 和 key9 经过哈希计算后,都落在同一个哈希桶,链式哈希的话,key1 就会通过 next 指针指向 key9,形成一个单向链表。 ![](https://img-blog.csdnimg.cn/img_convert/675c23857a36b2dab26ed2e6a7b94b5d.png) 不过,链式哈希局限性也很明显,随着链表长度的增加,在查询这一位置上的数据的耗时就会增加,毕竟链表的查询的时间复杂度是 O(n)。 要想解决这一问题,就需要进行 rehash,也就是对哈希表的大小进行扩展。 接下来,看看 Redis 是如何实现的 rehash 的。 ### rehash 哈希表结构设计的这一小节,我给大家介绍了 Redis 使用 dictht 结构体表示哈希表。不过,在实际使用哈希表时,Redis 定义一个 dict 结构体,这个结构体里定义了**两个哈希表(ht[2])**。 ```c typedef struct dict { … //两个 Hash 表,交替使用,用于 rehash 操作 dictht ht[2]; … } dict; ``` 之所以定义了 2 个哈希表,是因为进行 rehash 的时候,需要用上 2 个哈希表了。 ![](https://img-blog.csdnimg.cn/img_convert/2fedbc9cd4cb7236c302d695686dd478.png) 在正常服务请求阶段,插入的数据,都会写入到「哈希表 1」,此时的「哈希表 2」并没有被分配空间。 随着数据逐步增多,触发了 rehash 操作,这个过程分为三步: - 给「哈希表 2」分配空间,一般会比「哈希表 1」大 2 倍; - 将「哈希表 1」的数据迁移到「哈希表 2」中; - 迁移完成后,「哈希表 1」的空间会被释放,并把「哈希表 2」设置为「哈希表 1」,然后在「哈希表 2」新创建一个空白的哈希表,为下次 rehash 做准备。 为了方便你理解,我把 rehash 这三个过程画在了下面这张图: ![](https://img-blog.csdnimg.cn/img_convert/cabce0ce7e320bc9d9b5bde947b6811b.png) 这个过程看起来简单,但是其实第二步很有问题,**如果「哈希表 1」的数据量非常大,那么在迁移至「哈希表 2」的时候,因为会涉及大量的数据拷贝,此时可能会对 Redis 造成阻塞,无法服务其他请求**。 ### 渐进式 rehash 为了避免 rehash 在数据迁移过程中,因拷贝数据的耗时,影响 Redis 性能的情况,所以 Redis 采用了**渐进式 rehash**,也就是将数据的迁移的工作不再是一次性迁移完成,而是分多次迁移。 渐进式 rehash 步骤如下: - 给「哈希表 2」分配空间; - **在 rehash 进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会顺序将「哈希表 1」中索引位置上的所有 key-value 迁移到「哈希表 2」上**; - 随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间点会把「哈希表 1」的所有 key-value 迁移到「哈希表 2」,从而完成 rehash 操作。 这样就巧妙地把一次性大量数据迁移工作的开销,分摊到了多次处理请求的过程中,避免了一次性 rehash 的耗时操作。 在进行渐进式 rehash 的过程中,会有两个哈希表,所以在渐进式 rehash 进行期间,哈希表元素的删除、查找、更新等操作都会在这两个哈希表进行。 比如,查找一个 key 的值的话,先会在「哈希表 1」里面进行查找,如果没找到,就会继续到哈希表 2 里面进行找到。 另外,在渐进式 rehash 进行期间,新增一个 key-value 时,会被保存到「哈希表 2」里面,而「哈希表 1」则不再进行任何添加操作,这样保证了「哈希表 1」的 key-value 数量只会减少,随着 rehash 操作的完成,最终「哈希表 1」就会变成空表。 ### rehash 触发条件 介绍了 rehash 那么多,还没说什么时情况下会触发 rehash 操作呢? rehash 的触发条件跟**负载因子(load factor)**有关系。 负载因子可以通过下面这个公式计算: ![](https://img-blog.csdnimg.cn/img_convert/85f597f7851b90d6c78bb0d8e39690fc.png) 触发 rehash 操作的条件,主要有两个: - **当负载因子大于等于 1,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作。** - **当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作。** ## 整数集合 整数集合是 Set 对象的底层实现之一。当一个 Set 对象只包含整数值元素,并且元素数量不大时,就会使用整数集这个数据结构作为底层实现。 ### 整数集合结构设计 整数集合本质上是一块连续内存空间,它的结构定义如下: ```c typedef struct intset { //编码方式 uint32_t encoding; //集合包含的元素数量 uint32_t length; //保存元素的数组 int8_t contents[]; } intset; ``` 可以看到,保存元素的容器是一个 contents 数组,虽然 contents 被声明为 int8_t 类型的数组,但是实际上 contents 数组并不保存任何 int8_t 类型的元素,contents 数组的真正类型取决于 intset 结构体里的 encoding 属性的值。比如: - 如果 encoding 属性值为 INTSET_ENC_INT16,那么 contents 就是一个 int16_t 类型的数组,数组中每一个元素的类型都是 int16_t; - 如果 encoding 属性值为 INTSET_ENC_INT32,那么 contents 就是一个 int32_t 类型的数组,数组中每一个元素的类型都是 int32_t; - 如果 encoding 属性值为 INTSET_ENC_INT64,那么 contents 就是一个 int64_t 类型的数组,数组中每一个元素的类型都是 int64_t; 不同类型的 contents 数组,意味着数组的大小也会不同。 ### 整数集合的升级操作 整数集合会有一个升级规则,就是当我们将一个新元素加入到整数集合里面,如果新元素的类型(int32_t)比整数集合现有所有元素的类型(int16_t)都要长时,整数集合需要先进行升级,也就是按新元素的类型(int32_t)扩展 contents 数组的空间大小,然后才能将新元素加入到整数集合里,当然升级的过程中,也要维持整数集合的有序性。 整数集合升级的过程不会重新分配一个新类型的数组,而是在原本的数组上扩展空间,然后在将每个元素按间隔类型大小分割,如果 encoding 属性值为 INTSET_ENC_INT16,则每个元素的间隔就是 16 位。 举个例子,假设有一个整数集合里有 3 个类型为 int16_t 的元素。 ![](https://img-blog.csdnimg.cn/img_convert/5dbdfa7cfbdd1d12a4d9458c6c90d472.png) 现在,往这个整数集合中加入一个新元素 65535,这个新元素需要用 int32_t 类型来保存,所以整数集合要进行升级操作,首先需要为 contents 数组扩容,**在原本空间的大小之上再扩容多 80 位(4x32-3x16=80),这样就能保存下 4 个类型为 int32_t 的元素**。 ![](https://img-blog.csdnimg.cn/img_convert/e2e3e19fc934e70563fbdfde2af39a2b.png) 扩容完 contents 数组空间大小后,需要将之前的三个元素转换为 int32_t 类型,并将转换后的元素放置到正确的位上面,并且需要维持底层数组的有序性不变,整个转换过程如下: ![](https://img-blog.csdnimg.cn/img_convert/e84b052381e240eeb8cc97d6b729968b.png) > 整数集合升级有什么好处呢? 如果要让一个数组同时保存 int16_t、int32_t、int64_t 类型的元素,最简单做法就是直接使用 int64_t 类型的数组。不过这样的话,当如果元素都是 int16_t 类型的,就会造成内存浪费的情况。 整数集合升级就能避免这种情况,如果一直向整数集合添加 int16_t 类型的元素,那么整数集合的底层实现就一直是用 int16_t 类型的数组,只有在我们要将 int32_t 类型或 int64_t 类型的元素添加到集合时,才会对数组进行升级操作。 因此,整数集合升级的好处是**节省内存资源**。 > 整数集合支持降级操作吗? **不支持降级操作**,一旦对数组进行了升级,就会一直保持升级后的状态。比如前面的升级操作的例子,如果删除了 65535 元素,整数集合的数组还是 int32_t 类型的,并不会因此降级为 int16_t 类型。 --- ## 跳表 Redis 只有 Zset 对象的底层实现用到了跳表,跳表的优势是能支持平均 O(logN) 复杂度的节点查找。 zset 结构体里有两个数据结构:一个是跳表,一个是哈希表。这样的好处是既能进行高效的范围查询,也能进行高效单点查询。 ```c typedef struct zset { dict *dict; zskiplist *zsl; } zset; ``` Zset 对象在执行数据插入或是数据更新的过程中,会依次在跳表和哈希表中插入或更新相应的数据,从而保证了跳表和哈希表中记录的信息一致。 Zset 对象能支持范围查询(如 ZRANGEBYSCORE 操作),这是因为它的数据结构设计采用了跳表,而又能以常数复杂度获取元素权重(如 ZSCORE 操作),这是因为它同时采用了哈希表进行索引。 可能很多人会奇怪,为什么我开头说 Zset 对象的底层数据结构是「压缩列表」或者「跳表」,而没有说哈希表呢? Zset 对象在使用跳表作为数据结构的时候,是使用由「哈希表 + 跳表」组成的 struct zset,但是我们讨论的时候,都会说跳表是 Zset 对象的底层数据结构,而不会提及哈希表,是因为 struct zset 中的哈希表只是用于以常数复杂度获取元素权重,大部分操作都是跳表实现的。 接下来,详细的说下跳表。 ### 跳表结构设计 链表在查找元素的时候,因为需要逐一查找,所以查询效率非常低,时间复杂度是 O(N),于是就出现了跳表。**跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表**,这样的好处是能快读定位数据。 那跳表长什么样呢?我这里举个例子,下图展示了一个层级为 3 的跳表。 ![](https://img-blog.csdnimg.cn/img_convert/2ae0ed790c7e7403f215acb2bd82e884.png) 图中头节点有 L0~L2 三个头指针,分别指向了不同层级的节点,然后每个层级的节点都通过指针连接起来: - L0 层级共有 5 个节点,分别是节点 1、2、3、4、5; - L1 层级共有 3 个节点,分别是节点 2、3、5; - L2 层级只有 1 个节点,也就是节点 3。 如果我们要在链表中查找节点 4 这个元素,只能从头开始遍历链表,需要查找 4 次,而使用了跳表后,只需要查找 2 次就能定位到节点 4,因为可以在头节点直接从 L2 层级跳到节点 3,然后再往前遍历找到节点 4。 可以看到,这个查找过程就是在多个层级上跳来跳去,最后定位到元素。当数据量很大时,跳表的查找复杂度就是 O(logN)。 那跳表节点是怎么实现多层级的呢?这就需要看「跳表节点」的数据结构了,如下: ```c typedef struct zskiplistNode { //Zset 对象的元素值 sds ele; //元素权重值 double score; //后向指针 struct zskiplistNode *backward; //节点的 level 数组,保存每层上的前向指针和跨度 struct zskiplistLevel { struct zskiplistNode *forward; unsigned long span; } level[]; } zskiplistNode; ``` Zset 对象要同时保存「元素」和「元素的权重」,对应到跳表节点结构里就是 sds 类型的 ele 变量和 double 类型的 score 变量。每个跳表节点都有一个后向指针(struct zskiplistNode *backward),指向前一个节点,目的是为了方便从跳表的尾节点开始访问节点,这样倒序查找时很方便。 跳表是一个带有层级关系的链表,而且每一层级可以包含多个节点,每一个节点通过指针连接起来,实现这一特性就是靠跳表节点结构体中的**zskiplistLevel 结构体类型的 level 数组**。 level 数组中的每一个元素代表跳表的一层,也就是由 zskiplistLevel 结构体表示,比如 leve[0] 就表示第一层,leve[1] 就表示第二层。zskiplistLevel 结构体里定义了「指向下一个跳表节点的指针」和「跨度」,跨度时用来记录两个节点之间的距离。 比如,下面这张图,展示了各个节点的跨度。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/3层跳表-跨度.png) 第一眼看到跨度的时候,以为是遍历操作有关,实际上并没有任何关系,遍历操作只需要用前向指针(struct zskiplistNode *forward)就可以完成了。 **跨度实际上是为了计算这个节点在跳表中的排位**。具体怎么做的呢?因为跳表中的节点都是按序排列的,那么计算某个节点排位的时候,从头节点点到该结点的查询路径上,将沿途访问过的所有层的跨度累加起来,得到的结果就是目标节点在跳表中的排位。 举个例子,查找图中节点 3 在跳表中的排位,从头节点开始查找节点 3,查找的过程只经过了一个层(L2),并且层的跨度是 3,所以节点 3 在跳表中的排位是 3。 另外,图中的头节点其实也是 zskiplistNode 跳表节点,只不过头节点的后向指针、权重、元素值都没有用到,所以图中省略了这部分。 问题来了,由谁定义哪个跳表节点是头节点呢?这就介绍「跳表」结构体了,如下所示: ```c typedef struct zskiplist { struct zskiplistNode *header, *tail; unsigned long length; int level; } zskiplist; ``` 跳表结构里包含了: - 跳表的头尾节点,便于在 O(1) 时间复杂度内访问跳表的头节点和尾节点; - 跳表的长度,便于在 O(1) 时间复杂度获取跳表节点的数量; - 跳表的最大层数,便于在 O(1) 时间复杂度获取跳表中层高最大的那个节点的层数量; ### 跳表节点查询过程 查找一个跳表节点的过程时,跳表会从头节点的最高层开始,逐一遍历每一层。在遍历某一层的跳表节点时,会用跳表节点中的 SDS 类型的元素和元素的权重来进行判断,共有两个判断条件: - 如果当前节点指向的下一个节点(forward指向的节点)的权重「小于」要查找的权重时,跳表就会访问该层上的下一个节点。 - 如果当前节点指向的下一个节点(forward指向的节点)的权重「等于」要查找的权重时,并且下一个节点的 SDS 类型数据「小于」要查找的数据时,跳表就会访问该层上的下一个节点。 如果上面两个条件都不满足,或者下一个节点为空时,跳表就会使用目前遍历到的节点的 level 数组里的下一层指针,然后沿着下一层指针继续查找,这就相当于跳到了下一层接着查找。 举个例子,下图有个 3 层级的跳表。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/数据类型/3层跳表-跨度.drawio.png) 如果要查找「元素:abcd,权重:4」的节点,查找的过程是这样的: - 先从头节点的最高层开始,L2 指向了「元素:abc,权重:3」节点,这个节点的权重比要查找节点的小,所以要访问该层上的下一个节点; - 但是该层的下一个节点是空节点(leve[2]指向的是空节点),于是就会跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[1]; - 「元素:abc,权重:3」节点的 leve[1] 的下一个指针指向了「元素:abcde,权重:4」的节点,然后将其和要查找的节点比较。虽然「元素:abcde,权重:4」的节点的权重和要查找的权重相同,但是当前节点的 SDS 类型数据「大于」要查找的数据,所以会继续跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[0]; - 「元素:abc,权重:3」节点的 leve[0] 的下一个指针指向了「元素:abcd,权重:4」的节点,该节点正是要查找的节点,查询结束。 ### 跳表节点层数设置 跳表的相邻两层的节点数量的比例会影响跳表的查询性能。 举个例子,下图的跳表,第二层的节点数量只有 1 个,而第一层的节点数量有 6 个。 ![](https://img-blog.csdnimg.cn/img_convert/2802786ab4f52c1e248904e5cef33a74.png) 这时,如果想要查询节点 6,那基本就跟链表的查询复杂度一样,就需要在第一层的节点中依次顺序查找,复杂度就是 O(N) 了。所以,为了降低查询复杂度,我们就需要维持相邻层结点数间的关系。 **跳表的相邻两层的节点数量最理想的比例是 2:1,查找复杂度可以降低到 O(logN)**。 下图的跳表就是,相邻两层的节点数量的比例是 2 : 1。 ![](https://img-blog.csdnimg.cn/img_convert/cdc14698f629c74bf5a239cc8a611aeb.png) > 那怎样才能维持相邻两层的节点数量的比例为 2 : 1 呢? 如果采用新增节点或者删除节点时,来调整跳表节点以维持比例的方法的话,会带来额外的开销。 Redis 则采用一种巧妙的方法是,**跳表在创建节点的时候,随机生成每个节点的层数**,并没有严格维持相邻两层的节点数量比例为 2 : 1 的情况。 具体的做法是,**跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数**。 这样的做法,相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 64。 虽然我前面讲解跳表的时候,图中的跳表的「头节点」都是 3 层高,但是其实**如果层高最大限制是 64,那么在创建跳表「头节点」的时候,就会直接创建 64 层高的头节点**。 如下代码,创建跳表时,头节点的 level 数组有 ZSKIPLIST_MAXLEVEL 个元素(层),节点不存储任何 member 和 score 值,level 数组元素的 forward 都指向 NULL,span 值都为 0。 ```c /* Create a new skiplist. */ zskiplist *zslCreate(void) { int j; zskiplist *zsl; zsl = zmalloc(sizeof(*zsl)); zsl->level = 1; zsl->length = 0; zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL); for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) { zsl->header->level[j].forward = NULL; zsl->header->level[j].span = 0; } zsl->header->backward = NULL; zsl->tail = NULL; return zsl; } ``` 其中,ZSKIPLIST_MAXLEVEL 定义的是最高的层数,Redis 7.0 定义为 32,Redis 5.0 定义为 64,Redis 3.0 定义为 32。 ### 为什么用跳表而不用平衡树? 这里插一个常见的面试题:为什么 Zset 的实现用跳表而不用平衡树(如 AVL 树、红黑树等)? 对于[这个问题](https://news.ycombinator.com/item?id=1171423),Redis 的作者 @antirez 是怎么说的: > There are a few reasons: > > 1) They are not very memory intensive. It's up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees. > > 2) A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees. > > 3) They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code. 简单翻译一下,主要是从内存占用、对范围查找的支持、实现难易程度这三方面总结的原因: - 它们不是非常内存密集型的。基本上由你决定。改变关于节点具有给定级别数的概率的参数将使其比 btree 占用更少的内存。 - Zset 经常需要执行 ZRANGE 或 ZREVRANGE 的命令,即作为链表遍历跳表。通过此操作,跳表的缓存局部性至少与其他类型的平衡树一样好。 - 它们更易于实现、调试等。例如,由于跳表的简单性,我收到了一个补丁(已经在 Redis master 中),其中扩展了跳表,在 O(log(N)) 中实现了 ZRANK。它只需要对代码进行少量修改。 我再详细补充点: - **从内存占用上来比较,跳表比平衡树更灵活一些**。平衡树每个节点包含 2 个指针(分别指向左右子树),而跳表每个节点包含的指针数目平均为 1/(1-p),具体取决于参数 p 的大小。如果像 Redis 里的实现一样,取 p=1/4,那么平均每个节点包含 1.33 个指针,比平衡树更有优势。 - **在做范围查找的时候,跳表比平衡树操作要简单**。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在跳表上进行范围查找就非常简单,只需要在找到小值之后,对第 1 层链表进行若干步的遍历就可以实现。 - **从算法实现难度上来比较,跳表比平衡树要简单得多**。平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而跳表的插入和删除只需要修改相邻节点的指针,操作简单又快速。 ## quicklist 在 Redis 3.0 之前,List 对象的底层数据结构是双向链表或者压缩列表。然后在 Redis 3.2 的时候,List 对象的底层改由 quicklist 数据结构实现。 其实 quicklist 就是「双向链表 + 压缩列表」组合,因为一个 quicklist 就是一个链表,而链表中的每个元素又是一个压缩列表。 在前面讲压缩列表的时候,我也提到了压缩列表的不足,虽然压缩列表是通过紧凑型的内存布局节省了内存开销,但是因为它的结构设计,如果保存的元素数量增加,或者元素变大了,压缩列表会有「连锁更新」的风险,一旦发生,会造成性能下降。 quicklist 解决办法,**通过控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。因为压缩列表元素越少或越小,连锁更新带来的影响就越小,从而提供了更好的访问性能。** ### quicklist 结构设计 quicklist 的结构体跟链表的结构体类似,都包含了表头和表尾,区别在于 quicklist 的节点是 quicklistNode。 ```c typedef struct quicklist { //quicklist 的链表头 quicklistNode *head; //quicklist 的链表头 //quicklist 的链表尾 quicklistNode *tail; //所有压缩列表中的总元素个数 unsigned long count; //quicklistNodes 的个数 unsigned long len; ... } quicklist; ``` 接下来看看,quicklistNode 的结构定义: ```c typedef struct quicklistNode { //前一个 quicklistNode struct quicklistNode *prev; //前一个 quicklistNode //下一个 quicklistNode struct quicklistNode *next; //后一个 quicklistNode //quicklistNode 指向的压缩列表 unsigned char *zl; //压缩列表的的字节大小 unsigned int sz; //压缩列表的元素个数 unsigned int count : 16; //ziplist 中的元素个数 .... } quicklistNode; ``` 可以看到,quicklistNode 结构体里包含了前一个节点和下一个节点指针,这样每个 quicklistNode 形成了一个双向链表。但是链表节点的元素不再是单纯保存元素值,而是保存了一个压缩列表,所以 quicklistNode 结构体里有个指向压缩列表的指针 *zl。 我画了一张图,方便你理解 quicklist 数据结构。 ![](https://img-blog.csdnimg.cn/img_convert/f46cbe347f65ded522f1cc3fd8dba549.png) 在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 quicklistNode 结构。 quicklist 会控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来规避潜在的连锁更新的风险,但是这并没有完全解决连锁更新的问题。 ## listpack quicklist 虽然通过控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来减少连锁更新带来的性能影响,但是并没有完全解决连锁更新的问题。 因为 quicklistNode 还是用了压缩列表来保存元素,压缩列表连锁更新的问题,来源于它的结构设计,所以要想彻底解决这个问题,需要设计一个新的数据结构。 于是,Redis 在 5.0 新设计一个数据结构叫 listpack,目的是替代压缩列表,它最大特点是 listpack 中每个节点不再包含前一个节点的长度了,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患。 **我看了 Redis 的 Github,在最新 6.2 发行版本中,Redis Hash 对象、ZSet 对象的底层数据结构的压缩列表还未被替换成 listpack,而 Redis 的最新代码(还未发布版本)已经将所有用到压缩列表底层数据结构的 Redis 对象替换成 listpack 数据结构来实现,估计不久将来,Redis 就会发布一个将压缩列表为 listpack 的发行版本**。 ### listpack 结构设计 listpack 采用了压缩列表的很多优秀的设计,比如还是用一块连续的内存空间来紧凑地保存数据,并且为了节省内存的开销,listpack 节点会采用不同的编码方式保存不同大小的数据。 我们先看看 listpack 结构: ![](https://img-blog.csdnimg.cn/img_convert/4d2dc376b5fd68dae70d9284ae82b73a.png) listpack 头包含两个属性,分别记录了 listpack 总字节数和元素数量,然后 listpack 末尾也有个结尾标识。图中的 listpack entry 就是 listpack 的节点了。 每个 listpack 节点结构如下: ![](https://img-blog.csdnimg.cn/img_convert/c5fb0a602d4caaca37ff0357f05b0abf.png) 主要包含三个方面内容: - encoding,定义该元素的编码类型,会对不同长度的整数和字符串进行编码; - data,实际存放的数据; - len,encoding+data 的总长度; 可以看到,**listpack 没有压缩列表中记录前一个节点长度的字段了,listpack 只记录当前节点的长度,当我们向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题**。 ---- 参考资料: - 《Redis 设计与实现》 - 《Redis 源码剖析与实战》 --- ## 总结 终于完工了,松一口气。 好久没写那么长的图解技术文啦,这次潇潇洒洒写了 1.5 万字 + 画了 40 多张图,花费了不少时间,又是看书,又是看源码。 希望这篇文章,能帮你破除 Redis 数据结构的迷雾! ## 读者问答 > 压缩列表的 entry 为什么要保存 prevlen 呢?listpack 改成 len 之后不会影响功能吗? 压缩列表的 entry 保存 prevlen 是为了实现节点从后往前遍历,知道前一个节点的长度,就可以计算前一个节点的偏移量。 listpack 一样可以支持从后往前遍历的。详细的算法可以看:https://github.com/antirez/listpack/blob/master/listpack.c 里的 lpDecodeBacklen 函数,lpDecodeBacklen 函数就可以从当前列表项起始位置的指针开始,向左逐个字节解析,得到前一项的 entry-len 值。 ---- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: redis/module/strategy.md ================================================ # Redis 过期删除策略和内存淘汰策略有什么区别? 大家好,我是小林。 Redis 的「内存淘汰策略」和「过期删除策略」,很多小伙伴容易混淆,这两个机制虽然都是做删除的操作,但是触发的条件和使用的策略都是不同的。 今天就跟大家理一理,「内存淘汰策略」和「过期删除策略」。 发车! ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/过期策略/提纲.jpg) ## 过期删除策略 Redis 是可以对 key 设置过期时间的,因此需要有相应的机制将已过期的键值对删除,而做这个工作的就是过期键值删除策略。 ### 如何设置过期时间? 先说一下对 key 设置过期时间的命令。 设置 key 过期时间的命令一共有 4 个: - `expire `:设置 key 在 n 秒后过期,比如 expire key 100 表示设置 key 在 100 秒后过期; - `pexpire `:设置 key 在 n 毫秒后过期,比如 pexpire key2 100000 表示设置 key2 在 100000 毫秒(100 秒)后过期。 - `expireat `:设置 key 在某个时间戳(精确到秒)之后过期,比如 expireat key3 1655654400 表示 key3 在时间戳 1655654400 后过期(精确到秒); - `pexpireat `:设置 key 在某个时间戳(精确到毫秒)之后过期,比如 pexpireat key4 1655654400000 表示 key4 在时间戳 1655654400000 后过期(精确到毫秒) 当然,在设置字符串时,也可以同时对 key 设置过期时间,共有 3 种命令: - `set ex ` :设置键值对的时候,同时指定过期时间(精确到秒); - `set px ` :设置键值对的时候,同时指定过期时间(精确到毫秒); - `setex ` :设置键值对的时候,同时指定过期时间(精确到秒)。 如果你想查看某个 key 剩余的存活时间,可以使用 `TTL ` 命令。 ```bash # 设置键值对的时候,同时指定过期时间位 60 秒 > setex key1 60 value1 OK # 查看 key1 过期时间还剩多少 > ttl key1 (integer) 56 > ttl key1 (integer) 52 ``` 如果突然反悔,取消 key 的过期时间,则可以使用 `PERSIST ` 命令。 ```bash # 取消 key1 的过期时间 > persist key1 (integer) 1 # 使用完 persist 命令之后, # 查下 key1 的存活时间结果是 -1,表明 key1 永不过期 > ttl key1 (integer) -1 ``` ### 如何判定 key 已过期了? 每当我们对一个 key 设置了过期时间时,Redis 会把该 key 带上过期时间存储到一个**过期字典**(expires dict)中,也就是说「过期字典」保存了数据库中所有 key 的过期时间。 过期字典存储在 redisDb 结构中,如下: ```c typedef struct redisDb { dict *dict; /* 数据库键空间,存放着所有的键值对 */ dict *expires; /* 键的过期时间 */ .... } redisDb; ``` 过期字典数据结构结构如下: - 过期字典的 key 是一个指针,指向某个键对象; - 过期字典的 value 是一个 long long 类型的整数,这个整数保存了 key 的过期时间; 过期字典的数据结构如下图所示: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/过期策略/过期字典数据结构.png) 字典实际上是哈希表,哈希表的最大好处就是让我们可以用 O(1) 的时间复杂度来快速查找。当我们查询一个 key 时,Redis 首先检查该 key 是否存在于过期字典中: - 如果不在,则正常读取键值; - 如果存在,则会获取该 key 的过期时间,然后与当前系统时间进行比对,如果比系统时间大,那就没有过期,否则判定该 key 已过期。 过期键判断流程如下图所示: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/过期策略/过期判断流程.jpg) ### 过期删除策略有哪些? 在说 Redis 过期删除策略之前,先跟大家介绍下,常见的三种过期删除策略: - 定时删除; - 惰性删除; - 定期删除; 接下来,分别分析它们的优缺点。 > 定时删除策略是怎么样的? 定时删除策略的做法是,**在设置 key 的过期时间时,同时创建一个定时事件,当时间到达时,由事件处理器自动执行 key 的删除操作。** 定时删除策略的**优点**: - 可以保证过期 key 会被尽快删除,也就是内存可以被尽快地释放。因此,定时删除对内存是最友好的。 定时删除策略的**缺点**: - 在过期 key 比较多的情况下,删除过期 key 可能会占用相当一部分 CPU 时间,在内存不紧张但 CPU 时间紧张的情况下,将 CPU 时间用于删除和当前任务无关的过期键上,无疑会对服务器的响应时间和吞吐量造成影响。所以,定时删除策略对 CPU 不友好。 > 惰性删除策略是怎么样的? 惰性删除策略的做法是,**不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。** 惰性删除策略的**优点**: - 因为每次访问时,才会检查 key 是否过期,所以此策略只会使用很少的系统资源,因此,惰性删除策略对 CPU 时间最友好。 惰性删除策略的**缺点**: - 如果一个 key 已经过期,而这个 key 又仍然保留在数据库中,那么只要这个过期 key 一直没有被访问,它所占用的内存就不会释放,造成了一定的内存空间浪费。所以,惰性删除策略对内存不友好。 > 定期删除策略是怎么样的? 定期删除策略的做法是,**每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期 key。** 定期删除策略的**优点**: - 通过限制删除操作执行的时长和频率,来减少删除操作对 CPU 的影响,同时也能删除一部分过期的数据减少了过期键对空间的无效占用。 定期删除策略的**缺点**: - 内存清理方面没有定时删除效果好,同时没有惰性删除使用的系统资源少。 - 难以确定删除操作执行的时长和频率。如果执行的太频繁,定期删除策略变得和定时删除策略一样,对 CPU 不友好;如果执行的太少,那又和惰性删除一样了,过期 key 占用的内存不会及时得到释放。 ### Redis 过期删除策略是什么? 前面介绍了三种过期删除策略,每一种都有优缺点,仅使用某一个策略都不能满足实际需求。 所以, **Redis 选择「惰性删除 + 定期删除」这两种策略配和使用**,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。 > Redis 是怎么实现惰性删除的? Redis 的惰性删除策略由 db.c 文件中的 `expireIfNeeded` 函数实现,代码如下: ```c int expireIfNeeded(redisDb *db, robj *key) { // 判断 key 是否过期 if (!keyIsExpired(db,key)) return 0; .... /* 删除过期键 */ .... // 如果 server.lazyfree_lazy_expire 为 1 表示异步删除,反之同步删除; return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) : dbSyncDelete(db,key); } ``` Redis 在访问或者修改 key 之前,都会调用 expireIfNeeded 函数对其进行检查,检查 key 是否过期: - 如果过期,则删除该 key,至于选择异步删除,还是选择同步删除,根据 `lazyfree-lazy-expire` 参数配置决定(Redis 4.0 版本开始提供参数),然后返回 null 客户端; - 如果没有过期,不做任何处理,然后返回正常的键值对给客户端; 惰性删除的流程图如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/过期策略/惰性删除.jpg) > Redis 是怎么实现定期删除的? 再回忆一下,定期删除策略的做法:**每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期 key。** *1、这个间隔检查的时间是多长呢?* 在 Redis 中,默认每秒进行 10 次过期检查一次数据库,此配置可通过 Redis 的配置文件 redis.conf 进行配置,配置键为 hz 它的默认值是 hz 10。 特别强调下,每次检查数据库并不是遍历过期字典中的所有 key,而是从数据库中随机抽取一定数量的 key 进行过期检查。 *2、随机抽查的数量是多少呢?* 我查了下源码,定期删除的实现在 expire.c 文件下的 `activeExpireCycle` 函数中,其中随机抽查的数量由 `ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP` 定义的,它是写死在代码中的,数值是 20。 也就是说,数据库每轮抽查时,会随机选择 20 个 key 判断是否过期。 接下来,详细说说 Redis 的定期删除的流程: 1. 从过期字典中随机抽取 20 个 key; 1. 检查这 20 个 key 是否过期,并删除已过期的 key; 1. 如果本轮检查的已过期 key 的数量,超过 5 个(20/4),也就是「已过期 key 的数量」占比「随机抽取 key 的数量」大于 25%,则继续重复步骤 1;如果已过期的 key 比例小于 25%,则停止继续删除过期 key,然后等待下一轮再检查。 可以看到,定期删除是一个循环的流程。 那 Redis 为了保证定期删除不会出现循环过度,导致线程卡死现象,为此增加了定期删除循环流程的时间上限,默认不会超过 25ms。 针对定期删除的流程,我写了个伪代码: ```c do { //已过期的数量 expired = 0; //随机抽取的数量 num = 20; while (num--) { //1. 从过期字典中随机抽取 1 个 key //2. 判断该 key 是否过期,如果已过期则进行删除,同时对 expired++ } // 超过时间限制则退出 if (timelimit_exit) return; /* 如果本轮检查的已过期 key 的数量,超过 25%,则继续随机抽查,否则退出本轮检查 */ } while (expired > 20/4); ``` 定期删除的流程如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/过期策略/定时删除流程.jpg) ## 内存淘汰策略 前面说的过期删除策略,是删除已过期的 key,而当 Redis 的运行内存已经超过 Redis 设置的最大内存之后,则会使用内存淘汰策略删除符合条件的 key,以此来保障 Redis 高效的运行。 ### 如何设置 Redis 最大运行内存? 在配置文件 redis.conf 中,可以通过参数 `maxmemory ` 来设定最大运行内存,只有在 Redis 的运行内存达到了我们设置的最大运行内存,才会触发内存淘汰策略。 不同位数的操作系统,maxmemory 的默认值是不同的: - 在 64 位操作系统中,maxmemory 的默认值是 0,表示没有内存大小限制,那么不管用户存放多少数据到 Redis 中,Redis 也不会对可用内存进行检查,直到 Redis 实例因内存不足而崩溃也无作为。 - 在 32 位操作系统中,maxmemory 的默认值是 3G,因为 32 位的机器最大只支持 4GB 的内存,而系统本身就需要一定的内存资源来支持运行,所以 32 位操作系统限制最大 3 GB 的可用内存是非常合理的,这样可以避免因为内存不足而导致 Redis 实例崩溃。 ### Redis 内存淘汰策略有哪些? Redis 内存淘汰策略共有八种,这八种策略大体分为「不进行数据淘汰」和「进行数据淘汰」两类策略。 *1、不进行数据淘汰的策略* **noeviction**(Redis3.0 之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,这时如果有新的数据写入,则会触发 OOM,但是如果没有数据写入的话,只是单纯的查询或者删除操作的话,还是可以正常工作。 *2、进行数据淘汰的策略* 针对「进行数据淘汰」这一类策略,又可以细分为「在设置了过期时间的数据中进行淘汰」和「在所有数据范围内进行淘汰」这两类策略。 在设置了过期时间的数据中进行淘汰: - **volatile-random**:随机淘汰设置了过期时间的任意键值; - **volatile-ttl**:优先淘汰更早过期的键值。 - **volatile-lru**(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值; - **volatile-lfu**(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值; 在所有数据范围内进行淘汰: - **allkeys-random**:随机淘汰任意键值; - **allkeys-lru**:淘汰整个键值中最久未使用的键值; - **allkeys-lfu**(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。 > 如何查看当前 Redis 使用的内存淘汰策略? 可以使用 `config get maxmemory-policy` 命令,来查看当前 Redis 的内存淘汰策略,命令如下: ```bash 127.0.0.1:6379> config get maxmemory-policy 1) "maxmemory-policy" 2) "noeviction" ``` 可以看出,当前 Redis 使用的是 `noeviction` 类型的内存淘汰策略,它是 Redis 3.0 之后默认使用的内存淘汰策略,表示当运行内存超过最大设置内存时,不淘汰任何数据,但新增操作会报错。 > 如何修改 Redis 内存淘汰策略? 设置内存淘汰策略有两种方法: - 方式一:通过“`config set maxmemory-policy <策略>`”命令设置。它的优点是设置之后立即生效,不需要重启 Redis 服务,缺点是重启 Redis 之后,设置就会失效。 - 方式二:通过修改 Redis 配置文件修改,设置“`maxmemory-policy <策略>`”,它的优点是重启 Redis 服务后配置不会丢失,缺点是必须重启 Redis 服务,设置才能生效。 ### LRU 算法和 LFU 算法有什么区别? LFU 内存淘汰算法是 Redis 4.0 之后新增内存淘汰策略,那为什么要新增这个算法?那肯定是为了解决 LRU 算法的问题。 接下来,就看看这两个算法有什么区别?Redis 又是如何实现这两个算法的? > 什么是 LRU 算法? **LRU** 全称是 Least Recently Used 翻译为**最近最少使用**,会选择淘汰最近最少使用的数据。 传统 LRU 算法的实现是基于「链表」结构,链表中的元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可,因为链表尾部的元素就代表最久未被使用的元素。 Redis 并没有使用这样的方式实现 LRU 算法,因为传统的 LRU 算法存在两个问题: - 需要用链表管理所有的缓存数据,这会带来额外的空间开销; - 当有数据被访问时,需要在链表上把该数据移动到头端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。 > Redis 是如何实现 LRU 算法的? Redis 实现的是一种**近似 LRU 算法**,目的是为了更好的节约内存,它的**实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间**。 当 Redis 进行内存淘汰时,会使用**随机采样的方式来淘汰数据**,它是随机取 5 个值(此值可配置),然后**淘汰最久没有使用的那个**。 Redis 实现的 LRU 算法的优点: - 不用为所有的数据维护一个大链表,节省了空间占用; - 不用在每次数据访问时都移动链表项,提升了缓存的性能; 但是 LRU 算法有一个问题,**无法解决缓存污染问题**,比如应用一次读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在 Redis 缓存中很长一段时间,造成缓存污染。 因此,在 Redis 4.0 之后引入了 LFU 算法来解决这个问题。 > 什么是 LFU 算法? LFU 全称是 Least Frequently Used 翻译为**最近最不常用**,LFU 算法是根据数据访问次数来淘汰数据的,它的核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。 所以,LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。这样就解决了偶尔被访问一次之后,数据留存在缓存中很长一段时间的问题,相比于 LRU 算法也更合理一些。 > Redis 是如何实现 LFU 算法的? LFU 算法相比于 LRU 算法的实现,多记录了「数据的访问频次」的信息。Redis 对象的结构如下: ```c typedef struct redisObject { ... // 24 bits,用于记录对象的访问信息 unsigned lru:24; ... } robj; ``` Redis 对象头中的 lru 字段,在 LRU 算法下和 LFU 算法下使用方式并不相同。 **在 LRU 算法中**,Redis 对象头的 24 bits 的 lru 字段是用来记录 key 的访问时间戳,因此在 LRU 模式下,Redis 可以根据对象头中的 lru 字段记录的值,来比较最后一次 key 的访问时间长,从而淘汰最久未被使用的 key。 **在 LFU 算法中**,Redis 对象头的 24 bits 的 lru 字段被分成两段来存储,高 16bit 存储 ldt(Last Decrement Time),低 8bit 存储 logc(Logistic Counter)。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/过期策略/lru字段.png) - ldt 是用来记录 key 的访问时间戳; - logc 是用来记录 key 的访问频次,它的值越小表示使用频率越低,越容易淘汰,每个新加入的 key 的 logc 初始值为 5。 注意,logc 并不是单纯的访问次数,而是访问频次(访问频率),因为 **logc 会随时间推移而衰减的**。 在每次 key 被访问时,会先对 logc 做一个衰减操作,衰减的值跟前后访问时间的差距有关系,如果上一次访问的时间与这一次访问的时间差距很大,那么衰减的值就越大,这样实现的 LFU 算法是根据**访问频率**来淘汰数据的,而不只是访问次数。访问频率需要考虑 key 的访问是多长时间段内发生的。key 的先前访问距离当前时间越长,那么这个 key 的访问频率相应地也就会降低,这样被淘汰的概率也会更大。 对 logc 做完衰减操作后,就开始对 logc 进行增加操作,增加操作并不是单纯的 + 1,而是根据概率增加,如果 logc 越大的 key,它的 logc 就越难再增加。 所以,Redis 在访问 key 时,对于 logc 是这样变化的: 1. 先按照上次访问距离当前的时长,来对 logc 进行衰减; 1. 然后,再按照一定概率增加 logc 的值 redis.conf 提供了两个配置项,用于调整 LFU 算法从而控制 logc 的增长和衰减: - `lfu-decay-time` 用于调整 logc 的衰减速度,它是一个以分钟为单位的数值,默认值为 1,lfu-decay-time 值越大,衰减越慢; - `lfu-log-factor` 用于调整 logc 的增长速度,lfu-log-factor 值越大,logc 增长越慢。 ## 总结 Redis 使用的过期删除策略是「惰性删除 + 定期删除」,删除的对象是已过期的 key。 ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/过期策略/过期删除策略.jpg) 内存淘汰策略是解决内存过大的问题,当 Redis 的运行内存超过最大运行内存时,就会触发内存淘汰策略,Redis 4.0 之后共实现了 8 种内存淘汰策略,我也对这 8 种的策略进行分类,如下: ![](https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/过期策略/内存淘汰策略.jpg) 完! 答应我,下次别再搞混了 --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: redis/storage/aof.md ================================================ # AOF 持久化是怎么实现的? ![](https://img-blog.csdnimg.cn/img_convert/40ddab878c91fb6bb0f92789196bfc67.png) ## AOF 日志 试想一下,如果 Redis 每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里,然后重启 Redis 的时候,先去读取这个文件里的命令,并且执行它,这不就相当于恢复了缓存数据了吗? ![](https://img-blog.csdnimg.cn/img_convert/6f0ab40396b7fc2c15e6f4487d3a0ad7.png) 这种保存写操作命令到日志的持久化方式,就是 Redis 里的 **AOF(*Append Only File*)** 持久化功能,**注意只会记录写操作命令,读操作命令是不会被记录的**,因为没意义。 在 Redis 中 AOF 持久化功能默认是不开启的,需要我们修改 `redis.conf` 配置文件中的以下参数: ![](https://img-blog.csdnimg.cn/img_convert/0e2d081af084c41802c7b5de8aa41bd4.png) AOF 日志文件其实就是普通的文本,我们可以通过 `cat` 命令查看里面的内容,不过里面的内容如果不知道一定的规则的话,可能会看不懂。 我这里以「*set name xiaolin*」命令作为例子,Redis 执行了这条命令后,记录在 AOF 日志里的内容如下图: ![](https://img-blog.csdnimg.cn/img_convert/337021a153944fd0f964ca834e34d0f2.png) 我这里给大家解释下。 「`*3`」表示当前命令有三个部分,每部分都是以「`$+数字`」开头,后面紧跟着具体的命令、键或值。然后,这里的「`数字`」表示这部分中的命令、键或值一共有多少字节。例如,「`$3 set`」表示这部分有 3 个字节,也就是「`set`」命令这个字符串的长度。 不知道大家注意到没有,Redis 是先执行写操作命令后,才将该命令记录到 AOF 日志里的,这么做其实有两个好处。 第一个好处,**避免额外的检查开销。** 因为如果先将写操作命令记录到 AOF 日志里,再执行该命令的话,如果当前的命令语法有问题,那么如果不进行命令语法检查,该错误的命令记录到 AOF 日志里后,Redis 在使用日志恢复数据时,就可能会出错。 而如果先执行写操作命令再记录日志的话,只有在该命令执行成功后,才将命令记录到 AOF 日志里,这样就不用额外的检查开销,保证记录在 AOF 日志里的命令都是可执行并且正确的。 第二个好处,**不会阻塞当前写操作命令的执行**,因为当写操作命令执行成功后,才会将命令记录到 AOF 日志。 当然,AOF 持久化功能也不是没有潜在风险。 第一个风险,执行写操作命令和记录日志是两个过程,那当 Redis 在还没来得及将命令写入到硬盘时,服务器发生宕机了,这个数据就会有**丢失的风险**。 第二个风险,前面说道,由于写操作命令执行成功后才记录到 AOF 日志,所以不会阻塞当前写操作命令的执行,但是**可能会给「下一个」命令带来阻塞风险**。 因为将命令写入到日志的这个操作也是在主进程完成的(执行命令也是在主进程),也就是说这两个操作是同步的。 ![](https://img-blog.csdnimg.cn/img_convert/28afd536c57a46447ddab0a2062abe84.png) 如果在将日志内容写入到硬盘时,服务器的硬盘的 I/O 压力太大,就会导致写硬盘的速度很慢,进而阻塞住了,也就会导致后续的命令无法执行。 认真分析一下,其实这两个风险都有一个共性,都跟「AOF 日志写回硬盘的时机」有关。 ## 三种写回策略 Redis 写入 AOF 日志的过程,如下图: ![](https://img-blog.csdnimg.cn/img_convert/4eeef4dd1bedd2ffe0b84d4eaa0dbdea.png) 我先来具体说说: 1. Redis 执行完写操作命令后,会将命令追加到 `server.aof_buf` 缓冲区; 2. 然后通过 write() 系统调用,将 aof_buf 缓冲区的数据写入到 AOF 文件,此时数据并没有写入到硬盘,而是拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘; 3. 具体内核缓冲区的数据什么时候写入到硬盘,由内核决定。 Redis 提供了 3 种写回硬盘的策略,控制的就是上面说的第三步的过程。 在 `redis.conf` 配置文件中的 `appendfsync` 配置项可以有以下 3 种参数可填: - **Always**,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘; - **Everysec**,这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘; - **No**,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。 这 3 种写回策略都无法能完美解决「主进程阻塞」和「减少数据丢失」的问题,因为两个问题是对立的,偏向于一边的话,就会要牺牲另外一边,原因如下: - Always 策略的话,可以最大程度保证数据不丢失,但是由于它每执行一条写操作命令就同步将 AOF 内容写回硬盘,所以是不可避免会影响主进程的性能; - No 策略的话,是交由操作系统来决定何时将 AOF 日志内容写回硬盘,相比于 Always 策略性能较好,但是操作系统写回硬盘的时机是不可预知的,如果 AOF 日志内容没有写回硬盘,一旦服务器宕机,就会丢失不定数量的数据。 - Everysec 策略的话,是折中的一种方式,避免了 Always 策略的性能开销,也比 No 策略更能避免数据丢失,当然如果上一秒的写操作命令日志没有写回到硬盘,发生了宕机,这一秒内的数据自然也会丢失。 大家根据自己的业务场景进行选择: - 如果要高性能,就选择 No 策略; - 如果要高可靠,就选择 Always 策略; - 如果允许数据丢失一点,但又想性能高,就选择 Everysec 策略。 我也把这 3 个写回策略的优缺点总结成了一张表格: ![](https://img-blog.csdnimg.cn/img_convert/98987d9417b2bab43087f45fc959d32a.png) 大家知道这三种策略是怎么实现的吗? 深入到源码后,你就会发现这三种策略只是在控制 `fsync()` 函数的调用时机。 当应用程序向文件写入数据时,内核通常先将数据复制到内核缓冲区中,然后排入队列,然后由内核决定何时写入硬盘。 ![](https://img-blog.csdnimg.cn/img_convert/f64829ffc2e9e006b090f9aae51035ee.png) 如果想要应用程序向文件写入数据后,能立马将数据同步到硬盘,就可以调用 `fsync()` 函数,这样内核就会将内核缓冲区的数据直接写入到硬盘,等到硬盘写操作完成后,该函数才会返回。 - Always 策略就是每次写入 AOF 文件数据后,就执行 fsync() 函数; - Everysec 策略就会创建一个异步任务来执行 fsync() 函数; - No 策略就是永不执行 fsync() 函数; ## AOF 重写机制 AOF 日志是一个文件,随着执行的写操作命令越来越多,文件的大小会越来越大。 如果当 AOF 日志文件过大就会带来性能问题,比如重启 Redis 后,需要读 AOF 文件的内容以恢复数据,如果文件过大,整个恢复的过程就会很慢。 所以,Redis 为了避免 AOF 文件越写越大,提供了 **AOF 重写机制**,当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。 AOF 重写机制是在重写时,读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到「新的 AOF 文件」,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件。 举个例子,在没有使用重写机制前,假设前后执行了「*set name xiaolin*」和「*set name xiaolincoding*」这两个命令的话,就会将这两个命令记录到 AOF 文件。 ![](https://img-blog.csdnimg.cn/img_convert/723d6c580c05400b3841bc69566dd61b.png) 但是**在使用重写机制后,就会读取 name 最新的 value(键值对) ,然后用一条「set name xiaolincoding」命令记录到新的 AOF 文件**,之前的第一个命令就没有必要记录了,因为它属于「历史」命令,没有作用了。这样一来,一个键值对在重写日志中只用一条命令就行了。 重写工作完成后,就会将新的 AOF 文件覆盖现有的 AOF 文件,这就相当于压缩了 AOF 文件,使得 AOF 文件体积变小了。 然后,在通过 AOF 日志恢复数据时,只用执行这条命令,就可以直接完成这个键值对的写入了。 所以,重写机制的妙处在于,尽管某个键值对被多条写命令反复修改,**最终也只需要根据这个「键值对」当前的最新状态,然后用一条命令去记录键值对**,代替之前记录这个键值对的多条命令,这样就减少了 AOF 文件中的命令数量。最后在重写工作完成后,将新的 AOF 文件覆盖现有的 AOF 文件。 这里说一下为什么重写 AOF 的时候,不直接复用现有的 AOF 文件,而是先写到新的 AOF 文件再覆盖过去。 因为**如果 AOF 重写过程中失败了,现有的 AOF 文件就会造成污染**,可能无法用于恢复使用。 所以 AOF 重写过程,先重写到新的 AOF 文件,重写失败的话,就直接删除这个文件就好,不会对现有的 AOF 文件造成影响。 ## AOF 后台重写 写入 AOF 日志的操作虽然是在主进程完成的,因为它写入的内容不多,所以一般不太影响命令的操作。 但是在触发 AOF 重写时,比如当 AOF 文件大于 64M 时,就会对 AOF 文件进行重写,这时是需要读取所有缓存的键值对数据,并为每个键值对生成一条命令,然后将其写入到新的 AOF 文件,重写完后,就把现在的 AOF 文件替换掉。 这个过程其实是很耗时的,所以重写的操作不能放在主进程里。 所以,Redis 的**重写 AOF 过程是由后台子进程 *bgrewriteaof* 来完成的**,这么做可以达到两个好处: - 子进程进行 AOF 重写期间,主进程可以继续处理命令请求,从而避免阻塞主进程; - 子进程带有主进程的数据副本(*数据副本怎么产生的后面会说*),这里使用子进程而不是线程,因为如果是使用线程,多线程之间会共享内存,那么在修改共享内存数据的时候,需要通过加锁来保证数据的安全,而这样就会降低性能。而使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程任意一方修改了该共享内存,就会发生「写时复制」,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全。 子进程是怎么拥有主进程一样的数据副本的呢? 主进程在通过 `fork` 系统调用生成 bgrewriteaof 子进程时,操作系统会把主进程的「**页表**」复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,而不会复制物理内存,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。 ![](https://img-blog.csdnimg.cn/img_convert/5a1f2a90b5f3821c19bea3b7a5f27fa1.png) 这样一来,子进程就共享了父进程的物理内存数据了,这样能够**节约物理内存资源**,页表对应的页表项的属性会标记该物理内存的权限为**只读**。 不过,当父进程或者子进程在向这个内存发起写操作时,CPU 就会触发**缺页中断**,这个缺页中断是由于违反权限导致的,然后操作系统会在「缺页异常处理函数」里进行**物理内存的复制**,并重新设置其内存映射关系,将父子进程的内存读写权限设置为**可读写**,最后才会对内存进行写操作,这个过程被称为「**写时复制 (*Copy On Write*)**」。 ![](https://img-blog.csdnimg.cn/img_convert/d4cfac545377b54dd035c775603b4936.png) 写时复制顾名思义,**在发生写操作的时候,操作系统才会去复制物理内存**,这样是为了防止 fork 创建子进程时,由于物理内存数据的复制时间过长而导致父进程长时间阻塞的问题。 当然,操作系统复制父进程页表的时候,父进程也是阻塞中的,不过页表的大小相比实际的物理内存小很多,所以通常复制页表的过程是比较快的。 不过,如果父进程的内存数据非常大,那自然页表也会很大,这时父进程在通过 fork 创建子进程的时候,阻塞的时间也越久。 所以,有两个阶段会导致阻塞父进程: - 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长; - 创建完子进程后,如果子进程或者父进程修改了共享数据,就会发生写时复制,这期间会拷贝物理内存,如果内存越大,自然阻塞的时间也越长; 触发重写机制后,主进程就会创建重写 AOF 的子进程,此时父子进程共享物理内存,重写子进程只会对这个内存进行只读,重写 AOF 子进程会读取数据库里的所有数据,并逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志(新的 AOF 文件)。 但是子进程重写过程中,主进程依然可以正常处理命令。 如果此时**主进程修改了已经存在 key-value,就会发生写时复制,注意这里只会复制主进程修改的物理内存数据,没修改物理内存还是与子进程共享的**。 所以如果这个阶段修改的是一个 bigkey,也就是数据量比较大的 key-value 的时候,这时复制的物理内存数据的过程就会比较耗时,有阻塞主进程的风险。 还有个问题,重写 AOF 日志过程中,如果主进程修改了已经存在 key-value,此时这个 key-value 数据在子进程的内存数据就跟主进程的内存数据不一致了,这时要怎么办呢? 为了解决这种数据不一致问题,Redis 设置了一个 **AOF 重写缓冲区**,这个缓冲区在创建 bgrewriteaof 子进程之后开始使用。 在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会**同时将这个写命令写入到「AOF 缓冲区」和「AOF 重写缓冲区」**。 ![在这里插入图片描述](https://img-blog.csdnimg.cn/202105270918298.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM0ODI3Njc0,size_16,color_FFFFFF,t_70#pic_center) 也就是说,在 bgrewriteaof 子进程执行 AOF 重写期间,主进程需要执行以下三个工作: - 执行客户端发来的命令; - 将执行后的写命令追加到「AOF 缓冲区」; - 将执行后的写命令追加到「AOF 重写缓冲区」; 当子进程完成 AOF 重写工作(*扫描数据库中所有数据,逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志*)后,会向主进程发送一条信号,信号是进程间通讯的一种方式,且是异步的。 主进程收到该信号后,会调用一个信号处理函数,该函数主要做以下工作: - 将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,使得新旧两个 AOF 文件所保存的数据库状态一致; - 新的 AOF 的文件进行改名,覆盖现有的 AOF 文件。 信号函数执行完后,主进程就可以继续像往常一样处理命令了。 在整个 AOF 后台重写过程中,除了发生写时复制会对主进程造成阻塞,还有信号处理函数执行时也会对主进程造成阻塞,在其他时候,AOF 后台重写都不会阻塞主进程。 ## 总结 这次小林给大家介绍了 Redis 持久化技术中的 AOF 方法,这个方法是每执行一条写操作命令,就将该命令以追加的方式写入到 AOF 文件,然后在恢复时,以逐一执行命令的方式来进行数据恢复。 Redis 提供了三种将 AOF 日志写回硬盘的策略,分别是 Always、Everysec 和 No,这三种策略在可靠性上是从高到低,而在性能上则是从低到高。 随着执行的命令越多,AOF 文件的体积自然也会越来越大,为了避免日志文件过大,Redis 提供了 AOF 重写机制,它会直接扫描数据中所有的键值对数据,然后为每一个键值对生成一条写操作命令,接着将该命令写入到新的 AOF 文件,重写完成后,就替换掉现有的 AOF 日志。重写的过程是由后台子进程完成的,这样可以使得主进程可以继续正常处理命令。 用 AOF 日志的方式来恢复数据其实是很慢的,因为 Redis 执行命令由单线程负责的,而 AOF 日志恢复数据的方式是顺序执行日志里的每一条命令,如果 AOF 日志很大,这个「重放」的过程就会很慢了。 ---- #### 参考资料 - 《Redis 设计与实现》 - 《Redis 核心技术与实战 - 极客时间》 - 《Redis 源码分析》 ---- ![](https://img-blog.csdnimg.cn/img_convert/3e6afdfd91db015a8155f54355e133de.png) ---- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: redis/storage/bigkey_aof_rdb.md ================================================ # Redis 大 Key 对持久化有什么影响? 大家好,我是小林。 上周有位读者字节一二面时,被问到:**Redis 的大 Key 对持久化有什么影响?** ![·](https://img-blog.csdnimg.cn/2ae06f60d9614da0be4729944b2a317c.png) Redis 的持久化方式有两种:AOF 日志和 RDB 快照。 所以接下来,针对这两种持久化方式具体分析分析。 ## 大 Key 对 AOF 日志的影响 > 先说说 AOF 日志三种写回磁盘的策略 Redis 提供了 3 种 AOF 日志写回硬盘的策略,分别是: - Always,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘; - Everysec,这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘; - No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。 这三种策略只是在控制 fsync() 函数的调用时机。 当应用程序向文件写入数据时,内核通常先将数据复制到内核缓冲区中,然后排入队列,然后由内核决定何时写入硬盘。 ![](https://img-blog.csdnimg.cn/def7d5328829470c9f3cfd15bbcc6814.png) 如果想要应用程序向文件写入数据后,能立马将数据同步到硬盘,就可以调用 fsync() 函数,这样内核就会将内核缓冲区的数据直接写入到硬盘,等到硬盘写操作完成后,该函数才会返回。 - Always 策略就是每次写入 AOF 文件数据后,就执行 fsync() 函数; - Everysec 策略就会创建一个异步任务来执行 fsync() 函数; - No 策略就是永不执行 fsync() 函数; > 分别说说这三种策略,在持久化大 Key 的时候,会影响什么? 在使用 Always 策略的时候,主线程在执行完命令后,会把数据写入到 AOF 日志文件,然后会调用 fsync() 函数,将内核缓冲区的数据直接写入到硬盘,等到硬盘写操作完成后,该函数才会返回。 **当使用 Always 策略的时候,如果写入是一个大 Key,主线程在执行 fsync() 函数的时候,阻塞的时间会比较久,因为当写入的数据量很大的时候,数据同步到硬盘这个过程是很耗时的**。 当使用 Everysec 策略的时候,由于是异步执行 fsync() 函数,所以大 Key 持久化的过程(数据同步磁盘)不会影响主线程。 当使用 No 策略的时候,由于永不执行 fsync() 函数,所以大 Key 持久化的过程不会影响主线程。 ## 大 Key 对 AOF 重写和 RDB 的影响 当 AOF 日志写入了很多的大 Key,AOF 日志文件的大小会很大,那么很快就会触发 **AOF 重写机制**。 AOF 重写机制和 RDB 快照(bgsave 命令)的过程,都会分别通过 `fork()` 函数创建一个子进程来处理任务。 在创建子进程的过程中,操作系统会把父进程的「页表」复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,而不会复制物理内存,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。 ![](https://img-blog.csdnimg.cn/06657cb93ffa4a24b8fc5b3069cb29bf.png) 这样一来,子进程就共享了父进程的物理内存数据了,这样能够节约物理内存资源,页表对应的页表项的属性会标记该物理内存的权限为**只读**。 随着 Redis 存在越来越多的大 Key,那么 Redis 就会占用很多内存,对应的页表就会越大。 在通过 `fork()` 函数创建子进程的时候,虽然不会复制父进程的物理内存,但是**内核会把父进程的页表复制一份给子进程,如果页表很大,那么这个复制过程是会很耗时的,那么在执行 fork 函数的时候就会发生阻塞现象**。 而且,fork 函数是由 Redis 主线程调用的,如果 fork 函数发生阻塞,那么意味着就会阻塞 Redis 主线程。由于 Redis 执行命令是在主线程处理的,所以当 Redis 主线程发生阻塞,就无法处理后续客户端发来的命令。 我们可以执行 `info` 命令获取到 latest_fork_usec 指标,表示 Redis 最近一次 fork 操作耗时。 ```sql # 最近一次 fork 操作耗时 latest_fork_usec:315 ``` 如果 fork 耗时很大,比如超过 1 秒,则需要做出优化调整: - 单个实例的内存占用控制在 10 GB 以下,这样 fork 函数就能很快返回。 - 如果 Redis 只是当作纯缓存使用,不关心 Redis 数据安全性问题,可以考虑关闭 AOF 和 AOF 重写,这样就不会调用 fork 函数了。 - 在主从架构中,要适当调大 repl-backlog-size,避免因为 repl_backlog_buffer 不够大,导致主节点频繁地使用全量同步的方式,全量同步的时候,是会创建 RDB 文件的,也就是会调用 fork 函数。 > 那什么时候会发生物理内存的复制呢? 当父进程或者子进程在向共享内存发起写操作时,CPU 就会触发**缺页中断**,这个缺页中断是由于违反权限导致的,然后操作系统会在「缺页异常处理函数」里进行物理内存的复制,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写,最后才会对内存进行写操作,这个过程被称为「**写时复制 (Copy On Write)**」。 ![](https://img-blog.csdnimg.cn/451024fe10374431aff6f93a8fed4638.png) 写时复制顾名思义,在发生写操作的时候,操作系统才会去复制物理内存,这样是为了防止 fork 创建子进程时,由于物理内存数据的复制时间过长而导致父进程长时间阻塞的问题。 如果创建完子进程后,**父进程对共享内存中的大 Key 进行了修改,那么内核就会发生写时复制,会把物理内存复制一份,由于大 Key 占用的物理内存是比较大的,那么在复制物理内存这一过程中,也是比较耗时的,于是父进程(主线程)就会发生阻塞**。 所以,有两个阶段会导致阻塞父进程: - 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长; - 创建完子进程后,如果子进程或者父进程修改了共享数据,就会发生写时复制,这期间会拷贝物理内存,如果内存越大,自然阻塞的时间也越长; 这里额外提一下,如果 **Linux 开启了内存大页,会影响 Redis 的性能的**。 Linux 内核从 2.6.38 开始支持内存大页机制,该机制支持 2MB 大小的内存页分配,而常规的内存页分配是按 4KB 的粒度来执行的。 如果采用了内存大页,那么即使客户端请求只修改 100B 的数据,在发生写时复制后,Redis 也需要拷贝 2MB 的大页。相反,如果是常规内存页机制,只用拷贝 4KB。 两者相比,你可以看到,每次写命令引起的**复制内存页单位放大了 512 倍,会拖慢写操作的执行时间,最终导致 Redis 性能变慢**。 那该怎么办呢?很简单,关闭内存大页(默认是关闭的)。 禁用方法如下: ```shell echo never > /sys/kernel/mm/transparent_hugepage/enabled ``` ## 总结 当 AOF 写回策略配置了 Always 策略,如果写入是一个大 Key,主线程在执行 fsync() 函数的时候,阻塞的时间会比较久,因为当写入的数据量很大的时候,数据同步到硬盘这个过程是很耗时的。 AOF 重写机制和 RDB 快照(bgsave 命令)的过程,都会分别通过 `fork()` 函数创建一个子进程来处理任务。会有两个阶段会导致阻塞父进程(主线程): - 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长; - 创建完子进程后,如果父进程修改了共享数据中的大 Key,就会发生写时复制,这期间会拷贝物理内存,由于大 Key 占用的物理内存会很大,那么在复制物理内存这一过程,就会比较耗时,所以有可能会阻塞父进程。 大 key 除了会影响持久化之外,还会有以下的影响。 - 客户端超时阻塞。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。 - 引发网络阻塞。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。 - 阻塞工作线程。如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。 - 内存分布不均。集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有大 key 的 Redis 节点占用内存多,QPS 也会比较大。 如何避免大 Key 呢? 最好在设计阶段,就把大 key 拆分成一个一个小 key。或者,定时检查 Redis 是否存在大 key,如果该大 key 是可以删除的,不要使用 DEL 命令删除,因为该命令删除过程会阻塞主线程,而是用 unlink 命令(Redis 4.0+)删除大 key,因为该命令的删除过程是异步的,不会阻塞主线程。 完! --- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png) ================================================ FILE: redis/storage/rdb.md ================================================ # RDB 快照是怎么实现的? 大家好,我是小林哥。 虽说 Redis 是内存数据库,但是它为数据的持久化提供了两个技术。 分别是「AOF 日志和 RDB 快照」。 这两种技术都会用各用一个日志文件来记录信息,但是记录的内容是不同的。 - AOF 文件的内容是操作命令; - RDB 文件的内容是二进制数据。 关于 AOF 持久化的原理我在上一篇已经介绍了,今天主要讲下 **RDB 快照**。 所谓的快照,就是记录某一个瞬间东西,比如当我们给风景拍照时,那一个瞬间的画面和信息就记录到了一张照片。 所以,RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据。 因此在 Redis 恢复数据时,RDB 恢复数据的效率会比 AOF 高些,因为直接将 RDB 文件读入内存就可以,不需要像 AOF 那样还需要额外执行操作命令的步骤才能恢复数据。 接下来,就来具体聊聊 RDB 快照。 ## 快照怎么用? 要熟悉一个东西,先看看怎么用是比较好的方式。 Redis 提供了两个命令来生成 RDB 文件,分别是 `save` 和 `bgsave`,他们的区别就在于是否在「主线程」里执行: - 执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,**会阻塞主线程**; - 执行了 bgsave 命令,会创建一个子进程来生成 RDB 文件,这样可以**避免主线程的阻塞**; RDB 文件的加载工作是在服务器启动时自动执行的,Redis 并没有提供专门用于加载 RDB 文件的命令。 Redis 还可以通过配置文件的选项来实现每隔一段时间自动执行一次 bgsave 命令,默认会提供以下配置: ```plain save 900 1 save 300 10 save 60 10000 ``` 别看选项名叫 save,实际上执行的是 bgsave 命令,也就是会创建子进程来生成 RDB 快照文件。 只要满足上面条件的任意一个,就会执行 bgsave,它们的意思分别是: - 900 秒之内,对数据库进行了至少 1 次修改; - 300 秒之内,对数据库进行了至少 10 次修改; - 60 秒之内,对数据库进行了至少 10000 次修改。 这里提一点,Redis 的快照是**全量快照**,也就是说每次执行快照,都是把内存中的「所有数据」都记录到磁盘中。 所以可以认为,执行快照是一个比较重的操作,如果频率太频繁,可能会对 Redis 性能产生影响。如果频率太低,服务器故障时,丢失的数据会更多。 通常可能设置至少 5 分钟才保存一次快照,这时如果 Redis 出现宕机等情况,则意味着最多可能丢失 5 分钟数据。 这就是 RDB 快照的缺点,在服务器发生故障时,丢失的数据会比 AOF 持久化的方式更多,因为 RDB 快照是全量快照的方式,因此执行的频率不能太频繁,否则会影响 Redis 性能,而 AOF 日志可以以秒级的方式记录操作命令,所以丢失的数据就相对更少。 ## 执行快照时,数据能被修改吗? 那问题来了,执行 bgsave 过程中,由于是交给子进程来构建 RDB 文件,主线程还是可以继续工作的,此时主线程可以修改数据吗? 如果不可以修改数据的话,那这样性能一下就降低了很多。如果可以修改数据,又是如何做到到呢? 直接说结论吧,执行 bgsave 过程中,Redis 依然**可以继续处理操作命令**的,也就是数据是能被修改的。 那具体如何做到到呢?关键的技术就在于**写时复制技术(Copy-On-Write, COW)。** 执行 bgsave 命令的时候,会通过 `fork()` 创建子进程,此时子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个。 ![图片](https://img-blog.csdnimg.cn/img_convert/c34a9d1f58d602ff1fe8601f7270baa7.png) 只有在发生修改内存数据的情况时,物理内存才会被复制一份。 ![图片](https://img-blog.csdnimg.cn/img_convert/ebd620db8a1af66fbeb8f4d4ef6adc68.png) 这样的目的是为了减少创建子进程时的性能损耗,从而加快创建子进程的速度,毕竟创建子进程的过程中,是会阻塞主线程的。 所以,创建 bgsave 子进程后,由于共享父进程的所有内存数据,于是就可以直接读取主线程(父进程)里的内存数据,并将数据写入到 RDB 文件。 当主线程(父进程)对这些共享的内存数据也都是只读操作,那么,主线程(父进程)和 bgsave 子进程相互不影响。 但是,如果主线程(父进程)要**修改共享数据里的某一块数据**(比如键值对 `A`)时,就会发生写时复制,于是这块数据的**物理内存就会被复制一份(键值对 `A'`)**,然后**主线程在这个数据副本(键值对 `A'`)进行修改操作**。与此同时,**bgsave 子进程可以继续把原来的数据(键值对 `A`)写入到 RDB 文件**。 就是这样,Redis 使用 bgsave 对当前内存中的所有数据做快照,这个操作是由 bgsave 子进程在后台完成的,执行时不会阻塞主线程,这就使得主线程同时可以修改数据。 细心的同学,肯定发现了,bgsave 快照过程中,如果主线程修改了共享数据,**发生了写时复制后,RDB 快照保存的是原本的内存数据**,而主线程刚修改的数据,是没办法在这一时间写入 RDB 文件的,只能交由下一次的 bgsave 快照。 所以 Redis 在使用 bgsave 快照过程中,如果主线程修改了内存数据,不管是否是共享的内存数据,RDB 快照都无法写入主线程刚修改的数据,因为此时主线程(父进程)的内存数据和子进程的内存数据已经分离了,子进程写入到 RDB 文件的内存数据只能是原本的内存数据。 如果系统恰好在 RDB 快照文件创建完毕后崩溃了,那么 Redis 将会丢失主线程在快照期间修改的数据。 另外,写时复制的时候会出现这么个极端的情况。 在 Redis 执行 RDB 持久化期间,刚 fork 时,主进程和子进程共享同一物理内存,但是途中主进程处理了写操作,修改了共享内存,于是当前被修改的数据的物理内存就会被复制一份。 那么极端情况下,**如果所有的共享内存都被修改,则此时的内存占用是原先的 2 倍。** 所以,针对写操作多的场景,我们要留意下快照过程中内存的变化,防止内存被占满了。 ## RDB 和 AOF 合体 尽管 RDB 比 AOF 的数据恢复速度快,但是快照的频率不好把握: - 如果频率太低,两次快照间一旦服务器发生宕机,就可能会丢失比较多的数据; - 如果频率太高,频繁写入磁盘和创建子进程会带来额外的性能开销。 那有没有什么方法不仅有 RDB 恢复速度快的优点和,又有 AOF 丢失数据少的优点呢? 当然有,那就是将 RDB 和 AOF 合体使用,这个方法是在 Redis 4.0 提出的,该方法叫**混合使用 AOF 日志和内存快照**,也叫混合持久化。 如果想要开启混合持久化功能,可以在 Redis 配置文件将下面这个配置项设置成 yes: ```plain aof-use-rdb-preamble yes ``` 混合持久化工作在 **AOF 日志重写过程**。 当开启了混合持久化时,在 AOF 重写日志时,`fork` 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。 也就是说,使用了混合持久化,AOF 文件的**前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据**。 ![图片](https://img-blog.csdnimg.cn/img_convert/f67379b60d151262753fec3b817b8617.png) 这样的好处在于,重启 Redis 加载数据的时候,由于前半部分是 RDB 内容,这样**加载的时候速度会很快**。 加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的操作命令,可以使得**数据更少的丢失**。 ---- 最新的图解文章都在公众号首发,别忘记关注哦!!如果你想加入百人技术交流群,扫码下方二维码回复「加群」。 ![img](https://cdn.xiaolincoding.com/gh/xiaolincoder/ImageHost3@main/%E5%85%B6%E4%BB%96/%E5%85%AC%E4%BC%97%E5%8F%B7%E4%BB%8B%E7%BB%8D.png)