«640 КБ хватит всем», — предположительно Билл Гейтс, примерно 1981 год.
Мы решили, что в нашей системе управления финансовыми базами данных TigerBeetle для хранения всех финансовых сумм и балансов будут использоваться 128-битные числа, и что мы откажемся 64-битных целых чисел. Хотя кто-то может заявить, что 64-битного integer, способного хранить в себе целые числа от нуля до 264, достаточно для подсчёта всех песчинок на Земле, мы осознали, что для адекватного хранения всех транзакций нам нужно подняться выше того предела. И в статье мы расскажем, почему.
Как мы храним значения сумм денег?
Для хранения чисел (и для выполнения с ними математических действий) компьютерам нужно кодировать эти числа в двоичную систему, которая требует в зависимости от диапазона и вида числа определённого числа битов (каждый бит может иметь значение 0 или 1). Например, целые числа в интервале от -128 до 127 можно записать всего восемью битами, но если нам не нужны отрицательные числа, то можно использовать те же биты для описания любого целого числа от 0 до 255, а это байт! Для чисел побольше требуется больше битов, например, чаще всего используются 16-битные, 32-битные и 64-битные числа.
Вы могли обратить внимание, что мы говорим о деньгах как о целых числах, а не как о десятичных дробях или центах. С дробными числами всё становится сложнее, их можно кодировать при помощи чисел с плавающей запятой. Двоичные числа с плавающей запятой вполне могут подходить для других вычислений, но они не способны точно выражать числа с десятичными дробями. Аналогично тому, как мы сталкиваемся с проблемами, пытаясь выразить ⅓ в десятичном виде как 0,33333…, компьютерам приходится выражать ¹⁄₁₀ в двоичном виде!
>>> 1.0 / 100
.10000000000000001
Так как «части пенни» часто накапливаются, числа с плавающей запятой — это настоящий кошмар для финансового сектора!
Поэтому в TigerBeetle мы не используем дробных или десятичных чисел, каждый учётный регистр (ledger) выражен как кратное минимального целого множителя, определённого пользователем. Например, если представить доллар как число, кратное центам, тогда транзакцию в $1,00 можно описать как 100 центов. Даже недесятичные валютные системы можно лучше представить как кратные общего множителя.
Как ни странно, мы также не используем отрицательные числа (возможно, вы видели программные учётные регистры, в которых хранится только положительный/отрицательный баланс). Вместо этого мы храним два отдельных строго положительных целых значения: один для дебитов, другой для кредитов. Это не только позволяет избежать бремени обработки отрицательных чисел (таких как множество уникальных для каждого языка последствий переполнения в ту или другую сторону), но и, что самое важное, сохраняет информацию, демонстрируя объём транзакций относительно постоянно увеличивающихся балансов сторон дебита и кредита. Когда вам нужно подвести чистый баланс, один баланс можно вычесть из другого, представив чистый баланс в виде одного положительного или отрицательного числа.
Зачем же нам нужны 128-битные целые?
Вернёмся к примеру представления $1,00 в виде 100 центов. В этом случае в 64-битных целых числах можно посчитать примерно до 184,5 квадриллионов долларов. Хотя для большинства людей это не станет проблемой, верхний лимит 64-битного integer становится фактором ограничения, когда нужно представить значения меньше цента. Добавление новых знаков после запятой существенно снижает этот интервал.
По той же причине цифровые валюты стали ещё одним примером использования 128-битных балансов, ведь в них наименьшая денежная величина может быть выражена в микроцентах (10-6)… или в ещё меньшей сумме. Хотя этот пример и так достаточно убедителен, чтобы поддерживать их в TigerBeetle, мы нашли множество других областей применения, которые выиграют от использования 128-битных балансов.
Давайте ещё поразмыслим о ситуациях, в которых $0,01 слишком много для описания значения.
Например, во многих странах стоимость литра/галлона бензина требует трёх разрядов после десятичной запятой, а фондовые рынки уже требуют инкрементов курсов в сотых долях цента (0,0001).
В экономике высокочастотных микроплатежей тоже требуется повышенная точность и масштабы. Дальнейшее использование 64-битных значений наложит искусственные ограничения на потребности реального мира или заставит приложения обрабатывать различные масштабы одной и той же валюты в разных учётных регистрах, разбивая суммы на многочисленные «долларовые» и «микродолларовые» счета лишь потому, что единого 64-баланса недостаточно для покрытия всего диапазона точности и масштаба, необходимого для многих микроплатежей при описании сделки на много миллиардов долларов.
Значение в базе данных, которое можно считать точно (и с большим масштабом), может быть не только деньгами. TigerBeetle предназначена для подсчёта не только сумм денег, но и всего, что можно смоделировать при помощи двойной бухгалтерской записи. Например, инвентаризации товаров, частоты вызовов API или даже киловаттов электричества. И ничто из этого не обязано вести себя как деньги или быть ограниченным теми же пределами.
Бухгалтерия с расчётом на будущее
Ещё один важный аспект верхних пределов сумм и балансов заключается в том, что, несмотря на маловероятность превышения одной транзакцией порядка величин триллионов и квадриллионов, балансы счетов со временем аккумулируются. В долгоживущих системах велика вероятность того, что за многие годы через счёт могут пройти транзакции на такие суммы, поэтому должна существовать возможность переместить весь этот баланс с одного счёта на другой за одну транзакцию. С этим хитрым вопросом мы тоже столкнулись, когда у нас был выбор, переходить ли на 128-битные суммы транзакций и/или только на 128-битные балансы счетов.
Наконец, даже самые неожиданные события наподобие гиперинфляции способны подбросить валюту к верхней границе 64-битных integer, что потребует забыть о центах и отрезать нули, не имеющие практического смысла.
Сможет ли это пережить ваша схема базы данных?
Интуитивно сложно осознать, насколько велик 128-битный integer. Он не просто вдвое больше 64-битного; на самом деле, он больше в 264 раз! Пример: 64-битного integer недостаточно для хранения этой купюры в сто триллионов долларов, если мы кодируем учётный регистр в масштабе микроцентов. Однако при использовании 128-битных целых чисел мы сможем выполнять 1 миллион транзакций в секунду на ту же сумму в течение тысячи лет, но всё равно не дойти до предела баланса счёта.
1.000e20 // сто триллионов в масштабе микроцентов * 1.000e6 // 1 миллион транзакций в секунду * 3.154e7 // количество секунд в году * 1.000e3 // тысяча лет ------------= 3.154e36 // меньше 2^128 ≈ 3.4e38
С BigInteger приходит большая ответственность
Современные архитектуры процессоров наподобие x86-64 и ARM64 способны выполнять арифметические операции с 64-битными значениями, но, если мы правильно понимаем, у них не всегда есть конкретный набор команд для нативных 128-битных вычислений. При работе с 128-битными операндами задача может быть разбита на 64-битные части, которые CPU способен выполнить. Поэтому мы задались вопросом, будет ли 128-битная арифметика более требовательной по сравнению с исполнением за одну команду, возможную для 64-битных integer.
В таблице ниже представлено сравнение машинного кода x86_64, сгенерированного для 64-битных и 128-битных операндов. Не волнуйтесь, чтобы понять смысл, вам не нужно быть специалистом в ассемблере! Просто обратите внимание, что компилятор может оптимизировать большинство операций в последовательность тривиальных команд CPU, например, в сложение с переносом и вычитание с займом. Это означает, что излишние затраты использования 128-битных сумм — это не забота TigerBeetle.
Операция | 64-битные операнды | 128-битные операнды |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1. Для простоты в этом ассемблерном коде опущены проверка арифметических границ и паник, которые всегда включены для TigerBeetle.
2. 128-битное деление нельзя выразить как последовательность 64-битных команд, и оно должно быть реализовано программно.
Также при внесении изменений нам нужно было учесть всех наших клиентов, потому что TigerBeetle должна раскрывать свой API множеству различных языков программирования, не все из которых поддерживают 128-битные integer. Основные языки, для которых мы представляем клиенты, в настоящее время для выполнения операций с 128-битными integer должны использовать integer с произвольной точностью (BigInteger). Единственное исключение — это .Net, в котором недавно была добавлена поддержка типов данных Int128 и UInt128 в .Net 7.0 (большое спасибо команде DotNet!).
Использование BigInteger требует дополнительной траты ресурсов, потому что они не обрабатываются как 128-битные значения фиксированной длины, а распределяются в куче как байтовые массивы переменной длины. Кроме того, в среде исполнения арифметические операции эмулируются программно, то есть в них нельзя пользоваться большинством оптимизаций, которые могли бы быть возможны, если бы компилятор знал, с каким типом числа он имеет дело. Да, Java, Go и даже C#, мы говорим про вас.
Для снижения этих затрат на стороне клиента (и, разумеется, для сохранения соответствия нашему TigerStyle) мы храним и раскрываем все 128-битные значения (например, ID, суммы и так далее) просто как пару распределённых в стеке 64-битных integer (за исключением JavaScript, потому что он не поддерживает даже 64-битные числа). Хотя язык программирования не обладает знаниями об этом сыром типе и не может выполнять с ним арифметические операции, мы предлагаем набор вспомогательных функций для конвертации между идиоматическими альтернативами, существующими в каждой экосистеме (например, BigInteger, байтовый массив, UUID).
Наш API неагрессивен, он предоставляет каждому приложению право выбора между использованием BigInteger или обработкой 128-битных значений при помощи любой сторонней числовой библиотеки, которая подходит больше всего. Мы хотим предоставлять высокопроизводительные низкоуровневые примитивы с минимумом «сахара», не отбирая у пользователя свободу на более высоких уровнях.