Einfuehrung in Distributed Hash Tables: Filip Martinovsk y Philipp Wagner
Einfuehrung in Distributed Hash Tables: Filip Martinovsk y Philipp Wagner
Partition Tolerance
2 Theorie
2.1 Hashing
Hash-Funktionen (aus dem Englischen “to hash“ = zerhacken),
im Deutschen auch als Streuwertfunktionen bekannt, stellen eine
Abbildung von einer größeren Quellmenge in einen kleineren Ziel-
raum (auch Schlüsselraum genannt) dar. Sie sind in vielen Berei-
Abb. 1: CAP Theorem chen der Informatik relevant, denn oft ist es eine Notwendigkeit
Daten in kurzer Zeit in bestehende Verzeichnisse einzufügen oder
auszulesen. Beispiele sind:
4
g(x) = x mod 5
(RFC13219 ) oder SHA-1, da diese möglichst kollsionsfrei sind und
so die Wahrscheinlichkeit von doppelten Einträgen minimiert wird.
0
3
Resource B
Buckets
2
1
0
0 2 4 6 8 10 Resource A
Data
die Last zu verteilen, wäre es URL6 Adressen durch eine einfache IP2-2
Hashfunktion auf die Server zu abzubilden.
Audioscrobbler verwendete für seine Clients beispielsweise fol-
genden Hash7 :
IP2-1
N23 a) b)
p p
N30
n n
3.1 Lookup c) d)
3.1.1 Lookup ohne Finger-Informationen p p
Hat ein Knoten keine zusätzlichen Routinginformationen, kennt
er nur seinen Vorgänger und Nachfolger. Eine Suche nach einem
Schlüssel würde eine sequentielle Suche werden, wie in Abbildung n n
3 beschrieben. Die Komplexität würde ein Aufwand von O (n) be-
s s
deuten, und dies ist in großen Netzen nicht hinnehmbar.
3.1.2 Lookup mit Finger-Informationen
Um das System also zum Skalieren zu bringen, muss das Ziel Abb. 5: Einfügen eines Knotens
entweder eine konstante Suchzeit O (1) oder ein logarithmischer
Aufwand O (log n) sein. Eine konstante Suchzeit kann erreicht wer-
den, indem jeder Node den gesamten Ring kennt und Anfragen erfolgt.
direkt an den verantwortlichen Node weitergeleitet werden. Dies
bedeutet jedoch einen hohen Verwaltungsaufwand und stellt eine 4. Datenaustausch zwischen Node 4 und Node 11.
Speicherplatzanforderung für den Client dar. Chord ist deshalb für Es sei an dieser Stelle auf den Beweis verzichtet, daß die Such-
eine Suche in logarithmischer Zeit vorgesehen. Es werden nur m zeit in Chord in wirklich in O (log n) liegt. Denn die Beweisidee
Einträge in der Routingtabelle, auch Fingertabelle genannt, benötigt in [SMK+ 01] ist offensichtlich: durch Abbildung 4 und den Rou-
und m ist die Bitlänge der Hashfunktion. tinginformationen in Listing 1 ist einzusehen, daß sich mit jedem
Der i-te Eintrag in der Fingertable ist der Knoten, der den 2i−1 - Schritt der Suchraum mit hoher Wahrscheinlichkeit halbiert. Die
ten Eintrag im Ring verwaltet. Es ist ersichtlich, daß der erste Ein- Suche nach einem Wert wird letztendlich zu einer binären Suche
trag der Tabelle k + 20 der Nachfolger des Knotens k ist. Das Ziel mit bekanntermaßen logarithmischen Aufwand.
der Fingertable ist mit jedem Suchschritt den Suchraum innerhalb
des Rings zu halbieren. 3.2 Operationen
Der Lookup wird an einem konkreten Beispiel beschrieben: in 3.2.1 Einfügen eines Knotens
Abbildung 4 wird in einem Ring der Größe 26 bei Knoten 11 nach Soll ein neuer Knoten n hizugefügt werden so muss diesem be-
dem Schlüssel 2 gesucht, die Fingertables der Knoten sind mit Lis- reits ein Knoten x aus dem Ring bekannt sein. Ausgehend von x
ting 1 gegeben. kann der zu n zugehörige Nachfolger s im Ring gesucht werden,
Die Suche läuft folgendermassen: gemäß der thematisierten Lookup Strategie.10 , in Abbildung 5a dar-
1. Node 11 wählt Node 45 aus seiner Fingertable, denn dieser ist gestellt.
der größte Node der Fingertable der vor Key 2 liegt. n setzt seinen Nachfolger-Zeiger auf s und informiert ihn über
die Änderung. Ist n der Vorgänger von s im Ring (d.h. seine ID
2. Node 45 wählt Node 60 aus seiner Fingertable, denn Node 60 ist größer als die des urspünglichen Vorgängers p) setzt s seinen
ist der Node mit dem höchsten Hashwert vor Key 2. Vorgänger-Zeiger auf n, siehe Abbildung 5b.
3. Node 60 hat als Nachfolger Node 4. Da Node 4 schon einen
höheren Hashwert als 2 hat, ist Node 4 für Key 2 verant- 10 Die Position ist dabei abhängig von der gewählten Ve-
wortlich und eine Benachrichtigung des suchenden Knotens schlüsselung aus MAC-Adresse, IP-Adresse. . . und Hash-Funktion
a) b) c) 3. Fingertable aktualisieren
Nach den ersten beiden Stabilisierungsschritten kann davon
ausgegangen werden, daß der Nachfolger des Knotens exis-
tiert und eine Anfrage gestartet werden kann. Um die Finger-
table für den Node n aufzubauen, ist es notwendig die Nodes
zu finden die für den n + 2i−1 -ten Schlüssel zuständig sind,
Abb. 6: Löschen eines Knotens i ∈ {1, . . . , m}.
4 Implementierung
Eine Umsetzung von Chord erfolgte in der Programmierspra-
Bei der periodischen Ausführung der Stabilisierungsfunktion che Erlang13 . Die Softwareauswahl wurde für Erlang entschieden,
wird p feststellen, dass n der Vorgänger von s ist und aktualisiert denn mit dieser Sprache lassen sich prototypische Implementierun-
dementsprechend seinen Nachfolger-Zeiger auf n (Abbildung 5c) gen verteilter Systeme effizient durchführen. Eine sehr gute und hu-
und informiert seinen neuen Nachfolger, welcher seinen Vorgänger- morvolle Einführung ist für den interessierten Leser mit dem freien
Zeiger auf p setzen wird. Damit ist der Ring wieder in einem stabi- Buch [TH10] gegeben. Die folgende Einführung wurde mit Erlaub-
len Zustand (Abbildung 5d) übergetreten und alle Routinginforma- nis der Autoren aus [MWH10] übernommen.
tionen sind konsistent.
4.1 Erlang
3.2.2 Löschen eines Knotens Erlang ist eine funktionale Programmiersprache die von Erics-
Für das Löschen eines Knotens ist es nicht unbedingt erforde- sons Computer Science Laboratory in den späten 1980er Jahren
lich eine Funktion zur Verfügung zu stellen, da sich der Ring beim entwickelt wurde. Die Sprache, ähnlich wie Java von einer VM
Löschen eines Knotens periodisch stabilisiert. Es ist jedoch aus interpretiert, bringt ein Framework (OTP14 ) für die Entwicklung
Sicht der Datensicherung und der Performanz wünschenswert, denn von parallelen, verteilten und fehlertoleranten Systemen mit. 1998
verlässt ein Knoten freiwillig den Ring11 kann er die Stabilisierung wurde die Sprache und die VM von Ericsson als Open Source zur
autonom durchführen und deutlich beschleunigen. Verfügung gestellt. Die Charakteristika von Erlang sind:
Die erforderte Funktionalität ist einfach: der Node der den
Ring verlassen möchte aktualisiert die Zeiger seines Vorgängers • Funktionen höherer Ordnung
und Nachfolgers so, daß beide aufeinander zeigen, Abbildung 6b. • Prozesse und Message-Passing
Gleichzeitig wechselt der Node in einen Zustand in dem Lookups
• leichte Skalierbarkeit
gleich an den Nachfolger weitergeleitet werden. Dadurch werden
die Ergebnisse der gerade laufenden Stabilisierungsfunktionen so • Soft Real-Time Fähigkeit
manipuliert, dass sich der Ring nach dem Löschen des Knotens in
einem stabilen Zustand befindet (Abbildung 6c). Variablen
In Erlang beginnt ein Name einer Variable immer mit einem
3.2.3 Stabilisierungsphase Großbuchstaben. Werte werden mit Hilfe des Zuweisungsoperators
Da die Topologie dezentral ist gibt es keine zentrale Instanz die = an Variablen gebunden. Es ist zu beachten, dass in Erlang single
die Konsistenz der Routinginformationen gewährleistet. Jeder Teil- assignment gilt, das heisst eine Variable kann nur einmal an einen
nehmer im Ring ist also dafür verantwortlich seine Routinginfor- Wert gebunden werden:
mationen auf dem neuesten Stand zu halten.
Knoten können dem Ring jederzeit verlassen oder beitreten, 1> Q = 4683.
die Routinginformationen der einzelnen Knoten würden somit 4683
ungültig. Die für die Distributed Hash Table grundlegende Lookup 2> A = 30484.
Operation wäre nicht mehr möglich. 30484
Die in [SMK+ 01] als Stabilisierung bezeichnete Aktualisierung 3> Q = A.
der Routinginformationen wird bei jedem Knoten im Ring peri- ** exception error: no match of right
odisch ausgeführt. Die Stabilisierung erfolgt in 3 Schritten: hand side value 30484
setzt, es hat aber keine Auswirkungen ihn auf sich selbst zu setzen. 14 Open Telecommunication Platform
Zahlen Message
Integer A B
Erlang bietet die Möglichkeit Integer mit verschiedenen Basen
zu verwenden. Diese sind im Format BASIS#WERT einzugeben: Prozess A Prozess B (NewFun)
> NewFun ! ping. receive
> -10. PONG! ping ->
-10 ping io:format("PONG!~n")
> 2#101010101010110. end.
21846
> 16#CAFEBABE.
3405691582 Abb. 7: Message Passing in Erlang
Float
Gleitkommazahlen werden gemäß dem 64-bit Format des
IEEE754-1985 Standards abgespeichert (11 bit Exponent, 52 bit
Funktionen
Mantisse): Funktionen können in Erlang ausschliesslich in Modulen defi-
niert werden und können erst nachdem sie kompiliert wurden gela-
> 1.2E10 - 1.2E-10. den werden. Die meisten rekursiven Funktionen nutzen das Pattern-
1.2e10 Matching:
> 1.231.
1.231 mymap(Fun,[]) ->
[];
Tupel mymap(Fun,[Hd|Tl]) ->
Tupel dienen zur Speicherung einer festen Anzahl von Elemen- [Fun(Hd) | mymap(Fun,Tl)].
ten die meist in Beziehung zueinander stehen. Ein Tupel kann un-
terschiedliche Datentypen enthalten und wird durch geschweifte Dieses kurze Programm definiert die map Funktion. Als Funk-
Klammern gekennzeichnet: tion höherer Ordnung ruft map die Funktion Fun (eine anonyme
Funktion) mit jedem Element in der Liste auf und liefert anschlie-
> { ’Map’, 16#BABE }. ßend eine Ergebnisliste gleicher Kardinalität.
{ ’Map’, 47806 }
> tuple_size({ ’Map’, 16#BABE }). Nebenläufigkeit in Erlang
2 Die große Stärke von Erlang ist die Unterstützung von Ne-
Um die Arbeit mit Tuplen zu vereinfachen gibt es in Erlang die benläufigkeit. Jede Funktion kann mittels der Funktion spawn auf-
Funktionen element, tuple size und setelement. gerufen werden und als ein neuer Prozess in der Erlang VM fun-
gieren. Die Erlang VM verwaltet die Prozesse unabhängig vom Be-
Listen triebssystem und verteilt selbstständig die Erlang VM Threads auf
Listen werden wie Tupel für das Abspeichern von Daten ge- Betriebssystem-Prozesse.15 Die Erlangprozesse sind sehr leichtge-
nutzt. Im Unterschied zu Tupeln haben Listen eine variable Anzahl wichtig, da sie innerhalb der Erlang VM lediglich Funktionen sind
an Elementen. Listen werden durch eckige Klammern [] gekenn- und so der Overhead durch Kontextwechsel und Process Control
zeichnet. Blocks entfällt. Der Rückgabewert von spawn ist die Pid16 des neu
> [ ’Map’, 16#BABE ]. erstellten Prozesses.
[ ’Map’, 47806 ]
> length([ ’Map’, 16#BABE ]).
Message Passing
2 Weil ein Aufruf der Funktion spawn asynchroner Natur ist wird
> [ test1 | [ test2 | [] ] ]. ein realisiert Erlang die Kommunikationen zwischen den Prozessen
[ test1, test2 ] mit Hilfe des Message Passing. Nachrichten, Erlang Ausdrücke,
werden an Prozesse mit dem ! Operator gesendet. Die Nachricht
Pattern Matching wird in der Mailbox des empfangenden Prozesses gespeichert und
Erlang nutzt bei der Bindung von Variablen Pattern-Matching mittels Pattern-Matching innerhalb der receive Klausel ausgele-
und ermöglicht es so Werte aus komplexen Datenstrukturen zu ex- sen.
trahieren:
> NewFun = spawn(fun()-> receive
> {person, Name, en} = {person, ’Thomas’, de}. > ping -> io:format("PONG!˜n")
** exception error: no match of > end end ).
right hand side value {person,’Thomas’,de} <0.48.0>
> {person, Name, de} = {person, ’Thomas’, de}. > NewFun ! ping.
{person,’Thomas’,de} PONG!
> Name. ping
’Thomas’
In diesem Beispiel wird ein Prozess erstellt, der innerhalb sei-
Es gibt auch spezielle Operatoren für das Pattern-Matching bei ner receive Klausel auf die Nachricht ping wartet und bei Erfolg
Listen: die Nachricht PONG! herausgibt und beendet. Dies ist dargestellt in
> [Hd | Tl] = [test1, test2, test3]. Abbildung 7.
> Hd.
test1 15 [Link]
erl> Tl. html
[test2, test3] 16 Process identifier in der Erlang VM.
4.2 Chord Listing 3: Stabilisierung
4.2.1 Aufbau updater ( NodePid ) ->
Das System wurde in 3 Schichten aufgeteilt: NodeId = node_id ( NodePid ) ,
{_ , _NextId , NextPid} = node_sc ( NodePid ) ,
Basis Schicht enthält Distanz- und Hashing-Funktionen die als case node_pr ( NextPid ) of
Grundbausteine für den Chord-Algorithmus notwendig sind. { _ , NodeId , NodePid } -> ReqNode = NextPid ;
{ _ , _NewId , NewPid } -> ReqNode = NewPid ;
Chord Schicht ist der Kern der Implementierung. Diese Schicht {error, timeout } -> ReqNode = NodePid
setzt die Funktionen für Node-Join, Lookup und Stabilisie- end,
rungsfunktion um, sowie Hilfsfunktionen für die Kommuni- try finger_table ( NodeId , NodePid , ReqNode ) of
kation zwischen Nodes. FingerTable ->
NewFingerTable = [ {0, NodeId + 1, ReqNode} |
Daten Schicht implementiert, aufbauend auf den unteren Schich- FingerTable],
NodePid ! # msg{id = set_finger , from =self() , data =
ten, die Funktionen für das Abspeichern der Daten und deren NewFingerTable}
Lookup. Intern ist eine einfache Backup-Strategie umgesetzt catch
indem ein Schlüssel zweifach redundant an einen Node und error: badarith ->
dessen Nachfolger verteilt wird, damit Daten nach Absturz FingerTable = [ {I , idistance ( NodeId , math : pow (2 , I
)) , ReqNode } || I <- lists : seq (1 , 3, 1)],
eines Nodes dem Ring erhalten bleiben. NewFingerTable = [ {0, NodeId + 1, ReqNode} |
Die Nodes in dem Chord-Ring werden als Erlang-Prozesse im- FingerTable],
plementiert. Um die Absturzgefahr dieser Prozesse zu minimieren, NodePid ! # msg{id = set_finger , from =self() , data =
NewFingerTable};
kommunizieren diese mittels Funktionen in getrenten Prozessen. _:_ ->
4.2.2 Stabilisierung NewFingerTable = [ {I , idistance ( NodeId , math : pow
Die Stabilisierungsfunktion ist eine der wichtigsten Funktionen (2 , I)) , NodePid } || I <- lists : seq (0 , 3, 1)]
,
für den Chord-Ring. Ohne Stabilisierung wäre nach Absturz eines NodePid ! # msg{id = set_finger , from =self() , data =
Nodes kein lookup mehr möglich und noch wichtiger: es könnte gar NewFingerTable}
kein Chord-Ring aufgebaut werden. end,
Die Stabilisierungsfunktion wird periodisch aufgerufen und ruft SucNodePid = element(3 , node_sc ( NodePid )) ,
SucNodePid ! # msg{id = set_pre , from =self() , data ={NodeId
die Funktion node_updater auf, diese erledigt die eigentliche Ar- , NodePid}},
beit der Stabilisierungsfunktion: ok.
• Neue Nodes in den Ring aufnehmen
Die wichtigste Routine bei konsistenten Hashverfahren ist die
• Nodes ohne Rückmeldung aus den Routingtabellen zu entfer- Bestimmung des für einen Schlüsselbereich zuständigen Node.
nen
Beim Erstellen eines Nodes wird gleichzeitig ein Stabilisierung- Listing 4: Bestimmung des für Schlüssel zuständigen Node
prozess gestartet und mit dem Prozess des jeweiligen Nodes ver- findNode ( HashKey , NodePid ) ->
linkt. Tritt in einem der beiden Prozesse ein Fehler auf, werden bei- NodeId = node_id ( NodePid ) ,
de Prozesse beendet. { _ , PrevId , _PrevPid} = node_pr ( NodePid ) ,
Found = distance ( HashKey , NodeId ) =< distance ( HashKey ,
PrevId ) ,
Listing 2: Periodische Stabilisierung case Found of
node_updater ( NodePid ) -> true -> NodePid ;
receive false ->
after {_Pos , _NextId , NextPid } = node_fs ( HashKey ,
5000 -> NodePid ) ,
updater ( NodePid ) , findNode ( HashKey , NextPid )
node_updater ( NodePid ) end.
end.
4.2.3 Node Join
Mit den Funktionen node_id, node_sc und node_pr kann man Um dem Chord-Ring beizutreten muss dem neuen Node bereits
die Informationen über den Node, den Nachfolger-Node oder den ein Teilnehmer des Ringes bekannt sein. Die Methode join in Lis-
Vorgänger des Nodes abrufen. Der Rückgabewert dieser Funktio- ting 5 bekommt also die Adresse des neuen Nodes: ChordNodePid.
nen ist ein 3-Tupel {Num, Id, Pid}: Anschliessend wird der Hashwert NewNodeId des Nodes aus seiner
Num ist die Enfernung des Nodes vom aktuellem Node (interne Pid NewNodePid bestimmt und die Adresse NextNodePid des Nachfol-
Information) gers im Ring gesucht.
Nachdem diese Informationen bekannt sind, kann die Fingerta-
Id der Hashwert, bestimmt die Position des Nodes in dem Chord-
ble des Knotens neu aufgebaut werden. Gemäß der in Abbildung
Ring
4 gezeigten Strategie wird die bekannte Pid des Nachfolgers ange-
Pid die Pid des Nodes unter Erlang also die Addresse des Nodes fragt und die für NewNodeId+2i verantwortlichen Knoten gesucht.
Die updater Methode überprüft zuerst den Nachfolger des No- Mit NewNodePid !#msg{id=set_finger, from=self(), data=
des, durch Anwendung der Funktion node_sc( NodePid ). An- NewFingerTable} wird die Fingertable des beitretenden Nodes
schliessend wird der Vorgänger (node_pr) des Nodes mit NextPid gesetzt. Abschliessend wird der Vorgängerzeiger auf den, mit
überprüft. Sind die Routinginformationen noch konsistent, d.h. die node_pr( NextNodePid ) gefundenen, Vorgängerknoten gesetzt (der
Zeiger-Informationen sind aktuell, wird die Fingertable auf Basis Vorgänger des neuen Knotens ist, wie in der Theorie erwähnt,
des Nachfolgers gebaut. Ist jedoch ein neuer Node hinzugekom- der Vorgänger des Nachfolgers) und der Vorgängerzeiger des
men, wird die Fingertable auf Basis des neuen Nodes aufgebaut. Nachfolgers gesetzt.
Greift die letzte Regel ist ein Fehler bei der Kommunikation aufge-
treten und der Node NextPid wird aus der Fingertable entfernt und Listing 5: Beitritt eines Nodes
ein neuer Nachfolger ermittelt. join ( NewNodePid , ChordNodePid ) ->
NewNodeId = node_id ( NewNodePid ) ,
NextNodePid = findNode ( NewNodeId , ChordNodePid ) , Node < Citronella >
NodeID :8 af
FingerTable = [ {X+1 , idistance ( NewNodeId , math : pow (2 ,X NodePID : <0.42.0 >
)) , findNode ( idistance ( NewNodeId , math : pow (2 ,X)) ,
NextNodePid )} || X <- lists : seq (0 ,3 ,1)], Node <Dattel >
NewFingerTable = [{0, NewNodeId + 1, NextNodePid} | NodeID : eb5
FingerTable], NodePID : <0.43.0 >
{_ , PreId , PrePid} = node_pr ( NextNodePid ) , Die NodeID ist ein MD5 Hash (128bit Länge) und stellt die Po-
NewNodePid ! # msg{id = set_finger , from =self() , data = sition im Chord-Ring dar. Für das Beispiel werden nur die ersten 3
NewFingerTable}, Zeichen verwendet, da diese für dieses Beispiel eindeutig sind und
NewNodePid ! # msg{id = set_pre , from =self() , data ={PreId , es nachvollziehbarer machen.
PrePid}},
NextNodePid ! # msg{id = set_pre , from =self() , data ={ 4.4.2 Beitritt der Nodes
NewNodeId , NewNodePid}}, Jeder Node kann die Basis für einen Chord-Ring sein. Im fol-
ok. genden möchte Ananas dem Chord-Ring beitreten, der bisher ledig-
lich aus Banana besteht. Anschliessend treten weitere Nodes dem
4.3 Store Ring bei und erweitern diesen.
Im Chord-Ring werden (Schlüssel,Wert)-Paare gespeichert und
der Befehl zum Speichern kann an die Adresse eines beliebigen, be- join_node ( Ananas , Banana ) ,
join_node ( Citronella , Banana ) ,
kannten Node NodePid gestellt werden. Dieser bestimmt den für den join_node ( Dattel , Citronella ) ,
Schlüssel Key verantwortlichen Node StoreId und dessen Nachfolger
SucceId. Bevor die Stabilisierungsroutinen zur Aktualisierung der Rou-
Anschliessend wird der Befehl zum Speichern des tinginformationen ausgeführt wurden stellt sich der Ring dar als:
(Schlüssel,Wert)-Paar an die Adressen StorePid, SuccePid der
beiden Nodes gesandt oder bei keiner Verbindung nach 2 Sekunden JoinNode
mit einem Fehler quittiert. NodeID :3 e2 ( Ananas )
SuccessorId : e6f ( Banana )
node_store (Key , Value , NodePid ) ->
{ _ , StoreId , StorePid } = find_node (Key , NodePid ) , JoinNode
{ _ , SucceId , SuccePid } = node_successor ( StorePid ) , NodeID :8 af ( Citronella )
SuccessorId : e6f ( Banana )
io : format (" Store Key :˜32.16.0 b\t NodeId :˜32.16.0 b\n" ,[
Key , StoreId]) , JoinNode NodeID : eb5 ( Dattel )
SuccessorId : e6f ( Banana )
StorePid ! # msg{id = store_key , from =self() , data ={Key ,
Value}}, Nachdem die Stabilisierung abgeschlossen ist, stellt ist der Ring
receive dar als:
# msg{id = store_key , from = StorePid , data =ok} -> ok
after
NodeID : eb5 ( Dattel )
2000 -> error
Successor :3 e2 ( Ananas )
end,
NodeID :3 e2 ( Ananas )
io : format (" BackupId :˜32.16.0 b\n" ,[SucceId]) ,
SuccessorID :8 af ( Citronella )
SuccePid ! # msg{id = store_key , from =self() , data ={Key ,
NodeID :8 af ( Citronella )
Value}},
SuccessorId : e6f ( Banana )
receive
# msg{id = store_key , from = SuccePid , data =ok} -> ok
NodeID : e6f ( Banana )
after
SuccessorId : eb5 ( Dattel )
2000 -> {error, timeout}
end.
( Dattel -> Ananas -> Citronella -> Banana -> Dattel )