News for hackers. Stuff that matters.
Re:My £4 a month server can handle 4.2M requests a day by danudey on Tuesday September 07, 2021 @05:49PM (#28450386)
A lot of that is due to absolutely lousy code.

We had a system management backend at my last company. Loading the users list was unbearably slow; 10+ seconds on a warm cache. Not too terrible, except that most user management tasks required a page reload, so it was just wildly infuriating.

Eventually I took a look at the code for the page, which queried LDAP for user data and the database for permissions data. It did:

    get list of users
    
    foreach user:
        get list of all permissions
        filter down to the ones assigned directly to the user
    
    foreach user:
        get list of all groups
        foreach group:
            get list of all permissions
            filter down to the ones assigned to the group
        filter down to the ones the user has
    
I'm no algorithm genius, but I'm pretty sure O(n^2+n^3) is not an efficient one.

I replaced it with

    get list of all users
    get list of all groups
    get list of all permissions

    <filter accordingly>
Suffice to say, it was a lot more responsive.

Also worth noting was that fetching the user list required shelling out to a command (a python script) which shelled out to a command (ldapsearch), and the whole system was a nightmare. There were also dozens of pages where almost no processing was done in the view, but a bunch of objects with lazy-loaded properties were passed into the template and always used, so when benchmarking you'd get 0.01 seconds for the entire function and then 233 seconds for "return render(...)' because for every single row in the database (dozens or hundreds) the template would access a property that would trigger another SQL call to the backend, rather than just doing one giant "SELECT ALL THE THINGS" and hammering it out that way.

Note that we also weren't using Django's foreign keys support, so we couldn't even tell Django to "fetch everything non-lazily" because it had no idea.

If that app were written right it could have run on a Raspberry Pi 2, but instead there was no amount of cores that could have sped it up.

Re:My £4 a month server can handle 4.2M requests a day by benhoyt on Tuesday September 07, 2021 @07:28PM (#28451096)
Yeah, I see this a lot. I think it's especially easy to introduce this kind of "accidentally quadratic" behaviour using magical ORMs like Django's, where an innocent-looking attribute access like user.groups can trigger a database query ... access user.groups inside a loop and things get bad quickly.

In the case of groups and permissions there's probably only a few of each, so fetching all of them is probably fine. But depending on your data -- say you're fetching comments written by a subset of users, you can tweak the above to use IN filtering, something like this Python-ish code:

  users = select('SELECT id, name FROM users WHERE id IN $1', user_ids)
  comments = select('SELECT user_id, text FROM comments WHERE user_id IN $1', user_ids)
  comments_by_user_id = defaultdict(list)
  for c in comments:
    comments_by_user_id[c.user_id].append(c)
  for u in users:
    u.comments = comments_by_user_id[u.id]
Only two queries, and O(users + comments).

For development, we had a ?queries=1 query parameter you could add to the URL to show the number of SQL queries and their total time at the bottom of the page. Very helpful when trying to optimize this stuff. "Why is this page doing 350 queries totalling 5 seconds? Oops, I must have an N+1 query issue!"

Re:My £4 a month server can handle 4.2M requests a day by simonw on Tuesday September 07, 2021 @07:53PM (#28451266)
Django's prefetch_related mechanism essentially implements the pattern you describe here for you: https://docs.djangoproject.com/en/3.2/ref/models/querysets/#...
Re:My £4 a month server can handle 4.2M requests a day by benhoyt on Tuesday September 07, 2021 @08:19PM (#28451440)
Thanks. Yeah, I think I used that years ago when I first ran into this problem, and it worked well. Whether one uses an ORM or not, one needs to know how to use one's tools. My problem (not just with Django, but with ORMs in general) is how they make bad code look good. Like the following (I don't know Django well anymore, but something like this):

  users = User.objects.all()
  for u in user:
    print(u.name, len(u.comments))
To someone who doesn't know the ORM (or Python) well, u.comments looks cheap ("good"), but it's actually doing a db query under the hood each time around the loop ("bad"). Not to mention it's fetching all the comments when we're only using their count. Whereas if you did that in a more direct-SQL way:

  users = select('SELECT id, name FROM users WHERE id IN $1', user_ids)
  for u in user:
    num_comments = get('SELECT COUNT(*) FROM comments WHERE user_id = $1', u.id)
    print(u.name, num_comments)
This makes the bad pattern look bad. "Oops, I'm doing an SQL query every loop!"

The other thing I don't like about (most) ORMs is they fetch all the columns by default, even if the code only uses one or two of them. I know most ORMs provide a way to explicitly specify the columns you want, but the easy/simple default is to fetch them all.

I get the value ORMs provide: save a lot of boilerplate and give you nice classes with methods for your tables. I wonder if there's a middle ground where you couldn't do obviously bad things with the ORM without explicitly opting into them. Or even just a heuristic mode for development where it yelled loudly if it detected what looked like an N+1 issue or other query inside a loop.

Re:My £4 a month server can handle 4.2M requests a day by simonw on Tuesday September 07, 2021 @08:35PM (#28451553)
I agree that this stuff can definitely be handled better.

https://github.com/django-query-profiler/django-query-profil... has a neat option for detecting likely N+1 queries. I usually use the Django Debug Toolbar for this.

Django's ".only()" method lets you specify just the columns you want to retrieve - with the downside that any additional property access can trigger another SQL query. I thought I'd seen code somewhere that can turn those into errors but I'm failing to dig it up again now.

I've used the assertNumQueries() assertion in tests to guard against future changes that accidentally increase the number of queries being made without me intending that.

The points you raise are valid, but there are various levels of mitigations for them. Always room for improvement though!

Re:My £4 a month server can handle 4.2M requests a day by benhoyt on Tuesday September 07, 2021 @08:41PM (#28451591)
Thanks for the thoughtful responses and the link -- Django Query Profiler looks nice!
Re:My £4 a month server can handle 4.2M requests a day by cuu508 on Tuesday September 07, 2021 @09:49PM (#28452057)

    users = User.objects.all()
    for u in user:
        print(u.name, len(u.comments))
This is fine if you are working with a small data set. It is inefficient, but if it's quick enough, readability trumps efficiency IMHO.

Django ORM has a succinct way of doing the "SELECT COUNT(*)" pattern:

    users = User.objects.all()
    for u in user:
        print(u.name, u.comments.count())
And you can use query annotations to get rid of the N+1 query issue altogether:

    users = User.objects.annotate(n_comments=Count("comments"))
    for u in user:
        print(u.name, u.n_comments)
Re:My £4 a month server can handle 4.2M requests a day by tinus_hn on Wednesday September 08, 2021 @03:42AM (#28454276)
> This is fine if you are working with a small data set. It is inefficient, but if it's quick enough, readability trumps efficiency IMHO.

And this is how you end up with the problems the parent is describing. During testing and when you setup the system you always have a small dataset so it appears to work fine. But when it’s real work the system collapses.

Re:My £4 a month server can handle 4.2M requests a day by danjac on Wednesday September 08, 2021 @01:10AM (#28453283)
A good habit when writing unit tests is to use assertNumQueries especially with views. It's very easy even for an experienced developer to inadvertently add an extra query per row: for example if you have a query using only() to restrict the columns you return, and in a template you refer to another column not covered by only(), Django will do another query to fetch that column value (not 100% if that's still the case, but that was the behavior last time I looked). The developer might be just fixing what they think is a template issue and won't know they've just stepped on a performance landmine.
Re:My £4 a month server can handle 4.2M requests a day by benhoyt on Wednesday September 08, 2021 @01:17AM (#28453321)
Sounds like a good habit, yeah. But the fact that an attribute access in a template (which might be quite far removed from the view code) can kick off a db query is an indication there's something a bit too magical about the design. Often front-end developers without much knowledge of the data model or the SQL schema are writing templates, so this is a bit of a foot-gun.
Re:My £4 a month server can handle 4.2M requests a day by simonw on Wednesday September 08, 2021 @04:22PM (#28462539)
I've wanted a Django mechanism in the past that says "any SQL query triggered from a template is a hard error" - that way you could at least prevent accidental template edits from adding more SQL load.
Re:My £4 a month server can handle 4.2M requests a day by jbboehr on Wednesday September 08, 2021 @02:30AM (#28453769)
My SQL is a bit rusty, but, to reduce the queries even further, I think you can do:

> SELECT user_id, COUNT(*) FROM comments WHERE user_id IN $1 GROUP BY user_id

Re:My £4 a month server can handle 4.2M requests a day by bradstewart on Wednesday September 08, 2021 @09:43AM (#28456837)
> Or even just a heuristic mode for development where it yelled loudly if it detected what looked like an N+1 issue or other query inside a loop.

Fwiw, ActiveRecord with the Bullet gem does exactly that. I'd guess there's an equivalent for Django.

Re:My £4 a month server can handle 4.2M requests a day by petters on Wednesday September 08, 2021 @03:26AM (#28454168)
select_related and prefetch_related are absolutely essential when coding Django.
Re:My £4 a month server can handle 4.2M requests a day by theptip on Wednesday September 08, 2021 @03:37AM (#28454248)
I’m coming around to considering Django-seal to be mandatory for non-trivial applications. It will “seal” a query set so that you can’t make accidental DB lookups after the initial fetch. That way you can be more confident that you are doing the right joins in the initial query, and you are safe from the dreaded O(N) ORM issue.

SqlAlchemy has this as part of the ORM, it should really be part of Django IMO.

Re:My £4 a month server can handle 4.2M requests a day by c17r on Wednesday September 08, 2021 @11:30AM (#28458551)
I've never heard of django-seal before, thanks!
Re:My £4 a month server can handle 4.2M requests a day by kbenson on Tuesday September 07, 2021 @11:23PM (#28452654)
When I was doing a lot of DBIx::Class work, I contemplated writing a plugin to allow me to lock and and unlock automatic relation fetches and die (throw) if one was attempted when locked. I would carefully prefetch my queries (auto-fill relations from a larger joined query) and if something changed or there was an edge case I missed that caused fetching deep in a loop, it might kill performance, but be hard to track down if not given the best directions on what caused it other than "this is slow".

It's one of those things that in the long run would have been much more time effective to write, but the debugging never quite took long enough each time to make me take the time.

Re:My £4 a month server can handle 4.2M requests a day by mschuster91 on Wednesday September 08, 2021 @05:35AM (#28454944)
> For development, we had a ?queries=1 query parameter you could add to the URL to show the number of SQL queries and their total time at the bottom of the page. Very helpful when trying to optimize this stuff. "Why is this page doing 350 queries totalling 5 seconds? Oops, I must have an N+1 query issue!"

I'm so glad Symfony (PHP framework) has a built-in profiler and analysis tooling there...

Re:My £4 a month server can handle 4.2M requests a day by tra3 on Tuesday September 07, 2021 @06:37PM (#28450749)
This is an example of N+1 problem [0]. It should be a FizzBuzz for anyone doing any CRUD apps.

[0]: https://stackoverflow.com/questions/97197/what-is-the-n1-sel...

Re:My £4 a month server can handle 4.2M requests a day by JJMcJ on Tuesday September 07, 2021 @06:32PM (#28450712)
Your pattern is quite powerful: get data from several sources and do the rearranging on the client (which might be a web server), instead of multiple interactions for each data item.

For SQL you can also do a stored procedure. Sometimes that works well if you are good at your DBMS's procedure language and the schema is good.

Re:My £4 a month server can handle 4.2M requests a day by spfzero on Tuesday September 07, 2021 @11:51PM (#28452820)
Though stored procs will always execute on the server that is probably already the system bottleneck.
Re:My £4 a month server can handle 4.2M requests a day by funnybeam on Wednesday September 08, 2021 @02:56AM (#28453946)
Sending multiple queries from your web server will likely put more load on the database server than using a stored procedure.

With either technique you are still pulling all the data you need from the DB but with multiple queries instead of a stored procedure you are usually pulling more data than you need with each query and then dropping any rows or fields you’re not interested in. Together with multiple calls over the network to the DB server and (often) multiple SQL connection setups this is much worse for performance on both the web and database servers

Re:My £4 a month server can handle 4.2M requests a day by bigbizisverywyz on Wednesday September 08, 2021 @08:07AM (#28455869)
>Sending multiple queries from your web server will likely put more load on the database server than using a stored procedure.

Lots of people seem to not realize that db roundtrips are expensive, and should be avoided whenever possible.

One of the best illustrations of this I've found is in Transaction Processing book by Jim Gray and Andreas Reuters where they illustrate the relative cost of getting data from CPU vs CPU cache vs RAM vs cross host query.

Re:My £4 a month server can handle 4.2M requests a day by JJMcJ on Wednesday September 08, 2021 @12:51PM (#28459728)
Except for open and writing to files, it's about the heaviest action on a backend server.
Re:My £4 a month server can handle 4.2M requests a day by chenxiaolong on Tuesday September 07, 2021 @10:02PM (#28452140)
I had to fix a similar thing in our internal password reset email sender last year. The code was doing something like:

    for each user in (get_freeipa_users | grep_attribute uid):
        email = (get_freeipa_users | client_side_find user | grep_attribute email)
        last_change = (get_freeipa_users | client_side_find user | grep_attribute krblastpwdchange)
        expiration = (get_freeipa_users | client_side_find user | grep_attribute krbpasswordexpiration)

        # Some slightly incorrect date math...

        send_email
I changed it to a single LDAP query for every user that requests only the needed attributes. It cut that Jenkins job's runtime from 45 minutes to 0.2 seconds.
Re:My £4 a month server can handle 4.2M requests a day by Thaxll on Tuesday September 07, 2021 @07:35PM (#28451141)
Filter in app is rarely the right solution, your data should be organized in a way that you can get what you need in a single query. Reasons:

- it's memory efficient

- it's atomic

- it's faster

Also doesn't LDAP support filtering in query?

Re:My £4 a month server can handle 4.2M requests a day by zetsurin on Tuesday September 07, 2021 @07:47PM (#28451225)
Given the context, it probably took the poster very little time to implement that fix, without digging into ldapsearch. With massive speedup, for their likely smallish ldap install. Seems like not a bad call at all.
Re:My £4 a month server can handle 4.2M requests a day by VintageCool on Tuesday September 07, 2021 @07:29PM (#28451101)
I did some work to improve performance on a dashboard several years ago. The way the statistics were queried was generally terrible, so I spent some time setting up aggregations and cleaning that up, but then... the performance was still terrible.

It turned out that the dashboard had been built on top of Wordpress. The way that it checked if the user had permission to access the dashboard was to query all users, join the meta table which held the permission as a serialized object, run a full text search to check which users had permission to access this page, and return the list of all users with permission to access the page. Then, it checked if the current user was in that list.

I switched it to only check permissions for the current user, and the page loaded instantaneously.

Re:My £4 a month server can handle 4.2M requests a day by dimitrios1 on Tuesday September 07, 2021 @07:42PM (#28451192)
If I look at traces of all the service calls at my company within our microservices environment, the "meat" of each service is a fraction of the latency -- the part that's actually fetching the data from a database, or running an intense calculation. Often times its between 20-40ms

Everything else are network hops and what I call "distributed clutter", including authorizing via a third party like Auth0 multiple times for machine-to-machine token (because "zero trust"!), multiple parameter store calls, hitting a dcache, if interacting with a serverless function, cold starts, API gateway latency, etc...

So for the meat of a 20-40 ms call, we get about a 400ms-2s backend response time.

Then if you are loading a front end SPA with javascript...fugetaboutit it

But DevOps will say "but my managed services and infinite scalability!"

Re:My £4 a month server can handle 4.2M requests a day by scns on Wednesday September 08, 2021 @06:11AM (#28455131)
Re:My £4 a month server can handle 4.2M requests a day by dimitrios1 on Wednesday September 08, 2021 @09:09AM (#28456429)
Exactly like that.
Re:My £4 a month server can handle 4.2M requests a day by unclebucknasty on Tuesday September 07, 2021 @07:45PM (#28451214)
Not sure what the exact use case was (i.e. the output of the filtering) but—from reading the first algo—seems to be something to do with determining group membership and permissions for a user.

In that case, was there a reason joins couldn't be used? As it still seems pretty wasteful (and less performant) to load all of this data in memory and post-process; whereas a well-indexed database could possibly do it faster and with less-memory usage.

Re:My £4 a month server can handle 4.2M requests a day by hnbad on Wednesday September 08, 2021 @04:34AM (#28454580)
In defense of whoever wrote the original code: it probably would have been reasonably fast if it had been a database query with proper indexes. The filters would have whittled the selection down to only the relevant data, whereas returning basically three entire tables of data to then throw away most of it would have been extremely inefficient.

The mistake of course was not thinking about why this approach is faster in a database query and that it doesn't work that way when you already need to get all the data out of LDAP to do anything with it.

Re:My £4 a month server can handle 4.2M requests a day by onlyrealcuzzo on Tuesday September 07, 2021 @07:27PM (#28451080)
Yeah - you likely want to do this a single simple query - which you can optimize if necessary. an O(N+1) query is bad. An O(N^2) query is something I have rarely seen. Congrats!
Re:My £4 a month server can handle 4.2M requests a day by codebolt on Wednesday September 08, 2021 @03:35AM (#28454237)
I'd also add that groups and permissions are probably constant and can be cached with a long timeout.
Re:My £4 a month server can handle 4.2M requests a day by afrodc_ on Tuesday September 07, 2021 @09:38PM (#28451986)
Is there a reason you shelled out with a subprocess versus using a library like ldap3? Just curious
Re:My £4 a month server can handle 4.2M requests a day by IfOnlyYouKnew on Tuesday September 07, 2021 @06:37PM (#28450745)
I believe the parent's point was that code tends to be faster than what people expect, not slower.
Re:My £4 a month server can handle 4.2M requests a day by namenotrequired on Tuesday September 07, 2021 @06:49PM (#28450834)
I think the child's point is that people expect code to be slower than it is because they have seen code be slow far more than necessary.