软件设计 架构 阮一峰
2024-03-04
几乎所有我喜欢的软件架构师,都认同康威定律(Conway Law),认为这个定律非常重要,足以影响到所有系统。而且,你没法与之抗争,想要抗拒康威定律注定要失败。
康威定律的最好表述是:“任何系统的构成,都反映了设计这个系统的组织结构。”
它的出处是 Melvin Conway 在 1968 年写的一篇文章。后来,弗雷德·布鲁克斯(Fred Brooks)在著名的《人月神话》(The Mythical Man-Month)引用了这条定律。
Melvin Conway 观察到,软件系统的架构看起来与构建它的开发团队的组织结构非常相似。
最初的描述是,如果一个团队编写一个编译器,那么它将是一个单通道编译器;但是,如果两个团队共同开发,那么它将是一个双通道编译器。这个描述后来被发现,广泛适用于大量系统。
正如我的同事 Chris Ford 对我说的:“软件耦合是由人类交流促成的。” 如果我可以轻松与代码作者交谈,那么我就更容易对代码有更深入的了解,因此我的代码更容易耦合到该代码。
应对康威定律的第一步是不要与之抗争。我仍然记得一位技术主管,他刚刚被任命为 一个大型新项目的架构师,该项目由分布在世界各地不同城市的六个团队组成。“我做出了第一个架构决定”,他告诉我:“就是这个系统将有六个主要的子系统。我不知道它们会是什么子系统,但肯定会有六个。”
为了适应康威定律,现在有一种策略,就是一旦定下软件架构,就相应改变组织结构,让紧密耦合模块的开发者更容易沟通。
Redis 架构 分布式锁
2022-01-18
SET lockKey endpointFlag EX 10 NX
架构 微服务 etcd
2021-12-09
简介
etcd 是 CoreOS 的一个子项目,KV 数据库,原是设计用于存储集群共享配置数据,基于 Apache 协议开源。
PS: CoreOS 2018 年被 RedHat 收购,然后更名为 Container Linux, 后来和 RedHat 的 Project Atomic 合并,形成 Fedora CoreOS 项目。
本质是一个 KV 存储,从这个角度看和 Redis 很像。但:
Item |
etcd |
Redis |
性能 |
高 |
非常高 |
存储方式 |
磁盘 + 内存缓存 |
内存 |
持久化 |
boltdb |
AOF + rdb |
数据类型 |
string |
丰富 |
API |
gRPC |
RESP |
一致性 |
Raft |
复制 |
PS:性能:更高 QPS + 更低延迟
PS:RESP:REdis Serialization Protocol(TCP 文本协议)
作用
本质就是一个轻量级的 KV 存储系统,由于其高并发、支持分布式(强一致性)、支持版本控制、支持实时更新通知等特点,所以常用于:
- 服务发现:帮助分布式系统中的服务相互找到并建立连接。
- 配置管理:集中管理和动态更新分布式系统的配置信息。
- 分布式锁:确保在分布式环境中,同一时间只有一个客户端能获取锁,避免资源冲突。
概念
-
Raft 算法
Raft 是一种用于管理复制日志的一致性算法,节点之间通过复制日志来达成一致状态。
当客户端发起写请求时,领导者(Leader)节点接收请求并将其记录到日志中,接着把日志条目复制到其他跟随者(Follower)节点。
只有当多数节点都成功复制了该日志条目后,该条目才会被提交,数据更新才会被应用。
-
角色相关概念
- Node(节点)/Member(成员):指 etcd 集群中的单个服务器实例,每个节点都有一个唯一标识,存储着部分或全部的数据副本,并且参与集群的一致性协议。
- Leader(领导者):负责处理客户端写请求和协调日志复制的节点。同一时刻集群中只有一个领导者,它会接收客户端的写操作,并将这些操作广播给其他跟随者节点。
- Follower(跟随者):跟随领导者的指令,接收并复制领导者发送的日志条目。跟随者不直接处理客户端的写请求,只响应领导者的请求。
- Candidate(候选人):在 Raft 算法的选举过程中,当跟随者在一定时间内没有收到领导者的心跳信息时,它会转变为候选人状态。
候选人会发起新一轮的选举,向其他节点请求投票。如果获得多数节点的投票,候选人就会成为新的领导者。
- Peer(对等节点):指集群中地位平等的其他节点,节点之间相互通信以实现数据复制、选举等功能。
-
Term(任期)
逻辑时间概念,用于划分不同的选举周期,确保选举的正确性和防止过期的领导者重新掌权,同时在日志复制和消息传递中也起到重要的同步和协调作用。
每个任期从一次选举开始,有一个唯一的递增编号。
在一个任期内,最多只能有一个领导者。
如果选举失败或领导者故障,会开启一个新的任期。
结构


和 Redis 对比
- 两者都是 key-value 类型存储
- Redis 是 C 写的,而 etcd 是 Go 写的
- Redis 支持多种数据类型,而 etcd 不支持
- Redis 支持 Lua 编程,而 etcd 不支持
- Redis 支持简单的权限控制,而 etcd 不支持
- Redis 采用 RESP 私有协议,而 etcd 采用 GRPC 或 HTTP (JSON)
- Redis 支持复制集的方式同步数据,而 etcd 则是通过 Raft 实现强一致性
- Redis 通过 RDB (快照) / AOF (增量) 的方式持久化
部署 (Ubuntu)
sudo apt install -y etcd
etcd --version
sudo systemctl start etcd
sudo systemctl status etcd
部署 (Docker)
docker-compose.yml
:
version: "3"
networks:
etcd_net:
driver: bridge
ipam:
driver: default
config:
- subnet: ${NETWORK_CONFIG_SUBNET}
services:
etcd-0:
networks:
etcd_net:
ipv4_address: ${ETCD_01_NETWORKS_ETCD_NET_ADDRESS}
image: quay.io/coreos/etcd:latest
ports:
- ${ETCD_01_NETWORKS_ETCD_NET_ADDRESS}:4001:4001
- ${ETCD_01_NETWORKS_ETCD_NET_ADDRESS}:2380:2380
- ${ETCD_01_NETWORKS_ETCD_NET_ADDRESS}:2379:2379
hostname: etcd-0
environment:
- GOMAXPROCS=2
command: >-
/usr/local/bin/etcd
-name etcd-0
-advertise-client-urls http://etcd-0:2379,http://etcd-0:4001
-listen-client-urls http://${ETCD_01_NETWORKS_ETCD_NET_ADDRESS}:2379,http://${ETCD_01_NETWORKS_ETCD_NET_ADDRESS}:4001
-initial-advertise-peer-urls http://etcd-0:2380
-listen-peer-urls http://${ETCD_01_NETWORKS_ETCD_NET_ADDRESS}:2380
-initial-cluster-token etcd-cluster
-initial-cluster etcd-0=http://etcd-0:2380,etcd-1=http://etcd-1:2380,etcd-2=http://etcd-2:2380
-initial-cluster-state new
etcd-1:
networks:
etcd_net:
ipv4_address: ${ETCD_02_NETWORKS_ETCD_NET_ADDRESS}
image: quay.io/coreos/etcd:latest
ports:
- ${ETCD_02_NETWORKS_ETCD_NET_ADDRESS}:4001:4001
- ${ETCD_02_NETWORKS_ETCD_NET_ADDRESS}:2380:2380
- ${ETCD_02_NETWORKS_ETCD_NET_ADDRESS}:2379:2379
hostname: etcd-1
environment:
- GOMAXPROCS=2
command: >-
/usr/local/bin/etcd
-name etcd-1
-advertise-client-urls http://etcd-1:2379,http://etcd-1:4001
-listen-client-urls http://${ETCD_02_NETWORKS_ETCD_NET_ADDRESS}:2379,http://${ETCD_02_NETWORKS_ETCD_NET_ADDRESS}:4001
-initial-advertise-peer-urls http://etcd-1:2380
-listen-peer-urls http://${ETCD_02_NETWORKS_ETCD_NET_ADDRESS}:2380
-initial-cluster-token etcd-cluster
-initial-cluster etcd-0=http://etcd-0:2380,etcd-1=http://etcd-1:2380,etcd-2=http://etcd-2:2380
-initial-cluster-state new
etcd-2:
networks:
etcd_net:
ipv4_address: ${ETCD_03_NETWORKS_ETCD_NET_ADDRESS}
image: quay.io/coreos/etcd:latest
ports:
- ${ETCD_03_NETWORKS_ETCD_NET_ADDRESS}:4001:4001
- ${ETCD_03_NETWORKS_ETCD_NET_ADDRESS}:2380:2380
- ${ETCD_03_NETWORKS_ETCD_NET_ADDRESS}:2379:2379
hostname: etcd-2
environment:
- GOMAXPROCS=2
command: >-
/usr/local/bin/etcd
-name etcd-2
-advertise-client-urls http://etcd-2:2379,http://etcd-2:4001
-listen-client-urls http://${ETCD_03_NETWORKS_ETCD_NET_ADDRESS}:2379,http://${ETCD_03_NETWORKS_ETCD_NET_ADDRESS}:4001
-initial-advertise-peer-urls http://etcd-2:2380
-listen-peer-urls http://${ETCD_03_NETWORKS_ETCD_NET_ADDRESS}:2380
-initial-cluster-token etcd-cluster
-initial-cluster etcd-0=http://etcd-0:2380,etcd-1=http://etcd-1:2380,etcd-2=http://etcd-2:2380
-initial-cluster-state new
etcdctl
etcdctl - A simple command line client for etcd.
ENDPOINTS=ip:port,ip:port,ip:port
# set
etcdctl --endpoints=$ENDPOINTS put test1 111
etcdctl --endpoints=$ENDPOINTS put test2 222
etcdctl --endpoints=$ENDPOINTS put test3 333
# get
etcdctl --endpoints=$ENDPOINTS get test1
etcdctl --endpoints=$ENDPOINTS get test1 --write-out="json"
etcdctl --endpoints=$ENDPOINTS get test --prefix
# delete
etcdctl --endpoints=$ENDPOINTS del test1
etcdctl --endpoints=$ENDPOINTS del test --prefix
# 集群信息
etcdctl --write-out=table --endpoints=$ENDPOINTS endpoint status
etcdctl --write-out=table --endpoints=$ENDPOINTS member list
示例 (Golang)
go get go.etcd.io/etcd/client/v3
package main
import (
"context"
"fmt"
"time"
etcd "go.etcd.io/etcd/client/v3"
)
func main() {
cli, err := etcd.New(etcd.Config{
Endpoints: []string{"192.168.31.204:2379"},
DialTimeout: 3 * time.Second,
})
if err != nil {
fmt.Printf("etcd ConnectError: %v\n", err)
return
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
resp, err := cli.Get(ctx, "test1")
cancel()
if err != nil {
fmt.Printf("etcd GetError: %v\n", err)
}
for _, kv := range resp.Kvs {
fmt.Printf("etcd KeyVlue: %s, %s\n", kv.Key, kv.Value)
}
}
示例 (Python)
python3 -m pip install -u etcd3
import etcd3
etcd = etcd3.client(host='etcd-host-01', port=2379)
testkey = '/key'
testval = '1234'
etcd.put(testkey, testval)
v = etcd.get(testkey)
print(v)
assert v == testval
etcd.delete(testkey)
参考资料与拓展阅读
架构 日志 Loki
2021-12-05
Loki
Grafana 公司出品的一个日志系统。才出来没两年,是一个相对较年轻的项目,不过已经有一定知名度了。
业界最为知名的日志系统是 ELK,它对日志做全文索引,搜索起来最快、最灵活,同时大量索引导致存储成本相对较高。
Loki 则将日志分成时间戳、标签、正文三部分,标签就是索引,存储在
Promtail
Grafana
Grafana 是一个数据面板,常用于监控系统。它本身不会收集和存储数据,而是通过接入其他数据源来实现。
通过内置的插件,Loki 可以支持各种关系型数据库和时序数据库(Zabbix 一般配套使用 MySQL 做存储,Prometheus 本身就可以认为是一个时序数据库),也支持 Loki,Elasticsearch 这样的数据源。
实验
Install Loki & Promtail
# 获取最新版本号
# LOKI_VERSION=$(curl -s https://api.github.com/repos/grafana/loki/releases/latest | jq -r .tag_name)
LOKI_VERSION=$(curl -s https://api.github.com/repos/grafana/loki/releases/latest | grep -Po '"tag_name": "\Kv[0-9.]+')
# 下载 loki & promtail
curl -O -L "https://github.com/grafana/loki/releases/download/${LOKI_VERSION}/loki-linux-amd64.zip"
curl -O -L "https://github.com/grafana/loki/releases/download/${LOKI_VERSION}/promtail-linux-amd64.zip"
# loki : 18M -> 57M
# promtail: 21M -> 74M
# 解压 & 设置
unzip loki-linux-amd64.zip promtail-linux-amd64.zip
sudo mv -n loki-linux-amd64 /usr/local/bin/loki
sudo mv -n promtail-linux-amd64 /usr/local/bin/promtail
# chmod a+x /usr/local/bin/{loki,promtail} # already 755
# 下载配置文件
sudo -E wget -qO /etc/loki.config.yaml "https://raw.githubusercontent.com/grafana/loki/${LOKI_VERSION}/cmd/loki/loki-local-config.yaml"
sudo -E wget -qO /etc/promtail.config.yaml "https://raw.githubusercontent.com/grafana/loki/${LOKI_VERSION}/clients/cmd/promtail/promtail-local-config.yaml"
ls -l /etc/{loki,promtail}.config.yaml
# 启动 loki
loki -config.file /etc/loki.config.yaml
# 在另一个终端查看
browse http://localhost:3100/metrics
# 启动 promtail
Install Grafana
Install on Debian or Ubuntu
sudo apt-get install -y apt-transport-https software-properties-common wget
wget -q -O - https://packages.grafana.com/gpg.key | sudo apt-key add -
echo "deb https://packages.grafana.com/oss/deb stable main" | sudo tee -a /etc/apt/sources.list.d/grafana.list
# Bate 版本
# echo "deb https://packages.grafana.com/oss/deb beta main" | sudo tee -a /etc/apt/sources.list.d/grafana.list
sudo apt-get update
sudo apt-get install -y grafana
# 无法创建主目录"/usr/share/grafana"
# sudo systemctl daemon-reload
# sudo systemctl enable grafana-server
sudo systemctl start grafana-server
browse http://localhost:3000
参考资料与拓展阅读
开发者 架构
2021-08-22
阅读了来自公众号PM圈子的一篇文章《如何搭建一个拖垮公司的技术架构?》,反过来说的话,大概就是要注意以下方面。
开发者 架构 阮一峰
2019-11-19
阮一峰的博文(容错,高可用和灾备)中说:
- 容错:发生故障时,如何让系统继续运行。
飞机的四个引擎坏了一个还能继续飞行,汽车的四个轮子坏了一个也能将就驾驶。
- 高可用:系统中断时,如何尽快恢复。
汽车的备胎,用于快速恢复正常驾驶(允许短暂的业务中断)。
- 灾备:系统毁灭时,如何抢救数据。
飞机的弹射装置,保证最核心的“资产” —— 驾驶员能够存活。
架构 缓存
2019-02-12
什么地方需要缓存?
- DB 查询,加上缓存可以减小数据库压力,同时提升性能。
- 某些费时费资源的操作,加上缓存可以避免重复计算。
缓存方式
- 本地缓存
- 进程内缓存(内存)
- 磁盘缓存
- 缓存服务(redis/memcache)
- 数据库:有些任务可以提前进行计算,将结果存在数据库中。
本地缓存的问题是多个节点之间容易出现数据不一致的情况。我听说过 Java 的一些本地缓存组件,应该其中有一些可以做到多个节点之间的数据同步。如果要是自己实现的话,可以在服务中增加一个刷新缓存的接口调用,其中一个节点刷新缓存时,调用其他节点的刷新接口。也可以引入 MQ,避免这个调用造成的耦合和可能的性能损耗。
缓存策略
- 提前进行一些计算,将内容缓存起来。如有必要,可以选择合适的时间间隔进行数据刷新。
- DB 缓存可以由数据库中间层来做,也可以有客户端库来做,或者就在应用的数据库层中实现。
查询时,先尝试本地缓存,再尝试缓存服务(两道缓存,避免击穿),最后再进行数据库查询。
缓存的数据应该是这样的:
- 高命中(缓存命中率需要做好监控)
- 较少变更
- 尽可能保证数据变更之后(不一致问题)不会产生严重影响
如果一致性要求很高的话,要反复确实是否必须使用缓存,如果确定的话,缓存刷新策略需要考虑清楚。
穿透
数据库没有数据,缓存也没有数据。这样的请求直接穿过缓存读数据库,给数据库照成压力。
- 一般是非法请求所致,对于这一部分请求应该有机制可以进行过滤掉
恶意请求,或者高频请求
- 可以对没有数据的请求也进行缓存,可以给一个相对小一点的 TTL
- 布隆过滤器 参考:2021/03/07, 布隆过滤器
- 将空 Key 单独存到一个 Redis SET 中应该也可以
比如 system:cache:empty_keys:hhmm
, TTL = 123,每次用 SISMEMBER
查询当前分钟和上一分钟
击穿
某个热 Key 失效,导致大量请求打到数据库。
热 Key 应该预热,然后有一个比较大的 TTL,甚至没有过期时间。最后,通过定时刷新任务来更新这些热 Key。
雪崩
大量 Key 同时过期, 导致请求直接打到数据库,然后影响整个系统。
大面积的击穿。
- 比如系统刚启动的时候,批量写入大量数据,这些数据有相同的 TTL,就会同时过期。我们应该给过期时间加入一些随机,将过期时间点分散在一个区间内。
- 对于热 Key 的处理, 同击穿部分。
其他:
- 为了防止 Redis 奔溃,导致系统崩溃,应该在本地进程中也设置一个缓存。
LocalCache -> RedisCache -> DB
- 为了防止数据库奔溃,数据库请求应该由一个队列来处理。
- 网关部分对于大量来不及处理的请求应该丢弃。
- 缓存应该能够按重要性划分一下级别,如果遇到问题能够快速丢弃不重要的数据
还应该可以快速丢弃指定服务的所有缓存。
这应该叫做缓存降级。
预热
根据之前的经验,或者开发者预判,将部分数据事先写入缓存。
如果提供相关工具,让系统维护人员能够方便快捷地管理缓存数据,能够手动介入缓存的生命周期就更好了。至少在后台提供一个 缓存预热
的按钮。
- 提供指定热 Key,让定时任务负责刷新。
智能预热:给访问量大的 Key 延长 TTL, 启动定时刷新。
- 需要对缓存的访问有一个简单的监控,方便作为之后预热的依据。
缓存更新
另起一篇:缓存更新策略
架构 DNS
2018-05-02
历史
从阿帕网 (ARPANET) 时代一直到互联网的早期,网络节点比较少,都是通过本地 hosts 文件来实现主机名到 IP 地址的映射。
根据维基百科的信息,斯坦福研究所负责维护了一个公共 hosts 文件,大家会找他同步 (rfc606, rfc608)。
PS: 这个时候如果有主机名重复了谁来管?打电话过去让他们改名?
这套机制一直运行了十几年,公共 hosts 文件已经变的很大了,变化也很频繁(IP 可能已经不再那么固定了),需要经常同步。这个时候,斯坦福研究所的网络压力也越来越大了。
后来人们开始设计域名和域名相关的公共设施 (rfc805, rfc830)。最后,在 1983 年,形成了下面两个 RFC 文档:
- RFC 882, DOMAIN NAMES - CONCEPTS and FACILITIES
- RFC 883, DOMAIN NAMES - IMPLEMENTATION and SPECIFICATION
几年后(1987),正式的 DNS 标准 RFC 1034 和 RFC 1035 推出。
这套标准一直运行到现在,可能有对其进行拓展(比如 DNS 记录类型不断添加,Unicode 字符引入),但是基本技术设计没有改变。
DNS 的管理权问题
https://zhidao.baidu.com/question/1386069665602139980.html
基本流程
比如本站域名 www.markjour.com, 其完整形式应该是 www.markjour.com.
(后面多一个小数点)
DNS 软件
- BIND
- PowerDNS
- dnsmasq
- Unbound
- CoreDNS
- SmartDNS
Cache-Only DNS Server
新的发展
- 标准的 DNS 是运行在 UDP 53 端口上的。后来的 RFC 1123 增加了 TCP 的支持, 这个方案叫做 DNS over TCP, 还是在 53 端口。
- DNSCrypt, 2011 年设计的, 实现 DNS 的加密和验证,运行于 443 端口。注意:存在于 IETF 框架之外,但是好像有很多服务器支持。
- DNS over TLS (DoT), 2016 年 5 月成为规范。
RFC 7858 Specification for DNS over Transport Layer Security (TLS)
主要作用是加密传输,防止窃听。
- DNS over HTTPS (DoH), 2018 年 10 月成为规范。
RFC 8484 DNS Queries over HTTPS (DoH)
作用和 DoT 一样。
- DNS over TOR, 2019 年。
- Oblivious DNS-over-HTTPS (ODoH), 透过代理的方式,让 DoH 服务器无法获取客户端的真实 IP。同时代理无法获取 DNS 请求的内容。
参考资料与拓展阅读