Replikacija na visoki ravni v DBMS Tarantool

Pozdravljeni, ustvarjam aplikacije za DBMS Tarantool je platforma, ki jo je razvila skupina Mail.ru in združuje visoko zmogljiv DBMS in aplikacijski strežnik v jeziku Lua. Visoka hitrost rešitev, ki temeljijo na Tarantoolu, je dosežena predvsem zaradi podpore za način DBMS v pomnilniku in zmožnosti izvajanja poslovne logike aplikacije v enem samem naslovnem prostoru s podatki. Hkrati je obstojnost podatkov zagotovljena s transakcijami ACID (na disku se vzdržuje dnevnik WAL). Tarantool ima vgrajeno podporo za replikacijo in razrezovanje. Od različice 2.1 naprej so podprte poizvedbe v jeziku SQL. Tarantool je odprtokoden in licenciran pod licenco Simplified BSD. Obstaja tudi komercialna različica Enterprise.

Replikacija na visoki ravni v DBMS Tarantool
Občutite moč! (…ali uživajte v predstavi)

Zaradi vsega navedenega je Tarantool privlačna platforma za ustvarjanje visoko obremenjenih aplikacij, ki delujejo z bazami podatkov. V takih aplikacijah je pogosto potrebna replikacija podatkov.

Kot že omenjeno, ima Tarantool vgrajeno podvajanje podatkov. Načelo njegovega delovanja je zaporedno izvajanje vseh transakcij na replikah, ki jih vsebuje glavni dnevnik (WAL). Običajno taka replikacija (nadalje jo bomo imenovali nizka stopnja) se uporablja za zagotavljanje odpornosti na napake aplikacije in/ali za porazdelitev bralne obremenitve med vozlišči gruče.

Replikacija na visoki ravni v DBMS Tarantool
riž. 1. Replikacija znotraj gruče

Primer alternativnega scenarija bi bil prenos podatkov, ustvarjenih v eni bazi podatkov, v drugo bazo podatkov za obdelavo/nadzor. V slednjem primeru je morda bolj priročna rešitev uporaba visoka stopnja replikacija - replikacija podatkov na nivoju poslovne logike aplikacije. Tisti. Ne uporabljamo že pripravljene rešitve, vgrajene v DBMS, ampak izvajamo replikacijo sami v okviru aplikacije, ki jo razvijamo. Ta pristop ima tako prednosti kot slabosti. Naštejmo prednosti.

1. Prihranek prometa:

  • Ne morete prenesti vseh podatkov, ampak samo del (lahko na primer prenesete samo nekatere tabele, nekatere njihove stolpce ali zapise, ki ustrezajo določenemu kriteriju);
  • Za razliko od nizkonivojskega podvajanja, ki se izvaja neprekinjeno v asinhronem (implementirano v trenutni različici Tarantoola - 1.10) ali sinhronem (implementirano v naslednjih različicah Tarantoola) načinu, se lahko visokonivojsko podvajanje izvaja v sejah (tj. aplikacija najprej sinhronizira podatke - podatke seje izmenjave, nato pride do premora v podvajanju, po katerem pride do naslednje seje izmenjave itd.);
  • če je bil zapis večkrat spremenjen, lahko prenesete samo njegovo najnovejšo različico (za razliko od replikacije na nizki ravni, pri kateri bodo vse spremembe, narejene na glavnem zapisu, zaporedno predvajane na replikah).

2. Ni težav pri izvajanju izmenjave HTTP, ki vam omogoča sinhronizacijo oddaljenih baz podatkov.

Replikacija na visoki ravni v DBMS Tarantool
riž. 2. Replikacija prek HTTP

3. Ni nujno, da so strukture baze podatkov, med katerimi se prenašajo podatki, enake (še več, v splošnem primeru je možna celo uporaba različnih DBMS, programskih jezikov, platform itd.).

Replikacija na visoki ravni v DBMS Tarantool
riž. 3. Replikacija v heterogenih sistemih

Slaba stran je, da je programiranje v povprečju težje/dražje od konfiguracije in namesto da prilagodite vgrajeno funkcionalnost, boste morali implementirati svojo.

Če so v vaši situaciji zgornje prednosti ključne (ali nujen pogoj), potem je smiselno uporabiti replikacijo na visoki ravni. Oglejmo si več načinov implementacije replikacije podatkov na visoki ravni v DBMS Tarantool.

Minimizacija prometa

Torej je ena od prednosti replikacije na visoki ravni prihranek prometa. Da bi bila ta prednost v celoti uresničena, je treba zmanjšati količino prenesenih podatkov med vsako sejo izmenjave. Seveda pa ne smemo pozabiti, da mora biti ob koncu seje sprejemnik podatkov sinhroniziran z izvorom (vsaj za tisti del podatkov, ki je vključen v replikacijo).

Kako zmanjšati količino prenesenih podatkov med replikacijo na visoki ravni? Preprosta rešitev bi lahko bila izbira podatkov po datumu in času. Če želite to narediti, lahko uporabite polje datum-čas, ki že obstaja v tabeli (če obstaja). Na primer, dokument »naročilo« ima lahko polje »zahtevan čas izvedbe naročila« - delivery_time. Težava pri tej rešitvi je, da ni nujno, da so vrednosti v tem polju v zaporedju, ki ustreza ustvarjanju naročil. Torej se ne moremo spomniti največje vrednosti polja delivery_time, posredovanih med prejšnjo sejo izmenjave, med naslednjo sejo izmenjave pa izberite vse zapise z višjo vrednostjo polja delivery_time. Med sejami izmenjave so bili morda dodani zapisi z nižjo vrednostjo polja delivery_time. Tudi vrstni red je lahko doživel spremembe, ki pa niso vplivale na področje delivery_time. V obeh primerih spremembe ne bodo prenesene iz vira na cilj. Za rešitev teh težav bomo morali podatke prenašati "prekrivajoče". Tisti. v vsaki seji izmenjave bomo prenesli vse podatke z vrednostjo polja delivery_time, ki presega neko točko v preteklosti (na primer N ur od trenutnega trenutka). Vendar pa je očitno, da je za velike sisteme ta pristop zelo redundančen in lahko zmanjša prihranek prometa, h kateremu težimo, na nič. Poleg tega tabela, ki se prenaša, morda nima polja, povezanega z datumom in uro.

Druga rešitev, bolj zapletena v smislu izvedbe, je potrditev prejema podatkov. V tem primeru se med vsako sejo izmenjave prenašajo vsi podatki, katerih prejem ni bil potrjen s strani prejemnika. Če želite to izvesti, boste morali v izvorno tabelo dodati logični stolpec (na primer is_transferred). Če prejemnik potrdi prejem zapisa, ustrezno polje prevzame vrednost true, po katerem vnos ni več vključen v menjave. Ta možnost izvedbe ima naslednje pomanjkljivosti. Najprej je treba za vsak preneseni zapis ustvariti in poslati potrditev. Grobo rečeno, bi to lahko primerjali s podvojitvijo količine prenesenih podatkov in vodijo do podvojitve števila povratnih potovanj. Drugič, ni možnosti pošiljanja istega zapisa več prejemnikom (prejemnik, ki prvi prejme, potrdi prejem zase in za vse ostale).

Metoda, ki nima zgoraj navedenih pomanjkljivosti, je dodajanje stolpca v poslano tabelo za sledenje spremembam v njenih vrsticah. Takšen stolpec je lahko datumsko-časovnega tipa in ga mora aplikacija nastaviti/posodobiti na trenutni čas vsakič, ko so zapisi dodani/spremenjeni (atomsko z dodatkom/spremembo). Kot primer pokličimo stolpec update_time. Če shranimo največjo vrednost polja tega stolpca za prenesene zapise, lahko začnemo naslednjo sejo izmenjave s to vrednostjo (izberimo zapise z vrednostjo polja update_time, ki presega predhodno shranjeno vrednost). Težava pri slednjem pristopu je, da lahko pride do sprememb podatkov v serijah. Kot rezultat vrednosti polja v stolpcu update_time morda ni edinstven. Zato tega stolpca ni mogoče uporabiti za izpis podatkov po delih (stran za stranjo). Za prikaz podatkov stran za stranjo boste morali izumiti dodatne mehanizme, ki bodo najverjetneje zelo nizko učinkoviti (na primer pridobivanje iz baze podatkov vseh zapisov z vrednostjo update_time višji od danega in ustvari določeno število zapisov, začenši z določenim odmikom od začetka vzorca).

Učinkovitost prenosa podatkov lahko izboljšate tako, da nekoliko izboljšate prejšnji pristop. Da bi to naredili, bomo uporabili vrsto celega števila (dolgo celo število) kot vrednosti polja stolpca za sledenje spremembam. Poimenujmo stolpec row_ver. Vrednost polja tega stolpca mora biti še vedno nastavljena/posodobljena vsakič, ko je zapis ustvarjen/spremenjen. Toda v tem primeru polju ne bo dodeljen trenutni datum in čas, temveč vrednost nekega števca, povečana za eno. Kot rezultat, stolpec row_ver bo vseboval edinstvene vrednosti in se lahko uporablja ne le za prikaz "delta" podatkov (podatki, dodani/spremenjeni od konca prejšnje seje izmenjave), ampak tudi za preprosto in učinkovito razčlenitev na strani.

Zadnja predlagana metoda minimiziranja količine prenesenih podatkov v okviru replikacije na visoki ravni se mi zdi najbolj optimalna in univerzalna. Oglejmo si ga podrobneje.

Posredovanje podatkov z uporabo števca različic vrstic

Implementacija strežniškega/master dela

V MS SQL Server obstaja posebna vrsta stolpcev za izvajanje tega pristopa - rowversion. Vsaka zbirka podatkov ima števec, ki se poveča za eno vsakič, ko je zapis dodan/spremenjen v tabeli, ki ima stolpec, kot je rowversion. Vrednost tega števca je samodejno dodeljena polju tega stolpca v dodanem/spremenjenem zapisu. Tarantool DBMS nima podobnega vgrajenega mehanizma. Vendar ga v Tarantoolu ni težko implementirati ročno. Poglejmo, kako se to naredi.

Najprej malo terminologije: tabele v Tarantoolu se imenujejo prostori, zapisi pa tuple. V Tarantoolu lahko ustvarjate sekvence. Zaporedja niso nič drugega kot poimenovani generatorji urejenih celih vrednosti. Tisti. to je točno tisto, kar potrebujemo za naše namene. Spodaj bomo ustvarili takšno zaporedje.

Preden izvedete katero koli operacijo baze podatkov v Tarantoolu, morate zagnati naslednji ukaz:

box.cfg{}

Posledično bo Tarantool začel zapisovati posnetke baze podatkov in dnevnike transakcij v trenutni imenik.

Ustvarimo zaporedje row_version:

box.schema.sequence.create('row_version',
    { if_not_exists = true })

Možnost if_not_exists omogoča, da se skript za ustvarjanje izvede večkrat: če objekt obstaja, ga Tarantool ne bo poskušal znova ustvariti. Ta možnost bo uporabljena v vseh naslednjih ukazih DDL.

Za primer ustvarimo prostor.

box.schema.space.create('goods', {
    format = {
        {
            name = 'id',
            type = 'unsigned'

        },
        {
            name = 'name',
            type = 'string'

        },
        {
            name = 'code',
            type = 'unsigned'

        },
        {
            name = 'row_ver',
            type = 'unsigned'

        }
    },
    if_not_exists = true
})

Tukaj določimo ime prostora (goods), imena polj in njihove vrste.

Polja s samodejnim povečanjem v Tarantoolu so prav tako ustvarjena z zaporedji. Ustvarimo samodejni prirastni primarni ključ po polju id:

box.schema.sequence.create('goods_id',
    { if_not_exists = true })
box.space.goods:create_index('primary', {
    parts = { 'id' },
    sequence = 'goods_id',
    unique = true,
    type = 'HASH',
    if_not_exists = true
})

Tarantool podpira več vrst indeksov. Najpogosteje uporabljena indeksa sta tipa TREE in HASH, ki temeljita na strukturah, ki ustrezajo imenu. TREE je najbolj vsestranska vrsta indeksa. Omogoča organizirano pridobivanje podatkov. Toda za izbiro enakosti je primernejši HASH. Zato je priporočljivo uporabiti HASH za primarni ključ (kar smo tudi storili).

Za uporabo stolpca row_ver za prenos spremenjenih podatkov morate zaporedne vrednosti povezati s polji tega stolpca row_ver. Toda za razliko od primarnega ključa vrednost polja stolpca row_ver se mora povečati za eno ne samo pri dodajanju novih zapisov, ampak tudi pri spreminjanju obstoječih. Za to lahko uporabite sprožilce. Tarantool ima dve vrsti sprožilcev prostora: before_replace и on_replace. Sprožilci se sprožijo vsakič, ko se podatki v prostoru spremenijo (za vsako torko, na katero vplivajo spremembe, se zažene funkcija sprožilca). Za razliko od on_replace, before_replace-sprožilci vam omogočajo spreminjanje podatkov tuple, za katero se sprožilec izvaja. V skladu s tem nam zadnja vrsta sprožilcev ustreza.

box.space.goods:before_replace(function(old, new)
    return box.tuple.new({new[1], new[2], new[3],
        box.sequence.row_version:next()})
end)

Naslednji sprožilec nadomesti vrednost polja row_ver shranjeni tuple na naslednjo vrednost zaporedja row_version.

Da bi lahko črpali podatke iz vesolja goods po stolpcu row_ver, ustvarimo indeks:

box.space.goods:create_index('row_ver', {
    parts = { 'row_ver' },
    unique = true,
    type = 'TREE',
    if_not_exists = true
})

Vrsta indeksa - drevo (TREE), Ker podatke bomo morali ekstrahirati v naraščajočem vrstnem redu vrednosti v stolpcu row_ver.

V prostor dodamo nekaj podatkov:

box.space.goods:insert{nil, 'pen', 123}
box.space.goods:insert{nil, 'pencil', 321}
box.space.goods:insert{nil, 'brush', 100}
box.space.goods:insert{nil, 'watercolour', 456}
box.space.goods:insert{nil, 'album', 101}
box.space.goods:insert{nil, 'notebook', 800}
box.space.goods:insert{nil, 'rubber', 531}
box.space.goods:insert{nil, 'ruler', 135}

Ker Prvo polje je števec s samodejnim povečanjem; namesto tega posredujemo nič. Tarantool bo samodejno nadomestil naslednjo vrednost. Podobno velja za vrednost polj stolpcev row_ver lahko posredujete nič - ali vrednosti sploh ne podate, ker ta stolpec zavzema zadnje mesto v prostoru.

Preverimo rezultat vstavljanja:

tarantool> box.space.goods:select()
---
- - [1, 'pen', 123, 1]
  - [2, 'pencil', 321, 2]
  - [3, 'brush', 100, 3]
  - [4, 'watercolour', 456, 4]
  - [5, 'album', 101, 5]
  - [6, 'notebook', 800, 6]
  - [7, 'rubber', 531, 7]
  - [8, 'ruler', 135, 8]
...

Kot lahko vidite, se prvo in zadnje polje izpolnita samodejno. Zdaj bo enostavno napisati funkcijo za stran za stranjo nalaganje sprememb prostora goods:

local page_size = 5
local function get_goods(row_ver)
    local index = box.space.goods.index.row_ver
    local goods = {}
    local counter = 0
    for _, tuple in index:pairs(row_ver, {
        iterator = 'GT' }) do
        local obj = tuple:tomap({ names_only = true })
        table.insert(goods, obj)
        counter = counter + 1
        if counter >= page_size then
            break
        end
    end
    return goods
end

Funkcija kot parameter vzame vrednost row_ver, začenši s katerim je potrebno razložiti spremembe, in vrne del spremenjenih podatkov.

Vzorčenje podatkov v Tarantoolu poteka prek indeksov. funkcija get_goods uporablja iterator po indeksu row_ver za prejemanje spremenjenih podatkov. Vrsta iteratorja je GT (Greater Than, večje od). To pomeni, da bo iterator zaporedno prečkal vrednosti indeksa, začenši s posredovanim ključem (vrednost polja row_ver).

Iterator vrne torke. Da bi kasneje lahko prenašali podatke prek HTTP, je treba pretvoriti tuple v strukturo, primerno za kasnejšo serializacijo. Primer za to uporablja standardno funkcijo tomap. Namesto uporabe tomap lahko napišete svojo funkcijo. Na primer, morda želimo preimenovati polje name, ne mimo polja code in dodajte polje comment:

local function unflatten_goods(tuple)
    local obj = {}
    obj.id = tuple.id
    obj.goods_name = tuple.name
    obj.comment = 'some comment'
    obj.row_ver = tuple.row_ver
    return obj
end

Velikost strani izhodnih podatkov (število zapisov v enem delu) je določena s spremenljivko page_size. V primeru vrednost page_size je 5. V pravem programu je velikost strani običajno pomembnejša. Odvisno je od povprečne velikosti prostorske tuple. Optimalno velikost strani lahko določimo empirično z merjenjem časa prenosa podatkov. Večja kot je velikost strani, manjše je število povratnih prehodov med stranjo pošiljatelja in prejemnika. Na ta način lahko zmanjšate skupni čas za prenos sprememb. Če pa je velikost strani prevelika, bomo predolgo porabili strežnik za serializacijo vzorca. Posledično lahko pride do zamud pri obdelavi drugih zahtev, ki prihajajo na strežnik. Parameter page_size lahko naložite iz konfiguracijske datoteke. Za vsak preneseni prostor lahko nastavite lastno vrednost. Vendar pa je za večino prostorov morda primerna privzeta vrednost (na primer 100).

Izvedimo funkcijo get_goods:

tarantool> get_goods(0)

---
- - row_ver: 1
    code: 123
    name: pen
    id: 1
  - row_ver: 2
    code: 321
    name: pencil
    id: 2
  - row_ver: 3
    code: 100
    name: brush
    id: 3
  - row_ver: 4
    code: 456
    name: watercolour
    id: 4
  - row_ver: 5
    code: 101
    name: album
    id: 5
...

Vzemimo vrednost polja row_ver iz zadnje vrstice in ponovno pokličite funkcijo:

tarantool> get_goods(5)

---
- - row_ver: 6
    code: 800
    name: notebook
    id: 6
  - row_ver: 7
    code: 531
    name: rubber
    id: 7
  - row_ver: 8
    code: 135
    name: ruler
    id: 8
...

Ponovno:

tarantool> get_goods(8)
---
- []
...

Kot lahko vidite, pri uporabi na ta način funkcija vrne vse zapise prostora stran za stranjo goods. Zadnji strani sledi prazen izbor.

Spremenimo prostor:

box.space.goods:update(4, {{'=', 6, 'copybook'}})
box.space.goods:insert{nil, 'clip', 234}
box.space.goods:insert{nil, 'folder', 432}

Spremenili smo vrednost polja name za en vnos in dodal dva nova vnosa.

Ponovimo zadnji klic funkcije:

tarantool> get_goods(8)
---



- - row_ver: 9
    code: 800
    name: copybook
    id: 6
  - row_ver: 10
    code: 234
    name: clip
    id: 9
  - row_ver: 11
    code: 432
    name: folder
    id: 10
...

Funkcija je vrnila spremenjene in dodane zapise. Torej funkcija get_goods omogoča sprejem podatkov, ki so se spremenili od zadnjega klica, kar je osnova obravnavane metode replikacije.

Izdajanje rezultatov prek HTTP v obliki JSON bomo pustili zunaj obsega tega članka. O tem si lahko preberete tukaj: https://habr.com/ru/company/mailru/blog/272141/

Izvedba dela odjemalec/podrejeni

Poglejmo, kako izgleda implementacija sprejemne strani. Ustvarimo prostor na sprejemni strani za shranjevanje prenesenih podatkov:

box.schema.space.create('goods', {
    format = {
        {
            name = 'id',
            type = 'unsigned'

        },
        {
            name = 'name',
            type = 'string'

        },
        {
            name = 'code',
            type = 'unsigned'

        }
    },
    if_not_exists = true
})

box.space.goods:create_index('primary', {
    parts = { 'id' },
    sequence = 'goods_id',
    unique = true,
    type = 'HASH',
    if_not_exists = true
})

Struktura prostora je podobna strukturi prostora v izviru. Ker pa prejetih podatkov ne bomo posredovali nikamor drugam, stolpec row_ver ni v prejemnikovem prostoru. Na terenu id identifikatorji vira bodo zabeleženi. Zato na strani sprejemnika ni potrebe po samodejnem povečanju.

Poleg tega potrebujemo prostor za shranjevanje vrednosti row_ver:

box.schema.space.create('row_ver', {
    format = {
        {
            name = 'space_name',
            type = 'string'

        },
        {
            name = 'value',
            type = 'string'

        }
    },
    if_not_exists = true
})

box.space.row_ver:create_index('primary', {
    parts = { 'space_name' },
    unique = true,
    type = 'HASH',
    if_not_exists = true
})

Za vsak naložen prostor (polje space_name) tukaj bomo shranili zadnjo naloženo vrednost row_ver (polje value). Stolpec deluje kot primarni ključ space_name.

Ustvarimo funkcijo za nalaganje prostorskih podatkov goods prek HTTP. Za to potrebujemo knjižnico, ki implementira odjemalca HTTP. Naslednja vrstica naloži knjižnico in ustvari primerek odjemalca HTTP:

local http_client = require('http.client').new()

Potrebujemo tudi knjižnico za deserializacijo json:

local json = require('json')

To je dovolj za ustvarjanje funkcije nalaganja podatkov:

local function load_data(url, row_ver)
    local url = ('%s?rowVer=%s'):format(url,
        tostring(row_ver))
    local body = nil
    local data = http_client:request('GET', url, body, {
        keepalive_idle =  1,
        keepalive_interval = 1
    })
    return json.decode(data.body)
end

Funkcija izvede zahtevo HTTP na url naslov in jo pošlje row_ver kot parameter in vrne deserializiran rezultat zahteve.

Funkcija za shranjevanje prejetih podatkov izgleda takole:

local function save_goods(goods)
    local n = #goods
    box.atomic(function()
        for i = 1, n do
            local obj = goods[i]
            box.space.goods:put(
                obj.id, obj.name, obj.code)
        end
    end)
end

Cikel shranjevanja podatkov v prostor goods postavljen v transakcijo (za to se uporablja funkcija box.atomic), da zmanjšate število diskovnih operacij.

Končno funkcija sinhronizacije lokalnega prostora goods z virom lahko implementirate takole:

local function sync_goods()
    local tuple = box.space.row_ver:get('goods')
    local row_ver = tuple and tuple.value or 0

    —— set your url here:
    local url = 'http://127.0.0.1:81/test/goods/list'

    while true do
        local goods = load_goods(url, row_ver)

        local count = #goods
        if count == 0 then
            return
        end

        save_goods(goods)

        row_ver = goods[count].rowVer
        box.space.row_ver:put({'goods', row_ver})
    end
end

Najprej preberemo predhodno shranjeno vrednost row_ver za prostor goods. Če manjka (prva seja izmenjave), jo vzamemo kot row_ver nič. Nato v ciklu izvedemo stran za stranjo prenos spremenjenih podatkov iz vira na navedenem URL-ju. Pri vsaki ponovitvi shranimo prejete podatke v ustrezen lokalni prostor in posodobimo vrednost row_ver (v vesolju row_ver in v spremenljivki row_ver) - vzemite vrednost row_ver iz zadnje vrstice naloženih podatkov.

Za zaščito pred nenamernim zankanjem (v primeru napake v programu) je zanka while se lahko nadomesti z for:

for _ = 1, max_req do ...

Kot rezultat izvajanja funkcije sync_goods prostora goods sprejemnik bo vseboval najnovejše različice vseh prostorskih zapisov goods v viru.

Očitno brisanja podatkov ni mogoče oddajati na ta način. Če takšna potreba obstaja, lahko uporabite oznako za izbris. Dodaj v prostor goods logično polje is_deleted in namesto fizičnega brisanja zapisa uporabimo logično brisanje - nastavimo vrednost polja is_deleted v pomen true. Včasih namesto logičnega polja is_deleted bolj priročno je uporabljati polje deleted, ki hrani datum-čas logičnega izbrisa zapisa. Po izvedbi logičnega brisanja bo zapis, označen za brisanje, prenesen iz vira na cilj (v skladu z zgoraj opisano logiko).

Zaporedje row_ver se lahko uporablja za prenos podatkov iz drugih prostorov: ni treba ustvariti ločenega zaporedja za vsak preneseni prostor.

Ogledali smo si učinkovit način podvajanja podatkov na visoki ravni v aplikacijah, ki uporabljajo DBMS Tarantool.

Ugotovitve

  1. Tarantool DBMS je privlačen, obetaven izdelek za ustvarjanje zahtevnih aplikacij.
  2. Podvajanje podatkov na visoki ravni ima številne prednosti pred podvajanjem na nizki ravni.
  3. Metoda podvajanja na visoki ravni, obravnavana v članku, vam omogoča, da zmanjšate količino prenesenih podatkov s prenosom samo tistih zapisov, ki so bili spremenjeni od zadnje seje izmenjave.

Vir: www.habr.com

Dodaj komentar