<?xml version="1.0" encoding="UTF-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title>Blog Feed</title><link href="https://www.bytecho.net/feed.xml" rel="self" /><link href="https://www.bytecho.net" /><id>https://www.bytecho.net</id><updated>2026-09-04T10:30:00.000Z</updated><entry><title>基于 Debezium + Kafka 实现 CDC</title><link href="https://www.bytecho.net/archives/debezium_cdc.html" /><id>https://www.bytecho.net/archives/debezium_cdc.html</id><updated>2026-09-04T10:30:00.000Z</updated><summary>为了使得下游能够及时获取好友关系等上游状态，不必每次同步 RPC 查询，可以采用 CDC 对数据库变更进行捕获并下发至 Kafka 等中间件，供下游服务消费。</summary><content type="html">为了使得下游能够及时获取好友关系等上游状态，不必每次同步 RPC 查询，可以采用 CDC 对数据库变更进行捕获并下发至 Kafka 等中间件，供下游服务消费。**CDC 的核心无非是如下几点：**

* **数据库是关系状态的事实源**
* **Debezium 直接消费 MySQL binlog**
* Kafka 承载 CDC 事件
* 下游服务消费 Kafka 事件并更新本地 / Redis 分布式缓存
* 下游获取此类状态优先读取本地 / Redis 分布式缓存，避免频繁请求某个服务

相比业务层主动发送 Kafka Event，CDC 的优势：​**业务代码无需额外保证写 DB + 发事件的一致性**​。

本文采用 Docker 简易化部署 Debezium + Kafka。

## MySQL 前置条件

**Debezium MySQL CDC 依赖 binlog：**

```conf
[mysqld]
server-id=1
log_bin=mysql-bin
binlog_format=ROW
binlog_row_image=FULL
```

CDC 用户至少需要下列权限，这边已经提前创建好了用户 `debeziumuser`：

```sql
GRANT SELECT, RELOAD, SHOW DATABASES,
      REPLICATION SLAVE, REPLICATION CLIENT
ON *.*
TO &apos;debeziumuser&apos;@&apos;%&apos;;
```

## Kafka 部署及配置

下方的配置可以简化，因为我还有 K8s 的业务，本文中并未涉及，可忽略。

Kafka 监听只需关注容器内网络的配置即可：`PLAINTEXT://:19092 -&gt; PLAINTEXT://kafka:19092`

```sh
docker run -d \
  --name kafka \
  --network docker-net \
  --hostname kafka \
  --restart unless-stopped \
  -p 9092:9092 \
  -p 29092:29092 \
  -v kafka-data:/var/lib/kafka/data \
  -v kafka-secrets:/etc/kafka/secrets \
  -v kafka-config:/mnt/shared/config \
  -e KAFKA_NODE_ID=0 \
  -e KAFKA_PROCESS_ROLES=broker,controller \
  -e KAFKA_LISTENERS=&apos;CONTROLLER://:9093,PLAINTEXT://:19092,PLAINTEXT_HOST://:9092,K8S://:29092&apos; \
  -e KAFKA_ADVERTISED_LISTENERS=&apos;PLAINTEXT://kafka:19092,PLAINTEXT_HOST://127.0.0.1:9092,K8S://host.docker.internal:29092&apos; \
  -e KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=&apos;CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT,K8S:PLAINTEXT&apos; \
  -e KAFKA_INTER_BROKER_LISTENER_NAME=PLAINTEXT \
  -e KAFKA_CONTROLLER_QUORUM_VOTERS=&apos;0@kafka:9093&apos; \
  -e KAFKA_CONTROLLER_LISTENER_NAMES=CONTROLLER \
  -e KAFKA_LOG_DIRS=/var/lib/kafka/data \
  -e KAFKA_LOG_RETENTION_MS=3600000 \
  -e KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 \
  -e KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1 \
  -e KAFKA_TRANSACTION_STATE_LOG_MIN_ISR=1 \
  -e KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS=0 \
  -e KAFKA_HEAP_OPTS=&apos;-Xmx512m -Xms256m&apos; \
  apache/kafka:4.3.0
```

## Debezium 核心配置

```sh
docker run -d \
  --name debezium-connect \
  --network docker-net \
  --hostname debezium-connect \
  --restart unless-stopped \
  -p 8083:8083 \
  -e BOOTSTRAP_SERVERS=&apos;kafka:19092&apos; \
  -e GROUP_ID=&apos;debezium-connect&apos; \
  -e CONFIG_STORAGE_TOPIC=&apos;debezium_connect_configs&apos; \
  -e OFFSET_STORAGE_TOPIC=&apos;debezium_connect_offsets&apos; \
  -e STATUS_STORAGE_TOPIC=&apos;debezium_connect_statuses&apos; \
  -e CONNECT_CONFIG_STORAGE_REPLICATION_FACTOR=1 \
  -e CONNECT_OFFSET_STORAGE_REPLICATION_FACTOR=1 \
  -e CONNECT_STATUS_STORAGE_REPLICATION_FACTOR=1 \
  -e HEAP_OPTS=&apos;-Xmx512m -Xms256m&apos; \
  quay.io/debezium/connect:3.6
```

启动后需要对 Debezium Connect 进行配置，这里先罗列一下常用配置接口：

| 操作               | 接口                                  |
| -------------------- | --------------------------------------- |
| 查看所有 Connector | `GET /connectors`                 |
| 创建 Connector     | `POST /connectors`                |
| 查看状态           | `GET /connectors/{name}/status`   |
| 查看配置           | `GET /connectors/{name}/config`   |
| 更新配置           | `PUT /connectors/{name}/config`   |
| 重启 Connector     | `POST /connectors/{name}/restart` |
| 删除 Connector     | `DELETE /connectors/{name}`       |

首先创建一个 connector：

```json
{
  &quot;name&quot;: &quot;interaction-cdc&quot;,
  &quot;config&quot;: {
      &quot;connector.class&quot;: &quot;io.debezium.connector.mysql.MySqlConnector&quot;, 
      &quot;database.hostname&quot;: &quot;mysql8&quot;, 
      &quot;database.port&quot;: &quot;3306&quot;, 
      &quot;database.user&quot;: &quot;debeziumuser&quot;, 
      &quot;database.password&quot;: &quot;debeziumpassword&quot;, 
      &quot;database.server.id&quot;: &quot;23333&quot;, 
      &quot;topic.prefix&quot;: &quot;cdc_test&quot;, 
      &quot;database.include.list&quot;: &quot;yuelaiengine&quot;, 
      &quot;schema.history.internal.kafka.bootstrap.servers&quot;: &quot;kafka:19092&quot;, 
      &quot;schema.history.internal.kafka.topic&quot;: &quot;schemahistory.fullfillment&quot;, 
      &quot;include.schema.changes&quot;: &quot;false&quot;,
      &quot;table.include.list&quot;:&quot;yuelaiengine.relationship&quot;
  }
}
```

这里面需要关注数据库和 Kafka 的连接信息，此外 `name` 和 `database.server.id` 要注意每个 connector 需要不同。

关键配置：

- `topic.prefix`：决定了 CDC 下发到 Kafka 的 Topic 前缀；
- `include.schema.changes`：是否捕获数据库 schema 的变化；
- `database.include.list`：捕获哪些数据库的变更；
- `table.include.list`：捕获对应数据库中哪些表的变更（需要加上数据库前缀）。

添加完成后记得查询一下状态：

```json
{
    &quot;name&quot;: &quot;interaction-cdc&quot;,
    &quot;connector&quot;: {
        &quot;state&quot;: &quot;RUNNING&quot;,
        &quot;worker_id&quot;: &quot;192.168.107.3:8083&quot;,
        &quot;version&quot;: &quot;3.6.2.Final&quot;
    },
    &quot;tasks&quot;: [
        {
            &quot;id&quot;: 0,
            &quot;state&quot;: &quot;RUNNING&quot;,
            &quot;worker_id&quot;: &quot;192.168.107.3:8083&quot;,
            &quot;version&quot;: &quot;3.6.2.Final&quot;
        }
    ],
    &quot;type&quot;: &quot;source&quot;
}
```

## 消费 CDC 事件

上述配置完成之后，一旦对应数据在 MySQL 中发生变更，就会及时被 Debezium 捕获并下发 Kafa 事件。后续就应该解析事件结构并进行消费。

### Kafka 事件结构

Kafka Connect JSON Converter 开启 schema 时，消息大致如下：

```json
{
  &quot;schema&quot;: {},
  &quot;payload&quot;: {
    &quot;before&quot;: {},
    &quot;after&quot;: {},
    &quot;op&quot;: &quot;u&quot;
  }
}
```

常见 `op`：

```
r = snapshot read
c = create
u = update
d = delete
```

根据这个基本结构，我们可以在 Golang 工程 Infra 层中进行简单封装：

```go
/*
 * @Author: Henry csthenry@foxmail.com
 * @Date: 2026-09-03 15:12:41
 * @LastEditors: Henry csthenry@foxmail.com
 * @LastEditTime: 2026-09-03 15:12:43
 * @FilePath: /engine/internal/infra/cdc/debezium/model.go
 * @Description:
 *
 * Copyright (c) 2026 by Henry email: csthenry@foxmail.com, All Rights Reserved.
 */
package debezium

type Operation string

const (
	OperationRead   Operation = &quot;r&quot;
	OperationCreate Operation = &quot;c&quot;
	OperationUpdate Operation = &quot;u&quot;
	OperationDelete Operation = &quot;d&quot;
)

type Message[T any] struct {
	Payload *Payload[T] `json:&quot;payload&quot;`
}

type Payload[T any] struct {
	Before    *T        `json:&quot;before&quot;`
	After     *T        `json:&quot;after&quot;`
	Operation Operation `json:&quot;op&quot;`
}
```

这里我忽略掉了基本用不上的 `schema` 字段，下面是支持泛型的 JSON 反序列化，这样不同业务域可以用同一套事件结构。

```go
/*
 * @Author: Henry csthenry@foxmail.com
 * @Date: 2026-09-03 16:01:41
 * @LastEditors: Henry csthenry@foxmail.com
 * @LastEditTime: 2026-09-03 16:16:09
 * @FilePath: /engine/internal/infra/cdc/debezium/decode.go
 * @Description:
 *
 * Copyright (c) 2026 by Henry email: csthenry@foxmail.com, All Rights Reserved.
 */
package debezium

import (
	&quot;encoding/json&quot;
	&quot;errors&quot;
	&quot;fmt&quot;
)

var ErrTombstone = errors.New(&quot;debezium tombstone&quot;)

// Decode 解析 debezium kafka 消息 payload
func Decode[T any](data []byte) (*Payload[T], error) {
	if len(data) == 0 {
		return nil, ErrTombstone
	}

	var message Message[T]
	if err := json.Unmarshal(data, &amp;message); err != nil {
		return nil, fmt.Errorf(&quot;decode debezium message failed: %w&quot;, err)
	}

	return message.Payload, nil
}
```

&gt; https://developer.aliyun.com/ask/584158
&gt; 在 MySQL 的 Binlog 中，删除操作不会直接包含被删除的数据，而是包含一个表示删除操作的事件。当 CDC 工具捕获到这个删除事件时，它通常会生成一个 `tombstone` 消息，这是一个特殊的空消息或者包含极少信息的消息，用于指示某个键值对已被删除。

**这里注意要特殊处理一下 `Debezium Tombstone`，这类情况需要单独处理或忽略。**

### 消费 Kafka

具体消费的 Code 在这里就不罗列了，无非就是调用 Kafka 的 SDK，然后按照上述代码对具体业务的事件进行解析，并管理好 Consumer Goroutine 的生命周期即可。

这里说明一下该 CDC 工具生成的 Topic 格式：

```
&lt;topic.prefix&gt;.&lt;database&gt;.&lt;table&gt;
```

直接消费该 Topic 中的消息即可，对于 Consumer GroupID，这里有几种情况：

- 第一种是每个下游实例是本地维护缓存，这种情况下，每个实例的 Consumer GroupID 应该保证不同，分别消费一次；
- 第二种则是使用的 Redis 等分布式缓存，这种情况下则将各实例消费者设置为相同的 GroupID，避免重复消费。

***

**YuelaiGroup 字节星球**

</content></entry><entry><title>gRPC Resolver 实现 Nacos 服务发现</title><link href="https://www.bytecho.net/archives/grpc_nacos_subscribe.html" /><id>https://www.bytecho.net/archives/grpc_nacos_subscribe.html</id><updated>2026-08-10T08:07:01.284Z</updated><summary>Nacos SDK 提供了 Subscribe 接口，借助 gRPC manual.Resolver 可以轻松实现健康实例的实时推送，在此 mark 一下。</summary><content type="html">## 自行实现

Nacos SDK 提供了 Subscribe 接口，借助 gRPC `manual.Resolver` 可以实现健康实例的实时推送，在此 mark 一下。

基本流程是通过 gRPC 逻辑地址 `nacos:///group/service` 中的 `&lt;schema&gt;:///`，对 `manual.NewBuilderWithScheme(scheme string)` Resolver Schema 进行匹配。

因为 ` grpc.WithResolvers(rs ...resolver.Builder)` 是可以注册多个 resolver 的，当然也就需要进行区分。

此外可以发现上述 gRPC 逻辑地址中，`nacos:///` 后面的 endpoint 其实并没有什么用，这里只是为了可读性。

```go
/*
 * @Author: Henry csthenry@foxmail.com
 * @Date: 2026-08-10 14:14:29
 * @LastEditors: Henry csthenry@foxmail.com
 * @LastEditTime: 2026-08-10 15:53:07
 * @FilePath: /engine/internal/infra/nacos/subscriber.go
 * @Description:
 *
 * Copyright (c) 2026 by Henry email: csthenry@foxmail.com, All Rights Reserved.
 */
package nacos

import (
	&quot;fmt&quot;
	&quot;net&quot;
	&quot;strconv&quot;
	&quot;engine/internal/global&quot;

	&quot;github.com/nacos-group/nacos-sdk-go/v2/model&quot;
	&quot;github.com/nacos-group/nacos-sdk-go/v2/vo&quot;
	&quot;go.uber.org/zap&quot;
	&quot;google.golang.org/grpc&quot;
	&quot;google.golang.org/grpc/credentials/insecure&quot;
	&quot;google.golang.org/grpc/resolver&quot;
	&quot;google.golang.org/grpc/resolver/manual&quot;
)

// NacosRPCSubscriber 含 Nacos 订阅能力的 GRPC Client
type NacosRPCSubscriber struct {
	Conn     *grpc.ClientConn
	Resolver *manual.Resolver
}

// buildGRPCAddrs 将 Nacos 实例转换为 GRPC 地址
func buildGRPCAddrs(insts []*Instance) []resolver.Address {
	addrs := make([]resolver.Address, 0, len(insts))

	for _, inst := range insts {
		if !inst.Enable || !inst.Healthy || inst.Weight &lt;= 0 {
			continue
		}
		addr := resolver.Address{
			Addr: net.JoinHostPort(inst.Ip, strconv.FormatUint(inst.Port, 10)),
		}
		addrs = append(addrs, addr)
	}
	return addrs
}

// NewNacosRPC 创建支持 Nacos 动态服务发现的 GRPC Client
func NewNacosRPC(client *NacosClient, serviceName, groupName string) (*NacosRPCSubscriber, error) {
	if client == nil {
		return nil, fmt.Errorf(&quot;unavailable nacos client&quot;)
	}
	// 获取所有健康实例
	insts, err := client.SelectInstances(serviceName, groupName, true)
	if len(insts) == 0 || err != nil {
		return nil, fmt.Errorf(&quot;no healthy nacos instance: service: %s group: %s&quot;,
			serviceName,
			groupName,
		)
	}

	// gRPC manual resolver
	r := manual.NewBuilderWithScheme(&quot;nacos&quot;)
	r.InitialState(resolver.State{
		Addresses: buildGRPCAddrs(insts),
	})

	// GRPC 逻辑地址 nacos:///group/service
	target := fmt.Sprintf(
		&quot;nacos:///%s/%s&quot;,
		groupName,
		serviceName,
	)

	conn, err := grpc.NewClient(
		target,
		grpc.WithResolvers(r),
		grpc.WithTransportCredentials(
			insecure.NewCredentials(),
		),
		// Use round_robin LB policy.
		grpc.WithDefaultServiceConfig(
			`{&quot;loadBalancingConfig&quot;:[{&quot;round_robin&quot;:{}}]}`,
		),
	)
	if err != nil {
		return nil, fmt.Errorf(&quot;create grpc client: %w&quot;, err)
	}

	// Nacos 订阅
	logger := global.MustLogger()
	err = client.namingClient.Subscribe(
		&amp;vo.SubscribeParam{
			ServiceName: serviceName,
			GroupName:   groupName,
			SubscribeCallback: func(instsRaw []model.Instance, subErr error) {
				if subErr != nil {
					logger.Error(&quot;[Nacos] subscribe error&quot;,
						zap.String(&quot;Service&quot;, serviceName),
						zap.String(&quot;Group&quot;, groupName),
						zap.Error(subErr))
				}

				// 获取新的订阅地址
				insts := make([]*Instance, 0, len(instsRaw))
				for _, inst := range instsRaw {
					insts = append(insts, (*Instance)(&amp;inst))
				}
				addrs := buildGRPCAddrs(insts)
				logger.Info(&quot;[Nacos] service changed&quot;,
					zap.String(&quot;Service&quot;, serviceName),
					zap.String(&quot;Group&quot;, groupName),
					zap.Any(&quot;Addrs&quot;, addrs),
				)

				// 更新 ClientConn 地址
				r.UpdateState(resolver.State{
					Addresses: addrs,
				})

			},
		},
	)

	if err != nil {
		_ = conn.Close()
		return nil, fmt.Errorf(&quot;subscribe nacos failed: %w&quot;, err)
	}
	return &amp;NacosRPCSubscriber{
		Conn:     conn,
		Resolver: r,
	}, nil
}
```

上述逻辑使用到了我重新封装的 Nacos Client：

```go
/*
 * @Author: Henry csthenry@foxmail.com
 * @Date: 2026-08-05 21:11:51
 * @LastEditors: Henry csthenry@foxmail.com
 * @LastEditTime: 2026-08-10 14:24:19
 * @FilePath: /engine/internal/infra/nacos/client.go
 * @Description:
 *
 * Copyright (c) 2026 by Henry email: csthenry@foxmail.com, All Rights Reserved.
 */
package nacos

import (
	&quot;fmt&quot;

	&quot;github.com/nacos-group/nacos-sdk-go/v2/clients&quot;
	&quot;github.com/nacos-group/nacos-sdk-go/v2/clients/config_client&quot;
	&quot;github.com/nacos-group/nacos-sdk-go/v2/clients/naming_client&quot;
	&quot;github.com/nacos-group/nacos-sdk-go/v2/common/constant&quot;
	&quot;github.com/nacos-group/nacos-sdk-go/v2/vo&quot;
)

type NacosClient struct {
	namingClient naming_client.INamingClient
	configClient config_client.IConfigClient
}

func NewNacosClient(clientCfg NacosClientConfig, serverCfgs []NacosServerConfig) (*NacosClient, error) {
	clientConfig := constant.NewClientConfig(
		constant.WithNamespaceId(clientCfg.NamespaceId),
		constant.WithUsername(clientCfg.Username),
		constant.WithPassword(clientCfg.Password),
		constant.WithLogDir(clientCfg.LogDir),
		constant.WithLogLevel(clientCfg.LogLevel),
	)
	serverConfigs := make([]constant.ServerConfig, 0, len(serverCfgs))
	for _, serverCfg := range serverCfgs {
		serverConfigs = append(serverConfigs, *constant.NewServerConfig(
			serverCfg.IpAddr,
			serverCfg.Port,
			constant.WithScheme(serverCfg.Scheme),
		))
	}

	naming, err := clients.NewNamingClient(
		vo.NacosClientParam{
			ClientConfig:  clientConfig,
			ServerConfigs: serverConfigs,
		},
	)

	if err != nil {
		return nil, fmt.Errorf(&quot;create nacos client failed, err: %w&quot;, err)
	}

	config, err := clients.NewConfigClient(
		vo.NacosClientParam{
			ClientConfig:  clientConfig,
			ServerConfigs: serverConfigs,
		},
	)

	if err != nil {
		return nil, fmt.Errorf(&quot;create nacos client failed, err: %w&quot;, err)
	}

	return &amp;NacosClient{
		namingClient: naming,
		configClient: config,
	}, nil
}

func (c *NacosClient) NamingClient() naming_client.INamingClient {
	return c.namingClient
}

func (c *NacosClient) ConfigClient() config_client.IConfigClient {
	return c.configClient
}

// Register 向 Nacos 注册实例
func (c *NacosClient) Register(ip string, port uint64, serviceName, groupName string, metadata map[string]string) error {
	success, err := c.namingClient.RegisterInstance(vo.RegisterInstanceParam{
		Ip:          ip,
		Port:        port,
		ServiceName: serviceName,
		GroupName:   groupName,
		Weight:      1,
		Enable:      true,
		Healthy:     true,
		Ephemeral:   true,
		Metadata:    metadata,
	})

	if !success || err != nil {
		return fmt.Errorf(&quot;register instance failed: %w&quot;, err)
	}
	return nil

}

// Deregister 向 Nacos 注销实例
func (c *NacosClient) Deregister(ip string, port uint64, serviceName, groupName string) error {
	success, err := c.namingClient.DeregisterInstance(vo.DeregisterInstanceParam{
		Ip:          ip,
		Port:        port,
		ServiceName: serviceName,
		GroupName:   groupName,
		Ephemeral:   true,
	})

	if !success || err != nil {
		return fmt.Errorf(&quot;deregister instance failed: %w&quot;, err)
	}
	return nil
}

// PublishConfig 向 Nacos 推送配置
func (c *NacosClient) PublishConfig(config []byte, dataId string, group string) error {
	success, err := c.configClient.PublishConfig(
		vo.ConfigParam{
			DataId:  dataId,
			Group:   group,
			Content: string(config),
			Type:    &quot;json&quot;,
		},
	)
	if !success || err != nil {
		return fmt.Errorf(&quot;publish instance config failed: %w&quot;, err)
	}
	return nil
}

// DeleteConfig 删除 Nacos 上的配置
func (c *NacosClient) DeleteConfig(dataId string, group string) error {
	success, err := c.configClient.DeleteConfig(
		vo.ConfigParam{
			DataId: dataId,
			Group:  group,
		},
	)
	if !success || err != nil {
		return fmt.Errorf(&quot;delete instance config failed: %w&quot;, err)
	}
	return nil
}

// SelectOneHealthyInstance 从 Nacos 获取一个健康实例
func (c *NacosClient) SelectOneHealthyInstance(serviceName, groupName string) (*Instance, error) {
	inst, err := c.namingClient.SelectOneHealthyInstance(
		vo.SelectOneHealthInstanceParam{
			ServiceName: serviceName,
			GroupName:   groupName,
		},
	)
	if err != nil {
		return nil, err
	}
	return (*Instance)(inst), nil
}

// SelectInstances 获取 Nacos 服务中的所有实例
func (c *NacosClient) SelectInstances(serviceName, groupName string, healthyOnly bool) ([]*Instance, error) {
	rawInsts, err := c.namingClient.SelectInstances(
		vo.SelectInstancesParam{
			ServiceName: serviceName,
			GroupName:   groupName,
			HealthyOnly: healthyOnly,
		},
	)
	if err != nil {
		return nil, err
	}

	insts := make([]*Instance, 0, len(rawInsts))
	for _, inst := range rawInsts {
		insts = append(insts, (*Instance)(&amp;inst))
	}

	return insts, nil
}
```

使用时十分简单，后续只需要管理一下 gRPC Conn 和 Nacos Client 的生命周期即可，某服务示例：

```go
grpcConn, err := nacos.NewNacosRPC(res.nacosClient, relationCfg.ServiceName, relationCfg.GroupName)
if err != nil {
	global.MustLogger().Error(&quot;[Nacos] 服务实例订阅失败&quot;, zap.Error(err))
}
relationRes, _ := initRelationResourceFromConn(grpcConn.Conn)
res.relationConn = relationRes.conn
res.relationClient = relationRes.client
global.MustLogger().Info(&quot;[Nacos] 服务实例订阅成功&quot;)
```

## 封装库

```go
package resolver

// Package resolver 实现基于 Nacos 的 gRPC 服务发现 Resolver。
// 通过 import 即可自动注册 &quot;nacos&quot; scheme，在 gRPC Dial 时使用 &quot;nacos:///服务名&quot; 的 target。

import (
	&quot;fmt&quot;
	&quot;net&quot;
	&quot;strconv&quot;

	&quot;github.com/nacos-group/nacos-sdk-go/v2/clients/naming_client&quot;
	&quot;github.com/nacos-group/nacos-sdk-go/v2/model&quot;
	&quot;github.com/nacos-group/nacos-sdk-go/v2/vo&quot;
	&quot;google.golang.org/grpc/resolver&quot;
)

// Option 可选的订阅参数配置
type Option func(*nacosResolverBuilder)

// WithGroupName 指定分组名，默认 &quot;DEFAULT_GROUP&quot;
func WithGroupName(group string) Option {
	return func(b *nacosResolverBuilder) {
		b.groupName = group
	}
}

// WithClusters 指定集群名，默认 &quot;DEFAULT&quot;
func WithClusters(clusters []string) Option {
	return func(b *nacosResolverBuilder) {
		b.clusters = clusters
	}
}

// Register 使用给定的 Nacos namingClient 注册 gRPC Resolver。
// 一般在 main 中调用一次即可，因为 resolver 是全局注册。
//
// 使用示例：
//
//	resolver.Register(namingClient, resolver.WithGroupName(&quot;MY_GROUP&quot;))
func Register(client naming_client.INamingClient, opts ...Option) {
	b := &amp;nacosResolverBuilder{
		client:    client,
		groupName: &quot;DEFAULT_GROUP&quot;,
		clusters:  []string{&quot;DEFAULT&quot;},
	}
	for _, o := range opts {
		o(b)
	}
	resolver.Register(b)
}

func NewNacosResolverBuilder(client naming_client.INamingClient, opts ...Option) resolver.Builder {
	b := &amp;nacosResolverBuilder{
		client:    client,
		groupName: &quot;DEFAULT_GROUP&quot;,
		clusters:  []string{&quot;DEFAULT&quot;},
	}
	for _, o := range opts {
		o(b)
	}
	return b
}

// nacosResolverBuilder 实现 resolver.Builder 接口
type nacosResolverBuilder struct {
	client    naming_client.INamingClient
	groupName string
	clusters  []string
}

// Scheme 返回 &quot;nacos&quot; 作为 gRPC target scheme
func (b *nacosResolverBuilder) Scheme() string {
	return &quot;nacos&quot;
}

// Build 根据 target 创建 resolver.Resolver
func (b *nacosResolverBuilder) Build(target resolver.Target, cc resolver.ClientConn,
	_ resolver.BuildOptions) (resolver.Resolver, error) {

	serviceName := target.Endpoint()
	if serviceName == &quot;&quot; {
		return nil, fmt.Errorf(&quot;nacos resolver: target.Endpoint() is empty, use nacos:///serviceName&quot;)
	}

	r := &amp;nacosResolver{
		cc:          cc,
		client:      b.client,
		serviceName: serviceName,
		groupName:   b.groupName,
		clusters:    b.clusters,
		stopCh:      make(chan struct{}),
	}
	r.start()
	return r, nil
}

// nacosResolver 实现 resolver.Resolver 接口
type nacosResolver struct {
	cc          resolver.ClientConn
	client      naming_client.INamingClient
	serviceName string
	groupName   string
	clusters    []string
	stopCh      chan struct{}
}

func (r *nacosResolver) start() {
	// 1. 初次获取：同步拉取健康实例
	r.fetch()

	// 2. 订阅变更：Nacos 推送实例上下线通知
	param := &amp;vo.SubscribeParam{
		ServiceName: r.serviceName,
		GroupName:   r.groupName,
		Clusters:    r.clusters,
		SubscribeCallback: func(instances []model.Instance, err error) {
			if err != nil {
				return
			}
			r.updateAddrs(instances)
		},
	}
	if err := r.client.Subscribe(param); err != nil {
		// 订阅失败不 panic，仅日志记录（初次已拉取到实例）
		return
	}
}

func (r *nacosResolver) fetch() {
	instances, err := r.client.SelectInstances(vo.SelectInstancesParam{
		ServiceName: r.serviceName,
		GroupName:   r.groupName,
		Clusters:    r.clusters,
		HealthyOnly: true,
	})
	if err != nil {
		r.cc.ReportError(err)
		return
	}
	r.updateAddrs(instances)
}

func (r *nacosResolver) updateAddrs(instances []model.Instance) {
	addrs := make([]resolver.Address, 0, len(instances))
	for _, ins := range instances {
		if !ins.Healthy || !ins.Enable {
			continue
		}
		addr := net.JoinHostPort(ins.Ip, strconv.FormatUint(ins.Port, 10))
		addrs = append(addrs, resolver.Address{Addr: addr})
	}
	// if len(addrs) == 0 {
	// 	return
	// }
	r.cc.UpdateState(resolver.State{Addresses: addrs})
}

// ResolveNow 实现 resolver.Resolver 接口，主动触发解析
func (r *nacosResolver) ResolveNow(_ resolver.ResolveNowOptions) {
	r.fetch()
}

// Close 实现 resolver.Resolver 接口，取消订阅
func (r *nacosResolver) Close() {
	select {
	case &lt;-r.stopCh:
	default:
		close(r.stopCh)
	}
	_ = r.client.Unsubscribe(&amp;vo.SubscribeParam{
		ServiceName: r.serviceName,
		GroupName:   r.groupName,
		Clusters:    r.clusters,
	})
}
```

</content></entry><entry><title>基于 TCC 写一个所谓的分布式事务#2</title><link href="https://www.bytecho.net/archives/tcc_golang_2.html" /><id>https://www.bytecho.net/archives/tcc_golang_2.html</id><updated>2026-08-04T10:35:00.000Z</updated><summary>之前不是拿分布式事务扯了两篇文章吗，详见浅谈分布式事务 #1 和 #2，今天就用上次说到的 TCC 来实现一个相对可用的分布式事务。本篇是业务侧实现部分。</summary><content type="html">[基于 TCC 写一个所谓的分布式事务#1](/archives/tcc_golang_1.html)

紧接上篇，实现 TCC 的使用方。

## 实现 TCC Component

首先需要我们需要定义一些状态，比如事务的状态，业务方的状态（此处只是作为模拟，不涉及具体逻辑），并定义最基本的 Component 结构。

### 状态定义

```go
// tcc 组件侧记录的一笔事务的状态
type TXStatus string

func (t TXStatus) String() string {
	return string(t)
}

const (
	TXTried     TXStatus = &quot;tried&quot;     // 已执行 try 操作
	TXConfirmed TXStatus = &quot;confirmed&quot; // 已执行 confirm 操作
	TXCanceled  TXStatus = &quot;canceled&quot;  // 已执行 cancel 操作
)

// 一笔事务对应数据的状态
type DataStatus string

func (d DataStatus) String() string {
	return string(d)
}

const (
	DataFrozen     DataStatus = &quot;frozen&quot;     // 冻结态
	DataSuccessful DataStatus = &quot;successful&quot; // 成功态
)
```

### 结构定义

首先实现一个简单的 TCC Component 结构，里面应该包含一个 `id`，还有一个 `client`，这里的 `client` 其实是一个 Redis Client，主要用于数据存储和分布式锁，`locker` 则是我们上一篇基于 Redis 实现的分布式锁。

```go
type MockComponent struct {
	id     string
	client redislib.UniversalClient
	locker dlock.Locker
}
```

```go
func NewMockComponent(id string, client redislib.UniversalClient, locker dlock.Locker) *MockComponent {
	return &amp;MockComponent{
		id:     id,
		client: client,
		locker: locker,
	}
}
```

下面按照顺序来分别实现 TCC Component 的基本方法。

### Try

`Try` 方法的流程之前已经介绍过，事务方调用 Component Try 时，首先会添加分布式锁，然后进行幂等校验，将资源冻结（等到后续 Confirm 后才最终落库提交），然后记录事务的状态。

```go
func (m *MockComponent) Try(ctx context.Context, req *gotcc.TCCReq) (*gotcc.TCCResp, error) {
	// 基于 txID 维度加锁，这里不涉及耗时操作，直接继承 locker 的配置即可
	mutex := m.locker.NewMutex(pkg.BuildTXLockKey(m.id, req.TXID))
	if err := mutex.Lock(ctx); err != nil {
		return nil, err
	}
	defer func() {
		unlockCtx, unlockCancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second)
		defer unlockCancel()
		_ = mutex.Unlock(unlockCtx)
	}()

	// 基于 txID 幂等性去重，注意这里需要特殊排除一下键不存在的情况
	txStatus, err := m.client.Get(ctx, pkg.BuildTXKey(m.id, req.TXID)).Result()
	if err != nil &amp;&amp; !errors.Is(err, redislib.Nil) {
		return nil, err
	}

	res := &amp;gotcc.TCCResp{
		ComponentID: m.id,
		TXID:        req.TXID,
	}
	switch txStatus {
	// 重复的 try 请求，给予成功的响应
	case TXTried.String(), TXConfirmed.String():
		res.ACK = true
		return res, nil
	// 先 cancel，后收到 try 请求，拒绝
	case TXCanceled.String():
		return res, nil
	default:
	}

	// 执行 try 操作，将数据状态置为 frozen
	bizID := gocast.ToString(req.Data[&quot;biz_id&quot;])
	// 存储 bizID 和事务的关系，Confirm/Cancel 后删除，这里没有设置 TTL
	if err := m.client.SetNX(ctx, pkg.BuildTXDetailKey(m.id, req.TXID), bizID, 0).Err(); err != nil {
		return nil, err
	}

	// 要求必须从零到一把 bizID 对应的数据置为冻结态
	created, err := m.client.SetNX(ctx, pkg.BuildDataKey(m.id, req.TXID, bizID), DataFrozen.String(), 0).Result()
	if err != nil {
		return nil, err
	}
	if !created {
		return res, nil
	}

	// 更新事务状态至 Tried
	if err := m.client.Set(ctx, pkg.BuildTXKey(m.id, req.TXID), TXTried.String(), 0).Err(); err != nil {
		return nil, fmt.Errorf(&quot;set transaction tried, txid=%s: %w&quot;, req.TXID, err)
	}

	// try 请求执行成功
	res.ACK = true

	// TEST ComponentB Try Failed
	// if strings.Contains(req.ComponentID, &quot;componentB&quot;) {
	// 	res.ACK = false
	// }

	return res, nil
}
```

### Confirm

Confirm 则是将冻结的业务数据推向终态，本方法由 TXManager 在所有 Components Try 均返回成功响应后调用。

```go
func (m *MockComponent) Confirm(ctx context.Context, txID string) (*gotcc.TCCResp, error) {
	// 基于 txID 维度加锁
	mutex := m.locker.NewMutex(pkg.BuildTXLockKey(m.id, txID))
	if err := mutex.Lock(ctx); err != nil {
		return nil, err
	}
	defer func() {
		unlockCtx, unlockCancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second)
		defer unlockCancel()
		_ = mutex.Unlock(unlockCtx)
	}()

	// 要求 txID 此前状态为 tried
	// 如果该键不存在也直接抛出错误
	txStatus, err := m.client.Get(ctx, pkg.BuildTXKey(m.id, txID)).Result()
	if err != nil {
		return nil, err
	}

	res := &amp;gotcc.TCCResp{
		ComponentID: m.id,
		TXID:        txID,
	}

	switch txStatus {
	// 已 confirm，直接幂等响应为成功
	case TXConfirmed.String():
		res.ACK = true
		return res, nil
	// 只有状态为 try 放行
	case TXTried.String():
	// 其他情况直接拒绝
	default:
		return res, nil
	}

	// 获取事务对应的 bizID
	bizID, err := m.client.Get(ctx, pkg.BuildTXDetailKey(m.id, txID)).Result()
	if err != nil {
		return nil, err
	}

	// 要求对应的数据状态此前为 frozen
	dataStatus, err := m.client.Get(ctx, pkg.BuildDataKey(m.id, txID, bizID)).Result()
	if err != nil {
		return nil, err
	}
	switch dataStatus {
	case DataFrozen.String():
		// 把对应数据处理状态置为 successful
		if err = m.client.Set(ctx, pkg.BuildDataKey(m.id, txID, bizID), DataSuccessful.String(), 0).Err(); err != nil {
			return nil, err
		}
	// 数据已经推进状态了，只需推进 TX 状态即可
	case DataSuccessful.String():
	// 状态非法
	default:
		return res, nil
	}

	// 把事务状态更新为成功
	if err := m.client.Set(
		ctx,
		pkg.BuildTXKey(m.id, txID),
		TXConfirmed.String(),
		0,
	).Err(); err != nil {
		return nil, fmt.Errorf(&quot;set transaction confirmed, txid=%s: %w&quot;, txID, err)
	}

	// 处理成功，给予成功的响应
	res.ACK = true
	return res, nil
}
```

### Cancel

和 Confirm 类似，这里是处理 TCC 事务失败的链路。在实际业务实现中这里应该对冻结的数据操作进行回滚。

```go
func (m *MockComponent) Cancel(ctx context.Context, txID string) (*gotcc.TCCResp, error) {
	// 基于 txID 维度加锁
	mutex := m.locker.NewMutex(pkg.BuildTXLockKey(m.id, txID))
	if err := mutex.Lock(ctx); err != nil {
		return nil, err
	}
	defer func() {
		unlockCtx, unlockCancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second)
		defer unlockCancel()
		_ = mutex.Unlock(unlockCtx)
	}()

	tccResp := &amp;gotcc.TCCResp{
		ACK:         true,
		ComponentID: m.id,
		TXID:        txID,
	}

	// 查看事务的状态，只要不是 confirmed，就无脑置为 canceled
	// 其次事务如果根本不存在，也应该抛出错误
	txStatus, err := m.client.Get(ctx, pkg.BuildTXKey(m.id, txID)).Result()
	if err != nil {
		return nil, err
	}
	switch txStatus {
	// 非法流转链路
	case TXConfirmed.String():
		return nil, fmt.Errorf(&quot;invalid tx status: %s, txid: %s&quot;, txStatus, txID)
	// 直接幂等返回
	case TXCanceled.String():
		return tccResp, nil
	// 进入正常 Cancel 链路
	case TXTried.String():
	default:
		return nil, fmt.Errorf(
			&quot;unexpected tx status: %s, txid: %s&quot;,
			txStatus,
			txID,
		)
	}

	// 根据事务获取对应的 bizID
	bizID, err := m.client.Get(ctx, pkg.BuildTXDetailKey(m.id, txID)).Result()
	if err != nil {
		return nil, err
	}

	// 删除对应的 frozen 冻结记录
	if err = m.client.Del(ctx, pkg.BuildDataKey(m.id, txID, bizID)).Err(); err != nil {
		return nil, err
	}

	// 把事务状态更新为 canceled
	if err := m.client.Set(
		ctx,
		pkg.BuildTXKey(m.id, txID),
		TXCanceled.String(),
		0,
	).Err(); err != nil {
		return nil, fmt.Errorf(&quot;set transaction canceled, txid=%s: %w&quot;, txID, err)
	}

	return tccResp, nil
}
```

想必大伙儿已经发现，在 `Try/Confirm/Cancel` 方法中，同样维护了一份事务状态，即：

```go
const (
	TXTried     TXStatus = &quot;tried&quot;     // 已执行 try 操作
	TXConfirmed TXStatus = &quot;confirmed&quot; // 已执行 confirm 操作
	TXCanceled  TXStatus = &quot;canceled&quot;  // 已执行 cancel 操作
)
```

看过之前 TCC 框架原理应该清楚，在 TXStore 中也维护了一份事务状态，那这个事务状态和 Component 中的有何不同？其实就是视角不同。

**TXStore 中维护的是全局视角下的事务状态，即由多个 Components 组成的分布式事务状态，而 Component 中维护的仅仅是当前实例下的事务状态（单机事务）。**

## 实现 TXStore

### 数据库及 DAO

这里我们简单设计一下数据库结构及其 DAO 实现，只需要记录分布式事务状态和各个 Components Try 状态即可，为了简单，Components Try 状态直接采用 JSON 存储：

```sql
CREATE TABLE IF NOT EXISTS `tx_record`
(
    `id`                       bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT &apos;主键ID&apos;,
    `status`                   varchar(16) NOT NULL COMMENT &apos;事务状态 hanging/successful/failure&apos;,
    `component_try_statuses`   json DEFAULT NULL COMMENT &apos;各组件 try 接口请求状态 hanging/successful/failure&apos;,
    `deleted_at`        datetime     DEFAULT NULL COMMENT &apos;删除时间&apos;,
    `created_at`        datetime     NOT NULL COMMENT &apos;创建时间&apos;,
    `updated_at`        datetime     DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT &apos;更新时间&apos;,
    PRIMARY KEY (`id`) USING BTREE COMMENT &apos;主键索引&apos;,
    KEY `idx_status` (`status`) COMMENT &apos;事务状态索引&apos;
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT &apos;事务日志记录&apos;;
```

```go
type QueryOption func(db *gorm.DB) *gorm.DB

func WithID(id uint) QueryOption {
	return func(db *gorm.DB) *gorm.DB {
		return db.Where(&quot;id = ?&quot;, id)
	}
}

func WithStatus(status gotcc.TXStatus) QueryOption {
	return func(db *gorm.DB) *gorm.DB {
		return db.Where(&quot;status = ?&quot;, status.String())
	}
}
```

```go
type TXRecordPO struct {
	gorm.Model
	Status               string `gorm:&quot;status&quot;`
	ComponentTryStatuses string `gorm:&quot;component_try_statuses&quot;`
}

func (t TXRecordPO) TableName() string {
	return &quot;tx_record&quot;
}

type ComponentTryStatus struct {
	ComponentID string `json:&quot;componentID&quot;`
	TryStatus   string `json:&quot;tryStatus&quot;`
}

type TXRecordDAO struct {
	db *gorm.DB
}

func NewTXRecordDAO(db *gorm.DB) *TXRecordDAO {
	return &amp;TXRecordDAO{
		db: db,
	}
}

func (t *TXRecordDAO) GetTXRecords(ctx context.Context, opts ...QueryOption) ([]*TXRecordPO, error) {
	db := t.db.WithContext(ctx).Model(&amp;TXRecordPO{})
	for _, opt := range opts {
		db = opt(db)
	}

	var records []*TXRecordPO
	return records, db.Scan(&amp;records).Error
}

func (t *TXRecordDAO) CreateTXRecord(ctx context.Context, record *TXRecordPO) (uint, error) {
	return record.ID, t.db.WithContext(ctx).Model(&amp;TXRecordPO{}).Create(record).Error
}

// UpdateComponentStatus 更新事务中的 Component 状态
func (t *TXRecordDAO) UpdateComponentStatus(ctx context.Context, id uint, componentID string, status string) error {
	return t.LockAndDo(ctx, id, func(ctx context.Context, dao *TXRecordDAO, record *TXRecordPO) error {
		var statuses map[string]*ComponentTryStatus
		if err := json.Unmarshal([]byte(record.ComponentTryStatuses), &amp;statuses); err != nil {
			return err
		}

		componentStatus, ok := statuses[componentID]
		if !ok {
			return fmt.Errorf(&quot;invalid component: %s in txid: %d&quot;, componentID, id)
		}
		if componentStatus.TryStatus == status {
			return nil
		}

		if componentStatus.TryStatus == gotcc.TryHanging.String() {
			componentStatus.TryStatus = status
			body, _ := json.Marshal(statuses)
			record.ComponentTryStatuses = string(body)
			return dao.UpdateTXRecord(ctx, record)
		}

		return fmt.Errorf(&quot;invalid status: %s of component: %s, txid: %d&quot;, statuses[componentID].TryStatus, componentID, id)
	})
}

func (t *TXRecordDAO) UpdateTXRecord(ctx context.Context, record *TXRecordPO) error {
	return t.db.WithContext(ctx).Updates(record).Error
}

// LockAndDo 加锁拿到对应 id 的事务记录，然后对其执行 do 函数
func (t *TXRecordDAO) LockAndDo(ctx context.Context, id uint, do func(ctx context.Context, dao *TXRecordDAO, record *TXRecordPO) error) error {
	return t.db.Transaction(func(tx *gorm.DB) error {
		// 加写锁
		var record TXRecordPO

		if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: &quot;UPDATE&quot;}).First(&amp;record, id).Error; err != nil {
			return err
		}

		txDAO := NewTXRecordDAO(tx)
		return do(ctx, txDAO, &amp;record)
	})
}
```

### 结构定义

TXStore 采用关系型数据库的方式记录事务状态，所以这里我们将之前定义的 DAO 写入结构体，此外同样需要分布式锁 `locker`，这里的分布式锁仅仅是为了避免 TXManager 的全局轮询被多实例同时执行：

```go
type TXRecordDAO interface {
	GetTXRecords(ctx context.Context, opts ...expdao.QueryOption) ([]*expdao.TXRecordPO, error)
	CreateTXRecord(ctx context.Context, record *expdao.TXRecordPO) (uint, error)
	UpdateComponentStatus(ctx context.Context, id uint, componentID string, status string) error
	UpdateTXRecord(ctx context.Context, record *expdao.TXRecordPO) error
	LockAndDo(ctx context.Context, id uint, do func(ctx context.Context, dao *expdao.TXRecordDAO, record *expdao.TXRecordPO) error) error
}
```

```go
type MockTXStore struct {
	locker dlock.Locker
	mutex dlock.Mutex
	dao    TXRecordDAO
}

func NewMockTXStore(dao TXRecordDAO, locker dlock.Locker) *MockTXStore {
	return &amp;MockTXStore{
		dao:    dao,
		locker: locker,
	}
}
```

### 事务操作

```go
// CreateTX 创建一条事务，并注入 TCC Components
func (m *MockTXStore) CreateTX(ctx context.Context, components ...gotcc.TCCComponent) (string, error) {
	// 创建一项内容，里面以唯一事务 id 为 key
	componentTryStatuses := make(map[string]*expdao.ComponentTryStatus, len(components))
	for _, component := range components {
		componentTryStatuses[component.ID()] = &amp;expdao.ComponentTryStatus{
			ComponentID: component.ID(),
			TryStatus:   gotcc.TryHanging.String(),
		}
	}

	// 记录每个 Component 的处理状态
	statusesBody, _ := json.Marshal(componentTryStatuses)
	txID, err := m.dao.CreateTXRecord(ctx, &amp;expdao.TXRecordPO{
		Status:               gotcc.TXHanging.String(),
		ComponentTryStatuses: string(statusesBody),
	})
	if err != nil {
		return &quot;&quot;, err
	}

	return gocast.ToString(txID), nil
}

// TXUpdate 在事务日志中更新 Component 状态
func (m *MockTXStore) TXUpdate(ctx context.Context, txID string, componentID string, accept bool) error {
	_txID := gocast.ToUint(txID)
	status := gotcc.TXFailure.String()
	if accept {
		status = gotcc.TXSuccessful.String()
	}
	return m.dao.UpdateComponentStatus(ctx, _txID, componentID, status)
}

// GetHangingTXs 轮询用，获取还在 hanging 状态的事务
func (m *MockTXStore) GetHangingTXs(ctx context.Context) ([]*gotcc.Transaction, error) {
	records, err := m.dao.GetTXRecords(ctx, expdao.WithStatus(gotcc.TXHanging))
	if err != nil {
		return nil, err
	}

	txs := make([]*gotcc.Transaction, 0, len(records))
	for _, record := range records {
		componentTryStatuses := make(map[string]*expdao.ComponentTryStatus)
		_ = json.Unmarshal([]byte(record.ComponentTryStatuses), &amp;componentTryStatuses)
		components := make([]*gotcc.ComponentTryEntity, 0, len(componentTryStatuses))
		for _, component := range componentTryStatuses {
			components = append(components, &amp;gotcc.ComponentTryEntity{
				ComponentID: component.ComponentID,
				TryStatus:   gotcc.ComponentTryStatus(component.TryStatus),
			})
		}

		txs = append(txs, &amp;gotcc.Transaction{
			TXID:       gocast.ToString(record.ID),
			Status:     gotcc.TXHanging,
			CreatedAt:  record.CreatedAt,
			Components: components,
		})
	}

	return txs, nil
}

// TXSubmit 提交事务的最终状态
func (m *MockTXStore) TXSubmit(ctx context.Context, txID string, success bool) error {
	do := func(ctx context.Context, dao *expdao.TXRecordDAO, record *expdao.TXRecordPO) error {
		if success {
			if record.Status == gotcc.TXFailure.String() {
				return fmt.Errorf(&quot;invalid tx status: %s, txid: %s&quot;, record.Status, txID)
			}
			record.Status = gotcc.TXSuccessful.String()
		} else {
			if record.Status == gotcc.TXSuccessful.String() {
				return fmt.Errorf(&quot;invalid tx status: %s, txid: %s&quot;, record.Status, txID)
			}
			record.Status = gotcc.TXFailure.String()
		}
		return dao.UpdateTXRecord(ctx, record)
	}
	return m.dao.LockAndDo(ctx, gocast.ToUint(txID), do)
}

// GetTX 获取指定的一笔事务
func (m *MockTXStore) GetTX(ctx context.Context, txID string) (*gotcc.Transaction, error) {
	records, err := m.dao.GetTXRecords(ctx, expdao.WithID(gocast.ToUint(txID)))
	if err != nil {
		return nil, err
	}
	if len(records) != 1 {
		return nil, errors.New(&quot;get tx failed&quot;)
	}

	componentTryStatuses := make(map[string]*expdao.ComponentTryStatus)
	_ = json.Unmarshal([]byte(records[0].ComponentTryStatuses), &amp;componentTryStatuses)

	components := make([]*gotcc.ComponentTryEntity, 0, len(componentTryStatuses))
	for _, tryItem := range componentTryStatuses {
		components = append(components, &amp;gotcc.ComponentTryEntity{
			ComponentID: tryItem.ComponentID,
			TryStatus:   gotcc.ComponentTryStatus(tryItem.TryStatus),
		})
	}
	return &amp;gotcc.Transaction{
		TXID:       txID,
		Status:     gotcc.TXStatus(records[0].Status),
		Components: components,
		CreatedAt:  records[0].CreatedAt,
	}, nil
}
```

### 实现全局锁

TXStore 全局分布式锁在 TXManager 轮询中使用。

```go
func (m *MockTXStore) Lock(ctx context.Context, expireDuration time.Duration) error {
	m.mutex = m.locker.NewMutex(pkg.BuildTXRecordLockKey(), dlock.WithExpiry(expireDuration))
	return m.mutex.Lock(ctx)
}

func (m *MockTXStore) Unlock(ctx context.Context) error {
	if m.mutex == nil {
		return errors.New(&quot;tx store mutex is not locked&quot;)
	}
	return m.mutex.Unlock(ctx)
}
```

## 测试运行

下列测试所有 Components Try 都会通过，如果需要测试失败的情况，可以修改一下 Try 方法，使得其中某个 Component Try 执行失败。

```go
const (
	dsn      = &quot;请输入你的 mysql dsn&quot;
	address  = &quot;请输入你的 redis ip&quot;
	password = &quot;请输入你的 redis 密码&quot;
)

func main() {
	redisClient := pkg.NewRedisClient(address, password)
	locker := dlock.NewRedisLocker(redisClient, dlock.DefaultConfig())


	mysqlDB, err := pkg.NewDB(dsn)
	if err != nil {
		fmt.Println(err)
		return
	}

	componentAID := &quot;componentA&quot;
	componentBID := &quot;componentB&quot;
	componentCID := &quot;componentC&quot;

	// 构造出对应的 tcc component
	componentA := NewMockComponent(componentAID, redisClient, locker)
	componentB := NewMockComponent(componentBID, redisClient, locker)
	componentC := NewMockComponent(componentCID, redisClient, locker)

	// 构造出事务日志存储模块
	txRecordDAO := dao.NewTXRecordDAO(mysqlDB)
	txStore := NewMockTXStore(txRecordDAO, locker)

	txManager := gotcc.NewTXManager(txStore, gotcc.WithMonitorTick(time.Second))
	defer txManager.Stop()

	// 完成各组件的注册
	if err := txManager.Register(componentA); err != nil {
		fmt.Println(err)
		return
	}

	if err := txManager.Register(componentB); err != nil {
		fmt.Println(err)
		return
	}

	if err := txManager.Register(componentC); err != nil {
		fmt.Println(err)
		return
	}

	ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
	defer cancel()
	_, success, err := txManager.Transaction(ctx, []*gotcc.RequestEntity{
		{ComponentID: componentAID,
			Request: map[string]interface{}{
				&quot;biz_id&quot;: componentAID + &quot;_biz&quot;,
			},
		},
		{ComponentID: componentBID,
			Request: map[string]interface{}{
				&quot;biz_id&quot;: componentBID + &quot;_biz&quot;,
			},
		},
		{ComponentID: componentCID,
			Request: map[string]interface{}{
				&quot;biz_id&quot;: componentCID + &quot;_biz&quot;,
			},
		},
	}...)
	if err != nil {
		fmt.Printf(&quot;tx failed, err: %v&quot;, err)
		return
	}
	if !success {
		fmt.Println(&quot;tx failed&quot;)
	}

	// 这里等待一分钟，观察 TxManager 异步轮询 Hanging Transaction
	&lt;-time.After(time.Minute)

	fmt.Println(&quot;success&quot;)
}
```

结束！垮台！

</content></entry><entry><title>且慢！先来实现分布式锁！</title><link href="https://www.bytecho.net/archives/golang_dlock.html" /><id>https://www.bytecho.net/archives/golang_dlock.html</id><updated>2026-07-31T09:17:17.000Z</updated><summary>很遗憾，想要实现 TCC 业务侧，必然是需要分布式锁，有很多的方案都可以实现分布式锁，我们这里采用简单的 Redis 分布式锁。</summary><content type="html">很遗憾，想要实现 TCC 业务侧，必然是需要分布式锁，有很多的方案都可以实现分布式锁，我们这里采用简单的 Redis 分布式锁。

本文将不再啰嗦！否则成了小学生，啥都要解说。这是一份有人味儿的 Code！

### Option

```go
package dlock

import &quot;time&quot;

type Config struct {
	// 锁的默认有效期
	Expiry time.Duration

	// 获取锁最大尝试次数
	Tries int

	// 两次尝试之间的间隔
	RetryDelay time.Duration

	// Do 获取锁时的最大等待时间
	AcquireTimeout time.Duration

	// 解锁操作的最大执行时间
	UnlockTimeout time.Duration
}

func DefaultConfig() Config {
	return Config{
		Expiry:         10 * time.Second,
		Tries:          3,
		RetryDelay:     100 * time.Millisecond,
		AcquireTimeout: 800 * time.Millisecond,
		UnlockTimeout:  2 * time.Second,
	}
}

func normalizeConfig(config Config) Config {
	defaults := DefaultConfig()

	if config.Expiry &lt;= 0 {
		config.Expiry = defaults.Expiry
	}

	if config.Tries &lt;= 0 {
		config.Tries = defaults.Tries
	}

	if config.RetryDelay &lt;= 0 {
		config.RetryDelay = defaults.RetryDelay
	}

	if config.AcquireTimeout &lt;= 0 {
		config.AcquireTimeout = defaults.AcquireTimeout
	}

	if config.UnlockTimeout &lt;= 0 {
		config.UnlockTimeout = defaults.UnlockTimeout
	}

	return config
}

type mutexOptions struct {
	expiry         time.Duration
	tries          int
	retryDelay     time.Duration
	acquireTimeout time.Duration
}

type Option func(*mutexOptions)

func WithExpiry(expiry time.Duration) Option {
	return func(options *mutexOptions) {
		if expiry &gt; 0 {
			options.expiry = expiry
		}
	}
}

func WithTries(tries int) Option {
	return func(options *mutexOptions) {
		if tries &gt; 0 {
			options.tries = tries
		}
	}
}

func WithRetryDelay(delay time.Duration) Option {
	return func(options *mutexOptions) {
		if delay &gt; 0 {
			options.retryDelay = delay
		}
	}
}

func WithAcquireTimeout(timeout time.Duration) Option {
	return func(options *mutexOptions) {
		if timeout &gt; 0 {
			options.acquireTimeout = timeout
		}
	}
}
```

### Errors

```go
package dlock

import &quot;errors&quot;

var (
	// ErrNotAcquired 表示锁被其他实例占用，或者重试次数耗尽
	ErrNotAcquired = errors.New(&quot;distributed lock not acquired&quot;)

	// ErrUnlockFailed 表示锁没有成功释放
	ErrUnlockFailed = errors.New(&quot;distributed lock unlock failed&quot;)

	// ErrExtendFailed 表示锁续期失败
	ErrExtendFailed = errors.New(&quot;distributed lock extend failed&quot;)
)
```

### Locker Interface

```go
package dlock

import (
	&quot;context&quot;
	&quot;time&quot;
)

// 来自十年人物的提示：
// Locker 可以作为全局
// Mutex 请操作时创建，可以在 struct 中封装一个

// Locker 工厂函数
type Locker interface {
	// NewMutex 每次业务操作都应该创建新的 Mutex
	NewMutex(key string, opts ...Option) Mutex

	// Do 获取锁，执行业务并释放锁
	Do(ctx context.Context, key string, fn func(context.Context) error, opts ...Option) error
}

type Mutex interface {
	// Lock 按照配置可以重试多次获取锁
	Lock(ctx context.Context) error
	// TryLock 仅尝试一次，定时任务请使用这个
	TryLock(ctx context.Context) error
	// Unlock 解锁
	Unlock(ctx context.Context) error
	// Extend 锁续期
	Extend(ctx context.Context) error
	// 返回当当前锁预计截至时间
	Until() time.Time
}
```

### Redsync Impl

这部分是对工厂函数的实现，可以用自己喜欢的方式封装，这里选择基于 Redis 的分布式锁： https://github.com/go-redsync/redsync

&gt; Redsync provides a Redis-based distributed mutual exclusion lock implementation for Go as described in [this post](http://redis.io/topics/distlock). A reference library (by [antirez](https://github.com/antirez)) for Ruby is available at [github.com/antirez/redlock-rb](https://github.com/antirez/redlock-rb).

```go
package dlock

import (
	&quot;context&quot;
	&quot;errors&quot;
	&quot;fmt&quot;
	&quot;time&quot;

	&quot;github.com/go-redsync/redsync/v4&quot;
	redsyncRedis &quot;github.com/go-redsync/redsync/v4/redis/goredis/v9&quot;
	goredis &quot;github.com/redis/go-redis/v9&quot;
)

type redisLocker struct {
	rs     *redsync.Redsync
	config Config
}

// NewRedisLocker 创建锁鸡架，因为在这里想到了吃，所以叫做鸡架
func NewRedisLocker(client goredis.UniversalClient, config Config) Locker {
	config = normalizeConfig(config)

	pool := redsyncRedis.NewPool(client)

	return &amp;redisLocker{
		rs:     redsync.New(pool),
		config: config,
	}
}

// redisMutex 实现 Mutex interface
type redisMutex struct {
	mutex *redsync.Mutex
}

var _ Mutex = (*redisMutex)(nil)
var _ Locker = (*redisLocker)(nil)

func (m *redisMutex) Lock(ctx context.Context) error {
	err := m.mutex.LockContext(ctx)
	if err == nil {
		return nil
	}

	if errors.Is(err, redsync.ErrFailed) {
		return errors.Join(ErrNotAcquired, err)
	}

	return err
}

func (m *redisMutex) TryLock(ctx context.Context) error {
	err := m.mutex.TryLockContext(ctx)
	if err == nil {
		return nil
	}

	if errors.Is(err, redsync.ErrFailed) {
		return errors.Join(ErrNotAcquired, err)
	}

	return err
}

func (m *redisMutex) Unlock(ctx context.Context) error {
	ok, err := m.mutex.UnlockContext(ctx)
	if err != nil {
		return errors.Join(ErrUnlockFailed, err)
	}

	if !ok {
		return ErrUnlockFailed
	}

	return nil
}

func (m *redisMutex) Extend(ctx context.Context) error {
	ok, err := m.mutex.ExtendContext(ctx)
	if err != nil {
		return errors.Join(ErrExtendFailed, err)
	}

	if !ok {
		return ErrExtendFailed
	}

	return nil
}

func (m *redisMutex) Until() time.Time {
	return m.mutex.Until()
}

// NewMutex 创建锁
func (l *redisLocker) NewMutex(key string, opts ...Option) Mutex {
	return l.newMutex(key, l.buildOptions(opts...))
}

// newMutex 内部方法
func (l *redisLocker) newMutex(
	key string,
	options mutexOptions,
) Mutex {
	mutex := l.rs.NewMutex(
		key,
		redsync.WithExpiry(options.expiry),
		redsync.WithTries(options.tries),
		redsync.WithRetryDelay(options.retryDelay),
	)

	return &amp;redisMutex{
		mutex: mutex,
	}
}

// buildOptions 注入函数选项配置
func (l *redisLocker) buildOptions(opts ...Option) mutexOptions {
	options := mutexOptions{
		expiry:         l.config.Expiry,
		tries:          l.config.Tries,
		retryDelay:     l.config.RetryDelay,
		acquireTimeout: l.config.AcquireTimeout,
	}

	for _, apply := range opts {
		if apply != nil {
			apply(&amp;options)
		}
	}

	return options
}

// Do 执行短任务时调用，避免繁琐的使用 Mutex
func (l *redisLocker) Do(ctx context.Context, key string, fn func(ctx context.Context) error, opts ...Option) (err error) {
	if ctx == nil {
		ctx = context.Background()
	}

	if fn == nil {
		return errors.New(&quot;distributed lock callback function is nil&quot;)
	}

	options := l.buildOptions(opts...)
	mutex := l.newMutex(key, options)

	acquireCtx, acquireCancel := context.WithTimeout(ctx, options.acquireTimeout)
	defer acquireCancel()
	if err := mutex.Lock(acquireCtx); err != nil {
		return fmt.Errorf(&quot;acquire distributed lock %q: %w&quot;, key, err)
	}

	defer func() {
		// 不使用可能已经超时或取消的业务 ctx，所以这里使用 context.WithoutCancel(ctx) 重新继承
		// 这样会刷掉父 ctx 的 deadline 和 cancel 状态
		unlockCtx, unlockCancel := context.WithTimeout(context.WithoutCancel(ctx), l.config.UnlockTimeout)
		defer unlockCancel()
		if unlockErr := mutex.Unlock(unlockCtx); unlockErr != nil {
			// 这里会把 fn(ctx) 返回的 err 合并
			err = errors.Join(err,
				fmt.Errorf(&quot;unlock distributed lock %q: %w&quot;, key, unlockErr))
		}
	}()

	return fn(ctx)
}
```

</content></entry><entry><title>基于 TCC 写一个所谓的分布式事务#1</title><link href="https://www.bytecho.net/archives/tcc_golang_1.html" /><id>https://www.bytecho.net/archives/tcc_golang_1.html</id><updated>2026-07-27T09:17:17.000Z</updated><summary>之前不是拿分布式事务扯了两篇文章吗，详见浅谈分布式事务 #1 和 #2，今天就用上次说到的 TCC 来实现一个相对可用的分布式事务。</summary><content type="html">[浅谈分布式事务#1](/archives/distributed_transaction.html)

[浅谈分布式事务#2](/archives/distributed_transaction_tcc.html)

## 基本结构

什么是分布式事务，什么是 TCC 就不在阐述了，具体参考浅谈分布式事务 #1 和 #2。

TCC 框架下的分布式事务主要由 TxManager、TxStore、RegistryCenter、TCCComponent。

其中 TxManager 负责聚合 TxStore 和 RegistryCenter 并管理其生命周期，TxStore 和 TCCComponent 由业务侧自行实现，然后在 TxManager 中完成注册和注入。

![tcc](/api/v1/uploads/file/c695e6c64668c3069851d555533d3363_20260727115350.png)

**在实现 TCC 框架的实战环节中，首先需要明确的事情是：**

- 哪部分内容在 TCC 架构中属于通用的流程，这部分内容可以抽取出来放在 SDK 中，以供后续复用；
- 哪部分内容需要给使用方预留出足够的自由度，由使用方自行实现，然后和通用 SDK 进行接轨。

**这两个问题其实是开发 SDK 的通用问题。**

- 在 TCC SDK 中实现的通用逻辑包含了和事务协调器 TxManager 有关的核心流程；
- 事务协调器 TxManager 开启事务以及 try-confirm/cancel 的 2PC 流程串联；
- 事务协调器 TxManager 异步轮询任务，用于推进事务从中间态走向终态；
- TCC 组件的注册流程：
  - 需要预定义事务日志存储模块 TxStore 的实现规范（声明 interface）；
  - 需要预定义 TCC 组件 TCCComponent 的实现规范（声明 interface）。
- TCC 组件和 TxStore 两部分内容需要由使用方自行实现。
  - 使用方自行实现 TCCComponent 类，包括其 Try、Confirm、Cancel 方法的执行逻辑；
  - 使用方自行实现具体的 TxStore 日志存储模块. 可以根据实际需要，选型合适的存储组件和存储方式。

## 实现 TCC 框架

TCC 中最关键的组件就是 TxManager，下图清晰的描述了其结构关系：

![tcc](/api/v1/uploads/file/dcbacadf485c141a2b9b0028f2c0b2e1_20260727112520.png)

### TxManager

```go
type TXManager struct {
	ctx            context.Context
	stop           context.CancelFunc
	opts           *Options
	txStore        TXStore
	registryCenter *registryCenter
}
```

下面来一步步实现 TxManager 中的每一个方法。

对于初始化，我们依然采用函数选项模式进行配置注入，这在 SDK 的开发中十分常见：

```go
type Options struct {
	// 事务执行时长限制
	Timeout time.Duration
	// 轮询监控任务间隔时长
	MonitorTick time.Duration
}

type Option func(*Options)

func WithTimeout(timeout time.Duration) Option {
	if timeout &lt;= 0 {
		timeout = 5 * time.Second
	}

	return func(o *Options) {
		o.Timeout = timeout
	}
}

func WithMonitorTick(tick time.Duration) Option {
	if tick &lt;= 0 {
		tick = 10 * time.Second
	}

	return func(o *Options) {
		o.MonitorTick = tick
	}
}
```

业务方需要首先实现 TxStore，并调用一系列的 `WithXXX` 函数进行配置注入，完成 TxManager 的初始化：

```go
func NewTXManager(txStore TXStore, opts ...Option) *TXManager {
	ctx, cancel := context.WithCancel(context.Background())
	txManager := TXManager{
		opts:           &amp;Options{},
		txStore:        txStore,
		registryCenter: newRegistryCenter(),
		ctx:            ctx,
		stop:           cancel,
	}

	// 配置注入
	for _, opt := range opts {
		opt(txManager.opts)
	}

	// 配置合法化
	repair(txManager.opts)

	go txManager.run()
	return &amp;txManager
}

func (t *TXManager) Stop() {
	t.stop()
}
```

TxManager 如何打通整个链路我们先放一边，先将 TxManager 中所需的 Struct 分别完成定义。

### RegistryCenter

虽然 TxManager 从命名来看像是在直接管理 TCC Component，其实不然，Component 其实是通过注册中心管理的，TxManager 是负责调度整个事务而非事务中所参与的 Component。

```go
type registryCenter struct {
	mux        sync.RWMutex
	components map[string]TCCComponent
}

func newRegistryCenter() *registryCenter {
	return &amp;registryCenter{
		components: make(map[string]TCCComponent),
	}
}
```

注册中心写起来很简单，它仅仅负责外部 Component 的注册，所以只需要一个 Map 就足矣了，不过为了处理多线程问题，我们引入 RWMutex 来解决 Map 并发读写问题，当然也可以直接用 sync.Map，不过 sync.Map 可能难以处理组合逻辑的原子性。

```go
func (r *registryCenter) register(component TCCComponent) error {
	r.mux.Lock()
	defer r.mux.Unlock()
	if _, ok := r.components[component.ID()]; ok {
		return errors.New(&quot;repeat component id&quot;)
	}
	r.components[component.ID()] = component
	return nil
}

// getComponents 用于上游 TxManager 获取 Component
func (r *registryCenter) getComponents(componentIDs ...string) ([]TCCComponent, error) {
	components := make([]TCCComponent, 0, len(componentIDs))

	r.mux.RLock()
	defer r.mux.RUnlock()

	for _, componentID := range componentIDs {
		component, ok := r.components[componentID]
		if !ok {
			return nil, fmt.Errorf(&quot;component id: %s not existed&quot;, componentID)
		}
		components = append(components, component)
	}

	return components, nil
}
```

然后在 TxManager 中加入 Register 方法：

```go
func (t *TXManager) Register(component component.TCCComponent) error {
    return t.registryCenter.register(component)
}
```

### TxStore

TxStore 是用于存储 TCC 日志，此处使用 `interface` 声明，由业务侧实现所有方法。

```go
// 事务日志存储模块
type TXStore interface {
	// 创建一条事务明细记录
	CreateTX(ctx context.Context, components ...TCCComponent) (txID string, err error)
	// 更新事务进度：实际更新的是每个组件的 try 请求响应结果
	TXUpdate(ctx context.Context, txID string, componentID string, accept bool) error
	// 提交事务的最终状态, 标识事务执行结果为成功或失败
	TXSubmit(ctx context.Context, txID string, success bool) error
	// 获取到所有未完成的事务
	GetHangingTXs(ctx context.Context) ([]*Transaction, error)
	// 获取指定的一笔事务
	GetTX(ctx context.Context, txID string) (*Transaction, error)
	// 锁住整个 TXStore 模块（要求为分布式锁）
	Lock(ctx context.Context, expireDuration time.Duration) error
	// 解锁TXStore 模块
	Unlock(ctx context.Context) error
}
```

## 实现事务主链路

TCC 框架下的事务链路可以简单描述为：

```
Transaction(启动事务) -&gt; GetTCCComponents(获取 TCC 组件) -&gt; CreateTx(创建事务) -&gt; TwoPhaseCommit(进入 2PC 链路)
```

要启动事务，我们首先要明确要启动什么事务？TCC 组件调用参数分别是什么？

```go
type RequestEntity struct {
	// 组件名称
	ComponentID string `json:&quot;componentName&quot;`
	// 组件入参
	Request map[string]interface{} `json:&quot;request&quot;`
}
```

我们将 `RequestEntity` 结构体作为启动事务的关键参数：

```go
// Transaction 事务
func (t *TXManager) Transaction(ctx context.Context, reqs ...*RequestEntity) (string, bool, error) {
	tCtx, cancel := context.WithTimeout(ctx, t.opts.Timeout)
	defer cancel()

	// 获得所有的组件
	componentEntities, err := t.getComponents(tCtx, reqs...)
	if err != nil {
		return &quot;&quot;, false, err
	}

	// 先创建事务明细记录，并取得全局唯一的事务 id
	txID, err := t.txStore.CreateTX(tCtx, componentEntities.ToComponents()...)
	if err != nil {
		return &quot;&quot;, false, err
	}

	// 两阶段提交， try-confirm/cancel
	return txID, t.twoPhaseCommit(ctx, txID, componentEntities), nil
}
```

### 2PC

![2pc](/api/v1/uploads/file/b6adf8aba531b0a99a0d045fb3c3e13a_20260727141118.png)

2PC 即两阶段提交，是 TCC 中的精髓，之所以被称为 2PC，看图就能明白，要完成一个事务的提交，需要经历两个主要步骤，第一是分别异步调用该事务中所有 Components 的 Try 方法，等待分别返回结果，根据收集到的 Try 方法的结果，决定第二次 Commit 的流转链路，但凡有一个 Try 方法失败，那么 TxManager 会分别调用所有 Components 的 Cancel 方法完成事务回滚。

```go
// twoPhaseCommit 2PC
func (t *TXManager) twoPhaseCommit(ctx context.Context, txID string, componentEntities ComponentEntities) bool {
	cCtx, cancel := context.WithCancel(ctx)
	defer cancel()

	// 并发执行，只要中间某次出现了失败，直接终止流程进行 cancel
	// 如果全量执行成功，则批量执行 confirm，然后返回成功的 ACK
	errCh := make(chan error, len(componentEntities))

	// 异步处理与等待
	go func() {
		// 并发处理多个 component 的 try 流程
		var wg sync.WaitGroup
		for _, componentEntity := range componentEntities {
			// shadowing 解决 range 循环变量迭代复用导致的 goroutine 或闭包错误地捕获同一个 componentEntity
			// &gt;= go 1.22 range 每次迭代都会创建新变量，不必再 shadowing
			componentEntity := componentEntity

			wg.Add(1)
			go func() {
				defer wg.Done()
				resp, err := componentEntity.Component.Try(cCtx, &amp;TCCReq{
					ComponentID: componentEntity.Component.ID(),
					TXID:        txID,
					Data:        componentEntity.Request,
				})
				// 但凡有一个 component try 报错或者拒绝，需要进行 cancel，但会放在 advanceProgressByTXID 流程处理
				if err != nil || !resp.ACK {
					log.ErrorContextf(cCtx, &quot;tx try failed, tx id: %s, comonent id: %s, err: %v&quot;, txID, componentEntity.Component.ID(), err)
					// 对对应的事务进行更新
					if _err := t.txStore.TXUpdate(cCtx, txID, componentEntity.Component.ID(), false); _err != nil {
						log.ErrorContextf(cCtx, &quot;tx updated failed, tx id: %s, component id: %s, err: %v&quot;, txID, componentEntity.Component.ID(), _err)
					}
					errCh &lt;- fmt.Errorf(&quot;component: %s try failed&quot;, componentEntity.Component.ID())
					return
				}
				// try 请求成功，但是请求结果更新到事务日志失败时，也需要视为处理失败
				if err = t.txStore.TXUpdate(cCtx, txID, componentEntity.Component.ID(), true); err != nil {
					log.ErrorContextf(cCtx, &quot;tx updated failed, tx id: %s, component id: %s, err: %v&quot;, txID, componentEntity.Component.ID(), err)
					errCh &lt;- err
				}
			}()
		}

		wg.Wait()
		close(errCh)
	}()

	successful := true
	if err := &lt;-errCh; err != nil {
		// 只要有 try 请求出现问题，其他的都进行终止
		cancel()
		successful = false
	}

	// 执行二阶段. 即便第二阶段执行失败也无妨，可以通过轮询任务进行兜底处理
	if err := t.advanceProgressByTXID(txID); err != nil {
		log.ErrorContextf(ctx, &quot;advance tx progress fail, txid: %s, err: %v&quot;, txID, err)
	}
	return successful
}
```

### 进度推进

注意到 2PC 还对一个关键方法进行了调用 `t.advanceProgressByTXID(txID)` 来根据 Try 的结果推进当前事务的进度：

```go
// advanceProgressByTXID 传入一个事务 id 推进其进度
func (t *TXManager) advanceProgressByTXID(txID string) error {
	// 获取事务日志记录
	tx, err := t.txStore.GetTX(t.ctx, txID)
	if err != nil {
		return err
	}
	return t.advanceProgress(tx)
}

// advanceProgress 传入一个事务 id 推进其进度
func (t *TXManager) advanceProgress(tx *Transaction) error {
	// 根据各个 component try 请求的情况，推断出事务当前的状态
	// 注意这里还计算了超时，太久没有拿到结果的 try 请求也会算做失败
	txStatus := tx.getStatus(time.Now().Add(-t.opts.Timeout))

	// hanging 状态的暂时不处理，等待处理完成
	if txStatus == TXHanging {
		return nil
	}

	success := txStatus == TXSuccessful
	// 根据事务是否成功，定义不同的处理函数
	var confirmOrCancel func(ctx context.Context, component TCCComponent) (*TCCResp, error)
	var txAdvanceProgress func(ctx context.Context) error

	if success {
		confirmOrCancel = func(ctx context.Context, component TCCComponent) (*TCCResp, error) {
			// 对 component 进行第二阶段的 confirm 操作
			return component.Confirm(ctx, tx.TXID)
		}
		txAdvanceProgress = func(ctx context.Context) error {
			// 更新事务日志记录的状态为成功
			return t.txStore.TXSubmit(ctx, tx.TXID, true)
		}

	} else {
		confirmOrCancel = func(ctx context.Context, component TCCComponent) (*TCCResp, error) {
			// 对 component 进行第二阶段的 cancel 操作
			return component.Cancel(ctx, tx.TXID)
		}

		txAdvanceProgress = func(ctx context.Context) error {
			// 更新事务日志记录的状态为失败
			return t.txStore.TXSubmit(ctx, tx.TXID, false)
		}
	}

	// 需要遍历处理所有 TCC 组件
	for _, component := range tx.Components {
		components, err := t.registryCenter.getComponents(component.ComponentID)
		if err != nil || len(components) == 0 {
			return errors.New(&quot;get tcc component failed&quot;)
		}
		// 执行二阶段的 confirm 或者 cancel 操作
		resp, err := confirmOrCancel(t.ctx, components[0])
		if err != nil {
			return err
		}
		if !resp.ACK {
			return fmt.Errorf(&quot;component: %s ack failed&quot;, component.ComponentID)
		}
	}

	// 二阶段操作都执行完成后，对事务状态进行提交
	return txAdvanceProgress(t.ctx)
}
```

### 轮询机制

倘若存在事务已经完成第一阶段 Try 操作的执行，但是第二阶段没执行成功，则需要由异步轮询流程进行兜底处理，为事务补齐第二阶段的操作，并将事务状态更新为终态，以增强 TxManager 的鲁棒性。

轮询的时间间隔会根据一轮任务处理过程中是否出现错误，而进行动态调整。

这里调整规则是：当一次处理流程中发生了错误，就需要调大当前节点轮询的时间间隔，让其他节点的异步轮询任务得到更大的执行机会。

在之前给出的 `NewTXManager` 函数中使用 goroutine 启动了 `run` 方法：

```go
// backOffTick 指数退避
func (t *TXManager) backOffTick(tick time.Duration) time.Duration {
	tick &lt;&lt;= 1
	// 最大不超过 MonitorTick 的 2^3
	if threshold := t.opts.MonitorTick &lt;&lt; 3; tick &gt; threshold {
		return threshold
	}
	return tick
}

// run 轮询流程
func (t *TXManager) run() {
	var tick time.Duration
	var err error
	for {
		// 如果出现了失败，tick 需要避让，遵循退避策略增大 tick 间隔时长
		if err == nil {
			tick = t.opts.MonitorTick
		} else {
			tick = t.backOffTick(tick)
		}
		select {
		case &lt;-t.ctx.Done():
			return

		case &lt;-time.After(tick):
			// 加锁，避免多个分布式多个节点的监控任务重复执行
			if err = t.txStore.Lock(t.ctx, t.opts.MonitorTick); err != nil {
				// 取锁失败时（大概率被其他节点占有），不对 tick 进行退避升级
				err = nil
				continue
			}

			// 获取仍然处于 hanging 状态的事务
			var txs []*Transaction
			if txs, err = t.txStore.GetHangingTXs(t.ctx); err != nil {
				_ = t.txStore.Unlock(t.ctx)
				continue
			}

			err = t.batchAdvanceProgress(txs)
			_ = t.txStore.Unlock(t.ctx)
		}
	}
}

// batchAdvanceProgress 推进事务状态
func (t *TXManager) batchAdvanceProgress(txs []*Transaction) error {
	// 对每笔事务进行状态推进
	errCh := make(chan error)
	go func() {
		// 并发执行，推进各事务的进度
		var wg sync.WaitGroup
		for _, tx := range txs {
			// shadowing
			tx := tx
			wg.Add(1)
			go func() {
				defer wg.Done()
				// 每个 goroutine 负责处理一笔事务
				if err := t.advanceProgress(tx); err != nil {
					// 遇到错误则投递到 errCh
					errCh &lt;- err
				}
			}()
		}
		wg.Wait()
		close(errCh)
	}()

	var firstErr error
	// 通过 chan 阻塞在这里，直到所有 goroutine 执行完成，chan 被 close 才能往下
	for err := range errCh {
		// 记录遇到的第一个错误
		if firstErr != nil {
			continue
		}
		firstErr = err
	}

	return firstErr
}
```

## TCC 业务侧实现

很遗憾，这是一个很大的板块，我不能继续在该文章区拉 💩 了！

要使用 TCC 其实是很麻烦的，看似架构清晰，实际上来说，业务侧要做的事情非常多，比如需要针对事务操作封装 TCC Component，还需要实现 TxStore，实现分布式锁等。这是一个庞大的工程，就留到下一篇了！垮台！

</content></entry><entry><title>写一个所谓的通用状态机</title><link href="https://www.bytecho.net/archives/state_machine.html" /><id>https://www.bytecho.net/archives/state_machine.html</id><updated>2026-07-24T09:00:00.000Z</updated><summary>由于以前都没写过状态机，最近随便瞎搞了一个，被吐槽了，笑死，那么今天，将开启王者级 AI 哥模式，写一个所谓的状态机！</summary><content type="html">## 技术背景

当状态转移关系比较繁杂的时候，通常需要引入状态机，让业务层不用每次都去维护繁杂的状态，工程里最常见的写法是：

&gt; **强类型状态和事件 + 显式转换表 + Guard 校验 + 内存状态变更 + Application Service 负责事务和持久化。**

```go
permission := repo.GetForUpdate(...)

result, err := machine.Fire(permission, input)
if err != nil {
	return err
}
repo.Save(permission)
outbox.Append(result.Events...)
```

最近哥们儿在做即时通讯的权限部分，需要实现类似抖音、小红书的陌生人消息功能，很显然需要引入一手状态机，其实 Outbox 部分就已经需要状态机了，不过也是瞎搞的，招笑。

这次 AI 哥 A 明白了之后，把之前的 💩 修改一下。

## 基本结构

对于发信权限表，状态其实并不复杂，包含几种关系：没有打过招呼的透明关系、好友关系、打过招呼的陌生人关系、互相打过招呼的开放关系以及经典的红色感叹号❗️拉黑关系！

**要写一个状态机，首先要理清楚状态有哪些，事件有哪些，状态的流转链路是怎样的。**

我们可以简化假设存在以下关系：

**状态：**

```
Pending：已经发送招呼，等待对方回复
Open：会话开放，双方可以正常聊天
Banned：会话被禁止
```

**事件：**

```
Reply：对方第一次回复
Ban：拉黑
Unban：解除拉黑
```

### 定义状态

由于好友关系是通过外部微服务 RPC 获取的，所以我没有打算将好友态写入状态机，而是通过含淘汰算法的本地缓存来维护好友关系：

* **Eviction: SampledLFU** - on par with exact LRU and better performance on Search and Database traces.
* **Admission: TinyLFU** - extra performance with little memory overhead (12 bits per counter).

关系变更状态由好友服务实时下发，保证即时通讯服务不会出现异常的发信权限。

（为确保关系状态的实时性，目前必须这样设计，并没有想到更好的办法！）

```go
package permission

type Status int8

const (
	StatusPending Status = iota + 1
	StatusOpen
	StatusBanned
)

func (s Status) String() string {
	switch s {
	case StatusPending:
		return &quot;pending&quot;
	case StatusOpen:
		return &quot;open&quot;
	case StatusBanned:
		return &quot;banned&quot;
	default:
		return &quot;unknown&quot;
	}
}
```

### 定义事件

```go
type Event int8

const (
	EventReply Event = iota + 1
	EventBan
	EventUnban
)

func (e Event) String() string {
	switch e {
	case EventReply:
		return &quot;reply&quot;
	case EventBan:
		return &quot;ban&quot;
	case EventUnban:
		return &quot;unban&quot;
	default:
		return &quot;unknown&quot;
	}
}
```

事件决定了状态的流转方向，做过工作流引擎的应该很熟悉这个逻辑。

比如在这个场景中，我们有这个流转关系：`StatusPending + EventReply = StatusOpen`

用文字描述就是，某一个用户和对方打了招呼的**状态** + 对方回复**事件** = 开放聊天**状态**。

即：**状态 + 事件 = 新的状态。**

### 定义状态机输入

上面只是简单描述了有什么状态，有什么事件，但是不管是状态还是事件，其实都是和实体要产生联系的，所以我们在使用状态机时，需要告诉状态机我们操作的是哪个实体的状态，例如：

- 谁回复；
- 谁拉黑；
- 什么时候。

```go
type Input struct {
	Event      Event
	OperatorID string
	At         time.Time
}
```

### 定义转换规则

如果没有状态机约束，那业务层可以随便流转状态，比如我突发性的从 Open 状态流转到 Pending 状态，这显然是不符合预期的异常流转，导致流转链路混乱，同时难以维护。

**一个流转规则通常包含：**

- 当前状态
- 事件
- 目标状态
- Guard 条件
- Action 附加操作

```go
type transitionKey struct {
	From  Status
	Event Event
}

type Transition struct {
	To Status

	// Guard 只负责校验，不修改状态。
	Guard func(
		permission *ConversationPermission,
		input Input,
	) error

	// Action 修改状态之外的领域字段，并返回领域事件。
	Action func(
		permission *ConversationPermission,
		input Input,
	) []DomainEvent
}
```

### 定义领域事件

可选，状态流转后，我们可能需要触发一些 Outbox 事件，在此处定义：

```go
type DomainEvent interface {
	EventName() string
}

type ConversationOpened struct {
	ConversationID string
	OpenedAt       time.Time
}

func (ConversationOpened) EventName() string {
	return &quot;conversation.permission.opened&quot;
}

type ConversationBanned struct {
	ConversationID string
	OperatorID     string
	BannedAt       time.Time
}

func (ConversationBanned) EventName() string {
	return &quot;conversation.permission.banned&quot;
}
```

## 实现状态机

一个相对完整且通用的状态机一般由 **注册 Transition + Fire** 组成。

```go
var ErrInvalidTransition = errors.New(&quot;invalid state transition&quot;)

type StateMachine struct {
	transitions map[transitionKey]Transition
}

func NewStateMachine() *StateMachine {
	m := &amp;StateMachine{
		transitions: make(map[transitionKey]Transition),
	}

	m.registerTransitions()

	return m
}

func (m *StateMachine) register(
	from Status,
	event Event,
	transition Transition,
) {
	key := transitionKey{
		From:  from,
		Event: event,
	}

	m.transitions[key] = transition
}
```

### 注册流转规则

```go
func (m *StateMachine) registerTransitions() {
	m.registerOpenTransitions()
	m.registerBanTransitions()
	m.registerUnbanTransitions()
}
```

这一部分通常由业务层实现，然后注册到状态机中。

```go
func (m *StateMachine) registerOpenTransitions() {
	openTransitions := Transition{
		To: StatusOpen,

		Guard: func(
			permission *ConversationPermission,
			input Input,
		) error {
			if input.OperatorID == &quot;&quot; {
				return errors.New(&quot;operator_id is required&quot;)
			}

			if input.OperatorID == permission.InitiatorID {
				return errors.New(
					&quot;initiator cannot open conversation by replying&quot;,
				)
			}

			return nil
		},

		Action: func(
			permission *ConversationPermission,
			input Input,
		) []DomainEvent {
			at := input.At
			permission.OpenedAt = &amp;at
			permission.BannedAt = nil
			permission.BannedBy = &quot;&quot;

			return []DomainEvent{
				ConversationOpened{
					ConversationID: permission.ConversationID,
					OpenedAt:       at,
				},
			}
		},
	}

	// 定义流转路径
	// Pending -&gt; Event(Reply) -&gt; Transition(StatusOpen)
	m.register(StatusPending, EventReply, openTransitions)
}

func (m *StateMachine) registerBanTransitions() {
	banTransition := Transition{
		To: StatusBanned,

		// 过滤非法数据
		Guard: func(
			permission *ConversationPermission,
			input Input,
		) error {
			if input.OperatorID == &quot;&quot; {
				return errors.New(&quot;operator_id is required&quot;)
			}

			return nil
		},

		// 由业务定义，这里只是做演示
		Action: func(
			permission *ConversationPermission,
			input Input,
		) []DomainEvent {
			at := input.At

			permission.BannedAt = &amp;at
			permission.BannedBy = input.OperatorID

			return []DomainEvent{
				ConversationBanned{
					ConversationID: permission.ConversationID,
					OperatorID:     input.OperatorID,
					BannedAt:       at,
				},
			}
		},
	}

	// 定义流转路径
	// Pending/Open -&gt; Event(Ban) -&gt; Transition(StatusBanned)
	m.register(StatusPending, EventBan, banTransition)
	m.register(StatusOpen, EventBan, banTransition)
}

type ConversationUnbanned struct {
	ConversationID string
	OperatorID     string
	UnbannedAt     time.Time
}

func (ConversationUnbanned) EventName() string {
	return &quot;conversation.permission.unbanned&quot;
}

func (m *StateMachine) registerUnbanTransitions() {
	unbanTransition := Transition{
		To: StatusOpen,

		Guard: func(
			permission *ConversationPermission,
			input Input,
		) error {
			if input.OperatorID == &quot;&quot; {
				return errors.New(&quot;operator_id is required&quot;)
			}

			if permission.BannedBy != input.OperatorID {
				return errors.New(
					&quot;only the user who banned the conversation can unban it&quot;,
				)
			}

			return nil
		},

		Action: func(
			permission *ConversationPermission,
			input Input,
		) []DomainEvent {
			permission.BannedAt = nil
			permission.BannedBy = &quot;&quot;

			return []DomainEvent{
				ConversationUnbanned{
					ConversationID: permission.ConversationID,
					OperatorID:     input.OperatorID,
					UnbannedAt:     input.At,
				},
			}
		},
	}

	// 定义流转路径
	// Banned -&gt; Event(Unban) -&gt; Transition(StatusOpen)
	m.register(StatusBanned, EventUnban, unbanTransition)
}
```

### 实现 Fire

之前通过 register 方法将 Status + Event 组成的 Key 注册 Transitions，现在就能通过 Key 找到对应的 Transitions。Key 表示来源，Transitions 表示流转路径，从而完成状态转移。

```go
type Result struct {
	From   Status
	To     Status
	Event  Event
	Events []DomainEvent
}
```

```go
func (m *StateMachine) Fire(
	permission *ConversationPermission,
	input Input,
) (Result, error) {
	if permission == nil {
		return Result{}, errors.New(
			&quot;conversation permission is nil&quot;,
		)
	}

	if input.At.IsZero() {
		return Result{}, errors.New(
			&quot;transition time is required&quot;,
		)
	}

	// 通过来源构造 Transitions Key
	key := transitionKey{
		From:  permission.Status,
		Event: input.Event,
	}

	// 通过 key 找到之前注册的流转路径
	transition, ok := m.transitions[key]
	if !ok {
		return Result{}, fmt.Errorf(
			&quot;%w: status=%s event=%s&quot;,
			ErrInvalidTransition,
			permission.Status,
			input.Event,
		)
	}

	// 流转前验证
	if transition.Guard != nil {
		if err := transition.Guard(permission, input); err != nil {
			return Result{}, fmt.Errorf(
				&quot;transition guard rejected: %w&quot;,
				err,
			)
		}
	}

	from := permission.Status

	// 开始流转，并返回领域事件
	// 领域事件可选，主要用于广播通知
	var events []DomainEvent
	if transition.Action != nil {
		events = transition.Action(permission, input)
	}

	permission.Status = transition.To

	// 返回流转结果
	return Result{
		From:   from,
		To:     transition.To,
		Event:  input.Event,
		Events: events,
	}, nil
}
```

**总体流程如下：**

1. 找到流转规则
2. Guard 校验
3. 执行 Action
4. 更新状态
5. 返回状态变化和（领域事件）

## 业务调用

业务方只需要在事务中调用状态机 Fire 方法，传入当前状态和事件，状态机会构造 Key 获取到正确的流转路径，完成状态转移，然后业务方根据状态机结果调用 Repo 接口完成数据落库或其他事件广播。

```go
// 初始状态
permission, err :=
	s.repo.FindByConversationIDForUpdate(
		ctx,
		tx,
		conversationID,
	)
if err != nil {
	return fmt.Errorf(
		&quot;find conversation permission: %w&quot;,
		err,
	)
}

if permission == nil {
	return errors.New(
		&quot;conversation permission not found&quot;,
	)
}

// 调用状态机
// 状态机会根据状态和事件构造 Key 的到 Transitions 修改初始状态并返回流转结果
result, err := s.machine.Fire(
	permission,
	Input{
		Event:      EventReply,
		OperatorID: senderID,
		At:         time.Now(),
	},
)
if err != nil {
	return err
}

// 新状态落库
if err := s.repo.Save(
	ctx,
	tx,
	permission,
); err != nil {
	return fmt.Errorf(
		&quot;save conversation permission: %w&quot;,
		err,
	)
}

// 可选领域事件广播/Outbox
for _, event := range result.Events {
	if err := s.outboxRepo.Append(
		ctx,
		tx,
		event,
	); err != nil {
		return fmt.Errorf(
			&quot;append permission event: %w&quot;,
			err,
		)
	}
}
```

**在这里不得不吐槽一下 macOS 输入法，我想输入“初始状态”，非要给我联想出一个“出事状态”，我看怕是 🐒 猴王出事还差不多！**

## 状态机选型

| 状态机规模            | 推荐写法                       |
| ----------------------- | -------------------------------- |
| 2～5 个状态、规则简单 | `switch`                   |
| 状态和事件较多        | 转换表`map[key]Transition` |
| 涉及多服务长事务      | Saga / Workflow                |
| 仅单表简单状态更新    | 条件 UPDATE                    |

对于简单一些的状态机，那么定义一个流转路径 `map` 或者直接使用 `switch` 即可：

```go
func (m StateMachine) Fire(
	permission *ConversationPermission,
	input Input,
) (Result, error) {
	if permission == nil {
		return Result{}, errors.New(
			&quot;conversation permission is nil&quot;,
		)
	}

	switch input.Event {
	case EventReply:
		return m.reply(permission, input)

	case EventBan:
		return m.ban(permission, input)

	case EventUnban:
		return m.unban(permission, input)

	default:
		return Result{}, fmt.Errorf(
			&quot;unknown permission event: %d&quot;,
			input.Event,
		)
	}
}
```

结束！

</content></entry><entry><title>锐评简笔记/某站商店/某彦</title><link href="https://www.bytecho.net/archives/minimablog.html" /><id>https://www.bytecho.net/archives/minimablog.html</id><updated>2026-07-18T02:00:00.000Z</updated><summary>很早之前，名为“小彦”的哥们儿邀请我加入《个站商店》，那时候还算个正常人，后来莫名其妙单方面删站，我最开始没多想，认为可能升级之类的导致数据丢失了，然后我又重新添加了一次网站信息，过段时间又被删了，后面我留言询问了，然而某彦不做任何回应，那很遗憾，只能被我拉黑了，直到最近又看到了这样一幕...</summary><content type="html">## 前言

很早之前，名为“小彦”的哥们儿邀请我加入《个站商店》，那时候还算个正常人，后来莫名其妙单方面删站！

![](/api/v1/uploads/file/c799ba1cbaf9e7023072862aae2bdc89_20260718122254.png)

我最开始没多想，认为可能升级之类的导致数据丢失了，然后我又重新添加了一次网站信息，过段时间又被删了，后面我留言询问了，然而某彦不做任何回应，那很遗憾，只能被我拉黑了，直到最近又看到了这样一幕...

**那我就不得不锐评一下我之前见到的某彦的王者级重量级逆天型作品《简笔记》了。**

## 可笑的优势

《简笔记》对比流行的博客程序 WordPress 有如下“优势”：

&gt; 无插件，应有的功能全集合在主程序中，不需要折腾插件，也没有插件兼容性问题，插件拖慢网页访问速度等问题

“无插件=优势”？你是说，把所有用户可能用不上的功能集成且不可插拔是优势？我严重怀疑你根本不懂插件化如何开发...

“因为没有插件，所以不存在兼容性问题。”嗯是的！解决兼容问题的最好办法就是把它删除！没插件，当然就没兼容问题了！那我可以扩展一下，没软件，就没 Bug了！咱回家玩泥去吧 😆。

“插件拖慢网页速度...”嗯...很有一种小学时期看嘉豪在多媒体跑代码的感觉。

&gt; 天然支持评论邮件的发送，评论验证码。因是自主开发，完全没有 WP 这种的存在恶意/垃圾评论的问题

哦，用了个 SMTP 也能叫优势，哥们儿当初无 AI，三天手搓的玩具 TodoList 也能发邮件哦。

我又请问，是不是自主开发和是否存在恶意/垃圾评论有什么联系？是因为你网站用 Vue 写的连 SSR 也没有让爬虫爬不到你吗？😆 那确实没机器人来发垃圾评论了。

&gt; 顺便给某彦科普一下：Server-Side Rendering (SSR)

```
$ curl -L https://no*****ar.cn/
&lt;meta charset=&quot;utf-8&quot;&gt;
&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width,initial-scale=1&quot;&gt;
&lt;title&gt;加载中…&lt;/title&gt;
&lt;meta property=&quot;og:title&quot; content=&quot;简笔记博客程序&quot;&gt;
&lt;meta property=&quot;og:site\_name&quot; content=&quot;简笔记博客程序&quot;&gt;
&lt;meta property=&quot;og:type&quot; content=&quot;website&quot;&gt;
&lt;meta property=&quot;og:url&quot; content=&quot;https://minimablog.cn&quot;&gt;&lt;meta property=&quot;og:image&quot; content=&quot;/favicon.ico&quot;&gt;
&lt;meta property=&quot;og:description&quot; content=&quot;一个界面简约高效，专注于写作与阅读的博客程序&quot;&gt;&lt;meta name=&quot;title&quot; content=&quot;简笔记博客程序&quot;&gt;
&lt;meta name=&quot;description&quot; content=&quot;一个界面简约高效，专注于写作与阅读的博客程序&quot;/&gt;
&lt;meta name=&quot;keywords&quot; content=&quot;博客，程序，半开源，日志，个人网站，网络笔记&quot;/&gt;
&lt;meta name=&quot;author&quot; content=&quot;星球彦&quot;/&gt;
&lt;meta name=&quot;application-name&quot; content=&quot;简笔记博客程序&quot;/&gt;
```

**令人笑掉大牙的 Head 标签：**

- 😆 `&lt;title&gt;加载中…&lt;/title&gt;`
- 😆 `&lt;meta name=&quot;keywords&quot; content=&quot;博客，程序，半开源，日志，个人网站，网络笔记&quot;/&gt;`
- 😆 `&lt;meta property=&quot;og:title&quot; content=&quot;简笔记博客程序&quot;&gt;`

看了好几个页面，`&lt;head&gt;` 基本都是一模一样的，似乎根本没有做服务端渲染...请问这是一个博客应该有的首屏吗？SEO 能力为 0，您是在开发中后台吗？

**“博客，程序，半开源，日志，个人网站，网络笔记”** 这种关键词写了和没写有区别吗？盲猜是写死在源码中的 😂。

&gt; 天然防黑，防打流量。因为不是 WP，就很少有黑客扫描程序能扫描命中，天然减少被打流量的情况

无力吐槽...和前面那个“自主开发=没有垃圾评论”一样招笑...

其实我再教你一个办法，你把博客部署在局域网，那就更没人扫描命中 🎯 你了。

&gt; 访问速度极快：由于是前后端分离架构，访问速度瞬间能打开，比 WP 要强很多。

666，“前后端分离架构=速度快”，那我假如有图片呢，我假如有大量的前端资源呢？有没有一种可能，对于个人网站来说，首屏加载速度基本取决于服务器带宽而不是架构？你能有点常识吗？

**说白了，通篇没有技术名词，就像是小学生的口水话，“优势”更是不知道从哪个脑洞生拉硬拽出来的...**

**针对于以上“优势”，某彦给出了王者级重磅定价：360元！**

## 可笑的口碑

&gt; https://i.lilog.cn/post/187
&gt; https://i.lilog.cn/post/189
&gt; https://www.htaoo.com/record/934.html

内容涵盖了可笑的技术力、可笑的为人、可笑的...

&gt; 你说漏洞，他说你嫉妒。
&gt; 你说系统问题，他说你底层。
&gt; 你说产品质量，他开始问收入。
&gt; 你继续讲事实，他开始问朋友、问女朋友、问父母。

## 可笑的为人

很早之前，名为“小彦”的哥们儿邀请我加入《个站商店》，那时候还算个正常人，后来莫名其妙单方面删站且不做任何回应，被我拉黑，最近又看到了这样一幕...

其实之前看到他那所谓的《简笔记》的介绍和售价我就笑出了声，当时就想发个文，但没必要节外生枝，也就罢了，看一乐。

没想到今天在群里看到了这么重磅的消息，不得不说我看人还是蛮准的（笑...

&gt; 一个人处理问题的方式，往往比漏洞本身更能反映很多东西。

其实我觉得技术怎么样，作品怎么样，实则无人在意，都是其次了，但不能菜还装，一副谁都得罪不起的样子，何况那些人还是他的用户，人家反馈 Bug，对于我个人来说，我是很开心的，至少有人在用心让你的作品变得更好。

我之前问我 Mentor 到底怎么样的测试才算全面可靠，说到底其实上线才算是最全面的测试，可能有些人一辈子也理解不了用户在测试环节的重要性。当然很大一部分原因是他或许觉得自己是天才不会犯错 😆。

&gt; https://note-star.cn/2026-07/2383.html

依旧满篇吹自己，我是高级程序员！看不上 AI 写的代码！和我写的差太远了！

这边建议先了解一下 Agent，学习一下如何编排自己的工作流，某彦或许是这样引导 AI 的：

&gt; **某彦：** 帮我写一个《简笔记》博客，用 Java 和 Vue。
&gt; **Codex：** 😅 💦...

AI 替代的不是什么所谓的高级/低级程序员，而是自高自大不适应技术发展的那批人。

---

**字节星球 / Yuelai Group**

</content></entry><entry><title>浅谈 DDD 领域驱动设计架构</title><link href="https://www.bytecho.net/archives/domain_driven_design.html" /><id>https://www.bytecho.net/archives/domain_driven_design.html</id><updated>2026-07-17T06:18:41.000Z</updated><summary>在开始实习前 Mentor 给的 Landing 文档中就让我了解 DDD 架构，对于我这个只写过中小型项目的人来说，当时看的第一眼就觉得好复杂好麻烦，不过对于中大型项目来说，很多模式都是前期开发成本高，但保证了后期的可维护性，所以还是有必要了解的，而且我顺便还发现，小徐先生也发了这个文章，又可以美美开学了。</summary><content type="html">## 前言

在开始实习前 Mentor 给的 Landing 文档中就让我了解 DDD 架构，对于我这个只写过中小型项目的人来说，当时看的第一眼就觉得好复杂好麻烦，不过对于中大型项目来说，很多架构都是前期开发成本高，但保证了后期的可维护性，所以还是有必要了解的，而且我顺便还发现，小徐先生也发了这个文章，又可以美美开学了。

## 技术背景

&gt; DDD（全称 Domain-Driven Design）领域驱动设计，是一种项目设计方法论，通过关注业务领域（Domain）、拆分领域上下文（Bounded Context）、搭建领域模型（Domain Model），提高业务和开发之间的共识度与协作效率，保证项目准确反映出业务需求和规则。

我 Mentor 之前是做 Java 的，可能还是习惯对面向对象设计，我感觉 DDD 这种模式还是 Java 上应用更多，毕竟 Java 生态下，中大型项目太多了。Golang 以云原生领域作为主战场，在这块的应用还是相对较少。

| 层级名称       | 职责定位                                                                        | 经典三层结构 | DDD 四层结构 |
| -------------- | ------------------------------------------------------------------------------- | ------------ | ----------- |
| presentation   | 渲染展示层，负责接口请求参数的接收与响应参数的组装。（controller）              | ✅           | ✅          |
| application    | 业务应用层，负责对业务流程进行编排组织。（service-1）                           | ✅           | ✅          |
| domain         | 业务领域层，内聚了某个特定业务领域的功能。（service-2）                         | -            | ✅          |
| infrastructure | 基础能力层，包含数据访问对象（DAO）、第三方系统客户端（Client）以及其他通用能力 | ✅           | ✅          |

对于我这个没有仔细了解过 Java 工程化的人来说，甚至不知道 Java 中的 Controller 究竟是干什么吊用的，结果就是一个对外展示层而已，其实就是对应 Golang 中的 `api/endpoint/handler` 层，名字搞的故弄玄虚的。

### 传统三层结构存在的问题

在常规项目中，使用较多的是经典三层结构（Controller-Service-DAO），其中和上文提到的各层级间的映射关系为：

- Controller——Presentation：渲染展示层
- Service——Application：业务应用层
- DAO——Infrastructure：基础能力层

在 Golang 开发中的常见目录结构，按照技术层进行分包，这也是我用的最多的一种结构：

```
myapp/
├── go.mod
├── cmd/
│   ├── api/
│   │   └── main.go
│   ├── worker/
│   │   └── main.go
│   └── migrate/
│       └── main.go
├── internal/
│   ├── config/
│   ├── server/
│   ├── handler/
│   ├── service/
│   ├── repository/
│   ├── model/
│   ├── middleware/
│   └── app/
├── pkg/
│   ├── jwt/
│   ├── logger/
│   └── snowflake/
├── api/
│   └── proto/
├── configs/
│   └── config.yaml
├── scripts/
│   └── migrate.sh
└── docs/
    └── api.md
```

**这种常规三层结构是非常经典实用的，但其主要适用于中小型项目，其中存在的核心问题就是技术建模和业务需求存在视角差异，导致项目模块边界不清晰、耦合度过高。**

如上图所示，随着项目的迭代演进，各层级与各模块之间可能存在交叉引用的情况，一旦有迭代新的功能或者新增新的模块，会有很高的实现成本。比如下列场景：

&gt; 如果需要按照业务功能进行拆分，将项目由单体服务转变为微服务，这一刀应该落在何处，是否有明确的切分轨迹，是否能够做到低成本实现？

### 从三层结构到 DDD

DDD 相对传统三层结构，其核心差异在于：

- 设计视角上：架构设计环节中，需要从业务视角出发，根据业务功能归属，提前做好不同业务领域的拆分；
- 技术实现上：
  1. **在 application 与 infrastructure 之间新增 domain 领域层；**
  2. 将原本糅合在 service 层实现的业务逻辑拆分到 application 和 domain 两层；
  3. **domain 层提供一系列业务子域实例（subdomain），内聚各个业务模块的内部逻辑，对外隐藏细节；**
  4. application 层组装各个 subdomain，完成业务流程的编排，但不感知具体 subdomain 内部实现细节。

```mermaid
flowchart LR
    subgraph T[&quot;传统三层架构&quot;]
        direction TB
        T1[&quot;对外层&lt;br/&gt;Controller / Handler&quot;]
        T2[&quot;业务逻辑层&lt;br/&gt;Service&quot;]
        T3[&quot;数据访问层&lt;br/&gt;DAO / Repository / Mapper&quot;]
        T4[(&quot;MySQL / Redis&quot;)]

        T1 --&gt; T2
        T2 --&gt; T3
        T3 --&gt; T4
    end

    subgraph D[&quot;DDD 四层架构&quot;]
        direction TB
        D1[&quot;用户接口层&lt;br/&gt;Controller / API / RPC&quot;]
        D2[&quot;应用层&lt;br/&gt;Application Service / Use Case&quot;]
        D3[&quot;领域层&lt;br/&gt;Entity / Value Object&lt;br/&gt;Aggregate / Domain Service&quot;]
        D4[&quot;基础设施层&lt;br/&gt;Repository Impl / MQ / Cache&quot;]
        D5[(&quot;Database / External Services&quot;)]

        D1 --&gt; D2
        D2 --&gt; D3
        D2 --&gt; D4
        D3 --&gt; D4
        D4 --&gt; D5
    end

    T -. &quot;对比&quot; .-&gt; D

    style T1 fill:#E8F0FE,stroke:#5B8FF9
    style T2 fill:#E6F7EC,stroke:#52C41A
    style T3 fill:#FFF7E6,stroke:#FA8C16
    style T4 fill:#F9F0FF,stroke:#722ED1

    style D1 fill:#E8F0FE,stroke:#5B8FF9
    style D2 fill:#E6F7EC,stroke:#52C41A
    style D3 fill:#FFF1F0,stroke:#F5222D
    style D4 fill:#FFF7E6,stroke:#FA8C16
    style D5 fill:#F9F0FF,stroke:#722ED1
```

### 适用场景

![ddd](/api/v1/uploads/file/77963b7a931377ad4ab5ad6a9cd718aa_20260717111826.png)

如上图所示，DDD相对于传统架构而言，在初期的架构实现上存在更高的实现成本和项目复杂度，但是随着项目的迭代演进，其中存在的收益会愈发明显，**因而 DDD 模式主要适用于业务逻辑复杂、迭代演进频繁的大型业务系统。**

**因此我们需要做到在合适的场景中选择合适方案，而非一味迷信或者一味抵制，一些业务逻辑复杂、迭代演进频繁的大型业务系统是应用 DDD 的主战场，而对于简单的中小系统则不建议使用该模式。**

## 架构拆解

```mermaid
%%{init: {
  &quot;flowchart&quot;: {
    &quot;curve&quot;: &quot;linear&quot;,
    &quot;nodeSpacing&quot;: 45,
    &quot;rankSpacing&quot;: 55
  }
}}%%

flowchart TB

    subgraph Presentation[&quot;presentation 表现层&quot;]
        direction LR

        P1[&quot;Controller&quot;]
        P2[&quot;Handler / API&quot;]
        P3[&quot;Request / Response DTO&quot;]
        P4[&quot;...&quot;]

        P1 ~~~ P2 ~~~ P3 ~~~ P4
    end

    subgraph Application[&quot;application 应用层&quot;]
        direction LR

        A1[&quot;Application Service&quot;]
        A2[&quot;Use Case&quot;]
        A3[&quot;Command / Query&quot;]
        A4[&quot;...&quot;]

        A1 ~~~ A2 ~~~ A3 ~~~ A4
    end

    subgraph Domain[&quot;domain 领域层&quot;]
        direction LR

        D1[&quot;Aggregate / Entity&quot;]
        D2[&quot;Value Object&quot;]
        D3[&quot;Domain Service&quot;]
        D4[&quot;Repository Interface&quot;]
        D5[&quot;Domain Event&quot;]

        D1 ~~~ D2 ~~~ D3 ~~~ D4 ~~~ D5
    end

    subgraph Infrastructure[&quot;infrastructure 基础设施层&quot;]
        direction LR

        I1[&quot;Repository Impl&quot;]
        I2[&quot;DAO / ORM&quot;]
        I3[&quot;Redis / MQ&quot;]
        I4[&quot;RPC Client&quot;]
        I5[&quot;Database&quot;]

        I1 ~~~ I2 ~~~ I3 ~~~ I4 ~~~ I5
    end

    Presentation --&gt;|&quot;调用&quot;| Application
    Application --&gt;|&quot;调用领域能力&quot;| Domain
    Domain --&gt;|&quot;定义抽象，基础设施实现&quot;| Infrastructure

    classDef component fill:#ffffff,stroke:#222222,stroke-width:1.5px,color:#222222;

    class P1,P2,P3,P4 component;
    class A1,A2,A3,A4 component;
    class D1,D2,D3,D4,D5 component;
    class I1,I2,I3,I4,I5 component;

    style Presentation fill:#9fd2f4,stroke:#222222,stroke-width:2px
    style Application fill:#9fd2f4,stroke:#222222,stroke-width:2px
    style Domain fill:#9fd2f4,stroke:#222222,stroke-width:2px
    style Infrastructure fill:#9fd2f4,stroke:#222222,stroke-width:2px
```

如上图，DDD 四层架构层级包含了：

- presentation 层：渲染展示层，定位是 BFF（Backend for frontend），负责感知处理与前端对接的协议细节；
- application层：业务应用层，调用 domain 层各业务子域实例，完成业务流程编排。如果把 domain 层类比成一个积木市场的话，那么 application 层所做的事情就是“搭积木”——只需要按照预设图纸（业务需求）把积木拼接成对应的轮廓，而无需关心某个积木块内部的结构原理；
- domain 层：业务领域层，根据领域拆分出一系列业务子域实例（subdomain），在其中内聚该领域下的核心业务逻辑——作为一个又一个点状的积木组件，供 application 层使用；
- infrastructure 层：基础组件层，提供一系列基础能力，比如数据访问对象 DAO（Data access object），访问第三方系统的客户端 client，以及一系列通用能力。

除了 application 与 domain 层的拆分之外，在 DDD 架构中还存在几个要点：

- domain 层 repository 定义：domain 层定义了存储接口 repository（interface），定义好与业务数据底层存储交互的抽象接口，而具体实现则由 infrastructure 层中提供对应 dao 实例；
- infrastructure 层依赖倒置原则：infrastructrue 层逆向实现由上层定义的一系列通用能力的抽象接口，以此保证其实现细节不会侵入到上层模块，能够实现低成本替换。

&gt; DIP（Dependency Inversion Principle）依赖倒置原则核心思想是，高层模块不依赖于底层模块，二者都依赖于抽象接口，而抽象接口由高层定义，因此在依赖关系上属于底层依赖高层。

落地具体代码实现上，DDD四层结构对应的 package 依赖关系应该是：

- presentation 依赖于 application；
- application 依赖于 domain；
- infrastructure 则依赖于由其余三层（定义好的 interface）。

### Presentation

![presentation](/api/v1/uploads/file/2486923a98e77fd0740381d01acd1782_20260717113416.png)

presentation 层定位类似于传统三层结构中的 controller，其中需要：

- 定义 VO（view object），作为前后端交互的协议参数；
- 参数解析/转换/响应，解析和组装api请求与响应参数；
- 调用 application 层，执行业务流程（需要将 view object 转换为 application 层使用的 DTO）。

### Application

![application](/api/v1/uploads/file/3676d55f84497cbeadfc614c1b1b62fc_20260717113901.png)

application 层跨越不同业务子域 subdomain 完成整个业务流程的编排，该层只负责流程编排，而不关心某个业务领域内的具体逻辑。相对于 domain 层，application 应该是很薄的一层。

&gt; 如果说执行一个业务流程类似于写一本书，**那么 application 输出的内容就类似于一本书的目录**，按照执行顺序把各章各节的标题以及对应页码罗列出来，至于某个具体章节的内容，则需要根据页码跳转到对应位置（subdomain）来浏览更完整的上下文内容。

此外，在 application 层中需要完成对数据转换对象 DTO（Data transfer object）的定义，DTO 比较简单，用于在业务流程中传递，不包含具体业务逻辑。

### Domain

![domain](/api/v1/uploads/file/ad5f82e879a9c5d6b5b442eb37e50551_20260717120324.png)

domain 层是DDD架构的核心所在，在 DDD 模式落地实战过程中，决定成败的核心步骤就在于如何在 domain 层完成对业务领域的拆分，保证拆分出来的每个业务子域（subdomain）能完全内聚该领域内的业务逻辑，尽可能降低子域之间存在的互相依赖程度。

在 domain 层中，将各领域内的业务模型定义成 Entity 和 VO（value object），并且基于面向对象的思想，将相关的业务逻辑封装到类的成员方法中，以形成所谓的“充血模型”。

对于需要访问到的业务数据存储模型，子域中会定义出仓储接口 repository，并由 infrastructure 提供具体实现版本，这样能够对 domain 层屏蔽底层存储组件的具体实现细节，体现依赖倒置原则。

![domain](/api/v1/uploads/file/d4067b8f01c5ea966a202774bdadea5c_20260717120848.png)

domain 层中针对各子域在实现上设置“北向网关”和“南向网关”，本质上是两个防腐层代码：

- **北向网关：** application 层或者其他子域需要通过当前子域的北向网关来完成对该子域的请求，因此北向网关中会定义好对应当前子域的抽象接口；
- **南向网关：** 当前子域需要通过南向网关来实现对外部资源的请求，因此南向网关层中会定义好对应外部资源的抽象接口，**前文提及的业务数据仓储接口 repository interface 就是定义于此。**

随着项目的迭代演进，如何寻找将项目由单体服务拆分为微服务的拆分边界？

**在 DDD 模式下，如果对业务领域拆分得足够合理，那么我们可以沿着每个业务子域（subdomain）南北向网关的边界，将其拆分成一个个独立的微服务。**

### Infrastructure

![infra](/api/v1/uploads/file/2d0059c6b6b34a757c93f800bcb3f37c_20260717135259.png)

infrastructure 层作为基础能力层，核心功能是为其他层提供通用技术支持，也就是 TEG 在干的活，其封装了与外部资源的交互实现细节。通过依赖倒置原则，保证上层模块不会感知到 infrastructure 层具体实现，因此即便涉及底层组件的替换变更，对上层模块也是没有侵扰的。

![infra](/api/v1/uploads/file/d99fd5560a2c64e944eaf6400887c201_20260717135624.png)

## 其他概念

展开介绍 DDD 下各种模型前，需要首先探讨到有关“贫血模型”和“充血模型”的定义。

- **贫血模型（Anemic Domain Model）：** 作为数据传输的载体，只包含简单的属性和基本的访问器方法（getter、setter方法）而不包含业务逻辑，更偏向于面向过程的编程风格；
- **充血模型（Rich Domain Model）：** 该类对象不仅包含数据属性，还封装了与自身相关的业务逻辑，更符合面向对象的设计原则，强调对象的行为和状态的统一。

### DDD 模型

- VO（view object）视图对象：定义于 presentation 层的贫血模型，封装了展示层（前端）需要显示的数据；
- DTO（data transfer object）数据传输对象：定义于 application 层的贫血模型，包含多个属性和相应 getter/setter 方法，用于在展示层与应用层传输数据；
- Entity：定义于 domain 层的充血模型，是具有唯一标识的业务对象，其身份在整个业务流转的生命周期内保持不变。
- VO（value object）：定义于 domain 层的充血模型，是没有唯一标识的对象，其身份由其属性值而非唯一标识决定——有关 entity 与 value object 的差异，在下文详细说明；
- PO（persistent object）：定义于 infrastructure 层的贫血模型，是反映底层存储结构的持久化对象。

### VO、Entity 与 Aggregate

Entity 与 VO（value object）均定义于 domain 层，两者均是基于面向对象设计实现，根据领域职能归属，将业务逻辑挂载到成员方法中。两者的核心差异在于：

- VO 对应一系列没有唯一标识的静态属性集合，通过属性值标识其身份，本身没有生命周期的概念，一旦创建出来，其属性和状态即不可改变，属性值一旦发生更改，就代表产生出新的对象实例；
- Entity 对应一系列具有唯一标识的业务模型，具有状态或者生命周期的概念，其身份是持久的，即使对象的某些属性发生变化，其身份也不会改变。

在 domain 层，还有一个重要概念——聚合（Aggregate）。**Aggregate 用于将多个具有关联性的 entity 和 value object 组织在一起，确保聚合内的对象在业务处理流程中保持数据的一致性。**

对于 application 层而言，访问 domain 层时使用聚合后的 Aggregate，也能降低因为 entity 数量更多而导致的业务理解成本。

### Bounded context

最后要介绍的是 DDD 中的“限界上下文”（bounded context）。限界上下文与业务子域是具有强关联性的两个概念：

- **业务子域（Subdomain）：** 子域代表了一个特定的业务领域，具有自己专属独特的业务概念、规则和流程，其关注的是业务领域本身，与技术实现无关，更多地关注业务逻辑和规则；
- **限界上下文（Bounded Context）：** 限界上下文定义了业务领域的边界，保证领域内的模型含义与职责边界清晰一致。限界上下文属于针对业务子域在实现层面给出的解决方案，与技术实现密切相关，需要考虑如何在技术层面实现和管理模型。

在理想情况下，一个业务子域和一个限界上下文是一一对应的，但将理论落到实地之后还是不可避免存在差异部分，可能存在业务子域与限界上下文之间存在一对多或者多对一的情况。

![context](/api/v1/uploads/file/5c18ef72771564b7f43c497dc507aeab_20260717142046.png)

## DDD 项目基本结构

```
project/
├── cmd/
│   └── server/
│       └── main.go                 # 程序启动入口
│
├── internal/
│   ├── interfaces/                 # 用户接口层 Presentation
│   │   ├── http/
│   │   │   ├── handler/
│   │   │   │   ├── order_handler.go
│   │   │   │   └── user_handler.go
│   │   │   ├── dto/
│   │   │   │   ├── order_request.go
│   │   │   │   └── order_response.go
│   │   │   └── router.go
│   │   ├── grpc/
│   │   │   └── order_server.go
│   │   └── consumer/
│   │       └── order_consumer.go
│   │
│   ├── application/                # 应用层 Application
│   │   ├── order/
│   │   │   ├── service.go
│   │   │   ├── command.go
│   │   │   └── query.go
│   │   └── user/
│   │       └── service.go
│   │
│   ├── domain/                     # 领域层 Domain
│   │   ├── order/
│   │   │   ├── aggregate.go
│   │   │   ├── entity.go
│   │   │   ├── value_object.go
│   │   │   ├── service.go
│   │   │   ├── repository.go
│   │   │   └── event.go
│   │   └── user/
│   │       ├── entity.go
│   │       ├── repository.go
│   │       └── service.go
│   │
│   ├── infrastructure/             # 基础设施层 Infrastructure
│   │   ├── persistence/
│   │   │   ├── mysql/
│   │   │   │   ├── model/
│   │   │   │   │   └── order_po.go
│   │   │   │   ├── order_repository.go
│   │   │   │   └── user_repository.go
│   │   │   └── redis/
│   │   │       └── order_cache.go
│   │   ├── messaging/
│   │   │   └── kafka/
│   │   │       └── order_publisher.go
│   │   ├── client/
│   │   │   ├── payment_client.go
│   │   │   └── inventory_client.go
│   │   └── transaction/
│   │       └── gorm_transaction.go
│   │
│   ├── bootstrap/                  # 依赖组装
│   │   ├── app.go
│   │   ├── database.go
│   │   └── wire.go
│   │
│   └── shared/                     # 项目内部通用能力
│       ├── errs/
│       ├── idgen/
│       └── clock/
│
├── migrations/                     # 数据库迁移
├── configs/                        # 配置文件
├── api/                            # OpenAPI / Proto
├── go.mod
└── go.sum
```

```mermaid
flowchart TB
    Interfaces[&quot;接口层&lt;br/&gt;Handler / Controller / DTO&quot;]
    Application[&quot;应用层&lt;br/&gt;Application Service / Use Case&quot;]
    Domain[&quot;领域层&lt;br/&gt;Entity / Aggregate / Repository Interface&quot;]
    Infrastructure[&quot;基础设施层&lt;br/&gt;Repository Impl / GORM / Redis / MQ&quot;]

    Interfaces --&gt; Application
    Application --&gt; Domain
    Infrastructure -. &quot;实现领域层接口&quot; .-&gt; Domain
```

对于更重的项目，可以按照限界上下文拆分，而不一定要把所有业务都放在一个 domain 和 application 文件夹中：

```
internal/
├── order/
│   ├── interfaces/
│   │   └── http/
│   │       └── handler.go
│   ├── application/
│   │   ├── create_order.go
│   │   └── cancel_order.go
│   ├── domain/
│   │   ├── order.go
│   │   ├── order_item.go
│   │   ├── repository.go
│   │   └── service.go
│   └── infrastructure/
│       ├── mysql_repository.go
│       └── payment_client.go
│
├── user/
│   ├── interfaces/
│   ├── application/
│   ├── domain/
│   └── infrastructure/
│
└── bootstrap/
    └── app.go
```

---

内容参考自小徐先生，本篇仅作为理论指引，后面我应该会单出一篇介绍我们的具体项目案例，我们目前的微服务基本都是基于 DDD 架构设计开发。

</content></entry><entry><title>浅谈分布式事务#2</title><link href="https://www.bytecho.net/archives/distributed_transaction_tcc.html" /><id>https://www.bytecho.net/archives/distributed_transaction_tcc.html</id><updated>2026-07-16T09:00:00.000Z</updated><summary>由于最近在开发微服务，之前一直在优化单体的事务，多系统之间的协调后续大概率会用到分布式事务，这里先做个基本了解，避免走弯路。本文是对分布式事务解决方案的进一步介绍：TCC。</summary><content type="html">## 续前文

前文已经介绍了事务的基本概念，引出了分布式事务的具体场景和存在的难点。

基于此，之前提出了基于事务消息的解决方案，它能够利用消息队列 At lease once 的特性达到接近百分之百的消费可靠度，但是这个方案依然存在一个无法接近且严重的问题：依然无法完成下游失败上游回滚的需求。**这一点是 TX Msg 中存在的最大的局限性。**

所以我们引出一个全新的分布式事务解决方案：TCC。

## TCC 简述

TCC，全称 Try-Confirm-Cancel，指的是将一笔状态数据的修改操作拆分成两个阶段：

- 第一个阶段是 Try，指的是先对资源进行锁定，资源处于中间态但不处于最终态；
- 第二个阶段分为 Confirm 和 Cancel，指的是在 Try 操作的基础上，真正提交这次修改操作还是回滚这次变更操作。

```mermaid
sequenceDiagram
autonumber
actor Client as 客户端
participant TM as TCC事务协调器
participant A as 服务A
participant B as 服务B

Client-&gt;&gt;TM: 发起分布式事务

rect rgb(235, 245, 255)
    Note over TM,B: 第一阶段：Try 资源预留
    TM-&gt;&gt;A: Try：检查并预留资源
    A--&gt;&gt;TM: Try成功

    TM-&gt;&gt;B: Try：检查并预留资源
    B--&gt;&gt;TM: Try成功
end

alt 所有 Try 都成功
    rect rgb(235, 255, 235)
        Note over TM,B: 第二阶段：Confirm 正式提交
        TM-&gt;&gt;A: Confirm：正式使用预留资源
        A--&gt;&gt;TM: Confirm成功

        TM-&gt;&gt;B: Confirm：正式使用预留资源
        B--&gt;&gt;TM: Confirm成功
    end

    TM--&gt;&gt;Client: 事务成功

else 任意 Try 失败
    rect rgb(255, 240, 240)
        Note over TM,B: 第二阶段：Cancel 释放资源
        TM-&gt;&gt;A: Cancel：释放已预留资源
        A--&gt;&gt;TM: Cancel成功

        TM-&gt;&gt;B: Cancel：释放已预留资源
        B--&gt;&gt;TM: Cancel成功
    end

    TM--&gt;&gt;Client: 事务失败
end
```

在 TCC 分布式事务架构中，包含三类角色：

- 应用方 Application：指的是需要使用到分布式事务能力的应用方。
- TCC 组件 TCC Component：指的是需要完成分布式事务中某个特定步骤的子模块. 这个模块通常负责一些状态数据的维护和更新操作，需要对外暴露出 Try、Confirm 和 Cancel 三个 API：
  - Try：锁定资源，通常以类似【冻结】的语义对资源的状态进行描述，保留后续变化的可能性；
  - Confirm：对 Try 操作进行二次确认，将记录中的【冻结】态改为【成功】态；
  - Cancel：对 Try 操作进行回滚，将记录中的【冻结】状消除或者改为【失败】态，其底层对应的状态数据会进行回滚。
- 事务协调器 TX Manager：负责统筹分布式事务的执行：
  - 实现 TCC Component 的注册管理功能；
  - 负责和 Application 交互，提供分布式事务的创建入口，给予 Application 事务执行结果的响应；
  - 串联 Try -&gt; Confirm/Cancel 的两阶段流程. 在第一阶段中批量调用 TCC Component 的 Try 接口，根据其结果，决定第二阶段是批量调用 TCC Component 的 Confirm 接口还是 Cancel 接口。

## TCC 案例分析

下面我们引入一个具体的分布式事务场景问题，并通过 TCC 架构加以实现，帮助大家进一步提高对 TCC 分布式事务方案的认识。

现在假设我们需要维护一个电商后台系统，需要处理来自用户的支付请求。每当有一笔支付请求到达，我们需要执行下述三步操作，并要求其前后状态保持一致性：

- 在订单模块中，创建出这笔订单流水记录
- 在账户模块中，对用户的账户进行相应金额的扣减
- 在库存模块中，对商品的库存数量进行扣减

上面这三步操作分别需要对接订单、账户、库存三个不同的子模块，底层的状态数据是基于不同的数据库和存储组件实现的，并且我们这套后台系统是基于当前流行的微服务架构实现的，这三子个模块本身对应的就是三个相互独立的微服务，因此如何实现在一笔支付请求处理流程中，使得这三笔操作对应的状态数据始终保持高度一致性，就成了一个非常具有技术挑战性的问题。

首先，我们基于 TCC 的设计理念，**将订单模块、账户模块、库存模块分别改造成三个 TCC Component，每个 Component 对应需要暴露出 Try、Confirm、Cancel 三个 API，对应于冻结资源、确认更新资源、回滚解冻资源三个行为。**

同时，为了能够简化后续 TX Manager 和 Application 之间的交互协议，每个 TCC Component 会以插件的形式提前注册到 TX Manager 维护的组件市场 Component Market 中，并提前声明好一个全局唯一键与之进行映射关联。

由于每个 TCC Component 需要支持 Try 接口的锁定操作，因此其中维护的数据需要在明细记录中拆出一个用于标识 “冻结” 状态的标签，或者在状态机中拆出一个 “冻结” 状态。

**最终在第二阶段的 Confirm 或者 Cancel 请求到达时，再把 ”冻结“ 状态调整为 ”成功“ 或者 ”失败“ 的终态。**

基于 TCC 架构实现后，对应于一次支付请求的分布式事务处理流程：

- Application 调用 TX Manager 的接口，创建一轮分布式事务：
  
  - Application 需要向 TX Manager 声明，这次操作涉及到的 TCC Component 范围，包括订单组件、账户组件和库存组件；
  - Application 需要向 TX Manager 提前传递好，用于和每个 TCC Component 交互的请求参数（ TX Manager 调用 Component Try 接口时需要传递）。
- TX Manager 需要为这笔新开启的分布式事务分配一个全局唯一的事务主键 Transaction ID；
- TX Manager 将这笔分布式事务的明细记录添加到事务日志表中；
- TX Manager 分别调用订单、账户、库存组件的 Try 接口，试探各个子模块的响应状况，并尝试锁定对应的资源；
- TX Manager 收集每个 TCC Component Try 接口的响应结果，根据结果决定下一轮的动作是 Confirm 还是 Cancel：
  
  - 倘若三笔 Try 请求中，有任意一笔未请求成功：
    
    - TX Manager 给予 Application 事务执行失败的 Response；
    - TX Manager 批量调用订单、账户、库存 Component 的 Cancel 接口，回滚释放对应的资源
    - 在三笔 Cancel 请求都响应成功后，TX Manager 在事务日志表中将这笔事务记录置为 [失败] 状态。
  - 倘若三笔 Try 请求均响应成功了：
    
    - TX Manager 给予 Application 事务执行成功的 ACK；
    - TX Manager 批量调用订单、账户、库存 Component 的 Confirm 接口，使得对应的变更记录实际生效；
    - 在三笔 Confirm 请求都响应成功后，TX Manager 将这笔事务日志置为 [成功] 状态。

在上述流程中，有一个很重要的环节需要补充说明：

首先，TCC 本质上是一个两阶段提交（Two Phase Commitment Protocol，2PC）的实现方案，分为 Try 和 Confirm/Cancel 的两个阶段：

- Try 操作的容错率是比较高的，原因在于有人帮它兜底. Try 只是一个试探性的操作，不论成功或失败，后续可以通过第二轮的 Confirm 或 Cancel 操作对最终结果进行修正；
- Confirm/Cancel 操作是没有容错的，倘若在第二阶段出现问题，可能会导致 Component 中的状态数据被长时间”冻结“或者数据状态不一致的问题。

针对于这个场景，TCC 架构中采用的解决方案是：**在第二阶段中，TX Manager 轮询重试 + TCC Component 幂等去重。** 通过这两套动作形成的组合拳，保证 Confirm/ Cancel 操作至少会被 TCC Component 执行一次。

首先，针对于 TX Manager 而言：

- **需要启动一个定时轮询任务；**
- **对于事务日志表中，所有未被更新为 [成功/失败] 对应终态的事务，需要摘出进行检查；**
- 检查时查看其涉及的每个组件的 Try 接口的响应状态以及这笔事务的持续时长；
- 倘若事务应该被置为 [失败]（存在某个 TCC Component Try 接口请求失败），但状态却并未更新，说明之前批量执行 Cancel 操作时可能发生了错误. 此时需要补偿性地批量调用事务所涉及的所有 Component 的 Cancel 操作，待所有 Cancel 操作都成功后，将事务置为 [失败] 状态；
- 倘若事务应该被置为 [成功]（所有 TCC Component Try 接口均请求成功），但状态却并未更新，说明之前批量执行 Confirm 操作时可能发生了错误. 此时需要补偿性地批量调用事务所涉及的所有 Component 的 Confirm 操作，待所有 Confirm 操作都成功后，将事务置为 [成功] 状态；
- 倘若事务仍处于 [进行中] 状态（TCC Component Try 接口请求未出现失败，但并非所有 Component Try 接口都请求成功），则检查事务的创建时间，倘若其耗时过长，同样需要按照事务失败的方式进行处理。

需要注意，在 TX Manager 轮询重试的流程中，针对下游 TCC Component 的 Confirm 和 Cancel 请求只能保证 At least once 的语义，换句话说，这部分请求是可能出现重复的。

因此，**在下游 TCC Component 中，需要在接收到 Confirm/Cancel 请求时，执行幂等去重操作。** 幂等去重操作需要有一个唯一键作为去重的标识，这个标识键就是 TX Manager 在开启事务时为其分配的全局唯一的 Transaction ID，它既要作为这项事务在事务日志表中的唯一键，同时在 TX Manager 每次向 TCC Component 发起请求时，都要携带上 Transaction ID。

## TX Manager

通过上面这个实际案例的流程剖析后，相信大家已经对这套 TCC 架构有了一定的理解。接下来我们再从 TX Manager 和 TCC Component 职责领域划分的视角出发，进行一轮梳理。

首先针对于事务协调器 TX Manager，其核心要点包括：

- 暴露出注册 TCC Component 的接口，进行 Component 的注册和管理；
- 暴露出启动分布式事务的接口，作为和 Application 交互的唯一入口，并基于 Application 事务执行结果的反馈；
- 为每个事务维护全局唯一的 Transaction ID，基于事务日志表记录每项分布式事务的进展明细；
- 串联 Try——Confirm/Cancel 的两阶段流程，根据 Try 的结果，推进执行 Confirm 或 Cancel 流程；
- 持续运行轮询检查任务，推进每个处于中间态的分布式事务流转到终态。

## TCC Component

对于 TCC Component 而言，其需要关心和处理的工作包括：

- 暴露出 Try、Confirm、Cancel 三个入口，对应于 TCC 的语义；
- 针对数据记录，新增出一个对应于 Try 操作的中间状态枚举值；
- 针对于同一笔事务的重复请求，需要执行幂等性校验；
- 需要支持空回滚操作。即针对于一笔新的 Transaction ID，在没收到 Try 的前提下，若提前收到了 Cancel 操作，也需要将这个信息记录下来，但不需要对真实的状态数据发生变更。

下面针对最后一点提到的空回滚操作，进一步加以说明：

这个空回滚机制本质上是为了解决 TCC 流程中出现的悬挂问题，下面我们举个具体例子加以说明：

- TX Manager 在向 Component A 发起 Try 请求时，由于出现网络拥堵，导致请求超时；
- TX Manager 发现存在 Try 请求超时，将其判定为失败，因此批量执行 Component 的 Cancel 操作；
- **Component A 率先收到了后发先至的 Cancel 请求；**
- 过了一会儿，之前阻塞在网络链路中的 Try 请求也到达了 Component A。

从执行逻辑上，Try 应该先于 Cancel 到达和处理，然而在事实上，由于网络环境的不稳定性，请求到达的先后次序可能颠倒。在这个场景中，**Component A 需要保证的是，针对于同一笔事务，只要接受过对应的 Cancel 请求，之后到来的 Try 请求需要被忽略。** 这就是 TCC Component 需要支持空回滚操作的原因所在。

## UML

```mermaid
classDiagram
    class TxManager {
        -TXStore txStore
        -TCCRegistryCenter registryCenter

        +Transaction(ctx, req) bool, error
        +Register(component) error
        +Stop()
        -twoPhaseCommit(ctx, txID, components) bool, error
        -batchAdvanceProgress(transactions)
        -run()
    }

    class TCCRegistryCenter {
        &lt;&lt;interface&gt;&gt;
        +Register(component) error
        +GetComponents(ids) TCCComponent[], error
    }

    class RegistryCenter {
        -RWMutex mux
        -map~string,TCCComponent~ components

        +Register(component) error
        +GetComponents(ids) TCCComponent[], error
    }

    class TXStore {
        &lt;&lt;interface&gt;&gt;
        +CreateTX(ctx, components) string, error
        +TXUpdate(ctx, txID, success) error
        +TXSubmit(ctx, txID, success) error
        +GetHangingTXs(ctx) Transaction[], error
        +GetTX(ctx, txID) Transaction, error
        +Lock(ctx, duration) error
        +Unlock(ctx) error
    }

    class TCCComponent {
        &lt;&lt;interface&gt;&gt;
        +ID() string
        +Try(ctx, req) response, error
        +Confirm(ctx, txID) response, error
        +Cancel(ctx, txID) response, error
    }

    TxManager --&gt; TXStore : holds
    TxManager --&gt; TCCRegistryCenter : holds

    RegistryCenter ..|&gt; TCCRegistryCenter : implements
    RegistryCenter &quot;1&quot; --&gt; &quot;0..*&quot; TCCComponent : registers

    TXStore ..&gt; TCCComponent : uses
```

## 优劣势分析

**优势：**

* TCC 可以称得上是真正意义上的分布式事务：任意一个 Component 的 Try 操作发生问题，都能支持事务的整体回滚操作；
* TCC 流程中，分布式事务中数据的状态一致性能够趋近于 100%，这是因为第二阶段 Confirm/Cancel 的成功率是很高的，原因在于如下三个方面：
  * TX Manager 在此前刚和 Component 经历过一轮 Try 请求的交互并获得了成功的 ACK，因此短时间内，Component 出现网络问题或者自身节点状态问题的概率是比较小的；
  * TX Manager 已经通过 Try 操作，让 Component 提前锁定了对应的资源，因此确保了资源是充分的，且由于执行了状态锁定，出现并发问题的概率也会比较小；
  * TX Manager 中通过轮询重试机制，保证了在 Confirm 和 Cancel 操作执行失败时，也能够通过重试机制得到补偿。

**劣势：**

* TCC 分布式事务中，涉及的状态数据变更只能趋近于最终一致性，无法做到即时一致性；
* 事务的原子性只能做到趋近于 100%，而无法做到真正意义上的 100%，原因就在于第二阶段的 Confirm 和 Cancel 仍然存在极小概率发生失败，即便通过重试机制也无法挽救。这部分小概率事件，就需要通过人为介入进行兜底处理；
* TCC 架构的实现成本是很高的，需要所有子模块改造成 TCC 组件的格式，且整个事务的处理流程是相对繁重且复杂的，因此在针对数据一致性要求不那么高的场景中，通常不会使用到这套架构。

**事实上，上面提到的第二点劣势也并非是 TCC 方案的缺陷，而是所有分布式事务都存在的问题，由于网络请求以及第三方系统的不稳定性，分布式事务永远无法达到 100% 的原子性。**

后面我应该会跟着小徐进行王者级手搓 TCC SDK！

</content></entry><entry><title>浅谈分布式事务#1</title><link href="https://www.bytecho.net/archives/distributed_transaction.html" /><id>https://www.bytecho.net/archives/distributed_transaction.html</id><updated>2026-07-15T10:25:33.000Z</updated><summary>由于最近在开发微服务，之前一直在优化单体的事务，多系统之间的协调后续大概率会用到分布式事务，这里先做个基本了解，避免走弯路。</summary><content type="html">## 事务 Transaction

说简单点就是满足下面四种特性（ACID）的一段特殊的执行程序，就被称为事务。

* ​**原子性 Atomicity**​：事务中的操作要么全部成功，要么全部失败。
* ​**一致性 Consistency**​：事务执行前后，数据都满足业务约束。
* ​**隔离性 Isolation**​：并发事务之间尽量互不影响。
* ​**持久性 Durability**​：事务提交后，数据修改会被永久保存。

当事务的影响范围局限在一个关系型数据库范围内时，很多时候上述四项性质是能够水到渠成地得到实现的，但是倘若事务涉及修改的对象是**跨数据库甚至跨服务跨存储组件时**，这个问题就开始变得复杂了。

## 具体场景问题

假设我们在维护一个电商后台系统，每当在处理一笔来自用户创建订单的请求时，需要执行两步操作：

- 从账户系统中，扣减用户的账户余额
- 从库存系统中，扣减商品的剩余库存

从业务流程上来说，这个流程需要保证具备事务的原子性，即两个操作需要能够一气呵成地完成执行，要么同时成功，要么同时失败，不能够出现数据状态不一致的问题，比如发生从用户账户扣除了金额但商品库存却扣减失败的问题。

然而从技术流程上来讲，两个步骤是相对独立的两个操作，底层涉及到的存储介质也是相互独立的，因此无法基于本地事务的实现方式。

在我这个从来没有处理过分布式场景的视角下来看，其实很难在短时间能够想明白这种场景下的解决方案，对于这类问题，我们通常是调研行业内的成熟方案，既能学习，又确保稳定与正确性。

**但对于这些场景，我们需要有以下的基本认知：**

- 在分布式事务中，我们谈到的数据状态一致性，指的是数据的最终一致性，而非数据的即时一致性，因为即时一致性通常是不切实际的；
- 没有分布式事务能够保证数据状态具备百分之百的一致性，根本原因就在于网络环境和第三方系统的不稳定性。

在这样的预期下，我们再去寻找合理的解决方案。

## 事务消息方案 (TX Msg)

一类偏狭义的分布式事务解决方案是基于消息队列 Message Queue (MQ) 实现的事务消息 Transaction Message。之所以称之为狭义的分布式事务解决方案，是因为这个方案依然会存在一系列的问题，后文详述。

### RocketMQ

&gt; https://github.com/apache/rocketmq
&gt; https://rocketmq.apache.org/docs/4.x/producer/06message5/

RocketMQ 是阿里基于 Java 实现并托管于 Apache 基金会的王者级开源消息队列组件，其中事务消息 TX Msg 也是 RocketMQ 现有的一项能力。

![TX Msg](https://rocketmq.apache.org/assets/images/%E4%BA%8B%E5%8A%A1%E6%B6%88%E6%81%AF2-2673a99678f13a471b8fc0bd4ab3bf3a.png)

### 基于 MQ 实现分布式事务

我们知道在 MQ 组件中，通常能够为我们保证的一项能力是：投递到 MQ 中的消息能至少被下游消费者 Consumer 消费到一次，即所谓的 At least once 语义。

基于此，**MQ 组件能够保证消息不会在消费环节丢失，但是无法解决消息的重复性问题。**

因此，倘若我们需要追求精确消费一次的目标，则下游的 Consumer 还需要基于消息的唯一键执行幂等去重操作，在 At least once 的基础上过滤掉重复消息，最终达到 Exactly once 的语义。

该问题也在我最近实习中开发的 IM 微服务得到体现，基于 Outbox 事务 + Kafka MQ 实现的消息链路就将存在上述问题。一条消息的发出涉及一条很长的链路：

```
客户端 -&gt; IM 微服务 -&gt; 用户中心鉴权及关系验证 -&gt; 数据库事务写入 Outbox -&gt; 异步投放 Outbox Event 至 Kafka -&gt; 异步消费 Kafka 消息 -&gt; 推送消息至客户端
```

**目前我们简单的通过幂等键来解决消息可能被重复消费的问题。**

依赖于 MQ 中 At least once 的性质，完全可以简单认为，只要把一条消息成功投递到 MQ 组件中，它就一定被下游 Consumer 消费，至少不会发生消息丢失的问题。

倘若我们需要执行一个分布式事务，事务流程中包含需要在服务 A 中执行的 [动作 I] 以及需要在服务 B 中执行的 [动作 II]，此时可以基于如下思路串联流程：

1. 以服务 A 作为 MQ 生产方 producer，服务 B 作为 MQ 消费方 Consumer；
2. 服务 A 首先在执行 [动作 I]，执行成功后往 MQ 中投递消息，驱动服务 B 执行 [动作 II]；
3. 服务 B 消费到消息后，完成 [动作 II] 的执行。

对上述流程进行总结，其具备如下优势：

- 服务 A 和服务 B 通过 MQ 组件实现异步解耦，从而提高系统处理整个事务流程的吞吐量；
- 当服务 A 执行 [动作 I] 失败后，可以选择不投递消息从而熔断流程，因此保证不会出现 [动作 II] 执行成功，而 [动作 I] 执行失败的不一致问题；
- 基于 MQ At least once 的语义，服务 A 只要成功消息的投递，就可以相信服务 B 一定能消费到该消息，至少服务 B 能感知到 [动作 II] 需要执行；
- 依赖于 MQ 消费侧的 ACK 机制，可以实现服务 B 有限轮次的重试能力，即当服务 B 执行 [动作 II]  失败后，可以给予 MQ Bad ACK，从而通过消息重发的机制实现 [动作 II] 的重试，提高 [动作 II] 的执行成功率。

与之相对的，上述流程也具备如下几项局限性：

- **问题 1**：服务 B 消费到消息执行 [动作 II] 可能发生失败，即便依赖于 MQ 重试也无法保证动作一定能执行成功，此时缺乏令服务 A 回滚 [动作 I] 的机制。因此很可能出现 [动作 I] 执行成功，而 [动作 II] 执行失败的不一致问题；
- **问题 2**：在这个流程中，服务 A 需要执行的操作有两步：执行 [动作 I] 和投递消息，这两个步骤本质上也无法保证原子性，即可能出现服务 A 执行 [动作 I] 成功，而投递消息失败的问题。

**针对问题 1 是无能为力的，因为这个问题本身就脱离于事务消息的领域范畴之外，需要通过另一类分布式事务的实现方案加以解决。**

而针对于问题 2 的解决思路，则正是本节所重点探讨的话题。

### 本地事务+消息投递

不难想到有两种实现路径：

1. 先执行本地事务，后执行消息投递；
2. 先执行消息投递，后执行本地事务。

其各自的优缺点也十分明显，如果先执行本地事务，成功后再投递消息，虽然保证了如果下游收到消息，上游一定执行成功，但却没法保证上游执行成功，下游一定能收到消息。**一旦消息持续无法送出，那么回滚已经提交的本地事务，代价是非常高的。**

如果先投消息，再执行本地事务，问题也很明显，消息一经投递，可能会很快被下游所消费，因为在 MQ 链路中，通常我们都会使用多个 Consumer 并行对某个 Topic 进行消费，以保证消息的吞吐量，那在这样的场景下，**一旦后续事务执行失败，那么已投递的消息或许早已消费，覆水难收了。**

被小徐先生说中了，针对于以上问题，我们能够想到一个很简单，看似很完美的做法（Codex 也会犯这个错误）：

- 首先 begin transaction，开启本地事务；
- 在事务中，执行本地状态数据的更新；
- 完成数据更新后，不立即 commit transaction；
- **执行消息投递操作；**
- 倘若消息投递成功，则 commit transaction；
- 倘若消息投递失败，则 rollback transaction。

这个流程乍一看没啥毛病，重复利用了本地事务回滚的能力，解决了本地修改操作成功、消息投递失败后本地数据修正成本高的问题。

**但其实仔细一想就会发现问题很严重，投递消息的过程一般涉及网络或与第三方组件的 I/O 操作，而这类操作一般是比较耗时的，这会让本地事务时间变长，引发长时间锁占用等风险。**

此外，不能保证事务一定可以成功 Commit，虽然事务可以回滚，但我们投递的消息依然无法回滚。

### 事务消息的原理

我们以 RocketMQ 中 TX Msg 的实现方案为例展开介绍。首先，TX Msg 能保证我们做到在本地事务执行成功的情况下，后置的投递消息操作能以接近百分之百的概率被发出，其实现的核心流程为：

1. 生产方 Producer 首先向 RocketMQ 生产一条**半事务消息**，此消息处于中间态，会暂存于 RocketMQ 不会被立即发出；
2. Producer 执行本地事务；
3. 如果本地事务执行成功，Producer 直接提交本地事务，并且向 RocketMQ 发出一条确认消息；
4. 如果本地事务执行失败，Producer 向 RocketMQ 发出一条回滚指令；
5. 倘若 RocketMQ 接收到确认消息，则会执行消息的发送操作，供下游消费者 Consumer 消费；
6. 倘若 RocketMQ 接收到回滚指令，则会删除对应的半事务消息，不会执行实际的消息发送操作；
7. 在 RocketMQ 侧，针对半事务消息会有一个轮询任务，倘若半事务消息一直未收到来自 Producer 侧的二次确认，则 RocketMQ 会持续主动询问 Producer 侧本地事务的执行状态，从而引导半事务消息走向终态。

在 TX Msg 的实现流程中，能够保证前文谈及的各种 Bad case 都能被很好地消化：

- 倘若本地事务执行失败，则 Producer 会向 RocketMQ 发出删除半事务消息的回滚指令，因此保证消息不会被发出；
- 倘若本地事务执行成功， 则 Producer 会向 RocketMQ 发出事务成功的确认指令，因此消息能够被正常发出；
- 倘若 Producer 端在发出第二轮的确认或回滚指令前发生意外状况，导致第二轮结果指令缺失，则 RocketMQ 会基于自身的轮询机制主动询问本地事务的执行状况，最终帮助半事务消息推进进度。

### 事务消息的局限性

来总结梳理一下 TX Msg 中存在的几项局限性：

* **流程高度抽象：** TX Msg 把流程抽象成本地事务+投递消息两个步骤。然而在实际业务场景中，分布式事务内包含的步骤数量可能很多，因此就需要把更多的内容更重的内容糅合在所谓的“本地事务”环节中，上游 Producer 侧可能会存在比较大的压力；
* **不具备逆向回滚能力：** 倘若接收消息的下游 Consumer 侧执行操作失败，此时至多只能依赖于 MQ 的重放机制通过重试动作的方式提高执行成功率，但是无法从根本上解决下游 Consumer 操作失败后回滚上游 Producer 的问题。**这一点正是 TX Msg 中存在的最大的局限性。**

关于上面第二点，我们知道，并非所有动作都能通过简单的重试机制加以解决。

打个比方，倘若下游是一个库存管理系统，而对应商品的库存在事实上已经被扣减为 0，此时无论重试多少次请求都是徒然之举，这就是一个客观意义上的失败动作。

而遵循正常的事务流程，后置操作失败时，我们应该连带前置操作一起执行回滚，然而这部分能力在 TX Msg 的主流程中并没有予以体现。

要实现这种事务的逆向回滚能力，就必然需要构筑打通一条由下游逆流而上回调上游的通道，这一点并不属于 TX Msg 探讨的范畴。

针对于 TX Msg 中存在的问题，我们需要引入另一种分布式事务解决方案：Try-Confirm-Cancel (TCC)。

具体的，将在后续篇幅中讨论，毕竟没人愿意一次看很长的内容，我也一样，循序渐进挺好的。

---

**内容参考自小徐先生**，几年前刚入门 Golang 时就在看他，现在补充知识空白依然看他 😆，他的内容一眼就能和那些培训班级别的垃圾资源区分开。

</content></entry><entry><title>聊聊大学里面的奇葩</title><link href="https://www.bytecho.net/archives/260715.html" /><id>https://www.bytecho.net/archives/260715.html</id><updated>2026-07-15T08:24:24.479Z</updated><summary>实习时接到导师电话，说是本科答辩那边有点问题，然后导师给我具体截了一下微信聊天记录，让我添加一下对应的负责人（我是代替导师做答辩秘书的）。搞笑的场景来了...</summary><content type="html">### 答辩秘书一事

实习时接到导师电话，说是本科答辩那边有点问题，然后导师给我具体截了一下微信聊天记录，让我添加一下对应的负责人（我是代替导师做答辩秘书的）。

搞笑的场景来了，这人对我导师就是客客气气地询问，我加上她之后就是连续的几个质问，搞的好像默认是我的工作失误一样，学生没有成绩总表关我什么事，我通知到位了，也询问了对应的老师，从答辩开始到下发通知再到返还答辩材料形成了闭环，学生不去拿怪我咯？难道我还要给他送到宿舍？

这就是大学的行政岗吗？信息不对等也就算了，整个本科答辩流程毫无规范性，哪怕是经验丰富的老师都一直在群里询问各种问题，可见我本科毕设导师骂得真没毛病，这些人就是养来端茶送水的。最主要的是这群人对自己能力没有基本的认识，还喜欢欺软怕硬，研究生就是软呗。

再提一嘴，学院实验室管理办公室的某位男士，我把材料放办公室等答辩专家出差回来签字，专家签好字之后我立刻就打算去拿回来，我一进门那货就对着我在那冷笑，悄咪咪来一句“拿过来就不知道拿走了是吧？”

我：？，我吊都没吊他我就走了，算了，我不想骂人。我帮我导师是应该的，因为人家也给我提供了不少帮助，但我没有义务对这些蛀虫好脸色。这社会真的我就差鬼没见过了...

### More

[TODO] 我相信后续还会发生有意思的事情，持续更新！

</content></entry><entry><title>MySQL 加锁机制分析与死锁排查</title><link href="https://www.bytecho.net/archives/innodb-locking-deadlock.html" /><id>https://www.bytecho.net/archives/innodb-locking-deadlock.html</id><updated>2026-07-13T03:30:00.000Z</updated><summary>实习期间在开发 IM 微服务时遇到的一些数据库死锁问题，在此进行简单记录。本文介绍了常见的 MySQL 锁，分析了加锁机制和常见的导致死锁的业务场景。造成这些问题的场景其实会有很多，还会持续更新！</summary><content type="html">## 前置知识

### 锁的类型

* 排他锁（Exclusive Lock，X 锁）：又称写锁。当事务持有 X 锁时，可以读取和修改数据，但会阻塞其他事务获取相同数据的 S 锁和 X 锁。`INSERT`, `UPDATE`, `DELETE` 等数据修改操作会自动获取 X 锁。可以通过 `SELECT ... FOR UPDATE` 显式加锁；
* 共享锁（Shared Lock，S 锁）：又称读锁。当事务持有 S 锁时，可以读取数据，但不能修改数据。它允许其他事务同时获取相同数据的 S 锁，但会阻止其他事务获取 X 锁。可以通过 `SELECT ... LOCK IN SHARE MODE` 显式加锁。

即 X 锁与 S 锁之间的关系如下：

|      | X 锁 | S 锁 |
| ---- | ---- | ---- |
| X 锁 | 冲突 | 冲突 |
| S 锁 | 冲突 | 兼容 |

### 常见的锁

#### Intention Locks

Intention Locks 即意向锁，这个锁是**表级别的锁**，用于标记事务**后续**对表中某行所需的锁类型（共享锁或独占锁），**不会阻塞任何操作**，仅对全表请求例外。一共有两类意向锁：

* IS (Intention Shared)：事务计划在该表上获取一个或多个行级共享锁
  * `SELECT ... LOCK IN SHARE MODE`、某些读锁场景
* IX (Intention Exclusive)：事务计划在该表上获取一个或多个行级排他锁
  * `UPDATE/DELETE/INSERT`、`SELECT ... FOR UPDATE` 等

意向锁的主要是为了表明有人正在锁定某行，或即将锁定表中的某行。**这样数据库可以在表级别快速判断是否可以授予某些表级锁，而不用遍历所有行锁。**

|        |   IS |   IX | S 表锁 | X 表锁 |
| ------ | ---: | ---: | -----: | -----: |
| IS     | 兼容 | 兼容 |   兼容 |   冲突 |
| IX     | 兼容 | 兼容 |   冲突 |   冲突 |
| S 表锁 | 兼容 | 冲突 |   兼容 |   冲突 |
| X 表锁 | 冲突 | 冲突 |   冲突 |   冲突 |

#### Record Lock

记录锁（Record Lock）是一种行级锁，我喜欢把它称为行锁。**用于锁定索引中的一条具体记录**，即使表没有定义索引，InnoDB 也会创建一个隐式的聚簇索引并使用它加锁。

#### Gap Lock

间隙锁（Gap Lock）：锁定索引记录之间的间隙，而不是记录本身。

主要作用是防止其他事务在间隙中插入新的数据，从而在 `REPEATABLE READ` 隔离级别下有效避免 “幻读” 现象。

#### Next-Key Lock

Next-Key Lock 即临键锁，是记录锁（Record Lock）和间隙锁（Gap Lock）的合称。它**锁定一个索引记录以及该记录之前的间隙**，锁定区间是 “左开右闭”。

#### Insert Intention Locks

插入意向锁（Insert Intention Lock）：一种特殊的间隙锁（Gap Lock）。它是由 INSERT 操作执行前设置的一种特殊间隙锁，用于表明事务打算在某个间隙中插入新记录。

多个事务只要插入的位置不冲突（即不在间隙中的完全相同的位置），就可以同时持有对相同间隙的插入意向锁，不会相互阻塞。**插入意向锁与普通的间隙锁是互斥的**，如果一个事务已经持有了某个间隙的间隙锁，另一个事务尝试在该间隙获取插入意向锁时会被阻塞。

### 当前读和快照读

| 语句                              | 类型   | 读到什么版本               | 是否加锁 | 主要用途           |
| --------------------------------- | ------ | -------------------------- | -------- | ------------------ |
| `SELECT ...`                    | 快照读 | 事务 ReadView 中可见的版本 | 不加行锁 | 普通查询           |
| `SELECT ... LOCK IN SHARE MODE` | 当前读 | 最新已提交版本             | 加 S 锁  | 读后希望别人不能改 |
| `SELECT ... FOR SHARE`          | 当前读 | 最新已提交版本             | 加 S 锁  | MySQL 8 推荐写法   |
| `SELECT ... FOR UPDATE`         | 当前读 | 最新已提交版本             | 加 X 锁  | 读后准备更新       |

假设：`InnoDB`、`REPEATABLE READ`，初始 `val = 5`。

| 时序 | 会话 A                                                               | 会话 B                                                    |
| ---- | -------------------------------------------------------------------- | --------------------------------------------------------- |
| 1    | `BEGIN;`                                                           |                                                           |
| 2    | `SELECT val ...;`→**5**，建立快照                           |                                                           |
| 3    |                                                                      | `UPDATE ... SET val=val+1; COMMIT;`→ 最新值**6** |
| 4    | `SELECT val ...;`→**5**，普通读继续读旧快照                 |                                                           |
| 5    | `SELECT val ... LOCK IN SHARE MODE;`→**6**，当前读并加 S 锁 |                                                           |
| 6    | `SELECT val ...;`→**5**，快照并没有刷新                     |                                                           |
| 7    | `SELECT val ... FOR UPDATE;`→**6**，当前读并加 X 锁         |                                                           |
| 8    | `UPDATE ... SET val=val+1;`→ 修改为**7**                    |                                                           |
| 9    | `SELECT val ...;`→**7**，事务能看到自己的修改               |                                                           |
| 10   | `COMMIT;`                                                          |                                                           |

加锁期间，会话 B 的表现：

| 会话 A 持有 | 会话 B 普通 `SELECT` | 会话 B `LOCK IN SHARE MODE` | 会话 B `FOR UPDATE / UPDATE` |
| ----------- | ---------------------- | ----------------------------- | ------------------------------ |
| S 锁        | 不阻塞，读快照         | 不阻塞                        | 阻塞                           |
| X 锁        | 不阻塞，读快照         | 阻塞                          | 阻塞                           |

* 普通 SELECT：快照读，读旧版本，不加锁
* LOCK IN SHARE MODE：当前读，读最新版本，加 S 锁
* FOR UPDATE：当前读，读最新版本，加 X 锁
* 当前读不会刷新普通 SELECT 的快照
* **事务修改后，可以读到自己的新数据**

### 总结

| 分类      | 名称                            | 作用范围 / 含义                               | 常见场景                                          |
| --------- | ------------------------------- | --------------------------------------------- | ------------------------------------------------- |
| 锁模式    | **S 锁**                  | 共享锁；允许多个事务同时读，但阻止修改        | `SELECT ... FOR SHARE`                          |
| 锁模式    | **X 锁**                  | 排他锁；阻止其他事务加 S 锁或 X 锁            | `UPDATE`、`DELETE`、`SELECT ... FOR UPDATE` |
| 锁模式    | **IS 锁**                 | 表级意向共享锁；表示准备对表中部分记录加 S 锁 | 执行 `FOR SHARE`前自动获取                      |
| 锁模式    | **IX 锁**                 | 表级意向排他锁；表示准备对表中部分记录加 X 锁 | 执行 `UPDATE`、`FOR UPDATE`前自动获取         |
| 锁定范围  | **Record Lock**           | 锁住一条**索引记录**                    | 主键或唯一索引等值查询                            |
| 锁定范围  | **Gap Lock**              | 锁住两条索引记录之间的间隙，防止插入          | RR 隔离级别下的范围查询                           |
| 锁定范围  | **Next-Key Lock**         | `Record Lock + 前方 Gap Lock`               | RR 下非唯一索引范围查询                           |
| 特殊锁    | **Insert Intention Lock** | 表示准备向某个索引间隙插入数据                | `INSERT`                                        |
| 特殊锁    | **AUTO-INC Lock**         | 保护自增值的分配                              | 使用自增主键批量插入                              |
| Server 层 | **MDL 元数据锁**          | 保护表结构，避免查询和 DDL 同时破坏结构       | `SELECT`与 `ALTER TABLE`冲突                  |

## 死锁

死锁是指两个或多个事务在执行过程中，因操作需要加锁而相互等待的现象。这种情况下，MySQL 的 InnoDB 存储引擎会自动检测到死锁并回滚其中一个事务，以打破僵局。

举个例子，比如事务 A 持有事务 B 需要的锁，且事务 B 持有事务 A 需要的锁时，事务 A、B 双方都需要等待对方释放所需的锁，导致事务无法继续执行，形成一种 &quot;僵局&quot;（即循环依赖）。

## 如何判断是否发生了死锁

一种是直接通过服务端报错日志：由于我这边已经解决了死锁问题，找不到日志了，这里就不做展示了。

虽然但是，我刚搜索了一下，日志了又出现了新的死锁，正好展示：

```
{&quot;level&quot;:&quot;warn&quot;,&quot;ts&quot;:1783587512.371989,&quot;caller&quot;:&quot;db/gorm_logger_writer.go:36&quot;,&quot;msg&quot;:&quot;\u001b[31;1m/Users/henry/Proj/im-demo/internal/message/delivery/repo.go:70 \u001b[35;1mError 1213 (40001): Deadlock found when trying to get lock; try restarting transaction\n\u001b[0m\u001b[33m[8.594ms] \u001b[34;1m[rows:0]\u001b[0m UPDATE `message_deliveries` SET `received_at`=COALESCE(received_at, &apos;2026-07-09 16:58:32.363&apos;),`status`=CASE WHEN status &lt; 3 THEN 3 ELSE status END,`updated_at`=&apos;2026-07-09 16:58:32.363&apos; WHERE conversation_id = &apos;single_b74cd02da8b9267f2c45fff5083829236e56da8d0f5f8ec4a801e3ca298d90bd&apos; AND user_id = &apos;load_receiver_521&apos; AND seq &lt;= 1628 AND status &lt; 3&quot;}
{&quot;level&quot;:&quot;warn&quot;,&quot;ts&quot;:1783587540.527381,&quot;caller&quot;:&quot;db/gorm_logger_writer.go:36&quot;,&quot;msg&quot;:&quot;\u001b[31;1m/Users/henry/Proj/im-demo/internal/message/delivery/repo.go:86 \u001b[35;1mError 1213 (40001): Deadlock found when trying to get lock; try restarting transaction\n\u001b[0m\u001b[33m[4.207ms] \u001b[34;1m[rows:0]\u001b[0m UPDATE `message_deliveries` SET `read_at`=COALESCE(read_at, &apos;2026-07-09 16:59:00.523&apos;),`received_at`=COALESCE(received_at, &apos;2026-07-09 16:59:00.523&apos;),`status`=CASE WHEN status &lt; 4 THEN 4 ELSE status END,`updated_at`=&apos;2026-07-09 16:59:00.523&apos; WHERE conversation_id = &apos;single_2faf4cb35d1c7bb15e2c5839cb22e3b40d4612d59c0df40f0d6ee7ec48720ea3&apos; AND user_id = &apos;load_receiver_1147&apos; AND seq &lt;= 184 AND status &lt; 4&quot;}
{&quot;level&quot;:&quot;warn&quot;,&quot;ts&quot;:1783587551.845919,&quot;caller&quot;:&quot;db/gorm_logger_writer.go:36&quot;,&quot;msg&quot;:&quot;\u001b[31;1m/Users/henry/Proj/im-demo/internal/message/delivery/repo.go:46 \u001b[35;1mError 1213 (40001): Deadlock found when trying to get lock; try restarting transaction\n\u001b[0m\u001b[33m[12.359ms] \u001b[34;1m[rows:0]\u001b[0m UPDATE `message_deliveries` SET `consumed_at`=&apos;2026-07-09 16:59:11.833&apos;,`last_error`=&apos;&apos;,`realtime_delivered_at`=&apos;2026-07-09 16:59:11.833&apos;,`realtime_push_status`=1,`status`=CASE WHEN status &lt; 2 THEN 2 ELSE status END,`updated_at`=&apos;2026-07-09 16:59:11.833&apos; WHERE server_msg_id = &apos;msg_0538b439-e62d-4f95-a0bc-446ec8da83f9&apos; AND user_id = &apos;load_sender_960&apos;&quot;}
```

还有一种常见的就是通过 InnoDB 状态信息（`SHOW ENGINE INNODB STATUS`）输出最近的一次死锁：

```
=====================================
2026-07-07 17:24:57 281472767020800 INNODB MONITOR OUTPUT
=====================================
Per second averages calculated from the last 31 seconds
-----------------
BACKGROUND THREAD
-----------------
srv_master_thread loops: 542 srv_active, 0 srv_shutdown, 19702 srv_idle
srv_master_thread log flush and writes: 0
----------
SEMAPHORES
----------
OS WAIT ARRAY INFO: reservation count 343043
OS WAIT ARRAY INFO: signal count 310938
RW-shared spins 0, rounds 0, OS waits 0
RW-excl spins 0, rounds 0, OS waits 0
RW-sx spins 0, rounds 0, OS waits 0
Spin rounds per wait: 0.00 RW-shared, 0.00 RW-excl, 0.00 RW-sx
------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-07-07 17:14:14 281472678223616
*** (1) TRANSACTION:
TRANSACTION 411123, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 12 lock struct(s), heap size 1128, 8 row lock(s)
MySQL thread id 559, OS thread handle 281469453651712, query id 2959629 192.168.215.1 root update
INSERT INTO `conversation_members` (`conversation_id`,`user_id`,`peer_user_id`,`read_seq`,`recv_seq`,`unread_count`,`created_at`,`updated_at`) VALUES (&apos;single_26ac5b5c3a91f64083c980ebe5a1fac5ccbb8cb8fcadd66fb8bbaa07b560fda0&apos;,&apos;load_receiver_496&apos;,&apos;load_sender_496&apos;,0,0,0,&apos;2026-07-07 17:14:14.480975&apos;,&apos;2026-07-07 17:14:14.480975&apos;),(&apos;single_26ac5b5c3a91f64083c980ebe5a1fac5ccbb8cb8fcadd66fb8bbaa07b560fda0&apos;,&apos;load_sender_496&apos;,&apos;load_receiver_496&apos;,0,0,0,&apos;2026-07-07 17:14:14.480975&apos;,&apos;2026-07-07 17:14:14.480975&apos;) ON DUPLICATE KEY UPDATE `id`=`id`

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 98 page no 50 n bits 160 index PRIMARY of table `tinyim`.`conversation_members` trx id 411123 lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;


*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 98 page no 50 n bits 160 index PRIMARY of table `tinyim`.`conversation_members` trx id 411123 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;


*** (2) TRANSACTION:
TRANSACTION 411125, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 11 lock struct(s), heap size 1128, 8 row lock(s)
MySQL thread id 553, OS thread handle 281469431287552, query id 2959630 192.168.215.1 root update
INSERT INTO `conversation_members` (`conversation_id`,`user_id`,`peer_user_id`,`read_seq`,`recv_seq`,`unread_count`,`created_at`,`updated_at`) VALUES (&apos;single_f789bef7899a607cd55d518fbd6cab89fdeffc90d5324199ac37ed0af545eaa5&apos;,&apos;load_receiver_497&apos;,&apos;load_sender_497&apos;,0,0,0,&apos;2026-07-07 17:14:14.485384&apos;,&apos;2026-07-07 17:14:14.485384&apos;),(&apos;single_f789bef7899a607cd55d518fbd6cab89fdeffc90d5324199ac37ed0af545eaa5&apos;,&apos;load_sender_497&apos;,&apos;load_receiver_497&apos;,0,0,0,&apos;2026-07-07 17:14:14.485384&apos;,&apos;2026-07-07 17:14:14.485384&apos;) ON DUPLICATE KEY UPDATE `id`=`id`

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 98 page no 50 n bits 160 index PRIMARY of table `tinyim`.`conversation_members` trx id 411125 lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;


*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 98 page no 50 n bits 160 index PRIMARY of table `tinyim`.`conversation_members` trx id 411125 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;

*** WE ROLL BACK TRANSACTION (2)
------------
TRANSACTIONS
------------
Trx id counter 413117
Purge done for trx&apos;s n:o &lt; 412935 undo n:o &lt; 0 state: running but idle
History list length 0
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 562948231742720, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
---TRANSACTION 562948231720904, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
---TRANSACTION 562948231720096, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
---TRANSACTION 562948231719288, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
---TRANSACTION 562948231718480, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
---TRANSACTION 562948231715248, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
---TRANSACTION 562948231714440, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
--------
FILE I/O
--------
I/O thread 0 state: waiting for completed aio requests (insert buffer thread)
I/O thread 1 state: waiting for completed aio requests (read thread)
I/O thread 2 state: waiting for completed aio requests (read thread)
I/O thread 3 state: waiting for completed aio requests (read thread)
I/O thread 4 state: waiting for completed aio requests (read thread)
I/O thread 5 state: waiting for completed aio requests (read thread)
I/O thread 6 state: waiting for completed aio requests (read thread)
I/O thread 7 state: waiting for completed aio requests (read thread)
I/O thread 8 state: waiting for completed aio requests (read thread)
I/O thread 9 state: waiting for completed aio requests (read thread)
I/O thread 10 state: waiting for completed aio requests (write thread)
I/O thread 11 state: waiting for completed aio requests (write thread)
I/O thread 12 state: waiting for completed aio requests (write thread)
I/O thread 13 state: waiting for completed aio requests (write thread)
Pending normal aio reads: [0, 0, 0, 0, 0, 0, 0, 0, 0] , aio writes: [0, 0, 0, 0] ,
 ibuf aio reads:
Pending flushes (fsync) log: 0; buffer pool: 0
7835 OS file reads, 880740 OS file writes, 337207 OS fsyncs
1.38 reads/s, 16384 avg bytes/read, 4.34 writes/s, 3.58 fsyncs/s
-------------------------------------
INSERT BUFFER AND ADAPTIVE HASH INDEX
-------------------------------------
Ibuf: size 1, free list len 0, seg size 2, 0 merges
merged operations:
 insert 0, delete mark 0, delete 0
discarded operations:
 insert 0, delete mark 0, delete 0
Hash table size 34679, node heap has 0 buffer(s)
Hash table size 34679, node heap has 0 buffer(s)
Hash table size 34679, node heap has 0 buffer(s)
Hash table size 34679, node heap has 0 buffer(s)
Hash table size 34679, node heap has 0 buffer(s)
Hash table size 34679, node heap has 0 buffer(s)
Hash table size 34679, node heap has 0 buffer(s)
Hash table size 34679, node heap has 0 buffer(s)
0.00 hash searches/s, 15.74 non-hash searches/s
---
LOG
---
Log capacity                 104857600
Log capacity used            104857600
Log sequence number          1001130960
Log buffer assigned up to    1001130960
Log buffer completed up to   1001130960
Log written up to            1001130960
Log flushed up to            1001130960
Added dirty pages up to      1001130960
Pages flushed up to          1001130890
Last checkpoint at           1001130890
Log minimum file id is       277
Log maximum file id is       305
569598 log i/o&apos;s done, 2.03 log i/o&apos;s/second
----------------------
BUFFER POOL AND MEMORY
----------------------
Total large memory allocated 0
Dictionary memory allocated 1079211
Buffer pool size   8192
Free buffers       980
Database pages     7212
Old database pages 2670
Modified db pages  1
Pending reads      0
Pending writes: LRU 0, flush list 0, single page 0
Pages made young 35943, not young 1235373
0.00 youngs/s, 7.92 non-youngs/s
Pages read 7115, created 9250, written 285174
1.38 reads/s, 0.00 creates/s, 1.01 writes/s
Buffer pool hit rate 965 / 1000, young-making rate 0 / 1000 not 202 / 1000
Pages read ahead 0.00/s, evicted without access 0.00/s, Random read ahead 0.00/s
LRU len: 7212, unzip_LRU len: 0
I/O sum[98]:cur[1], unzip sum[0]:cur[0]
--------------
ROW OPERATIONS
--------------
0 queries inside InnoDB, 0 queries in queue
0 read views open inside InnoDB
Process ID=1, Main thread ID=281472593682176 , state=sleeping
Number of rows inserted 150253, updated 3480946, deleted 0, read 11822072
0.00 inserts/s, 0.00 updates/s, 0.00 deletes/s, 0.00 reads/s
Number of system rows inserted 815, updated 352, deleted 800, read 28838
0.00 inserts/s, 0.00 updates/s, 0.00 deletes/s, 8.77 reads/s
----------------------------
END OF INNODB MONITOR OUTPUT
============================
```

直接搜索关键词 `LATEST DETECTED DEADLOCK` 即可找到。

## 生产环境常见死锁

### 交叉死锁

假设发送事务顺序是：

```
SendMessage：
1. 锁 conversations
2. 更新 conversation_members
3. 更新 message_deliveries
```

而某个已读事务却是：

```
ConversationRead：
1. 更新 message_deliveries
2. 更新 conversation_members
3. 更新 conversations
```

就可能形成：

```
事务 A：
持有 conversations
持有 conversation_members
等待 message_deliveries

事务 B：
持有 message_deliveries
等待 conversation_members
```

由于两个事务中更新顺序不同带来的加锁顺序不同，造成相互持有对方需要的锁，从而死锁，这就是最经典的交叉死锁。

MySQL 官方也明确建议：修改多张表或同一张表多行时，所有事务都使用一致的访问顺序。

再来看第二个：

```
message.received
conversation.read
批量更新 message_deliveries
批量推进 read_seq / recv_seq
```

假设存在两个异步 Worker，并且它们分别在事务中更新相同的两条记录：

```
Worker A：先更新 delivery 100，再更新 delivery 101
Worker B：先更新 delivery 101，再更新 delivery 100
```

由于行锁会一直持有到事务提交或回滚，因此可能出现：

```
Worker A 持有 delivery 100 的行锁，等待 delivery 101
Worker B 持有 delivery 101 的行锁，等待 delivery 100
```

这就形成了循环等待，最终触发数据库死锁检测，其中一个事务会被回滚。

最直接的解决方式，是让所有事务按照统一顺序获取行锁。例如在更新前，统一按照 `DeliveryID` 排序：

```go
sort.Slice(tasks, func(i, j int) bool {
	return tasks[i].DeliveryID &lt; tasks[j].DeliveryID
})
```

当然上面的方法太简单粗暴了，任务队列一旦变大，那排序这里就会花不少时间（虽然比起网络延时与 I/O 也算不上什么），我们最好能让这个任务一开始就是有序入队的。

**我目前的方案是分片串行队列：**

给每一个 Worker 都单独开一个任务队列，然后将每个队列分配给固定的 Worker，这样可以保证同一个队列是由一个 Worker 串行消费，避免并行导致的顺序问题。此外，通过用**业务聚合键**等特征进行哈希，将同一个会话的 `delivery` 映射到相同的队列中，刚才说到，一个队列是由一个 Worker 串行消费，所以这样也就确定了执行顺序。

在我的 IM 微服务实现链路下，一次 `message.received` 或 `conversation.read` 任务，可能同时更新多条 `message_deliveries`，并推进同一用户在同一会话中的 `read_seq` 或 `recv_seq`。只要这些相关任务都进入同一个分片队列，就会由同一个 Worker 串行执行，从而避免它们并发争抢相同的行锁。

不会再出现一个持有 `delivery 100` 锁的 Worker 在等 `delivery 101` 的行锁，而另一个持有 `delivery 101` 锁的  Worker 又在等 `delivery 100` 的锁。

**此外，哈希碰撞会导致两个原本无关的会话被映射到同一个队列，虽然降低了部分并发度，但不会造成多个 Worker 交叉持有这些记录的行锁。**

### 其他场景

TODO...

累了，我先休息了。

</content></entry><entry><title>Golang pprof 案例实战</title><link href="https://www.bytecho.net/archives/golang_pprof.html" /><id>https://www.bytecho.net/archives/golang_pprof.html</id><updated>2026-06-08T15:05:00.000Z</updated><summary>前段时间在 Golang 开发实习生面试时被问到你了解哪些 Debug 手段，回答的比较片面，只解释到了如何处理业务逻辑问题的一些方法，但其实性能问题也是会经常遇到的，不过这些也都是三板斧了，熟悉了就好。在 Go 语言开发中，**pprof** 是官方提供的性能剖析工具。它能帮你动态地查看程序在运行时的 CPU 耗时、内存分配、协程状态等指标，是排查线上故障和性能优化的核心工具。</summary><content type="html">前段时间在 Golang 开发实习生面试时被问到你了解哪些 Debug 手段，回答的比较片面，只解释到了如何处理业务逻辑问题的一些方法，但其实性能问题也是会经常遇到的，不过这些也都是三板斧了，熟悉了就好。

在 Go 语言开发中，**pprof** 是官方提供的性能剖析工具。它能帮你动态地查看程序在运行时的 CPU 耗时、内存分配、协程状态等指标，是排查线上故障和性能优化的核心工具。

## 前置准备

小徐先生在去年发了一篇《Golang pprof 案例实战与原理解析》，里面基本涉及了 Golang pprof 的所有常见操作，基于以下项目进行实战：

https://github.com/wolfogre/go-pprof-practice

本文将使用 macOS 环境。

首先需要克隆该项目，然后安装 pprof 可视化工具 `graphviz`：

```sh
brew install graphviz
```

## 项目实战

进入 `go-pprof-practice` 项目，可以发现 `main.go` 中对 pprof 包进行了匿名导入：

```go
import (
	&quot;log&quot;
	&quot;net/http&quot;

	// 启用 pprof 性能分析
	_ &quot;net/http/pprof&quot;
	&quot;os&quot;
	&quot;runtime&quot;
	&quot;time&quot;

	&quot;github.com/wolfogre/go-pprof-practice/animal&quot;
)
```

此外，使用 `runtime` 包启用 `mutex` 和 `block` 分析：

```go
// 启用 mutex 性能分析
runtime.SetMutexProfileFraction(1)
// 启用 block 性能分析
runtime.SetBlockProfileRate(1)
```

启动一个 http server，pprof handler 会自动挂载到该端口下：

```go
go func() {
	if err := http.ListenAndServe(&quot;:6060&quot;, nil); err != nil {
		log.Fatal(err)
	}
	os.Exit(0)
}()
```

注意到上述片段中 `http.ListenAndServe` 第二个参数为 `nil`，此时会默认使用 `http.DefaultServeMux`。

**总结一下，要在项目中引入 pprof，需要完成：**

1. 匿名导入 pprof 包；
2. 按需使用 `runtime` 包启用 `mutex` 或 `block` 性能分析（默认关闭）；
3. 使用 goroutine 异步启用 http server，pprof 会向全局 `http.DefaultServerMux` 注册方法。

最后我们启动项目即可，然后打开 pprof 页面 (http://localhost:6060/debug/pprof/)：

```go
go run main.go
```

### CPU 分析

&gt; CPU 分析是在一段时间内进行打点采样，通过查看采样点在各个函数栈中的分布比例，以此来反映各函数对 CPU 的占用情况。

在 pprof 页面中点击 profile，因为需要进行采样，所以等待一段时间后才可以得到 profile 文件。

```sh
go tool pprof {YOUR PROFILE PATH}
```

输入上述命令后可以交互分析 profile 文件，比如这里输入 `top` 可以得到执行时长最长的一系列方法：

注：`go tool pprof -http=:8082  {YOUR PROFILE PATH}` 可以在对应端口开启图形界面。

```
Showing nodes accounting for 12.49s, 99.92% of 12.50s total
Dropped 11 nodes (cum &lt;= 0.06s)
      flat  flat%   sum%        cum   cum%
    12.41s 99.28% 99.28%     12.49s 99.92%  github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Eat
     0.08s  0.64% 99.92%      0.08s  0.64%  runtime.asyncPreempt
         0     0% 99.92%     12.49s 99.92%  github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Live
         0     0% 99.92%     12.50s   100%  main.main
         0     0% 99.92%     12.50s   100%  runtime.main
```

| **参数名称** | **含义**             | **解释**                                                            |
| -------------------- | ---------------------------- | ----------------------------------------------------------------------- |
| **`flat`**    | **Flat Time**             | 该函数**自身代码**执行所消耗的时间（不包含它调用的其他子函数的运行时间）。 |
| **`flat%`**   | **Flat Percentage**       | 该函数自身消耗的时间，占**本次采样总时间**的百分比。                       |
| **`sum%`**    | **Sum Percentage**        | 从第一行到当前行，所有 `flat%` 的​**累加和**​。                        |
| **`cum`**     | **Cumulative Time**       | ​**累计时间**​。该函数自身代码时间 + 它调用的所有子函数执行时间的总和。  |
| **`cum%`**    | **Cumulative Percentage** | 该函数的 `cum` 时间占**本次采样总时间**的百分比。                        |

可以看到，`Tiger` 结构体在指针类型上实现的 `Eat` 方法占用了 12.41s 的时间，在本次采样周期内，几乎所有的 CPU 时间都来自于此方法。下面我们进到该方法源码中查看：

```go
func (t *Tiger) Eat() {
	log.Println(t.Name(), &quot;eat&quot;)
	loop := 10000000000
	for i := 0; i &lt; loop; i++ {
		// do nothing
	}
}
```

很遗憾，这个方法啥也没做，仅仅是通过 `for` 循环无限空转打满了 CPU，刚才还注意到 CPU 分析中还有一个 `runtime.asyncPreempt` 方法，这个方法其实是来自于非协作式抢占（当前 g 中被插入 `asyncPreempt` 函数），因为 `Eat()` 方法仅仅是空转，没有 goroutine 的执行栈空间扩张，所以无法执行到协作抢占的检查桩点。

具体见 GMP 原理。

### Heap 分析

同样的，在 pprof 页面中点击 heap，可以得到如下信息：

```
heap profile: 3: 2583691424 [252: 10199507584] @ heap/1048576
0: 0 [1: 8388608] @ 0x102f8164c 0x102daffa4
#	0x102f8164b	github.com/wolfogre/go-pprof-practice/animal/muridae/mouse.(*Mouse).Pee.func1+0x9b	/Users/henry/工程/go-pprof-practice/animal/muridae/mouse/mouse.go:46

0: 0 [1: 67108864] @ 0x102f818e4 0x102f81198 0x102f81a58 0x102d711a8 0x102daffa4
#	0x102f818e3	github.com/wolfogre/go-pprof-practice/animal/muridae/mouse.(*Mouse).Steal+0xf3	/Users/henry/工程/go-pprof-practice/animal/muridae/mouse/mouse.go:60
#	0x102f81197	github.com/wolfogre/go-pprof-practice/animal/muridae/mouse.(*Mouse).Live+0x47	/Users/henry/工程/go-pprof-practice/animal/muridae/mouse/mouse.go:25
#	0x102f81a57	main.main+0xb7									/Users/henry/工程/go-pprof-practice/main.go:46
#	0x102d711a7	runtime.main+0x287								/usr/local/go/src/runtime/proc.go:272

0: 0 [1: 240] @ 0x102d40a48 0x102d40a91 0x102e04ee8 0x102f80ec8 0x102f80e59 0x102f80ae8 0x102f81a58 0x102d711a8 0x102daffa4
#	0x102e04ee7	log.(*Logger).output+0xb7							/usr/local/go/src/log/log.go:219
#	0x102f80ec7	log.Println+0x87								/usr/local/go/src/log/log.go:405
#	0x102f80e58	github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Pee+0x18	/Users/henry/工程/go-pprof-practice/animal/felidae/tiger/tiger.go:38
#	0x102f80ae7	github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Live+0x37	/Users/henry/工程/go-pprof-practice/animal/felidae/tiger/tiger.go:16
#	0x102f81a57	main.main+0xb7									/Users/henry/工程/go-pprof-practice/main.go:46
#	0x102d711a7	runtime.main+0x287								/usr/local/go/src/runtime/proc.go:272
```

第一行数据为全局视角 `heap profile: 3: 2583691424 [252: 10199507584] @ heap/1048576`：

| **参数**    | **代表指标** | **含义**                                                                                    |
| ------------------------ | -------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| **`3`**           | `inuse_objects`      | ​**当前存活的对象数**​：整个程序里现在还没被 GC（垃圾回收）释放的对象只有 3 个。                             |
| **`2583691424`**  | `inuse_bytes`        | ​**当前存活的内存字节数**​：约​**2.4 GB**​。说明当前程序内存开销极大。                                    |
| **`252`**         | `alloc_objects`      | ​**历史累计分配的对象数**​：从程序启动到现在，一共创建过 252 个对象（很多已被释放）。                        |
| **`10199507584`** | `alloc_bytes`        | ​**历史累计分配的内存字节数**​：约​**9.5 GB**​。说明程序历史上有过密集的内存分配行为。                    |
| **`1048576`**     | `MemProfileRate`     | ​**内存采样率**​：这里是 1048576 字节（即​**1 MB**​）。代表 Go 运行时每分配 1 MB 内存，就会记录一次数据。 |

第二行以后为调用栈信息，以第二行为例 `0: 0 [1: 67108864] @ 0x102f818e4 0x102f81198 0x102f81a58 0x102d711a8 0x102daffa4`：

| **参数**       | **对应数据** | **含义**                                                                       |
| --------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------- |
| 左起第 1 个数字           | **`0`**                 | **当前**通过该调用链存活的对象数。这里是 0，说明现在没被它占着。                                     |
| 左起第 2 个数字           | **`0`**                 | **当前**通过该调用链存活的内存大小。这里是 0，说明​**已经被 GC 回收干净了**​。                    |
| 中括号内第 1 个数字       | **`1`**                 | **历史累计**在这条调用链上分配的对象个数。                                                           |
| 中括号内第 2 个数字       | **`67108864`**          | **历史累计**在这条调用链上分配的字节数（67108864 字节 = 刚好​**64 MB**​）。                       |
| `@`符号后面的十六进制 | **`0x102f818e4...`**    | 内存分配时该调用栈每一层在代码里的​**内存物理地址**​（PC 寄存器值）。                              |
| `#`开始的后续行       | `mouse.go:60`等          | 把上面的十六进制地址位于哪个文件名、第几行代码、哪个函数。 |

就以第二行数据为例吧，来到源码：

```go
func (m *Mouse) Steal() {
	log.Println(m.Name(), &quot;steal&quot;)
	max := constant.Gi
	for len(m.buffer)*constant.Mi &lt; max {
		m.buffer = append(m.buffer, [constant.Mi]byte{})
	}
}
```

可以看到该方法在不断对 `buffer` 追加内容，导致了内存占用。

### Block 分析

&gt; 即阻塞分析，查看某个 goroutine 陷入 waiting 状态（被动阻塞，通常因 gopark 操作触发，比如因加锁、读 chan 条件不满足而陷入阻塞）的触发次数和持续时长。

pprof 默认不启用 block 分析，若要开启则需要进行如下设置：

```go
runtime.SetBlockProfileRate(1)
```

同样的，点击 pprof 页面中的 block 即可。

```
--- contention:
cycles/second=999999999
2130193082709 22 @ 0x102d3d824 0x102d50c8c 0x102daffa4
#	0x102d3d823	runtime.chanrecv1+0x13						/usr/local/go/src/runtime/chan.go:489
#	0x102d50c8b	runtime.unique_runtime_registerUniqueMapCleanup.func1+0x3b	/usr/local/go/src/runtime/mgc.go:1732

422399418761 422 @ 0x102f802b8 0x102f8025d 0x102f7fbd8 0x102f81a58 0x102d711a8 0x102daffa4
#	0x102f802b7	sync.(*Mutex).Lock+0x187							/usr/local/go/src/sync/mutex.go:92
#	0x102f8025c	github.com/wolfogre/go-pprof-practice/animal/canidae/wolf.(*Wolf).Howl+0x12c	/Users/henry/工程/go-pprof-practice/animal/canidae/wolf/wolf.go:76
#	0x102f7fbd7	github.com/wolfogre/go-pprof-practice/animal/canidae/wolf.(*Wolf).Live+0x47	/Users/henry/工程/go-pprof-practice/animal/canidae/wolf/wolf.go:33
#	0x102f81a57	main.main+0xb7									/Users/henry/工程/go-pprof-practice/main.go:46
#	0x102d711a7	runtime.main+0x287								/usr/local/go/src/runtime/proc.go:272

421391046552 421 @ 0x102d3d824 0x102f80820 0x102f80448 0x102f81a58 0x102d711a8 0x102daffa4
#	0x102d3d823	runtime.chanrecv1+0x13								/usr/local/go/src/runtime/chan.go:489
#	0x102f8081f	github.com/wolfogre/go-pprof-practice/animal/felidae/cat.(*Cat).Pee+0x9f	/Users/henry/工程/go-pprof-practice/animal/felidae/cat/cat.go:39
#	0x102f80447	github.com/wolfogre/go-pprof-practice/animal/felidae/cat.(*Cat).Live+0x37	/Users/henry/工程/go-pprof-practice/animal/felidae/cat/cat.go:19
#	0x102f81a57	main.main+0xb7									/Users/henry/工程/go-pprof-practice/main.go:46
#	0x102d711a7	runtime.main+0x287								/usr/local/go/src/runtime/proc.go:272
```

| **参数/指标**      | **含义**                                                                                                                                                     |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **`contention`**    | 代表当前是**竞争/阻塞**分析报告，专门记录因抢锁、读写通道（Channel）导致的挂起时间。                                                                                        |
| **`cycles/second`** | ​**每秒时钟周期数**​。这里约等于 10 亿（1 GHz）。Go 运行时内部使用 CPU 时钟周期来记录阻塞时长，这个参数是换算成“秒”的比例尺（时钟周期数 ÷ cycles/second = 阻塞秒数）。 |

以数据 `422399418761 422 @ 0x102f802b8 0x102f8025d 0x102f7fbd8 0x102f81a58 0x102d711a8 0x102daffa4` 为例：

| **参数** | **对应数据**       | **含义**                                                                                                                                  |
| ----------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | 
| 左起第 1 个数字                   | **`422399418761`**            | ​**累计阻塞总时间**​（单位：CPU 周期）。换算成人类时间：`422399418761 ÷ 999999999 ≈ 422.4秒`。也就是说，所有协程在这行代码上足足干等了 422 秒！ |
| 左起第 2 个数字                   | **`422`**                     | ​**累计阻塞总次数**​。说明在这个地方，一共有 422 次“准备往前冲，结果被拦下来挂起”的经历。                                                            |
| `@`符号后面的十六进制         | **`0x102f802b8...`**          | 发生阻塞时，当前调用栈每一层代码在内存中的物理地址。                                                                                                      |
| `#`开始的后续行               | `mutex.go:92`/`wolf.go:76` | ​指出是哪一行代码在阻塞。这里显示是`Wolf.Howl`函数里在执行`sync.(*Mutex).Lock`时被卡住了。                        |

同样来到源码：

```go
func (w *Wolf) Howl() {
	log.Println(w.Name(), &quot;howl&quot;)

	m := &amp;sync.Mutex{}
	m.Lock()

	// 子 goroutine 会解锁，所以主 goroutine 二次 Lock 不会导致死锁
	go func() {
		time.Sleep(time.Second)
		m.Unlock()
	}()

	// mutex 不可重入
	// 同一个 goroutine 对同一把锁重复加锁
	// 会阻塞等待锁被释放，如果这把锁一直没被 Unlock 那么会导致死锁
	m.Lock()
}
```

可以看到每次调用该方法都会被阻塞 1s，因为主 goroutine 会阻塞等待锁被 Unlock 后才能加锁。

### Goroutine 分析

在 pprof 页面点击 goroutine 即可进行分析：

```
goroutine profile: total 60
50 @ 0x102da7c18 0x102dabda0 0x102f803e8 0x102daffa4
#	0x102dabd9f	time.Sleep+0xdf										/usr/local/go/src/runtime/time.go:300
#	0x102f803e7	github.com/wolfogre/go-pprof-practice/animal/canidae/wolf.(*Wolf).Drink.func1+0x27	/Users/henry/工程/go-pprof-practice/animal/canidae/wolf/wolf.go:44

5 @ 0x102da7c18 0x102dabda0 0x102f815e0 0x102daffa4
#	0x102dabd9f	time.Sleep+0xdf										/usr/local/go/src/runtime/time.go:300
#	0x102f815df	github.com/wolfogre/go-pprof-practice/animal/muridae/mouse.(*Mouse).Pee.func1+0x2f	/Users/henry/工程/go-pprof-practice/animal/muridae/mouse/mouse.go:43

1 @ 0x102d66e0c 0x102da6a84 0x102f6bdb4 0x102f6bbd0 0x102f69168 0x102f7e054 0x102f7e968 0x102f47338 0x102f48f34 0x102f4ff5c 0x102f461bc 0x102daffa4
#	0x102f6bdb3	runtime/pprof.writeRuntimeProfile+0xb3	/usr/local/go/src/runtime/pprof/pprof.go:793
#	0x102f6bbcf	runtime/pprof.writeGoroutine+0x4f	/usr/local/go/src/runtime/pprof/pprof.go:752
#	0x102f69167	runtime/pprof.(*Profile).WriteTo+0x147	/usr/local/go/src/runtime/pprof/pprof.go:374
#	0x102f7e053	net/http/pprof.handler.ServeHTTP+0x443	/usr/local/go/src/net/http/pprof/pprof.go:272
#	0x102f7e967	net/http/pprof.Index+0xc7		/usr/local/go/src/net/http/pprof/pprof.go:388
#	0x102f47337	net/http.HandlerFunc.ServeHTTP+0x37	/usr/local/go/src/net/http/server.go:2220
#	0x102f48f33	net/http.(*ServeMux).ServeHTTP+0x1b3	/usr/local/go/src/net/http/server.go:2747
#	0x102f4ff5b	net/http.serverHandler.ServeHTTP+0xbb	/usr/local/go/src/net/http/server.go:3210
#	0x102f461bb	net/http.(*conn).serve+0x4fb		/usr/local/go/src/net/http/server.go:2092

1 @ 0x102d6fd4c 0x102db0b4c 0x102f80bb1 0x102f80ad0 0x102f81a58 0x102d711a8 0x102daffa4
#	0x102f80bb0	github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Eat+0x90	/Users/henry/工程/go-pprof-practice/animal/felidae/tiger/tiger.go:24
#	0x102f80acf	github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Live+0x1f	/Users/henry/工程/go-pprof-practice/animal/felidae/tiger/tiger.go:13
#	0x102f81a57	main.main+0xb7									/Users/henry/工程/go-pprof-practice/main.go:46
#	0x102d711a7	runtime.main+0x287								/usr/local/go/src/runtime/proc.go:272
```

第一行顾名思义就不解释了，从第二行开始 `50 @ 0x102da7c18 0x102dabda0 0x102f803e8 0x102daffa4`：

| **参数** | **含义**                                                                                          |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| 左起第一个数字`50`        | ​**相同状态的协程数量**​。代表当前整个程序里，有**50 个**独立的 Goroutine 正卡在下面完全一模一样的调用栈里。  |
| `@`符号后面的十六进制           | 这些协程当前执行到的代码在内存中的物理地址。                                                                      |
| `#`开始的后续行                 | ​指出这 50 个协程目前停留在`wolf.go:44`行的`time.Sleep`内部（处于休眠阻塞状态）。 |

来到源码部分：

```go
func (w *Wolf) Drink() {
	log.Println(w.Name(), &quot;drink&quot;)
	for i := 0; i &lt; 10; i++ {
		go func() {
			time.Sleep(30 * time.Second)
		}()
	}
}
```

```go
func (m *Mouse) Pee() {
	log.Println(m.Name(), &quot;pee&quot;)
	go func() {
		time.Sleep(time.Second * 30)
		max := constant.Gi
		for len(m.slowBuffer)*constant.Mi &lt; max {
			m.slowBuffer = append(m.slowBuffer, [constant.Mi]byte{})
			time.Sleep(time.Millisecond * 500)
		}
	}()
}
```

很明显，上述两个方法均在创建一个/多个 goroutine，然后在每个 goroutine 中注入各种耗时任务，导致 goroutine 长时间阻塞。

至此，我们把 pprof 中常用的性能分析流程串联了一遍，实战 demo 到此为止。

---

**字节星球 2026-06-08**

</content></entry><entry><title>Golang 手写一个 Channel</title><link href="https://www.bytecho.net/archives/golang_mychannel.html" /><id>https://www.bytecho.net/archives/golang_mychannel.html</id><updated>2026-05-29T13:16:53.840Z</updated><summary>对于 Golang 来说，其实永远绕不过几个东西，一个是 GMP 模型，这个已经倒背如流了，相信每一个 Gopher 都已经深挖过 GMP，这在我的内部 Wiki 中也有非常详细的源码解读，此外就是 Channel。</summary><content type="html">对于 Golang 来说，其实永远绕不过这两个东西，一个是 GMP 模型，这个已经倒背如流了，相信每一个 Gopher 都已经深挖过 GMP，这在我的内部 Wiki 中也有非常详细的源码解读，此外就是 Channel。至于内存管理，如 GC 等，其实和语言关联性不强，算是比较通用的解决方案，在 Golang 中我觉得没必要深究。

**GMP 和 Channel 算是组成了 Golang 的核心特性：高并发能力 + CSP**

&gt; **Do not communicate by sharing memory; instead, share memory by communicating.**

## sync.Cond

&gt; 一个条件变量，用于让 goroutine 在某个条件不满足时睡眠，等条件满足后再被唤醒。

它通常和 `sync.Mutex` 配合使用：`cond := *sync.NewCond(&amp;mutex)`

- `Wait` 会原子地释放锁并挂起当前 goroutine，等被 `Signal` 或 `Broadcast` 唤醒后，会在返回前重新加锁；
- `Broadcast` 会唤醒所有等待者，`Signal` 只唤醒一个等待者。

```go
mu.Lock()
for conditions {
	cond.Wait()
}
// ...
mu.Unlock()
```

**注意，一般要用 `for`，不要用 `if`。**

因为被唤醒后，只代表条件可能变了，不代表条件一定满足。所以醒来以后必须重新检查条件。Go 官方文档也建议把 `Wait` 放在循环里检查条件。

| 方法              | 作用                                  |
| ------------------- | --------------------------------------- |
| `Wait()`      | 当前 goroutine 进入等待，并临时释放锁 |
| `Signal()`    | 唤醒一个正在等待的 goroutine          |
| `Broadcast()` | 唤醒所有正在等待的 goroutine          |

例如：

```go
cond.Wait()
```

内部大概做了如下几件事：

1. 释放锁
2. 当前 goroutine 睡眠
3. 被 Signal 或 Broadcast 唤醒
4. 重新抢锁
5. Wait 返回

所以调用 `Wait()` 前，必须已经持有锁：

```go
mu.Lock()
cond.Wait()
mu.Unlock()
```

## 实现 Channel

### Model

```go
// unbufferedSlot 无缓冲 channel
type unbufferedSlot[T any] struct {
	// 泛型
	value T
	// 用于记录是否被消费，发送者需要等待无缓冲 channel 被完全消费
	consumed bool
}

// MyChan 自实现 channel
type MyChan[T any] struct {
	// mu 用于并发控制
	mu sync.Mutex

	// sync.Cond 用于阻塞 goroutine
	cond sync.Cond

	// capacity 表示缓冲区大小
	capacity int

	// buf 用于有缓冲的 channel
	buf []T

	// closed 记录 channel 是否已关闭
	closed bool

	// slot 用于无缓冲的 channel
	slot *unbufferedSlot[T]
}
```

### Common

```go
// NewMyChan 新建一个 channel
func NewMyChan[T any](capacity int) *MyChan[T] {
	if capacity &lt;= 0 {
		capacity = 0
	}

	ch := &amp;MyChan[T]{
		capacity: capacity,
	}

	// NewCond 参数为 Locker Interface
	// 而 Mutex 在指针类型上实现了 Locker，所以这里需要 &amp;ch.mu
	ch.cond = *sync.NewCond(&amp;ch.mu)

	return ch
}

// Close 关闭 Channel
func(ch *MyChan[T]) Close() {
	ch.mu.Lock()
	defer ch.mu.Unlock()

	// 和原版 Channel 保持一致
	if ch.closed {
		panic(&quot;close of closed channel&quot;)
	}

	ch.closed = true

	// 无缓冲 Channel 如果还有值等待接收
	// 则唤醒发送者并抛出 panic
	if ch.capacity == 0 &amp;&amp; ch.slot != nil &amp;&amp; !ch.slot.consumed {
		ch.slot = nil
	}

	// 唤醒阻塞中的发送者/接收者
	// 发送者醒来后发现 closed=true，然后 panic
	// 接收者醒来后如果没有数据，就返回零值和 false
	ch.cond.Broadcast()
}
```

这里需要注意的是 `sync.NewCond` 接受已实现 `Locker` 接口类型的参数。

查看源码可知 `sync.Mutex` 中，`Lock/Unlock` 方法使用的是指针接收器，所以 `*sync.Mutex` 才算实现了 `Locker` 接口。

| 方法                 | `T` 是否实现接口 | `*T` 是否实现接口 |
| ----------------------- | --------------------: | ---------------------: |
| `func (t T) M()`  |                  是 |                   是 |
| `func (t *T) M()` |                  否 |                   是 |

此外，Golang 不建议在同个类型的方法实现中同时使用值接收器和指针接收器。

&gt; 一个类型只要有任何方法需要用指针接收器，通常这个类型的其他方法也都建议用指针接收器。

这样可以避免接口方法集不一致，也能避免复制带来的语义和并发问题。

### Send

```go
// Send 向 Channel 发送数据
func (ch *MyChan[T]) Send(val T) {
	ch.mu.Lock()
	defer ch.mu.Unlock()

	// 首先判断 Channel 是否有缓冲
	if ch.capacity == 0 {
		ch.sendUnbuffered(val)
		return
	}

	// 有缓冲
	// 首先要判断缓冲区是否还有空间，没有空间则阻塞等待
	for len(ch.buf) &gt;= ch.capacity &amp;&amp; !ch.closed {
		ch.cond.Wait()
	}

	// buffer 有空间了，但是 channel 被关闭了
	// 和原版保持行为一致 panic
	if ch.closed {
		panic(&quot;send on closed channel&quot;)
	}

	ch.buf = append(ch.buf, val)

	// 现在 buffer 缓冲区有数据了，应该唤醒可能阻塞的接收方 goroutines
	ch.cond.Broadcast()
}

// sendUnbuffered 发送到无缓冲 Channel
func (ch *MyChan[T]) sendUnbuffered(val T) {
	// 由于是无缓冲的，所以如果有数据待接收
	// 将会阻塞发送方
	for ch.slot != nil &amp;&amp; !ch.closed {
		ch.cond.Wait()
	}

	// 和原版 channel 一样，如果向一个 closed channel 发送
	// 直接抛出 panic
	if ch.closed {
		panic(&quot;send on closed channel&quot;)
	}

	s := &amp;unbufferedSlot[T]{
		value: val,
		consumed: false,
	}
	ch.slot = s

	// 现在 channel 有数据了，唤醒等待的接收方 goroutine
	ch.cond.Broadcast()

	// 等待值被接收，否则阻塞
	for !s.consumed &amp;&amp; !ch.closed {
		ch.cond.Wait()
	}

	// 判断是否因关闭而跳出阻塞
	if !s.consumed &amp;&amp; ch.closed {
		panic(&quot;send on closed channel&quot;)
	}
}
```

### Receive

```go
func(ch *MyChan[T]) Recv() (T, bool) {
	ch.mu.Lock()
	defer ch.mu.Unlock()

	if ch.capacity == 0 {
		return ch.recvUnbuffered()
	}

	// 有缓冲
	// buffer 为空且 chan 没有被关闭，阻塞等待
	for len(ch.buf) == 0 != !ch.closed {
		ch.cond.Wait()
	}

	// 特殊处理 closed channel
	var zeroVal T
	if len(ch.buf) == 0 &amp;&amp; ch.closed {
		return zeroVal, false
	}

	// 有数据，按照 FIFO 出队
	val := ch.buf[0]

	// 截断掉第一个已经出队的元素
	ch.buf[0] = zeroVal
	ch.buf = ch.buf[1:]

	// 出队后，buffer 可能有空余空间
	// 唤醒阻塞的发送方
	ch.cond.Broadcast()

	return val, true
}

// recvUnbuffered 从无缓冲的 Channel 接收值
func(ch *MyChan[T]) recvUnbuffered() (T, bool) {
	// 等待接收数据
	for ch.slot == nil &amp;&amp; !ch.closed {
		ch.cond.Wait()
	}

	// 如果没有数据，且已被关闭，那么返回零值和 ok: false
	var zeroVal T
	if ch.slot == nil &amp;&amp; ch.closed {
		return zeroVal, false
	}

	// slot 不为空
	s := ch.slot
	val := s.value

	s.value = zeroVal
	s.consumed = true

	// 接收完之后注意清理掉 slot
	ch.slot = nil

	// 值已经接收完成，唤醒可能被阻塞的发送方 goroutines
	ch.cond.Broadcast()

	// 注意，如果 channel closed 但 buffer 还有数据
	// ok 依然为 true，直到 buffer 数据接收完毕，返回零值和 false
	return val, true
}
```

---

**YuelaiGroup | 字节星球 2026-05-29**

</content></entry><entry><title>字节星球终于全栈上新！</title><link href="https://www.bytecho.net/archives/bytecho_refabricated.html" /><id>https://www.bytecho.net/archives/bytecho_refabricated.html</id><updated>2026-05-08T15:12:00.000Z</updated><summary>这次把博客全栈翻新了一遍，心情就像给家里换了全新 MacBook Pro 一样，熟悉，但更耀眼了 🤩！至此，我的服务器也再无 PHP 的影子，也算是对一个时代的告别！画了几年的饼也终于完成！</summary><content type="html">**这次把博客全栈翻新了一遍，心情就像给家里换了全新 MacBook Pro 一样，熟悉，但更耀眼了 🤩！**

写代码的这几天很有生活感：早上先看接口返回，下午盯布局细节，晚上再把交互和文案一点点抛光。等页面终于顺起来那一刻，真的会忍不住在工位上小小欢呼一下！每天基本上都是深夜一两点才睡觉，但很舒服！

**至此，我的服务器也再无 PHP 的影子，也算是对一个时代的告别！画了几年的饼也终于完成！**

## 前台设计语言

这次前台基于 `yuelaiengine-blog` 的能力做了更完整的一体化呈现，不只是能看，而是努力做到好看、好用、好读。

### 1) 视觉气质：克制里带一点温柔

- 整体配色走清爽路线，主色稳，强调色轻轻点一下，不会喧宾夺主。
- 背景不是生硬纯色，而是有轻微层次感的渐变氛围，页面看起来更柔和。
- 卡片化内容区块更统一，信息边界清晰，阅读时不容易累。

简单说，就是希望它像一张整理好的**上床下桌**：有重点、有留白，也有一点点可爱的小心思。**对于各位大学生而言，应该懂上床下桌的含金量！**

### 2) 字体与阅读节奏：看得清，也看得久

- 标题和正文在字重、层级上更分明，读者一眼能抓到信息主次。
- 行高、段落间距、卡片内边距都做了统一，长文阅读更顺，不挤。
- 文章卡片里摘要、标签、元信息（时间/作者/阅读量）排布更整齐，扫读体验更友好。

我自己反复读了几轮，目标很简单：希望每个来访朋友的眼里只有博主所呈现的内容。

### 3) 信息结构：内容不只一条线

前台不再只是文章列表，而是把博客内容组织成多个生活化入口：

- 首页推荐 + 常规文章流
- 分类、标签、归档，方便按主题或时间找内容
- 说说（轻内容）、相册（图文氛围）、友链（社区连接）
- 独立页面能力（如关于页、说明页等）

这让博客更像一个有房间感的小站，而不是单页信息堆叠。

### 4) 交互细节：轻反馈，但不打扰

- 搜索、抽屉导航、下拉菜单、主题切换都做了更直觉的交互路径。
- 点赞、评论、目录折叠、复制分享、二维码等常用动作都更容易被发现。
- 动效以短促和克制为主，给反馈但不抢戏，尽量不打断阅读。

我很喜欢这种页面细腻但不过度设计的感觉，像是很懂分寸的搭档，**在这里吐槽一下有些魔改博客真的很辣眼睛！！！**

### 5) 多端与主题：白天夜里都舒服

- 桌面端保留双栏阅读效率，移动端改为更清晰的一栏逻辑。
- 深色/浅色/跟随系统主题都可用，夜间浏览不会刺眼。
- 细节上包括滚动条、边框对比、按钮状态，也做了统一处理。

## 项目架构及能力简要说明

### Yuelai Engine 通用开发平台

`yuelai-engine` 是我基于 **Golang + Gin + GORM + Casbin + Redis + RabbitMQ/RedisMQ + OSS + Vue3/TDesign** 独立开发的全栈平台，核心目标是提供一套可扩展的后台基础能力（认证、权限、菜单、日志、文件、系统管理）并在此基础上**通过插件承载具体业务域**。

### 基于 Yuelai Engine 的博客插件

作为一个从 2017 年开始建设博客的老玩家，深知博客最需要什么功能，以及如何设计其通用扩展能力。

**可以很自豪的说，我设计的作用域粒度自定义字段为博客前台扩展开发提供了更多可能，从基础的功能，到渐进式定制个性化需求，或许这才是博客爱好者最需要的能力。**

**无后台-主题耦合，前后台独立部署，使得前端开发者也能在后台架构黑盒下轻松扩展自己的前台功能。**

前端开发者可通过 JSON 将自定义字段注入后台，在每个作用域下都可以轻松自定义配置。

提供基于 Nuxt3 SSR 的 SEO 友好、部署链路清晰的默认前台主题：[如你现在所见](https://www.bytecho.net/)。

</content></entry><entry><title>欢迎试用 TodoList</title><link href="https://www.bytecho.net/archives/2394.html" /><id>https://www.bytecho.net/archives/2394.html</id><updated>2025-06-21T08:07:00.000Z</updated><summary></summary><content type="html">### 介绍

YuelaiEngine系列软件 - TodoList

「融合式协作/待办中心」，由 Gin + Element Plus 驱动。

### 项目特性

* 前后端分离架构，简易化部署，仅需一行命令
* 多样化待办清单，合作共享，Todo也能玩出花样
* 多维度待办清单，前端可设计的待办清单
* 邮件待办提醒，让待办不遗忘
* 合作待办清单，一起协作一项任务
* 共享待办清单，分享目前进度
* 支持 Markdown 的待办日志，记录每一次进步
* 待办清单截止锁定能力，不再错过DDL
* 大语言模型能力，一键生成待办清单
* 在线选号组件，简易而不失趣味
* 基于 RedisStream 轻量消息队列
* 轻量静态资源能力

### 技术说明

**「项目后端侧」消息队列感谢 redmq 提供思路。**

**「项目前端侧」侧边栏部分样式由大模型生成，与本项目设计无关！**

### 测试地址

系统下线，服务现已关闭！仅供内网访问！

### 网络要求

仅用于测试，故没有部署至云服务器，**需要IPv6**。

### 提议

反馈至评论区。

</content></entry><entry><title>记一次“说走就走”的成都行</title><link href="https://www.bytecho.net/archives/cd-2025.html" /><id>https://www.bytecho.net/archives/cd-2025.html</id><updated>2025-05-29T14:03:48.000Z</updated><summary></summary><content type="html">记录算是自己第一次的独自旅行，实则还是经历过不少犹豫不少纠结！

大概是在今年三月底，刷小红书刷到了草莓音乐节成都站的信息，看了一下阵容，发现有自己最近喜欢上的 DOUDOU，还有很早就在听的房东的猫，5月底时间正合适，那时候我课基本上完了，而且她们不算太红，所以说不太存在抢票或者说人太多的问题，这时候就有点想买票去试试现场的想法，学生票价也还好，200 来块钱，而且是最后一次用学生票看音乐节的机会了，超过 25 岁就不行了，这又加重了我去成都的决心。

看了下大麦，发现学生票早就售罄了，老闲鱼人首先想到的就是去闲鱼找票代，还算顺利，找到了靠谱的校园代理，拿下了学生票。由于那时候距离音乐节还太早了，后面很长一段时间都没有再去想这件事情。

很快到了5月，我大概在劳动节的时候就把去成都的动车票买好了，不贵，几十块钱（后面还捡漏到了50来块钱的动车票），这时候才开始规划去成都的几天该干嘛，首先想到的就是把成都主流的景区走一遍，虽然我已经去过很多次成都了，但都是和家人们一起去，很多时候得照顾大部分人的想法，所以或许有自己想去的地方，想做的事情没完成，再去走一遍应该会有一些收获。然而看了看门票价，哦～没钱，买不起！

在去成都的前几天，发现小红书推了去成都看演唱会/音乐节各大景区免票的通知，查了下成都文旅公众号，发现确有其事，只能说这波看音乐节算是这么些年做的唯一正确决定，什么好事都凑一块了。之前原本打算和高中同学一起在成都玩几天的，然而他是学医的，在实习，平时特别忙，在事前说没请到假，最后不了了之了，稍显遗憾！只能说随时年龄增大，朋友也好，自己也好，都会慢慢被琐事淹没，所以珍惜现在每一个闲暇时间，及时行乐。

后面就蛮顺利了，音乐现场确实不是什么耳机、音响和电影院能比的，除了天气太热把我手机搞过热了导致没拍全DOUDOU之外没出什么岔子，然后就是有点累。至于究竟去成都玩了啥，怎么玩的，就不细说了，不然就是流水账了，没什么意思。[Gallery](https://www.bytecho.net/albums) 专栏一句话没说但已然道出了一切，这次成都行总的来说消费蛮低！玩得蛮爽！

</content></entry><entry><title>Docker Desktop修改默认存储路径</title><link href="https://www.bytecho.net/archives/2324.html" /><id>https://www.bytecho.net/archives/2324.html</id><updated>2023-09-16T05:06:00.000Z</updated><summary></summary><content type="html">### 前言

最近二开了不少项目，基本都是用Docker部署，所以之前在电脑上装了Docker，不过在 Windows 上安装 Docker Desktop 后，Docker默认是把镜像保存到 `C:\Users\&lt;用户&gt;\AppData\Local\Docker\wsl\data\` 路径的`ext4.vhdx`文件下，在C盘放这些无关紧要的文件属实是浪费，所以整理了一下方法，把这些文件移动到其他磁盘去。

### 修改WSL

首次启动 Docker Desktop 会提示安装 WSL，没有WSL则无法启动 Docker Engine 服务。因为目前的 docker 依附 WSL 来进行文件映射，所以通过 WSL 来修改 docker 的文件映射路径，就可以把这些文件移动到其他磁盘中。

&gt; 默认情况下，Docker Desktop for Window 会创建如下两个发行版：
&gt; 
&gt; 1. docker-desktop (distro/ext4.vhdx)
&gt; 2. docker-desktop-data (data/ext4.vhdx)

目前WSL2已经全量发布，WSL2 下 docker-desktop-data 通常位于以下位置： `C:\Users\&lt;你当前用户名&gt;\AppData\Local\Docker\wsl\data\ext4.vhdx`，我电脑中这个文件夹高达`50GB+`，完全是浪费C盘空间！

在开始操作前，请先退出Docker Desktop，然后在terminal中输入`wsl --list -v`，确保两个服务都是停止状态：

![image.png](/api/v1/uploads/file/969818cac4a784b1e6461df45a2fbd74cd41e9d2.png)

#### 备份镜像

分别输入以下命令备份WSL，后面的备份路径可以自行修改，我这里是备份到了G磁盘：

```sh
wsl --export docker-desktop G:\docker-desktop.tar
wsl --export docker-desktop-data G:\docker-desktop-data.tar
```

#### 取消注册

```sh
wsl --unregister docker-desktop
wsl --unregister docker-desktop-data
```

#### 备份导入

执行命令前，请先更改命令中的`G:\docker-desktop.tar`，`G:\docker-desktop-data.tar`为自己之前备份的路径，挂载的路径`G:\docker\desktop`，`G:\docker\data`也需要改为自己想要挂载的路径（需提前创建好对应的文件夹，不然会提示找不到目录）：

```sh
wsl --import docker-desktop &quot;G:\docker\desktop&quot; &quot;G:\docker-desktop.tar&quot; --version 2
wsl --import docker-desktop-data &quot;G:\docker\data&quot; &quot;G:\docker-desktop-data.tar&quot; --version 2
```

### 测试

输入`wsl --list -v`查看其输出是否和修改之前一样，正常情况下会出现两个发行版，即：`docker-desktop`，`docker-desktop-data`。

然后启动你的Docker Desktop看看是否能够正常运行。之后制作或者拉取的镜像都会存储在新的目录，而不是C盘中的默认路径。

### Docker清理缓存

最后再搬运一个清理docker缓存的教程~

&gt; 在使用 docker build 构建镜像时，Docker 会按照 Dockerfile 中定义的步骤逐步生成 Docker 镜像。而镜像生成的过程中，每一步骤所生成的结果都会被缓存（cache）下来，以便下次镜像生成时不必再重新执行同一步骤以提高构建镜像的速度。

#### 使用 --no-cache

```
docker build --no-cache .
```

#### 使用 docker system prune

使用 `docker system prune` 命令来清理不再使用的资源，包括停止的容器、未被标记的镜像、未使用的网络和未使用的数据卷。

```
# 清理所有不再使用的资源
docker system prune
# 清理更加彻底，将未使用 Docker 镜像都删掉
docker system prune -a
```

---

Henry 23-09-16

</content></entry><entry><title>流程中心使用指南</title><link href="https://www.bytecho.net/archives/2316.html" /><id>https://www.bytecho.net/archives/2316.html</id><updated>2023-09-06T15:07:00.000Z</updated><summary></summary><content type="html">### 前言

很早之前就想让网站的某些操作自动化，今天终于实现了，话不多说，直接介绍吧。
本站工作流系统基于Ferry二次开发，采用Gin + ElementUI(Vue)前后端分离技术，集工单统计、任务钩子、权限管理、灵活设计流程与模版等能力于一身的开源工单系统。

项目二次开发源码已发布到：[Github项目仓库](https://www.bytecho.net/repository.html)

**由于字节星球已经使用全新的 Yuelai Engine，所以流程中心暂时弃用！**

### 如何登录（请以实际页面为准）

首先在[字节星球](https://www.bytecho.net/account/register.php)或其它已接入的平台注册一个账号（若已注册，请直接登录），由于流程中心与主站独立且暂未使用LDAP，所以需要等待系统将账号信息同步至流程中心（**该过程通常小于1分钟**），然后进入[流程中心首页](https://work.yuelaigroup.com/)：

![1720927123606.png](/api/v1/uploads/file/fe1991d126a6abab56a73e3fb943ebd72d066405.png)

**用户账号：同对应平台的账号/用户名**
**初始密码：注册时所用的邮箱**

目前支持的平台有[字节星球](https://www.bytecho.net)和[陌上花博客](https://moshanghua.net)。下面以字节星球账号为例：
![image.png](/api/v1/uploads/file/0198b0ce3984f737fae64516c646d22ac8a80ebb.png)

**用户名处所填写的内容才是你的账号，请勿将下方的Email作为账号，该Email仅用作邮件提醒。**

**首次进入系统后，请及时更改初始密码！请勿使用中文或符号作为用户名，避免登录失败！**

### 如何使用

使用方式很简单，点击工单系统，进入工单申请，选择需要申请的项目，按照表单提示填写后，即可发起申请。
下图以友情链接申请为例：

![image.png](/api/v1/uploads/file/5a3dd19343b87e9814eb23a248d14eabbbc2f017.png)

![image.png](/api/v1/uploads/file/ae12f1f73f3d6be040339592b477eac62d851301.png)

发起申请后，点击工单系统，进入我的工单，实时跟踪流程进度，直到流程结束。
**本系统已接入邮件提醒，所以在注册账号时请务必填写正确的邮箱**，字节星球账号所绑定的邮箱会同步到工作流系统，请注意来自`noreply#yuelaigroup.com`的邮件。

### 流程跟踪

通常一个工单流程有多个步骤，可能有需要申请人处理的流程，则需点击工单系统，进入**我的待办**，处理流转到自己的工单，是否有需要自己处理的工单请留意邮件提醒~

正在流转中的工单：

![image.png](/api/v1/uploads/file/b9cfba1b75f821772c1eb77d0ebf3907b81663d6.png)

已完成的工单：

![image.png](/api/v1/uploads/file/a171a5ad7d162122997c9424a4e0916ab61b83f1.png)

不通过而流程终止的工单：

![image.png](/api/v1/uploads/file/02f733fd3fb5595681f579216ea1144bfe949192.png)

### 已接入的板块

[友情链接](https://www.bytecho.net/links.html)与[星球认证](https://www.bytecho.net/archives/group.html)现已接入流程中心，申请表审核通过后，系统自动添加友情链接/认证信息，无需人工干预，后续会陆续将其他板块接入流程中心。

**由于字节星球已经使用全新的 Yuelai Engine，所以流程中心暂时弃用！**

---

Henry 2023-09-06

</content></entry><entry><title>微分方程小手册</title><link href="https://www.bytecho.net/archives/2307.html" /><id>https://www.bytecho.net/archives/2307.html</id><updated>2023-09-06T08:20:00.000Z</updated><summary></summary><content type="html">### 微分方程框架

![.png](/api/v1/uploads/file/39eae5ef13cf5da3246b7157af74f0a1229c1778.png)

### 一阶微分方程

#### 可分离变量型

形如：${\rm{y}}&apos; = f(x) \cdot g(y)$，有：

$$
{\rm{y}}&apos; = f(x) \cdot g(y) \Rightarrow \frac{{dy}}{{dx}} = f(x) \cdot g(y) \Rightarrow f(x) \Rightarrow \frac{{dy}}{{g(y)}} = f(x)dx \Rightarrow \int {\frac{{dy}}{{g(y)}}}  = \int {f(x)dx}
$$

进一步的，可通过换元得到以上形式的，也可以对其分离变量，如：

$$
{\rm{y}}&apos; = f(ax + by + c) \Rightarrow u{\rm{ = }}ax + by + c \Rightarrow \frac{{dy}}{{dx}} = f(u) \Rightarrow \frac{{du}}{{dx}} = a + bf(u) \Rightarrow \frac{{du}}{{a + bf(u)}} = dx \Rightarrow \int {\frac{{du}}{{a + bf(u)}}}  = \int {dx}
$$

#### 齐次型

形如$y&apos;=f(\frac{y}{x})$或$\frac{1}{y&apos;}=f(\frac{x}{y})$，按照上述方法换元转换为分离变量型，以$y&apos;=f(\frac{y}{x})$为例，令$u=\frac{y}{x}$，有：

$$
y = ux \Rightarrow \frac{{dy}}{{dx}} = x\frac{{du}}{{dx}} + u \Rightarrow y&apos; = \frac{{dy}}{{dx}} = f(u) = x\frac{{du}}{{dx}} + u \Rightarrow \int {\frac{1}{{f(u) - u}}du = \int {\frac{{dx}}{x}} }
$$

#### 一阶线性型

形如：$y&apos;+p(x)y=q(x)$，使用以下公式计算（由于是应试，推导步骤略）：

$$
y = {e^{ - \int p (x)dx}}\left[ {{{\int e }^{\int p (x)dx}} \cdot q(x)dx + C} \right]
$$

上式为一阶线性微分方程的通解公式，其中，式中的${\int p (x)dx}$为$p(x)$的某一个原函数。

注：上述公式中若$\int p (x)dx = \ln \left| {\varphi (y)} \right|$，该绝对值在上述公式中最后可以去掉，产生的$±$可以合并到常数$C$中得到常数$D$。

### 二阶微分方程（可降阶）

**形如：$y&apos;&apos;=f(x,y&apos;)$，即缺$y$型，令$y&apos;=p,y&apos;&apos;=p&apos;$，有：**

$$
y&apos;&apos; = \frac{{dp}}{{dx}} = f(x,y&apos;) = f(x,p)
$$

由上式降阶为一阶微分方程，按一阶微分方程方法求解得到$p=y&apos;=\varphi(x,C_1)$，则可求得原微分方程通解：

$$
y=\int \varphi(x,C_1)dx+C_2
$$

**形如：$y&apos;&apos;=f(y,y&apos;)$，即缺$x$型，令$y&apos;=p,y&apos;&apos;=p&apos;=\frac{dp}{dx}=\frac{dp}{dy} \cdot \frac{dy}{dx}=\frac{dp}{dy} \cdot p$，有：**

$$
y&apos;&apos;=\frac{dp}{dy} \cdot p=f(y,p)
$$

由上式降阶为一阶微分方程，按一阶微分方程方法求解得到$p=y&apos;=\varphi(y,C_1)$，分离变量后积分即可求得原微分方程的通解：

$$
\frac{{dy}}{{\varphi (y,{C_1})}} = dx \Rightarrow \int {\frac{{dy}}{{\varphi (y,{C_1})}}}  = \int {dx}  = x + {C_2}
$$

### 高阶常系数线性微分方程*

**对于形式为：$y&apos;&apos;+py&apos;+qy=f(x)$，$y&apos;&apos;+py&apos;+qy=f_1(x)+f_2(x)$求解步骤如下：**

1. 写出方程$\lambda^2+p\lambda+q=0$，解出$\lambda_1,\lambda_2$或共轭复根；
2. 根据以下类型，写出齐次线性微分方程的**通解**：
$$
y = \begin{cases} 
C_1 e^{\lambda_1 x} + C_2 e^{\lambda_2 x}, &amp; p^2 - 4q &gt; 0 \quad (\text{roots: } \lambda_1 \neq \lambda_2) \\
(C_1 + C_2 x) e^{\lambda x}, &amp; p^2 - 4q = 0 \quad (\text{roots: } \lambda_1 = \lambda_2 = \lambda) \\
e^{\alpha x} (C_1 \cos \beta x + C_2 \sin \beta x), &amp; p^2 - 4q &lt; 0 \quad (\text{roots: } \alpha \pm \beta i)
\end{cases}
$$
3. 对于第一种形式，直接根据自由项$f(x)$的形式设**特解**，对于第二种形式需分别根据自由项$f_1(x),f_2(x)$的形式设两个特解，然后相加得到微分方程的特解，特解形式如下： 
$$
y^* = \begin{cases} 
e^{\alpha x} Q_n(x) x^k, &amp; f(x) = P_n(x) e^{\alpha x} \\
e^{\alpha x} \left[ Q_l^{(1)}(x) \cos \beta x + Q_l^{(2)}(x) \sin \beta x \right] x^k, &amp; f(x) = e^{\alpha x} \left[ P_m(x) \cos \beta x + P_n(x) \sin \beta x \right]
\end{cases}
$$
   - 上式中的$e^{\alpha x}$直接从自由项中照抄，$Q_n$为$x$的$n$次一般多项式，$l=max\{m,n\}$，$Q_l^{(1)},Q_l^{(2)}$分别为$x$的两个不同的$l$次一般多项式。
   - $k$在${p^2} - 4q \ge 0$时：$\alpha$与所有特征根都不相等，此时$k=0$；与其中一个特征根相等，$k=1$；与所有特征根相等，$k=2$。
   - $k$在${p^2} - 4q &lt; 0$时：$\alpha \pm \beta i$不是特征根，此时$k=0$；$\alpha \pm \beta i$是特征根，$k=1$。

最后，将**齐次微分方程的通解加上该微分方程的一个特解**即是非齐次微分方程的通解，简单来说就是先写齐次通解再设非齐次特解，相加得非齐次通解。

**对于$y^{(n)}(n \ge 3)$的情形：**

形如$y&apos;&apos;&apos;+p_1y&apos;&apos;+p_2y&apos;+p_3y=0$，同样的写出特征方程：$\lambda ^3+p_1\lambda^2+p_2\lambda+p_3=0$，解得$\lambda_{1,2,3}$，然后根据以下不同情况直接写出通解：

1. 若$\lambda_i$为单实根：$Ce^{\lambda x}$;
2. 若$\lambda_i$为$k$重实根：$(C_1+C_2x+C_3x^2+\cdots+C_kx^{k-1})e^{\lambda x}$；
3. 若$\lambda_i$为单复根$\alpha\pm\beta i$：$e^{\alpha x}(C_1cos\beta x+C_2sin\beta x)$。

将上述每一个特征根产生的项相加，得到$y$的齐次通解。

---

Henry 2023-09-06

</content></entry><entry><title>近期小记</title><link href="https://www.bytecho.net/archives/2281.html" /><id>https://www.bytecho.net/archives/2281.html</id><updated>2023-07-22T10:18:00.000Z</updated><summary></summary><content type="html">&gt; 炎热的夏天，只能天天在家，没有一点能令人精神的清新而凉爽的空气，搞的一天昏昏沉沉！

家里台式已然是个老不死，还剩最后一口气只能说，死缠烂打之下家里台式机换了新的平台+AMD R7某系列CPU（顺便提一嘴，全能本夏天真难用，散热简直是灾难，不然我也没什么用台式的需求），然而之前的航嘉电源好像有些不稳定，寄去上海修了，也不知道上海那个 B 修电源的啥时候才能给你弄好，寄回来还得三四天，真的难受。

今天帮人搭建 OJ 系统，人家给了 100 左右，还白嫖到了高质量题库，满意！（哦不对，应该是昨天，已经过了12点了~

续上：又有信奥培训机构的判题后端服务遇到了疑难杂症，redis的docker容器反复重启...帮忙解决了，人家主动给了些辛苦费。

今天又发现上次给人搭建的评测机的安全沙箱跑不起来，排查了一下发现是CentOS7导致的问题，配置了一下解决。

此外不得不说，使用docker部署各类业务，使得自己的服务器干净了不少，对有电子洁癖的我非常友好，现在已经对自己的服务器爱不释手了，而不是跟之前一样嫌弃得都不想打开。

现阶段一天天盼望着能够工作，十多年的应试生活，早就已经厌倦了，你说真能学到什么？也就是培养了学习能力和拿到了找工作的敲门砖，实践还得自己来。目前我的好多想法都只能有稳定收入和足够的时间之后才敢尝试，期待这一天！

</content></entry><entry><title>字节星球搬家了！</title><link href="https://www.bytecho.net/archives/2274.html" /><id>https://www.bytecho.net/archives/2274.html</id><updated>2023-06-03T12:19:00.000Z</updated><summary>字节星球/肥柴之家业务现已迁移至新服务器，告别 106.54.176.177，全面使用 Docker 部署~</summary><content type="html">### 新闻

字节星球/肥柴之家业务现已迁移至新服务器，告别 106.54.176.177，**全面使用 Docker 部署~**

本次迁移的业务：主站、Blog（暂未使用，仅作为测试）二级、Api二级、Code（Flarum）二级。
~~注：以上服务有用的准确来说只有一个...~~

新增业务：[OnlineJudge](https://acm.bytecho.net)（信息学在线评测服务）欢迎注册使用、私有Git服务以及[服务探针](https://status.bytecho.net)。

![image.png](/api/v1/uploads/file/027b4aa0deb3137bbe8a3ac59719b71b738a2de3.png)
![image.png](/api/v1/uploads/file/0f1966e4f437bb80a50333cfa9163bce2dc43b8f.png)

后续会上线WePlanet Web端（Go+ElementUI，桌面端现已上线）、Home二级（用作字节星球生态导航）。

**注意，Images二级域已经废弃，所以大家的友情链接头像地址需要更新，新的头像地址已在[传送门](https://www.bytecho.net/links.html)页面更新。**

### 另外

106.54.176.177（腾讯云CVM）用了三年，也算是基本横跨了大学四年了，新的服务器（腾讯云）又将跨越我的研究生三年，蛮有意思！

注：本人腾讯忠实铁粉。

另外，在线判题服务器又再次开放了，在合适的时机投入使用！

### 新的开始

![大考（李庚希）](/api/v1/uploads/file/6d251f9503aed7bbad3e0a700bb793a5763f451a.png)
（附一张❤️李庚希的照片，她饰演的角色几乎都符合我心中的理想型，或许可以作为一种无形动力？）

还有十来天就算是本科正式毕业了，读完大学本科，发现个人似乎还是喜欢中学时的那种同学情谊，但教学模式和生活方式我还是更喜欢大学一点。

不管是喜欢与否，这些都已经成为回忆，我发现对于我而言，不论是快乐的还是负面的回忆，都让我觉得美好，始终还是一个喜欢念旧的人，以前的东西舍不得扔，以前的事情也舍不得忘。

继续自己的学术道路（可能是刚刚开始，毕竟本科算不上什么学术）吧，开启硕士研究生生活，本人现在最大的梦想还是能够留在重邮工作！

---

Henry 2023-06-03

</content></entry><entry><title>如何顺利注册ChatGPT？</title><link href="https://www.bytecho.net/archives/2257.html" /><id>https://www.bytecho.net/archives/2257.html</id><updated>2023-04-24T07:53:00.000Z</updated><summary></summary><content type="html">### ChatGPT

欢迎来到 ChatGPT，这里是一个 AI 智能助手，可以帮助您解答各种问题。其实这已经火了一段时间了，现在也推出了付费的ChatGPT Plus **USD $20/m**。

俗话说，**但凡和网红沾边的东西，都多少沾点**，GPT也不例外...因为注册用户太多，以及接口的滥用，现在已经封IP、封邮箱、封账号了，我在今天（2023-04-24）才选择注册ChatGPT，一直因为它是网红而不想去凑热闹。

### 开始注册

请自备一些地区的网络，其中`CN_MAINLAND,CN_HK,CN_TW`除外！

不然你第一步就阵亡了：

![image.png](/api/v1/uploads/file/c9096c8d797c83c9f2549e659fba79eaf7a302ff.png)

#### 访问官网

其网址为：https://chat.openai.com/

#### 关于邮箱

我这边给你们指一条明路（坑已经被我踩过了）：注册时，不要用什么QQ邮箱等烂大街的国内邮箱，大部分人为了方便都会选择这些邮箱，也不要用什么自己的域名邮箱（如bytecho.net邮箱），这些通通会在手机验证码步骤时出现如下错误：
`Your account was flagged for potential abuse. If you feel this is an error, please contact us at help.openai.com.`

**请直接用Google账号注册**，别用花里胡哨的邮箱去注册。
![image.png](/api/v1/uploads/file/5ea7ac93ddabf50ca19e5529197898cb39a0640b.png)

并且这样还不需要接收邮件验证码，直接进入下一步。

#### 填写个人信息

![image.png](/api/v1/uploads/file/64d7d5a00a129a9acee85c40f1078832018e8cc1.png)

这一步暂时无坑。

#### 手机号验证

![image.png](/api/v1/uploads/file/4554777f408ba0d2f729d777220deb7cc462b8f4.png)

首先国内手机号不用想了，用不了，只能用一些虚拟手机号平台，如：https://sms-activate.org/

![image.png](/api/v1/uploads/file/ca10cd955aced883940b60bec220d5d8e608eb10.png)

直接选择OpenAI，选择印度尼西亚的手机号（目前还没有出现滥用问题），如果出现`Your account was flagged for potential abuse.`点×取消订单就行，然后再次下单会有新的手机号产生，反复尝试。

**注：如果邮箱有问题，不管你用什么手机号都会出现`Your account was flagged for potential abuse.`**

#### Over

![image.png](/api/v1/uploads/file/49ff2c3c13ed5f0e02e24cd207f1cb3e8a1f17ff.png)

### 功能演示

![image.png](/api/v1/uploads/file/70d11d6a06e205e7da08f9843bcdf3b2469a3a12.png)

![image.png](/api/v1/uploads/file/4ce0e72255fae95f7342ede332f715d4dc24d522.png)

![image.png](/api/v1/uploads/file/2e4889e0094f170a58458b7f30bae452a4532851.png)

![image.png](/api/v1/uploads/file/7920d30b544383e4d893661aeee9c3eb823df41c.png)

---

字节星球 Henry 2023-04-24 **未经许可 严禁转载！**
https://www.bytecho.net/archives/2257.html

</content></entry><entry><title>披着CLion的外衣实则在讲CMake</title><link href="https://www.bytecho.net/archives/2225.html" /><id>https://www.bytecho.net/archives/2225.html</id><updated>2023-01-29T04:08:00.000Z</updated><summary></summary><content type="html">### CLion 配置

#### 安装和基础设置

至于 CLion 安装和基础设置，网上教程一大把，而且不是学习重点，根据自己需求配置即可。

#### 工具链配置

这个配置是进行 C++ 开发的关键，因为这个编译工具链就意味着 C++ 的编译环境。

按下图点开对应的信息，如果你任何编译工具链都没有添加，由于新版本的 CLion 它会自带一个 mingw 的编译套件，所以默认会有一个 CLion 自带的 mingw 编译工具链。
如下图所示我的编译工具链稍微有点丰富，有 msvc、g++、clang++、mingw，作为一个刚刚入门学编程的新手，我建议编译工具链这一块暂时就没必要了解了，但在 CLion 中编译的具体配置流程我认为还是有必要讲清楚。

![ECC2NHNK0W3FXP.png](/api/v1/uploads/file/cdfafa145b627b17777bc5d8e93063f178762545.png)

CLion 中添加编译工具链非常简单，你本机把对应工具链的路径加入到了环境变量，那么在你点击 `+` 对应编译链类型后，会自动扫描到，如果实在没有扫描到，那么也可以自己填入对应的路径，整个编译链包括：

1. cmake，用于跨平台以及简化底层编译脚本的工具。
2. cmake 生成更底层的编译命令(对应上述的 Build Tool)，比如 gmake 也就是解析.makefile 文件进行命令执行，比如 ninja 解析 .ninja 文件进行命令执行（编译速度比 makefile 更快）。
3. C 语言的编译器(clang/gcc/cl 等等)。
4. C++ 的编译器(clang++/g++/cl 等等)。

如果是 mingw，那么上述的一套都是包含的，只需要把 Toolset 这个选项选择为 mingw 对应的目录即可，选择好后，CLion 会自动识别上述四件套的位置。

接下来简单介绍如何添加一些工具链：

* 安装 msvc 编译工具链：直接到官网下载 VS2022，然后安装对应 C++ 环境，打开 CLion 后添加 msvc 环境时就会自动识别。官网：https://visualstudio.microsoft.com/zh-hans/vs/
* 安装 wsl2：其实 wsl2 的安装已经被简化到了极致，在 powershell 中 `wsl --install` 即可。
  具体的官方文档如下：https://learn.microsoft.com/zh-cn/windows/wsl/install
* 如果需要使用 CLion 进行 Qt 开发，可以查看视频讲解：[www.bilibili.com/video/BV18q…](https://link.juejin.cn?target=https%3A%2F%2Fwww.bilibili.com%2Fvideo%2FBV18q4y1i7kV%2F &quot;https://www.bilibili.com/video/BV18q4y1i7kV/&quot;)  ，对应的配置信息：[gitee.com/yuexingqin/…](https://link.juejin.cn?target=https%3A%2F%2Fgitee.com%2Fyuexingqin%2Ftemplate_qtclion &quot;https://gitee.com/yuexingqin/template_qtclion&quot;)
* 如果需要使用 CLion 进行 STM32 开发，那么可以查看稚晖君在知乎写的博客教程：[zhuanlan.zhihu.com/p/145801160](https://link.juejin.cn?target=https%3A%2F%2Fzhuanlan.zhihu.com%2Fp%2F145801160 &quot;https://zhuanlan.zhihu.com/p/145801160&quot;)

### CMake 配置项

![X04TEKS7F84K2TFNI1.png](/api/v1/uploads/file/66706d6f2391d497b198e43d72860c2a3ee9893d.png)

如上图所示，第二个 `CMake` 选项就是我们现在要讲的，而这两个正好也是整个开发环境中最重要的东西，第一个编译工具链决定了 CLion 中已经识别了本机有哪些编译环境，而第二个 `CMake` 选项，则是用于配置 cmake 基于哪些配置项生成。

所以我们现在应该了解了 CLion 是如何去编译项目生成可执行文件的了。

1. 通过 cmake 配置选项运行整个项目的 CMakeList.txt
2. 生成 makefile 或其他底层脚本后再通过对应的工具去执行这个脚本
3. 运行编译好的程序

而我们现在讲的就是添加 cmake 配置选项，如果你手动写 cmake 命令的话，那样对应的就是命令行参数了。

![S2MKM8_ZB3X_QBSOB.png](/api/v1/uploads/file/533db8c32f9591e6d8964639fad91d6a5a005290.png)

上述图片中已经解释了一些配置的作用。这些配置项一般是不常改动，使用默认值就行，比如 `Build options` 是执行最后的脚本所用的参数，默认为 `-j 12`，比如如果是 makefile，那么就是 `make -j12`。

下面是大家可能需要进行一些配置的选项：

1. Build type：这是程序最终编译的类型，意味着编译器该以何种程度对源代码进行优化，比如 Debug 版本一般再 gcc 中对应 o2 的优化，release 版本对应 o3 的优化，两者一般存在 10 倍左右的性能差距。
2. Toolchain：这是前面所说的编译工具链，一般来说，想要切换编译器，你切换这个选项就行了，默认使用 default 工具链。
3. Generator：这是前面所说的工具链中的较为底层的脚本的运行工具，可以是 makefile 或者 ninja，不选的话也是默认工具链里的那个。
4. CMake options：这个是 cmake 运行时可以加入的命令行参数，比如我们可以-D 来定义对应的变量控制对应的 cmake 行为，甚至于前面的 Build type 我们完全可以不写（当然这是 CLion，这个空必须得被填充），然后使用 `-DCMAKE_BUILD_TYPE=Release`，这个变量可以决定最终 cmake 生成的执行脚本是按照 release 的标准去运行的，又比如 `-DBUILD_SHARED_LIBS=ON`，那么最终是会生成动态库而不是静态库，我上图中的 `-DENABLE_TEST=ON` 是内部的 cmake 有定义一个变量默认为 OFF 值，如果为 ON 时会加入测试代码为子项目。

现在 cmake 在 CLion 中的配置项已经讲完了，简单实践一下来体验之前讲的 CLion 到整个运行的流程：

1. 通过 cmake 配置选项运行整个项目的 CMakeList.txt。
2. 生成 makefile 或其他底层脚本后再通过对应的工具去执行这个脚本。 我们先看一眼上一步 cmake 生成的文件（放出了两个不同的配置项产生的脚本，第一个使用的 Generator 为 ninja，第二个使用的为 gmake）：![UAF6YJ6JWP4U3DJF2K5XW.png](/api/v1/uploads/file/b7da526156ac1e07684d0bcaae7155a47fe9f778.png)
   如果想要继续执行这个脚本，应该在 CLion 中执行对应的源代码，CLion 会自动识别入口点函数，然后给出可执行的按钮。点击执行后，不仅会直接对应的 makefile 或 build.ninja 还会顺便把这个程序运行到 CLion 内置的终端环境中。
3. 运行编译好的程序：这一步已经在第二步一并执行了。

#### CMake 的使用与实战

经过上述文字和图片讲解，我们很自然的想到，整个 CLion 运行 C++ 代码其实就是在运行 cmake 和 makefile(或 build.ninja)，第二个过程我们参与不了，但是第一个 cmake 的编写过程我们却需要一直接触。

下面用 CLion 新建项目自动生成的 cmake 模板来简单对 cmake 语法热热身。

```cmake
cmake_minimum_required(VERSION 3.22)
project(untitled)

set(CMAKE_CXX_STANDARD 17)

add_executable(untitled main.cpp)
```

* `cmake_minimum_required` 命令：规定了编译本项目的 cmake 工具至少需要 3.22 版本。
* `project` 命令：规定了本项目的项目名称，同时也根据这个传入的值生成了一堆变量，常用的如下：
  
  1. `PROJECT_NAME` ：项目名称
  2. `PROJECT_BINARY_DIR` ：项目的二进制文件目录，即编译后的可执行文件和库文件的输出目录
  3. `PROJECT_SOURCE_DIR` ：项目的源文件目录，即包含 CMakeLists.txt 文件的目录
  
  举个简单例子说明上述变量的作用：
  比如一个测试的子项目中的 CMakeList.txt，可能需要写下面的语句（先不管 file 命令），由于是作为直接的子项目，那么里面肯定不会存在 project 语句，所以 PROJECT_SOURCE_DIR 变量表示的仍然是整个项目的根目录，直接通过 `${}` 的形式来使用它即可，这样就不需要关心相对或绝对路径了。
  
  ```cmake
  file(GLOB SONIC_TEST_FILES
       &quot;${PROJECT_SOURCE_DIR}/tests/*.h&quot;
       &quot;${PROJECT_SOURCE_DIR}/tests/*.cpp&quot;
  )
  ```
* `set` 命令：设置对应变量为对应的值，该变量存在，则修改该变量的值，如果不存在则会创建并初始化为对应的值，这里对 set 的使用是设置了 CMAKE_CXX_STANDARD 变量为 17，这个变量可以控制最终编译采用的 C++ 版本，这里是使用 C++17。
* `add_executable` 命令：这是用于生成可执行程序的命令，第一个参数为该执行程序最终编译后生成的文件名，后面跟着的都是需要编译的源代码。

对于新手而言，其实不太需要自己手写 cmake，因为 CLion 会在你新建源文件的时候把相应源文件添加到 add_excutable 命令的后面，但项目稍微大一点或者说引入了很多外部库，那么大概率会抛弃 CLion 的这种自动化了。

#### 常用的 CMake 变量

下面只列出了部分变量的作用，更多的变量请查看文档：https://cmake.org/cmake/help/latest/manual/cmake-variables.7.html

* `PROJECT_NAME` ：项目名称
* `PROJECT_BINARY_DIR` ：项目的二进制文件目录，即编译后的可执行文件和库文件的输出目录
* `PROJECT_SOURCE_DIR` ：项目的源文件目录，即包含 CMakeLists.txt 文件的目录
* `CMAKE_BINARY_DIR` ：当前 CMake 运行的二进制文件目录，通常和 PROJECT_BINARY_DIR 是同一个目录
* `CMAKE_SOURCE_DIR` ：当前 CMake 运行的源文件目录，通常和 PROJECT_SOURCE_DIR 是同一个目录
* `CMAKE_C_STANDARD` ：指定 C 语言的标准版本
* `CMAKE_CXX_STANDARD` ：指定 C++ 语言的标准版本
* `CMAKE_CXX_FLAGS` ：指定编译 C++ 代码时使用的编译选项
* `CMAKE_C_FLAGS` ：指定编译 C 代码时使用的编译选项
* `CMAKE_EXE_LINKER_FLAGS` ：指定链接可执行文件时使用的链接选项
* `CMAKE_SYSTEM_NAME` ：指定当前操作系统名称（如 Windows、Linux 等）
* `CMAKE_SYSTEM_PROCESSOR` ：指定当前处理器的类型（如 x86、x86_64 等）
* `CMAKE_CXX_COMPILER_ID` ：指定了当前使用的 C++ 编译器，同理可得 C 的编译器对应的名字。

##### 对这些变量做一个简单的实践

通过 message 打印出 `PROJECT_BINARY_DIR、PROJECT_SOURCE_DIR、CMAKE_BINARY_DIR、CMAKE_SOURCE_DIR` 来加以验证，目录结构如下：

```
.
├── CMakeLists.txt
├── main.cpp
└── sub
    └── CMakeLists.txt
```

```cmake
main:
cmake_minimum_required(VERSION 3.14)
project(main)

add_subdirectory(sub)

message(STATUS &quot;main:${PROJECT_NAME}\n  pro-src:${PROJECT_SOURCE_DIR}\n pro-bin:${PROJECT_BINARY_DIR}\n cmake-src:${CMAKE_SOURCE_DIR}\n cmake-bin:${CMAKE_BINARY_DIR}&quot;)

sub:
project(sub)

message(STATUS &quot;sub:${PROJECT_NAME}\n  pro-src:${PROJECT_SOURCE_DIR}\n pro-bin:${PROJECT_BINARY_DIR}\n cmake-src:${CMAKE_SOURCE_DIR}\n cmake-bin:${CMAKE_BINARY_DIR}&quot;)
```

打印信息如下：我们发现 CMake 对应的变量没有变化，而 Prject 有了变量，因为我们在 sub 也使用了 project 命令。![QPUOIN6QRMBZK5OR.png](/api/v1/uploads/file/e740c1cfe20d008400479de4a0209b818eaa1ea6.png)

通过变量检测环境执行不同的 cmake 代码：

```cmake
# 判断当前的操作系统
if (CMAKE_SYSTEM_NAME MATCHES &quot;Linux&quot;)
target_link_libraries(my-logger PUBLIC fmt-header-only pthread)
message(STATUS &quot;Now is Linux&quot;)
elseif (CMAKE_SYSTEM_NAME MATCHES &quot;Windows&quot;)
target_link_libraries(my-logger PUBLIC fmt-header-only ws2_32)
message(STATUS &quot;Now is windows&quot;)
endif ()
# 判断当前使用的编译器
if (CMAKE_CXX_COMPILER_ID STREQUAL &quot;GNU&quot;)
# Do something for GCC
elseif (CMAKE_CXX_COMPILER_ID STREQUAL &quot;Intel&quot;)
# Do something for Intel C++
elseif (CMAKE_CXX_COMPILER_ID STREQUAL &quot;Microsoft&quot;)
# Do something for Microsoft Visual C++
elseif (CMAKE_CXX_COMPILER_ID STREQUAL &quot;Clang&quot;)
`# Do something for Clang`
endif()

# 判断当前的系统架构
if (CMAKE_SYSTEM_PROCESSOR MATCHES &quot;i.86|x86|x86_64|AMD64&quot;)
# Do something for x86 architecture
elseif (CMAKE_SYSTEM_PROCESSOR MATCHES &quot;^(arm|aarch64)&quot;)
# Do something for ARM architecture
elseif (CMAKE_SYSTEM_PROCESSOR MATCHES &quot;^(mips|mipsel|mips64)&quot;)
# Do something for MIPS architecture
elseif (CMAKE_SYSTEM_PROCESSOR MATCHES &quot;^(powerpc|ppc64)&quot;)
# Do something for PowerPC architecture
endif()
```

通过调整链接时的 flag 防止动态链接，因为如果你是使用 Windows 平台下的编译工具链，CLion 有些时候最终链接并不是采用静态链接，导致你最终生成的可执行程序没法直接执行，这个时候你就需要使用下面的命令来强制静态链接了：

```
set(CMAKE_EXE_LINKER_FLAGS &quot;-static&quot;)
```

#### 常用的 CMake 命令

下列只列出了部分命令，如果你以后有需要用到的其他命令，请前往官网进行查询：[cmake.org/cmake/help/…](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fmanual%2Fcmake-commands.7.html &quot;https://cmake.org/cmake/help/latest/manual/cmake-commands.7.html&quot;)

我个人较为常用的命令：

1. [project](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Fproject.html &quot;https://cmake.org/cmake/help/latest/command/project.html&quot;)：用于定义项目名称、版本号和语言。
2. [add_executable](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Fadd_executable.html &quot;https://cmake.org/cmake/help/latest/command/add_executable.html&quot;)：用于添加可执行文件。第一个参数很重要，被称为 target，可以作为 target_xxx 命令的接收对象。
3. [add_library](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Fadd_library.html &quot;https://cmake.org/cmake/help/latest/command/add_library.html&quot;)：用于添加库文件，可以创建静态库或动态库。第一个参数很重要，被称为 target，可以作为 target_xxx 命令的接收对象。简单使用如下
   
   ```cmake
   add_library(test_lib a.cc b.cc) #默认生成静态库
   add_library(test_lib SHARED a.cc b.cc) #默认生成静态库
   ```
4. [add_definitions](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Fadd_definitions.html &quot;https://cmake.org/cmake/help/latest/command/add_definitions.html&quot;)：用于添加宏定义，注意该命令没有执行顺序的问题，只要改项目中用了该命令定义宏，那么所有的源代码都会被定义这个宏 `add_definitions(-DFOO -DBAR ...)` 。
5. [add_subdirectory](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Fadd_subdirectory.html &quot;https://cmake.org/cmake/help/latest/command/add_subdirectory.html&quot;)：用于添加子项目目录，如果有该条语句，就先会跑去执行子项目的 cmake 代码，这样会导致一些需要执行后立马生效的语句作用不到，比如 include_directories 和 link_directories 如果执行在这条语句后面，则他们添加的目录在子项目中无法生效。有些命令如 target_include_directories 和 target_link_directories 是根据目标 target 是否被链接使用来生效的，所以这些命令的作用范围与执行顺序无关，且恰好同一个 cmake 项目中产生的库文件是可以直接通过名称链接的，无论链接对象是在子目录还是父目录
6. [target_link_libraries](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Ftarget_link_libraries.html &quot;https://cmake.org/cmake/help/latest/command/target_link_libraries.html&quot;)：用于将可执行文件或库文件链接到库文件或可执行文件。身为 target_xxx 的一员，很明显第二个参数也可以进行权限控制。
7. [include_directories](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Finclude_directories.html &quot;https://cmake.org/cmake/help/latest/command/include_directories.html&quot;)：用于指定头文件搜索路径，优点是简单直接，缺点是无法进行权限控制，一旦被执行后，后续的所有代码都能搜索到对应的文件路径。
8. [target_include_directories](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Ftarget_include_directories.html &quot;https://cmake.org/cmake/help/latest/command/target_include_directories.html&quot;)：指定头文件搜索路径，并将搜索路径关联到一个 target 上，这里的 target 一般是指生成可执行程序命令里的 target 或者生成库文件的 target，与上一个命令的不同点在于可以设置导出权限，比如现在我写了一个项目，这个项目引入了其他库，但是我不想让其他库的符号暴露出去（毕竟使用这个项目的人只关注这个项目的接口，不需要关注其他依赖的接口）可以通过 PRIVATE 将头文件搜索目录设置不导出的权限。
9. [link_directories](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Flink_directories.html &quot;https://cmake.org/cmake/help/latest/command/link_directories.html&quot;)：与前面的 include_directories 命令类似，添加的是库的搜索路径。
10. [target_link_directories](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Ftarget_link_directories.html &quot;https://cmake.org/cmake/help/latest/command/target_link_directories.html&quot;)：和前面的 include 版本一样的，只是改成了库路径。
11. if\elseif\endif ，在编程语言立马已经用烂了，现在主要是了解 if(condition) 中的条件到底如何判断的，以及内部都支持哪些操作，比如大于等于啥的，这方面直接看官方文档吧，非常好懂：[cmake.org/cmake/help/…](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Fif.html &quot;https://cmake.org/cmake/help/latest/command/if.html&quot;)
12. [aux_source_directory](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Faux_source_directory.html &quot;https://cmake.org/cmake/help/latest/command/aux_source_directory.html&quot;)：这个指令简单实用，第一个参数传递一个文件目录，它会扫描这里面所有的源文件放到第二个参数定义的变量名中。注意第一个参数只能是文件夹。`aux_source_directory(${PROJECT_SOURCE_DIR} SRC)`
13. [file](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Ffile.html &quot;https://cmake.org/cmake/help/latest/command/file.html&quot;)：可以说是上面那个命令的增强版本，但如果熟悉这个命令的朋友肯定很快站出来反对，因为这个命令实在是太强大了，你如果翻一翻这个官方文档就会发现它具备几乎文件系统的所有功能，什么读写文件啊，什么从网上下载文件，本地上传文件之类的它都有，计算文件的相对路径，路径转化等等。但我们平时用到的最多的命令还是用来获取文件到变量里。比如 file(GLOB FILES &quot;文件路径表示 1&quot; &quot;文件路径表示 2&quot; ...) GLOB 会产生一个由所有匹配 globbing 表达式的文件组成的列表，并将其保存到第二个参数定义的变量中。Globbing 表达式与正则表达式类似，但更简单，比如如果要实现前一个命令的功能可以这么写：`file(GLOB SRC &quot;${PROJECT_SOURCE_DIR}/*.cc&quot;)`，如果 GLOB 换成 GLOB_RECURSE ，那么上述命令将递归的搜寻其子目录的所有符合条件的文件，而不仅仅是一个层级。
14. [execute_process](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Fexecute_process.html &quot;https://cmake.org/cmake/help/latest/command/execute_process.html&quot;)：用于执行外部的命令，如下的示例代码是执行 git clone 命令，执行命令的工作目录在 `${CMAKE_BINARY_DIR}/deps/`：
    
    ```cmake
    execute_process(COMMAND git clone https://github.com/&lt;username&gt;/&lt;repository&gt;.git
    WORKING_DIRECTORY ${CMAKE_BINARY_DIR}/deps/&lt;repository&gt;)
    ```
15. [message](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Fmessage.html &quot;https://cmake.org/cmake/help/latest/command/message.html&quot;)：打印出信息用于 debug。
16. [option](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Foption.html &quot;https://cmake.org/cmake/help/latest/command/option.html&quot;)：用于快速设置定义变量并赋值为对应的 bool 值，常被用于判断某些操作是否执行。
17. [find_package](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Ffind_package.html%23id5 &quot;https://cmake.org/cmake/help/latest/command/find_package.html#id5&quot;)：用于查找外界的 package，其实就是查找外界对应的 `&lt;package&gt;Config.cmake` 和 `Find&lt;package&gt;.cmake` 文件，这些文件里有外界包对应的变量信息以及库和头文件的各种路径信息。我们需要注意一些有关 `find_package` 命令查找 Config.cmake 路径的变量：
    
    * `CMAKE_PREFIX_PATH` 变量是一个路径列表，CMake 会在这些路径中搜索包的 `Config.cmake` 文件。
    * `&lt;Package&gt;_DIR` 变量是指向包的 `Config.cmake` 文件的路径。如果你手动设置了这个变量，那么 `find_package` 命令就可以找到包的信息。
    
    同时他的一些常用参数如下：
    
    * `CONFIG` ：显式指定 find_package 去查找 `&lt;package&gt;Config.cmake` 文件，一般只要你在变量里面指定了 `&lt;package&gt;Config.cmake` 的路径，那么该参数填不填都没差别。我建议最好还是带上该参数比较好。
    * `REQUIRED` ：该参数表示如果没找到，那么直接产生 cmake 错误，退出 cmake 执行过程，如果没有 REQUIRED，则即使没找到也不会终止编译。
    * `PATHS` ：这个参数的效果和前面的变量类似，也是指定查找的路径。
    * `COMPONENTS` ：用于指定查找的模块，模块分离在不同的文件中，需要使用哪个就指定哪个模块。典型的就是使用 Qt 时的 cmake 代码，比如 `find_package(Qt5 COMPONENT Core Gui Widgets REQUIRED)` 。
    * VERSION：可能有很多个不同版本的包，则需要通过该参数来指定，如：`find_package(XXX VERSION 1.2.3)`。
18. [include](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Finclude.html &quot;https://cmake.org/cmake/help/latest/command/include.html&quot;)：从文件或模块加载并运行 CMake 代码。我用这个命令实际上只是为了使用 [FetchContent](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fmodule%2FFetchContent.html%23id1 &quot;https://cmake.org/cmake/help/latest/module/FetchContent.html#id1&quot;) 这个 module 的功能，该功能是从 cmake3.11 开始支持的，使用该 module 前需要通过 include 命令加载该模块，命令如下：`include(FetchContent)`
19. [FetchContent](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fmodule%2FFetchContent.html%23id1 &quot;https://cmake.org/cmake/help/latest/module/FetchContent.html#id1&quot;)：这是一个模块功能，它用来从代码仓库中拉取代码，例如我要把最近写的日志库引入到当前的项目中使用（注意这中间不会有任何代理，所以拉取 GitHub 的仓库可能失败）：
    
    ```
    include(FetchContent)# 引入功能模块
    
    FetchContent_Declare(
            my-logger  		 #项目名称
            GIT_REPOSITORY https://github.com/ACking-you/my-logger.git #仓库地址
            GIT_TAG        v1.6.2  #仓库的版本tag
            GIT_SHALLOW TRUE    #是否只拉取最新的记录
    )
    FetchContent_MakeAvailable(my-logger)
    
    add_excutable(main ${SRC})
    # 链接到程序进行使用
    target_link_libraries(main my-logger)
    ```
    
    这样引入第三方库的好处显而易见，优点类似于包管理的效果了，但缺少了最关键的中心仓库来确保资源的有效和稳定。参考 golang 再做个 proxy 层级就好了。
    同样可以拉取最新的 googletest 可以使用下列语句：
    
    ```
    FetchContent_Declare(
            googletest
            GIT_REPOSITORY https://github.com/google/googletest.git
            GIT_TAG        release-1.12.1
            GIT_SHALLOW TRUE
    )
    # For Windows: Prevent overriding the parent project&apos;s compiler/linker settings
    set(gtest_force_shared_crt ON CACHE BOOL &quot;&quot; FORCE)
    FetchContent_MakeAvailable(googletest)
    
    target_link_libraries(main gtest_main)
    ```
20. [function/endfunction](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Ffunction.html &quot;https://cmake.org/cmake/help/latest/command/function.html&quot;) ：在 cmake 中用于定义函数，复用 cmake 代码的命令。第一个参数为函数的名称，后面为参数的名称，使用参数和使用变量时一样的，但是如果参数是列表类型，则在传入的时候就会被展开，然后与函数参数依次对应，多余的参数被 `ARGN` 参数吸收。

更多较为常用的命令：

* [add_custom_command](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Fadd_custom_command.html &quot;https://cmake.org/cmake/help/latest/command/add_custom_command.html&quot;)：添加自定义规则命令，同样也是执行外界命令，但多了根据依赖和产物判断执行时机的作用。
* [install](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Finstall.html &quot;https://cmake.org/cmake/help/latest/command/install.html&quot;)：添加 install 操作。
* [string](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Fstring.html &quot;https://cmake.org/cmake/help/latest/command/string.html&quot;)：对 string 的所有操作，比如字符串替换啥的。
* [list](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Flist.html &quot;https://cmake.org/cmake/help/latest/command/list.html&quot;)：对 list 的所有操作，比如列表处理之类的。
* [foreach](https://link.juejin.cn?target=https%3A%2F%2Fcmake.org%2Fcmake%2Fhelp%2Flatest%2Fcommand%2Fforeach.html &quot;https://cmake.org/cmake/help/latest/command/foreach.html&quot;)：cmake 中的 for 循环。
* ...

利用上述命令实现 Qt 开发中调用 uic 工具把 大量的 `.ui` 文件转化为 .cpp 和 .h 文件，并实现当 ui 文件更新时或 .cpp/.h 文件不存在时才创建对应的 .cpp/.h 文件。

```
# 函数功能实现

function(get_ui_source)
foreach (item ${ARGN})
set(UIC_EXE_PATH ${VCPKG_ROOT}/installed/x64-windows/tools/qt5/bin/uic.exe)
get_filename_component(name ${item} NAME_WLE)
string(PREPEND name &quot;ui_&quot;)
set(output_h ${PROJECT_SOURCE_DIR}/ui_gen/${name}.h)
set(output_cpp ${PROJECT_SOURCE_DIR}/ui_gen/${name}.cpp)
file(TIMESTAMP ${item} ui_time)

# 当.h 文件已经存在时，仅当.ui 文件被更新了才重新生成.h 文件

if (EXISTS ${output_h})
file(TIMESTAMP ${output_h} h_time)
if (ui_time GREATER h_time)
execute_process(COMMAND ${UIC_EXE_PATH} ${item} -o ${output_h})
endif ()
else ()
execute_process(COMMAND ${UIC_EXE_PATH} ${item} -o ${output_h})
endif ()

# 当.cpp 文件已经存在时，仅当.ui 文件被更新了才重新生成.cpp 文件

if (EXISTS ${output_cpp})
file(TIMESTAMP ${output_cpp} cpp_time)
if (ui_time GREATER cpp_time)
execute_process(COMMAND ${UIC_EXE_PATH} ${item} -o ${output_cpp})
endif ()
else ()
execute_process(COMMAND ${UIC_EXE_PATH} ${item} -o ${output_cpp})
endif ()
endforeach ()
endfunction()
```

---

Henry 2023-01-29 【此文自用】修改、转载自：https://juejin.cn/post/7184793007302901820

</content></entry><entry><title>守望之墓/电子骨灰盒</title><link href="https://www.bytecho.net/archives/ow.html" /><id>https://www.bytecho.net/archives/ow.html</id><updated>2023-01-23T12:50:00.000Z</updated><summary></summary><content type="html">### 来自上海网之易

&gt; https://ow.blizzard.cn/article/news/2047?blzcmp=app

致各位亲爱的暴雪游戏玩家：

感谢您一直以来给予暴雪游戏产品的支持与厚爱，我们很荣幸能与大家共同携手走过 14 年的历程，一起创造并分享了难忘的游戏体验，再次向大家致以最衷心的感谢！

由于我们与合作方暴雪娱乐的协议期限即将届满，在中国大陆地区由上海网之易网络科技发展有限公司所运营的《魔兽世界》《炉石传说》《守望先锋》《暗黑破坏神 III》《星际争霸 II》《魔兽争霸 III：重制版》《风暴英雄》（以下统称“暴雪游戏产品”），将于 2023 年 1 月 24 日 0 时终止运营，现将终止中国大陆地区运营相关事项通知如下：

* 2022 年 11 月 23 日起，关闭暴雪游戏产品在战网以及客户端内的充值服务及用户注册入口。
  * 在 2022 年 11 月 23 日至 2023 年 1 月 23 日期间，暴雪游戏产品的服务器将正常开放，《魔兽世界》“巨龙时代”内容更新、《炉石传说》“巫妖王的进军”以及《守望先锋》“归来”第 2 赛季内容更新将照常上线，用户可继续登录并体验游戏内容。同时，用户在账户中留存的战网点和虚拟货币将依旧可以在战网商城进行消耗。
* 2023 年 1 月 24 日 0 时起，正式停止暴雪游戏产品的运营，关闭战网登录以及所有游戏服务器，同时关闭客户端下载。
  * 游戏服务器关闭后，各游戏内的所有账号数据及角色资料等（包括但不限于人物角色、剩余游戏时间、各游戏道具、素材、充值信息等）游戏数据将被封存。我们将按照法律法规的要求妥善处理游戏数据，保障用户合法权益。
  * 针对玩家在游戏内已充值但未消耗的网络游戏虚拟货币以及付费购买且仍未失效的游戏时间（如有），我们将在暴雪游戏产品停止运营后开始安排退款，详情请关注与绑定“暴雪游戏服务中心”公众号。

以上所述，还请您周知并相互转告，如有任何疑义，请随时与网易暴雪游戏客服联系（在线支持：[www.battlenet.com.cn/support/zh/](http://www.battlenet.com.cn/support/zh/)；联系电话：0571-28090163）。

我们对此次终止运营给您造成的不便深表歉意！我们十分感谢您的理解和原谅，也衷心期待您继续支持和关注！

上海网之易网络科技发展有限公司

2022 年 11 月 17 日

---

### 来自暴雪中国

亲爱的国服玩家们:

我们想通过今天这封信，对暴雪娱乐在国服地区的游戏服务状况向大家做一些说明。

我们明白，自从网易的相关公告发布以来，这段时间大家都很煎熬，而我们自己也深感忧虑。我们永远都把玩家放在第一位，无论各位来自世界的哪个角落。大家对未来的不确定感，也让我们这些已服务国服玩家社区 20 年的暴雪同事们感到很痛苦。

许多暴雪的同事都是游戏玩家。我们通过电子游戏结识了许多终生挚友，收获了许多珍贵回忆。这些游戏空间对我们来说意义重大，让我们能尽情享受乐趣，成为一个热爱暴雪游戏的玩家。我们非常理解游戏对人们有多重要。许多国服玩家也发来消息和邮件分享你们的感受，你们不能玩到最喜欢游戏的痛苦，你们与暴雪游戏、乃至暴雪早期经典游戏的共同成长经历，你们从那时起就一直是暴雪游戏的玩家。每封邮件读来都让我们唏噓感叹。

正是出于我们作为游戏玩家的个人体会，以及国服玩家向我们所表达的懊恼，我们上周再次与网易接触并寻求协助，以探讨将现有的，基于网易于 2019 年已同意既定条款的协议，顺延六个月，从而使大家得以不受干扰地继续游戏，也让暴雪继续探寻未来在国服地区合理而长远的发展道路。

不幸的是，网易并未在上周的顺延谈判后，接受我们关于顺延现有游戏服务协议的提议。因此我们将不得不遵照网之易停服公告于 1 月 23 日中止国服游戏服务。

---

### 守望之墓（电子骨灰盒）

本人 **2016 至今** 守望生涯之记录，鉴于其承载我和王者级人物二号的太多回忆，故留下其**电子骨灰盒**用于后人瞻仰，让众人铭记于 2016 年诞生之旷世绝唱！

![_20230123_203035.png](/api/v1/uploads/file/2d460c406692e97f0de50140c10284c8_20260506213953.png)
![_20230123_210020.png](/api/v1/uploads/file/ef42d27be0369cff2ba235a513726ee7d226f0e8.png)
![_20230123_203159.png](/api/v1/uploads/file/6fcbe444f566e37e97318b99d19ecfa9c9b0694f.png)
![_20230123_203254.png](/api/v1/uploads/file/75aacc49983064b5c4b5bab1f4e4c4ef84417d1b.png)

“六年”生死两茫茫，不思量，自难忘。 千里孤坟，无处话凄凉。 纵使相逢应不识，尘满面，鬓如霜。

——江城子·乙卯正月二十日夜记梦 苏轼

**致 Blizzard！**

---

### 来自网易公众号 停服前的讣告

亲爱的暴雪游戏玩家：2023 年 1 月 24 日 0 时，由网之易代理的《魔兽世界》《炉石传说》《守望先锋》《暗黑破坏神 Ⅲ》《魔兽争霸 Ⅲ：重制版》《风暴英雄》《星际争霸》系列产品在中国大陆市场的所有运营将正式终止。届时，暴雪将关闭战网登录以及所有游戏服务器，同时关闭客户端下载。

相伴 14 年，说再见很难。我们一直清楚知道，对每个玩家，包括我们自己而言，所有的角色、账号、装备和好友列表，绝不仅仅是一串代码，而是我们的青春，我们的热血，我们的一段美好人生。所以，我们不会忘记对玩家的承诺，仍将尽最大努力，为暴雪国服玩家服务到最后一刻，与玩家共同走完最后一里路。我们将于停服后公布暴雪游戏产品的退款工作安排，请各位玩家关注“暴雪游戏服务中心”公众号。

与国服玩家相伴 14 年，除了感谢，我们更感荣幸。

感谢每个玩家对服务器的包容，对客服服务的理解，对黄金赛现场排队的耐心，甚至对暴雪游戏频道直播中的每一个广告都愿意忍受。

我们更荣幸，大家将人生最重要的青春时光，选择与我们共同度过。我们一起在游戏里与时间为敌，也在平凡的生活里打怪升级，一起创造不可复制的青春回忆。

我们永远记得，曾与每一个玩家在艾泽拉斯的世界里，迎战一个又一个强大的敌人；在炉石酒馆的闲暇中，思考、构筑、切磋牌技；在守望先锋和黑爪的战斗中，成为这个世界需要的英雄；也在庇护之地、在时空枢纽、在科普卢星区，书写篇章、挥洒热血。

这些美好的回忆，不会因停服而消逝，它们就像宝石一样，会在我们未来的平凡生活里闪闪发亮。这也是为什么我们由衷地希望，这次停服不是国服玩家的终点，而只是一次无奈的暂停。

**我们始终坚信，相逢的人总能再相逢。**衷心期待所有暴雪玩家重返国服的那一天。愿风指引我们的道路，愿星辰照亮我们前进的方向。

网易公司 1 月 23 日

---

***这个未来值得为之奋战！***
字节星球 Henry 2023-01-23

</content></entry><entry><title>Python笔记 第三章</title><link href="https://www.bytecho.net/archives/2084.html" /><id>https://www.bytecho.net/archives/2084.html</id><updated>2022-08-21T07:41:00.000Z</updated><summary></summary><content type="html">### for 循环语句

```
for i in range(5): #[1,5）
    print(i)
```

```
for i in range(-10,-100,-30): #步长-30
    print(i)

# &gt;&gt; -10 -40 -70(每个数单独一行)
```

```
for i in range(0)
    print(i) #&gt;&gt; 无输出
```

```
for i in range(2,2)
    print(i) #&gt;&gt; 无输出
```

（注意 `:`，print 前有四个空格）

### for 循环遍历列表

#### 写法一

```
a = [&apos;vx&apos;,&apos;QQ&apos;,&apos;YEB&apos;]
for i in range(len(a)):# len 求列表长度（元素个数）
    print(i,a[i])
#&gt;&gt;0 vx
#1 QQ
#2 YEB
```

- len 也可以用来求字符串长度，元组、集合、字典元素个数

```
print(len(&quot;abc&quot;)) #&gt;&gt;3
```

#### 写法二

```
a = [&apos;vx&apos;,&apos;QQ&apos;,&apos;YEB&apos;]
for i in a:
    print(i)
#&gt;&gt;vx QQ YEB(转行)
```

```
for letter in &apos;omage&apos;:
    print (letter)
#&gt;&gt;o m a g e(转行)
```

### break 语句

```
a = [&apos;vx&apos;,&apos;QQ&apos;,&apos;YEB&apos;]
for i in a:
    if i ==&apos;QQ&apos;:
        print(&quot;over&quot;)
        break #跳出循环
    print(i)
else:
    print(&quot;Not break&quot;)
print(&quot;Done!&quot;)
#&gt;&gt;vx
#over
#Done!
```

### continue 语句

```
for letter in &apos;omage&apos;:
    if letter == &apos;a&apos;:
        continue #不执行此次循环，执行下一次循环
    print (letter)
#&gt;&gt;o
m
g
e
```

### 补充

* 字符的编码
  
  * ord(x) 求字符 x 的编码
  * chr(x) 求编码为 x 的字符
  
  （使用 ASCII 编码方案）

```
for i in range(26):
    print(chr(ord(&apos;a&apos;) + i),end=&quot;&quot;)
#连续输出 26 个英文字符
```

### 例题

```
n = int(input(&quot;请输入将要输入数字的个数：&quot;))
total = 0
for i in range(n): #进行 n 次
    total +=int(input(&quot;请输入要加的数字：&quot;))
print(total)
#输入 n 个整数求和
```

```
n = int(input(&quot;请输入一个整数：&quot;))
for i in range (1,n+1):
    if n % i == 0:
        print(i)

# 输入一个正整数 n，从小到大输出它的公因数
```

```
n = int(input(&quot;请输入一个整数：&quot;))
for i in range (n,0,-1):
    if n % i == 0:
    print(i)
#从大到小
```

### 多重循环

```
for i in range n：
    # ...
    for j in range m：
        # ...
```

```
#从 n 里取两个数使其和等于 m，且每个数只能取一次
c = input(&quot;请输入 n m:&quot;).split()
n,m = int(c[0]),int(c[1])
for i in range(1,n):# n-1 种取法
    for j in range(i + 1,n + 1):#使 i&gt;j,避免重复
        if m % (i + j) == 0:
            print(i,j)
            break #后面的 j 不再取，换下一个 i
```

* 多重循环中的 break 只会跳出那重循环，不会跳出多重循环

### while 循环

```
count = 0
while count&lt;5:
    print(count,&quot;小于 5&quot;)
    count = count + 1
else:
    print(count,&quot;大于或等于 5&quot;)
```

```
i=0
while i&lt;26:
    print(chr(ord(&apos;a&apos;) + i),end=&quot;&quot;)
    i+=1
```

```
s = input().split()
x,y,z = int(s[0]),int(s[1]),int(s[2])
n = m =max(x,y,z)#从最大的数开始试
while not(n%x==0 and n%y==0 and n%z==0):
    n +=m #隔 m 个试一次
print(n)
#求三个数的最小公倍数
```

### 综合例题

```
#求斐波那契数列第 n 项
n =int(input())
c1=1
c2=1
for i in range (n-2):
   c3=c1+c2
    c1=c2
    c2=c3
print(c3)
```

```
#求阶乘的和
n = int(input())
s=0
for i in range (1,n+1):
    f=1
    for j in range (1,i+1):
        f*=j #重复计算多
        s+=f
print(s)
```

```
# 角谷猜想
n=int(input(&quot;请输入一个正整数：&quot;))
while n!=1:
    if n%2==1:
    print(str(n)+&quot;*3+1=&quot;+str(n*3+1))
    n=n*3+1
    else :
        print(str(n)+&quot;/2=&quot;+str(n//2))
    n=n//2
print(&quot;End&quot;)

# 法2：
n=int(input(&quot;请输入一个正整数：&quot;))
b = n
for i in range(n):#n 取多少合适
temp=b
if b==1:
break
if b%2==1:
b1=b*3+1
b =b1
print(&quot;%d = %d * 3 + 1&quot;%(b1,temp))
if b%2==0:
b1=b/2
b=b1
print(&quot;%d = %d / 2&quot;%(b1,temp))
#上式如何改正,已改;法 2：
```

```
#输入一个范围，找出 2 的个数
s=input().split()
ran1,ran2=int(s[0]),int(s[1])
tol=0
for i in range(ran1,ran2+1):
    s=str(i)
    for x in s:
        if x ==&quot;2&quot;:
            tol+=1
print(tol)
#法 2：
s=input().split()
ran1,ran2=int(s[0]),int(s[1])
tol=0
for i in range(ran1,ran2+1):
    if i/10==2 and i%10==2:
        tol+=2
        continue
    if i/10==2 or i%10==2:
        tol+=1
print(&quot;%d&quot;%(tol))
```

---

字节星球 林栈 2022-08-21
https://www.bytecho.net/archives/2084.html

</content></entry><entry><title>WePlanet现已发布！</title><link href="https://www.bytecho.net/archives/2105.html" /><id>https://www.bytecho.net/archives/2105.html</id><updated>2022-08-20T08:09:00.000Z</updated><summary></summary><content type="html">### WePlanet (Desktop) - 🚀适用于小型团体的协作系统


交流、工作、活动、分析、管理、审批...等的功能均集成于简约轻便的 **WePlanet(Desktop)**。

- 平台：Windows x64
- 开发环境：Visual Studio 2022
- 数据库：MySQL
- 语言：C++ 11
- 框架：Qt5.15.x

### 开发进度

**已完成：**

* [x] 用户系统
* [x] 考勤系统
* [x] 个人管理
* [x] 用户管理
* [x] 版本公告
* [x] 活动系统
* [x] 权限系统
* [x] 组织架构
* [x] Markdown 通知动态
* [x] 数据图表
* [x] 数据导出
* [x] 自动更新
* [x] 登录检测
* [x] ECharts 数据大屏
* [x] 认证系统
* [x] 好友系统（测试版）
* [x] 审批系统
* [x] 审批流程设计

**开发计划：**

* [ ] 积分商城
* [ ] 更多...

### 开发进度截图

![1.png](/api/v1/uploads/file/b5ad6c55631958285758b6d8a720c78efd4aa226.png)
![2.png](/api/v1/uploads/file/39b49a342c56bbdffd0017d905f2f3ba733dbeb4.png)
![3.png](/api/v1/uploads/file/8cda1d4b9b4a4b7ccc867204f8a636da8676bd2e.png)
![4.png](/api/v1/uploads/file/b0f94f874f01d918aaced884bdd1b88114af926a.png)
![5.png](/api/v1/uploads/file/f514553b8a863e5ca04904f74c86519742502025.png)
![6.png](/api/v1/uploads/file/939c966b2a7bfab0aa08fb77141e21fcb41e7025.png)

更多截图请查看：https://github.com/csthenry/weplanet-desktop/tree/master/screenshot

### 注意

项目已转换为 Visual Studio 项目，开发环境 Visual Studio 2022， 请使用 Visual Studio 2017+ 添加此项目。

在添加项目前，请先阅读：https://www.bytecho.net/archives/qt_mysql.html

### LICENSE

[GPL-3.0 License](https://github.com/csthenry/weplanet-desktop/blob/master/LICENSE)

### 🔥软件下载

**已经停止维护！期待在下一个项目中相遇！**

🔥**自动更新程序现已实装**，可下载安装后通过软件自动更新检测程序更新至最新版本。

**普通用户直接注册即可试用基本用户功能，评论区可申请试用管理员账号。**


---

Copyright (C) 2017-2023 [字节星球](https://www.bytecho.net/) [Henry](https://www.bytecho.net/about.html)

</content></entry><entry><title>简单选择排序和堆排序</title><link href="https://www.bytecho.net/archives/2090.html" /><id>https://www.bytecho.net/archives/2090.html</id><updated>2022-08-05T14:22:00.000Z</updated><summary></summary><content type="html">最近在全面学习数据结构，常用算法记录：简单选择排序和堆排序，简单选择排序的基本思想是每一趟在待排序元素中选取关键字最小的元素加入有序子序列，直到所有元素有序，总共进行 $n-1$ 趟。
堆排序的基本思想见文末图片。

简单选择排序为**不稳定**排序。
堆排序为**不稳定**排序。

**简单选择排序时间复杂度：**

时间复杂度：$O(n^2)$
空间复杂度：$O(1)$

**堆排序时间复杂度：**

一个节点每下降一层，最多只需要比较两次关键字。若树高度为 $h$，某节点在第 $i$ 层，则将这个节点向下调整最多只需要下降 $h-i$ 层，那么对比次数不超过 ${2}(h-i)$，$n$ 个节点的完全二叉树树高 $h = \left\lfloor {{{\log }_2}n} \right\rfloor  + 1$。

将整棵树调整为大根堆，关键字比较次数不超过：

$$
\sum\limits_{{\rm{i}} = h - 1}^1 {{2^{i - 1}}2(h - i) = } \sum\limits_{{\rm{i}} = h - 1}^1 {{2^i}(h - i) = } \sum\limits_{j = 1}^{h - 1} {{2^{h - j}}j \le 2n\sum\limits_{j = 1}^{h - 1} {\frac{j}{{{2^j}}}} }  \le 4n
$$

建堆的过程关键字的对比次数不超过 ${4}n$，建堆的时间复杂度：$O(n)$

`heapSort`总共需要 $n-1$ 趟，每一趟完成后都需要将根节点下坠，根节点最多下降 $h-1$ 层，因此，每一趟排序的复杂度不超过 $O(h)=O({{{\log }_2}n})$，总共 $n-1$ 趟，故总时间复杂度：$O(n{{{\log }_2}n})$

故堆排序的时间复杂度：$O(n)+O(n{{{\log }_2}n})=O(n{{{\log }_2}n})$
空间复杂度：$O(1)$

```cpp
#include &lt;iostream&gt;

using namespace std;

void swap(int &amp;a, int &amp;b);
void selectSort(int arr[], int n);  //简单选择排序

void buildMaxHeap(int arr[], int len);  //建立大根堆
void headAdjust(int arr[], int k, int len);  //调整节点，使其较小节点下坠，使其符合大根堆的特性

void heapSort(int arr[], int len);  //堆排序（基于大根堆）

int main()
{
    int arr[] = {5, 7, 12, 6, 2, 0, 8, 15, 1, 11}, heap_arr[] = {-1, 5, 7, 12, 6, 2, 0, 8, 15, 1, 11};
    int length = (int)(sizeof(arr) / sizeof(int));  //数组长度
    selectSort(arr, length);
    for(auto item:arr)
        cout &lt;&lt; item &lt;&lt; &quot; &quot;;
    cout &lt;&lt; endl;

    heapSort(heap_arr, length);
    for(int i = 1; i &lt; length + 1; i++)
        cout &lt;&lt; heap_arr[i] &lt;&lt; &quot; &quot;;

    return 0;
}

void swap(int &amp;a, int &amp;b)
{
    int temp = a;
    a = b;
    b = temp;
}

void selectSort(int arr[], int n)
{
    for (int i = 0; i &lt; n - 1; i++)     //进行n-1次即可，最后一个数必然是最大的
    {
        int min = i;
        for (int j = i + 1; j &lt; n; j++)     //从i后面一个数开始，找到一个最小的数
        {
            if (arr[j] &lt; arr[min])
                min = j;    //记录最小数的下标
        }
        swap(arr[i], arr[min]);     //将最小数与i位置的数交换
    }
}

void buildMaxHeap(int arr[], int len)
{
    for (int i = len / 2; i &gt; 0; i--)  //从最后一个分支节点开始调整，0为暂存节点
        headAdjust(arr, i, len);
}

void headAdjust(int arr[], int k, int len)
{
    arr[0] = arr[k];  //将当前节点暂存给arr[0]
    for (int i = 2 * k; i &lt;= len; i *= 2)   //沿值较大的节点向下查找
    {
        if(i &lt; len &amp;&amp; arr[i] &lt; arr[i + 1])  //i &lt; len 保证当前节点有右孩子
            i++;    //记录左右子节点中较大的节点
        if(arr[0] &gt;= arr[i])
            break;    //如果当前节点大于等于左右子节点，则不需要调整
        else{
            arr[k] = arr[i];   //将左右子节点中较大的节点放入当前双亲节点
            k = i;  //替换为较大的节点，进入下一次循环，看是否满足大于左右孩子的条件
        }
    }
    arr[k] = arr[0];  //将待调整的节点的值放入最终位置
}

void heapSort(int arr[], int len)
{
    buildMaxHeap(arr, len);     //建立大根堆
    for (int i = len; i &gt; 1; i--)
    {
        swap(arr[1], arr[i]);   //堆顶和堆底交换，使堆底元素最大，注意堆顶是arr[1]，arr[0]是暂存节点
        headAdjust(arr, 1, i - 1);  //调整剩余的堆（i及其后面的序列已经有序），让堆顶元素下坠
    }
}
```

![image.png](/api/v1/uploads/file/973b41299ded8d482656b6523d3c88bf_20260506215333.png)

</content></entry><entry><title>希尔排序</title><link href="https://www.bytecho.net/archives/2087.html" /><id>https://www.bytecho.net/archives/2087.html</id><updated>2022-08-04T14:13:00.000Z</updated><summary></summary><content type="html">最近在全面学习数据结构，常用算法记录：希尔排序，基本思想是选定一个增量 $d&lt;n$，将元素按此增量分组（所有相距 $d$ 的元素为一组），然后在各个子序列内进行插入排序，完成后缩小增量 $d&apos;(d&apos;&lt;d)$，如此反复操作，直到增量 $d = 1$ 为止，此时就成了标准的插入排序，但此时大部分元素已经有序，只需要少量操作，甚至不用操作即可完成排序。该排序算法为**不稳定**排序。

希尔排序还是比较绕的，需要多看看，多画一画。

最坏时间复杂度：$O(n^2)$
$n$ 在某范围内可达：$O(n^{1.3})$
目前无法用数学手段证明确切的时间复杂度。

```
#include &lt;iostream&gt;

using namespace std;

void shellSort(int arr[], int n);

int main()
{
    int arr[] = {-1, 5, 7, 12, 6, 2, 0, 8, 15, 1, 11};
    int length = (int)(sizeof(arr) / sizeof(int));  //数组长度
    shellSort(arr, length);
    for (int i = 1; i &lt; length; i++)
        cout &lt;&lt; arr[i] &lt;&lt; &quot; &quot;;
    return 0;
}

void shellSort(int arr[], int n)
{
    int d, i, j;
    //arr[0]为暂存单元
    for (d = n / 2; d &gt; 0; d /= 2)  //d为步长
    {
        for (i = d + 1; i &lt;= n; i++)    //从子表中第二个元素开始
            if(arr[i] &lt; arr[i - d])     //小于子序列前一项
            {
                arr[0] = arr[i];    //暂存待插入元素
                for (j = i - d; j &gt; 0 &amp;&amp; arr[0] &lt; arr[j]; j -= d)
                    arr[j + d] = arr[j];    //向后移动，为待插入元素腾出空位
                arr[j + d] = arr[0];    //插入暂存元素
            }
    }
}
```

![image.png](/api/v1/uploads/file/36186589368b863791051c2cd69fd6df73f003c3.png)

</content></entry><entry><title>插入排序</title><link href="https://www.bytecho.net/archives/2078.html" /><id>https://www.bytecho.net/archives/2078.html</id><updated>2022-08-03T14:08:00.000Z</updated><summary></summary><content type="html">最近在全面学习数据结构，常用算法记录：插入排序，基本思想是将待排序的记录按其关键字的大小逐个插入到一个有序序列（通常为左半部分），直到所有记录插入完成，是一种**稳定**排序。
空间复杂度：$O(1)$
平均时间复杂度：$O(n^2)$

```cpp
#include &lt;iostream&gt;

using namespace std;

//直接插入排序（含哨兵）优点：不用判断j&gt;=0，哨兵即为循环结束标志
void insertSort_1(int arr[], int n);
//直接插入排序（不含哨兵）
void insertSort_2(int arr[], int n);
//折半（二分）插入排序 对直接插入排序的优化
void insertSort_3(int arr[], int n);

int main()
{
    int arr[] = {-1, 5, 7, 12, 6, 2, 0, 8, 15, 1, 11}, arr_2[] = {-1, 5, 7, 12, 6, 2, 0, 8, 15, 1, 11}, arr_normal[] = {5, 7, 12, 6, 2, 0, 8, 15, 1, 11};
    int length = (int)(sizeof(arr) / sizeof(int));  //数组长度
    insertSort_1(arr, length);
    insertSort_2(arr_normal, length - 1);
    for (int i = 1; i &lt; length; i++)
        cout &lt;&lt; arr[i] &lt;&lt; &quot; &quot;;
    cout &lt;&lt; endl;
    for(auto item:arr_normal)
        cout &lt;&lt; item &lt;&lt; &quot; &quot;;
    cout &lt;&lt; endl;
    insertSort_3(arr_2, length);
    for (int i = 1; i &lt; length; i++)
        cout &lt;&lt; arr_2[i] &lt;&lt; &quot; &quot;;
    return 0;
}

void insertSort_1(int arr[],int n)
{
    int i, j;
    for (i = 2; i &lt; n; i++)     //从第二个元素开始，arr[0]为哨兵
    {
        if(arr[i] &lt; arr[i - 1])     //arr[i - 1]为上一个有序序列中的最大元素
        {
            arr[0] = arr[i];
            for (j = i - 1; arr[0] &lt; arr[j]; --j)  //当前元素若小于或等于哨兵元素，则停止移动，哨兵插入该位置后
                arr[j + 1] = arr[j];    //往后移，为待插入元素腾出空位
            arr[j + 1] = arr[0];    //将哨兵插入
        }
    }
}

void insertSort_2(int arr[],int n)
{
    int i, j, temp;
    for (i = 1; i &lt; n; i++)     //从第二个元素开始，arr[0]为哨兵
    {
        if(arr[i] &lt; arr[i - 1])     //arr[i - 1]为上一个有序序列中的最大元素
        {
            temp = arr[i];  //临时存储待插入元素
            for (j = i - 1; j &gt;= 0 &amp;&amp; temp &lt; arr[j]; --j)  //当前元素若小于或等于待插入元素，则停止移动，待插入元素插入该位置后
                arr[j + 1] = arr[j];    //往后移，为待插入元素腾出空位
            arr[j + 1] = temp;    //待插入元素插入
        }
    }
}

void insertSort_3(int arr[], int n)
{
    int i, j, low, high, mid;
    for (i = 2; i &lt; n; i++)
    {
        arr[0] = arr[i];    //待插入元素存入哨兵节点
        low = 1, high = i - 1;  //折半查找的区域
        while(low &lt;= high){
            mid = (low + high) / 2;
            if(arr[mid] &gt; arr[0])
                high = mid - 1;     //查找左半部分
            else
                low = high + 1;     //查找右半部分，同时当arr[mid]==arr[0]时，继续在mid右方查找插入位置，保证算法稳定性
        }
        for (j = i - 1; j &gt;= high + 1; j--)     //循环大于哨兵的所有元素，均往后移动一位
            arr[j + 1] = arr[j];    //往后移，为待插入元素腾出空位
        arr[high + 1] = arr[0];    //待插入元素插入
    }
}
```

![image.png](/api/v1/uploads/file/57756162a219ed9528d5312fc416a2b4bbc4d4e5.png)

</content></entry></feed>