SAREN//IT
MEMLOG // N_1 [SAREN-IT-OS 3.14] SEC//OK
user@robco:~$ ./play --log=n_1
[2026-07-19]  ·  6 MIN LOG

> Anatomy of an N+1: How One Redundant findById Turned 50 Updates into 1000 Queries

A production war story: APM flagged an N+1 pattern in a nightly cache job. The culprit was not lazy loading — it was a redundant re-fetch multiplied by a loop that should never have contained it.

Our APM tooling flagged a suspicious pattern in one of our background jobs: the same single-row SELECT fired 50+ times per run, in pairs. A cache-warming job that should have cost a handful of queries was generating around a thousand.

This is a walkthrough of that investigation — because this N+1 didn’t come from lazy loading, which is where everyone looks first. It came from two innocent-looking lines of code and a loop that multiplied them by ten.

Names of classes, methods, and domain objects below are changed — the shape of the problem is exactly what shipped.

The setup

The job in question warms a per-tenant cache of content items, across 10 timezones:

RefreshContentCacheJob.execute()
  └─ for each tenant:
       warmTimezoneCaches(tenantId, TIMEZONES)       // 10 timezones
         └─ listForTenant(tenantId)                   ← 1 bulk JOIN query (good)
         └─ for each timezone:
              applyItemFilters(items, timezone)
                └─ isAssetFresh(item)                 ← called for EVERY item × EVERY timezone
                     └─ markItemAsStale(item)         ← when the asset's freshness window expired
                          └─ repository.findById(id)  ← SELECT #1
                          └─ repository.save(item)    ← em.merge() → SELECT #2 + UPDATE

The bulk load at the top was fine — one JOIN query per tenant, using an entity graph. The damage happened further down, and it took two separate bugs working together.

Issue 1: the redundant findById

Here’s markItemAsStale, the method at the bottom of that call stack:

// BAD — entity `item` is already in memory, no need to re-fetch
@Transactional
public void markItemAsStale(ContentItem item) {
    Optional<ContentItem> found = itemRepository.findById(item.getId()); // SELECT (unnecessary!)
    if (found.isPresent()) {
        found.get().setStale(true);
        itemRepository.save(found.get()); // em.merge() → possible SELECT + UPDATE
    }
}

The method receives a fully loaded entity and immediately re-fetches it by ID. The findById adds zero correctness value — item was returned by a repository query moments earlier. The Optional.isPresent() guard protects against an ID that cannot be missing.

It gets worse. The entity had FetchType.EAGER on all of its collections:

@ElementCollection(fetch = FetchType.EAGER) Set<Integer> displaySlots;
@ElementCollection(fetch = FetchType.EAGER) Set<String> skuCodes;
@ManyToMany(fetch = FetchType.EAGER) Set<Tag> tags;
@ManyToMany(fetch = FetchType.EAGER) Set<Tag> excludedTags;

So that “one little findById” was not a cheap primary-key lookup. It was a ten-way LEFT JOIN monster — tags, exclusions, SKUs, attachments — all to flip a single boolean. And em.merge() on the freshly fetched entity fired a second SELECT before the UPDATE. That’s the “pattern of 2” APM detected.

Issue 2: the multiplier hiding in the loop

One redundant fetch per stale item would be bad. But the filter that triggers it ran inside the timezone loop:

// applyItemFilters — called once per timezone
items.stream().filter(item ->
    isNotExpired(item, timezone)
    && isValidFor(item, timezone)
    && isAssetFresh(item)   // ← timezone-INDEPENDENT, but called per timezone!
    && item.isActive())

isAssetFresh checks lastModified + freshnessWindow against wall-clock time. It has no timezone dependency whatsoever. Yet it sat between two predicates that do, so it ran once per item per timezone — and so did its database side effect.

The math: N stale items × 10 timezones × 2 queries each. Fifty stale items became 1000 queries where 50 updates would do.

There’s a second lesson buried here: isAssetFresh looks like a pure predicate but writes to the database. A side effect hiding inside a stream filter is exactly the kind of thing that gets multiplied when someone wraps the call in another loop.

The fixes

Fix 1: operate on the entity you already have

@Transactional
public void markItemAsStale(ContentItem item) {
    item.setStale(true);
    itemRepository.save(item);
}

If item happens to be detached, saveem.merge() re-attaches it correctly anyway. The manual pre-fetch bought nothing.

Fix 2: hoist the timezone-independent check out of the loop

public void warmTimezoneCaches(String tenantId, List<String> timezones) {
    List<ContentItem> items = contentService.listForTenant(tenantId);

    // Asset freshness is timezone-independent — evaluate once, mark stale once.
    List<ContentItem> freshItems = items.stream()
            .filter(this::isAssetFresh)
            .collect(Collectors.toList());

    timezones.forEach(timezone -> {
        List<ContentItem> filteredItems = freshItems.stream()
                .filter(item -> isNotExpired(item, timezone)
                        && isValidFor(item, timezone)
                        && item.isActive())
                .collect(Collectors.toList());
        contentCache.set(getCacheKey(tenantId, timezone), /* ... */);
    });
}

markItemAsStale now fires at most once per stale item per run instead of ten times. O(N) instead of O(N×T).

Fix 3 (the follow-up): one bulk UPDATE instead of N

Even after fixes 1 and 2, N stale items still cost N individual UPDATE round-trips. A @Modifying query collapses them into one:

@Modifying
@Query("UPDATE ContentItem c SET c.stale = true WHERE c.id IN :ids")
int setStaleForIds(@Param("ids") List<Long> ids);

Collect the stale IDs during filtering, call this once per tenant. One query whether 5 items are stale or 500.

One caveat worth knowing: bulk JPQL updates bypass Hibernate’s first-level cache — already loaded entities won’t reflect stale = true unless you also update them in memory (or never re-read them, as in this call path). And if the ID list can grow into the hundreds, chunk it — some databases limit IN clause size.

The impact

ScenarioBeforeAfter
50 stale items, 10 timezones50 × 10 × 2 = 1000 queries50 updates (fix 1+2), 1 update with fix 3
0 stale items0 extra0 extra
Per-tenant baseline1 bulk SELECT1 bulk SELECT

Takeaways

  • N+1 isn’t always lazy loading. This one was eager loading plus a redundant re-fetch. A findById on an entity with EAGER collections is a full JOIN query, not a cheap lookup.
  • Never re-fetch what you already hold. If a method receives a managed entity, mutate it and save it. The defensive findById + isPresent() dance costs round-trips and proves nothing.
  • Audit your loops for invariant work. A per-item cost is bad; a per-item cost inside a loop that doesn’t need it is a multiplier. Ask of every call in a loop: does this actually depend on the loop variable?
  • Keep side effects out of predicates. A filter that writes to the database will eventually be called somewhere its author didn’t anticipate.
  • APM earns its keep. This pattern survived code review because each piece looked reasonable in isolation. The “2 queries × 50 occurrences” grouping in the trace view is what made the shape obvious.
-- END OF RECORDING --