|
| 1 | +# 12306 架构设计难点 |
| 2 | + |
| 3 | +## 12306 中有哪些难点呢? |
| 4 | + |
| 5 | +先从业务角度上来说的话: |
| 6 | + |
| 7 | +- 对于抢票来说,瞬时抢票会导致对服务器有瞬间很大的压力,因此从业务设计上来说需要将抢票的压力给分散开,比如今天才开启抢 15 天之后的车票 |
| 8 | + |
| 9 | +- 对于库存来说,车票库存的设计是个难点,就比如 A -> B -> C -> D 共 4 个车站,假如乘客买了 B -> C 的车票,那么同时会影响到 A->C,A->D,B->C,B->D,涉及了多个车站的排列组合(这里计算是比较耗费性能的) |
| 10 | + |
| 11 | + 那么这里就涉及到了 `“读扩散”` 和 `“写扩散”` 的问题,在 12 年的时候,12306 使用的就是读扩散,也就是在扣减余票库存的时候,直接扣减对应车站,而在查询的时候,进行动态计算,而写扩散就是在写的时候,就动态计算每个车站应该扣除多少余票库存,在查询的时候直接查即可 |
| 12 | + |
| 13 | + 12306 是读多写少的场景,海哥认为使用写扩散比较好一些,这样可以减轻查询端的压力 |
| 14 | + |
| 15 | +- 对于扩容来说,在节假日与非节假日 12306 的流量差别是非常大的,因此必须要有动态扩容的能力 |
| 16 | + |
| 17 | + |
| 18 | + |
| 19 | +那么在技术角度上来看,难点主要有: |
| 20 | + |
| 21 | +- 首页读多写少,可以给首页部分内容做静态化处理,比如个人身份的信息、列车班次等不会变化的信息 |
| 22 | +- 抢票时,是一个高并发的操作 |
| 23 | + |
| 24 | + |
| 25 | + |
| 26 | +**12306 为什么选择 Pivotal Gemfire 而不是 Redis 呢?** |
| 27 | + |
| 28 | +Redis 在互联网公司中使用的是比较多的,而在银行、12306 很多实时交易的系统中,很多采用 Pivotal Gemfire 作为解决方案 |
| 29 | + |
| 30 | +Redis 是开源的缓存解决方案,而 Pivotal Gemfire 是商用的,我们在互联网项目中为什么使用 Redis 比较多呢,就是因为 Redis 是开源的,不要钱,开源对应的也就是稳定性不是那么的强,并且开源社区也不会给你提供解决方案,毕竟你是白嫖的,而在银行以及 12306 这些系统中,它们对可靠性要求非常的高,因此会选择商用的 Pivotal Gemfire,不仅性能强、高可用,而且 Gemfire 还会提供一系列的解决方案,据说做到了分布式系统中的 CAP(常识:分布式系统中,CAP 无法同时满足) |
| 31 | + |
| 32 | + |
| 33 | + |
| 34 | +**12306 的性能瓶颈** |
| 35 | + |
| 36 | +12306 的性能瓶颈就在于余票的查询操作上,上边已经说了,12306 是采用读扩散,也就是客户买票之后,扣减库存只扣减对应车站之间的余票库存,在读的时候,再来动态的计算每个站点应该有多少余票,因此读性能是 12306 的性能瓶颈 |
| 37 | + |
| 38 | +当时 12306 也尝试了许多其他的解决方案,比如 cassandra 和 mamcached,都扛不住查询的流量,而使用 Gemfire 之后扛住了流量,因此就使用了 Gemfire |
| 39 | + |
| 40 | + |
| 41 | + |
| 42 | +**Gemfire 的亮点** |
| 43 | + |
| 44 | +Gemfire 的存储和计算都在一个地方,它的存储和实时计算的性能目前还没有其他中间件可以取代 |
| 45 | + |
| 46 | +但是 Gemfire 也存在不足的地方,对于扩容的支持不太友好的,因为它里边有一个 Bucket 类似于 Topic 的概念,定好 Bucket 之后,扩容是比较难的,在 12306 中,也有过测试,需要几十个 T 的内存就可以将业务数据全部放到内存中来,因此直接将内存给加够,也就不需要很频繁的扩容 |
| 47 | + |
| 48 | +db-engines.com 这个网站可以对比主流数据库之间的差异 |
| 49 | + |
| 50 | + |
| 51 | + |
| 52 | +**每个车站余票的设计** |
| 53 | + |
| 54 | +就比如, A->B->C->D 共 4 个车站,车上只有 100 个座位,给哪些区间分配多少余票呢? |
| 55 | + |
| 56 | +这个是通过运营部来进行设计,首先考虑的肯定是要盈利,远途票价比较贵,因此比较倾向于远途的旅客,因此不会存在 B->C 站点比较火爆而导致 A->D 买不到票的情况 |
| 57 | + |
| 58 | +但是短途旅客又不能没有票,因此给每个车站都会放置一些余票 |
| 59 | + |
| 60 | + |
| 61 | + |
| 62 | +## 余票库存的表如何设计? |
| 63 | + |
| 64 | +这里的设计思路都是猜测的,并不一定是 12306 真实设计方案 |
| 65 | + |
| 66 | +**12306 余票库存的表的设计是非常特色并且重要的** |
| 67 | + |
| 68 | +首先说一下需要几个表来表示余票的库存信息: |
| 69 | + |
| 70 | +1、基础的车次表:表示车次的编号以及发车时间等具体的车次信息,属于比较稳定的数据 |
| 71 | + |
| 72 | +2、车的座位表:表示每个座位的具体信息,包括在几车厢、几行、几列,以及 `该座位的售卖情况` |
| 73 | + |
| 74 | +3、车的余票表:通过座位表可以计算出每个车位在各个车站区间还有多少余票,但是动态计算比较浪费性能,因此再添加余票表,通过定时计算余票信息放入到余票表中,提高查询的性能 |
| 75 | + |
| 76 | +(其实还应该有一个车厢表,不过不太重要,这里直接就省略了) |
| 77 | + |
| 78 | +**这里说一下这 3 个表的对应关系:** |
| 79 | + |
| 80 | +比如车次为 K123,该车上有很多的座位,每个座位对应座位表中的一条数据 |
| 81 | + |
| 82 | +而余票表指的是 K123 车次上,硬座、硬卧、软卧、无座各有多少张余票,余票表的信息可以由座位表来计算得到 |
| 83 | + |
| 84 | +**接下来说一下如何通过通过座位表来表示用户购买的车票:** |
| 85 | + |
| 86 | +12306 中的车票信息其实是比较复杂的,因为各个车站之间是有依赖关系的,比如 4 个车站 A->B->C->D |
| 87 | + |
| 88 | +如果乘客购买 B->C 的车票的话,不仅 B->C 的库存要减一,B->D 的库存也要减一,这是排列组合的情况,可以考虑通过二进制去简化车票的表示 |
| 89 | + |
| 90 | +在座位表中,我们设置一个字段 `sell varchar(50)` 表示该座位的售卖情况,如果该车次有 4 个站 A->B->C->D,那么 sell 字段的长度就为 3,sell 字段的第一位表示该座位 A->B 的票是否已经被买了,第二位表示 B->C 的票是否已经被买了... |
| 91 | + |
| 92 | +如果乘客购买 B->C 的车票,则 sell 字段的值为:`010` |
| 93 | + |
| 94 | +如果乘客购买 B->D 的车票,此时发现该座位在 B->C 已经被卖出去了,因此不能将该座位出售给这位乘客 |
| 95 | + |
| 96 | +如果乘客购买 C->D 的车票,则 sell 字段的值为:`011` ,表示 B->C,C->D 都已经有人了 |
| 97 | + |
| 98 | + |
| 99 | + |
| 100 | +**通过余票表提升查询性能** |
| 101 | + |
| 102 | +这里余票表就相当于是数据库中的视图 |
| 103 | + |
| 104 | +如果要去查询一个车次中某一个类型的余票还有多少,还需要去对座位表进行计算,这个消耗是比较大的 ,因此通过余票表来加快对于余票的查询 |
| 105 | + |
| 106 | +可以定时去计算座位表中的数据,将每种类型的座位的余票给统计出来,比如: |
| 107 | + |
| 108 | +```java |
| 109 | +硬卧:xx张 |
| 110 | +硬座:xx张 |
| 111 | +软卧:xx张 |
| 112 | +... |
| 113 | +``` |
| 114 | + |
| 115 | +再将余票表的信息给放入到缓存中,大大提高查询的性能 |
| 116 | + |
| 117 | +我们在使用 12306 的时候,也会发现,有时候显示的有票,但是真正去买的时候发现已经没有余票了,这就说明 12306 没有保证实时的一致性,只要保证了最终一致性即可,也就是用户真正去买的时候,保证对于余票数量的查询是准确的就可以了 |
| 118 | + |
| 119 | + |
| 120 | + |
| 121 | +**怎么避免远程旅客买不到票的情况:** |
| 122 | + |
| 123 | +这个就是处于业务方面的考虑了,比如 A->B->C->D,对于一个车次中的座位来说,如果 B->C 的乘客非常多,那么是不是就会导致 A->D 买不到票了? |
| 124 | + |
| 125 | +其实不会的,我们可以在业务层面去避免这个问题,比如给每个车站区间都留有一些余票,那么就不会因为某一个区间非常火爆,而导致其他乘客买不到长途的票了 |
| 126 | + |
| 127 | +至于具体留多少余票,这个就不是我们考虑的事情了,营业部根据具体的实际情况以及盈利情况来定一下各个区间预留多少票 |
| 128 | + |
| 129 | + |
| 130 | + |
| 131 | + |
| 132 | + |
| 133 | +## 坐过高铁吧,有抢过票吗?你说说抢票会有哪些情况? |
| 134 | + |
| 135 | +抢票会存在线程安全的问题,因为高铁票是作为一个共享的数据存在,多个线程去读写共享的数据,就会存现线程安全的问题 |
| 136 | + |
| 137 | +具体的线程不安全问题就是:高铁票的 `少卖` 和 `超卖` |
| 138 | + |
| 139 | +先说一下整个抢票中所涉及的流程:生成订单、扣减库存、用户支付 |
| 140 | + |
| 141 | +那么为了保证高并发,扣减库存的操作可以放在本地去做,生成订单的操作通过异步,可以大幅提高系统并发度 |
| 142 | + |
| 143 | +**接下来先说一下如何 `优化抢票性能` :** |
| 144 | + |
| 145 | +将库存放在每台机器的本地,比如总共有 1w 个余票库存,共有 100 台机器,那么就在每台机器上方 100 个库存 |
| 146 | + |
| 147 | +当用户抢票之后,就会在本地先扣减库存,如果本地库存不足,此时可以给用户返回一个友好提示,让用户稍后再重试抢票,再将用户抢票的请求路由到其他有库存的机器上去 |
| 148 | + |
| 149 | +如果本地库存足够的话,就先扣除本地库存,之后再发送一个 MQ 消息异步的生成高铁票的订单,等待用户支付,如果用户十分钟内不支付的话,订单就失效,返还库存 |
| 150 | + |
| 151 | + |
| 152 | + |
| 153 | +**接下来分析一下上边的流程是否会出现少卖和超卖的问题:** |
| 154 | + |
| 155 | +对于超卖来说,每次用户请求时,先扣除库存,再去生成订单,这样当库存不足时,就不会再生成订单了,因此肯定不会出现超卖的问题 |
| 156 | + |
| 157 | +对于少卖来说,总共有 100 台机器,每台机器有 100 个库存,如果其中的几台机器宕机了,那么宕机的机器上的库存就没办法继续售卖,就会出现少卖的问题 |
| 158 | + |
| 159 | + |
| 160 | + |
| 161 | +**解决少卖问题:** |
| 162 | + |
| 163 | +可以在每台机器上放一些冗余的库存,如果其他机器发生了宕机,就将宕机的机器上的库存给放到健康的机器上去,就可以避免机器宕机而导致一部分库存卖不出去的问题了 |
| 164 | + |
| 165 | +那么这样的话,就需要使用 Redis 来统一管理每台机器上的库存,也就是在分布式缓存 Redis 中存储一份缓存,在每台机器的本地也存储一份缓存,当扣减完机器本地的库存之后,再去发送一个远程请求扣减 Redis 上的库存 |
| 166 | + |
| 167 | + |
| 168 | + |
| 169 | +**最后完整的抢票流程:** |
| 170 | + |
| 171 | + |
| 172 | + |
| 173 | +1. 用户发出抢票请求,在本地进行扣减库存操作 |
| 174 | +2. 如果本地库存不足,返回用户友好提示,可以稍后重试,如果所有机器上的库存都不足的话,可以直接返回用户已售罄的提示 |
| 175 | +3. 如果本地库存充足,在本地扣减库存之后,再向 Redis 中发送网络请求,进行库存扣减(这里 Redis 的作用就是统一管理所有机器上的库存数量) |
| 176 | +4. 扣减库存之后,再发送 MQ 消息,异步的生成订单,之后等待用户支付即可 |
| 177 | + |
| 178 | + |
| 179 | + |
| 180 | +> 如有不足,欢迎指出 |
| 181 | +
|
| 182 | + |
| 183 | + |
| 184 | + |
| 185 | + |
| 186 | +## 现在我们来给 12306 抢票系统设计一个缓存,kv 存什么? |
| 187 | + |
| 188 | +在回答的时候,要先给面试官分析一下业务场景,再说怎么去设计缓存 |
| 189 | + |
| 190 | +在 12306 中如果要设计缓存的话,可以考虑给余票设计一个缓存,因为余票信息是读取比较多的数据,并且在首页,放在缓存中可以大大加快用户查询的速度,如下图 |
| 191 | + |
| 192 | + |
| 193 | + |
| 194 | + |
| 195 | + |
| 196 | + |
| 197 | + |
| 198 | +- 余票信息缓存 |
| 199 | + |
| 200 | +余票信息缓存的话,将车站到车站之间的信息以及余票信息给存储到缓存中,比如当用户查询 A 车站到 B 车站的车票信息时,直接从缓存中获取,如果缓存中没有的话,去数据库中查询,并且在 Redis 缓存中构建一份缓存数据 |
| 201 | + |
| 202 | +key 设计为站点的信息,比如查询 2023 年 12 月 15 日 A 车站到 B 车站的车票信息:`remaining_ticket_info:{year}:{month}:{day}:{起使车站}:{终止车站}` |
| 203 | + |
| 204 | +value 为起使车站到终止车站的信息,比如车次号、余票信息、票价信息、经过车站等一些信息 |
| 205 | + |
| 206 | +这里我觉得**余票数量可以和其他缓存给分开存储**,因为像余票信息的话,用户购买后是需要修改的,如果将余票数量和其他缓存数据放在一起的话,每次修改的时候,都要重新构建很多数据,比较麻烦 |
| 207 | + |
| 208 | + |
| 209 | + |
| 210 | +- 余票数量缓存 |
| 211 | + |
| 212 | +余票数量缓存的 key 设计为:`remaining_ticket_num:{year}:{month}:{day}:{起使车站}:{终止车站}` |
| 213 | + |
| 214 | +value :存储余票的数量 |
| 215 | + |
| 216 | + |
| 217 | + |
| 218 | + |
| 219 | + |
0 commit comments