category
共 11 个分类
共 11 个分类
CORS问题 CORS 跨域问题的产生原因 CORS(Cross-Origin Resource Sharing)是一种机制,允许从一个域(源)请求来自不同域(源)的资源。现代浏览器出于安全考虑,限制网页的 AJAX 请求,禁止前端脚本访问不同域的资源。这种安全策略称为同源策略(Same-Origin Policy),它限制了不同源(包括协议、域名和端口)的交互。CORS 的出现是为了在特定情况下允许跨源请求,增强了 Web 应用的灵活性。 如何解决 CORS 跨域问题 1. CORS 头部配置:通过设置 HTTP 响应头来告知浏览器允许跨域请求。常见的头部包括: Access-Control-Allow-Origin: 指定允许哪些域进行请求,可以是具体的域名或 *(允许所有域)。 Access-Control-Allow-Methods: 指定允许的 HTTP 方法,如 GET、POST 等。 Access-Control-Allow-Headers: 指定允许的请求头。 Access-Control-Max-Age: 指定预检请求的缓存时间。 2. 代理服务器:通过设置一个同域的代理服务器,将跨域请求转发到目标域。这种方法常用于开发环境。 3. JSONP:通过 <script> 标签的跨域特性,获取数据。此方法仅支持 GET 请求,已经逐渐被 CORS 取代。 CORS配置详解 CORS 头部是服务器响应中包含的一组 HTTP 头,用于控制哪些来源的请求被允许,如何处理跨域请求。以下是常见的 CORS 头部配置项及其含义: Access-Control-Allow-Origin 说明:指定哪些源可以访问资源。 可配置值: 特定的域名,例如 https://www.example.com,表示仅允许该域的请求。 *,表示允许所有域进行请求(不推荐用于敏感数据)。 示例:Access-Control-Allow-Origin: https://www.example.com Access-Control-Allow-Methods 说明:指定允许的 HTTP 方法,表示客户端可以使用哪些 HTTP 方法来访问资源。 可配置值:可以是任意 HTTP 方法,如 GET、POST、PUT、DELETE、OPTIONS 等,多个方法用逗号分隔。 示例:Access-Control-Allow-Methods: GET, POST, OPTIONS Access-Control-Allow-Headers 说明:指定哪些请求头可以被客户端发送。 可配置值:可以是任意请求头,例如 Content-Type、Authorization、X-Custom-Header 等,多个头部用逗号分隔。 示例:Access-Control-Allow-Headers: Content-Type, Authorization Access-Control-Expose-Headers 说明:指定哪些响应头可以被浏览器访问,浏览器默认只允许访问 Cache-Control、Content-Language、Content-Type、Expires、Last-Modified 和 Pragma 等标准响应头。 可配置值:可以列出多个响应头。 示例:Access-Control-Expose-Headers: X-Custom-Header Access-Control-Max-Age 说明:指定预检请求的缓存时间(以秒为单位),在此时间内,浏览器可以直接使用相同的请求,而无需再次发送预检请求。 可配置值:任意正整数,表示缓存时间。 示例:Access-Control-Max-Age: 86400 # 24小时 Access-Control-Allow-Credentials 说明:指示是否允许浏览器发送凭据(如 Cookies 和 HTTP 认证信息)到跨域请求的服务器。 可配置值: true,表示允许发送凭据。 false,表示不允许。 示例:Access-Control-Allow-Credentials: true Access-Control-Request-Method 说明:在预检请求中使用,指示实际请求所使用的 HTTP 方法。 示例:Access-Control-Request-Method: POST Access-Control-Request-Headers 说明:在预检请求中使用,指示实际请求所使用的请求头。 示例:Access-Control-Request-Headers: Content-Type, Authorization 不同部署方案CORS问题解决 同域名下项目部署 如果前端和后端共用一个域名(例如 https://www.example.com),则不需要 CORS 的额外配置。前端可以直接访问后端 API,只需确保 API 路由的正确设置。 前端项目配置 前端项目(Vue 3) 在 Vue 3 项目中,你可以直接调用后端 API。例如,使用 axios 发起请求: import axios from 'axios'; axios.get('/api/data') .then(response => { console.log(response.data); }) .catch(error => { console.error(error); }); 前端项目Nginx配置 server { listen 80; server_name www.example.com; # 前端项目的配置 location / { root /path/to/vue/dist; # Vue 生成的静态文件路径 try_files $uri $uri/ /index.html; } # 后端 API 的配置 location /api/ { proxy_pass http://127.0.0.1:8000; # 假设 后端项目 运行在 8000 端口 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } # 以下省略 ... } 总结 前端(Vue 3)和 后端(Laravel)在同一域名下运行,因此不需要 CORS 配置。 Nginx 配置中,前端和后端通过不同的请求路径(如 / 和 /api/)进行区分。所有以 /api/ 开头的请求将被代理到后端 Laravel 应用。 不同域名下项目部署 当前端和后端位于不同域名(例如前端 https://www.example.com,后端 https://api.example.com)时,就需要进行 CORS 配置。 后端项目配置 后端项目(PHP) 在 PHP 后端中,你可以通过设置响应头来解决 CORS: // index.php header('Access-Control-Allow-Origin: https://www.example.com'); header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); // 如果运行所有,则配置为 * header('Access-Control-Allow-Headers: Origin, Content-Type, Accept, Authorization'); // header('Content-Type: application/json'); $data = ['message' => 'Hello from API!']; echo json_encode($data); 后端项目Nginx配置 server { listen 80; server_name api.example.com; location / { # CORS配置... add_header 'Access-Control-Allow-Origin' 'https://www.example.com'; # 允许前端域名访问 add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; add_header 'Access-Control-Allow-Headers' 'Origin, Content-Type, Accept, Authorization'; # 处理预检请求 if ($request_method = 'OPTIONS') { add_header 'Access-Control-Max-Age' 86400; add_header 'Content-Length' 0; return 204; } # 其他配置... } # 以下省略 ... } 前端项目配置 前端项目(Vue 3) import axios from 'axios'; axios.get('https://api.example.com/api.php') .then(response => { console.log(response.data); }) .catch(error => { console.error(error); }); 前端项目Nginx配置 server { listen 80; server_name www.example.com; location / { root /path/to/vue/dist; try_files $uri $uri/ /index.html; } # 以下省略 ... } 总结 CORS 是一种用于解决跨域请求的机制。根据不同的需求,开发者可以选择不同的方案来处理跨域问题: 同域名的前端和后端之间无需 CORS 配置。 不同域名之间的请求需要通过设置 CORS 头部来允许访问。 配置 Nginx 时,要根据请求路径区分前端和后端的处理。 通过以上配置和理解,开发者可以有效解决跨域问题,实现前后端的顺利交互。
在 Laravel 应用程序中,我们经常会使用多个数据库连接来处理各种业务需求。然而,当在多个数据库连接中执行操作时,我们可能会遇到操作失败需要回滚的情况。本文将介绍如何使用 Laravel 的异常处理器来实现全局处理 MySQL 回滚问题,并提供了相应的代码示例。 准备工作 在开始之前,确保你已经安装了 Laravel 环境并具备基本的 Laravel 开发知识。 自定义异常处理器 首先,我们需要创建一个自定义的异常处理器类。这个自定义类需要实现 Illuminate\Contracts\Debug\ExceptionHandler 接口,以便能够捕获和处理异常。以下是一个简单的示例代码: <?php namespace App\Exceptions; use Exception; use Illuminate\Contracts\Debug\ExceptionHandler; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Support\Facades\DB; class CustomExceptionHandler extends ExceptionHandler implements ExceptionHandler { public function render($request, Exception $e) { if ($e instanceof \PDOException) { // 获取所有已经配置的数据库连接 $connections = config('database.connections'); foreach ($connections as $connection => $config) { // 判断数据库连接是否启用了事务 if ($config['transaction']) { // 回滚数据库操作 DB::connection($connection)->rollBack(); } } // 这里可以根据业务需求进行其他的处理操作 return response()->json([ 'message' => '数据库操作失败', 'error' => $e->getMessage(), ], 500); } return parent::render($request, $e); } } 在上述代码中,我们首先判断是否抛出的异常是 PDOException 类型的异常。如果是,我们获取所有已经配置的数据库连接,并遍历每个连接判断是否启用了事务。如果启用了事务,我们使用相应的连接对象执行回滚操作。 然后,我们可以根据实际情况在处理器中添加其他需要的处理操作。在示例代码中,我们返回一个 JSON 响应,包含自定义的错误信息和异常消息。 注册异常处理器 接下来,我们需要将自定义的异常处理器注册到 Laravel 框架中。打开 app/Exceptions/Handler.php 文件并修改 register 方法如下: public function register() { if ($this->app->environment('production')) { $this->reportable(function (Throwable $e) { // }); } else { $this->renderable(function (Throwable $e) { // 使用自定义的异常处理器 return resolve(\App\Exceptions\CustomExceptionHandler::class)->render(request(), $e); }); } } 在上述代码中,我们调用了 renderable 方法并传递一个回调函数进去。这个回调函数会在异常发生时被调用,并使用 resolve 函数来获取我们自定义的异常处理器实例,然后调用其 render 方法来处理异常。 请确保根据实际路径修改命名空间和类名。 总结 通过以上步骤,我们成功地实现了在 Laravel 框架中处理多个数据库连接的 MySQL 回滚问题。我们创建了一个自定义的异常处理器,通过遍历所有配置的数据库连接,如果启用了事务,就执行回滚操作。同时,我们还可以在处理器中添加其他的处理操作以满足实际业务需求。 以上就是本文介绍的方法,希望能对你解决多个数据库连接下的 MySQL 回滚问题有所帮助。 可能的优化点 除了以上方法,还有一些可能的优化点可以考虑: 异常处理细化:根据不同的异常类型,可以采取不同的处理策略。例如,某些异常可能只需要回滚特定的数据库连接,而不是所有连接。 错误日志记录:将异常信息记录到日志文件中,方便排查问题和追踪。 通知机制:在发生异常时,可以发送通知给开发团队或相关人员,以便及时处理问题。
在开发应用程序时,使用 Redis 是一种常见的方式来处理和存储数据。Redis 提供了五种常用的数据类型:String、List、Set、Hash 和 Sorted Set。除了这些数据类型之外,有时我们还需要在 Redis 中执行复杂的命令序列,这时可以使用 Lua 脚本来实现。在本篇博客中,我们将探讨如何在 Go 语言中使用 go-redis/redismock 来测试 Redis 常用的五种数据类型以及执行 Lua 脚本。 1. 简介 首先,让我们了解一下 go-redis/redismock 是什么。它是一个用于 Mock Redis 数据库的库,可以模拟 Redis 客户端的行为,无需实际连接到真实的 Redis 数据库。这样,我们可以在单元测试中更快速和可控地测试与 Redis 交互的代码。 2. 安装和设置 在开始之前,请确保您已经安装了 Go 环境,并配置好了 Go 的工作空间。 首先,我们需要安装 go-redis/redismock 和 go-redis/redis/v8: go get github.com/go-redis/redismock/v8 go get github.com/go-redis/redis/v8 3. 示例:测试 Redis String、List、Set、Hash 和 Sorted Set String 类型 首先,我们将测试 Redis 中的 String 类型。我们创建一个 string_ops.go 文件,并实现相应的 Go 代码: // string_ops.go package main import ( "context" "fmt" "github.com/go-redis/redis/v8" ) // SetString 设置 Redis 中的 String 类型数据 func SetString(client *redis.Client, key, value string) error { return client.Set(context.Background(), key, value, 0).Err() } // GetString 获取 Redis 中的 String 类型数据 func GetString(client *redis.Client, key string) (string, error) { return client.Get(context.Background(), key).Result() } 在这个示例中,我们编写了 SetString 和 GetString 函数,用于设置和获取 Redis 中的 String 类型数据。 接下来,我们编写单元测试来测试这两个函数。创建一个 string_ops_test.go 文件: // string_ops_test.go package main import ( "testing" "github.com/go-redis/redismock/v8" ) func TestSetAndGet_String(t *testing.T) { client, mock := redismock.NewClientMock() key := "mykey" value := "myvalue" mock.ExpectSet(key, value).SetVal("OK") mock.ExpectGet(key).SetVal(value) err := SetString(client, key, value) if err != nil { t.Errorf("SetString failed: %s", err) } result, err := GetString(client, key) if err != nil { t.Errorf("GetString failed: %s", err) } if result != value { t.Errorf("unexpected value, got: %s, want: %s", result, value) } if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("unfulfilled expectations: %s", err) } } List 类型 接下来,我们测试 Redis 中的 List 类型。我们创建一个 list_ops.go 文件,并实现相应的 Go 代码: // list_ops.go package main import ( "context" "fmt" "github.com/go-redis/redis/v8" ) // ListPush 在 Redis 中的 List 类型数据上进行 push 操作 func ListPush(client *redis.Client, key string, values ...string) (int64, error) { return client.LPush(context.Background(), key, values).Result() } // ListPop 在 Redis 中的 List 类型数据上进行 pop 操作 func ListPop(client *redis.Client, key string) (string, error) { return client.LPop(context.Background(), key).Result() } 在这个示例中,我们编写了 ListPush 和 ListPop 函数,用于在 Redis 中添加和弹出 List 类型数据。 接下来,我们编写单元测试来测试这两个函数。创建一个 list_ops_test.go 文件: // list_ops_test.go package main import ( "testing" "github.com/go-redis/redismock/v8" ) func TestListPushAndPop(t *testing.T) { client, mock := redismock.NewClientMock() key := "mylist" // 测试 ListPush values := []string{"item1", "item2", "item3"} mock.ExpectLPush(key, values).SetVal(3) // 测试 ListPop mock.ExpectLPop(key).SetVal("item3") count, err := ListPush(client, key, values...) if err != nil { t.Errorf("ListPush failed: %s", err) } if count != 3 { t.Errorf("unexpected count, got: %d, want: %d", count, 3) } item, err := ListPop(client, key) if err != nil { t.Errorf("ListPop failed: %s", err) } if item != "item3" { t.Errorf("unexpected item, got: %s, want: %s", item, "item3") } if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("unfulfilled expectations: %s", err) } } Set 类型 接下来,我们测试 Redis 中的 Set 类型。我们创建一个 set_ops.go 文件,并实现相应的 Go 代码: // set_ops.go package main import ( "context" "fmt" "github.com/go-redis/redis/v8" ) // SetAdd 在 Redis 中的 Set 类型数据上进行 add 操作 func SetAdd(client *redis.Client, key string, members ...string) (int64, error) { return client.SAdd(context.Background(), key, members).Result() } // SetMembers 获取 Redis 中的 Set 类型数据的所有成员 func SetMembers(client *redis.Client, key string) ([]string, error) { return client.SMembers(context.Background(), key).Result() } 在这个示例中,我们编写了 SetAdd 和 SetMembers 函数,用于在 Redis 中添加和获取 Set 类型数据。 接下来,我们编写单元测试来测试这两个函数。创建一个 set_ops_test.go 文件: // set_ops_test.go package main import ( "reflect" "testing" "github.com/go-redis/redismock/v8" ) func TestSetAddAndMembers(t *testing.T) { client, mock := redismock.NewClientMock() key := "myset" // 测试 SetAdd members := []string{"member1", "member2", "member3"} mock.ExpectSAdd(key, members).SetVal(3) // 测试 SetMembers mock.ExpectSMembers(key).SetVal([]string{"member1", "member2", "member3"}) count, err := SetAdd(client, key, members...) if err != nil { t.Errorf("SetAdd failed: %s", err) } if count != 3 { t.Errorf("unexpected count, got: %d, want: %d", count, 3) } result, err := SetMembers(client, key) if err != nil { t.Errorf("SetMembers failed: %s", err) } if !reflect.DeepEqual(result, members) { t.Errorf("unexpected members, got: %v, want: %v", result, members) } if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("unfulfilled expectations: %s", err) } } Hash 类型 接下来,我们测试 Redis 中的 Hash 类型。我们创建一个 hash_ops.go 文件,并实现相应的 Go 代码: // hash_ops.go package main import ( "context" "fmt" "github.com/go-redis/redis/v8" ) // HashSet 在 Redis 中的 Hash 类型数据上进行设置操作 func HashSet(client *redis.Client, key string, fieldValues map[string]interface{}) error { return client.HMSet(context.Background(), key, fieldValues).Err() } // HashGet 获取 Redis 中的 Hash 类型数据的指定字段值 func HashGet(client *redis.Client, key, field string) (string, error) { return client.HGet(context.Background(), key, field).Result() } 在这个示例中,我们编写了 HashSet 和 HashGet 函数,用于在 Redis 中设置和获取 Hash 类型数据。 接下来,我们编写单元测试来测试这两个函数。创建一个 hash_ops_test.go 文件: // hash_ops_test.go package main import ( "testing" "github.com/go-redis/redismock/v8" ) func TestHashSetAndGet(t *testing.T) { client, mock := redismock.NewClientMock() key := "myhash" // 测试 HashSet fieldValues := map[string]interface{}{ "field1": "value1", "field2": "value2", "field3": "value3", } mock.ExpectHMSet(key, fieldValues).SetVal("OK") // 测试 HashGet field := "field2" mock.ExpectHGet(key, field).SetVal("value2") err := HashSet(client, key, fieldValues) if err != nil { t.Errorf("HashSet failed: %s", err) } result, err := HashGet(client, key, field) if err != nil { t.Errorf("HashGet failed: %s", err) } if result != "value2" { t.Errorf("unexpected value, got: %s, want: %s", result, "value2") } if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("unfulfilled expectations: %s", err) } } Sorted Set 类型 最后,我们测试 Redis 中的 Sorted Set 类型。我们创建一个 sorted_set_ops.go 文件,并实现相应的 Go 代码: // sorted_set_ops.go package main import ( "context" "fmt" "github.com/go-redis/redis/v8" ) // SortedSetAdd 在 Redis 中的 Sorted Set 类型数据上进行 add 操作 func SortedSetAdd(client *redis.Client, key string, members ...*redis.Z) (int64, error) { return client.ZAdd(context.Background(), key, members...).Result() } // SortedSetRange 获取 Redis 中的 Sorted Set 类型数据指定范围的成员 func SortedSetRange(client *redis.Client, key string, start, stop int64) ([]string, error) { return client.ZRange(context.Background(), key, start, stop).Result() } 在这个示例中,我们编写了 SortedSetAdd 和 SortedSetRange 函数,用于在 Redis 中添加和获取 Sorted Set 类型数据。 接下来,我们编写单元测试来测试这两个函数。创建一个 sorted_set_ops_test.go 文件: // sorted_set_ops_test.go package main import ( "reflect" "testing" "github.com/go-redis/redismock/v8" ) func TestSortedSetAddAndRange(t *testing.T) { client, mock := redismock.NewClientMock() key := "mysortedset" // 测试 SortedSetAdd members := []*redis.Z{ &redis.Z{Score: 1, Member: "member1"}, &redis.Z{Score: 2, Member: "member2"}, &redis.Z{Score: 3, Member: "member3"}, } mock.ExpectZAdd(key, members...).SetVal(3) // 测试 SortedSetRange mock.ExpectZRange(key, 0, -1).SetVal([]string{"member1", "member2", "member3"}) count, err := SortedSetAdd(client, key, members...) if err != nil { t.Errorf("SortedSetAdd failed: %s", err) } if count != 3 { t.Errorf("unexpected count, got: %d, want: %d", count, 3) } result, err := SortedSetRange(client, key, 0, -1) if err != nil { t.Errorf("SortedSetRange failed: %s", err) } expected := []string{"member1", "member2", "member3"} if !reflect.DeepEqual(result, expected) { t.Errorf("unexpected members, got: %v, want: %v", result, expected) } if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("unfulfilled expectations: %s", err) } } 4. 执行 Lua 脚本 现在,让我们测试在 Redis 中执行 Lua 脚本。我们将使用 counter.lua 脚本,并在其中实现计数器应用。 首先,创建一个名为 counter.lua 的 Lua 脚本: -- counter.lua local key = KEYS[1] local increment = tonumber(ARGV[1]) if increment > 0 then return redis.call('INCRBY', key, increment) else return redis.call('GET', key) end 在该脚本中,我们接收一个键和一个增量值作为参数。如果增量值大于 0,则在 Redis 中使用 INCRBY 命令增加计数;否则,返回该键的当前值。 接下来,我们创建一个名为 counter.go 的文件,并实现相应的 Go 代码: // counter.go package main import ( "context" "github.com/go-redis/redis/v8" ) // Lua 脚本用于增加计数或获取当前值 const counterLuaScript = ` local key = KEYS[1] local increment = tonumber(ARGV[1]) if increment > 0 then return redis.call('INCRBY', key, increment) else return redis.call('GET', key) end ` // IncreaseCounter 使用 Redis EVAL 命令执行 Lua 脚本,增加计数或获取当前值 func IncreaseCounter(client *redis.Client, key string, increment int64) (int64, error) { // 调用 Redis EVAL 命令执行 Lua 脚本 result, err := client.Eval(context.Background(), counterLuaScript, []string{key}, increment).Result() if err != nil { return 0, err } // 转换结果为整数并返回 if intValue, ok := result.(int64); ok { return intValue, nil } return 0, fmt.Errorf("unexpected result type") } 在这个示例中,我们编写了 IncreaseCounter 函数,它使用 Redis 客户端执行我们之前创建的 Lua 脚本,并返回结果。 接下来,我们编写单元测试来测试 IncreaseCounter 函数。创建一个 counter_test.go 文件: // counter_test.go package main import ( "testing" "github.com/go-redis/redismock/v8" ) func TestIncreaseCounter_PositiveIncrement(t *testing.T) { client, mock := redismock.NewClientMock() key := "mykey" increment := int64(5) mock.ExpectEval(counterLuaScript, []string{key}, increment).SetVal(increment) result, err := IncreaseCounter(client, key, increment) if err != nil { t.Errorf("IncreaseCounter failed: %s", err) } if result != increment { t.Errorf("unexpected result, got: %d, want: %d", result, increment) } if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("unfulfilled expectations: %s", err) } } func TestIncreaseCounter_NegativeIncrement(t *testing.T) { client, mock := redismock.NewClientMock() key := "mykey" increment := int64(-5) currentValue := int64(10) mock.ExpectEval(counterLuaScript, []string{key}, increment).SetVal(currentValue) result, err := IncreaseCounter(client, key, increment) if err != nil { t.Errorf("IncreaseCounter failed: %s", err) } if result != currentValue { t.Errorf("unexpected result, got: %d, want: %d", result, currentValue) } if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("unfulfilled expectations: %s", err) } } 5. 结论 在本文中,我们学习了如何使用 go-redis/redismock 在 Go 语言中测试 Redis 常用的五种数据类型以及执行 Lua 脚本。我们测试了 Redis 中的 String、List、Set、Hash 和 Sorted Set 类型,并实现了一个简单的计数器应用来测试执行 Lua 脚本的功能。通过单元测试,我们可以确保这些 Redis 操作的正确性和稳定性。使用 go-redis/redismock,我们可以在不连接到真实 Redis 数据库的情况下快速和可控地测试与 Redis 交互的代码。这为我们的应用程序开发和维护带来了便利和信心。
引言 全局异常处理是一种关键的开发实践,可以帮助我们更好地处理应用程序中的异常情况。本文将介绍如何在 Laravel 中实现全局异常处理,并探讨一些最佳实践,包括日志记录、异常监控和报警以及单元测试。 在开发 Web 应用程序时,异常处理是至关重要的。当应用程序发生异常时,我们希望能够及时捕获和处理异常,并提供有用的错误信息给用户或开发团队。在 Laravel 框架中,我们可以通过全局异常处理来统一处理应用程序中的异常情况。本文将介绍如何在 Laravel 中实现全局异常处理,并分享一些最佳实践。 实现步骤 1. 创建自定义异常处理器类: 创建一个自定义的异常处理器类,用于处理应用程序中的异常。可以在 app/Exceptions 目录下创建一个新的异常处理器类,例如 CustomExceptionHandler.php。 <?php namespace App\Exceptions; use Exception; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; class CustomExceptionHandler extends ExceptionHandler { // 自定义数据 protected $data; public function __construct($message, $code, $data = null) { parent::__construct($message, $code); $this->data = $data; } public function render($request, Exception $exception) { // 自定义异常处理逻辑 // ... // 在这里可以将自定义数据记录到日志或其他地方 // 可以使用 $this->data 访问自定义数据 return parent::render($request, $exception); } } 2. 注册自定义异常处理器: 打开 app/Exceptions/Handler.php 文件,并将 report 和 render 方法中的异常处理逻辑迁移到自定义异常处理器中。 <?php namespace App\Exceptions; use Exception; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; class Handler extends ExceptionHandler { protected $dontReport = [ // ... ]; protected $dontFlash = [ // ... ]; public function register() { $this->reportable(function (Exception $exception) { // }); } public function render($request, Exception $exception) { if ($this->shouldReport($exception)) { return app(CustomExceptionHandler::class)->render($request, $exception); } return parent::render($request, $exception); } } 3. 创建异常处理器中间件: 创建一个异常处理器中间件,用于在全局范围内处理异常。可以在 app/Http/Middleware 目录下创建一个新的中间件类,例如 HandleExceptions.php。 <?php namespace App\Http\Middleware; use Closure; use Illuminate\Contracts\Debug\ExceptionHandler; class HandleExceptions { protected $handler; public function __construct(ExceptionHandler $handler) { $this->handler = $handler; } public function handle($request, Closure $next) { return $this->handler->render($request, $next($request)); } } 4. 注册异常处理器中间件: 打开 app/Http/Kernel.php 文件,并将异常处理器中间件添加到 $middleware 数组中。 <?php namespace App\Http; use Illuminate\Foundation\Http\Kernel as HttpKernel; class Kernel extends HttpKernel { protected $middleware = [ // ... \App\Http\Middleware\HandleExceptions::class, ]; // ... } 5. 日志记录: Laravel 默认已经配置了日志记录。确保在 config/logging.php 文件中的 channels 配置中有一个适当的日志通道,以记录异常信息。 在 config/logging.php 文件中,可以添加一个新的日志通道,例如 exceptions: 'channels' => [ // ... 'exceptions' => [ 'driver' => 'daily', 'path' => storage_path('logs/exceptions.log'), 'level' => 'error', ], ], 在自定义异常处理器类的 render 方法中,可以使用日志记录器来记录异常信息: use Illuminate\Support\Facades\Log; public function render($request, Exception $exception) { Log::channel('exceptions')->error($exception->getMessage()); return parent::render($request, $exception); } 这样,当发生异常时,异常信息将被记录到 storage/logs/exceptions.log 文件中。 6. 自定义异常响应: 可以根据需要,为不同类型的异常定义自定义的响应格式。在自定义异常处理器类的 render 方法中,根据异常的类型返回不同的错误响应。 public function render($request, Exception $exception) { if ($exception instanceof CustomException) { // 获取到自定义类的 data 数据 // $data = $exception->data; return response()->json([ 'error' => 'Custom Error', 'message' => $exception->getMessage(), ], 400); } return parent::render($request, $exception); } 7. 异常分类和处理: 根据异常的类型或来源,将异常进行分类,并为每个分类定义相应的处理逻辑。在自定义异常处理器类的 render 方法中,根据异常的类型执行特定的处理操作。 public function render($request, Exception $exception) { if ($exception instanceof DatabaseException) { // 处理数据库异常 } elseif ($exception instanceof ApiException) { // 处理 API 异常 } else { // 默认处理逻辑 } return parent::render($request, $exception); } 8. 友好的错误页面: 可以创建一个自定义的错误页面,用于显示异常信息。在自定义异常处理器类的 render 方法中,根据异常的类型或状态码返回相应的错误视图。 public function render($request, Exception $exception) { if ($this->isHttpException($exception)) { return response()->view('errors.custom', [], $exception->getStatusCode()); } return parent::render($request, $exception); } 9. 异常监控和报警: 可以使用 Laravel 提供的监控和报警工具,如 Laravel Telescope、Sentry 等,来监控和报警异常情况。 以下是一个使用 Sentry 的示例: 首先,安装 Sentry SDK: composer require sentry/sentry-laravel 在 .env 文件中,配置 Sentry 的 DSN: SENTRY_DSN=your-sentry-dsn 在 config/app.php 文件中,将 Sentry\Laravel\ServiceProvider::class 添加到 providers 数组中。 然后,可以在自定义异常处理器类的 render 方法中使用 Sentry 来报告异常: use Illuminate\Support\Facades\Log; use Sentry\State\HubInterface; public function render($request, Exception $exception) { app(HubInterface::class)->captureException($exception); return parent::render($request, $exception); } 这样,当发生异常时,Sentry 将捕获并报告异常信息。 10. 单元测试: 编写针对异常处理逻辑的单元测试,确保异常处理器的正确性和稳定性。可以使用 Laravel 提供的测试工具,如 PHPUnit,编写测试用例来覆盖不同类型的异常情况。 可以使用 Laravel 提供的 PHPUnit 测试框架编写单元测试用例来验证异常处理器的正确性和稳定性。以下是一个简单的示例: 创建一个测试类,例如 ExceptionHandlingTest.php,继承自 TestCase: use Tests\TestCase; class ExceptionHandlingTest extends TestCase { public function testCustomException() { $response = $this->get('/custom-exception'); $response->assertStatus(400); $response->assertJson([ 'error' => 'Custom Error', ]); } } 在测试类中,编写测试方法来模拟触发自定义异常,并验证异常处理器的响应。 在 routes/web.php 文件中,定义一个路由来触发自定义异常: Route::get('/custom-exception', function () { throw new CustomException('Custom Error'); }); 运行单元测试: php artisan test 以上是关于日志记录、异常监控和报警以及单元测试的简单示例。根据实际需求和使用的工具,可以进一步扩展和定制这些功能。
简介 在 Laravel 中,数据库查询是一个常见的任务。为了提高查询的性能和可维护性,我们可以通过自定义查询构造器类来优化数据库查询。本文将详细解析使用 自定义ORM查询构造器类 CacheBuilder 和 改造 Laravel 中的 DB 类,以使用自定义的查询构造器类 CacheBuilder 缓存技巧来优化数据库查询。并详细解释每个方法的意义和改造的原因。 优化方案 ORM 优化 查询构造器类是执行数据库查询的重要组件之一。通过自定义查询构造器类,我们可以扩展和优化查询功能,以满足特定的需求。在本文中,我们将详细解析自定义查询构造器类 CacheBuilder 中的每个方法,以及为什么要进行这样的改造。 当使用 Laravel 框架的 ORM 建造者模式时,你可以自定义一个新的查询方法 firstCache() 来实现从缓存中读取数据的功能。下面是一个详细的代码改造过程示例: 创建一个新的查询构造器类 CacheBuilder,继承自 Laravel 的原生查询构造器类 Illuminate\Database\Query\Builder。在这个类中,你可以添加自定义的方法 firstCache()。 <?php namespace App\Database\Query; use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\Cache; class CacheBuilder extends Builder { /** * Execute the query and get the first result from the cache or the database. * * @param array|string $columns * @return mixed */ public function firstCache($columns = ['*']) { // 1. 尝试从缓存中获取数据 $cacheKey = $this->getCacheKey(); $cachedData = Cache::get($cacheKey); if ($cachedData !== null) { // 如果缓存中存在数据,则直接返回 return $this->hydrateResults($this->model->newCollection([$cachedData]), $columns)->first(); } // 2. 从数据库中获取数据 $result = $this->first($columns); if ($result !== null) { // 如果数据库中存在数据,则将其缓存起来 Cache::put($cacheKey, $result->toArray(), $this->getCacheExpiration()); } return $result; } /** * Get the cache key for the query. * * @return string */ protected function getCacheKey() { // 这里可以根据你的需求生成一个唯一的缓存键 return 'cache_key_' . md5($this->toSql() . serialize($this->getBindings())); } /** * Get the cache expiration time in seconds. * * @return int */ protected function getCacheExpiration() { // 这里可以根据你的需求设置缓存的过期时间 return 3600; // 1 hour } } 上述代码展示了 CacheBuilder 类的基本结构和示例的缓存优化方法。现在,让我们逐个解析每个方法的意义和改造原因: firstCache() 方法 firstCache() 方法是我们自定义的方法,用于缓存查询结果并返回第一个结果。在该方法中,我们首先尝试从缓存中获取数据,如果缓存中存在数据,则直接返回缓存结果,避免了对数据库的额外查询操作。如果缓存中不存在数据,则从数据库中获取数据,并将结果缓存起来,以便下次查询时可以直接从缓存中读取。通过这样的改造,我们可以减少对数据库的访问次数,提高查询性能。 getCacheKey() 方法 getCacheKey() 方法用于生成缓存键名。在该方法中,我们根据查询的 SQL 语句和绑定的参数生成一个唯一的缓存键。通过自定义键名的生成规则,我们可以更灵活地控制缓存的存储和管理。 getCacheExpiration() 方法 getCacheExpiration() 方法用于设置缓存的过期时间。在该方法中,我们可以根据需求设置缓存的有效期,以确保缓存的数据在一定时间后会被更新。通过设置适当的缓存过期时间,我们可以在一定程度上保持数据的实时性。 通过以上的方法改造,我们实现了一个自定义的查询构造器类 CacheBuilder,它具备了缓存查询结果的能力。这样的改造使得我们可以更高效地执行数据库查询,减少对数据库的访问次数,从而提高查询性能和应用程序的响应速度。 接下来,让我们看一下如何在模型中使用自定义的查询构造器类 CacheBuilder。 <?php namespace App\Models; use App\Database\Query\CacheBuilder; use Illuminate\Database\Eloquent\Model; class YourModel extends Model { /** * Get a new query builder instance for the connection. * * @param \Illuminate\Database\Query\Builder|null $query * @return \Illuminate\Database\Query\Builder */ protected function newBaseQueryBuilder($query = null) { $connection = $this->getConnection(); // 使用自定义的查询构造器类 CacheBuilder return new CacheBuilder($connection, $connection->getQueryGrammar(), $connection->getPostProcessor(), $query); } } 现在你可以在使用模型查询时,使用新的方法 firstCache() 来从缓存中读取数据。例如: $result = YourModel::where('column', 'value')->firstCache(); 这将先尝试从缓存中获取数据,如果缓存中不存在,则从数据库中读取数据,并将结果缓存起来。 请注意,以上代码示例仅为演示目的,你可能需要根据你的实际需求进行适当的修改和调整。另外,确保你已经正确配置了缓存驱动程序和相关的缓存设置。 Query Builder 优化 在 Laravel 中,我们通常使用 DB 类来执行数据库查询操作。然而,有时候我们需要对查询进行优化,以提高性能和可维护性。这时,自定义查询构造器类就派上了用场。 继承ORM Builder实现 在本文中,我们将介绍如何改造 Laravel 的 DB 类,以使用自定义的查询构造器类 CacheBuilder。这个改造将为我们提供一个名为 firstCache() 的方法,用于缓存查询结果,从而进一步提高查询性能。 首先,我们需要创建一个新的类 CacheDB,它继承自 Laravel 的原生 DB 类,并重写了 connection 方法。这个方法用于获取数据库连接实例。 <?php namespace App\Database; use Illuminate\Support\Facades\DB as BaseDB; class CacheDB extends BaseDB { /** * Get a database connection instance. * * @param string|null $name * @return \Illuminate\Database\ConnectionInterface */ public static function connection($name = null) { $connection = parent::connection($name); // 使用自定义的查询构造器类 CacheBuilder $connection->setQueryGrammar($connection->getQueryGrammar()); $connection->setPostProcessor($connection->getPostProcessor()); $connection->setQueryBuilder(new CacheBuilder($connection, $connection->getQueryGrammar(), $connection->getPostProcessor())); return $connection; } } 上述代码中的 CacheDB 类继承了原生的 DB 类,并重写了 connection 方法。这个方法在获取数据库连接实例时被调用。 在重写的 connection 方法中,我们首先调用了父类的 connection 方法,以获取原始的数据库连接实例。然后,我们使用自定义的查询构造器类 CacheBuilder 替换了原始连接实例的查询语法和后处理器,并设置了新的查询构造器。 现在,我们需要在 config/app.php 文件中进行一些配置更改,以使用我们的自定义 CacheDB 类。 'aliases' => [ // ... 'DB' => App\Database\CacheDB::class, // ... ], 在上述代码中,我们将原生的 DB 类别名替换为我们自定义的 CacheDB 类。这样,当我们使用 DB 类进行数据库查询时,实际上是使用了我们自定义的查询构造器类 CacheBuilder。 现在,我们可以使用 firstCache() 方法来缓存查询结果,以提高查询性能。例如: $result = DB::table('your_table')->where('column', 'value')->firstCache(); 通过以上改造,我们实现了一个自定义的查询构造器类 CacheBuilder,并将其应用于 Laravel 的 DB 类。这样做的目的是为了提高数据库查询的性能和可维护性。 独立写Cache 当使用 Laravel 框架时,你可以通过自定义一个扩展类来实现对 DB 建造者模式的改造,以满足你的需求。下面是一个示例代码: <?php namespace App\Extensions; use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\Cache; class CustomQueryBuilder extends Builder { public function firstCache($minutes, $key = null) { $key = $key ?: $this->getCacheKey(); return Cache::remember($key, $minutes, function () { return $this->first(); }); } public function getCache($minutes, $key = null) { $key = $key ?: $this->getCacheKey(); return Cache::remember($key, $minutes, function () { return $this->get(); }); } protected function getCacheKey() { return 'query_cache_' . sha1($this->toSql() . serialize($this->getBindings())); } } 在上述代码中,我们自定义了一个名为 CustomQueryBuilder 的类,它继承自 Laravel 框架的 Builder 类,也就是 DB 建造者类。这个自定义类添加了 firstCache() 和 getCache() 两个方法。 firstCache() 方法用于获取第一条记录并使用缓存。它接受两个参数:缓存的分钟数和可选的缓存键。如果缓存存在,将直接从缓存中获取数据;否则,将从数据库获取数据并生成缓存。最后,返回获取到的第一条记录。 getCache() 方法用于获取多条记录并使用缓存。它接受两个参数:缓存的分钟数和可选的缓存键。与 firstCache() 方法类似,它也会先检查缓存是否存在,然后决定是直接从缓存中获取数据还是从数据库中获取数据并生成缓存。最后,返回获取到的多条记录。 这两个方法内部使用了 Laravel 框架的 Cache 类来进行缓存操作。缓存键的生成使用了查询的 SQL 语句和绑定参数,以保证每个查询的唯一性。 为了使用这个自定义的 CustomQueryBuilder 类,你需要在 Laravel 项目中注册这个自定义类作为 DB 建造者的默认类。可以在 AppServiceProvider 或其他合适的服务提供者中添加以下代码: use Illuminate\Database\ConnectionInterface; use App\Extensions\CustomQueryBuilder; public function register() { $this->app->bind(ConnectionInterface::class, function ($app) { return new CustomQueryBuilder( $app['db']->connection(), $app['db']->getQueryGrammar(), $app['db']->getPostProcessor() ); }); } 这段代码将绑定 ConnectionInterface 接口到 CustomQueryBuilder 类,以便在使用 DB 门面时默认使用你的自定义类。 这样,当你使用 DB 门面的 table() 方法时,将使用你自定义的 CustomQueryBuilder 类,从而具备了 firstCache() 和 getCache() 方法的功能。 使用封装的方法 现在你可以在项目中使用 firstCache() 和 getCache() 方法来获取缓存数据。以下是示例用法: $users = DB::table('users')->firstCache(60); // 获取第一条用户数据并使用缓存(缓存有效期为60分钟) 在上述示例中,我们使用 DB::table('users') 获取了一个查询构建器,然后通过调用 firstCache(60) 方法来获取第一条用户数据。如果缓存存在,将直接从缓存中获取数据,否则将从数据库中查询并生成缓存。 $users = DB::table('users')->getCache(60); // 获取所有用户数据并使用缓存(缓存有效期为60分钟) 这个示例展示了如何使用 getCache(60) 方法来获取所有的用户数据,并使用缓存。同样地,如果缓存存在,数据将直接从缓存中获取,否则将从数据库中查询并生成缓存。 总结: 通过自定义查询构造器类的改造,我们可以优化 Laravel 的数据库查询。在本文中,我们介绍了如何改造 DB 类,使用自定义的查询构造器类 CacheBuilder。这个改造允许我们使用新的方法 firstCache() 来缓存查询结果,从而提高查询性能。通过重写 connection 方法,我们成功地将自定义的查询构造器类应用于 DB 类,并在配置文件中进行了相应的修改。这样的改造提供了更高效和可扩展的数据库查询功能,使得我们能够更好地优化和管理我们的应用程序。无论是对于初学者还是有经验的开发者来说,这种改造都是非常有益的。 Laravel其他优化方法 除了对 Laravel 的建造者模式进行缓存优化外,还有许多其他的优化方式可以提升 Laravel 框架的性能和效率。以下是一些常见的优化方式: 缓存配置和路由: 使用缓存来存储配置文件,以减少每次请求时重新加载配置的开销。 缓存路由信息,避免在每次请求时重新解析路由。 使用缓存: 使用缓存来存储经常访问的数据,如数据库查询结果、API 响应等。 使用适当的缓存驱动(如 Memcached、Redis)来提高缓存性能。 数据库优化: 使用适当的索引来加速数据库查询。 避免在循环中执行数据库查询,尽量使用批量操作。 使用延迟加载(Lazy Loading)来减少关联模型的查询次数。 代码优化: 避免在视图中执行复杂的逻辑操作,尽量将逻辑放在控制器或服务层中处理。 使用 Eager Loading 来预加载关联模型,减少查询次数。 使用合适的数据结构和算法来提高代码的效率。 使用队列: 将耗时的任务放入队列中异步处理,提高应用程序的响应速度。 使用适当的队列驱动(如 Redis、Beanstalkd)来提高队列的处理性能。 优化自动加载: 使用 Composer 的 classmap 自动加载优化,将类映射到文件路径,减少自动加载的开销。 避免加载不必要的类和文件。 使用缓存视图: 将编译后的视图缓存起来,减少视图编译的开销。 使用 HTTP 缓存: 设置适当的缓存头,利用浏览器缓存和 CDN 缓存来减少重复请求。 使用性能分析工具: 使用工具如 Laravel Debugbar、Blackfire 等来分析应用程序的性能瓶颈,并进行相应的优化。 这些优化方式可以根据具体的应用场景和需求进行选择和实施。通过综合应用这些优化策略,可以显著提升 Laravel 应用程序的性能和效率。
当检查一个PHP项目中是否存在语法错误时,我们可以使用Shell脚本来自动化这个过程。在本文中,我们将介绍两种方法来实现这个目标。 方法一:使用串行方式 首先,我们可以使用一个简单的Shell脚本来遍历项目目录中的所有PHP文件,并使用php -l命令来检查每个文件是否存在语法错误。以下是实现这个方法的脚本: #!/bin/bash # 设置项目路径 project_path="/path/to/your/php/project" # 遍历项目目录中的所有php文件 for file in $(find $project_path -type f -name "*.php"); do # 检查文件是否存在语法错误 php -l $file done 这个脚本将会遍历项目目录中的所有PHP文件,并使用php -l命令来检查每个文件是否存在语法错误。如果存在语法错误,将会输出错误信息。 方法二:使用并行方式 如果你想要加快检查的速度,你可以使用以下方式同时开启多个线程并行检查PHP文件的语法错误。以下是实现这个方法的脚本: xargs命令 #!/bin/bash # 设置项目路径 project_path="/path/to/your/php/project" # 定义函数,用于检查语法错误 check_syntax() { php -l "$1" } # 导出函数,以便在xargs中使用 export -f check_syntax # 遍历项目目录中的所有php文件,并使用xargs并行执行检查语法错误的函数 find "$project_path" -type f -name "*.php" | xargs -I {} -P 20 bash -c 'check_syntax "$@"' _ {} 推荐下面这种写法,如果遇到语法错误,直接中断脚本执行。 #!/bin/bash # 提示用户输入项目路径 project_path="$1" echo "项目路径: $project_path" # 错误处理 handle_error() { echo "发生了错误,请检查脚本或项目路径。" exit 1 } trap 'handle_error' ERR # 遍历项目目录中的所有php文件,并使用xargs并行执行检查语法错误的命令 find "$project_path" -type f -name "*.php" -print0 | xargs -0 -I {} -P 20 bash -c 'php -l "{}"' && echo "OK" # 排除 vendor 目录 # find "$project_path" -type f -name "*.php" -not -path '*/vendor/*' -print0 | xargs -0 -I {} -P 20 bash -c 'php -l "{}"' && echo "OK" 工作原理 设置项目路径变量:将/path/to/your/php/project替换为你的PHP项目的实际路径。 定义检查语法错误的函数:check_syntax函数使用php -l命令来检查给定的PHP文件是否存在语法错误。 导出函数:使用export -f命令导出check_syntax函数,以便在xargs中使用。 遍历项目目录中的所有PHP文件:使用find命令查找项目目录中的所有PHP文件,并将它们传递给xargs命令。 并行执行检查语法错误的函数:xargs -I {} -P 20命令将每个PHP文件作为参数传递给bash -c 'check_syntax "$@"' _ {}命令。-P 20表示最多开启20个线程并行执行检查语法错误的函数。 检查语法错误:每个线程将使用check_syntax函数检查一个PHP文件是否存在语法错误。如果存在语法错误,将会输出错误信息。 parallel命令 #!/bin/bash # 设置项目路径 project_path="/path/to/your/php/project" # 使用GNU Parallel工具并行检查语法错误 find $project_path -type f -name "*.php" | parallel -j 20 php -l {} 设置项目路径变量:将/path/to/your/php/project替换为你的PHP项目的实际路径。 使用GNU Parallel工具并行检查语法错误:find $project_path -type f -name "*.php"命令用于查找项目目录中的所有PHP文件,并将它们作为输入传递给parallel命令。 -j 20参数表示最多开启20个线程并行执行后续的命令。 php -l {}命令用于检查每个PHP文件是否存在语法错误。{}是一个占位符,会被当前正在处理的文件名替换。 总结 使用Shell脚本来检查PHP项目中是否存在语法错误是一种简单而有效的方法。你可以选择使用串行方式或并行方式来实现这个目标,具体取决于你的需求和项目的规模。无论你选择哪种方法,都能够帮助你及时发现并修复PHP项目中的语法错误,提高代码的质量和可靠性。
问题描述 数据量大、并发量高场景,如何在流量低峰期,平滑实施表结构变更? 一般来说,是指增加表的属性,因为: 如果是减column,升级程序不使用即可; 如果是修改column,程序兼容性容易出问题; 常见方案 方案一:在线修改表结构。 alter table add column 数据量大的情况下,锁表时间会较长,造成拒绝服务,一般不可行。 方案二:通过增加表的方式扩展属性,通过外键join来查询。 举个例子,对: t_user(uid, c1, c2, c3) 想要扩展属性,可以通过增加一个表实现: t_user_ex(uid, c4, c5, c6) 数据量大的情况下,join性能较差,一般不可行。 方案三,通过增加表的方式扩展,通过视图来屏蔽底层复杂性。 同上,视图效率较低,一般不使用视图。 方案四,提前预留一些reserved字段,加列可复用这些字段。 这个方案可行,但如果预留过多,会造成空间浪费。 方案六,pt-online-schema-change 对于MySQL而言,这是目前比较成熟的方案,被广大公司所使用。 pt-online-schema-change方案说明 假设: user(uid, name, passwd) 要扩展到: user(uid, name, passwd, age, sex) 第一步,先创建一个扩充字段后的新表: user_new(uid, name, passwd, age, sex) 第二步,在原表user上创建三个触发器,对原表user进行的所有insert/delete/update操作,都会对新表user_new进行相同的操作; 第三步,分批将原表user中的数据insert到新表user_new,直至数据迁移完成; 第四步,删掉触发器,把原表移走(默认是drop掉); 第五步,把新表user_new重命名(rename)成原表user; 扩充字段完成,整个过程不需要锁表,可以持续对外提供服务。 操作过程中需要注意: 变更过程中,最重要的是冲突的处理,一条原则,以触发器的新数据为准,这就要求被迁移的表必须有主键(这个要求基本都满足); 变更过程中,写操作需要建立触发器,所以如果原表已经有很多触发器,方案就不行(互联网大数据高并发的在线业务,一般都禁止使用触发器); 触发器的建立,会影响原表的性能,所以这个操作必须在流量低峰期进行;
为了提高开发效率和质量,我们常常需要ORM来帮助我们快速实现持久层增删改查API,目前go语言实现的ORM有很多种,他们都有自己的优劣点,有的实现简单,有的功能复杂,有的API十分优雅。在使用了多个类似的工具之后,总是会发现某些点无法满足解决我们生产环境中碰到的实际问题,比如无法集成公司内部的监控,Trace组件,没有database层的超时设置,没有熔断等,所以有必要公司自己内部实现一款满足我们可自定义开发的ORM,好用的生产工具常常能够对生产力产生飞跃式的提升。 为什么需要ORM 直接使用database/sql的痛点 首先看看用database/sql如何查询数据库 我们用user表来做例子,一般的工作流程是先做技术方案,其中排在比较前面的是数据库表的设计,大部分公司应该有严格的数据库权限控制,不会给线上程序使用比较危险的操作权限,比如创建删除数据库,表,删除数据等。 表结构如下: CREATE TABLE `user` ( `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'id', `name` varchar(100) NOT NULL COMMENT '名称', `age` int(11) NOT NULL DEFAULT '0' COMMENT '年龄', `ctime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `mtime` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 首先我们要写出和表结构对应的结构体User,如果你足够勤奋和努力,相应的json tag 和注释都可以写上,这个过程无聊且重复,因为在设计表结构的时候你已经写过一遍了。 type User struct { Id int64 `json:"id"` Name string `json:"name"` Age int64 Ctime time.Time Mtime time.Time // 更新时间 } 定义好结构体,我们写一个查询年龄在20以下且按照id字段顺序排序的前20名用户的 go代码 func FindUsers(ctx context.Context) ([]*User, error) { rows, err := db.QueryContext(ctx, "SELECT `id`,`name`,`age`,`ctime`,`mtime` FROM user WHERE `age`<? ORDER BY `id` LIMIT 20 ", 20) if err != nil { return nil, err } defer rows.Close() result := []*User{} for rows.Next() { a := &User{} if err := rows.Scan(&a.Id, &a.Name, &a.Age, &a.Ctime, &a.Mtime); err != nil { return nil, err } result = append(result, a) } if rows.Err() != nil { return nil, rows.Err() } return result, nil } 当我们写少量这样的代码的时候我们可能还觉得轻松,但是当你业务工期排的很紧,并且要写大量的定制化查询的时候,这样的重复代码会越来越多。 上面的的代码我们发现有这么几个问题: SQL 语句是硬编码在程序里面的,当我需要增加查询条件的时候我需要另外再写一个方法,整个方法需要拷贝一份,很不灵活。 在查询表所有字段的情况下,第2行下面的代码都是一样重复的,不管sql语句后面的条件是怎么样的。 我们发现第1行SQL语句编写和rows.Scan()那行,写的枯燥层度是和表字段的数量成正比的,如果一个表有50个字段或者100个字段,手写是非常乏味的。 在开发过程中rows.Close() 和 rows.Err()忘记写是常见的错误。 我们总结出来用database/sql标准库开发的痛点: 开发效率很低 很显然写上面的那种代码是很耗费时间的,因为手误容易写错,无可避免要增加自测的时间。如果上面的结构体User、 查询方法FindUsers() 代码能够自动生成,那么那将会极大的提高开发效率并且减少human error的发生从而提高开发质量。 心智负担很重 如果一个开发人员把大量的时间花在这些代码上,那么他其实是在浪费自己的时间,不管在工作中还是在个人项目中,应该把重点花在架构设计,业务逻辑设计,困难点攻坚上面,去探索和开拓自己没有经验的领域,这块Dao层的代码最好在10分钟内完成。 ORM的核心组成 明白了上面的痛点,为了开发工作更舒服,更高效,我们尝试着自己去开发一个ORM,核心的地方在于两个方面: SQLBuilder:SQL语句要非硬编码,通过某种链式调用构造器帮助我构建SQL语句。 Scanner:从数据库返回的数据可以自动映射赋值到结构体中。 SQL SelectBuilder 我们尝试做个简略版的查询语句构造器,最终我们要达到如下图所示的效果。 我们可以通过和SQL关键字同名的方法来表达SQL语句的固有关键字,通过go方法参数来设置其中动态变化的元素,这样链式调用和写SQL语句的思维顺序是一致的,只不过我们之前通过硬编码的方式变成了方法调用。 具体代码如下: type SelectBuilder struct { builder *strings.Builder column []string tableName string where []func(s *SelectBuilder) args []interface{} orderby string offset *int64 limit *int64 } func (s *SelectBuilder) Select(field ...string) *SelectBuilder { s.column = append(s.column, field...) return s } func (s *SelectBuilder) From(name string) *SelectBuilder { s.tabelName = name return s } func (s *SelectBuilder) Where(f ...func(s *SelectBuilder)) *SelectBuilder { s.where = append(s.where, f...) return s } func (s *SelectBuilder) OrderBy(field string) *SelectBuilder { s.orderby = field return s } func (s *SelectBuilder) Limit(offset, limit int64) *SelectBuilder { s.offset = &offset s.limit = &limit return s } func GT(field string, arg interface{}) func(s *SelectBuilder) { return func(s *SelectBuilder) { s.builder.WriteString("`" + field + "`" + " > ?") s.args = append(s.args, arg) } } func (s *SelectBuilder) Query() (string, []interface{}) { s.builder.WriteString("SELECT ") for k, v := range s.column { if k > 0 { s.builder.WriteString(",") } s.builder.WriteString("`" + v + "`") } s.builder.WriteString(" FROM ") s.builder.WriteString("`" + s.tableName + "` ") if len(s.where) > 0 { s.builder.WriteString("WHERE ") for k, f := range s.where { if k > 0 { s.builder.WriteString(" AND ") } f(s) } } if s.orderby != "" { s.builder.WriteString(" ORDER BY " + s.orderby) } if s.limit != nil { s.builder.WriteString(" LIMIT ") s.builder.WriteString(strconv.FormatInt(*s.limit, 10)) } if s.offset != nil { s.builder.WriteString(" OFFSET ") s.builder.WriteString(strconv.FormatInt(*s.offset, 10)) } return s.builder.String(), s.args } 通过结构体上的方法调用返回自身,使其具有链式调用能力,并通过方法调用设置结构体中的值,用以构成SQL语句需要的元素。 SelectBuilder 包含性能较高的strings.Builder 来拼接字符串。 Query()方法构建出真正的SQL语句,返回包含占位符的SQL语句和args参数。 []func(s *SelectBuilder)通过函数数组来创建查询条件,可以通过函数调用的顺序和层级来生成 AND OR这种有嵌套关系的查询条件子句。 Where() 传入的是查询条件函数,为可变参数列表,查询条件之间默认是AND关系。 外部使用起来效果: b := SelectBuilder{builder: &strings.Builder{}} sql, args := b. Select("id", "name", "age", "ctime", "mtime"). From("user"). Where(GT("id", 0), GT("age", 0)). OrderBy("id"). Limit(0, 20). Query() Scanner的实现 顾名思义Scanner的作用就是把查询结果设置到对应的go对象上去,完成关系和对象的映射,关键核心就是通过反射获知传入对象的类型和字段类型,通过反射创建对象和值,并通过golang结构体的字段后面的tag来和查询结果的表头一一对应,达到动态给结构字段赋值的能力。 具体实现如下: func ScanSlice(rows *sql.Rows, dst interface{}) error { defer rows.Close() // dst的地址 val := reflect.ValueOf(dst) // &[]*main.User // 判断是否是指针类型,go是值传递,只有传指针才能让更改生效 if val.Kind() != reflect.Ptr { return errors.New("dst not a pointer") } // 指针指向的Value val = reflect.Indirect(val) // []*main.User if val.Kind() != reflect.Slice { return errors.New("dst not a pointer to slice") } // 获取slice中的类型 struPointer := val.Type().Elem() // *main.User // 指针指向的类型 具体结构体 stru := struPointer.Elem() // main.User cols, err := rows.Columns() // [id,name,age,ctime,mtime] if err != nil { return err } // 判断查询的字段数是否大于 结构体的字段数 if stru.NumField() < len(cols) { // 5,5 return errors.New("NumField and cols not match") } //结构体的json tag的value对应字段在结构体中的index tagIdx := make(map[string]int) //map tag -> field idx for i := 0; i < stru.NumField(); i++ { tagname := stru.Field(i).Tag.Get("json") if tagname != "" { tagIdx[tagname] = i } } resultType := make([]reflect.Type, 0, len(cols)) // [int64,string,int64,time.Time,time.Time] index := make([]int, 0, len(cols)) // [0,1,2,3,4,5] // 查找和列名相对应的结构体jsontag name的字段类型,保存类型和序号到resultType和index中 for _, v := range cols { if i, ok := tagIdx[v]; ok { resultType = append(resultType, stru.Field(i).Type) index = append(index, i) } } for rows.Next() { // 创建结构体指针,获取指针指向的对象 obj := reflect.New(stru).Elem() // main.User result := make([]interface{}, 0, len(resultType)) //[] // 创建结构体字段类型实例的指针,并转化为interface{} 类型 for _, v := range resultType { result = append(result, reflect.New(v).Interface()) // *Int64 ,*string .... } // 扫描结果 err := rows.Scan(result...) if err != nil { return err } for i, v := range result { // 找到对应的结构体index fieldIndex := index[i] // 把scan 后的值通过反射得到指针指向的value,赋值给对应的结构体字段 obj.Field(fieldIndex).Set(reflect.ValueOf(v).Elem()) // 给obj 的每个字段赋值 } // append 到slice vv := reflect.Append(val, obj.Addr()) // append到 []*main.User, maybe addr change val.Set(vv) // []*main.User } return rows.Err() } 通过反射赋值流程,如果想知道具体的实现细节可以仔细阅读上面代码里面的注释 以上主要的思想就是通过reflect包来获取传入dst的Slice类型,并通过反射创建其包含的对象,具体的步骤和解释请仔细阅读注释和图例。 通过指定的json tag 可以把查询结果和结构体字段mapping起来,即使查询语句中字段不按照表结构顺序。 ScanSlice是通用的Scanner。 使用反射创建对象明显创建了多余的对象,没有传统的方式赋值高效,但是换来的巨大的灵活性在某些场景下是值得的。 有了SQLBuilder和Scanner 我们就可以这样写查询函数了: func FindUserReflect() ([]*User, error) { b := SelectBuilder{builder: &strings.Builder{}} sql, args := b. Select("id", "name", "age", "ctime", "mtime"). From("user"). Where(GT("id", 0), GT("age", 0)). OrderBy("id"). Limit(0, 20). Query() rows, err := db.QueryContext(ctx, sql, args...) if err != nil { return nil, err } result := []*User{} err = ScanSlice(rows, &result) if err != nil { return nil, err } return result, nil } 生成的查询SQL语句和args如下: SELECT `id`,`name`,`age`,`ctime`,`mtime` FROM `user` WHERE `id` > ? AND `age` > ? ORDER BY id LIMIT 20 OFFSET 0 [0 0] 自动生成 通过上面的使用的例子来看,我们的工作轻松了不少: 第一:SQL语句不需要硬编码了; 第二:Scan不需要写大量结构体字段和的乏味的重复代码。 着实帮我们省了很大的麻烦。但是查询字段还需要我们自己手写,像这种 Select("id", "name", "age", "ctime", "mtime"). 其中传入的字段需要我们硬编码,我们可不可以再进一步,通过表结构定义来生成我们的golang结构体呢?答案是肯定的,要实现这一步我们需要一个SQL语句的解析器(https://github.com/xwb1989/sqlparser),把SQL DDL语句解析成go语言中如下的Table对象,其所包含的表名,列名、列类型、注释等都能获取到,再通过这些对象和写好的模板代码来生成我们实际业务使用的代码。 Table对象如下: type Table struct { TableName string // table name GoTableName string // go struct name PackageName string // package name Fields []*Column // columns } type Column struct { ColumnName string // column_name ColumnType string // column_type ColumnComment string // column_comment } 使用以上Table对象的模板代码: type {{.GoTableName}} struct { {{- range .Fields }} {{ .GoColumnName }} {{ .GoColumnType }} `json:"{{ .ColumnName }}"` // {{ .ColumnComment }} {{- end}} } const ( table = "{{.TableName}}" {{- range .Fields}} {{ .GoColumnName}} = "{{.ColumnName}}" {{- end }} ) var columns = []string{ {{- range .Fields}} {{ .GoColumnName}}, {{- end }} } 通过上面的模板我们用user表的建表SQL语句生成如下代码: type User struct { Id int64 `json:"id"` // id字段 Name string `json:"name"` // 名称 Age int64 `json:"age"` // 年龄 Ctime time.Time `json:"ctime"` // 创建时间 Mtime time.Time `json:"mtime"` // 更新时间 } const ( table = "user" Id = "id" Name = "name" Age = "age" Ctime = "ctime" Mtime = "mtime" ) var Columns = []string{"id","name","age","ctime","mtime"} 那么我们在查询的时候就可以这样使用 Select(Columns...) 通过模板自动生成代码,可以大大的减轻开发编码负担,使我们从繁重的代码中解放出来。 reflect真的有必要吗? 由于我们SELECT时选择查找的字段和顺序是不固定的,我们有可能 SELECT id, name, age FROM user,也可能 SELECT name, id FROM user,有很大的任意性,这种情况使用反射出来的结构体tag和查询的列名来确定映射关系是必须的。但是有一种情况我们不需要用到反射,而且是一种最常用的情况,即:查询的字段名和表结构的列名一致,且顺序一致。这时候我们可以这么写,通过DeepEqual来判断查询字段和表结构字段是否一致且顺序一致来决定是否通过反射还是通过传统方法来创建对象。用传统方式创建对象(如下图第12行)令我们编码痛苦,不过可以通过模板来自动生成下面的代码,以避免手写,这样既灵活方便好用,性能又没有损耗,看起来是一个比较完美的解决方案。 func FindUserNoReflect(b *SelectBuilder) ([]*User, error) { sql, args := b.Query() rows, err := db.QueryContext(ctx, sql, args...) if err != nil { return nil, err } result := []*User{} if DeepEqual(b.column, Columns) { defer rows.Close() for rows.Next() { a := &User{} if err := rows.Scan(&a.Id, &a.Name, &a.Age, &a.Ctime, &a.Mtime); err != nil { return nil, err } result = append(result, a) } if rows.Err() != nil { return nil, rows.Err() } return result, nil } err = ScanSlice(rows, &result) if err != nil { return nil, err } return result, nil } 总结与展望 总结 通过database/sql 库开发有较大痛点,ORM就是为了解决以上问题而生,其存在是有意义的。 ORM 两个关键的部分是SQLBuilder和Scanner的实现。 ORM Scanner 使用反射创建对象在性能上肯定会有一定的损失,但是带来极大的灵活性,同时在查询全表字段这种特殊情况下规避使用反射来提高性能。 展望 通过表结构,我们可以生成对应的结构体和持久层增删改查代码,我们再往前扩展一步,能否通过表结构生成的proto格式的message,以及一些常用的CRUD GRPC rpc接口定义。通过工具,我们甚至可以把前端的代码都生成好,实现半自动化编程。我想这个是值得期待的。 参考资料 https://github.com/ent/ent
在 Docker 17.05 版本之前,我们构建 Docker 镜像时,通常会采用两种方式: 全部放入一个 Dockerfile 一种方式是将所有的构建过程编包含在一个 Dockerfile 中,包括项目及其依赖库的编译、测试、打包等流程,这里可能会带来的一些问题: 镜像层次多,镜像体积较大,部署时间变长 源代码存在泄露的风险 例如,编写 app.go 文件,该程序输出 Hello World! package main import "fmt" func main(){ fmt.Printf("Hello World!"); } 编写 Dockerfile.one 文件 FROM golang:alpine RUN apk --no-cache add git ca-certificates WORKDIR /go/src/github.com/go/helloworld/ COPY app.go . RUN go get -d -v github.com/go-sql-driver/mysql \ && CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app . \ && cp /go/src/github.com/go/helloworld/app /root WORKDIR /root/ CMD ["./app"] 构建镜像 $ docker build -t go/helloworld:1 -f Dockerfile.one . 多阶段构建的优势 自 Docker 17.05 版本起,引入了多阶段构建(multi-stage builds)的概念,这种方式能有效解决上述问题。多阶段构建允许我们在一个 Dockerfile 中定义多个 FROM 指令,从而在构建过程中生成多个临时镜像,这样可以减小最终镜像的体积,并避免将源代码暴露在生产环境中。 多阶段构建的示例 以下是一个使用多阶段构建的示例,演示如何构建一个更为精简的 Docker 镜像。 编写 Dockerfile 文件: # 第一阶段:构建阶段 FROM golang:alpine AS builder RUN apk --no-cache add git ca-certificates WORKDIR /go/src/github.com/go/helloworld/ COPY app.go . # 获取依赖并构建 RUN go get -d -v github.com/go-sql-driver/mysql \ && CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app . # 第二阶段:运行阶段 FROM alpine:latest # 复制构建好的二进制文件 WORKDIR /root/ COPY --from=builder /go/src/github.com/go/helloworld/app . # 运行应用程序 CMD ["./app"] 构建镜像 使用以下命令构建镜像: $ docker build -t go/helloworld:multi . 运行镜像 使用以下命令运行生成的镜像: $ docker run --rm go/helloworld:multi 总结 多阶段构建 Docker 镜像的方式,不仅可以优化镜像的体积,还能提高构建的安全性,避免将源代码暴露到最终镜像中。通过使用 COPY --from=builder 指令,我们可以轻松地将需要的文件从构建阶段复制到运行阶段,确保最终镜像中仅包含必要的二进制文件和资源。这种构建方式对于构建微服务和容器化应用特别有用,能够提升持续集成和持续部署的效率。
背景 Go语言在 v1.14 之前使用 go path 模式,在 v1.14 之后采用 go mod 模式管理项目。该文章针对 v1.14 后的环境安装方式进行讲解。 Mac系统安装 MacOs 建议使用 brew 包管理工具安装go语言环境,运行以下命令安装go $ brew install go 配置环境变量 进入配置文件 # bash 终端 $ vim ~/.bash_profile # zsh 终端 $ vim ~/.zshrc 配置环境变量 export GO111MODULE=on export GOPATH=$HOME/go export GOBIN=$GOPATH/bin export GOPROXY=https://goproxy.cn,direct export PATH=$PATH:$GOBIN 配置参数说明 GO111MODULE:go mod 模式,v1.14后默认 auto,常见配置为:auto、on、off,建议配置为 on GOPATH:golang安装目录 GOBIN:golang执行目录 GOPROXY:配置国内镜像加速 PATH:配置环境变量 刷新环境变量 # bash 终端 $ source ~/.bash_profile # zsh 终端 $ source ~/.zshrc 查看配置 $ go env GO111MODULE="on" GOARCH="amd64" GOBIN="/Users/libin/go/bin" GOCACHE="/Users/libin/Library/Caches/go-build" GOENV="/Users/libin/Library/Application Support/go/env" GOEXE="" GOEXPERIMENT="" GOFLAGS="" GOHOSTARCH="amd64" GOHOSTOS="darwin" GOINSECURE="" GOMODCACHE="/Users/libin/go/pkg/mod" GONOPROXY="" GONOSUMDB="" GOOS="darwin" GOPATH="/Users/libin/go" GOPRIVATE="" GOPROXY="https://goproxy.cn,direct" GOROOT="/usr/local/Cellar/go/1.18.3/libexec" GOSUMDB="sum.golang.org" GOTMPDIR="" GOTOOLDIR="/usr/local/Cellar/go/1.18.3/libexec/pkg/tool/darwin_amd64" GOVCS="" GOVERSION="go1.18.3" # 省略 ... ... Windows系统安装 Go SDK 下载地址:https://golang.google.cn/dl/ File name Kind OS Arch Size SHA256 Checksum go1.18.3.windows-amd64.msi Installer Windows X86-64 130MB 692ee6225305ad909630c9cc152719a9bdb332e911d180cf3143a5b6a09cc863 请根据自己的电脑芯片下载合适的版本,我的机器是 x86 芯片 64 位系统,所以我就下载此版本的软件了。 下载完成后,和其他应用类软件一样,需要选择一个你喜欢的目录安装此软件,接下来开始配置环境变量。 配置环境变量 此电脑(右键属性) --> 关于 --> 高级系统设置 --> 环境变量 --> 新建 建立以下环境变量 变量:GO111MODULE 值:on 变量:GOROOT 值:E:\Go 变量:GOPROXY 值:https://goproxy.cn,direct 选择 PATH,点击编辑,点击新建,添加PATH环境变量 %GOROOT%\bin; 打开 cmd 窗口,输入 go env ,测试是否配置成功 C:\Users\58850>go env
接口实现判断依据 值方法集和接口匹配 给接口变量赋值的不管是值还是指针对象,都ok,因为都包含值方法集 指针方法集和接口匹配 只能将指针对象赋值给接口变量,因为只有指针方法集和接口匹配 如果将值对象赋值给接口变量,会在编译期报错(会触发接口合理性检查机制) 接口绑定 type Annimaler interface { Name() string } type Dog struct {} func (d *Dog) Name() string { return "二哈" } func main() { var i Annimaler i = &Dog{} i = (*Dog)(nil) i = new(Dog) // i = Handle{} // 无法编译通过,因为 i 是指针类型,Handle{} 不是指针类型 // 调用接口方法 fmt.Println(i.Name()) } 基于以上的代码,我们大致可以写出 go interface 合理性验证的代码如下: var _ Annimaler = &Dog{} var _ Annimaler = (*Dog)(nil) 代码解释 赋值的右边应该是断言类型的零值,也就是说 Dog 类型的零值等于Annimaler类型的零值。 如果是指针类型(如 *Annimaler)、切片和映射,这是 nil; 如果是结构类型,这是空结构。 (*Dog)(nil) 是类型断言,就是把变量用 nil 代替,把 nil 转换成一个 Dog 类型的空指针后赋值给 Annimaler 接收器 (receiver) 与接口,接口与具体方法集的匹配 一个类型可以有值接收器方法集和指针接收器方法集 使用值接收器的方法既可以通过值调用,也可以通过指针调用。带指针接收器的方法只能通过指针或 addressable values调用(其实和值调用类似)。 如果方法的接收者是值类型,无论调用者是对象还是对象指针,修改的都是对象的副本,不影响调用者;如果方法的接收者是指针类型,则调用者修改的是指针指向的对象本身。 通常我们使用指针作为方法的接收者的理由: 使用指针方法能够修改接收者指向的值。 可以避免在每次调用方法时复制该值,在值的类型为大型结构体时,这样做会更加高效。 在该代码中: i = &Dog{} fmt.Println(i.Name()) 编译是可以通过的,这就是通过 addressable values 调用的。 参考资料 uber-go/guide的中文翻译:https://github.com/xxjwxc/uber_go_guide_cn
前言 在 Go 语言中,我们通常用借用一些工具作为可执行程序使用。但是按照官方文档安装过程中,总是会发生 go get 成功了,但是执行命令的时候总是提示命令未找到的错误,例如:Mac: bash: /Users/libin/go/bin/xxx: No such file or directory、Windows: xxx不是内部或外部命令,也不是可运行的程序或批处理文件 在常用的工具中,拿 protoc-gen-go 和 wire 来举例,需要文档的安装命令是: go get github.com/google/wire/cmd/wire 如果你在非 go.mod 执行该程序会提示找不到 go.mod 的错误,例如: localhost:bin libin$ go get github.com/google/wire/cmd/wire go: go.mod file not found in current directory or any parent directory. 'go get' is no longer supported outside a module. To build and install a command, use 'go install' with a version, like 'go install example.com/cmd@latest' For more information, see https://golang.org/doc/go-get-install-deprecation or run 'go help get' or 'go help install'. 如果你在 go.mod 目录执行该命令,当然可以运行成功。但是问题是,虽然成功了,但是你无法在任意目录执行该应用程序,例如: localhost:bin libin$ wire bash: /Users/libin/go/bin/wire: No such file or directory 解决过程 原因分析 在 Go 1.3 之前,由于采用的是 GOPATH 模式,用以上的安装方法当然没问题,因为下载的包,最后会编译为可执行程序后安装到项目的bin目录。 在 Go 1.4 之后,采用了 go mod 模式,如果使用 go get 安装,则无法安装到执行的 bin 目录,所以我们要做的是想办法让程序安装到 $GOPATH/bin 目录,这样你就可以在全局使用该执行程序了。 小贴士: 可用 go env 命令查看更多 go 配置信息,包括 go 的环境变量配置。建议将$GOPATH/bin加入系统环境变量$PATH中。 解决方案 在任意目录处,使用 install 安装程序 go install github.com/google/wire/cmd/wire@latest 上面的命令会在$GOPATH/bin中生成一个可执行程序wire。这样我们就可以在任意目录使用该程序了。 localhost:~ libin$ wire help Usage: wire <flags> <subcommand> <subcommand args> Subcommands: check print any Wire errors found commands list all command names diff output a diff between existing wire_gen.go files and what gen would generate flags describe all known top-level flags gen generate the wire_gen.go file for each package help describe subcommands and their syntax show describe all top-level provider sets Use "wire flags" for a list of top-level flags 补充知识 go install 和 go get 的区别 go install 命令可以接受一个版本后缀了,(例如,go install sigs.k8s.io/kind@v0.9.0),并且它是在模块感知的模式下运行,可忽略当前目录或上层目录的 go.mod 文件。这对于在不影响主模块依赖的情况下,安装二进制很方便 go install 被设计为“用于构建和安装二进制文件”, go get 则被设计为 “用于编辑 go.mod 变更依赖” go get 其他参数介绍 -d 只下载不安装 -f 只有在你包含了 -u 参数的时候才有效,不让 -u 去验证 import 中的每一个都已经获取了,这对于本地 fork 的包特别有用 -fix 在获取源码之后先运行 fix,然后再去做其他的事情 -t 同时也下载需要为运行测试所需要的包 -u 强制使用网络去更新包和它的依赖包 -v 显示执行的命令 原文链接:https://www.yipwinghong.com/2021/12/10/Go_engineering-specification-design
Nginx 概述 Nginx 是开源、高性能、高可靠的 Web 和反向代理服务器,而且支持热部署,几乎可以做到 7 * 24 小时不间断运行,即使运行几个月也不需要重新启动,还能在不间断服务的情况下对软件版本进行热更新。性能是 Nginx 最重要的考量,其占用内存少、并发能力强、能支持高达 5w 个并发连接数,最重要的是, Nginx 是免费的并可以商业化,配置使用也比较简单。 Nginx 特点 高并发、高性能; 模块化架构使得它的扩展性非常好; 异步非阻塞的事件驱动模型这点和 Node.js 相似; 相对于其它服务器来说它可以连续几个月甚至更长而不需要重启服务器使得它具有高可靠性; 热部署、平滑升级; 完全开源,生态繁荣; Nginx 作用 Nginx 的最重要的几个使用场景: 静态资源服务,通过本地文件系统提供服务; 反向代理服务,延伸出包括缓存、负载均衡等; API 服务, OpenResty ; 对于前端来说 Node.js 并不陌生, Nginx 和 Node.js 的很多理念类似, HTTP 服务器、事件驱动、异步非阻塞等,且 Nginx 的大部分功能使用 Node.js 也可以实现,但 Nginx 和 Node.js 并不冲突,都有自己擅长的领域。 Nginx 擅长于底层服务器端资源的处理(静态资源处理转发、反向代理,负载均衡等), Node.js 更擅长上层具体业务逻辑的处理,两者可以完美组合。 用一张图表示: Nginx 安装 本文演示的是 Linux centOS 7.x 的操作系统上安装 Nginx ,至于在其它操作系统上进行安装可以网上自行搜索,都非常简单的。 使用 yum 安装 Nginx : yum install nginx -y 安装完成后,通过 rpm -ql nginx命令查看 Nginx 的安装信息: # Nginx配置文件 /etc/nginx/nginx.conf # nginx 主配置文件 /etc/nginx/nginx.conf.default # 可执行程序文件 /usr/bin/nginx-upgrade /usr/sbin/nginx # nginx库文件 /usr/lib/systemd/system/nginx.service # 用于配置系统守护进程 /usr/lib64/nginx/modules # Nginx模块目录 # 帮助文档 /usr/share/doc/nginx-1.16.1 /usr/share/doc/nginx-1.16.1/CHANGES /usr/share/doc/nginx-1.16.1/README /usr/share/doc/nginx-1.16.1/README.dynamic /usr/share/doc/nginx-1.16.1/UPGRADE-NOTES-1.6-to-1.10 # 静态资源目录 /usr/share/nginx/html/404.html /usr/share/nginx/html/50x.html /usr/share/nginx/html/index.html # 存放Nginx日志文件 /var/log/nginx 主要关注的文件夹有两个: /etc/nginx/conf.d/ 是子配置项存放处, /etc/nginx/nginx.conf 主配置文件会默认把这个文件夹中所有子配置项都引入; /usr/share/nginx/html/ 静态文件都放在这个文件夹,也可以根据你自己的习惯放在其他地方; Nginx 常用命令 systemctl 系统命令: # 开机配置 systemctl enable nginx # 开机自动启动 systemctl disable nginx # 关闭开机自动启动 # 启动Nginx systemctl start nginx # 启动Nginx成功后,可以直接访问主机IP,此时会展示Nginx默认页面 # 停止Nginx systemctl stop nginx # 重启Nginx systemctl restart nginx # 重新加载Nginx systemctl reload nginx # 查看 Nginx 运行状态 systemctl status nginx # 查看Nginx进程 ps -ef | grep nginx # 杀死Nginx进程 kill -9 pid # 根据上面查看到的Nginx进程号,杀死Nginx进程,-9 表示强制结束进程 Nginx 应用程序命令: nginx -s reload # 向主进程发送信号,重新加载配置文件,热重启 nginx -s reopen # 重启 Nginx nginx -s stop # 快速关闭 nginx -s quit # 等待工作进程处理完成后关闭 nginx -T # 查看当前 Nginx 最终的配置 nginx -t # 检查配置是否有问题 Nginx 核心配置 配置文件结构 Nginx 的典型配置示例: # main段配置信息 user nginx; # 运行用户,默认即是nginx,可以不进行设置 worker_processes auto; # Nginx 进程数,一般设置为和 CPU 核数一样 error_log /var/log/nginx/error.log warn; # Nginx 的错误日志存放目录 pid /var/run/nginx.pid; # Nginx 服务启动时的 pid 存放位置 # events段配置信息 events { use epoll; # 使用epoll的I/O模型(如果你不知道Nginx该使用哪种轮询方法,会自动选择一个最适合你操作系统的) worker_connections 1024; # 每个进程允许最大并发数 } # http段配置信息 # 配置使用最频繁的部分,代理、缓存、日志定义等绝大多数功能和第三方模块的配置都在这里设置 http { # 设置日志模式 log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; access_log /var/log/nginx/access.log main; # Nginx访问日志存放位置 sendfile on; # 开启高效传输模式 tcp_nopush on; # 减少网络报文段的数量 tcp_nodelay on; keepalive_timeout 65; # 保持连接的时间,也叫超时时间,单位秒 types_hash_max_size 2048; include /etc/nginx/mime.types; # 文件扩展名与类型映射表 default_type application/octet-stream; # 默认文件类型 include /etc/nginx/conf.d/*.conf; # 加载子配置项 # server段配置信息 server { listen 80; # 配置监听的端口 server_name localhost; # 配置的域名 # location段配置信息 location / { root /usr/share/nginx/html; # 网站根目录 index index.html index.htm; # 默认首页文件 deny 172.168.22.11; # 禁止访问的ip地址,可以为all allow 172.168.33.44;# 允许访问的ip地址,可以为all } error_page 500 502 503 504 /50x.html; # 默认50x对应的访问页面 error_page 400 404 error.html; # 同上 } } main 全局配置,对全局生效; events 配置影响 Nginx 服务器与用户的网络连接; http 配置代理,缓存,日志定义等绝大多数功能和第三方模块的配置; server 配置虚拟主机的相关参数,一个 http 块中可以有多个 server 块; location 用于配置匹配的 uri ; upstream 配置后端服务器具体地址,负载均衡配置不可或缺的部分; 用一张图清晰的展示它的层级结构: 配置文件 main 段核心参数 user 指定运行 Nginx 的 woker 子进程的属主和属组,其中组可以不指定。 user USERNAME [GROUP] user nginx lion; # 用户是nginx;组是lion pid 指定运行 Nginx master 主进程的 pid 文件存放路径。 pid /opt/nginx/logs/nginx.pid # master主进程的的pid存放在nginx.pid的文件 worker_rlimit_nofile_number 指定 worker 子进程可以打开的最大文件句柄数。 worker_rlimit_nofile 20480; # 可以理解成每个worker子进程的最大连接数量。 worker_rlimit_core 指定 worker 子进程异常终止后的 core 文件,用于记录分析问题。 worker_rlimit_core 50M; # 存放大小限制 working_directory /opt/nginx/tmp; # 存放目录 worker_processes_number 指定 Nginx 启动的 worker 子进程数量。 worker_processes 4; # 指定具体子进程数量 worker_processes auto; # 与当前cpu物理核心数一致 worker_cpu_affinity 将每个 worker 子进程与我们的 cpu 物理核心绑定。 worker_cpu_affinity 0001 0010 0100 1000; # 4个物理核心,4个worker子进程 将每个 worker 子进程与特定 CPU 物理核心绑定,优势在于,避免同一个 worker 子进程在不同的 CPU 核心上切换,缓存失效,降低性能。但其并不能真正的避免进程切换。 worker_priority 指定 worker 子进程的 nice 值,以调整运行 Nginx 的优先级,通常设定为负值,以优先调用 Nginx 。 worker_priority -10; # 120-10=110,110就是最终的优先级 Linux 默认进程的优先级值是120,值越小越优先; nice 定范围为 -20 到 +19 。 [备注] 应用的默认优先级值是120加上 nice 值等于它最终的值,这个值越小,优先级越高。 worker_shutdown_timeout 指定 worker 子进程优雅退出时的超时时间。 worker_shutdown_timeout 5s; timer_resolution worker 子进程内部使用的计时器精度,调整时间间隔越大,系统调用越少,有利于性能提升;反之,系统调用越多,性能下降。 timer_resolution 100ms; 在 Linux 系统中,用户需要获取计时器时需要向操作系统内核发送请求,有请求就必然会有开销,因此这个间隔越大开销就越小。 daemon 指定 Nginx 的运行方式,前台还是后台,前台用于调试,后台用于生产。 daemon off; # 默认是on,后台运行模式 配置文件 events 段核心参数 use Nginx 使用何种事件驱动模型。 use method; # 不推荐配置它,让nginx自己选择 method 可选值为:select、poll、kqueue、epoll、/dev/poll、eventport worker_connections worker 子进程能够处理的最大并发连接数。 worker_connections 1024 # 每个子进程的最大连接数为1024 accept_mutex 是否打开负载均衡互斥锁。 accept_mutex on # 默认是off关闭的,这里推荐打开 server_name 指令 指定虚拟主机域名。 server_name name1 name2 name3 # 示例: server_name www.nginx.com; 域名匹配的四种写法: 精确匹配: server_name www.nginx.com ; 左侧通配: server_name *.nginx.com ; 右侧统配: server_name www.nginx.* ; 正则匹配: server_name ~^www.nginx.*$ ; 匹配优先级**:精确匹配 > 左侧通配符匹配 > 右侧通配符匹配 > 正则表达式匹配** server_name 配置实例: 1、配置本地 DNS 解析 vim /etc/hosts ( macOS 系统) # 添加如下内容,其中 121.42.11.34 是阿里云服务器IP地址 121.42.11.34 www.nginx-test.com 121.42.11.34 mail.nginx-test.com 121.42.11.34 www.nginx-test.org 121.42.11.34 doc.nginx-test.com 121.42.11.34 www.nginx-test.cn 121.42.11.34 fe.nginx-test.club [注意] 这里使用的是虚拟域名进行测试,因此需要配置本地 DNS 解析,如果使用阿里云上购买的域名,则需要在阿里云上设置好域名解析。 2、配置阿里云 Nginx ,vim /etc/nginx/nginx.conf # 这里只列举了http端中的sever端配置 # 左匹配 server { listen 80; server_name *.nginx-test.com; root /usr/share/nginx/html/nginx-test/left-match/; location / { index index.html; } } # 正则匹配 server { listen 80; server_name ~^.*\.nginx-test\..*$; root /usr/share/nginx/html/nginx-test/reg-match/; location / { index index.html; } } # 右匹配 server { listen 80; server_name www.nginx-test.*; root /usr/share/nginx/html/nginx-test/right-match/; location / { index index.html; } } # 完全匹配 server { listen 80; server_name www.nginx-test.com; root /usr/share/nginx/html/nginx-test/all-match/; location / { index index.html; } } 3、访问分析 当访问 www.nginx-test.com 时,都可以被匹配上,因此选择优先级最高的“完全匹配”; 当访问 mail.nginx-test.com 时,会进行“左匹配”; 当访问 www.nginx-test.org 时,会进行“右匹配”; 当访问 doc.nginx-test.com 时,会进行“左匹配”; 当访问 www.nginx-test.cn 时,会进行“右匹配”; 当访问 fe.nginx-test.club 时,会进行“正则匹配”; root 指定静态资源目录位置,它可以写在 http 、 server 、 location 等配置中。 root path 例如: location /image { root /opt/nginx/static; } 当用户访问 www.test.com/image/1.png 时,实际在服务器找的路径是 /opt/nginx/static/image/1.png [注意] root 会将定义路径与 URI 叠加, alias 则只取定义路径。 alias 它也是指定静态资源目录位置,它只能写在 location 中。 location /image { alias /opt/nginx/static/image/; } 当用户访问 www.test.com/image/1.png 时,实际在服务器找的路径是 /opt/nginx/static/image/1.png [注意] 使用 alias 末尾一定要添加 / ,并且它只能位于 location 中。 location 配置路径。 location [ = | ~ | ~* | ^~ ] uri { ... } 匹配规则: = 精确匹配; ~ 正则匹配,区分大小写; ~* 正则匹配,不区分大小写; ^~ 匹配到即停止搜索; 匹配优先级: = > ^~ > ~ > ~* > 不带任何字符。 实例: server { listen 80; server_name www.nginx-test.com; # 只有当访问 www.nginx-test.com/match_all/ 时才会匹配到/usr/share/nginx/html/match_all/index.html location = /match_all/ { root /usr/share/nginx/html index index.html } # 当访问 www.nginx-test.com/1.jpg 等路径时会去 /usr/share/nginx/images/1.jpg 找对应的资源 location ~ \.(jpeg|jpg|png|svg)$ { root /usr/share/nginx/images; } # 当访问 www.nginx-test.com/bbs/ 时会匹配上 /usr/share/nginx/html/bbs/index.html location ^~ /bbs/ { root /usr/share/nginx/html; index index.html index.htm; } } location 中的反斜线 location /test { ... } location /test/ { ... } 不带 / 当访问 www.nginx-test.com/test 时, Nginx 先找是否有 test 目录,如果有则找 test 目录下的 index.html ;如果没有 test 目录, nginx 则会找是否有 test 文件。 带 / 当访问 www.nginx-test.com/test 时, Nginx 先找是否有 test 目录,如果有则找 test 目录下的 index.html ,如果没有它也不会去找是否存在 test 文件。 return 停止处理请求,直接返回响应码或重定向到其他 URL ;执行 return 指令后, location 中后续指令将不会被执行。 return code [text]; return code URL; return URL; 例如: location / { return 404; # 直接返回状态码 } location / { return 404 "pages not found"; # 返回状态码 + 一段文本 } location / { return 302 /bbs ; # 返回状态码 + 重定向地址 } location / { return https://www.baidu.com ; # 返回重定向地址 } rewrite 根据指定正则表达式匹配规则,重写 URL 。 语法:rewrite 正则表达式 要替换的内容 [flag]; 上下文:server、location、if 示例:rewirte /images/(.*\.jpg)$ /pic/$1; # $1是前面括号(.*\.jpg)的反向引用 flag 可选值的含义: last 重写后的 URL 发起新请求,再次进入 server 段,重试 location 的中的匹配; break 直接使用重写后的 URL ,不再匹配其它 location 中语句; redirect 返回302临时重定向; permanent 返回301永久重定向; server{ listen 80; server_name fe.lion.club; # 要在本地hosts文件进行配置 root html; location /search { rewrite ^/(.*) https://www.baidu.com redirect; } location /images { rewrite /images/(.*) /pics/$1; } location /pics { rewrite /pics/(.*) /photos/$1; } location /photos { } } 按照这个配置我们来分析: 当访问 fe.lion.club/search 时,会自动帮我们重定向到 https://www.baidu.com。 当访问 fe.lion.club/images/1.jpg 时,第一步重写 URL 为 fe.lion.club/pics/1.jpg ,找到 pics 的 location ,继续重写 URL 为 fe.lion.club/photos/1.jpg ,找到 /photos 的 location 后,去 html/photos 目录下寻找 1.jpg 静态资源。 if 指令 语法:if (condition) {...} 上下文:server、location 示例: if($http_user_agent ~ Chrome){ rewrite /(.*)/browser/$1 break; } condition 判断条件: $variable 仅为变量时,值为空或以0开头字符串都会被当做 false 处理; = 或 != 相等或不等; ~ 正则匹配; ! ~ 非正则匹配; ~* 正则匹配,不区分大小写; -f 或 ! -f 检测文件存在或不存在; -d 或 ! -d 检测目录存在或不存在; -e 或 ! -e 检测文件、目录、符号链接等存在或不存在; -x 或 ! -x 检测文件可以执行或不可执行; 实例: server { listen 8080; server_name localhost; root html; location / { if ( $uri = "/images/" ){ rewrite (.*) /pics/ break; } } } 当访问 localhost:8080/images/ 时,会进入 if 判断里面执行 rewrite 命令。 autoindex 用户请求以 / 结尾时,列出目录结构,可以用于快速搭建静态资源下载网站。 autoindex.conf 配置信息: server { listen 80; server_name fe.lion-test.club; location /download/ { root /opt/source; autoindex on; # 打开 autoindex,,可选参数有 on | off autoindex_exact_size on; # 修改为off,以KB、MB、GB显示文件大小,默认为on,以bytes显示出⽂件的确切⼤⼩ autoindex_format html; # 以html的方式进行格式化,可选参数有 html | json | xml autoindex_localtime off; # 显示的⽂件时间为⽂件的服务器时间。默认为off,显示的⽂件时间为GMT时间 } } 当访问 fe.lion.com/download/ 时,会把服务器 /opt/source/download/ 路径下的文件展示出来,如下图所示: 变量 Nginx 提供给使用者的变量非常多,但是终究是一个完整的请求过程所产生数据, Nginx 将这些数据以变量的形式提供给使用者。 下面列举些项目中常用的变量: 变量名 含义 remote_addr 客户端 IP 地址 remote_port 客户端端口 server_addr 服务端 IP 地址 server_port 服务端端口 server_protocol 服务端协议 binary_remote_addr 二进制格式的客户端 IP 地址 connection TCP 连接的序号,递增 connection_request TCP 连接当前的请求数量uri 请求的URL,不包含参数 request_uri 请求的URL,包含参数scheme 协议名, http 或 https request_method 请求方法 request_length 全部请求的长度,包含请求行、请求头、请求体args 全部参数字符串arg_参数名 获取特定参数值 is_args URL 中是否有参数,有的话返回 ? ,否则返回空 query_string 与 args 相同 host 请求信息中的 Host ,如果请求中没有 Host 行,则在请求头中找,最后使用 nginx 中设置的 server_name 。 http_user_agent 用户浏览器 http_referer 从哪些链接过来的请求 http_via 每经过一层代理服务器,都会添加相应的信息 http_cookie 获取用户 cookie request_time 处理请求已消耗的时间 https 是否开启了 https ,是则返回 on ,否则返回空 request_filename 磁盘文件系统待访问文件的完整路径 document_root 由 URI 和 root/alias 规则生成的文件夹路径 limit_rate 返回响应时的速度上限值 实例演示 var.conf : server{ listen 8081; server_name var.lion-test.club; root /usr/share/nginx/html; location / { return 200 " remote_addr: $remote_addr remote_port: $remote_port server_addr: $server_addr server_port: $server_port server_protocol: $server_protocol binary_remote_addr: $binary_remote_addr connection: $connection uri: $uri request_uri: $request_uri scheme: $scheme request_method: $request_method request_length: $request_length args: $args arg_pid: $arg_pid is_args: $is_args query_string: $query_string host: $host http_user_agent: $http_user_agent http_referer: $http_referer http_via: $http_via request_time: $request_time https: $https request_filename: $request_filename document_root: $document_root "; } } 当我们访问 http://var.lion-test.club:8081/test?pid=121414&cid=sadasd 时,由于 Nginx 中写了 return 方法,因此 chrome 浏览器会默认为我们下载一个文件,下面展示的就是下载的文件内容: remote_addr: 27.16.220.84 remote_port: 56838 server_addr: 172.17.0.2 server_port: 8081 server_protocol: HTTP/1.1 binary_remote_addr: 茉 connection: 126 uri: /test/ request_uri: /test/?pid=121414&cid=sadasd scheme: http request_method: GET request_length: 518 args: pid=121414&cid=sadasd arg_pid: 121414 is_args: ? query_string: pid=121414&cid=sadasd host: var.lion-test.club http_user_agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.182 Safari/537.36 http_referer: http_via: request_time: 0.000 https: request_filename: /usr/share/nginx/html/test/ document_root: /usr/share/nginx/html Nginx 的配置还有非常多,以上只是罗列了一些常用的配置,在实际项目中还是要学会查阅文档。 Nginx 应用核心概念 代理是在服务器和客户端之间假设的一层服务器,代理将接收客户端的请求并将它转发给服务器,然后将服务端的响应转发给客户端。 不管是正向代理还是反向代理,实现的都是上面的功能。 正向代理 正向代理,意思是一个位于客户端和原始服务器(origin server)之间的服务器,为了从原始服务器取得内容,客户端向代理发送一个请求并指定目标(原始服务器),然后代理向原始服务器转交请求并将获得的内容返回给客户端。 正向代理是为我们服务的,即为客户端服务的,客户端可以根据正向代理访问到它本身无法访问到的服务器资源。 正向代理对我们是透明的,对服务端是非透明的,即服务端并不知道自己收到的是来自代理的访问还是来自真实客户端的访问。 反向代理 反向代理*(Reverse Proxy)方式是指以代理服务器来接受internet上的连接请求,然后将请求转发给内部网络上的服务器,并将从服务器上得到的结果返回给internet上请求连接的客户端,此时代理服务器对外就表现为一个反向代理服务器。 反向代理是为服务端服务的,反向代理可以帮助服务器接收来自客户端的请求,帮助服务器做请求转发,负载均衡等。 反向代理对服务端是透明的,对我们是非透明的,即我们并不知道自己访问的是代理服务器,而服务器知道反向代理在为他服务。 反向代理的优势: 隐藏真实服务器; 负载均衡便于横向扩充后端动态服务; 动静分离,提升系统健壮性; 那么“动静分离”是什么?负载均衡又是什么? 动静分离 动静分离是指在 web 服务器架构中,将静态页面与动态页面或者静态内容接口和动态内容接口分开不同系统访问的架构设计方法,进而提示整个服务的访问性和可维护性。 一般来说,都需要将动态资源和静态资源分开,由于 Nginx 的高并发和静态资源缓存等特性,经常将静态资源部署在 Nginx 上。如果请求的是静态资源,直接到静态资源目录获取资源,如果是动态资源的请求,则利用反向代理的原理,把请求转发给对应后台应用去处理,从而实现动静分离。 使用前后端分离后,可以很大程度提升静态资源的访问速度,即使动态服务不可用,静态资源的访问也不会受到影响。 负载均衡 一般情况下,客户端发送多个请求到服务器,服务器处理请求,其中一部分可能要操作一些资源比如数据库、静态资源等,服务器处理完毕后,再将结果返回给客户端。 这种模式对于早期的系统来说,功能要求不复杂,且并发请求相对较少的情况下还能胜任,成本也低。随着信息数量不断增长,访问量和数据量飞速增长,以及系统业务复杂度持续增加,这种做法已无法满足要求,并发量特别大时,服务器容易崩。 很明显这是由于服务器性能的瓶颈造成的问题,除了堆机器之外,最重要的做法就是负载均衡。 请求爆发式增长的情况下,单个机器性能再强劲也无法满足要求了,这个时候集群的概念产生了,单个服务器解决不了的问题,可以使用多个服务器,然后将请求分发到各个服务器上,将负载分发到不同的服务器,这就是负载均衡,核心是「分摊压力」。 Nginx 实现负载均衡,一般来说指的是将请求转发给服务器集群。 举个具体的例子,晚高峰乘坐地铁的时候,入站口经常会有地铁工作人员大喇叭“请走 B 口, B 口人少车空…”,这个工作人员的作用就是负载均衡。 Nginx 实现负载均衡的策略: 轮询策略:默认情况下采用的策略,将所有客户端请求轮询分配给服务端。这种策略是可以正常工作的,但是如果其中某一台服务器压力太大,出现延迟,会影响所有分配在这台服务器下的用户。 最小连接数策略:将请求优先分配给压力较小的服务器,它可以平衡每个队列的长度,并避免向压力大的服务器添加更多的请求。 最快响应时间策略:优先分配给响应时间最短的服务器。 客户端 ip 绑定策略:来自同一个 ip 的请求永远只分配一台服务器,有效解决了动态网页存在的 session 共享问题。 Nginx 实战配置 在配置反向代理和负载均衡等等功能之前,有两个核心模块是我们必须要掌握的,这两个模块应该说是 Nginx 应用配置中的核心,它们分别是: upstream 、proxy_pass 。 upstream 用于定义上游服务器(指的就是后台提供的应用服务器)的相关信息。 语法:upstream name { ... } 上下文:http 示例: upstream back_end_server{ server 192.168.100.33:8081 } 在 upstream 内可使用的指令: server 定义上游服务器地址; zone 定义共享内存,用于跨 worker 子进程; keepalive 对上游服务启用长连接; keepalive_requests 一个长连接最多请求 HTTP 的个数; keepalive_timeout 空闲情形下,一个长连接的超时时长; hash 哈希负载均衡算法; ip_hash 依据 IP 进行哈希计算的负载均衡算法; least_conn 最少连接数负载均衡算法; least_time 最短响应时间负载均衡算法; random 随机负载均衡算法; server 定义上游服务器地址。 语法:server address [parameters] 上下文:upstream parameters 可选值: weight=number 权重值,默认为1; max_conns=number 上游服务器的最大并发连接数; fail_timeout=time 服务器不可用的判定时间; max_fails=numer 服务器不可用的检查次数; backup 备份服务器,仅当其他服务器都不可用时才会启用; down 标记服务器长期不可用,离线维护; keepalive 限制每个 worker 子进程与上游服务器空闲长连接的最大数量。 keepalive connections; 上下文:upstream 示例:keepalive 16; keepalive_requests 单个长连接可以处理的最多 HTTP 请求个数。 语法:keepalive_requests number; 默认值:keepalive_requests 100; 上下文:upstream keepalive_timeout 空闲长连接的最长保持时间。 语法:keepalive_timeout time; 默认值:keepalive_timeout 60s; 上下文:upstream 配置实例 upstream back_end{ server 127.0.0.1:8081 weight=3 max_conns=1000 fail_timeout=10s max_fails=2; keepalive 32; keepalive_requests 50; keepalive_timeout 30s; } proxy_pass 用于配置代理服务器。 语法:proxy_pass URL; 上下文:location、if、limit_except 示例: proxy_pass http://127.0.0.1:8081 proxy_pass http://127.0.0.1:8081/proxy URL 参数原则 URL 必须以 http 或 https 开头; URL 中可以携带变量; URL 中是否带 URI ,会直接影响发往上游请求的 URL ; 接下来让我们来看看两种常见的 URL 用法: proxy_pass http://192.168.100.33:8081 proxy_pass http://192.168.100.33:8081/ 这两种用法的区别就是带 / 和不带 / ,在配置代理时它们的区别可大了: 不带 / 意味着 Nginx 不会修改用户 URL ,而是直接透传给上游的应用服务器; 带 / 意味着 Nginx 会修改用户 URL ,修改方法是将 location 后的 URL 从用户 URL 中删除; 不带 / 的用法: location /bbs/{ proxy_pass http://127.0.0.1:8080; } 分析: 用户请求 URL : /bbs/abc/test.html 请求到达 Nginx 的 URL : /bbs/abc/test.html 请求到达上游应用服务器的 URL : /bbs/abc/test.html 带 / 的用法: location /bbs/{ proxy_pass http://127.0.0.1:8080/; } 分析: 用户请求 URL : /bbs/abc/test.html 请求到达 Nginx 的 URL : /bbs/abc/test.html 请求到达上游应用服务器的 URL : /abc/test.html 并没有拼接上 /bbs ,这点和 root 与 alias 之间的区别是保持一致的。 配置反向代理 这里为了演示更加接近实际,作者准备了两台云服务器,它们的公网 IP 分别是: 121.42.11.34 与 121.5.180.193 。 我们把 121.42.11.34 服务器作为上游服务器,做如下配置: # /etc/nginx/conf.d/proxy.conf server{ listen 8080; server_name localhost; location /proxy/ { root /usr/share/nginx/html/proxy; index index.html; } } # /usr/share/nginx/html/proxy/index.html <h1> 121.42.11.34 proxy html </h1> 配置完成后重启 Nginx 服务器 nginx -s reload 。 把 121.5.180.193 服务器作为代理服务器,做如下配置: # /etc/nginx/conf.d/proxy.conf upstream back_end { server 121.42.11.34:8080 weight=2 max_conns=1000 fail_timeout=10s max_fails=3; keepalive 32; keepalive_requests 80; keepalive_timeout 20s; } server { listen 80; server_name proxy.lion.club; location /proxy { proxy_pass http://back_end/proxy; } } 本地机器要访问 proxy.lion.club 域名,因此需要配置本地 hosts ,通过命令:vim /etc/hosts 进入配置文件,添加如下内容: 121.5.180.193 proxy.lion.club 分析: 当访问 proxy.lion.club/proxy 时通过 upstream 的配置找到 121.42.11.34:8080 ; 因此访问地址变为 http://121.42.11.34:8080/proxy ; 连接到 121.42.11.34 服务器,找到 8080 端口提供的 server ; 通过 server 找到 /usr/share/nginx/html/proxy/index.html 资源,最终展示出来。 配置负载均衡 配置负载均衡主要是要使用 upstream 指令。 我们把 121.42.11.34 服务器作为上游服务器,做如下配置( /etc/nginx/conf.d/balance.conf ): server{ listen 8020; location / { return 200 'return 8020 \n'; } } server{ listen 8030; location / { return 200 'return 8030 \n'; } } server{ listen 8040; location / { return 200 'return 8040 \n'; } } 配置完成后: nginx -t 检测配置是否正确; nginx -s reload 重启 Nginx 服务器; 执行 ss -nlt 命令查看端口是否被占用,从而判断 Nginx 服务是否正确启动。 把 121.5.180.193 服务器作为代理服务器,做如下配置( /etc/nginx/conf.d/balance.conf ): upstream demo_server { server 121.42.11.34:8020; server 121.42.11.34:8030; server 121.42.11.34:8040; } server { listen 80; server_name balance.lion.club; location /balance/ { proxy_pass http://demo_server; } } 配置完成后重启 Nginx 服务器。并且在需要访问的客户端配置好 ip 和域名的映射关系。 # /etc/hosts 121.5.180.193 balance.lion.club 在客户端机器执行 curl http://balance.lion.club/balance/ 命令: 不难看出,负载均衡的配置已经生效了,每次给我们分发的上游服务器都不一样。就是通过简单的轮询策略进行上游服务器分发。 接下来,我们再来了解下 Nginx 的其它分发策略。 hash 算法 通过制定关键字作为 hash key ,基于 hash 算法映射到特定的上游服务器中。关键字可以包含有变量、字符串。 upstream demo_server { hash $request_uri; server 121.42.11.34:8020; server 121.42.11.34:8030; server 121.42.11.34:8040; } server { listen 80; server_name balance.lion.club; location /balance/ { proxy_pass http://demo_server; } } hash $request_uri 表示使用 request_uri 变量作为 hash 的 key 值,只要访问的 URI 保持不变,就会一直分发给同一台服务器。 ip_hash 根据客户端的请求 ip 进行判断,只要 ip 地址不变就永远分配到同一台主机。它可以有效解决后台服务器 session 保持的问题。 upstream demo_server { ip_hash; server 121.42.11.34:8020; server 121.42.11.34:8030; server 121.42.11.34:8040; } server { listen 80; server_name balance.lion.club; location /balance/ { proxy_pass http://demo_server; } } 最少连接数算法 各个 worker 子进程通过读取共享内存的数据,来获取后端服务器的信息。来挑选一台当前已建立连接数最少的服务器进行分配请求。 语法:least_conn; 上下文:upstream; 示例: upstream demo_server { zone test 10M; # zone可以设置共享内存空间的名字和大小 least_conn; server 121.42.11.34:8020; server 121.42.11.34:8030; server 121.42.11.34:8040; } server { listen 80; server_name balance.lion.club; location /balance/ { proxy_pass http://demo_server; } } 最后你会发现,负载均衡的配置其实一点都不复杂。 配置缓存 缓存可以非常有效的提升性能,因此不论是客户端(浏览器),还是代理服务器( Nginx ),乃至上游服务器都多少会涉及到缓存。可见缓存在每个环节都是非常重要的。下面让我们来学习 Nginx 中如何设置缓存策略。 proxy_cache 存储一些之前被访问过、而且可能将要被再次访问的资源,使用户可以直接从代理服务器获得,从而减少上游服务器的压力,加快整个访问速度。 语法:proxy_cache zone | off ; # zone 是共享内存的名称 默认值:proxy_cache off; 上下文:http、server、location proxy_cache_path 设置缓存文件的存放路径。 语法:proxy_cache_path path [level=levels] ...可选参数省略,下面会详细列举 默认值:proxy_cache_path off 上下文:http 参数含义: path 缓存文件的存放路径; level path 的目录层级; keys_zone 设置共享内存; inactive 在指定时间内没有被访问,缓存会被清理,默认10分钟; proxy_cache_key 设置缓存文件的 key 。 语法:proxy_cache_key 默认值:proxy_cache_key $scheme$proxy_host$request_uri; 上下文:http、server、location proxy_cache_valid 配置什么状态码可以被缓存,以及缓存时长。 语法:proxy_cache_valid [code...] time; 上下文:http、server、location 配置示例:proxy_cache_valid 200 304 2m;; # 说明对于状态为200和304的缓存文件的缓存时间是2分钟 proxy_no_cache 定义相应保存到缓存的条件,如果字符串参数的至少一个值不为空且不等于“ 0”,则将不保存该响应到缓存。 语法:proxy_no_cache string; 上下文:http、server、location 示例:proxy_no_cache $http_pragma $http_authorization; proxy_cache_bypass 定义条件,在该条件下将不会从缓存中获取响应。 语法:proxy_cache_bypass string; 上下文:http、server、location 示例:proxy_cache_bypass $http_pragma $http_authorization; upstream_cache_status 变量 它存储了缓存是否命中的信息,会设置在响应头信息中,在调试中非常有用。 MISS: 未命中缓存 HIT: 命中缓存 EXPIRED: 缓存过期 STALE: 命中了陈旧缓存 REVALIDDATED: Nginx验证陈旧缓存依然有效 UPDATING: 内容陈旧,但正在更新 BYPASS: X响应从原始服务器获取 配置实例 我们把 121.42.11.34 服务器作为上游服务器,做如下配置( /etc/nginx/conf.d/cache.conf ): server { listen 1010; root /usr/share/nginx/html/1010; location / { index index.html; } } server { listen 1020; root /usr/share/nginx/html/1020; location / { index index.html; } } 把 121.5.180.193 服务器作为代理服务器,做如下配置( /etc/nginx/conf.d/cache.conf ): proxy_cache_path /etc/nginx/cache_temp levels=2:2 keys_zone=cache_zone:30m max_size=2g inactive=60m use_temp_path=off; upstream cache_server{ server 121.42.11.34:1010; server 121.42.11.34:1020; } server { listen 80; server_name cache.lion.club; location / { proxy_cache cache_zone; # 设置缓存内存,上面配置中已经定义好的 proxy_cache_valid 200 5m; # 缓存状态为200的请求,缓存时长为5分钟 proxy_cache_key $request_uri; # 缓存文件的key为请求的URI add_header Nginx-Cache-Status $upstream_cache_status # 把缓存状态设置为头部信息,响应给客户端 proxy_pass http://cache_server; # 代理转发 } } 缓存就是这样配置,我们可以在 /etc/nginx/cache_temp 路径下找到相应的缓存文件。 对于一些实时性要求非常高的页面或数据来说,就不应该去设置缓存,下面来看看如何配置不缓存的内容。 server { listen 80; server_name cache.lion.club; # URI 中后缀为 .txt 或 .text 的设置变量值为 "no cache" if ($request_uri ~ \.(txt|text)$) { set $cache_name "no cache" } location / { proxy_no_cache $cache_name; # 判断该变量是否有值,如果有值则不进行缓存,如果没有值则进行缓存 proxy_cache cache_zone; # 设置缓存内存 proxy_cache_valid 200 5m; # 缓存状态为200的请求,缓存时长为5分钟 proxy_cache_key $request_uri; # 缓存文件的key为请求的URI add_header Nginx-Cache-Status $upstream_cache_status # 把缓存状态设置为头部信息,响应给客户端 proxy_pass http://cache_server; # 代理转发 } } HTTPS 在学习如何配置 HTTPS 之前,我们先来简单回顾下 HTTPS 的工作流程是怎么样的?它是如何进行加密保证安全的? HTTPS 工作流程 客户端(浏览器)访问 https://www.baidu.com 百度网站; 百度服务器返回 HTTPS 使用的 CA 证书; 浏览器验证 CA 证书是否为合法证书; 验证通过,证书合法,生成一串随机数并使用公钥(证书中提供的)进行加密; 发送公钥加密后的随机数给百度服务器; 百度服务器拿到密文,通过私钥进行解密,获取到随机数(公钥加密,私钥解密,反之也可以); 百度服务器把要发送给浏览器的内容,使用随机数进行加密后传输给浏览器; 此时浏览器可以使用随机数进行解密,获取到服务器的真实传输内容; 这就是 HTTPS 的基本运作原理,使用对称加密和非对称机密配合使用,保证传输内容的安全性。 关于HTTPS更多知识,可以查看作者的另外一篇文章《学习 HTTP 协议》。 配置证书 下载证书的压缩文件,里面有个 Nginx 文件夹,把 xxx.crt 和 xxx.key 文件拷贝到服务器目录,再进行如下配置: server { listen 443 ssl http2 default_server; # SSL 访问端口号为 443 server_name lion.club; # 填写绑定证书的域名(我这里是随便写的) ssl_certificate /etc/nginx/https/lion.club_bundle.crt; # 证书地址 ssl_certificate_key /etc/nginx/https/lion.club.key; # 私钥地址 ssl_session_timeout 10m; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # 支持ssl协议版本,默认为后三个,主流版本是[TLSv1.2] location / { root /usr/share/nginx/html; index index.html index.htm; } } 如此配置后就能正常访问 HTTPS 版的网站了。 配置跨域 CORS 先简单回顾下跨域究竟是怎么回事。 跨域的定义 同源策略限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互。这是一个用于隔离潜在恶意文件的重要安全机制。通常不允许不同源间的读操作。 同源的定义 如果两个页面的协议,端口(如果有指定)和域名都相同,则两个页面具有相同的源。 下面给出了与 URL http://store.company.com/dir/page.html 的源进行对比的示例: http://store.company.com/dir2/other.html 同源 https://store.company.com/secure.html 不同源,协议不同 http://store.company.com:81/dir/etc.html 不同源,端口不同 http://news.company.com/dir/other.html 不同源,主机不同 不同源会有如下限制: Web 数据层面,同源策略限制了不同源的站点读取当前站点的 Cookie 、 IndexDB 、 LocalStorage 等数据。 DOM 层面,同源策略限制了来自不同源的 JavaScript 脚本对当前 DOM 对象读和写的操作。 网络层面,同源策略限制了通过 XMLHttpRequest 等方式将站点的数据发送给不同源的站点。 Nginx 解决跨域的原理 例如: 前端 server 的域名为: fe.server.com 后端服务的域名为: dev.server.com 现在我在 fe.server.com 对 dev.server.com 发起请求一定会出现跨域。 现在我们只需要启动一个 Nginx 服务器,将 server_name 设置为 fe.server.com 然后设置相应的 location 以拦截前端需要跨域的请求,最后将请求代理回 dev.server.com 。如下面的配置: server { listen 80; server_name fe.server.com; location / { proxy_pass dev.server.com; } } 这样可以完美绕过浏览器的同源策略: fe.server.com 访问 Nginx 的 fe.server.com 属于同源访问,而 Nginx 对服务端转发的请求不会触发浏览器的同源策略。 配置开启 gzip 压缩 GZIP 是规定的三种标准 HTTP 压缩格式之一。目前绝大多数的网站都在使用 GZIP 传输 HTML 、CSS 、 JavaScript 等资源文件。 对于文本文件, GZiP 的效果非常明显,开启后传输所需流量大约会降至 1/4~1/3 。 并不是每个浏览器都支持 gzip 的,如何知道客户端是否支持 gzip 呢,请求头中的 Accept-Encoding 来标识对压缩的支持。 启用 gzip 同时需要客户端和服务端的支持,如果客户端支持 gzip 的解析,那么只要服务端能够返回 gzip 的文件就可以启用 gzip 了,我们可以通过 Nginx 的配置来让服务端支持 gzip 。下面的 respone 中 content-encoding:gzip ,指服务端开启了 gzip 的压缩方式。 在 /etc/nginx/conf.d/ 文件夹中新建配置文件 gzip.conf : # # 默认off,是否开启gzip gzip on; # 要采用 gzip 压缩的 MIME 文件类型,其中 text/html 被系统强制启用; gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript; # ---- 以上两个参数开启就可以支持Gzip压缩了 ---- # # 默认 off,该模块启用后,Nginx 首先检查是否存在请求静态文件的 gz 结尾的文件,如果有则直接返回该 .gz 文件内容; gzip_static on; # 默认 off,nginx做为反向代理时启用,用于设置启用或禁用从代理服务器上收到相应内容 gzip 压缩; gzip_proxied any; # 用于在响应消息头中添加 Vary:Accept-Encoding,使代理服务器根据请求头中的 Accept-Encoding 识别是否启用 gzip 压缩; gzip_vary on; # gzip 压缩比,压缩级别是 1-9,1 压缩级别最低,9 最高,级别越高压缩率越大,压缩时间越长,建议 4-6; gzip_comp_level 6; # 获取多少内存用于缓存压缩结果,16 8k 表示以 8k*16 为单位获得; gzip_buffers 16 8k; # 允许压缩的页面最小字节数,页面字节数从header头中的 Content-Length 中进行获取。默认值是 0,不管页面多大都压缩。建议设置成大于 1k 的字节数,小于 1k 可能会越压越大; # gzip_min_length 1k; # 默认 1.1,启用 gzip 所需的 HTTP 最低版本; gzip_http_version 1.1; 其实也可以通过前端构建工具例如 webpack 、rollup 等在打生产包时就做好 Gzip 压缩,然后放到 Nginx 服务器中,这样可以减少服务器的开销,加快访问速度。 关于 Nginx 的实际应用就学习到这里,相信通过掌握了 Nginx 核心配置以及实战配置,之后再遇到什么需求,我们也能轻松应对。接下来,让我们再深入一点学习下 Nginx 的架构。 Nginx 架构 进程结构 多进程结构 Nginx 的进程模型图: 多进程中的 Nginx 进程架构如下图所示,会有一个父进程( Master Process ),它会有很多子进程( Child Processes )。 Master Process 用来管理子进程的,其本身并不真正处理用户请求。 某个子进程 down 掉的话,它会向 Master 进程发送一条消息,表明自己不可用了,此时 Master 进程会去新起一个子进程。 某个配置文件被修改了 Master 进程会去通知 work 进程获取新的配置信息,这也就是我们所说的热部署。 子进程间是通过共享内存的方式进行通信的。 配置文件重载原理 reload 重载配置文件的流程: 向 master 进程发送 HUP 信号( reload 命令); master 进程检查配置语法是否正确; master 进程打开监听端口; master 进程使用新的配置文件启动新的 worker 子进程; master 进程向老的 worker 子进程发送 QUIT 信号; 老的 worker 进程关闭监听句柄,处理完当前连接后关闭进程; 整个过程 Nginx 始终处于平稳运行中,实现了平滑升级,用户无感知; Nginx 模块化管理机制 Nginx 的内部结构是由核心部分和一系列的功能模块所组成。这样划分是为了使得每个模块的功能相对简单,便于开发,同时也便于对系统进行功能扩展。Nginx 的模块是互相独立的,低耦合高内聚。
在 Jetbrains 产品无限重置方法 我们讲了使用 IDE Eval Reset 插件激活 Jetbrains 产品的方法,但是由于该产品的作者在 gitee 宣布该插件停止维护,所以笔者在继续使用该插件的时候,发现存在不稳定的情况,例如编辑器已经 Reset 但是还继续提示我激活。所以为了继续可以使用 Jetbrains 的产品,找到了另一种可以激活该产品的方法。 激活步骤 插件激活 下载 ja-netfilter 插件,将改插件复制到编辑器的差距目录,我这里选择放置于我电脑的 PhpStorm 的插件目录 /Applications/PhpStorm.app/Contents/plugins/ja-netfilter 此时目录结构如下: libin@bogon ja-netfilter % pwd /Applications/PhpStorm.app/Contents/plugins/ja-netfilter libin@bogon ja-netfilter % ls README.pdf janf_config.txt sha1sum.txt ja-netfilter.jar plugins 打开软件,进入 help -> edit VM options加入一些代码 -javaagent:/Applications/PhpStorm.app/Contents/plugins/ja-netfilter/ja-netfilter.jar 注意:不可照抄,根据你放置破解插件的实际目录填写。 完成后,重启软件,激活完成! 试用期已过 试用期已过的话,无法进入软件的 help 页签,这里提供一下几种临时激活的方法。请使用临时激活后,然后用插件激活,即可永久使用。 温馨提示:激活码激活过程中,请断开网络连接! 激活码 2022激活jetBrains通用版:https://mano100.cn/thread-1942-1-1.html 网盘激活码(已废弃) https://pan.baidu.com/s/1LBh66mqG19DkAlU9gP1QCg?pwd=o9id 账号激活(已废弃) https://pan.baidu.com/s/1Gw_kYzP6HPeJBiBVCBZEnw?pwd=2233 https://pan.baidu.com/s/1wUdDvnQ9tthvXMUAB_k7WQ?pwd=Hh23 建议 可以在你的host文件里加上以下内容 127.0.0.1 account.jetbrains.com 127.0.0.1 www.jetbrains.com 参考资料 ja-netfilter插件下载:https://gitee.com/code_soft/ja-netfilter 爱激活:https://www.ajihuo.com/ 视频演示激活jetBrains方法:https://www.ixigua.com/7054789540506141221?wid_try=1 常见问题 如果这个方法激活不了,你也可以试试这个链接 https://33tool.com/idea
引言 在 Go原生方法实现RPC 文章中,我们通过原生的方法实现了 RPC 调用。但是大多是基于 protobuf 进行 RPC 的实现。 gRPC是Google公司基于Protobuf开发的跨语言的开源RPC框架。gRPC基于HTTP/2协议设计,可以基于一个HTTP/2链接提供多个服务,对于移动设备更加友好。本节将讲述gRPC的简单用法。 gRPC技术栈: 最底层为TCP或Unix Socket协议,在此之上是HTTP/2协议的实现,然后在HTTP/2协议之上又构建了针对Go语言的gRPC核心库。应用程序通过gRPC插件生产的Stub代码和gRPC核心库通信,也可以直接和gRPC核心库通信。 参考文献 protobuf入门学习:https://github.com/mailjobblog/dev_go/tree/master/220115_protobuf Go原生RPC+protobuf代码下载:https://github.com/mailjobblog/dev_go/tree/master/220113_rpc/3.rpc_protobuf gRPC代码下载:https://github.com/mailjobblog/dev_go/tree/master/220113_rpc/4.grpc RPC实现 Go原生rpc+proto实现 代码实现 hello.proto syntax = "proto3"; package pb; option go_package="./pb;pb"; // 请求结构体 message HelloRequest { string res = 1; } // 返回结构体 message HelloResponse { int64 reply = 1; } server.go func main() { rpc.Register(new(HelloService)) listener, err := net.Listen("tcp", ":8888") if err != nil { log.Fatal("ListenTCP error:", err) } for { conn, err := listener.Accept() if err != nil { log.Fatal("Accept error:", err) } go rpc.ServeConn(conn) } } type HelloService struct{} // Length 和原生相比,这里的接收参数和返回参数都用的是proto生成的代码 func (h *HelloService) Length(res pb.HelloRequest, reply *pb.HelloResponse) error { reply.Reply = int64(len(res.Res)) return nil } client_test.go func TestClient(t *testing.T) { client, err := rpc.Dial("tcp", "127.0.0.1:8888") if err != nil { log.Fatal("dialing:", err) } // 定义请求和接受参数 // 接收参数和返回参数都用的是proto生成的代码 res := pb.HelloRequest{Res: "test666"} var reply pb.HelloResponse err = client.Call("HelloService.Length", res, &reply) if err != nil { log.Fatal(err) } fmt.Println(reply) } gRPC实现 代码实现 hello.proto syntax = "proto3"; package pb; option go_package="./pb;pb"; // 请求结构体 message HelloRequest { string res = 1; } // 返回结构体 message HelloResponse { int64 reply = 1; } // GRPC服务 service HelloService { // 计算字符串长度 rpc Length(HelloRequest) returns (HelloResponse); } server.go func main() { // 创建一个 grpc server grpcServer := grpc.NewServer() // 注册 grpc pb.RegisterHelloServiceServer(grpcServer, new(HelloService)) lis, err := net.Listen("tcp", ":8888") if err != nil { log.Fatal(err) } // 监听端口上提供gRPC服务 grpcServer.Serve(lis) } type HelloService struct { pb.UnimplementedHelloServiceServer } func (h *HelloService) Length(ctx context.Context, res *pb.HelloRequest) (*pb.HelloResponse, error) { reply := &pb.HelloResponse{Reply: int64(len(res.Res))} return reply, nil } client_test.go func TestClient(t *testing.T) { conn, err := grpc.Dial("127.0.0.1:8888", grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { log.Fatal(err) } defer conn.Close() client := pb.NewHelloServiceClient(conn) reply, err := client.Length(context.Background(), &pb.HelloRequest{Res: "test123456"}) if err != nil { log.Fatal(err) } fmt.Println(reply.Reply) fmt.Println(reply.GetReply()) }
引言 RPC是远程过程调用的缩写(Remote Procedure Call),通俗地说就是调用远处的一个函数。使得应用程序之间可以进行通信,而且也遵从server/client模型。使用的时候客户端调用server端提供的接口就像是调用本地的函数一样。 参考文献 net/rpc实现rpc代码:https://github.com/mailjobblog/dev_go/tree/master/220113_rpc/1.rpc jsonrpc实现rpc代码:https://github.com/mailjobblog/dev_go/tree/master/220113_rpc/2.rpcjson RPC实现 Go原生net/rpc实现 Go语言的RPC包的路径为net/rpc,也就是放在了net包目录下面。因此我们可以猜测该RPC包是建立在net包基础之上的。 代码实现 server.go func main() { _ = rpc.Register(new(HelloService)) // _ = rpc.RegisterName("HelloService", new(HelloService)) // 自定义名称注册 listener, err := net.Listen("tcp", ":8888") if err != nil { log.Fatal("ListenTCP error:", err) } conn, err := listener.Accept() if err != nil { log.Fatal("Accept error:", err) } rpc.ServeConn(conn) } type HelloService struct {} func (h *HelloService) Length(res string, reply *int) error { *reply = len(res) return nil } rpc.Register函数调用会将对象类型中所有满足RPC规则的对象方法注册为RPC函数,所有注册的方法会放在“HelloService”服务空间之下。然后我们建立一个唯一的TCP链接,并且通过rpc.ServeConn函数在该TCP链接上为对方提供RPC服务。 其中Length方法必须满足Go语言的RPC规则: 方法只能有两个可序列化的参数 其中第二个参数是指针类型, 并且返回一个error类型 必须是公开的方法。 client.go func main() { client, err := rpc.Dial("tcp", "127.0.0.1:8888") if err != nil { log.Fatal("dialing:", err) } res := "test HelloService" var reply int err = client.Call("HelloService.Length", res, &reply) if err != nil { log.Fatal(err) } fmt.Println(reply) } Go使用jsonrpc实现 标准库的RPC默认采用Go语言特有的 gob 编码,因此从其它语言调用Go语言实现的RPC服务将比较困难。 Go语言的RPC框架有两个比较有特色的设计:一个是RPC数据打包时可以通过插件实现自定义的编码和解码;另一个是RPC建立在抽象的io.ReadWriteCloser接口之上的,我们可以将RPC架设在不同的通讯协议之上。这里我们将尝试通过官方自带的net/rpc/jsonrpc扩展实现一个跨语言的PPC。 代码实现 server.go func main() { _ = rpc.Register(new(HelloService)) listener, err := net.Listen("tcp", ":8888") if err != nil { log.Fatal("ListenTCP error:", err) } for { conn, err := listener.Accept() if err != nil { log.Fatal("Accept error:", err) } // 用rpc.ServeCodec函数替代了rpc.ServeConn函数 // 传入的参数是针对服务端的json编解码器 go rpc.ServeCodec(jsonrpc.NewServerCodec(conn)) } } type HelloService struct{} func (h *HelloService) Length(res string, reply *int) error { *reply = len(res) return nil } client.go func main() { // 手工调用net.Dial函数建立TCP链接 conn, err := net.Dial("tcp", "127.0.0.1:8888") if err != nil { log.Fatal("net.Dial:", err) } // 针对客户端的json编解码器 client := rpc.NewClientWithCodec(jsonrpc.NewClientCodec(conn)) res := "test HelloService" var reply int err = client.Call("HelloService.Length", res, &reply) if err != nil { log.Fatal(err) } fmt.Println(reply) } 知识拓展 查看服务器接收的客户端数据 为了查看到客户端调用时发送的数据格式。我们可以用 nc 命令 nc -l 8888 在同样的端口启动一个TCP服务。 然后再次执行一次RPC调用(client.go)将会发现nc输出了以下的信息: {"method":"HelloService.Length","params":["test HelloService"],"id":0} 这是一个json编码的数据,其中method部分对应要调用的rpc服务和方法组合成的名字,params部分的第一个元素为参数,id是由调用端维护的一个唯一的调用编号。 请求的json数据对象在内部对应两个结构体:客户端是clientRequest,服务端是serverRequest。 clientRequest 和 serverRequest结构体的内容基本是一致的: type clientRequest struct { Method string `json:"method"` Params [1]interface{} `json:"params"` Id uint64 `json:"id"` } type serverRequest struct { Method string `json:"method"` Params *json.RawMessage `json:"params"` Id *json.RawMessage `json:"id"` } 用linux命令模拟客户端请求服务端 在获取到RPC调用对应的json数据后,我们可以通过直接向架设了RPC服务(server.go)的TCP服务器发送json数据模拟RPC方法调用: $ echo -e '{"method":"HelloService.Length","params":["test HelloService"],"id":0}' | nc 127.0.0.1 8888 返回的结果也是一个json格式的数据: {"id":0,"result":17,"error":null} 返回的json数据也是对应内部的两个结构体:客户端是clientResponse,服务端是serverResponse。两个结构体的内容同样也是类似的: type clientResponse struct { Id uint64 `json:"id"` Result *json.RawMessage `json:"result"` Error interface{} `json:"error"` } type serverResponse struct { Id *json.RawMessage `json:"id"` Result interface{} `json:"result"` Error interface{} `json:"error"` } 因此无论采用何种语言,只要遵循同样的json结构,以同样的流程就可以和Go语言编写的RPC服务进行通信。这样我们就实现了跨语言的RPC。
介绍 etcd是一个高可用的 Key/Value 存储系统,主要用于分享配置和服务发现。它是一款云原生时代的首选元数据存储产品,已经成为云原生和分布式系统的存储基石。类似项目有zookeeper和consul。 etcd具有以下特点: 完全复制:集群中的每个节点都可以使用完整的存档 高可用性:Etcd可用于避免硬件的单点故障或网络问题 一致性:每次读取都会返回跨多主机的最新写入 简单:包括一个定义良好、面向用户的API(gRPC) 安全:实现了带有可选的客户端证书身份验证的自动化TLS 快速:每秒10000次写入的基准速度 可靠:使用Raft算法实现了强一致、高可用的服务存储目录 etcd架构介绍 Client层 组成 client v2 API 客户端库 client v3 API 客户端库 作用 提供了简洁易用的 API 支持负载均衡、节点间故障自动转移 极大降低业务使用 etcd 复杂度,提升开发效率、服务可用性 API 网络层 client 访问 server的通信协议 v2API HTTP/1.x 协议 v3API gRPC 协议 HTTP/1.x 协议(通过 etcd grpc-gateway 组件支持) server 节点之间的通信协议 节点间通过 Raft 算法实现数据复制和 Leader 选举等功能时使用的 HTTP 协议 Raft 算法层(基石和亮点) 核心算法组成 Leader 选举 日志复制 ReadIndex 作用 用于保障 etcd 多个节点间的数据一致性、提升服务可用性等 功能逻辑层(核心特性实现层) KVServer 模块 限速判断(保证集群稳定性,避免雪崩) 生成一个唯一的 ID,将此请求关联到一个对应的消息通知 channel,然后向 Raft 模块发起(Propose)一个提案(Proposal) 等待此 put 请求,等待写入结果通过消息通知 channel 返回或者超时。etcd 默认超时时间是 7 秒(5 秒磁盘 IO 延时 +2*1 秒竞选超时时间),如果一个请求超时未返回结果,则可能会出现 etcdserver: request timed out 错误 MVCC 模块 读场景 写场景 treeIndex 模块(内存树形索引模块) 保存用户 key 和版本号的映射关系 boltdb 模块 基于 B+ tree 实现的 key-value 键值库,支持事务,提供 Get/Put 等简易 API 给 etcd 操作 每次修改操作,生成一个新的版本号 (revision),以版本号为 key, value 为用户 key-value 等信息组成的结构体 数据隔离 boltdb 里每个 bucket 类似对应 MySQL 一个表 用户的 key 数据存放的 bucket 名字的是 key etcd MVCC 元数据存放的 bucket 是 meta Auth 鉴权模块 Lease 租约模块 Compactor 压缩模块 Quota 模块(配额) Apply模块 存储层 预写日志 (WAL) 模块 可保障 etcd crash 后数据不丢失 快照 (Snapshot) 模块 boltdb 模块 保存了集群元数据和用户写入的数据 参考文献 etcd官方下载:https://github.com/etcd-io/etcd/releases etcd集群搭建:https://www.wenjiangs.com/doc/h30rpjp5r etcd集群配置文件:https://www.cnblogs.com/skymyyang/p/9067280.html 安装与配置 本文基于 centos7 和 etcd v3.5.1 进行讲解,请注意版本软件区分。 安装 下载etcd curl -O https://github.com/etcd-io/etcd/releases/download/v3.5.1/etcd-v3.5.1-linux-amd64.tar.gz PS:curl默认不支持Https,命令curl -V(V大写)查看Protocols项有没有https ,如果没有就要用命令:yum install openssl-devel 装SSL 解压文件 tar -zxvf etcd-v3.5.1-linux-amd64.tar.gz cd etcd-v3.5.1-linux-amd6 添加环境变量 etcd是服务端,etcdctl是运维人员操作的控制端,一般只需要装etcd,现在是学习就都装在同一台机器。 cp etcd /usr/local/bin cp etcdctl /usr/local/bin PS:用echo $PATH查看自己的环境变量路径 修改环境变量PATH vi /etc/profile 在文件最后加入变量,因为etcd默认使用V2版本,我们需要V3版本的API export ETCDCTL_API=3 刷新环境变量 source /etc/profile 查看版本信息 etcd -version 或 etcdctl version 启动etcd 单机部署 启动: etcd 直接运行命令 ./etcd 就可以启动了,非常简单。默认使用2379端口为客户端提供通讯, 并使用端口2380来进行服务器间通讯。 测试: [root@VM-0-15-centos ~]# etcdctl put foo bar OK [root@VM-0-15-centos ~]# etcdctl get foo foo bar 集群部署 内容略去。。。。。。 其他配置 创建etcd配置文件 [root@VM-0-15-centos ~]# mkdir -p /var/lib/etcd/ [root@VM-0-15-centos ~]# cat <<EOF | sudo tee /etc/etcd.conf > # 节点名称 > ETCD_NAME=etcd1 > # 数据存放位置 > ETCD_DATA_DIR=/var/lib/etcd/ > EOF 创建开机启动文件 vim /etc/systemd/system/etcd.service 写入以下配置信息 [Unit] Description=Etcd Server Documentation=https://github.com/coreos/etcd After=network.target [Service] User=root Type=notify #这个文件特别关键,etcd使用的环境变量都需要通过环境变量文件读取 EnvironmentFile=-/etc/etcd.conf ExecStart=/usr/local/bin/etcd Restart=on-failure RestartSec=10s LimitNOFILE=40000 [Install] WantedBy=multi-user.target PS:EnvironmentFile=-/etc/etcd.conf 这个配置项里/etc前的-是K8S生成配置文件时就有的,不是我误写的。 重新加载配置 && 开机启动 && 启动etcd systemctl daemon-reload && systemctl enable etcd && systemctl start etcd 开机启动设置状态,状态enabled [root@VM-0-15-centos ~]# systemctl list-unit-files etcd.service UNIT FILE STATE etcd.service enabled 1 unit files listed. 查看etcd状态 systemctl show etcd.service 查看2379端口是否启动成功 [root@VM-0-15-centos ~]# netstat -an |grep 2379 tcp 0 0 127.0.0.1:2379 0.0.0.0:* LISTEN tcp 0 0 127.0.0.1:50822 127.0.0.1:2379 ESTABLISHED tcp 0 0 127.0.0.1:2379 127.0.0.1:50822 ESTABLISHED PS: CentOS默认没有装netstat,需要 yum install -y net-tools 自己装
引言 Go 不是完全面向对象语言,有一些面向对象模式不太适合它。但经过这些年的发展,Go 有自己的一些模式。今天介绍一个常见的模式:函数式选项模式(Functional Options Pattern)。 参考文献 引用官方博文:https://golang.cafe/blog/golang-functional-options-pattern.html 本文代码下载:https://github.com/mailjobblog/dev_go/tree/master/211223-FunOptionPattern 函数式选项模式介绍 Go 语言没有构造函数,一般通过定义 New 函数来充当构造函数。然而,如果结构有较多字段,要初始化这些字段,有很多种方式,但有一种方式认为是最好的,这就是函数式选项模式(Functional Options Pattern)。 函数式选项模式是一种在 Go 中构造结构体的模式,它通过设计一组非常有表现力和灵活的 API 来帮助配置和初始化结构体。 在 Uber 的 Go 语言规范中提到了该模式: Functional options 是一种模式,在该模式中,你可以声明一个不透明的 Option 类型,该类型在某些内部结构中记录信息。你接受这些可变数量的选项,并根据内部结构上的选项记录的完整信息进行操作。 将此模式用于构造函数和其他公共 API 中的可选参数,你预计这些参数需要扩展,尤其是在这些函数上已经有三个或更多参数的情况下。 代码演示 为了更好的理解该模式,我们通过一个例子来讲解。定义一个 Server 结构体: package main type Server struct{ host string port int } func New(host string, port int) *Server { return &Server{host, port} } func (s *Server) Start() error { } 如何使用呢? package main import ( "log" "server" ) func main() { svr := New("localhost", 1234) if err := svr.Start(); err != nil { log.Fatal(err) } } 但如果要扩展 Server 的配置选项,如何做?通常有三种做法: 为每个不同的配置选项声明一个新的构造函数 定义一个新的 Config 结构体来保存配置信息 使用 Functional Option Pattern 下面,我们将对于这三种模式,进行详细的讲解和演示: 方法一 这种做法是在构造函数上,定义配置参数。有几个配置参数,则在 New 上就配置几个参数。用这种方式,当配置少的时候没有问题。但是,如果配置项比较多的时候将会是灾难。 这种方式的应用。比如 net 包中的 Dial 和 DialTimeout: func Dial(network, address string) (Conn, error) func DialTimeout(network, address string, timeout time.Duration) (Conn, error) 完整演示代码示例: Pkg.server type Server struct { host string port int } func New(host string, port int) *Server { return &Server{host, port} } // TestFunc 测试包内函数调用 func (s *Server) TestFunc() (string, error) { return fmt.Sprintf("Run Success, host:%s, port:%d \n", s.host, s.port), nil // test fun return } main func main() { s := server.New("127.0.0.1", 1234) test, err := s.TestFunc() fmt.Println(test, err) } 方法二 这种方式也是很常见的,特别是当配置选项很多时。通常可以创建一个 Config 结构体,其中包含 Server 的所有配置选项。这种做法,即使将来增加更多配置选项,也可以轻松的完成扩展,不会破坏 Server 的 API。 在使用时,需要先构造 Config 实例,对这个实例,又回到了前面 Server 的问题上,因为增加或删除选项,需要对 Config 有较大的修改。如果将 Config 中的字段改为私有,可能需要定义 Config 的构造函数。。。 完整演示代码示例: Pkg.server type Server struct { cfg Config } type Config struct { Host string Port int } func New(cfg Config) *Server { return &Server{cfg} } // TestFunc 测试包内函数调用 func (s *Server) TestFunc() (string, error) { return fmt.Sprintf("Run Success, host:%s, port:%d \n", s.cfg.Host, s.cfg.Port), nil // test fun return } main func main() { s := server.New(server.Config{ Host: "127.0.0.1", Port: 1234, }) test, err := s.TestFunc() fmt.Println(test, err) } 方法三 一个更好的解决方案是使用 Functional Option Pattern。在这个模式中,我们定义一个 Option 函数类型: type Option func(*Server) Option 类型是一个函数类型,它接收一个参数:*Server。然后,Server 的构造函数接收一个 Option 类型的不定参数: func New(options ...Option) *Server { svr := &Server{} for _, f := range options { f(svr) } return svr } 那选项如何起作用?需要定义一系列相关返回 Option 的函数: func WithHost(host string) Option { return func(s *Server) { s.host = host } } func WithPort(port int) Option { return func(s *Server) { s.port = port } } func WithTimeout(timeout time.Duration) Option { return func(s *Server) { s.timeout = timeout } } func WithMaxConn(maxConn int) Option { return func(s *Server) { s.maxConn = maxConn } } 将来增加选项,只需要增加对应的 WithXXX 函数即可。 完整演示代码示例: Pkg.server type Server struct { host string port int } type Option func(*Server) func New(options ...Option) *Server { ser := &Server{} for _, f := range options { f(ser) } return ser } func WithHost(host string) Option { return func(s *Server) { s.host = host } } func WithPort(port int) Option { return func(s *Server) { s.port = port } } // TestFunc 测试包内函数调用 func (s *Server) TestFunc() (string, error) { return fmt.Sprintf("Run Success, host:%s, port:%d \n", s.host, s.port), nil // test fun return } main func main() { s := server.New( server.WithHost("127.0.0.1"), server.WithPort(1234), ) test, err := s.TestFunc() fmt.Println(test, err) } 扩展阅读 这种模式,在第三方库中使用挺多,比如 github.com/gocolly/colly: type Collector { // 省略... } func NewCollector(options ...CollectorOption) *Collector // 定义了一系列 CollectorOpiton type CollectorOption{ // 省略... } func AllowURLRevisit() CollectorOption func AllowedDomains(domains ...string) CollectorOption ... 不过 Uber 的 Go 语言编程规范中提到该模式时,建议定义一个 Option 接口,而不是 Option 函数类型。该 Option 接口有一个未导出的方法,然后通过一个未导出的 options 结构来记录各选项。 Uber 的这个例子能看懂吗? type options struct { cache bool logger *zap.Logger } type Option interface { apply(*options) } type cacheOption bool func (c cacheOption) apply(opts *options) { opts.cache = bool(c) } func WithCache(c bool) Option { return cacheOption(c) } type loggerOption struct { Log *zap.Logger } func (l loggerOption) apply(opts *options) { opts.logger = l.Log } func WithLogger(log *zap.Logger) Option { return loggerOption{Log: log} } // Open creates a connection. func Open( addr string, opts ...Option, ) (*Connection, error) { options := options{ cache: defaultCache, logger: zap.NewNop(), } for _, o := range opts { o.apply(&options) } // ... }
ServerLess介绍 过去是“构建一个框架运行在一台服务器上,对多个事件进行响应”,Serverless则变为“构建或使用一个微服务或微功能来响应一个事件”,做到当访问时,调入相关资源开始运行,运行完成后,卸载所有开销,真正做到按需按次计费。这是云计算向纵深发展的一种自然而然的过程。 Serverless是一种构建和管理基于微服务架构的完整流程,允许你在服务部署级别而不是服务器部署级别来管理你的应用部署。它与传统架构的不同之处在于,完全由第三方管理,由事件触发,存在于无状态(Stateless)、暂存(可能只存在于一次调用的过程中)计算容器内。构建无服务器应用程序意味着开发者可以专注在产品代码上,而无须管理和操作云端或本地的服务器或运行时。Serverless真正做到了部署应用无需涉及基础设施的建设,自动构建、部署和启动服务。 FaaS和BaaS Serverless由开发者实现的服务端逻辑运行在无状态的计算容器中,它由事件触发, 完全被第三方管理,其业务层面的状态则被开发者使用的数据库和存储资源所记录。Serverless涵盖了很多技术,分为两类:FaaS和BaaS。 FaaS(Function as a Service,函数即服务) FaaS意在无须自行管理服务器系统或自己的服务器应用程序,即可直接运行后端代码。其中所指的服务器应用程序,是该技术与容器和PaaS(平台即服务)等其他现代化架构最大的差异。 FaaS可以取代一些服务处理服务器(可能是物理计算机,但绝对需要运行某种应用程序),这样不仅不需要自行供应服务器,也不需要全时运行应用程序。 FaaS产品不要求必须使用特定框架或库进行开发。在语言和环境方面,FaaS函数就是常规的应用程序。例如AWS Lambda的函数可以通过Javascript、Python以及任何JVM语言(Java、Clojure、Scala)等实现。然而Lambda函数也可以执行任何捆绑有所需部署构件的进程,因此可以使用任何语言,只要能编译为Unix进程即可。FaaS函数在架构方面确实存在一定的局限,尤其是在状态和执行时间方面。 在迁往FaaS的过程中,唯一需要修改的代码是“主方法/启动”代码,其中可能需要删除顶级消息处理程序的相关代码(“消息监听器接口”的实现),但这可能只需要更改方法签名即可。在FaaS的世界中,代码的其余所有部分(例如向数据库执行写入的代码)无须任何变化。 相比传统系统,部署方法会有较大变化 – 将代码上传至FaaS供应商,其他事情均可由供应商完成。目前这种方式通常意味着需要上传代码的全新定义(例如上传zip或JAR文件),随后调用一个专有API发起更新过程。 FaaS中的函数可以通过供应商定义的事件类型触发。对于亚马逊AWS,此类触发事件可以包括S3(文件)更新、时间(计划任务),以及加入消息总线的消息(例如Kinesis)。通常你的函数需要通过参数指定自己需要绑定到的事件源。 大部分供应商还允许函数作为对传入Http请求的响应来触发,通常这类请求来自某种该类型的API网关(例如AWS API网关、Webtask)。 BaaS(Backend as a Service,后端即服务) BaaS(Backend as a Service,后端即服务)是指我们不再编写或管理所有服务端组件,可以使用领域通用的远程组件(而不是进程内的库)来提供服务。理解BaaS,需要搞清楚它与PaaS的区别。 首先BaaS并非PaaS,它们的区别在于:PaaS需要参与应用的生命周期管理,BaaS则仅仅提供应用依赖的第三方服务。典型的PaaS平台需要提供手段让开发者部署和配置应用,例如自动将应用部署到Tomcat容器中,并管理应用的生命周期。BaaS不包含这些内容,BaaS只以API的方式提供应用依赖的后端服务,例如数据库和对象存储。BaaS可以是公共云服务商提供的,也可以是第三方厂商提供的。其次从功能上讲,BaaS可以看作PaaS的一个子集,即提供第三方依赖组件的部分。 BaaS服务还允许我们依赖其他人已经实现的应用逻辑。对于这点,认证就是一个很好的例子。很多应用都要自己编写实现注册、登录、密码管理等逻辑的代码,而对于不同的应用这些代码往往大同小异。完全可以把这些重复性的工作提取出来,再做成外部服务,而这正是Auth0和Amazon Cognito等产品的目标。它们能实现全面的认证和用户管理,开发团队再也不用自己编写或者管理实现这些功能的代码。 腾讯云SCF介绍 腾讯云的ServerLess(云函数)写起来,和 AWS 的 serverless 是差不多一样的,因为腾讯云的serverless开发上很大程度的借鉴了AWS的云函数,所以本文将会以腾讯云的 SCF 进行演示开发 参考文献 腾讯云SCF:https://cloud.tencent.com/product/scf Go开发serverless参考文档:https://cloud.tencent.com/document/product/583/18032 Api网关触发:https://cloud.tencent.com/document/product/583/12513 本文代码下载:https://github.com/mailjobblog/dev_go/tree/master/211209-serverless 配置及开发步骤 函数创建 点击 “新建” 后,我这里选择了 “自定义创建” 即为空白模版(当然你也可以选择用模版创建)。 函数类型:我这里选择 ”事件函数“ ,因为待会我的目标是使用 api 调用云函数,如果你想托管静态网站的话,可以选择 ”Web函数“。 函数名称:根据业务需求可以自定义。 运行环境:你打算用什么语言开发就选择什么语言,这里我选择用 Go 语言开发。 函数创建完成后,我们现在腾讯云给的demo函数,然后根据自己的需求开发。 下载后,我们可以看到,是这样的一份代码。 package main import ( "context" "fmt" "github.com/tencentyun/scf-go-lib/cloudfunction" ) type DefineEvent struct { // test event define Key1 string `json:"key1"` Key2 string `json:"key2"` } func hello(ctx context.Context, event DefineEvent) (string, error) { fmt.Println("key1:", event.Key1) fmt.Println("key2:", event.Key2) return fmt.Sprintf("Hello %s!", event.Key1), nil } func main() { // Make the handler available for Remote Procedure Call by Cloud Function cloudfunction.Start(hello) } 业务开发演示 对于下载的代码,我们进行一番改写,需求则是对于给定的数字,x和y,进行求和运算,并发挥计算结果。 package main import ( "context" "github.com/tencentyun/scf-go-lib/cloudfunction" "strconv" ) func main() { // Make the handler available for Remote Procedure Call by Cloud Function // 调用 serverless 函数 cloudfunction.Start(hello) } // serverless 函数 // 入参1:go语言内置的context,用于上下文 // 入参2:腾讯serverless传递的参数会放在这个里面 func hello(ctx context.Context, event DefineEvent) (ResultDiy, error) { // 接收传递的参数 x := event.QueryString.X y := event.QueryString.Y // 业务实现 s := strJoin(x,y) result := ResultDiy{ ErrorCode: 200, String: s, } return result, nil } // DefineEvent 入参2定义 // 该参数的定义,需要遵循腾讯serverless给的规范进行定义 // api网关触发集成概述:https://cloud.tencent.com/document/product/583/12513 type DefineEvent struct { QueryString struct { X string `json:"x"` Y string `json:"y"` } `json:"queryString"` HttpMethod string `json:"httpMethod"` } // ResultDiy 定义返回的内容 type ResultDiy struct { ErrorCode int64 `json:"errorCode"` String string `json:"string"` } // 模拟实体业务 func strJoin(x,y string) string { length := strconv.Itoa(len(x + y)) return length + "_" + x + "_" + y } 参照腾讯文档,进行编译打包 GOOS=linux GOARCH=amd64 go build -o main main.go zip main.zip main 上传与部署 函数触发配置 使用 api 对刚刚写的云函数进行触发 首先,创建触发器 Tips: 触发方式:可以根据自己的需求选择,这里我选择 api 请求触发。 Api服务:即为api网关配置。 集成响应:这里我们选择不启用集成响应。意味着你可以自定义响应内容。否则你需要按照文档,返回对应的数据格式。 Api网关配置 因为刚刚选择“不集成响应”,所以我们需要在网关中指定返回的数据格式 在 Api网关 中,我们可以看到,刚刚自动生成的 api 服务。 然后编辑api,选择返回的数据格式为json。 测试Api触发 然后回到 “触发管理” 中,复制api访问路径。 请求测试
mongodb的云数据库,新用户注册,提供500m免费的空间,对于创建测试的网站数据库来说,足够使用。虽然是服务器是在美国,但是链接稳定。下面就介绍注册和使用的流程。 网址 MongoDB官网:https://account.mongodb.com Mongodb概念解析 不管我们学习什么数据库都应该学习其中的基础概念,在mongodb中基本的概念是文档、集合、数据库,下面我们挨个介绍。 下表将帮助您更容易理解Mongo中的一些概念: 基础概念学习地址:https://www.runoob.com/mongodb/mongodb-databases-documents-collections.html SQL术语/概念 MongoDB术语/概念 解释/说明 database database 数据库 table collection 数据库表/集合 row document 数据记录行/文档 column field 数据字段/域 index index 索引 table joins 表连接,MongoDB不支持 primary key primary key 主键,MongoDB自动将_id字段设置为主键 通过下图实例,我们也可以更直观的了解Mongo中的一些概念: 云数据库操作步骤 创建组织 首先,创建一个组织,名字你可以随便写,只要符合规范即可,命名完成后点击 Next 下一步 在这里,你可以添加其他的同事进入这个组织(这里就我一个人开发,就不邀请其他人了),后点击 Create Organization 创建组织 创建项目 完成创建后,我们进去 Organization的组织 页面,在这里我们可以创建一个 project 项目 创建数据库 项目创建完成后,我们需要 Create a database创建数据库 然后可以根据自己所在的地域,选择距离自己较近的节点,因为我当前所在的地域是北京,所以我在这里就选择东京了。然后 Create Cluster创建集群  创建连接账户 紧接着,我们在安全与快速入门这里,需要创建账号和密码,以及ip白名单。 连接账户的创建,有两种方式,一种是用户名和密码,一种是证书。在这里我们选择一种比较简单的连接方式,就是用户名和密码了。 在环境配置这里,有两种方式,一种是本地环境,一种是云环境。在这里我们选择第一种本地环境,并且开放所有的ip进入白名单。 了这里,我们就创建成功了 创建成功 如果看到这个界面,则表示,你已经创建成功了。 测试操作 点击上图中的 Cluster0进入集群0的节点,然后点击 Collections集合,再点击 Add My Own Data 创建数据库和集合。 创建成功后,可以在里面插入数据测试 测试连接mongodb 点击 Connect连接会出现以下界面,界面提供了三种连接mongodb的方式 Connect with the MongoDB Shell 表示用shell脚本连接mongodb Connect your application 表示使用你的应用代码连接,在项目开发中,无论你使用的是 java、php、go、nodejs 都需要选择这种方式连接mongodb Connect using MongoDB Compass 表示用客户端工具连接momgodb,你可以用mongodb官网提供的客户端工具,也可以使用 Navicat 连接 Navicat连接 选择 Connect using MongoDB Compass则会提示相对的连接信息,然后复制连接信息,注意这里的 <passwor>需要改成你设定的密码 Navicat 中连接方式选择 Mongodb 连接,然后选择 url 连接 应用后,进行连接测试,查看是否连接成功 代码连接 基于Go语言测试 代码下载:https://github.com/mailjobblog/dev_go/tree/master/211209-mongodb-connect package main import ( "context" "fmt" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" "log" "time" ) func main() { // 连接 mongodb // 注意:这里的账号和密码要改成你自己的 clientOptions := options.Client().ApplyURI("mongodb+srv://test_user:test_pwd@cluster0.sy0un.mongodb.net/myFirstDatabase?retryWrites=true&w=majority") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() client, err := mongo.Connect(ctx, clientOptions) if err != nil { log.Fatal("mongodb 连接失败,error:", err) } // 定义连接库和连接集合 collection := client.Database("t_db").Collection("t_coll") // 定义要插入的数据 user := UserData{ Name: "张三", Age: 20, } // 测试插入数据 insert,err := collection.InsertOne(ctx, &user) if err != nil { log.Fatal("mongodb 数据插入失败,error:", err) } // 打印执行结果的id fmt.Println(insert) } // UserData 定义插入数据的结构体 type UserData struct { // Id string `bson:"_id,omitempty" json:"id"` // 这里不设置id,让数据库自动生成 Name string `bson:"name" json:"name"` Age int `bson:"age" json:"age"` } 基于Node.js语言测试 代码下载:https://github.com/mailjobblog/dev_nodejs/tree/master/211209-mongodb-connect const { MongoClient } = require('mongodb'); // 定义数据库连接地址 // 这里:这里的用户名和密码要改成你自己的 const uri = "mongodb+srv://test_user:test_pwd@cluster0.sy0un.mongodb.net/myFirstDatabase?retryWrites=true&w=majority"; // 定义异步方法 const testMongo = async () => { // connect to your cluster const client = await MongoClient.connect(uri, { useNewUrlParser: true, useUnifiedTopology: true, }); // specify the DB's name const db = client.db('t_db'); // 数据插入测试 const items = await db.collection('t_coll').insertOne({ name: "test_names" }); console.log(items); // close connection client.close(); } // 调用异步方法 testMongo();
前言 在 Go 1.7 版本之前,context 还是非编制的,它存在于 golang.org/x/net/context 包中。 后来,Golang 团队发现 context 还挺好用的,就把 context 收编了,在 Go 1.7 版本正式纳入了标准库。 为什么需要Context 当一个协程(goroutine)开启后,我们是无法强制关闭它的。 常见的关闭协程的原因有如下几种: goroutine 自己跑完结束退出(正常关闭,本文不讨论)。 主进程 crash 退出,goroutine 被迫退出(属异常关闭,应优化代码)。 通过通道发送信号,引导协程的关闭(属于开发者手动控制协程的方法)。 func main() { // 定义通知 goroutine 停止的 chanel stopSingal := make(chan bool) // 创建 5 个 goroutine for i := 1; i <= 5; i++ { go monitor(stopSingal, i) } // 等待时间 time.Sleep(1 * time.Second) // 关闭所有 goroutine close(stopSingal) // 等待5s,若此时屏幕没有输出 <正在监控中> 就说明所有的goroutine都已经关闭 time.Sleep(5 * time.Second) fmt.Println("主程序退出!!") } func monitor(ch chan bool, number int) { for { select { case v := <-ch: // 仅当 ch 通道被 close,或者有数据发过来(无论是true还是false)才会走到这个分支 fmt.Printf("监控器%v,接收到通道值为:%v,监控结束。\n", number, v) return default: fmt.Printf("监控器%v,正在监控中...\n", number) time.Sleep(2 * time.Second) } } } 监控器1,正在监控中... 监控器5,正在监控中... 监控器4,正在监控中... 监控器2,正在监控中... 监控器3,正在监控中... 监控器2,接收到通道值为:false,监控结束。 监控器1,接收到通道值为:false,监控结束。 监控器3,接收到通道值为:false,监控结束。 监控器5,接收到通道值为:false,监控结束。 监控器4,接收到通道值为:false,监控结束。 主程序退出!! 上面的例子,在我们定义一个无缓冲通道时,要实现对所有的 goroutine 进行关闭,可以使用 close 关闭通道,然后在所有的 goroutine 里不断检查通道是否关闭*(前提你得约定好,该通道你只会进行 close 而不会发送其他数据,否则发送一次数据就会关闭一个goroutine,这样会不符合咱们的预期,所以最好你对这个通道再做一层封装做个限制)*来决定是否结束 goroutine。 所以你看到这里,我做为初学者还是没有找到使用 Context 的必然理由,我只能说 Context 是个很好用的东西,使用它方便了我们在处理并发时候的一些问题,但是它并不是不可或缺的。 换句话说,它解决的并不是 能不能 的问题,而是解决 更好用 的问题。 简单使用Context 此处的代码,我们先实现一个简单的 Context Demo(我使用 Context 对上面的例子进行了一番改造),然后详细分析其中的代码意义。 func main() { ctx, cancel := context.WithCancel(context.Background()) for i :=1 ; i <= 5; i++ { go monitor(ctx, i) } time.Sleep( 1 * time.Second) // 关闭所有 goroutine cancel() // 等待5s,若此时屏幕没有输出 <正在监控中> 就说明所有的goroutine都已经关闭 time.Sleep( 5 * time.Second) fmt.Println("主程序退出!!") } func monitor(ctx context.Context, number int) { for { select { // 其实可以写成 case <- ctx.Done() // 这里仅是为了让你看到 Done 返回的内容 case v :=<- ctx.Done(): fmt.Printf("监控器%v,接收到通道值为:%v,监控结束。\n", number,v) return default: fmt.Printf("监控器%v,正在监控中...\n", number) time.Sleep(2 * time.Second) } } } 代码分析 ctx, cancel := context.WithCancel(context.Background()) 以 context.Background() 为 parent context 定义一个可取消的 context case <- ctx.Done(): 然后你可以在所有的goroutine 里利用 for + select 搭配来不断检查 ctx.Done() 是否可读,可读就说明该 context 已经取消,你可以清理 goroutine 并退出了。 cancel() 当你想到取消 context 的时候,只要调用一下 cancel 方法即可。这个 cancel 就是我们在创建 ctx 的时候返回的第二个值。 该程序的运行结果如下: 监控器3,正在监控中... 监控器4,正在监控中... 监控器1,正在监控中... 监控器2,正在监控中... 监控器2,接收到通道值为:{},监控结束。 监控器5,接收到通道值为:{},监控结束。 监控器4,接收到通道值为:{},监控结束。 监控器1,接收到通道值为:{},监控结束。 监控器3,接收到通道值为:{},监控结束。 主程序退出!! 根Context 是什么? 创建 Context 必须要指定一个 父 Context,当我们要创建第一个Context时该怎么办呢? 不用担心,Go 已经帮我们实现了2个,我们代码中最开始都是以这两个内置context作为最顶层的parent context,衍生出更多的子Context。 var ( background = new(emptyCtx) todo = new(emptyCtx) ) func Background() Context { return background } func TODO() Context { return todo } Background:主要用于main函数、初始化以及测试代码中,作为Context这个树结构的最顶层的Context,也就是根Context,它不能被取消。 TODO:如果我们不知道该使用什么Context的时候,可以使用这个,但是实际应用中,暂时还没有使用过这个TODO。 他们两个本质上都是emptyCtx结构体类型,是一个不可取消,没有设置截止时间,没有携带任何值的Context。 type emptyCtx int func (*emptyCtx) Deadline() (deadline time.Time, ok bool) { return } func (*emptyCtx) Done() <-chan struct{} { return nil } func (*emptyCtx) Err() error { return nil } func (*emptyCtx) Value(key interface{}) interface{} { return nil } Context 的继承衍生 上面在定义我们自己的 Context 时,我们使用的是 WithCancel 这个方法。除它之外,context 包还有其他几个 With 系列的函数. func WithCancel(parent Context) (ctx Context, cancel CancelFunc) func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc) func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) func WithValue(parent Context, key, val interface{}) Context 这四个函数有一个共同的特点,就是第一个参数,都是接收一个 父context。 WithCancel WithCancel 返回带有新Done通道的父节点的副本。当调用返回的cancel函数或当关闭父上下文的Done通道时,将关闭返回上下文的Done通道,无论先发生什么情况。 取消此上下文将释放与其关联的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel。 func main() { ctx, cancel := context.WithCancel(context.Background()) for i := 1; i <= 5; i++ { go job1(ctx, i) } time.Sleep(1 * time.Second) // 关闭所有 goroutine cancel() // 等待5s,若此时屏幕没有输出 <正在监控中> 就说明所有的goroutine都已经关闭 time.Sleep(5 * time.Second) fmt.Println("主程序退出!!") } func job1(ctx context.Context, number int) { for { select { // 其实可以写成 case <- ctx.Done() // 这里仅是为了让你看到 Done 返回的内容 case v := <-ctx.Done(): fmt.Printf("监控器%v,接收到通道值为:%v,监控结束。\n", number, v) return default: fmt.Printf("监控器%v,正在监控中...\n", number) time.Sleep(2 * time.Second) } } } 监控器5,正在监控中... 监控器2,正在监控中... 监控器3,正在监控中... 监控器4,正在监控中... 监控器1,正在监控中... 监控器2,接收到通道值为:{},监控结束。 监控器4,接收到通道值为:{},监控结束。 监控器5,接收到通道值为:{},监控结束。 监控器1,接收到通道值为:{},监控结束。 监控器3,接收到通道值为:{},监控结束。 主程序退出!! WithDeadline WithDeadline 返回父上下文的副本,并将deadline调整为不迟于d。如果父上下文的deadline已经早于d,则WithDeadline(parent, d)在语义上等同于父上下文。当截止日过期时,当调用返回的cancel函数时,或者当父上下文的Done通道关闭时,返回上下文的Done通道将被关闭,以最先发生的情况为准。 取消此上下文将释放与其关联的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel。 func main() { ctx01, cancel := context.WithCancel(context.Background()) ctx02, cancel := context.WithDeadline(ctx01, time.Now().Add(1 * time.Second)) defer cancel() for i :=1 ; i <= 5; i++ { go job2(ctx02, i) } time.Sleep(5 * time.Second) if ctx02.Err() != nil { fmt.Println("监控器取消的原因: ", ctx02.Err()) } fmt.Println("主程序退出!!") } func job2(ctx context.Context, number int) { for { select { case <- ctx.Done(): fmt.Printf("监控器%v,监控结束。\n", number) return default: fmt.Printf("监控器%v,正在监控中...\n", number) time.Sleep(2 * time.Second) } } } 监控器5,正在监控中... 监控器3,正在监控中... 监控器4,正在监控中... 监控器2,正在监控中... 监控器1,正在监控中... 监控器1,监控结束。 监控器3,监控结束。 监控器2,监控结束。 监控器4,监控结束。 监控器5,监控结束。 监控器取消的原因: context deadline exceeded 主程序退出!! WithTimeout WithTimeout 返回WithDeadline(parent, time.Now().Add(timeout))。 取消此上下文将释放与其相关的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel,通常用于数据库或者网络连接的超时控制。 func main() { ctx01, cancel := context.WithCancel(context.Background()) // 此处定义1秒 ctx02, cancel := context.WithTimeout(ctx01, 1 * time.Second) defer cancel() for i :=1 ; i <= 5; i++ { go job3(ctx02, i) } time.Sleep(5 * time.Second) if ctx02.Err() != nil { fmt.Println("监控器取消的原因: ", ctx02.Err()) } fmt.Println("主程序退出!!") } func job3(ctx context.Context, number int) { for { select { case <- ctx.Done(): fmt.Printf("监控器%v,监控结束。\n", number) return default: fmt.Printf("监控器%v,正在监控中...\n", number) time.Sleep(2 * time.Second) } } } 监控器5,正在监控中... 监控器4,正在监控中... 监控器2,正在监控中... 监控器3,正在监控中... 监控器1,正在监控中... 监控器4,监控结束。 监控器1,监控结束。 监控器2,监控结束。 监控器5,监控结束。 监控器3,监控结束。 监控器取消的原因: context deadline exceeded 主程序退出!! WithValue WithValue 返回父节点的副本,其中与key关联的值为val。 仅对API和进程间传递请求域的数据使用上下文值,而不是使用它来传递可选参数给函数。 所提供的键必须是可比较的,并且不应该是string类型或任何其他内置类型,以避免使用上下文在包之间发生冲突。WithValue的用户应该为键定义自己的类型。为了避免在分配给interface{}时进行分配,上下文键通常具有具体类型struct{}。或者,导出的上下文关键变量的静态类型应该是指针或接口。 func main() { ctx01, cancel := context.WithCancel(context.Background()) ctx02, cancel := context.WithTimeout(ctx01, 1* time.Second) ctx03 := context.WithValue(ctx02, "item", "CPU") defer cancel() for i :=1 ; i <= 5; i++ { go job4(ctx03, i) } time.Sleep(5 * time.Second) if ctx02.Err() != nil { fmt.Println("监控器取消的原因: ", ctx02.Err()) } fmt.Println("主程序退出!!") } func job4(ctx context.Context, number int) { for { select { case <- ctx.Done(): fmt.Printf("监控器%v,监控结束。\n", number) return default: // 获取 item 的值 value := ctx.Value("item") fmt.Printf("监控器%v,正在监控 %v \n", number, value) time.Sleep(2 * time.Second) } } } 监控器5,正在监控 CPU 监控器1,正在监控 CPU 监控器4,正在监控 CPU 监控器3,正在监控 CPU 监控器2,正在监控 CPU 监控器3,监控结束。 监控器1,监控结束。 监控器5,监控结束。 监控器4,监控结束。 监控器2,监控结束。 监控器取消的原因: context deadline exceeded 主程序退出!!
Go是一门带有垃圾回收的现代语言,它抛弃了传统C/C++的开发者需要手动管理内存的方式,实现了内存的主动申请和释放的管理。Go的垃圾回收,让堆和栈的概念对程序员保持透明,它增加的逃逸分析与GC,使得程序员的双手真正地得到了解放,给了开发者更多的精力去关注软件设计本身。 就像《CPU缓存体系对Go程序的影响》文章中说过的一样,“你不一定需要成为一名硬件工程师,但是你确实需要了解硬件的工作原理”。Go虽然帮我们实现了内存的自动管理,我们仍然需要知道其内在原理。内存管理主要包括两个动作:分配与释放。逃逸分析就是服务于内存分配,为了更好理解逃逸分析,我们先谈一下堆栈。 堆和栈 应用程序的内存载体,我们可以简单地将其分为堆和栈。 在Go中,栈的内存是由编译器自动进行分配和释放,栈区往往存储着函数参数、局部变量和调用函数帧,它们随着函数的创建而分配,函数的退出而销毁。一个goroutine对应一个栈,栈是调用栈(call stack)的简称。一个栈通常又包含了许多栈帧(stack frame),它描述的是函数之间的调用关系,每一帧对应一次尚未返回的函数调用,它本身也是以栈形式存放数据。 举例:在一个goroutine里,函数A()正在调用函数B(),那么这个调用栈的内存布局示意图如下。 与栈不同的是,应用程序在运行时只会存在一个堆。狭隘地说,内存管理只是针对堆内存而言的。程序在运行期间可以主动从堆上申请内存,这些内存通过Go的内存分配器分配,并由垃圾收集器回收。 栈是每个goroutine独有的,这就意味着栈上的内存操作是不需要加锁的。而堆上的内存,有时需要加锁防止多线程冲突(为什么要说有时呢,因为Go的内存分配策略学习了TCMalloc的线程缓存思想,他为每个处理器P分配了一个mcache,从mcache分配内存也是无锁的)。 而且,对于程序堆上的内存回收,还需要通过标记清除阶段,例如Go采用的三色标记法。但是,在栈上的内存而言,它的分配与释放非常廉价。简单地说,它只需要两个CPU指令:一个是分配入栈,另外一个是栈内释放。而这,只需要借助于栈相关寄存器即可完成。 另外还有一点,栈内存能更好地利用CPU的缓存策略。因为它们相较于堆而言是更连续的。 逃逸分析 那么,我们怎么知道一个对象是应该放在堆内存,还是栈内存之上呢?可以官网的FAQ(地址:https://golang.org/doc/faq)中找到答案。 如果可以,Go编译器会尽可能将变量分配到到栈上。但是,当编译器无法证明函数返回后,该变量没有被引用,那么编译器就必须在堆上分配该变量,以此避免悬挂指针(dangling pointer)。另外,如果局部变量非常大,也会将其分配在堆上。 那么,Go是如何确定的呢?答案就是:逃逸分析。编译器通过逃逸分析技术去选择堆或者栈,逃逸分析的基本思想如下**:检查变量的生命周期是否是完全可知的,如果通过检查,则可以在栈上分配。否则,就是所谓的逃逸,必须在堆上进行分配。** Go语言虽然没有明确说明逃逸分析规则,但是有以下几点准则,是可以参考的。 逃逸分析是在编译器完成的,这是不同于jvm的运行时逃逸分析; 如果变量在函数外部没有引用,则优先放到栈中; 如果变量在函数外部存在引用,则必定放在堆中; 我们可通过go build -gcflags '-m -l'命令来查看逃逸分析结果,其中-m 打印逃逸分析信息,-l禁止内联优化。下面,我们通过一些案例,来熟悉一些常见的逃逸情况。 情况一:变量类型不确定 package main import "fmt" func main() { a := 666 fmt.Println(a) } 逃逸分析结果如下 $ go build -gcflags '-m -l' main.go # command-line-arguments ./main.go:7:13: ... argument does not escape ./main.go:7:13: a escapes to heap 可以看到,分析结果告诉我们变量a逃逸到了堆上。但是,我们并没有外部引用啊,为啥也会有逃逸呢?为了看到更多细节,可以在语句中再添加一个-m参数。得到信息如下 $ go build -gcflags '-m -m -l' main.go # command-line-arguments ./main.go:7:13: a escapes to heap: ./main.go:7:13: flow: {storage for ... argument} = &{storage for a}: ./main.go:7:13: from a (spill) at ./main.go:7:13 ./main.go:7:13: from ... argument (slice-literal-element) at ./main.go:7:13 ./main.go:7:13: flow: {heap} = {storage for ... argument}: ./main.go:7:13: from ... argument (spill) at ./main.go:7:13 ./main.go:7:13: from fmt.Println(... argument...) (call parameter) at ./main.go:7:13 ./main.go:7:13: ... argument does not escape ./main.go:7:13: a escapes to heap a逃逸是因为它被传入了fmt.Println的参数中,这个方法参数自己发生了逃逸。 func Println(a ...interface{}) (n int, err error) 因为fmt.Println的函数参数为interface类型,编译期不能确定其参数的具体类型,所以将其分配于堆上。 情况二:暴露给外部指针 package main func foo() *int { a := 666 return &a } func main() { _ = foo() } 逃逸分析如下,变量a发生了逃逸。 $ go build -gcflags '-m -m -l' main.go # command-line-arguments ./main.go:4:2: a escapes to heap: ./main.go:4:2: flow: ~r0 = &a: ./main.go:4:2: from &a (address-of) at ./main.go:5:9 ./main.go:4:2: from return &a (return) at ./main.go:5:2 ./main.go:4:2: moved to heap: a 这种情况直接满足我们上述中的原则:变量在函数外部存在引用。这个很好理解,因为当函数执行完毕,对应的栈帧就被销毁,但是引用已经被返回到函数之外。如果这时外部从引用地址取值,虽然地址还在,但是这块内存已经被释放回收了,这就是非法内存,问题可就大了。所以,很明显,这种情况必须分配到堆上。 情况三:变量所占内存较大 func foo() { s := make([]int, 10000, 10000) for i := 0; i < len(s); i++ { s[i] = i } } func main() { foo() } 逃逸分析结果 $ go build -gcflags '-m -m -l' main.go # command-line-arguments ./main.go:4:11: make([]int, 10000, 10000) escapes to heap: ./main.go:4:11: flow: {heap} = &{storage for make([]int, 10000, 10000)}: ./main.go:4:11: from make([]int, 10000, 10000) (too large for stack) at ./main.go:4:11 ./main.go:4:11: make([]int, 10000, 10000) escapes to heap 可以看到,当我们创建了一个容量为10000的int类型的底层数组对象时,由于对象过大,它也会被分配到堆上。这里我们不禁要想一个问题,为啥大对象需要分配到堆上。 这里需要注意,在上文中没有说明的是:在Go中,执行用户代码的goroutine是一种用户态线程,其调用栈内存被称为用户栈,它其实也是从堆区分配的,但是我们仍然可以将其看作和系统栈一样的内存空间,它的分配和释放是通过编译器完成的。与其相对应的是系统栈,它的分配和释放是操作系统完成的。在GMP模型中,一个M对应一个系统栈(也称为M的g0栈),M上的多个goroutine会共享该系统栈。 不同平台上的系统栈最大限制不同。 $ ulimit -s 8192 以x86_64架构为例,它的系统栈大小最大可为8Mb。我们常说的goroutine初始大小为2kb,其实说的是用户栈,它的最小和最大可以在runtime/stack.go中找到,分别是2KB和1GB。 // The minimum size of stack used by Go code _StackMin = 2048 ... var maxstacksize uintptr = 1 << 20 // enough until runtime.main sets it for real 而堆则会大很多,从1.11之后,Go采用了稀疏的内存布局,在Linux的x86-64架构上运行时,整个堆区最大可以管理到256TB的内存。所以,为了不造成栈溢出和频繁的扩缩容,大的对象分配在堆上更加合理。那么,多大的对象会被分配到堆上呢。 通过测试,小菜刀发现该大小为64KB(这在Go内存分配中是属于大对象的范围:>32kb),即s :=make([]int, n, n)中,一旦n达到8192,就一定会逃逸。注意,网上有人通过fmt.Println(unsafe.Sizeof(s))得到s的大小为24字节,就误以为只需分配24个字节的内存,这是错误的,因为实际还有底层数组的内存需要分配。 情况四:变量大小不确定 我们将情况三种的示例,简单更改一下。 package main func foo() { n := 1 s := make([]int, n) for i := 0; i < len(s); i++ { s[i] = i } } func main() { foo() } 得到逃逸分析结果如下 $ go build -gcflags '-m -m -l' main.go # command-line-arguments ./main.go:5:11: make([]int, n) escapes to heap: ./main.go:5:11: flow: {heap} = &{storage for make([]int, n)}: ./main.go:5:11: from make([]int, n) (non-constant size) at ./main.go:5:11 ./main.go:5:11: make([]int, n) escapes to heap 这次,我们在make方法中,没有直接指定大小,而是填入了变量n,这时Go逃逸分析也会将其分配到堆区去。可见,为了保证内存的绝对安全,Go的编译器可能会将一些变量不合时宜地分配到堆上,但是因为这些对象最终也会被垃圾收集器处理,所以也能接受。 总结 本文只列举了逃逸分析的部分例子,实际的情况还有很多,理解思想最重要。这里就不过多列举了。 既然Go的堆栈分配对于开发者来说是透明的,编译器已经通过逃逸分析为对象选择好了分配方式。那么我们还可以从中获益什么? 答案是肯定的,理解逃逸分析一定能帮助我们写出更好的程序。知道变量分配在栈堆之上的差别,那么我们就要尽量写出分配在栈上的代码,堆上的变量变少了,可以减轻内存分配的开销,减小gc的压力,提高程序的运行速度。 所以,你会发现有些Go上线项目,它们在函数传参的时候,并没有传递结构体指针,而是直接传递的结构体。这个做法,虽然它需要值拷贝,但是这是在栈上完成的操作,开销远比变量逃逸后动态地在堆上分配内存少的多。当然该做法不是绝对的,如果结构体较大,传递指针将更合适。 因此,从GC的角度来看,指针传递是个双刃剑,需要谨慎使用,否则线上调优解决GC延时可能会让你崩溃。 参考资料 https://zhuanlan.zhihu.com/p/343562181
今天我们来聊聊golang是如何进行垃圾回收的。我们知道,目前各语言进行垃圾回收的方法有很多,如引用计数、标记清除、分代回收、三色标记等,各种方式都有其特点,GO语言在发展过程中, 其GC算法也是不断改进的。 GO的GC里程碑 v1.3以前:STW golang的垃圾回收算法都非常简陋,其性能也广被诟病:go runtime在一定条件下(内存超过阈值或定期如2min),暂停所有任务的执行,进行mark&sweep操作,操作完成后启动所有任务的执行。在内存使用较多的场景下,go程序在进行垃圾回收时会发生非常明显的卡顿现象(Stop The World)。在对响应速度要求较高的后台服务进程中,这种延迟简直是不能忍受的!这个时期国内外很多在生产环境实践go语言的团队都或多或少踩过gc的坑。当时解决这个问题比较常用的方法是尽快控制自动分配内存的内存数量以减少gc负荷,同时采用手动管理内存的方法处理需要大量及高频分配内存的场景。 v1.3:Mark STW & Sweep 1.3版本中,go runtime分离了mark和sweep操作,和以前一样,也是先暂停所有任务执行并启动mark,mark完成后马上就重新启动被暂停的任务了,而是让sweep任务和普通协程任务一样并行的和其他任务一起执行。如果运行在多核处理器上,go会试图将gc任务放到单独的核心上运行而尽量不影响业务代码的执行。go team自己的说法是减少了50%-70%的暂停时间。 v1.5:三色标记 go 1.5正在实现的垃圾回收器“非分代的、非移动的、并发的、三色的标记清除垃圾收集器”。这种方法的mark操作是可以渐进执行的而不需每次都扫描整个内存空间,可以减少stop the world的时间。 由此可以看到,一路走来直到1.5版本,go的垃圾回收性能也是一直在提升。 v1.8:混合写屏障(hybrid write barrier) 由于标记操作和用户逻辑是并发执行的,用户逻辑会时常生成对象或者改变对象的引用。例如把⼀个对象标记为白色准备回收时,用户逻辑突然引用了它,或者又创建了新的对象。由于对象初始时都看为白色,会被 GC 回收掉,为了解决这个问题,引入了写屏障机制。 GC 对扫描过后的对象使⽤操作系统写屏障功能来监控这段内存。如果这段内存发⽣引⽤改变,写屏障会给垃圾回收期发送⼀个信号,垃圾回收器捕获到信号后就知道这个对象发⽣改变,然后重新扫描这个对象,看看它的引⽤或者被引⽤是否改变。利⽤状态的重置实现当对象状态发⽣改变的时候,依然可以再次其引用的对象。 GO的GC 三色标记 传统的标记清除算法中,垃圾收集器从垃圾收集的根对象出发,递归遍历这些对象指向的子对象并将所有可达的对象标记成存活;标记阶段结束后,垃圾收集器会依次遍历堆中的对象并清除其中的垃圾,整个过程需要标记对象的存活状态,用户程序在垃圾收集的过程中也不能执行,我们需要用到更复杂的机制来解决 STW 的问题,这就出现了三色标记法。 三色标记算法将程序中的对象分成白色、黑色和灰色三类: 白色对象:潜在的垃圾,其内存可能会被垃圾收集器回收; 黑色对象:活跃的对象,包括不存在任何引用外部指针的对象以及从根对象可达的对象; 灰色对象:活跃的对象,因为存在指向白色对象的外部指针,垃圾收集器会扫描这些对象的子对象; 在垃圾收集器开始工作时,程序中不存在任何的黑色对象,垃圾收集的根对象会被标记成灰色,垃圾收集器只会从灰色对象集合中取出对象开始扫描,当灰色集合中不存在任何对象时,标记阶段就会结束。 三色标记垃圾收集器的工作原理很简单,我们可以将其归纳成以下几个步骤: 从灰色对象的集合中选择一个灰色对象并将其标记成黑色; 将黑色对象指向的所有对象都标记成灰色,保证该对象和被该对象引用的对象都不会被回收; 重复上述两个步骤直到对象图中不存在灰色对象。 当三色的标记清除的标记阶段结束之后,应用程序的堆中就不存在任何的灰色对象,我们只能看到黑色的存活对象以及白色的垃圾对象,垃圾收集器可以回收这些白色的垃圾,下面是使用三色标记垃圾收集器执行标记后的堆内存,堆中只有对象 D 为待回收的垃圾: 因为用户程序可能在标记执行的过程中修改对象的指针,所以三色标记清除算法本身是不可以并发或者增量执行的,它仍然需要 STW,在如下所示的三色标记过程中,用户程序建立了从 A 对象到 D 对象的引用,但是因为程序中已经不存在灰色对象了,所以 D 对象会被垃圾收集器错误地回收。 本来不应该被回收的对象却被回收了,这在内存管理中是非常严重的错误,我们将这种错误称为悬挂指针,即指针没有指向特定类型的合法对象,影响了内存的安全性,想要并发或者增量地标记对象还是需要使用屏障技术。 整个流程如下: 混合写屏障 想要在并发或者增量的标记算法中保证正确性,我们需要达成以下两种三色不变性(Tri-color invariant)中的一种: 强三色不变性:黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象; 弱三色不变性:黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径。 上图分别展示了遵循强三色不变性和弱三色不变性的堆内存,遵循上述两个不变性中的任意一个,我们都能保证垃圾收集算法的正确性,而屏障技术就是在并发或者增量标记过程中保证三色不变性的重要技术。 垃圾收集中的屏障技术更像是一个钩子方法,它是在用户程序读取对象、创建新对象以及更新对象指针时执行的一段代码,根据操作类型的不同,我们可以将它们分成读屏障(Read barrier)和写屏障(Write barrier)两种,因为读屏障需要在读操作中加入代码片段,对用户程序的性能影响很大,所以编程语言往往都会采用写屏障保证三色不变性。 Go 语言在 v1.8 组合 Dijkstra 插入写屏障和 Yuasa 删除写屏障构成混合写屏障,该写屏障会将被覆盖的对象标记成灰色并在当前栈没有扫描时将新对象也标记成灰色。 为了移除栈的重扫描过程,除了引入混合写屏障之外,在垃圾收集的标记阶段,我们还需要将创建的所有新对象都标记成黑色,防止新分配的栈内存和堆内存中的对象被错误地回收,因为栈内存在标记阶段最终都会变为黑色,所以不再需要重新扫描栈空间。 增量和并发 传统的垃圾收集算法会在垃圾收集的执行期间暂停应用程序,一旦触发垃圾收集,垃圾收集器会抢占 CPU 的使用权占据大量的计算资源以完成标记和清除工作,然而很多追求实时的应用程序无法接受长时间的 STW。 为了减少应用程序暂停的最长时间和垃圾收集的总暂停时间,我们会使用下面的策略优化现代的垃圾收集器: 增量垃圾收集:增量地标记和清除垃圾,降低应用程序暂停的最长时间; 并发垃圾收集:利用多核的计算资源,在用户程序执行时并发标记和清除垃圾; 因为增量和并发两种方式都可以与用户程序交替运行,所以我们需要使用屏障技术保证垃圾收集的正确性;与此同时,应用程序也不能等到内存溢出时触发垃圾收集,因为当内存不足时,应用程序已经无法分配内存,这与直接暂停程序没有什么区别,增量和并发的垃圾收集需要提前触发并在内存不足前完成整个循环,避免程序的长时间暂停。 增量收集 增量式(Incremental)的垃圾收集是减少程序最长暂停时间的一种方案,它可以将原本时间较长的暂停时间切分成多个更小的 GC 时间片,虽然从垃圾收集开始到结束的时间更长了,但是这也减少了应用程序暂停的最大时间: 需要注意的是,增量式的垃圾收集需要与三色标记法一起使用,为了保证垃圾收集的正确性,我们需要在垃圾收集开始前打开写屏障,这样用户程序修改内存都会先经过写屏障的处理,保证了堆内存中对象关系的强三色不变性或者弱三色不变性。虽然增量式的垃圾收集能够减少最大的程序暂停时间,但是增量式收集也会增加一次 GC 循环的总时间,在垃圾收集期间,因为写屏障的影响用户程序也需要承担额外的计算开销,所以增量式的垃圾收集也不是只带来好处的,但是总体来说还是利大于弊。 并发收集 并发(Concurrent)的垃圾收集不仅能够减少程序的最长暂停时间,还能减少整个垃圾收集阶段的时间,通过开启读写屏障、利用多核优势与用户程序并行执行,并发垃圾收集器确实能够减少垃圾收集对应用程序的影响: 虽然并发收集器能够与用户程序一起运行,但是并不是所有阶段都可以与用户程序一起运行,部分阶段还是需要暂停用户程序的,不过与传统的算法相比,并发的垃圾收集可以将能够并发执行的工作尽量并发执行;当然,因为读写屏障的引入,并发的垃圾收集器也一定会带来额外开销,不仅会增加垃圾收集的总时间,还会影响用户程序,这是我们在设计垃圾收集策略时必须要注意的。 GC的时机 运行时会通过如下所示的 runtime.gcTrigger.test 方法决定是否需要触发垃圾收集,当满足触发垃圾收集的基本条件时 — 允许垃圾收集、程序没有崩溃并且没有处于垃圾收集循环,该方法会根据三种不同方式触发进行不同的检查: func (t gcTrigger) test() bool { if !memstats.enablegc || panicking != 0 || gcphase != _GCoff { return false } switch t.kind { case gcTriggerHeap: return memstats.heap_live >= memstats.gc_trigger case gcTriggerTime: if gcpercent < 0 { return false } lastgc := int64(atomic.Load64(&memstats.last_gc_nanotime)) return lastgc != 0 && t.now-lastgc > forcegcperiod case gcTriggerCycle: return int32(t.n-work.cycles) > 0 } return true } 1、gcTriggerHeap :堆内存的分配达到达控制器计算的触发堆大小; 2、gcTriggerTime :如果一定时间内没有触发,就会触发新的循环,该出发条件由 runtime.forcegcperiod 变量控制,默认为 2 分钟; 3、gcTriggerCycle:如果当前没有开启垃圾收集,则触发新的循环; 4、runtime.gcpercent 是触发垃圾收集的内存增长百分比,默认情况下为 100,即堆内存相比上次垃圾收集增长 100% 时应该触发 GC,并行的垃圾收集器会在到达该目标前完成垃圾收集。 用于开启垃圾收集的方法 runtime.gcStart 会接收一个 runtime.gcTrigger 类型的结构,所有出现 runtime.gcTrigger 结构体的位置都是触发垃圾收集的代码: runtime.sysmon 和 runtime.forcegchelper :后台运行定时检查和垃圾收集; runtime.GC :用户程序手动触发垃圾收集; runtime.mallocgc :申请内存时根据堆大小触发垃圾收集。
哈希表的意义不言而喻,它能提供 O(1) 复杂度的读写性能,所以主流编程语言中都内置有哈希表。 哈希表的关键在于哈希函数, 好的哈希函数能减少哈希碰撞,提供最优秀的读写性能。 哈希碰撞 因为没有完美的哈希函数, 所以哈希碰撞不可避免,一般有开放寻址法和拉链法,其中拉链法是主流 开放寻址法:当向哈希表写入新的数据时,如果发生了冲突,就会将键值对写入到下一个索引不为空的位置 拉链法:拉链法一般使用数组和链表组成,数据经过哈希函数得到一个桶时,先遍历桶中的链表,存在相同的键值对,则更新,不存在则在链表末尾追加新键值对 Go 表示哈希表的数据结构 type hmap struct { // 表示哈希表中元素的数量 count int flags uint8 // 表示哈希表中桶的数量, len(buckets) = 2^B B uint8 noverflow uint16 // hash函数的种子 hash0 uint32 buckets unsafe.Pointer // 用于在扩容时保存之前 buckets // 因为每次扩容都是2的倍数,所以 bucket = 2oldbuckets oldbuckets unsafe.Pointer nevacuate uintptr extra *mapextra } type mapextra struct { overflow *[]*bmap oldoverflow *[]*bmap nextOverflow *bmap } 哈希表 hmap 的桶是 bmap,每个 bmap 都能存储 8 个键值对,单个桶装满时会使用 nextOverflow 桶存储溢出的数据 type bmap struct { // 存储了键的哈希的高 8 位 // 通过比较不同键的哈希的高 8 位可以减少访问键值对次数以提高性能 tophash [bucketCnt]uint8 } 访问 map 中的数据 如上图所示,每一个桶都是一整片的内存空间,当发现桶中的 tophash 与传入键的 tophash 匹配之后,我们会通过指针和偏移量获取哈希中存储的键 keys[0] 并与 key 比较,如果两者相同就会获取目标值的指针 values[0] 并返回 向 map 写入数据 函数会根据传入的键拿到对应的哈希和桶,通过遍历比较桶中存储的 tophash 和键的哈希,如果找到了相同结果就会返回目标位置的地址,获得目标地址之后会通过算术计算寻址获得键值对 k 和 val, 如果当前键值对在哈希中不存在,哈希会为新键值对规划存储的内存地址,这期间只会返回内存地址,真正的赋值操作是在编译期间插入的。 00018 (+5) CALL runtime.mapassign_fast64(SB) 00020 (5) MOVQ 24(SP), DI ;; DI = &value 00026 (5) LEAQ go.string."88"(SB), AX ;; AX = &"88" 00027 (5) MOVQ AX, (DI) ;; *DI = AX 我们通过 LEAQ 指令将字符串的地址存储到寄存器 AX 中,MOVQ 指令将字符串 “88” 存储到了目标地址上完成了这次哈希的写入 扩容 随着哈希表中元素的逐渐增加,哈希表的性能会逐渐恶化,当装载因子 > 6.5 时, 或者 哈希表创建了太多的溢出桶, 会触发扩容 装载因子 = 元素数量 / 桶数量 哈希表在扩容的过程中会创建一组新桶和溢出桶,随后将原油的桶数组设置到 oldbuckets 上,将新桶设置到 buckets 上,新计算旧桶内元素的哈希到新桶上, 在扩容期间访问哈希表时会使用旧桶,整个期间元素再分配的过程也是在调用写操作时增量进行的,不会造成性能的瞬时巨大抖动
1.1 链表 举单链表的例子,双向链表同理只是多了pre指针。 定义单链表结构: type LinkNode struct { Data int64 NextNode *LinkNode } 构造链表及打印链表: func main() { node := new(LinkNode) node.Data = 1 node1 := new(LinkNode) node1.Data = 2 node.NextNode = node1 // node1 链接到 node 节点上 node2 := new(LinkNode) node2.Data = 3 node1.NextNode = node2 // node2 链接到 node1 节点上 // 顺序打印。把原链表头结点赋值到新的NowNode上 // 这样仍然保留了原链表头结点node不变 nowNode := node for nowNode != nil { fmt.Println(nowNode.Data) // 获取下一个节点。链表向下滑动 nowNode = nowNode.NextNode } } 1.2 可变数组 可变数组在各种语言中都非常常用,在golang中,可变数组语言本身已经实现,就是我们的切片slice。 1.3 栈和队列 1.3.1 原生切片实现栈和队列 栈:先进后出,后进先出,类似弹夹 队列:先进先出 golang中,实现并发不安全的栈和队列,非常简单,我们直接使用原生切片即可。 1.3.1.1 切片原生栈实现 func main() { // 用切片制作一个栈 var stack []int // 元素1 入栈 stack = append(stack, 1, 5, 7, 2) // 栈取出最近添加的数据。例如[1,5,7,2] ,len = 4 x := stack[len(stack)-1] // 2 // 切掉最近添加的数据,上一步和这一步模仿栈的pop。 stack = stack[:len(stack)-1] // [1,5,7] fmt.Printf("%d", x) } 1.3.1.2 切片原生队列实现 func main() { // 用切片模仿队列 var queue []int // 进队列 queue = append(queue, 1, 5, 7, 2) // 队头弹出,再把队头切掉,模仿队列的poll操作 cur := queue[0] queue = queue[1:] fmt.Printf("%d", cur) } 1.3.2 并发安全的栈和队列 1.3.2.1 切片实现并发安全的栈 并发安全的栈 // 数组栈,后进先出 type Mystack struct { array []string // 底层切片 size int // 栈的元素数量 lock sync.Mutex // 为了并发安全使用的锁 } 入栈 // 入栈 func (stack *Mytack) Push(v string) { stack.lock.Lock() defer stack.lock.Unlock() // 放入切片中,后进的元素放在数组最后面 stack.array = append(stack.array, v) // 栈中元素数量+1 stack.size = stack.size + 1 } 出栈 1、如果切片偏移量向前移动 stack.array[0 : stack.size-1],表明最后的元素已经不属于该数组了,数组变相的缩容了。此时,切片被缩容的部分并不会被回收,仍然占用着空间,所以空间复杂度较高,但操作的时间复杂度为:O(1)。 2、如果我们创建新的数组 newArray,然后把老数组的元素复制到新数组,就不会占用多余的空间,但移动次数过多,时间复杂度为:O(n)。 func (stack *Mystack) Pop() string { stack.lock.Lock() defer stack.lock.Unlock() // 栈中元素已空 if stack.size == 0 { panic("empty") } // 栈顶元素 v := stack.array[stack.size-1] // 切片收缩,但可能占用空间越来越大 //stack.array = stack.array[0 : stack.size-1] // 创建新的数组,空间占用不会越来越大,但可能移动元素次数过多 newArray := make([]string, stack.size-1, stack.size-1) for i := 0; i < stack.size-1; i++ { newArray[i] = stack.array[i] } stack.array = newArray // 栈中元素数量-1 stack.size = stack.size - 1 return v } 获取栈顶元素 // 获取栈顶元素 func (stack *Mystack) Peek() string { // 栈中元素已空 if stack.size == 0 { panic("empty") } // 栈顶元素值 v := stack.array[stack.size-1] return v } 获取栈大小和判定是否为空 // 栈大小 func (stack *Mystack) Size() int { return stack.size } // 栈是否为空 func (stack *Mystack) IsEmpty() bool { return stack.size == 0 } 1.3.2.2 切片实现并发安全的队列 队列结构 // 数组队列,先进先出 type Myqueue struct { array []string // 底层切片 size int // 队列的元素数量 lock sync.Mutex // 为了并发安全使用的锁 } 入队 // 入队 func (queue *Myqueue) Add(v string) { queue.lock.Lock() defer queue.lock.Unlock() // 放入切片中,后进的元素放在数组最后面 queue.array = append(queue.array, v) // 队中元素数量+1 queue.size = queue.size + 1 } 出队 1、原地挪位,依次补位 queue.array[i-1] = queue.array[i],然后数组缩容:queue.array = queue.array[0 : queue.size-1],但是这样切片缩容的那部分内存空间不会释放。 2、创建新的数组,将老数组中除第一个元素以外的元素移动到新数组。 // 出队 func (queue *Myqueue) Remove() string { queue.lock.Lock() defer queue.lock.Unlock() // 队中元素已空 if queue.size == 0 { panic("empty") } // 队列最前面元素 v := queue.array[0] /* 直接原位移动,但缩容后继的空间不会被释放 for i := 1; i < queue.size; i++ { // 从第一位开始进行数据移动 queue.array[i-1] = queue.array[i] } // 原数组缩容 queue.array = queue.array[0 : queue.size-1] */ // 创建新的数组,移动次数过多 newArray := make([]string, queue.size-1, queue.size-1) for i := 1; i < queue.size; i++ { // 从老数组的第一位开始进行数据移动 newArray[i-1] = queue.array[i] } queue.array = newArray // 队中元素数量-1 queue.size = queue.size - 1 return v } 1.4 字典Map和集合Set 1.4.1 Map 字典也是程序语言经常使用的结构,golang中的字典是其自身实现的map结构。具体操作可以查看语言api 并发安全的map,可以定义结构,结构中有一个map成员和一个锁变量成员,参考并发安全的栈和队列的实现。go语言也实现了一个并发安全的map,具体参考sync.map的api 1.4.2 Set 我们可以借助map的特性,实现一个Set结构。 Set结构 map的值我们不适用,定义为空的结构体struct{} // 集合结构体 type Set struct { m map[int]struct{} // 用字典来实现,因为字段键不能重复 len int // 集合的大小 sync.RWMutex // 锁,实现并发安全 } 初始化Set // 新建一个空集合 func NewSet(cap int64) *Set { temp := make(map[int]struct{}, cap) return &Set{ m: temp, } } 往set中添加一个元素 // 增加一个元素 func (s *Set) Add(item int) { s.Lock() defer s.Unlock() s.m[item] = struct{}{} // 实际往字典添加这个键 s.len = len(s.m) // 重新计算元素数量 } 删除一个元素 // 移除一个元素 func (s *Set) Remove(item int) { s.Lock() s.Unlock() // 集合没元素直接返回 if s.len == 0 { return } delete(s.m, item) // 实际从字典删除这个键 s.len = len(s.m) // 重新计算元素数量 } 查看元素是否在集合set中 // 查看是否存在元素 func (s *Set) Has(item int) bool { s.RLock() defer s.RUnlock() _, ok := s.m[item] return ok } 查看集合大小 // 查看集合大小 func (s *Set) Len() int { return s.len } 查看集合是否为空 // 集合是够为空 func (s *Set) IsEmpty() bool { if s.Len() == 0 { return true } return false } 清除集合所有元素 // 清除集合所有元素 func (s *Set) Clear() { s.Lock() defer s.Unlock() s.m = map[int]struct{}{} // 字典重新赋值 s.len = 0 // 大小归零 } 将集合转化为切片 func (s *Set) List() []int { s.RLock() defer s.RUnlock() list := make([]int, 0, s.len) for item := range s.m { list = append(list, item) } return list } 1.5 二叉树 二叉树:每个节点最多只有两个儿子节点的树。 满二叉树:叶子节点与叶子节点之间的高度差为 0 的二叉树,即整棵树是满的,树呈满三角形结构。在国外的定义,非叶子节点儿子都是满的树就是满二叉树。我们以国内为准。 完全二叉树:完全二叉树是由满二叉树而引出来的,设二叉树的深度为 k,除第 k 层外,其他各层的节点数都达到最大值,且第 k 层所有的节点都连续集中在最左边。 二叉树结构定义 // 二叉树 type TreeNode struct { Data string // 节点用来存放数据 Left *TreeNode // 左子树 Right *TreeNode // 右字树 } 树的遍历 1、先序遍历:先访问根节点,再访问左子树,最后访问右子树。 2、后序遍历:先访问左子树,再访问右子树,最后访问根节点。 3、中序遍历:先访问左子树,再访问根节点,最后访问右子树。 4、层次遍历:每一层从左到右访问每一个节点。 // 先序遍历 func PreOrder(tree *TreeNode) { if tree == nil { return } // 先打印根节点 fmt.Print(tree.Data, " ") // 再打印左子树 PreOrder(tree.Left) // 再打印右字树 PreOrder(tree.Right) } // 中序遍历 func MidOrder(tree *TreeNode) { if tree == nil { return } // 先打印左子树 MidOrder(tree.Left) // 再打印根节点 fmt.Print(tree.Data, " ") // 再打印右字树 MidOrder(tree.Right) } // 后序遍历 func PostOrder(tree *TreeNode) { if tree == nil { return } // 先打印左子树 MidOrder(tree.Left) // 再打印右字树 MidOrder(tree.Right) // 再打印根节点 fmt.Print(tree.Data, " ") } 按层遍历: func Level(head *TreeNode) { if head == nil { return } // 用切片模仿队列 var queue []*TreeNode queue = append(queue, head) for len(queue) != 0 { // 队头弹出,再把队头切掉,模仿队列的poll操作 cur := queue[0] queue = queue[1:] fmt.Printf("%d", (*cur).Data) // 当前节点有左孩子,加入左孩子进队列 if cur.Left != nil { queue = append(queue, cur.Left) } // 当前节点有右孩子,加入右孩子进队列 if cur.Right != nil { queue = append(queue, cur.Right) } } }
本文翻译自《A visual guide to Go Memory Allocator from scratch (Golang)》。 当我刚开始尝试了解Go的内存分配器时,我发现这真是一件可以令人发疯的事情,因为所有事情似乎都像一个神秘的黑盒(让我无从下手)。由于几乎所有技术魔法都隐藏在抽象之下,因此您需要逐一剥离这些抽象层才能理解它们。 在这篇文章中,我们就来这么做(剥离抽象层去了解隐藏在其下面的技术魔法)。如果您想了解有关Go内存分配器的知识,那么本篇文章正适合您。 一. 物理内存(Physical Memory)和虚拟内存(Virtual Memory) 每个内存分配器都需要使用由底层操作系统管理的虚拟内存空间(Virtual Memory Space)。让我们看看它是如何工作的吧。 物理存储单元的简单图示(不精确的表示) 单个存储单元(工作流程)的简要介绍: 地址线(address line, 作为开关的晶体管)提供了访问电容器的入口(数据到数据线(data line))。 当地址线中有电流流动时(显示为红色),数据线可能会写入电容器,因此电容器已充电,并且存储的逻辑值为“1”。 当地址线没有电流流动(显示为绿色)时,数据线可能不会写入电容器,因此电容器未充电,并且存储的逻辑值为“0”。 当处理器(CPU)需要从内存(RAM)中“读取”一个值时,会沿着“地址线”发送电流(关闭开关)。如果电容器保持电荷,则电流流经“ DATA LINE”(数据线)得到的值为1;否则,没有电流流过数据线,电容器将保持未充电状态,得到的值为0。 物理内存单元如何与CPU交互的简单说明 数据总线(Data Bus):用于在CPU和物理内存之间传输数据。 让我们讨论一下地址线(Address Line)和可寻址字节(Addressable Bytes)。 CPU和物理内存之间的地址线的表示 DRAM中的每个“字节(BYTE)”都被分配有唯一的数字标识符(地址)。 但“物理字节的表示 != 地址线的数量”。(例如:16位Intel 8088,PAE) 每条“地址线”都可以发送1bit值,因此它可以表示给定字节地址中指定“bit”。 在图中,我们有32条地址线。因此,每个可寻址字节都将拥有一个“32bit”的地址。 [ 00000000000000000000000000000000 ] — 低内存地址 [ 11111111111111111111111111111111 ] — 高内存地址 4.由于每个字节都有一个32bit地址,所以我们的地址空间由2的32次方个可寻址字节(即4GB)组成。 因此,可寻址字节取决于地址线的总量,对于64位地址线(x86–64 CPU),其可寻址字节为2的64次方个,但是大多数使用64位指针的体系结构实际上使用48位地址线(AMD64 )和42位地址线(英特尔),理论上支持256TB的物理RAM(Linux 在x86–64上每个进程支持128TB以及4级页表(page table)和Windows每个进程则支持192TB) 由于实际物理内存的限制,因此每个进程都在其自己的内存沙箱中运行-“虚拟地址空间”,即虚拟内存。 该虚拟地址空间中字节的地址不再与处理器在地址总线上放置的地址相同。因此,必须建立转换数据结构和系统,以将虚拟地址空间中的字节映射到物理内存地址上的字节。 虚拟地址长什么样呢? 虚拟地址空间表示 因此,当CPU执行引用内存地址的指令时。第一步是将VMA(virtual memory address)中的逻辑地址转换为线性地址(liner address)。 这个翻译工作由内存管理单元MMU(Memory Management Unit) 完成。 这不是物理图,仅是描述。为了简化,不包括地址翻译过程 由于此逻辑地址太大而无法单独管理(取决于各种因素),因此将通过页(page)对其进行管理。当必要的分页构造被激活后,虚拟内存空间将被划分为称为页的较小区域(大多数OS上页大小为4KB,可以更改)。它是虚拟内存中用于内存管理的最小单位。虚拟内存不存储任何内容,仅简单地将程序的地址空间映射到真实的物理内存空间上。 单个进程仅将VMA(虚拟内存地址)视为其地址。这样,当我们的程序请求更多“堆内存(heap memory)”时会发生什么呢? 一段简单的用户请求更多堆内存的汇编代码 增加堆内存 程序通过brk(sbrk/mmap等)系统调用请求更多内存。但内核实际上仅是更新了堆的VMA。 注意:此时,实际上并没有分配任何页帧,并且新页面也没有在物理内存存在。这也是VSZ与RSS之间的差异点。 二. 内存分配器 有了“虚拟地址空间”的基本概述以及堆内存增加的理解之后,内存分配器现在变得更容易说明了。 如果堆中有足够的空间来满足我们代码中的内存请求,则内存分配器可以在内核不参与的情况下满足该请求,否则它会通过系统调用brk扩大堆,通常会请求大量内存。(默认情况下,对于malloc而言,大量的意思是 > MMAP_THRESHOLD字节-128kB)。 但是,内存分配器的责任不仅仅是更新brk地址。其中一个主要的工作则是如何的降低内外部的内存碎片以及如何快速分配内存块。考虑按p1~p4的顺序,先使用函数malloc在程序中请求连续内存块,然后使用函数free(pointer)释放内存。 外部内存碎片演示 在第4步,即使我们有足够的内存块,我们也无法满足对6个连续内存块分配的请求,从而导致内存碎片。 那么如何减少内存碎片呢?这个问题的答案取决于底层库使用的特定的内存分配算法。 我们将研究TCMalloc内存分配器,Go内存分配器采用的就是该内存分配器模型。 三. TCMalloc TCMalloc(thread cache malloc)的核心思想是将内存划分为多个级别,以减少锁的粒度。在TCMalloc内部,内存管理分为两部分:线程内存和页堆(page heap)。 线程内存(thread memory) 每个内存页分为多级固定大小的“空闲列表”,这有助于减少碎片。因此,每个线程都会有一个无锁的小对象缓存,这使得在并行程序下分配小对象(<= 32k)非常高效。 线程缓存(每个线程拥有此线程本地线程缓存) 页堆(page heap) TCMalloc管理的堆由页集合组成,其中一组连续页的集合可以用span表示。当分配的对象大于32K时,将使用页堆进行分配。 页堆(用于span管理) 如果没有足够的内存来分配小对象,内存分配器就会转到页堆以获取内存。如果还没有足够的内存,页堆将从操作系统中请求更多内存。 由于这种分配模型维护了一个用户空间的内存池,因此极大地提高了内存分配和释放的效率。 注意:尽管go内存分配器最初是基于tcmalloc的,但是现在已经有了很大的不同。 四. Go内存分配器 我们知道Go运行时会将Goroutines(G)调度到逻辑处理器(P)上执行。同样,基于TCMalloc模型的Go还将内存页分为67个不同大小级别。 Go中的内存块的大小级别 Go默认采用8192B大小的页。如果这个页被分成大小为1KB的块,我们一共将拿到8块这样的页: 将8 KB页面划分为1KB的大小等级(在Go中,页的粒度保持为8KB) Go中的这些页面运行也通过称为mspan的结构进行管理。 选择要分配给每个尺寸级别的尺寸类别和页面计数(将页面数分成给定尺寸的对象),以便将分配请求圆整(四舍五入)到下一个尺寸级别最多浪费12.5% mspan 简而言之,它是一个双向链表对象,其中包含页面的起始地址,它具有的页面的span类以及它包含的页面数。 Go内存分配器中mspan的表示形式 mcache 与TCMalloc一样,Go为每个逻辑处理器(P)提供了一个称为mcache的本地内存线程缓存,因此,如果Goroutine需要内存,它可以直接从mcache中获取它而无需任何锁,因为在任何时间点只有一个Goroutine在逻辑处理器(P)上运行。 mcache包含所有级别大小的mspan作为缓存。 Go中P,mcache和mspan之间的关系 由于每个P拥有一个mcache,因此从mcache进行分配时无需加锁。 对于每个级别,都有两种类型。 scan —包含指针的对象。 noscan —不包含指针的对象。 这种方法的好处之一是在进行垃圾收集时,GC无需遍历noscan对象。 什么Go mcache? 对象大小<= 32K字节的分配将直接交给mcache,后者将使用对应大小级别的mspan应对 当mcache没有可用插槽(slot)时会发生什么? 从mcentral mspan list中获取一个对应大小级别的新的mspan。 mcentral mcentral对象集合了所有给定大小级别的span,每个mcentral是两个mspan列表。 空的mspanList — 没有空闲内存的mspan或缓存在mcache中的mspan的列表 非空mspanList – 仍有空闲内存的span列表。 当从mcentral请求新的Span时,它将从非空mspanList列表中获取(如果可用)。这两个列表之间的关系如下:当请求新的span时,该请求从非空列表中得到满足,并且该span被放入空列表中。释放span后,将根据span中空闲对象的数量将其放回非空列表。 mcentral表示 每个mcentral结构都在mheap中维护。 mheap mheap是在Go中管理堆的对象,且只有一个全局mheap对象。它拥有虚拟地址空间。 mheap的表示 从上图可以看出,mheap具有一个mcentral数组。此数组包含每个大小级别span的mcentral。 central [numSpanClasses]struct { mcentral mcentral pad [sys.CacheLineSize unsafe.Sizeof(mcentral{})%sys.CacheLineSize]byte } 由于我们对每个级别的span都有mcentral,因此当mcache从mcentral请求一个mspan时,仅涉及单个mcentral级别的锁,因此其他mache的不同级别mspan的请求也可以同时被处理。 padding确保将MCentrals以CacheLineSize字节间隔开,以便每个MCentral.lock获得自己的缓存行,以避免错误的共享问题。 那么,当该mcentral列表为空时会发生什么?mcentral将从mheap获取页以用于所需大小级别span的分配。 free [_MaxMHeapList]mSpanList:这是一个spanList数组。每个spanList中的mspan由1〜127(_MaxMHeapList-1)页组成。例如,free[3]是包含3个页面的mspan的链接列表。Free表示空闲列表,即尚未进行对象分配。它对应于忙碌列表(busy list)。 freelarge mSpanList:mspans列表。每个mspan的页数大于127。Go内存分配器以mtreap数据结构来维护它。对应busyLarge。 大小> 32k的对象是一个大对象,直接从mheap分配。这些较大的请求需要中央锁(central lock),因此在任何给定的时间点只能满足一个P的请求 五. 对象分配流程 大小> 32k是一个大对象,直接从mheap分配。 大小<16B,使用mcache的tiny分配器分配 大小在16B〜32k之间,计算要使用的sizeClass,然后在mcache中使用相应的sizeClass的块分配 如果与mcache对应的sizeClass没有可用的块,则向mcentral发起请求。 如果mcentral也没有可用的块,则向mheap请求。mheap使用BestFit查找最合适的mspan。如果超出了申请的大小,则会根据需要进行划分,以返回用户所需的页面数。其余页面构成一个新的mspan,并返回mheap空闲列表。 如果mheap没有可用的span,请向操作系统申请一组新的页(至少1MB)。 但是Go在OS级别分配的页面甚至更大(称为arena)。分配大量页面将分摊与操作系统进行对话的成本。 所有请求的堆内存都来自于arena。让我们看看arena是什么。 六. Go虚拟内存 让我们看一个简单go程序的内存。 func main(){ for {} } 程序的进程状态 因此,即使是简单的go程序,占用的虚拟空间也是大约100MB而RSS只有696kB。让我们尝试首先找出这种差异的原因。 map和smap统计信息 因此,内存区域的大小约为〜2MB, 64MB and 32MB。这些是什么? Arena 原来,Go中的虚拟内存布局由一组arena组成。初始堆映射是一个arena,即64MB(基于go 1.11.5)。 当前在不同系统上的arena大小。 因此,当前根据程序需要,内存以较小的增量进行映射,并且它以一个arena(〜64MB)开始。 这是可变的。早期的go保留连续的虚拟地址,在64位系统上,arena大小为512 GB。(如果分配足够大并且被mmap拒绝,会发生什么?) 这个arena集合是我们所谓的堆。Go以8192B大小粒度的页面管理每个arena。 单个arena(64 MB)。 Go还有两个span和bitmap块。它们都在堆外分配,并存储着每个arena的元数据。它主要在垃圾收集期间使用(因此我们现在将其保留)。 我们刚刚讨论过的Go中的内存分配策略,但这些也仅是奇妙多样的内存分配的一些皮毛。 但是,Go内存管理的总体思路是使用不同的内存结构为不同大小的对象使用不同的缓存级别内存来分配内存。将从操作系统接收的单个连续地址块分割为多级缓存以减少锁的使用,从而提高内存分配效率,然后根据指定的大小分配内存分配,从而减少内存碎片,并在内存释放houhou有利于更快的GC。 现在,我将向您提供此Go Memory Allocator的全景图。 运行时内存分配器的可视化全景图。
前言 面向对象程序设计是一种计算机编程架构,英文全称:Object Oriented Programming,简称OOP。OOP的一条基本原则是计算机程序由单个能够起到子程序作用的单元或对象组合而成,OOP达到了软件工程的三个主要目标:重用性、灵活性和扩展性。OOP=对象+类+继承+多态+消息,其中核心概念就是类和对象。 这一段话在网上介绍什么是面向对象编程时经常出现,大多数学习Go语言的朋友应该也都是从C++、python、java转过来的,所以对面向对象编程的理解应该很深了,所以本文就没必要介绍概念了,重点来看一下如何使用Go语言来实现面向对象编程的编程风格。 类 Go语言本身就不是一个面向对象的编程语言,所以Go语言中没有类的概念,但是他是支持类型的,因此我们可以使用struct类型来提供类似于java中的类的服务,可以定义属性、方法、还能定义构造器。来看个例子: type Hero struct { Name string Age uint64 } func NewHero() *Hero { return &Hero{ Name: "盖伦", Age: 18, } } func (h *Hero) GetName() string { return h.Name } func (h *Hero) GetAge() uint64 { return h.Age } func main() { h := NewHero() print(h.GetName()) print(h.GetAge()) } 这就一个简单的 "类"的使用,这个类名就是Hero,其中Name、Age就是我们定义的属性,GetName、GetAge这两个就是我们定义的类的方法,NewHero就是定义的构造器。因为Go语言的特性问题,构造器只能够依靠我们手动来实现。 这里方法的实现是依赖于结构体的值接收者、指针接收者的特性来实现的。 封装 封装是把一个对象的属性私有化,同时提供一些可以被外界访问的属性和方法,如果不想被外界访问,我们大可不必提供方法给外界访问。在Go语言中实现封装我们可以采用两种方式: Go语言支持包级别的封装,小写字母开头的名称只能在该包内程序中可见,所以我们如果不想暴露一些方法,可以通过这种方式私有包中的内容,这个理解比较简单,就不举例子了。 Go语言可以通过 type 关键字创建新的类型,所以我们为了不暴露一些属性和方法,可以采用创建一个新类型的方式,自己手写构造器的方式实现封装,举个例子: type IdCard string func NewIdCard(card string) IdCard { return IdCard(card) } func (i IdCard) GetPlaceOfBirth() string { return string(i[:6]) } func (i IdCard) GetBirthDay() string { return string(i[6:14]) } 声明一个新类型IdCard,本质是一个string类型,NewIdCard用来构造对象, GetPlaceOfBirth、GetBirthDay就是封装的方法。 继承 Go并没有原生级别的继承支持,不过我们可以使用组合的方式来实现继承,通过结构体内嵌类型的方式实现继承,典型的应用是内嵌匿名结构体类型和内嵌匿名接口类型,这两种方式还有点细微差别: 内嵌匿名结构体类型:将父结构体嵌入到子结构体中,子结构体拥有父结构体的属性和方法,但是这种方式不能支持参数多态。 内嵌匿名接口类型:将接口类型嵌入到结构体中,该结构体默认实现了该接口的所有方法,该结构体也可以对这些方法进行重写,这种方式可以支持参数多态,这里要注意一个点是如果嵌入类型没有实现所有接口方法,会引起编译时未被发现的运行错误。 内嵌匿名结构体类型实现继承的例子 type Base struct { Value string } func (b *Base) GetMsg() string { return b.Value } type Person struct { Base Name string Age uint64 } func (p *Person) GetName() string { return p.Name } func (p *Person) GetAge() uint64 { return p.Age } func check(b *Base) { b.GetMsg() } func main() { m := Base{Value: "I Love You"} p := &Person{ Base: m, Name: "asong", Age: 18, } fmt.Print(p.GetName(), " ", p.GetAge(), " and say ",p.GetMsg()) //check(p) } 上面注释掉的方法就证明了不能进行参数多态。 内嵌匿名接口类型实现继承的例子 直接拿一个业务场景举例子,假设现在我们现在要给用户发一个通知,web、app端发送的通知内容都是一样的,但是点击后的动作是不一样的,所以我们可以进行抽象一个接口OrderChangeNotificationHandler来声明出三个公共方法:GenerateMessage、GeneratePhotos、generateUrl,所有类都会实现这三个方法,因为web、app端发送的内容是一样的,所以我们可以抽相出一个父类OrderChangeNotificationHandlerImpl来实现一个默认的方法,然后在写两个子类WebOrderChangeNotificationHandler、AppOrderChangeNotificationHandler去继承父类重写generateUrl方法即可,后面如果不同端的内容有做修改,直接重写父类方法就可以了,来看例子: type Photos struct { width uint64 height uint64 value string } type OrderChangeNotificationHandler interface { GenerateMessage() string GeneratePhotos() Photos generateUrl() string } type OrderChangeNotificationHandlerImpl struct { url string } func NewOrderChangeNotificationHandlerImpl() OrderChangeNotificationHandler { return OrderChangeNotificationHandlerImpl{ url: "https://base.test.com", } } func (o OrderChangeNotificationHandlerImpl) GenerateMessage() string { return "OrderChangeNotificationHandlerImpl GenerateMessage" } func (o OrderChangeNotificationHandlerImpl) GeneratePhotos() Photos { return Photos{ width: 1, height: 1, value: "https://www.baidu.com", } } func (w OrderChangeNotificationHandlerImpl) generateUrl() string { return w.url } type WebOrderChangeNotificationHandler struct { OrderChangeNotificationHandler url string } func (w WebOrderChangeNotificationHandler) generateUrl() string { return w.url } type AppOrderChangeNotificationHandler struct { OrderChangeNotificationHandler url string } func (a AppOrderChangeNotificationHandler) generateUrl() string { return a.url } func check(handler OrderChangeNotificationHandler) { fmt.Println(handler.GenerateMessage()) } func main() { base := NewOrderChangeNotificationHandlerImpl() web := WebOrderChangeNotificationHandler{ OrderChangeNotificationHandler: base, url: "http://web.test.com", } fmt.Println(web.GenerateMessage()) fmt.Println(web.generateUrl()) check(web) } 因为所有组合都实现了OrderChangeNotificationHandler类型,所以可以处理任何特定类型以及是该特定类型的派生类的通配符。 多态 多态是面向对象编程的本质,多态是支代码可以根据类型的具体实现采取不同行为的能力,在Go语言中任何用户定义的类型都可以实现任何接口,所以通过不同实体类型对接口值方法的调用就是多态,举个例子: type SendEmail interface { send() } func Send(s SendEmail) { s.send() } type user struct { name string email string } func (u *user) send() { fmt.Println(u.name + " email is " + u.email + "already send") } type admin struct { name string email string } func (a *admin) send() { fmt.Println(a.name + " email is " + a.email + "already send") } func main() { u := &user{ name: "asong", email: "你猜", } a := &admin{ name: "asong1", email: "就不告诉你", } Send(u) Send(a) }
在计算机性能调试领域里,profiling 是指对应用程序的画像,画像就是应用程序使用 CPU 和 内存 的情况。 Go语言是一个对性能特别看重的语言,因此语言中自带了 profiling 的库,这篇文章就要讲解怎么在 golang 中做 profiling。 PPROF介绍 pprof 可以做什么 CPU 分析(profile): 你可以在 url 上用 seconds 参数指定抽样持续时间(默认 30s),你获取到概览文件后可以用 go tool pprof 命令调查这个概览 内存分配(allocs): 所有内存分配的抽样 阻塞(block): 堆栈跟踪导致阻塞的同步原语 命令行调用(cmdline): 命令行调用的程序 goroutine: 当前 goroutine 的堆栈信息 堆(heap): 当前活动对象内存分配的抽样,完全也可以指定 gc 参数在对堆取样前执行 GC 互斥锁(mutex): 堆栈跟踪竞争状态互斥锁的持有者 系统线程的创建(threadcreate): 堆栈跟踪系统新线程的创建 trace: 追踪当前程序的执行状况. 你可以用 seconds 参数指定抽样持续时间. 你获取到 trace 概览后可以用 go tool pprof 命令调查这个 trace pprof的两个标准库 Go语言内置了获取程序的运行数据的工具,包括以下两个标准库: runtime/pprof:采集工具型应用运行数据进行分析 net/http/pprof:采集服务型应用运行时数据进行分析 pprof开启后,每隔一段时间(10ms)就会收集下当前的堆栈信息,获取各个函数占用的CPU以及内存资源;最后通过对这些采样数据进行分析,形成一个性能分析报告。 注意,我们只应该在性能测试的时候才在代码中引入pprof。 go tool pprof命令 不管是工具型应用还是服务型应用,我们使用相应的pprof库获取数据之后,下一步的都要对这些数据进行分析,我们可以使用go tool pprof命令行工具。 go tool pprof最简单的使用方式为: go tool pprof [binary] [source] 其中: binary 是应用的二进制文件,用来解析各种符号; source 表示 profile 数据的来源,可以是本地的文件,也可以是 http 地址。 注意事项: 获取的 Profiling 数据是动态的,要想获得有效的数据,请保证应用处于较大的负载(比如正在生成中运行的服务,或者通过其他工具模拟访问压力)。否则如果应用处于空闲状态,得到的结果可能没有任何意义。 工具型应用 如果你的应用程序是运行一段时间就结束退出类型。那么最好的办法是在应用退出的时候把 profiling 的报告保存到文件中,进行分析。对于这种情况,可以使用runtime/pprof库。 首先在代码中导入runtime/pprof工具: import "runtime/pprof" CPU性能分析 开启CPU性能分析: pprof.StartCPUProfile(w io.Writer) 停止CPU性能分析: pprof.StopCPUProfile() 应用执行结束后,就会生成一个文件,保存了我们的 CPU profiling 数据。得到采样数据之后,使用go tool pprof工具进行CPU性能分析。 内存性能优化 记录程序的堆栈信息 pprof.WriteHeapProfile(w io.Writer) 得到采样数据之后,使用go tool pprof工具进行内存性能分析。 go tool pprof默认是使用-inuse_space进行统计,还可以使用-inuse-objects查看分配对象的数量。 服务型应用 如果你的应用程序是一直运行的,比如 web 应用,那么可以使用net/http/pprof库,它能够在提供 HTTP 服务进行分析。 如果使用了默认的http.DefaultServeMux(通常是代码直接使用 http.ListenAndServe(“0.0.0.0:8000”, nil)),只需要在你的web server端代码中按如下方式导入net/http/pprof import _ "net/http/pprof" 如果你使用自定义的 Mux,则需要手动注册一些路由规则: r.HandleFunc("/debug/pprof/", pprof.Index) r.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) r.HandleFunc("/debug/pprof/profile", pprof.Profile) r.HandleFunc("/debug/pprof/symbol", pprof.Symbol) r.HandleFunc("/debug/pprof/trace", pprof.Trace) 如果你使用的是gin框架,那么推荐使用github.com/gin-contrib/pprof,在代码中通过以下命令注册pprof相关路由。 pprof.Register(router) 不管哪种方式,你的 HTTP 服务都会多出/debug/pprof endpoint,访问它会得到类似下面的内容: 这个路径下还有几个子页面: /debug/pprof/profile:访问这个链接会自动进行 CPU profiling,持续 30s,并生成一个文件供下载 /debug/pprof/heap: Memory Profiling 的路径,访问这个链接会得到一个内存 Profiling 结果的文件 /debug/pprof/block:block Profiling 的路径 /debug/pprof/goroutines:运行的 goroutines 列表,以及调用关系 测试示例 测试代码如下: func main() { // CPU分析 fileC, errC := os.Create("./cpu.pprof") if errC != nil { fmt.Printf("create cpu pprof failed, err:%v\n", errC) return } pprof.StartCPUProfile(fileC) defer pprof.StopCPUProfile() // 内存分析 fileM, errM := os.Create("./mem.pprof") if errM != nil { fmt.Printf("create mem pprof failed, err:%v\n", errM) return } pprof.WriteHeapProfile(fileM) fileM.Close() // 业务程序执行 for i := 0; i < 8; i++ { go chanSelect() } time.Sleep(10 * time.Second) } func chanSelect() []int { var c chan int for { select { case v := <-c: fmt.Printf("recv from chan, value:%v\n", v) default: } } } 等待10秒后会在当前目录下生成一个cpu.pprof 和 mem.pprof 文件。 命令行交互界面 我们使用go工具链里的pprof来分析一下。 go tool pprof cpu.pprof 执行上面的代码会进入交互界面如下: PS G:\go_study\21.pprof> go tool pprof cpu.pprof Type: cpu Time: Nov 2, 2021 at 10:47pm (CST) Duration: 10.16s, Total samples = 23.64s (232.62%) Entering interactive mode (type "help" for commands, "o" for options) (pprof) 我们可以在交互界面输入top3来查看程序中占用CPU前3位的函数 我们还可以使用list 函数名命令查看具体的函数分析,例如执行list chanSelect查看我们编写的函数的详细分析。 (pprof) top 3 Showing nodes accounting for 23.33s, 98.69% of 23.64s total Dropped 24 nodes (cum <= 0.12s) Showing top 3 nodes out of 4 flat flat% sum% cum cum% 9.46s 40.02% 40.02% 18.63s 78.81% runtime.selectnbrecv 9.13s 38.62% 78.64% 9.14s 38.66% runtime.chanrecv 4.74s 20.05% 98.69% 23.51s 99.45% main.chanSelect (pprof) list chanSelect Total: 23.64s ROUTINE ======================== main.chanSelect in G:\go_study\21.pprof\main.go 4.74s 23.51s (flat, cum) 99.45% of Total . . 33: } . . 34: time.Sleep(10 * time.Second) . . 35:} . . 36: . . 37:// 测试代码 . 20ms 38:func chanSelect() []int { . . 39: var c chan int . . 40: for { . . 41: select { 4.74s 23.49s 42: case v := <-c: . . 43: fmt.Printf("recv from chan, value:%v\n", v) . . 44: default: . . 45: } . . 46: } . . 47:} 名称说明: flat:当前函数占用CPU的耗时 flat::当前函数占用CPU的耗时百分比 sun%:函数占用CPU的耗时累计百分比 cum:当前函数加上调用当前函数的函数占用CPU的总耗时 cum%:当前函数加上调用当前函数的函数占用CPU的总耗时百分比 最后一列:函数名称 在大多数的情况下,我们可以通过分析这五列得出一个应用程序的运行情况,并对程序进行优化。 通过分析发现大部分CPU资源被 43 行占用,我们分析出select语句中的default没有内容会导致上面的case v:=<-c:一直执行。我们在default分支添加一行time.Sleep(time.Second)即可。 图形化 或者可以直接输入web,通过svg图的方式查看程序中详细的CPU占用情况。 想要查看图形化的界面首先需要安装graphviz图形化工具。 Mac: brew install graphviz **Windows: ** 下载graphviz 将graphviz安装目录下的bin文件夹添加到Path环境变量中。 在终端输入dot -version查看是否安装成功。 如何测试: 在命令行中,可以直接输入 web 或者 png 查看 PS G:\go_study\21.pprof> go tool pprof cpu.pprof (pprof) web 关于图形的说明: 每个框代表一个函数,理论上框的越大表示占用的CPU资源越多。 方框之间的线条代表函数之间的调用关系。 线条上的数字表示函数调用的次数。 方框中的第一行数字表示当前函数占用CPU的百分比,第二行数字表示当前函数累计占用CPU的百分比。 除了分析CPU性能数据,pprof也支持分析内存性能数据。比如,使用下面的命令分析http服务的heap性能数据,查看当前程序的内存占用以及热点内存对象使用的情况。 # 查看内存占用数据 go tool pprof -inuse_space http://127.0.0.1:8080/debug/pprof/heap go tool pprof -inuse_objects http://127.0.0.1:8080/debug/pprof/heap # 查看临时内存分配数据 go tool pprof -alloc_space http://127.0.0.1:8080/debug/pprof/heap go tool pprof -alloc_objects http://127.0.0.1:8080/debug/pprof/heap go-torch和火焰图 火焰图(Flame Graph)是 Bredan Gregg 创建的一种性能分析图表,因为它的样子近似 🔥而得名。上面的 profiling 结果也转换成火焰图,如果对火焰图比较了解可以手动来操作,不过这里我们要介绍一个工具:go-torch。这是 uber 开源的一个工具,可以直接读取 golang profiling 数据,并生成一个火焰图的 svg 文件。 安装go-torch go get -v github.com/uber/go-torch 火焰图 svg 文件可以通过浏览器打开,它对于调用图的最优点是它是动态的:可以通过点击每个方块来 zoom in 分析它上面的内容。 火焰图的调用顺序从下到上,每个方块代表一个函数,它上面一层表示这个函数会调用哪些函数,方块的大小代表了占用 CPU 使用的长短。火焰图的配色并没有特殊的意义,默认的红、黄配色是为了更像火焰而已。 go-torch 工具的使用非常简单,没有任何参数的话,它会尝试从http://localhost:8080/debug/pprof/profile获取 profiling 数据。它有三个常用的参数可以调整: -u –url:要访问的 URL,这里只是主机和端口部分 -s –suffix:pprof profile 的路径,默认为 /debug/pprof/profile –seconds:要执行 profiling 的时间长度,默认为 30s 安装 FlameGraph 要生成火焰图,需要事先安装 FlameGraph工具,这个工具的安装很简单(需要perl环境支持),只要把对应的可执行文件加入到环境变量中即可。 下载安装perl:https://www.perl.org/get.html 下载FlameGraph:git clone https://github.com/brendangregg/FlameGraph.git 将FlameGraph目录加入到操作系统的环境变量中。 Windows平台的同学,需要把go-torch/render/flamegraph.go文件中的GenerateFlameGraph按如下方式修改,然后在go-torch目录下执行go install即可。 // GenerateFlameGraph runs the flamegraph script to generate a flame graph SVG. func GenerateFlameGraph(graphInput []byte, args ...string) ([]byte, error) { flameGraph := findInPath(flameGraphScripts) if flameGraph == "" { return nil, errNoPerlScript } if runtime.GOOS == "windows" { return runScript("perl", append([]string{flameGraph}, args...), graphInput) } return runScript(flameGraph, args, graphInput) } 压测工具wrk 推荐使用https://github.com/wg/wrk 或 https://github.com/adjust/go-wrk 使用go-torch 使用wrk进行压测: go-wrk -n 50000 http://127.0.0.1:8080/book/list 在上面压测进行的同时,打开另一个终端执行: go-torch -u http://127.0.0.1:8080 -t 30 30秒之后终端会初夏如下提示:Writing svg to torch.svg 然后我们使用浏览器打开torch.svg就能看到如下火焰图了。火焰图的y轴表示cpu调用方法的先后,x轴表示在每个采样调用时间内,方法所占的时间百分比,越宽代表占据cpu时间越多。通过火焰图我们就可以更清楚的找出耗时长的函数调用,然后不断的修正代码,重新采样,不断优化。 此外还可以借助火焰图分析内存性能数据: go-torch -inuse_space http://127.0.0.1:8080/debug/pprof/heap go-torch -inuse_objects http://127.0.0.1:8080/debug/pprof/heap go-torch -alloc_space http://127.0.0.1:8080/debug/pprof/heap go-torch -alloc_objects http://127.0.0.1:8080/debug/pprof/heap pprof与性能测试结合 go test命令有两个参数和 pprof 相关,它们分别指定生成的 CPU 和 Memory profiling 保存的文件: -cpuprofile:cpu profiling 数据要保存的文件地址 -memprofile:memory profiling 数据要报文的文件地址 我们还可以选择将pprof与性能测试相结合,比如: 比如下面执行测试的同时,也会执行 CPU profiling,并把结果保存在 cpu.prof 文件中: go test -bench . -cpuprofile=cpu.prof 比如下面执行测试的同时,也会执行 Mem profiling,并把结果保存在 cpu.prof 文件中: go test -bench . -memprofile=./mem.prof 需要注意的是,Profiling 一般和性能测试一起使用,这个原因在前文也提到过,只有应用在负载高的情况下 Profiling 才有意义。
go test工具 Go语言中的测试依赖go test命令。编写测试代码和编写普通的Go代码过程是类似的,并不需要学习新的语法、规则或工具。 go test命令是一个按照一定约定和组织的测试代码的驱动程序。在包目录内,所有以xxx_test.go为后缀名的源代码文件都是go test测试的一部分,不会被go build编译到最终的可执行文件中。 在xxx_test.go文件中有三种类型的函数,单元测试函数、基准测试函数和示例函数。 类型 格式 作用 测试函数 函数名前缀为Test 测试程序的一些逻辑行为是否正确 基准函数 函数名前缀为Benchmark 测试函数的性能 示例函数 函数名前缀为Example 为文档提供示例文档 go test命令会遍历所有的xxx_test.go文件中符合上述命名规则的函数,然后生成一个临时的main包用于调用相应的测试函数,然后构建并运行、报告测试结果,最后清理测试中生成的临时文件。 单元测试函数Test 格式 每个测试函数必须导入testing包,测试函数的基本格式(签名)如下: func TestName(t *testing.T){ // ... } 测试函数的名字必须以Test开头,可选的后缀名必须以大写字母开头,举几个例子: func TestAdd(t *testing.T){ ... } func TestSum(t *testing.T){ ... } func TestLog(t *testing.T){ ... } 其中参数t用于报告测试失败和附加的日志信息。 testing.T的拥有的方法如下: func (c *T) Cleanup(func()) func (c *T) Error(args ...interface{}) func (c *T) Errorf(format string, args ...interface{}) func (c *T) Fail() func (c *T) FailNow() func (c *T) Failed() bool func (c *T) Fatal(args ...interface{}) func (c *T) Fatalf(format string, args ...interface{}) func (c *T) Helper() func (c *T) Log(args ...interface{}) func (c *T) Logf(format string, args ...interface{}) func (c *T) Name() string func (c *T) Skip(args ...interface{}) func (c *T) SkipNow() func (c *T) Skipf(format string, args ...interface{}) func (c *T) Skipped() bool func (c *T) TempDir() string 单元测试示例 单元组件可以是函数、结构体、方法和最终用户可能依赖的任意东西。总之我们需要确保这些组件是能够正常运行的。单元测试是一些利用各种方法测试单元组件的程序,它会将结果与预期输出进行比较。 我在 factorial.go 中定义了一个 Factorial 函数,具体实现如下: // Fact 定义返回的结构体 type Fact struct { ret int nums []int } // Factorial 计算给定数字的阶乘 func Factorial(x int) *Fact { var factS = new(Fact) return operation(x,factS) } // 递归运算 func operation(x int, factS *Fact) *Fact { factS.nums = append(factS.nums, x) if x == 1 { factS.ret = x return factS } factS.ret = x * operation(x-1, factS).ret return factS } 在同级目录下,我们创建一个factorial_test.go的测试文件,并定义一个测试函数如下: func TestFactorial(t *testing.T) { // 程序输出的结果 got := Factorial(5) // 期望的结果 want := &Fact{ ret: 120, nums: []int{5,4,3,2,1}, } // 因为struct不能比较直接,借助反射包中的方法比较 if !reflect.DeepEqual(want, got) { // 测试失败输出错误提示 t.Errorf("expected:%v, got:%v", want, got) } } 在当前路径下执行go test命令,可以看到输出结果如下: shell > go test PASS ok go_study/22.unit_test 0.564s go test -v 我们再往factorial_test.go文件中添加一个测试用例: // 这里给到一个错误的结果,测试错误情况 func TestFactEr(t *testing.T) { got := Factorial(3) want := &Fact{ ret: 6, nums: []int{3,2,1}, } if !reflect.DeepEqual(want, got) { t.Errorf("expected:%v, got:%v", want, got) } } 现在我们有多个测试用例了,为了能更好的在输出结果中看到每个测试用例的执行情况,我们可以为go test命令添加-v参数,让它输出完整的测试结果。 shell> go test -v === RUN TestFactorial --- PASS: TestFactorial (0.00s) === RUN TestFactEr factorial_test.go:38: expected:&{666 [2 2 2]}, got:&{6 [3 2 1]} --- FAIL: TestFactEr (0.00s) FAIL exit status 1 FAIL go_study/22.unit_test 0.096s 从上面的输出结果我们能清楚的看到是TestFactEr这个测试用例没有测试通过。 go test -run 在执行go test命令的时候可以添加-run参数,它对应一个正则表达式,只有函数名匹配上的测试函数才会被go test命令执行。 例如通过给go test添加 -run=Sep 参数来告诉它本次测试只运行TestFactorial这个测试用例: shell> go test -run=TestFactorial -v === RUN TestFactorial --- PASS: TestFactorial (0.00s) PASS ok go_study/22.unit_test 0.099s 回归测试 我们修改了代码之后仅仅执行那些失败的测试用例或新引入的测试用例是错误且危险的,正确的做法应该是完整运行所有的测试用例,保证不会因为修改代码而引入新的问题。 shell> go test -v === RUN TestFactorial --- PASS: TestFactorial (0.00s) === RUN TestFactEr --- PASS: TestFactEr (0.00s) PASS ok go_study/22.unit_test 0.539s 测试结果表明我们的单元测试全部通过。 通过这个示例我们可以看到,有了单元测试就能够在代码改动后快速进行回归测试,极大地提高开发效率并保证代码的质量。 跳过某些测试用例 为了节省时间支持在单元测试时跳过某些耗时的测试用例。 func TestTimeConsuming(t *testing.T) { if testing.Short() { t.Skip("short模式下会跳过该测试用例") } ... } 当执行go test -short时就不会执行上面的TestTimeConsuming测试用例。 子测试 在上面的示例中我们为每一个测试数据编写了一个测试函数,而通常单元测试中需要多组测试数据保证测试的效果。Go1.7+中新增了子测试,支持在测试函数中使用t.Run执行一组测试用例,这样就不需要为不同的测试数据定义多个测试函数了。 表格驱动测试不是工具、包或其他任何东西,它只是编写更清晰测试的一种方式和视角。 使用表格驱动测试能够很方便的维护多个测试用例,避免在编写单元测试时频繁的复制粘贴。 表格驱动测试的步骤通常是定义一个测试用例表格,然后遍历表格,并使用t.Run对每个条目执行必要的测试。 func TestXXX(t *testing.T){ t.Run("case1", func(t *testing.T){...}) t.Run("case2", func(t *testing.T){...}) t.Run("case3", func(t *testing.T){...}) } 测试演示: func TestFactGroup(t *testing.T) { t.Parallel() // 将 TLog 标记为能够与其他测试并行运行 // 定义测试表格 testCases := []struct{ testName string input int output *Fact }{ {"case1",5, &Fact{ret: 120, nums: []int{5,4,3,2,1}}}, {"case2",3, &Fact{ret: 6, nums: []int{3,2,1}}}, } // 运行子测试代码 for _,tt := range testCases { tt := tt t.Run(tt.testName, func(t *testing.T) { t.Parallel() // 将每个测试用例标记为能够彼此并行运行 got := Factorial(tt.input) if !reflect.DeepEqual(tt.output, got) { t.Errorf("expected:%v, got:%v", tt.output, got) } }) } } 在终端执行go test -v,会得到如下测试输出结果: shell> go test -v === RUN TestFactGroup === RUN TestFactGroup/case1 === RUN TestFactGroup/case2 --- PASS: TestFactGroup (0.00s) --- PASS: TestFactGroup/case1 (0.00s) --- PASS: TestFactGroup/case2 (0.00s) PASS ok go_study/22.unit_test 0.405s 并行测试 表格驱动测试中通常会定义比较多的测试用例,而Go语言又天生支持并发,所以很容易发挥自身并发优势将表格驱动测试并行化。 想要在单元测试过程中使用并行测试,可以像下面的代码示例中那样通过添加t.Parallel()来实现。 t.Parallel() 这样我们执行go test -v的时候就会看到每个测试用例并不是按照我们定义的顺序执行,而是互相并行了。 使用工具生成测试代码 社区里有很多自动生成表格驱动测试函数的工具,比如gotests等,很多编辑器如Goland也支持快速生成测试文件。这里简单演示一下gotests的使用。 安装 go get github.com/cweill/gotests/... 执行 gotests -all -w factorial.go 上面的命令表示,为split.go文件的所有函数生成测试代码至split_test.go文件(目录下如果事先存在这个文件就不再生成)。 生成的测试代码大致如下: func Test_operation(t *testing.T) { type args struct { x int factS *Fact } tests := []struct { name string args args want *Fact }{ // TODO: Add test cases. } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := operation(tt.args.x, tt.args.factS); !reflect.DeepEqual(got, tt.want) { t.Errorf("operation() = %v, want %v", got, tt.want) } }) } } 测试覆盖率 测试覆盖率是指代码被测试套件覆盖的百分比。通常我们使用的都是语句的覆盖率,也就是在测试中至少被运行一次的代码占总代码的比例。在公司内部一般会要求测试覆盖率达到80%左右。 Go提供内置功能来检查你的代码覆盖率,即使用go test -cover来查看测试覆盖率。 shell> go test -cover PASS coverage: 100.0% of statements ok go_study/22.unit_test 0.291s 从上面的结果可以看到我们的测试用例覆盖了100%的代码。 Go还提供了一个额外的-coverprofile参数,用来将覆盖率相关的记录信息输出到一个文件。例如: shell> go test -cover -coverprofile=file.out PASS coverage: 100.0% of statements ok go_study/22.unit_test 0.180s 上面的命令会将覆盖率相关的信息输出到当前文件夹下面的c.out文件中。 ❯ tree . . ├── file.out ├── factorial.go └── factorial_test.go 然后我们执行go tool cover -html=file.out,使用cover工具来处理生成的记录信息,该命令会打开本地的浏览器窗口生成一个HTML报告。 testify/assert testify是一个社区非常流行的Go单元测试工具包,其中使用最多的功能就是它提供的断言工具——testify/assert或testify/require。 安装: go get github.com/stretchr/testify 我们在写单元测试的时候,通常需要使用断言来校验测试结果,但是由于Go语言官方没有提供断言,所以我们会写出很多的if...else...语句。而testify/assert为我们提供了很多常用的断言函数,并且能够输出友好、易于阅读的错误描述信息。 比如我们之前在TestSplit测试函数中就使用了reflect.DeepEqual来判断期望结果与实际结果是否一致。 testify/assert提供了非常多的断言函数,这里没办法一一列举出来,大家可以查看官方文档了解。 testify/require拥有testify/assert所有断言函数,它们的唯一区别就是——testify/require遇到失败的用例会立即终止本次测试。 此外,testify包还提供了mock、http等其他测试工具。 单元基准测试Benchmark 格式: 基准测试就是在一定的工作负载之下检测程序性能的一种方法。基准测试的基本格式如下: func BenchmarkName(b *testing.B){ // ... } 基准测试以Benchmark为前缀,需要一个*testing.B类型的参数b,基准测试必须要执行b.N次,这样的测试才有对照性,b.N的值是系统根据实际情况去调整的,从而保证测试的稳定性。 testing.B拥有的方法如下: func (c *B) Error(args ...interface{}) func (c *B) Errorf(format string, args ...interface{}) func (c *B) Fail() func (c *B) FailNow() func (c *B) Failed() bool func (c *B) Fatal(args ...interface{}) func (c *B) Fatalf(format string, args ...interface{}) func (c *B) Log(args ...interface{}) func (c *B) Logf(format string, args ...interface{}) func (c *B) Name() string func (b *B) ReportAllocs() func (b *B) ResetTimer() func (b *B) Run(name string, f func(b *B)) bool func (b *B) RunParallel(body func(*PB)) func (b *B) SetBytes(n int64) func (b *B) SetParallelism(p int) func (c *B) Skip(args ...interface{}) func (c *B) SkipNow() func (c *B) Skipf(format string, args ...interface{}) func (c *B) Skipped() bool func (b *B) StartTimer() func (b *B) StopTimer() 示例: 我们为 factorial.go 中的 Factorial 方法便携基准测试代码: func BenchmarkFactorial(b *testing.B) { for i := 0; i < b.N; i++ { Factorial(10) } } 基准测试并不会默认执行,需要增加-bench参数,所以我们通过执行go test -bench=Split命令执行基准测试,输出结果如下: shell> go test -bench=Factorial goos: darwin goarch: amd64 pkg: go_study/22.unit_test cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz BenchmarkFactorial-4 6067362 198.5 ns/op PASS ok go_study/22.unit_test 1.605s 其中BenchmarkSplit-4表示对Factorial函数进行基准测试,数字4表示GOMAXPROCS的值,这个对于并发基准测试很重要。6067362和198.5ns/op表示每次调用Factorial函数耗时198.05ns,这个结果是6067362次调用的平均值。 我们还可以为基准测试添加-benchmem参数,来获得内存分配的统计数据。 shell> go test -bench=Factorial -benchmem goos: darwin goarch: amd64 pkg: go_study/22.unit_test cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz BenchmarkFactorial-4 5139259 198.6 ns/op 248 B/op 5 allocs/op PASS ok go_study/22.unit_test 1.383s 其中,248 B/op表示每次操作内存分配了112字节,5 allocs/op则表示每次操作进行了5次内存分配。 性能比较函数 上面的基准测试只能得到给定操作的绝对耗时,但是在很多性能问题是发生在两个不同操作之间的相对耗时,比如同一个函数处理1000个元素的耗时与处理1万甚至100万个元素的耗时的差别是多少?再或者对于同一个任务究竟使用哪种算法性能最佳?我们通常需要对两个不同算法的实现使用相同的输入来进行基准比较测试。 性能比较函数通常是一个带有参数的函数,被多个不同的Benchmark函数传入不同的值来调用。举个例子如下: func benchmark(b *testing.B, size int){/* ... */} func Benchmark10(b *testing.B){ benchmark(b, 10) } func Benchmark100(b *testing.B){ benchmark(b, 100) } func Benchmark1000(b *testing.B){ benchmark(b, 1000) } 编写一个性能比较测试函数如下: shell> go test -bench=. goos: darwin goarch: amd64 pkg: go_study/22.unit_test cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz BenchmarkFactorial-4 6278547 189.5 ns/op BenchmarkFact1-4 50307910 22.53 ns/op BenchmarkFact5-4 8841597 133.6 ns/op BenchmarkFact10-4 6290251 199.7 ns/op BenchmarkFact50-4 2198989 534.9 ns/op BenchmarkFact100-4 1257559 955.7 ns/op PASS ok go_study/22.unit_test 9.797s 这里需要注意的是,默认情况下,每个基准测试至少运行1秒。如果在Benchmark函数返回时没有到1秒,则b.N的值会按1,2,5,10,20,50,…增加,并且函数再次运行。 所以如果函数的运行接近1秒,则测试出的数据可能不准确。像这种情况下我们应该可以使用-benchtime标志增加最小基准时间,以产生更准确的结果。例如: shell> go test -bench=Fact50 -benchtime=20s goos: darwin goarch: amd64 pkg: go_study/22.unit_test cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz BenchmarkFact50-4 42403383 557.6 ns/op PASS ok go_study/22.unit_test 24.516s 重置时间 b.ResetTimer之前的处理不会放到执行时间里,也不会输出到报告中,所以可以在之前做一些不计划作为测试报告的操作。例如: func BenchmarkFactorial(b *testing.B) { time.Sleep(5 * time.Second) // 假设需要做一些耗时的无关操作 b.ResetTimer() // 重置计时器 for i := 0; i < b.N; i++ { Factorial(10) } } 并行测试 格式: func (b *B) RunParallel(body func(*PB)) RunParallel会创建出多个goroutine,并将b.N分配给这些goroutine执行, 其中goroutine数量的默认值为GOMAXPROCS。 用户如果想要增加非CPU受限(non-CPU-bound)基准测试的并行性, 那么可以在RunParallel之前调用SetParallelism 。 RunParallel通常会与-cpu标志一同使用。 shell> go test -bench=. goos: darwin goarch: amd64 pkg: go_study/22.unit_test cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz BenchmarkFactorialParallel-4 9344024 132.3 ns/op PASS ok go_study/22.unit_test 1.658s 还可以通过在测试命令后添加-cpu参数如go test -bench=. -cpu 1来指定使用的CPU数量。 Setup与TearDown 测试程序有时需要在测试之前进行额外的设置(setup)或在测试之后进行拆卸(teardown)。 setUp()函数是在众多函数或者说是在一个类里面最先被调用的函数,而且每执行完一个函数都要从setUp()调用开始后再执行下一个函数,有几个函数就调用他几次,与位置无关,随便放在那里都是他先被调用。 tearDown()函数是在众多函数执行完后他才被执行,意思就是不管这个类里面有多少函数,他总是最后一个被执行的,与位置无关,放在那里都行,最后不管测试函数是否执行成功都执行tearDown()方法;如果setUp()方法失败,则认为这个测试项目失败,不会执行测试函数也不执行tearDown()方法。 TestMain 通过在*_test.go文件中定义TestMain函数来可以在测试之前进行额外的设置(setup)或在测试之后进行拆卸(teardown)操作。 如果测试文件包含函数:func TestMain(m *testing.M)那么生成的测试会先调用 TestMain(m),然后再运行具体测试。TestMain运行在主goroutine中, 可以在调用 m.Run前后做任何设置(setup)和拆卸(teardown)。退出测试的时候应该使用m.Run的返回值作为参数调用os.Exit。 一个使用TestMain来设置Setup和TearDown的示例如下: func TestMain(m *testing.M) { fmt.Println("write setup code here...") // 测试之前的做一些设置 // 如果 TestMain 使用了 flags,这里应该加上flag.Parse() retCode := m.Run() // 执行测试 fmt.Println("write teardown code here...") // 测试之后做一些拆卸工作 os.Exit(retCode) // 退出测试 } 需要注意的是:在调用TestMain时, flag.Parse并没有被调用。所以如果TestMain 依赖于command-line标志 (包括 testing 包的标记), 则应该显示的调用flag.Parse。 子测试的Setup与Teardown 有时候我们可能需要为每个测试集设置Setup与Teardown,也有可能需要为每个子测试设置Setup与Teardown。下面我们定义两个函数工具函数如下: // 测试集的Setup与Teardown func setupFactsTest(t *testing.T) func(t *testing.T) { t.Log("如有需要在此执行:Setup") return func(t *testing.T) { t.Log("如有需要在此执行:Setup") } } // 子测试的Setup与Teardown func downFactsTest(t *testing.T) func(t *testing.T) { t.Log("如有需要在此执行:Teardown") return func(t *testing.T) { t.Log("如有需要在此执行:Teardown") } } 测试代码如下: 执行顺序如下:setUp —> case1 —> setUp —> case2 —> tearDown func TestFactsGroup(t *testing.T) { t.Parallel() // 定义测试表格 testCases := []struct{ testName string input int output *Fact }{ {"case1",5, &Fact{ret: 120, nums: []int{5,4,3,2,1}}}, {"case2",3, &Fact{ret: 6, nums: []int{3,2,1}}}, } setupTest := setupFactsTest(t) defer setupTest(t) // 测试之前执行setup操作 // 运行子测试代码 for _,tt := range testCases { tt := tt t.Run(tt.testName, func(t *testing.T) { t.Parallel() downTest := downFactsTest(t) defer downTest(t) // 测试之后执行testdown操作 got := Factorial(tt.input) if !reflect.DeepEqual(tt.output, got) { t.Errorf("expected:%v, got:%v", tt.output, got) } }) } } 测试结果如下: libin@bogon 22.unit_test % go test -v -run=TestFactsGroup === RUN TestFactsGroup === PAUSE TestFactsGroup === CONT TestFactsGroup factorial_test.go:97: 如有需要在此执行:Setup === RUN TestFactsGroup/case1 === PAUSE TestFactsGroup/case1 === RUN TestFactsGroup/case2 === PAUSE TestFactsGroup/case2 === CONT TestFactsGroup factorial_test.go:99: 如有需要在此执行:Setup === CONT TestFactsGroup/case1 === CONT TestFactsGroup/case2 === CONT TestFactsGroup/case1 factorial_test.go:105: 如有需要在此执行:Teardown === CONT TestFactsGroup/case2 factorial_test.go:105: 如有需要在此执行:Teardown factorial_test.go:107: 如有需要在此执行:Teardown === CONT TestFactsGroup/case1 factorial_test.go:107: 如有需要在此执行:Teardown --- PASS: TestFactsGroup (0.00s) --- PASS: TestFactsGroup/case1 (0.00s) --- PASS: TestFactsGroup/case2 (0.00s) PASS ok go_study/22.unit_test 0.554s 单元示例函数Example 格式 被go test特殊对待的第三种函数就是示例函数,它们的函数名以Example为前缀。它们既没有参数也没有返回值。标准格式如下: func ExampleName() { // ... } 示例 func ExampleFactorial() { fmt.Println(unittest.Factorial(5)) // Output:&Fact{ret:120, nums:[]int{5,4,3,2,1}} } 为你的代码编写示例代码有如下三个用处: 示例函数能够作为文档直接使用,例如基于web的godoc中能把示例函数与对应的函数或包相关联。 示例函数只要包含了// Output:也是可以通过go test运行的可执行测试。 shell> go test -run Example PASS ok go_study/22.unit_test 0.006s 示例函数提供了可以直接运行的示例代码,可以直接在golang.org的godoc文档服务器上使用Go Playground运行示例代码。
在写命令行程序(工具、server)时,对命令参数进行解析是常见的需求。各种语言一般都会提供解析命令行参数的方法或库,以方便程序员使用。如果命令行参数纯粹自己写代码来解析,对于比较复杂的,还是挺费劲的。在 go 标准库中提供了一个包:flag,方便进行命令行解析。 使用示例 我们以 docker 为例,当我们输入 docker --help,输出信息如下: Usage: docker [OPTIONS] COMMAND A self-sufficient runtime for containers Options: --config string Location of client config files (default "/root/.docker") -c, --context string Name of the context to use to connect to the daemon (overrides DOCKER_HOST env var and default context set with "docker context use") -D, --debug Enable debug mode -H, --host list Daemon socket(s) to connect to -l, --log-level string Set the logging level ("debug"|"info"|"warn"|"error"|"fatal") (default "info") --tls Use TLS; implied by --tlsverify --tlscacert string Trust certs signed only by this CA (default "/root/.docker/ca.pem") ... ... 下文略去 ... ... 所以说,flag 包实现了命令行参数的解析的一个标准库,当然还有其他标准库也能实现类似的功能,例如:os.Args,但是使用 flag 处理会更加方便。 flag基本使用 参数类型: flag包支持的命令行参数类型有 bool、int、int64、uint、uint64、float、float64、string、duration。 flag参数 有效值 字符串flag 合法字符串 整数flag 1234、0664、0x1234等类型,也可以是负数。 浮点数flag 合法浮点数 bool类flag 1, 0, t, f, T, F, true, false, TRUE, FALSE, True, False。 时间段flag 任何合法的时间段字符串。如”300ms”、”-1.5h”、”2h45m”。 合法的单位有”ns”、”us” 、“µs”、”ms”、”s”、”m”、”h”。 定义命令行flag参数: 有以下两种常用的定义命令行flag参数的方法。 flag.Type() 格式: flag.Type("flag名", "默认值", "帮助信息") flag.Parse() // 解析命令行参数写入注册的flag里 测试: func main() { name := flag.String("name", "default_name", "Help:Please input your name") age := flag.Int("age", 0, "Help:Please input your age") flag.Parse() fmt.Println(*name) fmt.Println(*age) } flag.TypeVar() 格式: flag.IntVar(&flagvar, "flagname", "默认值", "帮助信息") flag.Parse() // 解析命令行参数写入注册的flag里 测试: func main() { var name string var age int flag.StringVar(&name,"name", "default_name", "Help:Please input your name") flag.IntVar(&age,"age", 0, "Help:Please input your age") flag.Parse() fmt.Println(name) fmt.Println(age) } 命令行参数指定方法 -flag -flag=xxx -flag xxx // 只有非bool类型的flag可以 --flag xxx // 只有非bool类型的flag可以 --flag=xxx 其中,布尔类型的参数必须使用等号的方式指定。 Flag解析在第一个非flag参数(单个”-“不是flag参数)之前停止,或者在终止符”–“之后停止。 flag其他函数 flag.Args() ////返回命令行参数后的其他参数,以[]string类型 flag.NArg() //返回命令行参数后的其他参数个数 flag.NFlag() //返回使用的命令行参数个数
切片泄露的可能 在业务代码的编写上,我们经常会接受来自外部的接口数据,再把他插入到对应的数据结构中去,再进行下一步的业务聚合、裁剪、封装、处理。 像在 PHP 语言,常常会放到数组(array)中。在 Go 语言,会放到切片(slice)中。因此在 Go 的切片处理逻辑中,常常会涉及到如下类似的动作。 示例代码如下: // 定义切片a var a []int func f(b []int) []int { a = b[:2] return a } func main() { ... } 仔细想想,这段程序有没有问题,是否存在内存泄露的风险? 答案是:有的。有明确的切片内存泄露的可能性和风险。 切片底层结构 可能有些人会疑惑,怎么就有问题了,是哪里有问题? 这里就得复习一下切片的底层基本数据结构了,切片在运行时的表现是 SliceHeader 结构体,定义如下: type SliceHeader struct { Data uintptr Len int Cap int } Data:指向具体的底层数组。 Len:代表切片的长度。 Cap:代表切片的容量。 要点是:切片真正存储数据的地方,是一个数组。切片的 Data 属性中存储的是指向所引用的数组指针地址。 背后的原因 在上述案例中,我们有一个包全局变量 a,共有 2 个切片 a 和 b,截取了 b 的一部分赋值给了 a,两者存在着关联。 从程序的直面来看,截取了 b 的一部分赋值给了 a,结构似乎是如下图: 但我们进一步打开程序底层来看,他应该是如下图所示: 切片 a 和 b 都共享着同一个底层数组(共享内存块),sliceB 包含全部所引用的字符。sliceA 只包含了 [:2],也就是 0 和 1 两个索引位的字符。 那他们泄露在哪里了? 泄露的点 泄露的点,就在于虽然切片 b 已经在函数内结束了他的使命了,不再使用了。但切片 a 还在使用,切片 a 和 切片 b 引用的是同一块底层数组(共享内存块)。 关键点**:切片 a 引用了底层数组中的一段**。 虽然切片 a 只有底层数组中 0 和 1 两个索引位正在被使用,其余未使用的底层数组空间毫无作用。但由于正在被引用,他们也不会被 GC,因此造成了泄露。 解决办法 解决的办法,就是利用切片的特性。当切片的容量空间不足时,会重新申请一个新的底层数组来存储,让两者彻底分手。 示例代码如下: var a []int var c []int // 第三者 func f(b []int) []int { a = b[:2] // 新的切片 append 导致切片扩容 c = append(c, b[:2]...) fmt.Printf("a: %p\nc: %p\nb: %p\n", &a[0], &c[0], &b[0]) return a } 输出结果: a: 0xc000102060 c: 0xc000124010 b: 0xc000102060 这段程序,新增了一个变量 c,他容量为 0。此时将期望的数据,追加过去。自然而然他就会遇到容量空间不足的情况,也就能实现申请新底层数据。 我们再将原本的切片置为 nil,就能成功实现两者分手的目标了。 参考 An interesting way to leak memory with Go slices internal/poll: avoid memory leak in Writev slice 类型内存泄露的逻辑 golang slice内存泄露回收
调用栈(call stack) 一叠便条:插入的待办事项放在清单的最前面;读取待办事项时,你只读取最上面的那个,并将其删除。因此这个待办事项清单只有两种操作**:压入**(插入)和弹出(删除并读取)。 这种数据结构称为栈。栈是一种简单的数据结构,刚才我们一直在使用它,却没有意识到! 函数演示 计算机在内部使用被称为调用栈的栈。我们来看看计算机是如何使用调用栈的。下面是一个简单的函数。 <?php function greet($name) { echo "hello ".$name; greet2($name); echo "go to ready bye..."; bye(); } function greet2($name) { echo "how are you" . $name; } function bye() { echo "ok bye"; } 栈调用分析 假设你调用greet("maggie"),计算机将首先为该函数调用分配一块内存。 我们来使用这些内存。变量name被设置为maggie,这需要存储到内存中。 每当你调用函数时,计算机都像这样将函数调用涉及的所有变量的值存储到内存中。接下来,你打印hello maggie!,再调用greet2("maggie")。同样,计算机也为这个函数调用分配一块内存。 计算机使用一个栈来表示这些内存块,其中第二个内存块位于第一个内存块上面。你打印how are you, maggie?,然后从函数调用返回。此时,栈顶的内存块被弹出。 现在,栈顶的内存块是函数greet的,这意味着你返回到了函数greet。当你调用函数greet2时,函数greet只执行了一部分。这是本节的一个重要概念:调用另一个函数时,当前函数暂停并处于未完成状态。该函数的所有变量的值都还在内存中。执行完函数greet2后,你回到函数greet,并从离开的地方开始接着往下执行:首先打印getting ready to say bye…,再调用函数bye。 在栈顶添加了函数bye的内存块。然后,你打印ok bye!,并从这个函数返回。 现在你又回到了函数greet。由于没有别的事情要做,你就从函数greet返回。这个栈用于存储多个函数的变量,被称为调用栈。 递归调用栈 递归函数也使用调用栈!来看看递归函数factorial的调用栈。factorial(5)写作5!,其定义如下:5! = 5 * 4 * 3 * 2 * 1。同理,factorial(3)为3 * 2 * 1。下面是计算阶乘的递归函数。 <?php function fact($x) { if($x == 1) { return 1; } else { return $x * fact($x-1 ); } }
使Simple主题支持图片点击放大 在使用 hexo 主题模版 hexo-theme-simple99 的时候,发现该模版并不支持数学公式的渲染,随即动手开始改造改模版使其支持图片点击放大。 修改步骤 主题配置 为了提高主题的可扩展性和可控制性,在 主题配置 文件中加入 fancybox 字段: fancybox: true 下载 fancybox 库 点击这里 下载最新的 fancybox 库, 解压缩至 /theme/material/source/js/fancybox/ 目录下,这里贴出目录结构: js └── fancybox ├── jquery.fancybox.min.css └── jquery.fancybox.min.js 下载下来的 fancybox 只保留了 jquery.fancybox.min.css 和 jquery.fancybox.min.js 文件,其他的非必需。 编写js 我在 /simple/source/js/ 下新增了 wrapImage.js 用于在指定的 <img> 外裹一层 fancybox 所需要的属性 $(document).ready(function() { $('img').not('.notice img').not('.copyright-wrap img').not('.sidebar-image img').not('#author-avatar img').not(".mdl-menu img").not(".something-else-logo img").each(function() { var $image = $(this); var imageCaption = $image.attr('alt'); var $imageWrapLink = $image.parent('a'); if ($imageWrapLink.size() < 1) { var src = this.getAttribute('src'); var idx = src.lastIndexOf('?'); if (idx != -1) { src = src.substring(0, idx); } $imageWrapLink = $image.wrap('<a href="' + src + '"></a>').parent('a'); } $imageWrapLink.attr('data-fancybox', 'images'); if (imageCaption) { $imageWrapLink.attr('data-caption', imageCaption); } }); }); head的ejs模版中引入js和css 由于我们需要在 html 的 <head> 标签中引入 fancybox js 库,所以我们找到 <head> 标签对应的模版文件 /simple/layout/partials/head.ejs, 利用之前配置的 fancybox 配置项在 head.ejs 中引入 fancybox: <!-- fancybox 图片点击放大插件 start --> <% if(theme.fancybox === true && page.fancybox !== false) { %> <%- js('js/fancybox/jquery.fancybox.min') %> <%- js('js/wrapImage') %> <%- css('js/fancybox/jquery.fancybox.min') %> <% } %> <!-- fancybox 图片点击放大插件 end --> 至此,图片点击放大预览效果已实现,撒花 ✿✿ヽ (゚▽゚) ノ✿ 参考博文 https://blog.csdn.net/kkstrive/article/details/78607094 https://www.php.cn/blog/detail/19580.html
参考文献 计算机存储单位关系:https://blog.mailjob.net/posts/506822510.html 地址与指针 何为内存? (注意,我们这里提到的内存并不是人们常说的计算机的物理内存,而是虚拟的逻辑内存空间) 简单点说:地址就是可以唯一标识某一点的一个编号,即一个数字!我们都见过尺子,我们统一以毫米为单位,一把长1000毫米的尺子,其范围区间为0~999,而我们可以准确的找到35毫米、256毫米处的位置。同样的道理,内存也如此,也是像尺子一样线性排布,只不过这个范围略大。 在我们最广泛使用的32位操作系统下,是从 0~4,294,967,295 之间,而地址就是这之中的的一个编号而已,习惯上,在计算机里地址我们常常用其对应的十六进制数来表示,比如 0x12ff7c 这样。 在C程序中,每一个定义的变量,在内存中都占有一个内存单元,比如int类型占4个字节,char类型占一个字节等等,每个字节都在0~4,294,967,295之间都有一个对应的编号,C语言允许在程序中使用变量的地址,并可以通过地址运算符"&"得到变量的地址。 何为指针? 简单的讲,地址就是逻辑内存上的编号,而指针虽然也表示一个编号,也是一个地址。但两者性质却不相同。一个代表了常量,另一个则是变量。就好比内存是一把尺子,而指针就是尺子上面的游标,可以左右移动,他某一个时刻是指向一个地方的,这就是指针变量。 Go语言中的指针不能进行偏移和运算,因此Go语言中的指针操作非常简单,我们只需要记住两个符号:&(取地址)和*(根据地址取值)。 指针地址和指针类型 每个变量在运行时都拥有一个地址,这个地址代表变量在内存中的位置。Go语言中使用&字符放在变量前面对变量进行“取地址”操作。 Go语言中的值类型(int、float、bool、string、array、struct)都有对应的指针类型,如:*int、*int64、*string等。 取变量指针的语法如下: ptr := &v // v的类型为T 其中: v:代表被取地址的变量,类型为T ptr:用于接收地址的变量,ptr的类型就为*T,称做T的指针类型。*代表指针。 举个栗子 package main import "fmt" func main() { a := 666 b := &a // 取a的指针地址 fmt.Printf("%p\n", &a) // a的内存地址 输出:0xc0000b0008 fmt.Printf("%p\n", b) // b的变量值 输出:0xc0000b0008 fmt.Printf("%p\n", &b) // b的内存地址 输出:0xc0000aa018 } 指针取值 package main import "fmt" func main() { a := 666 b := &a // 取a的指针地址 fmt.Printf("%p\n", &a) // a的内存地址 输出:0xc0000b0008 fmt.Printf("%p\n", b) // b的变量值 输出:0xc0000b0008 fmt.Printf("%p\n", &b) // b的内存地址 输出:0xc0000aa018 c := *b // 指针取值(根据指针去内存取值) fmt.Printf("%p\n", &c) // 输出:0xc000018088 fmt.Printf("type of c:%T\n", c) // 输出:type of c:int fmt.Printf("value of c:%v\n", c) // 输出:value of c:666 } 总结: 取地址操作符&和取值操作符*是一对互补操作符,&取出地址,*根据地址取出地址指向的值。 变量、指针地址、指针变量、取地址、取值的相互关系和特性如下: 对变量进行取地址(&)操作,可以获得这个变量的指针变量。 指针变量的值是指针地址。 对指针变量进行取值(*)操作,可以获得指针变量指向的原变量的值。 指针传值示例: package main import "fmt" func f1(a int) { a = 789 } // * 代表从内存地址取值 func f2(a *int) { *a = 123 // 将内存地址的值重新赋值 } func main() { a := 666 f1(a) fmt.Println(a) // 输出:666 // 此处传递 a 的内存地址 f2(&a) fmt.Println(a) // 输出:123 }
Supervisor简介 Supervisor是用Python开发的一套通用的进程管理程序,能将一个普通的命令行进程变为后台daemon,并监控进程状态,异常退出时能自动重启。 它是通过fork/exec的方式把这些被管理的进程当作supervisor的子进程来启动,这样只要在supervisor的配置文件中,把要管理的进程的可执行文件的路径写进去即可。 也实现当子进程挂掉的时候,父进程可以准确获取子进程挂掉的信息的,可以选择是否自己启动和报警。supervisor还提供了一个功能,可以为supervisord或者每个子进程,设置一个非root的user,这个user就可以管理它对应的进程。 事件背景 给公司用 docker 部署新的 dnmp 开发环境,想用 supervisor 实现对 php 进程和 swoole 进程的监控。但是部署了 dnmp 后,发现对于存活的 php 进程无法正确的监控到,始终报 fatal ,致命错误。 错误分析 在 dnmp 中,由于 supervisor 作为一个独立的容器存在,所以对于监控配置文件 supervisor/conf.d/php-fpm.ini 中运行的 php 命令并无法正常执行(因为只有在php容器中才可执行 php-cli 命令)。 所以,接下来的改写,目的就是吧 supervisor 移植到 php 容器中运行。 参考文献 已修改的配置下载:https://github.com/jefferyjob/dnmp_supervisor 配置修改过程 php的dockerfile改写 ARG PHP_VERSION FROM ${PHP_VERSION} ARG TZ ARG PHP_EXTENSIONS ARG CONTAINER_PACKAGE_URL # 配置清华镜像 RUN if [ $CONTAINER_PACKAGE_URL ] ; then sed -i "s/dl-cdn.alpinelinux.org/${CONTAINER_PACKAGE_URL}/g" /etc/apk/repositories ; fi # 安装php扩展 COPY ./extensions /tmp/extensions WORKDIR /tmp/extensions RUN chmod +x install.sh \ && sh install.sh \ && rm -rf /tmp/extensions # 添加扩展安装执行脚本 ADD ./extensions/install-php-extensions /usr/local/bin/ # 赋予可执行权限 RUN chmod uga+x /usr/local/bin/install-php-extensions # 安装必要的软件 # supervisor # bash # 设置时区 RUN apk update \ && apk upgrade \ && apk add supervisor \ && apk add bash \ && apk --no-cache add tzdata \ && cp "/usr/share/zoneinfo/$TZ" /etc/localtime \ && echo "$TZ" > /etc/timezone \ && rm -rf /var/cache/apk/* # Fix: https://github.com/docker-library/php/issues/240 RUN apk add gnu-libiconv libstdc++ --no-cache --repository http://${CONTAINER_PACKAGE_URL}/alpine/edge/community/ --allow-untrusted ENV LD_PRELOAD /usr/lib/preloadable_libiconv.so php # 安装 composer 并更改它的缓存主页 RUN curl -o /usr/bin/composer https://mirrors.aliyun.com/composer/composer.phar \ && chmod +x /usr/bin/composer ENV COMPOSER_HOME=/tmp/composer # 配置用户组 RUN apk --no-cache add shadow && usermod -u 1000 www-data && groupmod -g 1000 www-data # 设置工作目录 WORKDIR /var/www 主要修改的内容是: RUN apk update \ && apk upgrade \ && apk add supervisor \ && apk add bash \ && apk --no-cache add tzdata \ && cp "/usr/share/zoneinfo/$TZ" /etc/localtime \ && echo "$TZ" > /etc/timezone \ && rm -rf /var/cache/apk/* docker-composer中 php 的容器编排修改 # php7 php: build: context: ./services/php args: PHP_VERSION: php:7.4.7-fpm-alpine CONTAINER_PACKAGE_URL: mirrors.ustc.edu.cn PHP_EXTENSIONS: pdo_mysql,mysqli,mbstring,gd,curl,opcache,apcu,zip,opcache,redis,swoole TZ: "Asia/Tokyo" container_name: php ports: - "9001:9001" expose: - 9000 # php-fpm - 9001 # supervisor - 5200 # swoole laravels #extra_hosts: # - "www.site1.com:172.17.0.1" volumes: - ..:/var/www/:rw - ./services/php/php.ini:/usr/local/etc/php/php.ini:ro - ./services/php/php-fpm.conf:/usr/local/etc/php-fpm.d/www.conf:rw - ./logs/php:/var/log/php - ./data/composer:/tmp/composer # supervisor 文件映射配置 - ./services/supervisor/supervisord.conf:/etc/supervisord.conf:rw - ./services/supervisor/conf.d:/etc/supervisor/conf.d:rw - ./logs/supervisor:/var/log/supervisor:rw restart: always command: - /bin/sh - -c - | supervisord -n -c /etc/supervisord.conf cap_add: - SYS_PTRACE environment: APP_ENV: "developer" networks: - default 主要增加了 9001:9001的端口映射,然后在容器内部通过 expose 暴露 9001 的端口。 在 volumes 里面映射了 supervisor 的相关配置文件。 在 command 中加入了supervisor启动脚本: command: - /bin/sh - -c - | supervisord -n -c /etc/supervisord.conf 修改supervisor对于php的监听配置 services/supervisor/conf.d/php-fpm.ini [program:php] command=/usr/local/bin/php -S 0.0.0.0:80 directory=/var/www/ 然后重新 build 容器,最后 up 启动容器就成功了! 测试结果 总结 当前处理方式的弊端 本篇文章处理的方式是将 supervisor 安装进 php 容器中,然后运行命令 command=/usr/local/bin/php -S 0.0.0.0:80 来监听 php 的相关进程。 所以这样做的弊端就是,如果 php 的容器宕机后,由于 supervisor 存在于php容器中,那么 supervisor 也将无法使用,所以supervisor失去了他监控健康进程的意义。 基于当前的环境要求如何改写 可以将 supervisor 继续作为一个独立的容器运行,然后在配置文件command中,通过 docker exec - it xxx xx 等相关配置,来监听别的容器中的相关进程是否健康。
consul介绍 Consul是一个微服务管理软件。支持多数据中心下,分布式高可用的,服务发现和配置共享。采用 Raft 算法,用来保证服务的高可用。 参考文献 软件下载:https://www.consul.io/downloads 软件安装 下载软件 wget https://releases.hashicorp.com/consul/1.10.1/consul_1.10.1_linux_amd64.zip 解压软件 unzip consul_1.10.1_linux_amd64.zip 将软件置于系统启动目录 mv consul /usr/local/bin/consul 软件启动 consul agent -server -bootstrap-expect 1 -data-dir /data/consul -node=sn1 -bind=192.168.1.100 -ui -client=0.0.0.0 访问ui界面 访问地址:ip:8500 软件启动说明: consul必须启动agent才能使用,有两种启动模式server和client。 server用与持久化服务信息,集群官方建议3或5个节点。client只用与于server交互。ui界面可以查看集群情况的。 参数解释: -bootstrap-expect:集群期望的节点数,只有节点数量达到这个值才会选举leader。 -server: 运行在server模式 -data-dir:指定数据目录,其他的节点对于这个目录必须有读的权限 -node:指定节点的名称 -bind:为该节点绑定一个地址 -config-dir:指定配置文件,定义服务的,默认所有一.json结尾的文件都会读 -enable-script-checks=true:设置检查服务为可用 -datacenter: 数据中心没名称, -join:加入到已有的集群中 基本使用 常用命令介绍 如果不想用命令操作,当然你也可以通过 ui 界面进行服务管理。 服务管理 注册一个服务 [root@204 ~]# curl -X PUT -d '{"ID":"order_1","Name":"order","Tags":["xdp-\/core.order"],"Address":"192.168.232.201","Port":18307,"Check":{"name":"order_1.check","tcp":"192.168.232.201:18307","interval":"10s","timeout":"2s"}}' http://192.168.169.99:8500/v1/agent/service/register 查询指定服务 [root@204 ~]# curl http://192.168.169.99:8500/v1/health/service/order [{"Node":{"ID":"0d05756b-e7d3-6fbb-38e4-334de3220fea","Node":"consul1.4.4_client_public_5","Address":"172.17.0.5","Datacenter":"xdp_dc","TaggedAddresses":{"lan":"172.17.0.5","wan":"172.17.0.5"},"Meta":{"consul-network-segment":""},"CreateIndex":229415,"ModifyIndex":229415},"Service":{"ID":"order_1","Service":"order","Tags":["xdp-/core.order"],"Address":"192.168.232.201","Meta":null,"Port":18307,"Weights":{"Passing":1,"Warning":1},"EnableTagOverride":false,"ProxyDestination":"","Proxy":{},"Connect":{},"CreateIndex":229415,"ModifyIndex":229415},"Checks":[{"Node":"consul1.4.4_client_public_5","CheckID":"serfHealth","Name":"Serf Health Status","Status":"passing","Notes":"","Output":"Agent alive and reachable","ServiceID":"","ServiceName":"","ServiceTags":[],"Definition":{},"CreateIndex":229415,"ModifyIndex":229415},{"Node":"consul1.4.4_client_public_5","CheckID":"service:order_1","Name":"order_1.check","Status":"passing","Notes":"","Output":"TCP connect 192.168.232.201:18307: Success","ServiceID":"order_1","ServiceName":"order","ServiceTags":["xdp-/core.order"],"Definition":{},"CreateIndex":229415,"ModifyIndex":303563}]}][root@204 ~]# 查询所有服务 [root@204 ~]# curl http://192.168.169.99:8500/v1/agent/services {"core.product-/192.168.16.170:8001":{"ID":"core.product-/192.168.16.170:8001","Service":"core.product","Tags":["xdp-/core.product"],"Meta":{},"Port":18306,"Address":"192.168.232.100","Weights":{"Passing":1,"Warning":1},"EnableTagOverride":false},"goods_1":{"ID":"goods_1","Service":"goods","Tags":["xdp-/core.product"],"Meta":{},"Port":18307,"Address":"192.168.232.200","Weights":{"Passing":1,"Warning":1},"EnableTagOverride":false},"test1":{"ID":"test1","Service":"test1name","Tags":["xdp-/core.product"],"Meta":{},"Port":18306,"Address":"192.168.232.100","Weights":{"Passing":1,"Warning":1},"EnableTagOverride":false}}[root@204 ~]# 注销服务 [root@204 ~]# curl -X PUT http://192.168.169.99:8500/v1/agent/service/deregister/order_1 key/value配置中心命令行 查看所有key/value [root@204 ~]# curl http://192.168.169.99:8500/v1/kv/?recurse [{"LockIndex":0,"Key":"dd","Flags":0,"Value":"ewoiYiI6ImEiCn0=","CreateIndex":103396,"ModifyIndex":104797},{"LockIndex":0,"Key":"dd/dd/ff/uu","Flags":0,"Value":"NTU1NQ==","CreateIndex":104870,"ModifyIndex":104927},{"LockIndex":0,"Key":"dd/dd/gg","Flags":0,"Value":null,"CreateIndex":104868,"ModifyIndex":104868},{"LockIndex":0,"Key":"dd/ff","Flags":0,"Value":"MTIz","CreateIndex":104864,"ModifyIndex":104864}][root@204 ~]# 查询所有的key/value [root@204 ~]# curl http://192.168.169.99:8500/v1/kv/?recurse [{"LockIndex":0,"Key":"dd","Flags":0,"Value":"ewoiYiI6ImEiCn0=","CreateIndex":103396,"ModifyIndex":104797},{"LockIndex":0,"Key":"dd/dd/ff/uu","Flags":0,"Value":"NTU1NQ==","CreateIndex":104870,"ModifyIndex":104927},{"LockIndex":0,"Key":"dd/dd/gg","Flags":0,"Value":null,"CreateIndex":104868,"ModifyIndex":104868},{"LockIndex":0,"Key":"dd/ff","Flags":0,"Value":"MTIz","CreateIndex":104864,"ModifyIndex":104864}] 添加key/value [root@204 ~]# curl -X PUT -d 'test' http://192.168.169.99:8500/v1/kv/abc/key1 true 说明 key: abc/key1 value:test 查看单个key/value [root@204 ~]# curl http://192.168.169.99:8500/v1/kv/abc/key1 [{"LockIndex":0,"Key":"abc/key1","Flags":0,"Value":"dGVzdA==","CreateIndex":304012,"ModifyIndex":304012}] 说明 value是test的base64编码(使用base64编码是为了允许非UTF-8的字符) 修改key/value [root@204 ~]# curl -X PUT -d 'test666' http://192.168.169.99:8500/v1/kv/abc/key1 true 删除key/value [root@204 ~]# curl -X DELETE http://192.168.169.99:8500/v1/kv/abc/key1 true
参考文献 think-swoole ThinkPHP 6.0 开发文档:https://www.kancloud.cn/manual/thinkphp6_0/1037479 Think-swoole 文档:https://www.kancloud.cn/manual/thinkphp6_0/1359700 socket.io 客户端Api:https://www.w3cschool.cn/socket/socket-k49j2eia.html 代码下载:https://github.com/mailjobblog/dev_swoole/tree/master/210529_think-swoole laravels Laravel 开发文档:https://learnku.com/docs/laravel/8.x/installation/9354 laravels 中文文档:https://github.com/hhxsv5/laravel-s/blob/master/README-CN.md 代码下载:https://github.com/mailjobblog/dev_swoole/tree/master/210601_laravels inotify修改代码后自动Reload:https://github.com/hhxsv5/laravel-s/blob/master/README-CN.md#修改代码后自动reload supervisorctl使用详解:https://www.jianshu.com/p/0b9054b33db3 常见问题 think-swoole的通信协议问题 think-swoole 中的websocket协议聊天,使用的是 socket.io 协议,开发的时候要注意规范。 举个例子,比如 websocket 客户端可以使用 send 向服务端发送消息,但是 socket.io 使用的是 emit 发送消息。 还有事件的绑定,客户端绑定的事件名称要和服务端监听的事件名称一致。 swoole是长驻内存,修改代码后需要重启服务吗 由于swoole服务启动后会把 php-fpm 加载到服务里面,所以php每次访问的时候直接访问服务而省去了服务启动步骤。但是这样做的时候,新加入程序代码由于是在服务器启动后加入的,没有被加入到服务中,导致修改的代码不生效。 所以针对这个问题,有如下的解决方案: 采用swoole提供的 reload 重载方法,点击链接 阅读 采用第三方软件inotify实现自动重栽,参考上文中的链接
前言 在 Server 程序中如果需要执行很耗时的操作,比如一个聊天服务器发送广播,Web 服务器中发送邮件。如果直接去执行这些函数就会阻塞当前进程,导致服务器响应变慢。 Swoole 提供了异步任务处理的功能,可以投递一个异步任务到 TaskWorker 进程池中执行,不影响当前请求的处理速度。 参考文献 task异步任务初实现 task异步任务解释:https://wiki.swoole.com/#/start/start_task 异步服务器实现:https://wiki.swoole.com/#/start/start_tcp_server task异步任务数量计算:https://wiki.swoole.com/#/server/setting?id=task_worker_num 测试代码下载:https://github.com/mailjobblog/dev_swoole/tree/master/210524_task task异步消息丢失问题解决 swoole用 sysvmsg 消息队列通信:https://wiki.swoole.com/#/server/setting?id=task_ipc_mode swoole 设置linux消息队列的key:https://wiki.swoole.com/#/server/setting?id=message_queue_key php实现IPC消息队列:http://rango.swoole.com/archives/103 linux的IPC消息队列:https://www.linuxidc.com/Linux/2018-05/152191.htm 图例: worker和task关系图:https://www.kdocs.cn/view/l/sjVNT7NoZs3G task处理大量数据图:https://www.kdocs.cn/view/l/scsd4tcZHaqg Task异步任务的实现解析 异步任务 task id 计算方式 假设 Worker id 的范围是: min:0,max:$serv->setting[‘worker_num’] 则 Task 任务 id 计算范围如下: min:serv−>setting[′workernum’],‘max:‘serv->setting['worker_num’],`max:`serv−>setting[′workernum’],‘max:‘serv->setting[‘worker_num’] + $serv->setting['task_worker_num’] Task如何产生 当 worker 进程得到任务后, fork 出来一个 task 进程。然后把任务投递到 taskWorker 进程池中执行。 Worker 和 Task 投递实现 测试代码 <?php $serv = new Swoole\Server('0.0.0.0', 9601); //设置异步任务的工作进程数量 $serv->set([ 'worker_num' => 4, 'task_worker_num' => 6 ]); //此回调函数在worker进程中执行 $serv->on('Receive', function ($serv, $fd, $reactor_id, $data) { //投递异步任务 $task_id = $serv->task($data); echo "代码继续执行中: id={$task_id}\n"; }); //处理异步任务(此回调函数在task进程中执行) $serv->on('Task', function ($serv, $task_id, $reactor_id, $data) { echo "正在处理异步任务[id={$task_id}]" . PHP_EOL; sleep(6);// 睡眠中,模拟任务处理 //返回任务执行的结果 $serv->finish("{$data} -> OK"); }); //处理异步任务的结果(此回调函数在worker进程中执行) $serv->on('Finish', function ($serv, $task_id, $data) { echo "异步任务执行完成咯[{$task_id}] Finish: {$data}" . PHP_EOL; }); $serv->start(); 测试说明 服务端启动以上代码,设置4个worker和6个task,启动服务 客户端在服务器执行 telnet 127.0.0.1 9601 进行消息投递。 然后查看 task 进程树。可以发现 4 个worker进程,6个task进程。 # 查看进程树 pstree -ap | grep -v grep | grep task # 树状展示 pstree -p 25658 Task异步任务消息丢失问题 问题描述:task 异步任务,处理大量数据的时候。当 worker 进程不断的向 task 进程投递任务,然而当所有的 task 任务都处于忙碌状态的时候,此时的 task 异步任务会被暂时存放到 linux 服务器的临时目录 tmp 中。当 tmp 临时目录里存在一些数据的并且服务器宕机后,重启服务器后,然后再启动server服务,socket 连接会被重置,导致新的 server 服务中的 task “不认识” tmp 中的数据,由此产生丢失问题。 解决方案:Linux底层实现了 IPC 消息机制,对于默认task消息投递,加入了sysvms消息队列中间层。一个简单的流程表达是 Worker -> Sysvms -> tmp -> Task。在投递任务的时候,先定义一个key,然后基于这个key生成一个消息队列。worker 投递任务的时候到达 Sysvms 中间者,如果所有 task 都处于忙碌状态,则此任务会存储在 linux Sysvms 消息队列中。如果发送宕机,基于之前定义的 key ,就可以得到还没处理完毕的task任务,然后接着处理。 查看 linux sysvmsg 消息队列: ipcs -qa swoole 解决问题方案:
前言 php业务场景中,我们在多个进程之间的通信一般会通过redis内存缓存来达到效果 在协程间的通信,我们可以使用Channel来实现,在类比php多进程处理的时候,可以将Channel类比成redis的队列 Channel特点 与容量有关 如果channel未满,push不阻塞,如果已满,push让出控制流; 如果channel为空,pop让出控制流 demo代码 <?php /** * 生产者:3个协程序 * * 生产者:设置一个容量为15的channel */ $chan = new \Swoole\Coroutine\Channel(15); function t4(\Swoole\Coroutine\Channel $chan) { Co::sleep(0.005); #1 (让出控制流,刮起) $chan->push([__METHOD__=>__LINE__]); #2 } function t5(\Swoole\Coroutine\Channel $chan) { Co::sleep(0.005); #3 (让出控制流,刮起) $chan->push([__METHOD__=>__LINE__]); #4 } function t6(\Swoole\Coroutine\Channel $chan) { Co::sleep(0.005); #5 (让出控制流,刮起) $chan->push([__METHOD__=>__LINE__]); #6 } // 启动 3 个生产者协程 go("t4", $chan); go("t5", $chan); go("t6", $chan); /** * 消费者:cousume协程:c1 */ go(function() use($chan) { // chan元素个数 $chanNum = 3; // chan有数据时 while($chanNum>0) { #7 $item = $chan->pop(); #8 var_dump($item); #9 $chanNum --; } }); 代码分析 角色说明 3个生产者协程 (t4、t5、t6) 1个消费者协程 (c1) 代码执行 t4 协程中#1,遇到 Co::sleep() ,让出控制流,挂起(#3、#5 同理)。 c1开始执行,while循环为真,执行 channel::pop() 可能情况: #8中可能情况1:channel为空,c1让出控制流,挂起; #8中可能情况2:channel非空,pop弹出数据,while循环继续 如果 while 为假,则结束,c1 流量结束 如果 while 为真,则进入 channel::pop() 流程,t4 恢复执行(t4、t5、t6 的 sleep 时间相同,因此都有可能线恢复执行,但是统一时刻只有有一个恢复,这里以 t4 为例)。此时 t4 的 channel 写入数据,那么 channel 非空。此时控制流发生变化,消费者协程 c1 恢复执行。c1 协程运行直到让出控制流或者结束。
参考文献 新版文档:https://wiki.swoole.com/#/runtime 新版文档(协程高级):https://wiki.swoole.com/#/coroutine 新版文档(协程调度):https://wiki.swoole.com/#/coroutine/scheduler?id=coroutinescheduler 新版文档(协程api):https://wiki.swoole.com/#/coroutine/coroutine?id=create 旧版文档:https://wiki.swoole.com/wiki/page/p-coroutine.html PHP yield 关键字:https://www.php.net/manual/zh/language.generators.syntax.php 代码下载:https://github.com/mailjobblog/dev_swoole/tree/master/210525_coroutine swoole协程运行方式 协程是轻量级线程,协程也是属于线程,协程是在线程里执行的。协程的调度是用户手动切换的,所以又叫用户空间线程。协程的创建、切换、挂起、销毁全部为内存操作,消耗是非常低的。协程的调度策略是:协作式调度。 Swoole4 由于是单线程多进程的,同一时间同一个进程只会有一个协程在运行。 Swoole server 接收数据在 worker 进程触发 onReceive 回调,产生一个协程。Swoole 为每个请求创建对应携程。协程中也能创建子协程。 协程在底层实现上是单线程的,因此同一时间只有一个协程在工作,协程的执行是串行的。 因此多任务多协程执行时,一个协程正在运行时,其他协程会停止工作。当前协程执行阻塞 IO 操作时会挂起,底层调度器会进入事件循环。当有 IO 完成事件时,底层调度器恢复事件对应的协程的执行。。所以协程不存在 IO 耗时,非常适合高并发 IO 场景。(如下图) 协程没有 IO 等待 正常执行 PHP 代码,不会产生执行流程切换 协程遇到 IO 等待 立即将控制权切,待 IO 完成后,重新将执行流切回原来协程切出的点 协程并行协程依次执行,同上一个逻辑 协程嵌套执行流程由外向内逐层进入,直到发生 IO,然后切到外层协程,父协程不会等待子协程结束 多进程模型和协程模型应用场景 首先, 一般的计算机任务分为 2 种: CPU密集型, 比如加减乘除等科学计算 IO 密集型, 比如网络请求, 文件读写等 其次, 高性能相关的 2 个概念: 并行: 同一个时刻, 同一个 CPU 只能执行同一个任务, 要同时执行多个任务, 就需要有多个 CPU 才行 并发: 由于 CPU 切换任务非常快, 快到人类可以感知的极限, 就会有很多任务 同时执行 的错觉 了解了这些, 我们再来看协程, 协程适合的是 IO 密集型 应用, 因为协程在 IO阻塞 时会自动调度,减少IO阻塞导致的时间损失 测试注意事项 sleep() 可以看做是 CPU密集型任务, 不会引起协程的调度 Co::sleep() 模拟的是 IO密集型任务, 会引发协程的调度 协程实现方式 Scheduler <?php $scheduler = new Swoole\Coroutine\Scheduler; $scheduler->add(function(){ Swoole\Coroutine::sleep(3); echo 'i am Coroutine'.PHP_EOL; }); $scheduler->add(function(){ Swoole\Coroutine::sleep(1); echo 'i am Coroutine2222222222222222'.PHP_EOL; }); $scheduler->start(); Coroutine <?php echo 'start'.PHP_EOL; Swoole\Coroutine::create(function(){ Swoole\Coroutine::sleep(3); echo 'i am Coroutine'.PHP_EOL; }); echo 'end'.PHP_EOL; go <?php echo 'start'.PHP_EOL; go(function(){ co::sleep(3); echo 'i am Coroutine'.PHP_EOL; }); echo 'end'.PHP_EOL; Go\run <?php echo 'start'.PHP_EOL; Go\run(function(){ co::sleep(3); echo 'i am Coroutine'.PHP_EOL; }); echo 'end'.PHP_EOL;
参考文献 官方多进程文档:https://wiki.swoole.com/#/process/process?id=__construct php卡死问题定位:https://course.swoole-cloud.com/article/2 代码下载:https://github.com/mailjobblog/dev_swoole/tree/master/210524_process_more 多进程问题 为什么要 wait 结束子进程 每个子进程结束后,父进程必须都要执行一次 wait() 进行回收,否则子进程会变成僵尸进程,会浪费操作系统的进程资源。 如果子进程不写入管道数据可以吗 不可以。主进程会处于阻塞状态,等待子进程的管道数据。 演示demo 场景描述 定义两个进程,一个发送邮件的进程,一个发送短信的进程。swoole创建两个进程后,采用管道进行通信,然后由父进程读取管道的数据,进行返回展示。 代码实现 <?php // 程序开始时间 $start = microtime(true); // 数据定义 $info = array( "status" => 1, "mailto" => "666@qq.com", "smsto" => "999999999" ); /** * 开启两个进程 * * 发送邮件进程 + 发送短信进程 */ $mail_process = new Swoole\Process('sendMail',true); $mail_process->start();// fork 一个子进程 $sms_process = new Swoole\Process('sendSMS',true); $sms_process->start(); /** * 读取管道的内容 */ echo $mail_process->read(); echo PHP_EOL; echo $sms_process->read(); echo PHP_EOL; // 回收结束调用的子进程 Swoole\Process::wait(true);// true 为阻塞 Swoole\Process::wait(true); // 程序结束时间 $end = microtime(true); echo "用时:".($end - $start).PHP_EOL; /** * 发送邮件进程程序 */ function sendMail(Swoole\Process $worker){ global $info; // 模拟业务执行 sleep(2); // 写入到管道 $worker->write("子进程的pid:".$worker->pid."......邮件发送地址:".$info['mailto']); } /** * 发送短信进程程序 */ function sendSMS(Swoole\Process $worker){ global $info; if($info['status']==1){ sleep(3); echo "短信息发送地址:".$info['smsto']; } }
参考文献 进程结构官方文档:https://wiki.swoole.com/#/learn?id=diff-process 进程结构图:https://wiki.swoole.com/#/server/init?id=进程线程结构图 图例:https://www.kdocs.cn/view/l/sfUsBSd2K1f2 图例:https://www.kdocs.cn/view/l/snjd2FtKwsew Swoole进程结构 Master 进程 (主进程) Manager 进程 (管理进程) Worker 进程 (工作进程) task 进程 (异步任务工作进程) client与server的交互 1、client 请求到达main reactor,与master进程中的某个reactor线程连接 2、main reactor将请求注册给对应的reactor 3、客户端有变化时reactor将数据交给worker处理 4、worker处理完毕,通过进程间通信,发给对应的reactor 5、reactor将响应结果发给相应的连接请求处理完成 6、Manager 负责 (创建/回收)worker/task 进程 主要进程说明 Master进程 Master进程主要用来保证Swoole框架机制的运行。它会创建几个功能性的线程: Reactor线程:就是真正处理TCP连接,收发数据的线程。swoole的主线程在Accept新的连接后,会将这个连接分配给一个固定的Reactor线程,并由这个线程负责监听此socket。在socket可读时读取数据,并进行协议解析,将请求投递到Worker进程。在socket可写时将数据发送给TCP客户端。 Master线程(主线程): 负责:Accept新的连接、UNIX PROXI信号处理、定时器任务。 心跳包检测线程:(略) UDP收包线程:(略) Manager进程 swoole中Worker/Task进程都是由Manager进程Fork并管理的。 子进程结束运行时,manager进程负责回收此子进程,避免成为僵尸进程。并创建新的子进程 服务器关闭时,manager进程将发送信号给所有子进程,通知子进程关闭服务 服务器reload时,manager进程会逐个关闭/重启子进程 为什么不是Master进程呢,主要原因是Master进程是多线程的,不能安全的执行fork操作。 Worker进程 接受由Reactor线程投递的请求数据包,并执行PHP回调函数处理数据 生成响应数据并发给Reactor线程,由Reactor线程发送给TCP客户端 可以是异步非阻塞模式,也可以是同步阻塞模式 Worker以多进程的方式运行 Swoole提供了完善的进程管理机制,当Worker进程异常退出,如发生PHP的致命错误、被其他程序误杀,或达到max_request次数之后正常退出。主进程会重新拉起新的Worker进程。 Worker进程内可以像普通的apache+php或者php-fpm中写代码。不需要像Node.js那样写异步回调的代码。 Task进程 接受由Worker进程通过swoole_server->task/taskwait方法投递的任务 处理任务,并将结果数据返回给Worker进程 完全是同步阻塞模式 Task以多进程的方式运行 Task进程的全称是task_worker进程,是一种特殊的worker进程。所以onWorkerStart在task进程中也会被调用。 当$worker_id >= $serv->setting['worker_num']时表示这个进程是task_worker,否则,代表此进程是worker进程。 进程与事件回调的对应关系 Master进程内的回调函数 onStart onShutdown onMasterConnect onMasterClose onTimer Worker进程内的回调函数 onWorkerStart onWorkerStop onConnect onClose onReceive onTimer onFinish Task进程内的回调函数 onTask onWorkerStart Manager进程内的回调函数 onManagerStart onManagerStop 通俗比喻 假设Server就是一个工厂,那Reactor就是销售,接受客户订单。而Worker就是工人,当销售接到订单后,Worker去工作生产出客户要的东西。而TaskWorker可以理解为行政人员,可以帮助Worker干些杂事,让Worker专心工作。 Manager 相当于HR,负责招聘员工(Worker),或者把不干活的员工(Worker)开除。 Swoole进程测试 测试代码 <?php $server = new Swoole\Server('127.0.0.1', 9503); $server->on('connect', function ($server, $fd){ echo "connection open: {$fd}\n"; }); $server->on('receive', function ($server, $fd, $reactor_id, $data) { $server->send($fd, "Swoole: {$data}"); $server->close($fd); }); $server->on('close', function ($server, $fd) { echo "connection close: {$fd}\n"; }); $server->start(); 进程查看
参考文献 什么是reactor模型:https://www.cnblogs.com/52php/p/5701354.html php代码下载:https://github.com/mailjobblog/dev_php_io/tree/master/test/reactor 图解reactor模型:https://blog.csdn.net/weixin_39724469/article/details/111295927 Reactor介绍 connection per thread 在BIO线程模型中,为了解决同步阻塞的问题,采用了多线程的方式处理并发,即经典的connection per thread,每一个连接用一个线程处理。虽然在单个线程内仍然是阻塞的,但在整体上看是可以同时处理多个连接请求的,原理图如下: 但这种方式的缺点在于资源要求太高,系统中创建线程是需要比较高的系统资源的,如果连接数太多,系统无法承受,而且,线程的反复创建和销毁也需要代价。 Reactor线程思想 为了解决这个问题,出现了Reactor线程模型。简单来说,Reactor线程模型就是多路I/O复用结合线程池的思想。 I/O多路复用:多个连接共用一个阻塞对象(即下图中的ServiceHandler),应用程序只需要在一个阻塞对象等待,无需阻塞等待所有连接,当某个连接有新的数据可以处理时,操作系统通知应用程序线程从阻塞状态返回,并将数据分发给对应的线程处理 基于线程池复用线程资源:不必再为每个连接创建线程,将连接完成后的业务处理任务交给线程池中的线程处理,处理完成后归还线程,同一个线程可以处理多个连接的业务,达到线程复用 PHP原生代码实现
引言 PHP 原生实现 异步模型,主要依靠于 Event 事件实现 参考文献 5种IO模型:https://blog.mailjob.net/posts/3565199751.html github代码下载:https://github.com/mailjobblog/dev_php_io/tree/master/test/async php Event 事件使用:https://blog.mailjob.net/posts/1280406180.html 模型图 对于异步来说,用户进行读或者写后,将立刻返回,由内核去完成数据读取以及拷贝工作,完成后通知用户,并执行回调函数(用户提供的callback),此时数据已从内核拷贝到用户空间,用户线程只需要对数据进行处理即可,不需要关注读写,用户不需要等待内核对数据的复制操作,用户在得到通知时数据已经被复制到用户空间。我们以如下的真实异步非阻塞为例。 可以发现,用户在调用之后会立即返回,由内核完成数据的拷贝工作,并通知用户线程,进行回调。 PHP原生代码实现
参考资料 PHP中Event扩展安装:https://blog.mailjob.net/posts/877067227.html EventBase 类:https://www.php.net/manual/zh/class.eventbase.php 注册事件函数(Event::__construct):https://www.php.net/manual/zh/event.construct.php 添加事件函数(Event::add):https://www.php.net/manual/zh/event.add.php 创建应用监督事件函数(EventBase::__construct):https://www.php.net/manual/zh/eventbase.construct.php 调度未决事件(EventBase::loop):https://www.php.net/manual/zh/eventbase.loop.php Event预定义常量 参考文档:https://www.php.net/manual/zh/class.event.php#event.constants.read Event-flags 函数描述 Event::READ 此标志表示当所提供的文件描述符(通常是流资源或套接字)准备好可读取时活动的事件。 Event::WRITE 标志表示当提供的文件描述符(通常是流资源或套接字)准备好写入时活动的事件。 Event::SIGNAL 用于实现信号检测。参见下面的“构造信号事件” Event::TIMEOUT 这个Event::TIMEOUT在构造事件时忽略标志:可以在事件发生时设置超时。加或者不是。设置为 $什么 当超时发生时,回调函数的参数 代码测试 代码下载 https://github.com/mailjobblog/dev_swoole/tree/master/210519_event 轮询Event测试 基于php原生的 event 事件类,实现一个简单的定时轮询任务 <?php /** * 设置事件库 * https://www.php.net/manual/zh/class.eventbase.php */ $eventBase = new EventBase(); /** * 定义事件类 * https://www.php.net/manual/zh/event.construct.php * base 要关联的事件库 * fd 计时器使用 -1,信号则使用信号编号 * what (Event::TIMEOUT | Event::PERSIST)表示定时并且不结束 * cb 事件的回调函数 */ $event = new Event($eventBase, -1, Event::TIMEOUT | Event::PERSIST, function(){ // 事件类的动作 echo microtime(true). ": 我第一次来了\n"; }); /** * 将事件挂起 设置事件时间 * https://www.php.net/manual/zh/event.add.php */ $event->add(2);// 2 秒 // 重复上面的功能 $event1 = new Event($eventBase, -1, Event::TIMEOUT | Event::PERSIST, function(){ echo microtime(true). ": 嘎嘎嘎嘎,我又来了\n"; }); $event1->add(0.5); // 0.5 秒 /** * 调度未解决的事件 将事件变成活动状态 * https://www.php.net/manual/zh/eventbase.loop.php */ $eventBase->loop(); Event的Socket测试 代码下载 https://github.com/mailjobblog/dev_swoole/tree/master/210519_event/event_socket 代码 event_socket_server.php <?php // 建立协议服务 $socket = stream_socket_server("tcp://0.0.0.0:8000", $errno, $errstr); $eventBase = new EventBase(); /** * socket 可读,可写,持续监听 */ $event = new Event($eventBase, $socket, Event::READ | Event::WRITE | Event::PERSIST, function($socket) { $connect = stream_socket_accept($socket); $read = fread($connect, 65535); var_dump($read); fwrite($connect,"this is eventSocketServer \n"); fclose($connect); }); $event->add(); $eventBase->loop(); event_socket_client.php <?php $fp = stream_socket_client("tcp://127.0.0.1:8000"); fwrite($fp, "niHaoYa"); var_dump(fread($fp,65535)); Swoole 实现 Event 事件 代码下载 https://github.com/mailjobblog/dev_swoole/tree/master/210519_event/event_swoole 代码 swoole_event.php <?php $server = stream_socket_server("tcp://0.0.0.0:8000", $errno, $errstr); var_dump($server); Swoole\Event::add($server, function($socket){ // 事件类的动作 $conn = stream_socket_accept($socket); Swoole\Event::add($conn, function($socket){ // 事件类的动作 var_dump(fread($socket, 65535)); fwrite($socket, 'The local time is ' . date('n/j/Y g:i a') . "\n"); Swoole\Event::del($socket); fclose($socket); }); }); client.php <?php $fp = stream_socket_client("tcp://127.0.0.1:8000"); fwrite($fp, "niHaoYa6666666666666666"); var_dump(fread($fp,65535));
常见安装方式 通过pecl方式安装 通过php 容器中自带的几个特殊命令来安装,这些特殊命令可以在Dockerfile中的RUN命令中进行使用。 下载安装扩展,或者复制进容器安装扩展 通过pecl方式安装 pecl软件地址:http://pecl.php.net/ 因为一些扩展并不包含在 PHP 源码文件中,所有需要使用 PECL(PHP 的扩展库仓库,通过 PEAR 打包)。用 pecl install 安装扩展,然后再用官方提供的 docker-php-ext-enable 快捷脚本来启用扩展,如下示例 FROM php:7.1-fpm RUN apt-get update \ # 手动安装依赖 && apt-get install -y libmemcached-dev zlib1g-dev \ # 安装需要的扩展 && pecl install memcached-2.2.0 \ # 启用扩展 && docker-php-ext-enable memcached 通过几个特殊命令安装 docker-php-source docker-php-ext-install docker-php-ext-enable docker-php-ext-configure 演示这三个命令的作用 都是在PHP容器中进行演示的,PHP容器启动太简单,不作过多介绍 docker-php-source 此命令,实际上就是在PHP容器中创建一个/usr/src/php的目录,里面放了一些自带的文件而已。我们就把它当作一个从互联网中下载下来的PHP扩展源码的存放目录即可。事实上,所有PHP扩展源码扩展存放的路径: /usr/src/php/ext 里面。 格式: docker-php-source extract | delete 参数说明: * extract : 创建并初始化 /usr/src/php目录 * delete : 删除 /usr/src/php目录 案例: root@803cbcf702a4:/usr/src# ls -l total 11896 #此时,并没有php目录 -rw-r--r-- 1 root root 12176404 Jun 28 03:23 php.tar.xz -rw-r--r-- 1 root root 801 Jun 28 03:23 php.tar.xz.asc root@803cbcf702a4:/usr/src# docker-php-source extract root@803cbcf702a4:/usr/src# ls -l total 11900 #此时,生产了php目录,里面还有一些文件,由于篇幅问题,就不进去查看了 drwxr-xr-x 14 root root 4096 Aug 9 09:01 php -rw-r--r-- 1 root root 12176404 Jun 28 03:23 php.tar.xz -rw-r--r-- 1 root root 801 Jun 28 03:23 php.tar.xz.asc root@803cbcf702a4:/usr/src# docker-php-source delete root@803cbcf702a4:/usr/src# ls -l total 11896 #此时,将已创建 php 目录给删除了 -rw-r--r-- 1 root root 12176404 Jun 28 03:23 php.tar.xz -rw-r--r-- 1 root root 801 Jun 28 03:23 php.tar.xz.asc root@803cbcf702a4:/usr/src# docker-php-ext-enable 这个命令,就是用来启动 PHP扩展 的。我们使用pecl安装PHP扩展的时候,默认是没有启动这个扩展的,如果想要使用这个扩展必须要在php.ini这个配置文件中去配置一下才能使用这个PHP扩展。而 docker-php-ext-enable 这个命令则是自动给我们来启动PHP扩展的,不需要你去php.ini这个配置文件中去配置。 案例 # 查看现有可以启动的扩展 root@517b9c67507a:/usr/local/etc/php# ls /usr/local/lib/php/extensions/no-debug-non-zts-20170718/ opcache.so redis.so sodium.so root@517b9c67507a:/usr/local/etc/php# # 查看redis 扩展是否可以启动 root@517b9c67507a:/usr/local/etc/php# php -m | grep redis root@517b9c67507a:/usr/local/etc/php# # 启动 redis 扩展 root@517b9c67507a:/usr/local/etc/php# docker-php-ext-enable redis # 启动 成功 root@517b9c67507a:/usr/local/etc/php# php -m | grep redis redis root@517b9c67507a:/usr/local/etc/php# #说明,php容器中默认是没有php.ini配置文件的,加载原理如下所示 root@517b9c67507a:/usr/local/etc/php# php -i | grep -A 5 php.ini Configuration File (php.ini) Path => /usr/local/etc/php Loaded Configuration File => (none) # 核心是 /usr/local/etc/php/conf.d 目录下的扩展配置文件 Scan this dir for additional .ini files => /usr/local/etc/php/conf.d Additional .ini files parsed => /usr/local/etc/php/conf.d/docker-php-ext-redis.ini, /usr/local/etc/php/conf.d/docker-php-ext-sodium.ini root@517b9c67507a:/usr/local/etc/php# docker-php-ext-install 这个命令,是用来安装并启动PHP扩展的。 命令格式: docker-php-ext-install “源码包目录名” 注意点: “源码包“需要放在 /usr/src/php/ext 下 默认情况下,PHP容器没有 /usr/src/php这个目录,需要使用 docker-php-source extract来生成。 docker-php-ext-install 安装的扩展在安装完成后,会自动调用docker-php-ext-enable来启动安装的扩展。 卸载扩展,直接删除/usr/local/etc/php/conf.d 对应的配置文件即可。 案例 # 卸载redis 扩展 root@803cbcf702a4:/usr/local# rm -rf /usr/local/etc/php/conf.d/docker-php-ext-redis.ini root@803cbcf702a4:/usr/local# php -m [PHP Modules] Core ctype curl date dom fileinfo filter ftp hash iconv json libxml mbstring mysqlnd openssl pcre PDO pdo_sqlite Phar posix readline Reflection session SimpleXML sodium SPL sqlite3 standard tokenizer xml xmlreader xmlwriter zlib [Zend Modules] root@803cbcf702a4:/usr/local# #PHP容器默认是没有redis扩展的。所以我们通过docker-php-ext-install安装redis扩展 root@803cbcf702a4:/# curl -L -o /tmp/reids.tar.gz https://codeload.github.com/phpredis/phpredis/tar.gz/5.0.2 root@803cbcf702a4:/# cd /tmp root@517b9c67507a:/tmp# tar -xzf reids.tar.gz root@517b9c67507a:/tmp# ls phpredis-5.0.2 reids.tar.gz root@517b9c67507a:/tmp# docker-php-source extract root@517b9c67507a:/tmp# mv phpredis-5.0.2 /usr/src/php/ext/phpredis #检查移过去的插件源码包是否存在 root@517b9c67507a:/tmp# ls -l /usr/src/php/ext | grep redis drwxrwxr-x 6 root root 4096 Jul 29 15:04 phpredis root@517b9c67507a:/tmp# docker-php-ext-install phpredis # 检查redis 扩展是否已经安装上 root@517b9c67507a:/tmp# php -m | grep redis redis root@517b9c67507a:/tmp# docker-php-ext-configure docker-php-ext-configure 一般都是需要跟 docker-php-ext-install搭配使用的。它的作用就是,当你安装扩展的时候,需要自定义配置时,就可以使用它来帮你做到。 案例 FROM php:7.1-fpm RUN apt-get update \ # 相关依赖必须手动安装 && apt-get install -y \ libfreetype6-dev \ libjpeg62-turbo-dev \ libmcrypt-dev \ libpng-dev \ # 安装扩展 && docker-php-ext-install -j$(nproc) iconv mcrypt \ # 如果安装的扩展需要自定义配置时 && docker-php-ext-configure gd --with-freetype-dir=/usr/include/ --with-jpeg-dir=/usr/include/ \ && docker-php-ext-install -j$(nproc) gd 下载安装扩展 一些既不在 PHP 源码包,也不再 PECL 扩展仓库中的扩展,可以通过下载扩展程序源码,编译安装的方式安装,如下示例: FROM php:5.6-apache RUN curl -fsSL 'https://xcache.lighttpd.net/pub/Releases/3.2.0/xcache-3.2.0.tar.gz' -o xcache.tar.gz \ && mkdir -p xcache \ && tar -xf xcache.tar.gz -C xcache --strip-components=1 \ && rm xcache.tar.gz \ && ( \ cd xcache \ && phpize \ && ./configure --enable-xcache \ && make -j$(nproc) \ && make install \ ) \ && rm -r xcache \ && docker-php-ext-enable xcache 注意:官方提供的 docker-php-ext-* 脚本接受任意的绝对路径(不支持相对路径,以便与系统内置的扩展程序进行区分),所以,上面的例子也可以这样写: FROM php:5.6-apache RUN curl -fsSL 'https://xcache.lighttpd.net/pub/Releases/3.2.0/xcache-3.2.0.tar.gz' -o xcache.tar.gz \ && mkdir -p /tmp/xcache \ && tar -xf xcache.tar.gz -C /tmp/xcache --strip-components=1 \ && rm xcache.tar.gz \ && docker-php-ext-configure /tmp/xcache --enable-xcache \ && docker-php-ext-install /tmp/xcache \ && rm -r /tmp/xcache
很多被广泛使用的CMS,之所以被这么多人应用,就是因为他们的插件机制,可以实现丰富多彩的功能。而插件机制很大程度依赖于事件,在一些关键位置触发事件,可以方便二次开发,并且不更改系统核心代码。 事件机制本身并不复杂,我们不扯概念理论,直接上代码来看。看完此篇文章,或许可以有助让你更加深刻的理解 laravel 的 route 事件。比如我们有一个添加用户的方法,代码如下: function addUser($userInfo) { $db->execute('insert into tb_users(xxx)'); } 如果我想要我的系统在添加用户以后,可以让二次开发者做一些自定义处理,那么这时候可以使用事件。下面贴出YurunPHP的Event类代码: <?php /** * 事件类 */ class Event { /** * 事件绑定记录 */ private static $events = array(); /** * 注册事件 * @param string $event * @param mixed $callback * @param bool $first 是否优先执行,以靠后设置的为准 */ public static function register($event, $callback, $first=false) { if (! isset(self::$events[$event])) { self::$events[$event] = array (); } if($first) { array_unshift(self::$events[$event],$callback); } else { self::$events[$event][] = $callback; } } /** * 触发事件(监听事件) * 不是引用传参方式,如有需要请使用triggerReference方法 * @param name $event * @param boolean $once * @return mixed */ public static function trigger($event, $params=array()) { if (isset(self::$events[$event])) { foreach (self::$events[$event] as $item) { if(true === call_user_func($item,$params)) { // 事件返回true时不继续执行其余事件 return true; } } return false; } return true; } } 使用Event类监听一个事件 function addUser($userInfo) { // 逻辑执行 $result = $db->execute('insert into tb_users(xxx)'); // 事件触发 Event::trigger('ON_USER_ADD',array('userInfo'=>$userInfo,'result'=>&$result)); } 在其它地方注册事件进行处理,事件名称是 ON_USER_ADD Event::register('ON_USER_ADD',function($data){ // 这里可以做一些事情 var_dump($data); }); 其实原理很简单,就比如先挖个坑,然后再填进去。PHP中主要依靠 call_user_func 这类动态调用函数方法的函数来实现。
前言 PHP事件的基础设施的 libevent ,然后基于 event 做事件的处理 什么是事件? 正常的程序执行, 或者说人的思维趋势, 都是按照 时间线性串行 的, 保持 连续性. 不过现实中会存在各种 打断, 程序也不是永远都是 就绪状态, 那么, 就需要有一种机制, 来处理可能出现的各种打断, 或者在程序不同状态之间切换. 参考资料 PHP event 事件文档:https://www.php.net/manual/zh/book.event.php libevent 下载地址:https://libevent.org event 扩展下载地址:https://pecl.php.net/package/event 扩展安装 libevent 安装 # 下载软件 wget https://github.com/libevent/libevent/releases/download/release-2.1.12-stable/libevent-2.1.12-stable.tar.gz # 解压软件 tar -zxvf libevent-2.1.12-stable.tar.gz # 进入软件目录 cd libevent-2.1.12-stable/ # 生成 makeFile ./configure --prefix=/usr/local/libevent-2.1.12 # 编译软件 make # 安装软件 make install event 扩展安装 # 下载软件 wget https://pecl.php.net/get/event-3.0.4.tgz # 解压软件 tar -zxvf event-3.0.4.tgz # 进入软件目录 cd event-3.0.4 # 校验软件 phpize # 生成 makeFile ./configure --with-php-config=/usr/local/php/bin/php-config # 编译软件 make # 安装软件 make install 在php.ini添加下面配置 extension=event.so 重启 php-fpm 后,使用 php -m | grep event 查看event库插件是否安装成功
参考文献 5种IO模型:https://blog.mailjob.net/posts/3565199751.html github代码下载:https://github.com/mailjobblog/dev_php_io/tree/master/test/signal 快速了解信号驱动io:https://www.itzhai.com/articles/it-seems-not-so-perfect-signal-driven-io.html Linux 信号列表:https://wiki.swoole.com/#/other/signal 安装信号处理器函数(pcntl_signal):https://www.php.net/manual/zh/function.pcntl-signal.php 向进程发送信号函数(posix_kill):https://www.php.net/manual/zh/function.posix-kill.php 调用等待信号的处理器函数(pcntl_signal_dispatch):https://www.php.net/manual/zh/function.pcntl-signal-dispatch.php 获取进程的 PID 函数(posix_getpid):https://www.php.net/manual/zh/function.posix-getpid.php 信号驱动IO原理 所谓信号驱动式I/O(signal-driven I/O),就是预先告知内核,当某个描述符准备发生某件事情的时候,让内核发送一个信号通知应用进程。 主要的实现: Berkeley的实现使用SIGIO信号支持套接字和终端设备上的信号驱动式I/O; SVR4使用SIGPOLL信号支持流设备上的信号驱动。 SIGPOLL等价于SIGIO。 通过UDP的recvfrom()函数演示其工作原理如下图所示: 系统注册了ISGIO信号处理函数,并且启动了信号驱动式IO后,就可以继续执行程序了,等到数据报准备好之后,内核会发送一个SIGIO信号给应用进程,然后应用进程在信号处理函数中调用recvfrom读取数据报。 这种模型在内核等待数据报达到期间进程不会被阻塞,可以继续执行。 代码示例 server.php <?php require_once __DIR__."/../../vendor/autoload.php"; use DevPhpIO\Signal\Worker; $server = new Worker('0.0.0.0', 9500); $server->on('connect', function($server, $client){ dd($client, "客户端成功建立连接"); }); $server->on('receive', function(Worker $server, $client, $data){ dd($data, "处理client的数据"); $server->send($client, "hello i’m is server"); // $server->close($client); }); $server->on('close', function($server, $client){ dd($client, "连接断开"); }); $server->start(); client.php <?php require_once __DIR__."/../../vendor/autoload.php"; // 连接服务端 $fp = stream_socket_client("tcp://127.0.0.1:9500"); fwrite($fp, "hello world lalala"); dd(fread($fp, 65535)); 演示截图
前言 UML类图是一种结构图,用于描述一个系统的静态结构。类图以反映类结构和类之间关系为目的,用以描述软件系统的结构,是一种静态建模方法。类图中的类,与面向对象语言中的类的概念是对应的。 参考文献 uml图例工具:https://www.umlet.com php根据uml生成代码:https://www.laruence.com/2010/05/14/1473.html UML类图 类结构 在类的UML图中,使用长方形描述一个类的主要构成,长方形垂直地分为三层,以此放置类的名称、属性和方法。 其中, 一般类的类名用正常字体粗体表示,如上图;抽象类名用斜体字粗体,如User;接口则需在上方加上<>。 属性和方法都需要标注可见性符号,+代表public,#代表protected,-代表private。 另外,还可以用冒号:表明属性的类型和方法的返回类型,如+$name:string、+getName():string。当然,类型说明并非必须。 类关系 类与类之间的关系主要有六种:继承、实现、组合、聚合、关联和依赖,这六种关系的箭头表示如下, 接着我们来了解类关系的具体内容。 六种类关系 六种类关系中,组合、聚合、关联这三种类关系的代码结构一样,都是用属性来保存另一个类的引用,所以要通过内容间的关系来区别。 继承关系 继承关系也称泛化关系(Generalization),用于描述父类与子类之间的关系。父类又称作基类,子类又称作派生类。 继承关系中,子类继承父类的所有功能,父类所具有的属性、方法,子类应该都有。子类中除了与父类一致的信息以外,还包括额外的信息。 例如:公交车、出租车和小轿车都是汽车,他们都有名称,并且都能在路上行驶。 Php代码实现如下: <?php class Car { public $name; public function run() { return '在行驶中'; } } class Bus extends Car { public function __construct() { $this->name = '公交车'; } } class Taxi extends Car { public function __construct() { $this->name = '出租车'; } } // 客户端代码 $line2 = new Bus; echo $line2->name . $line2->run(); 实现关系 实现关系(Implementation),主要用来规定接口和实现类的关系。 接口(包括抽象类)是方法的集合,在实现关系中,类实现了接口,类中的方法实现了接口声明的所有方法。 例如:汽车和轮船都是交通工具,而交通工具只是一个可移动工具的抽象概念,船和车实现了具体移动的功能。 <?php interface Vehicle { public function run(); } class Car implements Vehicle { public $name = '汽车'; public function run() { return $this->name . '在路上行驶'; } } class Ship implements Vehicle { public $name = '轮船'; public function run() { return $this->name . '在海上航行'; } } // 客户端代码 $car = new Car; echo $car->run(); 组合关系 组合关系(Composition):整体与部分的关系,但是整体与部分不可以分开。 组合关系表示类之间整体与部分的关系,整体和部分有一致的生存期。一旦整体对象不存在,部分对象也将不存在,是同生共死的关系。 例如:人由头部和身体组成,两者不可分割,共同存在。 <?php class Head { public $name = '头部'; } class Body { public $name = '身体'; } class Human { public $head; public $body; public function setHead(Head $head) { $this->head = $head; } public function setBody(Body $body) { $this->body = $body; } public function display() { return sprintf('人由%s和%s组成', $this->head->name, $this->body->name); } } // 客户端代码 $man = new Human(); $man->setHead(new Head()); $man->setBody(new Body()); echo $man->display(); 聚合关系 聚合关系(Aggregation):整体和部分的关系,整体与部分可以分开。 聚合关系也表示类之间整体与部分的关系,成员对象是整体对象的一部分,但是成员对象可以脱离整体对象独立存在。 例如:公交车司机和工衣、工帽是整体与部分的关系,但是可以分开,工衣、工帽可以穿在别的司机身上,公交司机也可以穿别的工衣、工帽。 <?php class Clothes { public $name = '工衣'; } class Hat { public $name = '工帽'; } class Driver { public $clothes; public $hat; public function wearClothes(Clothes $clothes) { $this->clothes = $clothes; } public function wearHat(Hat $hat) { $this->hat = $hat; } public function show() { return sprintf('公交车司机穿着%s和%s', $this->clothes->name, $this->hat->name); } } // 客户端代码 $driver = new Driver(); $driver->wearClothes(new Clothes()); $driver->wearHat(new Hat()); echo $driver->show(); 关联关系 关联关系(Association):表示一个类的属性保存了对另一个类的一个实例(或多个实例)的引用。 关联关系是类与类之间最常用的一种关系,表示一类对象与另一类对象之间有联系。组合、聚合也属于关联关系,只是关联关系的类间关系比其他两种要弱。 关联关系有四种:双向关联、单向关联、自关联、多重数关联。 例如:汽车和司机,一辆汽车对应特定的司机,一个司机也可以开多辆车。 在UML图中,双向的关联可以有两个箭头或者没有箭头,单向的关联或自关联有一个箭头。上图对应的PHP代码如下: <?php class Driver { public $cars = array(); public function addCar(Car $car) { $this->cars[] = $car; } } class Car { public $drivers = array(); public function addDriver(Driver $driver) { $this->drivers[] = $driver; } } // 客户端代码 $jack = new Driver(); $line1 = new Car(); $jack->addCar($line1); $line1->addDriver($jack); print_r($jack); 在多重性关系中,可以直接在关联直线上增加一个数字,表示与之对应的另一个类的对象的个数。 1…1:仅一个 0…*:零个或多个 1…*:一个或多个 0…1:没有或只有一个 m…n:最少m、最多n个 (m<=n) 依赖关系 依赖关系(Dependence):假设A类的变化引起了B类的变化,则说名B类依赖于A类。 大多数情况下,依赖关系体现在某个类的方法使用另一个类的对象作为参数。 依赖关系是一种“使用”关系,特定事物的改变有可能会影响到使用该事物的其他事物,在需要表示一个事物使用另一个事物时使用依赖关系。 例如:汽车依赖汽油,如果没有汽油,汽车将无法行驶。 <?php class Oil { public $type = '汽油'; public function add() { return $this->type; } } class Car { public function beforeRun(Oil $oil) { return '添加' . $oil->add(); } } // 客户端代码 $car = new Car; echo $car->beforeRun(new Oil()); 总结 这六种类关系中,组合、聚合和关联的代码结构一样,可以从关系的强弱来理解,各类关系从强到弱依次是:继承→实现→组合→聚合→关联→依赖。如下是完整的一张UML关系图。 UML类图是面向对象设计的辅助工具,但并非是必须工具,如果暂时不理解本文的内容,可以继续看设计模式部分,并不会影响。
需求描述 某直播平台,需要观察员去不定时的抽查直播平台的内容,对于直播网站不良的直播进行封禁和停播的处理。 参考文献 Tcp server 服务:https://wiki.swoole.com/#/start/start_tcp_server?id=程序代码 端口监听:https://wiki.swoole.com/#/server/port?id=多端口监听 面向对象代码实现:https://github.com/mailjobblog/dev_swoole/tree/master/210427_listen 代码示例 面向过程代码 实现计划 server 建立一个 【TCP服务,端口为9501】 再建立一个监听服务,端口为9505,去监听 9501的TCP服务; client 作为观察角色,通过连接 9505 监听服务,然后监听服务再断掉 9501的TCP服务。 server.php <?php //创建Server对象,监听 0.0.0.0:9501 端口 $server = new Swoole\Server('0.0.0.0', 9501); //监听连接进入事件 $server->on('Connect', function ($server, $fd) { echo "Client: Connect.\n"; }); //监听数据接收事件 $server->on('Receive', function ($server, $fd, $reactor_id, $data) { $server->send($fd, "Server: {$data}"); }); //监听连接关闭事件 $server->on('Close', function ($server, $fd) { echo "Client: Close.\n"; }); //返回port对象 $port1 = $server->listen("127.0.0.1", 9505, SWOOLE_SOCK_TCP); $port1->on('connect', function ($serv, $fd){ echo " port1 Client:{$fd}Connect 已经连接到监听服务.\n"; }); $port1->on('receive', function ($serv, $fd, $from_id, $data )use ($server) { $server->shutdown(); // 关闭 server 服务 $serv->send($fd, 'Swoole: '.$data); $serv->close($fd); }); $port1->on('close', function ($serv, $fd) { echo " port1Client:{$fd} Close 关闭监听服务的连接.\n"; }); //启动服务器 $server->start(); client.php <?php $client = new Swoole\Client(SWOOLE_SOCK_TCP); if (!$client->connect('127.0.0.1', 9505, -1)) { exit("connect failed. Error: {$client->errCode}\n"); } $client->send("我是来关闭你的server的\n"); echo $client->recv(); $client->close(); 测试截图 面向对象代码 代码下载 参照上文中的参考文献下载 逻辑描述 通过连接业务服务,然后发送 code=0 的标识。 业务服务将该请求进行判断,然后发送给监听服务。 监听服务根据需求,处理相关的业务(停止/详情)逻辑。 stopClient.php 测试截图 infoClient.php 测试截图
参考文献 5种IO模型:https://blog.mailjob.net/posts/3565199751.html github代码下载:https://github.com/mailjobblog/dev_php_io/tree/master/test/multiplexing io复用模型理解:https://www.itzhai.com/articles/thoroughly-understand-io-reuse-take-you-in-depth-understanding-of-select-poll-epoll.html Epoll 多路复用是如何转起来的:https://mp.weixin.qq.com/s/Py2TE9CdQ92fGLpg-SEj_g IO多路复用原理 简单解读 在 I/O 多路复用模型中,会用到 select 或 poll 函数, 这两个函数也会使进程阻塞,但是和阻塞 I/O 所不同的是,这两个函数可以同时阻塞多个 I/O 操作,而且可以同时对多个读操作、多个写操作的 I/O 函数进行检测,直到有数据可读或可写时,才真正调用 I/O 操作函数。 从流程上来看,使用 select 函数进行 I/O 请求和同步阻塞模型没有太大的区别,甚至还多了添加监视 socket,以及调用 select 函数的额外操作,效率更差。但是,使用 select 以后最大的优势是用户可以在一个线程内同时处理多个 socket 的 I/O 请求。用户可以注册多个 socket,然后不断地调用 select 读取被激活的 socket,即可达到在同一个线程内同时处理多个 I/O 请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。 IO多路复用详解(可略过直接看代码) 非阻塞情况下无可用数据时,应用程序每次轮询内核看数据是否准备好了也耗费CPU,能否不让它轮询,当内核缓冲区数据准备好了,以事件通知当机制告知应用进程数据准备好了呢?应用进程在没有收到数据准备好的事件通知信号时可以忙写其他的工作。此时IO多路复用就派上用场了。 IO多路复用中文比较让人头大,IO多路复用的原文叫 I/O multiplexing,这里的 multiplexing 指的其实是在单个线程通过记录跟踪每一个Sock(I/O流)的状态来同时管理多个I/O流. 发明它的目的是尽量多的提高服务器的吞吐能力。实现一个线程监控多个IO请求,哪个IO有请求就把数据从内核拷贝到进程缓冲区,拷贝期间是阻塞的!现在已经可以通过采用mmap地址映射的方法,达到内存共享效果,避免真复制,提高效率。 像select、poll、epoll 都是I/O多路复用的具体的实现。 select select是第一版IO复用,提出后暴漏了很多问题。 select 会修改传入的参数数组,这个对于一个需要调用很多次的函数,是非常不友好的。 select 如果任何一个sock(I/O stream)出现了数据,select 仅仅会返回,但不会告诉是那个sock上有数据,只能自己遍历查找。 select 只能监视1024个链接。 select 不是线程安全的,如果你把一个sock加入到select, 然后突然另外一个线程发现这个sock不用,要收回,这个select 不支持的。 poll poll 修复了 select 的很多问题。 poll 去掉了1024个链接的限制。 poll 从设计上来说不再修改传入数组。 但是poll仍然不是线程安全的, 这就意味着不管服务器有多强悍,你也只能在一个线程里面处理一组 I/O 流。你当然可以拿多进程来配合了,不过然后你就有了多进程的各种问题。 epoll epoll 可以说是 I/O 多路复用最新的一个实现,epoll 修复了poll 和select绝大部分问题, 比如: epoll 现在是线程安全的。 epoll 现在不仅告诉你sock组里面数据,还会告诉你具体哪个sock有数据,你不用自己去找了。 epoll 内核态管理了各种IO文件描述符, 以前用户态发送所有文件描述符到内核态,然后内核态负责筛选返回可用数组,现在epoll模式下所有文件描述符在内核态有存,查询时不用传文件描述符进去了。 三者对比 横轴 Dead connections 是链接数的意思,叫这个名字只是它的测试工具叫deadcon。纵轴是每秒处理请求的数量,可看到epoll每秒处理请求的数量基本不会随着链接变多而下降的。poll 和/dev/poll 就很惨了。但 epoll 有个致命的缺点是只有linux支持。 比如平常Nginx为何可以支持4W的QPS是因为它会使用目标平台上面最高效的I/O多路复用模型。 原生PHP代码演示 服务端中主要用到了 stream_select 方法,通过 select 方法查找出可以操作的文件描述符,对其进行读写操作。 server.php <?php require_once __DIR__."/../../vendor/autoload.php"; use DevPhpIO\Multiplexing\Worker; $server = new Worker('0.0.0.0', 9500); $server->on('connect', function($server, $client){ dd($client, "客户端成功建立连接"); }); $server->on('receive', function(Worker $server, $client, $data){ dd($data, "处理client的数据"); $server->send($client, "hello i’m is server"); // $server->close($client); }); $server->on('close', function($server, $client){ dd($client, "连接断开"); }); $server->start(); client.php <?php require_once __DIR__."/../../vendor/autoload.php"; // 连接服务端 $fp = stream_socket_client("tcp://127.0.0.1:9500"); fwrite($fp, "hello world"); dd(fread($fp, 65535)); // 这里阻塞 10s 是为了便于演示 sleep(10); fwrite($fp, "第二个消息"); dd(fread($fp, 65535)); 演示截图
参考文献 5种IO模型:https://blog.mailjob.net/posts/3565199751.html github代码下载:https://github.com/mailjobblog/dev_php_io/tree/master/test/ 异步IO原理 对于异步来说,用户进行读或者写后,将立刻返回,由内核去完成数据读取以及拷贝工作,完成后通知用户,并执行回调函数(用户提供的callback),此时数据已从内核拷贝到用户空间,用户线程只需要对数据进行处理即可,不需要关注读写,用户不需要等待内核对数据的复制操作,用户在得到通知时数据已经被复制到用户空间。我们以如下的真实异步非阻塞为例。 同步跟异步对比 同步关注的消息通信机制synchronous communication,在发出一个调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了。换句话说,就是由调用者主动等待这个调用的结果。 异步关注消息通信机制asynchronous communication,调用在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在调用发出后,被调用者通过状态、通知来通知调用者,或通过回调函数处理这个调用。 代码示例
参考文献 5种IO模型:https://blog.mailjob.net/posts/3565199751.html github代码下载:https://github.com/mailjobblog/dev_php_io/tree/master/test/noblocking 函数(stream_set_blocking):https://php.golaravel.com/function.stream-set-blocking.html 函数(feof):https://php.golaravel.com/function.feof.html 函数(stream_select):https://php.golaravel.com/function.stream-select.html IO非阻塞模型原理 非阻塞IO发出read请求后发现数据没准备好,会继续往下执行,此时应用程序会不断轮询polling内核询问数据是否准备好,当数据没有准备好时,内核立即返回EWOULDBLOCK错误。直到数据被拷贝到应用程序缓冲区,read请求才获取到结果。并且你要注意!这里最后一次 read 调用获取数据的过程,是一个同步的过程,是需要等待的过程。这里的同步指的是内核态的数据拷贝到用户程序的缓存区这个过程。 原生php代码演示 代码 server.php <?php require __DIR__."/../../vendor/autoload.php"; use DevPhpIO\Blocking\Worker; $server = new Worker('0.0.0.0',9500); $server->on('connect',function($server,$client){ dd($client,'连接成功'); }); $server->on('receive',function($server,$client,$data){ dd($data,'处理client的数据'); sleep(5); // 进行阻塞,方便演示 $server->send($client, "hello i’m is server"); $server->close($client); }); $server->on('close',function($server,$client){ dd($client,'连接断开'); }); $server->start(); client.php <?php require __DIR__."/../../vendor/autoload.php"; $fp = stream_socket_client("tcp://127.0.0.1:9500"); //设置套接字为非阻塞模型 stream_set_blocking($fp, 0); fwrite($fp,'hello NO-blocking'); $time = time(); echo fread($fp,65535); echo "\n此处执行其他业务代码\r\n"; $m = time() - $time; echo "执行时间" . $m . "秒钟\n"; // # 1 // 用 feof 判断是否到达结尾的位置,如果到达,则跳出输出服务端返回的结果 // while(!feof($fp)){ // sleep(1); // var_dump(fread($fp,65535)); // } // # 2 // 用 stream_select 去循环遍历server的读写状态 // while(!feof($fp)){ // sleep(1); // $read[] = $fp; // stream_select($read, $write, $error, 1); // var_dump($read); // var_dump(fread($fp,65535)); // } fclose($fp); Tips: 和 php阻塞模型 相比,该模型代码中,在 client.php 中增加了 stream_set_blocking($fp, 0); 进而达到了,不等待服务端返回结果,而直接进行下一步处理的过程。 测试 测试后发现,虽然服务端阻塞了 5s ,但是该模型,并不需要等待阻塞,而直接返回结果。 上面的测试,虽然解决了,阻塞问题,但是又带来了一个新的问题,就是对于 server 返回的结果,无法拿到,所以根据上面的代码,打开 #1 的代码注释快,然后进行了如下的测试: 用 feof 判断是否到了指针的结束位置,如果到达了指针的结束位置,则输出 server 反馈的值。
环境配置 操作系统:CentOS Linux release 7.6.1810 Docker 版本:19.03.5 Nginx 版本:1.15.5 PHP 版本:7.2.26 MySQL 版本:8.0.18 Redis 版本:5.0.5 创建目录结构 docker目录://docker相关配置 [root@zhangdeTalk data]# tree docker docker ├── bin │ └── docker-compose-linux.yml //dockerfile.yml ├── config │ ├── mysql │ │ └── mysqld.cnf //数据库配置文件 │ ├── nginx │ │ ├── conf.d │ │ │ └── default.conf //nginx主要配置文件 │ │ └── nginx.conf //nginx基础配置文件 │ ├── php │ │ ├── php.ini //php基础配置文件 │ │ └── www.conf //php主要配置文件 │ └── redis │ └── redis.conf //redis配置文件 ├── dockerfile │ ├── mysql-8.0.18 │ │ └── Dockerfile //mysql的dockerfile │ ├── nginx-1.15.5 │ │ └── Dockerfile //nginx的dockerfile │ ├── php-7.2-fpm │ │ └── Dockerfile //php的dockerfile │ └── redis-5.0.5 │ └── Dockerfile //redis的dockerfile ├── README.en.md └── README.md www目录://站点目录 [root@zhangdeTalk data]# tree www www └── zhangdetalk_blog_admin ├── 1.html └── index.php logs目录://日志目录 [root@zhangdeTalk data]# tree logs logs ├── mysql ├── nginx │ ├── access.log │ └── error.log ├── php └── redis mysql目录://数据库数据目录 redis目录://数据库数据目录 构建 lnmp 镜像 Dockerfile PHP Dockerfile From php:7.2-fpm #维护者信息 MAINTAINER zhangdeTalk 2393222021@qq.com #时区 ENV TZ Asia/Shanghai RUN date -R #RUN docker-php-ext-install bcmath dom fileinfo filter ftp gd gmp hash iconv imap json mbstring mysqli odbc opcache pdo pdo_mysql pdo_odbc phar reflection session snmp soap sockets zip #RUN docker-php-ext-install mysqli opcache pdo_mysql WORKDIR /working RUN apt-get update --fix-missing && apt-get install -y libpng-dev libjpeg-dev libfreetype6-dev \ && docker-php-ext-configure gd --with-freetype-dir=/usr/include --with-jpeg-dir=/usr/include/jpeg \ && docker-php-ext-install gd mysqli opcache pdo_mysql gd zip ENV PHPREDIS_VERSION 4.0.1 ENV PHPXDEBUG_VERSION 2.6.0 ENV PHPSWOOLE_VERSION 4.2.13 ENV PHPMONGODB_VERSION 1.5.3 RUN pecl install redis-$PHPREDIS_VERSION \ && pecl install xdebug-$PHPXDEBUG_VERSION \ && pecl install swoole-$PHPSWOOLE_VERSION \ && pecl install mongodb-$PHPMONGODB_VERSION \ && docker-php-ext-enable redis xdebug swoole mongodb # install composer new # https://getcomposer.org/installer | https://install.phpcomposer.com/installer RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \ && php composer-setup.php \ && php -r "unlink('composer-setup.php');" \ && mv composer.phar /usr/local/bin/composer \ && composer config -g repo.packagist composer https://mirrors.aliyun.com/composer/ RUN apt-get install -y git # clear RUN rm -rf /var/cache/apt/* \ && rm -rf /var/lib/apt/lists/* RUN mkdir /var/lib/sessions \ && chmod o=rwx -R /var/lib/sessions #容器启动时执行指令 CMD ["php-fpm"] Nginx Dockerfile From nginx:1.15.5 #维护者信息 MAINTAINER zhangdeTalk 2393222021@qq.com #时区 ENV TZ Asia/Shanghai RUN date -R #容器启动时执行指令 CMD ["nginx", "-g", "daemon off;"] Mysql Dockerfile From mysql:8.0.18 #维护者信息 MAINTAINER zhangdeTalk 2393222021@qq.com #时区 ENV TZ Asia/Shanghai RUN date -R #容器启动时执行指令 CMD ["mysqld"] Redis Dockerfile From redis:5.0.5 #维护者信息 MAINTAINER zhangdeTalk 2393222021@qq.com #时区 ENV TZ Asia/Shanghai RUN date -R #容器启动时执行指令 CMD ["redis-server"] dockerfile.yml 配置 version: '3.3' services: nginx: build: ../dockerfile/nginx-1.15.5 ports: - "80:80" #nginx restart: always tty: true container_name: nginx volumes: - /data/www:/var/www/html - /data/logs/nginx:/var/log/nginx - /data/docker/config/nginx/conf.d:/etc/nginx/conf.d - /data/docker/config/nginx/nginx.conf:/etc/nginx/nginx.conf - /etc/letsencrypt:/etc/letsencrypt networks: - lnmp-networks php7: build: ../dockerfile/php-7.2-fpm tty: true restart: always container_name: php7 volumes: - /data/www:/var/www/html - /data/logs/php:/var/log/php - /data/docker/config/php/php.ini:/usr/local/etc/php/php.ini - /data/docker/config/php/www.conf:/usr/local/etc/php-fpm.d/www.conf depends_on: - nginx networks: - lnmp-networks redis: build: ../dockerfile/redis-5.0.5 container_name: redis tty: true restart: always volumes: - /data/docker/config/redis/redis.conf:/etc/redis/redis.conf - /data/redis:/var/lib/redis - /data/logs/redis:/var/log/redis networks: - lnmp-networks mysql: build: ../dockerfile/mysql-8.0.18 container_name: mysql tty: true restart: always ports: - "3306:3306" #mysql volumes: - /data/mysql:/var/lib/mysql - /data/docker/config/mysql/mysqld.cnf:/etc/mysql/mysql.conf.d/mysqld.cnf - /data/logs/mysql:/var/log/mysql - /data/mysqlback:/data/mysqlback environment: MYSQL_ROOT_PASSWORD: root networks: - lnmp-networks networks: lnmp-networks: Nginx配置文件 vim /data/docker/config/nginx/conf.d/default.conf server { listen 80; listen [::]:80; # Add index.php to the list if you are using PHP index index.html index.htm index.nginx-debian.html index.php; charset utf-8; server_name zhangdetalk.com www.zhangdetalk.com; location ~ \.md$ { default_type 'text/plain'; } root /var/www/html/zhangdetalk_blog_admin;//项目目录 location / { #try_files $uri $uri/ =404; index index.php index.htm index.html; if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s=$1 last; break; } } location ~ \.php$ { include fastcgi_params; fastcgi_index index.php; fastcgi_pass php7:9000;//容器:端口号 fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } } docker-compose管理lnmp 安装docker-compose 1. curl -L https://get.daocloud.io/docker/compose/releases/download/1.22.0/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose 2. chmod +x /usr/local/bin/docker-compose //设置可执行权限 创建并启动容器 docker-compose -f docker-compose-linux.yml up -d --force-recreate --remove-orphans 容器查看 docker ps 测试 vim /data/www/zhangdetalk_blog_admin/index.php <?php echo "Hello World"; $conn = mysqli_connect('数据库容器名字','dbuser','dbpw'); if($conn){ echo '数据库连接成功!'; }else{ echo '数据库连接失败!'; } phpinfo(); ?> 参考文献 docker集合 jenkins 实现自动化部署:https://learnku.com/articles/39597 Docker+LNMP+Jenkins+ 码云实现 PHP 代码自动化部署:https://learnku.com/articles/39601
参考文献 Github520:https://github.com/521xueweihan/GitHub520 SwitchHosts:https://github.com/oldj/SwitchHosts/releases 众所周知Github在国内的访问速度一直都不怎么样,主要的原因是DNS解析太慢了,也就是从github.com获取IP的过程太慢了,因此,一种最有效的办法是直接修改本地Hosts,饶过DNS解析。 这也是本文的出发点,虽然,这样的文章网上有很多,百度一搜出来基本上都是如下的套路: 通过IP解析网站得到github.com的IP/直接给出某个IP 修改本地Hosts 刷新缓存 当然,这样最大的一个坏处是当IP更新的时候每次都需要去修改Hosts,非常麻烦。受到该仓库的启发,本文提供了一种自动修改的方法,非常方便。 本文主要分成两个部分介绍: 手动修改Hosts 通过工具自动修改Hosts 首先看一下第一部分。 2 手动修改Hosts 2.1 修改Hosts 首先打开该仓库: 复制其中的内容到Hosts文件中,各大平台Hosts文件位置如下: Windows:C:\Windows\System32\drivers\etc\hosts Linux:/etc/hosts Mac:/etc/hosts Android:/system/etc/hosts iOS:/etc/hosts 根据对应平台修改上述的文件,添加内容到Hosts末尾即可。 2.2 使Hosts生效 大部分情况下修改完Hosts文件后直接生效,如果不生效,可以尝试手动刷新DNS缓存,具体如下: Windows:打开cmd,输入ipconfig /flushdns Linux:sudo rcnscd restart Mac:sudo killall -HUP mDNSResponder 如果不生效可以尝试重启机器。 3 自动方式(推荐) 上面的方式需要手动修改Hosts文件,非常麻烦,这里推荐一种自动修改的方式。 在此之前需要一个工具:SwitchHosts。 3.1 SwitchHosts安装 官方仓库在此处,直接到Release下载即可。 或者从软件包仓库安装,以笔者的Manjaro为例: paru -S switchhosts-bin 3.2 配置 打开后,点击左下角的加号按钮,添加一个新的规则: 内容如下: 标题:随便 类型:Remote URL:https://cdn.jsdelivr.net/gh/521xueweihan/GitHub520@main/hosts 自动刷新时间:第一次添加可以先选择1 minute,有了规则以后,就可以选择1 hour 配置好后就可以看到了: 3.3 一个小问题 笔者在实测的时候发现如果直接通过启动菜单启动SwitchHosts,添加新的规则老是失败: 无论输入的是用户的密码还是root的密码都不行。 于是笔者找了一下启动命令,直接sudo启动: sudo switchhosts 启动失败,按照提示加上--no-sandbox(这是一个eletron的参数): sudo switchhosts --no-sandbox 需要注意在启动之前需要把原来普通模式启动的SwitchHosts先退出了,不然会提示端口占用: 启动之后就可以直接修改Hosts而不需要密码了。 另外这里有一个小细节就是两种模式下(普通模式/sudo模式)启动的图标不一样: 4 效果 配置好DNS后应该能正常访问Github上的图片了: 5 后记 通过上面的配置DNS方法应该就可以顺利访问Github了,此外,该仓库还介绍了另一种自动配置DNS的方式:AdGuard Home:
引言 SnowFlake 算法,是 Twitter 开源的分布式 id 生成算法。其核心思想就是:使用一个 64 bit 的 long 型的数字作为全局唯一 id。在分布式系统中的应用十分广泛,且ID 引入了时间戳,基本上保持自增的。 这个算法的好处很简单可以在每秒产生约400W个不同的16位数字ID(10进制)。 参考文献 twitter的snowflake: https://github.com/twitter-archive/snowflake 原理 snowflake算法的核心原理是把一个64位的整数分为3个部分,如下图: 构成说明 ID由64bit组成,其中 第一个bit空缺 41bit用于存放毫秒级时间戳 10bit用于存放机器id 12bit用于存放自增ID 除了最高位bit标记为不可用以外,其余三组bit占位均可浮动,看具体的业务需求而定。默认情况下41bit的时间戳可以支持该算法使用到2082年,10bit的工作机器id可以支持1023台机器,序列号支持1毫秒产生4095个自增序列id 如图所示,高端的第一位不使用,接着的41位字节用于存储毫秒级的时间戳,紧跟着时间戳的10位作为机器ID,而最后12位为序列号。 对于不同的机器来说,可以为每一台机器分配一个唯一的机器ID,这样就可以保证每台机器锁生成的ID不会重复。 对于同一台机器,如果同一时刻多个客户端并发请求,那么可以通过增加序列号来保证ID唯一性。 PHP实现snowflake雪花算法 abstract class Particle { const EPOCH = 1479533469598; const max12bit = 4095; const max41bit = 1099511627775; static $machineId = null; public static function machineId($mId) { self::$machineId = $mId; } public static function generateParticle() { /* * Time - 42 bits */ $time = floor(microtime(true) * 1000); /* * Substract custom epoch from current time */ $time -= self::EPOCH; /* * Create a base and add time to it */ $base = decbin(self::max41bit + $time); /* * Configured machine id - 10 bits - up to 1024 machines */ $machineid = str_pad(decbin(self::$machineId), 10, "0", STR_PAD_LEFT); /* * sequence number - 12 bits - up to 4096 random numbers per machine */ $random = str_pad(decbin(mt_rand(0, self::max12bit)), 12, "0", STR_PAD_LEFT); /* * Pack */ $base = $base.$machineid.$random; /* * Return unique time id no */ return bindec($base); } public static function timeFromParticle($particle) { /* * Return time */ return bindec(substr(decbin($particle),0,41)) - self::max41bit + self::EPOCH; } } $machineID = 0; // Machine ID (aka Server ID no.) Particle::generateParticle($machineID); ## 输出示例 5190075640165958205 5190075733132707771 5190075762421530692
引言 MySQL 视图(View)是一种虚拟存在的表,同真实表一样,视图也由列和行构成,但视图并不实际存在于数据库中。行和列的数据来自于定义视图的查询中所使用的表,并且还是在使用视图时动态生成的。 数据库中只存放了视图的定义,并没有存放视图中的数据,这些数据都存放在定义视图查询所引用的真实表中。使用视图查询数据时,数据库会从真实表中取出对应的数据。因此,视图中的数据是依赖于真实表中的数据的。一旦真实表中的数据发生改变,显示在视图中的数据也会发生改变。 视图可以从原有的表上选取对用户有用的信息,那些对用户没用,或者用户没有权限了解的信息,都可以直接屏蔽掉,作用类似于筛选。这样做既使应用简单化,也保证了系统的安全。 基本概念 场面描述说明 例如,下面的数据库中有一张公司部门表 department。表中包括部门号(d_id)、部门名称(d_name)、功能(function)和办公地址(address)。department 表的结构如下: mysql> DESC department; +----------+-------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra | +----------+-------------+------+-----+---------+-------+ | d_id | int(4) | NO | PRI | NULL | | | d_name | varchar(20) | NO | UNI | NULL | | | function | varchar(50) | YES | | NULL | | | address | varchar(50) | YES | | NULL | | +----------+-------------+------+-----+---------+-------+ 4 rows in set (0.02 sec) 还有一张员工表 worker。表中包含了员工的工作号(num)、部门号(d_id)、姓名(name)、性别(sex)、出生日期(birthday)和家庭住址(homeaddress)。worker 表的结构如下: mysql> DESC worker; +-------------+-------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra | +-------------+-------------+------+-----+---------+-------+ | num | int(10) | NO | PRI | NULL | | | d_id | int(4) | YES |MUL | NULL | | | name | varchar(20) | NO | | NULL | | | sex | varchar(4) | NO | | NULL | | | birthday | datetime | YES | | NULL | | | homeaddress | varchar(50) | YES | | NULL | | +-------------+-------------+------+-----+---------+-------+ 6 rows in set (0.01 sec) 由于各部门领导的权力范围不同,因此,各部门的领导只能看到该部门的员工信息;而且,领导可能不关心员工的生日和家庭住址。为了达到这个目的,可以为各部门的领导建立一个视图,通过该视图,领导只能看到本部门员工的指定信息。 例如,为生产部门建立一个名为 product view 的视图。通过视图 product view,生产部门的领导只能看到生产部门员工的工作号、姓名和性别等信息。这些 department 表的信息和 worker 表的信息依然存在于各自的表中,而视图 product_view 中不保存任何数据信息。当 department 表和 worker 表的信息发生改变时,视图 product_view 显示的信息也会发生相应的变化。 技巧:如果经常需要从多个表查询指定字段的数据,可以在这些表上建立一个视图,通过这个视图显示这些字段的数据。 MySQL 的视图不支持输入参数的功能,因此交互性上还有欠缺。但对于变化不是很大的操作,使用视图可以很大程度上简化用户的操作。 视图并不同于数据表,它们的区别在于以下几点: 视图不是数据库中真实的表,而是一张虚拟表,其结构和数据是建立在对数据中真实表的查询基础上的。 存储在数据库中的查询操作 SQL 语句定义了视图的内容,列数据和行数据来自于视图查询所引用的实际表,引用视图时动态生成这些数据。 视图没有实际的物理记录,不是以数据集的形式存储在数据库中的,它所对应的数据实际上是存储在视图所引用的真实表中的。 视图是数据的窗口,而表是内容。表是实际数据的存放单位,而视图只是以不同的显示方式展示数据,其数据来源还是实际表。 视图是查看数据表的一种方法,可以查询数据表中某些字段构成的数据,只是一些 SQL 语句的集合。从安全的角度来看,视图的数据安全性更高,使用视图的用户不接触数据表,不知道表结构。 视图的建立和删除只影响视图本身,不影响对应的基本表。 视图的优点: 视图与表在本质上虽然不相同,但视图经过定义以后,结构形式和表一样,可以进行查询、修改、更新和删除等操作。同时,视图具有如下优点: 1) 定制用户数据,聚焦特定的数据 在实际的应用过程中,不同的用户可能对不同的数据有不同的要求。 例如,当数据库同时存在时,如学生基本信息表、课程表和教师信息表等多种表同时存在时,可以根据需求让不同的用户使用各自的数据。学生查看修改自己基本信息的视图,安排课程人员查看修改课程表和教师信息的视图,教师查看学生信息和课程信息表的视图。 2) 简化数据操作 在使用查询时,很多时候要使用聚合函数,同时还要显示其他字段的信息,可能还需要关联到其他表,语句可能会很长,如果这个动作频繁发生的话,可以创建视图来简化操作。 3) 提高数据的安全性 视图是虚拟的,物理上是不存在的。可以只授予用户视图的权限,而不具体指定使用表的权限,来保护基础数据的安全。 4) 共享所需数据 通过使用视图,每个用户不必都定义和存储自己所需的数据,可以共享数据库中的数据,同样的数据只需要存储一次。 5) 更改数据格式 通过使用视图,可以重新格式化检索出的数据,并组织输出到其他应用程序中。 6) 重用 SQL 语句 视图提供的是对查询操作的封装,本身不包含数据,所呈现的数据是根据视图定义从基础表中检索出来的,如果基础表的数据新增或删除,视图呈现的也是更新后的数据。视图定义后,编写完所需的查询,可以方便地重用该视图。 要注意区别视图和数据表的本质,即视图是基于真实表的一张虚拟的表,其数据来源均建立在真实表的基础上。 使用视图的时候,还应该注意以下几点: 创建视图需要足够的访问权限。 创建视图的数目没有限制。 视图可以嵌套,即从其他视图中检索数据的查询来创建视图。 视图不能索引,也不能有关联的触发器、默认值或规则。 视图可以和表一起使用。 视图不包含数据,所以每次使用视图时,都必须执行查询中所需的任何一个检索操作。如果用多个连接和过滤条件创建了复杂的视图或嵌套了视图,可能会发现系统运行性能下降得十分严重。因此,在部署大量视图应用时,应该进行系统测试。 提示:ORDER BY 子句可以用在视图中,但若该视图检索数据的 SELECT 语句中也含有 ORDER BY 子句,则该视图中的 ORDER BY 子句将被覆盖。 Mysql视图(View)操作演示 department CREATE TABLE `department` ( `d_id` int(11) NOT NULL AUTO_INCREMENT COMMENT '部门号', `d_name` varchar(20) NOT NULL COMMENT '部门名称', `function` varchar(50) DEFAULT NULL COMMENT '功能', `address` varchar(50) DEFAULT NULL COMMENT '办公地址', PRIMARY KEY (`d_id`,`d_name`) ) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COMMENT='部门表'; -- ---------------------------- -- Records of department -- ---------------------------- INSERT INTO `department` VALUES ('1', '总裁办', '批改奏折', '华北'); INSERT INTO `department` VALUES ('2', '财务部', '出账', '华南'); INSERT INTO `department` VALUES ('3', '技术部', '干技术', '华中'); INSERT INTO `department` VALUES ('4', '设计部', '干设计的', '华东'); worker CREATE TABLE `worker` ( `num` int(11) NOT NULL, `d_id` int(11) NOT NULL, `name` varchar(20) NOT NULL, `sex` varchar(4) NOT NULL, `birthday` datetime DEFAULT NULL, `homeaddress` varchar(50) DEFAULT NULL, PRIMARY KEY (`num`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工表'; -- ---------------------------- -- Records of worker -- ---------------------------- INSERT INTO `worker` VALUES ('222', '4', '张三', '男', '1993-06-03 09:23:24', '北京朝阳群众'); INSERT INTO `worker` VALUES ('333', '3', '李四', '男', '1990-07-03 09:23:24', '湖南长沙实名'); INSERT INTO `worker` VALUES ('444', '2', '王五', '女', '1998-01-13 09:23:24', '上海开发大道'); INSERT INTO `worker` VALUES ('555', '1', '赵六', '男', '1995-01-03 09:23:24', '深圳长江一号'); INSERT INTO `worker` VALUES ('666', '2', '小明', '男', '1999-07-07 09:23:24', '天津市武清区'); INSERT INTO `worker` VALUES ('777', '3', '小红', '女', '1992-04-23 09:23:24', '河北承德市'); 创建视图基本语法 可以使用 CREATE VIEW 语句来创建视图。语法格式如下: CREATE VIEW <视图名> AS <SELECT语句> 语法说明如下。 <视图名>:指定视图的名称。该名称在数据库中必须是唯一的,不能与其他表或视图同名。 <SELECT语句>:指定创建视图的 SELECT 语句,可用于查询多个基础表或源视图。 对于创建视图中的 SELECT 语句的指定存在以下限制: 用户除了拥有 CREATE VIEW 权限外,还具有操作中涉及的基础表和其他视图的相关权限。 SELECT 语句不能引用系统或用户变量。 SELECT 语句不能包含 FROM 子句中的子查询。 SELECT 语句不能引用预处理语句参数。 视图定义中引用的表或视图必须存在。但是,创建完视图后,可以删除定义引用的表或视图。可使用 CHECK TABLE 语句检查视图定义是否存在这类问题。 视图定义中允许使用 ORDER BY 语句,但是若从特定视图进行选择,而该视图使用了自己的 ORDER BY 语句,则视图定义中的 ORDER BY 将被忽略。 视图定义中不能引用 TEMPORARY 表(临时表),不能创建 TEMPORARY 视图。 WITH CHECK OPTION 的意思是,修改视图时,检查插入的数据是否符合 WHERE 设置的条件。 创建单表视图 【实例 1】在 worker 表中,创建 view_worker_info 视图 CREATE VIEW view_worker_info AS SELECT * FROM worker; 查看视图 mysql> select * from view_worker_info; +-----+------+------+-----+---------------------+--------------+ | num | d_id | name | sex | birthday | homeaddress | +-----+------+------+-----+---------------------+--------------+ | 222 | 4 | 张三 | 男 | 1993-06-03 09:23:24 | 北京朝阳群众 | | 333 | 3 | 李四 | 男 | 1990-07-03 09:23:24 | 湖南长沙实名 | | 444 | 2 | 王五 | 女 | 1998-01-13 09:23:24 | 上海开发大道 | | 555 | 1 | 赵六 | 男 | 1995-01-03 09:23:24 | 深圳长江一号 | | 666 | 2 | 小明 | 男 | 1999-07-07 09:23:24 | 天津市武清区 | | 777 | 3 | 小红 | 女 | 1992-04-23 09:23:24 | 河北承德市 | +-----+------+------+-----+---------------------+--------------+ 6 rows in set 【实例 2】在 worker 表中,根据某些字段,创建 view_worker_attr 视图 CREATE VIEW view_worker_attr (s_num,s_name,s_homeaddress) AS SELECT num,name,homeaddress FROM worker; 查看视图 mysql> select * from view_worker_attr; +-------+--------+---------------+ | s_num | s_name | s_homeaddress | +-------+--------+---------------+ | 222 | 张三 | 北京朝阳群众 | | 333 | 李四 | 湖南长沙实名 | | 444 | 王五 | 上海开发大道 | | 555 | 赵六 | 深圳长江一号 | | 666 | 小明 | 天津市武清区 | | 777 | 小红 | 河北承德市 | +-------+--------+---------------+ 6 rows in set 创建多表视图 创建基于 department 和 worker 的联合视图 CREATE VIEW view_info ( s_num, s_name, d_id, s_dname, s_homeaddress, s_address ) AS SELECT num, `name`, worker.d_id, d_name, homeaddress, address FROM worker, department WHERE worker.d_id = department.d_id; 查看视图 mysql> select * from view_info; +-------+--------+------+---------+---------------+-----------+ | s_num | s_name | d_id | s_dname | s_homeaddress | s_address | +-------+--------+------+---------+---------------+-----------+ | 222 | 张三 | 4 | 设计部 | 北京朝阳群众 | 华东 | | 333 | 李四 | 3 | 技术部 | 湖南长沙实名 | 华中 | | 444 | 王五 | 2 | 财务部 | 上海开发大道 | 华南 | | 555 | 赵六 | 1 | 总裁办 | 深圳长江一号 | 华北 | | 666 | 小明 | 2 | 财务部 | 天津市武清区 | 华南 | | 777 | 小红 | 3 | 技术部 | 河北承德市 | 华中 | +-------+--------+------+---------+---------------+-----------+ 6 rows in set 查看视图基本语法 查看视图的字段信息与查看数据表的字段信息一样,都是使用 DESCRIBE 关键字来查看的。具体语法如下: DESCRIBE 视图名; 或简写成: DESC 视图名; 测试 查看联合视图 view_info 的视图结构 mysql> DESCRIBE view_info; +---------------+-------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra | +---------------+-------------+------+-----+---------+-------+ | s_num | int(11) | NO | | NULL | | | s_name | varchar(20) | NO | | NULL | | | d_id | int(11) | NO | | NULL | | | s_dname | varchar(20) | NO | | NULL | | | s_homeaddress | varchar(50) | YES | | NULL | | | s_address | varchar(50) | YES | | NULL | | +---------------+-------------+------+-----+---------+-------+ 6 rows in set 查看视图表的详细信息 在 MySQL 中,SHOW CREATE VIEW 语句可以查看视图的详细定义。其语法如下所示: SHOW CREATE VIEW 视图名; 通过上面的语句,还可以查看创建视图的语句。创建视图的语句可以作为修改或者重新创建视图的参考,方便用户操作。 测试 mysql> SHOW CREATE VIEW view_info \G; *************************** 1. row *************************** View: view_info Create View: CREATE ALGORITHM=UNDEFINED DEFINER=`offline`@`%` SQL SECURITY DEFINER VIEW `view_info` AS select `worker`.`num` AS `s_num`,`worker`.`name` AS `s_name`,`worker`.`d_id` AS `d_id`,`department`.`d_name` AS `s_dname`,`worker`.`homeaddress` AS `s_homeaddress`,`department`.`address` AS `s_address` from (`worker` join `department`) where (`worker`.`d_id` = `department`.`d_id`) character_set_client: utf8 collation_connection: utf8_general_ci 1 row in set (0.04 sec) ERROR: No query specified 上述 SQL 语句以\G结尾,这样能使显示结果格式化。如果不使用\G,显示的结果会比较混乱 拓展阅读 所有视图的定义都是存储在 information_schema 数据库下的 views 表中,也可以在这个表中查看所有视图的详细信息,SQL 语句如下: SELECT * FROM information_schema.views; 不过,通常情况下都是使用 SHOW CREATE VIEW 语句。 修改视图基本语法 可以使用 ALTER VIEW 语句来对已有的视图进行修改。语法格式如下: ALTER VIEW <视图名> AS <SELECT语句> 语法说明如下: <视图名>:指定视图的名称。该名称在数据库中必须是唯一的,不能与其他表或视图同名。 <SELECT 语句>:指定创建视图的 SELECT 语句,可用于查询多个基础表或源视图。 需要注意的是,对于 ALTER VIEW 语句的使用,需要用户具有针对视图的 CREATE VIEW 和 DROP 权限,以及由 SELECT 语句选择的每一列上的某些权限。 修改视图的定义,除了可以通过 ALTER VIEW 外,也可以使用 DROP VIEW 语句先删除视图,再使用 CREATE VIEW 语句来实现。 修改视图内容 视图是一个虚拟表,实际的数据来自于基本表,所以通过插入、修改和删除操作更新视图中的数据,实质上是在更新视图所引用的基本表的数据。 注意:对视图的修改就是对基本表的修改,因此在修改时,要满足基本表的数据定义。 某些视图是可更新的。也就是说,可以使用 UPDATE、DELETE 或 INSERT 等语句更新基本表的内容。对于可更新的视图,视图中的行和基本表的行之间必须具有一对一的关系。 还有一些特定的其他结构,这些结构会使得视图不可更新。更具体地讲,如果视图包含以下结构中的任何一种,它就是不可更新的: 聚合函数 SUM()、MIN()、MAX()、COUNT() 等。 DISTINCT 关键字。 GROUP BY 子句。 HAVING 子句。 UNION 或 UNION ALL 运算符。 位于选择列表中的子查询。 FROM 子句中的不可更新视图或包含多个表。 WHERE 子句中的子查询,引用 FROM 子句中的表。 ALGORITHM 选项为 TEMPTABLE(使用临时表总会使视图成为不可更新的)的时候。 测试 案例1 修改视图表的表结构 ALTER VIEW view_worker_attr AS SELECT num,name FROM worker; 查看 mysql> select * from view_worker_attr; +-----+--------+ | num | name | +-----+--------+ | 222 | 张三 | | 333 | 李四 | | 444 | 王五 | | 555 | 赵六 | | 666 | 小明 | | 777 | 小红 | +-----+--------+ 6 rows in set (0.02 sec) 案例2 使用 UPDATE 语句更新视图 view_worker_attr UPDATE view_worker_attr SET name="666张三666" WHERE num=222; 查看 mysql> select * from view_worker_attr; +-----+--------------+ | num | name | +-----+--------------+ | 222 | 666张三666 | | 333 | 李四 | | 444 | 王五 | | 555 | 赵六 | | 666 | 小明 | | 777 | 小红 | +-----+--------------+ 6 rows in set (0.04 sec) mysql> select * from worker; +-----+------+--------------+-----+---------------------+--------------------+ | num | d_id | name | sex | birthday | homeaddress | +-----+------+--------------+-----+---------------------+--------------------+ | 222 | 4 | 666张三666 | 男 | 1993-06-03 09:23:24 | 北京朝阳群众 | | 333 | 3 | 李四 | 男 | 1990-07-03 09:23:24 | 湖南长沙实名 | | 444 | 2 | 王五 | 女 | 1998-01-13 09:23:24 | 上海开发大道 | | 555 | 1 | 赵六 | 男 | 1995-01-03 09:23:24 | 深圳长江一号 | | 666 | 2 | 小明 | 男 | 1999-07-07 09:23:24 | 天津市武清区 | | 777 | 3 | 小红 | 女 | 1992-04-23 09:23:24 | 河北承德市 | +-----+------+--------------+-----+---------------------+--------------------+ 6 rows in set (0.02 sec) 删除视图基本语法 可以使用 DROP VIEW 语句来删除视图。语法格式如下: DROP VIEW <视图名1> [ , <视图名2> …] 其中:<视图名>指定要删除的视图名。DROP VIEW 语句可以一次删除多个视图,但是必须在每个视图上拥有 DROP 权限。 测试 删除 view_worker_attr 视图 mysql> DROP VIEW view_worker_attr; Query OK, 0 rows affected (0.03 sec) mysql> select * from view_worker_attr; ERROR 1146 (42S02): Table 'view_db.view_worker_attr' doesn't exist
引言 本文基于原生的PHP代码实现,IO阻塞模型,所以本文章实现的阻塞模型和 swoole 没有任何关系。当然,日后用 swoole 实现该io模型肯定更加容易。但是原生php实现该模型确可以让你更好的理解这个 IO 模型。 参考文献 5种IO模型:https://blog.mailjob.net/posts/3565199751.html github代码下载:https://github.com/mailjobblog/dev_php_io/tree/master/test/blocking 函数(stream_socket_server):https://php.golaravel.com/function.stream-socket-server.html 函数(stream_socket_client):https://php.golaravel.com/function.stream-socket-client.html 函数(fread):https://php.golaravel.com/function.fread.html 函数(fwrtie):https://php.golaravel.com/function.fwrite.html IO阻塞模型 阻塞IO情况下,当用户调用read后,用户线程会被阻塞,等内核数据准备好并且数据从内核缓冲区拷贝到用户态缓存区后read才会返回。可以看到是阻塞的两个部分。 CPU把数据从磁盘读到内核缓冲区。 CPU把数据从内核缓冲区拷贝到用户缓冲区。 原生PHP代码演示 代码 server.php <?php require __DIR__."/../../vendor/autoload.php"; use DevPhpIO\Blocking\Worker; $server = new Worker('0.0.0.0',9500); $server->on('connect',function($server,$client){ dd($client,'连接成功'); }); $server->on('receive',function($server,$client,$data){ dd($data,'处理client的数据'); sleep(5); // 进行阻塞,方便演示 $server->send($client, "hello i’m is server"); $server->close($client); }); $server->on('close',function($server,$client){ dd($client,'连接断开'); }); $server->start(); client.php <?php require __DIR__."/../../vendor/autoload.php"; $fp = stream_socket_client("tcp://127.0.0.1:9500"); fwrite($fp,'hello blocking'); var_dump(fread($fp,65535)); 测试 测试后发现,两个client同时请求的时候,client2需要等待client1的完成。
粘包形成原因 什么是 TCP 粘包? TCP 粘包是指发送方发送的若干包数据 到 接收方接收时粘成一包,从接收缓冲区看,后一包数据的头紧接着前一包数据的尾。 TCP 出现粘包的原因? 发送方: 发送方需要等缓冲区满才发送出去,造成粘包 接收方: 接收方不及时接收缓冲区的包,造成多个包接收 粘包代码演示 tcpServer.php <?php echo swoole_get_local_ip()['eth0'].":9503\n"; $server = new Swoole\Server("0.0.0.0", 9503); $server->on('connect', function ($server, $fd){ echo "connection open: {$fd}\n"; }); $server->on('receive', function ($server, $fd, $reactor_id, $data) { echo "接收到信息".$data." \n"; $server->send($fd, "swoole: {$data}"); }); $server->on('close', function ($server, $fd) { echo "connection close: {$fd}\n"; }); $server->start(); tcpClient.php <?php $client = new Swoole\Client(SWOOLE_SOCK_TCP); if (!$client->connect('127.0.0.1', 9503, -1)) { exit("connect failed. Error: {$client->errCode}\n"); } for($i=0; $i<50; $i++) { $client->send('hello_'); } echo $client->recv(); 通过截图,可以看出: client 每次 send 一个 hello_ 字符串以后,server 接收到的字符串粘连在了一起 解决方案 1、特殊字符 根据客户端与服务端相互约定的特殊的符号,对接收的数据进行分割处理 2、固定包头+包体协议(主流) 通过与在数据传输之后会在tcp的数据包中携带上数据的长度,然后呢服务端就可以根据这个长度,对于数据进行截取 特殊字符 方案说明 EOF 结束协议 通过约定结束符,来确定包数据是否发送完毕。 开启open_eof_check=true,并用package_eof来设置一个完整数据结尾字符,同时设置自动拆分open_eof_split 注意: 1、要保证业务数据里不能出现package_eof设置的字符,否则将导致数据错误了。 2、可以手动拆包,去掉open_eof_split,自行 explode(“\r\n”, $data),然后循环发送 参考文献 swoole 文档:https://wiki.swoole.com/#/server/setting?id=open_eof_check 代码演示 tcpServer.php <?php // var_dump(swoole_get_local_ip());die; echo swoole_get_local_ip()['eth0'].":9503\n"; $server = new Swoole\Server("0.0.0.0", 9503); # swoole 拆包 $server->set([ 'open_eof_check' => true, //打开EOF检测 'package_eof' => "\r\n", //设置EOF ]); $server->on('connect', function ($server, $fd){ echo "connection open: {$fd}\n"; }); $server->on('receive', function ($server, $fd, $reactor_id, $data) { echo "接收到信息".$data." \n"; $server->send($fd, "swoole: {$data}"); // 自己手动拆包 // var_dump(explode("\r\n", $data)); }); $server->on('close', function ($server, $fd) { echo "connection close: {$fd}\n"; }); $server->start(); tcpClient.php <?php $client = new Swoole\Client(SWOOLE_SOCK_TCP); if (!$client->connect('127.0.0.1', 9503, -1)) { exit("connect failed. Error: {$client->errCode}\n"); } $end = "\r\n"; for($i=0; $i<50; $i++) { $client->send('hello_'.$end); } echo $client->recv(); 固定包头+包体协议 方案说明 原理是通过约定数据流的前几个字节来表示一个完整的数据有多长,从第一个数据到达之后,先通过读取固定的几个字节,解出数据包的长度,然后按这个长度继续取出后面的数据,依次循环。 参考文献 swoole相关配置文档:https://wiki.swoole.com/#/server/setting?id=open_length_check php的pack函数文档:https://www.php.net/manual/zh/function.pack.php 代码演示 tcpServer.php <?php echo swoole_get_local_ip()['eth0'].":9503\n"; $server = new Swoole\Server("0.0.0.0", 9503); $server->set([ 'open_length_check' => true, 'package_max_length' => 2 * 1024 * 1024, 'package_length_type' => 'n', 'package_length_offset' => 0, 'package_body_offset' => 2 ]); $server->on('connect', function ($server, $fd){ echo "connection open: {$fd}\n"; }); $server->on('receive', function ($server, $fd, $reactor_id, $data) { echo "接收到信息".$data." \n"; $server->send($fd, "swoole: {$data}"); }); $server->on('close', function ($server, $fd) { echo "connection close: {$fd}\n"; }); $server->start(); ## 用php方法解包 // $server->on('receive', function ($server, $fd, $reactor_id, $data) { // $fooLen = unpack("n", substr($data, 0, 2))[1]; // // 得到真正的数据 // $context = substr($data, 2, $fooLen); // var_dump($context); // $server->send($fd, "Swoole: ok"); // }); tcpClient.php <?php $client = new Swoole\Client(SWOOLE_SOCK_TCP); if (!$client->connect('127.0.0.1', 9503, -1)) { exit("connect failed. Error: {$client->errCode}\n"); } for($i=0; $i<50; $i++) { $context = '123'; // 利用pack打包长度 $len = pack("n", strlen($context)); // 组包 $send = $len . $context; // 发送 $client->send($send); } echo $client->recv();
参考文献 client同步阻塞客户端:https://wiki.swoole.com/#/client?id=完整示例 heartbeat_check_interval:https://wiki.swoole.com/#/server/setting?id=heartbeat_check_interval 健康检查机制 对于长连接这种断开的问题;主要的点就在于服务端会保存客户端会话的有效性以及平台上监控所有客户端的网络状况;对于这种功能的实现我们可以通过两种方式实现 1、轮询机制 2、心跳机制 代码演示 tcpServer.php <?php // var_dump(swoole_get_local_ip());die; echo swoole_get_local_ip()['eth0'].":9503\n"; $server = new Swoole\Server("0.0.0.0", 9503); // 每个2s检测,在3s内没有给我发送信息的连接 $server->set([ 'heartbeat_check_interval' => 2, // 检测所有的连接 'heartbeat_idle_time' => 3, ]); $server->on('connect', function ($server, $fd){ echo "connection open: {$fd}\n"; }); $server->on('receive', function ($server, $fd, $reactor_id, $data) { echo "接收到信息".$data." \n"; $server->send($fd, "swoole: {$data}"); }); $server->on('close', function ($server, $fd) { echo "connection close: {$fd}\n"; }); $server->start(); tcpClient.php <?php $client = new Swoole\Client(SWOOLE_SOCK_TCP); if (!$client->connect('127.0.0.1', 9503, -1)) { exit("connect failed. Error: {$client->errCode}\n"); } # 初始测试 // $client->send("hello world1\n"); // echo $client->recv(); // sleep(2); // $client->send("hello world2\n"); // echo $client->recv(); // sleep(3); // $client->send("hello world3\n"); // echo $client->recv(); // sleep(4); // $client->send("hello world4\n"); // echo $client->recv(); // $client->close(); // 本文采用此方法解决 // 每隔2000ms触发一次 swoole_timer_tick(2000, function ($timer_id) use ($client) { echo "string\n"; $client->send(1); $client->recv(); }); $client->close();
问题描述 å对于一个无序的数列,求出里面最大的连续子数列 解决方案 <?php $arr = array(1,9,5,5,6,7,0,9,4,3,4,5,6,3,0,1); $max = array(); $tem = array(); for($i=1; $i<count($arr); $i++){ if(($arr[$i]-1) == $arr[$i-1]){ if(!count($tem)) { $tem[] = $arr[$i-1]; } $tem[] = $arr[$i]; }else{ if(count($max) < count($tem)) { $max = array(); $max = $tem; } $tem = array(); } } var_dump($max); 时间复杂度 O(n)
前言 海量数据分页,已经无法使用数据库自带的分页机制,比如 MySQL 的 Limit ,这会导致严重的性能问题 性能问题演示 SELECT * FROM table LIMIT [offset,] rows | rows OFFSET offset offset:指定第一个返回记录行的偏移量 rows:指定返回记录行的最大数目 查询分页 分页offset偏移量计算 $offset = ($page-1) * 10 page:当前页码 10:每页条数 offset:计算的分页偏移量 分页测试 SELECT * FROM mp_tt_creative LIMIT 9999960,10 时间: 9.321s mysql> EXPLAIN SELECT * FROM mp_tt_creative LIMIT 9999960,10; +----+-------------+----------------+------------+------+---------------+------+---------+------+----------+----------+-------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+----------------+------------+------+---------------+------+---------+------+----------+----------+-------+ | 1 | SIMPLE | mp_tt_creative | NULL | ALL | NULL | NULL | NULL | NULL | 16519407 | 100 | NULL | +----+-------------+----------------+------------+------+---------------+------+---------+------+----------+----------+-------+ 1 row in set 分页优化方案 1、根据id索引优化 如果表中存在 连续 的数字列并 为 索引,那么通过页码即可计算出此字段的范围,直接作范围查询即可: start = (page-1)*pagesize end = page*pagesize select * from table where id >start and id <=end 测试 2、根据子查询优化 3、根据第三方工具优化
问题描述 有两个数组 A 和 B 。并且 A 数组 = B 数组,现将 B 数组中的其中一个值随机去掉,求被去掉的值。 解决方案 <?php $arr1 = array(1,2,3,4,5); $arr2 = array(1,2,3,5); for($i=0; $i<count($arr1); $i++){ $flag = false; for($j=0; $j<count($arr2); $j++){ if($arr1[$i] == $arr2[$j]) { $flag = true; break; } } if(!$flag) echo $arr1[$i]; } 算法时间复杂度 信息标记 for($i=0; $i<count($arr1); $i++){//{1} $flag = false; //{2} for($j=0; $j<count($arr2); $j++){ //{3} if($arr1[$i] == $arr2[$j]) { $flag = true; break; } } if(!$flag) //{6} echo $arr1[$i]; //{7} } 计算过程 第一步:计算算法运行过程中运行的代码总的条数N: N = n*n + n + n +1 n 代表{1}循环n次 {3} 过程中每条执行1次,所以得到 n*n {2} 过程中 $flag = false 被赋值 n次,所以 +n {6} 过程中 if(!$flag) 判断了 n 次,所以 +n {7} 最后输出,所以 +1 第二步:计算算法总运行时间f(n): f(n) = N * t = (n^2 + 2n +1) * t 第三步:计算时间复杂度T(n): T(n) = O( f(n) ) = O( (n^2 + 2n +1) * t ) 即 T(n) = O( (n^2 + 2n +1) * t ) 第四步:简化 T(n) = O(n^2)
1、总体架构 distribution 负责与docker registry交互,上传洗澡镜像以及v2 registry 有关的源数据 registry负责docker registry有关的身份认证、镜像查找、镜像验证以及管理registry mirror等交互操作 image 负责与镜像源数据有关的存储、查找,镜像层的索引、查找以及镜像tar包有关的导入、导出操作 reference负责存储本地所有镜像的repository和tag名,并维护与镜像id之间的映射关系 layer模块负责与镜像层和容器层源数据有关的增删改查,并负责将镜像层的增删改查映射到实际存储镜像层文件的graphdriver模块 graghdriver是所有与容器镜像相关操作的执行者 2、docker架构 如果觉得上面架构图比较乱可以看这个架构: 从上图不难看出,用户是使用Docker Client与Docker Daemon建立通信,并发送请求给后者。 而Docker Daemon作为Docker架构中的主体部分,首先提供Server的功能使其可以接受Docker Client的请求;而后Engine执行Docker内部的一系列工作,每一项工作都是以一个Job的形式的存在。 Job的运行过程中,当需要容器镜像时,则从Docker Registry中下载镜像,并通过镜像管理驱动graphdriver将下载镜像以Graph的形式存储;当需要为Docker创建网络环境时,通过网络管理驱动networkdriver创建并配置Docker容器网络环境;当需要限制Docker容器运行资源或执行用户指令等操作时,则通过execdriver来完成。 而libcontainer是一项独立的容器管理包,networkdriver以及execdriver都是通过libcontainer来实现具体对容器进行的操作。当执行完运行容器的命令后,一个实际的Docker容器就处于运行状态,该容器拥有独立的文件系统,独立并且安全的运行环境等。 3、docker架构 再来看看另外一个架构,这个个架构就简单清晰指明了server/client交互,容器和镜像、数据之间的一些联系。 这个架构图更加清晰了架构 docker daemon就是docker的守护进程即server端,可以是远程的,也可以是本地的,这个不是C/S架构吗,客户端Docker client 是通过rest api进行通信。 docker cli 用来管理容器和镜像,客户端提供一个只读镜像,然后通过镜像可以创建多个容器,这些容器可以只是一个RFS(Root file system根文件系统),也可以ishi一个包含了用户应用的RFS,容器再docker client中只是要给进程,两个进程之间互不可见。 用户不能与server直接交互,但可以通过与容器这个桥梁来交互,由于是操作系统级别的虚拟技术,中间的损耗几乎可以不计。 Docker架构各个模块的功能 主要的模块有:Docker Client、Docker Daemon、Docker Registry、Graph、Driver、libcontainer以及Docker container。 1、docker client docker client 是docker架构中用户用来和docker daemon建立通信的客户端,用户使用的可执行文件为docker,通过docker命令行工具可以发起众多管理container的请求。 docker client可以通过一下三宗方式和docker daemon建立通信:tcp://host:port;unix:path_to_socket;fd://socketfd。,docker client可以通过设置命令行flag参数的形式设置安全传输层协议(TLS)的有关参数,保证传输的安全性。 docker client发送容器管理请求后,由docker daemon接受并处理请求,当docker client 接收到返回的请求相应并简单处理后,docker client 一次完整的生命周期就结束了,当需要继续发送容器管理请求时,用户必须再次通过docker可以执行文件创建docker client。 2、docker daemon docker daemon 是docker架构中一个常驻在后台的系统进程,功能是:接收处理docker client发送的请求。该守护进程在后台启动一个server,server负载接受docker client发送的请求;接受请求后,server通过路由与分发调度,找到相应的handler来执行请求。 docker daemon启动所使用的可执行文件也为docker,与docker client启动所使用的可执行文件docker相同,在docker命令执行时,通过传入的参数来判别docker daemon与docker client。 docker daemon的架构可以分为:docker server、engine、job。daemon 3、docker server docker server在docker架构中时专门服务于docker client的server,该server的功能时:接受并调度分发docker client发送的请求,架构图如下: 在Docker的启动过程中,通过包gorilla/mux(golang的类库解析),创建了一个mux.Router,提供请求的路由功能。在Golang中,gorilla/mux是一个强大的URL路由器以及调度分发器。该mux.Router中添加了众多的路由项,每一个路由项由HTTP请求方法(PUT、POST、GET或DELETE)、URL、Handler三部分组成。 若Docker Client通过HTTP的形式访问Docker Daemon,创建完mux.Router之后,Docker将Server的监听地址以及mux.Router作为参数,创建一个httpSrv=http.Server{},最终执行httpSrv.Serve()为请求服务。 在Server的服务过程中,Server在listener上接受Docker Client的访问请求,并创建一个全新的goroutine来服务该请求。在goroutine中,首先读取请求内容,然后做解析工作,接着找到相应的路由项,随后调用相应的Handler来处理该请求,最后Handler处理完请求之后回复该请求。 需要注意的是:Docker Server的运行在Docker的启动过程中,是靠一个名为”serveapi”的job的运行来完成的。原则上,Docker Server的运行是众多job中的一个,但是为了强调Docker Server的重要性以及为后续job服务的重要特性,将该”serveapi”的job单独抽离出来分析,理解为Docker Server。 4、engine Engine是Docker架构中的运行引擎,同时也Docker运行的核心模块。它扮演Docker container存储仓库的角色,并且通过执行job的方式来操纵管理这些容器。 在Engine数据结构的设计与实现过程中,有一个handler对象。该handler对象存储的都是关于众多特定job的handler处理访问。举例说明,Engine的handler对象中有一项为:{“create”: daemon.ContainerCreate,},则说明当名为”create”的job在运行时,执行的是daemon.ContainerCreate的handler。 5、job 一个Job可以认为是Docker架构中Engine内部最基本的工作执行单元。Docker可以做的每一项工作,都可以抽象为一个job。例如:在容器内部运行一个进程,这是一个job;创建一个新的容器,这是一个job,从Internet上下载一个文档,这是一个job;包括之前在Docker Server部分说过的,创建Server服务于HTTP的API,这也是一个job,等等。 Job的设计者,把Job设计得与Unix进程相仿。比如说:Job有一个名称,有参数,有环境变量,有标准的输入输出,有错误处理,有返回状态等。 6、docker registry Docker Registry是一个存储容器镜像的仓库。而容器镜像是在容器被创建时,被加载用来初始化容器的文件架构与目录。 在Docker的运行过程中,Docker Daemon会与Docker Registry通信,并实现搜索镜像、下载镜像、上传镜像三个功能,这三个功能对应的job名称分别为”search”,”pull” 与 “push”。 其中,在Docker架构中,Docker可以使用公有的Docker Registry,即大家熟知的Docker Hub,如此一来,Docker获取容器镜像文件时,必须通过互联网访问Docker Hub;同时Docker也允许用户构建本地私有的Docker Registry,这样可以保证容器镜像的获取在内网完成。 7、Graph Graph在Docker架构中扮演已下载容器镜像的保管者,以及已下载容器镜像之间关系的记录者。一方面,Graph存储着本地具有版本信息的文件系统镜像,另一方面也通过GraphDB记录着所有文件系统镜像彼此之间的关系。 Graph的架构如下: 其中,GraphDB是一个构建在SQLite之上的小型图数据库,实现了节点的命名以及节点之间关联关系的记录。它仅仅实现了大多数图数据库所拥有的一个小的子集,但是提供了简单的接口表示节点之间的关系。 同时在Graph的本地目录中,关于每一个的容器镜像,具体存储的信息有:该容器镜像的元数据,容器镜像的大小信息,以及该容器镜像所代表的具体rootfs。8、driver Driver是Docker架构中的驱动模块。通过Driver驱动,Docker可以实现对Docker容器执行环境的定制。由于Docker运行的生命周期中,并非用户所有的操作都是针对Docker容器的管理,另外还有关于Docker运行信息的获取,Graph的存储与记录等。因此,为了将Docker容器的管理从Docker Daemon内部业务逻辑中区分开来,设计了Driver层驱动来接管所有这部分请求。 在Docker Driver的实现中,可以分为以下三类驱动:graphdriver、networkdriver和execdriver。 graphdriver主要用于完成容器镜像的管理,包括存储与获取。即当用户需要下载指定的容器镜像时,graphdriver将容器镜像存储在本地的指定目录;同时当用户需要使用指定的容器镜像来创建容器的rootfs时,graphdriver从本地镜像存储目录中获取指定的容器镜像。 在graphdriver的初始化过程之前,有4种文件系统或类文件系统在其内部注册,它们分别是aufs、btrfs、vfs和devmapper。而Docker在初始化之时,通过获取系统环境变量”DOCKER_DRIVER”来提取所使用driver的指定类型。而之后所有的graph操作,都使用该driver来执行。 graphdriver的架构如下: networkdriver的用途是完成Docker容器网络环境的配置,其中包括Docker启动时为Docker环境创建网桥;Docker容器创建时为其创建专属虚拟网卡设备;以及为Docker容器分配IP、端口并与宿主机做端口映射,设置容器防火墙策略等。networkdriver的架构如下: execdriver作为Docker容器的执行驱动,负责创建容器运行命名空间,负责容器资源使用的统计与限制,负责容器内部进程的真正运行等。在execdriver的实现过程中,原先可以使用LXC驱动调用LXC的接口,来操纵容器的配置以及生命周期,而现在execdriver默认使用native驱动,不依赖于LXC。 具体体现在Daemon启动过程中加载的ExecDriverflag参数,该参数在配置文件已经被设为”native”。这可以认为是Docker在1.2版本上一个很大的改变,或者说Docker实现跨平台的一个先兆。execdriver架构如下: 9、libcontainer libcontainer是Docker架构中一个使用Go语言设计实现的库,设计初衷是希望该库可以不依靠任何依赖,直接访问内核中与容器相关的API。正是由于libcontainer的存在,Docker可以直接调用libcontainer,而最终操纵容器的namespace、cgroups、apparmor、网络设备以及防火墙规则等。这一系列操作的完成都不需要依赖LXC或者其他包。libcontainer架构如下: 另外,libcontainer提供了一整套标准的接口来满足上层对容器管理的需求。或者说,libcontainer屏蔽了Docker上层对容器的直接管理。又由于libcontainer使用Go这种跨平台的语言开发实现,且本身又可以被上层多种不同的编程语言访问,因此很难说,未来的Docker就一定会紧紧地和Linux捆绑在一起。而于此同时,Microsoft在其著名云计算平台Azure中,也添加了对Docker的支持,可见Docker的开放程度与业界的火热度。 暂不谈Docker,由于libcontainer的功能以及其本身与系统的松耦合特性,很有可能会在其他以容器为原型的平台出现,同时也很有可能催生出云计算领域全新的项目。 10、docker container Docker container(Docker容器)是Docker架构中服务交付的最终体现形式。Docker按照用户的需求与指令,订制相应的Docker容器: 用户通过指定容器镜像,使得Docker容器可以自定义rootfs等文件系统; 用户通过指定计算资源的配额,使得Docker容器使用指定的计算资源; 用户通过配置网络及其安全策略,使得Docker容器拥有独立且安全的网络环境; 用户通过指定运行的命令,使得Docker容器执行指定的工作。
简单代数技术 案例: f(x) = x * 2 计算: f(x)中的 x 代表自变量, f 代表因变量 x = 5 那么计算结果:x*2 = 5*2 = 10 对数计算 案例: log2 16 计算: 在数学中,对数是对求幂的逆运算,正如除法是乘法的倒数,反之亦然 如果a的x次方等于N(a>0,且a≠1),那么数x叫做以a为底N的对数(logarithm)。其中,a叫做对数的底数,N叫做真数 2*2*2*2 = 16 所以计算结果是 4 幂运算 案例: 2^3 计算: 同底数幂相乘,底数不变,指数相加。同底数幂相除,底数不变,指数相减。幂的乘方,底数不变,指数相乘 2 * 2 * 2 = 8 阶乘运算 案例: 4! 计算: 阶乘是用来解决一些排列与组合问题 例如组合:有四个元素,6 4 2 1,写出这四个数能组成的两位数比如 62 12 42… n!=n×(n-1)×(n-2)×(n-3)×...×1 4! = 1*2*3*4 = 24 双阶乘表示方法: (2n-1)!! 当n=2时,3!!=3×1=3 当n=3时,5!!=5×3×1=15 当n=4时,7!!=7×5×3×1=105
算法具有五个基本特性:输入、输出、有穷性、确定性和可行性。 输入输出 算法允许具有零个或多个输入,但是必须至少有一个或多个输出。 算法允许输入这个不难理解,但有些情况例外,比如我们写一个函数只想输出固定的 “hello world” 就可以不需要输入参数了!但是算法是一定需要有输出的,因为解决完问题一定要有结果!那么对于算法而言输出的形式可以是打印输出,也可以是返回一个或多个值等。 有穷性 有穷性是指算法在执行有限的步骤之后,可以自动结束而不会出现无限循环,并且每一个步骤的执行时间都是在可接受的范围内。直白一些说就是算法的实现代码不能是死循环的!并且目标是1分钟内实现结果算法却要执行1年!这样虽然有穷但是并不是合格的算法! 确定性 确定性是指算法的每一步骤都具有确定的含义,不会出现二义性。举个例子来说就是有确定的输入就应该有固定的输出!不能是这次输入A得到结果B,下一次输入A却得到结果C!这就违反了确定性原则! 可行性 可行性是指在现有的条件下算法的每一个步骤都应该是可以实现的而不是只是空想或者你设计了一个20年之后可以实现的算法,这个在当下都是没有意义的。
要设计出优良的算法,前辈们已经总结出了一些原则供我们来参考!这样我们就可以站在巨人的肩膀上写我们自己的算法了! 基本的原则有五个: 正确性、健壮性、高效性、环保性和可读性! 正确性 正确性是设计算的最基本的原则!如果不能正确的解决问题,那设计算法的意义又是什么呢?这个容易理解!但是所谓的“正确”在这里确是多层面的! - 算法的实现上没有错误。即我们编写的代码没有语法错误。 - 算法对于合法的输入应该能得到满足要求的输出结果! - 算法对非法的输入应该得到合适的反馈结果。 - 算法程序是精心选择的,甚至刁难的测试数据都能得到满足要求的结果。 - 算法是针对该问题的而不是针对其它问题的!否则满足上面四项也是错误的! 前面的容易理解,最后一条反例是:要设计一个排序的算法你却设计了一个搜索的算法,或许语法正确,数据输入也能得到结果,但是并不能解决目标问题。这种常识上的没有错误在这里也是不正确的! 健壮性 健壮性是在证确性的基础之上对算法更高层次的要求!是说程序是稳定可靠的!不能说运行一段时间程序就要挂掉! 或者因为一次非法输入程序就崩溃了! 健壮性就是要求程序稳定运行! 高效性 高效性是说算法的时间效率尽量的高!也就是我们常常说的“做事儿的效率”!在这里就是算法解决问题的效率!其实这个也是大家常常去评判一个算法优劣的很重要的标准! 环保性 提到环保性,很多人是不是感觉莫名其妙!难道算法也要环保吗?环保除了人人有责,算法也有责?其实这里的环保性是指算法的资源利用越少越好!即我们常说的占用内存越低越好!其实这个也好理解!我们电脑上的资源都是有限的!对于算法而言,肯定是占用的内存的少一些是更有优势的! 可读性 最后一个原则往往被很多人忽略掉,但也是最重要的原则! 算法的实现最终是要靠程序的,在这里我们是要用PHP编写程序来实现算法!那么程序是不是一次性写完,以后就再也不会修改维护了呢?显然不是! 程序读的次数要远远多于写的次数!
要计算算法中的空间复杂度实际上就是计算算法占用内存的大小!要计算占用内存空间的大小,我们就要知道算法中有多少变量,每种变量对应了多少内存空间!虽然PHP是弱类型的语言,但不代表PHP没有类型! 所以,PHP每种数据类型占用的实际内存大小也不相同,不过我们在计算空间复杂度时,这些具体的值都会被简化掉!要研究PHP数据类型的大小需要阅读PHP内核代码,这里不是我们的重点,相关的内容我们制作专门的文章来说明。 在这里就那我们最好理解的形式来计算,按照标准C语言的类型大小就好了。 我们就以上面的两个代码片段为例打造一个遍历数组元素的算法来计算空间复杂度。 由于内存空间会受到所在机器及计算机系统影响,这里假设是在64位下的linux系统环境下的数据。 代码片段的空间复杂度的计算 function arrIterator( $arr ){ {1} for( $i=0; $i < count( $arr ); $i ++ ){ {2} echo $arr[ $i ]; {3} } } 第一步:统计算法中变量的个数,类型和对应的内存数量: 分析代码可知,其中用到的变量如下: $arr 数组 存储有n个整型元素 $i 整型 第二步:计算算法总内存空间f(n): f(n) = n * 8 + 8 = 8*n + 8 第三步:计算时间复杂度T(n): S(n) = O( f(n) ) = O( 8*n + 8 ) 即 S(n) = O( 8*n + 8 ) 第四步:简化 简化方法和时间复杂度相同: S(n) = O(n) 代码片段二的空间复杂度的计算 function arrIterator( $arr ){ {1} $len = count( $arr ); {2} for( $i=0; $i < $len; $i ++ ){ {3} echo $arr[ $i ]; {4} } } 第一步:统计算法中变量的个数,类型和对应的内存数量: 分析代码可知,其中用到的变量如下: $arr 数组 存储有n个整型元素 $len 整型 $i 整型 第二步:计算算法总内存空间f(n): f(n) = n * 8 + 8 + 8 = 8*n + 16 第三步:计算时间复杂度T(n): S(n) = O( f(n) ) = O( 8*n + 16 ) 即 S(n) = O( 8*n + 16 ) 第四步:简化 S(n) = O(n) 通过比较两种实现方式的空间复杂度都是O(n)级别的,但是第二种方案使用了代码片段二优化之后反而空间复杂度比使用第一个代码片段增加了整型数据的大小,但是我们公认为第二种更快了!而其中原因就是用空间换取了时间!
计算时间复杂度需要三个前提 不考虑任何的软硬件环境 假设程序每一条语句的执行时间相同,均为t 只考虑最坏情况,即数据规模足够大,设为n 所以,一定要注意这三个前提下,我们的分析才能正常进行也才有意义!所以我们这里的时间复杂度实际上指的是最坏时间复杂度! 这里有一个固定的公式来表示时间复杂度: T(n) = O(f(n)) 其中n表示的是数据规模,f(n)是算法执行的总时间,其计算公式是: f(n) = 算法执行语句的数量 * t 即算法中语句的执行数量与单条语句执行时间t的积。 所以时间复杂度公式就是: T(n) = O(算法运行总时间) 这里我们把算法运行总时间放在了一个固定的格式中: O()。 我们成这种表达方式为大O表示法,大O表示法就是表达 最坏的情况的一中表示形式,后面我们会学习最坏空间复杂度,也是用大O表示法来表示!而且在时间复杂度扩展知识中我们还会了解表达最好情况的大Ω表示法和表达平均情况的大Θ表示法! 常见的时间复杂度 时间复杂度 非正式术语 O(1) 常数阶 O(n) 线性阶 O(n^2) 平方阶 O(log n) 对数阶 O(n * log n) n log n 阶 O(n^3) 立方阶 O(2^n) 指数阶 O(n!) 阶乘阶 O(n^n) n的n方阶 复杂度排名如下 O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n^3) < O(2^n) < O(n!) < O(n^n) 时间复杂度知识扩展 1、最好情况下的算法时间复杂度 最好情况下的时间复杂度又叫做 最好时间复杂度,使用 大Ω 表示法表示! 其实“最好时间复杂度”好理解,它描述了一个算法的最好的情况下花费的时间,也可以看做是一个算法花费时间的下限!就比如我们生活中要找一个东西,最好的情况是他就摆在你眼前,你一下就能找到它!在算法里呢,如果要设计一个搜索算法,比如要在一个包含100个元素的数据中搜索指定目标,最好的情况就是第一次就找到了! 但是“最好时间复杂度”的实际意义并不大,除非我们要求一个算法只要最好的状态能满足需求就可以使用,但是这样的要求并不多见,一般都是要求最坏的情况下如果能接受那算法就可以使用!所以我们主要研究的和使用的都是“最坏时间复杂度”! 2、平均情况下的算法时间复杂度 平均情况下的时间复杂度又叫作 平均时间复杂度, 使用大Θ表示法表示! “平均时间复杂度”也有一定的实用性,它描述了一个算法在处理数据过程中单个数据元素花费的平均时间!但是“平均时间复杂度”有一个问题就是并不好测量,因为平均时间是在真正运行过程中统计并且计算出来的!但也更贴合实际效果!所以这种表示方法很难通过直接评估得到结果!这个更加接近事后统计的一种方案。 不过我们可以举一个比较好统计的例子,仍然是搜索100个排好序的数据中的某个元素!我们就使用直接遍历对比并且找到就立刻停下的形式来搜索!那么最好情况下找到的第一个元素就是我们要找的,也就是1次就找到了!最坏情况呢?是第100次才找到!那么平均呢?如果每一个元素都要找一遍的话,按照从头到尾找并且找到就停下的算法你可以计算一下应该是 ( 1+2+3+4+...100 ) / 100 = 50.5 平均要找 50.5 次! 时间复杂度计算案例 计算 1+2+3+4+5.....+n 的和! 第一种思想: 我们可以生成一个n个长度的数组,然后把数据存储到数组中,最后计算数组各项的和。 function sum( $n ){ // 设定返回结果 $res = 0; //{1} // 生成数组[1,2,3,4,5...n] $arr = range(1,$n); //{2} // 计算数组中各项的和 for($i = 0; $i < $n; ++ $i){ //{3} $res += $arr[$i]; //{4} } // 返回结果 return $res; //{5} } 时间复杂度计算 第一步:计算算法运行过程中运行的代码总的条数N 分析代码可知,其中{1}{2}{5}所标注的代码语句整个过程每条都只执行1次,而{3}{4}标注的语句执行过程中每一条都要执行n次!所以总条数N: N = 2*n + 3 第二步:计算算法总运行时间f(n): 前面我们已经制定了前提,以为每一条语句执行的时间实行同的,都是t,所以: f(n) = N * t = (2*n + 3) * t 第三步:计算时间复杂度T(n): 我们这里的时间复杂度指的是最坏的时间复杂度,使用大O表示法表示: T(n) = O( f(n) ) = O( (2*n + 3) * t ) 即 T(n) = O( (2*n + 3) * t ) 第四步:简化 实际上到这里我们已经成功的计算出了这个算法的时间复杂度,但是在实际的时间复杂度的表示中,对于上面的结果我们要做一定的简化,简化结果是: T(n) = O(n) Tips:简化步骤 1, 去掉常数t: 因为t代表的是每一条语句的运行时间,而每一条语句的运行时间都相同,所以t实际上是个常数,并且不影响 分析结果 T(n) = O( (2*n + 3) ) 2, 用常数1取代运行时间中的所有加法常数: T(n) = O( 2*n + 1 ) 3, 在修改后的运行次数函数中,只保留最高阶项: T(n) = O( 2*n + 1 ) 4, 如果最高阶项存在且不是1,则去除与这个项相乘的常数: T(n) = O( n + 1 ) 5, 如果存在数据规模n, 去掉加法常数项: T(n) = O(n) 关于时间复杂度的计算,你会发现计算过程实际上变成了要数数整个程序一共会有多少条语句被执行的一年级数数问题! 哈!开个玩笑!但,时间复杂度计算的实际结果确实也如此! 所以以后有人问你某个算法的时间发复杂度是什么,如果是直接描述,你就直接告诉他是O(XX), 如果是写表达式的话就写我们上面演示的这种T(n)=O(XX)就可以啦! 第二种思想 直接循环计算数组各项的和 function sum( $n ){ // 设定返回结果 $res = 0; //{1} // 计算数组中各项的和 for($i = 1; $i < $n; ++ $i){ //{2} $res += $i; //{3} } // 返回结果 return $res; //{4} } 时间复杂度计算 第一步:计算算法运行过程中运行的代码总的条数N: N = 2*n + 2 第二步:计算算法总运行时间f(n): f(n) = N * t = (2*n + 2) * t 第三步:计算时间复杂度T(n): T(n) = O( f(n) ) = O( (2*n + 2) * t ) 第四步:简化 T(n) = O(n) 第三种思想 利用高斯公式,f(n) = (1+n)*n/2 function sum( $n ){ // 设定返回结果 $res = 0; //{1} $res = ( 1 + $n ) * $n / 2; //{2} // 返回结果 return $res; //{3} } 时间复杂度计算 第一步:计算算法运行过程中运行的代码总的条数N: N = 3 第二步:计算算法总运行时间f(n): f(n) = N * t = 3 * t 第三步:计算时间复杂度T(n): T(n) = O( f(n) ) = O( 3 * t ) 第四步:简化。 T(n) = O(1)
数据结构分类 数据结构是指相互之间存在着一种或多种关系的数据元素的集合和该集合中数据元素之间的关系组成 数据类型介绍 数组 数组是可以再内存中连续存储多个元素的结构,在内存中的分配也是连续的,数组中的元素通过数组下标进行访问,数组下标从0开始。 例如下面这段代码就是将数组的第一个元素赋值为 1。 int[] data = new int[100];data[0] = 1; 优点: 1、按照索引查询元素速度快 2、按照索引遍历数组方便 缺点: 1、数组的大小固定后就无法扩容了 2、数组只能存储一种类型的数据 3、添加,删除的操作慢,因为要移动其他的元素。 适用场景: 频繁查询,对存储空间要求不大,很少增加和删除的情况。 链表 链表是物理存储单元上非连续的、非顺序的存储结构,数据元素的逻辑顺序是通过链表的指针地址实现,每个元素包含两个结点,一个是存储元素的数据域 (内存空间),另一个是指向下一个结点地址的指针域。根据指针的指向,链表能形成不同的结构,例如:单链表,双向链表,循环链表等。 链表的优点: 链表是很常用的一种数据结构,不需要初始化容量,可以任意加减元素; 添加或者删除元素时只需要改变前后两个元素结点的指针域指向地址即可,所以添加,删除很快; 缺点: 因为含有大量的指针域,占用空间较大; 查找元素需要遍历链表来查找,非常耗时。 适用场景: 数据量较小,需要频繁增加,删除操作的场景 队列 队列与栈一样,也是一种线性表,不同的是,队列可以在一端添加元素,在另一端取出元素,也就是:先进先出。 从一端放入元素的操作称为入队,取出元素为出队 示例图如下: 使用场景: 因为队列先进先出的特点,在多线程阻塞队列管理中非常适用。 栈 栈是一种特殊的线性表,仅能在线性表的一端操作,栈顶允许操作,栈底不允许操作。 栈的特点是:先进后出,或者说是后进先出,从栈顶放入元素的操作叫入栈,取出元素叫出栈。 栈的结构就像一个集装箱,越先放进去的东西越晚才能拿出来,所以,栈常应用于实现递归功能方面的场景,例如斐波那契数列。 堆 堆是一种比较特殊的数据结构,可以被看做一棵树的数组对象,具有以下的性质: 堆中某个节点的值总是不大于或不小于其父节点的值; 堆总是一棵完全二叉树。 将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。 常见的堆有二叉堆、斐波那契堆等。 堆的定义如下: n个元素的序列{k1,k2,ki,…,kn}当且仅当满足下关系时,称之为堆。 (ki <= k2i,ki <= k2i+1)或者(ki >= k2i,ki >= k2i+1), (i = 1,2,3,4…n/2),满足前者的表达式的成为小顶堆,满足后者表达式的为大顶堆,这两者的结构图可以用完全二叉树排列出来,示例图如下: 因为堆有序的特点,一般用来做数组中的排序,称为堆排序。 树 树是一种数据结构,它是由n(n>=1)个有限节点组成一个具有层次关系的集合。把它叫做 “树” 是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。 它具有以下的特点: 每个节点有零个或多个子节点; 没有父节点的节点称为根节点; 每一个非根节点有且只有一个父节点; 除了根节点外,每个子节点可以分为多个不相交的子树; 在日常的应用中,我们讨论和用的更多的是树的其中一种结构,就是二叉树。 二叉树是树的特殊一种,具有如下特点: 1、每个结点最多有两颗子树,结点的度最大为2。 2、左子树和右子树是有顺序的,次序不能颠倒。 3、即使某结点只有一个子树,也要区分左右子树。 二叉树是一种比较有用的折中方案,它添加,删除元素都很快,并且在查找方面也有很多的算法优化,所以,二叉树既有链表的好处,也有数组的好处,是两者的优化方案,在处理大批量的动态数据方面非常有用。 扩展: 二叉树有很多扩展的数据结构,包括平衡二叉树、红黑树、B+树等,这些数据结构二叉树的基础上衍生了很多的功能,在实际应用中广泛用到,例如mysql的数据库索引结构用的就是B+树,还有HashMap的底层源码中用到了红黑树。 散列表 散列表,也叫哈希表,是根据关键码和值 (key和value) 直接进行访问的数据结构,通过key和value来映射到集合中的一个位置,这样就可以很快找到集合中的对应元素。 图 图是由结点的有穷集合V和边的集合E组成。其中,为了与树形结构加以区别,在图结构中常常将结点称为顶点,边是顶点的有序偶对,若两个顶点之间存在一条边,就表示这两个顶点具有相邻关系。 按照顶点指向的方向可分为无向图和有向图: 图是一种比较复杂的数据结构,在存储数据上有着比较复杂和高效的算法,分别有邻接矩阵 、邻接表、十字链表、邻接多重表、边集数组等存储结构。
当我们PHP想添加新的扩张模块时,可以使用一下方法。当然,以下方法是saltstack进行安装拓展模块的方法 项目一:saltstack 添加PHP memcached模块 memcache-plugin: file.managed: - name: /usr/local/src/memcache-2.2.7.tgz - source: salt://php/files/memcache-2.2.7.tgz - user: root - group: root - mode: 755 cmd.run: - name: cd /usr/local/src && tar zxf memcache-2.2.7.tgz && cd memcache-2.2.7&& /usr/local/php/bin/phpize && ./configure --enable-memcache --with-php-config=/usr/local/php/bin/php-config && make&& make install - unless: test -f /usr/local/php/lib/php/extensions/*/memcache.so require: - file: memcache-plugin - cmd: php-install /usr/local/php/etc/php.ini: file.append: - text: - extension=memcache.so #温馨提示:在saltstack中,不要安装程序的时候直接复制原来的,会造成ID冲突memcache-plugin 步骤解释: 1.下载 memcached软件包 2.使用/usr/local/php/bin/phpize命令进行 phpize命令含义:phpize是用来扩展php扩展模块的,通过phpize可以建立php的外挂模块 3.在php.ini中添加一条extension=模块名称.so 4.重启PHP即可
前言 __construct(), __destruct(), __call(), __callStatic(), __get(), __set(), __isset(), __unset(), __sleep(), __wakeup(), __serialize(), __unserialize(), __toString(), __invoke(), __set_state(), __clone() 和 __debugInfo() 等方法在 PHP 中被称为魔术方法(Magic methods)。在命名自己的类方法时不能使用这些方法名,除非是想使用其魔术功能。 参考文献 魔术方法官方文档:https://www.php.net/manual/zh/language.oop5.magic.php 演示 __construct:构造方法 __destruct:析构函数 <?php class Magic { // 构造函数 public function __construct() { echo '对象被实例化 new 的时候调用此方法' . PHP_EOL; } // 析构函数 public function __destruct() { echo '对象删除或者销毁时被调用' . PHP_EOL; } } new Magic; /*输出: 对象被实例化 new 的时候调用此方法 对 象删除或者销毁时被调用 */ __call(),在对象中调用一个不可访问方法时调用 __callStatic(),用静态方式中调用一个不可访问方法时调用 <?php class Magic { public function __call($funName, $arguments) { echo "你所调用的函数:" . $funName . "(参数:"; // 输出调用不存在的方法名 print_r($arguments); // 输出调用不存在的方法时的参数列表 echo ")不存在!\n" . PHP_EOL; // 结束换行 } // 声明此方法用来处理调用对象中不存在的方法 public static function __callStatic($funName, $arguments) { echo "你所调用的静态方法:" . $funName . "(参数:"; // 输出调用不存在的方法名 print_r($arguments); // 输出调用不存在的方法时的参数列表 echo ")不存在!\n" . PHP_EOL; // 结束换行 } } $magic = new Magic; var_dump($magic->testCall('names')); var_dump(Magic::testCall('names')); /* 输出: 你所调用的函数:testCall(参数:Array ( [0] => names ) )不存在! NULL 你所调用的静态方法:testCall(参数:Array ( [0] => names ) )不存在! NULL */ __get(),获得一个类的成员私有的变量时调用 <?php class Magic { // 私有变量 private $value = 100; // 读取不可访问属性的值时调用 public function __get($propertyName) { return $this->$propertyName = 200; } } $magic = new Magic; echo $magic->value; /*输出: 200 */ __set(),设置一个类的成员私有的变量时调用 <?php class Magic { // 私有变量 private $value = 100; // 设置一个类的成员变量时调用 public function __set($property, $value){ $this->$property = $value; } // 输出私有方法 public function run(){ return $this->value; } } $magic = new Magic; $magic->value = 666; echo $magic->run(); /*输出: 666 */ __isset(),当对不可访问属性调用isset()或empty()时调用 __unset(),当对不可访问属性调用unset()时被调用 <?php class Magic { public $name = '张三'; private $area = '北京'; // 当对不可访问属性调用isset()或empty()时调用 public function __isset($content) { echo "当在类外部使用isset()函数测定私有成员{$content}时,自动调用<br>"; echo isset($this->$content) . "<br>"; } // 当对不可访问属性调用时被调用 public function __unset($content) { echo "当在类外部使用unset()函数来删除私有成员时自动调用的<br>"; echo isset($this->$content) . "<br>"; } } $magic = new Magic; echo isset($magic->name), "<br>"; // 返回:1 echo isset($magic->area), "<br>"; /*输出: 1 当在类外部使用isset()函数测定私有成员sex时,自动调用 1 */ unset($magic->name); // 返回:1 unset($magic->area); /*输出: 当在类外部使用unset()函数来删除私有成员时自动调用的 1 */ __sleep(),执行serialize()时,先会调用这个函数 __wakeup(),执行unserialize()时,先会调用这个函数 <?php class Magic { public $name = '张三'; private $area = '北京'; public function __sleep(){ echo "当在类外部使用serialize()时会调用这里的__sleep()方法<br>"; $this->name = base64_encode($this->name); return array('name', 'age'); // 这里必须返回一个数值,里边的元素表示返回的属性名称 } public function __wakeup(){ echo "当在类外部使用unserialize()时会调用这里的__wakeup()方法<br>"; $this->name = '李四'; $this->area = '上海'; } } $magic = new Magic; var_dump(serialize($magic)); /*输出: 当在类外部使用serialize()时会调用这里的__sleep()方法 O:5:"Magic":2:{s:4:"name";s:8:"5byg5LiJ";s:3:"age";N;} */ var_dump(unserialize(serialize($magic))); /*输出: 当在类外部使用serialize()时会调用这里的__sleep()方法 当在类外部使用unserialize()时会调用这里的__wakeup()方法 object(Magic)#2 (3) { ["name"]=> string(6) "李四" ["area":"Magic":private]=> string(6) "上海" ["age"]=> NULL } */ __toString(),类被当成字符串时的回应方法 <?php class Magic{ public function __toString(){ return '我是类呀,不是字符串'; } } $magic = new Magic; echo $magic; /*输出: 我是类呀,不是字符串 */ __invoke(),调用函数的方式调用一个对象时的回应方法 <?php class Magic{ public function __invoke(){ echo '这可是一个对象哦'; } } $magic = new Magic; $magic(); /*输出: 这可是一个对象哦 */ __set_state(),调用var_export()导出类时,此静态方法会被调用 <?php class Magic{ public $name; public $age; public function __construct($name = "", $age = 25){ $this->name = $name; $this->age = $age; } public static function __set_state($an_array){ $a = new Magic(); $a->name = $an_array['name']; return $a; } } $person = new magic('小明'); // 初始赋值 $person->name = '小红'; var_export($person); /*输出: Magic::__set_state(array( 'name' => '小红', 'age' => 25, )) */ __clone(),当对象复制完成时调用 <?php class Magic{ public $name = '张三'; public function __clone() { echo __METHOD__ . "你正在克隆对象<br>"; } } $magic = new magic(); $magic2 = clone $magic; $magic->name = '李四'; echo $magic->name . "<br>"; // 李四 echo $magic2->name . "<br>"; // 张三 $magic2->name = '王五'; echo $magic->name . "<br>"; // 李四 echo $magic2->name . "<br>"; // 王五 /*输出: Magic::__clone你正在克隆对象 李四 张三 李四 王五 */ __autoload(),尝试加载未定义的类 警告 本函数已自 PHP 7.2.0 起被废弃,并自 PHP 8.0.0 起被移除。 强烈建议不要依赖本函数。spl_autoload_register — 注册给定的函数作为 __autoload 的实现 <?php function __autoload($className) { $filePath = "project/class/{$className}.php"; die($filePath); if (is_readable($filePath)) { require($filePath); } } $a = new TestClass(); /*返回: Deprecated: __autoload() is deprecated, use spl_autoload_register() instead in F:\www\aaa.php on line 2 project/class/TestClass.php */ __debugInfo(),打印所需调试信息 <?php class Magic{ private $prop; public function __construct($val){ $this->prop = $val; } public function __debugInfo(){ return [ 'propSquared' => $this->prop ** 2, // 这里的 `**` 是乘方的意思 ]; } } var_dump(new Magic(666)); /*输出: object(Magic)#1 (1) { ["propSquared"]=> int(443556) } */
前言 PHP提供了错误处理和日志记录的功能. 这些函数允许你定义自己的错误处理规则,以及修改错误记录的方式. 这样,你就可以根据自己的需要,来更改和加强错误输出信息以满足实际需要。 通过日志记录功能,你可以将信息直接发送到其他日志服务器,或者发送到指定的电子邮箱(或者通过邮件网关发送),或者发送到操作系统日志等,从而可以有选择的记录和监视你的应用程序和网站的最重要的部分。 错误报告功能允许你自定义错误反馈的级别和类型,可以是简单的提示信息或者使用自定义的函数进行处理并返回信息。 error: 不能在编译期发现的运行期错误,不如试图用 echo 输出一个未赋值的变量,这类问题往往导致程序或逻辑无法继续下去而需要中断; exception: 程序执行过程中出现意料之外的情况,逻辑上往往是行得通的,但不符合应用场景,比如接收到一个长度超出预定格式的用户命名,因此,异常主要靠编码人员做预先做判断后抛出,捕获异常后改变程序流程来处理这些情况,不必中断程序。 参考文献 PHP官方文档:https://www.php.net/manual/zh/book.errorfunc.php Exception预定义异常:https://www.php.net/manual/zh/class.exception.php 错误处理函数:https://www.php.net/manual/zh/ref.errorfunc.php 示例演示 try-catch try-catch捕获异常 <?php try { throw new Exception('an error'); // 抛出异常 // you codes that maybe cause an error }catch(\Exception $e){ // 这个错误对象需要声明类型, Exception 是系统默认异常处理类 echo $e->getMessage().PHP_EOL; var_dump($e->getCode()); } // 输出 //an error int(0) try-catch扩展catch子句 <?php // ErrorException:用set_error_handler()函数将错误信息托管至ErrorException。是 PHP 5 增加的异常类以便将错误封装为异常,可以更好地处理错误信息,继承于 Exception。 // 自定义错误类 class MyException extends Exception{ public $errType = 'default'; public function __construct($errType=''){ $this->errType = $errType; } } // 测试实例 try{ // you codes that maybe cause an error throw new MyException('My an error'); }catch(MyException $err){ // 这个错误对象需要声明类型 echo $err->errType.PHP_EOL; }catch(ErrorException $e){ echo 'error !'.$e->getMessage().PHP_EOL; }catch(Exception $e){ echo $e->getMessage().PHP_EOL; } // 输出 // My an error Exception异常的回调函数 error_log error_log() 函数向服务器错误记录、文件或远程目标发送一个错误。如果成功该函数返回 TRUE,如果失败该函数返回 FALSE。 <?php $test=2; if ($test>1) { error_log("触发自定义错误",1,"someone@example.com","From: webmaster@example.com"); } set_error_handler 自定义错误处理函数 该函数用于创建运行期间的用户自己的错误处理方法。该函数返回旧的错误处理程序,如果失败则返回 NULL <?php //error handler function function customError($errno, $errstr, $errfile, $errline) { echo "<b>Custom error:</b> [$errno] $errstr<br />"; echo " Error on line 错误行数 $errline in $errfile<br />"; echo "Ending Script"; die(); } //set error handler set_error_handler("customError"); $test=2; //trigger error if ($test>1) { trigger_error("出发自定义错误"); } /* 输出 Custom error: [1024] 出发自定义错误 Error on line 错误行数 18 in F:\www\aaa.php Ending Script */ set_exception_handler 自定义异常处理函数 该函数用于创建运行期间的用户自己的异常处理方法。该函数返回旧的异常处理程序,如果失败则返回 NULL。 <?php function myException($exception) { echo "<b>Exception:</b> " , $exception->getMessage(); } set_exception_handler('myException'); throw new Exception('自定义异常处理返回'); /* 返回: Exception: 自定义异常处理返回 */ error_get_last 获得最后发生的错误 该函数以数组的形式返回最后发生的错误。如果没有错误发生则返回 NULL <?php echo $test + $abc; print_r(error_get_last()); /* 输出 Array ( [type] => 8 [message] => Undefined variable: abc [file] => F:\www\aaa.php [line] => 2 ) */ register_shutdown_function 注册一个会在php中止时执行的函数 注册一个 callback ,它会在脚本执行完成或者 exit() 后被调用。 <?php function shutdown() { // This is our shutdown function, in // here we can do any last operations // before the script is complete. echo 'Script executed with success', PHP_EOL; } register_shutdown_function('shutdown');
参考文献 PHP opcache 中文手册:http://php.net/manual/zh/book.opcache.php OPcache 配置选项:http://php.net/manual/zh/opcache.configuration.php Opcache优化原理 概述 在理解 OPCache 功能之前,我们有必要先理解PHP-FPM + Nginx 的工作机制,以及PHP脚本解释执行的机制。 1.1 PHP-FPM + Nginx 的工作机制 请求从Web浏览器到Nginx,再到PHP处理完成,一共要经历如下五个步骤: 第一步:启动服务 启动PHP-FPM。PHP-FPM 支持两种通信模式:TCP socket和Unix socket; PHP-FPM 会启动两种类型的进程:Master 进程 和 Worker 进程,前者负责监控端口、分配任务、管理Worker进程;后者就是PHP的cgi程序,负责解释编译执行PHP脚本。 启动Nginx。首先会载入 ngx_http_fastcgi_module 模块,初始化FastCGI执行环境,实现FastCGI协议请求代理 这里要注意:fastcgi的worker进程(cgi进程),是由PHP-FPM来管理,不是Nginx。Nginx只是代理 第二步:Request => Nginx Nginx 接收请求,并基于location配置,选择一个合适handler 这里就是代理PHP的 handler 第三步:Nginx => PHP-FPM Nginx 把请求翻译成fastcgi请求 通过TCP socket/Unix Socket 发送给PHP-FPM 的master进程 第四步:PHP-FPM Master => Worker PHP-FPM master 进程接收到请求 分配Worker进程执行PHP脚本,如果没有空闲的Worker,返回502错误 Worker(php-cgi)进程执行PHP脚本,如果超时,返回504错误 处理结束,返回结果 第五步:PHP-FPM Worker => Master => Nginx PHP-FPM Worker 进程返回处理结果,并关闭连接,等待下一个请求 PHP-FPM Master 进程通过Socket 返回处理结果 Nginx Handler顺序将每一个响应buffer发送给第一个filter → 第二个 → 以此类推 → 最终响应发送给客户端 1.2 PHP脚本解释执行的机制 了解了PHP + Nginx 整体的处理流程后,我们接下来看一下PHP脚本具体执行流程,首先我们看一个实例: <?php if (!empty($_POST)) { echo "Response Body POST: ", json_encode($_POST), "\n"; } if (!empty($_GET)) { echo "Response Body GET: ", json_encode($_GET), "\n"; } 我们分析一下执行过程: 1.php初始化执行环节,启动Zend引擎,加载注册的扩展模块 2.初始化后读取脚本文件,Zend引擎对脚本文件进行词法分析(lex),语法分析(bison),生成语法树 3.Zend 引擎编译语法树,生成opcode, 4.Zend 引擎执行opcode,返回执行结果 在PHP cli模式下,每次执行PHP脚本,四个步骤都会依次执行一遍; 在PHP-FPM模式下,步骤1)在PHP-FPM启动时执行一次,后续的请求中不再执行;步骤2)~4)每个请求都要执行一遍; 其实步骤2)、3)生成的语法树和opcode,同一个PHP脚本每次运行的结果都是一样的, 在PHP-FPM模式下,每次请求都要处理一遍,是对系统资源极大的浪费,那么有没有办法优化呢? 当然有,如: OPCache:前身是Zend Optimizer+ ,是 Zend Server 的一个开源组件;官方出品,强力推荐 APC:Alternative PHP Cache 是一个开放自由的 PHP opcode 缓存组件,用于缓存、优化 PHP 中间代码;已经不更新了不推荐 APCu:是APC的一个分支,共享内存,缓存用户数据,不能缓存opcode,可以配合Opcache 使用 eAccelerate:同样是不更新了,不推荐 xCache:不再推荐使用了 OPCache介绍与原理 OPCache 介绍 OPCache 是Zend官方出品的,开放自由的 opcode 缓存扩展,还具有代码优化功能,省去了每次加载和解析 PHP 脚本的开销。 PHP 5.5.0 及后续版本中已经绑定了 OPcache 扩展。 缓存两类内容: OPCode Interned String,如注释、变量名等 OPCache 原理 OPCache缓存的机制主要是:将编译好的操作码放入共享内存,提供给其他进程访问。 这里就涉及到内存共享机制,另外所有内存资源操作都有锁的问题,我们一一解读。 3.1 共享内存 UNIX/Linux 系统提供很多种进程间内存共享的方式: 1.System-V shm API: System V共享内存, sysv shm是持久化的,除非被一个进程明确的删除,否则它始终存在于内存里,直到系统关机; 2.mmap API: mmap映射的内存在不是持久化的,如果进程关闭,映射随即失效,除非事先已经映射到了一个文件上 内存映射机制mmap是POSIX标准的系统调用,有匿名映射和文件映射两种 mmap的一大优点是把文件映射到进程的地址空间 避免了数据从用户缓冲区到内核page cache缓冲区的复制过程; 当然还有一个优点就是不需要频繁的read/write系统调用 3.POSIX API:System V 的共享内存是过时的, POSIX共享内存提供了使用更简单、设计更合理的API. 4.Unix socket API OPCache 使用了前三个共享内存机制,根据配置或者默认mmap 内存共享模式。 依据PHP字节码缓存的场景,OPCache的内存管理设计非常简单,快速读写,不释放内存,过期数据置为Wasted。 当Wasted内存大于设定值时,自动重启OPCache机制,清空并重新生成缓存。 3.2 互斥锁 任何内存资源的操作,都涉及到锁的机制。 共享内存:一个单位时间内,只允许一个进程执行写操作,允许多个进程执行读操作; 写操作同时,不阻止读操作,以至于很少有锁死的情况。 这就引发另外一个问题:新代码、大流量场景,进程排队执行缓存opcode操作;重复写入,导致资源浪费。 4. OPCache 缓存解读 OPCache 是官方的Opcode 缓存解决方案,在PHP5.5版本之后,已经打包到PHP源码中一起发布。 它将PHP编译产生的字节码以及数据缓存到共享内存中, 在每次请求,从缓存中直接读取编译后的opcode,进行执行。 通过节省脚本的编译过程,提高PHP的运行效率。 如果正在使用APC扩展,做同样的工作,现在强烈推荐OPCache来代替,尤其是PHP7中。 4.1 OPCode 缓存 Opcache 会缓存OPCode以及如下内容: PHP脚本涉及到的函数 PHP脚本中定义的Class PHP脚本文件路径 PHP脚本OPArray PHP脚本自身结构/内容 4.2 Interned String 缓存 首先我们需要理解,什么是 Interned String? 在PHP5.4的时候, 引入了Interned String机制, 用于优化PHP对字符串的存储和处理。尤其是处理大块的字符串,比如PHP doces时,Interned String 可以优化内存。 Interned String 缓存的内容包括:变量名称、类名、方法名、字符串、注释等。 在PHP-FPM模式中,Interned String 缓存字符,仅限于Worker 进程内部。而缓存到OPCache中,那么Worker进程之间可以使用 Interned String 缓存的字符串,节省内存。 我们需要注意一个事情,在PHP开发中,一般会有大段的注释,也会被缓存到OPCache中。可以通过php.ini的配置,关闭注释的缓存。 但是,像Zend Framework等框架中,会引用注释,所以,是否关闭注释的缓存,需要区别对待。 5. OPCache 更新策略 是缓存,都存在过期,以及更新策略等。而OPCache的更新策略非常简单,到期数据置为Wasted,达到设定值,清空缓存,重建缓存。 这里需要注意:在高流量的场景下,重建缓存是一件非常耗费资源的事儿。OPCache 在创建缓存时并不会阻止其他进程读取。这会导致大量进程反复新建缓存。所以,不要设置OPCache过期时间 每次发布新代码时,都会出现反复新建缓存的情况。如何避免呢? 不要在高峰期发布代码,这是任何情况下都要遵守的规则 代码预热,比如使用脚本批量调PHP 访问URL,或者使用OPCache 暴露的API 如opcache_compile_file() 进行编译缓存 6. OPCache 的配置 6.1 内存配置 opcache.preferred_memory_model=“mmap” OPcache 首选的内存模块。如果留空,OPcache 会选择适用的模块, 通常情况下,自动选择就可以满足需求。可选值包括:mmap,shm, posix 以及 win32。 opcache.memory_consumption=64 OPcache 的共享内存大小,以兆字节为单位,默认64M opcache.interned_strings_buffer=4 用来存储临时字符串的内存大小,以兆字节为单位,默认4M opcache.max_wasted_percentage=5 浪费内存的上限,以百分比计。如果达到此上限,那么 OPcache 将产生重新启动续发事件。默认5 6.2 允许缓存的文件数量以及大小 opcache.max_accelerated_files=2000 OPcache 哈希表中可存储的脚本文件数量上限。真实的取值是在质数集合 { 223, 463, 983, 1979, 3907, 7963, 16229, 32531, 65407, 130987 } 中找到的第一个大于等于设置值的质数。设置值取值范围最小值是 200,最大值在 PHP 5.5.6 之前是 100000,PHP 5.5.6 及之后是 1000000。默认值2000 opcache.max_file_size=0 以字节为单位的缓存的文件大小上限。设置为 0 表示缓存全部文件。默认值0 6.3 注释相关的缓存 opcache.load_commentsboolean 如果禁用,则即使文件中包含注释,也不会加载这些注释内容。本选项可以和 opcache.save_comments 一起使用,以实现按需加载注释内容。 opcache.fast_shutdown boolean 如果启用,则会使用快速停止续发事件。所谓快速停止续发事件是指依赖 Zend 引擎的内存管理模块 一次释放全部请求变量的内存,而不是依次释放每一个已分配的内存块。 6.4 二级缓存的配置 opcache.file_cache 配置二级缓存目录并启用二级缓存。启用二级缓存可以在 SHM 内存满了、服务器重启或者重置 SHM 的时候提高性能。默认值为空字符串 “”,表示禁用基于文件的缓存。 opcache.file_cache_onlyboolean 启用或禁用在共享内存中的 opcode 缓存。 opcache.file_cache_consistency_checksboolean 当从文件缓存中加载脚本的时候,是否对文件的校验和进行验证。 opcache.file_cache_fallbackboolean 在 Windows 平台上,当一个进程无法附加到共享内存的时候, 使用基于文件的缓存,也即:opcache.file_cache_only=1。需要显示的启用文件缓存。 Opcache配置 PHP 5.5+版本以上的,可以使用PHP自带的opcache开启性能加速(默认是关闭的),PHP5.5之后opcache可以直接 --enable-opcache 。 对于PHP 5.5以下版本的,需要使用APC来进行缓存, 1. 打开php.ini文件 2. 找到:[opcache],设置为: [opcache] ; 开关打开 opcache.enable=1 ; 设置共享内存大小, 单位为:Mb opcache.memory_consumption=128 ; 如果启用,那么 OPcache 会每隔 opcache.revalidate_freq 设定的秒数 检查脚本是否更新。 如果禁用此选项,你必须使用 opcache_reset() 或者 opcache_invalidate() 函数来手动重置 OPcache,也可以 通过重启 Web 服务器来使文件系统更改生效。 opcache.validate_timestamps=60 3. 添加opcache.so 在php.ini最后一行添加opcache.so 主要作用是用来引用opcache zend_extension="opcache.so" 4. 重启Nginx和php 5. 测试 我们除了可以在 phpinfo 上查看,还可以在 php-fpm -m 命令进行查看 [root@abcdocker ~]$ php -m [PHP Modules] .... [Zend Modules] Zend OPcache
参考文献 DockerHub:https://hub.docker.com/r/jefferyjob/node-hexo GithubDockerfile:https://github.com/jefferyjob/node-hexo Docker-composer管理hexo:https://github.com/jefferyjob/docker-compose-hexo 使用 Dockerfile 基于 alpine 系统,构建 nodejs 环境和 hexo 基础依赖以及 git 环境。 Dockerfile构建 编写镜像包 第1种方法: # base image FROM node:alpine # MAINTAINER MAINTAINER lbinjob@163.com # work dir WORKDIR /app # run install RUN npm config set registry https://registry.npm.taobao.org \ && npm install -g hexo # port EXPOSE 4000 第2种方法:未来更好的扩展性,此处采用 alpine 从0到1开始构建。 # base image FROM alpine:3.10 # MAINTAINER MAINTAINER lbinjob@163.com # work dir WORKDIR /app # run install RUN apk add --no-cache nodejs \ && apk add --no-cache git \ && apk add --no-cache npm \ && npm config set registry https://registry.npm.taobao.org \ && npm install -g hexo # port EXPOSE 4000 镜像发布 镜像生成: docker build -t node-hexo:1.0 . –tag 或 -t:镜像的名字及标签,通常 name:tag 或者 name 格式;可以在一次构建中为一个镜像设置多个标签 -f:指定要使用的Dockerfile路径 镜像测试: # 查看镜像构建历史 docker history node-hexo:1.0 # run 一个测试容器 docker run -itd --name node-hexo-test node-hexo:1.0 # 进入容器 docker exec -it node-hexo-test /bin/sh # 进行软件测试 $ node -v v10.24.0 $ npm -v 6.14.11 $ git --version git version 2.22.5 $ hexo -version hexo-cli: 4.2.0 发布到DockerHub: 1、注册 Dockerhub 的仓库 注册地址:https://hub.docker.com/repository/create 名称要和你要发布的镜像名称保持一致哦 此处我创建的为 public 权限的 node-hexo 仓库 2、重命名 命名规范为:docker_username/repository_name docker tag node-hexo:1.0 jefferyjob/node-hexo:1.0 3、登录docker docker login -u jefferyjob 4、发布 docker push jefferyjob/node-hexo:1.0 注意,此版本发布后,把版本号改为 latest 然后再发布一次,让dockerhub中的latest保持最新。 5、高级技巧 你也可以将此仓库绑定到你的 github 仓库,每次修改 github 仓库的 dockerfile 的时候,实施 webhook 通知你的docker repository 实现自动更新 docker 镜像。 也可以在 dockerfile 中使用 ENTRYPOINT [“docker-entrypoint.sh”] 管理入口,提供更加便捷化的操作。 问题 开始我使用Nodejs官方的源码包安装Nodejs。也就是将官网下载的tar格式的包解压后,再链接二进制文件的方式。 但是发生了一个问题:我将源码包里边的node可执行文件链接到/usr/sbin/node后,执行node -v,提示command not found。 原因: 几经查找,发现原来是因为Nodejs官网提供的源码包是使用glibc库打包的,而Alpine使用的是musl库 解决方案: 使用Alpine自带的apk进行安装: apk add nodejs npm。 使用非官方构建源里边的包,里边有musl版本的:https://unofficial-builds.nodejs.org/download/release/ 。虽说是非官方构建源,也是官网上提供的链接。 反思: 之前也看到过Alpine关于编译库的坑,但是没想到这么快就让我遇上了,而且的确容易被command not found这个提示误导,这使我一度怀疑dash,$PATH和ln。
进制转换 10进制转2进制 此处计算,20 的 2 进制结果是 10100 连续除以 2,然后吧余数写到右边,知道除尽。在从下到上把2进制结果集读出来 2进制转10进制 此处计算,10100 的 10 进制结果是 20 将2进制的结果,从后到前排列,然后乘以 2 的 n 次方(n从0每次自增1)。最后把结果集相加。 0 * 2^0 = 0 0 * 2^1 = 0 1 * 2^2 = 4 0 * 2^3 = 0 1 * 2^4 = 16 然后将计算的结果,依次相加 16 + 0 + 4 + 0 +0 = 20 8进制转2进制 20 的 8进制 是 24 开始计算 8进制 数据 24 的 2进制 首先把 24 拆分成 2 和 4,然后分别计算 2进制结果 2 的 2进制 计算结果是:10 4 的 2进制 计算结果是:100 将2的2进制、4的2进制结果拼装起来 则 8进制 数据24 的 2进制 计算结果是:10100 10进制转8进制 此处计算,20 的 8 进制结果是 24 进制运算 20 的 2 进制结果是 10100 15 的 2 进制结果是 1111 加法运算 满2进1 减法运算 借1个等于2,然后运算 进制位运算 计算机中的数在内存中都是以二进制形式进行存储的,用位运算就是直接对整数在内存中的二进制位进行操作,因此其执行效率非常高,在程序中尽量使用位运算进行操作,这会大大提高程序的性能。 & 与运算 两个位都是 1 时,结果才为 1,否则为 0,如 1 0 0 1 1 1 1 0 0 1 ------------------------------ 1 0 0 0 1 | 或运算 两个位都是 0 时,结果才为 0,否则为 1,如 1 0 0 1 1 1 1 0 0 1 ------------------------------ 1 1 0 1 1 ^ 异或运算,两个位相同则为 0,不同则为 1,如 1 0 0 1 1 1 1 0 0 1 ----------------------------- 0 1 0 1 0 ~ 取反运算,0 则变为 1,1 则变为 0,如 1 0 0 1 1 ----------------------------- 0 1 1 0 0
前言 RabbitMQ 对于生产者的消息,可以通过 Exchange Type 发送到Queue,也可以直接发送到 Queue 常用的Exchange Type有fanout(扇形匹配)、direct(绝对匹配)、topic(正则匹配)、headers(head头键值对)这四种。AMQP规范里还提到两种Exchange Type,分别为system与自定义。 非Exchange Type一般有两种,simple(简单)、work(竞争)。 交换机模式 1、fanout(publish/subscribe发布订阅[共享资源]) fanout类型的Exchange路由规则非常简单,它会把所有发送到该Exchange的消息路由到所有与它绑定的Queue中。 上图中,生产者(P)发送到Exchange(X)的所有消息都会路由到图中的两个Queue,并最终被两个消费者(C1与C2)消费 2、direct(routing路由模式) direct类型的Exchange路由规则也很简单,它会把消息路由到那些binding key与routing key完全匹配的Queue中。 1.消息生产者将消息发送给交换机按照路由判断,路由是字符串(info) 当前产生的消息携带路由字符(对象的方法),交换机根据路由的key,只能匹配上路由key对应的消息队列,对应的消费者才能消费消息; 2.根据业务功能定义路由字符串 3.从系统的代码逻辑中获取对应的功能字符串,将消息任务扔到对应的队列中。 4.业务场景:error 通知;EXCEPTION;错误通知的功能;传统意义的错误通知;客户通知;利用key路由,可以将程序中的错误封装成消息传入到消息队列中,开发者可以自定义消费者,实时接收错误; 3、topic(主题模式) 1.* 号 # 号代表通配符 2.*代表多个单词,# 代表一个单词 3.路由功能添加模糊匹配 4.消息产生者产生消息,把消息交给交换机 5.交换机根据key的规则模糊匹配到对应的队列,由队列的监听消费者接收消息消费 (在我的理解看来就是routing查询的一种模糊匹配,就类似sql的模糊查询方式) 4、headers(head头键值对) headers 类型的 Exchange 不依赖于routing key与binding key的匹配规则来路由消息,而是根据发送的消息内容中的headers属性进行匹配。 在绑定Queue与Exchange时指定一组键值对;当消息发送到Exchange时,RabbitMQ会取到该消息的headers(也是一个键值对的形式),对比其中的键值对是否完全匹配Queue与Exchange绑定时指定的键值对;如果完全匹配则消息会路由到该Queue,否则不会路由到该Queue。 5、RPC MQ本身是基于异步的消息处理,前面的示例中所有的生产者(P)将消息发送到RabbitMQ后不会知道消费者(C)处理成功或者失败(甚至连有没有消费者来处理这条消息都不知道)。 但实际的应用场景中,我们很可能需要一些同步处理,需要同步等待服务端将我的消息处理完成后再进行下一步处理。这相当于RPC(Remote Procedure Call,远程过程调用)。在RabbitMQ中也支持RPC。 RabbitMQ中实现RPC的机制是: 客户端发送请求(消息)时,在消息的属性(MessageProperties,在AMQP协议中定义了14中properties,这些属性会随着消息一起发送)中设置两个值replyTo(一个Queue名称,用于告诉服务器处理完成后将通知我的消息发送到这个Queue中)和correlationId(此次请求的标识号,服务器处理完成后需要将此属性返还,客户端将根据这个id了解哪条请求被成功执行了或执行失败) 服务器端收到消息并处理 服务器端处理完消息后,将生成一条应答消息到replyTo指定的Queue,同时带上correlationId属性 客户端之前已订阅replyTo指定的Queue,从中收到服务器的应答消息后,根据其中的correlationId属性分析哪条请求被执行了,根据执行结果进行后续业务处理 非交换机模式 1、simple模式(即最简单的收发模式) 1.消息产生消息,将消息放入队列 2.消息的消费者(consumer) 监听 消息队列,如果队列中有消息,就消费掉,消息被拿走后,自动从队列中删除(隐患 消息可能没有被消费者正确处理,已经从队列中消失了,造成消息的丢失,这里可以设置成手动的ack,但如果设置成手动ack,处理完后要及时发送ack消息给队列,否则会造成内存溢出)。 2、work工作模式(资源的竞争) 消息产生者将消息放入队列消费者可以有多个,消费者1,消费者2同时监听同一个队列,消息被消费。 C1 C2共同争抢当前的消息队列内容,谁先拿到谁负责消费消息 (隐患:高并发情况下,默认会产生某一个消息被多个消费者共同使用,可以设置一个开关(syncronize) 保证一条消息只能被一个消费者使用)。
引言 消息服务擅长于解决多系统、异构系统间的数据交换(消息通知/通讯)问题,你也可以把它用于系统间服务的相互调用(RPC)。本文将要介绍的RabbitMQ就是当前最主流的消息中间件之一。 参考文献 主流消息中间件:https://blog.csdn.net/weixin_42367527/article/details/108219802 RabbitMQ简介 AMQP,即Advanced Message Queuing Protocol,高级消息队列协议,是应用层协议的一个开放标准,为面向消息的中间件设计。消息中间件主要用于组件之间的解耦,消息的发送者无需知道消息使用者的存在,反之亦然。 AMQP的主要特征是面向消息、队列、路由(包括点对点和发布/订阅)、可靠性、安全。 RabbitMQ是一个开源的AMQP实现,服务器端用Erlang语言编写,支持多种客户端,如:Python、Ruby、.NET、Java、JMS、C、PHP、ActionScript、XMPP、STOMP等,支持AJAX。用于在分布式系统中存储转发消息,在易用性、扩展性、高可用性等方面表现不俗。 术语理解 ConnectionFactory、Connection、Channel ConnectionFactory、Connection、Channel都是RabbitMQ对外提供的API中最基本的对象。 Connection是RabbitMQ的socket链接,它封装了socket协议相关部分逻辑。ConnectionFactory为Connection的制造工厂。 Channel是我们与RabbitMQ打交道的最重要的一个接口,我们大部分的业务操作是在Channel这个接口中完成的,包括定义Queue、定义Exchange、绑定Queue与Exchange、发布消息等。 Queue Queue(队列)是RabbitMQ的内部对象,用于存储消息,用下图表示。 RabbitMQ中的消息都只能存储在Queue中,生产者(下图中的P)生产消息并最终投递到Queue中,消费者可以从Queue中获取消息并消费。 多个消费者可以订阅同一个Queue,这时Queue中的消息会被平均分摊给多个消费者进行处理,而不是每个消费者都收到所有的消息并处理。 Message acknowledgment(ack机制) 在实际应用中,可能会发生消费者收到Queue中的消息,但没有处理完成就宕机(或出现其他意外)的情况,这种情况下就可能会导致消息丢失。为了避免这种情况发生,我们可以要求消费者在消费完消息后发送一个回执给RabbitMQ,RabbitMQ收到消息回执(Message acknowledgment)后才将该消息从Queue中移除;如果RabbitMQ没有收到回执并检测到消费者的RabbitMQ连接断开,则RabbitMQ会将该消息发送给其他消费者(如果存在多个消费者)进行处理。 这里不存在timeout概念,一个消费者处理消息时间再长也不会导致该消息被发送给其他消费者,除非它的RabbitMQ连接断开。 这里会产生另外一个问题,如果我们的开发人员在处理完业务逻辑后,忘记发送回执给RabbitMQ,这将会导致严重的bug——Queue中堆积的消息会越来越多;消费者重启后会重复消费这些消息并重复执行业务逻辑… Message durability 如果我们希望即使在RabbitMQ服务重启的情况下,也不会丢失消息,我们可以将Queue与Message都设置为可持久化的(durable),这样可以保证绝大部分情况下我们的RabbitMQ消息不会丢失。 但依然解决不了小概率丢失事件的发生(比如RabbitMQ服务器已经接收到生产者的消息,但还没来得及持久化该消息时RabbitMQ服务器就断电了),如果我们需要对这种小概率事件也要管理起来,那么我们要用到事务。 Prefetch count 前面我们讲到如果有多个消费者同时订阅同一个Queue中的消息,Queue中的消息会被平摊给多个消费者。这时如果每个消息的处理时间不同,就有可能会导致某些消费者一直在忙,而另外一些消费者很快就处理完手头工作并一直空闲的情况。我们可以通过设置prefetchCount来限制Queue每次发送给每个消费者的消息数,比如我们设置prefetchCount=1,则Queue每次给每个消费者发送一条消息;消费者处理完这条消息后Queue会再给该消费者发送一条消息。 Exchange 在上一节我们看到生产者将消息投递到Queue中,实际上这在RabbitMQ中这种事情永远都不会发生。实际的情况是,生产者将消息发送到Exchange(交换器,下图中的X),由Exchange将消息路由到一个或多个Queue中(或者丢弃)。 Exchange是按照什么逻辑将消息路由到Queue的?这个将在Binding一节介绍。 routing key 生产者在将消息发送给Exchange的时候,一般会指定一个routing key,来指定这个消息的路由规则,而这个routing key需要与Exchange Type及binding key联合使用才能最终生效。 在Exchange Type与binding key固定的情况下(在正常使用时一般这些内容都是固定配置好的),我们的生产者就可以在发送消息给Exchange时,通过指定routing key来决定消息流向哪里。 RabbitMQ为routing key设定的长度限制为255 bytes。 Binding RabbitMQ中通过Binding将Exchange与Queue关联起来,这样RabbitMQ就知道如何正确地将消息路由到指定的Queue了。 Binding key 在绑定(Binding)Exchange与Queue的同时,一般会指定一个binding key;消费者将消息发送给Exchange时,一般会指定一个routing key;当binding key与routing key相匹配时,消息将会被路由到对应的Queue中。 在绑定多个Queue到同一个Exchange的时候,这些Binding允许使用相同的binding key。 binding key 并不是在所有情况下都生效,它依赖于Exchange Type,比如fanout类型的Exchange就会无视binding key,而是将消息路由到所有绑定到该Exchange的Queue。
在工作和面试过程中,我们经常遇到优化SQL的问题,所以本文主要从:设计、中间件、高可用,三个方案,提供给大家一种优化mysql的方案。 常见优化方案 从设计角度优化 服务降级(限流,限定查询范围) 选择正确的存储引擎 建立合适的索引 分库分表(水平分表,垂直分表) 建立异构索引表 限定查询范围 从中间件角度优化 热点数据采用NoSql代替 模糊查询采用ES代替 从高可用角度优化 做数据库的读写分离,分布式 定期清理不用的数据,定时进行碎片整理 配置比较高的mysql并发数 使用数据库连接池 服务器优化(操作系统和硬件) 优化方案实践学习 常见分库分表方案:https://zhuanlan.zhihu.com/p/137368446 阿里巴巴异构索引表方案:https://www.jianshu.com/p/4318a619a815
前言 五种IO的模型:阻塞IO(blocking)、非阻塞IO(non-blocking)、多路复用IO(multiplexing)、信号驱动IO(Signal )、异步IO(asynchronous) 对于一个应用程序即一个操作系统进程来说,它既有内核空间(与其他进程共享),也有用户空间(进程私有),它们都是处于虚拟地址空间中。用户进程是无法访问内核空间的,它只能访问用户空间,通过用户空间去内核空间复制数据,然后进行处理。 参考资料 图解网络io模型:https://www.itzhai.com/articles/necessary-knowledge-of-network-programming-graphic-socket-core-insider-and-five-io-models.html IO是什么 IO (Input/Output,输入/输出):即数据的读取(接收)或写入(发送)操作,通常用户进程中的一个完整IO分为两阶段:用户进程空间<–>内核空间、内核空间<–>设备空间(磁盘、网络等)。IO有内存IO、网络IO和磁盘IO三种,通常我们说的IO指的是后两者。 LINUX中进程无法直接操作I/O设备,其必须通过系统调用请求kernel来协助完成I/O动作;内核会为每个I/O设备维护一个缓冲区。 对于一个输入操作来说,进程IO系统调用后,内核会先看缓冲区中有没有相应的缓存数据,没有的话再到设备中读取,因为设备IO一般速度较慢,需要等待;内核缓冲区有数据则直接复制到进程空间。 所以,对于一个网络输入操作通常包括两个不同阶段: 等待网络数据到达网卡→读取到内核缓冲区,数据准备好; 从内核缓冲区复制数据到进程空间。 5种IO模型 阻塞io(同步io): 发起请求就一直等待,直到数据返回。好比你去商场试衣间,里面有人,那你就一直在门外等着。(全程阻塞) 非阻塞io(同步io): 不管有没有数据都返回,没有就隔一段时间再来请求,如此循环。好比你要喝水,水还没烧开,你就隔段时间去看一下饮水机,直到水烧开为止。(复制数据时阻塞) io复用(同步io): I/O是指网络I/O,多路指多个TCP连接(即socket或者channel),复用指复用一个或几个线程。意思说一个或一组线程处理多个连接。比如课堂上学生做完了作业就举手,老师就下去检查作业。(对一个IO端口,两次调用,两次返回,比阻塞IO并没有什么优越性;关键是能实现同时对多个IO端口进行监听,可以同时对多个读/写操作的IO函数进行轮询检测,直到有数据可读或可写时,才真正调用IO操作函数。) 信号驱动io(同步io): 事先发出一个请求,当有数据后会返回一个标识回调,这时你可以去请求数据。好比银行排号,当叫到你的时候,你就可以去处理业务了(复制数据时阻塞)。 异步io: 发出请求就返回,剩下的事情会异步自动完成,不需要做任何处理。好比有事秘书干,自己啥也不用管。 总结: 五种IO的模型:阻塞IO、非阻塞IO、多路复用IO、信号驱动IO和异步IO;前四种都是同步IO,在内核数据copy到用户空间时都是阻塞的。 阻塞IO和非阻塞IO的区别在于第一步,发起IO请求是否会被阻塞,如果会那就是传统的阻塞IO,如果不会那就是非阻塞IO。 同步IO和异步IO的区别就在于第二个步骤是否阻塞,如果实际的IO读写阻塞请求进程,那么就是同步IO;如果不阻塞,而是操作系统帮你做完IO操作再将结果返回给你,那么就是异步IO
前言 在电脑中,数据是由0和1构成的,它模拟了自然界的开与关,通与止,阴与阳等等的一些现象,也就是我们称之为“二进制”中的数据。 数据在计算机中以二进制的形式存在的,也必须用二进制的形式来表示,也就是机器语言。 机器语言是一种计算机语言(低级语言),它是计算机唯一可以读懂的语言,由1和0组成。 单位 最小数据单位就是 “位”,符号“Bit”或“b”(小写),即一个比特,内容是0或1,表示一位二进制信息。 单位从小到大排序 比特Bit(b), 字节Byte(B), 千字节Kilobytes(KB), 兆字节Megabytes(MB), 吉字节Gigabyte(GB) and 太字节terabyte(TB) 换算关系 8b = 1B 1024B = 1k、1024kb = 1m、1024m = 1G、1024G = 1T 1M 等于 2^20 bytes ? 1M=1024KB,1KB = 1024byte 故:1M = 1024*1024 = 2^20byte = 1048576 byte Mysql中的单位 ASCII码: 一个英文字母(不分大小写)占一个字节的空间,一个中文汉字占两个字节的空间。一个二进制数字序列,在计算机中作为一个数字单元,一般为8位二进制数,换算为十进制。最小值0,最大值255。如一个ASCII码就是一个字节。 UTF-8编码: 一个英文字符等于一个字节,一个中文(含繁体)等于2个字节。 Unicode编码: 一个英文等于两个字节,一个中文(含繁体)等于2个字节。 UTF-16编码: 一个英文字母字符或一个汉字字符存储都需要2个字节(Unicode扩展区的一些汉字存储需要4个字节)。 UTF-32编码: 世界上任何字符的存储都需要4个字节。 常见问题: char 和 varchar 占用空间比较 表结构定义中声明char和varchar类型时,必须指定字符串的长度,也就是该列所能存储多少个字符。 注意这里:不是字节,是字符 例如:char(10)和varchar(10)都能存储10个字符 **char(M)**类型的数据列里,每个值都占用M个字节,如果某个长度小于M,MySQL就会在它的右边用空格字符补足。 **varchar(M)**类型的数据列里,每个值只占用刚好够用的字节再加上一个用来记录其长度的字节(即总长度为L+1字节) BloomFilter布隆过滤器 布隆过滤器中,由于是一个二进制的向量数组,所以每个占用空间是 1bit
参考文献 laravel服务容器:https://learnku.com/docs/laravel/8.x/container/9361 实现步骤 定义接口类 创建 app/Interfaces 文件夹,创建测试接口类 TestIocInterface.php,并定义接口契约。 <?php namespace App\Interfaces; interface TestIocInterface { public function test_string(string $string) : string; } 1)为了方便管理,定义一个契约目录,专门要来存放接口,将要实现的功能定义成接口,然后子类实现。 2)当然了,不这样使用也可以通过其它方法来实现服务容器与服务提供者的结合使用。 定义服务实现类 创建 app/Services 文件夹,创建测试接口类 TestIocService.php,实现定义的接口契约。 <?php namespace App\Services; use App\Interfaces\TestIocInterface; class TestIocService implements TestIocInterface { public function test_string(string $string): string { return "你的字符串返回结果是 {$string}"; } } 创建服务提供者 php artisan make:provider TestIocServiceProvider 编辑生成的 TestIocServiceProvider.php <?php namespace App\Providers; use App\Services\TestIocService; use Illuminate\Support\ServiceProvider; class TestIocServiceProvider extends ServiceProvider { /** * 将服务接口注绑定至服务容器 * * @return void */ public function register() { // demo1 写法 $this->app->bind('App\Interfaces\TestIocInterface', function(){ return new TestIocService(); }); // demo2 写法 //$this->app->bind('App\Interfaces\TestInterface', TestService::class); // demo3 写法 //$this->app->bind('App\Interfaces\TestIocInterface', 'App\Services\TestIocService'); // 单例写法 $this->app->singleton('TestIocInterface', function(){ return new TestIocService(); }); } /** * Bootstrap services. * * @return void */ public function boot() { // } } 注册服务提供者 这一步需要在app/config/app.php文件中的providers数组中将服务提供者注册到应用中。 <?php 'providers' => [ # 此处省略其它内容 App\Providers\TestIocServiceProvider::class, ], 测试 创建测试控制器,并编辑以下文件 php artisan make:controller TestIocController <?php namespace App\Http\Controllers; use App\Interfaces\TestIocInterface; use Illuminate\Http\Request; use Illuminate\Support\Facades\App; class TestIocController extends Controller { /** * demo1: 调用方法 * * @param TestIocInterface $test */ public function test(TestIocInterface $test) { echo $test->test_string("啦啦啦啦啦"); } /** * 单例调用方法 */ public function test2() { $testIoc = App::make('TestIocInterface'); echo $testIoc->test_string("啦啦啦啦啦"); } } 编写访问路由 <?php use Illuminate\Support\Facades\Route; use App\Http\Controllers\TestIocController; Route::get('testioc', [TestIocController::class, 'test']); Route::get('testioc2', [TestIocController::class, 'test2']);
前言 反射它指在 PHP 运行状态中,扩展分析 PHP 程序,导出或提取出关于类、方法、属性、参数等的详细信息,包括注释。 这种动态获取的信息以及动态调用对象的方法的功能称为反射 API。 反射是操纵面向对象范型中元模型的 API,其功能十分强大,可帮助我们构建复杂,可扩展的应用。其用途如:自动加载插件,自动生成文档,甚至可用来扩充 PHP 语言 概念理解 什么是反射? 获取类的有关信息 ReflectionClass:https://www.php.net/manual/zh/class.reflectionclass.php 获取类的构造函数 getConstructor:https://www.php.net/manual/zh/reflectionclass.getconstructor.php 获取类的依赖类 getParameters:https://www.php.net/manual/zh/reflectionfunctionabstract.getparameters.php 通过下面的伪代码,可以获取到 class A 的构造函数,还有构造函数依赖的类。此处 class A 构造函数依赖 args ,而且通过 TypeHint 可以知道他是类型为 Class B。 此反射机制可以让我去解析一个类,能获取一个类的属性、方法、构造函数、构造函数所需要的参数。 <?php class B{ } class A { public function __construct(B $args) { } public function dosomething() { echo 'Hello world'; } } // 建立 A class 的反射 // 返回:object(ReflectionClass)#1 (1) { ["name"]=> string(1) "A" } $reflection = new ReflectionClass('A'); //var_dump($reflection);die; // 获取class A 的实例 // 返回:object(A)#3 (0) { } $instance = $reflection ->newInstanceArgs([ new B() ]); //var_dump($instance);die; //输出 ‘Hellow World’ $dosomething = $instance->dosomething(); // 获取 A class 的构造函数 $constructor = $reflection->getConstructor(); //获取 A class 的依赖类 $dependencies = $constructor->getParameters(); // 返回 A 的构造函数 var_dump($constructor); // 返回 A 的依赖类 var_dump($dependencies); // 返回结果如下: //constructor /* ReflectionMethod {#351 ▼ +name: "__construct" +class: "A" parameters: array:1 [▶] extra: array:3 [▶] modifiers: "public" }*/ //$dependencies /*array:1 [▼ 0 => ReflectionParameter {#352 ▼ +name: "args" position: 0 typeHint: "B" } ] */ IOC 容器 接下来介绍一下 Laravel 的 IOC 服务容器概念。在 laravel 框架中, 服务容器是整个 laravel 的核心,它提供了整个系统功能及服务的配置,调用。 容器按照字面上的理解就是装东西的东西,比如冰箱, 当我们需要冰箱里面的东西的时候直接从里面拿就行了。服务容器也可以这样理解, 当程序开始运行的时候,我们把我们需要的一些服务放到或者注册到 (bind) 到容器里面,当我需要的时候直接取出来 (make) 就行了。上面提到的 bind 和 make 就是注册 和 取出的 两个动作。 IOC容器代码 下面要上一段容器的代码了。下面这段代码不是 laravel 的源码, 而是来自一本书《laravel 框架关键技术解析》 https://learnku.com/articles/4076/how-to-understand-laravels-ioc-container https://learnku.com/laravel/t/26922
理论场景化 在面向对象设计的软件系统中,它的底层都是由N个对象构成的,各个对象之间通过相互合作,最终实现系统地业务逻辑。 例:在机械表的内部,可以看到这样的情形。秒针的齿轮带动分钟的齿轮运转,分钟的齿轮带动时钟的齿轮运转,最终实现整个时间的展示。当然,如果其中一个齿轮出现了问题,那么其他齿轮系统也会出现问题,最终影响整个齿轮系统。 齿轮之间的联动关系,是不是与软件系统中对象之间的耦合关系非常相似。这些对象之间的耦合关系是无法避免的,也是必要的,这是协同工作的基础。 伴随着项目规模越来越大,对象之间的依赖关系也变的越来越复杂,常常出现对象之间的多重依赖关系。因此,程序员对于软件的设计,面临更大的挑战。对象之间耦合度过高的系统,会出现牵一发而动全身的现象。 耦合关系不仅会出现在对象与对象之间,也会出现在软件系统的各模块之间,以及软件系统和硬件系统之间。如何降低系统之间、模块之间和对象之间的耦合度,是软件工程永远追求的目标之一。 为了解决对象之间的耦合度过高的问题,软件专家Michael Mattson 1996年提出了IOC理论,用来实现对象之间的 “解耦”,目前这个理论已经被成功地应用到实践当中。 什么是IOC IOC理论提出的观点大体是这样的:借助于“第三方IOC”实现具有依赖关系的对象之间的解耦 通过这张图,我们可以看到,原来一个 A、B、C、D 互相依赖的关系,没有了耦合关系。齿轮之间的传动,完全依赖于 IOC 。 我们再来做个试验:把上图中间的IOC容器拿掉,然后再来看看这套系统: 去掉 IOC 后可以发现, A、B、C、D 没有耦合关系。这样的话,在你实现A的时候,更本不需要去考虑 B、C、D 了。对象之间的依赖关系已经降低到了最低的程度。所以,如果真能实现IOC容器,对于系统开发而言,这将是一件多么美好的事情,参与开发的每一成员只要实现自己的类就可以了。 为什么叫控制反转(IOC)? 没有用IOC之前 对象A依赖于对象B,那么对象A在初始化或者运行到某一点的时候,自己必须主动去创建对象B或者使用已经创建的对象B。无论是创建还是使用对象B,控制权都在A自己手上。 用了IOC之后 由于IOC容器的加入,对象A与对象B之间失去了直接联系。当对象A运行到需要对象B的时候,IOC容器会主动创建一个对象B注入到对象A需要的地方。 总结: 对象A获得依赖对象B的过程,由主动行为变为了被动行为,控制权颠倒过来了,这就是“控制反转”这个名称的由来。 所谓依赖注入,就是由IOC容器在运行期间,动态地将某种依赖关系注入到对象之中。 Tips IOC 也属于依赖注入(DI),不理解依赖注入概念的同学,请自行查阅资料学习。 软件案例剖析 传统程序设计都是主动去创建相关对象然后再组合起来如图, 当有了IoC/DI的容器后,在客户端类中不再主动去创建这些对象了,如图
为了更好的理解 IOC (inversion of controller) 本文先理解一下什么是依赖注入 (dependency injection ) 伪代码案例 什么是依赖? 从以下伪代码中,可以看到。PayBill 类中 payMyBill 方法的实现,需要先实例化 Alipay 类。所以这个时候依赖就产生了。 所以 ioc 的思想就是不要在 PayBill class 里面用 new 的方式去实例化解决依赖, 而且转为由外部来负责,简单一点就是内部没有 new 的这个步骤,通过依赖注入的方式同样的能获取到支付的实例。 <?php //支付宝支付 class Alipay { public function __construct(){} public function pay() { echo 'pay bill by alipay'; } } //微信支付 class Wechatpay { public function __construct(){} public function pay() { echo 'pay bill by wechatpay'; } } //支付账单类 class PayBill { private $payMethod; public function __construct() { $this->payMethod= new Alipay (); } // 获取支付账单 public function payMyBill() { $this->payMethod->pay(); } } // 获取支付宝账单 $pb = new PayBill (); $pb->payMyBill(); 什么是依赖注入? 跟之前的比较的话,我们加入一个 Pay 接口, 然后所有的支付方式都继承了这个接口并且实现了 pay 这个功能。 当我们实例化 PayBill class 的之前, 我们首先是实例化了一个 Alipay class,这个步骤就是生成了依赖了,然后我们需要把这个依赖注入到 PayBill class 的实例当中。 把 Alipay class 的实例通过 construct 注入的方式去实例化一个 PayBill class 在这里我们的注入是手动注入,不是自动的。而 Laravel 框架实现则是自动注入 <?php //支付类接口 interface Pay { public function pay(); } //支付宝支付 class Alipay implements Pay { public function __construct(){} public function pay() { echo 'pay bill by alipay'; } } //微信支付 class Wechatpay implements Pay { public function __construct(){} public function pay() { echo 'pay bill by wechatpay'; } } //付款 class PayBill { private $payMethod; public function __construct(Pay $payMethod) { $this->payMethod= $payMethod; } public function payMyBill() { $this->payMethod->pay(); } } //生成依赖 $payMethod = new Alipay(); //注入依赖 $pb = new PayBill( $payMethod ); $pb->payMyBill();
背景 该文章是基于**重做日志(redo log)**的内容补充。 mysql服务器宕机后,对于数据库的恢复,这个过程中也离不开**重做日志(redo log)**和 Checkpoint 的支持。 参考文献 Checkpoint思维导图:https://kdocs.cn/l/sc2IGPK1MWgD 前言 思考一下这个场景:如果重做日志可以无限地增大,同时缓冲池也足够大,那么是不需要将缓冲池中页的新版本刷新回磁盘。因为当发生宕机时,完全可以通过重做日志来恢复整个数据库系统中的数据到宕机发生的时刻。 但是这需要两个前提条件: 1、缓冲池可以缓存数据库中所有的数据; 2、重做日志可以无限增大 因此Checkpoint(检查点)技术就诞生了,目的是解决以下几个问题: 1、缩短数据库的恢复时间; 2、缓冲池不够用时,将脏页刷新到磁盘; 3、重做日志不可用时,刷新脏页。 当数据库发生宕机时,数据库不需要重做所有的日志,因为Checkpoint之前的页都已经刷新回磁盘。数据库只需对Checkpoint后的重做日志进行恢复,这样就大大缩短了恢复的时间。 当缓冲池不够用时,根据LRU算法会溢出最近最少使用的页,若此页为脏页,那么需要强制执行Checkpoint,将脏页也就是页的新版本刷回磁盘。 当重做日志出现不可用时,因为当前事务数据库系统对重做日志的设计都是循环使用的,并不是让其无限增大的,重做日志可以被重用的部分是指这些重做日志已经不再需要,当数据库发生宕机时,数据库恢复操作不需要这部分的重做日志,因此这部分就可以被覆盖重用。如果重做日志还需要使用,那么必须强制Checkpoint,将缓冲池中的页至少刷新到当前重做日志的位置。 innoDB - LNS 对于InnoDB存储引擎而言,是通过LSN(Log Sequence Number)来标记版本的。 LSN是8字节的数字,每个页有LSN,重做日志中也有LSN,Checkpoint也有LSN。 # 可以通过命令(show engine innodb status)来观察 mysql> show engine innodb status \G; --- LOG --- Log sequence number 34778380870 Log flushed up to 34778380870 Last checkpoint at 34778380870 0 pending log writes, 0 pending chkp writes 54020151 log i/o's done, 0.92 log i/o's/second 根据LSN,可以获取到几个有用的信息: 1.数据页的版本信息。 2.写入的日志总量,通过LSN开始号码和结束号码可以计算出写入的日志量。 3.可知道检查点的位置。 实际上还可以获得很多隐式的信息。 LSN不仅存在于redo log中,还存在于数据页中,在每个数据页的头部,有一个fil_page_lsn记录了当前页最终的LSN值是多少。通过数据页中的LSN值和redo log中的LSN值比较,如果页中的LSN值小于redo log中LSN值,则表示数据丢失了一部分,这时候可以通过redo log的记录来恢复到redo log中记录的LSN值时的状态。 其中: log sequence number就是当前的redo log(in buffer)中的lsn; log flushed up to是刷到redo log file on disk中的lsn; pages flushed up to是已经刷到磁盘数据页上的LSN; last checkpoint at是上一次检查点所在位置的LSN。 innodb执行修改语句演示LNS 1、首先修改内存中的数据页,并在数据页中记录LSN,暂且称之为data_in_buffer_lsn; 2、并且在修改数据页的同时(几乎是同时)向redo log in buffer中写入redo log,并记录下对应的LSN,暂且称之为redo_log_in_buffer_lsn; 3、写完buffer中的日志后,当触发了日志刷盘的几种规则时,会向redo log file on disk刷入redo重做日志,并在该文件中记下对应的LSN,暂且称之为redo_log_on_disk_lsn; 4、数据页不可能永远只停留在内存中,在某些情况下,会触发checkpoint来将内存中的脏页(数据脏页和日志脏页)刷到磁盘,所以会在本次checkpoint脏页刷盘结束时,在redo log中记录checkpoint的LSN位置,暂且称之为checkpoint_lsn。 5、要记录checkpoint所在位置很快,只需简单的设置一个标志即可,但是刷数据页并不一定很快,例如这一次checkpoint要刷入的数据页非常多。也就是说要刷入所有的数据页需要一定的时间来完成,中途刷入的每个数据页都会记下当前页所在的LSN,暂且称之为data_page_on_disk_lsn。 上图中,从上到下的横线分别代表:时间轴、buffer中数据页中记录的LSN(data_in_buffer_lsn)、磁盘中数据页中记录的LSN(data_page_on_disk_lsn)、buffer中重做日志记录的LSN(redo_log_in_buffer_lsn)、磁盘中重做日志文件中记录的LSN(redo_log_on_disk_lsn)以及检查点记录的LSN(checkpoint_lsn)。 假设在最初时(12:00:00)所有的日志页和数据页都完成了刷盘,也记录好了检查点的LSN,这时它们的LSN都是完全一致的。 假设此时开启了一个事务,并立刻执行了一个update操作,执行完成后,buffer中的数据页和redo log都记录好了更新后的LSN值,假设为110。 这时候如果执行 show engine innodb status 查看各LSN的值,即图中①处的位置状态,结果会是: log sequence number(110) > log flushed up to(100) = pages flushed up to = last checkpoint at 之后又执行了一个delete语句,LSN增长到150。等到12:00:01时,触发redo log刷盘的规则(其中有一个规则是 innodb_flush_log_at_timeout 控制的默认日志刷盘频率为1秒),这时redo log file on disk中的LSN会更新到和redo log in buffer的LSN一样,所以都等于150,这时 show engine innodb status ,即图中②的位置,结果将会是: log sequence number(150) = log flushed up to > pages flushed up to(100) = last checkpoint at 再之后,执行了一个update语句,缓存中的LSN将增长到300,即图中③的位置。 假设随后检查点出现,即图中④的位置,正如前面所说,检查点会触发数据页和日志页刷盘,但需要一定的时间来完成,所以在数据页刷盘还未完成时,检查点的LSN还是上一次检查点的LSN,但此时磁盘上数据页和日志页的LSN已经增长了,即: log sequence number > log flushed up to 和 pages flushed up to > last checkpoint at 但是log flushed up to和pages flushed up to的大小无法确定,因为日志刷盘可能快于数据刷盘,也可能等于,还可能是慢于。但是checkpoint机制有保护数据刷盘速度是慢于日志刷盘的:当数据刷盘速度超过日志刷盘时,将会暂时停止数据刷盘,等待日志刷盘进度超过数据刷盘。 等到数据页和日志页刷盘完毕,即到了位置⑤的时候,所有的LSN都等于300。 随着时间的推移到了12:00:02,即图中位置⑥,又触发了日志刷盘的规则,但此时buffer中的日志LSN和磁盘中的日志LSN是一致的,所以不执行日志刷盘,即此时 show engine innodb status 时各种lsn都相等。 随后执行了一个insert语句,假设buffer中的LSN增长到了800,即图中位置⑦。此时各种LSN的大小和位置①时一样。 随后执行了提交动作,即位置⑧。默认情况下,提交动作会触发日志刷盘,但不会触发数据刷盘,所以 show engine innodb status 的结果是: log sequence number = log flushed up to > pages flushed up to = last checkpoint at 最后随着时间的推移,检查点再次出现,即图中位置⑨。但是这次检查点不会触发日志刷盘,因为日志的LSN在检查点出现之前已经同步了。假设这次数据刷盘速度极快,快到一瞬间内完成而无法捕捉到状态的变化,这时 show engine innodb status 的结果将是各种LSN相等。 innodb的恢复行为 在启动innodb的时候,不管上次是正常关闭还是异常关闭,总是会进行恢复操作。 因为redo log记录的是数据页的物理变化,因此恢复的时候速度比逻辑日志(如二进制日志)要快很多。而且,innodb自身也做了一定程度的优化,让恢复速度变得更快。 重启innodb时,checkpoint表示已经完整刷到磁盘上data page上的LSN,因此恢复时仅需要恢复从checkpoint开始的日志部分。例如,当数据库在上一次checkpoint的LSN为10000时宕机,且事务是已经提交过的状态。启动数据库时会检查磁盘中数据页的LSN,如果数据页的LSN小于日志中的LSN,则会从检查点开始恢复。 还有一种情况,在宕机前正处于checkpoint的刷盘过程,且数据页的刷盘进度超过了日志页的刷盘进度。这时候一宕机,数据页中记录的LSN就会大于日志页中的LSN,在重启的恢复过程中会检查到这一情况,这时超出日志进度的部分将不会重做,因为这本身就表示已经做过的事情,无需再重做。 另外,事务日志具有幂等性,所以多次操作得到同一结果的行为在日志中只记录一次。而二进制日志不具有幂等性,多次操作会全部记录下来,在恢复的时候会多次执行二进制日志中的记录,速度就慢得多。例如,某记录中id初始值为2,通过update将值设置为了3,后来又设置成了2,在事务日志中记录的将是无变化的页,根本无需恢复;而二进制会记录下两次update操作,恢复时也将执行这两次update操作,速度比事务日志恢复更慢。 checkpoint图例讲解 如果通过以上 LNS 的理论讲解,若你还是没有看懂,没有关系,接下来,通过一个图例进行继续理解 静态检查点 现在有T1 、T2两个事务,则undolog中写入 这时到了检查点的周期,要往里写入检查点了,就得等到T1,T2全部提交完毕,然后写入检查点chkpoint。 也就是如果现在有一个T3要开启,是无法开启的。系统处于夯住状态。写入完后,开启T3,日志记录如下 这时候,如果系统挂掉了,故障恢复管理器会从undolog的尾部向前进行扫描,扫描到checkpoint后,就不会往前扫描了,因为前面的事务都已经提交过了,不存在数据一致性问题。所以只需要从checkpoint开始重做即可。 这样固然是好,省掉了需要undolog从头开始扫描的麻烦,但是这样做的缺点也很明显,那就是在写入checkpoint的过程中,系统是出于夯住状态的,所有的写入都要暂停。那能否有一种更好的方法既可以写入checkpoint又不需要系统暂停呢,必须的,当然有,这就是下面要讲的非静态检查点。 非静态检查点(重点) 非静态检查点是相对于静态检查点而来的,上文中所提到的就属于静态检查点,因为在检查点写入的同时,系统是不能写入的。而非静态检查点的引入,就是要解决这个问题。 非静态检查点的策略是在写入chkpoint的同时,会记录下当前活跃的事务。比如,当前状态下,T1和T2都是活跃状态,那么undolog中会被写入start checkpoint(T1,T2),这时整体系统仍然是正常写入的,也就是说在这条log写入后,仍然可以继续开启其他事务。当T1,T2完成后,会写入end checkpoint的记录。例如如下记录: 数据库宕机后Checkpoint定位恢复 第一种情况 数据库宕机后,恢复管理器仍然会从尾往前进行扫描undolog,如果遇到了“end chkpoint”,这时并不代表checkpoint前所有的事务都已经提交了,但我们可以知道,所有未提交的事务都是在上一个start checkpoint之后,所以会继续往前找,一直找到start checkpoint,找到start checkpoint后。 比如是start checkpoint(T1,T2),因为先前已经找到了end chkpoint,所以T1,T2这两个事务已经可以保证数据一致性了,需要重做的就是在start checpoint(T1,T2)到end chkpoint间的这一些非T1,T2事务,这些是需要重做的,所以要把这些进行重做。 另外一种情况 还有,就是恢复管理器在扫描时,先遇到了start checkpoint(T1,T2)的日志,在这种情况下,我们首先知道了T1,T2或许是未完成的事务,那这时需要在start checkpoint之后找到是否有某个事务的end语句,如果有,说明这个事务是完成了,如果没有,就说明没有完成,那就要从check point再往后寻找,找到这个事务的start,然后从start之后往后重做。说得比较罗嗦,我们上个例子来说明下这种情况。 例如,数据库宕机后,开始扫描undolog,得到以下片段: 这时,恢复管理器拿到这个片段后进行扫描,在遇到end chkpoint前遇到了start checkpoint(T1,T2),这说明了,T1,T2是可能未完成事务的,而且在这之前还遇到了T3的start,没有end T3,也没有任何T3的检查点的开始,这说明了T3一定是未完成事务的,所以T3一定是要重做的。 先前为什么说T1,T2是可能未完成事务的呢? 因为遇到了start checkpoint(T1,T2),没有遇到end chkpoint,并不代表T1和T2就一定是未完成的,可能有一个已经commit过了,因为两个都没有commit,所以才导致了没有end chkpoint,所以这时找start下面的日志,发现了“end T1”,说明了T1的事务是已经完成了的。 那只需要找T2的开启然后开始重做就可以了,然后就通过start checkpoint(T1,T2)再往上找,找到了start T2,然后开始重做T2,也就是这个日志里,T2和T3是需要重做的,然后重做掉。 Tips: 刚才先说了做T3,然后有说了重做T2,并不代表真正的顺序就是这样,实际上恢复管理器是先分析出需要重做的事务,然后通过buf一块做掉的。
前言 作为一名开发人员我们经常会听到HTTP协议、TCP/IP协议、UDP协议、Socket、Socket长连接、Socket连接池等字眼,然而它们之间的关系、区别及原理并不是所有人都能理解清楚,这篇文章就从网络协议基础开始到Socket连接池,一步一步解释他们之间的关系。 参考文献 网络大致基本框架:https://blog.csdn.net/xuzhangze/article/details/80778775 七层网络模型 首先从网络通信的分层模型讲起:七层模型,亦称OSI(Open System Interconnection)模型。自下往上分为:物理层、数据链路层、网络层、传输层、会话层、表示层和应用层。所有有关通信的都离不开它,下面这张图片介绍了各层所对应的一些协议和硬件 通过上图,我知道IP协议对应于网络层,TCP、UDP协议对应于传输层,而HTTP协议对应于应用层,OSI并没有Socket,那什么是Socket,后面我们将结合代码具体详细介绍。 TCP和UDP连接 关于传输层TCP、UDP协议可能我们平时遇见的会比较多,有人说TCP是安全的,UDP是不安全的,UDP传输比TCP快,那为什么呢,我们先从TCP的连接建立的过程开始分析,然后解释UDP和TCP的区别。 TCP的三次握手和四次分手 我们知道TCP建立连接需要经过三次握手,而断开连接需要经过四次分手,那三次握手和四次分手分别做了什么和如何进行的。 第一次握手:建立连接。客户端发送连接请求报文段,将SYN位置为1,Sequence Number为x;然后,客户端进入SYN_SEND状态,等待服务器的确认; 第二次握手:服务器收到客户端的SYN报文段,需要对这个SYN报文段进行确认,设置Acknowledgment Number为x+1(Sequence Number+1);同时,自己自己还要发送SYN请求信息,将SYN位置为1,Sequence Number为y;服务器端将上述所有信息放到一个报文段(即SYN+ACK报文段)中,一并发送给客户端,此时服务器进入SYN_RECV状态; 第三次握手:客户端收到服务器的SYN+ACK报文段。然后将Acknowledgment Number设置为y+1,向服务器发送ACK报文段,这个报文段发送完毕以后,客户端和服务器端都进入ESTABLISHED状态,完成TCP三次握手。 完成了三次握手,客户端和服务器端就可以开始传送数据。以上就是TCP三次握手的总体介绍。通信结束客户端和服务端就断开连接,需要经过四次分手确认。 第一次分手:主机1(可以使客户端,也可以是服务器端),设置Sequence Number和Acknowledgment Number,向主机2发送一个FIN报文段;此时,主机1进入FIN_WAIT_1状态;这表示主机1没有数据要发送给主机2了; 第二次分手:主机2收到了主机1发送的FIN报文段,向主机1回一个ACK报文段,Acknowledgment Number为Sequence Number加1;主机1进入FIN_WAIT_2状态;主机2告诉主机1,我“同意”你的关闭请求; 第三次分手:主机2向主机1发送FIN报文段,请求关闭连接,同时主机2进入LAST_ACK状态; 第四次分手:主机1收到主机2发送的FIN报文段,向主机2发送ACK报文段,然后主机1进入TIME_WAIT状态;主机2收到主机1的ACK报文段以后,就关闭连接;此时,主机1等待2MSL后依然没有收到回复,则证明Server端已正常关闭,那好,主机1也可以关闭连接了。 可以看到一次tcp请求的建立及关闭至少进行7次通信,这还不包过数据的通信,而UDP不需3次握手和4次分手。 TCP和UDP的区别 1、TCP是面向链接的,虽然说网络的不安全不稳定特性决定了多少次握手都不能保证连接的可靠性,但TCP的三次握手在最低限度上(实际上也很大程度上保证了)保证了连接的可靠性;而UDP不是面向连接的,UDP传送数据前并不与对方建立连接,对接收到的数据也不发送确认信号,发送端不知道数据是否会正确接收,当然也不用重发,所以说UDP是无连接的、不可靠的一种数据传输协议。 2、也正由于1所说的特点,使得UDP的开销更小数据传输速率更高,因为不必进行收发数据的确认,所以UDP的实时性更好。知道了TCP和UDP的区别,就不难理解为何采用TCP传输协议的MSN比采用UDP的QQ传输文件慢了,但并不能说QQ的通信是不安全的,因为程序员可以手动对UDP的数据收发进行验证,比如发送方对每个数据包进行编号然后由接收方进行验证啊什么的,即使是这样,UDP因为在底层协议的封装上没有采用类似TCP的“三次握手”而实现了TCP所无法达到的传输效率。 问题 关于传输层我们会经常听到一些问题 1.TCP服务器最大并发连接数是多少? 关于TCP服务器最大并发连接数有一种误解就是“因为端口号上限为65535,所以TCP服务器理论上的可承载的最大并发连接数也是65535”。首先需要理解一条TCP连接的组成部分**:客户端IP、客户端端口、服务端IP、服务端端口**。所以对于TCP服务端进程来说,他可以同时连接的客户端数量并不受限于可用端口号,理论上一个服务器的一个端口能建立的连接数是全球的IP数*每台机器的端口数。实际并发连接数受限于linux可打开文件数,这个数是可以配置的,可以非常大,所以实际上受限于系统性能。通过#ulimit -n 查看服务的最大文件句柄数,通过ulimit -n xxx 修改 xxx是你想要能打开的数量。也可以通过修改系统参数: #vi /etc/security/limits.conf * soft nofile 65536 * hard nofile 65536 2.为什么TIME_WAIT状态还需要等2MSL后才能返回到CLOSED状态? 这是因为虽然双方都同意关闭连接了,而且握手的4个报文也都协调和发送完毕,按理可以直接回到CLOSED状态(就好比从SYN_SEND状态到ESTABLISH状态那样);但是因为我们必须要假想网络是不可靠的,你无法保证你最后发送的ACK报文会一定被对方收到,因此对方处于LAST_ACK状态下的Socket可能会因为超时未收到ACK报文,而重发FIN报文,所以这个TIME_WAIT状态的作用就是用来重发可能丢失的ACK报文。 3.TIME_WAIT状态还需要等2MSL后才能返回到CLOSED状态会产生什么问题 通信双方建立TCP连接后,主动关闭连接的一方就会进入TIME_WAIT状态,TIME_WAIT状态维持时间是两个MSL时间长度,也就是在1-4分钟,Windows操作系统就是4分钟。进入TIME_WAIT状态的一般情况下是客户端,一个TIME_WAIT状态的连接就占用了一个本地端口。一台机器上端口号数量的上限是65536个,如果在同一台机器上进行压力测试模拟上万的客户请求,并且循环与服务端进行短连接通信,那么这台机器将产生4000个左右的TIME_WAIT Socket,后续的短连接就会产生address already in use : connect的异常,如果使用Nginx作为方向代理也需要考虑TIME_WAIT状态,发现系统存在大量TIME_WAIT状态的连接,通过调整内核参数解决。 vi /etc/sysctl.conf 编辑文件,加入以下内容: net.ipv4.tcp_syncookies = 1 net.ipv4.tcp_tw_reuse = 1 net.ipv4.tcp_tw_recycle = 1 net.ipv4.tcp_fin_timeout = 30 然后执行 /sbin/sysctl -p 让参数生效。 net.ipv4.tcp_syncookies = 1 表示开启SYN Cookies。当出现SYN等待队列溢出时,启用cookies来处理,可防范少量SYN攻击,默认为0,表示关闭; net.ipv4.tcp_tw_reuse = 1 表示开启重用。允许将TIME-WAIT sockets重新用于新的TCP连接,默认为0,表示关闭; net.ipv4.tcp_tw_recycle = 1 表示开启TCP连接中TIME-WAIT sockets的快速回收,默认为0,表示关闭。 net.ipv4.tcp_fin_timeout 修改系統默认的TIMEOUT时间 HTTP协议 关于TCP/IP和HTTP协议的关系,网络有一段比较容易理解的介绍:“我们在传输数据时,可以只使用(传输层)TCP/IP协议,但是那样的话,如果没有应用层,便无法识别数据内容。如果想要使传输的数据有意义,则必须使用到应用层协议。应用层协议有很多,比如HTTP、FTP、TELNET等,也可以自己定义应用层协议。 HTTP协议即超文本传送协议(Hypertext Transfer Protocol ),是Web联网的基础,也是手机联网常用的协议之一,WEB使用HTTP协议作应用层协议,以封装HTTP文本信息,然后使用TCP/IP做传输层协议将它发到网络上。 由于HTTP在每次请求结束后都会主动释放连接,因此HTTP连接是一种“短连接”,要保持客户端程序的在线状态,需要不断地向服务器发起连接请求。通常 的做法是即时不需要获得任何数据,客户端也保持每隔一段固定的时间向服务器发送一次“保持连接”的请求,服务器在收到该请求后对客户端进行回复,表明知道 客户端“在线”。若服务器长时间无法收到客户端的请求,则认为客户端“下线”,若客户端长时间无法收到服务器的回复,则认为网络已经断开。 下面是一个简单的HTTP Post application/json数据内容的请求: POST HTTP/1.1 Host: 127.0.0.1:9017 Content-Type: application/json Cache-Control: no-cache {"a":"a"} 关于Socket(套接字) 现在我们了解到TCP/IP只是一个协议栈,就像操作系统的运行机制一样,必须要具体实现,同时还要提供对外的操作接口。就像操作系统会提供标准的编程接口,比如Win32编程接口一样,TCP/IP也必须对外提供编程接口,这就是Socket。现在我们知道,Socket跟TCP/IP并没有必然的联系。Socket编程接口在设计的时候,就希望也能适应其他的网络协议。所以,Socket的出现只是可以更方便的使用TCP/IP协议栈而已,其对TCP/IP进行了抽象,形成了几个最基本的函数接口。比如create,listen,accept,connect,read和write等等。 不同语言都有对应的建立Socket服务端和客户端的库,下面举例Nodejs如何创建服务端和客户端: 服务端: const net = require('net'); const server = net.createServer(); server.on('connection', (client) => { client.write('Hi!\n'); // 服务端向客户端输出信息,使用 write() 方法 client.write('Bye!\n'); //client.end(); // 服务端结束该次会话 }); server.listen(9000); 服务监听9000端口 下面使用命令行发送http请求和telnet $ curl http://127.0.0.1:9000 Bye! $telnet 127.0.0.1 9000 Trying 192.168.1.21... Connected to 192.168.1.21. Escape character is '^]'. Hi! Bye! Connection closed by foreign host. 注意到curl只处理了一次报文。 客户端 const client = new net.Socket(); client.connect(9000, '127.0.0.1', function () { }); client.on('data', (chunk) => { console.log('data', chunk.toString()) //data Hi! //Bye! }); Socket长连接 所谓长连接,指在一个TCP连接上可以连续发送多个数据包,在TCP连接保持期间,如果没有数据包发送,需要双方发检测包以维持此连接(心跳包),一般需要自己做在线维持。 短连接是指通信双方有数据交互时,就建立一个TCP连接,数据发送完成后,则断开此TCP连接。比如Http的,只是连接、请求、关闭,过程时间较短,服务器若是一段时间内没有收到请求即可关闭连接。其实长连接是相对于通常的短连接而说的,也就是长时间保持客户端与服务端的连接状态。 通常的短连接操作步骤是: 连接→数据传输→关闭连接; 而长连接通常就是: 连接→数据传输→保持连接(心跳)→数据传输→保持连接(心跳)→……→关闭连接; 什么时候用长连接,短连接? 长连接多用于操作频繁,点对点的通讯,而且连接数不能太多情况,。每个TCP连接都需要三步握手,这需要时间,如果每个操作都是先连接,再操作的话那么处理 速度会降低很多,所以每个操作完后都不断开,次处理时直接发送数据包就OK了,不用建立TCP连接。例如:数据库的连接用长连接, 如果用短连接频繁的通信会造成Socket错误,而且频繁的Socket创建也是对资源的浪费。 什么是心跳包为什么需要: 心跳包就是在客户端和服务端间定时通知对方自己状态的一个自己定义的命令字,按照一定的时间间隔发送,类似于心跳,所以叫做心跳包。网络中的接收和发送数据都是使用Socket进行实现。但是如果此套接字已经断开(比如一方断网了),那发送数据和接收数据的时候就一定会有问题。可是如何判断这个套接字是否还可以使用呢?这个就需要在系统中创建心跳机制。其实TCP中已经为我们实现了一个叫做心跳的机制。如果你设置了心跳,那TCP就会在一定的时间(比如你设置的是3秒钟)内发送你设置的次数的心跳(比如说2次),并且此信息不会影响你自己定义的协议。也可以自己定义,所谓“心跳”就是定时发送一个自定义的结构体(心跳包或心跳帧),让对方知道自己“在线”,以确保链接的有效性。 实现: 服务端: const net = require('net'); let clientList = []; const heartbeat = 'HEARTBEAT'; // 定义心跳包内容确保和平时发送的数据不会冲突 const server = net.createServer(); server.on('connection', (client) => { console.log('客户端建立连接:', client.remoteAddress + ':' + client.remotePort); clientList.push(client); client.on('data', (chunk) => { let content = chunk.toString(); if (content === heartbeat) { console.log('收到客户端发过来的一个心跳包'); } else { console.log('收到客户端发过来的数据:', content); client.write('服务端的数据:' + content); } }); client.on('end', () => { console.log('收到客户端end'); clientList.splice(clientList.indexOf(client), 1); }); client.on('error', () => { clientList.splice(clientList.indexOf(client), 1); }) }); server.listen(9000); setInterval(broadcast, 10000); // 定时发送心跳包 function broadcast() { console.log('broadcast heartbeat', clientList.length); let cleanup = [] for (let i=0;i<clientList.length;i+=1) { if (clientList[i].writable) { // 先检查 sockets 是否可写 clientList[i].write(heartbeat); } else { console.log('一个无效的客户端'); cleanup.push(clientList[i]); // 如果不可写,收集起来销毁。销毁之前要 Socket.destroy() 用 API 的方法销毁。 clientList[i].destroy(); } } //Remove dead Nodes out of write loop to avoid trashing loop index for (let i=0; i<cleanup.length; i+=1) { console.log('删除无效的客户端:', cleanup[i].name); clientList.splice(clientList.indexOf(cleanup[i]), 1); } } 服务端输出结果: 客户端建立连接: ::ffff:127.0.0.1:57125 broadcast heartbeat 1 收到客户端发过来的数据: Thu, 29 Mar 2018 03:45:15 GMT 收到客户端发过来的一个心跳包 收到客户端发过来的数据: Thu, 29 Mar 2018 03:45:20 GMT broadcast heartbeat 1 收到客户端发过来的数据: Thu, 29 Mar 2018 03:45:25 GMT 收到客户端发过来的一个心跳包 客户端建立连接: ::ffff:127.0.0.1:57129 收到客户端发过来的一个心跳包 收到客户端发过来的数据: Thu, 29 Mar 2018 03:46:00 GMT 收到客户端发过来的数据: Thu, 29 Mar 2018 03:46:04 GMT broadcast heartbeat 2 收到客户端发过来的数据: Thu, 29 Mar 2018 03:46:05 GMT 收到客户端发过来的一个心跳包 客户端代码: const net = require('net'); const heartbeat = 'HEARTBEAT'; const client = new net.Socket(); client.connect(9000, '127.0.0.1', () => {}); client.on('data', (chunk) => { let content = chunk.toString(); if (content === heartbeat) { console.log('收到心跳包:', content); } else { console.log('收到数据:', content); } }); // 定时发送数据 setInterval(() => { console.log('发送数据', new Date().toUTCString()); client.write(new Date().toUTCString()); }, 5000); // 定时发送心跳包 setInterval(function () { client.write(heartbeat); }, 10000); 客户端输出结果: 发送数据 Thu, 29 Mar 2018 03:46:04 GMT 收到数据: 服务端的数据:Thu, 29 Mar 2018 03:46:04 GMT 收到心跳包: HEARTBEAT 发送数据 Thu, 29 Mar 2018 03:46:09 GMT 收到数据: 服务端的数据:Thu, 29 Mar 2018 03:46:09 GMT 发送数据 Thu, 29 Mar 2018 03:46:14 GMT 收到数据: 服务端的数据:Thu, 29 Mar 2018 03:46:14 GMT 收到心跳包: HEARTBEAT 发送数据 Thu, 29 Mar 2018 03:46:19 GMT 收到数据: 服务端的数据:Thu, 29 Mar 2018 03:46:19 GMT 发送数据 Thu, 29 Mar 2018 03:46:24 GMT 收到数据: 服务端的数据:Thu, 29 Mar 2018 03:46:24 GMT 收到心跳包: HEARTBEAT 定义自己的协议 如果想要使传输的数据有意义,则必须使用到应用层协议比如Http、Mqtt、Dubbo等。基于TCP协议上自定义自己的应用层的协议需要解决的几个问题: 心跳包格式的定义及处理 报文头的定义,就是你发送数据的时候需要先发送报文头,报文里面能解析出你将要发送的数据长度 你发送数据包的格式,是json的还是其他序列化的方式 下面我们就一起来定义自己的协议,并编写服务的和客户端进行调用: 定义报文头格式: length:000000000xxxx; xxxx代表数据的长度,总长度20,举例子不严谨。 数据表的格式: Json 服务端: const net = require('net'); const server = net.createServer(); let clientList = []; const heartBeat = 'HeartBeat'; // 定义心跳包内容确保和平时发送的数据不会冲突 const getHeader = (num) => { return 'length:' + (Array(13).join(0) + num).slice(-13); } server.on('connection', (client) => { client.name = client.remoteAddress + ':' + client.remotePort // client.write('Hi ' + client.name + '!\n'); console.log('客户端建立连接', client.name); clientList.push(client) let chunks = []; let length = 0; client.on('data', (chunk) => { let content = chunk.toString(); console.log("content:", content, content.length); if (content === heartBeat) { console.log('收到客户端发过来的一个心跳包'); } else { if (content.indexOf('length:') === 0){ length = parseInt(content.substring(7,20)); console.log('length', length); chunks =[chunk.slice(20, chunk.length)]; } else { chunks.push(chunk); } let heap = Buffer.concat(chunks); console.log('heap.length', heap.length) if (heap.length >= length) { try { console.log('收到数据', JSON.parse(heap.toString())); let data = '服务端的数据数据:' + heap.toString();; let dataBuff = Buffer.from(JSON.stringify(data)); let header = getHeader(dataBuff.length) client.write(header); client.write(dataBuff); } catch (err) { console.log('数据解析失败'); } } } }) client.on('end', () => { console.log('收到客户端end'); clientList.splice(clientList.indexOf(client), 1); }); client.on('error', () => { clientList.splice(clientList.indexOf(client), 1); }) }); server.listen(9000); setInterval(broadcast, 10000); // 定时检查客户端 并发送心跳包 function broadcast() { console.log('broadcast heartbeat', clientList.length); let cleanup = [] for(var i=0;i<clientList.length;i+=1) { if(clientList[i].writable) { // 先检查 sockets 是否可写 // clientList[i].write(heartBeat); // 发送心跳数据 } else { console.log('一个无效的客户端') cleanup.push(clientList[i]) // 如果不可写,收集起来销毁。销毁之前要 Socket.destroy() 用 API 的方法销毁。 clientList[i].destroy(); } } // 删除无效的客户端 for(i=0; i<cleanup.length; i+=1) { console.log('删除无效的客户端:', cleanup[i].name); clientList.splice(clientList.indexOf(cleanup[i]), 1) } } 日志打印: 客户端建立连接 ::ffff:127.0.0.1:50178 content: length:0000000000031 20 length 31 heap.length 0 content: "Tue, 03 Apr 2018 06:12:37 GMT" 31 heap.length 31 收到数据 Tue, 03 Apr 2018 06:12:37 GMT broadcast heartbeat 1 content: HeartBeat 9 收到客户端发过来的一个心跳包 content: length:0000000000031"Tue, 03 Apr 2018 06:12:42 GMT" 51 length 31 heap.length 31 收到数据 Tue, 03 Apr 2018 06:12:42 GMT 客户端 const net = require('net'); const client = new net.Socket(); const heartBeat = 'HeartBeat'; // 定义心跳包内容确保和平时发送的数据不会冲突 const getHeader = (num) => { return 'length:' + (Array(13).join(0) + num).slice(-13); } client.connect(9000, '127.0.0.1', function () {}); let chunks = []; let length = 0; client.on('data', (chunk) => { let content = chunk.toString(); console.log("content:", content, content.length); if (content === heartBeat) { console.log('收到服务端发过来的一个心跳包'); } else { if (content.indexOf('length:') === 0){ length = parseInt(content.substring(7,20)); console.log('length', length); chunks =[chunk.slice(20, chunk.length)]; } else { chunks.push(chunk); } let heap = Buffer.concat(chunks); console.log('heap.length', heap.length) if (heap.length >= length) { try { console.log('收到数据', JSON.parse(heap.toString())); } catch (err) { console.log('数据解析失败'); } } } }); // 定时发送数据 setInterval(function () { let data = new Date().toUTCString(); let dataBuff = Buffer.from(JSON.stringify(data)); let header =getHeader(dataBuff.length); client.write(header); client.write(dataBuff); }, 5000); // 定时发送心跳包 setInterval(function () { client.write(heartBeat); }, 10000); 日志打印: content: length:0000000000060 20 length 60 heap.length 0 content: "服务端的数据数据:\"Tue, 03 Apr 2018 06:12:37 GMT\"" 44 heap.length 60 收到数据 服务端的数据数据:"Tue, 03 Apr 2018 06:12:37 GMT" content: length:0000000000060"服务端的数据数据:\"Tue, 03 Apr 2018 06:12:42 GMT\"" 64 length 60 heap.length 60 收到数据 服务端的数据数据:"Tue, 03 Apr 2018 06:12:42 GMT" 客户端定时发送自定义协议数据到服务端,先发送头数据,在发送内容数据,另外一个定时器发送心跳数据,服务端判断是心跳数据,再判断是不是头数据,再是内容数据,然后解析后再发送数据给客户端。从日志的打印可以看出客户端先后write header和data数据,服务端可能在一个data事件里面接收到。 这里可以看到一个客户端在同一个时间内处理一个请求可以很好的工作,但是想象这么一个场景,如果同一时间内让同一个客户端去多次调用服务端请求,发送多次头数据和内容数据,服务端的data事件收到的数据就很难区别哪些数据是哪次请求的,比如两次头数据同时到达服务端,服务端就会忽略其中一次,而后面的内容数据也不一定就对应于这个头的。所以想复用长连接并能很好的高并发处理服务端请求,就需要连接池这种方式了。 Socket连接池 什么是Socket连接池,池的概念可以联想到是一种资源的集合,所以Socket连接池,就是维护着一定数量Socket长连接的集合。它能自动检测Socket长连接的有效性,剔除无效的连接,补充连接池的长连接的数量。从代码层次上其实是人为实现这种功能的类,一般一个连接池包含下面几个属性: 空闲可使用的长连接队列 正在运行的通信的长连接队列 等待去获取一个空闲长连接的请求的队列 无效长连接的剔除功能 长连接资源池的数量配置 长连接资源的新建功能 场景: 一个请求过来,首先去资源池要求获取一个长连接资源,如果空闲队列里面有长连接,就获取到这个长连接Socket,并把这个Socket移到正在运行的长连接队列。如果空闲队列里面没有,且正在运行的队列长度小于配置的连接池资源的数量,就新建一个长连接到正在运行的队列去,如果正在运行的不下于配置的资源池长度,则这个请求进入到等待队列去。当一个正在运行的Socket完成了请求,就从正在运行的队列移到空闲的队列,并触发等待请求队列去获取空闲资源,如果有等待的情况。 这里简单介绍Nodejs的Socket连接池generic-pool模块的源码。 主要文件目录结构 . |————lib ------------------------- 代码库 | |————DefaultEvictor.js ---------- | |————Deferred.js ---------------- | |————Deque.js ------------------- | |————DequeIterator.js ----------- | |————DoublyLinkedList.js -------- | |————DoublyLinkedListIterator.js- | |————factoryValidator.js -------- | |————Pool.js -------------------- 连接池主要代码 | |————PoolDefaults.js ------------ | |————PooledResource.js ---------- | |————Queue.js ------------------- 队列 | |————ResourceLoan.js ------------ | |————ResourceRequest.js --------- | |————utils.js ------------------- 工具 |————test ------------------------- 测试目录 |————README.md ------------------- 项目描述文件 |————.eslintrc ------------------- eslint静态检查配置文件 |————.eslintignore --------------- eslint静态检查忽略的文件 |————package.json ----------------- npm包依赖配置 下面介绍库的使用: 初始化连接池 'use strict'; const net = require('net'); const genericPool = require('generic-pool'); function createPool(conifg) { let options = Object.assign({ fifo: true, // 是否优先使用老的资源 priorityRange: 1, // 优先级 testOnBorrow: true, // 是否开启获取验证 // acquireTimeoutMillis: 10 * 1000, // 获取的超时时间 autostart: true, // 自动初始化和释放调度启用 min: 10, // 初始化连接池保持的长连接最小数量 max: 0, // 最大连接池保持的长连接数量 evictionRunIntervalMillis: 0, // 资源释放检验间隔检查 设置了下面几个参数才起效果 numTestsPerEvictionRun: 3, // 每次释放资源数量 softIdleTimeoutMillis: -1, // 可用的超过了最小的min 且空闲时间时间 达到释放 idleTimeoutMillis: 30000 // 强制释放 // maxWaitingClients: 50 // 最大等待 }, conifg.options); const factory = { create: function () { return new Promise((resolve, reject) => { let socket = new net.Socket(); socket.setKeepAlive(true); socket.connect(conifg.port, conifg.host); // TODO 心跳包的处理逻辑 socket.on('connect', () => { console.log('socket_pool', conifg.host, conifg.port, 'connect' ); resolve(socket); }); socket.on('close', (err) => { // 先end 事件再close事件 console.log('socket_pool', conifg.host, conifg.port, 'close', err); }); socket.on('error', (err) => { console.log('socket_pool', conifg.host, conifg.port, 'error', err); reject(err); }); }); }, //销毁连接 destroy: function (socket) { return new Promise((resolve) => { socket.destroy(); // 不会触发end 事件 第一次会触发发close事件 如果有message会触发error事件 resolve(); }); }, validate: function (socket) { //获取资源池校验资源有效性 return new Promise((resolve) => { // console.log('socket.destroyed:', socket.destroyed, 'socket.readable:', socket.readable, 'socket.writable:', socket.writable); if (socket.destroyed || !socket.readable || !socket.writable) { return resolve(false); } else { return resolve(true); } }); } }; const pool = genericPool.createPool(factory, options); pool.on('factoryCreateError', (err) => { // 监听新建长连接出错 让请求直接返回错误 const clientResourceRequest = pool._waitingClientsQueue.dequeue(); if (clientResourceRequest) { clientResourceRequest.reject(err); } }); return pool; }; let pool = createPool({ port: 9000, host: '127.0.0.1', options: {min: 0, max: 10} }); 使用连接池 下面连接池的使用,使用的协议是我们之前自定义的协议。 let pool = createPool({ port: 9000, host: '127.0.0.1', options: {min: 0, max: 10} }); const getHeader = (num) => { return 'length:' + (Array(13).join(0) + num).slice(-13); } const request = async (requestDataBuff) => { let client; try { client = await pool.acquire(); } catch (e) { console.log('acquire socket client failed: ', e); throw e; } let timeout = 10000; return new Promise((resolve, reject) => { let chunks = []; let length = 0; client.setTimeout(timeout); client.removeAllListeners('error'); client.on('error', (err) => { client.removeAllListeners('error'); client.removeAllListeners('data'); client.removeAllListeners('timeout'); pool.destroyed(client); reject(err); }); client.on('timeout', () => { client.removeAllListeners('error'); client.removeAllListeners('data'); client.removeAllListeners('timeout'); // 应该销毁以防下一个req的data事件监听才返回数据 pool.destroy(client); // pool.release(client); reject(`socket connect timeout set ${timeout}`); }); let header = getHeader(requestDataBuff.length); client.write(header); client.write(requestDataBuff); client.on('data', (chunk) => { let content = chunk.toString(); console.log('content', content, content.length); // TODO 过滤心跳包 if (content.indexOf('length:') === 0){ length = parseInt(content.substring(7,20)); console.log('length', length); chunks =[chunk.slice(20, chunk.length)]; } else { chunks.push(chunk); } let heap = Buffer.concat(chunks); console.log('heap.length', heap.length); if (heap.length >= length) { pool.release(client); client.removeAllListeners('error'); client.removeAllListeners('data'); client.removeAllListeners('timeout'); try { // console.log('收到数据', JSON.parse(heap.toString())); resolve(JSON.parse(heap.toString())); } catch (err) { reject(err); console.log('数据解析失败'); } } }); }); } request(Buffer.from(JSON.stringify({a: 'a'}))) .then((data) => { console.log('收到服务的数据',data) }).catch(err => { console.log(err); }); request(Buffer.from(JSON.stringify({b: 'b'}))) .then((data) => { console.log('收到服务的数据',data) }).catch(err => { console.log(err); }); setTimeout(function () { //查看是否会复用Socket 有没有建立新的连接 request(Buffer.from(JSON.stringify({c: 'c'}))) .then((data) => { console.log('收到服务的数据',data) }).catch(err => { console.log(err); }); request(Buffer.from(JSON.stringify({d: 'd'}))) .then((data) => { console.log('收到服务的数据',data) }).catch(err => { console.log(err); }); }, 1000) 日志打印: socket_pool 127.0.0.1 9000 connect socket_pool 127.0.0.1 9000 connect content length:0000000000040"服务端的数据数据:{\"a\":\"a\"}" 44 length 40 heap.length 40 收到服务的数据 服务端的数据数据:{"a":"a"} content length:0000000000040"服务端的数据数据:{\"b\":\"b\"}" 44 length 40 heap.length 40 收到服务的数据 服务端的数据数据:{"b":"b"} content length:0000000000040 20 length 40 heap.length 0 content "服务端的数据数据:{\"c\":\"c\"}" 24 heap.length 40 收到服务的数据 服务端的数据数据:{"c":"c"} content length:0000000000040"服务端的数据数据:{\"d\":\"d\"}" 44 length 40 heap.length 40 收到服务的数据 服务端的数据数据:{"d":"d"} 这里看到前面两个请求都建立了新的Socket连接 socket_pool 127.0.0.1 9000 connect,定时器结束后重新发起两个请求就没有建立新的Socket连接了,直接从连接池里面获取Socket连接资源。 源码分析 发现主要的代码就位于lib文件夹中的Pool.js 构造函数: lib/Pool.js /** * Generate an Object pool with a specified `factory` and `config`. * * @param {typeof DefaultEvictor} Evictor * @param {typeof Deque} Deque * @param {typeof PriorityQueue} PriorityQueue * @param {Object} factory * Factory to be used for generating and destroying the items. * @param {Function} factory.create * Should create the item to be acquired, * and call it's first callback argument with the generated item as it's argument. * @param {Function} factory.destroy * Should gently close any resources that the item is using. * Called before the items is destroyed. * @param {Function} factory.validate * Test if a resource is still valid .Should return a promise that resolves to a boolean, true if resource is still valid and false * If it should be removed from pool. * @param {Object} options */ constructor(Evictor, Deque, PriorityQueue, factory, options) { super(); factoryValidator(factory); // 检验我们定义的factory的有效性包含create destroy validate this._config = new PoolOptions(options); // 连接池配置 // TODO: fix up this ugly glue-ing this._Promise = this._config.Promise; this._factory = factory; this._draining = false; this._started = false; /** * Holds waiting clients * @type {PriorityQueue} */ this._waitingClientsQueue = new PriorityQueue(this._config.priorityRange); // 请求的对象管管理队列queue 初始化queue的size 1 { _size: 1, _slots: [ Queue { _list: [Object] } ] } /** * Collection of promises for resource creation calls made by the pool to factory.create * @type {Set} */ this._factoryCreateOperations = new Set(); // 正在创建的长连接 /** * Collection of promises for resource destruction calls made by the pool to factory.destroy * @type {Set} */ this._factoryDestroyOperations = new Set(); // 正在销毁的长连接 /** * A queue/stack of pooledResources awaiting acquisition * TODO: replace with LinkedList backed array * @type {Deque} */ this._availableObjects = new Deque(); // 空闲的资源长连接 /** * Collection of references for any resource that are undergoing validation before being acquired * @type {Set} */ this._testOnBorrowResources = new Set(); // 正在检验有效性的资源 /** * Collection of references for any resource that are undergoing validation before being returned * @type {Set} */ this._testOnReturnResources = new Set(); /** * Collection of promises for any validations currently in process * @type {Set} */ this._validationOperations = new Set();// 正在校验的中间temp /** * All objects associated with this pool in any state (except destroyed) * @type {Set} */ this._allObjects = new Set(); // 所有的链接资源 是一个 PooledResource对象 /** * Loans keyed by the borrowed resource * @type {Map} */ this._resourceLoans = new Map(); // 被借用的对象的map release的时候用到 /** * Infinitely looping iterator over available object * @type {DequeIterator} */ this._evictionIterator = this._availableObjects.iterator(); // 一个迭代器 this._evictor = new Evictor(); /** * handle for setTimeout for next eviction run * @type {(number|null)} */ this._scheduledEviction = null; // create initial resources (if factory.min > 0) if (this._config.autostart === true) { // 初始化最小的连接数量 this.start(); } } 可以看到包含之前说的空闲的资源队列,正在请求的资源队列,正在等待的请求队列等。 下面查看 Pool.acquire 方法 lib/Pool.js /** * Request a new resource. The callback will be called, * when a new resource is available, passing the resource to the callback. * TODO: should we add a seperate "acquireWithPriority" function * * @param {Number} [priority=0] * Optional. Integer between 0 and (priorityRange - 1). Specifies the priority * of the caller if there are no available resources. Lower numbers mean higher * priority. * * @returns {Promise} */ acquire(priority) { // 空闲资源队列资源是有优先等级的 if (this._started === false && this._config.autostart === false) { this.start(); // 会在this._allObjects 添加min的连接对象数 } if (this._draining) { // 如果是在资源释放阶段就不能再请求资源了 return this._Promise.reject( new Error("pool is draining and cannot accept work") ); } // 如果要设置了等待队列的长度且要等待 如果超过了就返回资源不可获取 // TODO: should we defer this check till after this event loop incase "the situation" changes in the meantime if ( this._config.maxWaitingClients !== undefined && this._waitingClientsQueue.length >= this._config.maxWaitingClients ) { return this._Promise.reject( new Error("max waitingClients count exceeded") ); } const resourceRequest = new ResourceRequest( this._config.acquireTimeoutMillis, // 对象里面的超时配置 表示等待时间 会启动一个定时 超时了就触发resourceRequest.promise 的reject触发 this._Promise ); // console.log(resourceRequest) this._waitingClientsQueue.enqueue(resourceRequest, priority); // 请求进入等待请求队列 this._dispense(); // 进行资源分发 最终会触发resourceRequest.promise的resolve(client) return resourceRequest.promise; // 返回的是一个promise对象resolve却是在其他地方触发 } /** * Attempt to resolve an outstanding resource request using an available resource from * the pool, or creating new ones * * @private */ _dispense() { /** * Local variables for ease of reading/writing * these don't (shouldn't) change across the execution of this fn */ const numWaitingClients = this._waitingClientsQueue.length; // 正在等待的请求的队列长度 各个优先级的总和 console.log('numWaitingClients', numWaitingClients) // 1 // If there aren't any waiting requests then there is nothing to do // so lets short-circuit if (numWaitingClients < 1) { return; } // max: 10, min: 4 console.log('_potentiallyAllocableResourceCount', this._potentiallyAllocableResourceCount) // 目前潜在空闲可用的连接数量 const resourceShortfall = numWaitingClients - this._potentiallyAllocableResourceCount; // 还差几个可用的 小于零表示不需要 大于0表示需要新建长连接的数量 console.log('spareResourceCapacity', this.spareResourceCapacity) // 距离max数量的还有几个没有创建 const actualNumberOfResourcesToCreate = Math.min( this.spareResourceCapacity, // -6 resourceShortfall // 这个是 -3 ); // 如果resourceShortfall>0 表示需要新建但是这新建的数量不能超过spareResourceCapacity最多可创建的 console.log('actualNumberOfResourcesToCreate', actualNumberOfResourcesToCreate) // 如果actualNumberOfResourcesToCreate >0 表示需要创建连接 for (let i = 0; actualNumberOfResourcesToCreate > i; i++) { this._createResource(); // 新增新的长连接 } // If we are doing test-on-borrow see how many more resources need to be moved into test // to help satisfy waitingClients if (this._config.testOnBorrow === true) { // 如果开启了使用前校验资源的有效性 // how many available resources do we need to shift into test const desiredNumberOfResourcesToMoveIntoTest = numWaitingClients - this._testOnBorrowResources.size;// 1 const actualNumberOfResourcesToMoveIntoTest = Math.min( this._availableObjects.length, // 3 desiredNumberOfResourcesToMoveIntoTest // 1 ); for (let i = 0; actualNumberOfResourcesToMoveIntoTest > i; i++) { // 需要有效性校验的数量 至少满足最小的waiting clinet this._testOnBorrow(); // 资源有效校验后再分发 } } // if we aren't testing-on-borrow then lets try to allocate what we can if (this._config.testOnBorrow === false) { // 如果没有开启有效性校验 就开启有效资源的分发 const actualNumberOfResourcesToDispatch = Math.min( this._availableObjects.length, numWaitingClients ); for (let i = 0; actualNumberOfResourcesToDispatch > i; i++) { // 开始分发资源 this._dispatchResource(); } } } /** * Attempt to move an available resource to a waiting client * @return {Boolean} [description] */ _dispatchResource() { if (this._availableObjects.length < 1) { return false; } const pooledResource = this._availableObjects.shift(); // 从可以资源池里面取出一个 this._dispatchPooledResourceToNextWaitingClient(pooledResource); // 分发 return false; } /** * Dispatches a pooledResource to the next waiting client (if any) else * puts the PooledResource back on the available list * @param {PooledResource} pooledResource [description] * @return {Boolean} [description] */ _dispatchPooledResourceToNextWaitingClient(pooledResource) { const clientResourceRequest = this._waitingClientsQueue.dequeue(); // 可能是undefined 取出一个等待的quene console.log('clientResourceRequest.state', clientResourceRequest.state); if (clientResourceRequest === undefined || clientResourceRequest.state !== Deferred.PENDING) { console.log('没有等待的') // While we were away either all the waiting clients timed out // or were somehow fulfilled. put our pooledResource back. this._addPooledResourceToAvailableObjects(pooledResource); // 在可用的资源里面添加一个 // TODO: do need to trigger anything before we leave? return false; } // TODO clientResourceRequest 的state是否需要判断 如果已经是resolve的状态 已经超时回去了 这个是否有问题 const loan = new ResourceLoan(pooledResource, this._Promise); this._resourceLoans.set(pooledResource.obj, loan); // _resourceLoans 是个map k=>value pooledResource.obj 就是socket本身 pooledResource.allocate(); // 标识资源的状态是正在被使用 clientResourceRequest.resolve(pooledResource.obj); // acquire方法返回的promise对象的resolve在这里执行的 return true; } 上面的代码就按种情况一直走下到最终获取到长连接的资源,其他更多代码大家可以自己去深入了解。
前言 本文中,对于 Elasticsearch、kibana、Elasticsearch-head的基本使用,做一个演示 参考文献 ES官方文档:https://www.elastic.co/guide/cn/elasticsearch/guide/current/index-doc.html DB Elasticsearch 数据库(database) 索引(indices) 表(tables) 类型(types) 行(rows) 文档(documents) 字段(columns) 字段(fields) CURD预览 示例名称 请求类型 路由 新建索引 PUT /{index}/{type}/{id} 查询索引 GET /{index}/{type}/{id} 更新索引 POST /{index}/{type}/{id} 删除索引 DELETE /{index}/{type}/{id} 路由 一个文档的 _index 、 _type 和 _id 唯一标识一个文档。 我们可以提供自定义的 _id 值,或者让 index API 自动生成。举个例子,如果我们的索引称为 website ,类型称为 blog ,并且选择 123 作为 ID ,那么索引请求应该是下面这样: PUT /website/blog/123 { "title": "My first blog entry", "text": "Just trying this out...", "date": "2014/01/01" } kibana使用 # 创建 PUT /mailjob/blog/789 { "title": "libin", "text": "this is blog" } # 查询 GET /mailjob/blog/789 { "title": "libin", "text": "this is blog" } # 更新 PUT /mailjob/blog/789 { "title": "啦啦啦啦啦" } # 删除 DELETE /mailjob/blog/789 { "text": "this is blog" } Elasticsearch-head使用 Elasticsearch在linux使用 查询 [root@VM-0-15-centos home]# curl -X GET 'http://127.0.0.1:9200/mailjob/blog/789' {"_index":"mailjob","_type":"blog","_id":"789","_version":1,"_seq_no":8,"_primary_term":1,"found":true,"_source":{ "title": "libin", "text": "this is blog" } 更新 [root@VM-0-15-centos home]# curl -H 'Content-Type: application/json' -X POST 'http://127.0.0.1:9200/mailjob/blog/789' -d'{"title": "libin"}' {"_index":"mailjob","_type":"blog","_id":"789","_version":2,"result":"updated","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":9,"_primary_term":1} ik分词器测试 IK提供了两个分词算法:ik_smart 和 ik_max_word,其中 ik_smart 为最少切分,ik_max_word为最细 粒度划分 GET _analyze { "analyzer" : "standard", "text" : "es插件来了" } { "tokens" : [ { "token" : "es", "start_offset" : 0, "end_offset" : 2, "type" : "<ALPHANUM>", "position" : 0 }, { "token" : "插", "start_offset" : 2, "end_offset" : 3, "type" : "<IDEOGRAPHIC>", "position" : 1 }, { "token" : "件", "start_offset" : 3, "end_offset" : 4, "type" : "<IDEOGRAPHIC>", "position" : 2 }, { "token" : "来", "start_offset" : 4, "end_offset" : 5, "type" : "<IDEOGRAPHIC>", "position" : 3 }, { "token" : "了", "start_offset" : 5, "end_offset" : 6, "type" : "<IDEOGRAPHIC>", "position" : 4 } ] } GET _analyze { "analyzer" : "ik_smart", "text" : "es插件来了" } { "tokens" : [ { "token" : "es", "start_offset" : 0, "end_offset" : 2, "type" : "ENGLISH", "position" : 0 }, { "token" : "插件", "start_offset" : 2, "end_offset" : 4, "type" : "CN_WORD", "position" : 1 }, { "token" : "来了", "start_offset" : 4, "end_offset" : 6, "type" : "CN_WORD", "position" : 2 } ] } GET _analyze { "analyzer" : "ik_max_word", "text" : "es插件来了" } { "tokens" : [ { "token" : "es", "start_offset" : 0, "end_offset" : 2, "type" : "ENGLISH", "position" : 0 }, { "token" : "插件", "start_offset" : 2, "end_offset" : 4, "type" : "CN_WORD", "position" : 1 }, { "token" : "来了", "start_offset" : 4, "end_offset" : 6, "type" : "CN_WORD", "position" : 2 } ] }
前言 对于 es 这个搜索引擎来说,某些数据命名上和mysql的叫法不一样,所以也就存在不同的理解 概念理解 基本命名 DB Elasticsearch 数据库(database) 索引(indices) 表(tables) 类型(types) 行(rows) 文档(documents) 字段(columns) 字段(fields) elasticsearch(集群)中可以包含多个索引(数据库),每个索引中可以包含多个类型(表),每个类型下又包含多个文档(行),每个文档中又包含多个字段(列)。 物理设计 elasticsearch 在后台把每个索引划分成多个分片,每分分片可以在集群中的不同服务器间迁移。一个人就是一个集群!默认的集群名称就是 elaticsearh { "name": "node-1", "cluster_name": "my-application", "cluster_uuid": "yM6l-wYUSQSSwAVaQKhNzA", "version": { "number": "7.6.1", "build_flavor": "default", "build_type": "tar", "build_hash": "aa751e09be0a5072e8570670309b1f12348f023b", "build_date": "2020-02-29T00:15:25.529771Z", "build_snapshot": false, "lucene_version": "8.4.0", "minimum_wire_compatibility_version": "6.8.0", "minimum_index_compatibility_version": "6.0.0-beta1" }, "tagline": "You Know, for Search" } 逻辑设计 一个索引类型中,包含多个文档,比如说文档1,文档2。 当我们索引一篇文档时,可以通过这样的一各 顺序找到 它: 索引 ▷ 类型 ▷ 文档ID ,通过这个组合我们就能索引到某个具体的文档。 注意:ID不必是整 数,实际上它是个字 符串。 ES基本概念理解 文档 就是我们的一条条数据 user 1 zhangsan 18 2 kuangshen 3 之前说elasticsearch是面向文档的,那么就意味着索引和搜索数据的最小单位是文档,elasticsearch 中,文档有几个 重要属性 : 自我包含,一篇文档同时包含字段和对应的值,也就是同时包含 key:value! 可以是层次型的,一个文档中包含自文档,复杂的逻辑实体就是这么来的! {就是一个json对象! fastjson进行自动转换!} 灵活的结构,文档不依赖预先定义的模式,我们知道关系型数据库中,要提前定义字段才能使用, 在elasticsearch中,对于字段是非常灵活的,有时候,我们可以忽略该字段,或者动态的添加一个 新的字段。 尽管我们可以随意的新增或者忽略某个字段,但是,每个字段的类型非常重要,比如一个年龄字段类 型,可以是字符 串也可以是整形。因为elasticsearch会保存字段和类型之间的映射及其他的设置。这种映射具体到每个映射的每种类型,这也是为什么在elasticsearch中,类型有时候也称为映射类型。 索引 就是数据库! 索引是映射类型的容器,elasticsearch中的索引是一个非常大的文档集合。索引存储了映射类型的字段 和其他设置。 然后它们被存储到了各个分片上了。 我们来研究下分片是如何工作的。 物理设计 :节点和分片 如何工作 一个集群至少有一个节点,而一个节点就是一个elasricsearch进程,节点可以有多个索引默认的,如果 你创建索引,那么索引将会有个5个分片 ( primary shard ,又称主分片 ) 构成的,每一个主分片会有一个 副本 ( replica shard ,又称复制分片 ) 上图是一个有3个节点的集群,可以看到主分片和对应的复制分片都不会在同一个节点内,这样有利于某 个节点挂掉 了,数据也不至于丢失。 实际上,一个分片是一个Lucene索引,一个包含倒排索引的文件 目录,倒排索引的结构使 得elasticsearch在不扫描全部文档的情况下,就能告诉你哪些文档包含特定的 关键字。 不过,等等,倒排索引是什 么鬼? 倒排索引 elasticsearch使用的是一种称为倒排索引的结构,采用Lucene倒排索作为底层。这种结构适用于快速的全文搜索, 一个索引由文档中所有不重复的列表构成,对于每一个词,都有一个包含它的文档列表。 例如,现在有两个文档, 每个文档包含如下内容: Study every day, good good up to forever # 文档1包含的内容 To forever, study every day, good good up # 文档2包含的内容 为了创建倒排索引,我们首先要将每个文档拆分成独立的词(或称为词条或者tokens),然后创建一个包 含所有不重 复的词条的排序列表,然后列出每个词条出现在哪个文档 现在,我们试图搜索 to forever,只需要查看包含每个词条的文档 term doc_1 doc_2 to √ × forever √ √ total 2 1 两个文档都匹配,但是第一个文档比第二个匹配程度更高。如果没有别的条件,现在,这两个包含关键 字的文档都将返回。 再来看一个示例,比如我们通过博客标签来搜索博客文章。那么倒排索引列表就是这样的一个结构 : 如果要搜索含有 python 标签的文章,那相对于查找所有原始数据而言,查找倒排索引后的数据将会快 的多。只需要 查看标签这一栏,然后获取相关的文章ID即可。完全过滤掉无关的所有数据,提高效率! elasticsearch的索引和Lucene的索引对比 在elasticsearch中, 索引 (库)这个词被频繁使用,这就是术语的使用。 在elasticsearch中,索引被 分为多个分片,每份 分片是一个Lucene的索引。所以一个elasticsearch索引是由多个Lucene索引组成 的。别问为什么,谁让elasticsearch使用Lucene作为底层呢! 如无特指,说起索引都是指elasticsearch 的索引。
前言 在 【Elasticsearch 的安装】文章中,解决了 es 工具的基本安装问题。在本文中,探讨对于 es 工具的两个可视化的管理工具的使用,分别是es官方出品的Kibana,还有号称做集群比较厉害的 Elasticsearch-head 参考文献 Kibana(ES可视化管理)下载地址:https://www.elastic.co/cn/downloads/kibana kibana配置文件详解:https://blog.csdn.net/cb2474600377/article/details/108884414 ES-head(ES可视化管理):https://github.com/mobz/elasticsearch-head 安装Kibana Kibana 是为 Elasticsearch设计的开源分析和可视化平台。你可以使用 Kibana 来搜索,查看存储在Elasticsearch 索引中的数据并与之交互。你可以很容易实现高级的数据分析和可视化,以图表的形式展现出来。 1、安装nodejs环境 因为该软件需要 node 和 npm 的支持,所以要先安装 nodejs 环境,此处略去安装过程。。。 2、下载kibana环境 (注意这里要和es的软件版本一致) wget https://artifacts.elastic.co/downloads/kibana/kibana-7.11.1-linux-x86_64.tar.gz 3、解压并移动到安装目录 tar -zxvf kibana-7.11.1-linux-x86_64.tar.gz mv kibana-7.11.1-linux-x86_64 /usr/local/kibana 4、进行相关配置 4.1、配置访问路由 # vim 打开 kibana 的配置文件 vim /usr/local/kibana/config/kibana.yml # 做出如下配置 server.port: 5601 # kibana 的端口 server.host: "0.0.0.0" # 表示允许所有的ip访问kibana # 表示es的集群地址,此处把服务转发到了本机的 9200 端口服务,即es服务 elasticsearch.hosts: ["http://127.0.0.1:9200/"] # es服务地址,单个的话,可用此参数配置 # elasticsearch.url: "http://127.0.0.1:9200" # 中文支持 i18n.locale: "zh-CN" 4.2、为kibana配置linux用户 和 Elasticsearch 一样,kibana 的启动,也需要独立的 linux 用户进行启动 # 添加linux用户 [root@VM-0-15-centos config]# useradd kibana # 为该用户设置密码 [root@VM-0-15-centos config]# passwd kibana Changing password for user es. New password: BAD PASSWORD: The password is shorter than 8 characters Retype new password: passwd: all authentication tokens updated successfully. # 将 kibana 服务的权限给予该linux用户 [root@VM-0-15-centos config]# chown -R kibana /usr/local/kibana 4.3、防火墙授权访问端口-5601 # 在 root 用户下,开发kibana的端口5601 [root@VM-0-15-centos root]# firewall-cmd --permanent --add-port=5601/tcp # 平滑重载防火墙 firewall-cmd --reload 4.4、软件启动 # 进入到软件目录 [root@VM-0-15-centos kibana]# cd /usr/local/kibana # 启动软件 [root@VM-0-15-centos kibana]# ./bin/kibana Tips 此处启动后,如果 ctrl+c 结束后,kibana服务将会关闭,所以启动的时候,请加入 & ,表示以守护进程的方式启动 es 服务,用 ps -aux | grep kibana 建仓 kibana 的服务状态 4.5、网页访问 然后再浏览器输入服务器的 IP + 端口(默认:5601) 就可以看到是否成功了 安装Sense Sense 是一个 Kibana 应用 它提供交互式的控制台,通过你的浏览器直接向 Elasticsearch 提交请求。 这本书的在线版本包含有一个 View in Sense 的链接,里面有许多代码示例。当点击的时候,它会打开一个代码示例的Sense控制台。 你不必安装 Sense,但是它允许你在本地的 Elasticsearch 集群上测试示例代码,从而使更具有交互性。 1、在 Kibana 目录下运行下面的命令,下载并安装 Sense app # linux 执行 ./bin/kibana plugin --install elastic/sense Windows上面执行: `bin\kibana.bat plugin --install elastic/sense` NOTE:你可以直接从这里 https://download.elastic.co/elastic/sense/sense-latest.tar.gz 下载 Sense 离线安装可以查看这里 install it on an offline machine 。 2、启动 Kibana ./bin/kibana 3、测试 在你的浏览器中打开 Sense: http://localhost:5601/app/sense 安装ES-head Elasticsearch-head 是 ES 集群管理工具、数据可视化、增删改查工具 1、安装nodejs环境 和上文中的 Kibana 软件一样,该软件也需要 node 和 npm 的支持,所以要先安装 nodejs 环境,此处略去安装过程。。。 2、下载软件 wget https://github.com/mobz/elasticsearch-head/archive/master.zip 3、解压并移动到安装目录 unzip elasticsearch-head.zip mv elasticsearch-head /usr/local/es-head 4、npm安装依赖 安装后面可能会出现异常,但是不会影响操作 # 进入软件安装目录 [root@VM-0-15-centos home]# cd /usr/local/es-head/ # 安装依赖 [root@VM-0-15-centos es-head]# npm install 5、解决npm项目跨域问题 进入 elasticsearch 的配置文件(es代表elasticsearch软件的安装目录) [root@VM-0-15-centos home]# vim /usr/local/es/config/elasticsearch.yml 在elasticsearch.yml配置文件中配置跨域 http.cors.enabled: true # elasticsearch中启用CORS http.cors.allow-origin: "*" # 允许访问的IP地址段,* 为所有IP都可以访问 配置完成后,重启es服务。 6、防火墙授权访问端口-9100 # 开放 9100 端口 [root@VM-0-15-centos root]# firewall-cmd --permanent --add-port=9100/tcp # 平滑重载防火墙 firewall-cmd --reload 7、软件启动 在elasticsearch-head目录下执行如下命令启动项目 npm run start 8、网页访问 然后再浏览器输入服务器的 IP + 端口(默认:9100) 就可以看到是否成功了
前言 此文讨论一个 全文搜索引擎 的解决方案,ES是基于RESTful web接口。当然,还有其他的方案供你选择,例如:Lucene、讯搜、sphinx、Sort 。 Elasticsearch是用Java语言开发的,并作为Apache许可条款下的开放源码发布,是一种流行的企业级搜索引擎。Elasticsearch用于云计算中,能够达到实时搜索,稳定,可靠,快速,安装使用方便。 参考文献 ES官方文档:https://www.elastic.co/guide/cn/elasticsearch/guide/current/index-doc.html ES学习文档:http://doc.codingdict.com/elasticsearch/95/ ES 下载地址:https://www.elastic.co/cn/downloads/elasticsearch ES集群搭建:http://www.cnblogs.com/aubin/p/8012840.html 中文分词器IK:https://github.com/medcl/elasticsearch-analysis-ik/releases 安装Elasticsearch 1、安装 java jdk 环境 因为 es 搜索引擎需要 java 环境的支持,所以需要先安装 java jdk,并且检查在全局情况下 java 和 javac 命令是否可用。此处略去 java jdk 的安装过程。。。 2、下载es软件 wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.11.1-linux-x86_64.tar.gz 3、解压并移动到安装目录 tar -zxvf elasticsearch-7.11.1-linux-x86_64.tar.gz mv elasticsearch-7.11.1-linux-x86_64 /usr/local/es 4、进行相关配置 4.1、新建 data 目录(用于存放数据文件) [root@VM-0-15-centos home]# cd /usr/local/es [root@VM-0-15-centos es]# mkdir data 4.2、修改config/elasticsearch.yml vi config/elasticsearch.yml 取消或者修改下列项注释并修改: #集群名称 cluster.name: my-application #节点名称 node.name: node-1 #数据和日志的存储目录 path.data: /usr/local/es/data path.logs: /usr/local/es/logs #设置绑定的ip,设置为0.0.0.0以后就可以让任何计算机节点访问到了 network.host: 0.0.0.0 #端口 http.port: 9200 #设置在集群中的所有节点名称,这个节点名称就是之前所修改的,当然你也可以采用默认的也行,目前 是单机,放入一个节点即可 cluster.initial_master_nodes: ["node-1"] 修改完毕后,:wq 保存退出 4.3、修改内存 vi ./config/jvm.options 因为我的服务器内存是1G、而es默认配置的内存是1G、所以启动时可能会出现内存不足的情况 所以此内存的配置,请大家根据自己服务器的内存情况进行配置 -Xms256m -Xmx256m :wq 保存并退出 4.4、为linux添加es服务的用户 Tips: elasticsearch 服务不允许以 root 用户进行启动,所以应该为elasticsearch新建一个用户,并且给定相应的权限,切换为 elasticsearch 的用户后,以 elasticsearch 用户的身份进行启动 此文中为Elasticsearch添加的用户是es,后面的文章会被该用户(es),进行配置相应的权限 # 添加linux用户 [root@VM-0-15-centos config]# useradd es # 为该用户设置密码 [root@VM-0-15-centos config]# passwd es Changing password for user es. New password: BAD PASSWORD: The password is shorter than 8 characters Retype new password: passwd: all authentication tokens updated successfully. # 将 es 服务的权限给予该linux用户 [root@VM-0-15-centos config]# chown -R es /usr/local/es 4.5、编辑 /etc/security/limits.conf,在末尾加上 此配置文件用于配置用户的进程数和可以打开的文件的最大数 soft nproc:单个用户可用的最大进程数量(超过会警告) hard nproc:单个用户可用的最大进程数量(超过会报错) soft nofile:可打开的文件描述符的最大数(超过会警告) hard nofile:可打开的文件描述符的最大数(超过会报错) # 进入 limits.conf 文件 [root@VM-0-15-centos config]# vim /etc/security/limits.conf # 在末尾为【es的用户】加上这些配置 # elasticsearch es soft nofile 65536 es hard nofile 65536 es soft nproc 4096 es hard nproc 4096 4.6、在 20-nproc.conf,将 改为用户名(es的用户)* 对 es 用户做权限限制 # 进入 20-nproc.conf 文件 [root@VM-0-15-centos config]# vi /etc/security/limits.d/20-nproc.conf # 把 * 改为用户名(es的用户) # Default limit for number of user's processes to prevent # accidental fork bombs. # See rhbz #432903 for reasoning. es soft nproc 4096 # 所有的用户默认可以打开最大的进程数为 4096 root soft nproc unlimited # root 用户默认可以打开最大的进程数 无限制的。 4.7、修改sysctl.conf配置文件 此处用于指定用户所拥有的内存大小 # vi 进入 sysctl.conf 配置文件 [root@VM-0-15-centos config]# vim /etc/sysctl.conf # 在末尾加上 vm.max_map_count = 655360 4.8、防火墙授权访问端口-9200 # 在 root 用户下,开发es的端口9200 [root@VM-0-15-centos root]# firewall-cmd --permanent --add-port=9200/tcp # 平滑重载防火墙 firewall-cmd --reload 4.9、启动ES服务 登录刚才新建的elasticsearch用户,并启动elasticsearch # 切换 es 用户 [root@VM-0-15-centos config]# su es # 启动 es 服务 [es@VM-0-15-centos es]$ ./bin/elasticsearch Tips 此处启动后,如果 ctrl+c 结束后,es服务将会关闭,所以启动的时候,请加入 -d ,表示以守护进程的方式启动 es 服务,用 ps -aux | grep elasticsearch 建仓 es 的服务状态 4.10、网页访问 然后再浏览器输入服务器的 IP + 端口(默认:9200) 就可以看到是否成功了 安装IK分词器 注意: 下载此软件,请和 es 的版本保持一致 ik分词器是一个中文分词器,安装这个后 es 就支持中文分词啦。IK提供了两个分词算法:ik_smart 和 ik_max_word,其中 ik_smart 为最少切分,ik_max_word为最细 粒度划分。elasticsearch-plugin 可以通过这个命令来查看加载进来的插件 1、下载软件 wget https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.11.1/elasticsearch-analysis-ik-7.11.1.zip 2、复制到es软件的插件目录 # 进入es插件目录 [root@VM-0-15-centos plugins]# /usr/local/es/plugins/ # 创建ik分词器目录 [root@VM-0-15-centos plugins]# mkdir elasticsearch-analysis-ik [root@VM-0-15-centos plugins]# ls elasticsearch-analysis-ik # 进入ik分词器目录 [root@VM-0-15-centos plugins]# cd elasticsearch-analysis-ik/ # 把(ik分词器软件)复制到(ik分词器目录) [root@VM-0-15-centos elasticsearch-analysis-ik]# cp /home/elasticsearch-analysis-ik-7.11.1.zip /usr/local/es/plugins/elasticsearch-analysis-ik/ 3、解压软件 # 解压 [root@VM-0-15-centos elasticsearch-analysis-ik]# unzip elasticsearch-analysis-ik-7.11.1.zip # 删除zip包 [root@VM-0-15-centos elasticsearch-analysis-ik]# rm -rf elasticsearch-analysis-ik-7.11.1.zip 4、重启es软件 重启后,就可以使用 ik 中文分词器了 安装遇到的问题 用户内存过低 max virtual memory areas vm.max_map_count [65530] is too low, increase to at least [262144] 翻译过来就是:elasticsearch用户拥有的内存权限太小,至少需要262144; 上文中,我已经修改了此参数为 65530,但是看来还是太小了 切换到root用户 执行命令: sysctl -w vm.max_map_count=262144 查看结果: sysctl -a|grep vm.max_map_count 显示: vm.max_map_count = 262144 上述方法修改之后,如果重启虚拟机将失效,所以: 解决办法: 在 /etc/sysctl.conf文件最后添加一行 vm.max_map_count=262144 即可永久修改 安全提示错误日志 Active licenses is now [Basic]; Security is disabled 此提示可忽略。这是一个很普通的INFO级别的日志信息,就是linux服务器告诉我目前我的es集群的是默认的[Basic]许可,并且我的elasticsearch集群没有开启security(es集群中的security可以实现基于角色的访问控制,可以为es服务设置密码账号访问),这里我是没有开启security,即没有设置账号密码登陆elasticsearch服务。这就是一个安全认证的提示信息,仅此而已
Mycat简介 一个数据库中间件产品 一个彻底开源的,面向企业应用开发的大数据库集群 支持 事务、ACID、可以替代 MySQL的加强版数据库 一个可以视为MySQL集群的企业级数据库,用来替代昂贵的Oracle集群 一个融合内存缓存技术、NoSQL技术、HDFS大数据的 新型SQL Server结合传统数据库和 新型分布式数据仓库的新一代 企业级数据库产品 参考资料 Mycat 学习文档:http://www.mycat.org.cn/document/mycat-definitive-guide.pdf mycat 网站:http://www.mycat.org.cn/ java jdk安装博文:https://www.cnblogs.com/wjup/p/11041274.html mycat github仓库:https://github.com/MyCATApache/Mycat-download mycat 基本逻辑结构(思维导图):https://kdocs.cn/l/shHheJJd9tG0 Mycat安装 Windows下安装mycat 1、安装 java jdk 安装 java jdk 需要 jdk version > 1.7 2、安装mycat 安装完成后,正常启动bin文件下: 启动startup_nowrap ,直接双击运行 startup_nowrap.bat 或者使用Navicat工具去连接(但是响应时间会很长,不建议使用),推荐使用cmd命令连接mycat 小帖士:本文演示下载的版本地址是:Mycat-server-1.6.7.3 Linux下安装mycat 1、安装 java jdk 1.1、去Oracle官网下载需要安装的jdk版本,我这里用的是 jdk-8u181-linux-x64.tar.gz 1.2、解压安装 # 解压文件 [root@localhost home]# tar -zxvf jdk-8u20-linux-x64.tar.gz # 移动到 use/local/java 软件安装目录 [root@VM-0-15-centos home]# mv jdk1.8.0_20 /usr/local/java 1.3、接下来就该配置 环境变量 了,输入以下指令进行配置: [root@localhost home]# vi /etc/profile # java set java environment JAVA_HOME=/usr/local/java JRE_HOME=/usr/local/java/jre CLASS_PATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar:$JRE_HOME/lib PATH=$PATH:$JAVA_HOME/bin:$JRE_HOME/bin export JAVA_HOME JRE_HOME CLASS_PATH PATH 小帖士:其中 JAVA_HOME, JRE_HOME 请根据自己的实际安装路径及 JDK 版本配置。 1.4、编辑完之后,保存并退出,然后输入以下指令,刷新环境配置使其生效 [root@localhost home]# source /etc/profile 1.5、测试是否安装成功 [root@VM-0-15-centos java]# java -version java version "1.8.0_20" Java(TM) SE Runtime Environment (build 1.8.0_20-b26) Java HotSpot(TM) 64-Bit Server VM (build 25.20-b23, mixed mode) 2、安装mycat 2.1、下载 mycat 后进行解压 [root@VM-0-15-centos home]# tar -zxvf Mycat-server-1.6.7.3-release-20190828135747-linux.tar.gz 2.2、移动到 /usr/local/ 目录下 [root@VM-0-15-centos home]# mv mycat /usr/local/mycat [root@VM-0-15-centos home]# cd /usr/local/mycat/ 2.3、启动mycat # 进入到 bin 目录、启动目录 [root@VM-0-15-centos mycat]# cd bin/ # 查看 mycat 的命令 [root@VM-0-15-centos bin]# ./mycat Usage: ./mycat { console | start | stop | restart | status | dump } # 检查mycat是否存在配置错误 [root@VM-0-15-centos bin]# ./mycat console # 启动mycat [root@VM-0-15-centos bin]# ./mycat start 2.5、修改 mycat 的 java 服务 # 进入配置文件 vim /usr/local/mycat/conf/wrapper.conf # 修改 java 的绝对路径的启动服务 wrapper.java.command=/usr/local/java/bin/java 2.4、连接mycat [root@VM-0-15-centos ~]# mysql -uroot -p123456 -h127.0.0.1 -P8066 --default_auth=mysql_native_password 小帖士: 由于 Mysql8 的缺省加密方式已经改为 caching_sha2_password ,而MyCat对此尚不支持。为此,需加 上 --default_auth=mysql_native_pasowrd 选项 mycat的默认配置密码是 123456,故这里用 123456 登录 mycat Mycat的使用 配置文件简介 server.xml 该文件几乎保存了所有mycat需要的系统配置信息,包括 mycat 用户管理、DML权限管理等,其在代码内直接的映射类为SystemConfig 类。 Mycat 1.6的用户权限管理是通过server.xml实现的。在server.xml中通过用户标签和防火墙标签进行配置管理的 (1)设置password属性,设置用户的密码 (2)修改 readOnly 为 true 或 false 来限制用户对数据库是否可读写的权限,true为只读,false为读写 (3)修改 schemas 内的文本来控制用户可放问的 schema,多个schema用,号隔开 (4)设置benchmark属性:benchmark 基准, 当前端的整体 connection 数达到基准值是, 对来自该账户的请求开始拒绝连接,0 或不设表示不限制。 (5)设置usingDecrypt属性:是否对密码加密默认 0 否 如需要开启配置 1,同时使用加密程序对密码加密,加密命令方法是执行mycat的jar程序: java -cp Mycat-server-1.6.1-dev.jar org.opencloudb.util.DecryptUtil 0:user:password vim查看用户配置(局部配置)信息如下: <user name="root" defaultAccount="true"> <property name="password">123456</property> <property name="schemas">TESTDB</property> <!-- 表级 DML 权限设置 --> <!-- <privileges check="false"> <schema name="TESTDB" dml="0110" > <table name="tb01" dml="0000"></table> <table name="tb02" dml="1111"></table> </schema> </privileges> --> </user> schema.xml 用来定义mycat实例中的逻辑库,mycat可以有多个逻辑库,每个逻辑库都有自己的相关配置。可以使用schema标签来划分这些不同的逻辑库。 如果不配置schema标签,所有表的配置会属于同一个默认的逻辑库。 逻辑库的概念和MySql的database的概念一样,我们在查询两个不同逻辑库中的表的时候,需要切换到该逻辑库下进行查询。 (注意:进行查询的时候需要在server.xml配置相关用户权限信息) vim查看数据源信息如下 <dataHost name="localhost1" maxCon="1000" minCon="10" balance="0" writeType="0" dbType="mysql" dbDriver="native" switchType="1" slaveThreshold="100"> <heartbeat>select user()</heartbeat> <!-- can have multi write hosts --> <writeHost host="hostM1" url="localhost:3306" user="root" password="123456"> <!-- can have multi read hosts --> <readHost host="hostS2" url="192.168.1.200:3306" user="root" password="xxx" /> </writeHost> <writeHost host="hostS1" url="localhost:3316" user="root" password="123456" /> <!-- <writeHost host="hostM2" url="localhost:3316" user="root" password="123456"/> --> </dataHost> rule.xml 里面就定义了我们对表进行拆分所涉及到的规则定义。我们可以灵活的对表使用不同的分片算法, 或者对表使用相同的算法但具体的参数不同。这个文件里面主要有 tableRule 和 function 这两个标签。在具体使 用过程中可以按照需求添加 tableRule 和 function Mycat的简单使用 接下来的实现需求如下 数据库实现读写分离 对于插入的订单数据按照算法,除模取余的方式插入的不同的表中 对于查询根据除模取余算法,联查不同的表 满足对于那些分库分表后进行查询的业务需求 做全局表,实现插入一条数据后,该数据同时插入到对应的n个库中 在schema.xml配置数据源信息 <?xml version="1.0"?> <!DOCTYPE mycat:schema SYSTEM "schema.dtd"> <mycat:schema xmlns:mycat="http://io.mycat/"> <!-- 用TESTDB映射对应的数据表 --> <schema name="TESTDB" checkSQLschema="true" sqlMaxLimit="100"> <!-- 表信息 --> <!-- subTables 表示0-9十张表 --> <!-- mod-long 表示对分表进行除模取余实现 --> <table name="order" primaryKey="id" subTables="order$0-9" dataNode="dn1" rule="mod-long" /> </schema> <!-- 指定数据源,即数据库 --> <dataNode name="dn1" dataHost="localhost1" database="my_test_db" /> <!-- 配置数据源连接信息。 balance=3 且实现读写分离 --> <dataHost name="localhost1" maxCon="1000" minCon="10" balance="3" writeType="0" dbType="mysql" dbDriver="native" switchType="1" slaveThreshold="100"> <!-- 数据节点心跳检测 --> <heartbeat>select user()</heartbeat> <!-- write 连接信息 --> <writeHost host="hostM1" url="127.0.0.1:33061" user="slave" password="slave"> <!-- read 连接信息 --> <readHost host="hostS2" url="127.0.0.1:33062" user="slave" password="slave" /> </writeHost> </dataHost> </mycat:schema> 映射数据表说明 name=“order” 配置为 order 表 subTables=“order$0-9” 代表创建的10个order表(下文会创建的) dataNode=“dn1” 表示数据源节点,可用逗号配置多个 rule=“mod-long” 用 除模取余 的方法,插入数据的时候,根据此方法插入到不同的表中。还有其他的方法,可查看 conf/rule.xml 指定数据源说明 name=“dn1” 这里是上文中定义的切片规则时定义的数据节点名称 dataHost=“localhost1” 这里是上文中定义的数据源的 dataHost 的 name 信息 database=“my_test_db” 这里是真实的数据库地址 数据库连接信息说明 balance=“0”, 不开启读写分离机制,所有读操作都发送到当前可用的writeHost上。 balance=“1”,全部的readHost与stand by writeHost参与select语句的负载均衡,简单的说,当双主双从模式(M1->S1,M2->S2,并且M1与 M2互为主备),正常情况下,M2,S1,S2都参与select语句的负载均衡。 balance=“2”,所有读操作都随机的在writeHost、readhost上分发。 balance=“3”,所有读请求随机的分发到wiriterHost对应的readhost执行,writerHost不负担读压力 Tips:请确保连接的账号有足够的权限 mysql> show databases; # 用此检测 slave 能不能查到mycat中配置的 databases 在rule.xml配置分表算法 <function name="mod-long" class="io.mycat.route.function.PartitionByMod"> <!-- how many data nodes --> <property name="count">10</property> </function> 因为接下来用 10 张表进行测试,所以我把这里的默认3改成了10 测试配置文件是否有误 # 进入mycat启动目录 [root@VM-0-15-centos home]# cd /usr/local/mycat/bin/ # 进行测试 [root@VM-0-15-centos conf]# ./mycat console 如果输出结果是 successfly 则表示正确,否则请查看日志,分析错误原因 重启mycat服务 [root@VM-0-15-centos bin]# ./mycat stop Stopping Mycat-server... Stopped Mycat-server. [root@VM-0-15-centos bin]# ./mycat start Starting Mycat-server... [root@VM-0-15-centos bin]# ./mycat status Mycat-server is running (19070). 创建测试表进行测试 在端口号为 33061 的数据库服务器创建 my_test_db 数据库,然后创建 order0~order9 10张表用于测试 CREATE TABLE `order` ( `id` int(11) NOT NULL AUTO_INCREMENT, `buyer` int(11) DEFAULT NULL, `name` varchar(20) DEFAULT NULL, `level` tinyint(4) DEFAULT NULL, `status` tinyint(4) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 测试mycat # 连接(-D 指定数据库) [root@VM-0-15-centos ~]# mysql -uroot -p123456 -h127.0.0.1 -P8066 -DTESTDB --default_auth=mysql_native_password # 插入一些数据 mysql> INSERT INTO order(id,buyer,name,level,status) VALUE(1,1,1,1,1); mysql> INSERT INTO order(id,buyer,name,level,status) VALUE(5,9,9,9,9); mysql> INSERT INTO order(id,buyer,name,level,status) VALUE(8,8,8,8,8); # 测试查询 mysql> select * from order order by level desc; +----+-------+------+-------+--------+ | id | buyer | name | level | status | +----+-------+------+-------+--------+ | 5 | 9 | 9 | 9 | 9 | | 8 | 8 | 8 | 8 | 8 | | 1 | 1 | 1 | 1 | 1 | +----+-------+------+-------+--------+ 3 rows in set (0.22 sec) # 分析被查询的表信息 mysql> explain select * from order order by level desc; +-----------+----------------------------------------------------+ | DATA_NODE | SQL | +-----------+----------------------------------------------------+ | dn1 | SELECT * FROM order0 ORDER BY level DESC LIMIT 100 | | dn1 | SELECT * FROM order1 ORDER BY level DESC LIMIT 100 | | dn1 | SELECT * FROM order2 ORDER BY level DESC LIMIT 100 | | dn1 | SELECT * FROM order3 ORDER BY level DESC LIMIT 100 | | dn1 | SELECT * FROM order4 ORDER BY level DESC LIMIT 100 | | dn1 | SELECT * FROM order5 ORDER BY level DESC LIMIT 100 | | dn1 | SELECT * FROM order6 ORDER BY level DESC LIMIT 100 | | dn1 | SELECT * FROM order7 ORDER BY level DESC LIMIT 100 | | dn1 | SELECT * FROM order8 ORDER BY level DESC LIMIT 100 | | dn1 | SELECT * FROM order9 ORDER BY level DESC LIMIT 100 | +-----------+----------------------------------------------------+ 10 rows in set (0.00 sec) 通过可视化工具,看到这些数据已经用 除模取余 的方法,已经插入到不同的表中。 实现全局表 基于上面已经对schema.xml的更改,再做如下更改 <?xml version="1.0"?> <!DOCTYPE mycat:schema SYSTEM "schema.dtd"> <mycat:schema xmlns:mycat="http://io.mycat/"> <schema name="TESTDB" checkSQLschema="true" sqlMaxLimit="100"> <!--全局表配置--> <table name="student" primaryKey="id" type="global" dataNode="dn1,dn2,dn3" /> </schema> <!-- 全局表指定的数据源1 --> <dataNode name="dn1" dataHost="localhost1" database="my_test_db" /> <!-- 全局表指定的数据源2 --> <dataNode name="dn2" dataHost="localhost1" database="my_test_db_2" /> <!-- 全局表指定的数据源3 --> <dataNode name="dn3" dataHost="localhost1" database="my_test_db_3" /> <dataHost name="localhost1" maxCon="1000" minCon="10" balance="3" writeType="0" dbType="mysql" dbDriver="native" switchType="1" slaveThreshold="100"> <heartbeat>select user()</heartbeat> <writeHost host="hostM1" url="127.0.0.1:33061" user="root" password="root"> <readHost host="hostS2" url="127.0.0.1:33062" user="root" password="root" /> </writeHost> </dataHost> </mycat:schema> 进行测试 需要现在三个数据库(my_test_db、my_test_db_2、my_test_db_3)创建同样结构的表 然后连接 mycat 服务 开始插入数据 mysql> INSERT INTO student(id,name) VALUE(1,1); Query OK, 1 row affected (0.30 sec) 然后,通过数据库管理工具,可以看到三个数据库都已经新增了这条数据 但是通过mycat却只能查到一个,这就是用mycat做数据库中间件的好处 mysql> select * from student; +----+------+ | id | name | +----+------+ | 1 | 1 | +----+------+ 1 row in set (0.06 sec) 问题 1、命令窗使用mycat第一次查询或者插入出现如下错误,然后紧接着第二次操作错误消失 错误描述: ERROR 2006 (HY000): MySQL server has gone away 解决方案: 出现此错误的原因是,mycat和mysql建立了一个连接后,长时间没有操作mysql,mysql会自动把这个连接 kill 掉。你可以通过配置mysql的 max_allowed_packed 进行解决
前言 在上文中 【Mysql 高可用 Haproxy】,曾经探讨过一个mysql高可用的方案。但是该方案存在一个致命性的问题 就是,如果 Haproxy 如果发生故障,那么这个服务整体就瘫痪了。 本文接着探讨一种mysql的高可用方案,即使用 Keepalived 检测服务器的状态 ,来进行故障转移 参考文献 Keepalived 下载:https://www.keepalived.org/download.html Keepalived 教程文档:http://www.yunweipai.com/35361.html Keepalived 学习博文:https://www.cnblogs.com/clsn/p/8052649.html 演示架构图简版(思维导图):https://kdocs.cn/l/sgVGyjR4O590 图示说明 前期准备,需要服务器5台: server1:安装keepalived1+haproxy1进行服务监控和服务处理 server2:安装keepalived2+haproxy2进行备用的服务监控和服务处理 server3:做master主数据库服务器 server4:做slave从数据库服务器 server5:做slave从数据库服务器 图示架构说明: 1、首先建立两个 keepalived 服务器,然后这两个服务会虚拟出来一个相同的ip。当k1发生故障的时候,这个ip会漂移到 keepalived2,完成故障转移 2、keeplived 会根据 haproxy 服务器的可用性选择服务进行连接 3、haproxy 服务会根据 slave 数据库服务器的可用性选择服务 keeplived介绍与配置 Keepalived介绍 Keepalived的作用是检测服务器的状态,如果有一台服务器宕机,或工作出现故障,Keepalived将检测到,并将有故障的服务器从系统中剔除,同时使用其它服务器代替该服务器的工作,当服务器工作 正常后Keepalived自动将服务器加入到服务器群中,这些工作全部自动完成,不需要人工干涉,需要人工做的只是修复故障的服务器。 Keepalived安装 安装Keepalived需要的扩展 # 依赖安装 [root@localhost keepalived]# yum install gcc gcc-c++ openssl openssl-devel # 软件下载 [root@localhost home]# wget https://www.keepalived.org/software/keepalived-1.2.18.tar.gz 解压Keepalived并安装 [root@localhost home]# tar -zxvf keepalived-1.2.18.tar.gz [root@localhost home]# cd keepalived-1.2.18 [root@localhost keepalived-1.2.18]# ./configure --prefix=/usr/local/keepalived [root@localhost keepalived-1.2.18]# make && make install 将 keepalived 安装成 Linux 系统服务 因为没有使用 keepalived 的默认路径安装(默认是/usr/local) ,安装完成之后,需要做一些工作 复制默认配置文件到默认路径 [root@localhost keepalived-1.2.18]# mkdir /etc/keepalived [root@localhost keepalived-1.2.18]# cp /usr/local/keepalived/etc/keepalived/keepalived.conf /etc/keepalived/ 复制 keepalived 服务脚本到默认的地址 [root@localhost keepalived-1.2.18]# cp /usr/local/keepalived/etc/rc.d/init.d/keepalived /etc/init.d/ [root@localhost keepalived-1.2.18]# cp /usr/local/keepalived/etc/sysconfig/keepalived /etc/sysconfig/ [root@localhost keepalived-1.2.18]# ln -s /usr/local/keepalived/sbin/keepalived /usr/sbin/ 设置 keepalived 服务开机启动 [root@localhost keepalived-1.2.18]# chkconfig keepalived on mysql高可用搭建 Keepalived配置 keepalived主机配置 ! Configuration File for keepalived global_defs { router_id LVS_MASTER } vrrp_script chk_haproxy { script "/etc/keepalived/haproxy_check.sh" ## 检测 haproxy 状态的脚本路径 interval 2 ## 检测时间间隔 weight 2 ## 如果条件成立,权重+2 } vrrp_instance VI_1 { state MASTER interface ens33 virtual_router_id 79 # 权重,主的slave建议权重高一些 priority 100 advert_int 1 nopreempt authentication { auth_type PASS auth_pass 1234 } virtual_ipaddress { 192.168.29.101 } track_script { chk_haproxy } } # 写VIP virtual_server,只配置本地机器 virtual_server 192.168.29.101 3307 {# 定义虚拟服务器,地址与上面的virtual_ipaddress相同 delay_loop 3 # 健康检查时间间隔,3秒 lb_algo rr # 负载均衡调度算法:rr|wrr|lc|wlc|sh|dh|lblc lb_kind DR # 负载均衡转发规则:NAT|DR|TUN # persistence_timeout 5 # 会话保持时间5秒,动态服务建议开启 protocol TCP # 转发协议protocol,一般有tcp和udp两种 real_server 192.168.29.106 3307 { weight 1 # 权重越大负载分越大,0表示失效 TCP_CHECK { connect_timeout 3 nb_get_retry 3 delay_before_retry 3 connect_port 3306 } } } Keepalived从机配置 ! Configuration File for keepalived global_defs { router_id LVS_MASTER } vrrp_script chk_haproxy { script "/etc/keepalived/haproxy_check.sh" ## 检测 haproxy 状态的脚本路径 interval 2 ## 检测时间间隔 weight 2 ## 如果条件成立,权重+2 } vrrp_instance VI_1 { state BACKUP interface ens33 virtual_router_id 79 priority 90 advert_int 1 nopreempt authentication { auth_type PASS auth_pass 1234 } virtual_ipaddress { 192.168.29.101 } track_script { chk_haproxy } } # 写VIP virtual_server,只配置本地机器 virtual_server 192.168.29.101 3307 {# 定义虚拟服务器,地址与上面的virtual_ipaddress相同 delay_loop 3 # 健康检查时间间隔,3秒 lb_algo rr # 负载均衡调度算法:rr|wrr|lc|wlc|sh|dh|lblc lb_kind DR # 负载均衡转发规则:NAT|DR|TUN # persistence_timeout 5 # 会话保持时间5秒,动态服务建议开启 protocol TCP # 转发协议protocol,一般有tcp和udp两种 real_server 192.168.29.107 3307 { weight 1 # 权重越大负载分越大,0表示失效 TCP_CHECK { connect_timeout 3 nb_get_retry 3 delay_before_retry 3 connect_port 3306 } } } haproxy状态检测脚本 #!/bin/bash START_HAPROXY="/usr/sbin/haproxy -f /etc/haproxy/haproxy.cfg" #haproxy启动命令 LOG_FILE="/usr/local/keepalived/log/haproxy-check.log" # 日志文件 HAPS=`ps -C haproxy --no-header |wc -l` # 检测haproxy的状态,0代表未启动,1已经启动 date "+%Y-%m-%d %H:%M:%S" >> $LOG_FILE #在日志文件当中记录检测时间 echo "check haproxy status" >> $LOG_FILE # 记录haproxy的状态 if [ $HAPS -eq 0 ];then #执行haproxy判断 echo $START_HAPROXY >> $LOG_FILE #记录启动命令 /usr/sbin/haproxy -f /etc/haproxy/haproxy.cfg #启动haproxy sleep 3 if [ `ps -C haproxy --no-header |wc -l` -eq 0 ];then echo "start haproxy failed, killall keepalived" >> $LOG_FILE killall keepalived service keepalived stop fi fi haproxy状态检测脚本不执行问题,如果是使用的service keeplived start 或者是 systemctl 方式启动,脚本可能会不执行,可以使用 Keepalived -f /etc/keepalived/keepalived.conf方式启动Keepalived keepalived配置注意点 - 配置完成但是ip不生效; 查看虚拟机/机器系统时间是否一致 virtual_router_id路由id不对,不能冲突。可以通过/var/log/messages查看此错误 测试 在本地通过192.168.199.101去连接mysql,查询sever_id,端口使用haproxy定义的3307 可以看到同样的连接方式,但是查询到的server_id确是不同机器的server_id.
前言 在以前的文章中,搭建了 mysql 的主从复制。但是如果数据库的从服务发生故障,那么 read 业务就会瘫痪 所以在此文中,我们探讨一个高可用的解决方案,当某一个从节点发生故障后,可以把 read 业务迅速切换到可用的数据库从服务器中 参考文献 Haproxy 教程文档:http://www.yunweipai.com/35237.html 演示架构图简版(思维导图):https://kdocs.cn/l/spPGtmcDDnOx 图示说明 前期准备,需要服务器4台: server1:做 haproxy 服务器,监控 mysql-slave 的状态,保障 slave 数据库的可用性 server2:做master主数据库服务器 server3:做slave从数据库服务器 server4:做slave从数据库服务器 haproxy 服务会根据 slave 数据库服务器的可用性选择服务 haproxy介绍与配置 haproxy介绍 HAProxy 是一个使用C语言编写的自由及开放源代码软件,其提供高可用性、负载均衡,以及基于 TCP 和 HTTP 的应用程序代理。 HAProxy 特别适用于那些负载特大的 web 站点,这些站点通常又需要会话保持或七层处理。 HAProxy 运行在当前的硬件上,完全可以支持数以万计的并发连接。并且它的运行模式使得它可以很简单安全的整合进您当前的架构中, 同时可以保护你的 web 服务器不被暴露到网络上。 HAProxy 实现了一种事件驱动,单一进程模型,此模型支持非常大的并发连接数。多进程或多线程模型受内存限制 、系统调度器限制以及无处不在的锁限制,很少能处理数千并发连接。事件驱动模型因为在有更好的资源和时间管理的用户空间 (User-Space) 实现所有这些任务,所以没有这些问题。此模型的弊端是,在多核系统上,这些程序通常扩展性较差。这就是为什么他们必须进行优化以使每个 CPU 时间片 Cycle 做更多的工作。 相较与 Nginx,HAProxy 更专注与反向代理,因此它可以支持更多的选项,更精细的控制,更多的健康状态检测机制和负载均衡算法。 包括 GitHub、Bitbucket、Stack Overflow、Reddit、Tumblr、Twitter 和 Tuenti 在内的知名网站,及亚马逊网络服务系统都使用了 HAProxy Haproxy的特性: 1. 可靠性与稳定性都非常出色,可与硬件级设备媲美。 2. 支持连接拒绝,可以用于防止 DDoS 攻击 3. 支持长连接、短连接和日志功能,可根据需要灵活配置 4. 路由 HTTP 请求到后端服务器,基于 cookie 作会话绑定;同时支持通过获取指定的 url 来检测后端服务器的状态 5. HAProxy 还拥有功能强大的 ACL 支持,可灵活配置路由功能,实现动静分离,在架构设计与实现上带来很大方便 6. 可支持四层和七层负载均衡,几乎能为所有服务常见的提供负载均衡功能 7. 拥有功能强大的后端服务器的状态监控 web 页面,可以实时了解设备的运行状态 ,还可实现设备上下线等简单操作。 8. 支持多种负载均衡调度算法,并且也支持 session 保持。 9. Haproxy 七层负载均衡模式下,负载均衡与客户端及后端的服务器会分别建立一次 TCP连接,而在四层负载均衡模式下(DR),仅建立一次 TCP 连接;七层负载均衡对负载均衡设备的要求更高,处理能力也低于四层负载均衡 haproxy 的配置文件由两部分组成: 全局设定(global settings) 对代理的设定(proxies) 全局设定 global settings:主要用于定义 haproxy 进程管理安全及性能相关的参数 代理设定 proxies 共分为4段:defaults,frontend,backend,listen proxies:代理相关的配置可以有如下几个配置端组成 defaults:为除了 global 以外的其它配置段提供默认参数,默认配置参数可由下一个 “defaults” 重新设定。 frontend:定义一系列监听的套接字,这些套接字可接受客户端请求并与之建立连接。 backend:定义 “后端” 服务器,前端代理服务器将会把客户端的请求调度至这些服务器。 listen:定义监听的套接字和后端的服务器。类似于将 frontend 和 backend 段放在一起 所有代理的名称只能使用大写字母、小写字母、数字、-(中线)、_(下划线)、.(点号)和:(冒号)。此外,ACL 名称会区分字母大小写。 配置文件详细介绍 global log 127.0.0.1 local0 # 定义全局的 syslog 服务器,最多可定义2个,格式:log <address> <facility> [max level [min level]] chroot /var/lib/haproxy # 修改 haproxy 的工作目录至指定的目录并在放弃权限之前执行,保证haproxy的安全,使用配置文件默认值即可 pidfile /var/run/haproxy.pid maxconn 10000 # 设定每个haproxy进程所接受的最大并发连接数,其等同于命令行选项“-n”;“ulimit -n”自动计算的结果正是参照此参数设定的; user haproxy # 以指定的 user 运行haproxy,建议使用专用于运行 haproxy 的 user, 以免因权限问题带来风险; group haproxy # 以指定的 group 运行haproxy,建议使用专用于运行 haproxy 的 group, 以免因权限问题带来风险; daemon # 让 haproxy 以守护进程的方式工作于后台,其等同于 “-D” 选项的功能, 当然,也可以在命令行中以 “-db” 选项将其禁用; ulimit-n 100000 # 设定每进程所能够打开的最大文件描述符数目,默认情况下其会自动进行计算,因此不推荐修改此选项;Linux默认单进程打开文件数为1024个 stats socket /var/lib/haproxy/stats level admin process 1 # 开启一个 socket 管理接口 nbproc 12 # 指定启动的 haproxy 进程个数,只能用于守护进程模式的 haproxy;默认只启动一个进程, cpu-map 1 0 # 绑定 cpu,和 nbproc 数量相对。进程号从1开始,cpu 核数从0开始; defaults log global option tcplog # 启用日志记录;tcplog 请求; option dontlognull # 日志中将不会记录空连接; retries 3 # 定义连接后端服务器的失败重连次数 timeout connect 2s # 定义 haproxy 将客户端请求转发至后端服务器所等待的超时时长 timeout client 3600s # 客户端非活动状态的超时时长 timeout server 3600s # 客户端与服务器端建立连接后,等待服务器端的超时时长 maxconn 10000 # 默认和前段的最大连接数,但不能超过 global 中的 maxconn 限制数 listen admin_stats # 开启一个统计报告服务 bind *:1080 # 监听1080端口 mode http # 基于http协议 maxconn 10 stats refresh 10s # 统计页面自动刷新时间间隔 stats uri /haproxy # url 地址 stats realm Haproxy # 统计页面密码框上提示文本 stats auth admin:admin # 账号:密码 stats hide-version # 隐藏统计报告版本信息 stats admin if TRUE # 在制定条件下开启admin 功能 frontend haproxy # 前端应用 bind *:40000 # 端口 mode tcp # tcp 模式 default_backend tidb # 此前端对应的后端应用 backend tidb # 后端应用 balance leastconn # balance 基于最少连接数 mode tcp # tcp 模式 # acl internal_networks src 192.168.0.0/16 172.16.0.0/12 10.0.0.0/8 127.0.0.1 定义一条ACL,ACL是根据数据包的指定属性以指定表达式计算出的true/false值。 # tcp-request content reject if ! internal_networks # option mysql-check user haproxy post-41 server tidb1 10.0.1.4:4000 check # 后端应用地址,代理将会将对应客户端的请求转发至这些服务器。 server tidb2 10.0.1.10:4000 check Haproxy 搭建 服务器名称 IP 操作系统 安装服务 Mysql-Master 192.168.29.105 CentOS7.1 mysql Mysql-Slave1 192.168.29.106 CentOS7.1 mysql Mysql-Slave2 192.168.29.107 CentOS7.1 mysql Haproxy 192.168.29.108 Centos7.1 haproxy 1、建立mysql主从复制 首先要对于,mysql 的 master 和 slave 建立主从复制关系 此处略去 。 。 。 2、安装 haproxy 软件 2.1、yum 安装 yum install -y haproxy 2.2、配置haproxy配置文件,目录:/etc/haproxy/haproxy.cfg global log 127.0.0.1 local2 chroot /var/lib/haproxy pidfile /var/run/haproxy.pid maxconn 4000 user haproxy group haproxy daemon stats socket /var/lib/haproxy/stats defaults mode tcp log global option tcplog option dontlognull option http-server-close option redispatch retries 3 timeout http-request 10s timeout queue 1m timeout connect 10s timeout client 1m timeout server 1m timeout http-keep-alive 10s timeout check 10s maxconn 3000 frontend mysql bind 0.0.0.0:3307 mode tcp log global default_backend mysql_server backend mysql_server balance roundrobin # 配置 mysql 从节点服务 server mysql1 192.168.199.106:3306 check inter 5s rise 2 fall 3 server mysql2 192.168.199.107:3306 check inter 5s rise 2 fall 3 # 可视化面板配置 listen stats mode http bind 0.0.0.0:1080 stats enable stats hide-version stats uri /haproxyadmin?stats stats realm Haproxy\ Statistics stats auth admin:admin stats admin if TRUE 2.3、启动haproxy负载均衡 ./usr/sbin/haproxy -f /etc/haproxy/haproxy.cfg
前言 在搭建主从的时候,默认是一个异步的过程,所以难免出现数据延迟。 虽然用同步复制(参考下文链接),可以解决数据延迟问题,但是牺牲了一些性能,并不利于生产环境的需求。 所以,对于主从复制的属于不一致问题,我们尽可能的使用高可用的方法和方案,去做到一个更高的方案。 下面介绍一款工具: Percona Toolkit 简称 pt 工具 参考文献 Percona Toolkit安装:https://blog.mailjob.net/posts/1022633166.html 主从原理(同步复制):https://blog.mailjob.net/posts/3006260634.html 优秀博文:https://www.cnblogs.com/kevingrace/p/6261091.html mysql 主从存在的一些问题 异步同步复制延迟 MySQL的Bug感 网络中断 服务器崩溃 非正常关闭 等其他一些错误。 PT 工具校验 接下来我用以下3个工具做数据校验 pt-table-checksum 负责检测MySQL主从数据一致性 pt-table-sync负责挡住从数据不一致时修复数据,让他们保存数据的一致性 pt-heartbeat 负责监控MySQL主从同步延迟 在mysql主从实战从,我搭建了一组主从,如下 容器名称 版本 IP 端口 root账号密码 slave账号密码 mysql1(主) 5.7 172.17.0.2 33061->3306 root root slave slave mysql2(从) 5.7 172.17.0.3 33062->3306 root root 现在我在从库(mysql2)中添加一些数据,使其形成数据不一致 CREATE TABLE `pt_test` ( `pt_id` int(11) NOT NULL AUTO_INCREMENT, `pt_name` varchar(20) DEFAULT NULL, PRIMARY KEY (`pt_id`) ) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4; -- ---------------------------- -- Records of pt_test -- ---------------------------- INSERT INTO `pt_test` VALUES ('1', 'qwer'); INSERT INTO `pt_test` VALUES ('2', 'zxcvbn'); INSERT INTO `pt_test` VALUES ('3', '159852'); 这里我添加的是从库,如果是主库的话,数据会被主从同步过去,没法进行接下来的演示了 所以当前的数据就是不一致的了,从库的数据多于主库 PT 检测数据不一致 在 mysql 主从实战搭建中,我曾经创建了一个 blog_db 数据库,然后里面创建了一个 user 表 我现在在 slave库(从)中添加一条数据,这样就会导致主从不一致,便于接下来的测试 INSERT INTO `user` VALUES ('3', '9'); 在主库服务器执行命令查验 pt-table-checksum --nocheck-replication-filters --no-check-binlog-format --replicate=test.checksums --recursion-method=hosts --databases=blog_db h=127.0.0.1,u=root,p=root,P=33061 返回结果如下 Checking if all tables can be checksummed ... Starting checksum ... TS ERRORS DIFFS ROWS DIFF_ROWS CHUNKS SKIPPED TIME TABLE 02-19T01:47:05 0 1 2 1 1 0 0.040 blog_db.user 返回参数说明 TS :完成检查的时间。 ERRORS :检查时候发生错误和警告的数量。 DIFFS :0表示一致,1表示不一致。当指定--no-replicate-check时,会一直为0,当指定--replicate-check-only会显示不同的信息。 ROWS :表的行数。 CHUNKS :被划分到表中的块的数目。 SKIPPED :由于错误或警告或过大,则跳过块的数目。 TIME :执行的时间。 TABLE :被检查的表名。 信息数据会记录在check_data表中 mysql> use check_data; mysql> show tables; +----------------------+ | Tables_in_check_data | +----------------------+ | checksums | +----------------------+ 1 row in set (0.00 sec) 问题: 1、Diffs cannot be detected because no slaves were found. Please read the --recursion-method documentation for information 这个是由于,master库(主)找不到slave库(从)导致的问题。在主库查看从库的 hosts 信息发现是空的: show slave hosts; a、把slave库(从)的账户连接权限对主库开放 我这里是图方便,授权了 root 账户,实际搭建中确不能这么做,存在安全隐患 建议在,搭建主从的时候,开发和主授权给从,同样的账号和密码,然后再使用 pt 工具中,就可以使用这个账号和密码进行数据检查和同步了 mysql> GRANT SELECT, PROCESS, SUPER, REPLICATION SLAVE ON *.* TO 'root'@'172.17.0.2' IDENTIFIED BY 'root'; mysql> flush privileges; b、更改slave库(从)的配置文件 my.cnf ,添加从的ip和端口信息 [mysqld] report_host=IP_INFO report_port=3306 再次在 master库(主)查看从库的 hosts 信息发现ok了 PT 恢复数据 在主库服务器执行以下命令查验 pt-table-sync --replicate=check_data.checksums h=127.0.0.1,u=root,p=root,P=33061 h=127.0.0.1,u=root,p=root,P=33062 --print 先master的ip,用户,密码,然后是slave的ip,用户,密码 –sync-to-master :指定一个DSN,即从的IP,他会通过show processlist或show slave status 去自动的找主。 –print :打印,但不执行命令。 –execute :执行命令。 返回结果 DELETE FROM `blog_db`.`user` WHERE `id`='3' LIMIT 1 /*percona-toolkit src_db:blog_db src_tbl:user src_dsn:P=33061,h=127.0.0.1,p=...,u=root dst_db:blog_db dst_tbl:user dst_dsn:P=3306,h=172.17.0.3,p=...,u=root lock:1 transaction:1 changing_src:check_data.checksums replicate:check_data.checksums bidirectional:0 pid:27104 user:root host:VM-0-15-centos*/; 数据恢复同步命令 pt-table-sync --replicate=check_data.checksums h=127.0.0.1,u=root,p=root,P=33061 h=127.0.0.1,u=root,p=root,P=33062 --execute 再次查看,是否存在问题,结果ok pt-heartbeat 监控 mysql 延迟 对于MySQL数据库主从复制延迟的监控,可以借助percona的有力武器 pt-heartbeat 来实现。 pt-heartbeat的工作原理通过使用时间戳方式在主库上更新特定表,然后在从库上读取被更新的时间戳然后与本地系统时间对比来得出其延迟。 具体流程: 1)在主库上创建一张heartbeat表,按照一定的时间频率更新该表的字段(把时间更新进去)。监控操作运行后,heartbeat 表能促使主从同步! 2)连接到从库上检查复制的时间记录,和从库的当前系统时间进行比较,得出时间的差异。 master库(主)上创建一个 hearbeat 表: use check_data; CREATE TABLE heartbeat ( ts VARCHAR (26) NOT NULL, server_id INT UNSIGNED NOT NULL PRIMARY KEY, file VARCHAR (255) DEFAULT NULL, -- SHOW MASTER STATUS position bigint unsigned DEFAULT NULL, -- SHOW MASTER STATUS relay_master_log_file varchar(255) DEFAULT NULL, -- SHOW SLAVE STATUS exec_master_log_pos bigint unsigned DEFAULT NULL -- SHOW SLAVE STATUS ); 更新主库(master)上的 heartbeat (注意这个启动操作要在主库服务器上执行) --interval=1 表示 1秒钟 更新一次 # 命令执行 [root@VM-0-15-centos src]# pt-heartbeat --ask-pass --user=root --host=127.0.0.1 --port=33061 --create-table --database blog_db --interval=1 --interval=1 --update --replace --daemonize Enter password: # Tips: 我这里检测的库只有 --database blog_db ,建议是生产环境中不要加入这个。这样可以监测全部数据库 # 查看是否启动 [root@VM-0-15-centos src]# ps -ef|grep pt-heartbeat root 6306 1 0 03:01 ? 00:00:00 perl /usr/bin/pt-heartbeat --ask-pass --user=root --host=127.0.0.1 --port=33061 --create-table --database blog_db --interval=1 --interval=1 --update --replace --daemonize root 6667 5386 0 03:02 pts/0 00:00:00 grep --color=auto pt-heartbeat 查看监测结果 [root@VM-0-15-centos src]# pt-heartbeat --database blog_db --table=heartbeat --monitor --host=127.0.0.1 --port=33061 --user=root --password=root --master-server-id=1 0.00s [ 0.00s, 0.00s, 0.00s ] 0.02s [ 0.00s, 0.00s, 0.00s ] 0.00s [ 0.00s, 0.00s, 0.00s ] 0.00s [ 0.00s, 0.00s, 0.00s ] 这其中 0.02s 表示延迟了 ,没有延迟是为0 而 [ 0.00s, 0.00s, 0.00s ] 则表示1m,5m,15m的平均值, 而这其中中需要注意的是 --master-server-id 为主服务器的服务id就是在 my.cnf 中配置的 server_id 的值 其他问题 如果想把这个输出结果加入自动化监控,那么可以使用如下命令使监控输出写到文件,然后使用脚本定期过滤文件中的最大值作为预警即可: 注意–log选项必须在有–daemonize参数的时候才会打印到文件中,且这个文件的路径最好在/tmp下,否则可能因为权限问题无法创建 [root@master-server ~]# pt-heartbeat -D huanqiu --table=heartbeat --monitor --host=192.168.1.102 --user=root --password=123456 --log=/opt/master-slave.txt --daemonize [root@master-server ~]# tail -f /opt/master-slave.txt //可以测试,在主库上更新数据时,从库上是否及时同步,如不同步,可以在这里看到监控的延迟数据 0.00s [ 0.00s, 0.00s, 0.00s ] 下面是编写的主从同步延迟监控脚本,就是定期过滤–log文件中最大值(此脚本运行的前提是:启动更新主库heartbeat命令以及带上–log的同步延迟检测命令)。如果发生延迟,发送报警邮件 [root@master-server ~]# cat /root/check-slave-monit.sh #!/bin/bash cat /opt/master-slave.txt > /opt/master_slave.txt echo > /opt/master-slave.txt max_time=`cat /opt/master_slave.txt |grep -v '^$' |awk '{print $1}' |sort -k1nr |head -1` NUM=$(echo "$max_time"|cut -d"s" -f1) if [ $NUM == "0.00" ];then echo "Mysql主从数据一致" else /usr/local/bin/sendEmail -f ops@163.com -t wang@163.com -s smtp.email.cn -u "Mysql主从同步延迟" -o message-content-type=html -o message-charset=utf8 -xu ops@huanqiu.cn -xp WEE78@12l$ -m "Mysql主从数据同步有延迟" fi 最后总结: 通过pt-heartbeart工具可以很好的弥补默认主从延迟的问题,但需要搞清楚该工具的原理。 默认的Seconds_Behind_Master值是通过将服务器当前的时间戳与二进制日志中的事件时间戳相对比得到的,所以只有在执行事件时才能报告延时。备库复制线程没有运行,也会报延迟null。 还有一种情况:大事务,一个事务更新数据长达一个小时,最后提交。这条更新将比它实际发生时间要晚一个小时才记录到二进制日志中。当备库执行这条语句时,会临时地报告备库延迟为一个小时,执行完后又很快变成0 便捷管理 1、使用 shell 进行定时查验和同步数据 要做的是:创建一个 shell 脚本,定时的去检查数据的一致性,如果发现延迟问题,自动的存储日志和同步数据。 当然,你也可以做到对于延迟的比较厉害的从库进行 linux email 通知运维人员 #!/usr/bin/env bash NUM=`pt-table-checksum --nocheck-replication-filters --replicate=check_data.checksums --no-check-binlog-format --databases=mytest --tables=t --user=mytest --password=rot | awk 'NR>1{sum+=$3}END{print sum}'` if [ $NUM -eq 0 ] ;then echo "Data is ok!" else echo "Data is error!" pt-table-sync --sync-to-master h=192.168.29.103,u=mytest,p=rot,P=3306 --databases=mytest --print pt-table-sync --sync-to-master h=192.168.29.103,u=mytest,p=rot,P=3306 --databases=mytest --execute pt-table-sync --sync-to-master h=192.168.29.104,u=mytest,p=rot,P=3306 --databases=mytest --print pt-table-sync --sync-to-master h=192.168.29.104,u=mytest,p=rot,P=3306 --databases=mytest --execute fi 主从不一致其他解决方案 1、减少锁竞争 如果查询导致大量的表锁定,需要考虑重构查询语句,尽量避免过多的锁。 2、负载均衡 搭建多少slave,并且使用lvs或nginx进行查询负载均衡,可以减少每个slave执行查询的次数和时间,从而将更多的时间用于去处理主从同步。 3、salve较高的机器配置 4、slave调整参数 为了保障较高的数据安全性,配置sync_binlog=1,innodb_flush_log_at_trx_commit=1等设置。而Slave可以关闭binlog,innodb_flush_log_at_trx_commit也可以设置为0来提高sql的执行效率(这两个参数很管用) 5、并行复制 即将单线程的复制改成多线程复制。 从库有两个线程与复制相关:io_thread 负责从主库拿binlog并写到relaylog, sql_thread 负责读relaylog并执行。 多线程的思路就是把sql_thread 变成分发线程,然后由一组worker_thread来负责执行。 几乎所有的并行复制都是这个思路,有不同的,便是sql_thread 的分发策略。 MySQL5.7的真正并行复制 enhanced multi-threaded slave(MTS)很好的解决了主从同步复制的延迟问题。
前言 Percona Toolkit 简称 pt 工具,PT-Tools 是 Percona 公司开发用于管理MySQL的工具,功能包括检查主从复制的数据一致性、检查重复索引、定位 IO 占用高的表文件、在线 DDL 等,DBA 熟悉掌握后将极大提高工作效率。 参考文献 Percona Toolkit手册:https://www.percona.com/doc/percona-toolkit/LATEST/pt-table-sync.html Percona Toolkit下载:https://www.percona.com/downloads/percona-toolkit/LATEST/ Percona Toolkit下载(清华镜像):https://mirrors.cnnic.cn/percona/tools/yum/release/latest/RPMS/x86_64/percona-toolkit-3.2.1-1.el6.x86_64.rpm。 安装步骤 使用wget下载到本地 wget --no-check-certificate https://mirrors.cnnic.cn/percona/tools/yum/release/latest/RPMS/x86_64/percona-toolkit-3.2.1-1.el6.x86_64.rpm 安装 pt 工具的依赖软件 yum install perl-IO-Socket-SSL perl-DBD-MySQL perl-Time-HiRes perl perl-DBI -y 安装 pt 工具 yum install percona-toolkit-3.2.1-1.el6.x86_64.rpm 查看安装情况 [root@localhost home]# yum list | grep percona-toolkit percona-toolkit.x86_64 3.2.1-1.el7 @/percona-toolkit-3.2.1-1.el7.x86_64 [root@localhost home]# pt-table-checksum --help 会自动安装perl依赖包和percona-toolkit工具,默认安装路径在/usr/bin/路径,帮助文档路径/usr/share/man/man1/,可以通过man 命名直接获取帮助文档。 shell>pt pt-align pt-fifo-split pt-kill pt-pmp pt-slave-restart pt-variable-advisor pt-archiver pt-find pt-mext pt-query-digest pt-stalk pt-visual-explain ptaskset pt-fingerprint pt-mongodb-query-digest pt-secure-collect pt-summary ptx pt-config-diff pt-fk-error-logger pt-mongodb-summary pt-show-grants pt-table-checksum pt-deadlock-logger pt-heartbeat pt-mysql-summary pt-sift pt-table-sync pt-diskstats pt-index-usage pt-online-schema-change pt-slave-delay pt-table-usage pt-duplicate-key-checker pt-ioprofile pt-pg-summary pt-slave-find pt-upgrade pt工具主要包含如上命令,今天主要介绍几个DBA必会的pt命令。 使用介绍 pt-archive pt-archive是MySQL的在线归档工具,无影响生产,用此命令操作的表必须有主键。它实现的功能包括: 归档历史数据 在线删除大批量数据 数据导出和备份 数据远程归档 数据清理 --limit 10000 每次取1000行数据用pt-archive处理 --txn-size 1000 设置1000行为一个事务提交一次 --where 'id<3000' 设置操作条件 --progress 5000 每处理5000行输出一次处理信息 --statistics 输出执行过程及最后的操作统计(只要不加上--quiet,默认情况下pt- archive都会输出执行过程的) --charset=UTF8 指定字符集为UTF8 这个最好加上不然可能出现乱码。 --bulk-delete 批量删除source上的旧数据(例如每次1000行的批量删除操作) 使用示例: 归档,不删除原表数据(–no-delete) pt-archiver --source h=源ip,P=源端口,u=用户,p=密码,D=库名,t=表名 --dest h=目标IP,P=端口,u=用户,p=密码,D=库名,t=表名 --no-check-charset --where 'ID<100' --progress 5000 --no-delete --limit=10000 –statistics 归档, 删除原表记录(不用加no-delete) pt-archiver --source h=源ip,P=源端口,u=用户,p=密码,D=库名,t=表名 --dest h=目标IP,P=端口,u=用户,p=密码,D=库名,t=表名 --no-check-charset --where 'ID<100' --progress 5000 --limit=10000 –statistics 加上字符 pt-archiver --charset 'utf8' --source h=源ip,P=源端口,u=用户,p=密码,D=库名,t=表名 --dest h=目标IP,P=端口,u=用户,p=密码,D=库名,t=表名 --no-check-charset --where 'ID<100' --progress 5000 --limit=10000 –statistics 直接删除,不归档 pt-archiver --source h=127.0.0.1,P=端口,u=root,p=‘密码',D=源库,t=源表 --where 'ID<100' --purge --limit=5000 --no-check-charset --statistics --progress 5000 pt-kill pt-kill 是一个优秀的kill MySQL连接的一个工具,是percona toolkit的一部分,这个工具可以kill掉你想Kill的任何语句,特别出现大量的阻塞,死锁,某个有问题的sql导致mysql负载很高的情况。 --daemonize 放在后台以守护进程的形式运行 --interval 多久运行一次,单位可以是s,m,h,d等默认是s –不加这个默认是5秒 --victims 默认是oldest,只杀最古老的查询。这是防止被查杀是不是真的长时间运行的查询,他们只是长期等待,这种匹配按时间查询,杀死一个时间最高值 --all 杀掉所有满足的线程 --kill-query 只杀掉连接执行的语句,但是线程不会被终止 --print 打印满足条件的语句 --busy-time 批次查询已运行的时间超过这个时间的线程 --idle-time 杀掉sleep 空闲了多少时间的连接线程,必须在--match-command sleep时才有效—也就是匹配使用 ----ignore-command 忽略相关的匹配 -- –match-command 匹配相关的语句 (这两个搭配使用一定是ignore-command在前,match-command在后) --match-db cdelzone 匹配哪个库 match-command:Query,Sleep,Binlog Dump,Connect,Delayed insert,Execute,Fetch,Init DB,Kill,Prepare,Processlist,Quit,Reset stmt,Table Dump match-State:Locked,login,copy to tmp table,Copying to tmp table,Copying to tmp table on disk,Creating tmp table,executing,Reading from net,Sending data,Sorting for order,Sorting result,Table lock,Updating 使用示例: 杀掉空闲连接sleep60秒的SQL /usr/bin/pt-kill --host=127.0.0.1 --user=用户 --password=密码 --port=端口 --match-command Sleep --idle-time 60 --victim all --interval 5 --kill --daemonize --pid=/tmp/ptkill.pid --print --log=/tmp/pt-kill.log & 查询SELECT超过1分钟的SQL /usr/bin/pt-kill --host=127.0.0.1 --user=用户 --password=密码 --port=端口 --match-info "SELECT|select" --busy-time 60 --victim all --interval 5 --kill --daemonize --pid=/tmp/ptkill.pid --print --log=/home/pt-kill.log & kill掉state是Locked的SQL /usr/bin/pt-kill --host=127.0.0.1 --user=用户 --password=密码 --port=端口 --victims all --match-state='Locked' --interval 5 --kill --daemonize --pid=/tmp/ptkill.pid --print --log=/home/pt-kill.log & 指定用户进行kill /usr/bin/pt-kill --host=127.0.0.1 --user=用户 --password=密码 --port=端口 --victims all --match-user='root' --interval 5 --kill --daemonize --pid=/tmp/ptkill.pid --print --log=/home/pt-kill.log & 指定某个库和host地址进行kill /usr/bin/pt-kill --host=127.0.0.1 --user=用户 --password=密码 --port=端口 --victims all --match-db='test' --match-host='10.0.0.51' --interval 10 --kill --daemonize --pid=/tmp/ptkill.pid --print --log=/home/pt-kill.log & 将kill掉的SQL插入到表中 /usr/bin/pt-kill --host=127.0.0.1 --user=root --password=‘密码' --port=3306 --log-dsn D=库名,t=表名 --create-log-table --busy-time=60 --victims all --kill-query --match-info "SELECT|select" --print & pt-online-schema-change pt-online-schema-change简称pt-osc在线更改表结构,可以实现在线加减字段、索引、修改字段属性等功能。pt-osc模仿MySQL内部的改表方式进行改表,但整个改表过程是通过对原始表的拷贝来完成的,即在改表过程中原始表不会被锁定,并不影响对该表的读写操作。 首先,osc创建与原始表相同的不包含数据的新表(下划线开头)并按照需求进行表结构的修改,然后将原始表中的数据按逐步拷贝到新表中,当拷贝完成后,会自动同时修改原始表和新表的名字并默认将原始表删除。有两个注意点:被操作的表如果有 触发器,或外键用不了。要特别注意(标准规范MySQL是不建议用外键与触发器的)如果有,要把外键与触发器去掉再操作。 工作原理: 创建一个和要执行 alter 操作的表一样的新的空表结构(是alter之前的结构) 在新表执行alter table 语句(速度应该很快) 在原表中创建触发器3个触发器分别对应insert,update,delete操作 以一定块大小从原表拷贝数据到临时表,拷贝过程中通过原表上的触发器在原表进行的写操作都会更新到新建的临时表 Rename 原表到old表中,在把临时表Rename为原表 如果有参考该表的外键,根据alter-foreign-keys-method参数的值,检测外键相关的表,做相应设置的处理 默认最后将旧原表删除,如果执行失败了,或手动停止了,需要手动删除下划线开头的表(_表名)及三个触发器 --max-load 默认为Threads_running=25。每个chunk拷贝完后,会检查SHOW GLOBAL STATUS的内容,检查指标是否超过了指定的阈值。如果超过,则先暂停。这里可以用逗号分隔,指定多个条件,每个条件格式: status指标=MAX_VALUE或者status指标:MAX_VALUE。如果不指定MAX_VALUE,那么工具会这只其为当前值的120%。 --critical-load 默认为Threads_running=50。用法基本与--max-load类似,如果不指定MAX_VALUE,那么工具会这只其为当前值的200%。如果超过指定值,则工具直接退出,而不是暂停。 --user: -u,连接的用户名 --password: -p,连接的密码 --database: -D,连接的数据库 --port -P,连接数据库的端口 --host: -h,连接的主机地址 --socket: -S,连接的套接字文件 --statistics 打印出内部事件的数目,可以看到复制数据插入的数目。 --dry-run 创建和修改新表,但不会创建触发器、复制数据、和替换原表。并不真正执行,可以看到生成的执行语句,了解其执行步骤与细节。--dry-run与--execute必须指定一个,二者相互排斥。和--print配合最佳。 --execute 确定修改表,则指定该参数。真正执行。--dry-run与--execute必须指定一个,二者相互排斥。 --print 打印SQL语句到标准输出。指定此选项可以让你看到该工具所执行的语句,和--dry-run配合最佳。 --progress 复制数据的时候打印进度报告,二部分组成:第一部分是百分比,第二部分是时间。 --quiet -q,不把信息标准输出。 使用示例: 添加索引 pt-online-schema-change --user=root --password='密码' --port=端口 --host=127.0.0.1 --critical-load Threads_running=100 --alter "ADD INDEX index_name (column_name)" D=库名,t=表名 --print --execute 添加列 pt-online-schema-change --user=root --password='密码' --port=端口 --host=127.0.0.1 --critical-load Threads_running=200 --alter "ADD COLUMN column_name int(10)" D=库名,t=表名 --print –execute 删除列 pt-online-schema-change --user=root --password='密码' --port=端口 --host=127.0.0.1 --critical-load Threads_running=200 --alter "drop column column_name" D=库名,t=表名 --print –execute 列与索引同时添加操作 pt-online-schema-change --user=root --password='密码' --port=6006 --host=127.0.0.1 --critical-load Threads_running=200 --alter "ADD COLUMN column_name int(10)" --alter "ADD INDEX index_name (column_name)" D=库名,t=表名 --print --execute pt-query-digest pt-query-digest,用来进行慢查询Log的分析 --create-review-table 当使用--review参数把分析结果输出到表中时,如果没有表就自动创建 --create-history-table 当使用--history参数把分析结果输出到表中时,如果没有表就自动创建 --filter 对输入的慢查询按指定的字符串进行匹配过滤后再进行分析 --limit限制输出结果百分比或数量,默认值是20,即将最慢的20条语句输出,如果是50%则按总响应时间占比从大到小排序,输出到总和达到50%位置截止 --host mysql服务器地址 --host mysql服务器地址 --user mysql用户名 --password mysql用户密码 --history 将分析结果保存到表中,分析结果比较详细,下次再使用--history时,如果存在相同的语句,且查询所在的时间区间和历史表中的不同,则会记录到数据表中,可以通过查询同一CHECKSUM来比较某类型查询的历史变化 --review 将分析结果保存到表中,这个分析只是对查询条件进行参数化,一个类型的查询一条记录,比较简单。当下次使用--review时,如果存在相同的语句分析,就不会记录到数据表中 --output 分析结果输出类型,值可以是report(标准分析报告)、slowlog(Mysql slow log)、json、json-anon,一般使用report,以便于阅读。 --since 从什么时间开始分析,值为字符串,可以是指定的某个”yyyy-mm-dd [hh:mm:ss]”格式的时间点,也可以是简单的一个时间值:s(秒)、h(小时)、m(分钟)、d(天),如12h就表示从12小时前开始统计 --until 截止时间,配合—since可以分析一段时间内的慢查询 使用示例: 指定时间段的慢查询 pt-query-digest /home/mysql/log/slow.log --since 'yyyy-mm-dd [hh:mm:ss]' --until 'yyyy-mm-dd [hh:mm:ss]' 把查询保存到query_review表 pt-query-digest --user=root –password=密码 --review h=localhost,D=test,t=query_review--create-review-table slow.log 分析指含有select语句的慢查询 pt-query-digest --filter '$event->{fingerprint} =~ m/^select/i' /home/mysql/log/slow.log > slow_report.log 针对某个用户的慢查询 pt-query-digest --filter '($event->{user} || "") =~ m/^root/i' /home/mysql/log/slow.log > slow_report.log pt-table-checksum & pt-table-sync pt-table-checksum & pt-table-sync,检查主从是否一致性,检查主从不一致之后用这个工具进行处理,这两个一搬是搭配使用。 replicate=test.checksum:主从不一致的结果放到哪一张表中,一般我放在一个既有的数据库中,这个checksum表由pt-table-checksum工具自行建立 databases=testdb:我们要检测的数据库有哪些,这里是testdb数据库,如果想检测所有数据库那么就不要写这个参数了,如果有多个数据库,我们用逗号连接就可以了 host='127.0.0.1':主库的IP地址或者主机名 user=dba :主机用户名(确定此用户可以访问主从数据库) port=6006:主库端口号 recursion-method=hosts :主库探测从库的方式 empty-replicate-table:清理上一次的检测结果后开始新的检测 no-check-bin-log-format:不检查二进制日志格式,鉴于目前大多数生产数据库都将二进制日志设置为“ROW”格式,而我们的pt-table-checksum会话会自行设定使用“STATEMENT”格式,所以这个选项请务必加上 使用示例: pt-table-checksum --nocheck-replication-filters --no-check-binlog-format --replicate=test.checksums --recursion-method=hosts --databases=log_manage h=localhost,u=用户,p=密码,P=端口 检测DIFF有异常时,立刻到从库去看,通过在从库中查询replicate参数指定的test.checksum表得出: SELECT db, tbl, SUM(this_cnt) AS total_rows, COUNT(*) AS chunks FROM test.checksums WHERE ( master_cnt <> this_cnt OR master_crc <> this_crc OR ISNULL(master_crc) <> ISNULL(this_crc)) GROUP BY db, tbl; 检测有差异之后到从库上执行一下修复,前提是此表必须要有主键或唯一索引: pt-table-sync --sync-to-master --replicate=test.checksums h=127.0.0.1,u=用户,P=端口,p=密码 --print pt-find pt-find,找出几天之前建立的表,使用示例: 找出大于10G的表 /usr/bin/pt-find --socket=/tmp/mysqld.sock --user=root --password=密码 --port=端口 --tablesize +10G 30分钟之修改过的表 /usr/bin/pt-find --socket=/tmp/mysqld.sock --user=root --password=密码 --port=端口 --mmin -25 没有数据的表 /usr/bin/pt-find --socket=/tmp/mysqld.sock --user=root --password=密码 --port=端口 --empty 找出1天以前的表,存储引擎是MyISAM /usr/bin/pt-find --socket=/tmp/mysqld.sock --user=root --password=密码 --port=端口--ctime +1 --engine MyISAM 找出存储引擎为MyISAM的表修改为InnoDB /usr/bin/pt-find --socket=/tmp/mysqld.sock --user=root --password=密码 --port=端口 --engine MyISAM --exec "ALTER TABLE %D.%N ENGINE=InnoDB" 找出test1,test2库里的空表并删除 /usr/bin/pt-find --socket=/tmp/mysqld.sock --user=root --password=密码 --port=端口 --empty test1 test2 --exec-plus "DROP TABLE %s" 找到所有的表,根据数据和索引总大小,从大到小排序 /usr/bin/pt-find --socket=/tmp/mysqld.sock --user=root --password=密码 --port=端口 --printf "%T\t%D.%N\n" | sort -rn pt-mysql-summary pt-mysql-summary,打印出来MySQL的描述信息,包括:版本信息,数据目录,命令的统计,用户,数据库以及复制等信息还包括各个变量(status、variables)信息和各个变量的比例信息,还有配置文件等信息。 pt-mysql-summary --user=root --password=密码 --host=127.0.0.1 --port=端口 Pt-summary Pt-summary,打印出来的信息包括:CPU、内存、硬盘、网卡等信息,还包括文件系统、磁盘调度和队列大小、LVM、RAID、网络链接信息、netstat 的统计,以及前10的负载占用信息和vmstat信息。 pt-align pt-align常用于列格式化输出,功能单一,但是实用性极强。 示例文件如下: DATABASE TABLE ROWS foo bar 100 long_db_name table 1 another long_name 500 经过pt-align处理 DATABASE TABLE ROWS foo bar 100 long_db_name table 1 another long_name 500 pt-config-diff pt-config-diff,比较MySQL配置文件的不同 参数说明: h:数据库主机 P:端口号 u:用户名 p:密码 使用示例: 比较host1和host2配置文件不同 pt-config-diff h=host1 h=host2 比较[mysqld]标签和host1不同 pt-config-diff /etc/my.cnf h=host1 输出结果如下: pt-config-diff /etc/my-small.cnf /etc/my-large.cnf 2 config differences Variable my.master.cnf my.slave.cnf ========================= =============== =============== datadir /tmp/12345/data /tmp/12346/data port 12345 12346 pt-deadlock-logger pt-deadlock-logger可以记录MySQL中的死锁信息到指定的地方,便于集中分析。 使用示例: 将host1主机产生的死锁信息保存在host2主机test库下面的deadlocks表中,–create-dest-table表示 pt-deadlock-logger h=host1,P=端口,u=用户,p=密码 --dest h=host2,P=端口,u=用户,p=密码,D=test,t=deadlocks --create-dest-table --log=/home/mysql/deadlock.log --daemonize --interval 5 --run-time 2m --iterations 4 pt-heartbeat pt-heartbeat是监控mysql主从复制延迟的。 pt-heartbeat的工作原理通过使用时间戳方式在主库上更新特定表,然后在从库上读取被更新的时间戳然后与本地系统时间对比来得出其延迟 格式 pt-heartbeat [OPTIONS] [DSN] --update|--monitor|--check|--stop 参数 注意:需要指定的参数至少有 --stop,--update,--monitor,--check。 其中--update,--monitor和--check是互斥的,--daemonize和--check也是互斥。 --ask-pass 隐式输入MySQL密码 --charset 字符集设置 --check 检查从的延迟,检查一次就退出,除非指定了--recurse会递归的检查所有的从服务器。 --check-read-only 如果从服务器开启了只读模式,该工具会跳过任何插入。 --create-table 在主上创建心跳监控的表,如果该表不存在,可以自己手动建立,建议存储引擎改成memory。通过更新该表知道主从延迟的差距。 CREATE TABLE heartbeat ( ts varchar(26) NOT NULL, server_id int unsigned NOT NULL PRIMARY KEY, file varchar(255) DEFAULT NULL, position bigint unsigned DEFAULT NULL, relay_master_log_file varchar(255) DEFAULT NULL, exec_master_log_pos bigint unsigned DEFAULT NULL ); heratbeat 表一直在更改ts和position,而ts是我们检查复制延迟的关键。 --daemonize 执行时,放入到后台执行 --user=-u, 连接数据库的帐号 --database=-D, 连接数据库的名称 --host=-h, 连接的数据库地址 --password=-p, 连接数据库的密码 --port=-P, 连接数据库的端口 --socket=-S, 连接数据库的套接字文件 --file 【--file=output.txt】 打印--monitor最新的记录到指定的文件,很好的防止满屏幕都是数据的烦恼。 --frames 【--frames=1m,2m,3m】 在--monitor里输出的[]里的记录段,默认是1m,5m,15m。可以指定1个,如:--frames=1s,多个用逗号隔开。可用单位有秒(s)、分钟(m)、小时(h)、天(d)。 --interval 检查、更新的间隔时间。默认是见是1s。最小的单位是0.01s,最大精度为小数点后两位,因此0.015将调整至0.02。 --log 开启daemonized模式的所有日志将会被打印到制定的文件中。 --monitor 持续监控从的延迟情况。通过--interval指定的间隔时间,打印出从的延迟信息,通过--file则可以把这些信息打印到指定的文件。 --master-server-id 指定主的server_id,若没有指定则该工具会连到主上查找其server_id。 --print-master-server-id 在--monitor和--check 模式下,指定该参数则打印出主的server_id。 --recurse 多级复制的检查深度。模式M-S-S...不是最后的一个从都需要开启log_slave_updates,这样才能检查到。 --recursion-method 指定复制检查的方式,默认为processlist,hosts。 --update 更新主上的心跳表。 --replace 使用--replace代替--update模式更新心跳表里的时间字段,这样的好处是不用管表里是否有行。 --stop 停止运行该工具(--daemonize),在/tmp/目录下创建一个“pt-heartbeat-sentinel” 文件。后面想重新开启则需要把该临时文件删除,才能开启(--daemonize)。 --table 指定心跳表名,默认heartbeat。
前言 搭建一个完全新的主从节点,非常容易,直接使用命令就行 但是,当对于一个存在业务的主节点的数据库服务器搭建主从的时候,因为主库存在数据,所以建议你先用热备工具,备份主库的数据,然后把主库的数据恢复到从库中 然后再使用命令,搭建主从 参考文献 主从原理:https://blog.mailjob.net/posts/3006260634.html 数据库热备份:https://blog.mailjob.net/posts/3523564565.html 安装mysql服务 因为我只有一个服务器,所以这里我采用docker去安装mysql服务 # 拉取 mysql 5.7 镜像 docker pull mysql:5.7 复制过程中binlog建议使用row格式,其他格式可能会造成主从数据不一致的情况 对于从服务器,最好在配置文件,配置禁止写操作 # 在宿主机 mysql1(master) 中创建配置文件, 以备后面创建配置文件映射之用 [root@VM-0-15-centos ~]# vim /data/mysql_master_slave/mysql1/conf/my.cnf # 写入以下内容 [mysqld] server-id=1 # 设置server_id,注意要唯一 log-bin=mysql-slave-bin # 开启二进制日志功能,以备Slave作为其它Slave的Master时使用 character-set-server=utf8 [client] default-character-set=utf8 [mysql] default-character-set=utf8 # 在宿主机 mysql2(slave) 中创建配置文件 [root@VM-0-15-centos ~]# vim /data/mysql_master_slave/mysql2/conf/my.cnf # 写入以下内容 [mysqld] server-id=2 # 设置server_id,注意要唯一 log-bin=mysql-slave-bin # 开启二进制日志功能,以备Slave作为其它Slave的Master时使用 relay_log=edu-mysql-relay-bin # relay_log配置中继日志 character-set-server=utf8 [client] default-character-set=utf8 [mysql] default-character-set=utf8 # 创建 mysql1 容器 docker run -p 33061:3306 --name mysql1 \ -v /data/mysql_master_slave/mysql1/conf/my.cnf:/etc/mysql/my.cnf \ -v /data/mysql_master_slave/mysql1/conf:/etc/mysql \ -v /data/mysql_master_slave/mysql1/logs:/var/log/mysql \ -v /data/mysql_master_slave/mysql1/data:/var/lib/mysql \ -e MYSQL_ROOT_PASSWORD=root \ -d mysql:5.7 # 创建 mysql2 容器 docker run -p 33062:3306 --name mysql2 \ -v /data/mysql_master_slave/mysql2/conf/my.cnf:/etc/mysql/my.cnf \ -v /data/mysql_master_slave/mysql2/conf:/etc/mysql \ -v /data/mysql_master_slave/mysql2/logs:/var/log/mysql \ -v /data/mysql_master_slave/mysql2/data:/var/lib/mysql \ -e MYSQL_ROOT_PASSWORD=root \ -d mysql:5.7 # 查看得到容器ip信息 [root@VM-0-15-centos ~]# docker container inspect mysql1 mysql2 | grep IPAddress "SecondaryIPAddresses": null, "IPAddress": "172.17.0.2", "IPAddress": "172.17.0.2", "SecondaryIPAddresses": null, "IPAddress": "172.17.0.3", "IPAddress": "172.17.0.3", –name:容器名,此处命名为 mysql1 。后面我还要创建一个 mysql2 作为从节点 -e:配置信息,此处配置mysql的root用户的登陆密码 -p:端口映射,此处映射 主机3306端口 到 容器的3306端口 -d:后台运行容器,保证在退出终端后容器继续运行 -v:主机和容器的目录映射关系,":"前为主机目录,之后为容器目录 主库对从库进行账号授权: Tips: 建议在授权该账户的时候,对从库也授权同样的账号和密码,在日后用第三方工具对于数据一致性进行管理的时候,比较方便! # 进入 mysql1 容器 docker exec -it mysql1 /bin/bash # 连接mysql服务 mysql -uroot -proot # 授权一个账号 mysql> GRANT REPLICATION SLAVE ON *.* to 'slave'@'%' identified by 'slave'; Query OK, 0 rows affected, 1 warning (0.00 sec) 所以依据上面的演示,我建立两个mysql服务节点: 容器名称 版本 IP 端口 root账号密码 slave账号密码 mysql1(主) 5.7 172.17.0.2 33061->3306 root root slave slave mysql2(从) 5.7 172.17.0.3 33062->3306 root root 开始搭建主从复制 查看 mysql1 (master)状态 mysql> show master status; +------------------+----------+--------------+------------------+-------------------+ | File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set | +------------------+----------+--------------+------------------+-------------------+ | mysql-bin.000002 | 893 | | | | +------------------+----------+--------------+------------------+-------------------+ 1 row in set (0.00 sec) 配置 mysql2(slave)的从服务 mysql> change master to master_host='172.17.0.2',master_port=3306,master_user='slave',master_password='slave',master_log_file='mysqlbin.000002',master_log_pos=1061; //启动从服务器复制功能 mysql>start slave; 查看是否配置成功 mysql> show slave status \G; Slave_IO_Running: Yes Slave_SQL_Running: Yes 都是yes就说明成功了 遇到的问题: 1、Slave_SQL_Running = no 请重复执行以下内容,直至 yes mysql>stop slave; mysql>set GLOBAL SQL_SLAVE_SKIP_COUNTER=1; # 跳过复制错误 mysql>start slave; 2、Slave_IO_Running= no a、先开始我以为是 auto.cnf 中的 uuid 一样导致的,我用的是docker部署服务,是不是我复用容器导致的。但是我查看后,发现 uuid 并不一致,所以先排除 uuid 冲突问题 # 也可用此方法查看 uuid 情况 mysql> show variables like '%uuid%'; +---------------+--------------------------------------+ | Variable_name | Value | +---------------+--------------------------------------+ | server_uuid | e9b4346e-7122-11eb-8772-0242ac110002 | +---------------+--------------------------------------+ 1 row in set (0.00 sec) b,在从节点,查看主从信息,看到了 1236 的错误信息 mysql> show slave status \G; Last_IO_Error: Got fatal error 1236 from master when reading data from binary log: 'Could not find first log file name in binary log index file' c、接着我查看容器的日志,也看到了同样的错误 [root@VM-0-15-centos data]# docker logs -f mysql2 # 得到如下错误信息 2021-02-17T14:53:03.499426Z 7 [ERROR] Slave I/O for channel '': Got fatal error 1236 from master when reading data from binary log: 'Could not find first log file name in binary log index file', Error_code: 1236 2021-02-17T14:53:03.499432Z 7 [Note] Slave I/O thread exiting for channel '', read up to log 'mysqlbin.000002', position 1061 # 然后我这样解决 stop slave; reset slave; start slave; # 非常魔幻,两个关键参数都变成了 yes ,都好了 # 查看资料得知,出现这个问题是不正常先重启数据库或者断电 可视化测试 创建数据库,创建表,添加数据,均正常同步 其他问题 1、如何查看 binlog 日志是否开启 mysql> show global variables like '%log_bin%'; +---------------------------------+--------------------------------+ | Variable_name | Value | +---------------------------------+--------------------------------+ | log_bin | ON | | log_bin_basename | /var/lib/mysql/mysql-bin | | log_bin_index | /var/lib/mysql/mysql-bin.index | | log_bin_trust_function_creators | OFF | | log_bin_use_v1_row_events | OFF | +---------------------------------+--------------------------------+ 5 rows in set (0.00 sec) 2、对于使用 docker 部署mysql主从的要注意 我在上面 run 容器的时候,只是指定了数据卷和配置文件,但是没有指定ip 这就存在一个问题,服务器重启或者docker重启的时候,或许会导致容器的ip发生变化,那么slave库就无法根据ip连接到master节点了 所以在用 docker 做主从的时候,要指定一个网桥,然后根据网桥给定的ip段,再给定 ip 地址,这样再重启的时候,ip信息就不会发生变化了 3、查看 server_id 情况 要求是,server_id 不同 mysql> show variables like '%server_id%'; +----------------+-------+ | Variable_name | Value | +----------------+-------+ | server_id | 1 | | server_id_bits | 32 | +----------------+-------+ 2 rows in set (0.00 sec)
前言 热备份的方式也是直接复制数据物理文件,和冷备份一样,但热备份可以不停机直接复制,一般用于7×24小时不间断的重要核心业务。 MySQL社区版的热备份 工具ImnoDB Hot Backup是付费的,只能试用30天,只有购买企业版才可以得到永久使用权。Percona公司发布了一个xtrabackup热备份工具,和官方付费版的 功能一样,支持在线热备份(备份时不影响数据读写),是商业备份工具InnoDBHot Backup的一个很好的替代品。 xtrabackup是Percona公司的开源项目,用以实现类似ImnoDB官方的热备份工具ImmoDB Hot Backup的功能,它能非常快速地备份与恢复MySQL数据库。 xtrabackup只能备份innoDB和xtraDB两种数据引擎的表,而不能备份MyISAM数据表。 MySQL冷备、mysqldump、MySQL热拷贝都无法实现对数据库进行增量备份。在实际生产环境中增量备份是非常实用的,如果数据大于50G或100G,存储空间足够的情况下,可以每天进行完整备份,如果每天产生的数据量较大,需要定制数据备份策略。例如每周实用完整备份,周一到周六实用增量备份。而Percona-Xtrabackup就是为了实现增量备份而出现的一款主流备份工具,xtrabakackup有2个工具,分别是xtrabakup、innobakupe 参考文献 xtrabackup手册:https://www.percona.com/doc/percona-xtrabackup/2.4/installation/yum_repo.html 软件下载地址:https://www.percona.com/downloads/ xtrabackup 的优点 (1)备份速度快,物理备份可靠 (2)备份过程不会打断正在执行的事务(无需锁表) (3)能够基于压缩等功能节约磁盘空间和流量 (4)自动备份校验 (5)还原速度快 (6)可以流传将备份传输到另外一台机器上 (7)在不增加服务器负载的情况备份数据 Xtrabackup备份原理 流程说明: (1)innobackupex启动后,会先fork一个进程,用于启动xtrabackup,然后等待xtrabackup备份ibd数据文件; (2)xtrabackup在备份innoDB数据是,有2种线程:redo拷贝线程和ibd数据拷贝线程。xtrabackup进程开始执行后,会启动一个redo拷贝的线程,用于从最新的checkpoint点开始顺序拷贝redo.log;再启动ibd数据拷贝线程,进行拷贝ibd数据。这里是先启动redo拷贝线程的。在此阶段,innobackupex进行处于等待状态(等待文件被创建) (4)xtrabackup拷贝完成ibd数据文件后,会通知innobackupex(通过创建文件),同时xtrabackup进入等待状态(redo线程依旧在拷贝redo.log) (5)innobackupex收到xtrabackup通知后哦,执行FLUSH TABLES WITH READ LOCK(FTWRL),取得一致性位点,然后开始备份非InnoDB文件(如frm、MYD、MYI、CSV、opt、par等格式的文件),在拷贝非InnoDB文件的过程当中,数据库处于全局只读状态。 (6)当innobackup拷贝完所有的非InnoDB文件后,会通知xtrabackup,通知完成后,进入等待状态; (7)xtrabackup收到innobackupex备份完成的通知后,会停止redo拷贝线程,然后通知innobackupex,redo.log文件拷贝完成; (8)innobackupex收到redo.log备份完成后,就进行解锁操作,执行:UNLOCK TABLES; (9)最后innbackupex和xtrabackup进程各自释放资源,写备份元数据信息等,innobackupex等xtrabackup子进程结束后退出。 xtrabackup的安装部署以及备份恢复实现 1、软件安装 # 下载软件 # 软件下载相关版本见上文 [参考文献] [root@VM-0-15-centos src]# wget https://www.percona.com/downloads/XtraBackup/Percona-XtraBackup-2.4.9/binary/redhat/7/x86_64/percona-xtrabackup-24-2.4.9-1.el7.x86_64.rpm # 直接采用rpm包的方式进行安装 [root@VM-0-15-centos src]# yum install -y percona-xtrabackup-24-2.4.9-1.el7.x86_64.rpm # 查看 rpm 软件安装情况 [root@VM-0-15-centos src]# rpm -qa |grep xtrabackup percona-xtrabackup-24-2.4.9-1.el7.x86_64 Xtrabackup中主要包含两个工具: xtrabackup:是用于热备innodb,xtradb表中数据的工具,不能备份其他类型的表,也不能备份数据表结构; innobackupex:是将xtrabackup进行封装的perl脚本,提供了备份myisam表的能力。 常用选项: --host 指定主机 --user 指定用户名 --password 指定密码 --port 指定端口 --databases 指定数据库 --incremental 创建增量备份 --incremental-basedir 指定包含完全备份的目录 --incremental-dir 指定包含增量备份的目录 --apply-log 对备份进行预处理操作 一般情况下,在备份完成后,数据尚且不能用于恢复操作,因为备份的数据中可能会包含尚未提交的事务或已经提交但尚未同步至数据文件中的事务。因此,此时数据文件仍处理不一致状态。“准备”的主要作用正是通过回滚未提交的事务及同步已经提交的事务至数据文件也使得数据文件处于一致性状态。 --redo-only 不回滚未提交事务 --copy-back 恢复备份目录 使用innobackupex备份时,其会调用xtrabackup备份所有的InnoDB表,复制所有关于表结构定义的相关文件(.frm)、以及MyISAM、MERGE、CSV和ARCHIVE表的相关文件,同时还会备份触发器和数据库配置信息相关的文件,这些文件会被保存到一个以时间命名的目录当中。在备份的同时,innobackupex还会在备份目录中创建如下文件: (1)xtrabackup_checkpoints -- 备份类型(如完全或增量)、备份状态(如是否已经为prepared状态)和LSN(日志序列号)范围信息: 每个InnoDB页(通常为16k大小) 都会包含一个日志序列号,即LSN,LSN是整个数据库系统的系统版本号,每个页面相关的LSN能够表明此页面最近是如何发生改变的。 (2)xtrabackup_binlog_info -- mysql服务器当前正在使用的二进制日志文件及备份这一刻位置二进制日志时间的位置。 (3)xtrabackup_binlog_pos_innodb -- 二进制日志文件及用于InnoDB或XtraDB表的二进制日志文件的当前position。 (4)xtrabackup_binary -- 备份中用到的xtrabackup的可执行文件; (5)backup-my.cnf -- 备份命令用到的配置选项信息: 在使用innobackupex进行备份时,还可以使用--no-timestamp选项来阻止命令自动创建一个以时间命名的目录:如此一来,innobackupex命令将会创建一个BACKUP-DIR目录来存储备份数据。 如果要使用一个最小权限的用户进行备份,则可基于如下命令创建此类用户:如果要使用一个最小权限的用户进行备份,则可基于如下命令创建此类用户: mysql> CREATE USER 'bkpuser'@'localhost' IDENTIFIED BY '123456'; #创建用户 mysql> REVOKE ALL PRIVILEGES,GRANT OPTION FROM 'bkpuser'; #回收此用户所有权限 mysql> GRANT RELOAD,LOCK TABLES,RELICATION CLIENT ON *.* TO 'bkpuser'@'localhost'; #授权刷新、锁定表、用户查看服务器状态 mysql> FLUSH PRIVILEGES; #刷新授权表 2、xtrabackup 全量备份与恢复 备份: innobackupex --user=DBUSER --password=DBUSERPASS --defaults-file=/etc/my.cnf /path/to/BACKUP-DIR/ 恢复: innobackupex --apply-log /backups/2018-07-30_11-04-55/ innobackupex --copy-back --defaults-file=/etc/my.cnf /backups/2018-07-30_11-04-55/ (1)准备(prepare)一个完全备份 一般情况下,在备份完成后,数据尚且不能用于恢复操作,因为备份的数据中可能会包含尚未提交的事务或者已经提交但尚未同步至数据文件中的事务。因此,此时数据文件仍处于不一致状态。 "准备"的主要作用正是通过回滚未提交的事务及同步已经提交的事务至数据文件也使用得数据文件处于一致性状态。 innobackupex命令的–apply-log选项可用于实现上述功能,如下面的命令: # innobackupex --apply-log /path/to/BACKUP-DIR 如果执行正确,其最后输出的几行信息通常如下: 120407 09:01:04 innobackupex: completed OK! 在实现"准备"的过程中,innobackupex通常还可以使用–user-memory选项来指定其可以使用的内存的大小,默认为100M.如果有足够的内存空间可用,可以多划分一些内存给prepare的过程,以提高其完成备份的速度。 (2)从一个完全备份中恢复数据 注意:恢复不用启动MySQL innobackupex命令的–copy-back选项用于恢复操作,其通过复制所有数据相关的文件至mysql服务器DATADIR目录中来执行恢复过程。innobackupex通过backup-my.cnf来获取DATADIR目录的相关信息。 # innobackupex --copy-back /path/to/BACKUP-DIR 当数据恢复至DATADIR目录以后,还需要确保所有的数据文件的属主和属组均为正确的用户,如mysql,否则,在启动mysqld之前还需要事先修改数据文件的属主和属组。如: # chown -R mysql.mysql /mydata/data/ (3)实战演示 (1)全量备份 #在master上进行全库备份#语法解释说明: [root@VM-0-15-centos backups]# innobackupex --user=root --password=rootroot --host=127.0.0.1 /data/backups/ #--user=root 指定备份用户 #--password=123456 指定备份用户密码 #--host 指定主机 #/backups 指定备份目录 # 进入到备份目录 [root@VM-0-15-centos 2021-02-17_19-26-01]# cd /data/backups [root@VM-0-15-centos backups]# ll total 4 drwxr-x--- 6 root root 4096 Feb 17 19:26 2021-02-17_19-26-01 [root@VM-0-15-centos backups]# cd 2021-02-17_19-26-01/ # 查看备份文件的列表 [root@VM-0-15-centos 2021-02-17_19-26-01]# ll total 75824 -rw-r----- 1 root root 424 Feb 17 19:26 backup-my.cnf #备份用到的配置选项信息文件 -rw-r----- 1 root root 291 Feb 17 19:26 ib_buffer_pool -rw-r----- 1 root root 77594624 Feb 17 19:26 ibdata1 #数据文件 drwxr-x--- 2 root root 4096 Feb 17 19:26 mysql drwxr-x--- 2 root root 4096 Feb 17 19:26 performance_schema drwxr-x--- 2 root root 4096 Feb 17 19:26 study drwxr-x--- 2 root root 12288 Feb 17 19:26 sys -rw-r----- 1 root root 22 Feb 17 19:26 xtrabackup_binlog_info #mysql服务器当前正在使用的二进制日志文件和此时二进制日志时间的位置信息文件 -rw-r----- 1 root root 113 Feb 17 19:26 xtrabackup_checkpoints #备份的类型、状态和LSN状态信息文件 -rw-r----- 1 root root 488 Feb 17 19:26 xtrabackup_info -rw-r----- 1 root root 2560 Feb 17 19:26 xtrabackup_logfile #备份的日志文件 (2)恢复 #停止slave上的mysql服务 [root@slave ~]# /etc/init.d/mysqld stop Shutting down MySQL.. SUCCESS! [root@slave tools]# yum install -y percona-xtrabackup-24-2.4.9-1.el7.x86_64.rpm #安装xtrabackup [root@master backups]# scp -r 2018-07-30_11-01-37/ root@192.168.56.12:/backups/ #从master上拷贝备份数据 [root@slave tools]# innobackupex --apply-log /backups/2018-07-30_11-01-37/ #合并数据,使数据文件处于一致性的状态 #在slave上删除原有的数据 [root@slave ~]# rm -rf /usr/local/mysql/data/ [root@slave ~]# vim /etc/my.cnf #配置my.cnf的数据目录路径,否则会报错,要和master一致 datadir=/usr/local/mysql/data #在slave上数据恢复 [root@slave ~]# innobackupex --copy-back /backups/2018-07-30_11-01-37/ 180729 23:32:03 innobackupex: Starting the copy-back operation 180729 23:32:08 completed OK! #看到completed OK就是恢复正常了 #slave上查看数据目录,可以看到数据已经恢复,但是属主会有问题,需要进行修改,所以一般使用mysql的运行用户进行恢复,否则需要进行修改属主和属组信息 [root@slave ~]# ll /usr/local/mysql/data/ total 188432 -rw-r----- 1 root root 79691776 Jul 29 23:32 ibdata1 -rw-r----- 1 root root 50331648 Jul 29 23:32 ib_logfile0 -rw-r----- 1 root root 50331648 Jul 29 23:32 ib_logfile1 -rw-r----- 1 root root 12582912 Jul 29 23:32 ibtmp1 drwxr-x--- 2 root root 20 Jul 29 23:32 kim drwxr-x--- 2 root root 4096 Jul 29 23:32 mysql drwxr-x--- 2 root root 4096 Jul 29 23:32 performance_schema drwxr-x--- 2 root root 20 Jul 29 23:32 repppp drwxr-x--- 2 root root 4096 Jul 29 23:32 wordpress -rw-r----- 1 root root 482 Jul 29 23:32 xtrabackup_info [root@slave ~]# chown -R mysql.mysql /usr/local/mysql/data/ #修改属主属组 [root@slave ~]# /etc/init.d/mysqld start #启动mysql Starting MySQL. SUCCESS! [root@slave ~]# mysql -uroot -p -e "show databases;" #查看数据,是否恢复 Enter password: +--------------------+ | Database | +--------------------+ | information_schema | | kim | | mysql | | performance_schema | | repppp | | wordpress | +--------------------+ 总结全库备份与恢复三步曲: a. innobackupex 全量备份,并指定备份目录路径; b. 在恢复前,需要使用 --apply-log 参数先进行合并数据文件,确保数据的一致性要求; c. 恢复时,直接使用 --copy-back 参数进行恢复,需要注意的是,在 my.cnf 中要指定数据文件目录的路径。 3、xtrabackup 增量备份与恢复 使用innobackupex进行增量备份,每个InnoDB的页面都会包含一个LSN信息,每当相关的数据发生改变,相关的页面的LSN就会自动增长。这正是InnoDB表可以进行增量备份的基础,即innobackupex通过备份上次完全备份之后发生改变的页面来实现。在进行增量备份时,首先要进行一次全量备份,第一次增量备份是基于全备的,之后的增量备份都是基于上一次的增量备份的,以此类推 要实现第一次增量备份,可以使用下面的命令进行 基于全量备份的增量备份与恢复 做一次增量备份(基于当前最新的全量备份) innobackupex --user=root --password=root --defaults-file=/etc/my.cnf --incremental /backups/ --incremental-basedir=/backups/2018-07-30_11-01-37 1. 准备基于全量 innobackupex --user=root --password=root --defaults-file=/etc/my.cnf --apply-log --redo-only /backups/2018-07-30_11-01-37 2. 准备基于增量 innobackupex --user=root --password=root --defaults-file=/etc/my.cnf --apply-log --redo-only /backups/2018-07-30_11-01-37 --incremental-dir=/backups/2018-07-30_13-51-47/ 3. 恢复 innobackupex --copy-back --defaults-file=/etc/my.cnf /opt/2017-01-05_11-04-55/ 解释: 1. 2018-07-30_11-01-37指的是完全备份所在的目录。 2. 2018-07-30_13-51-47指定是第一次基于2018-07-30_11-01-37增量备份的目录,其他类似以此类推,即如果有多次增量备份。每一次都要执行如上操作。 需要注意的是,增量备份仅能应用于InnoDB或XtraDB表,对于MyISAM表而言,执行增量备份时其实进行的是完全备份。 “准备”(prepare)增量备份与整理完全备份有着一些不同,尤其要注意的是: ①需要在每个备份 (包括完全和各个增量备份)上,将已经提交的事务进行"重放"。“重放"之后,所有的备份数据将合并到完全备份上。 ②基于所有的备份将未提交的事务进行"回滚” 实战演示: (1)增量备份演示 #全备数据 [root@master backups]# innobackupex --user=root --password=123456 --host=127.0.0.1 /backups/ #在master上创建student库并创建testtb表插入若干数据 [root@master ~]# mysql -uroot -p Enter password: mysql> create database student; Query OK, 1 row affected (0.03 sec) mysql> use student; Database changed mysql> create table testtb(id int); Query OK, 0 rows affected (0.07 sec) mysql> insert into testtb values(1),(10),(99); Query OK, 3 rows affected (0.04 sec) Records: 3 Duplicates: 0 Warnings: 0 mysql> select * from testtb; +------+ | id | +------+ | 1 | | 10 | | 99 | +------+ 3 rows in set (0.00 sec) mysql> quit; Bye #使用innobackupex进行增量备份 [root@master backups]# innobackupex --user=root --password=123456 --host=127.0.0.1 --incremental /backups/ --incremental-basedir=/backups/2018-07-30_11-01-37/ 180730 13:51:50 completed OK! # 执行结果是 ok 就是成功了 #查看备份数据 [root@master backups]# ll total 0 drwxr-x--- 7 root root 232 Jul 30 11:01 2018-07-30_11-01-37 #全量备份数据目录 drwxr-x--- 8 root root 273 Jul 30 13:51 2018-07-30_13-51-47 #增量备份数据目录 #查看全量备份的 [root@master 2018-07-30_11-01-37]# cat xtrabackup_checkpoints xtrabackup_checkpoints backup_type = full-backuped #备份类型为全量备份 from_lsn = 0 #lsn从0开始 to_lsn = 3127097 #lsn到3127097结束 last_lsn = 3127097 compact = 0 recover_binlog_info = 0 #查看增量备份的 [root@master 2018-07-30_13-51-47]# cat xtrabackup_checkpoints xtrabackup_checkpoints backup_type = incremental #备份类型为增量备份 from_lsn = 3127097 #lsn从3127097开始 to_lsn = 3158741 #lsn到啊3158741结束 last_lsn = 3158741 compact = 0 recover_binlog_info = 0 (2)增量备份后数据恢复演示 (1) 模拟mysql故障,删除数据目录所有数据 #模拟mysql故障,停止mysql [root@master ~]# /etc/init.d/mysqld stop #删除数据目录中的所有数据 [root@master ~]# rm -rf /usr/local/mysql/data/* (2)合并全备数据目录,确保数据的一致性 [root@master ~]# innobackupex --apply-log --redo-only /backups/2018-07-30_11-01-37/ (3)将增量备份数据合并到全备数据目录当中 [root@master ~]# innobackupex --apply-log --redo-only /backups/2018-07-30_11-01-37/ --incremental-dir=/backups/2018-07-30_13-51-47/ [root@master ~]# cat /backups/2018-07-30_11-01-37/xtrabackup_checkpoints backup_type = log-applied #查看到数据备份类型是增加 from_lsn = 0 #lsn从0开始 to_lsn = 3158741 #lsn结束号为最新的lsn last_lsn = 3158741 compact = 0 recover_binlog_info = 0 (4)恢复数据 [root@master ~]# innobackupex --copy-back /backups/2018-07-30_11-01-37/ [root@master ~]# ll /usr/local/mysql/data/ total 77844 -rw-r----- 1 root root 79691776 Jul 30 14:08 ibdata1 drwxr-x--- 2 root root 20 Jul 30 14:08 kim drwxr-x--- 2 root root 4096 Jul 30 14:08 mysql drwxr-x--- 2 root root 4096 Jul 30 14:08 performance_schema drwxr-x--- 2 root root 20 Jul 30 14:08 repppp drwxr-x--- 2 root root 56 Jul 30 14:08 student drwxr-x--- 2 root root 4096 Jul 30 14:08 wordpress -rw-r----- 1 root root 21 Jul 30 14:08 xtrabackup_binlog_pos_innodb -rw-r----- 1 root root 554 Jul 30 14:08 xtrabackup_info #更改数据的属主属组 [root@master ~]# chown -R mysql.mysql /usr/local/mysql/data #启动mysql [root@master ~]# /etc/init.d/mysqld start Starting MySQL.Logging to '/usr/local/mysql/data/master.err'. .. SUCCESS! #查看数据是否恢复 [root@master ~]# mysql -uroot -p -e "show databases;" Enter password: +--------------------+ | Database | +--------------------+ | information_schema | | kim | | mysql | | performance_schema | | repppp | | student | | wordpress | +--------------------+ 总结 (1)增量备份需要使用参数 --incremental 指定需要备份到哪个目录,使用 incremental-dir 指定全备目录; (2)进行数据备份时,需要使用参数 --apply-log redo-only 先合并全备数据目录数据,确保全备数据目录数据的一致性; (3)再将增量备份数据使用参数 --incremental-dir 合并到全备数据当中; (4)最后通过最后的全备数据进行恢复数据,注意,如果有多个增量备份,需要逐一合并到全备数据当中,再进行恢复。 关于阿里云RDS物理备份数据使用xtrabackup工具恢复到本地mysql当中, 请参考阿里云文档:https://help.aliyun.com/knowledge_detail/41817.html?spm=5176.11065259.1996646101.searchclickresult.53d420cclqekK3
前言 本文主要介绍三种冷备份的方式 暴力备份、mysqlsqldump、mydumper 说明 mysqldump 是mysql自身提供的工具,按照基本的命令即可使用,navicat中的导入导出用的就是这个 mydumper 是一个第三方工具,需要下载后安装,其速度是前者的10倍有余,但是会产生锁表问题 相关链接 mydumper官网:https://launchpad.net/mydumper 暴力备份 暴力备份就是将 mysql 的 data 目录整体备份。 需要注意的事,在备份和恢复的时候,停止mysql服务。等待完成后,在启动mysql服务,否则会由于 pid 的不一致,产生冲突等问题 mysqldump 工具 它的备份原理是通过协议连接到 MySQL 数据库,将需要备份的数据查询出来,将查询出的数据转换成对应的insert 语句,当我们需要还原这些数据时,只要执行这些 insert 语句,即可将对应的数据还原。 备份命令 1 命令格式 mysqldump [选项] 数据库名 [表名] > 脚本名 或 mysqldump [选项] --数据库名 [选项 表名] > 脚本名 或 mysqldump [选项] --all-databases [选项] > 脚本名 2 选项说明 参数名 缩写 含义 –host -h 服务器IP地址 –port -P 服务器端口号 –user -u MySQL 用户名 –pasword -p MySQL 密码 –databases 指定要备份的数据库 –all-databases 备份mysql服务器上的所有数据库 –compact 压缩模式,产生更少的输出 –comments 添加注释信息 –complete-insert 输出完成的插入语句 –lock-tables 备份前,锁定所有数据库表 –no-create-db/–no-create-info 禁止生成创建数据库语句 –force 当出现错误时仍然继续备份操作 –default-character-set 指定默认字符集 –add-locks 备份数据库表时锁定数据库表 3 实例 备份所有数据库: mysqldump -uroot -p --all-databases > /backup/mysqldump/all.db 备份指定数据库: mysqldump -uroot -p test > /backup/mysqldump/test.db 备份指定数据库指定表(多个表以空格间隔) mysqldump -uroot -p mysql db event > /backup/mysqldump/2table.db 备份指定数据库排除某些表 mysqldump -uroot -p test --ignore-table=test.t1 --ignore-table=test.t2 > /backup/mysqldump/test2.db 4、还原命令 4.1 系统行命令 mysqladmin -uroot -p create db_name mysql -uroot -p db_name < /backup/mysqldump/db_name.db 注:在导入备份数据库前,db_name如果没有,是需要创建的; 而且与db_name.db中数据库名是一样的才可以导入。 4.2 soure 方法 mysql > use db_name mysql > source /backup/mysqldump/db_name.db mydumper 工具 1、备份原理 1、主线程 FLUSH TABLES WITH READ LOCK, 施加全局只读锁,保证数据的一致性 2、读取当前时间点的二进制日志文件名和日志写入的位置并记录在metadata文件中,以供即使点恢复使用 3、N个(线程数可以指定,默认是4)dump线程把事务隔离级别改为可重复读 并开启读一致的事物 4、dump non-InnoDB tables, 首先导出非事物引擎的表 5、主线程 UNLOCK TABLES 非事物引擎备份完后,释放全局只读锁 6、dump InnoDB tables, 基于事物导出InnoDB表 7、事物结束 2、该工具介绍 Mydumper是一个针对MySQL和Drizzle的高性能多线程备份和恢复工具。 Mydumper主要特性: 轻量级C语言写的 多线程备份,备份后会生成多个备份文件 事务性和非事务性表一致的快照(适用于0.2.2以上版本) 快速的文件压缩 支持导出binlog 多线程恢复(适用于0.2.1以上版本) 以守护进程的工作方式,定时快照和连续二进制日志(适用于0.5.0以上版本) 开源 (GNU GPLv3) 3、安装步骤 mydumper使用c语言编写,使用glibc库 mydumper安装所依赖的软件包,glibc, zlib, pcre, pcre-devel, gcc, gcc-c++, cmake, make, mysql客户端库文件 1、安装依赖软件包,将mysql客户端库文件路径添加至/etc/ld.so.conf, 如/usr/local/mysql/lib 2、解压软件包进入目录,cmake 3、make && make install # 安装依赖软件包 [root@VM-0-15-centos local]# yum -y install glib2-devel mysql-devel zlib-devel pcre-devel zlib gcc-c++ gcc cmake # 我选择按照到了此目录 [root@VM-0-15-centos local]# pwd /usr/local # 下载软件 [root@VM-0-15-centos local]# wget https://launchpad.net/mydumper/0.9/0.9.1/+download/mydumper-0.9.1.tar.gz # 解压 tar 包 [root@VM-0-15-centos local]# tar zxf mydumper-0.9.1.tar.gz # 进入到解压目录 [root@VM-0-15-centos local]# cd mydumper-0.9.1/ # 进行cmake编译 [root@VM-0-15-centos mydumper-0.9.1]# cmake . # 进行编译和安装 [root@VM-0-15-centos mydumper-0.9.1]# make && make install #安装完成后生成两个二进制文件mydumper和myloader位于/usr/local/bin目录下 [root@VM-0-15-centos mydumper-0.9.1]# ls /usr/local/bin/ composer iconv libmcrypt-config mcrypt mdecrypt mydumper myloader mydumper 参数解释 -B, --database 要备份的数据库,不指定则备份所有库 -T, --tables-list 需要备份的表,名字用逗号隔开 -o, --outputdir 备份文件输出的目录 -s, --statement-size 生成的insert语句的字节数,默认1000000 -r, --rows 将表按行分块时,指定的块行数,指定这个选项会关闭 --chunk-filesize -F, --chunk-filesize 将表按大小分块时,指定的块大小,单位是 MB -c, --compress 压缩输出文件 -e, --build-empty-files 如果表数据是空,还是产生一个空文件(默认无数据则只有表结构文件) -x, --regex 是同正则表达式匹配 'db.table' -i, --ignore-engines 忽略的存储引擎,用都厚分割 -m, --no-schemas 不备份表结构 -k, --no-locks 不使用临时共享只读锁,使用这个选项会造成数据不一致 --less-locking 减少对InnoDB表的锁施加时间(这种模式的机制下文详解) -l, --long-query-guard 设定阻塞备份的长查询超时时间,单位是秒,默认是60秒(超时后默认mydumper将会退出) --kill-long-queries 杀掉长查询 (不退出) -b, --binlogs 导出binlog -D, --daemon 启用守护进程模式,守护进程模式以某个间隔不间断对数据库进行备份 -I, --snapshot-interval dump快照间隔时间,默认60s,需要在daemon模式下 -L, --logfile 使用的日志文件名(mydumper所产生的日志), 默认使用标准输出 --tz-utc 跨时区是使用的选项,不解释了 --skip-tz-utc 同上 --use-savepoints 使用savepoints来减少采集metadata所造成的锁时间,需要 SUPER 权限 --success-on-1146 Not increment error count and Warning instead of Critical in case of table doesn't exist -h, --host 连接的主机名 -u, --user 备份所使用的用户 -p, --password 密码 -P, --port 端口 -S, --socket 使用socket通信时的socket文件 -t, --threads 开启的备份线程数,默认是4 -C, --compress-protocol 压缩与mysql通信的数据 -V, --version 显示版本号 -v, --verbose 输出信息模式, 0 = silent, 1 = errors, 2 = warnings, 3 = info, 默认为 2 myloader 参数解释 -d, --directory 备份文件的文件夹 -q, --queries-per-transaction 每次事物执行的查询数量,默认是1000 -o, --overwrite-tables 如果要恢复的表存在,则先drop掉该表,使用该参数,需要备份时候要备份表结构 -B, --database 需要还原的数据库 -e, --enable-binlog 启用还原数据的二进制日志 -h, --host 主机 -u, --user 还原的用户 -p, --password 密码 -P, --port 端口 -S, --socket socket文件 -t, --threads 还原所使用的线程数,默认是4 -C, --compress-protocol 压缩协议 -V, --version 显示版本 -v, --verbose 输出模式, 0 = silent, 1 = errors, 2 = warnings, 3 = info, 默认为2 5、使用案例 备份 study 数据库库 到 /data/backup/mysql 文件夹中 # 备份命令 [root@VM-0-15-centos mysql]# mydumper -u root -p rootroot -P 3306 -h 127.0.0.1 -B study -o /data/backup/mysql/ # 查看备份内容 [root@VM-0-15-centos mysql]# ll total 16 # 记录了备份数据库在备份时间点的二进制日志文件名,日志的写入位置 -rw-r--r-- 1 root root 137 Feb 17 16:35 metadata # 库文件 -rw-r--r-- 1 root root 67 Feb 17 16:35 study-schema-create.sql # 表结构文件 -rw-r--r-- 1 root root 497 Feb 17 16:35 study.user_test-schema.sql # 表数据文件 -rw-r--r-- 1 root root 244 Feb 17 16:35 study.user_test.sql **如果是在从库进行备份,还会记录备份时同步至主库的二进制日志文件及写入位置 ** 恢复 study 数据库 # 先删除 study 数据库 mysql> drop database study; Query OK, 1 row affected (0.09 sec) # 恢复数据库 [root@VM-0-15-centos mysql]# myloader -u root -p rootroot -h 127.0.0.1 -B study -d /data/backup/mysql/ # 恢复成功 mysql> show databases; +--------------------+ | Database | +--------------------+ | information_schema | | mysql | | performance_schema | | study | | sys | +--------------------+ 5 rows in set (0.00 sec) 注意问题 在使用 mydumper 的时候,由于会产生一个全局锁防止写入,所以会导致备份过程总无法写入。 所以在使用的时候,要对用户提示一个短暂的维护提示
相关链接 MySQL 官网主从复制:https://dev.mysql.com/doc/refman/5.7/en/replication.html 优秀博文:https://blog.csdn.net/yhl_jxy/article/details/112486032 主从形式 一主一从 主主复制 一主多从—扩展系统读取的性能,因为读是在从库读取的; 多主一从—5.7开始支持 联级复制— 用途及条件 实时灾备,用于故障切换 读写分离,提供查询服务 备份,避免影响业务 主从部署必要条件: 主库开启binlog日志(设置log-bin参数) 主从server-id不同 从库服务器能连通主库 原理 过程描述 从库生成两个线程,一个 I/O 线程(I/O thread),一个 SQL 线程(SQL thread); 从库 I/O 线程连接主库发送请求到主库(传 pos,binlog event等参数),主库会生成一个 log dump 线程(dump thread),检查 binlog event, 根据从库要求,将 binlog 发送给 从库 I/O 线程,从库 I/O 线程将 binlog 写入 relay log(中继日志), 从库的 SQL 线程,会读取 relay log 文件中的日志,并解析成具体操作执行,实现主从最终数据一致; 概括 默认 MySQL 异步复制模式下,binlog 不是主库 dump thread 主动传给从库的,是从库 I/O 线程连接主库告诉主库发到什么 pos 的 binglog 给从库, 主库收到后,检查 binlog event,按需发送 binlog 给从库。这样处理是因为可能从库有多个,处理速度和性能不一样,从库根据自身情况 去触发获取 binlog 处理,各个从库互不影响,跟主库保持最终数据一致性 主从复制方式 1、异步复制 异步复制是 MySQL 默认的方式。在异步复制下,主库不会主动的向从库发送 binlog,而是等待从库的 I/O 线程建立连接, 从库 I/O 线程请求主库二进制日志事件(传pos等),然后主库创建dump线程,检查自己的 binlog event, 将对应位置的 binlog 发送给从库 I/O 线程,从库 I/O 线程将接收到的 binlog 写入到 relay log(中继日志) 中,从库开启 SQL 线程从 relay log 中刷入到从库磁盘,完成主从异步复制操作。 主库处理用户请求和主从复制是两个完全异步化的过程。 2、同步复制 同步模式则是,主库执行一个事务,那么主库必须等待所有的从库全部执行完事务返回 commit 之后才能给客户端返回成功。 主库会直接提交事务,而不是等待所有从库返回之后再提交。MySQL只是延迟了对客户端的返回,并没有延后事务的提交。 同步模式性能会大打折扣,它把客户端的请求和主从复制耦合在了一起,如果有某个从库复制线程执行的慢,那么对客户端的响应也会慢很多。 3、半同步复制 半同步相对于同步的区别在于,同步复制需要等待所有的从库 commit,而半同步只需要一个从库 commit 就可以返回了。 如果超过默认的时间仍然没有从库 commit,就会切换为异步模式再提交。客户端也不会一直去等待了。 因为即使后面主库宕机了,也能至少保证有一个从库节点是可以用的,此外还减少了同步时的等待时间。 4、并行复制 MySQL 并行复制 社区版5.6中新增 并行是指从库 SQL 线程负责转发到 多个 worker 线程去处理日志 库级别并行应用 binlog,同一个库数据更改还是串行的(5.7版并行复制基于事务组),一个事务发送到一个 worker 线程执行 设置 set global slave_parallel_workers=10; 设置 sql 线程数为10。 问题及解决方法 1、主库宕机后,数据可能丢失 半同步复制(解决数据丢失的问题) 2、从库只有一个 sql Thread,主库写压力大,复制很可能延时 并行复制(解决从库复制延迟的问题) 3、第一次搭建主从,数据库不存在问题 这个一般是第一次搭建主从的时候,从库存在的问题。需要从主库把数据复制过去,然后在从库恢复即可
1. 数据库设计三大范式 在设计关系数据库的时候,一般来说我们都是需要遵从不同的规范要求来设计出合理的关系型数据库,这些不同的规范要 求被称为不同的范式,各种范式呈递次规范,越高的范式数据库冗余越小。 范式分为:3大范式,以及BC范式,第四范式还有第五范式 一共六大范式通常来说满足与三大范式就基本足够 ; 注意:项目的数据库设计并不一定要完全满足与三大范式,有些时候我们会适量的冗余让Query尽两减少Join 误区:不是范式越高越就越好 好 => 结构清晰 早期:希望数据可以足够的小数据量不是问题主要分问题 现在:希望查询速度越快越好,同时操作越简单越好 1.1 第一范式(1NF) 简单地说,第一范式要求关系中的属性必须是原子项,即不可再分的基本类型,集合、数组和 结构不能作为某一属性出现,严禁关系中出现“表中有表”的情况在任何一个关系数据库系统中,第一范式是关系模 式的一个最起码的要求。不满足第一范式的数据库模式不能称为关系数据库。 原始表中,其中”工程地址”列还可以细分为省份,城市等。在国外,更多的程序把”姓名”列也分成2列,即”姓”和“名”。 虽然第一范式要求各列要保存原子性,不能再分,但是这种要求和我们的需求是相关联的,如上表中我们对”工程地址”没有省份,城市这样方面的查询和应用需求,则不需拆分,”姓名”列也是同样如此。 原始表: 工程号 工程名称 工程地址 员工编号 员工名称 薪资待遇 职务 P001 港珠澳大桥 广东珠海 E0001 Jack 6000/月 工人 P001 港珠澳大桥 广东珠海 E0002 Join 7800/月 工人 P001 港珠澳大桥 广东珠海 E0003 Apple 8000/月 高级技工 P002 南海航天 海南三亚 E0001 Jack 5000/月 工人 1.2 第二范式(2NF) 第二范式(2NF)是在第一范式(1NF)的基础建立起来的,既满足第二范式(2NF)就必须要 满足第一范式。第二范式(2NF)要求实体的属性完全依赖于主键字。 第二范式(2NF)要求实体的属性完全依赖于主关键字。所谓完全依赖是指不能存在仅依赖主关键字一部分的属性,如果存在,那么这个属性和主关键字的这一部分应该分离出来形成一个新的实体,新实体与原实体之间是一对多的关系。为实现区分通常需要为表加上一个列,以存储各个实例的唯一标识。简而言之,第二范式就是在第一范式的基础上属性完全依赖于主键。 例如:原始表中描述了工程信息,员工信息等。这样就造成了大量数据的重复。按照第二范式,我们可以将原始表分为工程信息表与员工信息表 工程信息表: 工程编号 工程名称 工程地址 P001 港珠澳大桥 广东珠海 P002 南海航天 海南三亚 员工信息表: 员工编号 员工姓名 职务 薪资水平 E0001 Jack 工人 3000/月 E0002 Join 工人 3000/月 E0003 Apple 高级技工 6000/月 1.3 第三范式(3NF) 第三范式(3NF)是第二范式的子集,既满足第三范式就必须满足第二范式。意思是不存在非 关键字段对任意候选关键字段的传递函数依赖 例如:现在我们来看看在第二范式的讲解中,我们将表1-1拆分成了两张表。这两个表是否符合第三范式呢。在员工信息表中包含:”员工编号”、”员工名称”、”职务”、”薪资水平”,而我们知道,薪资水平是有职务决定,这里”薪资水平”通过”职务”与员工相关,则不符合第三范式。我们需要将员工信息表进一步拆分,如下: 员工信息表: 员工编号 员工姓名 职务编号 E0001 Jack 1 E0002 Join 1 E0003 Apple 2 工程信息表: 工程编号 工程名称 工程地址 P001 港珠澳大桥 广东珠海 P002 南海航天 海南三亚 职务表(Duty) 职务编号 职务名称 工资待遇 1 工人 3000/月 2 高级技工 6000/月 工程参与人员记录表: 编号 工程编号 人员编号 1 P001 E0001 2 P001 E0002 3 P002 E0003 通过对比我们发现,表多了,关系复杂了,查询数据变的麻烦了,编程中的难度也提高了,但是各个表中内容更清晰了, 重复的数据少了,更新和维护变的更容易了。 不推荐存储的数据类型 二进制多媒体数据 将二进制多媒体数据存放在数据库中,一个问题是数据库空间资源耗用非常严重,另一个问题是 这些数据的存储很消耗数据库主机的CPU 资源。这种数据主要 包括图片,音频、视频和其他一些相关的二进制文件。 这些数据的处理本不是数据的优势,如果我们硬要将他们塞入数据库,肯定会造成数据库的处理资源消耗 严重。 流水队列数据 我们都知道,数据库为了保证事务的安全性(支持事务的存储引擎)以及可恢复性,都是需要记录所 有变更的日志信息的。而流水队列数据的用途就决定了存放 这种数据的表中的数据会不断的被 INSERT,UPDATE 和 DELETE,而每一个操作都会生成与之对应的日志信息。在 MySQL 中,如果是支持事务的存储引擎,这 个日志的产生量 更是要翻倍。而如果我们通过一些成熟的第三方队列软件来实现这个 Queue 数据的处理功能,性能将会成倍的提升。 超大文本数据 对于 5.0.3 之前的 MySQL 版本,VARCHAR 类型的数据最长只能存放 255 个字节,如果需要存储 更长的文本数据到一个字段,我们就必须使用 TEXT 类型(最大 可存放 64KB)的字段,甚至是更大的LONGTEXT 类型 (最大 4GB)。而 TEXT 类型数据的处理性能要远比 VARCHAR 类型数据的处理性能低下很多。从 5.0.3 版 本开始 ,VARCHAR 类型的最大长度被调整到 64KB 了,但是当实际数据小于 255Bytes 的时候,实际存储空间和实际的数据长 度一样,可一旦长度超过 255 Bytes 之后,所占用的存储空间就是实际数据长度的两倍。 对于图片的存储,如果说是 特殊情况可以使用BLOB,但是通常来说跟推介使用varchar存图片路径,而图片会放在一个文件夹中
前言 平常用 explain 就可以sql语句的性能了。但是,如果你觉得分析的不够详尽,你想查看内部的拆解过程,那么你可以试试用mysql优化器进行追踪分析 相关链接 explain 详解:https://blog.mailjob.net/posts/183747545.html skip_scan_range(MySQL 8.0的新特性):https://blog.csdn.net/weixin_43970890/article/details/89494915 操作方法 set optimizer_trace="enabled=on"; -- 开启trace查看优化器的结果 set end_markers_in_json=on; -- 增加注释 #sql query#; -- sql 执行语句 select * from information_schema.optimizer_trace \G; -- 查看分析结果 执行结果包含内容 1.join_preparation :准备阶段,包查询语句转换,转换成嵌套循环语句等 expanded_query transformations_to_nested_joins 2.join_optimization :优化阶段,包括以下主要阶段 condition_processing :处理where条件部分,主要包括等式处理、常量处理、多余条件处理 table_dependencies :表依赖检查 ref_optimizer_key_uses :评估可用的索引 rows_estimation :评估访问单表的方式,及扫描的行数与代价 considered_execution_plans :评估最终可使用的执行计划 condition_on_constant_tables :检查带常量表的条件 attaching_conditions_to_tables :将常量条件作用到表 refine_plan 改进计划,不理解 3.join_execution :执行阶段 优化器内容结果分析 explain 分析 以下 left` `join语句,d表与s表关联,当where条件在d.deptid上时,s表无法走索引。因此通过开启trace方式做一些追踪。 root@(none) 09:20:20>explain SELECT * FROM SSS.DEPARTMENT d LEFT JOIN ppp.shop s ON d.DEPTID = s.DEPTID WHERE d.DEPTID = '00001111'; +----+-------------+-------+------------+-------+----------------------------+---------+---------+-------+--------+----------+-------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+-------+----------------------------+---------+---------+-------+--------+----------+-------------+ | 1 | SIMPLE | d | NULL | const | PRIMARY,INDEX_DEPARTMENT_5 | PRIMARY | 130 | const | 1 | 100.00 | NULL | | 1 | SIMPLE | s | NULL | ALL | NULL | NULL | NULL | NULL | 978629 | 100.00 | Using where | +----+-------------+-------+------------+-------+----------------------------+---------+---------+-------+--------+----------+-------------+ 优化器分析 root@(none) 09:39:58> select * from information_schema.optimizer_trace\G; *************************** 1. row *************************** QUERY: SELECT * FROM SSS.DEPARTMENT d LEFT JOIN ppp.shop s ON d.DEPTID = s.DEPTID WHERE d.DEPTID = '00001111' TRACE: { "steps": [ #准备阶段 { "join_preparation": { "select#": 1, "steps": [ { #expanded_query,解析查询语句,"*" 转换成字段,left join on 处转化成on((`SSS`.`d`.`Deptid` = convert(`ppp`.`s`.`Deptid` using utf8mb4)))) "expanded_query": "/* select#1 */ select `SSS`.`d`.`Organid` AS `Organid`,。。。`s`.`Status` AS `Status`,`ppp`.`s`.`Stylecategoryid` AS `Stylecategoryid`,`ppp`.`s`.`Turnontime` AS `Turnontime` from (`SSS`.`department` `d` left join `ppp`.`shop` `s` on((`SSS`.`d`.`Deptid` = convert(`ppp`.`s`.`Deptid` using utf8mb4)))) where (`SSS`.`d`.`Deptid` = '00001111')" }, { #转化成的nested join语句: "transformations_to_nested_joins": { "transformations": [ "parenthesis_removal" ] /* transformations */, "expanded_query": "/* select#1 */ select `SSS`.`d`.`Organid`。。。 `SSS`.`d`.`Guidecode` AS `Guidecode`,`SSS`.`d`.`Createdate` AS `Createdate`,`SSS`.`d`.`Plateformuser` AS `Plateformuser`,`SSS`.`d`.`Plateformdept` AS `Plateformdept`,`SSS`.`d`.`Agentuser` AS `Agentuser`,`SSS`.`d`.`Agentdept` AS `Agentdept`,`SSS`.`d`.`Shopstatus` AS `Shopstatus`,`SSS`.`d`.`Deptshortname` AS `Deptshortname`,`SSS`.`d`.`Storetype` AS `Storetype`,`SSS`.`d`.`Depttype` AS `Depttype`,`ppp`.`s`.`Shopid` AS `Shopid`,`ppp`.`s`.`Objectid` AS `Objectid`,`ppp`.`s`.`Shopname` AS `Shopname`,`ppp`Tel`,`ppp`.`s`.`Introduce` AS `Introduce`,`ppp`.`s`.`Industry` AS `Industry`,`ppp`.`s`.`Address` AS `Address`,`ppp`.`s`.`Shop360image` AS `Shop360image`,`ppp`.`s`.`Domain` AS `Domain`,`ppp`.`s`.`Organid` AS `Organid`,`ppp`.`s`.`Deptid` AS `Deptid`,`ppp`.`s`.`Brandids` AS `Brandids`,`ppp`.`s`.`Extdata` AS `Extdata`,`ppp`.`s`.`Ranking` AS `Ranking`,`ppp`.`s`.`Isdelete` AS `Isdelete`,`ppp`.`s`.`District` AS `District`,`ppp`.`s`.`City` AS `City`,`ppp`.`s`.`Province` AS `Province`,`ppp`.`s`.`Phone` AS `Phone`,`ppp`.`s`.`Watermarkimage` AS `Watermarkimage`,`ppp`.`s`.`Drawingimage` AS `Drawingimage`,`ppp`.`s`.`Contactuser` AS `Contactuser`,`ppp`.`s`.`Panoloadingimage` AS `Panoloadingimage`,`ppp`.`s`.`Lngandlat` AS `Lngandlat`,`ppp`.`s`.`Createtime` AS `Createtime`,`ppp`.`s`.`Shoptype` AS `Shoptype`,`ppp`.`s`.`Status` AS `Status`,`ppp`.`s`.`Stylecategoryid` AS `Stylecategoryid`,`ppp`.`s`.`Turnontime` AS `Turnontime` from `SSS`.`department` `d` left join `ppp`.`shop` `s` on((`SSS`.`d`.`Deptid` = convert(`ppp`.`s`.`Deptid` using utf8mb4))) where (`SSS`.`d`.`Deptid` = '00001111')" } /* transformations_to_nested_joins */ } ] /* steps */ } /* join_preparation */ },#准备阶段结束 { #优化阶段: "join_optimization": { "select#": 1, "steps": [ { #处理where条件部分,化简条件: "condition_processing": { "condition": "WHERE", "original_condition": "(`SSS`.`d`.`Deptid` = '00001111')",---原始条件 "steps": [ { "transformation": "equality_propagation", ----等式处理 "resulting_condition": "(`SSS`.`d`.`Deptid` = '00001111')" }, { "transformation": "constant_propagation",-----常量处理 "resulting_condition": "(`SSS`.`d`.`Deptid` = '00001111')" }, { "transformation": "trivial_condition_removal",----去除多余无关的条件处理 "resulting_condition": "(`SSS`.`d`.`Deptid` = '00001111')" } ] /* steps */ } /* condition_processing */ },#结束,因为这里已经够简化了,所以三次处理后都是同样的。 { #替代产生的字段 "substitute_generated_columns": { } /* substitute_generated_columns */ }, { #表依赖关系检查 /* table:涉及的表名,如果有别名,也会展示出来 row_may_be_null:行是否可能为NULL,这里是指JOIN操作之后,这张表里的数据是不是可能为 NULL。如果语句中使用了LEFT JOIN,则后一张表的row_may_be_null会显示为true map_bit:表的映射编号,从0开始递增 depends_on_map_bits:依赖的映射表。主要是当使用STRAIGHT_JOIN强行控制连接顺序或者LEFT JOIN/RIGHT JOIN有顺序差别时,会在depends_on_map_bits中展示前置表的map_bit值。 */ "table_dependencies": [ { "table": "`SSS`.`department` `d`", ------表d "row_may_be_null": false, "map_bit": 0, "depends_on_map_bits": [ ] /* depends_on_map_bits */ }, { "table": "`ppp`.`shop` `s`", --------表s "row_may_be_null": true, "map_bit": 1, "depends_on_map_bits": [ 0 ] /* depends_on_map_bits */ } ] /* table_dependencies */ }, #表依赖关系检查结束 {#找出可使用索引的字段: "ref_optimizer_key_uses": [ { "table": "`SSS`.`department` `d`", "field": "Deptid", ----------可用的是Deptid "equals": "'00001111'", "null_rejecting": false --- }, { "table": "`SSS`.`department` `d`", "field": "Deptid", "equals": "'00001111'", "null_rejecting": false } ] /* ref_optimizer_key_uses */ }, {#评估每个表单表访问行数及相应代价。 "rows_estimation": [ { "table": "`SSS`.`department` `d`", "rows": 1, ---返回1行 "cost": 1, ---代价为1 "table_type": "const", ---d表使用的方式是const,即根据主键索引获取 "empty": false }, { "table": "`ppp`.`shop` `s`", "table_scan": { -------s表直接使用全表扫描 "rows": 978662, ------扫描978662行 "cost": 8109 ------代价为8109 } /* table_scan */ } ] /* rows_estimation */ }, {#评估执行计划,这里考虑两表连接(负责对比各可行计划的开销,并选择相对最优的执行计划) "considered_execution_plans": [ { "plan_prefix": [------------------执行计划的前缀,这里是d表,因为是left join 我认为指的应该是驱动表的意思 "`SSS`.`department` `d`" ] /* plan_prefix */, "table": "`ppp`.`shop` `s`", "best_access_path": {-------最优访问路径 "considered_access_paths": [考虑的访问路径 { "rows_to_scan": 978662,---扫描978662行 "access_type": "scan",--------全表扫描的方式 "resulting_rows": 978662, "cost": 203841,----------使用代价 "chosen": true-------选中 } ] /* considered_access_paths */ } /* best_access_path */, "condition_filtering_pct": 100,条件过滤率100%,指的是这里与上一个表进行行过滤的行数 "rows_for_plan": 978662,------执行计划的扫描行数978662 "cost_for_plan": 203841,-------执行计划的cost203841 "chosen": true---------选中 } ] /* considered_execution_plans */ }, {#检查带常量表的条件 "condition_on_constant_tables": "('00001111' = '00001111')", "condition_value": true }, { #将常量条件作用到表,这里主要是将d表的中的deptid条件作用到s表的deptid "attaching_conditions_to_tables": { "original_condition": "('00001111' = '00001111')", "attached_conditions_computation": [ ] /* attached_conditions_computation */, "attached_conditions_summary": [ { "table": "`ppp`.`shop` `s`", "attached": "<if>(is_not_null_compl(s), ('00001111' = convert(`ppp`.`s`.`Deptid` using utf8mb4)), true)" } ] /* attached_conditions_summary */ } /* attaching_conditions_to_tables */ }, { # 改善执行计划 "refine_plan": [ { "table": "`ppp`.`shop` `s`" } ] /* refine_plan */ } ] /* steps */ } /* join_optimization */ }, { "join_execution": { "select#": 1, "steps": [ ] /* steps */ } /* join_execution */ } ] /* steps */ } MISSING_BYTES_BEYOND_MAX_MEM_SIZE: 0 INSUFFICIENT_PRIVILEGES: 0 1 row in set (0.00 sec) rows_estimation 说明 table:表名 range_analysis: table_scan:如果全表扫描的话,需要扫描多少行(row,2838216),以及需要的代价(cost, 286799) potential_range_indexes:列出表中所有的索引并分析其是否可用。如果不可用的话,会列出不 可用的原因是什么;如果可用会列出索引中可用的字段; setup_range_conditions:如果有可下推的条件,则带条件考虑范围查询 group_index_range:当使用了GROUP BY或DISTINCT时,是否有合适的索引可用。当未使用GROUP BY或DISTINCT时,会显示chosen=false, cause=not_group_by_or_distinct;如使用了GROUP BY或 DISTINCT,但是多表查询时,会显示chosen=false,cause =not_single_table。其他情况下会尝试 分析可用的索引(potential_group_range_indexes)并计算对应的扫描行数及其所需代价 skip_scan_range:是否使用了skip scan TIPS skip_scan_range是MySQL 8.0的新特性,感兴趣的可详见 https://blog.csdn.net/weixin_43970890/article/details/89494915 analyzing_range_alternatives:分析各个索引的使用成本 range_scan_alternatives:range扫描分析 index:索引名 ranges:range扫描的条件范围 index_dives_for_eq_ranges:是否使用了index dive,该值会被参数 eq_range_index_dive_limit变量值影响。 rowid_ordered:该range扫描的结果集是否根据PK值进行排序 using_mrr:是否使用了mrr index_only:表示是否使用了覆盖索引 rows:扫描的行数 cost:索引的使用成本 chosen:表示是否使用了该索引 analyzing_roworder_intersect:分析是否使用了索引合并(index merge),如果未使用, 会在cause中展示原因;如果使用了索引合并,会在该部分展示索引合并的代价。 chosen_range_access_summary:在前一个步骤中分析了各类索引使用的方法及代价,得出了 一定的中间结果之后,在summary阶段汇总前一阶段的中间结果确认最后的方案 range_access_plan:range扫描最终选择的执行计划。 type:展示执行计划的type,如果使用了索引合并,则会显示index_roworder_intersect index:索引名 rows:扫描的行数 ranges:range扫描的条件范围 rows_for_plan:该执行计划的扫描行数 cost_for_plan:该执行计划的执行代价 chosen:是否选择该执行计划 considered_execution_plans 说明 plan_prefix:当前计划的前置执行计划。 table:涉及的表名,如果有别名,也会展示出来 best_access_path:通过对比considered_access_paths,选择一个最优的访问路径 considered_access_paths:当前考虑的访问路径 access_type:使用索引的方式,可参考explain中的type字段 index:索引 rows:行数 cost:开销 chosen:是否选用这种执行路径 condition_filtering_pct:类似于explain的filtered列,是一个估算值 rows_for_plan:执行计划最终的扫描行数,由considered_access_paths.rows X condition_filtering_pct计算获得。 cost_for_plan:执行计划的代价,由considered_access_paths.cost相加获得 chosen:是否选择了该执行计划 attaching_conditions_to_tables 基于considered_execution_plans中选择的执行计划,改造原有where条件,并针对表增加适当的附 加条件,以便于单表数据的筛选。 **TIPS ** 这部分条件的增加主要是为了便于ICP(索引条件下推),但ICP是否开启并不影响这部 分内容的构造。 ICP参考文档:https://www.cnblogs.com/Terry-Wu/p/9273177.html attaching_conditions_to_tables 说明 original_condition:原始的条件语句 attached_conditions_computation:使用启发式算法计算已使用的索引,如果已使用的索引的访问 类型是ref,则计算用range能否使用组合索引中更多的列,如果可以,则用range的方式替换ref。 attached_conditions_summary:附加之后的情况汇总 table:表名 attached:附加的条件或原语句中能直接下推给单表筛选的条件。 finalizing_table_conditions 说明 最终的、经过优化后的表条件 { "finalizing_table_conditions": [ { "table": "`salaries`", "original_table_condition": "((`salaries`.`to_date` = DATE'1987-06-26') and (`salaries`.`from_date` = DATE'1986-06-26'))", "final_table_condition ": null } ] /* finalizing_table_conditions */ }
IO流程 IO流程说明 首先就是用户发送一条SQL通过客户端接收之后,交由解析器解析SQL创建对应的解析树之后 然后优化获取对应的数据表的信息-结构 获取表中对应的数据表,首先就会去缓存中读取索引的如果没有就会通过IO读取在磁盘中记录索引的信息并返回 选择合适的索引:因为一个表会有很多的索引,MySQL会对于每一个索引进行相应的算法推敲然后再做相应的删选留下最为合适的索引,所以如果说索引的 数量多的话会给查询优化器带来一定的负担。 因为在当前的索引为二级索引所以这个时候就会根据二级索引的btree获取到对应的id 读取到所对应的id之后再通过回表查询 根据主键索引获取到对应的数据的页在磁盘中的位置 在获取数据之前会判断索引缓存的数据是否满足查询,然后再判断数据库缓冲池以及读缓冲区中是否有缓冲,如果有就返回。没有就会去执行对应的执行计 划,从磁盘中获取数据信息 Hint:可以理解为SQL中的一个优化标识,在优化器中如果对于一条语句分析完了 IO流程图例表示 IO写入流程与方式 MySQL支持用户自定义在commit时如何将log buffer中的日志刷log file中。这种控制通过变量 innodb_flush_log_at_trx_commit 的值来决定。该变量有3种值:0、1、2,默认为1。 但注意,这个变量只是控制commit动作是否刷新log buffer到磁盘。 innodb_flush_log_at_trx_commit 配置 innodb_flush_log_at_trx_commit = 0 innodb中的log thread每隔一秒钟将会log buffer中的数据写入文件,同时还会通知文件系统进行与文件同步的flush操作,保证数据确实已经写入磁盘。但是,每次事务的结束(commit或者rollback)并不会触发log thread将log buffer中的数据写入文件。所以当设置为0时候,在mysql crash或者oscrash或者主机断电的情况,最极端的情况是丢失一秒的数据变更。 innodb_flush_log_at_trx_commit = 1 这也是innodb默认设置,每次事务的结束都会触发log thread将log buffer中的数据写入文件,并通知文件系统同步文件。这个设置是最安全的,能够保证不论是mysql crash,os crash还是主机断电都不会丢失任何已经提交的事务。 innodb_flush_log_at_trx_commit = 2 log thread会在每次事务结束后将数据写入事务日志,但是仅仅是调用了文件系统的写入操作,而文件系统都是有缓存的,所以log thread的写入并不能保证将文件系统中缓存写入到物理磁盘进行永久固化。文件系统什么时候将缓存中的数据同步到物理磁盘,log thread 并不知道。所以当设置为2的时候,mysql 的吵嚷声并不会造成数据的丢失,但是os crash或者主机断电可能造成事务日志的丢失,各种文件系统对文件缓存的刷新机制各不相同。 日志刷盘的规则 log buffer中未刷到磁盘的日志称为脏日志(dirty log)。 在上面的说过,默认情况下事务每次提交的时候都会刷事务日志到磁盘中,这是因为变量 innodb_flush_log_at_trx_commit 的值为1。但是innodb不仅仅只会在有commit动作后才会刷日志到磁盘,这只是innodb存储引擎刷日志的规则之一。 刷日志到磁盘有以下几种规则: 1.发出commit动作时。已经说明过,commit发出后是否刷日志由变量 innodb_flush_log_at_trx_commit 控制。 2.每秒刷一次。这个刷日志的频率由变量 innodb_flush_log_at_timeout 值决定,默认是1秒。要注意,这个刷日志频率和commit动作无关。 3.当log buffer中已经使用的内存超过一半时。 4.当有checkpoint时,checkpoint在一定程度上代表了刷到磁盘时日志所处的LSN位置。 Tips: 有一个变量 innodb_flush_log_at_timeout 的值为1秒,该变量表示的是刷日志的频率,很多人误以为是控制 innodb_flush_log_at_trx_commit 值为0和2时的1秒频率,实际上并非如此。测试时将频率设置为5和设置为1,当 innodb_flush_log_at_trx_commit 设置为0和2的时候性能基本都是不变的。 总结 设置为1时是最安全的,但由于所作的io同步操作最多,性能也是三种当中最差的; 如果设置为0,则每秒同步一次,性能相对高些, 如果设置为2,性能可能是这三种中最好的,但也有可能会出现故障后丢失的数据最多的。 至于具体应该如何设置,一般来说,如果不能完全接受数据的丢失,那可以通过牺牲一定的性能来换取数据的安全性,选择设置为1,如果允许丢失少量的数据(比如说1秒内),那么设置为0, 当然如果操作系统够稳定,主机的硬件设备足够好的话,而且主机的供电系统也足够安全的话,那么可以将innodb_flush_log_at_trx_commit=2,保证系统的高性能。
带着问题来学习 MVCC是为了解决哪些问题 ? 并发访问(读或写)数据库时,对正在事务内处理的数据做多版本的管理。我们知道锁机制可以用来控制并发操作,但是其系统开销较大。而MVCC可以在大多数情况下代替行级锁,使用MVCC可以降低系统开销。 MVCC是如何实现的 ? MVCC通过保存数据在某个时间点的快照来实现。不同存储引擎的MVCC实现是不同的。当我们创建表之后,mysql会自动为每个表添加数据版本号(最后更新数据的事务id)和删除版本号(数据删除的事务id),事务id由mysql数据库自动生成,且递增。 在哪些隔离级别下实现了mvcc ? RR 和 RC 隔离级别都实现了 MVCC 来满足读写并行。 两者相同点: 它们读取的都是快照数据,并不会被写操作阻塞,所以这种读操作称为 快照读(Snapshot Read)。 两者不同点: RC每次读取数据前都生成一个ReadView。RR在第一次读取数据时生成一个ReadView。所以,RC 总是读取记录的最新版本,如果该记录被锁住,则读取该记录最新的一次快照,而 RR 是读取该记录事务开始时的那个版本。 在RU和Serializable为什么没有mvcc ? 在 RU 隔离级别下,每次都是读取最新版本的数据行,所以不能用 MVCC 的多版本,而 Serializable 隔离级别每次读取操作都会为记录加上读锁,也和 MVCC 不兼容,所以只有 RC 和 RR 这两个隔离级别才有 MVCC。 参考文献 mvcc思维导图: https://kdocs.cn/l/sd1gXtGXheTz mvcc 多事务执行excel:https://kdocs.cn/l/se4n2ocRoaU1 mvcc 多事务执行版本链:https://kdocs.cn/l/sb2gsOmOQwP8 优秀博文:https://zhuanlan.zhihu.com/p/117476959 基本概念 当前读(Current Read) 像select lock in share mode(共享锁), select for update ; update, insert ,delete(排他锁) 这些操作都是一种当前读,为什么叫当前读?就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。 快照读(Snapshot Read) 像不加锁的select操作就是快照读,即不加锁的非阻塞读;快照读的实现是基于多版本并发控制(mvcc)。可以认为MVCC是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销;既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。 MVCC数据修改过程: 每行数据都存在一个版本,每次数据更新时都更新该版本。 修改时Copy出当前版本随意修改,个事务之间无干扰。 保存时比较版本号,如果成功(commit),则覆盖原记录;失败则放弃copy(rollback)。 MVCC数据修改和悲观锁数据修改对比 InnoDB的悲观锁数据修改过程是:事务以排他锁的形式修改原始数据,把修改前的数据存放于undo log,通过回滚指针与主数据关联,修改成功(commit)啥都不做,失败则恢复undo log中的数据(rollback)。 总上来看,二者最本质的区别是,当修改数据时是否要排他锁定,如果锁定了还算不算是MVCC呢? Innodb的实现真算不上MVCC,因为并没有实现核心的多版本共存,undo log中的内容只是串行化的结果,记录了多个事务的过程,不属于多版本共存。但理想的MVCC是难以实现的,当事务仅修改一行记录使用理想的MVCC模式是没有问题的,可以通过比较版本号进行回滚;但当事务影响到多行数据时,理想的MVCC据无能为力了。 比如,如果 T1 执行理想的MVCC,修改Row1成功,而修改Row2失败,此时需要回滚Row1,但因为Row1没有被锁定,其数据可能又被 T2 所修改,如果此时回滚Row1的内容,则会破坏 T2 的修改结果,导致 T2 违反ACID。 理想MVCC难以实现的根本原因在于企图通过乐观锁代替二段提交。修改两行数据,但为了保证其一致性,与修改两个分布式系统中的数据并无区别,而二提交是目前这种场景保证一致性的唯一手段。二段提交的本质是锁定,乐观锁的本质是消除锁定,二者矛盾,故理想的MVCC难以真正在实际中被应用,Innodb只是借了MVCC这个名字,提供了读的非阻塞而已。 事务视图(ReadView)详解 ReadView上的几个参数: m_ids:表示在生成ReadView时当前系统中活跃的读写事务的事务id列表。 min_trx_id:表示在生成ReadView时当前系统中活跃的读写事务中最小的事务id,也就是m_ids中的最小值。 max_trx_id:表示生成ReadView时系统中应该分配给下一个事务的id值。 creator_trx_id:表示生成该ReadView的事务的事务id。 小贴士1: 注意max_trx_id并不是m_ids中的最大值,事务id是递增分配的。比方说现在有id为1,2,3这三个事务,之后id为3的事务提交了。那么一个新的读事务在生成ReadView时,m_ids就包括1和2,min_trx_id的值就是1,max_trx_id的值就是4。 小贴士2: 我们前边说过,只有在对表中的记录做改动时(执行INSERT、DELETE、UPDATE这些语句时)才会为事务分配事务id,否则在一个只读事务中的事务id值都默认为0。 根据ReadView判断可见性: 如果被访问版本的trx_id属性值与ReadView中的creator_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。 如果被访问版本的trx_id属性值小于ReadView中的min_trx_id值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。 如果被访问版本的trx_id属性值大于或等于ReadView中的max_trx_id值,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。 如果被访问版本的trx_id属性值在ReadView的min_trx_id和max_trx_id之间,那就需要判断一下trx_id属性值是不是在m_ids列表中,如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。 如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。 undolog版本链详解 图示:https://kdocs.cn/l/sjj4G4Mjce4q undo-log 版本链字段解释 row trx_id 数据的版本表示字段,用来标识数据的版本号 transaction id 事务ID,它在事务开始的时候向事务系统申请,按时间先后顺序递增 roll_pointer 指向到 undo-log 中的指针 图例过程演示 在 A (还未开始)的时候,roll_pointer 指向一个空的 undo log,因为之前这条数据是没有的。 修改的时候执行的流程如下: 1、用 排它锁 锁定这一行的数据 2、对于要修改的数据,生成 undo-log 3、把指针指向到 undo-log (便于数据回滚) 4、进行修改数据 5、把修改的数据指针指向到 row trx_id 中 以时间轴角度理解 undo log 版本链: 总结: 第一次的时候,因为是新插入的数据,没有指向一个空的 undo log 往后开始,每次指向的 undo log 都是修改前的数据生成的 undo log 案例分析mvcc机制 4个事务的执行流程 以上事务对应的 undo log 链如下 说明: 1、每个 trx_id 由 excel 中的事务序号标识(这样假设的话更好理解) 2、事务4,这里先不作体现,后续演示版本比较的时候再说明 事务4执行时,如何寻找 undo-log 当事务4执行的时候,对应的undo-log链如下: 事务4第一个update开始执行: 因为事务1、事务3 还没有 commit , 所以得到的 max trx id = 3 min trx id = 1 得到的版本链数组是 [1,3] 所以事务4这样开始判断 redo log 链来读取数据: 如果当前的事务id,小于undo log 链id,说明这个事务,比最早的事务早,则可以读 trx_id = 4 开始和 trx_id = 3 开始对比,判断出,4不比3小,其次存在于版本链数组中,则不可读 trx_id = 4 开始和 trx_id = 2 开始对比,判断出,4不比2小,但是 trx_id = 2 不存在与存在于版本链数组中,则可读,读到的是 name = B 事务4第二个update开始执行: 依据上面的演示,则可以推导出,读取的是 trx_id = 3 的数据 总结 对于一个事务视图(ReadView)来说,它能够读到那些版本数据,要遵循以下规则: 当前事务内的更新,可以读到; 版本未提交,不能读到; 版本已提交,但是却在快照创建后提交的,不能读到; 版本已提交,且是在快照创建前提交的,可以读到; 对于 undo log 版本链的判断,存在以下规则 事务id < 未提交事务的最小id:可读 最小id <= 事务id <= 事务的最大id:则判断事务id是否在未提交事务id的数组中,若在则不可读(只有自己可读) 事务id > 事务的最大id:则不可读
前言 本文所说的 MySQL 事务都是指在 InnoDB 引擎下,MyISAM 引擎是不支持事务的。 读未提交和串行化基本上是不需要考虑的隔离级别,前者不加锁限制。串行化相当于单线程执行,效率太差。 读提交解决了脏读问题,行锁解决了并发更新的问题。并且 MySQL 在可重复读级别解决了幻读问题,是通过行锁和间隙锁的组合 Next-Key 锁实现的。 参考文献 事务4大隔离级别(思维导图):https://kdocs.cn/l/ssTeFXsem1JU 优秀博文:https://zhuanlan.zhihu.com/p/117476959 基础概念 事务隔离级别产生的问题对比 事务隔离级别 脏读 不可重复读 幻读 加锁读 读未提交(read-uncommitted) 是 是 是 否 不可重复读(read-committed) 否 是 是 否 可重复读(repeatable-read) 否 否 是 否 串行化(serializable) 否 否 否 是 产生的问题名次解释 1、脏读 事务A读到了事务B还没有提交的数据 比如银行取钱,事务A开启事务,此时切换到事务B,事务B开启事务–>取走100元,此时切换回事务A,事务A读取的肯定是数据库里面的原始数据,因为事务B取走了100块钱,并没有提交,数据库里面的账务余额肯定还是原始余额,这就是脏读。 2、不可重复读 在一个事务里面读取了两次某个数据,读出来的数据不一致 还是以银行取钱为例,事务A开启事务–>查出银行卡余额为1000元,此时切换到事务B事务B开启事务–>事务B取走100元–>提交,数据库里面余额变为900元,此时切换回事务A,事务A再查一次查出账户余额为900元,这样对事务A而言,在同一个事务内两次读取账户余额数据不一致,这就是不可重复读。 3、幻读 在一个事务里面的操作中发现了未被操作的数据 比如学生信息,事务A开启事务–>修改所有学生当天签到状况为false,此时切换到事务B,事务B开启事务–>事务B插入了一条学生数据,此时切换回事务A,事务A提交的时候发现了一条自己没有修改过的数据,这就是幻读,就好像发生了幻觉一样。幻读出现的前提是并发的事务中有事务发生了插入、删除操作。 事务的隔离级别示例: 读未提交(READ_UNCOMMITTED) 即能够读取到没有被提交的数据 举例说明: 启动两个事务,分别为事务A和事务B。在事务A中使用 update 语句,修改 age 的值为10,初始是1。在执行完 update 语句之后。在事务B中查询 user 表,会看到 age 的值已经是 10 了,这时候事务A还没有提交(commit)。 在事务B进行操作的过程中,很有可能事务A由于某些原因,进行了事务回滚操作,那其实事务B得到的就是脏数据了,拿着脏数据去进行其他的计算,那结果肯定也是有问题的 顺着时间轴往表示两事务中操作的执行顺序,重点看图中 age 字段的值。 读已提交(READ_COMMITED ) 在一个事务里面读取了两次某个数据,读出来的数据不一致 举例说明: 启动两个事务,分别为事务A和事务B。在事务A中使用 update 语句将 id=1 的记录行 age 字段改为 10。此时,在事务B中使用 select 语句进行查询,我们发现在事务A提交之前,事务B中查询到的记录 age 一直是1,直到事务A提交,此时在事务B中 select 查询,发现 age 的值已经是 10 了。 这就出现了一个问题,在同一事务中(本例中的事务B),事务的不同时刻同样的查询条件,查询出来的记录内容是不一样的,事务A的提交影响了事务B的查询结果,这就是不可重复读,也就是读提交隔离级别。 每个 select 语句都有自己的一份快照,而不是一个事务一份,所以在不同的时刻,查询出来的数据可能是不一致的。 读提交解决了脏读的问题,但是无法做到可重复读,也没办法解决幻读。 可重复读取(REPEATABLE_READ) 在一个事务里面的操作中发现了未被操作的数据 举例说明: RR 隔离级别下,如果只是读的话,不会产生脏读、不可重复读、幻读。如果事务中有写 的话,则会产生幻读问题。下面,我通过两个案例,对读和写,进行隔离级别的分析 。 读 Read 事务A启动后修改了数据,并且在事务B之前提交。事务B在事务开始和事务A提交之后两个时间节点都读取的数据相同,已经可以看出可重复读的效果。 写 Write (幻读产生过程) 事务A开始后,执行 update 操作,将 age = 1 的记录的 name 改为 “风筝2号”; 事务B开始后,在事务执行完 update 后,执行 insert 操作,插入记录 age =1,name = 古时的风筝。 这和事务A修改的那条记录值相同,然后提交。 事务B提交后,事务A中执行 select,查询 age=1 的数据。 这时会发现多了一行,并且发现还有一条 name = 古时的风筝,age = 1 的记录,这其实就是事务B刚刚插入的,这就是幻读。 需要说明的是:当你在 MySQL 中测试幻读的时候,并不会出现图中的结果,幻读并没有发生,MySQL 的可重复读(RR)隔离级别其实解决了幻读问题(可阅读MVCC相关文章) 串行化(SERLALIZABLE) 最高的事务隔离级别,不管多少事务,挨个运行完一个事务的所有子事务之后才可以执行另外一个事务里面的所有子事务,这样就解决了脏读、不可重复读和幻读的问题了 但是效果最差,它将事务的执行变为顺序执行,与其他三个隔离级别相比,它就相当于单线程,后一个事务的执行必须等待前一个事务结束。
前言 本文所说的 MySQL 事务都是指在 InnoDB 引擎下,MyISAM 引擎是不支持事务的。 相关链接 事务4大特性ACID(思维导图):https://kdocs.cn/l/svHrg3qhhcfo 事务的特性(ACID) 名称 英文名称 描述 原子性 Atomicity 语句要么全执行,要么全不执行,是事务最核心的特性,事务本身就是以原子性来定义的。实现主要基于undo log日志 一致性 Consistency 指事务必须使数据库从一个一致性状态变换到另一个一致性状态,也就是说一个事务执行之前和执行之后都必须处于一致性状态 隔离性 Isolation 保证事务执行尽可能不受其他事务影响。InnoDB默认的隔离级别是RR,RR的实现主要基于锁机制、数据的隐藏列、undo log和类next-key lock机制 持久性 Durability 指一个事务一旦被提交了,那么对数据库中的数据的改变就是永久性的,即便是在数据库系统遇到故障的情况下也不会丢失提交事务的操作
锁的基本概念 1、什么是锁? 在数据库中,锁主要用于解决并发访问时保证数据的一致性和有效性。 锁是计算机协调多个进程或线程并发访问某一资源的机制。 2、锁的区分 3.1、按照锁的粒度划分:行锁(Record Lock)、表锁(table lock)、页锁(page lock)。 3.2、按照锁的使用方式划分(悲观锁的一种实现):共享锁(S Lock)、排它锁(X Lock)。 3.3、还有两种思想上的锁:悲观锁(PCC)、乐观锁(OCC)。 3.4、InnoDB中有几种行级锁类型:行锁(Record Lock)、间隙锁(Gap Lock)、后码锁(Next-key Lock)。 部分名次解释: 行锁:锁直接加在索引记录上面,锁住的是key。 间隙锁:锁定索引记录间隙,确保索引记录的间隙不变。间隙锁是针对事务隔离级别为可重复读或以上级别而已的。 后码锁:行锁和间隙锁组合起来就叫Next-Key Lock。当InnoDB扫描索引记录的时候,会首先对索引记录加上行锁(Record Lock),再对索引记录两边的间隙加上间隙锁(Gap Lock)。加上间隙锁之后,其他事务就不能在这个间隙修改或者插入记录。 页锁:BDB存储引擎支持页级锁(不常用)。页级锁是MySQL中锁定粒度介于行级锁和表级锁中间的一种锁。表级锁速度快,但冲突多,行级冲突少,但速度慢。所以取了折衷的页级,一次锁定相邻的一组记录。 3、悲观锁的分类 InnoDB 存储引擎实现了如下两种标准的行级锁: 共享锁(S Lock)( lock in share mode) 允许事务读一行数据。 共享锁也叫 读锁,允许持有锁的事务读取一行。即不能进行写操作来提供一致性读取。如果资源上没有写锁,事务可以立即获得读锁,多个事务可以在同时获得读锁,如果读锁没有释放,写锁不能被获取,写事务只能放入等待队列。 若事务 T 对数据对象 A 加上 S 锁,则事务 T 可以读 A 但不能修改 A。共享锁就是允许多个线程同时获取一个锁,一个锁可以同时被多个线程拥有 select ... lock in share mode; 排他锁(X Lock)( for update) 允许事务删除或者更新一行数据。 排它锁也叫 写锁 ,允许持有锁的事务更新或者删除行。在一定的时间范围内,只能存在一个写锁。 写锁的优先级高于读锁。当一个资源上没有锁时,或者所有的锁请求都在等待队列中, 若事务T对数据对象A加上X锁,事务T可以读A也可以修改A,其他事务不能再对A加任何锁,直到T释放A上的锁。这保证了其他事务在T释放A上的锁之前不能再读取和修改A。排它锁,也称作独占锁,一个锁在某一时刻只能被一个线程占有,其它线程必须等待锁被释放之后才可能获取到锁。 select ... for update 下面是锁的授予方式: 首先将锁授予写锁队列中等待的请求; 如果写锁队列中没有对资源的锁请求,那么将锁授予读锁队列中的第一个请求。 区别总结: 相同点: 都是属于悲观锁(PCC) for update 与 lock in share mode 都是用于确保被选中的记录值不能被其它事务更新(上锁) 不同点: lock in share mode 不会阻塞其它事务读取被锁定行记录的值,而 for update 会阻塞其他锁定性读对锁定行的读取(非锁定性读仍然可以读取这些记录,lock in share mode 和 for update 都是锁定性读) 举例说明: 这么说比较抽象,我们举个计数器的例子:在一条语句中读取一个值,然后在另一条语句中(UPDATE table SET num=num+1 WHERE id=x)更新这个值。使用 lock in share mode 的话可以允许两个事务读取相同的初始化值,所以执行两个事务之后最终计数器的值+1;而如果使用 for update 的话,会锁定第二个事务对记录值的读取直到第一个事务执行完成,这样计数器的最终结果就是+2了。 4、Lock 与 Latch 在数据库中,lock 与 latch 都可以被称之为 “锁”,但是两者的意义截然不同,本文主要关注 lock : **Latch: **latch一般称之为闩锁(轻量级的锁)。因为其要求锁定的时间必须非常短。若持续的时间比较长,则性能会非常差。在InnoDB引擎中,latch又可以分为 mutex(互斥锁)和 rwlock(读写锁)。其目的用于保证并发线程操作临界资源的正确性,并且通常没有死锁检测的机制。 Lock:lock的对象是事务,用于锁定数据库中的对象,例如:表、页、行。并且一般 lock 的对象仅在事务 commit 或者 rollback 后进行释放(不同隔离级别释放的时间可能不同)。此外,lock 正入其他大多数数据库意义,是有死锁机制的。 测试示例 1、悲观锁(PCC)测试: 测试注意事项: 需要关闭 mysql 中的 autocommit 属性,因为 mysql 默认使用自动提交模式,也就是说当我们进行一个sql操作的时候,mysql会将这个操作当做一个事务并且自动提交这个操作。 -- 开始事务 begin; / begin work; / start transaction; (三者选一就可以) -- 查询出商品信息(加一个锁) select ... for update; -- 提交事务(则会释放锁) commit; / commit work; 1.1、间隙锁(Gap Lock)测试 间隙锁,是在索引的间隙之间加上锁,这是为什么 RR隔离级别 下能防止幻读 的主要原因。 当我们用范围条件检索数据,并请求共享或排他锁时,InnoDB会给符合条件的已有数据记录的索引项加锁;对于键值在条件范围内但不存在的记录,叫做“间隙(GAP)”,InnoDB也会对这个“间隙”加锁,这种锁机制就是所谓的间隙锁(NEXT-KEY)锁。 危害: 因为Query执行过程中通过范围查找的话,他会锁定整个范围内所有的索引键值,即使这个键值并不存在。 间隙锁有一个比较致命的弱点,就是当锁定一个范围键值之后,即使某些不存在的键值也会被无辜的锁定,而造成在锁定的时候无法插入锁定值范围内的任何数据,在某些场景下这可能会针对性造成很大的危害。 数据表此时的数据如下: mysql> select * from user; +----+------------+------+ | id | username | age | +----+------------+------+ | 1 | ZAAAA York | 20 | | 2 | starsky | 20 | | 4 | will | 10 | | 5 | harry | 10 | | 7 | cara | 30 | | 8 | AAAA | 40 | +----+------------+------+ 6 rows in set (0.00 sec) 注意表中的数据,ID包含 【1,2,4,5,7,8】 缺少【3,6】。此处 id 不连续 -- 事务1 select * from user id between 1 and 5 for update -- 事务2 insert into user (id,username,age)values(3,'php',30) 可以看到,事务2在执行新增 id 为 2的数据时出现了所等待现象。说明id为2的数据被事务1进行的范围查询加锁锁住,其他事务需要等到事务1进行提交或者回滚之后 才能继续操作事务1锁住的数据。 2、乐观锁(OCC)测试: 比较常见的是,我们有这么几种方式实现乐观锁。例如:CAS、MVCC、Redis分布式锁。 2.1、CAS CAS(比较与交换,Compare and swap) 是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。实现非阻塞同步的方案称为“无锁编程算法”( Non-blocking algorithm)。 2.1.1、使用数据库版本字段version实现 何谓数据版本?即为数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。 CREATE TABLE `test_table` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增ID', `name` varchar(20) DEFAULT NULL COMMENT '姓名', `age` int(11) DEFAULT NULL COMMENT '年龄', `version` int(11) unsigned zerofill NOT NULL COMMENT '数据版本', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 得到数据的version mysql> SELECT id,`name`,version FROM test_table; -- 根据数据version修改数据记录 mysql> UPDATE test_table SET `name`='xx', version=version+1 WHERE version = #{version}; 所以,当你用 version 实现一个乐观锁的时候,可以不用事务,也不用锁表。 2.1.2、使用数据库时间戳字段timestamp实现 和第一种version差不多,同样是在需要乐观锁控制的table中增加一个字段,字段类型使用时间戳(timestamp), 和上面的version类似,也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,如果一致则OK,否则就是版本冲突。 CREATE TABLE `test_table` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增ID', `name` varchar(20) DEFAULT NULL COMMENT '姓名', `timestamp` int(11) NOT NULL COMMENT '时间戳', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 得到数据的timestamp mysql> SELECT id,`name`,timestamp FROM test_table; -- 根据数据version修改数据记录 mysql> UPDATE test_table SET `name`='xx', timestamp=unix_timestamp(now()) WHERE timestamp = #{timestamp}; 2.2、MVCC 维基百科: 多版本并发控制(Multiversion concurrency control, MCC 或 MVCC),是数据库管理系统常用的一种并发控制,也用于程序设计语言实现事务内存。 关于MVCC请阅读这篇文章:https://blog.mailjob.net/posts/2327774433.html 2.3、分布式锁 比较常见的分布式锁的实现方式有:Redis分布式锁、Zookeeper分布式锁 3、不同锁的优缺点比较: 悲观锁 悲观锁的优点: 悲观锁实际上是采取了 “先取锁在访问” 的策略,为数据的处理安全提供了保证 悲观锁的不足: 在效率方面,由于额外的加锁机制产生了额外的开销,并且增加了死锁的机会。并且降低了并发性;当一个事物所以一行数据的时候,其他事物必须等待该事务提交之后,才能操作这行数据。 乐观锁 乐观锁的优点: 乐观并发控制相信事务之间的数据竞争 (data race) 的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁。 乐观锁的不足: 但如果直接简单这么做,还是有可能会遇到不可预期的结果,例如两个事务都读取了数据库的某一行,经过修改以后写回数据库,这时就遇到了问题。 锁的常见问题: 1、锁升级现象 问题: 在不通过索引条件查询的时候,InnoDB使用的是表锁。InnoDB 升级为表锁后,届时并发性将大大折扣。 由于 MySQL 的行锁是针对索引加的锁,不是针对记录加的锁。所以虽然是访问不同行 的记录,但是如果是使用相同的索引键,是会出现锁冲突的。 当表有多个索引的时候,不同的事务可以使用不同的索引锁定不同的行。另外,不论是使用主键索引、唯一索引、普通索引。InnoDB 都会使用行锁来对数据加锁。 解决问题: 如果 MySQL 认为全表扫 效率更高。比如对一些很小的表,它就不会使用索引。这种情况下 InnoDB 将使用表锁,而不是行锁。因此,在分析锁冲突时,,别忘了检查 SQL 的执行计划(explain查看),以确认是否真正使用了索引。 2、死锁 注意:MyISAM中是不会产生死锁的,因为MyISAM总是一次性获得所需的全部锁,要么全部满足,要么全部等待。而在InnoDB中,锁是逐步获得的,就造成了死锁的可能。 问题: 在InnoDB中,行级锁并不是直接锁记录,而是锁索引。索引分为主键索引和非主键索引两种,如果一条sql语句操作了主键索引,MySQL就会锁定这条主键索引;如果一条语句操作了非主键索引,MySQL会先锁定该非主键索引,再锁定相关的主键索引。 当两个事务同时执行,一个锁住了主键索引,在等待其他相关索引。另一个锁定了非主键索引,在等待主键索引。这样就会发生死锁。 1.1、两个 session (窗口)的两条语句 首先session1获得 id=1的锁 session2获得id=5的锁,然后session想要获取id=5的锁 等待,session2想要获取id=1的锁 ,也等待!(互相等待,则发生了死锁) 1.2、 两个session的一条语句 这种情况需要我们了解数据的索引的检索顺序原理简单说下:普通索引上面保存了主键索引,当我们使用普通索引检索数据时,如果所需的信息不够,那么会继续遍历主键索引。 假设默认情况是RR隔离级别 针对session 1 从name索引出发,检索到的是(hdc,1)(hdc,6)不仅会加name索引上的记录X锁,而且会加聚簇索引上的记录X锁,加锁顺序为先[1,hdc,100],后[6,hdc,10] 这个顺序是因为B+树结构的有序性。 而Session 2,从pubtime索引出发,[10,6],[100,1]均满足过滤条件,同样也会加聚簇索引上的记录X锁,加锁顺序为[6,hdc,10],后[1,hdc,100]。 发现没有,跟Session 1的加锁顺序正好相反,如果两个Session恰好都持有了第一把锁,请求加第二把锁,死锁就发生了。 解决方案: 如果不同程序会并发存取多个表,尽量约定以相同的顺序访问表,可以大大降低死锁机会。 在同一个事务中,尽可能做到一次锁定所需要的所有资源,减少死锁产生概率。 对于非常容易产生死锁的业务部分,可以尝试使用升级锁定颗粒度,通过表级锁定来减少死锁产生的概率。 参考文献 https://blog.csdn.net/horses/article/details/103324323
普通索引 覆盖索引 覆盖索引是我们对于mysql建立索引的最终追求 只需要在一棵索引树上就能获取SQL所需的所有列数据,无需回表,速度更快。 Extra*:Using index* 如图,在innodb查询中,只需要通过普通索引就可以得到所查询的数据。无需通过主键索引取查询相应的数据,即为覆盖索引! 如果避免(innodb)回表查询 通过创建联合索引,避免回表查询问题:https://blog.mailjob.net/posts/2656561906.html 联合索引最左匹配原则 创建 (A,B,C)联合索引时,相当于创建了(A)单列索引,(A,B)联合索引以及(A,B,C)联合索引 在 mysql5.6 之前,想要索引生效的话,只能使用 A 和 A,B 和 A,B,C 三种组合。 在 mysql5.6 之后,由于mysql的优化器进行了优化,只要最终的排列顺序符合最左原则即可,那么此处符合覆盖索引条件的有以下索引: A A,B A,B,C A,C,B C,B,A C,A,B B,A,C B,C,A A,C 组合是否用到了索引? 只用到了 A 的索引,C 并没有用到 Hash索引 在MySQL的存储引擎中,MyISAM不支持哈希索引,而InnoDB中的hash索引是存储引擎根据B-Tree索引自建的 hash索引的特点 1、hash索引是基于hash表实现的,只有查询条件精确匹配hash索引中的所有列的时候,才能用到hash索引。 2、对于hash索引中的所有列,存储引擎都会为每一行计算一个hash码,hash索引中存储的就是hash码。 3、hash索引包括键值、hash码和指针 。 因为hash索引本身只需要存储对应的hash值,所以索引的结构十分紧凑,这也让hash索引查找的速度非常快。然而,hash索引也是存在其限制的: hash索引的限制 Hash索引必须进行二次查找 使用哈市索引两次查找,第一次找到相应的行,第二次读取数据,但是被频繁访问到的行一般会缓存在内存中,这点对数据库性能的影响不大。 hash索引不能用于外排序hash索引存储的是hash码而不是键值,所以无法用于外排序 hash索引不支持部分索引查找也不支持范围查找只能用到等值查询,不能范围和模糊查询 hash索引中的hash码的计算可能存在hash冲突 当出现hash冲突的时候,存储引擎必须遍历整个链表中的所有行指针,逐行比较,直到找到所有的符合条件的行,若hash冲突很多的话,一些索引的维护代价机会很高,所以说hash索引不适用于选择性很差的列上(重复值很多)。姓名、性别、身份证(合适) 上面说到InnoDB的“自适应hash索引”。就是当InnoDB注意到某些索引值被使用的非常频繁时,它会在内存中基于B-Tree索引上在创建一个hash索引,这样就让B-tree索引也具有hash索引的一些优点。这是一个完全自动的内部的行为,用户无法控制或配置,不过,如果有需要,完全可以关闭该功能。 BTree索引和哈希索引的区别 Hash索引结构的特殊性,其检索效率非常高,索引的检索可以一次定位,不像B-Tree索引需要从根节点到枝节点,最后才能访问到页节点这样多次的IO访问,所以Hash索引的查询效率要远高于B-Tree索引 Hash索引的弊端 1、Hash索引仅仅能满足"=",“IN"和”<=>"查询,不能使用范围查询。 2、Hash索引无法被用来避免数据的排序操作。 3、Hash索引不能利用部分索引键查询。 对于组合索引,Hash索引在计算Hash值的时候是组合索引键合并后再一起计算Hash值,而不是单独计算Hash值,所以通过组合索引的前面一个或几个索引键进行查询的时候,Hash索引也无法被利用 4、Hash索引在任何时候都不能避免表扫描。 Hash索引是将索引键通过Hash运算之后,将 Hash运算结果的Hash值和所对应的行指针信息存放于一个Hash表中,由于不同索引键存在相同Hash值,所以即使取满足某个Hash键值的数据的记录条数,也无法从Hash索引中直接完成查询,还是要通过访问表中的实际数据进行相应的比较,并得到相应的结果 5、Hash索引遇到大量Hash值相等的情况后性能并不一定就会比BTree索引高。 对于选择性比较低的索引键,如果创建Hash索引,那么将会存在大量记录指针信息存于同一个Hash值相关联。这样要定位某一条记录时就会非常麻烦,会浪费多次表数据的访问,而造成整体性能低下 全文索引 全文索引,通过建立倒排索引,可以极大的提升检索效率,解决判断字段是否包含的问题. 例如: 有title字段,需要查询所有包含 "政府"的记录. 需要 like "%政府%“方式查询,查询速度慢,当查询包含"政府” OR "中国"的需要是,sql难以简单满足.全文索引就可以实现这个功能. 倒排索引(英语:Inverted index),也常被称为反向索引、置入档案或反向档案,是一种索引方法,被用来存储在全文搜索下某个单词在一个文档或者一组文档中的存储位置的映射。它是文档检索系统中最常用的数据结构。 注意 在MySQL 5.6版本以前,只有MyISAM存储引擎支持全文引擎.在5.6版本中,InnoDB加入了对全文索引的支持,但是不支持中文全文索引.在5.7.6版本,MySQL内置了ngram全文解析器,用来支持亚洲语种的分词. 全文索引带来的负面影响: 占有存储空间更大,如果内存一次装不下全部索引,性能会非常差。 增删改代价更大,修改文本中10个单词,则要操作维护索引10次,而不是普通索引的一次。 如果一个列上有全文索引则一定会用上,即使有性能更好的其他索引也不会用上。由于只是存储文档指针,也就用不上索引覆盖。 示例 根据名称创建全文索引:alter table customers1 add fulltext index testfulltext(name) with parser ngram; select * from customers1 where match(name) against(‘-化实’,in boolean mode) limit 0,5; 索引选择原则 注:字段一般是推荐重复比较少的字段影响到数据的检索,如果是项目需求(可建立联合索引) 唯一字段可以单独建立单索引,非唯一考虑联合索引,推荐尽量使用唯一字段建立索引 索引的个数,联合索引的个数 最佳 6个 以内,如果索引因为项目需求:最多 10个 索引的使用遵循最左匹配原则其次覆盖索引 尽量选择小的字段建立索引 int ,varchar(10), char(5) 避免<,<= ,> ,>= , % ,between 之前的条件。选择索引的字段的范围和模糊之前,因为范围与模糊会引起索引失效,针对于联合索引,就是联合索引的中间尽量不要有范围查询的字段 尽量多使用explain分析 避免更新频繁的字段 (二叉树会一直变化,导致性能变慢) 建立的索引- 优先考虑 建立 联合索引 索引字段不要有 null, 不是 ‘’
php从以前到现在一直都是单继承的语言,无法同时从两个基类中继承属性和方法, 为了解决这个问题,php 5.4.0 出了Trait这个特性 看上去既像类又像接口,其实都不是,Trait可以看做类的部分实现,可以混入一个或多个现有的PHP类中,其作用有两个:表明类可以做什么;提供模块化实现。Trait是一种代码复用技术,为PHP的单继承限制提供了一套灵活的代码复用机制。 参考文献 官方文档:https://www.php.net/manual/zh/language.oop5.traits.php 功能说明 继承的方式虽然也能解决问题,但其思路违背了面向对象的原则,显得很粗暴;多态方式也可行,但不符合软件开发中的DRY原则,增加了维护成本。而Trait方式则避免了上述的不足之处,相对优雅的实现了代码的复用。 PHP语言使用一种典型的单继承模型,在这种模型中,我们先编写一个通用的根类,实现基本的功能,然后扩展这个根类,创建更具体的子类,直接从父类继承实现。这叫做继承层次结构,很多编程语言都使用这个模式。大多数时候这种典型的继承模型能够良好运作,但是如果想让两个无关的PHP类具有类似的行为,应该怎么做呢? Trait就是为了解决这种问题而诞生的。Trait能够把模块化的实现方式注入多个无关的类中,从而提高代码复用,符合DRY(Don’t Repeat Yourself)原则。 伪代码示例 <?php trait Dog{ public $name="dog"; public function bark(){ echo "This is dog"; } } class Animal{ public function eat(){ echo "This is animal eat"; } } class Cat extends Animal{ use Dog; public function drive(){ echo "This is cat drive"; } } $cat = new Cat(); $cat->drive(); echo "<br/>"; $cat->eat(); echo "<br/>"; $cat->bark(); # 输出内容如下 # This is cat drive # This is animal eat # This is dog 测试Trait、基类和本类对同名属性或方法的处理 <?php trait Dog{ public $name="dog"; public function drive(){ echo "This is dog drive"; } public function eat(){ echo "This is dog eat"; } } class Animal{ public function drive(){ echo "This is animal drive"; } public function eat(){ echo "This is animal eat"; } } class Cat extends Animal{ use Dog; public function drive(){ echo "This is cat drive"; } } $cat = new Cat(); $cat->drive(); echo "<br/>"; $cat->eat(); # 输出内容如下 # This is cat drive # This is dog eat 所以:Trait中的方法会覆盖 基类中的同名方法,而本类会覆盖Trait中同名方法 注意点:当trait定义了属性后,类就不能定义同样名称的属性,否则会产生 fatal error,除非是设置成相同可见度、相同默认值。不过在php7之前,即使这样设置,还是会产生E_STRICT 的提醒 一个类可以组合多个Trait,通过逗号相隔,如下 use trait1,trait2 当不同的trait中,却有着同名的方法或属性,会产生冲突,可以使用insteadof或 as进行解决,insteadof 是进行替代,而as是给它取别名 <?php trait trait1{ public function eat(){ echo "This is trait1 eat"; } public function drive(){ echo "This is trait1 drive"; } } trait trait2{ public function eat(){ echo "This is trait2 eat"; } public function drive(){ echo "This is trait2 drive"; } } class cat{ use trait1,trait2{ trait1::eat insteadof trait2; trait1::drive insteadof trait2; } } class dog{ use trait1,trait2{ trait1::eat insteadof trait2; trait1::drive insteadof trait2; trait2::eat as eaten; trait2::drive as driven; } } $cat = new cat(); $cat->eat(); echo "<br/>"; $cat->drive(); echo "<br/>"; echo "<br/>"; echo "<br/>"; $dog = new dog(); $dog->eat(); echo "<br/>"; $dog->drive(); echo "<br/>"; $dog->eaten(); echo "<br/>"; $dog->driven(); # 输出内容如下 # This is trait1 eat # This is trait1 drive # # # This is trait1 eat # This is trait1 drive # This is trait2 eat # This is trait2 drive as 还可以修改方法的访问控制 <?php trait Animal{ public function eat(){ echo "This is Animal eat"; } } class Dog{ use Animal{ eat as protected; } } class Cat{ use Animal{ Animal::eat as private eaten; } } $dog = new Dog(); $dog->eat();//报错,因为已经把eat改成了保护 $cat = new Cat(); $cat->eat();//正常运行,不会修改原先的访问控制 $cat->eaten();//报错,已经改成了私有的访问控制 Trait也可以互相组合,还可以使用抽象方法,静态属性,静态方法等 <?php trait Cat{ public function eat(){ echo "This is Cat eat"; } } trait Dog{ use Cat; public function drive(){ echo "This is Dog drive"; } abstract public function getName(); public function test(){ static $num=0; $num++; echo $num; } public static function say(){ echo "This is Dog say"; } } class animal{ use Dog; public function getName(){ echo "This is animal name"; } } $animal = new animal(); $animal->getName(); echo "<br/>"; $animal->eat(); echo "<br/>"; $animal->drive(); echo "<br/>"; $animal::say(); echo "<br/>"; $animal->test(); echo "<br/>"; $animal->test(); # 输出内容如下 # This is animal name # This is Cat eat # This is Dog drive # This is Dog say # 1 # 2
相关链接 redis集群演示服务分布图:https://www.kdocs.cn/view/l/sfN4qFXA2SyN 作者的docker-compose的redis集群:https://github.com/mailjobblog/dev_redis/tree/master/clusters/集群 docker-compose 文件说明 该演示示例,使用 redis6.0.10 版本进行演示 文件中容器对应关系 容器名称 IP 客户端连接端口映射 集群端口映射 预想角色 redis-c1 172.31.0.11 6301->6379 16301->16379 master redis-c2 172.31.0.12 6302->6379 16302->16379 master redis-c3 172.31.0.13 6303->6379 16303->16379 master redis-c4 172.31.0.14 6304->6379 16304->16379 slave redis-c5 172.31.0.15 6305->6379 16305->16379 slave redis-c6 172.31.0.16 6306->6379 16306->16379 slave redis-c7 172.31.0.17 6307->6379 16307->16379 master (演示集群伸缩中使用) redis-c8 172.31.0.18 6308->6379 16308->16379 slave (演示集群伸缩中使用) 初始服务分布如下 集群主要配置文件说明 # 端口 port 6379 # 是否开启集群 cluster-enabled yes # 集群超时时间 cluster-node-timeout 5000 # 更新操作后进行日志记录 appendonly yes # 集群配置文件 cluster-config-file "/redis/log/nodes.conf" # 是否开启外部连接 protected-mode no # redis守护进程 daemonize no # 本地数据库存放目录 dir "/redis/data" # redis日志文件 logfile "/redis/log/redis.log" daemonize 设置yes或者no区别(默认:no) daemonize:yes:redis采用的是单进程多线程的模式。当redis.conf中选项daemonize设置成yes时,代表开启守护进程模式。在该模式下,redis会在后台运行,并将进程pid号写入至redis.conf选项pidfile设置的文件中,此时redis将一直运行,除非手动kill该进程。 daemonize:no: 当daemonize选项设置成no时,当前界面将进入redis的命令行界面,exit强制退出或者关闭连接工具(putty,xshell等)都会导致redis进程退出。 开始搭建集群 在宿主机 /data 上传 j_cluster 如果上传到了其他目录需要更改 yml 里面的数据卷映射条件 启动项目 # 进入到项目目录 cd /data/j_cluster # 启动项目 docker-compose up -d 查看一下各个节点的ip docker container inspect redis-c1 redis-c2 redis-c3 redis-c4 redis-c5 redis-c6 | grep IPv4Address 可以看到和我在 yam 中定义的ip一致 开始搭建集群 这里以进入 redis-c1 为例 docker exec -it redis-c1 /bin/bash 浏览一下redis的集群命令 以下命令只有 redis5 以后才有,redis5 以后redis发布了集群搭建命令 redis5 以前如果你要搭建的话,可以采用 Ruby 脚本 # 查看集群帮助文档 root@e4d19717bbed:/redis# redis-cli --cluster help # 集群相关命令如下 Cluster Manager Commands: create host1:port1 ... hostN:portN #创建集群 --cluster-replicas <arg> #从节点个数 check host:port #检查集群 --cluster-search-multiple-owners #检查是否有槽同时被分配给了多个节点 info host:port #查看集群状态 fix host:port #修复集群 --cluster-search-multiple-owners #修复槽的重复分配问题 reshard host:port #指定集群的任意一节点进行迁移slot,重新分slots --cluster-from <arg> #需要从哪些源节点上迁移slot,可从多个源节点完成迁移,以逗号隔开,传递的是节点的node id,还可以直接传递--from all,这样源节点就是集群的所有节点,不传递该参数的话,则会在迁移过程中提示用户输入 --cluster-to <arg> #slot需要迁移的目的节点的node id,目的节点只能填写一个,不传递该参数的话,则会在迁移过程中提示用户输入 --cluster-slots <arg> #需要迁移的slot数量,不传递该参数的话,则会在迁移过程中提示用户输入。 --cluster-yes #指定迁移时的确认输入 --cluster-timeout <arg> #设置migrate命令的超时时间 --cluster-pipeline <arg> #定义cluster getkeysinslot命令一次取出的key数量,不传的话使用默认值为10 --cluster-replace #是否直接replace到目标节点 rebalance host:port #指定集群的任意一节点进行平衡集群节点slot数量 --cluster-weight <node1=w1...nodeN=wN> #指定集群节点的权重 --cluster-use-empty-masters #设置可以让没有分配slot的主节点参与,默认不允许 --cluster-timeout <arg> #设置migrate命令的超时时间 --cluster-simulate #模拟rebalance操作,不会真正执行迁移操作 --cluster-pipeline <arg> #定义cluster getkeysinslot命令一次取出的key数量,默认值为10 --cluster-threshold <arg> #迁移的slot阈值超过threshold,执行rebalance操作 --cluster-replace #是否直接replace到目标节点 add-node new_host:new_port existing_host:existing_port #添加节点,把新节点加入到指定的集群,默认添加主节点 --cluster-slave #新节点作为从节点,默认随机一个主节点 --cluster-master-id <arg> #给新节点指定主节点 del-node host:port node_id #删除给定的一个节点,成功后关闭该节点服务 call host:port command arg arg .. arg #在集群的所有节点执行相关命令 set-timeout host:port milliseconds #设置cluster-node-timeout import host:port #将外部redis数据导入集群 --cluster-from <arg> #将指定实例的数据导入到集群 --cluster-copy #migrate时指定copy --cluster-replace #migrate时指定replace help For check, fix, reshard, del-node, set-timeout you can specify the host and port of any working node in the cluster. 注意:Redis Cluster最低要求是3个主节点 创建集群主从节点 redis-cli --cluster create 172.31.0.11:6379 172.31.0.12:6379 172.31.0.13:6379 172.31.0.14:6379 172.31.0.15:6379 172.31.0.16:6379 --cluster-replicas 1 >>> Performing hash slots allocation on 6 nodes... Master[0] -> Slots 0 - 5460 Master[1] -> Slots 5461 - 10922 Master[2] -> Slots 10923 - 16383 Adding replica 172.31.0.15:6379 to 172.31.0.11:6379 Adding replica 172.31.0.16:6379 to 172.31.0.12:6379 Adding replica 172.31.0.14:6379 to 172.31.0.13:6379 M: 50fa88c4a01f968df6ab7e8bd02e1bb51c85f13f 172.31.0.11:6379 slots:[0-5460] (5461 slots) master M: 04a2118b3f7b7521a55cf77171f1c50fe1a80f4d 172.31.0.12:6379 slots:[5461-10922] (5462 slots) master M: b83a282329830e2ea686889cb8aa9eafa3441b8f 172.31.0.13:6379 slots:[10923-16383] (5461 slots) master S: 9b0a2284c341efa7055dd2046aec2e1c43ee6f9b 172.31.0.14:6379 replicates b83a282329830e2ea686889cb8aa9eafa3441b8f S: 09aca472595a229e7ceda2792aed98f88d757d45 172.31.0.15:6379 replicates 50fa88c4a01f968df6ab7e8bd02e1bb51c85f13f S: 2ce485e6a5bc5a3f300347c123ce911e605bf164 172.31.0.16:6379 replicates 04a2118b3f7b7521a55cf77171f1c50fe1a80f4d Can I set the above configuration? (type 'yes' to accept): –cluster create : 表示创建集群 –cluster-replicas 0 : 表示只创建n个主节点,不创建从节点 –cluster-replicas 1 : 表示为集群中的每个主节点创建一个从节点(例:master[172.31.0.11:6379] -> slave[172.31.0.14:6379]) 查看集群 # 查看节点主从关系 127.0.0.1:6379> cluster nodes b83a282329830e2ea686889cb8aa9eafa3441b8f 172.31.0.13:6379@16379 master - 0 1612778025467 3 connected 10923-16383 04a2118b3f7b7521a55cf77171f1c50fe1a80f4d 172.31.0.12:6379@16379 master - 0 1612778025000 2 connected 5461-10922 50fa88c4a01f968df6ab7e8bd02e1bb51c85f13f 172.31.0.11:6379@16379 myself,master - 0 1612778024000 1 connected 0-5460 9b0a2284c341efa7055dd2046aec2e1c43ee6f9b 172.31.0.14:6379@16379 slave b83a282329830e2ea686889cb8aa9eafa3441b8f 0 1612778024565 3 connected 2ce485e6a5bc5a3f300347c123ce911e605bf164 172.31.0.16:6379@16379 slave 04a2118b3f7b7521a55cf77171f1c50fe1a80f4d 0 1612778024465 2 connected 09aca472595a229e7ceda2792aed98f88d757d45 172.31.0.15:6379@16379 slave 50fa88c4a01f968df6ab7e8bd02e1bb51c85f13f 0 1612778024000 1 connected # 列出槽和节点信息 127.0.0.1:6379> cluster slots 1) 1) (integer) 10923 2) (integer) 16383 3) 1) "172.31.0.13" 2) (integer) 6379 3) "b83a282329830e2ea686889cb8aa9eafa3441b8f" 4) 1) "172.31.0.14" 2) (integer) 6379 3) "9b0a2284c341efa7055dd2046aec2e1c43ee6f9b" 2) 1) (integer) 5461 2) (integer) 10922 3) 1) "172.31.0.12" 2) (integer) 6379 3) "04a2118b3f7b7521a55cf77171f1c50fe1a80f4d" 4) 1) "172.31.0.16" 2) (integer) 6379 3) "2ce485e6a5bc5a3f300347c123ce911e605bf164" 3) 1) (integer) 0 2) (integer) 5460 3) 1) "172.31.0.11" 2) (integer) 6379 3) "50fa88c4a01f968df6ab7e8bd02e1bb51c85f13f" 4) 1) "172.31.0.15" 2) (integer) 6379 3) "09aca472595a229e7ceda2792aed98f88d757d45" 数据存储 # 直接存储提示槽信息不对 127.0.0.1:6379> set name libin (error) MOVED 5798 172.31.0.12:6379 # 客户端连接加入 -c 数据可以直接被重定向到槽服务器 root@00bfb4f9402a:/redis# redis-cli -c 127.0.0.1:6379> set name libin -> Redirected to slot [5798] located at 172.31.0.12:6379 OK # 存储多个key的时候,由于不同的槽服务器,报错问题 172.31.0.12:6379> mset k1 v1 k2 v2 (error) CROSSSLOT Keys in request don't hash to the same slot # 加入一个 tag 即可解决 172.31.0.12:6379> mset {r}k1 v1 {r}k2 v2 OK 集群伸缩 1、准备新的redis节点服务器 2、加入到集群中 3、分配数据槽和迁移数据 加入一个新的 master 节点 redis-cli -h 172.31.0.17 --cluster add-node 172.31.0.17:6379 172.31.0.11:6379 这里的新加入的master节点是 172.31.0.17 172.31.0.11 代表的事现在存在的集群中的任意一个 master 节点 为新加入的 master 节点添加一个 slave 节点 redis-cli -h 172.31.0.17 --cluster add-node 172.31.0.18:6379 172.31.0.17:6379 --cluster-slave 172.31.0.17 代表上面行加入的 master 节点 172.31.0.18 代表为 master(172.31.0.17)加入的从节点 –cluster-slave 代表是 slave(从节点)的身份 删除 slave 节点 redis-cli -h 172.31.0.18 --cluster del-node 172.31.0.18:6379 3a12f6b4ed5f26b83525681e73ee23750bcbcfbf 3a12f6b4ed5f26b83525681e73ee23750bcbcfbf 是通过 cluster nodes 命令得到的节点ID 如果上面有数据的话,无法删除,需要先迁移数据 为节点分配槽 # 指定分配 redis-cli -h 172.31.0.11 --cluster reshard 172.31.0.11:6379 # 平均分配 redis-cli -h 172.31.0.11 --cluster rebalance 172.31.0.11:6379 172.31.0.11 这个只要是随便一个 master 节点都可以操作集群 分配槽演示 # 可以先看到节点信息 >>> Performing Cluster Check (using node 172.31.0.11:6379) M: 9af405be84c1d448988117f88f78fa588d49a196 172.31.0.11:6379 slots:[0-5460] (5461 slots) master 1 additional replica(s) S: 3a12f6b4ed5f26b83525681e73ee23750bcbcfbf 172.31.0.18:6379 slots: (0 slots) slave replicates 34a33c9df9d909e69e5cad8965d905b72959c677 S: 7c35cf01519c5747ca262769ed47dbbe44eeb830 172.31.0.14:6379 slots: (0 slots) slave replicates 780d17c7c9ca86f68b6119762f0958f00093702a M: 780d17c7c9ca86f68b6119762f0958f00093702a 172.31.0.13:6379 slots:[10923-16383] (5461 slots) master 1 additional replica(s) M: 34a33c9df9d909e69e5cad8965d905b72959c677 172.31.0.17:6379 slots: (0 slots) master 1 additional replica(s) S: a4956784b0221598c23d18fbcad844e18eefab63 172.31.0.15:6379 slots: (0 slots) slave replicates 9af405be84c1d448988117f88f78fa588d49a196 M: ddf46bbdf6794d12debe824ce4e30ea96cd70212 172.31.0.12:6379 slots:[5461-10922] (5462 slots) master 1 additional replica(s) S: a2820f32aa18e7ec2d880aedc8d91c9db840dcd9 172.31.0.16:6379 slots: (0 slots) slave replicates ddf46bbdf6794d12debe824ce4e30ea96cd70212 [OK] All nodes agree about slots configuration. >>> Check for open slots... >>> Check slots coverage... [OK] All 16384 slots covered. 可以看到这里新加入的master 172.31.0.17 没有数据槽 # 询问要迁移的槽的数量(我在这里迁移了500个) How many slots do you want to move (from 1 to 16384)? 500 # 询问被迁移的槽的ID(我这里迁移的是:172.31.0.11) What is the receiving node ID? 34a33c9df9d909e69e5cad8965d905b72959c677 # 请输入所有源节点ID Please enter all the source node IDs. # 键入“all”将所有节点用作哈希槽的源节点 Type 'all' to use all the nodes as source nodes for the hash slots. # 输入所有源节点ID后键入“done”。 Type 'done' once you entered all the source nodes IDs. # 输入源的ID后,进行迁移 Source node #1: 9af405be84c1d448988117f88f78fa588d49a196 Source node #2: done 用这个方法,也可以把某一个源的槽都迁移后。那么该槽就可以进行删除了 搭建问题 创建集群主从节点报错 [ERR] Node 172.31.0.11:6379 is not configured as a cluster node 查看配置文件的配置问题
相关链接 集群实现源码:https://github.com/redis/redis/blob/6.0/src/cluster.h 官网集群文档:https://redis.io/topics/cluster-tutorial Redis集群smart jedis:https://kdocs.cn/l/soEOUpbcjY2q Redis哈希槽概念:https://blog.mailjob.net/posts/2893278639.html 前言 本博客针对 redis5 以后的集群搭建进行讲解。 redis3 到 redis5 开始支持集群,用的是 ruby 写的一个脚本,所以你需要先安装 ruby。然后根据官方提供了一个工具:redis-trib.rb(/redis-3.2.1/src/redis-trib.rb) 进行敲命令,一个个安装集群扩展。 redis5以后的开发者们比较幸福了,redis直接支持了命令集群,在redis的 redis-cli 客户端直接配置集群 节点间的内部通信机制 1、基础通信原理 redis cluster节点间采取gossip协议进行通信 跟集中式不同,不是将集群元数据(节点信息,故障,等等)集中存储在某个节点上,而是互相之间不断通信,保持整个集群所有节点的数据是完整的 集中式:好处在于,元数据的更新和读取,时效性非常好,一旦元数据出现了变更,立即就更新到集中式的存储中,其他节点读取的时候立即就可以感知到; 不好在于,所有的元数据的跟新压力全部集中在一个地方,可能会导致元数据的存储有压力 gossip:好处在于,元数据的更新比较分散,不是集中在一个地方,更新请求会陆陆续续,打到所有节点上去更新,有一定的延时,降低了压力; 缺点,元数据更新有延时,可能导致集群的一些操作会有一些滞后 10000端口 每个节点都有一个专门用于节点间通信的端口,就是自己提供服务的端口号+10000,比如7001,那么用于节点间通信的就是17001端口 每隔节点每隔一段时间都会往另外几个节点发送ping消息,同时其他几点接收到ping之后返回pong 交换的信息 故障信息,节点的增加和移除,hash slot信息,等等 2、gossip协议 gossip协议包含多种消息,包括ping,pong,meet,fail,等等 meet: 某个节点发送meet给新加入的节点,让新节点加入集群中,然后新节点就会开始与其他节点进行通信 redis-trib.rb add-node 其实内部就是发送了一个gossip meet消息,给新加入的节点,通知那个节点去加入我们的集群 ping: 每个节点都会频繁给其他节点发送ping,其中包含自己的状态还有自己维护的集群元数据,互相通过ping交换元数据 每个节点每秒都会频繁发送ping给其他的集群,ping,频繁的互相之间交换数据,互相进行元数据的更新 pong: 返回ping和meet,包含自己的状态和其他信息,也可以用于信息广播和更新 fail: 某个节点判断另一个节点fail之后,就发送fail给其他节点,通知其他节点,指定的节点宕机了 3、ping消息深入 ping很频繁,而且要携带一些元数据,所以可能会加重网络负担 每个节点每秒会执行10次ping,每次会选择5个最久没有通信的其他节点 当然如果发现某个节点通信延时达到了cluster_node_timeout / 2,那么立即发送ping,避免数据交换延时过长,落后的时间太长了 比如说,两个节点之间都10分钟没有交换数据了,那么整个集群处于严重的元数据不一致的情况,就会有问题 所以cluster_node_timeout可以调节,如果调节比较大,那么会降低发送的频率 每次ping,一个是带上自己节点的信息,还有就是带上1/10其他节点的信息,发送出去,进行数据交换 至少包含3个其他节点的信息,最多包含总节点-2个其他节点的信息 smart jedis (1)什么是smart jedis 基于重定向的客户端,很消耗网络IO,因为大部分情况下,可能都会出现一次请求重定向,才能找到正确的节点 所以大部分的客户端,比如java redis客户端,就是jedis,都是smart的 本地维护一份hashslot -> node的映射表,缓存,大部分情况下,直接走本地缓存就可以找到hashslot -> node,不需要通过节点进行moved重定向 (2)JedisCluster的工作原理 在JedisCluster初始化的时候,就会随机选择一个node,初始化hashslot -> node映射表,同时为每个节点创建一个JedisPool连接池 每次基于JedisCluster执行操作,首先JedisCluster都会在本地计算key的hashslot,然后在本地映射表找到对应的节点 如果那个node正好还是持有那个hashslot,那么就ok; 如果说进行了reshard这样的操作,可能hashslot已经不在那个node上了,就会返回moved 如果JedisCluter API发现对应的节点返回moved,那么利用该节点的元数据,更新本地的hashslot -> node映射表缓存 重复上面几个步骤,直到找到对应的节点,如果重试超过5次,那么就报错,JedisClusterMaxRedirectionException jedis老版本,可能会出现在集群某个节点故障还没完成自动切换恢复时,频繁更新hash slot,频繁ping节点检查活跃,导致大量网络IO开销 jedis最新版本,对于这些过度的hash slot更新和ping,都进行了优化,避免了类似问题 (3)hashslot迁移和ask重定向 如果hash slot正在迁移,那么会返回ask重定向给jedis jedis接收到ask重定向之后,会重新定位到目标节点去执行,但是因为ask发生在hash slot迁移过程中,所以JedisCluster API收到ask是不会更新hashslot本地缓存 已经可以确定说,hashslot已经迁移完了,moved是会更新本地hashslot->node映射表缓存的 故障转移: redis集群实现了高可用,当集群内少量节点出现故障时,通过故障转移可以保证集群正常对外提供服务。 当集群里某个节点出现了问题,redis集群内的节点通过ping pong消息发现节点是否健康,是否有故障,其实主要环节也包括了 主观下线和客观下线; 主观下线:指某个节点认为另一个节点不可用,即下线状态,当然这个状态不是最终的故障判定,只能代表这个节点自身的意见,也有可能存在误判; 下线流程: 节点a发送ping消息给节点b ,如果通信正常将接收到pong消息,节点a更新最近一次与节点b的通信时间; 如果节点a与节点b通信出现问题则断开连接,下次会进行重连,如果一直通信失败,则它们的最后通信时间将无法更新; 节点a内的定时任务检测到与节点b最后通信时间超过cluster_note-timeout时,更新本地对节点b的状态为主观下线(pfail) 客观下线: 指真正的下线,集群内多个节点都认为该节点不可用,达成共识,将它下线,如果下线的节点为主节点,还要对它进行故障转移 假如节点a标记节点b为主观下线,一段时间后节点a通过消息把节点b的状态发到其它节点,当节点c接受到消息并解析出消息体时,会发现节点b的pfail状态时,会触发客观下线流程; 当下线为主节点时,此时redis集群为统计持有槽的主节点投票数是否达到一半,当下线报告统计数大于一半时,被标记为客观下线状态。 故障恢复: 故障主节点下线后,如果下线节点的是主节点,则需要在它的从节点中选一个替换它,保证集群的高可用;转移过程如下: 资格检查:检查该从节点是否有资格替换故障主节点,如果此从节点与主节点断开过通信,那么当前从节点不具体故障转移; 准备选举时间:当从节点符合故障转移资格后,更新触发故障选举时间,只有到达该时间后才能执行后续流程; 发起选举:当到达故障选举时间时,进行选举; 选举投票:只有持有槽的主节点才有票,会处理故障选举消息,投票过程其实是一个领导者选举(选举从节点为领导者)的过程,每个主节点只能投一张票给从节点, 当从节点收集到足够的选票(大于N/2+1)后,触发替换主节点操作,撤销原故障主节点的槽,委派给自己,并广播自己的委派消息,通知集群内所有节点
相关链接 CRC16算法源码:https://github.com/redis/redis/blob/6.0/src/crc16.c 一致性hash算法原理:https://www.cnblogs.com/lpfuture/p/5796398.html 概念理解 Redis 集群没有使用一致性hash, 而是引入了哈希槽的概念。 Redis 集群有16384个哈希槽,每个key通过CRC16校验后对16384取模来决定放置哪个槽. 集群的每个节点负责一部分hash槽。这种结构很容易添加或者删除节点,并且无论是添加删除或者修改某一个节点,都不会造成集群不可用的状态。 使用哈希槽的好处就在于可以方便的添加或移除节点。 当需要增加节点时,只需要把其他节点的某些哈希槽挪到新节点就可以了; 当需要移除节点时,只需要把移除节点上的哈希槽挪到其他节点就行了; 在这一点上,我们以后新增或移除节点的时候不用先停掉所有的 redis 服务 当前集群有3个节点,槽默认是平均分的: 节点 A (6381)包含 0 到 5499号哈希槽. 节点 B (6382)包含5500 到 10999 号哈希槽. 节点 C (6383)包含11000 到 16383号哈希槽. 数据迁移 数据迁移可以理解为slot(槽)和key的迁移,这个功能很重要,极大地方便了集群做线性扩展,以及实现平滑的扩容或缩容。 现在要将Master A节点中编号为1、2、3的slot迁移到Master B节点中,在slot迁移的中间状态下,slot 1、2、3在Master A节点的状态表现为MIGRATING(迁移),在Master B节点的状态表现为IMPORTING(入口)。 此时并不刷新node的映射关系 IMPORTING状态 被迁移slot 在目标Master B节点中出现的一种状态,准备迁移slot从Mater A到Master B的时候,被迁移slot的状态首先变为IMPORTING状态。 键空间迁移 键空间迁移是指当满足了slot迁移前提的情况下,通过相关命令将slot 1、2、3中的键空间从Master A节点转移到Master B节点。此时刷新node的映射关系。 复制&高可用:集群的节点内置了复制和高可用特性。 特点: 1、节点自动发现 2、slave->master 选举,集群容错 3、Hot resharding:在线分片 4、基于配置(nodes-port.conf)的集群管理 5、客户端与redis节点直连、不需要中间proxy层. 6、所有的redis节点彼此互联(PING-PONG机制),内部使用二进制协议优化传输速度和带宽. 相关问题 1、用了哈希槽的概念,而没有用一致性哈希算法,不都是哈希么?这样做的原因是为什么呢? Redis Cluster是自己做的crc16的简单hash算法,没有用一致性hash。Redis的作者认为它的crc16(key) mod 16384的效果已经不错了,虽然没有一致性hash灵活,但实现很简单,节点增删时处理起来也很方便。 2、为了动态增删节点的时候,不至于丢失数据么? 节点增删时不丢失数据和hash算法没什么关系,不丢失数据要求的是一份数据有多个副本。 3、还有集群总共有2的14次方,16384个哈希槽,那么每一个哈希槽中存的key 和 value是什么? 当你往Redis Cluster中加入一个Key时,会根据crc16(key) mod 16384计算这个key应该分布到哪个hash slot中,一个hash slot中会有很多key和value。你可以理解成表的分区,使用单节点时的redis时只有一个表,所有的key都放在这个表里;改用Redis Cluster以后会自动为你生成16384个分区表,你insert数据时会根据上面的简单算法来决定你的key应该存在哪个分区,每个分区里有很多key。
CentOS 6.x 防火墙配置文件地址 vi /etc/sysconfig/iptables 管理命令 重启防火墙 # service iptables restart 查看开放端口 # /etc/init.d/iptables status 关闭防火墙 # /etc/init.d/iptables stop 配置防火墙允许指定ip访问端口 关闭所有80端口 # iptables -I INPUT -p tcp --dport 80 -j DROP 开启ip段192.168.1.0/24端的80口 # iptables -I INPUT -s 192.168.1.0/24 -p tcp --dport 80 -j ACCEPT 开启ip段211.123.16.123/24端ip段的80口 iptables -I INPUT -s 211.123.16.123/24 -p tcp --dport 80 -j ACCEPT 开放一个IP的一些端口,其它都封闭 iptables -A Filter -p tcp --dport 80 -s 192.168.100.200 -d www.pconline.com.cn -j ACCEPT iptables -A Filter -p tcp --dport 25 -s 192.168.100.200 -j ACCEPT iptables -A Filter -p tcp --dport 109 -s 192.168.100.200 -j ACCEPT iptables -A Filter -p tcp --dport 110 -s 192.168.100.200 -j ACCEPT iptables -A Filter -p tcp --dport 53 -j ACCEPT iptables -A Filter -p udp --dport 53 -j ACCEPT iptables -A Filter -j DROP 多个端口 iptables -A Filter -p tcp -m multiport --destination-port 22,53,80,110 -s 192.168.20.3 -j REJECT 指定时间上网 iptables -A Filter -s 10.10.10.253 -m time --timestart 6:00 --timestop 11:00 --days Mon,Tue,Wed,Thu,Fri,Sat,Sun -j DROP iptables -A Filter -m time --timestart 12:00 --timestop 13:00 --days Mon,Tue,Wed,Thu,Fri,Sat,Sun -j ACCEPT iptables -A Filter -m time --timestart 17:30 --timestop 8:30 --days Mon,Tue,Wed,Thu,Fri,Sat,Sun -j ACCEPT CentOS 7.x 管理命令 查看firewall服务状态 systemctl status firewalld firewall-cmd --state # 平滑重载防火墙 firewall-cmd --reload # 开启 service firewalld start systemctl start firewalld # 重启 service firewalld restart # 关闭 service firewalld stop systemctl stop firewalld # 开机启动 systemctl enable firewalld # 取消开机启动 systemctl disable firewalld 查看防火墙规则 firewall-cmd --list-all 查看防火墙的开放的端口 firewall-cmd --permanent --list-ports 防火墙管理 # 查询端口是否开放 firewall-cmd --query-port=8080/tcp # 开放80端口 firewall-cmd --permanent --add-port=80/tcp # 移除端口 firewall-cmd --permanent --remove-port=8080/tcp # 参数解释 1、firwall-cmd:是Linux提供的操作firewall的一个工具; 2、--permanent:表示设置为持久; 3、--add-port:标识添加的端口; CentOS切换为iptables防火墙 切换到iptables首先应该关掉默认的firewalld,然后安装iptables服务。 1、关闭firewall: systemctl stop firewalld # 关闭 systemctl disable firewalld # 取消开机启动12 2、安装iptables防火墙 yum install iptables-services #安装1 3、编辑iptables防火墙配置 vi /etc/sysconfig/iptables 1 下边是一个完整的配置文件: Firewall configuration written by system-config-firewall Manual customization of this file is not recommended. *filter :INPUT ACCEPT [0:0] :FORWARD ACCEPT [0:0] :OUTPUT ACCEPT [0:0] -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT -A INPUT -p icmp -j ACCEPT -A INPUT -i lo -j ACCEPT -A INPUT -m state --state NEW -m tcp -p tcp --dport 22 -j ACCEPT -A INPUT -m state --state NEW -m tcp -p tcp --dport 80 -j ACCEPT -A INPUT -m state --state NEW -m tcp -p tcp --dport 3306 -j ACCEPT -A INPUT -j REJECT --reject-with icmp-host-prohibited -A FORWARD -j REJECT --reject-with icmp-host-prohibited COMMIT 123456789101112131415161718192021222324252627282930 :wq! #保存退出 service iptables start #开启 systemctl enable iptables.service #设置防火墙开机启动
相关链接 Raft详解:http://www.cnblogs.com/likehua/p/5845575.html 分布式Raft算法:http://www.jdon.com/artichect/raft.html 分布式一致算法——Paxos:http://www.cnblogs.com/cchust/p/5617989.html 选举Leader哨兵,来进行故障转移 (1)Raft简单介绍 哨兵的选举采用的是Raft算法,Raft是一个用户管理日志一致性的协议,它将分布式一致性问题分解为多个子问题**:Leader选举**、日志复制、安全性、日志压缩等。Raft将系统中的角色分为领导者(Leader)、跟从者(Follower)和候选者(Candidate): Leader:接受客户端请求,并向Follower同步请求日式,当日志同步到大多数节点上后告诉Follower提交日志。 Follower:接受并持久化Leader同步的日志,在Leader告知日志可以提交之后,提交日志。 Candidate:Leader选举过程中的临时角色。 (2) Term(任期) 在分布式系统中,各个节点的时间同步是一个很大的难题,但是为了识别过期时间,时间信息有必不可少。Raft协议为了解决这个问题,引入了term(任期)的概念。 Raft算法将时间划分为任意不同长度的任期(term)。任期用连续的数字进行表示。每一个任期的开始都是一次选举(election),一个或多个候选人会试图成为领导人,如果一个候选人赢得了选举,它就会在该任期的剩余时间担任领导人。在某些情况下,选票会被瓜分,有可能没有选出领导人,那么将会开始另一个任期,并且立刻开始下一次选举。Raft算法保证在给定的一个任期内最多是有一个领导人。 (3) RPC Raft算法中服务器节点之间通信使用远程过程调用(RPC),并且基本的一致性算法只需要两种类型的RPC,为了在服务器之间传输快照增加了第三种 RPC。 RequestVote RPC:候选人在选举期间发起。 AppendEntries RPC:领导人发起的一种心跳机制,复制日志也在该命令中完成。 InstallSnapshot RPC:领导者使用该RPC来发送快照给太落后的追随者。 (4) 选举流程 redis中的纪元(epoch):使用了类似于Raft算法term(任期)的概念称为epoch(纪元),用来给时间增加版本号。主要有两种: currentEpoch:它的作用在于,当集群的状态发生改变,某个节点为了执行一些动作需要寻求其他节点的统一时,就会增加currentEpoch的值。目前curretnEpoch只用于slabe的故障转移流程。 configEpoch:这是一个集群节点配置相关的概念,每个集群节点都有自己独一无二的configepoch,所谓的节点配置,实际上是指节点所负责的槽位信息。每一个master在向其他节点发送包时,都会附带其configEpoch信息,以及一份表示它负责的slots信息。 1、某个Sentinel认定master客观下线的节点后,该Sentinel会先看看自己有没有投过票,如果自己已经投过票给其他Sentinel了,在2倍故障转移的超时时间自己就不会成为Leader。相当于它是一个Follower。 2、如果该Sentinel还没投过票,那么它就成为Candidate。 3、和Raft协议描述的一样,成为Candidate,Sentinel需要完成几件事情 3.1 更新故障转移状态为start 3.2 当前epoch加1,相当于进入一个新term,在Sentinel中epoch就是Raft协议中的term。 3.3 更新自己的超时时间为当前时间随机加上一段时间,随机时间为1s内的随机毫秒数。 3.4 向其他节点发送is-master-down-by-addr命令请求投票。命令会带上自己的epoch。 3.5 给自己投一票,在Sentinel中,投票的方式是把自己master结构体里的leader和leader_epoch改成投给的Sentinel和它的epoch。 4、其他Sentinel会收到Candidate的is-master-down-by-addr命令。如果Sentinel当前epoch和Candidate传给他的epoch一样,说明他已经把自己master结构体里的leader和leader_epoch改成其他Candidate,相当于把票投给了其他Candidate。投过票给别的Sentinel后,在当前epoch内自己就只能成为Follower。 5、Candidate会不断的统计自己的票数,直到他发现认同他成为Leader的票数超过一半而且超过它配置的quorum(quorum可以参考《redis sentinel设计与实现》)。Sentinel比Raft协议增加了quorum,这样一个Sentinel能否当选Leader还取决于它配置的quorum。 6、如果在一个选举时间内,Candidate没有获得超过一半且超过它配置的quorum的票数,自己的这次选举就失败了。 7、如果在一个epoch内,没有一个Candidate获得更多的票数。那么等待超过2倍故障转移的超时时间后,Candidate增加epoch重新投票。 8、如果某个Candidate获得超过一半且超过它配置的quorum的票数,那么它就成为了Leader。 9、与Raft协议不同,Leader并不会把自己成为Leader的消息发给其他Sentinel。其他Sentinel等待Leader从slave选出master后,检测到新的master正常工作后,就会去掉客观下线的标识,从而不需要进入故障转移流程。 大致简单过程 1、每个做主观下线的sentinel节点像其他sentinel节点发送命令,要求将自己设置为领导者 2、接收到的sentinel可以同意或者拒绝 3、如果该sentinel节点发现自己的票数已经超过半数并且超过了quorum 4、如果此过程选举出了多个领导者,那么将等待一段时重新进行选举
部分复制是为了解决全量复制开销过大的一种优化措施,当从节点复制主节点数据过程中,如果出现网络中断或者命令丢失等异常情况,此时从节点可以向主节点要求补发丢失的命令数据,如果主节点的复制缓冲区中刚好存在这一部分数据,那就直接发送给从节点以此来保证主从一致性,在了解部分复制之前我们需要先知道三个概念: runid runid就是主节点的运行id,是redis启动时随机分配的一个40位的十六进制字符串,运行id用来唯一识别redis节点,从节点保存主节点的runid从而知道自己需要从哪个节点来复制数据。 之所以使用runid而不是使用host+port的方式是因为一旦aof或者rdb文件发生改变并重启了redis服务,那么从节点再基于偏移量(offset)去复制数据是不安全的。 那么已经构建的主从架构,如果要主节点出现故障需要重启怎么办呢?可以使用slaveof no one先将从节点升级为主节点,待真正的主节点重启完成后再使用slaveof重新创建主从,当然这是一种很low的做法,高级的一点做法可以使用哨兵或者集群等高可用方案。 offset offset是复制偏移量,表示主节点向从节点传递的字节数,主节点每次向从节点传递N个字节数据时,主节点的复制偏移量增加N,从节点从主节点接收N个字节数据时,从节点的复制偏移量增加N。 复制偏移量可以用来判断主从节点的一致性,如果两者复制偏移量相同,那么就是主从一致,如果主节点偏移量大于从节点偏移量,且远远大于,那么此时可能出现了网络延迟或者命令阻塞,主节点的偏移量比从节点偏移量大的部分就存在于复制缓冲区中,当从节点请求部分复制时就从复制缓冲区中获取到对应偏移量的数据传递给从节点。 复制缓冲区 顾名思义,复制缓冲区就是一个缓冲区,它是保存在主节点上一个固定长度、先进先出的队列,默认大小时1M,当主节点存在从节点时,不管是几个从节点,主节点都会将写命令发送给从节点的同时缓存到复制缓冲区中,当缓冲区占满时就会将最先进入缓冲区的数据挤出缓冲区。 当主从节点断开重连时,从节点带着offset请求复制,主节点判断从节点的offset是否存在于缓冲区中,如果存在,那么就进行部分复制,将缓冲区中的数据直接返回给从节点,如果从节点传递的offset已经超过缓冲区中的offset的值,那么就需要开启全量复制。基于此,要根据具体的业务需求调整复制缓冲区的大小,尽可能地使用部分复制。 部分复制实例 我在两台机器上分别安装了一个redis,其中一个作为另外一个的从节点,从节点: 主节点: 为了验证部分复制,现在将两台机器之间的网络断开(简单粗暴的办法,拔网线),经过一会儿之后查看主从节点的日志,可以看到各自都出现了lost,如下所示: # 主节点日志 # Connection with slave 10.18.30.178:6379 lost. # 从节点日志 1:S # MASTER timeout: no data nor PING received... 1:S # Connection with master lost. 1:S * Caching the disconnected master state. 1:S * Connecting to MASTER 10.18.30.34:6379 1:S * MASTER <-> REPLICA sync started 1:S # Error condition on socket for SYNC: No route to host 1:S * Connecting to MASTER 10.18.30.34:6379 在主从节点各自丢失对方的连接时,在主节点上执行写操作(随便写入一些数据),如下所示: 经过一段时间以后,重新连通主从节点之间的网络,此时,在从节点的日志中可以看到如下所示的内容: 1:S * Connecting to MASTER 10.18.30.34:6379 # 连接上了主节点 1:S * MASTER <-> REPLICA sync started # 主从节点开始同步 1:S * Non blocking connect for SYNC fired the event. 1:S * Master replied to PING, replication can continue... # 主节点回复了ping,主从可以继续 1:S * Trying a partial resynchronization (request eb32e34c84e7123e7ddb6ae1fab5e348bc58af31:1234). # 开始部分复制,offset是1234 1:S * Successful partial resynchronization with master. # 部分复制成功 从上面的日志可以看出来,从节点连接上主节点之后开始尝试部分复制,并且最后部分复制成功。我们再看一下此时主节点的日志: * Slave 10.18.30.178:6379 asks for synchronization # 从节点请求同步 * Partial resynchronization request from 10.18.30.178:6379 accepted. Sending 444 bytes of backlog starting from offset 1234. # 接受从节点的部分复制请求,从1234的offset位置发送444bytes的数据 也就是主节点此时判断这次的同步请求符合部分复制,那么就从对应的offset位置发送数据给从节点,如果此时offset不在复制缓冲区的范围内,那么开启的就是全量复制,而不是部分复制了。 另外,如果部分复制完成后aof文件达到了auto-aof-rewrite-min-size以及auto-aof-rewrite-percentage的要求,那么此时就会触发aof的重写。 总结一下重写的过程,就是如下几个步骤: 主从节点网络中断,超过repl-timeout时间后,主节点认为从节点故障,打印lost日志; 主从断开期间,主节点继续响应请求,会将写命令缓存到大小为1M的复制缓冲区; 主从网络恢复后,从节点会再次连接上主节点,打印主从可以继续的日志; 主从恢复后,从节点使用psync请求,带着保存好的主节点运行id以及自身已经复制的偏移量去请求进行复制; 主节点接收到从节点的psync请求后,首先判断runID是否一直,不一致的话表示之前复制的不是当前主节点,需要重新开始全量复制,一致的话再查找请求offset是否存在于复制缓冲区中,不存在的话同样要开启全量复制; runID和offset都符合部分复制的要求后,主节点会把复制缓冲区中相应的数据发送给从节点,保证主从进入正常复制;
相关链接 redis-GitHub-配置文件下载:https://github.com/redis/redis redis 官网配置文件:https://redis.io/topics/config 作者搭建记录:https://github.com/mailjobblog/dev_redis/tree/master/sentinel redis哨兵原理 - 思维导图:https://kdocs.cn/l/spXDj5U6vDKF 实战演示的服务节点分布图 :https://kdocs.cn/l/sk1lDLmDbLqY redis哨兵原理:https://blog.mailjob.net/posts/2778025183.html redis 主从复制搭建:https://blog.mailjob.net/posts/1586519326.html redis广播:https://blog.mailjob.net/posts/2416487960.html 搭建方法 实战演练 1、下载redis和哨兵的配置文件 我是用 docker 搭建的 redis 节点,没有 .conf 文件,所以我需要下载 redis.conf 和 sentinel.conf redis版本:6.0。下载地址:https://github.com/redis/redis 注释:下载配置文件的时候,请先查看自己的redis版本,然后下载匹配的redis配置文件,否则redis启动报错 2、首先建立redis主从复制 请参照上文中,相关链接中,redis 主从复制搭建 进行搭建 通过以上方法,搭建了3个redis节点 ------------------------------------------------ 名字--------IP--------------端口映射-------角色 redis1------172.30.0.10-----6000->6379-----mater redis2------172.30.0.11-----6001->6379-----slave redis3------172.30.0.12-----6002->6379-----slave ------------------------------------------------ 3、开始建立3个哨兵节点 # 在 /data/redis_group/ 目录,创建 data4~data6 三个文件夹做数据卷 # 创建redis哨兵节点 docker run -itd -p 26000:26379 --network netredis --ip 172.30.0.20 -v /data/redis_group/data4:/data -v /data/redis_group/sentinel/sentinel-s1.conf:/etc/sentinel.conf --name redis-s1 [container id] docker run -itd -p 26001:26379 --network netredis --ip 172.30.0.21 -v /data/redis_group/data5:/data -v /data/redis_group/sentinel/sentinel-s2.conf:/etc/sentinel.conf --name redis-s2 [container id] docker run -itd -p 26002:26379 --network netredis --ip 172.30.0.22 -v /data/redis_group/data6:/data -v /data/redis_group/sentinel/sentinel-s3.conf:/etc/sentinel.conf --name redis-s3 [container id] # 哨兵节点如下 ------------------------------------------ 名字----------IP--------------端口映射---- redis-s1------172.30.0.20-----26000->26379 redis-s2------172.30.0.21-----26001->26379 redis-s3------172.30.0.22-----26002->26379 ------------------------------------------ 4、修改哨兵的配置文件 哨兵 redis-s1 配置 vim /data/redis_group/sentinel/sentinel-s1.conf daemonize yes logfile "/data/log-sentinel.log" sentinel monitor mymaster 172.30.0.10 6379 2 改好一份后,我复制了两份出来,sentinel-s2.conf、sentinel-s3.conf 这样,三个节点都有了配置文件 mymaster -是节点的起的别名,如果是多个集群,可以用集群的名字 6379 -是redis的端口 2 -至少需要2个哨兵节点同意,才能判定主节点故障并进行故障转移 5、启动哨兵节点 # 进入哨兵容器 docker exec -it [container id] bash # 启动哨兵 redis-sentinel sentinel.conf redis-server sentinel.conf --sentinel (二者作用是完全相同的) 6、查看是否配置成功 用 redis-cli 客户端操作哨兵 (26379是哨兵的默认端口) >> redis-cli -p 26379 查看配置信息 127.0.0.1:26379> info sentinel (或者使用命令也可以查看:sentinel master mymaster 【mymaster是配置哨兵的别名,自定义的名字】) # Sentinel sentinel_masters:1 sentinel_tilt:0 sentinel_running_scripts:0 sentinel_scripts_queue_length:0 sentinel_simulate_failure_flags:0 master0:name=mymaster,status=ok,address=172.30.0.12:6379,slaves=2,sentinels=3 这里可以看到主节点的redis信息,从节点的个数,哨兵个数 7、模拟宕机测试 # 我把 (master)172.30.0.10 这个容器进行stop宕机 # 30s 后去查看了 172.30.0.10 的日志如下 # 发现主(msater)节点 172.30.0.10 转移到了 172.30.0.12 25:X 05 Feb 2021 14:49:50.331 # +sdown slave 172.30.0.10:6379 172.30.0.10 6379 @ mymaster 172.30.0.12 6379 # 去 172.30.0.11 查看了一下,12 变成了主节点 127.0.0.1:6379> info replication # Replication role:slave master_host:172.30.0.12 # 再次启动 172.30.0.10 ,发现它已经变成了从节点 127.0.0.1:6379> info replication # Replication role:master connected_slaves:2 slave0:ip=172.30.0.11,port=6379,state=online,offset=547584,lag=0 slave1:ip=172.30.0.10,port=6379,state=online,offset=547584,lag=1 搭建报错问题 哨兵启动报错 root@85cbceb66bad:/data# redis-server /etc/sentinel.conf --sentinel *** FATAL CONFIG FILE ERROR (Redis 6.0.10) *** Reading the configuration file, at line 336 >>> 'SENTINEL resolve-hostnames no' Unrecognized sentinel configuration statement. 这个是由于 docker 按照的 redis 版本和 引入数据卷使用的哨兵配置文件,版本不一致导致的
相关链接 redis-GitHub-配置文件下载:https://github.com/redis/redis redis 官网配置文件:https://redis.io/topics/config redis哨兵原理 - 思维导图:https://kdocs.cn/l/spXDj5U6vDKF 实战演示的服务节点分布图 :https://kdocs.cn/l/sk1lDLmDbLqY redis 主从复制搭建:https://blog.mailjob.net/posts/1586519326.html redis广播:https://blog.mailjob.net/posts/2416487960.html 优秀文章: http://redis.cn/articles/20181020001.html 搭建步骤 # 让sentinel服务后台运行 daemonize yes Sentinel 的核心配置: sentinel monitor mymaster 127.0.0.1 6379 2 监控的主节点的名字、IP 和端口, 最后一个2的意思是有几台 Sentinel 发现有问题,就会发生故障转移 例如 配置为2,代表至少有2个 Sentinel 节点认为主节点 不可达,那么这个不可达的判定才是客观的。 对于设置的越小,那么达到下线的条件越宽松,反之越严格。一般建议将其设置为 Sentinel 节点的一半加1 注意:最后的参数不得大于conut(sentinel) sentinel down-after-millseconds mymaster 30000 这个是超时的时间(单位为毫秒)。打个比方,当你去 ping 一个机器的时候,多长时间后仍 ping 不 通,那么就认为它是有问题 sentinel parallel-syncs mymaster 1 当 Sentinel 节点集合对主节点故障判定达成一致时, Sentinel 领导者节点会做故障转移操作,选出新 的主节点,原来的从节点会向新的主节点发起复制操 作, parallel-syncs 就是用来限制在一次故障转移 之后,每次向新的主节点发起复制操作的从节点个数,指出 Sentinel 属于并发还是串行。1代表每次只 能 复制一个,可以减轻 Master 的压力 sentinel auth-pass master-name password 如果 Sentinel 监控的主节点配置了密码,sentinel auth-pass 配置通过添加主节点的密码,防止 Sentinel 节点对主节点无法监控。 sentinel failover-timeout mymaster 180000 表示故障转移的时间(单位:毫秒) Sentinel命令 sentinel是一个特殊的redis节点,它有自己专属的api; 1. sentinel masters 显示被监控的所有master以及它们的状态. 2. sentinel master 显示指定master的信息和状态; 3. sentinel slaves 显示指定master的所有slave以及它们的状态; 4. sentinel get-master-addr-by-name 返回指定master的ip和端口, 如果正在进行failover或者 failover已经完成,将会显示被提升 为master的slave的ip和端口。 5. sentinel failover 强制sentinel执行failover,并且不需要得到其他sentinel的同意。 但是failover 后会将最新的配置发送给其他 sentinel。 哨兵原理 为什么要做哨兵? Redis 主从复制有一个缺点,当主机 Master 宕机以后,我们需要人工解决切换,如使用 slaveof no one 。 实际上主从复制 并没有实现高可用。 高可用侧重备份机器, 利用集群中系统的冗余,当系统中某台机器发生损坏的时候,其他后备的机器 可以迅速的接替它来启动服务 实现逻辑 Redis Sentinel 一个分布式架构,其中包含若干个 Sentinel 节点和 Redis 数据节点,每个 Sentinel 节 点会对数据节点和其余 Sentinel 节点进行监 控,当它发现节点不可达时,会对节点做下线标识。 如果被标识的是主节点,它还会和其他 Sentinel 节点进行“协商”,当大多数 Sentinel 节点都认为主节点 不可达时,它们会选举出一个 Sentinel 节点来 完成自动故障转移的工作,同时会将这个变化实时通知 给 Redis 应用方。 可查看上文中【实战演示的服务节点分布图 】转移图 Redis Sentinel 具有以下几个功能: 监控:Sentinel 节点会定期检测 Redis 数据节点、其余 Sentinel 节点是否可达 通知:Sentinel 节点会将故障转移的结果通知给应用方 主节点故障转移: 实现从节点晋升为主节 点并维护后续正确的主从关系 配置提供者: 在 Redis Sentinel 结构中,客户端在初始化的时候连接的是 Sentinel 节点集合 ,从 中获取主节点信息。 Redis Sentinel 包含多个节点好处: 对于节点的故障判断是由多个 Sentinel 节点共同完成,这样可以有效地防止误判。 Sentinel 节点集合是由若干个 Sentinel 节点组成的,这样即使个别 Sentinel 节点不可用,整个 Sentinel 节点集合依然是健壮的。 哨兵监控原理 请查阅上文中的【redis发布订阅】 三个定时任务 首先要讲的是内部 Sentinel 会执行以下三个定时任务。 每10秒每个 Sentinel 对 Master 和 Slave 执行一次 Info Replication 。 每2秒每个 Sentinel 通过 Master 节点的 channel 交换信息(pub/sub)。 每1秒每个 Sentinel 对其他 Sentinel 和 Redis 执行 ping 。 第一个定时任务,指的是 Redis Sentinel 可以对 Redis 节点做失败判断和故障转移,在 Redis 内部有三 个定时任务作为基础,来 Info Replication 发现 Slave 节点,这个命令可以确定主从关系。 第两个定时任务,类似于发布订阅, Sentinel 会对主从关系进行判定,通过 sentinel:hello 频道交互。 了解主从关系可以帮助更好的自动化操作 Redis 。然后 Sentinel 会告知系统消息给其它 Sentinel 节 点,最终达到共识,同时 Sentinel 节点能够互相感知到对方。 主观下线 首先解析一下什么叫主观下线,所谓主观下线,就是单个sentinel认为某个服务下线(有可能是接收不到订阅,之间的网络不通等等原因)。 sentinel会以每秒一次的频率向所有与其建立了命令连接的实例(master,从服务,其他sentinel)发ping命令,通过判断ping回复是有效回复,还是无效回复来判断实例时候在线(对该sentinel来说是“主观在线”)。 客观下线 当sentinel监视的某个服务主观下线后,sentinel会询问其它监视该服务的sentinel,看它们是否也认为该服务主观下线,接收到足够数量(这个值可以配置)的sentinel判断为主观下线,既任务该服务客观下线,并对其做故障转移操作。 sentinel通过发送 SENTINEL is-master-down-by-addr ip port current_epoch runid,(ip:主观下线的服务id,port:主观下线的服务端口,current_epoch:sentinel的纪元,runid:*表示检测服务下线状态,如果是sentinel 运行id,表示用来选举领头sentinel)来询问其它sentinel是否同意服务下线。 选举领头sentinel 一个redis服务被判断为客观下线时,多个监视该服务的sentinel协商,选举一个领头sentinel,对该redis服务进行古战转移操作。选举领头sentinel遵循以下规则: 所有的sentinel都有公平被选举成领头的资格 所有的sentinel都有且只有一次将某个sentinel选举成领头的机会(在一轮选举中),一旦选举某个sentinel为领头,不能更改 sentinel设置领头sentinel是先到先得,一旦当前sentinel设置了领头sentinel,以后要求设置sentinel为领头请求都会被拒绝 每个发现服务客观下线的sentinel,都会要求其他sentinel将自己设置成领头 当一个sentinel(源sentinel)向另一个sentinel(目sentinel)发送is-master-down-by-addr ip port current_epoch runid命令的时候,runid参数不是*,而是sentinel运行id,就表示源sentinel要求目标sentinel选举其为领头 源sentinel会检查目标sentinel对其要求设置成领头的回复,如果回复的leader_runid和leader_epoch为源sentinel,表示目标sentinel同意将源sentinel设置成领头 如果某个sentinel被半数以上的sentinel设置成领头,那么该sentinel既为领头 如果在限定时间内,没有选举出领头sentinel,暂定一段时间,再选举 各个哨兵选举master依据点: 选择健康状态从节点(排除主观下线、断线),排除5秒钟没有心跳的、排除主节点失联超过10*down-after-millisecends。 选择最高优先级中复制偏移量最大的从机。 如果还没有选出来,则按照ID排序,获取 run id 最小的从节点。 为什么要选偏移量最大的:偏移量大,代表复制的数据更全 为什么要选 run id 最小的:runid就是节点的运行id,最小的就是最先启动的,数据可能更全 如何进行故障转移 故障转移分为三个主要步骤 a. 从下线的主服务的所有从服务里面挑选一个从服务,将其转成主服务 sentinel状态数据结构中保存了主服务的所有从服务信息,领头sentinel按照如下的规则从从服务列表中挑选出新的主服务 删除列表中处于下线状态的从服务 删除最近5秒没有回复过领头sentinel info信息的从服务 删除与已下线的主服务断开连接时间超过 down-after-milliseconds*10毫秒的从服务,这样就能保留从的数据比较新(没有过早的与主断开连接) 领头sentinel从剩下的从列表中选择优先级高的,如果优先级一样,选择偏移量最大的(偏移量大说明复制的数据比较新),如果偏移量一样,选择运行id最小的从服务 b. 已下线主服务的所有从服务改为复制新的主服务 挑选出新的主服务之后,领头sentinel 向原主服务的从服务发送 slaveof 新主服务 的命令,复制新master c. 将已下线的主服务设置成新的主服务的从服务,当其回复正常时,复制新的主服务,变成新的主服务的从服务 常见问题 客户端如何调用redis Master可能会因为某些情况宕机了,如果在客户端是固定一个地址去访问,肯定是不合理的,所以客户 端请求是请求哨兵,从哨兵获取主机地址的信息,或者是 从机的信息。可以实现一个例子 1、随机选择一个哨兵连接,获取主机、从机信息 2、模拟客户端定时访问,实现简单轮训效果,轮训从节点 3、连接失败重试访问 思路 sentinel节点集合具备了监控、通知、自动故障转移、配置提供着若干功能,也就是说实际上最了解主 节点的就是sentinel节点集合,而各个主节点可以通过 进行标识的,所以,无论是那种编程语言的客户 端,如果需要正确地连接redis sentinel 1. 遍历sentinel节点集合获取一个可用的sentinel节点,sentinel会共享数据,所以从任意一个 sentinel节点获取主节点信息都可以 2. 通过 sentinel get-master-addr-by-name master-name 这个api来获取对应主节点的相关信息 3. 验证当前获取的“主节点”是真正的主节点,这样做的目的是未来放置故障转移期间主节点的变化 4. 保持和sentinel节点集合的“联系”,时刻获取关于主节点的相关“信息” 搭建报错问题 1、异步复制导致数据丢失 因为master->slave的复制是异步,所以可能有部分还没来得及复制到slave就宕机了,此时这些部分数 据就丢失了。 解决方案: 在异步复制的过程当中,通过 min-slaves-max-lag 这个配置,就可以确保的说,一旦 slave 复制数据 和 ack 延迟时间太长,就认为可能 master 宕机 后损失的数据太多了,那么就拒绝写请求,这样就可以 把master宕机时由于部分数据未同步到 slave 导致的数据丢失降低到可控范围内 2、集群脑裂导致数据丢失 脑裂,也就是说,某个master所在机器突然脱离了正常的网络,跟其它slave机器不能连接,但是实际 上master还运行着。 解决方案: 原redis主节点活了以后,会自动变成从节点。不用我们自己处理 哨兵启动报错 root@85cbceb66bad:/data# redis-server /etc/sentinel.conf --sentinel *** FATAL CONFIG FILE ERROR (Redis 6.0.10) *** Reading the configuration file, at line 336 >>> 'SENTINEL resolve-hostnames no' Unrecognized sentinel configuration statement. 这个是由于 docker 按照的 redis 版本和 引入数据卷使用的哨兵配置文件,版本不一致导致的
相关链接 作者搭建记录:https://github.com/mailjobblog/dev_redis/tree/master/master_slave redis中国区文档:http://redis.cn/topics/replication.html redis主从复制原理:https://blog.mailjob.net/posts/707463622.html redis-GitHub(可下载redis.conf和sentinel.conf):https://github.com/redis/redis # 创建一个网络 docker network create --subnet=172.30.0.0/16 netredis # 查看该网络的信息 docker network inspect netredis -------------------------------- "Subnet": "172.30.0.0/16" -------------------------------- # 查看之前pull的redis镜像 docker images -------------------------------- REPOSITORY TAG IMAGE ID CREATED SIZE redis latest 621ceef7494a 3 weeks ago 104MB -------------------------------- # redis.conf 和 sentinel.conf 下载 下载地址:https://github.com/redis/redis # 在 /data/redis_group/masterslave 目录复制了3分redis.conf -------------------------------- [root@VM-0-15-centos masterslave]# pwd /data/redis_group/masterslave [root@VM-0-15-centos masterslave]# ls redis1.conf redis2.conf redis3.conf [root@VM-0-15-centos masterslave]# -------------------------------- # 在 /data/redis_group/ 目录,创建 data1~data3 三个文件夹做数据卷 # 创建redis主从复制节点 docker run -itd -p 6000:6379 --network netredis --ip 172.30.0.10 -v /data/redis_group/data1:/data -v /data/redis_group/masterslave/redis1.conf:/etc/redis.conf --name redis1 [container id] docker run -itd -p 6001:6379 --network netredis --ip 172.30.0.11 -v /data/redis_group/data2:/data -v /data/redis_group/masterslave/redis2.conf:/etc/redis.conf --name redis2 [container id] docker run -itd -p 6002:6379 --network netredis --ip 172.30.0.12 -v /data/redis_group/data3:/data -v /data/redis_group/masterslave/redis3.conf:/etc/redis.conf --name redis3 [container id] # 如果要开启数据持久化,加上这个。当然你也可以后面再改配置文件 --appendonly yes # 配置主从 1、分别进入两个从节点的容器 redis2、redis3 2、docker exec -it xxx /bash/bin # 分别进入两个从容器 3、运行 redis-cli 命令,开始在redis命令窗口配置 4 进行配置 # 配置主节点 127.0.0.1:6379> slaveof 172.30.0.10 6379 # 命令窗,查看节点配置信息 127.0.0.1:6379> info replication # redis主从节点如下 ------------------------------------------------ 名字--------IP--------------端口映射-------角色 redis1------172.30.0.10-----6000->6379-----mater redis2------172.30.0.11-----6001->6379-----slave redis3------172.30.0.12-----6002->6379-----slave ------------------------------------------------ redis 主节点查看配置信息 可以看到两个从节点 可以看到当前机器的角色是 master redis 从节点查看配置信息
参考资料 作者搭建记录:https://github.com/mailjobblog/dev_redis/tree/master/master_slave redis中国区文档:http://redis.cn/topics/replication.html redis-GitHub(可下载redis.conf和sentinel.conf):https://github.com/redis/redis redis 官网配置文件:https://redis.io/topics/config 优秀文章:https://www.cnblogs.com/kismetv/p/9236731.html 如何搭建 搭建命令 需要注意,主从复制的开启,完全是在从节点发起的;不需要我们在主节点做任何事情。 有3种配置方式: (1)配置文件 在从服务器的配置文件中加入:slaveof <masterip> <masterport> (2)启动命令 redis-server启动命令后加入 --slaveof <masterip> <masterport> (3)客户端命令 Redis服务器启动后,直接通过客户端执行命令:slaveof <masterip> <masterport>,则该Redis实例成为从节点。 密码验证,如果主节点的redis设置了密码,需要加入以下参数验证主节点的密码 (1)配置文件 masterauth <password> (2)客户端命令 config set requirepass <password> 查看节点配置信息 127.0.0.1:6379 >> info replication 名次解释 masterip:主节点redis的ip masterport:主节点redis的端口 master:主节点 slave:从节点 注意点: 搭建完成后,要用 info replication 查看配置是否成功 若用docker的话, run的时候记得要指定数据卷 docker的几个容器要在同一个网桥上面 实现原理 全量同步 Redis全量复制一般发生在Slave初始化阶段,这时Slave需要将Master上的所有数据都复制一份。具体步骤如下: 1)从服务器连接主服务器,发送SYNC命令; 2)主服务器接收到SYNC命名后,开始执行BGSAVE命令生成RDB文件并使用缓冲区记录此后执行的所有写命令; 3)主服务器BGSAVE执行完后,向所有从服务器发送快照文件,并在发送期间继续记录被执行的写命令; 4)从服务器收到快照文件后丢弃所有旧数据,载入收到的快照; 5)主服务器快照发送完毕后开始向从服务器发送缓冲区中的写命令; 6)从服务器完成对快照的载入,开始接收命令请求,并执行来自主服务器缓冲区的写命令; 完成上面几个步骤后就完成了从服务器数据初始化的所有操作,从服务器此时可以接收来自用户的读请求。 增量同步演示步骤 1、从节点执行slaveof 主节点host 主节点port命令后,在redis会打印如下所示的日志信息: * REPLICAOF 192.168.216.129:6379 enabled (user request from 'id=4 addr=127.0.0.1:58476 fd=9 name= age=142 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=49 qbuf-free=32719 argv-mem=26 obl=0 oll=0 omem=0 tot-mem=61490 events=r cmd=slaveof use r=default') slaveof是异步命令,执行完该命令后从节点保存主节点的host和port信息,但是并未真正开始复制; 2、创建socket连接,从节点内部定时每秒执行一次复制定时函数replicationCron,当发现存在可以连接主节点时就会根据主节点的信息创建socket连接,如果节点无法连接就会无限重试或者直到执行slaveof no one命令,此时redis会打印如下所示日志: * Connecting to MASTER 192.168.216.129:6379 * MASTER <-> REPLICA sync started 此时主节点会给从节点的socket连接创建客户端状态并将其当作主节点上的一个客户端,使用client list命令是可以明确看到这个客户端的,如下所示: 3、从节点发送ping命令,等待主节点回复pong,用以检测socket是否可用以及主节点是否可接受处理命令,如果从节点接收响应超时或者接受到pong以外的响应,从节点就会断开复制链接,等待下次定时任务时再发起重连,日志如下: * Master replied to PING, replication can continue... 4、masterauth验证,如果主节点设置了密码,那么此时需要验证密码才可以进行下一步操作,密码验证失败的话会断开连接,等待下一次重连; 5、数据同步,数据同步其实就是从节点的初始化的过程,数据同步包含全量同步以及部分同步,同步的过程后面具体分析,另外要注意一点的是在数据同步阶段主节点需要主动向从节点发送请求,因此此时主从节点互为客户端,数据同步对应的日志如下所示: * Trying a partial resynchronization (request 6c4167e7160b6bb316de536f24a93b1f260b2f10:1). * Full resync from master: ac0f31e4a32d99d77c2007009c312868023713a9:840612 * Discarding previously cached master state. * MASTER <-> REPLICA sync: receiving 269 bytes from master to disk * MASTER <-> REPLICA sync: Flushing old data * MASTER <-> REPLICA sync: Loading DB in memory * Loading RDB produced by version 6.0.9 * RDB age 0 seconds * RDB memory usage when created 1.85 Mb * MASTER <-> REPLICA sync: Finished with success 6、持续性复制,经过前面五个步骤,正常情况下主从已经创建成功,之后主节点会源源不断的将写命令发送到从节点,从而保证主从一致性 增量同步 Redis增量复制是指Slave初始化后开始正常工作时主服务器发生的写操作同步到从服务器的过程。 增量复制的过程主要是主服务器每执行一个写命令就会向从服务器发送相同的写命令,从服务器接收并执行收到的写命令。 Redis主从同步策略 主从刚刚连接的时候,进行全量同步;全同步结束后,进行增量同步。当然,如果有需要,slave 在任何时候都可以发起全量同步。redis 策略是,无论如何,首先会尝试进行增量同步,如不成功,要求从机进行全量同步。 增量同步实现 1)从服务器向主服务器发送PSYNC命令,携带主服务器的runid和复制偏移量; 2)主服务器验证runid和自身runid是否一致,如不一致,则进行全量复制; 3)主服务器验证复制偏移量是否在积压缓冲区内,如不在,则进行全量复制; 4)如都验证通过,则主服务器将保持在积压区内的偏移量后的所有数据发送给从服务器,主从服务器再次回到一致状态。 注意点 如果多个Slave断线了,需要重启的时候,因为只要Slave启动,就会发送sync请求和主机全量同步,当多个同时出现的时候,可能会导致Master IO剧增宕机。 存在问题 1、复制风暴 产生原因: 当从服务器大量宕机之后,又同时启动。这个时候,从服务器同时去同步主服务器的redis,这个时候会产生大量的并发,导致主服务器宕机发生 解决方案: 1、采用下方图示方案解决,一部分从节点挂到一些从节点上面。这个时候从库同步到主库的redis后,其他从库再从这个从库同步 2、采用多个主节点策略,就是多个master节点,这个就牵扯到了redis集群策略了 2、主从数据不同步 产生原因: 由于主库和从库的同步有一个过程,所以可能产生master库某些写入的数据,slave 短暂的读不到 如何解决: 1、如果这个数据不是很急的话,可以逻辑层面可以做一个延迟策略 2、如果你必须想要读取到这个数据,有这么几个办法: 3、 可以对slave的偏移量值进行监控,如果发现某台slave的偏移量有问题,则将数据读取操作切换到master,但本身这个监控开销比较高 3、读到过期数据(脏数据) 产生原因: redis在删除过期key的时候,是有两种策略,第一种是懒惰型策略,即只有当redis操作这个key的时候,发现这个key过期,就会把这个key删除。第二种是定期采样一些key进行删除 redis主从同步配置中,我们知道,redis里master和slave达成一种协议,slave是不能处理数据的(即不能删除数据)而我们的客户端没有及时读到到过期数据同步给master将key删除,就会导致slave读到过期的数据 如何解决: 这个问题已经在redis3.2版本中解决 4、数据延迟造成的脏数据 产生原因: master会异步的将数据复制到slave,如果这是slave发生阻塞,则会延迟master数据的写命令,造成数据不一致的情况 解决方案: 可以对slave的偏移量值进行监控,如果发现某台slave的偏移量有问题,则将数据读取操作切换到master 5、主从配置不一致 这个问题一般很少见,但如果有,就会发生很多诡异的问题 例如: 1. maxmemory配置不一致:这个会导致数据的丢失 原因:例如master配置4G,slave配置2G,这个时候主从复制可以成功,但,如果在进行某一次全量复制的时候,slave拿到master的RDB加载数据时发现自身的2G内存不够用,这时就会触发slave的maxmemory策略,将数据进行淘汰。更可怕的是,在高可用的集群环境下,如果我们将这台slave升级成master的时候,就会发现数据已经丢失了。 2. 数据结构优化参数不一致(例如hash-max-ziplist-entries):这个就会导致内存不一致 原因:例如在master上对这个参数进行了优化,而在slave没有配置,就会造成主从节点内存不一致的诡异问题。
相关链接 思维导图:https://kdocs.cn/l/sptprEzrCRMi 故事背景 试想一下,在一个保存100个联系人里面,我要想快速找到某一个人,最多需要多少次。因为通讯录是按照abcd依次排序,我只需要7次就可以命中。因为 log2 100 等于7 示例图 从给定的数字中,命中给定值(85):1,2,6,8,9,10,15,19,35,49,60,61,70,85,90,99,100 PHP实现 <?php // 二分查找法 function binSearch($arr, $search) { $height = count($arr)-1;$low = 0; while ($low <= $height) { $mid = floor(($low + $height) / 2);//获取中间数 if ($arr[$mid] == $search) { return $mid;//返回 } elseif ($arr[$mid] < $search) {//当中间值小于所查值时,则$mid左边的值都小于$search,此时要将$mid赋值给 $low $low = $mid + 1; } elseif ($arr[$mid] > $search) {//中间值大于所查值,则$mid右边的所有值都大于$search,此时要将$mid赋值给$height $height = $mid-1; } } return "查找失败"; } // 顺序查找法 function seqSearch($arr, $k) { foreach ($arr as $key => $val) { if ($val == $k) { return $key; } } return -1; }// 测试 $arr=array(5,10,19,22,33,44,48,55,60,68); // echo binSearch($arr,44).'<br/>'; // echo seqSearch($arr,44).'<br/>';
链接: https://kdocs.cn/l/snOTlPnlNDWn 一、 id SELECT识别符。这是SELECT的查询序列号 我的理解是SQL执行的顺序的标识,SQL从大到小的执行 \1. id相同时,执行顺序由上至下 \2. 如果是子查询,id的序号会递增,id值越大优先级越高,越先被执行 \3. id如果相同,可以认为是一组,从上往下顺序执行;在所有组中,id值越大,优先级越高,越先执行 -- 查看在研发部并且名字以Jef开头的员工,经典查询 explain select e.no, e.name from emp e left join dept d on e.dept_no = d.no where e.name like 'Jef%' and d.name = '研发部'; 二、select_type *示查询中每个select子句的类型* (1) SIMPLE(简单SELECT,不使用UNION或子查询等) (2) PRIMARY(子查询中最外层查询,查询中若包含任何复杂的子部分,最外层的select被标记为PRIMARY) (3) UNION(UNION中的第二个或后面的SELECT语句) (4) DEPENDENT UNION(UNION中的第二个或后面的SELECT语句,取决于外面的查询) (5) UNION RESULT(UNION的结果,union语句中第二个select开始后面所有select) (6) SUBQUERY(子查询中的第一个SELECT,结果不依赖于外部查询) (7) DEPENDENT SUBQUERY(子查询中的第一个SELECT,依赖于外部查询) (8) DERIVED(派生表的SELECT, FROM子句的子查询) (9) UNCACHEABLE SUBQUERY(一个子查询的结果不能被缓存,必须重新评估外链接的第一行) 三、table 显示这一步所访问数据库中表名称(显示这一行的数据是关于哪张表的),有时不是真实的表名字,可能是简称,例如上面的e,d,也可能是第几步执行的结果的简称 四、type 对表访问方式,表示MySQL在表中找到所需行的方式,又称“访问类型”。 常用的类型有: **ALL、index、range、 ref、eq_ref、const、system、**NULL(从左到右,性能从差到好) ALL:Full Table Scan, MySQL将遍历全表以找到匹配的行 index: Full Index Scan,index与ALL区别为index类型只遍历索引树 range:只检索给定范围的行,使用一个索引来选择行 ref: 表示上述表的连接匹配条件,即哪些列或常量被用于查找索引列上的值 eq_ref: 类似ref,区别就在使用的索引是唯一索引,对于每个索引键值,表中只有一条记录匹配,简单来说,就是多表连接中使用primary key或者 unique key作为关联条件 const、system: 当MySQL对查询某部分进行优化,并转换为一个常量时,使用这些类型访问。如将主键置于where列表中,MySQL就能将该查询转换为一个常量,system是const类型的特例,当查询的表只有一行的情况下,使用system NULL: MySQL在优化过程中分解语句,执行时甚至不用访问表或索引,例如从一个索引列里选取最小值可以通过单独索引查找完成。 五、possible_keys 指出MySQL能使用哪个索引在表中找到记录,查询涉及到的字段上若存在索引,则该索引将被列出,但不一定被查询使用(该查询可以利用的索引,如果没有任何索引显示 null) 该列完全独立于EXPLAIN输出所示的表的次序。这意味着在possible_keys中的某些键实际上不能按生成的表次序使用。 如果该列是NULL,则没有相关的索引。在这种情况下,可以通过检查WHERE子句看是否它引用某些列或适合索引的列来提高你的查询性能。如果是这样,创造一个适当的索引并且再次用EXPLAIN检查查询 六、Key key列显示MySQL实际决定使用的键(索引),必然包含在possible_keys中 如果没有选择索引,键是NULL。要想强制MySQL使用或忽视possible_keys列中的索引,在查询中使用FORCE INDEX、USE INDEX或者IGNORE INDEX。 七、key_len 表示索引中使用的字节数,可通过该列计算查询中使用的索引的长度(key_len显示的值为索引字段的最大可能长度,并非实际使用长度,即key_len是根据表定义计算而得,不是通过表内检索出的) 不损失精确性的情况下,长度越短越好 八、ref 列与索引的比较,表示上述表的连接匹配条件,即哪些列或常量被用于查找索引列上的值 九、rows 估算出结果集行数,表示MySQL根据表统计信息及索引选用情况,估算的找到所需的记录所需要读取的行数 十、Extra 该列包含MySQL解决查询的详细信息,有以下几种情况: Using where:不用读取表中所有信息,仅通过索引就可以获取所需数据,这发生在对表的全部的请求列都是同一个索引的部分的时候,表示mysql服务器将在存储引擎检索行后再进行过滤 Using temporary:表示MySQL需要使用临时表来存储结果集,常见于排序和分组查询,常见 group by ; order by Using filesort:当Query中包含 order by 操作,而且无法利用索引完成的排序操作称为“文件排序” -- 测试Extra的filesort explain select * from emp order by name; Using join buffer:改值强调了在获取连接条件时没有使用索引,并且需要连接缓冲区来存储中间结果。如果出现了这个值,那应该注意,根据查询的具体情况可能需要添加索引来改进能。 Impossible where:这个值强调了where语句会导致没有符合条件的行(通过收集统计信息不可能存在结果)。 Select tables optimized away:这个值意味着仅通过使用索引,优化器可能仅从聚合函数结果中返回一行 No tables used:Query语句中使用from dual 或不含任何from子句 -- explain select now() from dual; 总结:** • EXPLAIN不会告诉你关于触发器、存储过程的信息或用户自定义函数对查询的影响情况 • EXPLAIN不考虑各种Cache • EXPLAIN不能显示MySQL在执行查询时所作的优化工作 • 部分统计信息是估算的,并非精确值 • EXPALIN只能解释SELECT操作,其他操作要重写为SELECT后查看执行计划。** 通过收集统计信息不可能存在结果
myisam索引实现方式 MyISAM索引文件和数据文件是分离的,索引文件仅保存数据记录的地址。 MyISAM的索引与行记录是分开存储的,叫做 非聚集索引(UnClustered Index)。 其主键索引与普通索引没有本质差异: 有连续聚集的区域单独存储行记录,主键索引的叶子节点,存储主键,与对应行记录的指针,普通索引的叶子结点,存储索引列,与对应行记录的指针; 举个例子:有用户表如下:id为主键,name字段为普通索引 id name desc text 1 sj m A 5 ls m A 9 ww f B 主键索引与普通索引是两棵独立的索引B+树,通过索引列查找时,先定位到B+树的叶子节点,再通过指针定位到行记录。 行记录单独存储,id为PK,有一棵id的索引树,叶子指向行记录,name为KEY,有一棵name的索引树,叶子也指向行记录。 innodb索引实现方式 InnoDB的主键索引与行记录是存储在一起的,故叫做聚集索引(Clustered Index): 没有单独区域存储行记录,主键索引的叶子节点,存储主键,与对应行记录(而不是指针) 因为这个特性,InnoDB的表必须要有聚集索引: (1) 如果表定义了PK,则PK就是聚集索引; (2) 如果表没有定义PK,则第一个非空unique列是聚集索引; (3) 否则,InnoDB会创建一个隐藏的row-id作为聚集索引; 聚集索引,也只能够有一个,因为数据行在物理磁盘上只能有一份聚集存储。 聚集索引,也只能够有一个,因为数据行在物理磁盘上只能有一份聚集存储。 InnoDB的普通索引可以有多个,它与聚集索引是不同的**:普通索引的叶子节点,存储主键(也不是指针)** 对于InnoDB表,这里的启示是: (1)不建议使用较长的列做主键,例如char(64),因为所有的普通索引都会存储主键,会导致普通索引过于庞大; (2)建议使用趋势递增的key做主键,由于数据行与索引一体,这样不至于插入记录时,有大量索引分裂,行记录移动; 先通过name辅助索引定位到B+树的叶子节点得到 id=xxx,再通过聚集索引定位到行记录。 InnoDB 回表问题 如粉红色路径,需要扫码两遍索引树: (1)先通过普通索引定位到主键值id=5; (2)在通过聚集索引定位到行记录; 这就是所谓的回表查询,先定位主键值,再定位行记录,它的性能较扫一遍索引树更低。 什么是覆盖索引? mysql官网表达意思:只需要在一棵索引树上就能获取SQL所需的所有列数据,无需回表,速度更快。 概念:索引覆盖:通过普通索引查询的时候,不需要回表查询,直接可以获取到对应的数据 该怎么实现覆盖索引,提高效率? 创建联合索引(name,sex) 可以避免回表查询提高效率 索引的使用跟查询的and的前后顺序没有关系: 创建(name,sex,flag)索引,以下这两个查询都走索引 select name,id,sex from t where name = “lisi” and sex = “m”; select name,id,sex from t where sex = “m” and name = “lisi” ; 以下查询不走索引: select name,id,sex from t where sex = “m”; 建了一个索引idx(A, B, C), 说的是要使用A, AB, ABC这样的顺序查询, 而使用B, BC, 这样是使用不到索引的 聚集索引和非聚集索引 聚集索引(聚集索引也叫聚簇索引) 1,如果一个主键被定义了,那么这个主键就是作为聚集索引 2,如果没有主键被定义,那么该表的第一个唯一非空索引被作为聚集索引 3,如果没有主键也没有合适的唯一索引,那么innodb内部会生成一个隐藏的主键作为聚集索引,这个隐藏的主键是一个6个字节的列,改列的值会随着数据的插入自增 非聚集索引 因为mysiam的存储两个文件是分开的,索引文件存储的是文件的地址,并不需要通过 (pk主键)找到数据,所以我们说mysql是非聚集索引
相关链接 5叉树数据结构示例:https://kdocs.cn/l/suxIgTCLm4OX 平衡2叉树演示:https://www.cnblogs.com/zhangbaochong/p/5164994.html BTree 和 B+Tree 示例图 BTree 和 B+Tree 的区别 1、b+tree 的叶子节点只保存键值的信息 2、b+tree 所有的叶子节点都有一个链指针(形成链表,有利于做范围查找,排序,不用再从根节点查找一次了) 3、b+tree 所有的数据都保存在叶子节点中 4、B+树有个缺点,就是不论查什么数据都必须要遍历到叶子节点才可以拿到真实的数据地址 B+Tree 的优势 1、每个数据块的存储空间是有限的(默认16KB),如果数据了过大的话,将会导致key的容纳数量变小 2、key的容量变小,意味着要继续进行二叉树分裂,那么tree的深度就会变深,增大磁盘 I/O 次数 3、innodb数据分布在叶子节点,并且指针连接形成链表,排序和范围查找效率更高 mysiam 和 innodb 索引机制区别 1、myisam的索引取和数据区是分成两个文件存储的,innodb是吧索引和数据放在一起的 2、myisam的索引是一个单独的书,每个节点都有数据的存储地址;innodb只有主键索引有数据的存储地址,其他索引存储的是主键索引的关键字 3、myisam如果数据块上有值,可以直接拿到数据。innodb必须要到叶子节点拿值
CommitMessage的作用 格式化的Commit message,有几个好处。 (1)提供更多的历史信息,方便快速浏览。 比如,下面的命令显示上次发布后的变动,每个commit占据一行。你只看行首,就知道某次 commit 的目的。 git log <last tag> HEAD --pretty=format:%s (2)可以过滤某些commit(比如文档改动),便于快速查找信息。 比如,下面的命令仅仅显示本次发布新增加的功能。 $ git log <last release> HEAD --grep feature (3)可以直接从commit生成Change log。 Change Log 是发布新版本时,用来说明与上一个版本差异的文档,详见后文。 CommitMessage的格式 每次提交,Commit message 都包括三个部分:Header,Body 和 Footer。 type(scope): subject body footer 其中,Header 是必需的,Body 和 Footer 可以省略。 不管是哪一个部分,任何一行都不得超过72个字符(或100个字符)。这是为了避免自动换行影响美观。 Header Header部分只有一行,包括三个字段:type(必需)、scope(可选)和subject(必需)。 (1)type 用于说明 commit 的类别,只允许使用下面7个标识。 feat:新功能(feature) fix:修补bug docs:文档(documentation) style: 格式(不影响代码运行的变动) refactor:重构(即不是新增功能,也不是修改bug的代码变动) test:增加测试 chore:构建过程或辅助工具的变动 如果type为feat和fix,则该 commit 将肯定出现在 Change log 之中。其他情况(docs、chore、style、refactor、test)由你决定,要不要放入 Change log,建议是不要。 (2)scope 用于说明 commit 影响的范围,比如数据层、控制层、视图层等等,视项目不同而不同。 (3)subject 是 commit 目的的简短描述,不超过50个字符。 以动词开头,使用第一人称现在时,比如change,而不是changed或changes 第一个字母小写 结尾不加句号(.) Body Body 部分是对本次 commit 的详细描述,可以分成多行。下面是一个范例。 More detailed explanatory text, if necessary. Wrap it to about 72 characters or so. Further paragraphs come after blank lines. - Bullet points are okay, too - Use a hanging indent 有两个注意点。 (1)使用第一人称现在时,比如使用change而不是changed或changes。 (2)应该说明代码变动的动机,以及与以前行为的对比。 Footer Footer 部分只用于两种情况。 (1)不兼容变动 如果当前代码与上一个版本不兼容,则 Footer 部分以BREAKING CHANGE开头,后面是对变动的描述、以及变动理由和迁移方法。 BREAKING CHANGE: isolate scope bindings definition has changed. To migrate the code follow the example below: Before: scope: { myAttr: 'attribute', } After: scope: { myAttr: '@', } The removed `inject` wasn't generaly useful for directives so there should be no code using it. (2)关闭 Issue 如果当前 commit 针对某个issue,那么可以在 Footer 部分关闭这个 issue 。 Closes #234 也可以一次关闭多个 issue 。 Closes #123, #245, #992 Revert 还有一种特殊情况,如果当前 commit 用于撤销以前的 commit,则必须以revert:开头,后面跟着被撤销 Commit 的 Header。 revert: feat(pencil): add 'graphiteWidth' option This reverts commit 667ecc1654a317a13331b17617d973392f415f02. Body部分的格式是固定的,必须写成This reverts commit <hash>.,其中的hash是被撤销 commit 的 SHA 标识符。 如果当前 commit 与被撤销的 commit,在同一个发布(release)里面,那么它们都不会出现在 Change log 里面。如果两者在不同的发布,那么当前 commit,会出现在 Change log 的Reverts小标题下面。 Commit工具介绍 Commitizen Commitizen是一个撰写合格 Commit message 的工具。 安装命令如下。 npm install -g commitizen 然后,在项目目录里,运行下面的命令,使其支持 Angular 的 Commit message 格式。 commitizen init cz-conventional-changelog --save --save-exact 以后,凡是用到git commit命令,一律改为使用git cz。这时,就会出现选项,用来生成符合格式的 Commit message。 Validate-commit-msg validate-commit-msg 用于检查 Node 项目的 Commit message 是否符合格式。 它的安装是手动的。首先,拷贝下面这个JS文件,放入你的代码库。文件名可以取为validate-commit-msg.js。 接着,把这个脚本加入 Git 的 hook。下面是在package.json里面使用 ghooks,把这个脚本加为commit-msg时运行。 "config": { "ghooks": { "commit-msg": "./validate-commit-msg.js" } } 然后,每次git commit的时候,这个脚本就会自动检查 Commit message 是否合格。如果不合格,就会报错。 $ git add -A $ git commit -m "edit markdown" INVALID COMMIT MSG: does not match "<type>(<scope>): <subject>" ! was: edit markdown 生成ChangeLog 如果你的所有 Commit 都符合 Angular 格式,那么发布新版本时, Change log 就可以用脚本自动生成(例1,例2,例3)。 生成的文档包括以下三个部分。 New features Bug fixes Breaking changes. 每个部分都会罗列相关的 commit ,并且有指向这些 commit 的链接。当然,生成的文档允许手动修改,所以发布前,你还可以添加其他内容。 conventional-changelog 就是生成 Change log 的工具,运行下面的命令即可。 $ npm install -g conventional-changelog $ cd my-project $ conventional-changelog -p angular -i CHANGELOG.md -w 上面命令不会覆盖以前的 Change log,只会在CHANGELOG.md的头部加上自从上次发布以来的变动。 如果你想生成所有发布的 Change log,要改为运行下面的命令。 $ conventional-changelog -p angular -i CHANGELOG.md -w -r 0 为了方便使用,可以将其写入package.json的scripts字段。 { "scripts": { "changelog": "conventional-changelog -p angular -i CHANGELOG.md -w -r 0" } } 以后,直接运行下面的命令即可。 $ npm run changelog
常见工作流 Git flow Github flow Gitlab flow 参考文献 Git branching model:https://nvie.com/posts/a-successful-git-branching-model git使用规范:http://www.ruanyifeng.com/blog/2015/08/git-use-process.html git常用命令:http://www.ruanyifeng.com/blog/2015/12/git-cheat-sheet.html git远程操作详解:http://www.ruanyifeng.com/blog/2014/06/git_remote.html gitflow工作流程1:http://www.ruanyifeng.com/blog/2015/12/git-workflow.html gitflow工作流程2:http://www.ruanyifeng.com/blog/2012/07/git.html Git Flow 的常用分支 Production 分支 也就是我们经常使用的Master分支,这个分支最近发布到生产环境的代码,最近发布的Release, 这个分支只能从其他分支合并,不能在这个分支直接修改 Develop 分支 这个分支是我们是我们的主开发分支,包含所有要发布到下一个Release的代码,这个主要合并与其他分支,比如Feature分支 Feature 分支 这个分支主要是用来开发一个新的功能,一旦开发完成,我们合并回Develop分支进入下一个Release Release分支 当你需要一个发布一个新Release的时候,我们基于Develop分支创建一个Release分支,完成Release后,我们合并到Master和Develop分支 Hotfix分支 当我们在Production发现新的Bug时候,我们需要创建一个Hotfix, 完成Hotfix后,我们合并回Master和Develop分支,所以Hotfix的改动会进入下一个Release Git Flow 如何使用 Master/Devlop 分支 所有在Master分支上的Commit应该打上Tag,一般情况下Master不存在Commit,Devlop分支基于Master分支创建 Feature 分支 Feature分支做完后,必须合并回Develop分支, 合并完分支后一般会删点这个Feature分支,毕竟保留下来意义也不大。 Release 分支 Release分支基于Develop分支创建,打完Release分支之后,我们可以在这个Release分支上测试,修改Bug等。同时,其它开发人员可以基于Develop分支新建Feature (记住:一旦打了Release分支之后不要从Develop分支上合并新的改动到Release分支)发布Release分支时,合并Release到Master和Develop, 同时在Master分支上打个Tag记住Release版本号,然后可以删除Release分支了。 Hotfix 分支 hotfix分支基于Master分支创建,开发完后需要合并回Master和Develop分支,同时在Master上打一个tag。 Git-Commit常见提交规范 提交模版: type(scope):subject - body Type: feat :新功能 fix :修复bug doc : 文档改变 style : 代码格式改变 refactor :某个已有功能重构 perf :性能优化 test :增加测试 build :改变了build工具 如 grunt换成了 npm revert: 撤销上一次的 commit Scope: scope :用来说明此次修改的影响范围 可以随便填写任何东西,commitizen也给出了几个 如:location 、browser、compile。 不过我推荐使用: all :表示影响面大 ,如修改了网络框架 会对真个程序产生影响 loation: 表示影响小,某个小小的功能 module:表示会影响某个模块 如登录模块、首页模块 、用户管理模块等等 Others: subject: 用来简要描述本次改动,概述就好了 body:具体的修改信息 应该尽量详细 footer:放置写备注啥的,如果是 bug ,可以把bug id放入
冒泡排序 冒泡排序(英语**:Bubble Sort**)是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。 冒泡排序对 n 个项目需要 O(n2) 的比较次数,且可以原地排序。尽管这个算法是最简单了解和实现的排序算法之一,但它对于包含大量的元素的数列排序是很没有效率的。 冒泡排序算法的运作如下: 比较相邻的元素。如果第一个比第二个大,就交换他们两个。 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。 针对所有的元素重复以上的步骤,除了最后一个。 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。 以上是维基百科中的介绍,可以看到,原理并不复杂。但是在数据量大时,不是一个很好的选择。 动图演示 需要注意的是,下图与实例中的代码,顺序是相反的。 冒泡排序动图 使用冒泡排序为一列数字进行排序的过程 实例 <?php $arr = [33, 24, 8, 21, 2, 23, 3, 32, 16]; function bubbleSort($arr) { if (!is_array($arr)) { return false; } $count = count($arr); if ($count < 2) { return $arr; } for ($i = 0; $i < $count; $i++) { for ($k = $i + 1; $k < $count; $k++) { // $arr[$i] 和 $arr[$k] 是相邻的两个值 if ($arr[$i] > $arr[$k]) { // 前者大于后者,调换位置 // 如果想要按照从大到小进行排序,改为 $arr[$i] < $arr[$k] $temp = $arr[$i]; $arr[$i] = $arr[$k]; $arr[$k] = $temp; } } } return $arr; } print_r(bubbleSort($arr)); // Array ( [0] => 2 [1] => 3 [2] => 8 [3] => 16 [4] => 21 [5] => 23 [6] => 24 [7] => 32 [8] => 33 )
插入排序 插入排序(英语:Insertion Sort)是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。插入排序在实现上,通常采用in-place排序(即只需用到 O(1) 的额外空间的排序),因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。 一般来说,插入排序都采用in-place在数组上实现。具体算法描述如下: 从第一个元素开始,该元素可以认为已经被排序 取出下一个元素,在已经排序的元素序列中从后向前扫描 如果该元素(已排序)大于新元素,将该元素移到下一位置 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置 将新元素插入到该位置后 重复步骤2~5 动图演示 使用插入排序为一列数字进行排序的过程 使用插入排序为一列数字进行排序的过程 实例 <?php $arr = [33, 24, 8, 21, 2, 23, 3, 32, 16]; function insertSort($arr) { $count = count($arr); if ($count < 2) { return $arr; } for ($i = 1; $i < $count; $i++) { // 当前值 $temp = $arr[$i]; for ($k = $i - 1; $k >= 0; $k--) { // 条件成立,比较值后挪一位,将当前值替换成比较值 // 倒序 $temp > $arr[$k] if ($temp < $arr[$k]) { $arr[$k + 1] = $arr[$k]; $arr[$k] = $temp; } } } return $arr; } print_r(insertSort($arr)); // Array ( [0] => 2 [1] => 3 [2] => 8 [3] => 16 [4] => 21 [5] => 23 [6] => 24 [7] => 32 [8] => 33 )
故事背景 假如本次考试,本学校有100个学生,对于每个学生我们记录了他的分数,现在你需要将同学们的分数,按照从高到低排名 那么该如何做呢,一种办法是,遍历整个数据,然后吧分数最高的加入到一个新的列表中 依次这样做,我们可以得到一个有序列表 从计算机的角度看待一个这个问题 O(n)时间意味着查看列表中的每个元素一次。例如,对学生列表进行简单查找时,意味着每个学生都要查看一次 要找出分数最高的学生,必须检查列表中的每个元素。正如你刚才看到的,这需要的时间为O(n)。因此对于这种时间为O(n)的操作,你需要执行n次 需要检查的元素数越来越少 随着排序的进行,每次需要检查的元素数在逐渐减少,最后一次需要检查的元素都只有一个。既然如此,运行时间怎么还是O(n2)呢?这个问题问得好,这与大O表示法中的常数相关。 后面将详细解释,这里只简单地说一说。 你说得没错,并非每次都需要检查n个元素。第一次需要检查n个元素,但随后检查的元素数依次为n 1, n – 2, …, 2和1。平均每次检查的元素数为1/2 × n,因此运行时间为O(n × 1/2 × n)。 但大O表示法省略诸如1/2这样的常数(有关这方面的完整讨论,后面我会接着讨论), 因此简单地写作O(n × n)或O(n^2)。 选择排序是一种灵巧的算法,但其速度不是很快。快速排序是一种更快的排序算法,其运行时间为O(n log n),这将在后面介绍 选择排序(Selection sort)是一种简单直观的排序算法。它的工作原理如下。首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。 选择排序的主要优点与数据移动有关。如果某个元素位于正确的最终位置上,则它不会被移动。选择排序每次交换一对元素,它们当中至少有一个将被移到其最终位置上,因此对 n 个元素的表进行排序总共进行至多 n -1 次交换。在所有的完全依靠交换去移动元素的排序方法中,选择排序属于非常好的一种 下述代码提供了类似的功能:将数组元素按从小到大的顺序排列。先编写一个用于找出数组中最小元素的函数 // C语言实现 def findSmallest(arr): smallest = arr[0] smallest_index = 0 for i in range(1, len(arr)): if arr[i] < smallest: smallest = arr[i] smallest_index = i return smallest_index // 现在可以使用这个函数来编写选择排序算法了。 def selectionSort(arr): newArr = [] for i in range(len(arr)): smallest = findSmallest(arr) newArr.append(arr.pop(smallest)) return newArr // 进行测试 print selectionSort([5, 3, 6, 2, 10]) // PHP语言实现 <?php $arr = array(5, 8, 6, 7, 3); // 选择排序算法实现 function SelectSort($arr = array()) { //获取数组长度 $size = sizeof($arr); //外层循环控制比较的数值下标与轮询次数 for ($i=0; $i<$size; $i++) { // 先假设最小值的位置 $p=$i; //内层循环控制比较次数 for ($j=$i+1; $j<$size; $j++) { // 如果 j 下标的值比 p 小标的值小 if ($arr[$p] > $arr[$j]) { //记录最小值的下标 $p = $j; } } // 不相同则互换位置 if ($p != $i){ $tmp = $arr[$i]; $arr[$i] = $arr[$p]; $arr[$p] = $tmp; } } return $arr; } // 测试运行 print_r(SelectSort($arr)); 动画演示
相关链接 Redis Bgrewriteaof 命令:https://www.runoob.com/redis/server-bgrewriteaof.html Redis持久化博客:https://www.cnblogs.com/kismetv/p/9137897.html [官网] redis应该选择哪种持久化方式:http://www.redis.cn/topics/persistence.html#section 两者对比 Redis 默认开启RDB持久化方式,在指定的时间间隔内,执行指定次数的写操作,则将内存中的数据写入到磁盘中。 RDB 持久化适合大规模的数据恢复但它的数据一致性和完整性较差。 Redis 需要手动开启AOF持久化方式,默认是每秒将写操作日志追加到AOF文件中。 AOF 的数据完整性比RDB高,但记录内容多了,会影响数据恢复的效率。 Redis 针对 AOF文件大的问题,提供重写的瘦身机制。 若只打算用Redis 做缓存,可以关闭持久化。 若打算使用Redis 的持久化。建议RDB和AOF都开启。其实RDB更适合做数据的备份,留一后手。AOF出问题了,还有RDB AOF 详解 AOF :Redis 默认不开启。它的出现是为了弥补RDB的不足(数据的不一致性),所以它采用日志的形式来记录每个写操作,并追加到文件中。Redis 重启的会根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作。 从配置文件了解AOF 打开 redis.conf 文件,找到 APPEND ONLY MODE 对应内容 appendonly yes #启用AOF持久化方式 appendfilename appendonly.aof #AOF文件的名称,默认为appendonly.aof # appendfsync always #每次收到写命令就立即强制写入磁盘,是最有保证的完全的持久化,但速度也是最慢的,一般不推荐使用。 appendfsync everysec #每秒钟强制写入磁盘一次,在性能和持久化方面做了很好的折中,是受推荐的方式。 # appendfsync no #完全依赖OS的写入,一般为30秒左右一次,性能最好但是持久化最没有保证,不被推荐 Redis提供了bgrewriteaof命令 no-appendfsync-on-rewrite yes #在日志重写时,不进行命令追加操作,而只是将其放在缓冲区里,避免与命令的追加造成DISK IO上的冲突。 auto-aof-rewrite-percentage 100 # 这其实是【auto-aof-rewrite-min-size】的百分比,此处代表100%,如果大于【auto-aof-rewrite-min-size】设置的值的 100% 后出发重写操作 auto-aof-rewrite-min-size 64mb #当前AOF文件启动新的日志重写过程的最小值 解说:当AOF文件大小是上次rewrite后大小的一倍且文件大于64M时触发。一般都设置为3G,64M太小了。 根据AOF文件恢复数据 正常情况下,将appendonly.aof 文件拷贝到redis的安装目录的bin目录下,重启redis服务即可。 但在实际开发中,可能因为某些原因导致 appendonly.aof 文件格式异常,从而导致数据还原失败,可以通过命令 redis-check-aof --fix appendonly.aof 进行修复 。 AOF文件越来越大怎么办? -> AOF的重写机制 前面也说到了,AOF的工作原理是将写操作追加到文件中,文件的冗余内容会越来越多。所以聪明的 Redis 新增了重写机制。当AOF文件的大小超过所设定的阈值时,Redis就会对AOF文件的内容压缩。 重写的原理:Redis 会 fork 出一条新进程,读取内存中的数据,并重新写到一个临时文件中。并没有读取旧文件(你都那么大了,我还去读你??? o(゚Д゚)っ傻啊!)。最后替换旧的aof文件。 触发机制:当AOF文件大小是上次 ·rewrite 后大小的一倍且文件大于 64M 时触发。这里的“一倍”和 “64M” 可以通过配置文件修改。 AOF 的优缺点 优点:数据的完整性和一致性更高 缺点:因为AOF记录的内容多,文件会越来越大,数据恢复也会越来越慢。 操作演示 [root@itdragon bin]# vim appendonly.aof appendonly yes [root@itdragon bin]# ./redis-server redis.conf [root@itdragon bin]# ./redis-cli -h 127.0.0.1 -p 6379 127.0.0.1:6379> keys * (empty list or set) 127.0.0.1:6379> set keyAOf valueAof OK 127.0.0.1:6379> FLUSHALL OK 127.0.0.1:6379> SHUTDOWN not connected> QUIT [root@itdragon bin]# ./redis-server redis.conf [root@itdragon bin]# ./redis-cli -h 127.0.0.1 -p 6379 127.0.0.1:6379> keys * 1) "keyAOf" 127.0.0.1:6379> SHUTDOWN not connected> QUIT [root@itdragon bin]# vim appendonly.aof fjewofjwojfoewifjowejfwf [root@itdragon bin]# ./redis-server redis.conf [root@itdragon bin]# ./redis-cli -h 127.0.0.1 -p 6379 Could not connect to Redis at 127.0.0.1:6379: Connection refused not connected> QUIT [root@itdragon bin]# redis-check-aof --fix appendonly.aof 'x 3e: Expected prefix '*', got: ' AOF analyzed: size=92, ok_up_to=62, diff=30 This will shrink the AOF from 92 bytes, with 30 bytes, to 62 bytes Continue? [y/N]: y Successfully truncated AOF [root@itdragon bin]# ./redis-server redis.conf [root@itdragon bin]# ./redis-cli -h 127.0.0.1 -p 6379 127.0.0.1:6379> keys * 1) "keyAOf" 第一步:修改配置文件,开启AOF持久化配置。 第二步:重启Redis服务,并进入Redis 自带的客户端中。 第三步:保存值,然后模拟数据丢失,关闭Redis服务。 第四步:重启服务,发现数据恢复了。(额外提一点:有教程显示FLUSHALL 命令会被写入AOF文件中,导致数据恢复失败。我安装的是redis-4.0.2没有遇到这个问题)。 第五步:修改appendonly.aof,模拟文件异常情况。 第六步:重启 Redis 服务失败。这同时也说明了,RDB和AOF可以同时存在,且优先加载AOF文件。 第七步:校验appendonly.aof 文件。重启Redis 服务后正常。 补充点:aof 的校验是通过 redis-check-aof 文件,那么rdb 的校验是不是可以通过 redis-check-rdb 文件呢??? AOF流程 AOF重写机制 重启加载 RDB 详解 RDB 是 Redis 默认的持久化方案。在指定的时间间隔内,执行指定次数的写操作,则会将内存中的数据写入到磁盘中。即在指定目录下生成一个dump.rdb文件。Redis 重启会通过加载dump.rdb文件恢复数据。 从配置文件了解RDB 打开 redis.conf 文件,找到 SNAPSHOTTING 对应内容 1 RDB核心规则配置(重点) save <seconds> <changes> # save "" save 900 1 #当有一条Keys数据被改变时,900秒刷新到Disk一次 save 300 10 #当有10条Keys数据被改变时,300秒刷新到Disk一次 save 60 10000 #当有10000条Keys数据被改变时,60秒刷新到Disk一次 解说:save <指定时间间隔> <执行指定次数更新操作>,满足条件就将内存中的数据同步到硬盘中。官方出厂配置默认是 900秒内有1个更改,300秒内有10个更改以及60秒内有10000个更改,则将内存中的数据快照写入磁盘。 若不想用RDB方案,可以把 save “” 的注释打开,下面三个注释。 2 指定本地数据库文件名,一般采用默认的 dump.rdb dbfilename dump.rdb 3 指定本地数据库存放目录,一般也用默认配置 dir ./ 4 默认开启数据压缩 rdbcompression yes 解说:配置存储至本地数据库时是否压缩数据,默认为yes。Redis采用LZF压缩方式,但占用了一点CPU的时间。若关闭该选项,但会导致数据库文件变的巨大。建议开启。 触发RDB快照 1 在指定的时间间隔内,执行指定次数的写操作 2 执行save(阻塞, 只管保存快照,其他的等待) 或者是bgsave (异步)命令 3 执行flushall 命令,清空数据库所有数据,意义不大。 4 执行shutdown 命令,保证服务器正常关闭且不丢失任何数据,意义…也不大。 通过RDB文件恢复数据 将dump.rdb 文件拷贝到redis的安装目录的bin目录下,重启redis服务即可。在实际开发中,一般会考虑到物理机硬盘损坏情况,选择备份dump.rdb 。可以从下面的操作演示中可以体会到。 RDB 的优缺点 优点: 1 适合大规模的数据恢复。 2 如果业务对数据完整性和一致性要求不高,RDB是很好的选择。 缺点: 1 数据的完整性和一致性不高,因为RDB可能在最后一次备份时宕机了。 2 备份时占用内存,因为Redis 在备份时会独立创建一个子进程,将数据写入到一个临时文件(此时内存中的数据是原来的两倍哦),最后再将临时文件替换之前的备份文件。 所以Redis 的持久化和数据的恢复要选择在夜深人静的时候执行是比较合理的。 操作演示 [root@itdragon bin]# vim redis.conf save 900 1 save 120 5 save 60 10000 [root@itdragon bin]# ./redis-server redis.conf [root@itdragon bin]# ./redis-cli -h 127.0.0.1 -p 6379 127.0.0.1:6379> keys * (empty list or set) 127.0.0.1:6379> set key1 value1 OK 127.0.0.1:6379> set key2 value2 OK 127.0.0.1:6379> set key3 value3 OK 127.0.0.1:6379> set key4 value4 OK 127.0.0.1:6379> set key5 value5 OK 127.0.0.1:6379> set key6 value6 OK 127.0.0.1:6379> SHUTDOWN not connected> QUIT [root@itdragon bin]# cp dump.rdb dump_bk.rdb [root@itdragon bin]# ./redis-server redis.conf [root@itdragon bin]# ./redis-cli -h 127.0.0.1 -p 6379 127.0.0.1:6379> FLUSHALL OK 127.0.0.1:6379> keys * (empty list or set) 127.0.0.1:6379> SHUTDOWN not connected> QUIT [root@itdragon bin]# cp dump_bk.rdb dump.rdb cp: overwrite `dump.rdb'? y [root@itdragon bin]# ./redis-server redis.conf [root@itdragon bin]# ./redis-cli -h 127.0.0.1 -p 6379 127.0.0.1:6379> keys * 1) "key5" 2) "key1" 3) "key3" 4) "key4" 5) "key6" 6) "key2" 第一步:vim 修改持久化配置时间,120秒内修改5次则持久化一次。 第二步:重启服务使配置生效。 第三步:分别set 5个key,过两分钟后,在bin的当前目录下会自动生产一个dump.rdb文件。(set key6 是为了验证shutdown有触发RDB快照的作用) 第四步:将当前的dump.rdb 备份一份(模拟线上工作)。 第五步:执行FLUSHALL命令清空数据库数据(模拟数据丢失)。 第六步:重启Redis服务,恢复数据…咦????( ′◔ ‸◔`)。数据是空的????这是因为FLUSHALL也有触发RDB快照的功能。 第七步:将备份的 dump_bk.rdb 替换 dump.rdb 然后重新Redis。 注意点:SHUTDOWN 和 FLUSHALL 命令都会触发RDB快照,这是一个坑,请大家注意。 save 和 bgsave 对比 1.save命令:阻塞当前redis服务器,直到RDB过程完成为止,如果redis数据较多,可能造成redis进程的长时间阻塞。 2.bgsave(如下图): redis执行fork创建子进程,RDB持久化过程由这个子进程负责,完成之后结束。
服务器配置 Linux CentOS 7.5 密钥登陆授权 shell脚本 #!/bin/sh echo 'ssh-key:' $1 echo 'username:' $2 useradd -m $2 usermod -G wheel $2 passwd -d $2 mkdir /home/$2/.ssh echo $1 > /home/$2/.ssh/authorized_keys chmod 600 /home/$2/.ssh/authorized_keys chown $2:$2 /home/$2/.ssh -R 将此代码片更名为 adduser.sh 放在linux的/root目录下 添加用户的命令 bash adduser.sh "ssh-rsa AAAAB3NzaC1yTah93xxxxxx......xxxxxxoyBK6zmwLsxEjvMi1KGP54w==" libin 使添加的用户具备 sudo -i 进入root的权限 执行 visudo 命令,进行如下的修改 禁止root登录;禁止密码登录 禁止root登录 编辑远程服务器上的sshd_config文件: vi /etc/ssh/sshd_config PermitRootLogin yes改为no PermitRootLogin no 编辑保存完成后,重启ssh服务使得新配置生效,然后就无法使用口令来登录ssh了 禁止密码登录 编辑远程服务器上的sshd_config文件: vi /etc/ssh/sshd_config PasswordAuthentication yes改为no PasswordAuthentication no 编辑保存完成后,重启ssh服务使得新配置生效,然后就无法使用口令来登录ssh了 更改 ssh 默认端口 vi /etc/ssh/sshd_config Port 默认端口 22 改为了 端口:20002 重启 ssh 服务 systemctl restart sshd.service 防火墙授权新端口 20002 查看已经授权的端口 firewall-cmd --list-ports 授权新端口 20002 firewall-cmd --zone=public --add-port=20002/tcp --permanent 重新加载防火墙配置 firewall-cmd --reload
压力测试是每一个Web应用程序上线之前都需要做的一个测试,他可以帮助我们发现系统中的瓶颈问题,减少发布到生产环境后出问题的几率;预估系统的承载能力,使我们能根据其做出一些应对措施。 Apache Ab Apache Benchmark(简称ab) 是Apache安装包中自带的压力测试工具 ,简单易用。 使用起来非常的简单和方便。 不仅仅是可以apache服务器进行网站访问压力测试,还可以对其他类型的服务器进行压力测试。 比如nginx,tomcat,IIS等 官网:http://httpd.apache.org/docs/2.2/programs/ab.html 参数说明 Usage: ab [options] [http[s]://]hostname[:port]/path 用法:ab [选项] 地址 选项: Options are: -n requests #执行的请求数,即一共发起多少请求。 -c concurrency #请求并发数。 -t timelimit #测试所进行的最大秒数。其内部隐含值是-n 50000,它可以使对服务器的测试限制在一个固定的总时间以内。默认时,没有时间限制。 -s timeout #指定每个请求的超时时间,默认是30秒。 -b windowsize #指定tcp窗口的大小,单位是字节。 -B address #指定在发起连接时绑定的ip地址是什么。 -p postfile #指定要POST的文件,同时要设置-T参数。 -u putfile #指定要PUT的文件,同时要设置-T参数。 -T content-type #指定使用POST或PUT上传文本时的文本类型,默认是'text/plain'。 -v verbosity #设置详细模式等级。 -w #将结果输出到html的表中。 -i #使用HEAD方式代替GET发起请求。 -y attributes #以表格方式输出时,设置html表格tr属性。 -z attributes #以表格方式输出时,设置html表格th或td属性。 -C attribute #添加cookie,比如'Apache=1234'。(可重复) -H attribute #为请求追加一个额外的头部,比如'Accept-Encoding: gzip'。(可重复) -A attribute #对服务器提供BASIC认证信任。用户名和密码由一个:隔开,并以base64编码形式发送。无论服务器是否需要(即,是否发送了401认证需求代码),此字符串都会被发送。 -P attribute #对一个中转代理提供BASIC认证信任。用户名和密码由一个:隔开,并以base64编码形式发送。无论服务器是否需要(即, 是否发送了401认证需求代码),此字符串都会被发送。 -X proxy:port #指定代理服务器的IP和端口。 -V #打印版本信息。 -k #启用HTTP KeepAlive功能,即在一个HTTP会话中执行多个请求。默认时,不启用KeepAlive功能。 -d #不显示"percentage served within XX [ms] table"的消息(为以前的版本提供支持)。 -q #如果处理的请求数大于150,ab每处理大约10%或者100个请求时,会在stderr输出一个进度计数。此-q标记可以抑制这些信息。 -g filename #把所有测试结果写入一个'gnuplot'或者TSV(以Tab分隔的)文件。此文件可以方便地导入到Gnuplot,IDL,Mathematica,Igor甚至Excel中。其中的第一行为标题。 -e filename #产生一个以逗号分隔的(CSV)文件,其中包含了处理每个相应百分比的请求所需要(从1%到100%)的相应百分比的(以微妙为单位)时间。由于这种格式已经“二进制化”,所以比'gnuplot'格式更有用。 -r #当收到错误时不要退出。 -h #输出帮助信息 -Z ciphersuite 指定SSL/TLS密码套件 -f protocol 指定SSL/TLS协议(SSL3, TLS1, TLS1.1, TLS1.2 or ALL) 压测测试 ab -c 10 -n 1000 -k "http://www.baidu.com/" This is ApacheBench, Version 2.3 <$Revision: 1430300 $> Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ Licensed to The Apache Software Foundation, http://www.apache.org/ Benchmarking www.baidu.com (be patient) Completed 100 requests //* 已完成请求数 Completed 200 requests Completed 300 requests Completed 400 requests Completed 500 requests Completed 600 requests Completed 700 requests Completed 800 requests Completed 900 requests Completed 1000 requests Finished 1000 requests Server Software: BWS/1.1 //* 请求的服务器名称、版本号 Server Hostname: www.baidu.com //* 请求的地址 Server Port: 80 //* 请求的端口号 Document Path: / //* 请求的绝对路径,即具体接口名称 Document Length: 199875 bytes //* 响应数据的大小 Concurrency Level: 10 //* 并发用户数 Time taken for tests: 14.567 seconds //* 测试总耗时 ,单位:s Complete requests: 1000 //* 总请求数 Failed requests: 988 //* 失败总请求数 (Connect: 0, Receive: 0, Length: 988, Exceptions: 0) Write errors: 0 //* 发送请求失败的次数 Total transferred: 198476425 bytes //* 从服务端收到的总字节数 HTML transferred: 197338475 bytes //* 从服务端收到的总文档字节数,即不包含Total transferred中的HTTP头信息 Requests per second: 68.65 [#/sec] (mean) //* 平均每秒请求数,即吞吐量 Time per request: 145.672 [ms] (mean) //* 平均每次请求并发用户总耗时,即 该值=平均每次请求耗时 * 并发数 ,单位:ms Time per request: 14.567 [ms] (mean, across all concurrent requests) //* 平均每次请求耗时 ,单位:ms Transfer rate: 13305.53 [Kbytes/sec] received //* 服务端每秒响应的数据大小,单位: kb/s Connection Times (ms) //* 网络耗时 (依次是:最小、平均、标准偏差、中位数、最大) ,单位:ms min mean[+/-sd] median max Connect: 6 16 89.3 8 1011 Processing: 22 127 160.5 73 1919 Waiting: 8 23 53.5 10 499 Total: 28 143 182.4 82 1927 Percentage of the requests served within a certain time (ms) //* 请求耗时分布情况百分比 ,单位:ms 50% 82 66% 105 75% 134 80% 284 90% 321 //* 表示 90%的请求在 321ms 内得到服务端响应结果 95% 367 98% 746 99% 1038 100% 1927 (longest request) Jmeter Apache JMeter是Apache组织开发的基于Java的压力测试工具。用于对软件做压力测试,它最初被设计用于Web应用测试,但后来扩展到其他测试领域。 Apache jmeter 可以用于对静态的和动态的资源(文件,Servlet,Perl脚本,java 对象,数据库和查询,FTP服务器等等)的性能进行测试。它可以用于对服务器、网络或对象模拟繁重的负载来测试它们的强度或分析不同压力类型下的整体性能。你可以使用它做性能的图形分析或在大并发负载测试你的服务器/脚本/对象。 Windows 安装 Jmeter官网(本文采用apache-jmeter-5.4.1.zip):https://jmeter.apache.org/download_jmeter.cgi Java SDK Download:https://www.oracle.com/java/technologies/javase-downloads.html JMeter插件下载地址: http://jmeter-plugins.org/downloads/all/ Mac 安装 JavaJDK 安装 JDK安装此处略去,不作为文本的介绍的重点,读者可自行Google。 Jmeter安装 进入JMeter的下载地址页面,如下图,有两个版本可供下载 Binaries:二进制版,即已经编译好、可直接执行; Source:源代码版,需要自己编译; 将此软件下载后,进行解压 启动jmeter 进入 jmeter 的 bin 目录,执行 sh jmeter 命令,即可启动 jmeter 软件 改为中文
相关链接 redis LRU策略分析:https://www.cnblogs.com/linxiyue/p/10945216.html redis LFU策略分析:https://www.cnblogs.com/linxiyue/p/10955533.html redis LRU伪代码演示:https://github.com/mailjobblog/dev_redis/blob/master/LRU/LRU_Cache.php 内存淘汰策略 noeviction:当内存使用超过配置的时候会返回错误,不会驱逐任何键 allkeys-lru:加入键的时候,如果过限,首先通过LRU算法驱逐最久没有使用的键 volatile-lru:加入键的时候如果过限,首先从设置了过期时间的键集合中驱逐最久没有使用的键 allkeys-random:加入键的时候如果过限,从所有key随机删除 volatile-random:加入键的时候如果过限,从过期键的集合中随机驱逐 volatile-ttl:从配置了过期时间的键中驱逐马上就要过期的键 volatile-lfu:从所有配置了过期时间的键中驱逐使用频率最少的键 allkeys-lfu:从所有键中驱逐使用频率最少的键 LRU和LFU的区别 LRU是最近最少使用页面置换算法(Least Recently Used),也就是首先淘汰最长时间未被使用的页面! LFU是最近最不常用页面置换算法(Least Frequently Used),也就是淘汰一定时期内被访问次数最少的页! LRU 举例如下的访问模式,A每5s访问一次,B每2s访问一次,C与D每10s访问一次,|代表计算空闲时间的截止点: ~~~~~A~~~~~A~~~~~A~~~~A~~~~~A~~~~~A~~| ~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~| ~~~~~~~~~~C~~~~~~~~~C~~~~~~~~~C~~~~~~| ~~~~~D~~~~~~~~~~D~~~~~~~~~D~~~~~~~~~D| 在很长时期内、可以看到,LRU对于A、B、C工作的很好,完美预测了将来被访问到的概率B>A>C,但对于D却预测了最少的空闲时间。 但是,总体来说,LRU算法已经是一个性能足够好的算法了 图解说明 新数据插入到链表头部 每当缓存命中(即缓存数据被访问),则将数据移到链表头部 当链表满的时候,将链表尾部的数据丢弃 LRU Cache具备的操作: set(key,value):如果key在hashmap中存在,则先重置对应的value值,然后获取对应的节点cur,将cur节点从链表删除,并移动到链表的头部;若果key在hashmap不存在,则新建一个节点,并将节点放到链表的头部。当Cache存满的时候,将链表最后一个节点删除即可。 get(key):如果key在hashmap中存在,则把对应的节点放到链表头部,并返回对应的value值;如果不存在,则返回-1。 LRU配置参数 Redis配置中和LRU有关的有三个: maxmemory: 配置Redis存储数据时指定限制的内存大小,比如100m。当缓存消耗的内存超过这个数值时, 将触发数据淘汰。该数据配置为0时,表示缓存的数据量没有限制, 即LRU功能不生效。64位的系统默认值为0,32位的系统默认内存限制为3GB maxmemory_policy: 触发数据淘汰后的淘汰策略 maxmemory_samples: 随机采样的精度,也就是随即取出key的数目。该数值配置越大, 越接近于真实的LRU算法,但是数值越大,相应消耗也变高,对性能有一定影响,样本值默认为5。 LFU 示例图展示 ~~~~~A~~~~~A~~~~~A~~~~A~~~~~A~~~~~A~~| ~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~~B~| ~~~~~~~~~~C~~~~~~~~~C~~~~~~~~~C~~~~~~| ~~~~~D~~~~~~~~~~D~~~~~~~~~D~~~~~~~~~D| 在上面的情况中,在一定时期内,根据访问频繁情况,可以确定保留优先级:B>A>C=D。 LFU配置 Redis4.0之后为maxmemory_policy淘汰策略添加了两个LFU模式: volatile-lfu:对有过期时间的key采用LFU淘汰算法 allkeys-lfu:对全部key采用LFU淘汰算法 还有2个配置可以调整LFU算法: lfu-log-factor 10 lfu-decay-time 1 lfu-log-factor可以调整计数器counter的增长速度,lfu-log-factor越大,counter增长的越慢。 lfu-decay-time是一个以分钟为单位的数值,可以调整counter的减少速度 内存淘汰策略的选择 个人观点 我们在选择使用淘汰策略的时候可以根据访问key的方式来选择不同的淘汰策略 1、当我们redis中的key基本上都有用到,也就是说每个key都有周期性访问到,那就可以选择使用random策略 2、当我们redis中的key部分是我们经常访问的,部分是非经常访问的就可以考虑使用LRU和LFU策略 3、当我们想根据时间长久淘汰超时数据时,就选用ttl 4、我们根据我们的需要是否有要长久保存的key来选择volatile或者是all,如果有需要长久保存的key,则使用volatile,否则可以使用all全表扫描
相关链接 多级缓存策略思维导图:https://kdocs.cn/l/sfV9xmfovVKv nginx基于openresty验证jwt:https://segmentfault.com/a/1190000015677681 OpenResty不完全指南:https://zhuanlan.zhihu.com/p/42082302 基于OpenResty实现 JWT验证2:https://www.jianshu.com/p/66d5163b9e99 思维导图 OpenResty验证Jwt思维图 保证业务连贯性用uuid uuid生成 为每个请求生成唯一的uuid码可以将网关层上的请求和应用层的请求关联起来,对排查问题,接口统计都非常有用. 创建文件/usr/local/openresty/nginx/jwt-lua/resty/uuid.lua local M = {} local charset = {} do -- [0-9a-zA-Z] for c = 48, 57 do table.insert(charset, string.char(c)) end for c = 65, 90 do table.insert(charset, string.char(c)) end for c = 97, 122 do table.insert(charset, string.char(c)) end end function M.uuid(length) local res = "" for i = 1, length do res = res .. charset[math.random(1, #charset)] end return res end return M 修改配置文件nginx.conf worker_processes 1; error_log logs/error.log info; events { worker_connections 1024; } http { upstream tomcat{ server localhost:80; } lua_package_path "/usr/local/openresty/nginx/jwt-lua/?.lua;;"; server { listen 8080; set $uid ''; set $uuid ''; location / { access_by_lua ' local jwt = require("resty.nginx-jwt") jwt.auth() local u = require("resty.uuid") ngx.var.uuid = u.uuid(64) '; default_type application/json; proxy_set_header uid $uid; proxy_set_header uuid $uuid; proxy_pass http://tomcat; } } }
在分布式系统中,由于redis分布式锁相对于更简单和高效,成为了分布式锁的首先,被我们用到了很多实际业务场景当中。但不是说用了redis分布式锁,就可以高枕无忧了,如果没有用好或者用对,也会引来一些意想不到的问题。 今天我们就一起聊聊redis分布式锁的一些坑,给有需要的朋友一个参考。 对于分布式锁,最好能够满足以下几点 可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行 这把锁要是一把可重入锁(避免死锁) 这把锁最好是一把阻塞锁 有高可用的获取锁和释放锁功能 获取锁和释放锁的性能要好 1 非原子操作 使用redis的分布式锁,我们首先想到的可能是setNx命令。 if (jedis.setnx(lockKey, val) == 1) { jedis.expire(lockKey, timeout); } 容易,三下五除二,我们就可以把代码写好。 这段代码确实可以加锁成功,但你有没有发现什么问题? 加锁操作和后面的设置超时时间是分开的,并非原子操作。 假如加锁成功,但是设置超时时间失败了,该lockKey就变成永不失效。假如在高并发场景中,有大量的lockKey加锁成功了,但不会失效,有可能直接导致redis内存空间不足。 那么,有没有保证原子性的加锁命令呢? 答案是:有,请看下面。 2 忘了释放锁 上面说到使用setNx命令加锁操作和设置超时时间是分开的,并非原子操作。 而在redis中还有set命令,该命令可以指定多个参数。 String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); if ("OK".equals(result)) { return true; } return false; 其中: lockKey:锁的标识 requestId:请求id NX:只在键不存在时,才对键进行设置操作。 PX:设置键的过期时间为 millisecond 毫秒。 expireTime:过期时间 set命令是原子操作,加锁和设置超时时间,一个命令就能轻松搞定。 nice 使用set命令加锁,表面上看起来没有问题。但如果仔细想想,加锁之后,每次都要达到了超时时间才释放锁,会不会有点不合理?加锁后,如果不及时释放锁,会有很多问题。 分布式锁更合理的用法是: 手动加锁 业务操作 手动释放锁 如果手动释放锁失败了,则达到超时时间,redis会自动释放锁。 大致流程图如下:那么问题来了,如何释放锁呢? 伪代码如下: try{ String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); if ("OK".equals(result)) { return true; } return false; } finally { unlock(lockKey); } 需要捕获业务代码的异常,然后在finally中释放锁。换句话说就是:无论代码执行成功或失败了,都需要释放锁。 此时,有些朋友可能会问:假如刚好在释放锁的时候,系统被重启了,或者网络断线了,或者机房断点了,不也会导致释放锁失败? 这是一个好问题,因为这种小概率问题确实存在。 但还记得前面我们给锁设置过超时时间吗?即使出现异常情况造成释放锁失败,但到了我们设定的超时时间,锁还是会被redis自动释放。 但只在finally中释放锁,就够了吗? 3 释放了别人的锁 做人要厚道,先回答上面的问题:只在finally中释放锁,当然是不够的,因为释放锁的姿势,还是不对。 哪里不对? 答:在多线程场景中,可能会出现释放了别人的锁的情况。 有些朋友可能会反驳:假设在多线程场景中,线程A获取到了锁,但如果线程A没有释放锁,此时,线程B是获取不到锁的,何来释放了别人锁之说? 答:假如线程A和线程B,都使用lockKey加锁。线程A加锁成功了,但是由于业务功能耗时时间很长,超过了设置的超时时间。这时候,redis会自动释放lockKey锁。此时,线程B就能给lockKey加锁成功了,接下来执行它的业务操作。恰好这个时候,线程A执行完了业务功能,接下来,在finally方法中释放了锁lockKey。这不就出问题了,线程B的锁,被线程A释放了。 我想这个时候,线程B肯定哭晕在厕所里,并且嘴里还振振有词。 那么,如何解决这个问题呢? 不知道你们注意到没?在使用set命令加锁时,除了使用lockKey锁标识,还多设置了一个参数:requestId,为什么要需要记录requestId呢? 答:requestId是在释放锁的时候用的。 伪代码如下: if (jedis.get(lockKey).equals(requestId)) { jedis.del(lockKey); return true; } return false; 在释放锁的时候,先获取到该锁的值(之前设置值就是requestId),然后判断跟之前设置的值是否相同,如果相同才允许删除锁,返回成功。如果不同,则直接返回失败。 换句话说就是:自己只能释放自己加的锁,不允许释放别人加的锁。 这里为什么要用requestId,用userId不行吗? 答:如果用userId的话,对于请求来说并不唯一,多个不同的请求,可能使用同一个userId。而requestId是全局唯一的,不存在加锁和释放锁乱掉的情况。 此外,使用lua脚本,也能解决释放了别人的锁的问题: if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end lua脚本能保证查询锁是否存在和删除锁是原子操作,用它来释放锁效果更好一些。 说到lua脚本,其实加锁操作也建议使用lua脚本: if (redis.call('exists', KEYS[1]) == 0) then redis.call('hset', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; return redis.call('pttl', KEYS[1]); 这是redisson框架的加锁代码,写的不错,大家可以借鉴一下。 有趣,下面还有哪些好玩的东西? 4 大量失败请求 上面的加锁方法看起来好像没有问题,但如果你仔细想想,如果有1万的请求同时去竞争那把锁,可能只有一个请求是成功的,其余的9999个请求都会失败。 在秒杀场景下,会有什么问题? 答:每1万个请求,有1个成功。再1万个请求,有1个成功。如此下去,直到库存不足。这就变成均匀分布的秒杀了,跟我们想象中的不一样。 如何解决这个问题呢? 此外,还有一种场景: 比如,有两个线程同时上传文件到sftp,上传文件前先要创建目录。假设两个线程需要创建的目录名都是当天的日期,比如:20210920,如果不做任何控制,直接并发的创建目录,第二个线程必然会失败。 这时候有些朋友可能会说:这还不容易,加一个redis分布式锁就能解决问题了,此外再判断一下,如果目录已经存在就不创建,只有目录不存在才需要创建。 伪代码如下: try { String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); if ("OK".equals(result)) { if(!exists(path)) { mkdir(path); } return true; } } finally{ unlock(lockKey,requestId); } return false; 一切看似美好,但经不起仔细推敲。 来自灵魂的一问:第二个请求如果加锁失败了,接下来,是返回失败,还是返回成功呢? 主要流程图如下: 显然第二个请求,肯定是不能返回失败的,如果返回失败了,这个问题还是没有被解决。如果文件还没有上传成功,直接返回成功会有更大的问题。头疼,到底该如何解决呢? 答:使用自旋锁。 try { Long start = System.currentTimeMillis(); while(true) { String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); if ("OK".equals(result)) { if(!exists(path)) { mkdir(path); } return true; } long time = System.currentTimeMillis() - start; if (time>=timeout) { return false; } try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); } } } finally{ unlock(lockKey,requestId); } return false; 在规定的时间,比如500毫秒内,自旋不断尝试加锁(说白了,就是在死循环中,不断尝试加锁),如果成功则直接返回。如果失败,则休眠50毫秒,再发起新一轮的尝试。如果到了超时时间,还未加锁成功,则直接返回失败。 好吧,学到一招了,还有吗? 5 锁重入问题 我们都知道redis分布式锁是互斥的。假如我们对某个key加锁了,如果该key对应的锁还没失效,再用相同key去加锁,大概率会失败。 没错,大部分场景是没问题的。 为什么说是大部分场景呢? 因为还有这样的场景: 假设在某个请求中,需要获取一颗满足条件的菜单树或者分类树。我们以菜单为例,这就需要在接口中从根节点开始,递归遍历出所有满足条件的子节点,然后组装成一颗菜单树。 需要注意的是菜单不是一成不变的,在后台系统中运营同学可以动态添加、修改和删除菜单。为了保证在并发的情况下,每次都可能获取最新的数据,这里可以加redis分布式锁。 加redis分布式锁的思路是对的。但接下来问题来了,在递归方法中递归遍历多次,每次都是加的同一把锁。递归第一层当然是可以加锁成功的,但递归第二层、第三层…第N层,不就会加锁失败了? 递归方法中加锁的伪代码如下: private int expireTime = 1000; public void fun(int level,String lockKey,String requestId){ try{ String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); if ("OK".equals(result)) { if(level<=10){ this.fun(++level,lockKey,requestId); } else { return; } } return; } finally { unlock(lockKey,requestId); } } 如果你直接这么用,看起来好像没有问题。但最终执行程序之后发现,等待你的结果只有一个:出现异常。 因为从根节点开始,第一层递归加锁成功,还没释放锁,就直接进入第二层递归。因为锁名为lockKey,并且值为requestId的锁已经存在,所以第二层递归大概率会加锁失败,然后返回到第一层。第一层接下来正常释放锁,然后整个递归方法直接返回了。 这下子,大家知道出现什么问题了吧? 没错,递归方法其实只执行了第一层递归就返回了,其他层递归由于加锁失败,根本没法执行。 那么这个问题该如何解决呢? 答:使用可重入锁。 我们以redisson框架为例,它的内部实现了可重入锁的功能。 古时候有句话说得好:为人不识陈近南,便称英雄也枉然。 我说:分布式锁不识redisson,便称好锁也枉然。哈哈哈,只是自娱自乐一下。 由此可见,redisson在redis分布式锁中的江湖地位很高。 伪代码如下: private int expireTime = 1000; public void run(String lockKey) { RLock lock = redisson.getLock(lockKey); this.fun(lock,1); } public void fun(RLock lock,int level){ try{ lock.lock(5, TimeUnit.SECONDS); if(level<=10){ this.fun(lock,++level); } else { return; } } finally { lock.unlock(); } } 上面的代码也许并不完美,这里只是给了一个大致的思路,如果大家有这方面需求的话,以上代码仅供参考。 接下来,聊聊redisson可重入锁的实现原理。 加锁主要是通过以下脚本实现的: if (redis.call('exists', KEYS[1]) == 0) then redis.call('hset', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; return redis.call('pttl', KEYS[1]); 其中: KEYS[1]:锁名 ARGV[1]:过期时间 ARGV[2]:uuid + “:” + threadId,可认为是requestId 先判断如果锁名不存在,则加锁。 接下来,判断如果锁名和requestId值都存在,则使用hincrby命令给该锁名和requestId值计数,每次都加1。注意一下,这里就是重入锁的关键,锁重入一次值就加1。 如果锁名存在,但值不是requestId,则返回过期时间。 释放锁主要是通过以下脚本实现的: if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then return nil end local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[2]); return 0; else redis.call('del', KEYS[1]); redis.call('publish', KEYS[2], ARGV[1]); return 1; end; return nil 先判断如果锁名和requestId值不存在,则直接返回。 如果锁名和requestId值存在,则重入锁减1。 如果减1后,重入锁的value值还大于0,说明还有引用,则重试设置过期时间。 如果减1后,重入锁的value值还等于0,则可以删除锁,然后发消息通知等待线程抢锁。 再次强调一下,如果你们系统可以容忍数据暂时不一致,有些场景不加锁也行,我在这里只是举个例子,本节内容并不适用于所有场景。 6 锁竞争问题 如果有大量需要写入数据的业务场景,使用普通的redis分布式锁是没有问题的。 但如果有些业务场景,写入的操作比较少,反而有大量读取的操作。这样直接使用普通的redis分布式锁,会不会有点浪费性能? 我们都知道,锁的粒度越粗,多个线程抢锁时竞争就越激烈,造成多个线程锁等待的时间也就越长,性能也就越差。 所以,提升redis分布式锁性能的第一步,就是要把锁的粒度变细。 6.1 读写锁 众所周知,加锁的目的是为了保证,在并发环境中读写数据的安全性,即不会出现数据错误或者不一致的情况。 但在绝大多数实际业务场景中,一般是读数据的频率远远大于写数据。而线程间的并发读操作是并不涉及并发安全问题,我们没有必要给读操作加互斥锁,只要保证读写、写写并发操作上锁是互斥的就行,这样可以提升系统的性能。 我们以redisson框架为例,它内部已经实现了读写锁的功能。 读锁的伪代码如下: RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock"); RLock rLock = readWriteLock.readLock(); try { rLock.lock(); //业务操作 } catch (Exception e) { log.error(e); } finally { rLock.unlock(); } 写锁的伪代码如下: RReadWriteLock readWriteLock = redisson.getReadWriteLock("readWriteLock"); RLock rLock = readWriteLock.writeLock(); try { rLock.lock(); //业务操作 } catch (InterruptedException e) { log.error(e); } finally { rLock.unlock(); } 将读锁和写锁分开,最大的好处是提升读操作的性能,因为读和读之间是共享的,不存在互斥性。而我们的实际业务场景中,绝大多数数据操作都是读操作。所以,如果提升了读操作的性能,也就会提升整个锁的性能。 下面总结一个读写锁的特点: 读与读是共享的,不互斥 读与写互斥 写与写互斥 6.2 锁分段 此外,为了减小锁的粒度,比较常见的做法是将大锁:分段。 在java中ConcurrentHashMap,就是将数据分为16段,每一段都有单独的锁,并且处于不同锁段的数据互不干扰,以此来提升锁的性能。 放在实际业务场景中,我们可以这样做: 比如在秒杀扣库存的场景中,现在的库存中有2000个商品,用户可以秒杀。为了防止出现超卖的情况,通常情况下,可以对库存加锁。如果有1W的用户竞争同一把锁,显然系统吞吐量会非常低。 为了提升系统性能,我们可以将库存分段,比如:分为100段,这样每段就有20个商品可以参与秒杀。 在秒杀的过程中,先把用户id获取hash值,然后除以100取模。模为1的用户访问第1段库存,模为2的用户访问第2段库存,模为3的用户访问第3段库存,后面以此类推,到最后模为100的用户访问第100段库存。 如此一来,在多线程环境中,可以大大的减少锁的冲突。以前多个线程只能同时竞争1把锁,尤其在秒杀的场景中,竞争太激烈了,简直可以用惨绝人寰来形容,其后果是导致绝大数线程在锁等待。现在多个线程同时竞争100把锁,等待的线程变少了,从而系统吞吐量也就提升了。 需要注意的地方是:将锁分段虽说可以提升系统的性能,但它也会让系统的复杂度提升不少。因为它需要引入额外的路由算法,跨段统计等功能。我们在实际业务场景中,需要综合考虑,不是说一定要将锁分段。 7 锁超时问题 我在前面提到过,如果线程A加锁成功了,但是由于业务功能耗时时间很长,超过了设置的超时时间,这时候redis会自动释放线程A加的锁。 有些朋友可能会说:到了超时时间,锁被释放了就释放了呗,对功能又没啥影响。 答:错,错,错。对功能其实有影响。 通常我们加锁的目的是:为了防止访问临界资源时,出现数据异常的情况。比如:线程A在修改数据C的值,线程B也在修改数据C的值,如果不做控制,在并发情况下,数据C的值会出问题。 为了保证某个方法,或者段代码的互斥性,即如果线程A执行了某段代码,是不允许其他线程在某一时刻同时执行的,我们可以用synchronized关键字加锁。 但这种锁有很大的局限性,只能保证单个节点的互斥性。如果需要在多个节点中保持互斥性,就需要用redis分布式锁。 做了这么多铺垫,现在回到正题。 假设线程A加redis分布式锁的代码,包含代码1和代码2两段代码。 由于该线程要执行的业务操作非常耗时,程序在执行完代码1的时,已经到了设置的超时时间,redis自动释放了锁。而代码2还没来得及执行。 此时,代码2相当于裸奔的状态,无法保证互斥性。假如它里面访问了临界资源,并且其他线程也访问了该资源,可能就会出现数据异常的情况。(PS:我说的访问临界资源,不单单指读取,还包含写入) 那么,如何解决这个问题呢? 答:如果达到了超时时间,但业务代码还没执行完,需要给锁自动续期。 我们可以使用TimerTask类,来实现自动续期的功能: Timer timer = new Timer(); timer.schedule(new TimerTask() { @Override public void run(Timeout timeout) throws Exception { //自动续期逻辑 } }, 10000, TimeUnit.MILLISECONDS); 获取锁之后,自动开启一个定时任务,每隔10秒钟,自动刷新一次过期时间。这种机制在redisson框架中,有个比较霸气的名字:watch dog,即传说中的看门狗。 当然自动续期功能,我们还是优先推荐使用lua脚本实现,比如: if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('pexpire', KEYS[1], ARGV[1]); return 1; end; return 0; 需要注意的地方是:在实现自动续期功能时,还需要设置一个总的过期时间,可以跟redisson保持一致,设置成30秒。如果业务代码到了这个总的过期时间,还没有执行完,就不再自动续期了。 自动续期的功能是获取锁之后开启一个定时任务,每隔10秒判断一下锁是否存在,如果存在,则刷新过期时间。如果续期3次,也就是30秒之后,业务方法还是没有执行完,就不再续期了。 8 主从复制的问题 上面花了这么多篇幅介绍的内容,对单个redis实例是没有问题的。 but,如果redis存在多个实例。比如:做了主从,或者使用了哨兵模式,基于redis的分布式锁的功能,就会出现问题。 具体是什么问题? 假设redis现在用的主从模式,1个master节点,3个slave节点。master节点负责写数据,slave节点负责读数据。 本来是和谐共处,相安无事的。redis加锁操作,都在master上进行,加锁成功后,再异步同步给所有的slave。 突然有一天,master节点由于某些不可逆的原因,挂掉了。 这样需要找一个slave升级为新的master节点,假如slave1被选举出来了。 如果有个锁A比较悲催,刚加锁成功master就挂了,还没来得及同步到slave1。 这样会导致新master节点中的锁A丢失了。后面,如果有新的线程,使用锁A加锁,依然可以成功,分布式锁失效了。 那么,如何解决这个问题呢? 答:redisson框架为了解决这个问题,提供了一个专门的类:RedissonRedLock,使用了Redlock算法。 RedissonRedLock解决问题的思路如下: 需要搭建几套相互独立的redis环境,假如我们在这里搭建了5套。 每套环境都有一个redisson node节点。 多个redisson node节点组成了RedissonRedLock。 环境包含:单机、主从、哨兵和集群模式,可以是一种或者多种混合。 在这里我们以主从为例,架构图如下: RedissonRedLock加锁过程如下: 获取所有的redisson node节点信息,循环向所有的redisson node节点加锁,假设节点数为N,例子中N等于5。 如果在N个节点当中,有N/2 + 1个节点加锁成功了,那么整个RedissonRedLock加锁是成功的。 如果在N个节点当中,小于N/2 + 1个节点加锁成功,那么整个RedissonRedLock加锁是失败的。 如果中途发现各个节点加锁的总耗时,大于等于设置的最大等待时间,则直接返回失败。 从上面可以看出,使用Redlock算法,确实能解决多实例场景中,假如master节点挂了,导致分布式锁失效的问题。 但也引出了一些新问题,比如: 需要额外搭建多套环境,申请更多的资源,需要评估一下成本和性价比。 如果有N个redisson node节点,需要加锁N次,最少也需要加锁N/2+1次,才知道redlock加锁是否成功。显然,增加了额外的时间成本,有点得不偿失。 由此可见,在实际业务场景,尤其是高并发业务中,RedissonRedLock其实使用的并不多。 在分布式环境中,CAP是绕不过去的。 CAP指的是在一个分布式系统中: 一致性(Consistency) 可用性(Availability) 分区容错性(Partition tolerance) 这三个要素最多只能同时实现两点,不可能三者兼顾。 如果你的实际业务场景,更需要的是保证数据一致性。那么请使用CP类型的分布式锁,比如:zookeeper,它是基于磁盘的,性能可能没那么好,但数据一般不会丢。 如果你的实际业务场景,更需要的是保证数据高可用性。那么请使用AP类型的分布式锁,比如:redis,它是基于内存的,性能比较好,但有丢失数据的风险。 其实,在我们绝大多数分布式业务场景中,使用redis分布式锁就够了,真的别太较真。因为数据不一致问题,可以通过最终一致性方案解决。但如果系统不可用了,对用户来说是暴击一万点伤害。 扩展学习 Redis集群分布式锁:https://github.com/ronnylt/redlock-php
Namesilo 是一家国外的域名注册商,价格及其便宜,比国内大家熟知的阿里腾讯等便宜好多,.com域名不到6刀就可以买,而且续费也便宜。 Namesilo 是 ICANN 认证的域名域名注册商之一,注册和转入都很方便,安全保护到位而且性价比很高。 Namesilo 提供永久免费的域名隐私保护,防止别人通过 WHOIS 查询获取域名所有者的个人注册信息。这点Godaddy就很坑,续费时域名保护还要单独收费。 Namesilo 支持账户登陆二次验证和 Domain Defender,保护账户和域名安全。登陆、解锁域名等,都可以设置邮件或短信提醒。 Namesilo 支持支付宝、Paypal、信用卡等多种方式付款。 Namesilo 只有一个一美元优惠码,VIEN 或者 viencoding,一直有效,注册、转移等都可用,每个用户可以使用一次 相关链接 Namesilo 主页:https://www.namesilo.com/?rid=a1c4f61os Namesilo 注册页:https://www.namesilo.com/register.php?rid=a1c4f61os Namesilo 价格页:https://www.namesilo.com/pricing?rid=a1c4f61os Coupon优惠券 viencoding VIEN Namesilo域名优惠购买 查询 进入[Namesilo域名注册页],输入想要的域名,点search搜索 如果域名可用,勾选域名,然后点击下面的蓝色按钮注册该域名 使用优惠券 左下角输入优惠码VIEN或者viencoding并且点击submit,右边价格就会提示减去1刀。然后点击continue继续。 支付购买 这里你开心选什么就选什么呗,不过国内最方便的应该还是支付宝吧,其他的稍微麻烦点而且很多人没有。付款后就购买成功了。 配置解析 登陆后进入 [域名管理] 如果原来有解析记录,点右边的红色的叉叉删掉,然后点上面添加A记录,输入你要解析的ip就好了。 虽然域名很便宜,但是也有一个小缺点,就是域名解析配置后生效时间稍微长一点,运气好可能很快生效,运气不好半个小时甚至两个小时才会生效。所以,设置好之后没有生效,不要怀疑,不是你设置的有问题,而是他生效时间长。
使Simple主题支持数学公式 在使用 hexo 主题模版 hexo-theme-simple99 的时候,发现该模版并不支持数学公式的渲染,随即动手开始改造改模版使其支持数学公式的解析。 参考文献 Katex渲染器语法参考:https://katex.org/docs/supported.html 配置的参数说明:https://blog.csdn.net/qq_36667170/article/details/105846999 常见错误($转义):https://github.com/theme-kaze/hexo-theme-kaze/issues/24 常见错误(unicode错误):http://mtw.so/6qEG7l 部署过程 插件安装 npm uninstall hexo-renderer-marked --save npm install hexo-renderer-markdown-it-plus --save 修改主题的配置文件 在主题模版的_config.yml中编辑: # 数学公式支持 markdown_it_plus: highlight: true html: true xhtmlOut: true breaks: true langPrefix: linkify: true typographer: quotes: '“”‘’' plugins: - plugin: name: markdown-it-katex enable: true - plugin: name: markdown-it-mark enable: false anchors: level: 2 collisionSuffix: 'v' permalink: true permalinkClass: header-anchor permalinkSide: 'left' permalinkSymbol: ¶ 主题head.ejs中引入css 在主题文件 themes/simple/layout/partials/head.ejs 中,引入 css 的公式样式文件 <link href="https://cdn.bootcss.com/KaTeX/0.7.1/katex.min.css" rel="stylesheet"> 写作过程中的注意事项 1、在 markdown 头定义的时候要定义的时候记得要开启数学公式的支持 --- title: Hexo 数学公式支持 toc: true categories: blog abbrlink: 712298389 date: 2021-01-21 18:30:41 mathjax: true katex: true --- 2、为了方便使用,可以先在在线公式编辑器编辑好公式,然后粘贴到 markdown 文档中 https://www.codecogs.com/latex/eqneditor.php https://zh.numberempire.com/latexequationeditor.php 重新编译 hexo clean && hexo g 最后重新编译部署。可能会提示插件markdown-it-katex不存在,使用命令npm install markdown-it-katex --save安装即可。 测试数学公式的渲染 $$ \sum_{i=0}^n i^2 = \frac{(n^2+n)(2n+1)}{6} $$ ∑i=0ni2=(n2+n)(2n+1)6\sum_{i=0}^n i^2 = \frac{(n^2+n)(2n+1)}{6} i=0∑ni2=6(n2+n)(2n+1) $$ f(n) = \begin{cases} n/2, & \text{if $n$ is even} \\ 3n+1, & \text{if $n$ is odd} \end{cases} $$ f(n)={n/2,if n is even3n+1,if n is oddf(n) = \begin{cases} n/2, & \text{if $n$ is even} \\ 3n+1, & \text{if $n$ is odd} \end{cases} f(n)={n/2,3n+1,if n is evenif n is odd $$ \left[ \begin{array}{cc|c} 1&2&3\\ 4&5&6 \end{array} \right] $$ [123456]\left[ \begin{array}{cc|c} 1&2&3\\ 4&5&6 \end{array} \right] [142536]
前言 在做次URL优化之前,hexo-next文章链接默认的生成规则是::year/:month/:day/:title,是按照年、月、日、标题来生成的。 比如:https://blog.mailjob.net/2019/08/12/hello-world/ 这样,如果文章标题是中文的话,后面的 title 还会被 urlencode 变成一长串,非常不利于阅读,更不利于 SEO。 一种解决方案是:使用 hexo-permalink-pinyin 插件,将中文转英文。 这样方案也存在一定的缺陷,比如修改了文章标题,重新hexo三连后,URL就变了,以前的文章地址变成了404。而且这样生成的URL层级也很深,不利于SEO。 那能不能生成唯一不变的URl链接呢?答案是可以的。这就是我们要说的hexo-abbrlink插件。 参考文献 hexo-abbrlink仓库:https://github.com/rozbo/hexo-abbrlink 实现步骤 安装 npm install hexo-abbrlink --save 打开 config.yml,修改 permalink 中类似这样 permalink: posts/:abbrlink.html abbrlink: alg: crc32 # 算法:crc16(default) and crc32 rep: dec # 进制:dec(default) and hex 这个是使用了各个算法、进制的效果如下 算法 进制 结果 crc16 hex https://blog.mailjob.net/posts/3ab2.html crc16 dec https://blog.mailjob.net/posts/12345.html crc32 hex https://blog.mailjob.net/posts/9a8b6c4d.html crc32 dec https://blog.mailjob.net/posts/1690090958.html 问题 1、文章的链接都变成了undefined 这个配置完成之后,文章的链接都变成了undefined,新的文章没问题,老的文章就不行了。这个问题其实我们仔细想一下就能明白,我们首先要执行hexo clean 清楚掉以前生成的文章缓存,然后hexo g重新渲染就ok了
引言 在 hexo管理网站方式二 文章中,介绍了利用 hexo-admin 管理网站的方法,但是用过 hexo-admin 可能深有体会,就是改插件无论是文章管理上,还是在图片资源的管理上,都比较生硬,很没有现代化的感觉,所以用起来感觉不是很好用。 所以本文结合作者自己的感受,将会讲一讲作者在网站管理过程中,用起来非常舒适的网站管理组合工具。 Typora+PicGo介绍 Typora Typora是一款免费的轻量级Markdown编辑器,它有 OS X、Windows、Linux 三个平台的版本,界面设计简洁。支持数学公式,流程图等功能。 PicGo PicGo 算得上一款比较优秀的图床工具。它是一款用 Electron-vue 开发的软件,可以支持微博,七牛云,腾讯云COS,又拍云,GitHub,阿里云OSS,SM.MS,imgur 等8种常用图床,功能强大,简单易用。 管理工具下载 Markdown创作工具:https://www.typora.io PicGo图床:https://github.com/Molunerfinn/picgo/releases 截图工具:https://zh.snipaste.com PicGo图床配置: 设定AccessKey: *** 设定SecretKey**:* 设定存储空间名**:* 设定访问网址**: 确认存储区域**: 设定网址后缀: 指定存储路径**:/ 插件设置里面,打开【时间戳重命名】开关 这样命名的资源比较整齐 Typora编辑器配置picgo如下 这样做的话,在 Typora 粘贴的图片资源,可以直接鼠标右键自动上传到COS仓库,是非常方便的
前言 github-page搭建网站好处 全是静态文件,访问速度快; 免费方便,不用花一分钱就可以搭建一个网站,不需要服务器不需要后台; 可以随意绑定自己的域名; 数据绝对安全,基于github的版本管理,想恢复到哪个历史版本都行; 网站内容可以轻松打包、转移、发布到其它平台; 准备工作 有一个github账号,没有的话去注册一个; 安装了node.js、npm,并了解相关基础知识; 安装了git for windows(或者其它git客户端) nodejs 安装 nodejs 安装地址:https://nodejs.org/zh-cn/download/ nodejs 安装完成后查看版本,如果看到了版本号,意味着你安装成功了 node -v npm -v Github 配置 1、github repository 创建您的 github 仓库,请注意,格式是:xxx.github.io 比如我的 github 的 username 是 “jefferyjob”,那么我的仓库命名是:jefferyjob.github.io 2、ssh配置 打开你的 gith bash 输入以下命令,连续回车,集合创建成功 git 的私钥和公钥 ssh-keygen -t rsa -C "邮件地址" 输入以下命令查看您的公钥 cat ~/.ssh/id_rsa.pub 然后在 github 的仓库中添加自己的私钥 本地部署Hexo HEXO介绍 1. hexo简介 Hexo是一个简单、快速、强大的基于 Github Pages 的博客发布工具,支持Markdown格式,有众多优秀插件和主题。 官网: http://hexo.io github: https://github.com/hexojs/hexo 2. 原理 由于github pages存放的都是静态文件,博客存放的不只是文章内容,还有文章列表、分类、标签、翻页等动态内容,假如每次写完一篇文章都要手动更新博文目录和相关链接信息,相信谁都会疯掉,所以hexo所做的就是将这些md文件都放在本地,每次写完文章后调用写好的命令来批量完成相关页面的生成,然后再将有改动的页面提交到github。 3. 注意事项 3.1. 很多命令既可以用Windows的cmd来完成,也可以使用git bash来完成,但是部分命令会有一些问题,为避免不必要的问题,建议全部使用git bash来执行; 3.2. hexo不同版本差别比较大,网上很多文章的配置信息都是基于2.x的,所以注意不要被误导; 3.3. hexo有2种_config.yml文件,一个是根目录下的全局的_config.yml,一个是各个theme下的; 1、全局安装 $ npm install -g hexo 2、初始化 新建一个文件夹(名字可以随便取),比如我的是 G:\jefferyjob.io,由于这个文件夹将来就作为你存放代码的地方,所以最好放在你的工作目录。 // 进入项目目录 $ cd /g/jefferyjob.io // 初始化 hexo 项目(需要3到5分钟的时间) $ hexo init hexo会自动下载一些文件到这个目录,包括node_modules,目录结构如下图: 运行以下命令,启动试试吧 # 生成静态页面 $ hexo g # 启动服务 $ hexo s 执行以上命令之后,hexo就会在public文件夹生成相关html文件 测试网站 打开浏览器访问 http://localhost:4000/ 即可看到内容 很多人会碰到浏览器一直在转圈但是就是加载不出来的问题,一般情况下是因为端口占用的缘故 因为4000这个端口太常见了,解决端口冲突问题请参考这篇文章: http://blog.liuxianan.com/windows-port-bind.html 第一次初始化的时候hexo已经帮我们写了一篇名为 Hello World 的文章,打开时就是这个样子 Hexo部署到github 1、部署仓库地址 如果你一切都配置好了,发布上传很容易,一句 hexo d 就搞定,当然关键还是你要把所有东西配置好。 首先,ssh key 肯定要配置好。 其次,配置 _config.yml 中有关deploy的部分: 正确写法: deploy: type: git repository: git@github.com:jefferyjob/jefferyjob.github.io.git branch: main 错误写法: deploy: type: github repository: https://github.com/jefferyjob/jefferyjob.github.io.git branch: main 这里有几个注意点: 1、钩子请使用git,而不要使用http拉取 2、branch使用main,不要用master( 从2020年10月1日开始,github默认分支使用main,详见:https://github.com/github/renaming ) 3、该仓库的秘钥要提前配置好 后面一种写法是hexo2.x的写法,现在已经不行了,无论是哪种写法,此时直接执行hexo d的话一般会报如下错误: Deployer not found: github 或者 Deployer not found: git 2、安装插件 HexoBlog部署到git我们需要安装hexo-deployer-git插件,在博客HexoBlog根目录运行 npm install hexo-deployer-git --save 输入 hexo d 就会将本次有改动的代码全部提交,没有改动的不会: hexo d Hexo 常用命令 hexo new "postName" #新建文章 hexo new page "pageName" #新建页面 hexo generate #生成静态页面至public目录 hexo server #开启预览访问端口(默认端口4000,'ctrl + c'关闭server) hexo deploy #部署到GitHub hexo help # 查看帮助 hexo version #查看Hexo的版本 缩写: hexo n == hexo new hexo g == hexo generate hexo s == hexo server hexo d == hexo deploy 组合命令: hexo s -g #生成并本地预览 hexo d -g #生成并上传
引言 在 hexo管理网站方式一 文章中,可以看出,用原生的方法来管理博文十分的不便,尤其是博客发布上和文章中的图片资源管理。 HexoAdmin部署 Hexo-Admin介绍 hexo-admin 是一个Hexo博客引擎的管理用户界面插件。这个插件最初是作为本地编辑器设计的,在本地运行hexo使用hexo-admin编写文章,然后通过hexo g或hexo d(hexo g是本地渲染,hexo d是将渲染的静态页面发布到GitHub)将生成的静态页面发布到GitHub等静态服务器。如果你使用的是非静态托管服务器,比如自己买的主机搭建的hexo,那么一定要设置hexo-admin 的密码,否则谁都可以编辑你的文章。 插件安装 npm install --save hexo-admin 启动服务器 hexo s 即可http://localhost:4000/admin/中编辑博文了 然后,Deploy之前,还需要编辑配置文件 _config.yml。(否则会出现Error: Config value “admin.deployCommand” not found或者Error: spawn hexo ENOENT之类的报错。) 如果是Windows则在末尾加上 # hexo-admin authentification admin: deployCommand: hexo-pubish.bat 然后在同级目录新建 hexo-pubish.bat 文件,文件内容如下: hexo g -d 如果是Linux系统则参考Issues 编辑完毕后,就可以点击Deploy,直接部署发布Github博客上。 Hexo-Admin插入图片 Hexo Admin可以直接复制图片粘贴,然后自动下载到 source/images 目录并重命名。但在Windows中粘贴后会出现裂图。这时就需要手动把括号中的前后两个斜杠去掉,就能正常显示。
hexo 更换主题 默认主题很丑,那我们别的不做,首先来替换一个好看点的主题。 这是官方主题:https://hexo.io/themes/ 我个人想换 “simple99” 这个主题,然后执行命令,下载这个主题 git clone https://github.com/shuxhan/hexo-theme-simple99.git themes/simple99 # themes/simple99 代表clone到themes目录下的simple99目录 修改 _config.yml 中的 theme: landscape 改为 theme: simple99, 然后重新执行 hexo g 来重新生成。 如果出现一些莫名其妙的问题,可以先执行hexo clean来清理一下public的内容, 然后再来重新生成和发布 则主题更换成功
引言 注意:hexo管理网站方式一 和 hexo管理网站方式二 可能需要该文章中的七牛插件文件托管方法。如果你选择是 hexo管理网站方式三 方法管理你的网站,那么你不需要改文章讲的托管方法,所以你并不需要阅读此文章 在本站的 hexo网站搭建文章中,方法一和方法二,都采用的是将图片资源托管到git仓库进行管理。 但是在实际的开发过程中,我们总是习惯将图片资源和文档资源分开管理,这样可以让我们的代码仓库不会被静态的资源(图片、视频)等占据大量空间。 所以,在本文中,我们将探讨一种采用第三次 COS 托管图片的方案,选择的是七牛云,因为每个用户友10G的免费空间,这些空间足够我们托管hexo博客的静态资源了。 参考文献 插件github地址:https://github.com/gyk001/hexo-qiniu-sync 七牛云注册:https://www.qiniu.com/ 开始安装 安装七牛云插件 安装七牛云插件 npm install hexo-qiniu-sync --save 配置相关信息 配置站点文件 _config.yml,配置入内容(注意:添加到配置文件时,把//去掉) #plugins: # - hexo-qiniu-sync qiniu: offline: false sync: true bucket: blogwenbo //这里将其注释掉,不注释,执行hexo g报错 # secret_file: sec/qn.json or C: access_key: your access_key secret_key: your secret_key // 上传的资源子目录前缀.如设置,需与urlPrefix同步 dirPrefix: static //外链前缀 urlPrefix: http://p2zukkwm9.bkt.clouddn.com/static //使用默认配置即可 up_host: http://upload.qiniu.com //本地目录 local_dir: static // 是否更新已经上传过的文件(仅文件大小不同或在上次上传后进行更新的才会重新上传) update_exist: true image: folder: images extend: js: folder: js css: folder: css 生成七牛配置路径,执行下面命令任意一个 hexo s 或 hexo g //终端输出 INFO ----------------------------------------------------------- INFO qiniu state: online INFO qiniu sync: true INFO qiniu local dir: static INFO qiniu url: http://blogwenbo.com/static INFO ----------------------------------------------------------- INFO Start processing INFO Now start qiniu sync. INFO Need upload file num: 0 就会在static目录下生成images、css、js三个文件夹。这时我们把测试图片七牛云.png放在images文件夹下,然后按照如下标签语法书写: {% qnimg 七牛云.png title:七牛云 alt:七牛云 'class:' extend:?imageView2/2/w/450 %} 同步静态资源到七牛云空间,主要有两种方式,一种是使用hexo命令,还有一种是使用七牛插件命令,可以参考GitHub文档:hexo-qiniu-sync //1、启用本地服务器.即使用 hexo server 命令(简写为 hexo s) //当以本地服务器模式启动后,会自动监测 local_dir 目录下的文件变化, 会自动将新文件进行上传。 如果文件进行了修改,但设置中没有启用 update_exist 配置,则不会更新到七牛空间。 hexo s hexo server //2、使用命令行命令(sync | s | sync2 | s2) //命令行命令会扫描 local_dir 目录下的文件,同步至七牛空间。 hexo qiniu sync hexo qiniu s hexo qiniu sync2 hexo qiniu s2 问题解决 没有注释**secret_file: sec/qn.json or C:**报错 只需要吧这个注释掉就行了 # secret_file: sec/qn.json or C: hexo-qiniu-sync安装好后,hexo s命令不见了,hexo d也提示问题 #41 //将其注释就好了 #plugins: # - hexo-qiniu-sync
绑定自定义的域名 首先在 github 仓库 的 setting 的 github pages 里面设置你的域名 会发现仓库里面多了一个 CNAME 这个里面就是你的域名,如果里面没有内容的话,记得写入内容 然后再域名解析控制台,配置 cname 域名解析 大功告成,可以访问你的域名试试看了 避坑总结 每次 git push 后域名总是被清空 检查仓库是否存在 CNAME 文件,并且里面要有域名 hexo部署项目 CNANE 文件被清空 在 hexo 项目的 source 目录,新建 CNAME 文件,写入你的域名,返回再执行 hexo s -g 命令发布 换了仓库后设置域名报 No changes to custom domain 该问题的出现是因为,域名配置的时候有缓存时间造成的 先把原来仓库的域名删除 万网配置域名的时候,不要去修改。而是删除后再添加 检查当前仓库是否存在 CNAME 文件,并且里面的域名是否正确
前言 该文章是针对于本站博主在 hexo 博客搭建过程中,遇到的一些问题和相应的解决方案的整理 QA 引入网络图片无法显示 在 md 文件的头部加入以下代码即可 <meta name="referrer" content="no-referrer"/> hexo 部署到多个仓库 deploy: type: git repository: github: git@github.com:mailjobblog/mailjobblog.github.io.git,main gitee: git@gitee.com:libinblog/libinblog.git,master branch: main source里面的README.md每次hexo g被转义成html文件问题 跟目录的 _config.yml 文件加入忽略 skip_render: - README.md - LICENSE - robots.txt - '*.d.md' - _posts/blog/hexo/*.d.md skip_render使用了minimatch,开始匹配的位置是基于你的source_dir的,一般来说,是你的source文件夹下。下面我分别列举几种常见的情况进行说明: 请注意yml中的文件格式,输入单个数据请注意空格,输入数组请进行缩进 单个文件夹下全部文件:skip_render: test/* 单个文件夹下指定类型文件:skip_render: test/*.html 单个文件夹下全部文件以及子目录:skip_render: test/** Hexo文章Scaffolds脚手架 当然有,这时候Scaffolds脚手架登场了 脚手架在scaffolds文件夹下,里面默认有post.md、draft.md、page.md三个,分别为博文、草稿和page的脚手架 我把 post.md 修改成我想要的格式 --- title: {{ title }} date: {{ date }} categories: {{ categories }} tags: - {{ tags }} ---
引言 对于初始化完成的 hexo 网站项目,你可以采用以下方式对网站进行简单的管理,例如:发布新文章,文章图片添加。 方法介绍 文章发布 在我们放置博客文件的文件夹Hexo中 source/_posts/ 目录下存放着所有博文的Markdown文件,初始化只有一个 hello-world.md 文件。 我们可以在 Git Bash 中创建新博文: hexo new <title> 在_posts目录下会生成相应的.md文件,接下来我们可以编辑该文件,直接写博文啦。(注意使用Markdown语法) 写完博文后,执行即可在博客中更新。 hexo g hexo d 如果要删除博文,则直接把_posts目录下相应的.md文件删除,再执行上述命令即可。 博文中插入图片 可以把图片统一放置在source/images目录下,然后在使用时用下述方式引用。  更多相关的操作,可以查看Hexo的官网。
Redis 事务 关系型数据中的事务都是原子性的,而redis 的事务是非原子性的。 Redis事务相关命令: MULTI :开启事务,redis会将后续的命令逐个放入队列中,然后使用EXEC命令来原子化执行这个命令系列。 EXEC:执行事务中的所有操作命令。 DISCARD:取消事务,放弃执行事务块中的所有命令。 WATCH:监视一个或多个key,如果事务在执行前,这个key(或多个key)被其他命令修改,则事务被中断,不会执行事务中的任何命令。 UNWATCH:取消WATCH对所有key的监视。 DB事务和 redis事务对比 一致性(Consistency) 隔离性(Isolation) 持久性(Durability) 原子性(Atomicity) mysql 支持 支持 支持 支持 redis 支持 支持 不支持 不支持 事务演示 执行执行一组事务命令 其中一条命令执行错误,后续的命令还是会继续执行。比如对string类型进行incr操作,并没有语法错误,但是会产生一个异常 基于WATCH对于事务作中断测试 客户端<1> 客户端<2> 总结: 1、当监控的key被其中一个事务修改后,那么另一个事务的执行将会被打断 2、n个事务事务开启,谁先exec谁成功,后面的exec会被打断
Redis日志 slowlog-log-slower-than:指定执行时间超过多少微秒(1秒等于1000000微秒) 的命令请求会被记录到日志上 slowlog-max-len:指定服务器最多保存多少条慢查询操作 设置slowlog有两种方式: 方式一:通过配置redis.conf文件进行配置 # 执行时间大于多少微秒(microsecond,1秒 = 1,000,000 微秒)的查询进行记录。 slowlog-log-lower-than 1000 #最多能保存多少条日志 slowlog-max-len 200 方式二:通过CONFIG命令进行配置 # 配置查询时间超过1毫秒的, 第一个参数单位是微秒 > CONFIG SET slowlog-log-slower-than 1000 # 保存 100 条慢查记录 > config set slowlog-max-len 100 127.0.0.1:6379 > slowlog get 以windows为例查看记录如下 为了方便解说,我设置超时时间为0毫秒,日志记录为1条 那么记录的中的1)2)3)4)分别表示什么呢? 1)表示日志唯一标识符uid 2)命令执行时系统的时间戳 3)命令执行的时长,以微妙来计算 4)命令和命令的参数 做日志查询的时候,可以通过3)来查看是具体的命令运行时间(注意:再强调一次,时间的单位是微妙,但对于一个插入操作来说,10000微妙,也就是10毫秒即0.01秒已经可以算是慢操作了) 需要去查看redis生成的持久型日志,这需要额外去配置一些内容,其中涉及到了集群和分布式, redis 管道 pileline 1、未使用pipeline执行N条命令 2、使用了pipeline执行N条命令 3、两者性能对比 三、原生批命令(mset, mget)与Pipeline对比 1、原生批命令是原子性,pipeline是非原子性 (原子性概念:一个事务是一个不可分割的最小工作单位,要么都成功要么都失败。原子操作是指你的一个业务逻辑必须是不可拆分的. 处理一件事情要么都成功,要么都失败,原子不可拆分) 2、原生批命令一命令多个key, 但pipeline支持多命令(存在事务),非原子性 3、原生批命令是服务端实现,而pipeline需要服务端与客户端共同完成 4、使用pipeline组装的命令个数不能太多,不然数据量过大,增加客户端的等待时间,还可能造成网络阻塞,可以将大量命令的拆分多个小的pipeline命令完成 5、redis提供了mset、mget方法,但没有提供mdel方法,如果想实现,可以借助pipeline实现。 贴上代码 <?php $stime=microtime(true); //获取程序开始执行的时间 echo '开始内存:'.memory_get_usage(), ''; echo PHP_EOL; $redis = new \Redis(); $redis->connect('192.168.29.108',6379); $redis->auth("root"); //$pipe=$redis->multi($redis::PIPELINE);//将多个操作当成一个事务执行 $pipe=$redis->pipeline();//(多条)执行命令简单的,更加快速的发送给服务器,但是没有任何原子性的保证 for($i= 0; $i<10000 ; $i++) { $pipe->set("key::$i",str_pad($i,4,'0',0)); $pipe->get("key::$i"); } $replies=$pipe->exec(); $etime=microtime(true);//获取程序执行结束的时间 $total=($etime-$stime); //计算差值 echo "[页面执行时间:{$total} ]s"; echo PHP_EOL; echo '运行后内存:'.memory_get_usage(), ''; echo PHP_EOL;
前言 1、原本有10亿个号码,现在又来了10万个号码,要快速准确判断这10万个号码是否在10亿个号码库中? 办法一:将10亿个号码存入数据库中,进行数据库查询,准确性有了,但是速度会比较慢。 办法二:将10亿号码放入内存中,比如Redis缓存中,这里我们算一下占用内存大小:10亿*8字节=8GB,通过内存查询,准确性和速度都有了,但是大约8gb的内存空间,挺浪费内存空间的。 2、接触过爬虫的,应该有这么一个需求,需要爬虫的网站千千万万,对于一个新的网站url,我们如何判断这个url我们是否已经爬过了?解决办法还是上面的两种,很显然,都不太好。 3、同理还有垃圾邮箱的过滤。 那么对于类似这种,大数据量集合,如何准确快速的判断某个数据是否在大数据量集合中,并且不占用内存,布隆过滤器应运而生了。 相关链接 Bloom Filter原理 布隆过滤器是一个基于m位的比特向量(b1,b2…,bm),这些比特向量的初始值为0。同时还有一系列的哈希函数(h1,h2…,hk),这些哈希函数运算后的哈希值范围在[1, m]内。 如下图,就是 x、y、z 三个元素插入到布隆过滤器中,并判断w值是否在集合中的示意图。 上图中,m=18,k=3 h1、h2、h3 是三个哈希函数将输入值分别映射成比特向量上的某个位置上。 1、数据结构 布隆过滤器:一种数据结构,是由一串很长的二进制向量组成,可以将其看成一个二进制数组。 既然是二进制,那么里面存放的不是0,就是1,但是初始默认值都是0。 2、哈希映射 1、布隆需要记录见过的数据,这里的记录需要通过hash函数对数据进行hash操作,得到数组下标并存储在BIT 数组里记为1。这样的记录一个数据只占用1BIT空间 2、判断是否存在时:给布隆过滤器一个数据,进行hash得到下标,从BIT数组里取数据如果是1 则说明数据存在,如果是0 说明不存在 3、精确度 hash算法存在碰撞的可能,所以不同的数据可能hash为一个下标数据, 故为了提高精确度就需要 使用多个hash 算法标记一个数据,和增大BIT数组的大小 假阳子性问题,布隆过滤器判断为数据存在可能数据并不存在,但是如果判断为数据不存在那么数据就一定是不存在的。 4、不支持删除 布隆过滤器只能插入数据判断是否存在,不能删除,而且只能保证【不存在】判断绝对准确 以上不难看出如果给数组的每个BIT位上加一个计数器,插入的时候+1 删除的时候 –1 就可以实现删除。 但是加计数器的实现是有问题的: 由于hash碰撞问题,布隆过滤器不能准确判断数据是否存在,就不能随意删除。其次计数器的回绕问题也需要考虑。 5、布隆过滤器优缺点 优点: 优点很明显,二进制组成的数组,占用内存极少,并且插入和查询速度都足够快。 缺点: 随着数据的增加,误判率会增加;还有无法判断数据一定存在;另外还有一个重要缺点,无法删除数据。 问题 1、hash碰撞 原因:不同的数据可能hash为一个下标数据 解决: 1.1、使用多个 hash 标记一个key 1.2、增大 bit 数组的大小 2、假阳子性问题 原因: 如上图所示,使用哈希函数将每个元素映射到一个二进制向量的三个位上。将集合中每个元素对应的三个位记录成1。 当需要判断一个新的元素w 在不在集合中时,可以先计算出w的三个位,然后只要发现其中存在任何一个位为0, 则可以100%肯定,w不在此集合中。 解决: 2.1、使用多个 hash 标记一个key 2.2、增大 byte 数组的大小 布隆过滤器相关扩展 Counting filters 基本的布隆过滤器不支持删除(Deletion)操作,但是 Counting filters 提供了一种可以不用重新构建布隆过滤器但却支持元素删除操作的方法。在Counting filters中原来的位数组中的每一位由 bit 扩展为 n-bit 计数器,实际上,基本的布隆过滤器可以看作是只有一位的计数器的Counting filters。原来的插入操作也被扩展为把 n-bit 的位计数器加1,查找操作即检查位数组非零即可,而删除操作定义为把位数组的相应位减1,但是该方法也有位的算术溢出问题,即某一位在多次删除操作后可能变成负值,所以位数组大小 m 需要充分大。另外一个问题是Counting filters不具备伸缩性,由于Counting filters不能扩展,所以需要保存的最大的元素个数需要提前知道。否则一旦插入的元素个数超过了位数组的容量,false positive的发生概率将会急剧增加。当然也有人提出了一种基于 D-left Hash 方法实现支持删除操作的布隆过滤器,同时空间效率也比Counting filters高。 Data synchronization Byers等人提出了使用布隆过滤器近似数据同步。 Bloomier filters Chazelle 等人提出了一个通用的布隆过滤器,该布隆过滤器可以将某一值与每个已经插入的元素关联起来,并实现了一个关联数组Map[10]。与普通的布隆过滤器一样,Chazelle实现的布隆过滤器也可以达到较低的空间消耗,但同时也会产生false positive,不过,在Bloomier filter中,某 key 如果不在 map 中,false positive在会返回时会被定义出的。该Map 结构不会返回与 key 相关的在 map 中的错误的值。 布隆过滤器安装 1、下载: 地址:https://github.com/RedisBloom/RedisBloom 下载ZIP 文件,上传到linux RedisBloom-master.zip 2、解压编译 命令: unzip RedisBloom-master.zip cd RedisBloom-master make 扫行完以上命令 后文件夹内生成一个文件名为:redisbloom.so 3、启动redis 时加载该模块 两个办法 第一,直接修改配置文件 # 进入reids目录 配置在redis.conf中 更加方便 >> vim redis.conf # 修改redis配置文件,加入redisboom模块 >> loadmodule /path/to/redisbloom.so 第二,redis启动时加载该模块 # redis-server 加仓布隆过滤器 # 参照文档:https://github.com/RedisBloom/RedisBloom#building-and-loading-redisbloom >> redis-server --loadmodule /path/to/redisbloom.so 4、redis重新启动 # 杀死redis进程 [root@VM-0-15-centos etc]# ps aux|grep redis root 16679 0.0 0.1 153896 2440 ? Ssl 15:10 0:00 ./redis-server 0.0.0.0:6379 root 30971 0.0 0.0 112712 980 pts/0 R+ 15:17 0:00 grep --color=auto redis [root@VM-0-15-centos etc]# kill -9 16679 [root@VM-0-15-centos etc]# ps aux|grep redis root 31436 0.0 0.0 112712 980 pts/0 R+ 15:17 0:00 grep --color=auto redis # 启动redis服务 [root@VM-0-15-centos etc]# cd ../bin ## 启动redis服务 [root@VM-0-15-centos bin]# ./redis-server /usr/local/redis/etc/redis.conf 32294:C 22 Jan 2021 15:17:40.648 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo 32294:C 22 Jan 2021 15:17:40.648 # Redis version=5.0.9, bits=64, commit=00000000, modified=0, pid=32294, just started 32294:C 22 Jan 2021 15:17:40.648 # Configuration loaded [root@VM-0-15-centos bin]# ps aux|grep redis root 32295 0.0 0.1 156020 2672 ? Ssl 15:17 0:00 ./redis-server 0.0.0.0:6379 root 32637 0.0 0.0 112712 984 pts/0 R+ 15:17 0:00 grep --color=auto redis 5、测试是否安装成功 127.0.0.1:6379> bf.add mym meituan (integer) 1 127.0.0.1:6379> bf.exists mym meituan (integer) 1 127.0.0.1:6379> bf.exists mym baidu (integer) 0 基本命令 bf.add 添加元素到布隆过滤器,bf.add只能添加一个 bf.exists 判断元素是否在布隆过滤器 bf.madd 添加多个元素到布隆过滤器 bf.mexists 判断多个元素是否在布隆过滤器 例子: > bf.add rurl www.baidu.com > bf.exists rurl www.baidu.com > bf.madd rurl www.sougou.com www.jd.com > bf.mexists rurl www.jd.com www.taobao.com
参考文献 [击穿,穿透,雪崩] 思维导图:https://kdocs.cn/l/sv1T3ny7wRQl 布隆过滤器:https://blog.mailjob.net/posts/7164113.html Redis 缓存击穿 1、形成原因: 当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,也会给后端系统(比如DB)带来很大压力。 2、解决方案 1、增加互斥锁:基于 redis 或者 zookeeper 实现互斥锁,等待第一个请求构建完缓存之后,再释放锁,进而其它请求才能通过该 key 访问数据 2、设置用不过期:击穿采用互斥锁。可能会发生死锁、线程池阻塞等问题,建议:高热点key,写定时器更新key的过期时间,最好是在并发量最小的时候。 3、缓存击穿示例图 Redis 缓存穿透 1、形成原因: key对应的数据在数据源并不存在,每次针对此key的请求从缓存获取不到,请求都会到数据源,从而可能压垮数据源。比如用一个不存在的用户id获取用户信息,不论缓存还是数据库都没有,若黑客利用此漏洞进行攻击可能压垮数据库。 2、解决方案 1、采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bit map中,一个一定不存在的数据会被 这个bit 数组拦截掉,从而避免了对底层存储系统的查询压力 2、如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。 3、缓存击穿示例图 Redis 缓存雪崩 1、形成原因: 当缓存服务器重启或者大量key集中在某一个时间段失效,这样在失效的时候,也会给后端系统(比如DB)带来很大压力 2、解决方案 1、保证高可用:做redis集群,哨兵。如果有的redis服务器出现宕机,切换到其他的节点 2、后端限流削峰:利用redis lua做漏斗算法,其他的进程进行排队。降级服务,牺牲一些服务器,淘宝双十一的时候,就不允许查看一年前的订单也不允许退款 3、过期时间错开,生成缓存的时候,一定时间内的过期时间随机生成。尽量避免大量缓存同一时间内失效 3、缓存击穿示例图
相关链接 Redis 常用命令,思维导图:https://www.kdocs.cn/view/l/ss49IFenIOSm Redis Stream 是 Redis 5.0 版本新增加的数据结构。 Redis Stream 主要用于消息队列(MQ,Message Queue),Redis 本身是有一个 Redis 发布订阅 (pub/sub) 来实现消息队列的功能,但它有个缺点就是消息无法持久化,如果出现网络断开、Redis 宕机等,消息就会被丢弃。 简单来说发布订阅 (pub/sub) 可以分发消息,但无法记录历史消息。 而 Redis Stream 提供了消息的持久化和主备复制功能,可以让任何客户端访问任何时刻的数据,并且能记住每一个客户端的访问位置,还能保证消息不丢失。 Stream有以下特点: 消息ID的序列化生成 消息遍历 消息的阻塞和非阻塞读取 消息的分组消费 未完成消息的处理 消息队列监控 Redis Stream 的结构如下所示,它有一个消息链表,将所有加入的消息都串起来,每个消息都有一个唯一的 ID 和对应的内容: 每个 Stream 都有唯一的名称,它就是 Redis 的 key,在我们首次使用 xadd 指令追加消息时自动创建。 上图解析: Consumer Group :消费组,使用 XGROUP CREATE 命令创建,一个消费组有多个消费者(Consumer)。 last_delivered_id :游标,每个消费组会有个游标 last_delivered_id,任意一个消费者读取了消息都会使游标 last_delivered_id 往前移动。 pending_ids :消费者(Consumer)的状态变量,作用是维护消费者的未确认的 id。 pending_ids 记录了当前已经被客户端读取的消息,但是还没有 ack (Acknowledge character:确认字符)。 通过思维导图做一个理解 应用场景 对于插入到队列中的数据,应用于不同的业务模块 举个栗子:笔者所在的企业是一个广告代理商公司,每天 9:00 到 12:00 媒体会生成客户的广告投放数据呢,会陆续的生成。虽然生成的时间点不确定,但是有一点是确定的,只要生成了意味着我可以得到广告主的【消费组1】小时报表,【消费组2】日报表,【消费组3】昨日消费流水。当这个数据检测生成的时候,会吧这个广告主的数据添加到队列中,上文中提到的 3个消费组,会根据不同的需求进行各自业务数据的生成 实战演练 1、XADD,生产消息 其中语法格式为: XADD key ID field string [field string ...] # 开始演示 "1610517042092-0" 127.0.0.1:6379> xadd message1 * name zhagnsan msg 123 name zhagnsan msg 456 "1610517125092-0" 127.0.0.1:6379> xadd message1 * name lisi msg 789 "1610517140757-0" 需要提供key,消息ID方案,消息内容,其中消息内容为key-value型数据。 ID,最常使用*,表示由Redis生成消息ID Redis使用毫秒时间戳和序号生成了消息ID。此时,消息队列中就有这么一些消息可用了。 此返回的id格式是:毫米数-序号 如果你用事务进行一次性提交的话,可以看到这里的序号变成 0、1、2、3 2、XREAD,消费消息 语法格式为: XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...] # 开始演示 127.0.0.1:6379> xread streams message1 0 1) 1) "message1" 2) 1) 1) "1610517125092-0" 2) 1) "name" 2) "zhagnsan" 3) "msg" 4) "123" 5) "name" 6) "zhagnsan" 7) "msg" 8) "456" 2) 1) "1610517140757-0" 2) 1) "name" 2) "lisi" 3) "msg" 4) "789" [COUNT count],用于限定获取的消息数量 [BLOCK milliseconds],用于设置XREAD为阻塞模式,默认为非阻塞模式 ID,用于设置由哪个消息ID开始读取。使用0表示从第一条消息开始。(本例中就是使用0)此处需要注意,消息队列ID是单调递增的,所以通过设置起点,可以向后读取。在阻塞模式中,可以使用$,表示最新的消息ID。(在非阻塞模式下$无意义) 一个典型的阻塞模式用法为: 127.0.0.1:6379> XREAD block 1000 streams message1 $ (nil) (1.07s) 我们使用Block模式,配合$作为ID,表示读取最新的消息,若没有消息,命令阻塞!等待过程中,其他客户端向队列追加消息,则会立即读取到。 因此,典型的队列就是 XADD 配合 XREAD Block 完成。XADD负责生成消息,XREAD负责消费消息。 3、消费者组模式,consumer group 当多个消费者(consumer)同时消费一个消息队列时,可以重复的消费相同的消息,就是消息队列中有10条消息,三个消费者都可以消费到这10条消息。 但有时,我们需要多个消费者配合协作来消费同一个消息队列,就是消息队列中有10条消息, 三个消费者分别消费其中的某些消息,比如消费者A消费消息1、2、5、8,消费者B消费消息4、9、10,而消费者C消费消息3、6、7。 也就是三个消费者配合完成消息的消费,可以在消费能力不足,也就是消息处理程序效率不高时,使用该模式。该模式就是消费者组模式。如下图所示: # 生产者生产消息 127.0.0.1:6379> multi OK 127.0.0.1:6379> xadd mq1 * meessage 1 QUEUED 127.0.0.1:6379> xadd mq1 * meessage 2 QUEUED 127.0.0.1:6379> xadd mq1 * meessage 3 QUEUED 127.0.0.1:6379> xadd mq1 * meessage 4 QUEUED 127.0.0.1:6379> xadd mq1 * meessage 5 QUEUED 127.0.0.1:6379> exec 1) "1610522197116-0" 2) "1610522197116-1" 3) "1610522197116-2" 4) "1610522197116-3" 5) "1610522197116-4" # 创建消费组 group1、group2 127.0.0.1:6379> xgroup create mq1 group1 0 OK 127.0.0.1:6379> xgroup create mq1 group2 0 OK # 消费组开始消费 # --- 消费组 group1 中的消费者 cusA 消费一条数据 127.0.0.1:6379> xreadgroup group group1 cusA count 1 streams mq1 > 1) 1) "mq1" 2) 1) 1) "1610522197116-0" 2) 1) "meessage" 2) "1" # --- 获取消息列表 127.0.0.1:6379> XRANGE mq1 - + 1) 1) "1610522197116-0" 2) 1) "meessage" 2) "1" 2) 1) "1610522197116-1" 2) 1) "meessage" 2) "2" 3) 1) "1610522197116-2" 2) 1) "meessage" 2) "3" 4) 1) "1610522197116-3" 2) 1) "meessage" 2) "4" 5) 1) "1610522197116-4" 2) 1) "meessage" 2) "5" # --- 消费组 group1 中的消费者 cusA 消费一条数据 2) "5" 127.0.0.1:6379> xreadgroup group group1 cusA count 1 streams mq1 > 1) 1) "mq1" 2) 1) 1) "1610522197116-1" 2) 1) "meessage" 2) "2" # --- 消费组 group2 中的消费者cusA 消费一条数据 127.0.0.1:6379> xreadgroup group group2 cusA count 1 streams mq1 > 1) 1) "mq1" 2) 1) 1) "1610522197116-0" 2) 1) "meessage" 2) "1" # --- 消费组 group1 中的消费者 cusB 消费一条数据 127.0.0.1:6379> xreadgroup group group1 cusB count 1 streams mq1 > 1) 1) "mq1" 2) 1) 1) "1610522197116-2" 2) 1) "meessage" 2) "3" 特点: 1、创建了新的消费组之后,该消费组所处游标都是从0开始的 2、每个消费组消费的时候,各个消费组之间互不干扰 为完成的消费数据丢失问题 若某个消费者,消费了某条消息,但是并没有处理成功时(例如消费者进程宕机),这条消息可能会丢失,因为组内其他消费者不能再次消费到该消息了 为了解决组内消息读取但处理期间消费者崩溃带来的消息丢失问题,STREAM 设计了 Pending 列表,用于记录读取但并未处理完毕的消息。命令 XPENDIING 用来获消费组或消费内消费者的未处理完毕的消息 # 获取 group1 的未被消费的信息 127.0.0.1:6379> XPENDING mq1 group1 1) (integer) 3 # 3个已经读取但是没处理 2) "1610522197116-0" # 未被消费起始id 3) "1610522197116-2" # 未被消费结束id 4) 1) 1) "cusA" # greoup1组中的消费者A有2个已读未处理 2) "2" 2) 1) "cusB" # greoup1组中的消费者B有1个已读未处理 2) "1" # 利用 start end count 获取消息的详细信息 127.0.0.1:6379> XPENDING mq1 group1 - + 10 1) 1) "1610522197116-0" #消息ID 2) "cusA" #消费者 3) (integer) 1488851 #从第一次读取到现在过了1488851ms 4) (integer) 1 # 消息被读取了1次 2) 1) "1610522197116-1" 2) "cusA" 3) (integer) 1234483 4) (integer) 1 3) 1) "1610522197116-2" 2) "cusB" 3) (integer) 1082148 4) (integer) 1 # 获取某个消费者的 pending 列表 127.0.0.1:6379> XPENDING mq1 group1 - + 10 cusA 1) 1) "1610522197116-0" 2) "cusA" 3) (integer) 2889119 4) (integer) 1 2) 1) "1610522197116-1" 2) "cusA" 3) (integer) 2634751 4) (integer) 1 每个Pending的消息有4个属性: 消息ID 所属消费者 IDLE,已读取时长 delivery counter,消息被读取次数 如何标识消息处理完毕?XACK # 标识总消息列表的第二条消息处理完毕 127.0.0.1:6379> XACK mqq group1 1610522197116-1 #通知消息处理结束,用消息ID标识 (integer) 0 # 然后我查一下 group1 的 penging 信息 127.0.0.1:6379> XPENDING mq1 group1 1) (integer) 3 2) "1610522197116-0" 3) "1610522197116-2" 4) 1) 1) "cusA" 2) "2" 2) 1) "cusB" 2) "1" *可以看到,1610522197116-1 这条信息已经没有了* 如何做消息转移? # group1 中的 cusA 这条信息(1610522197116-1)有 3176717ms没有被处理 127.0.0.1:6379> XPENDING mq1 group1 - + 10 1) 1) "1610522197116-0" 2) "cusA" 3) (integer) 3431085 4) (integer) 1 2) 1) "1610522197116-1" 2) "cusA" 3) (integer) 3176717 4) (integer) 1 3) 1) "1610522197116-2" 2) "cusB" 3) (integer) 3024382 4) (integer) 1 # 接下来我把消息 1610522197116-1 转移给 group1 中的 cusB # 转移超过 35 秒的消息 1610522197116-1 到 group1 中的 cusB 127.0.0.1:6379> XCLAIM mq1 group1 cusB 35000 1610522197116-1 1) 1) "1610522197116-1" 2) 1) "meessage" 2) "2" # 查看消费者 group1 看到 1610522197116-1 已经跑到消费者 cusB 中了 127.0.0.1:6379> XPENDING mq1 group1 - + 10 1) 1) "1610522197116-0" 2) "cusA" 3) (integer) 3653453 4) (integer) 1 2) 1) "1610522197116-1" 2) "cusB" 3) (integer) 9656 4) (integer) 2 3) 1) "1610522197116-2" 2) "cusB" 3) (integer) 3246750 4) (integer) 1 坏消息问题,Dead Letter,死信问题 如果某个消息,不能被消费者处理,也就是不能被XACK,这是要长时间处于Pending列表中,当累加到某个我们预设的临界值时,我们就认为是坏消息(也叫死信,DeadLetter,无法投递的消息),由于有了判定条件,我们将坏消息处理掉即可,删除即可。删除一个消息,使用XDEL语法,演示如下: # 先查看 mq1 中的所有数据 127.0.0.1:6379> xrange mq1 - + 1) 1) "1610522197116-0" 2) 1) "meessage" 2) "1" 2) 1) "1610522197116-1" 2) 1) "meessage" 2) "2" 3) 1) "1610522197116-2" 2) 1) "meessage" 2) "3" 4) 1) "1610522197116-3" 2) 1) "meessage" 2) "4" 5) 1) "1610522197116-4" 2) 1) "meessage" 2) "5" # 然后删除 1610522197116-3 这条消息 127.0.0.1:6379> xdel mq1 1610522197116-3 (integer) 1 # 最后查看,发现没有了 127.0.0.1:6379> xrange mq1 - + 1) 1) "1610522197116-0" 2) 1) "meessage" 2) "1" 2) 1) "1610522197116-1" 2) 1) "meessage" 2) "2" 3) 1) "1610522197116-2" 2) 1) "meessage" 2) "3" 4) 1) "1610522197116-4" 2) 1) "meessage" 2) "5" 信息监控,XINFO 查看队列信息 127.0.0.1:6379> Xinfo stream mq1 1) "length" 2) (integer) 4 3) "radix-tree-keys" 4) (integer) 1 5) "radix-tree-nodes" 6) (integer) 2 7) "groups" 8) (integer) 2 9) "last-generated-id" 10) "1610522197116-4" 11) "first-entry" 12) 1) "1610522197116-0" 2) 1) "meessage" 2) "1" 13) "last-entry" 14) 1) "1610522197116-4" 2) 1) "meessage" 2) "5" 查看消费组信息 127.0.0.1:6379> Xinfo GROUPS mq1 1) 1) "name" 2) "group1" 3) "consumers" 4) (integer) 2 5) "pending" 6) (integer) 3 7) "last-delivered-id" 8) "1610522197116-2" 2) 1) "name" 2) "group2" 3) "consumers" 4) (integer) 1 5) "pending" 6) (integer) 1 7) "last-delivered-id" 8) "1610522197116-0" # 这里看到了之前创建的两个消费组 查看消费组成员信息 127.0.0.1:6379> XINFO CONSUMERS mq1 group1 1) 1) "name" 2) "cusA" 3) "pending" 4) (integer) 1 5) "idle" 6) (integer) 1392667 2) 1) "name" 2) "cusB" 3) "pending" 4) (integer) 2 5) "idle" 6) (integer) 637989 # 消费者 cusB 里面有两个没有被消费 常用命令 去文档自己看吧,今天这个笔记写了好久,累的不行,我去喝口水
相关链接 lua调试:http://redis.cn/topics/ldb.html Lua脚本优势 1、减少网络开销:可以将多个请求通过脚本的形式一次发送,减少网络时延和请求次数。 2、原子性的操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。因此在编写脚本的过程中无需担心会出现竞态条件,无需使用事务。 3、代码复用:客户端发送的脚步会永久存在redis中,这样,其他客户端可以复用这一脚本来完成相同的逻辑。 4、速度快:见 与其它语言的性能比较, 还有一个 JIT编译器可以显著地提高多数任务的性能; 对于那些仍然对性能不满意的人, 可以把关键部分使用C实现, 然后与其集成, 这样还可以享受其它方面的好处。 5、可以移植:只要是有ANSI C 编译器的平台都可以编译,你可以看到它可以在几乎所有的平台上运行:从 Windows 到Linux,同样Mac平台也没问题, 再到移动平台、游戏主机,甚至浏览器也可以完美使用 (翻译成JavaScript). 6、源码小巧:20000行C代码,可以编译进182K的可执行文件,加载快,运行快。 lua 脚本 数据类型 描述 nil 这个最简单,只有值nil属于该类,表示一个无效值(在条件表达式中相当于false)。 boolean 包含两个值:false和true。 number 表示双精度类型的实浮点数。 string 字符串由一对双引号或单引号来表示。 function 由 C 或 Lua 编写的函数 userdata 表示任意存储在变量中的C数据结构 thread 表示执行的独立线路,用于执行协同程序 table Lua 中的表(table)其实是一个"关联数组"(associative arrays),数组的索引可以是数字或者是字符串。在 Lua 里,table 的创建是通过"构造表达式"来完成,最简单构造表达式是{},用来创建一个空表 local tbl1 = {}。 Lua的变量分为全局变量和局部变量。全局变量无需声明就可以直接使用,默认值是nil。如: a = 5 -- 为全局变量a赋值 print(b) -- 无需声明即可使用,默认是nil a = nil -- 删除全局变量a的方法是将其赋值为nil,全局变量没有声明和未声明之分,只有nil和非nil的区别 而在Redis脚本中不能使用全局变量,只允许使用局部变量以防止脚本之间相互影响。用 local 显示声明为局部变量。 local c -- 声明一个局部变量,默认值是nil local d=1 -- 声明一个局部变量d并赋值为1 local e,f -- 可以同时声明多个局部变量 同时声明一个存储函数的局部变量的方法为: local say_hi = function() print 'hi' end 变量名必须是非数字开头,只能包含字母、数字和下划线,区分大小写。变量名不能与Lua的保留关键字相同。 局部变量的作用域为从声明开始到所在层的语句块末尾,比如: local x=10 if true then local x=x+1 print(x) do local x=x+! print(x) end end print(x) 命令格式 Redis 提供了 EVAL(直接执行脚本) 和 EVALSHA(执行 SHA1 值的脚本) 这两个命令,可以使用内置的 Lua 解析器执行 Lua 脚本。语法格式为: *EVAL* *script numkeys key [key …] arg [arg …]* *EVALSHA* *sha1 numkeys key [key …] arg [arg …]* 参数说明: script / sha1:EVAL 命令的第一个参数为需要执行的 Lua 脚本字符,EVALSHA 命令的一个参数为 Lua 脚本的 SHA1 值 numkeys:表示 key 的个数 key [key …]:从第三个参数开始算起,表示在脚本中所用到的那些 Redis 键(key),这些键名参数可以在 Lua 中通过全局数组 KYES[i] 访问 arg [arg …]:附加参数,在 Lua 中通过全局数组 ARGV[i] 访问 EVAL 命令的使用示例: > EVAL "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second 1) "key1" 2) "key2" 3) "first" 4) "second" 每次使用 EVAL 命令都会传递需执行的 Lua 脚本内容,这样增加了宽带的浪费。Redis 内部会永久保存被运行在脚本缓存中,所以使用 EVALSHA(建议使用) 命令就可以根据脚本 SHA1 值执行对应的 Lua 脚本。 > SCRIPT LOAD "return 'hello'" "1b936e3fe509bcbc9cd0664897bbe8fd0cac101b" > EVALSHA 1b936e3fe509bcbc9cd0664897bbe8fd0cac101b 0 "hello" 批量HGETTALL 这个例子演示通过 Lua 实现批量 HGETALL -- KEYS为uid数组 local users = {} for i,uid in ipairs(KEYS) do local user = redis.call('hgetall', uid) if user ~= nil then table.insert(users, i, user) end end return users 应用场景 1、防止DDOS防护:限制n秒内同IP的访问次数 2、游戏热更新 laravel 用例测试 /** * 修改人员 * * @param level 用户等级标识 * @param int suid 原始值 * @param int euid 修改值 */ public function updateUser($level, int $suid, int $euid) { $key = $this->processUserKey . ':' . $level; $script = <<<LUA_SCRIPT local delete = redis.call('srem', KEYS[1], ARGV[1]) local insert = redis.call('sadd', KEYS[2], ARGV[2]) if (delete == 1 and insert) == 1 then return 1 else return 0 end LUA_SCRIPT; return Redis::eval($script, 2, $key, $key, $suid, $euid); }
相关链接 Redis 发布订阅 - 简单模式:https://kdocs.cn/l/sl5exWLEzq2x Redis 发布订阅 - 复杂模式:https://kdocs.cn/l/sd0RbrsFajZE 生活化场景重现 学校时期,学级主任为了提高整个学级学生的写作能力,会要求我们订阅一些周刊例如《读者》《意林》。 简单模式: 同学各自订阅,然后出版商会把周刊递交给邮局【频道channel】,然后邮局会在递交给所订阅整个周刊的同学们【(订阅者/客户端)client】 复杂模式: 出资班费后**班级【频道channel】订阅,然后这个周刊,班级内的同学【(订阅者/客户端)client】**都可以阅读 底层实现 服务器将所有的订阅关系都保存在服务器状态的pubsub_channels属性里面,在模式订阅里服务器也将所有模式的订阅关系都保存在服务器状态的pubsub_patterns属性里面。 struct redisServer { // ... // 保存所有模式订阅关系 list *pubsub_patterns; // ... } pubsub_patterns 属性是一个链表,链表中的每个节点都包含着一个pubsubPattern结构,这个结构的pattern属性记录了被订阅的模式,而client属性则记录了订阅模式的客户端。 typedef struct pubsubPattern { // 订阅模式的客户端 redisClient *client; // 被订阅的模式 robj * pattern; } pubsubPattern; 每当客户端执行PSUBSCRIBE命令订阅某个或某些模式的时候,服务器会对每个被订阅的模式执行以下两个操作: 新建一个pubsubPattern结构,将结构的pattern属性设置为被订阅的模式(如:music.*),client 属性设置为订阅模式的客户端。 将pubsubPattern结构添加到pubsub_patterns链表的表尾。 实战演练 Gif动画 第一层窗口:发布订阅窗口 第二层窗口:1:订阅 it.new 频道、2:订阅 it.photo 频道 第三层窗口:1:订阅 it.* 模式频道、2: 订阅 it* 模式频道 应用场景 简单的应用场景的话, 以门户网站为例, 当编辑更新了某推荐板块的内容后: CMS发布清除缓存的消息到channel (推送者推送消息) 门户网站的缓存系统通过channel收到清除缓存的消息 (订阅者收到消息),更新了推荐板块的缓存 Redis 发布订阅缺陷 作为我个人角度来看这个redis发布订阅,觉得挺鸡肋的,首先客户端必须得订阅,发布端才能发布信息,否则发布信息就报异常的错误。还有客户端一直要保持在线才能收到消息,和设计模式中的观察者模式极为相似。 所以总结一下,redis订阅模式有以下缺陷 1、redis系统的稳定性有关 对于旧版的redis来说,如果一个客户端订阅了某个或者某些频道,但是它读取消息的速度不够快,那么不断的积压的消息就会使得redis输出缓冲区的体积越来越大,这可能会导致redis的速度变慢,甚至直接崩溃。也可能会导致redis被操作系统强制杀死,甚至导致操作系统本身不可用。新版的redis不会出现这种问题,因为它会自动断开不符合client-output-buffer-limit pubsub配置选项要求的订阅客户端 2、数据传输的可靠性 任何网络系统在执行操作时都可能会遇到断网的情况。而断线产生的连接错误通常会使得网络连接两端中的一端进行重新连接。如果客户端在执行订阅操作的过程中断线,那么客户端将会丢失在断线期间的消息,这在很多业务场景下是不可忍受的。 所以如果我碰到类似的业务的话,我也不会选择redis的发布订阅模式,我会选择:rabbitMQ,rockedMQ,activitedMQ 等消息中间件 常用命令 下表列出了 redis 发布订阅常用命令: 序号 命令及描述 1 [PSUBSCRIBE pattern pattern …] 订阅一个或多个符合给定模式的频道。 2 [PUBSUB subcommand argument [argument …]] 查看订阅与发布系统状态。 3 PUBLISH channel message 将信息发送到指定的频道。 4 [PUNSUBSCRIBE pattern [pattern …]] 退订所有给定模式的频道。 5 [SUBSCRIBE channel channel …] 订阅给定的一个或多个频道的信息。 6 [UNSUBSCRIBE channel [channel …]] 指退订给定的频道。
参考资料 Redis 常用命令,思维导图:https://www.kdocs.cn/view/l/ss49IFenIOSm 底层实现 Hash 就是字典,所以明白了这个,接着往下看就没啥问题了 Redis hash数据结构 是一个键值对(key-value)集合,它是一个 string 类型的 field 和 value 的映射表 一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对 table属性是一个数组,数组中的每个元素都是指向dictEntry结构的指针,每个dictEntry结构保存着一个键值对,size属性记录了table的大小 used哈希表目前已有的节点sizemask属性的值总是等于size-1 散列存储 创建的时候是ht0,有数据了就是ht1,然后发现数据大了,就把ht1的数据导入到ht0,然后ht1的数据被清空,ht0数据变大后,再导入到ht1。这样循环扩大存储空间 String+Json 和 Hash 存储对比 类型 String+Json Hash 效率 很高 高 容量 低 低 灵活性 低 高 序列化 简单 复杂 应用场景 1、当某个对象需要频繁的更新的时候,不适合用string+json这个方法,因为每次的更新对需要对整个对象序列化复制。如果使用hash的话,就可以直接怼某个单独的字段修改,不需要修改整个对象。(例如:销量、评论数、关注量) 2、所以说,如果你存储的数据里面有很多个field(字段),并且呢还经常更新,那么你最好使用hash,可以更加灵活。反之用string+json合适例如优惠券 常用命令 命 令 说 明 备 注 hdel key field1[field2…] 删除 hash 结构中的某个(些)字段 可以进行多个字段的删除 hexists key field 判断 hash 结构中是否存在 field 字段 存在返回 1,否则返回 0 hgetall key 获取所有 hash 结构中的键值 返回键和值 hincrby key field increment 指定给 hash 结构中的某一字段加上一个整数 要求该字段也是整数字符串 hincrbyfloat key field increment 指定给 hash 结构中的某一字段加上一个浮点数 要求该字段是数字型字符串 hkeys key 返回 hash 中所有的键 —— hlen key 返回 hash 中键值对的数量 —— hmget key field1[field2…] 返回 hash 中指定的键的值,可以是多个 依次返回值 hmset key field1 value1 [field2 field2…] hash 结构设置多个键值对 —— hset key filed value 在 hash 结构中设置键值对 单个设值 hsetnx key field value 当 hash 结构中不存在对应的键,才设置值 —— hvals key 获取 hash 结构中所有的值 ——
相关链接 Redis 常用命令,思维导图:https://www.kdocs.cn/view/l/ss49IFenIOSm Redis 的 Set 是 String 类型的无序集合。集合成员是唯一的,这就意味着集合中不能出现重复的数据。 Redis 中集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1) Set 数据类型的特点: 数据不重复 元素没有下标 redis的set类型是使用哈希表构造的,因此复杂度是O(1),它支持集合内的增删改查,并且支持多个集合间的交集、并集、差集操作。可以利用这些集合操作,解决程序开发过程当中很多数据集合间的问题。 Set数据类型的内部编码有两种: Intset(整数集合):当集合元素个数小于set-max-ziplist-entries配置(默认512个),redis会使用intset作为集合的内部实现来减少内存的使用 Hashtable(哈希表):当集合类型无法满足intset的条件时,redis会使用hashtable作为集合的内部实现 ≤512 >512 整数 inset hashTable 字符串 hashTable hashTable 交集,并集,差集 底层实现 (1)intset编码 intset编码的集合对象底层实现是整数集合,所有元素都保存在整数集合中。 (2)hashtable编码 hashtable编码的集合对象底层实现是字典,字典的每个键都是一个字符串对象,保存一个集合元素,不同的是字典的值都是NULL;可以参考java中的hashset结构。 应用场景 用于存储好友/关注/粉丝/感兴趣的人集合,集合中的元素数量可能很多 常用命令 1.sadd(name,values) # name对应的集合中添加元素 2.scard(name) # 获取name对应的集合中元素个数 3.sdiff(keys, *args) # 在第一个name对应的集合中且不在其他name对应的集合的元素集合 4.sdiffstore(dest, keys, *args) # 获取第一个name对应的集合中且不在其他name对应的集合,再将其新加入到dest对应的集合中 5.sinter(keys, *args) # 获取多一个name对应集合的并集 6.sinterstore(dest, keys, *args) # 获取多一个name对应集合的并集,再讲其加入到dest对应的集合中 7.sismember(name, value) # 检查value是否是name对应的集合的成员 8.smembers(name) # 获取name对应的集合的所有成员 9.smove(src, dst, value) # 将某个成员从一个集合中移动到另外一个集合 10.spop(name) # 从集合的右侧(尾部)移除一个成员,并将其返回 11.srandmember(name, numbers) # 从name对应的集合中随机获取 numbers 个元素 12.srem(name, values) # 在name对应的集合中删除某些值 13.sunion(keys, *args) # 获取多一个name对应的集合的并集 14.sunionstore(dest,keys, *args) # 获取多一个name对应的集合的并集,并将结果保存到dest对应的集合中 sscan(name, cursor=0, match=None, count=None) sscan_iter(name, match=None, count=None) # 同字符串的操作,用于增量迭代分批获取元素,避免内存消耗太大
相关链接 Redis 常用命令,思维导图: https://www.kdocs.cn/view/l/ss49IFenIOSm zset`是`Redis`提供的一个非常特别的数据结构,常用作排行榜等功能,以用户`id`为`value`,关注时间或者分数作为`score`进行排序。与其他数据结构相似,`zset`也有两种不同的实现,分别是`zipList`和`skipList 底层实现 zset 存储示意图:每个 value 后面都携带一个分数,然后用作排序 跳表(skip List)是一种随机化的数据结构,基于并联的链表,实现简单,插入、删除、查找的复杂度均为O(logN)。简单说来跳表也是链表的一种,只不过它在链表的基础上增加了跳跃功能,正是这个跳跃的功能,使得在查找元素时,跳表能够提供O(logN)的时间复杂度。 看一个有序链表,如下图(最左侧的灰色节点表示一个空的头结点): 在这样一个链表中,如果我们要查找某个数据,那么需要从头开始逐个进行比较,直到找到包含数据的那个节点,或者找到第一个比给定数据大的节点为止(没找到)。也就是说,时间复杂度为O(n)。同样,当我们要插入新数据的时候,也要经历同样的查找过程,从而确定插入位置。 假如我们每相邻两个节点增加一个指针,让指针指向下下个节点,如下图: 这样所有新增加的指针连成了一个新的链表,但它包含的节点个数只有原来的一半。现在当我们想查找数据的时候,可以先沿着这个新链表进行查找。当碰到比待查数据大的节点时,再回到原来的链表中进行查找。比如,我们想查找23,查找的路径是沿着下图中标红的指针所指向的方向进行的: 23首先和7比较,再和19比较,比它们都大,继续向后比较。 但23和26比较的时候,比26要小,因此回到下面的链表(原链表),与22比较。 23比22要大,沿下面的指针继续向后和26比较。23比26小,说明待查数据23在原链表中不存在,而且它的插入位置应该在22和26之间。 将压缩链表和跳跃链表作一个对比吧 List Set Zset 对比 数据结果 是否允许重复元素 是否有序 有序实现方式 应用场景 列表 List 是 是 索引下标 时间轴、消息队列 集合 Set 否 否 无 标签、社交 有序集合 Zset 否 是 分值 排行榜,社交 应用场景 & 学习总结 Redis zset 和 set 一样也是string类型元素的集合,且不允许重复的成员。 不同的是每个元素都会关联一个double类型的分数。redis正是通过分数来为集合中的成员进行从小到大的排序。 zset的成员是唯一的,但分数(score)却可以重复。 使用场景: 1、根据时间排序的新闻列表等, 2、 阅读排行榜 常用命令 zadd key score member[{score member}...] --- 创建或设置指定key对应的有序集合,根据每个值对应的score来排名,升序。例如有命令 zadd key1 10 A 20 B 30 D 40 C;那么真实排名是 A B D C zrem key member --- 删除指定key对应的集合中的member元素 zcard key --- 返回指定key对应的有序集合的元素数量 zincrby key increment member --- 将指定key对应的集合中的member元素对应的分值递增加 increment zcount key min max --- 返回指定key对应的有序集合中,分值在min~max之间的元素个数 zrank key member --- 返回指定key对应的有序集合中,指定元素member在集合中排名,从0开始切分值是从小到大升序 zscore key member --- 返回指定key中的集合中指定member元素对应的分值 zrange key min max [withscores] --- 返回指定key对应的有序集合中,索引在min~max之间的元素信息,如果带上 withscores 属性的话,可以将分值也带出来 zrevrank key member --- 返回指定key对应的集合中,指定member在其中的排名,注意排名从0开始且按照分值从大到小降序 zrevrange key start end [withscores] --- 指定key对应的集合中,分值在 start~end之间的降序,加上 withscores 的话可以将分值以及value都显示出来 zrangebyscore key start end [withscores] --- 同 zrange命令不同的是,zrange命令是索引在startend范围的查询,而zrangebyscore命令是根据分值在startend之间的查询且升序展示 zrevrangebyscore key max min [withscores] --- 同zrangebyscore命令不同的是,zrangebyscores是根据分值从小到大升序展示,而zrevrangebyscore命令是从max到min降序展示 zremrangebyrank key start end --- 移除指定key对应集合中索引在start~end之间(包括start和end本身)的元素 zremrangebyscore by min max --- 同zremrangebyrank命令类似,不同的该命令是删除分值在min~max之间的元素 zinterstore desk-key key-count key... --- 获取指定数量的key的交集。例如有 key1:{10:A,20:B,30:C},key2{40:B,50:C,60:D},那么命令 zinterstore key3 2 key1 key2 意思就是 将key1 key2这两个集合的交集 赋给key3,如何获取key1与key2的交集呢。 key1中存在 A B C,key2中存在 B C D,那么交集就是 B 和 C,且 B与C对应的score也会叠加,即 key3{B:20+40=60,C:30+50=80} zunionstore desk-key key-count key... --- 获取指定数量key的并集,例如有 key1{10:A,20:B,30:C},key2{40:B,50:C,60:D},可以看出 A和D不是key1与key2共有的,但是并集中只要存在就会记录进去,然后B与C是共有的,即 并集的结果就是 key3{10:A,B:60,D:60,C:80}
相关链接 Redis 常用命令,思维导图 :https://www.kdocs.cn/view/l/ss49IFenIOSm List 是一个字符串链表 Left、right都可插入元素 如果,key不存在,创建链表 如果,key存在,链表添加内容 如果,链表值全部移除,key也就消失了 效率分析 链表的头尾元素操作,效率都非常高 链表中间元素操作,效率比较低 List 底层实现 链表是一种常用的数据结构,C 语言内部是没有内置这种数据结构的实现,所以Redis自己构建了链表的实现 链表的定义 typedef struct listNode{ //前置节点 struct listNode *prev; //后置节点 struct listNode *next; //节点的值 void *value; }listNode 通过多个 listNode 结构就可以组成链表,这是一个双向链表,Redis还提供了操作链表的数据结构 typedef struct list{ //表头节点 listNode *head; //表尾节点 listNode *tail; //链表所包含的节点数量 unsigned long len; //节点值复制函数 void (*free) (void *ptr); //节点值释放函数 void (*free) (void *ptr); //节点值对比函数 int (*match) (void *ptr,void *key); }list; 在版本3.2之前,Redis 列表list使用两种数据结构作为底层实现: 压缩列表ziplist 双向链表linkedlist 因为双向链表占用的内存比压缩列表要多, 所以当创建新的列表键时, 列表会优先考虑使用压缩列表, 并且在有需要的时候, 才从压缩列表实现转换到双向链表实现 zipList **压缩列表 ziplist 的原理:压缩列表并不是对数据利用某种算法进行压缩,**而是将数据按照一定规则编码在一块连续的内存区域,目的是节省内存 *当元素个数较少时,Redis 用 ziplist 来存储数据,当元素个数超过某个值时,链表键中会把 ziplist 转化为 linkedlist,字典键中会把 ziplist 转化为 hashtable。 由于内存是连续分配的,所以遍历速度很快。* 在3.2之后,*ziplist被quicklist替代*。但是仍然是zset底层实现之一 quickList就是一个标准的双向链表的配置,有head 有tail; 每一个节点是一个quicklistNode,包含prev和next指针。 每一个quicklistNode 包含 一个ziplist,*zp 压缩链表里存储键值。 所以quicklist是对ziplist进行一次封装,使用小块的ziplist来既保证了少使用内存,也保证了性能。 下图展示了压缩列表的各个组成部分 下表则记录了各个组成部分的类型、长度以及用途 连锁更新 使用压缩链表使, 放置的数据是有限制的, 自如字符个数要在64个以内, 但是如果出现这种情况, 连续节点放置的数据都是63个, 如果突然第一个节点的字符超过了64个需要扩展, 因为压缩列表使用的内存是连续的, 所以后面的节点也应该扩展 尽管连锁更新的复杂度较高,但它真正造成性能问题的几率是很低的: 首先,压缩列表里要恰好有多个连续的、长度介于250字节至253字节之间的节点,连锁 更新才有可能被引发,在实际中,这种情况并不多见 其次,即使出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影 响:比如说,对三五个节点进行连锁更新是绝对不会影响性能的 因为以上原因,ziplistPush等命令的平均复杂度仅为O(N),在实际中,我们可以放心 地使用这些函数,而不必担心连锁更新会影响压缩列表的性能 因为连锁更新在最坏情况下需要对压缩列表执行N次空间重分配操作,而每次空间重分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N^{2}) linkedlist 多个listNode可以通过prev和next指针组成双端链表,如下图所示: 从上面的结构可以看出,Redis的链表是一个带头尾节点的双向无环链表,并且通过len字段记录了链表节点的长度 list结构为链表提供了表头指针head、表尾指针tail,以及链表长度计数器len dup、 free和match成员则是用于实现多态链表所需的类型特定函数: dup函数用于复制链表节点所保存的值 free函数用于释放链表节点所保存的值 match函数则用于对比链表节点所保存的值和另一个输入值是否相等 总结Redis的链表实现的特性 双向:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都 是O(1) 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以 NULL为终点 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点 和表尾节点的复杂度为O(1) 带链表长度计数器:程序使用list结构的len属性来对list持有的链表节点进行计数,程序 获取链表中节点数量的复杂度为O(1) 多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、 match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值 总结 双向链表,每个数据都有头和尾。但是压缩列表,只有一个头和尾,然后吧所有的数据都放在了中间,所以使用的内存最小 压缩列表转化成双向链表条件 创建新列表时 redis 默认使用 redis_encoding_ziplist 编码, 当以下任意一个条件被满足时, 列表会被转换成 redis_encoding_linkedlist 编码: 试图往列表新添加一个字符串值,且这个字符串的长度超过 server.list_max_ziplist_value (默认值为 64 )。 ziplist 包含的节点超过 server.list_max_ziplist_entries (默认值为 512 )。 注意:这两个条件是可以修改的,在 redis.conf 中: // 保存的所有元素成员的长度都小于64字节 list-max-ziplist-value 64 // 保存的元素数量小于512个 list-max-ziplist-entries 512 Redis List 应用场景 1、消息队列 如下图所示,Redis的lpush + brpop命令组合即可实现阻塞队列,生产者使用 lpush 从列表左侧插入元素,多个消费者客户端使用 brpop 命令阻塞式的争抢列表尾部的元素,多个客户端保证了消费的负载均衡和高可用; 2、最新列表 list类型的lpush命令和lrange命令能实现最新列表的功能,每次通过lpush命令往列表里插入新的元素,然后通过lrange命令读取最新的元素列表,如朋友圈的点赞列表、评论列表。 但是,并不是所有的最新列表都能用list类型实现,因为对于频繁更新的列表,list类型的分页可能导致列表元素重复或漏掉, 举个例子,当前列表里由表头到表尾依次有(E,D,C,B,A)五个元素,每页获取3个元素,用户第一次获取到(E,D,C)三个元素,然后表头新增了一个元素F,列表变成了(F,E,D,C,B,A),此时用户取第二页拿到(C,B,A),元素C重复了。只有不需要分页(比如每次都只取列表的前5个元素)或者更新频率低(比如每天凌晨更新一次)的列表才适合用list类型实现。 对于需要分页并且会频繁更新的列表,需用使用有序集合sorted set类型实现。 另外,需要通过时间范围查找的最新列表,list类型也实现不了,也需要通过有序集合sorted set类型实现,如以成交时间范围作为条件来查询的订单列表 3、排行榜 list类型的lrange命令可以分页查看队列中的数据。可将每隔一段时间计算一次的排行榜存储在list类型中,如京东每日的手机销量排行、学校每次月考学生的成绩排名、斗鱼年终盛典主播排名等,每日计算一次,存储在list类型中,接口访问时,通过page和size分页获取排行榜。 但是,并不是所有的排行榜都能用list类型实现,只有定时计算的排行榜才适合使用list类型存储,与定时计算的排行榜相对应的是实时计算的排行榜,list类型不能支持实时计算的排行榜 操作命令 1、redis中list列表的数据插入命令:lpush,rpush,linsert 127.0.0.1:6379>rpush mylist 1 ---结果为:(integer) 1 127.0.0.1:6379>rpush mylist 2 ---结果为:(integer) 2 127.0.0.1:6379>rpush mylist 3 ---rpush命令:向mylist列表中,从右边插入3条数据,返回值为当前列表的容量。结果为:(integer) 3 127.0.0.1:6379>lrange mylist 0 -1 ---lrange命令:查看mylist列表中的数据,0开始位置,-1结束位置,结束位置为-1时,表示列表的最后一个位置,即查看所有。结果为:1> "1" 2> "2" 3> "3" 127.0.0.1:6379>lpush mylist 0 ---lpush命令:向mylist列表中,从左边插入一条数据为0的数据 127.0.0.1:6379>lrange mylist 0 -1 ---结果为:1>"0" 2>"1" 3>"2" 4>"3" 127.0.0.1:6379>linsert mylist after 3 4 ---linsert命令,表达式为linsert key before|after pivot value ;这句命令的意思是在key为mylist的列表中查找值为3的数据,在其后插入一条值为4的数据。 127.0.0.1:6379>lrange mylist 0 -1 ---结果为:1>"0" 2>"1" 3>"2" 4>"3" 5>"4" 127.0.0.1:6379>linsert mylist before 0 -1 ---意思是:在key为mylist的列表中查找值为0的数据,在其前插入一条值为-1的数据。 127.0.0.1:6379>lrange mylist 0 -1 ---结果为:1>"-1" 2>"0" 3>"1" 4>"2" 5>"3" 6>"4" 127.0.0.1:6379>lisert mylist after 5 8 ---结果为:-1,由于mylist列表不存在值为5的数据,所以不执行任何操作,返回状态值-1。如果key不存在时,返回错误提示。 127.0.0.1:6379>lrange mylist 0 -1 ---结果为:1>"-1" 2>"0" 3>"1" 4>"2" 5>"3" 6>"4" 2、redis中list列表的数据删除命令:lpop,rpop 127.0.0.1:6379>lpop mylist ---lpop命令:从列表中的左边移除一条数据,同时输出被删除的数据,这里输出的结果为-1 127.0.0.1:6379>lrange mylist 0 -1 ---结果为:1>"0" 2>"1" 3>"2" 4>"3" 5>"4" 127.0.0.1:6379>rpop mylist ---rpop命令:从列表的右边移除一条数据,同时输出被删除的数据,这里输出的结果为4 127.0.0.1:6379>lrange mylist 0 -1 ---结果为:1>"0" 2>"1" 3>"2" 4>"3" 127.0.0.1:6379>ltrim mylist 1 3 ----ltrim命令:保留设定的两个下标区间的值,删除不在其区间的所有值。1为开始保留的下标值,3为结束保留的下标值。 127.0.0.1:6379>lrange mylist 0 -1 ---结果为:1>"1" 2>"2" 3>"3" 3、redis中list列表的数据查看命令:lrange,llen,lindex 127.0.0.1:6379>llen mylist ---llen命令:返回列表的长度,这里mylist只剩下4条数据,故输出结果为4 127.0.0.1:6379>lindex mylist 3 ---lindex命令:获取给定位置的数据,这里坐标为3的数据是"2",所以结果为2. 4、redis中list列表数据修改命令:lset 127.0.0.1:6379>lset mylist 2 zlh ---lset命令:把下标为2的值设置为zlh,如果下标值超出范围或对一个空list列表进行lset,那么将返回一个错误提示 127.0.0.1:6379>lrange mylist 0 -1 ---结果为: 1>"1" 2>"2" 3>"zlh" 5、redis中list列表,两个列表A,B,将A列表的尾元素添加到B列表的头元素中,命令:rpoplpush #这里我有连个列表A数据为{1,2,3} ,B列表数据为{4,5,6} 127.0.0.1:6379>rpoplpush A B 127.0.0.1:6379>lrange A ---结果为:1>"1' 2>"2" 127.0.0.1:6379>lrange B ---结果为:1>"3' 2>"4" 3>"5" 4>"6" 6、redis中的几个带阻塞的高级命令:blpop,brpop,brpoplpush 127.0.0.1:6379>blpop A 30 ---意思是:A列表有值的话,从左边移除一个数据,如果没有值的话,则等待A中插入数据为止,等待时间为30秒,如果时间设置为0表示阻塞时间无限延长 127.0.0.1:6379>blpop B30 ---意思是:A列表有值的话,从左边移除一个数据,如果没有值的话,则等待A中插入数据为止,等待时间为30秒,如果时间设置为0表示阻塞时间无限延长 127.0.0.1:6379>brpoplpush A B 30 ---意思是:将A列表的尾元素添加到B列表的头元素中,如果A列表中有值则插入,如果没值,则等待A中插入数据为止,等待时间为30秒,如果时间设置为0表示阻塞时间无限延长
相关链接 Redis 常用命令,思维导图:https://www.kdocs.cn/view/l/ss49IFenIOSm SDS数据格式:https://redisbook.readthedocs.io/en/latest/internal-datastruct/sds.html String string类型在redis中是最常见的类型,value存储最大数据量为512M,可以存放json数据,图像数据等等 String 底层实现 Redis中,默认以SDS作为自己的字符串表示。只有在一些字符串不可能出现变化的地方使用C字符串。 SDS源码定义如下: struct sdshdr{ // 用于记录buf数组中使用的字节的数目 // 和SDS存储的字符串的长度相等 int len; // 用于记录buf数组中没有使用的字节的数目 int free; // 字节数组,用于储存字符串 char buf[]; //buf的大小等于len+free+1,其中多余的1个字节是用来存储’\0’的(‘\0’ 在c语言中占用一个字节的内存空间,也代表结束)。 }; SDS除了用来保存数据库中的字符串之外,SDS还被用作缓冲区(buffer),如AOF模块中的AOF缓冲区,以及客户端状态中的输入缓冲区 SDS 的存储示例: 使用SDS而不使用c语言的string的好处: 1、常数复杂度获取字符串长度: SDS:只需要访问SDS的len属性就能得到字符串的长度,复杂度为O(1)。 2、杜绝缓冲区溢出: Redis是C语言编写的,并没有方便的数据类型来进行内存的分配和释放(C++ STL String),必须手动进行内存分配和释放。 对于字符串的拼接、复制等操作,C语言开发者必须确保目标字符串的空间足够大,不然就会出现溢出的情况。 当使用SDS的API对字符串进行修改的时候, API内部第一步会检测字符串的大小是否满足。 如果空间已经满足要求,那么就像C语言一样操作即可。如果不满足,则拓展buf的空间 之后再进行操作。每次操作之后,len和free的值会做相应的修改。 扩展buf空间策略: 修改之后总长度len<1MB: 总空间为2*len+1; 修改之后总长度len>=1MB: 总空间为len+1MB+1。 换句话说,预分配的空间上限是1MB,尽量为len。 3、减少修改字符串时带来的内存重分配次数 当执行字符串长度缩短的操作的时候,SDS并不直接重新分配多出来的字节,而是修改len和free的值(len相应减小,free相应增大,buf的空间大小不变化),避免内存重分配。 SDS也提供直接释放未使用空间的API,在需要的时候,也能真正的释放掉多余的空间。 4、二进制安全 C字符串除了末尾之外不能出现空字符,否则会被程序认为是字符串的结尾。这就使得C字符串只能存储文本数据,而不能保存图像,音频等二进制数据 使用SDS就不需要依赖控制符,而是用len来指定存储数据的大小,所有的SDS API都会以处理二进制的方式来处理SDS的buf的数据。程序不会对buf的数据做任何限制、过滤或假设,数据写入的时候是什么,读取的时候依然不变。 总结 C字符串 SDS 获取字符串长度的复杂度为O(N) 获取字符串长度的复杂度为O(1) API是不安全的,可能会造成缓冲区溢出 API是安全的,不会造成缓冲区溢出 修改字符串长度N次必然需要执行N次内存重分配 修改字符串长度N次最多需要执行N次内存重分配 只能保存文本数据 可以保存文本或者二进制数据 可以使用所有库中的函数 可以使用一部分库的函数 String应用场景 Session + Redis 实现Session共享 做计数器,计算文档浏览量。INCR article:readcount:{文章id} GET article:readcount:{文章id} 分布式锁 线程1:SETNX product:10001 true//返回1代表锁获取成功 线程2:SETNX product:10001 true//返回0代表所获取失败 为什么不用hash存储呢? 如果想获取一个对象数据(user1)的name,那么需要把user1的所有数据拿出,在单独获取name;如果用STRING格式,那么只需要取出user:1:name数据即可。 常用命令的使用 # redis数据写命令Set,相当于数据插入 127.0.0.1:6379> set name zlh # 返回值: ok,说明插入成功。如果当前name存在值则覆盖替换原有的value值。 # redis数据读命令Get,获取数据 127.0.0.1:6379> get name # 返回值: "zlh" ,如果当前key没有value值,则返回null # redis数据追加命令Append,追加数据 127.0.0.1:6379> append name ' is my friend' # 返回值:"zlh is my friend" ,如果当前key的value有值则附加到原有string后面,如果没有则写入。 # redis数据删除 127.0.0.1:6379>del name # redis数据读写操作命令GetSet,获取原有value值的同时写入新的value值 127.0.0.1:6379> getset name zlh # 返回值:"zlh is my friend",这里返回的是原有name的value值,同时又给name的value设置新值zlh。 # 此时name的值真实值为zlh 127.0.0.1:6379> get name # 返回值:zlh ,因为上面getset给name的value设置值为zlh。 # redis数据加法计算命令incr,incrby, # 数据加法运算,incr为+1内置运算,incrby为+n自设n运算 127.0.0.1:6379> incr name # -返回值:“数据不是整型或数据超出64位有符号整形数据范围” ,由于原有name的value为"zlh",所以不能转换为整型,故报异常。 127.0.0.1:6379> incr age # # 返回值:1,由于不存在age的key与value值,但是默认age为key值为0进行+1运算。 127.0.0.1:6379> incr age # -返回值:2,由于上一行代码给age赋值为1,这里incr命令进行+1运算,故返回值为2。 127.0.0.1:6379> incrby age 10 # -返回值:12 ,因为原有age是2,这里+10故为12。 # redis数据减法计算命令decr,decrby, # 数据减法运算,decr为-1内置运算,decrby为减n自设n运算 127.0.0.1:6379> decr name # 返回值:“数据不是整型或数据超出64位有符号整形数据范围” ,由于原有name的value为"zlh",所以不能转换为整型,故报异常。 127.0.0.1:6379> decr age # 返回自:11,因为原有age的value值为12,这里decr是自减1的意思,故为11。 127.0.0.1:6379> decrby age 10 # 返回值:1,因为原有age为11,这里-10,故为1。 # redis获取string长度的命令strlen 127.0.0.1:6379> strlen name # 返回值:3,因为name的value值为zlh,故长度为3,如果该key或者value不存在,则返回0。 # redis设置value值并设置过期时间命令setex(单位秒) 127.0.0.1:6379> setex sex 20 male # -返回值:ok,设置key为sex的value值为male,缓存的过期时间为20s。 127.0.0.1:6379> ddl sex # -返回值: 剩余过期时间,0为已过期,-1为永不过期。 127.0.0.1:6379> get sex # -返回值:male,说明此时为没有过期,当已经过期此处返回数据为null。 # redis赋值判断原值是否存在,存在不赋值,返回0;不存在才赋值,返回1;命令setnx 127.0.0.1:6379> setnx name Tom # -返回值:0,因为name的原有value为zlh,存在值则不赋值。 127.0.0.1:6379> gete name # -返回值:zlh,因为有值,故上面赋值为tom失败,返回0。 127.0.0.1:6379> setnx phone 18501733702 # -返回值:1,赋值成功,因为原来不存在phone的key与value。 127.0.0.1:6379> get phone # -返回值:18501733702,说明上面的setnx赋值成功。 # redis字符串替换赋值,从指定位置开始替换,命令setrange 127.0.0.1:6379> setrange phone 9 123 # 返回值:12,12为字符串长度,11位号码变成了12位。因为从第9位开始替换,替换到原有字符串的最后一位还没有替换完毕,所以在最后面添加啦一位设置为3。 127.0.0.1:6379> get phone # -返回值为:185017337123 127.0.0.1:6379> set phone 1 # -这里为了下面说下setrange的其他特性,把电话设置为1. 127.0.0.1:6379> setrange phone 3 aaa # -返回值为:6,因为原有phone的value值为1,不到三位,用0*00替换2位,所以要添加2为才能替换第三位后面的值为aaa。 127.0.0.1:6379> get phone # -返回值:1\*00\*00aaa。理解下上面的操作就知道这里为什么是这样的返回值了 # redis截取字符串,从下标为n开始截取到n或n+1,类似c#中的substring,命令getrange 127.0.0.1:6379> set phone 18511112222 # 方便下面操作 127.0.0.1:6379> getrange phone 1 5 # -返回值:85017,因为getrange是从下标为1开始截取截取到下标为5,这里包含下标为5的值。 127.0.0.1:6379> getrange phone 0 0 # -返回值:1,从下标为0开始截取,截取到下标为0,包含下标为0。故返回值为1。 127.0.0.1:6379> getrange phone 10 13 # 返回值为:1,此号码下标直到10的11位号码,从10开始截取,截到第13位,后2位不存在忽略,只返回第10位。故返回干净的1。 # redis批量操作修改及读取string数据, # 命令mget,批量读取,命令mset,批量赋值, # 命令msetnx,带事务性的赋值,发现有一个key已经存在,所有事务回归,不做赋值处理操作 127.0.0.1:6379> mset name zlh age 30 # -返回值:ok,这里设置了 key为name和age的value分别为zlh,30 127.0.0.1:6379> mget name zlh # 返回值:1>zlh 2>30 127.0.0.1:6379> msetnx name Jim address China # -返回值为:0,没有做任何修改,因为key(name)已存在。 127.0.0.1:6379> mget name address # -返回值:1>zlh 2>null # -这里看到adress空null,说明 mestnx 事物回归 127.0.0.1:6379> msetnx address China,hobbies sports # 返回值为:1,插入成功 127.0.0.1:6379> mget address hobbies # -返回值:1>China 2>sports
六种日志简介 慢查询日志 slow query log: 记录所有超过long_query_time时间的SQL语句, 二进制日志 binlog: 记录任何引起数据变化的操作,用于备份和还原。默认存放在数据目录中,在刷新和服务重启时会滚动二进制日志。 错误日志 errorlog: MySQL服务启动和关闭过程中的信息以及其它错误和警告信息。默认在数据目录下。 中继日志 relay log: 从主服务器的二进制文件中复制的事件,并保存为二进制文件,格式和二进制日志一样。 普通查询日志 general log: 用于记录select查询语句的日志。general_log、general_log_file 默认关闭,建议关闭。 事务日志: 保证事务一致性。重做日志(redo log)redo log在数据准备修改前写入缓存中的redo log中,然后才对缓存中的数据执行修改操作,而且保证在发出事务提交指令时,先向缓存中的redo log写入日志,写入完成后才执行提交动作。 回滚日志(undo log)提供回滚和多个行版本控制(MVCC) 日志小结: 重做日志(redo log)和回滚日志(undo log)与事务操作息息相关,二进制日志(binlog)也与事务操作有一定的关系 undo log和redo log记录物理日志不一样,它是逻辑日志。可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然,当update一条记录时,它记录一条对应相反的update记录。 另外,undo log也会产生redo log,因为undo log也要实现持久性保护。 日志详解 慢查询日志 慢查询日志相关参数: # 是否开启慢查询,1开启,0关闭 slow_query_log:1 # 5.6及以上版本日志路径。 # 可不设置该参数,系统则会默认给一个缺省的文件 host_name-slow.log # slow-query-log-file: # 慢查询阈值,当查询时间多于设定的阈值时,记录日志。 long_query_time : 5 # 未使用索引的查询也被记录到慢查询日志中(可选项) # log_queries_not_using_indexes:。 # 日志存储方式 # FILE 表示将日志存入文件,默认值是 FILE # TABLE 表示将日志存入数据库,日志会写入到 mysql.slow_log表 # 支持同时两种日志存储方式,如:log_output='FILE,TABLE' log_output:'TABLE' 参数配置 # 查看日志类型 mysql> show variables like '%log_output%'; +---------------+-------+ | Variable_name | Value | +---------------+-------+ | log_output | TABLE | +---------------+-------+ 1 row in set (0.00 sec) # 设置日志存入方式 set GLOBAL log_output = 'TABLE'; 查看慢日志文件 如果慢日志存储为File文件,可以使用 cat -n 查看错误日志。如果慢日志存储为Table表,则使用 select 查看表记录。 update `mp_tt_creative` set `image_md5` = 't1', `updated_at` = '1611023918' where (`image_ids` = 't2') and `image_md5` is null; # Time: 2021-01-19T02:41:59.595904Z # User@Host: RCMP[RCMP] @ [192.168.6.103] Id: 79024 # Query_time: 1.456396 Lock_time: 0.000028 Rows_sent: 0 Rows_examined: 3925992 SET timestamp=1611024119; select `image_md5` from `mp_tt_creative` where (`image_ids` = 't2') and `image_md5` is not null limit 1; /usr/local/mysql/bin/mysqld, Version: 5.7.11-log (Source distribution). started with: Tcp port: 3306 Unix socket: /tmp/mysql.sock Time Id Command Argument 通过对以上错误日志的查看,我们发现此处由于程序员对数据库采用循环操作,导致产生了死锁现象。SQL执行时间是(Query_time): 1.456396 (默认超时是1s)。锁定时间是(Lock_time): 0.000028 。发送的行数(Rows_sent): 0 。检查的行数 (Rows_examined): 3925992 小妙招 推荐使用 mysqldumpslow 工具去查看慢日志文件 # 得到返回记录集最多的10个SQL。 mysqldumpslow -s r -t 10 /database/mysql/mysql06_slow.log # 得到访问次数最多的10个SQL mysqldumpslow -s c -t 10 /database/mysql/mysql06_slow.log # 得到按照时间排序的前10条里面含有左连接的查询语句。 mysqldumpslow -s t -t 10 -g “left join” /database/mysql/mysql06_slow.log # 另外建议在使用这些命令时结合 | 和more 使用 ,否则有可能出现刷屏的情况。 mysqldumpslow -s r -t 20 /mysqldata/mysql/mysql06-slow.log | more 使用 pt-query-digest 工具进行分析 mysqldumpslow 是 mysql 安装后就自带的工具,用于分析慢查询日志,但是pt-query-digest却不是mysql自带的,如果想使用 pt-query-digest 进行慢查询日志的分析,则需要自己安装 pt-query-digest。pt-query-digest工具相较于mysqldumpslow功能多一点。 小结: 日志记录到系统的专用日志表中,要比记录到文件耗费更多的系统资源,因此对于需要启用慢查询日志,又需要能够获得更高的系统性能,那么建议优先记录到文件。 二进制日志 配置参数 # # mysql5.7必须加,否则mysql服务启动报错 server-id = 1 # # 路径及命名,默认在data下 log-bin = /data/3306/tmp/binlog/mysql-bin # 过期时间,二进制文件自动删除的天数,0代表不删除 expire_logs_days = 10 # # 单个日志文件大小 max_binlog_size = 100M 参数查看 # 查看二进制日志设置 mysql> show variables like 'log_bin%'; +---------------------------------+---------------------------------------+ | Variable_name | Value | +---------------------------------+---------------------------------------+ | log_bin | ON | | log_bin_basename | /data/3306/tmp/binlog/mysql-bin | | log_bin_index | /data/3306/tmp/binlog/mysql-bin.index | | log_bin_trust_function_creators | OFF | | log_bin_use_v1_row_events | OFF | +---------------------------------+---------------------------------------+ # 查看当前服务器所有的二进制日志文件 mysql> show binary logs; +------------------+-----------+ | Log_name | File_size | +------------------+-----------+ | mysql-bin.000001 | 732 | +------------------+-----------+ # 查看当前二进制日志的状态 mysql> show master status; +------------------+----------+--------------+------------------+-------------------+ | File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set | +------------------+----------+--------------+------------------+-------------------+ | mysql-bin.000001 | 732 | | | | +------------------+----------+--------------+------------------+-------------------+ # 查看 bin log 日志 mysql> show binlog events; #只查看第一个binlog文件的内容 mysql> show binlog events in 'mysql-bin.000002';#查看指定binlog文件的内容 # \G 代表返回结果旋转90度查看(要不然阅读不方便) mysql> show binlog events \G; *************************** 1. row *************************** Log_name: mysql-bin.000006 Pos: 4 Event_type: Format_desc Server_id: 1 End_log_pos: 123 Info: Server ver: 5.7.30-log, Binlog ver: 4 *************************** 2. row *************************** Log_name: mysql-bin.000006 Pos: 123 Event_type: Previous_gtids Server_id: 1 End_log_pos: 154 Info: 二进制日志的三种模式 二进制日志三种格式:STATEMENT,ROW,MIXED,由参数 binlog_format 控制 STATEMENT模式(SBR) 每一条会修改数据的sql语句会记录到binlog中。 优点:并不需要记录每一条sql语句和每一行的数据变化,减少了binlog日志量,节约IO,提高性能。 缺点:在某些情况(如非确定函数)下会导致master-slave中的数据不一致 < 如sleep()函数, last_insert_id(),以及user-defined functions(udf)等会出现问题 > ROW模式(RBR) 不记录每条sql语句的上下文信息,仅需记录哪条数据被修改了,修改成什么样了。而且不会出现某些特定情况下的存储过程、或function、或trigger的调用和触发无法被正确复制的问题。 缺点:会产生大量的日志,尤其是alter table的时候会让日志暴涨。 MIXED模式(MBR) 以上两种模式的混合使用,一般的复制使用STATEMENT模式保存binlog,对于STATEMENT模式无法复制的操作使用ROW模式保存binlog,MySQL会根据执行的SQL语句选择日志保存方式 二进制日志恢复数据库 如果开启了二进制日志,出现了数据丢失,可以通过二进制日志恢复数据库,语法如下 mysqlbinlog [option] filename | mysql -u user -p passwd option 的参数主要有两个 --start-datetime 、 --stop-datetime 和 start-position 、 --stop-position 前者指定恢复的时间点,后者指定恢复的位置。原理就是把记录的语句重新执行了一次,如果恢复了两次。会产生重复数据。 mysqlbinlog --start-position="291" --stop-position="439" /data/3306/tmp/binlog/mysql-bin.000001 | mysql -uroot -p123456 注意:要找到插入更新的语句所在的时间点或位置。如果恢复的语句包含只有 delete,会报错1032错误。 错误日志 相关配置 在 MySQL 数据库中,默认开启错误日志功能。一般情况下,错误日志存储在 MySQL 数据库的数据文件夹下,通常名称为 hostname.err。其中,hostname 表示 MySQL 服务器的主机名。 在 MySQL 配置文件中,错误日志所记录的信息可以通过 log-error 和 log-warnings 来定义,其中,log-err 定义是否启用错误日志功能和错误日志的存储位置,log-warnings 定义是否将警告信息也记录到错误日志中。 将 log_error 选项加入到 MySQL 配置文件的 [mysqld]组中,形式如下: [mysqld] log-error=dir/{filename} 其中,dir 参数指定错误日志的存储路径;filename 参数指定错误日志的文件名;省略参数时文件名默认为主机名,存放在 Data 目录中。 注意:错误日志中记录的并非全是错误信息,例如 MySQL 如何启动 InnoDB 的表空间文件、如何初始化自己的存储引擎等,这些也记录在错误日志文件中。 #查看当前的错误日志配置,缺省情况下位于数据目录 mysql> show variables like 'log_error'; +---------------+------------------------------+ | Variable_name | Value | +---------------+------------------------------+ | log_error | /home/data/var/ruidao104.err | +---------------+------------------------------+ 1 row in set (0.00 sec) # MySQL 中,可以使用 mysqladmin 命令来开启新的错误日志 mysqladmin -uroot -p flush-logs 通过tail查看错误日志,得到信息如下: 可以看到,获取的包已超过最大限制值,连接被中断 [root@ruidao104 var]# tail -3 ruidao104.err 2021-03-03T02:37:16.012565Z 417174 [Note] Aborted connection 417174 to db: 'RCMP' user: 'RCMP' host: '192.168.6.103' (Got a packet bigger than 'max_allowed_packet' bytes) 2021-03-03T03:45:45.595497Z 417172 [Note] Aborted connection 417172 to db: 'RCMP' user: 'RCMP' host: '192.168.6.103' (Got a packet bigger than 'max_allowed_packet' bytes) 2021-03-03T04:20:15.629779Z 418987 [Note] Aborted connection 418987 to db: 'RCMP' user: 'RCMP' host: '192.168.6.103' (Got a packet bigger than 'max_allowed_packet' bytes) 中继日志 什么是中继日志??? 从服务器 I/O 线程将主服务器的二进制日志读取过来记录到从服务器本地文件,然后从服务器 SQL 线程会读取 relay-log 日志的内容并应用到从服务器,从而使从服务器和主服务器的数据保持一致 相关配置: # 查看中继日志配置信息 mysql> show variables like '%relay%'; +---------------------------+---------------------------------------------+ | Variable_name | Value | +---------------------------+---------------------------------------------+ | max_relay_log_size | 0 | | relay_log | | | relay_log_basename | /var/lib/mysql/c467506ed598-relay-bin | | relay_log_index | /var/lib/mysql/c467506ed598-relay-bin.index | | relay_log_info_file | relay-log.info | | relay_log_info_repository | FILE | | relay_log_purge | ON | | relay_log_recovery | OFF | | relay_log_space_limit | 0 | | sync_relay_log | 10000 | | sync_relay_log_info | 10000 | +---------------------------+---------------------------------------------+ 11 rows in set (0.79 sec) max_relay_log_size: relay log 允许的最大值,如果该值为0,则默认值为 max_binlog_size (1G) relay_log: 定义 relay_log 的位置和名称,如果值为空,则默认位置在数据文件的目录; relay_log_index: 定义 relay_log 索引的位置和名称,记录有几个 relay_log 文件,默认为2个 relay_log_info_file: 定义 relay-log.info 的位置和名称。relay-log.info 记录 master 主库的 binary_log 的恢复位置和 从库 relay_log 的位置; relay_log_recovery: 当slave从库宕机后,假如relay-log损坏了,导致一部分中继日志没有处理,则自动放弃所有未执行的relay-log,并且重新从master上获取日志,这样就保证了relay-log的完整性。默认情况下该功能是关闭的,将relay_log_recovery的值设置为 1时,可在slave从库上开启该功能,建议开启。 relay_log_space_limit: 防止中继日志写满磁盘,这里设置中继日志最大限额。但此设置存在主库崩溃,从库中继日志不全的情况,不到万不得已不推荐使用 relay_log_purge: 是否自动清空中继日志,默认值为1(启用); **sync_relay_log: **当设置为1时,slave的I/O线程每次接收到master发送过来的binlog日志都要写入系统缓冲区,然后刷入relay log中继日志里,这样是最安全的,因为在崩溃的时候,你最多会丢失一个事务,但会造成磁盘的大量I/O。当设置为0时,并不是马上就刷入中继日志里,而是由操作系统决定何时来写入,虽然安全性降低了,但减少了大量的磁盘I/O操作。这个值默认是0,可动态修改。 sync_relay_log_info: 这个参数和 sync_relay_log 参数一样。 mysql主从原理图 主从简单说明: 从上图中,可以看出。mysql主服务器执行了一个 update 操作后,从库生成了两个线程,一个IO线程,一个SQL线程 。主库将 binlog发送给 从库 I/O 线程,从库 I/O 线程将 binlog 写入 relay log(中继日志)。从库的 SQL 线程,会读取 relay log 文件中的日志,并解析成具体操作执行,实现主从最终数据一致; 小结: 中继日志生效于mysql主从复制过程中的从服务器上。 它的任务就是,解析 binlog 日志,判断偏移量,根据偏移量对数据库做出DDL的操作,最终实现mysql的主从一致。 普通查询日志 开启 general log 将所有到达MySQL Server的SQL语句记录下来。 一般不会开启开功能,因为log的量会非常庞大。但个别情况下可能会临时的开一会儿general log以供排障使用。 相关参数一共有3:general_log、log_output、general_log_file show variables like 'general_log'; -- 查看日志是否开启 set global general_log=on; -- 开启日志功能 show variables like 'general_log_file'; -- 看看日志文件保存位置 set global general_log_file='tmp/general.lg'; -- 设置日志文件保存位置 show variables like 'log_output'; -- 看看日志输出类型 table或file set global log_output='table'; -- 设置输出类型为 table set global log_output='file'; -- 设置输出类型为file 事务日志 初步认识事务日志 InnoDB 事务日志一共分为两个,重做日志,回滚日志。redo log是重做日志,提供前滚操作。undo log是回滚日志,提供回滚操作。 undo log不是redo log的逆向过程,其实它们都算是用来恢复的日志: redo log通常是物理日志,记录的是数据页的物理修改,而不是某一行或某几行修改成怎样怎样,它用来恢复提交后的物理数据页(恢复数据页,且只能恢复到最后一次提交的位置)。 undo log 用来回滚行记录到某个版本。undo log一般是逻辑日志,根据每行记录进行记录(为了更好的理解 undo log 请学习一下mysql 的 MVCC)。 案例分析两个日志的区别 通过 update 伪代码进行演示 # 初始内容 id = 1 name = 阿里巴巴 # 执行语句,把阿里巴巴修改成百度 UPDATE user SET name = '百度' WHERE id = 1 undo 回滚日志记录信息如下: UPDATE user SET name = '阿里巴巴' WHERE id = 1 redo log 重做日志File记录信息如下: UPDATE user SET name = '百度' WHERE id = 1 另外,undo log也会产生redo log,因为undo log也要实现持久性保护 REDO LOG详解 为了更好的理解 redo log 请先学习:Mysql checkpoint 和 Linux fsync。checkpoint:https://www.cnblogs.com/lintong/p/4381578.html REDO LOG 和 BIN LOG的区别: redo log是属于innoDB层面,binlog属于MySQL Server层面的,这样在数据库用别的存储引擎时可以达到一致性的要求。 redo log是物理日志,记录该数据页更新的内容;binlog是逻辑日志,记录的是这个更新语句的原始逻辑 redo log是循环写,日志空间大小固定;binlog是追加写,是指一份写到一定大小的时候会更换下一个文件,不会覆盖。 binlog可以作为恢复数据使用,主从复制搭建,redo log作为异常宕机或者介质故障后的数据恢复使用。 REDO LOG 基本概念: redo log 包括两部分:一是内存中的日志缓冲(redo log buffer),该部分日志是易失性的;二是磁盘上的重做日志文件(redo log file),该部分日志是持久的 为了确保每次日志都能写入到事务日志文件中,在每次将log buffer中的日志写入日志文件的过程中都会调用一次操作系统的fsync操作(即fsync()系统调用)。 也就是说,从redo log buffer写日志到磁盘的redo log file中,过程如下(关系写入规则,可阅读 < mysql的 IO 流程 >): REDO LOG的日志块(log block) innodb存储引擎中,redo log以块为单位进行存储的,每个块占 512字节(byte),这称为 redo log block。所以不管是 log buffer 中还是 os buffer 中以及 redo log file on disk 中,都是这样以512字节的块存储的。 每个redo log block由3部分组成**:日志块头、日志块尾和日志主体**。其中日志块头占用12字节,日志块尾占用8字节,所以每个redo log block的日志主体部分只有512-12-8=492字节。 InnoDB事务恢复行为 在启动innodb的时候,不管上次是正常关闭还是异常关闭,总是会进行恢复操作。 因为redo log记录的是数据页的物理变化,因此恢复的时候速度比逻辑日志(如二进制日志)要快很多。而且,innodb自身也做了一定程度的优化,让恢复速度变得更快。 重启innodb时,checkpoint表示已经完整刷到磁盘上data page上的LSN,因此恢复时仅需要恢复从checkpoint开始的日志部分。例如,当数据库在上一次checkpoint的LSN为10000时宕机,且事务是已经提交过的状态。启动数据库时会检查磁盘中数据页的LSN,如果数据页的LSN小于日志中的LSN,则会从检查点开始恢复。 还有一种情况,在宕机前正处于checkpoint的刷盘过程,且数据页的刷盘进度超过了日志页的刷盘进度。这时候一宕机,数据页中记录的LSN就会大于日志页中的LSN,在重启的恢复过程中会检查到这一情况,这时超出日志进度的部分将不会重做,因为这本身就表示已经做过的事情,无需再重做。 另外,事务日志具有幂等性,所以多次操作得到同一结果的行为在日志中只记录一次。而二进制日志不具有幂等性,多次操作会全部记录下来,在恢复的时候会多次执行二进制日志中的记录,速度就慢得多。例如,某记录中id初始值为2,通过update将值设置为了3,后来又设置成了2,在事务日志中记录的将是无变化的页,根本无需恢复;而二进制会记录下两次update操作,恢复时也将执行这两次update操作,速度比事务日志恢复更慢。 UNDO LOG详解 为了更好的理解 undo log 请先学习:隔离机制,事务,mvcc。 隔离机制:https://blog.mailjob.net/posts/607672036.html。事务:https://blog.mailjob.net/posts/3043506726.html。MVCC:https://blog.mailjob.net/posts/1291031357.html。https://blog.mailjob.net/posts/2327774433.html 基本概念: undo log有两个作用:提供回滚和多个行版本控制(MVCC)。 在数据修改的时候,不仅记录了redo,还记录了相对应的undo,如果因为某些原因导致事务失败或回滚了,可以借助该undo进行回滚。 undo log和redo log记录物理日志不一样,它是逻辑日志。可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然,当update一条记录时,它记录一条对应相反的update记录。 当执行rollback时,就可以从 undo log 中的逻辑记录读取到相应的内容并进行回滚。 有时候应用到行版本控制的时候,也是通过 undo log 来实现的:当读取的某一行被其他事务锁定时,它可以从undo log 中分析出该行记录以前的数据是什么,从而提供该行版本信息,让用户实现非锁定一致性读取。 另外,undo log 也会产生 redo log,因为 undo log 也要实现持久性保护。 UNDO LOG存储方式 innodb存储引擎对undo的管理采用段的方式。rollback segment称为回滚段,每个回滚段中有1024个undo log segment。 在以前老版本,只支持1个rollback segment,这样就只能记录1024个undo log segment。后来MySQL5.5可以支持128个rollback segment,即支持128*1024个undo操作,还可以通过变量 innodb_undo_logs (5.6版本以前该变量是 innodb_rollback_segments )自定义多少个rollback segment,默认值为128。 undo log默认存放在共享表空间中。 [root@xuexi data]# ll /mydata/data/ib* -rw-rw---- 1 mysql mysql 79691776 Mar 31 01:42 /mydata/data/ibdata1 -rw-rw---- 1 mysql mysql 50331648 Mar 31 01:42 /mydata/data/ib_logfile0 -rw-rw---- 1 mysql mysql 50331648 Mar 31 01:42 /mydata/data/ib_logfile1 如果开启了 innodb_file_per_table ,将放在每个表的.ibd文件中。 在MySQL5.6中,undo的存放位置还可以通过变量 innodb_undo_directory 来自定义存放目录,默认值为"."表示datadir。 默认rollback segment全部写在一个文件中,但可以通过设置变量 innodb_undo_tablespaces 平均分配到多少个文件中。该变量默认值为0,即全部写入一个表空间文件。该变量为静态变量,只能在数据库示例停止状态下修改,如写入配置文件或启动时带上对应参数。但是innodb存储引擎在启动过程中提示,不建议修改为非0的值,如下: 2017-03-31 13:16:00 7f665bfab720 InnoDB: Expected to open 3 undo tablespaces but was able 2017-03-31 13:16:00 7f665bfab720 InnoDB: to find only 0 undo tablespaces. 2017-03-31 13:16:00 7f665bfab720 InnoDB: Set the innodb_undo_tablespaces parameter to the 2017-03-31 13:16:00 7f665bfab720 InnoDB: correct value and retry. Suggested value is 0 DELETE/UPDATE操作的内部机制 当事务提交的时候,innodb 不会立即删除undo log,因为后续还可能会用到 undo log,如隔离级别为RR时,事务读取的都是开启事务时的最新提交行版本,只要该事务不结束,该行版本就不能删除,即 undo log 不能删除。 但是在事务提交的时候,会将该事务对应的 undo log 放入到删除列表中,未来通过purge来删除。并且提交事务时,还会判断 undo log 分配的页是否可以重用,如果可以重用,则会分配给后面来的事务,避免为每个独立的事务分配独立的undo log页而浪费存储空间和性能。 通过undo log记录delete和update操作的结果发现:(insert操作无需分析,就是插入行而已) delete操作实际上不会直接删除,而是将delete对象打上delete flag,标记为删除,最终的删除操作是purge线程完成的。 update分为两种情况:update的列是否是主键列。 如果不是主键列,在undo log中直接反向记录是如何update的。即update是直接进行的。 如果是主键列,update分两部执行:先删除该行,再插入一行目标行。 总结 BinLog和事务日志的先后顺序及group commit 为了提高性能,通常会将有关联性的多个数据修改操作放在一个事务中,这样可以避免对每个修改操作都执行完整的持久化操作。这种方式,可以看作是人为的组提交(group commit)。 除了将多个操作组合在一个事务中,记录binlog的操作也可以按组的思想进行优化:将多个事务涉及到的binlog一次性flush,而不是每次flush一个binlog。 事务在提交的时候不仅会记录事务日志,还会记录二进制日志,但是它们谁先记录呢?二进制日志是MySQL的上层日志,先于存储引擎的事务日志被写入。 在MySQL5.6以前,当事务提交(即发出commit指令)后,MySQL接收到该信号进入commit prepare阶段;进入prepare阶段后,立即写内存中的二进制日志,写完内存中的二进制日志后就相当于确定了commit操作;然后开始写内存中的事务日志;最后将二进制日志和事务日志刷盘,它们如何刷盘,分别由变量sync_binlog 和 innodb_flush_log_at_trx_commit 控制。 但因为要保证二进制日志和事务日志的一致性,在提交后的prepare阶段会启用一个prepare_commit_mutex锁来保证它们的顺序性和一致性。但这样会导致开启二进制日志后group commmit失效,特别是在主从复制结构中,几乎都会开启二进制日志。 在MySQL5.6中进行了改进。提交事务时,在存储引擎层的上一层结构中会将事务按序放入一个队列,队列中的第一个事务称为leader,其他事务称为follower,leader控制着follower的行为。虽然顺序还是一样先刷二进制,再刷事务日志,但是机制完全改变了:删除了原来的prepare_commit_mutex行为,也能保证即使开启了二进制日志,group commit也是有效的。 MySQL5.6中分为3个步骤**:flush阶段、sync阶段、commit阶段。** flush阶段: 向内存中写入每个事务的二进制日志。 sync阶段: 将内存中的二进制日志刷盘。若队列中有多个事务,那么仅一次fsync操作就完成了二进制日志的刷盘操作。这在MySQL5.6中称为BLGC(binary log group commit)。 commit阶段: leader根据顺序调用存储引擎层事务的提交,由于innodb本就支持group commit,所以解决了因为锁 prepare_commit_mutex 而导致的group commit失效问题。 在flush阶段写入二进制日志到内存中,但是不是写完就进入sync阶段的,而是要等待一定的时间,多积累几个事务的binlog一起进入sync阶段,等待时间由变量 binlog_max_flush_queue_time 决定,默认值为0表示不等待直接进入sync,设置该变量为一个大于0的值的好处是group中的事务多了,性能会好一些,但是这样会导致事务的响应时间变慢,所以建议不要修改该变量的值,除非事务量非常多并且不断的在写入和更新。 进入到sync阶段,会将binlog从内存中刷入到磁盘,刷入的数量和单独的二进制日志刷盘一样,由变量 sync_binlog 控制。 当有一组事务在进行commit阶段时,其他新事务可以进行flush阶段,它们本就不会相互阻塞,所以group commit会不断生效。当然,group commit的性能和队列中的事务数量有关,如果每次队列中只有1个事务,那么group commit和单独的commit没什么区别,当队列中事务越来越多时,即提交事务越多越快时,group commit的效果越明显。 参考资料 binlog日志:https://www.cnblogs.com/f-ck-need-u/p/9001061.html#blog5 事务日志:https://www.cnblogs.com/f-ck-need-u/archive/2018/05/08/9010872.html mysql的IO流程:https://blog.mailjob.net/posts/694055385.html
前言 该文章提供两种重置的方法,一种是在插件市场安装插件无限重置,另外一种是下载zip的包然后手动安装此插件,然后进行无限重置。 使用说明 两种重置方法 1、插件市场安装 在Settings/Preferences... -> Plugins 内手动添加第三方插件仓库地址:https://plugins.zhile.io 搜索:IDE Eval Reset插件进行安装。如果搜索不到请注意是否做好了上一步?网络是否通畅? 插件会提示安装成功。 2、下载安装 点击这个链接(v2.3.5)下载插件的zip包(macOS可能会自动解压,然后把zip包丢进回收站) 通常可以直接把zip包拖进IDE的窗口来进行插件的安装。如果无法拖动安装,你可以在Settings/Preferences... -> Plugins 里手动安装插件(Install Plugin From Disk...) 插件会提示安装成功。 如何使用该插件 一般来说,在IDE窗口切出去或切回来时(窗口失去/得到焦点)会触发事件,检测是否长时间(25天)没有重置,给通知让你选择。(初次安装因为无法获取上次重置时间,会直接给予提示) 也可以手动唤出插件的主界面: 如果IDE没有打开项目,在Welcome界面点击菜单:Get Help -> Eval Reset 如果IDE打开了项目,点击菜单:Help -> Eval Reset 唤出的插件主界面中包含了一些显示信息,2个按钮,1个勾选项: 按钮:Reload 用来刷新界面上的显示信息。 按钮:Reset 点击会询问是否重置试用信息并重启IDE。选择Yes则执行重置操作并重启IDE生效,选择No则什么也不做。(此为手动重置方式) 勾选项:Auto reset before per restart 如果勾选了,则自勾选后每次重启/退出IDE时会自动重置试用信息,你无需做额外的事情。(此为自动重置方式) 如果你的IDE已经过了试用打不开,可以运行这个临时重置脚本(注意选择对应系统版本),它可以让你暂时进入IDE进行上述操作 其他说明 新试用机制 最新的IDE试用需要登录,我们可以任选以下方式中的一种来继续使用重置插件: 使用网络上热心大佬收集总结的key,进入IDE后使用重置插件。 登录账号试用IDE,安装设置好本插件,退出登录账号重启IDE即可。 先安装旧版本IDE,安装设置好本插件,升级IDE到最新版本即可。 不管哪种方法原理都是为了让你进入IDE,以便重置插件接管试用。 参考资料 本文转载自:https://zhile.io/2020/11/18/jetbrains-eval-reset-da33a93d.html jetbrains密钥下载1:https://www.ajihuo.com/ jetbrains密钥下载2:https://mp.weixin.qq.com/s/g5iP_Z3eumlYPHXEA__OmA
数据库系统能够接受 SQL 语句,并返回数据查询的结果,或者对数据库中的数据进行修改,可以说几乎每个程序员都使用过它。所以,解析一下 MySQL 编译并执行 SQL 语句的过程,一方面能帮助你加深对数据库领域的编译技术的理解。另一方面,由于 SQL 是一种最成功的 DSL(特定领域语言),所以理解了 MySQL 编译器的内部运作机制,也能加深你对所有使用数据操作类 DSL 的理解,比如文档数据库的查询语言。另外,解读 SQL与它的运行时的关系,也有助于你在自己的领域成功地使用 DSL 技术。 一条SELECT执行流程 连接层 主要负责用户登录数据库,进行用户的身份认证,包括校验账户密码,权限等操作。如果用户账户密码已通过,连接器会到权限表中查询该用户的所有权限,之后在这个连接里的权限逻辑判断都是会依赖此时读取到的权限数据,也就是说,后续只要这个连接不断开,即时管理员修改了该用户的权限,该用户也是不受影响的。 账户验证 权限分配 线程分配 如果线程满了,则处于线程等待状态 执行结果返回后,该线程不会立刻销毁,而是会将该线程保存起来(线程创建和销毁都牵扯到CPU上下文的切换,会有很大的开销)。如果有别的连接则可以复用该连接,如果长时间没有则会将该线程销毁 Server层 主要包括连接器、查询缓存、分析器、优化器、执行器等,所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图,函数等,还有一个通用的日志模块 binglog日志模块。 缓存层 按照查询语句作为key查找缓存(如果查到直接返回,如果未查到则进入分析器) 分析器 词法分析,一条SQL语句有多个字符串组成,首先要提取关键字,比如select,提出查询的表,提出字段名,提出查询条件等等。 语法分析,主要就是判断你输入的sql是否正确,是否符合mysql的语法。 优化器 根据检索条件(索引,表连接 等等),生成执行计划 选择成本最低的执行计划,调用执行器 执行器 执行前会校验该用户有没有权限,如果没有权限,就会返回错误信息,如果有权限,就会去调用引擎的接口,返回接口执行的结果。 小帖士:Mysql8.0为什么删除缓存层 查询语句和查询结果以键值对的形式被直接缓存在内存中。因为是对SQL语句做hash计算,所以查找缓存的时候,SQL语句必须是字节级的匹配,完全一致的SQL才能命中缓存,也意味着在缓存中SELECT * FROM test和select * from test是不一样的。 一旦涉及到缓存,我们就要考虑缓存失效的问题,如果数据表存在数据(表数据,表结构以及索引)的变更,那么这张表的查询缓存就要被清除。这时候频繁的变更必定导致更大的系统开销。此时查询缓存也就要考虑使用的实际场景有哪些了。 最重要的还有一个大招,就是MySQL查询缓存有严重的可伸缩性问题,并且很容易成为严重的瓶颈。它不能与多核计算机上在高吞吐量工作负载情况下进行扩展。关于这个具体说明,可以参考一下MySQL官方人员的博客文章:https://dev.mysql.com/blog-archive/mysql-8-0-retiring-support-for-the-query-cache 引擎层 根据查询主键ID绑定的 page_id 找到对应数据页 将数据页内容写入到 BufferPool 根据二分查找法从 Page Directory 找到对应的数据槽 在对应数据槽沿着数据的单向链表查找到对应的数据 返回查询结果 一条UPDATE语句的历险记 CREATE TABLE `users` ( `user_id` int NOT NULL AUTO_INCREMENT, `name` varchar(50) COLLATE utf8mb4_general_ci NOT NULL, `sex` char(1) COLLATE utf8mb4_general_ci DEFAULT NULL, `hobby` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL, PRIMARY KEY (`user_id`), UNIQUE KEY `idx_name` (`name`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; INSERT INTO `test`.`users` (`user_id`, `name`, `sex`, `hobby`) VALUES (1, 'tom', '男', '唱歌'); INSERT INTO `test`.`users` (`user_id`, `name`, `sex`, `hobby`) VALUES (2, 'mike', '女', 'rap'); INSERT INTO `test`.`users` (`user_id`, `name`, `sex`, `hobby`) VALUES (3, 'tony', '男', '篮球'); 我们根据对 users 表的 name 字段建立的唯一索引,然后执行修改语句 UPDATE `users` SET `hobby` = '跳舞' WHERE `name` = 'make'; 然后我们分析一下这个事务的整体执行流程。在介绍 SELECT 的时候大体流程是相同的,所以这里我们只把重要的点找出来分析 Server层 分析器 根据二级索引生成对应的执行计划 根据主键索引生成对应的执行计划 很明显,如果采用二级扫描只需要回表一次就可以拿到数据。如果采用主键索引方案则需要进行全表扫描。所以第一种方案是最优方案 引擎层 根据二级索引B+Tree找到主键ID,然后从主键索引的B+Tree找到数据页的 page_id 将数据页写入buffer pool 从数据页中找到查询的数据 如果找到则判断更新的数据是否发生变化(没有变化则不更新) 将要修改的数据写入到 redo-log 回滚数据写入到 undo-log 事务进入 prepare 状态 Server层 执行器 判断事务是否处于 prepare 状态 将更新记录写入 bin-log 将事务提交,事务变成 commit 状态 总结 本文是对InnoDB执行流程一次简单的探索,为了便于读者理解,其中存在一些不完善的地方。例如: 分析器对于语句的分析报告 优化器如何根据索引计划计算成本 索引计划的计算成本因素是什么 从InnoDB页中取数据的规则 修改数据的时候如果加锁和加在哪里 如果是Insert语句,隐式锁升级过程 在不同隔离级别下,取出的数据为什么不相同 MVCC如何在不加锁的情况下保证Mysql的高效查询 是的,Mysql是一个技术栈非常丰富的软件。这里面有很多的疑问和问题,就算拿其中一个写,都需要很长的篇幅去表述。但是不怕,我们把这个庞大的mysql进行拆解,一步步去认识他。带着这些疑问,我们一起来探索Mysql新世界的大门。
一个简洁、安全、免费的静态网站评论系统,基于腾讯云开发 (opens new window)。A simple, safe, free comment system based on Tencent CloudBase (tcb) 相关链接 twikoo 官方文档:https://twikoo.js.org/quick-start.html Server 酱(消息发送服务器):http://sc.ftqq.com/3.version Qmsg 酱(消息接收):https://qmsg.zendee.cn 注意事项 函数代码 twikoo 官方文档部署文档。第7点,复制以下代码、粘贴到“函数代码”输入框中,点击“确定” exports.main = require('twikoo-func').main 这里的的粘贴,指的是吧原先的内容清空,然后粘贴进去 查看twikoo所有的评论可以去腾讯云 关于用户评论的通知 邮件通知 微信通知 QQ通知 可以在此处配置 登录的私钥在此处下载 其他评论插件推荐 gitalk gitalk评论插件是基于github的Issues开发 github 传送门: https://github.com/gitalk/gitalk/