缓存 Redis para Artigos Populares com Agendamento via XXL-JOB e Prevenção de OOM

APIs Úteis

Utilize LocalDateTime para manipular datas. Exemplo para obter a data atual menos cinco dias:

LocalDateTime limiteTemporal = LocalDateTime.now().minusDays(5);
Date dataLimite = Date.from(limiteTemporal.atZone(ZoneId.systemDefault()).toInstant());

Filtragem e ordenação com Stream API:

List<ArtigoPopularVO> artigosDoCanal = listaCompletaArtigos.stream()
    .filter(artigo -> artigo.getCanalId().equals(canalAtual.getId()))
    .collect(Collectors.toList());

artigosDoCanal.sort(Comparator.comparingDouble(ArtigoPopularVO::getPontuacao).reversed());

Truncagem segura de listas para evitar exceções:

int limiteMaximo = 30;
if (artigosDoCanal.size() > limiteMaximo) {
    artigosDoCanal = new ArrayList<>(artigosDoCanal.subList(0, limiteMaximo));
}

Tratamento de respostas de chamadas remotas (Feign/RestTemplate):

if (respostaAPI.getStatus() == HttpStatus.OK.value()) {
    List<CanalMidia> canais = objectMapper.convertValue(
        respostaAPI.getCorpo(), new TypeReference<>() {}
    );
    // processar lista de canais
}

Para evitar dados duplicados entre cache Redis e consultas ao banco, filtre os IDs presentes no cache nas consultas subsequentes.

Requisitos e Fluxo

Artigos populares serão cacheados no Redis por canal. O cálculo da popularidade ocorre periodicamente via XXL-JOB. A leitura prioritária usa o cache, enuqanto operações de "carregar mais/novo" consultam o banco com exclusão dos IDs já cacheados.

Pontuação por Comportamento do Usuário

Pesos: Leitura = 1, Curtida = 3, Comentário = 5, Favorito = 8.

Cálculo da Popularidade

1. Consultar Artigos Recentes

Mapper com parâmetro de data:

@Select("SELECT a.* FROM artigo a " +
        "INNER JOIN config_artigo c ON a.id = c.artigo_id " +
        "WHERE c.ativo = 1 AND a.data_publicacao >= #{dataLimite}")
List<Artigo> listarArtigosRecentes(@Param("dataLimite") Date dataLimite);

Obtenção de canais via cliente Feign:

@FeignClient(name = "servico-midia", path = "/canais")
public interface ClienteMidia {
    @GetMapping
    List<Canal> listarTodos();
}

2. Calcular Pontuação

public List<ArtigoPopularVO> calcularPontuacoes(List<Artigo> artigos) {
    return artigos.stream().map(artigo -> {
        ArtigoPopularVO vo = new ArtigoPopularVO();
        vo.setId(artigo.getId());
        vo.setCanalId(artigo.getCanalId());
        double pontuacao = (artigo.getCurtidas() != null ? artigo.getCurtidas() * 3 : 0) +
                           (artigo.getLeituras() != null ? artigo.getLeituras() : 0) +
                           (artigo.getComentarios() != null ? artigo.getComentarios() * 5 : 0) +
                           (artigo.getFavoritos() != null ? artigo.getFavoritos() * 8 : 0);
        vo.setPontuacao(pontuacao);
        return vo;
    }).collect(Collectors.toList());
}

3. Cachear Top 30 por Canal

public void atualizarCache(List<ArtigoPopularVO> artigos, List<Canal> canais) {
    String chavePrefixo = "popular:canal:";

    for (Canal canal : canais) {
        List<ArtigoPopularVO> filtrados = artigos.stream()
            .filter(a -> a.getCanalId().equals(canal.getId()))
            .sorted(Comparator.comparingDouble(ArtigoPopularVO::getPontuacao).reversed())
            .limit(30)
            .collect(Collectors.toList());

        redisTemplate.opsForValue().set(chavePrefixo + canal.getId(), filtrados);
    }

    // Cache geral (recomendações)
    List<ArtigoPopularVO> topGeral = artigos.stream()
        .sorted(Comparator.comparingDouble(ArtigoPopularVO::getPontuacao).reversed())
        .limit(30)
        .collect(Collectors.toList());

    redisTemplate.opsForValue().set(chavePrefixo + "recomendacoes", topGeral);
}

Agendamento com XXL-JOB

Configuração do Executor

Adicionar dependência e configuração:

<dependency>
    <groupId>com.xuxueli</groupId>
    <artifactId>xxl-job-core</artifactId>
    <version>2.3.0</version>
</dependency>

Configuração do executor:

@Configuration
public class ConfiguracaoXxlJob {
    @Value("${xxl.job.admin.enderecos}")
    private String enderecosAdmin;

    @Value("${xxl.job.executor.nome-app}")
    private String nomeApp;

    @Value("${xxl.job.executor.porta}")
    private int porta;

    @Bean
    public XxlJobSpringExecutor criarExecutor() {
        XxlJobSpringExecutor executor = new XxlJobSpringExecutor();
        executor.setAdminAddresses(enderecosAdmin);
        executor.setAppname(nomeApp);
        executor.setPort(porta);
        return executor;
    }
}

Propriedades em application.yml:

xxl:
  job:
    admin:
      enderecos: http://endereco-admin:8080/xxl-job-admin
    executor:
      nome-app: executor-artigos-populares
      porta: 9999

Definição da tarefa agendada:

@Component
public class TarefaArtigosPopulares {
    private final ServicoPopularidade servicoPopularidade;

    @XxlJob("calcularArtigosPopulares")
    public void executar() {
        servicoPopularidade.calcularEAtualizarCache();
    }
}

Modificar Consulta de Artigos

Controlador com lógica prioritária para cache:

@RestController
@RequestMapping("/artigos")
public class ControladorArtigos {
    private final ServicoArtigo servicoArtigo;

    @PostMapping("/carregar")
    public ResponseEntity<List<ArtigoVO>> carregar(@RequestBody FiltroArtigo filtro) {
        return ResponseEntity.ok(servicoArtigo.carregarComPrioridadeCache(filtro));
    }
}

Serviço com fallback para banco:

@Service
public class ServicoArtigoImpl implements ServicoArtigo {
    private final CacheService cache;
    private final RepositorioArtigo repositorio;

    @Override
    public List<ArtigoVO> carregarComPrioridadeCache(FiltroArtigo filtro) {
        String chaveCache = "popular:canal:" + filtro.getCanalId();
        List<ArtigoPopularVO> cacheados = cache.obter(chaveCache);

        if (cacheados != null && !cacheados.isEmpty()) {
            return cacheados;
        }
        return carregarDoBanco(filtro);
    }

    private List<ArtigoVO> carregarDoBanco(FiltroArtigo filtro) {
        Set<Long> idsExcluidos = cache.obterIdsCacheados(filtro.getCanalId());
        return repositorio.buscarExcluindoIds(idsExcluidos, filtro.getLimite());
    }
}

Prevenção de OOM com XXL-JOB e Sharding

Estratégia: Dividir Carga com Sharding Broadcast

Configurar a tarefa no XXL-JOB com estratégia de roteamento "Sharding Broadcast".

Implementação da tarefa com processamento em lotes:

@XxlJob("calcularPopularidadeDistribuida")
public void executarSharding() {
    int shardIndex = XxlJobHelper.getShardIndex();
    int shardTotal = XxlJobHelper.getShardTotal();

    long offset = 0;
    final int TAMANHO_LOTE = 1000;

    while (true) {
        List<Artigo> lote = repositorio.buscarPorShard(shardIndex, shardTotal, offset, TAMANHO_LOTE);
        if (lote.isEmpty()) break;

        List<ArtigoPopularVO> resultados = calcularPontuacoes(lote);
        salvarNoRedis(resultados);

        offset += TAMANHO_LOTE;
    }
}

Consulta SQL otimizada para sharding:

SELECT id, titulo, curtidas, leituras, comentarios, favoritos
FROM artigo
WHERE MOD(id, #{totalShards}) = #{shardIndex}
  AND id >= #{offset}
ORDER BY id
LIMIT #{limite}

Processamento Streaming (Alternativa)

try (Connection conn = dataSource.getConnection()) {
    PreparedStatement stmt = conn.prepareStatement(sql,
        ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY);
    stmt.setFetchSize(Integer.MIN_VALUE); // Habilita streaming
    
    ResultSet rs = stmt.executeQuery();
    while (rs.next()) {
        Artigo artigo = mapearArtigo(rs);
        double pontuacao = calcularPontuacao(artigo);
        atualizarRedis(artigo.getId(), pontuacao);
    }
}

Agregação Final

Após execução de todos os shards, qualquer instância pode recuperar o top 30 global:

Set<String> top30 = redisTemplate.opsForZSet()
    .reverseRange("ranking:artigos:popular", 0, 29);

Resumo das práticas para prevenção de OOM:

  • Sharding broadcast divide dados entre executores
  • Sharding por módulo de ID distribui carga uniformemente
  • Processamento em lotes ou straeming evita carga total em memória
  • Redis Sorted Set agrega resultados de todos os shards
  • Escalabilidade horizonatl adicionando mais instâncias executoras

Tags: Redis XXL-JOB java Spring Boot Cache Distribuído

Publicado em 6-16 18:42 por Thomas