阅读视图

发现新文章,点击刷新页面。

Triton-Lang在Transformer优化加速中的实践 | 得物技术

一、前言

众所周知,英伟达(Nvidia)自2006年推出CUDA以来,经过近20年的发展,尤其是经历了以卷积为代表的深度学习和近两年以Transformer为基础的LLM的推动,CUDA编程基本上成为了GPU编程的代名词。CUDA作为GPU的编程语言,不仅使用户能充分发挥Nvidia GPU的高性能的并行计算能力,也逐渐构筑了一个包括硬件、驱动、开发库和编程技巧的完备生态链,从而使CUDA成为了人工智能、高性能计算和云计算中的核心依赖。

document.jpeg(图片来源:Triton-lang documentation )

Triton是OpenAI 推出的以python为编程语言基础,专门为深度学习研发和高性能计算而设计的编程语言和编译器,旨在简化和优化GPU编程的复杂操作,降低高性能优化的门槛。

在大模型推理优化领域,已有很多优秀的工作开始应用Triton编写高效算子,例如近期被众多大模型推理框架集成的Attention算子FlashAttention、推理加速框架lightllm、训练加速框架的Unsloth等。

Triton的初期版本以CUDA为起点而开发,为没有CUDA基础的编程者提供快速编写高效CUDA kernel的方案,而随着迭代已逐渐支持其他芯片和编程工具,如AMD的ROCm,并在继续支持其他的芯片,如Intel的CPU。因而,除了简化高性能计算,同时Triton也在试图构建一个“CUDA-free”的更高层的kernel编写方案,打破“天下苦CUDA久矣”的局面,把复杂的对底层芯片的交互,交给其IR和底层的编译器。

综上,可以说Triton是起于CUDA,又不止于CUDA。几个词可以简单总结Triton的特点和发展方向:

  • 门槛低
  • 高效
  • 多平台

二、GPU基础

在学习Triton的编程设计前,还是需要了解GPU一些简单的基础架构知识和GPU编程的基础概念。

以下左图是引自NVIDIA经典Ampere架构的GA100(A100)的datasheet的整体架构示意图,展现其所有128个SMs(Streaming Multiprocessors)和各级缓存、HBM(高性能内存)和NvLink(Nvidia卡间互联)等;而右图是A100的单个SM(Streaming MultiProcessor, 多核流处理器) 的结构。

CPU基础1.jpeg

gpu基础2.jpeg(图片来源:Nvidia-ampere-architecture-whitepaper )

从硬件的角度来讲,

  • SP (Streaming Processor 线程处理器) 是CUDA 编程模型的最基本单位。每个SP都有自己的registers (寄存器) 和 local memory (局部内存, L0 cache)。寄存器和局部内存只能被自己访问,不同的线程处理器之间彼此独立。
  • 由多个线程处理器 (SP) 和一块共享内存(shared memory, L1 cache)构成了一个SM。多核处理器里边的多个SP互相并行,且互不影响。每个SM内都有自己的共享内存,shared memory 可以被线程块内所有线程访问。

从软件的角度来讲,

  • thread(线程):一个CUDA程序被分成多个threads执行。
  • block 或 thread block (线程块):多个threads群组成一个block,同一个block中的threads可以同步,也可以通过shared memory 传递数据。
  • grid(网格):多个blocks会再构成grid。
  • warp:GPU执行程序时的调度单位。

对应关系:

  • 一个SP可以执行一个thread。
  • CUDA的device在执行任务时,会把任务分成一个个的block分配给SM执行, 而每个block又会以warp为单位执行(Nvidia把32个threads组成一个warp, warp即是SM调度和运行的基本单元,所有SP执行同一指令,但每个thread使用各自的data)。
  • 一个warp需要占用一个SM,多个warps则会轮流进入SM处理。

ss.jpeg(图片来源:OpenAI official introduction )

将上述结构大致抽象成3个组成部分DRAM, SRAM和ALU, 其中DRAM即各个HBMs(即俗称的显存),SRAM指各级缓存,ALU即计算单元(GPU中的SM),而当用户优化CUDA代码时需要考虑:

  • DRAM读写时的内存合并:以保证充分利用GPU的内存带宽;
  • 数据必须手动分配至各级SRAM:以尽可能地避免共享内存冲突;
  • 计算流程必须在SM内部和外部谨慎合理地设计、分配和调度:以促进并行线程的计算效率。

而在编程设计时充分考虑以上,即使是对于富有经验的CUDA编程者也颇具挑战,因而Triton希望底层编译器对多数的调度细节能自动优化,而用户只需要考虑一些顶层的逻辑设计,即SMs层级的,例如矩阵分片,SM之间数据同步等问题。

其官网介绍给出了一个对比,

AI对比.jpeg(表格来源:OpenAI official introduction)

通俗而言,相比于CUDA,使用Triton,你不必控制所有内容,因为有些事情可以留给工具自动优化;用Triton编写的模块可能不一定优于顶级的CUDA算子,但是性能通常能优于普通的CUDA kernel;而前者的门槛大大低于后者。

因而Triton的编程设计过程,其关键在于SM层级的并行处理过程的设计,即画好SM层级的网格图以表示算子的计算过程。

三、Triton 编程实例

向量求和

内核函数

向量求和对于Triton是一个"Hello World"式的示例。使用Pytorch,对于两个同长度的vector,直接相加,非常简单。

内核函数.jpeg

size = 1024
x = torch.rand(size, device='cuda')
y = torch.rand(size, device='cuda')
output_torch = x + y

而对于Triton,需要编写一个内核函数(kernel)和一个调用函数(wrapper),调用时的并行网格图如下:

并行网格图.jpeg kernel 函数代码如下:

import triton.language as tl

@triton.jit
def add_kernel(x_ptr,  # 第一个输入向量的指针
               y_ptr,  # 第二个输入向量的指针
               output_ptr,  # 输出向量的指针
               n_elements,  # 向量长度
               BLOCK_SIZE: tl.constexpr,  # 每个线程块处理的元素数量
               ):
    # 有多个'程序'处理不同的数据, 用pid标识当前是哪个程序
    pid = tl.program_id(axis=0)  
    # 计算当前程序所需要的数据的偏置
    block_start = pid * BLOCK_SIZE
    offsets = block_start + tl.arange(0, BLOCK_SIZE)
    # 创建一个掩码以防止内存操作超出范围
    mask = offsets < n_elements
    # 从 DRAM 加载 x 和 y
    x = tl.load(x_ptr + offsets, mask=mask)
    y = tl.load(y_ptr + offsets, mask=mask)
    output = x + y
    # 将计算结果output写回 DRAM
    tl.store(output_ptr + offsets, output, mask=mask)
  • @triton.jit装饰器用于定义内核函数,在程序执行时即时编译并在GPU上执行。

  • x_ptr, y_ptr, output_ptr 分别是两个输入向量和一个输出向量的指针,n_elements表示向量长度,BLOCK_SIZE 的数据类型为 tl.constexpr,表示一个编译时的常量,定义了每个线程块处理数据时的数据长度。

  • 向量相加虽然简单,但是基本体现了内核函数通常的编写流程,定义维度 -> 计算偏置 -> 设置掩码 -> 读取数据 -> 计算过程 -> 写回数据。

    • 定义维度:当前程序(线程块)通过tl.program_id 获取自己的pid, 该程序id标识了当前程序的唯一性。tl.program_id和块大小(BLOCK_SIZE)也决定了并行处理时对整个数据块的划分,比如在这个向量数据的处理时,axis=0表示一维的划分,再比如矩阵乘法的操作,当我们用分块矩阵的思路设计内核时,则是在二维层面的操作。
    • 计算偏置:得到当前程序的id时,我们需要从整个数据块拿取当前程序所需的那块数据,所以需要通过id和块大小(BLOCK_SIZE)计算offsets。需要注意的是,这里的offsets是一个list,即是当前需要的数据的所有索引。
    • 设置掩码:因为数据的长度通常无法被我们预设的块大小整除,比如下图示例中的最后一块,所以需要设置mask,防止内存操作超出范围。
    • 读取数据:根据输入数据的指针、偏置和掩码,从DRAM(显存) 读取数据到当前程序所在的SRAM(缓存)。
    • 计算过程:在这里定义我们所需要的计算流程,例如将两段数据 x和y相加。
    • 写回数据:处理完数据后,同样根据输出数据的指针、偏置和掩码,把结果output从SRAM写回DRAM。

线程块在GPU的计算模型里又被称为CTA(Cooperative Thread Array),以上的计算过程相当于一个CTA处理单个block。

block.jpeg

而当缓存受限时,我们也可以在单个CTA中处理多个blocks, 如下图和相应的写法:

相应写法.jpeg

@triton.jit
def add_kernel(x_ptr, y_ptr, o_ptr, n_elements, num_blocks_per_CTA, BLOCK_SIZE: tl.constexpr,):
    pid = tl.program_id(axis=0)  
    program_offsets = pid * num_blocks_per_CTA * BLOCK_SIZE 
    offsets = program_offsets + tl.arange(0, BLOCK_SIZE)
    
    for i in range(num_blocks_per_CTA):
        mask = offsets < n_elements
        x = tl.load(x_ptr + offsets, mask=mask)
        y = tl.load(y_ptr + offsets, mask=mask)
        output = x + y
        tl.store(o_ptr + offsets, output, mask=mask)
        offsets += BLOCK_SIZE

接口函数

有了内核函数,我们需要再写一个wrapper,就可以调用内核(好比Pytorch的torch.Add api, 即加号"+")。

def add(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
    output = torch.empty_like(x)
    assert x.is_cuda and y.is_cuda and output.is_cuda
    n_elements = output.numel()
    # SPMD启动网格,表示并行运行的内核实例数。
    grid = lambda meta: (triton.cdiv(n_elements, meta['BLOCK_SIZE']), )
    # 调用内核函数
    add_kernel[grid](x, y, output, n_elements, BLOCK_SIZE=1024)
    # 我们返回一个指向z的句柄,但是,由于`torch.cuda.synchronize()`尚未被调用,内核此时仍在异步运行。
    return output

torch.manual_seed(0)
size = 98432
x = torch.rand(size, device='cuda')
y = torch.rand(size, device='cuda')
output_triton = add(x, y)

这里需要注意两点:

  • 内核程序的运行需要启动一个网格,Triton以SPMD(单程序多数据,与SIMD类似)的方式执行程序。网格grid 与内核函数中一开始我们获取的程序标识(id)相对应,在向量处理这个示例中,它是一个一维的网格,数据格式可以是Callable(metaparameters) -> Tuple[int] ,如上面代码(triton.cdiv是Triton封装的除法,cdiv表示ceiling division),也可以直接是Tuple[int],如(n_elements + BLOCK_SIZE-1)//BLOCK_SIZE。
  • 我们看上述调用内核函数的格式,可以看到,内核函数可以被grid索引,每次索引可以得到一个GPU内核,启动一个程序。x,y,output这些张量作为参数传入内核函数的同时,被隐式地转化为指向各自张量第一个元素的指针。

性能测试

Triton自带性能测试函数,可以帮助衡量自己设计的算子与baseline之间的差距。装饰器@triton.testing.perf_report用于装饰benchmark函数,而triton.testing.Benchmark函数定义了plot折线图的属性,在benchmark函数里面我们可以定义指标来比较不同算子之间的性能,如Triton和Pytorch算子之间在不同size计算下的吞吐差距。

@triton.testing.perf_report(
    triton.testing.Benchmark(
        x_names=['size'],  # Argument names to use as an x-axis for the plot.
        x_vals=[2**i for i in range(12, 28, 1)],  # Different possible values for `x_name`.
        x_log=True,  # x axis is logarithmic.
        line_arg='provider',  # Argument name whose value corresponds to a different line in the plot.
        line_vals=['triton', 'torch'],  # Possible values for `line_arg`.
        line_names=['Triton', 'Torch'],  # Label name for the lines.
        styles=[('blue', '-'), ('green', '-')],  # Line styles.
        ylabel='GB/s',  # Label name for the y-axis.
        plot_name='vector-add-performance',  # Name for the plot. Used also as a file name for saving the plot.
        args={},  # Values for function arguments not in `x_names` and `y_name`.
    ))
def benchmark(size, provider):
    x = torch.rand(size, device='cuda', dtype=torch.float32)
    y = torch.rand(size, device='cuda', dtype=torch.float32)
    quantiles = [0.5, 0.2, 0.8]
    if provider == 'torch':
        ms, min_ms, max_ms = triton.testing.do_bench(lambda: x + y, quantiles=quantiles)
    if provider == 'triton':
        ms, min_ms, max_ms = triton.testing.do_bench(lambda: add(x, y), quantiles=quantiles)
    gbps = lambda ms: 3 * x.numel() * x.element_size() * 1e-9 / (ms * 1e-3)
    return gbps(ms), gbps(max_ms), gbps(min_ms)

benchmark.run(print_data=True, show_plots=True, save_path="./")

在左图,我们可以看到在向量维度较小时,torch的计算更快,而当维度较大时,Triton算子的计算更快一些。

相比向量的计算可能无法体现自定义算子的优势,右图展示了Triton 官方教程中定义的矩阵乘法算子的性能,可以看到其和cuBLAS编写的算子相比已能够达到持平的性能。

性能测试1.jpegVector Add Triton vs. Pytorch

性能测试2.jpeg

Matrix multiplication Triton vs cuBLAS

调试

早期的Triton核函数开发不支持调试,目前已经支持pdb和各种python ide的断点调试,只需设置环境变量即可。

os.environ["TRITON_INTERPRET"]=1

矩阵乘法

两个矩阵相乘,

两个矩阵相乘.jpeg

在Pytorch中,两个矩阵相乘可以直接以torch.matmul(A, B)计算得到。而进一步对其稍作优化,我们立刻能想到的通常是分块矩阵。用Pytorch表示具体的流程:

# Pytorch
import torch
from typing import Tuple

@torch.jit.script
def block_matrix_multiplication(A: torch.Tensor, B:torch.Tensor, 
                                M: int, N: int, K: int, 
                                BLOCK_SIZE_M: int, BLOCK_SIZE_N: int, BLOCK_SIZE_K: int) -> torch.Tensor:
    C = torch.zeros((M, N), dtype=torch.float32)
    for m in range(0, M, BLOCK_SIZE_M):
        for n in range(0, N, BLOCK_SIZE_N):
            acc = torch.zeros((BLOCK_SIZE_M, BLOCK_SIZE_N), dtype=torch.float32)
            for k in range(0, K, BLOCK_SIZE_K):
                a = A[m : m+BLOCK_SIZE_M, k : k+BLOCK_SIZE_K]
                b = B[k : k+BLOCK_SIZE_K, n : n+BLOCK_SIZE_N]
                acc += torch.matmul(a, b)
            C[m : m+BLOCK_SIZE_M, n : n+BLOCK_SIZE_N] = acc
    return C

# 用法示例
A = torch.rand(100, 100)
B = torch.rand(100, 100)
result = block_matrix_multiplication(A, B, 100, 100, 100, 16, 16, 16)
  • A, B表示两个输入矩阵,其二维尺寸分别为(M, K)和(K, N); BLOCK_SIZE_M, BLOCK_SIZE_N, BLOCK_SIZE_K 分别为分块时M, N, K三个维度的分块尺寸。
  • 主程序用3层循环计算,外边2层循环分别是依次遍历A和B两个矩阵的列和行;而最里边的循环,则是对于输出矩阵,每个对应的分块(M, N), 需要A和B相对应的列和行的分块依次相乘后累加。

从以上逻辑可以看出,这是一个行主序(Row Major)的分块矩阵乘法顺序。

我们可以画出其网格图,以下橙色的blocks 表示单个CTA的计算过程。

CTA.jpeg 按照网格图,将上述的计算过程改写为Triton的内核函数:

# Triton kernel
@triton.jit
det matmul_kernel(A, B, C, M, N, K, 
    BLOCK_M: tl.constexpr, BLOCK_N: tl.constexpr, BLOCK_K: tl.constexpr):
    # 2d grid
    pid_m = tl.program_id(0)
    pid_n = tl.program_id(1)
    offsets_m = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)
    mask_m = offsets_m < M
    offsets_n = pid_n * BLOCK_N + tl.arange(0, BLOCK_N)
    mask_n = offsets_n < N
    
    # 2d tile
    acc = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32)
    for start_k in range(0, K, BLOCK_K):
        offsets_k = start_k + tl.arange(0, BLOCK_K)
        mask_k = offsets_k < K
        
        a_ptrs = A + offsets_m[:, None]*K + offsets_k[None, :]
        mask_a = mask_m[:, None] & mask_k[None, :]
        b_ptrs = B + offsets_k[:, None]*N + offsets_n[None, :]
        mask_b = mask_k[:, None] & mask_n[None, :]
    
        a = tl.load(a_ptrs, mask=mask_a, other=0)
        b = tl.load(b_ptrs, mask=mask_b, other=0)
        acc += tl.dot(a, b)
    c_ptrs = C + offsets_m[:, None]*N + offsets_n
    mask_c = mask_m[:, None] & mask_n[None, :]
    tl.store(c_ptrs, acc, mask = mask_c) 
    
# grid = (tl.cdiv(M, BLOCK_M), tl.cdiv(N, BLOCK_N), 1)

以上代码,我们关注几点:

  • 网格:采用二维网格,作为程序标识,并计算其输入分块矩阵和输出分块矩阵的偏置,以及分块矩阵的掩码矩阵;
  • 累加:由上图可知,输出矩阵的每个分块,分别由其对应的K//BLOCK_K个矩阵相乘的结果累加得到。

行主序和列主序的代码和计算顺序如下,虽说CUDA是并行计算的程序,但是当我们将矩阵分为很多的程序执行时,如果我们的GPU并没有足够的SM来同时执行所有程序,因而这些程序是先后被加载入SM计算的。而CUDA默认是以列主序存储数据的,所以有时候列主序的程序性能要优于行主序。

  • 行主序:
for m in range(0, m, BLOCK_M):
    for n in range(0, n, BLOCK_N): 
        CTA(m, n) ....

行主序.jpeg

  • 列主序:
for n in range(0, N, BLOCK_N):
    for m in range(0, M, BLOCK_M): 
        CTA(m, n) ....

列主序.jpeg

而Triton的官网给出了一个基于L2 cache 优化的方案。其思路是以减少访存次数来提高cache的命中率。我们可以从下图比较其与通常的乘法算子的区别。

通常列主序.jpeg通常的列主序(column-major-ordering)

分组列主序.jpeg分组后的列主序 (grouped-column-major-ordering)

从上可以看出,左边计算4个CTA时,需要读取1列和4行,总共要进行5次读取;而右边的操作,只需要读取2列和2行,共4次读取。实际计算中,矩阵的行列维度数值都较大,分组后的计算在访存上会有一定的优化,而实际中在例如A100的硬件上这样的优化也能有10%的提升。

以下是官网优化示例给出的核心代码,相比于上述的二维索引,引入group之后采用一维索引,而代码的本质则是将这个一维索引pid转化为二维索引(pid_m, pid_n),而在这个变化中,我们重新定义了结果矩阵的计算顺序(即上图,右图中区别于左图的元素计算顺序)。

pid = tl.program_id(axis=0)
num_pid_m = tl.cdiv(M, BLOCK_SIZE_M)
num_pid_n = tl.cdiv(N, BLOCK_SIZE_N)
num_pid_in_group = GROUP_SIZE_M * num_pid_n
group_id = pid // num_pid_in_group
first_pid_m = group_id * GROUP_SIZE_M
group_size_m = min(num_pid_m - first_pid_m, GROUP_SIZE_M)
pid_m = first_pid_m + (pid % group_size_m)
pid_n = (pid % num_pid_in_group) // group_size_m

我们用两个9x9的矩阵相乘来说明这个索引的过程:

  • 首先num_pid_m,num_pid_n分别计算了两个矩阵的M, N 维度各自块的个数;如下图中num_pid_m=9,num_pid_n=9。
  • GROUP_SIZE_M定义了group的维度;如下图中的GROUP_SIZE_M=3。
  • num_pid_in_group 为单个group中块的个数;如下图中 GROUP_SIZE_M * num_pid_n=27。
  • group_id 则计算得到 pid 是在哪个group中,即group的 id;如下图中 pid // num_pid_in_group=1。
  • first_pid_m 计算的是这个pid所在的group的第一个块的pid_m的值,以方便后续为算pid最终的pid_m, pid_n 提供偏置;如下图中first_pid_m=3 ,即图中第27块的pid_m。
  • group_size_m 则是计算了这个pid所在的group的行数,这是为了避免M无法为GROUP_SIZE_M所整除时,最后一个group的行数小于GROUP_SIZE_M;如下图的group的行数值为GROUP_SIZE_M,若当图中的行数为8时,可以想像最后一行的group_size_m为2。
  • 最后两行代码则是计算 pid 的真正坐标 (pid_m, pid_n)。例如下图的pid=33, 则pid_m=3+(33%3)=3, pid_n=(33%27)//3=2。

pid真正坐标.jpeg

旋转位置编码

旋转位置编码(RoPE, Rotate Position Encoding)是Transformer 进入大模型应用时代后的重要算子,在Llama,ChatGLM 等主流的大模型中都有应用。关于旋转位置编码的原理和作用可以参考原论文和作者博客。其计算过程可以简要表示成以下的旋转变换,

旋转变换.jpeg

以下是一个Huggingface中Llama RoPE的前向计算流程。

  • d 表示 embedding的维度,则位置编码的相位频率表示如下,m = [0, 2, 4, ..., d/2] , f = (1/10000)^m,!

前向计算流程1.jpeg

  • n 表示token的个数,

前向计算流程2.jpeg

  • 前向计算流程3.jpeg
  • 前向计算流程4.jpeg
  • 前向计算流程5.jpeg

以上是计算cos和sin两个旋转变换矩阵的过程,而矩阵q和k在做注意力乘法前先做简单处理。

def rotate_half(x):
    """Rotates half the hidden dims of the input."""
    x1 = x[..., : x.shape[-1] // 2]
    x2 = x[..., x.shape[-1] // 2 :]
    return torch.cat((-x2, x1), dim=-1)

最后是旋转变换:

q_embed = (q*cos) + (rotate_half(q)*sin)
k_embed = (k*cos) + (rotate_half(k)*sin)

我们来看Triton kernel对前向过程的实现:

给定矩阵Q,Cos,Sin, 它们的维度分别为

它们的维度.jpeg h 是注意力模块中的head数,为方便说明,可令b=1;需要实现的计算过程是!

需要实现的计算流程.jpeg 为计算方便,现将Q reshape为(n,hd)。

考虑并行的维度时,按 token 并行是首先能想到的,n_rows = n,再考虑到有限的缓存,可以将另一个维度按group分,可以定义一个常量GROUP_SIZE=4(当然这里也可以设计成autotune模式,自动选择合适值),然后可以计算得到embedding维度的group数量n_groups ,并定义我们的网格 grid。

div, mod = divmod(n, GROUP_SIZE)
n_groups = div + (mod != 0)
grid = (n_rows, n_groups)

下图阐释了整体和单元的计算过程。并行程序按两个维度计算,每个CTA的计算过程中有个次数为GROUP_SIZE的循环累加,各自累加计算得到q*cos和 rotate_half(q)*sin,再相加。

各自累加计算再相加.jpeg

def _rope_embedding(
    Q,     Q_row_stride,
    cos, cos_row_stride,
    sin, sin_row_stride,
    seqlen,
    head_dim      : tl.constexpr,
    n_heads       : tl.constexpr,
    BLOCK_SIZE    : tl.constexpr,
):
    """
        Calculates the RoPE Embedding quickly
        RoPE is Q * cos + rotate_half(Q) * sin
        See our blog post for more info
    """
    GROUP_SIZE = 4
    row_position  = tl.program_id(0)
    group_head_position = tl.program_id(1)
    col_offsets  = tl.arange(0, BLOCK_SIZE)
    half_head_dim = head_dim // 2
    mask = col_offsets < half_head_dim

    sin1 = tl.load(sin + (row_position % seqlen)*sin_row_stride + \
                   half_head_dim*0 + col_offsets, mask = mask, other = 0)
    cos1 = tl.load(cos + (row_position % seqlen)*cos_row_stride + \
                   half_head_dim*0 + col_offsets, mask = mask, other = 0)

    head_start = group_head_position * GROUP_SIZE
    head_end = min((head_start + GROUP_SIZE), n_heads)

    for k in range(head_start, head_end):
        offs_q1 = row_position * Q_row_stride + k * head_dim + col_offsets
        offs_q2 = row_position * Q_row_stride + k * head_dim + col_offsets + half_head_dim

        Q1 = tl.load(Q + offs_q1, mask = mask, other = 0).to(sin1.dtype)
        Q2 = tl.load(Q + offs_q2, mask = mask, other = 0).to(sin1.dtype)

        tl.store(Q + offs_q1, Q1*cos1 - Q2*sin1, mask = mask)
        tl.store(Q + offs_q2, Q2*cos1 + Q1*sin1, mask = mask)

四、总结

参考Triton的官方示例、以及其他社区的开源加速工具,我们还能看到其他许多算子,诸如rmsNorm, Softmax, Flash-Attention 等的具体加速方案和并行思路,以及他们的反向传导过程。而这些算子则组成了Transformer 的注意力模块的整体结构。通过对各个模块的并行优化,可以实现对注意力计算的推理和训练加速。

总结1.jpegTransformer 的注意力结构及相关算子

在实际应用中,我们可以自己编写和优化自定义算子,也可以引用社区优秀的开源加速算子库,而且Pytorch在2.0+版本后已将Triton集成到其编译器,用torch.compile()可以直接对已加载模型编译,帮助自动优化可优化的算子。

参考:

Triton-lang 介绍 (openai.com/index/trito…)

Triton-tutorial(triton-lang.org/main/gettin…)

Nvidia GA100 datasheet (images.nvidia.cn/aem-dam/en-…)

CUDA programming(docs.nvidia.com/cuda/cuda-c…)

文 / xujiong

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

RAG应用在得物开放平台的智能答疑的探索

一、背景

得物开放平台是一个把得物能力进行开放,同时提供给开发者提供 公告、应用控制台、权限包申请、业务文档等功能的平台。

  1. 面向商家:通过接入商家自研系统。可以实现自动化库存、订单、对账等管理。
  2. 面向ISV :接入得物开放平台,能为其产品提供更完善的全平台支持。
  3. 面向内部应用:提供安全、可控的、快速支持的跨主体通讯。

得物开放平台目前提供了一系列的文档以及工具去辅助开发者在实际调用API之前进行基础的引导和查询。

但目前的文档搜索功能仅可以按照接口路径,接口名称去搜索,至于涉及到实际开发中遇到的接口前置检查,部分字段描述不清等实际问题,且由于信息的离散性,用户想要获得一个问题的答案需要在多个页面来回检索,造成用户焦虑,进而增大TS的答疑可能性。

背景.jpg

随着这几年AI大模型的发展,针对离散信息进行聚合分析且精准回答的能力变成了可能。而RAG应用的出现,解决了基础问答类AI应用容易产生幻觉现象的问题,达到了可以解决实际应用内问题的目标。

二、简介

什么是RAG

RAG(检索增强生成)指Retrieval Augmented Generation。

这是一种通过从外部来源获取知识来提高生成性人工智能模型准确性和可靠性的技术。通过RAG,用户实际上可以与任何数据存储库进行对话,这种对话可视为“开卷考试”,即让大模型在回答问题之前先检索相关信息。

RAG应用的可落地场景

RAG应用的根本是依赖一份可靠的外部数据,根据提问检索并交给大模型回答,任何基于可靠外部数据的场景均是RAG的发力点。

RAG应用的主要组成部分

  • 外部知识库:问题对应的相关领域知识,该知识库的质量将直接影响最终回答的效果。
  • Embedding模型:用于将外部文档和用户的提问转换成Embedding向量。
  • 向量数据库:将外部信息转化为Embedding向量后进行存储。
  • 检索器:该组件负责从向量数据库中识别最相关的信息。检索器将用户问题转换为Embedding向量后执行相似性检索,以找到与用户查询相关的Top-K文档(最相似的K个文档)。
  • 生成器(大语言模型LLM):一旦检索到相关文档,生成器将用户查询和检索到的文档结合起来,生成连贯且相关的响应。
  • 提示词工程(Prompt Engineering):这项技术用于将用户的问题与检索到的上下文有效组合,形成大模型的输入。

RAG应用的核心流程

以下为一个标准RAG应用的基础流程:

  1. 将查询转换为向量
  2. 在文档集合中进行语义搜索
  3. 将检索到的文档传递给大语言模型生成答案
  4. 从生成的文本中提取最终答案

RAG应用.jpg

但在实际生产中,为了确保系统的全面性、准确性以及处理效率,还有许多因素需要加以考虑和处理。

下面我将基于答疑助手在开放平台的落地,具体介绍每个步骤的详细流程。

三、实现目标

鉴于目前得物开放平台的人工答疑数量相对较高,用户在开放平台查询未果就会直接进入到人工答疑阶段。正如上文所说,RAG擅长依赖一份可靠的知识库作出相应回答,构建一个基于开放平台文档知识库的RAG应用再合适不过,同时可以一定程度降低用户对于人工答疑的依赖性,做到问题前置解决。

实现目标.jpg

四、整体流程

整体流程.jpg

技术选型

准确性思考

问答的准确性会直接反馈到用户的使用体验,当一个问题的回答是不准确的,会导致用户根据不准确的信息进一步犯错,导致人工客服介入,耐心丧失直至投诉。

所以在实际构建基于开放平台文档的答疑助手之前,首先考虑到的是问答的准确性,主要包括以下2点:

  1. 首要解决答疑助手针对非开放平台提问的屏蔽
  2. 寻找可能导致答非所问的时机以及相应的解决方案

屏蔽非相关问题

为了屏蔽AI在回答时可能会回答一些非平台相关问题,我们首先要做的是让AI明确我们的目标(即问答上下文),且告诉他什么样的问题可以回答,什么问题不可以回答。

在这一点上,常用的手段为告知其什么是开放平台以及其负责的范畴。

例如:得物的开放平台是一个包含着 API 文档,解决方案文档的平台,商家可以通过这个平台获取到得物的各种接口,以及解决方案,帮助商家更好的使用得物的服务。现在需要做一个智能答疑助手,你是其中的一部分。

在这一段描述中,我们告知了答疑助手,开放平台包含着API文档,包含着解决方案,同时包含接口信息,同时会有商家等之类的字眼。大模型在收到这段上下文后,将会对其基础回答进行判断。

同时,我们可以通过让答疑助手二选一的方式进行回答,即平台相关问题与非平台相关问题。我们可以让大模型返回特定的数据枚举,且限定枚举范围,例如:开放平台通用问题、开放平台API答疑问题,未知问题。

借助Json类型的输出 + JSON Schema,我们可通过Prompt描述来限定其返回,从而在进入实际问答前做到事前屏蔽。

寻找可能导致答非所问的时机

当问题被收拢到开放平台这个主题之后,剩余的部分就是将用户提问与上下文进行结合,再交由大模型回答处理。在这过程中,可能存在的答非所问的时机有:不够明确的Prompt说明、上下文信息过于碎片化以及上下文信息的连接性不足三种。

  • 不够明确的Prompt说明:Prompt本身描述缺少限定条件,导致大模型回答轻易超出我们给予的要求,从而导致答非所问。
  • 上下文信息过于碎片化:上下文信息可能被分割成N多份,这个N值过大或者过小,都会导致单个信息过大导致缺乏联想性、单个信息过小导致回答时不够聚焦。
  • 上下文信息连接性不够:若信息之间被随意切割,且缺少相关元数据连接,交给大模型的上下文将会是丧失实际意义的文本片段,导致无法提取出有用信息,从而答非所问。

为了解决以上问题,在设计初期,开放平台答疑助手设定了以下策略来前置解决准确性问题:

  • 用户提问的结构化
  • 向量的分割界限以及元信息处理
  • CO-STAR Prompt结构
  • 相似性搜索的K值探索

用户提问结构化

目标:通过大模型将用户提问的结构化,将用户提问分类并提取出精确的内容,便于提前引导、终止以及提取相关信息。

结构化.jpg

例如,用户提问今天天气怎么样,结构化Runnable会将用户问题进行初次判断。

一个相对简单的Prompt实现如下:

# CONTEXT
得物的开放平台是一个包含着 API 文档,解决方案文档的平台,商家可以通过这个平台获取到得物的各种接口,以及解决方案,帮助商家更好的使用得物的服务。现在需要做一个智能答疑助手,你是其中的一部分。

# OBJECTIVE
你现在扮演一名客服。请将每个客户问题分类到固定的类别中。
你只接受有关开放平台接口的相关问答,不接受其余任何问题。
具体的类别我会在提供给你的JSON Schema中进行说明。

# STYLE

你需要把你的回答以特定的 JSON 格式返回

# TONE

你给我的内容里,只能包含特定 JSON 结构的数据,不可以返回给我任何额外的信息。

# AUDIENCE

你的回答是给机器看的,所以不需要考虑任何人类的感受。

# RESPONSE

你返回的数据结构必须符合我提供的 JSON Schema 规范,我给你的 Schema 将会使用\`<json-schema></json-schema>\`标签包裹.
每个字段的描述,都是你推算出该字段值的依据,请仔细阅读。

<json-schema>
  {schema}
</json-schema>

Json Schema的结构通过zod描述如下:

const zApiCallMeta = z
  .object({
    type: z
      .enum(['api_call''unknown', 'general'])
      .describe('当前问题的二级类目, api_call为API调用类问题,unknown为非开放平台相关问题, general为通用类开放平台问题'),
    apiName: z
      .string()
      .describe(
        '接口的名称。接口名称为中文,若用户未给出明确的API中文名称,不要随意推测,将当前字段置为空字符串',
      ),
    apiUrl: z.string().describe('接口的具体路径, 一般以/开头'),
    requestParam: z.unknown().default({}).describe('接口的请求参数'),
    response: z
      .object({})
      .or(z.null())
      .default({})
      .describe('接口的返回值,若未提供则返回null'),
    error: z
      .object({
        traceId: z.string(),
      })
      .optional()
      .describe('接口调用的错误信息,若接口调用失败,则提取traceId并返回'),
  })
  .describe('当二级类目为api_call时,使用这个数据结构');

以上结构,将会对用户的问题输入进行结构化解析。同时给出相应JSON数据结构。

将以上结构化信息结合,可实现一个基于LangChain.js的结构化Runnable,在代码结构设计上,所有的Runnable将会使用$作为变量前缀,用于区分Runnable与普通函数。

import { ChatOpenAI } from '@langchain/openai';
import { StringOutputParser } from '@langchain/core/output_parsers';
import { RunnableSequence, RunnableMap } from '@langchain/core/runnables';
import { $getPrompt } from './$prompt';
import { zSchema, StructuredInputType } from './schema';
import { n } from 'src/utils/llm/gen-runnable-name';
import { getLLMConfig } from 'src/utils/llm/get-llm-config';
import { getStringifiedJsonSchema } from 'src/utils/llm/get-stringified-json-schema';

const b = n('$structured-input');

const $getStructuredInput = () => {
  const $model = new ChatOpenAI(getLLMConfig().ChatOpenAIConfig).bind({
    response_format: {
      type: 'json_object',
    },
  });

  const $input = RunnableMap.from<{ question: string }>({
    schema: () => getStringifiedJsonSchema(zSchema),
    question: (input) => input.question,
  }).bind({ runName: b('map') });

  const $prompt = $getPrompt();
  const $parser = new StringOutputParser();

  return RunnableSequence.from<{ question: string }, string>([
    $input.bind({ runName: b('map') }),
    $prompt.bind({ runName: b('prompt') }),
    $model,
    $parser.bind({ runName: b('parser') }),
  ]).bind({
    runName: b('chain'),
  });
};

export { $getStructuredInput, type StructuredInputType };

鉴于CO-STAR以及JSONSchema的提供的解析稳定性,此Runnable甚至具备了可单测的能力。

import dotenv from 'dotenv';
dotenv.config();
import { describe, expect, it } from 'vitest';
import { zSchema } from '../runnables/$structured-input/schema';
import { $getStructuredInput } from '../runnables/$structured-input';

const call = async (question: string) => {
  return zSchema.safeParse(
    JSON.parse(await $getStructuredInput().invoke({ question })),
  );
};

describe('The LLM should accept user input as string, and output as structured data', () => {
  it('should return correct type', { timeout: 10 * 10000 }, async () => {
    const r1 = await call('今天天气怎么样');
    expect(r1.data?.type).toBe('unknown');
    const r2 = await call('1 + 1');
    expect(r2.data?.type).toBe('unknown');
    const r3 = await call('trace: 1231231231231231313');
    expect(r3.data?.type).toBe('api_call');
    const r4 = await call('快递面单提示错误');
    expect(r4.data?.type).toBe('api_call');
    const r5 = await call('发货接口是哪个');
    expect(r5.data?.type).toBe('api_call');
    const r6 = await call('怎么发货');
    expect(r6.data?.type).toBe('general');
    const r7 = await call('获取商品详情');
    expect(r7.data?.type).toBe('api_call');
    const r8 = await call('dop/api/v1/invoice/cancel_pick_up');
    expect(r8.data?.type).toBe('api_call');
    const r9 = await call('开票处理');
    expect(r9.data?.type).toBe('api_call');
    const r10 = await call('权限包');
    expect(r10.data?.type).toBe('api_call');
  });

数据预处理与向量库的准备工作

RAG应用的知识库准备是实施过程中的关键环节,涉及多个步骤和技术。以下是知识库准备的主要过程:

  1. 知识库选择:【全面性与质量】数据源的信息准确性在RAG应用中最为重要,基于错误的信息将无法获得正确的回答。
  2. 知识库收集:【多类目数据】数据收集通常涉及从多个来源提取信息,包括不同的渠道,不同的格式等。如何确保数据最终可以形成统一的结构并被统一消费至关重要。
  3. 数据清理:【降低额外干扰】原始数据往往包含不相关的信息或重复内容。
  4. 知识库分割:【降低成本与噪音】将文档内容进行分块,以便更好地进行向量化处理。每个文本块应适当大小,并加以关联,以确保在检索时能够提供准确的信息,同时避免生成噪声。
  5. 向量化存储:【Embedding生成】使用Embedding模型将文本块转换为向量表示,这些向量随后被存储在向量数据库中,以支持快速检索。
  6. 检索接口构建:【提高信息准确性】构建检索模块,使其能够根据用户查询从向量数据库中检索相关文档。

知识库拆分

知识库文档的拆分颗粒度(Split Chunk Size) 是影响RAG应用准确性的重要指标:

  • 拆分颗粒度过大可能导致检索到的文本块包含大量不相关信息,从而降低检索的准确性。
  • 拆分颗粒度过小则可能导致必要的上下文信息丢失,使得生成的回答缺乏连贯性和深度。
  • 在实际应用中,需要不断进行实验以确定最佳分块大小。通常情况下,128字节大小的分块是一个合适的分割大小。
  • 同时还要考虑LLM的输入长度带来的成本问题。

下图为得物开放平台【开票取消预约上门取件】接口的接口文档:

接口文档.jpg开票取消预约上门取件接口信息

拆分逻辑分析(根据理论提供128字节大小)

在成功获取到对应文本数据后,我们需要在数据的预处理阶段,将文档根据分类进行切分。这一步将会将一份文档拆分为多份文档。

由上图中信息可见,一个文档的基础结构是由一级、二级标题进行分割分类的。一个基本的接口信息包括:基础信息、请求地址、公共参数、请求入参、请求出参、返回参数以及错误码信息组成。

拆分方式

拆分的实现一般有2种,一是根据固定的文档大小进行拆分(128字节)二是根据实际文档结构自己做原子化拆分。

直接根据文档大小拆分的优点当然是文档的拆分处理逻辑会直接且简单粗暴,缺点就是因为是完全根据字节数进行分割,一段完整的句子或者段落会被拆分成2半从而丢失语义(但可通过页码进行链接解决)。

根据文档做结构化拆分的优点是上下文结构容易连接,单个原子文档依旧具备语义化,检索时可以有效提取到信息,缺点是拆分逻辑复杂具备定制性,拆分逻辑难以与其他知识库复用,且多个文档之间缺乏一定的关联性(但可通过元信息关联解决)。

在得物开放平台的场景中,**因为文档数据大多以json为主(例如api表格中每个字段的名称、默认值、描述等),将这些json根据大小做暴力切分丢失了绝大部分的语义,难以让LLM理解。**所以,我们选择了第二种拆分方式。

拆分实现

在文档分割层面,Markdown作为一种LLM可识别且可承载文档元信息的文本格式,作为向量数据的基础元子单位最为合适。

拆分实现.jpg 基础的文档单元根据大标题进行文档分割,同时提供frontmatter作为多个向量之间连接的媒介。

正文层面,开放平台的API文档很适合使用Markdown Table来做内容承接,且Table对于大模型更便于理解。

根据以上这种结构,我们可得到以下拆分流程:

拆分流程.jpg

代码实现:

 const hbsTemplate = `
---
服务ID (serviceId): {{ service.id }}
接口ID (apiId): {{ apiId }}
接口名称 (apiName): {{ apiName }}
接口地址 (apiUrl): {{ apiUrl }}
页面地址 (pageUrl): {{ pageUrl }}
---

# {{ title }}

{{ paragraph }}
`;
export const processIntoEmbeddings = (data: CombinedApiDoc) => {
  const template = baseTemplate(data);

  const texts = [
    template(requestHeader(data)),
    template(requestUrl(data)),
    template(publicRequestParam(data)),
    template(requestParam(data)),
    template(responseParam(data)),
    template(errorCodes(data)),
    template(authPackage(data)),
  ].filter(Boolean) as string[][];

  return flattenDeep(texts).map((content) => {
    return new Document<MetaData>({
      // id: toString(data.apiId!),
      metadata: {
        serviceId: data.service.id,
        apiId: data.apiId!,
        apiName: data.apiName!,
        apiUrl: data.apiUrl!,
        pageUrl: data.pageUrl!,
      },
      pageContent: content!,
    });
  });
};

知识库导入

通过建立定时任务(DJOB),使用MILVUS sdk将以上拆分后的文档导入对应数据集中。

CO-STAR结构

在上文中的Prompt,使用了一种名为CO-STAR的结构化模板,该框架由新加坡政府科技局的数据科学与AI团队创立。CO-STAR框架是一种用于设计Prompt的结构化模板,旨在提高大型语言模型(LLM)响应的相关性和有效性,考虑了多种影响LLM输出的关键因素。

结构:

  • 上下文(Context): 提供与任务相关的背景信息,帮助LLM理解讨论的具体场景,确保其响应具有相关性。
  • 目标(Objective): 明确你希望LLM执行的具体任务。清晰的目标有助于模型聚焦于完成特定的请求,从而提高输出的准确性。
  • 风格(Style): 指定希望LLM采用的写作风格。这可以是某位名人的风格或特定职业专家的表达方式,甚至要求LLM不返回任何语气相关文字,确保输出符合要求。
  • 语气(Tone): 设定返回的情感或态度,例如正式、幽默或友善。这一部分确保模型输出在情感上与用户期望相符。
  • 受众(Audience): 确定响应的目标受众。根据受众的不同背景和知识水平调整LLM的输出,使其更加适合特定人群。
  • 响应(Response): 规定输出格式,以确保LLM生成符合后续使用需求的数据格式,如列表、JSON或专业报告等。这有助于在实际应用中更好地处理LLM的输出。

在上文结构化的实现中,演示了如何使用CO-STAR结构的Prompt,要求大模型“冰冷的”对用户提问进行的解析,当然CO-STAR也适用于直接面向用户的问答,例如:

## Context
我是一名正在寻找酒店信息的旅行者,计划在即将到来的假期前往某个城市。我希望了解关于酒店的设施、价格和预订流程等信息。

## Objective
请提供我所需的酒店信息,包括房间类型、价格范围、可用设施以及如何进行预订。

## Style
请以简洁明了的方式回答,确保信息易于理解。

## Tone
使用友好和热情的语气,给人一种欢迎的感觉。

## Audience
目标受众是普通旅行者,他们可能对酒店行业不太熟悉。

## Response
请以列表形式呈现每个酒店的信息,包括名称、地址、房间类型、价格和联系方式。每个酒店的信息应简短且直接,便于快速浏览。

相似性搜索

当我们使用了问题结构化Runnable后,非开放平台类问题将会提前终止,告知用户无法解答相关问题,其他有效回答将会进入相似性搜索环节。

相似性搜索基于数据之间的相似性度量,通过计算数据项之间的相似度来实现检索。在答疑助手的相似性实现是通过余弦相似度来进行相似性判断的。

我们将用户的提问,与向量数据库中数据进行余弦相似度匹配。取K为5获取最相似的五条记录。

注意:此K值是经过一系列的推断最终决定的,可根据实际情况调整。

import { Milvus } from '@langchain/community/vectorstores/milvus';
import { OpenAIEmbeddings } from '@langchain/openai';
import { RunnableSequence } from '@langchain/core/runnables';
import { getLLMConfig } from 'src/utils/llm/get-llm-config';

export const $getContext = async () => {
  const embeddings = new OpenAIEmbeddings(
    getLLMConfig().OpenAIEmbeddingsConfig,
  );

  const vectorStore = await Milvus.fromExistingCollection(embeddings, {
    collectionName: 'open_rag',
  });

  return RunnableSequence.from([
    (input) => {
      return input.question;
    },
    vectorStore.asRetriever(5),
  ]);
};

此Runnable会将搜索结果组成一大段可参考数据集,用于后续用户提问。

用户提问解答

用户提问的解答同样通过Runnable的方式来承接,通过用户提问、结构化数据、提取的相似性上下文进行结合,最终得到问题的解答。

我们先将上下文进行格式化整理:

import { RunnablePassthrough, RunnablePick } from '@langchain/core/runnables';
import { Document } from 'langchain/document';
import { PromptTemplate } from '@langchain/core/prompts';
import { MetaData } from 'src/types';

const $formatRetrieverOutput = async (documents: Document<MetaData>[]) => {
  const strings = documents.map(async (o) => {
    const a = await PromptTemplate.fromTemplate(`{pageContent}`).format({
      pageContent: o.pageContent,
    });

    return a;
  });

  const context = (await Promise.all(strings)).join('\n');

  return context;
};

export const $contextAssignRunnable = () => {
  return RunnablePassthrough.assign({
    context: new RunnablePick('context').pipe($formatRetrieverOutput),
  });
};

问答整体Prompt实现:

export const promptTemplateMarkdown = () => {
  return `
# CONTEXT

得物的开放平台是一个包含着 API 文档,解决方案文档的平台,商家可以通过这个平台获取到得物的各种接口,以及解决方案,帮助商家更好的使用得物的服务。
现在得物开放平台的人工答疑率相当高,原因可能是文档的信息藏的较深,我希望做一个人工智能答疑助手,通过分析开放平台的各种文档,来回答用户的问题,最终让用户不进入人工答疑阶段。
我们只讨论[开放平台接口]的相关问题,不要谈及其他内容。

# OBJECTIVE
你需要根据用户的输入,以及提供的得物开放平台的文档上下文,进行答疑。
你只接受有关[开放平台接口]的相关问答,不接受其余任何问题。

## 关于用户的输入:

1. 你会得到一份符合 JSONSchema 结构的结构化数据,这份数据我会使用\`<structured-input></structured-input>\`包裹。
   这份结构化数据是通过实际的用户提问进行了二次分析而得出的。结构化数据里也会包含用户的最初始的问题供你参考(最初始的问题会放在 question 字段里)

## 关于上下文

1.  我已经提前准备好了你需要参考的资料,作为你回答问题的上下文,上下文是由许多篇 Markdown 文档组成的。这些 Markdown 的文档大标题代表了这个片段的模块名,例如 \`# 接口入参\`就代表这部分是文档的接口入参部分, \`# 接口返回\`就代表这部分是文档的接口返回部分,
2.  上下文中的主要信息部分我会使用 Markdown Table 的结构提供给你。
3.  每个上下文的开头,我都会给你一些关于这份上下文的元信息(使用 FrontMatter 结构),这个元信息代表了这份文档的基础信息,例如文档的页面地址,接口的名称等等。

以下是我提供的结构化输入,我会使用\`<structured-input></structured-input>\`标签做包裹
<structured-input>
{structuredInput}
</structured-input>

以下是我为你提供的参考资料,我会使用\`<context></context>\`标签包裹起来:
<context>
{context}
</context>

# STYLE

你需要把你的回答以特定的 JSON 格式返回

# TONE

你是一个人工智能答疑助手,你的回答需要温柔甜美,但又不失严谨。对用户充满了敬畏之心,服务态度要好。在你回答问题之前,需要简单介绍一下自己,例如“您好,很高兴为您服务。已经收到您的问题。”

# AUDIENCE

你的用户是得物开放平台的开发者们,他们是你要服务的对象。

# RESPONSE

你返回的数据结构必须符合我提供的 JSON Schema 规范,我给你的 Schema 将会使用\`<structured-output-schema></structured-output-schema>\`标签包裹.

<structured-output-schema>
  {strcuturedOutputSchema}
</structured-output-schema>
`;
};

以上问答通过CO-STAR结构,从6个方面完全限定了答疑助手的回答腔调以及问答范畴,我们现在只需要准备相应的数据结构提供给这份Prompt模板。

问答结果结构化

在开放平台答疑助手的场景下,我们不仅要正面回答用户的问题,同时还需要给出相应的可阅读链接。结构如下:

import { z } from 'zod';

const zOutputSchema = z
  .object({
    question: z
      .string()
      .describe(
        '提炼后的用户提问。此处的问题指的是除去用户提供的接口信息外的问题。尽量多的引用用户的提问',
      ),
    introduction: z
      .string()
      .describe('开放平台智能答疑助手对用户的问候以及自我介绍'),
    answer: z
      .array(z.string())
      .describe(
        '开放平台智能答疑助手的回答,需将问题按步骤拆分,形成数组结构,回答拆分尽量步骤越少越好。如果回答的问题涉及到具体的页面地址引用,则将页面地址放在relatedUrl字段里。不需要在answer里给出具体的页面地址',
      ),
    relatedUrl: z
      .array(z.string())
      .describe(
        '页面的链接地址,取自上下文的pageUrl字段,若涉及多个文档,则给出所有的pageUrl,若没有pageUrl,则不要返回',
      )
      .optional(),
  })
  .required({
    question: true,
    introduction: true,
    answer: true,
  });

type OpenRagOutputType = z.infer<typeof zOutputSchema>;

export { zOutputSchema, type OpenRagOutputType };

在我们之前的设计中,我们的每一份向量数据的头部,均带有相应的文档meta信息,通过这种向量设计,我们可以很容易的推算出可阅读链接。同时,我们在这份zod schema中提供了很详细的description,来限定机器人的回答可以有效的提取相应信息。

Runnable的结合

在用户提问解答这个Runnable中,我们需要结合Retriever, 上下文,用户提问,用户输出限定这几部分进行组合。

import { ChatOpenAI } from '@langchain/openai';
import { $getPrompt } from './prompt/index';
import { JsonOutputParser } from '@langchain/core/output_parsers';
import { RunnableSequence, RunnableMap } from '@langchain/core/runnables';
import { zOutputSchema } from './schema';
import { $getContext } from './retriever/index';
import { getLLMConfig } from 'src/utils/llm/get-llm-config';
import { getStringifiedJsonSchema } from 'src/utils/llm/get-stringified-json-schema';
import { n } from 'src/utils/llm/gen-runnable-name';

const b = n('$open-rag');

type OpenRagInput = {
  structuredInput: string;
  question: string;
};

const $getOpenRag = async () => {
  const $model = new ChatOpenAI(getLLMConfig().ChatOpenAIConfig).bind({
    response_format: {
      type: 'json_object',
    },
  });

  const chain = RunnableSequence.from([
    RunnableMap.from<OpenRagInput>({
      // 问答上下文
      context: await $getContext(),
      // 结构化输入
      structuredInput: (input) => input.structuredInput,
      // 用户提问
      question: (input) => input.question,
      // 输出结构
      strcuturedOutputSchema: () => getStringifiedJsonSchema(zOutputSchema),
    }).bind({ runName: b('runnable-map') }),
    $getPrompt().bind({ runName: b('prompt') }),
    $model,
    new JsonOutputParser(),
  ]).bind({ runName: b('chain') });

  return chain;
};

export { $getOpenRag };

流程串联

通过上文的几大部分,我们已经将 用户提问、结构化解析、相似性搜索、问答上下文,问答Prompt模板以及结构化输出这几部分实现完成。

现在可以通过LangChain的Runnable能力进行流程串联。

const structure = safeJsonParse<StructuredInputType>(
  await $getStructuredInput().invoke({ question }),
  null,
)
const mainChain = RunnableSequence.from<{ question: string }, OpenRagOutputType>([
  RunnablePassthrough.assign({
    structuredInput: () => structure,
  }),
  await $getOpenRag(),
]);
const response = await mainChain.invoke({
  question: question,
});

串联流程如下:

串联流程.jpg

返回结果示例:

输入:

{
  "question": "正式环境调用:直发订单获取打印面单v2【品牌直发/众筹直发/拍卖直发】接口返回报错(https://openapi.dewu.com/dop/api/v2/order/brand_deliver/express_sheet)。请求参数:{\"order_no\":\"110152014363632294\",\"app_key\":\"953a43bec40943c98a5cdbc63333242f;\",\"sign\":\"AD968ED6A359D144B61F1FEF6BB380ED\",\"timestamp\":1720430064169}"
}

输出:

{
  "question": "我的问题是:正式环境调用:直发订单获取打印面单v2【品牌直发/众筹直发/拍卖直发】接口返回报错。",
  "introduction": "您好,很高兴为您服务。已经收到您的问题。",
  "answer": [
    "根据您提供的请求参数信息,出现错误可能是由于接口调用参数不正确导致的,接下来我们一步步排查问题。",
    "首先,您提供的请求参数中的订单号为\"110152014363632294\",请确认该订单号的状态是否为待物流揽收或待买家收货状态。",
    "其次,检查您所使用的app_key是否正确,确保该app_key授权了调用该接口的权限。",
    "再次,请确认传入的签名(sign)是否正确,可能需要重新生成签名以保证准确性。",
    "最后,检查时间戳(timestamp)是否正确,并且处于合理的时间范围内。",
    "如果以上步骤都没有解决问题,建议您查看详细的接口文档以进一步调试和解决问题。"
  ],
  "relatedUrl": [
    "https://open.dewu.com/#/api?apiId=1174"
  ]
}

五、应用调试

基于大模型应用可能设计到多个Runnable的多次调用,借用LangSmith的trace功能,我们可以对每一个Runnable进行出入参的debug。

关于LangSmith的接入:

Langsmith.jpg

六、未来展望

未来展望.gif RAG在减少模型幻觉,无需模型训练就可享受内容时效性的特点在此类答疑应用中展露无遗,RAG应用开放平台落地从一定程度上验证了依赖可靠知识库的答疑场景具备可执行性,还为内部系统的应用提供了有力的参考。在实际应用中,除了直接解决用户的提问外,通过回放用户提问的过程,可以为产品和业务的发展提供重要的洞察。

面向未来,是否可以尝试将答疑助手的形式在内部系统落地,在内部建立知识库体系,将部分问题前置给大模型处理,降低TS和开发介入答疑的成本。

文 / 惑普

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

得物基于AIGC生成测试用例的探索与实践

一、背景

随着人工智能技术的快速发展,尤其是在自然语言处理(NLP)、计算机视觉和生成对抗网络(GANs)等领域,AIGC(AI Generated Content)得到了广泛应用,这一技术的进步使得内容创作变得更加高效与多样化,推动了各个行业的创新与变革。对于测试而言,基于AI进行测试用例生成也逐渐从梦想变成现实。

传统问题

目前我们在编写测试用例时,大部分依赖人工编写,在实际编写过程中主要存在以下问题:

  1. 用例编写量大:传统的测试用例编写方法通常会耗费测试人员大量的时间和精力,编写效率不高;
  2. 编写颗粒度粗:由于时间有限,手工编写测试用例可能存在部分测试场景的遗漏,如边界场景、异常场景等;
  3. 维护成本高:不同测试同学编写习惯不同,导致部分用例的可读性较差,增加后期维护成本。

因此,借助AI技术自动生成初步的测试用例,随后由测试人员进行审核和优化,可以显著缩短用例的准备时间,提高测试工作的效率。

目标

利用AI技术自动生成测试用例,缩短编写测试用例的时间;

通过AI辅助生成测试用例,提升测试用例的覆盖范围和可读性。

二、方案

技术实现

技术实现.jpg

“RAG:指的是检索增强生成(Retrieval-Augmented Generation),这是一种结合了信息检索和文本生成的技术,通过检索相关信息来增强生成模型的能力,提高生成文本的相关性和准确性。”

“LLM:指的是“大型语言模型”(Large Language Model),这些模型是基于深度学习技术构建的,专注于自然语言处理(NLP),能够处理和生成自然语言文本数据。”

核心功能介绍

核心功能介绍.jpg

整个AI生成测试用例的功能主要分为三个方面:

  1. 用户输入:提供AI对话框,可供用户从需求PRD中复制粘贴功能点,实现快速输入;
  2. 测试点分析整合:提供智能提取测试点和专家经验介入的能力,用户可以对AI生成的测试点进行灵活调整;
  3. 用例生成:基于调整好的测试点自动生成对应的测试用例,并可一键同步至平台,方便后续的管理和使用。

使用流程

使用流程.jpg

需求输入

1.选择相关需求的用例模块,点击“AI生成用例”按钮跳转至AI生成用例交互页面:

需求输入.jpg

2.从需求PRD复制功能点,粘贴到输入框并发送:

粘贴框发送.jpg

完善测试点

1.评估AI提取出的测试点,选择可用的测试点并采纳到左侧的测试点列表中;

2.手动增/删/改左侧测试点列表中的测试点:

测试点.jpg

生成用例

1.点击生成测试用例按钮,等待测试用例生成;

2.测试用例生成成功后,可直接对生成的用例进行增/删/改,点击保存用例按钮将生成的用例保存;点击同步平台按钮将生成的用例同步至用例平台:

3.用例同步至平台采用的增量同步方式,不会将平台已有的用例覆盖。

生成用例.jpg

三、探索实践

实践策略

时间策略.jpg 我们制定了以上4种实践策略,分别在A业务域和B业务域进行功能的试用,探索AI辅助生成测试用例的落地方案,具体包括以下内容:

“A业务域主要面向公司的客户服务团队,包括一线客服、技术支持人员以及管理层等,提供了包括工单管理、实时聊天、知识库和客户反馈分析等多种功能,以提升客户支持的效率和服务质量,确保客户始终能够获得优质的服务体验。当前已上线的产品主要涉及Web端、PC端和APP端。

B业务域主要致力于运用产品、技术、数据等手段,全面提升公司的效率,该业务域的用户群体涵盖了公司各个部门的员工,规模量级庞大,涉及上万名员工的日常工作需求,目前已上线的产品主要集中在Web端,包括项目管理、内部协同和沟通、办公效率等多种功能。”

  1. 小范围试点:分别在A业务域和B业务域内开展小范围试点工作,评估AI生成测试用例的有效性和全面性,以满足不同业务域的实际业务需求;
  2. 持续推进:采用“以点带面”的策略,根据不同的业务场景和用户需求,分阶段推进AI生成测试用例的应用,逐步扩大应用范围,优化用户体验,确保AI工具能够适应多样化的业务需求;
  3. 迭代复盘:在试用过程中,定期进行迭代复盘,通过收集迭代数据和用户反馈并分析,探讨后续改进和优化的方向,并持续验证优化效果;
  4. 多维度指标量化:制定准确度、覆盖度、使用率等多个维度的评价指标,分析这些指标的变化趋势,全面衡量AI生成测试用例的潜在表现,确保其能够满足日常使用需求。

交互标准

需求分类和预处理

不同复杂度的需求其用例生成效果存在差异,根据需求的复杂程度,建立简单需求/复杂需求划分标准,对比不同需求的用例生成效果,优先选取生成效果较好的简单需求进行功能的使用。参考需求划分标准如下:

简单需求:研发资源<=7人日、测试资源<=1人日的需求

复杂需求:研发资源>=7人日、测试资源>=1人日的需求

其次,部分需求的PRD文档存在功能点描述简单、含糊不清等情况,直接复制这些功能点进行AI用例生成,用例生成的准确性和全面性都较差。因此,可以先对这种情况的输入进行预处理,列举出具体的功能点和预期结果,再输入到AI进行测试用例生成,提升用例生成的效果,具体示例如下:

需求分类和预处理.jpg

持续分批对话

AI生成用例时,可以分点输入功能点,以生成更多、更详细的测试点,包括一些边界、异常场景等,提高采纳率和覆盖率。具体的对比效果如下:

  • 全部功能点输入:

全部功能点输入.jpg

  • 功能点分点输入:

功能点分点输入.jpg

专家经验输入

  • **输入命令自动调整测试点:**目前平台支持用户输入命令并结合上文信息对生成的测试点进行调整,在初始生成的测试点基础上,输入一些简单的命令,例如“帮我拓展一下测试点X”、“合并测试点XX”等,优化AI生成测试点的生成效果。

自动调整.jpg

自动调整2.jpg

  • **手动调整测试点:**目前AI生成的测试点无法做到完全覆盖所有的功能点,可能存在生成的测试点不全或测试点描述不准确的情况,可以在AI生成的测试点基础上,人工介入补充遗漏的测试点以及修改描述不准确的测试点,提升AI生成用例的效果。

手动调整.jpg

手动调整2.jpg

关键项推进

量化生成效果

问题描述:AI生成的评价指标只有采纳率,难以全面评估AI生成测试用例的具体效果。

解决方案:在已有采纳率的基础上,新增覆盖率、需求使用率两个评价指标,分别刻画AI生成用例的实际覆盖程度以及各域AI生成用例功能的具体使用情况,其具体计算公式如下:

  • 需求使用率 = 使用AI生成用例的需求 / 子域总需求 * 100%(研发自测需求除外)
  • 采纳率 = 评估后采纳用例 / AI生成用例 * 100%
  • 覆盖率 = 评估后采纳用例 / 人工调整扩充后的总用例 * 100%

量化生成.jpg 后续改进:目前仍然无法衡量具体的提效效果,后续会配合平台探究更多的评价数据和指标,例如单需求的用例生成时间、用例编写理论提效时间等。

降低生成时长

问题描述:测试点较多时,AI生成测试用例时间太长,有时界面卡死,一直显示正在生成中。

降低生成时长.jpg

解决方案:更换新的AI,切换为GPT-4o-mini,优化了AI生成用例的时间,一般生成时间不超过1min;同时解决了因平台刷新机制导致的界面卡死无法实时刷新的问题。

提高生成精度

问题描述:AI生成的测试用例准确度较低,且存在较多重复用例。

解决方案:引入RAG技术,将业务域的历史存量用例作为AI的背景知识库信息,检索生成更准确的测试用例。同一需求接入RAG前后的生成效果对比如下:

  • 需求1:

接入RAG前:AI总结测试点5条,AI生成11条用例,采纳8条用例,采纳率:72%;

接入RAG后:AI总结测试点6条,AI生成14条用例,采纳12条用例,采纳率:85%;

接入RAG前:

需求一前.jpg接入RAG后:

需求一后.jpg

  • 需求2:

接入RAG前:AI总结测试点7条,AI生成23条用例,采纳12条用例,采纳率:52%;

接入RAG后:AI总结测试点8条,AI生成17条用例,采纳16条用例,采纳率:94%;

接入RAG前:

需求2前.jpg 接入RAG后:

需求2后.jpg

优化交互体验

问题描述:目前用例平台所使用的AI自由prompt的能力太差,无法联系上文信息持续进行命令提示,优化所生成的测试点。

解决方案:平台对功能进行优化,修复了AI丢失上下文关联的缺陷,支持自由prompt,AI能够根据用户输入命令结合上文信息对生成的测试点进行调整:

初始输入

初始输入.jpg输入命令,结合上文信息拓展测试点:

拓展测试点.jpg

四、实践结论

在上述行动标准的实施和关键项推进的同时,我们对A业务域和B业务域持续多个迭代的AI生成测试用例数据进行了梳理整合,评估AI生成用例的目标达成情况 。

数据对比

迭代维度

  • A业务域与B业务域1-6迭代的需求使用率、采纳率、覆盖率统计如下:

迭代维度1.jpg

  • A、B业务域各迭代需求使用率、采纳率、覆盖率的变化趋势:

迭代维度2.jpg

迭代维度3.jpg

迭代维度4.jpg

需求维度

  • 简单需求和复杂需求AI生成测试用例的采纳率、覆盖率,以及总体的使用人数、使用需求数的数据统计如下:

需求维度1.jpg

需求维度2.jpg

结论

通过分析对比需求维度和迭代维度的数据图表,可以得出以下几点结论:

  1. 效率提升明显:目前对简单需求使用AI生成用例的平均采纳率和覆盖率维持在较高水平,能够覆盖核心功能点和场景,测试同学只需要补充一些异常/非功能分析的用例即可,基本可以节约40%的用例编写时间;
  2. 生成准确率高:AI生成测试用例的采纳率提升尤为明显,最近迭代两域的采纳率在90%以上,这表明AI生成的功能用例绝大部分都是可用且有效的;
  3. 生成全面性不足:各迭代的平均覆盖率还有较大提升空间,AI生成的测试用例难以覆盖全部业务场景,仍然需要测试人员手动补充覆盖;
  4. 复杂需求生成效果差:A业务域在试用初期引入了一些较为复杂的需求进行实验,导致采纳率有所下降,从侧面反映出目前AI在复杂需求上的用例生成效果还有待提升。

五、总结&规划

总结

综上所述,本文的探索成功实现了得物基于AIGC在质量保障方面的创新应用,通过这种AI生成测试用例的方式,我们能够显著降低人工编写用例的时间和成本,提升测试用例的准确性和规范性。后续我们会不断优化AI生成测试用例的功能,确保其能够生成更准确的用例,覆盖更广的测试场景,在未来的测试工作中发挥更大的价值。

未来规划

  1. AI生成准确性和全面性提升:针对数据对比结论所反映出的复杂需求采纳率和覆盖率偏低的问题,后续可以从以下两个方向进行优化:
  • 接入不同的AI对比用例生成效果,供用户自由切换选择;
  • 增加输入途径,充分结合技术文档、需求评审、技术评审等渠道,提升用例生成的准确性和全面性。
  1. 研发自测需求功能推广:研发同学可以使用AI快速生成可靠的测试用例,减少自测的时间和人力成本,让研发能够更专注于业务逻辑的开发,同时提升自测的有效性和全面性;
  2. 历史存量用例相似度匹配检索:分析历史用例,计算相似度并进行匹配,以便生成新测试用例时推荐出最相关的存量用例,同时提供可视化界面,帮助测试人员直观地查看和选择存量相似用例,以提高用例的复用率;
  3. 支持多模态数据输入:支持从不同类型的数据源(如文本、图像、视频等)获取需求信息,增强AI生成测试用例的效果,生成与视觉交互相关的测试用例;
  4. 记录用户操作持续反哺大模型:建立用户行为分析机制,记录用户对AI生成用例的增删改操作,分析用户的实际需求和偏好,利用用户的反馈数据来不断训练和优化模型,生成更贴合用户编写习惯的用例,提升生成质量。

文 / 南瓜&齐

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~ 未经得物技术许可严禁转载,否则依法追究法律责任。

盘点这些年搭建器在用户体验优化的实践|得物技术

一、背景

得物App中嵌入了大量的前端Web页面用以承接各种灵活多变的业务场景和玩法,但因为众所周知的原因,Web应用的用户体验是很难与原生应用相比的。然而,随着搭建器功能的不断完善,支持的业务场景和组件也越来越多,越来越多的团队和部门优选使用搭建器搭建会场页面投放于得物App当中,这对搭建器的整体用户体验提出了更高的要求。

从我开始接触搭建器后,看到了很多搭建器项目为了用户体验优化所做的一些努力与优秀的解决方案,这些方案在各自的应用场景当中发挥了极其重要的作用。因此,抽时间以前端开发人员的视角梳理了现有的一些优秀方案,一则作为知识沉淀留档,方便之后查阅,二则也可以给后来者一些参考与借鉴。

二、用户体验指标

谈到用户体验,肯定首先要做的就是梳理衡量/验收指标以及当前瓶颈,这样才能做到有的放矢,针对高优的体验瓶颈进行针对性的优化,以最小的成本换取最大的收获。

体验指标统计

说到体验指标,或许每个公司都有不同的定义与口径,但无论如何变化,始终离不开以下几点核心要素:

  • 用户可以看见有意义内容的时间(FMP)
  • 核心信息展示时间(LCP)
  • 页面的抖动频率与幅度(CLS)
  • 用户交互流畅度(TTI)

结合上述核心要素,在得物中落地时被转化为以下指标:

秒开率

秒开率是衡量H5打开速度的重要指标。在业界,普遍会使用FMP(全称 “First Meaningful Paint”,翻译为“首次有效绘制”)表示页面的“主要内容”开始出现在屏幕上的时间点, 秒开率基本等同于FMP。得物的秒开率计算方式为:count_if( webview启动时间 + FMP时间 < 1000) / count(*)

业界方案

秒开率的统计与上报,绕不开FMP指标的计算与统计,我们参考了业界的一些现有方案,并结合业务特点设计更贴合我们业务的FMP计算公式。

  • 第一篇《前端监控实践——FMP的智能获取算法》
  • 第二篇《定位性能指标FMP》

两个方案大致相同都是基于权重计算出关键dom。通过mutationobserver来监听变化,记录对应时间;然后在渲染结束后筛选出比较重要的dom, 再用这些dom拿到对应的耗时。

他们区别如下:

  • 第一篇筛选出一批dom算平均值,第二篇筛选出权重最大的值。
  • dom类型的权重也有细微区别
  • 具体类型资源的计算方式有细微区别

dom类型:svg、canvas、img、video、object、embed

const IGNORE_TAG_SET = ["SCRIPT", "STYLE", "META", "HEAD", "LINK"];
// 如果一个页面内有一个容器,容器内有多张图片,图片的重要应该高于容器, 这个值不宜设置过小,
// 否则会出现大多数场景body就是权重最大的元素,而不是里面的图片元素。
// 至少应该在 3*3,2*5 这样的布局中权重最大的元素为其中最后显示的图片,
// 由于存在空隙、文本等其他元素,大致为 40
const TAG_WEIGHT_MAP = {
  SVG: 60,
  IMG: 60,
  CANVAS: 60,
  
  OBJECT: 120,
  EMBED: 120,
  VIDEO: 120
};
// 普通节点权重:1
// 权重计算公示:width*height*weight,有背景图片的 div 等同于 img 标签

我们的方案

我们的方案大致和上文中提到的一致,部分细节做了一些适配和优化。

  • 文章中提到的计算资源的的方法有两种: 资源的计算方式:performance timing api dom变动的计算方式:diff + responseEnd

文章中提到的dom变动的计算方式有些问题,两个相加的方式会造成误差比较大。因此我们选择使用 performance.mark来计算,不过隐藏的问题是这个只是资源加载的时间,没有包含渲染的时间,数值会偏小。

由于cat-design(内部组件UI库)对图片有CDN裁剪优化,我们需要把图片处理成去掉这些参数后的形式,以免资源名称不一致。

  • 监听dom的停止条件 过期时间:超过10s dom变化的时间间隔:超过1s
  • 选取权重排名前三的元素,计算其中fmp的最大值,如果出现异常,使用fcp兜底。

我们的方案.jpg

抖动率

抖动率是衡量一个页面是否稳定的核心指标,如果打开页面后,页面上的模块一直频繁变换,用户体验无疑极差的。因此,我们也得关注页面的CLS指标,防止大范围频繁抖动。后续也会对项目中针对页面抖动的优化做详细的介绍。

抖动率.jpg

用户满意度调查

由于用户设备所处的环境千奇百怪,可能是设备兼容性问题,也可能是网络问题,纯粹通过数据的统计,总是可能出现一些疏漏,并且缺乏对用户实际体验的真实反馈。为了补足这一部分可能缺失的数据,我们在一些用户访问频繁的核心频道页面,如:天天领券、疯狂周末、随心省 等页面设置了用户体验调查问卷,让有反馈需求的用户可以在这边反馈他们所遇到的体验问题:

用户满意度调查.jpg 通过用户反馈的一些高频体验问题,我们会针对性地进行排查可能导致问题的原因。

例如:

  • 卡顿:可能因为页面js主线程存在耗时长任务导致页面操作卡顿。
  • 闪退:可能因为页面逻辑出现死循环或未正常退出的递归,导致系统爆栈,内存占满,部分设备在这种情况下会直接杀死有问题的进程(webview实例)以确保其他程序的正常运行。
  • 白屏:可能因为网络链路不通或延迟导致无法正常下载html文档,又或者是核心渲染逻辑因为一些前置js的逻辑报错或资源获取失败而没有正常执行。当然,我们发现,很多时候,用户反馈的白屏,其实并不是真正的白屏,而是展示了页面骨架,此时有可能是进行CSR(客户端渲染)时数据接口请求异常或逻辑处理异常。
  • 抖动:可能因为AB实验、风控拦截、逻辑隐藏/展示、人群定投常见下出现人群跃迁等原因,导致页面骨架跟实际用户展示的不一致,骨架缺少某些组件,但用户展示的时候需要展示,反之亦然。这样就会导致页面因组件数量的变化而发生剧烈的抖动,影响用户的体验。
  • 手机发热:可能因为死循环、密集计算等占用CPU资源过高,导致CPU发热严重
  • 图片出不来:可能因为访问图片资源的目标CDN节点故障,导致访问异常,也可能因为用户网络环境不佳,图片加载过慢,也可能是因为页面资源并行下载量过高,导致图片资源加载延迟等等。

当然,很多时候,出现这些问题,不一定是代码实现有问题,有可能确实是用户的设备老旧,渲染性能和运行内存较低或者是用户所处的网络环境不佳(如在电梯中)导致的一些体验问题。因此用户的这些体验调查,仅作为体验指标统计的补充,我们的优化依然还是主要围绕着体验指标数据进行,再辅以用户反馈高频问题的排查以达到最真实的用户体验优化效果。

三、体验优化

确定了体验指标和优化的方向之后,我们再来具体的看一下应该如何针对这些指标进行针对性的优化。

静态资源优化

在绝大部分性能体验优化中,静态资源的优化都是首当其冲的,因为这个优化的效果往往是最为直接的,并且优化起来也是比较容易的,没有太多的弯弯绕绕,只需要想办法「降体增速」即可。

文档类资源

文档类资源指的是html、js、css等文件,这类的文件通常生成之后都是固定的,我们通常可以利用以下方式进行优化:

  • 【降体】文件体积压缩
  • 【降体|增速】资源公私分离 (通常公共的文件因业务需求变化的概率较小,没变化时可以直接访问浏览器缓存中的资源,而私有业务资源则因业务需求变化改变的概率较大,因此将文件进行公私分离有利于更细粒度的利用浏览器缓存)
  • 【降体】gzip压缩
  • 【增速】浏览器的缓存策略
  • 【增速】CDN加速
  • 【增速】离线访问(App Cache、PWA等)

除了上述通用优化策略外,我们通常还需要对html文件进行进一步的优化,原因主要是:

  • Html文件是应用的入口,html中有足够多的有效信息能够降低用户访问白屏的时间,优化用户体验
  • 现代前端应用大多是SPA(单页应用),html中的有效信息极少
  • 很多页面的数据需要服务端接口返回数据后才能确定如何展示
  • 有些页面的数据针对不同人群展示不同

因此,如果我们想要最大限度的利用上html文件,那么就需要解决以下两个问题:

  1. 提升html当中有效信息的占比
  2. 提升html访问返回速度

我们针对上述两个问题逐个分析,逐个解决

  • 提升有效信息占比 我们想要提升页面中有效信息的占比,可以利用上SSR(服务端渲染)技术,在返回html信息前,现在node.js服务端访问接口,把首屏需要展示的信息获取回来进行首次预渲染,并获取首屏展示所需要的 html 文本并塞会返回的html文档当中,使用这种方式,就可以解决SPA(单页应用)html内容有效信息过少的问题。

当然,我们需要注意,尽可能只是获取与首屏展示相关的信息,非首屏展示相关的不要再服务端渲染,不然会导致html体积增大从而影响资源响应速度。

  • 提升返回速度

使用SSR之后,html的有效信息确实是得到提升了,但CDN加速对SSR并不友好,CDN更适合用于缓存加速一些静态资源,而针对SSR这种动态资源有点力不从心。但如果我们想要资源响应速度得到进一步的提升,CDN又是不可或缺的一环。

因此,我们需要更近一步,从SSR变为SSG,从服务端渲染到服务端生成,也就是说,我们在使用SSR拿到了首屏渲染的html字符串后,不再是直接返回给浏览器,而是将其导出成html文件,并上传至CDN,这样就能够充分利用CDN 的加速能力加速首屏html的获取了。

不过我们使用SSG+CDN虽然达到了提速的目的,但是有个场景的问题不容忽视:针对不同用户、人群有不同展示的个性化组件。由于CDN缓存是没有状态和身份的,因此,所有用户访问的内容都是一样的,此时我们就没办法针对不同的用户在首屏渲染时展示特异性的数据。

基于上述原因,我们决定对组件进行分类:

通用骨架屏:针对有实验、目标人群、逻辑动态显示/隐藏的组件,在SSG阶段时不再直接按照接口返回数据展示,而是展示一个通用的骨架屏,当到了用户设备浏览器中进行客户端渲染时(此时可以拿到用户身份),再对骨架进行数据填充完成渲染。

骨架屏.jpg

  • SSR首屏渲染:针对所有用户全量展示的组件,我们直接在SSG阶段就直接用服务端返回的数据渲染首屏页面结构,由于该组件跟用户身份无关,因此到了浏览器进行客户端渲染时,服务端返回的数据只会有极其细微的数据差异,只需将部分数据替换即可完成展示。

SSR首屏渲染.jpg

图片类资源

我们上面的用户满意度调查当中,有一项是“图片不出来”,而从收集上来的用户反馈来说,图片加载问题其实反馈还是挺频繁的。再加上我们大部分的组件都需要通过图片的方式为用户提供更加丰富的表达,因此,对于图片类资源的优化也是很有必要的。

图片类资源.jpg

图片类资源也属于静态资源,因此同样可以使用上面文档类资源使用的一些优化方案,如:CDN加速、缓存策略、图片压缩等。除此之外,我们还需要针对图片资源进行更细粒度的优化。

通常我们在开发时,为了确保图片在高清屏不会模糊,我们下载下来的图片一般都是多倍图(搭建器这边通常用的是3倍),但如果在一些非高清屏护着是屏幕分辨率较低的设备上,下载多倍图无疑是画蛇添足的,不仅没能达到更好的展示效果,还可能出现锯齿,同时使得资源下载时间变得更长,推迟了用户看到图片的时间。

我们期望的效果是:在浏览器请求图片资源时,需要根据当前设备的分辨率、DPI等屏幕信息,选择最优的图片尺寸和清晰度,从而减少在低端设备图片下载的体积,提升下载速度,又能确保在高清设备当中能够展示高清图。

因此,在搭建器当中,我们封装了一个自定义的Image组件,当传入的图片是符合预设域名要求时,我们将会给图片链接上加上如下请求参数:

搭建器.jpg

这个参数是CDN服务器为我们提供的将图片转换为webp格式的参数,当带有这个参数的图片请求到服务器后,服务器给我们返回的格式便是webp。

CDN.jpg

或许有同学会说,webp好像并不是所有设备都支持吧,那如果在不支持webp的设备,图片不是就展示不了了?

确实,因此我们的Image组件经过多轮改造以确保图片在不同设备中均能正常展示:

版本1:

<picture>
  <source srcset="https://h5static.dewucdn.com/node-common/bbdb0b2c-8549-b2cf-ceb8-62b98de2c983-1125-984.jpg?x-oss-process=image/format,webp/resize,w_750" type="image/webp" />
  <img src="https://h5static.dewucdn.com/node-common/bbdb0b2c-8549-b2cf-ceb8-62b98de2c983-1125-984.jpg" alt="" />
</picture>

我们使用picture去加载图片,如果支持webp的设备,就使用webp,不支持的话,就还是用兜底的原图。但这个方案在IOS设备上会同时加载webp和原图,造成不必要的流量损耗和占用浏览器并行下载数,后来被废弃。

版本2:

try {
  window._promiseimgWebpError = new Promise(function(resolve,reject){
      var img = new Image();
      img.src = 'data:image/webp;base64,UklGRkwAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAIAAAAAL1ZQOCAkAAAAUAEAnQEqAQABAAFAJiUAToAoAAD+8iJYwmknR5t5G30DAAAA'; // 替换为小webp的base64
      img.onerror = function() {
        resolve('小webp图片error')
      };
  })
} catch (e) {
  console.error(e);
}

// ...

window._promiseimgWebpError.then(() => {
  const { src, type, options } = this.props;
  this.setState({
    localStr: transformSrc(type, src, options, undefined, false),
  });
});

在这个版本中,我们尝试在浏览器中加载一个很小的webp 图片,如果加载失败,就说明当前设备不支持webp图片,我们就会使用兜底的原始图片。这种方式的检测,就不会出现在IOS设备同时加载两种格式图片的情况,又可以确保在支持webp的设备展示webp ,不支持的设备展示兜底图。

接口请求效率优化

静态资源优化后,会场页面的整体体验已经得到了极大的提升了,绝大部分情况下用户访问页面时,能够以最快的速度获取到html文档和图片资源。

但是,还是有一些情况会导致首屏页面加载体验下滑,经过分析,这些体验下滑的会场有以下特点:

  • 抖动频繁:页面存在众多组件交付接口的请求,这些请求响应的时间不一,在接口尚未返回时,有些组件处于骨架状态,返回后又隐藏了,如果多个组件都存在这种情况,就会导致页面频繁抖动

抖动频繁.jpg

  • 接口请求滞后:由于我们访问一个会场时需要等待文档下载、html解析、main.js执行、组件交付接口等流程,等待组件交付接口返回后,才能真正展示核心信息,这个延迟将近2s左右。

上述两个问题都出现在「组件交付接口」上:

  • 组件交付接口请求次数过多(通常与组件的数量是正相关的)
  • 组件交付接口请求时间滞后

因此,要解决这两个问题,搭建器这边提出了:「接口聚合」、「接口前置」的概念。

接口聚合

接口聚合主要是为了解决一个页面中存在多个依赖组件交付接口的组件时,需要发起多次组件交付接口造成的抖动以及网络资源的浪费问题。核心的实现思路就是:

接口聚合.jpg

接口前置

就如上文所说,浏览器请求组件交付接口需要等待:文档下载、html解析、main.js执行、组件交付接口等流程,出现了较长时间的滞后,如果我们可以把这个请求交付接口的阶段提前,放到文档下载之后,无疑是可以让用户能够更快的看到核心内容的。

接口前置.jpg

接口预请求

上面两个接口优化,都是在h5层面上的优化,始终还是得经历「webview启动 -> 下载html」这样的一个过程,如果html体积偏大,那么这期间也是会产生一定的耗时的。为了在一些特定场景能够跨越这一个看似无法逾越的天堑。h5团队联合native团队一起,设计了一套 「接口预请求」机制,期望将首屏数据请求进一步的提前,在native打开webview的同时就并行地发起请求。

接口预请求.jpg

有了这样的预请求机制,我们首屏页面所依赖的接口数据返回的时间又可以缩短很多,让我们这些页面的首屏渲染体验达到最佳。

上图中提到了一个“竞速”机制,即哪个返回比较快就用哪个,但后续数据验证客户端请求在99%的情况下是快于h5的请求的,并且接口竞速在会场会有去重问题,因此目前最新的方案是使用的是等待超时走h5请求的兜底逻辑。

页面体验优化

上面我们分别从资源和接口层面尝试优化了从用户请求到实际展示内容的链路,让用户能够尽早的看到核心内容。接下来我们再来看一下当页面到达了浏览器进行CSR(客户端渲染)后的用户体验优化。

SSR 占位

对于一些跟用户无关,所有用户都展示一样的组件,我们在进行SSG生成html文件时,实际已经获得了这些组件的核心数据了,那么此时用户一打开网页,看到的实际上就是我们之前已经获取好的这些数据展现的组件样式。这样一来,用户一进入页面,白屏的时间几乎可以忽略,差不多一进来就可以看到一些内容。只需要等CSR的时候接口返回的数据去更新一下一些差异即可,对用户来说前后的变化比较小,从感官上就像是一打开就看到了实际内容一样。

骨架屏填充

如果某些组件的展示严重依赖于用户身份的,像上面所说的, CDN中无法识别用户身份,此时我们只能展示一个通用的骨架,至少让用户知道有这么一个模块,并且防止CSR后展示了这个模块后出现较严重的页面抖动。等待CSR接口返回之后,我们再去替换这个骨架完成渲染。

组件展示动画

上面说的SSR占位和骨架屏填充还有一个比较严重的体验问题需要解决:

由于在得物 App中,很多组件都会设置AB实验或者是某些组件只是针对特定人群展示,如:新客。而在CDN中拿到的缓存页面,实际上是区分不了人群和用户身份的,就会导致在CDN缓存中的页面,不知道究竟是否应该展示这个组件,如果展示了,到了客户端发现当前用户不应该展示,就会像上述视频一样出现刚开始有个模块,CSR之后消失的情况。如果不展示,到了客户端返现当前用户应该展示时,又会导致凭空多出一个组件把下面的组件直接往下挤的抖动情况。

针对这种情况,我们针对这种根据用户信息判断是否要展示的组件,在服务端渲染时,都将组件的高度默认设置为0,等到了客户端渲染时,如果发现当前组件需要展示,那么再将这个组件的高度设置为auto ,而为了让高度变化时不会突然变化,让用户看起来特别奇怪,我们为这个组件的高度变化设置了渐变过渡,让其逐步展开。就这样,一个原本看起来是极为生硬,体验拉胯的页面,经过改造之后,就变成了好像是精心设计好的动画一样,毫无违和感。

流式渲染

经过上面几轮的优化之后,我们会场页面的用户体验可以说又上了一个台阶。当然,我们进行上述优化的过程中,也产生了一些副作用。我们先来看几张图:

流式渲染.jpgCSR渲染流程

mm.jpegSSR渲染流程

我们可以看到,从我们将CSR渲染首屏换成SSR渲染首屏后,TTFB 变得比以前更长了,即在用户访问页面到页面文档返回的时间变长了。

  • TTFB TTFB测量的是从用户或客户端发出HTTP请求到客户端的浏览器接收到页面的第一个字节的持续时间,由发送HTTP请求所花费的时间以及获取页面的第一个字节所花费的时间组成。TTFB用于衡量Web服务器或其他网络资源的响应能力。

原因是因为我们在 SSR 渲染阶段,需要获取页面全量组件的数据并将其渲染成 HTML,而每个组件的数据获取都需要一定的耗时,从而导致我们最终获取到HTML的时间拉长。当然,我们上面说的SSG + CDN的方案可以很大程度上缓解用户可感知的等待时间,但每次CDN回源时依然还是需要走SSR的流程,TTFB的变长终归对用户体验有一些影响。

恰巧最近比较火的「流式渲染」就能够解决上述痛点,因此,团队也尝试在流式渲染的方向上摸索前进,预计达到的效果:

TTFB.jpg

接入流式渲染的页面,TTFB将会得到很大的降低,用户能够感知的白屏时间也被最大限度的缩短,并且可以利用浏览器空闲时间,高效且并行的进行多组件异步加载,哪个组件先加载好久展示哪个,没有加载好之前,依然可以展示骨架屏兜底展示,防止页面抖动。

组件异常处理

目前搭建器组件有100多个,涉及到的业务领域包括但不限于营销、交易、增长等多个业务域的20余组件开发者,每个双周迭代都会有大量的组件业务迭代需求。面对这如此密集的业务迭代以及涉及众多业务域的影响范围,倘若组件没有进行较为完善的容错机制,其中的某一个组件因为某个版本的改动而出现异常,就极有可能导致该页面的其他组件也受到影响,最严重的可能导致整个页面白屏。

本着「敬畏线上,谨慎编码」的原则,需要一个比较完善的组件容错机制和告警机制,一来确保即使某个组件出现严重Bug时不影响页面其他组件的正常工作,二来我们可以第一时间感知组件出现的异常,及时排查,修复止损。

组件异常隐藏机制

在搭建器的组件渲染时,为每一个组件的渲染单独包裹了一个错误边界组件,这个组件将会捕获当前组件的异常和错误,防止该错误继续往上冒泡影响到页面其他组件。这样就可以将当前组件的错误影响范围始终都限制在组件范围内,而不会扩大影响其他组件。

而当我们捕获到异常时,我们会直接隐藏这个组件,这样就可以避免因出现异常而导致组件渲染混乱而影响用户的使用。

组件异常隐藏机制.jpg

组件异常上报机制

在上面捕获到异常之后,我们会将捕获到的组件异常上报到监控平台并告警,这样,一旦正式环境有某些组件因业务迭代改动导致异常时我们可以第一时间感知,并及时处理。

四、体验劣化管控

至此对于搭建器的用户体验优化已经告一段落了。但我们还需要想办法对后续的业务迭代的体验劣化进行管控。就算你这一次体验做得再好,经过几轮业务迭代之后,可能体验又大幅下滑了。

因此,我们期待通过一些手段来防止前端页面的体验劣化。

得益于现成的体验卡口平台:体验卡口平台

体验卡口.jpg

我们只需要基于这个平台进行一定的改造和功能新增,就可以对我们关注的体验指标进行细粒度检测,如:接口前置、图片转webp、接口响应时间等等。后续我们还会不断的丰富检测能力,支持流式检测、ssr检测等等,尽可能通过这个平台的检测与管控,防止前端页面体验下滑。后续也可能做成强卡形式,如果高优体验问题不解决,禁止上线,以此保障前端页面的交付质量。

交付质量.jpg

五、优化成果验收

经历了上面这些体验后,是否真的达到了我们的预期呢?我们是不是身处于自身描绘的理想环境当中,而真正的用户体验不增反降呢?这一切的一切,都需要用实际的数据说话。

秒开率

首先,从我们的核心体验指标“秒开率”看一下:

秒开率.jpg 从对秒开数据的统计来看,虽然每次版本迭代都有不同程度的上下波动,但整体趋势上还是稳步提升的,由此也可以看出,我们在用户体验上的优化,至少在秒开率上是得到了正向的反馈。

抖动率

抖动率.jpg 从抖动率的指标来看,进行优化后项目的稳定率整体长期保持在99.5%左右,由此可看出对于页面抖动相关的优化以及在开发时有意识地避免一些可能出现抖动的技术方案还是颇有成效的。

用户反馈

用户反馈.jpg 而收集上来的用户体验报告来说,正向反馈还是占了绝大多数的。由此可见,我们的优化成果,不仅仅是我们单方面的臆想,而是实实在在能让用户感受出来的体验提升。当然,其中仍有一小部分问题反馈,我们也会持续跟进,在业务迭代之余,逐步优化体验,力求为用户提供最佳的使用体验。

六、结语

至此就算梳理完了当前搭建器及其关联项目在用户体验优化上的一些实践了。这些实践大部分都是我加入团队之前,团队的其他同学就已经完成的。当然,我也参与了其中一部分功能的开发与优化。

总的来说,团队对于用户体验的优化是孜孜不倦的,力求给用户最好的体验,促使用户能够顺利在平台上“得到好物”。

文 / 星河

关注得物技术,每周、更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

Java程序中的潜在危机: 深入探讨NullPointerException|得物技术

一、前言

在Java语言的世界里,处理错误和异常是每位开发者必须面对的重要课题。其中,NullPointerException无疑是最常见且令人头痛的错误之一。它的出现往往让我们措手不及,同时大概率会导致程序行为异常。尽管从最早的版本这个异常就贯穿在我们的编码世界里,但它背后却隐藏着深刻的历史和设计哲学。

二、一则趣闻

在讨论今天的主题之前,让我们先介绍一位计算机科学界的杰出人物:Tony Hoare。他在业界享有极高的声誉,成就斐然,重要事迹和头衔足以让人顶礼膜拜:

  • 发明了广为人知的快速排序算法
  • 1980年荣获图灵奖
  • 被选为美国国家工程院外籍院士、英国皇家工程院院士、牛津大学名誉教授

然而,Tony Hoare被大多数人所熟知的,还是他与空引用的故事。

1965年,Tony Hoare在设计ALGOL W语言时,引入了空引用Null Reference这一概念。他认为,空引用可以方便地表示无值或未知值。其设计初衷是借助编译器的自动检测机制,确保所有引用的使用都是绝对安全的。此外,这种设计思路在实现上相对简单,大大减少了开发者的工作量。因此,受到Tony Hoare的影响,随后几十年中,许多编程语言,包括1991年诞生的Java(前身为Oak语言),也纷纷被这一设计思路所影响。

然而,随着时间的推移,Hoare对自己当年引入空引用的决策进行了深刻的反思。在2009年,他坦言:

“我将我之前发明的空引用的处理称为十亿美元的错误。1965年,我在为一种面向对象的语言(ALGOL W)设计第一个全面的引用类型系统时,目标是确保所有引用的使用都应该是绝对安全的,由编译器自动进行检查。但我无法抵挡引入空引用的诱惑,因为这实在是太容易实现了。这导致了无数错误、漏洞和系统崩溃,可能在过去四十年里造成了十亿美元的损失和痛苦。”

但从今天的软件系统发展来看,空引用对业界的影响远不止这一数字。它不仅改变了程序设计的方式,也引发了对异常处理、内存管理等众多领域的深入思考。

三、空引用检查

空引用识别

我们先来想一个问题:虚拟机是如何识别到空引用的呢?

  • JDK底层封装识别
  • 字节码层面识别
  • 机器码层面识别
  • 类型检查
  • 内存数据分析

在不考虑实现复杂度的情况下,我们很快可以列举出上述可能的识别方向,但Java虚拟机这边给出了一种意料之外的解决方案:不主动识别

这可能会让很多研发人大跌眼镜。大家可能会想,Java作为一门风靡全球的语言,应该有细致且周全的检查空引用的逻辑,但实际却和大家想的恰恰相反。

public static int getSize(List first, List second, List third, List fourth) {
    return first.size() + second.size() + third.size() + fourth.size();
}

上述代码累加了多个列表的大小,理论上每个列表对象都可能是个空值。如果按照我们预想的对于每个对象引用做空是否为空的检查,那么对于每个列表对象都会做一次检查,这次检查会至少涉及到一条机器码比较指令。这个成本对于当下的Java应用程序来说是巨大且不可接受的

所以权衡之后虚拟机的开发者们采用了一种类似于Try-Catch的解决方案,白话一点的意思就是:我们并不实时去检查是否可能有空的引用,因为绝大多数情况下空引用都是少数情况,但是如果真的发生了我们保证一定会处理(抛出NullPointerException)。

检查细节

下面代码是JDK8的虚拟机内部判别是否需要检查空引用的实现,调用链路依次如图中所示。入口处的注释This platform only uses signal-based null checks. The Label is not needed就已经告诉我们了足够多的信息,意思是在x86环境下,使用了基于signal的方式来完成了空的检查,至于什么是signal我们先按下不表。

进一步的由于offset使用默认值,needs_explicit_null_check函数(是否需要显式的进行空引用检查)会返回false。这会导致最终函数null_check里什么也不做,仅有一行注释nothing to do, (later) access of M[reg + offset] will provoke OS NULL exception if reg = NULL。这里的代码注释已经足够直白,告诉我们如果空引用的情况下,访问内存的时候会触发操作系统层面的异常。


 ==================== c1_MacroAssembler_x86.hpp ====================
// This platform only uses signal-based null checks. The Label is not needed.
void null_check(Register r, Label *Lnull = NULL) { MacroAssembler::null_check(r); }


==================== macroAssembler_x86.cpp ====================
void MacroAssembler::null_check(Register reg, int offset) {
  if (needs_explicit_null_check(offset)) {
    // provoke OS NULL exception if reg = NULL by
    // accessing M[reg] w/o changing any (non-CC) registers
    // NOTE: cmpl is plenty here to provoke a segv
    cmpptr(rax, Address(reg, 0));
    // Note: should probably use testl(rax, Address(reg, 0));
    //       may be shorter code (however, this version of
    //       testl needs to be implemented first)
  } else {
    // nothing to do, (later) access of M[reg + offset]
    // will provoke OS NULL exception if reg = NULL
  }
}

==================== assembler.cpp ====================
bool MacroAssembler::needs_explicit_null_check(intptr_t offset) {
  // Exception handler checks the nmethod's implicit null checks table
  // only when this method returns false.
#ifdef _LP64
  if (UseCompressedOops && Universe::narrow_oop_base() != NULL) {
    assert (Universe::heap() != NULL, "java heap should be initialized");
    // The first page after heap_base is unmapped and
    // the 'offset' is equal to [heap_base + offset] for
    // narrow oop implicit null checks.
    uintptr_t base = (uintptr_t)Universe::narrow_oop_base();
    if ((uintptr_t)offset >= base) {
      // Normalize offset for the next check.
      offset = (intptr_t)(pointer_delta((void*)offset, (void*)base, 1));
    }
  }
#endif
  return offset < 0 || os::vm_page_size() <= offset;
}

四、空引用操作系统处理

我们回过头再看上面代码中的注释:

nothing to do, (later) access of M[reg + offset] will provoke OS NULL exception if reg = NULL

它明确的告诉了我们触发的细节,也就是当真的碰到了空引用,此时的流程应该是这样:

  • 空引用时寄存器里的地址也为空
  • 基于寄存器内的空地址从内存读取会触发操作系统层面的Exception

那这个操作系统的层面到底是什么呢?

初见SIGSEGV

Linux下把信号分为了两大类:可靠信号与不可靠信号。不可靠信号有可能丢失、顺序问题等特点。其中我们日常遇见的信号基本都在不可靠信号这个区间内。这里列举一些场景的信号:

sigs.jpg

而尤以SIGSEGV这个信号尤为重要和常见。它意味着此时发生了无效的内存访问,而虚拟机对于NullPointerException的识别便是依靠着SIGSEGV才能完成。

SIGSEGV捕获

操作系统对于所有的信号都有其默认行为。对于大部分不可靠信号来说,它的默认行为都是终止当前进程,有些场景下会同时生成核心转储文件。这意味着如果进程收到SIGSEGV信号其实是一件非常严重的事情,但操作系统层面同时也考虑到了扩展性: 虽然默认行为是终止进程,但是如果开发者确认这是个正常行为,那么可以尝试拦截这样的情况别忽略。所以操作系统在这里提供了回调方法的注册,开发可以自行注册回调来识别正常行为的信号。

如下是OpenJDK9中虚拟机的代码,3个方法主要做了三件事情:

  • install_signal_handlers(): 虚拟机启动时注册信号,这里完成了SIGSEGV的捕获注册
  • set_signal_handler(): 设置回调函数为signalHandler
  • signalHandler(): 进一步调用抽象的JNI函数JVM_handle_linux_signal

这里需要说明的是函数JVM_handle_linux_signal,它定义在os_linux.cpp下,但由于Linux平台下还有更细的架构划分,如x86、aarch64、arm、ppc、s390、sparc等,在不同的架构下有不同的实现,所以这里要抽象出统一的函数模型。

==================== os_linux.cpp ====================
void os::Linux::install_signal_handlers() {
    ...
    set_signal_handler(SIGSEGV, true);
    set_signal_handler(SIGPIPE, true);
    set_signal_handler(SIGBUS, true);
    set_signal_handler(SIGILL, true);
    set_signal_handler(SIGFPE, true);
    ...
}

void os::Linux::set_signal_handler(int sig, bool set_installed) {
    ...  
    if (!set_installed) {
        sigAct.sa_flags = SA_SIGINFO|SA_RESTART;
    } else {
        sigAct.sa_sigaction = signalHandler;
        sigAct.sa_flags = SA_SIGINFO|SA_RESTART;
    }
    ...
}

void signalHandler(int sig, siginfo_t* info, void* uc) {
  assert(info != NULL && uc != NULL, "it must be old kernel");
  int orig_errno = errno;  // Preserve errno value over signal handler.
  JVM_handle_linux_signal(sig, info, uc, true);
  errno = orig_errno;
}

==================== os_linux_x86.cpp ==================== 
extern "C" JNIEXPORT int
JVM_handle_linux_signal(int sig,
                        siginfo_t* info,
                        void* ucVoid,
                        int abort_if_unrecognized) {
    ...                        
}

SIGSEGV捕获后的行为

由于我们当前生产环境多为x86架构,所以这里我们只用关注os_linux_x86.cpp下的实现即可。这里可以看到一下的细节:

  • NullPointerException下的SIGSEGV处理:设置拦截后的跳转代码,这里是SharedRuntime::continuation_for_implicit_exception,该函数负责抛出Java层面的NullPointerException
  • ucontext_set_pc: 重置PC寄存器,更改代码执行行为,直接执行continuation_for_implicit_exception,这样接下来就会抛出NullPointerException
  • VMError::report_and_die等同于信号的默认语义,直接终止进程。

到此,NullPointerException从产生到抛出的全过程我们都有了了解。如下方注释所说,当虚拟机收到操作系统回调时,如果发现是SIGSEGV信号且对应的内存offset为0,会主动返回并抛出NullPointerException,系统也并不会崩溃。

==================== os_linux_x86.cpp ==================== 

extern "C" JNIEXPORT int
JVM_handle_linux_signal(int sig,
                        siginfo_t* info,
                        void* ucVoid,
                        int abort_if_unrecognized) {     
    ......
   
    // 这里处理NullPointerException的情况
    if (sig == SIGSEGV &&
               !MacroAssembler::needs_explicit_null_check((intptr_t)info->si_addr)) {
          // Determination of interpreter/vtable stub/compiled code null exception
          stub = SharedRuntime::continuation_for_implicit_exception(thread, pc, SharedRuntime::IMPLICIT_NULL);
      }
    }   
  }
 
  ...

  // StackoverflowError和NullPointerException会主动返回并被记录, 系统不挂
  if (stub != NULL) {
    // save all thread context in case we need to restore it
    if (thread != NULL) thread->set_saved_exception_pc(pc);

    os::Linux::ucontext_set_pc(uc, stub);
    return true;
  }
 
  ...
  
  // 虚拟机不主动处理的信号到达这里会触发系统挂掉 
  VMError::report_and_die(t, sig, pc, info, ucVoid);

  ShouldNotReachHere();
  return true; // Mute compiler
}

五、使用信号量的隐含风险

频繁的空引用

JVM规范只是规定了当遇见空引用需要抛出空指针异常,但在具体实现的细节上,NullPointerException的监测和抛出多少有点超出了我们的想象,但从结果看它确实是符合JVM规范的行为。同时当前方案的好处也显而易见,它将本来需要显式的检查一个引用是否为空的代码转换为了隐式的检查(可以理解为和虚拟机核心逻辑处理流程解耦了),算是很精妙的设计。

那么到这里可能就有人会问了,如果我们代码写的很烂到处都是空引用呢?这样的话NullPointerException要通过发信号、信号处理、跳转到空指针检查的后续处理代码的路径,比起直接生成显式检查的路径要长得多也慢得多,岂不是得不偿失?实际上也确实是这样,但虚拟机的开发者就是在做一种假设:一个正常健康运行的系统就不应该会有这么多的空指针异常,如果真出现大量异常,开发者应该先去检查自身代码的健壮性。

信号量资源共享

在程序开发里一个非常重要的细节就是,你一定要管控好你的程序的作用域。如果在管控域之外的行为需要多加留意。回到这个问题本身,由于JVM采用了操作系统级别的信号量来同步NullPointerException信息,这在JVM本身内部并无问题,但由于JVM可以加载JNI代码,如果加载的第三方JNI中也捕获了SIGSEGV信号,这便会导致虚拟机自身的捕获失效,届时面对一个普通的NullPointerException都会导致系统崩溃。

下面是一个简单的例子,大家可以在Linux环境尝试:

// NPETest.java
import java.util.UUID;
public class NPETest {
    public static void main(String[] args) throws Exception {
        System.loadLibrary("NPETest");
        UUID.fromString(null);
    }
}

// NPETest.c
#include <signal.h>
#include <jni.h>

JNIEXPORT
jint JNICALL JNI_OnLoad(JavaVM *jvm, void *reserved) {
    signal(SIGSEGV, SIG_DFL);
    return JNI_VERSION_1_8;
}

我们可以将这个例子打包成一个shell脚本来执行:

gcc -Wall -shared -fPIC NPETest.c -o libNPETest.so -I$JAVA_HOME/include -I$JAVA_HOME/include/linux
javac NPETest.java
java -Xcomp -Djava.library.path=. -cp . NPETest

如上是一个简单的例子,当加载的JNI代码中存在手工捕获了SIGSEGV之后,面对NullPointerException虚拟机只能无奈以崩溃告终,并生成堆转储文件。

信号量资源共享1.jpg

如果我们将JNI中的信号量捕获代码signal(SIGSEGV, SIG_DFL);注释掉,即可看到正常的异常抛出:

信号量资源共享2.jpg

六、JDK的改进

Optional

Optional是JDK8引入的一个容器类,旨在提供一种更安全且清晰的方式来处理可能为空的值,从而减少 NullPointerException的发生。通过使用Optional,开发者可以明确地表示某个值可能缺失,这种设计促使开发者在代码中显式处理缺失值的情况,增强了代码的健壮性和可读性。Optional类提供了一系列便捷的方法,如**isPresent()**来检查值是否存在、**ifPresent()**以避免空值的直接处理、**orElse()用于提供默认值,以及map()flatMap()**方法以支持函数式编程风格的链式操作。这些特性不仅使代码更简洁,而且帮助开发者以更直观的方式处理空值,提高了代码的可维护性和可理解性。

需要指出的是,Optional最早是由Google Guava库开发的。这一设计旨在提供一种更安全的方式来处理可能为空的值,减少空指针异常的发生。2014年发布的JDK8 中引入的Optional类,实际上是基于Guava的设计思想进行了改进和扩展。JDK8的Optional不仅保持了Guava的核心理念,还增加了一些新的方法和特性,使得开发者能够以更简洁和直观的方式处理缺失值,从而提高代码的可读性和可维护性。

异常提示细化

随着时间的推移,越来越多的开发者对于NullPointerException提出了更高的要求:

  • 开发者在调试时花费大量时间寻找导致NullPointerException的原因(特别是链式调用的场景)
  • 随着编程语言的发展,许多现代语言已经提供了更好的空值处理和更有用的异常信息。但Java 作为一个成熟且广泛使用的语言,却没有跟上这种趋势

以下面代码为例,研发就较难在第一时间决策出到底是代码中的哪个返回是空才导致了NPE的发生:

System.out.println(earth.getAsian().getCountryList().size()); // NullPointerException

于是基于以上的诉求,Goetz Lindenmaier(在SAP负责JIT编译器技术相关工作,是SAP的IA64移植的作者之一)发起了提案JEP 358: Helpful NullPointerExceptions, 核心主旨在于:通过准确指明哪个变量为 null,增强JVM生成的NullPointerExceptions的可用性。

对应该提案的内容在JDK14上正式生效。从这个版本开始,如果产生了NullPointerException,JVM可以给出详细的信息告诉我们空对象到底是谁(需开启**-XX:+ShowCodeDetailsInExceptionMessages**)。

Exception in thread "main" java.lang.NullPointerException: Cannot invoke "org.example.Main$Earth.getAsian()" because "earth" is null
        at org.example.Main.main(Main.java:8)

七、结语

在深入了解虚拟机如何处理NullPointerException之后,我们可以发现,表面上看似简单的异常处理背后,实际上蕴藏着大量复杂的逻辑思考和设计上的平衡。这不仅涉及到如何有效捕获和报告错误,还包括在性能、内存管理和用户体验之间进行权衡。Java虚拟机在设计时需要考虑到多种因素,例如如何迅速反馈给开发者,同时又不影响程序的整体性能和稳定性。通过深入分析这一过程,我们能够更好地理解异常处理机制的内在原理,这不仅提升了我们的编程技能,也为我们在开发过程中处理类似问题提供了更深刻的视角和解决方案。希望本文能够为你提供一些有价值的见解与帮助,激发你的进一步探索和思考。

文 / 财神

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

基于RocksDB编写一个简单的SQL数据库|得物技术

一、前言

数据库DBMS是当前互联网开发者最熟悉的基础设施之一,很多后端开发者的入门项目就是一个简单的基于MySQL的数据管理系统。笔者一直想自己编写一个简单的SQL数据库,正好最近正在学习RocksDB和Zig语言的内容,就想利用这个机会作为学习成果的检验,于是就有了这个小项目。

话不多说,先来看看这个小项目成品的效果: 在这里插入图片描述

当然这个小小的数据库只支持创建表、插入和简单查询的能力,并不包含复杂SQL、索引、多租户、事务等高级功能,有兴趣的同学可以自行扩展。

二、什么是RocksDB

RocksDB是由Facebook开发的一款高效的嵌入式键值存储引擎,基于Google的LevelDB进行了多项优化。它主要用于快速存储和高并发读写场景,特别适合在闪存等快速存储介质上运行。

RocksDB是C++开发的,不过它提供了一套C语言API,为不会C++的开发者提供了便利。

三、什么是Zig语言

Zig语言是一种新兴的系统编程语言,由Andrew Kelley于2015年开始开发。其设计目标是改进C语言,并借鉴Rust等其他语言的优点。Zig强调强健性、最佳性和可维护性,并致力于提供高效的手动内存管理和编译时特性。

Zig语言对开发者来说最好的一点就是简单(特别相比Rust来说,Rust笔者已经学了好几次都没能入门),如果你熟悉Rust或者C语言,那么上手Zig只需要2~3天,就算完全没有C家族语言的经验,2周也足够学这门语言。

Zig语言有这些值得关注的特点:

  • 并没有提供类似Rust的生命周期管理,所以需要手动管理内存和C一样;
  • Zig支持编译时执行代码(comptime),允许开发者在编译期间进行类型操作和函数调用,从而提高性能和灵活性(Go语言开发者默泪);
  • 可以无缝衔接C语言的API,继承C生态的库非常简单;
  • 强大的编译工具ZigCC,ZigCC 是一个强大的工具,旨在简化 C/C++ 项目的编译过程,同时提供现代编程语言的优势。它不仅提高了编译效率,还通过增量编译和高效的二进制生成,帮助开发者更好地管理和维护他们的代码库。

四、项目结构

为了实现这个简单SQL数据库,我们可以按照如下的架构来对这个项目做功能分层:

在这里插入图片描述

接下来,我们就分模块详解介绍。

五、实现解析

RocksDB Layer

我们第一步先完成对RocksDB库的桥接,RocksDB虽然提供了C的API,但是并没有过多的文档说明,好在RocksDB提供了一些使用的例子,我们可以根据这些例子知道这些API的用法。

Zig集成C语言的三方库非常容易:

  • 首先,我们把RocksDB的API声明加入项目的include目录

在这里插入图片描述

  • 第二步,我们添加一个RocksDB.zig文件,调用header中的API,由于这个项目比较简单,所以我们只需要set/get/iteration这几个简单的API:
const std = @import("std");
const rdb = @cImport(@cInclude("rocksdb.h"));
pub const Iter = @import("iter.zig").Iter;

pub fn init(allocator: std.mem.Allocator, dir: []const u8) !Self {
    const options: ?*rdb.rocksdb_options_t = rdb.rocksdb_options_create();
    rdb.rocksdb_options_set_create_if_missing(options, 1);
    var err: ?[*:0]u8 = null;
    const db = rdb.rocksdb_open(options, dir.ptr, &err);
    const r = Self{
        .db = db.?,
        .allocator = allocator,
        .dir = dir,
    };
    if (err) |errStr| {
        std.log.err("Failed to open RocksDB: {s}.\n", .{errStr});
        return RocksdbErrors.RocksDBOpenError;
    }
    return r;
}

pub fn deinit(self: Self) void {
    rdb.rocksdb_close(self.db);
}

pub fn set(self: Self, key: []const u8, value: []const u8) !void {
    const writeOptions = rdb.rocksdb_writeoptions_create();
    var err: ?[*:0]u8 = null;
    rdb.rocksdb_put(
        self.db,
        writeOptions,
        key.ptr,
        key.len,
        value.ptr,
        value.len,
        &err,
    );
    if (err) |errStr| {
        std.log.err("Failed to write RocksDB: {s}.\n", .{errStr});
        return RocksdbErrors.RocksDBWriteError;
    }
}

pub fn get(self: Self, key: []const u8, buf: *std.ArrayList(u8)) !void {
    const readOptions = rdb.rocksdb_readoptions_create();
    var value_length: usize = 0;
    var err: ?[*:0]u8 = null;
    const v = rdb.rocksdb_get(
        self.db,
        readOptions,
        key.ptr,
        key.len,
        &value_length,
        &err,
    );
    if (v == null) {
        return;
    }

    if (err) |errStr| {
        std.log.err("Failed to read RocksDB: {s}.\n", .{errStr});
        return RocksdbErrors.RocksDBReadError;
    }
    for (0..value_length) |i| {
        try buf.append(v[i]);
    }
}

pub fn getIter(self: Self, prefix: []const u8) !Iter {
    return Iter.init(self.db, prefix);
}
  • 第三步,我们需要在build.zig中添加RocksDB三方库的link,关于Zigcc的具体细节可以参考这篇文章(ziglang.org/learn/build…
const rocksdb_unit_tests = b.addTest(.{
        .root_source_file = b.path("src/Rocksdb.zig"),
        .target = target,
        .optimize = optimize,
    });
    rocksdb_unit_tests.linkLibC();
    rocksdb_unit_tests.addIncludePath(LazyPath{ .cwd_relative = "./include" });
    rocksdb_unit_tests.linkSystemLibrary("rocksdb");
    rocksdb_unit_tests.addLibraryPath(LazyPath{ .cwd_relative = "/opt/homebrew/lib" });

    const run_rocksdb_unit_tests = b.addRunArtifact(rocksdb_unit_tests);

Lexer

在完成了RocksDB的桥接层后,我们可以着手编写SQL语句的词法分析器。顾名思义,词法分析就是将用户输入的SQL语句转换为一组Token的过程。

  • 第一步,我们需要总结一下SQL语句中总共有哪些分词:

在这里插入图片描述

分析上述的几个SQL语句,我们可以将Token分为如下分类:

pub const Kind = enum {
    unknown,
    // ----------- 保留关键字 ------------
    select_keyword, // select
    create_table_keyword, // create table
    insert_keyword, // insert into
    values_keyword, // values
    from_keyword, // from
    where_keyword,// where

    // ----------- 运算符关键字 -------------
    plus_operator,// +
    equal_operator,// =
    lt_operator,// <
    gt_operator, // >
    concat_operator, // ||

    // ---------- 符号关键字 -------------
    left_paren_syntax, // (
    right_paren_syntax, // )
    comma_syntax, // ,

    identifier, // 普通标识符
    integer, // 整型
    string, // 字符串

    pub fn toString(self: Kind) []const u8 {
        return @tagName(self);
    }
};

const BUILTINS = [_]Builtin{
    .{ .name = "CREATE TABLE", .Kind = Token.Kind.create_table_keyword },
    .{ .name = "INSERT INTO", .Kind = Token.Kind.insert_keyword },
    .{ .name = "SELECT", .Kind = Token.Kind.select_keyword },
    .{ .name = "VALUES", .Kind = Token.Kind.values_keyword },
    .{ .name = "WHERE", .Kind = Token.Kind.where_keyword },
    .{ .name = "FROM", .Kind = Token.Kind.from_keyword },
    .{ .name = "||", .Kind = Token.Kind.concat_operator },
    .{ .name = "=", .Kind = Token.Kind.equal_operator },
    .{ .name = "+", .Kind = Token.Kind.plus_operator },
    .{ .name = "<", .Kind = Token.Kind.lt_operator },
    .{ .name = ">", .Kind = Token.Kind.gt_operator },
    .{ .name = "(", .Kind = Token.Kind.left_paren_syntax },
    .{ .name = ")", .Kind = Token.Kind.right_paren_syntax },
    .{ .name = ",", .Kind = Token.Kind.comma_syntax },
};

  • 第二步,我们定义一下Token的数据结构:
start: u64,
end: u64,
kind: Kind,
source: []const u8,

pub fn init(start: u64, end: u64, kind: Kind, source: []const u8) Self {
    return Self{
        .start = start,
        .end = end,
        .kind = kind,
        .source = source,
    };
}

pub fn getKind(self: Self) Kind {
    return self.kind;
}

pub fn string(self: Self) []const u8 {
    return self.source[self.start..self.end];
}
  • 第三步,我们需要完成上述种类中三类关键字的解析:
fn nextKeyword(self: *LexIterator) ?Token {
    var longest_len: usize = 0;
    var kind = Token.Kind.unknown;
    for (BUILTINS) |builtin| {
        if (self.index + builtin.name.len > self.source.len) continue;
        // 大小写不敏感
        if (asciiCaseInsensitiveEqual(self.source[self.index .. self.index + builtin.name.len], builtin.name)) {
            longest_len = builtin.name.len;
            kind = builtin.Kind;
            break;
        }
    }
    // 由于我们关键字是按长度倒排序的,所以匹配到的一定是最长的keyword
    if (longest_len == 0) return null;
    defer self.index += longest_len;
    return Token.init(self.index, self.index + longest_len, kind, self.source);
}
  • 第四步,完成整型、字符串和普通标识符解析:
fn nextInteger(self: *LexIterator) ?Token {
    var end = self.index;
    var i = self.index;
    while (i < self.source.len and self.source[i] >= '0' and self.source[i] <= '9') {
        end += 1;
        i += 1;
    }
    if (self.index == end) return null;
    defer self.index = end;
    return Token.init(self.index, end, Token.Kind.integer, self.source);
}

fn nextString(self: *LexIterator) ?Token {
    var i = self.index;
    if (self.source[i] != '\'') return null;
    i += 1;

    const start = i;
    var end = i;
    while (i < self.source.len and self.source[i] != '\'') {
        end += 1;
        i += 1;
    }
    if (self.source[i] == '\'') i += 1;
    if (start == end) return null;
    defer self.index = i;
    return Token.init(start, end, Token.Kind.string, self.source);
}

fn nextIdentifier(self: *LexIterator) ?Token {
    var i = self.index;
    var end = self.index;
    while (i < self.source.len and ((self.source[i] >= 'a' and self.source[i] <= 'z') or (self.source[i] >= 'A' and self.source[i] <= 'Z') or self.source[i] == '*')) {
        i += 1;
        end += 1;
    }
    if (self.index == end) return null;

    defer self.index = end;
    return Token.init(self.index, end, Token.Kind.identifier, self.source);
}
  • 第五步,按照关键字>整型>字符串>普通标识符的优先级完成Lexer
pub fn hasNext(self: *LexIterator) bool {
    self.index = eatWhitespace(self.source, self.index);
    return self.index < self.source.len;
}

pub fn next(self: *LexIterator) !Token {
    // std.debug.print("index: {d}, len: {d}, src: {s}\n", .{ self.index, self.source.len, self.source[self.index..] });
    self.index = eatWhitespace(self.source, self.index);
    if (self.index >= self.source.len) return error.OutOfSource;
    if (self.nextKeyword()) |token| {
        return token;
    }
    if (self.nextInteger()) |token| {
        return token;
    }
    if (self.nextString()) |token| {
        return token;
    }
    if (self.nextIdentifier()) |token| {
        return token;
    }
    return error.BadToken;
}

AST

有了词法分析,我们就得到了SQL语句的一串Token,此时,我们需要语法分析将这些没什么具体意义的Token拼装成符合SQL语义的AST。

首先,我们需要分析中SQL语法中有哪些语法单元,我们在这里一一列举,逐个分析。

  • 基本表达式

例如select name, age from dewuer where age > 10这个语句中name、age、age > 10都是表达式,表达式要么是一段字面逻辑,要么是一段计算逻辑。

在这里插入图片描述

所以,我们可以编写出表达式的数据结构:

pub const ExpressionAST = union(enum) {
    literal: Token, // 字面逻辑
    binary_operation: BinaryOperationAST, // 计算逻辑

    pub fn deinit(self: ExpressionAST) void {
        switch (self) {
            .binary_operation => |m| {
                var bin = m;
                bin.deinit();
            },
            else => {},
        }
    }
};

pub const BinaryOperationAST = struct {
    operator: Token,
    allocator: std.mem.Allocator,
    left: *ExpressionAST,
    right: *ExpressionAST,

    pub fn init(
        allocator: std.mem.Allocator,
        operator: Token,
    ) !BinaryOperationAST {
        return .{
            .operator = operator,
            .allocator = allocator,
            .left = try allocator.create(ExpressionAST),
            .right = try allocator.create(ExpressionAST),
        };
    }
    pub fn deinit(self: BinaryOperationAST) void {
        self.left.deinit();
        self.allocator.destroy(self.left);
        self.right.deinit();
        self.allocator.destroy(self.right);
    }
};

  • Select语法

以一个Select的SQL语句为例:SELECT age, name FROM dewuer WHERE age > 10

不难分析出,一个Select语法由如下元素构成:

  1. 选择的columns;
  2. 数据表Table;
  3. 选择条件where(optional);

综上,我们给出Select语法树的数据结构:

pub const SelectAST = struct {
    columns: []ExpressionAST, // 选择字段
    from: Token, // 数据表
    where: ?ExpressionAST, // where条件 ?表示可以为null
    allocator: std.mem.Allocator,

    pub fn init(allocator: std.mem.Allocator) SelectAST {
        return .{
            .columns = undefined,
            .from = undefined,
            .where = null,
            .allocator = allocator,
        };
    }

    pub fn deinit(self: *SelectAST) void {
        for (self.columns) |m| {
            var col = m;
            col.deinit();
        }
        self.allocator.free(self.columns);
        if (self.where) |m| {
            var where = m;
            where.deinit();
        }
    }
};

  • Create Table语法

以一个Create Table的sql语句为例子:CREATE TABLE dewuer (year int, age int, name text)

不难分析出,一个Create Table语法由如下元素构成:

  1. 数据表Table;
  2. 表字段的(字段名,字段类型)数组;

由此,我们可以给出Create Table语法树的数据结构:

pub const CreateTableColumnAST = struct {
    name: Token, // 字段名
    kind: Token, // 字段类型,目前只支持string和int

    pub fn init() CreateTableColumnAST {
        return .{
            .name = undefined,
            .kind = undefined,
        };
    }
};

pub const CreateTableAST = struct {
    table: Token, // 数据表
    columns: []CreateTableColumnAST, // 表字段定义
    allocator: std.mem.Allocator,

    pub fn init(allocator: std.mem.Allocator) CreateTableAST {
        return .{
            .table = undefined,
            .columns = undefined,
            .allocator = allocator,
        };
    }

    pub fn deinit(self: *CreateTableAST) void {
        self.allocator.free(self.columns);
    }
};
  • Insert Into语法

以一个Insert的SQL语句为例子:INSERT INTO dewuer VALUES (2010, 35, 'Zhangsan')

这个语法最为简单,只有2个基本元素:

  • 数据表Table;
  • 插入赋值数组;

由此,我们可以给出Insert语法的数据结构:

pub const InsertAST = struct {
    table: Token, // 数据表
    values: []ExpressionAST, // 插入赋值
    allocator: std.mem.Allocator,

    pub fn init(allocator: std.mem.Allocator) InsertAST {
        return .{
            .table = undefined,
            .values = undefined,
            .allocator = allocator,
        };
    }

    pub fn deinit(self: *InsertAST) void {
        for (self.values) |m| {
            var value = m;
            value.deinit();
        }
        self.allocator.free(self.values);
    }
};

Parser

有了各类AST的定义之后,我们需要完成语法分析,即从Tokens到AST的分析过程:

@spec parse(tokens) -> AST

与AST的分析步骤类似,我们也是按照AST的种类来逐一编写分析器:

  • 基本表达式分析:

在AST分析篇幅中,我们已经知道了基本表达式分为字面逻辑和计算逻辑两类,这里我们可以进一步归纳:

  1. token中遇到string\整型\普通标识符时,我们可以归纳为字面逻辑;
  2. token中遇到+><=这些关键字时,我们需要递归分析这些符号前后两个表达式,构建为一个计算逻辑,这里前后表达式分析任然可能为计算逻辑,例如select * from xxx where (age + (2+1) ) > (year - 4);

综上,我们可以编写出基本表达式的分析过程:

fn isExpectTokenKind(tokens: []Token, index: usize, kind: Token.Kind) bool {
    if (index >= tokens.len) {
        return false;
    }

    return tokens[index].kind == kind;
}

fn parseExpression(
    self: Self,
    tokens: []Token,
    index: *usize,
) !ast.ExpressionAST {
    var i = index.*;
    var e: ast.ExpressionAST = undefined;

    // 字符串、整型和普通标识符
    if (isExpectTokenKind(
        tokens,
        i,
        Token.Kind.string,
    ) or isExpectTokenKind(
        tokens,
        i,
        Token.Kind.integer,
    ) or isExpectTokenKind(
        tokens,
        i,
        Token.Kind.identifier,
    )) {
        e = .{ .literal = tokens[i] };
        index.* += 1;
    } else {
        return ParserError.InvalidStatement;
    }

    i += 1;

    // 计算符号
    if (isExpectTokenKind(
        tokens,
        i,
        Token.Kind.equal_operator,
    ) or isExpectTokenKind(
        tokens,
        i,
        Token.Kind.lt_operator,
    ) or isExpectTokenKind(
        tokens,
        i,
        Token.Kind.gt_operator,
    ) or isExpectTokenKind(
        tokens,
        i,
        Token.Kind.plus_operator,
    ) or isExpectTokenKind(
        tokens,
        i,
        Token.Kind.concat_operator,
    )) {
        const new_expression = ast.ExpressionAST{
            .binary_operation = try ast.BinaryOperationAST.init(
                self.allocator,
                tokens[i],
            ),
        };
        new_expression.binary_operation.left.* = e;
        e = new_expression;
        index.* += 1;
        const parsed_expression = try self.parseExpression(tokens, index); // 递归分析
        e.binary_operation.right.* = parsed_expression;
    }

    return e;
}

  • Select语法分析

分析Select语法也很简单,可以按照如下流程分析:

在这里插入图片描述

这个过程中,分析column表达式的过程需要注意,当有多个选择字段时,需要判断字段中间的逗号分隔符。

实现代码如下:

fn parseSelect(
    self: Self,
    tokens: []Token,
) !ast.SelectAST {
    var i: usize = 0;
    // expect select keyword
    if (!isExpectTokenKind(tokens, i, Token.Kind.select_keyword)) {
        return ParserError.ExpectSelectKeyword;
    }
    i += 1;
    var columns = std.ArrayList(ast.ExpressionAST).init(self.allocator);
    var select = ast.SelectAST.init(self.allocator);

    // 分析columns
    while (!isExpectTokenKind(tokens, i, Token.Kind.from_keyword)) {
        if (columns.items.len > 0) { // 不止一个字段时,expect逗号分隔符
            if (!isExpectTokenKind(tokens, i, Token.Kind.comma_syntax)) {
                return ParserError.ExpectCommaSyntax;
            }
            i += 1;
        }
        // 分析columns表达式
        const parsed_expression = try self.parseExpression(
            tokens,
            &i,
        );
        defer parsed_expression.deinit();
        try columns.append(parsed_expression);
    }
    select.columns = try columns.toOwnedSlice();
    if (select.columns.len == 0) { // columns不能为空
        return ParserError.ExpectIdentifier;
    }
    i += 1;

    // table name
    if (!isExpectTokenKind(tokens, i, Token.Kind.identifier)) {
        return ParserError.ExpectIdentifier;
    }
    select.from = tokens[i];
    i += 1;

    // 分析where条件
    if (isExpectTokenKind(tokens, i, Token.Kind.where_keyword)) {
        i += 1;
        const parsed_expression = try self.parseExpression(
            tokens,
            &i,
        );
        select.where = parsed_expression;
    }

    return select;
}

  • Create Table语法分析

建表的语法可以按照如下流程分析:

在这里插入图片描述

注意此处建表的columns相较于Select语法的columns有点不同,建表的columns不仅有字段名的标识符,还有类型标识,其余的逻辑与Select语法的columns分析方法一致:

fn parseCreateTable(self: Self, tokens: []Token) !ast.CreateTableAST {
    var i: usize = 0;
    // create table
    if (!isExpectTokenKind(tokens, i, Token.Kind.create_table_keyword)) {
        return ParserError.ExpectCreateKeyword;
    }

    i += 1;
    // table name
    if (!isExpectTokenKind(tokens, i, Token.Kind.identifier)) {
        return ParserError.ExpectIdentifier;
    }
    var create_table_ast = ast.CreateTableAST.init(self.allocator);
    create_table_ast.table = tokens[i];

    i += 1;
    // columns: (field1 type, field2 type,...)
    var columns = std.ArrayList(ast.CreateTableColumnAST).init(self.allocator);

    if (!isExpectTokenKind(tokens, i, Token.Kind.left_paren_syntax)) {
        return ParserError.ExpectLeftParenSyntax;
    }
    i += 1;
    while (!isExpectTokenKind(tokens, i, Token.Kind.right_paren_syntax)) {
        if (columns.items.len > 0) {
            if (!isExpectTokenKind(tokens, i, Token.Kind.comma_syntax)) {
                return ParserError.ExpectCommaSyntax;
            }
            i += 1;
        }
        var column = ast.CreateTableColumnAST.init();

        if (!isExpectTokenKind(tokens, i, Token.Kind.identifier)) {
            return ParserError.ExpectIdentifier;
        }
        column.name = tokens[i];
        i += 1;
        if (!isExpectTokenKind(tokens, i, Token.Kind.identifier)) {
            return ParserError.ExpectIdentifier;
        }
        column.kind = tokens[i];
        i += 1;

        try columns.append(column);
    }
    if (columns.items.len == 0) {
        return ParserError.EmptyColumns;
    }
    // )
    i += 1;

    if (i < tokens.len) {
        return ParserError.UnexpectedToken;
    }
    create_table_ast.columns = try columns.toOwnedSlice();
    return create_table_ast;
}

  • Insert语法分析

Insert语法相较于建表语法更为简单一些,可以按照如下流程分析:

在这里插入图片描述

实现代码如下:

fn parseInsert(self: Self, tokens: []Token) !ast.InsertAST {
    var i: usize = 0;
    // insert into
    if (!isExpectTokenKind(tokens, i, Token.Kind.insert_keyword)) {
        return ParserError.ExpectInsertKeyword;
    }
    i += 1;
    // table name
    if (!isExpectTokenKind(tokens, i, Token.Kind.identifier)) {
        return ParserError.ExpectIdentifier;
    }
    var insert_ast = ast.InsertAST.init(self.allocator);
    insert_ast.table = tokens[i];
    i += 1;

    // values (val1, val2,...)
    var values = std.ArrayList(ast.ExpressionAST).init(self.allocator);
    if (!isExpectTokenKind(tokens, i, Token.Kind.values_keyword)) {
        return ParserError.ExpectValueKeyword;
    }
    i += 1;
    if (!isExpectTokenKind(tokens, i, Token.Kind.left_paren_syntax)) {
        return ParserError.ExpectLeftParenSyntax;
    }
    i += 1;

    while (!isExpectTokenKind(tokens, i, Token.Kind.right_paren_syntax)) {
        if (values.items.len > 0) {
            if (!isExpectTokenKind(tokens, i, Token.Kind.comma_syntax)) {
                return ParserError.ExpectCommaSyntax;
            }
            i += 1;
        }
        const exp = try self.parseExpression(tokens, &i);
        defer exp.deinit();
        try values.append(exp);
    }
    if (values.items.len == 0) {
        return ParserError.EmptyValues;
    }
    // )
    i += 1;
    if (i < tokens.len) {
        return ParserError.UnexpectedToken;
    }
    insert_ast.values = try values.toOwnedSlice();
    return insert_ast;
}

Table to KV

至此,我们已经完成了SQL语法层面的解析,如果按编程语言的设计行话来说,我们已经完成了这门语言的前端,现在是时候开始后端的工作了。

就我们这个小项目而言,后端的工作就是将SQL的各类AST映射至RocksDB的KV操作上。在开始这项工作之前,我们先介绍一下关系型数据到KV数据的一般映射方法论,这里笔者强烈推荐大家阅读CockroachDB家(www.cockroachlabs.com/)的文档和博客,例如:

  • Structured data encoding in CockroachDB SQL
  • SQL in CockroachDB: Mapping table data to key-value storage

我们这里也提纲挈领的介绍一下表数据到KV数据转换的基本思想:

现在让我们考虑以下表和数据:

CREATE TABLE dewuer (
      id       INT PRIMARY KEY,
      name     STRING,
      age      INTEGER
)

INSERT INTO dewuer VALUES (10, "Zhangsan", 34)

每个表都必须有一个主键,主键由一个或多个列组成。

在我们上面的dewuer表中,它由一个单独的列id组成。我们将每个非主键列存储在由主键前缀和列名后缀的单独键下, 例如:

在这里插入图片描述

我们使用 /dewuer/ 作为表ID的占位符, /name 和 /age 作为列ID的占位符(表中的每一列都有一个在表中唯一的 ID)。

假设dewuer这张表的元数据如下:

在这里插入图片描述

那么我们表格中映射数据看起来如下:

在这里插入图片描述

有些同学可能认为这些键有很多重叠的前缀,可能造成存储的写放大。这点大家大可不必担心,主流的键值引擎例如RocksDB底层的数据结构都会有类似前缀树这样的压缩能力,所以放心大胆的存就行了。

此时如果我们执行查询语句:

SELECT * FROM dewuer WHERE id = 10

这句SQL就会被映射为:

SCAN(/666/10, /666/10/$) // $代表最大的后缀串

这里有个小问题,如果所有的列都为NULL值,那么KV映射中就什么也不剩了,为了解决这一问题,我们给每个column加上一个哨兵键:

在这里插入图片描述

当我们需要二级索引时,例如我们执行如下SQL:

CREATE INDEX dewuer-age ON dewuer (age)

这是一个非唯一索引,所以允许重复值。我们通过引入一个索引ID来实现这一点,该ID对于表中的每个索引都是唯一的,包括主键索引。这样,所有的key格式就演变为如下:

/tableID/indexID/indexColumns[/columnID]

这样,我们的KV表格就变成了这个样子:

在这里插入图片描述

当我们执行二级索引查询时例如SQL:SELECT name FROM deuwer WHERE age = 34

此时我们发现这张表存在age的二级索引,于是我们将这局SQL转换为如下的扫描任务:

SCAN(/666/dewuer-age/34/, /666/dewuer-age/34/$)

// 将检索出该键:/666/dewuer-age/34/10

注意,我们检索出的键中包含了索引值和主键索引值,如果查询语句中没有这两项以外的column,那么就不需要再去根据主键索引检索了,这就是平常我们所说的索引覆盖。

而唯一索引的情况有所不同,例如我们执行SQL:

CREATE UNIQUE INDEX uniq-name ON dewuer (name)

与普通索引不同,唯一索引的键仅由索引的部分列组成。存储在键中的值是所有非索引主键列的编码

在这里插入图片描述

当我们再次插入一个name=Zhangsan的数据时例如:

INSERT INTO dewuer VALUES (11, "Zhangsan", 35)

就会出现/666/uniq-name/Zhangsan这个键的碰撞导致插入失败,违反了唯一性约束。

Storage

在我们正式开始编写上述的映射逻辑之前,我们先准备好几个数据结构。

Table

一张表由表名、columns、column-types组成,我们的小项目不涉及索引这类高级功能,所以不需要更复杂的元数据。

定义如下:

pub const Table = struct {
    name: []const u8,
    columns: []const []const u8,
    types: []const []const u8,
    allocator: std.mem.Allocator,

    pub fn init(
        allocator: std.mem.Allocator,
        name: []const u8,
        columns: []const []const u8,
        types: []const []const u8,
    ) Table {
        return Table{
            .name = name,
            .columns = columns,
            .types = types,
            .allocator = allocator,
        };
    }

    pub fn deinit(self: Table) void {
        for (self.columns, 0..) |_, i| {
            self.allocator.free(self.columns[i]);
        }
        self.allocator.free(self.columns);
        for (self.types, 0..) |_, i| {
            self.allocator.free(self.types[i]);
        }
        self.allocator.free(self.types);
    }
};
  • row

一个数据行由每个columns的数据以及关联Table组成:

pub const Row = struct {
    const Self = @This();

    allocator: std.mem.Allocator,
    cells: std.ArrayList(Value),
    table: Table,

    pub fn init(allocator: std.mem.Allocator, table: Table) Self {
        return Self{
            .allocator = allocator,
            .cells = std.ArrayList(Value).init(allocator),
            .table = table,
        };
    }

    pub fn deinit(self: Self) void {
        for (self.cells.items) |c| {
            c.deinit(self.allocator);
        }
        self.cells.deinit();
    }

    pub fn append(self: *Self, cell: Value) !void {
        return self.cells.append(try cell.clone(self.allocator));
    }

    // 取出对应field的值
    pub fn get(self: Self, field: []const u8, val: *Value) bool {
        if (field.len == 0) {
            return false;
        }
        for (self.table.columns, 0..) |f, i| {
            if (std.mem.eql(u8, f, field)) {
                val.* = self.cells.items[i];
                return true;
            }
        }
        return false;
    }

    pub fn reset(self: *Self) void {
        for (self.cells.items) |c| {
            c.deinit(self.allocator);
        }
        self.cells.clearRetainingCapacity();
    }
};
  • rowIter

当我们做Select查询时,会对RocksDB执行前缀匹配搜索,所以我们需要一个获取特定前缀的数据行迭代器,该迭代器由RocksDB的迭代器和数据表组成:

const RowIter = struct {
    const Self = @This();

    iter: Rocksdb.Iter,
    row_prefix: []const u8,
    allocator: std.mem.Allocator,
    table: Table,

    fn init(
        allocator: std.mem.Allocator,
        db: Rocksdb,
        row_prefix: []const u8,
        table: Table,
    ) !Self {
        const rp = try allocator.dupe(u8, row_prefix);
        return .{
            .iter = try db.getIter(rp),
            .row_prefix = rp,
            .allocator = allocator,
            .table = table,
        };
    }

    pub fn deinit(self: Self) void {
        self.allocator.free(self.row_prefix);
        self.iter.deinit();
    }

    pub fn next(self: *Self) !?Row {
        var b: []const u8 = undefined;
        if (self.iter.next()) |m| { // 对rocksdb执行前缀搜索
            b = m.value;
        } else {
            return null;
        }

        var row = Row.init(self.allocator, self.table);

        var offset: usize = 0;
        while (offset < b.len) {
            var buf: []const u8 = undefined;
            offset += value.deserializeBytes(b[offset..], &buf);
            try row.append(Value.deserialize(buf));
        }
        return row;
    }
};

有了上述的数据结构,我们可以开始着手编写映射逻辑,由于我们这是一个非常简单的玩具项目,没必要非常严格的定义映射模型,甚至我们连主键索引都可以不用,用最简单直白的文本键值对来实现。

由于KV引擎中只能存储二进制数据,所以我们还需要实现一个小小的序列化协议,当然对于网络编程选手来说,这些都是轻车熟路了:

fn serializeInteger(comptime T: type, i: T, buf: *[@sizeOf(T)]u8) void {
    std.mem.writeInt(T, buf, i, .big);
}

fn deserializeInteger(comptime T: type, buf: []const u8) T {
    return std.mem.readInt(T, buf[0..@sizeOf(T)], .big);
}

// [length: 4bytes][bytes]
pub fn serializeBytes(allocator: std.mem.Allocator, bytes: []const u8) ![]const u8 {
    var h: [4]u8 = undefined;
    serializeInteger(u32, @intCast(bytes.len), &h);
    const parts = [_][]const u8{ &h, bytes };
    return std.mem.concat(allocator, u8, &parts);
}

pub fn deserializeBytes(bytes: []const u8, buf: *[]const u8) usize {
    const length = deserializeInteger(u32, bytes);
    const offset = length + 4;
    buf.* = bytes[4..offset];
    return offset;
}

pub fn serialize(self: Value, buf: *std.ArrayList(u8)) !void {
    switch (self) {
        .null_value => return buf.append('0'),
        .bool_value => |v| {
            try buf.append('1');
            return buf.append(if (v) '1' else '0');
        },
        .string_value => |v| {
            try buf.append('2');
            return buf.appendSlice(v);
        },
        .integer_value => |v| {
            try buf.append('3');
            var b2: [8]u8 = undefined;
            serializeInteger(i64, v, &b2);
            return buf.appendSlice(b2[0..]);
        },
    }
}

pub fn deserialize(buf: []const u8) Value {
    if (buf.len == 0) {
        unreachable;
    }
    switch (buf[0]) {
        '0' => return Value{ .null_value = true },
        '1' => {
            if (buf[1] == '1') {
                return Value{ .bool_value = true };
            } else {
                return Value{ .bool_value = false };
            }
        },
        '2' => return .{ .string_value = buf[1..] },
        '3' => {
            return Value{ .integer_value = deserializeInteger(i64, buf[1..]) };
        },
        else => unreachable,
    }
}

  • 创建表

当我们执行创建表语句时:

CREATE TABLE dewuer (year int, age int, name text)

我们将其转换为如下键值对用于存储表元数据

@spec serialize(int | string | bool) -> bytes // alias s/1
@spec serializeBytes(bytes) -> bytes // alias sB/1
table_dewuer => |sB(year)|sB(int)|sB(age)|sB(int)|sB(name)|sB(text)|

实现代码如下:

// table_{table} => |{column}|{type}|{column}|{type}|....
pub fn writeTable(self: Self, table: Table) !void {
    // table name
    const k = try std.fmt.allocPrint(self.allocator, "table_{s}", .{table.name});
    defer self.allocator.free(k);

    var cb = std.ArrayList(u8).init(self.allocator);
    defer cb.deinit();

    for (table.columns, 0..) |col, i| {
        const b1 = try value.serializeBytes(self.allocator, col);
        defer self.allocator.free(b1);
        try cb.appendSlice(b1);
        const b2 = try value.serializeBytes(self.allocator, table.types[i]);
        defer self.allocator.free(b2);
        try cb.appendSlice(b2);
    }
    try self.db.set(k, cb.items);
}

  • 插入数据

当我们执行插入数据语句时:

INSERT INTO dewuer VALUES (2020, 34, 'Lisi')

我们将其转换为如下键值对:

@spec serialize(int | string | bool) -> bytes // alias s/1
@spec serializeBytes(bytes) -> bytes // alias sB/1
row_dewuer_{random_id()} => sB(s(2020))++sB(s(34))++sB(s('Lisi'))

按照这个模型,我们可以实现插入语句的逻辑:

// row_{tablel}_{id} => {row}
pub fn writeRow(self: Self, table: []const u8, row: Row) !void {
    // make sure table exists
    if (!try self.tableExists(table)) {
        return StorageError.TableNotFound;
    }
    const buf = try self.allocator.alloc(u8, table.len + 21);
    defer self.allocator.free(buf);
    var id: [16]u8 = undefined;
    generateId(&id); // 生成随机串当做主键
    const k = try std.fmt.bufPrint(buf, "row_{s}_{s}", .{ table, id });

    // row data
    var va = std.ArrayList(u8).init(self.allocator);
    defer va.deinit();
    for (row.items()) |cell| {
        var b = std.ArrayList(u8).init(self.allocator);
        defer b.deinit();
        try cell.serialize(&b);

        const bin = try value.serializeBytes(self.allocator, b.items);
        defer self.allocator.free(bin);
        try va.appendSlice(bin);
    }
    return self.db.set(k, va.items);
}
  • 查询表

当我们需要从KV中重建出表结构时,就是创建表的逆过程,我们需要重建出表各个字段的名字和类型,生成一个Table结构:

pub fn getTable(self: Self, table_name: []const u8) !?Table {
    const k = try std.fmt.allocPrint(self.allocator, "table_{s}", .{table_name});
    defer self.allocator.free(k);

    var columns = std.ArrayList([]const u8).init(self.allocator);
    var types = std.ArrayList([]const u8).init(self.allocator);

    // get table info
    var b = std.ArrayList(u8).init(self.allocator);
    defer b.deinit();
    try self.db.get(k, &b);
    const detail = b.items;
    if (detail.len == 0) {
        return null;
    }
    // rebuild columns
    var offset: usize = 0;
    while (offset < detail.len) {
        var buf: []const u8 = undefined;
        offset += value.deserializeBytes(detail[offset..], &buf);
        try columns.append(try self.allocator.dupe(u8, buf));
        offset += value.deserializeBytes(detail[offset..], &buf);
        try types.append(try self.allocator.dupe(u8, buf));
    }

    return Table.init(
        self.allocator,
        table_name,
        try columns.toOwnedSlice(),
        try types.toOwnedSlice(),
    );
}

  • 查询表中数据

由于我们的数据结构中主键索引是自动生成的随机串,所以没法按主键来查询,我们选择根据Table作为维度,一次性把一张表的数据全部读入内存中,所以Iterator的设计非常简单:

pub fn getRowIter(self: Self, table: Table) !RowIter {
    const row_prefix = try std.fmt.allocPrint(self.allocator, "row_{s}", .{table.name});
    defer self.allocator.free(row_prefix);

    return RowIter.init(self.allocator, self.db, row_prefix, table);
}

Executer

至此,我们已经有了从SQL到AST的前端层,也有了从Table到KV的后端映射层,距离我们完整的SQL引擎只剩下了AST到Table的执行层了。

我们这个项目中AST有4类:表达式AST、SelectAST、CreateTableAST、InsertAST,我们只需要逐个实现它们即可。

  • 表达式AST

表达式AST有2类:字面逻辑表达式和计算逻辑表达式,所以我们的实现中也要分开讨论。

对于字面逻辑较为简单,要么是字符串或者整型、要么是column的名称,只需要从数据行中取出对应column的值即可。

对于计算逻辑则比较复杂,我们需要先递归的计算出计算表达式的两臂,再根据具体计算符号的含义来分别实现,好在我们这个项目中计算符号不多。

一般来说数据类型不一致的无法做计算,不过我们这个项目不考虑强类型的情况,这里可以做一些隐式转换。

fn executeExpression(self: Self, expr: ast.ExpressionAST, row: storage.Row) !Value {
    return switch (expr) {
        .literal => |literal| {
            switch (literal.getKind()) {
                .string => return Value{ .string_value = literal.string() },
                .integer => return Value{ .integer_value = try std.fmt.parseInt(i64, literal.string(), 10) },
                .identifier => { // 普通标记符,一般为column名
                    var val: Value = undefined;
                    if (row.get(literal.string(), &val)) {
                        return val;
                    }
                    unreachable;
                },
                else => unreachable,
            }
        },
        .binary_operation => |binary_operation| {
            const left = try self.executeExpression(binary_operation.getLeft(), row);
            defer left.deinit(self.allocator);
            const right = try self.executeExpression(binary_operation.getRight(), row);
            defer right.deinit(self.allocator);
            var lb = std.ArrayList(u8).init(self.allocator);
            defer lb.deinit();
            var rb = std.ArrayList(u8).init(self.allocator);
            defer rb.deinit();
            // 先将两臂的值都转换为字符串,方便后续的比较逻辑,这里实现有点丑陋==
            try left.asString(&lb);
            try right.asString(&rb);

            switch (binary_operation.getOpKind()) {
                // 相等计算 -> 比较两臂字符串是否相等
                .equal_operator => return Value{ .bool_value = std.mem.eql(u8, lb.items, rb.items) },
                // 连接计算 -> 将两臂字符串连接
                .concat_operator => {
                    var result = std.ArrayList(u8).init(self.allocator);
                    try result.appendSlice(lb.items);
                    try result.appendSlice(rb.items);
                    return Value{ .string_value = try result.toOwnedSlice() };
                },
                // 大于计算 -> 将两臂转换为整型后比较
                .lt_operator => {
                    if (try left.asInteger() < try right.asInteger()) {
                        return Value.TRUE;
                    } else {
                        return Value.FALSE;
                    }
                },
                // 小于计算 -> 同理大于计算
                .gt_operator => {
                    if (try left.asInteger() > try right.asInteger()) {
                        return Value.TRUE;
                    } else {
                        return Value.FALSE;
                    }
                },
                // 加法计算 -> 将两臂转换为整型后相加
                .plus_operator => {
                    return Value{ .integer_value = try left.asInteger() + try right.asInteger() };
                },
                else => unreachable,
            }
        },
    };
}

  • CreateTable AST

创建表AST中包含了column名和column类型的Token,所以我们只需要取出AST中的对应信息,构建一个Table数据结构,然后按照Storage中的逻辑存入KV即可:

fn executeCreateTable(self: Self, create_table: ast.CreateTableAST) !QueryResponse {
    const table_name = create_table.getTableName();
    if (try self.storage.getTable(table_name)) |t| {
        t.deinit();
        return ExecuteError.TableAlreadyExists;
    }

    var columns = std.ArrayList([]const u8).init(self.allocator);
    defer columns.deinit();
    var types = std.ArrayList([]const u8).init(self.allocator);
    defer types.deinit();

    for (create_table.columns) |c| {
        try columns.append(c.getName()); // 取出字段名
        try types.append(c.getKind()); // 取出字段类型
    }

    const table = Table.init(
        self.allocator,
        table_name,
        columns.items,
        types.items,
    );

    try self.storage.writeTable(table); // 写入KV

    return QueryResponse{
        .fields = undefined,
        .rows = undefined,
        .allocator = self.allocator,
    };
}

  • Insert AST

插入AST的结构中,各个Value都是表达式,所以我们在执行AST的过程中,需要先执行各个Value的表达式,例如:

INSERT INTO dewuer VALUES (2020+1, 34-2, 'Lisi' || 'Dalao')

这个SQL中Value都是需要递归处理,所以在实现中,Value部分的逻辑需要执行表达式AST的计算。

fn executeInsert(self: Self, insert: ast.InsertAST) !QueryResponse {
    const table_name = insert.getTableName();
    if (try self.storage.getTable(table_name)) |t| {
        defer t.deinit();
        var empty_row = Row.init(self.allocator, t);
        defer empty_row.deinit();
        var row = Row.init(self.allocator, t);
        defer row.deinit();
        for (insert.values) |v| {
            try row.append(try self.executeExpression(v, empty_row));
        }

        // write row
        try self.storage.writeRow(table_name, row);

        return QueryResponse{
            .fields = undefined,
            .rows = undefined,
            .allocator = self.allocator,
        };
    }

    return ExecuteError.TableNotFound;
}

注意到我们在执行表达式AST时用了一个空Row传入,只用其中Table的信息,我们这个小项目不支持字面量的取值再计算的复杂SQL,所以在Insert AST的执行时,不会遇到column的取值逻辑。

  • Select AST

目前最复杂的就是Select的查询实现了,Select AST执行的复杂性来自以下几个方面:

  1. Select的columns和Where条件都可能是计算表达式;
  2. 当执行Select * 时需要将*转换为所有columns;
  3. Where条件需要判断;

我们的执行逻辑如下:

在这里插入图片描述

由于我们的KV层只能实现全表取出,所以Where条件的匹配只能在内存中进行。

具体实现如下:

fn executeSelect(self: Self, select: ast.SelectAST) !QueryResponse {
    const table_name = select.getTableName();

    // select x, y
    var select_fields = std.ArrayList([]const u8).init(self.allocator);
    for (select.columns) |column| {
        var field_name: []const u8 = undefined;
        switch (column) {
            .literal => |l| {
                if (l.getKind() == .identifier) {
                    field_name = l.string();
                } else {
                    unreachable;
                }
            },
            else => unreachable,
        }
        try select_fields.append(field_name);
    }
    // 判断是否为Select *
    var select_all = false;
    if (select_fields.items.len == 1 and std.mem.eql(u8, select_fields.items[0], "*")) {
        select_all = true;
        select_fields.clearRetainingCapacity();
        if (try self.storage.getTable(table_name)) |table| {
            defer table.deinit();
            for (table.getColumns()) |c| {
                try select_fields.append(try self.allocator.dupe(u8, c));
            }
        } else {
            return ExecuteError.TableNotFound;
        }
    }

    var rows = std.ArrayList([][]const u8).init(self.allocator);
    if (try self.storage.getTable(table_name)) |table| {
        defer table.deinit();
        var iter = try self.storage.getRowIter(table);
        defer iter.deinit();

        while (try iter.next()) |row| {
            defer row.deinit();
            // 判断这条Row是否满足Where条件
            var whereable = false;
            if (select.where) |where| {
                const wv = try self.executeExpression(where, row);
                defer wv.deinit(self.allocator);
                if (wv.asBool()) {
                    whereable = true;
                }
            } else {
                // no where clause, add all
                whereable = true;
            }

            if (whereable) {
                var select_res = std.ArrayList([]const u8).init(self.allocator);
                if (select_all) {
                    for (table.getColumns()) |c| {
                        var val: Value = undefined;
                        if (row.get(c, &val)) {
                            var b = std.ArrayList(u8).init(self.allocator);
                            try val.asString(&b);
                            try select_res.append(try b.toOwnedSlice());
                        } else {
                            unreachable;
                        }
                    }
                } else {
                    for (select.columns) |column| {
                        const val = try self.executeExpression(column, row);
                        defer val.deinit(self.allocator);
                        var b = std.ArrayList(u8).init(self.allocator);
                        try val.asString(&b);
                        try select_res.append(try b.toOwnedSlice());
                    }
                }
                try rows.append(try select_res.toOwnedSlice());
            }
        }
        return QueryResponse{
            .fields = try select_fields.toOwnedSlice(),
            .rows = try rows.toOwnedSlice(),
            .allocator = self.allocator,
        };
    }
    // table not exists
    return ExecuteError.TableNotFound;
}

All In One

到目前为止,我们已经完成了本项目所有需要的基础组件,现在我们只需要将这些组件拼装起来,加入输入参数处理以及结果的打印就可以完成这个小型的SQL数据库了。

我们又回到了最开始我们介绍的项目结构,不过这里我们需要加上输入输出的部分:

在这里插入图片描述

由于是命令行类的工具,内存管理可以更为奔放一些,Zig非常贴心的提供了Arena内存管理器(Go程序员应该很熟悉),非常适合命令行这类的用完即走的工具,我们可以一次性的申请内存,用完后一次性丢弃。

Zig 中的Arena Allocator是一种特殊的内存管理策略,它与传统分配器不同,通过将释放操作推迟到程序执行结束时进行。这种方法可以在内存一次性分配和释放的场景中提高性能,而不是在整个程序生命周期中逐步释放。

我们最后的main函数实现如下:

pub fn main() !void {
    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();
    const allocator = arena.allocator();
    const stdout = std.io.getStdOut().writer();

    var args = try std.process.argsWithAllocator(allocator);
    defer args.deinit();

    var database_path: []const u8 = undefined;
    var script: []const u8 = undefined;
    while (args.next()) |arg| {
        if (std.mem.eql(u8, arg, "--database")) {
            database_path = args.next().?; // database
        } else if (std.mem.eql(u8, arg, "--script")) {
            script = args.next().?; // script
        }
    }

    if (database_path.len == 0) {
        std.log.err("No database specified", .{});
        return ZigRocksError.InvalidArgument;
    }
    if (script.len == 0) {
        std.log.err("No script specified", .{});
        return ZigRocksError.InvalidArgument;
    }
    // read script
    const script_file = try std.fs.cwd().openFile(script, .{});
    defer script_file.close();

    const file_size = try script_file.getEndPos();
    const script_buffer = try allocator.alloc(u8, file_size);
    defer allocator.free(script_buffer);

    _ = try script_file.readAll(script_buffer);

    // lex SQL script
    const tokens = try Lexer.init(script_buffer).lex(allocator);
    defer allocator.free(tokens);
    if (tokens.len == 0) {
        std.log.err("No tokens found", .{});
        return ZigRocksError.InvalidArgument;
    }

    // parse SQL script
    const ast = try Parser.init(allocator).parse(tokens);

    // init rocksdb
    const db = try Rocksdb.init(allocator, database_path);
    defer db.deinit();

    // execute AST
    const executer = Executer.init(allocator, db);
    var resp = try executer.execute(ast);
    defer resp.deinit();

    // for `create table` and `insert` SQL, we print OK
    if (resp.rows.len == 0) {
        try stdout.print("OK\n", .{});
        return;
    }

    // for select SQL
    // print fields
    try stdout.print("| ", .{});
    for (resp.fields) |field| {
        try stdout.print("{s}\t\t ", .{field});
    }
    try stdout.print("\n+", .{});

    // print ----
    for (resp.fields) |field| {
        var fl = field.len;
        while (fl > 0) : (fl -= 1) {
            try stdout.print("-", .{});
        }
        try stdout.print("\t\t ", .{});
    }
    try stdout.print("\n", .{});

    // print rows
    for (resp.rows) |row| {
        try stdout.print("| ", .{});
        for (row) |value| {
            try stdout.print("{s}\t\t ", .{value});
        }
        try stdout.print("\n", .{});
    }
}

六、总结

这篇文章中,我利用RocksDB引擎和Zig语言对C家族的集成能力,成功实现了一个简单的SQL数据库。

这个项目中,我们从词法分析器开始,逐步介绍了SQL语句的分析方法、AST的构建以及如何将关系型表数据转换为KV类型的数据。

通过这个玩具项目的经历,我们不仅学习感受了SQL这个计算机行业底座的实现方法,也了解了当下很流行的KV引擎到关系型表的桥接思想,同时锻炼了自身模块组织和编码的能力。

当然这个小项目的功能还是非常简陋的,有兴趣的同学可以按照以下方向继续迭代:

  • 改进Table到KV的映射模型,让其具备KV层的二级索引检索能力;
  • 扩充SQL支持的语法,例如UPDATE语句和DELET语句;
  • 支持多租户能力和事务的能力;
  • 添加一个Server,将SQL Cli工具转换为SQL Server。

文 / 古月方源

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

站外商详的重构与优化|得物技术

一、背景

背景1.jpg站外商详,功能较为单一

站外商详(H5/小程序)一直以来采用detailV3老接口数据,在样式和功能上,不能与最新版的客户端同步对齐,各个端之间的使用体验之间存在差异。

从唤端数据可以看出来,App商品详情页分享后的唤端成功率非常高,能够达到75%以上,代表着这些用户都是带有明确目标和意愿来App内部进入购买商品,ROI好的高净值用户:

背景2.jpg

而对于日均pv占据站外流量的TOP3的站外商详来说,唤端价值相对比较高的同时,uni-app多端同构方案的SPA架构限制了H5与小程序页面性能体验天花板,长期以来站外商详的性能指标在前端平台的性能统计大盘下比较靠后:

背景3.jpg

背景4.jpg

从上面的性能数据监控并对比源码搭建页面的性能数据可以看出来,旧版的商详性能数据并不理想,对用户在站外商详页的转化率有一定影响:

  • 平均fmp:2.75s;
  • 75分位平均fmp:2.74s——对比源码搭建大盘1.29s多了1.45s;
  • 平均lcp:3.29s;
  • 75分位平均lcp:4.06s——对比源码搭建大盘1.46s多了2.6s;

综上背景,我们决定重构站外商详,一方面可以接入得物后台最新版本的商详数据API,便于后续需求迭代,避免站外商详和App商详体验的持续割裂现象;另一方面可以同时提高站外商详的前端性能,带给用户更好的使用体验。

二、技术方案

我们本次站外商详升级到创新商详版本,放弃了原项目的uni-app多端同构方案,同时采用营销侧的技术基建——源码搭建;提高了站外秒开性能和用户体验,同时又保证了代码层面的同构开发,本文将详细介绍本次站外商详的重构与优化。

源码搭建

源码搭建是得物前端平台基于SSR架构的C端基建,本次商详重构采用源码搭建来完成重构任务,以下是源码搭建的简要介绍:

源码搭建.jpg

源码搭建介绍:

源码搭建是利用页面搭建器现有开发组件能力快速生产页面的开发方式,业务开发不需要关心公用组件、体验、性能和稳定性基础建设,只需要在建立好的页面仓库中开发业务代码即可,集成了流水线构建会自动帮助开发构建上传。

首屏性能保障方案

  • 本次重构有一个核心诉求:提高前端页面加载性能,而提高秒开体验的核心即SSR:在Node端请求服务端数据并渲染出HTML结构直出给到浏览器;
  • 但同时商详数据是电商平台的核心数据,尤其是得物出价相关数据一直都被各种黑产爬虫关注,所以风控侧要求商详接口数据需要做加密处理。

此时就有用户体验 & 数据安全的矛盾存在:

  • 数据加密的情况下,Node端无法解密数据,此时便无法直出HTML结构,相当于降级为SPA,用户体验相较于SSR大打折扣;
  • 如果数据不加密,Node端可以解析数据做到HTML直出,但是数据安全又无法得到保障。

那么这之间的矛盾和冲突我们是如何解决的呢?

  • 将首屏数据(即对用户体验影响最大的fmp、lcp元素渲染涉及到的数据)从完整接口中拆分出来,这一部分数据跟需要加密的敏感数据无关,所以不需要加密处理;
  • 拆分出首屏数据接口,然后在SSR阶段只请求首屏数据接口,并渲染HTML结构返回到浏览器;
  • 浏览器端运行时(即用户已经在浏览器端打开了页面的时机),再通过风控请求完整的加密数据接口,并渲染到页面;
  • 通过拆分首屏接口和分离首屏数据渲染和完整数据渲染,这样就能同时保障首屏渲染速度与风控侧的加密需求。

简要流程如下图所示:

性能保障方案.jpg

同构与多环境运行

我们重构的主要目的是为了提高性能以及对接最新版服务端接口,但是又不能因为重构而放弃了以往uni-app架构下的多端同构优势,所以需要设计一套新的运行流程来适配SSR下的新商详。

H5环境下我们可以直接访问SSR架构下的新商详,但是小程序运行环境该如何运行呢?

  • 小程序的开放组件包含了webview组件,所以我们可以依靠小程序环境中的webview组件来替换原本的小程序原生页面,以下设计图大致描述了我们是如何保障一份代码,如何多环境运行的:

同构与多环境运行.jpg

风险控制/止损策略

对于pv较高且包含完整交易链路的站外商详来说,冒烟点和阻塞线上购买流程的故障是不可接受的,因此我们设计了相对来说比较完备的止损策略。

故障降级页面——旧版商详

新版商详上线后,旧版页面暂时不会下线,路径和代码依旧保持不变;因此可以作为降级页面,能够保障在新版商详出问题后无缝将流量切回旧版商详。

SSR故障降级

如果SSR侧的请求出现了不可用现象,只会影响简版数据接口的渲染,因此即便失败了也只是影响秒开性能,而不会中断正常业务流程。

灰度策略

结合前端配置中心,我们可以通过逐步灰度放量方式对命中灰度的用户采取跳转新版商详策略,同时灰度配置也可以作为紧急的回滚手段,在遇到故障时及时将灰度放量关闭,引导所有用户跳转旧版商详。

三、一些针对性重构

在商详页面的整体重构过程中,我们识别出了一些关键模块需要进行针对性的重构。这些模块的重构目标是确保它们能够有效地适配商详页面的整体架构变化,同时提升可拓展性。这些针对性重构帮助我们解决了现有迭代中的瓶颈,并在保证系统稳定性的同时,加速开发的迭代过程。

接下来我们详细介绍其中请求拦截器与业务埋点Hook的重构设计。

请求拦截器的重构

因为新版商详需要在多种场景(Node.js / 微信小程序 / 移动端浏览器)运行代码,同时可以预见的是后续会有更多场景(如:支付宝小程序等)加入运行环境。

为了保障后续更多运行环境拓展性和可维护性,我们重构了请求拦截器模块:

1、RequestInceptor类型定义:

通过从定义层面区分不同环境,可以有效保障拦截器运行在有效环境,也从逻辑底层避免了一些可以前置避免的类型错误(比如在node环境下访问window等):

export interface RequestInceptor<T = InternalAxiosRequestConfig<unknown>> {
  (): {
    // node环境的请求拦截
    nodeEnv: (config: T, runtimeConfig?: RunTimeConfig) => Promise<T> | T;
    // 浏览器环境的请求拦截
    clientEnv: (config: T, runtimeConfig?: Pick<RunTimeConfig, 'query') => Promise<T> | T;
  };
}

2、RequestInceptor的具体实现:

每个RequestInceptor都是一个函数,根据环境返回不同的处理逻辑,示例代码:

const h5CommonHeaders: RequestInceptor = () => ({
  // 不同环境下需要携带一些不同的request header
  nodeEnv: config => {
    config.headers['reqEnv'] = 'node';
    return config;
  },
  clientEnv: async config => {
    config.headers['appid_org'] = 'wxapp';
    return config;
  },
});

const yunDunSDK: RequestInceptor = () => ({
  nodeEnv: config => config,
  clientEnv: async config => {
    // 只需要在浏览器环境加载的sdk
    await yunDunLoad;
    return config;
  },
});

3、inceptorsLoader和requestInceptorsCreator共同实现了请求拦截器的处理流程:

inceptorsLoader:

const inceptorsLoader = async (initialConfig: InitialConfig, inceptors: RequestInceptor[]) => {
  const promiseList = map(inceptors, interceptor => {
    return async (config: InitialConfig) => {
      const { nodeEnv, clientEnv } = interceptor();
        if (isInWindow) {
          return clientEnv(config, config?.runTimeConfig);
        } else {
          return nodeEnv(config, config?.runTimeConfig);
        }
    };
  });
  const promiseListResult = await promiseList.reduce(
    (promise, fn) =>
      promise.then(config => {
        return fn(config);
      }),
    Promise.resolve(initialConfig),
  );
  return promiseListResult;
};
  • 这个函数接收两个参数:initialConfig和interceptors;
  • initialConfig是初始的请求配置,包含请求方法、URL、参数等;
  • interceptors是一个请求拦截器的数组,每个拦截器都是一个对象,包含nodeEnv和clientEnv属性,分别表示在Node环境和浏览器环境下的处理逻辑;
  • 使用map函数将interceptors数组转换为处理函数的数组,并按顺序执行这些处理函数;
  • reduce方法用于串联这些处理函数,形成一个Promise链。每个处理函数(fn)接收当前的配置(config)作为参数,然后根据环境执行相应的处理(nodeEnv或clientEnv)。

requestInceptorsCreator:

export const requestInceptorsCreator = (config: InitialConfig) =>
  inceptorsLoader(config, [
    // 通用的headers
    h5CommonHeaders,
    // 风控
    yunDunSDK
  ]);

这个函数是一个工厂函数,它接收一个config对象作为参数,用于创建并返回一个处理后的配置对象。

4、通过RequestInceptor的设计,结合工厂函数requestInceptorsCreator,可以灵活地添加、删除或修改请求拦截器,同时保证拦截器按照特定的顺序执行。这种方式使得请求处理逻辑更加模块化、可测试和易于维护。在实际应用中,只需要调用requestInceptorsCreator函数,传入初始配置,即可得到一个完整的、优化过的请求配置,然后可以将其传给HTTP客户端(如axios)来发起请求。

埋点Hook的重构

一直以来,埋点开发深受前端同学吐槽和困扰,因为大量的埋点逻辑都跟业务逻辑/视图渲染有着强绑定的关系,同时又不得不写大量的“模版式代码”,费心又费力。

本次重构基于React Hook重构了埋点上报的应用层逻辑,可以在组件内引入Hook进行自定义上报/曝光上报;能更加高效的基于不同平台的运行环境去上报指定的埋点参数。

埋点Hook实现层:

1、generateTrackConfig 函数核心代码:

const generateTrackConfig = (trackSend: Readonly<Array<{ name: string }>>) => {
  return function createTrackConfig() {
    const names = trackSend.map(item => item.name);

    /**
     * 埋点名由三部分组成:
     * 例:trackEventName_1234_3210
     * event: 'trackEventName'
     * current_page: '1234'
     * block_type: '3210' 可能不存在
     */
    // 这里将埋点平台的函数名拆分出具体的上报入参
    const extractEventData = (current: string) => {
      const nameSplit = current.split('_');
      const [page, block] = nameSplit.slice(-2);
      const isBlockTypePresent = /\d+/.test(page);
      const event = nameSplit.slice(0, isBlockTypePresent ? -2 : -1).join('_');

      return {
        event,
        current_page: isBlockTypePresent ? page : block,
        block_type: isBlockTypePresent ? block : '',
      };
    };
    
    // ...
    
    // 这里统一通过reduce组装成埋点sdk所需要的trackConfig配置
    return names.reduce((total, current) => {
      const eventData = extractEventData(current);
      total[current] = (platform: string, { transParams }: any) =>
        createEventObject(eventData, platform, transParams);
      return total;
    }, {});
  };
};

2、useProTrack 函数核心代码:

export const useProTrack = <T extends string>(
  { props, functionalRef }: { props: any; functionalRef: any },
  trackSendProps: Readonly<TrackSend<T>>,
) => {
  // 这里注入核心的埋点sdk配置
  useWithReactFunctionalTrack({
    functionalRef: functionalRef,
    functionalProps: functionalPropsRef.current,
    useEffect,
    createTrackConfig: generateTrackConfig(trackSendRef.current),
  });
 
  // 这里通过useEffect接入曝光埋点的监听
  useEffect(() => {
    ObserveTrackRef.current = new IntersectionObserver(handleIntersection);
    return () => {
      ObserveTrackRef.current?.disconnect();
    };
  }, []);
  
  // 这里针对具体埋点函数做统一封装,保证TS类型完整
  const trackFuncMemo = useMemo(() => {
    return trackSendRef.current.reduce((result, item) => {
      result[item.name] = (
        trackParams?: Record<string, unknown>,
        options?: { ele?: HTMLElement | null },
      ) => {
        // 这里针对曝光埋点和主动埋点做区分,统一收敛调用方式,通过入参区分
        if (!options?.ele) {
          track(item.name)(trackParams);
          return;
        }
        const { ele } = options;
        startToObserveMap.current[item.name]();
      };
      return result;
    }, {} as { [key in T]: trackFunc });
  }, []);

  return trackFuncMemo;
};

埋点Hook应用层:

1、获取埋点名,从埋点任务中复制,不要手动拼写:

埋点hook应用层.jpg

2、组件内引用useProTrack,并注册埋点名:

  const proTrack = useProTrack({ props, functionalRef }, [
{ name: 'trackEvent_1234' },
    { name: 'trackEvent_2345' },
  ]);

3、主动上报类型例子:

    proTrack.trackEvent_1234({
      button_title: '我知道了',
    });

这里会有埋点名的类型提示,注意选择正确的埋点名:

4、曝光上报例子:

 <div
  className={bindClass(styles.button)}
  onClick={handleGoBuy}
  ref={ele =>
    trackFn?.trackEvent_1234_3210(
      {
        trade_type_list: tradeTypeListStr,
      },
      { ele },
    )
  }
></div>

在HTML元素(不要写在react组件只能是HTML元素)中的ref钩子注册,语法相比主动曝光多了一个配置(见上图)

埋点Hook的重构收益

  • 节省前端工时,减少心智负担: 前端无需手动配置trackConfig文件; 无需感知/调用埋点SDK内部。

  • 减少流程,避免埋点验证时返工: 简单一步,复制埋点系统的埋点名即可创建埋点函数; 再也不用担心埋点event、current_page、block_type写错了; 完备的TS类型提示。

四、重构后的数据回收

  • 非指标向收益:

通过本次重构,我们接入了得物后台最新版本的商详数据API,在数据层面有了完整性和最新版的保障,后续对于App商详的规划理论上站外商详也都可以参与进来。

本次重构在样式上基于最新的得物App设计语言,保持和App商详一致,避免了用户在多端切换之间的割裂感;在功能上虽然暂时还没有完全对齐App能力,但是重点模块都已经具备,进一步还有更多App商详模块会逐渐迁移到站外商详。

  • 前端性能指标收益:

前端性能指标收益.jpg

收益2.jpg

  • 平均fmp:1.06s,相比旧版2.75s,提升61.4%;
  • 75分位平均fmp:1.13s相比旧版2.74s,提升58.75%;
  • 平均lcp:1.20s,相较旧版3.29s,提升63.5%;
  • 75分位平均lcp:1.37s,相较旧版4.06s,提升66.25%;

可以看出来重构后的性能收益非常明显,对用户体验提升有比较大的帮助。

业务指标收益:

  • H5平台 立即购买点击(唤起浮层)触达率相较于旧版,约提升2.96个百分点; 购买按钮点击(跳转确认订单)触达率相较于旧版,约提升0.92个百分点。

  • 小程序平台 立即购买点击(唤起浮层)触达率相较于旧版,约提升0.72个百分点; 购买按钮点击(跳转确认订单)触达率相较于旧版,约提升0.08个百分点。

五、总结

本次针对站外商详页面的重构取得了显著的性能提升,给用户带来了更好的体验。

站外商详,作为得物站外链路的主要商品信息承载页,在本次重构之后也接入了最新的商详数据API,为后续功能扩展和后续的多环境集成也打下了良好的技术基础。

六、参考链接

web.dev/articles/lc…

web.dev/articles/cl…

web.dev/articles/op…

文 / 航飞

关注得物技术,每周一、三更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

如何以MLOps保障时效表达稳定性|得物技术

一、背景

消费者选择电商平台进行购物,除了独特的商品,购物体验也越来越成为消费者衡量平台的重要标准。如何帮助客户快速的检索到自己想要购买的商品,如何让客户买到性价比最高的商品,如何帮助客户在更短的时间内收到购买的商品,这些都是平台为消费者提供的重要服务。笔者在订单履约时效项目的参与过程中,主要负责通过算法,帮助平台提升订单履约率和准确率。

背景1.jpg

背景2.jpg

我们先来直观感受一下时效对体验的影响。从上面2张图,我们能够看到,右图订单的交付时效比较长,客户对订单支付意愿就会大大降低,有行业专业人士分析,时效表达多一天,影响的GMV就达千万级,可见时效表达越短对应的收益越大,但也会给供应链履约带来更大的成本。从运营的角度,如果想要给消费者更短时效的体验,但却超越了供应链当前的履约能力(比如告知消费者1天送达,实际是3天),则会带来大量客诉,在得物平台因为有“晚到必赔”规则,会额外增加超时赔付优惠券的负担,引发资损。所以在得物做时效表达,需要同时兼顾时效的准确率(告知消费者1天送达,实际也是1天)和履约率(告知消费者1天送达,实际不超过1天)。

在我们长期探索过程中,模型逐渐经历了统计模型、ML模型、DL模型的演化。商业环境上也经历了恶劣天气、春节、大促等各种极端场景的考验,算法模型也在一次次的挑战中,变得越来越坚实,并从手工操作逐步实现自动化流程。为了让流程更加高效、稳定,我们引入了MLOps的理念,本文抛砖引玉,与大家一起聊聊,我们在结合MLOps应用的心路历程,期待能与大家碰撞出思维的火花。

二、什么是MLOps

附:MLOps方法论: ml-ops.org/content/mlo…

MLOps的核心理念

随着算法模型在工业界的应用越来越深,越来越广,业界逐渐实践出一些标准,让我们能更高效、更稳定、低成本且长久地管理模型的整个生命周期, 涉及模型训练、模型发布、灰度或AB,及模型的长期监控等等。

其核心理念,主要有以下7个方面的模型管理规范:

  • Versioning, 数据、模型、代码等是否有版本记录,是否能便捷地回溯;
  • Testing, 数据、特征、模型的生成是否有逻辑校验;代码是否有单元测试等;
  • Automation,数据、特征的生成是否有自动调度,模型训练、搜参是否自动,代码提交合并是否CI/CD;
  • Reproducibility,基于Versioning的保障,是否能在任意时间复现历史某次模型预测结果;
  • Deployment,发布在生产、AB、陪跑等环境,是否能保证入参一致性,代码一致性;
  • Monitoring,模型精度下降是否能感知,数据漂移、数据泄露等是否会被发现;
  • Documentation & Project Structure, 文档、项目结构是否具备可读性,结构性。

MLOps核心理念落地关键问题

MLOps的核心理念,应用到业务的过程,并不是一个完全由机器完成,全自动化的过程,而是需要业务团队根据实际业务的情况,不断调整入参,调整模型的过程,会涉及大量的人与机器的交互。因此,如何将这些核心理念应用到具体的业务中,我们需要思考2个方面的问题:

算法模型在设计过程中,要保障业务执行的效果

  1. 模型复现问题:预测模型是如何生成的,如果模型丢失了,能否重新训练找回丢失的模型?
  2. 线上一致性问题 线上时效表达时,订单是由哪个模型预测的?同样的入参给到同一个模型预测,是否保持幂等? 发布新模型时,如何保障模型上线后的效果符合预期?
  3. 模型快速升级问题:线上指标下降了,模型更新流程需要多少人工介入?更新频次的增加,是否带来人工成本线性增长?

如何降低业务使用算法模型的门槛

  1. 运营模型的门槛是否可以降低?
  2. 业务、产品是否能配置化地训练、选择自己需要的模型?
  3. 是否能让更多样化的业务决策能落地,获得更好的业务收益呢?

三、MLOps关键理念的实践--时效仿真产品

基于上述的思考,我们设计并开发了时效仿真产品,并将MLOps的理念融入其中。

模型的可复现性

对于模型训练环节来说,训练集、代码版本和模型参数是三个非常重要的因素。其中代码版本、模型超参可以通过git和数据库控制, 比较容易忽略的是训练集的状态,我们通过数据分层和业务日期隔离两种方法确保了训练集的可追溯性。

数据分层,保障数据版本一致性

如下图所示,在训练环节,时效仿真产品的用户可以圈选任意天的订单用作训练。同样圈选了下列日期的样本,站在不同日期训练是不一样的。比如站在20240903和20240902,那么对于20240901支付的训练样本,20240903会比20240902多一批已签收订单进入模型训练。越近期的数据越能反映履约网络的变化,一般20240903训练得到的模型预测会更准一点。

模型1.jpg

时效仿真产品-训练样本的圈选

模型2.jpg数据分层

这就说明了圈选同样支付日期的订单做训练样本,因为训练日期不同导致已签收订单不同,进而影响实际进入模型训练的样本不同。

针对这个问题,我们做了数据分层,如上右图所示,数仓会将每日订单的最新状态更新完毕, 用户发起仿真任务后,服务端按需取对应订单,通过仿真任务ID作为分区,落到训练集和预测集表。随后通过AI计算平台调起训练、预测任务,同样传入仿真任务ID,训练预测任务取相应分区。这样日后需要重新训练,我们能保证同一个仿真任务得到的数据是一样的。

业务日期隔离,防止数据泄露问题

数据分层虽然保障了数据版本的一致性,但时效场景因其特殊性,我们仍可能遇到数据泄露的问题。如下图所示,按经验订单平均在4天内能被签收,但是数据上从20240830开始订单的签收时间延长了,因为20240902是台风摩羯登陆的日子,受到台风天气的影响,签收时间也发生了变化。

针对20240830~20240901期间的支付订单,做订单签收时长的时效预测,有一部分订单会在台风前签收,有一部分会在台风后签收,在台风到来前后训练得到的模型是完全不同的,预测的结果也是不同的。

  • 在台风前训练,模型平均预测时间就是4天,
  • 在台风后训练,模型就会预测时间偏长。

如果台风结束后模型没及时调整,那整个时效表达的准确性就会大打折扣,不仅会破坏模型的可复现性,对模型准确性也造成了影响。我们做过测算,在不加干预情况下,台风后预测样本履约率会大幅上升(+2.3pt),T日准确率大幅折损(-30.82pt),模型表达过于保守。

数据泄露1.jpg

同支付订单 签收区间示意图

如何解决呢?

  • 对于可复现性,我们在后台记录了每个仿真任务的执行业务日期, 根据业务执行日期来判断,模型可以看到哪些天已签收订单做训练数据, 这个场景里只要把业务日期固定在台风前,那不管是哪天训练的模型,其效果都是一样的。
  • 对于准确性而言,我们希望让模型训练正常日期,预测正常时长,台风、暴雪等异常情况通过bcp(Business Continuity Planning)加时来保障,台风过后指标能迅速回归正常。基于此,我们在时效仿真产品里设计了订单圈选时间类型,可以按支付或应履约日期来圈选,台风情况就按应履约圈选台风前的样本即可,如下左图。

台风.jpg

通过上述的产品设计,我们能保证模型在任意时间训练,任意历史状态下看到的数据都是一致的,为模型可复现性打下了基础。每个模型训练完成后,除了保存模型文件,我们也会记录代码版本、特征、后处理策略等模型必要参数。

线上的一致性保障

当我们得到理想的模型及仿真结果后,接下去要确保安全地上线,如何能确保也是知易行难。仿真可以失败无数次,只要有一次成功就可以,上线却不容许那么多次失败,最好是一次成功。

在时效场景里更特殊的是:

  • 随着时间的推移我们的履约网络能力是一个动态变化的过程,相应地模型也需要长期频繁的更新。
  • 每次模型上线后,其效果一般要等订单走完支付到签收的生命周期后才能回收效果,一般今天上线的模型,下周才知道线上效果如何。

那么如何保障模型每次更新都是可靠的,通过一段时间的探索,我们找到了模型可靠性三步曲,依靠流量回放、流程保障和自动发布来保障线上模型的效果。

流量回放,确保仿真入参和线上入参一致性,提升模型准确率

我们经常会遇到模型仿真效果很好,但线上陪跑就掉很多精度,排查后发现仿真和线上陪跑的入参存在不一致的情况。比如卖家发货地址、承运商等信息,在支付的时候存在不确定性,线上有入参补齐逻辑,比如用子模型预测,或拿历史众数填充;但仿真时这些信息已成历史,落在表里的是真实数据了。这就导致了线上、线下的入参不一致。

服务端同学为此建设了坚实的流量回放能力。平台不同的业务流程,各环节的预测是不同的。以相对复杂的现货流程为例,需要预测商家发货、头程运输、库内作业和尾程运输,理论上每一段都可以用不同的模型来预测,每一段预测入参都可能不一样。服务端同学设计的统一流量回放能力保障了在每一段的入参、出参、调用模型版本都记录在案,做到了任意订单的可追溯。

业务流程.jpg复杂的业务流程

流量回放.jpg统一的流量回放

对应到模型训练、仿真环节, 我们用真实数据做训练, 用线上实际入参来仿真,这样最大化地保障模型精度,减少了仿真与上线之间的精度损失。

流程保障,取得模型稳定性和准确性之间的平衡,最大化业务效果

网络履约能力会受到多种事件变化因素的影响,根据事件因素的特点,大致可分为临时性变化和长久性变化。

  • 临时性变化:比如台风恶劣天气、两会管控、春节打烊等事件触发,事件开始时履约能力急剧变差,事件结束后马上又恢复;
  • 长久性变化:比如承运商班次调整、仓网变化等,其影响时间较长。

临时性变化不应该放入模型训练, 长久性变化应该让模型去学习,并且越早学习到表达就越准。

如何判断是临时性还是长久性变化呢?如果不加约束,尽量用最新数据训练,难以排除临时性变化影响的样本,这样在事件结束后模型表达会偏保守,如上文的台风后陪跑,会损失指标的稳定性。如果加以约束,用履约已完成的订单做训练,这样可以通过履约时长等统计数据来判断是否属临时性异常,可以减少异常样本的干扰,但也会损失一定准确性。

与产品同学反复沟通了方案后,最终站在业务效果最大化的角度,我们选择在稳定性和准确性之间取其平衡,每周会定时启动较新样本仿真,选其中较优模型进入陪跑,待部分签收后决策是否选择模型上线,详细流程如下图。

流程保障.jpg

模型自动发布,确保模型落地效果

模型的发布涉及到一系列流程,流程中所做的配置和操作,对模型效果会有较大的影响。因此,模型的发布也是非常重要的环节。但模型发布,一般需要等一周后,有部分订单签收,才能看到实际的结果,存在滞后性。在早期曾发生过多起,因为模型发布流程问题,导致模型效果打了折扣,比如新特征模型取错了线上Redis特征名、Ark开关配置错误、模型更新后部分服务器sdk未升级等等。基于此,我们建立了模型自动发布流程,将线上逻辑不符合预期的情况提前反映出来。

  1. 梳理了模型发布流程图,将发布流程标准化,如下左图所示;
  2. 取消发布流程中的手动操作,改为系统自动操作,同时沉淀了SLA;
  3. 接入了模型上线审批流:陪跑回收到符合预期的效果后,需要由业务方决策选用哪个模型上线,模型的上线,将直接决定业务运营的效果和管理成本,为此我们也接入了审批流,如下右图所示;
  4. 建立离线陪跑相同模型的方法来验证线上、仿真预测的一致性,离线陪跑的订单及入参通过流量回放获得,两边对比预测结果是否一致;
  5. 建立旧模型反向陪跑:尤其模型有新特征或新逻辑的情况下,未来的网络履约能力可能存在较大的波动,如果旧模型直接下线,网络履约能力波动带来的影响将很难,需要有旧模型的陪跑,才能确定是线上逻辑问题,还是网络履约能力变化问题。

发布流程.jpg发布流程

上线审批流.jpg上线审批流

良好的自动化离不开合理的自动化流程设计,Automation作为MLOps的重要模块,帮助我们较好的解决了手动操作带来的风险和人工成本,在流量回放的一致性验证、模型仿真陪跑流程、发布自动化三方面做的自动化为我们日常低成本运营带来了很大帮助。

模型落地标准化、产品化、自动化,降低业务落地门槛

在模型线上表达比较稳定之后,公司希望在不同的业务场景尝试模型的应用,比如经济型快递独立模型、周末独立模型、支付和应履约模型等,不同的场景应用目的虽然不同,但其底层基础流程大致相同,为了能够让业务根据不同的管理诉求,能够灵活的调整模型训练集,我们跟时效工程、算法工程团队合作,借助AI计算平台的调度能力,结合自研的faas,将模型训练、仿真做成了完全可自助的产品。其大致流程如下:

模型落地.jpg

在产品前端,用户可以按需选择不同的样本,选择灵活性非常大,可以按不同周期、承运商、类目、订单类型、线路等等各种维度来圈选需要训练或仿真的订单。服务端在接受训练、仿真请求后,按需生成训练集和预测集,再调度AI计算平台执行相应任务。这里我们通过 faas解耦与仿真逻辑解析层隔离的方式支撑了产品灵活性和底层架构稳定性。

faas解耦,提升算法维护模型的灵活性

如下左图,没有faas,调用KubeAI的逻辑非常深地耦合在服务端的代码里,算法同学想要调整这部分配置,就需要服务端同学改代码去发版;

如下右图,有了faas,服务端只需要关心调用了哪个function,关键的入参是什么就可以了,其调用如右图。 镜像版本、启动命令、模板ID的更新就解耦给到算法同学去维护了,增加了算法迭代灵活性的同时,保障了服务端接口的稳定性。

服务器耦合.jpg服务端耦合过多调用AI计算平台代码

服务端只关心.jpg服务端只关心调用function和入参

仿真逻辑解析层隔离,计算任务原子化,适配多种业务场景

业务的需求往往是复杂的,灵活的,多变的,比如:

  • 增加多种业务类型如品牌直发、现货;
  • 批量训练、预测多个模型择优,不同模型对应不同特征、超参等,最后将较优模型注册到仿真产品;
  • 支持多种预测目标,如支付-签收、支付-到仓、发货-签收等不同模型。

面对业务多变的需求,我们需要让计算任务具有原子性,稳定的特点。我们选择在AI计算平台实际执行的任务中增加解析层和后处理层的方法,与用户的需求交互,按需生成不同的配置、启动命令来调用pipline,中间执行的pipline是一直稳定的。

pipline.jpg

如此,我们将工程与算法的交互做了良好的解耦,让工程与算法各司其职;让底层稳定下来,解析层灵动起来。时效仿真产品就变得更敏捷,整个模型的训练、预测可以更高效、自动地运转。

目前,模型的仿真已经基本不需要算法同学介入,极大的解放了研发团队在模型探索业务应用和模型更新上的精力,减少了研发在持续维护模型上的成本。

业务应用效果

综上所述,在保证模型效果的同时,将模型的应用门槛降低,使得产品业务等非研发同学也能参与进模型的应用上来,借助其业务的敏感性和专业性,可以在更多的业务场景中创造进一步价值。

本期我们将为大家介绍,业务在模型使用中,两个比较典型的创新方法,一个是时效AB方法,带来了较好的货币化收益,一个是新特征挖掘,显著提升了模型准确率两个典型案例。

时效AB,货币化收益明显

得物非常重视消费者的购物体验,在时效上,也制定了赔付策略,为用户提供订单时效保障,超时则提供赔付补偿。这套策略提升了客户订单的转化,留存,但也对时效的保障提出了更高的要求,不仅要考虑时效的准确性,还需要兼顾赔付成本和GMV收益。如果时效表达过于激进,可能会促转化提高GMV,但同时增大了订单超时的风险,造成晚到必赔及客诉成本, 表达保守则反之。

那收益和成本之间的平衡点在哪儿呢?

产品同学由此牵头做了AB实验,通过仿真产品,我们得到多组履约率和准确率不同的组合,在不同用户AB过程中找到了收益和成本之间的平衡点。

收益成本平衡.jpg

新特征挖掘,显著提升模型准确性

业务产品在运营管理中发现,相对日常,周末、节假日期间,部分商家、承运商的网络履约能力会有一定波动,模型预测准确度也会受到影响。

分析后发现,网络履约能力的波动可以由一系列统计指标来刻画,比如昨日支付单量、昨日未揽收单量比例等。那这些指标能否让模型感知到呢?

通过仿真产品测算得到这些指标作为新特征,模型可以显著提升准确率,上线后也能保持相应优势,使得平台在周末、节假日期间也能提供高质量履约服务。

通过产品化降低的使用门槛,我们也期望未来会有更多有意思的场景与产品业务同学共同挖掘,创造更大的价值!

五、延展scale的思考

经过近1年的建设,供应链算法团队和时效团队配合紧密做了完善的工程建设。但从长远来看,供应链未来会有越来越多的场景要复用同样的能力,从短期来看,当前合作模式也存在一定局限。

  1. 算法模型发布灵活性要求更高,预处理、后处理变动灵活,而业务代码又期望稳定。
  2. 模型运营上对算法可见度较低,模型版本记录、当前上线陪跑了哪些模型对算法无法有效感知。
  3. 模型推理耦合在业务应用里,对机器成本提高了要求。业务逻辑对机器配置要求低,但因为算法模型对机器要求配置高,所以拉高了业务域的机器成本,不便运维降本。

因此在最近供应链算法工程小分队成立了,并且与AI计算平台团队强强联合,希望把业务域逻辑和算法逻辑解耦开来,让算法同学能更好地干预、运维模型,让更多场景可以低成本地接入MLOps标准。

MLops标准.jpg

如上图所示,让AI负责底层调度,供应链工程负责抽象公共能力,供应链算法和业务开发团队灵活地在各种场景落地。希望在不久的将来,我们有更多更好的故事可以与诸位分享,也欢迎各位业界大佬能加入我们团队!

文 / 凡飞

关注得物技术,每周更新技术干货,要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

二十万分之一几率:if语句变do-while卡死问题分析|得物技术

一、背景

某次灰度发布之后没多久就收到线上ANR告警,经排查定位到是某个页面onCreate方法执行太久导致,而火焰图中的耗时堆栈指向了我们用于监控页面启动速度的一段插桩代码,反编译Apk之后发现本该是if语句的代码竟变成了一个do-while语句,形成了死循环最终导致主线程卡死。

此后每构建二、三十次都会复现一次该问题,且每次的异常页面,异常方法完全随机。

1.jpg

二、问题分析

if和do-while两个完全不相干的语句为什么出现互相转化的情况?在jadx反编译而来的smali代码中不难看出,if语句对应的标签正常情况下应该指向的是return语句,和Java源码中if语句块后面紧跟着return语句对应。而异常情况下标签跑到了整个函数的开头,故被jadx翻译成了do-while,因此问题的关键就在这个label上面。

2.jpg

初步分析

出现此问题的这段插桩代码出自我们的APM页面启动监控,原本是插桩在Activity和Fragment的onCreate等关键生命周期中用于耗时统计。其所在的类是由我们自定义的插桩plugin weaver所生成(基于byteX开发的一个plugin,支持插入,代理和替换等自定义的插桩行为)。

因此我们要对从该plugin所在的byteX transform开始,直到最终产出dex文件的R8 transform结束这期间的所有transform挨个分析。

由于问题偶现,且每次异常的类和方法完全随机,说明大概率是一个多线程并发读写的问题,因此我们在分析过程中会需要重点关注涉及并发读写的逻辑。

分析R8

我们在输入给R8的jar包中找到了这个异常类的class文件,这里可以看到jadx反编译这个方法会失败,看class字节码中if语句跳转指向的标签L29,但是函数中并没有定义L29指向的是哪里,并且smali视图下查看可以看到if语句指向的标签在整个函数体中也没有声明,但是前面反编译DEX文件得到的结论是标签有声明但是在函数体的第一行,两者不一致,说明R8可能在执行过程中编辑了字节码导致异常。

(这里我们早期误以为标签丢失并不会导致语句变化这种程度的错误,因此直接将范围锁定在了R8,虽然后续证明了此问题与R8无关,但这段分析也为最终解开谜底提供了关键线索)

R81.jpg

R82.jpg

环境准备

R8目前已经不再单独提供jar包,而是一同打包在AGP中,且开启了混淆,因此想要调试/修改代码就需要自行clone源码,切到自己项目AGP版本对应的git tag来构建R8.jar并指定,具体操作可以参考R8的git仓库中描述:r8.googlesource.com/r8

阶段产物分析

目前的R8是由早期的D8融合了一系列的包体/性能优化的操作而来,dx负责将jar包整合压缩成DEX文件,它相对于后来新增的编译优化操作来说出现问题的概率更低,因此我们优先关注R8中涉及对字节码进行编辑的优化功能。

由于R8在输入了jar包之后一直在内存中进行操作,并无中间产物,因此我们需要在相关功能的开始结束点手动将内存中所有由自定义weaver plugin生成的class(有统一的后缀名)写到文件并保存。

阶段产物分析1.jpg

阶段产物分析2.jpg

在多次打包复现问题之后,对阶段产物进行分析并未发现异常方法的字节码有任何变动,直到dx这一步,我们发现if语句在class字节码中跳转到指定标签的行为,在dex文件的smali字节码中被编译成了跳转到指定的函数偏移量。

而之前class字节码中if语句指向的label找不到声明的问题,在smali中表现为直接将函数偏移量设为默认值0X00,正好是函数体的第一行,和一开始反编译apk得到的结果吻合,这也就解释了为什么if语句最终会变成一个do-while语句。

jd3.jpg

小结

至此,我们已经知晓为什么if语句会变成毫不相干的do-while语句,同时也排除了R8的嫌疑,接下来就是要继续回溯transform,排查为什么class字节码中if语句指向的标签的声明会丢失。

分析weaver

在回溯排查完所有途径transform的产物之后确认这个异常的方法在一开始weaver生成他时就已经是异常状态,因此问题范围锁定到此plugin。

在继续分析问题之前我们来了解下weaver的插桩原理:

weaver插桩原理

weaver基于byteX实现了一些自定义的插桩行为,这次出问题的是insert行为,也就是在目标函数开头插入代码的模式,其实现原理是预先写好要插桩的代码,在plugin执行期间会用ASM的classNode读取这个类,并将其中的方法复制到一个新建的内部类中,这个内部类会被添加到在注解中指定的目标类中,再在目标类的生命周期函数中调用这个内部类对应的方法即可完成对生命周期的插桩。

插桩1.jpg

走码分析

虽然我们已经确认是weaver在生成内部类中方法时出现异常,但是生成的过程是从0到1,此时再去加日志打印class字节码分析中间产物已经没有意义,并且由于其极低的复现概率,我们也无法在本地做调试分析。

遂走码分析,最后发现在从旧方法中复制方法提供给内部类的过程中,出现了ASM版本不一致的问题,由于整个byteX组件全局指定了ASM的版本是9,但是weaver中使用了ASM9的methodNode去clone出一个指定为ASM5的methodNode,但是很遗憾这并不是根因,在修正版本后依旧会复现问题。

走码1.jpg

走码2.jpg

我们目前已知的只有class字节码中if语句指向的label没有声明,遂猜测是methodNode的指令链表中丢失了labelNode,但添加了相应的检测逻辑之后并未命中,故排除labelNode丢失的可能。

关键线索缺失

前文中提到过推测这个问题和多线程有关,因此理论上在本地固定输入输出,并用大量线程并发死循环跑是能够复现问题从而debug找到根因的,但是苦于没有明确的检测逻辑,即不知道这个methodNode在什么状态下才算异常,哪怕问题复现了也无法断点。

逆向分析异常字节码

当务之急是找到合适的异常字节码检测手段,但是在常规思路都碰壁时,不妨用逆向思维试试,于是把异常的class文件直接用ASM的classNode类读取到内存,仔细观察异常方法和正常方法的指令链表中labelNode是否有什么不一样。最终发现异常methodNode的指令链表中,jumpNode持有的labelNode和链表中的labelNode不是同一个对象(正常情况下是)。

带着这个逆向得到的结论,再正向去验证他,即编码实现主动将某个方法的labelNode给替换成新的对象,再输出为class文件,发现和前面得到的异常class完全一致,至此我们就得到了一个准确的异常检测逻辑。

逆向字节码.jpg

带着前面得到的精准检测逻辑,我们在本地写demo开16线程并发,瞬间就复现了此问题,随后顺着这个线索走码也找到了问题根因。

这里使用我们正常运行时使用的forkjoinpool,并发死循环执行前面提到的methodNode复制过程,模拟正常构建过程的并发度,最终得出结果是大约每执行20w次可以复现一次问题,除以我们App中相关方法的量级,正好和之前约每20次~30次构建复现一次的频率吻合。

逆向字节码2.jpg

逆向字节码3.jpg

小结

至此我们已经定位到了引起问题的代码,也通过多种手段验证了根因就是多线程复制methodNode,但稳妥起见还是要刨根问底弄明白并发复制到底是怎么引起的labelNode对象被替换,防止还有更深层次的问题被掩盖。

揭露谜底

ASM方法复制原理

methodNode复制流程图如下:

method流程.jpg

ASM的methodNode类,通过其accept方法可以将这个方法复制给一个methodVIsitor,通常情况下只会使用一次,如果有1次以上的复制行为,就会在复制之前将指令链表中的labelNode中记录跳转地址的label对象置为null。

(clone方法理应是创建一个全新的对象,不应该和旧对象有任何共用的数据,ASM这里的处理没问题,但是没有适配多线程的情况)

method2.jpgmethod3.jpg

随后在指令复制的过程中,在遍历到jump指令(通过持有labelNode来形成指向关系)时,会通过getLabel方法将刚刚被置null的label对象重新new出来,同时再从新的label对象中new一个新的LabelNode交给新的JumpNode。

m4.jpgm5.jpgm6.jpgm7.jpg

等遍历到对应的LabelNode时,此时getLabel拿到的是刚刚new出来的新Label,同样的链路再走一遍,此时无需再new新的,并且新方法中的JumpNode持有的labelNode也和当前是一个对象。

m8.jpgm9.jpg

多线程问题根因

至此我们能得知在复制methodNode的过程中,针对labelNode有多次读写操作。而weaver为了加快执行速度,对每一个class都单独安排了一个task,全都提交给一个forkJoinPool来执行,并且按照前面介绍的weaver插桩原理,提前写好的这个类里的方法,总计会复制成千上万次,提供给每一个Activity的内部类。因此在多线程高并发执行时就会出现以下顺序:

线程1.jpg

这样最终就会出现jumpNode持有的LabelNode和指令链表中的LabelNode不一致的问题。

三、修复方案

ASM为了规避同一个methodNode在多次复制时,复制出来的新methodNode的labelNode全都指向同一个对象的问题,加了这个resetLabel的标签重置逻辑,但是并没有考虑到多线程并发执行的场景,因此该问题最终加一个类锁即可解决,放那已上线验证有效。

修复方案.jpg

四、总结

这类多线程引起的字节码异常问题潜伏期可达到数年之久,例如本文遇到的问题在App的页面量级较低时几乎不会触发,但随着App的业务规模增长,又或是打包机器的一次升级换代,问题就会悄然出现,而他极低的复现概率和随机性又很容易使其被忽视。

字节码异常问题在互联网鲜有参考资料,倘若字节码损坏直接崩溃还则罢了,遇到这种恰巧能被当成其他语句继续执行的情况分析起来着实麻烦。因此开发插桩这类涉及代码编辑操作的plugin,针对"写”操作务必要慎重开发,重点测试下极端并发的场景。这类问题如果是发生在定时大量推送的活动页或者热修sdk之类稳定性兜底的功能,其危害可想而知。

文 / Jordas

关注得物技术,每周更新技术干货,要是觉得文章对你有帮助的话,欢迎评论转发点赞~未经得物技术许可严禁转载,否则依法追究法律责任。

❌