Optimize homepage post loading by eliminating N+1 queries for user data (#7668)

#### What type of PR is this?

/kind improvement
/area core
/milestone 2.21.x

#### What this PR does / why we need it:

This PR refactors posts query to reduce database queries significantly.

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/7593

Supersedes https://github.com/halo-dev/halo/pull/7644

#### Does this PR introduce a user-facing change?

```release-note
优化首页、归档页加载速度
```
pull/7673/head
John Niang 2025-08-07 00:08:37 +08:00 committed by GitHub
parent 27c18631e0
commit 3a50fdc4e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 204 additions and 35 deletions

View File

@ -1,5 +1,6 @@
package run.halo.app.core.user.service;
import java.util.Collection;
import java.util.Set;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@ -7,10 +8,14 @@ import run.halo.app.core.extension.User;
public interface UserService {
String GHOST_USER_NAME = "ghost";
Mono<User> getUser(String username);
Mono<User> getUserOrGhost(String username);
Flux<User> getUsersOrGhosts(Collection<String> names);
Mono<User> updatePassword(String username, String newPassword);
Mono<User> updateWithRawPassword(String username, String rawPassword);

View File

@ -1,5 +1,7 @@
package run.halo.app.core.counter;
import java.util.Collection;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Counter;
@ -11,5 +13,7 @@ public interface CounterService {
Mono<Counter> getByName(String counterName);
Flux<Counter> getByNames(Collection<String> names);
Mono<Counter> deleteByName(String counterName);
}

View File

@ -1,9 +1,15 @@
package run.halo.app.core.counter;
import java.util.Collection;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Counter;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.query.QueryFactory;
/**
* Counter service implementation.
@ -25,6 +31,17 @@ public class CounterServiceImpl implements CounterService {
return client.fetch(Counter.class, counterName);
}
@Override
public Flux<Counter> getByNames(Collection<String> names) {
if (CollectionUtils.isEmpty(names)) {
return Flux.empty();
}
var options = ListOptions.builder()
.andQuery(QueryFactory.in("metadata.name", names))
.build();
return client.listAll(Counter.class, options, ExtensionUtil.defaultSort());
}
@Override
public Mono<Counter> deleteByName(String counterName) {
return client.fetch(Counter.class, counterName)

View File

@ -5,6 +5,7 @@ import static run.halo.app.extension.index.query.QueryFactory.equal;
import java.time.Clock;
import java.time.Duration;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Objects;
@ -38,6 +39,7 @@ import run.halo.app.extension.ListOptions;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.exception.ExtensionNotFoundException;
import run.halo.app.extension.index.query.QueryFactory;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
@ -54,8 +56,6 @@ import run.halo.app.security.device.DeviceService;
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
public static final String GHOST_USER_NAME = "ghost";
private final ReactiveExtensionClient client;
private final PasswordEncoder passwordEncoder;
@ -92,6 +92,19 @@ public class UserServiceImpl implements UserService {
.switchIfEmpty(Mono.defer(() -> client.get(User.class, GHOST_USER_NAME)));
}
@Override
public Flux<User> getUsersOrGhosts(Collection<String> names) {
if (CollectionUtils.isEmpty(names)) {
return Flux.empty();
}
var nameSet = new HashSet<>(names);
nameSet.add(GHOST_USER_NAME);
var options = ListOptions.builder()
.andQuery(QueryFactory.in("metadata.name", nameSet))
.build();
return client.listAll(User.class, options, defaultSort());
}
@Override
public Mono<User> updatePassword(String username, String newPassword) {
return getUser(username)

View File

@ -1,6 +1,6 @@
package run.halo.app.theme.finders;
import java.util.List;
import java.util.Collection;
import org.springframework.lang.Nullable;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@ -19,7 +19,7 @@ public interface CategoryFinder {
Mono<CategoryVo> getByName(String name);
Flux<CategoryVo> getByNames(List<String> names);
Flux<CategoryVo> getByNames(Collection<String> names);
Mono<ListResult<CategoryVo>> list(@Nullable Integer page, @Nullable Integer size);

View File

@ -1,6 +1,6 @@
package run.halo.app.theme.finders;
import java.util.List;
import java.util.Collection;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.User;
@ -13,5 +13,6 @@ public interface ContributorFinder {
Mono<ContributorVo> getContributor(String name);
Flux<ContributorVo> getContributors(List<String> names);
Flux<ContributorVo> getContributors(Collection<String> names);
}

View File

@ -1,5 +1,6 @@
package run.halo.app.theme.finders;
import java.util.List;
import org.springframework.lang.NonNull;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Post;
@ -30,6 +31,8 @@ public interface PostPublicQueryService {
*/
Mono<ListedPostVo> convertToListedVo(@NonNull Post post);
Mono<List<ListedPostVo>> convertToListedVos(List<Post> posts);
/**
* Converts {@link Post} to post vo and populate post content by the given snapshot name.
* <p> This method will get post content by {@code snapshotName} and try to find

View File

@ -1,5 +1,6 @@
package run.halo.app.theme.finders;
import java.util.Collection;
import java.util.List;
import org.springframework.lang.Nullable;
import reactor.core.publisher.Flux;
@ -18,7 +19,7 @@ public interface TagFinder {
Mono<TagVo> getByName(String name);
Flux<TagVo> getByNames(List<String> names);
Flux<TagVo> getByNames(Collection<String> names);
Mono<ListResult<TagVo>> list(@Nullable Integer page, @Nullable Integer size);

View File

@ -19,15 +19,18 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Sort;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.content.CategoryService;
import run.halo.app.core.extension.content.Category;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.query.QueryFactory;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.theme.finders.CategoryFinder;
import run.halo.app.theme.finders.Finder;
@ -54,12 +57,15 @@ public class CategoryFinderImpl implements CategoryFinder {
}
@Override
public Flux<CategoryVo> getByNames(List<String> names) {
if (names == null) {
public Flux<CategoryVo> getByNames(Collection<String> names) {
if (CollectionUtils.isEmpty(names)) {
return Flux.empty();
}
return Flux.fromIterable(names)
.flatMap(this::getByName);
var options = ListOptions.builder()
.andQuery(QueryFactory.in("metadata.name", names))
.build();
return client.listAll(Category.class, options, ExtensionUtil.defaultSort())
.map(CategoryVo::from);
}
static Sort defaultSort() {

View File

@ -1,6 +1,6 @@
package run.halo.app.theme.finders.impl;
import java.util.List;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@ -23,16 +23,11 @@ public class ContributorFinderImpl implements ContributorFinder {
@Override
public Mono<ContributorVo> getContributor(String name) {
return userService.getUserOrGhost(name)
.map(ContributorVo::from);
return userService.getUserOrGhost(name).map(ContributorVo::from);
}
@Override
public Flux<ContributorVo> getContributors(List<String> names) {
if (names == null) {
return Flux.empty();
}
return Flux.fromIterable(names)
.flatMapSequential(this::getContributor);
public Flux<ContributorVo> getContributors(Collection<String> names) {
return userService.getUsersOrGhosts(names).map(ContributorVo::from);
}
}

View File

@ -291,7 +291,9 @@ public class PostFinderImpl implements PostFinder {
public Flux<ListedPostVo> listAll() {
return postPredicateResolver.getListOptions()
.flatMapMany(listOptions -> client.listAll(Post.class, listOptions, defaultSort()))
.flatMapSequential(postPublicQueryService::convertToListedVo);
.collectList()
.flatMap(postPublicQueryService::convertToListedVos)
.flatMapMany(Flux::fromIterable);
}
static int pageNullSafe(Integer page) {

View File

@ -1,18 +1,23 @@
package run.halo.app.theme.finders.impl;
import static java.util.Objects.requireNonNullElse;
import static java.util.Objects.requireNonNullElseGet;
import static run.halo.app.core.counter.MeterUtils.nameOf;
import static run.halo.app.core.user.service.UserService.GHOST_USER_NAME;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.PostService;
import run.halo.app.core.counter.CounterService;
import run.halo.app.core.counter.MeterUtils;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
@ -25,6 +30,7 @@ import run.halo.app.theme.finders.ContributorFinder;
import run.halo.app.theme.finders.PostPublicQueryService;
import run.halo.app.theme.finders.TagFinder;
import run.halo.app.theme.finders.vo.ContentVo;
import run.halo.app.theme.finders.vo.ContributorVo;
import run.halo.app.theme.finders.vo.ListedPostVo;
import run.halo.app.theme.finders.vo.PostVo;
import run.halo.app.theme.finders.vo.StatsVo;
@ -66,16 +72,12 @@ public class PostPublicQueryServiceImpl implements PostPublicQueryService {
return option;
})
.flatMap(listOptions -> client.listBy(Post.class, listOptions, page))
.flatMap(list -> Flux.fromStream(list.get())
.flatMapSequential(post -> convertToListedVo(post)
.flatMap(postVo -> populateStats(postVo)
.doOnNext(postVo::setStats).thenReturn(postVo)
.flatMap(list -> convertToListedVos(list.getItems())
.map(
postVos -> new ListResult<>(
list.getPage(), list.getSize(), list.getTotal(), postVos
)
)
.collectList()
.map(postVos -> new ListResult<>(list.getPage(), list.getSize(), list.getTotal(),
postVos)
)
)
.defaultIfEmpty(ListResult.emptyResult());
}
@ -128,6 +130,97 @@ public class PostPublicQueryServiceImpl implements PostPublicQueryService {
.defaultIfEmpty(postVo);
}
@Override
public Mono<List<ListedPostVo>> convertToListedVos(List<Post> posts) {
var counterNames = new HashSet<String>(posts.size());
var userNames = new HashSet<String>();
var tagNames = new HashSet<String>();
var categoryNames = new HashSet<String>();
posts.forEach(post -> {
counterNames.add(nameOf(Post.class, post.getMetadata().getName()));
var spec = post.getSpec();
userNames.add(spec.getOwner());
var status = post.getStatus();
if (status != null && status.getContributors() != null) {
userNames.addAll(status.getContributors());
}
if (spec.getTags() != null) {
tagNames.addAll(spec.getTags());
}
if (spec.getCategories() != null) {
categoryNames.addAll(spec.getCategories());
}
});
var getCounters = counterService.getByNames(counterNames)
.collectMap(counter -> counter.getMetadata().getName());
var getContributors = contributorFinder.getContributors(userNames)
.collectMap(ContributorVo::getName);
var getTags = tagFinder.getByNames(tagNames)
.collectMap(tagVo -> tagVo.getMetadata().getName());
var getCategories = categoryFinder.getByNames(categoryNames)
.collectMap(categoryVo -> categoryVo.getMetadata().getName());
return Mono.zip(getCounters, getContributors, getTags, getCategories)
.map(tuple -> {
var counters = tuple.getT1();
var contributors = tuple.getT2();
var tags = tuple.getT3();
var categories = tuple.getT4();
return posts.stream()
.map(post -> {
var vo = ListedPostVo.from(post);
vo.setCategories(List.of());
vo.setTags(List.of());
vo.setContributors(List.of());
var spec = post.getSpec();
var status = post.getStatus();
var ghost = requireNonNullElseGet(
contributors.get(GHOST_USER_NAME), ContributorVo::ghost
);
vo.setOwner(requireNonNullElse(contributors.get(spec.getOwner()), ghost));
if (status != null && !CollectionUtils.isEmpty(status.getContributors())) {
vo.setContributors(status.getContributors()
.stream()
.map(name -> requireNonNullElse(contributors.get(name), ghost))
.toList());
}
if (!CollectionUtils.isEmpty(spec.getTags())) {
vo.setTags(spec.getTags()
.stream()
.map(tags::get)
.filter(Objects::nonNull)
.toList());
}
if (!CollectionUtils.isEmpty(spec.getCategories())) {
vo.setCategories(spec.getCategories()
.stream()
.map(categories::get)
.filter(Objects::nonNull)
.toList());
}
var counterName = nameOf(Post.class, post.getMetadata().getName());
var counter = counters.get(counterName);
if (counter != null) {
vo.setStats(StatsVo.builder()
.visit(counter.getVisit())
.upvote(counter.getUpvote())
.comment(counter.getApprovedComment())
.build()
);
} else {
vo.setStats(StatsVo.empty());
}
return vo;
})
.toList();
});
}
@Override
public Mono<PostVo> convertToVo(Post post, String snapshotName) {
final String baseSnapshotName = post.getSpec().getBaseSnapshot();
@ -177,7 +270,7 @@ public class PostPublicQueryServiceImpl implements PostPublicQueryService {
}
private <T extends ListedPostVo> Mono<StatsVo> populateStats(T postVo) {
return counterService.getByName(MeterUtils.nameOf(Post.class, postVo.getMetadata()
return counterService.getByName(nameOf(Post.class, postVo.getMetadata()
.getName())
)
.map(counter -> StatsVo.builder()

View File

@ -1,5 +1,6 @@
package run.halo.app.theme.finders.impl;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
@ -9,11 +10,13 @@ import org.springframework.util.CollectionUtils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Tag;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.PageRequest;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.query.QueryFactory;
import run.halo.app.theme.finders.Finder;
import run.halo.app.theme.finders.TagFinder;
import run.halo.app.theme.finders.vo.TagVo;
@ -43,9 +46,15 @@ public class TagFinderImpl implements TagFinder {
}
@Override
public Flux<TagVo> getByNames(List<String> names) {
return Flux.fromIterable(names)
.flatMapSequential(this::getByName);
public Flux<TagVo> getByNames(Collection<String> names) {
if (CollectionUtils.isEmpty(names)) {
return Flux.empty();
}
var options = ListOptions.builder()
.andQuery(QueryFactory.in("metadata.name", names))
.build();
return client.listAll(Tag.class, options, ExtensionUtil.defaultSort())
.map(TagVo::from);
}
@Override

View File

@ -4,6 +4,8 @@ import lombok.Builder;
import lombok.ToString;
import lombok.Value;
import run.halo.app.core.extension.User;
import run.halo.app.core.user.service.UserService;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.MetadataOperator;
/**
@ -46,4 +48,22 @@ public class ContributorVo implements ExtensionVoOperator {
.metadata(user.getMetadata())
.build();
}
/**
* Create a ghost contributor.
*
* @return a ghost contributor value object
*/
public static ContributorVo ghost() {
var metadata = new Metadata();
metadata.setName(UserService.GHOST_USER_NAME);
return builder()
.name("ghost")
.displayName("Ghost")
// .avatar("/images/ghost.png")
.bio("A ghost user.")
.permalink("/authors/ghost")
.metadata(metadata)
.build();
}
}