W bazach danych SQL poziomy izolacji stanowią hierarchię zapobiegania anomaliom aktualizacji. Następnie ludzie myślą, że im wyższy, tym lepiej, a gdy baza danych zapewnia możliwość serializacji, nie ma potrzeby wykonywania odczytu. Jednak:
- Zatwierdzenie odczytu jest domyślnym ustawieniem w PostgreSQL . Konsekwencją jest to, że większość aplikacji go używa (i używa SELECT ... FOR UPDATE), aby zapobiec pewnym anomaliom
- Możliwość serializacji nie skaluje się z pesymistycznym blokowaniem. Rozproszone bazy danych używają optymistycznego blokowania i musisz zakodować ich logikę ponawiania transakcji
Dzięki tym dwóm rozproszona baza danych SQL, która nie zapewnia izolacji Read Committed, nie może twierdzić, że jest kompatybilna z PostgreSQL, ponieważ uruchamianie aplikacji zbudowanych z domyślnymi ustawieniami PostgreSQL jest niemożliwe.
YugabyteDB zaczął od pomysłu „im wyższy, tym lepszy”, a Read Committed w przejrzysty sposób wykorzystuje „Snapshot Isolation”. Dotyczy to nowych aplikacji. Jednak podczas migrowania aplikacji utworzonych dla Read Committed, gdzie nie chcesz implementować logiki ponawiania prób w przypadku awarii serializowalnych (SQLState 40001) i oczekiwać, że baza danych zrobi to za Ciebie. Możesz przełączyć się na Odczyt zatwierdzony za pomocą **yb_enable_read_committed_isolation**
flaga g.
Uwaga:GFlag w YugabyteDB jest globalnym parametrem konfiguracyjnym bazy danych, udokumentowanym w dokumentacji yb-tserver. Parametry PostgreSQL, które można ustawić za pomocą ysql_pg_conf_csv
GFlag dotyczy tylko interfejsu API YSQL, ale GFlags obejmuje wszystkie warstwy YugabyteDB
W tym poście na blogu zademonstruję prawdziwą wartość poziomu izolacji Odczytu zatwierdzonego:nie ma potrzeby kodowania logiki ponawiania próby ponieważ na tym poziomie YugabyteDB może to zrobić sam.
Uruchom YugabyteDB
Uruchamiam bazę danych pojedynczego węzła YugabyteDB dla tego prostego demo:
Franck@YB:~ $ docker run --rm -d --name yb \
-p7000:7000 -p9000:9000 -p5433:5433 -p9042:9042 \
yugabytedb/yugabyte \
bin/yugabyted start --daemon=false \
--tserver_flags=""
53cac7952500a6e264e6922fe884bc47085bcac75e36a9ddda7b8469651e974c
Ja wyraźnie nie ustawiłem żadnych GFlags, aby pokazać domyślne zachowanie. To jest version 2.13.0.0 build 42
.
Sprawdzam przeczytane popełnione powiązane gflagi
Franck@YB:~ $ curl -s http://localhost:9000/varz?raw | grep -E "\
(yb_enable_read_committed_isolation\
|ysql_output_buffer_size\
|ysql_sleep_before_retry_on_txn_conflict\
|ysql_max_write_restart_attempts\
|ysql_default_transaction_isolation\
)"
--yb_enable_read_committed_isolation=false
--ysql_max_write_restart_attempts=20
--ysql_output_buffer_size=262144
--ysql_sleep_before_retry_on_txn_conflict=true
--ysql_default_transaction_isolation=
Read Committed to domyślny poziom izolacji, zgodnie z kompatybilnością PostgreSQL:
Franck@YB:~ $ psql -p 5433 \
-c "show default_transaction_isolation"
default_transaction_isolation
-------------------------------
read committed
(1 row)
Tworzę prostą tabelę:
Franck@YB:~ $ psql -p 5433 -ec "
create table demo (id int primary key, val int);
insert into demo select generate_series(1,100000),0;
"
create table demo (id int primary key, val int);
insert into demo select generate_series(1,100000),0;
INSERT 0 100000
Uruchomię następującą aktualizację, ustawiając domyślny poziom izolacji na Odczyt zatwierdzony (na wszelki wypadek - ale jest to ustawienie domyślne):
Franck@YB:~ $ cat > update1.sql <<'SQL'
\timing on
\set VERBOSITY verbose
set default_transaction_isolation to "read committed";
update demo set val=val+1 where id=1;
\watch 0.1
SQL
To zaktualizuje jeden wiersz.
Wykonam to z wielu sesji w tym samym wierszu:
Franck@YB:~ $ timeout 60 psql -p 5433 -ef update1.sql >session1.txt &
Franck@YB:~ $ timeout 60 psql -p 5433 -ef update1.sql >session2.txt &
[1] 760
[2] 761
psql:update1.sql:5: ERROR: 40001: Operation expired: Transaction a83718c8-c8cb-4e64-ab54-3afe4f2073bc expired or aborted by a conflict: 40001
LOCATION: HandleYBStatusAtErrorLevel, pg_yb_utils.c:405
[1]- Done timeout 60 psql -p 5433 -ef update1.sql > session1.txt
Franck@YB:~ $ wait
[2]+ Exit 124 timeout 60 psql -p 5433 -ef update1.sql > session1.txt
Podczas sesji napotkano Transaction ... expired or aborted by a conflict
. Jeśli uruchomisz to samo kilka razy, możesz również otrzymać Operation expired: Transaction aborted: kAborted
, All transparent retries exhausted. Query error: Restart read required
lub All transparent retries exhausted. Operation failed. Try again: Value write after transaction start
. Wszystkie są to BŁĘDY 40001, które są błędami serializacji, które oczekują, że aplikacja spróbuje ponownie.
W Serializable należy ponowić próbę całej transakcji, a na ogół nie jest to możliwe w sposób przejrzysty dla bazy danych, która nie wie, co jeszcze aplikacja zrobiła podczas transakcji. Na przykład niektóre wiersze mogły zostać już odczytane i wysłane do ekranu użytkownika lub do pliku. Baza danych nie może tego cofnąć. Aplikacje muszą to obsłużyć.
Ustawiłem \Timing on
aby uzyskać czas, który upłynął, a ponieważ używam tego na moim laptopie, nie ma znaczącego czasu w sieci klient-serwer:
Franck@YB:~ $ awk '/Time/{print 5*int($2/5)}' session?.txt | sort -n | uniq -c
121 0
44 5
45 10
12 15
1 20
1 25
2 30
1 35
3 105
2 110
3 115
1 120
Większość aktualizacji trwała mniej niż 5 milisekund. Pamiętaj jednak, że program się nie powiódł 40001
szybko, więc jest to normalne obciążenie jednej sesji na moim laptopie.
Domyślnie yb_enable_read_committed_isolation
jest fałszywe i w tym przypadku poziom izolacji Read Committed warstwy transakcyjnej YugabyteDB powraca do bardziej rygorystycznego Snapshot Isolation (w takim przypadku READ COMMITTED i READ UNCOMMITTED z YSQL używają Snapshot Isolation).
yb_enable_read_committed_isolation=prawda
Teraz zmień to ustawienie, co powinieneś zrobić, jeśli chcesz być kompatybilny z aplikacją PostgreSQL, która nie implementuje żadnej logiki ponawiania.
Franck@YB:~ $ docker rm -f yb
yb
[1]+ Exit 124 timeout 60 psql -p 5433 -ef update1.sql > session1.txt
Franck@YB:~ $ docker run --rm -d --name yb \
-p7000:7000 -p9000:9000 -p5433:5433 -p9042:9042 \
yugabytedb/yugabyte \
bin/yugabyted start --daemon=false \
--tserver_flags="yb_enable_read_committed_isolation=true"
fe3e84c995c440d1a341b2ab087510d25ba31a0526859f08a931df40bea43747
Franck@YB:~ $ curl -s http://localhost:9000/varz?raw | grep -E "\
(yb_enable_read_committed_isolation\
|ysql_output_buffer_size\
|ysql_sleep_before_retry_on_txn_conflict\
|ysql_max_write_restart_attempts\
|ysql_default_transaction_isolation\
)"
--yb_enable_read_committed_isolation=true
--ysql_max_write_restart_attempts=20
--ysql_output_buffer_size=262144
--ysql_sleep_before_retry_on_txn_conflict=true
--ysql_default_transaction_isolation=
Działa tak samo jak powyżej:
Franck@YB:~ $ psql -p 5433 -ec "
create table demo (id int primary key, val int);
insert into demo select generate_series(1,100000),0;
"
create table demo (id int primary key, val int);
insert into demo select generate_series(1,100000),0;
INSERT 0 100000
Franck@YB:~ $ timeout 60 psql -p 5433 -ef update1.sql >session1.txt &
Franck@YB:~ $ timeout 60 psql -p 5433 -ef update1.sql >session2.txt &
[1] 1032
[2] 1034
Franck@YB:~ $ wait
[1]- Exit 124 timeout 60 psql -p 5433 -ef update1.sql > session1.txt
[2]+ Exit 124 timeout 60 psql -p 5433 -ef update1.sql > session2.txt
Nie mam żadnego błędu, a obie sesje aktualizują ten sam wiersz w ciągu 60 sekund.
Oczywiście nie było to dokładnie w tym samym czasie, kiedy baza musiała ponawiać wiele transakcji, co widać po upływie czasu:
Franck@YB:~ $ awk '/Time/{print 5*int($2/5)}' session?.txt | sort -n | uniq -c
325 0
199 5
208 10
39 15
11 20
3 25
1 50
34 105
40 110
37 115
13 120
5 125
3 130
Podczas gdy większość transakcji jest nadal krótsza niż 10 milisekund, niektóre do 120 milisekund z powodu ponownych prób.
ponów próbę wycofywania
Zwykła ponowna próba czeka wykładniczo przez czas między kolejnymi próbami, aż do maksimum. To jest to, co jest zaimplementowane w YugabyteDB, a kontrolują to 3 następujące parametry, które można ustawić na poziomie sesji:
Franck@YB:~ $ psql -p 5433 -xec "
select name, setting, unit, category, short_desc
from pg_settings
where name like '%retry%backoff%';
"
select name, setting, unit, category, short_desc
from pg_settings
where name like '%retry%backoff%';
-[ RECORD 1 ]---------------------------------------------------------
name | retry_backoff_multiplier
setting | 2
unit |
category | Client Connection Defaults / Statement Behavior
short_desc | Sets the multiplier used to calculate the retry backoff.
-[ RECORD 2 ]---------------------------------------------------------
name | retry_max_backoff
setting | 1000
unit | ms
category | Client Connection Defaults / Statement Behavior
short_desc | Sets the maximum backoff in milliseconds between retries.
-[ RECORD 3 ]---------------------------------------------------------
name | retry_min_backoff
setting | 100
unit | ms
category | Client Connection Defaults / Statement Behavior
short_desc | Sets the minimum backoff in milliseconds between retries.
Z moją lokalną bazą danych transakcje są krótkie i nie muszę czekać tak długo. Podczas dodawania set retry_min_backoff to 10;
do mojego pliku update1.sql
czas, który upłynął, nie jest zbytnio zawyżony przez tę logikę ponawiania próby:
Franck@YB:~ $ awk '/Time/{print 5*int($2/5)}' session?.txt | sort -n | uniq -c
338 0
308 5
302 10
58 15
12 20
9 25
3 30
1 45
1 50
yb_debug_log_internal_restarts
Restarty są przezroczyste. Jeśli chcesz zobaczyć powód ponownego uruchomienia lub powód, dla którego nie jest to możliwe, możesz zarejestrować to za pomocą yb_debug_log_internal_restarts=true
# log internal restarts
export PGOPTIONS='-c yb_debug_log_internal_restarts=true'
# run concurrent sessions
timeout 60 psql -p 5433 -ef update1.sql >session1.txt &
timeout 60 psql -p 5433 -ef update1.sql >session2.txt &
# tail the current logfile
docker exec -i yb bash <<<'tail -F $(bin/ysqlsh -twAXc "select pg_current_logfile()")'
Wersje
Zostało to zaimplementowane w YugabyteDB 2.13 i używam tutaj 2.13.1. Nie jest jeszcze zaimplementowany podczas uruchamiania transakcji z poleceń DO lub ANALIZA, ale działa w przypadku procedur. Możesz śledzić i komentować numer #12254, jeśli chcesz go w DO lub ANALIZA.
https://github.com/yugabyte/yugabyte-db/issues/12254
Podsumowując
Zaimplementowanie logiki ponawiania w aplikacji nie jest fatalizmem, ale wyborem w YugabyteDB. Rozproszona baza danych może zgłaszać błędy restartu z powodu przesunięcia zegara, ale nadal musi być przezroczysta dla aplikacji SQL, jeśli to możliwe.
Jeśli chcesz zapobiec wszystkim anomaliom transakcji (zobacz ten przykład jako przykład), możesz uruchomić w Serializable i obsłużyć wyjątek 40001. Nie daj się zwieść idei, że wymaga więcej kodu, ponieważ bez niego musisz przetestować wszystkie warunki wyścigu, co może być większym wysiłkiem. W Serializable baza danych zapewnia takie samo zachowanie jak uruchamianie szeregowe, dzięki czemu testy jednostkowe są wystarczające do zagwarantowania poprawności danych.
Jednak w przypadku istniejącej aplikacji PostgreSQL, używającej domyślnego poziomu izolacji, zachowanie jest sprawdzane na podstawie lat działania w środowisku produkcyjnym. To, czego chcesz, to nie unikanie możliwych anomalii, ponieważ aplikacja prawdopodobnie je obejdzie. Chcesz skalować w poziomie bez zmiany kodu. W tym miejscu YugabyteDB zapewnia poziom izolacji Read Committed, który nie wymaga dodatkowego kodu obsługi błędów.