Ръководство за Apache BookKeeper

1. Общ преглед

В тази статия ще представим BookKeeper, услуга, която прилага разпределена, устойчива на грешки система за съхранение на записи .

2. Какво е BookKeeper ?

BookKeeper първоначално е разработен от Yahoo като подпроект ZooKeeper и е завършен, за да се превърне в проект от най-високо ниво през 2015 г. В основата си BookKeeper цели да бъде надеждна и високоефективна система, която съхранява последователности от записи в дневника (известни още като записи ) в структурите на данни наречен Леджърс .

Важна характеристика на дневниците е фактът, че те са само добавени и неизменни . Това прави BookKeeper добър кандидат за определени приложения, като разпределени системи за регистриране, приложения за съобщения Pub-Sub и обработка на потоци в реално време.

3. Концепции на BookKeeper

3.1. Регистрационни записи

Записът в дневника съдържа неделима единица данни, която клиентско приложение съхранява или чете от BookKeeper. Когато се съхранява в книга, всеки запис съдържа предоставените данни и няколко полета с метаданни.

Тези полета с метаданни включват entryId, който трябва да е уникален в дадена книга. Има и код за удостоверяване, който BookKeeper използва, за да открие кога запис е повреден или е бил подправен.

BookKeeper сам по себе си не предлага функции за сериализация, така че клиентите трябва да измислят собствен метод за конвертиране на конструкции от по-високо ниво в / от байтови масиви.

3.2. Книги

Книгата е основната единица за съхранение, управлявана от BookKeeper, съхраняваща подредена последователност от записи в дневника. Както бе споменато по-горе, счетоводните книги имат само добавяне на семантика, което означава, че записите не могат да бъдат модифицирани, след като бъдат добавени към тях.

Също така, след като клиент спре да пише в счетоводна книга и я затвори, BookKeeper я запечатва и вече не можем да добавяме данни към нея, дори по-късно . Това е важен момент, който трябва да имате предвид, когато проектирате приложение около BookKeeper. Книгите не са добър кандидат за директно внедряване на конструкции от по-високо ниво , като опашка. Вместо това виждаме счетоводни книги, използвани по-често за създаване на по-основни структури от данни, които поддържат тези концепции от по-високо ниво.

Например проектът за разпределен дневник на Apache използва дневници като лог сегменти. Тези сегменти се обединяват в разпределени дневници, но основните книги са прозрачни за редовните потребители.

BookKeeper постига устойчивост на главната книга, като репликира записи в дневника в множество екземпляри на сървъра. Три параметъра контролират колко сървъри и копия се съхраняват:

  • Размер на ансамбъла: броят на сървърите, използвани за записване на данни от регистъра
  • Напишете размер на кворума: броят на сървърите, използвани за репликиране на даден запис в дневника
  • Проверка на размера на кворума: броят на сървърите, които трябва да потвърдят дадена операция за запис на регистрационен запис

Чрез коригиране на тези параметри можем да настроим характеристиките на производителността и устойчивостта на дадена книга. Когато пише в книга, BookKeeper ще счита операцията за успешна само когато минимален кворум от членовете на клъстера я признае.

В допълнение към вътрешните си метаданни, BookKeeper поддържа и добавяне на персонализирани метаданни към книга. Това са карта на двойки ключ / стойност, които клиентите предават по време на създаването и BookKeeper съхранява в ZooKeeper заедно със своите.

3.3. Букмейкъри

Букмейкърите са сървъри, които държат една или дневници на режима. Клъстерът BookKeeper се състои от множество букмейкъри, работещи в дадена среда, предоставящи услуги на клиенти чрез обикновени TCP или TLS връзки.

Букмейкърите координират действия, като използват клъстерни услуги, предоставени от ZooKeeper. Това предполага, че ако искаме да постигнем напълно устойчива на грешки система, се нуждаем от поне 3-инстанционна настройка на ZooKeeper и 3-инстанционна настройка на BookKeeper. Такава настройка би могла да толерира загуба, ако някой отделен екземпляр се провали и все пак да може да работи нормално, поне за настройката на основната книга: размер на ансамбъл с 3 възела, кворум за запис на 2 възела и кворум на 2 възела.

4. Локална настройка

Основните изисквания за локално стартиране на BookKeeper са доста скромни. Първо, имаме нужда от екземпляр на ZooKeeper, който осигурява съхранение на метаданни на книга за BookKeeper. След това разполагаме букмейкър, който предоставя действителните услуги на клиентите.

Въпреки че със сигурност е възможно да направите тези стъпки ръчно, тук ще използваме файл за съставяне на докер, който използва официални изображения на Apache, за да опрости тази задача:

$ cd  $ docker-compose up

Тази докер-композиция създава три букмейкъра и екземпляр на ZooKeeper. Тъй като всички букмейкъри работят на една и съща машина, това е полезно само за целите на тестването. Официалната документация съдържа необходимите стъпки за конфигуриране на напълно устойчив на грешки клъстер.

Нека направим основен тест, за да проверим дали работи, както се очаква, като използваме командния списък на командира на bookkeeper :

$ docker exec -it apache-bookkeeper_bookie_1 /opt/bookkeeper/bin/bookkeeper \ shell listbookies -readwrite ReadWrite Bookies : 192.168.99.101(192.168.99.101):4181 192.168.99.101(192.168.99.101):4182 192.168.99.101(192.168.99.101):3181 

Резултатът показва списъка на наличните букмейкъри , състоящ се от три букмейкъра. Моля, обърнете внимание, че показаните IP адреси ще се променят в зависимост от спецификата на локалната инсталация на Docker.

5. Using the Ledger API

The Ledger API is the most basic way to interface with BookKeeper. It allows us to interact directly with Ledger objects but, on the other hand, lacks direct support for higher-level abstractions such as streams. For those use cases, the BookKeeper project offers another library, DistributedLog, which supports those features.

Using the Ledger API requires adding the bookkeeper-server dependency to our project:

 org.apache.bookkeeper bookkeeper-server 4.10.0 

NOTE: As stated in the documentation, using this dependency will also include dependencies for the protobuf and guava libraries. Should our project also need those libraries, but at a different version than those used by BookKeeper, we could use an alternative dependency that shades those libraries:

 org.apache.bookkeeper bookkeeper-server-shaded 4.10.0  

5.1. Connecting to Bookies

The BookKeeper class is the main entry point of the Ledger API, providing a few methods to connect to our BookKeeper service. In its simplest form, all we need to do is create a new instance of this class, passing the address of one of the ZooKeeper servers used by BookKeeper:

BookKeeper client = new BookKeeper("zookeeper-host:2131"); 

Here, zookeeper-host should be set to the IP address or hostname of the ZooKeeper server that holds BookKeeper's cluster configuration. In our case, that's usually “localhost” or the host that the DOCKER_HOST environment variable points to.

If we need more control over the several parameters available to fine-tune our client, we can use a ClientConfiguration instance and use it to create our client:

ClientConfiguration cfg = new ClientConfiguration(); cfg.setMetadataServiceUri("zk+null://zookeeper-host:2131"); // ... set other properties BookKeeper.forConfig(cfg).build();

5.2. Creating a Ledger

Once we have a BookKeeper instance, creating a new ledger is straightforward:

LedgerHandle lh = bk.createLedger(BookKeeper.DigestType.MAC,"password".getBytes());

Here, we've used the simplest variant of this method. It will create a new ledger with default settings, using the MAC digest type to ensure entry integrity.

If we want to add custom metadata to our ledger, we need to use a variant that takes all parameters:

LedgerHandle lh = bk.createLedger( 3, 2, 2, DigestType.MAC, "password".getBytes(), Collections.singletonMap("name", "my-ledger".getBytes()));

This time, we've used the full version of the createLedger() method. The three first arguments are the ensemble size, write quorum, and ack quorum values, respectively. Next, we have the same digest parameters as before. Finally, we pass a Map with our custom metadata.

In both cases above, createLedger is a synchronous operation. BookKeeper also offers asynchronous ledger creation using a callback:

bk.asyncCreateLedger( 3, 2, 2, BookKeeper.DigestType.MAC, "passwd".getBytes(), (rc, lh, ctx) -> { // ... use lh to access ledger operations }, null, Collections.emptyMap()); 

Newer versions of BookKeeper (>= 4.6) also support a fluent-style API and CompletableFuture to achieve the same goal:

CompletableFuture cf = bk.newCreateLedgerOp() .withDigestType(org.apache.bookkeeper.client.api.DigestType.MAC) .withPassword("password".getBytes()) .execute(); 

Note that, in this case, we get a WriteHandle instead of a LedgerHandle. As we'll see later, we can use any of them to access our ledger as LedgerHandle implements WriteHandle.

5.3. Writing Data

Once we've acquired a LedgerHandle or WriteHandle, we write data to the associated ledger using one of the append() method variants. Let's start with the synchronous variant:

for(int i = 0; i < MAX_MESSAGES; i++) { byte[] data = new String("message-" + i).getBytes(); lh.append(data); } 

Here, we're using a variant that takes a byte array. The API also supports Netty's ByteBuf and Java NIO's ByteBuffer, which allow better memory management in time-critical scenarios.

For asynchronous operations, the API differs a bit depending on the specific handle type we've acquired. WriteHandle uses CompletableFuture, whereas LedgerHandle also supports callback-based methods:

// Available in WriteHandle and LedgerHandle CompletableFuture f = lh.appendAsync(data); // Available only in LedgerHandle lh.asyncAddEntry( data, (rc,ledgerHandle,entryId,ctx) -> { // ... callback logic omitted }, null);

Which one to choose is largely a personal choice, but in general, using CompletableFuture-based APIs tends to be easier to read. Also, there's the side benefit that we can construct a Mono directly from it, making it easier to integrate BookKeeper in reactive applications.

5.4. Reading Data

Reading data from a BookKeeper ledger works in a similar way to writing. First, we use our BookKeeper instance to create a LedgerHandle:

LedgerHandle lh = bk.openLedger( ledgerId, BookKeeper.DigestType.MAC, ledgerPassword); 

Except for the ledgerId parameter, which we'll cover later, this code looks much like the createLedger() method we've seen before. There's an important difference, though; this method returns a read-only LedgerHandle instance. If we try to use any of the available append() methods, all we'll get is an exception.

Alternatively, a safer way is to use the fluent-style API:

ReadHandle rh = bk.newOpenLedgerOp() .withLedgerId(ledgerId) .withDigestType(DigestType.MAC) .withPassword("password".getBytes()) .execute() .get(); 

ReadHandle has the required methods to read data from our ledger:

long lastId = lh.readLastConfirmed(); rh.read(0, lastId).forEach((entry) -> { // ... do something });

Here, we've simply requested all available data in this ledger using the synchronous read variant. As expected, there's also an async variant:

rh.readAsync(0, lastId).thenAccept((entries) -> { entries.forEach((entry) -> { // ... process entry }); });

If we choose to use the older openLedger() method, we'll find additional methods that support the callback style for async methods:

lh.asyncReadEntries( 0, lastId, (rc,lh,entries,ctx) -> { while(entries.hasMoreElements()) { LedgerEntry e = ee.nextElement(); } }, null);

5.5. Listing Ledgers

We've seen previously that we need the ledger's id to open and read its data. So, how do we get one? One way is using the LedgerManager interface, which we can access from our BookKeeper instance. This interface basically deals with ledger metadata, but also has the asyncProcessLedgers() method. Using this method – and some help form concurrent primitives – we can enumerate all available ledgers:

public List listAllLedgers(BookKeeper bk) { List ledgers = Collections.synchronizedList(new ArrayList()); CountDownLatch processDone = new CountDownLatch(1); bk.getLedgerManager() .asyncProcessLedgers( (ledgerId, cb) -> { ledgers.add(ledgerId); cb.processResult(BKException.Code.OK, null, null); }, (rc, s, obj) -> { processDone.countDown(); }, null, BKException.Code.OK, BKException.Code.ReadException); try { processDone.await(1, TimeUnit.MINUTES); return ledgers; } catch (InterruptedException ie) { throw new RuntimeException(ie); } } 

Let's digest this code, which is a bit longer than expected for a seemingly trivial task. The asyncProcessLedgers() method requires two callbacks.

The first one collects all ledgers ids in a list. We're using a synchronized list here because this callback can be called from multiple threads. Besides the ledger id, this callback also receives a callback parameter. We must call its processResult() method to acknowledge that we've processed the data and to signal that we're ready to get more data.

Второто обратно извикване се извиква, когато всички книги са изпратени до обратното извикване на процесора или когато има грешка. В нашия случай пропуснахме обработката на грешките. Вместо това, ние просто намаляващи на CountDownLatch , което, от своя страна, ще завърши очакват операцията и да позволи на метода да се върне със списък на всички налични дневници.

6. Заключение

В тази статия разгледахме проекта Apache BookKeeper, разгледахме основните му концепции и използвахме неговия API на ниско ниво за достъп до книгите и извършване на операции за четене / запис.

Както обикновено, целият код е достъпен в GitHub.