Affichage des articles dont le libellé est nosql. Afficher tous les articles
Affichage des articles dont le libellé est nosql. Afficher tous les articles

mardi 5 avril 2011

Livre MongoDB

Si vous cherchez un livre sur MongoDB, concis et précis et par dessus le marché gratuit, allez lire The Little MongoDB Book de Karl Seguin, le créateur de mongly.

Tutorial interactif MongoDB

Si vous voulez un tutorial MongoDB, rapide, efficace et totalement interactif, sans rien à installer, rendez vous sur mongly pour vous entrainer :

vendredi 17 septembre 2010

Stockez vos sessions PHP dans Redis !

Par défaut PHP stocke les sessions sur le système de fichiers. C'est bien, mais dans un environnement "Load-Balancé", ça ne marche en pratique pas très bien, à moins d'écrire les sessions sur un système de fichiers distants (NFS par exemple), et encore.

Pour palier à ce problème PHP permet de stocker les sessions PHP dans memcache, ce qui est déjà nettement mieux.

Ok, memcache c'est bien, mais Redis, c'est mieux (oui, je suis totalement subjectif là).

J'ai donc développé une petite classe qui permet de stocker vos sessions dans Redis, et donc de bénéficier de ses multiples avantages : vitesse, persistance.

Le code est disponible sous LGPLv3 sur github.

A l'usage, c'est extrêmement simple et transparent :

require_once 'RedisSession.php';

RedisSession::init ( array ( 
        'session_name' => 'redis_session', 
        'cookie_path' => '/', 
        'cookie_domain' => '.acme.org', 
        'lifetime' => 3600, 
        'server' => array ( 
                'host' => 'redis.acme.org', 
                'port' => 6379 ) ) );

mercredi 1 septembre 2010

MongoDB : scalabilité, réplication et failover grâce au sharding et aux replica set

Deux des fonctionnalités les plus attendues de MongoDB arrivent à maturation avec la sortie de la version 1.6 : le sharding et les replica set.

Le sharding, ou partitionnement, permet de rendre MongoDB parfaitement "horizontally scalable".
Les replica set permettent eux de répliquer les données entre des instances MongoDb, c'est une amélioration du mode master/slave existant, en ajoutant le failover automatique et la récupération automatiques des noeuds.

Mettre en place le Replica Set

Il est possible d'avoir autant de membres que voulu dans un replica set, et les données existeront sur chacun des noeuds du set. Cela permet de répartir les serveurs entre différents datacenters, et ainsi d'assurer une redondance totale. Un seul serveur est "primaire" et peut recevoir des lectures et des écriture, les autres sont "secondaires" et ne peuvent recevoir que des lectures.
Si le noeud primaire tombe, un autre noeud prendra le relai automatiquement.
Le changement de master se fait via un système d'élection, ou chaque noeud actif du set vote pour élire un nouveau master. Un noeud "arbitre", qui appartient au set mais ne reçoit ou n'envoie aucune données peut être ajouté. L'ajout de cet arbitre est obligatoire dans le cas où le set ne comporte que 2 noeuds : en effet si le master tombe, il ne reste plus qu'un noeud qui votera pour lui même, il aura donc 1 voix sur 2, ce qui est insuffisant pour qu'il soit élu.

On va donc commencer par lancer 2 serveurs mongod + l'arbitre (avec l'option --shardsvr pour préparer la suite), en indiquant qu'ils appartiennent à un Replica Set.

mongod --port=10001 --shardsvr --replSet=replset --logpath=${path}/nodes/node1/logs/node.log --logappend --dbpath=${path}/nodes/node1/data/ --fork --rest
mongod --port=10002 --shardsvr --replSet=replset --logpath=${path}/nodes/node2/logs/node.log --logappend --dbpath=${path}/nodes/node2/data/ --fork --rest
mongod --port=10009 --shardsvr --replSet=replset --logpath=${path}/nodes/arbiter/logs/node.log --logappend --dbpath=${path}/nodes/arbiter/data/ --fork --rest

Un petit coup d'oeil aux logs du premier noeud :
[initandlisten] ******
[websvr] web admin interface listening on port 11001
[initandlisten] connection accepted from 127.0.0.1:53323 #1
[startReplSets] replSet can't get local.system.replset config from self or any seed (EMPTYCONFIG)

Les 3 noeuds sont maintenant lancés, on va pouvoir configurer la réplication
mongo --port 10001
cfg = { _id: "replset", members: [{_id: 0, host: "ubuntu:10001"},{_id: 1, host: "ubuntu:10002"},{_id: 2, host: "ubuntu:10009", arbiterOnly: true }]}
rs.initiate(cfg)

On va pouvoir regarder les logs pour vérifier que tout se passe bien :

tail nodes/node1/logs/node.log

[conn1] replSet replSetInitiate admin command received from client
[conn1] replSet replSetInitiate config object parses ok, 3 members specified
[initandlisten] connection accepted from 127.0.1.1:60557 #3
[conn1] replSet replSetInitiate all members seem up
[conn1] replSet info saving a newer config version to local.system.replset
[conn1] replSet replSetInitiate config now saved locally.  Should come online in about a minute.
[conn1] end connection 127.0.0.1:53512
[rs Manager] replSet can't see a majority, will not try to elect self
[initandlisten] connection accepted from 127.0.1.1:49305 #4
[initandlisten] connection accepted from 127.0.1.1:49306 #5
[ReplSetHealthPollTask] replSet info ubuntu:10002 is now up
[ReplSetHealthPollTask] replSet info ubuntu:10009 is now up
[rs Manager] replSet info electSelf 0
[rs Manager] replSet PRIMARY
[initandlisten] connection accepted from 127.0.1.1:49308 #6

tail nodes/node2/logs/node.log

[startReplSets] replSet got config version 1 from a remote, saving locally
[startReplSets] replSet info saving a newer config version to local.system.replset
[rs Manager] replSet can't see a majority, will not try to elect self
[conn2] replSet info voting yea for 0
[ReplSetHealthPollTask] replSet info ubuntu:10001 is now up
[ReplSetHealthPollTask] replSet info ubuntu:10009 is now up
[rs_sync] replSet initial sync pending
[rs_sync] building new index on { _id: 1 } for local.me
[rs_sync] Buildindex local.me idxNo:0 { name: "_id_", ns: "local.me", key: { _id: 1 } }
[rs_sync] done for 0 records 0.001secs
[initandlisten] connection accepted from 127.0.1.1:37633 #3
[rs_sync] replSet initial sync drop all databases
[rs_sync] dropAllDatabasesExceptLocal 1
[rs_sync] replSet initial sync cloning db: admin
[rs_sync] replSet initial sync query minValid
[rs_sync] replSet initial sync copy+apply oplog
[rs_sync] replSet initial sync finishing up
[rs_sync] replSet set minValid=4c72a31e:1
[rs_sync] building new index on { _id: 1 } for local.replset.minvalid
[rs_sync] Buildindex local.replset.minvalid idxNo:0 { name: "_id_", ns: "local.replset.minvalid", key: { _id: 1 } }
[rs_sync] done for 0 records 0secs
[rs_sync] replSet initial sync done
[rs_sync] replSet SECONDARY

tail nodes/arbiter/logs/node.log

[startReplSets] replSet got config version 1 from a remote, saving locally
[startReplSets] replSet info saving a newer config version to local.system.replset
[initandlisten] connection accepted from 127.0.1.1:51923 #3
[ReplSetHealthPollTask] replSet info ubuntu:10001 is now up
[ReplSetHealthPollTask] replSet info ubuntu:10002 is now up

On peut donc voir que node1 a été élu PRIMARY, node2 est donc SECONDARY, et arbiter ne fait pas parti du groupe.

On va maintenant essayé d'ajouter un noeud a chaud :
mongod --port=10003 --shardsvr --replSet=replset/localhost:10001 --logpath=${path}/nodes/node3/logs/node.log --logappend --dbpath=${path}/nodes/node3/data/ --fork --rest

mongo --port 10001

rs.add("ubuntu:10003")

Encore une fois on jete un coup d'oeil aux logs :

tail nodes/node3/logs/node.log

[startReplSets] replSet got config version 2 from a remote, saving locally
[startReplSets] replSet info saving a newer config version to local.system.replset
[rs Manager] replSet warning total number of votes is even - considering giving one member an extra vote
[rs Manager] replSet can't see a majority, will not try to elect self
[ReplSetHealthPollTask] replSet info ubuntu:10001 is now up
[ReplSetHealthPollTask] replSet info ubuntu:10002 is now up
[ReplSetHealthPollTask] replSet info ubuntu:10009 is now up
[rs_sync] replSet initial sync pending
[rs_sync] building new index on { _id: 1 } for local.me
[rs_sync] Buildindex local.me idxNo:0 { name: "_id_", ns: "local.me", key: { _id: 1 } }
[rs_sync] done for 0 records 0.007secs
[rs_sync] replSet initial sync drop all databases
[rs_sync] dropAllDatabasesExceptLocal 1
[rs_sync] replSet initial sync cloning db: admin
[rs_sync] replSet initial sync query minValid
[rs_sync] replSet initial sync copy+apply oplog
[rs_sync] replSet initial sync finishing up
[rs_sync] replSet set minValid=4c72a3fe:1
[rs_sync] building new index on { _id: 1 } for local.replset.minvalid
[rs_sync] Buildindex local.replset.minvalid idxNo:0 { name: "_id_", ns: "local.replset.minvalid", key: { _id: 1 } }
[rs_sync] done for 0 records 0secs
[rs_sync] replSet initial sync done
[rs_sync] replSet SECONDARY

tail nodes/node2/logs/node.log

[rs Manager] replset msgReceivedNewConfig version: version: 2
[rs Manager] replSet info saving a newer config version to local.system.replset
[rs Manager] replSet replSetReconfig new config saved locally
[rs Manager] replSet warning total number of votes is even - considering giving one member an extra vote
[rs Manager] replSet can't see a majority, will not try to elect self
[ReplSetHealthPollTask] replSet info ubuntu:10001 is now up
[ReplSetHealthPollTask] replSet info ubuntu:10009 is now up
[rs Manager] replSet info electSelf 1
[rs Manager] replSet PRIMARY
[ReplSetHealthPollTask] replSet info ubuntu:10003 is now up

L'arrivée de node3 a donc entrainé un nouveau vote pour élire le master, et c'est maintenant node2 le nouveau PRIMARY.

Si on essaye maintenent de tuer le master, on va voir qu'un nouveau noeud est élu pour prendre son relai

kill -9 6486

[rs_sync] replSet syncThread: 10278 dbclient error communicating with server
[conn2] end connection 127.0.0.1:55409
[ReplSetHealthPollTask] replSet info ubuntu:10002 is now down (or slow to respond)
[rs Manager] replSet info electSelf 2
[rs Manager] replSet PRIMARY

Voilà, c'est aussi simple que ça, on a maintenant 3 serveurs MongoDB répliqués, en master/slave, avec élection automatique du master en cas de problème.

Passons maintenant au sharding

Mise en place du sharding

Maintenant qu'on a 3 serveurs MongoDB répliqués, on va pouvoir y ajouter le partitionnement horizontal, ou sharding.

On va donc passer à une architecture à 9 serveurs, répartis en 3 replica set.
Il y a 3 éléments à mettre en place pour que le sharding fonctionne :
  • Les Shard servers : ou instances mongod, c'est ce que l'on vient de faire.
  • Les Config servers : Serveur de configuration qui va stocker les metadat du shard. La documentation conseille au moins 3 serveurs de configuration dans un environnement de production, on va içi se limiter à un seul.
  • Le mongos : Sert de routeur vers les différents shards.



Nous allons maintenant activer le sharding sur notre replica set. Pour cela, on va commencer par créer nos replica set :
mongod --port=10011 --shardsvr --replSet=rs1 --logpath=${path}/nodes/node1-1/logs/node.log --logappend --dbpath=${path}/nodes/node1-1/data/ --fork --rest
mongod --port=10012 --shardsvr --replSet=rs1 --logpath=${path}/nodes/node1-2/logs/node.log --logappend --dbpath=${path}/nodes/node1-2/data/ --fork --rest
mongod --port=10013 --shardsvr --replSet=rs1 --logpath=${path}/nodes/node1-3/logs/node.log --logappend --dbpath=${path}/nodes/node1-3/data/ --fork --rest

mongo 127.0.0.1:10011/admin
> cfg = { _id: "rs1", members: [{_id: 0, host: "ubuntu:10011"},{_id: 1, host: "ubuntu:10012"},{_id: 2, host: "ubuntu:10013"}]};
> rs.initiate(cfg);

mongod --port=10021 --shardsvr --replSet=rs2 --logpath=${path}/nodes/node2-1/logs/node.log --logappend --dbpath=${path}/nodes/node2-1/data/ --fork --rest
mongod --port=10022 --shardsvr --replSet=rs2 --logpath=${path}/nodes/node2-2/logs/node.log --logappend --dbpath=${path}/nodes/node2-2/data/ --fork --rest
mongod --port=10023 --shardsvr --replSet=rs2 --logpath=${path}/nodes/node2-3/logs/node.log --logappend --dbpath=${path}/nodes/node2-3/data/ --fork --rest

mongo 127.0.0.1:10021/admin
> cfg = { _id: "rs2", members: [{_id: 0, host: "ubuntu:10021"},{_id: 1, host: "ubuntu:10022"},{_id: 2, host: "ubuntu:10023"}]};
> rs.initiate(cfg);

mongod --port=10031 --shardsvr --replSet=rs3 --logpath=${path}/nodes/node3-1/logs/node.log --logappend --dbpath=${path}/nodes/node3-1/data/ --fork --rest
mongod --port=10032 --shardsvr --replSet=rs3 --logpath=${path}/nodes/node3-2/logs/node.log --logappend --dbpath=${path}/nodes/node3-2/data/ --fork --rest
mongod --port=10033 --shardsvr --replSet=rs3 --logpath=${path}/nodes/node3-3/logs/node.log --logappend --dbpath=${path}/nodes/node3-3/data/ --fork --rest

mongo 127.0.0.1:10031/admin
> cfg = { _id: "rs3", members: [{_id: 0, host: "ubuntu:10031"},{_id: 1, host: "ubuntu:10032"},{_id: 2, host: "ubuntu:10033"}]};
> rs.initiate(cfg);

Nos 3 replica set sont prêts, on va pouvoir passer au sharding : en commençant par lancer les serveurs de configuration et le routeur :
mongod --port=11001 --configsvr --logpath=${path}/nodes/config1/logs/node.log --logappend --dbpath=${path}/nodes/config$i/data/ --fork
mongod --port=11002 --configsvr --logpath=${path}/nodes/config2/logs/node.log --logappend --dbpath=${path}/nodes/config$i/data/ --fork
mongod --port=11003 --configsvr --logpath=${path}/nodes/config3/logs/node.log --logappend --dbpath=${path}/nodes/config$i/data/ --fork

mongos --port=9999 --configdb=ubuntu:11001,ubuntu:11002,ubuntu:11003 --logpath=${path}/nodes/mongos/logs/node.log --logappend --fork --chunkSize=1

Par defaut, la taille minimale d'un chunk est de 50Mo, pour des raisons de facilité de test, on passe à 1Mo via l'option chunkSize.

Ensuite, on va activer le sharding sur une collection db.people :
mongo 127.0.0.1:9999/admin
> db.runCommand({addshard: "rs1/ubuntu:10011,ubuntu:10012,ubuntu:10013"})                                                  
{ "shardAdded" : "shard0000", "ok" : 1 }
> db.runCommand({addshard: "rs2/ubuntu:10021,ubuntu:10022,ubuntu:10023"})
{ "shardAdded" : "shard0001", "ok" : 1 }
> db.runCommand({addshard: "rs3/ubuntu:10031,ubuntu:10032,ubuntu:10033"})
{ "shardAdded" : "shard0002", "ok" : 1 }

> db.runCommand({enablesharding: "test"}) 
{ "ok" : 1 }
> db.runCommand({shardcollection: "test.people", key:{"_id":1}})
{ "collectionsharded" : "db.people", "ok" : 1 }

On a donc partitionner la collection "people" de la base "test" sur la clef autogénérée par MongoDb.

> db.printShardingStatus()
--- Sharding Status --- 
  sharding version: { "_id" : 1, "version" : 3 }
  shards:
      {
 "_id" : "shard0000",
 "host" : "rs1/ubuntu:10011,ubuntu:10012,ubuntu:10013"
}
      {
 "_id" : "shard0001",
 "host" : "rs2/ubuntu:10021,ubuntu:10022,ubuntu:10023"
}
      {
 "_id" : "shard0002",
 "host" : "rs3/ubuntu:10031,ubuntu:10032,ubuntu:10033"
}
  databases:
 { "_id" : "admin", "partitioned" : false, "primary" : "config" }
 { "_id" : "db", "partitioned" : true, "primary" : "shard0000" }
  db.people chunks:
   { "_id" : { $minKey : 1 } } -->> { "_id" : { $maxKey : 1 } } on : shard0000 { "t" : 1000, "i" : 0 }

Voila, le sharding est maintenant appliqué , on va pouvoir insérer un peu de données. Dans les stats, on voit bien que pour l'instant, un seul shard est utilisé par notre base, la répartition sur plusieurs shards se faisant uniquement quand le besoin s'en fait ressentir.

mongo 127.0.0.1:9999
> for (var i=1;i<=100000;i++) db.people.save({index:i, data:'Just for filling'})

Si on regarde les stats de la base immédiatement, on va se rendre compte qu'il y a plusieurs chunks, mais qu'ils sont tous sur le même shard, il faut attendre quelques minutes pour que la répartition se fasse entre les shards.

Apres 2 minutes d'insertions, on voit bien que les 3 shards sont utilisés :

mongo 127.0.0.1:9999
> db.people.stats()
{
 "sharded" : true,
 "ns" : "test.people",
 "count" : 89264,
 "size" : 12854112,
 "avgObjSize" : 144.00107546155226,
 "storageSize" : 33546240,
 "nindexes" : 1,
 "nchunks" : 13,
 "shards" : {
  "shard0000" : {
   "ns" : "test.people",
   "count" : 44609,
   "size" : 6423696,
   "avgObjSize" : 144,
   "storageSize" : 11182080,
   "numExtents" : 6,
   "nindexes" : 1,
   "lastExtentSize" : 8388608,
   "paddingFactor" : 1,
   "flags" : 1,
   "totalIndexSize" : 1867776,
   "indexSizes" : {
    "_id_" : 1867776
   },
   "ok" : 1
  },
  "shard0001" : {
   "ns" : "test.people",
   "count" : 20975,
   "size" : 3020448,
   "avgObjSize" : 144.0022884386174,
   "storageSize" : 11182080,
   "numExtents" : 6,
   "nindexes" : 1,
   "lastExtentSize" : 8388608,
   "paddingFactor" : 1,
   "flags" : 1,
   "totalIndexSize" : 876544,
   "indexSizes" : {
    "_id_" : 876544
   },
   "ok" : 1
  },
   "shard0002" : {
   "ns" : "test.people",
   "count" : 26220,
   "size" : 3775728,
   "avgObjSize" : 144.00183066361555,
   "storageSize" : 11182080,
   "numExtents" : 6,
   "nindexes" : 1,
   "lastExtentSize" : 8388608,
   "paddingFactor" : 1,
   "flags" : 1,
   "totalIndexSize" : 1089536,
   "indexSizes" : {
    "_id_" : 1089536
   },
   "ok" : 1
  }
 },
 "ok" : 1
}

Conclusion

En conclusion, par rapport au sharding sans replica set, on ne gagne pas vraiment en performances, on perd en stockage (les shards sont dupliqués sur les membres du set), mais on gagne fortement en fiabilité.

Pour optimiser un peu l'utilisation des serveurs, on pourrait faire se recouper les replica set, c'est un dire qu'un serveur physique ferait partie de plusieurs replica.

Notes sur le failover

Si une instance mongod crashe, ou un serveur de configuration, il n'y aura aucun impact sur la disponibilités des données, et toutes les opérations seront disponibles.
Par contre, si 2 noeuds sur les 3 disponibles d'un même replica set tombent, ce replica set passera en ReadOnly.
De même, un bonne architecture serait d'avoir au moins 4 serveurs par replica set, repartis sur 3 datacenter.

Extension

Pour scaler notre architecture, il suffit donc de créer un nouveau replica set, et de l'ajouter au shard, les données seront réparties automatiquement par MongoDB.

jeudi 19 août 2010

MongoDB 1.6 : réplication, partitionnement horizontal

Encore une bonne nouvelle dans le monde NoSQL, MongoDB 1.6 est sortie il y a peu de temps.

Scalabilité

La plus grosse nouveauté, et la plus attendue, de cette release est sans conteste le sharding, ou partitionnement horizontal et les replica sets.

La combinaison de ces 2 éléments augmentent encore la scalabilité de MongoDB;

On peut donc construire des clusters MongoDB, fortement "horizontally scalable", sans "single points of failure".

Réplication

La réplication était jusqu’alors assurée par une architecture master/slave qui souffrait d’un single point of failure à cause justement de cette notion de master. MongoDB 1.6 introduit la notion de replica set qui est un ensemble de noeuds qui possèderont des replicas d’une même donnée. Une élection de master permet alors de définir un noeud unique qui sera responsable des écritures.

Partitionnement

Le sharding est maintenant production ready dans MongoDB. L’architecture de partitionnement repose sur un ou plusieurs proxy intermédiaire entre les clients et les instances MongoDB.

Pour voir le release note complet, c'est par içi.

Voir un exemple de configuration complète d'un cluster avec sharding et replication.

jeudi 5 août 2010

Redis 2.0 : Mémoire virtuelle, hash et bien plus encore

Les Release Candidate de Redis 2.0 s'enchainent depuis quelques temps, et je ne resiste pas à l'envie de vous en faire partager les alléchantes nouveautés.

Le support de la mémoire virtuelle

La version 1 de Redis nécessitait que toutes les données soient stockées en mémoire, ce qui limitait fortement la taille des datasets. La version 2.0 apporte donc le support de la "Virtual memory".
Pourquoi avoir attendu tout ce temps alors que les OS permettent déjà ce mécanisme :

  • L'OS n'a aucune connaissance des structures utilisées par redis
  • La taille d'un bloc mémoire alloué par l'OS peut être insuffisant pour stocker une valeur, qui peut donc être répartie sur plusieurs blocs mémoires non adjacents
  • La structure des données n'est pas optimisée pour un recherche en RAM
On peut donc optimiser grandement la structure des données sur le disque en connaissant la structure, redis 2 implémente donc sa propre gestion de la mémoire virtuelle :
  • L'espace de stockage est toujours divisé en pages, mais leur taille est libre
  • Toutes les clefs restent en RAM
  • Les valeurs peuvent être soient en RAM, soient sur le disque, et redis connait leur position exacte sur le disque, ce qui limite le IO
  • Redis choisit quelles valeurs sont sur le disque en fonction d'un indice = taille * derniere utilisation. Les valeurs les plus souvent utilisées, dans la mesure où elles ne sont pas trop grosses, restent en RAM.
Allez faire un tour sur le blog de Salvatore Sanfilippo pour des explications complètes.


Transactions
Redis 2.0 amène avec lui les transactions : les commandes MULTI, EXEC et DISCARD permet d'assurer l'atomicité d'une liste de commandes.

Le type hash
Redis 2.0 apporte le support du type hash qui facilite grandement le stockage d'objets. La gestion mémoire du stockage des hash est très bien optimisé, et permet donc de stocker efficacement les objets. Pour plus d'informations, consulter l'article sur le wiki de redis.

Notification
Redis 2.0 possède désormais un système de notifications interne qui permet, par exemple de supprimer automatiquement les clefs dont les valeurs deviennent "vides", cf encore une fois le blog de Salvatore Sanfilippo.

Ca promet donc d'envoyer du lourd comme on dit, je ne peut que vous inviter à tester par vous même, et, pourquoi pas, à remplacer vos serveurs memcache ;).

mercredi 4 août 2010

Moteur de Blog NoSQL - Parte 2 : Redis

Idée

On va reprendre le moteur de blog NoSQL écrit ici, en remplaçant Cassandra par Redis

Présentation du moteur

Comme un petit rappel ne fait jamais de mal, re-voici les fonctionnalités implémentées par notre moteur :
  • Écriture d'un post
  • Ajout de tag aux posts
  • Affichage des derniers posts
  • Affichage des posts liés à un tag

On ajoute donc la gestion de redis à notre architecture :



Redis


Présentation

Redis se situe dans la lignée des bases de données clefs-valeurs, et on peut donc le situer entre Memcached et Cassandra.

La grande force par rapport à Memcached est la persistance des données. En effet, bien que redis travaille sur une hashtable en mémoire, les données sont écrites sur le disque. Il supporte aussi différentes types de valeurs (là où memcached ne stocke que des chaînes de caractères) :
  • Les chaînes de caractères : les opérations disponibles sont SET, GET, INCR, DECR
  • Les listes : ce sont des listes de chaînes triées par ordre d'insertion. Les principales opérations disponibles sont : LPUSH/RPUSH, LPOP/RPOP et LRANGE.
  • Les "sets" : ce sont des ensembles (au sens mathématique) d'objets sur lesquels ont peut effectuer les opérations ensemblistes classiques : UNION, INTERSECTION, DIFFERENCE
  • Les hashes : la valeur stockée est une hashmap, ce qui permet donc de structurer la donnée (JSON like).

Redis supporte aussi nativement la réplication master/slave, ce qui le rend scalable (par rapport à memcached).

Utilisation en php

De nombreux bindings existent pour php : Predis, Rediska en php pur, ou PHPRedis en module.
C'est PHPRedis que j'ai choisi d'utiliser içi, pour des raisons de performances.

Implémentation

Pour le stockage des posts, c'est très simple, on va se servir du type hash : chaque entrée aura pour clef le slug du post, et la valeur sera un mapping de l'objet post :
post:my-first-post : {
  slug => "my-first-post",
  title => "Yeah, my first blog post",
  text => "a little NoSQL stuff"
}

La commande redis pour stocker un post serait donc :
HMSET post:my-first-post slug "my-first-post" title "Yeah, my first blog post" text "a little NoSQL stuff"

Pour le stockage des tags d'un post, on va se servir du type liste :
post:my-first-post:tags : {"nosql", "tech"}

RPUSH post:my-first-post:tags "nosql"
RPUSH post:my-first-post:tags "tech"

Pour pouvoir récupérer la liste des derniers posts publiés, on va se servir de la même astuce que pour Cassandra, et créer un tag fictif qui sera associé à tous les posts.
L'idée est donc de créer, pour chaque tag, une liste, dont la clef sera le tag, est la valeur la liste des clefs des posts associés. En faisant une insertion à gauche des posts à chaque fois, l'ordre chronologique inversé sera automatique :

tagpost:nosql : {"post:my-first-post"}
tagpost:tech : {"an-another-post, "post:my-first-post"}

LPUSH tagpost:nosql post:my-first-post
LPUSH tagpost:tech post:my-first-post
LPUSH tagpost:tech post:an-another-post

LPUSH tagpost:__allposts__ post:my-first-post
LPUSH tagpost:__allposts__ post:an-another-post

Pour récupérer les 10 derniers posts d'un tag, du plus récent au plus ancien, il suffira donc de faire :

LRANGE nosql 0 9

Qui nous renverra les clefs des posts concernés, que l'on devra alors charger :

GET post:my-first-post
LRANGE post:my-first-post:tags 0 9

L'implémentation php de tout ça est très simpl, et tient en moins de 100 lignes de code :
class RedisPostRepository extends RedisRepository implements IPostRepository {

 const KEY_SEP = ':';
 const KEYPREFIX_POST = 'post';
 const KEYPREFIX_TAGPOST = 'tagpost';
 const KEY_ALLPOSTS = 'allposts';

 public function __construct() {
  parent::__construct();
 }

 /**
  * @param string $_sSlug
  * @return string
  */
 private function generatePostKey($_sSlug){
  return self::KEYPREFIX_POST . self::KEY_SEP .$_sSlug;
 }

 /**
  *
  * @param string $_sSlug
  * @return Post
  */
 public function getPost($_sSlug) {
  $aPost = $this->moClient->hGetAll($this->generatePostKey($_sSlug));
  $oPost = new Post();
  $oPost->slug = $aPost['slug'];
  $oPost->title = $aPost['title'];
  $oPost->text = $aPost['text'];
  $oPost->tags = $this->moClient->lGetRange($this->generatePostKey($_sSlug) . self::KEY_SEP . 'tags', 0, 10);
  return $oPost;
 }


 /**
  * @param int $_iCount
  * @param int $_iPage
  * @return array
  */
 public function getLastPosts($_iCount = 5, $_iPage = 1) {
  return $this->getLastPostsByTag(self::KEY_ALLPOSTS, $_iCount, $_iPage);
 }

 /**
  * @param string $_sTag
  * @param int $_iCount
  * @param int $_iPage
  * @return array
  */
 public function getLastPostsByTag($_sTag, $_iCount = 5, $_iPage = 1) {
  $aPosts = array();
  $aSlugs = $this->moClient->lGetRange(self::KEYPREFIX_TAGPOST.  self::KEY_SEP . $_sTag, ($_iPage - 1) * $_iCount,$_iPage * $_iCount - 1);
  foreach($aSlugs as $sSlug) {
   $aPosts[] = $this->getPost($sSlug);
  }
  return $aPosts;
 }

 /**
  * @param Post $_oPost
  * @return boolean
  */
 public function insertPost($_oPost) {
  $sPostKey = $this->generatePostKey($_oPost->slug);
  if ($this->moClient->exists($sPostKey) === false) {
   $this->moClient->hMset($sPostKey, array('slug'=> $_oPost->slug, 'title'=> $_oPost->title, 'text'=> $_oPost->text));
   $this->moClient->delete($sPostKey .  self::KEY_SEP . 'tags');
   foreach ($_oPost->tags as $sTag) {
    $this->moClient->rPush($sPostKey .  self::KEY_SEP . 'tags', $sTag);
    $this->moClient->lPush(self::KEYPREFIX_TAGPOST.  self::KEY_SEP . $sTag, $_oPost->slug);
   }
   $this->moClient->lPush(self::KEYPREFIX_TAGPOST.  self::KEY_SEP . self::KEY_ALLPOSTS, $_oPost->slug);

   return true;
  } else {
   return false;
  }
 }

}

Voilà, notre blog peut maintenant tourner sur une base redis. La prochaine étape pourrait être d'enrichir ses fonctionnalités (commentaires, auteurs, ...), ou d'ajouter un autre moteur de stockage, il me reste encore quelques trucs que j'aimerais tester : MongoDB, CouchDB ou Neo4j par exemple.

Comme toujours, le code est disponible sur github.

lundi 2 août 2010

Moteur de Blog NoSQL - Parte 1 : Cassandra

Idée

L'idée est de réaliser un moteur de blog très minimaliste (création de post et gestion des tags uniquement), en se basant sur les technos NoSQL et PHP.
Le moteur doit pouvoir switcher de repository facilement.

Présentation du moteur

Le moteur de blog remplit donc les fonctionnalités suivantes :
  • Écriture d'un post
  • Ajout de tag aux posts
  • Affichage des derniers posts
  • Affichage des posts liés à un tag
C'est très insuffisant pour un vrai moteur de blog, mais bien assez pour se faire la main sur les technos ciblées.

Le moteur se base sur une architecture MVC elle aussi très minimale.
L'accès aux données se fait à travers des repositories, design pattern issu du DDD (Domain Design Development).



Cassandra


Introduction à Cassandra

Cassandra est certainement le plus populaire des NoSQL. Initié par Facebook, il est actuellement utilisés chez les plus grands du web comme Digg ou Twitter, et est supporté par la fondation apache.
Apache Cassandra est une solution issue de Dynamo d'Amazon pour les notions d'"Eventually consistent" et l'approche Master-Master des requêtes et BigTable de Google pour la modélisation "Column-oriented".

La modélisation "column-oriented"
Le modèle "column-oriented" est plus complexe à appréhender que le modèle Clef/Valeur utilisée par exemple par memcache. Dans un modèle Clef/Valeur, une valeur est identifiée uniquement par une clé et la valeur peut éventuellement être structurée (au format JSON par exemple).

Les bases de données orientées colonnes sont organisées en Column Family. Ce type de regroupement se rapproche du concept de table dans une base de données relationnelle.
Bien qu'elles soient organisées, leur disposition est totalement différente d'une table dans un modèle relationnel. Alors que les colonnes d'une base de données relationnelle sont statiques et présentes pour chaque ligne, celles d'une base de données column oriented sont dynamiques et présentes uniquement pour les lignes concernées.

Elles sont donc pensées pour accueillir un très grand nombre de colonnes (jusqu'à plusieurs millions) pour chaque ligne, ce qui permet donc de stocker facilement des relation 1..N.

Les requêtes possibles sur ces bases sont simples.
  • Requête par clé : Toutes les colonnes de la ligne dont la clef est 42
  • Requête par ensemble de clefs : Toutes les colonnes dont le nom est compris entre "a" et "b" pour la ligne ayant la clef 42
  • Intervalle de colonnes : Toutes les colonnes de la ligne dont la clef est comprise entre 42 et 99

Cette volonté de restreindre le requêtage, à permis de simplifier le design, au profit des performances.

Un peu de vocabulaire Cassandra-ien :
  • Column : Élément de base, tuple composé d’un timestamp (posé par le client), du nom de la colonne et de la valeur de la colonne.
  • SuperColumn :Globalement une structure permettant de stocker une liste dynamique de Columns.
  • ColumnFamily : un ensemble de Columns (Equivalent à une table dans le monde relationnel, sauf que les colonnes peuvent varier d’une ligne à l'autre).
  • KeySpace : Un ensemble de ColumnFamily.

Utilisation
Cassandra utilise Thrift, un framework RPC ayant des bindings pour de nombreux langages, en tant que protocole d'acces.
En ce qui concerne PHP, de nombreuses bibliothèques existe déjà, et permettent d'abstraire Thrift, Pandra est l'une d'entre elles.

Implémentation

La première donnée à stocker est donc le post en lui même, on doit donc créer une ColumnFamily pour la stocker.
La clef pour récupérer un post est son slug, une transformation "url friendly" de son titre.

Si l'on met de côté les tags, on peut donc s'en sortir avec une seule ColumnFamily :


  


Le format des données sera donc

BlogEntries : {
 my-first-post : {
  title: Yeah, my first blog post
  text: a little NoSQL stuff
  tags: nosql,tech
  slug:  my-first-post
 },
 ...
}

Voilà, Cassandra est prêt à recevoir les posts, il ne reste donc plus qu'à écrire le code (grâce à Pandra) permettant de les insérer, et les récupérer. bien sûr.

public function insertPost($_oPost) {
 // Insert post data
 $oCfPost = new PandraColumnFamily(($_oPost->slug, 'NoSQLBlog', 'BlogEntries', PandraColumnFamily::TYPE_STRING);
 $oCfPost = $this->getPostsColumnFamily($_oPost->slug);
 $oCfPost->addColumn('slug')->setValue($_oPost->slug);
 $oCfPost->addColumn('title')->setValue($_oPost->title);
 $oCfPost->addColumn('text')->setValue($_oPost->text);
 $oCfPost->addColumn('tags')->setValue(implode(',', $_oPost->tags));
 $oCfPost->save();
}

public function getPost($_sSlug) {
 $oCfPost = new PandraColumnFamily($_sSlug, 'NoSQLBlog', 'BlogEntries', PandraColumnFamily::TYPE_STRING);
 $oCfPost->load();
 $oPost = new Post();
 $oPost->slug = $oCfPost['slug'];
 $oPost->title = $oCfPost['title'];
 $oPost->text = $oCfPost['text'];
 $oPost->tags = explode(',', $oCfPost['tags']);
 return $oPost;
}

On peut donc maintenant insérer un post, et le récupérer grâce à son "url", mais on a aucun moyen de récupérer la liste des derniers posts.
Une des façons Cassandra-ienne de faire cela, est d'avoir une ColumnFamily avec une méthode de tri "TimeUUIDType". On va en profiter pour implémenter en même temps la récupération par tag. En effet, récupérer les x derniers posts du tag xxx ou tous les derniers posts est sensiblement identique. On a juste à créer un tag fictif auquel seront reliés tous les posts.


  
  


Les relations tag/post ont donc la structure suivante :
TaggedPosts : {
 __allposts__ : {
  timeuuid_1 : my-first-post
  timeuuid_2 : another-post
 }
 nosql : {
  timeuuid_1b : my-first-post
 }
 tech: {
  timeuuid_1c: my-first-post
 }
}

Pour insérer les relations post/tag, on ajoute donc, pour la ligne ayant comme clef le tag, une nouvelle colonne, dont le nom est un timestamp, et la valeur l'url du post.

Pour récupérer les derniers posts d'un tag, on récupère les x dernières colonnes (triés par UUID, donc chronologiquement) ayant pour clef le tag. On boucle sur les valeurs de colonnes (les slugs) et on récupère ensuite le contenu du post comme précédemment.

Le code pour implémenter cela :
public function insertPost($_oPost) {
 // Insert post data
 $oCfPost = new PandraColumnFamily(($_oPost->slug, 'NoSQLBlog', 'BlogEntries', PandraColumnFamily::TYPE_STRING);
 $oCfPost = $this->getPostsColumnFamily($_oPost->slug);
 $oCfPost->addColumn('slug')->setValue($_oPost->slug);
 $oCfPost->addColumn('title')->setValue($_oPost->title);
 $oCfPost->addColumn('text')->setValue($_oPost->text);
 $oCfPost->addColumn('tags')->setValue(implode(',', $_oPost->tags));
 $oCfPost->save();
 // Insert post in fake tag entry
 $oCfTagPost = new PandraColumnFamily('__allposts__', 'NoSQLBlog', 'TaggedPosts', PandraColumnFamily::TYPE_UUID);
 $oCfTagPost->addColumn(UUID::v1())->setValue($_oPost->slug);
 $oCfTagPost->save();

 // associate post with tags
 foreach ($_oPost->tags as $sTag) {
  $oCfTagPost = new PandraColumnFamily($_sSlug, 'NoSQLBlog', 'TaggedPosts', PandraColumnFamily::TYPE_UUID);
  $oCfTagPost->addColumn(UUID::v1())->setValue($_oPost->slug);
  $oCfTagPost->save();
 }
}

public function getLastPosts($_iCount = 5, $_iPage = 1) {
 return $this->getLastPostsByTag('__allposts__', $_iCount, $_iPage);
}

public function getLastPostsByTag($_sTag, $_iCount = 5, $_iPage = 1) {
 $oCfPostTags = $this->getTagsPostsColumnFamily($_sTag);
 $oCfPostTags->limit($_iCount * $_iPage)->load();
 $aPosts = array();
 // Bad way to paging, must use cassandra slices, but not the purpose
 $aPostsResult = array_splice($oCfPostTags->toArray(), ($_iPage - 1) * $_iCount,$_iCount);
 foreach ($aPostsResult as $sSlug) {
  $aPosts[] = $this->getPost($sSlug);
 }
 return $aPosts;
}

Le code est disponible sur github.

Le blog est donc très minimaliste mais fonctionnel, le but étant seulement de tester Cassandra avec un exemple simple.

La structure du code permet de changer simplement de base de données, ce sera d'ailleurs l'objet d'un prochain post, pour porter ce code vers Redis.