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


Восстановить данные из 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 видео)




К сожалению, не доступен сервер mySQL