面試官:海量訂單數(shù)據(jù)如何存儲和查詢
你知道的越多,不知道的就越多,業(yè)余的像一棵小草!
你來,我們一起精進(jìn)!你不來,我和你的競爭對手一起精進(jìn)!
編輯:業(yè)余草
來源:juejin.cn/post/7294841029681250319
推薦:https://t.zsxq.com/13WJ1INdw
自律才能自由
冷熱數(shù)據(jù)架構(gòu)
假設(shè)我們考慮 12306 單個假期的人流量為 2 億人次,這一估算基于每年的三個主要假期:五一、國慶和春節(jié)。這些假期通常都有來回的流動,因此數(shù)據(jù)存儲與計算的公式變?yōu)椋? * (3*2) = 12 億,即每年的假期總?cè)舜芜_(dá)到了 12 億。
考慮到假期訂單數(shù)據(jù)以及日常購票數(shù)據(jù)的累積,隨著多年的積累,數(shù)據(jù)量將會變得相當(dāng)龐大。但我們需要再次審視一個關(guān)鍵問題:這些訂單數(shù)據(jù)是否需要一直保留在數(shù)據(jù)庫中呢?
經(jīng)過詳細(xì)分析 12306 車票訂單購買查看邏輯,我們發(fā)現(xiàn)用戶賬號只能查看最近一個月內(nèi)的訂單購買記錄。這一時間范圍最多涵蓋一個節(jié)假日周期,考慮往返車票等情況,大致數(shù)據(jù)量約為 4 億。這樣的數(shù)據(jù)規(guī)模相較之前大幅減少,有效降低了整體的存儲壓力。
上述的這種數(shù)據(jù)存儲技術(shù)叫做「冷熱數(shù)據(jù)」的架構(gòu)方案,那什么叫做冷數(shù)據(jù)?什么又是熱數(shù)據(jù)?
??
「熱數(shù)據(jù)」通常指經(jīng)常被訪問和使用的數(shù)據(jù),如最近的交易記錄或最新的新聞文章等。這些數(shù)據(jù)需要快速的讀寫速度和響應(yīng)時間,因此通常存儲在快速存儲介質(zhì)(如內(nèi)存或快速固態(tài)硬盤)中,以便快速訪問和處理。 「冷數(shù)據(jù)」則指很少被訪問和使用的數(shù)據(jù),如過去的交易記錄或舊的新聞文章等。這些數(shù)據(jù)訪問頻率較低,但需要長期保存,因此存儲在較慢的存儲介質(zhì)(如磁盤或云存儲)中,以便節(jié)省成本和存儲空間。
如何實現(xiàn)這種冷熱數(shù)據(jù)存儲架構(gòu)?比較簡單的方案就是,「我們每天有個定時任務(wù),把一個月前的數(shù)據(jù)從當(dāng)前的數(shù)據(jù)庫遷移到冷數(shù)據(jù)庫中」。這里就涉及了分庫分表操作。
這時需要注意一件事情,就是我們遷移到冷數(shù)據(jù)庫不意味著不查詢這些數(shù)據(jù)。如果遇到查詢歷史數(shù)據(jù)的需求,我們還是要能支持,比如支付寶的交易數(shù)據(jù)查詢。
訂單分片鍵選擇
每每說到分庫分表,最頭疼的是莫過于如何選擇分片鍵,用戶名?訂單號?還是創(chuàng)建時間?
先說我們的業(yè)務(wù)基本訴求,訂單分庫分表的基本查詢條件有兩種情況
-
「用戶要能查看自己的訂單」 -
「支持訂單號精準(zhǔn)查詢?!?/strong>
這樣的話,我們就需要按照兩個字段當(dāng)做分片鍵,這也就意味著每次查詢時需要帶著用戶和訂單兩個字段,非常的不方便。能不能通過一個字段分庫分表,但是查詢時兩個字段任意傳一個就能精準(zhǔn)查詢,而不導(dǎo)致讀擴(kuò)散問題?
基因法
這就需要用到咱們項目中使用的基因算法。那什么是分庫分表基因算法?
說的通俗易懂點,就是我們通過把用戶的后六位數(shù)據(jù)冗余到訂單號里。這樣的話,我們就可以按照用戶 ID 后六位進(jìn)行分庫分表,并且將分片鍵定義為用戶 ID 和訂單號,只要查詢中攜帶這兩個字段,我們就取用戶 ID 后六位進(jìn)行查找分片表的位置。
這樣我們就可以很好支持分庫分表需求了,同時能滿足用戶和訂單號兩種查詢邏輯,這也是大家熱衷于使用基因算法的原因。
訂單號生成
為了保證訂單號生成遞增,我們參考雪花算法自定義了一個 DistributedIdGenerator,生成后的分布式 ID 再拼接上用戶的后六位。
@Component
@RequiredArgsConstructor
public final class OrderIdGeneratorManager implements InitializingBean {
private static DistributedIdGenerator DISTRIBUTED_ID_GENERATOR;
/**
* 生成訂單全局唯一 ID
*
* @param userId 用戶名
* @return 訂單 ID
*/
public static String generateId(long userId) {
return DISTRIBUTED_ID_GENERATOR.generateId() + String.valueOf(userId % 1000000);
}
}
這種將用戶 ID 后六位拼接訂單號后面的技術(shù)方案,是參考了淘寶的訂單號設(shè)計。
訂單分庫分表代碼實戰(zhàn)
如果你沒有使用過 ShardingSphere 分庫分表操作,可以查看官網(wǎng)進(jìn)行一些前置條件理解。
引入 ShardingSphere 依賴
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core</artifactId>
<version>5.3.2</version>
</dependency>
定義分片規(guī)則
spring:
datasource:
# ShardingSphere 對 Driver 自定義,實現(xiàn)分庫分表等隱藏邏輯
driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver
# ShardingSphere 配置文件路徑
url: jdbc:shardingsphere:classpath:shardingsphere-config.yaml
訂單分片配置
為了避免繁瑣,這里只分 2 個庫以及對應(yīng)業(yè)務(wù) 16 張表。
shardingsphere-config.yaml
# 數(shù)據(jù)源集合,也就是咱們剛才說的分兩個庫
dataSources:
ds_0:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
driverClassName: com.mysql.cj.jdbc.Driver
jdbcUrl: jdbc:mysql://127.0.0.1:3306/12306_order_0?useUnicode=true&characterEncoding=UTF-8&rewriteBatchedStatements=true&allowMultiQueries=true&serverTimezone=Asia/Shanghai
username: root
password: root
ds_1:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
driverClassName: com.mysql.cj.jdbc.Driver
jdbcUrl: jdbc:mysql://127.0.0.1:3306/12306_order_1?useUnicode=true&characterEncoding=UTF-8&rewriteBatchedStatements=true&allowMultiQueries=true&serverTimezone=Asia/Shanghai
username: root
password: root
rules:
# 分片規(guī)則
- !SHARDING
# 分片表
tables:
# 訂單表
t_order:
# 真實的數(shù)據(jù)節(jié)點,也對應(yīng)著在數(shù)據(jù)庫中存儲的真實表
actualDataNodes: ds_${0..1}.t_order_${0..15}
# 分庫策略
databaseStrategy:
# 復(fù)合分庫策略(多個分片鍵)
complex:
# 用戶 ID 和訂單號
shardingColumns: user_id,order_sn
# 搜索 order_database_complex_mod 下方會有分庫算法
shardingAlgorithmName: order_database_complex_mod
# 分表策略
tableStrategy:
# 復(fù)合分表策略(多個分片鍵)
complex:
# 用戶 ID 和訂單號
shardingColumns: user_id,order_sn
# 搜索 order_table_complex_mod 下方會有分表算法
shardingAlgorithmName: order_table_complex_mod
# 訂單明細(xì)表,規(guī)則同訂單表
t_order_item:
actualDataNodes: ds_${0..1}.t_order_item_${0..15}
databaseStrategy:
complex:
shardingColumns: user_id,order_sn
shardingAlgorithmName: order_item_database_complex_mod
tableStrategy:
complex:
shardingColumns: user_id,order_sn
shardingAlgorithmName: order_item_table_complex_mod
# 分片算法
shardingAlgorithms:
# 訂單分庫算法
order_database_complex_mod:
# 通過加載全限定名類實現(xiàn)分片算法,相當(dāng)于分片邏輯都在 algorithmClassName 對應(yīng)的類中
type: CLASS_BASED
props:
algorithmClassName: org.opengoofy.index12306.biz.orderservice.dao.algorithm.OrderCommonDataBaseComplexAlgorithm
# 分庫數(shù)量
sharding-count: 2
# 復(fù)合(多分片鍵)分庫策略
strategy: complex
# 訂單分表算法
order_table_complex_mod:
# 通過加載全限定名類實現(xiàn)分片算法,相當(dāng)于分片邏輯都在 algorithmClassName 對應(yīng)的類中
type: CLASS_BASED
props:
algorithmClassName: org.opengoofy.index12306.biz.orderservice.dao.algorithm.OrderCommonTableComplexAlgorithm
# 分表數(shù)量
sharding-count: 16
# 復(fù)合(多分片鍵)分表策略
strategy: complex
order_item_database_complex_mod:
type: CLASS_BASED
props:
algorithmClassName: org.opengoofy.index12306.biz.orderservice.dao.algorithm.OrderCommonDataBaseComplexAlgorithm
sharding-count: 2
strategy: complex
order_item_table_complex_mod:
type: CLASS_BASED
props:
algorithmClassName: org.opengoofy.index12306.biz.orderservice.dao.algorithm.OrderCommonTableComplexAlgorithm
sharding-count: 16
strategy: complex
props:
sql-show: true
分片算法解析
調(diào)試的話可以分為兩種,一種是創(chuàng)建訂單,一種是查看訂單,控制臺都有現(xiàn)成的功能,Debug 到分片算法方法上就可以。
因為訂單和訂單明細(xì)表都是按照用戶和訂單號進(jìn)行的分片,分片算法規(guī)則一致,所以就進(jìn)行了復(fù)用。
訂單分庫分片算法代碼如下:
/**
* 訂單數(shù)據(jù)庫復(fù)合分片算法配置
* ComplexKeysShardingAlgorithm 是 ShardingSphere 預(yù)留出來的可擴(kuò)展分片算法接口
* 注意:不同版本的 ShardingSphere 可能包路徑、類名或者方法名不一致
*/
public class OrderCommonDataBaseComplexAlgorithm implements ComplexKeysShardingAlgorithm {
@Getter
private Properties props;
// 分庫數(shù)量,讀取的配置中定義的分庫數(shù)量
private int shardingCount;
private static final String SHARDING_COUNT_KEY = "sharding-count";
@Override
public Collection<String> doSharding(Collection availableTargetNames, ComplexKeysShardingValue shardingValue) {
Map<String, Collection<Comparable<Long>>> columnNameAndShardingValuesMap = shardingValue.getColumnNameAndShardingValuesMap();
Collection<String> result = new LinkedHashSet<>(availableTargetNames.size());
if (CollUtil.isNotEmpty(columnNameAndShardingValuesMap)) {
String userId = "user_id";
// 首先判斷 SQL 是否包含用戶 ID,如果包含直接取用戶 ID 后六位
Collection<Comparable<Long>> customerUserIdCollection = columnNameAndShardingValuesMap.get(userId);
if (CollUtil.isNotEmpty(customerUserIdCollection)) {
// 獲取到 SQL 中包含的用戶 ID 對應(yīng)值
Comparable<?> comparable = customerUserIdCollection.stream().findFirst().get();
// 如果使用 MybatisPlus 因為傳入時沒有強(qiáng)類型判斷,所以有可能用戶 ID 是字符串,也可能是 Long 等數(shù)值
// 比如傳入的用戶 ID 可能是 1683025552364568576 也可能是 '1683025552364568576'
// 根據(jù)不同的值類型,做出不同的獲取后六位判斷。字符串直接截取后六位,Long 類型直接通過 % 運(yùn)算獲取后六位
if (comparable instanceof String) {
String actualOrderSn = comparable.toString();
// 獲取真實數(shù)據(jù)庫的方法其實還是通過 HASH_MOD 方式取模的,shardingCount 就是咱們配置中的分庫數(shù)量
result.add("ds_" + hashShardingValue(actualOrderSn.substring(Math.max(actualOrderSn.length() - 6, 0))) % shardingCount);
} else {
String dbSuffix = String.valueOf(hashShardingValue((Long) comparable % 1000000) % shardingCount);
result.add("ds_" + dbSuffix);
}
} else {
// 如果對訂單中的 SQL 語句不包含用戶 ID 那么就要從訂單號中獲取后六位,也就是用戶 ID 后六位
// 流程同用戶 ID 獲取流程
String orderSn = "order_sn";
Collection<Comparable<Long>> orderSnCollection = columnNameAndShardingValuesMap.get(orderSn);
Comparable<?> comparable = orderSnCollection.stream().findFirst().get();
if (comparable instanceof String) {
String actualOrderSn = comparable.toString();
result.add("ds_" + hashShardingValue(actualOrderSn.substring(Math.max(actualOrderSn.length() - 6, 0))) % shardingCount);
} else {
result.add("ds_" + hashShardingValue((Long) comparable % 1000000) % shardingCount);
}
}
}
// 返回的是表名,
return result;
}
@Override
public void init(Properties props) {
this.props = props;
shardingCount = getShardingCount(props);
}
private int getShardingCount(final Properties props) {
Preconditions.checkArgument(props.containsKey(SHARDING_COUNT_KEY), "Sharding count cannot be null.");
return Integer.parseInt(props.getProperty(SHARDING_COUNT_KEY));
}
private long hashShardingValue(final Comparable<?> shardingValue) {
return Math.abs((long) shardingValue.hashCode());
}
}
訂單分表算法邏輯基本與訂單分庫算法一致,大家查看代碼也基本上都能清楚,就不再過多贅述。
/**
* 訂單表相關(guān)復(fù)合分片算法配置
*/
public class OrderCommonTableComplexAlgorithm implements ComplexKeysShardingAlgorithm {
@Getter
private Properties props;
private int shardingCount;
private static final String SHARDING_COUNT_KEY = "sharding-count";
@Override
public Collection<String> doSharding(Collection availableTargetNames, ComplexKeysShardingValue shardingValue) {
Map<String, Collection<Comparable<?>>> columnNameAndShardingValuesMap = shardingValue.getColumnNameAndShardingValuesMap();
Collection<String> result = new LinkedHashSet<>(availableTargetNames.size());
if (CollUtil.isNotEmpty(columnNameAndShardingValuesMap)) {
String userId = "user_id";
Collection<Comparable<?>> customerUserIdCollection = columnNameAndShardingValuesMap.get(userId);
if (CollUtil.isNotEmpty(customerUserIdCollection)) {
Comparable<?> comparable = customerUserIdCollection.stream().findFirst().get();
if (comparable instanceof String) {
String actualOrderSn = comparable.toString();
result.add(shardingValue.getLogicTableName() + "_" + hashShardingValue(actualOrderSn.substring(Math.max(actualOrderSn.length() - 6, 0))) % shardingCount);
} else {
String dbSuffix = String.valueOf(hashShardingValue((Long) comparable % 1000000) % shardingCount);
result.add(shardingValue.getLogicTableName() + "_" + dbSuffix);
}
} else {
String orderSn = "order_sn";
Collection<Comparable<?>> orderSnCollection = columnNameAndShardingValuesMap.get(orderSn);
Comparable<?> comparable = orderSnCollection.stream().findFirst().get();
if (comparable instanceof String) {
String actualOrderSn = comparable.toString();
result.add(shardingValue.getLogicTableName() + "_" + hashShardingValue(actualOrderSn.substring(Math.max(actualOrderSn.length() - 6, 0))) % shardingCount);
} else {
String dbSuffix = String.valueOf(hashShardingValue((Long) comparable % 1000000) % shardingCount);
result.add(shardingValue.getLogicTableName() + "_" + dbSuffix);
}
}
}
return result;
}
@Override
public void init(Properties props) {
this.props = props;
shardingCount = getShardingCount(props);
}
private int getShardingCount(final Properties props) {
Preconditions.checkArgument(props.containsKey(SHARDING_COUNT_KEY), "Sharding count cannot be null.");
return Integer.parseInt(props.getProperty(SHARDING_COUNT_KEY));
}
private long hashShardingValue(final Comparable<?> shardingValue) {
return Math.abs((long) shardingValue.hashCode());
}
}
架構(gòu)的設(shè)計沒有終點,架構(gòu)的盡頭是架構(gòu)師,架構(gòu)師的盡頭是找到最佳平衡點!
