mirror of
https://github.com/Snailclimb/JavaGuide
synced 2025-06-16 18:10:13 +08:00
commit
f759aff852
201
LICENSE
Normal file
201
LICENSE
Normal file
@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
95
README.md
95
README.md
@ -10,9 +10,6 @@
|
||||
更多原创内容和干货分享:
|
||||
|
||||
1. [公众号—JavaGuide](#公众号) : 最新原创文章+免费领取本文档配套的《Java面试突击》以及Java工程师必备学习资源)
|
||||
2. [微信](#联系我) :如果需要和我交流的话可以加我私人微信(ps: 有问题的话也可以问,我会尽量回答大家,我很随和的,哈哈!另外,账号快加满了)
|
||||
3. [B站-Guide哥](https://space.bilibili.com/504390397):(各种干货视频和生活向视频,来个一键三连可好!)
|
||||
4. [知识星球—JavaGuide读者圈](https://javaguide.cn/2019/01/02/chat/%E5%81%9A%E4%BA%86%E4%B8%80%E4%B8%AA%E5%BE%88%E4%B9%85%E6%B2%A1%E6%95%A2%E5%81%9A%E7%9A%84%E4%BA%8B%E6%83%85/)
|
||||
|
||||
Github用户如果访问速度缓慢的话,可以转移到[码云](https://gitee.com/SnailClimb/JavaGuide )查看,或者[在线阅读](https://snailclimb.gitee.io/javaguide )。
|
||||
|
||||
@ -48,13 +45,14 @@ Github用户如果访问速度缓慢的话,可以转移到[码云](https://git
|
||||
- [网络](#网络)
|
||||
- [操作系统](#操作系统)
|
||||
- [Linux](#linux)
|
||||
- [数据结构与算法](#数据结构与算法)
|
||||
- **[数据结构与算法](#数据结构与算法)**
|
||||
- [数据结构](#数据结构)
|
||||
- [算法](#算法)
|
||||
- [数据库](#数据库)
|
||||
- [MySQL](#mysql)
|
||||
- [Redis](#redis)
|
||||
- [系统设计](#系统设计)
|
||||
- [必知](#必知)
|
||||
- [常用框架](#常用框架)
|
||||
- [Spring](#spring)
|
||||
- [SpringBoot](#springboot)
|
||||
@ -76,16 +74,14 @@ Github用户如果访问速度缓慢的话,可以转移到[码云](https://git
|
||||
- [高可用](#高可用)
|
||||
- [微服务](#微服务)
|
||||
- [Spring Cloud](#spring-cloud)
|
||||
- [面试指南](#面试指南)
|
||||
- [Java学习常见问题汇总](#java学习常见问题汇总)
|
||||
- [工具](#工具)
|
||||
- [必会工具](#必会工具)
|
||||
- [Git](#git)
|
||||
- [Docker](#docker)
|
||||
- [其他](#其他-1)
|
||||
- [面试指南](#面试指南)
|
||||
- [Java学习常见问题汇总](#java学习常见问题汇总)
|
||||
- [资源](#资源)
|
||||
- [书单](#书单)
|
||||
- [书单推荐](#书单推荐)
|
||||
- [实战项目推荐](#实战项目推荐)
|
||||
- [Github](#github)
|
||||
- [待办](#待办)
|
||||
- [说明](#说明)
|
||||
|
||||
@ -146,7 +142,7 @@ Github用户如果访问速度缓慢的话,可以转移到[码云](https://git
|
||||
|
||||
1. **I/O** :[BIO,NIO,AIO 总结 ](docs/java/BIO-NIO-AIO.md)
|
||||
2. **Java 8** :[Java 8 新特性总结](docs/java/What's%20New%20in%20JDK8/Java8Tutorial.md)、[Java 8 学习资源推荐](docs/java/What's%20New%20in%20JDK8/Java8教程推荐.md)、[Java8 forEach 指南](docs/java/What's%20New%20in%20JDK8/Java8foreach指南.md)
|
||||
3. **[Java 编程规范以及优雅 Java 代码实践总结](docs/java/Java编程规范.md)**
|
||||
3. Java编程规范:**[Java 编程规范以及优雅 Java 代码实践总结](docs/java/Java编程规范.md)** 、[告别编码5分钟,命名2小时!史上最全的Java命名规范参考!](docs/java/java-naming-conventions.md)
|
||||
4. 设计模式 :[设计模式系列文章](docs/system-design/设计模式.md)
|
||||
|
||||
## 网络
|
||||
@ -157,11 +153,11 @@ Github用户如果访问速度缓慢的话,可以转移到[码云](https://git
|
||||
|
||||
## 操作系统
|
||||
|
||||
操作系统相关概念总结
|
||||
[最硬核的操作系统常见问题总结!](docs/operating-system/basis.md)
|
||||
|
||||
### Linux
|
||||
|
||||
* [后端程序员必备的 Linux 基础知识](docs/operating-system/后端程序员必备的Linux基础知识.md)
|
||||
* [后端程序员必备的 Linux 基础知识](docs/operating-system/linux.md)
|
||||
* [Shell 编程入门](docs/operating-system/Shell.md)
|
||||
|
||||
## 数据结构与算法
|
||||
@ -173,12 +169,13 @@ Github用户如果访问速度缓慢的话,可以转移到[码云](https://git
|
||||
|
||||
### 算法
|
||||
|
||||
- [算法学习资源推荐](docs/dataStructures-algorithms/算法学习资源推荐.md)
|
||||
- [几道常见的字符串算法题总结 ](docs/dataStructures-algorithms/几道常见的子符串算法题.md)
|
||||
- [几道常见的链表算法题总结 ](docs/dataStructures-algorithms/几道常见的链表算法题.md)
|
||||
- [剑指offer部分编程题](docs/dataStructures-algorithms/剑指offer部分编程题.md)
|
||||
- [公司真题](docs/dataStructures-algorithms/公司真题.md)
|
||||
- [回溯算法经典案例之N皇后问题](docs/dataStructures-algorithms/Backtracking-NQueens.md)
|
||||
- [硬核的算法学习书籍+资源推荐](docs/dataStructures-algorithms/算法学习资源推荐.md)
|
||||
- 常见算法问题总结:
|
||||
- [几道常见的字符串算法题总结 ](docs/dataStructures-algorithms/几道常见的子符串算法题.md)
|
||||
- [几道常见的链表算法题总结 ](docs/dataStructures-algorithms/几道常见的链表算法题.md)
|
||||
- [剑指offer部分编程题](docs/dataStructures-algorithms/剑指offer部分编程题.md)
|
||||
- [公司真题](docs/dataStructures-algorithms/公司真题.md)
|
||||
- [回溯算法经典案例之N皇后问题](docs/dataStructures-algorithms/Backtracking-NQueens.md)
|
||||
|
||||
## 数据库
|
||||
|
||||
@ -194,13 +191,25 @@ Github用户如果访问速度缓慢的话,可以转移到[码云](https://git
|
||||
|
||||
### Redis
|
||||
|
||||
* [Redis 总结](docs/database/Redis/Redis.md)
|
||||
* [Redlock分布式锁](docs/database/Redis/Redlock分布式锁.md)
|
||||
* [如何做可靠的分布式锁,Redlock真的可行么](docs/database/Redis/如何做可靠的分布式锁,Redlock真的可行么.md)
|
||||
* [几种常见的 Redis 集群以及使用场景](docs/database/Redis/redis集群以及应用场景.md)
|
||||
* [Redis 常见问题总结](docs/database/Redis/Redis.md)
|
||||
* **Redis 系列文章合集:**
|
||||
|
||||
1. [5种基本数据结构](docs/database/Redis/redis-collection/Redis(1)——5种基本数据结构.md)
|
||||
2. [跳跃表](docs/database/Redis/redis-collection/Redis(2)——跳跃表.md)
|
||||
3. [分布式锁深入探究](docs/database/Redis/redis-collection/Redis(3)——分布式锁深入探究.md) 、 [Redlock分布式锁](docs/database/Redis/Redlock分布式锁.md) 、[如何做可靠的分布式锁,Redlock真的可行么](docs/database/Redis/如何做可靠的分布式锁,Redlock真的可行么.md)
|
||||
4. [神奇的HyperLoglog解决统计问题](docs/database/Redis/redis-collection/Reids(4)——神奇的HyperLoglog解决统计问题.md)
|
||||
5. [亿级数据过滤和布隆过滤器](docs/database/Redis/redis-collection/Redis(5)——亿级数据过滤和布隆过滤器.md)
|
||||
6. [GeoHash查找附近的人](docs/database/Redis/redis-collection/Redis(6)——GeoHash查找附近的人.md)
|
||||
7. [持久化](docs/database/Redis/redis-collection/Redis(7)——持久化.md)
|
||||
8. [发布订阅与Stream](docs/database/Redis/redis-collection/Redis(8)——发布订阅与Stream.md)
|
||||
9. [史上最强【集群】入门实践教程](docs/database/Redis/redis-collection/Redis(9)——集群入门实践教程.md)
|
||||
|
||||
## 系统设计
|
||||
|
||||
### 必知
|
||||
|
||||
1. **[RestFul API 简明教程](docs/system-design/restful-api.md)**
|
||||
|
||||
### 常用框架
|
||||
|
||||
#### Spring
|
||||
@ -326,6 +335,21 @@ SSO(Single Sign On)即单点登录说的是用户登陆多个子系统的其中
|
||||
|
||||
- [ 大白话入门 Spring Cloud](docs/system-design/micro-service/spring-cloud.md)
|
||||
|
||||
## 必会工具
|
||||
|
||||
### Git
|
||||
|
||||
* [Git入门](docs/tools/Git.md)
|
||||
|
||||
### Docker
|
||||
|
||||
1. [Docker 基本概念解读](docs/tools/Docker.md)
|
||||
2. [一文搞懂 Docker 镜像的常用操作!](docs/tools/Docker-Image.md )
|
||||
|
||||
### 其他
|
||||
|
||||
- [阿里云服务器使用经验](docs/tools/阿里云服务器使用经验.md)
|
||||
|
||||
## 面试指南
|
||||
|
||||
> 这部分很多内容比如大厂面经、真实面经分析被移除,详见[完结撒花!JavaGuide面试突击版来啦!](./docs/javaguide面试突击版.md)。
|
||||
@ -347,36 +371,23 @@ SSO(Single Sign On)即单点登录说的是用户登陆多个子系统的其中
|
||||
4. [Java 还是大数据,你需要了解这些东西!](docs/questions/java-big-data)
|
||||
5. [Java 后台开发/大数据?你需要了解这些东西!](https://articles.zsxq.com/id_wto1iwd5g72o.html)(知识星球)
|
||||
|
||||
## 工具
|
||||
|
||||
### Git
|
||||
|
||||
* [Git入门](docs/tools/Git.md)
|
||||
|
||||
### Docker
|
||||
|
||||
1. [Docker 基本概念解读](docs/tools/Docker.md)
|
||||
2. [一文搞懂 Docker 镜像的常用操作!](docs/tools/Docker-Image.md )
|
||||
|
||||
### 其他
|
||||
|
||||
- [阿里云服务器使用经验](docs/tools/阿里云服务器使用经验.md)
|
||||
|
||||
## 资源
|
||||
|
||||
### 书单
|
||||
### 书单推荐
|
||||
|
||||
- [Java程序员必备书单](docs/data/java-recommended-books.md)
|
||||
- [算法相关](docs/books/alogorithm.md)
|
||||
- **[Java程序员必备书单](docs/books/java.md)**
|
||||
|
||||
### 实战项目推荐
|
||||
|
||||
- [Github 上热门的 Spring Boot 项目实战推荐](docs/data/spring-boot-practical-projects.md)
|
||||
- **[Java、SpringBoot实战项目推荐](https://github.com/Snailclimb/awesome-java#实战项目)**
|
||||
|
||||
### Github
|
||||
|
||||
- [Github 上非常棒的 Java 开源项目集合](https://github.com/Snailclimb/awesome-java)
|
||||
- [Github 上 Star 数最多的 10 个项目,看完之后很意外!](docs/tools/github/github-star-ranking.md)
|
||||
- [年末将至,值得你关注的16个Java 开源项目!](docs/github-trending/2019-12.md)
|
||||
- [Java 项目月榜单](docs/github-trending/JavaGithubTrending.md)
|
||||
- [Java 项目历史月榜单](docs/github-trending/JavaGithubTrending.md)
|
||||
|
||||
***
|
||||
|
||||
|
@ -1,13 +1,12 @@
|
||||
<p align="center">
|
||||
<img src="https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-3logo-透明.png" width=""/>
|
||||
<img src="./media/pictures/logo.png" width="200" height="200"/>
|
||||
</p>
|
||||
|
||||
|
||||
<h1 align="center">Java 学习/面试指南</h1>
|
||||
|
||||
[常用资源](https://shimo.im/docs/MuiACIg1HlYfVxrj/)
|
||||
[GitHub](<https://github.com/Snailclimb/JavaGuide>)
|
||||
[开始阅读](#java)
|
||||
|
||||

|
||||
|
||||
|
||||
|
@ -1,66 +0,0 @@
|
||||
最近经常被读者问到有没有 Spring Boot 实战项目可以学习,于是,我就去 Github 上找了 10 个我觉得还不错的实战项目。对于这些实战项目,有部分是比较适合 Spring Boot 刚入门的朋友学习的,还有一部分可能要求你对 Spring Boot 相关技术比较熟悉。需要的朋友可以根据个人实际情况进行选择。如果你对 Spring Boot 不太熟悉的话,可以看我最近开源的 springboot-guide:https://github.com/Snailclimb/springboot-guide 入门(还在持续更新中)。
|
||||
|
||||
### mall
|
||||
|
||||
- **Github地址**: [https://github.com/macrozheng/mall](https://github.com/macrozheng/mall)
|
||||
- **star**: 22.9k
|
||||
- **介绍**: mall项目是一套电商系统,包括前台商城系统及后台管理系统,基于SpringBoot+MyBatis实现。 前台商城系统包含首页门户、商品推荐、商品搜索、商品展示、购物车、订单流程、会员中心、客户服务、帮助中心等模块。 后台管理系统包含商品管理、订单管理、会员管理、促销管理、运营管理、内容管理、统计报表、财务管理、权限管理、设置等模块。
|
||||
|
||||
### jeecg-boot
|
||||
|
||||
- **Github地址**:[https://github.com/zhangdaiscott/jeecg-boot](https://github.com/zhangdaiscott/jeecg-boot)
|
||||
- **star**: 6.4k
|
||||
- **介绍**: 一款基于代码生成器的JAVA快速开发平台!采用最新技术,前后端分离架构:SpringBoot 2.x,Ant Design&Vue,Mybatis,Shiro,JWT。强大的代码生成器让前后端代码一键生成,无需写任何代码,绝对是全栈开发福音!! JeecgBoot的宗旨是提高UI能力的同时,降低前后分离的开发成本,JeecgBoot还独创在线开发模式,No代码概念,一系列在线智能开发:在线配置表单、在线配置报表、在线设计流程等等。
|
||||
|
||||
### eladmin
|
||||
|
||||
- **Github地址**:[https://github.com/elunez/eladmin](https://github.com/elunez/eladmin)
|
||||
- **star**: 3.9k
|
||||
- **介绍**: 项目基于 Spring Boot 2.1.0 、 Jpa、 Spring Security、redis、Vue的前后端分离的后台管理系统,项目采用分模块开发方式, 权限控制采用 RBAC,支持数据字典与数据权限管理,支持一键生成前后端代码,支持动态路由。
|
||||
|
||||
### paascloud-master
|
||||
|
||||
- **Github地址**:[https://github.com/paascloud/paascloud-master](https://github.com/paascloud/paascloud-master)
|
||||
- **star**: 5.9k
|
||||
- **介绍**: spring cloud + vue + oAuth2.0全家桶实战,前后端分离模拟商城,完整的购物流程、后端运营平台,可以实现快速搭建企业级微服务项目。支持微信登录等三方登录。
|
||||
|
||||
### vhr
|
||||
|
||||
- **Github地址**:[https://github.com/lenve/vhr](https://github.com/lenve/vhr)
|
||||
- **star**: 10.6k
|
||||
- **介绍**: 微人事是一个前后端分离的人力资源管理系统,项目采用SpringBoot+Vue开发。
|
||||
|
||||
### One mall
|
||||
|
||||
- **Github地址**:[https://github.com/YunaiV/onemall](https://github.com/YunaiV/onemall)
|
||||
- **star**: 1.2k
|
||||
- **介绍**: mall 商城,基于微服务的思想,构建在 B2C 电商场景下的项目实战。核心技术栈,是 Spring Boot + Dubbo 。未来,会重构成 Spring Cloud Alibaba 。
|
||||
|
||||
### Guns
|
||||
|
||||
- **Github地址**:[https://github.com/stylefeng/Guns](https://github.com/stylefeng/Guns)
|
||||
- **star**: 2.3k
|
||||
- **介绍**: Guns基于SpringBoot 2,致力于做更简洁的后台管理系统,完美整合springmvc + shiro + mybatis-plus + beetl!Guns项目代码简洁,注释丰富,上手容易,同时Guns包含许多基础模块(用户管理,角色管理,部门管理,字典管理等10个模块),可以直接作为一个后台管理系统的脚手架!
|
||||
|
||||
### SpringCloud
|
||||
|
||||
- **Github地址**:[https://github.com/YunaiV/onemall](https://github.com/YunaiV/onemall)
|
||||
- **star**: 1.2k
|
||||
- **介绍**: mall 商城,基于微服务的思想,构建在 B2C 电商场景下的项目实战。核心技术栈,是 Spring Boot + Dubbo 。未来,会重构成 Spring Cloud Alibaba 。
|
||||
|
||||
### SpringBoot-Shiro-Vue
|
||||
|
||||
- **Github地址**:[https://github.com/Heeexy/SpringBoot-Shiro-Vue](https://github.com/Heeexy/SpringBoot-Shiro-Vue)
|
||||
- **star**: 1.8k
|
||||
- **介绍**: 提供一套基于Spring Boot-Shiro-Vue的权限管理思路.前后端都加以控制,做到按钮/接口级别的权限。
|
||||
|
||||
### newbee-mall
|
||||
|
||||
最近开源的一个商城项目。
|
||||
|
||||
- **Github地址**:[https://github.com/newbee-ltd/newbee-mall](https://github.com/newbee-ltd/newbee-mall)
|
||||
- **star**: 50
|
||||
- **介绍**: newbee-mall 项目是一套电商系统,包括 newbee-mall 商城系统及 newbee-mall-admin 商城后台管理系统,基于 Spring Boot 2.X 及相关技术栈开发。 前台商城系统包含首页门户、商品分类、新品上线、首页轮播、商品推荐、商品搜索、商品展示、购物车、订单结算、订单流程、个人订单管理、会员中心、帮助中心等模块。 后台管理系统包含数据面板、轮播图管理、商品管理、订单管理、会员管理、分类管理、设置等模块。
|
||||
|
||||
|
||||
|
@ -41,7 +41,7 @@
|
||||
若 row 行的棋子和 i 行的棋子在同一对角线,等腰直角三角形两直角边相等,即 row - i == Math.abs(result[i] - column)
|
||||
|
||||
布尔类型变量 isValid 的作用是剪枝,减少不必要的递归。
|
||||
```
|
||||
```java
|
||||
public List<List<String>> solveNQueens(int n) {
|
||||
// 下标代表行,值代表列。如result[0] = 3 表示第1行的Q在第3列
|
||||
int[] result = new int[n];
|
||||
@ -104,7 +104,7 @@ row - i + n 的最大值为 2n(当row = n,i = 0时),故anti_diag的容
|
||||
|
||||
**解法二时间复杂度为O(n!),在校验相同列和相同对角线时,引入三个布尔类型数组进行判断。相比解法一,少了一层循环,用空间换时间。**
|
||||
|
||||
```
|
||||
```java
|
||||
List<List<String>> resultList = new LinkedList<>();
|
||||
|
||||
public List<List<String>> solveNQueens(int n) {
|
||||
|
@ -192,7 +192,7 @@ public class Main {
|
||||
我们上面已经知道了什么是回文串?现在我们考虑一下可以构成回文串的两种情况:
|
||||
|
||||
- 字符出现次数为双数的组合
|
||||
- 字符出现次数为双数的组合+一个只出现一次的字符
|
||||
- **字符出现次数为偶数的组合+单个字符中出现次数最多且为奇数次的字符** (参见 **[issue665](https://github.com/Snailclimb/JavaGuide/issues/665)** )
|
||||
|
||||
统计字符出现的次数即可,双数才能构成回文。因为允许中间一个数单独出现,比如“abcba”,所以如果最后有字母落单,总长度可以加 1。首先将字符串转变为字符数组。然后遍历该数组,判断对应字符是否在hashset中,如果不在就加进去,如果在就让count++,然后移除该字符!这样就能找到出现次数为双数的字符个数。
|
||||
|
||||
|
@ -155,7 +155,7 @@ Set 继承于 Collection 接口,是一个不允许出现重复元素,并且
|
||||
|
||||
红黑树的应用:
|
||||
|
||||
TreeMap、TreeSet以及JDK1.8之后的HashMap底层都用到了红黑树。
|
||||
TreeMap、TreeSet以及JDK1.8的HashMap底层都用到了红黑树。
|
||||
|
||||
**为什么要用红黑树?**
|
||||
|
||||
|
@ -1,38 +1,128 @@
|
||||
先占个坑,说一下我觉得算法这部分学习比较好的规划:
|
||||
|
||||
1. 未入门(对算法和基本数据结构不了解)之前建议先找一本入门书籍看;
|
||||
2. 如果时间比较多可以看一下我推荐的经典部分的书籍,《算法》这本书是首要要看的,其他推荐的神书看自己时间和心情就好,不要太纠结。
|
||||
3. 如果要准备面试,时间比较紧的话,就不需要再去看《算法》这本书了,时间来不及,当然你也可以选取其特定的章节查看。我也推荐了几本不错的专门为算法面试准备的书籍比如《剑指offer》和《程序员代码面试指南》。除了这两本书籍的话,我在下面推荐了 Leetcode 和牛客网这两个常用的刷题网站以及一些比较好的题目资源。
|
||||
|
||||
## 书籍推荐
|
||||
|
||||
> 以下提到的部分书籍的 PDF 高清阅读版本在我的公众号“JavaGuide”后台回复“书籍”即可获取。
|
||||
|
||||
先来看三本入门书籍,这三本入门书籍中的任何一本拿来作为入门学习都非常好。我个人比较倾向于 **《我的第一本算法书》** 这本书籍,虽然它相比于其他两本书集它的豆瓣评分略低一点。我觉得它的配图以及讲解是这三本书中最优秀,唯一比较明显的问题就是没有代码示例。但是,我觉得这不影响它是一本好的算法书籍。因为本身下面这三本入门书籍的目的就不是通过代码来让你的算法有多厉害,只是作为一本很好的入门书籍让你进入算法学习的大门。
|
||||
|
||||
### 入门
|
||||
|
||||
<img src="https://imgkr.cn-bj.ufileos.com/bcf73ee2-ca03-4985-b620-ebe36cc3e791.jpg" style="zoom:33%;" />
|
||||
|
||||
**[我的第一本算法书](https://book.douban.com/subject/30357170/) (豆瓣评分 7.1,0.2K+人评价)**
|
||||
|
||||
一本不那么“专业”的算法书籍。和下面两本推荐的算法书籍都是比较通俗易懂,“不那么深入”的算法书籍。我个人非常推荐,配图和讲解都非常不错!
|
||||
|
||||
<img src="https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-11/q2790p435q88491n967nqo15077ss401.jpg" alt="img" style="zoom:50%;" />
|
||||
|
||||
**[《算法图解》](https://book.douban.com/subject/26979890/)(豆瓣评分 8.4,1.5K+人评价)**
|
||||
|
||||
入门类型的书籍,读起来比较浅显易懂,非常适合没有算法基础或者说算法没学好的小伙伴用来入门。示例丰富,图文并茂,以让人容易理解的方式阐释了算法.读起来比较快,内容不枯燥!
|
||||
|
||||

|
||||
|
||||
**[啊哈!算法](https://book.douban.com/subject/25894685/) (豆瓣评分 7.7,0.5K+人评价)**
|
||||
|
||||
和《算法图解》类似的算法趣味入门书籍。
|
||||
|
||||
### 经典
|
||||
|
||||
<img src="https://imgkr.cn-bj.ufileos.com/b5a5a9b0-db43-4c04-9fd7-5fd6998a2491.jpg" style="zoom:50%;" />
|
||||
|
||||
**[《算法 第四版》](https://book.douban.com/subject/10432347/)(豆瓣评分 9.3,0.4K+人评价)**
|
||||
|
||||
我在大二的时候被我们的一个老师强烈安利过!自己也在当时购买了一本放在宿舍,到离开大学的时候自己大概看了一半多一点。因为内容实在太多了!另外,这本书还提供了详细的Java代码,非常适合学习 Java 的朋友来看,可以说是 Java 程序员的必备书籍之一了。
|
||||
|
||||
再来介绍一下这本书籍吧!这本书籍算的上是算法领域经典的参考书,全面介绍了关于算法和数据结构的必备知识,并特别针对排序、搜索、图处理和字符串处理进行了论述。
|
||||
|
||||
> **下面这些书籍都是经典中的经典,但是阅读起来难度也比较大,不做太多阐述,神书就完事了!推荐先看 《算法》,然后再选下面的书籍进行进一步阅读。不需要都看,找一本好好看或者找某本书的某一个章节知识点好好看。**
|
||||
|
||||
<img src="https://imgkr.cn-bj.ufileos.com/08dcc4fa-5b79-4761-848e-50f47bc31cd0.jpg" style="zoom:67%;" />
|
||||
|
||||
**[编程珠玑](https://book.douban.com/subject/3227098/)(豆瓣评分 9.1,2K+人评价)**
|
||||
|
||||
经典名著,被无数读者强烈推荐的书籍,几乎是顶级程序员必看的书籍之一了。这本书的作者也非常厉害,Java之父 James Gosling 就是他的学生。
|
||||
|
||||
很多人都说这本书不是教你具体的算法,而是教你一种编程的思考方式。这种思考方式不仅仅在编程领域适用,在其他同样适用。
|
||||
|
||||
|
||||
|
||||
<img src="https://imgkr.cn-bj.ufileos.com/2734e31f-433b-456c-98e2-39652ac97c86.png" style="zoom:50%;" />
|
||||
|
||||
**[《算法设计手册》](https://book.douban.com/subject/4048566/)(豆瓣评分9.1 , 45人评价)**
|
||||
|
||||
被 [Teach Yourself Computer Science](https://teachyourselfcs.com/) 强烈推荐的一本算法书籍。
|
||||
|
||||
<img src="https://imgkr.cn-bj.ufileos.com/1981a809-3828-4cfb-af0c-b636dd5c53bf.jpg" style="zoom:48%;" />
|
||||
|
||||
**[《算法导论》](https://book.douban.com/subject/20432061/) (豆瓣评分 9.2,0.4K+人评价)**
|
||||
|
||||

|
||||
|
||||
**[《计算机程序设计艺术(第1卷)》](https://book.douban.com/subject/1130500/)(豆瓣评分 9.4,0.4K+人评价)**
|
||||
|
||||
### 面试
|
||||
|
||||

|
||||
|
||||
**[《剑指Offer》](https://book.douban.com/subject/6966465/)(豆瓣评分 8.3,0.7K+人评价)**
|
||||
|
||||
这本面试宝典上面涵盖了很多经典的算法面试题,如果你要准备大厂面试的话一定不要错过这本书。
|
||||
|
||||
《剑指Offer》 对应的算法编程题部分的开源项目解析:[CodingInterviews](https://github.com/gatieme/CodingInterviews)
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
<img src="https://imgkr.cn-bj.ufileos.com/bee9a056-a6ba-4387-bd9b-c9a35a178dcf.jpg" style="zoom:50%;" />
|
||||
|
||||
**[程序员代码面试指南:IT名企算法与数据结构题目最优解(第2版)](https://book.douban.com/subject/30422021/) (豆瓣评分 8.7,0.2K+人评价)**
|
||||
|
||||
题目相比于《剑指 offer》 来说要难很多,题目涵盖面相比于《剑指 offer》也更加全面。全书一共有将近300道真实出现过的经典代码面试题。
|
||||
|
||||
|
||||
|
||||
<img src="https://imgkr.cn-bj.ufileos.com/cea5161f-cd7b-48c7-a9b0-55674f7dadcc.jpg" style="zoom:55%;" />
|
||||
|
||||
|
||||
|
||||
**[编程之美](https://book.douban.com/subject/3004255/)(豆瓣评分 8.4,3K+人评价)**
|
||||
|
||||
这本书收集了约60道算法和程序设计题目,这些题目大部分在近年的笔试、面试中出现过,或者是被微软员工热烈讨论过。作者试图从书中各种有趣的问题出发,引导读者发现问题,分析问题,解决问题,寻找更优的解法。
|
||||
|
||||
## 网站推荐
|
||||
|
||||
我比较推荐大家可以刷一下 Leetcode ,我自己平时没事也会刷一下,我觉得刷 Leetcode 不仅是为了能让你更从容地面对面试中的手撕算法问题,更可以提高你的编程思维能力、解决问题的能力以及你对某门编程语言 API 的熟练度。当然牛客网也有一些算法题,我下面也整理了一些。
|
||||
|
||||
## LeetCode
|
||||
### [LeetCode](https://leetcode-cn.com/)
|
||||
|
||||
- [LeetCode(中国)官网](https://leetcode-cn.com/)
|
||||
[如何高效地使用 LeetCode](https://leetcode-cn.com/articles/%E5%A6%82%E4%BD%95%E9%AB%98%E6%95%88%E5%9C%B0%E4%BD%BF%E7%94%A8-leetcode/)
|
||||
|
||||
- [如何高效地使用 LeetCode](https://leetcode-cn.com/articles/%E5%A6%82%E4%BD%95%E9%AB%98%E6%95%88%E5%9C%B0%E4%BD%BF%E7%94%A8-leetcode/)
|
||||
- [《程序员代码面试指南》](https://leetcode-cn.com/problemset/lcci/)
|
||||
- [《剑指offer》](https://leetcode-cn.com/problemset/lcof/)
|
||||
|
||||
|
||||
## 牛客网
|
||||
### [牛客网](https://www.nowcoder.com)
|
||||
|
||||
**[在线编程](https://www.nowcoder.com/activity/oj):**
|
||||
|
||||
- [《剑指offer》](https://www.nowcoder.com/ta/coding-interviews)
|
||||
- [《程序员代码面试指南》](https://www.nowcoder.com/ta/programmer-code-interview-guide)
|
||||
- [2019 校招真题](https://www.nowcoder.com/ta/2019test)
|
||||
- [大一大二编程入门训练](https://www.nowcoder.com/ta/beginner-programmers)
|
||||
- .......
|
||||
|
||||
**[大厂编程面试真题](https://www.nowcoder.com/contestRoom?filter=0&orderByHotValue=3&target=content&categories=-1&mutiTagIds=2491&page=1)**
|
||||
|
||||
|
||||
- [牛客网官网](https://www.nowcoder.com)
|
||||
- [剑指offer编程题](https://www.nowcoder.com/ta/coding-interviews)
|
||||
|
||||
- [2017校招真题](https://www.nowcoder.com/ta/2017test)
|
||||
- [华为机试题](https://www.nowcoder.com/ta/huawei)
|
||||
|
||||
|
||||
## 公司真题
|
||||
|
||||
- [ 网易2018校园招聘编程题真题集合](https://www.nowcoder.com/test/6910869/summary)
|
||||
- [ 网易2018校招内推编程题集合](https://www.nowcoder.com/test/6291726/summary)
|
||||
- [2017年校招全国统一模拟笔试(第五场)编程题集合](https://www.nowcoder.com/test/5986669/summary)
|
||||
- [2017年校招全国统一模拟笔试(第四场)编程题集合](https://www.nowcoder.com/test/5507925/summary)
|
||||
- [2017年校招全国统一模拟笔试(第三场)编程题集合](https://www.nowcoder.com/test/5217106/summary)
|
||||
- [2017年校招全国统一模拟笔试(第二场)编程题集合](https://www.nowcoder.com/test/4546329/summary)
|
||||
- [ 2017年校招全国统一模拟笔试(第一场)编程题集合](https://www.nowcoder.com/test/4236887/summary)
|
||||
- [百度2017春招笔试真题编程题集合](https://www.nowcoder.com/test/4998655/summary)
|
||||
- [网易2017春招笔试真题编程题集合](https://www.nowcoder.com/test/4575457/summary)
|
||||
- [网易2017秋招编程题集合](https://www.nowcoder.com/test/2811407/summary)
|
||||
- [网易有道2017内推编程题](https://www.nowcoder.com/test/2385858/summary)
|
||||
- [ 滴滴出行2017秋招笔试真题-编程题汇总](https://www.nowcoder.com/test/3701760/summary)
|
||||
- [腾讯2017暑期实习生编程题](https://www.nowcoder.com/test/1725829/summary)
|
||||
- [今日头条2017客户端工程师实习生笔试题](https://www.nowcoder.com/test/1649301/summary)
|
||||
- [今日头条2017后端工程师实习生笔试题](https://www.nowcoder.com/test/1649268/summary)
|
||||
|
||||
|
||||
|
||||
|
@ -35,7 +35,7 @@
|
||||
- [2. 避免数据类型的隐式转换](#2-避免数据类型的隐式转换)
|
||||
- [3. 充分利用表上已经存在的索引](#3-充分利用表上已经存在的索引)
|
||||
- [4. 数据库设计时,应该要对以后扩展进行考虑](#4-数据库设计时应该要对以后扩展进行考虑)
|
||||
- [5. 程序连接不同的数据库使用不同的账号,进制跨库查询](#5-程序连接不同的数据库使用不同的账号进制跨库查询)
|
||||
- [5. 程序连接不同的数据库使用不同的账号,禁止跨库查询](#5-程序连接不同的数据库使用不同的账号禁止跨库查询)
|
||||
- [6. 禁止使用 SELECT * 必须使用 SELECT <字段列表> 查询](#6-禁止使用-select--必须使用-select-字段列表-查询)
|
||||
- [7. 禁止使用不含字段列表的 INSERT 语句](#7-禁止使用不含字段列表的-insert-语句)
|
||||
- [8. 避免使用子查询,可以把子查询优化为 join 操作](#8-避免使用子查询可以把子查询优化为-join-操作)
|
||||
|
498
docs/database/Redis/redis-collection/Redis(1)——5种基本数据结构.md
Normal file
498
docs/database/Redis/redis-collection/Redis(1)——5种基本数据结构.md
Normal file
@ -0,0 +1,498 @@
|
||||
> 授权转载自: https://github.com/wmyskxz/MoreThanJava#part3-redis
|
||||
|
||||

|
||||
|
||||
# 一、Redis 简介
|
||||
|
||||
> **"Redis is an open source (BSD licensed), in-memory data structure store, used as a database, cache and message broker."** —— Redis是一个开放源代码(BSD许可)的内存中数据结构存储,用作数据库,缓存和消息代理。 *(摘自官网)*
|
||||
|
||||
**Redis** 是一个开源,高级的键值存储和一个适用的解决方案,用于构建高性能,可扩展的 Web 应用程序。**Redis** 也被作者戏称为 *数据结构服务器* ,这意味着使用者可以通过一些命令,基于带有 TCP 套接字的简单 *服务器-客户端* 协议来访问一组 **可变数据结构** 。*(在 Redis 中都采用键值对的方式,只不过对应的数据结构不一样罢了)*
|
||||
|
||||
## Redis 的优点
|
||||
|
||||
以下是 Redis 的一些优点:
|
||||
|
||||
- **异常快** - Redis 非常快,每秒可执行大约 110000 次的设置(SET)操作,每秒大约可执行 81000 次的读取/获取(GET)操作。
|
||||
- **支持丰富的数据类型** - Redis 支持开发人员常用的大多数数据类型,例如列表,集合,排序集和散列等等。这使得 Redis 很容易被用来解决各种问题,因为我们知道哪些问题可以更好使用地哪些数据类型来处理解决。
|
||||
- **操作具有原子性** - 所有 Redis 操作都是原子操作,这确保如果两个客户端并发访问,Redis 服务器能接收更新的值。
|
||||
- **多实用工具** - Redis 是一个多实用工具,可用于多种用例,如:缓存,消息队列(Redis 本地支持发布/订阅),应用程序中的任何短期数据,例如,web应用程序中的会话,网页命中计数等。
|
||||
|
||||
## Redis 的安装
|
||||
|
||||
这一步比较简单,你可以在网上搜到许多满意的教程,这里就不再赘述。
|
||||
|
||||
给一个菜鸟教程的安装教程用作参考:[https://www.runoob.com/redis/redis-install.html](https://www.runoob.com/redis/redis-install.html)
|
||||
|
||||
## 测试本地 Redis 性能
|
||||
|
||||
当你安装完成之后,你可以先执行 `redis-server` 让 Redis 启动起来,然后运行命令 `redis-benchmark -n 100000 -q` 来检测本地同时执行 10 万个请求时的性能:
|
||||
|
||||

|
||||
|
||||
当然不同电脑之间由于各方面的原因会存在性能差距,这个测试您可以权当是一种 **「乐趣」** 就好。
|
||||
|
||||
# 二、Redis 五种基本数据结构
|
||||
|
||||
**Redis** 有 5 种基础数据结构,它们分别是:**string(字符串)**、**list(列表)**、**hash(字典)**、**set(集合)** 和 **zset(有序集合)**。这 5 种是 Redis 相关知识中最基础、最重要的部分,下面我们结合源码以及一些实践来给大家分别讲解一下。
|
||||
|
||||
## 1)字符串 string
|
||||
|
||||
Redis 中的字符串是一种 **动态字符串**,这意味着使用者可以修改,它的底层实现有点类似于 Java 中的 **ArrayList**,有一个字符数组,从源码的 **sds.h/sdshdr 文件** 中可以看到 Redis 底层对于字符串的定义 **SDS**,即 *Simple Dynamic String* 结构:
|
||||
|
||||
```c
|
||||
/* Note: sdshdr5 is never used, we just access the flags byte directly.
|
||||
* However is here to document the layout of type 5 SDS strings. */
|
||||
struct __attribute__ ((__packed__)) sdshdr5 {
|
||||
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
|
||||
char buf[];
|
||||
};
|
||||
struct __attribute__ ((__packed__)) sdshdr8 {
|
||||
uint8_t len; /* used */
|
||||
uint8_t alloc; /* excluding the header and null terminator */
|
||||
unsigned char flags; /* 3 lsb of type, 5 unused bits */
|
||||
char buf[];
|
||||
};
|
||||
struct __attribute__ ((__packed__)) sdshdr16 {
|
||||
uint16_t len; /* used */
|
||||
uint16_t alloc; /* excluding the header and null terminator */
|
||||
unsigned char flags; /* 3 lsb of type, 5 unused bits */
|
||||
char buf[];
|
||||
};
|
||||
struct __attribute__ ((__packed__)) sdshdr32 {
|
||||
uint32_t len; /* used */
|
||||
uint32_t alloc; /* excluding the header and null terminator */
|
||||
unsigned char flags; /* 3 lsb of type, 5 unused bits */
|
||||
char buf[];
|
||||
};
|
||||
struct __attribute__ ((__packed__)) sdshdr64 {
|
||||
uint64_t len; /* used */
|
||||
uint64_t alloc; /* excluding the header and null terminator */
|
||||
unsigned char flags; /* 3 lsb of type, 5 unused bits */
|
||||
char buf[];
|
||||
};
|
||||
```
|
||||
|
||||
你会发现同样一组结构 Redis 使用泛型定义了好多次,**为什么不直接使用 int 类型呢?**
|
||||
|
||||
因为当字符串比较短的时候,len 和 alloc 可以使用 byte 和 short 来表示,**Redis 为了对内存做极致的优化,不同长度的字符串使用不同的结构体来表示。**
|
||||
|
||||
### SDS 与 C 字符串的区别
|
||||
|
||||
为什么不考虑直接使用 C 语言的字符串呢?因为 C 语言这种简单的字符串表示方式 **不符合 Redis 对字符串在安全性、效率以及功能方面的要求**。我们知道,C 语言使用了一个长度为 N+1 的字符数组来表示长度为 N 的字符串,并且字符数组最后一个元素总是 `'\0'`。*(下图就展示了 C 语言中值为 "Redis" 的一个字符数组)*
|
||||
|
||||

|
||||
|
||||
这样简单的数据结构可能会造成以下一些问题:
|
||||
|
||||
- **获取字符串长度为 O(N) 级别的操作** → 因为 C 不保存数组的长度,每次都需要遍历一遍整个数组;
|
||||
- 不能很好的杜绝 **缓冲区溢出/内存泄漏** 的问题 → 跟上述问题原因一样,如果执行拼接 or 缩短字符串的操作,如果操作不当就很容易造成上述问题;
|
||||
- C 字符串 **只能保存文本数据** → 因为 C 语言中的字符串必须符合某种编码(比如 ASCII),例如中间出现的 `'\0'` 可能会被判定为提前结束的字符串而识别不了;
|
||||
|
||||
我们以追加字符串的操作举例,Redis 源码如下:
|
||||
|
||||
```c
|
||||
/* Append the specified binary-safe string pointed by 't' of 'len' bytes to the
|
||||
* end of the specified sds string 's'.
|
||||
*
|
||||
* After the call, the passed sds string is no longer valid and all the
|
||||
* references must be substituted with the new pointer returned by the call. */
|
||||
sds sdscatlen(sds s, const void *t, size_t len) {
|
||||
// 获取原字符串的长度
|
||||
size_t curlen = sdslen(s);
|
||||
|
||||
// 按需调整空间,如果容量不够容纳追加的内容,就会重新分配字节数组并复制原字符串的内容到新数组中
|
||||
s = sdsMakeRoomFor(s,len);
|
||||
if (s == NULL) return NULL; // 内存不足
|
||||
memcpy(s+curlen, t, len); // 追加目标字符串到字节数组中
|
||||
sdssetlen(s, curlen+len); // 设置追加后的长度
|
||||
s[curlen+len] = '\0'; // 让字符串以 \0 结尾,便于调试打印
|
||||
return s;
|
||||
}
|
||||
```
|
||||
|
||||
- **注:Redis 规定了字符串的长度不得超过 512 MB。**
|
||||
|
||||
### 对字符串的基本操作
|
||||
|
||||
安装好 Redis,我们可以使用 `redis-cli` 来对 Redis 进行命令行的操作,当然 Redis 官方也提供了在线的调试器,你也可以在里面敲入命令进行操作:[http://try.redis.io/#run](http://try.redis.io/#run)
|
||||
|
||||
#### 设置和获取键值对
|
||||
|
||||
```console
|
||||
> SET key value
|
||||
OK
|
||||
> GET key
|
||||
"value"
|
||||
```
|
||||
|
||||
正如你看到的,我们通常使用 `SET` 和 `GET` 来设置和获取字符串值。
|
||||
|
||||
值可以是任何种类的字符串(包括二进制数据),例如你可以在一个键下保存一张 `.jpeg` 图片,只需要注意不要超过 512 MB 的最大限度就好了。
|
||||
|
||||
当 key 存在时,`SET` 命令会覆盖掉你上一次设置的值:
|
||||
|
||||
```console
|
||||
> SET key newValue
|
||||
OK
|
||||
> GET key
|
||||
"newValue"
|
||||
```
|
||||
|
||||
另外你还可以使用 `EXISTS` 和 `DEL` 关键字来查询是否存在和删除键值对:
|
||||
|
||||
```console
|
||||
> EXISTS key
|
||||
(integer) 1
|
||||
> DEL key
|
||||
(integer) 1
|
||||
> GET key
|
||||
(nil)
|
||||
```
|
||||
|
||||
#### 批量设置键值对
|
||||
|
||||
```console
|
||||
> SET key1 value1
|
||||
OK
|
||||
> SET key2 value2
|
||||
OK
|
||||
> MGET key1 key2 key3 # 返回一个列表
|
||||
1) "value1"
|
||||
2) "value2"
|
||||
3) (nil)
|
||||
> MSET key1 value1 key2 value2
|
||||
> MGET key1 key2
|
||||
1) "value1"
|
||||
2) "value2"
|
||||
```
|
||||
|
||||
#### 过期和 SET 命令扩展
|
||||
|
||||
可以对 key 设置过期时间,到时间会被自动删除,这个功能常用来控制缓存的失效时间。*(过期可以是任意数据结构)*
|
||||
|
||||
```console
|
||||
> SET key value1
|
||||
> GET key
|
||||
"value1"
|
||||
> EXPIRE name 5 # 5s 后过期
|
||||
... # 等待 5s
|
||||
> GET key
|
||||
(nil)
|
||||
```
|
||||
|
||||
等价于 `SET` + `EXPIRE` 的 `SETNX` 命令:
|
||||
|
||||
```console
|
||||
> SETNX key value1
|
||||
... # 等待 5s 后获取
|
||||
> GET key
|
||||
(nil)
|
||||
|
||||
> SETNX key value1 # 如果 key 不存在则 SET 成功
|
||||
(integer) 1
|
||||
> SETNX key value1 # 如果 key 存在则 SET 失败
|
||||
(integer) 0
|
||||
> GET key
|
||||
"value" # 没有改变
|
||||
```
|
||||
|
||||
#### 计数
|
||||
|
||||
如果 value 是一个整数,还可以对它使用 `INCR` 命令进行 **原子性** 的自增操作,这意味着及时多个客户端对同一个 key 进行操作,也决不会导致竞争的情况:
|
||||
|
||||
```console
|
||||
> SET counter 100
|
||||
> INCR count
|
||||
(interger) 101
|
||||
> INCRBY counter 50
|
||||
(integer) 151
|
||||
```
|
||||
|
||||
#### 返回原值的 GETSET 命令
|
||||
|
||||
对字符串,还有一个 `GETSET` 比较让人觉得有意思,它的功能跟它名字一样:为 key 设置一个值并返回原值:
|
||||
|
||||
```console
|
||||
> SET key value
|
||||
> GETSET key value1
|
||||
"value"
|
||||
```
|
||||
|
||||
这可以对于某一些需要隔一段时间就统计的 key 很方便的设置和查看,例如:系统每当由用户进入的时候你就是用 `INCR` 命令操作一个 key,当需要统计时候你就把这个 key 使用 `GETSET` 命令重新赋值为 0,这样就达到了统计的目的。
|
||||
|
||||
## 2)列表 list
|
||||
|
||||
Redis 的列表相当于 Java 语言中的 **LinkedList**,注意它是链表而不是数组。这意味着 list 的插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为 O(n)。
|
||||
|
||||
我们可以从源码的 `adlist.h/listNode` 来看到对其的定义:
|
||||
|
||||
```c
|
||||
/* Node, List, and Iterator are the only data structures used currently. */
|
||||
|
||||
typedef struct listNode {
|
||||
struct listNode *prev;
|
||||
struct listNode *next;
|
||||
void *value;
|
||||
} listNode;
|
||||
|
||||
typedef struct listIter {
|
||||
listNode *next;
|
||||
int direction;
|
||||
} listIter;
|
||||
|
||||
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;
|
||||
```
|
||||
|
||||
可以看到,多个 listNode 可以通过 `prev` 和 `next` 指针组成双向链表:
|
||||
|
||||

|
||||
|
||||
虽然仅仅使用多个 listNode 结构就可以组成链表,但是使用 `adlist.h/list` 结构来持有链表的话,操作起来会更加方便:
|
||||
|
||||

|
||||
|
||||
### 链表的基本操作
|
||||
|
||||
- `LPUSH` 和 `RPUSH` 分别可以向 list 的左边(头部)和右边(尾部)添加一个新元素;
|
||||
- `LRANGE` 命令可以从 list 中取出一定范围的元素;
|
||||
- `LINDEX` 命令可以从 list 中取出指定下表的元素,相当于 Java 链表操作中的 `get(int index)` 操作;
|
||||
|
||||
示范:
|
||||
|
||||
```console
|
||||
> rpush mylist A
|
||||
(integer) 1
|
||||
> rpush mylist B
|
||||
(integer) 2
|
||||
> lpush mylist first
|
||||
(integer) 3
|
||||
> lrange mylist 0 -1 # -1 表示倒数第一个元素, 这里表示从第一个元素到最后一个元素,即所有
|
||||
1) "first"
|
||||
2) "A"
|
||||
3) "B"
|
||||
```
|
||||
|
||||
#### list 实现队列
|
||||
|
||||
队列是先进先出的数据结构,常用于消息排队和异步逻辑处理,它会确保元素的访问顺序:
|
||||
|
||||
```console
|
||||
> RPUSH books python java golang
|
||||
(integer) 3
|
||||
> LPOP books
|
||||
"python"
|
||||
> LPOP books
|
||||
"java"
|
||||
> LPOP books
|
||||
"golang"
|
||||
> LPOP books
|
||||
(nil)
|
||||
```
|
||||
|
||||
#### list 实现栈
|
||||
|
||||
栈是先进后出的数据结构,跟队列正好相反:
|
||||
|
||||
```console
|
||||
> RPUSH books python java golang
|
||||
> RPOP books
|
||||
"golang"
|
||||
> RPOP books
|
||||
"java"
|
||||
> RPOP books
|
||||
"python"
|
||||
> RPOP books
|
||||
(nil)
|
||||
```
|
||||
|
||||
## 3)字典 hash
|
||||
|
||||
Redis 中的字典相当于 Java 中的 **HashMap**,内部实现也差不多类似,都是通过 **"数组 + 链表"** 的链地址法来解决部分 **哈希冲突**,同时这样的结构也吸收了两种不同数据结构的优点。源码定义如 `dict.h/dictht` 定义:
|
||||
|
||||
```c
|
||||
typedef struct dictht {
|
||||
// 哈希表数组
|
||||
dictEntry **table;
|
||||
// 哈希表大小
|
||||
unsigned long size;
|
||||
// 哈希表大小掩码,用于计算索引值,总是等于 size - 1
|
||||
unsigned long sizemask;
|
||||
// 该哈希表已有节点的数量
|
||||
unsigned long used;
|
||||
} dictht;
|
||||
|
||||
typedef struct dict {
|
||||
dictType *type;
|
||||
void *privdata;
|
||||
// 内部有两个 dictht 结构
|
||||
dictht ht[2];
|
||||
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
|
||||
unsigned long iterators; /* number of iterators currently running */
|
||||
} dict;
|
||||
```
|
||||
|
||||
`table` 属性是一个数组,数组中的每个元素都是一个指向 `dict.h/dictEntry` 结构的指针,而每个 `dictEntry` 结构保存着一个键值对:
|
||||
|
||||
```c
|
||||
typedef struct dictEntry {
|
||||
// 键
|
||||
void *key;
|
||||
// 值
|
||||
union {
|
||||
void *val;
|
||||
uint64_t u64;
|
||||
int64_t s64;
|
||||
double d;
|
||||
} v;
|
||||
// 指向下个哈希表节点,形成链表
|
||||
struct dictEntry *next;
|
||||
} dictEntry;
|
||||
```
|
||||
|
||||
可以从上面的源码中看到,**实际上字典结构的内部包含两个 hashtable**,通常情况下只有一个 hashtable 是有值的,但是在字典扩容缩容时,需要分配新的 hashtable,然后进行 **渐进式搬迁** *(下面说原因)*。
|
||||
|
||||
### 渐进式 rehash
|
||||
|
||||
大字典的扩容是比较耗时间的,需要重新申请新的数组,然后将旧字典所有链表中的元素重新挂接到新的数组下面,这是一个 O(n) 级别的操作,作为单线程的 Redis 很难承受这样耗时的过程,所以 Redis 使用 **渐进式 rehash** 小步搬迁:
|
||||
|
||||

|
||||
|
||||
渐进式 rehash 会在 rehash 的同时,保留新旧两个 hash 结构,如上图所示,查询时会同时查询两个 hash 结构,然后在后续的定时任务以及 hash 操作指令中,循序渐进的把旧字典的内容迁移到新字典中。当搬迁完成了,就会使用新的 hash 结构取而代之。
|
||||
|
||||
### 扩缩容的条件
|
||||
|
||||
正常情况下,当 hash 表中 **元素的个数等于第一维数组的长度时**,就会开始扩容,扩容的新数组是 **原数组大小的 2 倍**。不过如果 Redis 正在做 `bgsave(持久化命令)`,为了减少内存也得过多分离,Redis 尽量不去扩容,但是如果 hash 表非常满了,**达到了第一维数组长度的 5 倍了**,这个时候就会 **强制扩容**。
|
||||
|
||||
当 hash 表因为元素逐渐被删除变得越来越稀疏时,Redis 会对 hash 表进行缩容来减少 hash 表的第一维数组空间占用。所用的条件是 **元素个数低于数组长度的 10%**,缩容不会考虑 Redis 是否在做 `bgsave`。
|
||||
|
||||
### 字典的基本操作
|
||||
|
||||
hash 也有缺点,hash 结构的存储消耗要高于单个字符串,所以到底该使用 hash 还是字符串,需要根据实际情况再三权衡:
|
||||
|
||||
```console
|
||||
> HSET books java "think in java" # 命令行的字符串如果包含空格则需要使用引号包裹
|
||||
(integer) 1
|
||||
> HSET books python "python cookbook"
|
||||
(integer) 1
|
||||
> HGETALL books # key 和 value 间隔出现
|
||||
1) "java"
|
||||
2) "think in java"
|
||||
3) "python"
|
||||
4) "python cookbook"
|
||||
> HGET books java
|
||||
"think in java"
|
||||
> HSET books java "head first java"
|
||||
(integer) 0 # 因为是更新操作,所以返回 0
|
||||
> HMSET books java "effetive java" python "learning python" # 批量操作
|
||||
OK
|
||||
```
|
||||
|
||||
## 4)集合 set
|
||||
|
||||
Redis 的集合相当于 Java 语言中的 **HashSet**,它内部的键值对是无序、唯一的。它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL。
|
||||
|
||||
### 集合 set 的基本使用
|
||||
|
||||
由于该结构比较简单,我们直接来看看是如何使用的:
|
||||
|
||||
```console
|
||||
> SADD books java
|
||||
(integer) 1
|
||||
> SADD books java # 重复
|
||||
(integer) 0
|
||||
> SADD books python golang
|
||||
(integer) 2
|
||||
> SMEMBERS books # 注意顺序,set 是无序的
|
||||
1) "java"
|
||||
2) "python"
|
||||
3) "golang"
|
||||
> SISMEMBER books java # 查询某个 value 是否存在,相当于 contains
|
||||
(integer) 1
|
||||
> SCARD books # 获取长度
|
||||
(integer) 3
|
||||
> SPOP books # 弹出一个
|
||||
"java"
|
||||
```
|
||||
|
||||
## 5)有序列表 zset
|
||||
|
||||
这可能使 Redis 最具特色的一个数据结构了,它类似于 Java 中 **SortedSet** 和 **HashMap** 的结合体,一方面它是一个 set,保证了内部 value 的唯一性,另一方面它可以为每个 value 赋予一个 score 值,用来代表排序的权重。
|
||||
|
||||
它的内部实现用的是一种叫做 **「跳跃表」** 的数据结构,由于比较复杂,所以在这里简单提一下原理就好了:
|
||||
|
||||

|
||||
|
||||
|
||||
想象你是一家创业公司的老板,刚开始只有几个人,大家都平起平坐。后来随着公司的发展,人数越来越多,团队沟通成本逐渐增加,渐渐地引入了组长制,对团队进行划分,于是有一些人**又是员工又有组长的身份**。
|
||||
|
||||
再后来,公司规模进一步扩大,公司需要再进入一个层级:部门。于是每个部门又会从组长中推举一位选出部长。
|
||||
|
||||
跳跃表就类似于这样的机制,最下面一层所有的元素都会串起来,都是员工,然后每隔几个元素就会挑选出一个代表,再把这几个代表使用另外一级指针串起来。然后再在这些代表里面挑出二级代表,再串起来。**最终形成了一个金字塔的结构。**
|
||||
|
||||
想一下你目前所在的地理位置:亚洲 > 中国 > 某省 > 某市 > ....,**就是这样一个结构!**
|
||||
|
||||
### 有序列表 zset 基础操作
|
||||
|
||||
```console
|
||||
> ZADD books 9.0 "think in java"
|
||||
> ZADD books 8.9 "java concurrency"
|
||||
> ZADD books 8.6 "java cookbook"
|
||||
|
||||
> ZRANGE books 0 -1 # 按 score 排序列出,参数区间为排名范围
|
||||
1) "java cookbook"
|
||||
2) "java concurrency"
|
||||
3) "think in java"
|
||||
|
||||
> ZREVRANGE books 0 -1 # 按 score 逆序列出,参数区间为排名范围
|
||||
1) "think in java"
|
||||
2) "java concurrency"
|
||||
3) "java cookbook"
|
||||
|
||||
> ZCARD books # 相当于 count()
|
||||
(integer) 3
|
||||
|
||||
> ZSCORE books "java concurrency" # 获取指定 value 的 score
|
||||
"8.9000000000000004" # 内部 score 使用 double 类型进行存储,所以存在小数点精度问题
|
||||
|
||||
> ZRANK books "java concurrency" # 排名
|
||||
(integer) 1
|
||||
|
||||
> ZRANGEBYSCORE books 0 8.91 # 根据分值区间遍历 zset
|
||||
1) "java cookbook"
|
||||
2) "java concurrency"
|
||||
|
||||
> ZRANGEBYSCORE books -inf 8.91 withscores # 根据分值区间 (-∞, 8.91] 遍历 zset,同时返回分值。inf 代表 infinite,无穷大的意思。
|
||||
1) "java cookbook"
|
||||
2) "8.5999999999999996"
|
||||
3) "java concurrency"
|
||||
4) "8.9000000000000004"
|
||||
|
||||
> ZREM books "java concurrency" # 删除 value
|
||||
(integer) 1
|
||||
> ZRANGE books 0 -1
|
||||
1) "java cookbook"
|
||||
2) "think in java"
|
||||
```
|
||||
|
||||
# 扩展/相关阅读
|
||||
|
||||
1. 阿里云 Redis 开发规范 - [https://www.infoq.cn/article/K7dB5AFKI9mr5Ugbs_px](https://www.infoq.cn/article/K7dB5AFKI9mr5Ugbs_px)
|
||||
2. 为什么要防止 bigkey? - [https://mp.weixin.qq.com/s?__biz=Mzg2NTEyNzE0OA==&mid=2247483677&idx=1&sn=5c320b46f0e06ce9369a29909d62b401&chksm=ce5f9e9ef928178834021b6f9b939550ac400abae5c31e1933bafca2f16b23d028cc51813aec&scene=21#wechat_redirect](https://mp.weixin.qq.com/s?__biz=Mzg2NTEyNzE0OA==&mid=2247483677&idx=1&sn=5c320b46f0e06ce9369a29909d62b401&chksm=ce5f9e9ef928178834021b6f9b939550ac400abae5c31e1933bafca2f16b23d028cc51813aec&scene=21#wechat_redirect)
|
||||
3. Redis【入门】就这一篇! - [https://www.wmyskxz.com/2018/05/31/redis-ru-men-jiu-zhe-yi-pian/](https://www.wmyskxz.com/2018/05/31/redis-ru-men-jiu-zhe-yi-pian/)
|
||||
|
||||
# 参考资料
|
||||
|
||||
1. 《Redis 设计与实现》 - [http://redisbook.com/](http://redisbook.com/)
|
||||
2. 【官方文档】Redis 数据类型介绍 - [http://www.redis.cn/topics/data-types-intro.html](http://www.redis.cn/topics/data-types-intro.html)
|
||||
3. 《Redis 深度历险》 - [https://book.douban.com/subject/30386804/](https://book.douban.com/subject/30386804/)
|
||||
4. 阿里云 Redis 开发规范 - [https://www.infoq.cn/article/K7dB5AFKI9mr5Ugbs_px](https://www.infoq.cn/article/K7dB5AFKI9mr5Ugbs_px)
|
||||
5. Redis 快速入门 - 易百教程 - [https://www.yiibai.com/redis/redis_quick_guide.html](https://www.yiibai.com/redis/redis_quick_guide.html)
|
||||
6. Redis【入门】就这一篇! - [https://www.wmyskxz.com/2018/05/31/redis-ru-men-jiu-zhe-yi-pian/](https://www.wmyskxz.com/2018/05/31/redis-ru-men-jiu-zhe-yi-pian/)
|
||||
|
392
docs/database/Redis/redis-collection/Redis(2)——跳跃表.md
Normal file
392
docs/database/Redis/redis-collection/Redis(2)——跳跃表.md
Normal file
@ -0,0 +1,392 @@
|
||||
> 授权转载自: https://github.com/wmyskxz/MoreThanJava#part3-redis
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
# 一、跳跃表简介
|
||||
|
||||
跳跃表(skiplist)是一种随机化的数据结构,由 **William Pugh** 在论文[《Skip lists: a probabilistic alternative to balanced trees》](https://www.cl.cam.ac.uk/teaching/0506/Algorithms/skiplists.pdf)中提出,是一种可以于平衡树媲美的层次化链表结构——查找、删除、添加等操作都可以在对数期望时间下完成,以下是一个典型的跳跃表例子:
|
||||
|
||||

|
||||
|
||||
我们在上一篇中提到了 Redis 的五种基本结构中,有一个叫做 **有序列表 zset** 的数据结构,它类似于 Java 中的 **SortedSet** 和 **HashMap** 的结合体,一方面它是一个 set 保证了内部 value 的唯一性,另一方面又可以给每个 value 赋予一个排序的权重值 score,来达到 **排序** 的目的。
|
||||
|
||||
它的内部实现就依赖了一种叫做 **「跳跃列表」** 的数据结构。
|
||||
|
||||
## 为什么使用跳跃表
|
||||
|
||||
首先,因为 zset 要支持随机的插入和删除,所以它 **不宜使用数组来实现**,关于排序问题,我们也很容易就想到 **红黑树/ 平衡树** 这样的树形结构,为什么 Redis 不使用这样一些结构呢?
|
||||
|
||||
1. **性能考虑:** 在高并发的情况下,树形结构需要执行一些类似于 rebalance 这样的可能涉及整棵树的操作,相对来说跳跃表的变化只涉及局部 _(下面详细说)_;
|
||||
2. **实现考虑:** 在复杂度与红黑树相同的情况下,跳跃表实现起来更简单,看起来也更加直观;
|
||||
|
||||
基于以上的一些考虑,Redis 基于 **William Pugh** 的论文做出一些改进后采用了 **跳跃表** 这样的结构。
|
||||
|
||||
## 本质是解决查找问题
|
||||
|
||||
我们先来看一个普通的链表结构:
|
||||
|
||||

|
||||
|
||||
我们需要这个链表按照 score 值进行排序,这也就意味着,当我们需要添加新的元素时,我们需要定位到插入点,这样才可以继续保证链表是有序的,通常我们会使用 **二分查找法**,但二分查找是有序数组的,链表没办法进行位置定位,我们除了遍历整个找到第一个比给定数据大的节点为止 _(时间复杂度 O(n))_ 似乎没有更好的办法。
|
||||
|
||||
但假如我们每相邻两个节点之间就增加一个指针,让指针指向下一个节点,如下图:
|
||||
|
||||

|
||||
|
||||
这样所有新增的指针连成了一个新的链表,但它包含的数据却只有原来的一半 _(图中的为 3,11)_。
|
||||
|
||||
现在假设我们想要查找数据时,可以根据这条新的链表查找,如果碰到比待查找数据大的节点时,再回到原来的链表中进行查找,比如,我们想要查找 7,查找的路径则是沿着下图中标注出的红色指针所指向的方向进行的:
|
||||
|
||||

|
||||
|
||||
这是一个略微极端的例子,但我们仍然可以看到,通过新增加的指针查找,我们不再需要与链表上的每一个节点逐一进行比较,这样改进之后需要比较的节点数大概只有原来的一半。
|
||||
|
||||
利用同样的方式,我们可以在新产生的链表上,继续为每两个相邻的节点增加一个指针,从而产生第三层链表:
|
||||
|
||||

|
||||
|
||||
在这个新的三层链表结构中,我们试着 **查找 13**,那么沿着最上层链表首先比较的是 11,发现 11 比 13 小,于是我们就知道只需要到 11 后面继续查找,**从而一下子跳过了 11 前面的所有节点。**
|
||||
|
||||
可以想象,当链表足够长,这样的多层链表结构可以帮助我们跳过很多下层节点,从而加快查找的效率。
|
||||
|
||||
## 更进一步的跳跃表
|
||||
|
||||
**跳跃表 skiplist** 就是受到这种多层链表结构的启发而设计出来的。按照上面生成链表的方式,上面每一层链表的节点个数,是下面一层的节点个数的一半,这样查找过程就非常类似于一个二分查找,使得查找的时间复杂度可以降低到 _O(logn)_。
|
||||
|
||||
但是,这种方法在插入数据的时候有很大的问题。新插入一个节点之后,就会打乱上下相邻两层链表上节点个数严格的 2:1 的对应关系。如果要维持这种对应关系,就必须把新插入的节点后面的所有节点 _(也包括新插入的节点)_ 重新进行调整,这会让时间复杂度重新蜕化成 _O(n)_。删除数据也有同样的问题。
|
||||
|
||||
**skiplist** 为了避免这一问题,它不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是 **为每个节点随机出一个层数(level)**。比如,一个节点随机出的层数是 3,那么就把它链入到第 1 层到第 3 层这三层链表中。为了表达清楚,下图展示了如何通过一步步的插入操作从而形成一个 skiplist 的过程:
|
||||
|
||||

|
||||
|
||||
从上面的创建和插入的过程中可以看出,每一个节点的层数(level)是随机出来的,而且新插入一个节点并不会影响到其他节点的层数,因此,**插入操作只需要修改节点前后的指针,而不需要对多个节点都进行调整**,这就降低了插入操作的复杂度。
|
||||
|
||||
现在我们假设从我们刚才创建的这个结构中查找 23 这个不存在的数,那么查找路径会如下图:
|
||||
|
||||

|
||||
|
||||
# 二、跳跃表的实现
|
||||
|
||||
Redis 中的跳跃表由 `server.h/zskiplistNode` 和 `server.h/zskiplist` 两个结构定义,前者为跳跃表节点,后者则保存了跳跃节点的相关信息,同之前的 `集合 list` 结构类似,其实只有 `zskiplistNode` 就可以实现了,但是引入后者是为了更加方便的操作:
|
||||
|
||||
```c
|
||||
/* ZSETs use a specialized version of Skiplists */
|
||||
typedef struct zskiplistNode {
|
||||
// value
|
||||
sds ele;
|
||||
// 分值
|
||||
double score;
|
||||
// 后退指针
|
||||
struct zskiplistNode *backward;
|
||||
// 层
|
||||
struct zskiplistLevel {
|
||||
// 前进指针
|
||||
struct zskiplistNode *forward;
|
||||
// 跨度
|
||||
unsigned long span;
|
||||
} level[];
|
||||
} zskiplistNode;
|
||||
|
||||
typedef struct zskiplist {
|
||||
// 跳跃表头指针
|
||||
struct zskiplistNode *header, *tail;
|
||||
// 表中节点的数量
|
||||
unsigned long length;
|
||||
// 表中层数最大的节点的层数
|
||||
int level;
|
||||
} zskiplist;
|
||||
```
|
||||
|
||||
正如文章开头画出来的那张标准的跳跃表那样。
|
||||
|
||||
## 随机层数
|
||||
|
||||
对于每一个新插入的节点,都需要调用一个随机算法给它分配一个合理的层数,源码在 `t_zset.c/zslRandomLevel(void)` 中被定义:
|
||||
|
||||
```c
|
||||
int zslRandomLevel(void) {
|
||||
int level = 1;
|
||||
while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
|
||||
level += 1;
|
||||
return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
|
||||
}
|
||||
```
|
||||
|
||||
直观上期望的目标是 50% 的概率被分配到 `Level 1`,25% 的概率被分配到 `Level 2`,12.5% 的概率被分配到 `Level 3`,以此类推...有 2<sup>-63</sup> 的概率被分配到最顶层,因为这里每一层的晋升率都是 50%。
|
||||
|
||||
**Redis 跳跃表默认允许最大的层数是 32**,被源码中 `ZSKIPLIST_MAXLEVEL` 定义,当 `Level[0]` 有 2<sup>64</sup> 个元素时,才能达到 32 层,所以定义 32 完全够用了。
|
||||
|
||||
## 创建跳跃表
|
||||
|
||||
这个过程比较简单,在源码中的 `t_zset.c/zslCreate` 中被定义:
|
||||
|
||||
```c
|
||||
zskiplist *zslCreate(void) {
|
||||
int j;
|
||||
zskiplist *zsl;
|
||||
|
||||
// 申请内存空间
|
||||
zsl = zmalloc(sizeof(*zsl));
|
||||
// 初始化层数为 1
|
||||
zsl->level = 1;
|
||||
// 初始化长度为 0
|
||||
zsl->length = 0;
|
||||
// 创建一个层数为 32,分数为 0,没有 value 值的跳跃表头节点
|
||||
zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL);
|
||||
|
||||
// 跳跃表头节点初始化
|
||||
for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
|
||||
// 将跳跃表头节点的所有前进指针 forward 设置为 NULL
|
||||
zsl->header->level[j].forward = NULL;
|
||||
// 将跳跃表头节点的所有跨度 span 设置为 0
|
||||
zsl->header->level[j].span = 0;
|
||||
}
|
||||
// 跳跃表头节点的后退指针 backward 置为 NULL
|
||||
zsl->header->backward = NULL;
|
||||
// 表头指向跳跃表尾节点的指针置为 NULL
|
||||
zsl->tail = NULL;
|
||||
return zsl;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
即执行完之后创建了如下结构的初始化跳跃表:
|
||||
|
||||

|
||||
|
||||
|
||||
## 插入节点实现
|
||||
|
||||
这几乎是最重要的一段代码了,但总体思路也比较清晰简单,如果理解了上面所说的跳跃表的原理,那么很容易理清楚插入节点时发生的几个动作 *(几乎跟链表类似)*:
|
||||
|
||||
1. 找到当前我需要插入的位置 *(其中包括相同 score 时的处理)*;
|
||||
2. 创建新节点,调整前后的指针指向,完成插入;
|
||||
|
||||
为了方便阅读,我把源码 `t_zset.c/zslInsert` 定义的插入函数拆成了几个部分
|
||||
|
||||
### 第一部分:声明需要存储的变量
|
||||
|
||||
```c
|
||||
// 存储搜索路径
|
||||
zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
|
||||
// 存储经过的节点跨度
|
||||
unsigned int rank[ZSKIPLIST_MAXLEVEL];
|
||||
int i, level;
|
||||
```
|
||||
|
||||
### 第二部分:搜索当前节点插入位置
|
||||
|
||||
```c
|
||||
serverAssert(!isnan(score));
|
||||
x = zsl->header;
|
||||
// 逐步降级寻找目标节点,得到 "搜索路径"
|
||||
for (i = zsl->level-1; i >= 0; i--) {
|
||||
/* store rank that is crossed to reach the insert position */
|
||||
rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
|
||||
// 如果 score 相等,还需要比较 value 值
|
||||
while (x->level[i].forward &&
|
||||
(x->level[i].forward->score < score ||
|
||||
(x->level[i].forward->score == score &&
|
||||
sdscmp(x->level[i].forward->ele,ele) < 0)))
|
||||
{
|
||||
rank[i] += x->level[i].span;
|
||||
x = x->level[i].forward;
|
||||
}
|
||||
// 记录 "搜索路径"
|
||||
update[i] = x;
|
||||
}
|
||||
```
|
||||
|
||||
**讨论:** 有一种极端的情况,就是跳跃表中的所有 score 值都是一样,zset 的查找性能会不会退化为 O(n) 呢?
|
||||
|
||||
从上面的源码中我们可以发现 zset 的排序元素不只是看 score 值,也会比较 value 值 *(字符串比较)*
|
||||
|
||||
### 第三部分:生成插入节点
|
||||
|
||||
```c
|
||||
/* we assume the element is not already inside, since we allow duplicated
|
||||
* scores, reinserting the same element should never happen since the
|
||||
* caller of zslInsert() should test in the hash table if the element is
|
||||
* already inside or not. */
|
||||
level = zslRandomLevel();
|
||||
// 如果随机生成的 level 超过了当前最大 level 需要更新跳跃表的信息
|
||||
if (level > zsl->level) {
|
||||
for (i = zsl->level; i < level; i++) {
|
||||
rank[i] = 0;
|
||||
update[i] = zsl->header;
|
||||
update[i]->level[i].span = zsl->length;
|
||||
}
|
||||
zsl->level = level;
|
||||
}
|
||||
// 创建新节点
|
||||
x = zslCreateNode(level,score,ele);
|
||||
```
|
||||
|
||||
### 第四部分:重排前向指针
|
||||
|
||||
```c
|
||||
for (i = 0; i < level; i++) {
|
||||
x->level[i].forward = update[i]->level[i].forward;
|
||||
update[i]->level[i].forward = x;
|
||||
|
||||
/* update span covered by update[i] as x is inserted here */
|
||||
x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
|
||||
update[i]->level[i].span = (rank[0] - rank[i]) + 1;
|
||||
}
|
||||
|
||||
/* increment span for untouched levels */
|
||||
for (i = level; i < zsl->level; i++) {
|
||||
update[i]->level[i].span++;
|
||||
}
|
||||
```
|
||||
|
||||
### 第五部分:重排后向指针并返回
|
||||
|
||||
```c
|
||||
x->backward = (update[0] == zsl->header) ? NULL : update[0];
|
||||
if (x->level[0].forward)
|
||||
x->level[0].forward->backward = x;
|
||||
else
|
||||
zsl->tail = x;
|
||||
zsl->length++;
|
||||
return x;
|
||||
```
|
||||
|
||||
## 节点删除实现
|
||||
|
||||
删除过程由源码中的 `t_zset.c/zslDeleteNode` 定义,和插入过程类似,都需要先把这个 **"搜索路径"** 找出来,然后对于每个层的相关节点重排一下前向后向指针,同时还要注意更新一下最高层数 `maxLevel`,直接放源码 *(如果理解了插入这里还是很容易理解的)*:
|
||||
|
||||
```c
|
||||
/* Internal function used by zslDelete, zslDeleteByScore and zslDeleteByRank */
|
||||
void zslDeleteNode(zskiplist *zsl, zskiplistNode *x, zskiplistNode **update) {
|
||||
int i;
|
||||
for (i = 0; i < zsl->level; i++) {
|
||||
if (update[i]->level[i].forward == x) {
|
||||
update[i]->level[i].span += x->level[i].span - 1;
|
||||
update[i]->level[i].forward = x->level[i].forward;
|
||||
} else {
|
||||
update[i]->level[i].span -= 1;
|
||||
}
|
||||
}
|
||||
if (x->level[0].forward) {
|
||||
x->level[0].forward->backward = x->backward;
|
||||
} else {
|
||||
zsl->tail = x->backward;
|
||||
}
|
||||
while(zsl->level > 1 && zsl->header->level[zsl->level-1].forward == NULL)
|
||||
zsl->level--;
|
||||
zsl->length--;
|
||||
}
|
||||
|
||||
/* Delete an element with matching score/element from the skiplist.
|
||||
* The function returns 1 if the node was found and deleted, otherwise
|
||||
* 0 is returned.
|
||||
*
|
||||
* If 'node' is NULL the deleted node is freed by zslFreeNode(), otherwise
|
||||
* it is not freed (but just unlinked) and *node is set to the node pointer,
|
||||
* so that it is possible for the caller to reuse the node (including the
|
||||
* referenced SDS string at node->ele). */
|
||||
int zslDelete(zskiplist *zsl, double score, sds ele, zskiplistNode **node) {
|
||||
zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
|
||||
int i;
|
||||
|
||||
x = zsl->header;
|
||||
for (i = zsl->level-1; i >= 0; i--) {
|
||||
while (x->level[i].forward &&
|
||||
(x->level[i].forward->score < score ||
|
||||
(x->level[i].forward->score == score &&
|
||||
sdscmp(x->level[i].forward->ele,ele) < 0)))
|
||||
{
|
||||
x = x->level[i].forward;
|
||||
}
|
||||
update[i] = x;
|
||||
}
|
||||
/* We may have multiple elements with the same score, what we need
|
||||
* is to find the element with both the right score and object. */
|
||||
x = x->level[0].forward;
|
||||
if (x && score == x->score && sdscmp(x->ele,ele) == 0) {
|
||||
zslDeleteNode(zsl, x, update);
|
||||
if (!node)
|
||||
zslFreeNode(x);
|
||||
else
|
||||
*node = x;
|
||||
return 1;
|
||||
}
|
||||
return 0; /* not found */
|
||||
}
|
||||
```
|
||||
|
||||
## 节点更新实现
|
||||
|
||||
当我们调用 `ZADD` 方法时,如果对应的 value 不存在,那就是插入过程,如果这个 value 已经存在,只是调整一下 score 的值,那就需要走一个更新流程。
|
||||
|
||||
假设这个新的 score 值并不会带来排序上的变化,那么就不需要调整位置,直接修改元素的 score 值就可以了,但是如果排序位置改变了,那就需要调整位置,该如何调整呢?
|
||||
|
||||
从源码 `t_zset.c/zsetAdd` 函数 `1350` 行左右可以看到,Redis 采用了一个非常简单的策略:
|
||||
|
||||
```c
|
||||
/* Remove and re-insert when score changed. */
|
||||
if (score != curscore) {
|
||||
zobj->ptr = zzlDelete(zobj->ptr,eptr);
|
||||
zobj->ptr = zzlInsert(zobj->ptr,ele,score);
|
||||
*flags |= ZADD_UPDATED;
|
||||
}
|
||||
```
|
||||
|
||||
**把这个元素删除再插入这个**,需要经过两次路径搜索,从这一点上来看,Redis 的 `ZADD` 代码似乎还有进一步优化的空间。
|
||||
|
||||
## 元素排名的实现
|
||||
|
||||
跳跃表本身是有序的,Redis 在 skiplist 的 forward 指针上进行了优化,给每一个 forward 指针都增加了 `span` 属性,用来 **表示从前一个节点沿着当前层的 forward 指针跳到当前这个节点中间会跳过多少个节点**。在上面的源码中我们也可以看到 Redis 在插入、删除操作时都会小心翼翼地更新 `span` 值的大小。
|
||||
|
||||
所以,沿着 **"搜索路径"**,把所有经过节点的跨度 `span` 值进行累加就可以算出当前元素的最终 rank 值了:
|
||||
|
||||
```c
|
||||
/* Find the rank for an element by both score and key.
|
||||
* Returns 0 when the element cannot be found, rank otherwise.
|
||||
* Note that the rank is 1-based due to the span of zsl->header to the
|
||||
* first element. */
|
||||
unsigned long zslGetRank(zskiplist *zsl, double score, sds ele) {
|
||||
zskiplistNode *x;
|
||||
unsigned long rank = 0;
|
||||
int i;
|
||||
|
||||
x = zsl->header;
|
||||
for (i = zsl->level-1; i >= 0; i--) {
|
||||
while (x->level[i].forward &&
|
||||
(x->level[i].forward->score < score ||
|
||||
(x->level[i].forward->score == score &&
|
||||
sdscmp(x->level[i].forward->ele,ele) <= 0))) {
|
||||
// span 累加
|
||||
rank += x->level[i].span;
|
||||
x = x->level[i].forward;
|
||||
}
|
||||
|
||||
/* x might be equal to zsl->header, so test if obj is non-NULL */
|
||||
if (x->ele && sdscmp(x->ele,ele) == 0) {
|
||||
return rank;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
# 扩展阅读
|
||||
|
||||
1. 跳跃表 Skip List 的原理和实现(Java) - [https://blog.csdn.net/DERRANTCM/article/details/79063312](https://blog.csdn.net/DERRANTCM/article/details/79063312)
|
||||
2. 【算法导论33】跳跃表(Skip list)原理与java实现 - [https://blog.csdn.net/brillianteagle/article/details/52206261](https://blog.csdn.net/brillianteagle/article/details/52206261)
|
||||
|
||||
# 参考资料
|
||||
|
||||
1. 《Redis 设计与实现》 - [http://redisbook.com/](http://redisbook.com/)
|
||||
2. 【官方文档】Redis 数据类型介绍 - [http://www.redis.cn/topics/data-types-intro.html](http://www.redis.cn/topics/data-types-intro.html)
|
||||
3. 《Redis 深度历险》 - [https://book.douban.com/subject/30386804/](https://book.douban.com/subject/30386804/)
|
||||
4. Redis 源码 - [https://github.com/antirez/redis](https://github.com/antirez/redis)
|
||||
5. Redis 快速入门 - 易百教程 - [https://www.yiibai.com/redis/redis_quick_guide.html](https://www.yiibai.com/redis/redis_quick_guide.html)
|
||||
6. Redis【入门】就这一篇! - [https://www.wmyskxz.com/2018/05/31/redis-ru-men-jiu-zhe-yi-pian/](https://www.wmyskxz.com/2018/05/31/redis-ru-men-jiu-zhe-yi-pian/)
|
||||
7. Redis为什么用跳表而不用平衡树? - [https://mp.weixin.qq.com/s?__biz=MzA4NTg1MjM0Mg==&mid=2657261425&idx=1&sn=d840079ea35875a8c8e02d9b3e44cf95&scene=21#wechat_redirect](https://mp.weixin.qq.com/s?__biz=MzA4NTg1MjM0Mg==&mid=2657261425&idx=1&sn=d840079ea35875a8c8e02d9b3e44cf95&scene=21#wechat_redirect)
|
||||
8. 为啥 redis 使用跳表(skiplist)而不是使用 red-black? - 知乎@于康 - [https://www.zhihu.com/question/20202931](https://www.zhihu.com/question/20202931)
|
||||
|
228
docs/database/Redis/redis-collection/Redis(3)——分布式锁深入探究.md
Normal file
228
docs/database/Redis/redis-collection/Redis(3)——分布式锁深入探究.md
Normal file
@ -0,0 +1,228 @@
|
||||
> 授权转载自: https://github.com/wmyskxz/MoreThanJava#part3-redis
|
||||
|
||||

|
||||
|
||||
# 一、分布式锁简介
|
||||
|
||||
**锁** 是一种用来解决多个执行线程 **访问共享资源** 错误或数据不一致问题的工具。
|
||||
|
||||
如果 *把一台服务器比作一个房子*,那么 *线程就好比里面的住户*,当他们想要共同访问一个共享资源,例如厕所的时候,如果厕所门上没有锁...更甚者厕所没装门...这是会出原则性的问题的..
|
||||
|
||||

|
||||
|
||||
装上了锁,大家用起来就安心多了,本质也就是 **同一时间只允许一个住户使用**。
|
||||
|
||||
而随着互联网世界的发展,单体应用已经越来越无法满足复杂互联网的高并发需求,转而慢慢朝着分布式方向发展,慢慢进化成了 **更大一些的住户**。所以同样,我们需要引入分布式锁来解决分布式应用之间访问共享资源的并发问题。
|
||||
|
||||
## 为何需要分布式锁
|
||||
|
||||
一般情况下,我们使用分布式锁主要有两个场景:
|
||||
|
||||
1. **避免不同节点重复相同的工作**:比如用户执行了某个操作有可能不同节点会发送多封邮件;
|
||||
2. **避免破坏数据的正确性**:如果两个节点在同一条数据上同时进行操作,可能会造成数据错误或不一致的情况出现;
|
||||
|
||||
## Java 中实现的常见方式
|
||||
|
||||
上面我们用简单的比喻说明了锁的本质:**同一时间只允许一个用户操作**。所以理论上,能够满足这个需求的工具我们都能够使用 *(就是其他应用能帮我们加锁的)*:
|
||||
|
||||
1. **基于 MySQL 中的锁**:MySQL 本身有自带的悲观锁 `for update` 关键字,也可以自己实现悲观/乐观锁来达到目的;
|
||||
2. **基于 Zookeeper 有序节点**:Zookeeper 允许临时创建有序的子节点,这样客户端获取节点列表时,就能够当前子节点列表中的序号判断是否能够获得锁;
|
||||
3. **基于 Redis 的单线程**:由于 Redis 是单线程,所以命令会以串行的方式执行,并且本身提供了像 `SETNX(set if not exists)` 这样的指令,本身具有互斥性;
|
||||
|
||||
每个方案都有各自的优缺点,例如 MySQL 虽然直观理解容易,但是实现起来却需要额外考虑 **锁超时**、**加事务** 等,并且性能局限于数据库,诸如此类我们在此不作讨论,重点关注 Redis。
|
||||
|
||||
## Redis 分布式锁的问题
|
||||
|
||||
### 1)锁超时
|
||||
|
||||
假设现在我们有两台平行的服务 A B,其中 A 服务在 **获取锁之后** 由于未知神秘力量突然 **挂了**,那么 B 服务就永远无法获取到锁了:
|
||||
|
||||

|
||||
|
||||
所以我们需要额外设置一个超时时间,来保证服务的可用性。
|
||||
|
||||
但是另一个问题随即而来:**如果在加锁和释放锁之间的逻辑执行得太长,以至于超出了锁的超时限制**,也会出现问题。因为这时候第一个线程持有锁过期了,而临界区的逻辑还没有执行完,与此同时第二个线程就提前拥有了这把锁,导致临界区的代码不能得到严格的串行执行。
|
||||
|
||||
为了避免这个问题,**Redis 分布式锁不要用于较长时间的任务**。如果真的偶尔出现了问题,造成的数据小错乱可能就需要人工的干预。
|
||||
|
||||
有一个稍微安全一点的方案是 **将锁的 `value` 值设置为一个随机数**,释放锁时先匹配随机数是否一致,然后再删除 key,这是为了 **确保当前线程占有的锁不会被其他线程释放**,除非这个锁是因为过期了而被服务器自动释放的。
|
||||
|
||||
但是匹配 `value` 和删除 `key` 在 Redis 中并不是一个原子性的操作,也没有类似保证原子性的指令,所以可能需要使用像 Lua 这样的脚本来处理了,因为 Lua 脚本可以 **保证多个指令的原子性执行**。
|
||||
|
||||
### 延伸的讨论:GC 可能引发的安全问题
|
||||
|
||||
[Martin Kleppmann](https://martin.kleppmann.com/) 曾与 Redis 之父 Antirez 就 Redis 实现分布式锁的安全性问题进行过深入的讨论,其中有一个问题就涉及到 **GC**。
|
||||
|
||||
熟悉 Java 的同学肯定对 GC 不陌生,在 GC 的时候会发生 **STW(Stop-The-World)**,这本身是为了保障垃圾回收器的正常执行,但可能会引发如下的问题:
|
||||
|
||||

|
||||
|
||||
服务 A 获取了锁并设置了超时时间,但是服务 A 出现了 STW 且时间较长,导致了分布式锁进行了超时释放,在这个期间服务 B 获取到了锁,待服务 A STW 结束之后又恢复了锁,这就导致了 **服务 A 和服务 B 同时获取到了锁**,这个时候分布式锁就不安全了。
|
||||
|
||||
不仅仅局限于 Redis,Zookeeper 和 MySQL 有同样的问题。
|
||||
|
||||
想吃更多瓜的童鞋,可以访问下列网站看看 Redis 之父 Antirez 怎么说:[http://antirez.com/news/101](http://antirez.com/news/101)
|
||||
|
||||
### 2)单点/多点问题
|
||||
|
||||
如果 Redis 采用单机部署模式,那就意味着当 Redis 故障了,就会导致整个服务不可用。
|
||||
|
||||
而如果采用主从模式部署,我们想象一个这样的场景:*服务 A* 申请到一把锁之后,如果作为主机的 Redis 宕机了,那么 *服务 B* 在申请锁的时候就会从从机那里获取到这把锁,为了解决这个问题,Redis 作者提出了一种 **RedLock 红锁** 的算法 *(Redission 同 Jedis)*:
|
||||
|
||||
```java
|
||||
// 三个 Redis 集群
|
||||
RLock lock1 = redissionInstance1.getLock("lock1");
|
||||
RLock lock2 = redissionInstance2.getLock("lock2");
|
||||
RLock lock3 = redissionInstance3.getLock("lock3");
|
||||
|
||||
RedissionRedLock lock = new RedissionLock(lock1, lock2, lock2);
|
||||
lock.lock();
|
||||
// do something....
|
||||
lock.unlock();
|
||||
```
|
||||
|
||||
# 二、Redis 分布式锁的实现
|
||||
|
||||
分布式锁类似于 "占坑",而 `SETNX(SET if Not eXists)` 指令就是这样的一个操作,只允许被一个客户端占有,我们来看看 **源码(t_string.c/setGenericCommand)** 吧:
|
||||
|
||||
```c
|
||||
// SET/ SETEX/ SETTEX/ SETNX 最底层实现
|
||||
void setGenericCommand(client *c, int flags, robj *key, robj *val, robj *expire, int unit, robj *ok_reply, robj *abort_reply) {
|
||||
long long milliseconds = 0; /* initialized to avoid any harmness warning */
|
||||
|
||||
// 如果定义了 key 的过期时间则保存到上面定义的变量中
|
||||
// 如果过期时间设置错误则返回错误信息
|
||||
if (expire) {
|
||||
if (getLongLongFromObjectOrReply(c, expire, &milliseconds, NULL) != C_OK)
|
||||
return;
|
||||
if (milliseconds <= 0) {
|
||||
addReplyErrorFormat(c,"invalid expire time in %s",c->cmd->name);
|
||||
return;
|
||||
}
|
||||
if (unit == UNIT_SECONDS) milliseconds *= 1000;
|
||||
}
|
||||
|
||||
// lookupKeyWrite 函数是为执行写操作而取出 key 的值对象
|
||||
// 这里的判断条件是:
|
||||
// 1.如果设置了 NX(不存在),并且在数据库中找到了 key 值
|
||||
// 2.或者设置了 XX(存在),并且在数据库中没有找到该 key
|
||||
// => 那么回复 abort_reply 给客户端
|
||||
if ((flags & OBJ_SET_NX && lookupKeyWrite(c->db,key) != NULL) ||
|
||||
(flags & OBJ_SET_XX && lookupKeyWrite(c->db,key) == NULL))
|
||||
{
|
||||
addReply(c, abort_reply ? abort_reply : shared.null[c->resp]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 在当前的数据库中设置键为 key 值为 value 的数据
|
||||
genericSetKey(c->db,key,val,flags & OBJ_SET_KEEPTTL);
|
||||
// 服务器每修改一个 key 后都会修改 dirty 值
|
||||
server.dirty++;
|
||||
if (expire) setExpire(c,c->db,key,mstime()+milliseconds);
|
||||
notifyKeyspaceEvent(NOTIFY_STRING,"set",key,c->db->id);
|
||||
if (expire) notifyKeyspaceEvent(NOTIFY_GENERIC,
|
||||
"expire",key,c->db->id);
|
||||
addReply(c, ok_reply ? ok_reply : shared.ok);
|
||||
}
|
||||
```
|
||||
|
||||
就像上面介绍的那样,其实在之前版本的 Redis 中,由于 `SETNX` 和 `EXPIRE` 并不是 **原子指令**,所以在一起执行会出现问题。
|
||||
|
||||
也许你会想到使用 Redis 事务来解决,但在这里不行,因为 `EXPIRE` 命令依赖于 `SETNX` 的执行结果,而事务中没有 `if-else` 的分支逻辑,如果 `SETNX` 没有抢到锁,`EXPIRE` 就不应该执行。
|
||||
|
||||
为了解决这个疑难问题,Redis 开源社区涌现了许多分布式锁的 library,为了治理这个乱象,后来在 Redis 2.8 的版本中,加入了 `SET` 指令的扩展参数,使得 `SETNX` 可以和 `EXPIRE` 指令一起执行了:
|
||||
|
||||
```console
|
||||
> SET lock:test true ex 5 nx
|
||||
OK
|
||||
... do something critical ...
|
||||
> del lock:test
|
||||
```
|
||||
|
||||
你只需要符合 `SET key value [EX seconds | PX milliseconds] [NX | XX] [KEEPTTL]` 这样的格式就好了,你也在下方右拐参照官方的文档:
|
||||
|
||||
- 官方文档:[https://redis.io/commands/set](https://redis.io/commands/set)
|
||||
|
||||
另外,官方文档也在 [`SETNX` 文档](https://redis.io/commands/setnx)中提到了这样一种思路:**把 SETNX 对应 key 的 value 设置为 <current Unix time + lock timeout + 1>**,这样在其他客户端访问时就能够自己判断是否能够获取下一个 value 为上述格式的锁了。
|
||||
|
||||
## 代码实现
|
||||
|
||||
下面用 Jedis 来模拟实现以下,关键代码如下:
|
||||
|
||||
```java
|
||||
private static final String LOCK_SUCCESS = "OK";
|
||||
private static final Long RELEASE_SUCCESS = 1L;
|
||||
private static final String SET_IF_NOT_EXIST = "NX";
|
||||
private static final String SET_WITH_EXPIRE_TIME = "PX";
|
||||
|
||||
@Override
|
||||
public String acquire() {
|
||||
try {
|
||||
// 获取锁的超时时间,超过这个时间则放弃获取锁
|
||||
long end = System.currentTimeMillis() + acquireTimeout;
|
||||
// 随机生成一个 value
|
||||
String requireToken = UUID.randomUUID().toString();
|
||||
while (System.currentTimeMillis() < end) {
|
||||
String result = jedis
|
||||
.set(lockKey, requireToken, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
|
||||
if (LOCK_SUCCESS.equals(result)) {
|
||||
return requireToken;
|
||||
}
|
||||
try {
|
||||
Thread.sleep(100);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("acquire lock due to error", e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean release(String identify) {
|
||||
if (identify == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
|
||||
Object result = new Object();
|
||||
try {
|
||||
result = jedis.eval(script, Collections.singletonList(lockKey),
|
||||
Collections.singletonList(identify));
|
||||
if (RELEASE_SUCCESS.equals(result)) {
|
||||
log.info("release lock success, requestToken:{}", identify);
|
||||
return true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("release lock due to error", e);
|
||||
} finally {
|
||||
if (jedis != null) {
|
||||
jedis.close();
|
||||
}
|
||||
}
|
||||
|
||||
log.info("release lock failed, requestToken:{}, result:{}", identify, result);
|
||||
return false;
|
||||
}
|
||||
```
|
||||
|
||||
- 引用自下方 *参考资料 3*,其中还有 RedLock 的实现和测试,有兴趣的童鞋可以戳一下
|
||||
|
||||
# 推荐阅读
|
||||
|
||||
1. 【官方文档】Distributed locks with Redis - [https://redis.io/topics/distlock](https://redis.io/topics/distlock)
|
||||
2. Redis【入门】就这一篇! - [https://www.wmyskxz.com/2018/05/31/redis-ru-men-jiu-zhe-yi-pian/](https://www.wmyskxz.com/2018/05/31/redis-ru-men-jiu-zhe-yi-pian/)
|
||||
3. Redission - Redis Java Client 源码 - [https://github.com/redisson/redisson](https://github.com/redisson/redisson)
|
||||
4. 手写一个 Jedis 以及 JedisPool - [https://juejin.im/post/5e5101c46fb9a07cab3a953a](https://juejin.im/post/5e5101c46fb9a07cab3a953a)
|
||||
|
||||
# 参考资料
|
||||
|
||||
1. 再有人问你分布式锁,这篇文章扔给他 - [https://juejin.im/post/5bbb0d8df265da0abd3533a5#heading-0](https://juejin.im/post/5bbb0d8df265da0abd3533a5#heading-0)
|
||||
2. 【官方文档】Distributed locks with Redis - [https://redis.io/topics/distlock](https://redis.io/topics/distlock)
|
||||
3. 【分布式缓存系列】Redis实现分布式锁的正确姿势 - [https://www.cnblogs.com/zhili/p/redisdistributelock.html](https://www.cnblogs.com/zhili/p/redisdistributelock.html)
|
||||
4. Redis源码剖析和注释(九)--- 字符串命令的实现(t_string) - [https://blog.csdn.net/men_wen/article/details/70325566](https://blog.csdn.net/men_wen/article/details/70325566)
|
||||
5. 《Redis 深度历险》 - 钱文品/ 著
|
||||
|
361
docs/database/Redis/redis-collection/Redis(5)——亿级数据过滤和布隆过滤器.md
Normal file
361
docs/database/Redis/redis-collection/Redis(5)——亿级数据过滤和布隆过滤器.md
Normal file
@ -0,0 +1,361 @@
|
||||
> 授权转载自: https://github.com/wmyskxz/MoreThanJava#part3-redis
|
||||
|
||||

|
||||
|
||||
# 一、布隆过滤器简介
|
||||
|
||||
[上一次](https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/) 我们学会了使用 **HyperLogLog** 来对大数据进行一个估算,它非常有价值,可以解决很多精确度不高的统计需求。但是如果我们想知道某一个值是不是已经在 **HyperLogLog** 结构里面了,它就无能为力了,它只提供了 `pfadd` 和 `pfcount` 方法,没有提供类似于 `contains` 的这种方法。
|
||||
|
||||
就举一个场景吧,比如你 **刷抖音**:
|
||||
|
||||

|
||||
|
||||
你有 **刷到过重复的推荐内容** 吗?这么多的推荐内容要推荐给这么多的用户,它是怎么保证每个用户在看推荐内容时,保证不会出现之前已经看过的推荐视频呢?也就是说,抖音是如何实现 **推送去重** 的呢?
|
||||
|
||||
你会想到服务器 **记录** 了用户看过的 **所有历史记录**,当推荐系统推荐短视频时会从每个用户的历史记录里进行 **筛选**,过滤掉那些已经存在的记录。问题是当 **用户量很大**,每个用户看过的短视频又很多的情况下,这种方式,推荐系统的去重工作 **在性能上跟的上么?**
|
||||
|
||||
实际上,如果历史记录存储在关系数据库里,去重就需要频繁地对数据库进行 `exists` 查询,当系统并发量很高时,数据库是很难抗住压力的。
|
||||
|
||||

|
||||
|
||||
你可能又想到了 **缓存**,但是这么多用户这么多的历史记录,如果全部缓存起来,那得需要 **浪费多大的空间** 啊.. *(可能老板看一眼账单,看一眼你..)* 并且这个存储空间会随着时间呈线性增长,就算你用缓存撑得住一个月,但是又能继续撑多久呢?不缓存性能又跟不上,咋办呢?
|
||||
|
||||

|
||||
|
||||
如上图所示,**布隆过滤器(Bloom Filter)** 就是这样一种专门用来解决去重问题的高级数据结构。但是跟 **HyperLogLog** 一样,它也一样有那么一点点不精确,也存在一定的误判概率,但它能在解决去重的同时,在 **空间上能节省 90%** 以上,也是非常值得的。
|
||||
|
||||
## 布隆过滤器是什么
|
||||
|
||||
**布隆过滤器(Bloom Filter)** 是 1970 年由布隆提出的。它 **实际上** 是一个很长的二进制向量和一系列随机映射函数 *(下面详细说)*,实际上你也可以把它 **简单理解** 为一个不怎么精确的 **set** 结构,当你使用它的 `contains` 方法判断某个对象是否存在时,它可能会误判。但是布隆过滤器也不是特别不精确,只要参数设置的合理,它的精确度可以控制的相对足够精确,只会有小小的误判概率。
|
||||
|
||||
当布隆过滤器说某个值存在时,这个值 **可能不存在**;当它说不存在时,那么 **一定不存在**。打个比方,当它说不认识你时,那就是真的不认识,但是当它说认识你的时候,可能是因为你长得像它认识的另外一个朋友 *(脸长得有些相似)*,所以误判认识你。
|
||||
|
||||

|
||||
|
||||
## 布隆过滤器的使用场景
|
||||
|
||||
基于上述的功能,我们大致可以把布隆过滤器用于以下的场景之中:
|
||||
|
||||
- **大数据判断是否存在**:这就可以实现出上述的去重功能,如果你的服务器内存足够大的话,那么使用 HashMap 可能是一个不错的解决方案,理论上时间复杂度可以达到 O(1 的级别,但是当数据量起来之后,还是只能考虑布隆过滤器。
|
||||
- **解决缓存穿透**:我们经常会把一些热点数据放在 Redis 中当作缓存,例如产品详情。 通常一个请求过来之后我们会先查询缓存,而不用直接读取数据库,这是提升性能最简单也是最普遍的做法,但是 **如果一直请求一个不存在的缓存**,那么此时一定不存在缓存,那就会有 **大量请求直接打到数据库** 上,造成 **缓存穿透**,布隆过滤器也可以用来解决此类问题。
|
||||
- **爬虫/ 邮箱等系统的过滤**:平时不知道你有没有注意到有一些正常的邮件也会被放进垃圾邮件目录中,这就是使用布隆过滤器 **误判** 导致的。
|
||||
|
||||
# 二、布隆过滤器原理解析
|
||||
|
||||
布隆过滤器 **本质上** 是由长度为 `m` 的位向量或位列表(仅包含 `0` 或 `1` 位值的列表)组成,最初所有的值均设置为 `0`,所以我们先来创建一个稍微长一些的位向量用作展示:
|
||||
|
||||

|
||||
|
||||
当我们向布隆过滤器中添加数据时,会使用 **多个** `hash` 函数对 `key` 进行运算,算得一个证书索引值,然后对位数组长度进行取模运算得到一个位置,每个 `hash` 函数都会算得一个不同的位置。再把位数组的这几个位置都置为 `1` 就完成了 `add` 操作,例如,我们添加一个 `wmyskxz`:
|
||||
|
||||

|
||||
|
||||
向布隆过滤器查查询 `key` 是否存在时,跟 `add` 操作一样,会把这个 `key` 通过相同的多个 `hash` 函数进行运算,查看 **对应的位置** 是否 **都** 为 `1`,**只要有一个位为 `0`**,那么说明布隆过滤器中这个 `key` 不存在。如果这几个位置都是 `1`,并不能说明这个 `key` 一定存在,只能说极有可能存在,因为这些位置的 `1` 可能是因为其他的 `key` 存在导致的。
|
||||
|
||||
就比如我们在 `add` 了一定的数据之后,查询一个 **不存在** 的 `key`:
|
||||
|
||||

|
||||
|
||||
很明显,`1/3/5` 这几个位置的 `1` 是因为上面第一次添加的 `wmyskxz` 而导致的,所以这里就存在 **误判**。幸运的是,布隆过滤器有一个可以预判误判率的公式,比较复杂,感兴趣的朋友可以自行去阅读,比较烧脑.. 只需要记住以下几点就好了:
|
||||
|
||||
- 使用时 **不要让实际元素数量远大于初始化数量**;
|
||||
- 当实际元素数量超过初始化数量时,应该对布隆过滤器进行 **重建**,重新分配一个 `size` 更大的过滤器,再将所有的历史元素批量 `add` 进行;
|
||||
|
||||
# 三、布隆过滤器的使用
|
||||
|
||||
**Redis 官方** 提供的布隆过滤器到了 **Redis 4.0** 提供了插件功能之后才正式登场。布隆过滤器作为一个插件加载到 Redis Server 中,给 Redis 提供了强大的布隆去重功能。下面我们来体验一下 Redis 4.0 的布隆过滤器,为了省去繁琐安装过程,我们直接用
|
||||
Docker 吧。
|
||||
|
||||
```bash
|
||||
> docker pull redislabs/rebloom # 拉取镜像
|
||||
> docker run -p6379:6379 redislabs/rebloom # 运行容器
|
||||
> redis-cli # 连接容器中的 redis 服务
|
||||
```
|
||||
|
||||
如果上面三条指令执行没有问题,下面就可以体验布隆过滤器了。
|
||||
|
||||
- 当然,如果你不想使用 Docker,也可以在检查本机 Redis 版本合格之后自行安装插件,可以参考这里: [https://blog.csdn.net/u013030276/article/details/88350641](https://blog.csdn.net/u013030276/article/details/88350641)
|
||||
|
||||
## 布隆过滤器的基本用法
|
||||
|
||||
布隆过滤器有两个基本指令,`bf.add` 添加元素,`bf.exists` 查询元素是否存在,它的用法和 set 集合的 `sadd` 和 `sismember` 差不多。注意 `bf.add` 只能一次添加一个元素,如果想要一次添加多个,就需要用到 `bf.madd` 指令。同样如果需要一次查询多个元素是否存在,就需要用到 `bf.mexists` 指令。
|
||||
|
||||
```bash
|
||||
127.0.0.1:6379> bf.add codehole user1
|
||||
(integer) 1
|
||||
127.0.0.1:6379> bf.add codehole user2
|
||||
(integer) 1
|
||||
127.0.0.1:6379> bf.add codehole user3
|
||||
(integer) 1
|
||||
127.0.0.1:6379> bf.exists codehole user1
|
||||
(integer) 1
|
||||
127.0.0.1:6379> bf.exists codehole user2
|
||||
(integer) 1
|
||||
127.0.0.1:6379> bf.exists codehole user3
|
||||
(integer) 1
|
||||
127.0.0.1:6379> bf.exists codehole user4
|
||||
(integer) 0
|
||||
127.0.0.1:6379> bf.madd codehole user4 user5 user6
|
||||
1) (integer) 1
|
||||
2) (integer) 1
|
||||
3) (integer) 1
|
||||
127.0.0.1:6379> bf.mexists codehole user4 user5 user6 user7
|
||||
1) (integer) 1
|
||||
2) (integer) 1
|
||||
3) (integer) 1
|
||||
4) (integer) 0
|
||||
```
|
||||
|
||||
上面使用的布隆过过滤器只是默认参数的布隆过滤器,它在我们第一次 `add` 的时候自动创建。Redis 也提供了可以自定义参数的布隆过滤器,只需要在 `add` 之前使用 `bf.reserve` 指令显式创建就好了。如果对应的 `key` 已经存在,`bf.reserve` 会报错。
|
||||
|
||||
`bf.reserve` 有三个参数,分别是 `key`、`error_rate` *(错误率)* 和 `initial_size`:
|
||||
|
||||
- **`error_rate` 越低,需要的空间越大**,对于不需要过于精确的场合,设置稍大一些也没有关系,比如上面说的推送系统,只会让一小部分的内容被过滤掉,整体的观看体验还是不会受到很大影响的;
|
||||
- **`initial_size` 表示预计放入的元素数量**,当实际数量超过这个值时,误判率就会提升,所以需要提前设置一个较大的数值避免超出导致误判率升高;
|
||||
|
||||
如果不适用 `bf.reserve`,默认的 `error_rate` 是 `0.01`,默认的 `initial_size` 是 `100`。
|
||||
|
||||
# 四、布隆过滤器代码实现
|
||||
|
||||
## 自己简单模拟实现
|
||||
|
||||
根据上面的基础理论,我们很容易就可以自己实现一个用于 `简单模拟` 的布隆过滤器数据结构:
|
||||
|
||||
```java
|
||||
public static class BloomFilter {
|
||||
|
||||
private byte[] data;
|
||||
|
||||
public BloomFilter(int initSize) {
|
||||
this.data = new byte[initSize * 2]; // 默认创建大小 * 2 的空间
|
||||
}
|
||||
|
||||
public void add(int key) {
|
||||
int location1 = Math.abs(hash1(key) % data.length);
|
||||
int location2 = Math.abs(hash2(key) % data.length);
|
||||
int location3 = Math.abs(hash3(key) % data.length);
|
||||
|
||||
data[location1] = data[location2] = data[location3] = 1;
|
||||
}
|
||||
|
||||
public boolean contains(int key) {
|
||||
int location1 = Math.abs(hash1(key) % data.length);
|
||||
int location2 = Math.abs(hash2(key) % data.length);
|
||||
int location3 = Math.abs(hash3(key) % data.length);
|
||||
|
||||
return data[location1] * data[location2] * data[location3] == 1;
|
||||
}
|
||||
|
||||
private int hash1(Integer key) {
|
||||
return key.hashCode();
|
||||
}
|
||||
|
||||
private int hash2(Integer key) {
|
||||
int hashCode = key.hashCode();
|
||||
return hashCode ^ (hashCode >>> 3);
|
||||
}
|
||||
|
||||
private int hash3(Integer key) {
|
||||
int hashCode = key.hashCode();
|
||||
return hashCode ^ (hashCode >>> 16);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这里很简单,内部仅维护了一个 `byte` 类型的 `data` 数组,实际上 `byte` 仍然占有一个字节之多,可以优化成 `bit` 来代替,这里也仅仅是用于方便模拟。另外我也创建了三个不同的 `hash` 函数,其实也就是借鉴 `HashMap` 哈希抖动的办法,分别使用自身的 `hash` 和右移不同位数相异或的结果。并且提供了基础的 `add` 和 `contains` 方法。
|
||||
|
||||
下面我们来简单测试一下这个布隆过滤器的效果如何:
|
||||
|
||||
```java
|
||||
public static void main(String[] args) {
|
||||
Random random = new Random();
|
||||
// 假设我们的数据有 1 百万
|
||||
int size = 1_000_000;
|
||||
// 用一个数据结构保存一下所有实际存在的值
|
||||
LinkedList<Integer> existentNumbers = new LinkedList<>();
|
||||
BloomFilter bloomFilter = new BloomFilter(size);
|
||||
|
||||
for (int i = 0; i < size; i++) {
|
||||
int randomKey = random.nextInt();
|
||||
existentNumbers.add(randomKey);
|
||||
bloomFilter.add(randomKey);
|
||||
}
|
||||
|
||||
// 验证已存在的数是否都存在
|
||||
AtomicInteger count = new AtomicInteger();
|
||||
AtomicInteger finalCount = count;
|
||||
existentNumbers.forEach(number -> {
|
||||
if (bloomFilter.contains(number)) {
|
||||
finalCount.incrementAndGet();
|
||||
}
|
||||
});
|
||||
System.out.printf("实际的数据量: %d, 判断存在的数据量: %d \n", size, count.get());
|
||||
|
||||
// 验证10个不存在的数
|
||||
count = new AtomicInteger();
|
||||
while (count.get() < 10) {
|
||||
int key = random.nextInt();
|
||||
if (existentNumbers.contains(key)) {
|
||||
continue;
|
||||
} else {
|
||||
// 这里一定是不存在的数
|
||||
System.out.println(bloomFilter.contains(key));
|
||||
count.incrementAndGet();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
输出如下:
|
||||
|
||||
```bash
|
||||
实际的数据量: 1000000, 判断存在的数据量: 1000000
|
||||
false
|
||||
true
|
||||
false
|
||||
true
|
||||
true
|
||||
true
|
||||
false
|
||||
false
|
||||
true
|
||||
false
|
||||
```
|
||||
|
||||
这就是前面说到的,当布隆过滤器说某个值 **存在时**,这个值 **可能不存在**,当它说某个值 **不存在时**,那就 **肯定不存在**,并且还有一定的误判率...
|
||||
|
||||
|
||||
## 手动实现参考
|
||||
|
||||
当然上面的版本特别 low,不过主体思想是不差的,这里也给出一个好一些的版本用作自己实现测试的参考:
|
||||
|
||||
```java
|
||||
import java.util.BitSet;
|
||||
|
||||
public class MyBloomFilter {
|
||||
|
||||
/**
|
||||
* 位数组的大小
|
||||
*/
|
||||
private static final int DEFAULT_SIZE = 2 << 24;
|
||||
/**
|
||||
* 通过这个数组可以创建 6 个不同的哈希函数
|
||||
*/
|
||||
private static final int[] SEEDS = new int[]{3, 13, 46, 71, 91, 134};
|
||||
|
||||
/**
|
||||
* 位数组。数组中的元素只能是 0 或者 1
|
||||
*/
|
||||
private BitSet bits = new BitSet(DEFAULT_SIZE);
|
||||
|
||||
/**
|
||||
* 存放包含 hash 函数的类的数组
|
||||
*/
|
||||
private SimpleHash[] func = new SimpleHash[SEEDS.length];
|
||||
|
||||
/**
|
||||
* 初始化多个包含 hash 函数的类的数组,每个类中的 hash 函数都不一样
|
||||
*/
|
||||
public MyBloomFilter() {
|
||||
// 初始化多个不同的 Hash 函数
|
||||
for (int i = 0; i < SEEDS.length; i++) {
|
||||
func[i] = new SimpleHash(DEFAULT_SIZE, SEEDS[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加元素到位数组
|
||||
*/
|
||||
public void add(Object value) {
|
||||
for (SimpleHash f : func) {
|
||||
bits.set(f.hash(value), true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断指定元素是否存在于位数组
|
||||
*/
|
||||
public boolean contains(Object value) {
|
||||
boolean ret = true;
|
||||
for (SimpleHash f : func) {
|
||||
ret = ret && bits.get(f.hash(value));
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 静态内部类。用于 hash 操作!
|
||||
*/
|
||||
public static class SimpleHash {
|
||||
|
||||
private int cap;
|
||||
private int seed;
|
||||
|
||||
public SimpleHash(int cap, int seed) {
|
||||
this.cap = cap;
|
||||
this.seed = seed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 hash 值
|
||||
*/
|
||||
public int hash(Object value) {
|
||||
int h;
|
||||
return (value == null) ? 0 : Math.abs(seed * (cap - 1) & ((h = value.hashCode()) ^ (h >>> 16)));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 使用 Google 开源的 Guava 中自带的布隆过滤器
|
||||
|
||||
自己实现的目的主要是为了让自己搞懂布隆过滤器的原理,Guava 中布隆过滤器的实现算是比较权威的,所以实际项目中我们不需要手动实现一个布隆过滤器。
|
||||
|
||||
首先我们需要在项目中引入 Guava 的依赖:
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.google.guava</groupId>
|
||||
<artifactId>guava</artifactId>
|
||||
<version>28.0-jre</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
实际使用如下:
|
||||
|
||||
我们创建了一个最多存放 最多 1500 个整数的布隆过滤器,并且我们可以容忍误判的概率为百分之(0.01)
|
||||
|
||||
```java
|
||||
// 创建布隆过滤器对象
|
||||
BloomFilter<Integer> filter = BloomFilter.create(
|
||||
Funnels.integerFunnel(),
|
||||
1500,
|
||||
0.01);
|
||||
// 判断指定元素是否存在
|
||||
System.out.println(filter.mightContain(1));
|
||||
System.out.println(filter.mightContain(2));
|
||||
// 将元素添加进布隆过滤器
|
||||
filter.put(1);
|
||||
filter.put(2);
|
||||
System.out.println(filter.mightContain(1));
|
||||
System.out.println(filter.mightContain(2));
|
||||
```
|
||||
|
||||
在我们的示例中,当 `mightContain()` 方法返回 `true` 时,我们可以 **99%** 确定该元素在过滤器中,当过滤器返回 `false` 时,我们可以 **100%** 确定该元素不存在于过滤器中。
|
||||
|
||||
Guava 提供的布隆过滤器的实现还是很不错的 *(想要详细了解的可以看一下它的源码实现)*,但是它有一个重大的缺陷就是只能单机使用 *(另外,容量扩展也不容易)*,而现在互联网一般都是分布式的场景。为了解决这个问题,我们就需要用到 **Redis** 中的布隆过滤器了。
|
||||
|
||||
# 相关阅读
|
||||
|
||||
1. Redis(1)——5种基本数据结构 - [https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/](https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/)
|
||||
2. Redis(2)——跳跃表 - [https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/](https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/)
|
||||
3. Redis(3)——分布式锁深入探究 - [https://www.wmyskxz.com/2020/03/01/redis-3/](https://www.wmyskxz.com/2020/03/01/redis-3/)
|
||||
4. Reids(4)——神奇的HyperLoglog解决统计问题 - [https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/](https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/)\
|
||||
|
||||
# 参考资料
|
||||
|
||||
1. 《Redis 深度历险》 - 钱文品/ 著 - [https://book.douban.com/subject/30386804/](https://book.douban.com/subject/30386804/)
|
||||
2. 5 分钟搞懂布隆过滤器,亿级数据过滤算法你值得拥有! - [https://juejin.im/post/5de1e37c5188256e8e43adfc](https://juejin.im/post/5de1e37c5188256e8e43adfc)
|
||||
3. 【原创】不了解布隆过滤器?一文给你整的明明白白! - [https://github.com/Snailclimb/JavaGuide/blob/master/docs/dataStructures-algorithms/data-structure/bloom-filter.md](https://github.com/Snailclimb/JavaGuide/blob/master/docs/dataStructures-algorithms/data-structure/bloom-filter.md)
|
||||
|
227
docs/database/Redis/redis-collection/Redis(6)——GeoHash查找附近的人.md
Normal file
227
docs/database/Redis/redis-collection/Redis(6)——GeoHash查找附近的人.md
Normal file
@ -0,0 +1,227 @@
|
||||
> 授权转载自: https://github.com/wmyskxz/MoreThanJava#part3-redis
|
||||
|
||||

|
||||
|
||||
像微信 **"附近的人"**,美团 **"附近的餐厅"**,支付宝共享单车 **"附近的车"** 是怎么设计实现的呢?
|
||||
|
||||
# 一、使用数据库实现查找附近的人
|
||||
|
||||
我们都知道,地球上的任何一个位置都可以使用二维的 **经纬度** 来表示,经度范围 *[-180, 180]*,纬度范围 *[-90, 90]*,纬度正负以赤道为界,北正南负,经度正负以本初子午线 *(英国格林尼治天文台)* 为界,东正西负。比如说,北京人民英雄纪念碑的经纬度坐标就是 *(39.904610, 116.397724)*,都是正数,因为中国位于东北半球。
|
||||
|
||||
所以,当我们使用数据库存储了所有人的 **经纬度** 信息之后,我们就可以基于当前的坐标节点,来划分出一个矩形的范围,来得知附近的人,如下图:
|
||||
|
||||

|
||||
|
||||
所以,我们很容易写出下列的伪 SQL 语句:
|
||||
|
||||
```sql
|
||||
SELECT id FROM positions WHERE x0 - r < x < x0 + r AND y0 - r < y < y0 + r
|
||||
```
|
||||
|
||||
如果我们还想进一步地知道与每个坐标元素的距离并排序的话,就需要一定的计算。
|
||||
|
||||
当两个坐标元素的距离不是很远的时候,我们就可以简单利用 **勾股定理** 就能够得出他们之间的 **距离**。不过需要注意的是,地球不是一个标准的球体,**经纬度的密度** 是 **不一样** 的,所以我们使用勾股定理计算平方之后再求和时,需要按照一定的系数 **加权** 再进行求和。当然,如果不准求精确的话,加权也不必了。
|
||||
|
||||
参考下方 *参考资料 2* 我们能够差不多能写出如下优化之后的 SQL 语句来:*(仅供参考)*
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
users_location
|
||||
WHERE
|
||||
latitude > '.$lat.' - 1
|
||||
AND latitude < '.$lat.' + 1 AND longitude > '.$lon.' - 1
|
||||
AND longitude < '.$lon.' + 1
|
||||
ORDER BY
|
||||
ACOS(
|
||||
SIN( ( '.$lat.' * 3.1415 ) / 180 ) * SIN( ( latitude * 3.1415 ) / 180 ) + COS( ( '.$lat.' * 3.1415 ) / 180 ) * COS( ( latitude * 3.1415 ) / 180 ) * COS( ( '.$lon.' * 3.1415 ) / 180 - ( longitude * 3.1415 ) / 180 )
|
||||
) * 6380 ASC
|
||||
LIMIT 10 ';
|
||||
```
|
||||
|
||||
为了满足高性能的矩形区域算法,数据表也需要把经纬度坐标加上 **双向复合索引 (x, y)**,这样可以满足最大优化查询性能。
|
||||
|
||||
# 二、GeoHash 算法简述
|
||||
|
||||
这是业界比较通用的,用于 **地理位置距离排序** 的一个算法,**Redis** 也采用了这样的算法。GeoHash 算法将 **二维的经纬度** 数据映射到 **一维** 的整数,这样所有的元素都将在挂载到一条线上,距离靠近的二维坐标映射到一维后的点之间距离也会很接近。当我们想要计算 **「附近的人时」**,首先将目标位置映射到这条线上,然后在这个一维的线上获取附近的点就行了。
|
||||
|
||||
它的核心思想就是把整个地球看成是一个 **二维的平面**,然后把这个平面不断地等分成一个一个小的方格,**每一个** 坐标元素都位于其中的 **唯一一个方格** 中,等分之后的 **方格越小**,那么坐标也就 **越精确**,类似下图:
|
||||
|
||||

|
||||
|
||||
经过划分的地球,我们需要对其进行编码:
|
||||
|
||||

|
||||
|
||||
经过这样顺序的编码之后,如果你仔细观察一会儿,你就会发现一些规律:
|
||||
|
||||
- 横着的所有编码中,**第 2 位和第 4 位都是一样的**,例如第一排第一个 `0101` 和第二个 `0111`,他们的第 2 位和第 4 位都是 `1`;
|
||||
- 竖着的所有编码中,**第 1 位和第 3 位是递增的**,例如第一排第一个 `0101`,如果单独把第 1 位和第 3 位拎出来的话,那就是 `00`,同理看第一排第二个 `0111`,同样的方法第 1 位和第 3 位拎出来是 `01`,刚好是 `00` 递增一个;
|
||||
|
||||
通过这样的规律我们就把每一个小方块儿进行了一定顺序的编码,这样做的 **好处** 是显而易见的:每一个元素坐标既能够被 **唯一标识** 在这张被编码的地图上,也不至于 **暴露特别的具体的位置**,因为区域是共享的,我可以告诉你我就在公园附近,但是在具体的哪个地方你就无从得知了。
|
||||
|
||||
总之,我们通过上面的思想,能够把任意坐标变成一串二进制的编码了,类似于 `11010010110001000100` 这样 *(注意经度和维度是交替出现的哦..)*,通过这个整数我们就可以还原出元素的坐标,整数越长,还原出来的坐标值的损失程序就越小。对于 **"附近的人"** 这个功能来说,损失的一点经度可以忽略不计。
|
||||
|
||||
最后就是一个 `Base32` *(0~9, a~z, 去掉 a/i/l/o 四个字母)* 的编码操作,让它变成一个字符串,例如上面那一串儿就变成了 `wx4g0ec1`。
|
||||
|
||||
在 **Redis** 中,经纬度使用 `52` 位的整数进行编码,放进了 zset 里面,zset 的 `value` 是元素的 `key`,`score` 是 **GeoHash** 的 `52` 位整数值。zset 的 `score` 虽然是浮点数,但是对于 `52` 位的整数值来说,它可以无损存储。
|
||||
|
||||
# 三、在 Redis 中使用 Geo
|
||||
|
||||
> 下方内容引自 *参考资料 1 - 《Redis 深度历险》*
|
||||
|
||||
在使用 **Redis** 进行 **Geo 查询** 时,我们要时刻想到它的内部结构实际上只是一个 **zset(skiplist)**。通过 zset 的 `score` 排序就可以得到坐标附近的其他元素 *(实际情况要复杂一些,不过这样理解足够了)*,通过将 `score` 还原成坐标值就可以得到元素的原始坐标了。
|
||||
|
||||
Redis 提供的 Geo 指令只有 6 个,很容易就可以掌握。
|
||||
|
||||
## 增加
|
||||
|
||||
`geoadd` 指令携带集合名称以及多个经纬度名称三元组,注意这里可以加入多个三元组。
|
||||
|
||||
```bash
|
||||
127.0.0.1:6379> geoadd company 116.48105 39.996794 juejin
|
||||
(integer) 1
|
||||
127.0.0.1:6379> geoadd company 116.514203 39.905409 ireader
|
||||
(integer) 1
|
||||
127.0.0.1:6379> geoadd company 116.489033 40.007669 meituan
|
||||
(integer) 1
|
||||
127.0.0.1:6379> geoadd company 116.562108 39.787602 jd 116.334255 40.027400 xiaomi
|
||||
(integer) 2
|
||||
```
|
||||
|
||||
不过很奇怪.. Redis 没有直接提供 Geo 的删除指令,但是我们可以通过 zset 相关的指令来操作 Geo 数据,所以元素删除可以使用 `zrem` 指令即可。
|
||||
|
||||
## 距离
|
||||
|
||||
`geodist` 指令可以用来计算两个元素之间的距离,携带集合名称、2 个名称和距离单位。
|
||||
|
||||
```bash
|
||||
127.0.0.1:6379> geodist company juejin ireader km
|
||||
"10.5501"
|
||||
127.0.0.1:6379> geodist company juejin meituan km
|
||||
"1.3878"
|
||||
127.0.0.1:6379> geodist company juejin jd km
|
||||
"24.2739"
|
||||
127.0.0.1:6379> geodist company juejin xiaomi km
|
||||
"12.9606"
|
||||
127.0.0.1:6379> geodist company juejin juejin km
|
||||
"0.0000"
|
||||
```
|
||||
|
||||
我们可以看到掘金离美团最近,因为它们都在望京。距离单位可以是 `m`、`km`、`ml`、`ft`,分别代表米、千米、英里和尺。
|
||||
|
||||
## 获取元素位置
|
||||
|
||||
`geopos` 指令可以获取集合中任意元素的经纬度坐标,可以一次获取多个。
|
||||
|
||||
```bash
|
||||
127.0.0.1:6379> geopos company juejin
|
||||
1) 1) "116.48104995489120483"
|
||||
2) "39.99679348858259686"
|
||||
127.0.0.1:6379> geopos company ireader
|
||||
1) 1) "116.5142020583152771"
|
||||
2) "39.90540918662494363"
|
||||
127.0.0.1:6379> geopos company juejin ireader
|
||||
1) 1) "116.48104995489120483"
|
||||
2) "39.99679348858259686"
|
||||
2) 1) "116.5142020583152771"
|
||||
2) "39.90540918662494363"
|
||||
```
|
||||
|
||||
我们观察到获取的经纬度坐标和 `geoadd` 进去的坐标有轻微的误差,原因是 **Geohash** 对二维坐标进行的一维映射是有损的,通过映射再还原回来的值会出现较小的差别。对于 **「附近的人」** 这种功能来说,这点误差根本不是事。
|
||||
|
||||
## 获取元素的 hash 值
|
||||
|
||||
`geohash` 可以获取元素的经纬度编码字符串,上面已经提到,它是 `base32` 编码。 你可以使用这个编码值去 `http://geohash.org/${hash}` 中进行直接定位,它是 **Geohash** 的标准编码值。
|
||||
|
||||
```bash
|
||||
127.0.0.1:6379> geohash company ireader
|
||||
1) "wx4g52e1ce0"
|
||||
127.0.0.1:6379> geohash company juejin
|
||||
1) "wx4gd94yjn0"
|
||||
```
|
||||
|
||||
让我们打开地址 `http://geohash.org/wx4g52e1ce0`,观察地图指向的位置是否正确:
|
||||
|
||||

|
||||
|
||||
很好,就是这个位置,非常准确。
|
||||
|
||||
## 附近的公司
|
||||
`georadiusbymember` 指令是最为关键的指令,它可以用来查询指定元素附近的其它元素,它的参数非常复杂。
|
||||
|
||||
```bash
|
||||
# 范围 20 公里以内最多 3 个元素按距离正排,它不会排除自身
|
||||
127.0.0.1:6379> georadiusbymember company ireader 20 km count 3 asc
|
||||
1) "ireader"
|
||||
2) "juejin"
|
||||
3) "meituan"
|
||||
# 范围 20 公里以内最多 3 个元素按距离倒排
|
||||
127.0.0.1:6379> georadiusbymember company ireader 20 km count 3 desc
|
||||
1) "jd"
|
||||
2) "meituan"
|
||||
3) "juejin"
|
||||
# 三个可选参数 withcoord withdist withhash 用来携带附加参数
|
||||
# withdist 很有用,它可以用来显示距离
|
||||
127.0.0.1:6379> georadiusbymember company ireader 20 km withcoord withdist withhash count 3 asc
|
||||
1) 1) "ireader"
|
||||
2) "0.0000"
|
||||
3) (integer) 4069886008361398
|
||||
4) 1) "116.5142020583152771"
|
||||
2) "39.90540918662494363"
|
||||
2) 1) "juejin"
|
||||
2) "10.5501"
|
||||
3) (integer) 4069887154388167
|
||||
4) 1) "116.48104995489120483"
|
||||
2) "39.99679348858259686"
|
||||
3) 1) "meituan"
|
||||
2) "11.5748"
|
||||
3) (integer) 4069887179083478
|
||||
4) 1) "116.48903220891952515"
|
||||
2) "40.00766997707732031"
|
||||
```
|
||||
|
||||
除了 `georadiusbymember` 指令根据元素查询附近的元素,**Redis** 还提供了根据坐标值来查询附近的元素,这个指令更加有用,它可以根据用户的定位来计算「附近的车」,「附近的餐馆」等。它的参数和 `georadiusbymember` 基本一致,除了将目标元素改成经纬度坐标值:
|
||||
|
||||
```bash
|
||||
127.0.0.1:6379> georadius company 116.514202 39.905409 20 km withdist count 3 asc
|
||||
1) 1) "ireader"
|
||||
2) "0.0000"
|
||||
2) 1) "juejin"
|
||||
2) "10.5501"
|
||||
3) 1) "meituan"
|
||||
2) "11.5748"
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
在一个地图应用中,车的数据、餐馆的数据、人的数据可能会有百万千万条,如果使用 **Redis** 的 **Geo** 数据结构,它们将 **全部放在一个** zset 集合中。在 **Redis** 的集群环境中,集合可能会从一个节点迁移到另一个节点,如果单个 key 的数据过大,会对集群的迁移工作造成较大的影响,在集群环境中单个 key 对应的数据量不宜超过 1M,否则会导致集群迁移出现卡顿现象,影响线上服务的正常运行。
|
||||
|
||||
所以,这里建议 **Geo** 的数据使用 **单独的 Redis 实例部署**,不使用集群环境。
|
||||
|
||||
如果数据量过亿甚至更大,就需要对 **Geo** 数据进行拆分,按国家拆分、按省拆分,按市拆分,在人口特大城市甚至可以按区拆分。这样就可以显著降低单个 zset 集合的大小。
|
||||
|
||||
# 相关阅读
|
||||
|
||||
1. Redis(1)——5种基本数据结构 - [https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/](https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/)
|
||||
2. Redis(2)——跳跃表 - [https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/](https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/)
|
||||
3. Redis(3)——分布式锁深入探究 - [https://www.wmyskxz.com/2020/03/01/redis-3/](https://www.wmyskxz.com/2020/03/01/redis-3/)
|
||||
4. Reids(4)——神奇的HyperLoglog解决统计问题 - [https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/](https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/)
|
||||
5. Redis(5)——亿级数据过滤和布隆过滤器 - [https://www.wmyskxz.com/2020/03/11/redis-5-yi-ji-shu-ju-guo-lu-he-bu-long-guo-lu-qi/](https://www.wmyskxz.com/2020/03/11/redis-5-yi-ji-shu-ju-guo-lu-he-bu-long-guo-lu-qi/)
|
||||
|
||||
# 参考资料
|
||||
|
||||
1. 《Redis 深度历险》 - 钱文品/ 著 - [https://book.douban.com/subject/30386804/](https://book.douban.com/subject/30386804/)
|
||||
2. mysql经纬度查询并且计算2KM范围内附近用户的sql查询性能优化实例教程 - [https://www.cnblogs.com/mgbert/p/4146538.html](https://www.cnblogs.com/mgbert/p/4146538.html)
|
||||
3. Geohash算法原理及实现 - [https://www.jianshu.com/p/2fd0cf12e5ba](https://www.jianshu.com/p/2fd0cf12e5ba)
|
||||
4. GeoHash算法学习讲解、解析及原理分析 - [https://zhuanlan.zhihu.com/p/35940647](https://zhuanlan.zhihu.com/p/35940647)
|
||||
|
||||
> - 本文已收录至我的 Github 程序员成长系列 **【More Than Java】,学习,不止 Code,欢迎 star:[https://github.com/wmyskxz/MoreThanJava](https://github.com/wmyskxz/MoreThanJava)**
|
||||
> - **个人公众号** :wmyskxz,**个人独立域名博客**:wmyskxz.com,坚持原创输出,下方扫码关注,2020,与您共同成长!
|
||||
|
||||

|
||||
|
||||
非常感谢各位人才能 **看到这里**,如果觉得本篇文章写得不错,觉得 **「我没有三颗心脏」有点东西** 的话,**求点赞,求关注,求分享,求留言!**
|
||||
|
||||
创作不易,各位的支持和认可,就是我创作的最大动力,我们下篇文章见!
|
215
docs/database/Redis/redis-collection/Redis(7)——持久化.md
Normal file
215
docs/database/Redis/redis-collection/Redis(7)——持久化.md
Normal file
@ -0,0 +1,215 @@
|
||||
> 授权转载自: https://github.com/wmyskxz/MoreThanJava#part3-redis
|
||||
|
||||

|
||||
|
||||
# 一、持久化简介
|
||||
|
||||
**Redis** 的数据 **全部存储** 在 **内存** 中,如果 **突然宕机**,数据就会全部丢失,因此必须有一套机制来保证 Redis 的数据不会因为故障而丢失,这种机制就是 Redis 的 **持久化机制**,它会将内存中的数据库状态 **保存到磁盘** 中。
|
||||
|
||||
## 持久化发生了什么 | 从内存到磁盘
|
||||
|
||||
我们来稍微考虑一下 **Redis** 作为一个 **"内存数据库"** 要做的关于持久化的事情。通常来说,从客户端发起请求开始,到服务器真实地写入磁盘,需要发生如下几件事情:
|
||||
|
||||

|
||||
|
||||
**详细版** 的文字描述大概就是下面这样:
|
||||
|
||||
1. 客户端向数据库 **发送写命令** *(数据在客户端的内存中)*
|
||||
2. 数据库 **接收** 到客户端的 **写请求** *(数据在服务器的内存中)*
|
||||
3. 数据库 **调用系统 API** 将数据写入磁盘 *(数据在内核缓冲区中)*
|
||||
4. 操作系统将 **写缓冲区** 传输到 **磁盘控控制器** *(数据在磁盘缓存中)*
|
||||
5. 操作系统的磁盘控制器将数据 **写入实际的物理媒介** 中 *(数据在磁盘中)*
|
||||
|
||||
**注意:** 上面的过程其实是 **极度精简** 的,在实际的操作系统中,**缓存** 和 **缓冲区** 会比这 **多得多**...
|
||||
|
||||
## 如何尽可能保证持久化的安全
|
||||
|
||||
如果我们故障仅仅涉及到 **软件层面** *(该进程被管理员终止或程序崩溃)* 并且没有接触到内核,那么在 *上述步骤 3* 成功返回之后,我们就认为成功了。即使进程崩溃,操作系统仍然会帮助我们把数据正确地写入磁盘。
|
||||
|
||||
如果我们考虑 **停电/ 火灾** 等 **更具灾难性** 的事情,那么只有在完成了第 **5** 步之后,才是安全的。
|
||||
|
||||

|
||||
|
||||
所以我们可以总结得出数据安全最重要的阶段是:**步骤三、四、五**,即:
|
||||
|
||||
- 数据库软件调用写操作将用户空间的缓冲区转移到内核缓冲区的频率是多少?
|
||||
- 内核多久从缓冲区取数据刷新到磁盘控制器?
|
||||
- 磁盘控制器多久把数据写入物理媒介一次?
|
||||
- **注意:** 如果真的发生灾难性的事件,我们可以从上图的过程中看到,任何一步都可能被意外打断丢失,所以只能 **尽可能地保证** 数据的安全,这对于所有数据库来说都是一样的。
|
||||
|
||||
我们从 **第三步** 开始。Linux 系统提供了清晰、易用的用于操作文件的 `POSIX file API`,`20` 多年过去,仍然还有很多人对于这一套 `API` 的设计津津乐道,我想其中一个原因就是因为你光从 `API` 的命名就能够很清晰地知道这一套 API 的用途:
|
||||
|
||||
```c
|
||||
int open(const char *path, int oflag, .../*,mode_t mode */);
|
||||
int close (int filedes);int remove( const char *fname );
|
||||
ssize_t write(int fildes, const void *buf, size_t nbyte);
|
||||
ssize_t read(int fildes, void *buf, size_t nbyte);
|
||||
```
|
||||
|
||||
- 参考自:API 设计最佳实践的思考 - [https://www.cnblogs.com/yuanjiangw/p/10846560.html](https://www.cnblogs.com/yuanjiangw/p/10846560.html)
|
||||
|
||||
所以,我们有很好的可用的 `API` 来完成 **第三步**,但是对于成功返回之前,我们对系统调用花费的时间没有太多的控制权。
|
||||
|
||||
然后我们来说说 **第四步**。我们知道,除了早期对电脑特别了解那帮人 *(操作系统就这帮人搞的)*,实际的物理硬件都不是我们能够 **直接操作** 的,都是通过 **操作系统调用** 来达到目的的。为了防止过慢的 I/O 操作拖慢整个系统的运行,操作系统层面做了很多的努力,譬如说 **上述第四步** 提到的 **写缓冲区**,并不是所有的写操作都会被立即写入磁盘,而是要先经过一个缓冲区,默认情况下,Linux 将在 **30 秒** 后实际提交写入。
|
||||
|
||||

|
||||
|
||||
但是很明显,**30 秒** 并不是 Redis 能够承受的,这意味着,如果发生故障,那么最近 30 秒内写入的所有数据都可能会丢失。幸好 `PROSIX API` 提供了另一个解决方案:`fsync`,该命令会 **强制** 内核将 **缓冲区** 写入 **磁盘**,但这是一个非常消耗性能的操作,每次调用都会 **阻塞等待** 直到设备报告 IO 完成,所以一般在生产环境的服务器中,**Redis** 通常是每隔 1s 左右执行一次 `fsync` 操作。
|
||||
|
||||
到目前为止,我们了解到了如何控制 `第三步` 和 `第四步`,但是对于 **第五步**,我们 **完全无法控制**。也许一些内核实现将试图告诉驱动实际提交物理介质上的数据,或者控制器可能会为了提高速度而重新排序写操作,不会尽快将数据真正写到磁盘上,而是会等待几个多毫秒。这完全是我们无法控制的。
|
||||
|
||||
|
||||
# 二、Redis 中的两种持久化方式
|
||||
|
||||
## 方式一:快照
|
||||
|
||||

|
||||
|
||||
**Redis 快照** 是最简单的 Redis 持久性模式。当满足特定条件时,它将生成数据集的时间点快照,例如,如果先前的快照是在2分钟前创建的,并且现在已经至少有 *100* 次新写入,则将创建一个新的快照。此条件可以由用户配置 Redis 实例来控制,也可以在运行时修改而无需重新启动服务器。快照作为包含整个数据集的单个 `.rdb` 文件生成。
|
||||
|
||||
但我们知道,Redis 是一个 **单线程** 的程序,这意味着,我们不仅仅要响应用户的请求,还需要进行内存快照。而后者要求 Redis 必须进行 IO 操作,这会严重拖累服务器的性能。
|
||||
|
||||
还有一个重要的问题是,我们在 **持久化的同时**,**内存数据结构** 还可能在 **变化**,比如一个大型的 hash 字典正在持久化,结果一个请求过来把它删除了,可是这才刚持久化结束,咋办?
|
||||
|
||||

|
||||
|
||||
### 使用系统多进程 COW(Copy On Write) 机制 | fork 函数
|
||||
|
||||
操作系统多进程 **COW(Copy On Write) 机制** 拯救了我们。**Redis** 在持久化时会调用 `glibc` 的函数 `fork` 产生一个子进程,简单理解也就是基于当前进程 **复制** 了一个进程,主进程和子进程会共享内存里面的代码块和数据段:
|
||||
|
||||

|
||||
|
||||
这里多说一点,**为什么 fork 成功调用后会有两个返回值呢?** 因为子进程在复制时复制了父进程的堆栈段,所以两个进程都停留在了 `fork` 函数中 *(都在同一个地方往下继续"同时"执行)*,等待返回,所以 **一次在父进程中返回子进程的 pid,另一次在子进程中返回零,系统资源不够时返回负数**。 *(伪代码如下)*
|
||||
|
||||
```python
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
handle_client_request() # 父进程继续处理客户端请求
|
||||
if pid == 0:
|
||||
handle_snapshot_write() # 子进程处理快照写磁盘
|
||||
if pid < 0:
|
||||
# fork error
|
||||
```
|
||||
|
||||
所以 **快照持久化** 可以完全交给 **子进程** 来处理,**父进程** 则继续 **处理客户端请求**。**子进程** 做数据持久化,它 **不会修改现有的内存数据结构**,它只是对数据结构进行遍历读取,然后序列化写到磁盘中。但是 **父进程** 不一样,它必须持续服务客户端请求,然后对 **内存数据结构进行不间断的修改**。
|
||||
|
||||
这个时候就会使用操作系统的 COW 机制来进行 **数据段页面** 的分离。数据段是由很多操作系统的页面组合而成,当父进程对其中一个页面的数据进行修改时,会将被共享的页面复
|
||||
制一份分离出来,然后 **对这个复制的页面进行修改**。这时 **子进程** 相应的页面是 **没有变化的**,还是进程产生时那一瞬间的数据。
|
||||
|
||||
子进程因为数据没有变化,它能看到的内存里的数据在进程产生的一瞬间就凝固了,再也不会改变,这也是为什么 **Redis** 的持久化 **叫「快照」的原因**。接下来子进程就可以非常安心的遍历数据了进行序列化写磁盘了。
|
||||
|
||||
## 方式二:AOF
|
||||
|
||||

|
||||
|
||||
**快照不是很持久**。如果运行 Redis 的计算机停止运行,电源线出现故障或者您 `kill -9` 的实例意外发生,则写入 Redis 的最新数据将丢失。尽管这对于某些应用程序可能不是什么大问题,但有些使用案例具有充分的耐用性,在这些情况下,快照并不是可行的选择。
|
||||
|
||||
**AOF(Append Only File - 仅追加文件)** 它的工作方式非常简单:每次执行 **修改内存** 中数据集的写操作时,都会 **记录** 该操作。假设 AOF 日志记录了自 Redis 实例创建以来 **所有的修改性指令序列**,那么就可以通过对一个空的 Redis 实例 **顺序执行所有的指令**,也就是 **「重放」**,来恢复 Redis 当前实例的内存数据结构的状态。
|
||||
|
||||
为了展示 AOF 在实际中的工作方式,我们来做一个简单的实验:
|
||||
|
||||
```bash
|
||||
./redis-server --appendonly yes # 设置一个新实例为 AOF 模式
|
||||
```
|
||||
|
||||
然后我们执行一些写操作:
|
||||
|
||||
```bash
|
||||
redis 127.0.0.1:6379> set key1 Hello
|
||||
OK
|
||||
redis 127.0.0.1:6379> append key1 " World!"
|
||||
(integer) 12
|
||||
redis 127.0.0.1:6379> del key1
|
||||
(integer) 1
|
||||
redis 127.0.0.1:6379> del non_existing_key
|
||||
(integer) 0
|
||||
```
|
||||
|
||||
前三个操作实际上修改了数据集,第四个操作没有修改,因为没有指定名称的键。这是 AOF 日志保存的文本:
|
||||
|
||||
```bash
|
||||
$ cat appendonly.aof
|
||||
*2
|
||||
$6
|
||||
SELECT
|
||||
$1
|
||||
0
|
||||
*3
|
||||
$3
|
||||
set
|
||||
$4
|
||||
key1
|
||||
$5
|
||||
Hello
|
||||
*3
|
||||
$6
|
||||
append
|
||||
$4
|
||||
key1
|
||||
$7
|
||||
World!
|
||||
*2
|
||||
$3
|
||||
del
|
||||
$4
|
||||
key1
|
||||
```
|
||||
|
||||
如您所见,最后的那一条 `DEL` 指令不见了,因为它没有对数据集进行任何修改。
|
||||
|
||||
就是这么简单。当 Redis 收到客户端修改指令后,会先进行参数校验、逻辑处理,如果没问题,就 **立即** 将该指令文本 **存储** 到 AOF 日志中,也就是说,**先执行指令再将日志存盘**。这一点不同于 `MySQL`、`LevelDB`、`HBase` 等存储引擎,如果我们先存储日志再做逻辑处理,这样就可以保证即使宕机了,我们仍然可以通过之前保存的日志恢复到之前的数据状态,但是 **Redis 为什么没有这么做呢?**
|
||||
|
||||
> Emmm... 没找到特别满意的答案,引用一条来自知乎上的回答吧:
|
||||
> - **@缘于专注** - 我甚至觉得没有什么特别的原因。仅仅是因为,由于AOF文件会比较大,为了避免写入无效指令(错误指令),必须先做指令检查?如何检查,只能先执行了。因为语法级别检查并不能保证指令的有效性,比如删除一个不存在的key。而MySQL这种是因为它本身就维护了所有的表的信息,所以可以语法检查后过滤掉大部分无效指令直接记录日志,然后再执行。
|
||||
> - 更多讨论参见:[为什么Redis先执行指令,再记录AOF日志,而不是像其它存储引擎一样反过来呢? - https://www.zhihu.com/question/342427472](https://www.zhihu.com/question/342427472)
|
||||
|
||||
### AOF 重写
|
||||
|
||||

|
||||
|
||||
**Redis** 在长期运行的过程中,AOF 的日志会越变越长。如果实例宕机重启,重放整个 AOF 日志会非常耗时,导致长时间 Redis 无法对外提供服务。所以需要对 **AOF 日志 "瘦身"**。
|
||||
|
||||
**Redis** 提供了 `bgrewriteaof` 指令用于对 AOF 日志进行瘦身。其 **原理** 就是 **开辟一个子进程** 对内存进行 **遍历** 转换成一系列 Redis 的操作指令,**序列化到一个新的 AOF 日志文件** 中。序列化完毕后再将操作期间发生的 **增量 AOF 日志** 追加到这个新的 AOF 日志文件中,追加完毕后就立即替代旧的 AOF 日志文件了,瘦身工作就完成了。
|
||||
|
||||
### fsync
|
||||
|
||||

|
||||
|
||||
AOF 日志是以文件的形式存在的,当程序对 AOF 日志文件进行写操作时,实际上是将内容写到了内核为文件描述符分配的一个内存缓存中,然后内核会异步将脏数据刷回到磁盘的。
|
||||
|
||||
就像我们 *上方第四步* 描述的那样,我们需要借助 `glibc` 提供的 `fsync(int fd)` 函数来讲指定的文件内容 **强制从内核缓存刷到磁盘**。但 **"强制开车"** 仍然是一个很消耗资源的一个过程,需要 **"节制"**!通常来说,生产环境的服务器,Redis 每隔 1s 左右执行一次 `fsync` 操作就可以了。
|
||||
|
||||
Redis 同样也提供了另外两种策略,一个是 **永不 `fsync`**,来让操作系统来决定合适同步磁盘,很不安全,另一个是 **来一个指令就 `fsync` 一次**,非常慢。但是在生产环境基本不会使用,了解一下即可。
|
||||
|
||||
## Redis 4.0 混合持久化
|
||||
|
||||

|
||||
|
||||
重启 Redis 时,我们很少使用 `rdb` 来恢复内存状态,因为会丢失大量数据。我们通常使用 AOF 日志重放,但是重放 AOF 日志性能相对 `rdb` 来说要慢很多,这样在 Redis 实例很大的情况下,启动需要花费很长的时间。
|
||||
|
||||
**Redis 4.0** 为了解决这个问题,带来了一个新的持久化选项——**混合持久化**。将 `rdb` 文件的内容和增量的 AOF 日志文件存在一起。这里的 AOF 日志不再是全量的日志,而是 **自持久化开始到持久化结束** 的这段时间发生的增量 AOF 日志,通常这部分 AOF 日志很小:
|
||||
|
||||

|
||||
|
||||
于是在 Redis 重启的时候,可以先加载 `rdb` 的内容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,重启效率因此大幅得到提升。
|
||||
|
||||
# 相关阅读
|
||||
|
||||
1. Redis(1)——5种基本数据结构 - [https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/](https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/)
|
||||
2. Redis(2)——跳跃表 - [https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/](https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/)
|
||||
3. Redis(3)——分布式锁深入探究 - [https://www.wmyskxz.com/2020/03/01/redis-3/](https://www.wmyskxz.com/2020/03/01/redis-3/)
|
||||
4. Reids(4)——神奇的HyperLoglog解决统计问题 - [https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/](https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/)
|
||||
5. Redis(5)——亿级数据过滤和布隆过滤器 - [https://www.wmyskxz.com/2020/03/11/redis-5-yi-ji-shu-ju-guo-lu-he-bu-long-guo-lu-qi/](https://www.wmyskxz.com/2020/03/11/redis-5-yi-ji-shu-ju-guo-lu-he-bu-long-guo-lu-qi/)
|
||||
6. Redis(6)——GeoHash查找附近的人[https://www.wmyskxz.com/2020/03/12/redis-6-geohash-cha-zhao-fu-jin-de-ren/](https://www.wmyskxz.com/2020/03/12/redis-6-geohash-cha-zhao-fu-jin-de-ren/)
|
||||
|
||||
# 扩展阅读
|
||||
|
||||
1. Redis 数据备份与恢复 | 菜鸟教程 - [https://www.runoob.com/redis/redis-backup.html](https://www.runoob.com/redis/redis-backup.html)
|
||||
2. Java Fork/Join 框架 - [https://www.cnblogs.com/cjsblog/p/9078341.html](https://www.cnblogs.com/cjsblog/p/9078341.html)
|
||||
|
||||
# 参考资料
|
||||
|
||||
1. Redis persistence demystified | antirez weblog (作者博客) - [http://oldblog.antirez.com/post/redis-persistence-demystified.html](http://oldblog.antirez.com/post/redis-persistence-demystified.html)
|
||||
2. 操作系统 — fork()函数的使用与底层原理 - [https://blog.csdn.net/Dawn_sf/article/details/78709839](https://blog.csdn.net/Dawn_sf/article/details/78709839)
|
||||
3. 磁盘和内存读写简单原理 - [https://blog.csdn.net/zhanghongzheng3213/article/details/54141202](https://blog.csdn.net/zhanghongzheng3213/article/details/54141202)
|
||||
|
531
docs/database/Redis/redis-collection/Redis(8)——发布订阅与Stream.md
Normal file
531
docs/database/Redis/redis-collection/Redis(8)——发布订阅与Stream.md
Normal file
@ -0,0 +1,531 @@
|
||||
> 授权转载自: https://github.com/wmyskxz/MoreThanJava#part3-redis
|
||||
|
||||

|
||||
|
||||
# 一、Redis 中的发布/订阅功能
|
||||
|
||||
**发布/ 订阅系统** 是 Web 系统中比较常用的一个功能。简单点说就是 **发布者发布消息,订阅者接受消息**,这有点类似于我们的报纸/ 杂志社之类的: *(借用前边的一张图)*
|
||||
|
||||

|
||||
|
||||
- 图片引用自:「消息队列」看过来! - [https://www.wmyskxz.com/2019/07/16/xiao-xi-dui-lie-kan-guo-lai/](https://www.wmyskxz.com/2019/07/16/xiao-xi-dui-lie-kan-guo-lai/)
|
||||
|
||||
从我们 *前面(下方相关阅读)* 学习的知识来看,我们虽然可以使用一个 `list` 列表结构结合 `lpush` 和 `rpop` 来实现消息队列的功能,但是似乎很难实现实现 **消息多播** 的功能:
|
||||
|
||||

|
||||
|
||||
为了支持消息多播,**Redis** 不能再依赖于那 5 种基础的数据结构了,它单独使用了一个模块来支持消息多播,这个模块就是 **PubSub**,也就是 **PublisherSubscriber** *(发布者/ 订阅者模式)*。
|
||||
|
||||
## PubSub 简介
|
||||
|
||||
我们从 *上面的图* 中可以看到,基于 `list` 结构的消息队列,是一种 `Publisher` 与 `Consumer` 点对点的强关联关系,**Redis** 为了消除这样的强关联,引入了另一种概念:**频道** *(channel)*:
|
||||
|
||||

|
||||
|
||||
当 `Publisher` 往 `channel` 中发布消息时,关注了指定 `channel` 的 `Consumer` 就能够同时受到消息。但这里的 **问题** 是,消费者订阅一个频道是必须 **明确指定频道名称** 的,这意味着,如果我们想要 **订阅多个** 频道,那么就必须 **显式地关注多个** 名称。
|
||||
|
||||
为了简化订阅的繁琐操作,**Redis** 提供了 **模式订阅** 的功能 **Pattern Subscribe**,这样就可以 **一次性关注多个频道** 了,即使生产者新增了同模式的频道,消费者也可以立即受到消息:
|
||||
|
||||

|
||||
|
||||
例如上图中,**所有** 位于图片下方的 **`Consumer` 都能够受到消息**。
|
||||
|
||||
`Publisher` 往 `wmyskxz.chat` 这个 `channel` 中发送了一条消息,不仅仅关注了这个频道的 `Consumer 1` 和 `Consumer 2` 能够受到消息,图片中的两个 `channel` 都和模式 `wmyskxz.*` 匹配,所以 **Redis** 此时会同样发送消息给订阅了 `wmyskxz.*` 这个模式的 `Consumer 3` 和关注了在这个模式下的另一个频道 `wmyskxz.log` 下的 `Consumer 4` 和 `Consumer 5`。
|
||||
|
||||
另一方面,如果接收消息的频道是 `wmyskxz.chat`,那么 `Consumer 3` 也会受到消息。
|
||||
|
||||
## 快速体验
|
||||
|
||||
在 **Redis** 中,**PubSub** 模块的使用非常简单,常用的命令也就下面这么几条:
|
||||
|
||||
```bash
|
||||
# 订阅频道:
|
||||
SUBSCRIBE channel [channel ....] # 订阅给定的一个或多个频道的信息
|
||||
PSUBSCRIBE pattern [pattern ....] # 订阅一个或多个符合给定模式的频道
|
||||
# 发布频道:
|
||||
PUBLISH channel message # 将消息发送到指定的频道
|
||||
# 退订频道:
|
||||
UNSUBSCRIBE [channel [channel ....]] # 退订指定的频道
|
||||
PUNSUBSCRIBE [pattern [pattern ....]] #退订所有给定模式的频道
|
||||
```
|
||||
|
||||
我们可以在本地快速地来体验一下 **PubSub**:
|
||||
|
||||

|
||||
|
||||
具体步骤如下:
|
||||
|
||||
1. 开启本地 Redis 服务,新建两个控制台窗口;
|
||||
2. 在其中一个窗口输入 `SUBSCRIBE wmyskxz.chat` 关注 `wmyskxz.chat` 频道,让这个窗口成为 **消费者**。
|
||||
3. 在另一个窗口输入 `PUBLISH wmyskxz.chat 'message'` 往这个频道发送消息,这个时候就会看到 **另一个窗口实时地出现** 了发送的测试消息。
|
||||
|
||||
## 实现原理
|
||||
|
||||
可以看到,我们通过很简单的两条命令,几乎就可以简单使用这样的一个 **发布/ 订阅系统** 了,但是具体是怎么样实现的呢?
|
||||
|
||||
**每个 Redis 服务器进程维持着一个标识服务器状态** 的 `redis.h/redisServer` 结构,其中就 **保存着有订阅的频道** 以及 **订阅模式** 的信息:
|
||||
|
||||
```c
|
||||
struct redisServer {
|
||||
// ...
|
||||
dict *pubsub_channels; // 订阅频道
|
||||
list *pubsub_patterns; // 订阅模式
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### 订阅频道原理
|
||||
|
||||
当客户端订阅某一个频道之后,Redis 就会往 `pubsub_channels` 这个字典中新添加一条数据,实际上这个 `dict` 字典维护的是一张链表,比如,下图展示的 `pubsub_channels` 示例中,`client 1`、`client 2` 就订阅了 `channel 1`,而其他频道也分别被其他客户端订阅:
|
||||
|
||||

|
||||
|
||||
#### SUBSCRIBE 命令
|
||||
|
||||
`SUBSCRIBE` 命令的行为可以用下列的伪代码表示:
|
||||
|
||||
```python
|
||||
def SUBSCRIBE(client, channels):
|
||||
# 遍历所有输入频道
|
||||
for channel in channels:
|
||||
# 将客户端添加到链表的末尾
|
||||
redisServer.pubsub_channels[channel].append(client)
|
||||
```
|
||||
|
||||
通过 `pubsub_channels` 字典,程序只要检查某个频道是否为字典的键,就可以知道该频道是否正在被客户端订阅;只要取出某个键的值,就可以得到所有订阅该频道的客户端的信息。
|
||||
|
||||
#### PUBLISH 命令
|
||||
|
||||
了解 `SUBSCRIBE`,那么 `PUBLISH` 命令的实现也变得十分简单了,只需要通过上述字典定位到具体的客户端,再把消息发送给它们就好了:*(伪代码实现如下)*
|
||||
|
||||
```python
|
||||
def PUBLISH(channel, message):
|
||||
# 遍历所有订阅频道 channel 的客户端
|
||||
for client in server.pubsub_channels[channel]:
|
||||
# 将信息发送给它们
|
||||
send_message(client, message)
|
||||
```
|
||||
|
||||
#### UNSUBSCRIBE 命令
|
||||
|
||||
使用 `UNSUBSCRIBE` 命令可以退订指定的频道,这个命令执行的是订阅的反操作:它从 `pubsub_channels` 字典的给定频道(键)中,删除关于当前客户端的信息,这样被退订频道的信息就不会再发送给这个客户端。
|
||||
|
||||
### 订阅模式原理
|
||||
|
||||

|
||||
|
||||
正如我们上面说到了,当发送一条消息到 `wmyskxz.chat` 这个频道时,Redis 不仅仅会发送到当前的频道,还会发送到匹配于当前模式的所有频道,实际上,`pubsub_patterns` 背后还维护了一个 `redis.h/pubsubPattern` 结构:
|
||||
|
||||
```c
|
||||
typedef struct pubsubPattern {
|
||||
redisClient *client; // 订阅模式的客户端
|
||||
robj *pattern; // 订阅的模式
|
||||
} pubsubPattern;
|
||||
```
|
||||
|
||||
每当调用 `PSUBSCRIBE` 命令订阅一个模式时,程序就创建一个包含客户端信息和被订阅模式的 `pubsubPattern` 结构,并将该结构添加到 `redisServer.pubsub_patterns` 链表中。
|
||||
|
||||
我们来看一个 `pusub_patterns` 链表的示例:
|
||||
|
||||

|
||||
|
||||
这个时候客户端 `client 3` 执行 `PSUBSCRIBE wmyskxz.java.*`,那么 `pubsub_patterns` 链表就会被更新成这样:
|
||||
|
||||

|
||||
|
||||
通过遍历整个 `pubsub_patterns` 链表,程序可以检查所有正在被订阅的模式,以及订阅这些模式的客户端。
|
||||
|
||||
#### PUBLISH 命令
|
||||
|
||||
上面给出的伪代码并没有 **完整描述** `PUBLISH` 命令的行为,因为 `PUBLISH` 除了将 `message` 发送到 **所有订阅 `channel` 的客户端** 之外,它还会将 `channel` 和 `pubsub_patterns` 中的 **模式** 进行对比,如果 `channel` 和某个模式匹配的话,那么也将 `message` 发送到 **订阅那个模式的客户端**。
|
||||
|
||||
完整描述 `PUBLISH` 功能的伪代码定于如下:
|
||||
|
||||
```python
|
||||
def PUBLISH(channel, message):
|
||||
# 遍历所有订阅频道 channel 的客户端
|
||||
for client in server.pubsub_channels[channel]:
|
||||
# 将信息发送给它们
|
||||
send_message(client, message)
|
||||
# 取出所有模式,以及订阅模式的客户端
|
||||
for pattern, client in server.pubsub_patterns:
|
||||
# 如果 channel 和模式匹配
|
||||
if match(channel, pattern):
|
||||
# 那么也将信息发给订阅这个模式的客户端
|
||||
send_message(client, message)
|
||||
```
|
||||
|
||||
#### PUNSUBSCRIBE 命令
|
||||
|
||||
使用 `PUNSUBSCRIBE` 命令可以退订指定的模式,这个命令执行的是订阅模式的反操作:序会删除 `redisServer.pubsub_patterns` 链表中,所有和被退订模式相关联的 `pubsubPattern` 结构,这样客户端就不会再收到和模式相匹配的频道发来的信息。
|
||||
|
||||
## PubSub 的缺点
|
||||
|
||||
尽管 **Redis** 实现了 **PubSub** 模式来达到了 **多播消息队列** 的目的,但在实际的消息队列的领域,几乎 **找不到特别合适的场景**,因为它的缺点十分明显:
|
||||
|
||||
- **没有 Ack 机制,也不保证数据的连续:** PubSub 的生产者传递过来一个消息,Redis 会直接找到相应的消费者传递过去。如果没有一个消费者,那么消息会被直接丢弃。如果开始有三个消费者,其中一个突然挂掉了,过了一会儿等它再重连时,那么重连期间的消息对于这个消费者来说就彻底丢失了。
|
||||
- **不持久化消息:** 如果 Redis 停机重启,PubSub 的消息是不会持久化的,毕竟 Redis 宕机就相当于一个消费者都没有,所有的消息都会被直接丢弃。
|
||||
|
||||
|
||||
基于上述缺点,Redis 的作者甚至单独开启了一个 Disque 的项目来专门用来做多播消息队列,不过该项目目前好像都没有成熟。不过后来在 2018 年 6 月,**Redis 5.0** 新增了 `Stream` 数据结构,这个功能给 Redis 带来了 **持久化消息队列**,从此 PubSub 作为消息队列的功能可以说是就消失了..
|
||||
|
||||

|
||||
|
||||
# 二、更为强大的 Stream | 持久化的发布/订阅系统
|
||||
|
||||
**Redis Stream** 从概念上来说,就像是一个 **仅追加内容** 的 **消息链表**,把所有加入的消息都一个一个串起来,每个消息都有一个唯一的 ID 和内容,这很简单,让它复杂的是从 Kafka 借鉴的另一种概念:**消费者组(Consumer Group)** *(思路一致,实现不同)*:
|
||||
|
||||

|
||||
|
||||
上图就展示了一个典型的 **Stream** 结构。每个 Stream 都有唯一的名称,它就是 Redis 的 `key`,在我们首次使用 `xadd` 指令追加消息时自动创建。我们对图中的一些概念做一下解释:
|
||||
|
||||
- **Consumer Group**:消费者组,可以简单看成记录流状态的一种数据结构。消费者既可以选择使用 `XREAD` 命令进行 **独立消费**,也可以多个消费者同时加入一个消费者组进行 **组内消费**。同一个消费者组内的消费者共享所有的 Stream 信息,**同一条消息只会有一个消费者消费到**,这样就可以应用在分布式的应用场景中来保证消息的唯一性。
|
||||
- **last_delivered_id**:用来表示消费者组消费在 Stream 上 **消费位置** 的游标信息。每个消费者组都有一个 Stream 内 **唯一的名称**,消费者组不会自动创建,需要使用 `XGROUP CREATE` 指令来显式创建,并且需要指定从哪一个消息 ID 开始消费,用来初始化 `last_delivered_id` 这个变量。
|
||||
- **pending_ids**:每个消费者内部都有的一个状态变量,用来表示 **已经** 被客户端 **获取**,但是 **还没有 ack** 的消息。记录的目的是为了 **保证客户端至少消费了消息一次**,而不会在网络传输的中途丢失而没有对消息进行处理。如果客户端没有 ack,那么这个变量里面的消息 ID 就会越来越多,一旦某个消息被 ack,它就会对应开始减少。这个变量也被 Redis 官方称为 **PEL** *(Pending Entries List)*。
|
||||
|
||||
|
||||
|
||||
## 消息 ID 和消息内容
|
||||
|
||||
#### 消息 ID
|
||||
|
||||
消息 ID 如果是由 `XADD` 命令返回自动创建的话,那么它的格式会像这样:`timestampInMillis-sequence` *(毫秒时间戳-序列号)*,例如 `1527846880585-5`,它表示当前的消息是在毫秒时间戳 `1527846880585` 时产生的,并且是该毫秒内产生的第 5 条消息。
|
||||
|
||||
这些 ID 的格式看起来有一些奇怪,**为什么要使用时间来当做 ID 的一部分呢?** 一方面,我们要 **满足 ID 自增** 的属性,另一方面,也是为了 **支持范围查找** 的功能。由于 ID 和生成消息的时间有关,这样就使得在根据时间范围内查找时基本上是没有额外损耗的。
|
||||
|
||||
当然消息 ID 也可以由客户端自定义,但是形式必须是 **"整数-整数"**,而且后面加入的消息的 ID 必须要大于前面的消息 ID。
|
||||
|
||||
#### 消息内容
|
||||
|
||||
消息内容就是普通的键值对,形如 hash 结构的键值对。
|
||||
|
||||
## 增删改查示例
|
||||
|
||||
增删改查命令很简单,详情如下:
|
||||
|
||||
1. `xadd`:追加消息
|
||||
2. `xdel`:删除消息,这里的删除仅仅是设置了标志位,不影响消息总长度
|
||||
3. `xrange`:获取消息列表,会自动过滤已经删除的消息
|
||||
4. `xlen`:消息长度
|
||||
5. `del`:删除Stream
|
||||
|
||||
使用示例:
|
||||
|
||||
```bash
|
||||
# *号表示服务器自动生成ID,后面顺序跟着一堆key/value
|
||||
127.0.0.1:6379> xadd codehole * name laoqian age 30 # 名字叫laoqian,年龄30岁
|
||||
1527849609889-0 # 生成的消息ID
|
||||
127.0.0.1:6379> xadd codehole * name xiaoyu age 29
|
||||
1527849629172-0
|
||||
127.0.0.1:6379> xadd codehole * name xiaoqian age 1
|
||||
1527849637634-0
|
||||
127.0.0.1:6379> xlen codehole
|
||||
(integer) 3
|
||||
127.0.0.1:6379> xrange codehole - + # -表示最小值, +表示最大值
|
||||
1) 1) 1527849609889-0
|
||||
2) 1) "name"
|
||||
2) "laoqian"
|
||||
3) "age"
|
||||
4) "30"
|
||||
2) 1) 1527849629172-0
|
||||
2) 1) "name"
|
||||
2) "xiaoyu"
|
||||
3) "age"
|
||||
4) "29"
|
||||
3) 1) 1527849637634-0
|
||||
2) 1) "name"
|
||||
2) "xiaoqian"
|
||||
3) "age"
|
||||
4) "1"
|
||||
127.0.0.1:6379> xrange codehole 1527849629172-0 + # 指定最小消息ID的列表
|
||||
1) 1) 1527849629172-0
|
||||
2) 1) "name"
|
||||
2) "xiaoyu"
|
||||
3) "age"
|
||||
4) "29"
|
||||
2) 1) 1527849637634-0
|
||||
2) 1) "name"
|
||||
2) "xiaoqian"
|
||||
3) "age"
|
||||
4) "1"
|
||||
127.0.0.1:6379> xrange codehole - 1527849629172-0 # 指定最大消息ID的列表
|
||||
1) 1) 1527849609889-0
|
||||
2) 1) "name"
|
||||
2) "laoqian"
|
||||
3) "age"
|
||||
4) "30"
|
||||
2) 1) 1527849629172-0
|
||||
2) 1) "name"
|
||||
2) "xiaoyu"
|
||||
3) "age"
|
||||
4) "29"
|
||||
127.0.0.1:6379> xdel codehole 1527849609889-0
|
||||
(integer) 1
|
||||
127.0.0.1:6379> xlen codehole # 长度不受影响
|
||||
(integer) 3
|
||||
127.0.0.1:6379> xrange codehole - + # 被删除的消息没了
|
||||
1) 1) 1527849629172-0
|
||||
2) 1) "name"
|
||||
2) "xiaoyu"
|
||||
3) "age"
|
||||
4) "29"
|
||||
2) 1) 1527849637634-0
|
||||
2) 1) "name"
|
||||
2) "xiaoqian"
|
||||
3) "age"
|
||||
4) "1"
|
||||
127.0.0.1:6379> del codehole # 删除整个Stream
|
||||
(integer) 1
|
||||
```
|
||||
|
||||
## 独立消费示例
|
||||
|
||||
我们可以在不定义消费组的情况下进行 Stream 消息的 **独立消费**,当 Stream 没有新消息时,甚至可以阻塞等待。Redis 设计了一个单独的消费指令 `xread`,可以将 Stream 当成普通的消息队列(list)来使用。使用 `xread` 时,我们可以完全忽略 **消费组(Consumer Group)** 的存在,就好比 Stream 就是一个普通的列表(list):
|
||||
|
||||
```bash
|
||||
# 从Stream头部读取两条消息
|
||||
127.0.0.1:6379> xread count 2 streams codehole 0-0
|
||||
1) 1) "codehole"
|
||||
2) 1) 1) 1527851486781-0
|
||||
2) 1) "name"
|
||||
2) "laoqian"
|
||||
3) "age"
|
||||
4) "30"
|
||||
2) 1) 1527851493405-0
|
||||
2) 1) "name"
|
||||
2) "yurui"
|
||||
3) "age"
|
||||
4) "29"
|
||||
# 从Stream尾部读取一条消息,毫无疑问,这里不会返回任何消息
|
||||
127.0.0.1:6379> xread count 1 streams codehole $
|
||||
(nil)
|
||||
# 从尾部阻塞等待新消息到来,下面的指令会堵住,直到新消息到来
|
||||
127.0.0.1:6379> xread block 0 count 1 streams codehole $
|
||||
# 我们从新打开一个窗口,在这个窗口往Stream里塞消息
|
||||
127.0.0.1:6379> xadd codehole * name youming age 60
|
||||
1527852774092-0
|
||||
# 再切换到前面的窗口,我们可以看到阻塞解除了,返回了新的消息内容
|
||||
# 而且还显示了一个等待时间,这里我们等待了93s
|
||||
127.0.0.1:6379> xread block 0 count 1 streams codehole $
|
||||
1) 1) "codehole"
|
||||
2) 1) 1) 1527852774092-0
|
||||
2) 1) "name"
|
||||
2) "youming"
|
||||
3) "age"
|
||||
4) "60"
|
||||
(93.11s)
|
||||
```
|
||||
|
||||
客户端如果想要使用 `xread` 进行 **顺序消费**,一定要 **记住当前消费** 到哪里了,也就是返回的消息 ID。下次继续调用 `xread` 时,将上次返回的最后一个消息 ID 作为参数传递进去,就可以继续消费后续的消息。
|
||||
|
||||
`block 0` 表示永远阻塞,直到消息到来,`block 1000` 表示阻塞 `1s`,如果 `1s` 内没有任何消息到来,就返回 `nil`:
|
||||
|
||||
```bash
|
||||
127.0.0.1:6379> xread block 1000 count 1 streams codehole $
|
||||
(nil)
|
||||
(1.07s)
|
||||
```
|
||||
|
||||
## 创建消费者示例
|
||||
|
||||
Stream 通过 `xgroup create` 指令创建消费组(Consumer Group),需要传递起始消息 ID 参数用来初始化 `last_delivered_id` 变量:
|
||||
|
||||
```bash
|
||||
127.0.0.1:6379> xgroup create codehole cg1 0-0 # 表示从头开始消费
|
||||
OK
|
||||
# $表示从尾部开始消费,只接受新消息,当前Stream消息会全部忽略
|
||||
127.0.0.1:6379> xgroup create codehole cg2 $
|
||||
OK
|
||||
127.0.0.1:6379> xinfo codehole # 获取Stream信息
|
||||
1) length
|
||||
2) (integer) 3 # 共3个消息
|
||||
3) radix-tree-keys
|
||||
4) (integer) 1
|
||||
5) radix-tree-nodes
|
||||
6) (integer) 2
|
||||
7) groups
|
||||
8) (integer) 2 # 两个消费组
|
||||
9) first-entry # 第一个消息
|
||||
10) 1) 1527851486781-0
|
||||
2) 1) "name"
|
||||
2) "laoqian"
|
||||
3) "age"
|
||||
4) "30"
|
||||
11) last-entry # 最后一个消息
|
||||
12) 1) 1527851498956-0
|
||||
2) 1) "name"
|
||||
2) "xiaoqian"
|
||||
3) "age"
|
||||
4) "1"
|
||||
127.0.0.1:6379> xinfo groups codehole # 获取Stream的消费组信息
|
||||
1) 1) name
|
||||
2) "cg1"
|
||||
3) consumers
|
||||
4) (integer) 0 # 该消费组还没有消费者
|
||||
5) pending
|
||||
6) (integer) 0 # 该消费组没有正在处理的消息
|
||||
2) 1) name
|
||||
2) "cg2"
|
||||
3) consumers # 该消费组还没有消费者
|
||||
4) (integer) 0
|
||||
5) pending
|
||||
6) (integer) 0 # 该消费组没有正在处理的消息
|
||||
```
|
||||
|
||||
## 组内消费示例
|
||||
|
||||
Stream 提供了 `xreadgroup` 指令可以进行消费组的组内消费,需要提供 **消费组名称、消费者名称和起始消息 ID**。它同 `xread` 一样,也可以阻塞等待新消息。读到新消息后,对应的消息 ID 就会进入消费者的 **PEL** *(正在处理的消息)* 结构里,客户端处理完毕后使用 `xack` 指令 **通知服务器**,本条消息已经处理完毕,该消息 ID 就会从 **PEL** 中移除,下面是示例:
|
||||
|
||||
```bash
|
||||
# >号表示从当前消费组的last_delivered_id后面开始读
|
||||
# 每当消费者读取一条消息,last_delivered_id变量就会前进
|
||||
127.0.0.1:6379> xreadgroup GROUP cg1 c1 count 1 streams codehole >
|
||||
1) 1) "codehole"
|
||||
2) 1) 1) 1527851486781-0
|
||||
2) 1) "name"
|
||||
2) "laoqian"
|
||||
3) "age"
|
||||
4) "30"
|
||||
127.0.0.1:6379> xreadgroup GROUP cg1 c1 count 1 streams codehole >
|
||||
1) 1) "codehole"
|
||||
2) 1) 1) 1527851493405-0
|
||||
2) 1) "name"
|
||||
2) "yurui"
|
||||
3) "age"
|
||||
4) "29"
|
||||
127.0.0.1:6379> xreadgroup GROUP cg1 c1 count 2 streams codehole >
|
||||
1) 1) "codehole"
|
||||
2) 1) 1) 1527851498956-0
|
||||
2) 1) "name"
|
||||
2) "xiaoqian"
|
||||
3) "age"
|
||||
4) "1"
|
||||
2) 1) 1527852774092-0
|
||||
2) 1) "name"
|
||||
2) "youming"
|
||||
3) "age"
|
||||
4) "60"
|
||||
# 再继续读取,就没有新消息了
|
||||
127.0.0.1:6379> xreadgroup GROUP cg1 c1 count 1 streams codehole >
|
||||
(nil)
|
||||
# 那就阻塞等待吧
|
||||
127.0.0.1:6379> xreadgroup GROUP cg1 c1 block 0 count 1 streams codehole >
|
||||
# 开启另一个窗口,往里塞消息
|
||||
127.0.0.1:6379> xadd codehole * name lanying age 61
|
||||
1527854062442-0
|
||||
# 回到前一个窗口,发现阻塞解除,收到新消息了
|
||||
127.0.0.1:6379> xreadgroup GROUP cg1 c1 block 0 count 1 streams codehole >
|
||||
1) 1) "codehole"
|
||||
2) 1) 1) 1527854062442-0
|
||||
2) 1) "name"
|
||||
2) "lanying"
|
||||
3) "age"
|
||||
4) "61"
|
||||
(36.54s)
|
||||
127.0.0.1:6379> xinfo groups codehole # 观察消费组信息
|
||||
1) 1) name
|
||||
2) "cg1"
|
||||
3) consumers
|
||||
4) (integer) 1 # 一个消费者
|
||||
5) pending
|
||||
6) (integer) 5 # 共5条正在处理的信息还有没有ack
|
||||
2) 1) name
|
||||
2) "cg2"
|
||||
3) consumers
|
||||
4) (integer) 0 # 消费组cg2没有任何变化,因为前面我们一直在操纵cg1
|
||||
5) pending
|
||||
6) (integer) 0
|
||||
# 如果同一个消费组有多个消费者,我们可以通过xinfo consumers指令观察每个消费者的状态
|
||||
127.0.0.1:6379> xinfo consumers codehole cg1 # 目前还有1个消费者
|
||||
1) 1) name
|
||||
2) "c1"
|
||||
3) pending
|
||||
4) (integer) 5 # 共5条待处理消息
|
||||
5) idle
|
||||
6) (integer) 418715 # 空闲了多长时间ms没有读取消息了
|
||||
# 接下来我们ack一条消息
|
||||
127.0.0.1:6379> xack codehole cg1 1527851486781-0
|
||||
(integer) 1
|
||||
127.0.0.1:6379> xinfo consumers codehole cg1
|
||||
1) 1) name
|
||||
2) "c1"
|
||||
3) pending
|
||||
4) (integer) 4 # 变成了5条
|
||||
5) idle
|
||||
6) (integer) 668504
|
||||
# 下面ack所有消息
|
||||
127.0.0.1:6379> xack codehole cg1 1527851493405-0 1527851498956-0 1527852774092-0 1527854062442-0
|
||||
(integer) 4
|
||||
127.0.0.1:6379> xinfo consumers codehole cg1
|
||||
1) 1) name
|
||||
2) "c1"
|
||||
3) pending
|
||||
4) (integer) 0 # pel空了
|
||||
5) idle
|
||||
6) (integer) 745505
|
||||
```
|
||||
|
||||
## QA 1:Stream 消息太多怎么办? | Stream 的上限
|
||||
|
||||
很容易想到,要是消息积累太多,Stream 的链表岂不是很长,内容会不会爆掉就是个问题了。`xdel` 指令又不会删除消息,它只是给消息做了个标志位。
|
||||
|
||||
Redis 自然考虑到了这一点,所以它提供了一个定长 Stream 功能。在 `xadd` 的指令提供一个定长长度 `maxlen`,就可以将老的消息干掉,确保最多不超过指定长度,使用起来也很简单:
|
||||
|
||||
```bash
|
||||
> XADD mystream MAXLEN 2 * value 1
|
||||
1526654998691-0
|
||||
> XADD mystream MAXLEN 2 * value 2
|
||||
1526654999635-0
|
||||
> XADD mystream MAXLEN 2 * value 3
|
||||
1526655000369-0
|
||||
> XLEN mystream
|
||||
(integer) 2
|
||||
> XRANGE mystream - +
|
||||
1) 1) 1526654999635-0
|
||||
2) 1) "value"
|
||||
2) "2"
|
||||
2) 1) 1526655000369-0
|
||||
2) 1) "value"
|
||||
2) "3"
|
||||
```
|
||||
|
||||
如果使用 `MAXLEN` 选项,当 Stream 的达到指定长度后,老的消息会自动被淘汰掉,因此 Stream 的大小是恒定的。目前还没有选项让 Stream 只保留给定数量的条目,因为为了一致地运行,这样的命令必须在很长一段时间内阻塞以淘汰消息。*(例如在添加数据的高峰期间,你不得不长暂停来淘汰旧消息和添加新的消息)*
|
||||
|
||||
另外使用 `MAXLEN` 选项的花销是很大的,Stream 为了节省内存空间,采用了一种特殊的结构表示,而这种结构的调整是需要额外的花销的。所以我们可以使用一种带有 `~` 的特殊命令:
|
||||
|
||||
```bash
|
||||
XADD mystream MAXLEN ~ 1000 * ... entry fields here ...
|
||||
```
|
||||
|
||||
它会基于当前的结构合理地对节点执行裁剪,来保证至少会有 `1000` 条数据,可能是 `1010` 也可能是 `1030`。
|
||||
|
||||
## QA 2:PEL 是如何避免消息丢失的?
|
||||
|
||||
|
||||
在客户端消费者读取 Stream 消息时,Redis 服务器将消息回复给客户端的过程中,客户端突然断开了连接,消息就丢失了。但是 PEL 里已经保存了发出去的消息 ID,待客户端重新连上之后,可以再次收到 PEL 中的消息 ID 列表。不过此时 `xreadgroup` 的起始消息 ID 不能为参数 `>` ,而必须是任意有效的消息 ID,一般将参数设为 `0-0`,表示读取所有的 PEL 消息以及自 `last_delivered_id` 之后的新消息。
|
||||
|
||||
|
||||
## Redis Stream Vs Kafka
|
||||
|
||||
Redis 基于内存存储,这意味着它会比基于磁盘的 Kafka 快上一些,也意味着使用 Redis 我们 **不能长时间存储大量数据**。不过如果您想以 **最小延迟** 实时处理消息的话,您可以考虑 Redis,但是如果 **消息很大并且应该重用数据** 的话,则应该首先考虑使用 Kafka。
|
||||
|
||||
另外从某些角度来说,`Redis Stream` 也更适用于小型、廉价的应用程序,因为 `Kafka` 相对来说更难配置一些。
|
||||
|
||||
|
||||
# 相关阅读
|
||||
|
||||
1. Redis(1)——5种基本数据结构 - [https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/](https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/)
|
||||
2. Redis(2)——跳跃表 - [https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/](https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/)
|
||||
3. Redis(3)——分布式锁深入探究 - [https://www.wmyskxz.com/2020/03/01/redis-3/](https://www.wmyskxz.com/2020/03/01/redis-3/)
|
||||
4. Reids(4)——神奇的HyperLoglog解决统计问题 - [https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/](https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/)
|
||||
5. Redis(5)——亿级数据过滤和布隆过滤器 - [https://www.wmyskxz.com/2020/03/11/redis-5-yi-ji-shu-ju-guo-lu-he-bu-long-guo-lu-qi/](https://www.wmyskxz.com/2020/03/11/redis-5-yi-ji-shu-ju-guo-lu-he-bu-long-guo-lu-qi/)
|
||||
6. Redis(6)——GeoHash查找附近的人[https://www.wmyskxz.com/2020/03/12/redis-6-geohash-cha-zhao-fu-jin-de-ren/](https://www.wmyskxz.com/2020/03/12/redis-6-geohash-cha-zhao-fu-jin-de-ren/)
|
||||
7. Redis(7)——持久化【一文了解】 - [https://www.wmyskxz.com/2020/03/13/redis-7-chi-jiu-hua-yi-wen-liao-jie/](https://www.wmyskxz.com/2020/03/13/redis-7-chi-jiu-hua-yi-wen-liao-jie/)
|
||||
|
||||
|
||||
# 参考资料
|
||||
|
||||
1. 订阅与发布——Redis 设计与实现 - [https://redisbook.readthedocs.io/en/latest/feature/pubsub.html](https://redisbook.readthedocs.io/en/latest/feature/pubsub.html)
|
||||
2. 《Redis 深度历险》 - 钱文品/ 著 - [https://book.douban.com/subject/30386804/](https://book.douban.com/subject/30386804/)
|
||||
3. Introduction to Redis Streams【官方文档】 - [https://redis.io/topics/streams-intro](https://redis.io/topics/streams-intro)
|
||||
4. Kafka vs. Redis: Log Aggregation Capabilities and Performance - [https://logz.io/blog/kafka-vs-redis/](https://logz.io/blog/kafka-vs-redis/)
|
648
docs/database/Redis/redis-collection/Redis(9)——集群入门实践教程.md
Normal file
648
docs/database/Redis/redis-collection/Redis(9)——集群入门实践教程.md
Normal file
@ -0,0 +1,648 @@
|
||||
> 授权转载自: https://github.com/wmyskxz/MoreThanJava#part3-redis
|
||||
|
||||

|
||||
|
||||
# 一、Redis 集群概述
|
||||
|
||||
#### Redis 主从复制
|
||||
|
||||
到 [目前](#相关阅读) 为止,我们所学习的 Redis 都是 **单机版** 的,这也就意味着一旦我们所依赖的 Redis 服务宕机了,我们的主流程也会受到一定的影响,这当然是我们不能够接受的。
|
||||
|
||||
所以一开始我们的想法是:搞一台备用机。这样我们就可以在一台服务器出现问题的时候切换动态地到另一台去:
|
||||
|
||||

|
||||
|
||||
幸运的是,两个节点数据的同步我们可以使用 Redis 的 **主从同步** 功能帮助到我们,这样一来,有个备份,心里就踏实多了。
|
||||
|
||||

|
||||
|
||||
#### Redis 哨兵
|
||||
|
||||
后来因为某种神秘力量,Redis 老会在莫名其妙的时间点出问题 *(比如半夜 2 点)*,我总不能 24 小时时刻守在电脑旁边切换节点吧,于是另一个想法又开始了:给所有的节点找一个 **"管家"**,自动帮我监听照顾节点的状态并切换:
|
||||
|
||||

|
||||
|
||||
这大概就是 **Redis 哨兵** *(Sentinel)* 的简单理解啦。什么?管家宕机了怎么办?相较于有大量请求的 Redis 服务来说,管家宕机的概率就要小得多啦.. 如果真的宕机了,我们也可以直接切换成当前可用的节点保证可用..
|
||||
|
||||

|
||||
|
||||
#### Redis 集群化
|
||||
|
||||
好了,通过上面的一些解决方案我们对 Redis 的 **稳定性** 稍微有了一些底气了,但单台节点的计算能力始终有限,所谓人多力量大,如果我们把 **多个节点组合** 成 **一个可用的工作节点**,那就大大增加了 Redis 的 **高可用、可扩展、分布式、容错** 等特性:
|
||||
|
||||

|
||||
|
||||
# 二、主从复制
|
||||
|
||||

|
||||
|
||||
**主从复制**,是指将一台 Redis 服务器的数据,复制到其他的 Redis 服务器。前者称为 **主节点(master)**,后者称为 **从节点(slave)**。且数据的复制是 **单向** 的,只能由主节点到从节点。Redis 主从复制支持 **主从同步** 和 **从从同步** 两种,后者是 Redis 后续版本新增的功能,以减轻主节点的同步负担。
|
||||
|
||||
#### 主从复制主要的作用
|
||||
|
||||
- **数据冗余:** 主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。
|
||||
- **故障恢复:** 当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢复 *(实际上是一种服务的冗余)*。
|
||||
- **负载均衡:** 在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由从节点提供读服务 *(即写 Redis 数据时应用连接主节点,读 Redis 数据时应用连接从节点)*,分担服务器负载。尤其是在写少读多的场景下,通过多个从节点分担读负载,可以大大提高 Redis 服务器的并发量。
|
||||
- **高可用基石:** 除了上述作用以外,主从复制还是哨兵和集群能够实施的 **基础**,因此说主从复制是 Redis 高可用的基础。
|
||||
|
||||
## 快速体验
|
||||
|
||||
在 **Redis** 中,用户可以通过执行 `SLAVEOF` 命令或者设置 `slaveof` 选项,让一个服务器去复制另一个服务器,以下三种方式是 **完全等效** 的:
|
||||
|
||||
- **配置文件**:在从服务器的配置文件中加入:`slaveof <masterip> <masterport>`
|
||||
- **启动命令**:redis-server 启动命令后加入 `--slaveof <masterip> <masterport>`
|
||||
- **客户端命令**:Redis 服务器启动后,直接通过客户端执行命令:`slaveof <masterip> <masterport>`,让该 Redis 实例成为从节点。
|
||||
|
||||
需要注意的是:**主从复制的开启,完全是在从节点发起的,不需要我们在主节点做任何事情。**
|
||||
|
||||
#### 第一步:本地启动两个节点
|
||||
|
||||
在正确安装好 Redis 之后,我们可以使用 `redis-server --port <port>` 的方式指定创建两个不同端口的 Redis 实例,例如,下方我分别创建了一个 `6379` 和 `6380` 的两个 Redis 实例:
|
||||
|
||||
```bash
|
||||
# 创建一个端口为 6379 的 Redis 实例
|
||||
redis-server --port 6379
|
||||
# 创建一个端口为 6380 的 Redis 实例
|
||||
redis-server --port 6380
|
||||
```
|
||||
|
||||
此时两个 Redis 节点启动后,都默认为 **主节点**。
|
||||
|
||||
#### 第二步:建立复制
|
||||
|
||||
我们在 `6380` 端口的节点中执行 `slaveof` 命令,使之变为从节点:
|
||||
|
||||
```bash
|
||||
# 在 6380 端口的 Redis 实例中使用控制台
|
||||
redis-cli -p 6380
|
||||
# 成为本地 6379 端口实例的从节点
|
||||
127.0.0.1:6380> SLAVEOF 127.0.0.1ø 6379
|
||||
OK
|
||||
```
|
||||
|
||||
#### 第三步:观察效果
|
||||
|
||||
下面我们来验证一下,主节点的数据是否会复制到从节点之中:
|
||||
|
||||
- 先在 **从节点** 中查询一个 **不存在** 的 key:
|
||||
```bash
|
||||
127.0.0.1:6380> GET mykey
|
||||
(nil)
|
||||
```
|
||||
- 再在 **主节点** 中添加这个 key:
|
||||
```bash
|
||||
127.0.0.1:6379> SET mykey myvalue
|
||||
OK
|
||||
```
|
||||
- 此时再从 **从节点** 中查询,会发现已经从 **主节点** 同步到 **从节点**:
|
||||
```bash
|
||||
127.0.0.1:6380> GET mykey
|
||||
"myvalue"
|
||||
```
|
||||
|
||||
#### 第四步:断开复制
|
||||
|
||||
通过 `slaveof <masterip> <masterport>` 命令建立主从复制关系以后,可以通过 `slaveof no one` 断开。需要注意的是,从节点断开复制后,**不会删除已有的数据**,只是不再接受主节点新的数据变化。
|
||||
|
||||
从节点执行 `slaveof no one` 之后,从节点和主节点分别打印日志如下:、
|
||||
|
||||
```bash
|
||||
# 从节点打印日志
|
||||
61496:M 17 Mar 2020 08:10:22.749 # Connection with master lost.
|
||||
61496:M 17 Mar 2020 08:10:22.749 * Caching the disconnected master state.
|
||||
61496:M 17 Mar 2020 08:10:22.749 * Discarding previously cached master state.
|
||||
61496:M 17 Mar 2020 08:10:22.749 * MASTER MODE enabled (user request from 'id=4 addr=127.0.0.1:55096 fd=8 name= age=1664 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=34 qbuf-free=32734 obl=0 oll=0 omem=0 events=r cmd=slaveof')
|
||||
|
||||
# 主节点打印日志
|
||||
61467:M 17 Mar 2020 08:10:22.749 # Connection with replica 127.0.0.1:6380 lost.
|
||||
```
|
||||
|
||||
## 实现原理简析
|
||||
|
||||

|
||||
|
||||
为了节省篇幅,我把主要的步骤都 **浓缩** 在了上图中,其实也可以 **简化成三个阶段:准备阶段-数据同步阶段-命令传播阶段**。下面我们来进行一些必要的说明。
|
||||
|
||||
#### 身份验证 | 主从复制安全问题
|
||||
|
||||
在上面的 **快速体验** 过程中,你会发现 `slaveof` 这个命令居然不需要验证?这意味着只要知道了 ip 和端口就可以随意拷贝服务器上的数据了?
|
||||
|
||||

|
||||
|
||||
那当然不能够了,我们可以通过在 **主节点** 配置 `requirepass` 来设置密码,这样就必须在 **从节点** 中对应配置好 `masterauth` 参数 *(与主节点 `requirepass` 保持一致)* 才能够进行正常复制了。
|
||||
|
||||
#### SYNC 命令是一个非常耗费资源的操作
|
||||
|
||||
每次执行 `SYNC` 命令,主从服务器需要执行如下动作:
|
||||
|
||||
1. **主服务器** 需要执行 `BGSAVE` 命令来生成 RDB 文件,这个生成操作会 **消耗** 主服务器大量的 **CPU、内存和磁盘 I/O 的资源**;
|
||||
2. **主服务器** 需要将自己生成的 RDB 文件 发送给从服务器,这个发送操作会 **消耗** 主服务器 **大量的网络资源** *(带宽和流量)*,并对主服务器响应命令请求的时间产生影响;
|
||||
3. 接收到 RDB 文件的 **从服务器** 需要载入主服务器发来的 RBD 文件,并且在载入期间,从服务器 **会因为阻塞而没办法处理命令请求**;
|
||||
|
||||
特别是当出现 **断线重复制** 的情况是时,为了让从服务器补足断线时确实的那一小部分数据,却要执行一次如此耗资源的 `SYNC` 命令,显然是不合理的。
|
||||
|
||||
#### PSYNC 命令的引入
|
||||
|
||||
所以在 **Redis 2.8** 中引入了 `PSYNC` 命令来代替 `SYNC`,它具有两种模式:
|
||||
|
||||
1. **全量复制:** 用于初次复制或其他无法进行部分复制的情况,将主节点中的所有数据都发送给从节点,是一个非常重型的操作;
|
||||
2. **部分复制:** 用于网络中断等情况后的复制,只将 **中断期间主节点执行的写命令** 发送给从节点,与全量复制相比更加高效。**需要注意** 的是,如果网络中断时间过长,导致主节点没有能够完整地保存中断期间执行的写命令,则无法进行部分复制,仍使用全量复制;
|
||||
|
||||
|
||||
部分复制的原理主要是靠主从节点分别维护一个 **复制偏移量**,有了这个偏移量之后断线重连之后一比较,之后就可以仅仅把从服务器断线之后确实的这部分数据给补回来了。
|
||||
|
||||
> 更多的详细内容可以参考下方 *参考资料 3*
|
||||
|
||||
# 三、Redis Sentinel 哨兵
|
||||
|
||||

|
||||
|
||||
*上图* 展示了一个典型的哨兵架构图,它由两部分组成,哨兵节点和数据节点:
|
||||
|
||||
- **哨兵节点:** 哨兵系统由一个或多个哨兵节点组成,哨兵节点是特殊的 Redis 节点,不存储数据;
|
||||
- **数据节点:** 主节点和从节点都是数据节点;
|
||||
|
||||
在复制的基础上,哨兵实现了 **自动化的故障恢复** 功能,下方是官方对于哨兵功能的描述:
|
||||
|
||||
- **监控(Monitoring):** 哨兵会不断地检查主节点和从节点是否运作正常。
|
||||
- **自动故障转移(Automatic failover):** 当 **主节点** 不能正常工作时,哨兵会开始 **自动故障转移操作**,它会将失效主节点的其中一个 **从节点升级为新的主节点**,并让其他从节点改为复制新的主节点。
|
||||
- **配置提供者(Configuration provider):** 客户端在初始化时,通过连接哨兵来获得当前 Redis 服务的主节点地址。
|
||||
- **通知(Notification):** 哨兵可以将故障转移的结果发送给客户端。
|
||||
|
||||
其中,监控和自动故障转移功能,使得哨兵可以及时发现主节点故障并完成转移。而配置提供者和通知功能,则需要在与客户端的交互中才能体现。
|
||||
|
||||
## 快速体验
|
||||
|
||||
#### 第一步:创建主从节点配置文件并启动
|
||||
|
||||
正确安装好 Redis 之后,我们去到 Redis 的安装目录 *(mac 默认在 `/usr/local/`)*,找到 `redis.conf` 文件复制三份分别命名为 `redis-master.conf`/`redis-slave1.conf`/`redis-slave2.conf`,分别作为 `1` 个主节点和 `2` 个从节点的配置文件 *(下图演示了我本机的 `redis.conf` 文件的位置)*
|
||||
|
||||

|
||||
|
||||
打开可以看到这个 `.conf` 后缀的文件里面有很多说明的内容,全部删除然后分别改成下面的样子:
|
||||
|
||||
```bash
|
||||
#redis-master.conf
|
||||
port 6379
|
||||
daemonize yes
|
||||
logfile "6379.log"
|
||||
dbfilename "dump-6379.rdb"
|
||||
|
||||
#redis-slave1.conf
|
||||
port 6380
|
||||
daemonize yes
|
||||
logfile "6380.log"
|
||||
dbfilename "dump-6380.rdb"
|
||||
slaveof 127.0.0.1 6379
|
||||
|
||||
#redis-slave2.conf
|
||||
port 6381
|
||||
daemonize yes
|
||||
logfile "6381.log"
|
||||
dbfilename "dump-6381.rdb"
|
||||
slaveof 127.0.0.1 6379
|
||||
```
|
||||
|
||||
然后我们可以执行 `redis-server <config file path>` 来根据配置文件启动不同的 Redis 实例,依次启动主从节点:
|
||||
|
||||
```bash
|
||||
redis-server /usr/local/redis-5.0.3/redis-master.conf
|
||||
redis-server /usr/local/redis-5.0.3/redis-slave1.conf
|
||||
redis-server /usr/local/redis-5.0.3/redis-slave2.conf
|
||||
```
|
||||
|
||||
节点启动后,我们执行 `redis-cli` 默认连接到我们端口为 `6379` 的主节点执行 `info Replication` 检查一下主从状态是否正常:*(可以看到下方正确地显示了两个从节点)*
|
||||
|
||||

|
||||
|
||||
#### 第二步:创建哨兵节点配置文件并启动
|
||||
|
||||
按照上面同样的方法,我们给哨兵节点也创建三个配置文件。*(哨兵节点本质上是特殊的 Redis 节点,所以配置几乎没什么差别,只是在端口上做区分就好)*
|
||||
|
||||
```bash
|
||||
# redis-sentinel-1.conf
|
||||
port 26379
|
||||
daemonize yes
|
||||
logfile "26379.log"
|
||||
sentinel monitor mymaster 127.0.0.1 6379 2
|
||||
|
||||
# redis-sentinel-2.conf
|
||||
port 26380
|
||||
daemonize yes
|
||||
logfile "26380.log"
|
||||
sentinel monitor mymaster 127.0.0.1 6379 2
|
||||
|
||||
# redis-sentinel-3.conf
|
||||
port 26381
|
||||
daemonize yes
|
||||
logfile "26381.log"
|
||||
sentinel monitor mymaster 127.0.0.1 6379 2
|
||||
```
|
||||
|
||||
其中,`sentinel monitor mymaster 127.0.0.1 6379 2` 配置的含义是:该哨兵节点监控 `127.0.0.1:6379` 这个主节点,该主节点的名称是 `mymaster`,最后的 `2` 的含义与主节点的故障判定有关:至少需要 `2` 个哨兵节点同意,才能判定主节点故障并进行故障转移。
|
||||
|
||||
执行下方命令将哨兵节点启动起来:
|
||||
|
||||
```bash
|
||||
redis-server /usr/local/redis-5.0.3/redis-sentinel-1.conf --sentinel
|
||||
redis-server /usr/local/redis-5.0.3/redis-sentinel-2.conf --sentinel
|
||||
redis-server /usr/local/redis-5.0.3/redis-sentinel-3.conf --sentinel
|
||||
```
|
||||
|
||||
使用 `redis-cil` 工具连接哨兵节点,并执行 `info Sentinel` 命令来查看是否已经在监视主节点了:
|
||||
|
||||
```bash
|
||||
# 连接端口为 26379 的 Redis 节点
|
||||
➜ ~ redis-cli -p 26379
|
||||
127.0.0.1:26379> info Sentinel
|
||||
# Sentinel
|
||||
sentinel_masters:1
|
||||
sentinel_tilt:0
|
||||
sentinel_running_scripts:0
|
||||
sentinel_scripts_queue_length:0
|
||||
sentinel_simulate_failure_flags:0
|
||||
master0:name=mymaster,status=ok,address=127.0.0.1:6379,slaves=2,sentinels=3
|
||||
```
|
||||
|
||||
此时你打开刚才写好的哨兵配置文件,你还会发现出现了一些变化:
|
||||
|
||||
#### 第三步:演示故障转移
|
||||
|
||||
首先,我们使用 `kill -9` 命令来杀掉主节点,**同时** 在哨兵节点中执行 `info Sentinel` 命令来观察故障节点的过程:
|
||||
|
||||
```bash
|
||||
➜ ~ ps aux | grep 6379
|
||||
longtao 74529 0.3 0.0 4346936 2132 ?? Ss 10:30上午 0:03.09 redis-server *:26379 [sentinel]
|
||||
longtao 73541 0.2 0.0 4348072 2292 ?? Ss 10:18上午 0:04.79 redis-server *:6379
|
||||
longtao 75521 0.0 0.0 4286728 728 s008 S+ 10:39上午 0:00.00 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn 6379
|
||||
longtao 74836 0.0 0.0 4289844 944 s006 S+ 10:32上午 0:00.01 redis-cli -p 26379
|
||||
➜ ~ kill -9 73541
|
||||
```
|
||||
|
||||
如果 **刚杀掉瞬间** 在哨兵节点中执行 `info` 命令来查看,会发现主节点还没有切换过来,因为哨兵发现主节点故障并转移需要一段时间:
|
||||
|
||||
```bash
|
||||
# 第一时间查看哨兵节点发现并未转移,还在 6379 端口
|
||||
127.0.0.1:26379> info Sentinel
|
||||
# Sentinel
|
||||
sentinel_masters:1
|
||||
sentinel_tilt:0
|
||||
sentinel_running_scripts:0
|
||||
sentinel_scripts_queue_length:0
|
||||
sentinel_simulate_failure_flags:0
|
||||
master0:name=mymaster,status=ok,address=127.0.0.1:6379,slaves=2,sentinels=3
|
||||
```
|
||||
|
||||
一段时间之后你再执行 `info` 命令,查看,你就会发现主节点已经切换成了 `6381` 端口的从节点:
|
||||
|
||||
```bash
|
||||
# 过一段时间之后在执行,发现已经切换了 6381 端口
|
||||
127.0.0.1:26379> info Sentinel
|
||||
# Sentinel
|
||||
sentinel_masters:1
|
||||
sentinel_tilt:0
|
||||
sentinel_running_scripts:0
|
||||
sentinel_scripts_queue_length:0
|
||||
sentinel_simulate_failure_flags:0
|
||||
master0:name=mymaster,status=ok,address=127.0.0.1:6381,slaves=2,sentinels=3
|
||||
```
|
||||
|
||||
但同时还可以发现,**哨兵节点认为新的主节点仍然有两个从节点** *(上方 slaves=2)*,这是因为哨兵在将 `6381` 切换成主节点的同时,将 `6379` 节点置为其从节点。虽然 `6379` 从节点已经挂掉,但是由于 **哨兵并不会对从节点进行客观下线**,因此认为该从节点一直存在。当 `6379` 节点重新启动后,会自动变成 `6381` 节点的从节点。
|
||||
|
||||
另外,在故障转移的阶段,哨兵和主从节点的配置文件都会被改写:
|
||||
|
||||
- **对于主从节点:** 主要是 `slaveof` 配置的变化,新的主节点没有了 `slaveof` 配置,其从节点则 `slaveof` 新的主节点。
|
||||
- **对于哨兵节点:** 除了主从节点信息的变化,纪元(epoch) *(记录当前集群状态的参数)* 也会变化,纪元相关的参数都 +1 了。
|
||||
|
||||
## 客户端访问哨兵系统代码演示
|
||||
|
||||
上面我们在 *快速体验* 中主要感受到了服务端自己对于当前主从节点的自动化治理,下面我们以 Java 代码为例,来演示一下客户端如何访问我们的哨兵系统:
|
||||
|
||||
```java
|
||||
public static void testSentinel() throws Exception {
|
||||
String masterName = "mymaster";
|
||||
Set<String> sentinels = new HashSet<>();
|
||||
sentinels.add("127.0.0.1:26379");
|
||||
sentinels.add("127.0.0.1:26380");
|
||||
sentinels.add("127.0.0.1:26381");
|
||||
|
||||
// 初始化过程做了很多工作
|
||||
JedisSentinelPool pool = new JedisSentinelPool(masterName, sentinels);
|
||||
Jedis jedis = pool.getResource();
|
||||
jedis.set("key1", "value1");
|
||||
pool.close();
|
||||
}
|
||||
```
|
||||
|
||||
#### 客户端原理
|
||||
|
||||
Jedis 客户端对哨兵提供了很好的支持。如上述代码所示,我们只需要向 Jedis 提供哨兵节点集合和 `masterName` ,构造 `JedisSentinelPool` 对象,然后便可以像使用普通 Redis 连接池一样来使用了:通过 `pool.getResource()` 获取连接,执行具体的命令。
|
||||
|
||||
在整个过程中,我们的代码不需要显式的指定主节点的地址,就可以连接到主节点;代码中对故障转移没有任何体现,就可以在哨兵完成故障转移后自动的切换主节点。之所以可以做到这一点,是因为在 `JedisSentinelPool` 的构造器中,进行了相关的工作;主要包括以下两点:
|
||||
|
||||
1. **遍历哨兵节点,获取主节点信息:** 遍历哨兵节点,通过其中一个哨兵节点 + `masterName` 获得主节点的信息;该功能是通过调用哨兵节点的 `sentinel get-master-addr-by-name` 命令实现;
|
||||
2. **增加对哨兵的监听:** 这样当发生故障转移时,客户端便可以收到哨兵的通知,从而完成主节点的切换。具体做法是:利用 Redis 提供的 **发布订阅** 功能,为每一个哨兵节点开启一个单独的线程,订阅哨兵节点的 + switch-master 频道,当收到消息时,重新初始化连接池。
|
||||
|
||||
## 新的主服务器是怎样被挑选出来的?
|
||||
|
||||
**故障转移操作的第一步** 要做的就是在已下线主服务器属下的所有从服务器中,挑选出一个状态良好、数据完整的从服务器,然后向这个从服务器发送 `slaveof no one` 命令,将这个从服务器转换为主服务器。但是这个从服务器是怎么样被挑选出来的呢?
|
||||
|
||||

|
||||
|
||||
简单来说 Sentinel 使用以下规则来选择新的主服务器:
|
||||
|
||||
1. 在失效主服务器属下的从服务器当中, 那些被标记为主观下线、已断线、或者最后一次回复 PING 命令的时间大于五秒钟的从服务器都会被 **淘汰**。
|
||||
2. 在失效主服务器属下的从服务器当中, 那些与失效主服务器连接断开的时长超过 down-after 选项指定的时长十倍的从服务器都会被 **淘汰**。
|
||||
3. 在 **经历了以上两轮淘汰之后** 剩下来的从服务器中, 我们选出 **复制偏移量(replication offset)最大** 的那个 **从服务器** 作为新的主服务器;如果复制偏移量不可用,或者从服务器的复制偏移量相同,那么 **带有最小运行 ID** 的那个从服务器成为新的主服务器。
|
||||
|
||||
# 四、Redis 集群
|
||||
|
||||

|
||||
|
||||
*上图* 展示了 **Redis Cluster** 典型的架构图,集群中的每一个 Redis 节点都 **互相两两相连**,客户端任意 **直连** 到集群中的 **任意一台**,就可以对其他 Redis 节点进行 **读写** 的操作。
|
||||
|
||||
#### 基本原理
|
||||
|
||||

|
||||
|
||||
Redis 集群中内置了 `16384` 个哈希槽。当客户端连接到 Redis 集群之后,会同时得到一份关于这个 **集群的配置信息**,当客户端具体对某一个 `key` 值进行操作时,会计算出它的一个 Hash 值,然后把结果对 `16384` **求余数**,这样每个 `key` 都会对应一个编号在 `0-16383` 之间的哈希槽,Redis 会根据节点数量 **大致均等** 的将哈希槽映射到不同的节点。
|
||||
|
||||
再结合集群的配置信息就能够知道这个 `key` 值应该存储在哪一个具体的 Redis 节点中,如果不属于自己管,那么就会使用一个特殊的 `MOVED` 命令来进行一个跳转,告诉客户端去连接这个节点以获取数据:
|
||||
|
||||
```bash
|
||||
GET x
|
||||
-MOVED 3999 127.0.0.1:6381
|
||||
```
|
||||
|
||||
`MOVED` 指令第一个参数 `3999` 是 `key` 对应的槽位编号,后面是目标节点地址,`MOVED` 命令前面有一个减号,表示这是一个错误的消息。客户端在收到 `MOVED` 指令后,就立即纠正本地的 **槽位映射表**,那么下一次再访问 `key` 时就能够到正确的地方去获取了。
|
||||
|
||||
#### 集群的主要作用
|
||||
|
||||
1. **数据分区:** 数据分区 *(或称数据分片)* 是集群最核心的功能。集群将数据分散到多个节点,**一方面** 突破了 Redis 单机内存大小的限制,**存储容量大大增加**;**另一方面** 每个主节点都可以对外提供读服务和写服务,**大大提高了集群的响应能力**。Redis 单机内存大小受限问题,在介绍持久化和主从复制时都有提及,例如,如果单机内存太大,`bgsave` 和 `bgrewriteaof` 的 `fork` 操作可能导致主进程阻塞,主从环境下主机切换时可能导致从节点长时间无法提供服务,全量复制阶段主节点的复制缓冲区可能溢出……
|
||||
2. **高可用:** 集群支持主从复制和主节点的 **自动故障转移** *(与哨兵类似)*,当任一节点发生故障时,集群仍然可以对外提供服务。
|
||||
|
||||
## 快速体验
|
||||
|
||||
#### 第一步:创建集群节点配置文件
|
||||
|
||||
首先我们找一个地方创建一个名为 `redis-cluster` 的目录:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/Desktop/redis-cluster
|
||||
```
|
||||
|
||||
然后按照上面的方法,创建六个配置文件,分别命名为:`redis_7000.conf`/`redis_7001.conf`.....`redis_7005.conf`,然后根据不同的端口号修改对应的端口值就好了:
|
||||
|
||||
```bash
|
||||
# 后台执行
|
||||
daemonize yes
|
||||
# 端口号
|
||||
port 7000
|
||||
# 为每一个集群节点指定一个 pid_file
|
||||
pidfile ~/Desktop/redis-cluster/redis_7000.pid
|
||||
# 启动集群模式
|
||||
cluster-enabled yes
|
||||
# 每一个集群节点都有一个配置文件,这个文件是不能手动编辑的。确保每一个集群节点的配置文件不通
|
||||
cluster-config-file nodes-7000.conf
|
||||
# 集群节点的超时时间,单位:ms,超时后集群会认为该节点失败
|
||||
cluster-node-timeout 5000
|
||||
# 最后将 appendonly 改成 yes(AOF 持久化)
|
||||
appendonly yes
|
||||
```
|
||||
|
||||
记得把对应上述配置文件中根端口对应的配置都修改掉 *(port/ pidfile/ cluster-config-file)*。
|
||||
|
||||
#### 第二步:分别启动 6 个 Redis 实例
|
||||
|
||||
```bash
|
||||
redis-server ~/Desktop/redis-cluster/redis_7000.conf
|
||||
redis-server ~/Desktop/redis-cluster/redis_7001.conf
|
||||
redis-server ~/Desktop/redis-cluster/redis_7002.conf
|
||||
redis-server ~/Desktop/redis-cluster/redis_7003.conf
|
||||
redis-server ~/Desktop/redis-cluster/redis_7004.conf
|
||||
redis-server ~/Desktop/redis-cluster/redis_7005.conf
|
||||
```
|
||||
|
||||
然后执行 `ps -ef | grep redis` 查看是否启动成功:
|
||||
|
||||

|
||||
|
||||
可以看到 `6` 个 Redis 节点都以集群的方式成功启动了,**但是现在每个节点还处于独立的状态**,也就是说它们每一个都各自成了一个集群,还没有互相联系起来,我们需要手动地把他们之间建立起联系。
|
||||
|
||||
#### 第三步:建立集群
|
||||
|
||||
执行下列命令:
|
||||
|
||||
```bash
|
||||
redis-cli --cluster create --cluster-replicas 1 127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 127.0.0.1:7004 127.0.0.1:7005
|
||||
```
|
||||
|
||||
- 这里稍微解释一下这个 `--replicas 1` 的意思是:我们希望为集群中的每个主节点创建一个从节点。
|
||||
|
||||
观察控制台输出:
|
||||
|
||||

|
||||
|
||||
看到 `[OK]` 的信息之后,就表示集群已经搭建成功了,可以看到,这里我们正确地创建了三主三从的集群。
|
||||
|
||||
#### 第四步:验证集群
|
||||
|
||||
我们先使用 `redic-cli` 任意连接一个节点:
|
||||
|
||||
```bash
|
||||
redis-cli -c -h 127.0.0.1 -p 7000
|
||||
127.0.0.1:7000>
|
||||
```
|
||||
|
||||
- `-c`表示集群模式;`-h` 指定 ip 地址;`-p` 指定端口。
|
||||
|
||||
然后随便 `set` 一些值观察控制台输入:
|
||||
|
||||
```bash
|
||||
127.0.0.1:7000> SET name wmyskxz
|
||||
-> Redirected to slot [5798] located at 127.0.0.1:7001
|
||||
OK
|
||||
127.0.0.1:7001>
|
||||
```
|
||||
|
||||
可以看到这里 Redis 自动帮我们进行了 `Redirected` 操作跳转到了 `7001` 这个实例上。
|
||||
|
||||
我们再使用 `cluster info` *(查看集群信息)* 和 `cluster nodes` *(查看节点列表)* 来分别看看:*(任意节点输入均可)*
|
||||
|
||||
```bash
|
||||
127.0.0.1:7001> CLUSTER INFO
|
||||
cluster_state:ok
|
||||
cluster_slots_assigned:16384
|
||||
cluster_slots_ok:16384
|
||||
cluster_slots_pfail:0
|
||||
cluster_slots_fail:0
|
||||
cluster_known_nodes:6
|
||||
cluster_size:3
|
||||
cluster_current_epoch:6
|
||||
cluster_my_epoch:2
|
||||
cluster_stats_messages_ping_sent:1365
|
||||
cluster_stats_messages_pong_sent:1358
|
||||
cluster_stats_messages_meet_sent:4
|
||||
cluster_stats_messages_sent:2727
|
||||
cluster_stats_messages_ping_received:1357
|
||||
cluster_stats_messages_pong_received:1369
|
||||
cluster_stats_messages_meet_received:1
|
||||
cluster_stats_messages_received:2727
|
||||
|
||||
127.0.0.1:7001> CLUSTER NODES
|
||||
56a04742f36c6e84968cae871cd438935081e86f 127.0.0.1:7003@17003 slave 4ec8c022e9d546c9b51deb9d85f6cf867bf73db6 0 1584428884000 4 connected
|
||||
4ec8c022e9d546c9b51deb9d85f6cf867bf73db6 127.0.0.1:7000@17000 master - 0 1584428884000 1 connected 0-5460
|
||||
e2539c4398b8258d3f9ffa714bd778da107cb2cd 127.0.0.1:7005@17005 slave a3406db9ae7144d17eb7df5bffe8b70bb5dd06b8 0 1584428885222 6 connected
|
||||
d31cd1f423ab1e1849cac01ae927e4b6950f55d9 127.0.0.1:7004@17004 slave 236cefaa9cdc295bc60a5bd1aed6a7152d4f384d 0 1584428884209 5 connected
|
||||
236cefaa9cdc295bc60a5bd1aed6a7152d4f384d 127.0.0.1:7001@17001 myself,master - 0 1584428882000 2 connected 5461-10922
|
||||
a3406db9ae7144d17eb7df5bffe8b70bb5dd06b8 127.0.0.1:7002@17002 master - 0 1584428884000 3 connected 10923-16383
|
||||
127.0.0.1:7001>
|
||||
```
|
||||
|
||||
## 数据分区方案简析
|
||||
|
||||
#### 方案一:哈希值 % 节点数
|
||||
|
||||
哈希取余分区思路非常简单:计算 `key` 的 hash 值,然后对节点数量进行取余,从而决定数据映射到哪个节点上。
|
||||
|
||||
不过该方案最大的问题是,**当新增或删减节点时**,节点数量发生变化,系统中所有的数据都需要 **重新计算映射关系**,引发大规模数据迁移。
|
||||
|
||||
#### 方案二:一致性哈希分区
|
||||
|
||||
一致性哈希算法将 **整个哈希值空间** 组织成一个虚拟的圆环,范围是 *[0 , 2<sup>32</sup>-1]*,对于每一个数据,根据 `key` 计算 hash 值,确数据在环上的位置,然后从此位置沿顺时针行走,找到的第一台服务器就是其应该映射到的服务器:
|
||||
|
||||

|
||||
|
||||
与哈希取余分区相比,一致性哈希分区将 **增减节点的影响限制在相邻节点**。以上图为例,如果在 `node1` 和 `node2` 之间增加 `node5`,则只有 `node2` 中的一部分数据会迁移到 `node5`;如果去掉 `node2`,则原 `node2` 中的数据只会迁移到 `node4` 中,只有 `node4` 会受影响。
|
||||
|
||||
一致性哈希分区的主要问题在于,当 **节点数量较少** 时,增加或删减节点,**对单个节点的影响可能很大**,造成数据的严重不平衡。还是以上图为例,如果去掉 `node2`,`node4` 中的数据由总数据的 `1/4` 左右变为 `1/2` 左右,与其他节点相比负载过高。
|
||||
|
||||
#### 方案三:带有虚拟节点的一致性哈希分区
|
||||
|
||||
该方案在 **一致性哈希分区的基础上**,引入了 **虚拟节点** 的概念。Redis 集群使用的便是该方案,其中的虚拟节点称为 **槽(slot)**。槽是介于数据和实际节点之间的虚拟概念,每个实际节点包含一定数量的槽,每个槽包含哈希值在一定范围内的数据。
|
||||
|
||||
在使用了槽的一致性哈希分区中,**槽是数据管理和迁移的基本单位**。槽 **解耦** 了 **数据和实际节点** 之间的关系,增加或删除节点对系统的影响很小。仍以上图为例,系统中有 `4` 个实际节点,假设为其分配 `16` 个槽(0-15);
|
||||
|
||||
- 槽 0-3 位于 node1;4-7 位于 node2;以此类推....
|
||||
|
||||
如果此时删除 `node2`,只需要将槽 4-7 重新分配即可,例如槽 4-5 分配给 `node1`,槽 6 分配给 `node3`,槽 7 分配给 `node4`;可以看出删除 `node2` 后,数据在其他节点的分布仍然较为均衡。
|
||||
|
||||
## 节点通信机制简析
|
||||
|
||||
集群的建立离不开节点之间的通信,例如我们上访在 *快速体验* 中刚启动六个集群节点之后通过 `redis-cli` 命令帮助我们搭建起来了集群,实际上背后每个集群之间的两两连接是通过了 `CLUSTER MEET <ip> <port>` 命令发送 `MEET` 消息完成的,下面我们展开详细说说。
|
||||
|
||||
#### 两个端口
|
||||
|
||||
在 **哨兵系统** 中,节点分为 **数据节点** 和 **哨兵节点**:前者存储数据,后者实现额外的控制功能。在 **集群** 中,没有数据节点与非数据节点之分:**所有的节点都存储数据,也都参与集群状态的维护**。为此,集群中的每个节点,都提供了两个 TCP 端口:
|
||||
|
||||
- **普通端口:** 即我们在前面指定的端口 *(7000等)*。普通端口主要用于为客户端提供服务 *(与单机节点类似)*;但在节点间数据迁移时也会使用。
|
||||
- **集群端口:** 端口号是普通端口 + 10000 *(10000是固定值,无法改变)*,如 `7000` 节点的集群端口为 `17000`。**集群端口只用于节点之间的通信**,如搭建集群、增减节点、故障转移等操作时节点间的通信;不要使用客户端连接集群接口。为了保证集群可以正常工作,在配置防火墙时,要同时开启普通端口和集群端口。
|
||||
|
||||
#### Gossip 协议
|
||||
|
||||
节点间通信,按照通信协议可以分为几种类型:单对单、广播、Gossip 协议等。重点是广播和 Gossip 的对比。
|
||||
|
||||
- 广播是指向集群内所有节点发送消息。**优点** 是集群的收敛速度快(集群收敛是指集群内所有节点获得的集群信息是一致的),**缺点** 是每条消息都要发送给所有节点,CPU、带宽等消耗较大。
|
||||
- Gossip 协议的特点是:在节点数量有限的网络中,**每个节点都 “随机” 的与部分节点通信** *(并不是真正的随机,而是根据特定的规则选择通信的节点)*,经过一番杂乱无章的通信,每个节点的状态很快会达到一致。Gossip 协议的 **优点** 有负载 *(比广播)* 低、去中心化、容错性高 *(因为通信有冗余)* 等;**缺点** 主要是集群的收敛速度慢。
|
||||
|
||||
#### 消息类型
|
||||
|
||||
集群中的节点采用 **固定频率(每秒10次)** 的 **定时任务** 进行通信相关的工作:判断是否需要发送消息及消息类型、确定接收节点、发送消息等。如果集群状态发生了变化,如增减节点、槽状态变更,通过节点间的通信,所有节点会很快得知整个集群的状态,使集群收敛。
|
||||
|
||||
节点间发送的消息主要分为 `5` 种:`meet 消息`、`ping 消息`、`pong 消息`、`fail 消息`、`publish 消息`。不同的消息类型,通信协议、发送的频率和时机、接收节点的选择等是不同的:
|
||||
|
||||
- **MEET 消息:** 在节点握手阶段,当节点收到客户端的 `CLUSTER MEET` 命令时,会向新加入的节点发送 `MEET` 消息,请求新节点加入到当前集群;新节点收到 MEET 消息后会回复一个 `PONG` 消息。
|
||||
- **PING 消息:** 集群里每个节点每秒钟会选择部分节点发送 `PING` 消息,接收者收到消息后会回复一个 `PONG` 消息。**PING 消息的内容是自身节点和部分其他节点的状态信息**,作用是彼此交换信息,以及检测节点是否在线。`PING` 消息使用 Gossip 协议发送,接收节点的选择兼顾了收敛速度和带宽成本,**具体规则如下**:(1)随机找 5 个节点,在其中选择最久没有通信的 1 个节点;(2)扫描节点列表,选择最近一次收到 `PONG` 消息时间大于 `cluster_node_timeout / 2` 的所有节点,防止这些节点长时间未更新。
|
||||
- **PONG消息:** `PONG` 消息封装了自身状态数据。可以分为两种:**第一种** 是在接到 `MEET/PING` 消息后回复的 `PONG` 消息;**第二种** 是指节点向集群广播 `PONG` 消息,这样其他节点可以获知该节点的最新信息,例如故障恢复后新的主节点会广播 `PONG` 消息。
|
||||
- **FAIL 消息:** 当一个主节点判断另一个主节点进入 `FAIL` 状态时,会向集群广播这一 `FAIL` 消息;接收节点会将这一 `FAIL` 消息保存起来,便于后续的判断。
|
||||
- **PUBLISH 消息:** 节点收到 `PUBLISH` 命令后,会先执行该命令,然后向集群广播这一消息,接收节点也会执行该 `PUBLISH` 命令。
|
||||
|
||||
## 数据结构简析
|
||||
|
||||
节点需要专门的数据结构来存储集群的状态。所谓集群的状态,是一个比较大的概念,包括:集群是否处于上线状态、集群中有哪些节点、节点是否可达、节点的主从状态、槽的分布……
|
||||
|
||||
节点为了存储集群状态而提供的数据结构中,最关键的是 `clusterNode` 和 `clusterState` 结构:前者记录了一个节点的状态,后者记录了集群作为一个整体的状态。
|
||||
|
||||
#### clusterNode 结构
|
||||
|
||||
`clusterNode` 结构保存了 **一个节点的当前状态**,包括创建时间、节点 id、ip 和端口号等。每个节点都会用一个 `clusterNode` 结构记录自己的状态,并为集群内所有其他节点都创建一个 `clusterNode` 结构来记录节点状态。
|
||||
|
||||
下面列举了 `clusterNode` 的部分字段,并说明了字段的含义和作用:
|
||||
|
||||
```c
|
||||
typedef struct clusterNode {
|
||||
//节点创建时间
|
||||
mstime_t ctime;
|
||||
//节点id
|
||||
char name[REDIS_CLUSTER_NAMELEN];
|
||||
//节点的ip和端口号
|
||||
char ip[REDIS_IP_STR_LEN];
|
||||
int port;
|
||||
//节点标识:整型,每个bit都代表了不同状态,如节点的主从状态、是否在线、是否在握手等
|
||||
int flags;
|
||||
//配置纪元:故障转移时起作用,类似于哨兵的配置纪元
|
||||
uint64_t configEpoch;
|
||||
//槽在该节点中的分布:占用16384/8个字节,16384个比特;每个比特对应一个槽:比特值为1,则该比特对应的槽在节点中;比特值为0,则该比特对应的槽不在节点中
|
||||
unsigned char slots[16384/8];
|
||||
//节点中槽的数量
|
||||
int numslots;
|
||||
…………
|
||||
} clusterNode;
|
||||
```
|
||||
|
||||
除了上述字段,`clusterNode` 还包含节点连接、主从复制、故障发现和转移需要的信息等。
|
||||
|
||||
#### clusterState 结构
|
||||
|
||||
`clusterState` 结构保存了在当前节点视角下,集群所处的状态。主要字段包括:
|
||||
|
||||
```c
|
||||
typedef struct clusterState {
|
||||
//自身节点
|
||||
clusterNode *myself;
|
||||
//配置纪元
|
||||
uint64_t currentEpoch;
|
||||
//集群状态:在线还是下线
|
||||
int state;
|
||||
//集群中至少包含一个槽的节点数量
|
||||
int size;
|
||||
//哈希表,节点名称->clusterNode节点指针
|
||||
dict *nodes;
|
||||
//槽分布信息:数组的每个元素都是一个指向clusterNode结构的指针;如果槽还没有分配给任何节点,则为NULL
|
||||
clusterNode *slots[16384];
|
||||
…………
|
||||
} clusterState;
|
||||
```
|
||||
|
||||
除此之外,`clusterState` 还包括故障转移、槽迁移等需要的信息。
|
||||
|
||||
> 更多关于集群内容请自行阅读《Redis 设计与实现》,其中有更多细节方面的介绍 - [http://redisbook.com/](http://redisbook.com/)
|
||||
|
||||
# 相关阅读
|
||||
|
||||
1. Redis(1)——5种基本数据结构 - [https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/](https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/)
|
||||
2. Redis(2)——跳跃表 - [https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/](https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/)
|
||||
3. Redis(3)——分布式锁深入探究 - [https://www.wmyskxz.com/2020/03/01/redis-3/](https://www.wmyskxz.com/2020/03/01/redis-3/)
|
||||
4. Reids(4)——神奇的HyperLoglog解决统计问题 - [https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/](https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/)
|
||||
5. Redis(5)——亿级数据过滤和布隆过滤器 - [https://www.wmyskxz.com/2020/03/11/redis-5-yi-ji-shu-ju-guo-lu-he-bu-long-guo-lu-qi/](https://www.wmyskxz.com/2020/03/11/redis-5-yi-ji-shu-ju-guo-lu-he-bu-long-guo-lu-qi/)
|
||||
6. Redis(6)——GeoHash查找附近的人[https://www.wmyskxz.com/2020/03/12/redis-6-geohash-cha-zhao-fu-jin-de-ren/](https://www.wmyskxz.com/2020/03/12/redis-6-geohash-cha-zhao-fu-jin-de-ren/)
|
||||
7. Redis(7)——持久化【一文了解】 - [https://www.wmyskxz.com/2020/03/13/redis-7-chi-jiu-hua-yi-wen-liao-jie/](https://www.wmyskxz.com/2020/03/13/redis-7-chi-jiu-hua-yi-wen-liao-jie/)
|
||||
8. Redis(8)——发布/订阅与Stream - [https://www.wmyskxz.com/2020/03/15/redis-8-fa-bu-ding-yue-yu-stream/](https://www.wmyskxz.com/2020/03/15/redis-8-fa-bu-ding-yue-yu-stream/)
|
||||
|
||||
# 参考资料
|
||||
|
||||
1. 《Redis 设计与实现》 | 黄健宏 著 - [http://redisbook.com/](http://redisbook.com/)
|
||||
2. 《Redis 深度历险》 | 钱文品 著 - [https://book.douban.com/subject/30386804/](https://book.douban.com/subject/30386804/)
|
||||
3. 深入学习Redis(3):主从复制 - [https://www.cnblogs.com/kismetv/p/9236731.html](https://www.cnblogs.com/kismetv/p/9236731.html)
|
||||
4. Redis 主从复制 原理与用法 - [https://blog.csdn.net/Stubborn_Cow/article/details/50442950](https://blog.csdn.net/Stubborn_Cow/article/details/50442950)
|
||||
5. 深入学习Redis(4):哨兵 - [https://www.cnblogs.com/kismetv/p/9609938.html](https://www.cnblogs.com/kismetv/p/9609938.html)
|
||||
6. Redis 5 之后版本的高可用集群搭建 - [https://www.jianshu.com/p/8045b92fafb2](https://www.jianshu.com/p/8045b92fafb2)
|
||||
|
||||
> - 本文已收录至我的 Github 程序员成长系列 **【More Than Java】,学习,不止 Code,欢迎 star:[https://github.com/wmyskxz/MoreThanJava](https://github.com/wmyskxz/MoreThanJava)**
|
||||
> - **个人公众号** :wmyskxz,**个人独立域名博客**:wmyskxz.com,坚持原创输出,下方扫码关注,2020,与您共同成长!
|
||||
|
||||

|
||||
|
||||
非常感谢各位人才能 **看到这里**,如果觉得本篇文章写得不错,觉得 **「我没有三颗心脏」有点东西** 的话,**求点赞,求关注,求分享,求留言!**
|
||||
|
||||
创作不易,各位的支持和认可,就是我创作的最大动力,我们下篇文章见!
|
@ -0,0 +1,469 @@
|
||||
> 授权转载自: https://github.com/wmyskxz/MoreThanJava#part3-redis
|
||||
|
||||

|
||||
|
||||
# 一、HyperLogLog 简介
|
||||
|
||||
**HyperLogLog** 是最早由 [Flajolet](http://algo.inria.fr/flajolet/Publications/FlFuGaMe07.pdf) 及其同事在 2007 年提出的一种 **估算基数的近似最优算法**。但跟原版论文不同的是,好像很多书包括 Redis 作者都把它称为一种 **新的数据结构(new datastruct)** *(算法实现确实需要一种特定的数据结构来实现)*。
|
||||
|
||||

|
||||
|
||||
## 关于基数统计
|
||||
|
||||
**基数统计(Cardinality Counting)** 通常是用来统计一个集合中不重复的元素个数。
|
||||
|
||||
**思考这样的一个场景:** 如果你负责开发维护一个大型的网站,有一天老板找产品经理要网站上每个网页的 **UV(独立访客,每个用户每天只记录一次)**,然后让你来开发这个统计模块,你会如何实现?
|
||||
|
||||

|
||||
|
||||
|
||||
如果统计 **PV(浏览量,用户没点一次记录一次)**,那非常好办,给每个页面配置一个独立的 Redis 计数器就可以了,把这个计数器的 key 后缀加上当天的日期。这样每来一个请求,就执行 `INCRBY` 指令一次,最终就可以统计出所有的 **PV** 数据了。
|
||||
|
||||
但是 **UV** 不同,它要去重,**同一个用户一天之内的多次访问请求只能计数一次**。这就要求了每一个网页请求都需要带上用户的 ID,无论是登录用户还是未登录的用户,都需要一个唯一 ID 来标识。
|
||||
|
||||
你也许马上就想到了一个 *简单的解决方案*:那就是 **为每一个页面设置一个独立的 set 集合** 来存储所有当天访问过此页面的用户 ID。但这样的 **问题** 就是:
|
||||
|
||||
1. **存储空间巨大:** 如果网站访问量一大,你需要用来存储的 set 集合就会非常大,如果页面再一多.. 为了一个去重功能耗费的资源就可以直接让你 **老板打死你**;
|
||||
2. **统计复杂:** 这么多 set 集合如果要聚合统计一下,又是一个复杂的事情;
|
||||
|
||||

|
||||
|
||||
|
||||
## 基数统计的常用方法
|
||||
|
||||
对于上述这样需要 **基数统计** 的事情,通常来说有两种比 set 集合更好的解决方案:
|
||||
|
||||
### 第一种:B 树
|
||||
|
||||
**B 树最大的优势就是插入和查找效率很高**,如果用 B 树存储要统计的数据,可以快速判断新来的数据是否存在,并快速将元素插入 B 树。要计算基础值,只需要计算 B 树的节点个数就行了。
|
||||
|
||||
不过将 B 树结构维护到内存中,能够解决统计和计算的问题,但是 **并没有节省内存**。
|
||||
|
||||
### 第二种:bitmap
|
||||
|
||||
**bitmap** 可以理解为通过一个 bit 数组来存储特定数据的一种数据结构,**每一个 bit 位都能独立包含信息**,bit 是数据的最小存储单位,因此能大量节省空间,也可以将整个 bit 数据一次性 load 到内存计算。如果定义一个很大的 bit 数组,基础统计中 **每一个元素对应到 bit 数组中的一位**,例如:
|
||||
|
||||

|
||||
|
||||
bitmap 还有一个明显的优势是 **可以轻松合并多个统计结果**,只需要对多个结果求异或就可以了,也可以大大减少存储内存。可以简单做一个计算,如果要统计 **1 亿** 个数据的基数值,**大约需要的内存**:`100_000_000/ 8/ 1024/ 1024 ≈ 12 M`,如果用 **32 bit** 的 int 代表 **每一个** 统计的数据,**大约需要内存**:`32 * 100_000_000/ 8/ 1024/ 1024 ≈ 381 M`
|
||||
|
||||
可以看到 bitmap 对于内存的节省显而易见,但仍然不够。统计一个对象的基数值就需要 `12 M`,如果统计 1 万个对象,就需要接近 `120 G`,对于大数据的场景仍然不适用。
|
||||
|
||||

|
||||
|
||||
## 概率算法
|
||||
|
||||
实际上目前还没有发现更好的在 **大数据场景** 中 **准确计算** 基数的高效算法,因此在不追求绝对精确的情况下,使用概率算法算是一个不错的解决方案。
|
||||
|
||||
概率算法 **不直接存储** 数据集合本身,通过一定的 **概率统计方法预估基数值**,这种方法可以大大节省内存,同时保证误差控制在一定范围内。目前用于基数计数的概率算法包括:
|
||||
|
||||
- **Linear Counting(LC)**:早期的基数估计算法,LC 在空间复杂度方面并不算优秀,实际上 LC 的空间复杂度与上文中简单 bitmap 方法是一样的(但是有个常数项级别的降低),都是 O(N<sub>max</sub>)
|
||||
- **LogLog Counting(LLC)**:LogLog Counting 相比于 LC 更加节省内存,空间复杂度只有 O(log<sub>2</sub>(log<sub>2</sub>(N<sub>max</sub>)))
|
||||
- **HyperLogLog Counting(HLL)**:HyperLogLog Counting 是基于 LLC 的优化和改进,在同样空间复杂度情况下,能够比 LLC 的基数估计误差更小
|
||||
|
||||
其中,**HyperLogLog** 的表现是惊人的,上面我们简单计算过用 **bitmap** 存储 **1 个亿** 统计数据大概需要 `12 M` 内存,而在 **HyperLoglog** 中,只需要不到 **1 K** 内存就能够做到!在 Redis 中实现的 **HyperLoglog** 也只需要 **12 K** 内存,在 **标准误差 0.81%** 的前提下,**能够统计 2<sup>64</sup> 个数据**!
|
||||
|
||||

|
||||
|
||||
**这是怎么做到的?!** 下面赶紧来了解一下!
|
||||
|
||||
# 二、HyperLogLog 原理
|
||||
|
||||
我们来思考一个抛硬币的游戏:你连续掷 n 次硬币,然后说出其中**连续掷为正面的最大次数,我来猜你一共抛了多少次**。
|
||||
|
||||
这很容易理解吧,例如:你说你这一次 *最多连续出现了 2 次* 正面,那么我就可以知道你这一次投掷的次数并不多,所以 *我可能会猜是 5* 或者是其他小一些的数字,但如果你说你这一次 *最多连续出现了 20 次* 正面,虽然我觉得不可能,但我仍然知道你花了特别多的时间,所以 *我说 GUN...*。
|
||||
|
||||

|
||||
|
||||
|
||||
这期间我可能会要求你重复实验,然后我得到了更多的数据之后就会估计得更准。**我们来把刚才的游戏换一种说法**:
|
||||
|
||||

|
||||
|
||||
这张图的意思是,我们给定一系列的随机整数,**记录下低位连续零位的最大长度 K**,即为图中的 `maxbit`,**通过这个 K 值我们就可以估算出随机数的数量 N**。
|
||||
|
||||
## 代码实验
|
||||
|
||||
我们可以简单编写代码做一个实验,来探究一下 `K` 和 `N` 之间的关系:
|
||||
|
||||
```java
|
||||
public class PfTest {
|
||||
|
||||
static class BitKeeper {
|
||||
|
||||
private int maxbit;
|
||||
|
||||
public void random() {
|
||||
long value = ThreadLocalRandom.current().nextLong(2L << 32);
|
||||
int bit = lowZeros(value);
|
||||
if (bit > this.maxbit) {
|
||||
this.maxbit = bit;
|
||||
}
|
||||
}
|
||||
|
||||
private int lowZeros(long value) {
|
||||
int i = 0;
|
||||
for (; i < 32; i++) {
|
||||
if (value >> i << i != value) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return i - 1;
|
||||
}
|
||||
}
|
||||
|
||||
static class Experiment {
|
||||
|
||||
private int n;
|
||||
private BitKeeper keeper;
|
||||
|
||||
public Experiment(int n) {
|
||||
this.n = n;
|
||||
this.keeper = new BitKeeper();
|
||||
}
|
||||
|
||||
public void work() {
|
||||
for (int i = 0; i < n; i++) {
|
||||
this.keeper.random();
|
||||
}
|
||||
}
|
||||
|
||||
public void debug() {
|
||||
System.out
|
||||
.printf("%d %.2f %d\n", this.n, Math.log(this.n) / Math.log(2), this.keeper.maxbit);
|
||||
}
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
for (int i = 1000; i < 100000; i += 100) {
|
||||
Experiment exp = new Experiment(i);
|
||||
exp.work();
|
||||
exp.debug();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
跟上图中的过程是一致的,话说为啥叫 `PfTest` 呢,包括 Redis 中的命令也一样带有一个 `PF` 前缀,还记得嘛,因为 **HyperLogLog** 的提出者上文提到过的,叫 `Philippe Flajolet`。
|
||||
|
||||
截取部分输出查看:
|
||||
|
||||
```java
|
||||
//n n/log2 maxbit
|
||||
34000 15.05 13
|
||||
35000 15.10 13
|
||||
36000 15.14 16
|
||||
37000 15.18 17
|
||||
38000 15.21 14
|
||||
39000 15.25 16
|
||||
40000 15.29 14
|
||||
41000 15.32 16
|
||||
42000 15.36 18
|
||||
```
|
||||
|
||||
会发现 `K` 和 `N` 的对数之间存在显著的线性相关性:**N 约等于 2<sup>k</sup>**
|
||||
|
||||
## 更近一步:分桶平均
|
||||
|
||||
**如果 `N` 介于 2<sup>k</sup> 和 2<sup>k+1</sup> 之间,用这种方式估计的值都等于 2<sup>k</sup>,这明显是不合理的**,所以我们可以使用多个 `BitKeeper` 进行加权估计,就可以得到一个比较准确的值了:
|
||||
|
||||
```java
|
||||
public class PfTest {
|
||||
|
||||
static class BitKeeper {
|
||||
// 无变化, 代码省略
|
||||
}
|
||||
|
||||
static class Experiment {
|
||||
|
||||
private int n;
|
||||
private int k;
|
||||
private BitKeeper[] keepers;
|
||||
|
||||
public Experiment(int n) {
|
||||
this(n, 1024);
|
||||
}
|
||||
|
||||
public Experiment(int n, int k) {
|
||||
this.n = n;
|
||||
this.k = k;
|
||||
this.keepers = new BitKeeper[k];
|
||||
for (int i = 0; i < k; i++) {
|
||||
this.keepers[i] = new BitKeeper();
|
||||
}
|
||||
}
|
||||
|
||||
public void work() {
|
||||
for (int i = 0; i < this.n; i++) {
|
||||
long m = ThreadLocalRandom.current().nextLong(1L << 32);
|
||||
BitKeeper keeper = keepers[(int) (((m & 0xfff0000) >> 16) % keepers.length)];
|
||||
keeper.random();
|
||||
}
|
||||
}
|
||||
|
||||
public double estimate() {
|
||||
double sumbitsInverse = 0.0;
|
||||
for (BitKeeper keeper : keepers) {
|
||||
sumbitsInverse += 1.0 / (float) keeper.maxbit;
|
||||
}
|
||||
double avgBits = (float) keepers.length / sumbitsInverse;
|
||||
return Math.pow(2, avgBits) * this.k;
|
||||
}
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
for (int i = 100000; i < 1000000; i += 100000) {
|
||||
Experiment exp = new Experiment(i);
|
||||
exp.work();
|
||||
double est = exp.estimate();
|
||||
System.out.printf("%d %.2f %.2f\n", i, est, Math.abs(est - i) / i);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这个过程有点 **类似于选秀节目里面的打分**,一堆专业评委打分,但是有一些评委因为自己特别喜欢所以给高了,一些评委又打低了,所以一般都要 **屏蔽最高分和最低分**,然后 **再计算平均值**,这样的出来的分数就差不多是公平公正的了。
|
||||
|
||||

|
||||
|
||||
上述代码就有 **1024** 个 "评委",并且在计算平均值的时候,采用了 **调和平均数**,也就是倒数的平均值,它能有效地平滑离群值的影响:
|
||||
|
||||
```java
|
||||
avg = (3 + 4 + 5 + 104) / 4 = 29
|
||||
avg = 4 / (1/3 + 1/4 + 1/5 + 1/104) = 5.044
|
||||
```
|
||||
|
||||
观察脚本的输出,误差率百分比控制在个位数:
|
||||
|
||||
```java
|
||||
100000 94274.94 0.06
|
||||
200000 194092.62 0.03
|
||||
300000 277329.92 0.08
|
||||
400000 373281.66 0.07
|
||||
500000 501551.60 0.00
|
||||
600000 596078.40 0.01
|
||||
700000 687265.72 0.02
|
||||
800000 828778.96 0.04
|
||||
900000 944683.53 0.05
|
||||
```
|
||||
|
||||
真实的 HyperLogLog 要比上面的示例代码更加复杂一些,也更加精确一些。上面这个算法在随机次数很少的情况下会出现除零错误,因为 `maxbit = 0` 是不可以求倒数的。
|
||||
|
||||
## 真实的 HyperLogLog
|
||||
|
||||
有一个神奇的网站,可以动态地让你观察到 HyperLogLog 的算法到底是怎么执行的:[http://content.research.neustar.biz/blog/hll.html](http://content.research.neustar.biz/blog/hll.html)
|
||||
|
||||

|
||||
|
||||
其中的一些概念这里稍微解释一下,您就可以自行去点击 `step` 来观察了:
|
||||
|
||||
- **m 表示分桶个数:** 从图中可以看到,这里分成了 64 个桶;
|
||||
- **蓝色的 bit 表示在桶中的位置:** 例如图中的 `101110` 实则表示二进制的 `46`,所以该元素被统计在中间大表格 `Register Values` 中标红的第 46 个桶之中;
|
||||
- **绿色的 bit 表示第一个 1 出现的位置**: 从图中可以看到标绿的 bit 中,从右往左数,第一位就是 1,所以在 `Register Values` 第 46 个桶中写入 1;
|
||||
- **红色 bit 表示绿色 bit 的值的累加:** 下一个出现在第 46 个桶的元素值会被累加;
|
||||
|
||||
|
||||
### 为什么要统计 Hash 值中第一个 1 出现的位置?
|
||||
|
||||
因为第一个 1 出现的位置可以同我们抛硬币的游戏中第一次抛到正面的抛掷次数对应起来,根据上面掷硬币实验的结论,记录每个数据的第一个出现的位置 `K`,就可以通过其中最大值 K<sub>max</sub> 来推导出数据集合中的基数:**N = 2<sup>K<sub>max</sub></sup>**
|
||||
|
||||
### PF 的内存占用为什么是 12 KB?
|
||||
|
||||
我们上面的算法中使用了 **1024** 个桶,网站演示也只有 **64** 个桶,不过在 Redis 的 HyperLogLog 实现中,用的是 **16384** 个桶,即:2<sup>14</sup>,也就是说,就像上面网站中间那个 `Register Values` 大表格有 **16384** 格。
|
||||
|
||||
**而Redis 最大能够统计的数据量是 2<sup>64</sup>**,即每个桶的 `maxbit` 需要 **6** 个 bit 来存储,最大可以表示 `maxbit = 63`,于是总共占用内存就是:**(2<sup>14</sup>) x 6 / 8** *(每个桶 6 bit,而这么多桶本身要占用 16384 bit,再除以 8 转换成 KB)*,算出来的结果就是 `12 KB`。
|
||||
|
||||
# 三、Redis 中的 HyperLogLog 实现
|
||||
|
||||
从上面我们算是对 **HyperLogLog** 的算法和思想有了一定的了解,并且知道了一个 **HyperLogLog** 实际占用的空间大约是 `12 KB`,但 Redis 对于内存的优化非常变态,当 **计数比较小** 的时候,大多数桶的计数值都是 **零**,这个时候 Redis 就会适当节约空间,转换成另外一种 **稀疏存储方式**,与之相对的,正常的存储模式叫做 **密集存储**,这种方式会恒定地占用 `12 KB`。
|
||||
|
||||
## 密集型存储结构
|
||||
|
||||
密集型的存储结构非常简单,就是 **16384 个 6 bit 连续串成** 的字符串位图:
|
||||
|
||||

|
||||
|
||||
我们都知道,一个字节是由 8 个 bit 组成的,这样 6 bit 排列的结构就会导致,有一些桶会 **跨越字节边界**,我们需要 **对这一个或者两个字节进行适当的移位拼接** 才可以得到具体的计数值。
|
||||
|
||||
假设桶的编号为 `index`,这个 6 bity 计数值的起始字节偏移用 `offset_bytes` 表示,它在这个字节的其实比特位置偏移用 `offset_bits` 表示,于是我们有:
|
||||
|
||||
```python
|
||||
offset_bytes = (index * 6) / 8
|
||||
offset_bits = (index * 6) % 8
|
||||
```
|
||||
|
||||
前者是商,后者是余数。比如 `bucket 2` 的字节偏移是 1,也就是第 2 个字节。它的位偏移是 4,也就是第 2 个字节的第 5 个位开始是 bucket 2 的计数值。需要注意的是 **字节位序是左边低位右边高位**,而通常我们使用的字节都是左边高位右边低位。
|
||||
|
||||
这里就涉及到两种情况,**如果 `offset_bits` 小于等于 2**,说明这 **6 bit 在一个字节的内部**,可以直接使用下面的表达式得到计数值 `val`:
|
||||
|
||||
```python
|
||||
val = buffer[offset_bytes] >> offset_bits # 向右移位
|
||||
```
|
||||
|
||||
**如果 `offset_bits` 大于 2**,那么就会涉及到 **跨越字节边界**,我们需要拼接两个字节的位片段:
|
||||
|
||||
```python
|
||||
# 低位值
|
||||
low_val = buffer[offset_bytes] >> offset_bits
|
||||
# 低位个数
|
||||
low_bits = 8 - offset_bits
|
||||
# 拼接,保留低6位
|
||||
val = (high_val << low_bits | low_val) & 0b111111
|
||||
```
|
||||
|
||||
不过下面 Redis 的源码要晦涩一点,看形式它似乎只考虑了跨越字节边界的情况。这是因为如果 6 bit 在单个字节内,上面代码中的 `high_val` 的值是零,所以这一份代码可以同时照顾单字节和双字节:
|
||||
|
||||
```c
|
||||
// 获取指定桶的计数值
|
||||
#define HLL_DENSE_GET_REGISTER(target,p,regnum) do { \
|
||||
uint8_t *_p = (uint8_t*) p; \
|
||||
unsigned long _byte = regnum*HLL_BITS/8; \
|
||||
unsigned long _fb = regnum*HLL_BITS&7; \ # %8 = &7
|
||||
unsigned long _fb8 = 8 - _fb; \
|
||||
unsigned long b0 = _p[_byte]; \
|
||||
unsigned long b1 = _p[_byte+1]; \
|
||||
target = ((b0 >> _fb) | (b1 << _fb8)) & HLL_REGISTER_MAX; \
|
||||
} while(0)
|
||||
|
||||
// 设置指定桶的计数值
|
||||
#define HLL_DENSE_SET_REGISTER(p,regnum,val) do { \
|
||||
uint8_t *_p = (uint8_t*) p; \
|
||||
unsigned long _byte = regnum*HLL_BITS/8; \
|
||||
unsigned long _fb = regnum*HLL_BITS&7; \
|
||||
unsigned long _fb8 = 8 - _fb; \
|
||||
unsigned long _v = val; \
|
||||
_p[_byte] &= ~(HLL_REGISTER_MAX << _fb); \
|
||||
_p[_byte] |= _v << _fb; \
|
||||
_p[_byte+1] &= ~(HLL_REGISTER_MAX >> _fb8); \
|
||||
_p[_byte+1] |= _v >> _fb8; \
|
||||
} while(0)
|
||||
```
|
||||
|
||||
## 稀疏存储结构
|
||||
|
||||
稀疏存储适用于很多计数值都是零的情况。下图表示了一般稀疏存储计数值的状态:
|
||||
|
||||

|
||||
|
||||
当 **多个连续桶的计数值都是零** 时,Redis 提供了几种不同的表达形式:
|
||||
|
||||
- `00xxxxxx`:前缀两个零表示接下来的 6bit 整数值加 1 就是零值计数器的数量,注意这里要加 1 是因为数量如果为零是没有意义的。比如 `00010101` 表示连续 `22` 个零值计数器。
|
||||
- `01xxxxxx yyyyyyyy`:6bit 最多只能表示连续 `64` 个零值计数器,这样扩展出的 14bit 可以表示最多连续 `16384` 个零值计数器。这意味着 HyperLogLog 数据结构中 `16384` 个桶的初始状态,所有的计数器都是零值,可以直接使用 2 个字节来表示。
|
||||
- `1vvvvvxx`:中间 5bit 表示计数值,尾部 2bit 表示连续几个桶。它的意思是连续 `(xx +1)` 个计数值都是 `(vvvvv + 1)`。比如 `10101011` 表示连续 `4` 个计数值都是 `11`。
|
||||
|
||||
注意 *上面第三种方式* 的计数值最大只能表示到 `32`,而 HyperLogLog 的密集存储单个计数值用 6bit 表示,最大可以表示到 `63`。**当稀疏存储的某个计数值需要调整到大于 `32` 时,Redis 就会立即转换 HyperLogLog 的存储结构,将稀疏存储转换成密集存储。**
|
||||
|
||||
## 对象头
|
||||
|
||||
HyperLogLog 除了需要存储 16384 个桶的计数值之外,它还有一些附加的字段需要存储,比如总计数缓存、存储类型。所以它使用了一个额外的对象头来表示:
|
||||
|
||||
```c
|
||||
struct hllhdr {
|
||||
char magic[4]; /* 魔术字符串"HYLL" */
|
||||
uint8_t encoding; /* 存储类型 HLL_DENSE or HLL_SPARSE. */
|
||||
uint8_t notused[3]; /* 保留三个字节未来可能会使用 */
|
||||
uint8_t card[8]; /* 总计数缓存 */
|
||||
uint8_t registers[]; /* 所有桶的计数器 */
|
||||
};
|
||||
```
|
||||
|
||||
所以 **HyperLogLog** 整体的内部结构就是 **HLL 对象头** 加上 **16384** 个桶的计数值位图。它在 Redis 的内部结构表现就是一个字符串位图。你可以把 **HyperLogLog 对象当成普通的字符串来进行处理:**
|
||||
|
||||
```console
|
||||
> PFADD codehole python java golang
|
||||
(integer) 1
|
||||
> GET codehole
|
||||
"HYLL\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x80C\x03\x84MK\x80P\xb8\x80^\xf3"
|
||||
```
|
||||
|
||||
但是 **不可以** 使用 **HyperLogLog** 指令来 **操纵普通的字符串**,**因为它需要检查对象头魔术字符串是否是 "HYLL"**。
|
||||
|
||||
# 四、HyperLogLog 的使用
|
||||
|
||||
**HyperLogLog** 提供了两个指令 `PFADD` 和 `PFCOUNT`,字面意思就是一个是增加,另一个是获取计数。`PFADD` 和 `set` 集合的 `SADD` 的用法是一样的,来一个用户 ID,就将用户 ID 塞进去就是,`PFCOUNT` 和 `SCARD` 的用法是一致的,直接获取计数值:
|
||||
|
||||
```console
|
||||
> PFADD codehole user1
|
||||
(interger) 1
|
||||
> PFCOUNT codehole
|
||||
(integer) 1
|
||||
> PFADD codehole user2
|
||||
(integer) 1
|
||||
> PFCOUNT codehole
|
||||
(integer) 2
|
||||
> PFADD codehole user3
|
||||
(integer) 1
|
||||
> PFCOUNT codehole
|
||||
(integer) 3
|
||||
> PFADD codehole user4 user 5
|
||||
(integer) 1
|
||||
> PFCOUNT codehole
|
||||
(integer) 5
|
||||
```
|
||||
|
||||
我们可以用 Java 编写一个脚本来试试 HyperLogLog 的准确性到底有多少:
|
||||
|
||||
```java
|
||||
public class JedisTest {
|
||||
public static void main(String[] args) {
|
||||
for (int i = 0; i < 100000; i++) {
|
||||
jedis.pfadd("codehole", "user" + i);
|
||||
}
|
||||
long total = jedis.pfcount("codehole");
|
||||
System.out.printf("%d %d\n", 100000, total);
|
||||
jedis.close();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
结果输出如下:
|
||||
|
||||
```java
|
||||
100000 99723
|
||||
```
|
||||
|
||||
发现 `10` 万条数据只差了 `277`,按照百分比误差率是 `0.277%`,对于巨量的 UV 需求来说,这个误差率真的不算高。
|
||||
|
||||
当然,除了上面的 `PFADD` 和 `PFCOUNT` 之外,还提供了第三个 `PFMEGER` 指令,用于将多个计数值累加在一起形成一个新的 `pf` 值:
|
||||
|
||||
```console
|
||||
> PFADD nosql "Redis" "MongoDB" "Memcached"
|
||||
(integer) 1
|
||||
|
||||
> PFADD RDBMS "MySQL" "MSSQL" "PostgreSQL"
|
||||
(integer) 1
|
||||
|
||||
> PFMERGE databases nosql RDBMS
|
||||
OK
|
||||
|
||||
> PFCOUNT databases
|
||||
(integer) 6
|
||||
```
|
||||
|
||||
# 相关阅读
|
||||
|
||||
1. Redis(1)——5种基本数据结构 - [https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/](https://www.wmyskxz.com/2020/02/28/redis-1-5-chong-ji-ben-shu-ju-jie-gou/)
|
||||
2. Redis(2)——跳跃表 - [https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/](https://www.wmyskxz.com/2020/02/29/redis-2-tiao-yue-biao/)
|
||||
3. Redis(3)——分布式锁深入探究 - [https://www.wmyskxz.com/2020/03/01/redis-3/](https://www.wmyskxz.com/2020/03/01/redis-3/)
|
||||
|
||||
# 扩展阅读
|
||||
|
||||
1. 【算法原文】HyperLogLog: the analysis of a near-optimal
|
||||
cardinality estimation algorithm - [http://algo.inria.fr/flajolet/Publications/FlFuGaMe07.pdf](http://algo.inria.fr/flajolet/Publications/FlFuGaMe07.pdf)
|
||||
|
||||
# 参考资料
|
||||
|
||||
1. 【Redis 作者博客】Redis new data structure: the HyperLogLog - [http://antirez.com/news/75](http://antirez.com/news/75)
|
||||
2. 神奇的HyperLogLog算法 - [http://www.rainybowe.com/blog/2017/07/13/%E7%A5%9E%E5%A5%87%E7%9A%84HyperLogLog%E7%AE%97%E6%B3%95/index.html](http://www.rainybowe.com/blog/2017/07/13/%E7%A5%9E%E5%A5%87%E7%9A%84HyperLogLog%E7%AE%97%E6%B3%95/index.html)
|
||||
3. 深度探索 Redis HyperLogLog 内部数据结构 - [https://zhuanlan.zhihu.com/p/43426875](https://zhuanlan.zhihu.com/p/43426875)
|
||||
4. 《Redis 深度历险》 - 钱文品/ 著
|
||||
|
||||
|
||||
> - 本文已收录至我的 Github 程序员成长系列 **【More Than Java】,学习,不止 Code,欢迎 star:[https://github.com/wmyskxz/MoreThanJava](https://github.com/wmyskxz/MoreThanJava)**
|
||||
> - **个人公众号** :wmyskxz,**个人独立域名博客**:wmyskxz.com,坚持原创输出,下方扫码关注,2020,与您共同成长!
|
||||
|
||||

|
||||
|
||||
非常感谢各位人才能 **看到这里**,如果觉得本篇文章写得不错,觉得 **「我没有三颗心脏」有点东西** 的话,**求点赞,求关注,求分享,求留言!**
|
||||
|
||||
创作不易,各位的支持和认可,就是我创作的最大动力,我们下篇文章见!
|
@ -49,7 +49,7 @@ B+树是有序的,在这种范围查询中,优势非常大,直接遍历比
|
||||
|
||||
**一张数据表有只能有一个主键,并且主键不能为null,不能重复。**
|
||||
|
||||
**在mysql的InnoDB的表中,当没有显示的指定表的主键时,InnoDB会自动先检查表中是否有唯一索引的字段,如果有,则选择改字段为默认的主键,否则InnoDB将会自动创建一个6Byte的自增主键。**
|
||||
**在mysql的InnoDB的表中,当没有显示的指定表的主键时,InnoDB会自动先检查表中是否有唯一索引的字段,如果有,则选择该字段为默认的主键,否则InnoDB将会自动创建一个6Byte的自增主键。**
|
||||
|
||||
### 二级索引(辅助索引)
|
||||
**二级索引又称为辅助索引,是因为二级索引的叶子节点存储的数据是主键。也就是说,通过二级索引,可以定位主键的位置。**
|
||||
|
@ -127,7 +127,7 @@ JRE 是 Java 运行时环境。它是运行已编译 Java 程序所需的所有
|
||||
|
||||
**总结:**
|
||||
|
||||
1. Oracle JDK 大概每 6 个月发一次主要版本,而 OpenJDK 版本大概每三个月发布一次。但这不是固定的,我觉得了解这个没啥用处。详情参见:https://blogs.oracle.com/java-platform-group/update-and-faq-on-the-java-se-release-cadence。
|
||||
1. Oracle JDK 大概每 6 个月发一次主要版本,而 OpenJDK 版本大概每三个月发布一次。但这不是固定的,我觉得了解这个没啥用处。详情参见:[https://blogs.oracle.com/java-platform-group/update-and-faq-on-the-java-se-release-cadence](https://blogs.oracle.com/java-platform-group/update-and-faq-on-the-java-se-release-cadence) 。
|
||||
2. OpenJDK 是一个参考模型并且是完全开源的,而 Oracle JDK 是 OpenJDK 的一个实现,并不是完全开源的;
|
||||
3. Oracle JDK 比 OpenJDK 更稳定。OpenJDK 和 Oracle JDK 的代码几乎相同,但 Oracle JDK 有更多的类和一些错误修复。因此,如果您想开发企业/商业软件,我建议您选择 Oracle JDK,因为它经过了彻底的测试和稳定。某些情况下,有些人提到在使用 OpenJDK 可能会遇到了许多应用程序崩溃的问题,但是,只需切换到 Oracle JDK 就可以解决问题;
|
||||
4. 在响应性和 JVM 性能方面,Oracle JDK 与 OpenJDK 相比提供了更好的性能;
|
||||
@ -251,6 +251,8 @@ String 中的对象是不可变的,也就可以理解为常量,线程安全
|
||||
- **装箱**:将基本类型用它们对应的引用类型包装起来;
|
||||
- **拆箱**:将包装类型转换为基本数据类型;
|
||||
|
||||
更多内容见:[深入剖析Java中的装箱和拆箱](https://www.cnblogs.com/dolphin0520/p/3780005.html)
|
||||
|
||||
## 14. 在一个静态方法内调用一个非静态成员为什么是非法的?
|
||||
|
||||
由于静态方法可以不通过对象进行调用,因此在静态方法里,不能调用其他非静态变量,也不可以访问非静态变量成员。
|
||||
@ -534,7 +536,7 @@ Java Io 流共涉及 40 多个类,这些类看上去很杂乱,但实际上
|
||||
### BIO,NIO,AIO 有什么区别?
|
||||
|
||||
- **BIO (Blocking I/O):** 同步阻塞 I/O 模式,数据的读取写入必须阻塞在一个线程内等待其完成。在活动连接数不是特别高(小于单机 1000)的情况下,这种模型是比较不错的,可以让每一个连接专注于自己的 I/O 并且编程模型简单,也不用过多考虑系统的过载、限流等问题。线程池本身就是一个天然的漏斗,可以缓冲一些系统处理不了的连接或请求。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。
|
||||
- **NIO (New I/O):** NIO 是一种同步非阻塞的 I/O 模型,在 Java 1.4 中引入了 NIO 框架,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它支持面向缓冲的,基于通道的 I/O 操作方法。 NIO 提供了与传统 BIO 模型中的 `Socket` 和 `ServerSocket` 相对应的 `SocketChannel` 和 `ServerSocketChannel` 两种不同的套接字通道实现,两种通道都支持阻塞和非阻塞两种模式。阻塞模式使用就像传统中的支持一样,比较简单,但是性能和可靠性都不好;非阻塞模式正好与之相反。对于低负载、低并发的应用程序,可以使用同步阻塞 I/O 来提升开发速率和更好的维护性;对于高负载、高并发的(网络)应用,应使用 NIO 的非阻塞模式来开发
|
||||
- **NIO (Non-blocking/New I/O):** NIO 是一种同步非阻塞的 I/O 模型,在 Java 1.4 中引入了 NIO 框架,对应 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它支持面向缓冲的,基于通道的 I/O 操作方法。 NIO 提供了与传统 BIO 模型中的 `Socket` 和 `ServerSocket` 相对应的 `SocketChannel` 和 `ServerSocketChannel` 两种不同的套接字通道实现,两种通道都支持阻塞和非阻塞两种模式。阻塞模式使用就像传统中的支持一样,比较简单,但是性能和可靠性都不好;非阻塞模式正好与之相反。对于低负载、低并发的应用程序,可以使用同步阻塞 I/O 来提升开发速率和更好的维护性;对于高负载、高并发的(网络)应用,应使用 NIO 的非阻塞模式来开发
|
||||
- **AIO (Asynchronous I/O):** AIO 也就是 NIO 2。在 Java 7 中引入了 NIO 的改进版 NIO 2,它是异步非阻塞的 IO 模型。异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。AIO 是异步 IO 的缩写,虽然 NIO 在网络操作中,提供了非阻塞的方法,但是 NIO 的 IO 行为还是同步的。对于 NIO 来说,我们的业务线程是在 IO 操作准备好时,得到通知,接着就由这个线程自行进行 IO 操作,IO 操作本身是同步的。查阅网上相关资料,我发现就目前来说 AIO 的应用还不是很广泛,Netty 之前也尝试使用过 AIO,不过又放弃了。
|
||||
|
||||
## 36. 常见关键字总结:static,final,this,super
|
||||
|
@ -327,11 +327,10 @@ CountDownLatch允许 count 个线程阻塞在一个地方,直至所有线程
|
||||
|
||||
CountDownLatch是共享锁的一种实现,它默认构造 AQS 的 state 值为 count。当线程使用countDown方法时,其实使用了`tryReleaseShared`方法以CAS的操作来减少state,直至state为0就代表所有的线程都调用了countDown方法。当调用await方法的时候,如果state不为0,就代表仍然有线程没有调用countDown方法,那么就把已经调用过countDown的线程都放入阻塞队列Park,并自旋CAS判断state == 0,直至最后一个线程调用了countDown,使得state == 0,于是阻塞的线程便判断成功,全部往下执行。
|
||||
|
||||
#### 4.1 CountDownLatch 的三种典型用法
|
||||
#### 4.1 CountDownLatch 的两种典型用法
|
||||
|
||||
1. 某一线程在开始运行前等待 n 个线程执行完毕。将 CountDownLatch 的计数器初始化为 n :`new CountDownLatch(n)`,每当一个任务线程执行完毕,就将计数器减 1 `countdownlatch.countDown()`,当计数器的值变为 0 时,在`CountDownLatch上 await()` 的线程就会被唤醒。一个典型应用场景就是启动一个服务时,主线程需要等待多个组件加载完毕,之后再继续执行。
|
||||
2. 实现多个线程开始执行任务的最大并行性。注意是并行性,不是并发,强调的是多个线程在某一时刻同时开始执行。类似于赛跑,将多个线程放到起点,等待发令枪响,然后同时开跑。做法是初始化一个共享的 `CountDownLatch` 对象,将其计数器初始化为 1 :`new CountDownLatch(1)`,多个线程在开始执行任务前首先 `coundownlatch.await()`,当主线程调用 countDown() 时,计数器变为 0,多个线程同时被唤醒。
|
||||
3. 死锁检测:一个非常方便的使用场景是,你可以使用 n 个线程访问共享资源,在每次测试阶段的线程数目是不同的,并尝试产生死锁。
|
||||
|
||||
#### 4.2 CountDownLatch 的使用示例
|
||||
|
||||
@ -382,7 +381,17 @@ public class CountDownLatchExample1 {
|
||||
|
||||
与 CountDownLatch 的第一次交互是主线程等待其他线程。主线程必须在启动其他线程后立即调用 `CountDownLatch.await()` 方法。这样主线程的操作就会在这个方法上阻塞,直到其他线程完成各自的任务。
|
||||
|
||||
其他 N 个线程必须引用闭锁对象,因为他们需要通知 CountDownLatch 对象,他们已经完成了各自的任务。这种通知机制是通过 CountDownLatch.countDown()方法来完成的;每调用一次这个方法,在构造函数中初始化的 count 值就减 1。所以当 N 个线程都调 用了这个方法,count 的值等于 0,然后主线程就能通过 await()方法,恢复执行自己的任务。
|
||||
其他 N 个线程必须引用闭锁对象,因为他们需要通知 `CountDownLatch` 对象,他们已经完成了各自的任务。这种通知机制是通过 `CountDownLatch.countDown()`方法来完成的;每调用一次这个方法,在构造函数中初始化的 count 值就减 1。所以当 N 个线程都调 用了这个方法,count 的值等于 0,然后主线程就能通过 `await()`方法,恢复执行自己的任务。
|
||||
|
||||
再插一嘴:`CountDownLatch` 的 `await()` 方法使用不当很容易产生死锁,比如我们上面代码中的 for 循环改为:
|
||||
|
||||
```java
|
||||
for (int i = 0; i < threadCount-1; i++) {
|
||||
.......
|
||||
}
|
||||
```
|
||||
|
||||
这样就导致 `count` 的值没办法等于 0,然后就会导致一直等待。
|
||||
|
||||
如果对CountDownLatch源码感兴趣的朋友,可以查看: [【JUC】JDK1.8源码分析之CountDownLatch(五)](https://www.cnblogs.com/leesf456/p/5406191.html)
|
||||
|
||||
@ -390,7 +399,7 @@ public class CountDownLatchExample1 {
|
||||
|
||||
CountDownLatch 是一次性的,计数器的值只能在构造方法中初始化一次,之后没有任何机制再次对其设置值,当 CountDownLatch 使用完毕后,它不能再次被使用。
|
||||
|
||||
#### 4.4 CountDownLatch 相常见面试题:
|
||||
#### 4.4 CountDownLatch 相常见面试题
|
||||
|
||||
解释一下 CountDownLatch 概念?
|
||||
|
||||
|
@ -56,15 +56,14 @@ Atomic 翻译成中文是原子的意思。在化学上,我们知道原子是
|
||||
**引用类型**
|
||||
|
||||
- AtomicReference:引用类型原子类
|
||||
- AtomicReferenceFieldUpdater:原子更新引用类型里的字段
|
||||
- AtomicMarkableReference :原子更新带有标记位的引用类型
|
||||
- AtomicMarkableReference:原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来,~~也可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。~~
|
||||
- AtomicStampedReference :原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
|
||||
|
||||
**对象的属性修改类型**
|
||||
|
||||
- AtomicIntegerFieldUpdater:原子更新整型字段的更新器
|
||||
- AtomicLongFieldUpdater:原子更新长整型字段的更新器
|
||||
- AtomicStampedReference :原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
|
||||
- AtomicMarkableReference:原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来,~~也可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。~~
|
||||
- AtomicReferenceFieldUpdater:原子更新引用类型里的字段
|
||||
|
||||
> 修正: **AtomicMarkableReference 不能解决ABA问题** **[issue#626](https://github.com/Snailclimb/JavaGuide/issues/626)**
|
||||
|
||||
@ -353,8 +352,8 @@ public class AtomicIntegerArrayTest {
|
||||
基本类型原子类只能更新一个变量,如果需要原子更新多个变量,需要使用 引用类型原子类。
|
||||
|
||||
- AtomicReference:引用类型原子类
|
||||
- AtomicStampedReference:原子更新引用类型里的字段原子类
|
||||
- AtomicMarkableReference :原子更新带有标记位的引用类型
|
||||
- AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
|
||||
- AtomicMarkableReference :原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来,~~也可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。~~
|
||||
|
||||
上面三个类提供的方法几乎相同,所以我们这里以 AtomicReference 为例子来介绍。
|
||||
|
||||
@ -535,7 +534,7 @@ currentValue=true, currentMark=true, wCasResult=true
|
||||
|
||||
- AtomicIntegerFieldUpdater:原子更新整形字段的更新器
|
||||
- AtomicLongFieldUpdater:原子更新长整形字段的更新器
|
||||
- AtomicStampedReference :原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
|
||||
- AtomicReferenceFieldUpdater :原子更新引用类型里的字段的更新器
|
||||
|
||||
要想原子地更新对象的属性需要两步。第一步,因为对象的属性修改类型原子类都是抽象类,所以每次使用都必须使用静态方法 newUpdater()创建一个更新器,并且需要设置想要更新的类和属性。第二步,更新的对象属性必须使用 public volatile 修饰符。
|
||||
|
||||
@ -592,6 +591,10 @@ class User {
|
||||
23
|
||||
```
|
||||
|
||||
## Reference
|
||||
|
||||
- 《Java并发编程的艺术》
|
||||
|
||||
## 公众号
|
||||
|
||||
如果大家想要实时关注我更新的文章以及分享的干货的话,可以关注我的公众号。
|
||||
@ -600,4 +603,4 @@ class User {
|
||||
|
||||
**Java工程师必备学习资源:** 一些Java工程师常用学习资源公众号后台回复关键字 **“1”** 即可免费无套路获取。
|
||||
|
||||

|
||||

|
@ -186,10 +186,15 @@ synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团
|
||||
|
||||

|
||||
|
||||
### 2.2 并发编程的三个重要特性
|
||||
|
||||
### 2.2. 说说 synchronized 关键字和 volatile 关键字的区别
|
||||
1. **原子性** : 一个的操作或者多次操作,要么所有的操作全部都得到执行并且不会收到任何因素的干扰而中断,要么所有的操作都执行,要么都不执行。`synchronized ` 可以保证代码片段的原子性。
|
||||
2. **可见性** :当一个变量对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。`volatile` 关键字可以保证共享变量的可见性。
|
||||
3. **有序性** :代码在执行的过程中的先后顺序,Java 在编译器以及运行期间的优化,代码的执行顺序未必就是编写代码时候的顺序。`volatile` 关键字可以禁止指令进行重排序优化。
|
||||
|
||||
synchronized关键字和volatile关键字比较
|
||||
### 2.3. 说说 synchronized 关键字和 volatile 关键字的区别
|
||||
|
||||
`synchronized` 关键字和 `volatile` 关键字是两个互补的存在,而不是对立的存在:
|
||||
|
||||
- **volatile关键字**是线程同步的**轻量级实现**,所以**volatile性能肯定比synchronized关键字要好**。但是**volatile关键字只能用于变量而synchronized关键字可以修饰方法以及代码块**。synchronized关键字在JavaSE1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁以及其它各种优化之后执行效率有了显著提升,**实际开发中使用 synchronized 关键字的场景还是更多一些**。
|
||||
- **多线程访问volatile关键字不会发生阻塞,而synchronized关键字可能会发生阻塞**
|
||||
|
@ -165,7 +165,7 @@ Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的
|
||||
|
||||
### 8.1. 认识线程死锁
|
||||
|
||||
多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
|
||||
线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
|
||||
|
||||
如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
|
||||
|
||||
@ -232,23 +232,12 @@ Thread[线程 2,5,main]waiting get resource1
|
||||
|
||||
### 8.2. 如何避免线程死锁?
|
||||
|
||||
我们只要破坏产生死锁的四个条件中的其中一个就可以了。
|
||||
我上面说了产生死锁的四个必要条件,为了避免死锁,我们只要破坏产生死锁的四个条件中的其中一个就可以了。现在我们来挨个分析一下:
|
||||
|
||||
**破坏互斥条件**
|
||||
|
||||
这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。
|
||||
|
||||
**破坏请求与保持条件**
|
||||
|
||||
一次性申请所有的资源。
|
||||
|
||||
**破坏不剥夺条件**
|
||||
|
||||
占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
|
||||
|
||||
**破坏循环等待条件**
|
||||
|
||||
靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
|
||||
1. **破坏互斥条件** :这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。
|
||||
2. **破坏请求与保持条件** :一次性申请所有的资源。
|
||||
3. **破坏不剥夺条件** :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
|
||||
4. **破坏循环等待条件** :靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
|
||||
|
||||
我们对线程 2 的代码修改成下面这样就不会产生死锁了。
|
||||
|
||||
|
@ -392,7 +392,7 @@ pool-1-thread-1 End. Time = Tue Nov 12 20:59:54 CST 2019
|
||||
|
||||
没搞懂的话,也没关系,可以看看我的分析:
|
||||
|
||||
> 我们在代码中模拟了 10 个任务,我们配置的核心线程数为 5 、等待队列容量为 100 ,所以每次只可能存在 5 个任务同时执行,剩下的 5 个任务会被放到等待队列中去。当前的 5 个任务之行完成后,才会之行剩下的 5 个任务。
|
||||
> 我们在代码中模拟了 10 个任务,我们配置的核心线程数为 5 、等待队列容量为 100 ,所以每次只可能存在 5 个任务同时执行,剩下的 5 个任务会被放到等待队列中去。当前的 5 个任务执行完成后,才会执行剩下的 5 个任务。
|
||||
|
||||
### 4.3 几个常见的对比
|
||||
|
||||
|
@ -1,407 +0,0 @@
|
||||
# Java 并发基础知识
|
||||
|
||||
Java 并发的基础知识,可能会在笔试中遇到,技术面试中也可能以并发知识环节提问的第一个问题出现。比如面试官可能会问你:“谈谈自己对于进程和线程的理解,两者的区别是什么?”
|
||||
|
||||
**本节思维导图:**
|
||||
|
||||
## 一 进程和线程
|
||||
|
||||
进程和线程的对比这一知识点由于过于基础,所以在面试中很少碰到,但是极有可能会在笔试题中碰到。
|
||||
|
||||
常见的提问形式是这样的:**“什么是线程和进程?,请简要描述线程与进程的关系、区别及优缺点? ”**。
|
||||
|
||||
### 1.1. 何为进程?
|
||||
|
||||
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
|
||||
|
||||
在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。
|
||||
|
||||
如下图所示,在 windows 中通过查看任务管理器的方式,我们就可以清楚看到 window 当前运行的进程(.exe 文件的运行)。
|
||||
|
||||

|
||||
|
||||
### 1.2 何为线程?
|
||||
|
||||
线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的**堆**和**方法区**资源,但每个线程有自己的**程序计数器**、**虚拟机栈**和**本地方法栈**,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
|
||||
|
||||
Java 程序天生就是多线程程序,我们可以通过 JMX 来看一下一个普通的 Java 程序有哪些线程,代码如下。
|
||||
|
||||
```java
|
||||
public class MultiThread {
|
||||
public static void main(String[] args) {
|
||||
// 获取 Java 线程管理 MXBean
|
||||
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
|
||||
// 不需要获取同步的 monitor 和 synchronizer 信息,仅获取线程和线程堆栈信息
|
||||
ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
|
||||
// 遍历线程信息,仅打印线程 ID 和线程名称信息
|
||||
for (ThreadInfo threadInfo : threadInfos) {
|
||||
System.out.println("[" + threadInfo.getThreadId() + "] " + threadInfo.getThreadName());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
上述程序输出如下(输出内容可能不同,不用太纠结下面每个线程的作用,只用知道 main 线程执行 main 方法即可):
|
||||
|
||||
```
|
||||
[5] Attach Listener //添加事件
|
||||
[4] Signal Dispatcher // 分发处理给 JVM 信号的线程
|
||||
[3] Finalizer //调用对象 finalize 方法的线程
|
||||
[2] Reference Handler //清除 reference 线程
|
||||
[1] main //main 线程,程序入口
|
||||
```
|
||||
|
||||
从上面的输出内容可以看出:**一个 Java 程序的运行是 main 线程和多个其他线程同时运行**。
|
||||
|
||||
### 1.3 从 JVM 角度说进程和线程之间的关系(重要)
|
||||
|
||||
#### 1.3.1 图解进程和线程的关系
|
||||
|
||||
下图是 Java 内存区域,通过下图我们从 JVM 的角度来说一下线程和进程之间的关系。如果你对 Java 内存区域 (运行时数据区) 这部分知识不太了解的话可以阅读一下我的这篇文章:[《可能是把 Java 内存区域讲的最清楚的一篇文章》](<https://github.com/Snailclimb/JavaGuide/blob/3965c02cc0f294b0bd3580df4868d5e396959e2e/Java%E7%9B%B8%E5%85%B3/%E5%8F%AF%E8%83%BD%E6%98%AF%E6%8A%8AJava%E5%86%85%E5%AD%98%E5%8C%BA%E5%9F%9F%E8%AE%B2%E7%9A%84%E6%9C%80%E6%B8%85%E6%A5%9A%E7%9A%84%E4%B8%80%E7%AF%87%E6%96%87%E7%AB%A0.md>)
|
||||
|
||||
<div align="center">
|
||||
<img src="https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-3Java%E8%BF%90%E8%A1%8C%E6%97%B6%E6%95%B0%E6%8D%AE%E5%8C%BA%E5%9F%9FJDK1.8.png" width="600px"/>
|
||||
</div>
|
||||
|
||||
|
||||
从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的**堆**和**方法区 (JDK1.8 之后的元空间)**资源,但是每个线程有自己的**程序计数器**、**虚拟机栈** 和 **本地方法栈**。
|
||||
|
||||
下面来思考这样一个问题:为什么**程序计数器**、**虚拟机栈**和**本地方法栈**是线程私有的呢?为什么堆和方法区是线程共享的呢?
|
||||
|
||||
#### 1.3.2 程序计数器为什么是私有的?
|
||||
|
||||
程序计数器主要有下面两个作用:
|
||||
|
||||
1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
|
||||
2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
|
||||
|
||||
需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。
|
||||
|
||||
所以,程序计数器私有主要是为了**线程切换后能恢复到正确的执行位置**。
|
||||
|
||||
#### 1.3.3 虚拟机栈和本地方法栈为什么是私有的?
|
||||
|
||||
- **虚拟机栈:**每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
|
||||
- **本地方法栈:**和虚拟机栈所发挥的作用非常相似,区别是: **虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。** 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
|
||||
|
||||
所以,为了**保证线程中的局部变量不被别的线程访问到**,虚拟机栈和本地方法栈是线程私有的。
|
||||
|
||||
#### 1.3.4 一句话简单了解堆和方法区
|
||||
|
||||
堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
|
||||
|
||||
## 二 多线程并发编程
|
||||
|
||||
### 2.1 并发与并行概念解读
|
||||
|
||||
- **并发:** 同一时间段,多个任务都在执行 (单位时间内不一定同时执行);
|
||||
- **并行:**单位时间内,多个任务同时执行。
|
||||
|
||||
### 2.2 为什么要使用多线程?
|
||||
|
||||
先从总体上来说:
|
||||
|
||||
- **从计算机底层来说:**线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
|
||||
- **从当代互联网发展趋势来说:**现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
|
||||
|
||||
再深入到计算机底层来探讨:
|
||||
|
||||
- **单核时代:** 在单核时代多线程主要是为了提高 CPU 和 IO 设备的综合利用率。举个例子:当只有一个线程的时候会导致 CPU 计算时,IO 设备空闲;进行 IO 操作时,CPU 空闲。我们可以简单地说这两者的利用率目前都是 50%左右。但是当有两个线程的时候就不一样了,当一个线程执行 CPU 计算时,另外一个线程可以进行 IO 操作,这样两个的利用率就可以在理想情况下达到 100%了。
|
||||
- **多核时代:** 多核时代多线程主要是为了提高 CPU 利用率。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,CPU 只会一个 CPU 核心被利用到,而创建多个线程就可以让多个 CPU 核心被利用到,这样就提高了 CPU 的利用率。
|
||||
|
||||
### 2.3 使用多线程可能带来的问题
|
||||
|
||||
并发编程的目的就是为了能提高程序的执行效率提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、上下文切换、死锁还有受限于硬件和软件的资源闲置问题。
|
||||
|
||||
## 三 线程的创建与运行
|
||||
|
||||
前两种实际上很少使用,一般都是用线程池的方式比较多一点。
|
||||
|
||||
### 3.1 继承 Thread 类的方式
|
||||
|
||||
|
||||
```java
|
||||
public class MyThread extends Thread {
|
||||
@Override
|
||||
public void run() {
|
||||
super.run();
|
||||
System.out.println("MyThread");
|
||||
}
|
||||
}
|
||||
```
|
||||
Run.java
|
||||
|
||||
```java
|
||||
public class Run {
|
||||
|
||||
public static void main(String[] args) {
|
||||
MyThread mythread = new MyThread();
|
||||
mythread.start();
|
||||
System.out.println("运行结束");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
运行结果:
|
||||

|
||||
|
||||
从上面的运行结果可以看出:线程是一个子任务,CPU 以不确定的方式,或者说是以随机的时间来调用线程中的 run 方法。
|
||||
|
||||
### 3.2 实现 Runnable 接口的方式
|
||||
|
||||
推荐实现 Runnable 接口方式开发多线程,因为 Java 单继承但是可以实现多个接口。
|
||||
|
||||
MyRunnable.java
|
||||
|
||||
```java
|
||||
public class MyRunnable implements Runnable {
|
||||
@Override
|
||||
public void run() {
|
||||
System.out.println("MyRunnable");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run.java
|
||||
|
||||
```java
|
||||
public class Run {
|
||||
|
||||
public static void main(String[] args) {
|
||||
Runnable runnable=new MyRunnable();
|
||||
Thread thread=new Thread(runnable);
|
||||
thread.start();
|
||||
System.out.println("运行结束!");
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
运行结果:
|
||||

|
||||
|
||||
### 3.3 使用线程池的方式
|
||||
|
||||
使用线程池的方式也是最推荐的一种方式,另外,《阿里巴巴 Java 开发手册》在第一章第六节并发处理这一部分也强调到“线程资源必须通过线程池提供,不允许在应用中自行显示创建线程”。这里就不给大家演示代码了,线程池这一节会详细介绍到这部分内容。
|
||||
|
||||
## 四 线程的生命周期和状态
|
||||
|
||||
Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态(图源《Java 并发编程艺术》4.1.4 节)。
|
||||
|
||||

|
||||
|
||||
线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。Java 线程状态变迁如下图所示(图源《Java 并发编程艺术》4.1.4 节):
|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
由上图可以看出:线程创建之后它将处于 **NEW(新建)** 状态,调用 `start()` 方法后开始运行,线程这时候处于 **READY(可运行)** 状态。可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 **RUNNING(运行)** 状态。
|
||||
|
||||
> 操作系统隐藏 Java 虚拟机(JVM)中的 RUNNABLE 和 RUNNING 状态,它只能看到 RUNNABLE 状态(图源:[HowToDoInJava](https://howtodoinjava.com/):[Java Thread Life Cycle and Thread States](https://howtodoinjava.com/java/multi-threading/java-thread-life-cycle-and-thread-states/)),所以 Java 系统一般将这两个状态统称为 **RUNNABLE(运行中)** 状态 。
|
||||
|
||||

|
||||
|
||||
当线程执行 `wait()`方法之后,线程进入 **WAITING(等待)**状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态,而 **TIME_WAITING(超时等待)** 状态相当于在等待状态的基础上增加了超时限制,比如通过 `sleep(long millis)`方法或 `wait(long millis)`方法可以将 Java 线程置于 TIMED WAITING 状态。当超时时间到达后 Java 线程将会返回到 RUNNABLE 状态。当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到 **BLOCKED(阻塞)** 状态。线程在执行 Runnable 的` run() `方法之后将会进入到 **TERMINATED(终止)** 状态。
|
||||
|
||||
## 五 线程优先级
|
||||
|
||||
**理论上**来说系统会根据优先级来决定首先使哪个线程进入运行状态。当 CPU 比较闲的时候,设置线程优先级几乎不会有任何作用,而且很多操作系统压根不会不会理会你设置的线程优先级,所以不要让业务过度依赖于线程的优先级。
|
||||
|
||||
另外,**线程优先级具有继承特性**比如 A 线程启动 B 线程,则 B 线程的优先级和 A 是一样的。**线程优先级还具有随机性** 也就是说线程优先级高的不一定每一次都先执行完。
|
||||
|
||||
Thread 类中包含的成员变量代表了线程的某些优先级。如**Thread.MIN_PRIORITY(常数 1)**,**Thread.NORM_PRIORITY(常数 5)**,**Thread.MAX_PRIORITY(常数 10)**。其中每个线程的优先级都在**1** 到**10** 之间,在默认情况下优先级都是**Thread.NORM_PRIORITY(常数 5)**。
|
||||
|
||||
**一般情况下,不会对线程设定优先级别,更不会让某些业务严重地依赖线程的优先级别,比如权重,借助优先级设定某个任务的权重,这种方式是不可取的,一般定义线程的时候使用默认的优先级就好了。**
|
||||
|
||||
**相关方法:**
|
||||
|
||||
```java
|
||||
public final void setPriority(int newPriority) //为线程设定优先级
|
||||
public final int getPriority() //获取线程的优先级
|
||||
```
|
||||
**设置线程优先级方法源码:**
|
||||
|
||||
```java
|
||||
public final void setPriority(int newPriority) {
|
||||
ThreadGroup g;
|
||||
checkAccess();
|
||||
//线程游戏优先级不能小于 1 也不能大于 10,否则会抛出异常
|
||||
if (newPriority > MAX_PRIORITY || newPriority < MIN_PRIORITY) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
//如果指定的线程优先级大于该线程所在线程组的最大优先级,那么该线程的优先级将设为线程组的最大优先级
|
||||
if((g = getThreadGroup()) != null) {
|
||||
if (newPriority > g.getMaxPriority()) {
|
||||
newPriority = g.getMaxPriority();
|
||||
}
|
||||
setPriority0(priority = newPriority);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 六 守护线程和用户线程
|
||||
|
||||
**守护线程和用户线程简介:**
|
||||
|
||||
- **用户 (User) 线程:**运行在前台,执行具体的任务,如程序的主线程、连接网络的子线程等都是用户线程
|
||||
- **守护 (Daemon) 线程:**运行在后台,为其他前台线程服务.也可以说守护线程是 JVM 中非守护线程的 **“佣人”**。一旦所有用户线程都结束运行,守护线程会随 JVM 一起结束工作.
|
||||
|
||||
main 函数所在的线程就是一个用户线程啊,main 函数启动的同时在 JVM 内部同时还启动了好多守护线程,比如垃圾回收线程。
|
||||
|
||||
**那么守护线程和用户线程有什么区别呢?**
|
||||
|
||||
比较明显的区别之一是用户线程结束,JVM 退出,不管这个时候有没有守护线程运行。而守护线程不会影响 JVM 的退出。
|
||||
|
||||
**注意事项:**
|
||||
|
||||
1. `setDaemon(true)`必须在`start()`方法前执行,否则会抛出 `IllegalThreadStateException` 异常
|
||||
2. 在守护线程中产生的新线程也是守护线程
|
||||
3. 不是所有的任务都可以分配给守护线程来执行,比如读写操作或者计算逻辑
|
||||
4. 守护 (Daemon) 线程中不能依靠 finally 块的内容来确保执行关闭或清理资源的逻辑。因为我们上面也说过了一旦所有用户线程都结束运行,守护线程会随 JVM 一起结束工作,所以守护 (Daemon) 线程中的 finally 语句块可能无法被执行。
|
||||
|
||||
## 七 上下文切换
|
||||
|
||||
多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。
|
||||
|
||||
概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换会这个任务时,可以再加载这个任务的状态。**任务从保存到再加载的过程就是一次上下文切换**。
|
||||
|
||||
上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。
|
||||
|
||||
Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。
|
||||
|
||||
## 八 线程死锁
|
||||
|
||||
### 认识线程死锁
|
||||
|
||||
多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
|
||||
|
||||
如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
|
||||
|
||||

|
||||
|
||||
下面通过一个例子来说明线程死锁,代码模拟了上图的死锁的情况 (代码来源于《并发编程之美》):
|
||||
|
||||
```java
|
||||
public class DeadLockDemo {
|
||||
private static Object resource1 = new Object();//资源 1
|
||||
private static Object resource2 = new Object();//资源 2
|
||||
|
||||
public static void main(String[] args) {
|
||||
new Thread(() -> {
|
||||
synchronized (resource1) {
|
||||
System.out.println(Thread.currentThread() + "get resource1");
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
System.out.println(Thread.currentThread() + "waiting get resource2");
|
||||
synchronized (resource2) {
|
||||
System.out.println(Thread.currentThread() + "get resource2");
|
||||
}
|
||||
}
|
||||
}, "线程 1").start();
|
||||
|
||||
new Thread(() -> {
|
||||
synchronized (resource2) {
|
||||
System.out.println(Thread.currentThread() + "get resource2");
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
System.out.println(Thread.currentThread() + "waiting get resource1");
|
||||
synchronized (resource1) {
|
||||
System.out.println(Thread.currentThread() + "get resource1");
|
||||
}
|
||||
}
|
||||
}, "线程 2").start();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Output
|
||||
|
||||
```
|
||||
Thread[线程 1,5,main]get resource1
|
||||
Thread[线程 2,5,main]get resource2
|
||||
Thread[线程 1,5,main]waiting get resource2
|
||||
Thread[线程 2,5,main]waiting get resource1
|
||||
```
|
||||
|
||||
线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过` Thread.sleep(1000);`让线程 A 休眠 1s 为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。上面的例子符合产生死锁的四个必要条件。
|
||||
|
||||
学过操作系统的朋友都知道产生死锁必须具备以下四个条件:
|
||||
|
||||
1. 互斥条件:该资源任意一个时刻只由一个线程占用。
|
||||
1. 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
|
||||
1. 不剥夺条件:线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
|
||||
1. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
|
||||
|
||||
### 如何预防线程死锁?
|
||||
|
||||
我们只要破坏产生死锁的四个条件中的其中一个就可以了。
|
||||
|
||||
**破坏互斥条件**
|
||||
|
||||
这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。
|
||||
|
||||
**破坏请求与保持条件**
|
||||
|
||||
一次性申请所有的资源。
|
||||
|
||||
**破坏不剥夺条件**
|
||||
|
||||
占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
|
||||
|
||||
**破坏循环等待条件**
|
||||
|
||||
靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
|
||||
|
||||
我们对线程 2 的代码修改成下面这样就不会产生死锁了。
|
||||
|
||||
```java
|
||||
new Thread(() -> {
|
||||
synchronized (resource1) {
|
||||
System.out.println(Thread.currentThread() + "get resource1");
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
System.out.println(Thread.currentThread() + "waiting get resource2");
|
||||
synchronized (resource2) {
|
||||
System.out.println(Thread.currentThread() + "get resource2");
|
||||
}
|
||||
}
|
||||
}, "线程 2").start();
|
||||
```
|
||||
|
||||
Output
|
||||
|
||||
```
|
||||
Thread[线程 1,5,main]get resource1
|
||||
Thread[线程 1,5,main]waiting get resource2
|
||||
Thread[线程 1,5,main]get resource2
|
||||
Thread[线程 2,5,main]get resource1
|
||||
Thread[线程 2,5,main]waiting get resource2
|
||||
Thread[线程 2,5,main]get resource2
|
||||
|
||||
Process finished with exit code 0
|
||||
```
|
||||
|
||||
我们分析一下上面的代码为什么避免了死锁的发生?
|
||||
|
||||
线程 1 首先获得到 resource1 的监视器锁,这时候线程 2 就获取不到了。然后线程 1 再去获取 resource2 的监视器锁,可以获取到。然后线程 1 释放了对 resource1、resource2 的监视器锁的占用,线程 2 获取到就可以执行了。这样就破坏了破坏循环等待条件,因此避免了死锁。
|
||||
|
||||
## 参考
|
||||
|
||||
- 《Java 并发编程之美》
|
||||
|
||||
- 《Java 并发编程的艺术》
|
||||
|
||||
- https://howtodoinjava.com/java/multi-threading/java-thread-life-cycle-and-thread-states/
|
||||
|
||||
|
@ -47,7 +47,7 @@
|
||||
|
||||
- **2. 底层数据结构:** `Arraylist` 底层使用的是 **`Object` 数组**;`LinkedList` 底层使用的是 **双向链表** 数据结构(JDK1.6之前为循环链表,JDK1.7取消了循环。注意双向链表和双向循环链表的区别,下面有介绍到!)
|
||||
|
||||
- **3. 插入和删除是否受元素位置的影响:** ① **`ArrayList` 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。** 比如:执行`add(E e) `方法的时候, `ArrayList` 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是O(1)。但是如果要在指定位置 i 插入和删除元素的话(`add(int index, E element) `)时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。 ② **`LinkedList` 采用链表存储,所以对于`add(E e)`方法的插入,删除元素时间复杂度不受元素位置的影响,近似 O(1),如果是要在指定位置`i`插入和删除元素的话(`(add(int index, E element)`) 时间复杂度近似为`o(n))`因为需要先移动到指定位置再插入。**
|
||||
- **3. 插入和删除是否受元素位置的影响:** ① **`ArrayList` 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。** 比如:执行`add(E e) `方法的时候, `ArrayList` 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是O(1)。但是如果要在指定位置 i 插入和删除元素的话(`add(int index, E element) `)时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移一位的操作。 ② **`LinkedList` 采用链表存储,所以对于`add(E e)`方法的插入,删除元素时间复杂度不受元素位置的影响,近似 O(1),如果是要在指定位置`i`插入和删除元素的话(`(add(int index, E element)`) 时间复杂度近似为`o(n))`因为需要先移动到指定位置再插入。**
|
||||
|
||||
- **4. 是否支持快速随机访问:** `LinkedList` 不支持高效的随机元素访问,而 `ArrayList` 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于`get(int index) `方法)。
|
||||
|
||||
@ -62,7 +62,7 @@ public interface RandomAccess {
|
||||
|
||||
查看源码我们发现实际上 `RandomAccess` 接口中什么都没有定义。所以,在我看来 `RandomAccess` 接口不过是一个标识罢了。标识什么? 标识实现这个接口的类具有随机访问功能。
|
||||
|
||||
在 `binarySearch(`)方法中,它要判断传入的list 是否 `RamdomAccess` 的实例,如果是,调用`indexedBinarySearch()`方法,如果不是,那么调用`iteratorBinarySearch()`方法
|
||||
在 `binarySearch()` 方法中,它要判断传入的list 是否 `RamdomAccess` 的实例,如果是,调用`indexedBinarySearch()`方法,如果不是,那么调用`iteratorBinarySearch()`方法
|
||||
|
||||
```java
|
||||
public static <T>
|
||||
@ -74,12 +74,12 @@ public interface RandomAccess {
|
||||
}
|
||||
```
|
||||
|
||||
`ArrayList` 实现了 `RandomAccess` 接口, 而 `LinkedList` 没有实现。为什么呢?我觉得还是和底层数据结构有关!`ArrayList` 底层是数组,而 `LinkedList` 底层是链表。数组天然支持随机访问,时间复杂度为 O(1),所以称为快速随机访问。链表需要遍历到特定位置才能访问特定位置的元素,时间复杂度为 O(n),所以不支持快速随机访问。,`ArrayList` 实现了 `RandomAccess` 接口,就表明了他具有快速随机访问功能。 `RandomAccess` 接口只是标识,并不是说 `ArrayList` 实现 `RandomAccess` 接口才具有快速随机访问功能的!
|
||||
`ArrayList` 实现了 `RandomAccess` 接口, 而 `LinkedList` 没有实现。为什么呢?我觉得还是和底层数据结构有关!`ArrayList` 底层是数组,而 `LinkedList` 底层是链表。数组天然支持随机访问,时间复杂度为 O(1),所以称为快速随机访问。链表需要遍历到特定位置才能访问特定位置的元素,时间复杂度为 O(n),所以不支持快速随机访问。,`ArrayList` 实现了 `RandomAccess` 接口,就表明了他具有快速随机访问功能。 `RandomAccess` 接口只是标识,并不是说 `ArrayList` 实现 `RandomAccess` 接口才具有快速随机访问功能的!
|
||||
|
||||
**下面再总结一下 list 的遍历方式选择:**
|
||||
|
||||
- 实现了 `RandomAccess` 接口的list,优先选择普通 for 循环 ,其次 foreach,
|
||||
- 未实现 `RandomAccess`接口的list,优先选择iterator遍历(foreach遍历底层也是通过iterator实现的,),大size的数据,千万不要使用普通for循环
|
||||
- 未实现 `RandomAccess`接口的list,优先选择iterator遍历(foreach遍历底层也是通过iterator实现的),大size的数据,千万不要使用普通for循环
|
||||
|
||||
### 补充内容:双向链表和双向循环链表
|
||||
|
||||
@ -107,7 +107,7 @@ public interface RandomAccess {
|
||||
2. **效率:** 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰,不要在代码中使用它;
|
||||
3. **对Null key 和Null value的支持:** HashMap 中,null 可以作为键,这样的键只有一个,可以有一个或多个键所对应的值为 null。。但是在 HashTable 中 put 进的键值只要有一个 null,直接抛出 NullPointerException。
|
||||
4. **初始容量大小和每次扩充容量大小的不同 :** ①创建时如果不指定容量初始值,Hashtable 默认的初始大小为11,之后每次扩充,容量变为原来的2n+1。HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来的2倍。②创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为2的幂次方大小(HashMap 中的`tableSizeFor()`方法保证,下面给出了源代码)。也就是说 HashMap 总是使用2的幂作为哈希表的大小,后面会介绍到为什么是2的幂次方。
|
||||
5. **底层数据结构:** JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。
|
||||
5. **底层数据结构:** JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。
|
||||
|
||||
**HashMap 中带有初始容量的构造函数:**
|
||||
|
||||
@ -154,14 +154,14 @@ public interface RandomAccess {
|
||||
| :------------------------------: | :----------------------------------------------------------: |
|
||||
| 实现了Map接口 | 实现Set接口 |
|
||||
| 存储键值对 | 仅存储对象 |
|
||||
| 调用 `put()`向map中添加元素 | 调用 `add()`方法向Set中添加元素 |
|
||||
| 调用 `put()`向map中添加元素 | 调用 `add()`方法向Set中添加元素 |
|
||||
| HashMap使用键(Key)计算Hashcode | HashSet使用成员对象来计算hashcode值,对于两个对象来说hashcode可能相同,所以equals()方法用来判断对象的相等性, |
|
||||
|
||||
## HashSet如何检查重复
|
||||
|
||||
当你把对象加入`HashSet`时,HashSet会先计算对象的`hashcode`值来判断对象加入的位置,同时也会与其他加入的对象的hashcode值作比较,如果没有相符的hashcode,HashSet会假设对象没有重复出现。但是如果发现有相同hashcode值的对象,这时会调用`equals()`方法来检查hashcode相等的对象是否真的相同。如果两者相同,HashSet就不会让加入操作成功。(摘自我的Java启蒙书《Head fist java》第二版)
|
||||
当你把对象加入`HashSet`时,HashSet会先计算对象的`hashcode`值来判断对象加入的位置,同时也会与其他加入的对象的hashcode值作比较,如果没有相符的hashcode,HashSet会假设对象没有重复出现。但是如果发现有相同hashcode值的对象,这时会调用`equals()`方法来检查hashcode相等的对象是否真的相同。如果两者相同,HashSet就不会让加入操作成功。(摘自我的Java启蒙书《Head fist java》第二版)
|
||||
|
||||
**hashCode()与equals()的相关规定:**
|
||||
**hashCode()与equals()的相关规定:**
|
||||
|
||||
1. 如果两个对象相等,则hashcode一定也是相同的
|
||||
2. 两个对象相等,对两个equals方法返回true
|
||||
@ -218,7 +218,7 @@ static int hash(int h) {
|
||||
|
||||
### JDK1.8之后
|
||||
|
||||
相比于之前的版本, JDK1.8之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。
|
||||
相比于之前的版本, JDK1.8之后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
|
||||
|
||||

|
||||
|
||||
@ -238,7 +238,7 @@ static int hash(int h) {
|
||||
|
||||
## HashMap 多线程操作导致死循环问题
|
||||
|
||||
主要原因在于 并发下的Rehash 会造成元素之间会形成一个循环链表。不过,jdk 1.8 后解决了这个问题,但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在其他问题比如数据丢失。并发环境下推荐使用 ConcurrentHashMap 。
|
||||
主要原因在于并发下的Rehash 会造成元素之间会形成一个循环链表。不过,jdk 1.8 后解决了这个问题,但是还是不建议在多线程下使用 HashMap,因为多线程下使用 HashMap 还是会存在其他问题比如数据丢失。并发环境下推荐使用 ConcurrentHashMap 。
|
||||
|
||||
详情请查看:<https://coolshell.cn/articles/9606.html>
|
||||
|
||||
@ -436,7 +436,7 @@ Output:
|
||||
|
||||
### Map
|
||||
|
||||
- **HashMap:** JDK1.8之前HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间
|
||||
- **HashMap:** JDK1.8之前HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间
|
||||
- **LinkedHashMap:** LinkedHashMap 继承自 HashMap,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。详细可以查看:[《LinkedHashMap 源码详细分析(JDK1.8)》](https://www.imooc.com/article/22931)
|
||||
- **Hashtable:** 数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的
|
||||
- **TreeMap:** 红黑树(自平衡的排序二叉树)
|
||||
|
416
docs/java/java-naming-conventions.md
Normal file
416
docs/java/java-naming-conventions.md
Normal file
@ -0,0 +1,416 @@
|
||||
> 原文链接:https://www.cnblogs.com/liqiangchn/p/12000361.html
|
||||
|
||||
简洁清爽的代码风格应该是大多数工程师所期待的。在工作中笔者常常因为起名字而纠结,夸张点可以说是编程5分钟,命名两小时!究竟为什么命名成为了工作中的拦路虎。
|
||||
|
||||
每个公司都有不同的标准,目的是为了保持统一,减少沟通成本,提升团队研发效能。所以本文中是笔者结合阿里巴巴开发规范,以及工作中的见闻针对Java领域相关命名进行整理和总结,仅供参考。
|
||||
|
||||
## 一,Java中的命名规范
|
||||
|
||||
好的命名能体现出代码的特征,含义或者是用途,让阅读者可以根据名称的含义快速厘清程序的脉络。不同语言中采用的命名形式大相径庭,Java中常用到的命名形式共有三种,既首字母大写的UpperCamelCase,首字母小写的lowerCamelCase以及全部大写的并用下划线分割单词的UPPER_CAMEL_UNSER_SCORE。通常约定,**类一般采用大驼峰命名,方法和局部变量使用小驼峰命名,而大写下划线命名通常是常量和枚举中使用。**
|
||||
|
||||
| 类型 | 约束 | 例 |
|
||||
| :----: | :----------------------------------------------------------: | :--------------------------------------------: |
|
||||
| 项目名 | 全部小写,多个单词用中划线分隔‘-’ | spring-cloud |
|
||||
| 包名 | 全部小写 | com.alibaba.fastjson |
|
||||
| 类名 | 单词首字母大写 | Feature, ParserConfig,DefaultFieldDeserializer |
|
||||
| 变量名 | 首字母小写,多个单词组成时,除首个单词,其他单词首字母都要大写 | password, userName |
|
||||
| 常量名 | 全部大写,多个单词,用'_'分隔 | CACHE_EXPIRED_TIME |
|
||||
| 方法 | 同变量 | read(), readObject(), getById() |
|
||||
|
||||
## 二,包命名
|
||||
|
||||
**包名**统一使用**小写**,**点分隔符**之间有且仅有一个自然语义的英文单词或者多个单词自然连接到一块(如 springframework,deepspace不需要使用任何分割)。包名统一使用单数形式,如果类命有复数含义,则可以使用复数形式。
|
||||
|
||||
包名的构成可以分为以下几四部分【前缀】 【发起者名】【项目名】【模块名】。常见的前缀可以分为以下几种:
|
||||
|
||||
| 前缀名 | 例 | 含义 |
|
||||
| :-------------: | :----------------------------: | :----------------------------------------------------------: |
|
||||
| indi(或onem ) | indi.发起者名.项目名.模块名.…… | 个体项目,指个人发起,但非自己独自完成的项目,可公开或私有项目,copyright主要属于发起者。 |
|
||||
| pers | pers.个人名.项目名.模块名.…… | 个人项目,指个人发起,独自完成,可分享的项目,copyright主要属于个人 |
|
||||
| priv | priv.个人名.项目名.模块名.…… | 私有项目,指个人发起,独自完成,非公开的私人使用的项目,copyright属于个人。 |
|
||||
| team | team.团队名.项目名.模块名.…… | 团队项目,指由团队发起,并由该团队开发的项目,copyright属于该团队所有 |
|
||||
| 顶级域名 | com.公司名.项目名.模块名.…… | 公司项目,copyright由项目发起的公司所有 |
|
||||
|
||||
## 三,类命名
|
||||
|
||||
**类名使用大驼峰命名形式**,类命通常时**名词或名词短语**,接口名除了用名词和名词短语以外,还可以使用形容词或形容词短语,如Cloneable,Callable等,表示实现该接口的类有某种功能或能力。对于测试类则以它要测试的类开头,以Test结尾,如HashMapTest。
|
||||
|
||||
对于一些特殊特有名词缩写也可以使用全大写命名,比如XMLHttpRequest,不过笔者认为缩写三个字母以内都大写,超过三个字母则按照要给单词算。这个没有标准如阿里巴巴中fastjson用JSONObject作为类命,而google则使用JsonObjectRequest命名,对于这种特殊的缩写,原则是统一就好。
|
||||
|
||||
| 属性 | 约束 | 例 |
|
||||
| -------------- | ----------------------------------------- | ------------------------------------------------------------ |
|
||||
| 抽象类 | Abstract 或者 Base 开头 | BaseUserService |
|
||||
| 枚举类 | Enum 作为后缀 | GenderEnum |
|
||||
| 工具类 | Utils作为后缀 | StringUtils |
|
||||
| 异常类 | Exception结尾 | RuntimeException |
|
||||
| 接口实现类 | 接口名+ Impl | UserServiceImpl |
|
||||
| 领域模型相关 | /DO/DTO/VO/DAO | 正例:UserDAO 反例: UserDo, UserDao |
|
||||
| 设计模式相关类 | Builder,Factory等 | 当使用到设计模式时,需要使用对应的设计模式作为后缀,如ThreadFactory |
|
||||
| 处理特定功能的 | Handler,Predicate, Validator | 表示处理器,校验器,断言,这些类工厂还有配套的方法名如handle,predicate,validate |
|
||||
| 测试类 | Test结尾 | UserServiceTest, 表示用来测试UserService类的 |
|
||||
| MVC分层 | Controller,Service,ServiceImpl,DAO后缀 | UserManageController,UserManageDAO |
|
||||
|
||||
## 四,方法
|
||||
|
||||
**方法命名采用小驼峰的形式**,首字小写,往后的每个单词首字母都要大写。 和类名不同的是,方法命名一般为**动词或动词短语**,与参数或参数名共同组成动宾短语,即动词 + 名词。一个好的函数名一般能通过名字直接获知该函数实现什么样的功能。
|
||||
|
||||
### 4.1 返回真伪值的方法
|
||||
|
||||
注:Prefix-前缀,Suffix-后缀,Alone-单独使用
|
||||
|
||||
| 位置 | 单词 | 意义 | 例 |
|
||||
| ------ | ------ | ------------------------------------------------------------ | ------------- |
|
||||
| Prefix | is | 对象是否符合期待的状态 | isValid |
|
||||
| Prefix | can | 对象**能否执行**所期待的动作 | canRemove |
|
||||
| Prefix | should | 调用方执行某个命令或方法是**好还是不好**,**应不应该**,或者说**推荐还是不推荐** | shouldMigrate |
|
||||
| Prefix | has | 对象**是否持有**所期待的数据和属性 | hasObservers |
|
||||
| Prefix | needs | 调用方**是否需要**执行某个命令或方法 | needsMigrate |
|
||||
|
||||
### 4.2 用来检查的方法
|
||||
|
||||
| 单词 | 意义 | 例 |
|
||||
| -------- | ---------------------------------------------------- | -------------- |
|
||||
| ensure | 检查是否为期待的状态,不是则抛出异常或返回error code | ensureCapacity |
|
||||
| validate | 检查是否为正确的状态,不是则抛出异常或返回error code | validateInputs |
|
||||
|
||||
### 4.3 按需求才执行的方法
|
||||
|
||||
| 位置 | 单词 | 意义 | 例 |
|
||||
| ------ | --------- | ----------------------------------------- | ---------------------- |
|
||||
| Suffix | IfNeeded | 需要的时候执行,不需要的时候什么都不做 | drawIfNeeded |
|
||||
| Prefix | might | 同上 | mightCreate |
|
||||
| Prefix | try | 尝试执行,失败时抛出异常或是返回errorcode | tryCreate |
|
||||
| Suffix | OrDefault | 尝试执行,失败时返回默认值 | getOrDefault |
|
||||
| Suffix | OrElse | 尝试执行、失败时返回实际参数中指定的值 | getOrElse |
|
||||
| Prefix | force | 强制尝试执行。error抛出异常或是返回值 | forceCreate, forceStop |
|
||||
|
||||
### 4.4 异步相关方法
|
||||
|
||||
| 位置 | 单词 | 意义 | 例 |
|
||||
| --------------- | ------------ | -------------------------------------------- | --------------------- |
|
||||
| Prefix | blocking | 线程阻塞方法 | blockingGetUser |
|
||||
| Suffix | InBackground | 执行在后台的线程 | doInBackground |
|
||||
| Suffix | Async | 异步方法 | sendAsync |
|
||||
| Suffix | Sync | 对应已有异步方法的同步方法 | sendSync |
|
||||
| Prefix or Alone | schedule | Job和Task放入队列 | schedule, scheduleJob |
|
||||
| Prefix or Alone | post | 同上 | postJob |
|
||||
| Prefix or Alone | execute | 执行异步方法(注:我一般拿这个做同步方法名) | execute, executeTask |
|
||||
| Prefix or Alone | start | 同上 | start, startJob |
|
||||
| Prefix or Alone | cancel | 停止异步方法 | cancel, cancelJob |
|
||||
| Prefix or Alone | stop | 同上 | stop, stopJob |
|
||||
|
||||
### 4.5 回调方法
|
||||
|
||||
| 位置 | 单词 | 意义 | 例 |
|
||||
| ------ | ------ | -------------------------- | ------------ |
|
||||
| Prefix | on | 事件发生时执行 | onCompleted |
|
||||
| Prefix | before | 事件发生前执行 | beforeUpdate |
|
||||
| Prefix | pre | 同上 | preUpdate |
|
||||
| Prefix | will | 同上 | willUpdate |
|
||||
| Prefix | after | 事件发生后执行 | afterUpdate |
|
||||
| Prefix | post | 同上 | postUpdate |
|
||||
| Prefix | did | 同上 | didUpdate |
|
||||
| Prefix | should | 确认事件是否可以发生时执行 | shouldUpdate |
|
||||
|
||||
### 4.6 操作对象生命周期的方法
|
||||
|
||||
| 单词 | 意义 | 例 |
|
||||
| ---------- | ------------------------------ | --------------- |
|
||||
| initialize | 初始化。也可作为延迟初始化使用 | initialize |
|
||||
| pause | 暂停 | onPause ,pause |
|
||||
| stop | 停止 | onStop,stop |
|
||||
| abandon | 销毁的替代 | abandon |
|
||||
| destroy | 同上 | destroy |
|
||||
| dispose | 同上 | dispose |
|
||||
|
||||
### 4.7 与集合操作相关的方法
|
||||
|
||||
| 单词 | 意义 | 例 |
|
||||
| -------- | ---------------------------- | ---------- |
|
||||
| contains | 是否持有与指定对象相同的对象 | contains |
|
||||
| add | 添加 | addJob |
|
||||
| append | 添加 | appendJob |
|
||||
| insert | 插入到下标n | insertJob |
|
||||
| put | 添加与key对应的元素 | putJob |
|
||||
| remove | 移除元素 | removeJob |
|
||||
| enqueue | 添加到队列的最末位 | enqueueJob |
|
||||
| dequeue | 从队列中头部取出并移除 | dequeueJob |
|
||||
| push | 添加到栈头 | pushJob |
|
||||
| pop | 从栈头取出并移除 | popJob |
|
||||
| peek | 从栈头取出但不移除 | peekJob |
|
||||
| find | 寻找符合条件的某物 | findById |
|
||||
|
||||
### 4.8 与数据相关的方法
|
||||
|
||||
| 单词 | 意义 | 例 |
|
||||
| ------ | -------------------------------------- | ------------- |
|
||||
| create | 新创建 | createAccount |
|
||||
| new | 新创建 | newAccount |
|
||||
| from | 从既有的某物新建,或是从其他的数据新建 | fromConfig |
|
||||
| to | 转换 | toString |
|
||||
| update | 更新既有某物 | updateAccount |
|
||||
| load | 读取 | loadAccount |
|
||||
| fetch | 远程读取 | fetchAccount |
|
||||
| delete | 删除 | deleteAccount |
|
||||
| remove | 删除 | removeAccount |
|
||||
| save | 保存 | saveAccount |
|
||||
| store | 保存 | storeAccount |
|
||||
| commit | 保存 | commitChange |
|
||||
| apply | 保存或应用 | applyChange |
|
||||
| clear | 清除数据或是恢复到初始状态 | clearAll |
|
||||
| reset | 清除数据或是恢复到初始状态 | resetAll |
|
||||
|
||||
### 4.9 成对出现的动词
|
||||
|
||||
| 单词 | 意义 |
|
||||
| -------------- | ----------------- |
|
||||
| get获取 | set 设置 |
|
||||
| add 增加 | remove 删除 |
|
||||
| create 创建 | destory 移除 |
|
||||
| start 启动 | stop 停止 |
|
||||
| open 打开 | close 关闭 |
|
||||
| read 读取 | write 写入 |
|
||||
| load 载入 | save 保存 |
|
||||
| create 创建 | destroy 销毁 |
|
||||
| begin 开始 | end 结束 |
|
||||
| backup 备份 | restore 恢复 |
|
||||
| import 导入 | export 导出 |
|
||||
| split 分割 | merge 合并 |
|
||||
| inject 注入 | extract 提取 |
|
||||
| attach 附着 | detach 脱离 |
|
||||
| bind 绑定 | separate 分离 |
|
||||
| view 查看 | browse 浏览 |
|
||||
| edit 编辑 | modify 修改 |
|
||||
| select 选取 | mark 标记 |
|
||||
| copy 复制 | paste 粘贴 |
|
||||
| undo 撤销 | redo 重做 |
|
||||
| insert 插入 | delete 移除 |
|
||||
| add 加入 | append 添加 |
|
||||
| clean 清理 | clear 清除 |
|
||||
| index 索引 | sort 排序 |
|
||||
| find 查找 | search 搜索 |
|
||||
| increase 增加 | decrease 减少 |
|
||||
| play 播放 | pause 暂停 |
|
||||
| launch 启动 | run 运行 |
|
||||
| compile 编译 | execute 执行 |
|
||||
| debug 调试 | trace 跟踪 |
|
||||
| observe 观察 | listen 监听 |
|
||||
| build 构建 | publish 发布 |
|
||||
| input 输入 | output 输出 |
|
||||
| encode 编码 | decode 解码 |
|
||||
| encrypt 加密 | decrypt 解密 |
|
||||
| compress 压缩 | decompress 解压缩 |
|
||||
| pack 打包 | unpack 解包 |
|
||||
| parse 解析 | emit 生成 |
|
||||
| connect 连接 | disconnect 断开 |
|
||||
| send 发送 | receive 接收 |
|
||||
| download 下载 | upload 上传 |
|
||||
| refresh 刷新 | synchronize 同步 |
|
||||
| update 更新 | revert 复原 |
|
||||
| lock 锁定 | unlock 解锁 |
|
||||
| check out 签出 | check in 签入 |
|
||||
| submit 提交 | commit 交付 |
|
||||
| push 推 | pull 拉 |
|
||||
| expand 展开 | collapse 折叠 |
|
||||
| begin 起始 | end 结束 |
|
||||
| start 开始 | finish 完成 |
|
||||
| enter 进入 | exit 退出 |
|
||||
| abort 放弃 | quit 离开 |
|
||||
| obsolete 废弃 | depreciate 废旧 |
|
||||
| collect 收集 | aggregate 聚集 |
|
||||
|
||||
## 五,变量&常量命名
|
||||
|
||||
### 5.1 变量命名
|
||||
|
||||
变量是指在程序运行中可以改变其值的量,包括成员变量和局部变量。变量名由多单词组成时,第一个单词的首字母小写,其后单词的首字母大写,俗称骆驼式命名法(也称驼峰命名法),如 computedValues,index、变量命名时,尽量简短且能清楚的表达变量的作用,命名体现具体的业务含义即可。
|
||||
|
||||
变量名不应以下划线或美元符号开头,尽管这在语法上是允许的。变量名应简短且富于描述。变量名的选用应该易于记忆,即,能够指出其用途。尽量避免单个字符的变量名,除非是一次性的临时变量。pojo中的布尔变量,都不要加is(数据库中的布尔字段全都要加 is_ 前缀)。
|
||||
|
||||
### 5.2 常量命名
|
||||
|
||||
常量命名CONSTANT_CASE,一般采用全部大写(作为方法参数时除外),单词间用下划线分割。那么什么是常量呢?
|
||||
|
||||
常量是在作用域内保持不变的值,一般使用final进行修饰。一般分为三种,全局常量(public static final修饰),类内常量(private static final 修饰)以及局部常量(方法内,或者参数中的常量),局部常量比较特殊,通常采用小驼峰命名即可。
|
||||
|
||||
```java
|
||||
/**
|
||||
* 一个demo
|
||||
*
|
||||
* @author Jann Lee
|
||||
* @date 2019-12-07 00:25
|
||||
**/
|
||||
public class HelloWorld {
|
||||
|
||||
/**
|
||||
* 局部常量(正例)
|
||||
*/
|
||||
public static final long USER_MESSAGE_CACHE_EXPIRE_TIME = 3600;
|
||||
|
||||
/**
|
||||
* 局部常量(反例,命名不清晰)
|
||||
*/
|
||||
public static final long MESSAGE_CACHE_TIME = 3600;
|
||||
|
||||
/**
|
||||
* 全局常量
|
||||
*/
|
||||
private static final String ERROR_MESSAGE = " error message";
|
||||
|
||||
/**
|
||||
* 成员变量
|
||||
*/
|
||||
private int currentUserId;
|
||||
|
||||
/**
|
||||
* 控制台打印 {@code message} 信息
|
||||
*
|
||||
* @param message 消息体,局部常量
|
||||
*/
|
||||
public void sayHello(final String message){
|
||||
System.out.println("Hello world!");
|
||||
}
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
常量一般都有自己的业务含义,**不要害怕长度过长而进行省略或者缩写**。如,用户消息缓存过期时间的表示,那种方式更佳清晰,交给你来评判。
|
||||
|
||||
## 通用命名规则[#](https://www.cnblogs.com/liqiangchn/p/12000361.html#450918152)
|
||||
|
||||
1. 尽量不要使用拼音;杜绝拼音和英文混用。对于一些通用的表示或者难以用英文描述的可以采用拼音,一旦采用拼音就坚决不能和英文混用。
|
||||
正例: BeiJing, HangZhou
|
||||
反例: validateCanShu
|
||||
2. 命名过程中尽量不要出现特殊的字符,常量除外。
|
||||
3. 尽量不要和jdk或者框架中已存在的类重名,也不能使用java中的关键字命名。
|
||||
4. 妙用介词,如for(可以用同音的4代替), to(可用同音的2代替), from, with,of等。
|
||||
如类名采用User4RedisDO,方法名getUserInfoFromRedis,convertJson2Map等。
|
||||
|
||||
## 六,代码注解
|
||||
|
||||
### 6.1 注解的原则
|
||||
|
||||
好的命名增加代码阅读性,代码的命名往往有严格的限制。而注解不同,程序员往往可以自由发挥,单并不意味着可以为所欲为之胡作非为。优雅的注解通常要满足三要素。
|
||||
|
||||
1. Nothing is strange
|
||||
没有注解的代码对于阅读者非常不友好,哪怕代码写的在清除,阅读者至少从心理上会有抵触,更何况代码中往往有许多复杂的逻辑,所以一定要写注解,不仅要记录代码的逻辑,还有说清楚修改的逻辑。
|
||||
2. Less is more
|
||||
从代码维护角度来讲,代码中的注解一定是精华中的精华。合理清晰的命名能让代码易于理解,对于逻辑简单且命名规范,能够清楚表达代码功能的代码不需要注解。滥用注解会增加额外的负担,更何况大部分都是废话。
|
||||
|
||||
```java
|
||||
// 根据id获取信息【废话注解】
|
||||
getMessageById(id)
|
||||
```
|
||||
|
||||
1. Advance with the time
|
||||
注解应该随着代码的变动而改变,注解表达的信息要与代码中完全一致。通常情况下修改代码后一定要修改注解。
|
||||
|
||||
### 6.2 注解格式
|
||||
|
||||
注解大体上可以分为两种,一种是javadoc注解,另一种是简单注解。javadoc注解可以生成JavaAPI为外部用户提供有效的支持javadoc注解通常在使用IDEA,或者Eclipse等开发工具时都可以自动生成,也支持自定义的注解模板,仅需要对对应的字段进行解释。参与同一项目开发的同学,尽量设置成相同的注解模板。
|
||||
|
||||
#### a. 包注解
|
||||
|
||||
包注解在工作中往往比较特殊,通过包注解可以快速知悉当前包下代码是用来实现哪些功能,强烈建议工作中加上,尤其是对于一些比较复杂的包,包注解一般在包的根目录下,名称统一为package-info.java。
|
||||
|
||||
```java
|
||||
/**
|
||||
* 落地也质量检测
|
||||
* 1. 用来解决什么问题
|
||||
* 对广告主投放的广告落地页进行性能检测,模拟不同的系统,如Android,IOS等; 模拟不同的网络:2G,3G,4G,wifi等
|
||||
*
|
||||
* 2. 如何实现
|
||||
* 基于chrome浏览器,用chromedriver驱动浏览器,设置对应的网络,OS参数,获取到浏览器返回结果。
|
||||
*
|
||||
* 注意: 网络环境配置信息{@link cn.mycookies.landingpagecheck.meta.NetWorkSpeedEnum}目前使用是常规速度,可以根据实际情况进行调整
|
||||
*
|
||||
* @author cruder
|
||||
* @time 2019/12/7 20:3 下午
|
||||
*/
|
||||
package cn.mycookies.landingpagecheck;
|
||||
```
|
||||
|
||||
#### b. 类注接
|
||||
|
||||
javadoc注解中,每个类都必须有注解。
|
||||
|
||||
```java
|
||||
/**
|
||||
* Copyright (C), 2019-2020, Jann balabala...
|
||||
*
|
||||
* 类的介绍:这是一个用来做什么事情的类,有哪些功能,用到的技术.....
|
||||
*
|
||||
* @author 类创建者姓名 保持对齐
|
||||
* @date 创建日期 保持对齐
|
||||
* @version 版本号 保持对齐
|
||||
*/
|
||||
```
|
||||
|
||||
#### c. 属性注解
|
||||
|
||||
在每个属性前面必须加上属性注释,通常有一下两种形式,至于怎么选择,你高兴就好,不过一个项目中要保持统一。
|
||||
|
||||
```java
|
||||
/** 提示信息 */
|
||||
private String userName;
|
||||
/**
|
||||
* 密码
|
||||
*/
|
||||
private String password;
|
||||
```
|
||||
|
||||
#### d. 方法注释
|
||||
|
||||
在每个方法前面必须加上方法注释,对于方法中的每个参数,以及返回值都要有说明。
|
||||
|
||||
```java
|
||||
/**
|
||||
* 方法的详细说明,能干嘛,怎么实现的,注意事项...
|
||||
*
|
||||
* @param xxx 参数1的使用说明, 能否为null
|
||||
* @return 返回结果的说明, 不同情况下会返回怎样的结果
|
||||
* @throws 异常类型 注明从此类方法中抛出异常的说明
|
||||
*/
|
||||
```
|
||||
|
||||
#### e. 构造方法注释
|
||||
|
||||
在每个构造方法前面必须加上注释,注释模板如下:
|
||||
|
||||
```java
|
||||
/**
|
||||
* 构造方法的详细说明
|
||||
*
|
||||
* @param xxx 参数1的使用说明, 能否为null
|
||||
* @throws 异常类型 注明从此类方法中抛出异常的说明
|
||||
*/
|
||||
```
|
||||
|
||||
而简单注解往往是需要工程师字节定义,在使用注解时应该注意一下几点:
|
||||
|
||||
1. 枚举类的各个属性值都要使用注解,枚举可以理解为是常量,通常不会发生改变,通常会被在多个地方引用,对枚举的修改和添加属性通常会带来很大的影响。
|
||||
2. 保持排版整洁,不要使用行尾注释;双斜杠和星号之后要用1个空格分隔。
|
||||
|
||||
```java
|
||||
id = 1;// 反例:不要使用行尾注释
|
||||
//反例:换行符与注释之间没有缩进
|
||||
int age = 18;
|
||||
// 正例:姓名
|
||||
String name;
|
||||
/**
|
||||
* 1. 多行注释
|
||||
*
|
||||
* 2. 对于不同的逻辑说明,可以用空行分隔
|
||||
*/
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
无论是命名和注解,他们的目的都是为了让代码和工程师进行对话,增强代码的可读性,可维护性。优秀的代码往往能够见名知意,注解往往是对命名的补充和完善。命名太南了!
|
||||
|
||||
参考文献:
|
||||
|
||||
- 《码出高效》
|
||||
- https://www.cnblogs.com/wangcp-2014/p/10215620.html
|
||||
- https://qiita.com/KeithYokoma/items/2193cf79ba76563e3db6
|
||||
- https://google.github.io/styleguide/javaguide.html#s2.1-file-name
|
334
docs/operating-system/basis.md
Normal file
334
docs/operating-system/basis.md
Normal file
@ -0,0 +1,334 @@
|
||||
大家好,我是 Guide 哥!很多读者抱怨计算操作系统的知识点比较繁杂,自己也没有多少耐心去看,但是面试的时候又经常会遇到。所以,我带着我整理好的操作系统的常见问题来啦!这篇文章总结了一些我觉得比较重要的操作系统相关的问题比如**进程管理**、**内存管理**、**虚拟内存**等等。
|
||||
|
||||
文章形式通过大部分比较喜欢的面试官和求职者之间的对话形式展开。另外,Guide 哥也只是在大学的时候学习过操作系统,不过基本都忘了,为了写这篇文章这段时间看了很多相关的书籍和博客。如果文中有任何需要补充和完善的地方,你都可以在评论区指出。如果觉得内容不错的话,不要忘记点个在看哦!
|
||||
|
||||
我个人觉得学好操作系统还是非常有用的,具体可以看我昨天在星球分享的一段话:
|
||||
|
||||
<img src="https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-11/image-20200329145912767.png" height="666"/>
|
||||
|
||||
这篇文章只是对一些操作系统比较重要概念的一个概览,深入学习的话,建议大家还是老老实实地去看书。另外, 这篇文章的很多内容参考了《现代操作系统》第三版这本书,非常感谢。
|
||||
|
||||
## 一 操作系统基础
|
||||
|
||||
面试官顶着蓬松的假发向我走来,只见他一手拿着厚重的 Thinkpad ,一手提着他那淡黄的长裙。
|
||||
|
||||
<img src="http://wx4.sinaimg.cn/large/ceeb653ely1gd8wj5evc4j20i00n0dh0.jpg" height="300"></img>
|
||||
|
||||
### 1.1 什么是操作系统?
|
||||
|
||||
👨💻**面试官** : 先来个简单问题吧!**什么是操作系统?**
|
||||
|
||||
🙋 **我** :我通过以下四点向您介绍一下什么是操作系统吧!
|
||||
|
||||
1. **操作系统(Operating System,简称 OS)是管理计算机硬件与软件资源的程序,是计算机系统的内核与基石;**
|
||||
2. **操作系统本质上是运行在计算机上的软件程序 ;**
|
||||
3. **操作系统为用户提供一个与系统交互的操作界面 ;**
|
||||
4. **操作系统分内核与外壳(我们可以把外壳理解成围绕着内核的应用程序,而内核就是能操作硬件的程序)。**
|
||||
|
||||
> 关于内核多插一嘴:内核负责管理系统的进程、内存、设备驱动程序、文件和网络系统等等,决定着系统的性能和稳定性。是连接应用程序和硬件的桥梁。
|
||||
> 内核就是操作系统背后黑盒的核心。
|
||||
|
||||

|
||||
|
||||
### 1.2 系统调用
|
||||
|
||||
👨💻**面试官** :**什么是系统调用呢?** 能不能详细介绍一下。
|
||||
|
||||
🙋 **我** :介绍系统调用之前,我们先来了解一下用户态和系统态。
|
||||
|
||||
<img src="http://ww4.sinaimg.cn/large/006r3PQBjw1fbimb5c3srj30b40b40t9.jpg" height="200" width="2"/>
|
||||
|
||||
根据进程访问资源的特点,我们可以把进程在系统上的运行分为两个级别:
|
||||
|
||||
1. 用户态(user mode) : 用户态运行的进程或可以直接读取用户程序的数据。
|
||||
2. 系统态(kernel mode):可以简单的理解系统态运行的进程或程序几乎可以访问计算机的任何资源,不受限制。
|
||||
|
||||
说了用户态和系统态之后,那么什么是系统调用呢?
|
||||
|
||||
我们运行的程序基本都是运行在用户态,如果我们调用操作系统提供的系统态级别的子功能咋办呢?那就需要系统调用了!
|
||||
|
||||
也就是说在我们运行的用户程序中,凡是与系统态级别的资源有关的操作(如文件管理、进程控制、内存管理等),都必须通过系统调用方式向操作系统提出服务请求,并由操作系统代为完成。
|
||||
|
||||
这些系统调用按功能大致可分为如下几类:
|
||||
|
||||
- 设备管理。完成设备的请求或释放,以及设备启动等功能。
|
||||
- 文件管理。完成文件的读、写、创建及删除等功能。
|
||||
- 进程控制。完成进程的创建、撤销、阻塞及唤醒等功能。
|
||||
- 进程通信。完成进程之间的消息传递或信号传递等功能。
|
||||
- 内存管理。完成内存的分配、回收以及获取作业占用内存区大小及地址等功能。
|
||||
|
||||
## 二 进程和线程
|
||||
|
||||
### 2.1 进程和线程的区别
|
||||
|
||||
👨💻**面试官**: 好的!我明白了!那你再说一下: **进程和线程的区别**。
|
||||
|
||||
🙋 **我:** 好的! 下图是 Java 内存区域,我们从 JVM 的角度来说一下线程和进程之间的关系吧!
|
||||
|
||||
> 如果你对 Java 内存区域 (运行时数据区) 这部分知识不太了解的话可以阅读一下这篇文章:[《可能是把 Java 内存区域讲的最清楚的一篇文章》](<[https://snailclimb.gitee.io/javaguide/#/docs/java/jvm/Java%E5%86%85%E5%AD%98%E5%8C%BA%E5%9F%9F](https://snailclimb.gitee.io/javaguide/#/docs/java/jvm/Java内存区域)>)
|
||||
|
||||

|
||||
|
||||
从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的**堆**和**方法区 (JDK1.8 之后的元空间)**资源,但是每个线程有自己的**程序计数器**、**虚拟机栈** 和 **本地方法栈**。
|
||||
|
||||
**总结:** 线程是进程划分成的更小的运行单位,一个进程在其执行的过程中可以产生多个线程。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。
|
||||
|
||||
### 2.2 进程有哪几种状态?
|
||||
|
||||
👨💻**面试官** : 那你再说说**进程有哪几种状态?**
|
||||
|
||||
🙋 **我** :我们一般把进程大致分为 5 种状态,这一点和[线程](https://github.com/Snailclimb/JavaGuide/blob/master/docs/java/Multithread/JavaConcurrencyBasicsCommonInterviewQuestionsSummary.md#6-%E8%AF%B4%E8%AF%B4%E7%BA%BF%E7%A8%8B%E7%9A%84%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F%E5%92%8C%E7%8A%B6%E6%80%81)很像!
|
||||
|
||||
- **创建状态(new)** :进程正在被创建,尚未到就绪状态。
|
||||
- **就绪状态(ready)** :进程已处于准备运行状态,即进程获得了除了处理器之外的一切所需资源,一旦得到处理器资源(处理器分配的时间片)即可运行。
|
||||
- **运行状态(running)** :进程正在处理器上上运行(单核 CPU 下任意时刻只有一个进程处于运行状态)。
|
||||
- **阻塞状态(waiting)** :又称为等待状态,进程正在等待某一事件而暂停运行如等待某资源为可用或等待 IO 操作完成。即使处理器空闲,该进程也不能运行。
|
||||
- **结束状态(terminated)** :进程正在从系统中消失。可能是进程正常结束或其他原因中断退出运行。
|
||||
|
||||

|
||||
|
||||
### 2.3 进程间的通信方式
|
||||
|
||||
👨💻**面试官** :**进程间的通信常见的的有哪几种方式呢?**
|
||||
|
||||
🙋 **我** :大概有 7 种常见的进程间的通信方式。
|
||||
|
||||
> 下面这部分总结参考了:[《进程间通信 IPC (InterProcess Communication)》](https://www.jianshu.com/p/c1015f5ffa74) 这篇文章,推荐阅读,总结的非常不错。
|
||||
|
||||
1. **管道/匿名管道(Pipes)** :用于具有亲缘关系的父子进程间或者兄弟进程之间的通信。
|
||||
1. **有名管道(Names Pipes)** : 匿名管道由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道。有名管道严格遵循**先进先出(first in first out)**。有名管道以磁盘文件的方式存在,可以实现本机任意两个进程通信。
|
||||
1. **信号(Signal)** :信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生;
|
||||
1. **消息队列(Message Queuing)** :消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识。管道和消息队列的通信数据都是先进先出的原则。与管道(无名管道:只存在于内存中的文件;命名管道:存在于实际的磁盘介质或者文件系统)不同的是消息队列存放在内核中,只有在内核重启(即,操作系统重启)或者显示地删除一个消息队列时,该消息队列才会被真正的删除。消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比 FIFO 更有优势。**消息队列克服了信号承载信息量少,管道只能承载无格式字 节流以及缓冲区大小受限等缺。**
|
||||
1. **信号量(Semaphores)** :信号量是一个计数器,用于多进程对共享数据的访问,信号量的意图在于进程间同步。这种通信方式主要用于解决与同步相关的问题并避免竞争条件。
|
||||
1. **共享内存(Shared memory)** :使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。可以说这是最有用的进程间通信方式。
|
||||
1. **套接字(Sockets)** : 此方法主要用于在客户端和服务器之间通过网络进行通信。套接字是支持 TCP/IP 的网络通信的基本操作单元,可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是通信的两方的一种约定,用套接字中的相关函数来完成通信过程。
|
||||
|
||||
### 2.4 线程间的同步的方式
|
||||
|
||||
👨💻**面试官** :**那线程间的同步的方式有哪些呢?**
|
||||
|
||||
🙋 **我** :线程同步是两个或多个共享关键资源的线程的并发执行。应该同步线程以避免关键的资源使用冲突。操作系统一般有下面三种线程同步的方式:
|
||||
|
||||
1. **互斥量(Mutex)**:采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。比如 Java 中的 synchronized 关键词和各种 Lock 都是这种机制。
|
||||
1. **信号量(Semphares)** :它允许同一时刻多个线程访问同一资源,但是需要控制同一时刻访问此资源的最大线程数量
|
||||
1. **事件(Event)** :Wait/Notify:通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操
|
||||
|
||||
### 2.5 进程的调度算法
|
||||
|
||||
👨💻**面试官** :**你知道操作系统中进程的调度算法有哪些吗?**
|
||||
|
||||
🙋 **我** :嗯嗯!这个我们大学的时候学过,是一个很重要的知识点!
|
||||
|
||||
为了确定首先执行哪个进程以及最后执行哪个进程以实现最大 CPU 利用率,计算机科学家已经定义了一些算法,它们是:
|
||||
|
||||
- **先到先服务(FCFS)调度算法** : 从就绪队列中选择一个最先进入该队列的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。
|
||||
- **短作业优先(SJF)的调度算法** : 从就绪队列中选出一个估计运行时间最短的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。
|
||||
- **时间片轮转调度算法** : 时间片轮转调度是一种最古老,最简单,最公平且使用最广的算法,又称 RR(Round robin)调度。每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间。
|
||||
- **多级反馈队列调度算法** :前面介绍的几种进程调度的算法都有一定的局限性。如**短进程优先的调度算法,仅照顾了短进程而忽略了长进程** 。多级反馈队列调度算法既能使高优先级的作业得到响应又能使短作业(进程)迅速完成。,因而它是目前**被公认的一种较好的进程调度算法**,UNIX 操作系统采取的便是这种调度算法。
|
||||
- **优先级调度** : 为每个流程分配优先级,首先执行具有最高优先级的进程,依此类推。具有相同优先级的进程以 FCFS 方式执行。可以根据内存要求,时间要求或任何其他资源要求来确定优先级。
|
||||
|
||||
## 三 操作系统内存管理基础
|
||||
|
||||
### 3.1 内存管理介绍
|
||||
|
||||
👨💻 **面试官**: **操作系统的内存管理主要是做什么?**
|
||||
|
||||
🙋 **我:** 操作系统的内存管理主要负责内存的分配与回收(malloc 函数:申请内存,free 函数:释放内存),另外地址转换也就是将逻辑地址转换成相应的物理地址等功能也是操作系统内存管理做的事情。
|
||||
|
||||
### 3.2 常见的几种内存管理机制
|
||||
|
||||
👨💻 **面试官**: **操作系统的内存管理机制了解吗?内存管理有哪几种方式?**
|
||||
|
||||
🙋 **我:** 这个在学习操作系统的时候有了解过。
|
||||
|
||||
简单分为**连续分配管理方式**和**非连续分配管理方式**这两种。连续分配管理方式是指为一个用户程序分配一个连续的内存空间,常见的如 **块式管理** 。同样地,非连续分配管理方式允许一个程序使用的内存分布在离散或者说不相邻的内存中,常见的如**页式管理** 和 **段式管理**。
|
||||
|
||||
1. **块式管理** : 远古时代的计算机操系统的内存管理方式。将内存分为几个固定大小的块,每个块中只包含一个进程。如果程序运行需要内存的话,操作系统就分配给它一块,如果程序运行只需要很小的空间的话,分配的这块内存很大一部分几乎被浪费了。这些在每个块中未被利用的空间,我们称之为碎片。
|
||||
2. **页式管理** :把主存分为大小相等且固定的一页一页的形式,页较小,相对相比于块式管理的划分力度更大,提高了内存利用率,减少了碎片。页式管理通过页表对应逻辑地址和物理地址。
|
||||
3. **段式管理** : 页式管理虽然提高了内存利用率,但是页式管理其中的页实际并无任何实际意义。 段式管理把主存分为一段段的,每一段的空间又要比一页的空间小很多 。但是,最重要的是段是有实际意义的,每个段定义了一组逻辑信息,例如,有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等。 段式管理通过段表对应逻辑地址和物理地址。
|
||||
|
||||
👨💻**面试官** : 回答的还不错!不过漏掉了一个很重要的 **段页式管理机制** 。段页式管理机制结合了段式管理和页式管理的优点。简单来说段页式管理机制就是把主存先分成若干段,每个段又分成若干页,也就是说 **段页式管理机制** 中段与段之间以及段的内部的都是离散的。
|
||||
|
||||
🙋 **我** :谢谢面试官!刚刚把这个给忘记了~
|
||||
|
||||
<img src="http://ww4.sinaimg.cn/large/6af89bc8gw1f8txoxc2asj20k00k0mxv.jpg" alt="这就很尴尬了_尴尬表情" height="200" width="200"/>
|
||||
|
||||
### 3.3 快表和多级页表
|
||||
|
||||
👨💻**面试官** : 页表管理机制中有两个很重要的概念:快表和多级页表,这两个东西分别解决了页表管理中很重要的两个问题。你给我简单介绍一下吧!
|
||||
|
||||
🙋 **我** :在分页内存管理中,很重要的两点是:
|
||||
|
||||
1. 虚拟地址到物理地址的转换要快。
|
||||
2. 解决虚拟地址空间大,页表也会很大的问题。
|
||||
|
||||
#### 快表
|
||||
|
||||
为了解决虚拟地址到物理地址的转换速度,操作系统在 **页表方案** 基础之上引入了 **快表** 来加速虚拟地址到物理地址的转换。我们可以把块表理解为一种特殊的高速缓冲存储器(Cache),其中的内容是页表的一部分或者全部内容。作为页表的 Cache,它的作用与页表相似,但是提高了访问速率。由于采用页表做地址转换,读写内存数据时 CPU 要访问两次主存。有了快表,有时只要访问一次高速缓冲存储器,一次主存,这样可加速查找并提高指令执行速度。
|
||||
|
||||
使用快表之后的地址转换流程是这样的:
|
||||
|
||||
1. 根据虚拟地址中的页号查快表;
|
||||
2. 如果该页在快表中,直接从快表中读取相应的物理地址;
|
||||
3. 如果该页不在快表中,就访问内存中的页表,再从页表中得到物理地址,同时将页表中的该映射表项添加到快表中;
|
||||
4. 当快表填满后,又要登记新页时,就按照一定的淘汰策略淘汰掉快表中的一个页。
|
||||
|
||||
看完了之后你会发现快表和我们平时经常在我们开发的系统使用的缓存(比如 Redis)很像,的确是这样的,操作系统中的很多思想、很多经典的算法,你都可以在我们日常开发使用的各种工具或者框架中找到它们的影子。
|
||||
|
||||
#### 多级页表
|
||||
|
||||
引入多级页表的主要目的是为了避免把全部页表一直放在内存中占用过多空间,特别是那些根本就不需要的页表就不需要保留在内存中。多级页表属于时间换空间的典型场景,具体可以查看下面这篇文章
|
||||
|
||||
- 多级页表如何节约内存:[https://www.polarxiong.com/archives/多级页表如何节约内存.html](https://www.polarxiong.com/archives/多级页表如何节约内存.html)
|
||||
|
||||
#### 总结
|
||||
|
||||
为了提高内存的空间性能,提出了多级页表的概念;但是提到空间性能是以浪费时间性能为基础的,因此为了补充损失的时间性能,提出了快表(即 TLB)的概念。 不论是快表还是多级页表实际上都利用到了程序的局部性原理,局部性原理在后面的虚拟内存这部分会介绍到。
|
||||
|
||||
### 3.4 分页机制和分段机制的共同点和区别
|
||||
|
||||
👨💻**面试官** : **分页机制和分段机制有哪些共同点和区别呢?**
|
||||
|
||||
🙋 **我** :
|
||||
|
||||
<img src="http://wx3.sinaimg.cn/large/de80a5ably1gcuslckpygg208c08cwfu.gif" height="200" width="200"></img>
|
||||
|
||||
1. **共同点** :
|
||||
- 分页机制和分段机制都是为了提高内存利用率,较少内存碎片。
|
||||
- 页和段都是离散存储的,所以两者都是离散分配内存的方式。但是,每个页和段中的内存是连续的。
|
||||
2. **区别** :
|
||||
- 页的大小是固定的,由操作系统决定;而段的大小不固定,取决于我们当前运行的程序。
|
||||
- 分页仅仅是为了满足操作系统内存管理的需求,而段是逻辑信息的单位,在程序中可以体现为代码段,数据段,能够更好满足用户的需要。
|
||||
|
||||
### 3.5 逻辑(虚拟)地址和物理地址
|
||||
|
||||
👨💻**面试官** :你刚刚还提到了**逻辑地址和物理地址**这两个概念,我不太清楚,你能为我解释一下不?
|
||||
|
||||
🙋 **我:** em...好的嘛!我们编程一般只有可能和逻辑地址打交道,比如在 C 语言中,指针里面存储的数值就可以理解成为内存里的一个地址,这个地址也就是我们说的逻辑地址,逻辑地址由操作系统决定。物理地址指的是真实物理内存中地址,更具体一点来说就是内存地址寄存器中的地址。物理地址是内存单元真正的地址。
|
||||
|
||||
### 3.6 CPU 寻址了解吗?为什么需要虚拟地址空间?
|
||||
|
||||
👨💻**面试官** :**CPU 寻址了解吗?为什么需要虚拟地址空间?**
|
||||
|
||||
🙋 **我** :这部分我真不清楚!
|
||||
|
||||
<img src="http://wx2.sinaimg.cn/bmiddle/a9cf8ef6ly1fhqpdipcyfj20ce0b4wex.jpg " height="300px" />
|
||||
|
||||
于是面试完之后我默默去查阅了相关文档!留下了没有技术的泪水。。。
|
||||
|
||||
> 这部分内容参考了 Microsoft 官网的介绍,地址:<https://msdn.microsoft.com/zh-cn/library/windows/hardware/hh439648(v=vs.85).aspx>
|
||||
|
||||
现代处理器使用的是一种称为 **虚拟寻址(Virtual Addressing)** 的寻址方式。**使用虚拟寻址,CPU 需要将虚拟地址翻译成物理地址,这样才能访问到真实的物理内存。** 实际上完成虚拟地址转换为物理地址转换的硬件是 CPU 中含有一个被称为 **内存管理单元(Memory Management Unit, MMU)** 的硬件。如下图所示:
|
||||
|
||||

|
||||
|
||||
**为什么要有虚拟地址空间呢?**
|
||||
|
||||
先从没有虚拟地址空间的时候说起吧!没有虚拟地址空间的时候,**程序都是直接访问和操作的都是物理内存** 。但是这样有什么问题呢?
|
||||
|
||||
1. 用户程序可以访问任意内存,寻址内存的每个字节,这样就很容易(有意或者无意)破坏操作系统,造成操作系统崩溃。
|
||||
2. 想要同时运行多个程序特别困难,比如你想同时运行一个微信和一个 QQ 音乐都不行。为什么呢?举个简单的例子:微信在运行的时候给内存地址 1xxx 赋值后,QQ 音乐也同样给内存地址 1xxx 赋值,那么 QQ 音乐对内存的赋值就会覆盖微信之前所赋的值,这就造成了微信这个程序就会崩溃。
|
||||
|
||||
**总结来说:如果直接把物理地址暴露出来的话会带来严重问题,比如可能对操作系统造成伤害以及给同时运行多个程序造成困难。**
|
||||
|
||||
通过虚拟地址访问内存有以下优势:
|
||||
|
||||
- 程序可以使用一系列相邻的虚拟地址来访问物理内存中不相邻的大内存缓冲区。
|
||||
- 程序可以使用一系列虚拟地址来访问大于可用物理内存的内存缓冲区。当物理内存的供应量变小时,内存管理器会将物理内存页(通常大小为 4 KB)保存到磁盘文件。数据或代码页会根据需要在物理内存与磁盘之间移动。
|
||||
- 不同进程使用的虚拟地址彼此隔离。一个进程中的代码无法更改正在由另一进程或操作系统使用的物理内存。
|
||||
|
||||
## 四 虚拟内存
|
||||
|
||||
### 4.1 什么是虚拟内存(Virtual Memory)?
|
||||
|
||||
👨💻**面试官** :再问你一个常识性的问题!**什么是虚拟内存(Virtual Memory)?**
|
||||
|
||||
🙋 **我** :这个在我们平时使用电脑特别是 Windows 系统的时候太常见了。很多时候我们使用点开了很多占内存的软件,这些软件占用的内存可能已经远远超出了我们电脑本身具有的物理内存。**为什么可以这样呢?** 正是因为 **虚拟内存** 的存在,通过 **虚拟内存** 可以让程序可以拥有超过系统物理内存大小的可用内存空间。另外,**虚拟内存为每个进程提供了一个一致的、私有的地址空间,它让每个进程产生了一种自己在独享主存的错觉(每个进程拥有一片连续完整的内存空间)**。这样会更加有效地管理内存并减少出错。
|
||||
|
||||
**虚拟内存**是计算机系统内存管理的一种技术,我们可以手动设置自己电脑的虚拟内存。不要单纯认为虚拟内存只是“使用硬盘空间来扩展内存“的技术。**虚拟内存的重要意义是它定义了一个连续的虚拟地址空间**,并且 **把内存扩展到硬盘空间**。推荐阅读:[《虚拟内存的那点事儿》](https://juejin.im/post/59f8691b51882534af254317)
|
||||
|
||||
维基百科中有几句话是这样介绍虚拟内存的。
|
||||
|
||||
> **虚拟内存** 使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。与没有使用虚拟内存技术的系统相比,使用这种技术的系统使得大型程序的编写变得更容易,对真正的物理内存(例如 RAM)的使用也更有效率。目前,大多数操作系统都使用了虚拟内存,如 Windows 家族的“虚拟内存”;Linux 的“交换空间”等。From:<https://zh.wikipedia.org/wiki/虚拟内存>
|
||||
|
||||
### 4.2 局部性原理
|
||||
|
||||
👨💻**面试官** :要想更好地理解虚拟内存技术,必须要知道计算机中著名的**局部性原理**。另外,局部性原理既适用于程序结构,也适用于数据结构,是非常重要的一个概念。
|
||||
|
||||
🙋 **我** :局部性原理是虚拟内存技术的基础,正是因为程序运行具有局部性原理,才可以只装入部分程序到内存就开始运行。
|
||||
|
||||
> 以下内容摘自《计算机操作系统教程》 第 4 章存储器管理。
|
||||
|
||||
早在 1968 年的时候,就有人指出我们的程序在执行的时候往往呈现局部性规律,也就是说在某个较短的时间段内,程序执行局限于某一小部分,程序访问的存储空间也局限于某个区域。
|
||||
|
||||
局部性原理表现在以下两个方面:
|
||||
|
||||
1. **时间局部性** :如果程序中的某条指令一旦执行,不久以后该指令可能再次执行;如果某数据被访问过,不久以后该数据可能再次被访问。产生时间局部性的典型原因,是由于在程序中存在着大量的循环操作。
|
||||
2. **空间局部性** :一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也将被访问,即程序在一段时间内所访问的地址,可能集中在一定的范围之内,这是因为指令通常是顺序存放、顺序执行的,数据也一般是以向量、数组、表等形式簇聚存储的。
|
||||
|
||||
时间局部性是通过将近来使用的指令和数据保存到高速缓存存储器中,并使用高速缓存的层次结构实现。空间局部性通常是使用较大的高速缓存,并将预取机制集成到高速缓存控制逻辑中实现。虚拟内存技术实际上就是建立了 “内存一外存”的两级存储器的结构,利用局部性原理实现髙速缓存。
|
||||
|
||||
### 4.3 虚拟存储器
|
||||
|
||||
👨💻**面试官** :都说了虚拟内存了。你再讲讲**虚拟存储器**把!
|
||||
|
||||
🙋 **我** :
|
||||
|
||||
> 这部分内容来自:[王道考研操作系统知识点整理](https://wizardforcel.gitbooks.io/wangdaokaoyan-os/content/13.html)。
|
||||
|
||||
基于局部性原理,在程序装入时,可以将程序的一部分装入内存,而将其他部分留在外存,就可以启动程序执行。由于外存往往比内存大很多,所以我们运行的软件的内存大小实际上是可以比计算机系统实际的内存大小大的。在程序执行过程中,当所访问的信息不在内存时,由操作系统将所需要的部分调入内存,然后继续执行程序。另一方面,操作系统将内存中暂时不使用的内容换到外存上,从而腾出空间存放将要调入内存的信息。这样,计算机好像为用户提供了一个比实际内存大的多的存储器——**虚拟存储器**。
|
||||
|
||||
实际上,我觉得虚拟内存同样是一种时间换空间的策略,你用 CPU 的计算时间,页的调入调出花费的时间,换来了一个虚拟的更大的空间来支持程序的运行。不得不感叹,程序世界几乎不是时间换空间就是空间换时间。
|
||||
|
||||
### 4.4 虚拟内存的技术实现
|
||||
|
||||
👨💻**面试官** :**虚拟内存技术的实现呢?**
|
||||
|
||||
🙋 **我** :**虚拟内存的实现需要建立在离散分配的内存管理方式的基础上。** 虚拟内存的实现有以下三种方式:
|
||||
|
||||
1. **请求分页存储管理** :建立在分页管理之上,为了支持虚拟存储器功能而增加了请求调页功能和页面置换功能。请求分页是目前最常用的一种实现虚拟存储器的方法。请求分页存储管理系统中,在作业开始运行之前,仅装入当前要执行的部分段即可运行。假如在作业运行的过程中发现要访问的页面不在内存,则由处理器通知操作系统按照对应的页面置换算法将相应的页面调入到主存,同时操作系统也可以将暂时不用的页面置换到外存中。
|
||||
2. **请求分段存储管理** :建立在分段存储管理之上,增加了请求调段功能、分段置换功能。请求分段储存管理方式就如同请求分页储存管理方式一样,在作业开始运行之前,仅装入当前要执行的部分段即可运行;在执行过程中,可使用请求调入中断动态装入要访问但又不在内存的程序段;当内存空间已满,而又需要装入新的段时,根据置换功能适当调出某个段,以便腾出空间而装入新的段。
|
||||
3. **请求段页式存储管理**
|
||||
|
||||
**这里多说一下?很多人容易搞混请求分页与分页存储管理,两者有何不同呢?**
|
||||
|
||||
请求分页存储管理建立在分页管理之上。他们的根本区别是是否将程序全部所需的全部地址空间都装入主存,这也是请求分页存储管理可以提供虚拟内存的原因,我们在上面已经分析过了。
|
||||
|
||||
它们之间的根本区别在于是否将一作业的全部地址空间同时装入主存。请求分页存储管理不要求将作业全部地址空间同时装入主存。基于这一点,请求分页存储管理可以提供虚存,而分页存储管理却不能提供虚存。
|
||||
|
||||
不管是上面那种实现方式,我们一般都需要:
|
||||
|
||||
1. 一定容量的内存和外存:在载入程序的时候,只需要将程序的一部分装入内存,而将其他部分留在外存,然后程序就可以执行了;
|
||||
2. **缺页中断**:如果**需执行的指令或访问的数据尚未在内存**(称为缺页或缺段),则由处理器通知操作系统将相应的页面或段**调入到内存**,然后继续执行程序;
|
||||
3. **虚拟地址空间** :逻辑地址到物理地址的变换。
|
||||
|
||||
### 4.5 页面置换算法
|
||||
|
||||
👨💻**面试官** :虚拟内存管理很重要的一个概念就是页面置换算法。那你说一下 **页面置换算法的作用?常见的页面置换算法有哪些?**
|
||||
|
||||
🙋 **我** :
|
||||
|
||||
> 这个题目经常作为笔试题出现,网上已经给出了很不错的回答,我这里只是总结整理了一下。
|
||||
|
||||
地址映射过程中,若在页面中发现所要访问的页面不在内存中,则发生缺页中断 。
|
||||
|
||||
> **缺页中断** 就是要访问的**页**不在主存,需要操作系统将其调入主存后再进行访问。 在这个时候,被内存映射的文件实际上成了一个分页交换文件。
|
||||
|
||||
当发生缺页中断时,如果当前内存中并没有空闲的页面,操作系统就必须在内存选择一个页面将其移出内存,以便为即将调入的页面让出空间。用来选择淘汰哪一页的规则叫做页面置换算法,我们可以把页面置换算法看成是淘汰页面的规则。
|
||||
|
||||
- **OPT 页面置换算法(最佳页面置换算法)** :理想情况,不可能实现,一般作为衡量其他置换算法的方法。
|
||||
- **FIFO 页面置换算法(先进先出页面置换算法)** : 总是淘汰最先进入内存的页面,即选择在内存中驻留时间最久的页面进行淘汰。
|
||||
- **LRU 页面置换算法(最近未使用页面置换算法)** :LRU(Least Currently Used)算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间 T,当须淘汰一个页面时,选择现有页面中其 T 值最大的,即最近最久未使用的页面予以淘汰。
|
||||
- **LFU 页面置换算法(最少使用页面排序算法)** : LFU(Least Frequently Used)算法会让系统维护一个按最近一次访问时间排序的页面链表,链表首节点是最近刚刚使用过的页面,链表尾节点是最久未使用的页面。访问内存时,找到相应页面,并把它移到链表之首。缺页时,置换链表尾节点的页面。也就是说内存内使用越频繁的页面,被保留的时间也相对越长。
|
||||
|
||||
## Reference
|
||||
|
||||
- 《计算机操作系统—汤小丹》第四版
|
||||
- [《深入理解计算机系统》](https://book.douban.com/subject/1230413/)
|
||||
- [https://zh.wikipedia.org/wiki/输入输出内存管理单元](https://zh.wikipedia.org/wiki/输入输出内存管理单元)
|
||||
- [https://baike.baidu.com/item/快表/19781679](https://baike.baidu.com/item/快表/19781679)
|
||||
- https://www.jianshu.com/p/1d47ed0b46d5
|
||||
- <https://www.studytonight.com/operating-system>
|
||||
- <https://www.geeksforgeeks.org/interprocess-communication-methods/>
|
||||
- <https://juejin.im/post/59f8691b51882534af254317>
|
||||
- 王道考研操作系统知识点整理: https://wizardforcel.gitbooks.io/wangdaokaoyan-os/content/13.html
|
@ -156,7 +156,7 @@
|
||||
5. Atomic 原子类: ① 介绍一下 Atomic 原子类;② JUC 包中的原子类是哪 4 类?;③ 讲讲 AtomicInteger 的使用;④ 能不能给我简单介绍一下 AtomicInteger 类的原理。
|
||||
6. AQS :① 简介;② 原理;③ AQS 常用组件。
|
||||
|
||||
## **step 8:分布式**
|
||||
### **step 9:分布式**
|
||||
|
||||
1. 学习 **Dubbo、Zookeeper来实现简单的分布式服务**
|
||||
2. **学习 Redis** 来提高访问速度,减少对 MySQL数据库的依赖;
|
||||
@ -170,7 +170,7 @@
|
||||
|
||||
> 继续深入学习的话,我们要了解Netty、JVM这些东西。
|
||||
|
||||
### step 9:深入学习
|
||||
### step 10:深入学习
|
||||
|
||||
可以再回来看一下多线程方面的知识,还可以利用业余时间学习一下 **[NIO](https://github.com/Snailclimb/JavaGuide#io "NIO")** 和 **Netty** ,这样简历上也可以多点东西。如果想去大厂,**[JVM](https://github.com/Snailclimb/JavaGuide#jvm "JVM")** 的一些知识也是必学的(**Java 内存区域、虚拟机垃圾算法、虚拟垃圾收集器、JVM 内存管理**)推荐《深入理解 Java 虚拟机:JVM 高级特性与最佳实践(最新第二版》和《实战 Java 虚拟机》,如果嫌看书麻烦的话,你也可以看我整理的文档。
|
||||
|
||||
@ -178,7 +178,7 @@
|
||||
|
||||
> **微服务的概念庞大,技术种类也很多,但是目前大型互联网公司广泛采用的,**实话实话这些东西我不在行,自己没有真实做过微服务的项目。不过下面是我自己总结的一些关于微服务比价重要的知识,选学。
|
||||
|
||||
### step 10:微服务
|
||||
### step 11:微服务
|
||||
|
||||
这部分太多了,选择性学习。
|
||||
|
||||
@ -205,7 +205,7 @@ Spring Cloud Alibaba也是很值得学习的:
|
||||
4. **[seata](https://github.com/seata/seata "seata")** : Seata 是一种易于使用,高性能,基于 Java 的开源分布式事务解决方案。
|
||||
5. **[RocketMQ](https://github.com/apache/rocketmq "RocketMQ")** :阿里巴巴开源的一款高性能、高吞吐量的分布式消息中间件。
|
||||
|
||||
## 总结
|
||||
### 总结
|
||||
|
||||
我上面主要概括一下每一步要学习的内容,对学习规划有一个建议。知道要学什么之后,如何去学呢?我觉得学习每个知识点可以考虑这样去入手:
|
||||
|
||||
|
141
docs/system-design/restful-api.md
Normal file
141
docs/system-design/restful-api.md
Normal file
@ -0,0 +1,141 @@
|
||||

|
||||
|
||||
大家好我是 Guide 哥!这是我的第 **210** 篇优质原创!这篇文章主要分享了后端程序员必备的 RestFul API 相关的知识。
|
||||
|
||||
RESTful API 是每个程序员都应该了解并掌握的基本知识,我们在开发过程中设计 API 的时候也应该至少要满足 RESTful API 的最基本的要求(比如接口中尽量使用名词,使用 POST 请求创建资源,DELETE 请求删除资源等等,示例:`GET /notes/id`:获取某个指定 id 的笔记的信息)。
|
||||
|
||||
如果你看 RESTful API 相关的文章的话一般都比较晦涩难懂,包括我下面的文章也会提到一些概念性的东西。但是,实际上我们平时开发用到的 RESTful API 的知识非常简单也很容易概括!举个例子,如果我给你下面两个 url 你是不是立马能知道它们是干什么的!这就是 RESTful API 的强大之处!
|
||||
|
||||
**RESTful API 可以你看到 url + http method 就知道这个 url 是干什么的,让你看到了 http 状态码(status code)就知道请求结果如何。**
|
||||
|
||||
```
|
||||
GET /classes:列出所有班级
|
||||
POST /classes:新建一个班级
|
||||
```
|
||||
|
||||
下面的内容只是介绍了我觉得关于 RESTful API 比较重要的一些东西,欢迎补充。
|
||||
|
||||
### 一、重要概念
|
||||
|
||||
REST,即 **REpresentational State Transfer** 的缩写。这个词组的翻译过来就是"表现层状态转化"。这样理解起来甚是晦涩,实际上 REST 的全称是 **Resource Representational State Transfe** ,直白地翻译过来就是 **“资源”在网络传输中以某种“表现形式”进行“状态转移”** 。如果还是不能继续理解,请继续往下看,相信下面的讲解一定能让你理解到底啥是 REST 。
|
||||
|
||||
我们分别对上面涉及到的概念进行解读,以便加深理解,不过实际上你不需要搞懂下面这些概念,也能看懂我下一部分要介绍到的内容。不过,为了更好地能跟别人扯扯 “RESTful API”我建议你还是要好好理解一下!
|
||||
|
||||
- **资源(Resource)** :我们可以把真实的对象数据称为资源。一个资源既可以是一个集合,也可以是单个个体。比如我们的班级 classes 是代表一个集合形式的资源,而特定的 class 代表单个个体资源。每一种资源都有特定的 URI(统一资源定位符)与之对应,如果我们需要获取这个资源,访问这个 URI 就可以了,比如获取特定的班级:`/class/12`。另外,资源也可以包含子资源,比如 `/classes/classId/teachers`:列出某个指定班级的所有老师的信息
|
||||
- **表现形式(Representational)**:"资源"是一种信息实体,它可以有多种外在表现形式。我们把"资源"具体呈现出来的形式比如 json,xml,image,txt 等等叫做它的"表现层/表现形式"。
|
||||
- **状态转移(State Transfer)** :大家第一眼看到这个词语一定会很懵逼?内心 BB:这尼玛是啥啊? 大白话来说 REST 中的状态转移更多地描述的服务器端资源的状态,比如你通过增删改查(通过 HTTP 动词实现)引起资源状态的改变。ps:互联网通信协议 HTTP 协议,是一个无状态协议,所有的资源状态都保存在服务器端。
|
||||
|
||||
综合上面的解释,我们总结一下什么是 RESTful 架构:
|
||||
|
||||
1. 每一个 URI 代表一种资源;
|
||||
2. 客户端和服务器之间,传递这种资源的某种表现形式比如 json,xml,image,txt 等等;
|
||||
3. 客户端通过特定的 HTTP 动词,对服务器端资源进行操作,实现"表现层状态转化"。
|
||||
|
||||
### 二、REST 接口规范
|
||||
|
||||
#### 1、动作
|
||||
|
||||
- GET :请求从服务器获取特定资源。举个例子:`GET /classes`(获取所有班级)
|
||||
- POST :在服务器上创建一个新的资源。举个例子:`POST /classes`(创建班级)
|
||||
- PUT :更新服务器上的资源(客户端提供更新后的整个资源)。举个例子:`PUT /classes/12`(更新编号为 12 的班级)
|
||||
- DELETE :从服务器删除特定的资源。举个例子:`DELETE /classes/12`(删除编号为 12 的班级)
|
||||
- PATCH :更新服务器上的资源(客户端提供更改的属性,可以看做作是部分更新),使用的比较少,这里就不举例子了。
|
||||
|
||||
#### 2、路径(接口命名)
|
||||
|
||||
路径又称"终点"(endpoint),表示 API 的具体网址。实际开发中常见的规范如下:
|
||||
|
||||
1. **网址中不能有动词,只能有名词,API 中的名词也应该使用复数。** 因为 REST 中的资源往往和数据库中的表对应,而数据库中的表都是同种记录的"集合"(collection)。**如果 API 调用并不涉及资源(如计算,翻译等操作)的话,可以用动词。** 比如:`GET /calculate?param1=11¶m2=33`
|
||||
2. 不用大写字母,建议用中杠 - 不用下杠 \_ 比如邀请码写成 `invitation-code`而不是 ~~invitation_code~~
|
||||
|
||||
Talk is cheap!来举个实际的例子来说明一下吧!现在有这样一个 API 提供班级(class)的信息,还包括班级中的学生和教师的信息,则它的路径应该设计成下面这样。
|
||||
|
||||
**接口尽量使用名词,禁止使用动词。** 下面是一些例子:
|
||||
|
||||
```
|
||||
GET /classes:列出所有班级
|
||||
POST /classes:新建一个班级
|
||||
GET /classes/classId:获取某个指定班级的信息
|
||||
PUT /classes/classId:更新某个指定班级的信息(一般倾向整体更新)
|
||||
PATCH /classes/classId:更新某个指定班级的信息(一般倾向部分更新)
|
||||
DELETE /classes/classId:删除某个班级
|
||||
GET /classes/classId/teachers:列出某个指定班级的所有老师的信息
|
||||
GET /classes/classId/students:列出某个指定班级的所有学生的信息
|
||||
DELETE classes/classId/teachers/ID:删除某个指定班级下的指定的老师的信息
|
||||
```
|
||||
|
||||
反例:
|
||||
|
||||
```
|
||||
/getAllclasses
|
||||
/createNewclass
|
||||
/deleteAllActiveclasses
|
||||
```
|
||||
|
||||
理清资源的层次结构,比如业务针对的范围是学校,那么学校会是一级资源:`/schools`,老师: `/schools/teachers`,学生: `/schools/students` 就是二级资源。
|
||||
|
||||
#### 3、过滤信息(Filtering)
|
||||
|
||||
如果我们在查询的时候需要添加特定条件的话,建议使用 url 参数的形式。比如我们要查询 state 状态为 active 并且 name 为 guidegege 的班级:
|
||||
|
||||
```
|
||||
GET /classes?state=active&name=guidegege
|
||||
```
|
||||
|
||||
比如我们要实现分页查询:
|
||||
|
||||
```
|
||||
GET /classes?page=1&size=10 //指定第1页,每页10个数据
|
||||
```
|
||||
|
||||
#### 4、状态码(Status Codes)
|
||||
|
||||
**状态码范围:**
|
||||
|
||||
| 2xx:成功 | 3xx:重定向 | 4xx:客户端错误 | 5xx:服务器错误 |
|
||||
| --------- | -------------- | ---------------- | --------------- |
|
||||
| 200 成功 | 301 永久重定向 | 400 错误请求 | 500 服务器错误 |
|
||||
| 201 创建 | 304 资源未修改 | 401 未授权 | 502 网关错误 |
|
||||
| | | 403 禁止访问 | 504 网关超时 |
|
||||
| | | 404 未找到 | |
|
||||
| | | 405 请求方法不对 | |
|
||||
|
||||
|
||||
### 三 HATEOAS
|
||||
|
||||
> **RESTful 的极致是 hateoas ,但是这个基本不会在实际项目中用到。**
|
||||
|
||||
上面是 RESTful API 最基本的东西,也是我们平时开发过程中最容易实践到的。实际上,RESTful API 最好做到 Hypermedia,即返回结果中提供链接,连向其他 API 方法,使得用户不查文档,也知道下一步应该做什么。
|
||||
|
||||
比如,当用户向 api.example.com 的根目录发出请求,会得到这样一个文档。
|
||||
|
||||
```javascript
|
||||
{"link": {
|
||||
"rel": "collection https://www.example.com/classes",
|
||||
"href": "https://api.example.com/classes",
|
||||
"title": "List of classes",
|
||||
"type": "application/vnd.yourformat+json"
|
||||
}}
|
||||
```
|
||||
|
||||
上面代码表示,文档中有一个 link 属性,用户读取这个属性就知道下一步该调用什么 API 了。rel 表示这个 API 与当前网址的关系(collection 关系,并给出该 collection 的网址),href 表示 API 的路径,title 表示 API 的标题,type 表示返回类型 Hypermedia API 的设计被称为[HATEOAS](http://en.wikipedia.org/wiki/HATEOAS)。
|
||||
|
||||
在 Spring 中有一个叫做 HATEOAS 的 API 库,通过它我们可以更轻松的创建除符合 HATEOAS 设计的 API。
|
||||
|
||||
### 文章推荐
|
||||
|
||||
**RESTful API 介绍:**
|
||||
|
||||
- [RESTful API Tutorial](https://RESTfulapi.net/)
|
||||
- [RESTful API 最佳指南](http://www.ruanyifeng.com/blog/2014/05/RESTful_api.html)(阮一峰,这篇文章大部分内容来源)
|
||||
- [[译] RESTful API 设计最佳实践](https://juejin.im/entry/59e460c951882542f578f2f0)
|
||||
- [那些年,我们一起误解过的 REST](https://segmentfault.com/a/1190000016313947)
|
||||
- [Testing RESTful Services in Java: Best Practices](https://phauer.com/2016/testing-RESTful-services-java-best-practices/)
|
||||
|
||||
**Spring 中使用 HATEOAS:**
|
||||
|
||||
- [在 Spring Boot 中使用 HATEOAS](a)
|
||||
- [Building REST services with Spring](https://spring.io/guides/tutorials/classmarks/) (Spring 官网 )
|
||||
- [An Intro to Spring HATEOAS](https://www.baeldung.com/spring-hateoas-tutorial) (by [baeldung](https://www.baeldung.com/author/baeldung/))
|
||||
- [spring-hateoas-examples](https://github.com/spring-projects/spring-hateoas-examples/tree/master/hypermedia)
|
||||
- [Spring HATEOAS](https://spring.io/projects/spring-hateoas#learn) (Spring 官网 )
|
@ -1,14 +1,4 @@
|
||||
## 题外话
|
||||
|
||||
先来点题外话吧!如果想看正文的话可以直接看滑到下面正文。
|
||||
|
||||
来三亚旅行也有几天了,总体感觉很不错,后天就要返航回家了。偶尔出来散散心真的挺不错,放松一下自己的心情,感受一下大自然。个人感觉冬天的时候来三亚度假还是很不错的选择,但是不要 1 月份的时候过来(差不多就过年那会儿),那时候属于大旺季,各种东西特别是住宿都贵很多。而且,那时候的机票也很贵。很多人觉得来三亚会花很多钱,实际上你不是在大旺季来的话,花不了太多钱。我和我女朋友在这边玩的几天住的酒店都还不错(干净最重要!),价格差不多都在 200元左右,有一天去西岛和天涯海角那边住的全海景房间也才要 200多,不过过年那会儿可能会达到 1000+。
|
||||
|
||||
现在是晚上 7 点多,刚从外面玩耍完回来。女朋友拿着我的手机拼着图片,我一个只能玩玩电脑。这篇文章很早就想写了,毕竟不费什么事,所以逞着晚上有空写一下。
|
||||
|
||||
如果有读者想看去三亚拍的美照包括我和我女朋友的合照,可以在评论区扣个 “想看”,我可以整篇推文分享一下。
|
||||
|
||||
## 正文
|
||||
|
||||
> 下面的 10 个项目还是很推荐的!JS 的项目占比挺大,其他基本都是文档/学习类型的仓库。
|
||||
|
||||
|
BIN
media/pictures/logo.png
Normal file
BIN
media/pictures/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
Before Width: | Height: | Size: 2.0 MiB |
Loading…
x
Reference in New Issue
Block a user