2022年10月

在本文中,并没有实现过程,算是一个提纲,当我们需要对MySQL进行优化的时候,可以通过本篇文章,了解一下,我们要对MySQL进行哪些优化,也算是对MySQL的优化先要有一个总体的认知。

因为如果要将所有的操作过程都放到这篇文章中,估计还没等看完,你就已经睡着了,具体的操作,我会在其他系列的博文中陆续的更新出来。

优化方向

  • 数据表数据类型优化
  • 索引优化
  • SQL 语句优化
  • 存储引擎的优化
  • 数据表结构设计的优化
  • 数据库服务器架构的优化

数据表数据类型优化

在做数据表结构类型优化时,我们需要考虑如下几点:

  • 字段使用什么样的数据类型更合适
  • 字段使用什么样的数据类型性能更快

Int: tinyint、smallint、bigint

在使用Int类型存储值的时候,一定要按需选择,需要对空间、范围进行考虑,比如存储年龄通常是0~120多,所以我们选择一个最小的tinyint类型,如果加上无符号那么最大值是255,足够我们存储年龄,选择smallint或者bigint明显是一种浪费。

char、varchar

char 的空间效率要比 varchar 要好,如果是存储电话号码,固定为11位,这种类似的需求,使用char要更好。

对于varchar存储一些可变的数据,如用户名。

 enum

特性、固定的分类可以使用 enum 存储,效率更快。

一定要是固定的分类,如果将来的分类会变,就不适合使用enum,因为这样我们就需要去修改数据库表结构,扩展性会变得很差。

IP 地址的存储

IP 地址一般会采用字符型来进行存储,但是这种存储方式会带来很大的空间占用,我们可以使用整型来进行存储,PHP为我们提供了转换函数 ip2lang

索引优化

在做索引型优化时,我们需要考虑如下几点:

  • 建立合适的索引
  • 索引在什么场景下效率最高

索引的创建原则

  • 索引不是越多越好,在合适的字段上创建合适的索引,索引本身会影响我们的写操作的速度,并且会占用磁盘空间。
  • 符合索引的前缀原则。

索引的注意事项

  • 符合索引的前缀原则
  • like 查询%的问题
  • 全表扫描优化
  • or 条件索引使用情况
  • 字符串类型索引失效的问题

SQL 语句的优化

  • 优化查询过程中的数据访问

    • 使用Limit
    • 返回列不用 *
  • 优化长难句的查询语句

    • 变复杂为简单
    • 切分查询
    • 分解关联查询
  • 优化特定类型的查询语句

    • 优化count()
    • 优化关联查询
    • 优化子查询
    • 优化 Group by 和 distinct
    • 优化 limit 和 union

存储引擎的优化

  • 尽量使用 InnoDB 存储引擎

数据表结构设计的优化

  • 分区操作

    • 通过特定的策略对数据表进行物理拆分
    • 对用户透明
    • partition by
  • 分库分表

    • 水平拆分
    • 垂直拆分

数据库架构的优化

  • 主从复制
  • 读写分离
  • 双主热备
  • 负载均衡

    • 通过 LVS 的三种基本模式实现负载均衡
    • MyCat 数据库中间件实现负载均衡。

在上一小节,我们使用 bat 脚本完成了服务的部署,那么在这一些小节我们将搭建 Nginx 来反向代理我们的 golang 应用,既然是私活级的项目,那肯定是越快上线越好,使用 Docker 我们可以快速上线各类应用。

使用 alpine 镜像启动 Golang 应用程序

上一节我们在构建Golang 的时候使用了 alpine3.16 那么我们在打包 Golang 镜像的时候最好与打包镜像保持一致,防止有冲突。

[root@localhost conf]# docker pull alpine:3.16
3.16: Pulling from library/alpine
213ec9aee27d: Already exists
Digest: sha256:bc41182d7ef5ffc53a40b044e725193bc10142a1243f395ee852a8d9730fc2ad
Status: Downloaded newer image for alpine:3.16
docker.io/library/alpine:3.16
[root@localhost conf]# docker images
REPOSITORY   TAG                 IMAGE ID       CREATED         SIZE
nginx        latest              2d389e545974   2 weeks ago     142MB
golang       1.18.6-alpine3.16   b68eed002951   3 weeks ago     328MB
alpine       3.16                9c6f07244728   7 weeks ago     5.54MB
nginx        1.19-alpine         a64a6e03b055   17 months ago   22.6MB

因为我们在上一节的构建镜像使用的就是 3.16 所以在 docker pull 的时候就直接用了本地的 layer,从此就可以看到 docker 的强大,并且 alpine 体积足够小只有5.54 MB。

接下来我们使用 docker 来运行 api-server。

docker run --name api-server -d -v /data/server/api-server/:/app -w /app alpine:3.16 ./ser
ver

一个在线生成 Nginx 配置的网站

it.baiked.com 提供了一个可以快速生成 nginx 配置的工具,点击链接进行调转

2022-10-03T07:47:40.png

首先,nginx 配置这个东西是很基础的,所有的开发者都应该去学习,但是这个东西如果在你已经会用了的情况下,还是能够给我们的日常工作一些帮助。

2022-10-03T07:48:04.png

我们先将隐藏 nginx 版本点选,然后修改配置文件路径 /etc/nginx/nginx.conf,点击增加server。

2022-10-03T07:48:14.png

在 server 中修改我们的域名,注意在规则中,我们将其设置为了 api/ 然后对程序部署目录和根目录进行修改,最后点击生成。

生成好的 config,如下:

# Generated by xubo it.baiked.com

user nginx;
pid /run/nginx.pid;
worker_processes auto;
worker_rlimit_nofile 65535;

events {
    multi_accept on;
    worker_connections 65535;
}

http {
    charset utf-8;
    tcp_nopush on;
    server_tokens off;
    types_hash_max_size 2048;
    client_max_body_size 1M;

    # MIME
    include mime.types;
    default_type application/octet-stream;

    # logging
    access_log /var/log/nginx/access.log;
    error_log /var/log/nginx/error.log warn;


    # load configs
    include /etc/nginx/conf.d/*.conf;

    # sihuo.maksim.website
    server {
        listen 80;
        listen [::]:80;
    
        server_name sihuo.maksim.website;
        set $base /usr/share/nginx;
        root $base/html;


        # api/
        location /api/ {
        
            proxy_pass http://127.0.0.1:8080/;
        
            proxy_set_header HOST $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Real-IP $remote_addr;
        }
    }
}

安装 Nginx

我们需要将上面生成好的 nginx.conf 映射到 /etc/nginx/nginx.conf 中。

[root@localhost ~]# docker pull nginx:1.19-alpine

[root@localhost ~]# docker run --name nginx -d -v /data/data/nginx/conf/nginx.conf:/etc/nginx/nginx.conf -p 80:80 nginx:1.19-alpine

[root@localhost ~]# firewall-cmd --zone=public --add-port=80/tcp --permanent

[root@localhost ~]# firewall-cmd --reload

创建网络,让两个容器间进行通信

[root@localhost api-server]# docker network create -d bridge network-bridage
[root@localhost api-server]# docker network ls
NETWORK ID     NAME              DRIVER    SCOPE
e7f6b718f7dd   bridge            bridge    local
7f0c4539817b   host              host      local
1ea2a74e9f5c   network-bridage   bridge    local
1366058279d2   none              null      local

[root@localhost api-server]# docker network connect network-bridage api-server
[root@localhost api-server]# docker network connect network-bridge nginx-server

我们使用docker inspect 来查看容器的IP

[root@localhost api-server]# docker inspect api-server

"network-bridage": {
                    "IPAMConfig": {},
                    "Links": null,
                    "Aliases": [
                        "9b616e730cb0"
                    ],
                    "NetworkID": "1ea2a74e9f5ce9edaf32132a5b928c5859093baf4f5a76dbfcf172a1325ed395",
                    "EndpointID": "ed48e38a0e63b88fecc3030bcdc1604a10c460724ed4ee1ab5973a8ccc9bd769",
                    "Gateway": "172.18.0.1",
                    "IPAddress": "172.18.0.2",
                    "IPPrefixLen": 16,
                    "IPv6Gateway": "",
                    "GlobalIPv6Address": "",
                    "GlobalIPv6PrefixLen": 0,
                    "MacAddress": "02:42:ac:12:00:02",
                    "DriverOpts": {}
                }

然后修改nginx proxy_pass 的IP,最终在浏览器中查看

image-20221003070208270

小结

至此,我们的项目可以对外访问了,也完成了整个项目的 Docker 化,在这一小节中我们的部署方式也变成了 Docker ,但是脚本文件并没有进行修改,修改的部分我放到了下一章节,在下一章节,我们将解决线上的版本回滚。

我们在上一小节实现了应用的自动部署,但是其中有一个小问题,我们的部署依赖于线下打包,然后使用 scp 发给服务器,这个时候如果应用程序体积过大或者网络情况不好,那么就会非常耗时,你不能保证你时时刻刻都在一个非常良好的网络条件下。

这个时候我们就需要使用构建服务器了,但是使用 Jenkins 也需要新的服务器资源才可以,而且还需要进行学习,学习也是一个成本,最近呢,你还比较忙,真是抽不出来时间,那怎么办呢?

其实 Jenkins 的自动部署也是要依赖于我们的脚本的,我们使用 shell 完全可以解决这个问题,毕竟在没有自动化 CICD 之前,大家都是用 shell 解决的。

注意:这里是私活级!!!!

注意:这里是私活级!!!!

注意:这里是私活级!!!!

重要的事情说三遍,在一般的公司不会允许这么搞的!

想要在远程构建,首先我们需要解决代码如何同步到服务器上。

将代码上传至服务器

如果想要在线上进行打包,那么首先就需要将代码上传至服务器。

Goland 是一个强大的 IDE,其中为我们提供了上传代码的功能,这样一来我们就可以达到上传代码到服务器的目的。

在正常的公司里是肯定不允许这样操作的!

首先点击 Tools -> Deployment -> Configuration

2022-10-02T17:18:19.png

点击 + 号 -> sftp -> 输入 sever name

2022-10-02T17:19:17.png

开始填写 SFTP 内容,首先我们要新建 ssh 配置。

2022-10-02T17:20:44.png

因为我们之前已经添加了免密登录,所以我们在添加完IP信息后,选择 key pair ,他会默认帮我们选择好 id_rsa 文件,然后点击 Test Connection 看看能否链接服务器。

2022-10-02T17:20:53.png

回到 Deployment 配置页面我们点击 Root path配置目录,我们输入 /data/data。

2022-10-02T17:21:05.png

接下来点击 Mappings 来配置我们要将代码上传到哪个文件夹下,点击 Deployment path 并点击鼠标右键新建一个 src 的目录用于存放我们的源代码。

2022-10-02T17:21:20.png

然后再点击 Excluded Paths 来过滤掉不想上传的文件。

2022-10-02T17:21:31.png

build,.idea 两个目录其实是不用上传的,点击完成,接下来我们选中main.go 和 go.mod 这两个文件将其上传至 pro。

2022-10-02T17:21:40.png

接下来我们到 Linux 服务器上看一下代码是否已经被上传到服务器上。

[root@localhost src]# cd /data/data/src/ && ll
total 8
-rw-r--r--. 1 root root 1049 Oct  2 13:43 go.mod
-rw-r--r--. 1 root root  253 Oct  2 16:49 main.go

利用 Docker 构建应用程序

我们其实也可以在服务器上安装 golang 然后直接利用 go build 命令来构建应用程序,但是这其实涉及到污染生产环境的问题,所以我们采用 docker 的方式来进行打包,首先我们先安装 docker。

# 更新 yum 
sudo yum update

# 安装依赖
sudo yum install -y yum-utils device-mapper-persistent-data lvm2 -y

# 设置 docker 源
sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo

# 安装 docker
sudo yum install docker-ce -y

# 启动 docker
sudo systemctl start docker

# 随开机启动
sudo systemctl enable docker

# 查看 docker 版本
docker version

因为我们的本地 Golang 用的是1.18.6,所以我们在 docker hub 上选择了 1.18.6-apline3.16 作为我们的打包工具。

选择 alpine 版本还有一个原因就是它的体积是非常小的,既然是“私活”,那就应该从各个方面来思考如何节省资源。

[root@localhost src]# docker pull golang:1.18.6-alpine3.16

如果你觉着 pull 的速度太慢,那可以换成国内的源,这里我就不赘述了。

下载完成后,我们来执行 docker run 命令来打包试试:

[root@localhost src]# docker run --rm -it \
-v /data/data:/data/data \ 
-w /data/data/src/ \
 e CGO_ENABLED=0 \
-e GOPROXY=https://goproxy.cn \
golang:1.18.6-alpine3.16 go build -o ../server-build-docker main.go

go: downloading github.com/gin-gonic/gin v1.8.1
go: downloading github.com/gin-contrib/sse v0.1.0
go: downloading github.com/mattn/go-isatty v0.0.14
go: downloading golang.org/x/net v0.0.0-20210226172049-e18ecbb05110
go: downloading github.com/go-playground/validator/v10 v10.10.0
go: downloading github.com/pelletier/go-toml/v2 v2.0.1
go: downloading github.com/ugorji/go/codec v1.2.7
go: downloading google.golang.org/protobuf v1.28.0
go: downloading gopkg.in/yaml.v2 v2.4.0
go: downloading golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069
go: downloading github.com/go-playground/universal-translator v0.18.0
go: downloading github.com/leodido/go-urn v1.2.1
go: downloading golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97
go: downloading golang.org/x/text v0.3.6
go: downloading github.com/go-playground/locales v0.14.0

--rm 在docker 容器执行完成后会直接被删掉,我们首先是将/data/data 目录挂在到容器中,之后将工作目录切换到 /data/data/src 目录下进行打包操作。

[root@localhost data]# cd /data/data && ls -l
total 9880
-rwxr-xr-x. 1 root   root   10110954 Oct  2 23:42 server-build-docker
-rw-rw-r--. 1 maksim maksim     2783 Oct  2 17:20 server.log
drwxr-xr-x. 2 root   root         49 Oct  2 23:41 src

我们可以看到文件被生成了,并且被设置成了可执行文件。

这里存在一个问题,每一次都会去重新下载依赖,这样会影响打包的速度,我们可以将 gopath 目录找到然后将其目录挂载出来。

# 创建目录用来保存 go build 的依赖文件
mkdir /data/data/godep

# 执行新的 go build 命令
docker run --rm -it -v /data/data:/data/data \ 
-v /data/data/godep:/go \
-w /data/data/src/ \
 e CGO_ENABLED=0 \
-e GOPROXY=https://goproxy.cn \
golang:1.18.6-alpine3.16 go build -o ../server-build-docker main.go

# 第二次执行
docker run --rm -it -v /data/data:/data/data \ 
-v /data/data/godep:/go \
-w /data/data/src/ \
 e CGO_ENABLED=0 \
-e GOPROXY=https://goproxy.cn \
golang:1.18.6-alpine3.16 go build -o ../server-build-docker main.go

在我们第二次执行的时候就不会再去下载依赖了。

改进编译、部署脚本

这个时候我们再去完善我们的部署脚本,我们新建一个 remote-build-deploy 脚本,完成构建以及部署操作。

:: 构建应用
ssh [email protected] "bash /data/data/src/remote-build.sh"

这一次,我们的脚本变成了一个行,所有的编译环节的脚本我们都放到了服务器上,这是因为 ssh 单条执行命令的效率实在是太低了,并且部署脚本单独抽出来,我们可以在其中做一些逻辑判断,如下:

#!/bin/bash

build_path=/data/data/server-build-docker

build() {
  echo "开始执行编译"

  docker run --rm -i \
    -v /data/data:/data/data \
    -v /data/data/godep:/go \
    -w /data/data/src/ \
    -e GOPROXY=https://goproxy.cn \
    -e CGO_ENABLED=0 \
    golang:1.18.6-alpine3.16 go build -o $build_path main.go

  if [ -f $build_path ]; then
    echo "build 成功:目录 "$build_path
  else
    echo "build 失败,没有找到该文件"
        exit 20
  fi
}

build

当完成后,我们去判断是否有文件产出,也就是我们的常说的"制品",如果没有就赶紧报错!

之后就是部署脚本,与构建脚本一样,部署脚本我们也放到了远程执行。

:: 部署应用
ssh [email protected] "bash /data/data/src/remote-deploy.sh"
#!/bin/bash

server_path=/data/server/api-server/server
build_path=/data/data/server-build-docker

deploy() {
  if [ ! -f $build_path ]; then
    echo "build 失败,没有找到该文件"
    exit 20
  fi

  process=$(ps -ef | grep $server_path | grep -v "grep" | wc -l)

  if [[ process == 0 ]]; then
    echo "当前没有执行,可以直接启动,启动中...."
    # 进程不存在直接移动目录4
    mv $build_path $server_path
    nohup $server_path &>>/data/data/server.log 2>&1 &
  else
    echo "当前有执行,杀死进程"
    ps -ef | grep server | awk '{print $2}' | xargs kill -9
    echo "杀死成功,执行运动命令"
    mv $build_path $server_path
    echo "开始执行"
    nohup $server_path &>>/data/data/server.log 2>&1 &
  fi
  echo "执行成功";

}

test() {
    sleep 2
    curl localhost:8080
    echo ""
}

deploy

test
  1. 首先校验的是是否有制品存在
  2. 如果有制品,判断当前是否有正在执行的进程
  3. 如果有在执行的进程,那么就干掉,然后移动、执行。
  4. 如果没有则直接移动、执行

小结

到目前为止,我们完成了自动化远程 docker 编译,在下一小节,我们来实现 api-server 的 docker 化。

0.09x 是目前能够下载到的最古老的版本,在代码中有标注,这是一个预览版,所有代码加起来在 1W 多行,其中包括了文档,和一些辅助脚本,第三方案例,真正的 C 语言实现的代码仅有 5200 多行,这其中其实已经包含了很多 Redis 的核心逻辑,阅读起来的障碍也会小很多。

Reids 7 的C 代码行数已经超过了15W行,阅读起来的难度可想而知。

2023-03-14T09:01:22.png

我们先来看一下 redis 的目录结构

2023-03-14T09:01:37.png

这个时候的目录非常简洁,工程目录下直接存放了所有的源代码,以及几个目录。

  • client-libraries 存放了各个语言的 client sdk,我们直接可以利用其中的 sdk 进行与 redis-server 的交互。
  • doc 存放了 redis 的所有文档。
  • utils 是 redis 的工具包,使用 ruby 进行编写的。

这个时候的 Makefile 异常简洁,没有后面那么复杂。一共只有 62 行,只要拥有一定的 C 语言基础相信直接就能看明白。

# Redis Makefile
# Copyright (C) 2009 Salvatore Sanfilippo <antirez at gmail dot com>
# This file is released under the BSD license, see the COPYING file

DEBUG?= -g
CFLAGS?= -std=c99 -pedantic -O2 -Wall -W -DSDS_ABORT_ON_OOM
CCOPT= $(CFLAGS)

OBJ = adlist.o ae.o anet.o dict.o redis.o sds.o zmalloc.o lzf_c.o lzf_d.o
BENCHOBJ = ae.o anet.o benchmark.o sds.o adlist.o zmalloc.o
CLIOBJ = anet.o sds.o adlist.o redis-cli.o zmalloc.o

PRGNAME = redis-server
BENCHPRGNAME = redis-benchmark
CLIPRGNAME = redis-cli

all: redis-server redis-benchmark redis-cli

# Deps (use make dep to generate this)
adlist.o: adlist.c adlist.h
ae.o: ae.c ae.h
anet.o: anet.c anet.h
benchmark.o: benchmark.c ae.h anet.h sds.h adlist.h
dict.o: dict.c dict.h
redis-cli.o: redis-cli.c anet.h sds.h adlist.h
redis.o: redis.c ae.h sds.h anet.h dict.h adlist.h zmalloc.c zmalloc.h
sds.o: sds.c sds.h
sha1.o: sha1.c sha1.h
zmalloc.o: zmalloc.c

redis-server: $(OBJ)
    $(CC) -o $(PRGNAME) $(CCOPT) $(DEBUG) $(OBJ)
    @echo ""
    @echo "Hint: To run the test-redis.tcl script is a good idea."
    @echo "Launch the redis server with ./redis-server, then in another"
    @echo "terminal window enter this directory and run 'make test'."
    @echo ""

redis-benchmark: $(BENCHOBJ)
    $(CC) -o $(BENCHPRGNAME) $(CCOPT) $(DEBUG) $(BENCHOBJ)

redis-cli: $(CLIOBJ)
    $(CC) -o $(CLIPRGNAME) $(CCOPT) $(DEBUG) $(CLIOBJ)

.c.o:
    $(CC) -c $(CCOPT) $(DEBUG) $(COMPILE_TIME) $<

clean:
    rm -rf $(PRGNAME) $(BENCHPRGNAME) $(CLIPRGNAME) *.o

dep:
    $(CC) -MM *.c

test:
    tclsh test-redis.tcl

bench:
    ./redis-benchmark

log:
    git log '--pretty=format:%ad %s' --date=short > Changelog

Redis 的入口

我们直接到 redis 的入口函数,其代码在 redis.c 中,直接拖拽滚动框到最底部即可,我对代码的每个阶段做了注释。启动一个 redis 服务其实 3 个阶段总计 8 个步骤。

  • 阶段一:基本初始化

    1. 初始化 server 配置,initServerConfig()。
    2. 判断是否有指定配置文件,如果有则重新加载配置文件,loadServerConfig()。
    3. 初始化服务,initServer()。
    4. 判断是否为后台执行,daemonize()。
  • 阶段二:检查 RDB

    1. 加载 rdb 文件,当进程退出后会加载以前保存的 rdb 文件,rdbLoad()。
  • 阶段三:事件驱动

    1. 创建 event-driven 其实就是解析客户端请,并且将 socket fd 放入到事件驱动当中去,aeCreateFileEvent()。
    2. 执行死循环,接收请求,其实这一步就是我们说 redis 是单线程的原因,aeMain()。
    3. acMain 判断是否停止,如果停止则删除事件,并退出程序,aeDeleteEventLoop()。
int main(int argc, char **argv) {
    //1. 初始化 server 段配置
    initServerConfig();
    //2. 判断是否有指定配置文件,如果有则重新加载配置文件
    if (argc == 2) {
        ResetServerSaveParams();
        loadServerConfig(argv[1]);
    } else if (argc > 2) {
        //如果参数过多则报错,显示正确的配置
        fprintf(stderr, "Usage: ./redis-server [/path/to/redis.conf]\n");
        exit(1);
    }
    //3. 初始化服务
    initServer();
    //4. 判断是否为后台执行
    if (server.daemonize) daemonize();
    redisLog(REDIS_NOTICE, "Server started, Redis version " REDIS_VERSION);
    //5. 加载 rdb 文件,当进程退出后会加载以前保存的 rdb 文件
    if (rdbLoad(server.dbfilename) == REDIS_OK)
        redisLog(REDIS_NOTICE, "DB loaded from disk");
    //6. event-driven 其实就是解析客户端请求
    if (aeCreateFileEvent(server.el, server.fd, AE_READABLE,
                          acceptHandler, NULL, NULL) == AE_ERR)
        oom("creating file event");
    redisLog(REDIS_NOTICE, "The server is now ready to accept connections on port %d", server.port);
    //7. 死循环执行
    aeMain(server.el);
    //8. 当接收到退出信号后,删除事件
    aeDeleteEventLoop(server.el);

    return 0;
}

阶段一:基本初始化

initServerConfig 初始化 Server 配置

initServerConfig 就是对 redis 进行默认值的初始化。

static void initServerConfig() {
    # 初始化 redis 默认 DB 数量,默认是 16 个
    server.dbnum = REDIS_DEFAULT_DBNUM;
        # 初始化 redis 默认端口 3306
    server.port = REDIS_SERVERPORT;
    # 日志级别,默认是 debug
    server.verbosity = REDIS_DEBUG;
    # 默认超时时间
    server.maxidletime = REDIS_MAXIDLETIME;
    # 存储策略
    server.saveparams = NULL;
    server.logfile = NULL; /* NULL = log on standard output */
    server.bindaddr = NULL;
        # 设置在向客户端应答时,是否把较小的包合并为一个包发送,默认为开启
    server.glueoutputbuf = 1;
    server.daemonize = 0;
    server.pidfile = "/var/run/redis.pid";
    server.dbfilename = "dump.rdb";
    # 是否要求通行证
    server.requirepass = NULL;
        # 对象共享池
    server.shareobjects = 0;
    # 重置保存策略
    ResetServerSaveParams();
    appendServerSaveParams(60 * 60, 1);  /* save after 1 hour and 1 change */
    appendServerSaveParams(300, 100);  /* save after 5 minutes and 100 changes */
    appendServerSaveParams(60, 10000); /* save after 1 minute and 10000 changes */
    /* 主从复制相关的配置 */
    server.isslave = 0;
    server.masterhost = NULL;
    server.masterport = 6379;
    server.master = NULL;
    server.replstate = REDIS_REPL_NONE;
}

ResetServerSaveParams() 的操作这里很简单,其实就是将原有的内存空间清空。

static void ResetServerSaveParams() {
    zfree(server.saveparams);
    server.saveparams = NULL;
    server.saveparamslen = 0;
}

在这里,在 redis 0.091 版本中,redis 对内存分配使用的还是 zmalloc,这个我们放在后面去讲解,

server 是一个全局静态变量,数据类型是 redisServer。

loadServerConfig 重新加载配置文件

重新加载配置文件就很好理解了。

   if (argc == 2) {
        ResetServerSaveParams();
        loadServerConfig(argv[1]);
    } else if (argc > 2) {
        //如果参数过多则报错,显示正确的配置
        fprintf(stderr, "Usage: ./redis-server [/path/to/redis.conf]\n");
        exit(1);
    }

只是一段非常普通的 if 判断。

如果参数是两个,那么就说明有配置文件,执行加载逻辑

  1. 重新初始化 Server Save 参数
  2. loadServerConfig

loadServerConfig 由于代码太长了,而且大多数都是参数解析,在这里就不全都贴出来了,我们来看一些核心的逻辑。

//跳过了获取文件,判断文件是否有效的逻辑
while (fgets(buf, REDIS_CONFIGLINE_MAX + 1, fp) != NULL) {
        sds *argv;
        int argc, j;

        linenum++;
        line = sdsnew(buf);
        line = sdstrim(line, " \t\r\n");
/* Skip comments and blank lines*/
        if (line[0] == '#' || line[0] == '\0') {
            sdsfree(line);
            continue;
        }

        /* Split into arguments */
        argv = sdssplitlen(line, sdslen(line), " ", 1, &argc);
        sdstolower(argv[0]);

        /* Execute config directives */
        if (!strcmp(argv[0], "timeout") && argc == 2) {
            server.maxidletime = atoi(argv[1]);
            if (server.maxidletime < 1) {
                err = "Invalid timeout value";
                goto loaderr;
            }
                 } else if (xxxx) {
                        .....
                 } else .....
return;
    loaderr:
    fprintf(stderr, "\n*** FATAL CONFIG FILE ERROR ***\n");
    fprintf(stderr, "Reading the configuration file, at line %d\n", linenum);
    fprintf(stderr, ">>> '%s'\n", line);
    fprintf(stderr, "%s\n", err);
    exit(1);
}

在这段,出现了我们 redis 的第一个数据结构——sds,也就是我们在缓存中使用最多的字符串类型。

在 Redis0.091 中,SDS 的实现还是非常简单的,具体的 SDS 操作,我们在后续的部分进行讲解,我们先了解一下这个数据结构。头文件加实现代码一共才 275 行。

2023-03-14T09:02:12.png

Reids 利用 SDS 代替的字符串,这涉及到字符串的二进制安全问题。,C语言中,用“\0”表示字
符串的结束,如果字符串中本身就有“\0”字符,字符串就会被截断,即非二进制安全;若通过某种机制,保证读写字符串时不损害其内容,则是二进制安全。

在 C 语言中,字符串以 \0 结尾,这也就意味着字符串中如果出现了\0就会破坏数据的完整性,字符串也就会被截断。

为了解决这样的问题,redis 将 sds 设计成下面的样子。

typedef char *sds;

struct sdshdr {
    long len;
    long free;
    char buf[];
};
  • len 属性为5,表示这个SDS保存着一个5字节长的字符串,由于长度统计变量的存在,读取字符串不依赖”\0”终止符,保证了二进制安全。
  • free 属性为0,表示这个SDS没有分配任何未使用空间。
  • buf:内容存储在柔性数组 buf中,SDS 对上层暴露的指针不是指向结构体 SDS 的指针,而是直接只想荣幸数组的指针,上层就可以像读取 C 字符串一样读取 SDS 的内容,兼容 C 语言处理字符串的各种函数。

2023-03-14T09:02:32.png

柔性数组只能放在结构体的尾部。柔性数组的地址和接头体是连续的,这样查找内存更快,因为不需要额外通过指针找到字符串为止,可以方便地通过柔性数组的首地址偏移得到结构体首地址,进而方便获取其余变量。

总结一下 SDS 和 Char 的区别

  1. 通过使用SDS字符串(len记录字符串长度),使得获取字符串长度的复杂度从O(N)变为O(1)
  2. 杜绝缓存区溢出,C字符串不记录自身长度,在拼接字符串时可能造成缓存区溢出
  3. 通过未使用空间free,减少修改字符串带来的内存重分配次数

其实这种设计在其他项目中也存在,这是 PHP3 中对 string 类型的视线。

2023-03-14T09:02:47.png

当校验没有问题后,就开始抓好难过解析配置,如果配置正确就继续扫描下一行,如果错误就会利用 goto 跳转到 loaderr。

可能在很多 C 语言的帖子中或者老师的课程中都不允许使用 goto 关键字,但是在实际的项目中这种用法有很多,并不是绝对不可以使用,因为在 C语言中并没有异常机制,像这种利用 goto 特性跳转到输出位置进行输出信息,处理异常会特别方便,代码逻辑也更加的清晰,在处理异常的同事也可以进行回收资源。

老师说的意思其实是不要多层跳转,因为多层跳转会让代码变得混乱。

initServer() 初始化 server

得到配置后,万事俱备只欠东风,我们开始对 server 进行初始化。

static void initServer() {
    int j;

    // 屏蔽信号,避免异常
    signal(SIGHUP, SIG_IGN);
    signal(SIGPIPE, SIG_IGN);

   
    server.clients = listCreate();
    server.slaves = listCreate();
    server.monitors = listCreate();
    server.objfreelist = listCreate();

    // 创建 SharedObjects 这里就是存储 redis 数据的地方;
        //  如果有熟悉Redis的朋友可能会知道 RedisObject,这个就是它的前身。
    createSharedObjects();

        //创建事件驱动
    server.el = aeCreateEventLoop();
    server.db = zmalloc(sizeof(redisDb) * server.dbnum);
    server.sharingpool = dictCreate(&setDictType, NULL);
    server.sharingpoolsize = 1024;

        // 如果分配内存失败也就意味着没有可用空间了,那就 OOM
    if (!server.db || !server.clients || !server.slaves || !server.monitors || !server.el || !server.objfreelist)
        oom("server initialization"); /* Fatal OOM */

    #创建 TCP 连接
    server.fd = anetTcpServer(server.neterr, server.port, server.bindaddr);
    if (server.fd == -1) {
        redisLog(REDIS_WARNING, "Opening TCP port: %s", server.neterr);
        exit(1);
    }

    for (j = 0; j < server.dbnum; j++) {
        server.db[j].dict = dictCreate(&hashDictType, NULL);
        server.db[j].expires = dictCreate(&setDictType, NULL);
        server.db[j].id = j;
    }

    server.cronloops = 0;
    server.bgsaveinprogress = 0;
    server.lastsave = time(NULL);
    server.dirty = 0;
    server.usedmemory = 0;
    server.stat_numcommands = 0;
    server.stat_numconnections = 0;
    server.stat_starttime = time(NULL);
    aeCreateTimeEvent(server.el, 1000, serverCron, NULL, NULL);
}
  • 屏蔽信号
  • 初始化 clients,slaves,monitors,objfreelist 四个链表。

    • clients 客户端
    • slaves 从库
    • monitors 监控器,某个 client 执行 monitors 的时候会变成一个监控器,服务端执行的所有命令都会发送给监控器。
    • objfreelist 空闲对象池
  • 初始化 dict 也就是 shareobjects 存储键值对的地方
  • 创建 tcp 连接
  • 创建数据库,其中会初始化 dict,过期时间 dict
  • 初始化 server 剩余的属性
  • 注册听时期

listCreate() 引出了,我们的第二个数据结构——双向链表。

2023-03-14T09:14:02.png

其代码量加载一起一共是 214 行,核心主要是下面两个结构体,list、ListNode 构成了 redis 中的双向链表。

typedef struct list {
    listNode *head;
    listNode *tail;
    void *(*dup)(void *ptr);
    void (*free)(void *ptr);
    int (*match)(void *ptr, void *key);
    unsigned int len;
} list;

typedef struct listNode {
    struct listNode *prev;
    struct listNode *next;
    void *value;
} listNode;

在链表中保存着 head 和 tail,也就是链表中的头和尾。每个 Node 节点都保存着前一个和后一个的节点指针。

通过单个节点我们不仅能够访问到下一个节点,还能访问到上一个节点。使用双链表而非单链表的目的是想增加结构的灵活性,因为只有经常对数据进行增加或者删除才会用到链表这种数据结构。无论是增加节点抑或删除节点,双链表都要比单链表更加简单高效。但提高灵活性的代价是需要更多的空间,但这个代价小到可以被我们忽略。

daemonize 守护进程

static void daemonize(void) {
    int fd;
    FILE *fp;

    if (fork() != 0) exit(0); /* parent exits */
    setsid(); /* create a new session */

    /* Every output goes to /dev/null. If Redis is daemonized but
     * the 'logfile' is set to 'stdout' in the configuration file
     * it will not log at all. */
    if ((fd = open("/dev/null", O_RDWR, 0)) != -1) {
        dup2(fd, STDIN_FILENO);
        dup2(fd, STDOUT_FILENO);
        dup2(fd, STDERR_FILENO);
        if (fd > STDERR_FILENO) close(fd);
    }
    /* Try to write the pid file */
    fp = fopen(server.pidfile, "w");
    if (fp) {
        fprintf(fp, "%d\n", getpid());
        fclose(fp);
    }

这是一个标准的守护进程代码。

  1. fork 子进程,父进程退出
  2. 设置会话 id
  3. 转移标准输入,标准输出,标准错误
  4. 将 pid 放到文件中

阶段二:rdbLoad 加载 rdb 文件

函数loadDb 的实现完全是根据写入磁盘数据库文件的rdbSave函数来实现的, 后续我们在定时事件中再看rdbSave实现,在此略过分析loadDb的实现。

阶段三:事件驱动

事件驱动框架是 Redis Server 运行的核心,该框架爱一单启动后就会一直执循环执行,每次循环都会处理一批出发的网络读写事件。

其实进入事件框架开始执行并不复杂,main 函数直接调用时间框架爱的主题函数 aeMain 后就进入了时间处理循环了。

//7. 死循环执行
aeMain(server.el);

而他的源代码实现更简单。

void aeMain(aeEventLoop *eventLoop)
{
    eventLoop->stop = 0;
    while (!eventLoop->stop)
        aeProcessEvents(eventLoop, AE_ALL_EVENTS);
}

其实就是一个死循环,如果当前的事件驱动的 stop 属性等于 1 了就说明服务终止,那么就退出循环,整个业务逻辑就结束了。

//8. 当接收到退出信号后,删除事件
aeDeleteEventLoop(server.el);

在 0.091 这个版本中还没有引入 epoll 的支持,底层使用的还是 select。

2023-03-14T09:11:15.png

所以在这个版本中,如果没有做内核优化,那么 redis 的并发 fd 最多也就是 1024 个,远没有后面那么高的并发量。

到目前为止,我们算是知道了 redis0.091 的入口,接下来,我们会进入到具体的实现细节当中,下一章,我们来看 redis server 是如何处理用户请求的。

前言

大家好,我是一笑,在这篇文章中将为大家介绍如何利用 Docker 部署私活级 Golang 程序,最终我们要通过最低的成本来实现 goalng 的自动打包,部署操作。

首先我们来定义一下什么是私活级,私活大家都懂,就是我们利用业余去赚一些外快,一般情况下后端基本上都是一个人独立完成的,有的时候甚至真的只有一个人,没有运维,没有测试,一个人要负责整个软件的生命周期,从开发迭代到后面的运维部署,所以在本文中私活级将指代是没有企业级运维能力的研发场景

在这种情况,我们可以利用 Docker 和 脚本命令来完成一些简单的自动化操作,来减少我们的时间成本以及运维成本。

由于刚开始是一个人进行研发工作,所以我们并不会涉及到 gitlab, Jenkins 这些 DevOPS 工具,毕竟这些会增加我们的研发成本,如果非要部署这些服务,我的建议是可以使用树莓派,同时将 gitlab 更换为 gitea ,因为 gitea 可以更加节省服务器资源,同时一定要定时备份,这点很重要,以免树莓派损坏导致的数据丢失。

在后面我会不断追加这一系列的文章,其中也会讲到 DevOPS , 不过这些不是这篇文章的主要内容,在这里不做过多说明,接下来让我们正式开始私活级 Golang 程序的部署工作。

环境准备

这里为了节省成本,就不从云服务厂商购买服务器了,使用虚拟机来进行搭建。

  • Golang 1.18
  • Goland IDE
  • VitralBox
  • Windows(我本机使用的是 Windows 11)
虚拟机操作系统网络模式虚拟机 IP虚拟机 配置
CentOS 7桥接网卡192.168.1.100cup: 2 mem:2G

基础 Go 服务发布

代码准备

首先,我们来初始化我们的项目代码,因为这里的项目并不重要,所以我们用 gin 框架简单输出一个 Hello World 来模拟我们的项目代码,项目结构如下:

PS C:\Users\maksim\WorkSpace> tree .\proejct\
文件夹 PATH 列表
卷序列号为 5402-60A2
│  go.sum
│  main.go

main.go:

package main

import (
    "github.com/gin-gonic/gin"
    "log"
)

func main() {
    r := gin.Default()
    r.Handle("GET", "/", func(context *gin.Context) {
        context.String(200, "hello world!")
    })
    err := r.Run(":8080")

    if err != nil {
        log.Fatalln(err)
    }
}

代码很简单,就是利用 gin 返回 hello world!,我们将以此为基础来进行部署,首先我们不使用 docker 进行部署,而是通过 ssh 将 编译好的代码同步到服务器上。

由于我的开发机是 Windows, 所以这个时候需要进行交叉编译,在Windows 平台上编译 Linux 可执行文件,我们先来编写一个 build.bat 脚本,用来辅助我们自动化生成可执行文件,其脚本路径与 mian.go 同级。

set GOOS=linux
set GOARCH=amd64
go build -o build/server main.go

我们来执行一下脚本文件。

# 执行 build 脚本生成可执行文件
PS C:\Users\maksim\WorkSpace\proejct> .\build.bat

C:\Users\maksim\WorkSpace\proejct>set GOOS=linux

C:\Users\maksim\WorkSpace\proejct>set GOARCH=amd64

C:\Users\maksim\WorkSpace\proejct>go build -o build/server main.go

# 查看 build 目录下文件

PS C:\maksim\guozh\WorkSpace\proejct> ls .\build\


    目录: C:\Users\maksim\WorkSpace\proejct\build


Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----        2022-10-02     14:00       10062347 server

部署 Linux

我的 Linux 安装选用了最小化安装,简单配置了IP,没有对 Linux 进行用户设置,所以用 root 用户进行登录,所以为了安全第一件事就是为 Linux 设置新的账户。

# 1. ssh 登录
ssh [email protected]

# 2. 输入密码,这里略过

# 3. 新建账户
# 3.1 新增用户
useradd -m maksim
# 3.2 给用设置密码
passwd maksim

# 3.3 给 maksim  root 权限
cat >> /etc/sudoers <<EOF              #添加用户可以知晓所有命令(可以根据需要修改),切换不用输入密码
user1   ALL=(ALL)      NOPASSWD: ALL
EOF

# 4. 给新用户 SSH 登录权限
# 4.1 编辑 sshd_config 文件
vi /etc/ssh/sshd_config
# 4.2 找到 AllowUsers, 解除注释,并且加入用户名,用户名之间用空格隔开
AllowUsers username username2
# 4.3 重启 ssdh 服务
service sshd restart

这样主要是为了 Linux 的系统安全,在线上拥有 root 权限其实是一个非常可怕的情况,毕竟如果你想的话 rm -rf /* 也是完全没有问题的,所以一般公司都不会给开发人员 root 权限,线上一般也就是给只读权限的账户。

现在我们已经拥有了新的账户,接下来我们来部署应用,现在使用新的账户进行操作。

# 在根目录下新建data 目录,其中包括 soft,data,server
sudo mkdir -p /data/{soft,data,server}

[maksim@localhost data]$ cd /data/ && ls -l
total 0
drwxr-xr-x. 2 root root 6 Oct  2 14:29 data
drwxr-xr-x. 2 root root 6 Oct  2 14:29 server
drwxr-xr-x. 2 root root 6 Oct  2 14:29 soft
  • soft 目录是用来存放我们安装的应用,例如 redis, MySQL, nginx
  • data 用来存放应用数据,例如Nginx 日志,应用日志等。
  • server 用来存放我们的应用服务,也就是我们要部署的内容;

我们在 soft 目录下新建 api-server 目录,该目录用来存放我们的 helloworld 程序。

cd server  && sudo mkdir api-server

创建完目录后,我们来设置免密登录,这是因为我们需要上传我们的应用程序,如果是使用 scp 上传每一次都需要传入密码,就很麻烦。

首先我们打开 cmd 生成本机的 ssh 密匙。

PS C:\Users\maksim>  ssh-keygen -t rsa -C "[email protected]"
Generating public/private rsa key pair.
Enter file in which to save the key (C:\Users\maksim/.ssh/id_rsa):
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in C:\Users\maksim/.ssh/id_rsa
Your public key has been saved in C:\Users\maksim/.ssh/id_rsa.pub
The key fingerprint is:
SHA256:pwc27Wi3bpeGM8vZJ6MoM2Hoz5MWMU1SZdMl3cfVYLY [email protected]
The key's randomart image is:
+---[RSA 3072]----+
|       ...+..o=++|
|      . .. ..+..=|
|       +      E .|
|      o ..       |
|     . oS o      |
|    . +. B       |
|   . . ++ +. .   |
|    ..B. ==+* .  |
|     oo=.oBB.+   |
+----[SHA256]-----+

ssh-keygen -t rsa -C "[email protected]" 这个命令的具体意义,大家可自行百度,这里不做赘述,因为这篇文章的重点不在这里。

 PS C:\Users\maksim\.ssh> type .\id_rsa.pub

ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC8gUVLCs1Vbei3L6lYWrr7QTWee/qEnC9t2oNeiSKsQMtRTRUh+oCIqSR6zL+5giK1bthRUJnKaJZqrJPSsdaiispaoduoiIsklfdjJLIOJI@saslE= [email protected]

我们可以到查看该目录下的 ssh ,在这里的ssh 请不要复制,因为是我瞎写的。

我们将生成好的 id_rsa.pub 文件内容添加到 Linux 中。

PS C:\Users\maksim\.ssh> type ./id_rsa.pub | ssh [email protected] "mkdir .ssh && cat >> .ssh/authorized_keys"
注意:这里的脚本中有一段是 mkdir .ssh 这是因为我的虚拟机中没有该目录,如果报错了的话,可以将 makdir .ssh && 删掉。

在这里我们使用了 root 账户,这是因为可以避免很多权限问题。

我们来测试一下是否可以免密码登录:

PS C:\Users\maksim\.ssh> ssh [email protected]
Last login: Sun Oct  2 16:18:41 2022
[root@localhost ~]#

现在我们来编写 deploy.sh 脚本,用来部署我们的应用程序:

scp ./build/server root:/data/server/api-server/server

我们通过 scp 将编译好的目录上传上去,我们执行完成后就会发现,server 文件就被上传到了我们的Linux当中:

[root@localhost api-server]# ls -l
total 9828
-rw-r--r--. 1 root root 10062347 Oct  2 16:39 server

但是这个时候,细心的你发现这个文件没有可执行权限啊,很简单,我们来继续晚上 deploy.bat 脚本,我们使用 ssh 命令 来给该文件配置权限。

set server_path=/data/server/api-server/server

scp ./build/server [email protected]:%server_path%

set chmod_exec_cmd= chmod +x %server_path%

ssh [email protected] %chmod_exec_cmd%

这是我们修改后的脚本,使用 bat 变量来配置要上传的路径,并且在最后执行了权限修改命令,我们来执行一下,再看看 Linux 中 server 的变化。

[root@localhost api-server]# ls -l
total 9828
-rwxr-xr-x. 1 root root 10062347 Oct  2 16:45 server

我们可以看到,这样一来 server 就具备了可执行权限,我们可以执行一下 server。

[maksim@localhost api-server]$ ./server
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /                         --> main.main.func1 (3 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.
[GIN-debug] Listening and serving HTTP on :8080

我们先让它在后台运行

#我们现在用的是 maksim 账户,但是创建目录的时候用的 root ,所以我们需要将权限转交给 maksim 避免出现权限错误
[maksim@localhost api-server] chown -R /data/data maksim

[maksim@localhost api-server] nohup ./server &>> /data/data/server.log 2>&1 &

运行后我们执行 curl 命令来判断程序是否运行成功:

[maksim@localhost api-server]$ curl localhost:8080
hello world!

在这里还有一点需要进行优化,那就是我们的服务如果在运行阶段是无法重新部署的,我们可以试验一下:

[maksim@localhost api-server]$ ls -l
total 9828
-rwxr-xr-x. 1 root root 10062347 Oct  2 17:10 server

现在最新的上传时间是17:10,我们来执行上传脚本,然后在观察时间的变化。

PS C:\Users\maksim\WorkSpace\proejct> .\delopy.bat

C:\Users\maksim\WorkSpace\proejct>set server_path=/data/server/api-server/server

C:\Users\maksim\WorkSpace\proejct>scp ./build/server [email protected]:/data/server/api-server/server
scp: /data/server/api-server/server: Text file busy

C:\Users\maksim\WorkSpace\proejct>set chmod_exec_cmd= chmod +x /data/server/api-server/server

C:\Users\maksim\WorkSpace\proejct>ssh [email protected]  chmod +x /data/server/api-server/server

我们可以看到,这个时候的脚本执行是没有报错的,但是我们在 Linux 中执行 ls -l

[maksim@localhost api-server]$ ls -l
total 9828
-rwxr-xr-x. 1 root root 10062347 Oct  2 17:10 server

所以我们需要将 Linux 中的进程杀死,然后在重新启动脚本。

:: 设置服务文件路径
set server_path=/data/server/api-server/server

:: 将进程杀死
ssh [email protected] "ps -ef | grep server | awk '{print $2}' | xargs kill -9"

:: 发送应用程序文件
scp ./build/server [email protected]:%server_path%

:: 设置执行权限
set chmod_exec_cmd= chmod +x %server_path%
ssh [email protected] %chmod_exec_cmd%
ssh [email protected] "nohup %server_path% &>> /data/data/server.log 2>&1 &"

我们执行完成后再去 Linux 上看一下。

[maksim@localhost api-server]$ ps -ef | grep server
root      2210     1  0 17:19 ?        00:00:00 /data/server/api-server/server
maksim    2216  1867  0 17:19 pts/1    00:00:00 grep --color=auto server
[maksim@localhost api-server]$ curl localhost:8080
hello world!

好了,到目前为止,我们的开发,打包,部署就已经基本完成了,下面我们开始对项目进行 docker 化,如果你不想打包 docker 那么到这里你还有以下几点需要去做:

  1. 使用 supervisor 监控应用程序
  2. 如果真的只有一台节点,那么最好做应用的优雅重启,避免在部署阶段导致服务中断,而与此同时,还需要改造 deploy.sh 以支持优雅重启。

小结

在这一篇文章讲解了,如何利用 bat 脚本、ssh 命令来部署我们的 golang 应用程序。

其实在工作中,我真的有遇到过这种发布模式,很简单,几行脚本就搞定了大套的发布流程,看起来很作坊,很不成体系,但是确实成本最低的实现方案。

在下一章中我们将开始利用 Docker 来部署我们的项目。