• hrust工程化学习(六)----最近邻搜索


    0. 简介

    上一章我们主要讲了噪声滤除这种比较高级的点云处理算法,下面这一章我们将来看一下最近邻搜索。最近邻搜索(Nearest Neighbor Search)是一种广泛应用于机器学习、计算机视觉和自然语言处理等领域的算法。它的主要目的是找到一个给定数据集中与查询数据最相似的数据点。

    1. CUDA与Thrust

    在传统的CPU计算中,最近邻搜索通常需要对每个数据点与查询点进行一次比较。这样的计算复杂度在数据集较大时会变得非常高,导致查询时间非常慢。而使用CUDA和Thrust方法可以加速这个过程,大大提高最近邻搜索的效率。

    CUDA是一种并行计算架构,它可以利用GPU的强大计算能力来加速各种计算任务。在最近邻搜索中,可以使用CUDA实现并行计算,将每个数据点与查询点之间的比较分配给GPU处理。这种方法可以大大缩短查询时间,并且可以处理大量的数据集。Thrust是一个基于CUDA的C++模板库,提供了一些高效的并行算法和数据结构。它可以轻松地将串行代码转换为并行代码,从而使代码更加高效。在最近邻搜索中,可以使用Thrust实现并行排序和并行查找算法,从而提高搜索效率。

    2. CUDA代码完成加速

    下面这段代码是基于哈希表的最近邻搜索算法的CUDA实现。该算法主要通过将三维点云分割成一个个小的栅格,并将点云放入栅格中,以此实现快速查找每个点云的最近邻。具体实现方法如下:

    首先,将点云分为两部分,分别为d_first_point_cloudd_second_point_cloud,其中d_second_point_cloud为需要查找最近邻的点云。然后,通过调用CUDA核函数kernel_nearestNeighborSearch,对每个d_second_point_cloud中的点进行查找。

    在核函数中,**首先根据点的位置确定该点所在栅格的索引。如果该点不在栅格内,则将最近邻索引设为-1。**接下来,遍历与该点所在栅格相邻的栅格,寻找距离该点最近的点,并返回该点的索引。

    在遍历相邻的栅格时,考虑两种情况:如果栅格与该点所在栅格相同,则在栅格中查找距离该点最近的点,并将最近邻的索引存储在nn_index中;否则,在该栅格中找到一定数量的最近邻点,将它们与nn_index中的点进行比较,并更新nn_index中的最近邻点。

    最后,将每个点的最近邻点的索引存储在d_nearest_neighbour_indexes数组中,以供后续使用。

    /nearest neighbourhood search
    /*
    最近邻搜索核函数
    param:
    d_first_point_cloud:第一个点云
    number_of_points_first_point_cloud:第一个点云的点数
    d_second_point_cloud:第二个点云
    number_of_points_second_point_cloud:第二个点云的点数
    d_hashTable:hash表
    d_buckets:栅格
    rgd_params:网格参数
    search_radius:搜索半径
    max_number_considered_in_INNER_bucket:在内桶中考虑的最大点数
    max_number_considered_in_OUTER_bucket:在外桶中考虑的最大点数
    d_nearest_neighbour_indexes:最近邻索引
    */
    __global__ void kernel_nearestNeighborSearch(
    	pcl::PointXYZ *d_first_point_cloud,
    	int number_of_points_first_point_cloud,
    	pcl::PointXYZ *d_second_point_cloud,
    	int number_of_points_second_point_cloud,
    	hashElement *d_hashTable,
    	bucket *d_buckets,
    	gridParameters rgd_params,
    	float search_radius,
    	int max_number_considered_in_INNER_bucket,
    	int max_number_considered_in_OUTER_bucket,
    	int *d_nearest_neighbour_indexes)
    {
    	int index_of_point_second_point_cloud = blockIdx.x * blockDim.x + threadIdx.x; // 第二个点云的点索引
    
    	if (index_of_point_second_point_cloud < number_of_points_second_point_cloud) // 如果索引小于第二个点云的点数
    	{
    		bool isok = false;
    
    		float x = d_second_point_cloud[index_of_point_second_point_cloud].x;
    		float y = d_second_point_cloud[index_of_point_second_point_cloud].y;
    		float z = d_second_point_cloud[index_of_point_second_point_cloud].z;
    
    		if (x < rgd_params.bounding_box_min_X || x > rgd_params.bounding_box_max_X) // 如果x坐标不在网格的范围内
    		{
    			d_nearest_neighbour_indexes[index_of_point_second_point_cloud] = -1; // 将最近邻索引设为-1
    			return;
    		}
    		if (y < rgd_params.bounding_box_min_Y || y > rgd_params.bounding_box_max_Y)
    		{
    			d_nearest_neighbour_indexes[index_of_point_second_point_cloud] = -1;
    			return;
    		}
    		if (z < rgd_params.bounding_box_min_Z || z > rgd_params.bounding_box_max_Z)
    		{
    			d_nearest_neighbour_indexes[index_of_point_second_point_cloud] = -1;
    			return;
    		}
    
    		int ix = (x - rgd_params.bounding_box_min_X) / rgd_params.resolution_X; // 计算栅格的索引
    		int iy = (y - rgd_params.bounding_box_min_Y) / rgd_params.resolution_Y;
    		int iz = (z - rgd_params.bounding_box_min_Z) / rgd_params.resolution_Z;
    
    		int index_bucket = ix * rgd_params.number_of_buckets_Y *
    							   rgd_params.number_of_buckets_Z +
    						   iy * rgd_params.number_of_buckets_Z + iz; // 计算栅格在整个队列中的索引
    		int nn_index = -1;
    
    		if (index_bucket >= 0 && index_bucket < rgd_params.number_of_buckets) //
    		{
    			int sx, sy, sz, stx, sty, stz;
    			if (ix == 0)
    				sx = 0;
    			else
    				sx = -1;
    			if (iy == 0)
    				sy = 0;
    			else
    				sy = -1;
    			if (iz == 0)
    				sz = 0;
    			else
    				sz = -1;
    
    			if (ix == rgd_params.number_of_buckets_X - 1)
    				stx = 1;
    			else
    				stx = 2;
    			if (iy == rgd_params.number_of_buckets_Y - 1)
    				sty = 1;
    			else
    				sty = 2;
    			if (iz == rgd_params.number_of_buckets_Z - 1)
    				stz = 1;
    			else
    				stz = 2;
    
    			float _distance = 100000000.0f;
    			int index_next_bucket;
    			int iter;
    			int number_of_points_in_bucket;
    			int l_begin;
    			int l_end;
    
    			for (int i = sx; i < stx; i++)
    			{
    				for (int j = sy; j < sty; j++)
    				{
    					for (int k = sz; k < stz; k++)
    					{
    						index_next_bucket = index_bucket +
    											i * rgd_params.number_of_buckets_Y * rgd_params.number_of_buckets_Z +
    											j * rgd_params.number_of_buckets_Z + k;
    						if (index_next_bucket >= 0 && index_next_bucket < rgd_params.number_of_buckets)
    						{
    							number_of_points_in_bucket = d_buckets[index_next_bucket].number_of_points;
    							if (number_of_points_in_bucket <= 0)
    								continue;
    
    							int max_number_considered_in_bucket;
    							if (index_next_bucket == index_bucket)
    							{
    								max_number_considered_in_bucket = max_number_considered_in_INNER_bucket;
    							}
    							else
    							{
    								max_number_considered_in_bucket = max_number_considered_in_OUTER_bucket;
    							}
    							if (max_number_considered_in_bucket <= 0)
    								continue;
    
    							if (max_number_considered_in_bucket >= number_of_points_in_bucket)
    							{
    								iter = 1;
    							}
    							else
    							{
    								iter = number_of_points_in_bucket / max_number_considered_in_bucket;
    								if (iter <= 0)
    									iter = 1;
    							}
    
    							l_begin = d_buckets[index_next_bucket].index_begin;
    							l_end = d_buckets[index_next_bucket].index_end;
    
    							for (int l = l_begin; l < l_end; l += iter)
    							{
    								if (l >= 0 && l < number_of_points_first_point_cloud)
    								{
    									int hashed_index_of_point = d_hashTable[l].index_of_point;
    									// inA[hashed_index_of_point].var = 1;
    
    									float nn_x = d_first_point_cloud[hashed_index_of_point].x;
    									float nn_y = d_first_point_cloud[hashed_index_of_point].y;
    									float nn_z = d_first_point_cloud[hashed_index_of_point].z;
    
    									float dist = (x - nn_x) * (x - nn_x) +
    												 (y - nn_y) * (y - nn_y) +
    												 (z - nn_z) * (z - nn_z);
    
    									if (dist <= search_radius * search_radius)
    									{
    										if (dist < _distance)
    										{
    											isok = true;
    											nn_index = hashed_index_of_point;
    											_distance = dist;
    										}
    									}
    								}
    							}
    						}
    					}
    				}
    			}
    		}
    
    		if (isok)
    		{
    			d_nearest_neighbour_indexes[index_of_point_second_point_cloud] = nn_index;
    		}
    		else
    		{
    			d_nearest_neighbour_indexes[index_of_point_second_point_cloud] = -1;
    		}
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166
    • 167
    • 168
    • 169
    • 170
    • 171
    • 172
    • 173
    • 174
    • 175
    • 176
    • 177
    • 178
    • 179
    • 180
    • 181
    • 182
    • 183

    然后我们下面的函数就是调用上面的函数一个CUDA并行的最近邻搜索算法。通过传入采用了两个点云,并将其中一个点云的每个点与第二个点云中最近的点进行匹配,即找到第二个点云中距离该点最近的点的索引。

    cudaError_t cudaNearestNeighborSearch(
    	int threads,
    	pcl::PointXYZ *d_first_point_cloud,
    	int number_of_points_first_point_cloud,
    	pcl::PointXYZ *d_second_point_cloud,
    	int number_of_points_second_point_cloud,
    	hashElement *d_hashTable,
    	bucket *d_buckets,
    	gridParameters rgd_params,
    	float search_radius,
    	int max_number_considered_in_INNER_bucket,
    	int max_number_considered_in_OUTER_bucket,
    	int *d_nearest_neighbour_indexes)
    {
    	cudaError_t err = cudaGetLastError();
    
    	int blocks = number_of_points_second_point_cloud / threads + 1;
    
    	kernel_nearestNeighborSearch<<<blocks, threads>>>(
    		d_first_point_cloud,
    		number_of_points_first_point_cloud,
    		d_second_point_cloud,
    		number_of_points_second_point_cloud,
    		d_hashTable,
    		d_buckets,
    		rgd_params,
    		search_radius,
    		max_number_considered_in_INNER_bucket,
    		max_number_considered_in_OUTER_bucket,
    		d_nearest_neighbour_indexes);
    
    	err = cudaDeviceSynchronize();
    	return err;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    该函数为在CUDA平台上实现最近邻搜索(nearest neighbor search)算法。其输入参数为两个点云(pcl::PointCloudpcl::PointXYZ &first_point_cloud,pcl::PointCloudpcl::PointXYZ &second_point_cloud),搜索半径search_radiusBounding box extension参数,以及max_number_considered_in_INNER_bucketmax_number_considered_in_OUTER_bucket,这两个参数是用于限制每个bucket中内部和外部考虑的最大点数。最后一个参数nearest_neighbour_indexes为最近邻索引结果的输出。

    函数的大致流程为:

    1. 检查最近邻索引的大小是否等于第二个点云的大小,如果不相等,返回false。
    2. 设置GPU设备,并且打印当前可用的设备线程数。
    3. 为第一个点云和第二个点云在GPU中分配内存,并将它们的数据从主机内存复制到GPU内存中。
    4. 计算网格参数,并在GPU中分配内存来存储哈希表和bucket
    5. 在GPU中计算网格并计算最近邻索引。
    6. 将最近邻索引从GPU内存复制回主机内存中,存储在nearest_neighbour_indexes中。
    7. 使用了OpenGL来显示点云数据。点云数据由两个不同的点云组成:first_point_cloudsecond_point_cloud。在循环中,对于第二个点云中的每个点,找到最近邻的点(在第一个点云中),并将这两个点作为一条线绘制出来。这里使用了OpenGL的glVertex3f函数来定义每个点的坐标。整个循环遍历second_point_cloud中的所有点,从而绘制所有的最近邻点对的连线。如果nearest_neighbour_indexes中的索引是-1,则说明该点没有找到最近邻点,不会被绘制出来。

    函数中涉及到一些额外的函数,如cudaCalculateGridParamscudaCalculateGridcudaNearestNeighborSearch,这些函数都是在CUDA平台上执行的,并负责在GPU上执行相应的操作。

    bool nearestNeighbourhoodSearch(
    	pcl::PointCloud<pcl::PointXYZ> &first_point_cloud,
    	pcl::PointCloud<pcl::PointXYZ> &second_point_cloud,
    	float search_radius,
    	float bounding_box_extension,
    	int max_number_considered_in_INNER_bucket,
    	int max_number_considered_in_OUTER_bucket,
    	std::vector<int> &nearest_neighbour_indexes)
    {
    	if (nearest_neighbour_indexes.size() != second_point_cloud.size()) // 如果最近邻索引的大小不等于第二个点云的大小
    		return false;												   // 返回false
    
    	cudaError_t err = ::cudaSuccess;
    	err = cudaSetDevice(0); // 设置设备
    	if (err != ::cudaSuccess)
    		return false;
    
    	std::cout << "Before cudaMalloc" << std::endl;
    	coutMemoryStatus();
    
    	gridParameters rgd_params;
    	pcl::PointXYZ *d_first_point_cloud = NULL;
    	pcl::PointXYZ *d_second_point_cloud = NULL;
    	int *d_nearest_neighbour_indexes = NULL;
    	hashElement *d_hashTable = NULL;
    	bucket *d_buckets = NULL;
    
    	int threads = getNumberOfAvailableThreads();
    	std::cout << "CUDA code will use " << threads << " device threads" << std::endl;
    	if (threads == 0)
    		return false;
    
    	err = cudaMalloc((void **)&d_first_point_cloud, first_point_cloud.points.size() * sizeof(pcl::PointXYZ)); // 为第一个点云分配内存
    	if (err != ::cudaSuccess)
    		return false;
    	err = cudaMemcpy(d_first_point_cloud, first_point_cloud.points.data(), first_point_cloud.points.size() * sizeof(pcl::PointXYZ), cudaMemcpyHostToDevice); // 将第一个点云的数据复制到GPU中
    	if (err != ::cudaSuccess)
    		return false;
    
    	err = cudaMalloc((void **)&d_second_point_cloud, second_point_cloud.points.size() * sizeof(pcl::PointXYZ)); // 为第二个点云分配内存
    	if (err != ::cudaSuccess)
    		return false;
    	err = cudaMemcpy(d_second_point_cloud, second_point_cloud.points.data(), second_point_cloud.points.size() * sizeof(pcl::PointXYZ), cudaMemcpyHostToDevice); // 将第二个点云的数据复制到GPU中
    	if (err != ::cudaSuccess)
    		return false;
    
    	err = cudaCalculateGridParams(d_first_point_cloud, first_point_cloud.points.size(),
    								  search_radius, search_radius, search_radius, bounding_box_extension, rgd_params); // 计算网格参数
    	if (err != ::cudaSuccess)
    		return false;
    	std::cout << "regular grid parameters:" << std::endl;
    	std::cout << "bounding_box_min_X: " << rgd_params.bounding_box_min_X << std::endl;
    	std::cout << "bounding_box_min_Y: " << rgd_params.bounding_box_min_Y << std::endl;
    	std::cout << "bounding_box_min_Z: " << rgd_params.bounding_box_min_Z << std::endl;
    	std::cout << "bounding_box_max_X: " << rgd_params.bounding_box_max_X << std::endl;
    	std::cout << "bounding_box_max_Y: " << rgd_params.bounding_box_max_Y << std::endl;
    	std::cout << "bounding_box_max_Z: " << rgd_params.bounding_box_max_Z << std::endl;
    	std::cout << "number_of_buckets_X: " << rgd_params.number_of_buckets_X << std::endl;
    	std::cout << "number_of_buckets_Y: " << rgd_params.number_of_buckets_Y << std::endl;
    	std::cout << "number_of_buckets_Z: " << rgd_params.number_of_buckets_Z << std::endl;
    	std::cout << "resolution_X: " << rgd_params.resolution_X << std::endl;
    	std::cout << "resolution_Y: " << rgd_params.resolution_Y << std::endl;
    	std::cout << "resolution_Z: " << rgd_params.resolution_Z << std::endl;
    
    	err = cudaMalloc((void **)&d_hashTable, first_point_cloud.points.size() * sizeof(hashElement));
    	if (err != ::cudaSuccess)
    		return false;
    
    	err = cudaMalloc((void **)&d_buckets, rgd_params.number_of_buckets * sizeof(bucket));
    	if (err != ::cudaSuccess)
    		return false;
    
    	err = cudaMalloc((void **)&d_nearest_neighbour_indexes, second_point_cloud.points.size() * sizeof(int));
    	if (err != ::cudaSuccess)
    		return false;
    
    	std::cout << "After cudaMalloc" << std::endl;
    	coutMemoryStatus();
    
    	err = cudaCalculateGrid(threads, d_first_point_cloud, d_buckets, d_hashTable, first_point_cloud.points.size(), rgd_params); // 计算网格
    	if (err != ::cudaSuccess)
    		return false;
    
    	err = cudaNearestNeighborSearch(
    		threads,
    		d_first_point_cloud,
    		first_point_cloud.points.size(),
    		d_second_point_cloud,
    		second_point_cloud.points.size(),
    		d_hashTable,
    		d_buckets,
    		rgd_params,
    		search_radius,
    		max_number_considered_in_INNER_bucket,
    		max_number_considered_in_OUTER_bucket,
    		d_nearest_neighbour_indexes); // 计算最近邻索引
    	if (err != ::cudaSuccess)
    		return false;
    
    	err = cudaMemcpy(nearest_neighbour_indexes.data(), d_nearest_neighbour_indexes, second_point_cloud.points.size() * sizeof(int), cudaMemcpyDeviceToHost); // 将最近邻索引复制到CPU中
    	if (err != ::cudaSuccess)
    	{
    		return false;
    	}
    
    	err = cudaFree(d_first_point_cloud);
    	d_first_point_cloud = NULL;
    	if (err != ::cudaSuccess)
    		return false;
    
    	err = cudaFree(d_second_point_cloud);
    	d_second_point_cloud = NULL;
    	if (err != ::cudaSuccess)
    		return false;
    
    	err = cudaFree(d_hashTable);
    	d_hashTable = NULL;
    	if (err != ::cudaSuccess)
    		return false;
    
    	err = cudaFree(d_buckets);
    	d_buckets = NULL;
    	if (err != ::cudaSuccess)
    		return false;
    
    	err = cudaFree(d_nearest_neighbour_indexes);
    	d_nearest_neighbour_indexes = NULL;
    	if (err != ::cudaSuccess)
    		return false;
    
    	std::cout << "After cudaFree" << std::endl;
    	coutMemoryStatus();
    
    	return true;
    }
    
    //然后可以尝试着opengl显示
    for (size_t i = 0; i < second_point_cloud.size(); i++)
    	{
    		int index_nn = nearest_neighbour_indexes[i];
    		if (index_nn != -1 && index_nn >= 0 && index_nn < first_point_cloud.size())
    		{
    			glVertex3f(second_point_cloud[i].x, second_point_cloud[i].y, second_point_cloud[i].z);
    			glVertex3f(first_point_cloud[index_nn].x, first_point_cloud[index_nn].y, first_point_cloud[index_nn].z);
    		}
    	}
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146

    3. Thrust代码完成加速

    这段代码是使用Thrust库实现的最近邻搜索的函数。该函数的输入是两个点云first_point_cloudsecond_point_cloud,以及一个搜索半径search_radius。输出是一个向量nearest_neighbour_indexes,其中包含了first_point_cloud中每个点的最近邻在second_point_cloud中的索引。此外,在is_nearest_neighbor函数中,使用了一个for循环来遍历second_point_cloud中的每个点,计算该点和查询点之间的距离,并找到最小距离的点。如果找到了最近邻,则将最近邻的索引赋值给nearest_neighbour_indexes向量中对应的位置。这里使用了一个bool变量found_nearest_neighbor来标记是否找到了最近邻,函数返回该变量的值。

    …详情请参照古月居

  • 相关阅读:
    分布式存储之hash取余算法
    08 数据库查询(2) | OushuDB 数据库使用入门
    远程运维用什么软件?可以保障更安全?
    Apifox很难不爱
    CleanClip for Mac 剪切板 粘贴工具 历史记录 安装(保姆级教程,新手小白轻松上手)
    C# OpenVino Yolov8 Detect 目标检测
    红帽认证笔记2
    3大主流分布式事务框架详解(图文总结)
    do end用法的妙处
    SpringBoot 04 多环境配置和配置文件小技巧
  • 原文地址:https://blog.csdn.net/lovely_yoshino/article/details/129344647