2024年10月

一:背景

1. 讲故事

上一篇我们聊过
AOT编程
中可能会遇到的三大件问题,而这三大件问题又是考验你对AOT中
节点图
的理解,它是一切的原点,接下来我就画几张图以个人的角度来解读下吧,不一定对。

二:理解节点依赖图

1. 对节点的理解

按照官方的说法,构建依赖节点和GC的标记算法一样,都是采用深度优先,每一个节点都是一种类型,比如:

  1. MethodCodeNode 表示方法节点
  2. EETypeNode 表示 MethodTable 类型节点

同时节点的层级关系比较深,比如这样的链路,
MethodCodeNode -> ObjectNode -> SortableDependencyNode -> DependencyNodeCore<DependencyContextType> -> DependencyNode -> IDependencyNode

对了,最核心的节点依赖图算法来自于方法
DependencyAnalyzer.ComputeMarkedNodes()
, 简化后如下:


    public override void ComputeMarkedNodes()
    {
        do
        {
            // Run mark stack algorithm as much as possible
            using (PerfEventSource.StartStopEvents.DependencyAnalysisEvents())
            {
                ProcessMarkStack();
            }

            // Compute all dependencies which were not ready during the ProcessMarkStack step
            _deferredStaticDependencies.TryGetValue(_currentDependencyPhase, out var deferredDependenciesInCurrentPhase);

            if (deferredDependenciesInCurrentPhase != null)
            {
                ComputeDependencies(deferredDependenciesInCurrentPhase);
                foreach (DependencyNodeCore<DependencyContextType> node in deferredDependenciesInCurrentPhase)
                {
                    Debug.Assert(node.StaticDependenciesAreComputed);
                    GetStaticDependenciesImpl(node);
                }

                deferredDependenciesInCurrentPhase.Clear();
            }

            if (_markStack.Count == 0)
            {
                // Time to move to next deferred dependency phase.

                // 1. Remove old deferred dependency list(if it exists)
                if (deferredDependenciesInCurrentPhase != null)
                {
                    _deferredStaticDependencies.Remove(_currentDependencyPhase);
                }

                // 2. Increment current dependency phase
                _currentDependencyPhase++;

                // 3. Notify that new dependency phase has been entered
                ComputingDependencyPhaseChange?.Invoke(_currentDependencyPhase);
            }
        } while ((_markStack.Count != 0) || (_deferredStaticDependencies.Count != 0));

    }

在遍历的过程中,它是先用
ProcessMarkStack()
处理所有的静态节点,在处理完后再处理那些在上一阶段产生的新节点或者在上一阶段还没预备好的节点,这里叫
延迟节点
,这个说起来有点懵,举个例子: A 是必达节点,C 只有在 B 进入依赖图时才进去,否则不进入,所以这叫条件依赖。最后我再配一张图,大家可以观赏下:

再往下编我就编不下去了,写一个小例子直观的感受下吧。

2. 一个小例子

代码非常简单,大家可以看看这段代码构建的依赖图可能是个什么样子?


    internal class Program
    {
        static int Main(string[] args)
        {
            Animal animal = new Bird();
            animal.Sound();
            return animal is Dog ? 1 : 0;
        }
    }

    public abstract class Animal
    {
        public virtual void Fly() { }

        public abstract void Sound();
    }

    public class Bird : Animal
    {
        public override void Sound() { }

        public override void Fly() { }
    }

    public class Dog : Animal
    {
        public override void Sound() { }
    }

就不吊着大家了,最后的依赖图大概是这个样子。

上图稍微解释一下:

  • 矩形: 方法体
  • 椭圆: 类
  • 虚线矩形: 虚方法
  • 点状椭圆形: 未构造的类
  • 虚线边: 条件依赖关系

从图中可以看到,起点是在
Program::Main
函数上,这里要稍微提醒一下,这是逻辑上的托管入口,在 ilc 层面真正的入口是非托管函数
{[Example_21_1]<Module>.StartupCodeMain(int32,native int)}
上,大家可以对
DependencyAnalyzerBase<DependencyContextType>.AddRoot
上下一个断点即可,截图如下:

眼尖得朋友可能会有一个疑问,这个
Bird.Fly()
在依赖图中被移走了是能够说得通得,但有没有什么证据让我眼见为实一下呢?

3. 如何观察节点移走了

aot在调试支持上做了很多的努力,比如通过 IlcGenerateMapFile 就可以让你看到每一个依赖图的节点类型,在 csproj 上配置如下:


<Project Sdk="Microsoft.NET.Sdk">
	<PropertyGroup>
		<OutputType>Exe</OutputType>
		<TargetFramework>net8.0</TargetFramework>
		<ImplicitUsings>enable</ImplicitUsings>
		<Nullable>enable</Nullable>
		<PublishAot>true</PublishAot>
		<InvariantGlobalization>true</InvariantGlobalization>
		<IlcGenerateMapFile>true</IlcGenerateMapFile>
	</PropertyGroup>
</Project>

接下来打开生成好的
obj\Debug\net8.0\win-x64\native\Example_21_1.map.xml
文件,搜索对应的
Bird__Sound

Bird__Fly
方法。

对了,上面的 MethodCode 节点我稍微解释一下,完整的如下:


  <MethodCode Name="Example_21_1_Example_21_1_Bird__Sound" Length="16" Hash="5e2f1c14edcffc6459b012c27e0e8410215a90cfa5dda68376042264d59e6252" />

刚才也说了
MethodCode
是一个方法节点,Name 不用说了,Length 是方法的汇编代码长度,Hash是对字节码的hash表示,这个在源码上的
XmlObjectDumper.DumpObjectNode
上能够找到答案的。

4. 未构造类型解读

这个指的是上面的
return animal is Dog ? 1 : 0;
这句话,我个人觉得AOT团队这一块没做好,为什么呢?因为
Animal is Dog
底层调用的是
CastHelpers.IsInstanceOfClass
方法,而这个方法底层只需要保存
MethodTable.ParentMethodTable
信息就行了,截图如下:

但遗憾的是AOT居然把
Example_21_1.Dog.Sound()
也追加到依赖图,这就完全没有必要了。

退一万步说生成就生成吧,但恶心的是又不给生成
Dog::Dog
构造函数,这就导致这个 Dog 无法实例化,造成 Dog.Sound 成了一个孤岛函数,无语了,在 csproj 上配置
<IlcGenerateMapFile>true</IlcGenerateMapFile>
节点可以更直观的观察到。

三:总结

节点依赖图的生成是一个比较复杂的过程,目前.NET8 中的 AOT Compiler 还是有很大的优化空间,比如:

  1. 基于上下文的依赖推测。
  2. 未构造类型的推测。
  3. 还不知道的一些未知...

期待后续的 .NET9, .NET10 有更大的提升吧。
图片名称

redis配置

Redis
集群需要至少
3
个主节点,为保证数据的完整性每个主节点至少需要一个从节点,所以至少需要准备
6

Redis
服务

img

建议将
redis
注册为系统服务并设置自启动,服务注册命令为:

redis-server --service-install redis.windows.conf --service-name redis6379 --loglevel verbose

img

img

分别修改
redis.windows.config
中以下配置:

bind 127.0.0.1                               // ip地址
port 6379                                    // 端口
logfile "redis79.log"                        // 日志文件名称

cluster-enabled yes                          // 开启集群支持
cluster-config-file nodes-6379.conf          // 集群配置文件名称
cluster-node-timeout 15000                   // 集群节点超时时间 ms 

启动所有
Redis
服务后,在任一
Redis
目录下执行创建集群命令,副本数为
1
,会自动生成三主三从节点:

redis-cli --cluster create 127.0.0.1:6379 127.0.0.1:6380 127.0.0.1:6381 127.0.0.1:6382 127.0.0.1:6383 127.0.0.1:6384 --cluster-replicas 1

img

代码示例

public static void main(String[] args) {

    // 集群节点信息
    Set<HostAndPort> nodes = new HashSet<>();
    nodes.add(new HostAndPort("127.0.0.1", 6379));
    nodes.add(new HostAndPort("127.0.0.1", 6380));
    nodes.add(new HostAndPort("127.0.0.1", 6381));
    nodes.add(new HostAndPort("127.0.0.1", 6382));
    nodes.add(new HostAndPort("127.0.0.1", 6383));
    nodes.add(new HostAndPort("127.0.0.1", 6384));

    // 连接池配置
    JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
    jedisPoolConfig.setMaxTotal(10);
    jedisPoolConfig.setMaxIdle(5);
    jedisPoolConfig.setMinIdle(1);

    // 初始化集群对象,全局唯一
    JedisCluster cluster = new JedisCluster(nodes, jedisPoolConfig);

    // 执行命令
    cluster.set("key1", "1");
    cluster.set("key12", "12");
    cluster.set("key123", "123");
    cluster.set("key1234", "1234");
    cluster.set("key12345", "12345");
    cluster.set("key123456", "123456");
    cluster.set("key1234567", "1234567");
    cluster.set("key12345678", "12345678");
    cluster.set("key123456789", "123456789");

    cluster.close();
}

添加的
9

key
,分布在不同节点上

img

标准库 IO

输入输出功能并非C语言的组成部分,ANSI标准定义了相关的库函数

输入输出 <stdio.h>

流stream是与设备关联的数据的源或者目的地。

  • 文本流:由文本行组成的序列
    不同系统的特性可能不一样,比如行最大长度和行结束符
  • 二进制流:未经处理的字节序列

程序运行时,默认打开 stdin, stdout, stderr

标准 IO 常量

  • EOF:文件尾,实际值是几个字符避免混淆
  • FOPEN_MAX:一个程序同时最多能够打开的文件数量,与编译器有关,值至少为 8
  • FILENAM_MAX:编译器支持的最长文件名

文件流操作

文件指针指向包含文件信息的结构,包括缓冲区,读写状态等

打开与关闭

FILE *fopen(const char *filename, const char *mode);                    // 打开文件流
/*
mode:
* r, 打开读
* w, 打开或创建写,删除原有内容
* a, 打开或者创建,文件尾追加
* +, 更新,读或写,交叉操作前需要执行fflush或者定位操作
  * r+, 打开文件更新(读和写)
  * w+, 创建文件更新,删除原有内容
  * a+, 打开或创建文件,文件尾更新
* b, 二进制流模式

应始终检查返回值
*/

FILE *freopen(const char *filename, const char *mode, FILE *stream);    // 先关闭再打开文件流

FILE *fdopen(int fd, const char *type);   // POSIX标准,常用于由创建管道和网络通信通道函数返回的描述符,不能直接用 fopen 打开
// type 参数: r, w, a, (+)
// 注意读写模式下读写操作之间需要进行调用定位函数


int fclose(FILE *stream);                                               // 刷新输出缓冲,释放系统缓冲区,关闭流


int fileno(FILE *fp);   // POSIX 标准

流的定向

标准IO可用于单字节和多字节字符集,具体由流的定向决定。

  • freopen函数清除一个流的定向
  • fwide函数设置流的定向、
#include <stdio.h>
#include <wchar.h>

int fiwde(FILE *fp, int mode);
/*
mode<0, 试图设置为单字节
mode>0,试图设置为多字节
mode=0,不设置定向,返回流的定向

不会改变已经设置的流定向
*/

文件操纵函数

重命名

int remove(FILE *stream);                               // 删除文件

int rename(const char *oldname, const char *newname); // 重命名文件

临时文件

FILE *tmpfile(void);     // 以`wb+`模式创建临时文件,在关闭或者程序结束时自动删除
// 实现上tmpnam,创建文件,unlink(不会删除内容),文件的关闭会在程序结束中自动进行

char *tmpnam(char s[L_tmpnam]); // 创建不同现有文件名的字符串,NULL返回指向静态数组的指针

// UNIX 优势是不存在时间间隙,避免其他进程创建同名文件
char *mkdtemp(char *temple);  // 创建目录
int mkstmp(char *temple);     // 创建文件,不会自动删除

缓冲操作

标准IO提供缓冲的目的是尽可能减少read/write的调用次数

  • 全缓冲:缓冲区满了才进行实际IO操作

  • 行缓冲:遇到换行符才进行IO操作


    • 行缓冲的长度是固定的,行缓冲满了即使没有换行符也会进行IO操作
    • 标准IO要求从不带缓冲或者行缓冲(需要从内核请求数据)获取数据,会立即刷新所有行缓冲的输出流
  • 不带缓冲

ISO C标准

  • 当且仅当标准输入和标准输出不指向交互式设备才是全缓冲的
  • 标准错误不会是全缓冲的

默认情况

  • 标准错误不带缓冲
  • 若是指向终端设备的流,则是行缓冲的,否则是全缓冲的

更换缓冲的类型:流被打开之后,且在执行任何操作之前

int fflush(FILE *stream);                                               // 刷新缓冲区
/*
- 对于输出流,刷新写入缓冲区的内容至目标文件
- 对于输入流,其结果是未定义的
- NULL,刷新所有的缓冲区

实际使用技巧:每个调试 printf 之后立马调用 fflush
*/

int setvbuf(FILE *stream, char *buf, int mode, size_t size);            // 必须在执行读写操作之前设置缓冲
/*
mode:
- _IOFBF 完全缓冲
- _IOLBF 行缓冲
- _IONBF 不设置缓冲
*/

void setbuf(FILE *stream, char *buf);     // char buf[BUFSIZ]
/*
- buff为 NULL, 关闭缓冲
- 否则,等价于 `_IOFBF`

// 注意:buf 不要使用自动变量类型,尽量使用系统缓冲区或者动态分配内存
*/

​#TODO#​查看流缓冲状态 《UNIX高级环境编程》

读写流操作

字符 IO

​#TODO#​
输入输出函数家族 《C和指针》P301

int fgetc(FILE *stream);  // unsigned char 转 int, 兼容EOF
int getc(FILE *stream);   // 等价于 fgetc,注意实现为宏
int getchar(void);        // 等价于 getc(stdin)

int fputc(int c, FILE *stream);
int putc(int c, FILE *stream);  // 等价于 fputc, 注意实现为宏
int putchar(int c);             // 等价于 fputc(stdout)

// 只有fgetc和fputc是函数,其他都是宏

int ungetc(int c, FILE *stream);  // 将字符退回流中,依赖于当前位置
// 不同于写操作,仅涉及流本身而无关设备存储

未格式化的行IO

char *fgets(char *s, int n, FILE *stream);  // 自动包含换行符,\n 换为 \0,最多n-1
char *gets(char *s);                        // 不自动包含换行符。没有缓冲区长度参数,可能导致越界;不推荐使用,已废弃


int fputs(const char *s, FILE *stream);     // 不自动包含换行符\n,逐字符输入任意个数换行符
int puts(const char *s);                    // 自动添加换行符\n

ssize_t getline(char **lineptr, size_t *n, FILE *stream);
// 根据输入动态分配内存,无需预先确定输入字符串的最大长度
// 在存储的字符串中将换行符替换为字符串结束符'\0'

格式化 IO

int fprintf(FILE *stream, const char *format, ...);
int printf(const char *format, ...);                  // 等价于 `fprintf(stdout, fotmat, ...)`
int sprintf(char *s, const char *format, ...);        // 包含结束符 NUL,无长度参数,可能越界溢出
int snprintf(char *buf, szie_t n, const char *format, ...); // 超出部分截断,出错返回负值

int fdprintf(int fd, const char *format, ...);

// 变长参数列表变体
int vprintf(const char *format, va_list arg);
int vfprintf(FILE *stream, const char *format, va_list arg);
int vsprintf(char *s, const char *format, va_list arg);

转换格式:

  • 普通字符:复制到输出流

  • 转换说明:控制参数的格式转换


    • 开头 %

    • 标志[可选]



      • -
        ​左对齐,缺省为右对齐

      • +
        ​显示正负号
      • 空格 有符号值转换
      • 0
        ​ 宽度不足时填充 0

      • #
        ​指定另一种输出形式
    • 宽度数值[可选]:指定最小字段宽度

    • 精度数值[可选]:点号开始,后接十进制数值

    • 长度修饰符[可选]:指定参数的长度


      • h 按照short/unsigned short 输出
      • l 按照long/unsigned long 输出
      • L 按照long double 输出
    • 结尾: 转换字符, d, c, s, f, x等

int fscanf(FILE *stream, const char *format, ...);
int scanf(const char *format, ...);
int sscanf(const char *s, char *format, ...);

int fdscanf(int fd, const char *format,...);

// 变长参数列表变体 ...
  • 参数必须是指针
  • 到文件尾或者出错返回EOF,否则返回实际输入的字符数

转换格式

  • 空格或者制表符

  • 普通字符:匹配下一个输入

  • 转换说明


    • 开始标志
      %
      ​[可选]
    • 赋值屏蔽符号
      *
      ​[可选]
    • 最大字段宽度数值[可选]
    • 限定符
      h\l\L
      ​,指定参数的长度[可选]
    • 结束标志:转换字符 d,f,c, s, x等等

​#TODO#​4种使用场景P309

二进制 IO

直接IO/二进制IO,通常一次处理一个结构,能够处理null字节和换行符。

注意事项:只能用于同一系统,不同系统的偏移对齐以及存储格式可能不同。因此网络通信需要指定规范。

size_t fread(void *buffer, size_t size, size_t nobj, FILE *stream);

size_t fwrite(const void *buffer, size_t size, size_t nobj, FILE *stream);

/* 返回值是实际读写的元素的个数而非字节数
fread:少于nobj, 出错或者EOF,需要进一步分辨
fwrite:少于nobj,错误
*/

内存流

无关文件,直接在缓冲区和主存之间进行字节IO。非常适用于字符串。

Linux支持。

FILE *fmemopen(void *buf, size_t size, const char *type); // buf=null, 读写无意义;对于null字节的处理十分特殊

FILE *open_memstream(char **bufp, size_t sizep);   // 面向字节的流

FILE *open_wmemstream(wchar_t **bufp, size_t *sizep);  // 面向宽字节的流

文件定位函数

  • 二进制文件:使用字节偏移量,不一定支持 SEEK_END
  • 文本文件:格式不同不能使用字节,orgin=SEEK_SET, offset=0/ftell
int fseek(FILE *stream, long offset, int origin);
/*
- 二进制文件
  - origin
    - `SEEK_SET` 文件开始处
    - `SEEK_CUR` 当前位置
    - `SEEK_END` 文件结束处,可能不支持
- 文本文件
  - `SEEK_SET`;offset是 0 或者 `ftell`返回值
  - `SEEK_CUR`/`SEEK_END`:offset只能是 0

注意事项
- 行末指示符将被清除
- 退回的字符将被丢弃
- 更新模式中的读写操作切换
*/

long ftell(FILE *stream);

void rewind(FILE *stream);    // 重置为起始位置
//等价于 `fseek(stream, 0L, SEEK_SET); clearerr(stream);`
// off_t, 大于32位 UNIX 标准
off_t ftello(FILE *fp);
int seeko(FILE *fp, off_t offset, int origin);
// fpos_t ISO C标准,更加通用
int fgetpos(FILE *stream, fpos_t *ptr);

int fsetpos(FILE *stream, const fpos_t *ptr);

错误处理函数

发生错误或者到达文件尾时会设置状态指示符

整型表达式 errno 包含错误编号,定义在 <errno.h>

  • 只有库函数失败时才会设置 errno, 成功执行并不会修改 errno
  • 任何函数都不会将常量置为0
/* ----- 流错误 -----*/
int feof(FILE *stream);     // 流设置了文件结束指示符,返回非0值

int ferror(FILE *stream);   // 流设置了错误指示符,返回非0值

int clearerr(FILE *stream); // 清除流的所有指示符
#include <string.h>
char *strerror(int crrno);  // 映射错误信息

#include  <stdio.h>
int perror(const char *s);  // 打印字符串和 errno 错误信息
// 类似于 fprintf(stderr, "%s: %s\n", s, "error essage");

标准IO的替代

标准IO 的效率不高,调用行IO需要进行两次数据的复制

  • 快速IO fio: 使用指针而不是复制整行
  • sfio: 提高速度,同时推广IO流
  • mmap

适用于嵌入式系统的更低内存要求的实现

  • uClibc C库
  • Newlib C库

参考

  • 《C程序设计语言》
  • 《UNIX 环境高级编程》

作者:来自 vivo 互联网服务器团队- Zhang Mengtao

在项目研发过程中,由于时间、能力等因素往往会出现设计方案没有做到最好或最优、编码质量不够好等问题,技术债的出现是不可避免的,并且随着时间的推移,技术债对系统的影响会越来越大,同时使得对代码和架构设计的更改越来越困难,想要进一步提升效能必须要对技术债进行管理,本文通过在活动中台系统的技术债实践经验,介绍技术债的含义、分类和管理。

一、技术债的含义

1.1 技术债的含义

关于技术债的概念可以追溯到1992年,沃德·坎宁安(Ward Cunningham)首次提出,第一次发布代码,就好比借了一笔钱。只要通过不断重写来偿还债务,小额负债可以加速开发。但久未偿还债务会引发危险。复用马马虎虎的代码,类似于负债的利息。整个部门有可能因为松散的实现,不完全的面向对象的设计或其他诸如此类的负债而陷入窘境[1]。

《维基百科》中提出,技术负债(Technical debt),又称技术债,也称为设计负债(design debt)、代码负债(code debt),是程序设计及软件工程中的一个比喻。指开发人员为了加速软件开发,在应该采用最佳方案时进行了妥协,改用了短期内能加速软件开发的方案,从而在未来给自己带来的额外开发负担。这种技术上的选择,就像一笔债务一样,虽然眼前看起来可以得到好处,但必须在未来偿还。软件工程师必须付出额外的时间和精力持续修复之前的妥协所造成的问题及副作用[3]。

如下图所示,技术债在研发人员的日常工作付出中占据了一定的比例。

图片

1.2 技术债的危害

我们可以从效率、质量、体验三个方面来看:

1.2.1 效率

这是最直接的影响,当技术债不断增加,软件系统会变得非常脆弱。这种脆弱主要是由不良的架构设计或代码设计导致,不管最初是选择了划分良好的微服务架构,还是单体架构,技术债不断打破设计原则,让原则不复存在,以至于很难理清系统组件之间的关系和职责。当修改其中的一部分组件时,其他组件也会牵连,可能会陷入恶性循环。

1.2.2 质量

图片

以上图为例,从研发持续交付角度来分析的话,在项目版本迭代前期,业务功能较少的情况下,高质量要求的项目可能会比低质量要求的项目迭代速度慢一点,但是随着项目逐渐发展,高质量要求的项目对比低质量要求的项目迭代速度显著提升,技术债的持续累积是导致质量下降的关键原因,技术债是无法避免的,因此技术债的有效管理和消除是我们保障高质量软件的必不可少的方式之一。

1.2.3 体验

软件产品需要不断演进才能在长时间后依然还能适应市场,才能具有较强的生命力。相反,有的软件在经过几年的开发之后,随着技术债的增加,已经变的很难维护,很多时候只能被推倒重写,主要是不断叠加的技术债导致。技术债的叠加不断增加系统的复杂性,从而开发的成本逐渐增高,每次迭代都需要解决设计不足或技术所带来的问题。

二、技术债是怎么产生的?

技术债的出现是不可避免的,但是不同的场景下会产生不同的技术债,带来的影响也是不一样的,如果按照健康角度分类的话,对于研发人员允许出现的技术债可以划分为健康的一类,对于研发人员尽量避免的技术债则划分为不健康的一类。我们可以从以下四个维度进行分析。

图片

2.1 冲动/有意 - “没有做更好的设计”

研发团队虽然识别到这样做会导致技术债的积累,但是不清楚带来的后果,没有去做更好的设计方案。比如项目在线上运行时,突然出现了一个线上问题,如果不尽快修复上线,就会造成很大的损失,这种情况下,已经无法再针对问题作详尽的设计方案,现在需要以最快的方式修复上线,这种情况下往往不会考虑更好的设计方案,而是以最快捷的方式解决问题。对于当下的临时方案在未来会带来什么技术债,研发人员并没有关注。

2.2 谨慎/有意 - “必须尽快交付”

当研发团队面临业务压力时,例如在发布新产品时需要快速上线以占领市场时,快速解决问题的重要性常常超越了更好的实践。在这种情况下,团队往往会选择快速完成产品交付,然后再处理技术债务。团队清楚这样做会带来技术债务,也知道逾期还债的具体后果,以至已经安排好了未来的改进计划。这种场景很常见,是已知技术债的一种主要来源。

2.3 冲动/无意 - “不知道怎样设计更好”

这个维度技术债务产生的原因通常是由于人员技能的不足。在实际研发中,不可能保证所有人的水平都是一样的,由于缺乏相关技能,研发人员可能不清楚如何编写更优秀和精炼的代码,不知道如何设计更好的架构或者给出更佳的解决方案,这种情况下,研发人员按照自己的理解和设计方案进行工作,可能会带来一部分技术债。不管怎样的团队,人员的更替都是避免不了的,可能对项目不够熟悉,对某一块功能不够熟悉,短时间内快速理解并给出设计方案,可能会有很大难度,个人的经验不同,认知不同,在实现相同的功能时选择的方案也是不同的。

2.4 谨慎/无意 - “现在有更好的方案”

随着团队成员的能力提升或者行业技术上的演进,对于之前认为的最佳方案现在看来并不是最好的解决方案。但在当时,可能并不知道有更好的做法。这种技术债确实也是无法避免的,甚至会经常遇到,最简单的是基于当下的经验甚至业界最优的一些实践选择技术方案或者技术框架。可能在之前做这块功能的时候,团队成员已经对当时的方案达成了一致,认为是最优的方案,但是现在突然发现有更优的方案,那么为了项目的长远发展、稳定迭代,同样需要对项目作最优方案的替换。

以上将技术债务分为四类。我们通常认为,健康的技术债是右边的两个维度,不健康的技术债是左边的两个维度。基于此我们可以分析技术债产生的原因并制定相应的改进措施。

  • 对于冲动/有意类型的技术债,我们可以在日常的研发过程中拟制严格的规范,加强流程化的研发管理,让我们的研发人员对最优设计达成习惯和规范;

  • 对于谨慎/有意类型的技术债,我们可以和产品达成一致,每次面临这种紧急需求,或者需要快速上线而没有采取最优方案的情况,可以直接记录,后续尽快优化;

  • 对于冲动/无意类型的技术债,我们可以对所有的项目成员进行能力和认知的提升,尽可能让研发人员在熟悉功能和项目的情况下进行研发,另外可以增加设计方案评审流程和代码提交评审流程,有效减少这种类型的技术债;

  • 对于谨慎/无意类型的技术债,我们需要在识别到的时候第一时间记录下技术债,并且根据研发排期合理的安排时间进行修复。

    为了保证产品持续的竞争力,上面几点只是方法,如果没有成本上的投入,只能沦为空谈。从整个产品团队,都要提升对技术的正确理解,技术的构建并不是一劳永逸的,是需要不断的成本投入来维护的。

三、技术债管理实践

3.1 一个技术债真实案例

下面以一个项目中遇到的真实技术债案例来介绍,活动中台系统是一个面向用户的中台项目,可能每天都会产生大量的活动数据,即使进行了大量的分表处理,但是动辄千万级的数据仍然给数据库操作带来了一定的负担,所以我们目前沉淀了一套通用的数据库数据清理方案,根据不同类型的表配置不同的清理策略,基本参考维度是数据的产生时间和活动状态,如果一个已经结束的活动且数据产生时间大于半年则直接删除。这种方案虽然通用,但是在线上发现了严重的慢SQL问题,即使是通过离线库操作,仍然会让系统存在一定的风险,显然这个问题需要关注。

3.2 原来的做法

在原本的做法中,研发团队对于项目产生的技术债采取的方案是随机修复,也就是发现后会简单的记录下,如果是紧急问题则会同步项目组尽快上线,如果是非紧急问题,则会在下次版本迭代涉及该模块时进行修复,或者在版本gap期间随机进行修复,缺乏系统性的管理,往往可能会导致问题的遗漏,并且对于技术债的修复缺乏系统的分析和判断,虽然在有意识的修复技术债,但是效益容易被忽略,往往看不到真正的价值。

对于这个数据清理带来的慢SQL问题,虽然会产生慢SQL,但是对线上业务影响较小,所以优先级不高,暂时搁置,待到项版本有空闲人力再去优化,这个问题由于是技术侧单纯的技术优化,所以没有纳入需求列表,单纯的依赖开发人员人工记忆,等到人力空闲时再想是否有问题需要优化,这个时候才想到尘封已久的问题。方案的变动需要测试的介入来回归功能,这个时候开发人员想要优化,可能测试人力紧张,没能及时开发和测试,功能的上线又会搁置。

3.3 新的做法

按照现有方案,我们已经形成了一套稳定的技术债机制,相应的按照以下步骤进行处理:

图片

3.3.1 识别技术债

想要管理技术债,首先就是要识别技术债,技术的持续改进离不开团队中每个人的努力,因此需要每个成员都积极参与。通常我们在识别技术债的时候可以从以下几个类别去筛查。

图片

当我们发现这个数据清理问题时,可以直接记录在技术债跟踪列表中,记录技术债的所属项目、问题描述、创建人、处理人、创建时间、修复时间、技术债状态、规划版本、备注等属性,便于技术债的跟踪和审视。这种跟踪表只是一种方式,我们还有各种看板、空间可以用来记录,项目内达成一致即可。

图片

3.3.2 分析技术债

记录技术债之后,时常会遇到的问题是,需要改进的地方太多,尤其是对于遗留系统。怎么办?分析优先级。我们可以基于价值/成本矩阵来评估改进任务的价值和成本。基于下图的价值-成本矩阵,我们会:

  • 优先解决高价值+低成本的技术债;

  • 尝试将高价值+高成本的技术债拆分为高价值+低成本的技术债,逐步解决;

  • 在没有高价值+高/低成本的技术债时,再来考虑低价值+低成本的技术债;

  • 最后如果只剩下低价值+高成本的技术债,还是先拆分,再解决,或可考虑直接移除。

图片

3.3.3 解决技术债

分析完技术债的价值、成本、优先级、方案,我们可以在版本的gap期间跟随版本修复技术债,如果某部分功能刚好规划在版本中,那我们技术债的修复刚好由测试一起回归,这样可以做到工作量最小化,如果是高优的技术债,我们就需要尽早安排修复,紧急线上问题更是需要迅速迭代小版本上线。当然,修复完一定要及时更新技术债的状态。对于数据清理这条技术债,我们分析后得出结论:对于系统稳定方面有影响,需要一次性彻底优化,价值较高,优先级较高但是无需紧急修复上线,所以在下一个版本人力空闲期间就可以伺机修复上线。

这里要注意的是,对于同一条技术债,记录的人、修复的人可以不是同一人,这里需要技术债记录详细,便于方案的执行。数据清理这条技术债我们在随后的一段时间内就整改上线,完成了这条技术债的修复。

3.3.4 阶段审视

在实行技术债机制的管理过程中,建议进行阶段审视,查漏补缺。我们已经详细记录了技术债的所属项目、问题描述、创建人、处理人、创建时间、修复时间、技术债状态、规划版本、备注等属性,便于技术债的跟踪和审视。可以从多方面审视,一个是统计数据,如下表,可以看出每个阶段的修复数量、新增数量、上线总数、正在修复的数量和待开始的数量。这也是衡量项目质量的影响指标之一。

图片

另外一种是趋势图,我们可以从下图这样的折线图明显看出不同状态的技术债的趋势,包括不同阶段的技术债新增数量、修复数量、修复中数量和技术债总数趋势变化。

图片

除此之外我们可以观察团队成员在修复技术债方面的工作量体现,比如统计一年不同季度、不同团队成员每个季度对于技术债的修复数量,都是一些阶段审视的方式。

图片

3.4. 技术债管理机制

3.4.1 明确管理机制和责任分配

团队在针对技术债的治理过程中一定要确定主要责任人,虽然解决团队技术债问题是所有团队成员的责任,但是为了管理流程化、合理化、最优化,往往需要指定一个负责人专门跟踪技术债的管理。除此之外,技术债的管理机制要在团队内部达成高度一致,整个团队对于技术债问题的认知、修复、管理都是经过正式裁决的。

3.4.2 主动预防原则

通常来说,开发人员能直观感受到技术债的坏处,大都愿意去偿还技术债,所以技术债累积的主要原因是,没有认识到技术债累积给业务发展带来的巨大坏处。这也就意味着,解决技术债的第一步就是,要意识到偿还技术债的重要性,从而愿意投入资源去解决。对主动引入的技术债,要尽量让管理层和产品团队了解技术上的捷径将会带来的长期危害,尽量减少技术债的引入。

3.4.3 高价值优先原则

需要遵循高价值优先的原则来优先修复技术债,上面我们已经分析过技术债问题的价值/成本矩阵,对于分析结果,我们在版本中解决技术债问题的时候就要严格按照高价值优先的原则去修复,而不是根据技术债发现的时间,或者是成本低的技术债优先修复,切忌随机修复技术债。

四、经验总结

4.1 技术债是不是越少越好?

当然不是!提到技术债,我们想到的往往是它的坏处,比如难以维护、难以增加新功能等,但实际上它也有好处。关于技术债的好处,我们可以对应着金融领域的经济债务来理解。我们都知道,经济债务最明显的好处在于,可以帮助我们完成很多本来不可能完成的任务,比如贷款买房。相应的,技术债可以在短期内帮我们快速完成业务开发,满足用户需求,就类似房贷的作用。当研发团队面临业务压力时,例如在发布新产品时需要快速上线以占领市场时,快速解决问题的重要性常常超越了更好的实践。在这种情况下,团队往往会选择快速完成产品交付,然后再处理技术债务。团队清楚这样做会带来技术债务,也知道逾期还债的具体后果,只要有计划的进行优化即可。

4.2 持续管理技术债带来的益处有哪些?

  • 提升系统稳定性:不断规范化的解决更多的技术债有利于系统更加稳定;

  • 形成稳定机制:活动中台系统经过长时间的实践,逐渐形成稳定的技术债管理机制,面对项目中的技术债不再头疼如何跟踪;

  • 提升项目质量:随着技术债机制的实行,项目成员在迭代时就会更多的考虑技术债方面的问题,久而久之项目质量也会有所提升。

4.3 技术债是否可以作为项目管理的重要指标之一?

技术债已经成为很多项目管理的一个重要指标,用来衡量项目整体的研发效能,虽然本文没有过度涉及技术债在研发效能工具方面的体现,但是在这个过程中工具很重要。

参考文档:

[1] Ward Cunningham,《WyCash 投资组合管理系统》,ACM SIGPLAN OOPS Messenger4, no.2 (1992)

[2]
https://www.productplan.com/glossary/technical-debt/

[3]
https://zh.wikipedia.org/wiki/%E6%8A%80%E6%9C%AF%E8%B4%9F%E5%80%BA#cite_note-oopsla92-1

[4] Philippe Kruchten, Rod Nord, and Ipek Ozkaya, 《管理技术债务:减少软件开发中的摩擦》(Addison-Wesley, 2019)

[5]
https://time.geekbang.org/column/article/142210

Redis 提供了丰富的数据类型,每种数据类型都有其独特的存储结构和操作方法,可以满足不同的业务场景需求。下面详细介绍 Redis 支持的主要数据类型及其底层实现,并结合具体的应用场景说明其使用。

1.
字符串(String)

介绍:

  • Redis 中最基本的键值对类型,键和值都可以是字符串,值的最大限制为 512MB。
  • String
    类型是 Redis 最常用的数据类型,它支持简单的
    GET

    SET
    操作,以及自增、自减、字符串拼接等操作。

典型应用场景:

  • 缓存数据
    :存储用户登录状态、Token、配置信息等。
  • 计数器
    :通过
    INCR

    DECR
    实现简单的计数器,比如网站访问量、点赞数等。
  • 分布式锁
    :结合
    SETNX
    命令,可以用字符串来实现简单的分布式锁。

底层原理:

  • Redis 底层对字符串使用的是简单动态字符串(SDS),它不仅是 C 字符串的封装,还加入了长度属性和空间预留等优化策略。SDS 支持二进制安全,可以存储文本和二进制数据。


2.
哈希(Hash)

介绍:

  • 哈希是一个键值对集合,适合存储对象。每个键可以有多个字段,每个字段都有一个值。
  • 操作包括
    HSET

    HGET

    HDEL
    等。

典型应用场景:

  • 存储用户信息
    :如用户 ID 作为键,用户的属性(姓名、年龄、性别等)作为字段,避免将整个用户对象序列化成字符串。
  • 配置项管理
    :存储配置项,方便根据字段名快速访问和更新某个配置。

底层原理:

  • 哈希使用了两种底层数据结构:小数据量时使用压缩列表(ziplist),大数据量时使用哈希表(hashtable)。压缩列表可以节省内存,但随着哈希表的增长会自动转换为哈希表,保证查询效率。


3.
列表(List)

介绍:

  • 列表是一个双向链表,可以从头部或尾部插入、删除元素,常用命令包括
    LPUSH

    RPUSH

    LPOP

    RPOP
    等。
  • Redis 支持阻塞操作,如
    BLPOP

    BRPOP
    ,在没有元素时可以阻塞等待。

典型应用场景:

  • 消息队列
    :列表可以作为简单的消息队列,用
    LPUSH
    将消息放入队列,用
    RPOP

    BRPOP
    弹出消息。
  • 任务调度
    :异步任务分发系统中,可以将任务放入队列中,由多个消费者去消费。

底层原理:

  • 列表采用双向链表(quicklist)实现。对于较短的列表,Redis 会使用压缩列表(ziplist)来节省内存;对于较长的列表,则会采用真正的双向链表来平衡操作的时间复杂度。


4.
集合(Set)

介绍:

  • 集合是无序的、唯一的元素集合,提供类似于数学集合的操作,支持交集、并集、差集等。
  • 常用操作包括
    SADD

    SREM

    SISMEMBER

    SMEMBERS

    SINTER
    等。

典型应用场景:

  • 标签系统
    :如将用户标签存储为集合,每个集合代表一个用户群体,方便进行集合运算,如找出同时拥有某两个标签的用户。
  • 去重功能
    :在某些场景下(如热门搜索词、访问日志的去重),可以通过集合的唯一性特性来避免重复数据。

底层原理:

  • 小集合时使用整数集合(intset),大集合时使用哈希表(hashtable)实现。通过哈希表的快速查找特性,可以实现 O(1) 的时间复杂度来判断元素是否存在。


5.
有序集合(Sorted Set)

介绍:

  • 有序集合类似于集合,但每个元素关联一个分数,集合中的元素会按分数排序。支持的操作包括
    ZADD

    ZRANGE

    ZREM

    ZREVRANGE

    ZCOUNT
    等。

典型应用场景:

  • 排行榜
    :比如游戏中的积分榜,按用户分数进行排名。可以通过
    ZADD
    添加玩家及其分数,通过
    ZRANGE
    获取排名。
  • 延迟任务
    :通过分数设置任务执行的时间,按时间从集合中取出需要执行的任务。

底层原理:

  • 有序集合底层使用的是跳表(Skiplist)和哈希表相结合的数据结构,跳表使得有序集合支持快速的范围查询和插入操作(时间复杂度 O(logN)),而哈希表保证元素的快速定位。


6.
位图(Bitmaps)

介绍:

  • 位图实际上是字符串类型的扩展,可以把字符串看作一系列连续的二进制位,可以对这些二进制位进行位操作。支持的命令有
    SETBIT

    GETBIT

    BITCOUNT

    BITOP
    等。

典型应用场景:

  • 用户签到系统
    :用位图存储用户的签到记录,每天对应一个 bit,0 表示未签到,1 表示已签到。
  • 活跃用户统计
    :通过位图存储某一时间段内用户是否活跃,快速统计某天有多少活跃用户。

底层原理:

  • 位图的底层存储是 Redis 的字符串结构,但位操作是直接针对每个二进制位,因此能够在非常紧凑的存储空间内实现高效的操作,适合海量数据场景。


7.
HyperLogLog

介绍:

  • HyperLogLog 是一种用于基数统计的算法,可以用于估算一个集合中不重复元素的个数,且占用的内存空间非常小。
  • 常用命令有
    PFADD

    PFCOUNT

典型应用场景:

  • 独立访客统计
    :在网站分析中统计独立访客(UV),只需为每个访客 ID 添加到 HyperLogLog 中,快速得到不重复用户数。
  • 大规模数据去重计数
    :用于估算大规模数据中的去重个数,如点击、请求、访问量等。

底层原理:

  • HyperLogLog 是一种基数估计算法,通过哈希分布将数据映射到位向量中,通过统计不同前缀的最大长度来估算基数,其优点是占用内存极小,缺点是只能进行估算,存在一定误差。


8.
地理空间(Geospatial)

介绍:

  • Redis 支持存储地理位置数据,并基于这些数据进行范围查询和距离计算。常用命令包括
    GEOADD

    GEODIST

    GEORADIUS

    GEOHASH
    等。

典型应用场景:

  • LBS 应用
    :比如打车应用中,存储司机和乘客的地理位置,根据位置计算距离,匹配最近的车辆。
  • 附近商家搜索
    :用户输入位置后,查询附近的商家,并根据距离排序返回。

底层原理:

  • Redis 的地理空间数据是基于有序集合实现的,使用
    GEOHASH
    算法将地理坐标编码为 64 位的整数,存入有序集合中。通过对这些编码的范围查询,可以实现快速的空间检索。


9.
流(Streams)

介绍:

  • Stream
    是 Redis 5.0 引入的一种新的数据类型,支持消息队列的功能,类似于 Kafka 或者 RabbitMQ,支持消费组、消息持久化和自动应答等特性。常用命令包括
    XADD

    XREAD

    XGROUP

    XACK
    等。

典型应用场景:

  • 消息系统
    :通过流数据类型,多个消费者可以从同一个队列中消费数据,实现消息分发和处理。
  • 日志系统
    :可以将日志信息存储在 Redis 的流中,实现持久化和实时消费。

底层原理:

  • Stream 是基于压缩列表和链表的结合体,数据结构复杂度较高,可以高效存储大量的流式数据。通过内部维护的 ID 进行排序和管理,使得它适合处理有序的、持续生成的数据流。


总结

Redis 提供的多种数据类型,不仅丰富了其在不同业务场景下的适用性,还能通过内存友好的数据结构和高效的算法来保证性能。在选择 Redis 数据类型时,通常需要根据业务需求来匹配合适的数据结构,从而最大限度地提升系统性能和资源利用率。