Cache für Sinatra
Sunday, 28. October 2012
Ich habe eine ziemlich funktionsfähige Blogsoftware mit Sinatra gebaut. Ein absolutes Pflichtfeature war ein Cache, also dass einmal generierte Seiten irgendwo gespeichert und nachher ohne Neuerstellung genutzt werden können. Caches bei Blogsoftware sind unumgänglich, werden Blogs doch viel öfter gelesen als beschrieben. In meinem Fall ist nicht memcached oder ähnliches der dafür geeignete Ort, sondern die auch für alles andere genutzte SQLite-Datenbank.
Grundlage
Ein Cache ist im einfachsten Fall ein einfacher Key-Value-Speicher. Soll er automatisch nicht unbegrenzt speichern, braucht er irgendein Mittel zur automatischen Cache-Invalidierung, also wird noch ein Time-To-Live-Feld (ttl) eingebaut, das automatisch befüllt wird. Insgesamt:
@db.execute "CREATE TABLE IF NOT EXISTS cache( key TEXT PRIMARY KEY, value TEXT, ttl INTEGER DEFAULT (strftime('%s','now') + 604800) );"
Die Funktionen zum Befüllen und Holen von Werten:
def cache(key, value) begin @db.execute("INSERT OR IGNORE INTO cache(key, value) VALUES(?, ?)", key, value) @db.execute("UPDATE cache SET value = ?, ttl = (strftime('%s','now') + 604800) WHERE key = ?", value, key) rescue => error puts error end end def getCache(key) begin return @db.execute("SELECT value FROM cache WHERE key = ? AND ttl > strftime('%s','now') LIMIT 1;", key)[0]['value'] rescue => error puts error end end
In der restlichen Sinatraanwendung kann man nun alle GET-Requests durch den Cache laufen lassen: Nur wenn der keinen Inhalt hat wird der eigentliche Code ausgeführt und dessen Ergebnis dann gecached. Dafür ideal geeignet sind die before() und after() Funktionen von Sinatra, die vor und nach jedem Seitenaufruf ausgeführt werden.
Vor jedem Aufruf wird der Cache geprüft:
before do @cacheContent = Database.new.getCache(request.path_info) end after do Database.new.cache("request.path_info", body) end
In den eigentlichen Funktionen muss nun vor der eigentlichen Codeausführung auf den Cache reagiert werden:
get '/' do if @cacheContent != nil return @cacheContent end ... end
Und, wichtig: Damit body während after() schon gesetzt ist, muss er explizit gesetzt werden:
get '/' do if @cacheContent != nil return @cacheContent end ... # do stuff body erb :index # instead of erb :index end
Schon steht ein Cache.
Optimierungen
So wie oben reicht das natürlich noch nicht. Zum einen darf natürlich nicht jeder Request gecached werden, sondern nur GET-Requests:
before do @cacheContent = nil if request.request_method == "GET" @cacheContent = Database.new.getCache(request.path_info) end end after do if @cacheContent == nil && request.request_method == "GET" Database.new.cache(request.path_info, body) end end
Außerdem soll der Cache invalidiert werden, wenn mit einem POST-Request schreibend der Bloginhalt verändert wurde, sei es durch einen Kommentar oder einen neuen Artikel. Je granularer man hier arbeitet, desto besser wird der Cache funktionieren, aber im Zweifel muss eben der ganze Cache invalidiert werden. Entweder man führt dazu einen Cache-Instanzvariable ein, die dann verändert wird, sodass der Cacheinhalt nicht mehr gefunden wird. Oder man löscht einfach den gesamten Cacheinhalt:
def invalidateCache() begin return @db.execute("DELETE FROM cache WHERE key LIKE '/%'") rescue => error puts error end end
Da der Cache in meinem Fall noch andere Elemente beinhaltet, wird hier das Löschen über /% auf Elemente mit führendem /, wie eben alle request.path_info-Keys aussehen, eingeschränkt.
Auf der Sinatra-Ebene wird das bei jedem POST ausgelöst (alternativ sicherer auf Datenbankebene bei jedem relevanten Write):
after do if @cacheContent == nil && request.request_method == "GET" Database.new.cache(request.path_info, body) else if request.request_method == "POST" Database.new.invalidateCache end end end
Ganz reicht das noch nicht. In meinem Blog gibt es zwei Nutzergruppen, die unterschiedliche Seiten präsentiert bekommen: Einfache Besucher und solche mit Schreibrechten, Admins. Derzeit würden noch beide die gleichen Seiten gecached bekommen, je nachdem, welche Gruppe die Seite zuerst aufruft. Um da zu unterscheiden wird am einfachsten der key angepasst:
before do @cacheContent = nil if request.request_method == "GET" @cacheContent = Database.new.getCache("#{request.path_info}#{isAdmin?}") end end after do if @cacheContent == nil && request.request_method == "GET" Database.new.cache("#{request.path_info}#{isAdmin?}", body) else if request.request_method == "POST" Database.new.invalidateCache end end end
Das ist dann auch die derzeit finale Version.
Performance
Bringt das was? Benchmarken soll man zwar nie auf dem PC, auf dem der Server läuft, hier aber geht es nur um den Unterschied, daher halte ich das für legitim.
ab -n 1000 -c 5 http://localhost:4567:
Ohne Cache:
Requests per second: 23.58 [#/sec] (mean) Time per request: 212.042 [ms] (mean) Time per request: 42.408 [ms] (mean, across all concurrent requests) Transfer rate: 131.44 [Kbytes/sec] received
Mit Cache:
Requests per second: 124.77 [#/sec] (mean) Time per request: 40.074 [ms] (mean) Time per request: 8.015 [ms] (mean, across all concurrent requests) Transfer rate: 695.50 [Kbytes/sec] received
Grafisch:
Bei einem simulierten Benutzeransturm hilft der Cache definitiv (und mit 500% weit mehr als ich erhofft hatte).
Ohne Last sinkt die Wartezeit auf den Server, bei gefülltem Cache und laut Chrome, von 54ms auf 27ms. Auch nicht schlecht.