Если бы это была запись для твиттера, то она была бы следующей: «Программисты на Cache ObjectScript! Используйте $Sequence вместо $Increment для генерации Id». Но тут Хабр, поэтому придётся развернуть мысль – добро пожаловать под кат.



Небольшое отступление для читателей, которым и менять-то нечего, и слово $Increment они видят впервые. $Increment — встроенная функция в Cache ObjectScript — атомарная операция. Аргументом функции $Increment может быть только переменная — не выражение. $Increment неявно блокирует переменную, увеличивает её значение на 1, разблокирует переменную, возвращает новое значение. $Increment широко используется, когда нужно назначать числовой идентификатор типа счётчик новым объектам или записям, в таких случаях аргументом функции является имя глобала. Выглядит это примерно так:

 for i=1:1:10000 {
     
set id $Increment(^Person)
     
set surname ##class(%PopulateUtils).LastName() ; случайная фамилия
     
set name ##class(%PopulateUtils).FirstName()  ; случайное имя
     
set ^Person(id) = $ListBuild(surnamename)
 
}

Что же такое $Sequence? Эта функция появилась в версии 2015.1, как и $Increment она выполняет атомарную операцию и возвращает увеличенное на 1 значение своего аргумента. В отличие от $Increment, аргументом $Sequence может быть только глобал (не локальная переменная). При первом обращении процесса к $Sequence от определённого глобала, $Sequence кеширует набор возвращаемых значений и при последующих обращениях возвращает значения из кеша. Значение глобала увеличивается на количество закешированных значений. Когда значения в кеше заканчиваются, $Sequence кеширует новый набор, опять увеличивая значение глобала. $Sequence автоматически определяет количество значений, которые нужно закешировать. Чем чаще процесс обращается к $Sequence, тем больше значений будет закешировано:

USER>set $Sequence(^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(^myseq), получил бы значение 17.

$Sequence предназначена для использования в процессах, которые параллельно увеличивают одно и то же глобальное значение. Поскольку $Sequence кеширует значения порциями, то в порядке идентификаторов могут быть пропуски, если процесс использовал не все выделенные ему значения. Собственно, основное предназначение функции $Sequence — генерация уникальных значений счётчика. $Increment в этом смысле, несколько более общая функция.

Чтобы сравнить $Increment и $Sequence, запустим небольшой пример:

Class Habr.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(jobsurnamename)
     
}
     
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 запускает десять процессов, каждый из которых вставляет 200000 записей в глобал ^Person. Блокировка на глобал ^P нужна только для того, чтобы родительский процесс дождался окончания работы дочерних процессов. Поэтому он пытается получить экслюзивную блокировку на глобал ^P, но получит он её только тогда, когда все дочерние процессы доделают свою работу и снимут разделяемую блокировку; сразу после этого мы считываем отсечку времени ($zhorolog) ещё раз, снимаем полученную блокировку на ^P и смотрим, сколько секунд заняла вставка записей. На моём четырёхъядерном ноутбуке, выполнение метода run заняло 21 секунду (для зануд скажу, что это был уже пятый запуск этого же метода):

USER>do ##class(Habr.IncSeq.Test).run()
done:21.40948

Интересно узнать, на что ушла эта 21 секунда. Запустив ^%SYS.MONLBL (про который, кстати, была статья на хабре), видим cледующую картину:

 ; ** Source for Method 'filling' **
1            10    .000433    lock +^P:"S"
2            10    .000013    set job = $job
3            10    .000038    for i=1:1:200000 {
4       1999991  13.222959        set id = $Increment(^Person)
5       1997246   7.029486        set surname = ##class(%PopulateUtils).LastName()
6       1995420   4.766967        set name = ##class(%PopulateUtils).FirstName()
7       1999680 208.226093        set ^Person(id) = $ListBuild(job, surname, name)
8       1999790    1.69106    }
9            10    .000205    lock -^P:"S"
 ; ** End of source for Method 'filling' **
 ;
 ; ** Source for Method 'run' **
1             1     .01005    kill ^Person
2             1    .000003    set z1 = $zhorolog
3             1    .000004    for i=1:1:10 {
4            10    .056381        job ..filling()
5             0          0    }
6             1  26.244814    lock ^P
7             1    .000003    set z2 = $zhorolog - z1
8             1    .000006    lock
9             1    .000009    write "done:",z2,!
 ; ** End of source for Method 'run' **

Первый столбец в отчёте ^%SYS.MONLBL — номер строки в методе, второй — количество выполнений этой строки, третий — сколько секунд выполнялась эта строка.

В общей сложности 13.2 секунды было потрачено на получение Id. Разделив 13.2 на количество процессов, получим, что каждый из них потратил 1.32 секунды на получение нового Id, 1.1 секунды на вычисление имени и фамилии, и 20.8 секунд на запись данных в глобал. Общее время (26.24) получилось на 5 секунд больше из-за профилировщика.

Давайте заменим в нашем тесте (а именно в методе filling()) $Increment(^Person) на $Sequence(^Person) и запустим тест ещё раз:

USER>do ##class(Habr.IncSeq.Test).run()
done:3.324123

Результат удивительный. Пусть $Sequence уменьшила время получения Id, но куда делись 20.8 секунд на запись данных? Смотрим результаты ^%SYS.MONLBL:

 ; ** Source for Method 'filling' **
1            10    .000523    lock +^P:"S"
2            10    .000017    set job = $job
3            10    .000048    for i=1:1:200000 {
4       1911382    1.69533        set id = $Sequence(^Person)
5       1753050   3.783609        set surname = ##class(%PopulateUtils).LastName()
6       1830006   3.407867        set name = ##class(%PopulateUtils).FirstName()
7       1827874  21.544164        set ^Person(id) = $ListBuild(job, surname, name)
8       1879819    .843424    }
9            10     .00023    lock -^P:"S"
 ; ** End of source for Method 'filling' **
 ;
 ; ** Source for Method 'run' **
1             1    .010926    kill ^Person
2             1    .000004    set z1 = $zhorolog
3             1    .000004    for i=1:1:10 {
4            10    .049543        job ..filling()
5             0          0    }
6             1   5.090719    lock ^P
7             1    .000003    set z2 = $zhorolog - z1
8             1    .000007    lock
9             1     .00001    write "done:",z2,!
 ; ** End of source for Method 'run' **

На получение Id каждый процесс теперь тратит 0.17 секунды вместо 1.32. Но почему на запись в базу тратится 2.15 секунд на процесс? Как такое может быть? Дело в том, что глобалы хранятся в блоках по (обычно) 8 килобайт каждый. Каждый процесс перед изменением глобала (таким как set ^Person(id) = …) получает внутреннюю блокировку на блок. Если несколько процессов пытаются изменить один и тот же блок — один процесс ждёт, пока другой освободит блок. Если таких процессов десять, то девять ждут одного. Если посмотреть на глобал ^Person, созданный с $increment, то можно увидеть, что почти никогда две соседние записи не созданы одним процессом:

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 запись с номером процесса 12963
353:     ^Person(100352)    =    $lb("12963","Young","Lola")
354:     ^Person(100353)    =    $lb("12967","Roentgen","Barb")

«Все это здорово», скажет читатель, но ведь при объектном и SQL-доступе Cache за нас использует $Increment для генерации новых Id. Как использовать $Sequence? Начиная с версии 2015.1 параметр хранения IDFunction определяет функцию, генерирующую Id. По умолчанию он равен «increment», но вы можете изменить его на «sequence» (В инспекторе Студии выберите Storage > Default > IDFunction)

В заключение:
Не верьте ничему, что здесь написано. Я специально не пишу характеристики компьютера и настройки экземпляра Cache, на котором я запускал этот тест — лучше запустите его сами.

Бонус


В качестве ещё одного теста я собрал небольшую ECP конфигурацию с сервером баз данных на ноутбуке и сервером приложений на виртуальной машине внутри этого ноутбука. Настроил отображение глобала ^Person в удалённую (remote, а не removed) базу. Ни о какой репрезентативности этого теста речи быть не может. $Increment c ECP нужно использовать аккуратно. Тем не менее, вот результаты:

$Increment


USER>do ##class(Habr.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' **

$Sequence


USER>do ##class(Habr.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' **

У функции $Sequence есть некоторые ограничения — перед использованием ознакомьтесь с документацией.

Спасибо за внимание!

Комментарии (24)


  1. jxcoder
    31.07.2015 14:46

    А почему бы в статье не указать какие именно ограничения? Про численное переполнение?


    1. adaptun Автор
      31.07.2015 14:49
      +2

      Чтоб документацию читали :-)
      Численное переполнение, если начать с 1 никому не грозит.


      1. jxcoder
        31.07.2015 14:54
        -1

        А что сложного-то взять да указать в статье и написать остальное читайте в документации? Почему я должен лезть и открывать документацию. Почему мне предлагают перейти с одного на другое, но не говорят что именно плохого может случиться?

        Да дело не в копировании документации. А просто в отношении к людям (к разработчикам). Это отношение очень отталкивает.


        1. adaptun Автор
          31.07.2015 14:58
          +1

          Ну извините, если обидел. Не хотел ничего такого.

          Вы, конечно, никуда лезть не должны.

          Я постарался взять минимум из справки по $Sequence — зачем делать перевод на русский язык (почти наверняка корявый), того что просто, понятно, с примерами и не слишком длинно написано по-английски.


        1. doublefint
          31.07.2015 15:05

          «Как страшно жить!» — вы вообще с этой планеты?


          1. jxcoder
            31.07.2015 15:08
            +1

            А что смущает?


            1. doublefint
              31.07.2015 15:17
              +1

              Ну, планета Земля (Грязь)…

              Вас ненавязчиво подталкивают ознакомится с документацией.
              И вдруг такая феерия эмоций — «Дело в отношении к людям!!!», «Это… очень отталкивает»

              И это еще лето, солнце. Что же зимой то будет? Волнуюсь я за вас


  1. jxcoder
    31.07.2015 15:03
    +2

    Вопрос к Cache разработчикам: зачем sequence? Почему бы не улучшить increment?


    1. morisson
      31.07.2015 15:05
      +1

      Я не разработчик, но постараюсь ответить. Например потому, что $sequence только для глобалов.


      1. jxcoder
        31.07.2015 15:08
        +2

        Ну, это я прочитал, но вопрос «почему» остался. Почему всё таки не научить метод работать с глобалами? В Cache есть универсальные функции, которые могут работать с различными типами аргументов и всё ок. А чего бы тут так не сделать? м?


        1. morisson
          31.07.2015 15:14
          +1

          Опять же вопрос к разработчикам, вполне резонный впрочем, возможно в будущем это появится. Предпололожу, что можно написать такой хитрый макрос, какой-нибудь SmartInc, который будет разбираться по аргументу, глобал там или нет, и делать правильный выбор. Судя по тому, что вы хорошо пишете макросы, может быть и вы сможете.


        1. morisson
          31.07.2015 15:16
          +1

          не научить метод работать с глобалами?
          С глобалами-то он работает прекрасно. А для работы локальными переменными $sequence не предзначначен, пока.


    1. adaptun Автор
      31.07.2015 15:15
      +1

      Я тоже не разработчик. И тоже постараюсь ответить.

      У функции $Increment есть определённая спецификация (контракт), которую она выполняет. Написано много когда, который на эту спецификацию расчитывает. $Sequence работает быстрее, за счёт нарушения этой спецификация. Даже не так — это вообще другая функция, с другой спецификацией (кеширование, шаг только в 1 и только вверх и т.д), которая может быть использована для той же цели, что и $Increment.

      $Increment и так улучшается, но без нарушения совместимости.


    1. DAiMor
      31.07.2015 21:11
      +5

      Я отвечу, так как мне важны различия между этими двумя функциями.
      самое главное у $increment это гарантия последовательности нумерации, в некоторых случаях это может быть важно, если это быстрорастущая система, с мбольшим количеством добавлений в минуту/секунду (нужное подчеркнуть), и которое будет вестись неоднородно различными процессами, это неизбежно приведет к пустотам в нумерации.
      Далее в совокупности с другой возможностью Cache bitmap индексами, которые для тех кто не в курсе состоят из массива битовых строк разбитых на 64000 бита, каждый бит которого должен указывать на ID объекта. Так вот представим, что объектов у нас в системе миллиарды (вполне реально) делим эти миллиарды на чанки по 64000, и рост их по несколько в день. Далее, да мы помним о том что IS озаботилась так же об оптимизации хранении бит, и то что в таком чанке несколько установленных бит в 0 будет занимать совсем немного, но неоднородность работы $sequence может свести эту оптимизацию на нет, и приведет к тому, что такой индекс может занимать достаточно много места, обычно один блок это 8192 байта, но битовая строка из 64000 бит это максимум примерно 8005 байт, что вместе со служебной информацией самого блока, не влезает в один блок, и такие данные уже разбиваются, на 2 (big blocks) итого 16384 байт на один чанк, Здесь так же упоминается и работа в ECP конфигурации, в таком случае big блоки не кешируются, приходится сразу переходить на 16к блоки.
      как писал раньше, имеем 1 миллиард объектов, это получится 1000000000/64000*16384/1024/1024 > 240 Мб, хм, может где ошибся, но вроде все верно. А с такими массивами еще нужно оперировать выполняя разные логические операции.
      Вот в такой ситуации $sequence противопоказан, так как давая прирост в одном месте сильно мешает в другом.
      Но в целом можно найти применение данной функции в других местах.


      1. morisson
        02.08.2015 12:01
        +2

        Хороший комментарий. Одно соображение — если битмап-индексы делать не на той же OLTP базе, а на хранилище данных, вынесенном на другой сервер, то, похоже, $sec в этом случае можно использовать в обоих местах: в OLTP для быстрорастущих записей (и не использовать битмап индексы вообще) и в OLAP для нумерации фактов (например фактов DeepSee), которые создавать заново подряд, создавать все необходимые битмап и битслайс индексы и использовать их для таблицы фактов эффективно.


      1. adaptun Автор
        03.08.2015 12:04
        +1

        Нужно смотреть в каждом конкретном случае, какой будет процент дырок. Это зависит от того, как данные загружаются.

        В примере из статьи, после выполнения загрузки, значение ^Person = 2007040.

        То есть, узлы 2'000'001 — 2'007'040 значений не имеют, и следующий узел будет вставлен в 2'007'041

        7'040 / 2'000'000 = 0,00352 = 0.352%

        С другой стороны, если сохранение каждого объекта влечёт обновление многих индексов, то, возможно, $Increment не узкое место, и заменять его смысла нет.


  1. AlexeyMaslov
    31.07.2015 15:49
    +1

    Спасибо за статью, но мотивы разработчиков для введения новой функции и для меня остались неясны…
    Соглашусь с jxcoder, вполне можно было бы добавить в $Increment кэширование, аналогичное $Sequence, не нарушая её спецификации: нигде ведь не сказано, что при параллельно работающих N процессах, обновляющих один глобал (как в вашем примере), значения id должны распределяться приблизительно равномерно. С приращениями > 1 этот кэш также отлично бы справился. «Дырки» в глобале, которые неизбежно будут получаться, тоже не проблема: они могут быть и сейчас при аварийном завершении процессов/откате транзакций. То, что $I() работает и с глобальными, и с локальными переменными, а $Sequence() — нет, и вовсе «гнилая отговорка»: $I() и сейчас по-разному работает в обоих случаях, очевидно, что каждый случай обслуживает отдельная ветка кода.


    1. adaptun Автор
      31.07.2015 16:00
      +2

      Пожалуйста, Алексей. Всё равно не очень понятно.

      Пусть в программе написано
      set id = $Increment(^a)

      За счёт чего здесь может происходить кеширование? Ведь функция $Increment должна увеличить значение ^a на 1.

      Работать с локальными переменными функции $Sequence, по-моему мнению нет никакой необходимости (как и функции $Increment), ведь внутри одного процесса конкурентного доступа нет, и set a = $Increment(a) это то же самое, что и set a = $Get(a) + 1.

      Разброс Id можно, конечно, сделать и без $Sequence. В данном случае это побочный эффект.


  1. AlexeyMaslov
    31.07.2015 16:35
    +1

    Ведь функция $Increment должна увеличить значение ^a на 1
    Равенство id == ^a гарантируется только в момент присваивания, не так ли? Нигде не обещается, что уже в следующую секунду не налетят «100500 процессов» и не увеличат ^a ещё на 100500.
    Про локальные переменные упомянул только потому, что вы указали на неспособность работать с ними $seq как на важное отличие от $i. Нет необходимости — да, согласен, но возможность есть, как и во многих других $-функциях, скорее всего, для сохранения полноты языка.
    Выделять разброс Id в какое-то отдельное аномальное явление не стоит — это жизнь, т.к. любой программист, использующий
    tstart set id=$i(^a),^a(id)=«что-нибудь» tcommit
    не должен полагаться на отсутствие дыр в индексации ^a. Что принципиально изменится, если этих дыр станет немного больше?
    Вы и сами в заголовке статьи пишете «а вы уже поменяли?». Что понимается под заменой? Могу ли взять и поменять по контексту /$i(^/ на /$seq(^/, если знаю, что никогда не пользуюсь двухаргументной формой $i()?
    Если да, то спецификации функций совместимы.
    Если нет, объясните, почему.


    1. adaptun Автор
      31.07.2015 17:10
      +3

      Ведь функция $Increment должна увеличить значение ^a на 1

      Равенство id == ^a гарантируется только в момент присваивания, не так ли? Нигде не обещается, что уже в следующую секунду не налетят «100500 процессов» и не увеличат ^a ещё на 100500.


      Я имел в виду, что спецификация $Increment(^a) — это увеличить значение глобала ^a ровно на 1. У $Sequence такого ограничения нет.

      В общем случае, одноаргументный $Increment нельзя заменить на $Sequence? Радикальный пример:
       for i=1:1:20 {
           set a = $Increment(^a)
           write "^a=",^a,!
           if i#10=0 {
               kill ^a
           }
       }
      


      В некоторых популярных случаях одноаргументный $Increment можно заменить на $Sequence. В общем случае — нет.

      Если вы используете $Increment исключительно для генерации Id, как в примере статьи, то я не знаю причин, чтобы не заменить его на $Sequence (что, конечно же, не значит, что их нет).


      1. AlexeyMaslov
        31.07.2015 17:56

        Спасибо за разъяснение.

        Если вы используете $Increment исключительно для генерации Id, как в примере статьи, то я не знаю причин, чтобы не заменить его на $Sequence (что, конечно же, не значит, что их нет).
        Сухой остаток: InterSystems вводит в язык новую фичу и предлагает на неё переходить, не формулируя чётко критерии, когда это возможно.
        Немного успокаивает, что, на вскидку, пострадает лишь искусственный код типа вашего последнего примера (не всё ли равно, что было в вершине глобала, если его всё равно кильнули?), но где гарантия, что нет более тонких слачаев? Именно это и настораживает. Одно дело — писание чего-то нового, другое дело — поддержка и развитие сотен тысяч строк существующего кода, ответственность перед коллегами и клиентами, и т.д.


        1. adaptun Автор
          31.07.2015 18:25
          +3

          Я могу в начале статьи вставить дисклаймер, что статья частная позиция автора и ни в коей мере не позиция фирмы и так далее и тому подобное. Я хотел рассказать о новой интересной функции. Это вполне удалось. Что с ней делать или не делать — решать вам, как разработчику.

          «не формулируя чётко критерии, когда это возможно»
          Достаточно чётко, что делает функция $Sequence описано в документации.

          Можно ли в ваших сценариях использования менять $Increment на $Sequence решать вам. Правильно, что вас настораживает. Нельзя автоматом менять А на Б в сотне тысяч строк кода.


    1. adaptun Автор
      31.07.2015 17:18
      +3

      Я последовал своему совету и почитал-таки документацию про $Sequence. Можно эту функцию использовать с локальными переменным.