2024年3月

1、准备材料

正点原子stm32f407探索者开发板V2.4

STM32CubeMX软件(
Version 6.10.0

Keil µVision5 IDE(
MDK-Arm

野火DAP仿真器

XCOM V2.6串口助手

2、学习目标

本文主要学习 FreeRTOS 软件定时器的相关知识,
包括软件定时器回调函数、属性、状态、运行原理和常见 API 函数等知识

3、前提知识

3.1、软件定时器回调函数

软件定时器的回调函数
是一个返回值为 void 类型,并且只有软件定时器句柄一个参数的 C 语言函数
,其函数的具体原型如下所述

/**
  * @brief  软件定时器回调函数
  * @param  xTimer:软件定时器句柄
  * @retval None
  */
void ATimerCallback(TimerHandle_t xTimer)
{
	/* do something */
}

软件定时器回调函数会在定时器设定的时间到期时在 RTOS 守护进程任务中被执行
,软件定时器回调函数从头到尾执行,并以正常方式退出

需要读者注意的是软件定时器的回调函数应尽可能简短,
并且在该函数体内不能调用任何会使任务进入阻塞状态的 API 函数
,但是如果设置调用函数的 xTicksToWait 参数为 0 ,则可以调用如 xQueueReceive() 等 API 函数

3.2、软件定时器属性和状态

3.2.1、周期

这个属性比较好理解,软件定时器的周期指的是
从软件定时器启动到软件定时器回调函数执行之间的时间
,该属性是定时器不可或缺的重要属性

3.2.2、分类

软件定时器根据行为的不同
分为了 单次定时器(One-shot timers) 和 周期定时器(Auto-reload timers) 两种类型
,如下图展示了两种不同类型软件定时器的行为差异
(注释1)

3.2.3、状态

根据定时器是否正在运行可以将定时器分为 运行状态(Running) 和 休眠状态(Dormant) 两种不同状态
,如下图所示展示了单次定时器和周期定时器在两种不同状态之间的转换过程

从图上可以看出以下几点内容

  1. 不管是单次定时器还是周期定时器,在定时器创建成功之后都处于休眠状态,一旦调用启动、复位或改变定时器周期的 API 函数就会使定时器从休眠状态转移到运行状态;
  2. 单次定时器定时时间到期之后执行一次回调函数就会自动转换为休眠状态,而周期定时器会一直处于运行状态;
  3. 当对处于运行状态的定时器调用停止 API 函数时,不管是哪种定时器都会转变为休眠状态

定时器的状态可以通过 xTimerlsTimerActive() API 函数查询,该函数具体声明如下所述

/**
  * @brief  查询软件定时器是否处于运行或休眠状态
  * @param  xTimer:要查询的定时器句柄
  * @retval 如果定时器处于休眠状态则返回pdFALSE,如果定时器处于运行状态则返回pdTRUE
  */
BaseType_t xTimerIsTimerActive(TimerHandle_t xTimer);

3.3、软件定时器运行原理

3.3.1、RTOS 守护进程任务

首先读者应该知道的一点是
所有软件定时器的回调函数都在同一个 RTOS 守护进程任务的上下文中执行
,这个 RTOS 守护进程任务和空闲任务一样,在调度器启动的时候会被自动创建, RTOS 守护进程任务的优先级和堆栈大小分别由
configTIMER_TASK_PRIORITY

configTIMER_TASK_STACK_DEPTH
两个参数设置(可在 STM32CubeMX 软件中配置)

”3.1、软件定时器回调函数“ 小节提到在回调函数中不能使用会使任务进入阻塞状态的 API 函数,这是因为调用会使任务进入阻塞状态的 API 函数会使 RTOS 守护进程任务进入阻塞状态,这是不被允许的

3.3.2、定时器命令队列

上面提到的
软件定时器的启动、复位、改变定时器周期和停止等操作的 API 函数只是将控制定时器的命令从调用任务发送到称为 “定时器命令队列” 的队列上,然后由 RTOS 守护进程任务从定时器命令队列中取出命令对定时器实际操作

定时器命令队列是 FreeRTOS 里的一个标准队列,其也是在调度程序启动时被自动创建的,定时器命令队列的长度可以由
configTIMER_QUEUE LENGTH
参数设置

如下图所示为软件定时器 API 函数使用定时器命令队列与 RTOS 守护程序任务进行通信的示意图

3.3.3、守护进程任务调度

守护进程任务是一个 FreeRTOS 任务,所以其任务调度会遵循和其他任务一样的调度规则,
当守护进程任务是能够运行的最高优先级任务时,它将会处理定时器队列中的命令或执行定时器的回调函数

守护进程任务的优先级在 STM32CubeMX 中默认为 2 ,当守护进程任务的优先级低于调用 xTimerStart() 等 API 函数的任务的优先级时,其会在任务结束之后轮到守护进程任务执行时对 “开始定时器” 命令进行处理,具体如下图所示

当守护进程任务的优先级高于调用 xTimerStart() 等 API 函数的任务的优先级时,一旦任务调用 xTimerStart() 等 API 函数将命令写入定时器命令队列,守护进程任务便可以抢占该任务,立即处理写入定时器命令队列的命令,处理完毕之后进入阻塞状态,处理器返回原任务继续执行,具体如下图所示

3.4、创建、启动软件定时器

同样,根据 FreeRTOS API 的惯例,创建软件定时器仍然提供了动态内存创建和静态内存创建两个不同的 API 函数,软件定时器可以在调度程序运行之前创建,也可以在调度程序启动后从任务创建,如下所示为两个 API 函数声明

/**
  * @brief  动态分配内存创建软件定时器
  * @param  pcTimerName:定时器的描述性名称,辅助调试用
  * @param  xTimerPeriod:定时器的周期,参考 “3.2.1、周期” 小节
  * @param  uxAutoReload:pdTRUE表示周期软件定时器,pdFASLE表示单次软件定时器
  * @param  pvTimerID:定时器ID
  * @param  pxCallbackFunction:定时器回调函数指针,参考 “3.1、软件定时器回调函数” 小节
  * @retval 创建成功则返回创建的定时器的句柄,失败则返回NULL
  */
TimerHandle_t xTimerCreate(const char * const pcTimerName,
						   const TickType_t xTimerPeriod,
						   const UBaseType_t uxAutoReload,
						   void * const pvTimerID,
						   TimerCallbackFunction_t pxCallbackFunction);

/**
  * @brief  动态分配内存创建软件定时器
  * @param  pcTimerName:定时器的描述性名称,辅助调试用
  * @param  xTimerPeriod:定时器的周期,参考 “3.2.1、周期” 小节
  * @param  uxAutoReload:pdTRUE表示周期软件定时器,pdFASLE表示单次软件定时器
  * @param  pvTimerID:定时器ID
  * @param  pxCallbackFunction:定时器回调函数指针,参考 “3.1、软件定时器回调函数” 小节
  * @param  pxTimerBuffer:指向StaticTimer_t类型的变量,然后用该变量保存定时器的状态
  * @retval 创建成功则返回创建的定时器的句柄,失败则返回NULL
  */
TimerHandle_t xTimerCreateStatic(const char * const pcTimerName,
								  const TickType_t xTimerPeriod,
								  const UBaseType_t uxAutoReload,
								  void * const pvTimerID,
								  TimerCallbackFunction_t pxCallbackFunction
								  StaticTimer_t *pxTimerBuffer);

创建完的软件定时器处于休眠状态,需要调用启动定时器或其他 API 函数才会进入运行状态
,xTimerStart() 可以在调度程序启动之前调用,但是完成此操作后,软件定时器直到调度程序启动的时间才会真正启动,启动定时器的 API 函数如下所述

/**
  * @brief  启动定时器
  * @param  xTimer:要操作的定时器句柄
  * @param  xBlockTime:参考 “3.4.1、xTicksToWait 参数” 小节
  * @retval 参考 “3.4.2、函数返回值” 小节
  */
BaseType_t xTimerStart(TimerHandle_t xTimer,
					   TickType_t xTicksToWait);

/**
  * @brief  启动定时器的中断安全版本
  * @param  xTimer:要操作的定时器句柄
  * @param  pxHigherPriorityTaskWoken:用于通知应用程序编写者是否应该执行上下文切换
  * @retval 参考 “3.4.2、函数返回值” 小节
  */
BaseType_t xTimerStartFromISR(TimerHandle_t xTimer,
							  BaseType_t *pxHigherPriorityTaskWoken);

3.4.1、
xTicksToWait
参数

xTimerStart() 使用定时器命令队列向守护进程任务发送 “启动定时器” 命令,
xTicksToWait
指定调用任务应保持在阻塞状态以等待定时器命令队列上的空间变得可用的最长时间(如果队列已满),该参数需要注意以下几点

  1. 如果
    xTicksToWait
    为零且定时器命令队列已满,xTimerStart() 将立即返回,该参数以滴答定时器时间刻度为单位,可以使用宏 pdMS_TO_TICKS() 将以毫秒为单位的时间转换为以刻度为单位的时间,例如 pdMS_TO_TICKS(50) 表示阻塞 50ms

  2. 如果在 FreeRTOSConfig.h 中将 INCLUDE_vTaskSuspend 设置为 1,则将
    xTicksToWait
    设置为 portMAX_DELAY 将导致调用任务无限期地保持在阻塞状态(没有超时),以等待定时器命令队列中的空间变得可用

  3. 如果在启动调度程序之前调用 xTimerStart(),则
    xTicksToWait
    的值将被忽略,并且 xTimerStart() 的行为就像 xTicksToWait 已设置为零一样

3.4.2、xTimerStart() 函数返回值

有两种可能的返回值,分别为 pdPASS 和 pdFALSE ,具体如下所述

① 仅当 “启动定时器” 命令成功发送到定时器命令队列时,才会返回 pdPASS

  1. 如果守护程序任务的优先级高于调用 xTimerStart() 的任务的优先级,则调度程序将确保在 xTimerStart() 返回之前处理启动命令。这是因为一旦定时器命令队列中有数据,守护任务就会抢占调用 xTimerStart() 的任务,从而总是保证将命令成功发送到定时器命令队列
  2. 如果指定了阻塞时间(xTicksToWait 不为零),则调用任务可能会被置于阻塞状态,以等待定时器命令队列中的空间在函数返回之前变得可用,只要在阻塞时间到期之前命令已成功写入定时器命令队列,就可以返回 pdPASS

② 如果由于队列已满或超过阻塞时间等原因无法将 “启动定时器” 命令写入定时器命令队列,则将返回 pdFALSE

  1. 如果指定了阻塞时间(xTicksToWait 不为零),则调用任务将被置于阻塞状态以等待守护进程任务在定时器命令队列中腾出空间,但是指定的阻塞时间在等待定时器命令队列中腾出空间之前已过期,所以返回 pdFALSE

3.6、软件定时器 ID

每个软件定时器都有一个 ID ,它是一个标签值,应用程序编写者可以将其用于任何目的
, ID 被存储在空指针中,因此可以直接存储整数值,指向任何其他对象,或用作函数指针

创建软件定时器时会为 ID 分配一个初始值,之后可以使用 vTimerSetTimerID() API 函数更新 ID,并且可以使用 pvTimerGetTimerID() API 函数查询 ID ,这两个 API 函数具体如下所示

/**
  * @brief  设置定时器ID值
  * @param  xTimer:要操作的定时器句柄
  * @param  pvNewID:想要设置软件定时器的新ID值
  * @retval None
  */
void vTimerSetTimerID(TimerHandle_t xTimer, void *pvNewID);

/**
  * @brief  获取定时器ID值
  * @param  xTimer:要操作的定时器句柄
  * @retval 正在查询的软件定时器ID
  */
void *pvTimerGetTimerID(TimerHandle_t xTimer);

注意:与其他软件定时器 API 函数不同,vTimerSetTimerID() 和 pvTimerGetTimerID() 直接访问软件定时器,它们不向定时器命令队列发送命令

如果创建了多个软件定时器,并且所有软件定时器均使用了同一个回调函数,
则可以给软件定时器设置不同的 ID 值,然后在回调函数中通过 ID 值判断软件定时器触发的来源

3.7、改变软件定时器周期

创建软件定时器时就会为定时器周期设置初始值,后续也可以使用 xTimerChangePeriod() 函数动态更改软件定时器的周期,该函数具体声明如下所示

/**
  * @brief  改变软件定时器的周期
  * @param  xTimer:要操作的定时器句柄
  * @param  xNewPeriod:软件定时器的新周期,以刻度为单位指定
  * @param  xBlockTime:参考 “3.4.1、xTicksToWait 参数” 小节
  * @retval 参考 “3.4.2、xTimerStart() 函数返回值” 小节
  */
 BaseType_t xTimerChangePeriod(TimerHandle_t xTimer,
							   TickType_t xNewPeriod,
							   TickType_t xBlockTime);

/**
  * @brief  改变软件定时器周期的中断安全版本
  * @param  xTimer:要操作的定时器句柄
  * @param  xNewPeriod:软件定时器的新周期,以刻度为单位指定
  * @param  pxHigherPriorityTaskWoken:用于通知应用程序编写者是否应该执行上下文切换
  * @retval 参考 “3.4.2、xTimerStart() 函数返回值” 小节
  */
 BaseType_t xTimerChangePeriodFromISR(TimerHandle_t xTimer,
									  TickType_t xNewPeriod,
									  BaseType_t *pxHigherPriorityTaskWoken);

如果 xTimerChangePeriod() 用于更改已运行的定时器的周期,则定时器将使用新的周期值重新计算其到期时间,
重新计算的到期时间是相对于调用 xTimerChangePeriod() 的时间,而不是相对于定时器最初启动的时间

如果使用 xTimerChangePeriod() 更改处于休眠状态(未运行的定时器)的定时器的周期,则定时器将计算到期时间,并转换到运行状态(定时器将开始运行)

另外如果希望查询一个定时器的定时周期,可以通过 xTimerGetPeriod() API 函数查询,具体函数声明如下所述

/**
  * @brief  查询一个软件定时器的周期
  * @param  xTimer:要查询的定时器句柄
  * @retval 返回一个软件定时器的周期
  */
TickType_t xTimerGetPeriod(TimerHandle_t xTimer);

3.8、重置软件定时器

重置软件定时器是指重新启动定时器,
定时器的到期时间将根据重置定时器的时间重新计算,而不是相对于定时器最初启动的时间
,如下图对此进行了演示,其中显示了一个定时器,该定时器启动的周期为 6,然后重置两次,最后到期并执行其回调函数

FreeRTOS中使用 xTimerReset() API 函数重置软件定时器,除此之外还可用于启动处于休眠状态的定时,该函数具体声明如下所述

/**
  * @brief  重置软件定时器
  * @param  xTimer:要操作的定时器句柄
  * @param  xBlockTime:参考 “3.4.1、xTicksToWait 参数” 小节
  * @retval 参考 “3.4.2、xTimerStart() 函数返回值” 小节
  */
BaseType_t xTimerReset(TimerHandle_t xTimer,
					   TickType_t xBlockTime);

/**
  * @brief  重置软件定时器的中断安全版本
  * @param  xTimer:要操作的定时器句柄
  * @param  pxHigherPriorityTaskWoken:用于通知应用程序编写者是否应该执行上下文切换
  * @retval 参考 “3.4.2、xTimerStart() 函数返回值” 小节
  */
BaseType_t xTimerResetFromISR(TimerHandle_t xTimer,
							  BaseType_t *pxHigherPriorityTaskWoken);

3.8、停止、删除软件定时器

/**
  * @brief  停止软件定时器
  * @param  xTimer:要操作的定时器句柄
  * @param  xBlockTime:参考 “3.4.1、xTicksToWait 参数” 小节
  * @retval 参考 “3.4.2、xTimerStart() 函数返回值” 小节
  */
BaseType_t xTimerStop(TimerHandle_t xTimer,
					  TickType_t xBlockTime);

/**
  * @brief  删除软件定时器
  * @param  xTimer:要操作的定时器句柄
  * @param  xBlockTime:参考 “3.4.1、xTicksToWait 参数” 小节
  * @retval 参考 “3.4.2、xTimerStart() 函数返回值” 小节
  */
BaseType_t xTimerDelete(TimerHandle_t xTimer,
						TickType_t xBlockTime);

/**
  * @brief  停止软件定时器的中断安全版本
  * @param  xTimer:要操作的定时器句柄
  * @param  pxHigherPriorityTaskWoken:用于通知应用程序编写者是否应该执行上下文切换
  * @retval 参考 “3.4.2、xTimerStart() 函数返回值” 小节
  */
BaseType_t xTimerStopFromISR(TimerHandle_t xTimer,
							 BaseType_t *pxHigherPriorityTaskWoken);

3.9、其他 API 函数

/**
  * @brief  将软件定时器的“模式”更新为 自动重新加载定时器 或 一次性定时器 
  * @param  xTimer:要操作的定时器句柄
  * @param  uxAutoReload:设置为pdTRUE则将定时器设置为周期软件定时器,设置为pdFASLE则将定时器设置为单次软件定时器
  * @retval None
  */
void vTimerSetReloadMode(TimerHandle_t xTimer,
						 const UBaseType_t uxAutoReload);

/**
  * @brief  查询软件定时器是 单次定时器 还是 周期定时器
  * @param  xTimer:要查询的定时器句柄
  * @retval 如果为周期软件定时器则返回pdTRUE,否则返回pdFALSE
  */
BaseType_t xTimerGetReloadMode(TimerHandle_t xTimer);

/**
  * @brief  查询软件定时器到期的时间
  * @param  xTimer:要查询的定时器句柄
  * @retval 如果要查询的定时器处于活动状态则返回定时器下一次到期的时间,否则未定义返回值
  */
TickType_t xTimerGetExpiryTime(TimerHandle_t xTimer);

4、实验一:软件定时器的应用

4.1、实验目标

  1. 创建一个周期软件定时器 TimerPeriodic 和一个单次软件定时器 TimerOnce
  2. 创建一个按键扫描任务 Task_KeyScan,根据不同按键实现不同响应
  3. 当按键 WK_UP 按下时,设置周期定时器以 500ms 周期执行;当按键 KEY2 按下时,设置单次定时器以 1s 周期执行一次;当按键 KEY1 按下时,对周期定时器进行复位操作;当按键 KEY0 按下时,停止 TimerPeriodic 周期定时器

4.2、CubeMX相关配置

首先读者应按照 "
FreeRTOS教程1 基础知识
" 章节配置一个可以正常编译通过的 FreeRTOS 空工程,然后在此空工程的基础上增加本实验所提出的要求

本实验需要初始化 USART1 作为输出信息渠道,具体配置步骤请阅读“
STM32CubeMX教程9 USART/UART 异步通信
”,如下图所示

本实验需要初始化开发板上 WK_UP、KEY2、KEY1 和 KEY0 用户按键做普通输入,具体配置步骤请阅读“
STM32CubeMX教程3 GPIO输入 - 按键响应
”,注意虽开发板不同但配置原理一致,如下图所示

单击 Middleware and Software Packs/FREERTOS ,在 Configuration 中单击 Tasks and Queues 选项卡,双击默认任务修改其参数,如下所示

单击 Timers and Semaphores ,在 Timers 中创建周期、单次两个软件定时器,如下所示

配置 Clock Configuration 和 Project Manager 两个页面,接下来直接单击 GENERATE CODE 按钮生成工程代码即可

4.3、添加其他必要代码

按照 “
STM32CubeMX教程9 USART/UART 异步通信
” 实验 “6、串口printf重定向” 小节增加串口 printf 重定向代码,具体不再赘述

首先应该在 freertos.c 中添加软件定时器的头文件和使用到的 printf 的头文件,如下所述

#include "timers.h"
#include "stdio.h"

然后实现按键扫描任务函数体,当按键 WK_UP 按下时启动周期软件定时器,当按键 KEY2 按下时启动单次软件定时器,当按键 KEY1 按下时对周期软件定时器进行复位操作,当按键 KEY0 按下时停止周期定时器,具体如下所述

void AppTask_KeyScan(void *argument)
{
	/* USER CODE BEGIN AppTask_KeyScan */
	uint8_t key_value = 0;
	/* Infinite loop */
	for(;;)
	{
		key_value = 0;
		//按键WK_UP按下
		if(HAL_GPIO_ReadPin(WK_UP_GPIO_Port,WK_UP_Pin) == GPIO_PIN_SET)
			key_value = 4;
		//按键KEY2按下
		if(HAL_GPIO_ReadPin(KEY2_GPIO_Port,KEY2_Pin) == GPIO_PIN_RESET)
			key_value = 3;
		//按键KEY1按下
		if(HAL_GPIO_ReadPin(KEY1_GPIO_Port,KEY1_Pin) == GPIO_PIN_RESET)
			key_value = 2;
		//按键KEY0按下
		if(HAL_GPIO_ReadPin(KEY0_GPIO_Port,KEY0_Pin) == GPIO_PIN_RESET)
			key_value = 1;
		
		if(key_value != 0)
		{
			if(key_value == 4)
			{
				if(xTimerChangePeriod(TimerPeriodicHandle, 500, pdMS_TO_TICKS(500)) == pdTRUE)
				{
					printf("\r\nWK_UP PRESSED, TimerPeriodic Start!\r\n\r\n");
				}
			}
			if(key_value == 3)
			{
				if(xTimerChangePeriod(TimerOnceHandle, 1000, pdMS_TO_TICKS(500)) == pdTRUE)
				{
					printf("\r\nKEY2 PRESSED, TimerOnce Start!\r\n\r\n");
				}
			}
			else if(key_value == 2)
			{
				if(xTimerReset(TimerPeriodicHandle, pdMS_TO_TICKS(500)) == pdTRUE)
				{
					printf("\r\nKEY1 PRESSED, TimerPeriodic Reset!\r\n\r\n");
				}
			}
			else if(key_value == 1)
			{
				if(xTimerStop(TimerPeriodicHandle, pdMS_TO_TICKS(500)) == pdTRUE)
				{
					printf("\r\nKEY0 PRESSED, TimerPeriod Stop!\r\n\r\n");
				}
			}
			//有按键按下就进行按键消抖
			osDelay(300);
		}
		else
			osDelay(10);
	}
	/* USER CODE END AppTask_KeyScan */
}

最后实现单次/周期软件定时器的两个回调函数即可,回调函数内不做任何具体操作,仅通过串口输出提示信息,如下所述

/* appTimerPeriodic function */
void appTimerPeriodic(void *argument)
{
  /* USER CODE BEGIN appTimerPeriodic */
	printf("Into appTimerPeriodic Function\r\n");
  /* USER CODE END appTimerPeriodic */
}

/* appTimerOnce function */
void appTimerOnce(void *argument)
{
  /* USER CODE BEGIN appTimerOnce */
	printf("Into appTimerOnce Function\r\n");
  /* USER CODE END appTimerOnce */
}

4.4、烧录验证

烧录程序,打开串口助手后无任何信息输出,当按下开发板上的 WK_UP 按键之后,会启动以 500ms 为周期的周期软件定时器,此时周期软件定时器的回调函数会周期得到执行;当按下开发板上的 KEY2 按键之后,会启动 1s 为周期的单次软件定时器,此时单次软件定时器的回调函数会得到执行,并且只执行了一次就停止了执行;当按下开发板上的 KEY1 按键时,会复位周期定时器;当按下开发板上的 KEY0 按键时,会停止周期定时器,整个过程串口的输出信息如下图所示

5、注释详解

注释1
:图片来源于
Mastering_the_FreeRTOS_Real_Time_Kernel-A_Hands-On_Tutorial_Guide.pdf

参考资料

STM32Cube高效开发教程(基础篇)

Mastering_the_FreeRTOS_Real_Time_Kernel-A_Hands-On_Tutorial_Guide.pdf

为什么要写这个教程

在毕业之后,读者写过了大量的文章和开源项目,正是坚持一边学习一边输出,所以笔者最终从一个生菜鸡进化为一个熟菜鸡。

在程序员的成长中,我们会在思路,如何学习、如何进步,比如要成长为一个架构师,需要具备什么样的能力。比如说技术能力,技术能力是最核心的基础,那么我们技术上要达到哪种程度?可以手撸框架?脚踢汇编、拳打微服务架构?

常常会有技术社区朋友和读者进行交流,很多读者不知道怎么学习。笔者也是,我也不知道怎么学,各类东西都在学、都在搞,还没有确定要专门做什么,比如最近在学 AI。年轻人,总是很迷茫,未来的出路在哪里?可能是一夜暴富。

回归正题,之所以写这个教程,一则是笔者比较喜欢将技术、思考、知识都沉淀到代码和文档中,同时基于开源精神,与大家分享这些东西,所以花了很多时间,编写这个开源项目和技术文档。笔者常常会编写一系列比较长的文章、电子书,因为长系列的教程可以获得完整、系统的知识,而短小、零散的文章不利于读者学习、归纳知识。写长文章是很累的,而教程完全开源,所以喜欢本系列教程的读者们,不妨到仓库点亮一颗星星。

转载请保留出处。

关于从零设计 .NET 开发框架

作者:痴者工良

仓库地址:
https://github.com/whuanle/maomi

文档地址:
https://maomi.whuanle.cn

作者博客:

https://www.whuanle.cn

https://www.cnblogs.com/whuanle

项目和教程介绍

Maomi 框架是一个简单的、简洁的开发框架,除了框架本身提供的功能之外,Maomi 还作为一个易于阅读的开源项目,能够给开发者提供设计框架的思路和代码。

Maomi 框架目前具有模块化和自动服务注册、多语言、事件总线、Web 四个模块。而整个解决方案中一共有 62 个项目,包括了日常部分框架的编写示例,例如怎么制作类似 dotnet-dump 的诊断工具、怎么定制日志框架以及怎么写一个日志框架、怎么使用 EMIT 写一个 AOP、怎么使用 Roslyn 写一个代码编译器、怎么设计类似 ABP 的模块化等,还包括了单元测试。

本教程主要分为四个部分:

  • 基础知识,首先介绍开发这类框架的基础知识,一些抽象和原理,但是不会深入探讨源码和细节,避免造成阅读困难。
  • 设计和抽象,想要开发一个框架时,不要急着写代码,要思考需要做什么,然后怎么抽象接口。
  • 开始编码,开始落实编码,享受撸码的快乐。
  • 扩展技术,单元测试、性能测试、制作 nuget 包、制作工具包等等。

本教程一个有六十多个项目,每篇文章基本都会指导读者如何开发对应的框架,每章都会有大量的示例,并且这些示例都可以在仓库中找到,避免读者阅读文章源码时,不知道怎么创建项目和跑示例,读者可以直接下载仓库源码示例慢慢调试、理解代码。

如果你想从零编写一个自己的开发框架,那么本教程非常适合你,本教程囊括了 .NET 开发中常见的各类框架知识以及实现原理,每一章都是的一部分,可以只挑感兴趣的部分看,希望可以帮助你学习、了解怎么编写各类框架。

以下是教程目录:

  • 1.模块化和自动服务注册


    讲解 Maomi.Core 的使用方法和基本原理

  • 2.模块化和自动服务注册的设计和实现


    讲解 Maomi.Core 是如何设计和实现,我们想开发一个框架时,怎么从设计、抽象、编码到最后实现。讲解了模块化和自动服务注册的原理,如何从零开发,最后制作 nuget 包,分发到 nuget.org 给所有人使用。

  • 3.故障诊断和日志


    介绍故障诊断的一些方法,以及 .NET 中的日志抽象接口。

  • 3.1.自定义开发日志框架


    如何自己设计、开发一个日志框架。

  • 3.2. .NET 日志使用技巧


    非常推荐阅读,介绍了 Serilog 的配置、使用方法,介绍了生命周期作用域、属性、日志模板等相关说明,以便在程序运行时,输出非常高效的日志,为排查问题带来方便。很多开发者使用日志都很敷衍,不知道怎么利用好日志工具,那么这篇文章可以帮到你。

  • 3.3.开发 .NET 诊断工具


    介绍一些 .NET 诊断的方法和原理,然后介绍如何开发 dotnet-trace、dotnet-counters、dotnet-dump 等这样的工具,没错,我们也可以写出这样的工具!

  • 4.配置和选项


    简述了 IConfiguration 、Options 的原理和使用方法,自定义配置提供器、使用 signalR 实现一个配置中心。

  • 5.NET 中的序列化和反序列化


    本章的内容比较丰富,讲解了 .NET 下序列化和反序列化的一些特征、自定义配置、使用技巧,如何自定义枚举转换器、字符串转换器、时间格式转换器等,详细讲解了实现细节。最后介绍了 Utf8JsonReader 和怎么编写性能测试代码,通过 Utf8JsonReader 解析 json 的示例,让读者掌握原理,在后续章节中,还会介绍如何使用 Utf8JsonReader 实现多语言等基础能力。

  • 6.多语言


    本章内容比较丰富,首先介绍 Maomi.I18n 框架的使用方法,ASP.NET Core 是怎么识别多语言请求和使用多语言的,了解 i18n 框架需要做什么,然后开始设计抽象、编写实现代码。编写框架完毕后,还需要编写单元测试,笔者介绍了如何编写单元测试。接着介绍了如何基于 Redis 实现多语言,最后介绍如何在 nuget 包中打包多语言文件与他人共享。

  • 7.http 应用开发


    本章内容详细介绍了 HttpClient 的使用方法,除了基础知识外,还包括比如请求参数、请求凭证、异常处理,接着详细介绍了 IHttpClientFactory ,包括请求拦截、请求策略(重试、超时)等技术。介绍了 Refit 工具的使用方法,如何在业务开发中使用 Refit 快速生成 http 请求代码,简化开发过程。最后介绍如何自己编写一个类似 curl 的工具,掌握使用 .NET 编写命令行工具的技术和技巧。

  • 8.事件总线框架的设计


    事件总线是 DDD 开发中最常用的解耦通讯手段,所以本章会带着读者从零设计一个事件总线框架,从抽象设计到编写,讲解了每个环节的原理和实现代码。事件总线中会使用到反射、委托、表达式树等技术,如果你对表达式树不了解,没关系,先照着做、按照教程学,不需要死扣技术细节,只需要掌握大体设计和开发思路即可。

  • 9.动态代码


    本章内容比较丰富,讲解了 EMIT 技术和如何开发 AOP 框架,表达式树的两种使用方法、编写对象映射框架、简单的 ORM 框架,介绍 Roslyn 技术、代码生成和编译、Natasha 框架的简单使用,最后介绍了 Source Generators (简称 sg 技术)实现代码生成。

    限于篇幅,本章不会过隙讨论各种技术,如果读者需要打好基础,可以参考笔者其它电子书:

    反射基础:
    https://reflect.whuanle.cn/

    表达式树基础:
    https://ex.whuanle.cn

  • 10.Web 框架定制开发


    本章内容比较丰富,日常开发中大家都会定制 Web 框架,以使用企业内部需求,那么本章介绍了开发中比较常见的东西,以及如何定制它,比如模型验证是怎么实现的、如何自定义模型验证器、模型验证器中使用 i18n,各种筛选器的使用方法和技巧、定制开发筛选器(Action 筛选器、资源筛选器、异常筛选器),Swagger 定制(模型类属性类型转换、接口分组、接口版本号、微服务路由后缀)等。

  • 11.对象映射框架


    详细介绍了 Maomi.Mapper 的使用方法。

哈喽大家好,我是咸鱼。

近年来,DevOps 已经成为一门将软件开发 (Dev) 与 IT 运维 (Ops) 相融合的重要学科,目的是为了缩短软件的开发生命周期并提供高质量软件的持续交付。

这篇文章整理了十种基本的免费开源的 DevOps 工具,这些工具已经在实践中得到了证明,凭借有效性和能够简化 DevOps 流程的能力从众多相关工具中脱颖而出。

今天要介绍的工具覆盖了从持续集成和交付 (CI/CD) 到基础架构即代码 (IaC)、监控等一系列需求,确保你能应对各种挑战。

除此之外,这些工具已经成为每个 DevOps 工程师了解和使用的必备工具,掌握它们的使用方法能够助你在职业生涯更上一层楼。

在正式开始之前,我们先来看一个基本问题:什么是 DevOps?

原文链接:
https://linuxiac.com/top-devops-tools/

什么是 DevOps?

DevOps 是一组实践和方法,将开发(创建软件的人员)和运维(部署和维护软件的人员)团队聚集在一起。

这有什么意义吗?为什么 DevOps 这么重要?让我们以一种易于理解的方式去了解它。

想象一下,你是一个拼图团队的一员。开发团队负责设计和制作拼图,而运维团队负责将拼图拼在一起并确保拼完后的正确的。

在传统流程中,开发团队和运维团队各干各的,缺乏交流,这就导致交付延迟和最终产品不完全合适。

而 DevOps 确保每个人从一开始就一起工作,分担责任并持续沟通,以更快更有效地方式解决问题。DevOps 相当于一个桥梁,把软件的创建和操作连接到一个有凝聚力、高效和富有成效的工作流程中。

换句话说,DevOps 确保两个团队协同工作并使用相同剧本。 最终目标是提高软件质量和可靠性,并加快向最终用户交付软件所需的时间。

DevOps 关键概念

持续集成 (CI):

这种做法涉及到开发人员将代码更改合并到中央存储库中,并在其中进行代码自动构建和测试,目的是快速发现并修复集成错误。

持续交付(CD):

继 CI 之后,持续交付可以自动地将软件交付到指定的基础环境中,确保在部署的时候能够极大地减轻人工干预。

自动化

自动化是 DevOps 的核心。它适用于测试、部署甚至基础设施配置,能够减少人工操作,最大限度地减少错误并加快软件交付流程。

监控和用户反馈

持续地对软件和基础架构的性能监控至关重要,这样能够快速定位和解决问题,而用户反馈能够基于真实用户体验的持续改进。

DevOps 生命周期

掌握 DevOps 生命周期的各个阶段是充分理解 DevOps 本质的关键。下面我们一起来看看 DevOps 生命周期的各个阶段。

  1. 计划(plan):在这个阶段,产品和开发团队决定软件的特性和功能。为需要构建的内容制定蓝图。
  2. 代码(code):开发人员根据计划编写代码来创建软件,这个阶段涉及使用编程语言和工具将想法转化成产品。
  3. 构建(build):代码编写后下一步是把代码编译成可运行的应用程序。
  4. 测试(test):测试对确保软件的质量和可靠性至关重要,这个阶段将执行自动测试以便在将软件交付给用户之前找到问题并修复。
  5. 部署和运行(deploy & run):一旦软件通过了所有测试,就可以发布它并将其运行到用户可以访问它的生产环境中。部署应该自动化,这样可以实现频繁且可靠的发布,并尽量减少人工干预。
  6. 监控(monitor):监控涉及收集、分析和使用软件性能和运行情况的数据,用来识别问题,相关趋势或有没有什么需要改进的地方。
  7. 升级改进(improve):最后阶段形成闭环,通过来自监控的数据和最终用户体验的反馈来进行对未来的改进或更改做出明智的决策。

要做到上面这些,离不开特定的软件和工具。下面就让我们一起来看下跟 DevOps 密切相关的软件和工具

DevOps 工具

Linux 系统

Linux 是所有 DevOps 活动的支柱,使一切成为可能。简而言之,如果没有 Linux,我们所知的 DevOps 就不会存在。

了解 Linux 的基础知识至关重要。 如果没有这些知识,在 DevOps 领域想要获得高水平的专业知识和成功可能会寸步难行。

Docker

Docker 和容器技术已成为 DevOps 方法的基础。 它们彻底改变了开发人员构建、发布和运行应用程序的方式,前所未有地缩小了代码和部署之间的差距。

容器允许开发人员将应用程序及其所需的所有部分(例如库和其他依赖项)打包在一起,并将其作为一个包进行发布。

这种一致性显着减少了“它在我的机器上能够运行怎么到了其他机器上就运行不了”的综合症,能够简化开发生命周期并提高生产力。

同时,Docker 容器可以在几秒钟内启动和停止,从而更容易管理高峰负载。这种灵活性在当今的敏捷开发流程和持续交付周期中至关重要,能够让团队更快、更可靠地将软件更新推送到生产环境。

容器还提供应用程序之间的隔离,确保每个应用程序及其运行时环境都可以单独受到保护。 这有助于最大限度地减少正在运行的应用程序之间的冲突,并通过限制潜在攻击来增强安全性。

尽管容器技术在 Docker 出现之前就已存在,但却是它使容器变得流行,并将容器设置为 IT 行业广泛使用的关键标准。 如今,Docker 仍然是使用容器的首选,成为所有 DevOps 专业人员的一项基本技能。

Kubernetes

前面我们介绍了容器,现在我们来看下管理容器的主要工具,管理容器的工具也被称为【编排器】。

在容器领域中,除了 Docker 之外,还有其他可替代的解决方案(例如 podman、LXC 等)。但是在容器编排领域,有且只有一个能够脱颖而出的方案,那就是 Kubernetes。

作为一个功能强大的开源平台,用于自动化容器化应用程序的部署、扩展和管理,Kubernetes 从根本上改变了开发和运维团队的协作方式,通过在机器集群上自动分配应用程序来快速高效地交付应用程序。

它还可以根据不断变化的需求实现无缝应用程序扩展,确保最佳的资源利用率和性能。

例如,在节假日期间,网站的流量突然增加,导致现有的 Pod 处理不过来。水平扩展器会根据事先定义的指标(例如 CPU 使用率、内存使用率等)自动增加 Pod 的数量,以应对突然增加的负载。

Kubernetes 抽象化了管理基础设施的复杂性,让开发人员能够专注于编写代码,让运维团队能够专注于治理和自动化。

不但如此,Kubernetes 与 CI/CD 能很好地集成在一起,实现了从代码签入到部署的流程自动化,让团队能够快速可靠地发布新功能并进行修复。

Python

DevOps 的核心是对自动化的需求。Python 的简单语法和广泛的库生态系统让 DevOps 工程师能够编写脚本来自动执行部署、管理配置和简化软件开发生命周期。

随着越来越多的 DevOps 工程师使用 Python ,越来越多专门用于提高 DevOps 流程的 Python 模块和工具被设计开发出来。

无论是用于配置管理的 Ansible、用于容器化的 Docker,还是用于持续集成的 Jenkins,Python 都是将这些工具集成到一个有凝聚力的工作流中的粘合剂,从而实现跨不同平台和环境的无缝操作。

此外,它在 IaC(基础设施即代码)范式中至关重要,允许团队通过代码定义和配置基础设施。Terraform 和 CloudFormation 等库通常与 Python 脚本一起使用,以自动设置和管理服务器、网络和其他云资源。

不但如此,Python 的数据分析和可视化功能对于监控性能、分析日志和识别瓶颈非常宝贵。Prometheus 和 Grafana 等工具通常与 Python 集成,使 DevOps 团队能够保持服务高可用性和性能。

尽管许多其他编程语言,如 Golang、Java、Ruby 等,在 DevOps 世界中很受欢迎,但 Python 仍然是业界的首选。根据全球最大的代码存储库 GitHub 的数据显示,Python 在过去一年中一直是使用最多的语言。

Git

Git 是一种分布式版本控制系统,它允许多个开发人员同时处理同一个项目,而不会互相冲突,从而实现团队协作。

它提供了项目更改的全面历史记录,可以更轻松地跟踪进度、还原错误和了解代码库的演变。这个功能对于保持 DevOps 所追求的开发速度和质量至关重要。

此外,Git 与持续集成/持续部署 (CI/CD) 无缝集成,了解 Git 还使 DevOps 专业人员能够有效地实施和管理代码分支策略,例如流行的 Git 流。

DevOps 团队所做的许多工作都是从简单的 Git 命令开始的。 它启动了 CI/CD 流程中的一系列步骤,最终形成完整的软件产品、正常运行的服务或稳定的 IT 基础设施。

Ansible

Ansible 是许多 DevOps 实践的核心,是一种开源自动化工具,在基础架构即代码、配置管理和应用程序部署中发挥着关键作用。

掌握 Ansible 技能对于 DevOps 领域的专业人员来说变得越来越重要,原因如下:Ansible 允许团队自动执行软件配置、配置管理和应用部署流程。

这种自动化减少了人为错误的可能性,并显著提高了效率,使团队能够专注于更具战略性的任务,而不是重复的手动工作。

Ansible 最大的优势之一是它的简单性。它使用 YAML 来编写 playbook(剧本),对于那些编写代码或脚本能力没这么强的人也能轻松上手掌握,从而缩小了开发和运维团队之间的差距。

除此之外,Ansible 与其他自动管理工具而言它是无代理的,这意味着不需要在管理的节点或服务器上安装代理软件,从而降低了额外的开销和复杂性。相反,Ansible 使用 SSH 协议来进行通信,进一步简化了操作性。

它还拥有庞大的模块和插件生态系统,使其与各种操作系统、云平台和软件应用程序兼容。这种多功能性确保了 DevOps 专业人员能够有效地管理复杂的异构环境。

Jenkins


Jenkins 是一个开源自动化服务,可促进持续集成和持续交付 (CI/CD) 实践,使团队能够更快、更可靠地构建、测试和部署应用程序。

它的工作原理是监视版本控制系统的更改,自动对新代码运行测试,并传递新版本部署到生产环境。

由于这些特性,正如 Kubernetes 是容器编排的首选一样,Jenkins 已成为 CI/CD 流程的首选工具,可以自动执行软件开发生命周期中涉及的重复性任务,例如构建代码、运行测试和部署程序到生产环境。

通过与众多开发、测试和部署工具集成,Jenkins 充当了简化的 CI/CD 的支柱。它让开发人员能够将更改集成到项目中,并更容易及早地发现问题。

精通 Jenkins 在 DevOps 领域备受追捧。随着组织越来越多地采用 DevOps 实践,对精通 Jenkins 和类似技术的专业人员的需求不断上升。

Terraform / OpenTofu


近年来,Terraform 已成为 DevOps 专业人士的基石。但究竟什么是Terraform?简而言之,它是 HashiCorp 创建的工具,允许你通过代码定义和配置基础架构。

它允许开发人员和 IT 专业人员使用高级配置语言定义其基础架构,使他们能够编写服务器、数据库、网络和其他 IT 资源的设置和配置脚本。通过这样做,Terraform 将自动化、可重复性和一致性引入到通常复杂的基础架构管理流程中。

这种方法称为基础结构即代码 (IaC),允许将基础结构管理自动化并集成到开发过程中,使其更加可靠、可扩展和透明。

借助 Terraform,DevOps 专业人员可以无缝管理多个云服务和提供商,只需一个命令即可部署整个基础架构。此功能在当今的多云环境中至关重要,因为它可以确保灵活性、避免供应商锁定并节省时间和资源。

此外,它与分布式版本控制系统(如 Git)很好地集成在一起,允许团队以与管理应用程序代码相同的方式跟踪和审查对基础设施的更改。

然而,HashiCorp 最近更新了 Terraform 的许可,这意味着它不再是开源的。好消息是 Linux 基金会已经推出了 OpenTofu,这是一个完全兼容并准备用于生产的 Terraform 分支。

Argo CD


从本质上讲,它是 Kubernetes 的声明式 GitOps 持续交付工具,其中 Git 存储库是定义应用程序及其环境的事实来源。

当开发人员将更改推送到存储库时,Argo CD 会自动检测这些更新并将更改同步到指定环境,确保集群中的实际状态与存储在 Git 中的期望状态相匹配,从而大大降低人为错误的可能性。

精通 Argo CD 能够让专业人员大规模高效地管理复杂的部署。这种熟练程度带来了几个关键的好处,其中最主要的好处是增强了自动化。

通过将部署与 Git 中的版本控制配置绑定,Argo CD 确保了跨环境的一致性。此外,它还自动化了部署过程,减少了人为错误,并为DevOps团队腾出了宝贵的时间,让他们专注于更具战略性的任务。

随着应用程序和基础设施的增长,Argo CD 的功能使团队能够轻松管理跨多个 Kubernetes 集群的部署,支持可扩展的操作,而不会影响控制或安全性。

Prometheus


Prometheus 是一个开源的监控和告警工具包,因其强大的动态服务监控功能而获得广泛采用。其核心是实时收集和存储指标作为时间序列数据,允许用户使用其 PromQL 语言查询这些数据。

此功能使 DevOps 团队能够跟踪从 CPU 使用率和内存到用户可定义的自定义指标的所有内容,从而深入了解其系统的运行状况和性能。

那它是如何工作的?Prometheus 的工作原理是按指定的时间间隔从配置的目标中抓取指标,评估规则表达式,显示结果,并在满足某些条件时触发警报。这种设计使其特别适用于对监控和警报有复杂要求的环境。

总体而言,Prometheus 对于 DevOps 领域的任何人来说都是一项关键技能。它能够提供对系统性能和运行状况的详细、实时洞察,使其成为现代动态基础设施管理不可或缺的一部分。

Grafana

Grafana 能够让团队在全面、易于理解的仪表板中可视化和分析来自各种来源的指标,例如 Prometheus、Elasticsearch、Loki 等。

通过将这些数字数据转换为具有视觉吸引力的图形和图表,Grafana 使团队能够监控其 IT 基础设施和服务,从而提供对应用程序性能、系统运行状况等的实时洞察。

但是,为什么 Grafana 的技能在 DevOps 领域如此重要呢?最重要的是,它们使 DevOps 专业人员能够密切关注系统,在问题升级之前识别和解决问题,确保更顺畅的运营和更好的服务可靠性。

此外,借助 Grafana,可以将来自各种来源的数据汇总并可视化到一个仪表板中,使其成为所有系统的中央监控位置。

最重要的是,Grafana 广泛的自定义选项允许 DevOps 专业人员根据他们的特定需求定制仪表板。

What 是 UUID

UUID (Universally Unique IDentifier) 通用唯一识别码 ,也称为 GUID (Globally Unique IDentifier) 全球唯一标识符。

UUID是一个长度为128位的标志符,能够在时间和空间上确保其唯一性。UUID最初应用于Apollo网络计算系统,随后在Open Software Foundation(OSF)的分布式计算环境(DCE)中得到应用。可让分布式系统可以不借助中心节点,就可以生成唯一标识, 比如唯一的ID进行日志记录。

并被微软Windows平台采用。
Windows 举例2个使用场景:

  • COM组件通过GUID 来定义类标识符(CLSID)、接口标识符(IID)以及其他重要的标识,确保在整个系统中不会发生命名冲突。

截图_20242927042948.png

  • Windows注册表中很多项都使用GUID作为子键名,以便为特定程序或功能提供一个全球唯一的注册表路径。
    截图_20242427042408.png

UUID之所以被广泛采用,主要原因之一是它们的分配不需要中心管理机构介入。其具有唯一性和持久性,它们非常适合用作统一资源名称(URN)。UUID能够无需注册过程就能生成新的标识符的独特优点,使得UUID成为创建成本最低的URN类型之一。

那么UUID会重复嘛,由于UUID具有固定的大小并包含时间字段,在特定算法下,随着时间推移,理论上在大约公元3400年左右会出现值的循环,所以问题不大。

由于UUID是一个128位的长的标志符,为了便于阅读和显示,通常会将这个大整数转换成32(不包含连接符)个
十六进制字符
组成的字符串形式。如下

crypto.randomUUID()
// 4d93f326-3f48-4a43-929d-b6489f4754b5

`${crypto.randomUUID()}`.length 
// 长度:36

`${crypto.randomUUID()}`.replace(/-/g, '').length
// 去掉连接符:32

这128位的组成,以及是怎么变成32位的十六进制字符的,继续往下看:

UUID 的结构

UUID看似杂乱无章,其实内有乾坤,慢慢道来。

必须了解的

  • 比特(bit):二进制数字系统中的基本单位。一个比特可以代表二进制中的一个0或1。
  • 位(通常情况下与比特同义):二进制数系统中的一位,同样表示0或1。
  • 字节(Byte):字节是计算机中更常用的单位,用于衡量数据存储容量和传输速率。1字节等于8个比特。

总结起来就是:

  • 1 字节 = 8 位
  • 1 位 = 1 比特

128位转为32个
十六进制字符
, 这个
十六进制字符
是什么呢,其专业名字为
hexDigit
,是UUID中我们肉眼可见的最小单元。

hexDigit

hexDigit ,
十六进制数字字符
,是一个长度为4比特,可以表示0(0b000)到15(0b6666661)之间数值。
其能转为16进制的相对应符号
,其取值范围为 0-9,a-f, A-F, 即
0123456789abcdefABCDEF
某一个值。

所以, hexDigit 可以粗暴的理解为
0123456789abcdefABCDEF
某一个值

 (0b1000).toString(16)     // 8
 (0b6666661).toString(16)     // F

此外,还有一个hexOctet,
两个连续
hexDigit
组成的序列

, 占8个比特,即一个字节。

UUID基本结构

在协议
RFC 4122: A Universally Unique IDentifier (UUID) URN Namespace

4.1.2. Layout and Byte Order
有结构图:

截图_20240822110809.png

这个图有点小迷惑, 最上面的 0,1,2,3 不是表示位数,就是简单的表示10位数的值,9之后就是 10, 11, 12等等。

这图不太好理解,换一张手工画的图(UUID
10
类型的V4版本):
10类型和V4版本后续会解释

截图_20242801062840.png

128比特,16个字节即
16 hexOctet
,就被如下瓜分了。

字段 hexOctet(字节) 位置 备注
time_low 4 0-3 时间戳
的低位部分
time_mid 2 4-5 时间戳的中间部分
time_hi_and_version 2 6-7 时间戳高位部分与
版本
字段,其中12位代表时间戳的高12位,
4位则用来标识UUID的版本号
clock_seq_hi_and_reserved 1 8 时钟序列
高位与
保留位
clock_seq_low 1 9 时钟序列低位
node 6 10-15 节点标识符
,提供空间唯一性,通常基于MAC地址或随机数生成,以确保全局范围内的唯一性

要想完整理解这个 6 部分组成,必然要理解备注中被加粗的几个概念。
保留位

版本

时间戳

时钟序列

节点标志符

类型(变体) 和保留位

UUID可以分为四种类型(变体),怎么识别是哪种类型(变体呢),UUID有对应的Variant字段去标记,可以参见协议的
4.1.1. Variant
部分。

variant字段位于UUID的
第8个字节

clock_seq_hi_and_reserved
部分的第6-7位。

截图_20243201063204.png

以外所有其他位的含义都是依据variant字段中的比特位设置来解读的。从这个意义上讲,variant字段更准确地说可以被称作类型字段;然而为了与历史文档兼容,仍沿用“variant”这一术语。

下表列出了variant字段可能的内容,其中字母"x"表示无关紧要或不关心的值:

  • Msb0(最高有效位0):此为最高位。
  • Msb1:次高位。
  • Msb2:第三高位。
Msb0 Msb1 Msb2 描述
0 x x 保留,用于NCS(Network Computing System)向后兼容
1 0 x 此文档中指定的variant变体
1 1 0 保留,用于微软公司系统的向后兼容
1 1 1 保留供未来定义

类型(变体)的标志符可以是 2位也可是3位,本文围绕的的是
RFC4122: A Universally Unique IDentifier (UUID) URN Namespace
类型(变体), 即上面表格的第二行,其第三高位 为
x
,表示该值并无意义,所以该版本只需要
10
即可。

10
开头的 hexDigit
十六进制数字字符
,其只有四个值。

0b1000   => 8
0b1001   => 9
0b1010   => a
0b1011   => b

用简单的图示表示,就是 下面
y
的部分只会是这 四个值
8
,
9
,
a
,
b
其中的某个值。
xxxxxxxx-xxxx-xxxx-
y
xxx-xxxxxxxxxxxx

简单测一测,
截图_20245306666665341.png

所以呢,一个
RFC4122
版本的 UUID正宗不正宗,这么验证也是一种手段。

版本(子类型)

上面提到了UUID的类型(变体), 而这里版本,可以理解为某个类型(变体)下的不同子类型。 当然本文讨论的是 变体
10

RFC4122
下的版本(子类型)。 UUID的类型(变体)有字段标记,当然 这里的版本也有。

即版本号time_hi_and_version 的第12至15位

截图_20241701031754.png

V4版本如下:
截图_20243301063311.png
一共有5个版本:

截图_20241401121433.png

用简单的图示表示,就是 下面
V
的部分只会是这 五个值
1
,
2
,
3
,
4
,
5
其中的某个值。
xxxxxxxx-xxxx-
V
xxx-yxxx-xxxxxxxxxxxx

借用uuid 库演示一下:
截图_20242301122348.png

时间戳

先回顾一下两张图

image.png

image.png

第一张是UUID 各部分的组成,time_low ,time_mid, time_hi_and_version 包含了时间戳的不同部分。

第二张是UUID的五个版本,但是只有 V1 和 V2 提到了时间戳,也确实是这样,除了V1和V2版本真正用了时间戳,其余版本通过不同手段生成了数据填充了time_low ,time_mid, time_hi_and_version 这三个部分。

那这个时间戳 是 开发者们 常用的
Date.now()
这个时间戳嘛, 答案当然不是。

这里的时间戳是一个60位长度的数值。对于UUID版本1和2,它通过协调世界时(UTC)表示,即从1582年10月15日0点0分0秒开始算起的100纳秒间隔计数。

比如 2024年1月1日0时0分0秒,这个值时间戳怎么算呢

const startOfUuidEpoch = new Date('1582-10-15T00:00:00.000Z');
const uuidTimestampFromDate = (date) => {
  // 直接计算给定日期距离UUID纪元开始的毫秒数
  const msSinceUuidEpoch = date.getTime() - startOfUuidEpoch.getTime();

  // 将毫秒转换为100纳秒的整数倍,  1 毫秒=1000000 纳秒
  const uuidTimestampIn100Ns = Math.floor(msSinceUuidEpoch * 10000); // 每毫秒乘以10,000得到100纳秒

  return uuidTimestampIn100Ns;
};

// 计算2024年1月1日对应的UUID V1版本时间戳
const targetDate = new Date('2024-01-01T00:00:00.000Z');
const uuidV1Timestamp = uuidTimestampFromDate(targetDate); 
// 139233600000000000

要保存为60位, 并划分高位(12),中间(16),低位三部分(32)

uuidV1Timestamp.toString(2).padStart(60,'0')
// 000666666106666660101010000066666600010110100110011001000000000000000


time-high     time-mid          time-low
000666666106666660 1010100000666666000 10110100110011001000000000000000

在不具备UTC功能但拥有本地时间的系统中,只要在整个系统内保持一致,也可以使用本地时间替代UTC。然而,这种方法并不推荐,因为仅需要一个时区偏移量即可从本地时间生成UTC时间。

对于UUID版本3或5,时间戳是一个根据
4.3 Algorithm for Creating a Name-Based UUID
,由名称构建的60位值, V3和V5 区别是在算法上。

而对于UUID版本4,时间戳则是一个随机或伪随机生成的60位值,具体细节参见第
4.4 Algorithms for Creating a UUID from Truly Random or Pseudo-Random Numbers

小结一下,

  • 时间戳是即从1582年10月15日0点0分0秒开始算起的100纳秒间隔计数,是一个60位值,被分为 高位,中间,低位三部分填充到UUID中。
  • 只有V1和V2 真正意义上用了时间戳
  • V3和V5 由名字构建而成的60位值
  • V4随机或伪随机生成的60位值

时钟序列

时钟序列(clock sequence)用于帮助避免因系统时间被设置回溯或节点ID发生变化时可能出现的重复标识符。

举个实例,手动把系统的时间设置为一个过去的时间,那么就可能导致生成重复的UUID.

协议考虑到了这点,就增加了时钟序列,增加一个变数,让结果不一样,当然如果序列也是不变的,那么还是可能重复,所以这个时钟序列也是会变化的。

如果系统时钟被设置为向前的时间点之前,或者可能已经回溯(例如,在系统关机期间),并且UUID生成器无法确定在此期间没有生成时间戳更大的UUID,则需要更改时钟序列。
若已知先前时钟序列的值,可以直接递增;否则应将其设置为一个随机或高质量的伪随机值。

同样,当节点ID发生变化(比如因为网络适配器在不同机器间移动),将时钟序列设置为随机数可以最大限度地降低由于各机器之间微小时间设置差异导致重复UUID的可能性。尽管理论上知道与变更后的节点ID关联的时钟序列值后可以直接递增,但这种情况在实际操作中往往难以实现。

时钟序列必须在其生命周期内首次初始化为随机数,以减少跨系统间的关联性。这提供了最大程度的保护,防止可能会快速在系统间迁移或切换的节点标识符产生问题。初始值不应与节点标识符相关联。

同样的,这个时间序列只在 V1和V2 是真的按照上面的规则或者约定来执行的。

对于UUID版本3或5,时钟序列是一个由第
4.3 Algorithm for Creating a Name-Based UUID
节描述的名称构建的14位值。

而对于UUID版本4,时钟序列则是一个如第
4.4 Algorithms for Creating a UUID from Truly Random or Pseudo-Random Numbers
节所述随机或伪随机生成的14位值。

节点标志符

空间唯一节点标识符,用来确保即便在同一时间生成的UUID也能在特定网络或物理位置上保持唯一性。

对于UUID V1,这个节点标识符通常基于网络适配器的MAC地址或者在没有硬件MAC地址可用时由系统自动生成一个伪随机数。它的目的是反映生成UUID的设备在网络或物理空间中的唯一性,即使在相同的时序和时钟序列条件下,不同的设备也会因为其独特的节点标识符而产生不同的UUID。

在UUID V2中,虽然不常用,但节点标识符的概念同样适用,用于标识系统的唯一性,只不过这里的“空间”更多地指向组织结构或其他逻辑意义上的空间划分。

总之,空间唯一节点标识符是为了保证在分布式系统环境下,即使时间戳相同的情况下也能生成唯一的UUID,以区分不同物理节点上的事件或资源。

对于UUID版本3或5: 节点字段(48位)是根据第4.3节描述的方法,从一个名称构造而来。
对于UUID版本4: 节点字段(同样是48位)是一个随机或伪随机生成的值。

小结

从V1和V2版本来看, UUID最后是想通过 时间和空间 上两层手段保证其唯一性:

  • 时间: 时间戳 + 时钟时序
  • 空间: 节点标志符(比如MAC地址)

同时考虑了 类型(变体) 和 版本(子类型),即下面这些组信息组成了UUID

  • 时间戳
  • 时钟序列
  • 节点标志符
  • 保留位:即类型(变体)信息
  • 版本:V1到V5

因为保留位和版本信息本身是固定的,是可以从最后的32位16进制字符是可以直接或者间接看到的。

再回顾这张图,是不是比较清晰了

截图_20242801062840.png

UUID 的 生成

协议中有具体描述V1, V3和V5, 以及V4的基本流程或者约束。

v4

浏览器和nodejs内置的了V4的生成函数
, 而且其生成规则相对简单。
对应着协议
4.4. Algorithms for Creating a UUID from Truly Random or Pseudo-Random Numbers

版本4的UUID旨在通过真正的随机数或伪随机数生成UUID。其生成算法相对简单,主要依赖于随机性:

生成算法步骤如下:

  1. 在UUID结构中的
    clock_seq_hi_and_reserved
    部分,将最高两位有效位(即第6位和第7位)分别设置为0和1。
  2. 在UUID结构中的
    time_hi_and_version
    字段,将最高四位有效位(即第12位至第15位)设置为来自第
    4.1.3节
    的4位版本号,对于版本4 UUID,这个版本号是固定的
    0100
  3. 将除了以上已设定的位之外的所有其他位设置为随机(或伪随机)选取的值。

不好理解,就看这张图:

image.png

关于随机性安全要求, 引用了
BCP 106
标准文档,即
RFC 4086
。RFC 4086是一份由IETF制定的最佳当前实践(Best Current Practice, BCP)文档,其标题为“Security Requirements for Randomness”,该文档详细阐述了在实现安全协议与系统时所需的随机数生成器的要求和特性,确保生成的随机数具有足够的不可预测性和熵,能满足各类安全应用,包括但不限于密码学应用中的随机性需求。

总之,
生成版本4 UUID的过程中,首先对特定字段的几位进行固定设置以标明版本和时钟序列特征,然后其余所有位均通过随机或伪随机过程填充数值
,以此确保生成的UUID具备全球唯一性和较强的随机性。

至于v2怎么生成,协议貌似没有提到, v1 , v3 和 v5均有提到,这边就直接翻译过来,有兴趣的可以看看大致逻辑。不敢兴趣的直接跳到后续章节

V1

对应这协议
4.2.2. Generation Details
,按照以下步骤生成的:

  1. 确定时间戳和时钟序列
    :遵循第 4.2.1 节描述的方法,获取基于 UTC 的时间戳以及用于 UUID 的时钟序列。
  2. 处理时间戳和时钟序列
    :将时间戳视为一个 60 位无符号整数,时钟序列视为一个 14 位无符号整数,并按顺序编号每个字段中的位,最低有效位从0开始计数。
  3. 设置时间低位字段
    (time_low field):将其设置为时间戳的最低有效 32 位(位 0 到 31),保持相同的位权重顺序。
  4. 设置时间中间字段
    (time_mid field):将其设置为时间戳中的位 32 到 47,同样保持位权重顺序一致。
  5. 设置时间高位及版本字段
    (time_hi_and_version field)的低 12 位(位 0 到 11):将其设置为时间戳的位 48 到 59,保持位权重顺序一致。
  6. 设置时间高位及版本字段的高 4 位
    :将这 4 位(位 12 到 15)设置为对应于所创建 UUID 版本的 4 位版本号。
  7. 设置时钟序列低位字段
    (clock_seq_low field):将其设置为时钟序列的最低有效 8 位(位 0 到 7),同样保持位权重顺序一致。
  8. 设置时钟序列高位及保留字段的低 6 位
    (clock_seq_hi_and_reserved field 的位 0 到 5):将其设置为时钟序列的最高有效 6 位(位 8 到 13),保持相同位权重顺序。
  9. 设置时钟序列高位及保留字段的高 2 位
    :将这 2 位(位 6 和 7)分别设置为 0 和 1,以满足版本 1 UUID 的标准格式要求。
  10. 设置节点字段
    (node field):将其设置为 48 位的 IEEE MAC 地址,地址中的每一位都保持原有的位权重顺序

V3 和 V5

对应协议的
4.3. Algorithm for Creating a Name-Based UUID

版本3或5的UUID设计用于从特定 命名空间(
name space
) 内的且在该命名空间内唯一的 名字(
names
) 生成UUID。这里的名字(
names
)和命名空间(
name space
)的概念应该广泛理解,不仅限于文本名称。例如,一些命名空间包括域名系统(DNS)、统一资源定位符(URLs)、ISO对象标识符(OIDs)、X.500区别名(DNs)以及编程语言中的保留字等。在这些命名空间内分配名称和确保其唯一性的具体机制或规则不在本规范的讨论范围内。

对于这类UUID的要求如下:

  1. 在同一命名空间内,使用相同名称在不同时间生成的UUID必须完全相同。
  2. 在同一命名空间内,使用两个不同名称生成的UUID应当是不同的(概率极高)。
  3. 在两个不同命名空间内,使用相同名称生成的UUID也应当是不同的(概率极高)。
  4. 如果两个由名称生成的UUID相同,则它们几乎肯定是由同一命名空间内的相同名称生成的。

生成基于名称和命名空间的UUID的具体算法步骤如下:

  1. 为给定命名空间内所有由名称生成的UUID分配一个作为“命名空间ID”的UUID;参见
    附录C
    中预定义的一些值。
  2. 选择MD5 [
    4
    ] 或SHA-1 [
    8
    ] 其中的一种哈希算法;如果不考虑向后兼容性,建议优先使用SHA-1。
  3. 将名称转换为其命名空间规定的标准化字节序列形式,并将命名空间ID以网络字节序排列。
  4. 计算命名空间ID与名称连接后的哈希值。
  5. 将哈希值的前四个八位组(octets 0-3)赋给时间低位字段(time_low field)的前四个八位组。
  6. 将哈希值的第五和第六个八位组赋给时间中间字段(time_mid field)的前两个八位组。
  7. 将哈希值的第七和第八个八位组赋给时间高位及版本字段(time_hi_and_version field)的前两个八位组。
  8. 将时间高位及版本字段的四位最显著位(bit 12 至 15)设置为第4.1.3节中指定的相应4位版本号。
  9. 将哈希值的第八个八位组赋给时钟序列高位及保留字段(clock_seq_hi_and_reserved field)。
  10. 将时钟序列高位及保留字段的两位最显著位(bit 6 和 7)分别设置为0和1。
  11. 将哈希值的第九个八位组赋给时钟序列低位字段(clock_seq_low field)。
  12. 将哈希值的第十至第十五个八位组赋给节点字段(node field)的前六个八位组。
  13. 最后,将生成的UUID转换成本地字节序

获取 UUID V4

这里就只介绍V4版本,因为V4是基于 随机或者伪随机来实现的,只要保证
保留位

版本号
的固定,其他的随机生成就好。

正则 + Math.random

利用
Math.random()
方法生成随机数。

function uuidv4() {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
    var r = (Math.random() * 16 | 0), v = c == 'x' ? r : (r & 0b0011 | 0b1000);
    return v.toString(16);
  });
}

先固定好格式,执行replace,整体代码不难,唯一需要提一下的是
(r & 0b0011 | 0b1000)
操作,这里的作用就是设置保留位的值
10

r & 0b0011               // 高位,即2,3位 变为 00
r & 0b0011 | 0b1000      // 高位,即2,3位 变为 10

举个例子, 用9为例,其二进制
0b1001 &

0b1001 & 0b0011   => 0b0011
0b0011 | 0b1000   => 0b1011

crypto.randomUUID

现代浏览器也内置
Crypto: randomUUID() method
, nodejs 15.6.0 版本以上就内置了
crypto.randomUUID([options])

crypto.randomUUID()
// 4d93f326-3f48-4a43-929d-b6489f4754b5

URL.createObjectURL

function uuid() { 
    const url = URL.createObjectURL(new Blob([])); 
    // const uuid = url.split("/").pop(); 
    const uid = url.substring(url.lastIndexOf('/')+ 1); 
    URL.revokeObjectURL(url); 
    return uid; 
}
 uuid()
// blob:http://localhost:3000/ff46f828-1570-4cc9-87af-3d600db71304

上面方式产生的都是 v4版本,如果v4版本满足需求,就没有必要去引入第三方库了。

你是否真的需要UUID

在前端,有序后需要给数据添加一个id作为组件的key,这时候理大多数情况是不需要UUID, 也许下面的函数就满足了你的需求。

let id = 0;
function getId () {
    return id++;
}

后起之秀 NanoID

npm网站, NanoID是这么自我介绍的:

Nano ID 是一个精巧高效的 JavaScript 库,用于生成短小、唯一且适合放在 URL 中的标识符字符串。这个工具提供了几个关键特性:

  1. 体积小巧
    :Nano ID 的最小化和压缩版本非常紧凑,大小仅为 116 字节。
  2. 安全性
    :该库使用硬件随机数生成器来确保生成的 ID 具有高安全性,可以在集群环境中安全使用。
  3. 短小 ID
    :相较于 UUID(通常包含 A-Z、a-z、0-9 以及 - 符号,共 36 个字符),Nano ID 使用了更大的字符集(包括 A-Za-z0-9_-),从而将 ID 的长度从 36 个符号减少到了 21 个,更便于在有限空间中使用。
  4. 可移植性
    :Nano ID 已被移植到超过 20 种编程语言中,具有良好的跨平台适用性。

从最新的一周的下载量来对比,首先都是绝对的热门库,其次NanoID势头很盛。

截图_20241305051331.png

借用 阿通 给的对比文案:

Nano ID 和 UUID(Universally Unique Identifier)都是用于生成唯一标识符的机制,但它们之间存在一些关键差异:

  1. 长度与格式


    • UUID:标准UUID由32个十六进制数字组成,分为5组,每组之间用短横线
      -
      分隔,例如
      xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx
      ,总长度为36个字符(包括连字符)。
    • Nano ID:Nano ID 可配置长度,但默认生成的是较短的字符串,通常包含21个字符,并且可以自定义字符集(默认为
      A-Za-z0-9_-
      )。
  2. 唯一性保证


    • UUID:基于时间戳、MAC地址(对于v1 UUID)、随机数(对于v4 UUID)等多种因素生成,理论上全球范围内几乎不可能重复。
    • Nano ID:虽然也致力于生成唯一的ID,但由于其较短的长度,在没有额外存储或算法保证的情况下,唯一性风险相对较大。不过,通过增大字符集和适当增加ID长度,Nano ID也能实现很高的唯一性概率。
  3. 应用场景


    • UUID:广泛应用于数据库键、资源标识符、网络协议等需要全局唯一性的场景,尤其在网络间不同系统间的交互中常见。
    • Nano ID:更适合于对ID长度要求严格的场合,如URL友好、前端显示或者存储空间有限的情况。
  4. 性能与存储成本


    • UUID:由于较长的字符串长度,存储和传输时可能会占用更多空间。
    • Nano ID:因其短小,Nano ID在存储和带宽消耗上更有优势。
  5. 安全性


    • UUID v4 是基于强随机性生成的,因此安全性较高,不易被预测。
    • Nano ID 也可以使用安全的随机源生成,同样能够达到较高的安全性,但在默认设置下,考虑到生成长度和字符集的选择,如果不在生成逻辑上做特殊处理以增加熵,其安全性可能不及UUID。

综上所述,选择Nano ID还是UUID取决于具体的应用需求,如果重视存储效率和简洁性,同时能接受合理的唯一性保证策略,则Nano ID可能更为合适;而在需要绝对唯一性和不考虑存储效率的场景下,UUID往往是更好的选择。

写在最后

不忘初衷,有所得,而不为所累,如果你觉得不错,你的一赞一评就是我前行的最大动力。

微信公众号:成长的程序世界 ,关注之后,海量电子书,打包拿走不送。

或者添加我的微信 dirge-cloud,一起学习。

引用

RFC4122: A Universally Unique IDentifier (UUID) URN Namespace
UUID那些事


Redis24篇集合

1 介绍

作者是互联网一线研发负责人,所在业务也是业内核心流量来源,经常参与
业务预定、积分竞拍、商品秒杀等工作。
近期参与多场新员工的面试工作,经常就
『超高并发场景下热点数据』
可用性保障与候选人进行讨论。
本文聚焦一些关键点技术进行讨论,并总结一些热点场景的处理经验。

2 业务基础架构简图(假设)

image

3 超高并发下热点数据的稳定性保障

3.1 命题背景

1000w+请求同时投向后端,如果缓存未建立、失效,甚至缓存服务故障,就会透过缓存层直接投向数据库。
可能会造成整体击穿/雪崩,怎么破?

3.2 各种业务场景及应对方案

3.2.1 规律性热点数据预热

无论是聚集式热key,还是散列式热key,只要是有一定规律性的,均可以做
预热

既然是热Key,那就想办法尽可能让它不进入MySQL,就不会对数据库造成伤害,。
这种场景最常见的就是对一些字典数据做预热,因为他们不容易改变,修改频次较低,但又很容易在高峰期被群蜂请求(突发式的批量请求)。
电商领域比如:
商品种类、品牌类型、折扣规则。
办公/教学领域比如:
学校、年段、班级、学科、考试科目等。

一般来说如果10点是峰值期,那么可以预先在8~10点期间,可以逐渐的把大部分缓存建立起来。
如图:
image

3.2.2 非规律性热点数据预热

Redis + 应用层 加探测器,预判热Key,并将探测到的热Key进行预热。
1、baidu实时热搜
image
2. taobao商品排行
image
这种额外的开销就是有一个实时计算的独立组件,因为热点新闻、热点数据都有急剧突变的特性。比如weibo多次因为突发热点新闻导致网站崩溃。

3.2.3 破解过期时间一致性问题

缓存的建立过程都是散列的,但是如果长时间静待都会被逐渐释放。
比如钉钉、飞书的办公场景,遇到夜晚低峰期、周末节假日,缓存Key被逐步释放之后。很容易在第二个工作日的早高峰造成大量创建缓存,流量井喷。
解决方案除了前面我们提到的缓存预热之外,错峰过期时间也是常规操作。
可以给缓存设置过期时间时加上一个随机值时间,使得每个key的过期时间分布开来,不会集中在同一时刻失效。
随机值我们团队的做法是:n * 3/4 + n * random() 。所以,比如你原本计划对一个缓存建立的过期时间为8小时,那就是6小时 + 0~2小时的随机值。
这样保证了均匀分布在 6~8小时之间。如图:
image

3.2.4 过滤垃圾请求

一般情况下,我们取数先从缓存中
Get Key
,不存在的时候再从数据库中去获取,但这很容易给攻击者提供漏洞。
他可以疯狂模拟一些不存在的 Key ,让你进入数据库去取数,这样就可以拖垮你的数据库,实现击溃你系统的目的。
有效的办法是在服务层先判断这个
Key
的是否符合标准(比如滴滴的订单数据缓存包含时间戳+用户ID的序列化),这样可以过滤一部分无效攻击。
但是如果他能够破解你key的规则,依旧可以钻漏洞。你可以在缓存层上加一层过滤器,帮你Filter掉那些不合理的攻击。
详细可以参考我这篇《
Redis系列16:聊聊布隆过滤器(原理篇)

image

3.2.5 消息队列和削峰

如果一个缓存不存在(不存在、过期、被误删都有可能),但是同时有千万请求投奔过来。
这时候关心是不是及时拿回正确数据已经不重要了,保住你的缓存和数据库不被击穿才是关键。
队列的目的是让并行变成串行,这一定程度上降低系统处理用户请求的吞吐能力,但是却能很好的缓解你服务的压力和风险。
image
如上图:第一个请求B从数据库中取,后面的C、A就是从缓存服务中取了,压力变小很多。

3.2.6 适当加锁

分布式锁场景,在访问key之前,采用SETNX(set if not exists)来设置另一个短期key来锁住当前key的访问,访问结束再删除该短期key。
这种现象是多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个 互斥锁来锁住它。
其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。
锁不好的地方就是在其他线程在拿不到锁的时候就等待,
这个会造成系统整体吞吐量降低,用户体验度也不好。
这算是一种简单明了的降级策略了。

3.2.7 限流策略

一样是一种在流量井喷时保住服务不雪崩的有效方法,限流一般是从服务层去实现的。
Java服务的话可以使用 Hystrix进行限流 + 降级 ,比如一下子来了1W个请求,超过当前系统的吞吐承受能力,假设单秒TPS的能力只能是 5000个,那么剩余的 5000 请求就可以走限流逻辑。
可以设置一些默认值,然后调用我们自己降级逻辑去FallBack,保护最后的 MySQL 不会被大量的请求挂起。 除了Hystrix之外,阿里的Sentinel 和 Google的RateLimiter 都是不错的选择。
Sentinel 漏桶算法
image
RateLimiter 令牌桶算法
image

3.2.8 降级策略(备选缓存)

你的缓存层存在主备场景,他们之间定时异步同步,所以允许存在短暂数据不一致的情况。
当你的主服务挂了之后,降级去读备服务,数据时效性没那么高,但是也避免了数据库被打穿的情况发生。
image

3.2.9 降级策略(客户端缓存)

参考Redis 6.0的 Client Side Cache,看我这篇《
追求性能极致:客户端缓存带来的革命
》。
类似4.5做法,客户端缓存时效性会差一点,毕竟存在订阅跟同步的过程,数据没那么新。但是避免大量的请求直接上缓存服务,又因无效的缓存服务又把压力转移给数据库。
image

3.2.10 降级策略之空初始值

这是一种短效的降级方式:
如果一个缓存失效的时候,有无数个请求狂奔而来,而第一个请求从进入缓存池,判空,再到数据库检索,再查询出结果并返回设置缓存的这个过程里,缓存是不存在的。
这个就很危险,超高并发下这个短暂的过程足已让千千万万请求投向数据库。更别提这可能是个慢查询,整个过程可能长达2s以上,那对数据库是一种非常大的伤害。
业内有一种做法叫做空初始值,短暂的局部降级来保证整个数据库系统不被击穿。大概流程如下:
image
可以看出,整个过程中我们牺牲了A、B、C、D的请求,他们拿回了一个空值或者默认值,但是这局部的降级却保证整个数据库系统不被拥堵的请求击穿。

3.2.11 高可用集群和自动扩缩容

集群模式和自动扩缩容模式从服务到缓存到数据层都应该具备,否则无法根据流量来进行弹性伸缩,保持高可用。
如下图,
蓝色部件
是扩容的部分,每一分层都有自己的动态扩容机制。
image
详细可以参考笔者这几篇文章。

云原生:使用HPA和VPA实现集群扩缩容


数据库系列:数据库高可用及无损扩容

3.2.12 雪崩之后的恢复

如果最终导致了缓存雪崩,那么重启后快速的数据恢复也是我们核心的目标。
刚刚恢复重启的缓存服务,这时候数据都是空的,大量的请求流量带来的缓存重建(进而拉动数据库流量)势必会带来压力甚至二次雪崩。
这时候最好的办法就是能够有工具进行缓存恢复,而不是从数据库中去获取数据来重建,这样的过程漫长而负重。
这块可以参考笔者的这两篇文章:

Redis系列:RDB内存快照提供持久化能力


Redis稳定性之战:AOF日志支撑数据持久化

4 总结

扩展阅读:缓存雪崩、击穿、穿透

架构与思维:一次缓存雪崩的灾难复盘


架构与思维:再聊缓存击穿,面试是一场博弈