Несогласованность данных при записи в кластерную базу данных Galera MariaDB с go - sql -driver / mysql - PullRequest
2 голосов
/ 26 мая 2020

Описание

Несогласованность данных наблюдается, когда две программы golang одновременно записывают в одни и те же таблицы БД через разные экземпляры MariaDB. Экземпляры MariaDB кластеризованы с использованием Galera.

Тестовая база данных выглядит следующим образом:

CREATE TABLE `Inc` (
  `Id` int(11) NOT NULL,
  `Cnt` int(11) NOT NULL DEFAULT 0,
  PRIMARY KEY (`Id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

CREATE TABLE `Role` (
  `Name` varchar(64) COLLATE utf8_unicode_ci DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

INSERT INTO `Inc` (`Id`, `Cnt`) VALUES ('1', 0);

Программа golang запускает 10 горутин, каждая из которых выполняет следующие операции с БД:

SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;

SELECT `Cnt` FROM `Inc` WHERE `Id`=1 FOR UPDATE;
INSERT INTO `Role` (`Name`) VALUES(<some string unique to this transaction>);
UPDATE `Inc` SET `Cnt`=<previous select result + 1> WHERE `Id`=1 and `Cnt`=<previous select result>;
COMMIT;

Проще говоря, программа golang читает Inc.Cnt, вставляет уникальную строку в Role, а затем увеличивает Inc.Cnt на единицу. Когда программа завершится, Inc.Cnt должно быть идентично количеству строк в Role:

mysql> START TRANSACTION; SELECT `Cnt` FROM `Inc`; SELECT COUNT(*) FROM `Role`; COMMIT;
Query OK, 0 rows affected (0.27 sec)

+------+
| Cnt  |
+------+
|   30 |
+------+
1 row in set (0.27 sec)

+----------+
| count(*) |
+----------+
|       30 |
+----------+
1 row in set (0.27 sec)

Query OK, 0 rows affected (0.27 sec)

Но если две программы одновременно работают с двумя разными экземплярами MariaDB, Inc.Cnt становится меньше, чем число строк в Role. Еще более странно то, что иногда оператор обновления не возвращает измененную строку, как будто текущая транзакция не изолирована от другой транзакции. Более того, даже если я сделаю откат программы, когда возвращается 0 измененная строка, строка, вставленная в ту же транзакцию, все равно остается.

Сначала я подозревал, что это связано с тем, что Galera с несколькими мастерами не поддерживает уровень изоляции SERIALIZABLE. Он поддерживает уровень изоляции транзакции SNAPSHOT , который проверяет конфликт транзакций во время фиксации. При таком подходе optimisti c понятно, что одновременная запись часто приводит к тупикам. Но я все же считаю, что согласованность данных не должна нарушаться даже на уровне изоляции SNAPSHOT.

Это ожидаемое поведение кластера Galera или какая-то ошибка в конфигурации Galera? Или промах в использовании go-sql-driver/mysql или какое-либо известное ограничение / ошибка? Я не видел вышеуказанной аномалии, когда количество горутин равно одному.

Любое предложение будет оценено.

Окружающая среда

  • Golang версия: go1.13.5 linux / amd64
  • MySQl библиотека: go-sql-driver/mysql v1.5.0
  • MariaDB docker изображение: mariadb: 10.4.13
  • Количество горутин в программе: 10 (при значении 1 несогласованность не возникает)

Конфигурация MariaDB

[mysqld]
user = mysql
server_id = 11   # unique to this node in the Galera cluster
port = 13306

collation-server = utf8_unicode_ci
character-set-server = utf8
skip-character-set-client-handshake

skip_name_resolve = ON

default_storage_engine          = InnoDB
binlog_format                   = ROW
log_bin = /var/log/mysql/mysql-bin.log
relay_log = /var/log/mysql/mysql-relay-bin.log
log_slave_updates = on

innodb_flush_log_at_trx_commit  = 0
innodb_flush_method             = O_DIRECT
innodb_file_per_table           = 1
innodb_autoinc_lock_mode        = 2
innodb_lock_schedule_algorithm  = FCFS # MariaDB >10.1.19 and >10.2.3 only
innodb_rollback_on_timeout = 1
innodb_print_all_deadlocks = ON
bind-address                    = 0.0.0.0

wsrep_on                        = ON
wsrep_provider                  = /usr/lib/libgalera_smm.so
wsrep_sst_method                = mariabackup
wsrep_gtid_mode                 = ON
wsrep_gtid_domain_id            = 9999
wsrep_cluster_name              = mycluster
wsrep_cluster_address           = gcomm://172.24.50.27:14567,172.24.52.27:14567,172.24.54.27:14567 # all three MariaDB instances that constitute this Galera cluster
wsrep_sst_auth                  = repl:secret
wsrep_node_address              = 172.24.50.27:14567
wsrep_provider_options          = "ist.recv_addr=172.24.50.27:14568;socket.ssl_cert=/etc/mysql/certificates/maria-server-cert.pem;socket.ssl_key=/etc/mysql/certificates/maria-server-key.pem;socket.ssl_ca=/etc/mysql/certificates/maria-ca.pem"
wsrep_sst_receive_address       = 172.24.50.27:14444
wsrep_log_conflicts = ON

gtid_domain_id                  = 9011 # unique to this node in the Galera cluster

ssl_cert                        = /etc/mysql/certificates/maria-server-cert.pem
ssl_key                         = /etc/mysql/certificates/maria-server-key.pem
ssl_ca                          = /etc/mysql/certificates/maria-ca.pem

# File Key Management
plugin_load_add = file_key_management
file_key_management_filename = /etc/mysql/encryption/keyfile.enc
file_key_management_filekey = FILE:/etc/mysql/encryption/keyfile.key
file_key_management_encryption_algorithm = AES_CTR
# Enables table encryption, but allows unencrypted tables to be created
innodb_encrypt_tables = OFF
# Encrypt the Redo Log
innodb_encrypt_log = ON
# Binary Log Encryption
encrypt_binlog=ON

Golang писатель

package main

import (
    "context"
    "database/sql"
    "fmt"
    "github.com/go-sql-driver/mysql"
    _ "github.com/go-sql-driver/mysql"
    "os"
    "strconv"
    "sync"
)

func task(ctx context.Context, prefix string, num int, c *sql.Conn) error {
    _, err := c.ExecContext(ctx, "SET SESSION wsrep_sync_wait=15")
    if err != nil {
        return err
    }

    tx, err := c.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
    if err != nil {
        return err
    }

    var count int
    incRes, err := tx.Query("SELECT Cnt FROM Inc WHERE Id=? FOR UPDATE", 1)
    if err != nil {
        tx.Rollback()
        return err
    }
    defer incRes.Close()
    for incRes.Next() {
        err := incRes.Scan(&count)
        if err != nil {
            tx.Rollback()
            return err
        }
    }

    res, err := tx.ExecContext(ctx, "INSERT INTO Role (Name) VALUES (?)", fmt.Sprintf("%s-%03d", prefix, num))
    if err != nil {
        tx.Rollback()
        return err
    }
    affected, err := res.RowsAffected()
    if err != nil {
        tx.Rollback()
        return err
    }
    if affected != 1 {
        tx.Rollback()
        return fmt.Errorf("inconsistency: inserted row is %d", affected)
    }
    res, err = tx.ExecContext(ctx, "UPDATE Inc SET Cnt = ? WHERE Id=? AND Cnt=?", count+1, 1, count)
    if err != nil {
        tx.Rollback()
        return err
    }
    affected, err = res.RowsAffected()
    if err != nil {
        tx.Rollback()
        return err
    }
    if affected != 1 {
        tx.Rollback()
        return fmt.Errorf("inconsistency: inserted row is %d", affected)
    }
    err = tx.Commit()
    if err != nil {
        tx.Rollback()
        return err
    }
    return nil
}

func main() {
    if len(os.Args) != 5 {
        fmt.Println("Usage: %s <db host> <db port> <count>")
        return
    }
    host := os.Args[1]
    port, err := strconv.Atoi(os.Args[2])
    if err != nil {
        panic(err)
    }
    count, err := strconv.Atoi(os.Args[3])
    if err != nil {
        panic(err)
    }
    prefix := os.Args[4]
    db := "test"
    user := "root"
    pwd := "secret"
    parallelism := 10

    driver := "mysql"
    connstr := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s", user, pwd, host, port, db)

    dbconn, err := sql.Open(driver, connstr)
    if err != nil {
        panic(err)
    }
    defer dbconn.Close()

    wg := sync.WaitGroup{}
    for thread := 0; thread <parallelism ; thread++ {
        wg.Add(1)
        go func(thread int) {
            ctx := context.Background()
            defer wg.Done()
            for i := 0; i<count; i++ {
                conn, err := dbconn.Conn(ctx)
                if err != nil {
                    fmt.Println(err)
                }
                err = task(ctx, fmt.Sprintf("%s-%d", prefix, thread), i, conn)
                if err != nil {
                    fmt.Printf("error for %s-%d-%d: %s\n",prefix, thread, i, err)
                    _, ok := err.(*mysql.MySQLError)
                    if !ok {
                        break
                    }
                }
                conn.Close()
            }
        }(thread)
    }
    wg.Wait()
}

Запустить программу

$ go build
$ ./native 172.24.54.27 13306 50 first

EDITED Как было предложено, я добавил конфигурацию сеанса SET SESSION wsrep_sync_wait=15 перед начало транзакции. Операторы SQL отправляются должным образом, когда я проверял их с помощью wirehark. Но это не меняет результатов теста.

EDITED 2 После добавления опции interpolateParams=true из go - sql -driver , теперь мы делаем не вижу несоответствия данных. Эта опция необходима, чтобы избежать использования подготовленного оператора, делая каждое состояние полностью определенным. Мы подозреваем, что прерывание запроса подготовленных операторов не обрабатывается должным образом в ситуации с несколькими мастерами Galera. Измененный код выглядит следующим образом:

        driver := "mysql"
        connstr := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?interpolateParams=true", user, pwd, host, port, db)

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

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...