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

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


  • 首页

  • 归档

  • 搜索

Linux系统资源监控脚本

发表于 2021-11-29

这是我参与11月更文挑战的第29天,活动详情查看:2021最后一次更文挑战

本文涉及一些在Linux系统中进行资源监控的方法,其目的是为了在实际工作中监控系统的资源使用情况。

问题提出

自上个月开始,断断续续排查了几个疑难 bug,都是在生产环境发现的,兹事体大,必须解决,但限于能力和环境,有的bug并没有即时解决,但总算在一定期限内完成任务,由于时间紧,当时并没有详细记录过程,但过后回想,还是要做些经验总结。本文从进程占用内存脚本开始,对 Linux 资源使用情况的监控展开研究,以方便日后使用。

监控CPU和内存占用最多的十个进程

Linux 系统一般使用 ps 命令查看进程资源使用情况,其输出结果如下:

1
2
3
4
5
sql复制代码$ ps aux | head -n 4
USER       PID %CPU %MEM   VSZ   RSS TTY     STAT START   TIME COMMAND
root         1 0.1 0.1 191136 4172 ?       Ss   14:51   0:02 /usr/lib/systemd/systemd --switched-root --system --deserialize 22
root         2 0.0 0.0     0     0 ?       S   14:51   0:00 [kthreadd]
root         3 0.1 0.0     0     0 ?       S   14:51   0:02 [ksoftirqd/0]

从输出结果可以看出有CPU、内存、进程ID、进程名称等信息,其中CPU和内存的数值为百分比。

由此可知核心命令描述如下:

通过 ps 命令查看进程CPU、内存占用情况,再分别按CPU、内存数值倒序排序,再取前面10个即可。

查看内存占用最多的十个进程命令如下:

1
bash复制代码ps aux | grep -v PID | sort -rn -k +4 | head -n10

解释:

  • grep -v PID 为去掉带 PID 字样的一行,此处表示标题。
  • sort -rn -k +4,-k +4 表示按第4列(即内存)内容排序,-rn 表示按数值倒序排序。
  • head -n10表示取前面的10个,亦即占用内存最多的10个进程。

类似地,对于CPU占用情况,只需要将上面+4改为+3即可,因CPU占用百分比位于第三列。

监控指定进程的内存

该脚本是为了应付之前某进程内存泄漏的问题的,完整脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
bash复制代码#!/bin/bash
​
PROCESS=foobar
LOGFILE="memlog.txt"
​
#!/bin/bash
# for ((;;))
# while [ 1 ]
while :
do
   PID=$(ps aux | grep $PROCESS | grep -v 'grep' | awk '{print $2;}')
   if [ "$PID" != "" ]; then
       echo `date +'%Y-%m-%d %H:%M:%S'` >> "$LOGFILE"
       MEM=$(ps aux | grep $PROCESS | grep -v 'grep' | awk '{print $5;}')
       MEM="ps mem: "$MEM
       RSS=$(cat /proc/$PID/status | grep RSS)
       echo $PROCESS $MEM $RSS >> "$LOGFILE"
   fi
​
   echo "System memory info: " $(cat /proc/meminfo | grep -E 'MemTotal|MemFree|Cached' |grep -v SwapCached|xargs) >> "$LOGFILE"
   echo "-------------" >> "$LOGFILE"
​
   sleep 5
done

注释如下:

  • 由于要监控某一进程,因此需先指定进程名称。
  • 再从 ps 列表中查询其 PID 和 内存
  • 再由 PID 查询其常驻内存大小,该值在/proc/<PID>/status文件中。

脚本每隔5秒输出一次信息,有时内存泄漏量较小,一般观察数小时、半天可以得到结果。确定存在内存泄漏情况时,再深入代码排查。该脚本对占用内存大小并不做严格计算,因为只是观察内存使用是否稳定,或是否持续增长,是否有跳变。就当时监控情况下,该进程下载文件时内存占用突增,过后恢复稳定状态,但保持几分钟十多KB增长的态势,因此判定内存泄漏。

判断磁盘占用情况

本脚本用于磁盘空间使用情况的监控。机制如下:

指定监控目录,指定空间使用百分比的阈值,当超过时,再删除。根据经验,一般超过 85%~90%时即可删除。该脚本可使用定时任务执行,根据磁盘使用空间,可半天或一天执行一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
bash复制代码#!/bin/sh
​
#前提:已挂载目录
mount_dir=/mnt/hgfs
percent_in=85
file_del=1000
​
count_del=10
​
# 如果不存在目录,退出
if [ ! -d $mount_dir ]; then
  echo $mount_dir "not found, quit"
  exit
fi
​
percent=`df -h | grep $mount_dir | awk '{print $5}' | tr -d '%'`
dev_file=`df -h | grep $mount_dir | awk '{print $1}'`
file_count=`ls -l $mount_dir | wc -l`
​
echo $percent $percent_in
# 当空间占用百分比大于某个指定值时,删除目录前指定的数量
if [ $percent -ge $percent_in ];then
  echo "need to remove file! occupy" $percent"%" "of" $dev_file
  #cd $mount_dir
  #file=`ls | sort | head -$file_del`
  #rm $file
  #cd -
else
  echo "no need to remove file"
fi
​
# 按文件数量判断,用于文件体积小但数量大的情况,因其会占用文件索引
#if [ $file_count -ge $count_del ];then
#   echo "need to remove file! occupy total" $count_del "files of" $dev_file
  #cd $mount_dir
  #file=`ls | sort | head -$file_del`
  #rm $file
  #cd -
#else
#   echo "no need to remove file"
#fi
​
#file=`ls | sort | head -$file_del`
#echo $file
​
echo "comand complete at"
date
echo "======================================"

进阶

可在脚本中添加邮件通知功能(可用其它程序实现),当出现异常或有重大事件发生时——如CPU占用达100%或删除文件时,不过目前暂未有时间着手。

本文转载自: 掘金

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

一篇文章上手Fabric CA的使用 1概览 Fabric

发表于 2021-11-29

Fabric CA是Fabric中的证书颁发机构

他提供的功能有:

  • 身份登记,或者连接到LDAP作为用户登记
  • 颁发注册证书
  • 证书的续订和撤销

Hyperlowger Fabric CA由服务器和客户组件组成,如本文档稍后所述。

1.概览

下图说明了Fabric CA server如何在Fabric架构中发挥作用

image.png

和Fabric Server有两种交互方式,一种是通过Fabric CA client就是客户端,一种是Fabric SDK.这两种方式都是通过REST APIs.可以通过fabric-ca/swagger/swagger-fabric-ca.json来查看接口的swagger文档。

在一个集群中所有的Fabric CA server共享相同的数据库为了跟踪身份和证书,如果开启了LDAP,身份信息将保持到LDAP上而不是数据库中。

一个服务器可能包括多个CA颁发机构,每个CA要么是根CA要么是中间CA.每个中间CA都有一个父CA,这个父CA要么是一个根CA要么也是一个中间CA

本地启动一个fabric ca server

fabric-ca-server start -b admin:adminpw

Fabric CA 提供了三种方式配置设置Fabric CA客户端服务器。优先顺序是:

  • 1.命令行选项
  • 2.环境变量
  • 3.配置文件

在本文档的其余部分中,我们指的是更改配置文件。但是,可以通过环境变量或CLI标志覆盖配置文件更改。

Fabric CA Server

您可以在开始之前初始化Fabric CA服务器。这为您提供了一个机会,为您生成默认配置文件,可以在启动服务器之前进行审核和自定义。

Fabric CA Server的主目录如下:

  • 如果设置了-home命令行选项,请使用其值
  • 否则,如果设置了fabric_ca_server_home环境变量,请使用其值
  • 否则,如果设置Fabric_CA_HOME环境变量,请使用其值
  • 否则,如果设置CA_CFG_PATH环境变量,请使用其值
  • 否则,使用当前工作目录

对于此服务器部分的其余部分,我们假设您已设置FABRIC_CA_HOME为$HOME/fabric-ca/server.

下面的说明假定服务器配置文件存在于服务器的主目录中。

初始化服务器

1
csharp复制代码fabric-ca-server init -b admin:adminpw

启动Fabric CA Server需要至少一个引导身份;此身份是服务器管理员。

服务器配置文件包含可以配置的证书签名请求(CSR)部分。以下是示例CSR。

1
2
3
4
5
6
7
8
9
10
11
12
13
yaml复制代码cn: fabric-ca-server
names:
- C: US
ST: "North Carolina"
L:
O: Hyperledger
OU: Fabric
hosts:
- host1.example.com
- localhost
ca:
expiry: 131400h
pathlength: 1

上面的所有字段都属于X.509签名密钥和证书,由命令fabric-ca-server init产生。这对应于服务器配置文件中的ca.certfile和ca.keyfile文件。这些字段如下:

  • CN是常见名称
  • O是组织名称
  • OU 是组织单位
  • L 是位置或城市
  • ST 是国家
  • C是国家

如果需要CSR的自定义值,则可以自定义配置文件,删除ca.certfile和ca.keyfile配置项指定的文件,然后再次运行fabric-ca-server init -b admin:adminpw命令。

fabric-ca-server init命令将产生一套自签名的CA证书除非你指定-u <parent-fabric-ca-server-URL>选项。如果你指定了-u选项,那么CA证书将被父CA Server签名。为了对父Fabric CA服务器进行身份验证,URL形式必须是<scheme>://<enrollmentID>:<secret>@<host>:<port>,其中<enrollmentID> 和 <secret>对应于身份hf.IntermediateCA属性值设置为true.fabric-ca-server init命令将同时产生一个默认的配置文件名为fabric-ca-server-config.yaml在服务器的主目录下。

如果您希望Fabric CA Server使用您提供的CA签名证书和key文件,则必须将文件放在CA.Certfile和CA.KEYFILE所引用的位置。这两个文件都必须是PEM编码,不得加密。更具体地说,CA证书文件的内容必须以-----BEGIN CERTIFICATE-----和key文件的内容必须以-----BEGIN PRIVATE KEY----- - 而不是-----BEGIN ENCRYPTED PRIVATE KEY-----

可以自定义CSR以生成支持椭圆曲线(ECDSA)的X.509证书和密钥。以下设置是具有曲线prime256v1和签名算法ecdsa-with-SHA256的椭圆曲线数字签名算法(ECDSA)实现的示例

1
2
3
yaml复制代码key:
algo: ecdsa
size: 256

算法和密钥大小的选择基于安全需求。

启动服务器

1
ruby复制代码fabric-ca-server start -b <admin>:<adminpw>

如果先前未初始化服务器,则它将在第一次启动时初始化。在此初始化期间,如果它们尚不存在,则服务器将生成CA-Cert.pem和CA-key.pem文件,如果它不存在,还将创建默认配置文件。

为了让CA 服务器监听https而不是http,把tls.enabled设置为true.

限制相同秘密(或密码)的次数可用于注册,将registry.maxenrollments消息设置为适当的值。如果将值设置为1,则Fabric CA Server允许特定注册ID的密码只能使用一次。如果将值设置为-1,则Fabric CA Server不会限制秘密可以重用注册的次数。默认值为-1。将值设置为0,Fabric CA Server将禁用所有身份的注册,并不允许注册身份。

Fabric CA服务器现在应该在端口7054上收听。

默认情况下,Fabric-CA Server由单个默认CA组成。但是,可以使用cafiles或cacount配置选项添加附加的CA到单个服务器。每个附加的CA都有自己的主目录

1
sql复制代码fabric-ca-server start -b admin:adminpw --cacount 2

Fabric CA client

Fabric CA客户端的主目录如下:

  • 如果设置了-home命令行选项,请使用其值
  • 否则,如果设置fabric_ca_client_home环境变量,请使用其值
  • 否则,如果设置了Fabric_ca_home环境变量,请使用其值
  • 否则,如果设置CA_CFG_PATH环境变量,请使用其值
  • 否则,请使用$ home / .fabric-ca-client

下面的说明假定客户端配置文件存在于客户端的主目录中。

登记引导身份

这一步非常重要,不能跳过,否则你在注册其他信息的时候就会

1
2
3
4
5
6
7
python复制代码➜  ./fabric-ca-client register --id.name peer2 --id.type peer --id.affiliation org2.department1 --id.secret peer2pw

2021/11/29 15:16:30 [INFO] Configuration file location: /Users/xxx/workspace/zjwfa/fabric-ca-client/clients/admin/fabric-ca-client-config.yaml

2021/11/29 15:16:30 [ERROR] Enrollment check failed: Idemix enrollment information does not exist

Error: Enrollment information does not exist. Please execute enroll command first. Example: fabric-ca-client enroll -u http://user:userpw@serverAddr:serverPort

首先,如果需要,请在客户端配置文件中自定义CSR(证书签名请求)部分。请注意,CSR.CN字段必须设置为引导标识的ID。默认CSR值如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
yaml复制代码csr:
cn: <<enrollment ID>>
key:
algo: ecdsa
size: 256
names:
- C: US
ST: North Carolina
L:
O: Hyperledger Fabric
OU: Fabric CA
hosts:
- <<hostname of the fabric-ca-client>>
ca:
pathlen:
pathlenzero:
expiry:

之后,运行fabric-ca-client enroll命令登记身份。例如下面的命令注册其ID为Admin和Password是通过调用在7054端口本地运行的Fabric CA Server的AdminPW。

1
2
bash复制代码export FABRIC_CA_CLIENT_HOME=$HOME/fabric-ca/clients/admin
fabric-ca-client enroll -u http://admin:adminpw@localhost:7054

登记命令存储登记证书,相关的私钥,和CA证书链PEM文件在Fabric CA客户端子目录的msp目录下。你可以通过消息看到PEM文件存放的位置

注册一个新的身份

这里的实例是注册一个peer身份。

1
python复制代码fabric-ca-client register --id.name peer1 --id.type peer --id.affiliation org1.department1 --id.secret peer1pw

注意,这个的--id.affiliation指定的值必须是CA server中配置文件中存在的值,否则就会报错

1
2
3
4
5
python复制代码fabric-ca-client register --id.name peer4 --id.type peer --id.affiliation org6.department1 --id.secret peer4pw

2021/11/29 15:31:42 [INFO] Configuration file location: /Users/zhaojunwei/workspace/zjwfa/fabric-ca-client/clients/admin/fabric-ca-client-config.yaml

Error: Response from server: Error Code: 71 - Authorization failure

这是因为我们的CA server配置文件中只有下面的组织和部门

1
2
3
4
5
6
7
8
9
10
11
yaml复制代码affiliations:

   org1:

      - department1

      - department2

   org2:

      - department1

登记一个peer身份

1
2
bash复制代码export FABRIC_CA_CLIENT_HOME=$HOME/fabric-ca/clients/peer1
fabric-ca-client enroll -u http://peer1:peer1pw@localhost:7054 -M $FABRIC_CA_CLIENT_HOME/msp

重新登记

1
2
bash复制代码export FABRIC_CA_CLIENT_HOME=$HOME/fabric-ca/clients/peer1
fabric-ca-client reenroll

撤销一个证书或者身份

1
2
bash复制代码export FABRIC_CA_CLIENT_HOME=$HOME/fabric-ca/clients/admin
fabric-ca-client revoke -e peer1

本文转载自: 掘金

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

函数计算 GB 镜像秒级启动:下一代软硬件架构协同优化揭秘

发表于 2021-11-29

简介:本文将介绍借助函数计算下一代 IaaS 底座神龙裸金属和安全容器,进一步降低绝对延迟且能够大幅降低冷启动频率。

作者:修踪

背景

函数计算在 2020 年 8 月创新地提供了容器镜像的函数部署方式。AWS Lambda 在 2020 年 12 月 Re-Invent,国内其他 FaaS 提供商在 2021 年 6 月也相继宣布了 FaaS 支持容器的重磅功能。冷启动一直都是 FaaS 的痛点,引入比代码压缩包大几十倍的容器镜像后冷启动恶化便成为开发者最大的担忧。

函数计算在支持容器镜像的设计阶段就决定要让开发者像使用代码包(秒级弹性能力)一样的体验使用镜像,既要易用性也要保持 FaaS 自身的极致弹性,免除用户的纠结和取舍。理想的用户体验是函数调用几乎感觉不到镜像数据远程传输带来的延迟额外消耗。

优化镜像加速冷启动大致分为两种做法:降低绝对延迟和降低冷启动概率。自容器镜像上线以来我们已经通过镜像加速技术,分阶段降低了绝对延迟。本文在此基础上,介绍借助函数计算下一代 IaaS 底座神龙裸金属和安全容器,进一步降低绝对延迟且能够大幅降低冷启动频率。

优化历程

(以某一镜像为例)

第一代架构:ECS 虚构机

第一阶段(2021 年 3 月):按需加载,减少数据传输

过去的问题在于启动镜像前全量拉取镜像内部数据,导致无用的镜像数据也会被完整下载而占用了过多的准备时间。于是我们最初的优化方向是尽量忽略无用的镜像数据,达到按需加载。为此,我们通过镜像加速技术,省略掉了拉取无用数据的时间,实现了函数计算自定义镜像冷启动从分钟级到秒级提升的相关技术细节。

第二阶段(2021 年 6 月):记录容器实例启动 I/O 轨迹,在后续实例启动中提前预取镜像数据

我们发现,函数实例在容器启动和初始化阶段,I/O 数据访问模式高度一致。根据 FaaS 平台基于应用运行模式调度资源的特点,我们在函数实例首次启动时记录了 I/O 轨迹的脱敏数据,在后续的实例启动时,将轨迹数据作为提示,提前预取镜像数据到本地,进一步减小了冷启动延时。

上述两种加速优化虽然大幅减小了冷启动绝对延迟,但由于传统 ECS VM 在闲置一段时间后就会被回收,再次启动新机器时就会重新触发冷启动。于是,如何减少冷启动频次便成为了下一阶段重点攻克的题目之一。

下一代架构:弹性裸金属服务器(神龙)+microVM

在设计下一代架构时我们不仅考虑解决冷启动频次问题,也同样注意到缓存对于启动时延的影响。于是我们创新性的发明了 Serverless Caching,根据不同的存储服务特点构建数据驱动、智能高效的缓存体系,实现软硬件协同优化,将 Custom Container 体验进一步提升。函数计算后台神龙的更迭时间远大于 ECS VM 的闲置回收时间,对于用户侧而言,热启动频率大幅提升,在冷启动后,缓存会持续保留在神龙机器上,缓存命中率可达 90% 以上。

对比 ECS 虚拟机,神龙裸金属加上微型虚拟机的架构为镜像加速带来了更多的优化空间:

  • 减小回源带宽压力并且减少重复数据存储。比起 ECS VM 来,同时几千实例启动,对于镜像仓库的读放大和磁盘存储空间的写放大降低至少两个数量级。
  • 虚拟机级别的安全隔离使得函数计算组件可以安全地组成可用区级别缓存网络,速度传输速度甚至优于云盘。

函数计算 Custom Container 登陆神龙的同时也提高了资源利用率,降低成本,这对用户和服务端维护是双赢。

Serverless Caching 的架构则可以在不增加资源使用成本的同时提供更多的优化潜力。

(L1~L4 为不同级别缓存,距离和延迟从小到大)

横向对比

到目前为止,我们已经将镜像加速优化到了较高的水准。我们在函数计算的公开用例里面挑选了 4 个典型的镜像并将它们适配至国内外几个大型云厂商(名称以厂商 A、厂商 B 代替)进行横向对比,每间隔 3 小时调用上述镜像,重复数次,我们得到了以下结果:

1、AI 在线推理-猫狗识别

该镜像包含了基于 TensorFlow 深度学习框架的图像识别应用。阿里云函数计算和厂商 A 都能正常运行,但厂商 A 性能较差。厂商 B 则无法正常运行。下图中阿里云函数计算和厂商 A 的延时数据包含镜像拉取,容器启动,执行推理运算端对端的延时,而厂商 B 的数据只是拉取镜像部分的延时,都已经是最慢。FC 相对稳定,可以看出函数计算在 CPU 消耗型如 AI 推理方面有着更大优势。

以云盘热启动为基准(灰色),对比各个厂商的额外开销(彩色)

2、Python Flask Web Service

此镜像为常见的网络服务,内部使用 Python 搭配 Flask 服务框架。此镜像的作用旨在测试不同云产品是否有能力完成高效按需加载。FC 与厂商 A 均有波动但后者的波动最为明显。

以云盘热启动为基准(灰色),对比各个厂商的额外开销(彩色)

3、Python 机器学习运算

镜像内同样是 Python 运行环境,可以看出各个厂商依旧保持着各自的特性,厂商 B 全量下载,厂商 A 部分请求有优化但不稳定。

以云盘热启动为基准(灰色),对比各个厂商的额外开销(彩色)

4、Cypress Headless Chrome

此镜像包含无头浏览器测试流程,厂商 A 由于编程模型限制和运行环境不兼容无法运行。而厂商 B 过慢只能在规定时间内耗时 71.1 秒完成应用初始化。不难看出函数计算在重 I/O 的镜像方面依然有着不错的表现。

以云盘热启动为基准(灰色),对比各个厂商的额外开销(彩色),绿色部位为优于基准线的端到端耗时

推荐最佳实践

支持容器技术是 FaaS 的必备特质,容器增加了可移植性和交付敏捷性,而云服务减轻了运维与闲置成本、提供了弹性扩缩容能力。自定义镜像与函数计算结合最直接的解决了用户为云厂商定制化地移植大容量业务逻辑带来的困扰。

FaaS 运行容器时需要尽可能消除额外开销,使用户体验与本地运行场景相近。稳定快速的运行同样是优秀 FaaS 的标准,FC 提供了镜像加载优化的同时大大降低了冷启动频次为稳定快速的运行提供了保障。不仅如此,在应用的可移植方面更加需要做到平滑,不限制开发模式的同时也要尽量降低用户使用门槛。函数计算自定义镜像支持标准 HTTP 服务,自由配置可用端口,可读的同时也可写,提供多种工具链以及多元化的部署方案,无强制等待镜像准备完成时间,自带 HTTP 触发而不依赖其他云服务,支持自定义域名等一系列优质解决方案。

函数计算自定义镜像适用但不限于人工智能推理、大数据分析、游戏结算、在线课程教育、音视频处理等。推荐使用阿里云容器镜像服务企业版实例 ACR EE,自带镜像加速功能,省去使用 ACR 镜像时手动开启加速拉取和加速镜像准备的步骤。

AI/ML 在线推理

推理类计算依赖大体积底层训练框架以及大量的数据处理,普通的 AI 框架如 Tensorflow 的镜像可以轻松达到 GB 级,对 CPU 要求已经很高,要再满足扩缩容就更是挑战。函数计算自定义镜像可以很好的解决此类需求,用户只需直接使用底层训练框架镜像并与数据处理逻辑打包至新的镜像内便可以轻松省去更换运行环境所带来的移植开销,同时又可以满足弹性扩缩容带来的快速训练结果。歌曲喜好推理、图片 AI 识别分析等都可以无缝与函数计算衔接以达到弹性满足大量动态的在线推理请求。

轻量灵活 ETL

服务都依赖数据,而数据处理往往需要消耗大量资源来满足高效快速的数据变更请求。自定义镜像与其他函数计算运行时一样可以满足数据处理时的安全隔离,又同时保留了用户将数据处理部分的业务逻辑自由的打包成镜像的便捷能力。提供平滑迁移的同时满足了镜像启动的极低额外延时,满足了用户针对如数据库治理、万物物联等应用场景的安全,高效,弹性的数据处理需求。

游戏战斗结算

各类游戏内通常会设置日常任务等场景短时间集聚大量玩家同时需要战斗结算一类的数据处理,为了不让游戏玩家失去耐心,战斗数据校验通常需要在短短几秒内完成,且单个玩家的数据结算单位时间不能随着玩家数量增长而恶化。此类数据处理的业务逻辑通常繁杂且高度重复,将玩家数据处理逻辑打包至函数计算自定义镜像内便可以弹性满足短时间大量相似的玩家结算请求。

未来规划

优化函数计算自定义镜像的初衷就是要让用户感受不到容器镜像传输带来的额外延迟,给云原生开发者最极致的体验。优化不会停止,我们最终的目标是几乎消除容器镜像拉取的额外开销和大量扩容时镜像仓库成为瓶颈,极速伸缩。进一步完善 Serverless Caching 的同时 Custom Container 功能未来会帮助 Kubernetes 上的 Web 应用, Job 类工作负载无缝运行在函数计算。Kubernetes 负责处理常驻、流量稳定的工作负载,Serverless 服务分担波动明显的计算将逐渐成为云原生的最佳实践。

原文链接

本文为阿里云原创内容,未经允许不得转载。

本文转载自: 掘金

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

ZooKeeper分布式锁原理

发表于 2021-11-29

原理

  • ZooKeeper通过临时节点实现加锁,解锁,重入等操作。
  • 临时节点续期
    • ZooKeeper的节点是通过session心跳来续期的,比如客户端1创建了一个节点, 那么客户端1会和ZooKeeper服务器创建一个Session,通过这个Session的心跳来维持连接。如果ZooKeeper服务器长时间没收到这个Session的心跳,就认为这个Session过期了,也会把对应的节点删除。简单来说就是:当客户端宕机后,临时节点会随之消亡。****
    • 锁类型:公平锁,顺序抢占。来一个请求新建一个节点名称:node_01,node_02,node_03,01抢到锁后,02等待,01释放后,02抢锁,以此类推。
    • 到期处理:删除临时节点

代码

  • 上锁的入口是 acquire
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
java复制代码public boolean acquire(long time, TimeUnit unit) throws Exception {
return internalLock(time, unit);
}

// org.apache.curator.framework.recipes.locks.InterProcessMutex#internalLock
private boolean internalLock(long time, TimeUnit unit) throws Exception {
// 获取当前线程
Thread currentThread = Thread.currentThread();
// 尝试加锁
String lockPath = internals.attemptLock(time, unit, getLockNodeBytes());
// 加锁成功的话就放到threadData里
if ( lockPath != null ) {
LockData newLockData = new LockData(currentThread, lockPath);
threadData.put(currentThread, newLockData);
return true;
}
return false;
}

// 尝试加锁
String attemptLock(long time, TimeUnit unit, byte[] lockNodeBytes) throws Exception {
try {
// 创建这个锁
ourPath = driver.createsTheLock(client, path, localLockNodeBytes);
// 这个方法这里先不关心,是多个client抢锁时互斥阻塞等待的代码
hasTheLock = internalLockLoop(startMillis, millisToWait, ourPath);
}
catch ( KeeperException.NoNodeException e ) {
//...
}
return null;
}


// 创建这个锁
public String createsTheLock(CuratorFramework client, String path, byte[] lockNodeBytes) throws Exception {
return client
.create()
.creatingParentContainersIfNeeded()
.withProtection()
// 临时节点
.withMode(CreateMode.EPHEMERAL_SEQUENTIAL)
.forPath(path, lockNodeBytes);
}
  • 再来看看这个LockData里面是什么东西
1
2
3
4
5
6
7
8
arduino复制代码private static class LockData {
// 锁所属的线程
final Thread owningThread;
// 临时顺序节点的路径
final String lockPath;
// 重入次数 默认为1
final AtomicInteger lockCount = new AtomicInteger(1);
}
  • 互斥逻辑
    • 查找到所有临时顺序节点,然后按照编号从小到大排序
    • 判断当前客户端是不是 children 里的第一个,不是的话就代表不能加锁,那就计算出上一个节点编号,然后开启一个 Watcher 监听这个节点(刚计算出来的上一个节点)
    • wait() 。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
java复制代码private boolean internalLockLoop(long startMillis, Long millisToWait, String ourPath) throws Exception {
boolean haveTheLock = false;
try {
while ((client.getState() == CuratorFrameworkState.STARTED) && !haveTheLock) {
// 获取path下对应临时顺序节点,并按编号从小到大排序。底层采取的java.util.Comparator#compare来排序的
List<String> children = getSortedChildren();
// 获取当前线程创建的临时顺序节点名称
String sequenceNodeName = ourPath.substring(basePath.length() + 1);
// 这个方法底层就是判断当前节点编号是不是children里的第一个,是的话就能抢锁,不是的话就计算出上一个节点序号是谁,然后下面监听这个节点。(因为按照编号排序了,所以可以得出上一个节点是谁)
PredicateResults predicateResults = driver.getsTheLock(client, children, sequenceNodeName, maxLeases);
// 如果当前客户端就是持有锁的客户端,直接返回true
if (predicateResults.getsTheLock() ) {
haveTheLock = true;
} else {
// 如果没抢到锁,则监听上一个节点
String previousSequencePath = basePath + "/" + predicateResults.getPathToWatch();
synchronized(this) {
try {
// 监听器,watcher下面分析
client.getData().usingWatcher(watcher).forPath(previousSequencePath);
// 重点在这了,wait(),等待。也就是说没抢到锁的话就开启监听器然后wait()等待。
wait();
} catch ( KeeperException.NoNodeException e ) {}
}
}
}
}
return haveTheLock;
}
  • 解锁逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
csharp复制代码public void release() throws Exception {
// 获取当前线程
Thread currentThread = Thread.currentThread();
// 获取当前线程的锁对象,从ConcurrentHashMap里获取
LockData lockData = threadData.get(currentThread);
// 锁重入次数-1,然后看看是不是大于0,如果大于0那代表有锁重入,直接-1,不删除锁节点,因为没释放完全。
int newLockCount = lockData.lockCount.decrementAndGet();
if ( newLockCount > 0 ) {
return;
}
try {
// 如果锁重入次数为0了,那就释放锁
internals.releaseLock(lockData.lockPath);
}
finally {
// 释放完后从ConcurrentHashMap里移除
threadData.remove(currentThread);
}
}

整体流程

image.png

本文转载自: 掘金

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

Python爬虫实战,pyecharts模块,Python实

发表于 2021-11-29

「这是我参与11月更文挑战的第27天,活动详情查看:2021最后一次更文挑战」。

前言

利用Python实现中国地铁数据可视化。废话不多说。

让我们愉快地开始吧~

开发工具

Python版本: 3.6.4

相关模块:

requests模块;

wordcloud模块;

pandas模块;

numpy模块;

jieba模块;

pyecharts模块;

matplotlib模块;

以及一些Python自带的模块。

环境搭建

安装Python并添加到环境变量,pip安装需要的相关模块即可。

本次通过对地铁线路数据的获取,对城市分布情况数据进行可视化分析。

分析获取

地铁信息获取从高德地图上获取。

1.jpg

上面主要获取城市的「id」,「cityname」及「名称」。

用于拼接请求网址,进而获取地铁线路的具体信息。

2.jpg

找到请求信息,获取各个城市的地铁线路以及线路中站点详情。

获取数据

部分代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
python复制代码import json
import requests
from bs4 import BeautifulSoup

headers = {'user-agent': 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.132 Safari/537.36'}

def get_message(ID, cityname, name):
    """
    地铁线路信息获取
    """
    url = 'http://map.amap.com/service/subway?_1555502190153&srhdata=' + ID + '_drw_' + cityname + '.json'
    response = requests.get(url=url, headers=headers)
    html = response.text
    result = json.loads(html)
    for i in result['l']:
        for j in i['st']:
            # 判断是否含有地铁分线
            if len(i['la']) > 0:
                print(name, i['ln'] + '(' + i['la'] + ')', j['n'])
                with open('subway.csv', 'a+', encoding='gbk') as f:
                    f.write(name + ',' + i['ln'] + '(' + i['la'] + ')' + ',' + j['n'] + '\n')
            else:
                print(name, i['ln'], j['n'])
                with open('subway.csv', 'a+', encoding='gbk') as f:
                    f.write(name + ',' + i['ln'] + ',' + j['n'] + '\n')

获取数据结果展示

3.jpg

3541个地铁站点

数据可视化

先对数据进行清洗,去除重复的换乘站信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
python复制代码from wordcloud import WordCloud, ImageColorGenerator
from pyecharts import Line, Bar
import matplotlib.pyplot as plt
import pandas as pd
import numpy as np
import jieba

# 设置列名与数据对齐
pd.set_option('display.unicode.ambiguous_as_wide', True)
pd.set_option('display.unicode.east_asian_width', True)
# 显示10行
pd.set_option('display.max_rows', 10)
# 读取数据
df = pd.read_csv('subway.csv', header=None, names=['city', 'line', 'station'], encoding='gbk')
# 各个城市地铁线路情况
df_line = df.groupby(['city', 'line']).count().reset_index()
print(df_line)

通过城市及地铁线路进行分组,得到全国地铁线路总数。

4.png

183条地铁线路

1
2
3
4
5
6
7
python复制代码def create_map(df):
# 绘制地图
value = [i for i in df['line']]
attr = [i for i in df['city']]
geo = Geo("已开通地铁城市分布情况", title_pos='center', title_top='0', width=800, height=400, title_color="#fff", background_color="#404a59", )
geo.add("", attr, value, is_visualmap=True, visual_range=[0, 25], visual_text_color="#fff", symbol_size=15)
geo.render("已开通地铁城市分布情况.html")

已经开通地铁的城市数据,还有各个城市的地铁线路数。

5.png

32个城市开通地铁

城市分布情况

6.jpg

大部分都是省会城市,还有个别经济实力强的城市。

线路数量分布情况

7.png

可以看到大部分还是在「0-5」这个阶段的,当然最少为1条线。

1
2
python复制代码# 哪个城市哪条线路地铁站最多
print(df_line.sort_values(by='station', ascending=False))

哪个城市哪条线路地铁站最多

8.png

北京10号线第一,重庆3号线第二

9.png

10.png

去除重复换乘站数据

1
2
3
python复制代码# 去除重复换乘站的地铁数据
df_station = df.groupby(['city', 'station']).count().reset_index()
print(df_station)

包含3034个地铁站

减少了近400个地铁站

11.png

接下来看一下哪个城市地铁站最多

1
2
python复制代码# 统计每个城市包含地铁站数(已去除重复换乘站)
print(df_station.groupby(['city']).count().reset_index().sort_values(by='station', ascending=False))

武汉居然有那么多地铁站

12.png

实现一下新周刊中的操作,生成地铁名词云

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
python复制代码def create_wordcloud(df):
"""
生成地铁名词云
"""
# 分词
text = ''
for line in df['station']:
text += ' '.join(jieba.cut(line, cut_all=False))
text += ' '
backgroud_Image = plt.imread('rocket.jpg')
wc = WordCloud(
background_color='white',
mask=backgroud_Image,
font_path='C:\Windows\Fonts\华康俪金黑W8.TTF',
max_words=1000,
max_font_size=150,
min_font_size=15,
prefer_horizontal=1,
random_state=50,
)
wc.generate_from_text(text)
img_colors = ImageColorGenerator(backgroud_Image)
wc.recolor(color_func=img_colors)
# 看看词频高的有哪些
process_word = WordCloud.process_text(wc, text)
sort = sorted(process_word.items(), key=lambda e: e[1], reverse=True)
print(sort[:50])
plt.imshow(wc)
plt.axis('off')
wc.to_file("地铁名词云.jpg")
print('生成词云成功!')


create_wordcloud(df_station)

展示词云图

13.jpg

本文转载自: 掘金

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

Rust - if let简单控制流 if let简单控制流

发表于 2021-11-29

这是我参与11月更文挑战的第29天,活动详情查看:2021最后一次更文挑战

if let简单控制流

当我们只需要处理一种情况或者条件并且忽略其他情况时,if let的语法相对来说会比match更加的简洁,比如下述示例代码,希望当值为3的时候执行对应代码:

1
2
3
4
5
rust复制代码let some_u8_value = Some(0u8);
match some_u8_value {
Some(3) => println!("three"),
_ => (),
}

想要对 Some(3) 匹配进行操作但是不想处理任何其他 Some<u8> 值或 None 值。为了满足 match 表达式(穷尽性)的要求,必须在处理完这唯一的成员后加上 _ => (),这样也要增加很多样板代码。

不过我们可以使用 if let 这种更短的方式编写,执行效果和match完全一致

1
2
3
rust复制代码if let Some(3) = some_u8_value {
println!("three");
}

if let的工作方式和match相同, 这里的表达式对应match中第一个分支。

使用 if let 意味着编写更少代码,更少的缩进和更少的样板代码。然而,这样会失去 match 强制要求的穷尽性检查。match 和 if let 之间的选择依赖特定的环境以及增加简洁度和失去穷尽性检查的权衡取舍。

可以在 if let 中包含一个 else。else 块中的代码与 match 表达式中的 _ 分支块中的代码相同,这样的match 表达式在某种角度说就等同于 if let 和 else。

比如下述代码示例,比如说u8得范围是0-255,如果我们只关心1、3、5 和 7 这几个值,而且并不想必须列出 0、2、4、6、8、9 一直到 255 的值。

使用match表达式:

1
2
3
4
5
6
7
rust复制代码fn main() {
let some_u8_value = 0u8;
match some_u8_value {
1 => println!("one"),
_ => (),
}
}

我们也可以使用if let和else表达式:

1
2
3
4
5
6
rust复制代码let some_u8_value = Some(0u8);
if let Some(1) = some_u8_value {
println!("one");
} else {
println!("我只要1,你们都不行")
}

如果你的程序遇到一个使用 match 表达起来过于啰嗦的逻辑,记住 if let 绝对是一个非常好的选择。

总结

现在我们涉及到了如何使用枚举来创建有一系列可列举值的自定义类型。我们也展示了标准库的 Option<T> 类型是如何帮助你利用类型系统来避免出错的。当枚举值包含数据时,可以根据需要处理多少情况来选择使用 match 或 if let 来获取并使用这些值。

Rust 程序基于现在的知识能够使用结构体和枚举在自己的作用域内发挥起作用。

在接下来的文章中作者会介绍Rust的下一部分知识-模块系统,一起来康康吧~

结语

文章首发于微信公众号程序媛小庄,同步于掘金。

码字不易,转载请说明出处,走过路过的小伙伴们伸出可爱的小指头点个赞再走吧(╹▽╹)

本文转载自: 掘金

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

【完虐算法】「字符串-最长公共前缀」5种方法脑洞大开

发表于 2021-11-29

大家好!我是Johngo!

今天不准备一个专题的模块进行分享。

最近在专题制作过程中遇到了最长前缀公共子串的问题,也是读者最近校招面试到的一个题目。为什么拿出这个来说呢?

可怕的是,他居然给了 5 种解题方法。

更可怕的是,因此他直接少了一轮面试,天哪!!

今天顺便分享出来,作为「字符串」的第 5 个部分。

说在前面

言归正传,这一期来说说字符串的第五块内容 **「字符串 - 最长公共前缀」**问题

github:github.com/xiaozhutec/…

文档地址:github.com/xiaozhutec/…

整体架构:

字符串 - 最长公共前缀

小概念:子串的必须要连续,和子序列不同。

比如说一个字符串 “flower”

子串:”flow”, “ower”, “low” 等等都是它的子串,子串必须要连续;

子序列:”flwer”, “fler”, “wer” 等等都是它的子序列,可以不连续;

但需要注意的是它们的顺序需要和原字符串保持一致。

另外,前缀,一定是从字符串的开头进行计算的。

今天大概说的就是个这…

对,被框住的合集中,就是公共前缀(LCP)!

而且这期只举一个 LeetCode 中比较简单的案例来说明。

思路上比较简单!

但是!就是因为这个思路比较简单,本期就用 5 种方式进行分析。

分别是 Python 提供的 zip 方式解决、横向扫描、纵向扫描、分治、二分法。

案例 - 14.最长公共前缀【简单】

整体关于字符串「最长公共前缀」方面的问题。

利用 LeetCode 的 第 14 题,最长公共前缀【简单】来举例!

编写一个函数来查找字符串数组中的最长公共前缀。

如果不存在公共前缀,返回空字符串 “”。

1
2
3
4
> ini复制代码输入:strs = ["flower","flow","flight"]
> 输出:"fl"
>
>

方法一 Python zip轻松解决

熟悉的我的同学都知道,咱们刷题一直用的是 Python 进行刷题,然后也会用到不少 Python 提供的库函数进行问题的解决。

不熟悉 zip 作用的同学不要着急,此处不说原理,10 秒钟用一个例子说明它存在的实际意义。

zip() 函数简单说来,就是将可迭代对象中,各个对应元素打包成一个一个的元祖。

看例子:

1
2
3
4
5
python复制代码>>> str1 = [1,2,3]
>>> str2 = [4,5,6]
>>> str3 = [7,8,9]
>>> zip(str1, str2, str3)
[(1, 4, 7), (2, 5, 8), (3, 6, 9)]

又或者这个例子:

1
2
3
python复制代码>>> strs = ["flower", "flow", "flight"]
>>> zip(*strs)
[('f', 'f', 'f'), ('l', 'l', 'l'), ('o', 'o', 'i'), ('w', 'w', 'g')]

*str 有解包的作用,即把字符串解为一个一个的字符。

zip() 函数的大概作用明白了吧~

如果仔细看第二个例子的话,其实已经可以看出解决方式了。

将上述各个元祖进行 set 操作去重!

1
python复制代码[('f'), ('l'), ('o', 'i'), ('w', 'g')]

继续对各个进行长度计算操作,如果长度为 1 的,那么,前缀必然相同。

即可求出公共前缀了!

图中:最后长度为 1 的字符串,就是咱们要得出来的最长公共前缀了。

简单看下代码:

1
2
3
4
5
6
7
8
python复制代码def longestCommonPrefix1(self, strs):
lcp = ""
for tmp in zip(*strs):
if len(set(tmp)) == 1:
lcp += tmp[0]
else:
break
return lcp

方法二 纵向比较

循环比较个字符串的各个位置。

在第一次循环中比较每个字符串的第 0 位,在第二次循环中比较每个字符串的第 1 位,…, 以此类推,直到匹配到不是相同字符。

以下图做一个详细的分析:

tag 表示在比较过程中,是否相同,相同为True,不同为False;

lcp 表示最长公共前缀的长度;

第一次循环:字符都相同,则,tag=True,lcp+1=1

第二次循环:字符都相同,则,tag=True,lcp+1=2

第三次循环:字符在第三个字符比较中出现了不同,则,tag=False,退出循环,得到最终答案。

lcp的值停留在了上一次循环中。。

这就是纵向比较的全部流程,只要遇到不匹配的就退出循环。

下面看下代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
python复制代码def longestCommonPrefix2(self, strs):
s = strs[0]
size = len(s)
lcp = 0
tag = False
for index in range(size):
# 循环比较每一个位置的字符是否相同
for item in range(len(strs)):
# 需要判断位置 index 在 strs 中字符串是否越界
if index < len(strs[item]) and s[index] == strs[item][index]:
tag = True
else:
# 当匹配不到的时候,退出该次循环
tag = False
break
if tag is True:
lcp += 1
else:
break
return s[:lcp]

下面再看一种方法,是横向比较,即躺着比较,也称为**“咸鱼比较法”**。

方法三 横向比较(咸鱼比较法)

方法一 和 方法二的思路差不多,都是从每一个字符串的每一位进行比较。

横向比较方法,是利用每两个字符串相互比较,保留公共前缀,将保留下来的公共前缀和后面的字符串再进行比较。

还是用这个例子进行说明:strs = [“flower”,”flow”,”flight”]

将 0 位置的字符串作为哨兵,与 1 位置的字符串进行比较,得到最长公共前缀。

再用得到的最长公共前缀再与 2 位置的字符串进行比较。得到最后的结果。

以上述例子,看下图:

第一次比较:字符串”flower” 和 “flow” 进行比较,最长公共前缀是 4

第二次比较:将上一步中得到的字符串 “flow” 与下一个字符串再做比较,得到“fl”。

至此,问题解决!

看代码实现:

1
2
3
4
5
6
7
8
9
10
python复制代码def longestCommonPrefix3(self, strs):
s = strs[0]
for index in range(1, len(strs)):
w_index, size = 0, min(len(s), len(strs[index]))
for w_index in range(size):
if s[w_index] != strs[index][w_index]:
break
w_index += 1
s = s[0: w_index]
return s

再下面的两种解法是运用了分治和二分的思想。

有很多人可能很容易想到分治的思路,但是立马想不到二分的思路进行解决。

方法四 分治思想解决【较重要】

分治思想要比前两种明显感觉要高级一点。。

前两种想法设法的去比较,而当引入分治的时候,就要进行用高级的方式进行比较了。

如下如,分治体现的就是分而治之,部分决策。

将一个大问题,拆分为两个子问题,对子问题继续向下求解。

这个题目就非常清楚的阐明了分治思想的核心。

下面看下这块的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
python复制代码def longestCommonPrefix4(self, strs):
def lcp(left, right):
if left == right:
return strs[left]
mid = (left + right)//2
# 得到左右两个字符串
left_str, right_str = lcp(left, mid), lcp(mid+1, right)
index, min_len = 0, min(len(left_str), len(right_str))
while index < min_len:
if left_str[index] != right_str[index]:
return left_str[:index]
index += 1
return left_str[:index]
return lcp(0, len(strs)-1)

这块是一个典型的二分法的运用。

所以,要理解其中递归的思维逻辑,这个题目就很好的解决了。

方法五 二分思想解决【较重要】

再有一个方法呢,就是利用二分的思路进行解决。

还是用 [“flower”, “flow”, “flownlp”, “flowcv”]来举例子。

利用二分查找,以第一个字符串为基准,不断跟后面字符串进行比较。

初始化左右指针以及mid,left=0,right=len(s)-1, mid=(left+right)/2。

“flower” -> left=0,right=5,mid=(left+right)//2=2 => 左:[“flo”], 右:[“wer”]

如果左:[“flo”]在后面每个单词[left:mid]中,说明左侧子串都能够匹配,需要右面子串进行匹配,则 left=mid+1, mid=(left+right)//2

否则,right=mid, mid=(left+right)//2

所以以上述列表中字符串为例:

right=2,mid=1,左:[“fl”],右:[“o”]

如果左:[“fl”]在后面每个单词[left:mid]中,则 left=mid+1,mid=(left+right)//2

否则,right=mid, mid=(left+right)//2

如果看不清楚,可以看下面图解!

第一次比较:

left=0,right=5,mid=2,发现字符串左半部分”flo”与剩余的每一个字符串都匹配。

所以下面需要进行有半部分的匹配即可,即 left=mid+1!

第二次比较:

left=3,right=5,mid=4,发现子串左半部分”we”与剩余的每一个字符串都不匹配。

因此,需要缩小左半部分的范围,右指针 right=mid。

第三次比较:

left=3,right=4,mid=3,发现子串左半部分”w”与剩余的每一个字符串都匹配。

此时,已经得到了最后的结果。退出循环!

这样看下来,思路也是很清晰,下面用Python来实现一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
python复制代码def longestCommonPrefix5(self, strs):
s = strs[0]
lcp = ""
left, right = 0, len(strs[0])-1
mid = (left+right)//2
# 只有一个字符串的情况下
if len(strs) == 1:
return s
while left <= right:
tag = True
# 轮询判断子串与后面每个字符串对应位置的子串是否相同
for i in range(1, len(strs)):
if s[left:mid+1] not in strs[i][left:mid+1]:
tag = False
break

# 左边子串存在后面每个字符串中
if tag is True:
# 将匹配到的子串加入到结果集中
lcp += s[left:mid+1]
left = mid+1
mid = (left+right)//2
# 左边子串不存在后面每个字符串中
else:
if right == mid: # 当 right == mid,说明right指针已经无法靠左移动了,退出循环
break
else:
right = mid
mid = (left+right)//2
return lcp

以上就是就关于字符串「最长公共前缀」的全部分享了。

另外,方便的话也在我的github👇 加颗星,它是我持续输出最大最大的动力,感谢大家!

github:github.com/xiaozhutec/…


如果感觉内容对你有些许的帮助!

点赞、在看!

评论、转发!

下期想看哪方面的,评论区告诉我!

好了~ 咱们下期见!bye~~

本文转载自: 掘金

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

MySQL事务——commit or rollback

发表于 2021-11-29

什么是事务

事务是MYSQL的一项功能,它可以使一组数据操作要么全部成功,要么全部失败,不会出现只执行一部分操作的情况

事务的语法结构如下:

1
2
3
4
sql复制代码start transaction 或者使用 begin 开始事务
一组数据操作语句
commit 提交事务
rollback 事务回滚

事务主要有4个特性(ACID):

  • A(atomicity):原子性,表示事务中的操作要么全部成功,要么全部失败,像一个整体,不能从中间打断
  • C(consistency):一致性,表示数据的完整性不会因为事务的执行而受到破坏
  • I(isolation):隔离性,表示多个事务同时执行的过程中,不会相互干扰。不同的隔离级别,相互独立的程度不同
  • D(durability):持久性,当事务正确完成后,它对于数据的改变是永久性的

操作原子性

这里我举一个全家的例子,全家的收银员帮顾客结账的情况,这里主要涉及到的就是商品库存表和商品流水表:

stock(库存表)

itemnumer(商品编号) stock(商品库存)
1 10

record(流水表)

id(流水单号) itemnumber(商品编号) quantity(销售数量)

现在,假设门店销售了5个编号为1的商品,这个操作其实意味着我们需要创建一条流水记录,并更新商品库存,一共有两个操作:

  1. 像record表中插入一条1号商品卖了5个的记录
  2. 把stock表中商品编号为1的记录stock减5

为了避免意外的发生,我们需要将这两个操作放到一个事务中去执行,利用事务的原子性,来确保数据的一致性

这里我省略了建表语句,应该难不倒在座的各位,但要注意itemnumbe和id是两张表各自的主键:satisfied:

1
2
3
4
5
6
7
8
9
10
11
12
sql复制代码mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into record values (1, 1, 5);
Query OK, 1 row affected (0.00 sec)

mysql> update stock set stock = stock - 5 where itemnumber = 1;
Query OK, 1 row affected (0.01 sec)
Rows matched: 1 Changed: 1 Warnings: 0

mysql> commit;
Query OK, 0 rows affected (0.01 sec)

然后我们可以查询一下结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sql复制代码mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 5 |
+------------+-------+
1 row in set (0.00 sec)

mysql> select * from record;
+----+------------+----------+
| id | itemnumber | quantity |
+----+------------+----------+
| 1 | 1 | 5 |
+----+------------+----------+
1 row in set (0.00 sec)

操作的一致性

但是这里有一个细节点需要注意,事务并不会自动帮你处理SQL语句执行中的错误,如果你对事务中某一步的数据操作发生的错误不进行处理,仍然会导致数据不一致

这里如果在insert的过程中发生了错误,但是没有做回滚处理,继续执行后面的操作,最后提交事务,结果就会出现没有流水但是库存减少的情况:

1
2
3
4
5
6
7
8
9
10
11
12
sql复制代码mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into record values (1, 5);
# ERROR 1136 (21S01): Column count doesn't match value count at row 1

mysql> update stock set stock = stock - 5 where itemnumber = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0

mysql> commit;
Query OK, 0 rows affected (0.01 sec)

然后我们可以查询一下结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sql复制代码mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 0 |
+------------+-------+
1 row in set (0.00 sec)

mysql> select * from record;
+----+------------+----------+
| id | itemnumber | quantity |
+----+------------+----------+
| 1 | 1 | 5 |
+----+------------+----------+
1 row in set (0.00 sec)

可以看到并没有往流水表中添加记录,但是库存却被更新了

这就是因为没有正确使用事务导致的数据不完整问题。那么,如何使用事务,才能避免这种由于事务中的某一步或者几步操作出现错误,而导致数据不完整的情况发生呢?这就要用到事务中错误处理和回滚了:

  • 如果发现事务中的某个操作发生错误,要及时回滚
  • 只有事务中的操作都正常执行,才进行提交

那么我们只需要关注如果判断某个操作发生了错误,我们可以使用MYSQL的函数ROW_COUNT的返回结果,来判断操作是否成功,此时我们把stock表中的1号商品的库存重新调整为5

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sql复制代码mysql> update stock set stock = 5 where itemnumber = 1;
Query OK, 1 row affected (0.01 sec)
Rows matched: 1 Changed: 1 Warnings: 0

mysql> insert into record values (1, 5);
# ERROR 1136 (21S01): Column count doesn't match value count at row 1

mysql> select row_count();
+-------------+
| row_count() |
+-------------+
| -1 |
+-------------+
1 row in set (0.00 sec)

我们可以看到row_count函数返回的结果是-1,表示操作执行失败,1表示操作执行成功,这个时候我们就可以使用rollback进行回滚

事务的隔离性

这里还是使用全家的例子,只不过我们这次使用的是全家的会员卡,假设索隆的全家会员卡上有50块的余额(也就是5000积分),众所周知,全家的门店会员卡都是互通的,此时索隆拿着自己的实体会员卡来到了全家消费了50元,与此同时,索隆的爱人古伊娜用他的会员卡在全家APP上也消费了50元

索隆在门店消费的时候,开始了一个事务A,包括以下三个操作:

  1. 读取卡内余额50元
  2. 更新卡内余额为0
  3. 插入一条流水记录

古伊娜在APP中付款,开启了一个事务B,也来读取卡内余额,如果B读取卡内余额的操作发生了A更新卡内余额之后,并在插入流水记录之前,那么A有可能由于后面的操作失败而进行回滚。因此,本来可以成功进行的交易,因为读取到错误的信息而导致本可以成功的交易失败了

所以这里我们会用到MYSQL的锁机制。MYSQL可以把A中被修改过且还没有提交的数据锁住,让B处于等待状态,一直到A提交完成或者失败回滚,再释放锁,允许B读取数据,这样可以防止因为A回滚而B读取错误的可能了

MYSQL可以通过使用锁来控制事务对数据的操作,可以实现事务之间的相互隔离,锁的使用方式不同,隔离程度也不同

  1. READ UNCOMMITED:未提交读,这种就是我们上面举的例子,可以读取事务中还未提交的被更改的数据
  2. READ COMMITED:已提交读,只能读取事务中已经提交的被更改的数据
  3. REPEATABLE READ:可重复读,表示在一个事务中,对一个数据读取的值,永远跟第一次读取的值一致,不受其他事务中数据操作的影响。这个也是MYSQL的默认隔离级别
  4. SERIALIZARBLE:表示任何一个事务,一旦对某一个数据进行了任何操作,那么一直到这个事务结束,MYSQL都会把这个数据锁住。禁止其他事务对这个数据进行任何操作

下面是对各个隔离级别的总结:

事务隔离级别 脏读 不可重复读 幻读
读未提交(read-uncommitted) 是 是 是
不可重复读(read-committed) 否 是 是
可重复读(repeatable-read) 否 否 是
串行化(serializable) 否 否 否

然后我们来依次对各个隔离级别进行讲解

1
2
3
4
5
6
7
8
9
sql复制代码# 可以看到这里默认的隔离级别是可重复读
# 我安装的是MYSQL8.0的版本,低版本的请使用 select @@tx_isolation;
mysql> select @@transaction_isolation;
+-------------------------+
| @@transaction_isolation |
+-------------------------+
| REPEATABLE-READ |
+-------------------------+
1 row in set (0.00 sec)

读未提交

  1. 先打开终端A,然后将MYSQL的默认隔离级别修改为读未提交
  2. 开启事务但不提交事务
  3. 然后打开终端B,设置隔离级别,开始事务并修改数据
  4. 在终端未提交的事务中进行查询

终端A如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
sql复制代码mysql> set session transaction isolation level read uncommitted;
Query OK, 0 rows affected (0.00 sec)

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 5 |
+------------+-------+
1 row in set (0.00 sec)

##################### 第二步 #####################
# 查询编号为1的商品库存有多少
mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 10 |
+------------+-------+
1 row in set (0.00 sec)
# 此时这里应该是 5,因为终端B中的事务还没有提交,产生了脏读

终端B如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
sql复制代码mysql> set session transaction isolation level read uncommitted;
Query OK, 0 rows affected (0.00 sec)

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 5 |
+------------+-------+
1 row in set (0.00 sec)

##################### 第一步 #####################
# 设置 stock 的值为 10

mysql> update stock set stock = stock + 5 where itemnumber = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0

mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 10 |
+------------+-------+
1 row in set (0.00 sec)

此时如果终端B中因为某种原因进行了回滚,那么终端A中读取到的就是错误的数据,这也就是所谓的脏读

读已提交(不可重复读)

  1. 打开一个终端A,并设置当前事务模式为read committed(已提交读),查询表stock的所有记录
  2. 在终端A的事务提交之前,打开另一个终端B,更新表stock
  3. 这时,终端B的事务还没提交,终端A不能查询到B已经更新的数据,解决了脏读问题
  4. 终端B的事务提交
  5. 终端A再次查询,结果与上一步不一致,产生了不可重复读的问题

终端A如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
sql复制代码mysql> set session transaction isolation level read committed;
Query OK, 0 rows affected (0.00 sec)

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 5 |
+------------+-------+
1 row in set (0.00 sec)

##################### 第二步 #####################
# 在终端A中查看编号为1的商品的库存数量
mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 5 |
+------------+-------+
1 row in set (0.00 sec)
# 可见这里解决了上面的脏读问题

##################### 第四步 #####################
# 再次查询
mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 10 |
+------------+-------+
1 row in set (0.00 sec)
# 再次查询后发现和第二步的结果不一致,产生了不可重复读的问题

终端B如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
sql复制代码mysql> set session transaction isolation level read committed;
Query OK, 0 rows affected (0.00 sec)

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 5 |
+------------+-------+
1 row in set (0.00 sec)

##################### 第一步 #####################
# 设置 stock 的值为 10
mysql> update stock set stock = stock + 5 where itemnumber = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0

mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 10 |
+------------+-------+
1 row in set (0.00 sec)

##################### 第三步 #####################
# 提交事务
mysql> commit;
Query OK, 0 rows affected (0.01 sec)

此时由于终端B中进行了事务的提交,终端A中两次读取到的就是不一致的数据,这也就是所谓的不可重复读

可重复读

  1. 打开一个终端A,并设置当前事务模式为repeatable read,查询表stock的所有记录
  2. 在终端A的事务提交之前,打开另一个终端B,更新表stock并提交
  3. 在终端A查询表stock的所有记录,与步骤1查询结果一致,没有出现不可重复读的问题
  4. 在终端A,接着执行更新操作,数据的一致性没有被破坏
  5. 重新打开终端B,插入一条新数据后提交
  6. 接着在终端A中也插入步骤5同样的一条数据,发现无法插入,产生了幻读

终端A:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
sql复制代码mysql> set session transaction isolation level repeatable read;
Query OK, 0 rows affected (0.00 sec)

# 其实不用设置,我们发现MYSQL默认的隔离级别就是可重复读,但也是写出来让大家明白一些

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 5 |
+------------+-------+
1 row in set (0.00 sec)

##################### 第二步 #####################
# 在终端A中进行查询,发现stock没有变成10,所以没有脏读的问题
mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 5 |
+------------+-------+
1 row in set (0.00 sec)

##################### 第四步 #####################
# 再次在终端A中进行查询,发现stock仍然为5,没有不可重复读的问题
mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 5 |
+------------+-------+
1 row in set (0.00 sec)

mysql> update stock set stock = stock + 3 where itemnumber = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0

mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 13 |
+------------+-------+
1 row in set (0.00 sec)
# 这里发现上面查询出来的数据 stock 为 5,但是下面进行库存的更新时,最终的结果却是用了终端B提交事务后最新的stock值,产生了幻读

##################### 第六步 #####################
# 插入和第五步一样的数据,发现无法插入,但是在当前事务中,它认为确实没有itemnumber为2的数据,产生了幻读
mysql> insert into stock values (2, 10);
ERROR 1062 (23000): Duplicate entry '2' for key 'PRIMARY'

终端B:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
sql复制代码mysql> set session transaction isolation level repeatable read;
Query OK, 0 rows affected (0.00 sec)

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 5 |
+------------+-------+
1 row in set (0.00 sec)

##################### 第一步 #####################
# 更新库存为10
mysql> update stock set stock = stock + 5 where itemnumber = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0

mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 10 |
+------------+-------+
1 row in set (0.00 sec)

##################### 第三步 #####################
# 提交事务
mysql> commit;
Query OK, 0 rows affected (0.01 sec)

mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 10 |
+------------+-------+
1 row in set (0.00 sec)

##################### 第五步 #####################
# 再次开启事务并往stock表中插入一条新的数据(2, 10)
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> insert into stock values (2, 10);
Query OK, 1 row affected (0.00 sec)

mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 10 |
| 2 | 10 |
+------------+-------+
2 rows in set (0.00 sec)

串行化

  1. 打开一个终端A,并设置当前事务模式为serializable,查询表stock的所有记录
  2. 打开终端B,开始事务,并且往stock表中添加一条记录,但是不提交事务
  3. 在终端A中再次查询stock表,发现无法查询
  4. 在终端B中提交事务
  5. 再次在终端A中查询

终端A:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
sql复制代码mysql> set session transaction isolation level serializable;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 5 |
+------------+-------+
1 row in set (0.00 sec)

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

##################### 第二步 #####################
# 查询数据
mysql> select * from stock;
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

##################### 第四步 #####################
# 再次查询数据
mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 5 |
| 2 | 10 |
+------------+-------+
2 rows in set (0.00 sec)

终端B:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
sql复制代码mysql> set session transaction isolation level serializable;
Query OK, 0 rows affected (0.00 sec)

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 5 |
+------------+-------+
1 row in set (0.00 sec)

##################### 第一步 #####################
# 插入数据
mysql> insert into stock values (2, 10);
Query OK, 1 row affected (0.00 sec)

mysql> select * from stock;
+------------+-------+
| itemnumber | stock |
+------------+-------+
| 1 | 5 |
| 2 | 10 |
+------------+-------+
2 rows in set (0.00 sec)

##################### 第三步 #####################
# 提交事务
mysql> commit;
Query OK, 0 rows affected (0.01 sec)

可见串行化的数据库并发能力是最弱的,所以除非是要数据强一致,否则应该使用MYSQL数据库默认的可重复读的隔离级别

本文转载自: 掘金

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

EasyC++39,函数模板

发表于 2021-11-29

大家好,我是梁唐。

这是EasyC++系列的第39篇,来聊聊函数模板。

想要追求更好阅读体验的同学,可以访问github仓库: EasyLeetCode。

函数模板

所谓函数的模板,本质上也就是使用泛型来定义函数。

所谓的泛型其实也就是不定的类型,比如说我们使用vector的时候,可以定义各种类型的vector,可以定义存储int型的vector也可以定义存储float类型的,也可以定义存储其他类型。我们在声明的时候将存储的类型当做参数传给了模板。

泛型可以用具体的类型,比如(int或double)替换,通过将类型作为参数传给模板,编译器会根据传递的参数类型生成该类型的函数。这种方式也被称为通用编程或者参数化类型。

举一个很简单的例子,比如说我们要实现一个函数交换两个变量的值。对于int类型我们要实现一遍,对于double类型我们又要实现一遍,如果还需要其他类型,那么又需要额外实现很多同样逻辑的函数。当然可以拷贝代码,但显然这样会很浪费时间,而且会使得代码变得臃肿。

这个时候我们就可以使用函数模板自动完成这一功能,函数模板允许以任意类型来定义函数,所以我们就可以这样实现:

1
2
3
4
5
6
C++复制代码template <typename T>
void swap(T &a, T &b) {
T temp = a;
a = b;
b = temp;
}

当我们要创建一个模板的时候,需要首先声明模板的类型,也就是template语句做的事情。关键字typename也是必须的,也可以使用class代替。typename关键字是在C++98标准添加的,所以在更早的版本中往往使用class。在这个场景下,这两种方式是等价的。C++ Primer当中更建议使用typename而非class。

typename之后跟的是类型的名称,我们可以使用任意的名字,一般来说习惯性地会使用字母T。我们在使用的时候和普通函数并没有什么不同,当做普通函数使用即可。

1
2
3
4
5
6
7
8
9
10
11
C++复制代码template <typename T>
void swap(T &a, T &b) {
T temp = a;
a = b;
b = temp;
}

int i = 10, j = 20;
swap(i, j);
double a = 3.0, b = 4.0;
swap(a, b);

虽然我们只实现了一次函数,但是在编译的时候,编译器会将这个函数根据我们使用的情况生成多个版本。比如在上面的代码当中,我们使用了int和double两种类型的函数。编译器会替我们生成两份代码,也就是说最终代码上和我们手动实现函数重载是一样的,可以理解成一种方便我们程序编写的特性。

本文转载自: 掘金

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

力扣:58 - I 翻转单词顺序

发表于 2021-11-29

「这是我参与11月更文挑战的第20天,活动详情查看:2021最后一次更文挑战」

描述

输入一个英文句子,翻转句子中单词的顺序,但单词内字符的顺序不变。为简单起见,标点符号和普通字母一样处理。例如输入字符串”I am a student. “,则输出”student. a am I”。

  • 示例 1:
1
2
arduino复制代码输入: "the sky is blue"
输出: "blue is sky the"
  • 示例 2:
1
2
3
makefile复制代码输入: "  hello world!  "
输出: "world! hello"
解释: 输入字符串可以在前面或者后面包含多余的空格,但是反转后的字符不能包括。
  • 示例3
1
2
3
makefile复制代码输入: "a good   example"
输出: "example good a"
解释: 如果两个单词间有多余的空格,将反转后单词间的空格减少到只含一个。
  • 提示
1
2
3
4
复制代码无空格字符构成一个单词。
输入字符串可以在前面或者后面包含多余的空格,但是反转后的字符不能包括。
如果两个单词间有多余的空格,将反转后单词间的空格减少到只含一个。
​

解析

利用两个指针,一个快,一个慢,快指针遇到空格就停下来,把经过的非空放到stringbuilder中,要加上中间的空格” “,注意substring的写法,别写成subString,然后这个函数是前闭后开 之后快指针遇到空格就跳过,直到遇到新的不为空的,把慢指针挪过去j = i,继续下一个单词的计数,注意最后的单词,后面还跟着” “,需要再trim()一下

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
arduino复制代码class Solution {
   public String reverseWords(String s) {
       //双指针
       String ss = s.trim();//去掉首尾的空格
       int i = ss.length() - 1; int j = i;
       StringBuilder sb = new StringBuilder();
       while(i >= 0){
           while (i >= 0 && ss.charAt(i) != ' ') i--;
           sb.append(ss.substring(i+1,j+1)+" ");//前闭后开 注意这个substring 不是驼峰命名 不要搞错了
           while (i >= 0 && ss.charAt(i) == ' ') i--;
           j = i;
      }
       return sb.toString().trim();
​
  }
}
​

运行结果:

执行结果:通过

执行用时:0 ms, 在所有 Java 提交中击败了100.00%的用户

内存消耗:35.3 MB, 在所有 Java 提交中击败了84.82%的用户

本文转载自: 掘金

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

1…121122123…956

开发者博客

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