文章 pan John · 十一月 6 10m read

关于 $Sequence 函数

在本文中,我们将对比 $Increment 和 $Sequence 函数。

首先,为那些从未听说过$Increment 的读者说明一下。$Increment 是 Caché ObjectScript 中的一个函数,它执行原子操作,将参数递增 1 并返回结果值。 您只能将全局或局部变量节点作为参数传递给 $Increment,而不能传递任意表达式。$Increment 在分配序列 ID 时被广泛使用。在这种情况下,$Increment 的参数通常是一个全局节点。$Increment 保证每个使用它的进程都能获得一个唯一的 ID。

for i=1:1:10000 {
     set Id = $Increment(^Person) ; new Id
     set surname = ##class(%PopulateUtils).LastName() ; random last name
     set name = ##class(%PopulateUtils).FirstName()  ; random first name
     set ^Person(Id) = $ListBuild(surname, name)
}

$Increment 的问题在于,如果多个进程并行添加行,这些进程可能会花费时间等待轮到自己,以便原子地更改存储 ID 的全局节点(如上例中的 ^Person)的值。

$Sequence是一项为解决此问题而设计的新函数。自 Caché 2015.1 起,$Sequence 已正式提供。与 $Increment 一样,$Sequence 会原子性地递增其参数的值。 与 $Increment 不同,$Sequence 会为当前进程预留一些后续计数器值,而在同一进程的下次调用中,它将直接从预留范围内返回下一个值。$Sequence 会自动计算需要预留的值数。进程调用 $Sequence 的频率越高,$Sequence 预留的值就越多:

USER>kill ^myseq

USER>for i=1:1:15 {write "increment:",$Seq(^myseq)," allocated:",^myseq,! }
increment:1 allocated:1
increment:2 allocated:2
increment:3 allocated:4
increment:4 allocated:4
increment:5 allocated:8
increment:6 allocated:8
increment:7 allocated:8
increment:8 allocated:8
increment:9 allocated:16
increment:10 allocated:16
increment:11 allocated:16
increment:12 allocated:16
increment:13 allocated:16
increment:14 allocated:16
increment:15 allocated:16

当 $Sequence(^myseq) 返回 9 时,接下来的 8 个值(总计 16 个)已为当前进程预留。如果其他进程调用 $Sequence,它将获得值 17,而不是 10。

$Sequence 专为同时递增某个全局节点的进程而设计。由于 $Sequence 会预留值,如果进程未使用所有预留的值,ID 可能会出现空缺。$Sequence 的主要用途是生成顺序 ID。与 $Sequence 相比,$Increment 是一个更通用的函数。

让我们比较一下 $Increment 和 $Sequence 的性能:

Class DC.IncSeq.Test 
{

ClassMethod filling()
{
    lock +^P:"S"
    set job = $job
     for i=1:1:200000 {
         set Id = $Increment(^Person)
         set surname = ##class(%PopulateUtils).LastName()
         set name = ##class(%PopulateUtils).FirstName()
         set ^Person(Id) = $ListBuild(job, surname, name)
     }
     lock -^P:"S"
}

ClassMethod run()
{
    kill ^Person
    set z1 = $zhorolog
    for i=1:1:10 {
        job ..filling()
     }
     lock ^P
     set z2 = $zhorolog - z1
     lock
     write "done:",z2,!
}

}

方法 run 通过 10 个进程执行任务,每个进程向 ^Person 全局对象插入 200’000 条记录。 等待子进程完成。方法 run 尝试获取 ^P 的独占锁。当子进程完成任务并释放 ^P 的共享锁后,主进程将获取 ^P 的独占锁并继续执行。 紧接着,我们记录系统变量 $zhorolog 的时间,并计算插入这些记录所花费的时间。我那台配备慢速硬盘的多核笔记本电脑耗时 40 秒(出于研究目的,我之前已运行过几次,因此这是第 5 次运行):

USER>do ##class(DC.IncSeq.Test).run()
done:39.198488

深入分析这40秒的过程很有意思。通过运行^%SYS.MONLBL,我们可以发现总共花费了100秒来获取ID。 100 秒 / 10 个进程 = 每个进程花费 10 秒获取新 ID,1.7 秒获取名字和姓氏,28.5 秒将数据写入全局数据变量。

下文 %SYS.MONLBL 报告的第一列是行号,第二列是该行被执行的次数,第三列是执行该行所花费的秒数。

 ; ** Source for Method 'filling' **
1            10    .001143    lock +^P:"S"
2            10    .000055    set job = $JOB
3            10    .000118     for i=1:1:200000 {
4       1998499 100.356554         set Id = $Increment(^Person)
5       1993866  10.409804         set surname = ##class(%PopulateUtils).LastName()
6       1990461   6.347832         set name = ##class(%PopulateUtils).FirstName()
7       1999762  285.54603         set ^Person(Id) = $ListBuild(job, surname, name)
8       1999825   3.393706     }
9            10    .000259     lock -^P:"S"
 ; ** End of source for Method 'filling' **
 ;
 ; ** Source for Method 'run' **
1             1    .005503    kill ^Person
2             1    .000002    set z1 = $zhorolog
3             1    .000002    for i=1:1:10 {
4            10    .201327        job ..filling()
5             0          0     }
6             1  43.472692     lock ^P
7             1     .00003     set z2 = $zhorolog - z1
8             1     .00001     lock
9             1    .000053     write "done:",z2,!
 ; ** End of source for Method 'run' **

由于进行了性能分析,总时间(43.47秒)比上次运行多花了4秒。

让我们在测试代码的 filling 方法中修改一处内容。我们将 $Increment(^Person) 改为 $Sequence(^Person),然后重新运行测试:

USER>do ##class(DC.IncSeq.Test).run()
done:5.135189

这个结果令人惊讶。好吧,$Sequence 确实缩短了获取 ID 的时间,但将数据存储到全局变量所耗费的 28.5 秒去哪儿了?让我们检查一下 ^%SYS.MONLBL:

 ; ** Source for Method 'filling' **
1            10    .001181    lock +^P:"S"
2            10    .000026    set job = $JOB
3            10    .000087     for i=1:1:200000 {
4       1802473   1.996279         set Id = $Sequence(^Person)
5       1784910   4.429576         set surname = ##class(%PopulateUtils).LastName()
6       1853508   3.829051         set name = ##class(%PopulateUtils).FirstName()
7       1838752  32.281624         set ^Person(Id) = $ListBuild(job, surname, name)
8       1951569     1.0243     }
9            10    .000219     lock -^P:"S"
 ; ** End of source for Method 'filling' **
 ;
 ; ** Source for Method 'run' **
1             1    .006514    kill ^Person
2             1    .000002    set z1 = $zhorolog
3             1    .000002    for i=1:1:10 {
4            10    .385055        job ..filling()
5             0          0     }
6             1   6.558119     lock ^P
7             1    .000011     set z2 = $zhorolog - z1
8             1    .000008     lock
9             1    .000025     write "done:",z2,!
 ; ** End of source for Method 'run' **

现在,每个进程获取 ID 所花费的时间从 10 秒缩短到了 0.2 秒。但令人不解的是,为什么每个进程存储数据只需 3.23 秒?原因在于全局节点存储在数据块中,而通常每个数据块的大小为 8192 字节。 在更改全局节点值(如 set ^Person(Id) = …)之前,进程会锁定整个数据块。如果多个进程同时尝试修改同一数据块中的数据,则只有一个进程被允许修改该数据块,其余进程必须等待该进程完成操作。

让我们来看一下使用 $Increment 生成新 ID 的全局变量。连续的记录几乎不会拥有相同的进程 ID(请记住——我们将进程 ID 存储为数据列表的第一个元素):

1:    ^Person(100000)    =    $lb("12950","Kelvin","Lydia")
2:     ^Person(100001)    =    $lb("12943","Umansky","Agnes")
3:     ^Person(100002)    =    $lb("12945","Frost","Natasha")
4:     ^Person(100003)    =    $lb("12942","Loveluck","Terry")
5:     ^Person(100004)    =    $lb("12951","Russell","Debra")
6:     ^Person(100005)    =    $lb("12947","Wells","Chad")
7:     ^Person(100006)    =    $lb("12946","Geoffrion","Susan")
8:     ^Person(100007)    =    $lb("12945","Lennon","Roberta")
9:     ^Person(100008)    =    $lb("12944","Beatty","Mark")
10:     ^Person(100009)    =    $lb("12946","Kovalev","Nataliya")
11:     ^Person(100010)    =    $lb("12947","Klingman","Olga")
12:     ^Person(100011)    =    $lb("12942","Schultz","Alice")
13:     ^Person(100012)    =    $lb("12949","Young","Filomena")
14:     ^Person(100013)    =    $lb("12947","Klausner","James")
15:     ^Person(100014)    =    $lb("12945","Ximines","Christine")
16:     ^Person(100015)    =    $lb("12948","Quine","Mary")
17:     ^Person(100016)    =    $lb("12948","Rogers","Sally")
18:     ^Person(100017)    =    $lb("12950","Ueckert","Thelma")
19:     ^Person(100018)    =    $lb("12944","Xander","Kim")
20:     ^Person(100019)    =    $lb("12948","Ubertini","Juanita")

并发进程试图将数据写入同一个数据块,结果花在等待上的时间比实际修改数据的时间还要多。使用 $Sequence 时,ID 是分批生成的,因此不同的进程很可能使用不同的数据块:

1:     ^Person(100000)    =    $lb("12963","Yezek","Amanda")
// 351 records with process number 12963
353:     ^Person(100352)    =    $lb("12963","Young","Lola")
354:     ^Person(100353)    =    $lb("12967","Roentgen","Barb")

如果这个示例与您项目中的情况相似,请考虑使用 $Sequence 代替 $Increment。当然,在将所有 $Increment 替换为 $Sequence 之前,请务必查阅相关文档。

当然,不要轻信这里提供的测试结果——请务必亲自进行复核。

从 Caché 2015.2 开始,您可以配置表以使用 $Sequence 代替 $Increment。为此提供了一个系统函数$system.Sequence.SetDDLUseSequence,并且在管理门户的“SQL 设置”中也可找到相同的选项。

此外,类定义中新增了一个存储参数——IDFunction,其默认值设为“increment”,这意味着将使用 $Increment 生成 ID。您可以将其更改为“sequence”(检查器 > 存储 > 默认 > IDFunction)。

额外提示

我在笔记本电脑上进行了一次快速测试:这是一个小型 ECP 配置,其中数据库服务器位于主机操作系统上,而应用服务器则位于同一台笔记本电脑上的虚拟机中。 我将 ^Person 映射到了远程数据库。这只是一个基础测试,因此我不打算据此进行普遍推论。在使用 $Increment 和 ECP 时,需要考虑一些因素。话虽如此,以下是测试结果:

使用 $Increment 时:

USER>do ##class(DC.IncSeq.Test).run()
done:163.781288

^%SYS.MONLBL:

 ; ** Source for Method 'filling' **
1            10    .000503         --     lock +^P:"S"
2            10    .000016    set job = $job
3            10    .000044    for i=1:1:200000 {
4       1843745 1546.57015        set Id = $Increment(^Person)
5       1880231   6.818051        set surname = ##class(%PopulateUtils).LastName()
6       1944594   3.520858        set name = ##class(%PopulateUtils).FirstName()
7       1816896  16.576452        set ^Person(Id) = $ListBuild(job, surname, name)
8       1933736    .895912    }
9            10    .000279    lock -^P:"S"
 ; ** End of source for Method 'filling' **
 ;
 ; ** Source for Method 'run' **
1             1    .000045    kill ^Person
2             1    .000001    set z1 = $zhorolog
3             1    .000007    for i=1:1:10 {
4            10    .059868        job ..filling()
5             0          0    }
6             1 170.342459    lock ^P
7             1    .000005    set z2 = $zhorolog - z1
8             1    .000013    lock
9             1    .000018    write "done:",z2,!
 ; ** End of source for Method 'run' **

$Sequence:

USER>do ##class(DC.IncSeq.Test).run()
done:13.826716

^%SYS.MONLBL

 ; ** Source for Method 'filling' **
1            10    .000434     lock +^P:"S"
2            10    .000014    set job = $job
3            10    .000033    for i=1:1:200000 {
4       1838247  98.491738        set Id = $Sequence(^Person)
5       1712000   3.979588        set surname = ##class(%PopulateUtils).LastName()
6       1809643   3.522974        set name = ##class(%PopulateUtils).FirstName()
7       1787612  16.157567        set ^Person(Id) = $ListBuild(job, surname, name)
8       1862728    .825769    }
9            10    .000255    lock -^P:"S"
 ; ** End of source for Method 'filling' **
 ;
 ; ** Source for Method 'run' **
1             1    .000046    kill ^Person
2             1    .000002    set z1 = $zhorolog
3             1    .000004    for i=1:1:10 {
4            10    .037271        job ..filling()
5             0          0    }
6             1  14.620781    lock ^P
7             1    .000005    set z2 = $zhorolog - z1
8             1    .000013    lock
9             1    .000016    write "done:",z2,!
 ; ** End of source for Method 'run' **