| ▲ | Solving the 1+N Query Problem(acadia.engineering) |
| 59 points by wheatBread 2 days ago | 53 comments |
| |
|
| ▲ | red_admiral a day ago | parent | next [-] |
| 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. |
| |
| ▲ | BeetleB a day ago | parent | next [-] | | Dealing with Django, they give quite a few ways to query data. For the small internal web site we ran, I've yet to encounter an N+1 situation that I didn't find an alternative Django API that I should have been using. Not saying you never need bare SQL on Django sites, but the Django ORM does have some sophisticated APIs to prevent this problem. > 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 So ... tell it! You can specify in a query to fetch only certain data, and not the whole object. | |
| ▲ | plaguuuuuu a day ago | parent | prev | next [-] | | People always say this and my experience in C# has been the opposite. I've never wanted to reach for raw SQL and hardly ever do, only if its because there's no extension to do SKIP..LOCKED or something var name = dbContext.Users
.Where(u => u.Id == 123)
.Select(u => u.Name)
.First(); | | |
| ▲ | red_admiral 18 hours ago | parent | next [-] | | How does the (u => u.Id == 123) lambda parse/execute? The bad way would be to load all users from the DB and filter on the server, the good way is to send a WHERE clause to the DB in the first place. I ask because `Where(u => ComplicatedBooleanFunction(u))` can't in general be transformed into something you can do in SQL, especially if it's stateful, but maybe there's a trick if the function is simple enough? Some ORMs get around this problem with methods like .WhereEqual(User::id, 123) so you don't need to parse anything to know the semantics. PS. I have in the past got praised at work for massively speeding up database access, when I fixed something like `List<User> userList = UserDAO.fetchAll(); return userList.length();`. You need a minimal understanding of databases to use an ORM correctly. The DAO even had a `.count()` method, but you need to know a little about databases to know to look for that in the first place. | | |
| ▲ | fabian2k 18 hours ago | parent [-] | | In EF Core this will almost certainly generate a query like "SELECT name FROM users WHERE id = 123 LIMIT 1;". The LINQ expression is transformed into SQL, and any expression that can't be transformed will cause an error to be thrown. Certain methods like First() in this case materialize expressions. Only those trigger a DB query. |
| |
| ▲ | entropicdrifter a day ago | parent | prev [-] | | I mean, you're definitely still writing a query in this case, just using a lazy-loading C# interface. An ORM in the traditional sense abstracts the details of the query itself fully away in favor of an object-oriented interface. | | |
| ▲ | fabian2k a day ago | parent [-] | | EF Core does not lazy load by default. You have to explicitly include relations if you want them on an entity. You can enable lazy loading, but in the default configuration you usually don't have the N+1 problem. | | |
| ▲ | entropicdrifter a day ago | parent [-] | | Isn't Linq a lazy-loading interface by default? The example they gave was a Linq query. I'm not a C# user in my professional life so I'm happy to be corrected, BTW, just citing the source of my misinterpretation | | |
| ▲ | fabian2k a day ago | parent [-] | | Yes, but in this context this just means that until you call ToList()/ToListAsync() you have an IQueryable. That represents a query, but isn't executed yet. Only at the point where you call a method like ToList (in this case First() is the relevant one) is the actual DB query performed. The LINQ query in the comment above would only execute a single query like "SELECT name FROM users WHERE id = 123 LIMIT 1;" and would not even fetch the full entity, only the name. | | |
| ▲ | entropicdrifter a day ago | parent [-] | | Right, which was my point. They used Linq to get around the limitations of an ORM more-so than using an ORM itself (by fetching an entity and accessing its 'field'). They're evading the N+1 problem by writing a query by hand still, just not in SQL itself. The bit about lazy loading was a bit of a side-note more than my main point. | | |
| ▲ | fabian2k a day ago | parent [-] | | But using LINQ to query stuff is a core part of this ORM. And even if you access the full entity, it won't do an N+1 in the default configuration. You have to explicitly call Include() on any relation you want to fetch and it'll either fetch all of them with one query or do one query per relation type. There's a different footgun here with AsSingleQuery() and AsSplitQuery(), but that's a separate topic. |
|
|
|
|
|
| |
| ▲ | cnity a day ago | parent | prev | next [-] | | ORMs are great. They make the easy queries remain easy and the harder queries impossible. | |
| ▲ | tehlike a day ago | parent | prev | next [-] | | This is true but not super true in case of linq and related providers like efcore. Even nhibernate linq would do this. | |
| ▲ | seki285 a day ago | parent | prev | next [-] | | You should write a raw SQL query to grab just a user's name only when there's a need for that. | |
| ▲ | stephen a day ago | parent | prev | next [-] | | > all you want is their name I have a WIP PR that addresses exactly this: https://github.com/joist-orm/joist-orm/pull/1967 | |
| ▲ | ddorian43 a day ago | parent | prev [-] | | Or set lazy loadin to "raise" in the relationships and get exceptions if you dont explicitly join. | | |
|
|
| ▲ | stephen a day ago | parent | prev | next [-] |
| Afaict their solution is "here's a prolog-ish query DSL that safely translates FP-ish code to joins". That seems fine, but imo 1+Ns usually happen when you interleave business logic with database loads--like you have business logic that "really wants to be in a loop" b/c it's "not easily expressed in SQL" logic. So, the author's ~3 lines of "a loop with zero business logic" is not that convincing, and seems like a premature claim to "solving 1+Ns"? Like maybe if you can express truly generic business logic, and somehow that is translated into "evaled on the database-side" SQL? (Disclaimer, I work on an ORM that does let you interleave business logic & database loads, and still avoids 1+Ns: https://joist-orm.io/goals/avoiding-n-plus-1s/) |
|
| ▲ | quibono a day ago | parent | prev | next [-] |
| 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 a day ago | parent | next [-] | | 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. | | |
| ▲ | ryanrasti a day ago | parent [-] | | > 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. Full typed coverage for db is what I'm doing in Typegres [1] -- including all dialect built-in functions/operators. And regarding policy, instead of RLS it's all based on ocap: reachability is permission. So: `api.user.posts()` automatically injects a `where` clause on the `users` table and it's composable wherever a SQL set expression is allowed: `api.user.posts().join(...).groupBy(...)`. Since we're building up a SQL expression tree, we avoid the N+1 problem entirely. [1] https://typegres.com/ |
| |
| ▲ | cnity a day ago | parent | prev | next [-] | | This is my experience too. The solution is to train people to stop wanting to solve data query problems in the application layer. | |
| ▲ | hobofan a day ago | parent | prev [-] | | Yes, this does essentially nothing to solve the 1+N query problem. If your solution was to to stuff everything into one query, this was already possible with SQL! |
|
|
| ▲ | kstrauser a day ago | parent | prev | next [-] |
| 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 a day ago | parent | next [-] | | 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. | | |
| ▲ | k1w1 a day ago | parent | next [-] | | I think N+1 problem in real applications are more than just a coding experience problem. N+1 problems frequently arise because of separation of concerns where the code doing the looping, and the code making the sub-queries are significantly separated from each other. In that case preventing the N+1 is not obvious, and fixing it can be very complex, and result in messy code. In languages with strong meta-programming, like Ruby, it is possible to deal with N+1s more automatically, which allows you to prevent them systematically, and most importantly have an elegant solution to N+1s that span independent blocks of code. A couple of articles about these techniques:
https://www.aha.io/engineering/articles/90-percent-of-rails-...
https://www.aha.io/engineering/articles/automatically-avoidi... | |
| ▲ | ambicapter a day ago | parent | prev | next [-] | | > not a perplexing multi-faceted, engineering problem to be solved It's a common mistake, not a deep, interesting one. | |
| ▲ | rspeele a day ago | parent | prev [-] | | 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 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... |
| |
| ▲ | LanceH a day ago | parent | prev | next [-] | | Algebraically, it is the number of queries: n+1. That's traditionally how you write such a number, not 1+n. It's the number of queries not a sentence of "we did 1 query, then we had to do n queries." | | |
| ▲ | kstrauser a day ago | parent [-] | | This isn't algebraic. Going from one to many queries may be arithmetically the same as going from many to many plus one queries, but operationally, the implications are different. |
| |
| ▲ | dfee a day ago | parent | prev | next [-] | | N+1 is at best two queries, right? I always interpreted it as: query 0 = list
query 1_0 = getItem(0)
...
guest 1_n = getItem(n)
optimized to: query 0 = list
query 1 = getItems(0..n)
| | |
| ▲ | kstrauser a day ago | parent [-] | | I'm willing to look the other way when N=1, and 0..n is a small number of values on an indexed column. As others have pointed out, sometimes it's difficult to merge two queries like `get_item_list()` and `get_items()` located in different parts of the code, especially if they cross service boundaries. It's ugly, but may be a completely tolerable situation as long as timing profiles and usage patterns show that it's not going to blow things up. So there, 1+N => 1+1 which isn't inherently scary. It's when 1+N turns into 147 queries, one for each line of a table displayed on a web page, that it really chafes. |
| |
| ▲ | hnarayanan a day ago | parent | prev | next [-] | | Thank you. I turn grumpy when my colleagues keep calling it N+1. What even. | |
| ▲ | mwigdahl a day ago | parent | prev [-] | | But addition is commutative! :) | | |
|
|
| ▲ | vilterp a day ago | parent | prev | next [-] |
| > [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. |
| |
| ▲ | cryptonector a day ago | parent [-] | | If you can do Peano numbers... Guaranteed termination isn't really if you give me enough rope to implement the Ackermann function. | | |
| ▲ | debugnik a day ago | parent [-] | | Pure Datalog can't express peano up to infinity, its terms can't be functors as in Prolog. At best you could hardcode a successor relation up to a limit. |
|
|
|
| ▲ | WilcoKruijer a day ago | parent | prev | next [-] |
| I really believe that every engineer writing queries (even SQL) should read the FoundationDB data modeling guide [0]. It really gives an appreciation of what smart choice of primary key can do to query efficiency. With some de-normalization, joins aren’t even needed for performance. Postgres has supported query pipelining for a long time. In my opinion, most queries should be written in such a way that sequential queries don’t have any data dependencies on the previous query at all. This speeds up applications by huge amounts. [0] https://apple.github.io/foundationdb/data-modeling.html |
| |
| ▲ | NorthSouthNorth a day ago | parent [-] | | lol I remember a senior dev absolutely shitting on me for suggesting de-normalization to improve a JOIN query that was absolutely way too slow (on a table that was reseeded on deploys at that). I left it at that but to this day I maintain that it was the right move. |
|
|
| ▲ | wood_spirit a day ago | parent | prev | next [-] |
| Of course mainstream ORMs leave a lot of perf on the table. For example, I once patched the ORM in a struggling php web app that i had to help. I started as a logger profiler thingy but then had the crazy idea of being a trace optimiser. By recognising the call sites from previous visits I could spot the 1+N and select * etc and actually transcode that into better sql in the next run etc. Shockingly it made a massive difference and I was surprised that normal ORMs aren’t doing that kind of thing. |
| |
| ▲ | stephen a day ago | parent [-] | | > trace optimizer/better sql in the next run Ah wow! I admittedly already linked this PR in another reply, but I'm trying similar things here: https://github.com/joist-orm/joist-orm/pull/1967 Neat to hear you had success with it before; did you have to handle "the optimization was inaccurate [a novel codepath asked for a column we didn't return], so fallback to `select *`"? And do that without failing the overall request? This "fallback and implicit retry" is what I'm doing atm, and just assuming is the only way of handling the "novel codepath was hit this time" problem, but lmk if I'm missing something. |
|
|
| ▲ | mrkeen a day ago | parent | prev | next [-] |
| Compare with the prior art of N+1 queries of 2014: https://github.com/facebook/Haxl/blob/main/example/sql/readm... |
|
| ▲ | koliber a day ago | parent | prev | next [-] |
| This is largely a solved problem. New generations of programmers are simply rediscovering it. The solution: - be aware of it - add DB query monitoring via your favorite APM tool - review the APM tool regularly - when you see an N+1 issue apply one of the normal solutions. Follow this and N+1 query issues will disappear soon enough. If you can’t do this it means you chose an immature framework or tech stack and my advice is to consider starting from scratch. Otherwise you will need to re-live the mistakes many people have already solved before you which feels adventurous but is painful and dumb. |
| |
| ▲ | 1-more a day ago | parent [-] | | I used to work somewhere that was really good at testing. We had `assert_no_n_plus_ones` in our rspec controller tests where we'd load a page of things with one thing on it and again with many things on it and assert that the number of DB queries was the same between both. The thing is, as a developer I want every dumb mistake I could make appear as a squiggle in my editor. The hierarchy for programming error reporting is something like: lawsuit, social media post, ticket filed by support, bug found by QA, failing browser driver regression test, failing controller test, failing UI unit test, failing linter bug (elm-review is incredible for lint with auto fixes), failing compile which is caught by my editor. Only the last two might not require me to write any code, and only the last one might not require me to even write any configuration. If the error is caught anywhere past QA, that's good and cool, no disagreement there. But if it's found before I could ever have to assert it's not there, I feel so much more secure that I haven't introduced it. | | |
| ▲ | koliber 19 hours ago | parent [-] | | I really like how you phrase this and completely agree. Issues should be caught as early as possible in the dev process and what you describe is probably as good as it gets. I tend to look at such problems from an organizational perspective and a good APM is a fail safe that compensates for other failures. | | |
| ▲ | 1-more 8 hours ago | parent [-] | | > a good APM is a fail safe that compensates for other failures. Absolutely. The ideal organization would adopt a tool to make squiggles when you do something silly AND have an APM that treats slow pages as bugs AND have a culture of triaging bugs among the teams and having all engineers take a ownership of how the app is working. Swiss cheese model. When I worked at the place with assert_no_n_plus_ones it was the closest thing to that ideal organization. |
|
|
|
|
| ▲ | jbverschoor a day ago | parent | prev | next [-] |
| Wasn't this 'solved' by Hibernate ages ago? |
| |
| ▲ | _1tan a day ago | parent [-] | | Heavy Hibernate user here, Hibernate does not make it impossible to write N+1 queries afaik - unless there are some tips we're missing in our team? We just regularly check our slow queries dashboard, fix it (or well let an LLM work on it) and then move on. | | |
| ▲ | jbverschoor 21 hours ago | parent [-] | | I recall hibernate, it maybe rails, as those are my most used tech, that it can detect n+1, and then just query the whole relation. Detecting is easy if you’re iterating over a proxy collection |
|
|
|
| ▲ | a day ago | parent | prev | next [-] |
| [deleted] |
|
| ▲ | hmnxr1e a day ago | parent | prev | next [-] |
| Non-identity principle. A=A |
|
| ▲ | gigatexal a day ago | parent | prev [-] |
| 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 |