<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Все публикации подряд на Хабре [expanded by feedex.net]</title><link>https://habr.com/ru/articles/</link><description>Все публикации подряд на Хабре</description><atom:link href="https://feedex.net/feed/habr.com/ru/rss/articles/all/" rel="self"/><lastBuildDate>Tue, 23 Jun 2026 08:15:31 +0000</lastBuildDate><item><title>Электрохимический транзистор: что с патентами?</title><link>https://habr.com/ru/companies/onlinepatent/articles/1050814/?utm_source=habrahabr&amp;utm_medium=rss&amp;utm_campaign=1050814</link><description>&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;a href="https://habr.com/ru/users/Oksana_Nedvigina/" rel="nofollow"&gt;&lt;div&gt;&lt;img alt="" height="24" src="https://assets.habr.com/habr-web/release_2.325.7/client/img/avatars/018.png" width="24"&gt;&lt;/div&gt;&lt;/a&gt;&lt;span&gt;&lt;a href="https://habr.com/ru/users/Oksana_Nedvigina/" rel="nofollow"&gt;Oksana_Nedvigina&lt;/a&gt;&lt;span&gt;&lt;time datetime="2026-06-23T08:15:31.000Z" title="2026-06-23, 08:15"&gt;только что&lt;/time&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Обзор&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;div lang="ru"&gt;&lt;div id="post-content-body"&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;figure&gt;&lt;img src="https://habrastorage.org/r/w1560/getpro/habr/upload_files/7f1/8ad/528/7f18ad528d1a74be15fc0822a66472cd.png" width="662" height="487"&gt;&lt;/figure&gt;&lt;p&gt;Электрохимический транзистор — это электронное устройство, принцип работы которого основан на электрохимических процессах, а не на традиционных твердотельных полупроводниках с электронно-дырочным механизмом проводимости. Такие транзисторы используют электролиты, органические полупроводники и металлооксидные материалы с ионным и солитонным механизмами проводимости. &lt;/p&gt;&lt;p&gt;В электрохимическом транзисторе есть канал (полупроводниковый или проводящий слой), электроды истока и стока, а также электрод затвора, который находится в ионном контакте с каналом через электролит. Электролит может быть жидким, гелеобразным или твёрдым. Проводимость канала регулируется за счёт окислительно-восстановительных реакций и миграции ионов между каналом и электролитом. При подаче напряжения на электрод затвора происходит взаимодействие ионов из электролита с материалом канала, что изменяет плотность электронного заряда и, соответственно, ток стока. В зависимости от полярности напряжения и характера реакций канал может переходить из проводящего состояния в изолирующее и наоборот. Например, при положительном напряжении на затворе катионы из электролита вводятся в канал, что может привести к электрохимическому восстановлению материала (например, PEDOT:PSS) и снижению его проводимости. При снятии напряжения ионы возвращаются в электролит, и ток стока возвращается к первоначальному значению. Некоторые материалы канала могут удерживать мигрировавшие ионы даже после снятия напряжения на затворе, что позволяет использовать такие транзисторы в качестве запоминающих устройств. &lt;/p&gt;&lt;p&gt;О них сегодня мы и поговорим.&lt;/p&gt;&lt;h2&gt;Особенности&lt;/h2&gt;&lt;p&gt;Низкое рабочее напряжение (обычно менее 1 В), что делает электрохимические транзисторы перспективными для применения в биологических системах, биосенсорах и биоэлектронике.  &lt;/p&gt;&lt;p&gt;Возможность интеграции с биологическими системами. Это открывает пути для применения в нейроморфных вычислениях, нейронных интерфейсах, а также для мониторинга состояния здоровья, биомаркеров и других задач.  &lt;/p&gt;&lt;p&gt;Разнообразие материалов. В качестве электролита могут использоваться полимеры, ионные жидкости, ионные гели, водные жидкие электролиты. Для каналов часто применяют органические полупроводники, например, PEDOT:PSS, полипиррол, политиофен.&lt;/p&gt;&lt;p&gt;Примеры применения:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;p&gt;электрохимические биосенсоры; &lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;нейронные интерфейсы; &lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;нейроморфные устройства; &lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;мониторинг развития растений; &lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;носимые устройства для биомедицинского зондирования (измерение уровня натрия, калия в крови, частоты сердечных сокращений и т. д.). &lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;Таким образом, электрохимические транзисторы сочетают электронные и электрохимические процессы, что позволяет использовать их в специализированных областях, где требуется взаимодействие с химическими или биологическими средами.&lt;/p&gt;&lt;p&gt;Какие патенты на изобретения по этой теме существуют?&lt;/p&gt;&lt;p&gt;На портале Google.Patents поиск по запросу &lt;strong&gt;electrochemical transistor&lt;/strong&gt; показывал более 100 000 документов на июнь 2026 г. В рамках МПК рейтинг тематик следующий:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;p&gt;полупроводниковые приборы, предназначенные для преобразования энергии или информации &lt;a href="https://patents.google.com/?q=(electrochemical+transistor)&amp;amp;q=H10D&amp;amp;peid=65378030d2918%3A38%3A3b2d829a" rel="nofollow"&gt;H10D&lt;/a&gt; – 21,4%;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;исследование или анализ материалов путем определения их химических или физических свойств &lt;a href="https://patents.google.com/?q=(electrochemical+transistor)&amp;amp;q=G01N&amp;amp;peid=65378032ce230%3A3a%3Aa6d9531d" rel="nofollow"&gt;G01N&lt;/a&gt; – 19,7%;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;органические электрические твердотельные устройства &lt;a href="https://patents.google.com/?q=(electrochemical+transistor)&amp;amp;q=H10K&amp;amp;peid=6537802fe9e70%3A36%3Abbb64d57" rel="nofollow"&gt;H10K&lt;/a&gt; – 18,8%;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;оптические устройства &lt;a href="https://patents.google.com/?q=(electrochemical+transistor)&amp;amp;oq=electrochemical+transistor" rel="nofollow"&gt;G02F&lt;/a&gt; – 15,7%;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;сокращение выбросов парниковых газов &lt;a href="https://patents.google.com/?q=(electrochemical+transistor)&amp;amp;oq=electrochemical+transistor" rel="nofollow"&gt;Y02E&lt;/a&gt; – 15,6%;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;схемы или устройства управления индикаторными приборами с использованием статических средств для представления переменных величин &lt;a href="https://patents.google.com/?q=(electrochemical+transistor)&amp;amp;q=G09G&amp;amp;peid=653780252a318%3A2b%3A46c76181" rel="nofollow"&gt;G09G&lt;/a&gt; – 15,5%;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;способы и устройства, например батареи, для непосредственного преобразования химической энергии в электрическую &lt;a href="https://patents.google.com/?q=(electrochemical+transistor)&amp;amp;oq=electrochemical+transistor" rel="nofollow"&gt;H01M&lt;/a&gt; – 9,2%;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;и т.д.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;Динамика мирового патентования представлена на рис. 1.&lt;/p&gt;&lt;figure&gt;&lt;img src="https://habrastorage.org/r/w1560/getpro/habr/upload_files/5ed/ea8/561/5edea8561745135866fcb1d44667b565.png" alt="Источник: интерпретация автора данных Google.Patents  " title="Источник: интерпретация автора данных Google.Patents  " width="785" height="504"&gt;&lt;div&gt;&lt;figcaption&gt;&lt;em&gt;Источник: интерпретация автора данных Google.Patents &lt;/em&gt; &lt;/figcaption&gt;&lt;/div&gt;&lt;/figure&gt;&lt;p&gt;Видно, что в 1992-2007 активность патентования резко выросла, последние 20 лет была платообразной с небольшим пиком в 2010-2013 гг.&lt;/p&gt;&lt;p&gt;Рейтинг патентовладельцев следующий:&lt;/p&gt;&lt;ol&gt;&lt;li&gt;&lt;p&gt;&lt;a href="https://patents.google.com/?q=(electrochemical+transistor)&amp;amp;assignee=Seiko+Epson+Corporation&amp;amp;peid=65378118ab5a0%3A47%3A4c090afe" rel="nofollow"&gt;Seiko Epson Corporation&lt;/a&gt; – 7%;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;a href="https://patents.google.com/?q=(electrochemical+transistor)&amp;amp;assignee=Semiconductor+Energy+Laboratory+Co.%2c+Ltd.&amp;amp;peid=6537811e2dff0%3A49%3Ab35283d" rel="nofollow"&gt;Semiconductor Energy Laboratory Co., Ltd.&lt;/a&gt; – 3,1%;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;a href="https://patents.google.com/?q=(electrochemical+transistor)&amp;amp;oq=electrochemical+transistor" rel="nofollow"&gt;Samsung Display Co., Ltd.&lt;/a&gt; – 1%;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;a href="https://patents.google.com/?q=(electrochemical+transistor)&amp;amp;oq=electrochemical+transistor" rel="nofollow"&gt;The Regents Of The University Of California&lt;/a&gt; – 1%;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;a href="https://patents.google.com/?q=(electrochemical+transistor)&amp;amp;oq=electrochemical+transistor" rel="nofollow"&gt;The Board Of Trustees Of The University Of Illinois&lt;/a&gt; – 1%;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;a href="https://patents.google.com/?q=(electrochemical+transistor)&amp;amp;oq=electrochemical+transistor" rel="nofollow"&gt;Ethicon Llc&lt;/a&gt; – 0,9%;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;a href="https://patents.google.com/?q=(electrochemical+transistor)&amp;amp;oq=electrochemical+transistor" rel="nofollow"&gt;Acreo Ab&lt;/a&gt; – 0,8%;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;a href="https://patents.google.com/?q=(electrochemical+transistor)&amp;amp;oq=electrochemical+transistor" rel="nofollow"&gt;Monolithic 3D Inc.&lt;/a&gt; – 0,8%;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;a href="https://patents.google.com/?q=(electrochemical+transistor)&amp;amp;oq=electrochemical+transistor" rel="nofollow"&gt;Sony Corporation&lt;/a&gt; – 0,7%;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;a href="https://patents.google.com/?q=(electrochemical+transistor)&amp;amp;oq=electrochemical+transistor" rel="nofollow"&gt;Northwestern University&lt;/a&gt; – 0,7%;&lt;/p&gt;&lt;/li&gt;&lt;/ol&gt;&lt;p&gt;В ТОП-10 патентодержателей входят в основном японские, южнокорейские и американские компании и организации. При этом США в рейтинге представлены в основном вузами, а не техногигантами, что очень странно. Обычно в подобных рейтингах всегда присутствуют корпорации, вроде Intel или IBM. Вероятно, последним проще лицензировать или купить разработки у того же Калифорнийского университета. &lt;/p&gt;&lt;p&gt;Что примечательно, на втором месте &lt;a href="https://patents.google.com/?q=(gallium)&amp;amp;assignee=Semiconductor+Energy+Laboratory+Co.%2c+Ltd." rel="nofollow"&gt;Semiconductor Energy Laboratory Co., Ltd.&lt;/a&gt; О ее главе, патентом короле Японии Ямадзаки Сюмпэе, мы уже &lt;a href="https://habr.com/ru/companies/onlinepatent/articles/706412/" rel="nofollow"&gt;писали&lt;/a&gt; отдельный материал на Хабре. &lt;/p&gt;&lt;p&gt;Примеры патентов:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;p&gt;US10424751B2 Organic electrochemical transistors with tunable threshold voltage. Genesee Valley Innovations LLC. В одном варианте осуществления предлагается электронное устройство, которое может включать в себя, по меньшей мере, два органических электрохимических транзистора (ОЭХТ). Соответствующий ОЭХТ включает в себя проводящий канал, затвор, электрически связанный с проводящим каналом через первый электролит, и электроды истока и стока, отделенные друг от друга проводящим каналом. &lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;CN113607795B Double gate organic electrochemical transistor. Hong Kong Polytechnic University HKPU. Настоящее изобретение обеспечивает транзистор и способ обнаружения и/или определения концентрации анализируемого вещества в образце с использованием транзистора.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;US12364089B2 Pressure sensor device with organic electrochemical transistors with microstructured hydrogel gating medium. University of California San Diego UCSD. Раскрыт ионтронный датчик давления с низким энергопотреблением, основанный на OECT, в котором ионный гидрогель используется в качестве твердой стробирующей среды для сформированных на нем чувствительных к давлению транзисторных элементов. &lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;figure&gt;&lt;img src="https://habrastorage.org/r/w1560/getpro/habr/upload_files/edb/647/222/edb6472229fb8006f6b0190517393cb2.png" alt="Схема из патента US10424751B2" title="Схема из патента US10424751B2" width="1234" height="899"&gt;&lt;div&gt;&lt;figcaption&gt;Схема из патента US10424751B2&lt;/figcaption&gt;&lt;/div&gt;&lt;/figure&gt;&lt;h3&gt;Что в России?&lt;/h3&gt;&lt;p&gt;В базе ФИПС патентов РФ на изобретения по рефератам на &lt;strong&gt;электрохимический транзистор&lt;/strong&gt; поисковая машина выдаёт 32 ед. периода 1994-2026 гг., но большинство патентов составляет шум, например «электрохимическое осаждение плёнкой для полевых транзисторов», «защита от электрохимической коррозии устройствами с транзисторами». При ручной выборке обнаружено, увы, только два патента:&lt;/p&gt;&lt;p&gt;№&lt;a href="http://new.fips.ru/registers-doc-view/fips_servlet?DB=RUPAT&amp;amp;DocNumber=2796202&amp;amp;TypeFile=html" rel="nofollow"&gt;2796202&lt;/a&gt; (2023) &lt;em&gt;Способ изготовления биосенсорной структуры&lt;/em&gt;. Саратовский национальный исследовательский государственный университет имени Н.Г. Чернышевского. Изобретение относится к технологии изготовления сенсорных структур на основе твердотельного полупроводника и функционального органического покрытия и может быть использовано при создании ферментных биосенсоров на основе полевых транзисторов или структур «электролит-диэлектрик-полупроводник». &lt;/p&gt;&lt;figure&gt;&lt;img src="https://habrastorage.org/r/w1560/getpro/habr/upload_files/465/1c3/daa/4651c3daaa2ed9b12cca6be9e4e3d3ab.png" alt="Схема из патента №2796202" title="Схема из патента №2796202" width="609" height="324"&gt;&lt;div&gt;&lt;figcaption&gt;Схема из патента №2796202&lt;/figcaption&gt;&lt;/div&gt;&lt;/figure&gt;&lt;p&gt;№&lt;a href="http://new.fips.ru/registers-doc-view/fips_servlet?DB=RUPAT&amp;amp;DocNumber=2859283&amp;amp;TypeFile=html" rel="nofollow"&gt;2859283&lt;/a&gt; (2026) &lt;em&gt;Способ получения композитных пленок на основе гудрона.&lt;/em&gt; Национальный исследовательский Томский политехнический университет. Изобретение относится к получению композиций на основе органических высокомолекулярных соединений, а именно к получению композитных пленок на основе гудрона на подложке из полиэтилентерефталата, и может быть использовано при изготовлении электрохимических транзисторов и неметаллических антенн. &lt;/p&gt;&lt;p&gt;Патентов РФ на полезные модели по теме нет. Зарегистрированных баз данных, топологий интегральных схем и программ для ЭВМ по нашей теме в России нет. &lt;/p&gt;&lt;h3&gt;НИОКР&lt;/h3&gt;&lt;p&gt;По теме &lt;strong&gt;электрохимический транзистор&lt;/strong&gt; в базе ГИС «Наука» 68 документов на июнь 2026 года, в основном отчёты НИОКР.&lt;/p&gt;&lt;p&gt;Так, в 2025 г. Институт синтетических полимерных материалов им. Н.С. Ениколопова РАН за грант 38,2 млн руб. от Минобрнаука выполнил НИР «Разработка функциональных органических, полимерных и гибридных материалов с заданными полупроводниковыми, оптическими, электрохимическими, сенсорными и другими свойствами, необходимыми для обеспечения создания элементной базы электроники, фотоники и оптоэлектроники нового поколения на основе нанотехнологий». Ранее было сотрудничество с Курчатовским Комплексом Синхротронных и Нейтронных Исследований НИЦ Курчатовский Институт, 2022-2025 гг.&lt;/p&gt;&lt;p&gt;НИР «Молекулярное конструирование полимерных металлокомплексов с редокс-активными лигандами и разработка подходов к управлению их электронно-ионной проводимостью для электрохимических транзисторов нового поколения» в 2023-2025 гг. за 3 млн руб. выполнил Физико-технический институт им. А.Ф. Иоффе РАН. Целью являлось создание новых функциональных материалов для каналов электрохимических транзисторов на основе проводящих полимерных комплексов никеля (II) с саленовыми лигандами и комплексное исследование влияния природы мономера, условий электрополимеризации и осуществления окислительно-восстановительных превращений на параметры электронного и ионного транспорта в данных материалах. &lt;/p&gt;&lt;h3&gt;Заключение&lt;/h3&gt;&lt;p&gt;Уровень патентования в мире достаточно высокий последние 20 лет. В России он почти нулевой, хотя ведутся впечатляющие по умственной наполненности НИОКР, которые потенциально способны привести к изобретениям и соответствующим патентам.&lt;/p&gt;&lt;p&gt;Сделать точный прогноз рынка электрохимических транзисторов сложно из-за:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;p&gt;отсутствия массовых коммерческих продуктов;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;неопределённости в сроках преодоления технологических барьеров;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;конкуренции с более зрелыми технологиями (например, кремниевыми транзисторами).&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;Если технология получит широкое распространение, рост рынка может быть экспоненциальным, особенно в нишевых сегментах. Однако это произойдёт только при успешном решении проблем с надёжностью и производительностью.&lt;/p&gt;&lt;p&gt;Пока что мы видим множество патентов у японских, южнокорейских и американских компаний и организаций. &lt;/p&gt;&lt;details&gt;&lt;summary&gt;О сервисе Онлайн Патент:&lt;/summary&gt;&lt;div&gt;&lt;p&gt;Онлайн Патент — цифровая система №1 в рейтинге Роспатента. С 2013 года мы создаем уникальные LegalTech‑решения для защиты и управления интеллектуальной собственностью. Зарегистрируйтесь в &lt;a href="https://my.onlinepatent.ru/client/registration?context=Claim&amp;amp;type=TradeMark&amp;amp;utm%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_source=habr&amp;amp;utm%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_medium=smm&amp;amp;utm%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_campaign=habr%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_smm%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_postpodval260623" rel="nofollow"&gt;сервисе&lt;/a&gt; Онлайн Патент и получите доступ к следующим услугам: &lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;p&gt;Онлайн‑регистрация программ, патентов на изобретение, товарных знаков, промышленного дизайна;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;a href="https://onlinepatent.ru/uslugi/reestr/?utm%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_source=habr&amp;amp;utm%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_medium=smm&amp;amp;utm%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_campaign=habr%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_smm%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_postpodval2512%5C260623" rel="nofollow"&gt;Подача заявки на внесение в реестр отечественного ПО&lt;/a&gt;;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;a href="https://onlinepatent.ru/software/?utm%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_source=habr&amp;amp;utm%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_medium=smm&amp;amp;utm%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_campaign=habr%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_smm%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_postpodval260623" rel="nofollow"&gt;Поиск по программам&lt;/a&gt;;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;a href="https://onlinepatent.ru/uslugi/registraciya-programmy-dlya-evm/?utm%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_source=habr&amp;amp;utm%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_medium=smm&amp;amp;utm%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_campaign=habr%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_smm%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_postpodval260623" rel="nofollow"&gt;Регистрация программы в Роспатенте&lt;/a&gt;;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;a href="https://onlinepatent.ru/uslugi/registraciya-tovarnogo-znaka/?utm%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_source=habr&amp;amp;utm%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_medium=smm&amp;amp;utm%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_campaign=habr%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_smm%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C%5C_postpodval260623" rel="nofollow"&gt;Регистрация товарных знаков&lt;/a&gt;;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Опции ускоренного оформления услуг;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Бесплатный поиск по базам патентов, программ, товарных знаков;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Мониторинги новых заявок по критериям;&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Онлайн‑поддержку специалистов.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/details&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Oksana_Nedvigina (Online patent)</dc:creator><pubDate>Tue, 23 Jun 2026 08:15:31 +0000</pubDate><guid>https://habr.com/ru/companies/onlinepatent/articles/1050814/?utm_source=habrahabr&amp;utm_medium=rss&amp;utm_campaign=1050814</guid><category>транзистор</category><category>транзисторы</category><category>патенты</category><category>патентование</category><category>производство электроники</category><category>sony</category><category>samsung</category><category>ниокр</category><category>полупроводники</category><category>полупроводниковые приборы</category></item><item><title>Skills исправляют привычки MCP исправляет память</title><link>https://habr.com/ru/articles/1050802/?utm_source=habrahabr&amp;utm_medium=rss&amp;utm_campaign=1050802</link><description>&lt;div&gt;&lt;div&gt;&lt;svg height="24" width="24"&gt;Обновить&lt;/svg&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;article&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;a href="https://habr.com/ru/users/DarkenAmber/" rel="nofollow"&gt;&lt;div&gt;&lt;img alt="" height="24" src="https://habrastorage.org/r/w48/getpro/habr/avatars/961/bb7/9ff/961bb79ffbdb0d4e84344c7282d6a09f.png" width="24"&gt;&lt;/div&gt;&lt;/a&gt;&lt;span&gt;&lt;a href="https://habr.com/ru/users/DarkenAmber/" rel="nofollow"&gt;DarkenAmber&lt;/a&gt;&lt;span&gt;&lt;time datetime="2026-06-23T08:13:14.000Z" title="2026-06-23, 08:13"&gt;2 минуты назад&lt;/time&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;svg height="24" width="24"&gt;Время на прочтение&lt;/svg&gt;&lt;/span&gt;&lt;span&gt;2 мин&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Кейс&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;div lang="ru"&gt;&lt;div id="post-content-body"&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;p&gt;Я долго пытался решить одну проблему Claude Code в разных сессиях ведет себя поразному и это иногда просто ломает логику работы&lt;/p&gt;&lt;p&gt;сначала думал что дело в промптах потом начал думать что проблема в контексте но в итоге стало понятно что все немного глубже&lt;/p&gt;&lt;p&gt;.&lt;/p&gt;&lt;figure&gt;&lt;img src="https://habrastorage.org/r/w1560/getpro/habr/upload_files/cb8/3d5/471/cb83d5471f393c6e7546bedf3deb59e5.png" width="1920" height="973"&gt;&lt;/figure&gt;&lt;h2&gt;Что именно ломалось&lt;/h2&gt;&lt;p&gt;больше всего бесило то что модель сама начинала “улучшать” код даже когда ее об этом вообще не просили&lt;/p&gt;&lt;p&gt;она могла убирать тесты потому что так якобы чище или менять архитектуру которая уже нормально работала и это выглядело странно&lt;/p&gt;&lt;p&gt;вторая проблема это память между сессиями мы вроде договорились как должен выглядеть проект но через день это уже просто забывается&lt;/p&gt;&lt;p&gt;третье это MCP инструменты которые постепенно превращаются в кашу и потом уже непонятно где логика а где данные&lt;/p&gt;&lt;h2&gt;Я разделил систему на две части&lt;/h2&gt;&lt;p&gt;сначала я пытался просто улучшать промпты но это довольно быстро перестало работать&lt;/p&gt;&lt;p&gt;тогда я разделил систему на Skills которые отвечают за поведение и MCP которые отвечают за память и инструменты&lt;/p&gt;&lt;figure&gt;&lt;img src="https://habrastorage.org/r/w1560/getpro/habr/upload_files/1bb/c55/95a/1bbc5595a28249d34f5618c391255d60.png" width="1920" height="971"&gt;&lt;/figure&gt;&lt;h2&gt;Skills и первая ошибка&lt;/h2&gt;&lt;p&gt;Skill это просто &lt;a href="http://SKILL.md" rel="noopener noreferrer nofollow"&gt;SKILL.md&lt;/a&gt; файл и на первый взгляд это звучит очень просто&lt;/p&gt;&lt;p&gt;я сначала написал правило типа ship fast dont overthink и выглядело это нормально&lt;/p&gt;&lt;p&gt;но потом оказалось что модель начинает следовать этому слишком буквально&lt;/p&gt;&lt;p&gt;она начинала убирать тесты и упрощать код даже там где этого делать нельзя&lt;/p&gt;&lt;h2&gt;Пришлось добавлять ограничения&lt;/h2&gt;&lt;p&gt;после этого стало понятно что без явных ограничений это просто не работает нормально&lt;/p&gt;&lt;p&gt;я добавил правило Do NOT use when payments auth irreversible operations и только после этого стало хоть как то контролируемо&lt;/p&gt;&lt;h2&gt;Баги которые меня реально удивили&lt;/h2&gt;&lt;p&gt;один из первых багов был с asyncio и web сервером когда я просто соединил два подхода к event loop и все просто упало и сначала вообще непонятно было почему&lt;/p&gt;&lt;p&gt;потом была история с env переменными где ADMIN_IDS выглядели как обычная строка через запятую но pydantic ожидал json и все ломалось&lt;/p&gt;&lt;p&gt;самый неприятный баг был во Flutter когда длина строки считалась в символах а Google Drive ожидал байты и в итоге кириллица просто тихо обрезалась без ошибок&lt;br&gt;&lt;/p&gt;&lt;figure&gt;&lt;img src="https://habrastorage.org/r/w1560/getpro/habr/upload_files/948/61b/011/94861b0118df7c2616d02b426dd6052f.png" width="1920" height="921"&gt;&lt;/figure&gt;&lt;h2&gt;MCP и что там пошло не так&lt;/h2&gt;&lt;p&gt;memory kit это просто SQLite с FTS5 чтобы можно было хранить и искать память локально идея простая но на удивление рабочая&lt;/p&gt;&lt;p&gt;skills server внезапно начал ловить проблему с параллельными запросами когда один skill дергается много раз одновременно и GitHub просто не выдерживает&lt;/p&gt;&lt;p&gt;пришлось добавить asyncio Lock чтобы это не разлеталось&lt;/p&gt;&lt;p&gt;github MCP сначала вообще не имел нормальной валидации и потом стало понятно что repo строку можно сломать через странный input&lt;/p&gt;&lt;p&gt;telegram MCP я сначала хотел делать с передачей токена как параметра но потом понял что это плохая идея и теперь токен живет только в env и вообще не попадает в модель&lt;br&gt;&lt;/p&gt;&lt;h2&gt;Проект пока сырой&lt;/h2&gt;&lt;p&gt;я не пытаюсь делать вид что это какой то готовый фреймворк это скорее набор экспериментов который еще постоянно меняется&lt;/p&gt;&lt;p&gt;skills будут допиливаться MCP тоже будет меняться и возможно часть решений вообще окажется неправильной&lt;/p&gt;&lt;h2&gt;Что дальше&lt;/h2&gt;&lt;p&gt;планируется добавить - docs writer skill, test writer skill, новые MCP сервера и возможно mcpm как визуальный менеджер для всего этого&lt;/p&gt;&lt;h2&gt;Итог&lt;/h2&gt;&lt;p&gt;главное что я понял это то что проблема не в том что модель плохая а в том что у нее нет нормальных стабильных границ поведения и памяти&lt;/p&gt;&lt;p&gt;skills и MCP это просто попытка эти границы хоть как то зафиксировать&lt;/p&gt;&lt;figure&gt;&lt;img src="https://habrastorage.org/r/w1560/getpro/habr/upload_files/3ec/9cc/ac7/3ec9ccac7e8698b14ddee3c126c8437a.png" width="1920" height="974"&gt;&lt;/figure&gt;&lt;p&gt;&lt;br&gt;GitHub &lt;a href="https://github.com/DarkenAmber/claude-kit" rel="noopener noreferrer nofollow"&gt;https://github.com/DarkenAmber/claude-kit&lt;/a&gt;  &lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/article&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;img alt="Хабр Карьера Курсы" src="https://habrastorage.org/webt/qq/ey/pn/qqeypn-py71suynxbusbakjdfjw.png"&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;Хабр Курсы для всех&lt;/div&gt;&lt;div&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt; Практикум, Хекслет, SkyPro, авторские курсы — собрали всех и попросили скидки. Осталось выбрать! &lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">DarkenAmber</dc:creator><pubDate>Tue, 23 Jun 2026 08:13:14 +0000</pubDate><guid>https://habr.com/ru/articles/1050802/?utm_source=habrahabr&amp;utm_medium=rss&amp;utm_campaign=1050802</guid><category>Искусственный интеллект</category><category>программирование</category><category>системное администрирование</category><category>системное программирование</category></item><item><title>Регулирование криптовалют в России: убить то, что так и не смогли понять за прошедшие 17 лет</title><link>https://habr.com/ru/articles/1050810/?utm_source=habrahabr&amp;utm_medium=rss&amp;utm_campaign=1050810</link><description>&lt;div&gt;&lt;div&gt;&lt;svg height="24" width="24"&gt;Обновить&lt;/svg&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;article&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;a href="https://habr.com/ru/users/Hau515/" rel="nofollow"&gt;&lt;div&gt;&lt;img alt="" height="24" src="https://assets.habr.com/habr-web/release_2.325.7/client/img/avatars/042.png" width="24"&gt;&lt;/div&gt;&lt;/a&gt;&lt;span&gt;&lt;a href="https://habr.com/ru/users/Hau515/" rel="nofollow"&gt;Hau515&lt;/a&gt;&lt;span&gt;&lt;time datetime="2026-06-23T08:09:14.000Z" title="2026-06-23, 08:09"&gt;6 минут назад&lt;/time&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;svg height="24" width="24"&gt;Время на прочтение&lt;/svg&gt;&lt;/span&gt;&lt;span&gt;8 мин&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Мнение&lt;/span&gt;&lt;/div&gt;&lt;div&gt;&lt;a href="https://habr.com/ru/sandbox/" rel="nofollow"&gt;Из песочницы&lt;/a&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;div lang="ru"&gt;&lt;div id="post-content-body"&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;p&gt;Сразу обозначу: это моё личное мнение, и оно злое. Потому что то, что сейчас собираются принять под видом «регулирования криптовалют», — это не регулирование. Это управляемое уничтожение целой отрасли, замаскированное под оное «регулирование» и очередную «защиту наших граждан и их денег от них самих». И самое мерзкое — что делают это люди, которые либо не понимают, как работает крипта, либо прекрасно понимают и делают это сознательно, и тогда все еще хуже, чем кажется на первый взгляд.&lt;/p&gt;&lt;h3&gt;Ура! Наконец‑то ЦБ легализует криптовалюты!&lt;/h3&gt;&lt;p&gt;Рано радуетесь. ЦБ не запрещает криптовалюту прямо, и даже вроде бы как разрешает, а на самом деле — неявно её запрещает. Как? Очень просто: разрешает крупняку — Мосбирже, банкам и прочим «избранным», — а для всех остальных строит заградительные механики (о них — далее). И даже дело не в уставном капитале, это самое лайтовое из всего, что нас ждёт.&lt;/p&gt;&lt;h2&gt;Торгуют все! &lt;/h2&gt;&lt;p&gt;Начнём с «разрешённого» — 300 тысяч рублей в год на человека, такой торговый объём будет разрешён простым смертным. Ну праздник же. &lt;/p&gt;&lt;p&gt;Это целых 25 тысяч рублей в месяц. Цена одного похода в ресторан среднего уровня на двоих. Это меньше, чем билеты в театр на семью на не самые паршивые места (и, заметьте — даже без бутербродов в буфете). И вот это называют «инвестициями». &lt;/p&gt;&lt;p&gt;Это не инвестиции — это издевательство над людьми. Цирк, фарс и плевок в лицо людям, которые даже более‑менее понимают что такое криптовалюта. Это 1/195 биткоина. ОДНА СТОДЕВЯНОСТОПЯТАЯ. Тут даже пробелы ставить не хочется.&lt;/p&gt;&lt;p&gt;Инвестируйте на здоровье, это ведь гораздо более рискованно, чем Самолёт или ВТБ, или вообще весь наш рынок в целом (нет).&lt;/p&gt;&lt;h3&gt;Криптодепозитарии&lt;/h3&gt;&lt;p&gt;«Ладно, обычных смертных мы проехали и переехали, едем дальше — кого теперь мы переедем? А давайте переедем бизнес! Устроим монополию, ведь нам так скучно живётся, мы любим всё консолидировать, монополизировать и убивать МСП!» — мне кажется, примерно такие разговоры царят в курилках где‑то там, где пишутся грядущие законы, или что‑то в этом роде.&lt;/p&gt;&lt;p&gt;Познакомьтесь: прекрасная сущность под названием «криптодепозитарий». Что это такое и зачем? А это придумано ровно для того, чтобы обменники, биржи и кошельки не могли больше легально существовать. А если бы могли — то хранили бы криптовалюту ТОЛЬКО в этих самых депозитариях. Не у себя, не в своей инфраструктуре, которую они писали годы, интегрировали блокчейны, заботились о безопасности и собирали шишки, набирали опыт и вкладывали силы, время своей жизни и деньги. Всё это — на помойку, если хочешь работать в России. И пользуйся депозитариями, написанными кем? А тем, кто никогда до этого с криптовалютами в принципе не работал. &lt;/p&gt;&lt;p&gt;Иными словами: собираем всю ликвидность через обменники, кошельки и агентов, которые работают с физами, в один котёл, и там храним. «А чо, удобно же».&lt;/p&gt;&lt;p&gt;Кто это лоббирует, все знают, называть не будем эту одну большую биржу, которая очень хорошо «дружит» с ЦБ и методично пропихивает нужные ей положения в законе. Кто знает, тот знает, как говорится. А кто не знает, догадывается и понимает.&lt;/p&gt;&lt;p&gt;Только вот, эта одна большая биржа в силу своей жадности не понимает одной простой вещи: всё это приведёт к такой консолидации криптовалюты в одних руках (или в двух‑трех), что весь рынок и весь ВЭД ляжет и уже не встанет. Почему?&lt;/p&gt;&lt;p&gt;Да потому, что USDT там будут заморожены славной американской компанией Tether — это вопрос не «если», а «когда». А всё, что не USDT, будет промаркировано и не будет приниматься нигде, кроме России и даркнета. Крупная биржа что, всерьёз планирует обелять промаркированную криптовалюту через миксеры? Или сбывать и покупать оружие и наркотики в даркнете? На что надежда — на Казахстан, Беларусь? Кто будет забирать санкционные BTC и ETH?? Про USDT даже речи не идёт, забирать в принципе нечего будет.&lt;/p&gt;&lt;p&gt;Чем они мотивируются — понятно: всосать в себя максимум ликвидности и зарабатывать на стейкинге. Большие деньги, между прочим, если взять всю криптовалюту, которая есть в РФ.&lt;/p&gt;&lt;p&gt;А вот вопрос: а смогут ли они стейкать замороженные USDT? (спойлер: нет, не смогут.) Смогут ли стейкать маркированные BTC и ETH? С большим трудом, а скорее всего, тоже вообще не смогут. &lt;/p&gt;&lt;p&gt;А где после этого оборонка будет брать криптовалютную ликвидность для закупки стратегически важного для того же СВО? Да, там не всё через криптовалюту идет, но по тем же данным тех же ФОИВов, 40–50% ВЭД идет именно так. Так что, где ликвидность будут брать, которая не маркированная? Правильно — нигде. Останутся с рублём, который заперт ровно так же, как будет заперта и крипта.&lt;/p&gt;&lt;p&gt;А вот теперь особенно обидно: Минфин, который вроде бы за свободу криптовалюты в России ратовал, и сам теперь туда же. Вместо того, чтобы хоть раз сесть и разобраться, как вообще работает криптовалюта, в чём сила децентрализации и что реально произойдёт после принятия этого замечательного закона о цифровых валютах, придумали подарок всем нам ещё и от себя. О нём — далее.&lt;/p&gt;&lt;h3&gt;Запрет холодных кошельков&lt;/h3&gt;&lt;p&gt;Я даже не знаю, как к этому относиться. У меня, кажется, дежавю. Однажды запретили слово «жопа», но слово почему‑то осталось. Как и жопа, в которую мы все дружно погружаемся. Я конечно не сравниваю кошельки с этим запрещённым словом, но аналогия примерно такая же.&lt;/p&gt;&lt;p&gt;Мне вот искренне интересно: хоть кто‑нибудь из этих авторов задумывается, КАК физически/технически можно запретить холодный кошелёк? Они понимают, что холодный кошелёк — это, по сути, набор слов в голове или на бумажке? Что они запретят? Память? Бумагу? Математику? Интернет? Не назапрещались ещё?&lt;/p&gt;&lt;p&gt;Ну а раз запретить никак, надо что? Надо уголовку! Ура! Ещё статей! Больше! Жить станет лучше с каждым днём! Пока не знают, каких статей, но наверняка придумают. Это даже не 451 градус по Фаренгейту, а уже где‑то за 700. В общем, ждём и предвкушаем, друзья.&lt;/p&gt;&lt;p&gt;И тут внезапный вопрос: а ПОЧЕМУ они хотят запретить холодные кошельки? Ради безопасности? Контроля? Налогов? Чтобы знали, кто что куда зачем когда? Мне кажется, нет.&lt;/p&gt;&lt;p&gt;Я думаю, что дело в той же ликвидности, чтобы с большой и крупной биржи криптовалюту не выводили. Завёл — и хватит, сиди там. Получи расписку и пусть лежит, где положено. Стейкинг ведь должен капать, и не тебе, холопу, а достойной организации. И будет капать, пока благополучно не сдохнет в заморозке, вместе с настейканным. Очевидно, опыт заморозки ЗВР не научил ничему. Опыт Garantex и Grinex — тоже. Грабли — наше всё, это круче хождения по гвоздям в сто раз, ещё и звездочки увидеть можно.&lt;/p&gt;&lt;p&gt;Ладно, резюмируем: завёл крипту в «лицензированную историю» — нет у тебя крипты. Тока. Fin.&lt;/p&gt;&lt;p&gt;И кстати, не слушайте рассказов «ведь можно будет выводить на какие‑то иностранные биржи». Удачи выводить маркированную, красную, санкционную, замороженную криптовалюту на иностранные и даже не иностранные биржи. Можно, конечно, попробовать использовать какие‑то близлежащие биржи, типа Токенспота, Whityebird, как прокси: наша скрепная большая биржа → СНГшная → холодный кошелёк. Только на одной этой схеме вы разгрузитесь минимум на $10 на одних комиссиях блокчейна + комиссиях самих бирж за вывод (в среднем $3 на биржу + $3 с биржи на вторую + $3 со второй на холодный). И даже если всё это получится — поздравляю, у вас на холодном кошельке прекрасная маркированная криптовалюта! Winning! &lt;/p&gt;&lt;h3&gt;Подарок от Минфина&lt;/h3&gt;&lt;p&gt;Вы думаете, это всё? А вот и нет.&lt;/p&gt;&lt;p&gt;Биржам и обменникам планируется вменить обязательную торговую комиссию в 2–3% на каждую операцию. Да, это не опечатка. Не 0,2–0,3% — ДВА‑ТРИ процента! Представляете, какими «конкурентоспособными» они станут? Против 0,075–0,1% на Binance или Bybit? Консолидируем и заставим торговать с комиссиями в 40–50 раз выше, чем на нескрепных биржах.&lt;/p&gt;&lt;p&gt;Так вот, если всех отправят в это стойло и заставят жевать кактус, то к вашим $9 за перемещение собственных монет САМОМУ СЕБЕ вы ещё разгрузитесь на пару процентов сверху, если надо будет что‑то конвертнуть. Это ли не искренняя, нежная любовь к инвесторам и трейдерам?&lt;/p&gt;&lt;h3&gt;Откуда это всё взялось&lt;/h3&gt;&lt;p&gt;Напоминаю — личное мнение тут высказываю, можно соглашаться, можно нет. Мне кажется — тут дело в банальной аффилированности. От желания срубить денег здесь и сейчас, а «после нас хоть потоп», и гори оно всё (наша экономика) синим пламенем. Получалось торговать с заграницей через криптовалюту? Удобно было обходить санкции? А вот больше не надо, зато правильные люди денег заработают, а то голодают, небось.&lt;/p&gt;&lt;p&gt;Да, нам рассказывают красивые слова: «ВЭД это не коснётся, ВЭД будут торговать, как и раньше». Ещё как коснётся, бестолочи! Ликвидности в стране просто не будет! Откуда у ВЭД‑платформ и агентов криптовалюта? Оттуда, что её собирают по крошкам тут и там, в серой зоне, часто на честном слове. А после этого чудо‑юдо‑закона собирать будет негде и не у кого, либо уже не в серую, а в очень-очень чёрную. Вы были криптотрейдером или инвестором? Поздравляю, либо в стойло, либо вы будете на уровне торговцев наркотой, оружием и прочим запрещенным. &lt;/p&gt;&lt;h3&gt;Са‑бо‑таж&lt;/h3&gt;&lt;p&gt;Мне четко понятно и видно: то, что происходит, — это не действия в интересах страны, народа или государственной безопасности. Это запланированное разрушение целой индустрии, в которой мы итак находимся примерно в том самом запрещённом слове. Это настоящий саботаж даже не криптоиндустрии, а вообще всей экономики. Стартапы в крипте? Что? Какие такие стартапы? Блокчейн кошельки, DeFI? Что? Какая такая децентрализация, не знаем. Зато у нас — в квартире газ. А у вас?&lt;/p&gt;&lt;p&gt;Тут недавно услышал смешное: «за границей же есть депозитарии, и они работают, и у нас будут!». Да, там они работают. Только там есть две маленькие детали: 1) там нет санкций, и никто не «окрашивает» хранящуюся там крипту в красный и не будет ее замораживать; 2) их использование — сугубо по желанию, а не из‑под палки, не под угрозой уголовки. Как говорится, так‑то оно так, но есть нюансы.&lt;/p&gt;&lt;p&gt;Я вообще не уверен, что Эльвира Сахипзадовна в курсе, что творят её подчинённые. Мне кажется, что нет, — не может такого быть, она ведь очень умная женщина. Подозреваю, что ей рассказывают, как всё будет прекрасно и почему именно так надо сделать, как все заживут счастливо и весело, а о негативных последствиях либо сами не в курсе, либо умалчивают. И я очень сильно подозреваю, что как раз второе.&lt;/p&gt;&lt;p&gt;Напоследок, поделюсь наблюдением: Все, абсолютно ВСЕ, кто в России ещё остался и не уехал (наверное, мы дебилы — зачем мы тут сидим и пытаемся что‑то улучшить, построить, легализовать), об этих рисках говорили, писали, отправляли правки в законопроект. Толку — ноль. Ровно ноль. Никого из нас не услышали, потому что нас изначально и не собирались слушать.&lt;/p&gt;&lt;p&gt;Живи ярко, сгори красиво — лучше один раз полыхнуть, чем медленно тлеть.&lt;/p&gt;&lt;p&gt;Аминь.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;P. S.&lt;/strong&gt; А мы ещё лидеров ЕС ругаем и думаем, смеемся. Задорнов наверное бы, увидев то что происходит сейчас, переквалифицировался бы.&lt;br&gt;&lt;br&gt;P.P. S. Я добавил хаб киберпанк, потому что это уже не фантастика, это реальность к которой мы все ближе и ближе. &lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/article&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;img alt="Хабр Карьера Курсы" src="https://habrastorage.org/webt/qq/ey/pn/qqeypn-py71suynxbusbakjdfjw.png"&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;Хабр Курсы для всех&lt;/div&gt;&lt;div&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt; Практикум, Хекслет, SkyPro, авторские курсы — собрали всех и попросили скидки. Осталось выбрать! &lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Hau515</dc:creator><pubDate>Tue, 23 Jun 2026 08:09:14 +0000</pubDate><guid>https://habr.com/ru/articles/1050810/?utm_source=habrahabr&amp;utm_medium=rss&amp;utm_campaign=1050810</guid><category>криптовалюта</category><category>блокчейн</category><category>регулирование</category><category>цб</category><category>минфин</category><category>инвестиции</category><category>кошельки</category><category>криптобиржи</category><category>трейдинг</category><category>жопа</category></item><item><title>[Перевод] Что делать, если HTTP‑запрос прошёл, а транзакция в БД откатилась?</title><link>https://habr.com/ru/companies/timeweb/articles/1049740/?utm_source=habrahabr&amp;utm_medium=rss&amp;utm_campaign=1049740</link><description>&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;a href="https://habr.com/ru/users/ZheleznyChel/" rel="nofollow"&gt;&lt;div&gt;&lt;img alt="" height="24" src="https://assets.habr.com/habr-web/release_2.325.7/client/img/avatars/038.png" width="24"&gt;&lt;/div&gt;&lt;/a&gt;&lt;span&gt;&lt;a href="https://habr.com/ru/users/ZheleznyChel/" rel="nofollow"&gt;ZheleznyChel&lt;/a&gt;&lt;span&gt;&lt;time datetime="2026-06-23T08:05:12.000Z" title="2026-06-23, 08:05"&gt;10 минут назад&lt;/time&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Средний&lt;/span&gt;&lt;/div&gt;&lt;div&gt;&lt;span&gt;34 мин&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Туториал&lt;/span&gt;&lt;/div&gt;&lt;div&gt;&lt;span&gt;Перевод&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;div lang="ru"&gt;&lt;div id="post-content-body"&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;h2&gt;❯ Введение&lt;/h2&gt;&lt;p&gt;Суть проблемы предельно проста: если внешний сетевой запрос завершится успешно, а фиксация изменений в БД сорвется, ваша система зависнет в несогласованном состоянии, которое невозможно откатить. В этой статье мы подробно, шаг за шагом, разберем реально работающую реализацию связки паттернов &lt;strong&gt;Transactional Outbox (исходящие транзакции) + Result Table (таблица результатов) + Saga Compensation (компенсирующие транзакции)&lt;/strong&gt; на языке Scala с использованием Play Framework, Slick и Pekko.&lt;/p&gt;&lt;p&gt;Если ваше приложение одновременно сохраняет данные в базу и отправляет запросы к внешним веб-сервисам, у вас гарантированно возникнет проблема несогласованности данных (проблема двойной записи). Паттерны &lt;a href="https://microservices.io/patterns/data/transactional-outbox.html" rel="nofollow"&gt;Transactional Outbox&lt;/a&gt; и &lt;a href="https://microservices.io/patterns/data/saga.html" rel="nofollow"&gt;Saga&lt;/a&gt; – это классические, описанные во всех учебниках решения. Однако большинство статей на эту тему ограничиваются голой теорией. Мы же пойдем дальше.&lt;/p&gt;&lt;p&gt;Мы создадим полноценное рабочее приложение, которое вы сможете склонировать, запустить у себя и адаптировать под свои задачи. Проект объединяет три паттерна, которые идеально дополняют друг друга. &lt;strong&gt;Transactional Outbox (исходящий почтовый ящик)&lt;/strong&gt; дает железную гарантию того, что каждое событие будет обработано, даже если само приложение внезапно упадет посреди отправки запроса. &lt;strong&gt;Result Table (таблица результатов)&lt;/strong&gt; фиксирует детали каждого вызова API, ответы сторонних систем и полученные идентификаторы, которые понадобятся, если операцию придется отменить. &lt;strong&gt;Saga Compensation (компенсирующие транзакции)&lt;/strong&gt; – если, к примеру, четвертый шаг цепочки завершится сбоем, этот механизм автоматически и в правильном порядке откатит предыдущие три шага.&lt;/p&gt;&lt;p&gt;В нашем примере используются &lt;a href="https://www.playframework.com/" rel="nofollow"&gt;Play Framework&lt;/a&gt;, Scala 3, PostgreSQL, Slick и акторы Pekko. Тем не менее вся архитектура абсолютно не зависит от языка программирования или фреймворка. Вы можете заменить эти библиотеки на любые аналоги – сами концепции останутся неизменными.&lt;/p&gt;&lt;p&gt;Весь материал статьи построен на базе готового проекта. Настоятельно рекомендую склонировать репозиторий и держать код перед глазами по ходу чтения. Каждый фрагмент кода мы берем напрямую из репозитория:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;git clone https://github.com/hanishi/never-call-apis-inside-database-transactions
cd never-call-apis-inside-database-transactions&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Чтобы запустить его локально:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;docker-compose up -d postgres
sbt run
# Открыть http://localhost:9000&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;В проект встроен интерактивный симулятор внешних служб (склад, биллинг, доставка, проверка на мошенничество) с настраиваемой вероятностью сбоев, удобный веб-интерфейс для оформления заказов и провоцирования ошибок, а также подробный журнал аудита для каждого вызова API. О том, как запустить конкретные сценарии, мы поговорим в разделе &lt;em&gt;«Время экспериментов»&lt;/em&gt; в конце статьи.&lt;/p&gt;&lt;p&gt;Итак, поехали.&lt;/p&gt;&lt;h2&gt;❯ Зачем всё это нужно?&lt;/h2&gt;&lt;p&gt;Если ваша система выполняет хотя бы одно из следующих действий, вы неизбежно сталкиваетесь с проблемой двойной записи (dual-write problem):&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Сохраняет данные в локальную базу, а затем вызывает внешнее API, меняющее состояние сторонней системы&lt;/strong&gt; (списание денег через платежный шлюз, резервирование товара на складе, планирование логистики на стороне курьерской службы или публикация события в Kafka). Обратите внимание: запросы только на чтение (например, загрузка кредитного рейтинга) подобных проблем не создают, поскольку на той стороне просто нечего откатывать при аварии.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Координирует работу нескольких сервисов&lt;/strong&gt; в рамках одной сквозной бизнес-операции (например: заблокировать товар → проверить на фрод → запланировать доставку → провести оплату).&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Должна уметь откатить частично выполненную работу&lt;/strong&gt;, если один из этапов длинной цепочки завершился неудачей.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;Без этих паттернов ваша система рано или поздно окажется в ситуации, когда деньги с клиента успешно списаны, а самого заказа в базе нет; товары на складе заблокированы под заказы-невидимки, или курьеры спешат везти посылку, которая была давно отменена. Паттерн Outbox устраняет подобные нестыковки непосредственно на уровне архитектурного дизайна.&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;Прежде чем начать:&lt;/p&gt;&lt;p&gt;В примерах мы используем &lt;a href="https://www.scala-lang.org/" rel="nofollow"&gt;&lt;strong&gt;Scala 3&lt;/strong&gt;&lt;/a&gt; и три ключевые библиотеки. Вот краткий экскурс для быстрого погружения:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Slick&lt;/strong&gt;: функционально-реляционная библиотека для Scala, обеспечивающая типобезопасность запросов к СУБД. В коде вам будет регулярно встречаться тип &lt;code&gt;DBIO[T]&lt;/code&gt;. Это &lt;em&gt;декларативное описание&lt;/em&gt; операции с базой данных, возвращающее результат типа &lt;code&gt;T&lt;/code&gt;. Оно похоже на то, как &lt;code&gt;Future[T]&lt;/code&gt; описывает асинхронную операцию. Сам по себе &lt;code&gt;DBIO&lt;/code&gt; не запускается, пока вы явно не передадите его методу &lt;code&gt;db.run(...)&lt;/code&gt;. Вы можете объединять несколько цепочек &lt;code&gt;DBIO&lt;/code&gt; с помощью &lt;code&gt;for-comprehension&lt;/code&gt; и оборачивать их в &lt;code&gt;.transactionally&lt;/code&gt;, чтобы СУБД выполнила их как единую атомарную транзакцию.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Apache Pekko&lt;/strong&gt;: фреймворк для параллельного программирования на базе модели акторов (открытый преемник Акка). Акторы – это ультралегкие изолированные сущности, обрабатывающие сообщения строго поочередно, что делает их идеальными кандидатами для построения надежных фоновых обработчиков.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Play Framework&lt;/strong&gt;: веб-фреймворк для Scala, использующий формат HOCON (&lt;code&gt;application.conf&lt;/code&gt;) для удобной конфигурации приложения.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/blockquote&gt;&lt;h2&gt;❯ В чем корень проблемы?&lt;/h2&gt;&lt;p&gt;Представьте, что вы разрабатываете интернет-магазин. Когда покупатель оформляет заказ, вам нужно выполнить цепочку действий:&lt;/p&gt;&lt;ol&gt;&lt;li&gt;&lt;p&gt;Сохранить заказ в базу данных&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Зарезервировать товары на складе&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Запланировать доставку&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Списать деньги с карты&lt;/p&gt;&lt;/li&gt;&lt;/ol&gt;&lt;p&gt;Библиотека Slick предлагает удобную лазейку: метод &lt;code&gt;DBIO.from()&lt;/code&gt; преобразует любой &lt;code&gt;Future&lt;/code&gt; в &lt;code&gt;DBIO&lt;/code&gt;. Это позволяет выстраивать асинхронную работу и запросы к БД в единую цепочку с помощью &lt;code&gt;for-comprehension&lt;/code&gt;. Штука удобная, но в ней скрыта опасная ловушка. В Scala запросы к внешним API обычно возвращают &lt;code&gt;Future[T]&lt;/code&gt; (неважно, используете ли вы &lt;code&gt;WSClient&lt;/code&gt; из состава Play, библиотеку &lt;code&gt;sttp&lt;/code&gt; или любой другой HTTP-клиент). А поскольку &lt;code&gt;DBIO.from()&lt;/code&gt; проглатывает вообще любой &lt;code&gt;Future&lt;/code&gt;, у разработчика возникает соблазн упаковать туда сетевые вызовы и засунуть всё это внутрь блока &lt;code&gt;.transactionally&lt;/code&gt;. Код компилируется без единой ошибки, типы идеально сходятся, и всё выглядит так, будто весь процесс застрахован единой транзакцией:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;db.run {
  (for {
    orderId &amp;lt;- orders += order                              // Запись в БД
    _       &amp;lt;- DBIO.from(inventoryApi.reserve(orderId))     // HTTP-вызов, обернутый в DBIO
    _       &amp;lt;- DBIO.from(shippingApi.schedule(orderId))     // HTTP-вызов, обернутый в DBIO
    _       &amp;lt;- DBIO.from(billingApi.charge(orderId))        // HTTP-вызов, обернутый в DBIO
    _       &amp;lt;- shipments += Shipment(orderId, shippingRes)  // Запись в БД
  } yield orderId).transactionally
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Но здесь кроется подвох: транзакция СУБД контролирует исключительно процессы внутри самой базы данных. С точки зрения транзакции, HTTP-запросы внутри &lt;code&gt;DBIO.from()&lt;/code&gt; – это операции по принципу «выстрелил и забыл». Они выполняются, их побочные эффекты материализуются мгновенно, а у базы просто нет механизмов, чтобы откатить изменения на сторонних серверах. Представьте, что произойдет, если последняя запись в БД сорвется:&lt;/p&gt;&lt;ol&gt;&lt;li&gt;&lt;p&gt;Создание заказа в БД → успешно&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Вызов API склада → 200 OK (товар зарезервирован)&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Вызов API службы доставки → 200 OK (доставка запланирована)&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Вызов API платежной системы → 200 OK (деньги списаны)&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Запись информации о доставке в БД → &lt;strong&gt;сбой&lt;/strong&gt; (дедлок, сетевой таймаут – неважно)&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;База данных откатывает транзакцию&lt;/p&gt;&lt;/li&gt;&lt;/ol&gt;&lt;p&gt;&lt;strong&gt;Итог:&lt;/strong&gt; у клиента списали деньги, доставка вовсю готовится, на складе товар заблокирован… а в вашей базе данных нет и следа самого заказа! Внешние сервисы понятия не имеют, что транзакция в вашей локальной БД развалилась. Они свою работу сделали и спят спокойно.&lt;/p&gt;&lt;p&gt;Простая попытка разделить эти операции тоже не спасет:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;// Шаг 1: Сохраняем в БД
val orderId = db.run(orders += order).transactionally  // Успешно
// Шаг 2: Вызываем внешние сервисы
inventoryApi.reserve(orderId)   // Успешно
shippingApi.schedule(orderId)   // Здесь приложение падает; доставка не вызвана
billingApi.charge(orderId)      // Этот вызов даже не начинался&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Если приложение потерпит крах в зазоре между шагом 1 и шагом 2, заказ останется лежать в БД мертвым грузом – больше ничего не произойдет. Никакой повторный запуск не сработает, потому что идентификатор &lt;code&gt;orderId&lt;/code&gt; бесследно стерся из оперативной памяти.&lt;/p&gt;&lt;p&gt;Распределенных транзакций, способных бесшовно связать разнородные системы, в реальном мире не существует. СУБД гарантирует атомарность только для собственных таблиц, но она бессильна перед сторонними HTTP API, брокерами Kafka или чужими платежными шлюзами. Каждая система фиксирует изменения независимо, падает независимо и абсолютно не подозревает о том, что происходит у соседей.&lt;/p&gt;&lt;h2&gt;❯ Решение: паттерн Transactional Outbox&lt;/h2&gt;&lt;p&gt;Выход на удивление прост: &lt;strong&gt;забудьте о вызовах внешних API во время обработки клиентского запроса. Вместо этого запишите намерения (что должно произойти) прямо в базу данных, а всю грязную работу по отправке запросов поручите фоновому процессу.&lt;/strong&gt;&lt;/p&gt;&lt;p&gt;То есть вместо прямой отправки запросов на склад, доставку и оплату мы добавляем специальную запись в таблицу &lt;code&gt;outbox_events&lt;/code&gt; – и делаем это &lt;em&gt;в рамках той же транзакции базы данных&lt;/em&gt;, в которой создается сам заказ:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;BEGIN;
  INSERT INTO orders (customer_id, total_amount, ...) VALUES ('C-123', 99.99, ...);
  INSERT INTO outbox_events (event_type, payloads, status) VALUES ('OrderCreated', '{...}', 'PENDING');
COMMIT;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;По ходу транзакции не происходит никаких сетевых вызовов – только быстрые и надежные локальные записи в таблицы.&lt;/p&gt;&lt;p&gt;Специальный фоновый обработчик (в нашей реализации – актор Pekko под названием &lt;code&gt;OutboxProcessor&lt;/code&gt;) непрерывно сканирует эту таблицу. Обнаружив событие в статусе &lt;code&gt;PENDING&lt;/code&gt; (ожидает обработки), он резервирует его под себя, отправляет реальные HTTP-запросы и меняет статус записи на &lt;code&gt;PROCESSED&lt;/code&gt; (обработано). Если приложение упадет до того, как воркер доберется до записи, событие никуда не денется из БД. Воркер просто подхватит его сразу после перезапуска.&lt;/p&gt;&lt;p&gt;Это полностью решает проблему «API сработал, а БД откатилась», поскольку к внешним вызовам мы переходим только &lt;em&gt;после&lt;/em&gt; успешной фиксации локальной транзакции.&lt;/p&gt;&lt;h3&gt;❯ А что делать при частичных сбоях?&lt;/h3&gt;&lt;p&gt;Паттерн Outbox гарантирует доставку и обработку каждого события. Но представьте: воркер поочередно совершил два успешных вызова, а на третьем споткнулся:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;1. Зарезервировать товар  → успешно (reservationId: "RES-456")
2. Запланировать доставку → успешно (shipmentId: "SHIP-789")
3. Списать оплату         → сбой после 3 попыток отправки&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Товар на складе заблокирован, курьеры уже пакуют посылку, но оплата так и не прошла. Очевидно, нужно откатить шаги 1 и 2. Причем делать это нужно строго в обратном порядке (LIFO – последним вошёл, первым вышел), ведь оформление доставки логически зависит от резерва на складе.&lt;/p&gt;&lt;p&gt;Перед нами классический паттерн &lt;strong&gt;Saga Compensation (компенсирующая транзакция)&lt;/strong&gt;: при сбое на промежуточном этапе мы последовательно отменяем все успешные предыдущие шаги в обратном порядке. Сначала отменяем доставку, затем снимаем резерв со склада.&lt;/p&gt;&lt;p&gt;Но чтобы выполнить откаты, система должна точно знать, какие шаги завершились успехом и какие данные они нам вернули. Например, для вызова отмены доставки сервису нужен конкретный &lt;code&gt;shipmentId&lt;/code&gt;, сгенерированный сторонней службой. Вот почему нам жизненно необходим паттерн &lt;strong&gt;Result Table (таблица результатов)&lt;/strong&gt; – подробнейший аудит-лог, куда записывается каждый сетевой запрос, его результат и полный текст ответа API.&lt;/p&gt;&lt;p&gt;Эти три паттерна безупречно работают в связке:&lt;/p&gt;&lt;div&gt;&lt;div&gt;&lt;table&gt;&lt;tbody&gt;&lt;tr&gt;&lt;th width="193"&gt;&lt;p align="left"&gt;Паттерн&lt;/p&gt;&lt;/th&gt;&lt;th width="202"&gt;&lt;p align="left"&gt;Какую проблему решает&lt;/p&gt;&lt;/th&gt;&lt;th&gt;&lt;p align="left"&gt;Как именно&lt;/p&gt;&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td width="193"&gt;&lt;p align="left"&gt;&lt;strong&gt;Transactional Outbox&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;&lt;td width="202"&gt;&lt;p align="left"&gt;Двойная запись (dual-write)&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p align="left"&gt;Атомарно записывает заказ и событие в БД&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td width="193"&gt;&lt;p align="left"&gt;&lt;strong&gt;Result Table&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;&lt;td width="202"&gt;&lt;p align="left"&gt;«Что именно мне откатывать?»&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p align="left"&gt;Регистрирует каждый вызов API и ответ от него&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td width="193"&gt;&lt;p align="left"&gt;&lt;strong&gt;Saga compensation&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;&lt;td width="202"&gt;&lt;p align="left"&gt;Частичные сбои в цепочке&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p align="left"&gt;Отменяет успешные вызовы в обратном порядке (LIFO)&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Разберем создание всех трех компонентов шаг за шагом.&lt;/p&gt;&lt;h2&gt;❯ Схема базы данных&lt;/h2&gt;&lt;p&gt;Прежде чем переходить к коду, давайте взглянем на две основные таблицы, на которых держится вся магия Outbox.&lt;/p&gt;&lt;h3&gt;❯ orders – бизнес-данные&lt;/h3&gt;&lt;p&gt;Эту таблицу вы бы спроектировали в любом случае. Никакой специфики Outbox здесь нет:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;CREATE TABLE orders (
    id            BIGSERIAL PRIMARY KEY,
    customer_id   VARCHAR(255)   NOT NULL,
    total_amount  DECIMAL(10, 2) NOT NULL,
    shipping_type VARCHAR(20)    NOT NULL DEFAULT 'domestic',
    order_status  VARCHAR(50)    NOT NULL DEFAULT 'PENDING',
    created_at    TIMESTAMP      NOT NULL DEFAULT NOW(),
    updated_at    TIMESTAMP      NOT NULL DEFAULT NOW(),
    deleted       BOOLEAN        NOT NULL DEFAULT FALSE
);&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;❯ outbox_events – план работ&lt;/h3&gt;&lt;p&gt;Каждая строка в этой таблице представляет собой конкретное бизнес-событие, информацию о котором нужно передать внешним API. Чтением и записью здесь заправляет вспомогательный класс &lt;code&gt;OutboxHelper&lt;/code&gt; (его мы разберем далее):&lt;/p&gt;&lt;pre&gt;&lt;code&gt;CREATE TABLE outbox_events (
    id                BIGSERIAL PRIMARY KEY,
    aggregate_id      VARCHAR(255)  NOT NULL,   -- к какому заказу (или сущности) относится событие
    event_type        VARCHAR(255)  NOT NULL,   -- например, "OrderCreated", "OrderStatusUpdated"
    payloads          JSONB         NOT NULL,   -- специфические данные для каждого адресата (один ключ на API)
    status            VARCHAR(20)   NOT NULL DEFAULT 'PENDING',  -- PENDING → PROCESSING → PROCESSED
    retry_count       INT           NOT NULL DEFAULT 0,
    idempotency_key   VARCHAR(512)  NOT NULL,   -- защищает от дублирования событий
    next_retry_at     TIMESTAMP WITH TIME ZONE, -- когда повторить попытку в случае сбоя
    -- ... плюс временные метки, трекинг ошибок и т.д.
);
-- Только одно активное событие на связку агрегат + тип (защита от дубликатов)
CREATE UNIQUE INDEX idx_outbox_idempotency
    ON outbox_events (idempotency_key)
    WHERE status != 'PROCESSED';&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;СУБД-колонка &lt;code&gt;payloads&lt;/code&gt; с типом &lt;code&gt;JSONB&lt;/code&gt; хранит карту (map), где ключи – это названия систем-получателей (например, &lt;code&gt;"inventory"&lt;/code&gt;, &lt;code&gt;"billing"&lt;/code&gt;), а значения – передаваемые им данные. Совсем скоро мы увидим, как строятся эти структуры при объявлении доменных событий.&lt;/p&gt;&lt;p&gt;Для базовой реализации Outbox-паттерна достаточно этих двух таблиц. Остальные таблицы мы добавим позже, когда дойдем до логики логов результатов и проведения компенсирующих транзакций.&lt;/p&gt;&lt;h2&gt;❯ Атомарная запись событий&lt;/h2&gt;&lt;p&gt;База данных спроектирована, теперь напишем код для ее наполнения. Наша главная цель – сделать так, чтобы любая бизнес-операция, требующая внешних вызовов, атомарно регистрировала и бизнес-данные, и соответствующее событие в одной общей транзакции.&lt;/p&gt;&lt;h3&gt;❯ Доменные события&lt;/h3&gt;&lt;p&gt;Каждая бизнес-операция генерирует определенное доменное событие. Например, событие &lt;code&gt;OrderCreatedEvent&lt;/code&gt; точно знает, в какие сервисы нужно отправить данные и какие структуры им для этого нужны:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;sealed trait DomainEvent {
  def aggregateId: String                        // к какой сущности относится (например, ID заказа)
  def eventType: String                          // например, "OrderCreated"
  def toPayloads: Map[String, DestinationConfig] // специфические данные для каждого адресата
}
case class OrderCreatedEvent(
    orderId: Long, customerId: String,
    totalAmount: BigDecimal, shippingType: String,
    timestamp: Instant = Instant.now()
) extends DomainEvent {
  override def aggregateId = orderId.toString
  override def eventType   = "OrderCreated"
  override def toPayloads = Map(
    "inventory" -&amp;gt; DestinationConfig(payload = Some(Json.obj(
      "orderId" -&amp;gt; orderId, "totalAmount" -&amp;gt; totalAmount, "shippingType" -&amp;gt; shippingType
    ))),
    "fraudCheck" -&amp;gt; DestinationConfig(payload = Some(Json.obj(
      "orderId" -&amp;gt; orderId, "customerId" -&amp;gt; customerId, "totalAmount" -&amp;gt; totalAmount
    ))),
    "shipping" -&amp;gt; DestinationConfig(payload = Some(Json.obj(
      "customerId" -&amp;gt; customerId, "shippingType" -&amp;gt; shippingType, "totalAmount" -&amp;gt; totalAmount
    ))),
    "billing" -&amp;gt; DestinationConfig(payload = Some(Json.obj(
      "amount" -&amp;gt; totalAmount, "currency" -&amp;gt; "USD"
    )))
  )
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Каждая служба-адресат получает только те данные, которые ей действительно необходимы. Сервису проверки на мошенничество (&lt;code&gt;fraudCheck&lt;/code&gt;) уходят идентификатор клиента и сумма покупки для скоринга рисков. Сервису оплаты (&lt;code&gt;billing&lt;/code&gt;) – только сумма и валюта платежа.&lt;/p&gt;&lt;h3&gt;❯ Трейт OutboxHelper&lt;/h3&gt;&lt;p&gt;Трейт &lt;code&gt;OutboxHelper&lt;/code&gt; берет на себя гарантию атомарности. Он оборачивает выполнение вашей бизнес-логики и сохранение события в системный метод &lt;code&gt;.transactionally&lt;/code&gt;. В итоге они либо запишутся в базу вместе, либо обе операции будут откачены:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;trait OutboxHelper {
  protected val outbox = TableQuery[OutboxTable]
  protected def withEvent[T](event: DomainEvent)(action: DBIO[T])
      (using ec: ExecutionContext): DBIO[T] =
    (for {
      result &amp;lt;- action              // Ваша запись бизнес-данных (например, INSERT INTO orders)
      _      &amp;lt;- saveEvent(event)    // INSERT INTO outbox_events
    } yield result).transactionally // ← обе операции в рамках одной транзакции
  protected def withEventFactory[T](action: DBIO[T])(eventFactory: T =&amp;gt; DomainEvent)
      (using ec: ExecutionContext): DBIO[T] =
    (for {
      result &amp;lt;- action
      _      &amp;lt;- saveEvent(eventFactory(result))  // событие зависит от результата выполнения действия (например, автоинкрементный ID)
    } yield result).transactionally
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Здесь предусмотрено два удобных сценария использования:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;p&gt;&lt;code&gt;&lt;strong&gt;withEvent&lt;/strong&gt;&lt;/code&gt; принимает уже созданное событие и саму процедуру изменения БД. Он идеален для ситуаций, когда все реквизиты события у вас на руках еще до обращения к базе (например, при отмене заказа, когда его ID давно известен).&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;code&gt;&lt;strong&gt;withEventFactory&lt;/strong&gt;&lt;/code&gt; принимает СУБД-действие и &lt;em&gt;фабричный метод&lt;/em&gt; &lt;code&gt;T =&amp;gt; DomainEvent&lt;/code&gt;. Сначала выполняется запись бизнес-данных, результат этой записи передается в фабрику, а та генерирует событие. Без этого не обойтись, если параметры события зависят от значений, генерируемых самой СУБД (например, от автоинкрементного первичного ключа заказа). Вы просто не сможете заранее создать &lt;code&gt;OrderCreatedEvent&lt;/code&gt; без &lt;code&gt;orderId&lt;/code&gt;, а сам &lt;code&gt;orderId&lt;/code&gt; станет известен только после выполнения инструкции &lt;code&gt;INSERT&lt;/code&gt;. Метод &lt;code&gt;withEventFactory&lt;/code&gt; решает эту дилемму: он откладывает сборку события ровно до того момента, когда у нас появится сгенерированный базой ID, при этом по-прежнему гарантируя безупречную атомарность в рамках единой транзакции.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;Этот блок написан под Slick: сущности &lt;code&gt;DBIO&lt;/code&gt;, &lt;code&gt;TableQuery&lt;/code&gt; и метод &lt;code&gt;.transactionally&lt;/code&gt; – чисто его специфика. Но сам архитектурный паттерн абсолютно универсален. Ваша задача – связать воедино сохранение бизнес-сущности и запись события в одной транзакции СУБД. В Hibernate/JPA для этого используют аннотацию &lt;code&gt;@Transactional&lt;/code&gt;, в jOOQ – вызов &lt;code&gt;dsl.transaction(...)&lt;/code&gt;, а на голом JDBC – инструкцию &lt;code&gt;connection.setAutoCommit(false)&lt;/code&gt;.&lt;/p&gt;&lt;h3&gt;❯ Собираем всё вместе в репозитории&lt;/h3&gt;&lt;p&gt;Репозиторий &lt;code&gt;OrderRepository&lt;/code&gt; просто примешивает трейт &lt;code&gt;OutboxHelper&lt;/code&gt;. Теперь создание нового заказа превращается в элегантную однострочную конструкцию:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;@Singleton
class OrderRepository @Inject() ()(using ec: ExecutionContext) extends OutboxHelper {
  private val orders = TableQuery[OrderTable]
  def createWithEvent(order: Order): DBIO[Long] =
    withEventFactory((orders returning orders.map(_.id)) += order) { orderId =&amp;gt;
      OrderCreatedEvent(orderId, order.customerId, order.totalAmount, order.shippingType)
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Во время вызова метода &lt;code&gt;createWithEvent&lt;/code&gt; на стороне СУБД выполняется следующий SQL-сценарий:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;BEGIN;
  INSERT INTO orders (customer_id, total_amount, shipping_type, order_status)
    VALUES ('C-123', 99.99, 'domestic', 'PENDING')
    RETURNING id;  -- Возвращает 123
  INSERT INTO outbox_events (aggregate_id, event_type, payloads, idempotency_key, status)
    VALUES ('123', 'OrderCreated',
      '{"inventory": {...}, "fraudCheck": {...}, "shipping": {...}, "billing": {...}}',
      '123:OrderCreated', 'PENDING');
COMMIT;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;В результате либо сохранятся обе записи, либо ни одна из них. Сервисному уровню остается лишь выполнить привычный метод &lt;a href="http://db.run" rel="nofollow"&gt;&lt;code&gt;db.run&lt;/code&gt;&lt;/a&gt;&lt;code&gt;(...)&lt;/code&gt;. В терминах Slick эта функция принимает &lt;code&gt;DBIO&lt;/code&gt; (описание плана СУБД-операций), запускает его физическое выполнение и возвращает привычный &lt;code&gt;Future&lt;/code&gt; с полученным значением:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;class OrderService @Inject() (orderRepo: OrderRepository, ...)
    (using ec: ExecutionContext, db: Database) {
  def createOrder(order: Order): Future[Long] =
    db.run(orderRepo.createWithEvent(order))
    // createWithEvent возвращает DBIO[Long] (описание плана СУБД-транзакции)
    // db.run(...) запускает транзакцию на выполнение и возвращает Future[Long] (сгенерированный ID заказа)
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Отлично. Теперь заказ благополучно сохранен в базу, а в таблице событий дожидается своего часа запись со статусом &lt;code&gt;PENDING&lt;/code&gt;. Никаких лишних HTTP-запросов во время транзакции выполнено не было. Теперь перейдем к обработке накопившейся очереди.&lt;/p&gt;&lt;h2&gt;❯ Обработка событий: OutboxProcessor&lt;/h2&gt;&lt;p&gt;В нашей таблице появилось событие в состоянии &lt;code&gt;PENDING&lt;/code&gt;. Логично, что теперь нужен фоновый обработчик, который прочитает эту запись и дернет внешние API. За это и отвечает &lt;code&gt;OutboxProcessor&lt;/code&gt; – фоновый типизированный актор Pekko.&lt;/p&gt;&lt;h3&gt;❯ Безопасный захват событий в конкурентной среде&lt;/h3&gt;&lt;p&gt;В высоконагруженных продакшен-системах события обрабатывают сразу несколько параллельных воркеров. Но как гарантировать, что два независимых процесса не схватят одновременно одну и ту же запись? Эту проблему решает встроенная конструкция PostgreSQL &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; всего за один атомарный запрос:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;def findAndClaimUnprocessed(limit: Int = 100): DBIO[Seq[OutboxEvent]] = {
  sql"""
    WITH claimed AS (
      SELECT id FROM outbox_events
      WHERE status = 'PENDING'
        AND (next_retry_at IS NULL OR next_retry_at &amp;lt;= NOW())
      ORDER BY created_at
      LIMIT $limit
      FOR UPDATE SKIP LOCKED
    )
    UPDATE outbox_events e
    SET status = 'PROCESSING', status_changed_at = NOW()
    FROM claimed
    WHERE e.id = claimed.id
    RETURNING e.*
  """.as[OutboxEvent]
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;С помощью обобщенного табличного выражения (CTE) этот запрос выполняет три важнейших действия за одно обращение к диску (атомарно):&lt;/p&gt;&lt;ol&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Поиск (Select)&lt;/strong&gt;. Временная таблица &lt;code&gt;WITH claimed&lt;/code&gt; находит строки в статусе &lt;code&gt;'PENDING'&lt;/code&gt;, время повторной отправки которых подошло (или поле &lt;code&gt;next_retry_at&lt;/code&gt; вовсе пусто). Сортировка по &lt;code&gt;created_at&lt;/code&gt; гарантирует обработку строго по хронологии, а параметр &lt;code&gt;LIMIT&lt;/code&gt; ограничивает размер порции.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Блокировка (Lock)&lt;/strong&gt;. Ключевым элементом здесь выступает инструкция &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;. Она накладывает монопольную блокировку на выбранные строки, но при этом воркеры не зависают в ожидании освобождения занятых записей, а просто &lt;em&gt;пропускают заблокированные&lt;/em&gt; (&lt;code&gt;SKIP LOCKED&lt;/code&gt;). Если запустить три параллельных воркера одновременно, каждый из них получит чисто свой уникальный пакет событий – без пересечений, конфликтов или повторной обработки.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Обновление и возврат данных (Update &amp;amp; Return)&lt;/strong&gt;. Внешний оператор &lt;code&gt;UPDATE&lt;/code&gt; переводит выбранные записи из статуса &lt;code&gt;'PENDING'&lt;/code&gt; в &lt;code&gt;'PROCESSING'&lt;/code&gt; и мгновенно возвращает их содержимое в приложение. За один единственный сетевой цикл мы находим, блокируем, резервируем и забираем нужные события!&lt;/p&gt;&lt;/li&gt;&lt;/ol&gt;&lt;h3&gt;❯ Вызов внешних API&lt;/h3&gt;&lt;p&gt;Захватив событие, процессор переходит к публикации – то есть поочередно дергает внешние HTTP-эндпоинты. Дальнейшие действия зависят от ответа серверов:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;private def publishEvent(event: OutboxEvent): Future[Boolean] =
  publisher.publish(event).flatMap {
    case PublishResult.Success =&amp;gt;
      db.run(outboxRepo.markProcessed(event.id)).map(_ =&amp;gt; true)
    case PublishResult.Retryable(error, retryAfter) =&amp;gt;
      handleRetryableFailure(event, error, retryAfter)
    case PublishResult.NonRetryable(error) =&amp;gt;
      handleNonRetryableFailure(event, error)
  }&lt;/code&gt;&lt;/pre&gt;&lt;ul&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Успех (Success).&lt;/strong&gt; Помечаем запись в базе как &lt;code&gt;PROCESSED&lt;/code&gt;. Задача решена.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Повторяемый сбой (Retryable failure)&lt;/strong&gt; (например, при ошибке 503 Service Unavailable). Планируем повтор с экспоненциальной задержкой (выжидаем 2, 4, затем 8 секунд). Если сервер вернул заголовок &lt;code&gt;Retry-After&lt;/code&gt;, ориентируемся строго на него.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Неповторяемый сбой (Non-retryable failure)&lt;/strong&gt; (например, критический статус ответа 400 Bad Request). Отправляем событие напрямую в очередь недоставленных сообщений (DLQ – Dead Letter Queue).&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;Если лимит повторных попыток исчерпан (по умолчанию разрешено 3 попытки), событие отправляется в очередь недоставленных сообщений для запуска процедуры автоматического отката (о ней мы подробно поговорим в разделе &lt;em&gt;«Автоматическая компенсация»&lt;/em&gt;).&lt;/p&gt;&lt;h3&gt;❯ Мгновенный запуск процессов через механизмы LISTEN и NOTIFY&lt;/h3&gt;&lt;p&gt;Вместо того чтобы мучить базу данных постоянными опросами каждые несколько секунд и плодить лишние задержки, мы настроим триггер PostgreSQL. Он будет мгновенно будить наш обработчик при добавлении новой записи:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;CREATE OR REPLACE FUNCTION notify_new_outbox_event() RETURNS trigger AS $$
BEGIN
    IF NEW.status = 'PENDING' THEN
        PERFORM pg_notify('outbox_events_channel', NEW.id::text);
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER outbox_event_inserted
    AFTER INSERT OR UPDATE OF status ON outbox_events
    FOR EACH ROW EXECUTE FUNCTION notify_new_outbox_event();&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Разумеется, подобные конструкции специфичны для PostgreSQL. Однако именно благодаря богатейшему встроенному функционалу (&lt;code&gt;LISTEN/NOTIFY&lt;/code&gt;, &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;, поддержка типа &lt;code&gt;JSONB&lt;/code&gt;, частичные индексы) Postgres выглядит идеальным выбором под паттерн Outbox. В проекте из репозитория весь этот потенциал выжат по полной программе.&lt;/p&gt;&lt;p&gt;Мостом, связывающим уведомления из Postgres с нашим &lt;code&gt;OutboxProcessor&lt;/code&gt;, служит источник Pekko Streams (&lt;code&gt;PostgresListenNotifyStream&lt;/code&gt;). Он постоянно держит соединение в канале уведомлений, буферизирует поступающие ID событий и отправляет актору команду &lt;code&gt;ProcessUnhandledEvent&lt;/code&gt;. Использование реактивного стрима из коробки дает нам управление нагрузкой (backpressure), автоматическое восстановление соединения с растущим таймаутом и безопасное выключение фонового процесса. Как только транзакция с заказом и событием успешно фиксируется (о чем говорилось в разделе «Атомарная запись событий»), СУБД активирует триггер, стрим мгновенно перехватывает сигнал, и процессор приступает к работе буквально через миллисекунды. А если по каким-то причинам механизмы &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt; недоступны, система автоматически переключается в классический режим периодического опроса таблиц.&lt;/p&gt;&lt;h2&gt;❯ Веерная рассылка (fan-out) и условная маршрутизация&lt;/h2&gt;&lt;p&gt;До сих пор мы рассматривали обработку одиночных вызовов. Но наше событие &lt;code&gt;OrderCreated&lt;/code&gt; должно поочередно облететь &lt;em&gt;целый ряд&lt;/em&gt; систем, причем в строгом порядке. В нашей реализации правила маршрутизации вынесены целиком в файл конфигурации &lt;code&gt;application.conf&lt;/code&gt; – никакой жесткой привязки к адресам в программном коде. Это дает потрясающую гибкость: подключать внешние системы, менять URL или изменять порядок рассылки можно прямо «на лету», без перекомпиляции приложения:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;outbox.http.fanout {
  OrderCreated       = ["inventory", "fraudCheck", "shipping", "billing"]
  OrderStatusUpdated = ["notifications"]
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Термин «веерная рассылка» (fan-out) в данном случае означает, что на одно доменное событие завязана целая пачка вызовов. Эти запросы выполняются &lt;strong&gt;последовательно&lt;/strong&gt;, а не параллельно. Это сделано намеренно: последующие сервисы могут изменять свое поведение в зависимости от результатов предыдущих (например, биллинг сопоставляет риск-скоринг от фрода для выбора платежного шлюза). Тело каждого HTTP-запроса извлекается из той самой карты &lt;code&gt;toPayloads&lt;/code&gt;, которую мы подготовили внутри структуры нашего доменного события, где ключи в точности соответствуют именам систем-получателей. Каждому такому получателю сопоставляется конкретный эндпоинт в конфигурации:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;outbox.http.routes {
  inventory {
    url = "http://localhost:9000/api/inventory/reserve"
    method = "POST"
    timeout = 5 seconds
  }
  fraudCheck {
    url = "http://localhost:9000/api/fraud/check"
    method = "POST"
    timeout = 5 seconds
  }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;После каждого успешного запроса результат немедленно сохраняется в третью таблицу – &lt;/strong&gt;&lt;code&gt;&lt;strong&gt;aggregate_results&lt;/strong&gt;&lt;/code&gt;&lt;strong&gt;.&lt;/strong&gt; Это наш журнал аудита. Таблица фиксирует отправленные данные, полные ответы от API и признак успешности вызова:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;CREATE TABLE aggregate_results (
    id               BIGSERIAL PRIMARY KEY,
    aggregate_id     VARCHAR(255)  NOT NULL,   -- к какому заказу относится
    destination      VARCHAR(255)  NOT NULL,   -- получатель (например, "inventory", "shipping")
    request_payload  JSONB,                    -- что именно мы отправили в API
    response_payload JSONB,                    -- что API вернул нам в ответ
    success          BOOLEAN       NOT NULL,   -- прошел ли вызов успешно?
    fanout_order     INT           NOT NULL DEFAULT 0,  -- порядковый номер шага в рассылке (0, 1, 2...)
    -- ... плюс целевой URL, HTTP-метод, статус-код, время выполнения и т.д.
);&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Зачем тащить в СУБД весь JSON-ответ целиком? Дело в том, что если на этапе оплаты произойдет сбой, системе придется отменять ранее созданную доставку. А для этого нам позарез нужен идентификатор &lt;code&gt;shipmentId&lt;/code&gt;, сгенерированный сторонним сервисом во время прямого запроса. Это уникальное значение лежит как раз внутри сохраненного &lt;code&gt;response_payload&lt;/code&gt;. Колонка &lt;code&gt;fanout_order&lt;/code&gt; фиксирует порядковый номер шага (0, 1, 2…). Во время проведения компенсирующей транзакции мы пойдем по этим номерам в обратном направлении (LIFO), тем самым раскручивая клубок зависимостей без риска нарушить порядок этапов отмены.&lt;/p&gt;&lt;h3&gt;❯ Условная маршрутизация&lt;/h3&gt;&lt;p&gt;Пока всё выглядит просто – одному получателю соответствует строго один URL. Но что делать, если адрес назначения колеблется в зависимости от контекста заказа? Например, если для параметра &lt;code&gt;"shippingType"&lt;/code&gt; указано значение &lt;code&gt;"domestic"&lt;/code&gt; (внутри страны), доставка должна уходить на внутреннее API курьерской службы, а если &lt;code&gt;"international"&lt;/code&gt; – на шлюз глобального почтового оператора.&lt;/p&gt;&lt;p&gt;Для этого мы можем задекларировать динамический список маршрутов в конфигурационном блоке &lt;code&gt;routes&lt;/code&gt; в виде пар «URL + условие». При публикации обработчик последовательно проверяет эти правила сверху вниз и выбирает первый совпавший вариант:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;shipping {
  method = "POST"
  routes = [{
    url = "http://localhost:9000/api/domestic-shipping"
    condition {
      jsonPath = "$.shippingType"   # извлекаем это поле из входящих данных
      operator = "eq"               # проверяем на равенство
      value = "domestic"            # ожидаемое значение
    }
  }, {
    url = "http://localhost:9000/api/international-shipping"
    condition {
      jsonPath = "$.shippingType"
      operator = "eq"
      value = "international"
    }
  }]
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Добежав до этапа доставки (&lt;code&gt;shipping&lt;/code&gt;), модуль публикации открывает тело исходящего запроса (например, &lt;code&gt;{"shippingType": "domestic", ...}&lt;/code&gt;) и вычисляет выражение &lt;code&gt;$.shippingType == "domestic"&lt;/code&gt;. Условие соблюдено, а значит, система выполнит вызов &lt;code&gt;POST /api/domestic-shipping&lt;/code&gt;. Если бы в параметре доставки стояла международная почта, первая проверка благополучно провалилась бы и система переключилась бы на второй URL.&lt;/p&gt;&lt;p&gt;Реализации условного блока поддерживает широкий набор стандартных операторов сравнения: &lt;code&gt;eq&lt;/code&gt; (равно), &lt;code&gt;ne&lt;/code&gt; (не равно), &lt;code&gt;gt&lt;/code&gt; (больше), &lt;code&gt;gte&lt;/code&gt; (больше или равно), &lt;code&gt;lt&lt;/code&gt; (меньше), &lt;code&gt;lte&lt;/code&gt; (меньше или равно), &lt;code&gt;contains&lt;/code&gt; (содержит) и &lt;code&gt;exists&lt;/code&gt; (существует/присутствует).&lt;/p&gt;&lt;h3&gt;❯ Цепочка принятия решений: роутинг по ответам смежных сервисов&lt;/h3&gt;&lt;p&gt;По-настоящему условная маршрутизация раскрывается тогда, когда логика маршрута строится вокруг данных, которых &lt;em&gt;не было в изначальном запросе&lt;/em&gt;, – то есть параметров, возвращенных одной из соседних систем на ранних этапах.&lt;/p&gt;&lt;p&gt;Рассмотрим процесс оплаты. В клиентском запросе нет и не может быть оценки репутационных рисков – эту информацию генерирует наш собственный сервис фрод-скоринга (&lt;code&gt;fraudCheck&lt;/code&gt;). При этом бизнес-логика требует: транзакции с низким риском проводить через рядовой платежный шлюз, а рискованные платежи отправлять специализированной системе контроля высокорисковых операций. Поскольку проверка на фрод выполняется до инициации платежа (в списке веерной рассылки она расположена раньше), к моменту запуска биллинга в системе уже сохранен исчерпывающий ответ от скоринг-сервиса.&lt;/p&gt;&lt;p&gt;Чтобы связать их воедино, мы используем специальное поле &lt;code&gt;previousDestination&lt;/code&gt;. Оно дает инструкции обработчику: «проверяй указанное условие не во входящем payload, а в сохраненном ответе от указанного смежного сервиса»:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;billing {
  method = "POST"
  routes = [{
    url = "http://localhost:9000/api/billing"
    condition {
      jsonPath = "$.riskScore"
      operator = "lt"
      value = "50"
      previousDestination = "fraudCheck"  # ← смотрим на ответ fraudCheck, а не на исходный запрос
    }
  }, {
    url = "http://localhost:9000/api/high-value-processing"
    condition {
      jsonPath = "$.riskScore"
      operator = "gte"
      value = "50"
      previousDestination = "fraudCheck"
    }
  }]
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Каким образом публикатор получает доступ к ответам пройденных систем? Всё благодаря сущности &lt;code&gt;RoutingContext&lt;/code&gt;. Это хранилище данных, которое аккумулирует возвращаемые JSON-ответы на протяжении всего жизненного цикла цепочки:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;case class RoutingContext(destinationResponses: Map[String, JsValue] = Map.empty) {
  def withResponse(destination: String, response: JsValue): RoutingContext =
    copy(destinationResponses = destinationResponses + (destination -&amp;gt; response))
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Вот как весь этот путь выглядит для события &lt;code&gt;OrderCreated&lt;/code&gt; по шагам:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;1. Обращаемся к складу (inventory)
   → 200 OK, ответ: {"reservationId": "RES-456"}
   → Результат записывается в aggregate_results
   → В контекст добавляется: {inventory: {"reservationId": "RES-456"}}
2. Проверяем операцию на защищенность (fraudCheck)
   → 200 OK, ответ: {"riskScore": 25, ...}
   → Результат записывается in aggregate_results
   → В контекст добавляется: {inventory: {...}, fraudCheck: {"riskScore": 25}}
3. Рассчитываем маршрут доставки (shipping)
   → Проверяем исходное тело запроса: $.shippingType == "domestic"? Да.
   → Выполняем вызов: POST /api/domestic-shipping
   → Сохраняем результат, обновляем контекст
4. Рассчитываем маршрут биллинга (billing)
   → Видим параметр previousDestination = "fraudCheck" и заглядываем в context["fraudCheck"]
   → Проверяем: $.riskScore &amp;lt; 50? В скоринге записано 25 → Да!
   → Выполняем вызов: POST /api/billing (стандартная оплата, без повышенного контроля)
   → Сохраняем результат в бэкенд и закрываем цепочку&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Если бы скоринг-система вернула более тревожное значение вроде &lt;code&gt;{"riskScore": 75}&lt;/code&gt;, на четвертом шаге сработал бы альтернативный маршрут до &lt;code&gt;/api/high-value-processing&lt;/code&gt;. Самое приятное, что логика принятия решений диктуется исключительно гибкими конфигурациями и реальными данными – код приложения при этом остается железобетонным и защищенным от лишних изменений.&lt;/p&gt;&lt;h2&gt;❯ Описание логики отмены через конфигурацию&lt;/h2&gt;&lt;p&gt;В идеальном сценарии все четыре сервиса успешно завершают работу и событие помечается статусом &lt;code&gt;PROCESSED&lt;/code&gt;. Но что если на каком-то этапе произойдет критическая ошибка? Прежде чем углубляться в архитектуру механизма откатов, ответим на фундаментальный вопрос: &lt;strong&gt;как нашей системе понять, каким образом отменить конкретный HTTP-вызов?&lt;/strong&gt;&lt;/p&gt;&lt;p&gt;Вспомним, как шел прямой процесс. Веб-сервер отправил запрос службе доставки:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;POST /api/domestic-shipping
Body: {"customerId": "C-123", "shippingType": "domestic", ...}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;И в ответ прилетел документ:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;{"shipmentId": "SHIP-789", "orderId": "123", "carrier": "FedEx"}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;И исходящий запрос, и ответ сервиса благополучно лежат в нашей таблице &lt;code&gt;aggregate_results&lt;/code&gt; (как мы разбирали в разделе &lt;em&gt;«Веерная рассылка (Fan-Out) и условная маршрутизация»&lt;/em&gt;). Чтобы отменить эту доставку, нам нужно прийти на эндпоинт:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;POST /api/domestic-shipping/SHIP-789/cancel&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Разумеется, на этапе проектирования или компиляции мы не могли знать значение &lt;code&gt;SHIP-789&lt;/code&gt; – идентификатор сгенерировала сторонняя служба доставки в реальном времени. Единственное место, откуда мы можем его выудить, – это поле сохраненного JSON-ответа.&lt;/p&gt;&lt;h4&gt;❯ Обучаем систему искусству отката&lt;/h4&gt;&lt;p&gt;Любой шаг веерной рассылки имеет право содержать в конфигурации специальный блок &lt;code&gt;revert&lt;/code&gt;. Начнем с самого простого примера отмены – из каталога склада (inventory):&lt;/p&gt;&lt;pre&gt;&lt;code&gt;inventory {
  url = "http://localhost:9000/api/inventory/reserve"
  method = "POST"
  revert {
    url = "http://localhost:9000/api/inventory/{reservationId}/release"
    method = "DELETE"
    extract {
      reservationId = "response:$.reservationId"
    }
  }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Разберем архитектуру этого блока:&lt;/p&gt;&lt;ol&gt;&lt;li&gt;&lt;p&gt;&lt;code&gt;&lt;strong&gt;extract&lt;/strong&gt;&lt;/code&gt; (извлечение): дает указание системе выдернуть значение &lt;code&gt;reservationId&lt;/code&gt; из тела JSON-ответа прямого вызова с помощью выражения &lt;a href="https://goessner.net/articles/JsonPath/" rel="nofollow"&gt;JSONPath&lt;/a&gt; &lt;code&gt;$.reservationId&lt;/code&gt;. Приставка &lt;code&gt;response:&lt;/code&gt; явно указывает, что копать нужно в сохраненном ответе (в то время как приставка &lt;code&gt;request:&lt;/code&gt; заставила бы искать во входящем payload исходного запроса).&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;code&gt;&lt;strong&gt;url&lt;/strong&gt;&lt;/code&gt;: использует заполнитель &lt;code&gt;{reservationId}&lt;/code&gt; в качестве шаблона. Парсер автоматически подставит туда извлеченную строку.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;code&gt;&lt;strong&gt;method&lt;/strong&gt;&lt;/code&gt;: задает метод DELETE. Передавать тело запроса в данном случае не требуется.&lt;/p&gt;&lt;/li&gt;&lt;/ol&gt;&lt;p&gt;Таким образом, если при успешной попытке нам вернулось &lt;code&gt;{"reservationId": "RES-456", ...}&lt;/code&gt;, компенсирующий вызов преобразуется в:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;DELETE /api/inventory/RES-456/release&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Те системы доставки данных, у которых блок &lt;code&gt;revert&lt;/code&gt; отсутствует (например, сканирование &lt;code&gt;fraudCheck&lt;/code&gt;), процессор отмены просто проигнорирует. Это исключительно читающие операции, которые логически не требуют обратного действия.&lt;/p&gt;&lt;h3&gt;❯ Сложный пример: отмена запланированной доставки&lt;/h3&gt;&lt;p&gt;Процесс отмены доставки устроен немного сложнее: здесь получателю требуется полноценное тело POST-запроса, собранное из данных как исходного запроса, так и прилетевшего ранее ответа:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;shipping {
  routes = [{
    url = "http://localhost:9000/api/domestic-shipping"
    condition { jsonPath = "$.shippingType", operator = "eq", value = "domestic" }
    revert {
      url = "http://localhost:9000/api/domestic-shipping/{shipmentId}/cancel"
      method = "POST"
      extract {
        shipmentId = "response:$.shipmentId"    # Из ответа API службы доставки
        orderId    = "response:$.orderId"       # Тоже из ответа
        customerId = "request:$.customerId"     # Из исходного тела запроса, который мы слали
      }
      payload = """{"reason": "payment_failed", "shipmentId": "{shipmentId}",
                    "orderId": "{orderId}", "customerId": "{customerId}"}"""
    }
  }]
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;В данном случае блок &lt;code&gt;extract&lt;/code&gt; берет на себя извлечение трех параметров: двух из ответа и одного из запроса. Затем все найденные фигурные скобки и в строке URL, и в строке &lt;code&gt;payload&lt;/code&gt; заполняются полученными данными:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;URL:     /api/domestic-shipping/{shipmentId}/cancel
       → /api/domestic-shipping/SHIP-789/cancel
Payload: {"reason": "payment_failed", "shipmentId": "{shipmentId}", ...}
       → {"reason": "payment_failed", "shipmentId": "SHIP-789",
          "orderId": "123", "customerId": "C-123"}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Фабричный объект &lt;code&gt;RevertEndpointBuilder&lt;/code&gt; связывает воедино эти настройки с сохраненными в БД телами запросов/ответов и выдает полностью сконфигурированный объект для отправки в сеть:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;object RevertEndpointBuilder {
  def buildRevertEndpoint(
      revertConfig: RevertConfig,
      requestPayload: Option[JsValue],
      responsePayload: Option[JsValue],
      baseDestination: String,
      headers: Map[String, String],
      timeout: FiniteDuration
  ): Try[(HttpEndpointConfig, Option[JsValue])] =
    for {
      placeholderValues &amp;lt;- extractPlaceholderValues(revertConfig, requestPayload, responsePayload)
      revertUrl         &amp;lt;- buildRevertUrl(revertConfig.url, placeholderValues)
    } yield (
      HttpEndpointConfig(
        destinationName = baseDestination,
        url             = Some(revertUrl),
        method          = revertConfig.method,
        headers         = headers,
        timeout         = timeout
      ),
      buildRevertPayload(revertConfig, placeholderValues)
    )
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;При любых конфигурационных изменениях на стороне сторонних сервисов (сменились пути, обновился формат параметров) вы просто правите файлы настроек. Сам &lt;code&gt;RevertEndpointBuilder&lt;/code&gt; не привязан к предметной области: он ничего не знает ни о балансе склада, ни о перевозчиках. Это абстрактный парсер, который беспрекословно следует заданным правилам извлечения и подстановки.&lt;/p&gt;&lt;h2&gt;❯ Автоматическая компенсация (обработчик DLQ)&lt;/h2&gt;&lt;p&gt;Мы разобрались с тем, как формируются запросы отмены, теперь перейдем к механизму, который их дергает. Как отмечалось в процессе разбора фоновых процессов, при сетевых таймаутах &lt;code&gt;OutboxProcessor&lt;/code&gt; предпринимает повторные попытки с нарастающей паузой (2, 4, 8 секунд). Но если все попытки достучаться провалились, наступает фаза компенсации: нам нужно планомерно стереть следы всех действий, которые к этому моменту успели успешно выполниться на сторонних серверах.&lt;/p&gt;&lt;p&gt;Допустим, оплата после 3 честных попыток выбросила белый флаг. В базе данных таблица &lt;code&gt;aggregate_results&lt;/code&gt; будет выглядеть следующим образом:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;destination    success  fanout_order  response_payload
--------------+---------+--------------+-----------------------------------------
inventory      true     0             {"reservationId": "RES-456", ...}
fraudCheck     true     1             {"riskScore": 25, ...}
shipping       true     2             {"shipmentId": "SHIP-789", ...}
billing        false    3             null&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Три шага завершились успехом, последний – критической ошибкой. На складе забронировано, курьеры грузят коробки, но транзакция по карте не прошла. Придется разматывать цепочку 0-2 в обратную сторону. Именно на этом этапе в игру вступает паттерн Saga Compensation.&lt;/p&gt;&lt;p&gt;Чтобы не усложнять и не устраивать попытки отмены прямо внутри рабочего потока (ведь откат тоже может сорваться из-за сетевого сбоя, заведя систему в еще более дремучие дебри), разработчик использует изящный шаг. &lt;code&gt;OutboxProcessor&lt;/code&gt; немедленно убирает проблемное событие с радаров основной таблицы и отгружает его в специальную накопительную таблицу &lt;code&gt;dead_letter_events&lt;/code&gt;. Тут будут регистрироваться все инциденты, требующие принудительного отката:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;CREATE TABLE dead_letter_events (
    id                 BIGSERIAL PRIMARY KEY,
    original_event_id  BIGINT        NOT NULL,   -- ссылка на исходную запись в outbox_events
    aggregate_id       VARCHAR(255)  NOT NULL,
    event_type         VARCHAR(255)  NOT NULL,
    payloads           JSONB         NOT NULL,    -- резервная копия исходных данных
    status             VARCHAR(20)   NOT NULL DEFAULT 'PENDING',
    reason             VARCHAR(1024) NOT NULL,    -- причина сбоя, напр. "MAX_RETRIES_EXCEEDED"
    -- ... параметры отслеживания попыток отката
);&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Обработкой этой таблицы занимается автономный воркер &lt;code&gt;DLQProcessor&lt;/code&gt;. Подобно нашему основному процессору, это типизированный Pekko-актор, однако он запущен не сам по себе, а как дочерний элемент по отношению к &lt;code&gt;OutboxProcessor&lt;/code&gt;. В терминологии Pekko/Akka это означает постоянный супервизор-контроль над его жизненным циклом: если в коде дочернего процессора возникнет фатальная ошибка, родительский актор мгновенно перезапустит его без потери состояния. Каждый рабочий экземпляр &lt;code&gt;OutboxProcessor&lt;/code&gt; инициализирует собственного ребенка-обработчика &lt;code&gt;DLQProcessor&lt;/code&gt;. Соответственно, если на благо базы трудятся 3 параллельных воркера outbox, система автоматически выделит им 3 дублирующих DLQ-процессора.&lt;/p&gt;&lt;h3&gt;❯ Алгоритм работы DLQ-процессора&lt;/h3&gt;&lt;p&gt;Шаги проведения компенсирующих действий выглядят так:&lt;/p&gt;&lt;ol&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Выборка результатов (Query)&lt;/strong&gt;. Выгружаем из &lt;code&gt;aggregate_results&lt;/code&gt; список всех успешных прямых вызовов, отсортированный по убыванию номера шага &lt;code&gt;fanout_order DESC&lt;/code&gt; (это и есть LIFO / порядок от последнего к первому).&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Анализ конфигураций (Check)&lt;/strong&gt;. Для каждой записи смотрим, описан ли для нее компенсирующий блок &lt;code&gt;revert&lt;/code&gt; в файле настроек. Шаги без него (как условный фрод-скоринг) просто отбрасываем.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Формирование и отправка (Send)&lt;/strong&gt;. Собираем сетевой запрос с помощью &lt;code&gt;RevertEndpointBuilder&lt;/code&gt; и шлем его по сети, предварительно убедившись, что откат для этого конкретного шага не выполнялся ранее.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Завершение&lt;/strong&gt;. Меняем статус события в таблице DLQ на &lt;code&gt;PROCESSED&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;&lt;/ol&gt;&lt;p&gt;Если наложить этот сценарий на наш пример, шаги компенсации пройдут так:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;shipping (fanout_order=2):   Есть revert-блок → POST /api/domestic-shipping/SHIP-789/cancel
fraudCheck (fanout_order=1): Нет revert-блока → Пропускаем (только чтение, отмена не нужна)
inventory (fanout_order=0):  Есть revert-блок → DELETE /api/inventory/RES-456/release&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Главной точкой входа в процедуру отката является метод &lt;code&gt;revertDLQEvent&lt;/code&gt;. Первым делом он запрашивает из СУБД всё успешное наследие прямого пути (по ID заказа). Если во время выстраивания цепочки первый же вызов API потерпел неудачу, то и откатывать нам нечего – база просто моментально отметит DLQ-событие как успешно закрытое. Если же успешные шаги были, трансляция передается методу &lt;code&gt;publishRevertEvent&lt;/code&gt;, который поочередно раскрутит все шаги и отправит нужные HTTP-запросы в обратном порядке:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;private def revertDLQEvent(dlqEvent: DeadLetterEvent): Future[Boolean] = {
  db.run(
    resultRepo.findByAggregateId(dlqEvent.aggregateId, Result.Success, includeReverts = false)
  ).flatMap { successful =&amp;gt;
    if (successful.isEmpty) {
      db.run(dlqRepo.markProcessed(dlqEvent.id)).map(_ =&amp;gt; true)
    } else {
      publishRevertEvent(dlqEvent, successful)  // собираем и вызываем эндпоинты отмены в порядке LIFO
    }
  }
}&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;❯ Защита от повторной компенсации (идемпотентность)&lt;/h3&gt;&lt;p&gt;Перед отправкой компенсирующего запроса наш фоновый воркер обязательно убеждается, что данная операция не была отменена ранее. Все вызовы без исключений сохраняются в логах &lt;code&gt;aggregate_results&lt;/code&gt;. Чтобы на уровне БД легко разделять их на прямые и компенсирующие, к имени целевого сервиса при записи отката прибавляется суффикс &lt;code&gt;.revert&lt;/code&gt;: прямой вызов сохранится как &lt;code&gt;"shipping"&lt;/code&gt;, а компенсирующий – как &lt;code&gt;"shipping.revert"&lt;/code&gt;. Отсюда предельно прозрачная проверка на повторы: ищем в таблице результатов успешную запись с ключом &lt;code&gt;"shipping.revert"&lt;/code&gt;. Нашли? Значит, шаг отмены уже позади и его можно спокойно пропустить.&lt;/p&gt;&lt;p&gt;Описанный ниже метод разом проверяет все целевые точки. Он опрашивает базу по каждому получателю параллельно и возвращает список названий сервисов, которые уже благополучно пережили отмену:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;private def checkAlreadyCompensated(
    aggregateId: String, successfulResults: Seq[AggregateResult]
): Future[Set[String]] =
  Future.sequence(
    successfulResults.map { result =&amp;gt;
      db.run(resultRepo.findSuccessfulRevert(aggregateId, result.destination))
        .map(alreadyCompensated =&amp;gt; (result.destination, alreadyCompensated.isDefined))
    }
  ).map(_.filter(_._2).map(_._1).toSet)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Подобный барьер идемпотентности жизненно необходим, так как DLQ-процесс в силу внешних причин (аварийная перезагрузка серверов, деплой новой версии сервиса) может прерваться посреди выполнения. Без такой глубокой проверки повторный запуск недовыполненных задач мог бы привести к крайне неприятным последствиям: например, дважды перевести деньги обратно покупателю или отправить избыточный запрос отмены в доставку, которая давно закрыта.&lt;/p&gt;&lt;h2&gt;❯ Ручная отмена: повторное использование движка компенсаций&lt;/h2&gt;&lt;p&gt;Пока мы говорили об автоматическом запуске процедур отката при аварийном падении API. Но как быть в классической ситуации, когда &lt;em&gt;сам пользователь&lt;/em&gt; нажимает кнопку «Отменить заказ»? Прелесть архитектуры в том, что мы можем переиспользовать уже написанный движок отмены. Секрет кроется в простом соглашении об именовании событий: добавление восклицательного знака &lt;code&gt;!&lt;/code&gt; перед типом доменного события служит сигналом для &lt;code&gt;OutboxProcessor&lt;/code&gt; о том, что перед ним не обычный прямой процесс, а директива на проведение компенсации.&lt;/p&gt;&lt;p&gt;Класс события &lt;code&gt;OrderCancelledEvent&lt;/code&gt; как раз и реализует этот элегантный подход:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;case class OrderCancelledEvent(
    orderId: Long,
    reason: String,
    timestamp: Instant = Instant.now()
) extends DomainEvent {
  override def aggregateId: String = orderId.toString
  override def eventType: String   = "!OrderCreated"  // ← Префикс ! дает сигнал на проведение компенсации
  // ...
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Наш репозиторий оформляет запрос на отмену и запись события о компенсации в общую СУБД-транзакцию – точно так же, как мы делали при первичном создании корзины. Метод &lt;code&gt;cancelWithEvent&lt;/code&gt; сначала проверяет факт существования заказа в базе, после чего обновляет его статус на &lt;code&gt;CANCELLED&lt;/code&gt; и атомарно дописывает событие &lt;code&gt;!OrderCreated&lt;/code&gt; в очередь:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;def cancelWithEvent(orderId: Long, reason: String): DBIO[Int] =
  for {
    orderOpt &amp;lt;- findById(orderId)
    _        &amp;lt;- orderOpt match {
      case Some(_) =&amp;gt; DBIO.successful(())
      case None    =&amp;gt; DBIO.failed(new NoSuchElementException(s"Order $orderId not found"))
    }
    updated  &amp;lt;- withEvent(OrderCancelledEvent(orderId = orderId, reason = reason)) {
      orders.filter(_.id === orderId)
        .map(o =&amp;gt; (o.orderStatus, o.updatedAt))
        .update(("CANCELLED", Instant.now()))
    }
  } yield updated&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Обнаружив при парсинге свежего события символ &lt;code&gt;!&lt;/code&gt;, &lt;code&gt;OutboxProcessor&lt;/code&gt; автоматически перестраивает рабочий режим с прямого выполнения веерной отправки на процедуру отката. Вот как выглядит этот пошаговый алгоритм:&lt;/p&gt;&lt;ol&gt;&lt;li&gt;&lt;p&gt;Очистка имени. Срезаем системный префикс со служебной строки: &lt;code&gt;!OrderCreated&lt;/code&gt; превращается обратно в исходный &lt;code&gt;OrderCreated&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Поиск конфигурации. Считываем из файлов настроек схему связей для нужного события: &lt;code&gt;OrderCreated&lt;/code&gt; → &lt;code&gt;["inventory", "fraudCheck", "shipping", "billing"]&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Инверсия шагов (LIFO). Переворачиваем полученный список задом наперед: &lt;code&gt;["billing", "shipping", "fraudCheck", "inventory"]&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Получение логов. Для каждого шага ищем в таблице результатов &lt;code&gt;aggregate_results&lt;/code&gt; лог успешного прямого выполнения.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Построение запроса и выполнение. С помощью &lt;code&gt;RevertEndpointBuilder&lt;/code&gt; воссоздаем эндпоинты отмены и отправляем запросы в сеть (пропуская сервисы без revert-конфигураций).&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;Фиксация результата. Помечаем исходное событие в базе как полностью обработанное.&lt;/p&gt;&lt;/li&gt;&lt;/ol&gt;&lt;p&gt;Обратите внимание: &lt;code&gt;OutboxProcessor&lt;/code&gt; использует абсолютно те же программные классы &lt;code&gt;RevertEndpointBuilder&lt;/code&gt; и методы проверки дубликатов, что и фоновый &lt;code&gt;DLQProcessor&lt;/code&gt;. Вся система обладает &lt;strong&gt;врожденным свойством идемпотентности&lt;/strong&gt;: перед инициацией отработки отката она всегда запрашивает базу на предмет наличия признака &lt;code&gt;.revert&lt;/code&gt; у каждого адресата. Если нетерпеливый пользователь кликнет по кнопке «Отменить заказ» несколько раз подряд, повторная задача моментально обнаружит, что все шаги цепи отката уже проведены, и завершится как мгновенная пустая операция.&lt;/p&gt;&lt;h3&gt;❯ События отмены защищены от попадания в DLQ&lt;/h3&gt;&lt;p&gt;В случае если само компенсирующее событие (&lt;code&gt;!OrderCreated&lt;/code&gt;) терпит крах после исчерпания всех лимитов повтора, система ни в коем случае не пытается запустить для него процедуру отката. Идея «компенсировать отмену компенсации» увела бы систему в бесконечный цикл вызовов. Вместо этого строка помечается критическим флагом отказа и отправляется ожидать ручного разбора нашей дежурной сменой инженеров:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;if (isRevertEvent) {
  log.error(
    s"Revert event ${event.id} (${event.eventType}) exceeded retries - " +
      s"marking as FAILED, requires manual intervention"
  )
  db.run(outboxRepo.markProcessed(event.id)).map(_ =&amp;gt; false)
}&lt;/code&gt;&lt;/pre&gt;&lt;div&gt;&lt;div&gt;&lt;table&gt;&lt;tbody&gt;&lt;tr&gt;&lt;th width="210"&gt;&lt;p align="left"&gt;Компонент&lt;/p&gt;&lt;/th&gt;&lt;th&gt;&lt;p align="left"&gt;Ручная отмена (&lt;code&gt;!OrderCreated&lt;/code&gt;)&lt;/p&gt;&lt;/th&gt;&lt;th&gt;&lt;p align="left"&gt;Автоматическая (через DLQ)&lt;/p&gt;&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td width="210"&gt;&lt;p align="left"&gt;&lt;strong&gt;Триггер инициации&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p align="left"&gt;Действие пользователя (клик по кнопке отмены)&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p align="left"&gt;Сбой прямого запроса API после лимита попыток&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td width="210"&gt;&lt;p align="left"&gt;&lt;strong&gt;Таблица хранения&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p align="left"&gt;&lt;code&gt;outbox_events&lt;/code&gt;&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p align="left"&gt;&lt;code&gt;dead_letter_events&lt;/code&gt;&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td width="210"&gt;&lt;p align="left"&gt;&lt;strong&gt;Скорость выполнения&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p align="left"&gt;Сразу при обработке входящей очереди&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p align="left"&gt;После исчерпания попыток основного прохода&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td width="210"&gt;&lt;p align="left"&gt;&lt;strong&gt;Крах отката&lt;/strong&gt;&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p align="left"&gt;Требует внимания службы поддержки&lt;/p&gt;&lt;/td&gt;&lt;td&gt;&lt;p align="left"&gt;Аналогично требует ручного разбора в БД&lt;/p&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;/div&gt;&lt;h2&gt;❯ Инициализация: EventProcessingService&lt;/h2&gt;&lt;p&gt;Мы детально проанализировали все независимые шестеренки движка: от записи событий в БД до веерных рассылок, динамической маршрутизации отмены и механизмов DLQ. Пришло время собрать этот пазл воедино. В архитектуре Play Framework синглтоны с нетерпеливой инициализацией (eager singletons) автоматически запускаются прямо на старте системы. Именно таким звеном выступает &lt;code&gt;EventProcessingService&lt;/code&gt;. При поднятии приложения он разворачивает пул акторов, отправляет стартовое сообщение активации работы фонового воркера и аккуратно регистрирует слушателя завершения сессии для корректного выхода из процессов:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;@Singleton
class EventProcessingService @Inject() (
    lifecycle: ApplicationLifecycle,
    publisher: EventPublisher,
    outboxRepo: OutboxRepository,
    dlqRepo: DeadLetterRepository,
    resultRepo: DestinationResultRepository,
    eventRouter: EventRouter,
    config: Configuration
)(using system: ActorSystem[Nothing], ec: ExecutionContext, db: Database)
    extends Logging {
  val outboxActor: ActorRef[OutboxProcessor.Command] =
    if (poolSize &amp;gt; 1) {
      system.systemActorOf(
        OutboxProcessorRouter(publisher, outboxRepo, dlqRepo, resultRepo, eventRouter,
          pollInterval, batchSize, poolSize, maxRetries, useListenNotify,
          staleCleanupEnabled, staleTimeoutMinutes, cleanupInterval),
        "outbox-processor-pool"
      )
    } else {
      system.systemActorOf(
        OutboxProcessor(publisher, outboxRepo, dlqRepo, resultRepo, eventRouter,
          pollInterval, batchSize, maxRetries, useListenNotify,
          staleCleanupEnabled, staleTimeoutMinutes, cleanupInterval),
        "outbox-processor"
      )
    }
  // Запуск процесса
  outboxActor ! OutboxProcessor.ProcessUnhandledEvent
  // Корректное завершение работы
  lifecycle.addStopHook { () =&amp;gt;
    outboxActor.ask(To =&amp;gt; OutboxProcessor.Stop(To))
      .map(_ =&amp;gt; logger.info("Процессор Outbox успешно остановлен"))
  }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Конфигурационный файл содержит следующие параметры:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;outbox {
  pollInterval = 2 seconds
  batchSize    = 100
  poolSize     = 3              # 3 параллельных воркера
  maxRetries   = 3
  useListenNotify = true        # Мгновенная реакция через LISTEN/NOTIFY
  enableStaleEventCleanup = true
  staleEventTimeoutMinutes = 5  # Сброс зависших событий через 5 минут
  cleanupInterval = 1 minute
  dlq {
    maxRetries   = 3
    pollInterval = 2 seconds
  }
}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Настроив параметр &lt;code&gt;poolSize = 3&lt;/code&gt;, мы параллельно запускаем трех акторов &lt;code&gt;OutboxProcessor&lt;/code&gt; под управлением балансировщика, причем за каждым из них закреплен персональный дочерний подпроцесс &lt;code&gt;DLQProcessor&lt;/code&gt;. А неизменная конструкция &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; на уровне СУБД железобетонно страхует систему от ситуации, когда два разных потока параллельно захватят одну и ту же строчку.&lt;/p&gt;&lt;h2&gt;❯ Наглядный пример: как это работает&lt;/h2&gt;&lt;p&gt;Давайте проследим за кулисами реального сбоя: от создания корзины до экстренной отмены операций из-за отказа платежного шлюза.&lt;/p&gt;&lt;h4&gt;Шаг 1: Пользователь создает заказ&lt;/h4&gt;&lt;pre&gt;&lt;code&gt;POST /api/orders
Body: {"customerId": "C-123", "totalAmount": 99.99, "shippingType": "domestic"}&lt;/code&gt;&lt;/pre&gt;&lt;h4&gt;Шаг 2: Атомарная транзакция в базу&lt;/h4&gt;&lt;pre&gt;&lt;code&gt;BEGIN;
  INSERT INTO orders (...) RETURNING id;  -- Возвращает 123
  INSERT INTO outbox_events (aggregate_id='123', event_type='OrderCreated',
    payloads='{"inventory": {...}, "fraudCheck": {...}, "shipping": {...}, "billing": {...}}',
    status='PENDING');
COMMIT;
-- Триггер PostgreSQL посылает сигнал pg_notify('outbox_events_channel', '123')&lt;/code&gt;&lt;/pre&gt;&lt;h4&gt;Шаг 3: Воркер забирает событие и начинает веерную отправку&lt;/h4&gt;&lt;pre&gt;&lt;code&gt;SELECT ... FROM outbox_events WHERE status='PENDING' ... FOR UPDATE SKIP LOCKED;
UPDATE outbox_events SET status='PROCESSING' WHERE id=456;
POST /api/inventory/reserve       → 200 OK (reservationId: RES-456)   → UPSERT aggregate_results
POST /api/fraud/check             → 200 OK (riskScore: 25)            → UPSERT aggregate_results
POST /api/domestic-shipping       → 200 OK (shipmentId: SHIP-789)     → UPSERT aggregate_results
POST /api/billing                 → 503 Service Unavailable           → UPSERT aggregate_results&lt;/code&gt;&lt;/pre&gt;&lt;h4&gt;Шаг 4: Повторные попытки при таймаутах биллинга&lt;/h4&gt;&lt;pre&gt;&lt;code&gt;Попытка 1: ожидание 2с  → 503
Попытка 2: ожидание 4с  → 503
Попытка 3: ожидание 8с  → 503&lt;/code&gt;&lt;/pre&gt;&lt;h4&gt;Шаг 5: Перемещение в очередь недоставленных сообщений (DLQ)&lt;/h4&gt;&lt;pre&gt;&lt;code&gt;INSERT INTO dead_letter_events (original_event_id=456, aggregate_id='123',
  event_type='OrderCreated', status='PENDING', reason='MAX_RETRIES_EXCEEDED');
UPDATE outbox_events SET status='PROCESSED', moved_to_dlq=true WHERE id=456;&lt;/code&gt;&lt;/pre&gt;&lt;h4&gt;Шаг 6: DLQProcessor последовательно выполняет откат в порядке LIFO&lt;/h4&gt;&lt;pre&gt;&lt;code&gt;Query: SELECT * FROM aggregate_results WHERE aggregate_id='123' AND success=true
       ORDER BY fanout_order DESC
       → [shipping(2), fraudCheck(1), inventory(0)]
POST /api/domestic-shipping/SHIP-789/cancel  → 200 OK  → UPSERT (shipping.revert)
Пропускаем fraudCheck (нет revert-конфигурации)
DELETE /api/inventory/RES-456/release        → 200 OK  → UPSERT (inventory.revert)
UPDATE dead_letter_events SET status='PROCESSED' WHERE id=...;&lt;/code&gt;&lt;/pre&gt;&lt;h4&gt;Шаг 7: целостность данных спасена!&lt;/h4&gt;&lt;p&gt;Заказ корректно записан в нашей базе данных и имеет статус сбоя оплаты, но все сопутствующие внешние резервы на серверах партнеров были чисто компенсированы в автоматическом режиме. При этом таблица &lt;code&gt;aggregate_results&lt;/code&gt; теперь содержит исчерпывающую историю каждого сетевого чиха – со всеми входящими и исходящими телами прямых и компенсирующих вызовов.&lt;/p&gt;&lt;h2&gt;❯ О чем важно помнить&lt;/h2&gt;&lt;p&gt;Безусловно, связка паттернов Transactional Outbox, Result Table и Saga Compensation – это не какая-то серебряная пуля. Вам по-прежнему будут нужны качественный мониторинг для отслеживания зависших процессов, триггеры алертов на переполнение очередей DLQ и резервные регламенты работы для тех редких инфраструктурных инцидентов, когда автоматическая компенсация тоже падает, требуя человеческого вмешательства.&lt;/p&gt;&lt;p&gt;Но эти паттерны в корне меняют природу и критичность сбоев ваших систем. Без них вы обречены бороться с коварной бессистемной рассинхронизацией данных: деньгами, списанными за несуществующие заказы, заблокированными на веки вечные складами и фурами, выехавшими по отмененным накладным. Это те самые страшные баги, выковыривать которые приходится путем болезненных ручных правок СУБД глубокой ночью под крики руководства.&lt;/p&gt;&lt;p&gt;Заменив хаос на эти паттерны, вы переведете потенциальные аварии (зависшую в попытках строку DLQ, таймаут эндпоинта отмены или устаревшую запись, ожидающую повторной обработки) в категорию &lt;strong&gt;наблюдаемых, легко прослеживаемых и полностью контролируемых событий&lt;/strong&gt;. Благодаря таблице &lt;code&gt;aggregate_results&lt;/code&gt; у вас перед глазами будет доскональная история каждого вызова, а таблица событий &lt;code&gt;dead_letter_events&lt;/code&gt; всегда любезно укажет пальцем, какой шаг дал сбои и почему это произошло.&lt;/p&gt;&lt;h2&gt;❯ Подведем итоги&lt;/h2&gt;&lt;p&gt;Запомните главное правило: никогда не дергайте внешние API внутри активных транзакций базы данных. Просто зафиксируйте факт намерения в локальной таблице, поручите отправку фоновому асинхронному воркеру, а при возникновении форс-мажоров доверьтесь отполированному механизму компенсирующих транзакций.&lt;/p&gt;&lt;p&gt;В рамках этой статьи мы во всеоружии подошли к решению фундаментальной проблемы двойной записи, подстерегающей любые архитектуры при попытке атомарно связать воедино базу данных с внешним сетевым миром. Эти три паттерна превращают непредсказуемый хаос рассинхронизации данных в абсолютно прозрачные, предсказуемые и легко исправимые инциденты.&lt;/p&gt;&lt;p&gt;Наш демо-проект предлагает готовую к бою рабочую архитектуру, которую вы прямо сейчас можете скопировать и адаптировать под свой домашний технологический стек.&lt;/p&gt;&lt;blockquote&gt;&lt;p&gt;Исходный код проекта. Все разобранные в этой статье примеры кода вы можете в деталях изучить в репозитории проекта: &lt;a href="https://github.com/hanishi/never-call-apis-inside-database-transactions" rel="nofollow"&gt;never-call-apis-inside-database-transactions&lt;/a&gt;.&lt;/p&gt;&lt;/blockquote&gt;&lt;details&gt;&lt;summary&gt;Может быть интересно:&lt;/summary&gt;&lt;/details&gt;&lt;figure&gt;&lt;img src="https://habrastorage.org/r/w1560/getpro/habr/upload_files/d2a/ad0/920/d2aad0920968c58674fcbcedef177bf4.jpg" alt="Перейти ↩" title="Перейти ↩" width="1080" height="607"&gt;&lt;/figure&gt;&lt;blockquote&gt;&lt;p&gt;&lt;a href="https://t.me/timewebru" rel="nofollow"&gt;&lt;strong&gt;Новости, обзоры продуктов и конкурсы от команды &lt;/strong&gt;&lt;/a&gt;&lt;a href="http://Timeweb.Cloud" rel="nofollow"&gt;&lt;strong&gt;Timeweb.Cloud&lt;/strong&gt;&lt;/a&gt;&lt;a href="https://t.me/timewebru" rel="nofollow"&gt;&lt;strong&gt; — в нашем Telegram-канале&lt;/strong&gt;&lt;/a&gt; &lt;strong&gt;↩&lt;/strong&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">ZheleznyChel (Timeweb Cloud)</dc:creator><pubDate>Tue, 23 Jun 2026 08:05:12 +0000</pubDate><guid>https://habr.com/ru/companies/timeweb/articles/1049740/?utm_source=habrahabr&amp;utm_medium=rss&amp;utm_campaign=1049740</guid><category>Transactional Outbox</category><category>Saga Pattern</category><category>Saga Compensation</category><category>Dual-Write Problem</category><category>Scala</category><category>Play Framework</category><category>Slick</category><category>PostgreSQL</category><category>архитектура ПО</category><category>timeweb_статьи_перевод</category></item><item><title>Стрельба в шутерах по-простому: от мгновенного луча до отката времени на сервере</title><link>https://habr.com/ru/articles/1050808/?utm_source=habrahabr&amp;utm_medium=rss&amp;utm_campaign=1050808</link><description>&lt;div&gt;&lt;div&gt;&lt;svg height="24" width="24"&gt;Обновить&lt;/svg&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;article&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;a href="https://habr.com/ru/users/DyadichenkoGA/" rel="nofollow"&gt;&lt;div&gt;&lt;img alt="" height="24" src="https://habrastorage.org/r/w48/getpro/habr/avatars/c8f/bb6/d71/c8fbb6d715be9f3f163a3d9459331f68.jpg" width="24"&gt;&lt;/div&gt;&lt;/a&gt;&lt;span&gt;&lt;a href="https://habr.com/ru/users/DyadichenkoGA/" rel="nofollow"&gt;DyadichenkoGA&lt;/a&gt;&lt;span&gt;&lt;time datetime="2026-06-23T08:04:13.000Z" title="2026-06-23, 08:04"&gt;11 минут назад&lt;/time&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;svg height="24" width="24"&gt;Уровень сложности&lt;/svg&gt;&lt;/span&gt;&lt;span&gt;Средний&lt;/span&gt;&lt;/div&gt;&lt;div&gt;&lt;span&gt;&lt;svg height="24" width="24"&gt;Время на прочтение&lt;/svg&gt;&lt;/span&gt;&lt;span&gt;21 мин&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Туториал&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;div lang="ru"&gt;&lt;div id="post-content-body"&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;p&gt;Всем привет! Меня зовут Гриша Дядиченко, я технический директор и основатель &lt;a href="https://whitelabelgames.ru/" rel="noopener noreferrer nofollow"&gt;White Label Games&lt;/a&gt;. Уже больше десяти лет работаю с компьютерной графикой, AR/VR и компьютерным зрением — в основном это заказная разработка, плюс собственные прототипы по вечерам, до которых дотягиваются руки.&lt;/p&gt;&lt;p&gt;Делал я как-то на работе, по вечерам в свободное время, VR-шутер. Стрельбу, понятное дело, заложил себе на выходные: ну а что, raycast из ствола, событие попадания, отнял здоровье — делов-то. К вечеру воскресенья оно даже работало. Только ощущалось так, будто тыкаешь противника палкой: ни веса, ни отдачи, ни чувства, что ты вообще попал. Знакомо, наверное, каждому, кто хоть раз ставил в сцену оружие и жал «выстрел» — механически всё верно, а стрельба вялая и какая-то ненастоящая. Половина лечения тут — чистая полировка: вспышки, звук, тряска камеры, импакт-эффекты. А вот вторая половина — невидимая математика под капотом: та, что решает, ощущается стрельба честной и отзывчивой или кривой и несправедливой. Спред, который мозг считывает как «нечестный». Отдача, которую можно выучить. Попадание, которое по сети то засчитывается, то нет. Вот это всё и разберём.&lt;/p&gt;&lt;p&gt;Сталкивались ли вы с ситуацией, когда в шутере вы точно попали по противнику, а сервер сказал «промах»? Или с тем, что AI-противник стреляет в вас сверхскоростным снарядом и ни разу не попадает в движущуюся цель? Или с тем, что AK-47 в Counter-Strike рисует «семёрку» из пуль вверх и влево — и это, конечно же, никакой не баг, а вполне продуманная механика? Под капотом у всех этих ситуаций — конкретная математика.&lt;/p&gt;&lt;p&gt;Чтож, давайте по порядку. Чем hitscan отличается от projectile и какой хвост последствий тянется за выбором; с какой геометрией на самом деле проверяют попадание — лучи, капсулы и почему хитбоксы это не полигональный меш; как сделать честный спред пули и почему наивный random даёт квадрат вместо круга; откуда берутся «выученные» recoil-паттерны и почему AK рисует семёрку; как AI-снайпер вычисляет упреждение через школьное квадратное уравнение; и наконец, что такое lag compensation, зачем сервер откатывает время на сотню миллисекунд назад и откуда берётся эффект «убит из-за угла». Шесть разделов, в каждом — код на C#. Сразу оговорюсь: я сознательно не лезу в баллистику снайперок с ветром, в проникающие выстрелы через стены и в физику отдачи на уровне «как трясётся ствол» — иначе статья превратится в книжку.&lt;/p&gt;&lt;h3&gt;1. Hitscan против projectile: а в чём вообще разница&lt;/h3&gt;&lt;p&gt;Итак, вы поставили в сцену оружие и нажали кнопку «выстрел». Что должно произойти? У вас всего два ответа, и они реализованы принципиально по-разному.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Hitscan&lt;/strong&gt; — вы пускаете луч из ствола, в тот же кадр проверяете пересечение с миром, регистрируете попадание. Время полёта пули — ноль. Так стреляют все винтовки в Counter-Strike, Valorant, Call of Duty, и в Overwatch — Soldier 76, Widowmaker.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Projectile&lt;/strong&gt; — вы создаёте объект-снаряд, даёте ему скорость, и каждый тик симулируете его движение, проверяя коллизии. Так стреляют ракеты в Quake и Unreal Tournament, плазма в Halo, снайперки в Apex Legends, и вообще любое оружие в Splatoon.&lt;/p&gt;&lt;p&gt;На C# в Unity-стиле hitscan выглядит примерно так:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;void FireHitscan() {
    Ray ray = new Ray(muzzle.position, muzzle.forward);
    if (Physics.Raycast(ray, out RaycastHit hit, maxRange)) {
        ApplyDamage(hit.collider, damage);
        SpawnImpactEffect(hit.point, hit.normal);
    }
}&lt;/code&gt;&lt;div&gt;&lt;a href="https://sourcecraft.dev/" rel="nofollow"&gt;&lt;img&gt;&lt;/a&gt;&lt;/div&gt;&lt;/pre&gt;&lt;p&gt;А projectile так:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;void FireProjectile() {
    var bullet = Instantiate(bulletPrefab, muzzle.position, muzzle.rotation);
    bullet.GetComponent&amp;lt;Rigidbody&amp;gt;().linearVelocity = muzzle.forward * bulletSpeed;
}
// внутри пули — OnTriggerEnter / RaycastNonAlloc по тонкому ray-cast'у каждый
// FixedUpdate, чтобы пуля не «прошла сквозь» цель на больших скоростях.&lt;/code&gt;&lt;div&gt;&lt;a href="https://sourcecraft.dev/" rel="nofollow"&gt;&lt;img&gt;&lt;/a&gt;&lt;/div&gt;&lt;/pre&gt;&lt;p&gt;Ключевая разница видна сразу. В hitscan-версии мы вообще не храним пулю как объект — это сразу &lt;code&gt;Raycast&lt;/code&gt; и сразу результат. В projectile у нас живёт &lt;code&gt;Rigidbody&lt;/code&gt;, который ест CPU каждый тик, плюс проверка на «прошивку» через тонкий ray-cast (про неё — в части 2, иначе быстрая пуля просто перепрыгнет цель между кадрами).&lt;/p&gt;&lt;p&gt;Зачем вообще выбор, если hitscan дешевле? Hitscan по сути — компромисс в пользу простоты и сетевой нагрузки. Серверу нужно меньше пересылать, клиенту — меньше симулировать, попадание ощущается мгновенным. Для тактических шутеров это важно: вы целились в голову, нажали — попадание должно быть бескомпромиссным. На длинных дистанциях projectile-снайперке надо реально «доехать» до цели, и игрок читает задержку как «оружие медленное и несправедливое».&lt;/p&gt;&lt;p&gt;Projectile, наоборот, открывает игроку возможность увернуться. В Quake вы можете шагнуть в сторону, увидев летящую ракету. В CS вы не можете «отойти от пули» — она уже попала в момент выстрела. Это совсем другое игровое ощущение, и именно ради него projectile и берут.&lt;/p&gt;&lt;p&gt;Гибриды встречаются часто. В Halo battle rifle — hitscan, brute shot — projectile. В Apex R301 — hitscan, Kraber — projectile. В Doom Eternal плазма ведёт себя как hitscan-импульсы, а BFG — это projectile с авто-наведением. Для аркадных шутеров и быстрых PvP обычно берут комбинированный подход: основное оружие на hitscan ради отзывчивости, а тяжёлое и «уворачиваемое» — гранатомёт, ракетница, лук — на projectile. Для тактических — только hitscan, без вариантов.&lt;/p&gt;&lt;figure&gt;&lt;img src="https://habrastorage.org/r/w1560/getpro/habr/upload_files/c57/cd5/672/c57cd5672a8e83cc0c6666044f8ae7f8.png" width="1376" height="768"&gt;&lt;/figure&gt;&lt;p&gt;Тот самый промах из правой части — это и есть классическая боль projectile-AI: снаряд летит, а цель за время полёта уходит. Лечится упреждением — и промахи у движущейся мишени исчезнут.&lt;/p&gt;&lt;h3&gt;2. Геометрия попадания: лучи, капсулы, и почему хитбоксы — не меши&lt;/h3&gt;&lt;p&gt;Чтож, мы решили, что стреляем лучом или снарядом. Дальше встаёт чисто геометрический вопрос: а с чем именно проверять пересечение? С полигональным мешем персонажа? Со сферой? С прямоугольником? Вариантов много, и почти все они в индустрии стандартизированы — давайте разберём, какие и почему.&lt;/p&gt;&lt;h4&gt;Базовые ray–shape тесты&lt;/h4&gt;&lt;p&gt;Любая система попаданий стоит на пяти-шести примитивах. Их полезно знать наизусть: физический движок прячет их внутри себя, но иногда вы пишете collision-код сами — для предикта, для AI, для аналитики «куда стрелял противник».&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Луч–плоскость.&lt;/strong&gt; Самый простой случай: одна формула, одно скалярное произведение. Если плоскость задана точкой &lt;code&gt;p0&lt;/code&gt; и нормалью &lt;code&gt;n&lt;/code&gt;, а луч — origin &lt;code&gt;o&lt;/code&gt; и направлением &lt;code&gt;d&lt;/code&gt;, то параметрическое расстояние до пересечения это &lt;code&gt;t = ((p0 − o) · n) / (d · n)&lt;/code&gt;. Если знаменатель ноль — луч параллелен плоскости. Это базовый кирпич, на котором стоят более сложные тесты: AABB — это шесть таких проверок, OBB — те же шесть в локальном пространстве, mesh — миллион треугольников = миллион плоскостей. Кода тут на пару строк, отдельным листингом приводить не буду.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Луч–сфера.&lt;/strong&gt; Решаем квадратное уравнение &lt;code&gt;t²(d · d) + 2t(o − c) · d + |o − c|² − r² = 0&lt;/code&gt;, где &lt;code&gt;c&lt;/code&gt; — центр сферы, &lt;code&gt;r&lt;/code&gt; — радиус. Дискриминант скажет, попали или нет. Дёшево, используется для всего, что концептуально круглое, и как грубая обёртка перед точным тестом.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;// Луч–сфера: квадратное уравнение, дискриминант скажет, попали или нет.
public static bool RaySphere(Vector3 origin, Vector3 dir,
                             Vector3 c, float r,
                             out float t)
{
    Vector3 m = origin - c;
    float b = Vector3.Dot(m, dir);
    float cc = Vector3.Dot(m, m) - r * r;
    if (cc &amp;gt; 0f &amp;amp;&amp;amp; b &amp;gt; 0f) { t = 0f; return false; }     // мимо и удаляемся
    float disc = b * b - cc;
    if (disc &amp;lt; 0f) { t = 0f; return false; }             // мимо
    t = -b - Mathf.Sqrt(disc);
    if (t &amp;lt; 0f) t = 0f;                                  // источник внутри сферы
    return true;
}&lt;/code&gt;&lt;div&gt;&lt;a href="https://sourcecraft.dev/" rel="nofollow"&gt;&lt;img&gt;&lt;/a&gt;&lt;/div&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Луч–AABB (axis-aligned bounding box).&lt;/strong&gt; Slab method, шесть сравнений, никаких квадратных корней. Стандарт для грубой фазы: каждый объект в сцене сначала тестируется через AABB, и только если попало — переходим к точному тесту.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;// Луч–AABB: slab method, шесть сравнений, никаких квадратных корней.
public static bool RayAABB(Vector3 origin, Vector3 dir,
                           Vector3 min, Vector3 max,
                           out float t)
{
    float tmin = float.NegativeInfinity, tmax = float.PositiveInfinity;
    for (int i = 0; i &amp;lt; 3; i++) {
        float o = origin[i], d = dir[i];
        if (Mathf.Abs(d) &amp;lt; 1e-6f) {
            if (o &amp;lt; min[i] || o &amp;gt; max[i]) { t = 0f; return false; }
        } else {
            float inv = 1f / d;
            float t1 = (min[i] - o) * inv;
            float t2 = (max[i] - o) * inv;
            if (t1 &amp;gt; t2) (t1, t2) = (t2, t1);
            if (t1 &amp;gt; tmin) tmin = t1;
            if (t2 &amp;lt; tmax) tmax = t2;
            if (tmin &amp;gt; tmax) { t = 0f; return false; }
        }
    }
    t = tmin &amp;gt;= 0f ? tmin : tmax;
    return tmax &amp;gt;= 0f;
}&lt;/code&gt;&lt;div&gt;&lt;a href="https://sourcecraft.dev/" rel="nofollow"&gt;&lt;img&gt;&lt;/a&gt;&lt;/div&gt;&lt;/pre&gt;&lt;p&gt;&lt;strong&gt;Луч–OBB (oriented bounding box).&lt;/strong&gt; Это AABB, повёрнутый в локальном пространстве объекта. Решается ровно так же: переводим луч в локальные координаты объекта через &lt;code&gt;InverseTransformPoint&lt;/code&gt;/&lt;code&gt;InverseTransformDirection&lt;/code&gt; и зовём тот же &lt;code&gt;RayAABB&lt;/code&gt; с &lt;code&gt;±halfExtents&lt;/code&gt;. Отдельного листинга не заслуживает — это три строки поверх предыдущего.&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Луч–капсула.&lt;/strong&gt; Самый интересный случай для шутеров — потому что почти все хитбоксы в современных шутерах именно капсулы, и через эту функцию проходят все ваши попадания по противникам. Геометрически капсула — это отрезок &lt;code&gt;a→b&lt;/code&gt; плюс радиус &lt;code&gt;r&lt;/code&gt;: цилиндрическая «колбаса» с двумя полусферами на концах. Задача разваливается на две части.&lt;/p&gt;&lt;p&gt;Сначала считаем пересечение луча с &lt;strong&gt;бесконечным цилиндром&lt;/strong&gt; вокруг оси капсулы. Это сводится к классической задаче «кратчайшее расстояние между двумя скрещивающимися прямыми» — бесконечным лучом и осью &lt;code&gt;ab&lt;/code&gt;; в перпендикулярной к оси плоскости получается квадратное уравнение, по структуре похожее на ray–sphere. Дискриминант скажет, попали ли в цилиндр. Если попали — проверяем, что точка пересечения &lt;strong&gt;внутри отрезка&lt;/strong&gt; &lt;code&gt;[a, b]&lt;/code&gt;: считаем параметр &lt;code&gt;u&lt;/code&gt; вдоль оси и принимаем попадание только если &lt;code&gt;u ∈ [0, 1]&lt;/code&gt;. Если &lt;code&gt;u&lt;/code&gt; выскочил наружу или цилиндр промахнулся совсем — переходим к &lt;strong&gt;концевым полусферам&lt;/strong&gt;: считаем ray–sphere для центров &lt;code&gt;a&lt;/code&gt; и &lt;code&gt;b&lt;/code&gt; с тем же радиусом и берём ближайшее попадание.&lt;/p&gt;&lt;p&gt;То есть «луч–капсула» — это, по сути, ray–cylinder с проверкой диапазона по оси плюс два ray–sphere как fallback на полусферах концов. У Кристера Эриксона в &lt;em&gt;Real-Time Collision Detection&lt;/em&gt; лежит готовая формула в замкнутой форме (closed-form): пара скалярных произведений, квадратный корень, несколько ветвлений — точный ответ за фиксированное число операций, без итераций. Что критически важно для серверной валидации попаданий, где время выполнения должно быть предсказуемым.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;// Луч–капсула: отрезок a→b с радиусом r.
// Сводится к скрещивающимся прямым плюс две полусферы на концах.
public static bool RayCapsule(Vector3 origin, Vector3 dir,
                              Vector3 a, Vector3 b, float r,
                              out float t)
{
    Vector3 ab = b - a;
    Vector3 ao = origin - a;
    float abLen2 = Vector3.Dot(ab, ab);
    float abDotDir = Vector3.Dot(ab, dir);
    float abDotAo  = Vector3.Dot(ab, ao);
    float A = Vector3.Dot(dir, dir) - abDotDir * abDotDir / abLen2;
    float B = Vector3.Dot(dir, ao)  - abDotDir * abDotAo  / abLen2;
    float C = Vector3.Dot(ao, ao)   - abDotAo  * abDotAo  / abLen2 - r * r;
    if (Mathf.Abs(A) &amp;lt; 1e-6f) return Endcaps(origin, dir, a, b, r, out t);
    float disc = B * B - A * C;
    if (disc &amp;lt; 0f) return Endcaps(origin, dir, a, b, r, out t);
    t = (-B - Mathf.Sqrt(disc)) / A;
    if (t &amp;lt; 0f) return Endcaps(origin, dir, a, b, r, out t);
    Vector3 hp = origin + dir * t;
    float u = Vector3.Dot(hp - a, ab) / abLen2;
    if (u &amp;gt;= 0f &amp;amp;&amp;amp; u &amp;lt;= 1f) return true;     // попали в цилиндрическую часть
    return Endcaps(origin, dir, a, b, r, out t);
}
static bool Endcaps(Vector3 o, Vector3 d, Vector3 a, Vector3 b, float r, out float t)
{
    if (RaySphere(o, d, a, r, out float ta) &amp;amp;&amp;amp; RaySphere(o, d, b, r, out float tb))
        { t = Mathf.Min(ta, tb); return true; }
    if (RaySphere(o, d, a, r, out t)) return true;
    return RaySphere(o, d, b, r, out t);
}&lt;/code&gt;&lt;div&gt;&lt;a href="https://sourcecraft.dev/" rel="nofollow"&gt;&lt;img&gt;&lt;/a&gt;&lt;/div&gt;&lt;/pre&gt;&lt;p&gt;Это те самые пять кирпичей, на которых стоит почти вся collision-математика шутеров: плоскость, сфера, AABB, OBB и капсула. В большинстве проектов вы их в чистом виде не пишете — Unity Physics или собственный физический движок уже всё инкапсулировали. Но знать, что внутри, полезно: AI с предиктом, спекулятивные ray-cast'ы для предсказания попаданий, аналитика — везде эти функции всплывают.&lt;/p&gt;&lt;h4&gt;Почему хитбоксы — капсулы, а не меши&lt;/h4&gt;&lt;p&gt;В первый раз сталкиваясь с хитбоксами, многие думают: «ну как же — у нас есть полигональный меш персонажа, давайте по нему и проверять». Да? Конечно же нет.&lt;/p&gt;&lt;p&gt;Во-первых, &lt;strong&gt;стоимость&lt;/strong&gt;. Полигональный меш персонажа — это 5000–15000 треугольников, и проверка пересечения превращается в проход по каждому из них. Капсула — отрезок и радиус, всё разрешается одной формулой. На одну проверку разница в нагрузке на CPU выходит в 50–100 раз. На сервере, где в кадр прилетает несколько сотен проверок попаданий, эта разница становится решающей.&lt;/p&gt;&lt;p&gt;Во-вторых, &lt;strong&gt;стабильность&lt;/strong&gt;. Меш привязан к скелету и анимации, поэтому он буквально дёргается каждый кадр — пальцы, складки одежды, висящий на спине рюкзак. Капсула стоит на жёсткой кости и не дёргается. Это критически важно: если хитбокс «дрожит», игроки начинают замечать «странные» промахи и попадания, и разработчики получают вечный поток баг-репортов «вы убрали хитбокс с головы».&lt;/p&gt;&lt;p&gt;В-третьих, &lt;strong&gt;дизайн отдельно от визуала&lt;/strong&gt;. Хитбокс — это игровая механика, а меш — это визуал, и они должны управляться независимо. С капсулами вы можете сделать голову чуть больше визуальной модели для компенсации пинга или, наоборот, сузить торс, чтобы лучшие игроки чаще промахивались по краям. С полигональным мешем вы намертво привязаны к 3D-арту: художник перенарисовал плечи — изменилась игровая механика, и сетевую часть надо тестировать заново.&lt;/p&gt;&lt;p&gt;Стандартный набор для шутерного персонажа — 4–8 капсул: голова, торс, две руки, две ноги. В тактических играх (CS, Valorant) ещё отдельно бёдра и голени. В CS:GO стандарты хитбоксов перерабатывали несколько раз — самая громкая итерация была в &lt;a href="https://liquipedia.net/counterstrike/2015-09-15_Patch" rel="noopener noreferrer nofollow"&gt;патче от 15 сентября 2015&lt;/a&gt;, когда Valve полностью заменили старую боксовую систему на капсульную; в архивных сравнениях «до и после» видно, как они гонялись за «той самой» геометрией.&lt;/p&gt;&lt;figure&gt;&lt;img src="https://habrastorage.org/r/w1560/getpro/habr/upload_files/144/c95/4ae/144c954ae1b245992a848fb9b22a860e.png" width="1376" height="768"&gt;&lt;/figure&gt;&lt;h4&gt;Hit zones и multipliers&lt;/h4&gt;&lt;p&gt;Раз у нас несколько капсул на персонажа, логично каждой дать свой множитель урона. Стандартная пропорция для тактических шутеров: &lt;strong&gt;голова&lt;/strong&gt; — ×4 (одношотный хедшот из винтовки), &lt;strong&gt;торс&lt;/strong&gt; — ×1 (базовый урон), &lt;strong&gt;конечности&lt;/strong&gt; — ×0.5–0.75 (царапаешь, но не убиваешь). Реализуется тривиально: при попадании знаем, в какую капсулу попали (по тегу, слою или ID), у каждой свой множитель.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;public class Hitbox : MonoBehaviour {
    [SerializeField] float damageMultiplier = 1f;
    [SerializeField] BodyPart part = BodyPart.Torso;
    public void OnHit(float baseDamage, Vector3 hitPoint) {
        float dmg = baseDamage * damageMultiplier;
        var enemy = GetComponentInParent&amp;lt;Health&amp;gt;();
        enemy.TakeDamage(dmg, part);
    }
}&lt;/code&gt;&lt;div&gt;&lt;a href="https://sourcecraft.dev/" rel="nofollow"&gt;&lt;img&gt;&lt;/a&gt;&lt;/div&gt;&lt;/pre&gt;&lt;h4&gt;Лайфхак: квадраты вместо sqrt&lt;/h4&gt;&lt;p&gt;Раз мы говорим о геометрии — банально полезный лайфхак для тех мест, где collision-код выполняется сотни раз в кадр. Если вам не нужна точная дистанция, а нужно сравнение «ближе ли цель радиуса R» — никогда не считайте &lt;code&gt;sqrt&lt;/code&gt;. Сравнивайте квадраты:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;// плохо: считаем дистанцию каждый кадр для всех врагов
float dist = Vector3.Distance(player.position, enemy.position);
if (dist &amp;lt; range) Hit();
// хорошо: тот же результат, в 5–10 раз быстрее в часто выполняемом коде
if ((player.position - enemy.position).sqrMagnitude &amp;lt; range * range) Hit();&lt;/code&gt;&lt;div&gt;&lt;a href="https://sourcecraft.dev/" rel="nofollow"&gt;&lt;img&gt;&lt;/a&gt;&lt;/div&gt;&lt;/pre&gt;&lt;p&gt;На современных CPU это сэкономит вам считанные микросекунды, и в большинстве случаев такой micro-optimization в принципе не нужен. Но в тиках сетевой проверки попаданий на сервере, где может быть несколько сотен проверок в кадр, экономия становится осязаемой.&lt;/p&gt;&lt;h3&gt;3. Спред пули: где разработчики ломают «честность»&lt;/h3&gt;&lt;p&gt;Собственно, любое автоматическое оружие должно мазать. Если каждая пуля летит ровно в прицел — стрельба превращается в лазерную указку, и игроку нечего «осваивать». Поэтому у пули есть &lt;strong&gt;спред&lt;/strong&gt; — случайное (или заранее заданное) отклонение от центра прицела. Казалось бы, элементарно. На практике — это место, где разработчики чаще всего ломают игровое ощущение, причём незаметно для самих себя.&lt;/p&gt;&lt;h4&gt;Наивный подход — квадрат вместо круга&lt;/h4&gt;&lt;p&gt;Первое, что приходит в голову: «возьму два независимых random'а, один на &lt;code&gt;dx&lt;/code&gt;, другой на &lt;code&gt;dy&lt;/code&gt;».&lt;/p&gt;&lt;pre&gt;&lt;code&gt;// классическая ошибка
float dx = (Random.value * 2f - 1f) * spread;
float dy = (Random.value * 2f - 1f) * spread;
// итог: квадратное распределение&lt;/code&gt;&lt;div&gt;&lt;a href="https://sourcecraft.dev/" rel="nofollow"&gt;&lt;img&gt;&lt;/a&gt;&lt;/div&gt;&lt;/pre&gt;&lt;p&gt;Получается квадрат с биасом по углам: диагональ квадрата длиннее стороны в √2 раз, поэтому пуля в угловых направлениях улетает на &lt;code&gt;spread * 1.41&lt;/code&gt; вместо &lt;code&gt;spread&lt;/code&gt;. Игроки чувствуют это как «нечестно», но сформулировать почему обычно не могут — мозг просто ждёт круг, а получает квадрат. Что с этим делать — вариантов два, и оба сводятся к тому, чтобы вместо квадрата раздавать точки внутри диска.&lt;/p&gt;&lt;h4&gt;Rejection sampling и polar coordinates&lt;/h4&gt;&lt;p&gt;Самое прямое решение — &lt;strong&gt;rejection sampling&lt;/strong&gt;: кидаем точку в квадрат, и если она вне круга — кидаем ещё раз.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;public static Vector2 RejectionSpread(float spread)
{
    while (true) {
        float dx = (Random.value * 2f - 1f) * spread;
        float dy = (Random.value * 2f - 1f) * spread;
        if (dx * dx + dy * dy &amp;lt;= spread * spread) return new Vector2(dx, dy);
    }
}&lt;/code&gt;&lt;div&gt;&lt;a href="https://sourcecraft.dev/" rel="nofollow"&gt;&lt;img&gt;&lt;/a&gt;&lt;/div&gt;&lt;/pre&gt;&lt;p&gt;Площадь круга — это &lt;code&gt;π/4&lt;/code&gt; от площади квадрата, поэтому одна попытка успешна примерно в &lt;code&gt;π/4 ≈ 0.785&lt;/code&gt; случаев (то есть ~78%), а в среднем на одну точку уходит &lt;code&gt;4/π ≈ 1.27&lt;/code&gt; итерации. Минус — число итераций индетерминированное, в худшем случае циклов может потребоваться много. Для большинства случаев это не проблема, но если вы пилите детерминированный сетевой код (а вам этого, поверьте, рано или поздно захочется), то rejection sampling — не ваш друг: каждый клиент дёрнет &lt;code&gt;random&lt;/code&gt; разное число раз, и стейты разойдутся.&lt;/p&gt;&lt;p&gt;Хочется детерминизма и красоты — берите &lt;strong&gt;полярные координаты&lt;/strong&gt;. Один &lt;code&gt;random&lt;/code&gt; на угол, один на радиус — фиксированное число операций.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;public static Vector2 PolarSpread(float spread)
{
    float angle = Random.value * Mathf.PI * 2f;
    float radius = spread * Mathf.Sqrt(Random.value);   // ← важен sqrt!
    return new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius;
}&lt;/code&gt;&lt;div&gt;&lt;a href="https://sourcecraft.dev/" rel="nofollow"&gt;&lt;img&gt;&lt;/a&gt;&lt;/div&gt;&lt;/pre&gt;&lt;p&gt;Почему именно &lt;code&gt;sqrt&lt;/code&gt;? Если просто написать &lt;code&gt;radius = spread * Random.value&lt;/code&gt;, точки сожмутся к центру, потому что у вас линейное распределение по радиусу, а площадь круга растёт квадратично. Получится не равномерный диск, а bull's-eye с плотным центром. Чтобы плотность была равномерной по площади, нужно «растянуть» радиус через &lt;code&gt;sqrt&lt;/code&gt;. Это та же история, что в Monte-Carlo интеграции: равномерное распределение по &lt;code&gt;r&lt;/code&gt; не равно равномерному распределению по диску. И да — здесь &lt;code&gt;sqrt&lt;/code&gt;, наоборот, обязателен.&lt;/p&gt;&lt;h4&gt;Gaussian — для «честного, но рандомного» огня&lt;/h4&gt;&lt;p&gt;Если вам нужно «большинство пуль рядом с центром, редкие — далеко», берите &lt;strong&gt;Box-Muller&lt;/strong&gt; — преобразование, которое из пары равномерных чисел делает нормально распределённые. Это та самая работа Box &amp;amp; Muller (1958).&lt;/p&gt;&lt;pre&gt;&lt;code&gt;public static Vector2 GaussianSpread(float sigma)
{
    float u1 = Mathf.Max(Random.value, 1e-7f);  // log(0) — undefined
    float u2 = Random.value;
    float r = Mathf.Sqrt(-2f * Mathf.Log(u1)) * sigma;
    float t = 2f * Mathf.PI * u2;
    return new Vector2(r * Mathf.Cos(t), r * Mathf.Sin(t));
}&lt;/code&gt;&lt;div&gt;&lt;a href="https://sourcecraft.dev/" rel="nofollow"&gt;&lt;img&gt;&lt;/a&gt;&lt;/div&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;sigma&lt;/code&gt; контролирует ширину: меньше — точнее, больше — шире. В CS такой подход применяют для оружия sub-tier'а — FAMAS, Galil, кастомные пистолеты, — где хочется «случайно, но правдоподобно», с мягким центром и редкими далёкими выбросами.&lt;/p&gt;&lt;h4&gt;Детерминированные паттерны — Vandal и Phantom&lt;/h4&gt;&lt;p&gt;Теперь радикальный поворот. В Valorant у Vandal'а &lt;strong&gt;рандома вообще нет&lt;/strong&gt;. Каждая пуля в очереди — это заранее заданный offset из таблицы. Pro-игроки могут учить паттерны до почти полной воспроизводимости — и это не баг, а ровно то, чего Riot хотели (студия открыто говорила, что точность оружия в Valorant детерминирована). Минус понятен: если у двух игроков одинаковая позиция и одинаковая очередь, у них будет одинаковое попадание. Поэтому Riot балансируют точность через «горизонтальный sway» — после нескольких пуль начинается случайная горизонталь, ломающая идеальную копию.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;private static readonly Vector2[] VandalPattern = new Vector2[] {
    new(0, 0),    new(0, -7),   new(0, -16),  new(0, -25),
    new(-1, -34), new(-2, -42), new(-5, -48), /* ... до 30 точек */
};
public static Vector2 GetSpreadOffset(int bulletIndex, Vector2[] pattern)
{
    return pattern[Mathf.Min(bulletIndex, pattern.Length - 1)];
}&lt;/code&gt;&lt;div&gt;&lt;a href="https://sourcecraft.dev/" rel="nofollow"&gt;&lt;img&gt;&lt;/a&gt;&lt;/div&gt;&lt;/pre&gt;&lt;figure&gt;&lt;img src="https://habrastorage.org/r/w1560/getpro/habr/upload_files/326/ccf/cea/326ccfcea1e6ec8192a9979fb0e68b9f.png" width="1376" height="768"&gt;&lt;/figure&gt;&lt;h3&gt;4. Recoil patterns: почему AK-47 рисует семёрку&lt;/h3&gt;&lt;p&gt;Чтож, со спредом разобрались. Но в шутерах есть отдельная вещь — &lt;strong&gt;recoil&lt;/strong&gt;, отдача. И это не то же самое, что спред. Спред — это случайный (или фиксированный) разброс пуль вокруг прицела. Recoil — это смещение самого прицела вверх и в стороны после каждого выстрела. В Counter-Strike из этой механики выросла целая киберспортивная дисциплина — изучение spray patterns: игроки годами учат, как именно после первой пули AK поднимается ровно вверх, после четвёртой — резко влево, после девятой — назад вправо.&lt;/p&gt;&lt;h4&gt;Что вообще такое recoil-паттерн&lt;/h4&gt;&lt;p&gt;В принципе всё просто. Каждая последующая пуля в очереди добавляет смещение прицела по фиксированному (или почти фиксированному) набору offset'ов. Через 0.3–0.5 секунды паузы между выстрелами паттерн сбрасывается. Это не баг, а механика: игрок учит паттерн и получает преимущество за мастерство.&lt;/p&gt;&lt;p&gt;Насколько жёстко зафиксирован паттерн — каждая игра решает по-своему. В CS он фиксирован почти полностью: есть небольшой случайный jitter, но шейп один и тот же. В Battlefield и Tarkov — наоборот, рандомизация играет большую роль.&lt;/p&gt;&lt;h4&gt;Структура паттерна на примере AK-47 в CS&lt;/h4&gt;&lt;p&gt;Если посмотреть на реальный spray AK-47, видна характерная форма «семёрки» (или зеркального «Z»):&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Пули 1–4&lt;/strong&gt; — почти прямо вверх, vertical kick.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Пули 5–9&lt;/strong&gt; — резко влево, horizontal sway.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Пули 10–15&lt;/strong&gt; — назад вправо, balancing the kick.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Дальше&lt;/strong&gt; — мелкое колебание, всё равно мажет.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;Поэтому pro-игроки в CS на полной очереди тянут мышь по «обратной семёрке» — вниз и в обратные стороны, чтобы скомпенсировать паттерн.&lt;/p&gt;&lt;h4&gt;Реализация: таблица или формула&lt;/h4&gt;&lt;p&gt;Тут два подхода, и оба используются. Первый — &lt;strong&gt;lookup table&lt;/strong&gt;, самый прямой и прозрачный: массив &lt;code&gt;[(dx, dy)]&lt;/code&gt; на 30 элементов. Балансится через подкручивание чисел.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;private static readonly Vector2[] Ak47Pattern = new Vector2[] {
    new(0, 0), new(0, -8), new(0.5f, -18), new(-0.5f, -28),
    new(-1, -38), new(-3, -46), new(-7, -52), new(-12, -56),
    /* ... до 30 точек */
};
public Vector2 GetRecoilOffset(int bulletIndex) {
    return Ak47Pattern[Mathf.Min(bulletIndex, Ak47Pattern.Length - 1)];
}&lt;/code&gt;&lt;div&gt;&lt;a href="https://sourcecraft.dev/" rel="nofollow"&gt;&lt;img&gt;&lt;/a&gt;&lt;/div&gt;&lt;/pre&gt;&lt;p&gt;Второй — &lt;strong&gt;procedural&lt;/strong&gt;: функция от &lt;code&gt;bulletIndex&lt;/code&gt; через perlin-noise или несколько синусоид с разными фазами.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;public Vector2 ProceduralRecoil(int i) {
    float verticalKick = -i * 4f;
    float horizontalSway = Mathf.Sin(i * 0.6f) * (i * 0.5f);
    return new Vector2(horizontalSway, verticalKick);
}&lt;/code&gt;&lt;div&gt;&lt;a href="https://sourcecraft.dev/" rel="nofollow"&gt;&lt;img&gt;&lt;/a&gt;&lt;/div&gt;&lt;/pre&gt;&lt;p&gt;Procedural сложнее в балансе, но даёт «непохожесть» на конкурентов и легко скейлится по числу пуль. На практике AAA-шутеры с фиксированными узнаваемыми паттернами почти всегда сидят на lookup table — её можно вручную вылизать до миллиметра, а игрок именно эту фигуру и заучивает.&lt;/p&gt;&lt;h4&gt;Recoil compensation — как игроки «компенсируют»&lt;/h4&gt;&lt;p&gt;Pro-игрок в CS знает, что после выстрела AK прицел поедет вверх и влево. Он тянет мышь вниз и вправо — в реальном времени, по выученному паттерну. В результате на экране пули летят в одну точку. В играх с randomness в паттерне (Battlefield, Tarkov) полная компенсация невозможна — есть «потолок» точности, потому что часть смещения это настоящая случайность. Это сознательный выбор баланса: «мастерство имеет значение, но не отменяет случайность».&lt;/p&gt;&lt;figure&gt;&lt;img src="https://habrastorage.org/r/w1560/getpro/habr/upload_files/e98/3db/2ed/e983db2edfad23593bc8224d0d2cf2ff.png" width="1376" height="768"&gt;&lt;/figure&gt;&lt;h3&gt;5. Упреждение: квадратное уравнение для AI-снайперов&lt;/h3&gt;&lt;p&gt;Итак, у нас есть projectile-снаряд. Он летит со своей скоростью, и если мишень движется — целиться надо не туда, где мишень сейчас, а туда, &lt;strong&gt;где она окажется к моменту попадания пули&lt;/strong&gt;. Эту точку называют упреждением, англоязычные коллеги — leading the target. Это и есть то, чем мы обещали в первой части «починить» промахи projectile-AI. По сути же — это банально квадратное уравнение, и решается оно за десять строк кода.&lt;/p&gt;&lt;h4&gt;Постановка задачи&lt;/h4&gt;&lt;p&gt;Снайпер стоит в точке &lt;code&gt;P_s&lt;/code&gt;. Цель находится в точке &lt;code&gt;P_t&lt;/code&gt; и движется со скоростью &lt;code&gt;V_t&lt;/code&gt; (примем, что прямолинейно и с постоянной скоростью — упрощение, но именно эта модель работает в большинстве AI-снайперов). Снаряд летит со скоростью &lt;code&gt;S&lt;/code&gt;. В какую точку нужно стрелять, чтобы попасть?&lt;/p&gt;&lt;p&gt;В момент попадания цель окажется в точке &lt;code&gt;P_t + V_t · t&lt;/code&gt;, где &lt;code&gt;t&lt;/code&gt; — время полёта снаряда. Снаряд за то же время должен пролететь расстояние, равное &lt;code&gt;S · t&lt;/code&gt;. Значит, условие попадания:&lt;/p&gt;&lt;p&gt;&lt;code&gt;P_t + V_t · t − P_s = S · t&lt;/code&gt;&lt;/p&gt;&lt;p&gt;Возводим в квадрат, чтобы избавиться от модуля. Обозначим &lt;code&gt;D = P_t − P_s&lt;/code&gt; (вектор от стрелка до цели):&lt;/p&gt;&lt;p&gt;&lt;code&gt;(D + V_t · t) · (D + V_t · t) = S² · t²&lt;/code&gt;&lt;/p&gt;&lt;p&gt;Раскрываем скалярные произведения и собираем по степеням &lt;code&gt;t&lt;/code&gt;:&lt;/p&gt;&lt;p&gt;&lt;code&gt;(V_t² − S²) · t² + 2(V_t · D) · t + D² = 0&lt;/code&gt;&lt;/p&gt;&lt;p&gt;Получили &lt;strong&gt;квадратное уравнение&lt;/strong&gt; относительно &lt;code&gt;t&lt;/code&gt; вида &lt;code&gt;a·t² + b·t + c = 0&lt;/code&gt;, где:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;a = V_t² − S²b = 2 · (V_t · D)c = D²,        D = P_t − P_s&lt;/code&gt;&lt;div&gt;&lt;a href="https://sourcecraft.dev/" rel="nofollow"&gt;&lt;img&gt;&lt;/a&gt;&lt;/div&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;b = 2 · (V_t · D)&lt;/code&gt;&lt;/p&gt;&lt;p&gt;&lt;code&gt;c = D²,        D = P_t − P_s&lt;/code&gt;&lt;/p&gt;&lt;p&gt;Дальше — школа.&lt;/p&gt;&lt;h4&gt;Разбираем дискриминант&lt;/h4&gt;&lt;p&gt;Дискриминант &lt;code&gt;Δ = b² − 4ac&lt;/code&gt; (стандартный, не путать с вектором &lt;code&gt;D&lt;/code&gt; выше).&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Δ &amp;gt; 0:&lt;/strong&gt; два корня. Из них берём &lt;em&gt;меньший положительный&lt;/em&gt; — попадаем раньше.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Δ &amp;lt; 0:&lt;/strong&gt; вещественных корней нет, попасть невозможно (как правило — когда цель убегает быстрее снаряда и оторваться от неё нечем).&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Δ = 0:&lt;/strong&gt; один корень, граничный случай — тангенциальное попадание. Бывает редко, но численно стоит ловить.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;a = 0:&lt;/strong&gt; квадратное вырождается в линейное (&lt;code&gt;V_t = S&lt;/code&gt; ровно). Корней либо один, либо нет — обработать отдельно.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;h4&gt;Готовый код&lt;/h4&gt;&lt;p&gt;Возвращаем &lt;code&gt;Vector3?&lt;/code&gt; — &lt;code&gt;null&lt;/code&gt; означает «попасть невозможно». Или кодом:&lt;/p&gt;&lt;pre&gt;&lt;code&gt;public static Vector3? AimLead(
    Vector3 shooterPos, Vector3 targetPos,
    Vector3 targetVel, float bulletSpeed)
{
    Vector3 D = targetPos - shooterPos;
    float a = Vector3.Dot(targetVel, targetVel) - bulletSpeed * bulletSpeed;
    float b = 2f * Vector3.Dot(targetVel, D);
    float c = Vector3.Dot(D, D);
    // Вырожденный случай: V_t = S — квадратное превращается в линейное.
    if (Mathf.Abs(a) &amp;lt; 1e-6f) {
        if (Mathf.Abs(b) &amp;lt; 1e-6f) return null;
        float t0 = -c / b;
        return t0 &amp;gt; 0f ? (Vector3?)(targetPos + targetVel * t0) : null;
    }
    float disc = b * b - 4f * a * c;
    if (disc &amp;lt; 0f) return null;                          // Δ &amp;lt; 0 — попасть нельзя
    float sd = Mathf.Sqrt(disc);
    float t1 = (-b - sd) / (2f * a);
    float t2 = (-b + sd) / (2f * a);
    // Берём меньший положительный корень — попадаем раньше.
    float t = float.MaxValue;
    if (t1 &amp;gt; 0f &amp;amp;&amp;amp; t1 &amp;lt; t) t = t1;
    if (t2 &amp;gt; 0f &amp;amp;&amp;amp; t2 &amp;lt; t) t = t2;
    if (t == float.MaxValue) return null;
    return targetPos + targetVel * t;
}&lt;/code&gt;&lt;div&gt;&lt;a href="https://sourcecraft.dev/" rel="nofollow"&gt;&lt;img&gt;&lt;/a&gt;&lt;/div&gt;&lt;/pre&gt;&lt;h4&gt;Где это применяют&lt;/h4&gt;&lt;ul&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;AI-снайперы&lt;/strong&gt; в shooter'ах с medium-fast снарядами — Halo Brutes, артиллерия в Helldivers 2.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Авто-наведение&lt;/strong&gt; в аркадных играх и на мобиле (Free Fire, всякие метательные снаряды в духе Clash Royale).&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Турели&lt;/strong&gt; в tower defense.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Indicator упреждения&lt;/strong&gt; в прицелах танковых симуляторов — War Thunder, World of Tanks. Там, кстати, прямо в HUD рисуют точку, в которую игроку нужно целиться.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;Эта модель работает идеально только для прямолинейного движения с постоянной скоростью. Если цель ускоряется или меняет направление — результат становится приблизительным. Для более точного упреждения используют итеративные методы (повторяют вычисление с уточнённой позицией), но это уже про численные методы, и про них я как-нибудь напишу отдельно.&lt;/p&gt;&lt;figure&gt;&lt;img src="https://habrastorage.org/r/w1560/getpro/habr/upload_files/699/8c4/822/6998c4822c8ce03f3bc5e7d2816402b6.png" width="1376" height="768"&gt;&lt;/figure&gt;&lt;h3&gt;6. Lag compensation: где ты попал, но не засчиталось&lt;/h3&gt;&lt;p&gt;Чтож, мы дошли до самого болезненного. До той самой ситуации, где вы стреляли в голову противника, у вас на экране попали, а сервер сказал «промах». Это не баг и не ваш плохой интернет — это netcode так устроен. И за выбором, как именно netcode устроить, стоит честный trade-off.&lt;/p&gt;&lt;h4&gt;Откуда вообще берётся проблема&lt;/h4&gt;&lt;p&gt;В сетевом шутере у клиента нет «настоящего» мира. Есть собственная локальная симуляция, обновляемая по snapshot'ам с сервера. Между фактической позицией игрока на сервере и тем, что видит клиент, всегда есть задержка из двух слагаемых:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Пинг&lt;/strong&gt; — туда-обратно сетевая задержка, обычно 20–80 мс на хорошем соединении, до 200+ мс на плохом.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Interpolation buffer&lt;/strong&gt; — клиент сознательно отстаёт от сервера на 50–100 мс, чтобы плавно интерполировать между snapshot'ами вместо джиттера.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;В сумме клиент видит мир «в прошлом» примерно на 80–200 мс. Когда вы стреляете, вы стреляете в то, что видите, — то есть в позицию противника, какой она была сотню миллисекунд назад. На сервере противник за это время уже сдвинулся, и если сервер проверяет попадание по &lt;em&gt;текущей&lt;/em&gt; позиции — вы промазали, хотя на вашем экране попали идеально.&lt;/p&gt;&lt;h4&gt;Решение Valve: server rewind&lt;/h4&gt;&lt;p&gt;Идея до гениальности проста, и канонически её описал Yahn Bernier из Valve ещё на GDC 2001. Сервер хранит &lt;strong&gt;историю&lt;/strong&gt; позиций всех игроков за последнюю секунду — кольцевой буфер snapshot'ов. Когда клиент посылает выстрел, к пакету прикладывается метка времени: «выстрелил в момент &lt;code&gt;t&lt;/code&gt; по своим часам». Сервер откатывает позиции хитбоксов к этому моменту, проверяет попадание и восстанавливает текущее состояние мира.&lt;/p&gt;&lt;p&gt;Псевдокод серверного hit-checker'а в Unity-стиле&lt;/p&gt;&lt;pre&gt;&lt;code&gt;public void ProcessShot(ShotPacket shot)
{
    // когда клиент видел эту картину, по часам сервера
    float compTime = shot.ClientTime - shot.Ping * 0.5f;
    // откатываем хитбоксы всех игроков на этот момент
    var snapshot = playerHistory.GetAtTime(compTime);
    ApplySnapshot(snapshot);
    // проверяем попадание стандартным Physics.Raycast
    if (Physics.Raycast(shot.Origin, shot.Direction, out var hit, shot.MaxRange,
                        hitboxLayerMask)) {
        var target = hit.collider.GetComponentInParent&amp;lt;Health&amp;gt;();
        if (target != null) target.TakeDamage(shot.Damage);
    }
    // возвращаем мир в текущее состояние, чтобы остальная симуляция не сошла с ума
    RestoreCurrent();
}&lt;/code&gt;&lt;div&gt;&lt;a href="https://sourcecraft.dev/" rel="nofollow"&gt;&lt;img&gt;&lt;/a&gt;&lt;/div&gt;&lt;/pre&gt;&lt;p&gt;Маленькая оговорка к формуле &lt;code&gt;compTime&lt;/code&gt;: в боевых движках к &lt;code&gt;Ping * 0.5&lt;/code&gt; обычно добавляют ещё и величину interpolation-буфера (те самые 50–100 мс), потому что клиент видит мир в прошлом не только из-за пинга. Я это опустил, чтобы не загромождать псевдокод, — но в реальном откате его учитывают.&lt;/p&gt;&lt;p&gt;Это стандарт Source / GoldSrc (Valve), Apex Legends на Source 2 (с поправками), у Overwatch свой netcode со своими тонкостями, но идея та же. Игрок этого механизма не видит — но именно из-за него возникает побочный эффект, который видит и ненавидит жертва.&lt;/p&gt;&lt;h4&gt;Trade-off: «убит из-за угла»&lt;/h4&gt;&lt;p&gt;У server rewind есть жёсткий побочный эффект. Жертва уже скрылась за угол на своём экране — а на сервере её &lt;em&gt;откатили&lt;/em&gt; под выстрел стрелка. С точки зрения жертвы: «я была в безопасности, за углом, и всё равно умерла».&lt;/p&gt;&lt;p&gt;Можно ли это исправить? По сути нет — это объективное следствие выбора «приоритет за стрелком». Если бы сервер использовал текущую позицию, а не откат, то промах был бы у стрелка, и жалоб «я попал, не засчиталось» стало бы кратно больше. Это сознательное решение жанра: лучше иногда «убит из-за угла», чем регулярно «попал, не засчиталось». В CS, Apex и Valorant это считается приемлемой ценой за «попадаешь там, куда целишься». В играх с большим пингом разница становится заметнее, и это одна из причин, почему 64-tick CS:GO регулярно ругали по сравнению с 128-tick faceit-серверами — чем чаще тики, тем точнее rewind и тем меньше эффект «убит из-за угла».&lt;/p&gt;&lt;h4&gt;Альтернатива: client authority&lt;/h4&gt;&lt;p&gt;Можно вообще выкинуть rewind и доверить стрелку решать, попал он или нет. Клиент сам говорит «я попал», сервер просто верит.&lt;/p&gt;&lt;pre&gt;&lt;code&gt;// На стрелке:
void Fire() {
    if (Physics.Raycast(ray, out var hit)) {
        var enemy = hit.collider.GetComponent&amp;lt;Enemy&amp;gt;();
        if (enemy != null) {
            // отправляем серверу: "я попал в enemy.id, нанесите урон"
            networkClient.Send(new HitPacket { targetId = enemy.id, damage = 25 });
        }
    }
}&lt;/code&gt;&lt;div&gt;&lt;a href="https://sourcecraft.dev/" rel="nofollow"&gt;&lt;img&gt;&lt;/a&gt;&lt;/div&gt;&lt;/pre&gt;&lt;p&gt;Минимум CPU на сервере, минимум confusion для жертвы, никакого rewind. Проблема очевидна: легко читерить. Просто отправляй «попал» каждый раз — сервер засчитает. Поэтому client authority для PvP-шутеров — табу. Где это работает: &lt;strong&gt;кооперативные игры&lt;/strong&gt; против AI — Borderlands, Destiny boss fights, любой co-op шутер. Там cheating не критичен (вред от него ограничен своей же командой), и упрощённый netcode даёт лучший feel.&lt;/p&gt;&lt;figure&gt;&lt;img src="https://habrastorage.org/r/w1560/getpro/habr/upload_files/37f/e17/6d5/37fe176d548904d3f3a0c5aba1487d65.png" width="1376" height="768"&gt;&lt;/figure&gt;&lt;h3&gt;Что в итоге и куда копать дальше&lt;/h3&gt;&lt;p&gt;Чтож, мы прошли шесть базовых задач шутерной стрельбы. По одному предложению на каждую — на случай, если вы листали по диагонали:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hitscan vs projectile&lt;/strong&gt; — выбор между лучом (мгновенно, дёшево, нет уворота) и снарядом (симуляция, дороже, можно увернуться).&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Геометрия попадания&lt;/strong&gt; — хитбоксы это капсулы, а не меши; в часто выполняемом коде сравнивайте квадраты дистанций.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Спред&lt;/strong&gt; — два независимых random дают квадрат вместо круга; правильное решение — полярные координаты с &lt;code&gt;sqrt&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Recoil patterns&lt;/strong&gt; — детерминированная таблица offset'ов плюс компенсация мышью; это про мастерство, а не баг.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Упреждение&lt;/strong&gt; — банально квадратное уравнение по &lt;code&gt;t&lt;/code&gt;, корни делятся на «попасть можно» и «нельзя» через дискриминант.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Lag compensation&lt;/strong&gt; — сервер откатывает время на момент выстрела, побочный эффект — «убит из-за угла» с точки зрения жертвы.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;h4&gt;Чего нет в этой статье&lt;/h4&gt;&lt;p&gt;Я сознательно оставил за бортом несколько больших тем:&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Баллистика снайперок&lt;/strong&gt; — drop пули по дистанции, ветер, температура воздуха. Отдельная тема, важная для военных шутеров и снайперских симуляторов вроде Sniper Elite.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Penetration через стены и материалы&lt;/strong&gt; — в CS и Tarkov это глубокая система с разной плотностью материалов, замедлением пули и переменным damage falloff'ом.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Ricochet и rebound&lt;/strong&gt; — отскоки. В Destiny, Halo, Doom Eternal на этом играют отдельные оружия.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Damage falloff&lt;/strong&gt; — простая механика, но требует отдельного баланса. Зачем дробовику плохо стрелять на дистанции — отдельный разговор.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Звук, кросс-эффекты, partial visibility, бронирование&lt;/strong&gt; — тут уже стык с graphics, audio и UX.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;Если эта тема вам в принципе заходит — я веду телеграм-канал &lt;a href="https://t.me/easy_dev_math" rel="noopener noreferrer nofollow"&gt;«математика в геймдеве по-простому»&lt;/a&gt;: там такие разборы выходят короче и чаще, и туда же первыми попадают анонсы новых статей. Заходите.&lt;/p&gt;&lt;p&gt;Что почитать дальше (только то, в чём я уверен):&lt;/p&gt;&lt;ul&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Christer Ericson, &lt;em&gt;Real-Time Collision Detection&lt;/em&gt;&lt;/strong&gt; (Morgan Kaufmann, 2004) — библия ray–shape тестов, в том числе closed-form ray–capsule из части 2.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Yahn W. Bernier (Valve), &lt;em&gt;Latency Compensating Methods in Client/Server In-game Protocol Design and Optimization&lt;/em&gt;&lt;/strong&gt; (GDC 2001) — каноника по lag compensation. Плюс статья &lt;strong&gt;Source Multiplayer Networking&lt;/strong&gt; в Valve Developer Community wiki — то же на пальцах.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Gabriel Gambetta, &lt;em&gt;Fast-Paced Multiplayer&lt;/em&gt;&lt;/strong&gt; (&lt;a href="http://gabrielgambetta.com" rel="noopener noreferrer nofollow"&gt;&lt;code&gt;gabrielgambetta.com&lt;/code&gt;&lt;/a&gt;) — лучший связный разбор client prediction, server reconciliation, entity interpolation и lag compensation в одном месте.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Glenn Fiedler, &lt;/strong&gt;&lt;a href="http://gafferongames.com" rel="noopener noreferrer nofollow"&gt;&lt;code&gt;&lt;strong&gt;gafferongames.com&lt;/strong&gt;&lt;/code&gt;&lt;/a&gt; — теория сетевых игр, тики, снапшоты, надёжный UDP.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Box, G.E.P. &amp;amp; Muller, M.E. (1958), &lt;em&gt;A Note on the Generation of Random Normal Deviates&lt;/em&gt;&lt;/strong&gt; (Annals of Mathematical Statistics 29(2):610–611) — та самая формула Box-Muller для Gaussian-спреда.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;CS:GO patch от 15 сентября 2015&lt;/strong&gt; (&lt;code&gt;liquipedia.net/counterstrike/2015-09-15_Patch&lt;/code&gt;) — переход на капсульные хитбоксы.&lt;/p&gt;&lt;/li&gt;&lt;li&gt;&lt;p&gt;&lt;strong&gt;Battle(non)sense&lt;/strong&gt; на YouTube — практические замеры netcode современных шутеров, если хочется увидеть rewind и tickrate в цифрах.&lt;/p&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;Надеюсь, статья была полезна и хотя бы один из этих шести кусков пригодится в вашем проекте. Если хочется покрутить демки руками, а не смотреть на статичные кадры, — &lt;a href="https://dev-math.ru/articles/shooting/" rel="noopener noreferrer nofollow"&gt;интерактивная версия со всеми интерактивами тут&lt;/a&gt;.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/article&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;&lt;img alt="Хабр Карьера Курсы" src="https://habrastorage.org/webt/qq/ey/pn/qqeypn-py71suynxbusbakjdfjw.png"&gt;&lt;div&gt;&lt;div&gt;&lt;div&gt;Хабр Курсы для всех&lt;/div&gt;&lt;div&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt; Практикум, Хекслет, SkyPro, авторские курсы — собрали всех и попросили скидки. Осталось выбрать! &lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">DyadichenkoGA</dc:creator><pubDate>Tue, 23 Jun 2026 08:04:13 +0000</pubDate><guid>https://habr.com/ru/articles/1050808/?utm_source=habrahabr&amp;utm_medium=rss&amp;utm_campaign=1050808</guid><category>hitscan</category><category>projectile</category><category>lag compensation</category><category>server rewind</category><category>recoil</category><category>спред пули</category><category>упреждение</category><category>netcode</category><category>шутеры</category><category>геймдев</category></item></channel></rss>