Восстанавливаем данные из CockroachDB

    Восстановить данные из cockroachdb легко — просто накатите всё из бекапа. Как это не делали бэкапы? Для базы, у которой версия 1.0 вышла всего полгода назад? Что ж, не отчаивайтесь, скорее всего данные можно восстановить. Я буду рассказывать про то, как я восстанавливал базу данных для своего проекта потешной социальной сети вбамбуке и стримил сей процесс на ютьюбе.

    Как будем восстанавливать


    Для начала нужно разобраться с тем, что произошло, почему упал CockroachDB? Причины бывают разные, но в любом случае сервер больше не стартует или не отвечает на запросы. В моём случае, после недолгого гугления, оказалась побита rocksdb база:

    E171219 15:50:36.541517 25 util/log/crash_reporting.go:82  a panic has occurred!
    E171219 15:50:36.734485 74 util/log/crash_reporting.go:82  a panic has occurred!
    E171219 15:50:37.241298 25 util/log/crash_reporting.go:174  Reported as error 20a3dd770da3404fa573411e2b2ffe09
    panic: Corruption: block checksum mismatch [recovered]
    	panic: Corruption: block checksum mismatch
    
    goroutine 25 [running]:
    github.com/cockroachdb/cockroach/pkg/util/stop.(*Stopper).Recover(0xc4206c8500, 0x7fb299f4b180, 0xc4209de120)
    	/go/src/github.com/cockroachdb/cockroach/pkg/util/stop/stopper.go:200 +0xb1
    panic(0x1957a00, 0xc4240398a0)
    	/usr/local/go/src/runtime/panic.go:489 +0x2cf
    github.com/cockroachdb/cockroach/pkg/storage.(*Store).processReady(0xc420223000, 0x103)
    	/go/src/github.com/cockroachdb/cockroach/pkg/storage/store.go:3411 +0x427
    

    Восстанавливаем RocksDB хранилище


    Если у вас побилась rocksdb база, то для её восстановления в cockroach версии 1.1 уже встроена нужная команда:

    $ cockroach debug rocksdb repair
    

    Вам может также потребоваться указать путь до директории с данными, если путь кастомный.

    После восстановления можете попробовать запустить cockroachdb заново. В моём случае это не помогло, но ошибка стала другая:

    E171219 13:12:47.618517 1 cli/error.go:68  cockroach server exited with error: cannot verify empty engine for bootstrap: unable to read store ident: store has not been bootstrapped
    Error: cockroach server exited with error: cannot verify empty engine for bootstrap: unable to read store ident: store has not been bootstrapped
    

    Очевидно, что-то побилось где-то в настройках, и я не разбираюсь в формате хранения cockroachdb достаточно хорошо, чтобы понять, что все-таки ему не хватает. Поэтому пойдем другим путем: мы знаем, что внутри это Key-Value хранилище и даже примерно знаем, что нам нужно искать, поскольку разработчики рассказывали (тут и тут) об этом в своем блоге.

    «Выдираем» данные прямо из RocksDB


    Поскольку формат ключей и записей нам примерно известен, можно взять директорию с данными от «сломанного» инстанса и попробовать пройтись по всем ключам и вытащить данные напрямую оттуда. Я буду рассказывать про вариант с одним хостом, но если хостов много и умер весь кластер, то вам нужно будет ещё научиться удалять дубли и определять самые свежие версии ключей.

    Писать всё будем на go, конечно же. Сначала я решил попробовать взять библиотеку github.com/tecbot/gorocksdb, и она даже завелась, но выдавала ошибку, что ей неизвестен компаратор cockroach_comparator. Я взял нужный компаратор из исходников самого cockroach, но ничего не поменялось.

    Поскольку мне было лень разбираться, в чём дело, я решил пойти другим путем и просто взял и заюзал сразу готовый пакет прямо из исходников самого cockroachdb: в пакете github.com/cockroachdb/cockroach/pkg/storage/engine есть всё, что нужно для того, чтобы правильно работать с KV-базой.

    Поэтому мы откроем базу и начнем итерироваться и попробуем поискать имена ключей, в значении которых есть какие-то строчки, которые мы точно знаем, что есть в базе:

    package main
    
    import "github.com/cockroachdb/cockroach/pkg/storage/engine"
    
    func main() {
    	db, err := engine.NewRocksDB(engine.RocksDBConfig{
    		Dir:       "/Users/yuriy/tmp/vbambuke",
    		MustExist: true,
    	}, engine.NewRocksDBCache(1000000))
    	if err != nil {
    		log.Fatalf("Could not open cockroach rocksdb: %v", err.Error())
    	}
    
    	db.Iterate(
    		engine.MVCCKey{Timestamp: hlc.MinTimestamp},
    		engine.MVCCKeyMax,
    		func(kv engine.MVCCKeyValue) (bool, error) {
    			if bytes.Contains([]byte(kv.Value), []byte("safari@apple.com")) {
    				log.Printf("Email key: %s", kv.Key)
    			}
    			return false, nil
    		},
    	)
    }
    

    Мне вывелось примерно такое:

    Email key: /Table/54/1/158473728194052097/0/1503250869.243064075,0

    У этого ключа довольно много компонентов, но вот, что мне удалось выяснить:

    0. Table означает «таблица» :)
    1. Номер таблицы (таблицы должны идти в порядке создания)
    2. Тип ключа. 1 означает обычную запись, 2 означает индекс
    3. Значение первичного ключа (1,2,3, ...)
    4. не знаю, видимо версия?
    5. timestamp

    То есть, можно вывести все ключи и их значения для всех таблиц в отдельный файл и разбить их по номеру таблицы. После этого должно стать более-менее понятно, как таблицы называются (и какая у них структура, ведь она-то у вас сохранилась :)?).

    Разбираем формат записей


    Я восстанавливал данные из cockroachdb версии 1.0.4, поэтому для более поздних версий детали могут отличаться. Но вот, что мне удалось понять:

    1. Первые 6 байт в значении можно игнорировать. По всей видимости, это контрольная сумма данных и ещё какая-то мета-информация, например биты про nullable поля
    2. Дальше идут сами данные, и перед каждой колонкой, кроме первой, идет отдельный байт с её типом

    Пример из таблицы messages (я использовал od для того, чтобы получить читаемый вид бинарных данных):

    Структура таблицы messages была такая:

    CREATE TABLE messages (
      id SERIAL PRIMARY KEY,
      user_id BIGINT,
      user_id_to BIGINT,
      is_out BOOL,
      message TEXT,
      ts BIGINT
    );
    

    $ head -n 2 messages | od -c
    0000000    1   /   1   /   0   /   1   5   0   3   2   5   0   8   6   8
    0000020    .   7   2   7   5   5   4   8   2   8   ,   0                
    0000040    =                     241   E 270 276  \n   # 202 200 230 316
    0000060  316   ˁ  ** 263 004 023 202 200 204 231 374 235 222 264 004 032
    0000100  026 031   N   o   w       I       u   s   e       r   e   a   l
    0000120        P   o   s   t   g   r   e   S   Q   L 023 230 277 256 217
    0000140  240 320 375 342   (  \n                                        
    0000146
    

    Давайте разберем эти данные по порядку:

    1. сначала в файле я записал имя ключа — во фрагменте
    0000000    1   /   1   /   0   /   1   5   0   3   2   5   0   8   6   8
    0000020    .   7   2   7   5   5   4   8   2   8   ,   0                
    0000040    =                     

    это всё кусок ключа, из которого нам нужно взять значение первичного ключа (формат ключей описан выше)

    2. Заголовок. На строке 0000040 после ключа находится 6-байтовый заголовок:
    241   E 270 276  \n   #
    он всегда разный, но для всех моих таблиц первые 6 байт нужно было просто пропустить.

    3. Первое поле, user_id. Числа, которые мне встречались в cockroachdb, всегда были закодированы varint из стандартной библиотеки. Первую колонку можно прочитать с помощью binary.Varint. Мы должны будем прочитать следующий кусок:
    0000040    =                     241   E 270 276  \n   #        отсюда   --->    202 200 230 316
    0000060  316   ˁ  ** 263 004  <----     досюда      023 202 200 204 231 374 235 222 264 004 032
    

    4. Второе поле, user_id_to. Оказалось, что в начале поля стоит его тип и 023 означает число и точно также читается, как varint. Можно написать соответствующие функции для чтения таких колонок из байтового массива:
    func readVarIntFirst(v []byte) ([]byte, int64) {
    	res, ln := binary.Varint(v)
    	if ln <= 0 {
    		panic("could not read varint")
    	}
    	return v[ln:], res
    }
    
    func readVarInt(v []byte) ([]byte, int64) {
    	if v[0] != '\023' {
    		panic("invalid varint prefix")
    	}
    	return readVarIntFirst(v[1:])
    }
    

    5. Дальше идет булево поле. Пришлось немного повозиться, но я смог выяснить, что можно использовать готовую функцию из пакета github.com/cockroachdb/cockroach/pkg/util/encoding под названием encoding.DecodeBoolValue Эта функция работает примерно также, как и объявленные выше, только возвращает ошибку вместо паники. Мы используем panic для удобства — нам в одноразовой утилите ошибки шибко по-умному обрабатывать не надо.
    6. Дальше идет текст сообщения. Перед текстовыми полями идет байт 026, потом длина и потом содержимое. Выглядит это примерно так:

    0000100  026 031   N   o   w       I       u   s   e       r   e   a   l
    0000120        P   o   s   t   g   r   e   S   Q   L 023 230 277 256 217
    0000140  240 320 375 342   (  \n                                        
    

    Можно было бы подумать, что первый байт это длина, и дальше идет сам текст. Если значения небольшие (условно до 100 байт), то это даже работает. Но на самом деле длина закодирована ещё одним способом, и длину можно тоже прочесть с помощью функций из пакета encoding:

    func readStringFirst(v []byte) ([]byte, string) {
    	v, _, ln, err := encoding.DecodeNonsortingUvarint(v)
    	if err != nil {
    		panic("could not decode string length")
    	}
    
    	return v[ln:], string(v[0:ln])
    }
    
    func readString(v []byte) ([]byte, string) {
    	if v[0] != '\026' {
    		panic("invalid string prefix")
    	}
    	return readStringFirst(v[1:])
    }
    

    7. Ну и заключительное обычное число, читаем с помощью нашей функции readVarInt.

    Чтение колонки типа DATE


    С колонкой типа DATE я помучался, потому что в пакете encoding сходу не нашлось нужной функции :). Пришлось импровизировать. Не буду вас долго мучать, формат DATE представляет из себя обычное число (тип колонки 023 намекает), и в нём записано… Количество секунд в формате UNIX TIME, поделенное на 86400 (число секунд в сутках). То есть, чтобы прочитать дату, нужно умножить прочитанное число на 86400 и трактовать это как unix time:

    v, birthdate := readVarInt(v)
    ts := time.Unix(birthdate*86400, 0)
    formatted := fmt.Sprintf("%04d-%02d-%02d", ts.Year(), ts.Month(), ts.Day())
    

    Вставка обратно в базу


    Чтобы вставить данные обратно в базу, я лично написал простенькую функцию для экранирования строк:

    func escape(q string) string {
    	var b bytes.Buffer
    	for _, c := range q {
    		b.WriteRune(c)
    
    		if c == '\'' {
    			b.WriteRune(c)
    		}
    	}
    
    	return b.String()
    }
    

    И использовал её для составления SQL-запросов вручную:

    fmt.Printf(
    	"INSERT INTO messages2(id, user_id, user_id_to, is_out, message, ts) VALUES(%s, %d, %d, %v, '%s', %d);\n",
    	pk, userID, userIDTo, isOut, escape(message), ts,
    )
    

    Но вы можете составить CSV, использовать свою модель для базы, использовать подготовленные выражения, и т.д. — как вам угодно. Это не составляет труда после того, как вы распарсили бинарный формат хранения данных в CockroachDB :).

    Ссылки, выводы


    Спасибо за то, что доскроллили до конца :). Лучше делайте бэкапы, и не поступайте, как я. Но если вдруг вам очень нужно будет вытащить данные из CockroachDB, то эта статья должна будет вам немного помочь. Не теряйте данные!

    CockroachDB
    Моя потешная соцсеть
    Исходники моей утилиты для восстановления данных
    Процесс на youtube (2 из 3 видео)
    Поделиться публикацией
    Комментарии 10
      0

      Не читал, но одобряю.
      Но, раз уж вы ее используете, было бы интересно узнать почему. Если это только не JFF.

        +1
        Хотелось посмотреть, как хорошо работает cockroachdb на своем хобби-проекте. Пока что у меня опыт такой, что он достаточно часто (раз в несколько месяцев) «падает» и не поднимается обратно. Но некоторые испотьзуют его на продакшене, так что, видимо, это я такой невезучий.
          0

          Не умалая полезности статьи, было бы интересно узнать не только о том что падает, но общее впечатление:
          Как выглядит ваш кластер?
          Как перформанс?
          И что совместимостью с драйвером PostgreSQL?


          Дело в том что этот проект вроде как OpenSource эквивалент гугловскому Spanner. Ну по по "духу" покрайнейм мере, что делает его крайне интересным!
          Но как-то отпугивает теперь такая вот сыроватость.

            0
            1. «Кластер» представляет из себя один узел. Разработчики заверяют, что они поддерживают оба варианта — работу на одном хосте и работу в кластере. Но я честные кластера тоже пробовал делать и они тоже падали чернз какое-то время, правда на более ранних версиях, чем 1.0
            2. Производительность на запись очень низкая. Причем это касается и latency и пропускной способности. Каждый INSERT-запрос также принудительно флашится на диск (в raft log), поэтому несколько строк лучше одним запросом вставлять — так пропускная способность возрастает в сотни раз. На моей инсталляции с жесткими дисками средний INSERT занимает около 60 мс, но это время примерно одинаково для запроса на вставку одной записи и для вставки 100 записей.
            3. На чтение — все зависит от запроса. Если это простенький select по индексу, то производительность близка к постгресу и мускулу. Если запрос сложный, то тут все сильно зависит. Обычно получается намного медленней из-за того, что оптимизатор все же заточен под исполнение на кластере и многие подходы к выполнению запроса, которые использует постгрес или мускул, он не может себе позволить.
            4. Проблем с совместимостью с драйвером постгреса не замечал. Для мака даже есть GUI-клиент Postico.app, который уже давно поддерживает как постгрес, так и cockroachdb.

            Ну и любая база версии 1.0 вряд ли будет достаточно стабильной и производительной, особенно при таких амбициозных целях. Радует то, что разработчики на проблемы не забивают и стараются во всем разобраться и починить. Я думаю, что через пару мажорных версий таракана уже можно будет не бояться использовать в продакшене (бэкапы надо все равно делать при этом, конечно же).
              0

              спасибо!

        0
        Вот как-то не могу записать эту статью в плюс CockroachDB.
          +1
          Плюс тут только один — поскольку «под капотом» rocksdb, то можно напрямую его почитать и выдрать данные оттуда.
            0

            Думаю, это пока проект молодой и можно охватить формат хранения своим умом. А так, занятная статья. Да.

          0
          Мне кажется странным строить кластер из одного узла и потом долго восстанавливать данные. Изначально есть рекомендация — кластер должен содержать не менее чем из 3 нод (Use at least three nodes to ensure that a majority of replicas (2/3) remains available if a node fails)
            0

            Да. Правильнее иметь кластер, делать бэкапы, проверять их развертываемость и не использовать базу данных, которая полгода назад вышла из беты. Но где же фан в этом :)? Для хобби-проекта это все не обязательно.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое