2023年3月

背景

早上收到某系统的告警tidb节点挂掉无法访问,情况十万火急。登录中控机查了一下display信息,4个TiDB、Prometheus、Grafana全挂了,某台机器hang死无法连接,经过快速重启后集群恢复,经排查后是昨天上线的某个SQL导致频繁OOM。

企业微信截图_20230316113735.png

于是开始亡羊补牢,来一波近期慢SQL巡检 #手动狗头#。。。

随便找了一个出现频率比较高的慢SQL,经过优化后竟然性能提升了1500倍以上,感觉有点东西,分享给大家。

分析过程

该慢SQL逻辑非常简单,就是一个单表聚合查询,但是耗时达到8s以上,必有蹊跷。

脱敏后的SQL如下:

SELECT
    cast( cast( CAST( SUM( num ) / COUNT( time ) AS CHAR ) AS DECIMAL ( 9, 2 )) AS signed ) speed,
    ... -- 此处省略n个字段
FROM
    (
    SELECT 
        DATE_FORMAT( receive_time, '%Y-%m-%d %H:%i:00' ) AS time,
        COUNT(*) AS num 
    FROM
        db1.table 
    WHERE
        create_time > DATE_SUB( sysdate(), INTERVAL 20 MINUTE ) 
    GROUP BY
        time 
    ORDER BY
    time 
    ) speed;

碰到慢SQL不用多想,第一步先上执行计划:

企业微信截图_20230316150702.png

很明显,这张900多万行的表因为创建了TiFlash副本,在碰到聚合运算的时候优化器选择了走列存查询,最终结果就是在TiFlash完成暴力全表扫描、排序、分组、计算等一系列操作,返回给TiDB Server时基本已经加工完成,总共耗时8.02s。

咋一看好像没啥优化空间,但仔细观察会发现一个不合理的地方。执行计划倒数第二排的Selection算子,也就是SQL里面子查询的where过滤,实际有效数据1855行,却扫描了整个表接近950W行,这是一个典型的适合索引加速的场景。但遗憾的是,在TiFlash里面并没有索引的概念,所以只能默默地走全表扫描。

那么优化的第一步,先看过滤字段是否有索引,通常来说
create_time
这种十有八九都建过索引,检查后发现确实有。

第二步,尝试让优化器走TiKV查询,这里直接使用hint的方式:

SELECT /*+ READ_FROM_STORAGE(TIKV[db1.table]) */
    cast( cast( CAST( SUM( num ) / COUNT( time ) AS CHAR ) AS DECIMAL ( 9, 2 )) AS signed ) speed,
    ... -- 此处省略n个字段
FROM
    (
    SELECT 
        DATE_FORMAT( receive_time, '%Y-%m-%d %H:%i:00' ) AS time,
        COUNT(*) AS num 
    FROM
        db1.table 
    WHERE
        create_time > DATE_SUB( sysdate(), INTERVAL 20 MINUTE ) 
    GROUP BY
        time 
    ORDER BY
    time 
    ) speed;

再次生成执行计划,发现还是走了TiFlash查询。
这里就引申出一个重要知识点,关于hint作用域的问题,也就是说hint只能在指定的查询范围内生效。
具体到上面这个例子,虽然指定了
db1.table
走TiKV查询,但是对于它所在的查询块来说,压根不知道
db1.table
是谁直接就忽略掉了。所以正确的写法是把hint写到子查询中:

SELECT
    cast( cast( CAST( SUM( num ) / COUNT( time ) AS CHAR ) AS DECIMAL ( 9, 2 )) AS signed ) speed,
    ... -- 此处省略n个字段
FROM
    (
    SELECT  /*+ READ_FROM_STORAGE(TIKV[db1.table]) */
        DATE_FORMAT( receive_time, '%Y-%m-%d %H:%i:00' ) AS time,
        COUNT(*) AS num 
    FROM
        db1.table 
    WHERE
        create_time > DATE_SUB( sysdate(), INTERVAL 20 MINUTE ) 
    GROUP BY
        time 
    ORDER BY
    time 
    ) speed;

对应的执行计划为:

企业微信截图_20230316153949.png

小提示:

也可以通过
set session tidb_isolation_read_engines = 'tidb,tikv';
来让优化器走tikv查询。

发现这次虽然走了TiKV查询,但还是用的
TableFullScan
算子,整体时间不降反升,和我们预期的有差距。

没走索引那肯定是和查询字段有关系,分析上面SQL的逻辑,开发是想查询table表创建时间在最近20分钟的数据,用了一个
sysdate()
函数获取当前时间,问题就出在这。

获取当前时间常用的函数有
now()

sysdate()
,但这两者是有明显区别的。引用自官网的解释:

  • now()
    得到的是语句开始执行的时间,是一个固定值
  • sysdate()
    得到的是该函数实际执行的时间,是一个动态值

听起来比较饶,来个栗子一看便知:

mysql> select now(),sysdate(),sleep(3),now(),sysdate();
+---------------------+---------------------+----------+---------------------+---------------------+
| now()               | sysdate()           | sleep(3) | now()               | sysdate()           |
+---------------------+---------------------+----------+---------------------+---------------------+
| 2023-03-16 15:55:18 | 2023-03-16 15:55:18 |        0 | 2023-03-16 15:55:18 | 2023-03-16 15:55:21 |
+---------------------+---------------------+----------+---------------------+---------------------+
1 row in set (3.06 sec)

这个动态时间就意味着TiDB优化器在估算的时候并不知道它是个什么值,走索引和不走索引哪个成本更高,最终导致索引失效。

从业务上来看,这个SQL用
now()

sysdate()
都可以,那么就尝试改成
now()
看看效果:

SELECT
    cast( cast( CAST( SUM( num ) / COUNT( time ) AS CHAR ) AS DECIMAL ( 9, 2 )) AS signed ) speed,
    ... -- 此处省略n个字段
FROM
    (
    SELECT  /*+ READ_FROM_STORAGE(TIKV[db1.table]) */
        DATE_FORMAT( receive_time, '%Y-%m-%d %H:%i:00' ) AS time,
        COUNT(*) AS num 
    FROM
        db1.table 
    WHERE
        create_time > DATE_SUB( now(), INTERVAL 20 MINUTE ) 
    GROUP BY
        time 
    ORDER BY
    time 
    ) speed;

企业微信截图_20230316160428.png

最终结果4.43ms搞定,从8.02s到4.43ms,1800倍的提升。

滥用函数,属于是开发给自己挖的坑了。

解决方案

经过以上分析,优化思路已经很清晰了,甚至都是常规优化不值得专门拿出来讲,但前后效果差异太大,很适合作为一个反面教材来提醒大家认真写SQL。

其实就两点:

  • 让优化器不要走TiFlash查询,改走TiKV,可通过hint或SQL binding解决
  • 非必须不要使用动态时间,避免带来索引失效的问题

深度思考

优化完成之后,我开始思考优化器走错执行计划的原因。

在最开始的执行计划当中,优化器对Selection算子的估算值estRows和实际值actRows相差非常大,再加上本身计算和聚合比较多,这可能是导致误走TiFlash的原因之一。不清楚TiFlash的estRows计算原理是什么,如果在估算准确的情况并且索引正常的情况下会不会走TiKV呢?

另外,我还怀疑过动态时间导致优化器判断失误(认为索引失效才选择走TiFlash),但是在尝试只修改
sysdate()

now()
的情况下,发现依然走了TiFlash,说明这个可能性不大。

在索引字段没问题的时候,按正常逻辑来说,我觉得一个成熟的优化器应该要能够判断出这种场景走TiKV更好。

总结

TiFlash虽然是个好东西,但是优化器还在进化当中,难免有判断失误的时候,那么会导致适得其反的效果,我们要及时通过人工手段介入。再给TiDB优化器一些时间。

良好的SQL习惯至关重要,这也是老生常谈的问题了,再好的数据库也扛不住乱造的SQL。

作者介绍:hey-hoho,来自神州数码钛合金战队,是一支致力于为企业提供分布式数据库TiDB整体解决方案的专业技术团队。团队成员拥有丰富的数据库从业背景,全部拥有TiDB高级资格证书,并活跃于TiDB开源社区,是官方认证合作伙伴。目前已为10+客户提供了专业的TiDB交付服务,涵盖金融、证券、物流、电力、政府、零售等重点行业。

深入理解 python 虚拟机:pyc 文件结构

在本篇文章当中主要给大家介绍一下 .py 文件在被编译之后对应的 pyc 文件结构,pyc 文件当中的一个核心内容就是 python 字节码。

pyc 文件

pyc 文件是 Python 在解释执行源代码时生成的一种字节码文件,它包含了源代码的编译结果和相关的元数据信息,以便于 Python 可以更快地加载和执行代码。

Python 是一种解释型语言,它不像编译型语言那样将源代码直接编译成机器码执行。Python 的解释器会在运行代码之前先将源代码编译成字节码,然后将字节码解释执行。.pyc 文件就是这个过程中生成的字节码文件。

当 Python 解释器首次执行一个 .py 文件时,它会在同一目录下生成一个对应的 .pyc 文件,以便于下次加载该文件时可以更快地执行。如果源文件在修改之后被重新加载,解释器会重新生成 .pyc 文件以更新缓存的字节码。

生成 pyc 文件

正常的 python 文件需要通过编译器变成字节码,然后将字节码交给 python 虚拟机,然后 python 虚拟机会执行字节码。整体流程如下所示:

我们可以直接使用 compile all 模块生成对应文件的 pyc 文件。

➜  pvm ls
demo.py  hello.py
➜  pvm python -m compileall .
Listing '.'...
Listing './.idea'...
Listing './.idea/inspectionProfiles'...
Compiling './demo.py'...
Compiling './hello.py'...
➜  pvm ls
__pycache__ demo.py     hello.py
➜  pvm ls __pycache__ 
demo.cpython-310.pyc  hello.cpython-310.pyc

python -m compileall .
命令将递归扫描当前目录下面的 py 文件,并且生成对应文件的 pyc 文件。

pyc 文件布局

第一部分魔数由两部分组成:

第一部分 魔术是由一个 2 字节的整数和另外两个字符回车换行组成的, "\r\n" 也占用两个字节,一共是四个字节。这个两个字节的整数在不同的 python 版本还不一样,比如说在 python3.5 当中这个值为 3351 等值,在 python3.9 当中这个值为 3420,3421,3422,3423,3424等值(在 python 3.9 的小版本)。

第二部分 Bit Field 这个字段的主要作用是为了将来能够实现复现编译结果,但是在 python3.9a2 时,这个字段的值还全部是 0 。详细内容可以参考
PEP552-Deterministic pycs
。这个字段在 python2 和 python3 早期版本并没有(python3.5 还没有),在 python3 的后期版本这个字段才出现的。

第三部分 就是整个 py 源文件的大小了。

第四部分 也是整个 pyc 文件当中最重要的一个部分,最后一个部分就是一个 CodeObject 对象序列化之后的数据,我们稍后再来仔细分析一下这个对象相关的数据。

我们现在来具体分析一个 pyc 文件,对应的 python 代码为:

def f():
    x = 1
    return 2

pyc 文件的十六进制形式如下所示:

➜  __pycache__ hexdump -C hello.cpython-310.pyc
00000000  6f 0d 0d 0a 00 00 00 00  b9 48 21 64 20 00 00 00  |o........H!d ...|
00000010  e3 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00000020  00 02 00 00 00 40 00 00  00 73 0c 00 00 00 64 00  |.....@...s....d.|
00000030  64 01 84 00 5a 00 64 02  53 00 29 03 63 00 00 00  |d...Z.d.S.).c...|
00000040  00 00 00 00 00 00 00 00  00 01 00 00 00 01 00 00  |................|
00000050  00 43 00 00 00 73 08 00  00 00 64 01 7d 00 64 02  |.C...s....d.}.d.|
00000060  53 00 29 03 4e e9 01 00  00 00 e9 02 00 00 00 a9  |S.).N...........|
00000070  00 29 01 da 01 78 72 03  00 00 00 72 03 00 00 00  |.)...xr....r....|
00000080  fa 0a 2e 2f 68 65 6c 6c  6f 2e 70 79 da 01 66 01  |.../hello.py..f.|
00000090  00 00 00 73 04 00 00 00  04 01 04 01 72 06 00 00  |...s........r...|
000000a0  00 4e 29 01 72 06 00 00  00 72 03 00 00 00 72 03  |.N).r....r....r.|
000000b0  00 00 00 72 03 00 00 00  72 05 00 00 00 da 08 3c  |...r....r......<|
000000c0  6d 6f 64 75 6c 65 3e 01  00 00 00 73 02 00 00 00  |module>....s....|
000000d0  0c 00                                             |..|
000000d2

因为数据使用小端表示方式,因此对于上面的数据来说:

  • 第一部分魔数为:0xa0d0d6f 。
  • 第二部分 Bit Field 为:0x0 。
  • 第三部分最后一次修改日期为:0x642148b9 。
  • 第四部分文件大小为:0x20 字节,也就是说 hello.py 这个文件的大小是 32 字节。

下面是一个小的代码片段用于读取 pyc 文件的头部元信息:

import struct
import time
import binascii


fname = "./__pycache__/hello.cpython-310.pyc"
f = open(fname, "rb")
magic = struct.unpack('<l', f.read(4))[0]
bit_filed = f.read(4)
print(f"bit field = {binascii.hexlify(bit_filed)}")
moddate = f.read(4)
filesz = f.read(4)
modtime = time.asctime(time.localtime(struct.unpack('<l', moddate)[0]))
filesz = struct.unpack('<L', filesz)
print("magic %s" % (hex(magic)))
print("moddate (%s)" % (modtime))
print("File Size %d" % filesz)
f.close()

上面的代码输出结果如下所示:

bit field = b'00000000'
magic 0xa0d0d6f
moddate (Mon Mar 27 15:41:45 2023)
File Size 32

有关 pyc 文件的详细操作可以查看 python 标准库 importlib/_bootstrap_external.py 文件源代码。

CodeObject

在 CPython 中,
CodeObject
是一个对象,它包含了 Python 代码的字节码、常量、变量、位置参数、关键字参数等信息,以及一些用于运行代码的元数据,如文件名、代码行号等。

在 CPython 中,当我们执行一个 Python 模块或函数时,解释器会先将其代码编译为
CodeObject
,然后再执行。在编译过程中,解释器会将 Python 代码转换为字节码,并将其保存在
CodeObject
对象中。此后,每当我们调用该模块或函数时,解释器都会使用
CodeObject
中的字节码来执行代码。

CodeObject
对象是不可变的,一旦创建就不能被修改。这是因为 Python 代码的字节码是不可变的,而
CodeObject
对象包含了这些字节码,所以也是不可变的。

在本篇文章当中主要介绍 code object 当中主要的内容,以及简单介绍他们的作用,在后续的文章当中会仔细分析 code object 对应的源代码以及对应的字段的详细作用。

现在举一个例子来分析一下 pycdemo.py 的 pyc 文件,pycdemo.py 的源程序如下所示:


if __name__ == '__main__':
    a = 100
    print(a)

下面的代码是一个用于加载 pycdemo01.cpython-39.pyc 文件(也就是 hello.py 对应的 pyc 文件)的代码,使用 marshal 读取 pyc 文件里面的 code object 。

import marshal
import dis
import struct
import time
import types
import binascii


def print_metadata(fp):
    magic = struct.unpack('<l', fp.read(4))[0]
    print(f"magic number = {hex(magic)}")
    bit_field = struct.unpack('<l', fp.read(4))[0]
    print(f"bit filed = {bit_field}")
    t = struct.unpack('<l', fp.read(4))[0]
    print(f"time = {time.asctime(time.localtime(t))}")
    file_size = struct.unpack('<l', fp.read(4))[0]
    print(f"file size = {file_size}")


def show_code(code, indent=''):
    print ("%scode" % indent)
    indent += '   '
    print ("%sargcount %d" % (indent, code.co_argcount))
    print ("%snlocals %d" % (indent, code.co_nlocals))
    print ("%sstacksize %d" % (indent, code.co_stacksize))
    print ("%sflags %04x" % (indent, code.co_flags))
    show_hex("code", code.co_code, indent=indent)
    dis.disassemble(code)
    print ("%sconsts" % indent)
    for const in code.co_consts:
        if type(const) == types.CodeType:
            show_code(const, indent+'   ')
        else:
            print("   %s%r" % (indent, const))
    print("%snames %r" % (indent, code.co_names))
    print("%svarnames %r" % (indent, code.co_varnames))
    print("%sfreevars %r" % (indent, code.co_freevars))
    print("%scellvars %r" % (indent, code.co_cellvars))
    print("%sfilename %r" % (indent, code.co_filename))
    print("%sname %r" % (indent, code.co_name))
    print("%sfirstlineno %d" % (indent, code.co_firstlineno))
    show_hex("lnotab", code.co_lnotab, indent=indent)


def show_hex(label, h, indent):
    h = binascii.hexlify(h)
    if len(h) < 60:
        print("%s%s %s" % (indent, label, h))
    else:
        print("%s%s" % (indent, label))
        for i in range(0, len(h), 60):
            print("%s   %s" % (indent, h[i:i+60]))


if __name__ == '__main__':
    filename = "./__pycache__/pycdemo01.cpython-39.pyc"
    with open(filename, "rb") as fp:
        print_metadata(fp)
        code_object = marshal.load(fp)
        show_code(code_object)

执行上面的程序输出结果如下所示:

magic number = 0xa0d0d61
bit filed = 0
time = Tue Mar 28 02:40:20 2023
file size = 54
code
   argcount 0
   nlocals 0
   stacksize 2
   flags 0040
   code b'650064006b02721464015a01650265018301010064025300'
  3           0 LOAD_NAME                0 (__name__)
              2 LOAD_CONST               0 ('__main__')
              4 COMPARE_OP               2 (==)
              6 POP_JUMP_IF_FALSE       20

  4           8 LOAD_CONST               1 (100)
             10 STORE_NAME               1 (a)

  5          12 LOAD_NAME                2 (print)
             14 LOAD_NAME                1 (a)
             16 CALL_FUNCTION            1
             18 POP_TOP
        >>   20 LOAD_CONST               2 (None)
             22 RETURN_VALUE
   consts
      '__main__'
      100
      None
   names ('__name__', 'a', 'print')
   varnames ()
   freevars ()
   cellvars ()
   filename './pycdemo01.py'
   name '<module>'
   firstlineno 3
   lnotab b'08010401'

下面是 code object 当中各个字段的作用:

  • 首先需要了解一下代码块这个概念,所谓代码块就是一个小的 python 代码,被当做一个小的单元整体执行。在 python 当中常见的代码块块有:函数体、类的定义、一个模块。

  • argcount,这个表示一个代码块的参数个数,这个参数只对函数体代码块有用,因为函数可能会有参数,比如上面的 pycdemo.py 是一个模块而不是一个函数,因此这个参数对应的值为 0 。

  • co_code,这个对象的具体内容就是一个字节序列,存储真实的 python 字节码,主要是用于 python 虚拟机执行的,在本篇文章当中暂时不详细分析。

  • co_consts,这个字段是一个列表类型的字段,主要是包含一些字符串常量和数值常量,比如上面的 "__main__" 和 100 。

  • co_filename,这个字段的含义就是对应的源文件的文件名。

  • co_firstlineno,这个字段的含义为在 python 源文件当中第一行代码出现的行数,这个字段在进行调试的时候非常重要。

  • co_flags,这个字段的主要含义就是标识这个 code object 的类型。0x0080 表示这个 block 是一个协程,0x0010 表示这个 code object 是嵌套的等等。

  • co_lnotab,这个字段的含义主要是用于计算每个字节码指令对应的源代码行数。

  • co_varnames,这个字段的主要含义是表示在一个 code object 本地定义的一个名字。

  • co_names,和 co_varnames 相反,表示非本地定义但是在 code object 当中使用的名字。

  • co_nlocals,这个字段表示在一个 code object 当中本地使用的变量个数。

  • co_stackszie,因为 python 虚拟机是一个栈式计算机,这个参数的值表示这个栈需要的最大的值。

  • co_cellvars,co_freevars,这两个字段主要和嵌套函数和函数闭包有关,我们在后续的文章当中将详细解释这个字段。

总结

在本篇文章当中主要给大家介绍了 python 文件被编译之后的结果文件 .pyc 文件结构,在 pyc 文件当中一个最重要的结构就是 code object 对象,在本篇文章当中主要是简单介绍了 code object 各个字段的作用。在后续的文章当中将会举详细的例子进行说明,正确理解这些这些字段的含义,对于我们理解 python 虚拟机大有裨益。


本篇文章是深入理解 python 虚拟机系列文章之一,文章地址:
https://github.com/Chang-LeHung/dive-into-cpython

更多精彩内容合集可访问项目:
https://github.com/Chang-LeHung/CSCore

关注公众号:一无是处的研究僧,了解更多计算机(Java、Python、计算机系统基础、算法与数据结构)知识。

golang pprof监控系列(2) —— memory,block,mutex 使用

大家好,我是蓝胖子。

profile的中文被翻译轮廓,对于计算机程序而言,抛开业务逻辑不谈,它的轮廓是是啥呢?不就是cpu,内存,各种阻塞开销,线程,协程概况 这些运行指标或环境。golang语言自带了工具库来帮助我们描述,探测,分析这些指标或者环境信息,让我们来学习它。

在上一篇
golang pprof 监控系列(1) —— go trace 统计原理与使用
里我讲解了下golang trace 工具的统计原理,它能够做到对程序运行中的各种事件进行耗时统计。而今天要将的memory,block,mutex 统计的原理与之不同,这是一个在一段时间内进行采样得到的累加值。

还记得之前用go trace 生成的网页图吗?

trace 网页显示

里面是不是也有 3个名字带有blocking的 profile的轮廓图,分别是Network blocking profile,Synchronization blocking profile,Syscall blocking profile ,而它与今天要说的block 统计有没有什么关联?
先说下结论,block统计的内容有重合,但是不多,并且统计数据来源是不同的

Synchronization blocking profile 和 今天的block统计 是一致的

然后之所以 memory,block,mutex 把这3类数据放在一起讲,是因为他们统计的原理是很类似的,好的,在了解统计原理之前,先简单看下如何使用golang提供的工具对这些数据类型进行分析。

如何使用

在看使用代码前,还需要了解清楚对这3种类型的指标对哪些数据进行统计。

两种方式都比较常见,首先来看下http 接口暴露这些性能指标的方式。

http 接口暴露的方式

package main
import (
	"log"
		"net/http"
	_ "net/http/pprof"
)
func main() {
		log.Println(http.ListenAndServe(":6060", nil))
}

使用方式相当容易,直接代码引入net/http/pprof ,便在默认的http处理器上注册上了相关路由,引入包的目的就是为了调用对应包的init方法注册路由。下面就是引用包的init方法。

// src/net/http/pprof/pprof.go:80
func init() {
	http.HandleFunc("/debug/pprof/", Index)
	http.HandleFunc("/debug/pprof/cmdline", Cmdline)
	http.HandleFunc("/debug/pprof/profile", Profile)
	http.HandleFunc("/debug/pprof/symbol", Symbol)
	http.HandleFunc("/debug/pprof/trace", Trace)
}

接下来访问路由
http://127.0.0.1:6060/debug/pprof/
便能看到下面网页内容了。

image.png

标注为红色的部分就是今天要讲的内容,点击它们的链接会跳到对应的网页看到统计信息。我们挨个来看下。

allocs ,heap

这两个值都是记录程序内存分配的情况。

heap profile: 7: 5536 [110: 2178080] @ heap/1048576
2: 2304 [2: 2304] @ 0x100d7e0ec 0x100d7ea78 0x100d7f260 0x100d7f78c 0x100d811cc 0x100d817d4 0x100d7d6dc 0x100d7d5e4 0x100daba20
#	0x100d7e0eb	runtime.allocm+0x8b		/Users/lanpangzi/goproject/src/go/src/runtime/proc.go:1881
#	0x100d7ea77	runtime.newm+0x37		/Users/lanpangzi/goproject/src/go/src/runtime/proc.go:2207
#	0x100d7f25f	runtime.startm+0x11f		/Users/lanpangzi/goproject/src/go/src/runtime/proc.go:2491
#	0x100d7f78b	runtime.wakep+0xab		/Users/lanpangzi/goproject/src/go/src/runtime/proc.go:2590
#	0x100d811cb	runtime.resetspinning+0x7b	/Users/lanpangzi/goproject/src/go/src/runtime/proc.go:3222
#	0x100d817d3	runtime.schedule+0x2d3		/Users/lanpangzi/goproject/src/go/src/runtime/proc.go:3383
#	0x100d7d6db	runtime.mstart1+0xcb		/Users/lanpangzi/goproject/src/go/src/runtime/proc.go:1419
#	0x100d7d5e3	runtime.mstart0+0x73		/Users/lanpangzi/goproject/src/go/src/runtime/proc.go:1367
#	0x100daba1f	runtime.mstart+0xf		/Users/lanpangzi/goproject/src/go/src/runtime/asm_arm64.s:117

在 go1.17.12 这两者的信息输出其实是一样的,实现的代码也基本一致,仅仅是在名称上做了区分

image.png

按pprof http服务器启动网页上的显示来说,allocs 是过去内存的采样,heap 是存活对象的采用记录,然而实际上在代码实现上两者是一致的。并没有做区分。

下面来讲下网页输出内容的含义

heap profile: 7: 5536 [110: 2178080] @ heap/1048576

输出的第一行含义分别是:
7 代表 当前活跃的对象个数
5536 代表 当前活跃对象占用的字节数
110 代表 所有(包含历史的对象)对象个数
2178080 代表 所有对象(包含历史的对象)占用的对象字节数
1048576 控制了内存采样的频率,1048576 是两倍的内存采样频率的大小,默认采样频率是512kb 即平均每512kb就会采样一次,注意这个值512kb不是绝对的达到512kb就会进行采样,而是从一段时间内的采样来看的一个平均值。

接下来就是函数调用堆栈信息,来看第一行

2: 2304 [2: 2304] @ 0x100d7e0ec 0x100d7ea78 0x100d7f260 0x100d7f78c 0x100d811cc 0x100d817d4 0x100d7d6dc 0x100d7d5e4 0x100daba20

从左往右看:
2 代表 在该函数栈上当前活跃的对象个数
2304 代表 在该函数栈上当前活跃的对象所占用的字节数
方括号内的2 代表 在该函数栈上所有(包含历史的对象)对象个数
方括号内的2304 代表 在该函数栈上所有(包含历史的对象)对象所占用的字节数

然后是栈上pc寄存器的值。再往后就是具体的栈函数名信息了。

block

接下来看看block会对哪些行为产生记录,每次程序锁阻塞发生时,select 阻塞,channel通道阻塞,wait group 产生阻塞时就会记录一次阻塞行为。对阻塞行为的记录其实是和trace 的Synchronization blocking profile 是一致的,但是和其他两个blocking profile 不一样。

要得到block的输出信息首先要开启下记录block的开关.

   // 1 代表 全部采样,0 代表不进行采用, 大于1则是设置纳秒的采样率
	runtime.SetBlockProfileRate(1)

这个采样率是怎样计算的,我们来看下具体代码。

// src/runtime/mprof.go:409
func blocksampled(cycles, rate int64) bool {
	if rate <= 0 || (rate > cycles && int64(fastrand())%rate > cycles) {
		return false
	}
	return true
}

cycles你可以把它理解成也是一个纳秒级的事件,rate就是我们设置的纳秒时间,如果 cycles大于等于rate则直接记录block行为,如果cycles小于rate的话,则需要fastrand函数乘以设置的纳秒时间rate 来决定是否采样了。

然后回过头来看看网页的输出信息

--- contention:
cycles/second=1000000000
180001216583 1 @ 0x1002a1198 0x1005159b8 0x100299fc4
#	0x1002a1197	sync.(*Mutex).Lock+0xa7	/Users/lanpangzi/goproject/src/go/src/sync/mutex.go:81
#	0x1005159b7	main.main.func2+0x27	/Users/lanpangzi/goproject/src/go/main/main.go:33

contention 是为这个profile文本信息取的名字,总之中文翻译是争用。

cycles/second 是每秒钟的周期数,用它来表示时间也是为了更精确,其实你可以发现在我的机器上每秒是10的9次方个周期,换算成纳秒就是1ns一个周期时间了。

接着的180001216583 是阻塞的周期数,其实周期就是cputicks,那么180001216583除以 cycles/second 1000000000得到的就是阻塞的秒数了。

接着 1代表阻塞的次数。

无论是阻塞周期时长还是次数,都是一个累加值,即在相同的地方阻塞会导致这个值变大,并且次数会增加。剩下的部分就是函数堆栈信息了。

mutex

接着来看mutex相关内容,block也在锁阻塞时记录阻塞行为,那么mutex与它有什么不同?

直接说下结论,mutex是在解锁unlock时才会记录一次阻塞行为,而block在记录mutex锁阻塞信息时,是在开始执行lock调用的时候记录的。

要想记录mutex信息,和block类似,也需要开启mutex采样开关。

   // 0 代表不进行采用, 1则全部采用,大于1则是一个随机采用
   	runtime.SetMutexProfileFraction(1)

来看看采样的细节代码,代码版本go1.17.12

if rate > 0 && int64(fastrand())%rate == 0 {
		saveblockevent(cycles, rate, skip+1, mutexProfile)
	}

可以看到fastrand() 与rate取模等于0才会去采样,rate如果设置成1,则任何数与整数与1取模都会得到0,所以设置为1为完全采用,大于1比如rate设置为2,则要求fastrand必须是2的倍数才能被采样。

接着来看下网页的输出信息。

--- mutex:
cycles/second=1000000812
sampling period=1
180001727833 1 @ 0x100b9175c 0x100e05840 0x100b567ec 0x100b89fc4
#	0x100b9175b	sync.(*Mutex).Unlock+0x8b	/Users/lanpangzi/goproject/src/go/src/sync/mutex.go:190
#	0x100e0583f	main.main+0x19f			/Users/lanpangzi/goproject/src/go/main/main.go:39
#	0x100b567eb	runtime.main+0x25b		/Users/lanpangzi/goproject/src/go/src/runtime/proc.go:255

第一行mutex就是profile文本信息的名称了,同样也和block一样,采用cpu周期数计时,但是多了一个sampling period ,这个就是我们设置的采用频率。

接下来的数据都和block类似,180001727833就是锁阻塞的周期, 1为解锁的次数。然后是解锁的堆栈信息。

介绍完利用http服务查看pprof性能指标的方式来看看,如何用代码来生成这些pprof的二进制或者文本文件。

代码生成profile文件的方式

首先来看看如何对内存信息生成pprof文件。

os.Remove("heap.out")
	f, _ := os.Create("heap.out")
	defer f.Close()

	err := pprof.Lookup("heap").WriteTo(f, 1)
	if err != nil {
		log.Fatal(err)
	}

lookup 传递的heap参数也可以改成allocs,不过输出的内容是一致的。

WriteTo 第二个参数叫做debug,传1代表 会生成可读的文本文件,就和http服务看heap allocs的网页一样的。传0代表是要生成一个二进制文件。生成的二进制文件可以用go tool pprof pprof文件名 去分析,关于go tool pprof 工具的使用网上有相当多的资料,这里就不再展开了。

然后来看看如何生成block的pprof文件。

runtime.SetBlockProfileRate(1)
	os.Remove("block.out")
	f, _ := os.Create("block.out")
	defer f.Close()

	err := pprof.Lookup("block").WriteTo(f, 1)
	if err != nil {
		log.Fatal(err)
	}

pprof.Lookup方法传递block即可生成文件了,使用方式和生成内存分析文件一致,,传1代表 会生成可读的文本文件,就和http服务看block的网页一样的。传0代表是要生成一个二进制文件。生成的二进制文件可以用go tool pprof pprof文件名 去分析。

最后看看mutex,和前面两者基本一致仅仅是Lookup传递的参数有变化,这里就不再叙述了。

	runtime.SetMutexProfileFraction(1)
	os.Remove("mutex.out")
	f, _ := os.Create("mutex.out")
	defer f.Close()

	err := pprof.Lookup("mutex").WriteTo(f, 1)
	if err != nil {
		log.Fatal(err)
	}

总结

经过上述的讲解,应该是能够明白相关的指标究竟是采集了哪些内容并且何时采集的了,而go tool pprof 工具的使用不是此文的重点,所以我直接省略掉了,也是没想到即使省略了,还是内容有那么多,那就把这部分的统计原理留到下一篇文章了,下一篇,memory,block,mutex 统计的原理介绍。

一. 平台对业务敏捷支撑的挑战

早期阿里的交易中台遇到了一些挑战,这个在毗卢的博客中有提到,主要遇到了这些问题:新小业务都有一个成长规律,在早期业务模式验证阶段,需要的玩法比较简单,希望能频繁的发布快速试错。我们以电商领域为例,在成熟的电商体系下,有众多复杂、庞大的平台,如交易平台、商品平台、营销平台等。虽然这些平台对于天猫、淘宝各种业务模式、玩法都支撑得很好,但对于新小业务而言,这些模式、玩法在早期并不适用,他们希望平台能按照自己的要求做一些裁剪。

  • 早期的交易平台的下单流程是硬编码方式写死的。整个流程,无论什么业务,都必须要与优惠系统、物流系统、卡券平台等系统对接。新业务开发时,必须要确保整个下单过程与这些服务的集成不会出现调用异常,一旦有异常出现,就需要与相关系统协调看如何屏蔽错误。
  • 商品单价计算逻辑也没有很好在平台中得到解耦,也没有提供可扩展机制。汽车金融业务的商品单价是来源于支付宝的微贷系统。最终,该业务也只能以硬编码方式,在平台计算商品单价的地方,与其他业务的代码混在一起去编写相关计算逻辑。这部分代码因为可能会影响到其他业务,所以代码在发布上线前,必须经过严格的全网回归验证,以确保不会对其他业务产生影响以及资损。
  • 汽车金融业务的发布节奏因此也必须跟随平台版本的节奏,只能耐心的等待每周一次的全网回归验证后,才能进行发布,无法按自己的意愿随时发布。
    以上内容来自:
    https://www.ryu.xin

有赞交易遇到的瓶颈
早期的有赞交易也遇到类似的问题,17年以后公司迅速发展壮大,团队规模扩张了数倍,业务线也从单一的微商城电商交易扩展到多个垂直行业。然而,交易团队却面临了许多尴尬的情况:

首先是垂直行业的接入问题,由于交易团队的响应速度无法跟上业务团队的迭代速度,垂直行业被迫自行开发了一套针对自身业务的交易系统,导致公司技术资源浪费。这包括商业化服务订购、收银、批发等业务。

其次,交易团队新加入的成员发现交易核心开发非常困难,必须先完全理解整个系统,才敢尝试开发,这增加了学习成本。

另外,许多交易玩法无法组合,如果想要组合,就必须重新编码,将组合方案作为一个新方案进行开发,例如虚拟商品的拼团业务、酒店的分销业务等。这限制了产品运营同学创意想法的快速实现。

最后,一个小需求的上线可能会导致核心交易系统大面积故障,影响无法隔离。

二. 基于可复用模式的平台支持:加速业务发展

在现代企业架构中,企业级业务架构规划已经关注面向能力的规划,超越了面向功能与服务的规划。如何通过能力的识别和规划来最大化沉淀企业级可复用能力,以及如何将这些能力应用到更多的场景中,通过扩展、编排和组合等形式,是平台型企业架构需要解决的核心问题。为了应对业务的快速迭代、多场景和不确定性,企业需要在平台上构建可复用的“能力”,并为这些能力提供必要的扩展和可变机制,以便为不同前台提供灵活多变的业务服务,满足不同前台的差异化个性化需求。这些“能力”可根据粒度的不同,细分为“基础能力”、“能力组件”和“解决方案”三个层级。不同业务的差异性可以通过“能力的扩展点”设计和不同“业务身份”在扩展点上的“扩展实现”进行区分。

说明:基础能力、能力组件和解决方案概念来自ThoughtWork现代企业架构白皮书

下单的基础能力
在现代企业架构中,基础能力被认为是支撑业务运营的关键要素。例如,交易下单的一些基础能力包括快递可达性和同城可达性校验、下单扣减库存、支付扣减库存、子订单计价能力、收单能力等。这些基础能力的识别和规划对于企业级业务架构的规划至关重要。通过将这些基础能力沉淀成为企业级可复用的能力,企业可以在多个场景中应用这些能力,从而最大化效益。

为了更好地应对业务的快速迭代、多场景和不确定性,企业需要在平台上构建可复用的能力,并提供必要的扩展和可变机制,以此为不同的前台提供灵活多变的业务服务,满足不同前台差异化个性化的需求。这些能力可以分为三个层级:基础能力、能力组件和解决方案。基础能力是企业架构中最基本的能力,而能力组件则是由多个基础能力组成的较高层级的能力。最高层级的能力则是解决方案,它是一组能力组件的集合,可以满足特定的业务需求。

在不同业务之间的差异性,则可以通过能力的“扩展点”设计和不同“业务身份”在扩展点上的“扩展实现”进行区分。通过这种方式,企业可以针对不同业务的需求,快速扩展和定制能力,从而满足不同的前台业务需求。

三. Lattice能力的定义

能力模型(Ability)
针对买家下单过程中需要计算运费和创建物流订单的场景,在电商交易中,我们可以为不同的业务场景设计各种物流方案。例如,在国内不同城市之间的物流配送中,可以针对不同地区的快递可达性和同城可达性进行校验,以便为买家提供最佳的配送服务。在下单后,我们可以利用基础能力,如下单扣减库存和支付扣减库存,来保证订单的准确性和库存的实时管理。

除此之外,我们还可以考虑如何在不同的业务场景下,通过扩展和组合不同的能力来提供更灵活和个性化的服务。例如,为了应对高峰期的订单量激增,我们可以通过组合子订单计价能力和收单能力,来提高交易系统的处理能力。同时,我们也可以通过基础能力的不断扩展,如增加新的快递配送服务、更加灵活的计费方式等,来满足不同业务的差异性和个性化需求。

在实现可复用的能力方面,我们需要考虑基础能力、能力组件和解决方案这三个层级的划分,并为不同业务场景设定扩展点和扩展实现,以实现更加灵活和个性化的服务。通过这样的方式,我们可以最大化地沉淀企业级可复用的能力,并为业务的快速迭代、多场景和不确定性提供支持。

  • 传统通过快递进行配送的物流方式(例子中,我们定义 NormalLogisticsAbility)
  • 基于线下门店自提方式进行履约的物流方式。 这种方式,货品发到门店,但交易订单产生后不会有实际物流,用户需要人肉到线下门店去提货。比如,买轮胎,轮胎不是寄到家里,而是自己开车去4S店去安装已购买好的轮胎。 (例子中,我们定义 OfflineLogisticsAbility)
  • 虚拟物品发货方式:比如卡密等商品。。
    无论这些物流方式外在的差异有多大,但他们总有一些共性的功能是可以抽象成统一的接口。比如,无论何种运输方式,他都需要计算运费价格、需要在物流订单上保存物流信息等。平台> 则以面向接口的方式,去调用这些抽象好的接口,最终可实现平台内核稳定,同时也能支持未来新的运输方式的扩展。
@Ability(name = "OrderLine's Price Ability")  
public class OrderLinePriceAbility extends BaseLatticeAbility<OrderLinePriceExt> {  
  
    public OrderLinePriceAbility(OrderLine bizObject) {  
        super(bizObject);  
    }  
    public Long getCustomUnitPrice(OrderLine orderLine) {  
        return Optional.ofNullable(reduceExecute(p -> p.getCustomUnitPrice(orderLine),  
                        Reducers.firstOf(Objects::nonNull)))  
                .orElse(orderLine.getUnitPrice());  
    }  
    @Override  
    public BlankOrderLinePriceExt getDefaultRealization() {  
        return new BlankOrderLinePriceExt();  
    }}

能力SPI扩展(Extension)

能力是指某种实体或个体在特定环境下展示出的可行动的潜能,而这些行动是可以被扩展的。就像人的手,它天生具备抓握、捏取等能力,但是通过加装手套或者增加外挂,比如手指尖的抓握延伸器等,就能够进一步扩展手的功能,让人具备更多的操作能力。类似地,在电子商务的场景中,为了满足不同的需求,我们需要根据不同的物流方案来计算运费和创建物流订单,这些行为的实现也是可以通过扩展不同的能力来完成的。

总的来说,能力不仅仅是一个固定的概念,而是可以通过不同形式的扩展和组合来实现更多的行为,从而适应不同场景和需求的。
定义一个“自定义子订单商品单价”的扩展点,允许业务能够去实现该扩展点,返回商品的自定义单价

public interface OrderLinePriceExt extends IBusinessExt {

    String EXT_ORDER_LINE_CUSTOM_UNIT_PRICE = "OrderLinePriceExt.EXT_ORDER_LINE_CUSTOM_UNIT_PRICE";

    @Extension(
            code = EXT_ORDER_LINE_CUSTOM_UNIT_PRICE,
            name = "Custom the Item's unit price of OrderLine",
            reduceType = ReduceType.FIRST
    )
    Long getCustomUnitPrice(OrderLine orderLine);
}

业务(Business)
"关于垂直业务"可以用"行业"来替代。这是因为每个行业都有自己的一套规则和标准,而不同的行业之间存在明显的差异。虽然有时候会有跨界合作,但是对于某些行业的规则和术语,只有在这个行业内部的人才能真正理解。这就好比在不同的山间穿梭,每个山谷的景色都不尽相同,需要有足够的经验才能明白其中的奥秘。

在编程中,我们使用 @Business 注解来定义业务,例如业务A,编码为 "business.a"。这有助于我们更好地区分不同的业务,并为每个业务定义其独特的规则和逻辑。

@Business(code = "business.a", name = "Business A")
public class BusinessA extends BusinessTemplate {
}

然后,针对业务A,定义它的商品自定义单价为 2000 (单位分)。

@Realization(codes = "business.a")
public class BusinessAExt extends BlankOrderLinePriceExt {
    @Override
    public Long getCustomUnitPrice(OrderLine orderLine) {
        return 2000L;
    }
}

产品(Product)
产品是一个服务,有完整功能、能对外输出并能形成业务场景,快速支撑业务场景的功能集合体。定义团购场景 “GroupBuyProduct” 产品

@Product(code = GroupBuyProduct.GROUP_BUY_PRODUCT_CODE, name = "Group Buy Trade Product")
public class GroupBuyProduct extends ProductTemplate {

    public static final String GROUP_BUY_PRODUCT_CODE = "lattice.productGroupBuyProduct";

    @Override
    public boolean isEffect(ScenarioRequest request) {
        if (request instanceof BuyScenarioRequest) {
            boolean effect = StringUtils.equals("groupBuy", ((BuyScenarioRequest) request).getSource());
            System.out.println("GroupBuyProduct effect status:" + effect);
            return effect;
        }
        return false;
    }
}

一旦我们识别和构建了平台的基础能力和能力组件,就可以让不同的业务根据其特定需求来复用这些能力并快速进行定制。这可以通过配置和开发基础能力下的扩展点来实现,以下是示例:

(图片来自ThoughtWork)