2016年春运刚过,12306购票系统又一次被推到风头浪尖,这次春运12306系统表现还是不错的,除了神一样的验证码以外,基本上没有出现过大面积长时间的故障。12306系统复杂程度、系统如何设计,作为技术人员值得深入思考一下。
##12306数据分析
- 预售期:60天
- 车站:3000+
- 车次:7000/天
- 日期车次:7000 X 60=42万
- 站站:21万/天
- 日期站站:21万x 60=1260万
- 商品(站站、车次、日期、座位类型):1.5亿左右
商品间存在关联,当售出一张票后,两站间的库存都要减少.
- 2015年春节访问量峰值当天:共有297亿pv次访问,536.9万张票
- 访问量平均值;51万 qps
- 访问量峰值(平均值2倍计算);51万 x 2 = 102万 qps
虽然访问量很高,但主要集中在搜索查询上,搜索的压力体现在库存管理上.
##架构设计思路
- 如上图:系统架构可分为几大块,数据分析和维护一块,数据服务一块,交易系统、用户中心及其它平台一块;
- 交易系统和用户中心这里不赘述;
- 原始数据来源这块,应该包括数据分析和数据维护部分,数据分析分为离线数据分析和实时数据分析;数据分析在火车票这个方向上,是非常值得深究的,包括火车调度、火车调图、增减车次、运力评估及应急预案分析等等;有兴趣的同学可以深入研究一下;
- 数据服务这块是本文重点(红圈部分),由于火车票售票系统和传统的电商平台在数据结构和业务上的差别,在设计上要下一些功夫;
- 在数据上我们可以把数据分为静态数据和动态数据,二者区别是更新频率,而我们最关注库存问题,就是要讨论的动态数据;
- 库存管理外侧我们需要软负载,按照日期车次分片,同一日期车次使用同一台服务器,可免去分布式锁问题;如果是联程或往返仍需分布式锁(目前联程和往返12306还没有真正支持,方案上大家可以思考一下);
- 存储方面需要有分布式cache存储和分布式持久化存储两层,确保数据高效且不丢失。
##基础数据设计思路
- 如上图:原始数据可以包括车次日历、车次信息日历、车次信息及日期车次库存等,核心数据保存在车次信息部分,这有个技巧就是同一车次不同日期车次信息不同的存储问题,即0–20160315–20160418;
- 车次车厢数据这块,考虑到存储空间问题,可以简化为根据车厢编号和车厢类型计算出座位编号,比如1车1排D座顺序号是4;
- 基础数据维护系统补充原始数据,并由原始数据生成查询服务数据,静态数据提前生成,动态数据实时计算,站站车次库存与日期车次库存之间可以有一定缓存时间以减少计算量;
- 日期车次库存是动态数据是经常变化的,要充分考虑空间和时间复杂度,后面有具体设计思路;
- 其它数据可参考上面设计,如果想节约空间,设计上可以多用数字或Byte数组替代字符串等方式。
##库存管理设计思路
购票占座其实就是找到符合条件的座位,假设有一列经过A到F ,6个车站的火车,车上共有10个座位,大体需要有以下几种场景需要满足:
- 场景1:当卖掉了一张A到F的车票,则AF之间所有站站库存要减1,若果经过100个站,则需要C(100,2)=4950次操作;
- 场景2:列车中途需要增加减少车厢,或者有一节车厢不对外出售;
- 场景3:为了降低空座率,需要特定站站最多卖票数量,特定出发站的最多卖票数量;
- 场景4:由于座位的唯一性,需要标示特定座位某站站已被卖出,并且找到满足特定条件的座位,如果1列火车有2000个座位,这个运算就是一个挑战;
- 场景5:某特定车厢或座位只能卖给特定始发站的人群,即某特定车厢是给某个车站预留的;
###数据结构和算法设计
由于车站A,B,C,D,E,F顺序固定,可以用用数字表示为(1,2,3,4,5,6),相邻车站站 (AB,BC,CD,DE,EF)顺序号可表示为(0,1,2,3,4);
-
购票流程:
-
1、输入车次、站站(AE)、坐席类型信息;
-
2、根据站站找到A和E的位置信息,即1和5;
-
3、根据车次站站库存判断索引,判断该车次站站是否有余票,若果有继续;
-
4、根据购买限制索引,判断站站余票数量限制,如果可以购买继续;
-
5、遍历座位站站库存判断索引数组,找到该站站有余票的座位索引编号;
-
6、更新座位站站库存判断索引值,相关位置置1,说明该站站座位票已卖出;
-
7、相邻站库存数组,AE间相邻站库存减1;
-
8、更新车次站站库存判断索引,如果AE间相邻站库存为0,则对应标示位置1,表示该邻近站站已无库存;
-
9、更新购买限制索引,如果存在限制索引则减1;
-
10、返回座位索引编号;
-
取消票流程:
-
1、输入车次、站站(AE)、座位编号;
-
2、根据座位编号找到座位站站库存判断索引,相关索引位置置0,说明该站站座位票未卖出;
-
3、更新相邻站库存,AE间相邻站库存加1;
-
4、更新车次站站库存判断索引,AE间相邻站站位置置0,说明AE间所有站站还有库存;
-
5、更新购买限制索引,如果存在限制索引则加1;
-
余票查询流程
-
1、输入车次、站站(AE)、坐席类型信息;
-
2、根据车次站站库存判断索引,判断该车次站站是否有余票,若有记录限制值并,若无返回0;
-
3、遍历AE间相邻站(AB,BC,CD,DE)间余票,记录相邻站站余票最小值min(AB,BC,CD,DE);
-
4、返回min(限制值,相邻站站余票最小值);
以100车站2000座位车次为例,存储空间应不超过3K,其实大多数车次不超过10个车站1000个座位,基本上1.5K能搞定,理想情况下42W的日期车次库存数据不超过1G的存储空间。当然这个不包括存储数据本身需要的数据结构维护的空间,这也与实现方式和存储方案有关;
整个实时计算过程中采用位运算和数组操作,模拟一个100车站2000坐席的车次,在出票情况最多的场景下,即每次都购买相邻站并且每次都需要遍历,不存在被可购买索引挡出的情况下,订票19.8万张票用时17秒,即每个日期车次每秒钟可订票11000张。理想情况下最多每秒钟可出47万(日期车次数量)x1.1万张票,即47亿张,如果每个日期车次最多只售卖1万张票,则所有票可在1秒内出完。
###算法代码
import java.util.BitSet;
import java.util.HashMap;
import java.util.Map;
/**
* Created by jerrysun on 16/2/23.
*/
public class TrainStock {
public Map<Integer, Integer> stationMap = new HashMap<Integer, Integer>();
public BitSet canBookIndex;
public Map<Integer, Integer> s2sMaxCountMap = new HashMap<Integer, Integer>();
public Integer[] s2sTickets;
public BitSet[] seatCanBookIndex;
@Override
public String toString() {
String temp = "stationMap:" + stationMap.toString() + "\r\n";
temp = temp + "s2sTickets:";
for (int i = 0; i < s2sTickets.length; i++) {
temp = temp + "," + s2sTickets[i];
}
temp = temp + "\r\n";
temp = temp + "canBookIndex:" + canBookIndex.toString() + "\r\n";
temp = temp + "s2sMaxCountMap:" + s2sMaxCountMap.toString() + "\r\n";
temp = temp + "seatCanBookIndex:";
for (int i = 0; i < seatCanBookIndex.length; i++) {
temp = temp + "," + seatCanBookIndex[i].toString();
}
temp = temp + "\r\n";
return temp;
}
public Integer bookTicket(Integer fromStation, Integer toStation) {
Integer fIdx = stationMap.get(fromStation);
Integer tIdx = stationMap.get(toStation);
BitSet bookIndex = this.canBookIndex.get(fIdx, tIdx);
if (bookIndex.isEmpty()) {
Integer maxCount = s2sMaxCountMap.get(fromStation * 10000 + toStation);
if (maxCount != null && maxCount > 0 || maxCount == null) {
for (int i = 0; i < this.seatCanBookIndex.length; i++) {
BitSet seatBookIndex = this.seatCanBookIndex[i].get(fIdx, tIdx);
if (seatBookIndex.isEmpty()) {
for (int j = fIdx; j < tIdx; j++) {
int temp1 = s2sTickets[j] - 1;
s2sTickets[j] = temp1;
if (temp1 == 0)
canBookIndex.set(j);
this.seatCanBookIndex[i].set(j);
}
if (maxCount != null)
this.s2sMaxCountMap.put(fromStation * 10000 + toStation, maxCount - 1);
return i;
}
}
return -101;
} else
return -102;
} else {
return -100;
}
}
public Integer cancleTicket(Integer fromStation, Integer toStation, int seatIndex) {
Integer fIdx = stationMap.get(fromStation);
Integer tIdx = stationMap.get(toStation);
for (int j = fIdx; j < tIdx; j++) {
int temp1 = s2sTickets[j] + 1;
s2sTickets[j] = temp1;
if (temp1 == 0)
canBookIndex.set(j);
this.seatCanBookIndex[seatIndex].set(j, false);
}
Integer maxCount = this.s2sMaxCountMap.get(fromStation * 10000 + toStation);
if (maxCount != null)
this.s2sMaxCountMap.put(fromStation * 10000 + toStation, maxCount + 1);
return 1;
}
public Integer getStock(Integer fromStation, Integer toStation) {
Integer fIdx = this.stationMap.get(fromStation);
Integer tIdx = this.stationMap.get(toStation);
int temp1 = 10000;
for (int j = fIdx; j < tIdx; j++) {
if (temp1 > s2sTickets[j])
temp1 = s2sTickets[j];
}
return temp1;
}
}
###算法测试结果
本机测试、只测试算法、内存存储,在实际环境和业务场景下会有偏差。
功能及数据测试:模拟6车站10坐席,经过测试可模拟假设场景。
stationMap:{1=0, 2=1, 3=2, 4=3, 5=4, 6=5}
s2sTickets:,10,10,10,10,10
canBookIndex:{}
s2sMaxCountMap:{40005=10, 1=10, 20003=10, 30004=10, 10002=10}
seatCanBookIndex:,{},{},{},{},{},{},{},{},{},{}
1ms
Book s2s=1_2:0
stationMap:{1=0, 2=1, 3=2, 4=3, 5=4, 6=5}
s2sTickets:,9,10,10,10,10
canBookIndex:{}
s2sMaxCountMap:{40005=10, 1=10, 20003=10, 30004=10, 10002=9}
seatCanBookIndex:,{0},{},{},{},{},{},{},{},{},{}
4ms
Book s2s=2_3:0
stationMap:{1=0, 2=1, 3=2, 4=3, 5=4, 6=5}
s2sTickets:,9,9,10,10,10
canBookIndex:{}
s2sMaxCountMap:{40005=10, 1=10, 20003=9, 30004=10, 10002=9}
seatCanBookIndex:,{0, 1},{},{},{},{},{},{},{},{},{}
1ms
Cancle s2s=1_2_0
stationMap:{1=0, 2=1, 3=2, 4=3, 5=4, 6=5}
s2sTickets:,10,9,10,10,10
canBookIndex:{}
s2sMaxCountMap:{40005=10, 1=10, 20003=9, 30004=10, 10002=10}
seatCanBookIndex:,{1},{},{},{},{},{},{},{},{},{}
1ms
Cancle s2s=2_3_0
stationMap:{1=0, 2=1, 3=2, 4=3, 5=4, 6=5}
s2sTickets:,10,10,10,10,10
canBookIndex:{}
s2sMaxCountMap:{40005=10, 1=10, 20003=10, 30004=10, 10002=10}
seatCanBookIndex:,{},{},{},{},{},{},{},{},{},{}
19ms
Get s2s=1_2:10
stationMap:{1=0, 2=1, 3=2, 4=3, 5=4, 6=5}
s2sTickets:,10,10,10,10,10
canBookIndex:{}
s2sMaxCountMap:{40005=10, 1=10, 20003=10, 30004=10, 10002=10}
seatCanBookIndex:,{},{},{},{},{},{},{},{},{},{}
19ms
Get s2s=2_3:10
stationMap:{1=0, 2=1, 3=2, 4=3, 5=4, 6=5}
s2sTickets:,10,10,10,10,10
canBookIndex:{}
s2sMaxCountMap:{40005=10, 1=10, 20003=10, 30004=10, 10002=10}
seatCanBookIndex:,{},{},{},{},{},{},{},{},{},{}
性能测试:模拟100车站2000坐席,订票19.8万张票用时17秒,每秒钟可订票11000张,查询余票性能和订票持平,取消票性能更快一些;在订票和取消票需要加写锁,实际情况查询余票是按站站车次坐席类型查询,可并发提高相关性能性能。
Book Ticket
17599ms
198000times
0.08888384ms
11000/s
==========================
Get Stock
17700ms
198000times
0.089393936ms
11000/s
测试代码
import org.junit.Test;
import java.util.*;
/**
* Created by 再青 .
*/
public class TrainStockTest {
@Test
public void testData() throws Exception {
Integer stationCount = 6;
Integer seatCount = 10;
TrainStock trainStock = buildTrainStock(stationCount, seatCount);
System.out.println(trainStock.toString());
//已出票索引,这么设计存储有性能问题,其实可以优化为List<Byte[]>,数据按位存储,实际应用中应该是和订单相关信息
Map<String, TrainSeatMsg> bookedSeatMap = new HashMap<String, TrainSeatMsg>();
//订票测试,并计入已出票
long start = System.currentTimeMillis();
for (int i = 1; i < 3; i++) {
int num = trainStock.bookTicket(i, i + 1);
if (num >= 0) {
TrainSeatMsg trainSeatMsg = new TrainSeatMsg();
trainSeatMsg.setFromStation(i);
trainSeatMsg.setToStation(i + 1);
trainSeatMsg.setSeatIndex(num);
bookedSeatMap.put(i + "_" + (i + 1) + "_" + num, trainSeatMsg);
}
System.out.println((System.currentTimeMillis() - start) + "ms");
System.out.println("Book s2s=" + i + "_" + (i + 1) + ":" + num);
System.out.println(trainStock.toString());
}
//取消订票测试
long start1 = System.currentTimeMillis();
for (TrainSeatMsg value : bookedSeatMap.values()) {
int num = trainStock.cancleTicket(value.fromStation, value.toStation, value.seatIndex);
System.out.println((System.currentTimeMillis() - start1) + "ms");
System.out.println("Cancle s2s=" + value.fromStation + "_" + value.toStation + "_" + value.seatIndex);
System.out.println(trainStock.toString());
}
//余票查询
long start2 = System.currentTimeMillis();
for (int i = 1; i < stationCount; i++) {
int num = trainStock.getStock(i, i + 1);
System.out.println((System.currentTimeMillis() - start) + "ms");
System.out.println("Get s2s=" + i + "_" + (i + 1) + ":" + num);
System.out.println(trainStock.toString());
}
}
@Test
public void testPerformance() throws Exception {
Integer stationCount = 100;
Integer seatCount = 2000;
TrainStock trainStock = buildTrainStock(stationCount, seatCount);
System.out.println(trainStock.toString());
//订票测试,模拟stationCount*seatCount次,模拟出票情况最多的情况,即每次都购买相邻站并且每次都需要遍历,不存在被可购买索引挡出的情况
Integer bookcount=0;
System.out.println("Book Ticket");
long start = System.currentTimeMillis();
for (int i = 1; i < stationCount; i++) {
for (int j = 0; j < seatCount; j++) {
int num = trainStock.bookTicket(i, i + 1);
bookcount++;
}
}
long bookTotalTime= System.currentTimeMillis() - start;
System.out.println(bookTotalTime + "ms");
System.out.println(bookcount + "times");
System.out.println((float)bookTotalTime/(float)bookcount + "ms");
System.out.println(bookcount/bookTotalTime*1000+"/s");
//System.out.println(trainStock.toString());
System.out.println("==========================");
//余票查询,模拟stationCount*seatCount次,模拟最耗时的始发站到终点站的查询
Integer getcount=0;
System.out.println("Get " + "Stock");
long start1 = System.currentTimeMillis();
for (int i = 1; i < stationCount; i++) {
for (int j = 0; j < seatCount; j++) {
int num = trainStock.getStock(1,stationCount);
getcount++;
}
}
long getTotalTime= System.currentTimeMillis() - start;
System.out.println(getTotalTime + "ms");
System.out.println(getcount + "times");
System.out.println((float)getTotalTime/(float)getcount + "ms");
System.out.println(getcount/getTotalTime*1000+"/s");
//System.out.println(trainStock.toString());
}
/**
* 构建车次库存数据实体
*
* @param stationCount 车站数量
* @param seatCount 座位数量
* @return
*/
private TrainStock buildTrainStock(Integer stationCount, Integer seatCount) {
TrainStock trainStock = new TrainStock();
//构建车站map,车站索引从1开始构建
Integer[] stations = new Integer[stationCount];
for (int i = 0; i < stationCount; i++) {
stations[i] = i + 1;
}
Map<Integer, Integer> stationMap = new HashMap<Integer, Integer>();
for (int i = 0; i < stationCount; i++) {
stationMap.put(stations[i], i);
}
//构建可预定判断索引
BitSet canBookIndex = new BitSet(stationCount - 1);
//构建车次购买限制Map,构建相邻车站最大可购买数量
Map<Integer, Integer> s2sMaxCountMap = new HashMap<Integer, Integer>();
for (int i = 0; i < stationCount - 1; i++) {
s2sMaxCountMap.put(i * 10000 + i + 1, seatCount);
}
//构建相邻俩站剩余库存
Integer[] s2stickets = new Integer[stationCount - 1];
for (int i = 0; i < stationCount - 1; i++) {
s2stickets[i] = seatCount;
}
//构建座位可预定判断索引Map
BitSet[] seatCanBookIndexs = new BitSet[seatCount];
for (int i = 0; i < seatCanBookIndexs.length; i++) {
seatCanBookIndexs[i] = new BitSet(stationCount - 1);
}
//构建库存数据实体类
trainStock.stationMap=stationMap;
trainStock.canBookIndex=canBookIndex;
trainStock.s2sMaxCountMap=s2sMaxCountMap;
trainStock.s2sTickets=s2stickets;
trainStock.seatCanBookIndex=seatCanBookIndexs;
return trainStock;
}
/**
* 车次库存
*/
public class TrainStock {
public Map<Integer, Integer> stationMap = new HashMap<Integer, Integer>();
public BitSet canBookIndex;
public Map<Integer, Integer> s2sMaxCountMap = new HashMap<Integer, Integer>();
public Integer[] s2sTickets;
public BitSet[] seatCanBookIndex;
@Override
public String toString() {
String temp = "stationMap:" + stationMap.toString() + "\r\n";
temp = temp + "s2sTickets:";
for (int i = 0; i < s2sTickets.length; i++) {
temp = temp + "," + s2sTickets[i];
}
temp = temp + "\r\n";
temp = temp + "canBookIndex:" + canBookIndex.toString() + "\r\n";
temp = temp + "s2sMaxCountMap:" + s2sMaxCountMap.toString() + "\r\n";
temp = temp + "seatCanBookIndex:";
for (int i = 0; i < seatCanBookIndex.length; i++) {
temp = temp + "," + seatCanBookIndex[i].toString();
}
temp = temp + "\r\n";
return temp;
}
public Integer bookTicket(Integer fromStation, Integer toStation) {
Integer fIdx = stationMap.get(fromStation);
Integer tIdx = stationMap.get(toStation);
BitSet bookIndex = this.canBookIndex.get(fIdx, tIdx);
if (bookIndex.isEmpty()) {
Integer maxCount = s2sMaxCountMap.get(fromStation * 10000 + toStation);
if (maxCount != null && maxCount > 0 || maxCount == null) {
for (int i = 0; i < this.seatCanBookIndex.length; i++) {
BitSet seatBookIndex = this.seatCanBookIndex[i].get(fIdx, tIdx);
if (seatBookIndex.isEmpty()) {
for (int j = fIdx; j < tIdx; j++) {
int temp1 = s2sTickets[j] - 1;
s2sTickets[j] = temp1;
if (temp1 == 0)
canBookIndex.set(j);
this.seatCanBookIndex[i].set(j);
}
if (maxCount != null)
this.s2sMaxCountMap.put(fromStation * 10000 + toStation, maxCount - 1);
return i;
}
}
return -101;
} else
return -102;
} else {
return -100;
}
}
public Integer cancleTicket(Integer fromStation, Integer toStation, int seatIndex) {
Integer fIdx = stationMap.get(fromStation);
Integer tIdx = stationMap.get(toStation);
for (int j = fIdx; j < tIdx; j++) {
int temp1 = s2sTickets[j] + 1;
s2sTickets[j] = temp1;
if (temp1 == 0)
canBookIndex.set(j);
this.seatCanBookIndex[seatIndex].set(j, false);
}
Integer maxCount = this.s2sMaxCountMap.get(fromStation * 10000 + toStation);
if (maxCount != null)
this.s2sMaxCountMap.put(fromStation * 10000 + toStation, maxCount + 1);
return 1;
}
public Integer getStock(Integer fromStation, Integer toStation) {
Integer fIdx = this.stationMap.get(fromStation);
Integer tIdx = this.stationMap.get(toStation);
int temp1 = 10000;
for (int j = fIdx; j < tIdx; j++) {
if (temp1 > s2sTickets[j])
temp1 = s2sTickets[j];
}
return temp1;
}
}
/**
* 车次坐席信息
*/
public class TrainSeatMsg {
Integer fromStation;
Integer toStation;
Integer seatIndex;
public Integer getSeatIndex() {
return seatIndex;
}
public void setSeatIndex(Integer seatIndex) {
this.seatIndex = seatIndex;
}
public Integer getFromStation() {
return fromStation;
}
public void setFromStation(Integer fromStation) {
this.fromStation = fromStation;
}
public Integer getToStation() {
return toStation;
}
public void setToStation(Integer toStation) {
this.toStation = toStation;
}
}
}