Иэн Гортон
Основы масштабируемых систем

Foundations of
scalable systems
Глава 3
Основы распределённых систем
Основы коммуникаций
Как я уже описывал в главе 2, масштабирование системы естественным образом подразумевает добавление множества независимо движущихся частей. Мы запускаем наши программные компоненты на нескольких машинах, а наши базы данных — на нескольких узлах хранения, и всё это для того, чтобы увеличить вычислительную мощность. Следовательно, наши решения распределены по нескольким машинам в разных местах, каждая машина обрабатывает события одновременно и обменивается сообщениями по сети.

Эта фундаментальная природа распределённых систем оказывает глубокое влияние на то, как мы проектируем, создаём и эксплуатируем наши решения. В этой главе представлена основная информация, которую необходимо знать, чтобы оценить проблемы и сложности распределённых программных систем. Я кратко расскажу об аппаратном и программном обеспечении сетей, об удалённом вызове методов, о том, как бороться с последствиями сбоев связи, о распределённой координации и о таком сложном вопросе, как время в распределённых системах.
В каждой распределённой системе есть программные компоненты, которые взаимодействуют по сети. Если мобильное банковское приложение запрашивает текущий баланс банковского счета пользователя, то (очень упрощенно) последовательность взаимодействий выглядит следующим образом:
  1. Мобильное банковское приложение отправляет запрос по сотовой сети в банк, чтобы получить данные о банковском балансе пользователя.
  2. Запрос направляется через Интернет туда, где расположены веб-серверы банка.
  3. Веб-сервер банка проверяет подлинность запроса (подтверждает, что он исходит от предполагаемого пользователя) и отправляет запрос на сервер базы данных для получения информации о состоянии счёта.
  4. Сервер базы данных считывает баланс счёта с диска и возвращает его веб-серверу.
  5. Веб-сервер отправляет баланс в ответном сообщении, адресованном приложению, которое передаётся через интернет и сотовую сеть, пока баланс волшебным образом не появится на экране мобильного устройства.

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

Для физических проводных сетей наиболее распространены два типа: локальные сети (LAN) и глобальные сети (WAN). Локальные сети — это сети, которые могут соединять устройства в "масштабах здания", способные передавать данные на небольшое количество (например, 1-2) километров. Современные локальные сети в центрах обработки данных могут передавать от 10 до 100 гигабит в секунду (Гбит/с). Это называется пропускной способностью сети, или её ёмкостью. Время, необходимое для передачи сообщения по локальной сети — задержка сети — с современными технологиями занимает считанные миллисекунды.

Глобальные сети — это сети, пересекающие весь земной шар и составляющие то, что мы называем Интернетом. Эти удалённые соединения представляют собой высокоскоростные трубопроводы для передачи данных, соединяющие города, страны и континенты с помощью оптоволоконных кабелей. Эти кабели поддерживают сетевую технологию, известную как мультиплексирование с разделением по длине волны, которая позволяет передавать до 171 Гбит/с по 400 различным каналам, что дает более 70 терабит в секунду (Тбит/с) общей пропускной способности для одного оптоволоконного канала. Оптоволоконные кабели, охватывающие весь мир, обычно состоят из четырёх и более нитей волокна, что обеспечивает пропускную способность в сотни Тбит/с для каждого кабеля.

Однако с глобальными сетями дело обстоит сложнее. Глобальные сети передают данные на сотни и тысячи километров, а максимальная скорость, с которой данные могут перемещаться по оптоволоконным кабелям — это теоретическая скорость света. В реальности эти кабели не могут достичь скорости света, но довольно близки к ней, как показано в Таблице 3-1.
Таблица 3-1. Скорости глобальной сети
Фактическое время будет медленнее, чем время прохождения по оптоволокну в Таблице 3-1, поскольку данные должны пройти через сетевое оборудование, известное как маршрутизаторы. Глобальная сеть Интернет имеет сложную топологию "звезда" (hub-and-spoke) с множеством потенциальных путей между узлами сети. Поэтому маршрутизаторы отвечают за передачу данных по физическим сетевым соединениям, чтобы обеспечить передачу данных через Интернет от источника к месту назначения.

Маршрутизаторы — это специализированные высокоскоростные устройства, которые могут обрабатывать несколько сотен Гбит/с сетевого трафика, получая данные с входящих соединений и отправляя их на различные исходящие сетевые соединения в зависимости от их назначения. Маршрутизаторы в ядре Интернета состоят из стоек этих устройств и могут обрабатывать десятки и сотни Тбит/с. Именно так вы и тысячи ваших друзей можете одновременно смотреть непрерывный видеопоток на Netflix.

Беспроводные технологии имеют разные характеристики дальности и пропускной способности. Маршрутизаторы Wi-Fi, которые мы можем встретить в наших домах и офисах, представляют собой беспроводные сети Ethernet и используют протоколы 802.11 для передачи и приёма данных. Самый распространённый протокол Wi-Fi, 802.11ac, обеспечивает максимальную (теоретическую) скорость передачи данных до 5 400 Мбит/с. Самый новый протокол 802.11ax, также известный как Wi-Fi 6, является развитием технологии 802.11ac и обещает увеличение скорости передачи данных до 9,6 Гбит/с. Радиус действия маршрутизаторов WiFi составляет порядка десятков метров, и, конечно, на него влияют физические препятствия, такие как стены и пол.

Технология сотовой беспроводной связи использует радиоволны для передачи данных с наших телефонов на маршрутизаторы, установленные на вышках сотовой связи, которые обычно соединены проводами с основной сетью Интернета для маршрутизации сообщений. Каждая следующая технология сотовой связи улучшает пропускную способность и другие параметры производительности. На момент написания статьи наиболее распространённой технологией является беспроводная широкополосная связь 4G LTE. 4G LTE примерно в 10 раз быстрее, чем более старая 3G, и способна обеспечить устойчивую скорость скачивания около 10 Мбит/с (пиковая скорость скачивания приближается к 50 Мбит/с) и скорость отправки от 2 до 5 Мбит/с.
Появляющиеся сотовые сети 5G обещают 10-кратное увеличение пропускной способности по сравнению с существующими 4G, при этом задержки между устройствами и сотовыми вышками будут составлять 1-2 миллисекунды. Это колоссальный прорыв по сравнению с задержками 4G, которые находятся в диапазоне 20-40 миллисекунд. Компромисс заключается в дальности действия. Радиус действия базовых станций 5G составляет не более 500 метров, в то время как 4G обеспечивает уверенный приём на расстоянии 10-15 км.

Все это разнообразие различных типов оборудования для работы в сети объединяется в глобальный Интернет. Интернет — это гетерогенная сеть с множеством различных операторов по всему миру и всеми возможными типами оборудования. На рисунке 3-1 показано упрощённое представление основных компонентов, из которых состоит Интернет. Сети первого уровня — это глобальная высокоскоростная интернет-магистраль. Существует около 20 интернет-провайдеров первого уровня (ISP), которые распределяют и контролируют глобальный трафик. Провайдеры второго уровня, как правило, являются региональными (например, в одной стране), имеют меньшую пропускную способность, чем провайдеры первого уровня, и доставляют контент клиентам через провайдеров третьего уровня. Провайдеры третьего уровня — это те, кто ежемесячно взимает с вас непомерную плату за домашний интернет.
Рисунок 3-1. Упрощённое представление интернета
Работа Интернета намного сложнее, чем описано здесь. Такой уровень сложности сетей и протоколов выходит за рамки данной главы. С точки зрения программного обеспечения распределённых систем, нам нужно больше понять о "магии", которая позволяет всему этому оборудованию направлять сообщения, скажем, от моего мобильного телефона к моему банку и обратно. Именно здесь на помощь приходит Протокол Интернета (Internet Protocol — IP).
Программное обеспечение для коммуникаций
Программные системы в Интернете взаимодействуют с помощью набора протоколов Интернета (IP). Набор протоколов определяет адресацию хостов, форматы передачи данных, маршрутизацию сообщений и характеристики доставки. Существует четыре абстрактных уровня, которые содержат связанные протоколы, поддерживающие функциональность, необходимую на данном уровне. К ним относятся, от низшего к высшему:

  1. Канальный уровень, определяющий методы передачи данных через один сегмент сети. Он реализуется драйверами устройств и сетевыми картами, которые находятся внутри ваших устройств.
  2. Уровень Интернета определяет протоколы адресации и маршрутизации, которые позволяют трафику перемещаться по независимо управляемым и контролируемым сетям, составляющим Интернет. Это уровень IP в наборе интернет-протоколов.
  3. Транспортный уровень, определяющий протоколы для надёжных и оптимальных коммуникаций между хостами. Именно здесь находятся хорошо известные протокол управления передачей (Transmission Control Protocol — TCP) и протокол пользовательских датаграмм (User Datagram Protocol — UDP).
  4. Прикладной уровень, включающий в себя несколько протоколов прикладного уровня, таких как HTTP и протокол безопасного копирования (Secure Copy Protocol — SCP).

Каждый из протоколов верхнего уровня опирается на возможности нижних уровней. В следующем разделе я кратко расскажу об IP для обнаружения хостов и маршрутизации сообщений, а также о TCP и UDP, которые могут использоваться в распределённых приложениях.
Интернет-протокол (IP)
IP определяет, как хостам присваиваются адреса в Интернете и как передаются сообщения между двумя хостами, которые знают адреса друг друга.

Каждое устройство в Интернете имеет свой собственный адрес. Они известны как адреса протокола Интернета (IP). Местоположение IP-адреса можно определить с помощью службы каталогов, известной в Интернете как система доменных имён (Domain Name System — DNS). DNS — это широко распространённая иерархическая база данных, выполняющая роль адресной книги в Интернете.

Технология, используемая в настоящее время для присвоения IP-адресов, известная как Протокол Интернета версии 4 (Internet Protocol version 4 — IPv4), в конечном итоге будет заменена ее преемником — IPv6. IPv4 — это 32-битная схема адресации, которая скоро исчерпает себя из-за количества устройств, подключающихся к Интернету. IPv6 — это 128-битная схема, которая будет предлагать (почти) бесконечное количество IP-адресов. В качестве примера можно привести тот факт, что в июле 2020 года около 33% трафика, обрабатываемого Google.com, будет приходиться на IPv6.
Серверы DNS организованы иерархически. Небольшое количество корневых DNS-серверов, которые отличаются высокой степенью репликации, являются отправной точкой для разрешения (resolving) IP-адреса. Когда интернет-браузер пытается найти веб-сайт, сетевой узел, известный как локальный DNS-сервер (управляемый вашим работодателем или интернет-провайдером), обращается к корневому DNS-серверу с запрошенным именем хоста. В ответ корневой сервер направляет запрос к так называемому авторитативному DNS-серверу, который управляет разрешением имён для, в нашем банковском примере, адресов .com. Авторитативный сервер имён существует для каждого интернет-домена верхнего уровня (.com, .org, .net и т. д.).

Далее локальный DNS-сервер запрашивает DNS-сервер .com, который отвечает адресом DNS-сервера, знающего обо всех IP-адресах, управляемых igbank.com. Этот DNS запрашивается, и он возвращает фактический IP-адрес, необходимый нам для связи с приложением. Общая схема показана на рисунке 3-2.
Рисунок 3-2. Пример поиска DNS для igbank.com
Вся база данных DNS географически реплицирована, поэтому единых точек отказа нет, а запросы распределяются между несколькими физическими серверами. Локальные DNS-серверы также запоминают IP-адреса узлов, с которыми недавно связывались, что возможно, поскольку IP-адреса меняются нечасто. Это означает, что полный процесс разрешения имён не происходит для каждого сайта, с которым мы пытаемся связаться.

Вооружившись IP-адресом назначения, хост может начать отправлять данные по сети в виде серии IP-пакетов. IP доставляет данные от источника к хосту назначения на основе IP-адресов в заголовках пакетов. IP определяет структуру пакета, который содержит данные, подлежащие доставке, а также данные заголовков, включающие IP-адреса источника и назначения. Данные, отправляемые приложением, разбиваются на ряд пакетов, которые независимо друг от друга передаются через Интернет.

IP известен как протокол негарантированной доставки. Это означает, что он не пытается компенсировать различные ошибки, которые могут возникнуть во время передачи пакетов. Возможные ошибки передачи включают повреждение данных, потерю пакетов и дублирование. Кроме того, каждый пакет направляется через Интернет от источника к месту назначения независимо. Независимая обработка каждого пакета известна как коммутация пакетов. Это позволяет сети динамически реагировать на такие условия, как отказ сетевого канала и перегрузка, и, следовательно, является определяющей характеристикой Интернета. Однако это означает, что разные пакеты могут быть доставлены в один и тот же пункт назначения по разным сетевым маршрутам, что приведет к непоследовательной доставке к получателю.

Из-за такой конструкции IP является ненадёжным. Если двум узлам требуется надёжная передача данных, им необходимо добавить дополнительные функции. Именно здесь на сцену выходит следующий уровень в наборе протоколов IP — транспортный.
Протокол управления передачей (TCP)
После того как приложение или браузер определили IP-адрес сервера, с которым они хотят установить связь, они могут отправлять сообщения, используя API транспортного протокола. Для этого используются TCP или UDP, которые являются популярными стандартными транспортными протоколами для сетевого стека IP.

Распределённые приложения могут выбирать, какой из этих протоколов использовать. Реализации широко доступны в основных языках программирования, таких как Java, Python и C++. В действительности использование этих API не так часто встречается, поскольку абстракции программирования более высокого уровня скрывают детали от большинства приложений. На самом деле прикладной уровень набора протоколов IP содержит несколько таких API на уровне приложений, включая HTTP, который очень широко используется в популярных распределённых системах.

Тем не менее, важно понимать TCP, UDP и их различия. Большинство запросов в Интернете отправляются с помощью TCP. Он:

  • Ориентирован на соединение;
  • Ориентирован на поток;
  • Надёжен.

Ниже я расскажу о каждом из этих качеств и о том, почему они важны.

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

После установления соединения клиент отправляет на сервер последовательность запросов в виде потока данных. Когда поток данных отправляется по TCP, он разбивается на отдельные сетевые пакеты, максимальный размер которых составляет 65 535 байт. Каждый пакет содержит адрес источника и назначения, который используется протоколом IP для маршрутизации сообщений по сети.
Интернет — это сеть с коммутацией пакетов, что означает, что каждый пакет индивидуально маршрутизируется по сети. Маршрут, по которому проходит каждый пакет, может динамически меняться в зависимости от условий в сети, таких как перегрузка или отказ канала. Это означает, что пакеты могут прибыть на сервер не в том же порядке, в котором они были отправлены клиентом. Чтобы решить эту проблему, отправитель TCP включает в каждый пакет порядковый номер, чтобы получатель мог собрать пакеты в поток, идентичный тому, в котором они были отправлены.

Надёжность необходима, поскольку сетевые пакеты могут быть потеряны или задержаны при передаче между отправителем и получателем. Для обеспечения надёжной доставки пакетов TCP использует механизм накопительного подтверждения. Это означает, что приёмник будет периодически отправлять пакет подтверждения, содержащий наибольший порядковый номер из всех пакетов, полученных без пробелов в потоке пакетов. Это неявно подтверждает все отправленные пакеты с меньшим порядковым номером, означая, что все они были успешно получены. Если отправитель не получает подтверждения в течение периода таймаута, пакет отправляется повторно.

TCP имеет множество других функций, таких как контрольные суммы для проверки целостности пакетов и динамический контроль потока, чтобы отправитель не перегружал медленный приёмник, отправляя данные слишком быстро. Наряду с установлением соединения и подтверждением, это делает TCP относительно тяжеловесным протоколом, в котором надёжность превыше эффективности.

Именно здесь на помощь приходит UDP. UDP — это простой протокол без соединений, который делает программу пользователя уязвимой для любой ненадёжности базовой сети. Нет никакой гарантии, что доставка произойдёт в установленном порядке, или что она вообще произойдёт. Его можно рассматривать как тонкую оболочку (слой) поверх основного протокола IP, и он сознательно отдаёт предпочтение чистой производительности перед надёжностью.

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

На Рисунке 3-3 показаны некоторые из основных различий между TCP и UDP. TCP включает в себя трёхпакетное рукопожатие при установлении соединения (SYN, SYN ACK, ACK) и передачу подтверждений (ACK) пакетов, так что любая потеря пакетов может быть обработана протоколом. Существует также фаза закрытия TCP-соединения, включающая четырёхстороннее рукопожатие, которая не показана на диаграмме. UDP обходится без установления, разрыва, подтверждения и повторных попыток соединения. Поэтому приложения, использующие UDP, должны быть готовы к потере пакетов и сбоям в работе клиента или сервера (и вести себя соответствующим образом).
Рисунок 3-3. Сравнение TCP и UDP
Удаленный вызов метода
Вполне реально писать наши распределённые приложения, используя низкоуровневые API, которые напрямую взаимодействуют с протоколами транспортного уровня TCP и UDP. Наиболее распространённым подходом является стандартизированная библиотека сокетов — см. краткий обзор ниже. Надеюсь, вам никогда не придётся этого делать, поскольку сокеты сложны и чреваты ошибками. По сути, сокеты создают двунаправленную трубу между двумя узлами, которую можно использовать для передачи потоков данных. Существуют (к счастью) способы получше для построения распределённых коммуникаций, которые я опишу в этом разделе. Эти подходы абстрагируют большую часть сложностей, связанных с использованием сокетов. Однако сокеты все еще скрываются под ними, поэтому некоторые знания все еще необходимы.

Обзор сокетов


Сокет (socket) — это одна из конечных точек двустороннего сетевого соединения между клиентом и сервером. Сокеты идентифицируются комбинацией IP-адреса узла и абстракции, известной как порт. Порт — это уникальный числовой идентификатор, который позволяет узлу поддерживать связь для нескольких приложений, работающих на одном узле.
Каждый IP-адрес может поддерживать 65 535 TCP-портов и еще 65 535 UDP-портов. На сервере каждая комбинация {<IP-адрес>, <порт>} может быть связана с конкретным приложением. Эта комбинация образует уникальную конечную точку, которую транспортный уровень использует для доставки данных на нужный сервер.

Сокетное соединение идентифицируется уникальной комбинацией IP-адресов и портов клиента и сервера, а именно <IP-адрес клиента, порт клиента, IP-адрес сервера, порт сервера>. Для каждого уникального соединения также выделяется дескриптор сокета как на клиенте, так и на сервере. После создания соединения клиент отправляет данные на сервер в виде потока, а сервер отвечает результатами. Библиотека сокетов поддерживает оба протокола, с опцией SOCK_STREAM для TCP и SOCK_DGRAM для UDP.

Вы можете писать распределённые приложения непосредственно на API сокетов, который является основным компонентом операционной системы. API сокетов доступен во всех основных языках программирования. Однако библиотека сокетов — это низкоуровневый, сложный в использовании API. Вы должны избегать его, если у вас нет реальной необходимости писать код системного уровня.
В нашем примере с мобильным банком клиент может запросить баланс расчётного счёта пользователя, используя сокеты. Не обращая внимания на специфические языковые проблемы (и безопасность!), клиент может отправить полезную нагрузку (payload) сообщения через соединение с сервером следующим образом:
{ "balance", "000169990" }.
В этом сообщении "balance" представляет собой операцию, которую мы хотим, чтобы сервер выполнил, а "000169990" — это номер банковского счёта.

Серверу необходимо знать, что первая строка в сообщении — это идентификатор операции, поскольку её значение равно "balance", а вторая — номер банковского счёта. Затем сервер использует эти значения для предполагаемого запроса к базе данных, получения баланса и отправки результатов, возможно, в виде сообщения, отформатированного с указанием номера счёта и текущего баланса, как показано ниже:
{"000169990", "220.77"}
В любой сложной системе сервер поддерживает множество операций. В igbank.com это могут быть, например, "вход", "перевод", "адрес", "выписка", "операции" и так далее. Каждая из них будет сопровождаться различными сообщениями, которые сервер должен правильно интерпретировать, чтобы выполнить запрос клиента.

То, что мы определяем здесь — это протокол для конкретного приложения. Если мы отправляем необходимые значения в правильном порядке для каждой операции, сервер сможет ответить правильно. Если у нас есть ошибочный клиент, который не придерживается протокола нашего приложения, то серверу необходимо провести тщательную проверку ошибок. Библиотека сокетов предоставляет примитивный, низкоуровневый метод взаимодействия клиента и сервера. Он обеспечивает высокоэффективную связь, но его сложно правильно реализовать и создать прикладной протокол, учитывающий все возможности. Существуют более эффективные механизмы.
Если бы мы определяли интерфейс сервера igbank.com на объектно-ориентированном языке, таком как Java, то каждая операция, которую он может обработать, была бы представлена в виде метода. Каждому методу передаётся соответствующий список параметров для этой операции, как показано в этом примере кода:
// Простой интерфейс сервера igbank.com 
public interface IGBank {
public float balance (String accNo); 
public boolean statement(String month);
// другие операции
}
Наличие такого интерфейса имеет ряд преимуществ, а именно:

  • Вызовы от клиента к серверу могут быть статически проверены компилятором, чтобы убедиться, что они имеют правильный формат и типы аргументов.
  • Изменения в интерфейсе сервера (например, добавление нового параметра) заставляют изменять код клиента, чтобы он соответствовал новой сигнатуре метода.
  • Интерфейс чётко определён в описании класса и поэтому понятен и прост для программиста-клиента.

Эти преимущества явного интерфейса, конечно, хорошо известны инженерам-программистам. Вся дисциплина объектно-ориентированного проектирования в значительной степени базируется на этих основах, где интерфейс определяет контракт между вызывающей и вызываемой сторонами. По сравнению с неявным протоколом приложения, которому мы должны следовать при работе с сокетами, эти преимущества весьма существенны.

Этот факт был признан довольно давно при создании распределённых систем. С начала 1990-х годов мы наблюдаем развитие технологий, которые позволяют нам определять явные интерфейсы серверов и вызывать их по сети, используя практически тот же синтаксис, что и в последующей программе. Краткое описание основных подходов приведено в Таблице 3-2. В совокупности они известны как технологии удалённого вызова процедур (Remote Procedure Call — RPC) или удалённого вызова методов (Remote Method Invocation — RMI).
Хотя синтаксис и семантика этих технологий RPC/RMI отличаются, суть работы каждой из них одна и та же. Продолжим наш Java-пример с сайтом igbank.com, чтобы рассмотреть весь класс подходов. Java предлагает API Remote Method Invocation (RMI) для создания клиент-серверных приложений.

Используя Java RMI, мы можем тривиально превратить наш пример интерфейса IGBank, приведённый выше, в удалённый интерфейс, как показано в следующем коде:
import java.rmi.*;
// Простой интерфейс сервера igbank.com 
public interface IGBank extends Remote{ 
public float balance (String accNo)
throws RemoteException;
public boolean statement(String month) 
throws RemoteException;
// другие операции
}
Интерфейс java.rmi.Remote служит маркером, информирующим компилятор Java о том, что мы создаем RMI-сервер. Кроме того, каждый метод должен выбрасывать исключение java.rmi.RemoteException. Эти исключения представляют собой ошибки, которые могут возникнуть, когда распределённый вызов между двумя объектами происходит по сети. Наиболее распространёнными причинами возникновения таких исключений являются обрыв связи или падение сервера.

Затем мы должны создать класс, реализующий этот удалённый интерфейс. Пример кода ниже показывает выдержку из реализации сервера:
public class IGBankServer extends UnicastRemoteObject 
implements IGBank {
// реализации конструкторов/методов опущены
public static void main(String args[]){
try{
IGBankServer server=new IGBankServer();
// создаем реестр в локальной JVM на порту по умолчанию 
Registry registry = LocateRegistry.createRegistry(1099); registry.bind("IGBankServer", server); System.out.println("сервер готов");
}catch(Exception e){
// код опущен для краткости}
}
}
Следует отметить некоторые моменты:
  • Сервер расширяет класс UnicastRemoteObject. По сути, он предоставляет функциональность для инстанцирования удалённо вызываемого объекта.
  • После создания объекта сервера его доступность должна быть объявлена удалённым клиентам. Это достигается путем хранения ссылки на объект в системной службе, известной как реестр RMI, и привязки к нему логического имени — в данном примере "IGBankServer". Реестр — это простая служба каталогов, которая позволяет клиентам искать местоположение (сетевой адрес и ссылку на объект) и получить ссылку на RMI-сервер, просто указав логическое имя, с которым он связан в реестре.

Выдержка из кода клиента для подключения к серверу показана в следующем примере. Он получает ссылку на удалённый объект, выполнив операцию поиска в реестре RMI и указав логическое имя, идентифицирующее сервер. Ссылка, возвращённая операцией lookup, может быть использована для вызова объекта сервера таким же образом, как и локального объекта. Однако есть одно отличие — клиент должен быть готов перехватить исключение RemoteException, которое будет выброшено средой выполнения Java, когда объект сервера не будет доступен:
// получаем удаленную ссылку на сервер 
IGBank bankServer=
(IGBank)Naming.lookup("rmi://localhost:1099/IGBankServer");
//теперь мы можем вызвать сервер
System.out.println(bankServer.balance("00169990"));
На рисунке 3-4 показана последовательность вызовов компонентов, составляющих систему RMI. Заглушка (Stub) и скелет (Skeleton) — это объекты, сгенерированные компилятором на основе определения интерфейса RMI, и они обеспечивают фактические удалённые коммуникации. Скелет фактически является конечной точкой сети TCP (хост, порт), которая прослушивает вызовы связанного сервера.
Рисунок 3-4. Схема, изображающая последовательность вызовов для установления соединения и обращения к объекту сервера RMI
Последовательность операций следующая:

  1. Когда сервер запускается, его логическая ссылка сохраняется в реестре RMI. Эта запись содержит заглушку Java-клиента, которая может быть использована для удалённых вызовов сервера.
  2. Клиент запрашивает реестр, и ему возвращается заглушка для сервера.
  3. Заглушка клиента принимает вызов метода к интерфейсу сервера от реализации Java-клиента.
  4. Заглушка преобразует запрос в один или несколько сетевых пакетов, которые отправляются на серверный узел. Этот процесс преобразования известен как маршаллинг (marshalling).
  5. Скелет принимает сетевые запросы от клиента и выполняет демаршаллинг данных сетевых пакетов в корректный вызов реализации объекта сервера RMI. Демаршаллинг противоположен маршаллингу — он принимает последовательность сетевых пакетов и преобразует их в вызов объекта.
  6. Скелет ждет, пока метод вернёт ответ.
  7. Скелет выполняет маршаллинг результатов работы метода в ответный сетевой пакет, который возвращается клиенту.
  8. Заглушка выполняет демаршаллинг данных и передаёт результат туда, где расположен вызывающий Java-клиент.
Этот пример Java RMI иллюстрирует основы, которые используются для реализации любого механизма RPC/RMI, даже в таких современных языках, как Erlang и Go. Скорее всего, вы столкнетесь с Java RMI при использовании технологии Java Enterprise JavaBeans (EJB). EJB — это модель компонентов на стороне сервера, построенная на RMI, которая получила широкое распространение в корпоративных системах за последние 20 с лишним лет.

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

RPC/RMI не лишен недостатков. Маршаллинг и демаршаллинг могут оказаться неэффективными для сложных параметров-объектов. Межъязыковой маршаллинг — клиент на одном языке, сервер на другом — может вызвать проблемы из-за того, что типы в разных языках представлены по-разному, что приводит к сложно уловимой несовместимости. А если сигнатура удалённого метода изменяется, всем клиентам необходимо получить новую совместимую заглушку, что может быть обременительным при сложных сценариях развертывания.

По этим причинам большинство современных систем построено на более простых протоколах, основанных на HTTP и использующих JSON для представления параметров. Вместо имён операций, глаголы HTTP (PUT, GET, POST и т. д.) имеют ассоциированную семантику, которая привязывается к определенному URL. Этот подход зародился в работе Роя Филдинга над подходом REST. REST имеет набор семантик, которые являются основой стиля архитектуры RESTful, но в реальности большинство систем не придерживаются их. Мы обсудим механизмы REST и HTTP API в главе 5.
Частичные отказы
Компоненты распределённых систем взаимодействуют через сеть. В терминологии коммуникационных технологий общие локальные и глобальные сети, по которым взаимодействуют наши системы, называются асинхронными сетями.

В асинхронных сетях:

  • Узлы могут отправлять данные другим узлам в любое время.
  • Сеть является полудуплексной, то есть один узел посылает запрос и должен ждать ответа от другого. Это две отдельные коммуникации.
  • Время передачи данных между узлами может быть разным по таким причинам, как перегрузка сети, динамическая маршрутизация пакетов и временные сбои в соединении.
  • Принимающий узел может быть недоступен из-за сбоя программного обеспечения или машины.
  • Данные могут быть потеряны. В беспроводных сетях пакеты могут быть повреждены и, следовательно, потеряны из-за слабого сигнала или помех. Интернет-маршрутизаторы могут отбрасывать пакеты при перегрузке.
  • Узлы не имеют одинаковых внутренних часов, поэтому они не синхронизированы.

В отличии от синхронных сетей, которые, по сути, являются полнодуплексными, передавая данные в обоих направлениях одновременно, каждый узел должен иметь идентичные часы для синхронизации.
Что это значит для наших приложений? Проще говоря, когда клиент отправляет запрос на сервер, как долго он должен ждать ответа? Может быть, серверный узел просто медленный? Может быть, сеть перегружена и пакет был отброшен маршрутизатором? Если клиент не получает ответа, что ему следует делать?

Давайте рассмотрим эти сценарии подробнее. Основная проблема здесь, а именно, получен ли ответ и когда он получен, известна как обработка частичных отказов, и общая ситуация изображена на Рисунке 3-5.
Рисунок 3-5. Обработка частичных отказов
Когда клиент хочет подключиться к серверу и обменивается сообщениями, могут произойти следующие события:

  • Запрос проходит успешно, и на него приходит быстрый ответ. Все хорошо. (В действительности такой исход происходит почти с каждым запросом. Почти — это главное слово).
  • Поиск IP-адреса назначения может завершиться неудачей. В этом случае клиент быстро получает сообщение об ошибке и может действовать соответствующим образом.
  • IP-адрес действителен, но на узле назначения или в целевом серверном процессе произошел сбой. Отправитель получит сообщение об ошибке тайм-аута и сможет сообщить об этом пользователю.
  • Запрос поступает на целевой сервер, который не справляется с обработкой запроса и не отправляет ответ.
  • Запрос поступает на целевой сервер, который сильно загружен. Он обрабатывает запрос, но на ответ уходит много времени (например, 34 секунды).
  • Запрос принимается целевым сервером и отправляется ответ. Однако клиент не получает ответ из-за сбоя в сети.
С первыми тремя пунктами клиент справляется легко, поскольку ответ приходит быстро. Результат от сервера или сообщение об ошибке — и то, и другое позволяет клиенту продолжить работу. Со сбоями, которые можно быстро обнаружить, легко справиться.

Остальные результаты представляют проблему для клиента. Они не дают никакого представления о причинах неполучения ответа. С точки зрения клиента, эти три результата выглядят одинаково. Клиент не может знать, не ожидая (потенциально вечно), придет ли ответ в конце концов или не придет никогда; вечное ожидание не даёт возможности выполнить много работы.

Ещё более коварно то, что клиент не может знать, прошла ли операция успешно, но из-за сбоя сервера или сети результат так и не был получен, или же запрос уже в пути, но задерживается просто из-за перегрузки сети/сервера. Эти ошибки в совокупности известны как ошибки сбоя (crash faults).

Типичное решение, которое принимают клиенты для обработки сбоев — это повторная отправка запроса после заданного периода тайм-аута. Однако это чревато опасностью, как показано на Рисунке 3-6. Клиент посылает серверу запрос на зачисление денег на банковский счёт. Не получив ответа по истечении тайм-аута, он отправляет запрос повторно. Каков будет итоговый баланс? Сервер может внести депозит, а может и не внести, в зависимости от сценария частичного отказа.
Рисунок 3-6. Клиент повторяет запрос по истечении времени
Вероятность того, что депозит может быть внесён дважды, вполне устраивает клиента, но банк вряд ли будет в восторге от такой возможности. Поэтому нам нужен способ обеспечить в нашей реализации серверных операций, чтобы повторные, дублирующие друг друга запросы от клиентов приводили к тому, что запрос применялся только один раз. Это необходимо для поддержания корректной семантики приложения.

Это свойство известно как идемпотентность (idempotence). Идемпотентные операции могут применяться много раз без изменения результата после первоначального применения. Это означает, что в примере на Рисунке 3-6 клиент может повторять запрос сколько угодно раз, а счёт будет увеличен только на 100 долларов.

Запросы, которые не изменяют постоянное состояние, естественно, являются идемпотентными. Это означает, что все запросы на чтение безопасны по своей сути и не требуют дополнительной работы на сервере. Обновления — совсем другое дело. Система должна разработать такой механизм, чтобы дублирующие друг друга клиентские запросы не вызывали изменений состояния и могли быть обнаружены сервером. В терминах API эти конечные точки вызывают мутацию состояния сервера и поэтому должны быть идемпотентными.

Общий подход к построению идемпотентных операций заключается в следующем:

  • Клиенты включают уникальный ключ идемпотентности (idempotency key) во все запросы, изменяющие состояние. Ключ идентифицирует одну операцию от конкретного клиента или источника событий. Он обычно представляет собой композицию из идентификатора пользователя, например, ключа сессии, и уникального значения, например, локальной временной метки, UUID или номера последовательности.
  • Когда сервер получает запрос, он проверяет, не встречалось ли ему ранее значение ключа идемпотентности, считывая его из базы данных, предназначенной для реализации идемпотентности. Если ключа в базе данных нет, это новый запрос. Поэтому сервер выполняет бизнес-логику для обновления состояния приложения. Он также сохраняет ключ идемпотентности в базе данных, чтобы указать, что операция была успешно применена.
  • Если ключ идемпотентности находится в базе данных, это указывает на то, что данный запрос является повторным запросом от клиента и, следовательно, не должен обрабатываться. В этом случае сервер возвращает корректный ответ на операцию, чтобы (надеемся) клиент не повторял попыток.
База данных, используемая для хранения ключей идемпотентности, может быть реализована, например, как:

  • Отдельная таблица или коллекция в транзакционной базе данных, используемой для данных приложения;
  • Специальная база данных, обеспечивающая очень низкую задержку при поиске, например, простое хранилище ключ-значение.

В отличие от данных приложения, ключи идемпотентности не нужно хранить вечно. Как только клиент получает подтверждение об успехе отдельной операции, ключ идемпотентности может быть выброшен. Самый простой способ добиться этого — автоматически удалять ключи идемпотентности из хранилища по истечении определенного периода времени, например 60 минут или 24 часа, в зависимости от потребностей приложения и объёма запросов.

Кроме того, реализация идемпотентного API должна обеспечить изменение состояния приложения и сохранение ключа идемпотентности. Для успеха должно произойти и то, и другое. Если состояние приложения изменено, а ключ идемпотентности по какой-то причине не сохранён, то повторная попытка приведет к тому, что операция будет применена дважды. Если ключ идемпотентности сохранён, но по какой-то причине состояние приложения не изменено, то операция не была применена. Если повторная попытка поступит, она будет отфильтрована как дублирующая, поскольку ключ идемпотентности уже существует, и обновление будет потеряно.

Здесь подразумевается, что обновления состояния приложения и хранилища ключей идемпотентности должны происходить одновременно, либо не происходить ни одного из них. Если вы знаете свои базы данных, вы поймёте, что это требование транзакционной семантики. О том, как осуществляются распределённые транзакции, мы поговорим в Главе 12. По сути, транзакции обеспечивают семантику "ровно один раз" (exactly-once) для операций, что гарантирует, что все сообщения всегда будут обработаны ровно один раз — именно то, что нам нужно для идемпотентности.

Ровно один раз — это не значит, что не будет сбоев в передаче сообщений, повторных попыток и сбоев в работе приложений. Все они неизбежны. Главное, что повторные попытки в конечном итоге заканчиваются успехом и результат всегда один и тот же.

Мы вернёмся к вопросу о гарантиях доставки сообщений в последующих главах. Как показано на Рисунке 3-7, существует целый спектр семантик, каждая из которых имеет свои гарантии и характеристики производительности. Доставка по принципу “максимум один раз” (at-most-once) — быстрая и ненадёжная — это то, что обеспечивает протокол UDP. Доставка "хотя бы один раз" (at-least-once) — это гарантия, предоставляемая TCP/IP, что означает неизбежность дублирования. Доставка "ровно один раз", как мы уже обсуждали, требует защиты от дубликатов и, следовательно, является компромиссом между надёжностью и низкой производительностью.
Рисунок 3-7. Гарантии доставки сообщений
Как мы увидим, некоторые продвинутые механизмы связи могут обеспечить нашим приложениям семантику "ровно один раз". Однако они не работают в масштабах Интернета из-за последствий для производительности. Поэтому, поскольку наши приложения построены на семантике TCP/IP, мы должны реализовать семантику "хотя бы один раз" в наших API, которые вызывают мутацию состояния.
Консенсус в распределённых системах
Неисправности при сбоях имеют ещё одну интерпретацию для того, как мы строим распределенные системы. Лучше всего это видно на примере проблемы двух генералов, которая изображена на Рисунке 3-8.
Рисунок 3-8. Проблема двух генералов
Представьте себе город, осаждаемый двумя армиями. Армии находятся по разные стороны города, а местность, окружающая город, труднопроходима и хорошо видна снайперам в городе. Чтобы одолеть город, очень важно, чтобы обе армии атаковали одновременно. Это растянет оборону города и сделает победу более вероятной для нападающих. Если же атакует только одна армия, то она, скорее всего, будет отбита.

Учитывая эти ограничения, как два генерала могут договориться о точном времени нападения, чтобы оба генерала точно знали, что соглашение достигнуто? Им обоим нужна уверенность в том, что другая армия нападет в оговорённое время, иначе произойдёт катастрофа.

Чтобы скоординировать атаку, первый генерал посылает гонца к другому с указанием атаковать в определённое время. Поскольку гонец может быть захвачен или убит снайперами, отправляющий генерал не может быть уверен, что сообщение дошло, пока не получит подтверждающий посыл от второго генерала. Конечно, гонец с ответом тоже может быть захвачен или убит, поэтому даже если первоначальный гонец дойдёт, первый генерал об этом не узнает. И даже если сообщение с подтверждением придёт, как второй генерал узнает об этом, если только он не получит подтверждение от первого генерала?

Надеюсь, проблема очевидна. Поскольку гонцы захватываются или уничтожаются случайным образом, нет никакой гарантии, что два генерала придут к консенсусу по поводу времени атаки. Более того, можно доказать, что невозможно гарантировать достижение согласия. Существуют решения, которые повышают вероятность достижения консенсуса. Например, в стиле "Игры престолов", каждый генерал может каждый раз посылать 100 разных гонцов, и даже если большинство из них будут убиты, это увеличит вероятность того, что хотя бы один из них проделает опасный путь к другой дружественной армии и успешно доставит сообщение.

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

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

К счастью, это лишь теоретическое ограничение, поскольку невозможно гарантировать достижение консенсуса при неограниченных задержках сообщений в асинхронной сети. В реальности распределённые системы постоянно достигают консенсуса. Это возможно, потому что, хотя наши сети асинхронны, мы можем установить разумные практические ограничения на задержку сообщений и повторить попытку после тайм-аута. Таким образом, FLP — это наихудший сценарий, и поэтому в главе 12 я буду обсуждать алгоритмы достижения консенсуса в распределённых базах данных.

Наконец, стоит обратить внимание на проблему Византийских неудач (Byzantine failures). Представьте, что проблема двух генералов расширяется до N генералов, которым нужно договориться о времени атаки. Однако в этом сценарии предательские посланники могут изменить значение времени атаки, или генерал-предатель может послать ложную информацию другим генералам.

Этот класс вредоносных сбоев известен как Византийские ошибки (Byzantine faults) и особенно опасен в распределённых системах. К счастью, системы, которые мы рассматриваем в этой книге, обычно находятся в хорошо защищённых, безопасных корпоративных сетях и административных средах. Это означает, что на практике мы можем исключить обработку Византийских ошибок. Алгоритмы, устраняющие подобное вредоносное поведение, существуют, и если вас интересует практический пример, посмотрите на механизмы консенсуса блокчейна и биткойна.
Время в распределённых системах
Каждый узел распределённой системы имеет свои собственные внутренние часы. Если бы все часы на каждой машине были идеально синхронизированы, мы всегда могли бы просто сравнивать временные метки событий на разных узлах, чтобы определить точный порядок их наступления. Если бы это было реальностью, многие проблемы, которые я буду обсуждать с распределёнными системами, практически исчезли бы.

К сожалению, это не так. Часы на отдельных узлах дрейфуют под воздействием внешних условий, таких как изменение температуры или напряжения. Величина дрейфа различна для каждой машины, но такие значения, как 10-20 секунд в день, не являются редкостью. (Или, например, для моей домашней кофеварки — около 5 минут в день!).

Если не принять меры, дрейф часов сделает время на узле бессмысленным, как время на моей кофеварке, если я не буду корректировать его каждые несколько дней. Для решения этой проблемы существует ряд служб времени. Служба времени представляет собой источник точного времени, например GPS или атомные часы, которые можно использовать для периодической переустановки часов на узле с целью коррекции дрейфа в сетях передачи данных с коммутацией пакетов и переменной задержкой.

Наиболее широко используемой службой времени является протокол сетевого времени (Network Time Protocol — NTP), который представляет собой иерархически организованную коллекцию серверов времени, расположенных по всему миру. Корневые серверы, которых в мире насчитывается около 300, являются самыми точными. Серверы времени на следующем уровне иерархии (около 20 000) периодически синхронизируются с корневым сервером с точностью до нескольких миллисекунд, и так далее по всей иерархии, максимум 15 уровней. Во всем мире насчитывается более 175 000 серверов NTP.

С помощью протокола NTP узел приложения, на котором запущен NTP-клиент, может синхронизироваться с NTP-сервером. Время на узле устанавливается путем обмена UDP-сообщениями с одним или несколькими NTP-серверами. Сообщения снабжены метками времени, и в процессе обмена сообщениями оценивается время, затраченное на их передачу. Это становится фактором в алгоритме, используемом NTP для определения времени, на которое должен быть сброшен клиент. Простая конфигурация NTP показана на рисунке 3-9. В локальной сети машины могут синхронизироваться с сервером NTP с точностью до нескольких миллисекунд.

Одним из интересных эффектов синхронизации NTP для наших приложений является то, что сброс часов может сдвинуть время локального узла вперёд или назад. Это означает, что если наше приложение измеряет время, необходимое для наступления событий (например, для расчёта времени отклика на событие), то возможно, что время окончания события может быть раньше времени начала, если протокол NTP установил локальное время назад.
Рисунок 3-9. Иллюстрация использования службы NTP
На самом деле вычислительный узел имеет два вида часов. Это:

Часы времени суток
Это количество миллисекунд, прошедших с полуночи 1 января 1970 года. В Java вы можете получить текущее время с помощью System.currentTimeMillis(). Это часы, которые могут быть сброшены по NTP, и, следовательно, могут скакать вперёд или назад, если они сильно отстают или опережают время NTP.

Монотонные часы
Он представляет собой количество времени (в секундах и наносекундах), прошедшее с неопределённого момента в прошлом, например с момента последнего перезапуска системы. Он будет двигаться только вперёд; однако он снова может быть не совсем точным показателем прошедшего времени, поскольку замирает во время такого события, как приостановка работы виртуальной машины. В Java вы можете получить текущее время монотонных часов с помощью System.nanoTime().

Приложения могут использовать службу NTP для обеспечения строгой синхронизации часов на каждом узле системы. Обычно приложениям требуется повторная синхронизация часов с интервалом от одного часа до одного дня. Это гарантирует, что часы остаются близкими по значению. Тем не менее, если приложению действительно необходимо точно знать порядок событий, происходящих на разных узлах, дрейф часов будет представлять опасность.

Существуют и другие службы времени, обеспечивающие более высокую точность, чем NTP. Chrony поддерживает протокол NTP, но обеспечивает гораздо более высокую точность и масштабируемость, чем NTP — именно поэтому его взяла на вооружение компания Facebook. Компания Amazon создала службу синхронизации времени Amazon Time Sync Service, установив в своих дата-центрах GPS и атомные часы. Эта услуга доступна бесплатно для всех клиентов AWS.

Вывод из этого обсуждения заключается в том, что наши приложения не могут полагаться на временные метки событий на разных узлах, чтобы представить реальный порядок этих событий. Дрейф часов даже на секунду или две делает межузловые временные метки бессмысленными для сравнения. Последствия этого станут понятны, когда мы начнем подробно обсуждать распределённые базы данных.
Резюме и дополнительная литература
В этой главе мы проделали большой объём работы, чтобы объяснить некоторые важные характеристики коммуникаций и времени в распределённых системах. Эти характеристики важно понимать проектировщикам и разработчикам приложений.

Ключевые вопросы, которые были раскрыты в этой главе, следующие:

  1. Коммуникации в распределённых системах могут прозрачно пересекать множество различных типов базовых физических сетей, включая Wi-Fi, беспроводные, глобальные и локальные сети. Поэтому время ожидания связи сильно варьируется и зависит от физического расстояния между узлами, физических свойств сети и временных перегрузок сети. В больших масштабах задержки между компонентами приложений должны быть максимально минимизированы (в рамках законов физики, конечно).
  2. Стек интернет-протокола обеспечивает надёжную связь в гетерогенных сетях благодаря сочетанию протоколов IP и TCP. Коммуникации могут нарушаться из-за сбоев в сетевой структуре связи и маршрутизаторах, которые делают узлы недоступными, а также из-за сбоев отдельных узлов. Ваш код будет испытывать различные накладные расходы TCP/IP, например, на установление соединения, и ошибки при сбоях в сети. Поэтому понимание основ набора протоколов IP важно для проектирования и отладки.
  3. Технологии RMI/RPC используют уровень TCP/IP для обеспечения абстракций для связи между клиентом и сервером, которые зеркально отражают локальные вызовы методов/процедур. Однако эти более абстрактные подходы к программированию всё ещё должны быть устойчивы к сетевым проблемам, таким как сбои и повторные передачи. Это наиболее очевидно в прикладных API, которые изменяют состояние на сервере, и должны быть спроектированы так, чтобы быть идемпотентными.
  4. Достижение согласия или консенсуса по состоянию между несколькими узлами при возникновении сбоев невозможно за ограниченное время в асинхронных сетях. К счастью, реальные сети, особенно локальные, быстры и в основном надёжны, что означает, что мы можем разработать алгоритмы, позволяющие достичь консенсуса на практике. Я расскажу об этом в третьей части книги, когда мы будем обсуждать распределённые базы данных.
  5. Не существует надёжного глобального источника времени, на который могли бы опираться узлы приложения для синхронизации своего поведения. Часы на отдельных узлах различаются и не могут использоваться для значимых сравнений. Это означает, что приложения не могут осмысленно сравнивать часы на разных узлах, чтобы определить порядок событий.

Эти вопросы будут обсуждаться во всех остальных частях этой книги. Многие из уникальных проблем и решений, которые принимаются в распределённых системах, вытекают из этих основ. От них никуда не деться!

Отличным источником для более подробного, более теоретического освещения всех аспектов распределённых систем является George Coulouris et al, Distributed Systems: Concepts and Design, 5th ed. (Pearson, 2001).

Что касается компьютерных сетей, то всё, что вы хотели узнать, и, несомненно, больше, вы найдете в книге James Kurose and Keith Ross's Computer Networking: A Top-Down Approach, 7th ed. (Pearson, 2017).