Здравствуйте, меня зовут почему.
Несколько дней назад я увидел техническую проблему на одной платформе, что очень интересно.
Два задействованных технических момента обычно разрабатываются и используются всеми, но они относятся к небольшой детали, все же интересно копнуть глубже.
Приходите, давайте сначала посмотрим, в чем проблема, и заодно объясним ее вам:
Сначала одноклассник дал фрагмент кода:
Он сказал, что у него есть метод func, который делает две вещи:
- 1. Сначала запросите список продуктов в базе данных.
- 2. Если инвентарь еще есть, то уменьшить инвентарь на единицу, чтобы имитировать продажу товара.
Во-вторых, студент, который задал вопрос, на самом деле написал в нем две операции, поэтому я разобью его:
- 2.1 Уменьшить инвентарь на единицу.
- 2.2 Вставьте данные заказа в таблицу заказов.
Очевидно, что эти две операции будут работать с базой данных и должны быть атомарными операциями.
Итак, добавьте@Transactional
аннотация.
Затем, чтобы решить проблему параллельного доступа, он обернул весь код блокировкой, чтобы при монолитной структуре только один запрос мог выполнять операции сокращения запасов и генерации заказов одновременно.
превосходно.
Прежде всего, сформулируем основную предпосылку: механизм изоляции базы данных MySQL использует повторяемый уровень чтения.
В это время приходит проблема.
В случае высокого уровня параллелизма предполагается, что несколько потоков одновременно вызывают метод func.
Чтобы гарантировать отсутствие ситуации перепроданности, открытие и отправка транзакций должны быть полностью обернуты между блокировкой и разблокировкой.
Очевидно, что открытие сделки должно быть после блокировки.
Итак, ключ в том, должна ли фиксация транзакции быть до разблокировки?
Если фиксация транзакции происходит до разблокировки, проблем нет.
Поскольку транзакция была зафиксирована, это означает, что запасы должны быть уменьшены, а блокировка в это время не снята, поэтому другие потоки не могут войти.
Нарисуйте простую схему следующим образом:
После разблокировки приходит другой поток для выполнения операции запроса к базе данных, тогда запрошенное значение должно быть значением после вычитания инвентаризации.
Однако, если фиксация сделки после разблокировки, то происходит интересное, у вас, скорее всего, будет перепроданность.
Изображение выше становится таким, обратите внимание, что последние два шага поменялись местами:
Например.
Предположим, что сейчас в наличии есть только один.
В это время два потока, A и B, запрашивают размещение заказа.
A сначала просит получить замок, а затем спрашивает, что инвентарь равен 1, и может разместить заказ.После процесса заказа инвентарь уменьшается до 0.
Но поскольку A сначала выполнил операцию разблокировки, блокировка снимается.
Поток B немедленно бросился, чтобы получить блокировку, увидев ее, и выполнил операцию запроса инвентаризации.
Обратите внимание, что в это время поток A еще не успел отправить транзакцию, поэтому инвентаризация, прочитанная B, по-прежнему равна 1. Если программа плохо контролируется, она также пройдет через процесс заказа.
Ого, это перепродано.
Итак, повторяю вопрос:
В случае приведенного выше примера кода проблем не возникает, если фиксация транзакции происходит до разблокировки. Но будут проблемы, если он разлочен.
Так фиксируется транзакция до или после разблокировки?
В этом вопросе сначала разбираемся в проблеме, а потом уже нажимаем на стол. Вы можете просто подумать об этом.
Я хочу рассказать об этой фразе, которую я недооценил и взял на время, а вы, вероятно, не заметили:
Очевидно, что открытие сделки должно быть после блокировки.
Это предложение не то, что сказал я, а то, что сказал студент, задавший вопрос:
У вас есть сомнения?
Почему это очевидно? Где очевидно? Почему транзакция не начинается сразу после ввода метода?
Пожалуйста, дайте мне доказательства.
Давай, взгляни на доказательства.
время начала бизнеса
Для доказательства нам нужно перейти к исходному коду, чтобы найти его.
Кроме того, я должен сказать, что исходный код Spring для транзакций очень ясен и прост для понимания, и кажется, что препятствий в принципе нет.
Так что, если вы не знаете, как взломать исходный код, то исходный код транзакции может стать для вас возможностью порвать исходный код.
Что ж, без лишних слов, давайте найдем ответ.
Ответ кроется в этом методе:
org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin
Сначала взгляните на строку журнала, которую я разместил ниже:
Switching JDBC Connection [HikariProxyConnection@946359486 wrapping com.mysql.jdbc.JDBC4Connection@7a24806] to manual commit
Вы знаете, я технический блоггер и иногда учу слова.
Переключение, преобразование.
Подключение, ссылка.
ручная фиксация, ручная фиксация.
Переключение... на..., конвертировать что во что.
Неожиданно в этот раз я выучил не только несколько слов, но и грамматику.
Итак, перевод приведенного выше предложения очень прост:
Переключите соединение с базой данных на ручную фиксацию.
Затем давайте взглянем на логику кода для печати этой строки журналов, которая является частью кода, находящейся в фрейме.
Я выношу его один:
Логика очень понятна: изменить параметр AutoCommit соединения с true на false.
Итак, теперь возникает вопрос, в это время транзакция началась?
Я не думаю, что это началось, это просто готово.
Между запуском и готовностью все еще есть небольшая разница, готовность — это шаг перед запуском.
Итак, каковы способы начать транзакцию?
-
Первый: используйте оператор для запуска транзакции, это явный запуск транзакции. Например, операторы начала или начала транзакции. Сопутствующая инструкция фиксации — это фиксация, а инструкция отката — откат.
-
Второе: значение autocommit по умолчанию равно 1, что означает, что включена автоматическая фиксация транзакции. Если мы выполним set autocommit=0, эта команда отключит автофиксацию для этого потока. Это означает, что если вы выполните только один оператор select, транзакция запустится и не будет зафиксирована автоматически. Эта транзакция сохраняется до тех пор, пока вы активно не выполните оператор фиксации или отката или не отключитесь.
Очевидно, что второй способ принят в Spring.
в то время как приведенный выше кодcon.setAutoCommit(false)
Просто отключите автофиксацию для этой ссылки.
Когда на самом деле начинается транзакция?
Упомянутая выше команда начала/запуска транзакции не является отправной точкой транзакции.После выполнения первого оператора, работающего с таблицей InnoDB, транзакция действительно запускается.
Если вы хотите начать транзакцию немедленно, используйте команду запуска транзакции с согласованным снимком. Обратите внимание, что эта команда не имеет значения на уровне фиксации чтения (RC) и приводит к непосредственному использованию стартовой транзакции.
Вернемся к предыдущему вопросу: когда будет выполнен первый оператор SQL?
Сразу после кода блокировки.
Таким образом, очевидно, что открытие транзакции должно быть после блокировки.
Это простое «очевидное» должно дать вам фору.
Далее давайте взглянем на анимацию, которая более интуитивно понятна.
Давайте сначала поговорим об этом SQL:
select * from information_schema.innodb_trx;
Без особых объяснений вам просто нужно знать, что это оператор для запроса того, какие транзакции выполняются в текущей базе данных.
Просто обратите внимание на следующую анимацию, это после выполнения оператора запроса в строке 27 оператор запроса транзакции может узнать данные, указывающие на то, что транзакция действительно открыта:
Наконец, обратим внимание на аннотацию этого метода:
Написание столь длинного комментария означает, что этот параметр по умолчанию имеет значение true, поскольку в некоторых JDBC-драйверах переключение на автоматическую фиксацию — очень тяжелая операция.
Так где же установлено значение true ?
Обычно я не сдаюсь, не увидев код.
Итак, смотрим вместе.
Для метода setAutoCommit есть несколько классов реализации, и я не знаю, какой из них пойдет:
Итак, мы можем поставить точку останова на следующем интерфейсе:
java.sql.Connection#setAutoCommit
Затем перезапустите программу, IDE автоматически поможет вам определить, какой класс реализации использовать:
Как видите, значение по умолчанию действительно верно.
Подожди, ты же не думаешь, что я хочу, чтобы ты увидел это правдой, не так ли?
Я просто хотел, чтобы вы знали об этой технике отладки.
Я не знаю, сколько друзей спрашивали меня: существует так много классов реализации этого интерфейса, откуда мне знать, где прерывать?
Я сказал: очень просто, просто поставьте точку останова на первую строку кода каждого класса реализации.
Тогда он сказал: Не создавайте проблем, я часто ставлю ваши статьи тройками в один клик.
Я был тронут в то время.Так как я такой хороший читатель, конечно, я научил его этому маленькому трюку, который может напрямую прерывать точку на интерфейсе.
Ладно, не будем заходить слишком далеко.
Еще одна маленькая деталь, и этот раздел заканчивается.
Вы снова переходите к началу этого раздела, я прямо говорю, что ответ скрыт в этом методе:
org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin
Прямо сказать вам ответ и скрыть процесс исследования.
Но такая штука, как вывод математических формул, пропуск шага заставит людей выглядеть сбитыми с толку.
Как эта маленькая мышка:
Итак, как я узнал, что нужно сломать точку в этом месте?
Ответ — стек вызовов.
Позвольте мне сначала показать вам мой код:
Не заботьтесь ни о чем, просто поставьте точку останова на входе метода в строке 26 и запустите:
Эй, посмотри на этот стек вызовов, это место, которое я подставил:
Глядя на имя, вам не любопытно?
Просто прыгает на лапках и зовет тебя: щелкни меня, поторопись, что ты делаешь, поспеши со мной. У меня есть секрет здесь!
Потом, приложив немного усилий, я получил вот это:
org.springframework.transaction.interceptor.TransactionAspectSupport#invokeWithinTransaction
Здесь есть аспект, который можно понимать как выполнение логики нашего бизнес-кода в try:
И в блоке кода try перед выполнением нашего бизнес-кода есть эта строка кода:
Найдите его здесь, прямо перед этой строкой кода, мягко нажмите на точку останова, а затем отладьте его, вы можете найти метод, упомянутый в начале этого раздела:
org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin
Не верю? Видишь ли, я тебе не вру.
Между ними всего три звонка:
Так нашел ответ.
Стек вызовов, еще один трюк для отладки исходного кода, опробованный и протестированный, и отправленный вам.
до или после
Ну, впереди закуски. Некоторые студенты, возможно, были сыты после того, как съели закуски.
Все в порядке, теперь это ужин, и вы все еще можете съесть его, нажав кнопку еще раз.
Или возьмем в качестве примера предыдущий код, процесс выглядит так:
- 1. Сначала возьмите замок.
- 2. Проверьте инвентарь.
- 3. Определите, есть ли еще запас.
- 4. При наличии запасов будет выполняться логика сокращения запасов и создания заказов.
- 5. Возврат, если товара нет в наличии.
- 6. Разблокируйте замок.
Итак, код такой:
Точно в соответствии с нашим предыдущим фрагментом кода, с транзакциями и блокировками:
Вернемся к вопросу, который мы задали в начале:
В случае приведенного выше примера кода транзакция фиксируется до или после разблокировки?
Мы можем привести конкретный сценарий.
Например, в моей базе есть 10 топовых iPad.Первоначальная цена 1,6 Вт за комплект, но теперь цена за единицу составляет 1 Вт.Достаточно ли этой цены, чтобы убить?
Во всяком случае, всего их 10, поэтому моя база данных выглядит так:
Затем я поручу 100 людям украсть эти вещи, не слишком ли это много?
Здесь я использую CountDownLatch для имитации параллелизма:
Выполните его, сначала посмотрите результаты и сразу же увидите разницу:
Правая часть анимации:
Выше приведен запрос браузера, который запускает код контроллера.
Затем посередине находится таблица продуктов с 10 товарами на складе.
Внизу таблица заказов, данных нет.
После срабатывания кода запас равен 0, проблем нет.
Однако на самом деле заказов 20!
То есть 10 топовых версий ipad pro перепроданы!
Перепродано, но не в рамках бюджета мероприятия!
То есть один 1.6w, 10 16w.
Он настолько ничем не примечательный, безобидный для людей и животных, и даже код, который выглядит убого и убого, на самом деле заставил меня потерять целых 16w.
На самом деле результаты появляются, и ответ последует.
В примере кода выше фиксация транзакции происходит после разблокировки.
На самом деле после тщательного анализа можно догадаться, что это должно быть после разблокировки.
А приведенное выше описание «после разблокировки» на самом деле несколько сбивает с толку, потому что снятие блокировки — это особая операция.
Это легче понять, если вы измените описание:
В приведенном выше примере кода фиксация транзакции происходит после завершения выполнения метода.
Если вы присмотритесь, не станет ли это описание менее запутанным, и даже вы вдруг поймете: разве это не здравый смысл?
Почему именно после окончания метода, прежде чем разбирать конкретные причины, хотелось бы вкратце разобрать причины, по которым пишется такой код.
Я думаю, это может быть так.
Исходная структура кода выглядит так:
Потом это было написано и оказалось неверным: в параллельном сценарии инвентарь является общим ресурсом, и эту штуку нужно заблокировать.
Итак, я сделал это:
Когда я позже снова просмотрел код, я обнаружил, что: о, этот третий шаг должен быть операцией транзакции.
Итак, код становится таким:
Путь эволюции очень разумен, и окончательный код выглядит почти безупречным.
Но в чем проблема?
найти ответ
Ответ все еще в этом классе:
org.springframework.transaction.interceptor.TransactionAspectSupport#invokeWithinTransaction
Когда мы говорили об открытии транзакции ранее, мы говорили о 382-й строке кода.
Затем блок кода try выполняет наш бизнес-код.
Теперь мы собираемся изучить фиксацию транзакции, так что в основном это то, где я ее обрамляю.
Во-первых, в блоке кода catch, строка 392, имя метода уже очень знакомо:
completeTransactionAfterThrowing
Фиксация транзакции выполняется после создания исключения.
Посмотрите на мой код, просто используйте@Transactional
Аннотация, и никаких исключений не указано.
Тогда возникает вопрос:
Что такое исключение отката по умолчанию для транзакций, управляемых Spring?
Если вы не знаете ответа, вы можете обратиться к исходному коду с вопросом.
Если вы знаете ответ, но не видели соответствующего кода, вы также можете перейти к исходному коду.
Если вы знаете ответ, вы также читали эту часть исходного кода, просматривая прошлое и узнавая новое.
Сначала ответьте:Исключением отката по умолчанию является RuntimeException или Error..
Мне просто нужно создать подкласс RuntimeException в бизнес-коде, например:
Затем поставьте точку останова на строке 392, начните отладку, и все готово:
Всего за несколько шагов отладки вы можете добраться до этого метода:
org.springframework.transaction.interceptor.RuleBasedTransactionAttribute#rollbackOn
Выясняется, что объект-победитель пуст, и далее следует такая логика:
return super.rollbackOn(ex);
Ответ кроется за этой строкой кода:
Если тип исключения является подклассом RuntimeException или Error, то возвращаем true, то есть требуется откат, и вызывается метод отката:
Если возврат ложный, значит, откат не требуется, и вызывается метод коммита:
Итак, как заставить его возвращать false?
Это очень просто, просто сделайте это так:
Фреймворк оставляет для вас дыру, и вы ее используете.
Когда я изменяю код на указанный выше, перезапускаю проект и снова получаю доступ к коду.
Давайте искать конкретную логику реализации, которая не выполняет откат при возникновении указанного исключения.
На самом деле, это также в методе, который мы только что видели:
Видите ли, в настоящее время победитель не является нулевым. Это тоже объект NoRollbackRuleAttribute.
Итак, я вошел в эту строку кода и вернул false:
return !(winner instanceof NoRollbackRuleAttribute);
Итак, я успешно перешел на ветку else, и я совершил исключение, когда было исключение, вы сказали, что оно волшебное или нет:
Когда я писал это, я вдруг подумал о эффектной операции, которая может даже стать вопросом интервью с песочной скульптурой:
Шутка ли эта операция или нет, откатится она или нет?
Если вы видите такой код в проекте, вас должны назвать дураком.
Но интервьюер любит заниматься этими преступными темами.
Когда я думал об этом вопросе, я не знал ответа, но я знал, что ответ все еще находится в исходном коде:
Во-первых, по результату интуитивно видно, что после цикла for побеждает объект RollbackRuleAttribute, поэтому следующий код возвращает true и его нужно откатить:
return !(winner instanceof NoRollbackRuleAttribute);
Возникает вопрос, почему RollbackRuleAttribute победителя оказывается после цикла for?
Ответ нужно отлаживать самому.Это легко понять.Мне сложнее описать.
Простое предложение: причина, по которой победителем является RollbackRuleAttribute, заключается в том, что список, который зацикливается, первым добавляет объект RollbackRuleAttribute.
Так почему же объект RollbackRuleAttribute добавляется в коллекцию в первую очередь?
org.springframework.transaction.annotation.SpringTransactionAnnotationParser#parseTransactionAnnotation(org.springframework.core.annotation.AnnotationAttributes)
Не спрашивайте, спрашивайте, потому что так написано в коде.
Почему код написан именно так?
Я думаю, что, возможно, разработчики, которые разработали этот код, думали, что rollbackFor имеет более высокий приоритет, чем noRollbackFor.
Еще один вопрос:
Как исходный код Spring соответствует текущему исключению, которое необходимо откатить?
Не думайте об этом так сложно, дорога проста, рекурсивно рекурсивно, затем слой за слоем найдите родительский класс и сравните имена, и все готово.
Обратите внимание на примечание на скриншоте:
Один найден!
Это означает, что он был найден и сопоставлен, а восклицательный знак используется для обозначения того, что я очень счастлив.
Один из них: если мы зашли так далеко, как только могли, и не нашли его...
Что это значит? Это, насколько это союз на английском языке, что означает «до... до...». Введены наречные предложения, которые подчеркивают степень или объем.
Итак, приведенная выше фраза означает:
Если мы зашли так далеко, как только могли, и не нашли совпадений, код можно написать только так:
Самый дальний класс исключений — Throwable.class. Если совпадения нет, вернуть -1.
Ну и через два бесполезных пункта знаний немного подучил кстати английский практический.Это почти то же самое,что ли бизнес-код откатился нештатно или код этой штуки сдан.
Но я все же рекомендую вам зайти в Debug самостоятельно, настолько это интересно.
Тогда давайте поговорим о коммитах в обычных сценариях.
В этом блоке кода мы также говорили о try и о catch.
Это почти наконец.
Я прочитал несколько статей в Интернете, в которых говорилось, что наконец-то настало место для коммита.
Неправильно, бро.
Здесь просто сбросьте соединение с базой данных.
Метод был ясно объяснен вам:
Транзакции Spring основаны на ThreadLocal. В текущей транзакции могут быть какие-то персонализированные настройки уровня изоляции, типа отката, тайм-аута и т.д.
Независимо от того, возвращается ли транзакция нормально или возникает исключение, до тех пор, пока она завершена, все эти персонализированные конфигурации должны быть восстановлены до конфигурации по умолчанию.
Итак, поместите его в блок кода finally для выполнения.
Настоящая фиксация — это строка кода:
Затем снова возникает проблема:
Придя сюда, транзакция будет совершена?
Не будь таким абсолютным, брат, посмотри на код:
org.springframework.transaction.support.AbstractPlatformTransactionManager#commit
Перед фиксацией есть еще два решения.Если транзакция помечена как откатная, ее все равно нужно откатить.
А ты по логам посмотри.
Я еще не зафиксировал эту транзакцию, и блокировка снята?
Затем посмотрите на логику, связанную с фиксацией, мы встретим старых друзей:
HikariCP, пул соединений по умолчанию после SpringBoot 2.0, сильнее того, который был представлен в предыдущей статье.
Что касается представления транзакций, то здесь не так много введения.
Укажи путь:
com.mysql.cj.protocol.a.NativeProtocol#sendQueryString
Поставьте точку останова на входе в этот метод:
Затем вы обнаружите, что через это место будет проходить много SQL.
Итак, чтобы у вас прошла отладка гладко, вам нужно установить точку останова:
Это остановится только тогда, когда оператор SQL будет фиксацией.
Еще одна небольшая отладочная деталь, пожалуйста.
Теперь, когда мы знаем почему, я немного изменю код:
Замените ReentrantLock на синхронизированный.
Как вы думаете, будут ли проблемы с этим кодом?
Студенты, которые говорят, что проблем нет, пожалуйста, подумайте об этом.
Принцип этого места точно такой же, как упомянутый выше, поэтому должны быть некоторые проблемы.
Этот метод блокировки является неправильным.
Так вы помните, интервьюер спросит вас позже@Transactional
Когда вы запомните стандартный ответ, если вы хорошо знакомы со знаниями о блокировках, вы можете ненароком говорить об аномальных сценариях в сочетании с блокировками.
Не говорите, что вы написали, просто скажите, что вы нашли это, просматривая код, и что у вас большой авторитет и слава.
Кроме того, не забудьте расширить, теперь это кластерный сервис, и блокировка может быть распределенной блокировкой.
Но принцип тот же.
Теперь, когда мы поговорили о распределенных блокировках, нам предстоит еще несколько раундов борьбы с интервьюером.
Это вы по собственной инициативе подняли его и вывели интервьюера на ваше основное поле боя.Не так уж и много, чтобы набрать несколько баллов.
Совет для интервью для вас, пожалуйста.
решение
Теперь мы знаем причину проблемы.
Решение действительно готово к выходу.
Используйте блокировки правильно и поместите всю транзакцию в рамки работы блокировки:
Таким образом, можно гарантировать, что фиксация транзакции должна быть до разблокировки.
правильно?
Студенты, которые правы, сегодня я буду здесь первым, пожалуйста, вернитесь и ждите уведомления.
Не ведись в канаву, мой друг.
Как вы думаете, эта сделка вступит в силу?
Подскажите ученикам, которые еще не разобрались, поторопитесь и найдите несколько сценариев, при которых транзакция не проходит.
Здесь я говорю о сценарии, который можно использовать как обычно:
Просто такой способ инъекций я нахожу отвратительным.
Если такой код появляется в проекте, это должно быть связано с тем, что наслоение кода сделано некачественно, а структура проекта крайне хаотична.
Не рекомендуется.
Вы также можете использовать программные транзакции для записи, и вы можете самостоятельно управлять открытием, фиксацией и откатом транзакций.
чем использовать напрямую@Transactional
надежный.
В дополнение к этому, есть немного более хитрое решение.
Остальное место оставьте без изменений, просто измените место @Transactional:
Сериализуйте уровень изоляции и снова запустите тестовый пример, никогда не будет ситуации перепроданности.
Ему даже не нужна логика блокировки.
Как ты себя чувствуешь?
Ну и что?
Производительность сериализации не может идти в ногу!
Это слишком пессимистично, для данных одной строки будет выполняться операция блокировки при чтении и записи. Когда возникает конфликт между блокировками чтения-записи, транзакции, которые приходят позже, ставятся в очередь.
Достаточно знать эту грязную операцию, не используйте ее.
Просто относитесь к этому как к точке знания, которая бесполезна.
Однако, если вы представляете собой сцену, которая не преследует производительность, эта бесполезная точка знаний станет операцией фарса.
rollback-only
Об этом откате-только я упоминал ранее.Для лучшего написания я взял его в одно предложение.На самом деле в нем очень много историй.Я вынесу его в отдельный раздел,чтобы вкратце рассказать о нем,и смоделировать эту сцену для всех .
Вы будете чувствовать себя очень сердечно, когда увидите эту аномалию в будущем.
Уровень распространения транзакций Spring по умолчанию REQUIRED, что означает, что если текущей транзакции нет, создается новая транзакция, а если в контексте уже есть транзакция, транзакция разделяется.
Перейдите непосредственно к коду:
Есть две транзакции, sellProduct и sellProductBiz, а sellProductBiz — это внутренняя транзакция, которая генерирует исключение.
Когда вся логика выполняется, выбрасывается это исключение:
Transaction rolled back because it has been marked as rollback-only
По стеку этого исключения можно найти это место, которое появилось раньше:
Поэтому нам нужно только проанализировать, почему это, если условие выполняется, и тогда мы, вероятно, сможем понять контекст.
if (!shouldCommitOnGlobalRollbackOnly() && defStatus.isGlobalRollbackOnly())
Предыдущее значение shouldCommitOnGlobalRollbackOnly по умолчанию равно false:
Вопрос упрощается: почему значение defStatus.isGlobalRollbackOnly() истинно?
Почему?
Поскольку sellProductBiz выдает исключение, он вызывает метод completeTransactionAfterThrowing для выполнения логики отката.
В этом методе должно что-то происходить.
org.springframework.transaction.support.AbstractPlatformTransactionManager#processRollback
Здесь установите для ссылки rollbackOnly значение true.
Поэтому, когда последующая транзакция захочет зафиксироваться, проверьте этот параметр, да ладно, откатите его.
Вероятно, что-то вроде этого:
Если это не то исключение, которое вы ожидаете, как это исправить?
Это простое сравнение, чтобы понять механизм распространения транзакций:
Таким образом, открывается новый бизнес, и нет проблем вести его, не мешая друг другу.
Последнее слово
Что ж, увидев это, организуйте продолжение Чжоу Гэн очень устал и нуждается в положительном отзыве.
Спасибо за прочтение, настаиваю на оригинальности, очень приветствую и благодарю за внимание.
Я почему, вы также можете называть меня Сяо Вай, программист, который в основном пишет код, часто пишет статьи и изредка снимает видео.