Solving the 1+N Query Problem

(acadia.engineering)

30 points | by wheatBread 7 hours ago ago

19 comments

  • red_admiral 3 hours ago

    This is what you get when you let your ORM loose on the database without understanding JOINs. Especially, the bit where something like 'book.author.name' that looks like a simple field dereference actually is a method call on an ORM proxy object (book), via python's __getattr__ or similar, that fires off a new query if the data you want is not loaded yet.

    Some ORMs let you specify the extent of the data that you want, like Hibernate has its own Hibernate Query Language.

    At some point you are better off just writing SQL yourself, though. Even without join problems, if you ask an ORM to get the person with user id 123 and all you want is their name, the ORM cannot know that unless to tell it, and so you end up with a 'SELECT *' type query.

    • ddorian43 2 minutes ago

      Or set lazy loadin to "raise" in the relationships and get exceptions if you dont explicitly join.

    • tehlike 2 hours ago

      This is true but not super true in case of linq and related providers like efcore. Even nhibernate linq would do this.

    • seki285 41 minutes ago

      You should write a raw SQL query to grab just a user's name only when there's a need for that.

    • cnity 2 hours ago

      ORMs are great. They make the easy queries remain easy and the harder queries impossible.

  • vilterp 2 hours ago

    > [Datalog] is a subset of Prolog that lacks recursion

    Datalog does allow for recursion — a common example is graph reachability:

    reachable(a, b) :- edge(a, b). reachable(a, c) :- edge(a, b), reachable(b, c).

    (Evan mentioned implementing kCFA, which would require recursion like this...)

    'Base datalog' guarantees termination by requiring all input relations to be finite. Notably this means that it doesn't have numerical operations like addition or multiplication, since `plus(a, b)` or `times(a, b)` would be infinite relations.

    More practical Datalog engines like Souffle (https://souffle-lang.github.io/) have numerical operations but don't guarantee termination.

    Recursive queries are not needed by most applications, but maybe Acadia could allow them (compiling to recursive CTEs) by proving that recursion only goes through finite relations.

  • kstrauser 2 hours ago

    Side note: I strongly prefer referring to this as the "1+N problem" as the author did here. I didn't understand what people were grousing about when they talked about "N+1".

    N+1: You're already doing N queries. Is adding 1 more that big of a deal?

    1+N: This should have been 1 query, but somehow you blew it up into that one plus N more.

    I'd seen that query antipattern plenty of times and knew what it was bad, but didn't realize that's what people meant by "N+1", which I thought must mean something different.

    • libria 2 hours ago

      You're not the only one. I never stopped to delve into what this N+1 problem was b/c I assumed it was never an issue for me. All these years and this is the 1st time I've finally understood what they were saying.

      However, after going back and forth with LLM on it just now, I feel like "1+N" is just a coding mistake, not a perplexing multi-faceted, engineering problem to be solved. Experience or a slow application would teach you to find a better way to get that info and then you move on.

      • ambicapter an hour ago

        > not a perplexing multi-faceted, engineering problem to be solved

        It's a common mistake, not a deep, interesting one.

      • rspeele 19 minutes ago

        It is just a coding mistake, except that fixing that mistake leaves you with clunkier abstractions.

        If you have Foos, and users have permissions that control what they can do to a Foo, you'd like to have a function `GetPermissions : (UserId, FooId) -> Async<Permissions>`. If users can frob Foos you'd like to have a `FrobFoo : (FooId) -> Async<void>` function.

        But as soon as you let users select multiple Foos, or god forbid, an entire folder containing Foos, and bulk-frob them now you have to write `FrobFoos : (List<FooId>) -> Async<void>`. And to avoid the implementation of that causing another 1+N checking permissions, you also need `GetPermissionsBulk : (UserId, List<FooId> -> Async<Dictionary<FooId, Permissions>>`. The singular forms of those functions, to avoid duplication, now become wrappers over the bulk forms.

        The logic becomes harder to trace in the rewritten, bulk forms of the functions, but they are efficient.

        Next the customer hits you with a request like "let's have a smart-frob function that works on all the selected foos. For foos that are red, it frobs them, if they are blue, it fizzles them". Now you have to bulk-load to select the redness or blueness of all your Foos, build two separate lists, red and blue, then call your bulk-frob and bulk-fizzle functions accordingly on the two lists. Again the machinery to turn the requirement into a batch-shaped thing is not a lot, but it does kind of obscure the original business requirement.

        At various times in the life of the project you will have a feature that starts as a "always done on one Foo" thing because it's triggered by a button on the detail screen. Then somebody will possibly come along and want to do it in bulk later and you have to rewrite the implementation. Unless you have very strict code review that everything MUST be written in batch-style taking a list of IDs up to the API layer.

        I wrote a library[1] many years ago to solve this problem and allow the straightforward, non-batch versions of the functions to be automatically batchable. The idea is kind of like what React did for frontend dev: React was not faster than mutating the page with jQuery soup, but it was much faster than replacing the entire DOM on every render, and it let you write your code as if that was what you were doing. That was a very simple mental model and much less buggy than jQuery soup.

        The idea of my library was basically borrowed from other functional languages with a resumption monad, meaning that instead of an opaque async task to go do a thing, you have a "plan" which could either be a. done or b. waiting on some errand that requires firing off a query. If you have a list of plans like from a loop, you could step all of them to the next errand they are waiting on, then fire those off in a batch. So plans could be composed linearly or "batch-style" depending on your preference[2].

        What makes it very powerful is the combination with an F# type provider that could analyze your SQL and automatically determine a caching profile for each query. It knows what tables the query reads from, what tables it writes to, whether it uses any impure functions like random(), etc. So within one transaction, it wouldn't re-run the same pure query again, it would pull the results from a local cache -- except that it also knows which tables the query uses and if another command issued in that transaction updates those tables, the cache is automatically invalidated. This solves the other code smell that starts to accumulate as you try to write efficient database code in a complex app -- keeping materialized objects loaded in memory and passing them around to other functions so they don't have to re-query for them.

        Anyway, it was a little too weird to catch on, and I was a little too burnt out to maintain it.

        [1]https://github.com/fsprojects/Rezoom.SQL

        [2]https://fsprojects.github.io/Rezoom.SQL/doc/Rezoom/README.ht...

    • hnarayanan an hour ago

      Thank you. I turn grumpy when my colleagues keep calling it N+1. What even.

    • mwigdahl 30 minutes ago

      But addition is commutative! :)

      • kstrauser 27 minutes ago

        Let me introduce you to our frenemy, IEEE-754.

  • quibono 3 hours ago

    Nice, and I understand why using `getAuthorNames` solves the N+1 here.

    But... isn't this solving the problem by removing most of what makes it an issue in the first place? I imagine most people use ORMs for the SQL <-> native class data sync capability. And this assumes one would run the Acadia query instead.

    FWIW I'm not trying to be negative, it's just my general impression is that these N+1 usually occur because people _want_ direct object access and _want_ to write loops, and _want_ to access fields and have the underlying SQL be sorted by the ORM.

    • lobofta 2 hours ago

      As far as I understand Acadia gives you Acadia <-> Native class data sync, only just Haskell and Elm at the moment unfortunately.

      I'd be willing to rewrite queries in some other language that transpiles to SQL if it allows me to do all the queries I want and gives me full compile time type support for db access in return.

      The policies look interesting too by the way, but they don't solve a major IMO.

    • cnity 2 hours ago

      This is my experience too. The solution is to train people to stop wanting to solve data query problems in the application layer.

  • mrkeen 3 hours ago

    Compare with the prior art of N+1 queries of 2014:

    https://github.com/facebook/Haxl/blob/main/example/sql/readm...

  • jbverschoor 2 hours ago

    Wasn't this 'solved' by Hibernate ages ago?

  • gigatexal 43 minutes ago

    just write sql smh

    it's so easy to get proper queries and then the mapping from a list of tuples to your object is easy