这个库的学习可以通过各个文件的Test案例进行了解,基本上使用起来不会有太大的问题。
库中对Go语言Duck Type的运用是很优秀的,而且也是很常用的。
例如,通过对SECS-II数据节点的抽象;对HSMS消息的抽象。这些在看源码时,都是可以有心学习的。
1 | type ItemNode interface { |
其中,经过我对比其他库,这个库的优点是对消息中变量的支持,可以事先插入变量在SML消息中进行占位,在后面对这些变量占位进行填充。
1 | NewIntNode(2, "var1").FillVariables(map[string]interface{}{"var1": 1}) |
但是,这里存在一个问题,这个库对数据获取的没有提供一个好的方式,例如我想获取List嵌套下的某一个Int中的第二个元素,这个就没办法了,需要我们自己进行拓展。例如Java中:
1 | // github.com/kenta-shimizu/secs4java8 |
库中对消息节点的解析已经将数据解析到节点的values中,但是没有获取数据的method。
1 | type IntNode struct { |
这里就是要将其暴露出去,给到外部进行获取。
ItemNode
增加三个方法,Values、Get、Type.
Values:获取节点上的数据;
Get:获取节点,主要是List节点;
Type:标识数据节点;
1 | type ItemNode interface { |
List节点Get
函数:
1 | func (node *ListNode) Get(indices ...int) (ItemNode, error) { |
其他节点Get
函数:
1 | func (node *IntNode) Get(indices ...int) (ItemNode, error) { |
给DataMessage
添加获取节点数据的函数:
1 | GetAscii(indices ...int) (string, error) |
这样,即可获取到指定数据节点的信息,效果和Java库是一致的。
1 | func TestMessageNode_ProducedByFactoryMethod_HSMS11(t *testing.T) { |
protoreflect
的方法,自动代理服务,可以摆脱开proto文件的限制,这样网关可以将这些服务都自动代理起来。更进一步的,像现在微服务框架,如Kratos、ego、go-zero都兼顾根据proto文件上,根据google/api/annotations.proto
增加的注解,生成HTTP的服务,而这些属性注释,网关都可以通过反射拿到,然后代理其HTTP。除此之外,HTTP —> Gateway —> GRPC 有没有省事点的方法呢?可以直接将HTTP/1.1协议转成GRPC兼容,然后将返回再翻译为HTTP/1.1。
这是偶然看到go-kratos/gateway中实现的http—>h2所使用的技术。
这里利用了GRPC协议中为了兼容一些HTTP1.1端,在消息体的格式中,可使用json
格式进行序列化(application/grpc+json
)。
在使用此 Content-Type 时,gRPC 服务器会将 JSON 格式的数据转换为 Protocol Buffers 格式的数据,然后进行处理。所以使用什么数据格式传输,并不影响其协议依然为HTTP/2。
不想看代码,可以直接看总结
中看如何实现的。
1 | # This is a gateway config. |
这里来看下来看下kratos-gateway的代码。
1 | go-kratos/gateway/cmd/gateway/main.go |
这里使用了库net/http2
,是Golang标准库中用于支持HTTP/2协议的包,它提供了HTTP/2客户端和服务器的实现,以及与HTTP/1.x的兼容性支持。
从这里也就能看到实际上在gateway中的客户端请求的实例了,gateway通过它来请求到后端实际的服务。到这里还得看HTTP request是怎么转化到GRPC请求的。
继续看Gateway怎么构建proxy的。
1 | go-kratos/gateway/cmd/gateway/main.go |
实际请求时, 将进行协议中间件进行转化。
协议转化在go-kratos/gateway/middleware/transcoder/transcoder.go。
1 | // Middleware is a gRPC transcoder. |
通过GRPC协议中为了兼容HTTP/1.1协议所给出传输格式application/grpc+json
,使用golang.org/x/net/http2
,通过修改HTTP请求头,可以做到使用HTTP访问到GRPC服务。以下是更简略的版本。
1 | func Test_defaultH2Client(t *testing.T) { |
基于以上的需要,进行相应的实现,即可得到所需的效果。
通过接口获取对应服务proto定义的能力,需要对方服务打开proto 反射。
1 | s := grpc.NewServer() |
Register
其实是注册一个服务,里面就一个函数,可以拿到该proto的所有数据,如同在自己的项目中调用reflection
库一般。
1 | // Register registers the server reflection service on the given gRPC server. |
启动一个可以接受任何请求的GRPC
服务器,这里做一步如同grpc proxy
的操作,不去注册固定的服务处理函数:
1 | s := grpc.NewServer(grpc.UnknownServiceHandler(func(srv interface{}, stream grpc.ServerStream) error { |
在前端界面上输入目标服务器的地址,获取到其接口和参数,然后填入mock值。
新增的功能:
yapi
)SECS/GEM 的库市面上是有开源软件可选的。例如:
Python:
C#:
Java:
基础知识了解:
https://blog.csdn.net/jxb_memory/category_9885599.html
视频资料:
对于新了解的协议还是比较好奇的,特别是Go中没有其实现。(工业软件中Go还是没有什么可以插足的)
闲来无事,对基础协议进行一定量的开发,算是对协议的一个了解过程。其中,解析协议内容使用库wolimst/lib-secs2-hsms-go。另外的操作就是对TCP连接的管理,以及针对协议中提到的几个Timeout时间,体现在协议中。在此基础就能实现大体协议的内容。
ID间的联系:
1 | DeviceID(SessionID):用于Host识别的设备号。 |
在实际做库的过程中,可以将各类数据都转化到一个层级进行管理,用各类map都搜集起来,这样数据的读和取都是统一对此层级负责的,也是只能从这层数据中拿到操作的,数据一致性也有所保证。
至于Go中直接调用Python,或其他语言编成库等形式,应该也是可以尝试的。
]]>Go
语言中,对Oracle
进行操作,比访问其他常见DB可谓是要麻烦一些,显而易见的问题是库的选择,这里我尝试了两种库:go-ora + 官方库database/sql (github.com/jmoiron/sqlx、github.com/blockloop/scan 辅助)
go-ora
是原生Go实现的,不需要依赖CGo,相对比较方便,也比较易用,但在使用过程中,使用该库连接Oracle一段时间后,其会请求出错,我也对此提了issue
,以及解决方法,但由于联系不上maintainer
,所以无法确认解决方法是否靠谱了。
https://github.com/sijms/go-ora/issues/240
也有人遇到了与我相似的问题,用我issue上提到的解决方式解决了该问题,但我依然不推荐这么操作。
go-oci8
是使用了CGo实现了Oracle的客户端,虽然对环境有所依赖,好在库很稳定,不用担心出幺蛾子,且xorm
实验性的支持,对只使用oracle基础功能来说,还是够用的。
推荐使用下面这种。
在使用oracle
Console时,输入表名、字段,不管大小写都可以识别。但其实这里有个误区。
Oracle默认是大写的
,也就是说在没有使用双引号"
对表名和列名进行限定的时候,表名不过是小写还是大写,最后都默认成了大写。
若是以双引号进行限定后,字段大小写就不再转化为默认的大写了。
1 | CREATE TABLE department ( |
上面字段都会转化为大写,DEPARTMENT
,ID
,DESCRIPTION
。
1 | SELECT id FROM department; √ |
明白上述所述后,使用xorm
时需要注意的是。
在使用xorm自动创建表时,其会根据你的注释的大小写,创建字段。这里就要区分一下了。
1 | type Info struct { |
对时区的设置,这里是:
1 | ALTER DATABASE SET TIME_ZONE='Asia/Shanghai'; |
update_time
、created_time
常见的两个时间,保存时间戳是没有什么问题(int64
)。但要保存time时间格式,估计会发现没法存进去。
update_time
可以使用raw sql:
1 | UPDATE xxxx SET "max_id"="max_id" + "step", "update_time" = TO_TIMESTAMP('%s', 'YYYY-MM-DD HH24:MI:SS.FF6') WHERE "biz_tag"='123' |
created_time
:
1 | CREATE TABLE "xxxxxx"( |
12版本以前没法像MySQL
一样生成自增主键,需要一些曲线救国的方式。
使用SYS_GUID()
,系统根据当前时间和机器码,生成全球唯一的一个序列号。
1 | CREATE TABLE department ( |
自增ID的实现:
1 | Oracle 12c之前: |
Yapi
。在使用上,官方文档已经很清晰了,文档。
文章主要功夫是写一下安装环节遇到的问题,主要是项目年久失修,而各路环境又是突飞猛进的进行改变,所以造成其有兼容性问题。
docker部署yapi
是极其方便易用的。
1 | # 账号:admin@docker.yapi |
但这里面会碰到一个问题,在使用mock测试集的时候会碰到safeify
引发的Error: EROFS: read-only file system
问题,跟着#2376修改完成之后,使用assert
依然会碰到Error: Method Promise.prototype.then called on incompatible receiver [object Object]
。
该问题也被记录在#2536。
解决方法当时未找到,这里就没有深究了。
环境要求:
Node.js(7.6+)
MongoDB(2.6+)
当前node版本太高,Yapi
需要一个低版本的node
。
参考:怎么在Ubuntu中更新node的版本,安装工具n。使用工具n,指定版本安装,我这里安装的是12.16.3。
1 | sudo n 12.16.3 |
参考官方安装文档。
1 | mkdir yapi |
config.json
中设置MongoDB
的用户,需要MongoDB开启auth
。
注意,config.json的引用是在文件
server/yapi.js
中。
1 | docker pull mongo:latest |
config.json
中DB的设置:
1 | "db": { |
所有新建完成后,mock
会遇到新问题。
UnhandledPromiseRejectionWarning: TypeError: Converting circular structure to JSON
需要改动,vendors/server/utils/sandbox.js
。
1 | const Safeify = require('safeify').default; |
开启之后即可接通公司的账户体系。
1 | "ldapLogin": { |
MySQL学习推荐课程:MySQL 实战 45 讲
1 | CREATE TABLE `like` ( |
业务上有这样的需求,A、B两个用户,如果互相关注,则成为好友。设计上是有两张表,一个是like表,一个是friend表,like表有user_id、liker_id两个字段,我设置为复合唯一索引即uk_user_id_liker_id。语句执行逻辑是这样的:
以A关注B为例:
第一步,先查询对方有没有关注自己(B有没有关注A)
select * from like where user_id = B and liker_id = A;
如果有,则成为好友
insert into friend;
没有,则只是单向关注关系
insert into like;
但是如果A、B同时关注对方,会出现不会成为好友的情况。因为上面第1步,双方都没关注对方。第1步即使使用了排他锁也不行,因为记录不存在,行锁无法生效。请问这种情况,在MySQL锁层面有没有办法处理?
如图:
A、B之间没有关注关系(没有记录),事务开始时,查询为空。
对业务来说,双方已经互点关注了,已经不是喜欢
了,而是需要在friend
表中插入记录。
如图示中,在进行查询时,选择的数据加上行锁也没啥用,毕竟在查询的时候数据都还不存在。
首先,要给“like”表增加一个字段,比如叫作 relation_ship,并设为整型,取值1、2、3。
值是1的时候,表示user_id 关注 liker_id; 0b01
值是2的时候,表示liker_id 关注 user_id; 0b10
值是3的时候,表示互相关注。 0b11
然后,当 A关注B的时候,逻辑改成如下所示的样子:
应用代码里面,比较A和B的大小,如果A<B,就执行下面的逻辑
1 | mysql> begin; /*启动事务*/ |
如果A>B,则执行下面的逻辑
1 | mysql> begin; /*启动事务*/ |
这个设计里,让“like”表里的数据保证user_id < liker_id,这样不论是A关注B,还是B关注A,在操作“like”表的时候,如果反向的关系已经存在,就会出现行锁冲突。
把正反向的数据都转化到一行记录里面,而不是分开(通过A、B UserID的大小)
然后,insert … on duplicate语句,确保了在事务内部,执行了这个SQL语句后,就强行占住了这个行锁,之后的select 判断relation_ship这个逻辑时就确保了是在行锁保护下的读操作。
事务在执行第一条语句时开启,开启后,碰到
insert … on duplicate语句
(先判断记录是否存在,存在即update,不存在即insert),修改语句,占用该行锁直到事务结束。(占不住则意味着要等待别人把该记录的行锁释放,也就是另外的事务结束)
操作符 “|” 是按位或,连同最后一句insert语句里的ignore,是为了保证重复调用时的幂等性。
insert ignore into,重复则忽略,不重复则插入。(重复时只是报警,不会报错)
这样,即使在双方“同时”执行关注操作,最终数据库里的结果,也是like表里面有一条关于A和B的记录,而且relation_ship的值是3, 并且friend表里面也有了A和B的这条记录。
这里的问题就是事务隔离引申出来的问题,“可见性”。当数据都还未出现呢,此时不管是当前读
,还是快照读
都得眼瞎。
解决此问题,依然还是用到了行锁,精妙的地方是,利用了insert … on duplicate
语句,创建与更新同时存在,后面的查询语句去更新验证结果,由于两阶段锁协议,所以查询语句也是带锁的。
拦截中间件以及验证JWT Token,过滤掉不验证的URL。
1 | func NewWhiteListMatcher() selector.MatchFunc { |
Login生成token:
1 | // generate token |
生成的token,前端放在Header
的Authorization
中。经过jwt
中间件的解析,然后再经过我们写的中间件加上我们自己的验证方式,例如验证参数逻辑。
1 | http.Middleware( |
综上,我们自己写的代码要使用Server Middleware
,需要按照上面的格式进行编写。
另外还有很多插件形式:
1 | httpSrv := http.NewServer( |
执行顺序为:globalFilter(http)
--> routeFilter(http)
--> pathFilter(http)
--> serviceFilter(service)
依赖问题引发的问题是很多的,对于依赖的管理也会出现很多,例如:
https://en.wikipedia.org/wiki/Dependency_hell
https://zh.m.wikipedia.org/zh-hans/%E7%9B%B8%E4%BE%9D%E6%80%A7%E5%9C%B0%E7%8B%B1
参考Google家的库googleapis:
目录 | 举栗 |
---|---|
总目录(子仓) | xxxxxapis |
项目名 | cloud / ads / monitor |
模块名 | device / datacatalog / user |
版本 | v1 / v2 / v3 |
文件 | service.proto、error.proto |
子仓不保存IDL文件生成的中间文件,只保存原始文件。
1 | git submodule add https://xxxx.com/xxxx/myapis |
子仓在引入后,不去更新,子仓会呈现固定的版本。
更新子仓,合并上游版本变化
1 | // 进入子仓内,运行命令 |
更多关于子仓的操作命令,参考:Git-工具-子模块,也可参考下图。
脑图分享链接:https://naotu.baidu.com/file/df1c4f51d9617121e17b31ba6e577d3a?token=280c1c34c3b920e4
proto文件中,package
、option go_package
,这两个参数是我们需要注意的:
package
:即别的proto文件在引用该文件后,其使用的索引前缀。
option go_package
: protoc 编译时,生成的路径地址,可以用go_out设置生成的路径,使用source_relative
让文件生成在相对路径中。
proto文件语法以及版本追踪工具:
由于依赖原始文件,在拉取到子仓后,或是更新到最新子仓后,proto文件都应是再次编译,故在Makefile
中,build
命令中,需要加入重新编译proto文件的操作,让proto文件每次都保持最新状态,防止中间版本的出现。
也可以使用例如BAZEL编译,声名依赖,指定proto文件。
protoc-gen-go-http
,对body以及query参数只能选择其一支持,不论其Method
为何种。query、vars支持同时存在。源码可查:go-kratos/kratos/cmd/protoc-gen-go-http/template.go
。
和Google API 设计指南上对方法的设计有点不一样。
GET | POST | PUT | DELETE | |
---|---|---|---|---|
query (/hello?x=s) | 支持 | 支持 | 支持 | 支持 |
vars (/hello/{name}) | 支持 | 支持 | 支持 | 支持 |
body | 支持 | 支持 | 支持 | 支持 |
body与query同时存在:
1 | service Auth { |
1 | // client发上来的header保存在此处 |
从test文件的示例可以看出,github.com/go-kratos/kratos/encoding/form/form_test.go
,解析URL中的嵌套数据的方法:
1 | message HelloRequest { |
URL Query:
1 | http://localhost:8000/helloworld?ones.one=1234 |
1 | Simples: []string{"3344", "5566"} |
1 | Field: &fieldmaskpb.FieldMask{Paths: []string{"1", "2"}} |
FieldMask
借助库:
https://github.com/mennanov/fmutils
https://github.com/mennanov/fieldmask-utils
不过当前Kratos对query中的fieldmask
会进行大写转换,导致其字段无法进行有效的Filter
。我提了一个issue。
fieldmask_utils是对字段名进行匹配,而不是tag名,而Kratos则会将Me ——> _me,而其字段为Me
,那库则无法将其正常过滤,正常GRPC协议不会出现此转换,此转换应该是Kratos HTTP decode request时发生的。
1 | import fieldmask_utils "github.com/mennanov/fieldmask-utils" |
FieldMask
用在response、request参数限制返回,以及指定参数的更新上。
request中指定paths,response根据paths mask参数,返回需要的字段。
Netflix API 设计实践(二): 使用FieldMask进行数据变更
1 | message UpdateProductionRequest { |
更新操作就会执行更新format
,schedule.planned_launch_date
两个字段,由于后者没有传值,变相的也就是将此字段置空。
Leaf
的替代版本,而Leaf的实现细节有很多文章都分析过了,这样看起来移植一下也不困难了。美团Leaf的技术细节在官方文档中介绍的很详细,这里参考其技术实现细节Leaf——美团点评分布式ID生成系统。
在号段模式时,碰到的问题主要有两个:
锁竞争的问题是在请求中使用锁,但我在最初的版本中,当并发比较大时,就会出现死锁的问题。后面使用闭包+defer
解决了此问题。
1 | if value := func() int64 { |
context传递的问题,这个其实在gin
中就碰到过,在handler中开协程出去,若是将handler中的context传递过去,这里面就会出问题,因为handler中的context在handler主体执行完后,会执行context的cancel(若该上下文其为timeout context),也就会导致协程若是用到了这个上下文也会被cancel。
所以这里我改为在传递的时候传入context.TODO()
。
这里面遇到的问题,就是在创建实例节点的时候,没有自增序号的问题,我这里借助etcd
的迷你事务TXN
,通过乐观锁形式的创建方式,去创建key
,这样来防止创建重复的键值。
https://github.com/younglifestyle/seg-server
看下服务治理的大概定义:
1、服务注册与发现。
2、可观测性。
3、流量管理。
4、安全。
5、控制。
那监控就属于服务治理中的可观测性
——常见的包括监控(Metrics)、日志(Logging)、调用追踪(Trace)。而本篇则是着重讲述监控这一点。
服务在运行时,会产生很多数据指标(CPU、内存占用,QPS等),而这些数据的产生则可由服务本身记录,例如发送一个HTTP请求,就加1
,与逻辑无关,却又是与数据相关的搜集代码就是埋点操作。普通服务状态下,我们使用微服务框架去封装以及分层这一步操作。
对常见协议的Metrics收集,HTTP/GRPC。
目前业务上使用的是Go-Micro,不过Go-Micro没写HTTP的指标收集,这个比较简单,需要自己添加一下,使用gin自带的也可以。
使用docker-compose将服务一次性全部启动。
在docker-compose.yml
中加入,钉钉通知插件。
1 | #钉钉插件 |
Token从钉钉群里的自定义机器人中获取
另外注意,dingtalk组件版本为v1.4.0,v2版本后此启动方式将报错。V2版本使用。
‘config.yml’ does not exist, try --help
alertmanager/config.yml
修改:
1 | route: |
prometheus
的配置文件中,指定了alertmanager
的地址,以及报警规则的文件位置:
1 | # Load and evaluate rules in this file every 'evaluation_interval' seconds. |
alert.rules
文件:
1 | groups: |
grafana
需要饼图插件的话,可以下命令获取:
1 | https://grafana.com/grafana/plugins/grafana-piechart-panel/ |
prometheus服务自发现,不需要手动去修改文件,指定prometheus的监控程序。
https://prometheus.io/blog/2018/07/05/implementing-custom-sd/
prometheus服务发现实现:https://github.com/fabxc/prom_sd_example
非常全的对prometheus各组件采集的文档:https://erdong.site/prometheus-notes/
P95、P90的值都比较重要,是查看接口性能的一个重要指标。
]]>拉取一个基准环境镜像
运行指令
将宿主机的目录复制到镜像中,注意目录路径
改变在容器的当前目录
表示容器会暴露此端口,但不是真正的在运行时暴露这个端口,只是一个类似于文档的作用,真正的暴露还是在docker -p 8000:8000,这样做一个端口的映射
类似的是CMD命令,ENTRYPOINT 是容器启动时执行的不变的命令,CMD是可以被用户修改的参数。
ENTRYPOINT [ “echo”, “a” ]
CMD [ “b” ]
执行结果: a, b
Docker run -p 8000:8000 imageName c d e
执行结果:a c d e ( ENTRYPOINT 正常情况下不会被覆盖,CMD是提供个默认值,可被重写 )
目录:
1 | /learn-docker |
代码:
1 | package main |
Dockerfile:
1 | # 启动编译环境 |
命令执行的顺序会影响构建速度,不变的命令应在之前运行,变化的放在后面,不变的会尽量用到cache。
构建:
1 | docker build -t hello -f Dockerfile . |
这俩协议真是有太多说的了,毕竟网络里头,TCP/IP协议栈,可太重要了。
常见的,TCP是面向连接的,UDP是面向无连接的。
在互通之前,面向连接的协议会先建立连接。例如,TCP会三次握手,而UDP不会。
什么是连接呢?
所谓连接,即是两端的状态维护,中间过程没有所谓的连接,一旦传输失败,一端收到消息,才知道状态的变化
为了在客户端和服务端维护连接,而建立一定的数据结构来维护双方交互的状态,用这样的数据结构来保证所谓的面向连接的特性。
TCP提供可靠交付。通过TCP连接传输的数据,无差错、不丢失、不重复、并且按序到达。UDP继承了IP包的特性,不保证不丢失,不保证按顺序到达。
TCP是面向字节流的。发送的时候发的是一个流,没头没尾。而UDP继承了IP的特性,基于数据报的,一个一个地发,一个一个地收。
TCP是可以有拥塞控制的。它意识到包丢弃了或者网络的环境不好了,就会根据情况调整自己的行为,看看是不是发快了,要不要发慢点。UDP就不会,应用让我发,我就发。
因而TCP其实是一个有状态服务,里面精确地记着发送了没有,接收到没有,发送到哪个了,应该接收哪个了,错一点儿都不行。而UDP则是无状态服务。
IP层中的IP头里面定义了传输层是UDP还是TCP协议。
UDP可以看到格式比较简单,基本上只用到了端口号。
第一,沟通简单,不需要(大量的数据结构、处理逻辑、包头字段)。
第二,无需连接。它不会建立连接,虽然有端口号,但是监听在这个地方,谁都可以传给他数据,他也可以传给任何人数据,甚至可以同时传给多个人数据。
第三,无拥塞控制。不会根据网络的情况进行发包的拥塞控制,无论网络丢包丢成啥样了,它该怎么发还怎么发。
第一,需要资源少,在网络情况比较好的内网,或者对于丢包不敏感的应用。
第二,不需要一对一沟通,建立连接,而是可以广播的应用。
第三,需要处理速度快,时延低,可以容忍少数丢包,但是要求即便网络拥塞,也毫不退缩,一往无前的时候。
UDP虽然简单,但它有简单的用法。它可以用在环境简单、需要多播、应用层自己控制传输的地方。例如DHCP、VXLAN、QUIC等。
TCP包头很复杂,但是主要关注五个问题,顺序问题,丢包问题,连接维护,流量控制,拥塞控制;
顺序问题:为了解决包乱序问题,使用“序号”编号,确定先来后到的顺序;确认序号,发出去的包应该有确认,如果没有收到就应该重新发送,直到送达。
丢包问题:从IP层面来讲,如果网络状况的确那么差,是没有任何可靠性保证的,而作为IP的上一层TCP也无能为力,唯一能做的就是更加努力,不断重传,通过各种算法保证。
连接维护:状态位。例如SYN是发起一个连接,ACK是回复,RST是重新连接,FIN是结束连接等。TCP是面向连接的,因而双方要维护连接的状态,这些带状态位的包的发送,会引起双方的状态变更。
流量控制:解决的是发送方和接收方速率不匹配的问题,发送方发送过快接收方就来不及接收和处理。采用的机制是滑动窗口的机制,控制的是发送了但未被Ack的包数量。
拥塞控制:解决的是避免网络资源被耗尽的问题,通过大家自律的采取避让的措施,来避免网络有限资源被耗尽。当出现丢包时,控制发送的速率达到降低网络负载的目的。
流量控制和拥塞控制,一个是对另一端的,一个是针对网络的。
常称为“请求->应答->应答之应答”的三个回合。总之,这个流程即是让C/S端都做到消息一去一回。
三次握手除了双方建立连接外,主要还是为了沟通一件事情,就是TCP包的序号的问题。
Client要告诉Server,我这发起的包的序号起始是从哪个号开始的,Server同样也要告诉Client,Server发起的包的序号起始是从哪个号开始的。
为什么序号不能都从1开始呢?因为这样往往会出现冲突。
在同一时间,同一序号的包因为重启等各类因素出现在网络上,导致接收错误。
因而,每个连接都要有不同的序号。这个序号的起始序号是随着时间变化的,可以看成一个32位的计数器,每4us加一,如果计算一下,如果到重复,需要4个多小时,那个绕路的包早就死翘翘了,因为我们都知道IP包头里面有个TTL,也即生存时间。
你关闭你的发送通道,我关闭我的发送通道。(给对方留下时间准备关闭连接)
FIN_WAIT_2,如果这个时候Server直接跑路,则Client将永远在这个状态。TCP协议里面并没有对这个状态的处理,但是Linux有,可以调整tcp_fin_timeout这个参数(default 60s),设置一个超时时间。超时后会直接进入Closed状态。
TIME_WAIT状态,保证ACK能发送到对端,同时保证对端的包都被当前的Client端消耗掉(免得被下一个使用此端口的client端接收到)。
若是Client端已经等了2MSL,状态会转至Closed状态。Server超过了2MSL的时间,依然没有收到Client发的FIN的ACK,按照TCP的原理,Server还会重发FIN,这个时候Client再收到这个包之后,Client会直接发送RST,Server就知道Client已经关闭了。
等待的时间设为2MSL,MSL是Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。因为TCP报文基于是IP协议的,而IP头中有一个TTL域,是IP数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减1,当此值为0则数据报将被丢弃,同时发送ICMP报文通知源主机。协议规定MSL为2分钟,实际应用中常用的是30秒,1分钟和2分钟等。
应用层重试是解决应用层的错误
都和报文生存有关,前者是时间维度的概念,后者是经过路由跳数,不是时间单位.
TCP中的顺序问题、丢包,以及超时传递,滑动窗口、拥塞控制,非常推荐大家阅读趣谈网络协议“第12讲讲TCP协议(下)”。建议需要的时候拿出来反复理解。
这里我只提一个TCP队首阻塞的问题,HTTP的队首阻塞是因为HTTP1.1规定先收到的请求要先返回,这样,如果前面的请求耗用太多,就会出现请求队列阻塞的问题。
我们知道HTTP 2.0解决了这个问题(后面介绍HTTP2.0),但因为TCP的特性,还是有队首阻塞的问题(这里是传输层的问题,1.1是应用层的问题)。这里需要了解TCP保证顺序性的特性以及滑动窗口的相关知识。
在TCP协议中,接收端的窗口的起始点是下一个要接收并且ACK的包,即便后来的包都到了,放在缓存里面,窗口也不能右移,因为TCP的ACK机制是基于序列号的累计应答,一旦ACK了一个系列号,就说明前面的都到了,所以只要前面的没到,后面的到了也不能ACK,就会导致后面的到了,也有可能超时重传,浪费带宽。
]]>网络方面的东西,我也是后面才去详细了解的,之前知道的都比较片面,对一些点知道的比较浅,也就对UDP、TCP了解的会稍微多一点,后面当我详细去了解的时候,我发现看完之后豁然开朗,就像表面上没改变什么,但实际上对上层的事了解的更多了,很玄妙的感觉。
对于二层设备、三层设备、四层LB和七层LB的时候,其对应的就是网络协议中的不同层级。
网络中的上下层,其实更具体的说是内外层,最外层是MAC地址,最内层是HTTP包。
发送端类似于打包,接收端类似于拆封。
当一个网络包从一个网口经过的时候,你看到了,首先先看看要不要请进来,处理一把。有的网口配置了混杂模式,凡是经过的,全部拿进来。
所谓的二层设备、三层设备,都是这些设备上跑的程序不同而已。一个HTTP协议的包经过一个二层设备,二层设备收进去的是整个网络包。这里面HTTP、TCP、 IP、 MAC都有。什么叫二层设备呀,就是只把MAC头摘下来,看看到底是丢弃、转发,还是自己留着。那什么叫三层设备呢?就是把MAC头摘下来之后,再把IP头摘下来,看看到底是丢弃、转发,还是自己留着。
负载均衡又分为四层负载均衡和七层负载均衡。四层负载均衡工作在OSI模型的传输层,主要工作是转发,它在接收到客户端的流量以后通过修改数据包的地址信息将流量转发到应用服务器。
七层负载均衡工作在OSI模型的应用层,因为它需要解析应用层流量,所以七层负载均衡在接到客户端的流量以后,还需要一个完整的TCP/IP协议栈。七层负载均衡会与客户端建立一条完整的连接并将应用层的请求流量解析出来,再按照调度算法选择一个应用服务器,并与应用服务器建立另外一条连接将请求发送过去,因此七层负载均衡的主要工作就是代理。
L4负载均衡,更准确的术语是“第 3/4 层负载平衡”——因为负载平衡器的决定基于两个 IP 地址源服务器和目标服务器(第 3 层)以及应用程序的 TCP 端口号(第 4 层)。“第 7 层负载平衡”更准确的术语可能是“第 5 层到第 7 层负载平衡”,因为 HTTP 结合了 OSI 第 5、6 和 7 层的功能。
问:图中为什么经过L4转发是直连到后端server,L7不是?
答:L4是基于传输层,也就是TCP/UDP这一层,以TCP为例,看看TCP包头的格式以及IP包头格式:
一个完整的网络如下:
客户端向负载均衡发送SYN请求建立第一次连接,通过配置的负载均衡算法选择一台后端服务器,并且将报文中的IP地址信息修改为后台服务器的IP地址信息,因此TCP三次握手连接是与后端服务器直接建立起来的。
七层服务均衡在应用层选择服务器,只能先与负载均衡设备进行TCP连接,然后负载均衡设备再与后端服务器建立另外一条TCP连接通道。
到L7 Server时,网络包已经被扒得只剩下HTTP数据了,无法做更多的更底层协议的操作。但更方便做数据清洗,因为这已经是原始的数据了,可以根据规则筛选数据。
Linux 通过判断判断IP的网段,来判断一个IP是否与自己处于同一网络环境内。(至于实际其是否在同一环境则未知)。
在同一网段,它才会发送ARP请求,获取MAC地址。
如果这是一个跨网段的调用,它便不会直接将包发送到网络上,而是企图将包发送到网关。
如果你配置了网关的话,Linux会获取网关的MAC地址,然后将包发出去。
如果没有配置网关呢?那包压根就发不出去。
IP配置:不同系统的配置文件格式不同,但是无非就是CIDR、子网掩码、广播地址和网关地址。
DHCP(Dynamic Host Configuration Protocol,动态主机配置协议)是一个局域网的网络协议,使用UDP协议工作, 主要给内部网络或 网络服务供应商自动分配 IP地址
1 | sequenceDiagram |
客户机会在租期过去50%的时候,直接向为其提供IP地址的DHCP Server发送DHCP request消息包。
客户机接收到该服务器回应的DHCP ACK消息包,会根据包中所提供的新的租期以及其他已经更新的TCP/IP参数,更新自己的配置。这样,IP租用更新就完成了。
正常两台电脑是可以直接互联的,需要配置这两台电脑的IP地址(相同网段)、子网掩码和默认网关。再配上一根交叉网线。(以前不同,还以为网线都是一类——交叉直连网线,后面搞嵌入式才发现交叉、直连是两种网线,当前网卡已可以自适应)
两台电脑互联即构成局域网,即LAN。
多台电脑也可以使用集线器(Hub)。这种设备有多个口,可以将宿舍里的多台电脑连接起来。但是,和交换机不同,集线器没有大脑,它完全在物理层工作。它会将自己收到的每一个字节,都复制到其他端口上去。这是第一层物理层联通的方案。
多电脑连接中,Hub采取的是广播的模式,如果每一台电脑发出的包,宿舍的每个电脑都能收到。传输数据过程中易产生冲突,带宽利用率不高
MAC层就是用来解决多路访问的堵车问题的。而交换机的出现也是在解决了在广播的情况下,避免冲突的产生。
不使用广播方式,就需要解决几个问题:
大家都在发,会不会产生混乱?有没有谁先发、谁后发的规则?
MAC的全称是Medium Access Control,即媒体访问控制。控制在往媒体上发数据的时候,谁先发、谁后发的问题。防止发生混乱。这解决的是第二个问题。这个问题中的规则,学名叫多路访问。以太网中使用随机接入协议。(需要发就发,网络堵塞的适合就等会再发)
解决了第二个问题,就是解决了媒体接入控制的问题,MAC的问题也就解决好了。这和MAC地址没什么关系。
这个包是发给谁的?谁应该接收?
这里用到一个物理地址,叫作**链路层地址。**但是因为第二层主要解决媒体接入控制的问题,所以它常被称为MAC地址。
解决第一个问题就牵扯到第二层的网络包格式。对于以太网,第二层的最开始,就是目标的MAC地址和源的MAC地址。
接下来是类型,大部分的类型是IP数据包,然后IP里面包含TCP、UDP,以及HTTP等,这都是里层封装的事情。
有了这个目标MAC地址,数据包在链路上广播,MAC的网卡才能发现,这个包是给它的。MAC的网卡把包收进来,然后打开IP包,发现IP地址也是自己的,再打开TCP包,发现端口是自己,也就是80,而nginx就是监听80。
对于以太网,第二层的最后面是CRC,也就是循环冗余检测。通过XOR异或的算法,来计算整个包是否在发送的过程中出现了错误,主要解决第三个问题。
正常情况下是只知道对方的IP,不知道MAC地址的,这就需要用到ARP协议,就是已知IP地址,求MAC地址的协议。即发送一个广播包,谁是这个IP谁来回答。为了避免每次都用ARP请求,机器本地也会进行ARP缓存。当然机器会不断地上线下线,IP也可能会变,所以ARP的MAC地址缓存过一段时间就会过期。
交换机是有MAC地址学习能力的,学完了它就知道谁在哪儿了,不用广播了。
总结:
Hub:
1.一个广播域,一个冲突域。
2.传输数据的过程中易产生冲突,带宽利用率不高
Switch:
1.在划分vlan
的前提下可以实现多个广播域,每个接口都是一个单独的冲突域
2.通过自我学习的方法可以构建出CAM表,并基于CAM进行转发数据。
3.支持生成树算法(STP,全称Spanning Tree Protocol)。可以构建出物理有环,逻辑无环的网络,网络冗余和数据传输效率都甩Hub好几条街。SW是目前组网的基本设备之一。
CAM表,我理解其就是MAC表,也就是MAC地址与Port的对应的一个table。
冲突域(物理分段):同一物理网段上所有节点的集合或以太网上竞争同一带宽的节点集合
广播域:接收同样广播消息的节点的集合
交换机MAC头:
这样只有相同VLAN ID的包,才会互相转发,不同VLAN的包,是看不到的。
我们可以设置交换机每个口所属的VLAN。
交换机之间通过Trunk口连接,它可以转发属于任何VLAN的口。
如果没有STP算法:
ARP广播时,交换机会将一个端口收到的包转发到其它所有的端口上。
比如数据包经过交换机A到达交换机B,交换机B又将包复制为多份广播出去。
如果整个局域网存在一个环路,使得数据包又重新回到了最开始的交换机A,这个包又会被A再次复制多份广播出去。
如此循环,数据包会不停得转发,而且越来越多,最终占满带宽,或者使解析协议的硬件过载,行成广播风暴。
ICMP相当于网络世界的侦察兵。有两种类型的ICMP报文,一种是主动探查的查询报文,一种异常报告的差错报文;
ping使用查询报文,Traceroute使用差错报文。(其在网络层,自然包裹IP头)
查询报文,是一种主动请求,并且获得主动应答的ICMP协议。
对ping的主动请求,进行网络抓包,称为ICMP ECHO REQUEST。同理主动请求的回复,称为ICMP ECHO REPLY。
差错报文类型,返回时,类型代表出错类型。例如终点不可达为3,源抑制为4,超时为11,重定向为5。
差错报文后面是跟上出错的那个IP包的IP头和IP正文的前8个字节。
Traceroute:
使用IP header的TTL(Time To Live)这个field,Traceroute的参数指向某个目的IP地址,它会发送一个UDP的数据包。将TTL设置成1,路由器拿到包后将TTL减1,TTL为0,则会返回ICMP time exceeded
怎么知道UDP有没有到达目的主机呢?Traceroute程序会发送一份UDP数据报给目的主机,但它会选择一个不可能的值作为UDP端口号(大于30000)。当该数据报到达时,将使目的主机的 UDP模块产生一份“端口不可达”错误ICMP报文。如果数据报没有到达,则可能是超时。
若ICMP差错报文自身出错,则不再发送关于差错报文的差错报文。
为何传递UDP包其返回ICMP报文?
协议栈能正常走到UDP,就正常返回UDP。
还没到UDP和TCP的传输层,所以UDP出错可以返回ICMP差错报文。(ICMP属于网络层,是管理和控制IP的一种协议)
技术难,代码量可能不多,但都是一些比较核心的需要攻坚型的问题,不是靠“堆人”就能搞定的,比如自动驾驶、图像识别、高性能消息队列等;
复杂度,意思是说,技术不难,但项目很庞大,业务复杂,代码量多,参与开发的人多,比如物流系统、财务系统等。
而恰好,软件架构的终极目标是,用最小的人力成本来满足构建和维护该系统的需求。
“一切皆文件”就体现了封装和抽象的设计思想。
封装了不同类型设备的访问细节,抽象为统一的文件访问方式,更高层的代码就能基于统一的访问方式,来访问底层不同类型的设备。这样做的好处是,隔离底层设备访问的复杂性。统一的访问方式能够简化上层代码的编写,并且代码更容易复用。
除此之外,抽象和封装还能有效控制代码复杂性的蔓延,将复杂性封装在局部代码中,隔离实现的易变性,提供简单、统一的访问接口,让其他模块来使用,其他模块基于抽象的接口而非具体的实现编程,代码会更加稳定。
这里包括底层驱动都做了很多封装和抽象,实现一个新的驱动程序也只用填充已经规范好的handler
,其他的无需多少考虑。
不同的模块之间通过接口来进行通信,模块之间耦合很小,每个小的团队聚焦于一个独立的高内聚模块来开发,最终像搭积木一样,将各个模块组装起来,构建成一个超级复杂的系统。
面对复杂系统的开发,我们要善于应用分层技术,把容易复用、跟具体业务关系不大的代码,尽量下沉到下层,把容易变动、跟具体业务强相关的代码,尽量上移到上层。
对于Linux来说,还是拿驱动举例,一个PCI的串口设备,首先其是PCI设备,其又是serial设备,那将它整体注册到PCI总线上,初始化完PCI相关的之后,实际操作代码还是串口设备的方式。
分层也在DDD中体现的很明显,像repo层,专注于与数据进行交互,业务代码上浮,这样,BIZ层不需要关心底层数据库是否变化,只用具体关心数据拿到手之后做什么操作。
依赖接口,而不是依赖具体的实现。
暴露给其他层的是接口,屏蔽复杂实现于接口内部,这样内部修改,也不会影响到其他层的代码。
这个更多的是一个通用的设计思想,代码符合以上的设计理念,其自然也就符合高内聚,低耦合。
我的理解是,功能上模块进行聚合,集中在几个实例中,对外界依赖少,代码集中。向外提供接口,向内调用接口。
识别出代码可变部分和不可变部分,将可变部分封装起来,隔离变化,提供抽象化的不可变接口,供上层系统使用。当具体的实现发生变化的时候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改。
我的理解是,已经实现的接口在保持不变的前提下(面子),继承该接口,改变某个接口内部功能,完成功能扩展。
不管是自己还是团队,在参与大型项目开发的时候,要尽量避免过度设计、过早优化,在扩展性和可读性有冲突的时候,或者在两者之间权衡,模棱两可的时候,应该选择遵循KISS原则,首选可读性。
这个深有体会,在实际开发过程中,没有必要提前设想的,不要实现,比如某个功能可以动态变化,但当前以及可见的未来并不需要此功能,实际做出来就很复杂,反而当前的设计都会很难用。
在做设计或者编码的时候要遵守统一的开发规范,避免反直觉的设计。
在Go中,可能会好一点,毕竟代码风格比较统一,但实际还会有很多写代码时,用法上出现很多问题,这个问题,我觉得还是得靠code review和lint工具来解决,由上至下,加上强制工具。
在开发中,经常听到的就是,我们可以未来再重构代码,产品上线最重要,但实际上重构永远不会来,往往自己欺骗自己,屎山代码就是这么来的,而且熵增
是持续的,若不从最开始控制结构,那结构往往从一开始就不复存在。
理想的分布式文件系统应该为所有用户提供对同一组文件的一致的统一的访问,并且可以任意伸缩,以便为不断增长的用户社区提供更多的存储空间和更高的性能。尽管组件出现故障,但它仍然具有很高的可用性。这将需要最少的人工管理,并且随着添加更多组件,管理不会变得更加复杂。
Frangipani是一种新的文件系统,它近似于这种理想状态,而且由于它的两层结构,构建起来相对容易。底层是Petal(在之前的文章描述过),这是一种分布式存储服务,提供可扩展、高可用性、自动管理的虚拟磁盘。在上层,多台机器在共享的Petal虚拟磁盘上运行相同的Frangipani文件系统代码,使用分布式锁服务来确保一致性。
Frangipani是在拥有统一管理下的集群中运行的,可以安全的通信。因此,机器之间相互信任,共享虚拟磁盘方法是可行的。当然,Frangipani文件系统可以使用普通网络文件访问协议剔除不受信任的机器。
我们在运行数码UNIX 4.0的AlphaS
集合上实施了Frangipani。初始测量表明,Frangipani随着服务器的增加,其依然具有优异的单服务器性能和可扩展性。
使用当下技术构建的可应用于大型、持续增长的计算机集群的文件管理系统,处理现有业务来说是一项艰巨的任务。其困局是,为了保存更多文件并为更多用户提供服务,必须添加更多磁盘,连接到更多机器上。 这些组件中的每一个都需要人工维护。 文件组通常手动分配到特定磁盘,然后在组件装满、出现故障或成为性能热点时手动移动或复制。 使用RAID技术将多个磁盘驱动器连接成一个单元只是部分解决方案;当系统变得足够大,需要多个raid和多个服务器时,仍然会出现管理问题。
Frangipani是一种新的可扩展的分布式文件系统,它将多台机器上的磁盘集合管理为一个共享存储池。假定这些机器处于共同的管理之下,并且能够安全地通信。在构建分布式文件系统方面,已经有很多早期的尝试,它们在吞吐量和容量上都有很好的扩展性[1,11,19,20,21,22,26,31,33,34]。Frangipani的一个显著特征是它有一个非常简单的内部结构—一组相互协作的机器使用一个公共存储并用锁同步对该存储的访问。这个简单的结构使我们能够用很少的机器处理系统恢复、重新配置和负载平衡。Frangipani的另一个关键在于,它结合了一组特性,使其比我们所知道的现有文件系统更容易使用和管理Frangipani。
Frangipani位于Petal[24]之上,Petal是一个易于管理的分布式存储系统,它为客户端提供虚拟磁盘。与物理磁盘一样,Petal虚拟磁盘提供了可以在块中读写的存储空间。与物理磁盘不同,虚拟磁盘提供不连续的264字节地址空间,物理存储空间按需分配。Petal可以选择性的复制数据以实现高可用性。Petal还提供了高效的快照[7,10]来支持一致的备份。Frangipani从底层存储系统继承了许多可伸缩性、容错性和易于管理的特性,但是需要仔细设计才能将这些属性扩展到文件系统级别。下一节将详细介绍Frangipani的结构及其与Petal的关系。
图1演示了Frangipani系统中的分层。多个可互换的Frangipani服务器通过在共享的Petal虚拟磁盘上运行来提供对相同文件的访问,用锁来协调它们的操作,以确保一致性。文件系统层可以通过添加Frangipani服务器来缩放。它通过自动从服务器故障中恢复并继续使用幸存的服务器来实现容错。它在单一的网络文件服务器上提供了改进的负载平衡,通过分流文件系统负载并将其转移到正在使用这些文件的机器上。Petal和锁服务也被用于在可伸缩性、容错和负载平衡方面。
Frangipani服务器,Petal服务器和锁服务相互信任。Frangipani设计之初用于在单个管理域内的工作站集群中运行良好,但Frangipani文件系统可以导出到其他域。因此,Frangipani可以看作是一个集群文件系统。
我们已经在DIGITAL Unix 4.0下实现了Frangipani。由于Frangipani在现有Petal服务之上的清晰分层,我们能够在短短几个月内实现一个工作系统。
Frangipani针对具有程序开发和工程工作负载的环境。我们的测试表明,在此类工作负载上,Frangipani具有出色的性能,并缩小网络限制。
图2描述了系统的一个经典情况。上面显示的机器运行用户程序和Frangipani文件服务器模块;它们可以是无磁盘的。底部显示的运行Petal和分布式锁服务。
Frangipani的组件不必完全按照图2所示的方式分配给机器。Frangipani和Petal服务器不需要在单独的机器上;每台Petal机器也可以运行Frangipani,特别是在花瓣机器没有重载的情况下。分布式锁服务独立于系统的其他部分;我们展示了在每台Petal服务器上运行一个lock服务器,但它们也可以在Frangipani主机或任何其他可用的机器上运行。
如图2所示,用户程序通过标准操作系统调用接口访问Frangipani。在不同机器上运行的程序都看到相同的文件,它们的视图是一致的;也就是说,对一台计算机上的文件或目录所做的更改在所有其他计算机上都立即可见。程序基本上得到了与本地Unix文件系统相同的语义保证:对文件内容的更改通过本地内核缓冲池暂存,在下一次适用的fsync或sync系统调用之前不能保证到达非易失性存储,但是元数据的更改会被记录下来,并且可以选择在系统调用返回时保证为非易失性。与本地文件系统语义稍有不同的是,Frangipani仅粗略地维护一个文件的最后访问时间,以避免每次读取数据时都进行元数据写操作
元数据:将元数据定义为除普通文件内容以外的任何磁盘数据结构。
每台机器上的Frangipani文件服务器模块在操作系统内核内运行。它将自己注册到内核的文件系统中,作为可用的文件系统实现之一。文件服务器模块使用内核的缓冲池来缓存最近使用的文件中的数据。它使用本地Petal设备驱动程序读取和写入Petal虚拟磁盘。所有文件服务器在共享Petal磁盘上读取和写入相同的文件系统数据结构,但每个服务器在Petal磁盘的不同部分保留其自己的挂起更改重做日志。日志保存在Petal中,以便在Frangipani服务器崩溃时,另一台服务器可以访问日志并运行恢复。Frangipani服务器之间不需要直接通信;它们只与Petal和锁服务通信。这使服务器的添加、删除和恢复变得简单。
Petal设备驱动程序隐藏了Petal的分布式特性,使得Petal对于操作系统的更高层来说就像一个普通的本地磁盘。驱动程序负责于对应的Petal服务器通信,并在发生故障时切换到另一个服务器。任何Digital Unix文件系统都可以在Petal上运行,但只有Frangipani提供了从多台机器对相同文件的一致访问。
Petal服务器协同运行,为Frangipani提供大型的、可伸缩的、容错的虚拟磁盘,这些虚拟磁盘是在连接到每个服务器的普通物理磁盘之上实现的。Petal可以容忍一个或多个磁盘或服务器故障,只要Petal服务器的大部分保持正常并保持通信,每个数据块至少有一个副本保持物理上可访问。花瓣的更多细节可在另一份文件[24]。
锁服务是一种通用服务,它向网络上的客户端提供多读/单写
锁。它的实现是分布式的,以容错和可扩展的性能。Frangipani使用锁服务来协调对虚拟磁盘的访问,并在多个服务器上保持缓冲区缓存一致。
在图2所示的配置中,运行用户程序的每台计算机也运行一个Frangipani文件服务器模块。这种配置有可能实现良好的负载平衡和扩展,但会带来安全问题。任何Frangipani机器都可以读取或写入共享Petal虚拟磁盘的任何块,因此Frangipani必须仅在具有可信操作系统的机器上运行;Frangipani机器向Petal验证自己是否代表特定用户是不够的,就像在NFS等远程文件访问协议中所做的那样。完全安全性还要求Petal服务器和锁服务器在受信任的操作系统上运行,并要求所有三种类型的组件彼此进行身份验证。最后,为了确保文件数据保密,应防止用户在连接Petal和Frangipani机器的网络上窃听。
通过将机器放置在一个环境中,防止用户在机器上启动修改过的操作系统内核,并将其与用户进程无权访问的专用网络互连,可以完全解决这些问题。这并不一定意味着必须将机器锁定在具有专用物理网络的房间中;可以使用已知的用于安全引导、身份验证和加密链接的加密技术[13,37]。此外,在许多应用中,部分解决方案是可以接受的;典型的现有NFS安装对于在工作站上引导修改过的内核的用户的网络窃听甚至数据修改都不安全。到目前为止,我们还没有实施任何这些安全措施,但是我们可以通过让Petal服务器只接受来自属于受信任的Frangipani服务器机器的网络地址列表的请求,大致达到NFS安全级别。
Frangipani文件系统可以使用图3所示的配置导出到管理域之外的不受信任的机器。这里我们区分Frangipani客户端和服务器。只有受信任的Frangipani服务器与Petal和锁服务通信。它们可以位于受限环境中,并通过如上所述的专用网络互连。远程、不受信任的客户端通过单独的网络与Frangipani服务器通信,无法直接访问Petal服务器。
客户端可以使用主机操作系统支持的任何文件访问协议(如DCE/DFS、NFS或SMB)与Frangipani服务器通信,因为Frangipani看起来就像运行Frangipani服务器的机器上的本地文件系统。当然,一个支持一致性访问的协议(例如 DCE/DFS)是最好的,这样Frangipani跨多个服务器的一致性就不会在下一级丢失。( ? ) 理想情况下,该协议还应该支持从一个Frangipani服务器到另一个Frangipani服务器的故障转移。刚才提到的协议不直接支持故障转移,但是让新机器接管故障机器的IP地址的技术已经在其他系统中使用过[3,25],也可以在这里应用。
除了安全性之外,使用此客户机/服务器配置还有第二个原因。因为Frangipani在内核中运行,所以它不能在不同的操作系统甚至不同版本的Unix之间快速移植。客户端可以通过远程访问受支持的系统,从不受支持的系统使用Frangipani。
将文件系统分为两层构建的想法——较低级别提供存储库,较高级别提供名称、目录和文件——并不是Frangipani所独有的。我们知道的最早的例子是通用文件服务器[4]。然而,Petal提供的存储设施与早期的系统有很大不同,这也导致了不同的更高级别结构。第10节包含与以前系统的详细比较。
Frangipani的设计目的是与Petal提供的存储空间配合使用。我们还没有充分考虑开发NASD等替代存储抽象所需的设计更改[13]。
Petal提供了高可用性存储,可以随着资源的添加而扩展吞吐量和容量。然而,Petal没有提供在多个客户端之间协调或共享存储的功能。此外,大多数应用程序不能直接使用Petal的客户端接口,因为它是磁盘类型而不是文件类型。Frangipani提供了一个文件系统层,使Petal在保留和扩展其良好属性的同时对应用程序有用。
Frangipani的优势在于它允许透明的添加服务器、删除和故障恢复。通过将预写式日志和锁与一个统一的可访问的、高可用性的存储结合起来,它能够轻松地做到这一点。
Frangipani的另一个优势是它能够在系统运行时创建一致的备份。第8节讨论了Frangipani的备份机制。
Frangipani的设计有三个方面可能会有问题。将Frangipani与复制的Petal虚拟磁盘一起使用,意味着日志记录有时会发生两次,一次是到Frangipani日志,另一次是在Petal本身。其次,Frangipani在放置数据时不使用磁盘位置信息,事实上它不能,因为Petal虚拟了磁盘。最后,Frangipani锁定整个文件和目录,而不是单个块。我们没有足够的使用经验来评估我们设计的这些方面,但尽管如此,Frangipani在我们测试的工程工作负载上的测量性能还是不错的。
Frangipani使用Petal的大而稀疏的磁盘地址空间来简化其数据结构。这个总体思路让人想起了过去在大内存地址空间的计算机编程工作。有这么多的地址空间可用,可以慷慨地将其分割开来。
Petal虚拟磁盘有264字节的地址空间。Petal仅在写入虚拟地址时才将物理磁盘空间提交给虚拟地址。Petal还提供了一个decommit原语,可以释放支持一系列虚拟磁盘地址的物理空间。
为了保持内部数据结构的小型化,Petal以相当大的块(目前为64 KB)提交和释放空间。也就是说,每个64 KB的地址范围【a*216,(a+1)*216】(其中一些数据已写入且未解除提交)都分配有64 KB的物理磁盘空间。因此,Petal客户机不能使其数据结构过于稀疏,否则过多的物理磁盘空间将因碎片化而被浪费。图4显示了Frangipani是如何划分其虚拟磁盘空间的。
第一个区域存储共享的配置参数和内务管理信息(housekeeping information,不太理解
)。我们允许这个区域有一兆字节(TB)的虚拟空间,但实际上目前只使用了其中的几千字节。
第二个区域存储日志。每台Frangipani服务器获得 一部分空间来存放它的私人日志。我们已经为这个区域保留了 1TB(240字节)给这个区域,划分为256个日志。这 这个选择限制了我们目前的实施,使其只能容纳256个服务器,但这很容易进行调整。
第三个区域用于分配位图,以描述剩余区域中的哪些块是空闲的。每个Frangipani服务器都会锁定位图空间的一部分以供其专用。当服务器的位图空间填满时,它会查找并锁定另一个未使用的部分。位图区域的长度为3 TB。
第四个区域存放节点。每个文件都需要一个inode
来保存其元数据,如时间戳和指向其数据位置的指针。符号链接将其数据直接存储在inode节点中。我们将节点的长度定为512字节,也就是一个磁盘块的大小,从而避免了服务器之间不必要的争夺(“虚假共享”),如果两个服务器需要访问同一块中的不同节点,就会出现这种情况。我们分配了1TB的节点空间,允许231个节点的空间。分配位图和节点之间的映射是固定的,所以每个Frangipani服务器只从与分配位图的部分相对应的节点空间中为新文件分配节点。但任何Frangipani服务器都可以读取、写入或释放任何现有文件的节点。
第五个区域存放小数据块,每个4 KB(212字节)大小。一个文件的前64 KB(16个块)被存储在小块中。如果一个文件增长到超过64KB,剩下的就存储在一个大块中。我们为小块分配247个字节,因此最多允许有235个小块,是最大节点数的16倍。
Petal地址空间的其余部分存放大数据块。每个大数据块都保留了1TB的地址空间。
我们使用4KB块的磁盘布局策略可能会比更谨慎地支配磁盘空间的策略遭受更多的碎片。另外,为每个节点分配512字节的空间也有些浪费。我们可以通过将小文件存储在inode本身来缓解这些问题[29]。我们的设计所获得的是简单性,我们相信这对于额外的物理磁盘空间的成本来说是一个合理的权衡。
目前的方案将Frangipani限制在略低于224(1600万)大文件,其中大文件是指大于64KB的任何文件。另外,任何文件都不能大于16个小块加一个大块(64KB加1TB)。如果这些限制被证明太小,我们可以很容易地减少大块的大小,从而使更多的数量可用,并允许大文件跨越一个以上的大块,从而提高最大文件大小。如果264字节的地址空间限制被证明是不够的,一个Frangipani服务器可以在多个虚拟磁盘上支持多个Frangipani文件系统。
我们根据早期文件系统的使用经验,选择了这些文件系统参数。我们相信我们的选择将为我们提供良好的服务,但只有时间和使用才能证实这一点。Frangipani的设计足够灵活,我们可以以文件系统的备份和恢复为代价来试验不同的布局。
在本节中,文件一词包括目录、符号链接等。
Frangipani使用元数据的预写重做日志记录来简化故障恢复并提高性能;用户数据不被记录。每个Frangipani服务器在Petal中都有自己的私有日志。当Frangipani文件服务器需要进行元数据更新时,它首先创建一个描述更新的记录,并将其附加到其内存中的日志中。这些日志记录会按照它们所描述的更新被请求的顺序定期写入Petal。(我们可以选择让日志记录同步写入。这提供了更好的故障语义,但增加了元数据操作的延迟。)。只有在日志记录
被写入Petal之后,服务器才会修改其固定位置中的实际元数据。Unix update demon会定期(大约每30秒)更新固定位置。
日志的大小是有限制的,在目前的实现中是128KB。考虑到Petal的分配策略,一个日志将由两个不同的物理磁盘上的两个64KB的片段组成。为每个日志分配的空间被作为一个循环缓冲区管理。当日志填满时,Frangipani会回收最旧的25%的日志空间,用于新的日志条目。通常情况下,回收区域的所有条目都是指已经写入Petal的元数据块(在之前的同步操作中),在这种情况下,不需要进行额外的Petal写入。如果有尚未写入的元数据块,这项工作将在日志被回收之前完成。考虑到日志的大小和Frangipani日志记录的典型大小(80-128字节),如果在两个周期性同步操作之间有大约1000-1600个修改元数据的操作,日志就会被填满。
如果一个Frangipani服务器崩溃了,系统最终会检测到失败,并在该服务器的日志上运行恢复。故障可能是由故障服务器的客户端检测到的,或者当锁服务要求故障服务器返回它所持有的锁而没有得到答复时。恢复守护进程被隐式的赋予失败服务器的日志和锁的所有权。该守护进程找到日志的开始和结束,然后按顺序检查每条记录,执行每一个尚未完成的描述性更新。在日志处理完成后,恢复进程释放其所有的锁并释放日志。然后,其他Frangipani服务器可以不受故障服务器的阻碍,故障服务器本身也可以选择重新启动(有一个空日志)。只要底层的Petal卷保持可用,系统就可以容忍无限数量的Frangipani服务器故障。
为了确保恢复能够找到日志的结尾(即使磁盘控制器不按顺序写入数据),我们在日志的每个512字节块上附加一个单调增加的日志序列号。通过找到一个低于前一个的序列号,可以可靠地检测到日志的结束。
Frangipani确保在有多个日志的情况下,日志和恢复工作正常。这需要注意几个细节。
首先,Frangipani的锁协议,在下一节中描述,确保不同服务器对相同数据的更新请求是序列化的。覆盖脏数据的写锁只有在脏数据被写入 Petal 之后才能更改所有者,可以是原始锁持有者写入,也可以是代表它运行的恢复进程写入。这意味着对于任何给定的块,最多只能有一个日志保存未完成的更新。
其次,Frangipani确保恢复只适用于自服务器获得覆盖它们的锁后所记录的更新,并且它仍然持有这些锁。这是为了确保锁协议所规定的序列化不被违反而需要的。我们通过强制执行一个更强的条件来实现这一保证:恢复绝不重复描述已经完成的更新的日志记录。为了实现后者,我们在每个512字节的元数据块上保留一个版本号。元数据如目录,它跨越了多个块,有多个版本号。对于日志记录所更新的每一个块,该记录包含了对更改的描述和新的版本号。在恢复过程中,只有当块的版本号小于记录的版本号时,才会应用对块的修改。
因为用户数据的更新没有被记录下来,只有元数据块有预留空间给版本号。这就产生了一个强制性的问题。如果一个块被用于元数据,被释放,然后又被重新用于用户数据,那么在版本号被错误的用户数据覆盖后,引用该块的旧日志记录可能不会被正确跳过。Frangipani通过重用释放的元数据块来保存新的元数据,从而避免了这个问题。
最后,Frangipani确保在任何时候只有一个恢复进程试图重放特定服务器的日志区域。锁服务通过授予活动的恢复进程对日志的独占锁来保证这一点。
Frangipani的记录和恢复方案假定,磁盘写入失败会使单个扇区的内容处于旧状态或新状态,但绝不会同时处于这两种状态。如果一个扇区被损坏,以至于读取它时出现CRC错误,Petal的内置复制通常可以恢复它。如果一个扇区的两个副本都丢失了,或者Frangipani的数据结构被软件错误破坏了,就需要一个元数据一致性检查和修复工具(像Unix fsck)。到目前为止,我们还没有实现这样的工具。
Frangipani的日志不是为了向用户提供高级别的语义保证。它的目的是提高元数据更新的性能,并通过避免每次服务器故障时运行fsck等程序来加速故障恢复。只有元数据被记录下来,而不是用户数据,所以用户不能保证在故障后文件系统的状态在他看来是一致的。我们并不声称这些语义是理想的,但它们与标准的本地Unix文件系统所提供的相同。在本地 Unix 文件系统和 Frangipani 中,用户可以通过在适当的检查点调用 fsync 来获得更好的一致性语义。
Frangipani的日志记录是应用了最早为数据库开发的技术[2],后来被用于其他几个基于日志的文件系统[9, 11, 16, 18]。Frangipani不是一个日志结构的文件系统[32];它不把所有的数据保存在日志中,而是维护传统的磁盘数据结构,用一个小的日志作为辅助,以提供更好的性能和故障原子性。与上述其他基于日志的文件系统不同,但与日志结构的文件系统Zebra[17]和xFS[1]一样,Frangipani保留多个日志。
由于多个Frangipani服务器都在修改共享的磁盘数据结构,因此需要谨慎地进行同步,以便为每个服务器提供一致的数据视图,同时允许有足够的并发性,以便在负载增加或服务器增加时扩展性能。Frangipani使用多读/单写锁来实现必要的同步。当锁服务检测到冲突的锁请求时,会要求锁的当前持有者释放或降级以消除冲突。
一个读锁允许服务器从磁盘上读取相关数据并进行缓存。如果一个服务器被要求释放它的读锁,它必须在遵守之前使其缓存条目失效。写锁允许服务器读取或写入相关的数据并缓存它。服务器缓存的磁盘块副本只有在它持有相关的写锁时才能与磁盘上的版本不同。因此,如果一个服务器 被要求释放其写锁或将其降级为读锁,它必须在遵守之前将脏数据写到磁盘。如果是降级锁,它可以保留其缓存条目,但如果释放锁,则必须使其失效。
当写锁被释放或降级时,我们可以选择绕过磁盘,将脏数据直接转发给请求者,而不是将脏数据刷到磁盘。出于简单的原因,我们没有这样做。首先,在我们的设计中,Frangipani服务器不需要相互通信。它们只与Petal和锁服务器进行通信。其次,我们的设计确保当一个服务器崩溃时,我们只需要处理该服务器使用的日志。如果直接转发脏缓冲区,并且具有脏缓冲区的目标服务器崩溃,那么指向脏缓冲区的日志条目可能分布在多台机器上。这将给恢复和在日志空间填满时回收日志空间带来问题。
我们将磁盘上的结构分为逻辑段,并为每个段加锁。为了避免错误的共享,我们确保一个磁盘扇区不包含一个以上可以共享的数据结构。我们将磁盘上的数据结构划分为可上锁的段,旨在保持锁的数量合理地少,但又能避免普通情况下的锁争夺,从而使锁服务不成为系统的瓶颈。
因为日志是私有的,所以每个日志都是一个单独的可锁定段。位图空间也被划分为独占锁定的段,这样当分配新文件时就不会有争用。当前未分配给文件的数据块或索引节点受到分配位图段上的锁的保护,该段上的锁持有标记为空闲的位。最后,每个文件、目录或符号链接都是一个段;也就是说,一个锁同时保护inode和它所指向的任何文件数据。这种每个文件的锁粒度适合于很少并发写共享的工程工作负载。然而,其他工作负载可能需要更细粒度的锁定。
有些操作需要原子化的更新由不同锁覆盖的几个磁盘数据结构。我们通过对这些锁进行全局排序并在两个阶段获得这些锁来避免死锁。首先,一个服务器确定它需要什么锁。这可能涉及到获取和释放一些锁,例如在一个目录中查找名字。其次,它按照节点地址对锁进行排序,并依次获取每个锁。然后,服务器检查它在第一阶段检查的任何对象是否在其锁被释放时被修改。如果是的话,它就释放锁,并循环重复第一阶段。否则,它就执行操作,弄脏缓存中的一些块,并写一条日志记录。它保留每个锁,直到它覆盖的脏块被写回磁盘。
我们刚刚描述的缓存一致性协议与Echo[26]、Andrew文件系统[19]、DCE/DFS[21]和Sprite[30]中用于客户端文件缓存的协议相似。避免死锁的技术与Echo的类似。和Frangipani一样,Oracle数据库(Oracle Parallel Server),也是将脏数据写入磁盘,而不是在写入锁的后续所有者之间使用缓存到缓存的传输。
Frangipani只需要其lock server的一小部分通用功能,而且我们不希望该服务在正常运行中成为性能瓶颈,因此许多不同的实现可以满足其要求。在Frangipani项目的过程中,我们已经使用了三种不同的lock server的实现,并且其他现有的lock server可以提供必要的功能,也许只需在上面加一层薄薄的代码。
lock server提供多读/单写锁。锁是粘性的;也就是说,一个客户端通常会保留一个锁,直到其他客户端需要一个冲突的锁。(回顾一下,锁服务的客户端是Frangipani服务器)。
锁定服务使用租约来处理客户端故障[15, 26]。当一个客户端第一次通讯lock server时,它获得了一个租约。客户端获得的所有锁都与租约相关。每个租约都有一个过期时间,目前设置为创建或最后一次更新后的30秒。客户端
必须在到期时间前更新其租约,否则服务会认为它已经失败。
网络故障可以阻止Frangipani服务器更新其租约,即使它没有崩溃。当这种情况发生时,服务器会丢弃它所有的锁和缓存中的数据。如果缓存中的任何东西是脏的,Frangipani会打开一个内部标志,使所有来自用户程序的后续请求返回一个错误。文件系统必须被卸载以清除这个错误状况。我们选择了这种激烈的报错方式,使它难以被无意中忽略。
我们最初的lock server实现是一个单一的、集中的服务器,它将所有的锁状态保存在易失性内存中。这样的服务器对Frangipani来说是足够的,因为Frangipani servers和他们的日志持有足够的状态信息,即使锁服务在崩溃中失去了所有的状态,也可以恢复。然而,锁服务的失败将导致一个巨大的性能故障。
我们的第二个实施方案将锁的状态存储在Petal虚拟磁盘上,在返回客户端之前,将每个锁的状态变化写到Petal上。如果主lock server崩溃了,备份服务器将从Petal中读取当前状态并接管,以提供持续服务。有了这个方案,故障恢复更加透明,但普通情况下的性能比集中式的内存方法要差。在进入下一个实施方案之前,我们没有完全实现对所有故障模式的自动恢复。
我们的第三个也是最后一个锁服务实现是完全分布式的,用于容错和可扩展的性能。它由一组相互合作的锁服务器和一个连接到每个Frangipani服务器的办事员模块组成。
锁服务将锁组织成由ASCII字符串命名的表。表内的各个锁是由64位整数命名的。回顾一下,一个Frangipani文件系统只使用一个Petal虚拟磁盘,尽管多个Frangipani文件系统可以安装在同一台机器上。每个文件系统都有一个与之相关的表。当一个Frangipani文件系统被挂载时,Frangipani服务器调用clerk,打开与该文件系统相关的锁表。锁服务器在成功打开时给clerk一个租赁标识符,这个标识符被用于他们之间所有的次序通信。当文件系统被卸载时,clerk关闭锁表。(clerk翻译应该有点问题,我理解的客户端
)
客户端和锁服务器通过异步消息而不是RPC进行通信,以尽量减少内存的使用量,并实现良好的灵活性和性能。对锁进行操作的基本消息类型是请求、授予、撤销和释放。请求和释放消息类型是由客户端发送给锁服务器的,而授予和撤销消息类型是由锁服务器发送给客户端的。锁的升级和降级操作也是使用这四种消息类型处理的。
锁服务使用一个容错的分布式故障检测机制来检测锁服务器的崩溃。这与Petal使用的机制相同。它是基于各组服务器之间及时交换心跳信息。它使用多数共识来容忍网络分区。
锁在服务器和每个clerk那里都要消耗内存。在我们目前的实现中,服务器为每个锁分配了112个字节的块,此外还有104个字节给每个有未决或已批准的锁请求的clerk。每个客户端每个锁占用232字节。为了避免因为粘性锁而消耗过多的内存,clerk 会丢弃那些长时间(1小时)没有使用的锁。
使用Lamport的Paxos算法[23],在所有锁服务器上持续复制少量不经常变化的全局状态信息。锁服务重复使用最初为Petal编写的Paxos的实现。全局状态信息包括一个锁服务器的列表,每个服务器负责服务的锁的列表,以及已经打开但尚未关闭每个锁表的clerk的列表。这些信息被用来达成共识,在锁服务器之间重新分配锁,在锁服务器崩溃后从clerk那里恢复锁状态,并促进Frangipani服务器的恢复。为了提高效率,锁被划分为大约一百个不同的锁组,并按组分配给服务器,而不是单独分配。
锁偶尔会在不同的锁服务器之间重新分配,以弥补一个崩溃的锁服务器或利用一个新恢复的锁服务器。当一个锁服务器被永久地添加到系统中或从系统中移除时,也会发生类似的重新分配。在这种情况下,锁总是被重新分配,以便每个服务器提供的锁的数量是平衡的,重新分配的数量是最小的,并且每个锁正好由一个锁服务器提供。重新分配分两个阶段进行。在第一阶段,失去锁的锁服务器从其内部状态中丢弃这些锁。在第二阶段,获得锁的锁服务器与打开相关锁表的办事员联系。这些服务器从clerk那里恢复其新锁的状态,而clerk则被告知其锁的新服务器。
当Frangipani服务器崩溃时,在执行适当的恢复操作之前,无法释放其拥有的锁。具体来说,必须处理崩溃的Frangipani服务器的日志,并且必须将任何挂起的更新写入Petal。当Frangipani服务器的租约到期时,锁服务将要求另一台Frangipani机器上的clerk执行恢复,然后重新租用属于崩溃的Frangipani服务器的所有锁。该clerk被授予一个锁,以确保以独占方式访问日志。此锁本身由租约覆盖,因此如果此恢复过程失败,锁服务将启动另一个恢复过程。
一般来说,Frangipani系统可以容忍网络分区,在可能的情况下继续运行,否则会麻利的关闭。具体来说,Petal可以在网络分区的情况下继续运行,只要大多数Petal服务器保持正常并处于通信状态,但如果大多数分区中没有副本,Petal虚拟磁盘的部分将无法访问。只要大多数锁服务器保持正常并处于通信状态,锁服务就会继续运行。如果一个Frangipani服务器被分区离开了锁服务,它将无法续租。锁服务将宣布这样的Frangipani服务器死亡,并从它在Petal上的日志开始恢复。如果一个Frangipani服务器被脑裂无法访问Petal,它将无法读取或写入虚拟磁盘。在这两种情况下,服务器将不允许用户进一步访问受影响的文件系统,直到脑裂恢复和文件系统被重新挂载。
当Frangipani server的租约过期时,有一个小的危险。如果服务器没有真正崩溃,而只是由于网络问题与锁服务失去联系,它可能在租约过期后仍然试图访问Petal。Frangipani服务器会检查它的租约是否仍然有效(并且在一定的时间内仍然有效),在失效之前依然试图对Petal进行写入。然而,当写请求到达时,Petal不做任何检查。因此,如果在Frangipani的租约检查和随后的写请求到达Petal之间有足够的时间延迟,我们可能会有一个问题:租约可能已经过期,锁已经给了另一个服务器。我们使用了足够大的误差范围(15秒),在正常情况下,这个问题不会发生,但我们不能绝对排除它。
在未来,我们希望能消除这种危险;一种可行的方法是如下。我们在每个写给Petal的请求上添加一个到期时间戳。时间戳设置为生成写请求时的当前租约到期时间,减去锁延时删除的时间。然后我们让Petal忽略任何时间戳小于当前时间的写请求。只要Petal和Frangipani服务器上的时钟同步在差值范围内,这种方法就能可靠地拒绝租约过期的写入。
另一种不需要同步时钟的方法是将锁服务器与Petal集成,并将从锁服务器获得的租约标识符包含在每个对Petal的写入请求中。然后,Petal将拒绝任何具有过期租约标识符的写入请求。
随着Frangipani安装的增长和变化,系统管理员偶尔会需要增加或删除服务器机器。Frangipani的设计使这项任务变得简单。
在一个正在运行的系统中添加另一个Frangipani服务器,只需要少量的管理工作。新的服务器只需要被告知使用哪个Petal虚拟磁盘和在哪里找到锁服务。新的服务器与锁服务通讯以获得租约,从租约标识符中确定使用哪一部分日志空间,然后开始运行。管理员不需要接触其他服务器;它们会自动适应新服务器的存在。
移除Frangipani服务器甚至更容易。简单地关闭服务器就可以了。服务器最好刷新所有脏数据并在停止前释放其锁,但这并不是严格的需要。如果服务器突然停止,在下次启动时,它先获取一个锁,然后在它的日志上运行恢复程序,使共享磁盘进入一个一致的状态。同样,管理员不需要接触其他服务器。
Petal服务器也可以透明地添加和删除,如Petal论文[24]中所述。锁定服务器的添加和删除方式类似。
Petal的快照功能为我们提供了一种方便的方式,使Frangipani文件系统的完整转储一致。Petal允许客户在任何时间点创建一个虚拟磁盘的精确拷贝。快照副本与普通虚拟磁盘相同,只是无法修改。为了提高效率,该实现使用了写时拷贝技术。快照是崩溃一致的;也就是说,快照反映了一种一致的状态,如果所有Frangipani服务器崩溃,Petal虚拟磁盘可能会处于这种状态。
因此,我们可以简单地通过提取Petal快照并复制到磁带上来备份一个Frangipani文件系统。该快照将包括所有的日志,因此可以通过将其恢复到新的Petal虚拟磁盘,并在每个日志上运行恢复功能来恢复它。由于崩溃的一致性,从快照中恢复与从整个系统的电源故障中恢复的问题相同。
我们可以通过对Frangipani的一个小改动来改进这个方案,创建在文件系统层面上一致的快照,并且不需要恢复。我们可以通过让备份程序强制所有的Frangipani服务器进入一个屏障来实现这一目标,该屏障使用一个由锁服务提供的普通全局锁。Frangipani服务器以共享模式获得这个锁,以进行任何修改操作,而备份程序则以独占模式请求它。当Frangipani服务器收到释放屏障锁的请求时,它通过阻止所有修改数据的新文件系统调用进入屏障,清理其缓存中的所有脏数据,然后释放该锁。当所有的Frangipani服务器都进入屏障时,备份程序能够获得最终锁;然后,它创建一个Petal快照并释放锁。此时,服务器以共享模式重新获取锁,并恢复正常操作。
使用后一种方案,新快照可以作为Frangipani卷装载,而无需恢复。新卷可以在线访问以检索单个文件,也可以以传统备份格式转储到磁带上,而不需要Frangipani进行恢复。但是,新卷必须以只读方式装载,因为Petal快照当前是只读的。将来,我们可能会扩展Petal以支持可写快照,或者在Petal上实现一个分层来模拟它们。
]]>ZK的论文里面的东西感觉很多都是和ETCD相似的,之前也写过ZK的一些不同的点,原理上,毕竟其也是基于raft协议的,操作上相似感觉比较合理。
Aurora,这里面还是有很多东西的,不管是基于MySQL的改造(canal搬运log感觉很像),另外还有quorum因地制宜的使用,数据库服务与存储层服务的分层设计,简化副本复制,采用链式复制等,对这种大型服务的设计能力可见一斑。
思维导图文件:
https://pan.baidu.com/link/zhihu/79hWzOuMhjikTJ1ER3Xw1XZmQCW0JESwdsBT==
思维导图中有很多链接,以及我附带的一些文章附件,用于帮助理解。
论文翻译的地址:
【译】Amazon Aurora: Design Considerations for High Throughput Cloud-Native Relational Databases 上篇https://link.zhihu.com/?target=https%3A//xie.infoq.cn/article/09849d56c3b18064af6c7f857)
只依靠binlog是没有crash-safe能力的,所以InnoDB使用另外一套日志系统——也就是redo log来实现crash-safe能力。
这两种日志有以下三点不同。
Binlog有两种模式,statement 格式的话是记sql语句, row格式会记录行的内容,记两条,更新前和更新后都有。
图中浅色框表示是在innodb内部执行的,深色框表示是在执行器中执行的。
两阶段提交是指redo log类似于事务写日志的方式,只有在确认binlog 被实际落盘时,才会出现提交redo log的情况。
分阶段失败
1 prepare阶段 2 写binlog 3 commit
当在2之前崩溃时
重启恢复:后发现没有commit,回滚。 备份恢复:没有binlog 。
结果:一致
当在3之前崩溃
重启恢复:虽没有commit,但满足prepare和binlog完整,所以重启后会自动commit。
备份:有binlog。
结果: 一致
binlog用于备份,redolog则保证binlog的正确性,提供crash-safe能力,redo log循环写,不持久保存,binlog则进行“归档”。
MySQL中undo的内容会被记录到redo中吗?会的
比如一个事务在执行到一半的时候实例崩溃了,在恢复的时候先恢复redo,再根据redo构造undo回滚宕机前没有提交的事务
数据被修改一般会积累再内存中(如图上),累积一段后再刷入磁盘,如果这个时候崩溃,也不会出现什么问题,重启时会扫描binlog,没有写入磁盘的数据也会在此时被写入。
]]>ISOWeek
函数就不好使了,找了下发现Go没有现成的,也没人实现过,所以就有了这篇文章。推荐代码:
1 | func weekStartEnd(year, week int) (time.Time, time.Time, error) { |
以前的实现,直接上代码吧:
1 | type timex struct { |
参考链接:
]]>https://stackoverflow.com/questions/52300644/date-range-by-week-number-golang
https://stackoverflow.com/questions/45910292/get-week-number-with-week-starting-from-sunday
消息中间件的作用是为了简化应用程序对消息队列的交互,让应用程序更多的关心代码逻辑,不用关心Kafka
的操作。兼容Redis
协议,可以更为方便去掉写客户端的麻烦事(可以使用Redis
客户端,后面会提)。
兼容Redis
协议,redis
C/S使用的是TCP
连接,协议编码为二进制,类似于这样:
1 | "*3\r\n$3\r\nSET\r\n$5\r\nmykey\r\n$7\r\nmyvalue\r\n" |
也就是在处理逻辑上,套一层协议解析。像Go Redis
客户端肯定是都实现了协议解析的,但有更方便的做法,使用**redcon**。这个库帮我们做掉了协议套壳的那一层。
1 | package main |
Kafka交互使用的库是:sarama。
以前sarama
这块实现缺的地方是对offset
的更多底层操作,没将mark权限暴露给开发者来做。需要借助另一个库sarama-cluster 来做。但现在它已经把这块给做掉了,所以单用这个库是没有什么问题的。
实际操作参考官方库案例:https://github.com/Shopify/sarama/blob/main/examples/consumergroup/main.go
客户端的编写会是比较麻烦的点,应该中间件使用的是redis
协议,那客户端需要也兼容它,那我们怎么兼容呢?
由于服务端使用了Redis
兼容,那客户端,是可以直接使用Redis
的库的,比如go-redis/redis。选用这个库是因为其把连接池和重试策略都做了,这样我们可以复用这个连接库,往里面添加自己的逻辑代码即可。
1 | redis.Options{ |
最好,整体写完中间件全部服务还是花了一些功夫,主要是一些细节需要抠,特别是Kafka sarama
的使用,大家还是优先使用sarama-cluster,我看了一些仓库使用sarama
的方式,里面其实也有一些细节处有问题,但可能其他库的使用环境不会触发那些个问题把。
另外就是使用redis
库时,中间件不回发数据,客户端其会重试发送数据,尝试获取返回结果,所以返回数据时需要妥善处理。