angle-uparrow-clockwisearrow-counterclockwisearrow-down-uparrow-leftatcalendarcard-listchatcheckenvelopefolderhouseinfo-circlepencilpeoplepersonperson-fillperson-plusphoneplusquestion-circlesearchtagtrashx

Vermindering van de responstijden van een Flask SQLAlchemy website

Verminder de uitstoot van kooldioxide (CO2) van uw web app met behulp van query resultaat caching en template caching.

29 augustus 2019
post main image
unsplash.com/@agkdesign

Objecten, het is leuk om er een applicatie mee te bouwen, maar het heeft één groot nadeel: het kan erg traag zijn door de extra CPU-cycli en al het extra gebruikte geheugen. De vertraging wordt natuurlijk vooral veroorzaakt door de extra mapperlagen en extra data.

Zou u zich zorgen moeten maken over de prestaties? Ja! Het administratieve gedeelte van een applicatie hoeft niet erg snel te zijn, maar de voorpagina's, de pagina's die bezoekers kunnen openen moeten zo snel mogelijk geladen worden. Ik was geschokt door het enorme verschil tussen een ORM vraag en een ruwe SQLAlchemy vraag. Al deze extra CPU-cycli, al deze extra -cycli, al dit extra geheugen gebruikt. En ook niet erg milieuvriendelijk. Ik wil niet dat mijn website verantwoordelijk is voor de klimaatverandering.... ;-)

Dan is er ook nog Jinja templateren. Ik zie tijden van 30 milliseconden voor het bouwen van de blog posts voor de homepage. Waarom? Hoe is dit mogelijk? Ik heb wel wat debugging gedaan, maar heb de oorzaak nog steeds niet gevonden, maar bij de enscenering en productie, beide met behulp van Gunicorn, lijkt het erop dat de Jinja vertragingen veel kleiner zijn.

Wat we kunnen doen om de tijd die nodig is voor het genereren van pagina's te verminderen

We hebben twee opties:

1. Verlaat het object / de ORM modus

- Sommige ORM query's omzetten naar ruwe SQL queries
- Bouw enkele Jinja sjabloononderdelen in Python

Profs:
- Zeer snel, ook bij de eerste treffer.

Nadelen:
- Zeer veel werk

2. Toepassingsniveau caching toevoegen

- Cache het resultaat van enkele ORM queries
- Cache het resultaat van sommige sjabloononderdelen

Profs:
- Gemakkelijk toe te voegen, niet veel codewijzigingen

Nadelen:
- Eerste treffer blijft traag als de cache-tijden verlopen zijn.

Wat is het nut van een cache op applicatieniveau?

Databases doen ook aan caching, dus waarom caching eigenlijk? Door gebruik te maken van query resultaat caching op applicatieniveau vermijden we toegang tot de database binnen een bepaalde cache tijd. Veel pagina's op een website zijn statisch in die zin dat ze slechts af en toe veranderen. Als er slechts een deel van een pagina statisch is, dan kunnen we alleen dat deel cachen.

Caching is niet voor de eerste bezoeker, maar dient zijn doel wanneer veel bezoekers de website bezoeken. De eerste bezoeker krijgt dus een responstijd van 200 milliseconden, maar de volgende bezoekers krijgen een responstijd van 30 milliseconden, als ze de pagina's binnen de cache-tijd bezoeken.

Er is hier een probleem en dat is de tijd die de zoekmachines registreren. Google kan uw website lager waarderen vanwege de eerste keer (geen cache) bezoeken. Er is niet echt veel dat we kunnen doen. Een optie zou zijn om bepaalde artikelen te prefetcheren, maar dit is een complexere opdracht.

Het implementeren van een cache

Caching is niet echt moeilijk om onszelf te coderen. We kunnen hier ook Flask-Caching gebruiken. Ik hou van de Caching Jinja2 Snippets functie:

{% cache [timeout [,[key1, [key2, ...]]]] %}
     ...
{% endcache %}

Maar zoals ik al eerder zei, wil ik zoveel mogelijk zelf doen, dat is hoe ik leer Python, FlaskIn het verleden heb ik caching geïmplementeerd voor een PHP website met behulp van gegevens uit een extern systeem dat zeer effectief is gebleken, dus laten we dit nu doen in Python.

Het cache-object

Aangezien deze website app draait op een Linux systeem dat volgens mij niet nodig is voor Redis of Memcached, kunnen we het bestandssysteem gebruiken voor caching. Het Linux bestandssysteem is snel genoeg. Ik cache alleen een aantal query resultaten en Jinja sjabloon onderdelen. Hoe weet je wat je moet cachen? Dit is eenvoudig, maat, maat, maat, maat, zie ook de post over Profilering. Ik heb timers toegevoegd aan de home page met blog posts, aan de blog post pagina. Vervolgens heb ik de queries en sjabloononderdelen geselecteerd die de meeste tijd zouden besparen.

De implementatie van het cache-object lijkt erg op de implementatie van de instellingen, zie een eerdere post. Leuk Flask is dat we een object kunnen maken tijdens het maken van een app en het kunnen gebruiken tijdens het draaien van de app.

Hieronder staat de code van de caching klasse. Het heeft twee belangrijke functies: dumpen, om gegevens in de cache op te slaan, en laden, om gegevens uit de cache te halen. Beide gebruiken een cache_item_id die het item in de cache identificeert. Pickl wordt gebruikt om objecten naar een bestand te schrijven en objecten uit een bestand te lezen. Op het moment van dumpen kunnen we ook andere parameters doorgeven, zoals niet-standaard cache tijd en een cache_item_type. Dit type is niet nodig, ik gebruik het om een andere subdirectory te selecteren, dus alle resultaten van de query zijn in directory A en alle render_template resultaten zijn in directory B. Gemakkelijk voor het debuggen.

class AppCache:

    def __init__(self, app=None):
        self.app = app
        if app is not None:
            self.init_app(app)

    def init_app(self, app):
        self.cache_item_id2cache_details = {}

    def get_subdir_and_seconds(self, cache_item_type, cache_item_seconds):
        ...
        return cache_subdir, cache_seconds

    def dump(self, cache_item_id, cache_item_data, cache_item_type=None , cache_item_seconds=None):

        cache_subdir, cache_seconds = self.get_subdir_and_seconds(cache_item_type, cache_item_seconds)

        cache_file = os.path.join('cache', cache_subdir, cache_item_id + '.pickle')

        temp_name = next(tempfile._get_candidate_names())
        cache_file_tmp = os.path.join('cache', cache_subdir, cache_item_id + '_' + temp_name + '.pickle')

        try:
            fh = open(cache_file_tmp, 'wb')
            pickle.dump(cache_item_data, fh)
            fh.close()
            # rename is atomic
            os.rename(cache_file_tmp, cache_file)
        except:
            return False

        # no errors so store
        if cache_item_id not in self.cache_item_id2cache_details:
            self.cache_item_id2cache_details[cache_item_id] = { 'cache_file': cache_file, 'cache_seconds': cache_seconds } 
        else:
            self.cache_item_id2cache_details[cache_item_id]['cache_file'] = cache_file
            self.cache_item_id2cache_details[cache_item_id]['cache_seconds'] = cache_seconds
        return True

    def load(self, cache_item_id):
        if cache_item_id in self.cache_item_id2cache_details:
            cache_file = self.cache_item_id2cache_details[cache_item_id]['cache_file']
            cache_seconds = self.cache_item_id2cache_details[cache_item_id]['cache_seconds']

            try:
                if os.path.isfile(cache_file):
                    mtime = os.path.getmtime(cache_file)
                    if (mtime + cache_seconds) > time.time():
                        fh = open(cache_file, 'rb')
                        return True, pickle.load(fh) 
            except:
                pass

        return False, None

Net als bij de vorige instellingenklasse, installeer ik het object in de create_app functie:

    app.app_cache = AppCache(app)

Nu kunnen we het in andere weergaven gebruiken door .app_cache.dump() en current_app.app_cache.load() aan te roepen current_app:

    cache_item_id = 'some_cache_item_id'
    hit, data = current_app.app_cache.load(cache_item_id)
    if not hit:
        # get the data somewhere
        ...
        # cache it
        current_app.app_cache.dump(cache_item_id, data)
    ...
    # use data

Caching ORM query resultaten

We moeten ons beperken tot vragen die het systeem echt vertragen. Een goede kandidaat is de vraag die de blog posts voor de homepage krijgt. De startpagina kan in verschillende talen worden weergegeven en de blogberichten zijn onderverdeeld in pagina's met een maximum van 10 blogberichten op een pagina. Dit betekent dat de cache_item_id zowel de taal als het paginanummer moet weergeven:

    cache_item_id = 'home_page_blog_posts_' + lang_code + '_p' + str(page_number)
    hit, home_page_blog_posts = current_app.app_cache.load(cache_item_id)
    if not hit:
        home_page_blog_posts = get_home_page_blog_posts(lang_code, page_number)
        # cache it
        current_app.app_cache.dump(cache_item_id, home_page_blog_posts)

Caching Jinja render_template resultaten

In Flask deze handleiding genereren render_template we de HTML voor een pagina. Voor de homepage heb ik een index.html
met een for-loop om de blogberichten af te drukken. Om de prestaties te verbeteren kunnen we precies dit onderdeel in de cache plaatsen. Om dit te doen voegen we een andere render_template functie toe. Eerst renderen we de blog_posts, het resultaat heet rendered_blog_posts. In de tweede render_template gebruiken we de gerenderde blog posts.

    cache_item_id = 'homepage_blog_posts_' + lang_code + '_p' + str(page_number)
    hit, rendered_blog_posts = current_app.app_cache.load(cache_item_id)
    if not hit:
        rendered_blog_posts = render_template(
            'pages/index_blog_posts.html', 
            page_title=page_title,
            ...
            home_page_blog_posts=home_page_blog_posts)

        # cache it
        current_app.app_cache.dump(cache_item_id, rendered_blog_posts)

    ...
    return render_template(
        'pages/index_using_rendered_blog_posts.html', 
        page_title=page_title,
        rendered_blog_posts=rendered_blog_posts)

Natuurlijk moeten we het template bestand index.html opsplitsen in twee bestanden, ik noemde ze index_blog_posts.html en
index_user_rendered_blog_posts.html. Eerst kopiëren we index.html naar deze twee bestanden, daarna bewerken we de bestanden. Zoals je je kunt voorstellen ziet de index_blog_posts.html er zo uit:

    {% if blog_posts %}
        {% for blog_post in blog_posts %}
            <div class="row">
                <div class="col-12 p-0">

                    <h1><a href="{{ url_for('pages.blog_post_view', slug=blog_post.slug) }}">{{ blog_post.title }}</a></h1>

                </div>
            </div>
            ...
        {% endfor %}

        {{ list_page_pagination(pagination) if pagination }}

    {% endif %}

en de index_user_rendered_blog_posts.html ziet er zo uit:

{% extends "content_right_column.html" %}

{% block content %}

    {% if rendered_blog_posts %}
        {{ rendered_blog_posts|safe }}
    {% endif %}
                
{% endblock %}

Samenvatting

Bovenstaande veranderingen hebben de - tweede hit - home page generation tijd teruggebracht van 150 milliseconden naar 30 milliseconden op mijn lokale PC. Caching is een zeer effectieve manier om de responstijden te verkorten. In feite geloof ik dat de meeste Flask SQLAlchemy websites niet zonder kunnen. De codewijzigingen zijn minimaal en we hoeven alleen maar caching toe te voegen waar het er echt toe doet. Als we echt behoefte hebben aan een eerste hit, kunnen we onze 'langzame' ORM zoekopdrachten altijd omzetten naar ruwe SQL. Tot die tijd richten we ons op functionaliteit, niet op tijdrovende optimalisaties.

Links / credits

Flask Extension Development
https://flask.palletsprojects.com/en/1.1.x/extensiondev/

Flask-Caching
https://pythonhosted.org/Flask-Caching/

pickle — Python object serialization
https://docs.python.org/3/library/pickle.html

Laat een reactie achter

Reageer anoniem of log in om commentaar te geven.

Opmerkingen

Laat een antwoord achter

Antwoord anoniem of log in om te antwoorden.