SpringBoot+Redis 實戰:從零搭建千萬級數據實時熱銷商品排行榜
引言
圖片
在電商平臺、內容社區等業務場景中,實時熱銷商品排行榜是提升用戶體驗、引導消費決策的核心功能之一。當面對千萬級商品數據和高并發訪問時,傳統的數據庫排序方案會面臨性能瓶頸,而Redis憑借其高性能的內存數據結構,成為實現實時排行榜的最優選擇。
設計背景
傳統基于關系型數據庫(如 MySQL)的排行榜實現,通常依賴ORDER BY語句對商品銷量、熱度等字段進行排序。但在千萬級數據場景下,存在以下問題:
- 性能瓶頸:數據庫排序需掃描全表或大范圍索引,單次查詢耗時可達數百毫秒甚至秒級,無法滿足實時性要求;
- 并發壓力:高并發訪問時,數據庫連接池易耗盡,導致系統響應超時;
- 數據一致性:銷量、熱度等數據實時更新,頻繁寫入會加劇數據庫鎖競爭,影響讀寫性能。
針對上述痛點,系統需滿足以下核心需求:
- 實時性:排行榜數據更新延遲不超過
1秒,用戶訪問時可即時獲取最新排名; - 高并發:支持每秒
1000+查詢請求(QPS),且響應時間控制在100ms以內; - 可擴展性:支持千萬級商品數據存儲,且能隨業務增長橫向擴展;
- 多維度排序:支持按銷量、銷售額、熱度(點擊+收藏)等多維度生成排行榜;
- 數據持久化:排行榜數據需持久化,避免
Redis重啟后數據丟失。
系統架構設計
系統采用MySQL存基礎數據 + Redis存排序數據的雙層架構,核心流程如下:
- 數據寫入:商品銷量 / 熱度更新時,先更新
MySQL中的數據,再通過Redisson原子操作更新Redis ZSet中的分數(Score); - 數據查詢:用戶訪問排行榜時,直接從
Redis ZSet中查詢Top N數據,再關聯MySQL中的商品基礎信息返回給前端; - 數據同步:通過定時任務(如
Spring定時任務)或binlog同步工具(如Canal),確保MySQL與Redis數據最終一致性。
Redis ZSet 結構設計
Redis ZSet由成員(Member)和分數(Score)組成,天然適合實現排行榜場景。本系統中ZSet的設計如下:
Key命名規則:hot:ranking:{維度}:{時間范圍},例如:
hot:ranking:sales:daily(今日銷量排行榜)
hot:ranking:heat:weekly(本周熱度排行榜)
Member:商品ID(Long類型),確保唯一標識商品;Score:排序依據,例如:- 銷量維度:
Score= 商品今日銷量(整數); - 熱度維度:
Score= 點擊量×0.3 + 收藏量×0.5 + 加購量×0.2(加權計算)。
實現
核心配置
spring:
# MySQL 配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/ecommerce?useSSL=false&serverTimeznotallow=UTC
username: root
password: 123456
# Redis 配置
redis:
host: localhost
port: 6379
password: 123456
database: 0
timeout: 5000ms
lettuce:
pool:
max-active: 20 # 最大連接數
max-idle: 10 # 最大空閑連接數
min-idle: 5 # 最小空閑連接數
# Redisson 配置(支持持久化和分布式鎖)
redisson:
address: redis://localhost:6379
password: 123456
database: 0
connection-pool-size: 16
connection-minimum-idle-size: 8
retry-attempts: 3 # 重試次數
retry-interval: 1000 # 重試間隔(毫秒)
# MyBatis-Plus 配置
mybatis-plus:
mapper-locations: classpath:mapper/**/*.xml
type-aliases-package: com.example.ecommerce.entity
configuration:
map-underscore-to-camel-case: true# 下劃線轉駝峰數據庫表設計
CREATE TABLE `product` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '商品ID',
`name` varchar(255) NOT NULL COMMENT '商品名稱',
`image_url` varchar(512) DEFAULT NULL COMMENT '商品圖片鏈接',
`price` decimal(10,2) NOT NULL COMMENT '商品價格',
`sales` int NOT NULL DEFAULT '0' COMMENT '累計銷量',
`heat` int NOT NULL DEFAULT '0' COMMENT '商品熱度(點擊+收藏+加購)',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '創建時間',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
PRIMARY KEY (`id`),
KEY `idx_sales` (`sales`), # 銷量索引
KEY `idx_heat` (`heat`) # 熱度索引
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';排行榜常量定義
public class RankingConstant {
// 排行榜維度:銷量、熱度
public static final String DIMENSION_SALES = "sales";
public static final String DIMENSION_HEAT = "heat";
// 時間范圍:今日、本周、本月
public static final String TIME_RANGE_DAILY = "daily";
public static final String TIME_RANGE_WEEKLY = "weekly";
public static final String TIME_RANGE_MONTHLY = "monthly";
// Key 生成模板:hot:ranking:{維度}:{時間范圍}
public static String getRankingKey(String dimension, String timeRange) {
return String.format("hot:ranking:%s:%s", dimension, timeRange);
}
}排行榜服務實現(核心)
@Service
public class HotRankingService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private ProductMapper productMapper;
/**
* 1. 更新商品在排行榜中的分數(支持增量更新)
*
* @param productId 商品ID
* @param dimension 排序維度(sales/heat)
* @param timeRange 時間范圍(daily/weekly/monthly)
* @param scoreIncrement 分數增量(正數為增加,負數為減少)
*/
public void updateProductScore(Long productId, String dimension, String timeRange, double scoreIncrement) {
// 1. 獲取 ZSet 實例
RScoredSortedSet<Long> zSet = redissonClient.getScoredSortedSet(
RankingConstant.getRankingKey(dimension, timeRange)
);
// 2. 原子增量更新分數(避免并發更新導致的分數不一致)
zSet.addScore(productId, scoreIncrement);
// 3. (可選)設置過期時間(如日榜過期時間為次日0點,周榜為下周1零點)
String key = RankingConstant.getRankingKey(dimension, timeRange);
RKeys rKeys = redissonClient.getKeys();
if (!rKeys.isExists(key)) {
long expireTime = getExpireTime(timeRange);
redissonClient.getBucket(key).expire(expireTime, TimeUnit.MILLISECONDS);
}
}
/**
* 2. 查詢排行榜 Top N 數據(含商品基礎信息)
*
* @param dimension 排序維度
* @param timeRange 時間范圍
* @param topSize 取前 N 條
* @return 排行榜列表(含排名、商品信息、分數)
*/
public List<RankingVO> getRankingTopN(String dimension, String timeRange, int topSize) {
// 1. 獲取 ZSet 實例,按分數降序排列(分數越高排名越前)
RScoredSortedSet<Long> zSet = redissonClient.getScoredSortedSet(
RankingConstant.getRankingKey(dimension, timeRange)
);
// 2. 查詢 Top N 的商品ID和分數
Collection<ScoredEntry<Long>> topEntries = zSet.entryRangeReversed(0, topSize - 1);
if (CollectionUtil.isEmpty(topEntries)) {
return Collections.emptyList();
}
// 3. 批量查詢商品基礎信息(減少 MySQL 連接次數)
List<Long> productIds = topEntries.stream()
.map(ScoredEntry::getValue)
.collect(Collectors.toList());
List<Product> products = productMapper.selectBatchByIds(productIds);
Map<Long, Product> productMap = products.stream()
.collect(Collectors.toMap(Product::getId, Function.identity()));
// 4. 組裝返回結果(含排名、商品信息、分數)
List<RankingVO> rankingVOList = new ArrayList<>();
int rank = 1;
for (ScoredEntry<Long> entry : topEntries) {
Long productId = entry.getValue();
Product product = productMap.get(productId);
if (product == null) {
continue; // 商品已下架,跳過
}
RankingVO vo = new RankingVO();
vo.setRank(rank++);
vo.setProductId(productId);
vo.setProductName(product.getName());
vo.setProductImage(product.getImageUrl());
vo.setProductPrice(product.getPrice());
vo.setScore(Math.round(entry.getScore())); // 分數取整(如銷量、熱度為整數)
rankingVOList.add(vo);
}
return rankingVOList;
}
/**
* 3. 查詢單個商品在排行榜中的排名
*
* @param productId 商品ID
* @param dimension 排序維度
* @param timeRange 時間范圍
* @return 商品排名(null 表示未入榜)
*/
public Integer getProductRank(Long productId, String dimension, String timeRange) {
RScoredSortedSet<Long> zSet = redissonClient.getScoredSortedSet(
RankingConstant.getRankingKey(dimension, timeRange)
);
// ZSet 排名從 0 開始,需 +1 轉為自然排名
return zSet.revRank(productId) == null ? null : zSet.revRank(productId) + 1;
}
/**
* 輔助方法:計算排行榜過期時間
*/
private long getExpireTime(String timeRange) {
LocalDateTime now = LocalDateTime.now();
LocalDateTime expireDateTime;
switch (timeRange) {
case RankingConstant.TIME_RANGE_DAILY:
// 今日榜:過期時間為次日0點
expireDateTime = now.plusDays(1).withHour(0).withMinute(0).withSecond(0).withNano(0);
break;
case RankingConstant.TIME_RANGE_WEEKLY:
// 本周榜:過期時間為下周1零點(ISO周:周一為一周第一天)
expireDateTime = now.plusWeeks(1).with(TemporalAdjusters.next(DayOfWeek.MONDAY))
.withHour(0).withMinute(0).withSecond(0).withNano(0);
break;
case RankingConstant.TIME_RANGE_MONTHLY:
// 本月榜:過期時間為下月1零點
expireDateTime = now.plusMonths(1).withDayOfMonth(1)
.withHour(0).withMinute(0).withSecond(0).withNano(0);
break;
default:
throw new IllegalArgumentException("不支持的時間范圍:" + timeRange);
}
// 計算當前時間到過期時間的毫秒數
return Duration.between(now, expireDateTime).toMillis();
}
}訂單服務實現
@Service
public class OrderService {
@Autowired
private ProductMapper productMapper;
@Autowired
private HotRankingService rankingService;
/**
* 用戶下單:更新商品銷量,并同步更新銷量排行榜
* @param productId 商品ID
* @param quantity 購買數量
*/
@Transactional // 保證MySQL更新和Redis操作的原子性(最終一致性)
public void createOrder(Long productId, Integer quantity) {
// 1. 更新MySQL中的商品銷量(增量更新)
Product product = new Product();
product.setId(productId);
product.setSales(quantity); // MyBatis-Plus會自動轉為 SET sales = sales + #{quantity}
int updateCount = productMapper.update(product,
new LambdaQueryWrapper<Product>().eq(Product::getId, productId));
if (updateCount == 0) {
throw new RuntimeException("商品不存在或已下架:" + productId);
}
// 2. 同步更新Redis銷量排行榜(日榜、周榜、月榜)
rankingService.updateProductScore(
productId,
RankingConstant.DIMENSION_SALES,
RankingConstant.TIME_RANGE_DAILY,
quantity
);
rankingService.updateProductScore(
productId,
RankingConstant.DIMENSION_SALES,
RankingConstant.TIME_RANGE_WEEKLY,
quantity
);
rankingService.updateProductScore(
productId,
RankingConstant.DIMENSION_SALES,
RankingConstant.TIME_RANGE_MONTHLY,
quantity
);
}
}性能優化
Redis 層面優化
內存優化:
- 啟用
Redis內存淘汰策略(如allkeys-lru),當內存達到閾值時自動淘汰不常用的排行榜數據; - 對
ZSet進行分片存儲:若單維度排行榜數據超過1000萬,可按商品ID哈希分片(如hot:ranking:sales:daily:00~hot:ranking:sales:daily:15),降低單ZSet大小,提升操作性能。
持久化優化:
- 采用
AOF+RDB混合持久化:RDB用于全量備份(如每天凌晨2點執行),AOF用于增量日志(每秒刷盤一次),兼顧數據安全性和性能; - 關閉
AOF重寫期間的自動觸發,手動在業務低峰期執行(如凌晨3點),避免重寫占用過多CPU。
集群部署:
- 采用
Redis Cluster集群(至少3主3從),將不同維度的排行榜分散到不同主節點(通過Key哈希分片),實現負載均衡; - 為每個主節點配置從節點,避免主節點故障導致排行榜不可用。
數據庫層面優化
減少查詢壓力:
- 對商品基礎信息查詢添加 本地緩存(如
Caffeine),緩存熱點商品數據(如Top 1000商品),過期時間設為5分鐘,減少MySQL訪問次數; - 批量查詢代替單條查詢:排行榜關聯商品信息時,通過
selectBatchByIds批量獲取,避免循環查詢(N+1問題)。
寫入性能優化:
- 若商品銷量更新頻率極高(如每秒
thousands次),采用消息隊列(Kafka/RabbitMQ)異步批量更新:將銷量更新請求寫入隊列,消費者批量(如每100條或每1秒)更新MySQL和Redis,降低瞬時寫入壓力; - 對
product表的sales字段添加 樂觀鎖(如增加version字段),避免并發更新導致的數據覆蓋。
應用層面優化
接口緩存:
- 對排行榜查詢接口添加
Redis緩存(如緩存hot:ranking:cache:sales:daily:top10),緩存時間設為10秒(根據實時性需求調整),避免高頻查詢穿透到Redis ZSet; - 采用緩存預熱:在業務高峰期前(如電商大促
0點前),通過定時任務提前查詢Top N數據并寫入緩存,避免高峰期緩存擊穿。
并發控制:
- 對商品排名查詢接口添加 接口限流(如使用
Sentinel),限制單IP每秒查詢次數(如10次),避免惡意請求壓垮系統; - 使用
Redisson分布式鎖,解決多實例部署時的并發更新問題(如多個服務實例同時更新同一商品的銷量分數)。































